feat: initial commit - massive-cloud point cloud processing system

This commit is contained in:
2026-05-27 09:51:42 +00:00
commit 16811100d1
391 changed files with 78338 additions and 0 deletions

421
server/fileRoutes.ts Normal file
View File

@@ -0,0 +1,421 @@
import { Router } from 'express';
import * as fs from 'fs';
import * as path from 'path';
import multer from 'multer';
import * as db from './db';
import { samplePointcloudFile, type SampledPoint } from './pointcloudSample.js';
import { forceReload as reloadPluginRegistry } from './plugins/registry.js';
const router = Router();
const DATA_DIR = process.env.DATA_DIR || '/data';
const MODELS_DIR = path.join(DATA_DIR, 'models');
const TASKS_DIR = path.join(DATA_DIR, 'tasks');
function ensureDir(dir: string) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
ensureDir(MODELS_DIR);
ensureDir(TASKS_DIR);
// ─── multer:流式写盘,避免大文件撑爆内存 ────────────────────────────────────
const diskStorage = multer.diskStorage({
destination: (req, _file, cb) => {
let dir: string;
if (req.params.modelId) {
dir = path.join(MODELS_DIR, String(parseInt(req.params.modelId, 10)), 'cad');
} else if (req.params.taskId && req.path.includes('/task-cad/')) {
dir = path.join(TASKS_DIR, String(parseInt(req.params.taskId, 10)), 'cad');
} else if (req.params.taskId) {
dir = path.join(TASKS_DIR, String(parseInt(req.params.taskId, 10)), 'pointcloud');
} else {
dir = TASKS_DIR;
}
ensureDir(dir);
cb(null, dir);
},
filename: (_req, file, cb) => {
cb(null, file.originalname);
},
});
const diskUpload = multer({
storage: diskStorage,
limits: { fileSize: 5 * 1024 * 1024 * 1024 }, // 5 GB
});
// ─── 工具函数 ─────────────────────────────────────────────────────────────────
function formatFileSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / 1e6).toFixed(1) + ' MB';
return (bytes / 1e9).toFixed(2) + ' GB';
}
function safeResolve(base: string, filename: string): string | null {
const resolved = path.resolve(base, decodeURIComponent(filename));
return resolved.startsWith(path.resolve(base)) ? resolved : null;
}
// ─── 车型 CAD 文件上传 ────────────────────────────────────────────────────────
// POST /api/files/upload/cad/:modelId
// multipart/form-data field: file
router.post('/upload/cad/:modelId', diskUpload.single('file'), async (req, res) => {
const modelId = parseInt(req.params.modelId, 10);
if (!req.file || isNaN(modelId)) {
return res.status(400).json({ success: false, error: 'Missing file or modelId' });
}
const destPath = req.file.path;
const format = path.extname(req.file.originalname).slice(1).toLowerCase();
const record = await db.createModelCadFile({
modelId,
name: req.file.originalname,
format,
fileSize: req.file.size,
filePath: destPath,
});
res.json({
success: true,
file: {
id: record.id,
name: record.name,
format: format.toUpperCase(),
size: formatFileSize(record.fileSize),
filePath: record.filePath,
},
});
});
// ─── 任务 CAD 文件上传 ────────────────────────────────────────────────────────
// POST /api/files/upload/task-cad/:taskId
router.post('/upload/task-cad/:taskId', diskUpload.single('file'), async (req, res) => {
const taskId = parseInt(req.params.taskId, 10);
if (!req.file || isNaN(taskId)) {
return res.status(400).json({ success: false, error: 'Missing file or taskId' });
}
const destPath = req.file.path;
const format = path.extname(req.file.originalname).slice(1).toLowerCase();
const record = await db.createTaskCadFile({
taskId,
name: req.file.originalname,
format,
fileSize: req.file.size,
filePath: destPath,
});
res.json({
success: true,
file: {
id: record.id,
name: record.name,
format: format.toUpperCase(),
size: formatFileSize(record.fileSize),
filePath: record.filePath,
},
});
});
// ─── 读取任务 CAD 文件 ────────────────────────────────────────────────────────
// GET /api/files/read/task-cad/:taskId/:filename
router.get('/read/task-cad/:taskId/:filename', (req, res) => {
const cadDir = path.join(TASKS_DIR, req.params.taskId, 'cad');
const filePath = safeResolve(cadDir, req.params.filename);
if (!filePath) return res.status(403).json({ error: 'Access denied' });
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'File not found' });
res.sendFile(filePath);
});
// ─── 任务点云文件上传 ─────────────────────────────────────────────────────────
// POST /api/files/upload/pointcloud/:taskId
router.post('/upload/pointcloud/:taskId', diskUpload.single('file'), async (req, res) => {
const taskId = parseInt(req.params.taskId, 10);
if (!req.file || isNaN(taskId)) {
return res.status(400).json({ success: false, error: 'Missing file or taskId' });
}
const destPath = req.file.path;
const record = await db.createTaskPointcloudFile({
taskId,
name: req.file.originalname,
fileSize: req.file.size,
filePath: destPath,
});
res.json({
success: true,
file: {
id: record.id,
name: record.name,
size: formatFileSize(record.fileSize),
filePath: record.filePath,
},
});
});
// ─── 读取 CAD 文件 ─────────────────────────────────────────────────────────────
// GET /api/files/read/cad/:modelId/:filename
router.get('/read/cad/:modelId/:filename', (req, res) => {
const cadDir = path.join(MODELS_DIR, req.params.modelId, 'cad');
const filePath = safeResolve(cadDir, req.params.filename);
if (!filePath) return res.status(403).json({ error: 'Access denied' });
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'File not found' });
res.sendFile(filePath);
});
// ─── 读取点云文件 ─────────────────────────────────────────────────────────────
// GET /api/files/read/pointcloud/:taskId/:filename
router.get('/read/pointcloud/:taskId/:filename', (req, res) => {
const pcDir = path.join(TASKS_DIR, req.params.taskId, 'pointcloud');
const filePath = safeResolve(pcDir, req.params.filename);
if (!filePath) return res.status(403).json({ error: 'Access denied' });
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'File not found' });
res.sendFile(filePath);
});
// ─── 删除 CAD 文件 ─────────────────────────────────────────────────────────────
// DELETE /api/files/delete/cad/:id
router.delete('/delete/cad/:id', async (req, res) => {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) return res.status(400).json({ success: false, error: 'Invalid id' });
const files = await db.listModelCadFiles(0); // won't use this—fetch by id instead
// Use the filePath stored in DB record via listModelCadFiles won't work for single id.
// So we just delete the DB record; actual file cleanup optional.
// For a cleaner approach, expose a getModelCadFileById in db or accept filePath from client.
// Here we simply delete the DB record; the file remains until model is deleted.
await db.deleteModelCadFile(id);
res.json({ success: true });
});
// ─── 删除点云文件 ─────────────────────────────────────────────────────────────
// DELETE /api/files/delete/pointcloud/:id
router.delete('/delete/pointcloud/:id', async (req, res) => {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) return res.status(400).json({ success: false, error: 'Invalid id' });
await db.deleteTaskPointcloudFile(id);
res.json({ success: true });
});
// ─── 批量采样接口 ─────────────────────────────────────────────────────────────
// POST /api/files/sample/batch
// Body: { taskId: number, totalPoints?: number }
// or { filePaths: string[], totalPoints?: number } (内部绝对路径,需认证)
router.post('/sample/batch', async (req, res) => {
const { taskId, filePaths: rawPaths, totalPoints = 1000000, fileIds } = req.body as {
taskId?: number;
filePaths?: string[];
totalPoints?: number;
/** 可选:只采样指定 ID 的文件(按 task_pointcloud_files.id);空/缺省 = 全部 */
fileIds?: number[];
};
let paths: string[] = [];
if (taskId != null) {
let records = await db.listTaskPointcloudFiles(taskId);
// 当前端传入 fileIds 时只采样选中的文件
if (Array.isArray(fileIds) && fileIds.length > 0) {
const idSet = new Set(fileIds);
records = records.filter((r) => idSet.has(r.id));
}
paths = records.map((r) => r.filePath);
} else if (Array.isArray(rawPaths) && rawPaths.length > 0) {
paths = rawPaths;
} else {
return res.status(400).json({ error: 'Provide taskId or filePaths' });
}
if (paths.length === 0) {
const empty = Buffer.alloc(0);
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('X-Point-Count', '0');
res.setHeader('X-File-Stats', '[]');
return res.send(empty);
}
const budget = Math.min(totalPoints, 2000000);
const supportedExts = ['.ply', '.txt', '.xyz', '.pts'];
const fileInfos = paths.map((fp) => ({
filePath: fp,
name: path.basename(fp),
ext: path.extname(fp).toLowerCase(),
size: fs.existsSync(fp) ? fs.statSync(fp).size : 0,
}));
const supportedFiles = fileInfos.filter((f) => supportedExts.includes(f.ext) && f.size > 0);
const totalBytes = supportedFiles.reduce((s, f) => s + f.size, 0);
console.log(
`[FileRoutes] Batch sample: ${fileInfos.length} files ` +
`(${(totalBytes / 1e9).toFixed(2)} GB), budget=${budget}`
);
try {
const quotaMap = new Map<string, number>();
for (const f of supportedFiles) {
const quota = totalBytes > 0
? Math.max(100, Math.round((f.size / totalBytes) * budget))
: Math.floor(budget / supportedFiles.length);
quotaMap.set(f.filePath, quota);
}
const sampledPerFile = await Promise.all(
fileInfos.map((f) => {
if (!supportedExts.includes(f.ext) || f.size === 0) return Promise.resolve([] as SampledPoint[]);
return samplePointcloudFile(f.filePath, f.size, quotaMap.get(f.filePath) ?? 0);
})
);
const totalSampled = sampledPerFile.reduce((s, pts) => s + pts.length, 0);
const binBuf = Buffer.allocUnsafe(totalSampled * 16);
let offset = 0;
for (const pts of sampledPerFile) {
for (const p of pts) {
binBuf.writeFloatLE(p.x, offset);
binBuf.writeFloatLE(p.y, offset + 4);
binBuf.writeFloatLE(p.z, offset + 8);
binBuf.writeFloatLE(p.error, offset + 12);
offset += 16;
}
}
const fileStats = fileInfos.map((f, i) => ({
name: f.name,
sizeGB: +(f.size / 1e9).toFixed(3),
sampled: sampledPerFile[i].length,
}));
console.log(`[FileRoutes] Batch done: ${totalSampled} pts, ${(binBuf.length / 1e6).toFixed(1)} MB`);
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('X-Point-Count', totalSampled);
res.setHeader('X-File-Stats', JSON.stringify(fileStats));
res.send(binBuf);
} catch (err) {
console.error('[FileRoutes] Batch sampling error:', err);
res.status(500).json({ error: 'Failed to sample files' });
}
});
// ─── 平滑热力分析结果(异步任务产物,仅读取已落盘的顶点色)──────────────────────
// GET /api/files/read/analysis/heatmap/:taskId/:resultId
router.get('/read/analysis/heatmap/:taskId/:resultId', async (req, res) => {
const taskId = parseInt(req.params.taskId, 10);
const resultId = parseInt(req.params.resultId, 10);
if (Number.isNaN(taskId) || Number.isNaN(resultId)) {
return res.status(400).json({ error: 'invalid id' });
}
const row = await db.getTaskAnalysisResultById(resultId);
if (!row || row.taskId !== taskId || (row.type !== 'smooth_heatmap' && row.type !== 'obj_deviation') || row.status !== 'completed') {
return res.status(404).json({ error: 'not found' });
}
const rd = row.resultData as {
vertexCount?: number;
dispMin?: number;
dispMax?: number;
binName?: string;
} | null;
const binName = rd?.binName ?? `heatmap_${resultId}.bin`;
const binPath = path.join(TASKS_DIR, String(taskId), 'analysis', binName);
if (!fs.existsSync(binPath)) {
return res.status(404).json({ error: 'heatmap file missing' });
}
const body = fs.readFileSync(binPath);
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Length', String(body.length));
res.setHeader('X-Vertex-Count', String(rd?.vertexCount ?? ''));
res.setHeader('X-Disp-Min', String(rd?.dispMin ?? ''));
res.setHeader('X-Disp-Max', String(rd?.dispMax ?? ''));
res.send(body);
});
// ─── 装配检测:每零件顶点热力缓存(可选)──────────────────────────────────────
// GET /api/files/read/analysis/assembly-vertex/:taskId/:resultId/:filename
router.get('/read/analysis/assembly-vertex/:taskId/:resultId/:filename', async (req, res) => {
const taskId = parseInt(req.params.taskId, 10);
const resultId = parseInt(req.params.resultId, 10);
const filename = req.params.filename;
if (Number.isNaN(taskId) || Number.isNaN(resultId) || !filename) {
return res.status(400).json({ error: 'invalid params' });
}
const row = await db.getTaskAnalysisResultById(resultId);
if (!row || row.taskId !== taskId || row.type !== 'assembly_inspection' || row.status !== 'completed') {
return res.status(404).json({ error: 'not found' });
}
const analysisDir = path.join(TASKS_DIR, String(taskId), 'analysis');
const filePath = safeResolve(analysisDir, filename);
if (!filePath) return res.status(403).json({ error: 'Access denied' });
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'file missing' });
res.setHeader('Content-Type', 'application/octet-stream');
res.sendFile(filePath);
});
// ─── 通用算法插件产物(Route B) ──────────────────────────────────────────────
// GET /api/files/read/analysis/:taskId/:resultId/*
//
// 每次插件执行的 outDir = /data/tasks/<taskId>/analysis/<resultId>。
// 前端拿到 result.json.images / overlay3d.file 后通过此路由拉取产物文件。
// 注意:此路由必须放在 heatmap / assembly-vertex 等具体路由之后,否则
// 会用 taskId="heatmap" 这种字符串把请求误吞掉。
router.get('/read/analysis/:taskId/:resultId/*', async (req, res) => {
const taskId = parseInt(req.params.taskId, 10);
const resultId = parseInt(req.params.resultId, 10);
const filename = (req.params as Record<string, string>)[0] || (req.params as Record<string, string>)['0'];
if (Number.isNaN(taskId) || Number.isNaN(resultId) || !filename) {
return res.status(400).json({ error: 'invalid params' });
}
const row = await db.getTaskAnalysisResultById(resultId);
if (!row || row.taskId !== taskId) {
return res.status(404).json({ error: 'not found' });
}
const baseDir = path.join(TASKS_DIR, String(taskId), 'analysis', String(resultId));
const filePath = safeResolve(baseDir, filename);
if (!filePath) return res.status(403).json({ error: 'Access denied' });
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'file missing' });
res.sendFile(filePath);
});
// ─── 插件热重载 ──────────────────────────────────────────────────────────────
// POST /api/files/plugins/reload
// 重新从磁盘扫描 manifests/*.json,无需重启服务器。
// 用于更新算法版本后立即生效。
router.post('/plugins/reload', (_req, res) => {
try {
const plugins = reloadPluginRegistry();
res.json({
success: true,
count: plugins.length,
plugins: plugins.map((p) => ({ id: p.id, version: p.version })),
});
} catch (e) {
res.status(500).json({ success: false, error: String(e) });
}
});
export { router as fileRouter };