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>
317 lines
8.1 KiB
Vue
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>
|