144 lines
4.8 KiB
TypeScript
144 lines
4.8 KiB
TypeScript
import { spawnSync, type SpawnSyncOptions } from 'node:child_process';
|
||
import * as fs from 'node:fs';
|
||
import * as path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const DATA_DIR = process.env.DATA_DIR || '/data';
|
||
|
||
export function stepParseCacheDir(modelId: number, cadFileId: number): string {
|
||
return path.join(DATA_DIR, 'models', String(modelId), 'cad', '.step_parse', String(cadFileId));
|
||
}
|
||
|
||
function scriptPath(): string {
|
||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||
return path.join(here, '..', 'scripts', 'step_parse_mvp.py');
|
||
}
|
||
|
||
/**
|
||
* 不依赖 existsSync:挂载/命名空间下路径探测可能不准;子进程 PATH 过短会导致 `python3` ENOENT。
|
||
* 顺序尝试可执行文件,仅对 ENOENT 换下一个。
|
||
*/
|
||
function python3Candidates(): string[] {
|
||
const fromEnv = process.env.PYTHON3_PATH?.trim();
|
||
const list: string[] = [];
|
||
if (fromEnv) list.push(fromEnv);
|
||
if (process.platform !== 'win32') {
|
||
list.push('/usr/bin/python3', '/usr/local/bin/python3');
|
||
}
|
||
list.push('python3');
|
||
return Array.from(new Set(list));
|
||
}
|
||
|
||
function spawnStepParseSync(script: string, stepAbsolutePath: string, outDir: string) {
|
||
const envBase: NodeJS.ProcessEnv = {
|
||
...process.env,
|
||
PYTHONUNBUFFERED: '1',
|
||
// 保证按名查找 python3 时能找到 Debian/Ubuntu 常见路径
|
||
PATH: `/usr/bin:/usr/local/bin:/bin:/usr/sbin:${process.env.PATH ?? ''}`,
|
||
};
|
||
const opts: SpawnSyncOptions = {
|
||
encoding: 'utf-8',
|
||
maxBuffer: 64 * 1024 * 1024,
|
||
timeout: 60 * 60 * 1000,
|
||
env: envBase,
|
||
};
|
||
|
||
const args = [script, stepAbsolutePath, outDir];
|
||
let lastErr: NodeJS.ErrnoException | undefined;
|
||
|
||
for (const py of python3Candidates()) {
|
||
const r = spawnSync(py, args, opts);
|
||
if (!r.error) {
|
||
return r;
|
||
}
|
||
lastErr = r.error as NodeJS.ErrnoException;
|
||
if (lastErr.code !== 'ENOENT') {
|
||
throw new Error(
|
||
`无法启动 Python(${py}):${lastErr.message}。PYTHON3_PATH=${process.env.PYTHON3_PATH ?? '(未设置)'}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
throw new Error(
|
||
`无法启动 Python(已尝试: ${python3Candidates().join(' → ')}):${lastErr?.message ?? 'ENOENT'}。` +
|
||
`请确认镜像已安装 python3(docker/Dockerfile.dev),或设置 PYTHON3_PATH 为实际解释器路径。`,
|
||
);
|
||
}
|
||
|
||
export type StepManifestPart = { id: string; name: string; stlRelative: string };
|
||
|
||
export type StepManifest = {
|
||
ok: boolean;
|
||
parts: StepManifestPart[];
|
||
sourceStep?: string;
|
||
};
|
||
|
||
/** cadquery/OCP 可能在 stdout 打印非 JSON 日志,不能只取最后一行 */
|
||
function parseJsonResultFromProcessOutput(stdout: string, stderr: string): {
|
||
ok?: boolean;
|
||
error?: string;
|
||
parts?: StepManifestPart[];
|
||
} | null {
|
||
const text = `${stdout || ''}\n${stderr || ''}`;
|
||
let last: { ok?: boolean; error?: string; parts?: StepManifestPart[] } | null = null;
|
||
for (const line of text.split('\n')) {
|
||
const t = line.trim();
|
||
if (!t.startsWith('{') || !t.endsWith('}')) continue;
|
||
try {
|
||
const o = JSON.parse(t) as { ok?: boolean };
|
||
if (typeof o.ok === 'boolean') {
|
||
last = o as { ok?: boolean; error?: string; parts?: StepManifestPart[] };
|
||
}
|
||
} catch {
|
||
/* 单行非完整 JSON */
|
||
}
|
||
}
|
||
return last;
|
||
}
|
||
|
||
/**
|
||
* 调用 Python cadquery 将 STEP 三角化为每零件 STL,并写入 manifest.json。
|
||
* @returns manifest 或抛出带 message 的错误
|
||
*/
|
||
export function runStepParseMvp(stepAbsolutePath: string, outDir: string): StepManifest {
|
||
fs.mkdirSync(outDir, { recursive: true });
|
||
const script = scriptPath();
|
||
if (!fs.existsSync(script)) {
|
||
throw new Error(`未找到 STEP 解析脚本: ${script}`);
|
||
}
|
||
const r = spawnStepParseSync(script, stepAbsolutePath, outDir);
|
||
|
||
const out = (v: string | Buffer | null | undefined) =>
|
||
v == null ? '' : typeof v === 'string' ? v : v.toString('utf-8');
|
||
const combined = `${out(r.stdout)}\n${out(r.stderr)}`;
|
||
let parsed = parseJsonResultFromProcessOutput(out(r.stdout), out(r.stderr));
|
||
|
||
const resultFile = path.join(outDir, '_parse_result.json');
|
||
if ((!parsed || typeof parsed.ok !== 'boolean') && fs.existsSync(resultFile)) {
|
||
try {
|
||
parsed = JSON.parse(fs.readFileSync(resultFile, 'utf-8')) as typeof parsed;
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
if (!parsed || typeof parsed.ok !== 'boolean') {
|
||
const sig = r.signal != null ? ` signal=${r.signal}` : '';
|
||
throw new Error(
|
||
`STEP 解析无有效 JSON 结果(exit=${r.status ?? 'null'}${sig})。` +
|
||
`请确认 cadquery 可用。输出摘录:\n${combined.trim().slice(-2500)}`,
|
||
);
|
||
}
|
||
|
||
if (!parsed.ok) {
|
||
throw new Error(parsed.error || 'STEP 解析失败');
|
||
}
|
||
|
||
const manFile = path.join(outDir, 'manifest.json');
|
||
if (!fs.existsSync(manFile)) {
|
||
throw new Error(`未生成 manifest.json: ${manFile}`);
|
||
}
|
||
const raw = JSON.parse(fs.readFileSync(manFile, 'utf-8')) as StepManifest;
|
||
return raw;
|
||
}
|