Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 4 additions & 11 deletions eval/tasks/cqe-task-export-trio/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,17 +77,10 @@ export default async function harness(scriptPath: string): Promise<HarnessResult
await initOcct();
const code = readFileSync(scriptPath, 'utf8');

// STL — STL of a Scene currently requires an explicit Scene.toUnion() /
// Scene.toCompound() upstream, so the harness rewrites the candidate's
// trailing `return ...;` to fuse the Scene into a single Shape before
// shipping to the writer. The rewrite is intentionally local: the 3MF /
// GLB paths still see the original Scene return so per-part names + colors
// survive the multi-body fan-out.
const stlCode = code.replace(
/return\s+([^;]+?)\s*;\s*$/,
'return ($1).toUnion();',
);
const stl = await runAndExport({ code: stlCode, fileName: scriptPath, format: 'stl' });
// STL — multi-body Scenes are auto-fused in runAndExport (world-frame
// union). 3MF / GLB still receive the original Scene so per-part names +
// colors survive the multi-body fan-out.
const stl = await runAndExport({ code, fileName: scriptPath, format: 'stl' });
const stlErrors = stl.diagnostics.filter((d) => d.severity === 'error');
const stlOk = stlErrors.length === 0 && stl.bytes.length >= 84;

Expand Down
47 changes: 27 additions & 20 deletions src/agent/script-runtime/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,10 +307,11 @@ export async function runAndExport(input: ExportInput): Promise<ExportResult> {
return { bytes, featureCount, diagnostics: r.diagnostics };
}

// Scene-aware path: STEP export of a SceneBackend ships a STEP file
// with one named body per part (replicad.exportSTEP(ShapeConfig[])
// writes XCAFDoc names + colors). For STL we still need a single mesh,
// so fall back to the boolean union via assemblyExport(union).
// Scene-aware path: STEP/3MF/GLB keep per-part identity. STL is a single
// triangle mesh, so multi-body Scenes are auto-fused (world-frame boolean
// union) — same geometry as an explicit Scene.toUnion() upstream. Studio
// header STL/3MF on assembly returns (e.g. multi-material keycaps) must
// not fail with "return toUnion()" after the model already viewed fine.
if (isSceneBackend(lowered)) {
if (format === 'step') {
const connectorManifest = manifestRequest === undefined
Expand Down Expand Up @@ -378,22 +379,28 @@ export async function runAndExport(input: ExportInput): Promise<ExportResult> {
throw e;
}
}
// STL of a Scene: caller must explicitly fuse via Scene.toUnion() /
// Scene.toCompound() upstream — surface a structured diagnostic
// pointing at the right call.
return {
bytes: new Uint8Array(),
featureCount,
diagnostics: [...r.diagnostics, {
target: 'export-occt',
code: 'export.no-shape',
featureId: targetId,
severity: 'error',
message: 'STL export of a Scene requires an explicit Scene.toUnion() or Scene.toCompound() upstream.',
hint: 'Return arm.solvedModel(poses).toUnion() (or .toCompound()) for STL; STEP export accepts the Scene directly and preserves per-part names + colors.',
nextAction: NEXT_ACTIONS['export.no-shape'],
}],
};
if (format === 'stl') {
// Single-mesh STL: fuse world-frame parts (clone+transform already in
// sceneToWorldFrameParts). Mirrors Scene.toUnion() / assemblyExport('union')
// without requiring the script author to call it for Studio downloads.
const worldParts = sceneToWorldFrameParts(lowered);
let fused: OcctBackend = worldParts[0]!.shape;
for (let i = 1; i < worldParts.length; i++) {
fused = fused.union(worldParts[i]!.shape);
}
const verify = (input.options as { verify?: boolean } | undefined)?.verify !== false;
const { bytes, report } = await fused.exportSTLWithReportAsync();
if (verify && !report.ok) {
return {
bytes,
featureCount,
diagnostics: [...r.diagnostics, stlNotWatertightDiagnostic(report, targetId)],
};
}
return { bytes, featureCount, diagnostics: r.diagnostics };
}
// Remaining formats (urdf/srdf/sdf-gazebo) fall through to the single-shape
// path below, which will emit format-specific diagnostics for Scenes.
}

const shape = lowered as OcctBackend;
Expand Down
21 changes: 14 additions & 7 deletions tests/integration/lowering/sceneStepExport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,9 @@ describe('Scene STEP export preserves part names + colors', () => {
expect(text).toContain('stator');
});

it('STL export of a Scene return surfaces a structured diagnostic pointing at toUnion/toCompound', async () => {
// Single-mesh STL of a multi-body Scene needs an explicit fuse — the
// export path should not silently fall back. Spec: §5 risk #3.
it('STL export of a Scene auto-fuses world-frame parts into one mesh', async () => {
// Studio header STL on multi-part assemblies (keycap body + inlays, etc.)
// must produce bytes without requiring Scene.toUnion() in the script.
const code = `
const arm = assembly('test');
arm.part('a', box(10, 10, 10));
Expand All @@ -96,12 +96,19 @@ describe('Scene STEP export preserves part names + colors', () => {
`;
const result = await runAndExport({
code,
fileName: 'scene-stl-fail.kcad.ts',
fileName: 'scene-stl-auto-union.kcad.ts',
format: 'stl',
options: { format: 'stl', verify: false },
});
const errors = result.diagnostics.filter((d) => d.severity === 'error');
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].hint).toContain('toUnion');
expect(errors[0].hint).toContain('toCompound');
expect(errors).toEqual([]);
// Binary STL: 80-byte header + 4-byte triangle count + 50 bytes/tri.
expect(result.bytes.length).toBeGreaterThanOrEqual(84);
const triCount = new DataView(
result.bytes.buffer,
result.bytes.byteOffset,
result.bytes.byteLength,
).getUint32(80, true);
expect(triCount).toBeGreaterThan(0);
});
});
Loading