← All Pull Requests

fusion_export_optimized_glb: produce a publishable molecule (board, footprint/symbol JSON, gates) #17

Merged Released in 1.13.28 on 30 Oct 2026 Approved opened by AdityaAngajala 2026-10-09
Merges fix/optimized-glb-board-footprint → master

Why

fusion_export_optimized_glb sent service-step2glb only the STEP and the silk PNGs. The current service (v1.6.1, sha 2881c577) and its reference client (adom-step2glb CLI 0.13.2, convert --board) also take the board file. With it, the service generates the footprint and symbol JSON with per-pad wire-attach Z. Without it, footprint_applied is null, so a GLB from this verb could not be published as a molecule. The verb also dropped the molecule-publish gate stats, never downloaded the side artifacts, and had no compression option.

What changed

  • rust/bridge/src/services.rs
    • New board arg, default true. The verb switches to the 2D board, runs export_eagle_source to <stem>.brd, switches back to the 3D board, and sends the file as the board multipart part. If the .brd export fails, the GLB is still built, and boardNote says why there is no footprint.
    • New optional compress arg (quantize, draco or none), passed through as &compress=. Any other value is refused with a hint.
    • After the GLB downloads, the verb fetches GET /jobs/{id}/footprint, /symbol and /weblod, and writes <stem>_footprint.json, <stem>_symbol.json and <stem>.web.glb beside the GLB. A 404 means "none", the same as the CLI.
    • Replies (sync, fetch by jobId, and the async exportId status) carry boardPath, footprintPath, symbolPath, webGlbPath and gates. gates holds pinSize, pinSizeSource, footprintApplied, footprintPins, footprintRebase, contactZ and warnings. When a gate is not met, the _hint starts with "Publish gates NOT met" and names it. Fetch-by-jobId takes an optional boardSent; the async path passes it.
  • describe.py and contracts/describe.json: new args and outputs (refrozen; 176 verbs, bridge.json matches).
  • skills/fusion-molecule-export/SKILL.md: documents the board output, side files and gates.
  • CHANGELOG.md: Unreleased entry. The version number is left to the release.

How it was verified

Box H2O (Windows 11, Smart App Control off). Bridge cache dev-pinned, exe cross-built with cargo build --release --target x86_64-pc-windows-gnu. cargo test -p adom-fusion-bridge: 31 passed.

Board: Main / Molecules / Drone Refrence / "Servo Quick Realse Molecule" (large machine pins).

Before (released 1.11.39), fusion_export_optimized_glb {outputPath:"C:/tmp/glbtest/before/servo.glb", async:true}, service job 1b5edafcf54d3f07:

  • files: servo.glb, servo.step, servo_silk_top.png, servo_silk_bottom.png
  • service: molecule_anchored true, pin_size large (markers), footprint_applied null, footprint_pins null

After (this branch), same call to after/, service job 8ef6ae9e5bcb7b39:

  • files: servo.glb, servo.step, servo.brd, servo_footprint.json, servo_symbol.json, both silk PNGs
  • reply: gates: {footprintApplied: true, footprintPins: 4, contactZ: 8.4, pinSize: "large", pinSizeSource: "markers", footprintRebase: null, warnings: []}
  • GLB size unchanged (175760 bytes, 8 meshes)

fusion_fetch_optimized_glb by jobId on both jobs: the board job returns the side files and gates. The no-board job returns _hint: "Publish gates NOT met: no board file was sent, so there is no footprint/symbol JSON; ...".

Note: the after job spent 250 s in the service's optimize_gold stage, against 10 s for the before job. footprint_join took 0 ms, so this looks like service load, not the board part. Worth watching.

Not in this PR (follow-ups)

  • The separate skill page adom/fusion-export-molecule still calls the old adom-desktop CLI name and fusion_take_silkscreen_screenshot, which is not an exposed verb (describe.py says it answers Unknown command). That needs its own PR on that page.
  • The board-look exporter decisions from 2026-10-08 (Gerbers and an export.json on every export) are a separate change.

🤖 Generated with Claude Code

Diff Skip to comments (1)

