diff --git a/drizzle/0010_model_library_task_links.sql b/drizzle/0010_model_library_task_links.sql new file mode 100644 index 0000000..b6d0bd3 --- /dev/null +++ b/drizzle/0010_model_library_task_links.sql @@ -0,0 +1,22 @@ +-- 车型点云库,以及任务输入对车型库文件的轻量关联 + +CREATE TABLE IF NOT EXISTS `model_pointcloud_files` ( + `id` INT NOT NULL AUTO_INCREMENT, + `modelId` INT NOT NULL, + `name` VARCHAR(255) NOT NULL, + `fileSize` BIGINT NOT NULL, + `filePath` VARCHAR(512) NOT NULL, + `createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + INDEX `idx_model_pointcloud_files_modelId` (`modelId`) +); + +ALTER TABLE `task_pointcloud_files` + ADD COLUMN `sourceModelPointcloudId` INT NULL + AFTER `sourceResultId`; + +ALTER TABLE `task_cad_files` + ADD COLUMN `source` VARCHAR(32) NOT NULL DEFAULT 'uploaded' + AFTER `filePath`, + ADD COLUMN `sourceModelCadFileId` INT NULL + AFTER `source`; diff --git a/drizzle/0011_registration_source_pointcloud.sql b/drizzle/0011_registration_source_pointcloud.sql new file mode 100644 index 0000000..2bebc0d --- /dev/null +++ b/drizzle/0011_registration_source_pointcloud.sql @@ -0,0 +1,5 @@ +-- 记录配准产物来自哪个任务点云文件,用于阻止链式配准并检测重复配准 + +ALTER TABLE `task_pointcloud_files` + ADD COLUMN `sourcePointcloudFileId` INT NULL + AFTER `sourceResultId`; diff --git a/drizzle/schema.ts b/drizzle/schema.ts index dbe83be..eb6c8e5 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -62,6 +62,21 @@ export const modelCadFiles = mysqlTable("model_cad_files", { export type ModelCadFile = typeof modelCadFiles.$inferSelect; export type InsertModelCadFile = typeof modelCadFiles.$inferInsert; +// ─── 车型点云文件 ───────────────────────────────────────────────────────────── +// 一个车型可维护多个通用点云文件,供任务按需关联复用 + +export const modelPointcloudFiles = mysqlTable("model_pointcloud_files", { + id: int("id").autoincrement().primaryKey(), + modelId: int("modelId").notNull(), + name: varchar("name", { length: 255 }).notNull(), + fileSize: bigint("fileSize", { mode: "number" }).notNull(), + filePath: varchar("filePath", { length: 512 }).notNull(), + createdAt: timestamp("createdAt").defaultNow().notNull(), +}); + +export type ModelPointcloudFile = typeof modelPointcloudFiles.$inferSelect; +export type InsertModelPointcloudFile = typeof modelPointcloudFiles.$inferInsert; + // ─── 任务 CAD 文件 ───────────────────────────────────────────────────────────── // 每个任务可关联任意个 CAD 文件(与车型级 CAD 独立,用于本次检测的实测参考) @@ -72,6 +87,10 @@ export const taskCadFiles = mysqlTable("task_cad_files", { format: varchar("format", { length: 32 }).notNull(), fileSize: bigint("fileSize", { mode: "number" }).notNull(), filePath: varchar("filePath", { length: 512 }).notNull(), + /** 来源:uploaded = 用户上传;model_library = 车型 CAD 库关联 */ + source: varchar("source", { length: 32 }).default("uploaded").notNull(), + /** 来源车型 CAD 文件 ID(仅 model_library 时有值) */ + sourceModelCadFileId: int("sourceModelCadFileId"), createdAt: timestamp("createdAt").defaultNow().notNull(), }); @@ -105,10 +124,14 @@ export const taskPointcloudFiles = mysqlTable("task_pointcloud_files", { name: varchar("name", { length: 255 }).notNull(), fileSize: bigint("fileSize", { mode: "number" }).notNull(), filePath: varchar("filePath", { length: 512 }).notNull(), - /** 来源:uploaded = 用户上传;registered = 配准算法产出 */ + /** 来源:uploaded = 用户上传;registered = 配准算法产出;model_library = 车型点云库关联 */ source: varchar("source", { length: 32 }).default("uploaded").notNull(), /** 产出该点云的分析结果 ID(仅 registered 时有值) */ sourceResultId: int("sourceResultId"), + /** 生成该配准产物的原始任务点云 ID(仅 registered 时有值) */ + sourcePointcloudFileId: int("sourcePointcloudFileId"), + /** 来源车型点云文件 ID(仅 model_library 时有值) */ + sourceModelPointcloudId: int("sourceModelPointcloudId"), createdAt: timestamp("createdAt").defaultNow().notNull(), }); diff --git a/server/algorithmJobRunner.ts b/server/algorithmJobRunner.ts index b5706f7..e5ce13c 100644 --- a/server/algorithmJobRunner.ts +++ b/server/algorithmJobRunner.ts @@ -43,6 +43,26 @@ function ensureDir(dir: string) { fs.mkdirSync(dir, { recursive: true }); } +function formatRegistrationTimestamp(date: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); + return [ + date.getFullYear(), + pad(date.getMonth() + 1), + pad(date.getDate()), + '-', + pad(date.getHours()), + pad(date.getMinutes()), + pad(date.getSeconds()), + ].join(''); +} + +function registeredPointcloudDisplayName(sourceName: string | undefined, derivedFile: string, resultId: number): string { + const sourceParsed = path.parse(sourceName || 'pointcloud'); + const stem = sourceParsed.name || 'pointcloud'; + const ext = path.extname(derivedFile) || sourceParsed.ext || '.ply'; + return `${stem}_已配准_${formatRegistrationTimestamp(new Date())}_R${resultId}${ext}`; +} + /** 把 process / mock cmd 模板里的占位符替换成实际路径。 */ function resolvePlaceholders(value: string, ctx: Record): string { return value.replace(/\{(\w+)\}/g, (_, key) => ctx[key] ?? `{${key}}`); @@ -91,7 +111,7 @@ async function gatherInputs( } } - return { task, pointCloudPaths, cadPath }; + return { task, pointCloudRecords, pointCloudPaths, cadPath }; } async function writeInputJson( @@ -282,7 +302,12 @@ export async function runAlgorithmJob( try { await patchProgress(resultId, pluginId, 0.02, '准备输入'); - const { pointCloudPaths, cadPath } = await gatherInputs(taskId, manifest, pointCloudFileIds, cadFileIds); + const { pointCloudRecords, pointCloudPaths, cadPath } = await gatherInputs( + taskId, + manifest, + pointCloudFileIds, + cadFileIds, + ); const inputJsonPath = await writeInputJson( workDir, taskId, @@ -350,13 +375,16 @@ export async function runAlgorithmJob( continue; } try { + const sourcePointcloud = pointCloudRecords[0]; + const displayName = registeredPointcloudDisplayName(sourcePointcloud?.name, d.file, resultId); await db.createRegisteredPointcloudFile( taskId, - d.name ?? d.file, + displayName, absPath, resultId, + sourcePointcloud?.id ?? null, ); - console.log(`[AlgorithmJobRunner] registered derived pointcloud: ${d.name ?? d.file}`); + console.log(`[AlgorithmJobRunner] registered derived pointcloud: ${displayName}`); } catch (err) { console.error('[AlgorithmJobRunner] failed to register derived pointcloud:', err); } diff --git a/server/db.ts b/server/db.ts index ee0f188..48b504e 100644 --- a/server/db.ts +++ b/server/db.ts @@ -6,6 +6,7 @@ import { vehicleSeries, InsertVehicleSeries, VehicleSeries, vehicleModels, InsertVehicleModel, VehicleModel, modelCadFiles, InsertModelCadFile, ModelCadFile, + modelPointcloudFiles, InsertModelPointcloudFile, ModelPointcloudFile, taskCadFiles, InsertTaskCadFile, TaskCadFile, inspectionTasks, InsertInspectionTask, InspectionTask, taskPointcloudFiles, InsertTaskPointcloudFile, TaskPointcloudFile, @@ -229,6 +230,37 @@ export async function deleteModelCadFile(id: number): Promise { await db.delete(modelCadFiles).where(eq(modelCadFiles.id, id)); } +// ─── 车型点云文件 ───────────────────────────────────────────────────────────── + +export async function createModelPointcloudFile(data: InsertModelPointcloudFile): Promise { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + const result = await db.insert(modelPointcloudFiles).values(data); + const [row] = await db.select().from(modelPointcloudFiles).where(eq(modelPointcloudFiles.id, getMysqlInsertId(result))); + return row; +} + +export async function listModelPointcloudFiles(modelId: number): Promise { + const db = await getDb(); + if (!db) return []; + return db.select().from(modelPointcloudFiles) + .where(eq(modelPointcloudFiles.modelId, modelId)) + .orderBy(desc(modelPointcloudFiles.createdAt)); +} + +export async function getModelPointcloudFileById(id: number): Promise { + const db = await getDb(); + if (!db) return undefined; + const [row] = await db.select().from(modelPointcloudFiles).where(eq(modelPointcloudFiles.id, id)); + return row; +} + +export async function deleteModelPointcloudFile(id: number): Promise { + const db = await getDb(); + if (!db) return; + await db.delete(modelPointcloudFiles).where(eq(modelPointcloudFiles.id, id)); +} + // ─── 任务 CAD 文件 ───────────────────────────────────────────────────────────── export async function createTaskCadFile(data: InsertTaskCadFile): Promise { @@ -260,6 +292,46 @@ export async function deleteTaskCadFile(id: number): Promise { await db.delete(taskCadFiles).where(eq(taskCadFiles.id, id)); } +export async function linkTaskCadFilesFromModel(taskId: number, modelCadFileIds: number[]): Promise { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + const task = await getInspectionTaskById(taskId); + if (!task) throw new Error("Task not found"); + + const existing = await listTaskCadFiles(taskId); + const existingSourceIds = new Set( + existing + .map((row) => row.sourceModelCadFileId) + .filter((id): id is number => typeof id === "number"), + ); + + const inserted: TaskCadFile[] = []; + for (const id of modelCadFileIds) { + if (existingSourceIds.has(id)) continue; + + const source = await getModelCadFileById(id); + if (!source) throw new Error("Model CAD file not found"); + if (source.modelId !== task.modelId) { + throw new Error("Cannot link CAD file from another model"); + } + + const record = await createTaskCadFile({ + taskId, + name: source.name, + format: source.format, + fileSize: source.fileSize, + filePath: source.filePath, + source: "model_library", + sourceModelCadFileId: source.id, + } as InsertTaskCadFile); + inserted.push(record); + existingSourceIds.add(id); + } + + return inserted; +} + // ─── 检测任务 ───────────────────────────────────────────────────────────────── export async function createInspectionTask(data: InsertInspectionTask): Promise { @@ -315,6 +387,7 @@ export async function createRegisteredPointcloudFile( name: string, filePath: string, sourceResultId: number, + sourcePointcloudFileId?: number | null, ): Promise { const fs = await import('node:fs'); const fileSize = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0; @@ -325,6 +398,7 @@ export async function createRegisteredPointcloudFile( fileSize, source: 'registered', sourceResultId, + sourcePointcloudFileId: sourcePointcloudFileId ?? null, } as InsertTaskPointcloudFile); } @@ -342,6 +416,48 @@ export async function deleteTaskPointcloudFile(id: number): Promise { await db.delete(taskPointcloudFiles).where(eq(taskPointcloudFiles.id, id)); } +export async function linkTaskPointcloudFilesFromModel( + taskId: number, + modelPointcloudFileIds: number[], +): Promise { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + const task = await getInspectionTaskById(taskId); + if (!task) throw new Error("Task not found"); + + const existing = await listTaskPointcloudFiles(taskId); + const existingSourceIds = new Set( + existing + .map((row) => row.sourceModelPointcloudId) + .filter((id): id is number => typeof id === "number"), + ); + + const inserted: TaskPointcloudFile[] = []; + for (const id of modelPointcloudFileIds) { + if (existingSourceIds.has(id)) continue; + + const source = await getModelPointcloudFileById(id); + if (!source) throw new Error("Model pointcloud file not found"); + if (source.modelId !== task.modelId) { + throw new Error("Cannot link pointcloud file from another model"); + } + + const record = await createTaskPointcloudFile({ + taskId, + name: source.name, + fileSize: source.fileSize, + filePath: source.filePath, + source: "model_library", + sourceModelPointcloudId: source.id, + } as InsertTaskPointcloudFile); + inserted.push(record); + existingSourceIds.add(id); + } + + return inserted; +} + // ─── 任务分析结果 ────────────────────────────────────────────────────────────── export async function createTaskAnalysisResult(data: InsertTaskAnalysisResult): Promise { diff --git a/server/fileRoutes.ts b/server/fileRoutes.ts index e23e27e..6a7f584 100644 --- a/server/fileRoutes.ts +++ b/server/fileRoutes.ts @@ -24,7 +24,9 @@ ensureDir(TASKS_DIR); const diskStorage = multer.diskStorage({ destination: (req, _file, cb) => { let dir: string; - if (req.params.modelId) { + if (req.params.modelId && req.path.includes('/model-pointcloud/')) { + dir = path.join(MODELS_DIR, String(parseInt(req.params.modelId, 10)), 'pointcloud'); + } else 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'); @@ -92,6 +94,35 @@ router.post('/upload/cad/:modelId', diskUpload.single('file'), async (req, res) }); }); +// ─── 车型点云文件上传 ──────────────────────────────────────────────────────── +// POST /api/files/upload/model-pointcloud/:modelId +// multipart/form-data field: file + +router.post('/upload/model-pointcloud/: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 record = await db.createModelPointcloudFile({ + modelId, + 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 文件上传 ──────────────────────────────────────────────────────── // POST /api/files/upload/task-cad/:taskId @@ -173,6 +204,17 @@ router.get('/read/cad/:modelId/:filename', (req, res) => { res.sendFile(filePath); }); +// ─── 读取车型点云文件 ───────────────────────────────────────────────────────── +// GET /api/files/read/model-pointcloud/:modelId/:filename + +router.get('/read/model-pointcloud/:modelId/:filename', (req, res) => { + const pcDir = path.join(MODELS_DIR, req.params.modelId, '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); +}); + // ─── 读取点云文件 ───────────────────────────────────────────────────────────── // GET /api/files/read/pointcloud/:taskId/:filename diff --git a/server/routers.ts b/server/routers.ts index 922eabd..d902c5f 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -14,6 +14,52 @@ import { stepParseCacheDir } from "./stepParse.js"; import { listPlugins, getPlugin } from "./plugins/registry.js"; import { enqueueAlgorithmJob, algorithmJobStats, cancelAlgorithmJob, cancelAlgorithmJobsByTask } from "./algorithmJob.js"; +async function validateRegistrationRunInput(input: { + taskId: number; + pointCloudFileIds?: number[]; + cadFileIds?: number[]; + allowDuplicateRegistration?: boolean; +}) { + const pointCloudFileIds = input.pointCloudFileIds ?? []; + const cadFileIds = input.cadFileIds ?? []; + + if (pointCloudFileIds.length !== 1 || cadFileIds.length !== 1) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "整车配准必须恰好选择 1 个点云文件和 1 个 CAD 文件。", + }); + } + + const pointcloudFiles = await db.listTaskPointcloudFiles(input.taskId); + const pointcloud = pointcloudFiles.find((row) => row.id === pointCloudFileIds[0]); + if (!pointcloud) { + throw new TRPCError({ code: "NOT_FOUND", message: "选中的点云文件不存在。" }); + } + if (pointcloud.source === "registered") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "已配准点云不能再次作为配准输入,请选择原始点云进行配准。", + }); + } + + const cadFiles = await db.listTaskCadFiles(input.taskId); + const cad = cadFiles.find((row) => row.id === cadFileIds[0]); + if (!cad) { + throw new TRPCError({ code: "NOT_FOUND", message: "选中的 CAD 文件不存在。" }); + } + + const registeredChildren = pointcloudFiles + .filter((row) => row.source === "registered" && row.sourcePointcloudFileId === pointcloud.id) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + if (registeredChildren.length > 0 && !input.allowDuplicateRegistration) { + const latest = registeredChildren[0]; + throw new TRPCError({ + code: "CONFLICT", + message: `点云「${pointcloud.name}」已有配准产物「${latest.name}」,重复配准需要确认。`, + }); + } +} + export const appRouter = router({ system: systemRouter, @@ -147,6 +193,23 @@ export const appRouter = router({ }), }), + // ─── 车型点云文件 ───────────────────────────────────────────────────────── + + modelPointcloudFile: router({ + list: protectedProcedure + .input(z.object({ modelId: z.number() })) + .query(async ({ input }) => { + return db.listModelPointcloudFiles(input.modelId); + }), + + delete: protectedProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ input }) => { + await db.deleteModelPointcloudFile(input.id); + return { success: true }; + }), + }), + // ─── 任务 CAD 文件 ──────────────────────────────────────────────────────────── taskCadFile: router({ @@ -163,6 +226,27 @@ export const appRouter = router({ return { success: true }; }), + linkFromModel: protectedProcedure + .input(z.object({ + taskId: z.number(), + modelCadFileIds: z.array(z.number()).min(1), + })) + .mutation(async ({ input }) => { + try { + const files = await db.linkTaskCadFilesFromModel(input.taskId, input.modelCadFileIds); + return { success: true, files }; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + if (message.includes("another model")) { + throw new TRPCError({ code: "BAD_REQUEST", message: "只能关联当前车型的 CAD 库文件" }); + } + if (message.includes("not found")) { + throw new TRPCError({ code: "NOT_FOUND", message: "任务或 CAD 库文件不存在" }); + } + throw e; + } + }), + // 检查 CAD 文件是否已有三角化采样缓存(.cad_sample_cache/ 目录下存在对应 PLY) hasSampleCache: protectedProcedure .input(z.object({ cadFileIds: z.array(z.number()) })) @@ -257,6 +341,27 @@ export const appRouter = router({ await db.deleteTaskPointcloudFile(input.id); return { success: true }; }), + + linkFromModel: protectedProcedure + .input(z.object({ + taskId: z.number(), + modelPointcloudFileIds: z.array(z.number()).min(1), + })) + .mutation(async ({ input }) => { + try { + const files = await db.linkTaskPointcloudFilesFromModel(input.taskId, input.modelPointcloudFileIds); + return { success: true, files }; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + if (message.includes("another model")) { + throw new TRPCError({ code: "BAD_REQUEST", message: "只能关联当前车型的点云库文件" }); + } + if (message.includes("not found")) { + throw new TRPCError({ code: "NOT_FOUND", message: "任务或点云库文件不存在" }); + } + throw e; + } + }), }), // ─── 任务分析结果 ──────────────────────────────────────────────────────────── @@ -467,6 +572,8 @@ export const appRouter = router({ pointCloudFileIds: z.array(z.number()).optional(), /** 可选:任务级 CAD 文件 ID(空/缺省 = 不传 CAD) */ cadFileIds: z.array(z.number()).optional(), + /** 配准:允许同一原始点云再次生成配准产物 */ + allowDuplicateRegistration: z.boolean().optional(), }), ) .mutation(async ({ input }) => { @@ -477,6 +584,9 @@ export const appRouter = router({ message: `未注册的算法插件:${input.pluginId}`, }); } + if (manifest.category === "配准") { + await validateRegistrationRunInput(input); + } try { const record = await db.createTaskAnalysisResult({ @@ -529,6 +639,8 @@ export const appRouter = router({ pointCloudFileIds: z.array(z.number()).optional(), /** 可选:任务级 CAD 文件 ID(空/缺省 = 不传 CAD) */ cadFileIds: z.array(z.number()).optional(), + /** 配准:允许同一原始点云再次生成配准产物 */ + allowDuplicateRegistration: z.boolean().optional(), jobs: z .array( z.object({ @@ -542,6 +654,10 @@ export const appRouter = router({ ) .mutation(async ({ input }) => { const created: { id: number; pluginId: string }[] = []; + const hasRegistrationJob = input.jobs.some((job) => getPlugin(job.pluginId)?.category === "配准"); + if (hasRegistrationJob) { + await validateRegistrationRunInput(input); + } for (const job of input.jobs) { const manifest = getPlugin(job.pluginId); if (!manifest) continue;