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
Update 14 file(s)
14 files changed
+350−52
CHANGELOG.md+14bridge.json+1describe.py+4−1rust/bridge/src/addin.rs+51−1rust/bridge/src/docs_script.py+9−3rust/bridge/src/orchestrations.rs+35−5rust/bridge/src/palette.rs+11−2rust/bridge/src/routing.rs+2−2rust/bridge/src/routing_script.py+85−14rust/bridge/src/savedoc_script.py+7−1rust/bridge/src/sch_script.py+26−20rust/bridge/src/small_verbs.rs+8−2tests/run_verb_tests.py+2−1tests/test_routing.py+95CHANGELOG.md+14@@ -3,6 +3,20 @@ 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` +## Next release++From Glenn Jones's reports on a 4-layer board:++`fusion_export_gerbers` takes the layer count from the board's own stack (#142). Inner planes that were only pours read as a 2-layer board, so the inner layers were left out while the reply said success. A count its CAM jobs don't cover is refused (`unsupported_layer_count`).++New `fusion_change_vias` (#145): drill and diameter of existing vias, by position or by a filter, with only the Vias layer displayed while picking, read back, undone on a mismatch.++A failed `fusion_apply_silkscreen` batch is really undone, free texts included, or says it isn't (#144). The undo stopped when the parts' names matched, which left the new texts on the board.++The "driving Fusion" panel comes down when the verb answers (#143). A second message while it was up stopped it ever closing.++A document with no visible tab no longer shifts the tab a board read switches to (#141). The read-back after an EAGLE import waits for the board to load (#139). The Select Library picker names a cloud copy as well as a file (#140).+ ## 1.13.70 (2026-11-04) `fusion_build_library_3d` re-exports faster: a part already minted on this machine and account, with the same model, footprint, placement and destination, reuses its 3D package when its cloud file still exists, so only new or changed parts mint. Parts say `reused: true`; the reply has `partsReused` and `partsMinted`, and `freshMint: true` makes them again. A deleted cloud file falls back to a fresh mint. The last `_3d` scratch document is closed once the library is open (`scratchClosedAfterOpen`).
bridge.json+1@@ -73,6 +73,7 @@ "fusion_cancel_run", "fusion_capture_library_views", "fusion_change_pours",+ "fusion_change_vias", "fusion_check_clearance", "fusion_check_dialogs", "fusion_check_orientation",
describe.py+4−1@@ -681,7 +681,10 @@ _T = [ ("fusion_place_parts", "Footprints from a .lbr onto a board with no schematic (the standard's place_parts), with position, rotation and side, read back. A board linked to a schematic is refused: Fusion takes its parts from the sheet (fusion_sch_place_part, then fusion_move_parts).", {"parts":"[{library, footprint, ref?, value?, x, y, rotation?, side?}] mm (required)","dryRun":"bool","expectDocument":"the board","expectedRevision":"optional"}, {"results":"[ok|err:...]","placed":"[{ref, x, y, rotation, side}]","revision":"str"},120,"fusion_routing_state",False,{"parts":[{"library":"C:/lib/parts.lbr","footprint":"R0603","ref":"R1","x":10,"y":10}],"dryRun":True}),- ("fusion_edit_tracks", "Change given tracks by the id routing_state returns: width, layer, ends (the standard's edit_tracks). Read back: only those tracks may differ, or every change is undone. An end moves only along the track's own line: at an angle Fusion bends the track into two segments, so that is undone and refused (end_move_bends). Change a track's layer and its ends in two calls.",+ ("fusion_change_vias", "Change existing vias, and nothing but vias: drill and diameter, by position or by a filter on what they are now. Only the Vias layer is displayed while they're picked, so a track end or pad at the same point can't be taken instead, and the board's layer display goes back after. Read back: only the asked vias may differ, or every change is undone.",+ {"vias":"[{x, y, drill?, diameter?}] mm: the vias at these points","filter":"{drill?, diameter?, net?}: every via matching, with set","set":"{drill?, diameter?} mm","dryRun":"bool","expectDocument":"the board","expectedRevision":"optional","boardId":"optional"},+ {"results":"[ok|err:...]","changes":"[{id, before, after}]","verified":"bool","revision":"str"},120,"fusion_routing_state",False,{}),+ ("fusion_edit_tracks", "Change given tracks by the id routing_state returns: width, layer, ends (the standard's edit_tracks). Read back: only those tracks may differ, or every change is undone. An end moved at an angle draws the track again and deletes the old one, as Fusion's MOVE would bend it into two segments. Change a track's layer and its ends in two calls.", {"tracks":"[{id, width?, layer?, start?, end?}] mm (required)","dryRun":"bool","expectDocument":"the board","expectedRevision":"optional"}, {"results":"[ok|err:...]","changes":"[{id, before, after}]","verified":"bool","revision":"str"},120,"fusion_routing_state",False,{}), ("fusion_model_check", "Per part, is there a 3D model and does its cloud file open for this account (the standard's model_check), for a board or a .lbr footprint. Fusion can't say whether one holds geometry without opening it, so hasGeometry is null. Read only.",
rust/bridge/src/addin.rs+51−1@@ -322,6 +322,9 @@ pub fn pass_through(cmd: &str, args: &Value, ctx: &Ctx) -> Value { if matches!(cmd, "open_board" | "open_schematic") { return crate::orchestrations::open_file_guarded(ctx, cmd, args, &|v: &str, a: Value| pass_through_checked(v, &a, ctx)); }+ if cmd == "export_gerbers" && !matches!(args["layers"].as_str(), Some("2") | Some("4")) {+ return export_gerbers_counted(args, ctx);+ } if cmd == "run_modeling_script" { let watch = crate::small_verbs::watch_for_open_notice(&ctx.caller, &ctx.pids); return crate::small_verbs::finish_notice_watch(watch, pass_through_checked(cmd, args, ctx));@@ -329,6 +332,34 @@ pub fn pass_through(cmd: &str, args: &Value, ctx: &Ctx) -> Value { pass_through_checked(cmd, args, ctx) } +/// Gerbers for as many copper layers as the board has. The add-in's detection counts layers carrying traces, so inner+/// planes that are only pours read as a 2-layer board, and the export dropped them and said success (#142). The board's+/// own stack says how many it has. When it can't be read, the add-in's detection still runs, and the reply says so.+fn export_gerbers_counted(args: &Value, ctx: &Ctx) -> Value {+ let pass = |v: &str, a: Value| pass_through_checked(v, &a, ctx);+ let doc = pass("get_app_state", json!({}))["data"]["activeDocument"].clone();+ let layers = crate::routing::call("copper_layers", &json!({"expectDocument": doc}), &pass);+ let count = gerber_layer_count(&layers);+ let mut a = args.clone();+ if !a.is_object() { a = json!({}); }+ match count {+ Some(n @ (2 | 4)) => { a["layers"] = json!(n.to_string()); }+ Some(n) => return json!({"success": false, "errorCode": "unsupported_layer_count", "layerCount": n, "layerSource": "board",+ "error": format!("This board has {n} copper layers and the bridge's Gerber jobs cover 2 and 4, so nothing was exported rather than leave layers out."),+ "_hint": "Export the rest with fusion_export_manufacturing, or Fusion's own CAM processor."}),+ None => {}+ }+ let mut r = pass_through_checked("export_gerbers", &a, ctx);+ if r.is_object() { r["layerSource"] = json!(if count.is_some() { "board" } else { "addin_detection" }); }+ if let (Some(n), Some(d)) = (count, r["data"].as_object_mut()) { d.insert("detectionMethod".into(), json!("board_stack")); d.insert("boardCopperLayers".into(), json!(n)); }+ r+}++fn gerber_layer_count(layers: &Value) -> Option<usize> {+ if layers["success"].as_bool() == Some(false) { return None; }+ layers["copperLayers"].as_array().or(layers["layers"].as_array()).map(|a| a.len()).filter(|n| *n >= 2)+}+ fn pass_through_checked(cmd: &str, args: &Value, ctx: &Ctx) -> Value { let r = pass_through_inner(cmd, args, ctx); // Fusion 2706 dropped the Electron.boardInfo text command (live 2026-10-28): the same answer@@ -368,7 +399,14 @@ pub fn cpl_from_board_info(args: &Value, bi: &Value) -> Value { } fn pass_through_inner(cmd: &str, args: &Value, ctx: &Ctx) -> Value {- if let Some((title, detail)) = crate::palette::for_verb(cmd, args) { crate::palette::say(&title, &detail, None, crate::palette::eta_for_verb(&ctx.state_dir, cmd)); }+ let spoke = crate::palette::for_verb(cmd, args);+ if let Some((title, detail)) = &spoke { crate::palette::say(title, detail, None, crate::palette::eta_for_verb(&ctx.state_dir, cmd)); }+ let r = pass_through_verb(cmd, args, ctx);+ if spoke.is_some() { crate::palette::verb_finished(); }+ r+}++fn pass_through_verb(cmd: &str, args: &Value, ctx: &Ctx) -> Value { let sidecar = ctx.sidecar; let dialogs = ctx.dialogs; let t0 = Instant::now();@@ -469,6 +507,18 @@ pub fn long_command_active() -> Option<Value> { LONG_COMMAND.lock().unwrap().clone().map(|(c, at)| json!({"command": c, "startedAt": now_f() - at.elapsed().as_secs_f64()})) } +#[cfg(test)]+mod gerber_tests {+ use super::gerber_layer_count;+ use serde_json::json;+ #[test] fn the_count_comes_from_the_boards_stack() {+ assert_eq!(gerber_layer_count(&json!({"success": true, "copperLayers": ["F.Cu", "In1.Cu", "In2.Cu", "B.Cu"]})), Some(4));+ assert_eq!(gerber_layer_count(&json!({"layers": [{}, {}]})), Some(2));+ assert_eq!(gerber_layer_count(&json!({"success": false, "copperLayers": ["F.Cu", "B.Cu"]})), None);+ assert_eq!(gerber_layer_count(&json!({})), None);+ }+}+ #[cfg(test)] mod tests { use super::*;
rust/bridge/src/docs_script.py+9−3@@ -5,6 +5,12 @@ KIND = {'EcadDesignProductType': 'project', 'SchematicProductType': 'schematic', 'DesignProductType': 'model', 'ElectronLibraryProductType': 'library', 'ElectronicsLibraryProductType': 'library'} +def _tab_number(app, i):+ """The tab of the document at index i in app.documents. A document with no visible tab (opened by a script+ with visible=False) has no place in the tab bar, so every index after it was off by one (#141)."""+ return sum(1 for k in range(min(int(i), app.documents.count)) if app.documents.item(k).isVisible)++ def _pt(d): try: return d.products.item(0).productType if d.products.count else ''@@ -134,7 +140,7 @@ def render_board_3d(app, args): docs = [app.documents.item(k) for k in range(app.documents.count)] front = docs.index(app.activeDocument) if app.activeDocument in docs else None try:- app.executeTextCommand('UI.SwitchToTab %d' % i)+ app.executeTextCommand('UI.SwitchToTab %d' % _tab_number(app, i)) vp = app.activeViewport cam = vp.camera cam.viewOrientation = getattr(adsk.core.ViewOrientations, SIDES[side])@@ -233,7 +239,7 @@ def render_board_3d(app, args): ok = vp.saveAsImageFileWithOptions(opts) finally: if front is not None and front != i:- app.executeTextCommand('UI.SwitchToTab %d' % front)+ app.executeTextCommand('UI.SwitchToTab %d' % _tab_number(app, front)) if not ok or not os.path.exists(out): return {'success': False, 'errorCode': 'render_failed', 'error': 'Fusion did not write %s' % out} return {'success': True, 'path': out, 'side': side, 'width': w, 'height': h, 'format': fmt, 'design': doc.name,@@ -286,7 +292,7 @@ def handle(app, args, action): docs = [app.documents.item(i) for i in range(app.documents.count)] return {'success': True, 'index': docs.index(app.activeDocument) if app.activeDocument in docs else None} if action == 'switch_tab':- app.executeTextCommand('UI.SwitchToTab %d' % int(args['index']))+ app.executeTextCommand('UI.SwitchToTab %d' % _tab_number(app, int(args['index']))) return {'success': True} if action == 'render_board_3d': return render_board_3d(app, args)
rust/bridge/src/orchestrations.rs+35−5@@ -681,6 +681,25 @@ pub fn board_file_problems(text: &str) -> Vec<String> { out } +/// The parts on a just-imported board. Fusion answers before the board view has loaded, with no parts or some of+/// them, so a read straight after the import called a good board nothing_instantiated (#139). It's read again until+/// every part is there, or the count has held still, about 30 s at most.+fn read_placed_parts(expected: usize, read: &dyn Fn() -> Value, wait: &dyn Fn()) -> (usize, Vec<Value>) {+ let mut last = None;+ let (mut placed, mut names) = (0, vec![]);+ for _ in 0..10 {+ let bi = read();+ let d = if bi["data"].is_object() { bi["data"].clone() } else { bi.clone() };+ let sm = if d["summary"].is_object() { d["summary"].clone() } else { d.clone() };+ placed = sm["componentCount"].as_u64().map(|n| n as usize).or_else(|| sm["components"].as_array().map(|a| a.len())).unwrap_or(0);+ names = sm["components"].as_array().cloned().unwrap_or_default().iter().take(40).map(|c| c["name"].clone()).collect();+ if placed >= expected || (placed > 0 && last == Some(placed)) { break; }+ last = Some(placed);+ wait();+ }+ (placed, names)+}+ /// fusion_open_board and fusion_open_schematic: a file Fusion would refuse never reaches it, and one it /// refuses anyway is answered as that, not as a hung call or a success with an empty design. pub fn open_file_guarded(ctx: &Ctx, cmd: &str, args: &Value, run: &dyn Fn(&str, Value) -> Value) -> Value {@@ -1625,11 +1644,7 @@ pub fn new_electronics_from_eagle(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, V let expected = std::fs::read_to_string(&brd).map(|t| t.matches("<element ").count()).unwrap_or(0); let (mut placed, mut placed_names): (usize, Vec<Value>) = (0, vec![]); if ready {- let bi = pass("board_info", json!({}));- let d = if bi["data"].is_object() { bi["data"].clone() } else { bi.clone() };- let sm = if d["summary"].is_object() { d["summary"].clone() } else { d.clone() };- placed = sm["componentCount"].as_u64().map(|n| n as usize).or_else(|| sm["components"].as_array().map(|a| a.len())).unwrap_or(0);- placed_names = sm["components"].as_array().cloned().unwrap_or_default().iter().take(40).map(|c| c["name"].clone()).collect();+ (placed, placed_names) = read_placed_parts(expected, &|| pass("board_info", json!({})), &|| std::thread::sleep(Duration::from_secs(3))); } let instantiated = expected == 0 || placed > 0; if let Some(rid) = args["_runId"].as_str() {@@ -1774,6 +1789,21 @@ mod eagle_wait_tests { } } +#[cfg(test)] mod placed_parts_tests {+ use super::read_placed_parts;+ use serde_json::json;+ fn run(answers: Vec<usize>, expected: usize) -> (usize, usize) {+ let i = std::cell::Cell::new(0);+ let (placed, _) = read_placed_parts(expected, &|| { let n = answers[i.get().min(answers.len() - 1)]; i.set(i.get() + 1); json!({"data": {"summary": {"componentCount": n}}}) }, &|| {});+ (placed, i.get())+ }+ #[test] fn a_board_still_loading_is_read_again() { assert_eq!(run(vec![0, 0, 5], 5), (5, 3)); }+ #[test] fn all_there_answers_at_once() { assert_eq!(run(vec![5], 5), (5, 1)); }+ #[test] fn a_count_that_holds_still_is_the_answer() { assert_eq!(run(vec![0, 3, 3, 5], 5), (3, 3)); }+ #[test] fn nothing_is_given_up_on_in_the_end() { assert_eq!(run(vec![0], 5), (0, 10)); }+ #[test] fn an_empty_board_expects_nothing() { assert_eq!(run(vec![0], 0), (0, 1)); }+}+ #[cfg(test)] mod board_file_tests { use super::board_file_problems; const GOOD: &str = r#"<eagle><drawing><board><libraries><library name="L"><packages><package name="R0603"/></packages></library></libraries><elements><element name="R1" library="L" package="r0603"/></elements><signals><signal name="N"><contactref element="R1" pad="1"/></signal></signals></board></drawing></eagle>"#;
rust/bridge/src/palette.rs+11−2@@ -20,6 +20,7 @@ static HIDE: AtomicBool = AtomicBool::new(false); /// repainted in place, so no per-beat creation cost (the add-in retries delivery 3 s on creation). static HOLD: AtomicBool = AtomicBool::new(false); static SEQ: AtomicU64 = AtomicU64::new(0);+static FINISHED_SEQ: AtomicU64 = AtomicU64::new(0); static PAINTED_SEQ: AtomicU64 = AtomicU64::new(0); static LAST: Mutex<Option<(String, String, Option<f64>, Option<f64>, u64)>> = Mutex::new(None); /// What the worker last did, for fusion_interactive: (action, title, success, error, seconds ago).@@ -51,8 +52,12 @@ fn worker() { } else { std::thread::sleep(Duration::from_secs(2)); } continue; }- if !shown && PAINTED_SEQ.load(Ordering::Relaxed) > painted_seq { shown = true; painted_seq = PAINTED_SEQ.load(Ordering::Relaxed); painted_at = Instant::now(); }- if shown && !HOLD.load(Ordering::Relaxed) && painted_at.elapsed() > Duration::from_secs(25) && SEQ.load(Ordering::Relaxed) == painted_seq && !run_in_flight() {+ // every paint counts, also one made while the panel was already up: with `!shown &&` here, a second message+ // left painted_seq behind for good, so the test below could never pass and the panel stayed up forever (#143)+ if PAINTED_SEQ.load(Ordering::Relaxed) > painted_seq { shown = true; painted_seq = PAINTED_SEQ.load(Ordering::Relaxed); painted_at = Instant::now(); }+ let finished = FINISHED_SEQ.load(Ordering::Relaxed) == painted_seq;+ let idle_for = if finished { Duration::from_secs(3) } else { Duration::from_secs(25) };+ if shown && !HOLD.load(Ordering::Relaxed) && painted_at.elapsed() > idle_for && SEQ.load(Ordering::Relaxed) == painted_seq && !run_in_flight() { if send("hide", "", "", None, None)["success"].as_bool() == Some(true) { shown = false; *LAST.lock().unwrap() = None; } else { painted_at = Instant::now(); } }@@ -72,6 +77,10 @@ pub fn say(title: &str, detail: &str, percent: Option<f64>, eta: Option<f64>) { *LAST_RESULT.lock().unwrap() = Some(("show".into(), title.to_string(), r["success"].as_bool() == Some(true), r["error"].as_str().unwrap_or("").chars().take(120).collect(), Instant::now())); if r["success"].as_bool() == Some(true) { PAINTED_SEQ.store(seq, Ordering::Relaxed); } else { DIRTY.store(true, Ordering::Relaxed); } }+/// The verb that last spoke has answered: nothing is running behind that message any more, so the panel comes down in+/// moments rather than after the idle wait. A newer message or a run in flight keeps it up.+pub fn verb_finished() { FINISHED_SEQ.store(SEQ.load(Ordering::Relaxed), Ordering::Relaxed); }+ /// Keep the palette up between messages (a tour in flight) or let the idle timer take it down. pub fn hold(on: bool) { HOLD.store(on, Ordering::Relaxed); }
rust/bridge/src/routing.rs+2−2@@ -3,7 +3,7 @@ use serde_json::{json, Value}; const SOURCE: &str = concat!(include_str!("routing_script.py"), "\n", include_str!("routing_planner.py"), "\n", include_str!("board_model.py"), "\n", include_str!("lbr_use.py"), "\nFUSION_DEFAULT_RULES = r'''", include_str!("fusion_default_designrules.xml"), "'''\n");-pub fn owns(cmd: &str) -> bool { matches!(cmd, "edit_tracks" | "place_parts" | "routing_state" | "route_net" | "routing_drc" | "move_parts" | "place_vias" | "apply_pours" | "pour_state" | "board_source" | "ripup_nets" | "schematic_run" | "board_bom" | "change_pours" | "schematic_plan" | "board_seed" | "remove_pours" | "layer_stack" | "netlist" | "bodies" | "component_data" | "component_pins" | "list_nets" | "net_connections" | "list_designators" | "layers" | "copper_layers" | "layers_in_use" | "read_rules" | "rules_by_kind" | "silk_state" | "net_classes" | "diff_pairs" | "run_drc" | "repour" | "zoom_to" | "export_bom" | "save_document" | "save_to_cloud" | "apply_silkscreen" | "set_layer_visibility" | "select_net" | "set_track_width" | "remove_items" | "set_outline" | "board_shape" | "board_outline" | "run_erc" | "autoroute" | "update_board_from_eagle" | "board_dxf" | "edit_parts" | "align_parts" | "remove_graphics" | "remove_parts" | "check_inputs" | "export_odb" | "export_ipc356" | "export_gencad" | "export_ipc2581" | "import_svg" | "copy_routing" | "set_layer_count" | "board_info_compat" | "footprint_primitives" | "place_planned_vias" | "place_teardrops" | "board_features" | "add_rule" | "read_board") }+pub fn owns(cmd: &str) -> bool { matches!(cmd, "change_vias" | "edit_tracks" | "place_parts" | "routing_state" | "route_net" | "routing_drc" | "move_parts" | "place_vias" | "apply_pours" | "pour_state" | "board_source" | "ripup_nets" | "schematic_run" | "board_bom" | "change_pours" | "schematic_plan" | "board_seed" | "remove_pours" | "layer_stack" | "netlist" | "bodies" | "component_data" | "component_pins" | "list_nets" | "net_connections" | "list_designators" | "layers" | "copper_layers" | "layers_in_use" | "read_rules" | "rules_by_kind" | "silk_state" | "net_classes" | "diff_pairs" | "run_drc" | "repour" | "zoom_to" | "export_bom" | "save_document" | "save_to_cloud" | "apply_silkscreen" | "set_layer_visibility" | "select_net" | "set_track_width" | "remove_items" | "set_outline" | "board_shape" | "board_outline" | "run_erc" | "autoroute" | "update_board_from_eagle" | "board_dxf" | "edit_parts" | "align_parts" | "remove_graphics" | "remove_parts" | "check_inputs" | "export_odb" | "export_ipc356" | "export_gencad" | "export_ipc2581" | "import_svg" | "copy_routing" | "set_layer_count" | "board_info_compat" | "footprint_primitives" | "place_planned_vias" | "place_teardrops" | "board_features" | "add_rule" | "read_board") } /// How many schematic commands one call into Fusion runs. Fusion's main thread is held for the whole /// call, so a long list goes in batches, which lets it answer between them (#137). const SCH_BATCH: usize = 300;@@ -155,7 +155,7 @@ fn call_one(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Val } out }- None => json!({"success":false,"errorCode":"invalid_routing_response","error":"Fusion did not return a structured routing result", "mutationMayHaveOccurred":matches!(cmd, "route_net" | "move_parts" | "place_vias" | "apply_pours" | "apply_silkscreen" | "set_track_width" | "remove_items" | "set_outline" | "board_shape" | "autoroute" | "update_board_from_eagle" | "edit_parts" | "align_parts" | "remove_graphics" | "remove_parts" | "import_svg" | "copy_routing" | "set_layer_count" | "place_planned_vias" | "place_teardrops" | "board_features" | "add_rule")}),+ None => json!({"success":false,"errorCode":"invalid_routing_response","error":"Fusion did not return a structured routing result", "mutationMayHaveOccurred":matches!(cmd, "change_vias" | "route_net" | "move_parts" | "place_vias" | "apply_pours" | "apply_silkscreen" | "set_track_width" | "remove_items" | "set_outline" | "board_shape" | "autoroute" | "update_board_from_eagle" | "edit_parts" | "align_parts" | "remove_graphics" | "remove_parts" | "import_svg" | "copy_routing" | "set_layer_count" | "place_planned_vias" | "place_teardrops" | "board_features" | "add_rule")}), } }
rust/bridge/src/routing_script.py+85−14@@ -61,15 +61,21 @@ def identifier(v): return v +def _tab_number(app, i):+ """The tab of the document at index i in app.documents. A document with no visible tab (opened by a script+ with visible=False) has no place in the tab bar, so every index after it was off by one (#141)."""+ return sum(1 for k in range(min(int(i), app.documents.count)) if app.documents.item(k).isVisible)++ def show_tab_of(app, doc): """Put Fusion's UI on the tab of `doc`. doc.activate() moves the API's active document but not always the UI, and the board export and every Electron command act on the UI's board: with the two apart, a read named one board and returned another, and an edit landed on the wrong one- (live 2026-10-26). A tab's index is the document's index in app.documents."""+ (live 2026-10-26).""" docs = [app.documents.item(i) for i in range(app.documents.count)] i = next((k for k, d in enumerate(docs) if d == doc), None) if i is not None:- app.executeTextCommand('UI.SwitchToTab %d' % i)+ app.executeTextCommand('UI.SwitchToTab %d' % _tab_number(app, i)) def snapshot(app, args):@@ -84,7 +90,7 @@ def snapshot(app, args): boards = [i for i, d in enumerate(docs) if d.name == args['expectDocument'] and pt(d) == 'BoardProductType'] if len(boards) == 1: docs[boards[0]].activate()- app.executeTextCommand('UI.SwitchToTab %d' % boards[0])+ app.executeTextCommand('UI.SwitchToTab %d' % _tab_number(app, boards[0])) doc = app.activeDocument if not doc or app.activeProduct.productType != 'BoardProductType': fail('wrong_document', 'Activate a PCB board before reading or routing it')@@ -1120,18 +1126,21 @@ def apply_silkscreen(app, root, board, meta, args): if failed and not args.get('keepPartial'): # one text not as asked: take every command back, so the board is as it was (checked against # the silkscreen read before the edit), and say which texts failed- def sig(st_):- return sorted((c['ref'], tuple(sorted((k, round(v, 3) if isinstance(v, float) else v) for k, v in (c.get('name') or {}).items())),- tuple(sorted((k, round(v, 3) if isinstance(v, float) else v) for k, v in (c.get('comment') or {}).items()))) for c in st_['components'])- start = sig(silk_before)+ def sig(root_, board_, st_):+ # the free texts and lines too: comparing the parts' names alone stopped at the first undo when no+ # name had moved, and left every other new text on the board while saying all was undone (#144)+ return (sorted((c['ref'], tuple(sorted((k, round(v, 3) if isinstance(v, float) else v) for k, v in (c.get('name') or {}).items())),+ tuple(sorted((k, round(v, 3) if isinstance(v, float) else v) for k, v in (c.get('comment') or {}).items()))) for c in st_['components']),+ sorted(g['id'] for g in plain_graphics(board_, Layers(board_))))+ start = sig(root, board, silk_before) for _ in range(len(commands) + 2): app.executeTextCommand('Electron.run UNDO;') r_, b_, _m = snapshot(app, {**args, 'expectedRevision': None})- if sig(rd_silk_state(r_, b_, {})) == start: break+ if sig(r_, b_, rd_silk_state(r_, b_, {})) == start: break r_, b_, m_ = snapshot(app, {**args, 'expectedRevision': None})- back = sig(rd_silk_state(r_, b_, {})) == start+ back = sig(r_, b_, rd_silk_state(r_, b_, {})) == start return {'success': False, 'mutated': not back, 'errorCode': 'readback_mismatch', 'applied': 0, 'failed': failed, 'results': results,- 'error': '%d text(s) did not land as asked; %s' % (failed, 'every command was undone' if back else 'the undo did not restore the silkscreen exactly: read fusion_silk_state'),+ 'error': '%d text(s) did not land as asked; %s' % (failed, 'the silkscreen is back as it was' if back else 'the undo did not restore the silkscreen exactly: read fusion_silk_state to see what is left'), 'revision': m_['revision'], 'commands': commands} return {'success': failed == 0, 'mutated': True, 'applied': len(results) - failed, 'failed': failed, 'results': results, 'smashed': to_smash, 'commands': len(commands), **meta2, 'after': st,@@ -1321,6 +1330,67 @@ def edit_tracks(app, root, board, meta, args): '_hint': 'Run fusion_run_drc to check clearances. A moved end leaves the track\'s old connection open unless it lands on the same net.'} +def change_vias(app, root, board, meta, args):+ """Change existing vias, and nothing but vias: drill and diameter, picked by position or by a filter on what they+ are now. Only the Vias layer is displayed while they're picked, so a track end or pad at the same point can't be+ taken instead, and the board's display goes back after. Read back: only the asked vias may differ, and only as+ asked, or every change is undone."""+ _guard(meta, args)+ _, _, _, vias = objects(board)+ where, setting, flt = args.get('vias'), args.get('set') or {}, args.get('filter')+ if bool(where) == bool(flt):+ fail('bad_args', 'Give vias: [{x, y, drill?, diameter?}], or filter: {drill?, diameter?, net?} with set: {drill?, diameter?}')+ def same(a, b): return a is not None and abs(float(a) - float(b)) < 1e-4+ plan, results = [], []+ if flt:+ if setting.get('drill') is None and setting.get('diameter') is None: fail('bad_args', 'filter needs set: {drill?, diameter?}')+ for v in vias:+ if (flt.get('drill') is None or same(v['drill'], flt['drill'])) and (flt.get('diameter') is None or same(v['diameter'], flt['diameter'])) and (flt.get('net') is None or v['net'] == flt['net']):+ plan.append((v, setting.get('drill'), setting.get('diameter'))); results.append('ok')+ else:+ for w in where:+ x, y = number(w.get('x')), number(w.get('y'))+ hit = [v for v in vias if abs(v['at'][0] - x) < 0.01 and abs(v['at'][1] - y) < 0.01]+ if len(hit) != 1:+ results.append('err:%d vias at (%g, %g)' % (len(hit), x, y)); continue+ d, dia = w.get('drill', setting.get('drill')), w.get('diameter', setting.get('diameter'))+ if d is None and dia is None:+ results.append('err:nothing to change at (%g, %g): give drill or diameter' % (x, y)); continue+ plan.append((hit[0], d, dia)); results.append('ok')+ cmds, want = [], []+ for v, d, dia in plan:+ new_d = number(d, True) if d is not None else v['drill']+ new_dia = number(dia, True) if dia is not None else v['diameter']+ if new_dia and new_d >= new_dia:+ fail('invalid_geometry', 'A via at (%g, %g) would have a %g mm drill in a %g mm diameter' % (v['at'][0], v['at'][1], new_d, new_dia))+ if d is not None: cmds.append('CHANGE DRILL %.6fmm (%.6fmm %.6fmm);' % (new_d, v['at'][0], v['at'][1]))+ if dia is not None: cmds.append('CHANGE DIAMETER %.6fmm (%.6fmm %.6fmm);' % (new_dia, v['at'][0], v['at'][1]))+ want.append(dict(v, drill=new_d, diameter=new_dia))+ changes = [{'net': v['net'], 'x': v['at'][0], 'y': v['at'][1], 'drill': [v['drill'], w_['drill']], 'diameter': [v['diameter'], w_['diameter']]} for (v, _, _), w_ in zip(plan, want)]+ if args.get('dryRun'):+ return {'success': True, 'mutated': False, **meta, 'results': results, 'changes': changes, 'commands': cmds}+ if not cmds:+ return {'success': False, 'mutated': False, **meta, 'errorCode': 'nothing_to_do', 'results': results, 'error': 'No via matched'}+ visible = _visible_layers(root)+ try:+ app.executeTextCommand('Electron.run DISPLAY NONE 18;')+ for b_ in range(0, len(cmds), 40):+ app.executeTextCommand('Electron.run ' + ' '.join(cmds[b_:b_ + 40]))+ finally:+ _restore_display(app, visible)+ app.executeTextCommand('Electron.run RATSNEST;')+ _, after, meta2 = snapshot(app, args)+ _, _, w1, v1 = objects(after)+ untouched = [v for v in vias if not any(v is p_[0] for p_ in plan)]+ expected = copper_signature(objects(board)[2] + untouched + want)+ if copper_signature(w1 + v1) != expected:+ before = copper_signature(objects(board)[2] + vias)+ undone, _, _ = undo_route(app, args, cmds, before)+ fail('readback_mismatch', 'The vias after the change were not exactly the asked ones; %s' % ('it was undone' if undone else 'the undo did not restore them exactly: read fusion_routing_state'))+ return {'success': True, 'mutated': True, 'verified': True, **meta2, 'results': results, 'changes': changes, 'changed': len(plan),+ '_hint': 'Run fusion_run_drc: a larger via can break a clearance that the smaller one met.'}++ def place_parts(app, root, board, meta, args): """The standard's place_parts on a board with no schematic (fusion_open_board, fusion_build_board): footprints from a .lbr with position, rotation and side. A board linked to a schematic gets its parts from the sheet@@ -1749,12 +1819,12 @@ def run_erc(app, root, board, meta, args): out = Path(d) / 'erc.tsv'; ulp = Path(d) / 'erc.ulp' ulp.write_text('schematic(S) { output("' + out.as_posix() + '") { printf("CHECKED\\n"); S.errors(E) { printf("ERROR\\t%d\\t%d\\t%d\\t%d\\t%d\\t%d\\t%s\\n", E.code, E.type, E.state, E.sheet, E.x, E.y, E.description); } } }') try:- app.executeTextCommand('UI.SwitchToTab %d' % i)+ app.executeTextCommand('UI.SwitchToTab %d' % _tab_number(app, i)) if app.activeDocument != sdoc: fail('wrong_document', 'Fusion did not bring the schematic tab forward for the ERC') app.executeTextCommand('Electron.run ERC;') app.executeTextCommand("Electron.run RUN '" + ulp.as_posix() + "';") finally:- app.executeTextCommand('UI.SwitchToTab %d' % j)+ app.executeTextCommand('UI.SwitchToTab %d' % _tab_number(app, j)) lines = out.read_text().splitlines() if out.exists() else [] if not lines or lines[0] != 'CHECKED': fail('erc_failed', 'Fusion ran the ERC but its result could not be read') msgs = []@@ -3263,11 +3333,11 @@ def remove_parts(app, root, board, meta, args): el = _element(board, r_) if el.get('locked') == 'yes': app.executeTextCommand('Electron.run LOCK (S%.6fmm %.6fmm);' % (float(el.get('x')), float(el.get('y'))))- app.executeTextCommand('UI.SwitchToTab %d' % i)+ app.executeTextCommand('UI.SwitchToTab %d' % _tab_number(app, i)) for r_ in todo: app.executeTextCommand("Electron.run SET CONFIRM NO; DELETE '%s'; SET CONFIRM OFF;" % identifier(r_)) finally:- app.executeTextCommand('UI.SwitchToTab %d' % j)+ app.executeTextCommand('UI.SwitchToTab %d' % _tab_number(app, j)) app.executeTextCommand('Electron.run RATSNEST;') _, after, meta2 = snapshot(app, args) still = [r_ for r_ in todo if _element(after, r_) is not None]@@ -4243,6 +4313,7 @@ def handle(app,args,action): if action=='select_net': return select_net(app,root,board,meta,args) if action=='set_track_width': return set_track_width(app,root,board,meta,args) if action=='edit_tracks': return edit_tracks(app,root,board,meta,args)+ if action=='change_vias': return change_vias(app,root,board,meta,args) if action=='place_parts': return place_parts(app,root,board,meta,args) if action=='add_rule': return add_rule(app,root,board,meta,args) if action=='read_board': return read_board(app,root,board,meta,args)
rust/bridge/src/savedoc_script.py+7−1@@ -27,6 +27,12 @@ class SaveError(Exception): self.code, self.extra = code, extra +def _tab_number(app, i):+ """The tab of the document at index i in app.documents. A document with no visible tab (opened by a script+ with visible=False) has no place in the tab bar, so every index after it was off by one (#141)."""+ return sum(1 for k in range(min(int(i), app.documents.count)) if app.documents.item(k).isVisible)++ def sd_pt(d): try: return d.products.item(0).productType if d.products.count else ''@@ -91,7 +97,7 @@ def sd_activate(app, d): if i is None: d.activate() else:- app.executeTextCommand('UI.SwitchToTab %d' % i)+ app.executeTextCommand('UI.SwitchToTab %d' % _tab_number(app, i)) def sd_anchor(app, name):
rust/bridge/src/sch_script.py+26−20@@ -21,6 +21,12 @@ class SchError(Exception): self.code = code +def _tab_number(app, i):+ """The tab of the document at index i in app.documents. A document with no visible tab (opened by a script+ with visible=False) has no place in the tab bar, so every index after it was off by one (#141)."""+ return sum(1 for k in range(min(int(i), app.documents.count)) if app.documents.item(k).isVisible)++ def sfail(code, message): raise SchError(code, message) @@ -500,11 +506,11 @@ def sch_snapshot(app, args): with tempfile.TemporaryDirectory(prefix='adom-sch-') as d: p = Path(d) / 'snapshot.fsch' try:- app.executeTextCommand('UI.SwitchToTab %d' % i)+ app.executeTextCommand('UI.SwitchToTab %d' % _tab_number(app, i)) app.executeTextCommand('Document.CopyToDesktop ' + p.as_posix()) finally: if j is not None and j != i:- app.executeTextCommand('UI.SwitchToTab %d' % j)+ app.executeTextCommand('UI.SwitchToTab %d' % _tab_number(app, j)) root = sch_root_from_file(str(p)) return root, {'source': 'live', 'documentName': doc.name, 'revision': sch_revision(root)}, (i, j) @@ -646,7 +652,7 @@ class Edit: def run(self): app, sent, shown = self.app, 0, None try:- app.executeTextCommand('UI.SwitchToTab %d' % self.tab)+ app.executeTextCommand('UI.SwitchToTab %d' % _tab_number(app, self.tab)) if len(sheets_of(self.root)) > 1: shown = current_sheet(app) app.executeTextCommand('Electron.run SET CONFIRM NO; EDIT .s%d; SET CONFIRM OFF;' % self.n)@@ -664,7 +670,7 @@ class Edit: app.executeTextCommand('Electron.run SET CONFIRM NO; EDIT .s%d; SET CONFIRM OFF;' % shown) finally: if self.front is not None:- app.executeTextCommand('UI.SwitchToTab %d' % self.front)+ app.executeTextCommand('UI.SwitchToTab %d' % _tab_number(app, self.front)) self.after, self.meta2, _ = sch_snapshot(app, self.args) _, self.sheet2 = pick_sheet(self.after, {'sheet': self.n}) return sent@@ -689,11 +695,11 @@ class Edit: steps = sum(1 for c in self.cmds if not c.startswith('GROUP')) + self.extra_steps taken = [0] def step(cmd, k):- app.executeTextCommand('UI.SwitchToTab %d' % self.tab)+ app.executeTextCommand('UI.SwitchToTab %d' % _tab_number(app, self.tab)) for _ in range(k): app.executeTextCommand('Electron.run %s;' % cmd) if self.front is not None:- app.executeTextCommand('UI.SwitchToTab %d' % self.front)+ app.executeTextCommand('UI.SwitchToTab %d' % _tab_number(app, self.front)) return sch_snapshot(app, self.args)[1]['revision'] if steps > 1: taken[0] += steps - 1@@ -1776,11 +1782,11 @@ def copy_tab(app, i, front_index, suffix): with tempfile.TemporaryDirectory(prefix='adom-xp-') as d: p = Path(d) / ('snapshot.' + suffix) try:- app.executeTextCommand('UI.SwitchToTab %d' % i)+ app.executeTextCommand('UI.SwitchToTab %d' % _tab_number(app, i)) app.executeTextCommand('Document.CopyToDesktop ' + p.as_posix()) finally: if front_index is not None and front_index != i:- app.executeTextCommand('UI.SwitchToTab %d' % front_index)+ app.executeTextCommand('UI.SwitchToTab %d' % _tab_number(app, front_index)) raw = p.read_bytes() if zipfile.is_zipfile(p): # a copied Fusion document is a zip with the EAGLE file inside@@ -1859,11 +1865,11 @@ def cross_probe(app, args): show = 'SHOW ' + ' '.join(quote(r) for r in refs) try: for k in ([i] + ([bi] if bi is not None else [])):- app.executeTextCommand('UI.SwitchToTab %d' % k)+ app.executeTextCommand('UI.SwitchToTab %d' % _tab_number(app, k)) app.executeTextCommand('Electron.run ' + show) # no ';' so the highlight stays finally: if j is not None:- app.executeTextCommand('UI.SwitchToTab %d' % j)+ app.executeTextCommand('UI.SwitchToTab %d' % _tab_number(app, j)) selected = refs unknown = [m['ref'] for m in matches if m.get('found') is False] hint = '%d match(es); linked:false means one side is missing. Fusion links a schematic part to its board part by designator (renames go both ways).' % len(matches)@@ -2423,19 +2429,19 @@ def sch_batch(app, args): steps = STEPS[0] undone, state = 0, None if steps:- app.executeTextCommand('UI.SwitchToTab %d' % tabs[0])+ app.executeTextCommand('UI.SwitchToTab %d' % _tab_number(app, tabs[0])) for _ in range(steps): app.executeTextCommand('Electron.run UNDO;') undone += 1 if tabs[1] is not None:- app.executeTextCommand('UI.SwitchToTab %d' % tabs[1])+ app.executeTextCommand('UI.SwitchToTab %d' % _tab_number(app, tabs[1])) state = sch_snapshot(app, args)[1]['revision'] if state != meta0['revision']:- app.executeTextCommand('UI.SwitchToTab %d' % tabs[0])+ app.executeTextCommand('UI.SwitchToTab %d' % _tab_number(app, tabs[0])) for _ in range(undone): app.executeTextCommand('Electron.run REDO;') if tabs[1] is not None:- app.executeTextCommand('UI.SwitchToTab %d' % tabs[1])+ app.executeTextCommand('UI.SwitchToTab %d' % _tab_number(app, tabs[1])) return {'success': False, 'errorCode': 'batch_partial', 'failedOp': i, 'mutated': True, 'partial': True, 'ranOps': list(range(i)), 'results': results, 'error': 'op %d (%s) failed: %s. UNDO did not bring the schematic back to its revision before the batch, so ops 0-%d are left in place' % (i, n, err[1], i - 1), 'opError': {'errorCode': err[0], 'error': err[1]}, **sch_snapshot(app, args)[1]}@@ -2533,7 +2539,7 @@ def sch_render(app, args): sfail('bad_args', 'png: the file to write is required (the bridge fills it in)') i, j = tabs try:- app.executeTextCommand('UI.SwitchToTab %d' % i)+ app.executeTextCommand('UI.SwitchToTab %d' % _tab_number(app, i)) shown = current_sheet(app) if len(sheets_of(root)) > 1 else None if shown and shown != n: app.executeTextCommand('Electron.run SET CONFIRM NO; EDIT .s%d; SET CONFIRM OFF;' % n)@@ -2551,7 +2557,7 @@ def sch_render(app, args): app.executeTextCommand('Electron.run SET CONFIRM NO; EDIT .s%d; SET CONFIRM OFF;' % shown) finally: if j is not None and j != i:- app.executeTextCommand('UI.SwitchToTab %d' % j)+ app.executeTextCommand('UI.SwitchToTab %d' % _tab_number(app, j)) if not Path(out).exists(): sfail('render_failed', 'Fusion did not write %s' % out) head = Path(out).read_bytes()[:24]@@ -2597,7 +2603,7 @@ def sch_zoom(app, args): try: os.remove(out) except OSError: pass try:- app.executeTextCommand('UI.SwitchToTab %d' % i)+ app.executeTextCommand('UI.SwitchToTab %d' % _tab_number(app, i)) if len(sheets_of(root)) > 1: app.executeTextCommand('Electron.run SET CONFIRM NO; EDIT .s%d; SET CONFIRM OFF;' % at_sheet) app.executeTextCommand('Electron.run SHOW %s;' % ' '.join(quote(r) for r in found))@@ -2606,7 +2612,7 @@ def sch_zoom(app, args): app.executeTextCommand("Electron.run SET CONFIRM YES; EXPORT IMAGE '%s' WINDOW 150; SET CONFIRM OFF;" % out) finally: if args.get('stay') is not True and front is not None and front != i:- app.executeTextCommand('UI.SwitchToTab %d' % front)+ app.executeTextCommand('UI.SwitchToTab %d' % _tab_number(app, front)) return {'success': True, 'mutated': False, **meta, 'sheet': at_sheet, 'found': found, 'missing': missing, 'rectMm': [round(v, 3) for v in box], 'image': out if os.path.isfile(out) else None, '_hint': 'The parts are highlighted and the schematic view framed on them; image is that view. stay:true leaves the schematic in front. fusion_cross_probe finds the same parts on the board.'}@@ -2630,11 +2636,11 @@ def export_spice(app, args): try: os.remove(out) except OSError: pass try:- app.executeTextCommand('UI.SwitchToTab %d' % i)+ app.executeTextCommand('UI.SwitchToTab %d' % _tab_number(app, i)) app.executeTextCommand("Electron.run SET CONFIRM YES; EXPORT SPICENETLIST '%s'; SET CONFIRM OFF;" % out) finally: if j is not None and j != i:- app.executeTextCommand('UI.SwitchToTab %d' % j)+ app.executeTextCommand('UI.SwitchToTab %d' % _tab_number(app, j)) if not os.path.isfile(out): sfail('export_failed', 'Fusion did not write the netlist %s' % out) text = open(out, 'rb').read().decode('utf-8', 'replace')
rust/bridge/src/small_verbs.rs+8−2@@ -470,7 +470,8 @@ pub fn watch_for_library_picker(caller: &fusion_core::ab::Caller, fusion_pids: & /// The files the picker lists: each row reads "name (path)". pub fn library_picker_paths(body: &str) -> Vec<String> {- let mut out: Vec<String> = regex::Regex::new(r"\(([^()]+\.lbr)\)").unwrap().captures_iter(body).map(|c| c[1].to_string()).collect();+ // a file row names its .lbr, a cloud copy's row its place in the hub: either has a path separator or a colon+ let mut out: Vec<String> = regex::Regex::new(r"\(([^()]*[/\\:][^()]*)\)").unwrap().captures_iter(body).map(|c| c[1].to_string()).collect(); out.dedup(); out }@@ -485,7 +486,7 @@ pub fn finish_picker_watch(watch: Option<PickerWatch>, mut r: Value) -> Value { r["errorCode"] = json!("library_name_in_use"); r["librariesInUse"] = json!(paths); r["pickerClosed"] = found["closed"].clone();- r["error"] = json!(format!("Fusion has two libraries with one name in use ({}) and opened its Select Library picker. The bridge closed it, and that part didn't land.{}", paths.join(" and "), if found["closed"] == true { "" } else { " The picker did not close, so Fusion is still blocked." }));+ r["error"] = json!(format!("Fusion has two libraries with one name in use{} and opened its Select Library picker. The bridge closed it, and that part didn't land.{}", if paths.is_empty() { String::new() } else { format!(" ({})", paths.join(" and ")) }, if found["closed"] == true { "" } else { " The picker did not close, so Fusion is still blocked." })); r["_hint"] = json!("Give this file another name, or take the other file away and restart Fusion (fusion_restart_for_addin). A restart alone doesn't clear it: Fusion keeps its in-use list until the file is gone."); r }@@ -798,6 +799,11 @@ mod image_delivery_tests { let body = "System There are multiple libraries named lane!\nPick one. lane (C:/a/lane.lbr) lane (C:/b/lane.lbr)"; assert_eq!(library_picker_paths(body), vec!["C:/a/lane.lbr".to_string(), "C:/b/lane.lbr".to_string()]); }+ #[test] fn a_cloud_copys_row_counts_and_plain_words_dont() {+ assert_eq!(library_picker_paths("Pick one. TI772 (C:/x/TI772.lbr) TI772 (urn:adsk.wipprod:dm.lineage:AB) (not this)"), vec!["C:/x/TI772.lbr".to_string(), "urn:adsk.wipprod:dm.lineage:AB".to_string()]);+ let r = finish_picker_watch(watch(Some(json!({"paths": [], "closed": true}))), json!({"success": false}));+ assert!(!r["error"].as_str().unwrap().contains("()"));+ } #[test] fn a_picker_turns_the_reply_into_the_reason() { let r = finish_picker_watch(watch(Some(json!({"paths": ["C:/a/l.lbr", "C:/b/l.lbr"], "closed": true}))), json!({"success": false, "errorCode": "fusion_assert", "commands": 3})); assert_eq!(r["errorCode"], "library_name_in_use");
tests/run_verb_tests.py+2−1@@ -292,7 +292,8 @@ t("fusion_project_info", "daily", dict(B), {"ok": True, "fields": ["files", "boa t("fusion_schematic_data", "daily", dict(B, designator="C11", params=True), {"ok": True, "fields": ["components", "count"]}) t("fusion_part_parameters", "daily", dict(B), {"ok": True, "fields": ["names"]}) t("fusion_cross_check", "daily", dict(B), {"ok": True, "fields": ["matched", "onSheetNotOnBoard", "onBoardNotOnSheet"]})-for v in ("fusion_sch_label", "fusion_sch_wire", "fusion_edit_tracks", "fusion_place_parts"):+t("fusion_change_vias#guard", "negative", {"expectDocument": "ZZZ_NoSuchDoc_TEST", "filter": {"drill": 0.2}, "set": {"drill": 0.3}}, {"pred": "must_refuse"}, "same guards as the other edits")+for v in ("fusion_sch_label", "fusion_sch_wire", "fusion_edit_tracks", "fusion_place_parts", "fusion_change_vias"): t(v, "manual", note="edits a design: run live by tests/sch_drill.py (place_parts and a track's end on a board with no schematic by hand)") t("fusion_model_check", "daily", dict(B), {"ok": True, "fields": ["parts", "checked", "ok"]}, "does each part's cloud 3D model open, read only") t("fusion_extensions", "daily", {}, {"ok": True, "fields": ["extensions", "count"]}, "the add-ins installed for this user")
tests/test_routing.py+95@@ -327,6 +327,101 @@ class PourIdTests(unittest.TestCase): self.assertTrue(any('CHANGE ISOLATE 0.400000mm' in c for c in out['commands']), out) +class TabNumberTests(unittest.TestCase):+ """#141: a document with no visible tab doesn't take a place in the tab bar."""+ def test_a_hidden_document_shifts_nothing(self):+ class D:+ def __init__(self, v): self.isVisible = v+ class Docs:+ def __init__(self, v): self.v = [D(x) for x in v]; self.count = len(v)+ def item(self, i): return self.v[i]+ class App: documents = Docs([True, False, True, True])+ self.assertEqual([0, 1, 1, 2], [r._tab_number(App, i) for i in range(4)])+++class ChangeViasTests(unittest.TestCase):+ """#145: only vias change, only as asked, and a read back that disagrees is undone."""+ def setUp(self):+ self.root = ET.parse(ROOT/'demo/routing/fixture/fusion-routing.brd').getroot(); self.b = self.root.find('drawing/board')+ self.sig = self.b.find('signals/signal'); self.net = self.sig.get('name')+ for x, d in ((60, '0.2'), (70, '0.2'), (80, '0.4')):+ ET.SubElement(self.sig, 'via', x=str(x), y='60', extent='1-16', drill=d, diameter='0.6')+ self.sent = []; test = self+ class App:+ def executeTextCommand(self, cmd): test.sent.append(cmd)+ self.app = App(); self.saved = r.snapshot; self.after = None+ r.snapshot = lambda app, args: (self.root, (self.after if self.after is not None and any('CHANGE' in c for c in self.sent) and 'Electron.run UNDO;' not in self.sent else self.b), {'boardId': 'b', 'revision': 'r'})+ def tearDown(self): r.snapshot = self.saved+ def call(self, **a): return r.handle(self.app, dict(a, boardId='b', expectedRevision='r'), 'change_vias')+ def landed(self, *changes):+ import copy+ b = copy.deepcopy(self.b)+ for x, attr, val in changes:+ next(v for v in b.iter('via') if v.get('x') == str(x)).set(attr, val)+ self.after = b+ def test_a_filter_changes_every_match_and_nothing_else(self):+ self.landed((60, 'drill', '0.3'), (70, 'drill', '0.3'))+ out = self.call(filter={'drill': 0.2}, set={'drill': 0.3})+ self.assertTrue(out['success'], out); self.assertEqual(2, out['changed'])+ runs = [c for c in self.sent if 'CHANGE DRILL' in c]; self.assertEqual(1, len(runs))+ self.assertIn('(60.000000mm 60.000000mm)', runs[0]); self.assertNotIn('(80.000000mm', runs[0])+ self.assertIn('Electron.run DISPLAY NONE 18;', self.sent)+ def test_by_position_each_via_gets_its_own_values(self):+ self.landed((80, 'diameter', '0.9'))+ out = self.call(vias=[{'x': 80, 'y': 60, 'diameter': 0.9}])+ self.assertTrue(out['success'], out); self.assertTrue(any('CHANGE DIAMETER 0.900000mm (80.000000mm' in c for c in self.sent))+ def test_a_point_with_no_via_is_reported(self):+ self.landed((60, 'drill', '0.3'))+ out = self.call(vias=[{'x': 60, 'y': 60, 'drill': 0.3}, {'x': 1, 'y': 1, 'drill': 0.3}])+ self.assertEqual(['ok', 'err:0 vias at (1, 1)'], out['results'])+ def test_a_drill_as_big_as_its_via_is_refused(self):+ out = self.call(filter={'drill': 0.2}, set={'drill': 0.6})+ self.assertEqual('invalid_geometry', out['errorCode']); self.assertFalse(self.sent)+ def test_a_read_back_that_disagrees_is_undone(self):+ self.landed((60, 'drill', '0.3')) # the filter asked for two, one landed+ out = self.call(filter={'drill': 0.2}, set={'drill': 0.3})+ self.assertEqual('readback_mismatch', out['errorCode']); self.assertIn('Electron.run UNDO;', self.sent)+ def test_dry_run_sends_nothing(self):+ out = self.call(filter={'drill': 0.2}, set={'drill': 0.3}, dryRun=True)+ self.assertFalse(out['mutated']); self.assertEqual(2, len(out['changes'])); self.assertFalse(self.sent)+ def test_needs_one_way_of_naming_them(self):+ self.assertEqual('bad_args', self.call(set={'drill': 0.3})['errorCode'])+ self.assertEqual('bad_args', self.call(filter={'drill': 0.2})['errorCode'])+++class SilkUndoTests(unittest.TestCase):+ """#144: a failed batch is really undone, new free texts included, or says what's left."""+ def setUp(self):+ import copy+ self.root = ET.parse(ROOT/'demo/routing/fixture/fusion-routing.brd').getroot(); self.b = self.root.find('drawing/board')+ el = self.b.find('elements/element'); el.set('smashed', 'yes')+ ET.SubElement(el, 'attribute', name='NAME', x='1', y='1', size='1', layer='25')+ self.ref = el.get('name')+ # the name stayed shown, and the new text landed+ self.landed = copy.deepcopy(self.b); ET.SubElement(self.landed.find('plain'), 'text', x='5', y='5', size='1', layer='21').text = 'NEW'+ self.undos = 0; test = self; self.limit = 1+ class App:+ def executeTextCommand(self, cmd):+ if cmd == 'Electron.run UNDO;': test.undos += 1+ self.app = App(); self.saved = r.snapshot+ def tearDown(self): r.snapshot = self.saved+ def run_batch(self, undo_works):+ def snap(app, args):+ gone = undo_works and self.undos >= 2+ return self.root, (self.b if gone or self.calls['n'] == 0 else self.landed), {'boardId': 'b', 'revision': 'r'}+ self.calls = {'n': 0}+ r.snapshot = snap+ self.calls['n'] = 1+ return r.apply_silkscreen(self.app, self.root, self.b, {'boardId': 'b', 'revision': 'r'}, {'names': [{'ref': self.ref, 'x': 1, 'y': 1, 'show': False}], 'texts': [{'text': 'NEW', 'x': 5, 'y': 5}]})+ def test_undo_goes_on_until_the_free_text_is_gone_too(self):+ out = self.run_batch(True)+ self.assertEqual('readback_mismatch', out['errorCode']); self.assertGreaterEqual(self.undos, 2)+ self.assertFalse(out['mutated']); self.assertIn('back as it was', out['error'])+ def test_an_undo_that_leaves_a_text_says_so(self):+ out = self.run_batch(False)+ self.assertTrue(out['mutated']); self.assertIn('did not restore', out['error'])++ class RouteMismatchTests(unittest.TestCase): """A route whose copper does not land as planned comes off again, without taking an earlier edit with it.""" def run_route(self, undo_steps_needed):