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>
This commit is contained in:
likegears
2025-12-12 14:00:17 +08:00
commit 7af9c323f6
86 changed files with 20343 additions and 0 deletions

41
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,41 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Accept build arg for API URL
ARG VITE_API_URL=http://localhost:3000
ENV VITE_API_URL=$VITE_API_URL
# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# Copy package files
COPY package.json pnpm-lock.yaml* ./
# Install dependencies
RUN pnpm install
# Copy source code
COPY . .
# Build the application
RUN pnpm build
# Production stage with nginx
FROM nginx:alpine AS runner
# Copy custom nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy built assets
COPY --from=builder /app/dist /usr/share/nginx/html
# Expose port
EXPOSE 80
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget -q --spider http://localhost/ || exit 1
CMD ["nginx", "-g", "daemon off;"]

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>3D 模型查看器</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

42
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,42 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript application/json;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA routing - serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;
}
# API proxy - forwards /api requests to the backend
location /api {
proxy_pass http://api:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
# Increase timeouts for large file uploads
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
}

32
frontend/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "viewer3d-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@types/earcut": "^3.0.0",
"axios": "^1.6.0",
"earcut": "^3.0.2",
"online-3d-viewer": "^0.16.0",
"pinia": "^2.1.0",
"three": "^0.160.0",
"three-mesh-bvh": "^0.9.3",
"vue": "^3.4.0"
},
"devDependencies": {
"@types/node": "^20.14.0",
"@types/three": "^0.160.0",
"@vitejs/plugin-vue": "^5.0.0",
"@vue/tsconfig": "^0.5.0",
"typescript": "~5.6.0",
"vite": "^5.0.0",
"vue-tsc": "^2.1.0"
}
}

1213
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

20
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { useModelsStore } from '@/stores/models'
import AppLayout from '@/components/layout/AppLayout.vue'
const modelsStore = useModelsStore()
onMounted(async () => {
await modelsStore.fetchModels()
modelsStore.startPolling(5000)
})
onUnmounted(() => {
modelsStore.stopPolling()
})
</script>
<template>
<AppLayout />
</template>

145
frontend/src/api/client.ts Normal file
View File

