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 7 file(s)
7 files changed
+151−3
CHANGELOG.md+6@@ -3,6 +3,12 @@ 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++`fusion_schematic_run` takes a list of any length (#137, Andy Engevold): it was refused past 400 commands, about 57 parts at 7 commands each. The bridge+checks the whole list first, so a bad command still means nothing ran, then runs it in batches of 300 and answers as one call (`batches`, and on a failure+`failedAt` in your list, `batchesDone`). Fusion can answer other calls between batches.+ ## 1.13.61 (2026-11-03) Vias on converted boards: `fusion_set_layer_count` wrote a via definition with no start or stop layer, so a VIA with a layer range landed
describe.py+1−1@@ -662,7 +662,7 @@ _T = [ ("fusion_ripup_nets", "Take whole nets' tracks and vias back up (RIPUP per net, one undo step each) so they can be routed again, wider or another way. Their pours stay. Readback proves no track or via is left on those nets and no pour was lost.", {"expectDocument":"required exact board name","boardId":"required","expectedRevision":"required","nets":"required [net name]"}, {"success":"bool","verified":"bool","ripped":"[net]","revision":"str"},120,"fusion_routing_state",False,{}),- ("fusion_schematic_run", "Build a schematic live: a list of complete, non-interactive EAGLE commands run one at a time on the active schematic (ADD 'dev@lib' 'NAME' R0 (x y), VALUE, NET 'name' (pts), LABEL, WIRE, TEXT, LAYER, CHANGE STYLE/SIZE/WIDTH/RATIO, WINDOW FIT), with an optional pause between them so a viewer sees it grow. Anything else is refused before the first command runs. ADD is proven against libraryPath on disk first (the deviceset must be there; the library is USEd by the bridge), so the component picker can never open. A .lbr Fusion 2706 cannot ADD from (no line breaks between elements, a byte-order mark, single-quoted attributes, a 3D package naming a footprint the file does not have) is rewritten in place first, keeping its element tree apart from those dead 3D entries (libraryReformatted, library3dEntriesRemoved). The bridge sends USE itself, for the file named in libraryLoaded. Readback: the parts and nets on the sheet from a fresh export.",+ ("fusion_schematic_run", "Build a schematic live: a list of complete, non-interactive EAGLE commands run one at a time on the active schematic (ADD 'dev@lib' 'NAME' R0 (x y), VALUE, NET 'name' (pts), LABEL, WIRE, TEXT, LAYER, CHANGE STYLE/SIZE/WIDTH/RATIO, WINDOW FIT), with an optional pause between them so a viewer sees it grow. Anything else is refused before the first command runs. A list of any length: past 300 commands the bridge runs it in batches and answers as one call (batches; on a failure failedAt in your list, batchesDone). ADD is proven against libraryPath on disk first (the deviceset must be there; the library is USEd by the bridge), so the component picker can never open. A .lbr Fusion 2706 cannot ADD from (no line breaks between elements, a byte-order mark, single-quoted attributes, a 3D package naming a footprint the file does not have) is rewritten in place first, keeping its element tree apart from those dead 3D entries (libraryReformatted, library3dEntriesRemoved). The bridge sends USE itself, for the file named in libraryLoaded. Readback: the parts and nets on the sheet from a fresh export.", {"expectDocument":"optional exact schematic name","libraryPath":"required for ADD: the .lbr on this machine (its file name is the library name in ADD)","commands":"required [command] (1..400)","pauseMs":"optional 0..3000 between commands"}, {"success":"bool","verified":"bool","parts":"[name]","added":"[name]","nets":"{net:[part.pin]}","commands":"int","libraryLoaded":"path","libraryReformatted":"bool","library3dEntriesRemoved":"[name]"},120,"fusion_get_app_state",False,{}), ("fusion_update_pcb", "The standard's update_pcb. Fusion forward-annotates the board from the schematic as you edit, so there is no change order to run: the preview compares the sheet and the board (addParts on the sheet with no package, removeParts only on the board), and execute has nothing to carry over. allowRemovals with parts to remove is refused: Fusion cannot delete a part from a linked board by command.",
rust/bridge/src/routing.rs+97@@ -4,7 +4,62 @@ 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") }+/// 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;++/// fusion_schematic_run with more commands than one batch: the whole list is checked first, so a bad+/// command still means nothing ran; then each batch in turn. The reply reads like one call's. A failure+/// stops there, with failedAt counted in the caller's list and what already ran in batchesDone.+fn schematic_run_batched(args: &Value, list: &[Value], run: &dyn Fn(&Value) -> Value) -> Value {+ let mut check = args.clone();+ check["validateOnly"] = json!(true);+ let checked = run(&check);+ if checked["success"].as_bool() != Some(true) { return checked; }+ let batches: Vec<&[Value]> = list.chunks(SCH_BATCH).collect();+ let (mut added, mut removed, mut ran, mut reformatted, mut assertion) = (Vec::new(), Vec::new(), 0u64, false, Value::Null);+ let mut last = Value::Null;+ for (i, chunk) in batches.iter().enumerate() {+ let mut a = args.clone();+ a["commands"] = json!(chunk);+ let mut r = run(&a);+ added.extend(r["added"].as_array().cloned().unwrap_or_default());+ removed.extend(r["library3dEntriesRemoved"].as_array().cloned().unwrap_or_default());+ reformatted = reformatted || r["libraryReformatted"].as_bool() == Some(true);+ if assertion.is_null() { assertion = r["fusionUnexpectedCondition"].clone(); }+ let this = r["commands"].as_u64().unwrap_or(0);+ if r["success"].as_bool() != Some(true) {+ if let Some(at) = r["failedAt"].as_u64() { r["failedAt"] = json!(at + (i * SCH_BATCH) as u64); }+ r["commands"] = json!(ran + this);+ r["commandsAsked"] = json!(list.len());+ r["batches"] = json!(batches.len());+ r["batchesDone"] = json!(i);+ if i > 0 { r["mutated"] = json!(true); }+ r["added"] = json!(added);+ return r;+ }+ ran += this;+ last = r;+ }+ last["commands"] = json!(ran);+ last["batches"] = json!(batches.len());+ last["added"] = json!(added);+ last["library3dEntriesRemoved"] = json!(removed);+ last["libraryReformatted"] = json!(reformatted);+ if !assertion.is_null() { last["fusionUnexpectedCondition"] = assertion; }+ last+}+ pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ if cmd == "schematic_run" && args["validateOnly"].is_null() {+ if let Some(list) = args["commands"].as_array().filter(|l| l.len() > SCH_BATCH) {+ return schematic_run_batched(args, list, &|a: &Value| call_one(cmd, a, pass));+ }+ }+ call_one(cmd, args, pass)+}++fn call_one(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value { let mut args_v = args.clone(); if args_v.is_object() { args_v["_bridgeVersion"] = json!(env!("CARGO_PKG_VERSION")); } let script = format!("import json\nexec({})\nresult=handle(app,json.loads({}),{})\n",@@ -57,6 +112,48 @@ pub fn export_bom(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value { #[cfg(test)] mod tests { use super::*;+ fn commands(n: usize) -> Vec<Value> { (0..n).map(|i| json!(format!("c{i}"))).collect() }+ #[test] fn a_long_schematic_list_runs_in_batches_after_one_check() {+ let list = commands(700);+ let seen = std::cell::RefCell::new(Vec::new());+ let r = schematic_run_batched(&json!({"libraryPath": "l.lbr", "commands": list}), &list, &|a: &Value| {+ seen.borrow_mut().push((a["validateOnly"].as_bool() == Some(true), a["commands"].as_array().unwrap().len()));+ if a["validateOnly"].as_bool() == Some(true) { return json!({"success": true, "commands": 700}); }+ let n = a["commands"].as_array().unwrap().len();+ json!({"success": true, "commands": n, "added": [format!("P{n}")], "parts": ["all"], "libraryReformatted": n == 300 && seen.borrow().len() == 2})+ });+ assert_eq!(*seen.borrow(), vec![(true, 700), (false, 300), (false, 300), (false, 100)]);+ assert_eq!(r["success"], true);+ assert_eq!(r["commands"], 700);+ assert_eq!(r["batches"], 3);+ assert_eq!(r["added"], json!(["P300", "P300", "P100"]));+ assert_eq!(r["libraryReformatted"], true);+ }+ #[test] fn a_bad_command_anywhere_means_no_batch_ran() {+ let list = commands(700);+ let runs = std::cell::Cell::new(0);+ let r = schematic_run_batched(&json!({"commands": list}), &list, &|_: &Value| { runs.set(runs.get() + 1); json!({"success": false, "errorCode": "off_the_sheet"}) });+ assert_eq!(r["errorCode"], "off_the_sheet");+ assert_eq!(runs.get(), 1);+ }+ #[test] fn a_failure_in_a_later_batch_counts_from_the_callers_list() {+ let list = commands(700);+ let n = std::cell::Cell::new(0);+ let r = schematic_run_batched(&json!({"commands": list}), &list, &|a: &Value| {+ if a["validateOnly"].as_bool() == Some(true) { return json!({"success": true}); }+ n.set(n.get() + 1);+ if n.get() == 2 { json!({"success": false, "errorCode": "add_failed", "failedAt": 5, "commands": 6}) }+ else { json!({"success": true, "commands": 300, "added": ["R1"]}) }+ });+ assert_eq!(r["errorCode"], "add_failed");+ assert_eq!(r["failedAt"], 305);+ assert_eq!(r["commands"], 306);+ assert_eq!(r["batchesDone"], 1);+ assert_eq!(r["commandsAsked"], 700);+ assert_eq!(r["mutated"], true);+ assert_eq!(r["added"], json!(["R1"]));+ assert_eq!(n.get(), 2);+ } #[test] fn unwraps_semantic_failure() { let r=call("route_net",&json!({}),&|_,_|json!({"success":true,"data":{"result":{"success":false,"errorCode":"stale_board"}}})); assert_eq!(r["errorCode"],"stale_board");
rust/bridge/src/routing_script.py+5−1@@ -710,7 +710,9 @@ def sch_run(app, args): name, before, _ = sch_snapshot(app) if args.get('expectDocument') not in (None, name): fail('wrong_document', 'expectDocument must match the active schematic name exactly') cmds = args.get('commands')- if not isinstance(cmds, list) or not 1 <= len(cmds) <= 400: fail('invalid_args', 'commands must be a list of 1..400 schematic commands')+ # the bridge sends a longer list in batches (routing.rs), having checked the whole of it here first+ limit = 20000 if args.get('validateOnly') else 400+ if not isinstance(cmds, list) or not 1 <= len(cmds) <= limit: fail('invalid_args', 'commands must be a list of 1..%d schematic commands' % limit) lbr = args.get('libraryPath') devices = set() if lbr:@@ -733,6 +735,8 @@ def sch_run(app, args): at = re.search(r'\(\s*(-?[0-9.]+)mm\s+(-?[0-9.]+)mm\s*\)', c) if at and max(abs(float(at.group(1))), abs(float(at.group(2)))) > SHEET_LIMIT_MM: fail('off_the_sheet', 'Fusion cannot place a part this far out (past about 2 m it drops it silently and the name stays unusable): %s. Keep positions within %g mm; nothing was run.' % (c.strip(), SHEET_LIMIT_MM))+ if args.get('validateOnly'):+ return {'success': True, 'mutated': False, 'documentName': name, 'commands': len(cmds)} done = [] # ADD takes the device from the .lbr loaded with USE. (Fusion cannot take a library out of use from a # command, so two in-use libraries with the same name at different paths make ADD ask which one.)
skills/fusion-board-from-netlist/SKILL.md+1−1@@ -52,7 +52,7 @@ later step targets it by name. `fusion_schematic_plan {libraryPath, parts:[{ref, deviceset, value, block}], nets}` returns one command group per block. Run each group with `fusion_schematic_run {libraryPath, commands, pauseMs}`: the sheet grows block by block, ADD is proven against the library first (no picker), and the reply carries the-sheet's nets. Compare them with the netlist pin by pin before going on. The board follows the schematic:+sheet's nets. A long list is fine: the bridge checks all of it first, then runs it in batches of 300. Compare them with the netlist pin by pin before going on. The board follows the schematic: `fusion_board_info` should count every part. ## 4. The board: adom-aiflow's Fusion lane
tests/sch_drill.py+18@@ -104,6 +104,24 @@ r = call('fusion_sch_place_part', {'library': LBR, 'symbol': 'RES', 'designator' results.append(('off the sheet refused', expect(r.get('errorCode') == 'off_the_sheet', str(r.get('errorCode'))))) print('%-34s %s' % (results[-1][0], 'PASS' if results[-1][1] is None else 'FAIL ' + str(results[-1][1]))) +# ── a long command list (#137): 80 parts at 7 commands each, as AI Flow sends ──+long_cmds = []+for i in range(80):+ x, y = 300 + 25 * (i % 10), 300 + 25 * (i // 10)+ long_cmds += ["ADD 'RES@%s' 'L%d' R0 (%dmm %dmm);" % (LBR.rsplit('/', 1)[-1][:-4], i + 1, x, y), "VALUE 'L%d' '%dk';" % (i + 1, i + 1)]+ long_cmds += ["NET 'LN%d_%d' (%dmm %dmm) (%dmm %dmm);" % (i + 1, k, x + 12, y + 2 * k, x + 18, y + 2 * k) for k in range(5)]+bad_late = long_cmds[:300] + ["ADD 'RES@%s' 'LFAR' R0 (5000mm 300mm);" % LBR.rsplit('/', 1)[-1][:-4]] + long_cmds[300:]+before_long = set(parts())+r = call('fusion_schematic_run', {'libraryPath': LBR, 'commands': bad_late})+results.append(('long list, bad command late: nothing ran', expect(r.get('errorCode') == 'off_the_sheet' and set(parts()) == before_long, '%s, added %s' % (r.get('errorCode'), sorted(set(parts()) - before_long)[:5]))))+print('%-34s %s' % (results[-1][0], 'PASS' if results[-1][1] is None else 'FAIL ' + str(results[-1][1])))+t_long = time.time()+r = call('fusion_schematic_run', {'libraryPath': LBR, 'commands': long_cmds})+print(' 560 commands took %.0f s, %s batches, %s commands' % (time.time() - t_long, r.get('batches'), r.get('commands')))+added_long = set(parts()) - before_long+results.append(('long list runs whole', expect(r.get('success') and r.get('batches') == 2 and r.get('commands') == 560 and len(added_long) == 80, '%s %s, %d parts' % (r.get('errorCode'), str(r.get('error'))[:120], len(added_long)))))+print('%-34s %s' % (results[-1][0], 'PASS' if results[-1][1] is None else 'FAIL ' + str(results[-1][1])))+ # ── wiring ───────────────────────────────────────────────────────────────────────────────────────── step('sch_connect label', 'fusion_sch_connect', {'connections': [{'designator': 'R4', 'pin': 'A', 'net': 'N1'}]}, lambda _r: expect(net('R4', 'A') == 'N1', 'R4.A is on %s' % net('R4', 'A')))
tests/test_routing.py+23@@ -253,6 +253,29 @@ class InnerLayerPourTests(unittest.TestCase): self.assertEqual('readback_mismatch', out['errorCode']) +class SchematicRunListTests(unittest.TestCase):+ """validateOnly checks a list of any length and runs nothing; a real run stays one batch's worth."""+ def setUp(self):+ self.sent = []+ test = self+ class App:+ def executeTextCommand(self, cmd): test.sent.append(cmd)+ self.app = App(); self.saved = r.sch_snapshot+ r.sch_snapshot = lambda app: ('doc', [], {})+ def tearDown(self): r.sch_snapshot = self.saved+ def test_a_long_list_is_checked_without_running(self):+ out = r.sch_run(self.app, {'commands': ['WINDOW FIT;'] * 560, 'validateOnly': True})+ self.assertTrue(out['success']); self.assertFalse(out['mutated']); self.assertEqual(560, out['commands']); self.assertFalse(self.sent)+ def test_a_bad_command_late_in_a_long_list_is_caught(self):+ with self.assertRaises(r.RoutingError) as e:+ r.sch_run(self.app, {'commands': ['WINDOW FIT;'] * 559 + ['RUN evil;'], 'validateOnly': True})+ self.assertEqual('command_not_allowed', e.exception.code); self.assertFalse(self.sent)+ def test_one_call_stays_a_batch(self):+ with self.assertRaises(r.RoutingError) as e:+ r.sch_run(self.app, {'commands': ['WINDOW FIT;'] * 401})+ self.assertEqual('invalid_args', e.exception.code)++ 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):