app
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.
← Commit history
Merge PR #17 (Aditya Angajala): export_optimized_glb produces a publishable molecule
5 files changed
+246−22
CHANGELOG.md+18−1@@ -3,7 +3,24 @@ 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` -## 1.13.27 (unreleased)+## 1.13.28 (unreleased)++From Aditya Angajala (PR #17): `fusion_export_optimized_glb` 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.+- 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`, `webGlbPath` and the molecule-publish `gates`+ (pinSize, pinSizeSource, footprintApplied, footprintPins, footprintRebase, contactZ, warnings); the `_hint`+ names any gate that is not met.+- Every export also writes `<stem>-fab.zip`, the Gerber + drill output of the bridge's own JLCPCB CAM job (new+ `gerbers` arg, default true; Fusion's one Excellon file is marked mixed plating), and `export.json`: tool and+ version from the .gbrjob, exporter version, every source file with sha256 and role (STEP, .brd, silk PNGs, the+ zip and each file inside it) and the stackup the Gerber job knows. Nothing in the STEP or Gerbers is renamed.+ Replies carry `fabZipPath` and `exportRecordPath`. Merged onto master's silk cleanup (#113) and `compress` check.++## 1.13.27 (2026-10-30) From Drew Owens (PR #28): the mechanical layer on big assemblies, profiled inside Fusion on a 4,579-occurrence production assembly built from 23 referenced designs.
contracts/describe.json+18−4@@ -2472,25 +2472,34 @@ }, "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": "draco (default) | quantize | none: none gives a plain GLB that wiki README and widget frames can load (they cannot reach a Draco decoder)",+ "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",+ "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",+ "exportRecordPath": "str|null (export.json beside the outputs)",+ "fabZipPath": "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. 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 }, {@@ -2665,6 +2674,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)",@@ -2673,10 +2683,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@@ -920,8 +920,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", "compress": "draco (default) | quantize | none: none gives a plain GLB that wiki README and widget frames can load (they cannot reach a Draco decoder)", "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. 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": "draco (default) | quantize | none: none gives a plain GLB that wiki README and widget frames can load (they cannot reach a Draco decoder)", "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"}), ("fusion_export_dxf", "Board layers to DXF, one file per layer (the standard's export_dxf), when layers is given: the outline (Edge.Cuts: Dimension and Milling), copper (tracks as polylines carrying their width, arcs, vias, pads, Fusion's pour fill), silkscreen and fab (footprint and board lines, circles, rectangles, polygons, texts the readable way up). DXF R12, mm or inches, the board's origin, y up. Without layers it is Fusion's mechanical DXF of the active design (outputPath).",
rust/bridge/src/services.rs+193−11@@ -85,9 +85,12 @@ pub(crate) fn clean_silk_png(bytes: &[u8]) -> Option<Vec<u8>> { Some(png_out) } -fn glb_submit(step_path: &str, silk_top: Option<&str>, silk_bottom: Option<&str>, pin: &str, compress: Option<&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(raw) = std::fs::read(p) { let b = clean_silk_png(&raw).unwrap_or(raw); 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 mut url = format!("{}/convert?molecule=true&pin={}", step2glb_url(), fusion_core::aps::urlencode(pin));@@ -114,8 +117,106 @@ 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("; "))) })+}+++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).@@ -124,6 +225,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 want_gerbers = args["gerbers"].as_bool().unwrap_or(true); let compress = args["compress"].as_str().filter(|c| !c.is_empty()).map(str::to_lowercase); if let Some(c) = &compress { if !matches!(c.as_str(), "draco" | "quantize" | "none") { return json!({"success": false, "errorCode": "bad_args", "error": format!("compress is draco, quantize or none, not {c}"), "_hint": "compress:\"none\" gives a plain GLB that wiki README and widget frames can load (they cannot reach a Draco decoder)."}); } } let _ = pass("show_3d_board", json!({}));@@ -137,8 +240,41 @@ 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"))); }+ }+ // 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, silk_top.as_deref(), silk_bottom.as_deref(), &pin, compress.as_deref(), &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);@@ -146,12 +282,17 @@ 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"], "compress": compress.as_deref().unwrap_or("draco (the molecule default)"), "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, "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, "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."}) }@@ -166,7 +307,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"))})@@ -200,7 +347,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", "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; }@@ -219,7 +368,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", "fabZipPath", "gerbersNote", "exportRecordPath", "exportRecordNote", "footprintPath", "symbolPath", "webGlbPath", "_hint"] { if !st["result"][k].is_null() { out[k] = st["result"][k].clone(); } } } out } @@ -331,6 +480,39 @@ 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);+ }+}+ #[cfg(test)] mod silk_tests { use super::clean_silk_png;
skills/fusion-molecule-export/SKILL.md+15−4@@ -93,16 +93,27 @@ 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.+# 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, 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. 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.