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>
177 lines
3.9 KiB
Vue
177 lines
3.9 KiB
Vue
<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>
|