← Commit history
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")