@@ -0,0 +1,145 @@
import axios from 'axios'
import type { Model, UploadInitResponse, ApiResponse } from '@/types/model'
// Use relative path - requests will be proxied by nginx
const client = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
})
// Models API
export async function getModels(params?: {
search?: string
status?: string
format?: string
limit?: number
offset?: number
}): Promise<{ models: Model[]; total: number }> {
const response = await client.get<ApiResponse<Model[]>>('/models', { params })
return {
models: response.data.data || [],
total: response.data.meta?.total || 0,
}
}
export async function getModel(id: string): Promise<Model> {
const response = await client.get<ApiResponse<Model>>(`/models/${id}`)
if (!response.data.data) throw new Error('Model not found')
return response.data.data
}
export async function getModelUrl(id: string): Promise<string> {
const response = await client.get<ApiResponse<{ url: string }>>(`/models/${id}/url`)
if (!response.data.data) throw new Error('Model URL not found')
return response.data.data.url
}
/**
* Get all available LOD URLs for a model
*/
export async function getModelLodUrls(id: string): Promise<Record<string, string>> {
const response = await client.get<ApiResponse<Record<string, string>>>(`/models/${id}/lod`)
if (!response.data.data) throw new Error('LOD URLs not found')
return response.data.data
}
/**
* Get URL for a specific LOD level
* @param id Model ID
* @param level LOD level (0=highest quality, 1=medium, 2=lowest)
*/
export async function getModelLodUrl(id: string, level: number): Promise<string> {
const response = await client.get<ApiResponse<{ url: string; level: number }>>(`/models/${id}/lod/${level}`)
if (!response.data.data) throw new Error('LOD URL not found')
return response.data.data.url
}
export async function deleteModel(id: string): Promise<void> {
await client.delete(`/models/${id}`)
}
export async function updateModel(id: string, data: { name?: string }): Promise<Model> {
const response = await client.patch<ApiResponse<Model>>(`/models/${id}`, data)
if (!response.data.data) throw new Error('Update failed')
return response.data.data
}
// Upload API
export async function initUpload(filename: string): Promise<UploadInitResponse> {
const response = await client.post<ApiResponse<UploadInitResponse>>('/upload/presigned-url', {
filename,
})
if (!response.data.data) throw new Error('Failed to initialize upload')
return response.data.data
}
export async function confirmUpload(data: {
modelId: string
filename: string
fileSize: number
storageKey: string
}): Promise<Model> {
const response = await client.post<ApiResponse<Model>>('/upload/complete', data)
if (!response.data.data) throw new Error('Failed to confirm upload')
return response.data.data
}
// Direct upload to MinIO
export async function uploadToMinIO(url: string, file: File): Promise<void> {
await axios.put(url, file, {
headers: {
'Content-Type': file.type || 'application/octet-stream',
},
})
}
/**
* Fetch a file with progress tracking using ReadableStream
* @param url - URL to fetch
* @param onProgress - Callback with progress 0-100
* @param signal - AbortSignal for cancellation
* @returns Blob of the downloaded file
*/
export async function fetchWithProgress(
url: string,
onProgress?: (progress: number) => void,
signal?: AbortSignal
): Promise<Blob> {
const response = await fetch(url, { signal })
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`)
}
const contentLength = response.headers.get('content-length')
const total = contentLength ? parseInt(contentLength, 10) : 0
// If no content-length header, fall back to simple fetch
if (!total || !response.body) {
return response.blob()
}
const reader = response.body.getReader()
const chunks: Uint8Array[] = []
let received = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
received += value.length
if (onProgress && total > 0) {
onProgress(Math.round((received / total) * 100))
}
}
// Combine chunks into a single blob
return new Blob(chunks)
}
export default client

View File

@@ -0,0 +1,189 @@
<script setup lang="ts">
import { onMounted, onUnmounted, watch } from 'vue'
const props = defineProps<{
show: boolean
title: string
message: string
confirmText?: string
cancelText?: string
danger?: boolean
}>()
const emit = defineEmits<{
confirm: []
cancel: []
}>()
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && props.show) {
emit('cancel')
}
}
function handleOverlayClick(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains('dialog-overlay')) {
emit('cancel')
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
// Prevent body scroll when dialog is open
watch(() => props.show, (show) => {
if (show) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
})
</script>
<template>
<Teleport to="body">
<div
v-if="show"
class="dialog-overlay"
@click="handleOverlayClick"
>
<div class="dialog-modal">
<div class="dialog-header">
<h3>{{ title }}</h3>
</div>
<div class="dialog-body">
<p>{{ message }}</p>
</div>
<div class="dialog-footer">
<button class="btn-cancel" @click="emit('cancel')">
{{ cancelText || '取消' }}
</button>
<button
class="btn-confirm"
:class="{ danger }"
@click="emit('confirm')"
>
{{ confirmText || '确认' }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
backdrop-filter: blur(4px);
animation: fade-in 0.15s ease;
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.dialog-modal {
background: var(--bg-primary);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
min-width: 320px;
max-width: 400px;
animation: scale-in 0.15s ease;
}
@keyframes scale-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.dialog-header {
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--border-color);
}
.dialog-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.dialog-body {
padding: var(--space-5);
}
.dialog-body p {
margin: 0;
font-size: 14px;
color: var(--text-secondary);
line-height: 1.5;
}
.dialog-footer {
display: flex;
gap: var(--space-3);
padding: var(--space-4) var(--space-5);
justify-content: flex-end;
border-top: 1px solid var(--border-color);
}
.btn-cancel,
.btn-confirm {
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.15s ease;
}
.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);
}
.btn-confirm {
background: var(--primary-color);
color: white;
}
.btn-confirm:hover {
background: var(--primary-hover);
}
.btn-confirm.danger {
background: var(--danger-color);
}
.btn-confirm.danger:hover {
background: #dc2626;
}
</style>

View File

@@ -0,0 +1,223 @@
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
const props = defineProps<{
show: boolean
currentName: string
}>()
const emit = defineEmits<{
confirm: [newName: string]
cancel: []
}>()
const newName = ref('')
const inputRef = ref<HTMLInputElement | null>(null)
// Sync with currentName when dialog opens
watch(() => props.show, async (show) => {
if (show) {
newName.value = props.currentName
document.body.style.overflow = 'hidden'
// Auto focus input
await nextTick()
inputRef.value?.focus()
inputRef.value?.select()
} else {
document.body.style.overflow = ''
}
})
function handleConfirm() {
const trimmed = newName.value.trim()
if (trimmed && trimmed !== props.currentName) {
emit('confirm', trimmed)
} else {
emit('cancel')
}
}
function handleKeydown(e: KeyboardEvent) {
if (!props.show) return
if (e.key === 'Escape') {
emit('cancel')
} else if (e.key === 'Enter') {
handleConfirm()
}
}
function handleOverlayClick(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains('dialog-overlay')) {
emit('cancel')
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
</script>
<template>
<Teleport to="body">
<div
v-if="show"
class="dialog-overlay"
@click="handleOverlayClick"
>
<div class="dialog-modal">
<div class="dialog-header">
<h3>重命名模型</h3>
</div>
<div class="dialog-body">
<input
ref="inputRef"
v-model="newName"
type="text"
class="name-input"
placeholder="输入新名称"
/>
</div>
<div class="dialog-footer">
<button class="btn-cancel" @click="emit('cancel')">
取消
</button>
<button
class="btn-confirm"
:disabled="!newName.trim() || newName.trim() === currentName"
@click="handleConfirm"
>
保存
</button>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
backdrop-filter: blur(4px);
animation: fade-in 0.15s ease;
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.dialog-modal {
background: var(--bg-primary);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
min-width: 320px;
max-width: 400px;
animation: scale-in 0.15s ease;
}
@keyframes scale-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.dialog-header {
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--border-color);
}
.dialog-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.dialog-body {
padding: var(--space-5);
}
.name-input {
width: 100%;
padding: 10px 12px;
font-size: 14px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-secondary);
color: var(--text-primary);
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.name-input:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px var(--primary-subtle);
}
.name-input::placeholder {
color: var(--text-tertiary);
}
.dialog-footer {
display: flex;
gap: var(--space-3);
padding: var(--space-4) var(--space-5);
justify-content: flex-end;
border-top: 1px solid var(--border-color);
}
.btn-cancel,
.btn-confirm {
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.15s ease;
}
.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);
}
.btn-confirm {
background: var(--primary-color);
color: white;
}
.btn-confirm:hover:not(:disabled) {
background: var(--primary-hover);
}
.btn-confirm:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,122 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useThemeStore } from '@/stores/theme'
const themeStore = useThemeStore()
const isDark = computed(() => themeStore.isDark)
function handleToggle() {
themeStore.toggle()
}
</script>
<template>
<button
class="theme-toggle"
:title="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
:aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
@click="handleToggle"
>
<!-- Sun icon (visible in dark mode) -->
<svg
v-if="isDark"
class="theme-icon"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
clip-rule="evenodd"
/>
</svg>
<!-- Moon icon (visible in light mode) -->
<svg
v-else
class="theme-icon"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"
/>
</svg>
</button>
</template>
<style scoped>
.theme-toggle {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
padding: 0;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
cursor: pointer;
overflow: hidden;
transition:
background-color var(--duration-fast) var(--ease-default),
border-color var(--duration-fast) var(--ease-default),
transform var(--duration-normal) var(--ease-spring),
box-shadow var(--duration-fast) var(--ease-default);
}
.theme-toggle::before {
content: '';
position: absolute;
inset: 0;
background: var(--gradient-primary);
opacity: 0;
transition: opacity var(--duration-fast) var(--ease-default);
}
.theme-toggle:hover {
border-color: var(--primary-color);
transform: translateY(-2px);
box-shadow: var(--shadow-sm);
}
.theme-toggle:hover::before {
opacity: 0.08;
}
.theme-toggle:focus-visible {
outline: none;
box-shadow:
0 0 0 2px var(--bg-secondary),
0 0 0 4px var(--primary-color);
}
.theme-toggle:active {
transform: translateY(0) scale(0.95);
transition-duration: var(--duration-instant);
}
.theme-icon {
position: relative;
width: 1.125rem;
height: 1.125rem;
color: var(--text-secondary);
transition:
color var(--duration-fast) var(--ease-default),
transform var(--duration-normal) var(--ease-spring);
z-index: 1;
}
.theme-toggle:hover .theme-icon {
color: var(--primary-color);
transform: rotate(15deg) scale(1.1);
}
/* Sun icon specific animation */
.theme-toggle:hover .theme-icon[aria-hidden="true"]:first-of-type {
transform: rotate(45deg) scale(1.1);
}
</style>

View File

@@ -0,0 +1,102 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import SidebarPanel from './SidebarPanel.vue'
import ViewerPanel from './ViewerPanel.vue'
import PartsTreePanel from '@/components/partsTree/PartsTreePanel.vue'
import { usePartsTreeStore } from '@/stores/partsTree'
import { useViewerStore } from '@/stores/viewer'
const partsTreeStore = usePartsTreeStore()
const viewerStore = useViewerStore()
const isTreePanelCollapsed = ref(false)
const treePanelWidth = ref(280)
// Build tree when model loads - must be here so it runs before conditional render
watch(
() => viewerStore.model,
(model) => {
if (model && viewerStore.scene) {
partsTreeStore.buildTree()
} else {
partsTreeStore.reset()
}
},
{ immediate: true }
)
const treePanelStyle = computed(() => ({
width: isTreePanelCollapsed.value ? '0px' : `${treePanelWidth.value}px`,
minWidth: isTreePanelCollapsed.value ? '0px' : `${treePanelWidth.value}px`,
}))
function toggleTreePanel() {
isTreePanelCollapsed.value = !isTreePanelCollapsed.value
}
</script>
<template>
<div class="app-layout">
<SidebarPanel />
<ViewerPanel />
<div
v-if="partsTreeStore.hasTree"
class="tree-panel-wrapper"
:style="treePanelStyle"
>
<button
class="tree-panel-toggle"
:class="{ collapsed: isTreePanelCollapsed }"
@click="toggleTreePanel"
:title="isTreePanelCollapsed ? 'Show Parts Tree' : 'Hide Parts Tree'"
>
<svg viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
</button>
<PartsTreePanel v-show="!isTreePanelCollapsed" />
</div>
</div>
</template>
<style scoped>
.tree-panel-wrapper {
position: relative;
flex-shrink: 0 !important;
transition: width 0.2s, min-width 0.2s;
/* Remove overflow: hidden to allow toggle button to be visible */
}
.tree-panel-toggle {
position: absolute;
left: -1.5rem;
top: 50%;
transform: translateY(-50%);
width: 1.5rem;
height: 3rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-right: none;
border-radius: 0.375rem 0 0 0.375rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.tree-panel-toggle:hover {
background: var(--bg-tertiary);
}
.tree-panel-toggle svg {
width: 1rem;
height: 1rem;
color: var(--text-secondary);
transition: transform 0.2s;
}
.tree-panel-toggle.collapsed svg {
transform: rotate(180deg);
}
</style>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import { useModelsStore } from '@/stores/models'
import SearchFilter from '@/components/models/SearchFilter.vue'
import UploadButton from '@/components/models/UploadButton.vue'
import ModelList from '@/components/models/ModelList.vue'
import ThemeToggle from '@/components/common/ThemeToggle.vue'
const modelsStore = useModelsStore()
</script>
<template>
<aside class="sidebar">
<div class="sidebar-header">
<h1>3D 模型</h1>
<div class="header-actions">
<ThemeToggle />
<UploadButton />
</div>
</div>
<div class="sidebar-content">
<SearchFilter
v-model="modelsStore.searchQuery"
placeholder="搜索模型..."
/>
<ModelList />
</div>
</aside>
</template>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useModelsStore } from '@/stores/models'
import { useViewerStore } from '@/stores/viewer'
import ModelViewer from '@/components/viewer/ModelViewer.vue'
import FeaturePanel from '@/components/viewer/FeaturePanel.vue'
const modelsStore = useModelsStore()
const viewerStore = useViewerStore()
const hasSelectedModel = computed(() => !!modelsStore.selectedModel)
const isModelReady = computed(() =>
modelsStore.selectedModel?.conversion_status === 'completed'
)
</script>
<template>
<main class="viewer-panel">
<div class="viewer-container">
<template v-if="hasSelectedModel">
<template v-if="isModelReady">
<ModelViewer :model-id="modelsStore.selectedModelId!" />
<FeaturePanel />
</template>
<div v-else class="empty-state">
<div v-if="viewerStore.isLoading" class="loading">
<div class="spinner"></div>
</div>
<template v-else>
<p>模型状态{{ modelsStore.selectedModel?.conversion_status }}</p>
<p class="text-sm">请等待转换完成</p>
</template>
</div>
</template>
<div v-else class="empty-state">
<p>选择一个模型查看</p>
<p class="text-sm">或从侧边栏上传新模型</p>
</div>
</div>
</main>
</template>
<style scoped>
.text-sm {
font-size: 14px;
color: var(--text-secondary);
}
</style>

View File

@@ -0,0 +1,262 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import type { Model } from '@/types/model'
const props = defineProps<{
model: Model
selected?: boolean
}>()
const emit = defineEmits<{
click: []
rename: []
delete: []
}>()
const showMenu = ref(false)
const menuRef = ref<HTMLElement | null>(null)
function handleClickOutside(e: MouseEvent) {
// Use setTimeout to allow button click handlers to complete first
// This prevents the menu from closing before the emit is processed
setTimeout(() => {
if (showMenu.value && menuRef.value && !menuRef.value.contains(e.target as Node)) {
showMenu.value = false
}
}, 0)
}
function handleRename() {
showMenu.value = false
emit('rename')
}
function handleDelete() {
showMenu.value = false
emit('delete')
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
const formatSize = (bytes: number): string => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
const formatDate = (date: string): string => {
return new Date(date).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
const statusLabel = computed(() => {
switch (props.model.conversion_status) {
case 'pending': return '等待中'
case 'processing': return '转换中...'
case 'completed': return '就绪'
case 'failed': return '失败'
default: return props.model.conversion_status
}
})
</script>
<template>
<div
class="model-card"
:class="{ selected, 'has-menu-open': showMenu }"
@click="$emit('click')"
>
<div class="model-card-content">
<img
v-if="model.thumbnail_url"
:src="model.thumbnail_url"
:alt="model.name"
class="thumbnail"
@error="(e) => console.error('Thumbnail load failed:', model.thumbnail_url, e)"
/>
<div v-else class="thumbnail placeholder">
<span>3D</span>
</div>
<div class="model-card-info">
<div class="name" :title="model.name">{{ model.name }}</div>
<div class="meta">
<span class="format">{{ model.original_format.toUpperCase() }}</span>
<span class="size">{{ formatSize(model.file_size) }}</span>
</div>
<div class="meta">
<span
class="status"
:class="model.conversion_status"
>
{{ statusLabel }}
</span>
<span class="date">{{ formatDate(model.created_at) }}</span>
</div>
</div>
<!-- Three-dot menu -->
<div ref="menuRef" class="model-card-menu">
<button
class="menu-trigger"
@click.stop="showMenu = !showMenu"
title="更多操作"
>
<svg viewBox="0 0 24 24" fill="currentColor" class="icon">
<circle cx="12" cy="5" r="2"/>
<circle cx="12" cy="12" r="2"/>
<circle cx="12" cy="19" r="2"/>
</svg>
</button>
<div v-if="showMenu" class="menu-dropdown" @click.stop>
<button @click="handleRename">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="icon">
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/>
</svg>
重命名
</button>
<button class="danger" @click="handleDelete">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="icon">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
删除
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.model-card-content {
position: relative;
}
.thumbnail.placeholder {
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-primary);
color: var(--text-secondary);
font-weight: 600;
font-size: 14px;
}
.format {
font-weight: 500;
color: var(--primary-color);
}
.date {
font-size: 11px;
}
/* Three-dot menu styles */
.model-card-menu {
position: absolute;
top: 8px;
right: 8px;
z-index: 100;
}
.menu-trigger {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: var(--radius-md);
background: var(--bg-secondary);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s ease;
opacity: 0.7;
}
.model-card:hover .menu-trigger,
.menu-trigger:focus {
opacity: 1;
}
.menu-trigger:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.menu-trigger .icon {
width: 16px;
height: 16px;
}
.menu-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
min-width: 120px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
overflow: hidden;
animation: dropdown-in 0.15s ease;
z-index: 1000;
}
@keyframes dropdown-in {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.menu-dropdown button {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 10px 12px;
border: none;
background: transparent;
color: var(--text-primary);
font-size: 13px;
text-align: left;
cursor: pointer;
transition: background 0.1s ease;
}
.menu-dropdown button:hover {
background: var(--bg-secondary);
}
.menu-dropdown button.danger {
color: var(--danger-color);
}
.menu-dropdown button.danger:hover {
background: rgba(239, 68, 68, 0.1);
}
.menu-dropdown button .icon {
width: 14px;
height: 14px;
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,106 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useModelsStore } from '@/stores/models'
import ModelCard from './ModelCard.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import RenameDialog from '@/components/common/RenameDialog.vue'
import type { Model } from '@/types/model'
const modelsStore = useModelsStore()
// Delete dialog state
const showDeleteDialog = ref(false)
const modelToDelete = ref<Model | null>(null)
// Rename dialog state
const showRenameDialog = ref(false)
const modelToRename = ref<Model | null>(null)
function openDeleteDialog(model: Model) {
modelToDelete.value = model
showDeleteDialog.value = true
}
function openRenameDialog(model: Model) {
modelToRename.value = model
showRenameDialog.value = true
}
async function confirmDelete() {
if (modelToDelete.value) {
await modelsStore.removeModel(modelToDelete.value.id)
}
showDeleteDialog.value = false
modelToDelete.value = null
}
async function confirmRename(newName: string) {
if (modelToRename.value) {
await modelsStore.renameModel(modelToRename.value.id, newName)
}
showRenameDialog.value = false
modelToRename.value = null
}
</script>
<template>
<div class="model-list">
<div v-if="modelsStore.isLoading && modelsStore.models.length === 0" class="loading">
<div class="spinner"></div>
</div>
<template v-else-if="modelsStore.filteredModels.length > 0">
<ModelCard
v-for="model in modelsStore.filteredModels"
:key="model.id"
:model="model"
:selected="model.id === modelsStore.selectedModelId"
@click="modelsStore.selectModel(model.id)"
@rename="openRenameDialog(model)"
@delete="openDeleteDialog(model)"
/>
</template>
<div v-else class="empty-message">
<p v-if="modelsStore.searchQuery">没有匹配的模型</p>
<p v-else>暂无模型上传一个开始吧</p>
</div>
<!-- Delete confirmation dialog -->
<ConfirmDialog
:show="showDeleteDialog"
title="删除模型"
:message="`确定要删除 '${modelToDelete?.name}' 吗?此操作不可撤销。`"
confirm-text="删除"
:danger="true"
@confirm="confirmDelete"
@cancel="showDeleteDialog = false"
/>
<!-- Rename dialog -->
<RenameDialog
:show="showRenameDialog"
:current-name="modelToRename?.name ?? ''"
@confirm="confirmRename"
@cancel="showRenameDialog = false"
/>
</div>
</template>
<style scoped>
.empty-message {
padding: 24px;
text-align: center;
color: var(--text-secondary);
}
.empty-message p {
font-size: 14px;
}
.loading {
padding: 24px;
display: flex;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
defineProps<{
modelValue: string
placeholder?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
function handleInput(event: Event) {
const target = event.target as HTMLInputElement
emit('update:modelValue', target.value)
}
</script>
<template>
<div class="search-bar">
<input
type="text"
class="input"
:value="modelValue"
:placeholder="placeholder || 'Search...'"
@input="handleInput"
/>
</div>
</template>

View File

@@ -0,0 +1,107 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useModelsStore } from '@/stores/models'
const modelsStore = useModelsStore()
const fileInput = ref<HTMLInputElement | null>(null)
const isUploading = ref(false)
const isDragover = ref(false)
const ALLOWED_EXTENSIONS = ['step', 'stp', 'stl', 'obj', 'glb', 'gltf', '3ds', 'fbx', 'iges', 'igs']
function openFileDialog() {
fileInput.value?.click()
}
function isValidFile(file: File): boolean {
const ext = file.name.split('.').pop()?.toLowerCase() || ''
return ALLOWED_EXTENSIONS.includes(ext)
}
async function handleFiles(files: FileList | null) {
if (!files || files.length === 0) return
const file = files[0]
if (!isValidFile(file)) {
alert(`Invalid file type. Allowed: ${ALLOWED_EXTENSIONS.join(', ')}`)
return
}
isUploading.value = true
try {
const model = await modelsStore.uploadModel(file)
if (model) {
modelsStore.selectModel(model.id)
}
} finally {
isUploading.value = false
if (fileInput.value) {
fileInput.value.value = ''
}
}
}
function handleFileChange(event: Event) {
const target = event.target as HTMLInputElement
handleFiles(target.files)
}
function handleDrop(event: DragEvent) {
event.preventDefault()
isDragover.value = false
handleFiles(event.dataTransfer?.files || null)
}
function handleDragOver(event: DragEvent) {
event.preventDefault()
isDragover.value = true
}
function handleDragLeave() {
isDragover.value = false
}
</script>
<template>
<div>
<button
class="btn btn-primary btn-sm"
:disabled="isUploading"
@click="openFileDialog"
>
<template v-if="isUploading">上传中...</template>
<template v-else>+ 上传</template>
</button>
<input
ref="fileInput"
type="file"
:accept="ALLOWED_EXTENSIONS.map(e => '.' + e).join(',')"
hidden
@change="handleFileChange"
/>
<!-- Hidden drop zone that covers the sidebar when dragging -->
<div
v-if="isDragover"
class="upload-area dragover"
@drop="handleDrop"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
>
<p>拖放文件到此处</p>
</div>
</div>
</template>
<style scoped>
.upload-area {
position: absolute;
top: 60px;
left: 0;
right: 0;
bottom: 0;
margin: 12px;
z-index: 10;
}
</style>

View File

@@ -0,0 +1,157 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { getPartsTreeService } from '@/services/partsTreeService'
const props = defineProps<{
uuid: string
}>()
const emit = defineEmits<{
colorChanged: [color: number]
}>()
const isOpen = ref(false)
const service = getPartsTreeService()
// Get CAD color palette from service
const colorPalette = computed(() => service.getColorPalette())
function togglePicker(event: Event) {
event.stopPropagation()
isOpen.value = !isOpen.value
}
function closePicker() {
isOpen.value = false
}
function selectColor(color: number, event: Event) {
event.stopPropagation()
service.setPartColor(props.uuid, color)
emit('colorChanged', color)
isOpen.value = false
}
function toHexString(color: number): string {
return '#' + color.toString(16).padStart(6, '0')
}
// Close picker when clicking outside
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement
if (!target.closest('.color-picker')) {
closePicker()
}
}
// Add/remove click listener when picker opens/closes
import { watch, onUnmounted } from 'vue'
watch(isOpen, (open) => {
if (open) {
setTimeout(() => {
document.addEventListener('click', handleClickOutside)
}, 0)
} else {
document.removeEventListener('click', handleClickOutside)
}
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>
<template>
<div class="color-picker">
<button
class="color-btn"
title="更改颜色"
@click="togglePicker"
>
<svg viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4 2a2 2 0 00-2 2v11a3 3 0 106 0V4a2 2 0 00-2-2H4zm1 14a1 1 0 100-2 1 1 0 000 2zm5-1.757l4.9-4.9a2 2 0 000-2.828L13.485 5.1a2 2 0 00-2.828 0L10 5.757v8.486zM16 18H9.071l6-6H16a2 2 0 012 2v2a2 2 0 01-2 2z" clip-rule="evenodd"/>
</svg>
</button>
<div v-if="isOpen" class="color-dropdown">
<div class="color-grid">
<button
v-for="color in colorPalette"
:key="color"
class="color-swatch"
:style="{ backgroundColor: toHexString(color) }"
:title="toHexString(color)"
@click="(e) => selectColor(color, e)"
/>
</div>
</div>
</div>
</template>
<style scoped>
.color-picker {
position: relative;
}
.color-btn {
width: 24px;
height: 24px;
padding: 4px;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, background-color 0.15s;
flex-shrink: 0;
}
.tree-node:hover .color-btn {
opacity: 1;
}
.color-btn:hover {
background: var(--bg-primary);
}
.color-btn svg {
width: 16px;
height: 16px;
color: var(--text-secondary);
}
.color-dropdown {
position: absolute;
top: 100%;
right: 0;
z-index: 100;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
min-width: 140px;
}
.color-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 4px;
}
.color-swatch {
width: 24px;
height: 24px;
border: 2px solid transparent;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
}
.color-swatch:hover {
transform: scale(1.15);
border-color: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
</style>

View File

@@ -0,0 +1,316 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { FlatTreeNode } from '@/types/partsTree'
import { usePartsTreeStore } from '@/stores/partsTree'
import { getExplodeService } from '@/services/explodeService'
import { useViewerStore } from '@/stores/viewer'
import ColorPicker from './ColorPicker.vue'
const props = defineProps<{
node: FlatTreeNode
searchQuery?: string
}>()
const partsTreeStore = usePartsTreeStore()
const viewerStore = useViewerStore()
const isHovered = computed(() => partsTreeStore.hoveredNodeId === props.node.id)
const isExploded = ref(false)
const indentStyle = computed(() => ({
paddingLeft: `${props.node.depth * 16 + 8}px`
}))
function handleToggleExpand() {
if (props.node.hasChildren) {
partsTreeStore.toggleExpanded(props.node.id)
}
}
function handleToggleVisible(event: Event) {
event.stopPropagation()
partsTreeStore.toggleVisible(props.node.id)
}
function handleToggleExplode(event: Event) {
event.stopPropagation()
const service = getExplodeService()
const nodeUuid = props.node.object.uuid
if (isExploded.value) {
service.animateResetPart(nodeUuid, 300)
isExploded.value = false
} else {
service.animateExplodePart(nodeUuid, 100, 300)
isExploded.value = true
}
viewerStore.forceRender()
}
function handleMouseEnter() {
partsTreeStore.highlightNode(props.node.id)
}
function handleMouseLeave() {
partsTreeStore.highlightNode(null)
}
function handleColorChanged() {
viewerStore.forceRender()
}
// Highlight matching text in search
function highlightText(text: string, query: string): string {
if (!query) return text
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi')
return text.replace(regex, '<mark>$1</mark>')
}
const displayName = computed(() => {
if (props.searchQuery) {
return highlightText(props.node.name, props.searchQuery)
}
return props.node.name
})
</script>
<template>
<div
class="tree-node"
:class="{ 'is-hovered': isHovered, 'is-hidden': !node.visible }"
:style="indentStyle"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@click="handleToggleExpand"
>
<span class="expand-icon" :class="{ 'has-children': node.hasChildren }">
<template v-if="node.hasChildren">
<svg v-if="node.isExpanded" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
<svg v-else viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
</template>
</span>
<span class="node-name" :title="node.name" v-html="displayName"></span>
<span v-if="node.childCount > 0" class="child-count">
({{ node.childCount }})
</span>
<button
class="explode-btn"
:class="{ 'is-exploded': isExploded }"
:title="isExploded ? '复位' : '爆炸'"
@click="handleToggleExplode"
>
<svg viewBox="0 0 20 20" fill="currentColor">
<path v-if="!isExploded" d="M10 2a1 1 0 011 1v4.586l2.293-2.293a1 1 0 111.414 1.414L11.414 10l3.293 3.293a1 1 0 01-1.414 1.414L11 12.414V17a1 1 0 11-2 0v-4.586l-2.293 2.293a1 1 0 01-1.414-1.414L8.586 10 5.293 6.707a1 1 0 011.414-1.414L9 7.586V3a1 1 0 011-1z"/>
<path v-else d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"/>
</svg>
</button>
<ColorPicker
:uuid="node.object.uuid"
@color-changed="handleColorChanged"
/>
<button
class="visibility-btn"
:class="{ 'is-visible': node.visible }"
:title="node.visible ? '隐藏' : '显示'"
@click="handleToggleVisible"
>
<svg v-if="node.visible" viewBox="0 0 20 20" fill="currentColor">
<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" />
</svg>
<svg v-else viewBox="0 0 20 20" fill="currentColor">
<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.742L2.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" />
</svg>
</button>
</div>
</template>
<style scoped>
.tree-node {
display: flex;
align-items: flex-start;
gap: var(--space-1);
padding: 6px 10px;
cursor: pointer;
border-radius: var(--radius-md);
user-select: none;
transition:
background-color var(--duration-fast) var(--ease-default),
transform var(--duration-fast) var(--ease-spring);
}
.tree-node:hover {
background: var(--bg-tertiary);
}
.tree-node:active {
transform: scale(0.995);
}
.tree-node.is-hovered {
background: var(--primary-subtle);
box-shadow: inset 0 0 0 1px var(--primary-color);
}
.tree-node.is-hidden {
opacity: 0.4;
}
.tree-node.is-hidden .node-name {
text-decoration: line-through;
text-decoration-color: var(--text-tertiary);
}
.expand-icon {
width: 18px;
height: 18px;
margin-top: 2px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.expand-icon svg {
width: 14px;
height: 14px;
color: var(--text-tertiary);
transition:
transform var(--duration-normal) var(--ease-spring),
color var(--duration-fast) var(--ease-default);
}
.expand-icon.has-children {
cursor: pointer;
}
.expand-icon.has-children:hover svg {
color: var(--text-primary);
transform: scale(1.1);
}
/* Chevron rotation when expanded */
.tree-node .expand-icon.has-children svg {
transform: rotate(-90deg);
}
.tree-node:has(.expand-icon.has-children) .expand-icon svg {
transform: rotate(-90deg);
}
.node-name {
flex: 1;
min-width: 60px;
font-size: 0.8125rem;
font-weight: 450;
line-height: 1.4;
/* 多行截断 - 最多显示2行 */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-word;
transition: color var(--duration-fast) var(--ease-default);
}
.tree-node:hover .node-name {
color: var(--text-primary);
}
.node-name :deep(mark) {
background: var(--warning-subtle);
color: var(--warning-color);
padding: 1px 4px;
border-radius: var(--radius-xs);
font-weight: 600;
}
.child-count {
font-size: 0.6875rem;
font-family: var(--font-mono);
color: var(--text-tertiary);
padding: 1px 5px;
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
margin-left: var(--space-1);
}
/* Action buttons base */
.visibility-btn,
.explode-btn {
width: 22px;
height: 22px;
padding: 3px;
background: transparent;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
opacity: 0;
flex-shrink: 0;
transition:
opacity var(--duration-fast) var(--ease-default),
background-color var(--duration-fast) var(--ease-default),
transform var(--duration-fast) var(--ease-spring);
}
.tree-node:hover .visibility-btn,
.tree-node:hover .explode-btn {
opacity: 1;
}
.visibility-btn:hover,
.explode-btn:hover {
background: var(--bg-sunken);
transform: scale(1.1);
}
.visibility-btn:active,
.explode-btn:active {
transform: scale(0.95);
}
.visibility-btn svg,
.explode-btn svg {
width: 16px;
height: 16px;
color: var(--text-tertiary);
transition: color var(--duration-fast) var(--ease-default);
}
.visibility-btn:hover svg {
color: var(--primary-color);
}
.visibility-btn.is-visible svg {
color: var(--primary-color);
}
.explode-btn:hover svg {
color: var(--warning-color);
}
.explode-btn.is-exploded {
opacity: 1;
}
.explode-btn.is-exploded svg {
color: var(--warning-color);
animation: explode-pulse 0.5s ease-out;
}
@keyframes explode-pulse {
0% { transform: scale(1); }
50% { transform: scale(1.3); }
100% { transform: scale(1); }
}
</style>

View File

@@ -0,0 +1,306 @@
<script setup lang="ts">
import { ref, watch, onUnmounted, computed } from 'vue'
import { usePartsTreeStore } from '@/stores/partsTree'
import PartsTreeNode from './PartsTreeNode.vue'
const partsTreeStore = usePartsTreeStore()
const searchInput = ref('')
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
// Debounced search
watch(searchInput, (query) => {
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer)
}
searchDebounceTimer = setTimeout(() => {
partsTreeStore.setSearchQuery(query)
}, 200)
})
onUnmounted(() => {
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer)
}
// Note: Don't call partsTreeStore.reset() here
// Tree reset is controlled by AppLayout watch on viewerStore.model
})
const displayedNodes = computed(() => {
return partsTreeStore.filteredFlatTree
})
function handleShowAll() {
partsTreeStore.showAll()
}
function handleHideAll() {
partsTreeStore.hideAll()
}
function handleExpandAll() {
partsTreeStore.expandAll()
}
function handleCollapseAll() {
partsTreeStore.collapseAll()
}
function handleClearSearch() {
searchInput.value = ''
partsTreeStore.setSearchQuery('')
}
</script>
<template>
<div class="parts-tree-panel" v-if="partsTreeStore.hasTree">
<div class="panel-header">
<h3>零件树</h3>
<span class="node-count">{{ partsTreeStore.nodeCount }} </span>
</div>
<div class="search-container">
<input
v-model="searchInput"
type="text"
class="input search-input"
placeholder="搜索零件..."
/>
<button
v-if="searchInput"
class="clear-btn"
@click="handleClearSearch"
>
<svg viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</div>
<div class="bulk-actions">
<button class="btn btn-sm btn-secondary" @click="handleShowAll" title="Show All">
<svg viewBox="0 0 20 20" fill="currentColor">
<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" />
</svg>
</button>
<button class="btn btn-sm btn-secondary" @click="handleHideAll" title="Hide All">
<svg viewBox="0 0 20 20" fill="currentColor">
<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.742L2.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" />
</svg>
</button>
<div class="divider"></div>
<button class="btn btn-sm btn-secondary" @click="handleExpandAll" title="Expand All">
<svg viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
<button class="btn btn-sm btn-secondary" @click="handleCollapseAll" title="Collapse All">
<svg viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z" clip-rule="evenodd" />
</svg>
</button>
</div>
<div class="tree-container">
<div v-if="displayedNodes.length === 0" class="empty-state">
<p v-if="partsTreeStore.searchQuery">没有匹配的零件</p>
<p v-else>未找到零件</p>
</div>
<template v-else>
<PartsTreeNode
v-for="node in displayedNodes"
:key="node.id"
:node="node"
:search-query="partsTreeStore.searchQuery"
/>
</template>
</div>
</div>
</template>
<style scoped>
.parts-tree-panel {
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg-secondary);
border-left: 1px solid var(--border-color);
transition:
background-color var(--duration-slow) var(--ease-default),
border-color var(--duration-slow) var(--ease-default);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4) var(--space-4);
border-bottom: 1px solid var(--border-color);
min-height: var(--header-height);
}
.panel-header h3 {
font-size: 0.9375rem;
font-weight: 600;
letter-spacing: -0.01em;
margin: 0;
display: flex;
align-items: center;
gap: var(--space-2);
}
/* Tree icon */
.panel-header h3::before {
content: '';
display: inline-block;
width: 16px;
height: 16px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%232563eb'%3E%3Cpath fill-rule='evenodd' d='M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z' clip-rule='evenodd'/%3E%3C/svg%3E");
background-size: contain;
background-repeat: no-repeat;
opacity: 0.8;
}
.node-count {
font-size: 0.75rem;
font-family: var(--font-mono);
font-weight: 500;
color: var(--text-tertiary);
padding: 2px 8px;
background: var(--bg-tertiary);
border-radius: var(--radius-full);
}
.search-container {
position: relative;
padding: var(--space-3) var(--space-4);
}
.search-input {
padding-right: 36px;
padding-left: var(--space-10);
}
/* Search icon */
.search-container::before {
content: '';
position: absolute;
left: calc(var(--space-4) + var(--space-3));
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2394a3b8' stroke-width='2'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z'/%3E%3C/svg%3E");
background-size: contain;
background-repeat: no-repeat;
pointer-events: none;
transition: opacity var(--duration-fast) var(--ease-default);
z-index: 1;
}
.clear-btn {
position: absolute;
right: calc(var(--space-4) + 8px);
top: 50%;
transform: translateY(-50%);
width: 24px;
height: 24px;
padding: 4px;
background: transparent;
border: none;
cursor: pointer;
border-radius: var(--radius-sm);
transition:
background-color var(--duration-fast) var(--ease-default),
transform var(--duration-fast) var(--ease-spring);
}
.clear-btn:hover {
background: var(--bg-tertiary);
transform: translateY(-50%) scale(1.1);
}
.clear-btn:active {
transform: translateY(-50%) scale(0.95);
}
.clear-btn svg {
width: 16px;
height: 16px;
color: var(--text-secondary);
transition: color var(--duration-fast) var(--ease-default);
}
.clear-btn:hover svg {
color: var(--danger-color);
}
.bulk-actions {
display: flex;
align-items: center;
gap: var(--space-1);
padding: 0 var(--space-4) var(--space-3);
}
.bulk-actions .btn {
padding: 6px 10px;
gap: var(--space-1);
}
.bulk-actions .btn svg {
width: 14px;
height: 14px;
transition: transform var(--duration-fast) var(--ease-spring);
}
.bulk-actions .btn:hover svg {
transform: scale(1.15);
}
.divider {
width: 1px;
height: 20px;
background: var(--border-color);
margin: 0 var(--space-2);
border-radius: 1px;
}
.tree-container {
flex: 1;
overflow-y: auto;
padding: var(--space-2) var(--space-3);
scroll-behavior: smooth;
}
/* Custom scrollbar */
.tree-container::-webkit-scrollbar {
width: 8px;
}
.tree-container::-webkit-scrollbar-track {
background: transparent;
}
.tree-container::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: var(--radius-full);
border: 2px solid var(--bg-secondary);
}
.tree-container::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
}
.empty-state {
padding: var(--space-8);
text-align: center;
color: var(--text-tertiary);
}
.empty-state p {
font-size: 0.875rem;
font-weight: 450;
}
</style>

View File

@@ -0,0 +1,391 @@
<script setup lang="ts">
import { ref, computed, onUnmounted, watch } from 'vue'
import { useViewerStore } from '@/stores/viewer'
import { usePartsTreeStore } from '@/stores/partsTree'
import { getPartsTreeService } from '@/services/partsTreeService'
import { getExplodeService } from '@/services/explodeService'
const viewerStore = useViewerStore()
const partsTreeStore = usePartsTreeStore()
// Color submenu state
const showColorSubmenu = ref(false)
// Get CAD color palette from service
const colorPalette = computed(() => getPartsTreeService().getColorPalette())
// Check if current part is exploded
const isExploded = computed(() => {
const partId = viewerStore.contextMenu.partId
if (!partId) return false
return getExplodeService().isPartExploded(partId)
})
// Get the node for the current part
const currentNode = computed(() => {
const partId = viewerStore.contextMenu.partId
if (!partId || !partsTreeStore.tree) return null
return getPartsTreeService().findNodeById(partsTreeStore.tree, partId)
})
// Check if the current part is visible
const isVisible = computed(() => {
return currentNode.value?.visible ?? true
})
// Menu position style
const menuStyle = computed(() => {
const menu = viewerStore.contextMenu
return {
left: `${menu.x}px`,
top: `${menu.y}px`,
}
})
// Hide the selected part
function handleHide() {
const partId = viewerStore.contextMenu.partId
if (partId) {
partsTreeStore.setVisible(partId, false)
}
viewerStore.hideContextMenu()
}
// Show the selected part (if hidden)
function handleShow() {
const partId = viewerStore.contextMenu.partId
if (partId) {
partsTreeStore.setVisibleIndependent(partId, true)
}
viewerStore.hideContextMenu()
}
// Isolate: show only this part, hide all others
function handleIsolate() {
const partId = viewerStore.contextMenu.partId
if (partId) {
partsTreeStore.isolate(partId)
}
viewerStore.hideContextMenu()
}
// Show all parts
function handleShowAll() {
partsTreeStore.showAll()
viewerStore.hideContextMenu()
}
// Make part transparent
function handleTransparent() {
const partId = viewerStore.contextMenu.partId
if (partId) {
partsTreeStore.setTransparent(partId, 0.3)
}
viewerStore.hideContextMenu()
}
// Zoom to fit (reset camera)
function handleZoomToFit() {
viewerStore.fitToView()
viewerStore.hideContextMenu()
}
// Reset all to initial state
function handleResetAll() {
partsTreeStore.resetAll()
viewerStore.hideContextMenu()
}
// Set color for the part
function handleSetColor(color: number) {
const partId = viewerStore.contextMenu.partId
if (partId) {
getPartsTreeService().setPartColor(partId, color)
viewerStore.forceRender()
}
showColorSubmenu.value = false
viewerStore.hideContextMenu()
}
// Toggle explosion for the part
function handleToggleExplode() {
const partId = viewerStore.contextMenu.partId
if (!partId) return
const service = getExplodeService()
if (isExploded.value) {
service.animateResetPart(partId, 300)
} else {
service.animateExplodePart(partId, 100, 300)
}
viewerStore.forceRender()
viewerStore.hideContextMenu()
}
// Convert color number to hex string
function toHexString(color: number): string {
return '#' + color.toString(16).padStart(6, '0')
}
// Handle click outside to close menu
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement
if (!target.closest('.context-menu')) {
viewerStore.hideContextMenu()
}
}
// Handle escape key to close menu
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
viewerStore.hideContextMenu()
}
}
// Add/remove event listeners when menu visibility changes
watch(() => viewerStore.contextMenu.visible, (visible) => {
if (visible) {
// Use setTimeout to avoid immediately closing from the same click
setTimeout(() => {
document.addEventListener('click', handleClickOutside)
document.addEventListener('keydown', handleKeyDown)
}, 0)
} else {
document.removeEventListener('click', handleClickOutside)
document.removeEventListener('keydown', handleKeyDown)
showColorSubmenu.value = false
}
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
document.removeEventListener('keydown', handleKeyDown)
})
</script>
<template>
<Teleport to="body">
<div
v-if="viewerStore.contextMenu.visible"
class="context-menu"
:style="menuStyle"
@click.stop
>
<!-- Hide/Show button -->
<button
v-if="isVisible"
class="menu-item"
@click="handleHide"
>
<svg viewBox="0 0 20 20" fill="currentColor" class="menu-icon">
<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.742L2.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" />
</svg>
<span>隐藏</span>
</button>
<button
v-else
class="menu-item"
@click="handleShow"
>
<svg viewBox="0 0 20 20" fill="currentColor" class="menu-icon">
<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" />
</svg>
<span>显示</span>
</button>
<!-- Isolate: show only this part -->
<button
class="menu-item"
@click="handleIsolate"
>
<svg viewBox="0 0 20 20" fill="currentColor" class="menu-icon">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd" />
</svg>
<span>独立显示</span>
</button>
<!-- Show All -->
<button
class="menu-item"
@click="handleShowAll"
>
<svg viewBox="0 0 20 20" fill="currentColor" class="menu-icon">
<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" />
</svg>
<span>显示全部</span>
</button>
<!-- Transparent -->
<button
class="menu-item"
@click="handleTransparent"
>
<svg viewBox="0 0 20 20" fill="currentColor" class="menu-icon">
<path fill-rule="evenodd" d="M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm12 12H4l4-8 3 6 2-4 3 6z" clip-rule="evenodd" />
</svg>
<span>透明</span>
</button>
<div class="menu-divider"></div>
<!-- Change Color with submenu -->
<div
class="menu-item has-submenu"
@mouseenter="showColorSubmenu = true"
@mouseleave="showColorSubmenu = false"
>
<svg viewBox="0 0 20 20" fill="currentColor" class="menu-icon">
<path fill-rule="evenodd" d="M4 2a2 2 0 00-2 2v11a3 3 0 106 0V4a2 2 0 00-2-2H4zm1 14a1 1 0 100-2 1 1 0 000 2zm5-1.757l4.9-4.9a2 2 0 000-2.828L13.485 5.1a2 2 0 00-2.828 0L10 5.757v8.486zM16 18H9.071l6-6H16a2 2 0 012 2v2a2 2 0 01-2 2z" clip-rule="evenodd"/>
</svg>
<span>更改颜色</span>
<svg viewBox="0 0 20 20" fill="currentColor" class="submenu-arrow">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
<!-- Color submenu -->
<div v-if="showColorSubmenu" class="color-submenu">
<button
v-for="color in colorPalette"
:key="color"
class="color-swatch"
:style="{ backgroundColor: toHexString(color) }"
:title="toHexString(color)"
@click="handleSetColor(color)"
/>
</div>
</div>
<!-- Explode/Reset button -->
<button
class="menu-item"
@click="handleToggleExplode"
>
<svg viewBox="0 0 20 20" fill="currentColor" class="menu-icon">
<path v-if="!isExploded" d="M10 2a1 1 0 011 1v4.586l2.293-2.293a1 1 0 111.414 1.414L11.414 10l3.293 3.293a1 1 0 01-1.414 1.414L11 12.414V17a1 1 0 11-2 0v-4.586l-2.293 2.293a1 1 0 01-1.414-1.414L8.586 10 5.293 6.707a1 1 0 011.414-1.414L9 7.586V3a1 1 0 011-1z"/>
<path v-else d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"/>
</svg>
<span>{{ isExploded ? '复位' : '爆炸' }}</span>
</button>
<div class="menu-divider"></div>
<!-- Zoom to Fit -->
<button
class="menu-item"
@click="handleZoomToFit"
>
<svg viewBox="0 0 20 20" fill="currentColor" class="menu-icon">
<path d="M9 9a2 2 0 114 0 2 2 0 01-4 0z" />
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-13a4 4 0 00-3.446 6.032l-2.261 2.26a1 1 0 101.414 1.415l2.261-2.261A4 4 0 1011 5z" clip-rule="evenodd" />
</svg>
<span>缩放适配</span>
</button>
<!-- Reset All -->
<button
class="menu-item"
@click="handleResetAll"
>
<svg viewBox="0 0 20 20" fill="currentColor" class="menu-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>
<span>全部复位</span>
</button>
</div>
</Teleport>
</template>
<style scoped>
.context-menu {
position: fixed;
z-index: 1000;
min-width: 160px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
padding: 4px 0;
user-select: none;
}
.menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
background: transparent;
border: none;
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
transition: background-color 0.15s;
text-align: left;
}
.menu-item:hover {
background: var(--bg-tertiary);
}
.menu-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
color: var(--text-secondary);
}
.menu-item:hover .menu-icon {
color: var(--text-primary);
}
.menu-divider {
height: 1px;
background: var(--border-color);
margin: 4px 8px;
}
.has-submenu {
position: relative;
}
.submenu-arrow {
width: 14px;
height: 14px;
margin-left: auto;
color: var(--text-secondary);
}
.color-submenu {
position: absolute;
left: 100%;
top: 0;
margin-left: 4px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
min-width: 120px;
}
.color-swatch {
width: 24px;
height: 24px;
border: 2px solid transparent;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
}
.color-swatch:hover {
transform: scale(1.15);
border-color: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
</style>

View File

@@ -0,0 +1,316 @@
<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>

View File

@@ -0,0 +1,153 @@
<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

@@ -0,0 +1,18 @@
<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

@@ -0,0 +1,860 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import * as OV from 'online-3d-viewer'
import * as THREE from 'three'
import { useViewerStore } from '@/stores/viewer'
import { useModelsStore } from '@/stores/models'
import { useThemeStore } from '@/stores/theme'
import { getModel, getModelLodUrls, fetchWithProgress } from '@/api/client'
import { getPartsTreeService } from '@/services/partsTreeService'
import { getClippingService, type Axis } from '@/services/clippingService'
import { getRenderService } from '@/services/renderService'
import { captureViewerScreenshot, uploadThumbnail } from '@/services/screenshotService'
import ContextMenu from './ContextMenu.vue'
import ViewCube from './ViewCube.vue'
const props = defineProps<{
modelId: string
}>()
const containerRef = ref<HTMLDivElement | null>(null)
const viewerStore = useViewerStore()
const modelsStore = useModelsStore()
const themeStore = useThemeStore()
let viewer: OV.EmbeddedViewer | null = null
// Theme-aware background colors
const LIGHT_BG = { r: 248, g: 249, b: 252 } // #f8f9fc
const DARK_BG = { r: 22, g: 24, b: 31 } // #16181f
let loadAbortController: AbortController | null = null
let loadCheckInterval: ReturnType<typeof setInterval> | null = null
let loadTimeoutId: ReturnType<typeof setTimeout> | null = null
// Maximum load timeout: 5 minutes for large files
const MAX_LOAD_TIMEOUT = 5 * 60 * 1000
onMounted(() => {
if (!containerRef.value) return
// Patch canvas.getContext to enable stencil buffer for WebGL
// This is needed for section cap rendering using stencil buffer technique
const originalGetContext = HTMLCanvasElement.prototype.getContext
HTMLCanvasElement.prototype.getContext = function(
this: HTMLCanvasElement,
contextType: string,
contextAttributes?: WebGLContextAttributes
) {
if (contextType === 'webgl' || contextType === 'webgl2') {
const attrs = { ...contextAttributes, stencil: true }
return originalGetContext.call(this, contextType, attrs)
}
return originalGetContext.call(this, contextType, contextAttributes)
} as typeof HTMLCanvasElement.prototype.getContext
// Initialize Online3DViewer with theme-aware background
const bg = themeStore.isDark ? DARK_BG : LIGHT_BG
viewer = new OV.EmbeddedViewer(containerRef.value, {
backgroundColor: new OV.RGBAColor(bg.r, bg.g, bg.b, 255),
defaultColor: new OV.RGBColor(180, 180, 180),
edgeSettings: new OV.EdgeSettings(false, new OV.RGBColor(0, 0, 0), 1),
})
// Restore original getContext after viewer is created
HTMLCanvasElement.prototype.getContext = originalGetContext
viewerStore.setViewer(viewer)
// Enable free orbit mode for unlimited rotation in all directions
const threeViewer = viewer.GetViewer()
if (threeViewer) {
const navigation = (threeViewer as unknown as { navigation?: { SetNavigationMode: (mode: number) => void } }).navigation
if (navigation) {
navigation.SetNavigationMode(OV.NavigationMode.FreeOrbit)
}
}
// Setup WebGL context loss handling after viewer is created
nextTick(() => {
setupWebGLErrorHandling()
setupPlaneDragEvents()
})
// Load initial model
loadModel(props.modelId)
})
onUnmounted(() => {
cancelLoading()
cleanupPlaneDragEvents()
if (viewer) {
viewer.Destroy()
viewer = null
}
viewerStore.setViewer(null)
viewerStore.resetFeatures()
})
watch(() => props.modelId, (newId) => {
if (newId) {
loadModel(newId)
}
})
// Watch for theme changes to update viewer background
watch(() => themeStore.resolvedTheme, (theme) => {
if (!viewer) return
const bg = theme === 'dark' ? DARK_BG : LIGHT_BG
const threeViewer = viewer.GetViewer()
if (threeViewer) {
const renderer = (threeViewer as unknown as { renderer?: THREE.WebGLRenderer }).renderer
if (renderer) {
const hexColor = (bg.r << 16) | (bg.g << 8) | bg.b
renderer.setClearColor(hexColor)
viewerStore.forceRender()
}
}
})
/**
* Setup WebGL context loss error handling
*/
function setupWebGLErrorHandling() {
const canvas = containerRef.value?.querySelector('canvas')
if (canvas) {
canvas.addEventListener('webglcontextlost', (e) => {
e.preventDefault()
console.error('WebGL context lost')
cancelLoading()
viewerStore.setError('WebGL 内存不足,模型过大无法加载。请尝试加载较小的模型。')
viewerStore.setLoading(false)
})
canvas.addEventListener('webglcontextrestored', () => {
console.log('WebGL context restored')
})
}
}
/**
* Setup industrial lighting for better model visibility from all angles
*/
function setupIndustrialLighting() {
const scene = viewerStore.scene
if (!scene) return
// Remove existing industrial lights (for model reload)
scene.children
.filter(child => child.name.startsWith('__industrial_'))
.forEach(light => scene.remove(light))
// 1. Ambient light - base brightness
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6)
ambientLight.name = '__industrial_ambient__'
scene.add(ambientLight)
// 2. Hemisphere light - sky/ground environment
const hemiLight = new THREE.HemisphereLight(0xffffff, 0x444444, 0.4)
hemiLight.name = '__industrial_hemi__'
hemiLight.position.set(0, 20, 0)
scene.add(hemiLight)
// 3. Multi-directional fill lights - ensure all angles are lit
const positions = [
[1, 1, 1], [-1, 1, 1], [1, 1, -1], [-1, 1, -1], // Top corners
[1, -1, 1], [-1, -1, 1] // Bottom fill
]
positions.forEach((pos, i) => {
const light = new THREE.DirectionalLight(0xffffff, 0.3)
light.name = `__industrial_dir_${i}__`
light.position.set(pos[0] * 10, pos[1] * 10, pos[2] * 10)
scene.add(light)
})
}
/**
* Auto-capture thumbnail after model loads
* Captures the current view and uploads to server
*/
async function autoCaptureThumbnail(modelId: string) {
// Wait for render to complete (2 frames + delay for all effects)
await new Promise(resolve => requestAnimationFrame(resolve))
await new Promise(resolve => requestAnimationFrame(resolve))
await new Promise(resolve => setTimeout(resolve, 800))
const renderer = viewerStore.renderer
const scene = viewerStore.scene
const camera = viewerStore.camera
if (!renderer || !scene || !camera) {
console.warn('Auto-capture: Viewer not ready')
return
}
try {
// Force a final render before capture
viewerStore.forceRender()
await new Promise(resolve => requestAnimationFrame(resolve))
const blob = await captureViewerScreenshot(renderer, scene, camera, 512)
const thumbnailUrl = await uploadThumbnail(modelId, blob)
// Update store to show new thumbnail immediately with cache-busting
const model = modelsStore.models.find(m => m.id === modelId)
if (model) {
// Add timestamp to force browser to fetch fresh image instead of cached old one
const cacheBustedUrl = `${thumbnailUrl}?t=${Date.now()}`
modelsStore.updateModelInStore({ ...model, thumbnail_url: cacheBustedUrl })
}
console.log('Auto-captured thumbnail for model:', modelId)
} catch (error) {
// Log detailed error for debugging
if (error instanceof Error) {
console.warn('Failed to auto-capture thumbnail:', error.message, error.stack)
} else {
console.warn('Failed to auto-capture thumbnail:', String(error))
}
}
}
/**
* Cancel ongoing loading
*/
function cancelLoading() {
if (loadAbortController) {
loadAbortController.abort()
loadAbortController = null
}
if (loadCheckInterval) {
clearInterval(loadCheckInterval)
loadCheckInterval = null
}
if (loadTimeoutId) {
clearTimeout(loadTimeoutId)
loadTimeoutId = null
}
}
/**
* Select optimal LOD level based on face count
* - Small models (<100k faces): LOD0 (full quality)
* - Medium models (100k-500k): LOD1 (50% quality)
* - Large models (>500k): LOD2 (25% quality)
*/
function selectOptimalLod(faces: number): number {
if (faces < 100000) return 0
if (faces < 500000) return 1
return 2
}
async function loadModel(modelId: string) {
if (!viewer) return
// Cancel any previous loading
cancelLoading()
viewerStore.setLoading(true)
viewerStore.setError(null)
viewerStore.setLoadingProgress(0, 'downloading')
// Clear color maps BEFORE resetFeatures to avoid stale mesh references
const partsService = getPartsTreeService()
partsService.clearColorMaps()
// Invalidate cached edges for new model
getRenderService().invalidateEdges()
viewerStore.resetFeatures()
// Create abort controller for this load
loadAbortController = new AbortController()
try {
// Get model info to determine face count for LOD selection
const modelInfo = await getModel(modelId)
const metadata = modelInfo.metadata as Record<string, unknown> | null
const faceCount = (metadata?.faces as number) || 0
// Get LOD URLs
let lodUrls: Record<string, string>
try {
lodUrls = await getModelLodUrls(modelId)
} catch {
// Fallback to legacy model_url if LOD not available
lodUrls = { lod0: modelInfo.model_url || '' }
}
// Select optimal LOD based on face count
const optimalLod = selectOptimalLod(faceCount)
const lodKey = `lod${optimalLod}`
const url = lodUrls[lodKey] || lodUrls.lod0 || modelInfo.model_url
if (!url) {
throw new Error('No model URL available')
}
console.log(`Loading model with ${faceCount} faces, using LOD${optimalLod}`)
viewerStore.setCurrentModelUrl(url)
// Extract filename from URL (before query params)
const urlPath = new URL(url).pathname
const filename = urlPath.split('/').pop() || 'model.glb'
// Download with progress tracking
const blob = await fetchWithProgress(
url,
(progress) => {
viewerStore.setLoadingProgress(progress, 'downloading')
},
loadAbortController.signal
)
// Switch to parsing stage
viewerStore.setLoadingProgress(100, 'parsing')
// Create File object with proper filename (online-3d-viewer needs extension)
const file = new File([blob], filename, { type: blob.type })
// Load model from File object
viewer.LoadModelFromFileList([file])
// Wait for model to load (Online3DViewer doesn't have a proper callback)
// We'll use a polling approach - check both model metadata AND mesh in scene
loadCheckInterval = setInterval(() => {
const model = viewer?.GetModel()
if (model) {
// Also check if model mesh is actually in the scene
const threeViewer = viewer?.GetViewer()
const scene = (threeViewer as unknown as { scene?: { children: Array<{ name: string; type: string }> } })?.scene
if (scene) {
// Check scene has non-system objects (actual model mesh)
const hasModelInScene = scene.children.some(child =>
!child.name.startsWith('__') &&
!child.type.includes('Light') &&
!child.type.includes('Camera') &&
!child.type.includes('Helper')
)
if (hasModelInScene) {
if (loadCheckInterval) {
clearInterval(loadCheckInterval)
loadCheckInterval = null
}
if (loadTimeoutId) {
clearTimeout(loadTimeoutId)
loadTimeoutId = null
}
viewerStore.setModel(model)
setupIndustrialLighting()
// Compute BVH for all meshes for fast raycasting (O(log n) instead of O(n))
// Wrapped in try-catch to prevent errors from blocking loading
try {
const threeScene = viewerStore.scene
if (threeScene) {
threeScene.traverse((object: THREE.Object3D) => {
if (object.type === 'Mesh' || (object as THREE.Mesh).isMesh) {
const mesh = object as THREE.Mesh
const geom = mesh.geometry as THREE.BufferGeometry & { boundsTree?: unknown; computeBoundsTree?: () => void }
if (geom && !geom.boundsTree && typeof geom.computeBoundsTree === 'function') {
geom.computeBoundsTree()
}
}
})
}
} catch (e) {
console.warn('BVH computation failed, using standard raycasting:', e)
}
viewerStore.setLoading(false)
// Cache mesh data for section cap worker (avoids blocking on drag end)
getClippingService().updateMeshDataCache()
// Set scene reference in partsService BEFORE applying auto-coloring
// This fixes the race condition where applyAutoColors runs before buildTree sets the scene
const threeSceneForColors = viewerStore.scene
if (threeSceneForColors) {
partsService.setScene(threeSceneForColors)
}
// Apply auto-coloring if enabled (global setting persists across models)
if (viewerStore.renderSettings.autoColorEnabled) {
partsService.applyAutoColors()
}
// Apply edges if enabled (global setting persists across models)
if (viewerStore.renderSettings.edgesEnabled) {
getRenderService().setEdgesEnabled(true)
}
viewerStore.forceRender()
// Auto-capture thumbnail after model loads with all effects applied
autoCaptureThumbnail(modelId)
}
}
}
}, 100)
// Timeout after MAX_LOAD_TIMEOUT (5 minutes)
loadTimeoutId = setTimeout(() => {
if (loadCheckInterval) {
clearInterval(loadCheckInterval)
loadCheckInterval = null
}
if (viewerStore.isLoading) {
viewerStore.setLoading(false)
viewerStore.setError('模型加载超时,文件可能过大或网络较慢。')
}
}, MAX_LOAD_TIMEOUT)
} catch (error) {
viewerStore.setLoading(false)
if (error instanceof Error) {
if (error.name === 'AbortError') {
// Loading was cancelled, don't show error
return
}
viewerStore.setError(error.message)
} else {
viewerStore.setError('加载模型失败')
}
}
}
// ==================== Plane Drag Interaction ====================
// Track if we're in rotation mode (prevents plane detection during rotation drag)
let isRotating = false
// Track pending finalizeDrag callback (to cancel if new interaction starts)
let pendingFinalizeId: number | null = null
// Track ALL axes that need finalizeDrag (persists across interactions until completed)
let pendingFinalizeAxes: Set<Axis> = new Set()
// Cached camera reference for light updates
let cachedCamera: THREE.Camera | null = null
// Cached canvas reference for accurate mouse coordinate calculation
let cachedCanvas: HTMLCanvasElement | null = null
// Track mouse down position for click vs drag detection
let mouseDownPos: { x: number; y: number } | null = null
// Raycaster for click detection - firstHitOnly for better performance
const raycaster = new THREE.Raycaster()
raycaster.firstHitOnly = true
/**
* Get normalized mouse coordinates (-1 to 1)
* Uses the canvas bounding rect for accurate coordinate calculation
*/
function getNormalizedMouse(event: MouseEvent): THREE.Vector2 {
// Use canvas rect for accurate coordinates (canvas may differ from container)
const element = cachedCanvas || containerRef.value!
const rect = element.getBoundingClientRect()
return new THREE.Vector2(
((event.clientX - rect.left) / rect.width) * 2 - 1,
-((event.clientY - rect.top) / rect.height) * 2 + 1
)
}
/**
* Get OrbitControls from viewer
*/
function getControls() {
const threeViewer = viewer?.GetViewer()
return (threeViewer as unknown as { navigation?: { camera?: { orbitEnabled: boolean } } })?.navigation?.camera
}
/**
* Handle mouse down - check for plane intersection
* Locks interaction mode: either plane drag or rotation (no switching mid-drag)
*/
function handleMouseDown(event: MouseEvent) {
if (!containerRef.value) return
// Record mouse down position for click detection
mouseDownPos = { x: event.clientX, y: event.clientY }
// Cancel any pending finalizeDrag from previous interaction
if (pendingFinalizeId !== null) {
clearTimeout(pendingFinalizeId)
pendingFinalizeId = null
}
const service = getClippingService()
if (!service.isInitialized()) {
isRotating = true // Default to rotation if service not ready
return
}
const mouse = getNormalizedMouse(event)
const hitAxis = service.checkPlaneIntersection(mouse)
if (hitAxis) {
// Plane drag mode
isRotating = false
service.startDrag(hitAxis)
// Disable orbit controls during drag
const controls = getControls()
if (controls) {
controls.orbitEnabled = false
}
containerRef.value.style.cursor = 'grabbing'
event.preventDefault()
event.stopPropagation()
} else {
// Rotation mode - lock this mode for the entire drag
isRotating = true
}
}
/**
* Handle mouse move - update drag or hover state
* If rotating, skip all plane detection
*/
function handleMouseMove(event: MouseEvent) {
if (!containerRef.value) return
// If rotating, update plane render order for correct occlusion
// and cancel any pending section cap generation (debounce)
if (isRotating) {
getClippingService().updatePlaneRenderOrder()
// Update camera light to follow camera direction
if (cachedCamera) {
getRenderService().updateCameraLight(cachedCamera)
}
if (pendingFinalizeId !== null) {
clearTimeout(pendingFinalizeId)
pendingFinalizeId = null
}
return
}
const service = getClippingService()
if (!service.isInitialized()) return
const mouse = getNormalizedMouse(event)
if (service.isDragging()) {
// Calculate new position during drag
const newPercent = service.drag(mouse)
if (newPercent !== null) {
const axis = service.getDragAxis()!
// Call setPlanePosition directly with isDragging=true for performance
// This skips expensive cap geometry regeneration during drag
// Don't update store during drag - this would trigger watcher and regenerate cap
service.setPlanePosition(axis, newPercent, true)
// Trigger render to show plane movement and clipping updates
viewerStore.forceRender()
}
containerRef.value.style.cursor = 'grabbing'
} else {
// Check for hover state (only when not dragging and not rotating)
const hoverAxis = service.checkPlaneIntersection(mouse)
containerRef.value.style.cursor = hoverAxis ? 'grab' : ''
}
}
/**
* Handle wheel event - update plane render order and camera light when zooming
*/
function handleWheel() {
getClippingService().updatePlaneRenderOrder()
// Update camera light to follow camera position
if (cachedCamera) {
getRenderService().updateCameraLight(cachedCamera)
}
}
/**
* Handle click for part selection
* Uses the parts tree to find the correct node for the clicked mesh
*/
function handleClick(event: MouseEvent) {
if (!containerRef.value || !mouseDownPos) return
// Check if this was a click (not a drag)
const dx = event.clientX - mouseDownPos.x
const dy = event.clientY - mouseDownPos.y
const distance = Math.sqrt(dx * dx + dy * dy)
// If moved more than 5 pixels, it's a drag, not a click
if (distance > 5) return
const scene = viewerStore.scene
const camera = viewerStore.camera
if (!scene || !camera) return
// Update camera matrices to ensure accurate raycasting after rotation
camera.updateMatrixWorld()
const mouse = getNormalizedMouse(event)
raycaster.setFromCamera(mouse, camera)
// Raycast against all objects, filtering out system objects
const intersects = raycaster.intersectObjects(scene.children, true)
.filter(hit => {
// Skip system objects (prefixed with '__')
if (hit.object.name.startsWith('__')) return false
// Skip non-mesh objects
if (!hit.object.type.includes('Mesh')) return false
// Skip hidden objects
if (!hit.object.visible) return false
return true
})
const partsService = getPartsTreeService()
if (intersects.length > 0) {
// Select the clicked mesh directly - simple and reliable
const clickedMesh = intersects[0].object
partsService.selectPart(clickedMesh)
viewerStore.setSelectedPart(clickedMesh.uuid)
viewerStore.forceRender()
} else {
// Click on empty space - clear selection
partsService.selectPart(null)
viewerStore.setSelectedPart(null)
viewerStore.forceRender()
}
}
/**
* Handle right-click context menu
* Shows a context menu with options for the clicked part
*/
function handleContextMenu(event: MouseEvent) {
event.preventDefault()
if (!containerRef.value) return
const scene = viewerStore.scene
const camera = viewerStore.camera
if (!scene || !camera) return
// Update camera matrices to ensure accurate raycasting after rotation
camera.updateMatrixWorld()
const mouse = getNormalizedMouse(event)
raycaster.setFromCamera(mouse, camera)
// Raycast against all objects, filtering out system objects
const intersects = raycaster.intersectObjects(scene.children, true)
.filter(hit => {
// Skip system objects (prefixed with '__')
if (hit.object.name.startsWith('__')) return false
// Skip non-mesh objects
if (!hit.object.type.includes('Mesh')) return false
// Skip hidden objects
if (!hit.object.visible) return false
return true
})
if (intersects.length > 0) {
// Found a part - show context menu
const clickedMesh = intersects[0].object
viewerStore.showContextMenu(event.clientX, event.clientY, clickedMesh.uuid)
} else {
// Right-click on empty space - hide context menu if visible
viewerStore.hideContextMenu()
}
}
/**
* Handle mouse up - end drag and reset rotation mode
*/
function handleMouseUp(event: MouseEvent) {
// Reset cursor IMMEDIATELY for responsive UX
if (containerRef.value) {
containerRef.value.style.cursor = ''
}
// Update plane render order and camera light after rotation (before resetting isRotating)
if (isRotating) {
getClippingService().updatePlaneRenderOrder()
if (cachedCamera) {
getRenderService().updateCameraLight(cachedCamera)
}
}
// Handle click for part selection (before resetting state)
const service = getClippingService()
if (!service.isDragging()) {
handleClick(event)
}
// Reset rotation mode
isRotating = false
mouseDownPos = null
if (service.isDragging()) {
const dragAxis = service.getDragAxis()
service.endDrag()
// Re-enable orbit controls immediately
const controls = getControls()
if (controls) {
controls.orbitEnabled = true
}
// Track this axis for finalization (don't sync store yet - let timeout handle it)
if (dragAxis) {
pendingFinalizeAxes.add(dragAxis)
}
}
// Schedule finalizeDrag for ALL pending axes (re-schedules after each interaction)
// This ensures section cap is generated after ALL interactions complete
if (pendingFinalizeAxes.size > 0) {
// Cancel any existing timeout first
if (pendingFinalizeId !== null) {
clearTimeout(pendingFinalizeId)
}
pendingFinalizeId = window.setTimeout(() => {
// Finalize ALL pending axes
pendingFinalizeAxes.forEach(axis => {
// Get current visual position (from THREE.Plane.constant)
const currentPercent = service.getPlanePositionPercent(axis)
// Generate section cap asynchronously
service.finalizeDrag(axis)
// Sync store with current visual position
viewerStore.setCrossSectionPosition(axis, currentPercent)
})
pendingFinalizeAxes.clear()
pendingFinalizeId = null
}, 500) as unknown as number // 500ms debounce - wait for user to finish all interactions
}
}
/**
* Setup plane drag event listeners
* Uses capture mode to intercept events before OrbitControls
*/
function setupPlaneDragEvents() {
const canvas = containerRef.value?.querySelector('canvas')
if (!canvas) return
// Cache canvas reference for accurate mouse coordinate calculation
cachedCanvas = canvas
// Set camera reference for clipping service and cache for light updates
const threeViewer = viewer?.GetViewer()
const camera = (threeViewer as unknown as { camera?: THREE.Camera })?.camera
if (camera) {
getClippingService().setCamera(camera)
cachedCamera = camera
// Initialize camera light position
getRenderService().updateCameraLight(camera)
}
// Add event listeners with capture mode to intercept before OrbitControls
canvas.addEventListener('mousedown', handleMouseDown, { capture: true })
canvas.addEventListener('mousemove', handleMouseMove, { capture: true })
canvas.addEventListener('mouseup', handleMouseUp, { capture: true })
canvas.addEventListener('mouseleave', handleMouseUp, { capture: true })
// Wheel listener for zoom - update plane render order
canvas.addEventListener('wheel', handleWheel, { passive: true })
// Context menu listener for right-click (capture mode to intercept before OrbitControls)
canvas.addEventListener('contextmenu', handleContextMenu, { capture: true })
}
/**
* Cleanup plane drag event listeners
*/
function cleanupPlaneDragEvents() {
const canvas = containerRef.value?.querySelector('canvas')
if (!canvas) return
canvas.removeEventListener('mousedown', handleMouseDown, { capture: true })
canvas.removeEventListener('mousemove', handleMouseMove, { capture: true })
canvas.removeEventListener('mouseup', handleMouseUp, { capture: true })
canvas.removeEventListener('mouseleave', handleMouseUp, { capture: true })
canvas.removeEventListener('wheel', handleWheel)
canvas.removeEventListener('contextmenu', handleContextMenu, { capture: true })
// Clear cached references
cachedCanvas = null
}
</script>
<template>
<div ref="containerRef" class="model-viewer">
<ContextMenu />
<ViewCube v-if="viewerStore.model" />
<div v-if="viewerStore.isLoading" class="loading-indicator">
<div class="spinner"></div>
<span class="loading-text">
{{ viewerStore.loadingStage === 'downloading' ? '下载中' : '解析中' }}...
</span>
</div>
<div v-if="viewerStore.error" class="viewer-overlay error">
<p>{{ viewerStore.error }}</p>
</div>
</div>
</template>
<style scoped>
.model-viewer {
width: 100%;
height: 100%;
position: relative;
}
/* Loading indicator - bottom left corner */
.loading-indicator {
position: absolute;
bottom: 20px;
left: 20px;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(8px);
border-radius: 20px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
z-index: 10;
}
.loading-indicator .spinner {
width: 18px;
height: 18px;
border-width: 2px;
}
.loading-indicator .loading-text {
font-size: 13px;
color: var(--text-secondary);
margin: 0;
white-space: nowrap;
}
/* Error overlay */
.viewer-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 10;
}
.viewer-overlay p {
margin-top: 12px;
color: var(--text-primary);
}
.viewer-overlay.error {
background: rgba(239, 68, 68, 0.2);
}
.viewer-overlay.error p {
color: var(--danger-color);
max-width: 400px;
text-align: center;
padding: 20px;
background: var(--bg-secondary);
border-radius: 8px;
}
</style>

View File

@@ -0,0 +1,481 @@
<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

@@ -0,0 +1,288 @@
<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,73 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useViewerStore } from '@/stores/viewer'
import {
getViewCubeService,
resetViewCubeService,
type ViewDirection,
} from '@/services/viewCubeService'
const viewerStore = useViewerStore()
const containerRef = ref<HTMLElement | null>(null)
let renderLoopId: number | null = null
onMounted(() => {
if (containerRef.value) {
const service = getViewCubeService()
service.initialize(
containerRef.value,
(direction: ViewDirection) => {
viewerStore.animateCameraToView(direction)
},
(deltaX: number, deltaY: number) => {
viewerStore.rotateCamera(deltaX, deltaY)
},
(direction: ViewDirection) => {
// When directly facing a face and clicking it, rotate 90° clockwise
viewerStore.rotateCameraAroundAxis(direction)
}
)
// Start render loop to keep ViewCube in sync with main camera
startRenderLoop()
}
})
function startRenderLoop(): void {
const sync = () => {
const service = getViewCubeService()
if (service.isInitialized() && viewerStore.camera) {
service.syncWithMainCamera(viewerStore.camera)
}
renderLoopId = requestAnimationFrame(sync)
}
renderLoopId = requestAnimationFrame(sync)
}
onUnmounted(() => {
if (renderLoopId !== null) {
cancelAnimationFrame(renderLoopId)
}
resetViewCubeService()
})
</script>
<template>
<div class="viewcube-container" ref="containerRef"></div>
</template>
<style scoped>
.viewcube-container {
position: absolute;
top: 16px;
right: 16px;
width: 100px;
height: 100px;
z-index: 20;
border-radius: 8px;
overflow: hidden;
background: rgba(0, 0, 0, 0.2);
backdrop-filter: blur(4px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
</style>

16
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,16 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import './style.css'
import { useThemeStore } from '@/stores/theme'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
// Initialize theme before mounting to prevent flash
const themeStore = useThemeStore(pinia)
themeStore.initialize()
app.mount('#app')

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,658 @@
import * as THREE from 'three'
import { getRenderService } from './renderService'
import { getClippingService } from './clippingService'
import { getPartsTreeService } from './partsTreeService'
interface PartData {
mesh: THREE.Object3D
uuid: string
originalPosition: THREE.Vector3
direction: THREE.Vector3 // Direction relative to parent (or model center for roots)
distance: number // Distance to parent center (or model center for roots)
isExploded: boolean // For per-part explosion tracking
parentUuid: string | null // Parent part UUID (null for root parts)
childrenUuids: string[] // Children part UUIDs
depth: number // Hierarchy depth (0 for roots)
}
export class ExplodeService {
private parts: PartData[] = []
private partsMap: Map<string, PartData> = new Map()
private modelCenter: THREE.Vector3 = new THREE.Vector3()
private maxExplosionDistance: number = 1
private initialized: boolean = false
private currentFactor: number = 0
private animationId: number | null = null
private onRenderCallback: (() => void) | null = null
/**
* Initialize explosion data from the Three.js scene
* Builds hierarchical parent-child relationships for recursive explosion
*/
initializeFromScene(scene: THREE.Scene, onRender?: () => void): void {
this.parts = []
this.partsMap.clear()
this.initialized = false
this.currentFactor = 0
this.onRenderCallback = onRender || null
// Calculate overall bounding box for model center
const modelBox = new THREE.Box3().setFromObject(scene)
if (modelBox.isEmpty()) {
console.warn('Scene bounding box is empty')
return
}
modelBox.getCenter(this.modelCenter)
// Calculate max dimension for scaling explosion distance
const modelSize = new THREE.Vector3()
modelBox.getSize(modelSize)
this.maxExplosionDistance = Math.max(modelSize.x, modelSize.y, modelSize.z) * 0.5
// Pass 1: Collect all mesh parts
scene.traverse((object) => {
const isMesh = object.type === 'Mesh' || (object as unknown as { isMesh?: boolean }).isMesh === true
if (isMesh) {
const mesh = object as THREE.Mesh
if (!mesh.geometry) return
const partData: PartData = {
mesh: object,
uuid: object.uuid,
originalPosition: object.position.clone(),
direction: new THREE.Vector3(),
distance: 0,
isExploded: false,
parentUuid: null,
childrenUuids: [],
depth: 0,
}
this.parts.push(partData)
this.partsMap.set(object.uuid, partData)
}
})
// Pass 2: Build parent-child relationships (only mesh parents)
for (const part of this.parts) {
let parent = part.mesh.parent
while (parent && parent.type !== 'Scene') {
if (this.partsMap.has(parent.uuid)) {
part.parentUuid = parent.uuid
this.partsMap.get(parent.uuid)!.childrenUuids.push(part.uuid)
break
}
parent = parent.parent
}
}
// Pass 3: Calculate depth and explosion directions relative to parent
for (const part of this.parts) {
// Calculate depth
part.depth = this.calculateDepth(part.uuid)
// Get parent center for direction calculation
let parentCenter: THREE.Vector3
if (part.parentUuid) {
// Has mesh parent - use parent's center
parentCenter = this.getPartCenter(part.parentUuid)
} else {
// Root part - try to find a Group ancestor for better explosion direction
const groupCenter = this.findGroupParentCenter(part.mesh)
parentCenter = groupCenter || this.modelCenter
}
// Get this part's center
const partCenter = this.getPartCenter(part.uuid)
// Calculate direction from parent center to part center
const direction = new THREE.Vector3().subVectors(partCenter, parentCenter)
const distance = direction.length()
if (distance > 0.001) {
direction.normalize()
} else {
// For parts at same center as parent, use a pseudo-random direction
direction.set(
Math.sin(part.mesh.id * 0.1),
Math.cos(part.mesh.id * 0.2),
Math.sin(part.mesh.id * 0.3)
).normalize()
}
part.direction = direction
part.distance = distance
}
this.initialized = this.parts.length > 0
const rootCount = this.parts.filter(p => !p.parentUuid).length
const maxDepth = Math.max(...this.parts.map(p => p.depth), 0)
console.log(`ExplodeService: Initialized with ${this.parts.length} parts (${rootCount} roots, max depth: ${maxDepth})`)
}
/**
* Calculate hierarchy depth for a part
*/
private calculateDepth(uuid: string): number {
const part = this.partsMap.get(uuid)
if (!part || !part.parentUuid) return 0
return 1 + this.calculateDepth(part.parentUuid)
}
/**
* Get the world-space center of a part
*/
private getPartCenter(uuid: string): THREE.Vector3 {
const part = this.partsMap.get(uuid)
if (!part) return new THREE.Vector3()
const box = new THREE.Box3().setFromObject(part.mesh)
return box.getCenter(new THREE.Vector3())
}
/**
* Get the world-space center of any Object3D
*/
private getObjectCenter(object: THREE.Object3D): THREE.Vector3 {
const box = new THREE.Box3().setFromObject(object)
return box.getCenter(new THREE.Vector3())
}
/**
* Find the center of the nearest Group ancestor with multiple mesh descendants
* Used for calculating explosion direction for root parts
*/
private findGroupParentCenter(mesh: THREE.Object3D): THREE.Vector3 | null {
let parent = mesh.parent
while (parent && parent.type !== 'Scene') {
const meshCount = this.countMeshDescendants(parent)
if (meshCount > 1) {
return this.getObjectCenter(parent)
}
parent = parent.parent
}
return null
}
/**
* Check if an object is a mesh
*/
private isMesh(object: THREE.Object3D): boolean {
return object.type === 'Mesh' ||
(object as unknown as { isMesh?: boolean }).isMesh === true
}
/**
* Count mesh descendants of an object (excluding the object itself)
*/
private countMeshDescendants(object: THREE.Object3D): number {
let count = 0
object.traverse((child) => {
if (child !== object && this.isMesh(child)) {
count++
}
})
return count
}
/**
* Convert a world-space direction vector to local-space for a given parent object
* This is needed because mesh.position is in local coordinates, but our explosion
* directions are calculated in world coordinates
*/
private worldToLocalDirection(parent: THREE.Object3D, worldDir: THREE.Vector3): THREE.Vector3 {
// Get the inverse of the parent's world matrix
const parentWorldMatrixInverse = new THREE.Matrix4()
parentWorldMatrixInverse.copy(parent.matrixWorld).invert()
// Transform direction only (rotation/scale, not translation)
// Note: transformDirection normalizes the result, so we need to restore length
const length = worldDir.length()
const localDir = worldDir.clone()
localDir.transformDirection(parentWorldMatrixInverse)
localDir.setLength(length)
return localDir
}
/**
* Apply explosion with given factor (0 = collapsed, 100 = fully exploded)
* Uses hierarchical recursive explosion based on assembly tree structure
* @param factor - Explosion factor (0-100)
* @param isDragging - If true, skip expensive sync operations for better performance during slider drag
*/
applyExplosion(factor: number, isDragging: boolean = false): void {
if (!this.initialized) return
this.currentFactor = factor
if (factor === 0) {
// Reset all parts to original position
this.parts.forEach((part) => {
if (part.mesh && part.mesh.parent) {
part.mesh.position.copy(part.originalPosition)
}
})
} else {
// Normalize factor from 0-100 to 0-1
const normalizedFactor = Math.max(0, Math.min(1, factor / 100))
// Scale factor for visible effect
const explosionScale = normalizedFactor * this.maxExplosionDistance * 1.5
// Find root parts (no parent) and recursively apply explosion
const rootParts = this.parts.filter(p => !p.parentUuid)
for (const root of rootParts) {
this.applyExplosionRecursive(root, explosionScale, new THREE.Vector3())
}
}
// Sync edge lines with new positions (now O(n) after optimization, fast enough for dragging)
getRenderService().syncEdgeTransforms()
// Skip expensive operations during dragging for smooth slider interaction
if (!isDragging) {
// Update clipping bounds when explosion changes
getClippingService().updateBounds()
// Sync selection overlay positions
getPartsTreeService().syncSelectionOverlays()
}
}
/**
* Recursively apply explosion offset, accumulating parent offsets
* parentOffset is in world coordinates and gets converted to local coordinates for each part
*/
private applyExplosionRecursive(
part: PartData,
explosionScale: number,
parentWorldOffset: THREE.Vector3 // World-space accumulated offset
): void {
// Check if mesh is still valid
if (!part.mesh || !part.mesh.parent) return
// Calculate this part's explosion offset in world coordinates
// (direction is already in world coordinates from initialization)
const partWorldOffset = part.direction.clone().multiplyScalar(
part.distance > 0.001 ? explosionScale : explosionScale * 0.5
)
// Total world offset = parent's accumulated world offset + this part's world offset
const totalWorldOffset = parentWorldOffset.clone().add(partWorldOffset)
// Convert world-space offset to local-space for this mesh's parent
const localOffset = this.worldToLocalDirection(part.mesh.parent, totalWorldOffset)
// Apply local offset to local position
part.mesh.position.copy(part.originalPosition).add(localOffset)
// Recursively apply to children with accumulated world offset
for (const childUuid of part.childrenUuids) {
const childPart = this.partsMap.get(childUuid)
if (childPart) {
this.applyExplosionRecursive(childPart, explosionScale, totalWorldOffset)
}
}
}
/**
* Animate explosion to target factor
*/
animateExplosion(
targetFactor: number,
duration: number = 500,
onComplete?: () => void
): void {
if (!this.initialized) return
// Cancel any existing animation
this.cancelAnimation()
const startFactor = this.currentFactor
const startTime = performance.now()
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / duration, 1)
// Ease out cubic
const eased = 1 - Math.pow(1 - progress, 3)
const factor = startFactor + (targetFactor - startFactor) * eased
this.applyExplosionDirect(factor)
if (this.onRenderCallback) {
this.onRenderCallback()
}
if (progress < 1) {
this.animationId = requestAnimationFrame(animate)
} else {
this.animationId = null
this.currentFactor = targetFactor
// Update clipping bounds when animation completes
getClippingService().updateBounds()
if (onComplete) onComplete()
}
}
this.animationId = requestAnimationFrame(animate)
}
/**
* Direct apply explosion (used by animation, bypasses per-part logic)
* Uses hierarchical recursive explosion
*/
private applyExplosionDirect(factor: number): void {
this.currentFactor = factor
if (factor === 0) {
this.parts.forEach((part) => {
part.mesh.position.copy(part.originalPosition)
})
} else {
const normalizedFactor = Math.max(0, Math.min(1, factor / 100))
const explosionScale = normalizedFactor * this.maxExplosionDistance * 1.5
// Find root parts and recursively apply explosion
const rootParts = this.parts.filter(p => !p.parentUuid)
for (const root of rootParts) {
this.applyExplosionRecursive(root, explosionScale, new THREE.Vector3())
}
}
// Sync edge lines with new positions
getRenderService().syncEdgeTransforms()
// Sync selection overlay positions
getPartsTreeService().syncSelectionOverlays()
}
/**
* Cancel any running animation
*/
cancelAnimation(): void {
if (this.animationId !== null) {
cancelAnimationFrame(this.animationId)
this.animationId = null
}
}
/**
* Check if animation is running
*/
isAnimating(): boolean {
return this.animationId !== null
}
/**
* Explode a single part by UUID (also moves children)
*/
explodePart(uuid: string, factor: number = 100): void {
const part = this.partsMap.get(uuid)
if (!part || !part.mesh.parent) return
part.isExploded = true
const normalizedFactor = Math.max(0, Math.min(1, factor / 100))
const explosionScale = normalizedFactor * this.maxExplosionDistance * 1.5
// Calculate offset in world coordinates
const worldOffset = part.direction.clone().multiplyScalar(
part.distance > 0.001 ? explosionScale : explosionScale * 0.5
)
// Convert to local coordinates for this part
const localOffset = this.worldToLocalDirection(part.mesh.parent, worldOffset)
// Move this part
part.mesh.position.copy(part.originalPosition).add(localOffset)
// Move all descendants by the same world offset (each converted to their local space)
this.moveDescendants(part, worldOffset)
// Sync edge lines with new position
getRenderService().syncEdgeTransforms()
// Sync selection overlay positions
getPartsTreeService().syncSelectionOverlays()
if (this.onRenderCallback) {
this.onRenderCallback()
}
}
/**
* Recursively move all descendants by a world-space offset
*/
private moveDescendants(part: PartData, worldOffset: THREE.Vector3): void {
for (const childUuid of part.childrenUuids) {
const child = this.partsMap.get(childUuid)
if (child && child.mesh.parent) {
// Convert world offset to child's local coordinate system
const localOffset = this.worldToLocalDirection(child.mesh.parent, worldOffset)
child.mesh.position.copy(child.originalPosition).add(localOffset)
this.moveDescendants(child, worldOffset)
}
}
}
/**
* Animate explode a single part by UUID (also moves children)
*/
animateExplodePart(
uuid: string,
targetFactor: number = 100,
duration: number = 300
): void {
const part = this.partsMap.get(uuid)
if (!part || !part.mesh.parent) return
// Collect start positions for part and all descendants
const startPositions = new Map<string, THREE.Vector3>()
startPositions.set(uuid, part.mesh.position.clone())
this.collectDescendantPositions(part, startPositions)
const normalizedFactor = Math.max(0, Math.min(1, targetFactor / 100))
const explosionScale = normalizedFactor * this.maxExplosionDistance * 1.5
// Calculate world offset
const worldOffset = part.direction.clone().multiplyScalar(
part.distance > 0.001 ? explosionScale : explosionScale * 0.5
)
// Convert to local offset for target position
const localOffset = this.worldToLocalDirection(part.mesh.parent, worldOffset)
const targetPosition = part.originalPosition.clone().add(localOffset)
const startTime = performance.now()
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / duration, 1)
const eased = 1 - Math.pow(1 - progress, 3)
// Move main part
const startPos = startPositions.get(uuid)!
part.mesh.position.lerpVectors(startPos, targetPosition, eased)
// Move all descendants by interpolated world offset (converted to local for each)
const currentWorldOffset = worldOffset.clone().multiplyScalar(eased)
this.animateDescendants(part, startPositions, currentWorldOffset)
// Sync edge lines with new position
getRenderService().syncEdgeTransforms()
// Sync selection overlay positions
getPartsTreeService().syncSelectionOverlays()
if (this.onRenderCallback) {
this.onRenderCallback()
}
if (progress < 1) {
requestAnimationFrame(animate)
} else {
part.isExploded = targetFactor > 0
}
}
requestAnimationFrame(animate)
}
/**
* Collect start positions for all descendants
*/
private collectDescendantPositions(part: PartData, positions: Map<string, THREE.Vector3>): void {
for (const childUuid of part.childrenUuids) {
const child = this.partsMap.get(childUuid)
if (child) {
positions.set(childUuid, child.mesh.position.clone())
this.collectDescendantPositions(child, positions)
}
}
}
/**
* Animate descendants during single-part animation
* worldOffset is in world coordinates
*/
private animateDescendants(
part: PartData,
startPositions: Map<string, THREE.Vector3>,
worldOffset: THREE.Vector3
): void {
for (const childUuid of part.childrenUuids) {
const child = this.partsMap.get(childUuid)
if (child && child.mesh.parent) {
const startPos = startPositions.get(childUuid)
if (startPos) {
// Convert world offset to child's local coordinate system
const localOffset = this.worldToLocalDirection(child.mesh.parent, worldOffset)
child.mesh.position.copy(child.originalPosition).add(localOffset)
}
this.animateDescendants(child, startPositions, worldOffset)
}
}
}
/**
* Reset a single part to original position (also resets children)
*/
resetPart(uuid: string): void {
const part = this.partsMap.get(uuid)
if (!part) return
part.isExploded = false
part.mesh.position.copy(part.originalPosition)
// Reset all descendants too
this.resetDescendants(part)
// Sync edge lines with new position
getRenderService().syncEdgeTransforms()
// Sync selection overlay positions
getPartsTreeService().syncSelectionOverlays()
if (this.onRenderCallback) {
this.onRenderCallback()
}
}
/**
* Recursively reset descendants to original positions
*/
private resetDescendants(part: PartData): void {
for (const childUuid of part.childrenUuids) {
const child = this.partsMap.get(childUuid)
if (child) {
child.isExploded = false
child.mesh.position.copy(child.originalPosition)
this.resetDescendants(child)
}
}
}
/**
* Animate reset a single part
*/
animateResetPart(uuid: string, duration: number = 300): void {
this.animateExplodePart(uuid, 0, duration)
}
/**
* Get current explosion factor
*/
getCurrentFactor(): number {
return this.currentFactor
}
/**
* Reset to original positions
*/
reset(): void {
this.cancelAnimation()
this.parts.forEach((part) => {
part.mesh.position.copy(part.originalPosition)
part.isExploded = false
})
this.currentFactor = 0
// Sync edge lines with reset positions
getRenderService().syncEdgeTransforms()
// Update clipping bounds when reset
getClippingService().updateBounds()
}
/**
* Check if service is initialized
*/
isInitialized(): boolean {
return this.initialized
}
/**
* Get number of parts
*/
getPartsCount(): number {
return this.parts.length
}
/**
* Check if a part is exploded
*/
isPartExploded(uuid: string): boolean {
const part = this.partsMap.get(uuid)
return part?.isExploded ?? false
}
/**
* Get exploded parts UUIDs
*/
getExplodedParts(): string[] {
return this.parts.filter(p => p.isExploded).map(p => p.uuid)
}
}
// Singleton instance
let explodeService: ExplodeService | null = null
export function getExplodeService(): ExplodeService {
if (!explodeService) {
explodeService = new ExplodeService()
}
return explodeService
}
export function resetExplodeService(): void {
if (explodeService) {
explodeService.reset()
}
explodeService = null
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,823 @@
import * as THREE from 'three'
import { LineSegments2 } from 'three/examples/jsm/lines/LineSegments2.js'
import { LineMaterial } from 'three/examples/jsm/lines/LineMaterial.js'
import { LineSegmentsGeometry } from 'three/examples/jsm/lines/LineSegmentsGeometry.js'
import { getClippingService } from './clippingService'
// Render mode enum
export enum RenderMode {
Standard = 'standard', // Standard solid rendering (default)
HiddenLine = 'hiddenLine', // Hidden line removal mode
Wireframe = 'wireframe', // Wireframe mode (all edges visible)
}
export class RenderService {
private scene: THREE.Scene | null = null
private renderer: THREE.WebGLRenderer | null = null
private edgesGroup: THREE.Group | null = null
private edgesEnabled: boolean = false
private edgeColor: THREE.Color = new THREE.Color(0x000000)
private edgeOpacity: number = 0.8
private edgeLineWidth: number = 1
private initialized: boolean = false
private lightsGroup: THREE.Group | null = null
private lightsInitialized: boolean = false
private cameraLight: THREE.DirectionalLight | null = null
private ambientLight: THREE.AmbientLight | null = null
private keyLight: THREE.DirectionalLight | null = null
// Render mode properties
private renderMode: RenderMode = RenderMode.Standard
private renderModeMaterials: Map<string, THREE.Material | THREE.Material[]> = new Map()
private hiddenMeshes: Set<string> = new Set()
/**
* Initialize with Three.js scene and renderer
*/
initialize(scene: THREE.Scene, renderer?: THREE.WebGLRenderer | null): void {
this.scene = scene
this.renderer = renderer || null
this.initialized = true
// Setup lighting on initialization
this.setupLighting()
// Setup environment - skipped due to PMREMGenerator conflicts
this.setupEnvironment()
// Configure renderer for transparent materials
this.configureRendererForTransparency()
console.log('RenderService: Initialized')
}
/**
* Configure renderer settings for transparent/glass materials
*/
private configureRendererForTransparency(): void {
if (!this.renderer) return
// Enable proper transparency sorting
this.renderer.sortObjects = true
// Enable local clipping for cross-section features
this.renderer.localClippingEnabled = true
// Enable tone mapping for exposure control
this.renderer.toneMapping = THREE.ACESFilmicToneMapping
this.renderer.toneMappingExposure = 1.0
console.log('RenderService: Renderer configured for transparency and tone mapping')
}
/**
* Setup environment map for metallic reflections
* NOTE: PMREMGenerator conflicts with online-3d-viewer's THREE.js instance,
* causing "Cannot read properties of undefined (reading 'length')" errors
* in updateRenderTargetMipmap. Skip environment map - metalness/roughness
* still work with the scene lighting.
*/
private setupEnvironment(): void {
// PMREMGenerator causes render target conflicts with online-3d-viewer.
// Skip environment map generation - metalness/roughness will still work
// with the custom lighting setup.
console.log('RenderService: Environment map skipped (compatibility mode)')
}
/**
* Setup lighting for the scene
*/
setupLighting(): void {
if (!this.scene || this.lightsInitialized) return
// Create lights group
this.lightsGroup = new THREE.Group()
this.lightsGroup.name = '__custom_lights__'
// Ambient light - overall illumination
this.ambientLight = new THREE.AmbientLight(0xffffff, 0.6)
this.ambientLight.name = '__ambient_light__'
this.lightsGroup.add(this.ambientLight)
// Key light - main directional light from top-right
this.keyLight = new THREE.DirectionalLight(0xffffff, 0.8)
this.keyLight.name = '__key_light__'
this.keyLight.position.set(5, 10, 7)
this.lightsGroup.add(this.keyLight)
// Fill light - softer light from opposite side
const fillLight = new THREE.DirectionalLight(0xffffff, 0.3)
fillLight.name = '__fill_light__'
fillLight.position.set(-5, 3, -5)
this.lightsGroup.add(fillLight)
// Back light - rim lighting effect
const backLight = new THREE.DirectionalLight(0xffffff, 0.2)
backLight.name = '__back_light__'
backLight.position.set(0, 5, -10)
this.lightsGroup.add(backLight)
// Camera light - follows camera direction for front-facing illumination
const cameraLight = new THREE.DirectionalLight(0xffffff, 0.5)
cameraLight.name = '__camera_light__'
this.cameraLight = cameraLight
this.lightsGroup.add(cameraLight)
// DirectionalLight target needs to be in scene for proper direction
this.scene.add(cameraLight.target)
this.scene.add(this.lightsGroup)
this.lightsInitialized = true
console.log('RenderService: Lighting setup complete')
}
/**
* Update camera light to follow camera position and direction
* Call this in the render loop
*/
updateCameraLight(camera: THREE.Camera): void {
if (!this.cameraLight) return
// Position light at camera position
this.cameraLight.position.copy(camera.position)
// Make light point in camera's look direction
const target = new THREE.Vector3()
camera.getWorldDirection(target)
target.add(camera.position)
this.cameraLight.target.position.copy(target)
this.cameraLight.target.updateMatrixWorld()
}
/**
* Enable or disable edge lines
* Uses visibility toggle for performance (avoids recreating geometry on each toggle)
*/
setEdgesEnabled(enabled: boolean): void {
if (!this.initialized || !this.scene) return
this.edgesEnabled = enabled
if (enabled) {
// If edges already exist, just show them
if (this.edgesGroup) {
this.edgesGroup.visible = true
} else {
// First time - create edges
this.createEdges()
}
} else {
// Hide edges instead of destroying them (much faster toggle)
if (this.edgesGroup) {
this.edgesGroup.visible = false
}
}
}
/**
* Invalidate cached edges (call when model changes)
* This forces edge recreation on next enable
*/
invalidateEdges(): void {
this.removeEdges()
}
/**
* Create edge lines for all meshes using LineSegments2 for variable line width support
*/
private createEdges(): void {
if (!this.scene || !this.renderer) return
// Remove existing edges first
this.removeEdges()
// Create edges group
this.edgesGroup = new THREE.Group()
this.edgesGroup.name = '__edge_lines__'
// Get renderer size for LineMaterial resolution
const size = new THREE.Vector2()
this.renderer.getSize(size)
// Traverse scene and create edges for each mesh
this.scene.traverse((object) => {
const isMesh = object.type === 'Mesh' || (object as unknown as { isMesh?: boolean }).isMesh === true
if (isMesh) {
const mesh = object as THREE.Mesh
if (!mesh.geometry) return
// Skip hidden meshes
if (!mesh.visible) return
// Skip our own edge lines
if (mesh.name.startsWith('__')) return
try {
// Create edges geometry with angle threshold for CAD-style lines
const edgesGeometry = new THREE.EdgesGeometry(mesh.geometry, 30)
const positions = edgesGeometry.attributes.position.array as Float32Array
// Convert EdgesGeometry positions to LineSegmentsGeometry format
const lineGeometry = new LineSegmentsGeometry()
// LineSegmentsGeometry expects pairs of points for each line segment
// EdgesGeometry provides pairs: [p0, p1, p2, p3, ...] where (p0,p1), (p2,p3) are separate segments
lineGeometry.setPositions(positions)
// Get clipping planes from clipping service
const clippingPlanes = getClippingService().getActiveClippingPlanes()
// Create LineMaterial with adjustable width
const lineMaterial = new LineMaterial({
color: this.edgeColor.getHex(),
linewidth: this.edgeLineWidth,
transparent: true,
opacity: this.edgeOpacity,
resolution: size,
})
// Set clipping planes (inherited from Material base class)
if (clippingPlanes.length > 0) {
lineMaterial.clippingPlanes = clippingPlanes
}
// Create LineSegments2 with proper width support
const edges = new LineSegments2(lineGeometry, lineMaterial)
edges.name = `__edge_${mesh.uuid}__`
edges.computeLineDistances()
// Store source mesh reference for fast transform sync (avoids O(n) scene search)
edges.userData.sourceMesh = mesh
// Copy world transform from mesh
mesh.updateMatrixWorld(true)
edges.matrix.copy(mesh.matrixWorld)
edges.matrixAutoUpdate = false
this.edgesGroup!.add(edges)
} catch (e) {
// Skip meshes with invalid geometry
console.warn('RenderService: Could not create edges for mesh', mesh.name)
}
}
})
this.scene.add(this.edgesGroup)
console.log(`RenderService: Created edges for ${this.edgesGroup.children.length} meshes`)
}
/**
* Remove edge lines
*/
private removeEdges(): void {
if (this.edgesGroup && this.scene) {
// Dispose geometries and materials
this.edgesGroup.traverse((object) => {
// Handle LineSegments2 objects
if (object instanceof LineSegments2) {
object.geometry.dispose()
if (object.material instanceof LineMaterial) {
object.material.dispose()
}
}
// Handle legacy LineSegments for backwards compatibility
if (object instanceof THREE.LineSegments) {
object.geometry.dispose()
if (object.material instanceof THREE.Material) {
object.material.dispose()
}
}
})
this.scene.remove(this.edgesGroup)
this.edgesGroup = null
}
}
/**
* Update edges when model changes (e.g., explosion)
*/
updateEdges(): void {
if (this.edgesEnabled) {
this.createEdges()
}
}
/**
* Update clipping planes on all edge line materials
* Called when cross-section planes change
*/
updateEdgeClipping(): void {
if (!this.edgesGroup) return
const clippingPlanes = getClippingService().getActiveClippingPlanes()
this.edgesGroup.traverse((object) => {
if (object instanceof LineSegments2) {
const material = object.material as LineMaterial
material.clippingPlanes = clippingPlanes.length > 0 ? clippingPlanes : null
material.needsUpdate = true
}
})
}
/**
* Sync edge line transforms with their corresponding meshes
* More efficient than updateEdges() - only updates transforms, not geometry
* Optimized: Uses stored mesh reference (O(1)) instead of scene search (O(n))
*/
syncEdgeTransforms(): void {
if (!this.edgesGroup || !this.scene || !this.edgesEnabled) return
this.edgesGroup.traverse((edge) => {
// Handle LineSegments2 objects
if ((edge instanceof LineSegments2 || edge instanceof THREE.LineSegments) && edge.name.startsWith('__edge_')) {
// Use stored mesh reference for O(1) lookup instead of O(n) scene search
const mesh = edge.userData.sourceMesh as THREE.Object3D
if (mesh) {
mesh.updateMatrixWorld(true)
edge.matrix.copy(mesh.matrixWorld)
}
}
})
}
/**
* Sync edge line visibility for specific mesh UUIDs
*/
syncEdgeVisibility(meshUuids: string[], visible: boolean): void {
if (!this.edgesGroup) return
for (const uuid of meshUuids) {
const edgeName = `__edge_${uuid}__`
const edge = this.edgesGroup.getObjectByName(edgeName)
if (edge) {
edge.visible = visible
}
}
}
/**
* Sync all edge line visibilities with their mesh opacity
*/
syncAllEdgeVisibility(): void {
if (!this.edgesGroup || !this.scene) return
this.edgesGroup.traverse((edge) => {
if ((edge instanceof LineSegments2 || edge instanceof THREE.LineSegments) && edge.name.startsWith('__edge_')) {
const uuid = edge.name.slice(7, -2)
const mesh = this.scene!.getObjectByProperty('uuid', uuid)
if (mesh) {
// Check mesh material opacity to determine visibility
const meshObj = mesh as THREE.Mesh
const material = meshObj.material as THREE.MeshStandardMaterial
edge.visible = material && material.opacity > 0
}
}
})
}
/**
* Create a single edge line for a mesh with configurable options
*/
private createSingleEdgeLine(
mesh: THREE.Mesh,
options: {
color: number
opacity: number
depthTest: boolean
renderOrder: number
nameSuffix?: string
}
): LineSegments2 | null {
if (!mesh.geometry || !this.renderer) return null
try {
// Get renderer size for LineMaterial resolution
const size = new THREE.Vector2()
this.renderer.getSize(size)
// Create edges geometry with angle threshold for CAD-style lines
const edgesGeometry = new THREE.EdgesGeometry(mesh.geometry, 30)
const positions = edgesGeometry.attributes.position.array as Float32Array
// Convert EdgesGeometry positions to LineSegmentsGeometry format
const lineGeometry = new LineSegmentsGeometry()
lineGeometry.setPositions(positions)
// Get clipping planes from clipping service
const clippingPlanes = getClippingService().getActiveClippingPlanes()
// Create LineMaterial with configurable options
const lineMaterial = new LineMaterial({
color: options.color,
linewidth: this.edgeLineWidth,
transparent: true,
opacity: options.opacity,
resolution: size,
depthTest: options.depthTest,
})
// Set clipping planes (inherited from Material base class)
if (clippingPlanes.length > 0) {
lineMaterial.clippingPlanes = clippingPlanes
}
// Create LineSegments2
const edges = new LineSegments2(lineGeometry, lineMaterial)
edges.name = `__edge_${mesh.uuid}${options.nameSuffix || ''}__`
edges.renderOrder = options.renderOrder
edges.computeLineDistances()
// Store source mesh reference for fast transform sync (avoids O(n) scene search)
edges.userData.sourceMesh = mesh
// Copy world transform from mesh
mesh.updateMatrixWorld(true)
edges.matrix.copy(mesh.matrixWorld)
edges.matrixAutoUpdate = false
return edges
} catch (e) {
console.warn('RenderService: Could not create edge line for mesh', mesh.name)
return null
}
}
/**
* Set edge color
*/
setEdgeColor(color: number): void {
this.edgeColor.setHex(color)
if (this.edgesEnabled && this.edgesGroup) {
this.edgesGroup.traverse((object) => {
// Handle LineSegments2 objects
if (object instanceof LineSegments2) {
const material = object.material as LineMaterial
material.color.setHex(color)
}
// Handle legacy LineSegments
if (object instanceof THREE.LineSegments) {
const material = object.material as THREE.LineBasicMaterial
material.color.copy(this.edgeColor)
}
})
}
}
/**
* Set edge line width
*/
setEdgeLineWidth(width: number): void {
this.edgeLineWidth = Math.max(0.5, Math.min(5, width))
if (this.edgesGroup) {
this.edgesGroup.traverse((object) => {
if (object instanceof LineSegments2) {
const material = object.material as LineMaterial
material.linewidth = this.edgeLineWidth
material.needsUpdate = true
}
})
}
}
/**
* Get current edge line width
*/
getEdgeLineWidth(): number {
return this.edgeLineWidth
}
/**
* Check if edges are enabled
*/
isEdgesEnabled(): boolean {
return this.edgesEnabled
}
// ==================== Lighting Control Methods ====================
/**
* Set tone mapping exposure (overall scene brightness)
* @param value Exposure value (0.1 - 3.0, default 1.0)
*/
setToneMappingExposure(value: number): void {
if (!this.renderer) return
this.renderer.toneMappingExposure = Math.max(0.1, Math.min(3.0, value))
}
/**
* Get current tone mapping exposure
*/
getToneMappingExposure(): number {
return this.renderer?.toneMappingExposure ?? 1.0
}
/**
* Set main (key) light intensity
* @param intensity Light intensity (0 - 2.0, default 0.8)
*/
setMainLightIntensity(intensity: number): void {
if (this.keyLight) {
this.keyLight.intensity = Math.max(0, Math.min(2.0, intensity))
}
}
/**
* Get current main light intensity
*/
getMainLightIntensity(): number {
return this.keyLight?.intensity ?? 0.8
}
/**
* Set ambient light intensity
* @param intensity Light intensity (0 - 2.0, default 0.6)
*/
setAmbientLightIntensity(intensity: number): void {
if (this.ambientLight) {
this.ambientLight.intensity = Math.max(0, Math.min(2.0, intensity))
}
}
/**
* Get current ambient light intensity
*/
getAmbientLightIntensity(): number {
return this.ambientLight?.intensity ?? 0.6
}
// ==================== Render Mode Methods ====================
/**
* Get current render mode
*/
getRenderMode(): RenderMode {
return this.renderMode
}
/**
* Set render mode
*/
setRenderMode(mode: RenderMode): void {
if (!this.initialized || !this.scene) return
if (this.renderMode === mode) return
// First restore to standard mode
this.restoreStandardMode()
// Then apply new mode
this.renderMode = mode
switch (mode) {
case RenderMode.HiddenLine:
this.applyHiddenLineMode()
break
case RenderMode.Wireframe:
this.applyWireframeMode()
break
case RenderMode.Standard:
default:
// Already restored, nothing more to do
break
}
console.log(`RenderService: Render mode set to ${mode}`)
}
/**
* Find the model root in the scene (skip lights, helpers, etc.)
*/
private findModelRoot(): THREE.Object3D | null {
if (!this.scene) return null
for (const child of this.scene.children) {
// Skip lights
if (child.type.includes('Light') || (child as unknown as { isLight?: boolean }).isLight) continue
// Skip cameras
if (child.type.includes('Camera') || (child as unknown as { isCamera?: boolean }).isCamera) continue
// Skip helpers
if (child.type.includes('Helper')) continue
// Skip our custom objects
if (child.name.startsWith('__')) continue
return child
}
return null
}
/**
* Check if object is a mesh
*/
private isMesh(obj: THREE.Object3D): obj is THREE.Mesh {
return obj.type === 'Mesh' || (obj as unknown as { isMesh?: boolean }).isMesh === true
}
/**
* Apply Hidden Line mode - white materials with two-layer edge lines (hidden + visible)
*/
private applyHiddenLineMode(): void {
const modelRoot = this.findModelRoot()
if (!modelRoot) return
// Apply white materials to all meshes
modelRoot.traverse((object) => {
if (this.isMesh(object) && object.material) {
const mesh = object
// Store original material
if (!this.renderModeMaterials.has(mesh.uuid)) {
this.renderModeMaterials.set(mesh.uuid, mesh.material)
}
// Create white material with polygon offset to avoid z-fighting with edges
const whiteMaterial = new THREE.MeshBasicMaterial({
color: 0xffffff,
polygonOffset: true,
polygonOffsetFactor: 1,
polygonOffsetUnits: 1,
})
mesh.material = whiteMaterial
}
})
// Create two-layer edge lines for hidden line mode
this.createHiddenLineEdges()
}
/**
* Create two-layer edge lines for Hidden Line mode:
* 1. Hidden edges (no depth test, light color) - rendered first
* 2. Visible edges (with depth test, normal color) - rendered second
*/
private createHiddenLineEdges(): void {
if (!this.scene) return
// Remove existing edges first
this.removeEdges()
// Create edges group
this.edgesGroup = new THREE.Group()
this.edgesGroup.name = '__edge_lines__'
const modelRoot = this.findModelRoot()
if (!modelRoot) return
// Traverse scene and create two-layer edges for each mesh
modelRoot.traverse((object) => {
if (this.isMesh(object)) {
const mesh = object
if (!mesh.geometry) return
if (!mesh.visible) return
if (mesh.name.startsWith('__')) return
// Layer 1: Hidden edges (light gray, no depth test, rendered first)
const hiddenEdges = this.createSingleEdgeLine(mesh, {
color: 0xaaaaaa, // Light gray
opacity: 0.35, // Lower opacity
depthTest: false, // No depth test - always visible
renderOrder: 0, // Rendered first
nameSuffix: '_hidden',
})
// Layer 2: Visible edges (black, with depth test, rendered second)
const visibleEdges = this.createSingleEdgeLine(mesh, {
color: 0x000000, // Black
opacity: 0.8, // Normal opacity
depthTest: true, // With depth test - occluded by surfaces
renderOrder: 1, // Rendered second, overlays hidden edges
nameSuffix: '_visible',
})
if (hiddenEdges) this.edgesGroup!.add(hiddenEdges)
if (visibleEdges) this.edgesGroup!.add(visibleEdges)
}
})
this.scene.add(this.edgesGroup)
this.edgesEnabled = true
console.log(`RenderService: Created hidden line edges for ${this.edgesGroup.children.length / 2} meshes`)
}
/**
* Apply Wireframe mode - hide meshes, show only edges
*/
private applyWireframeMode(): void {
const modelRoot = this.findModelRoot()
if (!modelRoot) return
// Hide all meshes but keep them for edge generation
modelRoot.traverse((object) => {
if (this.isMesh(object)) {
const mesh = object
// Store original visibility state if visible
if (mesh.visible) {
this.hiddenMeshes.add(mesh.uuid)
}
}
})
// First create edges while meshes are visible
this.setEdgesEnabled(true)
// Then hide meshes after edges are created
modelRoot.traverse((object) => {
if (this.isMesh(object)) {
const mesh = object
if (this.hiddenMeshes.has(mesh.uuid)) {
mesh.visible = false
}
}
})
}
/**
* Restore standard rendering mode
*/
private restoreStandardMode(): void {
const modelRoot = this.findModelRoot()
if (!modelRoot) return
// Restore original materials
modelRoot.traverse((object) => {
if (this.isMesh(object)) {
const mesh = object
// Restore original material
const originalMaterial = this.renderModeMaterials.get(mesh.uuid)
if (originalMaterial) {
// Dispose the temporary material
if (mesh.material !== originalMaterial) {
const currentMat = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
currentMat.forEach(m => m.dispose())
}
mesh.material = originalMaterial
this.renderModeMaterials.delete(mesh.uuid)
}
// Restore visibility
if (this.hiddenMeshes.has(mesh.uuid)) {
mesh.visible = true
this.hiddenMeshes.delete(mesh.uuid)
}
}
})
// Clear tracking sets
this.renderModeMaterials.clear()
this.hiddenMeshes.clear()
// Disable edges if we were in a special mode
if (this.renderMode !== RenderMode.Standard) {
this.setEdgesEnabled(false)
}
}
/**
* Reset service
*/
reset(): void {
// Restore standard mode first
if (this.renderMode !== RenderMode.Standard) {
this.restoreStandardMode()
}
this.renderMode = RenderMode.Standard
this.renderModeMaterials.clear()
this.hiddenMeshes.clear()
this.removeEdges()
this.edgesEnabled = false
// Remove lights
if (this.lightsGroup && this.scene) {
this.scene.remove(this.lightsGroup)
this.lightsGroup = null
}
this.lightsInitialized = false
}
/**
* Check if initialized
*/
isInitialized(): boolean {
return this.initialized
}
}
// Singleton instance
let renderService: RenderService | null = null
export function getRenderService(): RenderService {
if (!renderService) {
renderService = new RenderService()
}
return renderService
}
export function resetRenderService(): void {
if (renderService) {
renderService.reset()
}
renderService = null
}

View File

@@ -0,0 +1,121 @@
import * as THREE from 'three'
const API_URL = import.meta.env.VITE_API_URL || ''
/**
* Capture a full-resolution screenshot from the Three.js renderer
* Preserves the original viewport dimensions
* @param renderer - WebGL renderer
* @param scene - Scene to render
* @param camera - Camera for the view
* @returns PNG blob of the screenshot at original resolution
*/
export async function captureFullScreenshot(
renderer: THREE.WebGLRenderer,
scene: THREE.Scene,
camera: THREE.Camera
): Promise<Blob> {
// Force render the current scene
renderer.render(scene, camera)
// Get the canvas from the renderer
const canvas = renderer.domElement
// Convert to blob directly (original size)
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) {
resolve(blob)
} else {
reject(new Error('Failed to create blob from canvas'))
}
},
'image/png',
1.0
)
})
}
/**
* Capture a screenshot from the Three.js renderer using canvas approach
* This avoids WebGLRenderTarget issues with online-3d-viewer
* @param renderer - WebGL renderer
* @param scene - Scene to render
* @param camera - Camera for the view
* @param size - Output image size (default 512x512)
* @returns PNG blob of the screenshot
*/
export async function captureViewerScreenshot(
renderer: THREE.WebGLRenderer,
scene: THREE.Scene,
camera: THREE.Camera,
size: number = 512
): Promise<Blob> {
// Force render the current scene
renderer.render(scene, camera)
// Get the canvas from the renderer
const sourceCanvas = renderer.domElement
// Create a temporary canvas for the thumbnail
const thumbCanvas = document.createElement('canvas')
thumbCanvas.width = size
thumbCanvas.height = size
const ctx = thumbCanvas.getContext('2d')!
// Calculate crop area for square thumbnail (center crop)
const srcWidth = sourceCanvas.width
const srcHeight = sourceCanvas.height
const minDim = Math.min(srcWidth, srcHeight)
const srcX = (srcWidth - minDim) / 2
const srcY = (srcHeight - minDim) / 2
// Draw the center-cropped source onto the thumbnail canvas
ctx.drawImage(
sourceCanvas,
srcX, srcY, minDim, minDim, // Source rectangle (center crop)
0, 0, size, size // Destination rectangle (full thumbnail)
)
// Convert to blob
return new Promise<Blob>((resolve, reject) => {
thumbCanvas.toBlob(
(blob) => {
if (blob) {
resolve(blob)
} else {
reject(new Error('Failed to create blob from canvas'))
}
},
'image/png',
1.0
)
})
}
/**
* Upload a thumbnail blob to the server
* @param modelId - Model ID
* @param blob - PNG blob
* @returns Updated thumbnail URL
*/
export async function uploadThumbnail(
modelId: string,
blob: Blob
): Promise<string> {
const formData = new FormData()
formData.append('thumbnail', blob, 'thumbnail.png')
const response = await fetch(`${API_URL}/api/models/${modelId}/thumbnail`, {
method: 'POST',
body: formData,
})
if (!response.ok) {
throw new Error(`Failed to upload thumbnail: ${response.statusText}`)
}
const data = await response.json()
return data.thumbnail_url
}

View File

@@ -0,0 +1,463 @@
import * as THREE from 'three'
export type ViewDirection = 'front' | 'back' | 'left' | 'right' | 'top' | 'bottom'
interface FaceConfig {
name: string
direction: ViewDirection
normal: THREE.Vector3
color: string
// Three.js BoxGeometry face order: +X, -X, +Y, -Y, +Z, -Z
materialIndex: number
}
type FaceClickCallback = (direction: ViewDirection) => void
type RotateCallback = (deltaX: number, deltaY: number) => void
type Rotate90Callback = (direction: ViewDirection) => void
class ViewCubeService {
private scene: THREE.Scene | null = null
private camera: THREE.OrthographicCamera | null = null
private renderer: THREE.WebGLRenderer | null = null
private cube: THREE.Mesh | null = null
private raycaster: THREE.Raycaster = new THREE.Raycaster()
private mouse: THREE.Vector2 = new THREE.Vector2()
private container: HTMLElement | null = null
private onFaceClick: FaceClickCallback | null = null
private onRotate: RotateCallback | null = null
private onRotate90: Rotate90Callback | null = null
private hoveredFaceIndex: number = -1
private materials: THREE.MeshBasicMaterial[] = []
private animationFrameId: number | null = null
// Drag rotation state
private isDragging: boolean = false
private hasMouseDown: boolean = false
private lastMouseX: number = 0
private lastMouseY: number = 0
private dragStartX: number = 0
private dragStartY: number = 0
// Face configurations
// BoxGeometry face order: +X (right), -X (left), +Y (top), -Y (bottom), +Z (front), -Z (back)
private faces: FaceConfig[] = [
{ name: '右', direction: 'right', normal: new THREE.Vector3(1, 0, 0), color: '#5cb85c', materialIndex: 0 },
{ name: '左', direction: 'left', normal: new THREE.Vector3(-1, 0, 0), color: '#5cb85c', materialIndex: 1 },
{ name: '上', direction: 'top', normal: new THREE.Vector3(0, 1, 0), color: '#d9534f', materialIndex: 2 },
{ name: '下', direction: 'bottom', normal: new THREE.Vector3(0, -1, 0), color: '#d9534f', materialIndex: 3 },
{ name: '前', direction: 'front', normal: new THREE.Vector3(0, 0, 1), color: '#4a90d9', materialIndex: 4 },
{ name: '后', direction: 'back', normal: new THREE.Vector3(0, 0, -1), color: '#4a90d9', materialIndex: 5 },
]
initialize(
container: HTMLElement,
onFaceClick: FaceClickCallback,
onRotate?: RotateCallback,
onRotate90?: Rotate90Callback
): void {
this.container = container
this.onFaceClick = onFaceClick
this.onRotate = onRotate || null
this.onRotate90 = onRotate90 || null
// Create scene
this.scene = new THREE.Scene()
// Create orthographic camera for consistent cube size
const size = 2
this.camera = new THREE.OrthographicCamera(-size, size, size, -size, 0.1, 100)
this.camera.position.set(3, 3, 3)
this.camera.lookAt(0, 0, 0)
// Create renderer with transparent background
this.renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
})
this.renderer.setSize(container.clientWidth, container.clientHeight)
this.renderer.setPixelRatio(window.devicePixelRatio)
this.renderer.setClearColor(0x000000, 0)
container.appendChild(this.renderer.domElement)
// Create cube with face materials
this.createCube()
// Add lighting
const ambientLight = new THREE.AmbientLight(0xffffff, 0.8)
this.scene.add(ambientLight)
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.4)
directionalLight.position.set(5, 5, 5)
this.scene.add(directionalLight)
// Event listeners
container.addEventListener('mousemove', this.handleMouseMove)
container.addEventListener('click', this.handleClick)
container.addEventListener('mouseleave', this.handleMouseLeave)
container.addEventListener('mousedown', this.handleMouseDown)
// Listen on window to catch mouseup even when mouse leaves the container
window.addEventListener('mouseup', this.handleMouseUp)
// Initial render
this.render()
}
private createCube(): void {
if (!this.scene) return
const geometry = new THREE.BoxGeometry(1.8, 1.8, 1.8)
// Create materials for each face
this.materials = this.faces.map((face) => {
const texture = this.createFaceTexture(face.name, face.color)
return new THREE.MeshBasicMaterial({
map: texture,
transparent: false,
})
})
this.cube = new THREE.Mesh(geometry, this.materials)
this.scene.add(this.cube)
}
private createFaceTexture(label: string, bgColor: string): THREE.CanvasTexture {
const canvas = document.createElement('canvas')
canvas.width = 128
canvas.height = 128
const ctx = canvas.getContext('2d')!
// Background with rounded corners effect
ctx.fillStyle = bgColor
ctx.fillRect(0, 0, 128, 128)
// Inner border
ctx.strokeStyle = 'rgba(255, 255, 255, 0.6)'
ctx.lineWidth = 3
ctx.strokeRect(4, 4, 120, 120)
// Text shadow for depth
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'
ctx.font = 'bold 52px sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(label, 66, 66)
// Main text
ctx.fillStyle = '#ffffff'
ctx.fillText(label, 64, 64)
const texture = new THREE.CanvasTexture(canvas)
texture.needsUpdate = true
return texture
}
private createHighlightTexture(label: string, bgColor: string): THREE.CanvasTexture {
const canvas = document.createElement('canvas')
canvas.width = 128
canvas.height = 128
const ctx = canvas.getContext('2d')!
// Brighter background for hover
ctx.fillStyle = this.lightenColor(bgColor, 30)
ctx.fillRect(0, 0, 128, 128)
// Highlight border
ctx.strokeStyle = '#ffffff'
ctx.lineWidth = 5
ctx.strokeRect(4, 4, 120, 120)
// Text shadow
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'
ctx.font = 'bold 52px sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(label, 66, 66)
// Main text
ctx.fillStyle = '#ffffff'
ctx.fillText(label, 64, 64)
const texture = new THREE.CanvasTexture(canvas)
texture.needsUpdate = true
return texture
}
private lightenColor(hex: string, percent: number): string {
const num = parseInt(hex.replace('#', ''), 16)
const amt = Math.round(2.55 * percent)
const R = Math.min(255, (num >> 16) + amt)
const G = Math.min(255, ((num >> 8) & 0x00ff) + amt)
const B = Math.min(255, (num & 0x0000ff) + amt)
return `#${((1 << 24) + (R << 16) + (G << 8) + B).toString(16).slice(1)}`
}
private handleMouseMove = (event: MouseEvent): void => {
if (!this.container || !this.camera || !this.cube) return
// Handle drag rotation
if (this.isDragging && this.onRotate) {
const deltaX = event.clientX - this.lastMouseX
const deltaY = event.clientY - this.lastMouseY
this.lastMouseX = event.clientX
this.lastMouseY = event.clientY
// Call rotation callback with sensitivity adjustment (higher = more responsive)
this.onRotate(deltaX * 1.5, deltaY * 1.5)
return // Skip hover handling during drag
}
const rect = this.container.getBoundingClientRect()
this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
this.raycaster.setFromCamera(this.mouse, this.camera)
const intersects = this.raycaster.intersectObject(this.cube)
if (intersects.length > 0) {
const faceIndex = Math.floor(intersects[0].faceIndex! / 2)
if (faceIndex !== this.hoveredFaceIndex) {
// Reset previous hover
if (this.hoveredFaceIndex >= 0) {
const prevFace = this.faces[this.hoveredFaceIndex]
this.materials[this.hoveredFaceIndex].map = this.createFaceTexture(
prevFace.name,
prevFace.color
)
this.materials[this.hoveredFaceIndex].needsUpdate = true
}
// Set new hover
this.hoveredFaceIndex = faceIndex
const face = this.faces[faceIndex]
this.materials[faceIndex].map = this.createHighlightTexture(face.name, face.color)
this.materials[faceIndex].needsUpdate = true
this.container.style.cursor = 'pointer'
this.render()
}
} else {
this.resetHover()
}
}
private handleClick = (event: MouseEvent): void => {
if (!this.container || !this.camera || !this.cube || !this.onFaceClick) return
// Only check drag distance if mousedown was triggered on this element
if (this.hasMouseDown) {
const dragDistance = Math.sqrt(
Math.pow(event.clientX - this.dragStartX, 2) +
Math.pow(event.clientY - this.dragStartY, 2)
)
// If dragged more than 5 pixels, don't fire click
if (dragDistance > 5) return
}
const rect = this.container.getBoundingClientRect()
this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
this.raycaster.setFromCamera(this.mouse, this.camera)
const intersects = this.raycaster.intersectObject(this.cube)
if (intersects.length > 0 && intersects[0].face) {
// Transform face normal to world space (accounting for cube rotation)
const worldNormal = intersects[0].face.normal.clone()
worldNormal.applyQuaternion(this.cube.quaternion)
// Determine direction from world-space normal
const direction = this.getDirectionFromNormal(worldNormal)
if (direction) {
// Check if already directly facing this direction (showing only one face)
if (this.isDirectlyFacing(direction) && this.onRotate90) {
// Already facing this face → rotate 90 degrees clockwise
this.onRotate90(direction)
} else {
// Not directly facing → animate to this face view
this.onFaceClick(direction)
}
}
}
}
private handleMouseDown = (event: MouseEvent): void => {
if (!this.container) return
this.isDragging = true
this.hasMouseDown = true
this.lastMouseX = event.clientX
this.lastMouseY = event.clientY
this.dragStartX = event.clientX
this.dragStartY = event.clientY
this.container.style.cursor = 'grabbing'
// Prevent text selection during drag
event.preventDefault()
}
private handleMouseUp = (): void => {
this.isDragging = false
// Reset hasMouseDown after a short delay to allow click event to fire first
setTimeout(() => {
this.hasMouseDown = false
}, 10)
if (this.container) {
this.container.style.cursor = 'default'
}
}
private handleMouseLeave = (): void => {
this.resetHover()
}
private resetHover(): void {
if (this.hoveredFaceIndex >= 0) {
const face = this.faces[this.hoveredFaceIndex]
this.materials[this.hoveredFaceIndex].map = this.createFaceTexture(face.name, face.color)
this.materials[this.hoveredFaceIndex].needsUpdate = true
this.hoveredFaceIndex = -1
if (this.container) {
this.container.style.cursor = 'default'
}
this.render()
}
}
/**
* Determine view direction from a world-space normal vector.
* Finds which axis the normal is most aligned with.
*/
private getDirectionFromNormal(normal: THREE.Vector3): ViewDirection | null {
const absX = Math.abs(normal.x)
const absY = Math.abs(normal.y)
const absZ = Math.abs(normal.z)
if (absX > absY && absX > absZ) {
return normal.x > 0 ? 'right' : 'left'
} else if (absY > absX && absY > absZ) {
return normal.y > 0 ? 'top' : 'bottom'
} else {
return normal.z > 0 ? 'front' : 'back'
}
}
/**
* Check if camera is directly facing a specific direction (showing only one face).
* Uses dot product to determine alignment - when facing directly, dot product ≈ 1.
*/
private isDirectlyFacing(direction: ViewDirection): boolean {
if (!this.camera) return false
// Get normalized camera position (direction from origin to camera)
const cameraDir = this.camera.position.clone().normalize()
// Threshold for "directly facing" - 0.99 means within ~8 degrees of perfect alignment
const threshold = 0.99
// Map directions to their corresponding camera position vectors
// When viewing "front", camera is at +Z looking toward origin
const axisMap: Record<ViewDirection, THREE.Vector3> = {
front: new THREE.Vector3(0, 0, 1),
back: new THREE.Vector3(0, 0, -1),
right: new THREE.Vector3(1, 0, 0),
left: new THREE.Vector3(-1, 0, 0),
top: new THREE.Vector3(0, 1, 0),
bottom: new THREE.Vector3(0, -1, 0),
}
// Check if camera direction aligns with the face's expected camera position
return Math.abs(cameraDir.dot(axisMap[direction])) > threshold
}
syncWithMainCamera(mainCamera: THREE.Camera): void {
if (!this.cube || !this.camera) return
// Keep cube at identity rotation (faces always aligned with world axes)
this.cube.quaternion.identity()
// Move ViewCube camera to match main camera's view direction
// Get main camera's forward direction (looking at -Z in camera space)
const direction = new THREE.Vector3(0, 0, -1)
direction.applyQuaternion(mainCamera.quaternion)
// Position ViewCube camera opposite to view direction
const distance = 5
this.camera.position.copy(direction.clone().multiplyScalar(-distance))
this.camera.lookAt(0, 0, 0)
// Sync up vector to maintain proper orientation
const up = new THREE.Vector3(0, 1, 0)
up.applyQuaternion(mainCamera.quaternion)
this.camera.up.copy(up)
this.render()
}
render(): void {
if (!this.renderer || !this.scene || !this.camera) return
this.renderer.render(this.scene, this.camera)
}
isInitialized(): boolean {
return this.renderer !== null
}
dispose(): void {
// Cancel animation frame if running
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId)
}
// Remove event listeners
if (this.container) {
this.container.removeEventListener('mousemove', this.handleMouseMove)
this.container.removeEventListener('click', this.handleClick)
this.container.removeEventListener('mouseleave', this.handleMouseLeave)
this.container.removeEventListener('mousedown', this.handleMouseDown)
}
window.removeEventListener('mouseup', this.handleMouseUp)
// Dispose Three.js resources
if (this.cube) {
this.cube.geometry.dispose()
this.materials.forEach((mat) => {
if (mat.map) mat.map.dispose()
mat.dispose()
})
}
if (this.renderer) {
this.renderer.dispose()
if (this.container && this.renderer.domElement.parentNode === this.container) {
this.container.removeChild(this.renderer.domElement)
}
}
this.scene = null
this.camera = null
this.renderer = null
this.cube = null
this.container = null
this.onFaceClick = null
this.onRotate = null
this.materials = []
}
}
// Singleton instance
let viewCubeServiceInstance: ViewCubeService | null = null
export function getViewCubeService(): ViewCubeService {
if (!viewCubeServiceInstance) {
viewCubeServiceInstance = new ViewCubeService()
}
return viewCubeServiceInstance
}
export function resetViewCubeService(): void {
if (viewCubeServiceInstance) {
viewCubeServiceInstance.dispose()
viewCubeServiceInstance = null
}
}