CHANGELOG.md+13
@@ -3,6 +3,19 @@ All notable changes to the Adom Bridge Fusion 360 bridge, newest first. Canonical: https://wiki.adom.inc/adom/fusion-bridge · GitHub backup: `adom-inc/fusion-bridge` +## Unreleased++- `fusion_export_optimized_glb` now produces a publishable molecule, not just a GLB. It exports the EAGLE `.brd`+  (new `board` arg, default true) and sends it to service-step2glb as the `board` part, the same contract as+  `step2glb convert --board`. With the board the service generates the footprint and symbol JSON with per-pad+  wire-attach Z; without it `footprint_applied` was null and the GLB could not be published as a molecule.+- The export and `fusion_fetch_optimized_glb` download the job's side artifacts beside the GLB:+  `<stem>_footprint.json`, `<stem>_symbol.json`, and `<stem>.web.glb` when the service made a web-grade twin.+  Replies carry `boardPath`, `footprintPath`, `symbolPath` and `webGlbPath`.+- Replies carry the molecule-publish gates as `gates` (pinSize, pinSizeSource, footprintApplied, footprintPins,+  footprintRebase, contactZ, warnings), and the `_hint` names any gate that is not met.+- New optional `compress` arg (quantize, draco or none) is passed through to the service.+ ## 1.11.9 (2026-09-29)  - `fusion_aps_signin` always opens a browser. With no remembered browser profile it passed an EMPTY profile name, ab
contracts/describe.json+17−5
@@ -1,6 +1,6 @@ {   "contract": "fusion-bridge verb catalog",-  "frozen": "2026-10-06",+  "frozen": "2026-10-08",   "note": "The Python bridge and the Rust bridge must both serve this catalog byte-for-byte in meaning: same names, same args, same result shapes, same timeouts. Regenerate ONLY from describe.py and diff in CI.",   "source": "describe.py",   "verbs": [@@ -1389,24 +1389,31 @@       },       "input": {         "async": "bool - RECOMMENDED true: fires the whole export+optimize pipeline in the background and returns {exportId} instantly (the sync form can outlive the relay window on a cold Fusion session); poll fusion_fetch_optimized_glb {exportId}",+        "board": "bool default true (export the EAGLE .brd and send it, for the footprint + symbol JSON)",+        "compress": "optional str quantize|draco|none (default: the service default, Draco for molecule boards)",         "outputPath": "required str (.glb)",-        "pin": "str medium|large default medium",+        "pin": "str medium|large default medium (fallback only: the service reads the size from the MP marker names when they carry one)",         "silkscreen": "bool default true",         "wait": "int seconds default 15 (keep under the ~60s relay timeout; re-call fetch until complete)"       },       "longRunning": true,       "name": "fusion_export_optimized_glb",       "output": {+        "boardPath": "str|null",+        "footprintPath": "str|null",+        "gates": "object",         "glbPath": "str",         "jobId": "str",         "meshesAfter": "int",         "moleculeAnchored": "bool",         "pending": "bool",         "silkscreenApplied": "bool",-        "sizeBytes": "int"+        "sizeBytes": "int",+        "symbolPath": "str|null",+        "webGlbPath": "str|null"       },       "statusVerb": null,-      "summary": "Fusion board -> wiki-grade optimized GLB. Exports STEP (+ optional silkscreen) and submits it to service-step2glb molecule mode (anchor to MP pins + silk bake + gold pins + join/weld/prune + Draco): ~465KB/~31 draw calls vs a raw ~16MB/~25000 that halts the viewer. Bounded-waits `wait`s (default 90); small boards return complete, big boards return {pending, jobId} - finish with fusion_fetch_optimized_glb. Use for a component page's component.parts.model_3d. STACKUP RULE (John 2026-07-15, see pcb-stackup skill): board bbox = the FR4 body, NEVER the whole assembly (components overhang edges + tower above); each overlay goes at ITS layer's z (copper at FR4-top, silk above mask); copper traces are REAL 35um 3D bodies colored like the FR4 - recolor them + translucent mask in the RAW GLB before optimizing to make routing visible; put the measured stackup table on the board's wiki page. PUBLISH EVERYTHING: a board's wiki page repo must carry EVERY exportable design file (.f3d/.step/.usdz/EAGLE .brd+.sch/Fusion .fbrd+.fsch/gerbers/BOM/CPL/GLBs/renders), not just the 3D - the page exists to share the design.",+      "summary": "Fusion board -> wiki-grade optimized GLB. Exports STEP, the EAGLE .brd (board:true) and optional silkscreen and submits them to service-step2glb molecule mode (anchor to MP pins + silk bake + gold pins + join/weld/prune + Draco; with the board it also writes <stem>_footprint.json and <stem>_symbol.json beside the GLB, and <stem>.web.glb when the model is over the wiki inline budget). The reply carries the molecule-publish gates (gates.pinSize/pinSizeSource/footprintApplied/footprintPins/footprintRebase/warnings) and its _hint names any gate that is not met: ~465KB/~31 draw calls vs a raw ~16MB/~25000 that halts the viewer. Bounded-waits `wait`s (default 90); small boards return complete, big boards return {pending, jobId} - finish with fusion_fetch_optimized_glb. Use for a component page's component.parts.model_3d. STACKUP RULE (John 2026-07-15, see pcb-stackup skill): board bbox = the FR4 body, NEVER the whole assembly (components overhang edges + tower above); each overlay goes at ITS layer's z (copper at FR4-top, silk above mask); copper traces are REAL 35um 3D bodies colored like the FR4 - recolor them + translucent mask in the RAW GLB before optimizing to make routing visible; put the measured stackup table on the board's wiki page. PUBLISH EVERYTHING: a board's wiki page repo must carry EVERY exportable design file (.f3d/.step/.usdz/EAGLE .brd+.sch/Fusion .fbrd+.fsch/gerbers/BOM/CPL/GLBs/renders), not just the 3D - the page exists to share the design.",       "timeoutSeconds": 200     },     {@@ -1517,6 +1524,7 @@         "outputPath": "C:/out/board.glb"       },       "input": {+        "boardSent": "optional bool (whether the job carried a board; decides the footprint gate)",         "exportId": "str (async form from export_optimized_glb async:true - returns {phase, done, glbPath} instantly per poll)",         "jobId": "str (service job form)",         "outputPath": "str (.glb)",@@ -1525,10 +1533,14 @@       "longRunning": false,       "name": "fusion_fetch_optimized_glb",       "output": {+        "footprintPath": "str|null",+        "gates": "object",         "glbPath": "str",         "pending": "bool",         "sizeBytes": "int",-        "status": "str"+        "status": "str",+        "symbolPath": "str|null",+        "webGlbPath": "str|null"       },       "statusVerb": null,       "summary": "Finish a fusion_export_optimized_glb job: bounded-poll the optimizer by jobId and write the GLB to outputPath when complete. Re-call until status=complete (big boards tessellate a few minutes). No Fusion needed.",
describe.py+2−2
@@ -319,8 +319,8 @@ _T = [     ("fusion_export_stl", "Export to STL mesh.", {"outputPath": "required str"}, {"format": "str"}, 300, None, True, {"outputPath": "C:/out/part.stl"}),     ("fusion_export_3mf", "Export to 3MF mesh.", {"outputPath": "required str"}, {"format": "str"}, 300, None, True, {"outputPath": "C:/out/part.3mf"}),     ("fusion_export_usdz", "Export to USDZ (AR / Hydrogen 3D).", {"outputPath": "required str"}, {"format": "str"}, 300, None, True, {"outputPath": "C:/out/part.usdz"}),-    ("fusion_export_optimized_glb", "Fusion board -> wiki-grade optimized GLB. Exports STEP (+ optional silkscreen) and submits it to service-step2glb molecule mode (anchor to MP pins + silk bake + gold pins + join/weld/prune + Draco): ~465KB/~31 draw calls vs a raw ~16MB/~25000 that halts the viewer. Bounded-waits `wait`s (default 90); small boards return complete, big boards return {pending, jobId} - finish with fusion_fetch_optimized_glb. Use for a component page's component.parts.model_3d. STACKUP RULE (John 2026-07-15, see pcb-stackup skill): board bbox = the FR4 body, NEVER the whole assembly (components overhang edges + tower above); each overlay goes at ITS layer's z (copper at FR4-top, silk above mask); copper traces are REAL 35um 3D bodies colored like the FR4 - recolor them + translucent mask in the RAW GLB before optimizing to make routing visible; put the measured stackup table on the board's wiki page. PUBLISH EVERYTHING: a board's wiki page repo must carry EVERY exportable design file (.f3d/.step/.usdz/EAGLE .brd+.sch/Fusion .fbrd+.fsch/gerbers/BOM/CPL/GLBs/renders), not just the 3D - the page exists to share the design.", {"outputPath": "required str (.glb)", "silkscreen": "bool default true", "pin": "str medium|large default medium", "wait": "int seconds default 15 (keep under the ~60s relay timeout; re-call fetch until complete)", "async": "bool - RECOMMENDED true: fires the whole export+optimize pipeline in the background and returns {exportId} instantly (the sync form can outlive the relay window on a cold Fusion session); poll fusion_fetch_optimized_glb {exportId}"}, {"glbPath": "str", "jobId": "str", "pending": "bool", "meshesAfter": "int", "sizeBytes": "int", "moleculeAnchored": "bool", "silkscreenApplied": "bool"}, 200, None, True, {"outputPath": "C:/out/board.glb"}),-    ("fusion_fetch_optimized_glb", "Finish a fusion_export_optimized_glb job: bounded-poll the optimizer by jobId and write the GLB to outputPath when complete. Re-call until status=complete (big boards tessellate a few minutes). No Fusion needed.", {"jobId": "str (service job form)", "exportId": "str (async form from export_optimized_glb async:true - returns {phase, done, glbPath} instantly per poll)", "outputPath": "str (.glb)", "wait": "int seconds default 15 (keep under the ~60s relay timeout; re-call fetch until complete)"}, {"glbPath": "str", "status": "str", "pending": "bool", "sizeBytes": "int"}, 150, None, False, {"jobId": "abc123", "outputPath": "C:/out/board.glb"}),+    ("fusion_export_optimized_glb", "Fusion board -> wiki-grade optimized GLB. Exports STEP, the EAGLE .brd (board:true) and optional silkscreen and submits them to service-step2glb molecule mode (anchor to MP pins + silk bake + gold pins + join/weld/prune + Draco; with the board it also writes <stem>_footprint.json and <stem>_symbol.json beside the GLB, and <stem>.web.glb when the model is over the wiki inline budget). The reply carries the molecule-publish gates (gates.pinSize/pinSizeSource/footprintApplied/footprintPins/footprintRebase/warnings) and its _hint names any gate that is not met: ~465KB/~31 draw calls vs a raw ~16MB/~25000 that halts the viewer. Bounded-waits `wait`s (default 90); small boards return complete, big boards return {pending, jobId} - finish with fusion_fetch_optimized_glb. Use for a component page's component.parts.model_3d. STACKUP RULE (John 2026-07-15, see pcb-stackup skill): board bbox = the FR4 body, NEVER the whole assembly (components overhang edges + tower above); each overlay goes at ITS layer's z (copper at FR4-top, silk above mask); copper traces are REAL 35um 3D bodies colored like the FR4 - recolor them + translucent mask in the RAW GLB before optimizing to make routing visible; put the measured stackup table on the board's wiki page. PUBLISH EVERYTHING: a board's wiki page repo must carry EVERY exportable design file (.f3d/.step/.usdz/EAGLE .brd+.sch/Fusion .fbrd+.fsch/gerbers/BOM/CPL/GLBs/renders), not just the 3D - the page exists to share the design.", {"outputPath": "required str (.glb)", "silkscreen": "bool default true", "board": "bool default true (export the EAGLE .brd and send it, for the footprint + symbol JSON)", "compress": "optional str quantize|draco|none (default: the service default, Draco for molecule boards)", "pin": "str medium|large default medium (fallback only: the service reads the size from the MP marker names when they carry one)", "wait": "int seconds default 15 (keep under the ~60s relay timeout; re-call fetch until complete)", "async": "bool - RECOMMENDED true: fires the whole export+optimize pipeline in the background and returns {exportId} instantly (the sync form can outlive the relay window on a cold Fusion session); poll fusion_fetch_optimized_glb {exportId}"}, {"glbPath": "str", "jobId": "str", "pending": "bool", "meshesAfter": "int", "sizeBytes": "int", "moleculeAnchored": "bool", "silkscreenApplied": "bool", "boardPath": "str|null", "footprintPath": "str|null", "symbolPath": "str|null", "webGlbPath": "str|null", "gates": "object"}, 200, None, True, {"outputPath": "C:/out/board.glb"}),+    ("fusion_fetch_optimized_glb", "Finish a fusion_export_optimized_glb job: bounded-poll the optimizer by jobId and write the GLB to outputPath when complete. Re-call until status=complete (big boards tessellate a few minutes). No Fusion needed.", {"jobId": "str (service job form)", "exportId": "str (async form from export_optimized_glb async:true - returns {phase, done, glbPath} instantly per poll)", "outputPath": "str (.glb)", "wait": "int seconds default 15 (keep under the ~60s relay timeout; re-call fetch until complete)", "boardSent": "optional bool (whether the job carried a board; decides the footprint gate)"}, {"glbPath": "str", "status": "str", "pending": "bool", "sizeBytes": "int", "footprintPath": "str|null", "symbolPath": "str|null", "webGlbPath": "str|null", "gates": "object"}, 150, None, False, {"jobId": "abc123", "outputPath": "C:/out/board.glb"}),     ("fusion_board_stackup", "Read a board's PHYSICAL fabrication stackup (see the pcb-stackup skill): copper layer count + copper/dielectric thicknesses from the EAGLE design rules (layerSetup/mtCopper/mtIsolate) plus the measured FR4 extent. A PCB is Cu/prepreg/Cu/core/.../Cu - the FR4 is NOT one slab. Use to build the stackup table + real-thickness exploded 3D view, and put a stackup table on every exported board's wiki page.", {}, {"layerSetup": "str", "copperLayers": "list", "copperCount": "int", "copperThickness_mm": "list", "dielectricBonds": "list (prepreg/core order)", "fr4": "{x_mm,y_mm,dielectric_mm}"}, 200, None, True, {}),     ("fusion_export_obj", "Export to OBJ mesh.", {"outputPath": "required str"}, {"format": "str"}, 300, None, True, {"outputPath": "C:/out/part.obj"}),     ("fusion_export_dxf", "Export 2D DXF.", {"outputPath": "required str"}, {"format": "str"}, 120, None, True, {"outputPath": "C:/out/sketch.dxf"}),
rust/bridge/src/services.rs+69−12
@@ -49,12 +49,16 @@ fn multipart(files: &[(&str, String, Vec<u8>, &str)]) -> (Vec<u8>, String) {     (out, boundary) } -fn glb_submit(step_path: &str, silk_top: Option<&str>, silk_bottom: Option<&str>, pin: &str, job_name: &str) -> Value {+fn glb_submit(step_path: &str, board: Option<&str>, silk_top: Option<&str>, silk_bottom: Option<&str>, pin: &str, compress: Option<&str>, job_name: &str) -> Value {     let Ok(step) = std::fs::read(step_path) else { return json!({"ok": false, "error": format!("could not read STEP: {step_path}")}) };     let mut files: Vec<(&str, String, Vec<u8>, &str)> = vec![("step", std::path::Path::new(step_path).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default(), step, "application/step")];+    // The board file (EAGLE .brd) is what lets the service generate the footprint + symbol JSON with per-pad+    // wire-attach Z; the service sniffs .brd vs .kicad_pcb from the content (same contract as `step2glb --board`).+    if let Some(p) = board { if let Ok(b) = std::fs::read(p) { files.push(("board", std::path::Path::new(p).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default(), b, "application/octet-stream")); } }     for (field, p) in [("silk_top", silk_top), ("silk_bottom", silk_bottom)] { if let Some(p) = p { if let Ok(b) = std::fs::read(p) { files.push((field, std::path::Path::new(p).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default(), b, "image/png")); } } }     let (body, boundary) = multipart(&files);-    let url = format!("{}/convert?molecule=true&pin={}", step2glb_url(), fusion_core::aps::urlencode(pin));+    let mut url = format!("{}/convert?molecule=true&pin={}", step2glb_url(), fusion_core::aps::urlencode(pin));+    if let Some(c) = compress { url.push_str(&format!("&compress={}", fusion_core::aps::urlencode(c))); }     let resp = ureq::post(&url).timeout(Duration::from_secs(90)).set("Content-Type", &format!("multipart/form-data; boundary={boundary}")).set("X-Client", "fusion-bridge/adom").set("X-Job-Name", job_name).set("User-Agent", &service_ua()).send_bytes(&body);     let queued: Value = match resp { Ok(r) => r.into_json().unwrap_or(json!({})), Err(e) => return json!({"ok": false, "error": format!("service POST failed: {e}"), "_hint": format!("Is {} reachable from this box? Override with ADOM_STEP2GLB_URL.", step2glb_url())}) };     match queued["job_id"].as_str() { Some(j) => json!({"ok": true, "jobId": j}), None => json!({"ok": false, "error": "service did not return a job_id", "raw": queued}) }@@ -76,8 +80,38 @@ fn glb_fetch(job_id: &str, out_path: Option<&str>, poll_s: u64) -> Value {     let (code, glb) = fusion_core::aps::http_bytes(&format!("{}/jobs/{job_id}/result", step2glb_url()));     if code != 200 { return json!({"ok": false, "status": "complete", "error": format!("download failed: HTTP {code}"), "stats": stats}); }     let mut wrote = Value::Null;-    if let Some(p) = out_path { match std::fs::write(p, &glb) { Ok(_) => wrote = json!(p), Err(e) => return json!({"ok": false, "status": "complete", "error": format!("could not write GLB: {e}"), "stats": stats}) } }-    json!({"ok": true, "status": "complete", "stats": stats, "glbBytes": glb.len(), "wrote": wrote})+    let mut side = json!({});+    if let Some(p) = out_path {+        match std::fs::write(p, &glb) { Ok(_) => wrote = json!(p), Err(e) => return json!({"ok": false, "status": "complete", "error": format!("could not write GLB: {e}"), "stats": stats}) }+        side = fetch_side_artifacts(job_id, p);+    }+    json!({"ok": true, "status": "complete", "stats": stats, "glbBytes": glb.len(), "wrote": wrote, "side": side})+}++/// GET /jobs/{id}/footprint, /symbol (JSON, only when a board was attached) and /weblod (a decimated+/// `<stem>.web.glb` twin, only when the model is over the wiki's inline budget), written beside the GLB the+/// same way the step2glb CLI does. A 404 means the job produced none, which is not an error.+fn fetch_side_artifacts(job_id: &str, glb_path: &str) -> Value {+    let stem = &glb_path[..glb_path.len().saturating_sub(4)];+    let mut out = json!({"footprintPath": null, "symbolPath": null, "webGlbPath": null});+    for (kind, key, path) in [("footprint", "footprintPath", format!("{stem}_footprint.json")), ("symbol", "symbolPath", format!("{stem}_symbol.json")), ("weblod", "webGlbPath", format!("{stem}.web.glb"))] {+        let (code, bytes) = fusion_core::aps::http_bytes(&format!("{}/jobs/{job_id}/{kind}", step2glb_url()));+        if code == 200 && !bytes.is_empty() && std::fs::write(&path, &bytes).is_ok() { out[key] = json!(path); }+    }+    out+}++/// The molecule-publish gate stats from the job status, under the names the rest of the reply uses. A gate+/// that is not met is a stop sign for publishing (see the adom-step2glb skill), so the hint says which.+fn molecule_gates(stats: &Value, board_sent: bool) -> (Value, Option<String>) {+    let g = json!({"pinSize": stats["pin_size"], "pinSizeSource": stats["pin_size_source"], "footprintApplied": stats["footprint_applied"],+        "footprintPins": stats["footprint_pins"], "footprintRebase": stats["footprint_rebase"], "contactZ": stats["contact_z"], "warnings": stats["warnings"]});+    let mut stop: Vec<String> = Vec::new();+    if stats["molecule_anchored"].as_bool() != Some(true) { stop.push("molecule_anchored is not true: the MP1-MP4 markers were not found in the STEP".into()); }+    if board_sent && stats["footprint_applied"].as_bool() != Some(true) { stop.push("footprint_applied is not true: the service could not use the .brd (read warnings)".into()); }+    if !board_sent { stop.push("no board file was sent, so there is no footprint/symbol JSON; this GLB is not publishable as a molecule".into()); }+    if stats["warnings"].as_array().map(|w| !w.is_empty()).unwrap_or(false) { stop.push("the service returned warnings; each one is actionable".into()); }+    (g, if stop.is_empty() { None } else { Some(format!("Publish gates NOT met: {}.", stop.join("; "))) }) }  /// `fusion_export_optimized_glb` (the synchronous form).@@ -86,6 +120,8 @@ pub fn export_optimized_glb(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -     if !out.to_lowercase().ends_with(".glb") { out.push_str(".glb"); }     let pin = args["pin"].as_str().unwrap_or("medium").to_string();     let want_silk = args["silkscreen"].as_bool().unwrap_or(true);+    let want_board = args["board"].as_bool().unwrap_or(true);+    let compress = match args["compress"].as_str() { None => None, Some(c @ ("quantize" | "draco" | "none")) => Some(c.to_string()), Some(c) => return json!({"success": false, "error": format!("compress must be quantize, draco or none (got {c:?})"), "_hint": "Leave compress out for the service default (Draco for molecule boards)."}) };     let _ = pass("show_3d_board", json!({}));     let step_path = format!("{}.step", &out[..out.len() - 4]);     let sr = pass("export_step", json!({"outputPath": step_path}));@@ -97,8 +133,17 @@ pub fn export_optimized_glb(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -         if pass("take_silkscreen_screenshot", json!({"outputPath": sb, "layer": "bottom"}))["success"].as_bool() == Some(true) { silk_bottom = Some(sb); }         if silk_top.is_none() && silk_bottom.is_none() { silk_note = Some("silkscreen capture unavailable on this design; GLB built without a baked silk texture."); }     }+    // The EAGLE .brd from the 2D board, then back to the 3D board so the caller's view is unchanged.+    let (mut board_path, mut board_note): (Option<String>, Option<String>) = (None, None);+    if want_board {+        let bp = format!("{}.brd", &out[..out.len() - 4]);+        let _ = pass("show_2d_board", json!({}));+        let br = pass("export_eagle_source", json!({"outputPath": bp}));+        if br["success"].as_bool() == Some(true) && std::path::Path::new(&bp).exists() { board_path = Some(bp); } else { board_note = Some(format!("board (.brd) export failed, so no footprint/symbol JSON: {}", br["error"].as_str().or(br["message"].as_str()).unwrap_or("None"))); }+        let _ = pass("show_3d_board", json!({}));+    }     let job_name = std::path::Path::new(&out).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();-    let sub = glb_submit(&step_path, silk_top.as_deref(), silk_bottom.as_deref(), &pin, &job_name);+    let sub = glb_submit(&step_path, board_path.as_deref(), silk_top.as_deref(), silk_bottom.as_deref(), &pin, compress.as_deref(), &job_name);     if sub["ok"].as_bool() != Some(true) { return json!({"success": false, "error": sub["error"], "_hint": sub["_hint"], "stepPath": step_path, "note": "STEP exported OK; optimize submit failed."}); }     let job_id = sub["jobId"].as_str().unwrap_or("").to_string();     let wait_s = args["wait"].as_u64().unwrap_or(15);@@ -106,12 +151,16 @@ pub fn export_optimized_glb(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -     let (status_url, result_url) = (format!("{}/jobs/{job_id}", step2glb_url()), format!("{}/jobs/{job_id}/result", step2glb_url()));     if fetched["status"].as_str() == Some("complete") && !fetched["wrote"].is_null() {         let stats = &fetched["stats"]; let size = fetched["glbBytes"].as_u64().unwrap_or(0);-        return json!({"success": true, "status": "complete", "glbPath": out, "stepPath": step_path, "jobId": job_id, "silkTop": silk_top, "silkBottom": silk_bottom, "meshesBefore": stats["meshes_before"], "meshesAfter": stats["meshes_after"], "sizeBytes": size, "moleculeAnchored": stats["molecule_anchored"], "silkscreenApplied": stats["silkscreen_applied"], "note": silk_note,-            "message": format!("Optimized GLB written ({} KB, meshes {}->{}, anchored={}, silk={}) to {out}", size / 1024, crate::fusion_prefs::py_str(&stats["meshes_before"]), crate::fusion_prefs::py_str(&stats["meshes_after"]), crate::fusion_prefs::py_str(&stats["molecule_anchored"]), crate::fusion_prefs::py_str(&stats["silkscreen_applied"])),-            "_hint": "Pull it with pull_file, then set it as component.parts.model_3d on a wiki component page. Same optimizer as the molecule GLBs (Colby's pipeline)."});+        let (gates, stop) = molecule_gates(stats, board_path.is_some());+        let mut r = json!({"success": true, "status": "complete", "glbPath": out, "stepPath": step_path, "boardPath": board_path, "jobId": job_id, "silkTop": silk_top, "silkBottom": silk_bottom, "meshesBefore": stats["meshes_before"], "meshesAfter": stats["meshes_after"], "sizeBytes": size, "moleculeAnchored": stats["molecule_anchored"], "silkscreenApplied": stats["silkscreen_applied"], "gates": gates, "note": silk_note, "boardNote": board_note,+            "footprintPath": fetched["side"]["footprintPath"], "symbolPath": fetched["side"]["symbolPath"], "webGlbPath": fetched["side"]["webGlbPath"],+            "message": format!("Optimized GLB written ({} KB, meshes {}->{}, anchored={}, silk={}, footprint={}) to {out}", size / 1024, crate::fusion_prefs::py_str(&stats["meshes_before"]), crate::fusion_prefs::py_str(&stats["meshes_after"]), crate::fusion_prefs::py_str(&stats["molecule_anchored"]), crate::fusion_prefs::py_str(&stats["silkscreen_applied"]), crate::fusion_prefs::py_str(&stats["footprint_applied"])),+            "_hint": "Pull the GLB and its _footprint.json / _symbol.json with pull_file, then publish through molecule-publish. Same optimizer as the molecule GLBs (Colby's pipeline)."});+        if let Some(s) = stop { r["_hint"] = json!(format!("{s} Fix that before publishing. {}", r["_hint"].as_str().unwrap_or(""))); }+        return r;     }     if fetched["ok"].as_bool() != Some(true) { return json!({"success": false, "error": fetched["error"], "jobId": job_id, "stepPath": step_path, "statusUrl": status_url, "resultUrl": result_url}); }-    json!({"success": true, "status": fetched["status"].as_str().unwrap_or("processing"), "pending": true, "jobId": job_id, "stepPath": step_path, "glbPath": out, "silkTop": silk_top, "silkBottom": silk_bottom, "note": silk_note, "statusUrl": status_url, "resultUrl": result_url,+    json!({"success": true, "status": fetched["status"].as_str().unwrap_or("processing"), "pending": true, "jobId": job_id, "stepPath": step_path, "boardPath": board_path, "boardNote": board_note, "glbPath": out, "silkTop": silk_top, "silkBottom": silk_bottom, "note": silk_note, "statusUrl": status_url, "resultUrl": result_url,         "message": format!("Optimize job {job_id} submitted; still processing after {wait_s}s. Finish it with fusion_fetch_optimized_glb {{\"jobId\":\"{job_id}\",\"outputPath\":\"{out}\"}} (re-call until complete)."),         "_hint": "Big boards tessellate for a few minutes. Either re-call fusion_fetch_optimized_glb with this jobId (writes the GLB on the box when ready), or GET the resultUrl directly."}) }@@ -126,7 +175,13 @@ pub fn fetch_optimized_glb(args: &Value) -> Value {     let (status_url, result_url) = (format!("{}/jobs/{job_id}", step2glb_url()), format!("{}/jobs/{job_id}/result", step2glb_url()));     if res["status"].as_str() == Some("complete") && !res["wrote"].is_null() {         let stats = &res["stats"]; let size = res["glbBytes"].as_u64().unwrap_or(0);-        return json!({"success": true, "status": "complete", "glbPath": out, "jobId": job_id, "sizeBytes": size, "meshesAfter": stats["meshes_after"], "moleculeAnchored": stats["molecule_anchored"], "silkscreenApplied": stats["silkscreen_applied"], "message": format!("Optimized GLB written ({} KB) to {}", size / 1024, out.as_deref().unwrap_or("None"))});+        // A fetch by jobId cannot know whether a board was sent; footprint JSON on disk means it was.+        let board_sent = args["boardSent"].as_bool().unwrap_or(!res["side"]["footprintPath"].is_null() || stats["footprint_applied"].is_boolean());+        let (gates, stop) = molecule_gates(stats, board_sent);+        let mut r = json!({"success": true, "status": "complete", "glbPath": out, "jobId": job_id, "sizeBytes": size, "meshesAfter": stats["meshes_after"], "moleculeAnchored": stats["molecule_anchored"], "silkscreenApplied": stats["silkscreen_applied"], "gates": gates,+            "footprintPath": res["side"]["footprintPath"], "symbolPath": res["side"]["symbolPath"], "webGlbPath": res["side"]["webGlbPath"], "message": format!("Optimized GLB written ({} KB) to {}", size / 1024, out.as_deref().unwrap_or("None"))});+        if let Some(s) = stop { r["_hint"] = json!(format!("{s} Fix that before publishing.")); }+        return r;     }     if res["ok"].as_bool() != Some(true) { return json!({"success": false, "error": res["error"], "jobId": job_id, "statusUrl": status_url, "resultUrl": result_url}); }     json!({"success": true, "status": res["status"].as_str().unwrap_or("processing"), "pending": true, "jobId": job_id, "statusUrl": status_url, "resultUrl": result_url, "message": format!("Job {job_id} still {}; re-call fusion_fetch_optimized_glb to finish.", res["status"].as_str().unwrap_or("processing"))})@@ -160,7 +215,9 @@ pub fn start_glb_export_async(ctx: &Ctx, args: &Value) -> Value {         st["phase"] = json!("optimizing"); st["jobId"] = json!(job); glb_state_set(&eid, st.clone());         let deadline = Instant::now() + Duration::from_secs(600);         while Instant::now() < deadline {-            let f = fetch_optimized_glb(&json!({"jobId": job, "outputPath": inner["outputPath"], "wait": 20}));+            let f = fetch_optimized_glb(&json!({"jobId": job, "outputPath": inner["outputPath"], "wait": 20, "boardSent": res["boardPath"].is_string()}));+            let mut f = f;+            for k in ["boardPath", "boardNote", "stepPath"] { if !res[k].is_null() { f[k] = res[k].clone(); } }             st["result"] = f.clone();             if f["glbPath"].is_string() && f["pending"].as_bool() != Some(true) { st["phase"] = json!("complete"); st["done"] = json!(true); glb_state_set(&eid, st); return; }             if f["success"].as_bool() != Some(true) && f["pending"].as_bool() != Some(true) { st["phase"] = json!("failed"); st["failed"] = json!(true); st["done"] = json!(true); st["error"] = json!(f["error"].as_str().unwrap_or("None").chars().take(200).collect::<String>()); glb_state_set(&eid, st); return; }@@ -179,7 +236,7 @@ pub fn glb_export_async_status(args: &Value) -> Value {     let mut out = json!({"success": st["failed"].as_bool() != Some(true), "exportId": id, "phase": st["phase"], "done": st["done"], "elapsedSec": (now_f() - st["started"].as_f64().unwrap_or(now_f())) as i64});     if st["jobId"].is_string() { out["jobId"] = st["jobId"].clone(); }     if st["error"].is_string() { out["error"] = st["error"].clone(); }-    if st["done"].as_bool() == Some(true) && st["result"].is_object() { for k in ["glbPath", "sizeBytes", "meshesAfter", "moleculeAnchored", "silkscreenApplied"] { if !st["result"][k].is_null() { out[k] = st["result"][k].clone(); } } }+    if st["done"].as_bool() == Some(true) && st["result"].is_object() { for k in ["glbPath", "sizeBytes", "meshesAfter", "moleculeAnchored", "silkscreenApplied", "gates", "boardPath", "boardNote", "footprintPath", "symbolPath", "webGlbPath", "_hint"] { if !st["result"][k].is_null() { out[k] = st["result"][k].clone(); } } }     out } 
skills/fusion-molecule-export/SKILL.md+13−4
@@ -93,16 +93,25 @@ request timeout), so it is submit-then-poll, never one long blocking call: ``` # 1) export STEP + submit to the optimizer (returns fast with a jobId; small boards may #    already come back complete within `wait` seconds)-fusion_export_optimized_glb {"outputPath":"%TEMP%/board.glb", "silkscreen":true, "pin":"medium", "wait":15}-#    -> { pending:true, jobId:"...", stepPath, resultUrl }   (big board)-#    -> { status:"complete", glbPath, sizeBytes, meshesAfter, moleculeAnchored }  (small board)+fusion_export_optimized_glb {"outputPath":"%TEMP%/board.glb", "async":true}+#    board:true (default) also exports the EAGLE .brd and sends it, so the service writes+#    <stem>_footprint.json and <stem>_symbol.json (per-pad wire-attach Z) beside the GLB.+#    compress: quantize|draco|none (optional). pin: fallback only; the size comes from the MP marker names.+#    -> { pending:true, jobId:"...", stepPath, boardPath, resultUrl }   (big board)+#    -> { status:"complete", glbPath, boardPath, footprintPath, symbolPath, webGlbPath, gates }  (small board)  # 2) finish it: re-call until status=complete (each call polls ~15s, well under the relay #    timeout, and writes the GLB on the box when ready). No Fusion needed for this verb. fusion_fetch_optimized_glb {"jobId":"...", "outputPath":"%TEMP%/board.glb", "wait":15}-#    -> { status:"complete", glbPath, sizeBytes }   then pull_file it.+#    -> { status:"complete", glbPath, sizeBytes, footprintPath, symbolPath, gates }   then pull_file them. ``` +**Gate before publishing.** `gates` carries the molecule-publish stop signs from the service:+`pinSize` + `pinSizeSource` (expect `markers`), `footprintApplied` (expect true) ++`footprintPins` (expect 4), `footprintRebase` (absent or small), `contactZ`, and `warnings`+(expect none). When one is not met, the reply's `_hint` starts with "Publish gates NOT met" and+names it. `moleculeAnchored:false` means the MP1-MP4 markers are missing from the STEP.+ Gotchas baked in from the live bring-up (2026-07-14): - **The *.adom.cloud edge WAF 403s the default `Python-urllib` User-Agent.** The bridge sets   a UA on every service call; if you ever call the service yourself from Python, set one too.

Reviews

Colby Knox approved · 12d ago

Reviewed and merged by fusion-lane. Thanks Aditya, and sorry it sat three weeks.

Master had moved under it. Since your base, master gained:

  • clean_silk_png (issue #113): it crops the silk capture to the board and keeps only the silk strokes;
  • its own compress argument (1.12.1).

So I merged by hand: your services.rs with master's silk cleanup applied to the silk parts, and master's compress check (errorCode bad_args, lowercased). Your board, gerbers, side artifacts, gates, fab zip and export.json are unchanged.

  • describe.py and the contract: your inputs and outputs, with master's compress wording (none is the form wiki README frames can load).
  • Your skill page and CHANGELOG entry are in 1.13.28 (unreleased), credited to you.
  • cargo test -p adom-fusion-bridge: 90 passed, including your role-map and zip-reader tests and master's silk tests.

It ships in the next insiders build. I'll run it on a board with MP markers before it goes public.

Comments

AdityaAngajala · 1mo ago

Added to this PR, as Aditya decided on 2026-10-08 (board-look EXPORTERS.md, Decision 6): every Fusion export now writes the fab files and an export record.

What changed (services.rs, describe.py/contract, CHANGELOG, skills/fusion-molecule-export, rust/bridge/Cargo.toml plus a one-line Cargo.lock change; zip was already in the workspace lock):

  • New gerbers arg, default true. While the 2D board is active for the .brd, the verb runs the bridge's own export_gerbers (the bundled JLCPCB 2- or 4-layer CAM job, not the user's) and writes <stem>-fab.zip. With gerbers:false, export.json records that they were left out and why.
  • export.json beside the outputs: schema adom.export/1; tool and toolVersion (from the .gbrjob GenerationSoftware, "Fusion Electronics 9.7.0"); exporter and exporterVersion; every source file with bytes, sha256 and role (step, board_eagle_brd, silk PNGs, fab_zip, and each zip entry: copper_top/bottom/inner_n, mask, silk, paste, outline, drill_mixed, gerber_job); drill.plating: "mixed" (Fusion's CAM writes one Excellon for plated and unplated holes); and the stackup the job file knows (thicknessMm, copperLayers, sizeMm; mask/silk colour and finish are null because Fusion does not export them).
  • Nothing in the STEP or the Gerbers is renamed, so the hashes describe the design.
  • Replies (sync, fetch, and async status) carry fabZipPath, exportRecordPath, gerbersNote and exportRecordNote. A missing fab zip is named in the _hint.

Note: Fusion's CAM writes RS-274X without X2 file attributes, so roles come from the bundled CAM job's extensions. Unit tests cover the role map and the zip reader (cargo test: 33 passed).

Verified on H2O, dev-pinned, same board (Servo Quick Realse Molecule), fusion_export_optimized_glb {outputPath:"C:/tmp/glbtest/after2/servo.glb", async:true}, 65 s:

  • files: servo.glb, servo.step, servo.brd, servo-fab.zip (9 Gerbers, 1 .XLN, 1 .gbrjob), export.json, servo_footprint.json, servo_symbol.json, both silk PNGs
  • gates: footprintApplied true, footprintPins 4, contactZ 8.4, pinSize large (markers), warnings []
  • export.json checked independently: sha256 of servo.step, servo.brd, servo-fab.zip and all 11 zip entries recomputed in the container and all match.

H2O is back on the released 1.11.39 and unpinned.

Log in to comment.