From 19e8ea7b1b27c08b85ed7f0ee2d8c4e0606913bb Mon Sep 17 00:00:00 2001 From: Andrii Shylenko <14119286+w1ne@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:50:35 +0200 Subject: [PATCH] fix(export): auto-union multi-part Scenes for STL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Studio header STL on assemblies (e.g. keycap body + inlays) failed with export.no-shape asking for Scene.toUnion(). STL is a single mesh, so fuse world-frame parts automatically — same geometry as an explicit toUnion — while STEP/3MF/GLB keep per-part identity. --- eval/tasks/cqe-task-export-trio/harness.ts | 15 ++---- src/agent/script-runtime/export.ts | 47 +++++++++++-------- .../lowering/sceneStepExport.test.ts | 21 ++++++--- 3 files changed, 45 insertions(+), 38 deletions(-) diff --git a/eval/tasks/cqe-task-export-trio/harness.ts b/eval/tasks/cqe-task-export-trio/harness.ts index ddc7f7545..d9eab1cd1 100644 --- a/eval/tasks/cqe-task-export-trio/harness.ts +++ b/eval/tasks/cqe-task-export-trio/harness.ts @@ -77,17 +77,10 @@ export default async function harness(scriptPath: string): Promise d.severity === 'error'); const stlOk = stlErrors.length === 0 && stl.bytes.length >= 84; diff --git a/src/agent/script-runtime/export.ts b/src/agent/script-runtime/export.ts index eb7bf3671..81e7d351a 100644 --- a/src/agent/script-runtime/export.ts +++ b/src/agent/script-runtime/export.ts @@ -307,10 +307,11 @@ export async function runAndExport(input: ExportInput): Promise { 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 @@ -378,22 +379,28 @@ export async function runAndExport(input: ExportInput): Promise { 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; diff --git a/tests/integration/lowering/sceneStepExport.test.ts b/tests/integration/lowering/sceneStepExport.test.ts index c08b1acd4..b707d21e6 100644 --- a/tests/integration/lowering/sceneStepExport.test.ts +++ b/tests/integration/lowering/sceneStepExport.test.ts @@ -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)); @@ -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); }); });