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:
303
api/src/routes/models.routes.ts
Normal file
303
api/src/routes/models.routes.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import { Router, type IRouter } from 'express';
|
||||
import multer from 'multer';
|
||||
import { validate, schemas } from '../middleware/validation.middleware.js';
|
||||
import { NotFoundError } from '../middleware/error.middleware.js';
|
||||
import * as modelsService from '../services/models.service.js';
|
||||
import { addThumbnailJob } from '../services/queue.service.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
// Configure multer for thumbnail uploads (memory storage for small images)
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {
|
||||
fileSize: 2 * 1024 * 1024, // 2MB max
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.mimetype === 'image/png' || file.mimetype === 'image/jpeg') {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only PNG and JPEG images are allowed'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /models - List all models
|
||||
*/
|
||||
router.get('/', validate(schemas.modelListQuery, 'query'), async (req, res, next) => {
|
||||
try {
|
||||
const query = req.query as unknown as {
|
||||
search?: string;
|
||||
status?: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
format?: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
|
||||
const result = await modelsService.getModels({
|
||||
search: query.search,
|
||||
status: query.status,
|
||||
format: query.format,
|
||||
limit: Number(query.limit) || 20,
|
||||
offset: Number(query.offset) || 0,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.models,
|
||||
meta: {
|
||||
total: result.total,
|
||||
limit: Number(query.limit) || 20,
|
||||
offset: Number(query.offset) || 0,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /models/:id - Get a single model
|
||||
*/
|
||||
router.get('/:id', validate(schemas.uuidParam, 'params'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const model = await modelsService.getModelById(id);
|
||||
if (!model) {
|
||||
throw new NotFoundError('Model not found');
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: model,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /models/:id/parts - Get model parts
|
||||
*/
|
||||
router.get('/:id/parts', validate(schemas.uuidParam, 'params'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const model = await modelsService.getModelById(id);
|
||||
if (!model) {
|
||||
throw new NotFoundError('Model not found');
|
||||
}
|
||||
|
||||
const parts = await modelsService.getModelParts(id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: parts,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /models/:id/url - Get download URL for viewing
|
||||
*/
|
||||
router.get('/:id/url', validate(schemas.uuidParam, 'params'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const url = await modelsService.getModelDownloadUrl(id);
|
||||
if (!url) {
|
||||
throw new NotFoundError('Model not ready or not found');
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { url },
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /models/:id/lod - Get all LOD URLs for a model
|
||||
*/
|
||||
router.get('/:id/lod', validate(schemas.uuidParam, 'params'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const lodUrls = await modelsService.getModelLodUrls(id);
|
||||
if (!lodUrls) {
|
||||
throw new NotFoundError('Model not ready or not found');
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: lodUrls,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /models/:id/lod/:level - Get URL for specific LOD level
|
||||
*/
|
||||
router.get('/:id/lod/:level', validate(schemas.uuidParam, 'params'), async (req, res, next) => {
|
||||
try {
|
||||
const { id, level } = req.params;
|
||||
const lodLevel = parseInt(level, 10);
|
||||
|
||||
if (isNaN(lodLevel) || lodLevel < 0 || lodLevel > 2) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid LOD level. Must be 0, 1, or 2.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const url = await modelsService.getModelLodUrl(id, lodLevel);
|
||||
if (!url) {
|
||||
throw new NotFoundError('Model not ready or not found');
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { url, level: lodLevel },
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /models/:id - Update model metadata
|
||||
*/
|
||||
router.patch(
|
||||
'/:id',
|
||||
validate(schemas.uuidParam, 'params'),
|
||||
validate(schemas.updateModel),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
const model = await modelsService.updateModel(id, updates);
|
||||
if (!model) {
|
||||
throw new NotFoundError('Model not found');
|
||||
}
|
||||
|
||||
logger.info({ modelId: id }, 'Model updated');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: model,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /models/:id - Delete a model
|
||||
*/
|
||||
router.delete('/:id', validate(schemas.uuidParam, 'params'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const deleted = await modelsService.deleteModel(id);
|
||||
if (!deleted) {
|
||||
throw new NotFoundError('Model not found');
|
||||
}
|
||||
|
||||
logger.info({ modelId: id }, 'Model deleted');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Model deleted successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /models/:id/thumbnail - Upload a thumbnail for a model
|
||||
*/
|
||||
router.post(
|
||||
'/:id/thumbnail',
|
||||
validate(schemas.uuidParam, 'params'),
|
||||
upload.single('thumbnail'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!req.file) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'No thumbnail file provided',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const model = await modelsService.uploadThumbnail(id, req.file.buffer);
|
||||
if (!model) {
|
||||
throw new NotFoundError('Model not found');
|
||||
}
|
||||
|
||||
logger.info({ modelId: id }, 'Thumbnail uploaded');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: model,
|
||||
thumbnail_url: model.thumbnail_url,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /models/:id/regenerate-thumbnail - Regenerate thumbnail for a model
|
||||
*/
|
||||
router.post(
|
||||
'/:id/regenerate-thumbnail',
|
||||
validate(schemas.uuidParam, 'params'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const model = await modelsService.getModelById(id);
|
||||
if (!model) {
|
||||
throw new NotFoundError('Model not found');
|
||||
}
|
||||
|
||||
if (!model.model_url) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Model not ready for thumbnail generation',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await addThumbnailJob({ modelId: id, modelUrl: model.model_url });
|
||||
|
||||
logger.info({ modelId: id }, 'Thumbnail regeneration job queued');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Thumbnail regeneration job queued',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user