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>
304 lines
6.8 KiB
TypeScript
304 lines
6.8 KiB
TypeScript
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;
|