View File

@@ -0,0 +1,173 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Model, ConversionStatus } from '@/types/model'
import * as api from '@/api/client'
export const useModelsStore = defineStore('models', () => {
// State
const models = ref<Model[]>([])
const selectedModelId = ref<string | null>(null)
const searchQuery = ref('')
const isLoading = ref(false)
const error = ref<string | null>(null)
const total = ref(0)
// Getters
const selectedModel = computed(() => {
if (!selectedModelId.value) return null
return models.value.find(m => m.id === selectedModelId.value) || null
})
const filteredModels = computed(() => {
if (!searchQuery.value) return models.value
const query = searchQuery.value.toLowerCase()
return models.value.filter(m =>
m.name.toLowerCase().includes(query) ||
m.original_filename.toLowerCase().includes(query)
)
})
const readyModels = computed(() =>
models.value.filter(m => m.conversion_status === 'completed')
)
// Actions
async function fetchModels(params?: {
search?: string
status?: ConversionStatus
format?: string
limit?: number
offset?: number
}) {
isLoading.value = true
error.value = null
try {
const result = await api.getModels(params)
models.value = result.models
total.value = result.total
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to fetch models'
console.error('Failed to fetch models:', e)
} finally {
isLoading.value = false
}
}
async function refreshModels() {
await fetchModels()
}
function selectModel(id: string | null) {
selectedModelId.value = id
}
async function uploadModel(file: File): Promise<Model | null> {
try {
// 1. Initialize upload
const { uploadUrl, modelId, storageKey } = await api.initUpload(file.name)
// 2. Upload directly to MinIO
await api.uploadToMinIO(uploadUrl, file)
// 3. Confirm upload
const model = await api.confirmUpload({
modelId,
filename: file.name,
fileSize: file.size,
storageKey,
})
// Add to local state
models.value.unshift(model)
return model
} catch (e) {
error.value = e instanceof Error ? e.message : 'Upload failed'
console.error('Upload failed:', e)
return null
}
}
async function removeModel(id: string): Promise<boolean> {
try {
await api.deleteModel(id)
models.value = models.value.filter(m => m.id !== id)
if (selectedModelId.value === id) {
selectedModelId.value = null
}
return true
} catch (e) {
error.value = e instanceof Error ? e.message : 'Delete failed'
console.error('Delete failed:', e)
return false
}
}
async function renameModel(id: string, newName: string): Promise<Model | null> {
try {
const updated = await api.updateModel(id, { name: newName })
updateModelInStore(updated)
return updated
} catch (e) {
error.value = e instanceof Error ? e.message : 'Rename failed'
console.error('Rename failed:', e)
return null
}
}
function updateModelInStore(model: Model) {
const index = models.value.findIndex(m => m.id === model.id)
if (index !== -1) {
models.value[index] = model
}
}
// Polling for status updates
let pollInterval: number | null = null
function startPolling(interval = 5000) {
stopPolling()
pollInterval = window.setInterval(async () => {
// Only poll if there are pending/processing models
const hasPending = models.value.some(
m => m.conversion_status === 'pending' || m.conversion_status === 'processing'
)
if (hasPending) {
await refreshModels()
}
}, interval)
}
function stopPolling() {
if (pollInterval !== null) {
clearInterval(pollInterval)
pollInterval = null
}
}
return {
// State
models,
selectedModelId,
searchQuery,
isLoading,
error,
total,
// Getters
selectedModel,
filteredModels,
readyModels,
// Actions
fetchModels,
refreshModels,
selectModel,
uploadModel,
removeModel,
renameModel,
updateModelInStore,
startPolling,
stopPolling,
}
})

