Files
massive-cloud/server/assemblyInspectionJob.ts

256 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
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'],
});
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,
},
});
}