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