Initial commit: 3D Viewer application

Features:
- Vue 3 frontend with Three.js/Online3DViewer
- Node.js API with PostgreSQL and Redis
- Python worker for model conversion
- Docker Compose for deployment
- ViewCube navigation with drag rotation and 90° snap
- Cross-section, exploded view, and render settings
- Parts tree with visibility controls

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
likegears
2025-12-12 14:00:17 +08:00
commit 7af9c323f6
86 changed files with 20343 additions and 0 deletions

55
.env.example Normal file
View 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
View 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
View File

@@ -0,0 +1,2 @@
- 每次更新都要确保到docker compose更新
- always redeploy docker compose after change

56
api/Dockerfile Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

55
api/src/app.ts Normal file
View 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
View 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
View 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);
});

View 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`,
},
});
}

View 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(),
}),
};

View 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;

View 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;

View 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;

View 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;

View 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;
}

View 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');
}

View 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
View 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
View 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
View 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
View 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
View File

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

13
frontend/index.html Normal file
View File

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

42
frontend/nginx.conf Normal file
View File

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

32
frontend/package.json Normal file
View File

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

1213
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,153 @@
<script setup lang="ts">
import { ref, watch, onUnmounted } from 'vue'
import { useViewerStore } from '@/stores/viewer'
import { getExplodeService, resetExplodeService } from '@/services/explodeService'
import { getRenderService } from '@/services/renderService'
import { getClippingService } from '@/services/clippingService'
import { getPartsTreeService } from '@/services/partsTreeService'
const viewerStore = useViewerStore()
const isInitialized = ref(false)
const partsCount = ref(0)
const isAnimating = ref(false)
const isDragging = ref(false)
// Initialize when model changes (not scene - scene reference stays the same)
watch(
() => viewerStore.model,
(model) => {
if (model && viewerStore.scene) {
const service = getExplodeService()
service.initializeFromScene(viewerStore.scene, () => viewerStore.forceRender())
isInitialized.value = service.isInitialized()
partsCount.value = service.getPartsCount()
}
},
{ immediate: true }
)
// Apply explosion when factor changes (immediate for slider dragging)
watch(
() => viewerStore.explosionFactor,
(factor) => {
if (isInitialized.value && !isAnimating.value) {
const service = getExplodeService()
// Pass isDragging to skip expensive operations during drag
service.applyExplosion(factor, isDragging.value)
viewerStore.forceRender()
}
}
)
onUnmounted(() => {
resetExplodeService()
})
function handleSliderInput(event: Event) {
// Mark as dragging to skip expensive operations
isDragging.value = true
const target = event.target as HTMLInputElement
viewerStore.setExplosionFactor(Number(target.value))
}
function handleSliderChange() {
// Drag ended - execute expensive sync operations now
isDragging.value = false
finalizeExplosion()
}
function finalizeExplosion() {
// Execute expensive operations that were skipped during dragging
getRenderService().syncEdgeTransforms()
getClippingService().updateBounds()
getPartsTreeService().syncSelectionOverlays()
viewerStore.forceRender()
}
function handlePlayAnimation() {
if (isAnimating.value || !isInitialized.value || partsCount.value < 2) return
const service = getExplodeService()
const currentFactor = viewerStore.explosionFactor
// Toggle: if <= 50% go to 100%, if > 50% go to 0%
const targetFactor = currentFactor <= 50 ? 100 : 0
isAnimating.value = true
service.animateExplosion(targetFactor, 800, () => {
viewerStore.setExplosionFactor(targetFactor)
isAnimating.value = false
})
}
// Computed: button label based on current factor
function getButtonLabel(): string {
return viewerStore.explosionFactor <= 50 ? '爆炸' : '收回'
}
</script>
<template>
<div class="feature-section">
<div class="feature-header">
<h4>爆炸图</h4>
<div class="header-controls">
<button
class="btn btn-sm"
:class="viewerStore.explosionFactor > 50 ? 'btn-primary' : 'btn-secondary'"
:disabled="!isInitialized || partsCount < 2 || isAnimating"
@click="handlePlayAnimation"
>
{{ getButtonLabel() }}
</button>
</div>
</div>
<div v-if="isInitialized && partsCount >= 2" class="slider-container">
<input
type="range"
class="slider"
min="0"
max="100"
:value="viewerStore.explosionFactor"
:disabled="isAnimating"
@input="handleSliderInput"
@change="handleSliderChange"
/>
<span class="slider-value">{{ viewerStore.explosionFactor }}%</span>
</div>
<div v-if="partsCount > 0" class="feature-info">
<small>检测到 {{ partsCount }} 个零件</small>
</div>
</div>
</template>
<style scoped>
.feature-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.feature-header h4 {
margin: 0;
}
.header-controls {
display: flex;
align-items: center;
gap: 8px;
}
.feature-info {
margin-top: 8px;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 8px;
}
.feature-info small {
font-size: 11px;
}
</style>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import { useViewerStore } from '@/stores/viewer'
import ExplodedView from './ExplodedView.vue'
import CrossSection from './CrossSection.vue'
import RenderSettings from './RenderSettings.vue'
import ThumbnailCapture from './ThumbnailCapture.vue'
const viewerStore = useViewerStore()
</script>
<template>
<div v-if="viewerStore.model" class="feature-panel">
<RenderSettings />
<ExplodedView />
<CrossSection />
<ThumbnailCapture />
</div>
</template>

View File

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

View File

@@ -0,0 +1,481 @@
<script setup lang="ts">
import { computed, watch, onUnmounted } from 'vue'
import { useViewerStore } from '@/stores/viewer'
import { getPartsTreeService, MaterialType } from '@/services/partsTreeService'
import { getRenderService, resetRenderService, RenderMode } from '@/services/renderService'
const viewerStore = useViewerStore()
const renderMode = computed({
get: () => viewerStore.renderSettings.renderMode,
set: (value) => viewerStore.setRenderMode(value),
})
// Check if in special render mode (where certain controls should be disabled)
const isSpecialRenderMode = computed(() =>
renderMode.value === 'hiddenLine' || renderMode.value === 'wireframe'
)
const edgesEnabled = computed({
get: () => viewerStore.renderSettings.edgesEnabled,
set: (value) => viewerStore.setEdgesEnabled(value),
})
const edgeLineWidth = computed({
get: () => viewerStore.renderSettings.edgeLineWidth,
set: (value) => viewerStore.setEdgeLineWidth(value),
})
const autoColorEnabled = computed({
get: () => viewerStore.renderSettings.autoColorEnabled,
set: (value) => viewerStore.setAutoColorEnabled(value),
})
const materialType = computed({
get: () => viewerStore.renderSettings.materialType,
set: (value) => viewerStore.setMaterialType(value),
})
// Lighting settings
const exposure = computed({
get: () => viewerStore.renderSettings.exposure,
set: (value) => viewerStore.setExposure(value),
})
const mainLightIntensity = computed({
get: () => viewerStore.renderSettings.mainLightIntensity,
set: (value) => viewerStore.setMainLightIntensity(value),
})
const ambientLightIntensity = computed({
get: () => viewerStore.renderSettings.ambientLightIntensity,
set: (value) => viewerStore.setAmbientLightIntensity(value),
})
// Initialize render service when scene is available
watch(
() => viewerStore.scene,
(scene) => {
if (scene) {
const renderService = getRenderService()
renderService.initialize(scene, viewerStore.renderer)
// Apply edge line setting immediately after initialization
if (viewerStore.renderSettings.edgesEnabled) {
renderService.setEdgesEnabled(true)
}
}
},
{ immediate: true }
)
// Watch edge line toggle
watch(
() => viewerStore.renderSettings.edgesEnabled,
(enabled) => {
const renderService = getRenderService()
if (renderService.isInitialized()) {
renderService.setEdgesEnabled(enabled)
viewerStore.forceRender()
}
}
)
// Watch edge line width changes
watch(
() => viewerStore.renderSettings.edgeLineWidth,
(width) => {
const renderService = getRenderService()
if (renderService.isInitialized()) {
renderService.setEdgeLineWidth(width)
viewerStore.forceRender()
}
}
)
// Watch auto-color changes and apply/reset colors
watch(
() => viewerStore.renderSettings.autoColorEnabled,
(enabled) => {
const service = getPartsTreeService()
if (enabled) {
service.applyAutoColors()
} else {
service.resetToOriginalColors()
}
viewerStore.forceRender()
}
)
// Watch material type changes
watch(
() => viewerStore.renderSettings.materialType,
(type) => {
const service = getPartsTreeService()
const materialTypeMap: Record<string, MaterialType> = {
'clay': MaterialType.Clay,
'metal': MaterialType.Metal,
'paint': MaterialType.Paint,
}
service.setGlobalMaterial(materialTypeMap[type])
viewerStore.forceRender()
}
)
// Watch render mode changes
watch(
() => viewerStore.renderSettings.renderMode,
(mode) => {
const renderService = getRenderService()
if (renderService.isInitialized()) {
const modeMap: Record<string, RenderMode> = {
'standard': RenderMode.Standard,
'hiddenLine': RenderMode.HiddenLine,
'wireframe': RenderMode.Wireframe,
}
renderService.setRenderMode(modeMap[mode])
viewerStore.forceRender()
}
}
)
// Watch exposure (scene brightness) changes
watch(
() => viewerStore.renderSettings.exposure,
(value) => {
const renderService = getRenderService()
if (renderService.isInitialized()) {
renderService.setToneMappingExposure(value)
viewerStore.forceRender()
}
}
)
// Watch main light intensity changes
watch(
() => viewerStore.renderSettings.mainLightIntensity,
(value) => {
const renderService = getRenderService()
if (renderService.isInitialized()) {
renderService.setMainLightIntensity(value)
viewerStore.forceRender()
}
}
)
// Watch ambient light intensity changes
watch(
() => viewerStore.renderSettings.ambientLightIntensity,
(value) => {
const renderService = getRenderService()
if (renderService.isInitialized()) {
renderService.setAmbientLightIntensity(value)
viewerStore.forceRender()
}
}
)
onUnmounted(() => {
resetRenderService()
})
</script>
<template>
<div class="feature-section">
<h4>渲染设置</h4>
<div class="setting-row" :class="{ disabled: isSpecialRenderMode }">
<label class="toggle-label" :class="{ disabled: isSpecialRenderMode }">
<input
v-model="edgesEnabled"
type="checkbox"
class="toggle-checkbox"
:disabled="isSpecialRenderMode"
/>
<span class="toggle-switch"></span>
<span class="toggle-text">
边缘线
<span v-if="isSpecialRenderMode" class="hint-inline">(自动启用)</span>
</span>
</label>
</div>
<div v-if="edgesEnabled || isSpecialRenderMode" class="setting-row slider-row">
<span class="setting-label">线宽</span>
<div class="slider-container">
<input
v-model.number="edgeLineWidth"
type="range"
min="0.5"
max="5"
step="0.5"
class="slider"
/>
<span class="slider-value">{{ edgeLineWidth }}px</span>
</div>
</div>
<div class="setting-row">
<label class="toggle-label">
<input
v-model="autoColorEnabled"
type="checkbox"
class="toggle-checkbox"
/>
<span class="toggle-switch"></span>
<span class="toggle-text">
自动着色
</span>
</label>
</div>
<div class="setting-row material-row">
<span class="setting-label">渲染模式</span>
<div class="material-buttons">
<button
:class="['material-btn', { active: renderMode === 'standard' }]"
@click="renderMode = 'standard'"
title="标准实体渲染"
>
实体
</button>
<button
:class="['material-btn', { active: renderMode === 'hiddenLine' }]"
@click="renderMode = 'hiddenLine'"
title="消隐线模式"
>
消隐
</button>
<button
:class="['material-btn', { active: renderMode === 'wireframe' }]"
@click="renderMode = 'wireframe'"
title="线框模式"
>
线框
</button>
</div>
</div>
<div class="setting-row material-row" :class="{ disabled: isSpecialRenderMode }">
<span class="setting-label">材质类型</span>
<div class="material-buttons">
<button
:class="['material-btn', { active: materialType === 'clay' }]"
:disabled="isSpecialRenderMode"
@click="materialType = 'clay'"
title="白模材质 - 结构展示"
>
白模
</button>
<button
:class="['material-btn', { active: materialType === 'metal' }]"
:disabled="isSpecialRenderMode"
@click="materialType = 'metal'"
title="金属材质"
>
金属
</button>
<button
:class="['material-btn', { active: materialType === 'paint' }]"
:disabled="isSpecialRenderMode"
@click="materialType = 'paint'"
title="工业哑光烤漆"
>
烤漆
</button>
</div>
</div>
<p v-if="autoColorEnabled" class="hint">
明亮色系已应用到各零件
</p>
<div class="setting-row section-divider">
<span class="setting-label section-title">光照设置</span>
</div>
<div class="setting-row slider-row">
<span class="setting-label">整体亮度</span>
<div class="slider-container">
<input
v-model.number="exposure"
type="range"
min="0.1"
max="3"
step="0.1"
class="slider"
/>
<span class="slider-value">{{ exposure.toFixed(1) }}</span>
</div>
</div>
<div class="setting-row slider-row">
<span class="setting-label">主光亮度</span>
<div class="slider-container">
<input
v-model.number="mainLightIntensity"
type="range"
min="0"
max="2"
step="0.1"
class="slider"
/>
<span class="slider-value">{{ mainLightIntensity.toFixed(1) }}</span>
</div>
</div>
<div class="setting-row slider-row">
<span class="setting-label">环境亮度</span>
<div class="slider-container">
<input
v-model.number="ambientLightIntensity"
type="range"
min="0"
max="2"
step="0.1"
class="slider"
/>
<span class="slider-value">{{ ambientLightIntensity.toFixed(1) }}</span>
</div>
</div>
</div>
</template>
<style scoped>
.setting-row {
margin-top: 10px;
}
.section-divider {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--border-color);
}
.section-title {
font-weight: 500;
color: var(--text-primary);
}
.hint {
margin-top: 8px;
font-size: 11px;
color: var(--text-secondary);
}
.hint-inline {
font-size: 11px;
color: var(--text-secondary);
font-style: italic;
}
.toggle-label.disabled {
cursor: not-allowed;
opacity: 0.6;
}
.setting-row.disabled {
opacity: 0.7;
}
/* Material type selector */
.material-row {
display: flex;
flex-direction: column;
gap: 8px;
}
.setting-label {
font-size: 13px;
color: var(--text-primary);
}
.material-buttons {
display: flex;
gap: 6px;
}
.material-btn {
flex: 1;
padding: 6px 8px;
font-size: 12px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-secondary);
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s ease;
}
.material-btn:hover {
background: var(--bg-tertiary);
border-color: var(--primary-color);
}
.material-btn.active {
background: var(--primary-color);
border-color: var(--primary-color);
color: white;
}
.material-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.material-btn:disabled:hover {
background: var(--bg-secondary);
border-color: var(--border-color);
}
.material-row.disabled {
opacity: 0.6;
}
/* Slider row */
.slider-row {
display: flex;
flex-direction: column;
gap: 6px;
}
.slider-container {
display: flex;
align-items: center;
gap: 10px;
}
.slider {
flex: 1;
height: 4px;
appearance: none;
background: var(--bg-tertiary);
border-radius: 2px;
outline: none;
}
.slider::-webkit-slider-thumb {
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--primary-color);
cursor: pointer;
}
.slider::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--primary-color);
cursor: pointer;
border: none;
}
.slider-value {
min-width: 40px;
font-size: 12px;
color: var(--text-secondary);
text-align: right;
}
</style>

View File

@@ -0,0 +1,288 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useViewerStore } from '@/stores/viewer'
import { captureFullScreenshot } from '@/services/screenshotService'
const viewerStore = useViewerStore()
const isCapturing = ref(false)
const showPreview = ref(false)
const previewUrl = ref<string | null>(null)
const screenshotBlob = ref<Blob | null>(null)
async function captureScreenshot() {
if (!viewerStore.renderer || !viewerStore.scene || !viewerStore.camera) {
return
}
isCapturing.value = true
try {
const blob = await captureFullScreenshot(
viewerStore.renderer,
viewerStore.scene,
viewerStore.camera
)
screenshotBlob.value = blob
previewUrl.value = URL.createObjectURL(blob)
showPreview.value = true
} catch (error) {
console.error('Failed to capture screenshot:', error)
} finally {
isCapturing.value = false
}
}
function downloadScreenshot() {
if (!screenshotBlob.value) return
const now = new Date()
const timestamp = now.toISOString().replace(/[-:T]/g, '').slice(0, 14)
const filename = `screenshot_${timestamp}.png`
const url = URL.createObjectURL(screenshotBlob.value)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
closePreview()
}
function closePreview() {
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value)
}
showPreview.value = false
previewUrl.value = null
screenshotBlob.value = null
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && showPreview.value) {
closePreview()
}
}
function handleOverlayClick(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains('preview-overlay')) {
closePreview()
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value)
}
})
</script>
<template>
<div class="feature-section screenshot-capture">
<h4>截图</h4>
<div class="capture-row">
<button
class="capture-btn"
:disabled="isCapturing || !viewerStore.model"
@click="captureScreenshot"
title="截取当前视图"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="icon">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
<circle cx="12" cy="13" r="4"/>
</svg>
<span>{{ isCapturing ? '截图中...' : '截图' }}</span>
</button>
</div>
</div>
<Teleport to="body">
<div
v-if="showPreview"
class="preview-overlay"
@click="handleOverlayClick"
>
<div class="preview-modal">
<div class="preview-image-container">
<img
v-if="previewUrl"
:src="previewUrl"
alt="Screenshot preview"
class="preview-image"
/>
</div>
<div class="preview-actions">
<button class="btn-download" @click="downloadScreenshot">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="icon">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
下载
</button>
<button class="btn-cancel" @click="closePreview">
取消
</button>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.screenshot-capture {
margin-top: 10px;
}
.capture-row {
display: flex;
align-items: center;
gap: 10px;
}
.capture-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
font-size: 13px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-secondary);
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s ease;
}
.capture-btn:hover:not(:disabled) {
background: var(--bg-tertiary);
border-color: var(--primary-color);
}
.capture-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.capture-btn .icon {
width: 16px;
height: 16px;
}
/* Preview overlay */
.preview-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.75);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
backdrop-filter: blur(4px);
animation: fade-in 0.2s ease;
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.preview-modal {
background: var(--bg-primary);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xl);
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
animation: scale-in 0.2s ease;
}
@keyframes scale-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.preview-image-container {
padding: var(--space-4);
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
}
.preview-image {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
}
.preview-actions {
display: flex;
gap: var(--space-3);
padding: var(--space-4);
justify-content: center;
border-top: 1px solid var(--border-color);
}
.btn-download,
.btn-cancel {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 20px;
font-size: 14px;
font-weight: 500;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s ease;
}
.btn-download {
background: var(--primary-color);
color: white;
}
.btn-download:hover {
background: var(--primary-hover);
transform: translateY(-1px);
}
.btn-download .icon {
width: 16px;
height: 16px;
}
.btn-cancel {
background: var(--bg-tertiary);
color: var(--text-secondary);
border: 1px solid var(--border-color);
}
.btn-cancel:hover {
background: var(--bg-sunken);
color: var(--text-primary);
}
</style>

View File

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

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

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

1248
frontend/src/style.css Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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

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

View File

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

25
frontend/tsconfig.json Normal file
View File

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

View File

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

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

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

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

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

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

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

View 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
View File

@@ -0,0 +1,4 @@
里程碑ID,里程碑名称,目标日期,交付物,状态,完成百分比
M1,基础架构完成,2024-12-06,Docker环境 + API框架 + 前端框架,已完成,100%
M2,核心功能可用,2024-12-13,上传→转换→查看 完整流程,进行中,20%
M3,MVP 发布,2024-12-20,完整功能 + 测试通过 + 文档,待开始,0%
1 里程碑ID 里程碑名称 目标日期 交付物 状态 完成百分比
2 M1 基础架构完成 2024-12-06 Docker环境 + API框架 + 前端框架 已完成 100%
3 M2 核心功能可用 2024-12-13 上传→转换→查看 完整流程 进行中 20%
4 M3 MVP 发布 2024-12-20 完整功能 + 测试通过 + 文档 待开始 0%

27
project-management.csv Normal file
View 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配置
1 任务ID 任务名称 负责人 阶段 优先级 状态 预计工时(天) 开始日期 截止日期 依赖项 备注
2 1.1 Docker 环境搭建 后端 第一周 P0 已完成 1 2024-12-02 2024-12-02 - PostgreSQL/Redis/MinIO
3 1.2 数据库 Schema 设计 后端 第一周 P0 已完成 0.5 2024-12-02 2024-12-02 1.1 models/model_parts表
4 1.3 API 服务框架 后端 第一周 P0 已完成 1.5 2024-12-02 2024-12-03 1.1 Express + TypeScript
5 1.4 MinIO 存储服务 后端 第一周 P0 已完成 1 2024-12-03 2024-12-04 1.1 预签名URL上传
6 1.5 BullMQ 队列服务 后端 第一周 P0 已完成 0.5 2024-12-04 2024-12-04 1.1 任务队列
7 1.6 Python Worker 框架 后端 第一周 P0 已完成 1 2024-12-04 2024-12-05 1.5 模型转换基础
8 1.7 Vue 前端框架 前端 第一周 P0 已完成 1 2024-12-02 2024-12-03 - Vue3 + Vite + Pinia
9 1.8 3D Viewer 组件 前端 第一周 P0 已完成 1.5 2024-12-03 2024-12-05 1.7 Online3DViewer集成
10 1.9 爆炸图服务 前端 第一周 P1 已完成 1 2024-12-05 2024-12-06 1.8 ExplodeService
11 1.10 剖面服务 前端 第一周 P1 已完成 1 2024-12-05 2024-12-06 1.8 ClippingService
12 2.1 STEP 文件转换 后端 第二周 P0 进行中 2 2024-12-09 2024-12-10 1.6 cascadio → GLB
13 2.2 STL/OBJ 转换 后端 第二周 P0 待开始 1 2024-12-10 2024-12-11 1.6 trimesh → GLB
14 2.3 缩略图生成 后端 第二周 P1 待开始 1.5 2024-12-11 2024-12-12 2.1 pyrender + OSMesa
15 2.4 模型上传流程 前端 第二周 P0 待开始 1 2024-12-09 2024-12-10 1.4 拖拽上传 + 进度条
16 2.5 模型列表页 前端 第二周 P0 待开始 1.5 2024-12-10 2024-12-11 2.4 缩略图 + 搜索 + 筛选
17 2.6 模型详情页 前端 第二周 P0 待开始 1 2024-12-11 2024-12-12 2.5 查看 + 编辑 + 删除
18 2.7 爆炸图 UI 完善 前端 第二周 P1 待开始 1 2024-12-12 2024-12-13 1.9 滑块 + 动画效果
19 2.8 剖面 UI 完善 前端 第二周 P1 待开始 1 2024-12-12 2024-12-13 1.10 三轴控制 + 翻转
20 3.1 错误处理完善 全栈 第三周 P0 待开始 1 2024-12-16 2024-12-16 2.* 统一错误格式
21 3.2 加载状态优化 前端 第三周 P1 待开始 0.5 2024-12-16 2024-12-16 2.5 Skeleton + Loading
22 3.3 大模型性能优化 前端 第三周 P1 待开始 1.5 2024-12-17 2024-12-18 1.8 LOD + 懒加载
23 3.4 Worker 并发优化 后端 第三周 P2 待开始 1 2024-12-17 2024-12-18 1.6 资源限制
24 3.5 UI 样式美化 前端 第三周 P2 待开始 1 2024-12-18 2024-12-19 2.* 响应式 + 动效
25 3.6 E2E 测试 QA 第三周 P1 待开始 1 2024-12-19 2024-12-20 3.* Playwright
26 3.7 文档编写 全栈 第三周 P2 待开始 0.5 2024-12-20 2024-12-20 全部 README + API文档
27 3.8 部署脚本 DevOps 第三周 P1 待开始 0.5 2024-12-20 2024-12-20 全部 CI/CD配置

5
risks.csv Normal file
View File

@@ -0,0 +1,5 @@
风险ID,风险描述,发生概率,影响程度,风险等级,缓解措施,负责人,状态
R1,STEP 转换失败率高,,,,多引擎回退 (cascadio → OpenCASCADE),后端,监控中
R2,大文件上传超时,,,,分片上传 + 断点续传,全栈,待处理
R3,3D渲染性能问题,,,,WebGL降级 + LOD,前端,待处理
R4,依赖包安全漏洞,,,,定期 npm audit / pip check,DevOps,监控中
1 风险ID 风险描述 发生概率 影响程度 风险等级 缓解措施 负责人 状态
2 R1 STEP 转换失败率高 多引擎回退 (cascadio → OpenCASCADE) 后端 监控中
3 R2 大文件上传超时 分片上传 + 断点续传 全栈 待处理
4 R3 3D渲染性能问题 WebGL降级 + LOD 前端 待处理
5 R4 依赖包安全漏洞 定期 npm audit / pip check DevOps 监控中

66
start.sh Executable file
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
# 3D Model Conversion Worker

49
worker/src/config.py Normal file
View 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
View 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()

View File

@@ -0,0 +1 @@
# Model processors

View 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

View File

@@ -0,0 +1 @@
# Worker services

View 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}")

View 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