feat: integrate SimpleCADAPI 2.0.2 CAD workflows
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
import crypto from "node:crypto";
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { artifactUrl, safeArtifactPath } from "@/lib/preview-artifacts";
|
||||
import { enginePath } from "@/lib/paths";
|
||||
import {
|
||||
readManifest,
|
||||
safeTaskId,
|
||||
taskDir,
|
||||
upsertManifest,
|
||||
} from "@/lib/task-store";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export type RobotExportFormat = "urdf" | "mjcf";
|
||||
|
||||
export type RobotExportResult = {
|
||||
status: "generated" | "cached";
|
||||
format: RobotExportFormat;
|
||||
taskId: string;
|
||||
packagePath: string;
|
||||
packageUrl: string;
|
||||
descriptionPath: string;
|
||||
descriptionUrl: string;
|
||||
meshPath: string;
|
||||
warnings: string[];
|
||||
robotStructure: "static_single_link";
|
||||
};
|
||||
|
||||
type ExportState = {
|
||||
schema_version: "1.0";
|
||||
source_sha256: string;
|
||||
format: RobotExportFormat;
|
||||
package_path: string;
|
||||
description_path: string;
|
||||
mesh_path: string;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
function pythonExecutable() {
|
||||
return process.env.CAD_PYTHON
|
||||
|| enginePath("text-to-cad", ".venv", "bin", "python");
|
||||
}
|
||||
|
||||
function exporterScript() {
|
||||
return enginePath(
|
||||
"designir-pipeline",
|
||||
"scripts",
|
||||
"robot_description_export.py",
|
||||
);
|
||||
}
|
||||
|
||||
function relativeArtifact(root: string, absolutePath: string) {
|
||||
const relative = path.relative(root, path.resolve(absolutePath));
|
||||
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
throw new Error(`Robot exporter returned an artifact outside the task: ${absolutePath}`);
|
||||
}
|
||||
return safeArtifactPath(relative);
|
||||
}
|
||||
|
||||
async function fileExists(absolutePath: string) {
|
||||
return fs.access(absolutePath).then(() => true).catch(() => false);
|
||||
}
|
||||
|
||||
async function readCachedState(
|
||||
statePath: string,
|
||||
sourceSha256: string,
|
||||
format: RobotExportFormat,
|
||||
root: string,
|
||||
) {
|
||||
try {
|
||||
const state = JSON.parse(await fs.readFile(statePath, "utf8")) as ExportState;
|
||||
if (
|
||||
state.source_sha256 !== sourceSha256
|
||||
|| state.format !== format
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const required = [
|
||||
state.package_path,
|
||||
state.description_path,
|
||||
state.mesh_path,
|
||||
].map((value) => path.join(root, safeArtifactPath(value)));
|
||||
return (await Promise.all(required.map(fileExists))).every(Boolean)
|
||||
? state
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resultFromState(
|
||||
taskId: string,
|
||||
state: ExportState,
|
||||
status: "generated" | "cached",
|
||||
): RobotExportResult {
|
||||
const version = state.source_sha256.slice(0, 16);
|
||||
return {
|
||||
status,
|
||||
format: state.format,
|
||||
taskId,
|
||||
packagePath: state.package_path,
|
||||
packageUrl: artifactUrl(taskId, state.package_path, version),
|
||||
descriptionPath: state.description_path,
|
||||
descriptionUrl: artifactUrl(taskId, state.description_path, version),
|
||||
meshPath: state.mesh_path,
|
||||
warnings: state.warnings,
|
||||
robotStructure: "static_single_link",
|
||||
};
|
||||
}
|
||||
|
||||
export async function exportRobotDescription(
|
||||
requestedTaskId: string,
|
||||
format: RobotExportFormat,
|
||||
): Promise<RobotExportResult> {
|
||||
const taskId = safeTaskId(requestedTaskId);
|
||||
if (format !== "urdf" && format !== "mjcf") {
|
||||
throw new Error("Robot export format must be urdf or mjcf.");
|
||||
}
|
||||
const manifest = await readManifest(taskId);
|
||||
if (!manifest) {
|
||||
throw new Error("The selected CAD task has no manifest.");
|
||||
}
|
||||
const sourcePath = safeArtifactPath(String(manifest.source?.path || ""));
|
||||
if (!sourcePath.toLowerCase().endsWith(".json")) {
|
||||
throw new Error("Robot export requires a DesignIR 3.0 JSON source.");
|
||||
}
|
||||
const root = taskDir(taskId);
|
||||
const sourceAbsolutePath = path.join(root, sourcePath);
|
||||
const sourceData = await fs.readFile(sourceAbsolutePath);
|
||||
const sourceSha256 = crypto
|
||||
.createHash("sha256")
|
||||
.update(sourceData)
|
||||
.digest("hex");
|
||||
const exportDir = path.join(root, "exports", format);
|
||||
const statePath = path.join(exportDir, "export-state.json");
|
||||
const cached = await readCachedState(
|
||||
statePath,
|
||||
sourceSha256,
|
||||
format,
|
||||
root,
|
||||
);
|
||||
if (cached) {
|
||||
return resultFromState(taskId, cached, "cached");
|
||||
}
|
||||
|
||||
await fs.mkdir(exportDir, { recursive: true });
|
||||
const { stdout } = await execFileAsync(
|
||||
pythonExecutable(),
|
||||
[
|
||||
exporterScript(),
|
||||
"--designir",
|
||||
sourceAbsolutePath,
|
||||
"--output-dir",
|
||||
exportDir,
|
||||
"--format",
|
||||
format,
|
||||
],
|
||||
{
|
||||
cwd: root,
|
||||
timeout: 900_000,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
const jsonLine = stdout
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.reverse()
|
||||
.find((line) => line.trim().startsWith("{"));
|
||||
if (!jsonLine) {
|
||||
throw new Error("Robot exporter did not return a JSON result.");
|
||||
}
|
||||
const worker = JSON.parse(jsonLine) as Record<string, unknown>;
|
||||
const packagePath = relativeArtifact(root, String(worker.package || ""));
|
||||
const descriptionPath = relativeArtifact(
|
||||
root,
|
||||
String(worker.description || ""),
|
||||
);
|
||||
const meshPath = relativeArtifact(root, String(worker.mesh || ""));
|
||||
const warnings = Array.isArray(worker.warnings)
|
||||
? worker.warnings.map(String)
|
||||
: [];
|
||||
const state: ExportState = {
|
||||
schema_version: "1.0",
|
||||
source_sha256: sourceSha256,
|
||||
format,
|
||||
package_path: packagePath,
|
||||
description_path: descriptionPath,
|
||||
mesh_path: meshPath,
|
||||
warnings,
|
||||
};
|
||||
await fs.writeFile(
|
||||
statePath,
|
||||
`${JSON.stringify(state, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const existingArtifacts = manifest.artifacts || [];
|
||||
const nextArtifacts = existingArtifacts.filter(
|
||||
(artifact) => artifact.role !== `${format}_package`
|
||||
&& artifact.role !== `${format}_description`
|
||||
&& artifact.role !== `${format}_mesh`,
|
||||
);
|
||||
nextArtifacts.push(
|
||||
{ path: packagePath, role: `${format}_package`, kind: "zip" },
|
||||
{
|
||||
path: descriptionPath,
|
||||
role: `${format}_description`,
|
||||
kind: format,
|
||||
},
|
||||
{ path: meshPath, role: `${format}_mesh`, kind: "stl" },
|
||||
);
|
||||
await upsertManifest(taskId, {
|
||||
artifacts: nextArtifacts,
|
||||
studio: {
|
||||
...(manifest.studio || {}),
|
||||
robotExports: {
|
||||
...(
|
||||
manifest.studio?.robotExports
|
||||
&& typeof manifest.studio.robotExports === "object"
|
||||
? manifest.studio.robotExports
|
||||
: {}
|
||||
),
|
||||
[format]: state,
|
||||
},
|
||||
},
|
||||
});
|
||||
return resultFromState(taskId, state, "generated");
|
||||
}
|
||||
Reference in New Issue
Block a user