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 { useModelsStore } from '@/stores/models'
|
||||||
import { useViewerStore } from '@/stores/viewer'
|
import { useViewerStore } from '@/stores/viewer'
|
||||||
import ModelViewer from '@/components/viewer/ModelViewer.vue'
|
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 modelsStore = useModelsStore()
|
||||||
const viewerStore = useViewerStore()
|
const viewerStore = useViewerStore()
|
||||||
@@ -20,7 +20,7 @@ const isModelReady = computed(() =>
|
|||||||
<template v-if="hasSelectedModel">
|
<template v-if="hasSelectedModel">
|
||||||
<template v-if="isModelReady">
|
<template v-if="isModelReady">
|
||||||
<ModelViewer :model-id="modelsStore.selectedModelId!" />
|
<ModelViewer :model-id="modelsStore.selectedModelId!" />
|
||||||
<FeaturePanel />
|
<FloatingToolbar />
|
||||||
</template>
|
</template>
|
||||||
<div v-else class="empty-state">
|
<div v-else class="empty-state">
|
||||||
<div v-if="viewerStore.isLoading" class="loading">
|
<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 { getClippingService, resetClippingService, type Axis } from '@/services/clippingService'
|
||||||
import { getRenderService } from '@/services/renderService'
|
import { getRenderService } from '@/services/renderService'
|
||||||
import { getPartsTreeService } from '@/services/partsTreeService'
|
import { getPartsTreeService } from '@/services/partsTreeService'
|
||||||
|
import ToolbarPopup from './ToolbarPopup.vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
show: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
const viewerStore = useViewerStore()
|
const viewerStore = useViewerStore()
|
||||||
const isInitialized = ref(false)
|
const isInitialized = ref(false)
|
||||||
@@ -37,12 +46,8 @@ watch(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Update edge line clipping planes
|
|
||||||
getRenderService().updateEdgeClipping()
|
getRenderService().updateEdgeClipping()
|
||||||
|
|
||||||
// Update selection overlay clipping planes
|
|
||||||
getPartsTreeService().updateSelectionClipping()
|
getPartsTreeService().updateSelectionClipping()
|
||||||
|
|
||||||
viewerStore.forceRender()
|
viewerStore.forceRender()
|
||||||
},
|
},
|
||||||
{ deep: true }
|
{ deep: true }
|
||||||
@@ -81,12 +86,10 @@ function toggleSection() {
|
|||||||
const service = getClippingService()
|
const service = getClippingService()
|
||||||
service.flipAllPlaneNormals()
|
service.flipAllPlaneNormals()
|
||||||
viewerStore.setCrossSectionFlipped(!viewerStore.crossSection.sectionFlipped)
|
viewerStore.setCrossSectionFlipped(!viewerStore.crossSection.sectionFlipped)
|
||||||
// Update selection overlay clipping when planes flip
|
|
||||||
getPartsTreeService().updateSelectionClipping()
|
getPartsTreeService().updateSelectionClipping()
|
||||||
viewerStore.forceRender()
|
viewerStore.forceRender()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if any plane is active
|
|
||||||
const hasActivePlane = computed(() => {
|
const hasActivePlane = computed(() => {
|
||||||
return (
|
return (
|
||||||
viewerStore.crossSection.x.enabled ||
|
viewerStore.crossSection.x.enabled ||
|
||||||
@@ -103,30 +106,54 @@ const axisColors: Record<Axis, string> = {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="feature-section">
|
<ToolbarPopup :show="show" title="剖面" @close="emit('close')">
|
||||||
<h4>剖面</h4>
|
<div class="section-content">
|
||||||
|
|
||||||
<div class="controls-row">
|
|
||||||
<!-- Axis toggle buttons -->
|
<!-- Axis toggle buttons -->
|
||||||
<div class="axis-toggles">
|
<div class="axis-row">
|
||||||
<button
|
<span class="label">切割轴</span>
|
||||||
v-for="axis in ['x', 'y', 'z'] as Axis[]"
|
<div class="axis-toggles">
|
||||||
:key="axis"
|
<button
|
||||||
class="axis-btn"
|
v-for="axis in ['x', 'y', 'z'] as Axis[]"
|
||||||
:class="[axis, { active: viewerStore.crossSection[axis].enabled }]"
|
:key="axis"
|
||||||
:disabled="!isInitialized"
|
class="axis-btn"
|
||||||
@click="toggleAxis(axis)"
|
:class="[axis, { active: viewerStore.crossSection[axis].enabled }]"
|
||||||
>
|
:disabled="!isInitialized"
|
||||||
|
@click="toggleAxis(axis)"
|
||||||
|
>
|
||||||
|
{{ axis.toUpperCase() }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Position sliders -->
|
||||||
|
<div
|
||||||
|
v-for="axis in ['x', 'y', 'z'] as Axis[]"
|
||||||
|
:key="`slider-${axis}`"
|
||||||
|
v-show="viewerStore.crossSection[axis].enabled"
|
||||||
|
class="slider-row"
|
||||||
|
>
|
||||||
|
<span class="axis-label" :style="{ color: axisColors[axis] }">
|
||||||
{{ axis.toUpperCase() }}
|
{{ axis.toUpperCase() }}
|
||||||
</button>
|
</span>
|
||||||
|
<div class="slider-container">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
class="slider"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
:value="viewerStore.crossSection[axis].position"
|
||||||
|
@input="(e) => handlePositionChange(axis, e)"
|
||||||
|
/>
|
||||||
|
<span class="slider-value">{{ viewerStore.crossSection[axis].position }}%</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Action buttons -->
|
<!-- Action buttons -->
|
||||||
<div class="action-toggles">
|
<div v-if="hasActivePlane" class="action-row">
|
||||||
<button
|
<button
|
||||||
class="action-btn"
|
class="action-btn"
|
||||||
:class="{ active: viewerStore.crossSection.planeVisible }"
|
:class="{ active: viewerStore.crossSection.planeVisible }"
|
||||||
:disabled="!isInitialized || !hasActivePlane"
|
:disabled="!isInitialized"
|
||||||
@click="togglePlaneVisibility"
|
@click="togglePlaneVisibility"
|
||||||
title="显示/隐藏切割平面"
|
title="显示/隐藏切割平面"
|
||||||
>
|
>
|
||||||
@@ -140,56 +167,42 @@ const axisColors: Record<Axis, string> = {
|
|||||||
<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"/>
|
<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>
|
</template>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span>{{ viewerStore.crossSection.planeVisible ? '隐藏平面' : '显示平面' }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="action-btn flip-btn"
|
class="action-btn"
|
||||||
:class="{ active: viewerStore.crossSection.sectionFlipped }"
|
:class="{ active: viewerStore.crossSection.sectionFlipped, flip: true }"
|
||||||
:disabled="!isInitialized || !hasActivePlane"
|
:disabled="!isInitialized"
|
||||||
@click="toggleSection"
|
@click="toggleSection"
|
||||||
title="切换显示区域"
|
title="切换显示区域"
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 20 20" fill="currentColor" class="icon">
|
<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"/>
|
<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>
|
</svg>
|
||||||
|
<span>翻转</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</ToolbarPopup>
|
||||||
<!-- Position sliders -->
|
|
||||||
<div
|
|
||||||
v-for="axis in ['x', 'y', 'z'] as Axis[]"
|
|
||||||
:key="`slider-${axis}`"
|
|
||||||
v-show="viewerStore.crossSection[axis].enabled"
|
|
||||||
class="axis-slider"
|
|
||||||
>
|
|
||||||
<span class="axis-label" :style="{ color: axisColors[axis] }">
|
|
||||||
{{ axis.toUpperCase() }}
|
|
||||||
</span>
|
|
||||||
<div class="slider-container">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
class="slider"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
:value="viewerStore.crossSection[axis].position"
|
|
||||||
@input="(e) => handlePositionChange(axis, e)"
|
|
||||||
/>
|
|
||||||
<span class="slider-value">{{ viewerStore.crossSection[axis].position }}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="hasActivePlane && viewerStore.crossSection.sectionFlipped" class="hint">
|
|
||||||
已翻转显示区域
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.controls-row {
|
.section-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
min-width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.axis-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.axis-toggles {
|
.axis-toggles {
|
||||||
@@ -197,36 +210,119 @@ const axisColors: Record<Axis, string> = {
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-toggles {
|
.axis-btn {
|
||||||
display: flex;
|
width: 32px;
|
||||||
gap: 4px;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: var(--space-2);
|
||||||
margin-top: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.axis-label {
|
.axis-label {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 12px;
|
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 {
|
.action-btn {
|
||||||
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 32px;
|
gap: 6px;
|
||||||
height: 32px;
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
color: var(--text-primary);
|
color: var(--text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s ease;
|
transition: all var(--duration-fast) var(--ease-default);
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn:hover:not(:disabled) {
|
.action-btn:hover:not(:disabled) {
|
||||||
@@ -240,77 +336,18 @@ const axisColors: Record<Axis, string> = {
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-btn.flip.active {
|
||||||
|
background: #f59e0b;
|
||||||
|
border-color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
.action-btn:disabled {
|
.action-btn:disabled {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn .icon {
|
.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;
|
width: 14px;
|
||||||
height: 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>
|
</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