View File

@@ -0,0 +1,407 @@
import { defineStore } from 'pinia'
import { ref, shallowRef, computed } from 'vue'
import type { TreeNode, FlatTreeNode } from '@/types/partsTree'
import { getPartsTreeService, resetPartsTreeService } from '@/services/partsTreeService'
import { getRenderService } from '@/services/renderService'
import { getExplodeService } from '@/services/explodeService'
import { useViewerStore } from './viewer'
export const usePartsTreeStore = defineStore('partsTree', () => {
const viewerStore = useViewerStore()
// State
const tree = shallowRef<TreeNode | null>(null)
const expandedIds = ref<Set<string>>(new Set())
const searchQuery = ref('')
const matchingIds = ref<Set<string>>(new Set())
const hoveredNodeId = ref<string | null>(null)
const isPanelCollapsed = ref(false)
const panelWidth = ref(280)
// Computed
const flattenedTree = computed<FlatTreeNode[]>(() => {
if (!tree.value) return []
const service = getPartsTreeService()
return service.flattenTree(tree.value, expandedIds.value)
})
const filteredFlatTree = computed<FlatTreeNode[]>(() => {
if (!searchQuery.value.trim()) return flattenedTree.value
// Only show nodes that match or have matching descendants
return flattenedTree.value.filter(node => matchingIds.value.has(node.id))
})
const hasTree = computed(() => tree.value !== null)
const nodeCount = computed(() => {
if (!tree.value) return 0
let count = 0
const countNodes = (node: TreeNode) => {
count++
node.children.forEach(countNodes)
}
countNodes(tree.value)
return count
})
// Actions
function buildTree() {
const scene = viewerStore.scene
if (!scene) {
console.warn('PartsTreeStore: No scene available')
tree.value = null
return
}
const service = getPartsTreeService()
tree.value = service.buildTree(scene)
// Initialize expanded IDs from tree
if (tree.value) {
expandedIds.value = new Set()
initExpandedIds(tree.value)
}
}
function initExpandedIds(node: TreeNode) {
if (node.isExpanded) {
expandedIds.value.add(node.id)
}
node.children.forEach(initExpandedIds)
}
function toggleExpanded(nodeId: string) {
if (!tree.value) return
const service = getPartsTreeService()
const node = service.findNodeById(tree.value, nodeId)
if (!node) return
service.toggleExpanded(node)
if (node.isExpanded) {
expandedIds.value.add(nodeId)
} else {
expandedIds.value.delete(nodeId)
}
// Trigger reactivity
expandedIds.value = new Set(expandedIds.value)
}
/**
* Helper: Collect mesh UUIDs from a node and all its descendants
*/
function collectMeshUuids(node: TreeNode): string[] {
const meshUuids: string[] = []
const collect = (n: TreeNode) => {
n.object.traverse((obj) => {
if (obj.type === 'Mesh' || (obj as unknown as { isMesh?: boolean }).isMesh) {
meshUuids.push(obj.uuid)
}
})
n.children.forEach(collect)
}
collect(node)
return meshUuids
}
/**
* Helper: Collect mesh UUIDs from a single node (not descendants)
*/
function collectNodeMeshUuids(node: TreeNode): string[] {
const meshUuids: string[] = []
node.object.traverse((obj) => {
if (obj.type === 'Mesh' || (obj as unknown as { isMesh?: boolean }).isMesh) {
meshUuids.push(obj.uuid)
}
})
return meshUuids
}
/**
* Set visibility with cascade (for hiding parent)
*/
function setVisible(nodeId: string, visible: boolean) {
if (!tree.value) return
const service = getPartsTreeService()
const node = service.findNodeById(tree.value, nodeId)
if (!node) return
service.setVisible(node, visible)
// Sync edge lines
const renderService = getRenderService()
if (renderService.isEdgesEnabled()) {
const meshUuids = collectMeshUuids(node)
renderService.syncEdgeVisibility(meshUuids, visible)
}
// Force re-render
viewerStore.forceRender()
// Trigger tree reactivity by creating new reference
tree.value = { ...tree.value }
}
/**
* Set visibility independently (only this node, no cascade)
* Used when showing a child while parent is hidden
*/
function setVisibleIndependent(nodeId: string, visible: boolean) {
if (!tree.value) return
const service = getPartsTreeService()
const node = service.findNodeById(tree.value, nodeId)
if (!node) return
service.setVisibleIndependent(node, visible)
// Sync edge lines
const renderService = getRenderService()
if (renderService.isEdgesEnabled()) {
const meshUuids = collectNodeMeshUuids(node)
renderService.syncEdgeVisibility(meshUuids, visible)
}
// Force re-render
viewerStore.forceRender()
// Trigger tree reactivity by creating new reference
tree.value = { ...tree.value }
}
/**
* Toggle visibility:
* - Hiding: cascade to children
* - Showing: independent (only this node)
*/
function toggleVisible(nodeId: string) {
if (!tree.value) return
const service = getPartsTreeService()
const node = service.findNodeById(tree.value, nodeId)
if (!node) return
if (node.visible) {
// Hiding: use cascade
setVisible(nodeId, false)
} else {
// Showing: use independent
setVisibleIndependent(nodeId, true)
}
}
function showAll() {
if (!tree.value) return
const service = getPartsTreeService()
service.showAll(tree.value)
// Sync edge lines
const renderService = getRenderService()
if (renderService.isEdgesEnabled()) {
const meshUuids = collectMeshUuids(tree.value)
renderService.syncEdgeVisibility(meshUuids, true)
}
viewerStore.forceRender()
tree.value = { ...tree.value }
}
function hideAll() {
if (!tree.value) return
const service = getPartsTreeService()
service.hideAll(tree.value)
// Sync edge lines
const renderService = getRenderService()
if (renderService.isEdgesEnabled()) {
const meshUuids = collectMeshUuids(tree.value)
renderService.syncEdgeVisibility(meshUuids, false)
}
viewerStore.forceRender()
tree.value = { ...tree.value }
}
/**
* Isolate a node: hide all others, show only this node and its children
*/
function isolate(nodeId: string) {
if (!tree.value) return
const service = getPartsTreeService()
const node = service.findNodeById(tree.value, nodeId)
if (!node) return
// 1. Hide all first
service.hideAll(tree.value)
// 2. Show the selected node and its children (cascade)
service.setVisible(node, true)
// Sync edge lines
const renderService = getRenderService()
if (renderService.isEdgesEnabled()) {
// Hide all edges first
const allMeshUuids = collectMeshUuids(tree.value)
renderService.syncEdgeVisibility(allMeshUuids, false)
// Show edges for isolated node
const nodeMeshUuids = collectMeshUuids(node)
renderService.syncEdgeVisibility(nodeMeshUuids, true)
}
viewerStore.forceRender()
tree.value = { ...tree.value }
}
/**
* Set a node to be transparent (semi-transparent)
*/
function setTransparent(nodeId: string, opacity: number = 0.3) {
if (!tree.value) return
const service = getPartsTreeService()
const node = service.findNodeById(tree.value, nodeId)
if (!node) return
service.setPartTransparency(node, opacity)
viewerStore.forceRender()
tree.value = { ...tree.value }
}
/**
* Reset all to initial state: show all, reset colors, reset explode, reset opacity
*/
function resetAll() {
if (!tree.value) return
const service = getPartsTreeService()
// 0. Clear highlight state first
service.clearHighlight()
// 1. Show all parts
showAll()
// 2. Reset colors to original
service.resetToOriginalColors()
// 3. Reset explode state
getExplodeService().reset()
// 4. Reset all opacity to original
service.resetAllOpacity(tree.value)
viewerStore.forceRender()
tree.value = { ...tree.value }
}
function expandAll() {
if (!tree.value) return
const service = getPartsTreeService()
service.expandAll(tree.value)
// Update expanded IDs
expandedIds.value = new Set()
const collectIds = (node: TreeNode) => {
expandedIds.value.add(node.id)
node.children.forEach(collectIds)
}
collectIds(tree.value)
expandedIds.value = new Set(expandedIds.value)
}
function collapseAll() {
if (!tree.value) return
const service = getPartsTreeService()
service.collapseAll(tree.value)
expandedIds.value = new Set()
}
function setSearchQuery(query: string) {
searchQuery.value = query
if (!tree.value || !query.trim()) {
matchingIds.value = new Set()
return
}
const service = getPartsTreeService()
matchingIds.value = service.filterBySearch(tree.value, query)
// Auto-expand matching nodes' ancestors
matchingIds.value.forEach(id => {
expandedIds.value.add(id)
})
expandedIds.value = new Set(expandedIds.value)
}
function highlightNode(nodeId: string | null) {
hoveredNodeId.value = nodeId
const service = getPartsTreeService()
if (!nodeId || !tree.value) {
service.highlightPart(null, null)
viewerStore.forceRender()
return
}
const node = service.findNodeById(tree.value, nodeId)
if (node) {
service.highlightPart(node.object, viewerStore.renderer)
viewerStore.forceRender()
}
}
function setPanelCollapsed(collapsed: boolean) {
isPanelCollapsed.value = collapsed
}
function setPanelWidth(width: number) {
panelWidth.value = Math.max(200, Math.min(500, width))
}
function reset() {
tree.value = null
expandedIds.value = new Set()
searchQuery.value = ''
matchingIds.value = new Set()
hoveredNodeId.value = null
resetPartsTreeService()
}
return {
// State
tree,
expandedIds,
searchQuery,
matchingIds,
hoveredNodeId,
isPanelCollapsed,
panelWidth,
// Computed
flattenedTree,
filteredFlatTree,
hasTree,
nodeCount,
// Actions
buildTree,
toggleExpanded,
setVisible,
setVisibleIndependent,
toggleVisible,
showAll,
hideAll,
isolate,
setTransparent,
resetAll,
expandAll,
collapseAll,
setSearchQuery,
highlightNode,
setPanelCollapsed,
setPanelWidth,
reset,
}
})

