← Commit history

fusion_export_optimized_glb: Gerber fab zip + export.json on every export

AdityaAngajala ·f5b93a31ef ·1mo ago ·parent 8a3bc56
7 files changed +150−8
CHANGELOG.md+8
@@ -15,6 +15,14 @@ Canonical: https://wiki.adom.inc/adom/fusion-bridge ยท GitHub backup: `adom-inc/ - 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.+- Every export also writes the fab files and an export record (board-look EXPORTERS.md, Decision 6 and+  Aditya 2026-10-08). `<stem>-fab.zip` is the Gerber + drill output of the bridge's own JLCPCB CAM job (new+  `gerbers` arg, default true). Fusion's CAM writes one Excellon file for plated and unplated holes, so it is+  marked mixed. `export.json` beside the outputs lists the tool and version (from the .gbrjob), the exporter+  version, every source file with its sha256 and role (the STEP, the .brd, the silk PNGs, the zip and each+  Gerber, drill and job file inside it) and the stackup the Gerber job knows (thickness, copper layers, size).+  Nothing is renamed in the STEP or the Gerbers, so the hashes describe the design. Replies carry `fabZipPath`+  and `exportRecordPath`.  ## 1.11.9 (2026-09-29) 
contracts/describe.json+4−1
@@ -1391,6 +1391,7 @@         "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)",+        "gerbers": "bool default true (Gerber + Excellon from the bundled CAM job as <stem>-fab.zip; false is recorded in export.json)",         "outputPath": "required str (.glb)",         "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",@@ -1400,6 +1401,8 @@       "name": "fusion_export_optimized_glb",       "output": {         "boardPath": "str|null",+        "exportRecordPath": "str|null (export.json beside the outputs)",+        "fabZipPath": "str|null",         "footprintPath": "str|null",         "gates": "object",         "glbPath": "str",@@ -1413,7 +1416,7 @@         "webGlbPath": "str|null"       },       "statusVerb": null,-      "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.",+      "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. Every export also writes <stem>-fab.zip (Gerber + mixed-plating Excellon + .gbrjob from the bundled JLCPCB CAM job) and export.json (tool and version, exporter version, every source file with sha256 and role including each file in the zip, and the stackup from the Gerber job), per board-look EXPORTERS.md. Nothing in the STEP or the Gerbers is renamed: ~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     },     {
describe.py+1−1
@@ -319,7 +319,7 @@ _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, 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_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. Every export also writes <stem>-fab.zip (Gerber + mixed-plating Excellon + .gbrjob from the bundled JLCPCB CAM job) and export.json (tool and version, exporter version, every source file with sha256 and role including each file in the zip, and the stackup from the Gerber job), per board-look EXPORTERS.md. Nothing in the STEP or the Gerbers is renamed: ~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)", "gerbers": "bool default true (Gerber + Excellon from the bundled CAM job as <stem>-fab.zip; false is recorded in export.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", "fabZipPath": "str|null", "exportRecordPath": "str|null (export.json beside the outputs)", "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"}),
rust/Cargo.lock+1
@@ -27,6 +27,7 @@ dependencies = [  "sha2",  "tiny_http",  "ureq",+ "zip", ]  [[package]]
rust/bridge/Cargo.toml+1
@@ -13,6 +13,7 @@ path = "src/main.rs" roxmltree = "0.20" regex = "1" sha2 = "0.10"+zip = { version = "2", default-features = false, features = ["deflate"] } getrandom = "0.2" base64 = "0.22" png = "0.17"
rust/bridge/src/services.rs+132−5
@@ -114,6 +114,74 @@ fn molecule_gates(stats: &Value, board_sent: bool) -> (Value, Option<String>) {     (g, if stop.is_empty() { None } else { Some(format!("Publish gates NOT met: {}.", stop.join("; "))) }) } ++fn sha256_hex(bytes: &[u8]) -> String { use sha2::{Digest, Sha256}; Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect() }++/// The role of one CAM output, from the extension the bridge's bundled JLCPCB CAM jobs write. Fusion's CAM+/// processor writes RS-274X without X2 file attributes and its .gbrjob carries no FilesAttributes, so the+/// extension is the only reliable signal. Fusion cannot split plated and unplated holes, so the one Excellon+/// file is `drill_mixed` and normalize decides plating from the copper around each hole.+fn fab_role(name: &str) -> &'static str {+    let ext = name.rsplit('.').next().unwrap_or("").to_ascii_uppercase();+    match ext.as_str() {+        "GTL" => "copper_top", "GBL" => "copper_bottom", "G1" => "copper_inner_1", "G2" => "copper_inner_2",+        "GTS" => "mask_top", "GBS" => "mask_bottom", "GTO" => "silk_top", "GBO" => "silk_bottom",+        "GTP" => "paste_top", "GBP" => "paste_bottom", "GKO" => "outline", "XLN" | "DRL" => "drill_mixed",+        "GBRJOB" => "gerber_job", _ => "other",+    }+}++/// Every entry of the fab zip with its sha256 and role, plus the parsed Gerber job file.+fn read_fab_zip(path: &str) -> Result<(Vec<Value>, Value), String> {+    let f = std::fs::File::open(path).map_err(|e| format!("open {path}: {e}"))?;+    let mut z = zip::ZipArchive::new(f).map_err(|e| format!("read zip {path}: {e}"))?;+    let (mut entries, mut job) = (Vec::new(), Value::Null);+    for i in 0..z.len() {+        let mut e = z.by_index(i).map_err(|e| e.to_string())?;+        if e.is_dir() { continue; }+        let name = e.name().to_string();+        let mut buf = Vec::new();+        std::io::Read::read_to_end(&mut e, &mut buf).map_err(|e| e.to_string())?;+        let role = fab_role(&name);+        if role == "gerber_job" { job = serde_json::from_slice(&buf).unwrap_or(Value::Null); }+        let format = match role { "drill_mixed" => "excellon", "gerber_job" => "gerber_job_json", "other" => "unknown", _ => "gerber_rs274x" };+        entries.push(json!({"path": name, "role": role, "format": format, "bytes": buf.len(), "sha256": sha256_hex(&buf)}));+    }+    Ok((entries, job))+}++fn file_entry(path: &str, role: &str) -> Option<Value> {+    let b = std::fs::read(path).ok()?;+    let name = std::path::Path::new(path).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default();+    Some(json!({"path": name, "role": role, "bytes": b.len(), "sha256": sha256_hex(&b)}))+}++/// `export.json`, the export record the board-look pipeline reads (board-look EXPORTERS.md, Aditya 2026-10-08):+/// tool and version, exporter version, every source file written with its sha256 and role (each Gerber and drill+/// file inside the fab zip too), and the stackup the tool knows. Nothing in the STEP or the Gerbers is renamed,+/// so the hashes describe the design as Fusion wrote it.+fn write_export_record(dir: &std::path::Path, design: &str, sources: &[Option<Value>], fab: Option<(&str, &[Value], &Value)>, gerbers_note: Option<&str>) -> Result<String, String> {+    let mut files: Vec<Value> = sources.iter().flatten().cloned().collect();+    let (mut tool_version, mut stackup) = (Value::Null, Value::Null);+    if let Some((zip_path, entries, job)) = fab {+        if let Some(mut z) = file_entry(zip_path, "fab_zip") { z["entries"] = json!(entries); files.push(z); }+        let gs = &job["Header"]["GenerationSoftware"];+        if gs.is_object() { tool_version = json!(format!("{} {}", gs["Application"].as_str().unwrap_or(""), gs["Version"].as_str().unwrap_or("")).trim().to_string()); }+        stackup = json!({"thicknessMm": job["Overall"]["BoardThickness"], "copperLayers": job["Overall"]["LayerNumber"], "sizeMm": job["Overall"]["Size"],+            "maskColour": {"top": null, "bottom": null}, "silkColour": {"top": null, "bottom": null}, "finish": null,+            "source": "gerber_job.gbrjob (Fusion does not export mask, silk colour or finish)"});+    }+    let rec = json!({"schema": "adom.export/1", "tool": "Autodesk Fusion", "toolVersion": tool_version,+        "exporter": "adom/fusion-bridge fusion_export_optimized_glb", "exporterVersion": crate::VERSION,+        "exportedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true), "design": design,+        "drill": {"plating": "mixed", "note": "Fusion's CAM processor writes one Excellon file for plated and unplated holes."},+        "gerbers": gerbers_note.map(|n| json!({"included": false, "reason": n})).unwrap_or(json!({"included": true, "format": "RS-274X (no X2 attributes)", "camJob": "bundled jlcpcb-2layers/4layers.cam"})),+        "stackup": stackup, "files": files});+    let p = dir.join("export.json");+    std::fs::write(&p, serde_json::to_string_pretty(&rec).unwrap_or_default()).map_err(|e| format!("write export.json: {e}"))?;+    Ok(p.to_string_lossy().replace('\\', "/"))+}+ /// `fusion_export_optimized_glb` (the synchronous form). pub fn export_optimized_glb(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {     let Some(mut out) = args["outputPath"].as_str().or(args["output_path"].as_str()).map(str::to_string).filter(|s| !s.is_empty()) else { return json!({"success": false, "error": "No outputPath specified.", "_hint": "Usage: fusion_export_optimized_glb {\"outputPath\":\"C:/tmp/board.glb\", \"silkscreen\":true, \"pin\":\"medium\"}"}) };@@ -121,6 +189,7 @@ pub fn export_optimized_glb(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -     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 want_gerbers = args["gerbers"].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]);@@ -140,8 +209,32 @@ pub fn export_optimized_glb(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -         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!({}));     }+    // Gerber + drill from the CAM job shipped with the bridge (not the user's), as <stem>-fab.zip, on every+    // export by default (Decision 6, board-look 3D_PIPELINE.md). Needs the 2D board, like the .brd.+    let (mut fab_path, mut fab_entries, mut fab_job, mut gerbers_note): (Option<String>, Vec<Value>, Value, Option<String>) = (None, Vec::new(), Value::Null, None);+    if want_gerbers {+        if !want_board { let _ = pass("show_2d_board", json!({})); }+        let tmp = std::env::temp_dir().join(format!("adom-fab-{}", (now_f() * 1000.0) as u64)).to_string_lossy().replace('\\', "/");+        let gr = pass("export_gerbers", json!({"outputDir": tmp}));+        let src = gr["data"]["zipPath"].as_str().or(gr["zipPath"].as_str()).map(str::to_string);+        let dst = format!("{}-fab.zip", &out[..out.len() - 4]);+        match src.filter(|_| gr["success"].as_bool() == Some(true)).map(|s| std::fs::copy(&s, &dst).map(|_| s)) {+            Some(Ok(s)) => {+                let _ = std::fs::remove_file(&s); let _ = std::fs::remove_dir(&tmp);+                match read_fab_zip(&dst) { Ok((e, j)) => { fab_entries = e; fab_job = j; fab_path = Some(dst); } Err(e) => gerbers_note = Some(format!("fab zip written but unreadable: {e}")) }+            }+            Some(Err(e)) => gerbers_note = Some(format!("could not copy the fab zip: {e}")),+            None => gerbers_note = Some(format!("Gerber export failed: {}", gr["error"].as_str().or(gr["message"].as_str()).unwrap_or("None"))),+        }+    } else { gerbers_note = Some("gerbers:false was passed".into()); }+    if want_board || want_gerbers { let _ = pass("show_3d_board", json!({})); }+    // The export record covers the SOURCE files this verb wrote (the GLB and footprint JSON are derived).+    let out_dir = std::path::Path::new(&out).parent().map(|p| p.to_path_buf()).unwrap_or_default();+    let design = std::path::Path::new(&out).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();+    let sources = [file_entry(&step_path, "step"), board_path.as_deref().and_then(|p| file_entry(p, "board_eagle_brd")),+        silk_top.as_deref().and_then(|p| file_entry(p, "silk_png_top")), silk_bottom.as_deref().and_then(|p| file_entry(p, "silk_png_bottom"))];+    let (export_record, record_note) = match write_export_record(&out_dir, &design, &sources, fab_path.as_deref().map(|p| (p, fab_entries.as_slice(), &fab_job)), gerbers_note.as_deref()) { Ok(p) => (Some(p), None), Err(e) => (None, Some(e)) };     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, 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."}); }@@ -152,15 +245,16 @@ pub fn export_optimized_glb(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -     if fetched["status"].as_str() == Some("complete") && !fetched["wrote"].is_null() {         let stats = &fetched["stats"]; let size = fetched["glbBytes"].as_u64().unwrap_or(0);         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,+        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, "fabZipPath": fab_path, "gerbersNote": gerbers_note, "exportRecordPath": export_record, "exportRecordNote": record_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(""))); }+        if let Some(g) = gerbers_note.as_deref() { if want_gerbers { r["_hint"] = json!(format!("No fab zip ({g}); every export should carry the Gerbers. {}", 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, "boardPath": board_path, "boardNote": board_note, "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, "fabZipPath": fab_path, "gerbersNote": gerbers_note, "exportRecordPath": export_record, "exportRecordNote": record_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."}) }@@ -217,7 +311,7 @@ pub fn start_glb_export_async(ctx: &Ctx, args: &Value) -> Value {         while Instant::now() < deadline {             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(); } }+            for k in ["boardPath", "boardNote", "stepPath", "fabZipPath", "gerbersNote", "exportRecordPath", "exportRecordNote"] { 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; }@@ -236,7 +330,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", "gates", "boardPath", "boardNote", "footprintPath", "symbolPath", "webGlbPath", "_hint"] { 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", "fabZipPath", "gerbersNote", "exportRecordPath", "exportRecordNote", "footprintPath", "symbolPath", "webGlbPath", "_hint"] { if !st["result"][k].is_null() { out[k] = st["result"][k].clone(); } } }     out } @@ -347,3 +441,36 @@ pub fn set_design_rules(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> V     }     res }++#[cfg(test)]+mod fab_tests {+    use super::*;+    #[test]+    fn fab_roles_follow_the_bundled_cam_jobs() {+        for (n, r) in [("b.GTL", "copper_top"), ("b.GBL", "copper_bottom"), ("b_x.G1", "copper_inner_1"), ("b_x.G2", "copper_inner_2"),+            ("b.GTS", "mask_top"), ("b.GBS", "mask_bottom"), ("b.GTO", "silk_top"), ("b.GBO", "silk_bottom"), ("b.GTP", "paste_top"),+            ("b.GBP", "paste_bottom"), ("b.GKO", "outline"), ("b.XLN", "drill_mixed"), ("b_drill.XLN", "drill_mixed"),+            ("Adom/Main/GerberJob/gerber_job.gbrjob", "gerber_job"), ("readme.txt", "other"), ("x.pdf", "other")] {+            assert_eq!(fab_role(n), r, "{n}");+        }+    }+    #[test]+    fn fab_zip_entries_carry_sha256_and_the_job_file() {+        let p = std::env::temp_dir().join(format!("fabtest-{}.zip", std::process::id()));+        {+            let f = std::fs::File::create(&p).unwrap();+            let mut z = zip::ZipWriter::new(f);+            let o = zip::write::SimpleFileOptions::default();+            z.start_file("b.GTL", o).unwrap(); std::io::Write::write_all(&mut z, b"G04*").unwrap();+            z.start_file("job/gerber_job.gbrjob", o).unwrap();+            std::io::Write::write_all(&mut z, br#"{"Header":{"GenerationSoftware":{"Application":"Fusion Electronics","Version":"9.7.0"}},"Overall":{"BoardThickness":1.67,"LayerNumber":2}}"#).unwrap();+            z.finish().unwrap();+        }+        let (e, job) = read_fab_zip(p.to_str().unwrap()).unwrap();+        let _ = std::fs::remove_file(&p);+        assert_eq!(e.len(), 2);+        assert_eq!(e[0]["role"], "copper_top");+        assert_eq!(e[0]["sha256"], sha256_hex(b"G04*"));+        assert_eq!(job["Overall"]["BoardThickness"], 1.67);+    }+}
skills/fusion-molecule-export/SKILL.md+3−1
@@ -96,9 +96,11 @@ request timeout), so it is submit-then-poll, never one long blocking call: 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.+#    gerbers:true (default) writes <stem>-fab.zip (Gerber + Excellon + .gbrjob from the bundled CAM job) and+#    export.json (every source file with sha256 + role, Fusion version, stackup) beside the outputs. #    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)+#    -> { status:"complete", glbPath, boardPath, fabZipPath, exportRecordPath, 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.