258 lines
8.9 KiB
TypeScript
258 lines
8.9 KiB
TypeScript
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<void> {
|
||
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<typeof computePartStatsFromPerPartDists>;
|
||
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<string, unknown>[] = [];
|
||
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<string, unknown> = {
|
||
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<void> {
|
||
const p = Math.min(1, Math.max(0, progress));
|
||
await db.updateTaskAnalysisResult(resultId, {
|
||
resultData: {
|
||
kind: 'assembly_inspection',
|
||
progress: p,
|
||
stage,
|
||
},
|
||
});
|
||
}
|