View File

@@ -0,0 +1,125 @@
import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue'
export type Theme = 'light' | 'dark' | 'system'
export type ResolvedTheme = 'light' | 'dark'
const STORAGE_KEY = 'viewer3d-theme'
export const useThemeStore = defineStore('theme', () => {
// State
const preference = ref<Theme>('system')
const resolvedTheme = ref<ResolvedTheme>('light')
const systemPreference = ref<ResolvedTheme>('light')
// Getters
const isDark = computed(() => resolvedTheme.value === 'dark')
const isLight = computed(() => resolvedTheme.value === 'light')
const isSystem = computed(() => preference.value === 'system')
/**
* Apply theme to DOM by setting data-theme attribute
*/
function applyTheme() {
document.documentElement.setAttribute('data-theme', resolvedTheme.value)
}
/**
* Update the resolved theme based on preference
*/
function updateResolvedTheme() {
if (preference.value === 'system') {
resolvedTheme.value = systemPreference.value
} else {
resolvedTheme.value = preference.value
}
applyTheme()
}
/**
* Initialize theme from localStorage and system preference
* Should be called before app mount to prevent flash
*/
function initialize() {
// 1. Read saved preference from localStorage
try {
const saved = localStorage.getItem(STORAGE_KEY) as Theme | null
if (saved && ['light', 'dark', 'system'].includes(saved)) {
preference.value = saved
}
} catch {
// localStorage might be unavailable (e.g., private browsing)
console.warn('Could not read theme preference from localStorage')
}
// 2. Detect system preference
if (typeof window !== 'undefined' && window.matchMedia) {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
systemPreference.value = mediaQuery.matches ? 'dark' : 'light'
// 3. Listen for system preference changes
mediaQuery.addEventListener('change', (e) => {
systemPreference.value = e.matches ? 'dark' : 'light'
updateResolvedTheme()
})
}
// 4. Initial resolution
updateResolvedTheme()
}
/**
* Set theme preference (light, dark, or system)
*/
function setTheme(theme: Theme) {
preference.value = theme
// Persist to localStorage
try {
localStorage.setItem(STORAGE_KEY, theme)
} catch {
console.warn('Could not save theme preference to localStorage')
}
updateResolvedTheme()
}
/**
* Toggle between light and dark (sets explicit preference, not system)
*/
function toggle() {
const newTheme = resolvedTheme.value === 'light' ? 'dark' : 'light'
setTheme(newTheme)
}
/**
* Cycle through themes: light -> dark -> system -> light
*/
function cycle() {
const order: Theme[] = ['light', 'dark', 'system']
const currentIndex = order.indexOf(preference.value)
const nextIndex = (currentIndex + 1) % order.length
setTheme(order[nextIndex])
}
// Watch preference changes
watch(preference, updateResolvedTheme)
return {
// State
preference,
resolvedTheme,
systemPreference,
// Getters
isDark,
isLight,
isSystem,
// Actions
initialize,
setTheme,
toggle,
cycle,
}
})

