Files
3Dviewer/frontend/src/components/viewer/CrossSection.vue
likegears 7af9c323f6 Initial commit: 3D Viewer application
Features:
- Vue 3 frontend with Three.js/Online3DViewer
- Node.js API with PostgreSQL and Redis
- Python worker for model conversion
- Docker Compose for deployment
- ViewCube navigation with drag rotation and 90° snap
- Cross-section, exploded view, and render settings
- Parts tree with visibility controls

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 14:00:17 +08:00

317 lines
8.1 KiB
Vue

<script setup lang="ts">
import { ref, computed, watch, onUnmounted } from 'vue'
import { useViewerStore } from '@/stores/viewer'
import { getClippingService, resetClippingService, type Axis } from '@/services/clippingService'
import { getRenderService } from '@/services/renderService'
import { getPartsTreeService } from '@/services/partsTreeService'
const viewerStore = useViewerStore()
const isInitialized = ref(false)
// Initialize when scene/renderer changes
watch(
[() => viewerStore.scene, () => viewerStore.renderer],
([scene, renderer]) => {
if (scene && renderer) {
const service = getClippingService()
service.initialize(scene, renderer)
isInitialized.value = service.isInitialized()
}
},
{ immediate: true }
)
// Apply clipping when axis enabled state changes
watch(
() => viewerStore.crossSection,
(cs) => {
if (!isInitialized.value) return
const service = getClippingService()
const axes: Axis[] = ['x', 'y', 'z']
axes.forEach((axis) => {
service.setAxisEnabled(axis, cs[axis].enabled)
if (cs[axis].enabled) {
service.setPlanePosition(axis, cs[axis].position)
}
})
// Update edge line clipping planes
getRenderService().updateEdgeClipping()
// Update selection overlay clipping planes
getPartsTreeService().updateSelectionClipping()
viewerStore.forceRender()
},
{ deep: true }
)
// Watch plane visibility changes
watch(
() => viewerStore.crossSection.planeVisible,
(visible) => {
if (!isInitialized.value) return
const service = getClippingService()
service.setPlaneVisible(visible)
viewerStore.forceRender()
}
)
onUnmounted(() => {
resetClippingService()
})
function toggleAxis(axis: Axis) {
const current = viewerStore.crossSection[axis].enabled
viewerStore.setCrossSectionAxis(axis, !current)
}
function handlePositionChange(axis: Axis, event: Event) {
const target = event.target as HTMLInputElement
viewerStore.setCrossSectionPosition(axis, Number(target.value))
}
function togglePlaneVisibility() {
viewerStore.setCrossSectionPlaneVisible(!viewerStore.crossSection.planeVisible)
}
function toggleSection() {
const service = getClippingService()
service.flipAllPlaneNormals()
viewerStore.setCrossSectionFlipped(!viewerStore.crossSection.sectionFlipped)
// Update selection overlay clipping when planes flip
getPartsTreeService().updateSelectionClipping()
viewerStore.forceRender()
}
// Check if any plane is active
const hasActivePlane = computed(() => {
return (
viewerStore.crossSection.x.enabled ||
viewerStore.crossSection.y.enabled ||
viewerStore.crossSection.z.enabled
)
})
const axisColors: Record<Axis, string> = {
x: '#ef4444',
y: '#22c55e',
z: '#3b82f6',
}
</script>
<template>
<div class="feature-section">
<h4>剖面</h4>
<div class="controls-row">
<!-- Axis toggle buttons -->
<div class="axis-toggles">
<button
v-for="axis in ['x', 'y', 'z'] as Axis[]"
:key="axis"
class="axis-btn"
:class="[axis, { active: viewerStore.crossSection[axis].enabled }]"
:disabled="!isInitialized"
@click="toggleAxis(axis)"
>
{{ axis.toUpperCase() }}
</button>
</div>
<!-- Action buttons -->
<div class="action-toggles">
<button
class="action-btn"
:class="{ active: viewerStore.crossSection.planeVisible }"
:disabled="!isInitialized || !hasActivePlane"
@click="togglePlaneVisibility"
title="显示/隐藏切割平面"
>
<svg viewBox="0 0 20 20" fill="currentColor" class="icon">
<template v-if="viewerStore.crossSection.planeVisible">
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z"/>
<path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"/>
</template>
<template v-else>
<path fill-rule="evenodd" d="M3.707 2.293a1 1 0 00-1.414 1.414l14 14a1 1 0 001.414-1.414l-1.473-1.473A10.014 10.014 0 0019.542 10C18.268 5.943 14.478 3 10 3a9.958 9.958 0 00-4.512 1.074l-1.78-1.781zm4.261 4.26l1.514 1.515a2.003 2.003 0 012.45 2.45l1.514 1.514a4 4 0 00-5.478-5.478z" clip-rule="evenodd"/>
<path d="M12.454 16.697L9.75 13.992a4 4 0 01-3.742-3.741L2.335 6.578A9.98 9.98 0 00.458 10c1.274 4.057 5.065 7 9.542 7 .847 0 1.669-.105 2.454-.303z"/>
</template>
</svg>
</button>
<button
class="action-btn flip-btn"
:class="{ active: viewerStore.crossSection.sectionFlipped }"
:disabled="!isInitialized || !hasActivePlane"
@click="toggleSection"
title="切换显示区域"
>
<svg viewBox="0 0 20 20" fill="currentColor" class="icon">
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/>
</svg>
</button>
</div>
</div>
<!-- Position sliders -->
<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>
<style scoped>
.controls-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.axis-toggles {
display: flex;
gap: 4px;
}
.action-toggles {
display: flex;
gap: 4px;
}
.axis-slider {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.axis-label {
font-weight: 600;
font-size: 12px;
min-width: 20px;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-secondary);
color: var(--text-primary);
cursor: pointer;
transition: all 0.15s ease;
}
.action-btn:hover:not(:disabled) {
background: var(--bg-tertiary);
border-color: var(--primary-color);
}
.action-btn.active {
background: var(--primary-color);
border-color: var(--primary-color);
color: white;
}
.action-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.action-btn .icon {
width: 16px;
height: 16px;
}
.flip-btn.active {
background: #f59e0b;
border-color: #f59e0b;
}
.hint {
margin-top: 8px;
font-size: 11px;
color: var(--text-secondary);
}
.slider-container {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
}
.slider {
flex: 1;
-webkit-appearance: none;
height: 4px;
border-radius: 2px;
background: var(--bg-tertiary);
}
.slider::-webkit-slider-runnable-track {
height: 4px;
border-radius: 2px;
background: var(--bg-tertiary);
}
.slider::-moz-range-track {
height: 4px;
border-radius: 2px;
background: var(--bg-tertiary);
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--primary-color);
cursor: pointer;
margin-top: -5px;
}
.slider::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--primary-color);
cursor: pointer;
border: none;
}
.slider-value {
min-width: 40px;
text-align: right;
font-size: 12px;
color: var(--text-secondary);
}
</style>