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>
457 lines
12 KiB
Vue
457 lines
12 KiB
Vue
<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>
|