View File

@@ -0,0 +1,562 @@
import { defineStore } from 'pinia'
import { ref, shallowRef } from 'vue'
import type * as OV from 'online-3d-viewer'
import * as THREE from 'three'
import type { ViewDirection } from '@/services/viewCubeService'
export const useViewerStore = defineStore('viewer', () => {
// State - use shallowRef for complex objects that shouldn't be deeply reactive
const viewer = shallowRef<OV.EmbeddedViewer | null>(null)
const model = shallowRef<OV.Model | null>(null)
const scene = shallowRef<THREE.Scene | null>(null)
const renderer = shallowRef<THREE.WebGLRenderer | null>(null)
const camera = shallowRef<THREE.Camera | null>(null)
const isLoading = ref(false)
const loadingProgress = ref(0)
const loadingStage = ref<'downloading' | 'parsing' | null>(null)
const error = ref<string | null>(null)
const currentModelUrl = ref<string | null>(null)
// Exploded view state
const explosionFactor = ref(0)
const isExplodedViewEnabled = ref(false)
// Cross-section state
const crossSection = ref({
x: { enabled: false, position: 100 },
y: { enabled: false, position: 100 },
z: { enabled: false, position: 100 },
planeVisible: true, // Show/hide cutting plane visualization
sectionFlipped: false, // Flip all plane normals to show opposite region
})
// Render settings state
const renderSettings = ref({
renderMode: 'standard' as 'standard' | 'hiddenLine' | 'wireframe', // Render mode
edgesEnabled: true, // Edge lines toggle (default ON)
edgeLineWidth: 1, // Edge line width (0.5-5)
autoColorEnabled: true, // Auto-assign part colors toggle (default ON)
materialType: 'clay' as 'clay' | 'metal' | 'paint', // Global material type (clay default)
// Lighting settings
exposure: 1.0, // Tone mapping exposure / scene brightness (0.1 - 3.0)
mainLightIntensity: 0.8, // Main directional light intensity (0 - 2.0)
ambientLightIntensity: 0.6, // Ambient light intensity (0 - 2.0)
})
// Selection state
const selectedPartId = ref<string | null>(null)
// Context menu state
const contextMenu = ref({
visible: false,
x: 0,
y: 0,
partId: null as string | null,
})
// Actions
function setViewer(v: OV.EmbeddedViewer | null) {
viewer.value = v
if (v) {
// Access Three.js internals
const threeViewer = v.GetViewer()
if (threeViewer) {
scene.value = (threeViewer as unknown as { scene: THREE.Scene }).scene
renderer.value = (threeViewer as unknown as { renderer: THREE.WebGLRenderer }).renderer
camera.value = (threeViewer as unknown as { camera: THREE.Camera }).camera
}
} else {
scene.value = null
renderer.value = null
camera.value = null
model.value = null
}
}
function setModel(m: OV.Model | null) {
model.value = m
}
function setLoading(loading: boolean) {
isLoading.value = loading
if (!loading) {
loadingProgress.value = 0
loadingStage.value = null
}
}
function setLoadingProgress(progress: number, stage?: 'downloading' | 'parsing') {
loadingProgress.value = Math.max(0, Math.min(100, progress))
if (stage) {
loadingStage.value = stage
}
}
function setError(err: string | null) {
error.value = err
}
function setCurrentModelUrl(url: string | null) {
currentModelUrl.value = url
}
function setExplosionFactor(factor: number) {
explosionFactor.value = Math.max(0, Math.min(100, factor))
}
function setExplodedViewEnabled(enabled: boolean) {
isExplodedViewEnabled.value = enabled
if (!enabled) {
explosionFactor.value = 0
}
}
function setCrossSectionAxis(axis: 'x' | 'y' | 'z', enabled: boolean) {
crossSection.value[axis].enabled = enabled
}
function setCrossSectionPosition(axis: 'x' | 'y' | 'z', position: number) {
crossSection.value[axis].position = Math.max(0, Math.min(100, position))
}
function setCrossSectionPlaneVisible(visible: boolean) {
crossSection.value.planeVisible = visible
}
function setCrossSectionFlipped(flipped: boolean) {
crossSection.value.sectionFlipped = flipped
}
function setRenderMode(mode: 'standard' | 'hiddenLine' | 'wireframe') {
renderSettings.value.renderMode = mode
}
function setEdgesEnabled(enabled: boolean) {
renderSettings.value.edgesEnabled = enabled
}
function setEdgeLineWidth(width: number) {
renderSettings.value.edgeLineWidth = Math.max(0.5, Math.min(5, width))
}
function setAutoColorEnabled(enabled: boolean) {
renderSettings.value.autoColorEnabled = enabled
}
function setMaterialType(type: 'clay' | 'metal' | 'paint') {
renderSettings.value.materialType = type
}
function setExposure(value: number) {
renderSettings.value.exposure = Math.max(0.1, Math.min(3.0, value))
}
function setMainLightIntensity(value: number) {
renderSettings.value.mainLightIntensity = Math.max(0, Math.min(2.0, value))
}
function setAmbientLightIntensity(value: number) {
renderSettings.value.ambientLightIntensity = Math.max(0, Math.min(2.0, value))
}
function setSelectedPart(id: string | null) {
selectedPartId.value = id
}
function showContextMenu(x: number, y: number, partId: string) {
contextMenu.value = { visible: true, x, y, partId }
}
function hideContextMenu() {
contextMenu.value.visible = false
contextMenu.value.partId = null
}
function resetFeatures() {
// Preserve global settings that should persist across model switches
const currentAutoColor = renderSettings.value.autoColorEnabled
const currentEdges = renderSettings.value.edgesEnabled
explosionFactor.value = 0
isExplodedViewEnabled.value = false
selectedPartId.value = null
contextMenu.value = { visible: false, x: 0, y: 0, partId: null }
crossSection.value = {
x: { enabled: false, position: 100 },
y: { enabled: false, position: 100 },
z: { enabled: false, position: 100 },
planeVisible: true,
sectionFlipped: false,
}
renderSettings.value = {
renderMode: 'standard',
edgesEnabled: currentEdges, // Preserve edge setting
edgeLineWidth: 1,
autoColorEnabled: currentAutoColor, // Preserve auto-color setting
materialType: 'clay', // Default to clay material
exposure: 1.0, // Default exposure
mainLightIntensity: 0.8, // Default main light
ambientLightIntensity: 0.6, // Default ambient light
}
}
function forceRender() {
if (viewer.value) {
const threeViewer = viewer.value.GetViewer()
if (threeViewer && typeof (threeViewer as unknown as { Render: () => void }).Render === 'function') {
(threeViewer as unknown as { Render: () => void }).Render()
}
}
}
/**
* Fit the camera to show the entire model
*/
function fitToView() {
if (!viewer.value) return
const threeViewer = viewer.value.GetViewer()
if (!threeViewer) return
// Access navigation and bounding sphere from Online3DViewer
const nav = (threeViewer as unknown as {
navigation?: {
FitSphereToWindow: (sphere: unknown, animate: boolean) => void
}
}).navigation
const getBoundingSphere = (threeViewer as unknown as {
GetBoundingSphere?: () => unknown
}).GetBoundingSphere
if (nav && getBoundingSphere) {
const sphere = getBoundingSphere.call(threeViewer)
nav.FitSphereToWindow(sphere, true) // true = animate
}
}
// Camera animation state
let cameraAnimationId: number | null = null
/**
* Animate camera to a specific view direction (for ViewCube)
*/
function animateCameraToView(direction: ViewDirection, duration: number = 500): void {
if (!viewer.value || !camera.value) return
const threeViewer = viewer.value.GetViewer()
if (!threeViewer) return
// Cancel any ongoing animation
if (cameraAnimationId !== null) {
cancelAnimationFrame(cameraAnimationId)
}
// Get bounding sphere for model center and distance calculation
// GetBoundingSphere takes a callback function to filter which meshes to include
// Pass a function that returns true for all meshes to include everything
const viewerWithSphere = threeViewer as unknown as {
GetBoundingSphere?: (needToProcess: (meshUserData: unknown) => boolean) => { center: { x: number; y: number; z: number }; radius: number }
}
if (!viewerWithSphere.GetBoundingSphere) return
// Call with a filter function that includes all meshes
const boundingSphere = viewerWithSphere.GetBoundingSphere(() => true)
if (!boundingSphere) return
// Extract center and radius from bounding sphere
const sphereCenter = boundingSphere.center as { x: number; y: number; z: number }
if (!sphereCenter || typeof sphereCenter.x !== 'number') return
// Create Vector3 from our THREE module to avoid cross-module issues
const center = new THREE.Vector3(sphereCenter.x, sphereCenter.y, sphereCenter.z)
const radius = boundingSphere.radius
if (typeof radius !== 'number' || radius <= 0) return
const distance = radius * 2.5
// Calculate target position based on direction
const positions: Record<ViewDirection, THREE.Vector3> = {
front: new THREE.Vector3(center.x, center.y, center.z + distance),
back: new THREE.Vector3(center.x, center.y, center.z - distance),
right: new THREE.Vector3(center.x + distance, center.y, center.z),
left: new THREE.Vector3(center.x - distance, center.y, center.z),
top: new THREE.Vector3(center.x, center.y + distance, center.z + 0.001), // Small offset to avoid gimbal lock
bottom: new THREE.Vector3(center.x, center.y - distance, center.z + 0.001),
}
const targetPosition = positions[direction]
const currentCamera = camera.value as THREE.PerspectiveCamera | null
if (!currentCamera) return
// Create startPosition using our THREE module to avoid cross-module Vector3 issues
const camPos = currentCamera.position
const startPosition = new THREE.Vector3(camPos.x, camPos.y, camPos.z)
const startTime = performance.now()
// Get navigation to directly update its internal camera state
const nav = (threeViewer as unknown as {
navigation?: {
camera: {
eye: { x: number; y: number; z: number }
center: { x: number; y: number; z: number }
up: { x: number; y: number; z: number }
}
Update: () => void
}
}).navigation
if (!nav || !nav.camera) {
console.error('Navigation camera not available')
return
}
const animate = (currentTime: number) => {
try {
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / duration, 1)
// Ease-out cubic for smooth deceleration
const eased = 1 - Math.pow(1 - progress, 3)
// Interpolate position
const newX = startPosition.x + (targetPosition.x - startPosition.x) * eased
const newY = startPosition.y + (targetPosition.y - startPosition.y) * eased
const newZ = startPosition.z + (targetPosition.z - startPosition.z) * eased
// Directly update navigation's internal camera state (plain objects, no Vector3)
nav.camera.eye.x = newX
nav.camera.eye.y = newY
nav.camera.eye.z = newZ
// Keep center at model center
nav.camera.center.x = center.x
nav.camera.center.y = center.y
nav.camera.center.z = center.z
// Update up vector for top/bottom views
if (direction === 'top') {
nav.camera.up.x = 0
nav.camera.up.y = 0
nav.camera.up.z = -1
} else if (direction === 'bottom') {
nav.camera.up.x = 0
nav.camera.up.y = 0
nav.camera.up.z = 1
} else {
nav.camera.up.x = 0
nav.camera.up.y = 1
nav.camera.up.z = 0
}
// Sync the navigation's internal state with the Three.js camera
nav.Update()
// Force update camera matrix so quaternion is available for ViewCube sync
if (camera.value) {
camera.value.updateMatrixWorld(true)
}
if (progress < 1) {
cameraAnimationId = requestAnimationFrame(animate)
} else {
cameraAnimationId = null
}
} catch (err) {
console.error('Animation error:', err instanceof Error ? err.message : err)
console.error('Stack:', err instanceof Error ? err.stack : 'no stack')
cameraAnimationId = null
}
}
cameraAnimationId = requestAnimationFrame(animate)
}
/**
* Rotate the camera by delta amounts (for ViewCube drag rotation)
*/
function rotateCamera(deltaX: number, deltaY: number): void {
if (!viewer.value) return
const threeViewer = viewer.value.GetViewer()
if (!threeViewer) return
const nav = (threeViewer as unknown as {
navigation?: {
camera: {
eye: { x: number; y: number; z: number }
center: { x: number; y: number; z: number }
up: { x: number; y: number; z: number }
}
Update: () => void
}
}).navigation
if (!nav || !nav.camera) return
// Calculate camera-to-center offset vector
const eye = new THREE.Vector3(nav.camera.eye.x, nav.camera.eye.y, nav.camera.eye.z)
const center = new THREE.Vector3(nav.camera.center.x, nav.camera.center.y, nav.camera.center.z)
const offset = eye.clone().sub(center)
// Convert delta to radians (adjust sensitivity)
const azimuthAngle = -deltaX * 0.01 // Horizontal rotation
const polarAngle = -deltaY * 0.01 // Vertical rotation
// Use spherical coordinates for orbit rotation
const spherical = new THREE.Spherical().setFromVector3(offset)
spherical.theta += azimuthAngle
spherical.phi += polarAngle
// Clamp polar angle to prevent camera flip
spherical.phi = Math.max(0.01, Math.min(Math.PI - 0.01, spherical.phi))
// Apply new position
offset.setFromSpherical(spherical)
const newEye = center.clone().add(offset)
nav.camera.eye.x = newEye.x
nav.camera.eye.y = newEye.y
nav.camera.eye.z = newEye.z
nav.Update()
}
/**
* Rotate the camera 90 degrees clockwise around the current viewing axis
* (for ViewCube single-face click rotation) with smooth animation
*/
function rotateCameraAroundAxis(direction: ViewDirection, duration: number = 300): void {
if (!viewer.value) return
const threeViewer = viewer.value.GetViewer()
if (!threeViewer) return
// Cancel any ongoing animation
if (cameraAnimationId !== null) {
cancelAnimationFrame(cameraAnimationId)
}
const nav = (threeViewer as unknown as {
navigation?: {
camera: {
eye: { x: number; y: number; z: number }
center: { x: number; y: number; z: number }
up: { x: number; y: number; z: number }
}
Update: () => void
}
}).navigation
if (!nav || !nav.camera) return
// Get current up vector as start point
const startUp = new THREE.Vector3(nav.camera.up.x, nav.camera.up.y, nav.camera.up.z)
// Rotation axis is the viewing direction (from camera eye toward center)
// When viewing "front", the axis is (0, 0, -1)
const axisMap: Record<ViewDirection, THREE.Vector3> = {
front: new THREE.Vector3(0, 0, -1),
back: new THREE.Vector3(0, 0, 1),
right: new THREE.Vector3(-1, 0, 0),
left: new THREE.Vector3(1, 0, 0),
top: new THREE.Vector3(0, -1, 0),
bottom: new THREE.Vector3(0, 1, 0),
}
// Calculate target up vector (rotated 90 degrees clockwise)
const targetUp = startUp.clone()
const quaternion = new THREE.Quaternion()
quaternion.setFromAxisAngle(axisMap[direction], -Math.PI / 2)
targetUp.applyQuaternion(quaternion)
const startTime = performance.now()
const animate = (currentTime: number) => {
try {
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / duration, 1)
// Ease-out cubic for smooth deceleration
const eased = 1 - Math.pow(1 - progress, 3)
// Interpolate up vector
const currentUp = new THREE.Vector3().lerpVectors(startUp, targetUp, eased)
currentUp.normalize()
// Update navigation's up vector
nav.camera.up.x = currentUp.x
nav.camera.up.y = currentUp.y
nav.camera.up.z = currentUp.z
nav.Update()
// Force update camera matrix for ViewCube sync
if (camera.value) {
camera.value.updateMatrixWorld(true)
}
if (progress < 1) {
cameraAnimationId = requestAnimationFrame(animate)
} else {
cameraAnimationId = null
}
} catch (err) {
console.error('Rotation animation error:', err)
cameraAnimationId = null
}
}
cameraAnimationId = requestAnimationFrame(animate)
}
return {
// State
viewer,
model,
scene,
renderer,
camera,
isLoading,
loadingProgress,
loadingStage,
error,
currentModelUrl,
explosionFactor,
isExplodedViewEnabled,
crossSection,
renderSettings,
selectedPartId,
contextMenu,
// Actions
setViewer,
setModel,
setLoading,
setLoadingProgress,
setError,
setCurrentModelUrl,
setExplosionFactor,
setExplodedViewEnabled,
setCrossSectionAxis,
setCrossSectionPosition,
setCrossSectionPlaneVisible,
setCrossSectionFlipped,
setRenderMode,
setEdgesEnabled,
setEdgeLineWidth,
setAutoColorEnabled,
setMaterialType,
setExposure,
setMainLightIntensity,
setAmbientLightIntensity,
setSelectedPart,
showContextMenu,
hideContextMenu,
resetFeatures,
forceRender,
fitToView,
animateCameraToView,
rotateCamera,
rotateCameraAroundAxis,
}
})

