← Commit history

fusion_export_optimized_glb: send the .brd, fetch footprint/symbol/weblod, report publish gates, add compress

AdityaAngajala ·8a3bc56785 ·1mo ago ·parent 6372b5f
5 files changed +114−23
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.