← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.12.2\ No newline at end of file+1.12.3\ No newline at end of file
CHANGELOG.md+20−1
@@ -3,8 +3,27 @@ All notable changes to the Adom Bridge Fusion 360 bridge, newest first. Canonical: https://wiki.adom.inc/adom/fusion-bridge ยท GitHub backup: `adom-inc/fusion-bridge` -## 1.12.3 (unreleased)+## 1.12.3 (2026-10-27, insiders) +- New `fusion_update_board_from_eagle {brdPath, expectDocument, save?}` (page issue #125, item 4): makes the open+  design's board the board in an EAGLE .brd (or .fbrd) in place, so a re-run updates the same design instead of+  importing another. Parts move and turn by name; tracks and vias are ripped up and drawn from the file; pours, the+  outline, holes and net-less copper are replaced; then the whole board (parts, copper, pours, outline, holes, nets)+  is read back against the file. With `save:true` it is saved in place only after an exact read back; otherwise+  nothing is saved and the reply says to close without saving. A file that adds or removes parts or changes a net's+  pads is a schematic change and is refused with `schematic_differs`. Not synced, and named in `notSynced` when the+  file differs: silkscreen and documentation, design rules, name and value positions. Fusion's export writes a pour+  as `<polygonpour>`, a file as `<polygon>`: both are compared by net, layer, pen, isolation, rank and outline.+  Verified live on a design saved to Default Project / fusion-lane-tests: J2 moved, NET_1 routed on both layers+  through two vias, a pour and a hole, then a second file applied with `save:true`: board v1 to v2 of the same+  lineage, the folder still holds one .fprj, one .fsch and one .fbrd, and the design reopened from the cloud shows the+  second file's board.+- `fusion_open_cloud_file` by name opens the electronics design (.fprj) when one has that name: opening the+  board or schematic file of a saved design raised Fusion's "links to multiple Electronics designs" chooser, which+  cannot be answered headlessly. It falls back to the plain name match when there is no design of that name;+  `fileExtension` still picks a file outright.+- Checked for #125: closing a never-saved imported design without saving writes nothing to the hub (no+  Untitled file appears in the project). - Schematic edits honour `save: true`: after a read-back edit the design is saved in place (as   `fusion_save_document`). `save` defaults to false, as the standard's conventions now allow, because each Fusion   save is a new cloud version; every edit verb's describe says so. A never-saved design answers `saved: false` with
README.md+3−2
@@ -527,7 +527,7 @@ The AI does the whole setup - the user never gets handed a download link: ## Full verb catalog  All verbs return a rich `_hint`. `fusion_describe '{}'` is the machine-readable source of truth; this table is generated from it.-_243 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`.+_244 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`.  **Lifecycle and readiness** @@ -649,6 +649,7 @@ _243 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`. - `fusion_show_3d_board` - Switch the OPEN electronics design to its 3D PCB view (populated board). - `fusion_show_project` - Eight-surfaces contract: bring the PROJECT/design to its top level. - `fusion_show_schematic` - Switch the OPEN electronics design to its SCHEMATIC view (the symmetric partner of fusion_show_2d_board / fusion_show_3d_board, via Fusion's SwitchSchDocCmd).+- `fusion_update_board_from_eagle` - Make the open design's board the board in an EAGLE .brd (or a .fbrd) on the Fusion machine, in place, so the design keeps its files and gets a new version...  **Design rules and manufacturing** @@ -832,7 +833,7 @@ adom-wiki pkg install adom/fusion-bridge   # the skills pack for your AI  Auto-updates via `updateManifestUrl` (ab polls on launch + every 4h). Every release lands on the insiders manifest first, and the public manifest moves only after the pre-production gate, so the-two tiers can serve different versions. 243 verbs -+two tiers can serve different versions. 244 verbs - `fusion_describe '{}'` returns the full machine-readable catalog. More demos:  <video controls width="100%" src="https://wiki.adom.inc/api/v1/pages/fusion-bridge/files/library2-3d-montage.mp4"></video>
bridge.json+4−2
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.12.2",+  "version": "1.12.3",   "description": "Drive Autodesk Fusion 360 from the cloud: launch Fusion, electronics board layout, design rules, exports (STEP/IGES/STL/3MF/USDZ/OBJ/DXF/DWG/Gerbers/BOM/CPL), fast APS server-indexed cloud search plus browse/recent/file-info/versions, cloud file download/upload and folder creation, and in-app parametric modeling (fusion_run_modeling_script). Never-charge: APS calls are capped to the free tier.",   "homepage": "https://wiki.adom.inc/adom/fusion-bridge",   "author": "Adom Inc.",@@ -259,6 +259,7 @@     "fusion_stop",     "fusion_take_screenshot",     "fusion_uninstall",+    "fusion_update_board_from_eagle",     "fusion_verb_times",     "fusion_view",     "fusion_walk_cloud_tree",@@ -396,7 +397,8 @@       "render_schematic": 180,       "sch_reset_fields": 150,       "sch_create": 150,-      "sch_sheet_pins": 90+      "sch_sheet_pins": 90,+      "update_board_from_eagle": 300     },     "prefixes": [       {
contracts/describe.json+31
@@ -4846,6 +4846,37 @@       "summary": "CASCADE CLEANUP, called by ab's bridge_uninstall BEFORE this bridge's cache is deleted (ab 2.0.35+). Removes the artifacts this bridge planted OUTSIDE its cache: the Fusion ADD-IN in Autodesk's AddIns directories (all three historical locations), plus this bridge's prefs (~/.adom/fusion-bridge) and APS OAuth tokens (~/.adom/fusion-aps). This matters more here than for most bridges: a stale AdomBridge add-in keeps loading on every Fusion launch and binding its port long after the bridge is gone. Fusion itself is never touched. Pass dryRun:true to see exactly what would be removed. If Fusion is RUNNING it holds the add-in files open, so removal may fail - the response says so and names the remedy.",       "timeoutSeconds": 60     },+    {+      "example": {+        "brdPath": "C:/work/board.brd",+        "expectDocument": "Buck12V5V",+        "save": true+      },+      "input": {+        "brdPath": "required: .brd or .fbrd on the Fusion machine",+        "comment": "the version comment",+        "dryRun": "bool: the plan only",+        "expectDocument": "required: the open board's name",+        "expectedRevision": "optional",+        "save": "bool, default false: save in place after a verified read back"+      },+      "longRunning": true,+      "name": "fusion_update_board_from_eagle",+      "output": {+        "mismatched": "[str]",+        "notSynced": "[str]",+        "plan": "{partsMoved, copper, pours, outline, looseCopper, holes}",+        "revision": "str",+        "save": "fusion_save_document's reply",+        "saved": "bool",+        "success": "bool",+        "unconnected": "int",+        "verified": "bool"+      },+      "statusVerb": null,+      "summary": "Make the open design's board the board in an EAGLE .brd (or a .fbrd) on the Fusion machine, in place, so the design keeps its files and gets a new version instead of a new design (page issue #125, item 4): parts moved and turned by name, tracks and vias ripped up and drawn from the file, pours, the outline, holes and net-less copper replaced, then the whole board read back against the file. With save:true it is saved in place, and only after an exact read back. A file whose parts or nets differ (a schematic change) is refused with schematic_differs. Silkscreen and documentation, design rules and name/value positions are not synced; notSynced says when the file differs in them.",+      "timeoutSeconds": 300+    },     {       "example": {},       "input": {},
dashboard/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "fusion-dashboard"-version = "1.12.2"+version = "1.12.3" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.12.2"+version = "1.12.3" edition = "2021"  [dependencies]
describe.py+4
@@ -199,6 +199,10 @@ _T = [      {"sheet": "int, default 1", "expectDocument": "optional"},      {"errorCode": "unsupported", "error": "str"},      60, None, False, {}),+    ("fusion_update_board_from_eagle", "Make the open design's board the board in an EAGLE .brd (or a .fbrd) on the Fusion machine, in place, so the design keeps its files and gets a new version instead of a new design (page issue #125, item 4): parts moved and turned by name, tracks and vias ripped up and drawn from the file, pours, the outline, holes and net-less copper replaced, then the whole board read back against the file. With save:true it is saved in place, and only after an exact read back. A file whose parts or nets differ (a schematic change) is refused with schematic_differs. Silkscreen and documentation, design rules and name/value positions are not synced; notSynced says when the file differs in them.",+     {"brdPath": "required: .brd or .fbrd on the Fusion machine", "expectDocument": "required: the open board's name", "expectedRevision": "optional", "save": "bool, default false: save in place after a verified read back", "comment": "the version comment", "dryRun": "bool: the plan only"},+     {"success": "bool", "verified": "bool", "plan": "{partsMoved, copper, pours, outline, looseCopper, holes}", "mismatched": "[str]", "notSynced": "[str]", "saved": "bool", "save": "fusion_save_document's reply", "unconnected": "int", "revision": "str"},+     300, None, True, {"brdPath": "C:/work/board.brd", "expectDocument": "Buck12V5V", "save": True}),     ("fusion_apply_silkscreen", "Land a silkscreen plan on the live board: designators (names) and values (comments) moved to their own location, sized, turned or hidden; free silkscreen texts and lines added (bottom ones mirrored); earlier free items removed by the raw handle fusion_silk_state gives. Parts not yet smashed are smashed first. Each pick is made with only the target's layer displayed, so a pad or wire is never taken instead, and the board's layer display is restored. Read back; if copper, parts or the outline moved, every command is undone. The edit side of a designator plan (silk_designators).",      {"expectDocument":"required exact board name","expectedRevision":"optional: refuse with stale_board if the board moved on","names":"[{ref, x, y, size?, rotation?, show?}] the designator text's own location (lower left before rotation), mm","comments":"[same] for the value text","texts":"[{text, x, y, size?, rotation?, side?: top|bottom}]","lines":"[{x1, y1, x2, y2, width? (0.15), side?}]","remove":"{texts:[raw], lines:[raw]} from fusion_silk_state"},      {"applied":"int","failed":"int","results":"[ok | err:...]","smashed":"[ref]","after":"silk_state","revision":"str"},120,"fusion_routing_state",False,{"expectDocument":"fusion-routing","names":[{"ref":"J1","x":10,"y":20,"size":1.5,"rotation":90}]}),
install.sh+1−1
@@ -21,7 +21,7 @@ echo "Installed fusion-bridge skills: $(ls "$PKG_DIR/skills" 2>/dev/null | tr '\ # binary (the three-artifact pattern: bridge zip -> ab, THIS pkg -> container skills + # this download, page media -> wiki). Per the wiki's PKG_VS_RELEASE policy, binaries are # platform-tagged RELEASE assets, never tarball content - the installer fetches them.-DASH_VERSION="1.12.2"+DASH_VERSION="1.12.3" DASH_URL="https://wiki.adom.inc/download/adom/fusion-bridge/${DASH_VERSION}/fusion-dashboard" DEST="" if [ -w /usr/local/bin ]; then DEST=/usr/local/bin
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.12.2",+  "version": "1.12.3",   "type": "app",   "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.",   "dependencies": {
page.json+2−2
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.12.2",+  "version": "1.12.3",   "type": "app",   "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.",   "dependencies": {@@ -143,4 +143,4 @@     "name": "John Lauer",     "email": "[email protected]"   }-}\ No newline at end of file+}
rust/Cargo.toml+1−1
@@ -4,7 +4,7 @@ members = ["core", "lbr", "os-win", "os-mac", "bridge"]  # One version for the whole bridge, in lockstep with BRIDGE_VERSION (tools/release.sh bumps both). [workspace.package]-version = "1.12.2"+version = "1.12.3" edition = "2021" license = "MIT" 
rust/bridge/src/orchestrations.rs+13−1
@@ -935,7 +935,19 @@ const INSPECT_BODIES_SCRIPT: &str = include_str!("inspect_bodies_script.py");  /// `fusion_open_cloud_file`: the add-in open, then the classified blocker and the post-open capture. pub fn open_cloud_file(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {-    let mut r = pass("open_cloud_file", args.clone());+    // A saved electronics design is several files of one name (.fprj, .fsch, .fbrd, .f3d). Opening+    // the board or schematic file raises Fusion's "links to multiple Electronics designs" chooser,+    // which cannot be answered headlessly (live 2026-10-27): with no extension or id named, the+    // design (.fprj) of that name is tried first, and the plain name match only when there is none.+    let named = args["fileExtension"].as_str().map_or(false, |e| !e.is_empty()) || args["fileId"].is_string() || args["urn"].is_string();+    let mut r = if named { pass("open_cloud_file", args.clone()) } else {+        let mut a = args.clone();+        a["fileExtension"] = json!("fprj");+        let first = pass("open_cloud_file", a);+        let missing = first["success"].as_bool() != Some(true)+            && first["error"].as_str().map_or(false, |e| { let e = e.to_lowercase(); e.contains("not found") || e.contains("no file") || e.contains("could not find") });+        if missing { pass("open_cloud_file", args.clone()) } else { let mut f = first; f["openedDesign"] = json!(true); f }+    };     let settle = if r["success"].as_bool() == Some(true) { 2.0 } else {         if let Some(ab) = fusion_core::ab::Loopback::from_env() {             let dialogs = crate::small_verbs::classify_blocking_dialogs(&ab, &ctx.caller, &ctx.pids);
rust/bridge/src/routing.rs+12−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"),     "\nFUSION_DEFAULT_RULES = r'''", include_str!("fusion_default_designrules.xml"), "'''\n");-pub fn owns(cmd: &str) -> bool { matches!(cmd, "routing_state" | "route_net" | "routing_drc" | "plan_route" | "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" | "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") }+pub fn owns(cmd: &str) -> bool { matches!(cmd, "routing_state" | "route_net" | "routing_drc" | "plan_route" | "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" | "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") } pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {     let script = format!("import json\nexec({})\nresult=handle(app,json.loads({}),{})\n",         serde_json::to_string(SOURCE).unwrap(),@@ -18,9 +18,19 @@ pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Val             let mut out = r["data"]["result"].clone();             for k in ["fusionUnexpectedCondition", "documentHygiene", "_hintDocumentHygiene"] { if !r[k].is_null() { out[k] = r[k].clone(); } }             if !r["fusionUnexpectedCondition"].is_null() { out["_hint"] = json!(format!("{} Fusion Electronics raised its 'unexpected condition' assertion after this edit and the bridge clicked OK; the copper read-back above is the truth. Checkpoint now (fusion_export_eagle_source).", out["_hint"].as_str().unwrap_or("")).trim().to_string()); }+            // the board from a file is saved only when asked and only after an exact read back:+            // a new version of the same design, never a new design (page issue #125, item 4)+            if cmd == "update_board_from_eagle" && out["mutated"].as_bool() == Some(true) {+                out["saved"] = json!(false);+                if args["save"].as_bool() == Some(true) && out["verified"].as_bool() == Some(true) {+                    let sv = crate::savedoc::save_document(&json!({"documentName": out["documentName"], "comment": args["comment"].as_str().unwrap_or("fusion-bridge: board updated from an EAGLE file")}), pass);+                    out["saved"] = json!(sv["success"].as_bool() == Some(true) && sv["saved"].as_bool() != Some(false));+                    out["save"] = sv;+                }+            }             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")}),+        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")}),     } } 
rust/bridge/src/routing_script.py+223
@@ -1302,6 +1302,228 @@ def autoroute(app, root, board, meta, args):             '_hint': "Fusion's autorouter ran on the listed nets; existing copper was kept. unrouted are nets it could not finish: route them with fusion_route_net or rip up and retry. Check after.errors with fusion_run_drc."}  +def load_board_file(path):+    """A board file on the Fusion machine: EAGLE .brd XML, or the .brd inside a Fusion .fbrd."""+    pth = Path(path)+    if not pth.exists():+        fail('file_not_found', 'No file at %s on the Fusion machine' % path)+    raw = pth.read_bytes()+    if zipfile.is_zipfile(pth):+        with zipfile.ZipFile(pth) as z:+            names = [n for n in z.namelist() if n.endswith('.brd')]+            if len(names) != 1:+                fail('invalid_file', 'Expected one board inside %s, found %d' % (path, len(names)))+            raw = z.read(names[0])+    try:+        root = ET.fromstring(raw)+    except ET.ParseError as e:+        fail('invalid_file', 'Not a board file: %s' % e)+    board = root.find('drawing/board')+    if board is None:+        fail('invalid_file', '%s holds no board' % path)+    return root, board+++def _netlist_of(board):+    return {s.get('name'): sorted((c.get('element'), c.get('pad')) for c in s.findall('contactref')) for s in board.findall('signals/signal')}+++def _elements_of(board):+    return {e.get('name'): {'x': round(float(e.get('x')), 4), 'y': round(float(e.get('y')), 4), 'rot': (e.get('rot') or 'R0').lstrip('S') or 'R0',+                            'package': e.get('package'), 'library': e.get('library')} for e in board.findall('elements/element')}+++def _plain_wires(board, layers):+    return sorted((int(w.get('layer')), round(_f(w, 'x1'), 4), round(_f(w, 'y1'), 4), round(_f(w, 'x2'), 4), round(_f(w, 'y2'), 4),+                   round(_f(w, 'width'), 4), round(_f(w, 'curve'), 3)) for w in board.findall('plain/wire') if int(w.get('layer', '0')) in layers)+++def _norm_wire(t):+    """A wire with its ends in a fixed order, so a wire drawn the other way compares equal."""+    layer, x1, y1, x2, y2, w, c = t+    if (x2, y2) < (x1, y1):+        return (layer, x2, y2, x1, y1, w, -c)+    return t+++def _holes(board):+    return sorted((round(_f(h, 'x'), 4), round(_f(h, 'y'), 4), round(_f(h, 'drill'), 4)) for h in board.findall('plain/hole'))+++def _pour_rows(board):+    """Each pour as (net, layer, width, isolate, rank, outline). An EAGLE file writes <polygon>+    with its vertices; Fusion's own export writes <polygonpour> with them under+    <polygonoutlineobjects> (and its fill beside them), and no pour, orphans or thermals flags:+    only what both carry is compared."""+    rows = []+    for s_ in board.findall('signals/signal'):+        for p in list(s_.findall('polygon')) + list(s_.findall('polygonpour')):+            vs = p.findall('vertex') if p.tag == 'polygon' else p.findall('polygonoutlineobjects/vertex')+            verts = tuple((round(_f(v, 'x'), 4), round(_f(v, 'y'), 4), round(_f(v, 'curve'), 3)) for v in vs)+            rows.append((s_.get('name'), int(p.get('layer')), round(_f(p, 'width'), 4), round(_f(p, 'isolate'), 4), int(p.get('rank', '1')), verts))+    return rows+++def _pour_key(r):+    """A pour by net, layer, pen and outline, whichever vertex it starts on."""+    return r[:5] + (tuple(sorted(r[5])),)+++def update_board_from_eagle(app, root, board, meta, args):+    """Make the open design's board the board in an EAGLE file, in place: parts moved and turned+    by name, tracks and vias ripped up and drawn again from the file, pours, the outline, holes and+    net-less copper replaced; then the whole board read back against the file. Saving is the+    caller's (save:true, after a verified read back), so the cloud design changes only when+    the board in Fusion is exactly the file's."""+    if args.get('expectedRevision') not in (None, meta['revision']):+        fail('stale_board', 'expectedRevision must match the current live board')+    path = args.get('brdPath') or args.get('filePath')+    if not path:+        fail('bad_args', 'brdPath: the .brd (or .fbrd) on the Fusion machine')+    _, new = load_board_file(path)+    # the parts and the nets come from the schematic: a file that changes them is a schematic change+    e0, e1 = _elements_of(board), _elements_of(new)+    added, removed = sorted(set(e1) - set(e0)), sorted(set(e0) - set(e1))+    swapped = sorted(n for n in set(e0) & set(e1) if (e0[n]['package'], e0[n]['library']) != (e1[n]['package'], e1[n]['library']))+    n0, n1 = _netlist_of(board), _netlist_of(new)+    nets_diff = sorted(n for n in set(n0) | set(n1) if n0.get(n) != n1.get(n))+    if added or removed or swapped or nets_diff:+        fail('schematic_differs', 'The file changes what the schematic decides: %s. Update the schematic first (or import the files as a new design)' % '; '.join(x for x in (+            ('parts added %s' % added[:10]) if added else '', ('parts removed %s' % removed[:10]) if removed else '',+            ('packages changed %s' % swapped[:10]) if swapped else '', ('nets with other pads %s' % nets_diff[:10]) if nets_diff else '') if x))+    L0 = Layers(board)+    moves = [n for n in sorted(e1) if (e0[n]['x'], e0[n]['y'], e0[n]['rot']) != (e1[n]['x'], e1[n]['y'], e1[n]['rot'])]+    _, _, w_old, v_old = objects(board)+    _, _, w_new, v_new = objects(new)+    copper_same = copper_signature(w_old + v_old) == copper_signature(w_new + v_new)+    pours_same = sorted(map(_pour_key, _pour_rows(board))) == sorted(map(_pour_key, _pour_rows(new)))+    cu_layers = set(COPPER)+    outline_same = sorted(map(_norm_wire, _plain_wires(board, {20}))) == sorted(map(_norm_wire, _plain_wires(new, {20})))+    loose_same = sorted(map(_norm_wire, _plain_wires(board, cu_layers))) == sorted(map(_norm_wire, _plain_wires(new, cu_layers)))+    holes_same = _holes(board) == _holes(new)+    # not synced: what is not copper, parts, outline or holes+    def unsynced_plain(b):+        keep = cu_layers | {20}+        return sorted(ET.tostring(x) for x in b.find('plain') if not (x.tag == 'hole' or (x.tag == 'wire' and int(x.get('layer', '0')) in keep)))+    others = {'silkscreen and documentation (plain texts, lines and shapes off the copper and outline layers)': unsynced_plain(board) != unsynced_plain(new),+              'design rules': (ET.tostring(board.find('designrules')) if board.find('designrules') is not None else b'') != (ET.tostring(new.find('designrules')) if new.find('designrules') is not None else b''),+              'name and value positions': sorted(ET.tostring(a) for e in board.findall('elements/element') for a in e.findall('attribute')) != sorted(ET.tostring(a) for e in new.findall('elements/element') for a in e.findall('attribute'))}+    plan = {'partsMoved': moves, 'copper': not copper_same, 'pours': not pours_same, 'outline': not outline_same, 'looseCopper': not loose_same, 'holes': not holes_same}+    if args.get('dryRun'):+        return {'success': True, 'mutated': False, **meta, 'plan': plan, 'notSynced': [k for k, v in others.items() if v],+                'tracks': len(w_new), 'vias': len(v_new), 'pours': len(_pour_rows(new))}+    if not any([moves, not copper_same, not pours_same, not outline_same, not loose_same, not holes_same]):+        return {'success': True, 'mutated': False, **meta, 'plan': plan, 'note': 'The board already is the file\'s (parts, copper, pours, outline, holes)',+                'notSynced': [k for k, v in others.items() if v]}+    visible = _visible_layers(root)+    lines = []+    def batch(cmds, size=30):+        for k in range(0, len(cmds), size):+            lines.append(' '.join(cmds[k:k + size]))+    try:+        # 1. tracks and vias up (pours stay until 2): every net that has any, now or in the file+        if not copper_same:+            nets = sorted({o['net'] for o in w_old + v_old})+            batch(["RIPUP '%s';" % identifier(n) for n in nets])+        # 2. old pours out: shift-click on the outline point farthest from every pad+        if not pours_same:+            pad_pts = [p_['at'] for p_ in objects(board)[1].values()]+            cmds = []+            for e in pours(board):+                ol = e['outline']; best = None+                for a_, b_ in zip(ol, ol[1:] + ol[:1]):+                    for t_ in (0.25, 0.5, 0.75):+                        q = (a_[0] + (b_[0] - a_[0]) * t_, a_[1] + (b_[1] - a_[1]) * t_)+                        dmin = min((math.hypot(q[0] - pp[0], q[1] - pp[1]) for pp in pad_pts), default=99)+                        if best is None or dmin > best[0]:+                            best = (dmin, q)+                if best[0] < 0.5:+                    fail('unsafe_pick', 'No outline point of the %s pour on %s is 0.5 mm clear of a pad: nothing was changed' % (e['net'], L0.name(e['layer'])))+                cmds.append('DISPLAY NONE %d; DELETE (S %.6fmm %.6fmm);' % (e['layer'], best[1][0], best[1][1]))+            batch(cmds, 10)+        # 3. parts to their places, by name+        batch(["MOVE '%s' (%.6fmm %.6fmm); ROTATE =%s '%s';" % (identifier(n), e1[n]['x'], e1[n]['y'], e1[n]['rot'], identifier(n)) for n in moves])+        # 4. the outline, loose copper and holes: old ones picked with only their layer shown, new ones drawn+        def redo_wires(layers):+            cmds = []+            for wv in board.findall('plain/wire'):+                if int(wv.get('layer', '0')) not in layers:+                    continue+                p1, p2, c = (_f(wv, 'x1'), _f(wv, 'y1')), (_f(wv, 'x2'), _f(wv, 'y2')), _f(wv, 'curve')+                pts_ = wire_points(p1, p2, c)+                mid = pts_[len(pts_) // 2] if c else ((p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2)+                cmds.append('DISPLAY NONE %s; DELETE (%.6fmm %.6fmm);' % (wv.get('layer'), mid[0], mid[1]))+            for wv in new.findall('plain/wire'):+                if int(wv.get('layer', '0')) not in layers:+                    continue+                c = _f(wv, 'curve')+                cmds.append('LAYER %s; WIRE %.6fmm (%.6fmm %.6fmm) %s(%.6fmm %.6fmm);' % (wv.get('layer'), _f(wv, 'width'), _f(wv, 'x1'), _f(wv, 'y1'), ('%+g ' % c) if c else '', _f(wv, 'x2'), _f(wv, 'y2')))+            batch(cmds, 20)+        if not outline_same:+            redo_wires({20})+        if not loose_same:+            redo_wires(cu_layers)+        if not holes_same:+            cmds = ['DISPLAY NONE 45; DELETE (%.6fmm %.6fmm);' % (x, y) for x, y, _ in _holes(board)]+            cmds += ['CHANGE DRILL %.6fmm; HOLE (%.6fmm %.6fmm);' % (d, x, y) for x, y, d in _holes(new)]+            batch(cmds, 20)+        # 5. the file's tracks and vias, each on its own net by name+        if not copper_same:+            cmds = []+            for s_ in new.findall('signals/signal'):+                n = identifier(s_.get('name'))+                for wv in s_.findall('wire'):+                    if wv.get('layer') == '19':+                        continue+                    c = _f(wv, 'curve')+                    cmds.append("LAYER %s; WIRE '%s' %.6fmm (%.6fmm %.6fmm) %s(%.6fmm %.6fmm);" % (wv.get('layer'), n, _f(wv, 'width'), _f(wv, 'x1'), _f(wv, 'y1'), ('%+g ' % c) if c else '', _f(wv, 'x2'), _f(wv, 'y2')))+                for v in s_.findall('via'):+                    shape = {'square': 'SQUARE', 'octagon': 'OCTAGON'}.get(v.get('shape', 'round'), 'ROUND')+                    dia = _f(v, 'diameter') or 0+                    cmds.append("CHANGE DRILL %.6fmm; VIA '%s' %s%s %s (%.6fmm %.6fmm);" % (_f(v, 'drill'), n, ('%.6fmm ' % dia) if dia else '', shape, v.get('extent', '1-16'), _f(v, 'x'), _f(v, 'y')))+            batch(cmds)+        # 6. the file's pours, with their own settings+        if not pours_same:+            cmds = []+            for s_ in new.findall('signals/signal'):+                for p in s_.findall('polygon'):+                    verts = p.findall('vertex')+                    path_ = ''+                    for k, v in enumerate(verts + verts[:1]):+                        path_ += '(%.6fmm %.6fmm) ' % (_f(v, 'x'), _f(v, 'y'))+                        c = _f(v, 'curve')+                        if c and k < len(verts):+                            path_ += '%+g ' % c+                    cmds.append("CHANGE ISOLATE %.6fmm; CHANGE RANK %s; CHANGE POUR %s; CHANGE ORPHANS %s; CHANGE THERMALS %s; LAYER %s; POLYGON '%s' %.6fmm %s;" % (+                        _f(p, 'isolate'), p.get('rank', '1'), p.get('pour', 'solid').upper(), 'ON' if p.get('orphans') == 'yes' else 'OFF',+                        'OFF' if p.get('thermals') == 'no' else 'ON', p.get('layer'), identifier(s_.get('name')), _f(p, 'width'), path_.strip()))+            batch(cmds, 10)+        for ln in lines:+            app.executeTextCommand('Electron.run ' + ln)+    finally:+        _restore_display(app, visible)+    app.executeTextCommand('Commands.Start Electron::PolyShowFillAll')+    app.executeTextCommand('Electron.run RATSNEST;')+    app.executeTextCommand('Electron.ImmediateRepaint /full')+    _, after, meta2 = snapshot(app, args)+    _, _, w_a, v_a = objects(after)+    checks = {'parts': _elements_of(after) == e1 or {n: (v['x'], v['y'], v['rot']) for n, v in _elements_of(after).items()} == {n: (v['x'], v['y'], v['rot']) for n, v in e1.items()},+              'copper': copper_signature(w_a + v_a) == copper_signature(w_new + v_new),+              'pours': sorted(map(_pour_key, _pour_rows(after))) == sorted(map(_pour_key, _pour_rows(new))),+              'outline': sorted(map(_norm_wire, _plain_wires(after, {20}))) == sorted(map(_norm_wire, _plain_wires(new, {20}))),+              'looseCopper': sorted(map(_norm_wire, _plain_wires(after, cu_layers))) == sorted(map(_norm_wire, _plain_wires(new, cu_layers))),+              'holes': _holes(after) == _holes(new),+              'nets': _netlist_of(after) == n1}+    wrong = sorted(k for k, ok in checks.items() if not ok)+    st = state(after, meta2)+    out = {'success': not wrong, 'mutated': True, 'verified': not wrong, **meta2, 'plan': plan, 'commandLines': len(lines),+           'notSynced': [k for k, v in others.items() if v], 'unconnected': st['unconnected'], 'tracks': len(w_a), 'vias': len(v_a)}+    if wrong:+        out.update(errorCode='readback_mismatch', mismatched=wrong,+                   error='The board in Fusion is not the file\'s in: %s. Nothing was saved: close the design without saving to discard these edits' % ', '.join(wrong))+    return out++ def handle(app,args,action):     attempted=False; commands=[]     try:@@ -1330,6 +1552,7 @@ def handle(app,args,action):         if action=='apply_silkscreen':             return apply_silkscreen(app,root,board,meta,args)         if action=='autoroute': return autoroute(app,root,board,meta,args)+        if action=='update_board_from_eagle': return update_board_from_eagle(app,root,board,meta,args)         if action=='run_erc': return run_erc(app,root,board,meta,args)         if action=='set_layer_visibility': return set_layer_visibility(app,root,board,meta,args)         if action=='select_net': return select_net(app,root,board,meta,args)
tests/run_verb_tests.py+3−1
@@ -226,6 +226,8 @@ t("fusion_sch_update_from_library#refuse", "negative", B, {"pred": "must_refuse" for v in ("fusion_sch_text", "fusion_sch_junction", "fusion_sch_delete_wire", "fusion_sch_connect_pins", "fusion_sch_split_wire", "fusion_sch_bus",           "fusion_sch_edit_label", "fusion_sch_move_region", "fusion_sch_bus_entry", "fusion_sch_connect_to_bus", "fusion_sch_reset_fields", "fusion_sch_create"):     t(v, "manual", note="schematic edit: tested live on a scratch import of BQ25792 (never the cloud design)")+t("fusion_update_board_from_eagle#guard", "negative", dict(B, brdPath="C:/nonexistent/none.brd"), {"pred": "must_refuse"}, "a missing file is refused before anything changes")+t("fusion_update_board_from_eagle", "manual", note="writes a saved design: tested live on fl-sync-test in Default Project / fusion-lane-tests (#125 item 4)") t("fusion_export_bom#parts", "daily", {"outputPath": f"{SCRATCH}/bom-parts.csv"}, {"ok": True, "pred": "bom_has_part_numbers"}, "1.12.0: MPN/LCSC columns from the part attributes (page issue #105)") t("fusion_list_text_commands", "daily", {"filter": "Electron"}, {"ok": True, "fields": ["commands"]}, "TextCommands.List needs the electronics editor context") t("fusion_export_source", "weekly", {"outputPath": f"{SCRATCH}/verbtest.fbrd"}, {"ok": True, "fields": ["savedTo"]}, ".fbrd needs the 2D board view ACTIVE (Document.CopyToDesktop)")@@ -418,7 +420,7 @@ _SECTION_BY_VERB = {     "fusion_verb_times": "bridge-lifecycle", "fusion_live_feed": "bridge-lifecycle", "fusion_account": "auth-tokens", "fusion_claim_seat": "auth-tokens", "fusion_release_seat": "auth-tokens", "fusion_prewarm": "bridge-lifecycle", "fusion_prove_result": "cad-3d-bom",     "fusion_set_driving_badge": "bridge-lifecycle", "fusion_set_window_sizing": "gui-driving", "fusion_cancel_run": "cad-3d-bom",     "fusion_preflight_lbr": "cad-3d-bom", "fusion_library_parts": "cad-3d-bom", "fusion_take_screenshot": "gui-driving",-    "fusion_schematic": "gui-driving", "fusion_show_attributes": "cad-3d-bom", "fusion_make_showcase_project": "cad-3d-bom", "fusion_eagle_import_status": "cad-3d-bom", "fusion_routing_state": "routing", "fusion_route_net": "routing", "fusion_routing_drc": "routing", "fusion_plan_route": "routing", "fusion_move_parts": "routing", "fusion_place_vias": "routing", "fusion_apply_pours": "routing", "fusion_pour_state": "routing", "fusion_board_source": "routing", "fusion_ripup_nets": "routing", "fusion_schematic_run": "routing", "fusion_board_bom": "routing", "fusion_change_pours": "routing", "fusion_board_seed": "routing", "fusion_schematic_plan": "routing", "fusion_remove_pours": "routing", "fusion_layer_stack": "routing", "fusion_netlist": "routing", "fusion_read_sheet_file": "routing", "fusion_bodies": "routing", "fusion_save_document": "aps", "fusion_apply_silkscreen": "routing", "fusion_component_data": "routing", "fusion_component_pins": "routing", "fusion_list_nets": "routing", "fusion_net_connections": "routing", "fusion_list_designators": "routing", "fusion_layers": "routing", "fusion_copper_layers": "routing", "fusion_layers_in_use": "routing", "fusion_read_rules": "routing", "fusion_rules_by_kind": "routing", "fusion_silk_state": "routing", "fusion_net_classes": "routing", "fusion_run_drc": "routing", "fusion_repour": "routing", "fusion_zoom_to": "routing", "fusion_launch": "bridge-lifecycle", "fusion_export_gerber": "exports", "fusion_dismiss_dialogs": "gui-driving", "fusion_board_summary": "routing", "fusion_install": "bridge-lifecycle", "fusion_sch_items": "routing", "fusion_sch_pins": "routing", "fusion_sch_trace_point": "routing", "fusion_sch_page": "routing", "fusion_sch_hierarchy": "routing", "fusion_sch_sheet_pins": "routing", "fusion_sch_connect_pins": "routing", "fusion_sch_fix_connectivity": "routing", "fusion_render_schematic": "routing", "fusion_sch_update_from_library": "routing", "fusion_sch_text": "routing", "fusion_sch_junction": "routing", "fusion_sch_delete_wire": "routing", "fusion_sch_split_wire": "routing", "fusion_sch_bus": "routing", "fusion_sch_edit_label": "routing", "fusion_sch_move_region": "routing", "fusion_sch_bus_entry": "routing", "fusion_sch_connect_to_bus": "routing", "fusion_sch_reset_fields": "routing", "fusion_sch_create": "routing", "fusion_select_net": "routing", "fusion_set_layer_visibility": "routing", "fusion_board_outline": "routing", "fusion_run_erc": "routing", "fusion_set_track_width": "routing", "fusion_remove_items": "routing", "fusion_set_outline": "routing", "fusion_board_shape": "routing", "fusion_autoroute": "routing", "fusion_check_dialogs": "gui-driving", "fusion_status": "bridge-lifecycle", "fusion_save": "aps", "fusion_run_script": "gui-driving", "fusion_run_command": "gui-driving", "fusion_list_commands": "gui-driving", "fusion_dialog_log": "gui-driving", "fusion_signin_status": "bridge-lifecycle", "fusion_license_status": "bridge-lifecycle", "fusion_open_document": "cad-3d-bom", "fusion_show_3d_chip": "cad-3d-bom",+    "fusion_schematic": "gui-driving", "fusion_show_attributes": "cad-3d-bom", "fusion_make_showcase_project": "cad-3d-bom", "fusion_eagle_import_status": "cad-3d-bom", "fusion_routing_state": "routing", "fusion_route_net": "routing", "fusion_routing_drc": "routing", "fusion_plan_route": "routing", "fusion_move_parts": "routing", "fusion_place_vias": "routing", "fusion_apply_pours": "routing", "fusion_pour_state": "routing", "fusion_board_source": "routing", "fusion_ripup_nets": "routing", "fusion_schematic_run": "routing", "fusion_board_bom": "routing", "fusion_change_pours": "routing", "fusion_board_seed": "routing", "fusion_schematic_plan": "routing", "fusion_remove_pours": "routing", "fusion_layer_stack": "routing", "fusion_netlist": "routing", "fusion_read_sheet_file": "routing", "fusion_bodies": "routing", "fusion_save_document": "aps", "fusion_apply_silkscreen": "routing", "fusion_component_data": "routing", "fusion_component_pins": "routing", "fusion_list_nets": "routing", "fusion_net_connections": "routing", "fusion_list_designators": "routing", "fusion_layers": "routing", "fusion_copper_layers": "routing", "fusion_layers_in_use": "routing", "fusion_read_rules": "routing", "fusion_rules_by_kind": "routing", "fusion_silk_state": "routing", "fusion_net_classes": "routing", "fusion_run_drc": "routing", "fusion_repour": "routing", "fusion_zoom_to": "routing", "fusion_launch": "bridge-lifecycle", "fusion_export_gerber": "exports", "fusion_dismiss_dialogs": "gui-driving", "fusion_board_summary": "routing", "fusion_install": "bridge-lifecycle", "fusion_sch_items": "routing", "fusion_sch_pins": "routing", "fusion_sch_trace_point": "routing", "fusion_sch_page": "routing", "fusion_sch_hierarchy": "routing", "fusion_sch_sheet_pins": "routing", "fusion_sch_connect_pins": "routing", "fusion_sch_fix_connectivity": "routing", "fusion_render_schematic": "routing", "fusion_sch_update_from_library": "routing", "fusion_sch_text": "routing", "fusion_sch_junction": "routing", "fusion_sch_delete_wire": "routing", "fusion_sch_split_wire": "routing", "fusion_sch_bus": "routing", "fusion_sch_edit_label": "routing", "fusion_sch_move_region": "routing", "fusion_sch_bus_entry": "routing", "fusion_sch_connect_to_bus": "routing", "fusion_sch_reset_fields": "routing", "fusion_sch_create": "routing", "fusion_update_board_from_eagle": "routing", "fusion_select_net": "routing", "fusion_set_layer_visibility": "routing", "fusion_board_outline": "routing", "fusion_run_erc": "routing", "fusion_set_track_width": "routing", "fusion_remove_items": "routing", "fusion_set_outline": "routing", "fusion_board_shape": "routing", "fusion_autoroute": "routing", "fusion_check_dialogs": "gui-driving", "fusion_status": "bridge-lifecycle", "fusion_save": "aps", "fusion_run_script": "gui-driving", "fusion_run_command": "gui-driving", "fusion_list_commands": "gui-driving", "fusion_dialog_log": "gui-driving", "fusion_signin_status": "bridge-lifecycle", "fusion_license_status": "bridge-lifecycle", "fusion_open_document": "cad-3d-bom", "fusion_show_3d_chip": "cad-3d-bom",     "fusion_apply_fusion_update": "bridge-lifecycle", "fusion_resolve_seat_dialog": "bridge-lifecycle", "fusion_interactive": "gui-driving", "fusion_narrate": "gui-driving", "fusion_view": "gui-driving", "fusion_electronics_query": "cad-3d-bom",     "fusion_activate_document": "gui-driving", "fusion_library_manager": "gui-driving", "fusion_plan_library_import": "cad-3d-bom",     "fusion_create_library": "cad-3d-bom", "fusion_install_library_bundle": "cad-3d-bom",
tests/test_routing.py+39
@@ -255,4 +255,43 @@ class EditVerbTests(unittest.TestCase):         self.assertEqual('layers_not_enabled', e.exception.code); self.assertIn('(2)', str(e.exception)); self.assertEqual([], self.sent)  +class BoardFromEagleTests(unittest.TestCase):+    """update_board_from_eagle plans from a file and refuses a file that changes the schematic."""+    def setUp(self):+        import tempfile+        self.tmp = tempfile.mkdtemp()+        self.root = ET.parse(ROOT/'demo/routing/fixture/fusion-routing.brd').getroot()+        self.board = self.root.find('drawing/board')+        self.saved = r.snapshot+        r.snapshot = lambda app, args: (self.root, self.board, {'boardId': 'b', 'revision': 'r1'})+    def tearDown(self): r.snapshot = self.saved+    def write(self, fn):+        root = copy.deepcopy(self.root); fn(root.find('drawing/board'))+        p = Path(self.tmp) / 'new.brd'; ET.ElementTree(root).write(p); return str(p)+    def test_plan_moves_and_copper(self):+        def edit(b):+            el = b.find("elements/element[@name='J2']"); el.set('x', str(float(el.get('x')) + 2.54))+            ET.SubElement(b.find("signals/signal[@name='NET_1']"), 'wire', x1='7', y1='7', x2='20', y2='7', width='0.25', layer='1')+        out = r.update_board_from_eagle(None, self.root, self.board, {'boardId': 'b', 'revision': 'r1'}, {'brdPath': self.write(edit), 'dryRun': True})+        self.assertEqual(['J2'], out['plan']['partsMoved'])+        self.assertTrue(out['plan']['copper']); self.assertFalse(out['plan']['outline']); self.assertFalse(out['plan']['pours'])+    def test_same_file_changes_nothing(self):+        out = r.update_board_from_eagle(None, self.root, self.board, {'boardId': 'b', 'revision': 'r1'}, {'brdPath': self.write(lambda b: None)})+        self.assertFalse(out['mutated'])+    def test_fusion_and_eagle_pours_compare_equal(self):+        # Fusion exports a pour as <polygonpour> with <polygonoutlineobjects>; a file has <polygon>+        fus = ET.fromstring('<board><signals><signal name="N"><polygonpour layer="16" isolate="0.3" width="0.254"><polygonoutlineobjects>'+                            '<vertex x="52" y="20"/><vertex x="52" y="30"/><vertex x="2" y="30"/><vertex x="2" y="20"/></polygonoutlineobjects></polygonpour></signal></signals></board>')+        eag = ET.fromstring('<board><signals><signal name="N"><polygon width="0.254" layer="16" isolate="0.3" rank="1">'+                            '<vertex x="2" y="20"/><vertex x="52" y="20"/><vertex x="52" y="30"/><vertex x="2" y="30"/></polygon></signal></signals></board>')+        self.assertEqual(sorted(map(r._pour_key, r._pour_rows(fus))), sorted(map(r._pour_key, r._pour_rows(eag))))+    def test_schematic_changes_are_refused(self):+        def edit(b):+            c = b.find("signals/signal[@name='NET_1']/contactref"); c.set('pad', '99')+        with self.assertRaises(r.RoutingError) as e:+            r.update_board_from_eagle(None, self.root, self.board, {'boardId': 'b', 'revision': 'r1'}, {'brdPath': self.write(edit)})+        self.assertEqual('schematic_differs', e.exception.code)+        self.assertIn('NET_1', str(e.exception))++ if __name__=='__main__':unittest.main()