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:
likegears
2025-12-12 17:51:47 +08:00
parent a9971ea0b1
commit 8daf1a601c
12 changed files with 1417 additions and 1073 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>

View 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>

View File

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

View 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>

View File

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

View 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>

View 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>