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:
55
.env.example
Normal file
55
.env.example
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# 3D Model Viewer - Environment Variables
|
||||||
|
# Copy this file to .env and update values as needed
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# API Server
|
||||||
|
# ===================
|
||||||
|
NODE_ENV=development
|
||||||
|
PORT=3000
|
||||||
|
API_PREFIX=/api
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# Database (PostgreSQL)
|
||||||
|
# ===================
|
||||||
|
DATABASE_URL=postgresql://viewer:viewer_password@localhost:5432/viewer_db
|
||||||
|
DATABASE_POOL_MIN=2
|
||||||
|
DATABASE_POOL_MAX=10
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# Redis (BullMQ Queue)
|
||||||
|
# ===================
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# MinIO Object Storage
|
||||||
|
# ===================
|
||||||
|
MINIO_ENDPOINT=localhost
|
||||||
|
MINIO_PORT=9000
|
||||||
|
MINIO_ACCESS_KEY=minioadmin
|
||||||
|
MINIO_SECRET_KEY=minioadmin
|
||||||
|
MINIO_USE_SSL=false
|
||||||
|
MINIO_BUCKET_RAW=raw-models
|
||||||
|
MINIO_BUCKET_CONVERTED=converted-models
|
||||||
|
MINIO_BUCKET_THUMBNAILS=thumbnails
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# Security
|
||||||
|
# ===================
|
||||||
|
CORS_ORIGINS=http://localhost:5173
|
||||||
|
PRESIGNED_URL_EXPIRY=3600
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# Logging
|
||||||
|
# ===================
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# Worker Settings
|
||||||
|
# ===================
|
||||||
|
WORKER_CONCURRENCY=2
|
||||||
|
WORKER_MAX_RETRIES=3
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# Frontend (Vite)
|
||||||
|
# ===================
|
||||||
|
VITE_API_URL=http://localhost:3000
|
||||||
52
.gitignore
vendored
Normal file
52
.gitignore
vendored
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnpm-store/
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# IDE and editor
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.Python
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# Claude Code
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# Docker volumes (local data)
|
||||||
|
postgres_data/
|
||||||
|
redis_data/
|
||||||
|
minio_data/
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
.cache/
|
||||||
2
CLAUDE.md
Normal file
2
CLAUDE.md
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
- 每次更新都要确保到docker compose更新
|
||||||
|
- always redeploy docker compose after change
|
||||||
56
api/Dockerfile
Normal file
56
api/Dockerfile
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 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 TypeScript
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install pnpm
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
|
adduser --system --uid 1001 expressjs
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package.json pnpm-lock.yaml* ./
|
||||||
|
|
||||||
|
# Install production dependencies only
|
||||||
|
RUN pnpm install --prod
|
||||||
|
|
||||||
|
# Copy built application
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
# Set ownership
|
||||||
|
RUN chown -R expressjs:nodejs /app
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER expressjs
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD wget -q --spider http://localhost:3000/api/health || exit 1
|
||||||
|
|
||||||
|
# Start application
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
44
api/package.json
Normal file
44
api/package.json
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"name": "viewer3d-api",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "3D Model Viewer API Server",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"lint": "eslint src --ext .ts",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bullmq": "^5.12.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.0",
|
||||||
|
"express": "^4.21.0",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"ioredis": "^5.4.0",
|
||||||
|
"minio": "^8.0.0",
|
||||||
|
"multer": "^2.0.2",
|
||||||
|
"pg": "^8.12.0",
|
||||||
|
"pino": "^9.0.0",
|
||||||
|
"uuid": "^10.0.0",
|
||||||
|
"zod": "^3.23.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/multer": "^2.0.0",
|
||||||
|
"@types/node": "^20.14.0",
|
||||||
|
"@types/pg": "^8.11.6",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||||
|
"@typescript-eslint/parser": "^7.0.0",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"tsx": "^4.16.0",
|
||||||
|
"typescript": "^5.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
2975
api/pnpm-lock.yaml
generated
Normal file
2975
api/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
55
api/src/app.ts
Normal file
55
api/src/app.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import { env } from './config/env.js';
|
||||||
|
import logger from './utils/logger.js';
|
||||||
|
import { errorHandler, notFoundHandler } from './middleware/error.middleware.js';
|
||||||
|
import healthRoutes from './routes/health.routes.js';
|
||||||
|
import uploadRoutes from './routes/upload.routes.js';
|
||||||
|
import modelsRoutes from './routes/models.routes.js';
|
||||||
|
|
||||||
|
export function createApp(): express.Application {
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// Security middleware
|
||||||
|
app.use(helmet({
|
||||||
|
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// CORS
|
||||||
|
app.use(cors({
|
||||||
|
origin: env.CORS_ORIGINS,
|
||||||
|
credentials: true,
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
|
allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
|
||||||
|
exposedHeaders: ['X-Request-ID'],
|
||||||
|
maxAge: 86400,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Request logging middleware
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (req.url !== '/api/health') {
|
||||||
|
logger.info({ method: req.method, url: req.url }, 'Request received');
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Body parsing
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
app.use('/api/health', healthRoutes);
|
||||||
|
app.use('/api/upload', uploadRoutes);
|
||||||
|
app.use('/api/models', modelsRoutes);
|
||||||
|
|
||||||
|
// 404 handler
|
||||||
|
app.use(notFoundHandler);
|
||||||
|
|
||||||
|
// Error handler (must be last)
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createApp;
|
||||||
56
api/src/config/env.ts
Normal file
56
api/src/config/env.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
// Load environment variables
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const envSchema = z.object({
|
||||||
|
// Application
|
||||||
|
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||||
|
PORT: z.string().transform(Number).default('3000'),
|
||||||
|
API_PREFIX: z.string().default('/api'),
|
||||||
|
|
||||||
|
// Database
|
||||||
|
DATABASE_URL: z.string().url(),
|
||||||
|
DATABASE_POOL_MIN: z.string().transform(Number).default('2'),
|
||||||
|
DATABASE_POOL_MAX: z.string().transform(Number).default('10'),
|
||||||
|
|
||||||
|
// Redis
|
||||||
|
REDIS_URL: z.string().url(),
|
||||||
|
|
||||||
|
// MinIO
|
||||||
|
MINIO_ENDPOINT: z.string(),
|
||||||
|
MINIO_PORT: z.string().transform(Number).default('9000'),
|
||||||
|
MINIO_PUBLIC_ENDPOINT: z.string().optional(), // External endpoint for browser access
|
||||||
|
MINIO_PUBLIC_PORT: z.string().transform(Number).optional(),
|
||||||
|
MINIO_ACCESS_KEY: z.string().min(1),
|
||||||
|
MINIO_SECRET_KEY: z.string().min(1),
|
||||||
|
MINIO_USE_SSL: z.string().transform((v) => v === 'true').default('false'),
|
||||||
|
MINIO_BUCKET_RAW: z.string().default('raw-models'),
|
||||||
|
MINIO_BUCKET_CONVERTED: z.string().default('converted-models'),
|
||||||
|
MINIO_BUCKET_THUMBNAILS: z.string().default('thumbnails'),
|
||||||
|
|
||||||
|
// Security
|
||||||
|
CORS_ORIGINS: z.string().transform((s) => s.split(',')).default('http://localhost:5173'),
|
||||||
|
PRESIGNED_URL_EXPIRY: z.string().transform(Number).default('3600'),
|
||||||
|
|
||||||
|
// Logging
|
||||||
|
LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error']).default('info'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Env = z.infer<typeof envSchema>;
|
||||||
|
|
||||||
|
function validateEnv(): Env {
|
||||||
|
try {
|
||||||
|
return envSchema.parse(process.env);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
const missing = error.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join('\n');
|
||||||
|
console.error('Environment validation failed:\n', missing);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const env = validateEnv();
|
||||||
94
api/src/index.ts
Normal file
94
api/src/index.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { createApp } from './app.js';
|
||||||
|
import { env } from './config/env.js';
|
||||||
|
import logger from './utils/logger.js';
|
||||||
|
import { testConnection, closePool } from './services/database.service.js';
|
||||||
|
import { initializeBuckets } from './services/storage.service.js';
|
||||||
|
import { closeQueue, setupQueueEvents } from './services/queue.service.js';
|
||||||
|
import * as modelsService from './services/models.service.js';
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
logger.info({ env: env.NODE_ENV }, 'Starting API server...');
|
||||||
|
|
||||||
|
// Test database connection
|
||||||
|
const dbConnected = await testConnection();
|
||||||
|
if (!dbConnected) {
|
||||||
|
logger.fatal('Failed to connect to database');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
logger.info('Database connected');
|
||||||
|
|
||||||
|
// Initialize MinIO buckets
|
||||||
|
try {
|
||||||
|
await initializeBuckets();
|
||||||
|
logger.info('MinIO buckets initialized');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error, 'Failed to initialize MinIO buckets');
|
||||||
|
// Continue anyway - buckets might already exist
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup queue event handlers
|
||||||
|
setupQueueEvents(
|
||||||
|
async (jobId, result) => {
|
||||||
|
// Update model on job completion
|
||||||
|
const data = result as { modelUrl?: string; thumbnailUrl?: string; metadata?: Record<string, unknown> };
|
||||||
|
await modelsService.updateModel(jobId, {
|
||||||
|
conversion_status: 'completed',
|
||||||
|
model_url: data.modelUrl,
|
||||||
|
thumbnail_url: data.thumbnailUrl,
|
||||||
|
metadata: data.metadata,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async (jobId, error) => {
|
||||||
|
// Update model on job failure
|
||||||
|
await modelsService.updateModel(jobId, {
|
||||||
|
conversion_status: 'failed',
|
||||||
|
conversion_error: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create Express app
|
||||||
|
const app = createApp();
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
const server = app.listen(env.PORT, () => {
|
||||||
|
logger.info({ port: env.PORT }, 'API server listening');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
const shutdown = async (signal: string): Promise<void> => {
|
||||||
|
logger.info({ signal }, 'Shutdown signal received');
|
||||||
|
|
||||||
|
// Stop accepting new connections
|
||||||
|
server.close(() => {
|
||||||
|
logger.info('HTTP server closed');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close queue connections
|
||||||
|
await closeQueue();
|
||||||
|
|
||||||
|
// Close database pool
|
||||||
|
await closePool();
|
||||||
|
|
||||||
|
logger.info('Graceful shutdown complete');
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
|
|
||||||
|
// Handle uncaught errors
|
||||||
|
process.on('uncaughtException', (error) => {
|
||||||
|
logger.fatal(error, 'Uncaught exception');
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('unhandledRejection', (reason) => {
|
||||||
|
logger.error({ reason }, 'Unhandled rejection');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
logger.fatal(error, 'Failed to start server');
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
119
api/src/middleware/error.middleware.ts
Normal file
119
api/src/middleware/error.middleware.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
|
import { ZodError } from 'zod';
|
||||||
|
import logger from '../utils/logger.js';
|
||||||
|
|
||||||
|
export interface ApiError extends Error {
|
||||||
|
statusCode?: number;
|
||||||
|
code?: string;
|
||||||
|
details?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom error classes
|
||||||
|
*/
|
||||||
|
export class NotFoundError extends Error implements ApiError {
|
||||||
|
statusCode = 404;
|
||||||
|
code = 'NOT_FOUND';
|
||||||
|
|
||||||
|
constructor(message: string = 'Resource not found') {
|
||||||
|
super(message);
|
||||||
|
this.name = 'NotFoundError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ValidationError extends Error implements ApiError {
|
||||||
|
statusCode = 400;
|
||||||
|
code = 'VALIDATION_ERROR';
|
||||||
|
details?: unknown;
|
||||||
|
|
||||||
|
constructor(message: string = 'Validation failed', details?: unknown) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ValidationError';
|
||||||
|
this.details = details;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConflictError extends Error implements ApiError {
|
||||||
|
statusCode = 409;
|
||||||
|
code = 'CONFLICT';
|
||||||
|
|
||||||
|
constructor(message: string = 'Resource conflict') {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ConflictError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StorageError extends Error implements ApiError {
|
||||||
|
statusCode = 503;
|
||||||
|
code = 'STORAGE_ERROR';
|
||||||
|
|
||||||
|
constructor(message: string = 'Storage service error') {
|
||||||
|
super(message);
|
||||||
|
this.name = 'StorageError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global error handler middleware
|
||||||
|
*/
|
||||||
|
export function errorHandler(
|
||||||
|
err: ApiError,
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
_next: NextFunction
|
||||||
|
): void {
|
||||||
|
// Log the error
|
||||||
|
logger.error({
|
||||||
|
err,
|
||||||
|
method: req.method,
|
||||||
|
url: req.url,
|
||||||
|
body: req.body,
|
||||||
|
}, 'Request error');
|
||||||
|
|
||||||
|
// Handle Zod validation errors
|
||||||
|
if (err instanceof ZodError) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
message: 'Validation failed',
|
||||||
|
details: err.errors.map((e) => ({
|
||||||
|
path: e.path.join('.'),
|
||||||
|
message: e.message,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle custom errors
|
||||||
|
const statusCode = err.statusCode || 500;
|
||||||
|
const code = err.code || 'INTERNAL_ERROR';
|
||||||
|
const message = statusCode === 500 ? 'Internal server error' : err.message;
|
||||||
|
|
||||||
|
const errorResponse: { code: string; message: string; details?: unknown } = {
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
if (err.details) {
|
||||||
|
errorResponse.details = err.details;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(statusCode).json({
|
||||||
|
success: false,
|
||||||
|
error: errorResponse,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 404 handler for unknown routes
|
||||||
|
*/
|
||||||
|
export function notFoundHandler(req: Request, res: Response): void {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: `Route ${req.method} ${req.path} not found`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
75
api/src/middleware/validation.middleware.ts
Normal file
75
api/src/middleware/validation.middleware.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation middleware factory
|
||||||
|
*/
|
||||||
|
export function validate<T extends z.ZodSchema>(
|
||||||
|
schema: T,
|
||||||
|
source: 'body' | 'query' | 'params' = 'body'
|
||||||
|
) {
|
||||||
|
return (req: Request, res: Response, next: NextFunction): void => {
|
||||||
|
try {
|
||||||
|
const data = schema.parse(req[source]);
|
||||||
|
req[source] = data;
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common validation schemas
|
||||||
|
export const schemas = {
|
||||||
|
// UUID parameter
|
||||||
|
uuidParam: z.object({
|
||||||
|
id: z.string().uuid('Invalid ID format'),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Pagination query
|
||||||
|
pagination: z.object({
|
||||||
|
limit: z.string().transform(Number).pipe(z.number().min(1).max(100)).optional(),
|
||||||
|
offset: z.string().transform(Number).pipe(z.number().min(0)).optional(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Model list query
|
||||||
|
modelListQuery: z.object({
|
||||||
|
search: z.string().max(255).optional(),
|
||||||
|
status: z.enum(['pending', 'processing', 'completed', 'failed']).optional(),
|
||||||
|
format: z.string().max(10).optional(),
|
||||||
|
limit: z.string().transform(Number).pipe(z.number().min(1).max(100)).default('50'),
|
||||||
|
offset: z.string().transform(Number).pipe(z.number().min(0)).default('0'),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Upload initialization
|
||||||
|
initUpload: z.object({
|
||||||
|
filename: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(255)
|
||||||
|
.refine(
|
||||||
|
(name) => !name.includes('..') && !name.includes('/'),
|
||||||
|
'Invalid filename'
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(name) => {
|
||||||
|
const ext = name.split('.').pop()?.toLowerCase();
|
||||||
|
return ['step', 'stp', 'stl', 'obj', 'glb', 'gltf', '3ds', 'fbx', 'iges', 'igs'].includes(ext || '');
|
||||||
|
},
|
||||||
|
'Unsupported file format'
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Upload confirmation
|
||||||
|
confirmUpload: z.object({
|
||||||
|
modelId: z.string().uuid(),
|
||||||
|
filename: z.string().min(1).max(255),
|
||||||
|
fileSize: z.number().positive().max(500 * 1024 * 1024), // Max 500MB
|
||||||
|
storageKey: z.string().min(1),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Model update
|
||||||
|
updateModel: z.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
92
api/src/routes/health.routes.ts
Normal file
92
api/src/routes/health.routes.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { Router, type IRouter } from 'express';
|
||||||
|
import { testConnection } from '../services/database.service.js';
|
||||||
|
import { redis } from '../services/queue.service.js';
|
||||||
|
import { minioClient } from '../services/storage.service.js';
|
||||||
|
import logger from '../utils/logger.js';
|
||||||
|
|
||||||
|
const router: IRouter = Router();
|
||||||
|
|
||||||
|
interface HealthCheck {
|
||||||
|
status: 'up' | 'down';
|
||||||
|
latency?: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /health - Basic liveness check
|
||||||
|
*/
|
||||||
|
router.get('/', (_req, res) => {
|
||||||
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /health/ready - Full readiness check
|
||||||
|
*/
|
||||||
|
router.get('/ready', async (_req, res) => {
|
||||||
|
const checks: Record<string, HealthCheck> = {};
|
||||||
|
let allHealthy = true;
|
||||||
|
|
||||||
|
// Check database
|
||||||
|
const dbStart = Date.now();
|
||||||
|
try {
|
||||||
|
const dbOk = await testConnection();
|
||||||
|
checks.database = {
|
||||||
|
status: dbOk ? 'up' : 'down',
|
||||||
|
latency: Date.now() - dbStart,
|
||||||
|
};
|
||||||
|
if (!dbOk) allHealthy = false;
|
||||||
|
} catch (error) {
|
||||||
|
checks.database = {
|
||||||
|
status: 'down',
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
};
|
||||||
|
allHealthy = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Redis
|
||||||
|
const redisStart = Date.now();
|
||||||
|
try {
|
||||||
|
await redis.ping();
|
||||||
|
checks.redis = {
|
||||||
|
status: 'up',
|
||||||
|
latency: Date.now() - redisStart,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
checks.redis = {
|
||||||
|
status: 'down',
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
};
|
||||||
|
allHealthy = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check MinIO
|
||||||
|
const minioStart = Date.now();
|
||||||
|
try {
|
||||||
|
await minioClient.listBuckets();
|
||||||
|
checks.minio = {
|
||||||
|
status: 'up',
|
||||||
|
latency: Date.now() - minioStart,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
checks.minio = {
|
||||||
|
status: 'down',
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
};
|
||||||
|
allHealthy = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
status: allHealthy ? 'healthy' : 'unhealthy',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
uptime: process.uptime(),
|
||||||
|
checks,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!allHealthy) {
|
||||||
|
logger.warn(response, 'Health check failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(allHealthy ? 200 : 503).json(response);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
303
api/src/routes/models.routes.ts
Normal file
303
api/src/routes/models.routes.ts
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
import { Router, type IRouter } from 'express';
|
||||||
|
import multer from 'multer';
|
||||||
|
import { validate, schemas } from '../middleware/validation.middleware.js';
|
||||||
|
import { NotFoundError } from '../middleware/error.middleware.js';
|
||||||
|
import * as modelsService from '../services/models.service.js';
|
||||||
|
import { addThumbnailJob } from '../services/queue.service.js';
|
||||||
|
import logger from '../utils/logger.js';
|
||||||
|
|
||||||
|
const router: IRouter = Router();
|
||||||
|
|
||||||
|
// Configure multer for thumbnail uploads (memory storage for small images)
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: {
|
||||||
|
fileSize: 2 * 1024 * 1024, // 2MB max
|
||||||
|
},
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
if (file.mimetype === 'image/png' || file.mimetype === 'image/jpeg') {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error('Only PNG and JPEG images are allowed'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /models - List all models
|
||||||
|
*/
|
||||||
|
router.get('/', validate(schemas.modelListQuery, 'query'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const query = req.query as unknown as {
|
||||||
|
search?: string;
|
||||||
|
status?: 'pending' | 'processing' | 'completed' | 'failed';
|
||||||
|
format?: string;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await modelsService.getModels({
|
||||||
|
search: query.search,
|
||||||
|
status: query.status,
|
||||||
|
format: query.format,
|
||||||
|
limit: Number(query.limit) || 20,
|
||||||
|
offset: Number(query.offset) || 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.models,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
limit: Number(query.limit) || 20,
|
||||||
|
offset: Number(query.offset) || 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /models/:id - Get a single model
|
||||||
|
*/
|
||||||
|
router.get('/:id', validate(schemas.uuidParam, 'params'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const model = await modelsService.getModelById(id);
|
||||||
|
if (!model) {
|
||||||
|
throw new NotFoundError('Model not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: model,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /models/:id/parts - Get model parts
|
||||||
|
*/
|
||||||
|
router.get('/:id/parts', validate(schemas.uuidParam, 'params'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const model = await modelsService.getModelById(id);
|
||||||
|
if (!model) {
|
||||||
|
throw new NotFoundError('Model not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = await modelsService.getModelParts(id);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: parts,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /models/:id/url - Get download URL for viewing
|
||||||
|
*/
|
||||||
|
router.get('/:id/url', validate(schemas.uuidParam, 'params'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const url = await modelsService.getModelDownloadUrl(id);
|
||||||
|
if (!url) {
|
||||||
|
throw new NotFoundError('Model not ready or not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { url },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /models/:id/lod - Get all LOD URLs for a model
|
||||||
|
*/
|
||||||
|
router.get('/:id/lod', validate(schemas.uuidParam, 'params'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const lodUrls = await modelsService.getModelLodUrls(id);
|
||||||
|
if (!lodUrls) {
|
||||||
|
throw new NotFoundError('Model not ready or not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: lodUrls,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /models/:id/lod/:level - Get URL for specific LOD level
|
||||||
|
*/
|
||||||
|
router.get('/:id/lod/:level', validate(schemas.uuidParam, 'params'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id, level } = req.params;
|
||||||
|
const lodLevel = parseInt(level, 10);
|
||||||
|
|
||||||
|
if (isNaN(lodLevel) || lodLevel < 0 || lodLevel > 2) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid LOD level. Must be 0, 1, or 2.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = await modelsService.getModelLodUrl(id, lodLevel);
|
||||||
|
if (!url) {
|
||||||
|
throw new NotFoundError('Model not ready or not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { url, level: lodLevel },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /models/:id - Update model metadata
|
||||||
|
*/
|
||||||
|
router.patch(
|
||||||
|
'/:id',
|
||||||
|
validate(schemas.uuidParam, 'params'),
|
||||||
|
validate(schemas.updateModel),
|
||||||
|
async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const updates = req.body;
|
||||||
|
|
||||||
|
const model = await modelsService.updateModel(id, updates);
|
||||||
|
if (!model) {
|
||||||
|
throw new NotFoundError('Model not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ modelId: id }, 'Model updated');
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: model,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /models/:id - Delete a model
|
||||||
|
*/
|
||||||
|
router.delete('/:id', validate(schemas.uuidParam, 'params'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const deleted = await modelsService.deleteModel(id);
|
||||||
|
if (!deleted) {
|
||||||
|
throw new NotFoundError('Model not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ modelId: id }, 'Model deleted');
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Model deleted successfully',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /models/:id/thumbnail - Upload a thumbnail for a model
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/:id/thumbnail',
|
||||||
|
validate(schemas.uuidParam, 'params'),
|
||||||
|
upload.single('thumbnail'),
|
||||||
|
async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
if (!req.file) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'No thumbnail file provided',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = await modelsService.uploadThumbnail(id, req.file.buffer);
|
||||||
|
if (!model) {
|
||||||
|
throw new NotFoundError('Model not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ modelId: id }, 'Thumbnail uploaded');
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: model,
|
||||||
|
thumbnail_url: model.thumbnail_url,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /models/:id/regenerate-thumbnail - Regenerate thumbnail for a model
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/:id/regenerate-thumbnail',
|
||||||
|
validate(schemas.uuidParam, 'params'),
|
||||||
|
async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const model = await modelsService.getModelById(id);
|
||||||
|
if (!model) {
|
||||||
|
throw new NotFoundError('Model not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!model.model_url) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Model not ready for thumbnail generation',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await addThumbnailJob({ modelId: id, modelUrl: model.model_url });
|
||||||
|
|
||||||
|
logger.info({ modelId: id }, 'Thumbnail regeneration job queued');
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Thumbnail regeneration job queued',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
52
api/src/routes/upload.routes.ts
Normal file
52
api/src/routes/upload.routes.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { Router, type IRouter } from 'express';
|
||||||
|
import { validate, schemas } from '../middleware/validation.middleware.js';
|
||||||
|
import * as modelsService from '../services/models.service.js';
|
||||||
|
import logger from '../utils/logger.js';
|
||||||
|
|
||||||
|
const router: IRouter = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /upload/presigned-url - Get a presigned URL for uploading
|
||||||
|
*/
|
||||||
|
router.post('/presigned-url', validate(schemas.initUpload), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { filename } = req.body;
|
||||||
|
|
||||||
|
const result = await modelsService.initializeUpload(filename);
|
||||||
|
|
||||||
|
logger.info({ modelId: result.modelId, filename }, 'Upload initialized');
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
uploadUrl: result.uploadUrl,
|
||||||
|
modelId: result.modelId,
|
||||||
|
storageKey: result.storageKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /upload/complete - Confirm upload and start conversion
|
||||||
|
*/
|
||||||
|
router.post('/complete', validate(schemas.confirmUpload), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { modelId, filename, fileSize, storageKey } = req.body;
|
||||||
|
|
||||||
|
const model = await modelsService.confirmUpload(modelId, filename, fileSize, storageKey);
|
||||||
|
|
||||||
|
logger.info({ modelId: model.id }, 'Upload confirmed, conversion queued');
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: model,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
46
api/src/services/database.service.ts
Normal file
46
api/src/services/database.service.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import pg from 'pg';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
import logger from '../utils/logger.js';
|
||||||
|
|
||||||
|
const { Pool } = pg;
|
||||||
|
|
||||||
|
// Create connection pool
|
||||||
|
export const pool = new Pool({
|
||||||
|
connectionString: env.DATABASE_URL,
|
||||||
|
min: env.DATABASE_POOL_MIN,
|
||||||
|
max: env.DATABASE_POOL_MAX,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test connection on startup
|
||||||
|
pool.on('connect', () => {
|
||||||
|
logger.debug('New database connection established');
|
||||||
|
});
|
||||||
|
|
||||||
|
pool.on('error', (err) => {
|
||||||
|
logger.error(err, 'Unexpected database pool error');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test database connection
|
||||||
|
*/
|
||||||
|
export async function testConnection(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const client = await pool.connect();
|
||||||
|
await client.query('SELECT 1');
|
||||||
|
client.release();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error, 'Database connection test failed');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close database pool
|
||||||
|
*/
|
||||||
|
export async function closePool(): Promise<void> {
|
||||||
|
await pool.end();
|
||||||
|
logger.info('Database pool closed');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default pool;
|
||||||
328
api/src/services/models.service.ts
Normal file
328
api/src/services/models.service.ts
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import pool from './database.service.js';
|
||||||
|
import { addConversionJob, addThumbnailJob } from './queue.service.js';
|
||||||
|
import { BUCKETS, getPresignedUploadUrl, getPresignedDownloadUrl, deleteObjectsByPrefix, getPublicUrl, toPublicUrl, uploadBuffer } from './storage.service.js';
|
||||||
|
import type { Model, ModelPart, CreateModelInput, UpdateModelInput, ConversionStatus } from '../types/model.js';
|
||||||
|
import logger from '../utils/logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform model URLs to use public endpoint
|
||||||
|
*/
|
||||||
|
function transformModelUrls(model: Model): Model {
|
||||||
|
return {
|
||||||
|
...model,
|
||||||
|
model_url: toPublicUrl(model.model_url),
|
||||||
|
thumbnail_url: toPublicUrl(model.thumbnail_url),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all models with optional filtering
|
||||||
|
*/
|
||||||
|
export async function getModels(options: {
|
||||||
|
search?: string;
|
||||||
|
status?: ConversionStatus;
|
||||||
|
format?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
} = {}): Promise<{ models: Model[]; total: number }> {
|
||||||
|
const { search, status, format, limit = 50, offset = 0 } = options;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE 1=1';
|
||||||
|
const params: unknown[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (name ILIKE $${paramIndex} OR original_filename ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ` AND conversion_status = $${paramIndex}`;
|
||||||
|
params.push(status);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format) {
|
||||||
|
whereClause += ` AND original_format = $${paramIndex}`;
|
||||||
|
params.push(format);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const countResult = await pool.query(
|
||||||
|
`SELECT COUNT(*) FROM models ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
const total = parseInt(countResult.rows[0].count);
|
||||||
|
|
||||||
|
// Get models
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT * FROM models ${whereClause} ORDER BY created_at DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
[...params, limit, offset]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
models: (result.rows as Model[]).map(transformModelUrls),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single model by ID
|
||||||
|
*/
|
||||||
|
export async function getModelById(id: string): Promise<Model | null> {
|
||||||
|
const result = await pool.query('SELECT * FROM models WHERE id = $1', [id]);
|
||||||
|
const model = result.rows[0] as Model | null;
|
||||||
|
return model ? transformModelUrls(model) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new model record
|
||||||
|
*/
|
||||||
|
export async function createModel(input: CreateModelInput): Promise<Model> {
|
||||||
|
const { name, original_filename, original_format, file_size, raw_storage_key } = input;
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO models (name, original_filename, original_format, file_size, raw_storage_key, conversion_status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, 'pending')
|
||||||
|
RETURNING *`,
|
||||||
|
[name, original_filename, original_format, file_size, raw_storage_key]
|
||||||
|
);
|
||||||
|
|
||||||
|
const model = result.rows[0] as Model;
|
||||||
|
logger.info({ modelId: model.id }, 'Model record created');
|
||||||
|
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a model
|
||||||
|
*/
|
||||||
|
export async function updateModel(id: string, input: UpdateModelInput): Promise<Model | null> {
|
||||||
|
const fields: string[] = [];
|
||||||
|
const values: unknown[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(input)) {
|
||||||
|
if (value !== undefined) {
|
||||||
|
fields.push(`${key} = $${paramIndex}`);
|
||||||
|
values.push(key === 'metadata' ? JSON.stringify(value) : value);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.length === 0) {
|
||||||
|
return getModelById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(id);
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE models SET ${fields.join(', ')} WHERE id = $${paramIndex} RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] as Model | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a model and its associated files
|
||||||
|
*/
|
||||||
|
export async function deleteModel(id: string): Promise<boolean> {
|
||||||
|
const model = await getModelById(id);
|
||||||
|
if (!model) return false;
|
||||||
|
|
||||||
|
// Delete from database (cascade will delete model_parts)
|
||||||
|
await pool.query('DELETE FROM models WHERE id = $1', [id]);
|
||||||
|
|
||||||
|
// Delete files from MinIO
|
||||||
|
try {
|
||||||
|
await deleteObjectsByPrefix(BUCKETS.RAW, `${id}/`);
|
||||||
|
await deleteObjectsByPrefix(BUCKETS.CONVERTED, `${id}/`);
|
||||||
|
await deleteObjectsByPrefix(BUCKETS.THUMBNAILS, `${id}/`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ modelId: id, error }, 'Error deleting model files from storage');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ modelId: id }, 'Model deleted');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get model parts
|
||||||
|
*/
|
||||||
|
export async function getModelParts(modelId: string): Promise<ModelPart[]> {
|
||||||
|
const result = await pool.query(
|
||||||
|
'SELECT * FROM model_parts WHERE model_id = $1 ORDER BY name',
|
||||||
|
[modelId]
|
||||||
|
);
|
||||||
|
return result.rows as ModelPart[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate presigned upload URL for a new model
|
||||||
|
*/
|
||||||
|
export async function initializeUpload(filename: string): Promise<{
|
||||||
|
uploadUrl: string;
|
||||||
|
modelId: string;
|
||||||
|
storageKey: string;
|
||||||
|
}> {
|
||||||
|
const modelId = uuidv4();
|
||||||
|
const storageKey = `${modelId}/${filename}`;
|
||||||
|
const uploadUrl = await getPresignedUploadUrl(BUCKETS.RAW, storageKey);
|
||||||
|
|
||||||
|
return {
|
||||||
|
uploadUrl,
|
||||||
|
modelId,
|
||||||
|
storageKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm upload and start conversion
|
||||||
|
*/
|
||||||
|
export async function confirmUpload(
|
||||||
|
modelId: string,
|
||||||
|
filename: string,
|
||||||
|
fileSize: number,
|
||||||
|
storageKey: string
|
||||||
|
): Promise<Model> {
|
||||||
|
const format = filename.split('.').pop()?.toLowerCase() || 'unknown';
|
||||||
|
const name = filename.replace(/\.[^/.]+$/, ''); // Remove extension
|
||||||
|
|
||||||
|
// Create model record
|
||||||
|
const model = await createModel({
|
||||||
|
name,
|
||||||
|
original_filename: filename,
|
||||||
|
original_format: format,
|
||||||
|
file_size: fileSize,
|
||||||
|
raw_storage_key: storageKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Queue conversion job (unless already GLB)
|
||||||
|
if (format !== 'glb' && format !== 'gltf') {
|
||||||
|
await addConversionJob({
|
||||||
|
modelId: model.id,
|
||||||
|
key: storageKey,
|
||||||
|
fileType: format,
|
||||||
|
});
|
||||||
|
logger.info({ modelId: model.id }, 'Conversion job queued');
|
||||||
|
} else {
|
||||||
|
// GLB/GLTF don't need conversion - file stays in raw bucket
|
||||||
|
const modelUrl = getPublicUrl(BUCKETS.RAW, storageKey);
|
||||||
|
await updateModel(model.id, {
|
||||||
|
conversion_status: 'completed',
|
||||||
|
// Don't set converted_storage_key - file is in raw bucket, not converted bucket
|
||||||
|
model_url: modelUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Queue thumbnail generation job for GLB/GLTF files
|
||||||
|
await addThumbnailJob({
|
||||||
|
modelId: model.id,
|
||||||
|
modelUrl: modelUrl,
|
||||||
|
});
|
||||||
|
logger.info({ modelId: model.id }, 'Thumbnail job queued for GLB/GLTF');
|
||||||
|
}
|
||||||
|
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get download URL for a model
|
||||||
|
*/
|
||||||
|
export async function getModelDownloadUrl(id: string): Promise<string | null> {
|
||||||
|
const model = await getModelById(id);
|
||||||
|
if (!model || model.conversion_status !== 'completed') return null;
|
||||||
|
|
||||||
|
// If model_url is already set (GLB/GLTF files or converted models), return it directly
|
||||||
|
if (model.model_url) {
|
||||||
|
return model.model_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise generate presigned URL for files that need it
|
||||||
|
const key = model.converted_storage_key || model.raw_storage_key;
|
||||||
|
if (!key) return null;
|
||||||
|
|
||||||
|
const bucket = model.converted_storage_key ? BUCKETS.CONVERTED : BUCKETS.RAW;
|
||||||
|
return getPresignedDownloadUrl(bucket, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get download URL for a specific LOD level
|
||||||
|
* @param id Model ID
|
||||||
|
* @param lodLevel LOD level (0, 1, or 2). Default is 0 (highest quality)
|
||||||
|
*/
|
||||||
|
export async function getModelLodUrl(id: string, lodLevel: number = 0): Promise<string | null> {
|
||||||
|
const model = await getModelById(id);
|
||||||
|
if (!model || model.conversion_status !== 'completed') return null;
|
||||||
|
|
||||||
|
// Check if LOD URLs are available in metadata
|
||||||
|
const metadata = model.metadata as Record<string, unknown> | null;
|
||||||
|
const lodUrls = metadata?.lod_urls as Record<string, string> | undefined;
|
||||||
|
|
||||||
|
if (lodUrls) {
|
||||||
|
const lodKey = `lod${lodLevel}`;
|
||||||
|
if (lodUrls[lodKey]) {
|
||||||
|
return toPublicUrl(lodUrls[lodKey]);
|
||||||
|
}
|
||||||
|
// Fallback to LOD0 if requested level not available
|
||||||
|
if (lodUrls['lod0']) {
|
||||||
|
return toPublicUrl(lodUrls['lod0']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to original model_url for backward compatibility
|
||||||
|
return model.model_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all available LOD URLs for a model
|
||||||
|
*/
|
||||||
|
export async function getModelLodUrls(id: string): Promise<Record<string, string> | null> {
|
||||||
|
const model = await getModelById(id);
|
||||||
|
if (!model || model.conversion_status !== 'completed') return null;
|
||||||
|
|
||||||
|
const metadata = model.metadata as Record<string, unknown> | null;
|
||||||
|
const lodUrls = metadata?.lod_urls as Record<string, string> | undefined;
|
||||||
|
|
||||||
|
if (lodUrls) {
|
||||||
|
// Transform all URLs to public URLs
|
||||||
|
const publicLodUrls: Record<string, string> = {};
|
||||||
|
for (const [key, url] of Object.entries(lodUrls)) {
|
||||||
|
const publicUrl = toPublicUrl(url);
|
||||||
|
if (publicUrl) {
|
||||||
|
publicLodUrls[key] = publicUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.keys(publicLodUrls).length > 0 ? publicLodUrls : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: return model_url as lod0 for backward compatibility
|
||||||
|
if (model.model_url) {
|
||||||
|
return { lod0: model.model_url };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a thumbnail for a model
|
||||||
|
*/
|
||||||
|
export async function uploadThumbnail(id: string, buffer: Buffer): Promise<Model | null> {
|
||||||
|
const model = await getModelById(id);
|
||||||
|
if (!model) return null;
|
||||||
|
|
||||||
|
// Upload to MinIO
|
||||||
|
const thumbnailKey = `${id}/preview.png`;
|
||||||
|
const thumbnailUrl = await uploadBuffer(BUCKETS.THUMBNAILS, thumbnailKey, buffer, 'image/png');
|
||||||
|
|
||||||
|
// Update database
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE models SET thumbnail_url = $1, thumbnail_storage_key = $2, updated_at = NOW() WHERE id = $3 RETURNING *`,
|
||||||
|
[thumbnailUrl, thumbnailKey, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info({ modelId: id }, 'Thumbnail uploaded');
|
||||||
|
return result.rows[0] ? transformModelUrls(result.rows[0] as Model) : null;
|
||||||
|
}
|
||||||
129
api/src/services/queue.service.ts
Normal file
129
api/src/services/queue.service.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { Queue, QueueEvents } from 'bullmq';
|
||||||
|
import { Redis } from 'ioredis';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
import logger from '../utils/logger.js';
|
||||||
|
import type { ConversionJobData, ThumbnailJobData, QueueJobData } from '../types/model.js';
|
||||||
|
|
||||||
|
// Parse Redis URL
|
||||||
|
const redisUrl = new URL(env.REDIS_URL);
|
||||||
|
const redisConnection = {
|
||||||
|
host: redisUrl.hostname,
|
||||||
|
port: parseInt(redisUrl.port || '6379'),
|
||||||
|
password: redisUrl.password || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create Redis client
|
||||||
|
export const redis = new Redis(env.REDIS_URL);
|
||||||
|
|
||||||
|
// Queue name
|
||||||
|
const QUEUE_NAME = 'model-conversion';
|
||||||
|
|
||||||
|
// Default job options
|
||||||
|
const defaultJobOptions = {
|
||||||
|
attempts: 3,
|
||||||
|
backoff: {
|
||||||
|
type: 'exponential' as const,
|
||||||
|
delay: 5000, // 5s, 25s, 125s
|
||||||
|
},
|
||||||
|
removeOnComplete: {
|
||||||
|
age: 3600, // Keep for 1 hour
|
||||||
|
count: 100, // Keep last 100
|
||||||
|
},
|
||||||
|
removeOnFail: {
|
||||||
|
age: 86400, // Keep failures for 24 hours
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create the queue (accepts both conversion and thumbnail jobs)
|
||||||
|
export const conversionQueue = new Queue<QueueJobData>(QUEUE_NAME, {
|
||||||
|
connection: redisConnection,
|
||||||
|
defaultJobOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create queue events listener
|
||||||
|
export const queueEvents = new QueueEvents(QUEUE_NAME, {
|
||||||
|
connection: redisConnection,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a conversion job to the queue
|
||||||
|
*/
|
||||||
|
export async function addConversionJob(data: ConversionJobData): Promise<string> {
|
||||||
|
const job = await conversionQueue.add('convert', data, {
|
||||||
|
jobId: data.modelId, // Prevent duplicate jobs for same model
|
||||||
|
});
|
||||||
|
logger.info({ jobId: job.id, modelId: data.modelId }, 'Conversion job added to queue');
|
||||||
|
return job.id!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a thumbnail-only job to the queue (for GLB/GLTF files that don't need conversion)
|
||||||
|
*/
|
||||||
|
export async function addThumbnailJob(data: { modelId: string; modelUrl: string }): Promise<string> {
|
||||||
|
const thumbnailJobData: ThumbnailJobData = {
|
||||||
|
modelId: data.modelId,
|
||||||
|
modelUrl: data.modelUrl,
|
||||||
|
jobType: 'thumbnail',
|
||||||
|
};
|
||||||
|
const job = await conversionQueue.add('thumbnail', thumbnailJobData, {
|
||||||
|
jobId: `thumbnail-${data.modelId}`, // Unique job ID for thumbnail
|
||||||
|
attempts: 2,
|
||||||
|
backoff: {
|
||||||
|
type: 'exponential' as const,
|
||||||
|
delay: 3000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logger.info({ jobId: job.id, modelId: data.modelId }, 'Thumbnail job added to queue');
|
||||||
|
return job.id!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get job status
|
||||||
|
*/
|
||||||
|
export async function getJobStatus(jobId: string): Promise<{
|
||||||
|
state: string;
|
||||||
|
progress: number;
|
||||||
|
error?: string;
|
||||||
|
} | null> {
|
||||||
|
const job = await conversionQueue.getJob(jobId);
|
||||||
|
if (!job) return null;
|
||||||
|
|
||||||
|
const state = await job.getState();
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
progress: job.progress as number || 0,
|
||||||
|
error: job.failedReason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup queue event handlers
|
||||||
|
*/
|
||||||
|
export function setupQueueEvents(
|
||||||
|
onCompleted: (jobId: string, result: unknown) => Promise<void>,
|
||||||
|
onFailed: (jobId: string, error: string) => Promise<void>
|
||||||
|
): void {
|
||||||
|
queueEvents.on('completed', async ({ jobId, returnvalue }) => {
|
||||||
|
logger.info({ jobId }, 'Job completed');
|
||||||
|
await onCompleted(jobId, returnvalue);
|
||||||
|
});
|
||||||
|
|
||||||
|
queueEvents.on('failed', async ({ jobId, failedReason }) => {
|
||||||
|
logger.error({ jobId, error: failedReason }, 'Job failed');
|
||||||
|
await onFailed(jobId, failedReason);
|
||||||
|
});
|
||||||
|
|
||||||
|
queueEvents.on('progress', ({ jobId, data }) => {
|
||||||
|
logger.debug({ jobId, progress: data }, 'Job progress');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Graceful shutdown
|
||||||
|
*/
|
||||||
|
export async function closeQueue(): Promise<void> {
|
||||||
|
await queueEvents.close();
|
||||||
|
await conversionQueue.close();
|
||||||
|
await redis.quit();
|
||||||
|
logger.info('Queue connections closed');
|
||||||
|
}
|
||||||
146
api/src/services/storage.service.ts
Normal file
146
api/src/services/storage.service.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { Client as MinioClient } from 'minio';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
import logger from '../utils/logger.js';
|
||||||
|
|
||||||
|
// Internal client for server-to-server operations
|
||||||
|
const minioClient = new MinioClient({
|
||||||
|
endPoint: env.MINIO_ENDPOINT,
|
||||||
|
port: env.MINIO_PORT,
|
||||||
|
useSSL: env.MINIO_USE_SSL,
|
||||||
|
accessKey: env.MINIO_ACCESS_KEY,
|
||||||
|
secretKey: env.MINIO_SECRET_KEY,
|
||||||
|
region: 'us-east-1', // Fixed region to avoid bucket region lookup
|
||||||
|
});
|
||||||
|
|
||||||
|
// Public client for generating presigned URLs (uses public endpoint but region is fixed)
|
||||||
|
const publicMinioClient = new MinioClient({
|
||||||
|
endPoint: env.MINIO_PUBLIC_ENDPOINT || env.MINIO_ENDPOINT,
|
||||||
|
port: env.MINIO_PUBLIC_PORT || env.MINIO_PORT,
|
||||||
|
useSSL: env.MINIO_USE_SSL,
|
||||||
|
accessKey: env.MINIO_ACCESS_KEY,
|
||||||
|
secretKey: env.MINIO_SECRET_KEY,
|
||||||
|
region: 'us-east-1', // Fixed region to avoid bucket region lookup
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bucket names
|
||||||
|
export const BUCKETS = {
|
||||||
|
RAW: env.MINIO_BUCKET_RAW,
|
||||||
|
CONVERTED: env.MINIO_BUCKET_CONVERTED,
|
||||||
|
THUMBNAILS: env.MINIO_BUCKET_THUMBNAILS,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize MinIO buckets (ensure they exist)
|
||||||
|
*/
|
||||||
|
export async function initializeBuckets(): Promise<void> {
|
||||||
|
for (const bucket of Object.values(BUCKETS)) {
|
||||||
|
const exists = await minioClient.bucketExists(bucket);
|
||||||
|
if (!exists) {
|
||||||
|
await minioClient.makeBucket(bucket);
|
||||||
|
logger.info(`Created bucket: ${bucket}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a presigned URL for uploading a file (uses public endpoint for browser access)
|
||||||
|
*/
|
||||||
|
export async function getPresignedUploadUrl(
|
||||||
|
bucket: string,
|
||||||
|
key: string,
|
||||||
|
expirySeconds: number = env.PRESIGNED_URL_EXPIRY
|
||||||
|
): Promise<string> {
|
||||||
|
return publicMinioClient.presignedPutObject(bucket, key, expirySeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a presigned URL for downloading a file (uses public endpoint for browser access)
|
||||||
|
*/
|
||||||
|
export async function getPresignedDownloadUrl(
|
||||||
|
bucket: string,
|
||||||
|
key: string,
|
||||||
|
expirySeconds: number = env.PRESIGNED_URL_EXPIRY
|
||||||
|
): Promise<string> {
|
||||||
|
return publicMinioClient.presignedGetObject(bucket, key, expirySeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an object from MinIO
|
||||||
|
*/
|
||||||
|
export async function deleteObject(bucket: string, key: string): Promise<void> {
|
||||||
|
await minioClient.removeObject(bucket, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete multiple objects with a prefix
|
||||||
|
*/
|
||||||
|
export async function deleteObjectsByPrefix(bucket: string, prefix: string): Promise<void> {
|
||||||
|
const objects = minioClient.listObjects(bucket, prefix, true);
|
||||||
|
const objectsToDelete: string[] = [];
|
||||||
|
|
||||||
|
for await (const obj of objects) {
|
||||||
|
objectsToDelete.push(obj.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (objectsToDelete.length > 0) {
|
||||||
|
await minioClient.removeObjects(bucket, objectsToDelete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an object exists
|
||||||
|
*/
|
||||||
|
export async function objectExists(bucket: string, key: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await minioClient.statObject(bucket, key);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get public URL for an object (for publicly accessible buckets)
|
||||||
|
*/
|
||||||
|
export function getPublicUrl(bucket: string, key: string): string {
|
||||||
|
const protocol = env.MINIO_USE_SSL ? 'https' : 'http';
|
||||||
|
const endpoint = env.MINIO_PUBLIC_ENDPOINT || env.MINIO_ENDPOINT;
|
||||||
|
const port = env.MINIO_PUBLIC_PORT || env.MINIO_PORT;
|
||||||
|
return `${protocol}://${endpoint}:${port}/${bucket}/${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform internal MinIO URL to public URL
|
||||||
|
* Handles URLs that were stored with internal hostname or localhost
|
||||||
|
*/
|
||||||
|
export function toPublicUrl(url: string | null): string | null {
|
||||||
|
if (!url) return null;
|
||||||
|
|
||||||
|
const publicEndpoint = env.MINIO_PUBLIC_ENDPOINT || env.MINIO_ENDPOINT;
|
||||||
|
const publicPort = env.MINIO_PUBLIC_PORT || env.MINIO_PORT;
|
||||||
|
|
||||||
|
// Replace internal hostname patterns with public endpoint
|
||||||
|
// Also handle localhost URLs from legacy data
|
||||||
|
return url
|
||||||
|
.replace(/minio:9000/g, `${publicEndpoint}:${publicPort}`)
|
||||||
|
.replace(/localhost:9000/g, `${publicEndpoint}:${publicPort}`)
|
||||||
|
.replace(/127\.0\.0\.1:9000/g, `${publicEndpoint}:${publicPort}`)
|
||||||
|
.replace(new RegExp(`${env.MINIO_ENDPOINT}:${env.MINIO_PORT}`, 'g'), `${publicEndpoint}:${publicPort}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a buffer directly to MinIO
|
||||||
|
*/
|
||||||
|
export async function uploadBuffer(
|
||||||
|
bucket: string,
|
||||||
|
key: string,
|
||||||
|
buffer: Buffer,
|
||||||
|
contentType: string = 'application/octet-stream'
|
||||||
|
): Promise<string> {
|
||||||
|
await minioClient.putObject(bucket, key, buffer, buffer.length, {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
});
|
||||||
|
return getPublicUrl(bucket, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { minioClient };
|
||||||
76
api/src/types/model.ts
Normal file
76
api/src/types/model.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
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: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConversionStatus = 'pending' | 'processing' | 'completed' | 'failed';
|
||||||
|
|
||||||
|
export interface ModelMetadata {
|
||||||
|
vertices?: number;
|
||||||
|
faces?: number;
|
||||||
|
bounding_box?: BoundingBox;
|
||||||
|
parts_count?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoundingBox {
|
||||||
|
min: { x: number; y: number; z: number };
|
||||||
|
max: { 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: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateModelInput {
|
||||||
|
name: string;
|
||||||
|
original_filename: string;
|
||||||
|
original_format: string;
|
||||||
|
file_size: number;
|
||||||
|
raw_storage_key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateModelInput {
|
||||||
|
name?: string;
|
||||||
|
converted_storage_key?: string;
|
||||||
|
thumbnail_storage_key?: string;
|
||||||
|
model_url?: string;
|
||||||
|
thumbnail_url?: string;
|
||||||
|
conversion_status?: ConversionStatus;
|
||||||
|
conversion_error?: string;
|
||||||
|
metadata?: ModelMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversionJobData {
|
||||||
|
modelId: string;
|
||||||
|
key: string;
|
||||||
|
fileType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThumbnailJobData {
|
||||||
|
modelId: string;
|
||||||
|
modelUrl: string;
|
||||||
|
jobType: 'thumbnail';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QueueJobData = ConversionJobData | ThumbnailJobData;
|
||||||
22
api/src/utils/logger.ts
Normal file
22
api/src/utils/logger.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import pino from 'pino';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
|
export const logger = pino({
|
||||||
|
level: env.LOG_LEVEL,
|
||||||
|
transport:
|
||||||
|
env.NODE_ENV === 'development'
|
||||||
|
? {
|
||||||
|
target: 'pino-pretty',
|
||||||
|
options: {
|
||||||
|
colorize: true,
|
||||||
|
translateTime: 'SYS:standard',
|
||||||
|
ignore: 'pid,hostname',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
base: {
|
||||||
|
env: env.NODE_ENV,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default logger;
|
||||||
24
api/tsconfig.json
Normal file
24
api/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
168
docker-compose.yml
Normal file
168
docker-compose.yml
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
platform: linux/amd64
|
||||||
|
container_name: viewer3d-postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: viewer
|
||||||
|
POSTGRES_PASSWORD: viewer_password
|
||||||
|
POSTGRES_DB: viewer_db
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ./infrastructure/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U viewer -d viewer_db"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- viewer3d-network
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
platform: linux/amd64
|
||||||
|
container_name: viewer3d-redis
|
||||||
|
command: redis-server --appendonly yes --maxmemory-policy noeviction
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- viewer3d-network
|
||||||
|
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
platform: linux/amd64
|
||||||
|
container_name: viewer3d-minio
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: minioadmin
|
||||||
|
MINIO_ROOT_PASSWORD: minioadmin
|
||||||
|
ports:
|
||||||
|
- "9000:9000"
|
||||||
|
- "9001:9001"
|
||||||
|
volumes:
|
||||||
|
- minio_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mc", "ready", "local"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 20s
|
||||||
|
retries: 3
|
||||||
|
networks:
|
||||||
|
- viewer3d-network
|
||||||
|
|
||||||
|
minio-init:
|
||||||
|
image: minio/mc:latest
|
||||||
|
platform: linux/amd64
|
||||||
|
container_name: viewer3d-minio-init
|
||||||
|
depends_on:
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
entrypoint: >
|
||||||
|
/bin/sh -c "
|
||||||
|
mc alias set local http://minio:9000 minioadmin minioadmin;
|
||||||
|
mc mb --ignore-existing local/raw-models;
|
||||||
|
mc mb --ignore-existing local/converted-models;
|
||||||
|
mc mb --ignore-existing local/thumbnails;
|
||||||
|
mc anonymous set download local/raw-models;
|
||||||
|
mc anonymous set download local/converted-models;
|
||||||
|
mc anonymous set download local/thumbnails;
|
||||||
|
echo 'Buckets created successfully';
|
||||||
|
exit 0;
|
||||||
|
"
|
||||||
|
networks:
|
||||||
|
- viewer3d-network
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ./api
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
platform: linux/amd64
|
||||||
|
container_name: viewer3d-api
|
||||||
|
ports:
|
||||||
|
- "4000:3000"
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: 3000
|
||||||
|
DATABASE_URL: postgresql://viewer:viewer_password@postgres:5432/viewer_db
|
||||||
|
REDIS_URL: redis://redis:6379
|
||||||
|
MINIO_ENDPOINT: minio
|
||||||
|
MINIO_PORT: 9000
|
||||||
|
MINIO_PUBLIC_ENDPOINT: ${HOST_IP:-localhost}
|
||||||
|
MINIO_PUBLIC_PORT: 9000
|
||||||
|
MINIO_ACCESS_KEY: minioadmin
|
||||||
|
MINIO_SECRET_KEY: minioadmin
|
||||||
|
MINIO_USE_SSL: "false"
|
||||||
|
CORS_ORIGINS: http://${HOST_IP:-localhost},http://${HOST_IP:-localhost}:80,http://localhost,http://localhost:80,http://localhost:5173
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
minio-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
networks:
|
||||||
|
- viewer3d-network
|
||||||
|
|
||||||
|
worker:
|
||||||
|
build:
|
||||||
|
context: ./worker
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
platform: linux/amd64
|
||||||
|
environment:
|
||||||
|
REDIS_URL: redis://redis:6379
|
||||||
|
DATABASE_URL: postgresql://viewer:viewer_password@postgres:5432/viewer_db
|
||||||
|
MINIO_ENDPOINT: minio:9000
|
||||||
|
MINIO_PUBLIC_ENDPOINT: ${HOST_IP:-localhost}:9000
|
||||||
|
MINIO_ACCESS_KEY: minioadmin
|
||||||
|
MINIO_SECRET_KEY: minioadmin
|
||||||
|
MINIO_USE_SSL: "false"
|
||||||
|
PYOPENGL_PLATFORM: osmesa
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
minio-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
deploy:
|
||||||
|
replicas: 2
|
||||||
|
networks:
|
||||||
|
- viewer3d-network
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
# VITE_API_URL no longer needed - using nginx proxy with relative path /api
|
||||||
|
platform: linux/amd64
|
||||||
|
container_name: viewer3d-frontend
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
networks:
|
||||||
|
- viewer3d-network
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
|
minio_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
viewer3d-network:
|
||||||
|
driver: bridge
|
||||||
41
frontend/Dockerfile
Normal file
41
frontend/Dockerfile
Normal 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
13
frontend/index.html
Normal 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
42
frontend/nginx.conf
Normal 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
32
frontend/package.json
Normal 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
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
20
frontend/src/App.vue
Normal 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
145
frontend/src/api/client.ts
Normal 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
|
||||||
189
frontend/src/components/common/ConfirmDialog.vue
Normal file
189
frontend/src/components/common/ConfirmDialog.vue
Normal 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>
|
||||||
223
frontend/src/components/common/RenameDialog.vue
Normal file
223
frontend/src/components/common/RenameDialog.vue
Normal 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>
|
||||||
122
frontend/src/components/common/ThemeToggle.vue
Normal file
122
frontend/src/components/common/ThemeToggle.vue
Normal 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>
|
||||||
102
frontend/src/components/layout/AppLayout.vue
Normal file
102
frontend/src/components/layout/AppLayout.vue
Normal 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>
|
||||||
28
frontend/src/components/layout/SidebarPanel.vue
Normal file
28
frontend/src/components/layout/SidebarPanel.vue
Normal 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>
|
||||||
48
frontend/src/components/layout/ViewerPanel.vue
Normal file
48
frontend/src/components/layout/ViewerPanel.vue
Normal 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>
|
||||||
262
frontend/src/components/models/ModelCard.vue
Normal file
262
frontend/src/components/models/ModelCard.vue
Normal 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>
|
||||||
106
frontend/src/components/models/ModelList.vue
Normal file
106
frontend/src/components/models/ModelList.vue
Normal 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>
|
||||||
27
frontend/src/components/models/SearchFilter.vue
Normal file
27
frontend/src/components/models/SearchFilter.vue
Normal 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>
|
||||||
107
frontend/src/components/models/UploadButton.vue
Normal file
107
frontend/src/components/models/UploadButton.vue
Normal 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>
|
||||||
157
frontend/src/components/partsTree/ColorPicker.vue
Normal file
157
frontend/src/components/partsTree/ColorPicker.vue
Normal 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>
|
||||||
316
frontend/src/components/partsTree/PartsTreeNode.vue
Normal file
316
frontend/src/components/partsTree/PartsTreeNode.vue
Normal 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>
|
||||||
306
frontend/src/components/partsTree/PartsTreePanel.vue
Normal file
306
frontend/src/components/partsTree/PartsTreePanel.vue
Normal 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>
|
||||||
391
frontend/src/components/viewer/ContextMenu.vue
Normal file
391
frontend/src/components/viewer/ContextMenu.vue
Normal 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>
|
||||||
316
frontend/src/components/viewer/CrossSection.vue
Normal file
316
frontend/src/components/viewer/CrossSection.vue
Normal 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>
|
||||||
153
frontend/src/components/viewer/ExplodedView.vue
Normal file
153
frontend/src/components/viewer/ExplodedView.vue
Normal 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>
|
||||||
18
frontend/src/components/viewer/FeaturePanel.vue
Normal file
18
frontend/src/components/viewer/FeaturePanel.vue
Normal 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>
|
||||||
860
frontend/src/components/viewer/ModelViewer.vue
Normal file
860
frontend/src/components/viewer/ModelViewer.vue
Normal 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>
|
||||||
481
frontend/src/components/viewer/RenderSettings.vue
Normal file
481
frontend/src/components/viewer/RenderSettings.vue
Normal 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>
|
||||||
288
frontend/src/components/viewer/ThumbnailCapture.vue
Normal file
288
frontend/src/components/viewer/ThumbnailCapture.vue
Normal 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>
|
||||||
73
frontend/src/components/viewer/ViewCube.vue
Normal file
73
frontend/src/components/viewer/ViewCube.vue
Normal 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
16
frontend/src/main.ts
Normal 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')
|
||||||
1633
frontend/src/services/clippingService.ts
Normal file
1633
frontend/src/services/clippingService.ts
Normal file
File diff suppressed because it is too large
Load Diff
658
frontend/src/services/explodeService.ts
Normal file
658
frontend/src/services/explodeService.ts
Normal 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
|
||||||
|
}
|
||||||
1067
frontend/src/services/partsTreeService.ts
Normal file
1067
frontend/src/services/partsTreeService.ts
Normal file
File diff suppressed because it is too large
Load Diff
823
frontend/src/services/renderService.ts
Normal file
823
frontend/src/services/renderService.ts
Normal 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
|
||||||
|
}
|
||||||
121
frontend/src/services/screenshotService.ts
Normal file
121
frontend/src/services/screenshotService.ts
Normal 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
|
||||||
|
}
|
||||||
463
frontend/src/services/viewCubeService.ts
Normal file
463
frontend/src/services/viewCubeService.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
173
frontend/src/stores/models.ts
Normal file
173
frontend/src/stores/models.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
407
frontend/src/stores/partsTree.ts
Normal file
407
frontend/src/stores/partsTree.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
125
frontend/src/stores/theme.ts
Normal file
125
frontend/src/stores/theme.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
562
frontend/src/stores/viewer.ts
Normal file
562
frontend/src/stores/viewer.ts
Normal 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
1248
frontend/src/style.css
Normal file
File diff suppressed because it is too large
Load Diff
71
frontend/src/types/model.ts
Normal file
71
frontend/src/types/model.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
40
frontend/src/types/partsTree.ts
Normal file
40
frontend/src/types/partsTree.ts
Normal 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
9
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_URL: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
260
frontend/src/workers/sectionCapWorker.ts
Normal file
260
frontend/src/workers/sectionCapWorker.ts
Normal 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
25
frontend/tsconfig.json
Normal 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" }]
|
||||||
|
}
|
||||||
12
frontend/tsconfig.node.json
Normal file
12
frontend/tsconfig.node.json
Normal 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
2
frontend/vite.config.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
declare const _default: import("vite").UserConfig;
|
||||||
|
export default _default;
|
||||||
25
frontend/vite.config.js
Normal file
25
frontend/vite.config.js
Normal 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
27
frontend/vite.config.ts
Normal 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',
|
||||||
|
},
|
||||||
|
})
|
||||||
94
infrastructure/postgres/init.sql
Normal file
94
infrastructure/postgres/init.sql
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
-- 3D Model Viewer Database Schema
|
||||||
|
-- PostgreSQL 16
|
||||||
|
|
||||||
|
-- Enable UUID extension
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- Models table: stores metadata about uploaded 3D models
|
||||||
|
CREATE TABLE models (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
original_filename VARCHAR(255) NOT NULL,
|
||||||
|
original_format VARCHAR(10) NOT NULL,
|
||||||
|
file_size BIGINT NOT NULL,
|
||||||
|
raw_storage_key TEXT,
|
||||||
|
converted_storage_key TEXT,
|
||||||
|
thumbnail_storage_key TEXT,
|
||||||
|
model_url TEXT,
|
||||||
|
thumbnail_url TEXT,
|
||||||
|
conversion_status VARCHAR(20) DEFAULT 'pending'
|
||||||
|
CHECK (conversion_status IN ('pending', 'processing', 'completed', 'failed')),
|
||||||
|
conversion_error TEXT,
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Model parts table: stores information about individual parts of a model
|
||||||
|
-- Used for exploded view feature
|
||||||
|
CREATE TABLE model_parts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
model_id UUID NOT NULL REFERENCES models(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(255),
|
||||||
|
mesh_index INTEGER,
|
||||||
|
bounding_box JSONB NOT NULL, -- {min: {x,y,z}, max: {x,y,z}}
|
||||||
|
center_point JSONB NOT NULL, -- {x, y, z}
|
||||||
|
parent_part_id UUID REFERENCES model_parts(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for common queries
|
||||||
|
CREATE INDEX idx_models_status ON models(conversion_status);
|
||||||
|
CREATE INDEX idx_models_created ON models(created_at DESC);
|
||||||
|
CREATE INDEX idx_models_name ON models(name);
|
||||||
|
CREATE INDEX idx_models_format ON models(original_format);
|
||||||
|
CREATE INDEX idx_model_parts_model ON model_parts(model_id);
|
||||||
|
CREATE INDEX idx_model_parts_parent ON model_parts(parent_part_id);
|
||||||
|
|
||||||
|
-- Full-text search index on model name
|
||||||
|
CREATE INDEX idx_models_name_search ON models USING gin(to_tsvector('english', name));
|
||||||
|
|
||||||
|
-- Updated_at trigger function
|
||||||
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Apply updated_at trigger to models table
|
||||||
|
CREATE TRIGGER models_updated_at
|
||||||
|
BEFORE UPDATE ON models
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- Helper function to search models by name
|
||||||
|
CREATE OR REPLACE FUNCTION search_models(search_query TEXT)
|
||||||
|
RETURNS SETOF models AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT *
|
||||||
|
FROM models
|
||||||
|
WHERE to_tsvector('english', name) @@ plainto_tsquery('english', search_query)
|
||||||
|
OR name ILIKE '%' || search_query || '%'
|
||||||
|
ORDER BY created_at DESC;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- View for models with part counts
|
||||||
|
CREATE VIEW models_with_parts AS
|
||||||
|
SELECT
|
||||||
|
m.*,
|
||||||
|
COALESCE(p.part_count, 0) AS part_count
|
||||||
|
FROM models m
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT model_id, COUNT(*) AS part_count
|
||||||
|
FROM model_parts
|
||||||
|
GROUP BY model_id
|
||||||
|
) p ON m.id = p.model_id;
|
||||||
|
|
||||||
|
-- Grant permissions (for production, you might want more restricted permissions)
|
||||||
|
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO viewer;
|
||||||
|
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO viewer;
|
||||||
|
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO viewer;
|
||||||
4
milestones.csv
Normal file
4
milestones.csv
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
里程碑ID,里程碑名称,目标日期,交付物,状态,完成百分比
|
||||||
|
M1,基础架构完成,2024-12-06,Docker环境 + API框架 + 前端框架,已完成,100%
|
||||||
|
M2,核心功能可用,2024-12-13,上传→转换→查看 完整流程,进行中,20%
|
||||||
|
M3,MVP 发布,2024-12-20,完整功能 + 测试通过 + 文档,待开始,0%
|
||||||
|
27
project-management.csv
Normal file
27
project-management.csv
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
任务ID,任务名称,负责人,阶段,优先级,状态,预计工时(天),开始日期,截止日期,依赖项,备注
|
||||||
|
1.1,Docker 环境搭建,后端,第一周,P0,已完成,1,2024-12-02,2024-12-02,-,PostgreSQL/Redis/MinIO
|
||||||
|
1.2,数据库 Schema 设计,后端,第一周,P0,已完成,0.5,2024-12-02,2024-12-02,1.1,models/model_parts表
|
||||||
|
1.3,API 服务框架,后端,第一周,P0,已完成,1.5,2024-12-02,2024-12-03,1.1,Express + TypeScript
|
||||||
|
1.4,MinIO 存储服务,后端,第一周,P0,已完成,1,2024-12-03,2024-12-04,1.1,预签名URL上传
|
||||||
|
1.5,BullMQ 队列服务,后端,第一周,P0,已完成,0.5,2024-12-04,2024-12-04,1.1,任务队列
|
||||||
|
1.6,Python Worker 框架,后端,第一周,P0,已完成,1,2024-12-04,2024-12-05,1.5,模型转换基础
|
||||||
|
1.7,Vue 前端框架,前端,第一周,P0,已完成,1,2024-12-02,2024-12-03,-,Vue3 + Vite + Pinia
|
||||||
|
1.8,3D Viewer 组件,前端,第一周,P0,已完成,1.5,2024-12-03,2024-12-05,1.7,Online3DViewer集成
|
||||||
|
1.9,爆炸图服务,前端,第一周,P1,已完成,1,2024-12-05,2024-12-06,1.8,ExplodeService
|
||||||
|
1.10,剖面服务,前端,第一周,P1,已完成,1,2024-12-05,2024-12-06,1.8,ClippingService
|
||||||
|
2.1,STEP 文件转换,后端,第二周,P0,进行中,2,2024-12-09,2024-12-10,1.6,cascadio → GLB
|
||||||
|
2.2,STL/OBJ 转换,后端,第二周,P0,待开始,1,2024-12-10,2024-12-11,1.6,trimesh → GLB
|
||||||
|
2.3,缩略图生成,后端,第二周,P1,待开始,1.5,2024-12-11,2024-12-12,2.1,pyrender + OSMesa
|
||||||
|
2.4,模型上传流程,前端,第二周,P0,待开始,1,2024-12-09,2024-12-10,1.4,拖拽上传 + 进度条
|
||||||
|
2.5,模型列表页,前端,第二周,P0,待开始,1.5,2024-12-10,2024-12-11,2.4,缩略图 + 搜索 + 筛选
|
||||||
|
2.6,模型详情页,前端,第二周,P0,待开始,1,2024-12-11,2024-12-12,2.5,查看 + 编辑 + 删除
|
||||||
|
2.7,爆炸图 UI 完善,前端,第二周,P1,待开始,1,2024-12-12,2024-12-13,1.9,滑块 + 动画效果
|
||||||
|
2.8,剖面 UI 完善,前端,第二周,P1,待开始,1,2024-12-12,2024-12-13,1.10,三轴控制 + 翻转
|
||||||
|
3.1,错误处理完善,全栈,第三周,P0,待开始,1,2024-12-16,2024-12-16,2.*,统一错误格式
|
||||||
|
3.2,加载状态优化,前端,第三周,P1,待开始,0.5,2024-12-16,2024-12-16,2.5,Skeleton + Loading
|
||||||
|
3.3,大模型性能优化,前端,第三周,P1,待开始,1.5,2024-12-17,2024-12-18,1.8,LOD + 懒加载
|
||||||
|
3.4,Worker 并发优化,后端,第三周,P2,待开始,1,2024-12-17,2024-12-18,1.6,资源限制
|
||||||
|
3.5,UI 样式美化,前端,第三周,P2,待开始,1,2024-12-18,2024-12-19,2.*,响应式 + 动效
|
||||||
|
3.6,E2E 测试,QA,第三周,P1,待开始,1,2024-12-19,2024-12-20,3.*,Playwright
|
||||||
|
3.7,文档编写,全栈,第三周,P2,待开始,0.5,2024-12-20,2024-12-20,全部,README + API文档
|
||||||
|
3.8,部署脚本,DevOps,第三周,P1,待开始,0.5,2024-12-20,2024-12-20,全部,CI/CD配置
|
||||||
|
5
risks.csv
Normal file
5
risks.csv
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
风险ID,风险描述,发生概率,影响程度,风险等级,缓解措施,负责人,状态
|
||||||
|
R1,STEP 转换失败率高,中,高,高,多引擎回退 (cascadio → OpenCASCADE),后端,监控中
|
||||||
|
R2,大文件上传超时,中,中,中,分片上传 + 断点续传,全栈,待处理
|
||||||
|
R3,3D渲染性能问题,低,中,低,WebGL降级 + LOD,前端,待处理
|
||||||
|
R4,依赖包安全漏洞,低,高,中,定期 npm audit / pip check,DevOps,监控中
|
||||||
|
66
start.sh
Executable file
66
start.sh
Executable file
@@ -0,0 +1,66 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 3D Viewer Startup Script
|
||||||
|
# Automatically detects the host IP address for network access
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Detect the host IP address
|
||||||
|
detect_ip() {
|
||||||
|
local ip=""
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||||
|
# Try en0 (Wi-Fi) first, then en1 (Ethernet)
|
||||||
|
ip=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo "")
|
||||||
|
# Linux
|
||||||
|
else
|
||||||
|
# Get the first non-localhost IP
|
||||||
|
ip=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fallback to localhost if no IP found
|
||||||
|
if [[ -z "$ip" ]]; then
|
||||||
|
ip="localhost"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$ip"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main
|
||||||
|
export HOST_IP=$(detect_ip)
|
||||||
|
|
||||||
|
echo "========================================"
|
||||||
|
echo " 3D Viewer Startup"
|
||||||
|
echo "========================================"
|
||||||
|
echo ""
|
||||||
|
echo "Detected HOST_IP: $HOST_IP"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check if we need to rebuild frontend (first time or --rebuild flag)
|
||||||
|
if [[ "$1" == "--rebuild" ]] || [[ "$1" == "-r" ]]; then
|
||||||
|
echo "Rebuilding frontend..."
|
||||||
|
docker compose build --no-cache frontend
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Stop existing containers
|
||||||
|
echo "Stopping existing containers..."
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# Start all services
|
||||||
|
echo "Starting services..."
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "========================================"
|
||||||
|
echo " 3D Viewer is running!"
|
||||||
|
echo "========================================"
|
||||||
|
echo ""
|
||||||
|
echo " Local access: http://localhost"
|
||||||
|
echo " Network access: http://$HOST_IP"
|
||||||
|
echo ""
|
||||||
|
echo " MinIO Console: http://$HOST_IP:9001"
|
||||||
|
echo " API Health: http://$HOST_IP:4000/api/health"
|
||||||
|
echo ""
|
||||||
|
echo "To view logs: docker compose logs -f"
|
||||||
|
echo "To stop: docker compose down"
|
||||||
|
echo ""
|
||||||
54
worker/Dockerfile
Normal file
54
worker/Dockerfile
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Python Worker for 3D Model Conversion
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies for 3D processing and headless rendering
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
# OpenGL/OSMesa for headless rendering (pyrender)
|
||||||
|
libosmesa6-dev \
|
||||||
|
libgl1 \
|
||||||
|
libglu1-mesa \
|
||||||
|
# Build tools for some Python packages
|
||||||
|
build-essential \
|
||||||
|
# OpenCASCADE runtime libraries for cascadio (STEP conversion)
|
||||||
|
libocct-data-exchange-7.8 \
|
||||||
|
libocct-draw-7.8 \
|
||||||
|
libocct-foundation-7.8 \
|
||||||
|
libocct-modeling-algorithms-7.8 \
|
||||||
|
libocct-modeling-data-7.8 \
|
||||||
|
libocct-ocaf-7.8 \
|
||||||
|
libocct-visualization-7.8 \
|
||||||
|
# Cleanup
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Set environment for headless rendering
|
||||||
|
ENV PYOPENGL_PLATFORM=osmesa
|
||||||
|
|
||||||
|
# Install uv for fast package management
|
||||||
|
RUN pip install --no-cache-dir uv
|
||||||
|
|
||||||
|
# Copy project files
|
||||||
|
COPY pyproject.toml .
|
||||||
|
COPY src/ src/
|
||||||
|
|
||||||
|
# Create __init__.py files if they don't exist
|
||||||
|
RUN touch src/__init__.py src/processors/__init__.py src/services/__init__.py
|
||||||
|
|
||||||
|
# Install dependencies using uv
|
||||||
|
RUN uv pip install --system -e .
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN groupadd --system --gid 1001 worker && \
|
||||||
|
useradd --system --uid 1001 --gid worker worker && \
|
||||||
|
mkdir -p /tmp/conversions && \
|
||||||
|
chown -R worker:worker /app /tmp/conversions
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER worker
|
||||||
|
|
||||||
|
# Set temp directory
|
||||||
|
ENV TEMP_DIR=/tmp/conversions
|
||||||
|
|
||||||
|
# Run the worker
|
||||||
|
CMD ["python", "-m", "src.main"]
|
||||||
34
worker/pyproject.toml
Normal file
34
worker/pyproject.toml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
[project]
|
||||||
|
name = "viewer3d-worker"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "3D Model Conversion Worker Service"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"redis>=5.0.0",
|
||||||
|
"minio>=7.2.0",
|
||||||
|
"trimesh>=4.4.0",
|
||||||
|
"cascadio>=0.0.13",
|
||||||
|
"pillow>=10.0.0",
|
||||||
|
"pyrender>=0.1.45",
|
||||||
|
"pydantic-settings>=2.0.0",
|
||||||
|
"psycopg[binary]>=3.1.0",
|
||||||
|
"numpy>=1.24.0",
|
||||||
|
"fast-simplification>=0.1.7",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
worker = "src.main:main"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py311"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "N", "W"]
|
||||||
1
worker/src/__init__.py
Normal file
1
worker/src/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# 3D Model Conversion Worker
|
||||||
49
worker/src/config.py
Normal file
49
worker/src/config.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"""Configuration management using Pydantic Settings."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from functools import lru_cache
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Application settings loaded from environment variables."""
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
redis_url: str = "redis://localhost:6379"
|
||||||
|
redis_stream: str = "bull:model-conversion:wait"
|
||||||
|
redis_consumer_group: str = "conversion-workers"
|
||||||
|
redis_consumer_name: str = f"worker-{os.getpid()}"
|
||||||
|
|
||||||
|
# Database
|
||||||
|
database_url: str = "postgresql://viewer:viewer_password@localhost:5432/viewer_db"
|
||||||
|
|
||||||
|
# MinIO
|
||||||
|
minio_endpoint: str = "localhost:9000"
|
||||||
|
minio_public_endpoint: str = "localhost:9000" # For public URLs (browser access)
|
||||||
|
minio_access_key: str = "minioadmin"
|
||||||
|
minio_secret_key: str = "minioadmin"
|
||||||
|
minio_use_ssl: bool = False
|
||||||
|
minio_bucket_raw: str = "raw-models"
|
||||||
|
minio_bucket_converted: str = "converted-models"
|
||||||
|
minio_bucket_thumbnails: str = "thumbnails"
|
||||||
|
|
||||||
|
# Processing
|
||||||
|
temp_dir: str = "/tmp/conversions"
|
||||||
|
max_file_size_mb: int = 500
|
||||||
|
thumbnail_size: tuple[int, int] = (256, 256)
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log_level: str = "INFO"
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
case_sensitive = False
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
"""Get cached settings instance."""
|
||||||
|
return Settings()
|
||||||
|
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
463
worker/src/main.py
Normal file
463
worker/src/main.py
Normal file
@@ -0,0 +1,463 @@
|
|||||||
|
"""
|
||||||
|
3D Model Conversion Worker
|
||||||
|
|
||||||
|
Listens to BullMQ queue in Redis and processes model conversion jobs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
import redis
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
from .processors.converter import convert_to_glb, convert_to_glb_with_lod
|
||||||
|
from .services.storage import download_file, upload_file
|
||||||
|
from .services.thumbnail import generate_thumbnail
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, settings.log_level),
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
|
handlers=[logging.StreamHandler(sys.stdout)],
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Graceful shutdown flag
|
||||||
|
shutdown_requested = False
|
||||||
|
|
||||||
|
|
||||||
|
def signal_handler(signum, frame):
|
||||||
|
"""Handle shutdown signals."""
|
||||||
|
global shutdown_requested
|
||||||
|
logger.info(f"Received signal {signum}, initiating graceful shutdown...")
|
||||||
|
shutdown_requested = True
|
||||||
|
|
||||||
|
|
||||||
|
def get_redis_client() -> redis.Redis:
|
||||||
|
"""Create Redis client."""
|
||||||
|
return redis.from_url(settings.redis_url, decode_responses=True)
|
||||||
|
|
||||||
|
|
||||||
|
def get_db_connection() -> psycopg.Connection:
|
||||||
|
"""Create database connection."""
|
||||||
|
return psycopg.connect(settings.database_url)
|
||||||
|
|
||||||
|
|
||||||
|
def update_thumbnail_only(
|
||||||
|
model_id: str,
|
||||||
|
thumbnail_url: str,
|
||||||
|
) -> None:
|
||||||
|
"""Update only the thumbnail URL in the database (for thumbnail-only jobs)."""
|
||||||
|
with get_db_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE models
|
||||||
|
SET thumbnail_url = %s,
|
||||||
|
thumbnail_storage_key = %s,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
thumbnail_url,
|
||||||
|
f"{model_id}/preview.png",
|
||||||
|
model_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
logger.info(f"Updated thumbnail for model {model_id}")
|
||||||
|
|
||||||
|
|
||||||
|
def update_model_status(
|
||||||
|
model_id: str,
|
||||||
|
status: str,
|
||||||
|
model_url: str = None,
|
||||||
|
thumbnail_url: str = None,
|
||||||
|
metadata: dict = None,
|
||||||
|
error: str = None,
|
||||||
|
lod_urls: dict = None,
|
||||||
|
) -> None:
|
||||||
|
"""Update model status in the database."""
|
||||||
|
with get_db_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
if status == 'completed':
|
||||||
|
# Include LOD URLs in metadata
|
||||||
|
full_metadata = metadata or {}
|
||||||
|
if lod_urls:
|
||||||
|
full_metadata['lod_urls'] = lod_urls
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE models
|
||||||
|
SET conversion_status = %s,
|
||||||
|
model_url = %s,
|
||||||
|
thumbnail_url = %s,
|
||||||
|
converted_storage_key = %s,
|
||||||
|
thumbnail_storage_key = %s,
|
||||||
|
metadata = %s,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
status,
|
||||||
|
model_url,
|
||||||
|
thumbnail_url,
|
||||||
|
f"{model_id}/model.glb",
|
||||||
|
f"{model_id}/preview.png",
|
||||||
|
json.dumps(full_metadata),
|
||||||
|
model_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
elif status == 'failed':
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE models
|
||||||
|
SET conversion_status = %s,
|
||||||
|
conversion_error = %s,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(status, error, model_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE models
|
||||||
|
SET conversion_status = %s,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(status, model_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
logger.info(f"Updated model {model_id} status to {status}")
|
||||||
|
|
||||||
|
|
||||||
|
def save_model_parts(model_id: str, parts: list[dict]) -> None:
|
||||||
|
"""Save model parts to the database."""
|
||||||
|
if not parts:
|
||||||
|
return
|
||||||
|
|
||||||
|
with get_db_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
for part in parts:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO model_parts (model_id, name, bounding_box, center_point)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
model_id,
|
||||||
|
part.get('name', 'unnamed'),
|
||||||
|
json.dumps(part.get('bounding_box', {})),
|
||||||
|
json.dumps(part.get('center_point', {})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
logger.info(f"Saved {len(parts)} parts for model {model_id}")
|
||||||
|
|
||||||
|
|
||||||
|
def process_thumbnail_job(job_data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Process a thumbnail-only job for GLB/GLTF files.
|
||||||
|
|
||||||
|
1. Download GLB file from model URL
|
||||||
|
2. Generate thumbnail
|
||||||
|
3. Upload thumbnail to MinIO
|
||||||
|
4. Update database with thumbnail URL
|
||||||
|
"""
|
||||||
|
model_id = job_data['modelId']
|
||||||
|
model_url = job_data['modelUrl']
|
||||||
|
|
||||||
|
logger.info(f"Processing thumbnail job for model {model_id}")
|
||||||
|
|
||||||
|
# Create temp directory for this job
|
||||||
|
temp_dir = Path(settings.temp_dir) / f"thumb_{model_id}"
|
||||||
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. Download GLB file from model URL
|
||||||
|
import urllib.request
|
||||||
|
input_path = temp_dir / "input.glb"
|
||||||
|
|
||||||
|
# model_url might be internal MinIO URL, convert to accessible URL
|
||||||
|
download_url = model_url
|
||||||
|
# If it's an internal URL, we need to use MinIO client instead
|
||||||
|
if 'minio:9000' in model_url or 'localhost:9000' in model_url:
|
||||||
|
# Extract bucket and key from URL
|
||||||
|
# URL format: http://minio:9000/bucket/key
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
parsed = urlparse(model_url)
|
||||||
|
path_parts = parsed.path.lstrip('/').split('/', 1)
|
||||||
|
if len(path_parts) == 2:
|
||||||
|
bucket, key = path_parts
|
||||||
|
download_file(bucket, key, input_path)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Invalid model URL format: {model_url}")
|
||||||
|
else:
|
||||||
|
# External URL, download directly
|
||||||
|
urllib.request.urlretrieve(download_url, input_path)
|
||||||
|
|
||||||
|
logger.info(f"Downloaded GLB file to {input_path}")
|
||||||
|
|
||||||
|
# 2. Generate thumbnail
|
||||||
|
thumbnail_path = temp_dir / "preview.png"
|
||||||
|
generate_thumbnail(input_path, thumbnail_path)
|
||||||
|
logger.info(f"Generated thumbnail: {thumbnail_path}")
|
||||||
|
|
||||||
|
# 3. Upload thumbnail to MinIO
|
||||||
|
thumbnail_key = f"{model_id}/preview.png"
|
||||||
|
thumbnail_url = upload_file(
|
||||||
|
thumbnail_path,
|
||||||
|
settings.minio_bucket_thumbnails,
|
||||||
|
thumbnail_key,
|
||||||
|
content_type="image/png",
|
||||||
|
)
|
||||||
|
logger.info(f"Uploaded thumbnail: {thumbnail_key}")
|
||||||
|
|
||||||
|
# 4. Update database with thumbnail URL
|
||||||
|
update_thumbnail_only(model_id, thumbnail_url)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'modelId': model_id,
|
||||||
|
'thumbnailUrl': thumbnail_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Thumbnail job failed for model {model_id}: {e}", exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Cleanup temp files
|
||||||
|
import shutil
|
||||||
|
if temp_dir.exists():
|
||||||
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||||
|
logger.debug(f"Cleaned up temp directory: {temp_dir}")
|
||||||
|
|
||||||
|
|
||||||
|
def process_job(job_data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Process a single conversion job with LOD support.
|
||||||
|
|
||||||
|
1. Download original file from MinIO
|
||||||
|
2. Convert to GLB with multiple LOD levels
|
||||||
|
3. Generate thumbnail
|
||||||
|
4. Upload all LOD files to MinIO
|
||||||
|
5. Update database
|
||||||
|
"""
|
||||||
|
# Check if this is a thumbnail-only job
|
||||||
|
job_type = job_data.get('jobType', 'conversion')
|
||||||
|
if job_type == 'thumbnail':
|
||||||
|
return process_thumbnail_job(job_data)
|
||||||
|
|
||||||
|
model_id = job_data['modelId']
|
||||||
|
storage_key = job_data['key']
|
||||||
|
file_type = job_data['fileType']
|
||||||
|
|
||||||
|
logger.info(f"Processing job for model {model_id}, type: {file_type}")
|
||||||
|
|
||||||
|
# Update status to processing
|
||||||
|
update_model_status(model_id, 'processing')
|
||||||
|
|
||||||
|
# Create temp directory for this job
|
||||||
|
temp_dir = Path(settings.temp_dir) / model_id
|
||||||
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. Download original file
|
||||||
|
input_path = temp_dir / f"input.{file_type}"
|
||||||
|
download_file(settings.minio_bucket_raw, storage_key, input_path)
|
||||||
|
logger.info(f"Downloaded input file to {input_path}")
|
||||||
|
|
||||||
|
# 2. Convert to GLB with LOD levels
|
||||||
|
output_dir = temp_dir / "lod"
|
||||||
|
metadata = convert_to_glb_with_lod(input_path, output_dir, file_type, model_id)
|
||||||
|
logger.info(f"Converted to GLB with LOD: {output_dir}")
|
||||||
|
|
||||||
|
# Get LOD files info
|
||||||
|
lod_files = metadata.get('lod_files', {'lod0': f'{model_id}_lod0.glb'})
|
||||||
|
|
||||||
|
# 3. Generate thumbnail from LOD0 (highest quality)
|
||||||
|
lod0_path = output_dir / lod_files['lod0']
|
||||||
|
thumbnail_path = temp_dir / "preview.png"
|
||||||
|
generate_thumbnail(lod0_path, thumbnail_path)
|
||||||
|
logger.info(f"Generated thumbnail: {thumbnail_path}")
|
||||||
|
|
||||||
|
# 4. Upload all LOD files to MinIO
|
||||||
|
lod_urls = {}
|
||||||
|
for lod_level, lod_filename in lod_files.items():
|
||||||
|
lod_path = output_dir / lod_filename
|
||||||
|
if lod_path.exists():
|
||||||
|
lod_key = f"{model_id}/{lod_filename}"
|
||||||
|
lod_url = upload_file(
|
||||||
|
lod_path,
|
||||||
|
settings.minio_bucket_converted,
|
||||||
|
lod_key,
|
||||||
|
content_type="model/gltf-binary",
|
||||||
|
)
|
||||||
|
lod_urls[lod_level] = lod_url
|
||||||
|
logger.info(f"Uploaded {lod_level}: {lod_key}")
|
||||||
|
|
||||||
|
# Also upload LOD0 as model.glb for backward compatibility
|
||||||
|
model_key = f"{model_id}/model.glb"
|
||||||
|
model_url = upload_file(
|
||||||
|
lod0_path,
|
||||||
|
settings.minio_bucket_converted,
|
||||||
|
model_key,
|
||||||
|
content_type="model/gltf-binary",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Upload thumbnail
|
||||||
|
thumbnail_key = f"{model_id}/preview.png"
|
||||||
|
thumbnail_url = upload_file(
|
||||||
|
thumbnail_path,
|
||||||
|
settings.minio_bucket_thumbnails,
|
||||||
|
thumbnail_key,
|
||||||
|
content_type="image/png",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. Save model parts if available
|
||||||
|
parts = metadata.get('parts', [])
|
||||||
|
if parts:
|
||||||
|
save_model_parts(model_id, parts)
|
||||||
|
|
||||||
|
# 6. Update database with success (includes LOD URLs in metadata)
|
||||||
|
update_model_status(
|
||||||
|
model_id,
|
||||||
|
'completed',
|
||||||
|
model_url=model_url,
|
||||||
|
thumbnail_url=thumbnail_url,
|
||||||
|
metadata=metadata,
|
||||||
|
lod_urls=lod_urls,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'modelId': model_id,
|
||||||
|
'modelUrl': model_url,
|
||||||
|
'thumbnailUrl': thumbnail_url,
|
||||||
|
'lodUrls': lod_urls,
|
||||||
|
'metadata': metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Job failed for model {model_id}: {e}", exc_info=True)
|
||||||
|
update_model_status(model_id, 'failed', error=str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Cleanup temp files
|
||||||
|
import shutil
|
||||||
|
if temp_dir.exists():
|
||||||
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||||
|
logger.debug(f"Cleaned up temp directory: {temp_dir}")
|
||||||
|
|
||||||
|
|
||||||
|
def poll_jobs(redis_client: redis.Redis) -> None:
|
||||||
|
"""
|
||||||
|
Poll for jobs from the BullMQ queue.
|
||||||
|
|
||||||
|
BullMQ stores jobs in Redis with a specific structure.
|
||||||
|
We use BRPOPLPUSH to atomically move jobs from wait to active.
|
||||||
|
"""
|
||||||
|
wait_key = "bull:model-conversion:wait"
|
||||||
|
active_key = "bull:model-conversion:active"
|
||||||
|
completed_key = "bull:model-conversion:completed"
|
||||||
|
|
||||||
|
while not shutdown_requested:
|
||||||
|
try:
|
||||||
|
# Try to get a job (blocking with 5 second timeout)
|
||||||
|
job_id = redis_client.brpoplpush(wait_key, active_key, timeout=5)
|
||||||
|
|
||||||
|
if job_id is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(f"Received job: {job_id}")
|
||||||
|
|
||||||
|
# Get job data
|
||||||
|
job_key = f"bull:model-conversion:{job_id}"
|
||||||
|
job_json = redis_client.hget(job_key, "data")
|
||||||
|
|
||||||
|
if not job_json:
|
||||||
|
logger.warning(f"No data found for job {job_id}")
|
||||||
|
redis_client.lrem(active_key, 1, job_id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
job_data = json.loads(job_json)
|
||||||
|
|
||||||
|
# Process the job
|
||||||
|
try:
|
||||||
|
result = process_job(job_data)
|
||||||
|
|
||||||
|
# Mark job as completed
|
||||||
|
redis_client.hset(job_key, "returnvalue", json.dumps(result))
|
||||||
|
redis_client.hset(job_key, "finishedOn", str(int(time.time() * 1000)))
|
||||||
|
redis_client.lrem(active_key, 1, job_id)
|
||||||
|
redis_client.lpush(completed_key, job_id)
|
||||||
|
|
||||||
|
logger.info(f"Job {job_id} completed successfully")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Mark job as failed
|
||||||
|
redis_client.hset(job_key, "failedReason", str(e))
|
||||||
|
redis_client.hset(job_key, "finishedOn", str(int(time.time() * 1000)))
|
||||||
|
redis_client.lrem(active_key, 1, job_id)
|
||||||
|
|
||||||
|
# Move to failed queue
|
||||||
|
failed_key = "bull:model-conversion:failed"
|
||||||
|
redis_client.lpush(failed_key, job_id)
|
||||||
|
|
||||||
|
logger.error(f"Job {job_id} failed: {e}")
|
||||||
|
|
||||||
|
except redis.exceptions.ConnectionError as e:
|
||||||
|
logger.error(f"Redis connection error: {e}")
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error in job polling: {e}", exc_info=True)
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point for the worker."""
|
||||||
|
logger.info("Starting 3D Model Conversion Worker")
|
||||||
|
logger.info(f"Redis URL: {settings.redis_url}")
|
||||||
|
logger.info(f"MinIO endpoint: {settings.minio_endpoint}")
|
||||||
|
|
||||||
|
# Setup signal handlers
|
||||||
|
signal.signal(signal.SIGTERM, signal_handler)
|
||||||
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
|
|
||||||
|
# Create temp directory
|
||||||
|
temp_dir = Path(settings.temp_dir)
|
||||||
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Connect to Redis
|
||||||
|
redis_client = get_redis_client()
|
||||||
|
|
||||||
|
# Test connection
|
||||||
|
try:
|
||||||
|
redis_client.ping()
|
||||||
|
logger.info("Connected to Redis")
|
||||||
|
except redis.exceptions.ConnectionError as e:
|
||||||
|
logger.fatal(f"Failed to connect to Redis: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Start polling for jobs
|
||||||
|
logger.info("Worker ready, polling for jobs...")
|
||||||
|
poll_jobs(redis_client)
|
||||||
|
|
||||||
|
logger.info("Worker shutdown complete")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
1
worker/src/processors/__init__.py
Normal file
1
worker/src/processors/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Model processors
|
||||||
391
worker/src/processors/converter.py
Normal file
391
worker/src/processors/converter.py
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
"""3D model conversion processor with LOD support."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import trimesh
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# LOD configuration: level -> face ratio (for non-STEP files)
|
||||||
|
LOD_LEVELS = {
|
||||||
|
0: 1.0, # LOD0: 100% faces (original)
|
||||||
|
1: 0.5, # LOD1: 50% faces
|
||||||
|
2: 0.25, # LOD2: 25% faces
|
||||||
|
}
|
||||||
|
|
||||||
|
# LOD tessellation parameters for STEP files (cascadio)
|
||||||
|
# Higher values = coarser mesh = fewer triangles
|
||||||
|
LOD_TESSELLATION = {
|
||||||
|
0: {'tol_linear': 0.01, 'tol_angular': 0.5}, # High quality (default)
|
||||||
|
1: {'tol_linear': 0.1, 'tol_angular': 1.0}, # Medium quality
|
||||||
|
2: {'tol_linear': 0.5, 'tol_angular': 2.0}, # Low quality (for preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def convert_to_glb(input_path: Path, output_path: Path, file_type: str) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Convert a 3D model to GLB format with LOD support.
|
||||||
|
|
||||||
|
Supports: STEP, STL, OBJ, and other formats via trimesh/cascadio.
|
||||||
|
|
||||||
|
Returns metadata about the converted model including LOD file paths.
|
||||||
|
"""
|
||||||
|
file_type = file_type.lower()
|
||||||
|
|
||||||
|
# Ensure output directory exists
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if file_type in ('step', 'stp'):
|
||||||
|
return _convert_step(input_path, output_path)
|
||||||
|
else:
|
||||||
|
return _convert_with_trimesh(input_path, output_path, file_type)
|
||||||
|
|
||||||
|
|
||||||
|
def convert_to_glb_with_lod(input_path: Path, output_dir: Path, file_type: str, model_id: str) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Convert a 3D model to GLB format with multiple LOD levels.
|
||||||
|
|
||||||
|
For STEP files: Generate each LOD directly from source with different tessellation precision.
|
||||||
|
For other files: Generate LOD0 then simplify for other levels.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_path: Path to input file
|
||||||
|
output_dir: Directory to save LOD files
|
||||||
|
file_type: File extension (step, stl, obj, etc.)
|
||||||
|
model_id: Unique model identifier for file naming
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Metadata including LOD file paths and statistics
|
||||||
|
"""
|
||||||
|
file_type = file_type.lower()
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
lod_files = {}
|
||||||
|
|
||||||
|
# STEP files: Generate each LOD with different tessellation precision
|
||||||
|
if file_type in ('step', 'stp'):
|
||||||
|
return _convert_step_with_lod(input_path, output_dir, model_id)
|
||||||
|
|
||||||
|
# Non-STEP files: Use post-processing simplification
|
||||||
|
return _convert_other_with_lod(input_path, output_dir, file_type, model_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_step_with_lod(input_path: Path, output_dir: Path, model_id: str) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Convert STEP file to GLB with multiple LOD levels using different tessellation precision.
|
||||||
|
|
||||||
|
This is more effective than post-processing simplification because it controls
|
||||||
|
mesh generation at the source.
|
||||||
|
"""
|
||||||
|
lod_files = {}
|
||||||
|
metadata = None
|
||||||
|
|
||||||
|
for level, params in LOD_TESSELLATION.items():
|
||||||
|
lod_path = output_dir / f"{model_id}_lod{level}.glb"
|
||||||
|
|
||||||
|
try:
|
||||||
|
level_metadata = _convert_step(
|
||||||
|
input_path,
|
||||||
|
lod_path,
|
||||||
|
tol_linear=params['tol_linear'],
|
||||||
|
tol_angular=params['tol_angular'],
|
||||||
|
)
|
||||||
|
|
||||||
|
lod_files[f'lod{level}'] = str(lod_path.name)
|
||||||
|
faces = level_metadata.get('faces', 0)
|
||||||
|
logger.info(f"Generated LOD{level} with {faces:,} faces (tol_linear={params['tol_linear']})")
|
||||||
|
|
||||||
|
# Use LOD0 metadata as the primary metadata
|
||||||
|
if level == 0:
|
||||||
|
metadata = level_metadata
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to generate LOD{level}: {e}")
|
||||||
|
# Fall back to LOD0 if available
|
||||||
|
if 'lod0' in lod_files:
|
||||||
|
lod_files[f'lod{level}'] = lod_files['lod0']
|
||||||
|
|
||||||
|
# If LOD0 failed, raise error
|
||||||
|
if metadata is None:
|
||||||
|
raise RuntimeError("Failed to convert STEP file")
|
||||||
|
|
||||||
|
# Add LOD info to metadata
|
||||||
|
metadata['lod_files'] = lod_files
|
||||||
|
metadata['lod_levels'] = len(set(lod_files.values()))
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_other_with_lod(input_path: Path, output_dir: Path, file_type: str, model_id: str) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Convert non-STEP files to GLB with LOD using post-processing simplification.
|
||||||
|
"""
|
||||||
|
# LOD0 path (original quality)
|
||||||
|
lod0_path = output_dir / f"{model_id}_lod0.glb"
|
||||||
|
|
||||||
|
# Convert to LOD0
|
||||||
|
metadata = _convert_with_trimesh(input_path, lod0_path, file_type)
|
||||||
|
|
||||||
|
lod_files = {
|
||||||
|
'lod0': str(lod0_path.name),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get face count for LOD generation decision
|
||||||
|
total_faces = metadata.get('faces', 0)
|
||||||
|
|
||||||
|
# Only generate LODs if model has enough faces
|
||||||
|
if total_faces > 1000:
|
||||||
|
try:
|
||||||
|
# Generate LOD1 and LOD2 using mesh simplification
|
||||||
|
for level in [1, 2]:
|
||||||
|
lod_path = output_dir / f"{model_id}_lod{level}.glb"
|
||||||
|
ratio = LOD_LEVELS[level]
|
||||||
|
|
||||||
|
# Reload mesh fresh for each LOD level
|
||||||
|
mesh = trimesh.load(str(lod0_path))
|
||||||
|
|
||||||
|
simplified = _simplify_mesh(mesh, ratio)
|
||||||
|
if simplified is not None:
|
||||||
|
simplified.export(str(lod_path), file_type='glb')
|
||||||
|
lod_files[f'lod{level}'] = str(lod_path.name)
|
||||||
|
logger.info(f"Generated LOD{level} with {ratio*100:.0f}% faces: {lod_path.name}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Failed to generate LOD{level}, using LOD0")
|
||||||
|
lod_files[f'lod{level}'] = lod_files['lod0']
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"LOD generation failed: {e}, using LOD0 for all levels")
|
||||||
|
lod_files['lod1'] = lod_files['lod0']
|
||||||
|
lod_files['lod2'] = lod_files['lod0']
|
||||||
|
else:
|
||||||
|
# Small model, use LOD0 for all levels
|
||||||
|
logger.info(f"Model has {total_faces} faces, skipping LOD generation")
|
||||||
|
lod_files['lod1'] = lod_files['lod0']
|
||||||
|
lod_files['lod2'] = lod_files['lod0']
|
||||||
|
|
||||||
|
# Add LOD info to metadata
|
||||||
|
metadata['lod_files'] = lod_files
|
||||||
|
metadata['lod_levels'] = len(set(lod_files.values()))
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
def _simplify_mesh(mesh: trimesh.Trimesh | trimesh.Scene, ratio: float) -> trimesh.Trimesh | trimesh.Scene | None:
|
||||||
|
"""
|
||||||
|
Simplify a mesh or scene to the target face ratio.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mesh: Trimesh mesh or scene
|
||||||
|
ratio: Target ratio of faces (0.0 - 1.0)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Simplified mesh/scene or None if failed
|
||||||
|
"""
|
||||||
|
# Minimum reduction required (at least 10% reduction for fast_simplification to work)
|
||||||
|
MIN_REDUCTION_RATIO = 0.9
|
||||||
|
|
||||||
|
try:
|
||||||
|
if isinstance(mesh, trimesh.Scene):
|
||||||
|
# Simplify each geometry in the scene
|
||||||
|
simplified_geometries = {}
|
||||||
|
for name, geom in mesh.geometry.items():
|
||||||
|
# Skip small geometries and non-mesh objects
|
||||||
|
if not hasattr(geom, 'faces') or len(geom.faces) < 100:
|
||||||
|
simplified_geometries[name] = geom
|
||||||
|
continue
|
||||||
|
|
||||||
|
original_faces = len(geom.faces)
|
||||||
|
target_faces = max(int(original_faces * ratio), 4)
|
||||||
|
|
||||||
|
# Only simplify if we're reducing by at least 10%
|
||||||
|
# (fast_simplification requires reduction > 0)
|
||||||
|
if target_faces < original_faces * MIN_REDUCTION_RATIO:
|
||||||
|
try:
|
||||||
|
simplified = geom.simplify_quadric_decimation(target_faces)
|
||||||
|
simplified_geometries[name] = simplified
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to simplify geometry {name}: {e}")
|
||||||
|
simplified_geometries[name] = geom
|
||||||
|
else:
|
||||||
|
# Reduction too small, skip simplification
|
||||||
|
simplified_geometries[name] = geom
|
||||||
|
|
||||||
|
# Create new scene with simplified geometries
|
||||||
|
new_scene = trimesh.Scene()
|
||||||
|
for name, geom in simplified_geometries.items():
|
||||||
|
try:
|
||||||
|
# Get original transform if exists
|
||||||
|
node_name = None
|
||||||
|
if hasattr(mesh.graph, 'nodes_geometry'):
|
||||||
|
for item in mesh.graph.nodes_geometry:
|
||||||
|
# Handle both tuple formats: (node, geom_name) or (node, geom_name, ...)
|
||||||
|
if len(item) >= 2 and item[1] == name:
|
||||||
|
node_name = item[0]
|
||||||
|
break
|
||||||
|
|
||||||
|
if node_name:
|
||||||
|
transform = mesh.graph.get(node_name)[0]
|
||||||
|
new_scene.add_geometry(geom, node_name=node_name, geom_name=name, transform=transform)
|
||||||
|
else:
|
||||||
|
new_scene.add_geometry(geom, geom_name=name)
|
||||||
|
except Exception as e:
|
||||||
|
# If transform lookup fails, just add geometry without transform
|
||||||
|
logger.debug(f"Could not get transform for {name}: {e}")
|
||||||
|
new_scene.add_geometry(geom, geom_name=name)
|
||||||
|
|
||||||
|
return new_scene
|
||||||
|
|
||||||
|
elif hasattr(mesh, 'faces') and len(mesh.faces) >= 100:
|
||||||
|
# Single mesh simplification
|
||||||
|
original_faces = len(mesh.faces)
|
||||||
|
target_faces = max(int(original_faces * ratio), 4)
|
||||||
|
|
||||||
|
# Only simplify if we're reducing by at least 10%
|
||||||
|
if target_faces < original_faces * MIN_REDUCTION_RATIO:
|
||||||
|
return mesh.simplify_quadric_decimation(target_faces)
|
||||||
|
|
||||||
|
return mesh
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Mesh simplification failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_step(
|
||||||
|
input_path: Path,
|
||||||
|
output_path: Path,
|
||||||
|
tol_linear: float = 0.01,
|
||||||
|
tol_angular: float = 0.5,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Convert STEP file using cascadio with configurable tessellation precision.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_path: Path to STEP file
|
||||||
|
output_path: Path to save GLB file
|
||||||
|
tol_linear: Linear deflection tolerance (higher = coarser mesh)
|
||||||
|
tol_angular: Angular deflection tolerance in radians (higher = coarser mesh)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Metadata about the converted model
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import cascadio
|
||||||
|
|
||||||
|
logger.info(f"Converting STEP file with cascadio: {input_path}")
|
||||||
|
logger.info(f"Tessellation params: tol_linear={tol_linear}, tol_angular={tol_angular}")
|
||||||
|
|
||||||
|
cascadio.step_to_glb(
|
||||||
|
str(input_path),
|
||||||
|
str(output_path),
|
||||||
|
tol_linear=tol_linear,
|
||||||
|
tol_angular=tol_angular,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load the result to get metadata
|
||||||
|
mesh = trimesh.load(str(output_path))
|
||||||
|
return _extract_metadata(mesh)
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
logger.error("cascadio not installed, cannot convert STEP files")
|
||||||
|
raise RuntimeError("STEP conversion requires cascadio package")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"STEP conversion failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_with_trimesh(input_path: Path, output_path: Path, file_type: str) -> dict[str, Any]:
|
||||||
|
"""Convert STL, OBJ, and other formats using trimesh."""
|
||||||
|
logger.info(f"Converting {file_type.upper()} file with trimesh: {input_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Load the mesh
|
||||||
|
mesh = trimesh.load(str(input_path))
|
||||||
|
|
||||||
|
# Export to GLB
|
||||||
|
mesh.export(str(output_path), file_type='glb')
|
||||||
|
|
||||||
|
return _extract_metadata(mesh)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Trimesh conversion failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_metadata(mesh: trimesh.Trimesh | trimesh.Scene) -> dict[str, Any]:
|
||||||
|
"""Extract metadata from a trimesh object."""
|
||||||
|
metadata: dict[str, Any] = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if isinstance(mesh, trimesh.Scene):
|
||||||
|
# Scene with multiple meshes
|
||||||
|
metadata['type'] = 'scene'
|
||||||
|
metadata['parts_count'] = len(mesh.geometry)
|
||||||
|
|
||||||
|
# Aggregate stats
|
||||||
|
total_vertices = 0
|
||||||
|
total_faces = 0
|
||||||
|
|
||||||
|
for name, geom in mesh.geometry.items():
|
||||||
|
if hasattr(geom, 'vertices'):
|
||||||
|
total_vertices += len(geom.vertices)
|
||||||
|
if hasattr(geom, 'faces'):
|
||||||
|
total_faces += len(geom.faces)
|
||||||
|
|
||||||
|
metadata['vertices'] = total_vertices
|
||||||
|
metadata['faces'] = total_faces
|
||||||
|
|
||||||
|
# Bounding box
|
||||||
|
if hasattr(mesh, 'bounds') and mesh.bounds is not None:
|
||||||
|
bounds = mesh.bounds
|
||||||
|
metadata['bounding_box'] = {
|
||||||
|
'min': {'x': float(bounds[0][0]), 'y': float(bounds[0][1]), 'z': float(bounds[0][2])},
|
||||||
|
'max': {'x': float(bounds[1][0]), 'y': float(bounds[1][1]), 'z': float(bounds[1][2])},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parts info
|
||||||
|
parts = []
|
||||||
|
for name, geom in mesh.geometry.items():
|
||||||
|
part_info = {'name': name}
|
||||||
|
|
||||||
|
if hasattr(geom, 'bounds') and geom.bounds is not None:
|
||||||
|
part_bounds = geom.bounds
|
||||||
|
part_info['bounding_box'] = {
|
||||||
|
'min': {'x': float(part_bounds[0][0]), 'y': float(part_bounds[0][1]), 'z': float(part_bounds[0][2])},
|
||||||
|
'max': {'x': float(part_bounds[1][0]), 'y': float(part_bounds[1][1]), 'z': float(part_bounds[1][2])},
|
||||||
|
}
|
||||||
|
part_info['center_point'] = {
|
||||||
|
'x': float((part_bounds[0][0] + part_bounds[1][0]) / 2),
|
||||||
|
'y': float((part_bounds[0][1] + part_bounds[1][1]) / 2),
|
||||||
|
'z': float((part_bounds[0][2] + part_bounds[1][2]) / 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.append(part_info)
|
||||||
|
|
||||||
|
metadata['parts'] = parts
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Single mesh
|
||||||
|
metadata['type'] = 'mesh'
|
||||||
|
metadata['parts_count'] = 1
|
||||||
|
|
||||||
|
if hasattr(mesh, 'vertices'):
|
||||||
|
metadata['vertices'] = len(mesh.vertices)
|
||||||
|
if hasattr(mesh, 'faces'):
|
||||||
|
metadata['faces'] = len(mesh.faces)
|
||||||
|
|
||||||
|
if hasattr(mesh, 'bounds') and mesh.bounds is not None:
|
||||||
|
bounds = mesh.bounds
|
||||||
|
metadata['bounding_box'] = {
|
||||||
|
'min': {'x': float(bounds[0][0]), 'y': float(bounds[0][1]), 'z': float(bounds[0][2])},
|
||||||
|
'max': {'x': float(bounds[1][0]), 'y': float(bounds[1][1]), 'z': float(bounds[1][2])},
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error extracting metadata: {e}")
|
||||||
|
|
||||||
|
return metadata
|
||||||
1
worker/src/services/__init__.py
Normal file
1
worker/src/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Worker services
|
||||||
61
worker/src/services/storage.py
Normal file
61
worker/src/services/storage.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"""MinIO storage service for the worker."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from minio import Minio
|
||||||
|
from minio.error import S3Error
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_minio_client() -> Minio:
|
||||||
|
"""Create MinIO client."""
|
||||||
|
return Minio(
|
||||||
|
endpoint=settings.minio_endpoint,
|
||||||
|
access_key=settings.minio_access_key,
|
||||||
|
secret_key=settings.minio_secret_key,
|
||||||
|
secure=settings.minio_use_ssl,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def download_file(bucket: str, key: str, local_path: Path) -> None:
|
||||||
|
"""Download a file from MinIO."""
|
||||||
|
client = get_minio_client()
|
||||||
|
|
||||||
|
# Ensure parent directory exists
|
||||||
|
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
logger.info(f"Downloading {bucket}/{key} to {local_path}")
|
||||||
|
client.fget_object(bucket_name=bucket, object_name=key, file_path=str(local_path))
|
||||||
|
|
||||||
|
|
||||||
|
def upload_file(local_path: Path, bucket: str, key: str, content_type: str = None) -> str:
|
||||||
|
"""Upload a file to MinIO and return the URL."""
|
||||||
|
client = get_minio_client()
|
||||||
|
|
||||||
|
logger.info(f"Uploading {local_path} to {bucket}/{key}")
|
||||||
|
|
||||||
|
client.fput_object(
|
||||||
|
bucket_name=bucket,
|
||||||
|
object_name=key,
|
||||||
|
file_path=str(local_path),
|
||||||
|
content_type=content_type or "application/octet-stream",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return the public URL (using public endpoint for browser access)
|
||||||
|
protocol = "https" if settings.minio_use_ssl else "http"
|
||||||
|
public_endpoint = settings.minio_public_endpoint or settings.minio_endpoint
|
||||||
|
return f"{protocol}://{public_endpoint}/{bucket}/{key}"
|
||||||
|
|
||||||
|
|
||||||
|
def delete_file(bucket: str, key: str) -> None:
|
||||||
|
"""Delete a file from MinIO."""
|
||||||
|
client = get_minio_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
client.remove_object(bucket_name=bucket, object_name=key)
|
||||||
|
logger.info(f"Deleted {bucket}/{key}")
|
||||||
|
except S3Error as e:
|
||||||
|
logger.warning(f"Failed to delete {bucket}/{key}: {e}")
|
||||||
277
worker/src/services/thumbnail.py
Normal file
277
worker/src/services/thumbnail.py
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
"""Thumbnail generation service using trimesh and legacy OSMesa."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import trimesh
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Maximum faces to render for thumbnail (performance limit for immediate mode)
|
||||||
|
MAX_FACES_FOR_RENDER = 50000
|
||||||
|
|
||||||
|
|
||||||
|
def generate_thumbnail(glb_path: Path, output_path: Path) -> bool:
|
||||||
|
"""
|
||||||
|
Generate a thumbnail image from a GLB file.
|
||||||
|
|
||||||
|
Uses legacy OSMesa for off-screen rendering.
|
||||||
|
Falls back to a simple placeholder if rendering fails.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return _generate_with_osmesa(glb_path, output_path)
|
||||||
|
except ImportError as e:
|
||||||
|
logger.warning(f"OSMesa not available: {e}, using simple thumbnail")
|
||||||
|
return _generate_simple_thumbnail(glb_path, output_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to generate thumbnail with OSMesa: {e}", exc_info=True)
|
||||||
|
return _generate_simple_thumbnail(glb_path, output_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_with_osmesa(glb_path: Path, output_path: Path) -> bool:
|
||||||
|
"""Generate thumbnail using legacy OSMesa context and OpenGL."""
|
||||||
|
from OpenGL import osmesa
|
||||||
|
from OpenGL.GL import (
|
||||||
|
GL_COLOR_BUFFER_BIT,
|
||||||
|
GL_DEPTH_BUFFER_BIT,
|
||||||
|
GL_DEPTH_TEST,
|
||||||
|
GL_LESS,
|
||||||
|
GL_LIGHT0,
|
||||||
|
GL_LIGHT1,
|
||||||
|
GL_LIGHTING,
|
||||||
|
GL_MODELVIEW,
|
||||||
|
GL_NORMALIZE,
|
||||||
|
GL_POSITION,
|
||||||
|
GL_PROJECTION,
|
||||||
|
GL_SMOOTH,
|
||||||
|
GL_TRIANGLES,
|
||||||
|
GL_UNSIGNED_BYTE,
|
||||||
|
glBegin,
|
||||||
|
glClear,
|
||||||
|
glClearColor,
|
||||||
|
glColor3f,
|
||||||
|
glDepthFunc,
|
||||||
|
glEnable,
|
||||||
|
glEnd,
|
||||||
|
glFinish,
|
||||||
|
glLightfv,
|
||||||
|
glLoadIdentity,
|
||||||
|
glMatrixMode,
|
||||||
|
glNormal3fv,
|
||||||
|
glShadeModel,
|
||||||
|
glVertex3fv,
|
||||||
|
glViewport,
|
||||||
|
)
|
||||||
|
from OpenGL.GLU import gluLookAt, gluPerspective
|
||||||
|
|
||||||
|
# Load the mesh
|
||||||
|
mesh = trimesh.load(str(glb_path))
|
||||||
|
logger.info(f"Loaded mesh from {glb_path}")
|
||||||
|
|
||||||
|
# Get combined geometry if it's a scene
|
||||||
|
if isinstance(mesh, trimesh.Scene):
|
||||||
|
# Combine all geometries into a single mesh
|
||||||
|
meshes = []
|
||||||
|
for name, geom in mesh.geometry.items():
|
||||||
|
if isinstance(geom, trimesh.Trimesh):
|
||||||
|
meshes.append(geom)
|
||||||
|
if meshes:
|
||||||
|
mesh = trimesh.util.concatenate(meshes)
|
||||||
|
else:
|
||||||
|
logger.warning("No valid meshes found in scene")
|
||||||
|
return _generate_simple_thumbnail(glb_path, output_path)
|
||||||
|
|
||||||
|
if not isinstance(mesh, trimesh.Trimesh):
|
||||||
|
logger.warning(f"Unsupported mesh type: {type(mesh)}")
|
||||||
|
return _generate_simple_thumbnail(glb_path, output_path)
|
||||||
|
|
||||||
|
# Simplify mesh if too large for immediate mode rendering
|
||||||
|
num_faces = len(mesh.faces)
|
||||||
|
if num_faces > MAX_FACES_FOR_RENDER:
|
||||||
|
logger.info(f"Simplifying mesh from {num_faces} to ~{MAX_FACES_FOR_RENDER} faces for thumbnail")
|
||||||
|
try:
|
||||||
|
# Try fast-simplification library first
|
||||||
|
import fast_simplification
|
||||||
|
simplified_vertices, simplified_faces = fast_simplification.simplify(
|
||||||
|
mesh.vertices,
|
||||||
|
mesh.faces,
|
||||||
|
target_reduction=1 - (MAX_FACES_FOR_RENDER / num_faces)
|
||||||
|
)
|
||||||
|
mesh = trimesh.Trimesh(vertices=simplified_vertices, faces=simplified_faces)
|
||||||
|
logger.info(f"Simplified to {len(mesh.faces)} faces using fast-simplification")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Simplification failed: {e}, will render subset")
|
||||||
|
# Limit to first N faces if simplification fails
|
||||||
|
if num_faces > MAX_FACES_FOR_RENDER:
|
||||||
|
mesh.faces = mesh.faces[:MAX_FACES_FOR_RENDER]
|
||||||
|
|
||||||
|
# Get mesh data
|
||||||
|
vertices = mesh.vertices
|
||||||
|
faces = mesh.faces
|
||||||
|
face_normals = mesh.face_normals if hasattr(mesh, 'face_normals') else None
|
||||||
|
|
||||||
|
# Get vertex colors if available
|
||||||
|
vertex_colors = None
|
||||||
|
if hasattr(mesh, 'visual') and hasattr(mesh.visual, 'vertex_colors'):
|
||||||
|
try:
|
||||||
|
vc = mesh.visual.vertex_colors
|
||||||
|
if vc is not None and len(vc) > 0:
|
||||||
|
vertex_colors = vc[:, :3].astype(np.float32) / 255.0
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Calculate bounding box and camera position
|
||||||
|
bounds = mesh.bounds
|
||||||
|
center = (bounds[0] + bounds[1]) / 2
|
||||||
|
size = np.max(bounds[1] - bounds[0])
|
||||||
|
|
||||||
|
if size == 0 or np.isnan(size):
|
||||||
|
size = 1.0
|
||||||
|
|
||||||
|
# Camera setup - position for isometric-like view
|
||||||
|
camera_distance = size * 2.5
|
||||||
|
camera_pos = center + np.array([
|
||||||
|
camera_distance * 0.7,
|
||||||
|
camera_distance * 0.5,
|
||||||
|
camera_distance * 0.7,
|
||||||
|
])
|
||||||
|
|
||||||
|
# Create OSMesa context using legacy function
|
||||||
|
width, height = settings.thumbnail_size
|
||||||
|
ctx = osmesa.OSMesaCreateContextExt(osmesa.OSMESA_RGBA, 24, 0, 0, None)
|
||||||
|
|
||||||
|
if not ctx:
|
||||||
|
raise RuntimeError("Failed to create OSMesa context")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create buffer for rendering
|
||||||
|
buffer = np.zeros((height, width, 4), dtype=np.uint8)
|
||||||
|
|
||||||
|
# Make context current
|
||||||
|
result = osmesa.OSMesaMakeCurrent(ctx, buffer, GL_UNSIGNED_BYTE, width, height)
|
||||||
|
if not result:
|
||||||
|
raise RuntimeError("Failed to make OSMesa context current")
|
||||||
|
|
||||||
|
# Set up viewport
|
||||||
|
glViewport(0, 0, width, height)
|
||||||
|
|
||||||
|
# Set up projection matrix
|
||||||
|
glMatrixMode(GL_PROJECTION)
|
||||||
|
glLoadIdentity()
|
||||||
|
aspect = width / height
|
||||||
|
gluPerspective(45.0, aspect, size * 0.01, size * 100)
|
||||||
|
|
||||||
|
# Set up modelview matrix
|
||||||
|
glMatrixMode(GL_MODELVIEW)
|
||||||
|
glLoadIdentity()
|
||||||
|
gluLookAt(
|
||||||
|
float(camera_pos[0]), float(camera_pos[1]), float(camera_pos[2]),
|
||||||
|
float(center[0]), float(center[1]), float(center[2]),
|
||||||
|
0, 1, 0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Enable depth testing
|
||||||
|
glEnable(GL_DEPTH_TEST)
|
||||||
|
glDepthFunc(GL_LESS)
|
||||||
|
|
||||||
|
# Enable lighting
|
||||||
|
glEnable(GL_LIGHTING)
|
||||||
|
glEnable(GL_LIGHT0)
|
||||||
|
glEnable(GL_LIGHT1)
|
||||||
|
glEnable(GL_NORMALIZE)
|
||||||
|
glShadeModel(GL_SMOOTH)
|
||||||
|
|
||||||
|
# Enable color material so vertex colors work with lighting
|
||||||
|
from OpenGL.GL import GL_COLOR_MATERIAL, GL_AMBIENT_AND_DIFFUSE, glColorMaterial
|
||||||
|
glEnable(GL_COLOR_MATERIAL)
|
||||||
|
glColorMaterial(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE)
|
||||||
|
|
||||||
|
# Set up light 0 (main light from camera direction)
|
||||||
|
from OpenGL.GL import GL_AMBIENT, GL_DIFFUSE, GL_SPECULAR
|
||||||
|
light0_pos = [float(camera_pos[0]), float(camera_pos[1]), float(camera_pos[2]), 0.0] # Directional light
|
||||||
|
glLightfv(GL_LIGHT0, GL_POSITION, light0_pos)
|
||||||
|
glLightfv(GL_LIGHT0, GL_AMBIENT, [0.3, 0.3, 0.3, 1.0])
|
||||||
|
glLightfv(GL_LIGHT0, GL_DIFFUSE, [0.8, 0.8, 0.8, 1.0])
|
||||||
|
|
||||||
|
# Set up light 1 (fill light from opposite side)
|
||||||
|
light1_pos = [float(-camera_pos[0]), float(camera_pos[1]), float(-camera_pos[2]), 0.0]
|
||||||
|
glLightfv(GL_LIGHT1, GL_POSITION, light1_pos)
|
||||||
|
glLightfv(GL_LIGHT1, GL_AMBIENT, [0.2, 0.2, 0.2, 1.0])
|
||||||
|
glLightfv(GL_LIGHT1, GL_DIFFUSE, [0.5, 0.5, 0.5, 1.0])
|
||||||
|
|
||||||
|
# Clear buffers
|
||||||
|
glClearColor(0.15, 0.15, 0.18, 1.0)
|
||||||
|
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
|
||||||
|
|
||||||
|
# Render the mesh using immediate mode
|
||||||
|
glBegin(GL_TRIANGLES)
|
||||||
|
|
||||||
|
for i, face in enumerate(faces):
|
||||||
|
# Set face normal for lighting
|
||||||
|
if face_normals is not None:
|
||||||
|
n = face_normals[i]
|
||||||
|
glNormal3fv([float(n[0]), float(n[1]), float(n[2])])
|
||||||
|
|
||||||
|
for vertex_idx in face:
|
||||||
|
# Set vertex color (default to light gray if no colors)
|
||||||
|
if vertex_colors is not None and vertex_idx < len(vertex_colors):
|
||||||
|
c = vertex_colors[vertex_idx]
|
||||||
|
glColor3f(float(c[0]), float(c[1]), float(c[2]))
|
||||||
|
else:
|
||||||
|
glColor3f(0.75, 0.75, 0.78)
|
||||||
|
|
||||||
|
# Draw vertex
|
||||||
|
v = vertices[vertex_idx]
|
||||||
|
glVertex3fv([float(v[0]), float(v[1]), float(v[2])])
|
||||||
|
|
||||||
|
glEnd()
|
||||||
|
glFinish()
|
||||||
|
|
||||||
|
# Flip the image vertically (OpenGL origin is bottom-left)
|
||||||
|
image_data = np.flipud(buffer)
|
||||||
|
|
||||||
|
# Save the image
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
Image.fromarray(image_data).save(str(output_path))
|
||||||
|
logger.info(f"Thumbnail generated with OSMesa: {output_path} ({len(faces)} faces rendered)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
finally:
|
||||||
|
osmesa.OSMesaDestroyContext(ctx)
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_simple_thumbnail(glb_path: Path, output_path: Path) -> bool:
|
||||||
|
"""
|
||||||
|
Generate a simple placeholder thumbnail when OSMesa is not available.
|
||||||
|
Creates a solid color image with a gradient pattern.
|
||||||
|
"""
|
||||||
|
width, height = settings.thumbnail_size
|
||||||
|
|
||||||
|
# Create a simple gradient background
|
||||||
|
img = Image.new('RGB', (width, height), color=(64, 64, 64))
|
||||||
|
|
||||||
|
# Add a simple icon/pattern to indicate 3D model
|
||||||
|
pixels = img.load()
|
||||||
|
center_x, center_y = width // 2, height // 2
|
||||||
|
|
||||||
|
# Draw a simple cube-like shape
|
||||||
|
for y in range(height):
|
||||||
|
for x in range(width):
|
||||||
|
# Create a gradient
|
||||||
|
dist = ((x - center_x) ** 2 + (y - center_y) ** 2) ** 0.5
|
||||||
|
max_dist = (width ** 2 + height ** 2) ** 0.5 / 2
|
||||||
|
factor = 1 - (dist / max_dist) * 0.5
|
||||||
|
|
||||||
|
r = int(80 * factor)
|
||||||
|
g = int(120 * factor)
|
||||||
|
b = int(160 * factor)
|
||||||
|
pixels[x, y] = (r, g, b)
|
||||||
|
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
img.save(str(output_path))
|
||||||
|
logger.info(f"Simple thumbnail generated: {output_path}")
|
||||||
|
return True
|
||||||
Reference in New Issue
Block a user