1248
frontend/src/style.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,71 @@
export interface Model {
id: string
name: string
original_filename: string
original_format: string
file_size: number
raw_storage_key: string | null
converted_storage_key: string | null
thumbnail_storage_key: string | null
model_url: string | null
thumbnail_url: string | null
conversion_status: ConversionStatus
conversion_error: string | null
metadata: ModelMetadata
created_at: string
updated_at: string
}
export type ConversionStatus = 'pending' | 'processing' | 'completed' | 'failed'
export interface ModelMetadata {
vertices?: number
faces?: number
parts_count?: number
bounding_box?: BoundingBox
parts?: ModelPartMeta[]
[key: string]: unknown
}
export interface BoundingBox {
min: { x: number; y: number; z: number }
max: { x: number; y: number; z: number }
}
export interface ModelPartMeta {
name: string
bounding_box?: BoundingBox
center_point?: { x: number; y: number; z: number }
}
export interface ModelPart {
id: string
model_id: string
name: string | null
mesh_index: number | null
bounding_box: BoundingBox
center_point: { x: number; y: number; z: number }
parent_part_id: string | null
created_at: string
}
export interface UploadInitResponse {
uploadUrl: string
modelId: string
storageKey: string
}
export interface ApiResponse<T> {
success: boolean
data?: T
error?: {
code: string
message: string
details?: unknown
}
meta?: {
total?: number
limit?: number
offset?: number
}
}

