← Commit history

fusion_export_bom carries MPN, Manufacturer, LCSC, Mouser and DigiKey (issue #105); one part-number name table for every BOM read

Colby Knox ·b2cfe0fc6c ·16d ago ·parent e76e7a7
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()