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; }