import * as fs from 'node:fs'; import * as path from 'node:path'; import { fork } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import * as db from './db.js'; import { assignNearestPartAndStats, computePartStatsFromPerPartDists, computeVertexDistanceHeatmap, float32ToVertexColorRgb, loadStlToBVH, perPartDistsFromAssignments, type PartMeshBVH, } from './assemblyInspectionCompute.js'; import { tryRunAssemblyNearestGpu } from './assemblyGpuNearest.js'; import { sampleTaskPointclouds } from './sampleTaskPointclouds.js'; import type { SampledPoint } from './pointcloudSample.js'; import { runStepParseMvp, stepParseCacheDir } from './stepParse.js'; import type { StepManifest } from './stepParse.js'; import { registerLegacyAnalysisChild } from './analysisJobControl.js'; const DATA_DIR = process.env.DATA_DIR || '/data'; const TASKS_DIR = path.join(DATA_DIR, 'tasks'); function resolveRunnerEntry(): string { const dir = path.dirname(fileURLToPath(import.meta.url)); if (process.env.NODE_ENV === 'production') { return path.join(dir, 'assemblyInspectionJobRunner.js'); } return path.join(dir, 'assemblyInspectionJobRunner.ts'); } export function enqueueAssemblyInspectionJob( resultId: number, taskId: number, options: AssemblyInspectionOptions, ): void { const script = resolveRunnerEntry(); const isProd = process.env.NODE_ENV === 'production'; const payload = JSON.stringify(options); const child = fork(script, [String(resultId), String(taskId), payload], { env: { ...process.env }, stdio: ['ignore', 'inherit', 'inherit', 'ipc'], execArgv: isProd ? [] : ['--import', 'tsx'], }); registerLegacyAnalysisChild(resultId, taskId, 'assembly_inspection', child); child.on('error', (err) => console.error('[AssemblyInspection] fork 失败:', err)); child.on('exit', (code, signal) => { if (code !== 0 && code != null) { console.error(`[AssemblyInspection] 子进程退出 code=${code} signal=${signal ?? ''}`); } }); } export type AssemblyInspectionOptions = { cadFileId?: number; samplePoints: number; toleranceMm: number; includeVertexHeatmap: boolean; }; export async function runAssemblyInspectionJob( resultId: number, taskId: number, options: AssemblyInspectionOptions, ): Promise { const sampleBudget = Math.min(Math.max(10_000, options.samplePoints), 500_000); const tol = options.toleranceMm > 0 ? options.toleranceMm : 0.3; try { await reportProgress(resultId, 0.08, '加载任务与 CAD…'); const task = await db.getInspectionTaskById(taskId); if (!task) throw new Error('task not found'); const cadList = await db.listModelCadFiles(task.modelId); const stepFiles = cadList.filter((f) => { const ext = path.extname(f.name).toLowerCase(); return ext === '.stp' || ext === '.step' || f.format?.toLowerCase() === 'stp' || f.format?.toLowerCase() === 'step'; }); if (stepFiles.length === 0) { throw new Error('该车型下未上传 STEP(.stp / .step)。请上传装配体 STEP 后再运行装配检测。'); } let cad = stepFiles[0]!; if (options.cadFileId != null) { const found = stepFiles.find((f) => f.id === options.cadFileId); if (!found) throw new Error('指定的 cadFileId 不是 STEP 文件或不存在'); cad = found; } if (!fs.existsSync(cad.filePath)) throw new Error('STEP 文件不存在'); const cacheDir = stepParseCacheDir(task.modelId, cad.id); const manifestPath = path.join(cacheDir, 'manifest.json'); if (!fs.existsSync(manifestPath)) { await reportProgress(resultId, 0.15, '解析 STEP(Python/cadquery,可能较慢)…'); runStepParseMvp(cad.filePath, cacheDir); } const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as StepManifest; if (!manifest.ok || !manifest.parts?.length) { throw new Error('STEP manifest 无效'); } await reportProgress(resultId, 0.35, '加载零件网格…'); const parts: PartMeshBVH[] = []; for (const p of manifest.parts) { const stlAbs = path.join(cacheDir, p.stlRelative); if (!fs.existsSync(stlAbs)) throw new Error(`缺少零件 STL: ${p.stlRelative}`); const buf = fs.readFileSync(stlAbs); const { geometry, bvh } = loadStlToBVH(buf); parts.push({ part: p, geometry, bvh }); } await reportProgress(resultId, 0.45, '点云采样…'); const sampled = await sampleTaskPointclouds(taskId, sampleBudget); if (sampled.length === 0) throw new Error('任务下无可用点云'); await reportProgress(resultId, 0.55, '最近零件归属与距离…'); const gpu = tryRunAssemblyNearestGpu(cacheDir, sampled); let assignments: number[]; let pointDistances: number[]; let stats: ReturnType; if (gpu) { assignments = gpu.assignments; pointDistances = gpu.pointDistances; stats = computePartStatsFromPerPartDists( manifest.parts, perPartDistsFromAssignments(parts.length, assignments, pointDistances), tol, ); } else { const r = await assignNearestPartAndStats(parts, sampled, tol, { onProgress: async (done, total) => { const t = total > 0 ? done / total : 1; await reportProgress( resultId, 0.55 + 0.17 * t, `最近零件归属与距离… ${done.toLocaleString()}/${total.toLocaleString()}`, ); }, }); assignments = r.assignments; pointDistances = r.pointDistances; stats = r.stats; } const outDir = path.join(TASKS_DIR, String(taskId), 'analysis'); fs.mkdirSync(outDir, { recursive: true }); const partOutputs: Record[] = []; let gMin = Infinity; let gMax = -Infinity; const pointsByPart: SampledPoint[][] = Array.from({ length: parts.length }, () => []); for (let i = 0; i < sampled.length; i++) { pointsByPart[assignments[i]!]!.push(sampled[i]!); } for (const d of pointDistances) { if (d < gMin) gMin = d; if (d > gMax) gMax = d; } if (!Number.isFinite(gMin)) gMin = 0; if (!Number.isFinite(gMax)) gMax = 0; const meanAll = pointDistances.length > 0 ? pointDistances.reduce((a, b) => a + b, 0) / pointDistances.length : 0; let vsum = 0; for (const d of pointDistances) { const t = d - meanAll; vsum += t * t; } const stdAll = pointDistances.length > 0 ? Math.sqrt(vsum / pointDistances.length) : 0; let within = 0; for (const d of pointDistances) { if (d < tol) within++; } await reportProgress(resultId, 0.72, '汇总与可选顶点热力…'); for (let j = 0; j < parts.length; j++) { const pm = parts[j]!; const st = stats[j]!; const entry: Record = { partId: st.partId, name: st.name, min: st.min, max: st.max, mean: st.mean, stdDev: st.stdDev, passRate: st.passRate, pointCount: st.pointCount, }; if (options.includeVertexHeatmap && st.pointCount > 0) { const vals = computeVertexDistanceHeatmap(pm.geometry, pointsByPart[j]!); if (vals) { let vmin = Infinity; let vmax = -Infinity; for (let k = 0; k < vals.length; k++) { if (vals[k]! < vmin) vmin = vals[k]!; if (vals[k]! > vmax) vmax = vals[k]!; } const rgb = float32ToVertexColorRgb(vals, vmin, vmax); const binName = `assembly_vertex_${resultId}_${st.partId}.bin`; fs.writeFileSync(path.join(outDir, binName), Buffer.from(rgb.buffer)); entry.vertexHeatmapBin = binName; } } partOutputs.push(entry); } await db.updateTaskAnalysisResult(resultId, { status: 'completed', errorStats: { min: gMin, max: gMax, mean: meanAll, stdDev: stdAll, totalPoints: sampled.length, withinTolerance: within, outOfTolerance: sampled.length - within, }, resultData: { kind: 'assembly_inspection', toleranceMm: tol, cadFileId: cad.id, modelId: task.modelId, samplePoints: sampled.length, parts: partOutputs, manifestPathRelative: path.relative(DATA_DIR, manifestPath), }, }); console.log(`[AssemblyInspection] ok resultId=${resultId} taskId=${taskId} parts=${parts.length}`); } catch (err) { console.error('[AssemblyInspection]', err); await db.updateTaskAnalysisResult(resultId, { status: 'error', resultData: { kind: 'assembly_inspection', error: err instanceof Error ? err.message : String(err), }, }); } } async function reportProgress(resultId: number, progress: number, stage: string): Promise { const p = Math.min(1, Math.max(0, progress)); await db.updateTaskAnalysisResult(resultId, { resultData: { kind: 'assembly_inspection', progress: p, stage, }, }); }