app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
← Commit history
Update 19 file(s)
19 files changed
+1083−27
BRIDGE_VERSION+1−1CHANGELOG.md+23bridge.json+23−2contracts/describe.json+281dashboard/Cargo.lock+1−1dashboard/Cargo.toml+1−1describe.py+48install.sh+1−1package.json+1−1page.json+5−11rust/Cargo.toml+1−1rust/bridge/src/docs.rs+52−1rust/bridge/src/routing.rs+2−2rust/bridge/src/routing_script.py+317−1rust/bridge/src/sch.rs+1−1rust/bridge/src/sch_script.py+259−2tests/run_verb_tests.py+14−1tests/test_routing.py+39tests/test_sch.py+13BRIDGE_VERSION+1−1@@ -1 +1 @@-1.13.0+1.13.1
CHANGELOG.md+23@@ -3,6 +3,29 @@ 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.13.1 (2026-10-28, insiders)++More of the standard's schematic, document and board verbs. 298 verbs.++- New, read only:+ - `fusion_schematic_data`: parts with pins (pad numbers, electrical type), gates, values and attributes;+ - `fusion_part_parameters`: attribute names across the placed parts, or one attribute's value on each;+ - `fusion_cross_check`: the schematic's designators against the board's;+ - `fusion_footprint_primitives`: one placed part's pads, wires, arcs and texts on the board;+ - `fusion_export_pdf`: every sheet as one PDF, plus the board with `board:true`.+- New edits, each read back and undone on a mismatch:+ - `fusion_doc_params`: the schematic's global attributes;+ - `fusion_sheet_params`: attributes on placed parts (the BOM write-back);+ - `fusion_sch_edit`: move, rotate, rename, re-value or delete placed parts;+ - `fusion_annotate`: numbers `R?`-style designators.+- New board furniture, each spot clear of other copper, pads, holes and the edge, read back and undone on a+ mismatch:+ - `fusion_thermal_vias`: a grid in an exposed pad;+ - `fusion_via_fence`: vias along RF or fast tracks and/or the board edge, at lambda/20 or a pitch;+ - `fusion_board_features`: mounting holes, fiducials, keepouts and a stitching grid.+- Fixed: GenCAD writes `MIRRORX FLIP` for bottom parts, matching KiCad's native GenCAD (standard #16).+- Live on fusion-lane's copy of BQ25792, with the test edits removed afterwards.+ ## 1.13.0 (2026-10-28, insiders) Drew's typed mechanical layer, Fusion 2706 support, his safe-restart work, and library authoring.
bridge.json+23−2@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.13.0",+ "version": "1.13.1", "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.",@@ -27,6 +27,7 @@ "fusion_addin_status", "fusion_alerts", "fusion_align_parts",+ "fusion_annotate", "fusion_apply_fusion_update", "fusion_apply_pours", "fusion_apply_silkscreen",@@ -51,6 +52,7 @@ "fusion_autoroute", "fusion_bind_library_3d", "fusion_board_bom",+ "fusion_board_features", "fusion_board_info", "fusion_board_outline", "fusion_board_seed",@@ -81,6 +83,7 @@ "fusion_create_footprint", "fusion_create_library", "fusion_create_symbol",+ "fusion_cross_check", "fusion_cross_probe", "fusion_current_capacity", "fusion_delete_cloud_file",@@ -91,6 +94,7 @@ "fusion_dialog_log", "fusion_dismiss_blocking_dialogs", "fusion_dismiss_dialogs",+ "fusion_doc_params", "fusion_document_info", "fusion_eagle_import_status", "fusion_edit_footprint",@@ -122,6 +126,7 @@ "fusion_export_obj", "fusion_export_odb", "fusion_export_optimized_glb",+ "fusion_export_pdf", "fusion_export_sat", "fusion_export_skp", "fusion_export_source",@@ -131,6 +136,7 @@ "fusion_fab_check", "fusion_fetch_optimized_glb", "fusion_find_symbol",+ "fusion_footprint_primitives", "fusion_generate_body", "fusion_generate_package", "fusion_get_app_state",@@ -194,6 +200,7 @@ "fusion_open_electronics", "fusion_open_lbr", "fusion_open_schematic",+ "fusion_part_parameters", "fusion_physical_properties", "fusion_place_vias", "fusion_plan_library_import",@@ -248,6 +255,7 @@ "fusion_sch_connect_to_bus", "fusion_sch_create", "fusion_sch_delete_wire",+ "fusion_sch_edit", "fusion_sch_edit_label", "fusion_sch_fix_connectivity", "fusion_sch_hierarchy",@@ -263,6 +271,7 @@ "fusion_sch_trace_point", "fusion_sch_update_from_library", "fusion_schematic",+ "fusion_schematic_data", "fusion_schematic_plan", "fusion_schematic_run", "fusion_screenshot_fusion",@@ -280,6 +289,7 @@ "fusion_set_seat_takeover", "fusion_set_track_width", "fusion_set_window_sizing",+ "fusion_sheet_params", "fusion_show_2d_board", "fusion_show_3d_board", "fusion_show_3d_chip",@@ -299,9 +309,11 @@ "fusion_status", "fusion_stop", "fusion_take_screenshot",+ "fusion_thermal_vias", "fusion_uninstall", "fusion_update_board_from_eagle", "fusion_verb_times",+ "fusion_via_fence", "fusion_view", "fusion_walk_cloud_tree", "fusion_web_signin",@@ -464,7 +476,16 @@ "set_layer_count": 150, "project_info": 120, "project_snapshot": 300,- "project_create": 180+ "project_create": 180,+ "export_pdf": 300,+ "cross_check": 120,+ "doc_params": 120,+ "sheet_params": 120,+ "sch_edit": 120,+ "annotate": 120,+ "thermal_vias": 180,+ "via_fence": 180,+ "board_features": 180 }, "prefixes": [ {
contracts/describe.json+281@@ -116,6 +116,24 @@ "summary": "Line parts up on a common left, right, top, bottom or centre line, or space them evenly (the standard's align_parts), by footprint box (pads, placement and documentation outlines, keepouts) or by anchor point. Rotation and side are kept. A part pushed off the board or onto another part is refused unless allowOutside / allowOverlap. Moved by name and read back.", "timeoutSeconds": 150 },+ {+ "example": {+ "dryRun": true+ },+ "input": {+ "dryRun": "bool",+ "expectDocument": "the design"+ },+ "longRunning": false,+ "name": "fusion_annotate",+ "output": {+ "planned": "[{sheet, from, to}]",+ "renamed": "[..]"+ },+ "statusVerb": null,+ "summary": "Number every 'R?'-style designator with the lowest free number of its prefix, sheet by sheet, top-left first (the standard's annotate). Fusion names parts as they are added, so there is usually nothing to do.",+ "timeoutSeconds": 120+ }, { "example": {}, "input": {},@@ -594,6 +612,33 @@ "summary": "The bill of materials from the live board's own part attributes: one line per MPN with designators, quantity, value, package, manufacturer and the Mouser, DigiKey and LCSC numbers, plus the JLCPCB form (Comment, Designator, Footprint, LCSC). Board-only parts (test pads) are listed as such, and any part missing a distributor number is named. Read-only; with outputDir it also writes bom.csv and bom-jlcpcb.csv there.", "timeoutSeconds": 60 },+ {+ "example": {+ "dryRun": true,+ "stitching": {+ "net": "GND",+ "pitch": 5+ }+ },+ "input": {+ "dryRun": "bool",+ "expectDocument": "required",+ "fiducials": "[{x, y, size?, opening?, side?}]",+ "keepouts": "[{rect, layer?: all|F.Cu|B.Cu}]",+ "mountingHoles": "[{x, y, drill?, plated?, pad?, net?}] or {corners: {inset}, drill?, plated?}",+ "stitching": "{net, pitch?, size?, drill?, clearance?, edge?, region?, max?}"+ },+ "longRunning": false,+ "name": "fusion_board_features",+ "output": {+ "applied": "int",+ "planned": "{mountingHoles, fiducials, keepouts, stitching:{count, points}}",+ "results": "[ok|err: ...]"+ },+ "statusVerb": null,+ "summary": "Board furniture in one call (the standard's board_features): mounting holes (bare, or plated via-holes on a net; at points or the four corners), fiducials (a filled copper circle and its mask opening), keepouts (Fusion's restrict layers) and a stitching-via grid on a net, each spot clear of other copper, pads, holes and the edge. Read back; a mismatch undoes it.",+ "timeoutSeconds": 180+ }, { "example": {}, "input": {},@@ -1232,6 +1277,24 @@ "summary": "Create a component in a .lbr, offline (the standard's create_symbol): an EAGLE deviceset with one rectangular symbol per part and its pins on the sides asked ('des,name,left|right|top|bottom,offset_mil[,part[,hiddenNet]];...'); part 0 and hidden-net pins go on a power gate placed on request. Pin numbers are kept so set_params {footprint} can join pins to pads.", "timeoutSeconds": 30 },+ {+ "example": {},+ "input": {+ "expectDocument": "the design"+ },+ "longRunning": false,+ "name": "fusion_cross_check",+ "output": {+ "boardComponents": "int",+ "matched": "int",+ "onBoardNotOnSheet": "[ref]",+ "onSheetNotOnBoard": "[ref]",+ "schematicComponents": "int"+ },+ "statusVerb": null,+ "summary": "The schematic's designators against the board's (the standard's cross_check): matched, on the sheet only (no board part), on the board only (placed there: logos, holes).",+ "timeoutSeconds": 120+ }, { "example": {}, "input": {@@ -1422,6 +1485,28 @@ "summary": "The EDA Bridge Standard's name for fusion_dismiss_blocking_dialogs: the same verb, same arguments and reply (marked aliasOf). One name across Altium, KiCad and Fusion bridges.", "timeoutSeconds": 120 },+ {+ "example": {+ "dryRun": true,+ "set": {+ "REVISION": "B"+ }+ },+ "input": {+ "dryRun": "bool",+ "expectDocument": "the design",+ "set": "{TITLE: ..., REVISION: ...}"+ },+ "longRunning": false,+ "name": "fusion_doc_params",+ "output": {+ "params": "[{name, value}]",+ "set": "int"+ },+ "statusVerb": null,+ "summary": "Read the schematic's global attributes (Fusion's title-block values), or set them with set: {NAME: value} (the standard's doc_params). Read back; a mismatch undoes the edit.",+ "timeoutSeconds": 120+ }, { "example": {}, "input": {},@@ -2088,6 +2173,27 @@ "summary": "Fusion board -> wiki-grade optimized GLB. Exports STEP (+ optional silkscreen) and submits it to service-step2glb molecule mode (anchor to MP pins + silk bake + gold pins + join/weld/prune + Draco): ~465KB/~31 draw calls vs a raw ~16MB/~25000 that halts the viewer. Bounded-waits `wait`s (default 90); small boards return complete, big boards return {pending, jobId} - finish with fusion_fetch_optimized_glb. Use for a component page's component.parts.model_3d. STACKUP RULE (John 2026-07-15, see pcb-stackup skill): board bbox = the FR4 body, NEVER the whole assembly (components overhang edges + tower above); each overlay goes at ITS layer's z (copper at FR4-top, silk above mask); copper traces are REAL 35um 3D bodies colored like the FR4 - recolor them + translucent mask in the RAW GLB before optimizing to make routing visible; put the measured stackup table on the board's wiki page. PUBLISH EVERYTHING: a board's wiki page repo must carry EVERY exportable design file (.f3d/.step/.usdz/EAGLE .brd+.sch/Fusion .fbrd+.fsch/gerbers/BOM/CPL/GLBs/renders), not just the 3D - the page exists to share the design.", "timeoutSeconds": 200 },+ {+ "example": {+ "board": true+ },+ "input": {+ "board": "bool",+ "expectDocument": "the design",+ "output": "the .pdf path (default Documents/<design>.pdf)"+ },+ "longRunning": false,+ "name": "fusion_export_pdf",+ "output": {+ "boardPdf": "path|null",+ "outputBytes": "int",+ "pdfPath": "path",+ "sheets": "int"+ },+ "statusVerb": null,+ "summary": "Every sheet of the design as one PDF (the standard's export_pdf), each page Fusion's own render of the sheet; board:true adds the board as a second PDF. Fusion has no assembly or fabrication print outputs.",+ "timeoutSeconds": 180+ }, { "example": { "outputPath": "C:/out/part.sat"@@ -2257,6 +2363,26 @@ "summary": "Find components (or footprints with kind:'footprint') by part of their name across .lbr files, offline (the standard's find_symbol): the exact match first, else the shortest name that contains the query. Fusion keeps no installed-library list a script can read, so name the libraries or a folder.", "timeoutSeconds": 30 },+ {+ "example": {+ "designator": "U1"+ },+ "input": {+ "designator": "str (required)",+ "expectDocument": "required: the board's name"+ },+ "longRunning": false,+ "name": "fusion_footprint_primitives",+ "output": {+ "otherPrimitives": "{kind: n}",+ "pattern": "str",+ "primitives": "[{kind, layerId, ...}]",+ "side": "top|bottom"+ },+ "statusVerb": null,+ "summary": "Geometry of one placed part (the standard's footprint_primitives): its pads (with mask and paste), wires and arcs, and texts, placed on the board in mm and mil with layer ids; other kinds are counted.",+ "timeoutSeconds": 120+ }, { "example": { "dimensionsMm": {@@ -17473,6 +17599,24 @@ "summary": "Open a LOCAL legacy EAGLE .sch in Fusion's Schematic Editor (dialog-free Document.newDesignFromLocal; a background open). NAV RULE: the schematic, 2D board and 3D board are VIEWS of ONE electronics design, not standalone files. For a team design open the DESIGN document (fusion_open_cloud_file / fusion_open_by_urn) and switch views with fusion_show_schematic; this verb is for a .sch that exists on the Fusion machine (push it there first). Drive the opened sheet with fusion_electron_run (WINDOW FIT, DISPLAY ALL). The bridge captures a post-open screenshot because Fusion can raise a blocking dialog the API cannot see.", "timeoutSeconds": 60 },+ {+ "example": {+ "name": "MPN"+ },+ "input": {+ "expectDocument": "the design",+ "name": "an attribute, e.g. MPN"+ },+ "longRunning": false,+ "name": "fusion_part_parameters",+ "output": {+ "names": "[{name, parts}]",+ "values": "[{designator, value, sheet}] (with name)"+ },+ "statusVerb": null,+ "summary": "The attribute names the design's placed parts carry, with how many carry each (the standard's part_parameters); with name, that attribute's value on every part.",+ "timeoutSeconds": 120+ }, { "example": { "accuracy": "low"@@ -18688,6 +18832,34 @@ "summary": "Delete schematic wires by id, by their two ends, or every wire through a point; junctions left joining fewer than three items are removed too. Every other wire must read back unchanged, or the delete is undone.", "timeoutSeconds": 120 },+ {+ "example": {+ "dryRun": true,+ "edits": [+ {+ "action": "rotate",+ "degrees": 90,+ "designator": "R1"+ }+ ]+ },+ "input": {+ "dryRun": "bool",+ "edits": "[{designator, action, x,y | dx,dy | degrees | to | text}] (required)",+ "expectDocument": "the design",+ "sheet": "default the one shown"+ },+ "longRunning": false,+ "name": "fusion_sch_edit",+ "output": {+ "applied": "int",+ "failed": "[..]",+ "results": "[{designator, check, result}]"+ },+ "statusVerb": null,+ "summary": "Change placed parts on a sheet (the standard's sch_edit): move (x, y mil), moveBy (dx, dy mil), rotate (degrees), designator (rename, the board follows), comment (value), delete. Each is read back; a mismatch undoes the edit.",+ "timeoutSeconds": 120+ }, { "example": { "edits": [@@ -19088,6 +19260,30 @@ "summary": "START HERE for a schematic. ONE verb, three actions. action:\"open\" lands in the schematic editor from whatever names the design: fileName (+projectName) or urn or query for a CLOUD design (opens it, then switches to the schematic view), schPath for a local EAGLE .sch (its sibling .brd is imported with it so parts instantiate, unless withBoard:false; a .sch alone opens dialog-free), or filePath for a Fusion-native .fsch. action:\"create\" writes a blank EAGLE schematic on the box and opens it as a NEW local electronics design with the schematic editor active, no dialog anywhere. action:\"show\" switches the already-open design to its schematic view. Every reply says which underlying verb it used (via). Remember: a schematic is a VIEW of an electronics design, not a file of its own, which is why there is no verb called open_a_schematic_file. To put a library's parts on a NEW schematic and board for showing them off, use fusion_make_showcase_project instead.", "timeoutSeconds": 240 },+ {+ "example": {+ "designator": "U1",+ "params": true+ },+ "input": {+ "designator": "one part",+ "expectDocument": "the design (default the one in front)",+ "limit": "default 500",+ "paramNamesOnly": "bool",+ "params": "bool",+ "pins": "default true",+ "sheet": "a sheet number, default all"+ },+ "longRunning": false,+ "name": "fusion_schematic_data",+ "output": {+ "components": "[{designator, libReference, comment, gates, pins, params?}]",+ "count": "int"+ },+ "statusVerb": null,+ "summary": "Parts on the schematic (the standard's schematic_data): each with its library part, value, description, gates with position and turn, and pins with pad number, name and electrical type (0 input, 1 io, 2 output, 3 open collector, 4 passive, 5 hi-z, 7 power); params adds its attributes.",+ "timeoutSeconds": 120+ }, { "example": {}, "input": {@@ -19452,6 +19648,32 @@ "summary": "Read/toggle auto-sizing Fusion's WINDOW after launch (STICKY, default ON). Fusion opens at whatever geometry it last had, which after an automated launch is often a small default; the bridge grows it to ~92% of the monitor work area, centered, using SetWindowPos with NOACTIVATE (never ShowWindow/maximize) so focus is never stolen. A window the user maximized, or already sized to >=60% of the work area, is left alone. No args = read.", "timeoutSeconds": 15 },+ {+ "example": {+ "designators": [+ "R1"+ ],+ "dryRun": true,+ "params": {+ "MPN": "0603WAF1002T5E"+ }+ },+ "input": {+ "designators": "['R1','R2'] or 'R1,R2' (required)",+ "dryRun": "bool",+ "expectDocument": "the design",+ "params": "{MPN: ...} or 'Key=Value;...' (required)"+ },+ "longRunning": false,+ "name": "fusion_sheet_params",+ "output": {+ "notFound": "[ref]",+ "updated": "[ref]"+ },+ "statusVerb": null,+ "summary": "Write attributes onto placed parts (the standard's sheet_params, the BOM write-back): MPN, Manufacturer, distributor numbers. Fusion keeps the board's parts in step. Read back; a mismatch undoes the edit.",+ "timeoutSeconds": 120+ }, { "example": {}, "input": {@@ -19827,6 +20049,31 @@ "summary": "Screenshot the Fusion window in the BACKGROUND (no focus steal, reads through occlusion). Use it to VERIFY your own work: geometry at the wrong Z, an empty view, or a Placeholder 3D body all render successfully and only a look at the pixels catches them.", "timeoutSeconds": 60 },+ {+ "example": {+ "dryRun": true,+ "ref": "U3"+ },+ "input": {+ "drill": "mm",+ "dryRun": "bool",+ "expectDocument": "required",+ "insetMm": "default 0.1",+ "pad": "default its largest pad",+ "pitchMm": "default size + 0.5, at least 1",+ "ref": "the part (required)",+ "size": "via diameter mm"+ },+ "longRunning": false,+ "name": "fusion_thermal_vias",+ "output": {+ "applied": "int",+ "planned": "{part, pad, net, padMm, vias, pitchMm, via, points}"+ },+ "statusVerb": null,+ "summary": "A thermal via grid in a part's exposed pad (the standard's thermal_vias): centred, inset from the pad's edge, sized by the board's via rule, on the pad's net. Read back; a mismatch undoes it.",+ "timeoutSeconds": 180+ }, { "example": { "dryRun": true@@ -19893,6 +20140,40 @@ "summary": "This machine MEASURED timings per verb/stage - the same store that paces every progress bar and ETA. Read it to see whether an estimate is measured (this box has run it) or typical (still the seeded literal), and to explain a slow run.", "timeoutSeconds": 15 },+ {+ "example": {+ "dryRun": true,+ "freqGHz": 2.4,+ "nets": [+ "ANT"+ ],+ "viaNet": "GND"+ },+ "input": {+ "clearance": "default 0.2",+ "drill": "default 0.3",+ "dryRun": "bool",+ "edge": "bool",+ "edgeInsetMm": "number",+ "expectDocument": "required",+ "freqGHz": "number",+ "nets": "[net]",+ "offsetMm": "track to via centre",+ "pitchMm": "default 2",+ "size": "default 0.6",+ "viaNet": "usually GND (required)"+ },+ "longRunning": false,+ "name": "fusion_via_fence",+ "output": {+ "applied": "int",+ "failed": "int",+ "planned": "{vias, candidates, pitchMm, offsetMm, points}"+ },+ "statusVerb": null,+ "summary": "A via fence for EMC (the standard's via_fence): vias on viaNet both sides of the nets' tracks and/or round the board edge, at lambda/20 for freqGHz or a pitch; each clear of other copper, pads, holes and the edge, and a pitch from each other. Read back; a mismatch undoes it.",+ "timeoutSeconds": 180+ }, { "example": {}, "input": {
dashboard/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "fusion-dashboard"-version = "1.13.0"+version = "1.13.1" dependencies = [ "serde_json", ]
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.13.0"+version = "1.13.1" edition = "2021" [dependencies]
describe.py+48@@ -362,6 +362,54 @@ _T = [ {"libraryPath": "a .lbr on the Fusion machine (required)", "symbolName": "default the first component", "params": "{MPN: ..., Manufacturer: ...}", "comment": "str", "footprint": "a package in the same library", "dryRun": "bool"}, {"component": "str", "paramsSet": "int", "footprint": "str|null"}, 30, None, False, {'libraryPath': 'C:/libs/new.lbr', 'symbolName': 'LM358', 'params': {'MPN': 'LM358DR'}, 'footprint': 'SOIC-8'}),+ ('fusion_schematic_data', "Parts on the schematic (the standard's schematic_data): each with its library part, value, description, gates with position and turn, and pins with pad number, name and electrical type (0 input, 1 io, 2 output, 3 open collector, 4 passive, 5 hi-z, 7 power); params adds its attributes.",+ {"expectDocument": "the design (default the one in front)", "sheet": "a sheet number, default all", "designator": "one part", "pins": "default true", "params": "bool", "paramNamesOnly": "bool", "limit": "default 500"},+ {"count": "int", "components": "[{designator, libReference, comment, gates, pins, params?}]"},+ 120, None, False, {'designator': 'U1', 'params': True}),+ ('fusion_part_parameters', "The attribute names the design's placed parts carry, with how many carry each (the standard's part_parameters); with name, that attribute's value on every part.",+ {"expectDocument": "the design", "name": "an attribute, e.g. MPN"},+ {"names": "[{name, parts}]", "values": "[{designator, value, sheet}] (with name)"},+ 120, None, False, {'name': 'MPN'}),+ ('fusion_cross_check', "The schematic's designators against the board's (the standard's cross_check): matched, on the sheet only (no board part), on the board only (placed there: logos, holes).",+ {"expectDocument": "the design"},+ {"matched": "int", "onSheetNotOnBoard": "[ref]", "onBoardNotOnSheet": "[ref]", "schematicComponents": "int", "boardComponents": "int"},+ 120, None, False, {}),+ ('fusion_doc_params', "Read the schematic's global attributes (Fusion's title-block values), or set them with set: {NAME: value} (the standard's doc_params). Read back; a mismatch undoes the edit.",+ {"expectDocument": "the design", "set": "{TITLE: ..., REVISION: ...}", "dryRun": "bool"},+ {"params": "[{name, value}]", "set": "int"},+ 120, None, False, {'set': {'REVISION': 'B'}, 'dryRun': True}),+ ('fusion_sheet_params', "Write attributes onto placed parts (the standard's sheet_params, the BOM write-back): MPN, Manufacturer, distributor numbers. Fusion keeps the board's parts in step. Read back; a mismatch undoes the edit.",+ {"expectDocument": "the design", "designators": "['R1','R2'] or 'R1,R2' (required)", "params": "{MPN: ...} or 'Key=Value;...' (required)", "dryRun": "bool"},+ {"updated": "[ref]", "notFound": "[ref]"},+ 120, None, False, {'designators': ['R1'], 'params': {'MPN': '0603WAF1002T5E'}, 'dryRun': True}),+ ('fusion_sch_edit', "Change placed parts on a sheet (the standard's sch_edit): move (x, y mil), moveBy (dx, dy mil), rotate (degrees), designator (rename, the board follows), comment (value), delete. Each is read back; a mismatch undoes the edit.",+ {"expectDocument": "the design", "sheet": "default the one shown", "edits": "[{designator, action, x,y | dx,dy | degrees | to | text}] (required)", "dryRun": "bool"},+ {"applied": "int", "results": "[{designator, check, result}]", "failed": "[..]"},+ 120, None, False, {'edits': [{'designator': 'R1', 'action': 'rotate', 'degrees': 90}], 'dryRun': True}),+ ('fusion_annotate', "Number every 'R?'-style designator with the lowest free number of its prefix, sheet by sheet, top-left first (the standard's annotate). Fusion names parts as they are added, so there is usually nothing to do.",+ {"expectDocument": "the design", "dryRun": "bool"},+ {"planned": "[{sheet, from, to}]", "renamed": "[..]"},+ 120, None, False, {'dryRun': True}),+ ('fusion_footprint_primitives', "Geometry of one placed part (the standard's footprint_primitives): its pads (with mask and paste), wires and arcs, and texts, placed on the board in mm and mil with layer ids; other kinds are counted.",+ {"expectDocument": "required: the board's name", "designator": "str (required)"},+ {"pattern": "str", "side": "top|bottom", "primitives": "[{kind, layerId, ...}]", "otherPrimitives": "{kind: n}"},+ 120, None, False, {'designator': 'U1'}),+ ('fusion_export_pdf', "Every sheet of the design as one PDF (the standard's export_pdf), each page Fusion's own render of the sheet; board:true adds the board as a second PDF. Fusion has no assembly or fabrication print outputs.",+ {"expectDocument": "the design", "output": "the .pdf path (default Documents/<design>.pdf)", "board": "bool"},+ {"pdfPath": "path", "sheets": "int", "outputBytes": "int", "boardPdf": "path|null"},+ 180, None, False, {'board': True}),+ ('fusion_thermal_vias', "A thermal via grid in a part's exposed pad (the standard's thermal_vias): centred, inset from the pad's edge, sized by the board's via rule, on the pad's net. Read back; a mismatch undoes it.",+ {"expectDocument": "required", "ref": "the part (required)", "pad": "default its largest pad", "size": "via diameter mm", "drill": "mm", "pitchMm": "default size + 0.5, at least 1", "insetMm": "default 0.1", "dryRun": "bool"},+ {"applied": "int", "planned": "{part, pad, net, padMm, vias, pitchMm, via, points}"},+ 180, None, False, {'ref': 'U3', 'dryRun': True}),+ ('fusion_via_fence', "A via fence for EMC (the standard's via_fence): vias on viaNet both sides of the nets' tracks and/or round the board edge, at lambda/20 for freqGHz or a pitch; each clear of other copper, pads, holes and the edge, and a pitch from each other. Read back; a mismatch undoes it.",+ {"expectDocument": "required", "viaNet": "usually GND (required)", "nets": "[net]", "edge": "bool", "freqGHz": "number", "pitchMm": "default 2", "offsetMm": "track to via centre", "size": "default 0.6", "drill": "default 0.3", "clearance": "default 0.2", "edgeInsetMm": "number", "dryRun": "bool"},+ {"applied": "int", "failed": "int", "planned": "{vias, candidates, pitchMm, offsetMm, points}"},+ 180, None, False, {'nets': ['ANT'], 'viaNet': 'GND', 'freqGHz': 2.4, 'dryRun': True}),+ ('fusion_board_features', "Board furniture in one call (the standard's board_features): mounting holes (bare, or plated via-holes on a net; at points or the four corners), fiducials (a filled copper circle and its mask opening), keepouts (Fusion's restrict layers) and a stitching-via grid on a net, each spot clear of other copper, pads, holes and the edge. Read back; a mismatch undoes it.",+ {"expectDocument": "required", "mountingHoles": "[{x, y, drill?, plated?, pad?, net?}] or {corners: {inset}, drill?, plated?}", "fiducials": "[{x, y, size?, opening?, side?}]", "keepouts": "[{rect, layer?: all|F.Cu|B.Cu}]", "stitching": "{net, pitch?, size?, drill?, clearance?, edge?, region?, max?}", "dryRun": "bool"},+ {"applied": "int", "planned": "{mountingHoles, fiducials, keepouts, stitching:{count, points}}", "results": "[ok|err: ...]"},+ 180, None, False, {'stitching': {'net': 'GND', 'pitch': 5}, 'dryRun': True}), ('fusion_read_footprint', "Every detail of one library footprint (the standard's read_footprint): pads with number, type, shape, position, size, drill, layers and turn; the layers used; the courtyard box (component exclude layers 39/40, else pads and placement outline); graphics per layer; the 3D packages that instance it. From a .lbr (libraryPath + name) or, by libId 'Lib:Name', from the libraries the open board embeds.", {"libraryPath": "a .lbr on the Fusion machine (with name)", "name": "the package's name", "libId": "'Library:Package' from the board's embedded libraries", "expectDocument": "the board's name, for libId"}, {"name": "str", "pads": "[{number, type, shape, at, sizeMm, drillMm, layers, rotation}]", "layers": "[str]", "courtyardMm": "[x1, y1, x2, y2]", "graphics": "{layer: count}", "attributes": "{mount}", "models": "[{name, urn, type}]"},
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.13.0"+DASH_VERSION="1.13.1" 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.13.0",+ "version": "1.13.1", "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+5−11@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.13.0",+ "version": "1.13.1", "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": {@@ -17,9 +17,7 @@ "pcb", "exports", "aps",- "modeling",- "mechanical",- "assemblies"+ "modeling" ], "org": "adom", "title": "Fusion - the Fusion 360 Bridge",@@ -92,13 +90,9 @@ "pin electrical types from a schematic", "part heights on my fusion board", "which fusion parts have no 3d model",- "will my fusion board fit the enclosure",- "build a parametric part in fusion",- "assemble mechanical parts in fusion",- "insert a McMaster part in fusion",- "generate a mechanical assembly BOM"+ "will my fusion board fit the enclosure" ],- "discovery_pitch": "Use the Adom Bridge Fusion 360 bridge when the user wants to DRIVE Autodesk Fusion 360 from the cloud: open/search their team's Fusion cloud designs, author parametric mechanical parts and internal assemblies, ingest McMaster CAD and generate mechanical BOMs, lay out a PCB or do electronics, export STEP/IGES/STL/Gerbers/BOM, or build a component library with real rendering 3D. It runs Fusion on the user's own machine via Adom Bridge. NOT for web/browser/login/form-filling (that's the native-browser extension) and NOT for KiCad (that's the kicad bridge).",+ "discovery_pitch": "Use the Adom Bridge Fusion 360 bridge when the user wants to DRIVE Autodesk Fusion 360 from the cloud: open/search their team's Fusion cloud designs, lay out a PCB or do electronics, export STEP/IGES/STL/Gerbers/BOM, or build a component library with real rendering 3D. It runs Fusion on the user's own machine via Adom Bridge. NOT for web/browser/login/form-filling (that's the native-browser extension) and NOT for KiCad (that's the kicad bridge).", "brief": "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.", "sample_prompts": [ {@@ -149,4 +143,4 @@ "name": "John Lauer", "email": "[email protected]" }-}+}
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.13.0"+version = "1.13.1" edition = "2021" license = "MIT"
rust/bridge/src/docs.rs+52−1@@ -2,7 +2,7 @@ //! Fusion's main thread). document_info keeps the add-in's reply and adds the standard's fields. use serde_json::{json, Value}; const SOURCE: &str = include_str!("docs_script.py");-pub fn owns(cmd: &str) -> bool { matches!(cmd, "document_info" | "close_all_documents" | "render_board_3d" | "read_footprint" | "project_info" | "project_snapshot" | "project_rename") }+pub fn owns(cmd: &str) -> bool { matches!(cmd, "document_info" | "close_all_documents" | "render_board_3d" | "read_footprint" | "project_info" | "project_snapshot" | "project_rename" | "export_pdf") } fn script(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value { let s = format!("import json\nexec({})\nresult=handle(app,json.loads({}),{})\n", serde_json::to_string(SOURCE).unwrap(), serde_json::to_string(&args.to_string()).unwrap(), serde_json::to_string(cmd).unwrap());@@ -16,6 +16,7 @@ pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Val "project_info" => project_info(args, pass), "project_snapshot" => project_snapshot(args, pass), "project_rename" => project_rename(args, pass),+ "export_pdf" => export_pdf(args, pass), "document_info" => { let mut old = pass("document_info", args.clone()); let std_ = script(cmd, args, pass);@@ -222,3 +223,53 @@ fn project_rename(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value { json!({"success": true, "mutated": true, "renamed": renamed, "changed": [], "_hint": "EAGLE's schematic and board find each other by file name in one folder, so nothing inside the files needed changing. Open it again with fusion_new_electronics_from_eagle."}) }++/// The standard's export_pdf: every sheet of the design as one PDF (rendered with Fusion's own+/// EXPORT IMAGE, one page per sheet), and with board:true the board as a second PDF. Fusion has no+/// assembly or fabrication print outputs, so those are refused by name.+fn export_pdf(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ if args["assembly"].as_bool() == Some(true) || args["fabrication"].as_bool() == Some(true) {+ return json!({"success": false, "errorCode": "not_supported", "error": "Fusion has no assembly-drawing or fabrication-print output; ask for sheets and board (the composite) only"});+ }+ let mut a = args.clone();+ if a["expectDocument"].is_null() && a["projectPath"].is_string() { a["expectDocument"] = a["projectPath"].clone(); }+ let files = script("project_files", &a, pass);+ if files["success"].as_bool() != Some(true) { return files; }+ let name = files["name"].as_str().unwrap_or("design").to_string();+ let safe: String = name.chars().map(|c| if c.is_ascii_alphanumeric() || "-_.".contains(c) { c } else { '_' }).collect();+ let out = a["output"].as_str().map(|s| s.replace('\\', "/")).unwrap_or_else(|| {+ let home = std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME")).unwrap_or_else(|_| ".".into()).replace('\\', "/");+ format!("{home}/Documents/{safe}.pdf")+ });+ let dir = std::path::Path::new(&out).parent().map(|p| p.to_string_lossy().replace('\\', "/")).unwrap_or_else(|| ".".into());+ let _ = std::fs::create_dir_all(&dir);+ let tree = crate::sch::call("sch_hierarchy", &json!({"expectDocument": name}), pass);+ if tree["success"].as_bool() == Some(false) { return tree; }+ let n = tree["tree"]["sheets"].as_array().map(|s| s.len()).unwrap_or(1).max(1);+ let mut pages: Vec<(String, f64)> = Vec::new();+ for k in 1..=n {+ let r = crate::sch::call("render_schematic", &json!({"expectDocument": name, "sheet": k, "outputDir": dir, "name": format!("{safe}-pdf-sheet{k}"), "width": 3000}), pass);+ match r["png"].as_str() { Some(p) => pages.push((p.to_string(), r["dpi"].as_f64().unwrap_or(150.0))), None => return json!({"success": false, "error": format!("sheet {k}: {}", r["error"].as_str().unwrap_or("render failed"))}) }+ }+ if let Err(e) = crate::pdf::images_to_pdf(&pages, &out) { return json!({"success": false, "errorCode": "write_failed", "error": e}); }+ let bytes = std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0);+ for (p, _) in &pages { let _ = std::fs::remove_file(p); }+ let mut board_pdf = Value::Null;+ if a["board"].as_bool() == Some(true) {+ let front = script("front_tab", &json!({}), pass)["index"].clone();+ let tab = files["files"].as_array().and_then(|f| f.iter().find(|r| r["kind"] == "board" && r["open"] == true)).map(|r| r["tab"].clone());+ if let Some(tab) = tab {+ let _ = script("switch_tab", &json!({"index": tab}), pass);+ let png = format!("{dir}/{safe}-board.png");+ let r = pass("export_board_image", json!({"outputPath": png, "preset": "all", "dpi": 300}));+ if !front.is_null() { let _ = script("switch_tab", &json!({"index": front}), pass); }+ if r["success"].as_bool() == Some(true) {+ let bp = out.trim_end_matches(".pdf").to_string() + "-board.pdf";+ if crate::pdf::images_to_pdf(&[(png.clone(), r["dpi"].as_f64().unwrap_or(300.0))], &bp).is_ok() { board_pdf = json!(bp); }+ let _ = std::fs::remove_file(&png);+ }+ }+ }+ json!({"success": true, "pdfPath": out, "sheets": n, "outputBytes": bytes, "board": !board_pdf.is_null(), "boardPdf": board_pdf, "assembly": false, "fabrication": false,+ "_hint": "One page per sheet, rendered by Fusion's own EXPORT IMAGE at about 3000 px wide; board:true adds the board (all layers) as a second PDF."})+}
rust/bridge/src/routing.rs+2−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" | "update_board_from_eagle" | "board_dxf" | "edit_parts" | "align_parts" | "remove_graphics" | "remove_parts" | "check_inputs" | "export_odb" | "export_ipc356" | "export_gencad" | "export_ipc2581" | "import_svg" | "copy_routing" | "set_layer_count" | "board_info_compat") }+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" | "board_dxf" | "edit_parts" | "align_parts" | "remove_graphics" | "remove_parts" | "check_inputs" | "export_odb" | "export_ipc356" | "export_gencad" | "export_ipc2581" | "import_svg" | "copy_routing" | "set_layer_count" | "board_info_compat" | "footprint_primitives" | "thermal_vias" | "via_fence" | "board_features") } pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value { let mut args_v = args.clone(); if args_v.is_object() { args_v["_bridgeVersion"] = json!(env!("CARGO_PKG_VERSION")); }@@ -32,7 +32,7 @@ pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Val } 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" | "update_board_from_eagle" | "edit_parts" | "align_parts" | "remove_graphics" | "remove_parts" | "import_svg" | "copy_routing" | "set_layer_count")}),+ 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" | "edit_parts" | "align_parts" | "remove_graphics" | "remove_parts" | "import_svg" | "copy_routing" | "set_layer_count" | "thermal_vias" | "via_fence" | "board_features")}), } }
rust/bridge/src/routing_script.py+317−1@@ -3209,7 +3209,7 @@ def gencad_text(board, meta): dev = '%s_%s' % (shape_names[key], re.sub(r'\W+', '_', el.get('value') or '')) devs[dev] = (shape_names[key], el.get('value') or '') comps += ['COMPONENT %s' % q(el.get('name')), 'PLACE %s %s' % (f(el.get('x')), f(el.get('y'))), 'LAYER %s' % ('BOTTOM' if mirrored else 'TOP'),- 'ROTATION %s' % f(rot), 'SHAPE %s %s %s' % (q(shape_names[key]), 'MIRRORY' if mirrored else '0', 'FLIP' if mirrored else '0'), 'DEVICE %s' % q(dev)]+ 'ROTATION %s' % f(rot), 'SHAPE %s %s %s' % (q(shape_names[key]), 'MIRRORX' if mirrored else '0', 'FLIP' if mirrored else '0'), 'DEVICE %s' % q(dev)] out += ['$COMPONENTS'] + comps + ['$ENDCOMPONENTS'] out += ['$DEVICES'] + [l for d, (shp, val) in sorted(devs.items()) for l in ('DEVICE %s' % q(d), 'PART %s' % q(shp), 'VALUE %s' % q(val))] + ['$ENDDEVICES'] sig = ['$SIGNALS']@@ -3323,6 +3323,318 @@ def board_info_compat(board, meta): 'data': {'summary': summary, 'rawXml': '\n'.join(parts), 'source': 'board export (Electron.boardInfo is gone in this Fusion)'}} +def footprint_primitives(board, meta, args):+ """The standard's footprint_primitives for one placed part: its package's pads (mask and paste+ as EAGLE draws them), wires, arcs, circles, polygons and texts, placed on the board (mm, the+ board's frame; the standard's mil and layer ids come with them)."""+ ref = args.get('designator')+ el = _element(board, ref) if ref else None+ if el is None:+ fail('designator_not_found', 'No part %s on the board' % ref)+ pkg = next((p_ for l in board.findall('libraries/library') if l.get('name') == el.get('library') for p_ in l.findall('packages/package') if p_.get('name') == el.get('package')), None)+ if pkg is None:+ fail('invalid_export', 'The board does not embed %s' % el.get('package'))+ place, mirrored, _ = _place(el)+ L = Layers(board)+ flip = (lambda n: FLIP.get(n, n)) if mirrored else (lambda n: n)+ prims, other = [], {}+ for g in pkg:+ t = g.tag+ if t in ('smd', 'pad'):+ x, y = place(_f(g, 'x'), _f(g, 'y'))+ layer = flip(int(g.get('layer', '1'))) if t == 'smd' else 17+ row = {'kind': 'pad', 'name': g.get('name'), 'layerId': layer, 'layer': L.name(layer), 'xMm': round(x, 4), 'yMm': round(y, 4),+ 'xMil': round(x / 0.0254, 2), 'yMil': round(y / 0.0254, 2)}+ if t == 'smd':+ row.update(sizeMm=[_f(g, 'dx'), _f(g, 'dy')], roundness=_f(g, 'roundness'), solderMask=g.get('stop', 'yes') != 'no', paste=g.get('cream', 'yes') != 'no')+ else:+ row.update(drillMm=_f(g, 'drill'), diameterMm=_f(g, 'diameter'), shape=g.get('shape', 'round'), solderMask=g.get('stop', 'yes') != 'no')+ prims.append(row)+ elif t == 'wire':+ a, b = place(_f(g, 'x1'), _f(g, 'y1')), place(_f(g, 'x2'), _f(g, 'y2'))+ n = flip(int(g.get('layer')))+ prims.append({'kind': 'arc' if _f(g, 'curve') else 'track', 'layerId': n, 'layer': L.name(n), 'start': [round(a[0], 4), round(a[1], 4)], 'end': [round(b[0], 4), round(b[1], 4)],+ 'widthMm': _f(g, 'width'), 'curve': _f(g, 'curve') * (-1 if mirrored else 1)})+ elif t == 'text':+ x, y = place(_f(g, 'x'), _f(g, 'y'))+ n = flip(int(g.get('layer')))+ prims.append({'kind': 'text', 'layerId': n, 'layer': L.name(n), 'text': (g.text or ''), 'at': [round(x, 4), round(y, 4)], 'sizeMm': _f(g, 'size')})+ elif t != 'description':+ other[t] = other.get(t, 0) + 1+ return {'success': True, 'mutated': False, **meta, 'designator': ref, 'pattern': el.get('package'), 'side': 'bottom' if mirrored else 'top',+ 'count': len(prims), 'otherPrimitives': other, 'primitives': prims}+++# ── board furniture: thermal vias, via fences, mounting holes, fiducials, keepouts, stitching ──++def _seg_dist(p, a, b):+ ax, ay = a; bx, by = b; dx, dy = bx - ax, by - ay+ L = dx * dx + dy * dy+ t = 0.0 if L == 0 else max(0.0, min(1.0, ((p[0] - ax) * dx + (p[1] - ay) * dy) / L))+ return math.hypot(p[0] - ax - t * dx, p[1] - ay - t * dy)+++def _copper_env(board):+ """Everything a new via or hole must keep clear of: pads (their real shape), tracks, vias,+ holes, and the outline."""+ _, pads, wires, vias = objects(board)+ model, _ = board_model(board, {})+ loops = [[tuple(e['start']) for e in model['outlineEdges']]]+ edges = [(tuple(e['start']), tuple(e['end'])) for e in model['outlineEdges']] + [(tuple(e['start']), tuple(e['end'])) for c in model['outlineCutouts'] for e in c['edges']]+ holes = [((_f(h, 'x'), _f(h, 'y')), _f(h, 'drill') / 2) for h in board.findall('plain/hole')]+ return {'pads': pads, 'wires': wires, 'vias': vias, 'holes': holes, 'outline': loops[0], 'edges': edges}+++def _spot_clear(env, pt, radius, net, clearance, edge, layers=(1, 16), new=()):+ """Is a through via (or hole) of this radius at pt clear: of other nets' pads and tracks by the+ clearance, of every via and hole (same net too, by the clearance), and inside the outline by edge?"""+ if not _point_in(pt, env['outline']):+ return 'outside the board'+ if any(_seg_dist(pt, a, b) < radius + edge for a, b in env['edges']):+ return 'too close to the edge'+ for key, p_ in env['pads'].items():+ if p_['net'] == net and net:+ continue+ if not any(l in p_['layers'] for l in layers):+ continue+ if p_['poly']:+ inside = _point_in(pt, p_['poly'])+ d = 0.0 if inside else min(_seg_dist(pt, p_['poly'][k], p_['poly'][(k + 1) % 4]) for k in range(4))+ else:+ d = max(0.0, math.hypot(pt[0] - p_['at'][0], pt[1] - p_['at'][1]) - p_['radius'])+ if d < radius + clearance:+ return 'pad %s' % key+ for w in env['wires']:+ if w['net'] == net and net:+ continue+ if _seg_dist(pt, w['start'], w['end']) < radius + w['width'] / 2 + clearance:+ return 'track %s' % w['net']+ for v in list(env['vias']) + list(new):+ if math.hypot(pt[0] - v['at'][0], pt[1] - v['at'][1]) < radius + (v['diameter'] or v['drill'] * 2) / 2 + clearance:+ return 'via %s' % v['net']+ for at, r in env['holes']:+ if math.hypot(pt[0] - at[0], pt[1] - at[1]) < radius + r + clearance:+ return 'hole'+ return None+++def _via_rule(board):+ """The board's routing via: diameter from the drill and the via restring, drill from mlViaMin... or 0.3."""+ drill = 0.3+ rv = board.find("designrules/param[@name='rvViaOuter']")+ try: ring = float(rv.get('value')) if rv is not None else 0.25+ except ValueError: ring = 0.25+ mn = _rule_len(board, 'rlMinViaOuter', 0.2)+ return max(drill + 2 * max(drill * ring, mn), 0.6), drill+++def _place_and_check(app, args, board, items, label):+ """VIAs, HOLEs and plain shapes, then a read back: every via and hole must be where asked;+ otherwise every command is undone."""+ cmds = []+ for it in items:+ if it['kind'] == 'via':+ cmds.append("CHANGE DRILL %.6fmm; VIA '%s' %.6fmm ROUND 1-16 (%.6fmm %.6fmm);" % (it['drill'], identifier(it['net']), it['diameter'], it['at'][0], it['at'][1]))+ elif it['kind'] == 'hole':+ cmds.append("CHANGE DRILL %.6fmm; HOLE (%.6fmm %.6fmm);" % (it['drill'], it['at'][0], it['at'][1]))+ elif it['kind'] == 'circle':+ cmds.append("LAYER %d; CIRCLE %.6fmm (%.6fmm %.6fmm) (%.6fmm %.6fmm);" % (it['layer'], it.get('width', 0), it['at'][0], it['at'][1], it['at'][0] + it['radius'], it['at'][1]))+ elif it['kind'] == 'rect':+ x1, y1, x2, y2 = it['rect']+ cmds.append("LAYER %d; RECT R0 (%.6fmm %.6fmm) (%.6fmm %.6fmm);" % (it['layer'], x1, y1, x2, y2))+ done = 0+ for k in range(0, len(cmds), 25):+ app.executeTextCommand('Electron.run ' + ' '.join(cmds[k:k + 25]))+ done += len(cmds[k:k + 25])+ app.executeTextCommand('Electron.run RATSNEST;')+ _, after, meta2 = snapshot(app, args)+ _, _, _, vias2 = objects(after)+ holes2 = [(_f(h, 'x'), _f(h, 'y')) for h in after.findall('plain/hole')]+ missing = []+ for it in items:+ if it['kind'] == 'via' and not any(v['net'] == it['net'] and abs(v['at'][0] - it['at'][0]) < 0.002 and abs(v['at'][1] - it['at'][1]) < 0.002 for v in vias2):+ missing.append(it)+ if it['kind'] == 'hole' and not any(abs(h[0] - it['at'][0]) < 0.002 and abs(h[1] - it['at'][1]) < 0.002 for h in holes2):+ missing.append(it)+ if missing:+ for _ in range(done):+ app.executeTextCommand('Electron.run UNDO;')+ fail('readback_mismatch', '%s: %d placed item(s) did not read back (first at %s); every command was undone' % (label, len(missing), missing[0]['at']))+ return after, meta2, done+++def thermal_vias(app, root, board, meta, args):+ _guard(meta, args)+ ref = args.get('ref')+ _, pads, _, _ = objects(board)+ mine = {k: p_ for k, p_ in pads.items() if k.split('.', 1)[0] == ref}+ if not mine:+ fail('designator_not_found', 'No part %s on the board' % ref)+ def area(p_):+ if p_['poly']:+ q = p_['poly']; return abs(sum(q[i][0] * q[(i + 1) % 4][1] - q[(i + 1) % 4][0] * q[i][1] for i in range(4))) / 2+ return math.pi * p_['radius'] ** 2+ key = ('%s.%s' % (ref, args['pad'])) if args.get('pad') else max(mine, key=lambda k: area(mine[k]))+ pad = mine.get(key)+ if pad is None:+ fail('pad_not_found', 'No pad %s on %s' % (args.get('pad'), ref))+ if not pad['poly']:+ fail('not_supported', 'Pad %s is a through-hole pad: thermal vias go in an SMD exposed pad' % key)+ if not pad['net']:+ fail('no_net', 'Pad %s is on no net' % key)+ dia0, drill0 = _via_rule(board)+ dia = float(args.get('size') or dia0); drill = float(args.get('drill') or drill0)+ pitch = float(args.get('pitchMm') or max(dia + 0.5, 1.0)); inset = float(args.get('insetMm') or 0.1)+ q = pad['poly']+ c = (sum(x for x, _ in q) / 4, sum(y for _, y in q) / 4)+ u = (q[1][0] - q[0][0], q[1][1] - q[0][1]); v = (q[3][0] - q[0][0], q[3][1] - q[0][1])+ lu, lv = math.hypot(*u), math.hypot(*v)+ u, v = (u[0] / lu, u[1] / lu), (v[0] / lv, v[1] / lv)+ room_u, room_v = lu - 2 * (inset + dia / 2), lv - 2 * (inset + dia / 2)+ if room_u < 0 or room_v < 0:+ fail('pad_too_small', 'Pad %s (%.2f x %.2f mm) has no room for a %.2f mm via %.2f mm in from its edge' % (key, lu, lv, dia, inset))+ nu, nv = int(room_u // pitch) + 1, int(room_v // pitch) + 1+ pts = []+ for i in range(nu):+ for j in range(nv):+ a = (i - (nu - 1) / 2) * pitch; b = (j - (nv - 1) / 2) * pitch+ pts.append((round(c[0] + a * u[0] + b * v[0], 4), round(c[1] + a * u[1] + b * v[1], 4)))+ items = [{'kind': 'via', 'net': pad['net'], 'at': p, 'diameter': dia, 'drill': drill} for p in pts]+ planned = {'part': ref, 'pad': key.split('.', 1)[1], 'net': pad['net'], 'padMm': [round(lu, 3), round(lv, 3)], 'vias': len(items), 'pitchMm': pitch, 'via': {'diameter': dia, 'drill': drill}, 'points': pts}+ if args.get('dryRun'):+ return {'success': True, 'mutated': False, **meta, 'planned': planned}+ after, meta2, _ = _place_and_check(app, args, board, items, 'thermal vias')+ return {'success': True, 'mutated': True, **meta2, 'applied': len(items), 'failed': 0, 'planned': planned,+ '_hint': 'Open vias in a pad wick solder away during reflow: tent or plug them, or keep drills at 0.3 mm or less.'}+++def via_fence(app, root, board, meta, args):+ _guard(meta, args)+ via_net = args.get('viaNet')+ if not via_net or board.find("signals/signal[@name='%s']" % via_net) is None:+ fail('bad_args', 'viaNet (the net the vias tie to, usually GND) is required and must be on the board')+ nets = args.get('nets') or []+ dia = float(args.get('size') or 0.6); drill = float(args.get('drill') or 0.3); clr = float(args.get('clearance') or 0.2)+ pitch = float(args.get('pitchMm') or 2.0)+ if args.get('freqGHz'):+ lam20 = 300.0 / float(args['freqGHz']) / math.sqrt(4.4) / 20.0+ pitch = min(pitch, lam20)+ env = _copper_env(board)+ fence_wires = [w for w in env['wires'] if w['net'] in nets]+ if nets and not fence_wires:+ fail('no_tracks', 'No tracks on %s' % ', '.join(nets))+ widest = max([w['width'] for w in fence_wires] or [0.2])+ off = float(args.get('offsetMm') or (widest / 2 + clr + dia / 2 + 0.1))+ cands = []+ for w in fence_wires:+ a, b = w['start'], w['end']; L = math.hypot(b[0] - a[0], b[1] - a[1])+ if L == 0: continue+ ux, uy = (b[0] - a[0]) / L, (b[1] - a[1]) / L; nx, ny = -uy, ux+ n = max(1, int(L // pitch))+ for k in range(n + 1):+ t = k * L / n+ for sgn in (1, -1):+ cands.append((round(a[0] + ux * t + sgn * nx * off, 4), round(a[1] + uy * t + sgn * ny * off, 4)))+ if args.get('edge'):+ inset = float(args.get('edgeInsetMm') or (dia / 2 + 0.5))+ for a, b in env['edges']:+ L = math.hypot(b[0] - a[0], b[1] - a[1])+ if L == 0: continue+ ux, uy = (b[0] - a[0]) / L, (b[1] - a[1]) / L+ for sgn in (1, -1):+ nx, ny = -uy * sgn, ux * sgn+ probe = (a[0] + ux * L / 2 + nx * inset, a[1] + uy * L / 2 + ny * inset)+ if _point_in(probe, env['outline']):+ n = max(1, int(L // pitch))+ cands += [(round(a[0] + ux * k * L / n + nx * inset, 4), round(a[1] + uy * k * L / n + ny * inset, 4)) for k in range(n + 1)]+ break+ placed = []+ for p in cands:+ new = [{'at': q, 'diameter': dia, 'drill': drill, 'net': via_net} for q in placed]+ # same-net vias keep a pitch apart too, so a fence is a row, not a cluster+ if any(math.hypot(p[0] - q[0], p[1] - q[1]) < pitch * 0.8 for q in placed):+ continue+ if _spot_clear(env, p, dia / 2, via_net, clr, max(0.0, dia / 2), new=new) is None:+ placed.append(p)+ items = [{'kind': 'via', 'net': via_net, 'at': p, 'diameter': dia, 'drill': drill} for p in placed]+ planned = {'vias': len(items), 'candidates': len(cands), 'pitchMm': round(pitch, 3), 'offsetMm': round(off, 3), 'points': placed}+ if args.get('dryRun') or not items:+ return {'success': True, 'mutated': False, **meta, 'planned': planned, 'applied': 0}+ after, meta2, _ = _place_and_check(app, args, board, items, 'via fence')+ return {'success': True, 'mutated': True, **meta2, 'applied': len(items), 'failed': len(cands) - len(items), 'planned': planned,+ '_hint': 'Vias go through, top to bottom; a pour on %s should exist on the layers they join. Spots too close to other copper, pads or the edge were skipped.' % via_net}+++def board_features(app, root, board, meta, args):+ _guard(meta, args)+ env = _copper_env(board)+ items, results = [], []+ xs = [p[0] for p in env['outline']]; ys = [p[1] for p in env['outline']]+ mh = args.get('mountingHoles') or []+ if isinstance(mh, dict) and mh.get('corners') is not None:+ ins = float((mh.get('corners') or {}).get('inset', 4))+ spec = {k: v for k, v in mh.items() if k != 'corners'}+ mh = [dict(spec, x=x, y=y) for x, y in ((min(xs) + ins, min(ys) + ins), (max(xs) - ins, min(ys) + ins), (max(xs) - ins, max(ys) - ins), (min(xs) + ins, max(ys) - ins))]+ for h in mh:+ d = float(h.get('drill', 3.2)); at = (float(h['x']), float(h['y']))+ if h.get('plated'):+ pad = float(h.get('pad', d + 2.8)); net = h.get('net') or 'MOUNT'+ why = _spot_clear(env, at, pad / 2, net if h.get('net') else None, 0.3, 0.3)+ it = {'kind': 'via', 'net': net, 'at': at, 'diameter': pad, 'drill': d}+ else:+ why = _spot_clear(env, at, d / 2, None, 0.3, 0.3)+ it = {'kind': 'hole', 'at': at, 'drill': d}+ if why: results.append('err: mounting hole at %s: %s' % (list(at), why)); continue+ items.append(it); results.append('ok')+ for fd in args.get('fiducials') or []:+ at = (float(fd['x']), float(fd['y'])); size = float(fd.get('size', 1)); opening = float(fd.get('opening', 2))+ bottom = fd.get('side') == 'bottom'+ why = _spot_clear(env, at, opening / 2, None, 0.1, 0.5, layers=(16,) if bottom else (1,))+ if why: results.append('err: fiducial at %s: %s' % (list(at), why)); continue+ items.append({'kind': 'circle', 'layer': 16 if bottom else 1, 'at': at, 'radius': size / 2, 'width': 0})+ items.append({'kind': 'circle', 'layer': 30 if bottom else 29, 'at': at, 'radius': opening / 2, 'width': 0})+ results.append('ok')+ layer_of = {'all': (41, 42, 43), 'F.Cu': (41,), 'B.Cu': (42,)}+ for ko in args.get('keepouts') or []:+ r_ = [float(v) for v in ko['rect']]+ lay = ko.get('layer', 'all')+ for n in layer_of.get(lay, (43,)):+ items.append({'kind': 'rect', 'layer': n, 'rect': r_})+ results.append('ok')+ st = args.get('stitching')+ stitched = []+ if st:+ net = st.get('net')+ if not net or board.find("signals/signal[@name='%s']" % net) is None:+ fail('bad_args', 'stitching.net must be a net on the board')+ pitch = float(st.get('pitch', 5)); dia = float(st.get('size', 0.6)); drill = float(st.get('drill', 0.3))+ clr = float(st.get('clearance', 0.3)); edge = float(st.get('edge', 1))+ region = st.get('region')+ rx = [p[0] for p in region] if region else xs; ry = [p[1] for p in region] if region else ys+ keep = [tuple(ko['rect']) for ko in args.get('keepouts') or []]+ new = [{'at': it['at'], 'diameter': it.get('diameter') or it.get('drill', 0) or 0.1, 'drill': it.get('drill', 0.1), 'net': it.get('net')} for it in items if it['kind'] in ('via', 'hole')]+ y = min(ry) + pitch / 2+ while y < max(ry) and len(stitched) < int(st.get('max', 400)):+ x = min(rx) + pitch / 2+ while x < max(rx) and len(stitched) < int(st.get('max', 400)):+ p = (round(x, 4), round(y, 4))+ inside_region = _point_in(p, [tuple(q) for q in region]) if region else True+ in_keep = any(min(k[0], k[2]) <= p[0] <= max(k[0], k[2]) and min(k[1], k[3]) <= p[1] <= max(k[1], k[3]) for k in keep)+ if inside_region and not in_keep and _spot_clear(env, p, dia / 2, net, clr, edge, new=new + [{'at': q, 'diameter': dia, 'drill': drill, 'net': net} for q in stitched]) is None:+ stitched.append(p)+ x += pitch+ y += pitch+ items += [{'kind': 'via', 'net': net, 'at': p, 'diameter': dia, 'drill': drill} for p in stitched]+ planned = {'mountingHoles': sum(1 for it in items if it['kind'] == 'hole' or (it['kind'] == 'via' and it['diameter'] >= 2)),+ 'fiducials': sum(1 for it in items if it['kind'] == 'circle') // 2, 'keepouts': len(args.get('keepouts') or []),+ 'stitching': {'count': len(stitched), 'points': stitched}}+ if args.get('dryRun') or not items:+ return {'success': True, 'mutated': False, **meta, 'planned': planned, 'results': results, 'applied': 0}+ after, meta2, n = _place_and_check(app, args, board, items, 'board features')+ return {'success': True, 'mutated': True, **meta2, 'applied': len(items), 'planned': planned, 'results': results,+ '_hint': 'Stitching keeps a pad clearance from each pad\'s real shape and ignores pours (other nets\' pours repour around the vias). Unplated holes are bare; plated ones are via-holes on their net.'}++ def handle(app,args,action): attempted=False; commands=[] try:@@ -3333,6 +3645,10 @@ def handle(app,args,action): args['expectDocument']=app.activeDocument.name root,board,meta=snapshot(app,args) if action=='board_info_compat': return board_info_compat(board,meta)+ if action=='footprint_primitives': return footprint_primitives(board,meta,args)+ if action=='thermal_vias': return thermal_vias(app,root,board,meta,args)+ if action=='via_fence': return via_fence(app,root,board,meta,args)+ if action=='board_features': return board_features(app,root,board,meta,args) if action=='routing_state': st=state(board,meta); n,fa=st['unconnected'],st['fusionAirwires'] hint='Edit verbs take boardId and this revision as expectedRevision. unconnected (%d) is computed from the copper itself.'%n
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") }+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") } 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+259−2@@ -1529,7 +1529,258 @@ def cross_probe(app, args): 'matches': matches, 'symbolWithoutPart': without, 'selected': selected, 'errorCode': 'designator_not_found' if unknown and len(unknown) == len(matches) else None, '_hint': hint + ('' if bdoc is not None else ' No board is open beside this schematic, so every part reads linked:false.')} -EDITS = {'sch_text': sch_text, 'sch_junction': sch_junction, 'sch_delete_wire': sch_delete_wire, 'sch_connect_pins': sch_connect_pins,++# ── schematic data, parameters, cross check (read) ──────────────────────────────────────────────++ELECTRICAL = {'in': 0, 'io': 1, 'out': 2, 'oc': 3, 'pas': 4, 'hiz': 5, 'pwr': 7, 'sup': 7, 'nc': 4}+++def _part_attrs(lib, p):+ """A placed part's attributes: its library device's technology attributes, then the part's own."""+ out = {}+ ds = lib.devicesets.get((p.get('library'), p.get('deviceset')))+ if ds is not None:+ dev = next((d for d in ds.findall('devices/device') if d.get('name', '') == p.get('device', '')), None)+ if dev is not None:+ tech = next((t for t in dev.findall('technologies/technology') if t.get('name', '') == p.get('technology', '')), None)+ for a in (tech.findall('attribute') if tech is not None else []):+ if a.get('name') != 'ADOM_PINMAP':+ out[a.get('name')] = a.get('value', '')+ for a in p.findall('attribute'):+ out[a.get('name')] = a.get('value', '')+ return out+++def schematic_data(root, args):+ """The standard's schematic_data: every part (on one sheet, or all) with its pins (number, name,+ electrical type), position and turn of each gate, value, library part and attributes."""+ sch = root.find('drawing/schematic')+ lib = Lib(sch)+ want_sheet = args.get('sheet') if args.get('sheet') not in (None, '', 'all') else None+ where = {}+ for n, sh in enumerate(sheets_of(root), 1):+ if want_sheet is not None and n != int(want_sheet):+ continue+ for inst in sh.findall('instances/instance'):+ where.setdefault(inst.get('part'), []).append((n, inst))+ comps = []+ for name, p in lib.parts.items():+ if args.get('designator') and name != args['designator']:+ continue+ if name not in where:+ continue+ ds = lib.devicesets.get((p.get('library'), p.get('deviceset')))+ gates = []+ pins = []+ for n, inst in where[name]:+ g = inst.get('gate')+ rot = inst.get('rot', 'R0')+ gates.append({'gate': g, 'sheet': n, 'at': [_sf(inst, 'x'), _sf(inst, 'y')], 'rotation': _rot(rot)[1], 'mirrored': 'M' in rot})+ if args.get('pins', True) is not False:+ sym, _ = lib.gate_symbol(name, g)+ for pin in (sym.findall('pin') if sym is not None else []):+ pins.append({'number': ' '.join(lib.pads(name, g, pin.get('name'))), 'name': pin.get('name').split('@')[0], 'pin': pin.get('name'), 'gate': g,+ 'electrical': ELECTRICAL.get(pin.get('direction', 'io'), 1)})+ row = {'designator': name, 'libReference': p.get('deviceset'), 'device': p.get('device', ''), 'library': p.get('library'), 'comment': p.get('value') or p.get('deviceset'),+ 'description': (ds.findtext('description') or '').strip()[:300] if ds is not None else '', 'gates': gates,+ 'xMm': gates[0]['at'][0], 'yMm': gates[0]['at'][1], 'rotation': gates[0]['rotation'], 'pins': pins}+ if args.get('params') or args.get('paramNamesOnly'):+ a = _part_attrs(lib, p)+ row['params'] = sorted(a) if args.get('paramNamesOnly') else a+ comps.append(row)+ comps.sort(key=lambda c: c['designator'])+ limit = int(args.get('limit') or 500)+ return {'count': len(comps), 'components': comps[:limit], 'truncated': len(comps) > limit}+++def part_parameters(root, args):+ sch = root.find('drawing/schematic')+ lib = Lib(sch)+ placed = {inst.get('part'): n for n, sh in enumerate(sheets_of(root), 1) for inst in sh.findall('instances/instance')}+ if args.get('name'):+ want = str(args['name']).upper()+ vals = []+ for name, p in sorted(lib.parts.items()):+ a = {k.upper(): v for k, v in _part_attrs(lib, p).items()}+ if want in a and name in placed:+ vals.append({'designator': name, 'value': a[want], 'sheet': placed.get(name)})+ return {'name': args['name'], 'values': vals, 'count': len(vals)}+ counts = {}+ for name, p in lib.parts.items():+ if name not in placed:+ continue+ for k in _part_attrs(lib, p):+ counts[k] = counts.get(k, 0) + 1+ return {'names': [{'name': k, 'parts': v} for k, v in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))]}+++def cross_check(app, args):+ """The standard's cross_check: the schematic's designators against the board's (Fusion keeps one+ design's schematic and board linked; a difference is a part placed or deleted on one side only)."""+ i, sdoc = sch_doc(app, args)+ docs = [app.documents.item(k) for k in range(app.documents.count)]+ j = docs.index(app.activeDocument) if app.activeDocument in docs else None+ sroot = copy_tab(app, i, j, 'fsch')+ bi, bdoc = board_doc_of(app, sdoc)+ if bdoc is None:+ sfail('no_board', 'No board is open beside the schematic %s' % sdoc.name)+ broot = copy_tab(app, bi, j, 'fbrd')+ matches, without = cross_probe_pairs(sroot, broot, {})+ on_board_only = [m['ref'] for m in matches if 'board' in m and 'schematic' not in m]+ return {'success': True, 'mutated': False, 'sheet': sdoc.name, 'board': bdoc.name, 'sameProject': True,+ 'schematicComponents': sum(1 for m in matches if 'schematic' in m), 'boardComponents': sum(1 for m in matches if 'board' in m),+ 'matched': sum(1 for m in matches if m.get('linked')), 'onSheetNotOnBoard': without, 'onBoardNotOnSheet': on_board_only,+ '_hint': 'Fusion forward-annotates: a part only on the board was placed there (logos, mounting holes), one only on the sheet has no package (supply symbols are left out) or the board is out of step.'}+++# ── document and part parameters, part edits, annotate (edit) ──────────────────────────────────++def doc_params(app, args):+ """The standard's doc_params: the schematic's global attributes (Fusion's title-block values)+ read, and with set written by ATTRIBUTE * NAME 'value'."""+ to_set = args.get('set') or {}+ if not to_set:+ root, meta, _ = sch_snapshot(app, args)+ attrs = root.findall('drawing/schematic/attributes/attribute')+ return {'success': True, 'mutated': False, **meta, 'params': [{'name': a.get('name'), 'value': a.get('value', '')} for a in attrs], 'set': 0}+ e = Edit(app, args)+ for k, v in to_set.items():+ e.cmds.append('SET CONFIRM YES; ATTRIBUTE * %s %s; SET CONFIRM OFF;' % (quote(str(k).upper()), quote(str(v))))+ if dry(args):+ return plan_reply(e, set=to_set)+ e.run()+ got = {a.get('name').upper(): a.get('value', '') for a in e.after.findall('drawing/schematic/attributes/attribute')}+ bad = [k for k, v in to_set.items() if got.get(str(k).upper()) != str(v)]+ if bad:+ e.undo('global attributes %s did not read back' % bad)+ return {'success': True, 'mutated': True, **e.meta2, 'params': [{'name': k, 'value': v} for k, v in sorted(got.items())], 'set': len(to_set)}+++def sheet_params(app, args):+ """The standard's sheet_params: attributes written onto placed parts (ATTRIBUTE 'REF' NAME 'value'),+ the BOM write-back; Fusion keeps the board's copy in step."""+ refs = args.get('designators') or []+ if isinstance(refs, str):+ refs = [r.strip() for r in refs.split(',') if r.strip()]+ params = args.get('params') or {}+ if isinstance(params, str):+ params = dict(kv.split('=', 1) for kv in params.split(';') if '=' in kv)+ if not refs or not params:+ sfail('bad_args', 'designators and params are required')+ e = Edit(app, args)+ lib = Lib(e.root.find('drawing/schematic'))+ found = [r for r in refs if r in lib.parts]+ missing = [r for r in refs if r not in lib.parts]+ for r in found:+ for k, v in params.items():+ e.cmds.append('SET CONFIRM YES; ATTRIBUTE %s %s %s; SET CONFIRM OFF;' % (quote(r), quote(str(k).upper()), quote(str(v))))+ if dry(args):+ return plan_reply(e, updated=found, notFound=missing)+ if not e.cmds:+ sfail('designator_not_found', 'None of %s is in the schematic' % refs)+ e.run()+ lib2 = Lib(e.after.find('drawing/schematic'))+ bad = []+ for r in found:+ a = {k.upper(): v for k, v in _part_attrs(lib2, lib2.parts[r]).items()} if r in lib2.parts else {}+ bad += ['%s.%s' % (r, k) for k, v in params.items() if a.get(str(k).upper()) != str(v)]+ if bad:+ e.undo('attributes %s did not read back' % bad[:5])+ return {'success': True, 'mutated': True, **e.meta2, 'updated': found, 'notFound': missing, 'sheets': [{'sheet': e.n, 'designators': found, 'paramsSet': len(found) * len(params)}]}+++MIL = 0.0254+++def sch_edit(app, args):+ """The standard's sch_edit on placed parts: move (x, y mil), moveBy (dx, dy mil), rotate (degrees),+ designator (rename), comment (value) and delete, each read back; a mismatch undoes the edit."""+ e = Edit(app, args)+ lib = Lib(e.root.find('drawing/schematic'))+ insts = {inst.get('part'): inst for inst in e.sheet.findall('instances/instance')}+ plan, failed = [], []+ for ed in args.get('edits') or []:+ r, act = ed.get('designator'), ed.get('action')+ if r not in lib.parts or r not in insts:+ failed.append({'edit': ed, 'error': 'no part %s on this sheet' % r}); continue+ inst = insts[r]+ at = (_sf(inst, 'x'), _sf(inst, 'y'))+ if act == 'move':+ q = (float(ed['x']) * MIL, float(ed['y']) * MIL)+ e.cmds.append('SET CONFIRM NO; MOVE %s %s; SET CONFIRM OFF;' % (quote(r), mm(q))); plan.append((r, 'at', q))+ elif act == 'moveBy':+ q = (at[0] + float(ed['dx']) * MIL, at[1] + float(ed['dy']) * MIL)+ e.cmds.append('SET CONFIRM NO; MOVE %s %s; SET CONFIRM OFF;' % (quote(r), mm(q))); plan.append((r, 'at', q))+ elif act == 'rotate':+ deg = float(ed['degrees']) % 360+ e.cmds.append('ROTATE =R%g %s;' % (deg, quote(r))); plan.append((r, 'rot', deg))+ elif act == 'designator':+ if ed.get('to') in lib.parts:+ failed.append({'edit': ed, 'error': '%s is taken' % ed.get('to')}); continue+ e.cmds.append('SET CONFIRM YES; NAME %s %s; SET CONFIRM OFF;' % (quote(r), quote(ed['to']))); plan.append((ed['to'], 'exists', r))+ elif act == 'comment':+ e.cmds.append('SET CONFIRM YES; VALUE %s %s; SET CONFIRM OFF;' % (quote(r), quote(ed.get('text', '')))); plan.append((r, 'value', ed.get('text', '')))+ elif act == 'delete':+ e.cmds.append('SET CONFIRM YES; DELETE %s; SET CONFIRM OFF;' % quote(r)); plan.append((r, 'gone', None))+ else:+ failed.append({'edit': ed, 'error': 'action is move, moveBy, rotate, designator, comment or delete'})+ if dry(args):+ return plan_reply(e, planned=[{'designator': a, 'check': b} for a, b, _ in plan], failed=failed)+ if not e.cmds:+ sfail('nothing_to_do', 'No edit applies: %s' % failed)+ e.run()+ lib2 = Lib(e.after.find('drawing/schematic'))+ insts2 = {inst.get('part'): inst for inst in e.sheet2.findall('instances/instance')}+ bad, results = [], []+ for r, kind, v in plan:+ ok = True+ if kind == 'at': i2 = insts2.get(r); ok = i2 is not None and abs(_sf(i2, 'x') - v[0]) < 0.01 and abs(_sf(i2, 'y') - v[1]) < 0.01+ elif kind == 'rot': i2 = insts2.get(r); ok = i2 is not None and abs(_rot(i2.get('rot', 'R0'))[1] % 360 - v) < 0.01+ elif kind == 'exists': ok = r in lib2.parts and v not in lib2.parts+ elif kind == 'value': ok = r in lib2.parts and (lib2.parts[r].get('value') or '') == v+ elif kind == 'gone': ok = r not in lib2.parts+ results.append({'designator': r, 'check': kind, 'result': 'ok' if ok else 'mismatch'})+ if not ok: bad.append(r)+ if bad:+ e.undo('parts %s did not read back as asked' % bad)+ return {'success': True, 'mutated': True, **e.meta2, 'applied': len(plan), 'failed': failed, 'results': results}+++def annotate(app, args):+ """The standard's annotate: every 'R?'-style designator gets the lowest free number of its prefix,+ sheet by sheet, top-left first (Fusion names parts as they are added, so this is rare)."""+ e = Edit(app, args)+ lib = Lib(e.root.find('drawing/schematic'))+ used = {}+ for n in lib.parts:+ m = re.fullmatch(r'([A-Za-z_$]+)(\d+)', n)+ if m:+ used.setdefault(m[1], set()).add(int(m[2]))+ todo = []+ for k, sh in enumerate(sheets_of(e.root), 1):+ for inst in sorted(sh.findall('instances/instance'), key=lambda i: (-_sf(i, 'y'), _sf(i, 'x'))):+ name = inst.get('part')+ if name.endswith('?') and name not in [t[1] for t in todo]:+ todo.append((k, name))+ planned = []+ for k, name in todo:+ pre = name[:-1] or 'U'+ num = 1+ while num in used.setdefault(pre, set()):+ num += 1+ used[pre].add(num)+ planned.append({'sheet': k, 'from': name, 'to': '%s%d' % (pre, num)})+ e.cmds.append('SET CONFIRM YES; NAME %s %s; SET CONFIRM OFF;' % (quote(name), quote('%s%d' % (pre, num))))+ if dry(args) or not planned:+ return {'success': True, 'mutated': False, **e.meta, 'planned': planned, 'renamed': [], 'skippedMultiPart': []}+ e.run()+ lib2 = Lib(e.after.find('drawing/schematic'))+ bad = [p for p in planned if p['to'] not in lib2.parts]+ if bad:+ e.undo('renames %s did not read back' % bad[:3])+ return {'success': True, 'mutated': True, **e.meta2, 'renamed': [dict(p, result='ok') for p in planned], 'skippedMultiPart': []}++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} @@ -1651,9 +1902,15 @@ def handle(app, args, action): return sch_render(app, args) if action == 'cross_probe': return cross_probe(app, args)+ if action == 'cross_check':+ return cross_check(app, args) root, meta, _ = sch_snapshot(app, args)- n, sheet = pick_sheet(root, args) if action != 'sch_hierarchy' else (None, None)+ n, sheet = pick_sheet(root, args) if action not in ('sch_hierarchy', 'schematic_data', 'part_parameters') 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_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.'}
tests/run_verb_tests.py+14−1@@ -258,6 +258,19 @@ t("fusion_export_gencad", "daily", dict(B, outputDir=f"{SCRATCH}/gencad"), {"ok" t("fusion_export_ipc2581", "daily", dict(B, outputDir=f"{SCRATCH}/ipc2581"), {"ok": True, "fields": ["path", "components", "pourIslands"]}) t("fusion_read_footprint", "daily", dict(B, libId="Capacitor:CAPC2012X110"), {"ok": True, "fields": ["pads", "courtyardMm", "layers"]}) t("fusion_project_info", "daily", dict(B), {"ok": True, "fields": ["files", "board", "sheets"]})+t("fusion_schematic_data", "daily", dict(B, designator="C11", params=True), {"ok": True, "fields": ["components", "count"]})+t("fusion_part_parameters", "daily", dict(B), {"ok": True, "fields": ["names"]})+t("fusion_cross_check", "daily", dict(B), {"ok": True, "fields": ["matched", "onSheetNotOnBoard", "onBoardNotOnSheet"]})+t("fusion_doc_params", "daily", dict(B), {"ok": True, "fields": ["params"]})+t("fusion_sheet_params", "daily", dict(B, designators=["C11"], params={"ADOM_TEST": "1"}, dryRun=True), {"ok": True})+t("fusion_sch_edit", "daily", dict(B, edits=[{"designator": "C11", "action": "rotate", "degrees": 90}], dryRun=True), {"ok": True})+t("fusion_annotate", "daily", dict(B, dryRun=True), {"ok": True, "fields": ["planned"]})+t("fusion_thermal_vias", "daily", dict(B, ref="L2", pad="2", dryRun=True), {"ok": True, "fields": ["planned"]})+t("fusion_via_fence", "daily", dict(B, nets=["D_P", "D_N"], viaNet="PGND", dryRun=True), {"ok": True, "fields": ["planned"]})+t("fusion_board_features", "daily", dict(B, stitching={"net": "PGND", "pitch": 4}, dryRun=True), {"ok": True, "fields": ["planned", "results"]})+t("fusion_thermal_vias", "negative", dict(B, ref="IC1"), {"errorCode": "pad_too_small"}, "IC1 has no exposed pad: refused before anything is drawn")+t("fusion_footprint_primitives", "daily", dict(B, designator="C11"), {"ok": True, "fields": ["primitives", "pattern"]})+t("fusion_export_pdf", "daily", dict(B, output=f"{SCRATCH}/pdf/design.pdf"), {"ok": True, "fields": ["pdfPath", "sheets"]}) t("fusion_project_rename", "negative", {"project": "Main/Molecules/BQ25792", "newName": "X"}, {"errorCode": "not_supported"}, "a cloud design is renamed in Fusion's data panel, not by path") t("fusion_project_create", "daily", {"parentDir": "C:/Users/olive/AppData/Local/Temp/fusion-lane", "name": "lane-new", "layers": 4, "dryRun": True}, {"ok": True, "fields": ["plan"]}) t("fusion_project_snapshot", "daily", dict(B, label="daily", outputDir=f"{SCRATCH}/snapshots"), {"ok": True, "fields": ["folder", "files", "pdfs"]})@@ -475,7 +488,7 @@ _SECTION_BY_VERB = { "fusion_action_ledger": "bridge-lifecycle", "fusion_addin_status": "python-addin", "fusion_alerts": "gui-driving", "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_export_library_part": "cad-3d-bom", "fusion_edit_footprint": "cad-3d-bom", "fusion_create_footprint": "cad-3d-bom", "fusion_create_symbol": "cad-3d-bom", "fusion_read_symbol": "cad-3d-bom", "fusion_find_symbol": "cad-3d-bom", "fusion_copy_footprint": "cad-3d-bom", "fusion_rename_footprint": "cad-3d-bom", "fusion_remove_footprint": "cad-3d-bom", "fusion_xcopy_footprint": "cad-3d-bom", "fusion_clone_symbol": "cad-3d-bom", "fusion_rename_symbol": "cad-3d-bom", "fusion_remove_symbol": "cad-3d-bom", "fusion_set_params": "cad-3d-bom", "fusion_read_footprint": "cad-3d-bom", "fusion_cross_probe": "routing", "fusion_project_info": "routing", "fusion_project_create": "routing", "fusion_project_rename": "routing", "fusion_project_snapshot": "routing", "fusion_import_svg": "routing", "fusion_copy_routing": "routing", "fusion_set_layer_count": "routing", "fusion_take_screenshot": "gui-driving",+ "fusion_preflight_lbr": "cad-3d-bom", "fusion_library_parts": "cad-3d-bom", "fusion_export_library_part": "cad-3d-bom", "fusion_edit_footprint": "cad-3d-bom", "fusion_create_footprint": "cad-3d-bom", "fusion_create_symbol": "cad-3d-bom", "fusion_read_symbol": "cad-3d-bom", "fusion_find_symbol": "cad-3d-bom", "fusion_copy_footprint": "cad-3d-bom", "fusion_rename_footprint": "cad-3d-bom", "fusion_remove_footprint": "cad-3d-bom", "fusion_xcopy_footprint": "cad-3d-bom", "fusion_clone_symbol": "cad-3d-bom", "fusion_rename_symbol": "cad-3d-bom", "fusion_remove_symbol": "cad-3d-bom", "fusion_set_params": "cad-3d-bom", "fusion_read_footprint": "cad-3d-bom", "fusion_cross_probe": "routing", "fusion_project_info": "routing", "fusion_schematic_data": "routing", "fusion_part_parameters": "routing", "fusion_cross_check": "routing", "fusion_doc_params": "routing", "fusion_sheet_params": "routing", "fusion_sch_edit": "routing", "fusion_annotate": "routing", "fusion_footprint_primitives": "routing", "fusion_thermal_vias": "routing", "fusion_via_fence": "routing", "fusion_board_features": "routing", "fusion_export_pdf": "routing", "fusion_project_create": "routing", "fusion_project_rename": "routing", "fusion_project_snapshot": "routing", "fusion_import_svg": "routing", "fusion_copy_routing": "routing", "fusion_set_layer_count": "routing", "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_update_board_from_eagle": "routing", "fusion_render_board_3d": "cad-3d-bom", "fusion_generate_body": "cad-3d-bom", "fusion_library_views": "gui-driving", "fusion_edit_parts": "routing", "fusion_align_parts": "routing", "fusion_remove_graphics": "routing", "fusion_remove_parts": "routing", "fusion_copper_balance": "routing", "fusion_return_path": "routing", "fusion_impedance": "routing", "fusion_current_capacity": "routing", "fusion_fab_check": "routing", "fusion_export_odb": "exports", "fusion_export_ipc356": "exports", "fusion_export_gencad": "exports", "fusion_export_ipc2581": "exports", "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",
tests/test_routing.py+39@@ -478,4 +478,43 @@ class LayerCountTests(unittest.TestCase): r.stackup_for(8, 0.8, {}, None) ++class FootprintPrimitivesTests(unittest.TestCase):+ def test_pads_land_where_the_board_model_has_them(self):+ root = ET.parse(ROOT/'demo/routing/fixture/fusion-routing.brd').getroot(); b = root.find('drawing/board')+ ref = b.find('elements/element').get('name')+ out = r.footprint_primitives(b, {}, {'designator': ref})+ m, _ = r.board_model(b, {})+ model_pads = {(p_['name']): (round(p_['x'], 3), round(p_['y'], 3)) for p_ in m['pads'] if p_['ref'] == ref}+ for p_ in out['primitives']:+ if p_['kind'] == 'pad':+ self.assertEqual(model_pads[p_['name']], (round(p_['xMm'], 3), round(p_['yMm'], 3)))+ with self.assertRaises(r.RoutingError):+ r.footprint_primitives(b, {}, {'designator': 'NOPE9'})++ def test_gencad_bottom_parts_mirror_x(self):+ bq = Path('/home/adom/project/fusion-lane/bq25792-charger/bq25792-charger.brd')+ if not bq.exists():+ self.skipTest('BQ25792 fixture not on this machine')+ b = ET.parse(bq).getroot().find('drawing/board')+ text, _ = r.gencad_text(b, {'documentName': 'x'})+ self.assertIn('MIRRORX FLIP', text)+ self.assertNotIn('MIRRORY', text)++++class BoardFurnitureTests(unittest.TestCase):+ def test_spot_clear_and_grid(self):+ root = ET.parse(ROOT/'demo/routing/fixture/fusion-routing.brd').getroot(); b = root.find('drawing/board')+ env = r._copper_env(b)+ _, pads, _, _ = r.objects(b)+ k, p_ = next(iter(pads.items()))+ self.assertIsNotNone(r._spot_clear(env, tuple(p_['at']), 0.3, None, 0.2, 0.5), 'a spot on a pad of another net is refused')+ self.assertEqual('outside the board', r._spot_clear(env, (-50, -50), 0.3, None, 0.2, 0.5))+ r._guard = lambda m, a: None+ o = r.board_features(None, None, b, {'documentName': 'x'}, {'stitching': {'net': p_['net'] or 'GND', 'pitch': 3}, 'dryRun': True})+ for pt in o['planned']['stitching']['points']:+ self.assertIsNone(r._spot_clear(env, tuple(pt), 0.3, p_['net'], 0.3, 1.0))++ if __name__=='__main__':unittest.main()
tests/test_sch.py+13@@ -148,5 +148,18 @@ class CrossProbeTests(unittest.TestCase): self.assertEqual(sorted(x['ref'] for x in m), sorted(without)) ++class SchematicDataTests(unittest.TestCase):+ def test_parts_pins_and_params(self):+ root = s.sch_root_from_file(str(FIXTURE))+ d = s.schematic_data(root, {})+ self.assertGreater(d['count'], 0)+ j1 = [c for c in d['components'] if c['designator'] == 'J1'][0]+ self.assertTrue(j1['pins'] and all(p['number'] for p in j1['pins']))+ self.assertIn(j1['pins'][0]['electrical'], (0, 1, 2, 3, 4, 5, 7))+ names = s.part_parameters(root, {})['names']+ self.assertIsInstance(names, list)++ if __name__ == '__main__': unittest.main()