Refactor settings panel to compact floating toolbar
Replace the bulky side panel (290-340px wide) with a compact floating toolbar at the bottom center of the viewer, maximizing the model viewing area. New toolbar components: - FloatingToolbar.vue: Main container with all controls - ToolbarButton.vue: Reusable icon button with active state - ToolbarPopup.vue: Reusable popup with click-outside close - RenderModeSelector.vue: 3-segment render mode control - ExplodePopup.vue: Explosion settings popup - SectionPopup.vue: Cross-section settings popup - RenderPopup.vue: Material and lighting settings popup Toolbar features: - Direct controls: Render mode, edge lines toggle, auto-color toggle - Popup controls: Explode (click for animation), section, render settings - Screenshot button with preview modal Removed old components: - FeaturePanel.vue, ExplodedView.vue, CrossSection.vue - RenderSettings.vue, ThumbnailCapture.vue 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@ import { computed } from 'vue'
|
||||
import { useModelsStore } from '@/stores/models'
|
||||
import { useViewerStore } from '@/stores/viewer'
|
||||
import ModelViewer from '@/components/viewer/ModelViewer.vue'
|
||||
import FeaturePanel from '@/components/viewer/FeaturePanel.vue'
|
||||
import FloatingToolbar from '@/components/viewer/toolbar/FloatingToolbar.vue'
|
||||
|
||||
const modelsStore = useModelsStore()
|
||||
const viewerStore = useViewerStore()
|
||||
@@ -20,7 +20,7 @@ const isModelReady = computed(() =>
|
||||
<template v-if="hasSelectedModel">
|
||||
<template v-if="isModelReady">
|
||||
<ModelViewer :model-id="modelsStore.selectedModelId!" />
|
||||
<FeaturePanel />
|
||||
<FloatingToolbar />
|
||||
</template>
|
||||
<div v-else class="empty-state">
|
||||
<div v-if="viewerStore.isLoading" class="loading">
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onUnmounted } from 'vue'
|
||||
import { useViewerStore } from '@/stores/viewer'
|
||||
import { getExplodeService, resetExplodeService } from '@/services/explodeService'
|
||||
import { getRenderService } from '@/services/renderService'
|
||||
import { getClippingService } from '@/services/clippingService'
|
||||
import { getPartsTreeService } from '@/services/partsTreeService'
|
||||
|
||||
const viewerStore = useViewerStore()
|
||||
const isInitialized = ref(false)
|
||||
const partsCount = ref(0)
|
||||
const isAnimating = ref(false)
|
||||
const isDragging = ref(false)
|
||||
|
||||
// Initialize when model changes (not scene - scene reference stays the same)
|
||||
watch(
|
||||
() => viewerStore.model,
|
||||
(model) => {
|
||||
if (model && viewerStore.scene) {
|
||||
const service = getExplodeService()
|
||||
service.initializeFromScene(viewerStore.scene, () => viewerStore.forceRender())
|
||||
isInitialized.value = service.isInitialized()
|
||||
partsCount.value = service.getPartsCount()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Apply explosion when factor changes (immediate for slider dragging)
|
||||
watch(
|
||||
() => viewerStore.explosionFactor,
|
||||
(factor) => {
|
||||
if (isInitialized.value && !isAnimating.value) {
|
||||
const service = getExplodeService()
|
||||
// Pass isDragging to skip expensive operations during drag
|
||||
service.applyExplosion(factor, isDragging.value)
|
||||
viewerStore.forceRender()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
resetExplodeService()
|
||||
})
|
||||
|
||||
function handleSliderInput(event: Event) {
|
||||
// Mark as dragging to skip expensive operations
|
||||
isDragging.value = true
|
||||
const target = event.target as HTMLInputElement
|
||||
viewerStore.setExplosionFactor(Number(target.value))
|
||||
}
|
||||
|
||||
function handleSliderChange() {
|
||||
// Drag ended - execute expensive sync operations now
|
||||
isDragging.value = false
|
||||
finalizeExplosion()
|
||||
}
|
||||
|
||||
function finalizeExplosion() {
|
||||
// Execute expensive operations that were skipped during dragging
|
||||
getRenderService().syncEdgeTransforms()
|
||||
getClippingService().updateBounds()
|
||||
getPartsTreeService().syncSelectionOverlays()
|
||||
viewerStore.forceRender()
|
||||
}
|
||||
|
||||
function handlePlayAnimation() {
|
||||
if (isAnimating.value || !isInitialized.value || partsCount.value < 2) return
|
||||
|
||||
const service = getExplodeService()
|
||||
const currentFactor = viewerStore.explosionFactor
|
||||
// Toggle: if <= 50% go to 100%, if > 50% go to 0%
|
||||
const targetFactor = currentFactor <= 50 ? 100 : 0
|
||||
|
||||
isAnimating.value = true
|
||||
service.animateExplosion(targetFactor, 800, () => {
|
||||
viewerStore.setExplosionFactor(targetFactor)
|
||||
isAnimating.value = false
|
||||
})
|
||||
}
|
||||
|
||||
// Computed: button label based on current factor
|
||||
function getButtonLabel(): string {
|
||||
return viewerStore.explosionFactor <= 50 ? '爆炸' : '收回'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="feature-section">
|
||||
<div class="feature-header">
|
||||
<h4>爆炸图</h4>
|
||||
<div class="header-controls">
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
:class="viewerStore.explosionFactor > 50 ? 'btn-primary' : 'btn-secondary'"
|
||||
:disabled="!isInitialized || partsCount < 2 || isAnimating"
|
||||
@click="handlePlayAnimation"
|
||||
>
|
||||
{{ getButtonLabel() }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isInitialized && partsCount >= 2" class="slider-container">
|
||||
<input
|
||||
type="range"
|
||||
class="slider"
|
||||
min="0"
|
||||
max="100"
|
||||
:value="viewerStore.explosionFactor"
|
||||
:disabled="isAnimating"
|
||||
@input="handleSliderInput"
|
||||
@change="handleSliderChange"
|
||||
/>
|
||||
<span class="slider-value">{{ viewerStore.explosionFactor }}%</span>
|
||||
</div>
|
||||
|
||||
<div v-if="partsCount > 0" class="feature-info">
|
||||
<small>检测到 {{ partsCount }} 个零件</small>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.feature-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.feature-header h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.feature-info {
|
||||
margin-top: 8px;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.feature-info small {
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,18 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useViewerStore } from '@/stores/viewer'
|
||||
import ExplodedView from './ExplodedView.vue'
|
||||
import CrossSection from './CrossSection.vue'
|
||||
import RenderSettings from './RenderSettings.vue'
|
||||
import ThumbnailCapture from './ThumbnailCapture.vue'
|
||||
|
||||
const viewerStore = useViewerStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="viewerStore.model" class="feature-panel">
|
||||
<RenderSettings />
|
||||
<ExplodedView />
|
||||
<CrossSection />
|
||||
<ThumbnailCapture />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,481 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watch, onUnmounted } from 'vue'
|
||||
import { useViewerStore } from '@/stores/viewer'
|
||||
import { getPartsTreeService, MaterialType } from '@/services/partsTreeService'
|
||||
import { getRenderService, resetRenderService, RenderMode } from '@/services/renderService'
|
||||
|
||||
const viewerStore = useViewerStore()
|
||||
|
||||
const renderMode = computed({
|
||||
get: () => viewerStore.renderSettings.renderMode,
|
||||
set: (value) => viewerStore.setRenderMode(value),
|
||||
})
|
||||
|
||||
// Check if in special render mode (where certain controls should be disabled)
|
||||
const isSpecialRenderMode = computed(() =>
|
||||
renderMode.value === 'hiddenLine' || renderMode.value === 'wireframe'
|
||||
)
|
||||
|
||||
const edgesEnabled = computed({
|
||||
get: () => viewerStore.renderSettings.edgesEnabled,
|
||||
set: (value) => viewerStore.setEdgesEnabled(value),
|
||||
})
|
||||
|
||||
const edgeLineWidth = computed({
|
||||
get: () => viewerStore.renderSettings.edgeLineWidth,
|
||||
set: (value) => viewerStore.setEdgeLineWidth(value),
|
||||
})
|
||||
|
||||
const autoColorEnabled = computed({
|
||||
get: () => viewerStore.renderSettings.autoColorEnabled,
|
||||
set: (value) => viewerStore.setAutoColorEnabled(value),
|
||||
})
|
||||
|
||||
const materialType = computed({
|
||||
get: () => viewerStore.renderSettings.materialType,
|
||||
set: (value) => viewerStore.setMaterialType(value),
|
||||
})
|
||||
|
||||
// Lighting settings
|
||||
const exposure = computed({
|
||||
get: () => viewerStore.renderSettings.exposure,
|
||||
set: (value) => viewerStore.setExposure(value),
|
||||
})
|
||||
|
||||
const mainLightIntensity = computed({
|
||||
get: () => viewerStore.renderSettings.mainLightIntensity,
|
||||
set: (value) => viewerStore.setMainLightIntensity(value),
|
||||
})
|
||||
|
||||
const ambientLightIntensity = computed({
|
||||
get: () => viewerStore.renderSettings.ambientLightIntensity,
|
||||
set: (value) => viewerStore.setAmbientLightIntensity(value),
|
||||
})
|
||||
|
||||
// Initialize render service when scene is available
|
||||
watch(
|
||||
() => viewerStore.scene,
|
||||
(scene) => {
|
||||
if (scene) {
|
||||
const renderService = getRenderService()
|
||||
renderService.initialize(scene, viewerStore.renderer)
|
||||
|
||||
// Apply edge line setting immediately after initialization
|
||||
if (viewerStore.renderSettings.edgesEnabled) {
|
||||
renderService.setEdgesEnabled(true)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Watch edge line toggle
|
||||
watch(
|
||||
() => viewerStore.renderSettings.edgesEnabled,
|
||||
(enabled) => {
|
||||
const renderService = getRenderService()
|
||||
if (renderService.isInitialized()) {
|
||||
renderService.setEdgesEnabled(enabled)
|
||||
viewerStore.forceRender()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Watch edge line width changes
|
||||
watch(
|
||||
() => viewerStore.renderSettings.edgeLineWidth,
|
||||
(width) => {
|
||||
const renderService = getRenderService()
|
||||
if (renderService.isInitialized()) {
|
||||
renderService.setEdgeLineWidth(width)
|
||||
viewerStore.forceRender()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Watch auto-color changes and apply/reset colors
|
||||
watch(
|
||||
() => viewerStore.renderSettings.autoColorEnabled,
|
||||
(enabled) => {
|
||||
const service = getPartsTreeService()
|
||||
if (enabled) {
|
||||
service.applyAutoColors()
|
||||
} else {
|
||||
service.resetToOriginalColors()
|
||||
}
|
||||
viewerStore.forceRender()
|
||||
}
|
||||
)
|
||||
|
||||
// Watch material type changes
|
||||
watch(
|
||||
() => viewerStore.renderSettings.materialType,
|
||||
(type) => {
|
||||
const service = getPartsTreeService()
|
||||
const materialTypeMap: Record<string, MaterialType> = {
|
||||
'clay': MaterialType.Clay,
|
||||
'metal': MaterialType.Metal,
|
||||
'paint': MaterialType.Paint,
|
||||
}
|
||||
service.setGlobalMaterial(materialTypeMap[type])
|
||||
viewerStore.forceRender()
|
||||
}
|
||||
)
|
||||
|
||||
// Watch render mode changes
|
||||
watch(
|
||||
() => viewerStore.renderSettings.renderMode,
|
||||
(mode) => {
|
||||
const renderService = getRenderService()
|
||||
if (renderService.isInitialized()) {
|
||||
const modeMap: Record<string, RenderMode> = {
|
||||
'standard': RenderMode.Standard,
|
||||
'hiddenLine': RenderMode.HiddenLine,
|
||||
'wireframe': RenderMode.Wireframe,
|
||||
}
|
||||
renderService.setRenderMode(modeMap[mode])
|
||||
viewerStore.forceRender()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Watch exposure (scene brightness) changes
|
||||
watch(
|
||||
() => viewerStore.renderSettings.exposure,
|
||||
(value) => {
|
||||
const renderService = getRenderService()
|
||||
if (renderService.isInitialized()) {
|
||||
renderService.setToneMappingExposure(value)
|
||||
viewerStore.forceRender()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Watch main light intensity changes
|
||||
watch(
|
||||
() => viewerStore.renderSettings.mainLightIntensity,
|
||||
(value) => {
|
||||
const renderService = getRenderService()
|
||||
if (renderService.isInitialized()) {
|
||||
renderService.setMainLightIntensity(value)
|
||||
viewerStore.forceRender()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Watch ambient light intensity changes
|
||||
watch(
|
||||
() => viewerStore.renderSettings.ambientLightIntensity,
|
||||
(value) => {
|
||||
const renderService = getRenderService()
|
||||
if (renderService.isInitialized()) {
|
||||
renderService.setAmbientLightIntensity(value)
|
||||
viewerStore.forceRender()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
resetRenderService()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="feature-section">
|
||||
<h4>渲染设置</h4>
|
||||
|
||||
<div class="setting-row" :class="{ disabled: isSpecialRenderMode }">
|
||||
<label class="toggle-label" :class="{ disabled: isSpecialRenderMode }">
|
||||
<input
|
||||
v-model="edgesEnabled"
|
||||
type="checkbox"
|
||||
class="toggle-checkbox"
|
||||
:disabled="isSpecialRenderMode"
|
||||
/>
|
||||
<span class="toggle-switch"></span>
|
||||
<span class="toggle-text">
|
||||
边缘线
|
||||
<span v-if="isSpecialRenderMode" class="hint-inline">(自动启用)</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="edgesEnabled || isSpecialRenderMode" class="setting-row slider-row">
|
||||
<span class="setting-label">线宽</span>
|
||||
<div class="slider-container">
|
||||
<input
|
||||
v-model.number="edgeLineWidth"
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="5"
|
||||
step="0.5"
|
||||
class="slider"
|
||||
/>
|
||||
<span class="slider-value">{{ edgeLineWidth }}px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label class="toggle-label">
|
||||
<input
|
||||
v-model="autoColorEnabled"
|
||||
type="checkbox"
|
||||
class="toggle-checkbox"
|
||||
/>
|
||||
<span class="toggle-switch"></span>
|
||||
<span class="toggle-text">
|
||||
自动着色
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row material-row">
|
||||
<span class="setting-label">渲染模式</span>
|
||||
<div class="material-buttons">
|
||||
<button
|
||||
:class="['material-btn', { active: renderMode === 'standard' }]"
|
||||
@click="renderMode = 'standard'"
|
||||
title="标准实体渲染"
|
||||
>
|
||||
实体
|
||||
</button>
|
||||
<button
|
||||
:class="['material-btn', { active: renderMode === 'hiddenLine' }]"
|
||||
@click="renderMode = 'hiddenLine'"
|
||||
title="消隐线模式"
|
||||
>
|
||||
消隐
|
||||
</button>
|
||||
<button
|
||||
:class="['material-btn', { active: renderMode === 'wireframe' }]"
|
||||
@click="renderMode = 'wireframe'"
|
||||
title="线框模式"
|
||||
>
|
||||
线框
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row material-row" :class="{ disabled: isSpecialRenderMode }">
|
||||
<span class="setting-label">材质类型</span>
|
||||
<div class="material-buttons">
|
||||
<button
|
||||
:class="['material-btn', { active: materialType === 'clay' }]"
|
||||
:disabled="isSpecialRenderMode"
|
||||
@click="materialType = 'clay'"
|
||||
title="白模材质 - 结构展示"
|
||||
>
|
||||
白模
|
||||
</button>
|
||||
<button
|
||||
:class="['material-btn', { active: materialType === 'metal' }]"
|
||||
:disabled="isSpecialRenderMode"
|
||||
@click="materialType = 'metal'"
|
||||
title="金属材质"
|
||||
>
|
||||
金属
|
||||
</button>
|
||||
<button
|
||||
:class="['material-btn', { active: materialType === 'paint' }]"
|
||||
:disabled="isSpecialRenderMode"
|
||||
@click="materialType = 'paint'"
|
||||
title="工业哑光烤漆"
|
||||
>
|
||||
烤漆
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="autoColorEnabled" class="hint">
|
||||
明亮色系已应用到各零件
|
||||
</p>
|
||||
|
||||
<div class="setting-row section-divider">
|
||||
<span class="setting-label section-title">光照设置</span>
|
||||
</div>
|
||||
|
||||
<div class="setting-row slider-row">
|
||||
<span class="setting-label">整体亮度</span>
|
||||
<div class="slider-container">
|
||||
<input
|
||||
v-model.number="exposure"
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="3"
|
||||
step="0.1"
|
||||
class="slider"
|
||||
/>
|
||||
<span class="slider-value">{{ exposure.toFixed(1) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row slider-row">
|
||||
<span class="setting-label">主光亮度</span>
|
||||
<div class="slider-container">
|
||||
<input
|
||||
v-model.number="mainLightIntensity"
|
||||
type="range"
|
||||
min="0"
|
||||
max="2"
|
||||
step="0.1"
|
||||
class="slider"
|
||||
/>
|
||||
<span class="slider-value">{{ mainLightIntensity.toFixed(1) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row slider-row">
|
||||
<span class="setting-label">环境亮度</span>
|
||||
<div class="slider-container">
|
||||
<input
|
||||
v-model.number="ambientLightIntensity"
|
||||
type="range"
|
||||
min="0"
|
||||
max="2"
|
||||
step="0.1"
|
||||
class="slider"
|
||||
/>
|
||||
<span class="slider-value">{{ ambientLightIntensity.toFixed(1) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.setting-row {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.hint-inline {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.toggle-label.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.setting-row.disabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Material type selector */
|
||||
.material-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.material-buttons {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.material-btn {
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.material-btn:hover {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.material-btn.active {
|
||||
background: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.material-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.material-btn:disabled:hover {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.material-row.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Slider row */
|
||||
.slider-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.slider-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.slider {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
appearance: none;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.slider-value {
|
||||
min-width: 40px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
@@ -1,288 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useViewerStore } from '@/stores/viewer'
|
||||
import { captureFullScreenshot } from '@/services/screenshotService'
|
||||
|
||||
const viewerStore = useViewerStore()
|
||||
|
||||
const isCapturing = ref(false)
|
||||
const showPreview = ref(false)
|
||||
const previewUrl = ref<string | null>(null)
|
||||
const screenshotBlob = ref<Blob | null>(null)
|
||||
|
||||
async function captureScreenshot() {
|
||||
if (!viewerStore.renderer || !viewerStore.scene || !viewerStore.camera) {
|
||||
return
|
||||
}
|
||||
|
||||
isCapturing.value = true
|
||||
|
||||
try {
|
||||
const blob = await captureFullScreenshot(
|
||||
viewerStore.renderer,
|
||||
viewerStore.scene,
|
||||
viewerStore.camera
|
||||
)
|
||||
|
||||
screenshotBlob.value = blob
|
||||
previewUrl.value = URL.createObjectURL(blob)
|
||||
showPreview.value = true
|
||||
} catch (error) {
|
||||
console.error('Failed to capture screenshot:', error)
|
||||
} finally {
|
||||
isCapturing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function downloadScreenshot() {
|
||||
if (!screenshotBlob.value) return
|
||||
|
||||
const now = new Date()
|
||||
const timestamp = now.toISOString().replace(/[-:T]/g, '').slice(0, 14)
|
||||
const filename = `screenshot_${timestamp}.png`
|
||||
|
||||
const url = URL.createObjectURL(screenshotBlob.value)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
closePreview()
|
||||
}
|
||||
|
||||
function closePreview() {
|
||||
if (previewUrl.value) {
|
||||
URL.revokeObjectURL(previewUrl.value)
|
||||
}
|
||||
showPreview.value = false
|
||||
previewUrl.value = null
|
||||
screenshotBlob.value = null
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && showPreview.value) {
|
||||
closePreview()
|
||||
}
|
||||
}
|
||||
|
||||
function handleOverlayClick(e: MouseEvent) {
|
||||
if ((e.target as HTMLElement).classList.contains('preview-overlay')) {
|
||||
closePreview()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
if (previewUrl.value) {
|
||||
URL.revokeObjectURL(previewUrl.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="feature-section screenshot-capture">
|
||||
<h4>截图</h4>
|
||||
<div class="capture-row">
|
||||
<button
|
||||
class="capture-btn"
|
||||
:disabled="isCapturing || !viewerStore.model"
|
||||
@click="captureScreenshot"
|
||||
title="截取当前视图"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="icon">
|
||||
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
|
||||
<circle cx="12" cy="13" r="4"/>
|
||||
</svg>
|
||||
<span>{{ isCapturing ? '截图中...' : '截图' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showPreview"
|
||||
class="preview-overlay"
|
||||
@click="handleOverlayClick"
|
||||
>
|
||||
<div class="preview-modal">
|
||||
<div class="preview-image-container">
|
||||
<img
|
||||
v-if="previewUrl"
|
||||
:src="previewUrl"
|
||||
alt="Screenshot preview"
|
||||
class="preview-image"
|
||||
/>
|
||||
</div>
|
||||
<div class="preview-actions">
|
||||
<button class="btn-download" @click="downloadScreenshot">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="icon">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
下载
|
||||
</button>
|
||||
<button class="btn-cancel" @click="closePreview">
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.screenshot-capture {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.capture-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.capture-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.capture-btn:hover:not(:disabled) {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.capture-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.capture-btn .icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* Preview overlay */
|
||||
.preview-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
backdrop-filter: blur(4px);
|
||||
animation: fade-in 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.preview-modal {
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-xl);
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: scale-in 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes scale-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.preview-image-container {
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
object-fit: contain;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
justify-content: center;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-download,
|
||||
.btn-cancel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-download {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-download:hover {
|
||||
background: var(--primary-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-download .icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background: var(--bg-sunken);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
176
frontend/src/components/viewer/toolbar/ExplodePopup.vue
Normal file
176
frontend/src/components/viewer/toolbar/ExplodePopup.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onUnmounted, computed } from 'vue'
|
||||
import { useViewerStore } from '@/stores/viewer'
|
||||
import { getExplodeService, resetExplodeService } from '@/services/explodeService'
|
||||
import { getRenderService } from '@/services/renderService'
|
||||
import { getClippingService } from '@/services/clippingService'
|
||||
import { getPartsTreeService } from '@/services/partsTreeService'
|
||||
import ToolbarPopup from './ToolbarPopup.vue'
|
||||
|
||||
defineProps<{
|
||||
show: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const viewerStore = useViewerStore()
|
||||
const isInitialized = ref(false)
|
||||
const partsCount = ref(0)
|
||||
const isAnimating = ref(false)
|
||||
const isDragging = ref(false)
|
||||
|
||||
// Initialize when model changes
|
||||
watch(
|
||||
() => viewerStore.model,
|
||||
(model) => {
|
||||
if (model && viewerStore.scene) {
|
||||
const service = getExplodeService()
|
||||
service.initializeFromScene(viewerStore.scene, () => viewerStore.forceRender())
|
||||
isInitialized.value = service.isInitialized()
|
||||
partsCount.value = service.getPartsCount()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Apply explosion when factor changes
|
||||
watch(
|
||||
() => viewerStore.explosionFactor,
|
||||
(factor) => {
|
||||
if (isInitialized.value && !isAnimating.value) {
|
||||
const service = getExplodeService()
|
||||
service.applyExplosion(factor, isDragging.value)
|
||||
viewerStore.forceRender()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
resetExplodeService()
|
||||
})
|
||||
|
||||
function handleSliderInput(event: Event) {
|
||||
isDragging.value = true
|
||||
const target = event.target as HTMLInputElement
|
||||
viewerStore.setExplosionFactor(Number(target.value))
|
||||
}
|
||||
|
||||
function handleSliderChange() {
|
||||
isDragging.value = false
|
||||
finalizeExplosion()
|
||||
}
|
||||
|
||||
function finalizeExplosion() {
|
||||
getRenderService().syncEdgeTransforms()
|
||||
getClippingService().updateBounds()
|
||||
getPartsTreeService().syncSelectionOverlays()
|
||||
viewerStore.forceRender()
|
||||
}
|
||||
|
||||
const canExplode = computed(() => isInitialized.value && partsCount.value >= 2)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToolbarPopup :show="show" title="爆炸图" @close="emit('close')">
|
||||
<div v-if="canExplode" class="explode-content">
|
||||
<div class="slider-row">
|
||||
<span class="label">爆炸系数</span>
|
||||
<div class="slider-container">
|
||||
<input
|
||||
type="range"
|
||||
class="slider"
|
||||
min="0"
|
||||
max="100"
|
||||
:value="viewerStore.explosionFactor"
|
||||
@input="handleSliderInput"
|
||||
@change="handleSliderChange"
|
||||
/>
|
||||
<span class="slider-value">{{ viewerStore.explosionFactor }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-text">检测到 {{ partsCount }} 个零件</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
<span>需要至少2个零件才能使用爆炸图</span>
|
||||
</div>
|
||||
</ToolbarPopup>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.explode-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.slider-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.slider-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.slider {
|
||||
flex: 1;
|
||||
-webkit-appearance: none;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.slider-value {
|
||||
min-width: 40px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
padding-top: var(--space-2);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.info-text {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
padding: var(--space-2);
|
||||
}
|
||||
</style>
|
||||
456
frontend/src/components/viewer/toolbar/FloatingToolbar.vue
Normal file
456
frontend/src/components/viewer/toolbar/FloatingToolbar.vue
Normal file
@@ -0,0 +1,456 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useViewerStore } from '@/stores/viewer'
|
||||
import { getRenderService } from '@/services/renderService'
|
||||
import { getPartsTreeService } from '@/services/partsTreeService'
|
||||
import { getExplodeService } from '@/services/explodeService'
|
||||
import { captureFullScreenshot } from '@/services/screenshotService'
|
||||
|
||||
import ToolbarButton from './ToolbarButton.vue'
|
||||
import RenderModeSelector from './RenderModeSelector.vue'
|
||||
import ExplodePopup from './ExplodePopup.vue'
|
||||
import SectionPopup from './SectionPopup.vue'
|
||||
import RenderPopup from './RenderPopup.vue'
|
||||
|
||||
const viewerStore = useViewerStore()
|
||||
|
||||
// Active popup state
|
||||
const activePopup = ref<'explode' | 'section' | 'render' | null>(null)
|
||||
|
||||
// Screenshot state
|
||||
const isCapturing = ref(false)
|
||||
const showPreview = ref(false)
|
||||
const previewUrl = ref<string | null>(null)
|
||||
const screenshotBlob = ref<Blob | null>(null)
|
||||
|
||||
// Explode animation state
|
||||
const isExplodeAnimating = ref(false)
|
||||
|
||||
// Computed states
|
||||
const edgesEnabled = computed(() => viewerStore.renderSettings.edgesEnabled)
|
||||
const autoColorEnabled = computed(() => viewerStore.renderSettings.autoColorEnabled)
|
||||
const hasActiveCrossSection = computed(() =>
|
||||
viewerStore.crossSection.x.enabled ||
|
||||
viewerStore.crossSection.y.enabled ||
|
||||
viewerStore.crossSection.z.enabled
|
||||
)
|
||||
const isExploded = computed(() => viewerStore.explosionFactor > 0)
|
||||
|
||||
// Watch edge line toggle
|
||||
watch(
|
||||
() => viewerStore.renderSettings.edgesEnabled,
|
||||
(enabled) => {
|
||||
const renderService = getRenderService()
|
||||
if (renderService.isInitialized()) {
|
||||
renderService.setEdgesEnabled(enabled)
|
||||
viewerStore.forceRender()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Watch auto-color changes
|
||||
watch(
|
||||
() => viewerStore.renderSettings.autoColorEnabled,
|
||||
(enabled) => {
|
||||
const service = getPartsTreeService()
|
||||
if (enabled) {
|
||||
service.applyAutoColors()
|
||||
} else {
|
||||
service.resetToOriginalColors()
|
||||
}
|
||||
viewerStore.forceRender()
|
||||
}
|
||||
)
|
||||
|
||||
function togglePopup(popup: 'explode' | 'section' | 'render') {
|
||||
activePopup.value = activePopup.value === popup ? null : popup
|
||||
}
|
||||
|
||||
function closePopup() {
|
||||
activePopup.value = null
|
||||
}
|
||||
|
||||
function toggleEdges() {
|
||||
viewerStore.setEdgesEnabled(!edgesEnabled.value)
|
||||
}
|
||||
|
||||
function toggleAutoColor() {
|
||||
viewerStore.setAutoColorEnabled(!autoColorEnabled.value)
|
||||
}
|
||||
|
||||
function handleExplodeClick() {
|
||||
// Single click triggers animation
|
||||
const service = getExplodeService()
|
||||
if (!service.isInitialized() || service.getPartsCount() < 2) {
|
||||
togglePopup('explode')
|
||||
return
|
||||
}
|
||||
|
||||
const currentFactor = viewerStore.explosionFactor
|
||||
const targetFactor = currentFactor <= 50 ? 100 : 0
|
||||
|
||||
isExplodeAnimating.value = true
|
||||
service.animateExplosion(targetFactor, 800, () => {
|
||||
viewerStore.setExplosionFactor(targetFactor)
|
||||
isExplodeAnimating.value = false
|
||||
})
|
||||
}
|
||||
|
||||
async function captureScreenshot() {
|
||||
if (!viewerStore.renderer || !viewerStore.scene || !viewerStore.camera) {
|
||||
return
|
||||
}
|
||||
|
||||
isCapturing.value = true
|
||||
|
||||
try {
|
||||
const blob = await captureFullScreenshot(
|
||||
viewerStore.renderer,
|
||||
viewerStore.scene,
|
||||
viewerStore.camera
|
||||
)
|
||||
|
||||
screenshotBlob.value = blob
|
||||
previewUrl.value = URL.createObjectURL(blob)
|
||||
showPreview.value = true
|
||||
} catch (error) {
|
||||
console.error('Failed to capture screenshot:', error)
|
||||
} finally {
|
||||
isCapturing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function downloadScreenshot() {
|
||||
if (!screenshotBlob.value) return
|
||||
|
||||
const now = new Date()
|
||||
const timestamp = now.toISOString().replace(/[-:T]/g, '').slice(0, 14)
|
||||
const filename = `screenshot_${timestamp}.png`
|
||||
|
||||
const url = URL.createObjectURL(screenshotBlob.value)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
closePreview()
|
||||
}
|
||||
|
||||
function closePreview() {
|
||||
if (previewUrl.value) {
|
||||
URL.revokeObjectURL(previewUrl.value)
|
||||
}
|
||||
showPreview.value = false
|
||||
previewUrl.value = null
|
||||
screenshotBlob.value = null
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
if (showPreview.value) {
|
||||
closePreview()
|
||||
} else if (activePopup.value) {
|
||||
closePopup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleOverlayClick(e: MouseEvent) {
|
||||
if ((e.target as HTMLElement).classList.contains('preview-overlay')) {
|
||||
closePreview()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
if (previewUrl.value) {
|
||||
URL.revokeObjectURL(previewUrl.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="viewerStore.model" class="floating-toolbar">
|
||||
<!-- Render Mode Selector -->
|
||||
<RenderModeSelector />
|
||||
|
||||
<div class="toolbar-divider"></div>
|
||||
|
||||
<!-- Edge Lines Toggle -->
|
||||
<ToolbarButton
|
||||
:active="edgesEnabled"
|
||||
title="边缘线"
|
||||
@click="toggleEdges"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
|
||||
<line x1="12" y1="22.08" x2="12" y2="12"/>
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
|
||||
<!-- Auto Color Toggle -->
|
||||
<ToolbarButton
|
||||
:active="autoColorEnabled"
|
||||
title="自动着色"
|
||||
@click="toggleAutoColor"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="14" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="14" y="14" width="7" height="7" rx="1"/>
|
||||
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
|
||||
<div class="toolbar-divider"></div>
|
||||
|
||||
<!-- Explode -->
|
||||
<div class="toolbar-item">
|
||||
<ToolbarButton
|
||||
:active="isExploded"
|
||||
:disabled="isExplodeAnimating"
|
||||
title="爆炸图 (单击动画,右键设置)"
|
||||
@click="handleExplodeClick"
|
||||
@contextmenu.prevent="togglePopup('explode')"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2L8 6H4v4l-4 4 4 4v4h4l4 4 4-4h4v-4l4-4-4-4V6h-4L12 2z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
<ExplodePopup
|
||||
:show="activePopup === 'explode'"
|
||||
@close="closePopup"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Cross Section -->
|
||||
<div class="toolbar-item">
|
||||
<ToolbarButton
|
||||
:active="hasActiveCrossSection"
|
||||
title="剖面"
|
||||
@click="togglePopup('section')"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="2" x2="12" y2="22"/>
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
<SectionPopup
|
||||
:show="activePopup === 'section'"
|
||||
@close="closePopup"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-divider"></div>
|
||||
|
||||
<!-- Render Settings -->
|
||||
<div class="toolbar-item">
|
||||
<ToolbarButton
|
||||
title="渲染设置"
|
||||
@click="togglePopup('render')"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
<RenderPopup
|
||||
:show="activePopup === 'render'"
|
||||
@close="closePopup"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Screenshot -->
|
||||
<ToolbarButton
|
||||
:disabled="isCapturing"
|
||||
title="截图"
|
||||
@click="captureScreenshot"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
|
||||
<circle cx="12" cy="13" r="4"/>
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
|
||||
<!-- Screenshot Preview Modal -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showPreview"
|
||||
class="preview-overlay"
|
||||
@click="handleOverlayClick"
|
||||
>
|
||||
<div class="preview-modal">
|
||||
<div class="preview-image-container">
|
||||
<img
|
||||
v-if="previewUrl"
|
||||
:src="previewUrl"
|
||||
alt="Screenshot preview"
|
||||
class="preview-image"
|
||||
/>
|
||||
</div>
|
||||
<div class="preview-actions">
|
||||
<button class="btn-download" @click="downloadScreenshot">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="icon">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
下载
|
||||
</button>
|
||||
<button class="btn-cancel" @click="closePreview">
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.floating-toolbar {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 10px;
|
||||
background: var(--panel-overlay-bg);
|
||||
backdrop-filter: blur(var(--panel-backdrop-blur));
|
||||
-webkit-backdrop-filter: blur(var(--panel-backdrop-blur));
|
||||
border-radius: var(--radius-xl);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.toolbar-divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: var(--border-color);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.toolbar-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Screenshot Preview Styles */
|
||||
.preview-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
backdrop-filter: blur(4px);
|
||||
animation: fade-in 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.preview-modal {
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-xl);
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: scale-in 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes scale-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.preview-image-container {
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
object-fit: contain;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
justify-content: center;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-download,
|
||||
.btn-cancel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-download {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-download:hover {
|
||||
background: var(--primary-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-download .icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background: var(--bg-sunken);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useViewerStore } from '@/stores/viewer'
|
||||
|
||||
const viewerStore = useViewerStore()
|
||||
|
||||
const renderMode = computed({
|
||||
get: () => viewerStore.renderSettings.renderMode,
|
||||
set: (value) => viewerStore.setRenderMode(value),
|
||||
})
|
||||
|
||||
const modes = [
|
||||
{ value: 'standard', label: '实体', title: '标准实体渲染' },
|
||||
{ value: 'hiddenLine', label: '消隐', title: '消隐线模式' },
|
||||
{ value: 'wireframe', label: '线框', title: '线框模式' },
|
||||
] as const
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="render-mode-selector">
|
||||
<button
|
||||
v-for="mode in modes"
|
||||
:key="mode.value"
|
||||
class="mode-btn"
|
||||
:class="{ active: renderMode === mode.value }"
|
||||
:title="mode.title"
|
||||
@click="renderMode = mode.value"
|
||||
>
|
||||
{{ mode.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.render-mode-selector {
|
||||
display: flex;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.mode-btn {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
border-radius: calc(var(--radius-md) - 2px);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast) var(--ease-default);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mode-btn:hover:not(.active) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.mode-btn.active {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
</style>
|
||||
356
frontend/src/components/viewer/toolbar/RenderPopup.vue
Normal file
356
frontend/src/components/viewer/toolbar/RenderPopup.vue
Normal file
@@ -0,0 +1,356 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watch, onUnmounted } from 'vue'
|
||||
import { useViewerStore } from '@/stores/viewer'
|
||||
import { getPartsTreeService, MaterialType } from '@/services/partsTreeService'
|
||||
import { getRenderService, resetRenderService, RenderMode } from '@/services/renderService'
|
||||
import ToolbarPopup from './ToolbarPopup.vue'
|
||||
|
||||
defineProps<{
|
||||
show: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const viewerStore = useViewerStore()
|
||||
|
||||
const renderMode = computed(() => viewerStore.renderSettings.renderMode)
|
||||
const isSpecialRenderMode = computed(() =>
|
||||
renderMode.value === 'hiddenLine' || renderMode.value === 'wireframe'
|
||||
)
|
||||
|
||||
const edgeLineWidth = computed({
|
||||
get: () => viewerStore.renderSettings.edgeLineWidth,
|
||||
set: (value) => viewerStore.setEdgeLineWidth(value),
|
||||
})
|
||||
|
||||
const materialType = computed({
|
||||
get: () => viewerStore.renderSettings.materialType,
|
||||
set: (value) => viewerStore.setMaterialType(value),
|
||||
})
|
||||
|
||||
const exposure = computed({
|
||||
get: () => viewerStore.renderSettings.exposure,
|
||||
set: (value) => viewerStore.setExposure(value),
|
||||
})
|
||||
|
||||
const mainLightIntensity = computed({
|
||||
get: () => viewerStore.renderSettings.mainLightIntensity,
|
||||
set: (value) => viewerStore.setMainLightIntensity(value),
|
||||
})
|
||||
|
||||
const ambientLightIntensity = computed({
|
||||
get: () => viewerStore.renderSettings.ambientLightIntensity,
|
||||
set: (value) => viewerStore.setAmbientLightIntensity(value),
|
||||
})
|
||||
|
||||
// Initialize render service when scene is available
|
||||
watch(
|
||||
() => viewerStore.scene,
|
||||
(scene) => {
|
||||
if (scene) {
|
||||
const renderService = getRenderService()
|
||||
renderService.initialize(scene, viewerStore.renderer)
|
||||
if (viewerStore.renderSettings.edgesEnabled) {
|
||||
renderService.setEdgesEnabled(true)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Watch edge line width changes
|
||||
watch(
|
||||
() => viewerStore.renderSettings.edgeLineWidth,
|
||||
(width) => {
|
||||
const renderService = getRenderService()
|
||||
if (renderService.isInitialized()) {
|
||||
renderService.setEdgeLineWidth(width)
|
||||
viewerStore.forceRender()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Watch material type changes
|
||||
watch(
|
||||
() => viewerStore.renderSettings.materialType,
|
||||
(type) => {
|
||||
const service = getPartsTreeService()
|
||||
const materialTypeMap: Record<string, MaterialType> = {
|
||||
'clay': MaterialType.Clay,
|
||||
'metal': MaterialType.Metal,
|
||||
'paint': MaterialType.Paint,
|
||||
}
|
||||
service.setGlobalMaterial(materialTypeMap[type])
|
||||
viewerStore.forceRender()
|
||||
}
|
||||
)
|
||||
|
||||
// Watch render mode changes
|
||||
watch(
|
||||
() => viewerStore.renderSettings.renderMode,
|
||||
(mode) => {
|
||||
const renderService = getRenderService()
|
||||
if (renderService.isInitialized()) {
|
||||
const modeMap: Record<string, RenderMode> = {
|
||||
'standard': RenderMode.Standard,
|
||||
'hiddenLine': RenderMode.HiddenLine,
|
||||
'wireframe': RenderMode.Wireframe,
|
||||
}
|
||||
renderService.setRenderMode(modeMap[mode])
|
||||
viewerStore.forceRender()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Watch exposure changes
|
||||
watch(
|
||||
() => viewerStore.renderSettings.exposure,
|
||||
(value) => {
|
||||
const renderService = getRenderService()
|
||||
if (renderService.isInitialized()) {
|
||||
renderService.setToneMappingExposure(value)
|
||||
viewerStore.forceRender()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Watch main light intensity changes
|
||||
watch(
|
||||
() => viewerStore.renderSettings.mainLightIntensity,
|
||||
(value) => {
|
||||
const renderService = getRenderService()
|
||||
if (renderService.isInitialized()) {
|
||||
renderService.setMainLightIntensity(value)
|
||||
viewerStore.forceRender()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Watch ambient light intensity changes
|
||||
watch(
|
||||
() => viewerStore.renderSettings.ambientLightIntensity,
|
||||
(value) => {
|
||||
const renderService = getRenderService()
|
||||
if (renderService.isInitialized()) {
|
||||
renderService.setAmbientLightIntensity(value)
|
||||
viewerStore.forceRender()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
resetRenderService()
|
||||
})
|
||||
|
||||
const materialOptions = [
|
||||
{ value: 'clay', label: '白模', title: '白模材质 - 结构展示' },
|
||||
{ value: 'metal', label: '金属', title: '金属材质' },
|
||||
{ value: 'paint', label: '烤漆', title: '工业哑光烤漆' },
|
||||
] as const
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToolbarPopup :show="show" title="渲染设置" @close="emit('close')">
|
||||
<div class="render-content">
|
||||
<!-- Material Type -->
|
||||
<div class="setting-row" :class="{ disabled: isSpecialRenderMode }">
|
||||
<span class="label">材质类型</span>
|
||||
<div class="material-buttons">
|
||||
<button
|
||||
v-for="mat in materialOptions"
|
||||
:key="mat.value"
|
||||
class="material-btn"
|
||||
:class="{ active: materialType === mat.value }"
|
||||
:disabled="isSpecialRenderMode"
|
||||
:title="mat.title"
|
||||
@click="materialType = mat.value"
|
||||
>
|
||||
{{ mat.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edge Line Width -->
|
||||
<div class="setting-row slider-row">
|
||||
<span class="label">线宽</span>
|
||||
<div class="slider-container">
|
||||
<input
|
||||
v-model.number="edgeLineWidth"
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="5"
|
||||
step="0.5"
|
||||
class="slider"
|
||||
/>
|
||||
<span class="slider-value">{{ edgeLineWidth }}px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lighting Section -->
|
||||
<div class="section-divider">
|
||||
<span class="section-title">光照</span>
|
||||
</div>
|
||||
|
||||
<div class="setting-row slider-row">
|
||||
<span class="label">整体亮度</span>
|
||||
<div class="slider-container">
|
||||
<input
|
||||
v-model.number="exposure"
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="3"
|
||||
step="0.1"
|
||||
class="slider"
|
||||
/>
|
||||
<span class="slider-value">{{ exposure.toFixed(1) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row slider-row">
|
||||
<span class="label">主光亮度</span>
|
||||
<div class="slider-container">
|
||||
<input
|
||||
v-model.number="mainLightIntensity"
|
||||
type="range"
|
||||
min="0"
|
||||
max="2"
|
||||
step="0.1"
|
||||
class="slider"
|
||||
/>
|
||||
<span class="slider-value">{{ mainLightIntensity.toFixed(1) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row slider-row">
|
||||
<span class="label">环境亮度</span>
|
||||
<div class="slider-container">
|
||||
<input
|
||||
v-model.number="ambientLightIntensity"
|
||||
type="range"
|
||||
min="0"
|
||||
max="2"
|
||||
step="0.1"
|
||||
class="slider"
|
||||
/>
|
||||
<span class="slider-value">{{ ambientLightIntensity.toFixed(1) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ToolbarPopup>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.render-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.setting-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.setting-row.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.material-buttons {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.material-btn {
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast) var(--ease-default);
|
||||
}
|
||||
|
||||
.material-btn:hover:not(:disabled) {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.material-btn.active {
|
||||
background: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.material-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.slider-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.slider-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.slider {
|
||||
flex: 1;
|
||||
-webkit-appearance: none;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.slider-value {
|
||||
min-width: 36px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
padding-top: var(--space-2);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,15 @@ import { useViewerStore } from '@/stores/viewer'
|
||||
import { getClippingService, resetClippingService, type Axis } from '@/services/clippingService'
|
||||
import { getRenderService } from '@/services/renderService'
|
||||
import { getPartsTreeService } from '@/services/partsTreeService'
|
||||
import ToolbarPopup from './ToolbarPopup.vue'
|
||||
|
||||
defineProps<{
|
||||
show: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const viewerStore = useViewerStore()
|
||||
const isInitialized = ref(false)
|
||||
@@ -37,12 +46,8 @@ watch(
|
||||
}
|
||||
})
|
||||
|
||||
// Update edge line clipping planes
|
||||
getRenderService().updateEdgeClipping()
|
||||
|
||||
// Update selection overlay clipping planes
|
||||
getPartsTreeService().updateSelectionClipping()
|
||||
|
||||
viewerStore.forceRender()
|
||||
},
|
||||
{ deep: true }
|
||||
@@ -81,12 +86,10 @@ function toggleSection() {
|
||||
const service = getClippingService()
|
||||
service.flipAllPlaneNormals()
|
||||
viewerStore.setCrossSectionFlipped(!viewerStore.crossSection.sectionFlipped)
|
||||
// Update selection overlay clipping when planes flip
|
||||
getPartsTreeService().updateSelectionClipping()
|
||||
viewerStore.forceRender()
|
||||
}
|
||||
|
||||
// Check if any plane is active
|
||||
const hasActivePlane = computed(() => {
|
||||
return (
|
||||
viewerStore.crossSection.x.enabled ||
|
||||
@@ -103,11 +106,11 @@ const axisColors: Record<Axis, string> = {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="feature-section">
|
||||
<h4>剖面</h4>
|
||||
|
||||
<div class="controls-row">
|
||||
<ToolbarPopup :show="show" title="剖面" @close="emit('close')">
|
||||
<div class="section-content">
|
||||
<!-- Axis toggle buttons -->
|
||||
<div class="axis-row">
|
||||
<span class="label">切割轴</span>
|
||||
<div class="axis-toggles">
|
||||
<button
|
||||
v-for="axis in ['x', 'y', 'z'] as Axis[]"
|
||||
@@ -120,39 +123,6 @@ const axisColors: Record<Axis, string> = {
|
||||
{{ axis.toUpperCase() }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="action-toggles">
|
||||
<button
|
||||
class="action-btn"
|
||||
:class="{ active: viewerStore.crossSection.planeVisible }"
|
||||
:disabled="!isInitialized || !hasActivePlane"
|
||||
@click="togglePlaneVisibility"
|
||||
title="显示/隐藏切割平面"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" class="icon">
|
||||
<template v-if="viewerStore.crossSection.planeVisible">
|
||||
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z"/>
|
||||
<path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<path fill-rule="evenodd" d="M3.707 2.293a1 1 0 00-1.414 1.414l14 14a1 1 0 001.414-1.414l-1.473-1.473A10.014 10.014 0 0019.542 10C18.268 5.943 14.478 3 10 3a9.958 9.958 0 00-4.512 1.074l-1.78-1.781zm4.261 4.26l1.514 1.515a2.003 2.003 0 012.45 2.45l1.514 1.514a4 4 0 00-5.478-5.478z" clip-rule="evenodd"/>
|
||||
<path d="M12.454 16.697L9.75 13.992a4 4 0 01-3.742-3.741L2.335 6.578A9.98 9.98 0 00.458 10c1.274 4.057 5.065 7 9.542 7 .847 0 1.669-.105 2.454-.303z"/>
|
||||
</template>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="action-btn flip-btn"
|
||||
:class="{ active: viewerStore.crossSection.sectionFlipped }"
|
||||
:disabled="!isInitialized || !hasActivePlane"
|
||||
@click="toggleSection"
|
||||
title="切换显示区域"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" class="icon">
|
||||
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Position sliders -->
|
||||
@@ -160,7 +130,7 @@ const axisColors: Record<Axis, string> = {
|
||||
v-for="axis in ['x', 'y', 'z'] as Axis[]"
|
||||
:key="`slider-${axis}`"
|
||||
v-show="viewerStore.crossSection[axis].enabled"
|
||||
class="axis-slider"
|
||||
class="slider-row"
|
||||
>
|
||||
<span class="axis-label" :style="{ color: axisColors[axis] }">
|
||||
{{ axis.toUpperCase() }}
|
||||
@@ -178,18 +148,61 @@ const axisColors: Record<Axis, string> = {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="hasActivePlane && viewerStore.crossSection.sectionFlipped" class="hint">
|
||||
已翻转显示区域
|
||||
</p>
|
||||
<!-- Action buttons -->
|
||||
<div v-if="hasActivePlane" class="action-row">
|
||||
<button
|
||||
class="action-btn"
|
||||
:class="{ active: viewerStore.crossSection.planeVisible }"
|
||||
:disabled="!isInitialized"
|
||||
@click="togglePlaneVisibility"
|
||||
title="显示/隐藏切割平面"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" class="icon">
|
||||
<template v-if="viewerStore.crossSection.planeVisible">
|
||||
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z"/>
|
||||
<path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<path fill-rule="evenodd" d="M3.707 2.293a1 1 0 00-1.414 1.414l14 14a1 1 0 001.414-1.414l-1.473-1.473A10.014 10.014 0 0019.542 10C18.268 5.943 14.478 3 10 3a9.958 9.958 0 00-4.512 1.074l-1.78-1.781zm4.261 4.26l1.514 1.515a2.003 2.003 0 012.45 2.45l1.514 1.514a4 4 0 00-5.478-5.478z" clip-rule="evenodd"/>
|
||||
<path d="M12.454 16.697L9.75 13.992a4 4 0 01-3.742-3.741L2.335 6.578A9.98 9.98 0 00.458 10c1.274 4.057 5.065 7 9.542 7 .847 0 1.669-.105 2.454-.303z"/>
|
||||
</template>
|
||||
</svg>
|
||||
<span>{{ viewerStore.crossSection.planeVisible ? '隐藏平面' : '显示平面' }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
:class="{ active: viewerStore.crossSection.sectionFlipped, flip: true }"
|
||||
:disabled="!isInitialized"
|
||||
@click="toggleSection"
|
||||
title="切换显示区域"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" class="icon">
|
||||
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span>翻转</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ToolbarPopup>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.controls-row {
|
||||
.section-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.axis-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.axis-toggles {
|
||||
@@ -197,36 +210,119 @@ const axisColors: Record<Axis, string> = {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.action-toggles {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
.axis-btn {
|
||||
width: 32px;
|
||||
height: 28px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast) var(--ease-default);
|
||||
}
|
||||
|
||||
.axis-slider {
|
||||
.axis-btn:hover:not(:disabled) {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.axis-btn.active.x {
|
||||
background: #ef4444;
|
||||
border-color: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.axis-btn.active.y {
|
||||
background: #22c55e;
|
||||
border-color: #22c55e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.axis-btn.active.z {
|
||||
background: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.axis-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.slider-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.axis-label {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
min-width: 20px;
|
||||
min-width: 16px;
|
||||
}
|
||||
|
||||
.slider-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.slider {
|
||||
flex: 1;
|
||||
-webkit-appearance: none;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.slider-value {
|
||||
min-width: 36px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
padding-top: var(--space-2);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
transition: all var(--duration-fast) var(--ease-default);
|
||||
}
|
||||
|
||||
.action-btn:hover:not(:disabled) {
|
||||
@@ -240,77 +336,18 @@ const axisColors: Record<Axis, string> = {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn.flip.active {
|
||||
background: #f59e0b;
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.action-btn .icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.flip-btn.active {
|
||||
background: #f59e0b;
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.slider-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.slider {
|
||||
flex: 1;
|
||||
-webkit-appearance: none;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-runnable-track {
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.slider::-moz-range-track {
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color);
|
||||
cursor: pointer;
|
||||
margin-top: -5px;
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.slider-value {
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
59
frontend/src/components/viewer/toolbar/ToolbarButton.vue
Normal file
59
frontend/src/components/viewer/toolbar/ToolbarButton.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
active?: boolean
|
||||
disabled?: boolean
|
||||
title?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
:class="{ active, disabled }"
|
||||
:disabled="disabled"
|
||||
:title="title"
|
||||
@click="emit('click')"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast) var(--ease-default);
|
||||
}
|
||||
|
||||
.toolbar-btn:hover:not(:disabled) {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.toolbar-btn.active {
|
||||
background: var(--primary-subtle);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.toolbar-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.toolbar-btn :deep(svg) {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
</style>
|
||||
136
frontend/src/components/viewer/toolbar/ToolbarPopup.vue
Normal file
136
frontend/src/components/viewer/toolbar/ToolbarPopup.vue
Normal file
@@ -0,0 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
title?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const popupRef = ref<HTMLElement | null>(null)
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (!props.show) return
|
||||
const target = event.target as HTMLElement
|
||||
|
||||
// Check if click is outside popup
|
||||
if (popupRef.value && !popupRef.value.contains(target)) {
|
||||
// Check if click is on a toolbar button (parent will handle popup switching)
|
||||
const toolbar = target.closest('.floating-toolbar')
|
||||
const toolbarItem = target.closest('.toolbar-item')
|
||||
const toolbarBtn = target.closest('.toolbar-btn')
|
||||
|
||||
// If clicking on toolbar controls, let parent handle it
|
||||
if (toolbar && (toolbarItem || toolbarBtn)) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="popup">
|
||||
<div v-if="show" ref="popupRef" class="toolbar-popup">
|
||||
<div v-if="title" class="popup-header">
|
||||
<span class="popup-title">{{ title }}</span>
|
||||
</div>
|
||||
<div class="popup-content">
|
||||
<slot />
|
||||
</div>
|
||||
<div class="popup-arrow"></div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar-popup {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-bottom: 12px;
|
||||
padding: var(--space-4);
|
||||
background: var(--panel-overlay-bg);
|
||||
backdrop-filter: blur(var(--panel-backdrop-blur));
|
||||
-webkit-backdrop-filter: blur(var(--panel-backdrop-blur));
|
||||
border-radius: var(--radius-xl);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadow-xl);
|
||||
min-width: 220px;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
margin-bottom: var(--space-3);
|
||||
padding-bottom: var(--space-2);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
font-size: 13px;
|
||||
font-weight: 550;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.popup-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.popup-arrow {
|
||||
position: absolute;
|
||||
bottom: -6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) rotate(45deg);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: var(--panel-overlay-bg);
|
||||
border-right: 1px solid var(--border-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Animation */
|
||||
.popup-enter-active {
|
||||
animation: popup-enter var(--duration-fast) var(--ease-spring);
|
||||
}
|
||||
|
||||
.popup-leave-active {
|
||||
animation: popup-leave var(--duration-fast) var(--ease-default);
|
||||
}
|
||||
|
||||
@keyframes popup-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(8px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes popup-leave {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0) scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(8px) scale(0.95);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user