Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
fusion_export_optimized_glb: produce a publishable molecule (board, footprint/symbol JSON, gates) #17
fix/optimized-glb-board-footprint → masterWhy
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
boardarg, default true. The verb switches to the 2D board, runsexport_eagle_sourceto<stem>.brd, switches back to the 3D board, and sends the file as theboardmultipart part. If the .brd export fails, the GLB is still built, andboardNotesays why there is no footprint. - New optional
compressarg (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,/symboland/weblod, and writes<stem>_footprint.json,<stem>_symbol.jsonand<stem>.web.glbbeside 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,webGlbPathandgates.gatesholds pinSize, pinSizeSource, footprintApplied, footprintPins, footprintRebase, contactZ and warnings. When a gate is not met, the_hintstarts with "Publish gates NOT met" and names it. Fetch-by-jobId takes an optionalboardSent; the async path passes it.
- New
describe.pyandcontracts/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-desktopCLI name andfusion_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
Comments
Log in to comment.