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
plate_vias, cleanup_libs, sch_update_from_library plan (standard#29)
10 files changed
+298−5
CHANGELOG.md+10@@ -5,6 +5,16 @@ Canonical: https://wiki.adom.inc/adom/fusion-bridge ยท GitHub backup: `adom-inc/ ## 1.13.28 (unreleased) +- Three standard verbs that were not_supported now work (eda-bridge-standard#29, every verb):+ - `fusion_plate_vias`: each unplated `<hole>` in a .lbr's footprints becomes a plated pad. One inside an SMD land (a+ heatsink via in an exposed pad) is named N@k after the land and joined to its connects, so it is on the land's+ net; any other is H<k>. Offline, with a .bak and the preflight.+ - `fusion_cleanup_libs`: a .lbr's empty leftovers removed (components with no device, and footprints and symbols+ with nothing drawn that nothing uses).+ - `fusion_sch_update_from_library`, the plan: each placed part's component as the schematic holds it against the+ same-named deviceset in the .lbr files named (gates, pins added/removed/changed, symbol drawing, attributes),+ and the wired pins a new symbol would drop. apply is refused with the plan (Fusion's UPDATE asks per part).+ - `fusion_restart_for_addin` no longer meets Fusion's Unsaved changes box when the add-in cannot save (issue #129). A save whose add-in connection dropped is retried once after 5 s (the add-in can be unreachable for a moment right after `bridge_install`). If the add-in still answers its status probe but the save failed, the restart
bridge.json+3@@ -78,6 +78,7 @@ "fusion_check_recovery", "fusion_claim_seat", "fusion_cleanup_cloud_files",+ "fusion_cleanup_libs", "fusion_clone_symbol", "fusion_close_all_documents", "fusion_close_document",@@ -235,6 +236,7 @@ "fusion_plan_fanout", "fusion_plan_library_import", "fusion_plan_route",+ "fusion_plate_vias", "fusion_pour_state", "fusion_preflight_lbr", "fusion_prefs_close",@@ -308,6 +310,7 @@ "fusion_sch_text", "fusion_sch_trace_point", "fusion_sch_update_from_library",+ "fusion_sch_update_from_library", "fusion_sch_zoom", "fusion_schematic", "fusion_schematic_data",
contracts/describe.json+62@@ -1221,6 +1221,25 @@ "summary": "PRECISELY delete a list of cloud files by lineage urn - SAFE cleanup of AI-created clutter. Deletes ONLY the exact fileIds given (never name-guessing), so it can't touch a teammate's file in a shared folder. Loops server-side. Get the ids from fusion_aps_browse (item .id). Use this to remove f3d files the bridge created. A big list takes minutes (relay may time out at ~60s while deletes continue server-side - re-browse to confirm).", "timeoutSeconds": 1800 },+ {+ "example": {+ "dryRun": true,+ "libraryPath": "C:/libs/parts.lbr"+ },+ "input": {+ "dryRun": "bool",+ "libraryPath": ".lbr on the Fusion machine (required)"+ },+ "longRunning": false,+ "name": "fusion_cleanup_libs",+ "output": {+ "backup": "str",+ "data": "{removed: int, items: ['component X' | 'symbol X' | 'footprint X']}"+ },+ "statusVerb": null,+ "summary": "Remove the empty leftovers from a library (the standard's cleanup_libs), offline in a .lbr: components with no device (Altium's factory Component_1 has the same role), and footprints and symbols with nothing drawn that no component uses. Anything with content stays, used or not. Written with a .bak and preflighted; dryRun lists what would go.",+ "timeoutSeconds": 30+ }, { "example": {}, "input": {@@ -19915,6 +19934,26 @@ "summary": "Plan a path for one net between two pads on the live board without changing it (the standard's plan_route), from the shared checks engine (adom-eda-checks): a bounded orthogonal A* on any copper layers listed (default F.Cu and B.Cu), with through vias between them; pads by their real outline, the board's strictest copper clearance and copper-to-edge rule. Answered as routeNet, fusion_route_net's arguments (boardId and expectedRevision included), to pass on unchanged; found:false with a reason when no path fits. Pours are not obstacles: fusion_route_net undoes a route that cuts another net's pour in two. Fine-pitch pads need gridMm 0.1.", "timeoutSeconds": 60 },+ {+ "example": {+ "dryRun": true,+ "libraryPath": "C:/libs/parts.lbr"+ },+ "input": {+ "dryRun": "bool",+ "footprintName": "str (optional: one footprint; default all)",+ "libraryPath": ".lbr on the Fusion machine (required)"+ },+ "longRunning": false,+ "name": "fusion_plate_vias",+ "output": {+ "backup": "str",+ "data": "{holes: int, plated: int, fixed: 'Footprint:Pad;...', joinedToLands: int}"+ },+ "statusVerb": null,+ "summary": "Make every drilled hole in a library's footprints a plated pad (the standard's plate_vias), offline in a .lbr. In EAGLE a pad is always plated and an unplated drill is a <hole>, so each hole becomes a pad. A hole inside an SMD land (a heatsink via in an exposed pad) is named after the land (EP@2, EP@3 ...) and joined to that land's connect in every device, so it sits on the land's net; any other hole becomes H1, H2 ... and stays unconnected. The copper ring is left to the board's restring rules, as for any EAGLE via. Written with a .bak and preflighted; dryRun shows the plan.",+ "timeoutSeconds": 30+ }, { "example": {}, "input": {@@ -21657,6 +21696,29 @@ "summary": "Refused with unsupported, naming the libraries the design embeds: Fusion updates parts from their libraries in its Library Manager, which asks about each part, so there is no command the bridge can answer up front.", "timeoutSeconds": 60 },+ {+ "example": {+ "library": "C:/libs/parts.lbr"+ },+ "input": {+ "apply": "bool: refused with the plan (not_supported)",+ "expectDocument": "str",+ "filePath": "a .sch/.fsch to read instead of the open design",+ "library": ".lbr on the Fusion machine (or libraries: [.lbr]; required)",+ "symbols": "[deviceset names] (default every part)"+ },+ "longRunning": false,+ "name": "fusion_sch_update_from_library",+ "output": {+ "differs": "int",+ "parts": "[{designator, symbol, library, status: up_to_date|differs|library_not_found, diff?: {gates?, symbols?: {gate: {pinsAdded?, pinsRemoved?, pinsChanged?, drawingChanged?}}, attributes?, device?}, droppedWiredPins?}]",+ "plan": "true",+ "revision": "str"+ },+ "statusVerb": null,+ "summary": "Compare placed parts with their library (the standard's sch_update_from_library), the plan: each part's component as the schematic holds it (Fusion keeps a copy of every library a design uses) against the same-named deviceset in the .lbr files named: gates added or removed, each symbol's pins added, removed or changed (position, length, direction, turn), its drawing, and the device's attributes (MPN ...). Each part is up_to_date, differs or library_not_found; droppedWiredPins names pins the library's symbol drops that are wired on the sheet. apply is refused with the plan, because Fusion's UPDATE asks about each part in a dialog.",+ "timeoutSeconds": 60+ }, { "example": { "refs": [
describe.py+12@@ -458,6 +458,18 @@ _T = [ {"libraryPath": ".lbr on the Fusion machine (required)", "names": "[footprint or component names] (default all)"}, {"ok": "bool", "checked": "int", "errors": "int", "warnings": "int", "infos": "int", "findings": "[{name, kind, rule, severity, detail}]", "unread": "[]", "engine": "str"}, 30, None, False, {"libraryPath": "C:/libs/parts.lbr"}),+ ('fusion_plate_vias', "Make every drilled hole in a library's footprints a plated pad (the standard's plate_vias), offline in a .lbr. In EAGLE a pad is always plated and an unplated drill is a <hole>, so each hole becomes a pad. A hole inside an SMD land (a heatsink via in an exposed pad) is named after the land (EP@2, EP@3 ...) and joined to that land's connect in every device, so it sits on the land's net; any other hole becomes H1, H2 ... and stays unconnected. The copper ring is left to the board's restring rules, as for any EAGLE via. Written with a .bak and preflighted; dryRun shows the plan.",+ {"libraryPath": ".lbr on the Fusion machine (required)", "footprintName": "str (optional: one footprint; default all)", "dryRun": "bool"},+ {"data": "{holes: int, plated: int, fixed: 'Footprint:Pad;...', joinedToLands: int}", "backup": "str"},+ 30, None, False, {"libraryPath": "C:/libs/parts.lbr", "dryRun": True}),+ ('fusion_cleanup_libs', "Remove the empty leftovers from a library (the standard's cleanup_libs), offline in a .lbr: components with no device (Altium's factory Component_1 has the same role), and footprints and symbols with nothing drawn that no component uses. Anything with content stays, used or not. Written with a .bak and preflighted; dryRun lists what would go.",+ {"libraryPath": ".lbr on the Fusion machine (required)", "dryRun": "bool"},+ {"data": "{removed: int, items: ['component X' | 'symbol X' | 'footprint X']}", "backup": "str"},+ 30, None, False, {"libraryPath": "C:/libs/parts.lbr", "dryRun": True}),+ ('fusion_sch_update_from_library', "Compare placed parts with their library (the standard's sch_update_from_library), the plan: each part's component as the schematic holds it (Fusion keeps a copy of every library a design uses) against the same-named deviceset in the .lbr files named: gates added or removed, each symbol's pins added, removed or changed (position, length, direction, turn), its drawing, and the device's attributes (MPN ...). Each part is up_to_date, differs or library_not_found; droppedWiredPins names pins the library's symbol drops that are wired on the sheet. apply is refused with the plan, because Fusion's UPDATE asks about each part in a dialog.",+ {"library": ".lbr on the Fusion machine (or libraries: [.lbr]; required)", "symbols": "[deviceset names] (default every part)", "expectDocument": "str", "filePath": "a .sch/.fsch to read instead of the open design", "apply": "bool: refused with the plan (not_supported)"},+ {"plan": "true", "differs": "int", "parts": "[{designator, symbol, library, status: up_to_date|differs|library_not_found, diff?: {gates?, symbols?: {gate: {pinsAdded?, pinsRemoved?, pinsChanged?, drawingChanged?}}, attributes?, device?}, droppedWiredPins?}]", "revision": "str"},+ 60, None, False, {"library": "C:/libs/parts.lbr"}), ('fusion_update_footprints_from_library', "Compare placed footprints with their library (the standard's update_footprints_from_library), the plan: each part's footprint as the board holds it against the same-named package in the .lbr files named, pad by pad (added, removed, moved, resized, reshaped, drill, turn, layer), each up_to_date, differs or library_not_found. A Fusion design's libraries are cloud items, so the .lbr files are named. apply:true is refused (not_supported) with the plan: Fusion's UPDATE asks about each part in a dialog.", {"expectDocument": "the board (required)", "libraryPath": ".lbr to compare against", "libraries": "[.lbr] (instead of libraryPath)", "refs": "[ref] (default every part)", "apply": "refused: see above"}, {"plan": "true", "differs": "int", "footprints": "[{ref, footprint, library, status, diff?: {padsAdded, padsRemoved, padsChanged: [{pad, changes}]}, why?}]", "revision": "str"},
rust/bridge/src/main.rs+3−1@@ -691,7 +691,7 @@ fn main() { "fusion_edit_footprint" | "edit_footprint" => native_edit_footprint(&parsed["args"]), v @ ("fusion_create_footprint" | "create_footprint" | "fusion_create_symbol" | "create_symbol" | "fusion_read_symbol" | "read_symbol" | "fusion_find_symbol" | "find_symbol" | "fusion_copy_footprint" | "copy_footprint" | "fusion_rename_footprint" | "rename_footprint" | "fusion_remove_footprint" | "remove_footprint" | "fusion_xcopy_footprint" | "xcopy_footprint"- | "fusion_clone_symbol" | "clone_symbol" | "fusion_rename_symbol" | "rename_symbol" | "fusion_remove_symbol" | "remove_symbol" | "fusion_set_params" | "set_params" | "fusion_footprint_from_land_pattern" | "footprint_from_land_pattern" | "fusion_set_models" | "set_models" | "fusion_add_models" | "add_models" | "fusion_symbol_prims" | "symbol_prims" | "fusion_set_visibility" | "set_visibility" | "fusion_add_symbol_art" | "add_symbol_art" | "fusion_lint_library" | "lint_library") => native_lib_authoring(v.strip_prefix("fusion_").unwrap_or(v), &parsed["args"]),+ | "fusion_clone_symbol" | "clone_symbol" | "fusion_rename_symbol" | "rename_symbol" | "fusion_remove_symbol" | "remove_symbol" | "fusion_set_params" | "set_params" | "fusion_footprint_from_land_pattern" | "footprint_from_land_pattern" | "fusion_set_models" | "set_models" | "fusion_add_models" | "add_models" | "fusion_symbol_prims" | "symbol_prims" | "fusion_set_visibility" | "set_visibility" | "fusion_add_symbol_art" | "add_symbol_art" | "fusion_lint_library" | "lint_library" | "fusion_plate_vias" | "plate_vias" | "fusion_cleanup_libs" | "cleanup_libs") => native_lib_authoring(v.strip_prefix("fusion_").unwrap_or(v), &parsed["args"]), "fusion_preflight_lbr" | "preflight_lbr" => native_preflight_lbr(&parsed["args"]), "fusion_not_supported" | "not_supported" => unsupported::answer(&parsed["args"]), // Native since Phase 2: the raw authenticated APS GET (never-charge guarded).@@ -1082,6 +1082,8 @@ fn native_lib_authoring(verb: &str, args: &Value) -> Value { Some(prm) => a::set_visibility(text.as_deref().unwrap(), &s("symbolName").or(s("name")).unwrap_or_default(), &prm, args["visible"].as_bool().unwrap_or(true)), None => Err("parameter is required (the attribute to show or hide, e.g. MPN)".to_string()), },+ "plate_vias" => a::plate_vias(text.as_deref().unwrap(), s("footprintName").as_deref()),+ "cleanup_libs" => a::cleanup_libs(text.as_deref().unwrap()), "set_models" => a::set_models(text.as_deref().unwrap(), &s("symbolName").unwrap_or_default(), &s("footprint").unwrap_or_default()), "add_models" => { // a model's footprint from another .lbr is copied in; its pcblib is that library
rust/bridge/src/sch.rs+1−1@@ -2,7 +2,7 @@ //! thread through the script transport, as the routing verbs do. use serde_json::{json, Value}; const SOURCE: &str = include_str!("sch_script.py");-pub fn owns(cmd: &str) -> bool { matches!(cmd, "sch_items" | "sch_pins" | "sch_trace_point" | "sch_page" | "sch_hierarchy" | "sch_text" | "sch_junction" | "sch_delete_wire" | "sch_connect_pins" | "sch_split_wire" | "sch_bus" | "sch_edit_label" | "sch_move_region" | "sch_fix_connectivity" | "sch_bus_entry" | "sch_connect_to_bus" | "render_schematic" | "sch_reset_fields" | "sch_create" | "sch_sheet_pins" | "sch_update_from_library" | "cross_probe" | "cross_check" | "schematic_data" | "part_parameters" | "doc_params" | "sheet_params" | "sch_edit" | "annotate" | "sch_connect" | "sch_place_part" | "sch_swap_part" | "export_spice" | "simulate" | "sch_zoom" | "jlcpcb_order" | "variants") }+pub fn owns(cmd: &str) -> bool { matches!(cmd, "sch_update_from_library" | "sch_items" | "sch_pins" | "sch_trace_point" | "sch_page" | "sch_hierarchy" | "sch_text" | "sch_junction" | "sch_delete_wire" | "sch_connect_pins" | "sch_split_wire" | "sch_bus" | "sch_edit_label" | "sch_move_region" | "sch_fix_connectivity" | "sch_bus_entry" | "sch_connect_to_bus" | "render_schematic" | "sch_reset_fields" | "sch_create" | "sch_sheet_pins" | "sch_update_from_library" | "cross_probe" | "cross_check" | "schematic_data" | "part_parameters" | "doc_params" | "sheet_params" | "sch_edit" | "annotate" | "sch_connect" | "sch_place_part" | "sch_swap_part" | "export_spice" | "simulate" | "sch_zoom" | "jlcpcb_order" | "variants") } pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value { if cmd == "render_schematic" { return render_schematic(args, pass); } let script = format!("import json\nexec({})\nresult=handle(app,json.loads({}),{})\n",
rust/bridge/src/sch_script.py+99−1@@ -2058,6 +2058,97 @@ def variants(app, args): _hint='Variants live in the schematic; Fusion keeps the board in step. The variant asked is now the current one (EAGLE makes a new variant current). fusion_board_bom reads the fitted parts of the current variant.') +def _pins_of(sym):+ return {pn.get('name'): tuple(pn.get(k, '') for k in ('x', 'y', 'length', 'direction', 'rot')) for pn in sym.findall('pin')}+++def _art_of(sym):+ return sorted(ET.tostring(c).decode() for c in sym if c.tag != 'pin')+++def sch_update_from_library(root, args):+ """The standard's sch_update_from_library, the plan: each placed part's component as the schematic+ holds it (its embedded library) against the same-named deviceset in the .lbr files named: gates,+ each symbol's pins (added, removed, moved) and drawing, and the device's attributes. A pin the+ library drops that is wired on the sheet is named in droppedWiredPins (Altium's force case)."""+ paths = args.get('libraries') or ([args['library']] if args.get('library') else []) or ([args['libraryPath']] if args.get('libraryPath') else [])+ if not paths:+ sfail('bad_args', 'library (or libraries: [.lbr]) is required: a Fusion design keeps a copy of each library it uses, so name the .lbr files to compare against')+ libs = []+ for path in paths:+ try:+ lr = ET.parse(path).getroot()+ except Exception as ex:+ sfail('library_unreadable', '%s: %s' % (path, ex))+ lb = lr.find('drawing/library')+ if lb is None:+ sfail('library_unreadable', '%s holds no library' % path)+ libs.append((path, {s.get('name'): s for s in lb.findall('symbols/symbol')}, {d.get('name').lower(): d for d in lb.findall('devicesets/deviceset')}))+ sch = root.find('drawing/schematic')+ lib = Lib(sch)+ wired = set()+ for pr in sch.iter('pinref'):+ wired.add((pr.get('part'), pr.get('gate'), pr.get('pin')))+ want = {str(s).lower() for s in (args.get('symbols') or [])}+ rows, differs = [], 0+ for name, part in sorted(lib.parts.items()):+ dsn, ln = part.get('deviceset') or '', part.get('library') or ''+ if want and dsn.lower() not in want:+ continue+ held = lib.devicesets.get((ln, dsn))+ hit = next(((path, syms, dss[dsn.lower()]) for path, syms, dss in libs if dsn.lower() in dss), None)+ if held is None or hit is None:+ rows.append({'designator': name, 'symbol': dsn, 'library': ln if hit is None else hit[0], 'status': 'library_not_found',+ 'why': 'no deviceset of that name in the libraries given' if hit is None else 'the schematic holds no copy of it'})+ continue+ path, syms, ds = hit+ diff = {}+ g_held = {g.get('name'): g.get('symbol') for g in held.findall('gates/gate')}+ g_lib = {g.get('name'): g.get('symbol') for g in ds.findall('gates/gate')}+ if set(g_held) != set(g_lib):+ diff['gates'] = {'added': sorted(set(g_lib) - set(g_held)), 'removed': sorted(set(g_held) - set(g_lib))}+ dropped = []+ for g in sorted(set(g_held) & set(g_lib)):+ a, b = lib.symbols.get((ln, g_held[g])), syms.get(g_lib[g])+ if a is None or b is None:+ continue+ pa, pb = _pins_of(a), _pins_of(b)+ d = {}+ if set(pb) - set(pa):+ d['pinsAdded'] = sorted(set(pb) - set(pa))+ if set(pa) - set(pb):+ d['pinsRemoved'] = sorted(set(pa) - set(pb))+ dropped += ['%s.%s' % (g, x) if len(g_held) > 1 else x for x in d['pinsRemoved'] if (name, g, x) in wired]+ moved = sorted(x for x in set(pa) & set(pb) if pa[x] != pb[x])+ if moved:+ d['pinsChanged'] = moved+ if _art_of(a) != _art_of(b):+ d['drawingChanged'] = True+ if d:+ diff.setdefault('symbols', {})[g] = d+ dev = part.get('device') or ''+ tech = part.get('technology') or ''+ def attrs(dset):+ dv = next((x for x in dset.findall('devices/device') if (x.get('name') or '') == dev), None)+ if dv is None:+ return None+ tn = next((x for x in dv.findall('technologies/technology') if (x.get('name') or '') == tech), None)+ return {a.get('name'): a.get('value') for a in (tn.findall('attribute') if tn is not None else [])}+ ah, al = attrs(held), attrs(ds)+ if al is None:+ diff['device'] = 'the library has no device %r (technology %r) of %s' % (dev, tech, dsn)+ elif ah is not None and ah != al:+ diff['attributes'] = {k: {'held': ah.get(k), 'library': al.get(k)} for k in sorted(set(ah) | set(al)) if ah.get(k) != al.get(k)}+ row = {'designator': name, 'symbol': dsn, 'library': path, 'status': 'differs' if diff else 'up_to_date'}+ if diff:+ differs += 1+ row['diff'] = diff+ if dropped:+ row['droppedWiredPins'] = dropped+ rows.append(row)+ return {'plan': True, 'differs': differs, 'parts': rows}++ EDITS = {'doc_params': doc_params, 'sheet_params': sheet_params, 'sch_edit': sch_edit, 'annotate': annotate, 'sch_text': sch_text, 'sch_junction': sch_junction, 'sch_delete_wire': sch_delete_wire, 'sch_connect_pins': sch_connect_pins, 'sch_split_wire': sch_split_wire, 'sch_bus': sch_bus, 'sch_edit_label': sch_edit_label, 'sch_move_region': sch_move_region, 'sch_fix_connectivity': sch_fix_connectivity, 'sch_bus_entry': sch_bus_entry, 'sch_connect_to_bus': sch_connect_to_bus, 'sch_reset_fields': sch_reset_fields, 'sch_create': sch_create, 'sch_connect': sch_connect, 'sch_place_part': sch_place_part, 'sch_swap_part': sch_swap_part, 'variants': variants}@@ -2275,12 +2366,19 @@ def handle(app, args, action): if action == 'cross_check': return cross_check(app, args) root, meta, _ = sch_snapshot(app, args)- n, sheet = pick_sheet(root, args) if action not in ('sch_hierarchy', 'schematic_data', 'part_parameters') else (None, None)+ n, sheet = pick_sheet(root, args) if action not in ('sch_hierarchy', 'schematic_data', 'part_parameters', 'sch_update_from_library') else (None, None) base = {'success': True, 'mutated': False, **meta, 'sheet': n, 'sheetCount': len(sheets_of(root))} if action == 'schematic_data': return {**base, **schematic_data(root, args), '_hint': 'Pins carry the pad numbers their device joins them to (empty for a pin with no pad, such as a supply symbol). Positions are each gate on its sheet, in mm.'} if action == 'part_parameters': return {**base, **part_parameters(root, args)}+ if action == 'sch_update_from_library':+ plan = sch_update_from_library(root, args)+ if args.get('apply'):+ return {**base, **plan, 'success': False, 'errorCode': 'not_supported',+ 'error': "apply is not supported in Fusion: its UPDATE asks about each part in a dialog, which the bridge never raises. The plan is below.",+ '_hint': 'Update the parts in Fusion (Library Manager > Update, or right-click a part > Update); then fusion_run_erc.'}+ return {**base, **plan, '_hint': 'A plan: each placed part against the same-named deviceset in the libraries given (gates, pins, symbol drawing, attributes). Fusion updates parts from their library itself (it asks about each one), so apply is refused. droppedWiredPins names pins a new symbol would drop that are wired on the sheet.'} if action == 'sch_items': return {**base, **items(root, sheet, args.get('kinds')), '_hint': 'Ids are built from each item\'s kind and position (Fusion stores none): pass them to the sch_* edit verbs; an id stops matching once its item moves. Parts are in schematic_data or sch_pins.'}
rust/bridge/src/unsupported.rs−2@@ -25,8 +25,6 @@ pub const TABLE: &[(&str, &str)] = &[ ("connect_workspace", "Altium's workspace picker; Fusion works in the signed-in Autodesk account's hub (fusion_account)"), ("selftest_script_error", "a test of Altium's DelphiScript agent; Fusion runs none"), ("symbol_mode", "an EAGLE symbol has one look: there are no display modes to show"),- ("cleanup_libs", "a new Fusion library starts empty: there is no factory-default part to remove"),- ("plate_vias", "EAGLE pads are always plated: an unplated hole is a separate hole object"), ("panel", "Altium's docked panel tabs; Fusion's editors show their own panels, and the bridge opens a surface (library, symbol, footprint, board, schematic, 3D) with fusion_show_* in the background"), ("selection", "Fusion does not show the user's board selection to the bridge: a ULP's ingroup() reads no part even right after a GROUP (checked live on Fusion 2706, 2026-10-30). Name the parts instead; fusion_electron_select shows one to the user"), ("uninstall_library", "a library Fusion offers is a file in the Autodesk hub, so taking it out means deleting that file for everyone in the hub, which the standard's uninstall never does (it leaves files alone); a hub file the AI made itself is deleted with fusion_cleanup_cloud_files"),
rust/lbr/src/authoring.rs+105@@ -837,6 +837,83 @@ pub fn set_params(xml: &str, symbol: Option<&str>, params: &Value, comment: Opti } Ok((root.to_xml_document(), json!({"component": n, "paramsSet": set_n, "footprint": linked}))) }+/// The standard's plate_vias: in EAGLE a pad is always plated, and an unplated drill is a <hole>.+/// Each hole in a footprint (or one footprint) becomes a plated pad. A hole inside an SMD land (a+/// heatsink via in an exposed pad) is named after that land, N@k, and joined to its connects in every+/// device, so it is on the land's net; any other hole becomes H<k>, unconnected.+pub fn plate_vias(xml: &str, footprint: Option<&str>) -> Result<(String, Value), String> {+ let mut root = Element::parse(xml).map_err(|e| e.to_string())?;+ let lib = lib_mut(&mut root)?;+ let fp = match footprint { Some(w) => Some(find_ci(&names(section(lib, "packages"), "package"), w).cloned().ok_or_else(|| format!("no footprint {w}"))?), None => None };+ let num = |e: &Element, k: &str| e.attr(k).and_then(|v| v.parse::<f64>().ok()).unwrap_or(0.0);+ let (mut holes, mut fixed, mut joins) = (0usize, Vec::new(), Vec::<(String, String, String)>::new());+ for pk in section(lib, "packages").children.iter_mut().filter(|p| p.tag == "package" && fp.as_deref().map(|n| p.attr("name") == Some(n)).unwrap_or(true)) {+ let pname = pk.attr("name").unwrap_or("").to_string();+ let mut taken: std::collections::HashSet<String> = pk.children.iter().filter(|c| c.tag == "pad" || c.tag == "smd").filter_map(|c| c.attr("name").map(str::to_string)).collect();+ // each SMD land as its half extents round its centre (a turned land by its bounding box)+ let lands: Vec<(String, f64, f64, f64, f64)> = pk.children.iter().filter(|c| c.tag == "smd").map(|s| {+ let deg = s.attr("rot").map(|r| r.trim_start_matches(|c: char| c.is_ascii_alphabetic()).parse::<f64>().unwrap_or(0.0)).unwrap_or(0.0).to_radians();+ let (hw, hh) = (num(s, "dx") / 2.0, num(s, "dy") / 2.0);+ (s.attr("name").unwrap_or("").to_string(), num(s, "x"), num(s, "y"), hw * deg.cos().abs() + hh * deg.sin().abs(), hw * deg.sin().abs() + hh * deg.cos().abs())+ }).collect();+ for c in pk.children.iter_mut().filter(|c| c.tag == "hole") {+ holes += 1;+ let (x, y) = (num(c, "x"), num(c, "y"));+ let land = lands.iter().find(|(_, lx, ly, hw, hh)| (x - lx).abs() <= *hw && (y - ly).abs() <= *hh).map(|l| l.0.clone());+ let stem = land.clone().map(|l| format!("{}@", l.split('@').next().unwrap_or(&l))).unwrap_or_else(|| "H".into());+ let name = (if land.is_some() { 2 } else { 1 }..).map(|k| format!("{stem}{k}")).find(|n| !taken.contains(n)).unwrap();+ taken.insert(name.clone());+ let drill = c.attr("drill").unwrap_or("0").to_string();+ // no diameter: the board's restring rules size the copper ring, as for any EAGLE via+ *c = el("pad", &[("name", name.clone()), ("x", f(x)), ("y", f(y)), ("drill", drill)]);+ fixed.push(format!("{pname}:{name}"));+ if let Some(l) = land { joins.push((pname.clone(), l, name)); }+ }+ }+ // a via named after its land joins the connect that land is on, in every device of that footprint+ for d in section(lib, "devicesets").children.iter_mut().filter(|d| d.tag == "deviceset") {+ for dev in d.find_child_mut("devices").map(|x| x.children.iter_mut().collect::<Vec<_>>()).unwrap_or_default() {+ let pk = dev.attr("package").unwrap_or("").to_string();+ for cn in dev.find_child_mut("connects").map(|x| x.children.iter_mut().collect::<Vec<_>>()).unwrap_or_default() {+ let mut pads: Vec<String> = cn.attr("pad").unwrap_or("").split_whitespace().map(str::to_string).collect();+ let add: Vec<String> = joins.iter().filter(|(p, l, _)| *p == pk && pads.contains(l)).map(|j| j.2.clone()).collect();+ if add.is_empty() { continue; }+ pads.extend(add);+ set(cn, "pad", &pads.join(" "));+ }+ }+ }+ let plated = fixed.len();+ Ok((root.to_xml_document(), json!({"holes": holes, "plated": plated, "fixed": fixed.join(";"), "joinedToLands": joins.len(),+ "note": if holes == 0 { "no unplated holes: every drilled pad in an EAGLE footprint is already plated" } else { "each hole is now a plated pad; one inside an SMD land is on that land's net" }})))+}++/// The standard's cleanup_libs: the empty leftovers a library collects removed: components with no+/// device, and footprints and symbols with nothing drawn that nothing uses. Anything with content stays.+pub fn cleanup_libs(xml: &str) -> Result<(String, Value), String> {+ let mut root = Element::parse(xml).map_err(|e| e.to_string())?;+ let lib = lib_mut(&mut root)?;+ let mut removed: Vec<String> = Vec::new();+ let ds = section(lib, "devicesets");+ ds.children.retain(|d| {+ let empty = d.tag == "deviceset" && d.find_child("devices").map(|x| x.children.iter().all(|c| c.tag != "device")).unwrap_or(true);+ if empty { removed.push(format!("component {}", d.attr("name").unwrap_or(""))); }+ !empty+ });+ let used_sym: std::collections::HashSet<String> = ds.children.iter().flat_map(|d| d.find_child("gates").map(|g| g.children.iter().filter_map(|x| x.attr("symbol").map(str::to_string)).collect::<Vec<_>>()).unwrap_or_default()).collect();+ let used_pk: std::collections::HashSet<String> = ds.children.iter().flat_map(|d| d.find_child("devices").map(|g| g.children.iter().filter_map(|x| x.attr("package").map(str::to_string)).collect::<Vec<_>>()).unwrap_or_default()).collect();+ let drawn = |e: &Element| e.children.iter().any(|c| c.tag != "description");+ for (sec, tag, used, what) in [("symbols", "symbol", &used_sym, "symbol"), ("packages", "package", &used_pk, "footprint")] {+ section(lib, sec).children.retain(|e| {+ let gone = e.tag == tag && !drawn(e) && !e.attr("name").map(|n| used.contains(n)).unwrap_or(false);+ if gone { removed.push(format!("{what} {}", e.attr("name").unwrap_or(""))); }+ !gone+ });+ }+ let n = removed.len();+ Ok((root.to_xml_document(), json!({"removed": n, "items": removed})))+}+ /// The preflight a library under construction must pass: every check of fusion_preflight_lbr /// except the ones only a finished library meets (has symbols, devicesets, packages, connects):@@ -971,3 +1048,31 @@ mod layout_tests { assert!(create_from_layout(None, "X", "", &json!({"pads": [], "lines": []})).is_err()); } }++#[cfg(test)]+mod plate_cleanup_tests {+ use super::*;+ const LIB: &str = r#"<?xml version="1.0"?><eagle><drawing><library><packages>+<package name="QFN"><smd name="EP" x="0" y="0" dx="3" dy="3" layer="1"/><smd name="1" x="2" y="0" dx="0.3" dy="0.8" layer="1"/><hole x="0.5" y="0.5" drill="0.3"/><hole x="-0.5" y="-0.5" drill="0.3"/><hole x="5" y="5" drill="1"/></package>+<package name="EMPTY"></package></packages><symbols><symbol name="S"><pin name="GND" x="0" y="0"/><pin name="A" x="0" y="2"/></symbol><symbol name="BLANK"></symbol></symbols>+<devicesets><deviceset name="CHIP"><gates><gate name="G$1" symbol="S" x="0" y="0"/></gates><devices><device name="" package="QFN"><connects><connect gate="G$1" pin="GND" pad="EP"/><connect gate="G$1" pin="A" pad="1"/></connects></device></devices></deviceset>+<deviceset name="Component_1"><gates/><devices/></deviceset></devicesets></library></drawing></eagle>"#;++ #[test]+ fn holes_in_a_land_become_its_plated_vias_on_its_net() {+ let (x, s) = plate_vias(LIB, None).unwrap();+ assert_eq!((s["holes"].as_u64(), s["plated"].as_u64(), s["joinedToLands"].as_u64()), (Some(3), Some(3), Some(2)));+ assert_eq!(s["fixed"], "QFN:EP@2;QFN:EP@3;QFN:H1");+ assert!(x.contains(r#"pad="EP EP@2 EP@3""#) && !x.contains("<hole"));+ assert!(x.contains(r#"<pad name="H1" x="5" y="5" drill="1" />"#));+ assert_eq!(plate_vias(&x, Some("qfn")).unwrap().1["holes"], 0);+ }++ #[test]+ fn cleanup_takes_only_empty_unused_items() {+ let (x, s) = cleanup_libs(LIB).unwrap();+ assert_eq!(s["removed"], 3);+ assert!(x.contains(r#"name="CHIP""#) && !x.contains("Component_1") && !x.contains("BLANK") && !x.contains(r#"name="EMPTY""#));+ assert_eq!(cleanup_libs(&x).unwrap().1["removed"], 0);+ }+}
tests/run_verb_tests.py+3@@ -460,6 +460,9 @@ t("fusion_panelize", "daily", dict(B, rows=2, cols=2, separation="vscore", dryRu t("fusion_replicate_layout", "daily", dict(B, source=["C11", "C12"], targets=[["C13", "C14"]], dryRun=True), {"ok": True, "fields": ["moves", "footprintMismatches"]}, "a channel's placement planned onto another (dry run on BQ25792)") t("fusion_symbol_prims", "daily", {"libraryPath": "C:/Users/olive/Documents/fusion-lane-tests/import/lane-import.lbr", "symbolName": "LDO-LANE"}, {"ok": True, "fields": ["pins", "bodyRectMil", "models"]}, "the imported LDO's drawing counted") t("fusion_set_visibility", "daily", {"libraryPath": "C:/Users/olive/Documents/fusion-lane-tests/import/lane-import.lbr", "symbolName": "LDO-LANE", "parameter": "MPN", "visible": True, "dryRun": True}, {"ok": True, "fields": ["data"]}, "show MPN on the sheet (dry run)")+t("fusion_sch_update_from_library", "daily", {"library": "C:/Users/olive/Documents/fusion-lane-tests/import/lane-import.lbr"}, {"ok": True, "fields": ["parts", "differs"]}, "read-only plan on the open design's schematic")+t("fusion_plate_vias", "daily", {"libraryPath": "C:/Users/olive/Documents/fusion-lane-tests/import/lane-import.lbr", "dryRun": True}, {"ok": True, "fields": ["data"]}, "offline .lbr: holes become plated pads (dryRun writes nothing)")+t("fusion_cleanup_libs", "daily", {"libraryPath": "C:/Users/olive/Documents/fusion-lane-tests/import/lane-import.lbr", "dryRun": True}, {"ok": True, "fields": ["data"]}, "offline .lbr: empty unused items listed (dryRun writes nothing)") t("fusion_variants", "daily", dict(B), {"ok": True, "fields": ["variants"]}, "the design's assembly variants (BQ25792 has none)") t("fusion_variants#edit", "negative", dict(B, name="FL-TEST", notFitted=["C11"]), {"pred": "must_refuse"}, "Fusion's VARIANT text command changes nothing, so an edit is refused not_supported") t("fusion_silkscreen_prep", "daily", dict(B, dryRun=True), {"ok": True, "fields": ["before", "after", "silkOffCopper"]}, "designators on pads counted and a move planned (dry run)")