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
export_manufacturing, pcb_report
7 files changed
+193−1
CHANGELOG.md+5@@ -5,6 +5,11 @@ Canonical: https://wiki.adom.inc/adom/fusion-bridge ยท GitHub backup: `adom-inc/ ## 1.13.38 (unreleased) +- `fusion_export_manufacturing` (the standard's export_manufacturing): silkscreen prep, Gerbers and drill (JLCPCB CAM+ job), pick and place with JLCPCB's CPL, the BOM order files, and ODB++ / IPC-2581 with `formats`, in one folder.+- `fusion_pcb_report` (the standard's pcb_report): a .lbr's footprints (pads, pad bounding box in mil, primitive+ census, 3D packages) offline, or the open board's outline, parts, nets, census and tracks.+ - `fusion_panel`: a panel the editor in front does not have is refused with that said (Fusion lists panels per editor; the 1.13.37 battery met the 3D design in front, which has no Inspector), and the battery opens Browser.
bridge.json+2@@ -139,6 +139,7 @@ "fusion_export_ipc356", "fusion_export_lbr", "fusion_export_library_part",+ "fusion_export_manufacturing", "fusion_export_obj", "fusion_export_odb", "fusion_export_optimized_glb",@@ -235,6 +236,7 @@ "fusion_panel", "fusion_panelize", "fusion_part_parameters",+ "fusion_pcb_report", "fusion_physical_properties", "fusion_pin_pad_check", "fusion_place_decoupling",
contracts/describe.json+49@@ -2535,6 +2535,34 @@ "summary": "One part of a .lbr into its own small library (the standard's export_library_part): the deviceset with only the symbols its gates use and the packages its devices use (or only one footprint). Offline, no Fusion. A Fusion library holds symbol and footprint together, so both exports name the one file; 3D packages are cloud items, listed by URN.", "timeoutSeconds": 30 },+ {+ "example": {+ "formats": [+ "odb",+ "ipc2581"+ ]+ },+ "input": {+ "clean": "bool, default true: empty the folder first",+ "expectDocument": "the board's name (required)",+ "formats": "[odb, ipc2581]",+ "outputDir": "folder (default %TEMP%/adom-manufacturing/<board>)",+ "silkPrep": "bool, default true"+ },+ "longRunning": true,+ "name": "fusion_export_manufacturing",+ "output": {+ "dialogs": "[] (none raised)",+ "elapsedSec": "float",+ "files": "[relative paths]",+ "issues": "[{from, what}]",+ "outputDir": "str",+ "steps": "{silkPrep, gerbers, bom, odb?, ipc2581?}"+ },+ "statusVerb": null,+ "summary": "The manufacturing set in one call, in one folder (the standard's export_manufacturing): silkscreen prep first (silkPrep, default true: designators off the pads), the Gerbers and NC drill from the bundled JLCPCB CAM job (zipped, under gerber/), pick and place in CSV mm with JLCPCB's CPL (assembly/), the BOM and its order files (bom/), and with formats ODB++ (.tgz) and IPC-2581 beside them. Every step that fails is named in issues; nothing opens a dialog.",+ "timeoutSeconds": 420+ }, { "example": { "outputPath": "C:/out/part.obj"@@ -19916,6 +19944,27 @@ "summary": "The attribute names the design's placed parts carry, with how many carry each (the standard's part_parameters); with name, that attribute's value on every part.", "timeoutSeconds": 120 },+ {+ "example": {+ "path": "C:/libs/parts.lbr"+ },+ "input": {+ "expectDocument": "the board's name (board form)",+ "limit": "int, tracks listed (default 2000)",+ "name": "footprint name filter (library form)",+ "path": "a .lbr on the Fusion machine (library form); omitted, the open board",+ "want": "'board' for the board form"+ },+ "longRunning": false,+ "name": "fusion_pcb_report",+ "output": {+ "data": "{kind: library, components: [{name, pads, padsBboxMil, census, bodies}], bodyCols} | {kind: board, outlineMil, components: [{designator, footprint, pads, padsBboxMil, nets, bodies}], census, tracks, bodyCols}",+ "roundtripMs": "int"+ },+ "statusVerb": null,+ "summary": "Numbers instead of pixels (the standard's pcb_report). For a .lbr (path), every footprint (or the ones name matches): its pads, the pads' bounding box in mil, a census of its primitives (smd, pad, wire, circle, polygon, text, hole ...) and its 3D packages. For the open board (no path, or want:'board'): the outline in mil, each part with its pads, nets and pad bounding box, the copper census and the tracks.",+ "timeoutSeconds": 120+ }, { "example": { "accuracy": "low"
describe.py+6@@ -237,6 +237,12 @@ _T = [ ("fusion_render_board_layers", "Chosen board layers as an image without a window (the standard's render_board_layers), through Fusion's EXPORT IMAGE: a preset (all, top_copper, bottom_copper, top_silkscreen, bottom_silkscreen, top_soldermask, bottom_soldermask, top_paste, bottom_paste, outline, drill, assembly_top, assembly_bottom, fabrication) or layers by the standard's copper names (F.Cu, In1.Cu .. In14.Cu, B.Cu) or Fusion's numbers; dpi, mirror, monochrome. png only: Fusion writes no svg, so format svg is refused by name.", {"preset": "a preset name, default all", "layers": "[layer] overriding preset", "format": "png (svg refused)", "dpi": "int, default 300 (max 600)", "mirror": "bool", "monochrome": "bool", "outputPath": "the png on the Fusion machine", "listPresets": "bool", "expectDocument": "the board's name"}, {"path": "str", "format": "png", "widthPx": "int", "heightPx": "int", "preset": "str or custom"}, 120, None, False, {"preset": "top_copper"}),+ ("fusion_export_manufacturing", "The manufacturing set in one call, in one folder (the standard's export_manufacturing): silkscreen prep first (silkPrep, default true: designators off the pads), the Gerbers and NC drill from the bundled JLCPCB CAM job (zipped, under gerber/), pick and place in CSV mm with JLCPCB's CPL (assembly/), the BOM and its order files (bom/), and with formats ODB++ (.tgz) and IPC-2581 beside them. Every step that fails is named in issues; nothing opens a dialog.",+ {"expectDocument": "the board's name (required)", "outputDir": "folder (default %TEMP%/adom-manufacturing/<board>)", "clean": "bool, default true: empty the folder first", "formats": "[odb, ipc2581]", "silkPrep": "bool, default true"},+ {"outputDir": "str", "files": "[relative paths]", "steps": "{silkPrep, gerbers, bom, odb?, ipc2581?}", "issues": "[{from, what}]", "dialogs": "[] (none raised)", "elapsedSec": "float"}, 420, None, True, {"formats": ["odb", "ipc2581"]}),+ ("fusion_pcb_report", "Numbers instead of pixels (the standard's pcb_report). For a .lbr (path), every footprint (or the ones name matches): its pads, the pads' bounding box in mil, a census of its primitives (smd, pad, wire, circle, polygon, text, hole ...) and its 3D packages. For the open board (no path, or want:'board'): the outline in mil, each part with its pads, nets and pad bounding box, the copper census and the tracks.",+ {"path": "a .lbr on the Fusion machine (library form); omitted, the open board", "name": "footprint name filter (library form)", "want": "'board' for the board form", "expectDocument": "the board's name (board form)", "limit": "int, tracks listed (default 2000)"},+ {"data": "{kind: library, components: [{name, pads, padsBboxMil, census, bodies}], bodyCols} | {kind: board, outlineMil, components: [{designator, footprint, pads, padsBboxMil, nets, bodies}], census, tracks, bodyCols}", "roundtripMs": "int"}, 120, None, False, {"path": "C:/libs/parts.lbr"}), ("fusion_bom", "The bill of materials (the standard's bom) through the shared engine: the schematic's parts with their attributes, the live board and the variant (variant names one; default the current), grouped one line per manufacturer part number, checked for what an order needs (missing MPN, maker, Mouser/DigiKey/LCSC numbers, lines mixing values, JLCPCB lines without LCSC, DNP, sheet and board mismatches), and the order files written to outputDir: bom.csv, bom-jlcpcb.csv, cpl-jlcpcb.csv, bom-mouser.csv, bom-digikey.csv.", {"expectDocument": "the board's name", "variant": "a variant's name (default the current one)", "boards": "int, default 1: multiplies quantities", "outputDir": "folder for bom.csv and bom-jlcpcb.csv", "write": "bool, default true"}, {"lines": "[{quantity, designators, value, comment, description, footprint, package, mpn, manufacturer, mouser, digikey, lcsc, libReference}]", "fittedParts": "int", "checks": "{missingMpn, missingManufacturer, missingPartNumbers, inconsistent, dnp, excludedKinds, sheetNotOnBoard, boardNotOnSheet}", "variants": "[str]", "alternates": "[{designator, alternatePart}]", "files": "[path] (write:false answers the files' text instead)", "outputDir": "str"}, 180, None, False, {}),
rust/bridge/src/main.rs+1−1@@ -567,7 +567,7 @@ fn main() { r["shell"] = json!("rust"); r }, "fusion_start" | "start" | "launch" if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let t0 = Instant::now(); let ind = timings::Indicator::start(&sd, "start", &caller); let mut r = launch::start(&ctx, &parsed["args"]); ind.stop(r["success"].as_bool() == Some(false)); timings::log_run(&sd, "start", t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r }, "fusion_open_lbr" | "open_lbr" if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let t0 = Instant::now(); let ind = timings::Indicator::start(&sd, "open_lbr", &caller); let mut r = orchestrations::open_lbr(&ctx, &parsed["args"], &|v, a| addin::pass_through(v, &a, &ctx)); ind.stop(r["success"].as_bool() == Some(false)); timings::log_run(&sd, "open_lbr", t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r },- v @ ("fusion_show_symbol" | "show_symbol" | "fusion_show_footprint" | "show_footprint" | "fusion_show_library" | "show_library" | "fusion_show_project" | "show_project" | "fusion_show_schematic" | "show_schematic" | "fusion_show_2d_board" | "show_2d_board" | "fusion_show_3d_board" | "show_3d_board" | "fusion_capture_library_views" | "capture_library_views" | "fusion_library_views" | "library_views" | "fusion_open_cloud_file" | "open_cloud_file" | "fusion_save_lbr" | "save_lbr" | "fusion_cleanup_cloud_files" | "cleanup_cloud_files" | "fusion_inspect_bodies" | "inspect_bodies" | "fusion_board_stackup" | "board_stackup" | "fusion_export_optimized_glb" | "export_optimized_glb" | "fusion_export_3d" | "export_3d" | "fusion_board_summary" | "board_summary" | "fusion_read_schematic" | "read_schematic" | "fusion_export_pos" | "export_pos" | "fusion_render_board_layers" | "render_board_layers" | "fusion_bom" | "bom" | "fusion_import_step" | "import_step" | "fusion_fetch_optimized_glb" | "fetch_optimized_glb" | "fusion_set_design_rules" | "set_design_rules" | "fusion_make_test_project" | "make_test_project" | "fusion_project_create" | "project_create" | "fusion_test_board" | "test_board" | "fusion_build_board" | "build_board" | "fusion_swap_footprint" | "swap_footprint" | "fusion_panelize" | "panelize" | "fusion_make_showcase_project" | "make_showcase_project" | "fusion_open_design" | "open_design" | "fusion_new_electronics_from_eagle" | "new_electronics_from_eagle" | "fusion_aps_open" | "aps_open" | "fusion_schematic" | "schematic" | "fusion_eagle_import_status" | "eagle_import_status" | "fusion_show_attributes" | "show_attributes") if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let cmd = v.strip_prefix("fusion_").unwrap_or(v); let t0 = Instant::now(); let ind = timings::Indicator::start(&sd, cmd, &caller); let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let r0 = match cmd { "show_symbol" => orchestrations::show_symbol(&ctx, &parsed["args"], &pass), "show_footprint" => orchestrations::show_footprint(&ctx, &parsed["args"], &pass), "show_library" => orchestrations::show_library(&ctx, &parsed["args"], &pass), "show_project" => match orchestrations::show_project(&ctx, &parsed["args"], &pass) { Some(r) => r, None => side("show_project", parsed["args"].clone(), 240) }, "capture_library_views" => small_verbs::apply_failure_dialogs(orchestrations::capture_library_views(&ctx, &parsed["args"], &pass), &caller, &pids), "library_views" => { let mut r = small_verbs::apply_failure_dialogs(orchestrations::capture_library_views(&ctx, &docs::library_views_args(&parsed["args"]), &pass), &caller, &pids); r["aliasOf"] = json!("fusion_capture_library_views"); r }, "open_cloud_file" => orchestrations::open_cloud_file(&ctx, &parsed["args"], &pass), "save_lbr" => small_verbs::merge_dialog_array(orchestrations::save_lbr(&ctx, &parsed["args"], &pass), &caller, &pids), "cleanup_cloud_files" => orchestrations::cleanup_cloud_files(&parsed["args"], &pass), "inspect_bodies" => orchestrations::inspect_bodies(&parsed["args"], &pass), "board_stackup" => services::board_stackup(&parsed["args"], &pass), "board_summary" => stdshapes::board_summary(&parsed["args"], &pass), "read_schematic" => stdshapes::read_schematic(&parsed["args"], &pass), "export_pos" => stdshapes::export_pos(&parsed["args"], &pass), "render_board_layers" => stdshapes::render_board_layers(&parsed["args"], &pass), "bom" => stdshapes::bom(&parsed["args"], &pass), "export_3d" => services::export_3d(&parsed["args"], &pass), "import_step" => if parsed["args"]["into"].as_str() == Some("new") { pass("import_step", parsed["args"].clone()) } else { services::import_step_with_board(&parsed["args"], &pass) }, "export_optimized_glb" => if parsed["args"]["async"].as_bool() == Some(true) { services::start_glb_export_async(&ctx, &parsed["args"]) } else { services::export_optimized_glb(&parsed["args"], &pass) }, "fetch_optimized_glb" => if parsed["args"]["exportId"].is_string() { services::glb_export_async_status(&parsed["args"]) } else { services::fetch_optimized_glb(&parsed["args"]) }, "set_design_rules" => services::set_design_rules(&ctx, &parsed["args"], &pass), "make_test_project" => small_verbs::merge_dialog_array(testproject::make_test_project(&ctx, &parsed["args"], &pass), &caller, &pids), "project_create" => small_verbs::merge_dialog_array(testproject::project_create(&ctx, &parsed["args"], &pass), &caller, &pids), "test_board" => small_verbs::merge_dialog_array(boardgen::test_board(&ctx, &parsed["args"], &pass), &caller, &pids), "build_board" => small_verbs::merge_dialog_array(boardgen::build_board(&ctx, &parsed["args"], &pass), &caller, &pids), "swap_footprint" => small_verbs::merge_dialog_array(boardgen::swap_footprint(&ctx, &parsed["args"], &pass), &caller, &pids), "panelize" => small_verbs::merge_dialog_array(boardgen::panelize(&ctx, &parsed["args"], &pass), &caller, &pids), "make_showcase_project" => small_verbs::merge_dialog_array(testproject::make_showcase_project(&ctx, &parsed["args"], &pass), &caller, &pids), "open_design" => orchestrations::open_design(&ctx, &parsed["args"], &pass), "new_electronics_from_eagle" => orchestrations::new_electronics_from_eagle(&ctx, &parsed["args"], &pass), "schematic" => orchestrations::schematic(&ctx, &parsed["args"], &pass), "eagle_import_status" => orchestrations::eagle_import_status(&ctx, &parsed["args"]), "show_attributes" => orchestrations::show_attributes(&ctx, &parsed["args"], &pass), "aps_open" => orchestrations::aps_open(&ctx, &parsed["args"], &pass), other => orchestrations::open_with_screenshot(&ctx, other, &parsed["args"], &pass) }; let mut r = small_verbs::merge_dialog_array(r0, &caller, &pids); small_verbs::add_image_delivery(&mut r); ind.stop(r["success"].as_bool() == Some(false)); timings::log_run(&sd, cmd, t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r },+ v @ ("fusion_show_symbol" | "show_symbol" | "fusion_show_footprint" | "show_footprint" | "fusion_show_library" | "show_library" | "fusion_show_project" | "show_project" | "fusion_show_schematic" | "show_schematic" | "fusion_show_2d_board" | "show_2d_board" | "fusion_show_3d_board" | "show_3d_board" | "fusion_capture_library_views" | "capture_library_views" | "fusion_library_views" | "library_views" | "fusion_open_cloud_file" | "open_cloud_file" | "fusion_save_lbr" | "save_lbr" | "fusion_cleanup_cloud_files" | "cleanup_cloud_files" | "fusion_inspect_bodies" | "inspect_bodies" | "fusion_board_stackup" | "board_stackup" | "fusion_export_optimized_glb" | "export_optimized_glb" | "fusion_export_3d" | "export_3d" | "fusion_board_summary" | "board_summary" | "fusion_read_schematic" | "read_schematic" | "fusion_export_pos" | "export_pos" | "fusion_render_board_layers" | "render_board_layers" | "fusion_bom" | "bom" | "fusion_export_manufacturing" | "export_manufacturing" | "fusion_pcb_report" | "pcb_report" | "fusion_import_step" | "import_step" | "fusion_fetch_optimized_glb" | "fetch_optimized_glb" | "fusion_set_design_rules" | "set_design_rules" | "fusion_make_test_project" | "make_test_project" | "fusion_project_create" | "project_create" | "fusion_test_board" | "test_board" | "fusion_build_board" | "build_board" | "fusion_swap_footprint" | "swap_footprint" | "fusion_panelize" | "panelize" | "fusion_make_showcase_project" | "make_showcase_project" | "fusion_open_design" | "open_design" | "fusion_new_electronics_from_eagle" | "new_electronics_from_eagle" | "fusion_aps_open" | "aps_open" | "fusion_schematic" | "schematic" | "fusion_eagle_import_status" | "eagle_import_status" | "fusion_show_attributes" | "show_attributes") if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let cmd = v.strip_prefix("fusion_").unwrap_or(v); let t0 = Instant::now(); let ind = timings::Indicator::start(&sd, cmd, &caller); let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let r0 = match cmd { "show_symbol" => orchestrations::show_symbol(&ctx, &parsed["args"], &pass), "show_footprint" => orchestrations::show_footprint(&ctx, &parsed["args"], &pass), "show_library" => orchestrations::show_library(&ctx, &parsed["args"], &pass), "show_project" => match orchestrations::show_project(&ctx, &parsed["args"], &pass) { Some(r) => r, None => side("show_project", parsed["args"].clone(), 240) }, "capture_library_views" => small_verbs::apply_failure_dialogs(orchestrations::capture_library_views(&ctx, &parsed["args"], &pass), &caller, &pids), "library_views" => { let mut r = small_verbs::apply_failure_dialogs(orchestrations::capture_library_views(&ctx, &docs::library_views_args(&parsed["args"]), &pass), &caller, &pids); r["aliasOf"] = json!("fusion_capture_library_views"); r }, "open_cloud_file" => orchestrations::open_cloud_file(&ctx, &parsed["args"], &pass), "save_lbr" => small_verbs::merge_dialog_array(orchestrations::save_lbr(&ctx, &parsed["args"], &pass), &caller, &pids), "cleanup_cloud_files" => orchestrations::cleanup_cloud_files(&parsed["args"], &pass), "inspect_bodies" => orchestrations::inspect_bodies(&parsed["args"], &pass), "board_stackup" => services::board_stackup(&parsed["args"], &pass), "board_summary" => stdshapes::board_summary(&parsed["args"], &pass), "read_schematic" => stdshapes::read_schematic(&parsed["args"], &pass), "export_pos" => stdshapes::export_pos(&parsed["args"], &pass), "render_board_layers" => stdshapes::render_board_layers(&parsed["args"], &pass), "bom" => stdshapes::bom(&parsed["args"], &pass), "export_manufacturing" => stdshapes::export_manufacturing(&parsed["args"], &pass), "pcb_report" => stdshapes::pcb_report(&parsed["args"], &pass), "export_3d" => services::export_3d(&parsed["args"], &pass), "import_step" => if parsed["args"]["into"].as_str() == Some("new") { pass("import_step", parsed["args"].clone()) } else { services::import_step_with_board(&parsed["args"], &pass) }, "export_optimized_glb" => if parsed["args"]["async"].as_bool() == Some(true) { services::start_glb_export_async(&ctx, &parsed["args"]) } else { services::export_optimized_glb(&parsed["args"], &pass) }, "fetch_optimized_glb" => if parsed["args"]["exportId"].is_string() { services::glb_export_async_status(&parsed["args"]) } else { services::fetch_optimized_glb(&parsed["args"]) }, "set_design_rules" => services::set_design_rules(&ctx, &parsed["args"], &pass), "make_test_project" => small_verbs::merge_dialog_array(testproject::make_test_project(&ctx, &parsed["args"], &pass), &caller, &pids), "project_create" => small_verbs::merge_dialog_array(testproject::project_create(&ctx, &parsed["args"], &pass), &caller, &pids), "test_board" => small_verbs::merge_dialog_array(boardgen::test_board(&ctx, &parsed["args"], &pass), &caller, &pids), "build_board" => small_verbs::merge_dialog_array(boardgen::build_board(&ctx, &parsed["args"], &pass), &caller, &pids), "swap_footprint" => small_verbs::merge_dialog_array(boardgen::swap_footprint(&ctx, &parsed["args"], &pass), &caller, &pids), "panelize" => small_verbs::merge_dialog_array(boardgen::panelize(&ctx, &parsed["args"], &pass), &caller, &pids), "make_showcase_project" => small_verbs::merge_dialog_array(testproject::make_showcase_project(&ctx, &parsed["args"], &pass), &caller, &pids), "open_design" => orchestrations::open_design(&ctx, &parsed["args"], &pass), "new_electronics_from_eagle" => orchestrations::new_electronics_from_eagle(&ctx, &parsed["args"], &pass), "schematic" => orchestrations::schematic(&ctx, &parsed["args"], &pass), "eagle_import_status" => orchestrations::eagle_import_status(&ctx, &parsed["args"]), "show_attributes" => orchestrations::show_attributes(&ctx, &parsed["args"], &pass), "aps_open" => orchestrations::aps_open(&ctx, &parsed["args"], &pass), other => orchestrations::open_with_screenshot(&ctx, other, &parsed["args"], &pass) }; let mut r = small_verbs::merge_dialog_array(r0, &caller, &pids); small_verbs::add_image_delivery(&mut r); ind.stop(r["success"].as_bool() == Some(false)); timings::log_run(&sd, cmd, t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r }, "fusion_state" | "state" if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: h.bridge_state_dir(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let mut r = orchestrations::fusion_state(&ctx, &parsed["args"]); r["shell"] = json!("rust"); r }, v @ ("fusion_make_3d_package" | "make_3d_package" | "fusion_build_library_3d" | "build_library_3d" | "fusion_bind_library_3d" | "bind_library_3d" | "fusion_prove_library" | "prove_library" | "fusion_attach_3d_package" | "attach_3d_package" | "fusion_show_3d_package" | "show_3d_package" | "fusion_import_part" | "import_part") if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let cmd = v.strip_prefix("fusion_").unwrap_or(v); let t0 = Instant::now(); *LONG_OP.lock().unwrap() = Some(cmd.to_string()); *LONG_OP_SINCE.lock().unwrap() = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0); let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let r0 = match cmd { "make_3d_package" => mint::make_3d_package(&ctx, &parsed["args"], &pass), "bind_library_3d" => mint::bind_library_3d(&ctx, &parsed["args"], &pass), "prove_library" => prove::prove_library(&ctx, &parsed["args"], &pass), "attach_3d_package" => mint::attach_3d_package(&ctx, &parsed["args"], &pass), "show_3d_package" => mint::show_3d_package(&ctx, &parsed["args"], &pass), "import_part" => partimport::import_part(&ctx, &parsed["args"], &pass), _ => mint::build_library_3d(&ctx, &parsed["args"], &pass) }; *LONG_OP.lock().unwrap() = None; let mut r = small_verbs::apply_failure_dialogs(r0, &caller, &pids); timings::log_run(&sd, cmd, t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r }, v @ ("fusion_apply_fusion_update" | "apply_fusion_update" | "fusion_tool_update" | "tool_update" | "fusion_window_info" | "window_info" | "fusion_install_fusion" | "install_fusion" | "fusion_uninstall" | "uninstall" | "fusion_prefs_open" | "prefs_open" | "fusion_prefs_close" | "prefs_close" | "fusion_generate_package" | "generate_package" | "fusion_mcp_status" | "mcp_status" | "fusion_mcp_tools" | "mcp_tools" | "fusion_mcp_call" | "mcp_call" | "fusion_mcp_resources" | "mcp_resources" | "fusion_mcp_enable" | "mcp_enable" | "fusion_library_manager" | "library_manager" | "fusion_managed_library_create" | "managed_library_create" | "fusion_managed_library_link" | "managed_library_link" | "fusion_managed_library_pull" | "managed_library_pull" | "fusion_managed_library_push" | "managed_library_push" | "fusion_managed_library_unlink" | "managed_library_unlink" | "fusion_signin" | "signin" | "fusion_signin_2fa" | "signin_2fa" | "fusion_web_signin" | "web_signin" | "fusion_aps_signin" | "aps_signin" | "fusion_demo" | "demo" | "fusion_demo_progress" | "demo_progress" | "fusion_demo_control" | "demo_control" | "fusion_electronics_query" | "electronics_query" | "fusion_view" | "view" | "fusion_interactive" | "interactive" | "fusion_narrate" | "narrate") if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: h.bridge_state_dir(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let cmd = v.strip_prefix("fusion_").unwrap_or(v); let mut r = match cmd { "apply_fusion_update" => maintenance::apply_fusion_update(&ctx, &pass), "tool_update" => maintenance::tool_update(&ctx, &parsed["args"], &pass), "window_info" => stdshapes::window_info(&ctx), "install_fusion" => maintenance::install_fusion(&ctx), "prefs_open" => maintenance::prefs_open(&ctx, &parsed["args"], &pass), "prefs_close" => maintenance::prefs_close(&ctx, &parsed["args"]), "generate_package" => epg::generate_package(&parsed["args"], VERSION, &pass),
rust/bridge/src/stdshapes.rs+127@@ -255,6 +255,123 @@ pub fn bom(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value { r } +/// `fusion_export_manufacturing`: the manufacturing set in one call, in one folder: silkscreen prep+/// first (silkPrep, default true: designators off the pads), the Gerbers and NC drill from the+/// bundled JLCPCB CAM job (zipped), pick and place (CSV, mm), the BOM and its order files, and with+/// `formats` ODB++ and IPC-2581 beside them. Every step that fails is named in `issues`; nothing+/// opens a dialog.+pub fn export_manufacturing(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let t0 = std::time::Instant::now();+ let doc = args["expectDocument"].clone();+ let base = json!({"expectDocument": doc});+ let stem = doc.as_str().unwrap_or("board").chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect::<String>();+ let dir = args["outputDir"].as_str().map(|s| s.replace('\\', "/")).unwrap_or_else(|| format!("{}/adom-manufacturing/{stem}", std::env::temp_dir().to_string_lossy().replace('\\', "/")));+ if args["clean"].as_bool() != Some(false) { let _ = std::fs::remove_dir_all(&dir); }+ if let Err(e) = std::fs::create_dir_all(&dir) { return json!({"success": false, "errorCode": "write_failed", "error": e.to_string()}); }+ let mut issues: Vec<Value> = Vec::new();+ let mut steps = serde_json::Map::new();+ if args["silkPrep"].as_bool() != Some(false) {+ let sp = crate::checks::silkscreen_prep(&base, pass);+ if sp["success"].as_bool() == Some(false) { issues.push(json!({"from": "silkscreen_prep", "what": sp["error"]})); }+ steps.insert("silkPrep".into(), json!({"moved": sp["moved"], "before": sp["before"], "after": sp["after"], "stillOnPads": sp["stillOnPads"]}));+ }+ let g = pass("export_gerbers", json!({"outputDir": format!("{dir}/gerber")}));+ let gd = if g["data"].is_object() { g["data"].clone() } else { g.clone() };+ if gd["zipPath"].is_null() { issues.push(json!({"from": "export_gerbers", "what": gd["error"].as_str().or(g["error"].as_str()).unwrap_or("the CAM job wrote no zip")})); }+ steps.insert("gerbers".into(), json!({"zip": gd["zipPath"], "files": gd["fileCount"]}));+ let pos = export_pos(&json!({"expectDocument": doc, "outputDir": format!("{dir}/assembly"), "fab": "jlcpcb"}), pass);+ if pos["success"].as_bool() != Some(true) { issues.push(json!({"from": "export_pos", "what": pos["error"]})); }+ let bm = bom(&json!({"expectDocument": doc, "outputDir": format!("{dir}/bom")}), pass);+ if bm["success"].as_bool() != Some(true) { issues.push(json!({"from": "bom", "what": bm["error"]})); }+ steps.insert("bom".into(), json!({"lines": bm["lineCount"], "fittedParts": bm["fittedParts"]}));+ for f in args["formats"].as_array().into_iter().flatten().filter_map(|x| x.as_str()) {+ let r = match f {+ "odb" => crate::routing::call("export_odb", &json!({"expectDocument": doc, "outputDir": format!("{dir}/odb"), "compression": "tgz"}), pass),+ "ipc2581" => crate::writers::call("export_ipc2581", &json!({"expectDocument": doc, "outputDir": format!("{dir}/ipc2581")}), pass),+ other => json!({"success": false, "error": format!("{other} is not a format here: odb or ipc2581")}),+ };+ if r["success"].as_bool() == Some(false) { issues.push(json!({"from": format!("export_{f}"), "what": r["error"]})); }+ steps.insert(f.into(), json!({"path": r["path"]}));+ }+ // every file written, relative to the folder+ fn walk(d: &std::path::Path, root: &std::path::Path, out: &mut Vec<String>) {+ if let Ok(rd) = std::fs::read_dir(d) { for e in rd.flatten() { let p = e.path(); if p.is_dir() { walk(&p, root, out); } else if let Ok(r) = p.strip_prefix(root) { out.push(r.to_string_lossy().replace('\\', "/")); } } }+ }+ let mut files = Vec::new();+ walk(std::path::Path::new(&dir), std::path::Path::new(&dir), &mut files);+ files.sort();+ json!({"success": issues.is_empty(), "mutated": steps.contains_key("silkPrep") && steps["silkPrep"]["moved"].as_u64().unwrap_or(0) > 0, "outputDir": dir, "files": files, "steps": steps,+ "issues": issues, "dialogs": [], "elapsedSec": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0,+ "errorCode": if issues.is_empty() { Value::Null } else { json!("manufacturing_incomplete") },+ "_hint": "Gerbers and drill are the zip under gerber/; assembly/ has pick and place (and JLCPCB's CPL); bom/ has the order files. silkPrep:false leaves the silkscreen as drawn."})+}++/// `fusion_pcb_report`: numbers instead of pixels. For a .lbr (path), every footprint: its pads, the+/// pads' bounding box in mil, a census of its primitives, and its 3D packages; for the open board+/// (no path, or want:'board'), the outline, each part with its pads, nets and bounding box, the census+/// and the tracks.+pub fn pcb_report(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let t0 = std::time::Instant::now();+ const MIL: f64 = 0.0254;+ let mil = |v: f64| (v / MIL * 10.0).round() / 10.0;+ let path = args["path"].as_str().or(args["libraryPath"].as_str()).map(|p| p.replace('\\', "/"));+ if let Some(path) = path.filter(|p| p.to_lowercase().ends_with(".lbr") && args["want"].as_str() != Some("board")) {+ let text = match std::fs::read_to_string(&path) { Ok(t) => t, Err(e) => return json!({"success": false, "errorCode": "library_unreadable", "error": format!("cannot read {path}: {e}")}) };+ let root = match fusion_lbr::Element::parse(&text) { Ok(r) => r, Err(e) => return json!({"success": false, "errorCode": "library_unreadable", "error": e.to_string()}) };+ fn find<'a>(e: &'a fusion_lbr::Element, tag: &str) -> Option<&'a fusion_lbr::Element> { if e.tag == tag { return Some(e); } e.children.iter().find_map(|c| find(c, tag)) }+ let want = args["name"].as_str().map(str::to_lowercase);+ // 3D packages: <packages3d><package3d><packageinstances><packageinstance name="PKG"/>+ let mut bodies: std::collections::HashMap<String, Vec<String>> = Default::default();+ if let Some(p3) = find(&root, "packages3d") {+ for p in p3.children.iter().filter(|c| c.tag == "package3d") {+ if let Some(pi) = p.children.iter().find(|c| c.tag == "packageinstances") {+ for i in pi.children.iter().filter_map(|c| c.attr("name")) { bodies.entry(i.to_string()).or_default().push(p.attr("name").unwrap_or("").to_string()); }+ }+ }+ }+ let num = |e: &fusion_lbr::Element, k: &str| e.attr(k).and_then(|v| v.parse::<f64>().ok()).unwrap_or(0.0);+ let mut comps = Vec::new();+ for pk in find(&root, "packages").map(|s| s.children.iter().filter(|c| c.tag == "package").collect::<Vec<_>>()).unwrap_or_default() {+ let name = pk.attr("name").unwrap_or("").to_string();+ if let Some(w) = &want { if !name.to_lowercase().contains(w) { continue; } }+ let mut census: std::collections::BTreeMap<String, u64> = Default::default();+ for c in &pk.children { if c.tag != "description" { *census.entry(c.tag.clone()).or_default() += 1; } }+ let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);+ let mut pads = 0;+ for c in pk.children.iter().filter(|c| c.tag == "smd" || c.tag == "pad") {+ pads += 1;+ let (x, y) = (num(c, "x"), num(c, "y"));+ let (hw, hh) = if c.tag == "smd" { (num(c, "dx") / 2.0, num(c, "dy") / 2.0) } else { let d = num(c, "diameter").max(num(c, "drill") * 1.5) / 2.0; (d, d) };+ let r = c.attr("rot").map(|r| r.trim_start_matches(|ch: char| ch.is_ascii_alphabetic()).parse::<f64>().unwrap_or(0.0)).unwrap_or(0.0).to_radians();+ let (ex, ey) = (hw * r.cos().abs() + hh * r.sin().abs(), hw * r.sin().abs() + hh * r.cos().abs());+ x0 = x0.min(x - ex); y0 = y0.min(y - ey); x1 = x1.max(x + ex); y1 = y1.max(y + ey);+ }+ let bbox = if pads > 0 { json!([mil(x0), mil(y0), mil(x1), mil(y1)]) } else { Value::Null };+ comps.push(json!({"name": name, "pads": pads, "padsBboxMil": bbox, "census": census, "bodies": bodies.get(&name).cloned().unwrap_or_default()}));+ }+ return json!({"success": true, "mutated": false, "data": {"kind": "library", "components": comps, "bodyCols": ["package3d"]}, "path": path, "roundtripMs": t0.elapsed().as_millis() as u64});+ }+ let rb = crate::routing::call("read_board", args, pass);+ if rb["success"].as_bool() != Some(true) { return rb; }+ let d = &rb["data"];+ let pads = d["copper"]["pads"].as_array().cloned().unwrap_or_default();+ let comps: Vec<Value> = d["components"]["components"].as_array().cloned().unwrap_or_default().iter().map(|c| {+ let r = c["ref"].as_str().unwrap_or("");+ let mine: Vec<&Value> = pads.iter().filter(|p| p["key"].as_str().map(|k| k.split('.').next() == Some(r)).unwrap_or(false)).collect();+ let xs: Vec<f64> = mine.iter().filter_map(|p| p["at"][0].as_f64()).collect();+ let ys: Vec<f64> = mine.iter().filter_map(|p| p["at"][1].as_f64()).collect();+ let bbox = if xs.is_empty() { Value::Null } else { json!([mil(xs.iter().cloned().fold(f64::MAX, f64::min)), mil(ys.iter().cloned().fold(f64::MAX, f64::min)), mil(xs.iter().cloned().fold(f64::MIN, f64::max)), mil(ys.iter().cloned().fold(f64::MIN, f64::max))]) };+ let mut nets: Vec<String> = mine.iter().filter_map(|p| p["net"].as_str().map(str::to_string)).filter(|n| !n.is_empty()).collect(); nets.sort(); nets.dedup();+ json!({"designator": r, "footprint": c["pattern"], "x": c["x"], "y": c["y"], "rotation": c["rotation"], "side": c["side"], "pads": mine.len(), "padsBboxMil": bbox, "nets": nets, "bodies": if c["has3d"] == true { json!(["package3d"]) } else { json!([]) }})+ }).collect();+ let census = json!({"tracks": d["copper"]["tracks"].as_array().map(|a| a.len()), "arcs": d["copper"]["arcs"].as_array().map(|a| a.len()), "vias": d["copper"]["vias"].as_array().map(|a| a.len()),+ "pads": pads.len(), "polygons": d["copper"]["polygons"].as_array().map(|a| a.len()), "components": comps.len()});+ let tracks: Vec<Value> = d["copper"]["tracks"].as_array().cloned().unwrap_or_default().into_iter().take(args["limit"].as_u64().unwrap_or(2000) as usize).collect();+ let outline: Vec<Value> = d["outline"]["outline"].as_array().cloned().unwrap_or_default().iter().map(|p| json!([mil(p[0].as_f64().unwrap_or(0.0)), mil(p[1].as_f64().unwrap_or(0.0))])).collect();+ json!({"success": true, "mutated": false, "data": {"kind": "board", "outlineMil": outline, "components": comps, "census": census, "tracks": tracks, "bodyCols": ["package3d"]},+ "documentName": rb["documentName"], "revision": rb["revision"], "roundtripMs": t0.elapsed().as_millis() as u64})+}+ #[cfg(test)] mod tests { use super::*;@@ -264,6 +381,16 @@ mod tests { assert_eq!(preset_layers("drill"), Some(vec![20, 44, 45])); } #[test]+ fn a_library_report_counts_pads_primitives_and_bodies() {+ let p = std::env::temp_dir().join(format!("adom-pr-{}.lbr", std::process::id()));+ std::fs::write(&p, r#"<eagle><drawing><library><packages><package name="R0603"><description>r</description><smd name="1" x="-0.8" y="0" dx="0.9" dy="1" layer="1"/><smd name="2" x="0.8" y="0" dx="0.9" dy="1" layer="1"/><wire x1="0" y1="0" x2="1" y2="0" layer="21"/></package></packages><packages3d><package3d name="R0603-3D"><packageinstances><packageinstance name="R0603"/></packageinstances></package3d></packages3d></library></drawing></eagle>"#).unwrap();+ let r = pcb_report(&json!({"path": p.to_string_lossy()}), &|_: &str, _: Value| json!({}));+ let c = &r["data"]["components"][0];+ assert_eq!((c["pads"].as_u64(), c["census"]["smd"].as_u64(), c["census"]["wire"].as_u64(), c["bodies"].clone()), (Some(2), Some(2), Some(1), json!(["R0603-3D"])));+ assert_eq!(c["padsBboxMil"], json!([-49.2, -19.7, 49.2, 19.7]));+ let _ = std::fs::remove_file(&p);+ }+ #[test] fn a_library_lists_its_components_or_its_footprints() { let p = std::env::temp_dir().join(format!("adom-ll-{}.lbr", std::process::id())); std::fs::write(&p, r#"<eagle><drawing><library><packages><package name="R0603"/><package name="SOT23"/></packages><symbols/><devicesets><deviceset name="R"/></devicesets></library></drawing></eagle>"#).unwrap();
tests/run_verb_tests.py+3@@ -450,6 +450,9 @@ t("fusion_bom", "daily", B, {"ok": True, "fields": ["lines", "checks", "fittedPa t("fusion_render_board_layers", "daily", dict(B, preset="top_copper", outputPath=f"{SCRATCH}/layers/top_copper.png"), {"ok": True, "fields": ["path", "widthPx"]}, "EXPORT IMAGE with the standard's preset") t("fusion_render_board_layers#svg", "negative", dict(B, format="svg"), {"errorCode": "not_supported"}, "Fusion writes no svg: refused by name") t("fusion_render_board_3d#framed", "daily", dict(B, side="top", refs=["IC1"], rotate="-45,0,45", outputPath=f"{SCRATCH}/render/framed.png"), {"ok": True, "fields": ["path", "framed"]}, "framed on a part and turned")+t("fusion_pcb_report", "daily", {"path": "C:/Users/olive/Documents/fusion-lane-tests/plate/plate-test.lbr"}, {"ok": True, "fields": ["data"]}, "offline .lbr report")+t("fusion_pcb_report#board", "daily", dict(B, want="board"), {"ok": True, "fields": ["data"]}, "the open board's report from read_board")+t("fusion_export_manufacturing", "manual", note="silkscreen prep moves designators on the board: tested live on lane-BQ25792-copy") t("fusion_log_tail", "daily", {"sinceOffset": 0, "maxBytes": 20000, "errorsOnly": True}, {"ok": True, "fields": ["entries", "next", "truncated"]}, "read-only: Fusion's own log since an offset") t("fusion_verb_times", "daily", {"limit": 20}, {"ok": True, "fields": ["verbs", "since"]}) t("fusion_live_feed", "daily", {"action": "poll"}, {"ok": True}, "one poll of the feed; no waiting")