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
fusion_export_bom carries MPN, Manufacturer, LCSC, Mouser and DigiKey (issue #105); one part-number name table for every BOM read
9 files changed
+141−15
CHANGELOG.md+7@@ -57,6 +57,13 @@ The board model, to the [EDA Bridge Standard](https://wiki.adom.inc/adom/eda-bri `fusion_routing_drc` (and the AI flow's DRC gate) reported the BQ25792 board's 1611 errors as warnings and its 14 Wire Stub warnings as errors. Each violation now carries `severity` and `approved` from Fusion's own constants (read live from the ULP, 2026-10-26).+- **`fusion_export_bom` carries part numbers** (page issue #105): after the old columns come MPN, Manufacturer, LCSC,+ Mouser and DigiKey from each part's attributes, read under every name libraries use (Adom's `MPN`/`LCSC_PN`,+ LCSC/JLCPCB's `LCSC`, SnapEDA's `MANUFACTURER_PART_NUMBER`, `MF`/`MP`...), blank when a part has none. The reply+ counts the parts with an MPN and an LCSC number and names the rest. The export now runs in the exe from the board+ export, in the background, and switches to the board itself when the schematic or 3D view is in front. The same+ names now feed `fusion_board_bom` (which read only `LCSC_PN`) and `fusion_component_data`. Live on BQ25792: 109+ parts, 45 lines, LCSC on 63 parts, MPN on 28. - Layer arguments take the standard's names: `F.Cu` / `B.Cu` as well as 1 / 16 (`fusion_route_net`, pours). - Net names with `!` (active-low, `!CE`) are accepted by the routing verbs; they were refused as invalid. - Tests: `tests/test_board_model.py` (16 cases over every demo board, against the standard's schemas, copied
README.md+1−1@@ -648,7 +648,7 @@ _200 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`. - `fusion_board_stackup` - Read a board's PHYSICAL fabrication stackup (see the pcb-stackup skill): copper layer count + copper/dielectric thicknesses from the EAGLE design rules... - `fusion_detect_layers` - Detect 2-layer vs 4-layer board (ULP + CAM comparison).-- `fusion_export_bom` - Export a Bill of Materials CSV (grouped by value+package) from the open board.+- `fusion_export_bom` - Export a Bill of Materials CSV from the open board, in the background: Comment, Designator, Footprint, Quantity, Library (grouped by value, footprint, library... - `fusion_export_cpl` - Export the Component Placement List (pick-and-place) CSV. - `fusion_export_gerbers` - Run the bundled JLCPCB CAM job (auto 2/4-layer) to produce the gerber + drill ZIP. - `fusion_set_design_rules` - Load a FAB's design rules (DRC) into the open board.
contracts/describe.json+5−4@@ -1289,16 +1289,17 @@ }, "input": { "grouped": "optional bool (default true)",- "outputPath": "optional str"+ "outputPath": "optional str (default %TEMP%/adom-bom.csv)" }, "longRunning": false, "name": "fusion_export_bom", "output": {- "componentCount": "int",- "grouped": "bool"+ "csv": "str (the file's text)",+ "data": "{outputPath, componentCount, uniquePartCount, grouped, withMpn, withLcsc, missingMpn, missingLcsc}",+ "output": "str" }, "statusVerb": null,- "summary": "Export a Bill of Materials CSV (grouped by value+package) from the open board.",+ "summary": "Export a Bill of Materials CSV from the open board, in the background: Comment, Designator, Footprint, Quantity, Library (grouped by value, footprint, library and MPN) or one line per part, then each part's MPN, Manufacturer, LCSC, Mouser and DigiKey numbers from its attributes (Adom, LCSC/JLCPCB and SnapEDA attribute names all read; blank when a part has none). Switches to the board itself when the schematic or 3D view is in front.", "timeoutSeconds": 60 }, {
describe.py+2−2@@ -386,8 +386,8 @@ _T = [ # ── Manufacturing ────────────────────────────────────────────────────── ("fusion_export_gerbers", "Run the bundled JLCPCB CAM job (auto 2/4-layer) to produce the gerber + drill ZIP.", {"outputDir": "optional str (default C:/tmp/adom-gerbers)"}, {"fileCount": "int", "files": "[{name,size}]", "zipPath": "str"}, 180, None, True, {"outputDir": "C:/tmp/jlc"}),- ("fusion_export_bom", "Export a Bill of Materials CSV (grouped by value+package) from the open board.",- {"outputPath": "optional str", "grouped": "optional bool (default true)"}, {"componentCount": "int", "grouped": "bool"}, 60, None, False, {"outputPath": "C:/tmp/bom.csv"}),+ ("fusion_export_bom", "Export a Bill of Materials CSV from the open board, in the background: Comment, Designator, Footprint, Quantity, Library (grouped by value, footprint, library and MPN) or one line per part, then each part's MPN, Manufacturer, LCSC, Mouser and DigiKey numbers from its attributes (Adom, LCSC/JLCPCB and SnapEDA attribute names all read; blank when a part has none). Switches to the board itself when the schematic or 3D view is in front.",+ {"outputPath": "optional str (default %TEMP%/adom-bom.csv)", "grouped": "optional bool (default true)"}, {"output": "str", "data": "{outputPath, componentCount, uniquePartCount, grouped, withMpn, withLcsc, missingMpn, missingLcsc}", "csv": "str (the file's text)"}, 60, None, False, {"outputPath": "C:/tmp/bom.csv"}), ("fusion_export_cpl", "Export the Component Placement List (pick-and-place) CSV.", {"outputPath": "optional str", "side": "optional str (all|top|bottom)"}, {"topCount": "int", "bottomCount": "int"}, 60, None, False, {"outputPath": "C:/tmp/cpl.csv"}), ("fusion_set_design_rules", "Load a FAB's design rules (DRC) into the open board. The rule packs live on the wiki page adom/pcb-design-rules (JLCPCB, PCBWay, OSH Park, Seeed, Eurocircuits, Aisler, Elecrow, Advanced Circuits, Sierra, MacroFab, NextPCB, ALLPCB and 3rd party fab; one .edru per vendor/process/layer count, every number citing the vendor's own capabilities page): the bridge fetches the pack, stages it on the box, and the add-in runs `drc load`. {action:'list'} answers with every (fab, process, layers) row so you can offer the user a real choice; no fab means the bundled 3rd party fab rules for the board's layer count (what ab's removed fusion_apply_3rdpartyfab_rules did); druPath loads an .edru already on the Fusion machine; druText/druBase64 loads one that exists only on your side. Layers auto-detects from the open board. ALWAYS confirm with fusion_alerts afterwards: a refused file is an in-canvas toast, never a dialog.",
rust/bridge/src/board_model.py+61−2@@ -819,6 +819,26 @@ def netlist(board): return nets +# ---------------------------------------------------------------- part identifiers++# The attribute names libraries put part numbers under: Adom's own (adom-lbr: MPN, MANUFACTURER,+# MOUSER_PN, DIGIKEY_PN, LCSC_PN), LCSC/JLCPCB libraries (LCSC), SnapEDA/SamacSys+# (MANUFACTURER_PART_NUMBER, MANUFACTURER_NAME) and common hand-made variants. First match wins.+PART_ID_ATTRS = {+ 'mpn': ('MPN', 'MANUFACTURER_PART_NUMBER', 'MFR_PN', 'MPN#', 'PART_NUMBER', 'PARTNUMBER', 'MP'),+ 'manufacturer': ('MANUFACTURER', 'MANUFACTURER_NAME', 'MFR', 'MF', 'MANUF'),+ 'lcsc': ('LCSC_PN', 'LCSC', 'LCSC_PART', 'LCSC#', 'JLCPCB', 'JLC_PN', 'JLCPCB_PN'),+ 'mouser': ('MOUSER_PN', 'MOUSER', 'MOUSER_PART_NUMBER', 'MOUSER#'),+ 'digikey': ('DIGIKEY_PN', 'DIGIKEY', 'DIGI-KEY_PN', 'DIGI-KEY_PART_NUMBER', 'DIGIKEY_PART_NUMBER', 'DIGIKEY#'),+}+++def part_ids(element):+ """A placed part's identifiers from its attributes, by the names above (case-insensitive)."""+ attrs = {(a.get('name') or '').upper(): (a.get('value') or '').strip() for a in element.findall('attribute')}+ return {k: next((attrs[n] for n in names if attrs.get(n)), '') for k, names in PART_ID_ATTRS.items()}++ # ---------------------------------------------------------------- reads served from the same export # Each takes (root, board, args) and answers the EDA Bridge Standard's verb of the same name, in mm. @@ -871,12 +891,13 @@ def rd_component_data(root, board, args): el = els[c['ref']] mine = [p for p in model['pads'] if p['ref'] == c['ref']] box = part_box(el, pkgs.get((el.get('library'), el.get('package')))) if pkgs.get((el.get('library'), el.get('package'))) is not None else None- attrs = {a.get('name', '').upper(): a.get('value', '') for a in el.findall('attribute')}+ ids = part_ids(el) rows.append({'designator': c['ref'], 'pattern': c['pattern'], 'comment': c['value'], 'library': c['library'], 'side': c['side'], 'layer': 'B.Cu' if c['side'] == 'bottom' else 'F.Cu', 'xMm': c['x'], 'yMm': c['y'], 'rotation': c['rotation'], 'box': box, 'wMm': _r(box[2] - box[0]) if box else None, 'hMm': _r(box[3] - box[1]) if box else None, 'pads': len(mine), 'nets': sorted({p['net'] for p in mine if p['net']}), 'has3d': c['has3d'], 'locked': c['locked'],- 'partNumber': attrs.get('MPN') or attrs.get('MANUFACTURER_PART_NUMBER') or None})+ 'partNumber': ids['mpn'] or None, 'manufacturer': ids['manufacturer'] or None, 'lcsc': ids['lcsc'] or None,+ 'mouser': ids['mouser'] or None, 'digikey': ids['digikey'] or None}) limit = int(args.get('limit', 500)) if want is not None: return {'component': rows[0]}@@ -1118,3 +1139,41 @@ READS = {'component_data': rd_component_data, 'component_pins': rd_component_pin 'net_connections': rd_net_connections, 'list_designators': rd_list_designators, 'layers': rd_layers, 'copper_layers': rd_copper_layers, 'layers_in_use': rd_layers_in_use, 'read_rules': rd_read_rules, 'rules_by_kind': rd_rules_by_kind, 'silk_state': rd_silk_state, 'net_classes': rd_net_classes}+++def export_bom_csv(board, args):+ """fusion_export_bom: the BOM CSV from the live board, with each part's MPN, manufacturer, LCSC,+ Mouser and DigiKey numbers from its attributes (blank when a part has none). Grouped (default):+ one line per value, footprint, library and MPN; else one line per part. The columns the export+ always had come first, in their old order, so readers of the old file keep working."""+ import csv, io+ els = sorted(board.findall('elements/element'), key=lambda e: _natural(e.get('name')))+ rows = []+ for e in els:+ ids = part_ids(e)+ rows.append({'name': e.get('name'), 'value': e.get('value') or '', 'package': e.get('package') or '',+ 'library': e.get('library') or '', 'side': 'Bottom' if 'M' in e.get('rot', 'R0') else 'Top', **ids})+ grouped = args.get('grouped', True) is not False+ ID_COLS = ['MPN', 'Manufacturer', 'LCSC', 'Mouser', 'DigiKey']+ ids_of = lambda r: [r['mpn'], r['manufacturer'], r['lcsc'], r['mouser'], r['digikey']]+ out = io.StringIO()+ w = csv.writer(out, lineterminator='\n')+ if grouped:+ groups = {}+ for r in rows:+ g = groups.setdefault((r['value'], r['package'], r['library'], r['mpn']), dict(r, designators=[]))+ g['designators'].append(r['name'])+ lines = sorted(groups.values(), key=lambda g: _natural(g['designators'][0]))+ w.writerow(['Comment', 'Designator', 'Footprint', 'Quantity', 'Library'] + ID_COLS)+ for g in lines:+ w.writerow([g['value'], ' '.join(g['designators']), g['package'], len(g['designators']), g['library']] + ids_of(g))+ unique = len(lines)+ else:+ w.writerow(['Designator', 'Comment', 'Footprint', 'Library', 'Side'] + ID_COLS)+ for r in rows:+ w.writerow([r['name'], r['value'], r['package'], r['library'], r['side']] + ids_of(r))+ unique = len({(r['value'], r['package'], r['library'], r['mpn']) for r in rows})+ missing = {k: [r['name'] for r in rows if not r[k]] for k in ('mpn', 'lcsc')}+ return out.getvalue(), {'componentCount': len(rows), 'uniquePartCount': unique, 'grouped': grouped,+ 'withMpn': len(rows) - len(missing['mpn']), 'withLcsc': len(rows) - len(missing['lcsc']),+ 'missingMpn': missing['mpn'], 'missingLcsc': missing['lcsc']}
rust/bridge/src/main.rs+1−1@@ -412,7 +412,7 @@ fn main() { let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe:h.fusion_executable(), short_path:|p|host().short_path(p), state_dir:h.bridge_state_dir(), caller:caller.clone(), pids:h.fusion_processes().iter().map(|p|p.pid).collect(), sidecar:&side, dialogs:&dlg }; let cmd=v.strip_prefix("fusion_").unwrap_or(v);- let mut r=if cmd=="bodies" { bodies::bodies(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)) } else { routing::call(cmd,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)) };+ let mut r=match cmd { "bodies" => bodies::bodies(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "export_bom" => routing::export_bom(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), _ => routing::call(cmd,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)) }; if cmd=="netlist" { r=sheet::shape_board_netlist(r,&parsed["args"]); } r["bridgeVersion"]=json!(VERSION); r["shell"]=json!("rust"); r },
rust/bridge/src/routing.rs+20−1@@ -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") }+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") } 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(),@@ -24,6 +24,25 @@ pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Val } } +/// `fusion_export_bom {outputPath?, grouped?}`: the board export carries every part's attributes, so the+/// BOM is written from it. The caller names no document: the active one is read first, and when a+/// schematic or 3D view is in front the design is switched to its board.+pub fn export_bom(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let probe = "result={'name':app.activeDocument.name if app.activeDocument else None,'product':app.activeProduct.productType if app.activeProduct else None}\n";+ let mut active = pass("run_modeling_script", json!({"script": probe}))["data"]["result"].clone();+ if active["product"] != "BoardProductType" {+ let _ = pass("show_2d_board", json!({}));+ active = pass("run_modeling_script", json!({"script": probe}))["data"]["result"].clone();+ }+ if active["product"] != "BoardProductType" {+ return json!({"success": false, "errorCode": "no_board", "error": format!("no board is open (the active document is {} {})", active["name"], active["product"]),+ "_hint": "Open the electronics design (fusion_schematic {action:'open'} or fusion_open_cloud_file), then call fusion_export_bom again; it switches to the board itself."});+ }+ let mut a = args.clone();+ a["expectDocument"] = active["name"].clone();+ call("export_bom", &a, pass)+}+ #[cfg(test)] mod tests { use super::*; #[test] fn unwraps_semantic_failure() {
rust/bridge/src/routing_script.py+13−4@@ -600,12 +600,12 @@ def board_bom(board, args): import csv, io rows = {} for e in board.iter('element'):- a = {x.get('name'): (x.get('value') or '') for x in e.findall('attribute')}- mpn = a.get('MPN', '')+ ids = part_ids(e) # MPN, LCSC... under any of the names libraries use (board_model.py)+ mpn = ids['mpn'] key = mpn or ('board-only:' + e.get('package', '')) r = rows.setdefault(key, {'designators': [], 'value': e.get('value', ''), 'package': e.get('package', ''),- 'mpn': mpn, 'manufacturer': a.get('MANUFACTURER', ''), 'mouser': a.get('MOUSER_PN', ''),- 'digikey': a.get('DIGIKEY_PN', ''), 'lcsc': a.get('LCSC_PN', '')})+ 'mpn': mpn, 'manufacturer': ids['manufacturer'], 'mouser': ids['mouser'],+ 'digikey': ids['digikey'], 'lcsc': ids['lcsc']}) r['designators'].append(e.get('name')); r.setdefault('_values', set()).add(e.get('value', '')) for r in rows.values(): # one value for the line only when every part on it agrees (contacts carry signal names)@@ -816,6 +816,15 @@ def handle(app,args,action): if action=='pour_state': return {'success':True,'mutated':False,**meta,**pour_state(board)} if action=='board_bom': return {'success':True,'mutated':False,**meta,**board_bom(board,args)} if action in READS: return {'success':True,'mutated':False,**meta,**READS[action](root,board,args)}+ if action=='export_bom':+ out=args.get('outputPath') or (tempfile.gettempdir().replace('\\','/')+'/adom-bom.csv')+ text,info=export_bom_csv(board,args)+ Path(out).parent.mkdir(parents=True,exist_ok=True); Path(out).write_text(text,encoding='utf-8')+ n,u=info['componentCount'],info['uniquePartCount']+ return {'success':True,'mutated':False,**meta,'output':'BOM exported: %d components%s -> %s'%(n,' in %d unique parts'%u if info['grouped'] else '',out),+ 'data':{'outputPath':out,**info,'nextSteps':['Run fusion_export_cpl to generate the pick-and-place CSV','Run fusion_export_gerbers to generate the Gerber fabrication ZIP']},+ 'csv':text if len(text)<200000 else None,+ '_hint':'Columns: %s, then MPN, Manufacturer, LCSC, Mouser, DigiKey from each part\'s attributes. %d of %d parts carry an MPN and %d an LCSC number; data.missingMpn / missingLcsc name the rest (set them in the library or the part\'s attributes).'%('Comment, Designator, Footprint, Quantity, Library' if info['grouped'] else 'Designator, Comment, Footprint, Library, Side',info['withMpn'],n,info['withLcsc'])} if action=='run_drc': # the native check on the live board, no revision guard (it changes nothing) app.executeTextCommand('Electron.run RATSNEST;')
tests/test_board_model.py+31@@ -309,5 +309,36 @@ class BoardReadTests(unittest.TestCase): self.assertIsNotNone(ic1['body']) self.assertTrue(all(c['name']['text'] == c['ref'] for c in silk['components'])) ++class BomTests(unittest.TestCase):+ """adom/fusion-bridge#105 (n105): the BOM export carries each part's MPN, manufacturer and LCSC."""+ b = ET.parse(BQ).getroot().find('drawing/board')++ def test_part_ids_read_every_naming(self):+ def el(**attrs):+ e = ET.Element('element')+ for k, v in attrs.items():+ ET.SubElement(e, 'attribute', name=k, value=v)+ return e+ self.assertEqual({'mpn': 'KT-0603R', 'manufacturer': 'Hubei KENTO Elec', 'lcsc': 'C2286', 'mouser': '', 'digikey': ''},+ r.part_ids(el(MP='KT-0603R', MF='Hubei KENTO Elec', LCSC='C2286')))+ self.assertEqual('BQ25792RQMR', r.part_ids(el(MANUFACTURER_PART_NUMBER='BQ25792RQMR'))['mpn'])+ self.assertEqual('C1', r.part_ids(el(lcsc_pn='C1'))['lcsc'])+ self.assertEqual('X', r.part_ids(el(MPN='', MANUFACTURER_PART_NUMBER='X'))['mpn']) # an empty one does not win++ def test_export_csv_carries_the_numbers_after_the_old_columns(self):+ import csv, io+ text, info = r.export_bom_csv(self.b, {})+ rows = list(csv.reader(io.StringIO(text)))+ self.assertEqual(['Comment', 'Designator', 'Footprint', 'Quantity', 'Library', 'MPN', 'Manufacturer', 'LCSC', 'Mouser', 'DigiKey'], rows[0])+ r2 = [x for x in rows if 'R2' in x[1].split()][0]+ self.assertEqual(('WSL2010R0100FEA18', 'Vishay-Dale', 'C844898'), (r2[5], r2[6], r2[7]))+ self.assertEqual(109, sum(int(x[3]) for x in rows[1:]))+ self.assertEqual(info['withLcsc'], 109 - len(info['missingLcsc']))+ text, info = r.export_bom_csv(self.b, {'grouped': False})+ rows = list(csv.reader(io.StringIO(text)))+ self.assertEqual(['Designator', 'Comment', 'Footprint', 'Library', 'Side', 'MPN', 'Manufacturer', 'LCSC', 'Mouser', 'DigiKey'], rows[0])+ self.assertEqual(110, len(rows))+ if __name__ == '__main__': unittest.main()