View File

@@ -0,0 +1,40 @@
import type * as THREE from 'three'
/**
* Represents a node in the parts tree hierarchy
*/
export interface TreeNode {
/** Unique identifier (THREE.Object3D.uuid) */
id: string
/** Display name (object.name or fallback) */
name: string
/** Nesting level for indentation */
depth: number
/** Own visibility state */
visible: boolean
/** Original material opacity (for restore) */
originalOpacity: number
/** UI expansion state */
isExpanded: boolean
/** Total number of descendants */
childCount: number
/** Reference to Three.js object */
object: THREE.Object3D
/** Child nodes */
children: TreeNode[]
}
/**
* Flattened tree node for virtual scrolling
*/
export interface FlatTreeNode {
id: string
name: string
depth: number
visible: boolean
originalOpacity: number
isExpanded: boolean
hasChildren: boolean
childCount: number
object: THREE.Object3D
}

9
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

@@ -0,0 +1,260 @@
/**
* Web Worker for section cap geometry calculation
* Receives pre-computed intersection segments from main thread
* Performs contour building and triangulation off the main thread
*
* No Three.js or three-mesh-bvh dependencies - pure math only
*/
/* eslint-disable no-restricted-globals */
import earcut from 'earcut'
type Axis = 'x' | 'y' | 'z'
type Vec3 = [number, number, number]
interface WorkerInput {
segments: Float32Array // Packed: [x1,y1,z1, x2,y2,z2, ...] (6 floats per segment)
axis: Axis
requestId: number // Unique request ID to match responses
}
interface GeometryResult {
vertices: Float32Array
indices: Uint32Array
}
interface WorkerOutput extends GeometryResult {
axis: Axis // Echo back axis
requestId: number // Echo back requestId
}
// Parse packed Float32Array into segment array
function parseSegments(packed: Float32Array): Array<[Vec3, Vec3]> {
const segments: Array<[Vec3, Vec3]> = []
const count = packed.length / 6
for (let i = 0; i < count; i++) {
const offset = i * 6
segments.push([
[packed[offset], packed[offset + 1], packed[offset + 2]],
[packed[offset + 3], packed[offset + 4], packed[offset + 5]]
])
}
return segments
}
function vec3Distance(a: Vec3, b: Vec3): number {
const dx = a[0] - b[0]
const dy = a[1] - b[1]
const dz = a[2] - b[2]
return Math.sqrt(dx * dx + dy * dy + dz * dz)
}
function vec3Clone(v: Vec3): Vec3 {
return [v[0], v[1], v[2]]
}
/**
* Build closed contours from line segments using spatial hashing
* O(n) instead of O(n²) for finding connecting segments
*/
function buildContoursWithSpatialHash(segments: Array<[Vec3, Vec3]>): Vec3[][] {
if (segments.length === 0) return []
const epsilon = 1e-4
const cellSize = epsilon * 10
interface EndpointEntry {
segIdx: number
pointIdx: 0 | 1
point: Vec3
}
const hash = new Map<string, EndpointEntry[]>()
const getKey = (p: Vec3): string => {
const x = Math.floor(p[0] / cellSize)
const y = Math.floor(p[1] / cellSize)
const z = Math.floor(p[2] / cellSize)
return `${x},${y},${z}`
}
// Index all segment endpoints
for (let i = 0; i < segments.length; i++) {
const seg = segments[i]
for (const pointIdx of [0, 1] as const) {
const point = seg[pointIdx]
const key = getKey(point)
if (!hash.has(key)) hash.set(key, [])
hash.get(key)!.push({ segIdx: i, pointIdx, point })
}
}
// O(1) average lookup for connecting point
const findConnecting = (point: Vec3, used: Set<number>): { segIdx: number; startEnd: 0 | 1 } | null => {
const cx = Math.floor(point[0] / cellSize)
const cy = Math.floor(point[1] / cellSize)
const cz = Math.floor(point[2] / cellSize)
let bestDist = epsilon
let best: { segIdx: number; startEnd: 0 | 1 } | null = null
// Check 3x3x3 neighborhood
for (let dx = -1; dx <= 1; dx++) {
for (let dy = -1; dy <= 1; dy++) {
for (let dz = -1; dz <= 1; dz++) {
const key = `${cx + dx},${cy + dy},${cz + dz}`
const entries = hash.get(key)
if (!entries) continue
for (const entry of entries) {
if (used.has(entry.segIdx)) continue
const d = vec3Distance(point, entry.point)
if (d < bestDist) {
bestDist = d
best = { segIdx: entry.segIdx, startEnd: entry.pointIdx }
}
}
}
}
}
return best
}
// Build contours by connecting segments
const contours: Vec3[][] = []
const used = new Set<number>()
while (used.size < segments.length) {
let startIdx = -1
for (let i = 0; i < segments.length; i++) {
if (!used.has(i)) {
startIdx = i
break
}
}
if (startIdx === -1) break
const contour: Vec3[] = []
used.add(startIdx)
contour.push(vec3Clone(segments[startIdx][0]))
contour.push(vec3Clone(segments[startIdx][1]))
let extended = true
while (extended) {
extended = false
const endPoint = contour[contour.length - 1]
const nextEnd = findConnecting(endPoint, used)
if (nextEnd) {
used.add(nextEnd.segIdx)
const seg = segments[nextEnd.segIdx]
const newPoint = nextEnd.startEnd === 0 ? seg[1] : seg[0]
contour.push(vec3Clone(newPoint))
extended = true
}
const startPoint = contour[0]
const nextStart = findConnecting(startPoint, used)
if (nextStart) {
used.add(nextStart.segIdx)
const seg = segments[nextStart.segIdx]
const newPoint = nextStart.startEnd === 0 ? seg[1] : seg[0]
contour.unshift(vec3Clone(newPoint))
extended = true
}
}
if (contour.length >= 3) {
contours.push(contour)
}
}
return contours
}
/**
* Create cap geometry from contours by triangulating in 2D
*/
function createCapGeometryFromContours(
contours: Vec3[][],
axis: Axis
): GeometryResult {
if (contours.length === 0) {
return {
vertices: new Float32Array(0),
indices: new Uint32Array(0)
}
}
const allVertices: number[] = []
const allIndices: number[] = []
let vertexOffset = 0
for (const contour of contours) {
if (contour.length < 3) continue
// Project 3D points to 2D based on axis
const coords2D: number[] = []
for (const p of contour) {
switch (axis) {
case 'x':
coords2D.push(p[2], p[1])
break
case 'y':
coords2D.push(p[0], p[2])
break
case 'z':
coords2D.push(p[0], p[1])
break
}
}
// Triangulate the 2D polygon
const indices = earcut(coords2D)
// Add vertices (3D)
for (const p of contour) {
allVertices.push(p[0], p[1], p[2])
}
// Add indices with offset
for (const idx of indices) {
allIndices.push(idx + vertexOffset)
}
vertexOffset += contour.length
}
return {
vertices: new Float32Array(allVertices),
indices: new Uint32Array(allIndices)
}
}
// Worker message handler
const workerSelf = self as unknown as {
onmessage: ((e: MessageEvent<WorkerInput>) => void) | null
postMessage: (message: WorkerOutput, transfer?: Transferable[]) => void
}
workerSelf.onmessage = (e: MessageEvent<WorkerInput>) => {
const { segments, axis, requestId } = e.data
const startTime = performance.now()
// Parse packed segments
const segmentArray = parseSegments(segments)
// Build contours with spatial hash (O(n))
const contours = buildContoursWithSpatialHash(segmentArray)
// Triangulate
const result = createCapGeometryFromContours(contours, axis)
const elapsed = performance.now() - startTime
console.log(`[Worker] Contour + triangulate: ${segmentArray.length} segments → ${contours.length} contours in ${elapsed.toFixed(1)}ms`)
// Include axis and requestId in response to match with original request
const response: WorkerOutput = { ...result, axis, requestId }
workerSelf.postMessage(response, [result.vertices.buffer, result.indices.buffer])
}

25
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}

2
frontend/vite.config.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
declare const _default: import("vite").UserConfig;
export default _default;

25
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
var __dirname = dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
build: {
target: 'esnext',
},
});

27
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,27 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
build: {
target: 'esnext',
},
})