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 26 file(s)
26 files changed
+1327−41
BRIDGE_VERSION+1−1CHANGELOG.md+23bridge.json+14−2contracts/describe.json+145dashboard/Cargo.lock+1−1dashboard/Cargo.toml+1−1describe.py+24install.sh+1−1package.json+1−1page.json+5−11rust/Cargo.lock+6−6rust/Cargo.toml+1−1rust/bridge/src/checks.rs+88−5rust/vendor/adom-eda-checks/Cargo.toml+1−1rust/vendor/adom-eda-checks/README.md+11−1rust/vendor/adom-eda-checks/VENDOR.md+3−3rust/vendor/adom-eda-checks/src/lengthtune.rs+138rust/vendor/adom-eda-checks/src/lib.rs+12−1rust/vendor/adom-eda-checks/src/main.rs+1−1rust/vendor/adom-eda-checks/src/pairroute.rs+195rust/vendor/adom-eda-checks/src/report.rs+170rust/vendor/adom-eda-checks/src/review.rs+223rust/vendor/adom-eda-checks/src/route.rs+1−1rust/vendor/adom-eda-checks/src/schreview.rs+252rust/vendor/adom-eda-checks/src/writers/ipc2581.rs+1−1tests/run_verb_tests.py+8−2BRIDGE_VERSION+1−1@@ -1 +1 @@-1.13.11+1.13.12
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.12 (2026-10-29)++The standard's pair and length planners, the schematic checks and the design review and report from the shared engine 0.17.0 (standard 0.20.0). 326 verbs.++- `fusion_route_pair`: a differential pair planned as a pair, one centreline with each net offset at width + gap+ (or the gap for diffOhms from the stack-up): routeNet for each net, widthRule for fusion_add_rule, skewMm, and the+ problems that stop it. On BQ25792's D pair it finds the pads on swapped sides at the two ends.+- `fusion_length_tune`: a serpentine in a net's longest straight track to reach targetMm or match another net,+ answered as remove (fusion_remove_items) and the routeNet that replaces it. On BQ25792 it took SCL from 53.613 to+ exactly 55.6 mm, placed with no split and no airwire (and put back).+- `fusion_schematic_review`: every placed pin's electrical type against the netlist (power pins on no net, outputs+ driving each other, open inputs, single-pin nets, missing decoupling). BQ25792: no errors; SW1 pin 3 open and no+ decoupling cap on VBAT near U$1/U$3.+- `fusion_pin_pad_check`: each symbol's pins against its footprint's pads, and parts only on the sheets or only on+ the board. BQ25792: 103 parts match; SW1's mounting lugs (pads 4, 5) have no pin; the logos are board-only.+- Package-less symbols (EAGLE's supply symbols) are not parts to these checks.+- `fusion_review_board`: thirteen design-review checks over one read of the board, errors first, with what was not+ judged and why.+- `fusion_board_report`: one self-contained HTML page with every check the shared engine has, Fusion's own DRC and a+ picture of the board (BQ25792: 10 sections, 523 KB). Fusion stores its DRC result in the board: drc:false skips it.+- The engine verbs' replies no longer carry empty `moveArgs`, `remove` and `routeNet` fields (1.13.11 added them as+ null to every reply).+ ## 1.13.11 (2026-10-29) Six more of the standard's verbs, engine 0.14.1 (standard 0.17.1). 320 verbs.
bridge.json+14−2@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.13.11",+ "version": "1.13.12", "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.",@@ -58,6 +58,7 @@ "fusion_board_features", "fusion_board_info", "fusion_board_outline",+ "fusion_board_report", "fusion_board_seed", "fusion_board_shape", "fusion_board_source",@@ -168,6 +169,7 @@ "fusion_layer_stack", "fusion_layers", "fusion_layers_in_use",+ "fusion_length_tune", "fusion_library_manager", "fusion_library_parts", "fusion_library_views",@@ -214,6 +216,7 @@ "fusion_open_schematic", "fusion_part_parameters", "fusion_physical_properties",+ "fusion_pin_pad_check", "fusion_place_decoupling", "fusion_place_vias", "fusion_placement_check",@@ -252,8 +255,10 @@ "fusion_resolve_seat_dialog", "fusion_restart_for_addin", "fusion_return_path",+ "fusion_review_board", "fusion_ripup_nets", "fusion_route_net",+ "fusion_route_pair", "fusion_routing_drc", "fusion_routing_state", "fusion_rules_by_kind",@@ -293,6 +298,7 @@ "fusion_schematic", "fusion_schematic_data", "fusion_schematic_plan",+ "fusion_schematic_review", "fusion_schematic_run", "fusion_screenshot_fusion", "fusion_search_cloud_files",@@ -529,7 +535,13 @@ "auto_classes": 120, "silk_designators": 180, "find_space": 150,- "check_orientation": 120+ "check_orientation": 120,+ "route_pair": 120,+ "length_tune": 120,+ "schematic_review": 150,+ "pin_pad_check": 150,+ "review_board": 150,+ "board_report": 300 }, "prefixes": [ {
contracts/describe.json+145@@ -742,6 +742,28 @@ "summary": "Report the board edge the fab gets. Fusion's edge is its Dimension layer (20) and its Gerbers export it as the outline, so there is nothing to draw: this says how many outline items are there and whether they close. Change the shape with fusion_set_outline.", "timeoutSeconds": 60 },+ {+ "example": {},+ "input": {+ "drc": "bool, default true",+ "expectDocument": "required: the board's name",+ "outputDir": "default %TEMP%/adom-board-report",+ "title": "default the design's name",+ "vendor": "fab record, default jlcpcb"+ },+ "longRunning": false,+ "name": "fusion_board_report",+ "output": {+ "drcStoredInBoard": "bool",+ "engine": "str",+ "path": "the .html",+ "sections": "[...]",+ "sizeBytes": "int"+ },+ "statusVerb": null,+ "summary": "One shareable page for the board (the standard's board_report): every check the shared engine has, run on one bundle of the live board and its companions (stack-up, pours, rules, 3D bodies, a fab record, the schematic's parts and netlist), with Fusion's own DRC and a picture of the board, written as one self-contained HTML file on the Fusion machine. A section missing its input says which. Fusion stores its DRC result in the board (the document then reads as modified): drc:false skips it.",+ "timeoutSeconds": 300+ }, { "example": {}, "input": {@@ -3071,6 +3093,32 @@ "summary": "Which layers actually carry something: copper layers with copper (most first) and every layer with any primitive (board, footprints on their real side, signals; a via counts on each layer it passes).", "timeoutSeconds": 60 },+ {+ "example": {+ "matchTo": "D_N",+ "net": "D_P"+ },+ "input": {+ "expectDocument": "required: the board's name",+ "matchTo": "a net whose length to match instead",+ "maxAmplitudeMm": "number",+ "net": "the net (required)",+ "pitchMm": "number",+ "targetMm": "the length to reach"+ },+ "longRunning": false,+ "name": "fusion_length_tune",+ "output": {+ "addedMm": "number",+ "engine": "str",+ "remove": "fusion_remove_items arguments",+ "revision": "str",+ "routeNet": "fusion_route_net arguments"+ },+ "statusVerb": null,+ "summary": "Plan the lengthening of a routed net (the standard's length_tune), from the shared checks engine: a serpentine in its longest straight track with room beside it, sized to add exactly what targetMm (or matching matchTo) needs, clear of other copper by the board's rule. Answers remove (that one track, fusion_remove_items arguments) and the routeNet that replaces it (fusion_route_net arguments).",+ "timeoutSeconds": 120+ }, { "example": { "tab": "private"@@ -17937,6 +17985,24 @@ "summary": "Per-component physical properties (volume cm3, mass kg, density, area, center of mass) for the active design. Reads them directly so BOM/costing tools no longer have to run getPhysicalProperties inside a modeling script. Pass names to scope to specific components.", "timeoutSeconds": 120 },+ {+ "example": {},+ "input": {+ "expectDocument": "the design"+ },+ "longRunning": false,+ "name": "fusion_pin_pad_check",+ "output": {+ "engine": "str",+ "ok": "bool",+ "onlyOnBoard": "[ref]",+ "onlyOnSheets": "[ref]",+ "parts": "[{designator, pinsWithoutPad, padsWithoutPin}]"+ },+ "statusVerb": null,+ "summary": "Do the footprints fit their symbols (the standard's pin_pad_check)? Part by part, the symbol's pin numbers (fusion_schematic_data) against the placed footprint's pad names on the board: pins with no pad, pads with no pin, and parts only on the sheets or only on the board. The shared checks engine.",+ "timeoutSeconds": 150+ }, { "example": { "ic": "U1"@@ -18796,6 +18862,28 @@ "summary": "Signal tracks running over a gap in the neighbouring layer's reference copper, with a pour plan that closes each gap (apply as fusion_apply_pours): the shared checks engine on the live board, its stack-up and Fusion's pour fills.", "timeoutSeconds": 120 },+ {+ "example": {},+ "input": {+ "checks": "[check] (default all)",+ "edgeMm": "default 0.5",+ "expectDocument": "required: the board's name",+ "gridMm": "placement grid for the off-grid check"+ },+ "longRunning": false,+ "name": "fusion_review_board",+ "output": {+ "counts": "{error, warning, info}",+ "engine": "str",+ "findings": "[{check, severity, ...}]",+ "notImplemented": "[...]",+ "ran": "[check]",+ "skipped": "[...]"+ },+ "statusVerb": null,+ "summary": "Design-review checks over one read of the board (the standard's review_board), from the shared checks engine: thirteen checks, errors first; skipped and notImplemented name what was not judged. With the schematic's parts (fusion_schematic_data) for the checks that need them; the off-grid check runs only with gridMm.",+ "timeoutSeconds": 150+ }, { "example": {}, "input": {@@ -18848,6 +18936,46 @@ "summary": "Create explicit net copper in the live Fusion canvas, on any copper layer of the board (F.Cu, In1.Cu ... B.Cu, or EAGLE's numbers). Clearance preflight against pads by their real outline, tracks and arcs, vias, holes (footprint mounting holes too), footprint and board copper and keepouts (tRestrict/bRestrict), with board/revision guards and exact copper readback. Pours are repoured round the new copper; a route that cuts another net's pour in two is undone (splits_net) unless allowSplit. One or several paths per call; no general autorouting. Use routing_drc for native DRC.", "timeoutSeconds": 120 },+ {+ "example": {+ "from": {+ "neg": "IC1.7",+ "pos": "IC1.6"+ },+ "neg": "D_N",+ "pos": "D_P",+ "to": {+ "neg": "MP13.P$1",+ "pos": "MP14.P$1"+ }+ },+ "input": {+ "diffOhms": "target impedance instead of gap",+ "expectDocument": "required: the board's name",+ "fanMm": "escape length from the pads",+ "from": "{pos, neg} pads (Ref.Pad)",+ "gap": "mm",+ "layer": "copper layer",+ "neg": "negative net",+ "pos": "positive net",+ "to": "{pos, neg} pads",+ "via": "[[x, y]] waypoints",+ "width": "mm"+ },+ "longRunning": false,+ "name": "fusion_route_pair",+ "output": {+ "engine": "str",+ "problems": "[...]",+ "revision": "str",+ "routeNet": "[fusion_route_net arguments, one per net]",+ "skewMm": "number",+ "widthRule": "fusion_add_rule arguments"+ },+ "statusVerb": null,+ "summary": "Plan a differential pair routed as a pair (the standard's route_pair), from the shared checks engine: one centreline from the source pads' midpoint through waypoints to the destination pads' midpoint, each net offset to its side at width + gap (or the gap for diffOhms from the stack-up), with mitred corners. Answers routeNet [pos, neg] as fusion_route_net arguments, widthRule for fusion_add_rule and skewMm; problems lists crossings and other copper closer than the clearance, and while there are any success is false.",+ "timeoutSeconds": 120+ }, { "example": {}, "input": {@@ -19845,6 +19973,23 @@ "summary": "Plan a grouped schematic from a netlist: one labelled frame per block, the block's parts in a row, every connected pin given a short stub named for its net and a label. Returns the command groups for fusion_schematic_run (one call per group, so the sheet grows block by block on camera). Pins may be named by pad (U1.3) or pin (U1.VIN). Pure computation from the .lbr; nothing in Fusion changes.", "timeoutSeconds": 60 },+ {+ "example": {},+ "input": {+ "expectDocument": "the design"+ },+ "longRunning": false,+ "name": "fusion_schematic_review",+ "output": {+ "engine": "str",+ "errors": "[...]",+ "ok": "bool",+ "warnings": "[...]"+ },+ "statusVerb": null,+ "summary": "Review the schematic (the standard's schematic_review), from the shared checks engine: every placed pin with its electrical type (fusion_schematic_data) against the netlist (Fusion keeps board and schematic in step). Errors such as power pins on no net and outputs driving each other; warnings such as inputs left open and single-pin nets.",+ "timeoutSeconds": 150+ }, { "example": {}, "input": {
dashboard/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "fusion-dashboard"-version = "1.13.11"+version = "1.13.12" dependencies = [ "serde_json", ]
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.13.11"+version = "1.13.12" edition = "2021" [dependencies]
describe.py+24@@ -30,6 +30,14 @@ _T = [ ("fusion_plan_route", "Plan a path for one net between two pads on the live board without changing it (the standard's plan_route), from the shared checks engine (adom-eda-checks): a bounded orthogonal A* on any copper layers listed (default F.Cu and B.Cu), with through vias between them; pads by their real outline, the board's strictest copper clearance and copper-to-edge rule. Answered as routeNet, fusion_route_net's arguments (boardId and expectedRevision included), to pass on unchanged; found:false with a reason when no path fits. Pours are not obstacles: fusion_route_net undoes a route that cuts another net's pour in two. Fine-pitch pads need gridMm 0.1.", {"expectDocument":"required: the board's name","net":"required str","from":"the start pad as Ref.Pad","to":"the end pad","layers":"[copper layer] default [F.Cu, B.Cu]; list them all (e.g. [F.Cu, In1.Cu, In2.Cu, B.Cu]) so a via is checked on every layer it passes","widthMm":"default the board's width rule, else 0.25","viaSizeMm":"default the board's via rule","viaDrillMm":"default the board's via rule","gridMm":"0.05..5, default 0.5","allowVias":"bool default true","viaCost":"a via costs as much as this many mm, default 5","clearanceMm":"can only raise the board's","edgeClearanceMm":"can only raise the board's","maxNodes":"default 50000"}, {"success":"bool","mutated":"false","found":"bool","routeNet":"fusion_route_net's arguments","lengthMm":"number","vias":"int","viaAt":"[[x,y]]","reason":"str (found false)","planner":"{algorithm, gridMm, layers, clearanceMm, edgeClearanceMm, widthFrom, viaFrom}","revision":"str","engine":"str"},60,"fusion_routing_state",False,{"net":"SDA","from":"U1.12","to":"J3.4","layers":["F.Cu","In1.Cu","In2.Cu","B.Cu"]}),+ ('fusion_route_pair', "Plan a differential pair routed as a pair (the standard's route_pair), from the shared checks engine: one centreline from the source pads' midpoint through waypoints to the destination pads' midpoint, each net offset to its side at width + gap (or the gap for diffOhms from the stack-up), with mitred corners. Answers routeNet [pos, neg] as fusion_route_net arguments, widthRule for fusion_add_rule and skewMm; problems lists crossings and other copper closer than the clearance, and while there are any success is false.",+ {"expectDocument": "required: the board's name", "pos": "positive net", "neg": "negative net", "from": "{pos, neg} pads (Ref.Pad)", "to": "{pos, neg} pads", "via": "[[x, y]] waypoints", "layer": "copper layer", "width": "mm", "gap": "mm", "diffOhms": "target impedance instead of gap", "fanMm": "escape length from the pads"},+ {"routeNet": "[fusion_route_net arguments, one per net]", "widthRule": "fusion_add_rule arguments", "skewMm": "number", "problems": "[...]", "revision": "str", "engine": "str"},+ 120, None, False, {'pos': 'D_P', 'neg': 'D_N', 'from': {'pos': 'IC1.6', 'neg': 'IC1.7'}, 'to': {'pos': 'MP14.P$1', 'neg': 'MP13.P$1'}}),+ ('fusion_length_tune', "Plan the lengthening of a routed net (the standard's length_tune), from the shared checks engine: a serpentine in its longest straight track with room beside it, sized to add exactly what targetMm (or matching matchTo) needs, clear of other copper by the board's rule. Answers remove (that one track, fusion_remove_items arguments) and the routeNet that replaces it (fusion_route_net arguments).",+ {"expectDocument": "required: the board's name", "net": "the net (required)", "targetMm": "the length to reach", "matchTo": "a net whose length to match instead", "maxAmplitudeMm": "number", "pitchMm": "number"},+ {"remove": "fusion_remove_items arguments", "routeNet": "fusion_route_net arguments", "addedMm": "number", "revision": "str", "engine": "str"},+ 120, None, False, {'net': 'D_P', 'matchTo': 'D_N'}), ("fusion_route_net", "Create explicit net copper in the live Fusion canvas, on any copper layer of the board (F.Cu, In1.Cu ... B.Cu, or EAGLE's numbers). Clearance preflight against pads by their real outline, tracks and arcs, vias, holes (footprint mounting holes too), footprint and board copper and keepouts (tRestrict/bRestrict), with board/revision guards and exact copper readback. Pours are repoured round the new copper; a route that cuts another net's pour in two is undone (splits_net) unless allowSplit. One or several paths per call; no general autorouting. Use routing_drc for native DRC.", {"expectDocument":"required exact board name","boardId":"required from routing_state","expectedRevision":"required from routing_state","net":"required exact name","points":"waypoints [x,y] or {pad} or {x,y,layer}; a waypoint's layer is the layer after it (a via where it changes)","paths":"optional list of paths instead of points","width":"mm, default .25","layer":"the first copper layer, by name or number; default F.Cu","viaDiameter":"mm, default .6 (viaSize also works: fusion_plan_route's routeNet)","viaDrill":"mm, default .3","clearance":"mm, can only raise the board's rules","allowSplit":"bool: keep a route that splits another net","dryRun":"optional bool"}, {"success":"bool","mutated":"bool","verified":"bool","revision":"str","plan":"obj","remainingNets":"list","splitNets":"[net] (splits_net)"},120,"fusion_routing_state",False,{}),@@ -262,6 +270,14 @@ _T = [ {"expectDocument": "required: the board's name", "vendor": "jlcpcb|pcbway|oshpark ... (adom/pcb-design-rules)", "process": "optional record process", "fab": "a record instead", "limits": "{minTrack, minClearance, ...} mm"}, {"measured": "{...}", "outsideLimits": "[{limit, boardMm, limitMm, at, source}]", "fab": "the record", "engine": "str"}, 120, None, False, {'vendor': 'jlcpcb'}),+ ('fusion_review_board', "Design-review checks over one read of the board (the standard's review_board), from the shared checks engine: thirteen checks, errors first; skipped and notImplemented name what was not judged. With the schematic's parts (fusion_schematic_data) for the checks that need them; the off-grid check runs only with gridMm.",+ {"expectDocument": "required: the board's name", "checks": "[check] (default all)", "gridMm": "placement grid for the off-grid check", "edgeMm": "default 0.5"},+ {"findings": "[{check, severity, ...}]", "counts": "{error, warning, info}", "ran": "[check]", "skipped": "[...]", "notImplemented": "[...]", "engine": "str"},+ 150, None, False, {}),+ ('fusion_board_report', "One shareable page for the board (the standard's board_report): every check the shared engine has, run on one bundle of the live board and its companions (stack-up, pours, rules, 3D bodies, a fab record, the schematic's parts and netlist), with Fusion's own DRC and a picture of the board, written as one self-contained HTML file on the Fusion machine. A section missing its input says which. Fusion stores its DRC result in the board (the document then reads as modified): drc:false skips it.",+ {"expectDocument": "required: the board's name", "title": "default the design's name", "vendor": "fab record, default jlcpcb", "drc": "bool, default true", "outputDir": "default %TEMP%/adom-board-report"},+ {"path": "the .html", "sizeBytes": "int", "sections": "[...]", "drcStoredInBoard": "bool", "engine": "str"},+ 300, None, False, {}), ('fusion_jlcpcb_order', "A JLCPCB order in one call (the standard's jlcpcb_order): the board against JLCPCB's fab limits (adom/pcb-design-rules), the Gerbers and drill zipped by the bundled JLCPCB CAM job, the JLCPCB BOM and pick-and-place (CPL), the assembly-tier check and the order-form values (layers, size, outer copper, smallest track/space and hole), in one folder written fresh, with every issue in one list. Fusion has no Silkscreen Preparation: silk goes out as drawn.", {"expectDocument": "required: the board's name", "outputDir": "the order folder (default %TEMP%/JLCPCB order <board>; emptied first)"}, {"ready": "bool: fab fits, an assembly tier passes, no issues", "issues": "[{from, what, at?}]", "folder": "path", "gerberZip": "{path, files}", "bomFiles": "[path] incl. bom-jlcpcb.csv, cpl-jlcpcb.csv", "assemblyService": "economic | standard | null", "orderForm": "{layers, dimensionsMm, outerCopperOz, minTrackSpaceMm, minHoleMm}"},@@ -434,6 +450,14 @@ _T = [ {"expectDocument": "the design", "name": "an attribute, e.g. MPN"}, {"names": "[{name, parts}]", "values": "[{designator, value, sheet}] (with name)"}, 120, None, False, {'name': 'MPN'}),+ ('fusion_schematic_review', "Review the schematic (the standard's schematic_review), from the shared checks engine: every placed pin with its electrical type (fusion_schematic_data) against the netlist (Fusion keeps board and schematic in step). Errors such as power pins on no net and outputs driving each other; warnings such as inputs left open and single-pin nets.",+ {"expectDocument": "the design"},+ {"ok": "bool", "errors": "[...]", "warnings": "[...]", "engine": "str"},+ 150, None, False, {}),+ ('fusion_pin_pad_check', "Do the footprints fit their symbols (the standard's pin_pad_check)? Part by part, the symbol's pin numbers (fusion_schematic_data) against the placed footprint's pad names on the board: pins with no pad, pads with no pin, and parts only on the sheets or only on the board. The shared checks engine.",+ {"expectDocument": "the design"},+ {"ok": "bool", "parts": "[{designator, pinsWithoutPad, padsWithoutPin}]", "onlyOnSheets": "[ref]", "onlyOnBoard": "[ref]", "engine": "str"},+ 150, None, False, {}), ('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"},
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.11"+DASH_VERSION="1.13.12" 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.11",+ "version": "1.13.12", "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.11",+ "version": "1.13.12", "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.lock+6−6@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-eda-checks"-version = "0.14.1"+version = "0.17.0" dependencies = [ "roxmltree", "serde_json",@@ -19,7 +19,7 @@ dependencies = [ [[package]] name = "adom-fusion-bridge"-version = "1.13.11"+version = "1.13.12" dependencies = [ "adom-eda-checks", "base64",@@ -299,7 +299,7 @@ dependencies = [ [[package]] name = "fusion-core"-version = "1.13.11"+version = "1.13.12" dependencies = [ "serde", "serde_json",@@ -310,7 +310,7 @@ dependencies = [ [[package]] name = "fusion-lbr"-version = "1.13.11"+version = "1.13.12" dependencies = [ "regex", "roxmltree",@@ -322,14 +322,14 @@ dependencies = [ [[package]] name = "fusion-os-mac"-version = "1.13.11"+version = "1.13.12" dependencies = [ "fusion-core", ] [[package]] name = "fusion-os-win"-version = "1.13.11"+version = "1.13.12" dependencies = [ "fusion-core", "windows-sys 0.59.0",
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.11"+version = "1.13.12" edition = "2021" license = "MIT"
rust/bridge/src/checks.rs+88−5@@ -38,7 +38,7 @@ pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Val } } }- if matches!(cmd, "plan_route" | "plan_fanout") {+ if matches!(cmd, "plan_route" | "plan_fanout" | "route_pair" | "length_tune") { // route_net's preflight holds the route to the board's strictest copper clearance (the // engine takes the smallest rule) and its copper-to-edge rule: plan to the same, so the // plan is one route_net accepts. A caller's larger value still wins.@@ -57,6 +57,35 @@ pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Val } } if cmd == "silk_designators" { silk_boxes(&mut bundle["silk"]); }+ if cmd == "board_report" {+ // every companion this bridge has: the 3D bodies, a fab record (JLCPCB unless vendor names another),+ // Fusion's own DRC (it stores its result in the board: drc:false skips it) and a picture of the board+ let bodies = crate::bodies::bodies(args, pass);+ if bodies["success"].as_bool() == Some(true) { bundle["bodies"] = bodies; }+ let layers = inputs["stack"]["copperLayers"].as_u64().unwrap_or(2);+ if let Ok(r) = fab_record(args["vendor"].as_str().unwrap_or("jlcpcb"), layers, args["process"].as_str()) { bundle["fab"] = r; }+ if args["drc"].as_bool() != Some(false) {+ let d = crate::routing::call("run_drc", args, pass);+ if d["success"].as_bool() != Some(false) { bundle["drc"] = d; }+ }+ let png = std::env::temp_dir().join("adom-board-report").join("board.png").to_string_lossy().replace('\\', "/");+ let _ = std::fs::create_dir_all(std::path::Path::new(&png).parent().unwrap());+ let _ = pass("export_board_image", json!({"outputPath": png, "dpi": 150, "preset": "all"}));+ if let Ok(bytes) = std::fs::read(&png) { use base64::Engine; bundle["picture"] = json!(base64::engine::general_purpose::STANDARD.encode(bytes)); }+ if bundle["args"]["title"].is_null() { bundle["args"]["title"] = inputs["documentName"].clone(); }+ }+ if matches!(cmd, "pin_pad_check" | "schematic_review" | "review_board" | "board_report") {+ // the sheets' parts with their pins (number, name, electrical type) from Fusion's schematic,+ // and the netlist (Designator.Pad); Fusion keeps board and schematic in step+ let sd = crate::sch::call("schematic_data", args, pass);+ if sd["success"].as_bool() == Some(false) { return sd; }+ bundle["sheetParts"] = json!(sheet_parts(&sd));+ if matches!(cmd, "schematic_review" | "board_report") {+ let nl = crate::routing::call("netlist", args, pass);+ if nl["success"].as_bool() == Some(false) { return nl; }+ bundle["netlist"] = json!({"nets": nl["nets"]});+ }+ } if matches!(cmd, "height_check" | "placement_check" | "check_clearance" | "place_decoupling" | "find_space") { let bodies = crate::bodies::bodies(args, pass); if bodies["success"].as_bool() == Some(true) { bundle["bodies"] = bodies; }@@ -115,12 +144,35 @@ pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Val r["_hint"] = json!("A plan: pass moveArgs to fusion_move_parts unchanged (it carries boardId and expectedRevision); validation is placement_check on the planned spots. Then route the caps short to the power pins."); } // the follow-up verb's guards, so its arguments pass on unchanged- for key in ["routeNet", "moveArgs"] {- if !r[key].is_object() { continue; }- for (k, v) in [("boardId", inputs["boardId"].clone()), ("expectedRevision", inputs["revision"].clone()), ("expectDocument", args["expectDocument"].clone())] {- if !v.is_null() { r[key][k] = v; }+ // (route_pair answers routeNet as a list, one per net; length_tune answers remove beside it)+ for key in ["routeNet", "moveArgs", "remove"] {+ let guards = [("boardId", inputs["boardId"].clone()), ("expectedRevision", inputs["revision"].clone()), ("expectDocument", args["expectDocument"].clone())];+ // look without writing: indexing a missing key mutably would add it as null+ if r.get(key).map(|v| v.is_object()).unwrap_or(false) {+ for (k, v) in guards.iter() { if !v.is_null() { r[key][*k] = v.clone(); } }+ } else if let Some(list) = r.get_mut(key).and_then(|v| v.as_array_mut()) {+ for item in list.iter_mut() { for (k, v) in guards.iter() { if *k != "expectedRevision" && !v.is_null() { item[*k] = v.clone(); } } } } }+ if cmd == "board_report" && r["text"].is_string() {+ // the page is written beside the board's other outputs; the reply keeps the path, not the page+ let text = r.as_object_mut().and_then(|o| o.remove("text")).and_then(|t| t.as_str().map(str::to_string)).unwrap_or_default();+ let dir = args["outputDir"].as_str().map(|s| s.replace('\\', "/")).unwrap_or_else(|| std::env::temp_dir().join("adom-board-report").to_string_lossy().replace('\\', "/"));+ let _ = std::fs::create_dir_all(&dir);+ let name: String = inputs["documentName"].as_str().unwrap_or("board").chars().map(|c| if c.is_ascii_alphanumeric() || "-_.".contains(c) { c } else { '_' }).collect();+ let path = format!("{dir}/{name}-report.html");+ if let Err(e) = std::fs::write(&path, text.as_bytes()) { return json!({"success": false, "errorCode": "write_failed", "error": format!("{path}: {e}")}); }+ r["path"] = json!(path); r["sizeBytes"] = json!(text.len());+ r["drcStoredInBoard"] = json!(bundle.get("drc").is_some());+ }+ if matches!(cmd, "route_pair" | "length_tune") {+ r["mutated"] = json!(false);+ r["_hint"] = json!(if cmd == "route_pair" {+ "A plan: send each routeNet entry to fusion_route_net in turn (expectedRevision: revision for the first, the previous reply's after), then widthRule through fusion_add_rule if you want the pair's width held. problems lists crossings and copper too close: while there are any it is not a route to place."+ } else {+ "A plan: fusion_remove_items with remove (the one track), then fusion_route_net with routeNet (expectedRevision: the revision fusion_remove_items answered). If the route is refused, put the removed track back with fusion_route_net."+ });+ } if cmd == "plan_route" && r["routeNet"].is_object() { r["mutated"] = json!(false); r["_hint"] = json!("Pass routeNet to fusion_route_net unchanged (it carries boardId and expectedRevision), then fusion_repour: pours are not obstacles to the plan, and Fusion repours them round the new copper. fusion_run_drc is still the proof. Fine-pitch pads need gridMm 0.1.");@@ -186,3 +238,34 @@ mod silk_tests { assert_eq!(s["components"][2]["name"]["box"], json!([8.4, 5.0, 10.0, 6.0])); } }++/// fusion_schematic_data's components as the standard's sheetParts: {designator, pins: [{number,+/// name, electrical}]}, a pin that lands on several pads listed once per pad.+fn sheet_parts(sd: &Value) -> Vec<Value> {+ // a symbol with no package (EAGLE's supply symbols: GND, VCC) is no part: it has no pads, and it+ // joins its net by the pin's name+ sd["components"].as_array().cloned().unwrap_or_default().iter()+ .filter(|c| c["pins"].as_array().map(|ps| ps.iter().any(|p| !p["number"].as_str().unwrap_or("").trim().is_empty())).unwrap_or(false))+ .map(|c| {+ let pins: Vec<Value> = c["pins"].as_array().cloned().unwrap_or_default().iter().flat_map(|p| {+ let nums: Vec<String> = p["number"].as_str().unwrap_or("").split_whitespace().map(str::to_string).collect();+ let nums = if nums.is_empty() { vec![String::new()] } else { nums };+ nums.into_iter().map(move |n| json!({"number": n, "name": p["name"].clone(), "electrical": p["electrical"].clone(), "gate": p["gate"].clone()}))+ }).collect();+ json!({"designator": c["designator"], "pins": pins})+ }).collect()+}++#[cfg(test)]+mod sheet_tests {+ use super::*;+ #[test]+ fn a_pin_on_two_pads_is_listed_per_pad() {+ let sd = json!({"components": [{"designator": "U1", "pins": [{"number": "3 4", "name": "GND", "electrical": "pwr", "gate": "G$1"}, {"number": "1", "name": "IN", "electrical": 1}]},+ {"designator": "SUPPLY1", "pins": [{"number": "", "name": "GND", "electrical": "sup"}]}]});+ let p = sheet_parts(&sd);+ assert_eq!(p.len(), 1, "a supply symbol is no part");+ assert_eq!(p[0]["pins"].as_array().unwrap().len(), 3);+ assert_eq!(p[0]["pins"][1]["number"], "4");+ }+}
rust/vendor/adom-eda-checks/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.14.1"+version = "0.17.0" edition = "2021" description = "The EDA Bridge Standard's checks and board-file writers, computed from the board model JSON: no bridge calls, no tool." license = "MIT"
rust/vendor/adom-eda-checks/README.md+11−1@@ -13,6 +13,7 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. |---|---|---| | `assembly_check` | board, stack? | the board against JLCPCB's two PCBA tiers as published (with the page and its read date): size (or `panelMm`), thickness and layers from the stack, one or two sides, the smallest two-pad part and the finest IC pitch, fiducials, and parts without an LCSC number (`lcscMissing` from the bridge's BOM). Pads sharing one centre are one land, and a component with no pads is no part. `service` is the cheaper tier every rule allows. Matches the Altium bridge's answers on its test boards. From its assembly.rs | | `auto_classes` | board | net classes from the names the nets already have (GND, Power, Clock, HighSpeed) and differential pairs from their suffixes (`_P`/`_N`, `DP`/`DN`, `+`/`-`), with `applyArgs`: the net_classes and diff_pairs calls that write them (Power with a `powerWidthMm` width rule). From the Altium bridge's autoclass.rs |+| `board_report` | board, and every companion it can use | every check the engine has, run on this bundle, as one self-contained HTML page (`text`), dark by default: fab limits (with `fab`), JLCPCB assembly, schematic review and symbols against footprints (with `sheetParts` and `netlist`), impedance, return paths and copper balance (with `stack`), 3D heights (with `bodies`), the design review, and the tool's own DRC (`drc`) and a base64 PNG of the board (`picture`) when the bridge passes them. A section whose input is missing says which to pass. From the Altium bridge's report.rs | | `check_clearance` | board, bodies? | pairs of parts closer than `thresholdMm`, closest first: `mode: courtyard` (default 0.25) measures part boxes (pads, and 3D bodies when given) on the same side; `mode: pads` (default 0.2) measures pad copper of different parts that shares a layer, by its real outline, skipping same-net pairs unless `includeSameNet`; `refs` keeps pairs involving those parts. A part under a raised one (pads apart, bodies apart in 3D) is listed in `stacked`, not `pairs`, as placement_check lists it. From the KiCad bridge's place_tools | | `check_orientation` | board | advisory: each two-pad part's quarter turns scored by airline length (MST of a routable net with the pad there, distance to the nearest pad of a plane-like net such as GND); turns saving `minImprovementMm` (0.6) or more are suggested, nothing moves. From the Altium bridge's design.rs | | `copper_balance` | board, stack, pours | copper per layer and how evenly mirrored layers match (bow and twist) |@@ -23,11 +24,16 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. | `impedance` | stack | the track width (and pair gap) for a target impedance on each copper layer; `apply` answers `add_rule` arguments | | `current_capacity` | board, stack? | per net and layer, the narrowest copper against the width the current needs (IPC-2221), and the vias a layer change needs | | `height_check` | bodies, board? | each part's height on its side, parts over `maxTopMm`/`maxBottomMm` or under a lid region (`regions`), body collisions and near misses (`minGapMm`); `noBody` and `complete: false` when a part has no 3D body |+| `pin_pad_check` | board, sheetParts | each symbol's pins against its footprint's pads: pins with no pad (a net that can never reach copper), pads with no pin (mounting and exposed pads excepted), parts only on the sheets or only on the board. From the Altium bridge's schreview.rs | | `place_decoupling` | board, bodies? | positions for an IC's decoupling caps (`ic`, `caps?`, `gapMm` 0.3): each just outside the IC's box on the side of the power pin it serves, turned so its power pad faces the pin and level with it, sliding along when the spot is taken (by a part on the same side, or a through-hole pad from the other). Without `caps` it takes caps across ground and a supply of the IC, leaving out (`leftOut`) a signal net with one cap (a crystal's load) and a cap nearer another IC's pin on that supply. Each cap's pads keep clear of the stubs and vias plan_fanout would give the IC's other pins (`leaveFanout`, default true), so the fanout still fits after. A cap it cannot place stays where it is and keeps its spot. `icPowerNets` lists the IC's supplies. `moveArgs` is move_parts' arguments; `validation` is placement_check on the moved board. From the KiCad bridge's place_tools |+| `length_tune` | board, rules? | a serpentine on the net's longest straight track with room beside it, its bumps sized to add exactly what `targetMm` (or `matchTo`, another net's length) asks, every bump `clearance` (the board's strictest rule) from other nets' pads, routed tracks and vias and inside the board; answered as the track to `remove` and the `routeNet` that replaces it. From the Altium bridge's lengthtune.rs | | `net_lengths` | board | the routed length of each net (tracks and arcs, by layer, with its vias), the skew of each differential pair (`pairs`, else found by net name: `_P`/`_N`, `+`/`-`) and the spread of each matched-length group against its tolerance (`groups`). From the Altium bridge's lengths.rs | | `placement_check` | board, bodies? | courtyard overlaps (pads and bodies, grown by `marginMm`), parts off or over the edge, pads within `edgeMm` of the edge, parts on mounting holes; `stacked` lists plan-view overlaps where one part sits wholly above the other (a part under a raised molecule: pads apart, no bodies meeting in 3D); `bodiesUsed: false` when no bodies were given | | `plan_fanout` | board, stack?, rules? | the fanout of a BGA or QFN/QFP (`ref`): pitch and package from its pads; a BGA's outer ring escapes on the surface and each inner ball takes a dogbone via, or a via in the pad when the dogbone does not fit (`style` auto); a perimeter pad takes an outward stub, with a via at its end for ground or `viaPerimeter`, and an exposed pad a via grid. `calls` are route_net arguments, one per net; `conflicts` lists each via and stub closer than the clearance to another net's pad, track, arc or via anywhere on the board. A pin its net's copper already reaches is skipped (`includeRouted` plans it anyway), the exposed-pad grid keeps clear of holes already there, and `crossesPours` names other nets' pours the plan runs into (repour before DRC). From the KiCad bridge's place_tools | | `plan_route` | board, rules? | a path between two pads of a net (`from`, `to` as Ref.Pad) on any listed copper `layers`, with through vias between them: a bounded orthogonal A* on `gridMm`, width and via from the args, else the board's width and via rules (`planner.widthFrom`, `viaFrom`), keeping `widthMm/2` + the board's strictest board-wide copper clearance rule from other nets' copper (pads by their real outline), inside the outline, no via in a pad; a through via is checked on every copper layer of the board (`planner.viasCheckedOn`), not only the planned ones. `routeNet` is route_net's arguments, unchanged. Pours are not obstacles (repour after), but `crossesPours` names each other net's pour the route runs through, since that can split it; `avoidPours: true` keeps the route out of them (or a list of nets: out of theirs only), and `pourCost` makes a mm inside one cost 1 + pourCost mm, a preference where a hard block finds no path; a pour that holds the route's own end pad is exempt and named (`planner.avoidExempt`); with no path, `ends` names the avoided pours at each end pad and whether they are exempt, and `withoutAvoid` says whether a path exists through them. The grid path is smoothed to straight and 45-degree segments where they keep the same clearance (`smooth: false` keeps it) |+| `review_board` | board, sheetParts?, rules? | small design-review checks, errors first: placeholder values, single-pin nets, duplicate designators, parts with pads off the outline polygon or near the edge (`edgeMm`), parts with no footprint name, off-grid parts (with `gridMm`); from the sheet parts, missing Mouser/DigiKey/LCSC numbers and datasheets and placeholder comments; from the rules, disabled ones, missing clearance/width/hole-size/short-circuit rules and same-priority ties. `skipped` and `notImplemented` say what was not judged. From the Altium bridge's review.rs |+| `route_pair` | board, stack?, rules? | a differential pair planned as one centreline (pad midpoints through `via` waypoints) with each net offset half a pitch to its own side, mitred corners and a `fanMm` fan-out; width and gap given, or from `diffOhms` and the stack. `problems` names a crossing or other copper (pads by outline, routed tracks, vias) closer than the clearance; `routeNet` holds both nets' route_net arguments and `widthRule` the pair's add_rule. From the Altium bridge's pairroute.rs |+| `schematic_review` | sheetParts, netlist | every placed pin, with its electrical type, against the compiled netlist: power pins on no net and outputs driving each other (errors); unconnected pins, one-pin nets, inputs nothing drives and IC supplies with no capacitor to ground (warnings; a regulator's switch node on an inductor is no missing capacitor). From the Altium bridge's schreview.rs | | `silk_designators` | board, silk | a place for each reference designator: one size (`size`, stepping down to `minSize`), beside its own part, clear of every pad by `padClearance`, of other parts' bodies and of other labels, inside the board. `applyArgs` is apply_silkscreen's arguments; parts with no clear spot are listed, not forced. From the Altium bridge's silkplace.rs | | `thermal_vias` | board, rules? | a grid of vias in a part's exposed pad (`ref`, `pad?`), clear of vias already there; `apply` is `place_vias` arguments | | `via_fence` | board, rules? | vias of `viaNet` both sides of the named nets' tracks and/or round the board edge (`edge`), at `pitchMm` or lambda/20 at `freqGHz`, clear of other copper; `apply` is `place_vias` arguments |@@ -71,6 +77,10 @@ Every check takes one JSON object: | `silk` | `<prefix>_silk_state` | | `fab` | one record from adom/pcb-design-rules `rules/records/<vendor>.json`, with `vendor` | | `prices` | a price record read from the fab's own page: `{vendor, url, read, currency, pcb: [{layers, setup, perM2, promo?: {maxMm: [w, h], prices: [[qty, price]]}}], thickness?: {standardMm, factor}, assembly?: {setupPerSide, stencilPerSide, perSmdJoint, perThtJoint, perExtendedPart}, excludes?}`. The pcb row used is the one for the fewest copper layers that covers the board |+| `sheetParts` | every sheet's parts as `<prefix>_read_sheet_file` gives them: `[{designator, comment?, params?: [{name, value}], pins: [{number, name, electrical}]}]` (or a reply with `components`). `electrical` is a type name (input, output, io, passive, power, supply, open_collector, hiz, not_connected) or Altium's code; a part whose pins have no numbers (a package-less supply symbol) is no part |+| `netlist` | the compiled netlist: `{nets: {net: ["U1.8", ...]}}` |+| `drc` | board_report only: the tool's own DRC reply (`run_drc`: errors, unconnected, violations) |+| `picture` | board_report only: a base64 PNG of the board, embedded in the page | | `args` | the check's own arguments, as the standard verb documents them | Each part may be the bare model or the bridge's whole reply: the model's keys at the top level, or under `data`.@@ -102,7 +112,7 @@ adom-eda-checks import_svg --svg logo.svg --args '{"at": [10, 10], "widthMm": 20 Replies keep the standard envelope (`success`, `errorCode`, `error`, `_hint`) and name verbs without a prefix (`apply_pours`, `add_rule`, `place_vias`): a bridge adds its own. Every reply also carries `engine`-(`"adom-eda-checks 0.14.1"`), so a bridge reports the engine it runs without writing the version by hand.+(`"adom-eda-checks 0.17.0"`), so a bridge reports the engine it runs without writing the version by hand. ## Tests
rust/vendor/adom-eda-checks/VENDOR.md+3−3@@ -3,9 +3,9 @@ The EDA Bridge Standard's shared checks engine, copied unchanged from adom/eda-bridge-standard `engine/` (the wiki has no git protocol, so Cargo cannot fetch it). -- Standard version: 0.17.1-- Standard commit: 337fb00c55e052ad7c3918bf4f3a097f5b772158-- Crate version: 0.14.1+- Standard version: 0.20.0+- Standard commit: 38b5e58fbad4278de70f9e4cfe258056853810a3+- Crate version: 0.17.0 - Copied: 2026-10-28, by fusion-lane To update: clone the standard fresh (`adom-wiki repo clone adom/eda-bridge-standard`), copy `engine/src`,
rust/vendor/adom-eda-checks/src/lengthtune.rsadded+138@@ -0,0 +1,138 @@+//! Length tuning: a serpentine in the net's longest straight track that has room beside it, its bumps sized to add+//! exactly the length a target (or another net's length) asks for, every bump clear of other copper and inside the+//! board, or a refusal that says why. From the Altium bridge's lengthtune.rs; the plan is answered as the track to+//! remove and the route_net arguments that replace it, for the bridge to apply in one undo step.+use crate::route::{pad_shape, Shape};+use serde_json::{json, Value};++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }+type P = (f64, f64);++/// The serpentine's points replacing a track a->b: `n` rectangular bumps of height `amp` to the side `side` (+1 left,+/// -1 right), each `pitch` long along the track, centred on it. Pure.+pub fn meander(a: P, b: P, n: usize, amp: f64, pitch: f64, side: f64) -> Vec<P> {+ let len = ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt();+ let (ux, uy) = ((b.0 - a.0) / len, (b.1 - a.1) / len);+ let (nx, ny) = (-uy * side, ux * side);+ let at = |s: f64, h: f64| (a.0 + ux * s + nx * h, a.1 + uy * s + ny * h);+ let start = (len - n as f64 * pitch) / 2.0;+ let mut pts = vec![a];+ for k in 0..n {+ let s = start + k as f64 * pitch;+ pts.push(at(s, 0.0));+ pts.push(at(s, amp));+ pts.push(at(s + pitch / 2.0, amp));+ pts.push(at(s + pitch / 2.0, 0.0));+ }+ pts.push(b);+ pts.dedup_by(|p, q| (p.0 - q.0).abs() < 1e-9 && (p.1 - q.1).abs() < 1e-9);+ pts+}++/// How many bumps and how tall, to add `extra` mm along a track `len` long: as few bumps as fit at `max_amp`, each+/// `pitch` long, leaving `margin` at both ends. None when it cannot fit. Pure.+pub fn sizing(extra: f64, len: f64, pitch: f64, max_amp: f64, margin: f64) -> Option<(usize, f64)> {+ if extra <= 0.0 { return None; }+ let room = ((len - 2.0 * margin) / pitch).floor() as usize;+ if room == 0 { return None; }+ let n = ((extra / (2.0 * max_amp)).ceil() as usize).max(1);+ if n > room { return None; }+ Some((n, extra / (2.0 * n as f64)))+}++/// length_tune: input {board, rules?, args: {net, targetMm? | matchTo?, maxAmplitudeMm?, pitchMm?, clearance?}}.+pub fn length_tune(input: &Value) -> Value {+ let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+ let args = &input["args"];+ let Some(net) = args["net"].as_str().map(String::from) else { return crate::input::refuse("bad_args", "net is the net to lengthen") };+ let lens = crate::lengths::lengths(&st);+ let len_of = |n: &str| lens.iter().find(|(k, _)| k.eq_ignore_ascii_case(n)).map(|(_, v)| f(&v["lengthMm"]));+ let Some(now) = len_of(&net) else { return crate::input::refuse("not_routed", &format!("{net} has no routed track")) };+ let target = match (args["targetMm"].as_f64(), args["matchTo"].as_str()) {+ (Some(t), _) => t,+ (None, Some(m)) => match len_of(m) { Some(l) => l, None => return crate::input::refuse("not_routed", &format!("{m} has no routed track to match")) },+ _ => return crate::input::refuse("bad_args", "targetMm (mm) or matchTo (a net) says how long it should be"),+ };+ let extra = target - now;+ if extra <= 0.01 { return json!({"success": true, "changed": false, "net": net, "lengthMm": r3(now), "targetMm": r3(target), "_hint": "Already at or past the target: tuning only lengthens. Shorten the other net, or route this one more directly."}); }+ let num = |k: &str, d: f64| args[k].as_f64().unwrap_or(d);+ let rule = crate::fab::routing_clearance(&crate::input::rules(input));+ let clearance = num("clearance", 0.0).max(rule.unwrap_or(0.2));+ let same = |v: &Value| v["net"].as_str().map(|n| n.eq_ignore_ascii_case(&net)).unwrap_or(false);+ // other nets' copper by layer ("*" every layer): routed tracks (a hatched pour's net-less pieces re-pour round the+ // bumps), vias, and pads by their real outline+ let mut other: Vec<(String, Shape)> = Vec::new();+ for t in st["tracks"].as_array().into_iter().flatten().filter(|t| !same(t) && t["net"].as_str().map(|n| !n.is_empty()).unwrap_or(false)) {+ other.push((t["layer"].as_str().unwrap_or("").into(), Shape::Cap((f(&t["start"]["x"]), f(&t["start"]["y"])), (f(&t["end"]["x"]), f(&t["end"]["y"])), f(&t["width"]) / 2.0)));+ }+ for v in st["vias"].as_array().into_iter().flatten().filter(|v| !same(v)) {+ let c = (f(&v["position"]["x"]), f(&v["position"]["y"]));+ other.push(("*".into(), Shape::Cap(c, c, f(&v["size"]) / 2.0)));+ }+ for p in st["pads"].as_array().into_iter().flatten().filter(|p| !same(p)) {+ let l = if p["layer"] == "*.Cu" || f(&p["drill"]) > 0.0 { "*".to_string() } else { p["layer"].as_str().unwrap_or("").to_string() };+ other.push((l, pad_shape(p)));+ }+ let ring = crate::coppermap::outline_ring(&st);+ // the net's tracks, longest first; for each, a side where the serpentine fits+ let mut tracks: Vec<Value> = st["tracks"].as_array().cloned().unwrap_or_default().into_iter().filter(same).collect();+ let tlen = |t: &Value| ((f(&t["end"]["x"]) - f(&t["start"]["x"])).powi(2) + (f(&t["end"]["y"]) - f(&t["start"]["y"])).powi(2)).sqrt();+ tracks.sort_by(|a, b| tlen(b).partial_cmp(&tlen(a)).unwrap());+ let mut why = Vec::new();+ for t in &tracks {+ let (a, b) = ((f(&t["start"]["x"]), f(&t["start"]["y"])), (f(&t["end"]["x"]), f(&t["end"]["y"])));+ let w = f(&t["width"]);+ let layer = t["layer"].as_str().unwrap_or("F.Cu").to_string();+ let pitch = num("pitchMm", (4.0 * w).max(4.0 * clearance + 2.0 * w));+ let Some((n, amp)) = sizing(extra, tlen(t), pitch, num("maxAmplitudeMm", 3.0), 2.0 * w) else { why.push(json!({"track": [[r3(a.0), r3(a.1)], [r3(b.0), r3(b.1)]], "why": "too short for the bumps this needs at this amplitude"})); continue };+ for side in [1.0, -1.0] {+ let pts = meander(a, b, n, amp, pitch, side);+ let clear = pts.windows(2).all(|s| other.iter().filter(|o| o.0 == layer || o.0 == "*").all(|o| o.1.dist(s[0], s[1]) >= w / 2.0 + clearance - 1e-9))+ && (ring.len() < 3 || pts.iter().all(|p| crate::route::inside(*p, &ring)));+ if !clear { continue; }+ let added: f64 = pts.windows(2).map(|s| ((s[1].0 - s[0].0).powi(2) + (s[1].1 - s[0].1).powi(2)).sqrt()).sum::<f64>() - tlen(t);+ let way: Vec<Value> = pts.iter().map(|p| json!({"x": r3(p.0), "y": r3(p.1), "layer": layer})).collect();+ return json!({"success": true, "changed": false, "net": net, "lengthMm": r3(now), "targetMm": r3(target), "expectedMm": r3(now + added), "clearanceMm": clearance,+ "plan": {"layer": layer, "replaces": [[r3(a.0), r3(a.1)], [r3(b.0), r3(b.1)]], "bumps": n, "amplitudeMm": r3(amp), "pitchMm": r3(pitch), "side": if side > 0.0 { "left" } else { "right" }, "addsMm": r3(added)},+ "remove": {"tracks": [{"net": net, "layer": layer, "start": {"x": a.0, "y": a.1}, "end": {"x": b.0, "y": b.1}}]},+ "routeNet": {"net": net, "layer": layer, "width": w, "paths": [way]}, "revision": st["revision"],+ "_hint": "A plan: remove the one track in `remove` and route `routeNet` in its place, in one undo step, with expectedRevision: revision. net_lengths then shows the new length; every bump keeps clearanceMm from other nets' pads, routed tracks and vias."});+ }+ why.push(json!({"track": [[r3(a.0), r3(a.1)], [r3(b.0), r3(b.1)]], "why": "no clear room on either side for the bumps"}));+ }+ json!({"success": false, "errorCode": "no_room", "error": format!("no track of {net} has room for {:.2} mm more", extra), "lengthMm": r3(now), "targetMm": r3(target), "tried": why,+ "_hint": "Lower maxAmplitudeMm for more, shorter bumps, raise it for fewer, or reroute the net with a longer straight run in open space."})+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn a_meander_adds_exactly_twice_its_height_per_bump() {+ let (n, amp) = sizing(6.0, 30.0, 1.2, 1.0, 0.4).unwrap();+ assert_eq!(n, 3);+ assert!((amp - 1.0).abs() < 1e-9);+ let pts = meander((0.0, 0.0), (30.0, 0.0), n, amp, 1.2, 1.0);+ let len: f64 = pts.windows(2).map(|s| ((s[1].0 - s[0].0).powi(2) + (s[1].1 - s[0].1).powi(2)).sqrt()).sum();+ assert!((len - 36.0).abs() < 1e-9, "{len}");+ assert!(sizing(100.0, 5.0, 1.2, 1.0, 0.4).is_none());+ assert!(sizing(-1.0, 30.0, 1.2, 1.0, 0.4).is_none());+ }++ #[test]+ fn the_verb_plans_round_other_copper_to_match_a_net() {+ let t = |net: &str, y: f64, x1: f64| json!({"net": net, "layer": "F.Cu", "width": 0.2, "start": {"x": 2.0, "y": y}, "end": {"x": x1, "y": y}});+ // A is 30 mm, B 24 mm; another net runs just above A, so the bumps go below it+ let b = json!({"outline": [[0, -20], [40, -20], [40, 20], [0, 20]], "revision": "r3",+ "tracks": [t("A", 0.0, 32.0), t("B", 10.0, 26.0), json!({"net": "X", "layer": "F.Cu", "width": 0.2, "start": {"x": 2.0, "y": 0.6}, "end": {"x": 32.0, "y": 0.6}})]});+ let r = length_tune(&json!({"board": b, "args": {"net": "B", "matchTo": "A"}}));+ assert_eq!(r["success"], json!(true), "{r}");+ assert!((f(&r["expectedMm"]) - 30.0).abs() < 1e-6, "{r}");+ let a = length_tune(&json!({"board": b, "args": {"net": "A", "targetMm": 34.0}}));+ assert_eq!(a["plan"]["side"], "right", "{a}");+ assert_eq!(length_tune(&json!({"board": b, "args": {"net": "A", "targetMm": 20.0}}))["changed"], json!(false));+ assert_eq!(length_tune(&json!({"board": b, "args": {"net": "Q", "targetMm": 20.0}}))["errorCode"], "not_routed");+ }+}
rust/vendor/adom-eda-checks/src/lib.rs+12−1@@ -19,9 +19,14 @@ pub mod geom; pub mod impedance; pub mod input; pub mod lengths;+pub mod lengthtune;+pub mod pairroute;+pub mod report;+pub mod review; pub mod placement; pub mod placetools; pub mod route;+pub mod schreview; pub mod silkplace; pub mod space; pub mod writers;@@ -31,7 +36,7 @@ pub mod writers; pub const VERSION: &str = env!("CARGO_PKG_VERSION"); /// The checks this build answers, by standard verb name.-pub const CHECKS: &[&str] = &["assembly_check", "auto_classes", "check_clearance", "check_orientation", "copper_balance", "current_capacity", "estimate_cost", "fab_check", "find_space", "height_check", "impedance", "net_lengths", "place_decoupling", "placement_check", "plan_fanout", "plan_route", "return_path", "silk_designators", "thermal_vias", "via_fence"];+pub const CHECKS: &[&str] = &["assembly_check", "auto_classes", "board_report", "check_clearance", "check_orientation", "copper_balance", "current_capacity", "estimate_cost", "fab_check", "find_space", "height_check", "impedance", "length_tune", "net_lengths", "pin_pad_check", "place_decoupling", "placement_check", "plan_fanout", "plan_route", "return_path", "review_board", "route_pair", "schematic_review", "silk_designators", "thermal_vias", "via_fence"]; /// Run one check or writer by its standard name. The reply carries `engine`. pub fn run(check: &str, input: &Value) -> Value {@@ -45,6 +50,7 @@ fn answer(check: &str, input: &Value) -> Value { match check { "assembly_check" => assembly::assembly_check(input), "auto_classes" => autoclass::auto_classes(input),+ "board_report" => report::board_report(input), "check_clearance" => placetools::check_clearance(input), "check_orientation" => space::check_orientation(input), "copper_balance" => coppermap::copper_balance(input),@@ -54,12 +60,17 @@ fn answer(check: &str, input: &Value) -> Value { "fab_check" => fab::fab_check(input), "height_check" => bodies::height_check(input), "impedance" => impedance::impedance(input),+ "length_tune" => lengthtune::length_tune(input), "net_lengths" => lengths::net_lengths(input),+ "pin_pad_check" => schreview::pin_pad_check(input), "place_decoupling" => placetools::place_decoupling(input), "plan_fanout" => placetools::plan_fanout(input), "placement_check" => placement::placement_check(input), "plan_route" => route::plan_route(input), "return_path" => coppermap::return_path(input),+ "review_board" => review::review_board(input),+ "route_pair" => pairroute::route_pair(input),+ "schematic_review" => schreview::schematic_review(input), "silk_designators" => silkplace::silk_designators(input), "thermal_vias" => features::thermal_vias(input), "via_fence" => features::via_fence(input),
rust/vendor/adom-eda-checks/src/main.rs+1−1@@ -23,7 +23,7 @@ fn bundle(rest: &[String]) -> Result<Value, String> { while let Some(flag) = it.next() { let val = it.next().ok_or_else(|| format!("{flag} needs a value"))?; match flag.as_str() {- "--board" | "--stack" | "--pours" | "--rules" | "--silk" | "--fab" | "--bodies" | "--prices" | "--sheet-parts" | "--netlist" => {+ "--board" | "--stack" | "--pours" | "--rules" | "--silk" | "--fab" | "--bodies" | "--prices" | "--sheet-parts" | "--netlist" | "--drc" => { let key = match flag.as_str() { "--sheet-parts" => "sheetParts", f => &f[2..] }; b[key] = read(val)?; }
rust/vendor/adom-eda-checks/src/pairroute.rsadded+195@@ -0,0 +1,195 @@+//! A differential pair planned as a pair: one centreline from the midpoint of the source pads through waypoints to the+//! midpoint of the destination pads, each net offset half a pitch (width + gap) to its own side with mitred corners, a+//! short fan-out from each pad, both lengths and the skew, and what the plan would run into. From the Altium bridge's+//! pairroute.rs; the plan is answered as route_net arguments (and the pair's add_rule width rule), for the bridge to+//! apply.+use crate::route::{pad_shape, Shape};+use serde_json::{json, Value};++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }+type P = (f64, f64);++fn sub(a: P, b: P) -> P { (a.0 - b.0, a.1 - b.1) }+fn norm(a: P) -> P { let l = (a.0 * a.0 + a.1 * a.1).sqrt(); if l > 0.0 { (a.0 / l, a.1 / l) } else { (0.0, 0.0) } }+fn left(d: P) -> P { (-d.1, d.0) }++/// The polyline offset `d` to the left (negative: right), corners mitred. Pure.+pub fn offset(path: &[P], d: f64) -> Vec<P> {+ let n = path.len();+ (0..n).map(|i| {+ let din = if i > 0 { norm(sub(path[i], path[i - 1])) } else { norm(sub(path[1], path[0])) };+ let dout = if i + 1 < n { norm(sub(path[i + 1], path[i])) } else { din };+ let (n1, n2) = (left(din), left(dout));+ let m = norm((n1.0 + n2.0, n1.1 + n2.1));+ let k = (m.0 * n1.0 + m.1 * n1.1).max(0.2); // a hairpin would blow the mitre up: cap it at 5x+ (path[i].0 + m.0 * d / k, path[i].1 + m.1 * d / k)+ }).collect()+}++fn length(p: &[P]) -> f64 { p.windows(2).map(|w| ((w[1].0 - w[0].0).powi(2) + (w[1].1 - w[0].1).powi(2)).sqrt()).sum() }++/// Both nets' points from pad to pad: the centreline from the pad midpoints through `via`, each net on the side its+/// source pad is on, `fan` mm of fan-out at each end. Pure.+pub fn plan(pos: (P, P), neg: (P, P), via: &[P], width: f64, gap: f64, fan: f64) -> (Vec<P>, Vec<P>) {+ let s = ((pos.0 .0 + neg.0 .0) / 2.0, (pos.0 .1 + neg.0 .1) / 2.0);+ let e = ((pos.1 .0 + neg.1 .0) / 2.0, (pos.1 .1 + neg.1 .1) / 2.0);+ let mut centre = vec![s];+ centre.extend_from_slice(via);+ centre.push(e);+ // pull the ends in by the fan-out so each pad has room to reach its own side+ let d0 = norm(sub(centre[1], centre[0]));+ let k = centre.len();+ let d1 = norm(sub(centre[k - 1], centre[k - 2]));+ centre[0] = (s.0 + d0.0 * fan, s.1 + d0.1 * fan);+ centre[k - 1] = (e.0 - d1.0 * fan, e.1 - d1.1 * fan);+ let half = (width + gap) / 2.0;+ // the positive net goes on the side its source pad is on+ let side = { let v = sub(pos.0, s); let n = left(d0); if v.0 * n.0 + v.1 * n.1 >= 0.0 { 1.0 } else { -1.0 } };+ let mut p = vec![pos.0]; p.extend(offset(¢re, half * side)); p.push(pos.1);+ let mut n = vec![neg.0]; n.extend(offset(¢re, -half * side)); n.push(neg.1);+ (p, n)+}++fn crosses(a: P, b: P, c: P, d: P) -> bool {+ let o = |p: P, q: P, r: P| (q.0 - p.0) * (r.1 - p.1) - (q.1 - p.1) * (r.0 - p.0);+ let (d1, d2, d3, d4) = (o(c, d, a), o(c, d, b), o(a, b, c), o(a, b, d));+ (d1 > 0.0) != (d2 > 0.0) && (d3 > 0.0) != (d4 > 0.0)+}++/// What is wrong with a planned pair before it touches the board: the two nets crossing each other (the pads swap sides+/// between the ends), or a segment closer than the clearance to another net's copper on the layer. `copper` is+/// (what, net, shape). Pure.+pub(crate) fn check(p: &[P], n: &[P], copper: &[(String, String, Shape)], nets: (&str, &str), width: f64, clearance: f64) -> Vec<Value> {+ let mut out = Vec::new();+ for i in 0..p.len().saturating_sub(1) {+ for j in 0..n.len().saturating_sub(1) {+ if crosses(p[i], p[i + 1], n[j], n[j + 1]) {+ out.push(json!({"problem": "pair_crosses", "at": [r3((p[i].0 + p[i + 1].0) / 2.0), r3((p[i].1 + p[i + 1].1) / 2.0)],+ "fix": "the pads are on swapped sides at the two ends: route to a via pair and cross on another layer, or swap the pins"}));+ return out;+ }+ }+ }+ for (path, net) in [(p, nets.0), (n, nets.1)] {+ // the first and last segments leave their own pads: those pads are the net's own, so any copper they touch is checked too+ 'segs: for w in path.windows(2) {+ for (what, on, s) in copper {+ if on.eq_ignore_ascii_case(net) { continue; }+ let d = s.dist(w[0], w[1]);+ if d < width / 2.0 + clearance - 1e-9 {+ out.push(json!({"problem": if what.contains('.') { "over_a_pad" } else { "over_copper" }, "net": net, "with": what, "withNet": on, "gapMm": r3((d - width / 2.0).max(0.0)),+ "fix": "add waypoints (via) that lead the pair out of the part and round other copper"}));+ break 'segs;+ }+ }+ }+ }+ out+}++/// route_pair: input {board, stack?, rules?, args: {pos, neg, from: {pos, neg}, to: {pos, neg}, via?: [[x, y]], layer?,+/// width?, gap?, diffOhms?, fanMm?, clearance?}}. The plan: `routeNet` for each net, the pair's `widthRule`, lengths,+/// skew, and `problems` (the plan answers success: false while there are any).+pub fn route_pair(input: &Value) -> Value {+ let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+ let args = &input["args"];+ let s = |k: &str| args[k].as_str().map(String::from);+ let (Some(pn), Some(nn)) = (s("pos"), s("neg")) else { return crate::input::refuse("bad_args", "pos and neg are the pair's two nets") };+ let pad = |o: &str, k: &str| args[o][k].as_str().map(String::from);+ let (Some(pf), Some(nf), Some(pt), Some(nt)) = (pad("from", "pos"), pad("from", "neg"), pad("to", "pos"), pad("to", "neg")) else {+ return crate::input::refuse("bad_args", "from: {pos: \"U1.3\", neg: \"U1.4\"} and to: {pos, neg} name the pads at each end");+ };+ let pads = st["pads"].as_array().cloned().unwrap_or_default();+ let key = |p: &Value| format!("{}.{}", p["ref"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or(""));+ let at = |k: &str| pads.iter().find(|p| key(p).eq_ignore_ascii_case(k)).map(|p| (f(&p["x"]), f(&p["y"])));+ let (Some(a), Some(b), Some(c), Some(d)) = (at(&pf), at(&nf), at(&pt), at(&nt)) else {+ return crate::input::refuse("no_such_pad", &format!("one of {pf}, {nf}, {pt}, {nt} is not a pad on the board"));+ };+ let layer = args["layer"].as_str().unwrap_or("F.Cu").to_string();+ // width and gap: given, or solved from the stack-up for diffOhms on this layer+ let (mut width, mut gap, mut sized) = (args["width"].as_f64(), args["gap"].as_f64(), Value::Null);+ if let (Some(z), Some(stack)) = (args["diffOhms"].as_f64(), crate::input::stack(input)) {+ let geo = crate::impedance::geometry(&stack);+ let names = crate::coppermap::stack_layers(&stack);+ if let Some(i) = names.iter().position(|n| n.0 == layer) {+ if i < geo.len() {+ let rows = crate::impedance::widths(&geo[i..=i], None, Some(z), gap);+ if let (Some(w), Some(g)) = (rows[0]["diffWidth"].as_f64(), rows[0]["diffGap"].as_f64()) { width = width.or(Some(w)); gap = gap.or(Some(g)); sized = rows[0].clone(); }+ }+ }+ }+ let (Some(width), Some(gap)) = (width, gap) else { return crate::input::refuse("bad_args", "give width and gap (mm), or diffOhms with the stack to solve them") };+ let via: Vec<P> = args["via"].as_array().map(|a| a.iter().map(|p| (f(&p[0]), f(&p[1]))).collect()).unwrap_or_default();+ let fan = args["fanMm"].as_f64().unwrap_or(1.0);+ let (pp, np) = plan((a, c), (b, d), &via, width, gap, fan);+ // other nets' copper on the layer: pads by their outline, routed tracks and vias (a hatched pour's net-less pieces+ // re-pour round the pair)+ let rule = crate::fab::routing_clearance(&crate::input::rules(input));+ let clearance = args["clearance"].as_f64().unwrap_or(0.0).max(rule.unwrap_or(0.15));+ let mut copper: Vec<(String, String, Shape)> = pads.iter().filter(|p| p["layer"] == layer || p["layer"] == "*.Cu" || f(&p["drill"]) > 0.0)+ .map(|p| (key(p), p["net"].as_str().unwrap_or("").to_string(), pad_shape(p))).collect();+ for t in st["tracks"].as_array().into_iter().flatten().filter(|t| t["layer"] == json!(layer) && t["net"].as_str().map(|n| !n.is_empty()).unwrap_or(false)) {+ copper.push(("track".into(), t["net"].as_str().unwrap_or("").into(), Shape::Cap((f(&t["start"]["x"]), f(&t["start"]["y"])), (f(&t["end"]["x"]), f(&t["end"]["y"])), f(&t["width"]) / 2.0)));+ }+ for v in st["vias"].as_array().into_iter().flatten() {+ let c = (f(&v["position"]["x"]), f(&v["position"]["y"]));+ copper.push(("via".into(), v["net"].as_str().unwrap_or("").into(), Shape::Cap(c, c, f(&v["size"]) / 2.0)));+ }+ let problems = check(&pp, &np, &copper, (&pn, &nn), width, clearance);+ let path = |v: &[P], first: &str, last: &str| -> Value {+ let mut out = vec![json!({"pad": first})];+ for p in &v[1..v.len() - 1] { out.push(json!({"x": r3(p.0), "y": r3(p.1), "layer": layer})); }+ out.push(json!({"pad": last}));+ json!([out])+ };+ let (lp, ln) = (length(&pp), length(&np));+ let ok = problems.is_empty();+ json!({"success": ok, "errorCode": if ok { Value::Null } else { json!("pair_plan_blocked") }, "error": if ok { Value::Null } else { json!("the planned pair would run into copper or cross itself") },+ "problems": problems, "widthMm": r3(width), "gapMm": r3(gap), "sizedFor": sized, "layer": layer, "clearanceMm": clearance,+ "routeNet": [{"net": pn, "layer": layer, "width": r3(width), "paths": path(&pp, &pf, &pt)}, {"net": nn, "layer": layer, "width": r3(width), "paths": path(&np, &nf, &nt)}],+ "widthRule": {"kind": "width", "name": format!("Pair {pn} {nn}"), "scope1": format!("InNet('{pn}') Or InNet('{nn}')"), "min": r3(width * 0.9), "preferred": r3(width), "max": r3(width * 1.1)},+ "lengthsMm": {pn.clone(): r3(lp), nn.clone(): r3(ln)}, "skewMm": r3((lp - ln).abs()), "revision": st["revision"],+ "_hint": if ok { "A plan: send each routeNet entry to route_net (with expectedRevision: revision), then widthRule to add_rule so the board's default width rule does not flag the pair. skewMm is the length difference from the fan-outs and corners: net_lengths measures it routed, length_tune evens it." }+ else { "Nothing to apply while problems is not empty: add waypoints (via) that lead the pair out of the parts and round other copper, or change layer where the pair must cross, and plan again." }})+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn offset_keeps_the_pitch_round_a_corner() {+ let l = offset(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)], 0.5);+ assert!((l[0].1 - 0.5).abs() < 1e-9);+ assert!((l[1].0 - 9.5).abs() < 1e-9 && (l[1].1 - 0.5).abs() < 1e-9);+ assert!((l[2].0 - 9.5).abs() < 1e-9);+ }++ #[test]+ fn plan_puts_each_net_on_its_pads_side_and_check_catches_crossings_and_copper() {+ let (p, n) = plan(((0.0, 0.5), (20.0, 0.5)), ((0.0, -0.5), (20.0, -0.5)), &[], 0.2, 0.15, 1.0);+ assert!(p[1].1 > 0.0 && n[1].1 < 0.0);+ assert!((p[1].1 - n[1].1 - 0.35).abs() < 1e-9);+ assert!((length(&p) - length(&n)).abs() < 1e-9);+ let (x, y) = plan(((0.0, 0.5), (20.0, -0.5)), ((0.0, -0.5), (20.0, 0.5)), &[], 0.2, 0.15, 1.0);+ assert_eq!(check(&x, &y, &[], ("P", "N"), 0.2, 0.15)[0]["problem"], "pair_crosses");+ let pad = pad_shape(&json!({"x": 10.0, "y": 0.175, "sizeX": 0.6, "sizeY": 0.6, "shape": "round"}));+ assert_eq!(check(&p, &n, &[("U1.8".into(), "X".into(), pad)], ("P", "N"), 0.2, 0.15)[0]["problem"], "over_a_pad");+ let track = Shape::Cap((10.0, -3.0), (10.0, 3.0), 0.1);+ assert_eq!(check(&p, &n, &[("track".into(), "X".into(), track)], ("P", "N"), 0.2, 0.15)[0]["problem"], "over_copper");+ assert!(check(&p, &n, &[], ("P", "N"), 0.2, 0.15).is_empty());+ }++ #[test]+ fn the_verb_answers_route_net_arguments_for_both_nets() {+ let pad = |r: &str, n: &str, x: f64, y: f64, net: &str| json!({"ref": r, "name": n, "x": x, "y": y, "sizeX": 0.3, "sizeY": 0.3, "rotation": 0, "layer": "F.Cu", "drill": 0, "net": net, "shape": "rect"});+ let b = json!({"revision": "r7", "pads": [pad("U1", "1", 0.0, 0.5, "USB_P"), pad("U1", "2", 0.0, -0.5, "USB_N"), pad("J1", "1", 20.0, 0.5, "USB_P"), pad("J1", "2", 20.0, -0.5, "USB_N")]});+ let r = route_pair(&json!({"board": b, "args": {"pos": "USB_P", "neg": "USB_N", "from": {"pos": "U1.1", "neg": "U1.2"}, "to": {"pos": "J1.1", "neg": "J1.2"}, "width": 0.2, "gap": 0.15}}));+ assert_eq!(r["success"], json!(true), "{r}");+ assert_eq!(r["routeNet"][0]["paths"][0][0], json!({"pad": "U1.1"}));+ assert_eq!(r["skewMm"], json!(0.0));+ assert_eq!(r["widthRule"]["preferred"], json!(0.2));+ assert_eq!(route_pair(&json!({"board": b, "args": {"pos": "USB_P", "neg": "USB_N", "from": {"pos": "U9.1", "neg": "U1.2"}, "to": {"pos": "J1.1", "neg": "J1.2"}, "width": 0.2, "gap": 0.15}}))["errorCode"], "no_such_pad");+ }+}
rust/vendor/adom-eda-checks/src/report.rsadded+170@@ -0,0 +1,170 @@+//! One shareable page per board: every check the engine has, run against one bundle, written as a single self-contained+//! HTML page with the board's picture when the bridge passes one. From the Altium bridge's report.rs.+use serde_json::{json, Value};++fn esc(s: &str) -> String { s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) }+fn txt(v: &Value) -> String {+ match v {+ Value::String(s) => s.clone(),+ Value::Null => String::new(),+ // lists read as plain text, not JSON+ Value::Array(a) => a.iter().map(txt).filter(|s| !s.is_empty()).collect::<Vec<_>>().join(", "),+ other => other.to_string(),+ }+}++/// One section of the page: a title, a verdict (true, false, None = information) and rows of (label, value). Pure.+pub struct Section { pub title: String, pub ok: Option<bool>, pub summary: String, pub rows: Vec<(String, String)> }++/// The sections from the checks' replies (any may be an error reply: it shows as such). Pure.+pub fn sections(c: &Value) -> Vec<Section> {+ let mut out = Vec::new();+ let failed = |r: &Value| r.get("success").and_then(|b| b.as_bool()) == Some(false);+ let err = |title: &str, r: &Value| Section { title: title.into(), ok: None, summary: format!("not run: {}", txt(r.get("error").unwrap_or(&Value::Null))), rows: vec![] };+ // design rules+ let d = &c["drc"];+ out.push(if d.is_null() { Section { title: "Design rules (the tool's DRC)".into(), ok: None, summary: "not run: pass the tool's DRC reply (run_drc) as drc".into(), rows: vec![] } } else if failed(d) { err("Design rules (the tool's DRC)", d) } else {+ Section { title: "Design rules (the tool's DRC)".into(), ok: Some(d["errors"].as_i64() == Some(0) && d["unconnected"].as_i64() == Some(0)),+ summary: format!("{} violations, {} unconnected", txt(&d["errors"]), txt(&d["unconnected"])),+ rows: d["violations"].as_array().cloned().unwrap_or_default().iter().take(30).map(|v| (txt(&v["rule"]), txt(&v["objects"]))).collect() } });+ let f = &c["fab"];+ // the vendor as each finding's source names it, else the record's own name+ let vendor = f["outsideLimits"][0]["source"]["name"].as_str().or(f["fab"]["vendor"].as_str()).or(f["fab"]["name"].as_str()).unwrap_or("").to_string();+ let fab_title = format!("Fab limits ({})", vendor.chars().take(40).collect::<String>());+ let fab_title = if fab_title == "Fab limits ()" { "Fab limits".to_string() } else { fab_title };+ out.push(if failed(f) { err(&fab_title, f) } else {+ let mut rows: Vec<(String, String)> = f["outsideLimits"].as_array().cloned().unwrap_or_default().iter().map(|o| (txt(&o["limit"]), format!("board {} mm, limit {} mm", txt(&o["boardMm"]), txt(&o["limitMm"])))).collect();+ for k in ["track", "viaDrill", "padHole", "edgeClearance", "silkText", "boardSize"] { if let Some(m) = f["measured"].get(k) { rows.push((format!("measured {k}"), format!("{} mm", txt(&m["mm"])))); } }+ Section { title: fab_title.clone(), ok: f["fits"].as_bool(), summary: format!("{} outside the fab's limits", f["outsideLimits"].as_array().map(|a| a.len()).unwrap_or(0)), rows } });+ let a = &c["assembly"];+ out.push(if failed(a) { err("JLCPCB assembly", a) } else {+ let rows = ["economic", "standard"].iter().flat_map(|t| a[*t]["rules"].as_array().cloned().unwrap_or_default().into_iter().filter(|r| r["ok"] == json!(false)).map(move |r| (format!("{t}: {}", txt(&r["rule"])), txt(&r["detail"])))).collect();+ Section { title: "JLCPCB assembly".into(), ok: Some(!a["service"].is_null()), summary: format!("tier: {}", if a["service"].is_null() { "none passes yet".to_string() } else { txt(&a["service"]) }), rows } });+ let s = &c["schematic"];+ out.push(if failed(s) { err("Schematic review", s) } else {+ let rows = s["errors"].as_array().cloned().unwrap_or_default().iter().chain(s["warnings"].as_array().cloned().unwrap_or_default().iter()).take(30)+ .map(|w| (txt(&w["check"]), [w.get("pin"), w.get("net"), w.get("ics")].iter().flatten().map(|v| txt(v)).collect::<Vec<_>>().join(" "))).collect();+ Section { title: "Schematic review".into(), ok: s["ok"].as_bool(), summary: format!("{} errors, {} warnings, {} pins on {} nets", s["errors"].as_array().map(|a| a.len()).unwrap_or(0), s["warnings"].as_array().map(|a| a.len()).unwrap_or(0), txt(&s["pins"]), txt(&s["nets"])), rows } });+ let p = &c["pinPad"];+ out.push(if failed(p) { err("Symbols against footprints", p) } else {+ Section { title: "Symbols against footprints".into(), ok: p["ok"].as_bool(), summary: format!("{} of {} parts match", txt(&p["matching"]), txt(&p["partsChecked"])),+ rows: p["mismatches"].as_array().cloned().unwrap_or_default().iter().map(|m| (txt(&m["ref"]), format!("pins without pad {} / pads without pin {}", txt(&m["pinsWithoutPad"]), txt(&m["padsWithoutPin"])))).collect() } });+ let i = &c["impedance"];+ out.push(if failed(i) { err("Controlled impedance", i) } else {+ Section { title: "Controlled impedance (50 ohm, 90 ohm pairs)".into(), ok: None, summary: "track widths from the stack-up".into(),+ rows: i["layers"].as_array().cloned().unwrap_or_default().iter().map(|l| (txt(&l["layer"]), format!("{}: 50 ohm {} mm; 90 ohm pair {} / {} mm", txt(&l["model"]), txt(&l["width"]), txt(&l["diffWidth"]), txt(&l["diffGap"])))).collect() } });+ let r = &c["returnPath"];+ out.push(if failed(r) { err("Return paths", r) } else {+ Section { title: "Return paths".into(), ok: r["ok"].as_bool(), summary: format!("{} net-layer runs over a gap in the reference copper", r["nets"].as_array().map(|a| a.len()).unwrap_or(0)),+ rows: r["nets"].as_array().cloned().unwrap_or_default().iter().take(20).map(|n| (format!("{} on {}", txt(&n["net"]), txt(&n["layer"])), format!("{} mm of {} mm ({}%)", txt(&n["unreferencedMm"]), txt(&n["lengthMm"]), txt(&n["percent"])))).collect() } });+ let b = &c["balance"];+ out.push(if failed(b) { err("Copper balance", b) } else {+ Section { title: "Copper balance".into(), ok: b["balanced"].as_bool(), summary: "copper share per layer; mirrored pairs within 20 points".into(),+ rows: b["layers"].as_array().cloned().unwrap_or_default().iter().map(|l| (txt(&l["layer"]), format!("{}% ({} mm2)", txt(&l["percent"]), txt(&l["copperMm2"])))).collect() } });+ let h = &c["heights"];+ out.push(if failed(h) { err("3D heights and clearance", h) } else {+ let mut rows: Vec<(String, String)> = h["heights"].as_array().cloned().unwrap_or_default().iter().take(8).map(|x| (txt(&x["ref"]), format!("{} mm, {}", txt(&x["heightMm"]), txt(&x["side"])))).collect();+ for col in h["collisions"].as_array().cloned().unwrap_or_default() { rows.push(("collision".into(), format!("{} by {} mm", txt(&col["parts"]), txt(&col["overlapMm"])))); }+ Section { title: "3D heights and clearance".into(), ok: Some(h["collisions"].as_array().map(|a| a.is_empty()).unwrap_or(true)), summary: format!("{} bodies; {} parts without a 3D body", txt(&h["bodies"]), h["noBody"].as_array().map(|a| a.len()).unwrap_or(0)), rows } });+ let v = &c["review"];+ out.push(if failed(v) { err("Design review", v) } else {+ Section { title: "Design review".into(), ok: Some(v["counts"]["error"].as_i64() == Some(0)), summary: format!("{} errors, {} warnings, {} notes", txt(&v["counts"]["error"]), txt(&v["counts"]["warning"]), txt(&v["counts"]["info"])),+ rows: v["findings"].as_array().cloned().unwrap_or_default().iter().take(30).map(|x| (format!("{} ({})", txt(&x["check"]), txt(&x["severity"])), txt(&x["message"]))).collect() } });+ out+}++/// The page. Dark by default with a light scheme, tokens on :root. Pure.+pub fn page(board: &str, when: &str, version: &str, shot: Option<&str>, secs: &[Section]) -> String {+ let badge = |ok: Option<bool>| match ok { Some(true) => "<span class=\"b ok\">pass</span>", Some(false) => "<span class=\"b no\">needs work</span>", None => "<span class=\"b info\">info</span>" };+ let mut body = String::new();+ for s in secs {+ body.push_str(&format!("<section><h2>{} {}</h2><p class=\"sum\">{}</p>", esc(&s.title), badge(s.ok), esc(&s.summary)));+ if !s.rows.is_empty() {+ body.push_str("<table>");+ for (k, v) in &s.rows { body.push_str(&format!("<tr><th>{}</th><td>{}</td></tr>", esc(k), esc(v))); }+ body.push_str("</table>");+ }+ body.push_str("</section>");+ }+ let pass = secs.iter().filter(|s| s.ok == Some(true)).count();+ let work = secs.iter().filter(|s| s.ok == Some(false)).count();+ let img = shot.map(|b| format!("<img alt=\"the board\" src=\"data:image/png;base64,{b}\">")).unwrap_or_default();+ format!(r#"<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">+<meta name="color-scheme" content="dark"><title>Board report</title><style>+:root{{--bg:#101418;--panel:#182028;--ink:#e6edf3;--dim:#9aa7b4;--line:#2a3542;--ok:#3fb950;--no:#f0883e;--info:#58a6ff}}+body{{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 system-ui,-apple-system,Segoe UI,sans-serif}}+main{{max-width:960px;margin:0 auto;padding:24px 16px}} h1{{font-size:24px;margin:0 0 4px}} .meta{{color:var(--dim);margin:0 0 16px}}+img{{width:100%;border:1px solid var(--line);border-radius:8px;margin:8px 0 16px}}+section{{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px 16px;margin:12px 0}}+h2{{font-size:17px;margin:0;display:flex;gap:8px;align-items:center}} .sum{{color:var(--dim);margin:4px 0 8px}}+.b{{font-size:12px;border-radius:999px;padding:1px 8px;border:1px solid}} .ok{{color:var(--ok)}} .no{{color:var(--no)}} .info{{color:var(--info)}}+table{{width:100%;border-collapse:collapse;font-size:13px}} th,td{{text-align:left;padding:4px 6px;border-top:1px solid var(--line);vertical-align:top;overflow-wrap:anywhere}} th{{width:34%;color:var(--dim);font-weight:500}}+</style></head><body><main><h1>{}</h1><p class="meta">{} · {} pass, {} need work · {}</p>{}{}</main></body></html>"#,+ esc(board), esc(when), pass, work, esc(version), img, body)+}++/// board_report: input {board, stack?, pours?, rules?, bodies?, fab?, sheetParts?, netlist?, drc?, picture?, args: {title?,+/// date?}}. Every check the engine has, run on this bundle, as one self-contained HTML page (`text`), with the tool's+/// own DRC reply (`drc`) and a base64 PNG of the board (`picture`) when the bridge passes them. A section whose input+/// is missing says so instead of passing.+pub fn board_report(input: &Value) -> Value {+ if let Err(e) = crate::input::board(input) { return e; }+ let with = |args: Value| { let mut i = input.clone(); i["args"] = args; i };+ let need = |k: &str, what: &str| crate::input::part(input, k).is_none().then(|| json!({"success": false, "error": format!("pass {what} as {k}")}));+ let mut c = json!({});+ c["schematic"] = need("sheetParts", "the sheets' parts").or_else(|| need("netlist", "the compiled netlist")).unwrap_or_else(|| crate::run("schematic_review", &with(json!({}))));+ c["pinPad"] = need("sheetParts", "the sheets' parts").unwrap_or_else(|| crate::run("pin_pad_check", &with(json!({}))));+ c["fab"] = need("fab", "a pcb-design-rules record").unwrap_or_else(|| crate::run("fab_check", &with(json!({}))));+ c["assembly"] = crate::run("assembly_check", &with(json!({})));+ c["impedance"] = need("stack", "the stack-up").unwrap_or_else(|| crate::run("impedance", &with(json!({"z0": 50, "diff": 90}))));+ c["returnPath"] = need("stack", "the stack-up").unwrap_or_else(|| crate::run("return_path", &with(json!({}))));+ c["balance"] = need("stack", "the stack-up").unwrap_or_else(|| crate::run("copper_balance", &with(json!({}))));+ c["heights"] = need("bodies", "the 3D bodies").unwrap_or_else(|| crate::run("height_check", &with(json!({}))));+ c["review"] = crate::run("review_board", &with(json!({})));+ c["drc"] = crate::input::part(input, "drc").unwrap_or(Value::Null);+ let secs = sections(&c);+ let args = &input["args"];+ let title = args["title"].as_str().unwrap_or("board");+ let date = args["date"].as_str().map(String::from).unwrap_or_else(|| crate::writers::ipc2581::now().replace('T', " ").replace('Z', " UTC"));+ let picture = input["picture"].as_str();+ let text = page(title, &date, &format!("adom-eda-checks {}", crate::VERSION), picture, &secs);+ json!({"success": true, "text": text, "sections": secs.iter().map(|s| json!({"title": s.title, "ok": s.ok, "summary": s.summary})).collect::<Vec<_>>(),+ "pass": secs.iter().filter(|s| s.ok == Some(true)).count(), "needsWork": secs.iter().filter(|s| s.ok == Some(false)).count(), "picture": picture.is_some(),+ "_hint": "text is a single self-contained HTML page: the bridge writes it beside the project and opens or sends it. Each section's verdict comes from the engine check named in it: run that check for the full reply. A section marked 'not run' names the companion to pass."})+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn sections_read_every_check_and_the_page_escapes() {+ let c = json!({"drc": {"success": true, "errors": 0, "unconnected": 0, "violations": []},+ "fab": {"success": true, "fits": false, "fab": {"vendor": "JLCPCB"}, "outsideLimits": [{"limit": "minTrack", "boardMm": 0.08, "limitMm": 0.1}], "measured": {"track": {"mm": 0.08}}},+ "assembly": {"success": false, "error": "no outline"},+ "schematic": {"success": true, "ok": true, "errors": [], "warnings": [{"check": "single_pin_net", "net": "<X>"}], "pins": 10, "nets": 4}});+ let s = sections(&c);+ assert_eq!(s[0].ok, Some(true));+ assert_eq!((s[1].title.as_str(), s[1].ok), ("Fab limits (JLCPCB)", Some(false)));+ assert!(s[2].summary.starts_with("not run"));+ let html = page("lane", "2026-10-26", "adom-eda-checks", None, &s);+ assert!(html.contains("<X>") && !html.contains("<X>"));+ assert_eq!(txt(&json!(["Pad U2-5", "Pad U3-4"])), "Pad U2-5, Pad U3-4");+ }++ #[test]+ fn the_report_runs_the_engines_checks_and_names_what_is_missing() {+ let st = json!({"outline": [[0, 0], [40, 0], [40, 20], [0, 20]], "components": [{"ref": "R1", "x": 10.0, "y": 10.0, "rotation": 0.0, "side": "top", "pattern": "0603"}],+ "pads": [{"ref": "R1", "name": "1", "x": 9.2, "y": 10.0, "sizeX": 0.8, "sizeY": 0.9, "rotation": 0, "layer": "F.Cu", "drill": 0, "net": "A", "shape": "rect"},+ {"ref": "R1", "name": "2", "x": 10.8, "y": 10.0, "sizeX": 0.8, "sizeY": 0.9, "rotation": 0, "layer": "F.Cu", "drill": 0, "net": "B", "shape": "rect"}]});+ let r = board_report(&json!({"board": st, "args": {"title": "Test", "date": "2026-10-29 12:00 UTC"}}));+ assert_eq!(r["success"], json!(true), "{r}");+ let text = r["text"].as_str().unwrap();+ assert!(text.starts_with("<!doctype html>") && text.contains("<h1>Test</h1>"));+ let sec = |t: &str| r["sections"].as_array().unwrap().iter().find(|s| s["title"].as_str().unwrap_or("").starts_with(t)).cloned().unwrap();+ assert!(sec("Design rules")["summary"].as_str().unwrap().contains("pass the tool's DRC"));+ assert!(sec("Fab limits")["summary"].as_str().unwrap().contains("not run"));+ assert_eq!(sec("Design review")["ok"], json!(true), "{r}");+ assert!(sec("JLCPCB assembly")["summary"].as_str().unwrap().starts_with("tier"));+ }+}
rust/vendor/adom-eda-checks/src/review.rsadded+223@@ -0,0 +1,223 @@+//! Design review: small checks, each a pure function over the board model, the sheet parts and the board's rules. From+//! the Altium bridge's review.rs, on the standard model: components off the board are judged against the outline+//! polygon (Altium's read carried only a bounding box), and rules are classed from a standard kind, a descriptor's+//! leading words or Altium's rule ordinal. What the model cannot answer is named in `notImplemented`.+use serde_json::{json, Map, Value};+use std::collections::{BTreeMap, BTreeSet};++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn s<'a>(v: &'a Value, k: &str) -> &'a str { v.get(k).and_then(|x| x.as_str()).unwrap_or("") }++const PLACEHOLDER_VALUES: [&str; 12] = ["comment", "value", "*", "?", "n/a", "na", "tbd", "xxx", "component", "designator", "part", "unknown"];++/// Adom house rule: Mouser is the preferred vendor, and every purchased part carries Mouser, DigiKey and LCSC numbers.+const VENDOR_PATTERNS: [(&str, &[&str]); 3] = [("Mouser", &["mouser"]), ("DigiKey", &["digikey", "digi-key", "digi key"]), ("LCSC", &["lcsc", "jlcpcb"])];++/// Designator prefixes that carry no vendor part number.+const NON_PURCHASED: [&str; 8] = ["TP", "FID", "MH", "H", "LOGO", "NET", "PWR", "GND"];++/// The rules a fab relies on: clearance, width, hole size, short circuit.+const CRITICAL: [&str; 4] = ["clearance", "width", "hole size", "short circuit"];++pub const NOT_IMPLEMENTED: [(&str, &str); 4] = [+ ("unused_mechanical_layers", "the board model carries no mechanical layers"),+ ("unrouted_connections", "needs the ratsnest: routing_drc or the tool's DRC"),+ ("silkscreen_over_pad", "silk_designators reports labels on pads (onPadsBefore)"),+ ("missing_decoupling", "schematic_review checks each IC supply for a capacitor to ground"),+];++fn finding(check: &str, severity: &str, key: (&str, &str), message: String) -> Value {+ let mut v = json!({"check": check, "severity": severity, "message": message});+ if !key.1.is_empty() { v[key.0] = json!(key.1); }+ v+}++fn purchased(designator: &str) -> bool {+ let prefix: String = designator.to_uppercase().chars().take_while(|c| c.is_ascii_alphabetic()).collect();+ !NON_PURCHASED.contains(&prefix.as_str())+}++fn params_of(c: &Value) -> Vec<(String, String)> {+ c["params"].as_array().map(|a| a.iter().filter_map(|p| {+ let name = p["name"].as_str()?.trim().to_lowercase();+ (!name.is_empty()).then(|| (name, p["value"].as_str().unwrap_or("").trim().to_string()))+ }).collect()).unwrap_or_default()+}++/// A rule's class: a standard kind, the descriptor's leading words, or Altium's rule ordinal. Pure.+pub fn rule_class(r: &Value) -> String {+ if let Some(k) = r["kind"].as_str() {+ let k = k.to_lowercase();+ for c in CRITICAL { if k.replace(['_', '-'], " ").contains(c) || (c == "hole size" && k == "hole") || (c == "short circuit" && k == "short") { return c.into(); } }+ return k;+ }+ let d = s(r, "descriptor").to_lowercase().replace('-', " ");+ for (prefix, c) in [("clearance constraint", "clearance"), ("width constraint", "width"), ("hole size constraint", "hole size"), ("short circuit constraint", "short circuit")] {+ if d.starts_with(prefix) { return c.into(); }+ }+ match r["kind"].as_i64() {+ Some(0) => "clearance".into(), Some(2) => "width".into(), Some(42) => "hole size".into(), Some(15) => "short circuit".into(),+ _ => d.split('(').next().unwrap_or("").trim().to_string(),+ }+}++/// The checks over the board model, the sheet parts (read_sheet_file's components, or none) and the rules. Returns the+/// findings (errors first), the checks run and those skipped with why. Pure.+pub fn run_checks(st: &Value, sheet: Option<&[Value]>, rules: &[Value], grid: f64, edge: f64, wanted: Option<&[String]>) -> (Vec<Value>, Vec<String>, Map<String, Value>) {+ let comps = st["components"].as_array().cloned().unwrap_or_default();+ let pads = st["pads"].as_array().cloned().unwrap_or_default();+ let ring = crate::coppermap::outline_ring(st);+ let want = |n: &str| wanted.map(|w| w.iter().any(|x| x == n)).unwrap_or(true);+ let (mut out, mut ran, mut skipped) = (Vec::new(), Vec::new(), Map::new());+ let mut run = |name: &str, needs: Option<&str>, out: &mut Vec<Value>, f: &dyn Fn() -> Vec<Value>| {+ if !want(name) { return; }+ if let Some(why) = needs { skipped.insert(name.into(), json!(why)); return; }+ ran.push(name.to_string());+ out.extend(f());+ };+ let placed: Vec<&Value> = comps.iter().filter(|c| pads.iter().any(|p| p["ref"] == c["ref"])).collect();+ let own = |r: &str| -> Vec<(f64, f64)> { pads.iter().filter(|p| p["ref"].as_str() == Some(r)).map(|p| (f(&p["x"]), f(&p["y"]))).collect() };+ run("placeholder_values", None, &mut out, &|| placed.iter().filter_map(|c| {+ let v = c.get("value").or(c.get("comment")).and_then(|v| v.as_str()).unwrap_or("").trim().to_string();+ PLACEHOLDER_VALUES.contains(&v.to_lowercase().as_str()).then(|| finding("placeholder_values", "error", ("designator", s(c, "ref")), format!("{} has the placeholder value {v:?}: it will reach the BOM as is.", s(c, "ref"))))+ }).collect());+ run("single_pin_nets", None, &mut out, &|| {+ let mut n: BTreeMap<&str, usize> = BTreeMap::new();+ for p in &pads { if let Some(net) = p["net"].as_str().filter(|x| !x.is_empty()) { *n.entry(net).or_default() += 1; } }+ n.into_iter().filter(|(_, k)| *k == 1).map(|(net, _)| finding("single_pin_nets", "warning", ("net", net), format!("Net {net:?} has a single pad: it connects to nothing."))).collect()+ });+ run("off_grid_components", if grid > 0.0 { None } else { Some("no placement grid: pass gridMm (a 5 mil grid is 0.127)") }, &mut out, &|| placed.iter().filter_map(|c| {+ let off = |v: f64| { let k = v / grid; (k - k.round()).abs() * grid > 0.001 };+ (off(f(&c["x"])) || off(f(&c["y"]))).then(|| finding("off_grid_components", "info", ("designator", s(c, "ref")), format!("{} sits at ({}, {}) mm, off the {grid} mm grid.", s(c, "ref"), f(&c["x"]), f(&c["y"]))))+ }).collect());+ run("duplicate_designators", None, &mut out, &|| {+ let mut v = Vec::new();+ let mut count = |label: &str, names: Vec<String>| {+ let mut seen: BTreeMap<String, u32> = BTreeMap::new();+ for d in names.into_iter().filter(|d| !d.is_empty()) { *seen.entry(d).or_default() += 1; }+ for (d, n) in seen { if n > 1 { v.push(finding("duplicate_designators", "error", ("designator", &d), format!("Designator {d} occurs {n} times on the {label}."))); } }+ };+ count("board", comps.iter().map(|c| s(c, "ref").to_string()).collect());+ // a multi-part chip appears once per part on the sheets: count each designator once per part id+ if let Some(sh) = sheet {+ let mut uniq: BTreeSet<(String, String)> = BTreeSet::new();+ let names: Vec<String> = sh.iter().filter(|c| uniq.insert((s(c, "designator").to_string(), format!("{}{}", s(c, "uniqueId"), c["partId"])))).map(|c| s(c, "designator").to_string()).collect();+ let multi: BTreeSet<String> = sh.iter().filter(|c| c["partCount"].as_i64().unwrap_or(1) > 1).map(|c| s(c, "designator").to_string()).collect();+ count("sheets", names.into_iter().filter(|d| !multi.contains(d)).collect());+ }+ v+ });+ run("components_outside_board_outline", if ring.len() < 3 { Some("the board has no outline") } else { None }, &mut out, &|| placed.iter().filter_map(|c| {+ let ps = own(s(c, "ref"));+ let outside = ps.iter().filter(|p| !crate::route::inside(**p, &ring)).count();+ (outside > 0).then(|| finding("components_outside_board_outline", "error", ("designator", s(c, "ref")),+ format!("{} has {outside} of {} pad(s) outside the board outline.", s(c, "ref"), ps.len())))+ }).collect());+ run("components_near_board_edge", if ring.len() < 3 { Some("the board has no outline") } else { None }, &mut out, &|| placed.iter().filter_map(|c| {+ let ps = own(s(c, "ref"));+ if ps.iter().any(|p| !crate::route::inside(*p, &ring)) { return None; }+ let seg = |p: (f64, f64), a: (f64, f64), b: (f64, f64)| {+ let (dx, dy) = (b.0 - a.0, b.1 - a.1);+ let l2 = dx * dx + dy * dy;+ let t = if l2 == 0.0 { 0.0 } else { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / l2).clamp(0.0, 1.0) };+ ((p.0 - a.0 - t * dx).powi(2) + (p.1 - a.1 - t * dy).powi(2)).sqrt()+ };+ let d = ps.iter().map(|p| (0..ring.len()).map(|i| seg(*p, ring[i], ring[(i + 1) % ring.len()])).fold(f64::MAX, f64::min)).fold(f64::MAX, f64::min);+ (d < edge).then(|| finding("components_near_board_edge", "warning", ("designator", s(c, "ref")), format!("{} has a pad centre {:.3} mm from the board edge (under {edge} mm).", s(c, "ref"), d)))+ }).collect());+ let no_sheet = sheet.is_none().then_some("no sheet parts: pass sheetParts");+ let sheet_parts: Vec<Value> = sheet.map(|x| x.to_vec()).unwrap_or_default();+ run("vendor_part_numbers", no_sheet, &mut out, &|| sheet_parts.iter().filter(|c| purchased(s(c, "designator"))).filter_map(|c| {+ let blob = params_of(c).iter().map(|(k, v)| format!("{k} {v}")).collect::<Vec<_>>().join(" ").to_lowercase();+ let missing: Vec<&str> = VENDOR_PATTERNS.iter().filter(|(_, keys)| !keys.iter().any(|k| blob.contains(k))).map(|(v, _)| *v).collect();+ if missing.is_empty() { return None; }+ let mut x = finding("vendor_part_numbers", "warning", ("designator", s(c, "designator")), format!("{} has no {} part number (house rule: Mouser preferred, all three required).", s(c, "designator"), missing.join(" / ")));+ x["missing"] = json!(missing);+ Some(x)+ }).collect());+ run("missing_datasheets", no_sheet, &mut out, &|| sheet_parts.iter().filter(|c| purchased(s(c, "designator"))).filter_map(|c| {+ let has = params_of(c).iter().any(|(k, _)| k.contains("datasheet") || k == "helpurl");+ (!has).then(|| finding("missing_datasheets", "info", ("designator", s(c, "designator")), format!("{} has no Datasheet parameter.", s(c, "designator"))))+ }).collect());+ run("schematic_placeholder_values", no_sheet, &mut out, &|| sheet_parts.iter().filter_map(|c| {+ let v = s(c, "comment").trim().to_string();+ PLACEHOLDER_VALUES.contains(&v.to_lowercase().as_str()).then(|| finding("schematic_placeholder_values", "error", ("designator", s(c, "designator")), format!("{} on the sheet has the placeholder Comment {v:?}.", s(c, "designator"))))+ }).collect());+ let no_rules = rules.is_empty().then_some("no rules: pass read_rules as rules");+ run("disabled_design_rules", no_rules, &mut out, &|| rules.iter().filter(|r| r["enabled"] == json!(false)).map(|r| {+ let c = rule_class(r);+ let sev = if CRITICAL.contains(&c.as_str()) { "error" } else { "warning" };+ finding("disabled_design_rules", sev, ("rule", s(r, "name")), format!("Design rule {:?} ({c}) is disabled: DRC passes without ever applying it.", s(r, "name")))+ }).collect());+ run("missing_critical_rules", no_rules, &mut out, &|| {+ let have: BTreeSet<String> = rules.iter().filter(|r| r["enabled"] != json!(false)).map(rule_class).collect();+ CRITICAL.iter().filter(|c| !have.contains(**c)).map(|c| finding("missing_critical_rules", "error", ("rule", c), format!("No enabled {c} rule on this board."))).collect()+ });+ run("duplicate_rule_priority", no_rules, &mut out, &|| {+ let mut seen: BTreeMap<(String, String), Vec<String>> = BTreeMap::new();+ for r in rules.iter().filter(|r| r["enabled"] != json!(false) && !r["priority"].is_null()) { seen.entry((rule_class(r), r["priority"].to_string())).or_default().push(s(r, "name").to_string()); }+ seen.into_iter().filter(|(_, n)| n.len() > 1).map(|((c, p), mut n)| { n.sort(); finding("duplicate_rule_priority", "info", ("rule", &n.join(", ")), format!("{} enabled {c} rules share priority {p} ({}): which one wins is not readable from the board.", n.len(), n.join(", "))) }).collect()+ });+ run("components_without_footprint", None, &mut out, &|| placed.iter().filter(|c| s(c, "pattern").trim().is_empty())+ .map(|c| finding("components_without_footprint", "warning", ("designator", s(c, "ref")), format!("{} has no footprint pattern name: it cannot be matched back to a library.", s(c, "ref")))).collect());+ let order = |x: &Value| match s(x, "severity") { "error" => 0, "warning" => 1, _ => 2 };+ out.sort_by(|a, b| order(a).cmp(&order(b)).then_with(|| s(a, "check").cmp(s(b, "check"))));+ (out, ran, skipped)+}++/// review_board: input {board, sheetParts?, rules?, args: {checks?, gridMm?, edgeMm?}}.+pub fn review_board(input: &Value) -> Value {+ let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+ let args = &input["args"];+ let sheet: Option<Vec<Value>> = crate::input::part(input, "sheetParts").and_then(|p| p.get("components").or(Some(&p)).and_then(|x| x.as_array()).cloned());+ let wanted: Option<Vec<String>> = args["checks"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect());+ let (findings, ran, skipped) = run_checks(&st, sheet.as_deref(), &crate::input::rules(input), args["gridMm"].as_f64().unwrap_or(0.0), args["edgeMm"].as_f64().unwrap_or(0.5), wanted.as_deref());+ let mut counts = json!({"error": 0, "warning": 0, "info": 0});+ for x in &findings { let k = s(x, "severity").to_string(); counts[&k] = json!(counts[&k].as_i64().unwrap_or(0) + 1); }+ json!({"success": true, "findings": findings, "counts": counts, "checks": ran, "skipped": skipped,+ "notImplemented": NOT_IMPLEMENTED.iter().map(|(k, v)| (k.to_string(), json!(v))).collect::<Map<String, Value>>(),+ "_hint": "Findings are errors first. skipped names the checks a missing companion stopped (pass sheetParts and rules for all of them); notImplemented names what this review does not judge, so silence there is not a clean board."})+}++#[cfg(test)]+mod tests {+ use super::*;++ fn pad(r: &str, n: &str, x: f64, y: f64, net: &str) -> Value { json!({"ref": r, "name": n, "x": x, "y": y, "net": net}) }++ #[test]+ fn each_check_finds_its_problem() {+ let st = json!({"outline": [[0, 0], [20, 0], [20, 10], [0, 10]],+ "components": [{"ref": "U1", "x": 5.0, "y": 5.0, "pattern": "SOIC8", "value": "TBD"}, {"ref": "R1", "x": 19.8, "y": 5.0, "pattern": ""}, {"ref": "R1", "x": 10.0, "y": 5.0, "pattern": "0603"}, {"ref": "J1", "x": 25.0, "y": 5.0, "pattern": "HDR"}],+ "pads": [pad("U1", "1", 4.0, 5.0, "A"), pad("U1", "2", 6.0, 5.0, "LONE"), pad("R1", "1", 19.8, 5.0, "A"), pad("J1", "1", 25.0, 5.0, "")]});+ let sheet = vec![json!({"designator": "U1", "comment": "LM358", "params": [{"name": "Mouser Part Number", "value": "595-LM358"}]}), json!({"designator": "TP1", "comment": "Comment"})];+ let rules = vec![json!({"name": "Clearance", "kind": 0, "enabled": true, "priority": 1, "descriptor": "Clearance Constraint (Gap=0.1mm)"}),+ json!({"name": "Short", "kind": 15, "enabled": false, "priority": 1, "descriptor": "Short-Circuit Constraint (Allowed=No)"}),+ json!({"name": "W1", "kind": "width", "enabled": true, "priority": 2}), json!({"name": "W2", "kind": "width", "enabled": true, "priority": 2})];+ let (f, ran, _) = run_checks(&st, Some(&sheet), &rules, 0.127, 0.5, None);+ let has = |c: &str, key: &str, v: &str| f.iter().any(|x| x["check"] == c && x[key] == v);+ assert!(has("placeholder_values", "designator", "U1"));+ assert!(has("single_pin_nets", "net", "LONE"));+ assert!(has("duplicate_designators", "designator", "R1"));+ assert!(has("components_outside_board_outline", "designator", "J1"));+ assert!(has("components_near_board_edge", "designator", "R1"));+ assert!(has("components_without_footprint", "designator", "R1"));+ assert!(f.iter().any(|x| x["check"] == "vendor_part_numbers" && x["designator"] == "U1" && x["missing"] == json!(["DigiKey", "LCSC"])));+ assert!(!f.iter().any(|x| x["check"] == "vendor_part_numbers" && x["designator"] == "TP1"), "a test point is not purchased");+ assert!(has("schematic_placeholder_values", "designator", "TP1"));+ assert!(f.iter().any(|x| x["check"] == "disabled_design_rules" && x["severity"] == "error"));+ assert!(has("missing_critical_rules", "rule", "hole size") && has("missing_critical_rules", "rule", "short circuit"));+ assert!(f.iter().any(|x| x["check"] == "duplicate_rule_priority"));+ assert_eq!(f[0]["severity"], "error");+ assert_eq!(ran.len(), 13);+ let (_, _, skipped) = run_checks(&st, None, &[], 0.127, 0.5, None);+ assert!(skipped.contains_key("vendor_part_numbers") && skipped.contains_key("missing_critical_rules"));+ }++ #[test]+ fn rules_are_classed_three_ways() {+ assert_eq!(rule_class(&json!({"kind": 42})), "hole size");+ assert_eq!(rule_class(&json!({"kind": 99, "descriptor": "Width Constraint (Min=0.1mm)"})), "width");+ assert_eq!(rule_class(&json!({"kind": "short_circuit"})), "short circuit");+ }+}
rust/vendor/adom-eda-checks/src/route.rs+1−1@@ -43,7 +43,7 @@ pub(crate) enum Shape { Cap(P, P, f64), Poly(Vec<P>) } impl Shape { /// Edge-to-edge distance from the segment a..b (its centreline) to this copper.- fn dist(&self, a: P, b: P) -> f64 {+ pub(crate) fn dist(&self, a: P, b: P) -> f64 { match self { Shape::Cap(c, d, r) => seg_seg(a, b, *c, *d) - r, Shape::Poly(ring) => {
rust/vendor/adom-eda-checks/src/schreview.rsadded+252@@ -0,0 +1,252 @@+//! A schematic review before the board is updated: every placed pin from the sheets (with its electrical type) against+//! the compiled netlist, and the symbols' pins against the footprints' pads. From the Altium bridge's schreview.rs. Unconnected pins (power pins as errors), nets with one pin, outputs driving each+//! other, inputs nothing drives, and IC power pins with no decoupling capacitor to ground.+use serde_json::{json, Map, Value};+use std::collections::{BTreeMap, BTreeSet};++/// Altium's pin electrical types (the ELECTRICAL field).+pub fn electrical(n: i64) -> &'static str {+ match n { 0 => "input", 1 => "io", 2 => "output", 3 => "open_collector", 4 => "passive", 5 => "hiz", 6 => "open_emitter", 7 => "power", _ => "unknown" }+}++/// A pin's type from the sheet model: Altium's code, or the name the standard uses (Fusion also sends `supply`, a+/// supply symbol's pin that drives its net, and `not_connected`).+pub fn pin_type(v: &Value) -> String {+ match v {+ Value::Number(n) => electrical(n.as_i64().unwrap_or(-1)).to_string(),+ Value::String(t) => match t.to_ascii_lowercase().replace(['-', ' '], "_").as_str() {+ "bidirectional" | "bidi" | "i/o" => "io".into(),+ "pwr" => "power".into(),+ "sup" => "supply".into(),+ "nc" => "not_connected".into(),+ "oc" | "opencollector" => "open_collector".into(),+ "pas" => "passive".into(),+ "tri" | "tristate" | "3state" => "hiz".into(),+ "in" => "input".into(),+ "out" => "output".into(),+ o => o.to_string(),+ },+ _ => "unknown".into(),+ }+}++pub fn is_ground(net: &str) -> bool {+ let n = net.to_ascii_uppercase();+ n == "GND" || n == "0V" || n == "VSS" || n == "AGND" || n == "DGND" || n == "PGND" || n == "GNDA" || n == "GNDD" || n.starts_with("GND_") || n.ends_with("_GND")+}++fn prefix(des: &str) -> String { des.chars().take_while(|c| c.is_ascii_alphabetic()).collect::<String>().to_ascii_uppercase() }++/// The review, from the parts (read_sheet_file components: designator, pins with number/name/electrical) and the nets+/// ({net: ["U1.8", ...]}). Pure.+pub fn review(parts: &[Value], nets: &Map<String, Value>) -> Value {+ // pin -> net+ let mut net_of: BTreeMap<String, String> = BTreeMap::new();+ for (n, ps) in nets { for p in ps.as_array().cloned().unwrap_or_default() { if let Some(s) = p.as_str() { net_of.insert(s.to_ascii_uppercase(), n.clone()); } } }+ // every placed pin, once (a multi-part chip appears once per part on the sheets)+ let mut pins: BTreeMap<String, (String, String, String, String)> = BTreeMap::new(); // key -> (designator, number, name, type)+ for c in parts {+ let des = c["designator"].as_str().unwrap_or("").to_string();+ if des.is_empty() || des.contains('?') { continue; }+ // a supply symbol with no package (Fusion's SUPPLY1..) joins its net by pin name and has no pin numbers: it is+ // no part on the board (fusion-lane)+ if c["pins"].as_array().map(|a| !a.is_empty() && a.iter().all(|p| p["number"].as_str().map(|n| n.trim().is_empty()).unwrap_or(true))).unwrap_or(false) { continue; }+ for p in c["pins"].as_array().cloned().unwrap_or_default() {+ let num = p["number"].as_str().unwrap_or("").to_string();+ pins.entry(format!("{des}.{num}").to_ascii_uppercase()).or_insert((des.clone(), num, p["name"].as_str().unwrap_or("").to_string(), pin_type(&p["electrical"])));+ }+ }+ let nc = |name: &str| { let n = name.to_ascii_uppercase(); n == "NC" || n == "N/C" || n == "DNC" || n.starts_with("NC_") || n == "NO CONNECT" };+ let mut errors = Vec::new();+ let mut warnings = Vec::new();+ for (key, (des, num, name, ty)) in &pins {+ if net_of.contains_key(key) || nc(name) || ty == "not_connected" { continue; }+ let f = json!({"check": "unconnected_pin", "pin": format!("{des}.{num}"), "name": name, "type": ty});+ if ty == "power" { errors.push(f) } else { warnings.push(f) }+ }+ for (n, ps) in nets {+ let members = ps.as_array().map(|a| a.len()).unwrap_or(0);+ if members == 1 { warnings.push(json!({"check": "single_pin_net", "net": n, "pin": ps[0]})); }+ let kinds: Vec<&str> = ps.as_array().cloned().unwrap_or_default().iter().filter_map(|p| p.as_str()).filter_map(|p| pins.get(&p.to_ascii_uppercase())).map(|t| t.3.as_str()).collect();+ let outs = kinds.iter().filter(|k| **k == "output").count();+ if outs >= 2 { errors.push(json!({"check": "outputs_together", "net": n, "outputs": outs})); }+ let driven = kinds.iter().any(|k| matches!(*k, "output" | "io" | "power" | "supply" | "passive" | "open_collector" | "open_emitter" | "hiz" | "unknown"));+ if !kinds.is_empty() && kinds.iter().all(|k| *k == "input") && !driven && members > 0 {+ warnings.push(json!({"check": "undriven_input", "net": n, "inputs": kinds.len()}));+ }+ }+ // decoupling: each IC power pin on a supply net (not ground) wants a capacitor between that net and a ground+ let caps_between: BTreeMap<String, Vec<String>> = {+ let mut m: BTreeMap<String, Vec<String>> = BTreeMap::new();+ let mut cap_nets: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();+ for (key, (des, ..)) in &pins {+ if prefix(des) == "C" { if let Some(n) = net_of.get(key) { cap_nets.entry(des.clone()).or_default().insert(n.clone()); } }+ }+ for (c, ns) in cap_nets {+ if ns.iter().any(|n| is_ground(n)) { for n in ns.iter().filter(|n| !is_ground(n)) { m.entry(n.clone()).or_default().push(c.clone()); } }+ }+ m+ };+ let mut supplies: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();+ for (key, (des, _, _, ty)) in &pins {+ if ty != "power" || !matches!(prefix(des).as_str(), "U" | "IC") { continue; }+ if let Some(n) = net_of.get(key).filter(|n| !is_ground(n)) { supplies.entry(n.clone()).or_default().insert(des.clone()); }+ }+ let mut decoupling = Vec::new();+ for (net, ics) in &supplies {+ let caps = caps_between.get(net).cloned().unwrap_or_default();+ // an inductor on a 'supply' pin's net makes it a switch node (a regulator's SW/LX pin): no capacitor belongs+ // there (the Bluetooth Sentinel's SMPS_LX read as missing decoupling)+ let switch = nets.get(net).and_then(|p| p.as_array()).map(|a| a.iter().filter_map(|p| p.as_str()).any(|p| prefix(p.split('.').next().unwrap_or("")) == "L")).unwrap_or(false);+ let row = json!({"net": net, "ics": ics, "capacitors": caps, "ok": !caps.is_empty() || switch, "switchNode": switch});+ if caps.is_empty() && !switch { warnings.push(json!({"check": "no_decoupling", "net": net, "ics": ics})); }+ decoupling.push(row);+ }+ json!({"pins": pins.len(), "nets": nets.len(), "errors": errors, "warnings": warnings, "decoupling": decoupling, "ok": errors.is_empty()})+}++/// Symbol pins against footprint pads, part by part: pins with no pad (a net that can never reach copper), pads with no+/// pin (copper no net reaches: fine for mounting and mechanical pads), and parts on only one side. Pure.+pub fn pin_pad(parts: &[Value], pads: &[Value], board_refs: &[String]) -> Value {+ let mut pins: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();+ for c in parts {+ let des = c["designator"].as_str().unwrap_or("").to_string();+ if des.is_empty() || des.contains('?') { continue; }+ // a supply symbol with no package (Fusion's SUPPLY1..) joins its net by pin name and has no pin numbers: it is+ // no part on the board (fusion-lane)+ if c["pins"].as_array().map(|a| !a.is_empty() && a.iter().all(|p| p["number"].as_str().map(|n| n.trim().is_empty()).unwrap_or(true))).unwrap_or(false) { continue; }+ let e = pins.entry(des).or_default();+ for p in c["pins"].as_array().cloned().unwrap_or_default() { if let Some(n) = p["number"].as_str() { e.insert(n.to_ascii_uppercase()); } }+ }+ let mut padset: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();+ for p in pads {+ let (Some(r), Some(n)) = (p["ref"].as_str().filter(|r| !r.is_empty()), p["name"].as_str()) else { continue };+ padset.entry(r.to_string()).or_default().insert(n.to_ascii_uppercase());+ }+ let mut mismatches = Vec::new();+ let mut ok = 0;+ for (des, ps) in &pins {+ let Some(pd) = padset.get(des) else { continue };+ let no_pad: Vec<&String> = ps.iter().filter(|n| !pd.contains(*n)).collect();+ // unnamed or '0' pads and mounting pads (MH*, EP*, SH*, MP*) carry no pin by design+ let no_pin: Vec<&String> = pd.iter().filter(|n| !ps.contains(*n) && !n.is_empty() && *n != "0" && !["MH", "MP", "SH", "EP", "PAD", "TAB"].iter().any(|m| n.starts_with(m))).collect();+ if no_pad.is_empty() && no_pin.is_empty() { ok += 1; continue; }+ mismatches.push(json!({"ref": des, "pinsWithoutPad": no_pad, "padsWithoutPin": no_pin, "pins": ps.len(), "pads": pd.len()}));+ }+ let on_board: BTreeSet<String> = board_refs.iter().cloned().collect();+ let sheet_only: Vec<&String> = pins.keys().filter(|d| !on_board.contains(*d) && !padset.contains_key(*d)).collect();+ let board_only: Vec<&String> = on_board.iter().filter(|d| !pins.contains_key(*d)).collect();+ json!({"partsChecked": ok + mismatches.len(), "matching": ok, "mismatches": mismatches, "onSheetsOnly": sheet_only, "onBoardOnly": board_only,+ "ok": mismatches.iter().all(|m| m["pinsWithoutPad"].as_array().map(|a| a.is_empty()).unwrap_or(true))})+}++/// The sheet parts companion (read_sheet_file's components, each with its pins): a list, or a reply with `components`.+fn sheet_parts(input: &Value) -> Option<Vec<Value>> {+ let p = crate::input::part(input, "sheetParts")?;+ p.get("components").or(Some(&p)).and_then(|x| x.as_array()).cloned()+}++/// schematic_review: input {sheetParts, netlist}. The netlist companion is {nets: {net: ["U1.8", ...]}} (or the map).+pub fn schematic_review(input: &Value) -> Value {+ let Some(parts) = sheet_parts(input) else { return crate::input::refuse("bad_args", "pass the sheets' parts (read_sheet_file with pins, every sheet) as sheetParts") };+ let Some(n) = crate::input::part(input, "netlist") else { return crate::input::refuse("bad_args", "pass the compiled netlist ({nets: {net: [\"U1.8\", ...]}}) as netlist") };+ let nets = n.get("nets").or(Some(&n)).and_then(|x| x.as_object()).cloned().unwrap_or_default();+ let mut out = review(&parts, &nets);+ out["success"] = json!(true);+ out["_hint"] = json!("errors stop a board (a power pin on no net, two outputs on one net); warnings deserve a look (pins on no net, one-pin nets, inputs nothing drives, an IC supply with no capacitor to ground). Pin types come from the symbols: a passive pin is never 'undriven'. Fix on the sheets (sch_connect, sch_place_part), then update_pcb.");+ out+}++/// pin_pad_check: input {board, sheetParts}. The symbols' pins against the placed footprints' pads.+pub fn pin_pad_check(input: &Value) -> Value {+ let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+ let Some(parts) = sheet_parts(input) else { return crate::input::refuse("bad_args", "pass the sheets' parts (read_sheet_file with pins, every sheet) as sheetParts") };+ let refs: Vec<String> = st["components"].as_array().cloned().unwrap_or_default().iter().filter_map(|c| c["ref"].as_str().map(String::from)).collect();+ let mut out = pin_pad(&parts, st["pads"].as_array().map(|a| a.as_slice()).unwrap_or(&[]), &refs);+ out["success"] = json!(true);+ out["_hint"] = json!("pinsWithoutPad is a part whose footprint cannot carry every pin's net (a wrong footprint, or pin numbers that do not match): fix the footprint or the symbol (swap_footprint, build_footprint) before routing. padsWithoutPin is copper no net reaches. onSheetsOnly / onBoardOnly differ until update_pcb.");+ out+}++#[cfg(test)]+mod tests {+ use super::*;++ fn part(des: &str, pins: &[(&str, &str, i64)]) -> Value {+ json!({"designator": des, "pins": pins.iter().map(|(n, nm, e)| json!({"number": n, "name": nm, "electrical": e})).collect::<Vec<_>>()})+ }++ #[test]+ fn pin_types_come_as_codes_or_names() {+ assert_eq!(pin_type(&json!(7)), "power");+ assert_eq!(pin_type(&json!("Bidirectional")), "io");+ assert_eq!(pin_type(&json!("sup")), "supply");+ let parts = vec![json!({"designator": "U1", "pins": [+ {"number": "1", "name": "IN", "electrical": "input"}, {"number": "2", "name": "X", "electrical": "not_connected"}]}),+ json!({"designator": "P1", "pins": [{"number": "1", "name": "VCC", "electrical": "supply"}]})];+ let nets = json!({"VCC": ["U1.1", "P1.1"]});+ let r = review(&parts, nets.as_object().unwrap());+ assert!(r["warnings"].as_array().unwrap().is_empty(), "{r}");+ }++ #[test]+ fn the_review_finds_each_kind_of_problem() {+ let parts = vec![+ part("U1", &[("1", "VCC", 7), ("2", "GND", 7), ("3", "OUT", 2), ("4", "NC", 0), ("5", "EN", 0)]),+ part("U2", &[("1", "VDD", 7), ("2", "GND", 7), ("3", "OUT", 2), ("4", "IN", 0)]),+ part("C1", &[("1", "", 4), ("2", "", 4)]),+ part("R1", &[("1", "", 4), ("2", "", 4)]),+ part("U3", &[("1", "SW", 7)]), part("L1", &[("1", "", 4), ("2", "", 4)]),+ ];+ let nets = json!({"3V3": ["U1.1", "C1.1"], "VDD": ["U2.1"], "LX": ["U3.1", "L1.1"], "GND": ["U1.2", "U2.2", "C1.2"], "BUS": ["U1.3", "U2.3"], "SENSE": ["U2.4"], "R": ["R1.1", "R1.2"]});+ let r = review(&parts, nets.as_object().unwrap());+ let checks = |k: &str| r[k].as_array().unwrap().iter().map(|f| f["check"].as_str().unwrap().to_string()).collect::<Vec<_>>();+ // U1.5 EN floats (input, warning); NC is skipped; two outputs share BUS (error)+ assert!(checks("errors").contains(&"outputs_together".to_string()));+ assert!(checks("warnings").contains(&"unconnected_pin".to_string()));+ assert!(!r["warnings"].as_array().unwrap().iter().any(|f| f["pin"] == "U1.4"));+ // VDD has one pin and no capacitor; SENSE is an input nothing drives+ assert!(checks("warnings").contains(&"single_pin_net".to_string()));+ assert!(checks("warnings").contains(&"no_decoupling".to_string()));+ assert!(checks("warnings").contains(&"undriven_input".to_string()));+ let dec = r["decoupling"].as_array().unwrap();+ assert!(dec.iter().any(|d| d["net"] == "3V3" && d["ok"] == json!(true)));+ assert!(dec.iter().any(|d| d["net"] == "VDD" && d["ok"] == json!(false)));+ // a regulator's switch pin on an inductor is not a missing capacitor+ assert!(dec.iter().any(|d| d["net"] == "LX" && d["switchNode"] == json!(true) && d["ok"] == json!(true)));+ }+ #[test]+ fn pin_pad_finds_footprints_that_do_not_fit_their_symbols() {+ let parts = vec![part("U1", &[("1", "A", 0), ("2", "B", 0), ("3", "C", 0)]), part("R1", &[("1", "", 4), ("2", "", 4)]), part("C9", &[("1", "", 4), ("2", "", 4)])];+ let pad = |r: &str, n: &str| json!({"ref": r, "name": n});+ let pads = vec![pad("U1", "1"), pad("U1", "2"), pad("U1", "4"), pad("U1", "EP"), pad("R1", "1"), pad("R1", "2"), pad("J9", "1")];+ let r = pin_pad(&parts, &pads, &["U1".into(), "R1".into(), "J9".into()]);+ assert_eq!(r["matching"], 1);+ let m = &r["mismatches"][0];+ assert_eq!(m["ref"], "U1");+ assert_eq!(m["pinsWithoutPad"], json!(["3"]));+ assert_eq!(m["padsWithoutPin"], json!(["4"]));+ assert_eq!(r["onSheetsOnly"], json!(["C9"]));+ assert_eq!(r["onBoardOnly"], json!(["J9"]));+ assert_eq!(r["ok"], json!(false));+ }++ #[test]+ fn the_verbs_take_the_companions_bare_or_in_a_reply() {+ let parts = json!({"success": true, "components": [part("U1", &[("1", "VCC", 7), ("2", "GND", 7)]), part("C1", &[("1", "", 4), ("2", "", 4)])]});+ let r = schematic_review(&json!({"sheetParts": parts, "netlist": {"nets": {"3V3": ["U1.1", "C1.1"], "GND": ["U1.2", "C1.2"]}}}));+ assert_eq!((r["success"].clone(), r["ok"].clone(), r["pins"].clone()), (json!(true), json!(true), json!(4)), "{r}");+ assert_eq!(schematic_review(&json!({"netlist": {}}))["errorCode"], "bad_args");+ let b = json!({"components": [{"ref": "U1"}, {"ref": "C1"}], "pads": [{"ref": "U1", "name": "1"}, {"ref": "U1", "name": "2"}, {"ref": "C1", "name": "1"}, {"ref": "C1", "name": "2"}]});+ let p = pin_pad_check(&json!({"board": b, "sheetParts": parts}));+ assert_eq!((p["matching"].clone(), p["ok"].clone()), (json!(2), json!(true)), "{p}");+ }++ #[test]+ fn a_supply_symbol_without_pin_numbers_is_no_part() {+ let parts = vec![part("U1", &[("1", "VCC", 7)]), json!({"designator": "SUPPLY1", "pins": [{"number": "", "name": "VCC", "electrical": "supply"}]})];+ let r = review(&parts, json!({"VCC": ["U1.1"]}).as_object().unwrap());+ assert_eq!(r["pins"], json!(1), "{r}");+ assert!(r["errors"].as_array().unwrap().is_empty());+ }+}
rust/vendor/adom-eda-checks/src/writers/ipc2581.rs+1−1@@ -321,7 +321,7 @@ pub fn ipc2581_text(st: &Value, input: &Value, title: &str, date: &str) -> (Stri } /// The current UTC time as an xsd:dateTime, for the history record when the caller gives no `date`.-fn now() -> String {+pub(crate) fn now() -> String { let s = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0); let (days, rem) = (s.div_euclid(86400), s.rem_euclid(86400)); // civil from days (Howard Hinnant)
tests/run_verb_tests.py+8−2@@ -240,6 +240,8 @@ t("fusion_update_board_from_eagle#guard", "negative", dict(B, brdPath="C:/nonexi t("fusion_update_board_from_eagle", "manual", note="writes a saved design: tested live on fl-sync-test in Default Project / fusion-lane-tests (#125 item 4)") t("fusion_export_dxf#board", "daily", dict(B, layers=["Edge.Cuts", "F.Cu", "F.SilkS"], outputDir=f"{SCRATCH}/dxf"), {"ok": True, "fields": ["files"]}, "board layers to DXF (the standard's export_dxf)") t("fusion_plan_route#standard", "daily", dict(B, net="N$15", layers=["F.Cu", "In1.Cu", "In2.Cu", "B.Cu"], gridMm=0.25, **{"from": "SW1.2", "to": "JP5.P1"}), {"ok": True, "fields": ["found", "routeNet", "planner", "engine"]}, "the shared engine plans on all four of BQ25792's layers (read only)")+t("fusion_length_tune", "daily", dict(B, net="SCL", targetMm=55.6), {"ok": True, "fields": ["remove", "routeNet", "engine"]}, "a plan only (D_P has no room on this board; SCL does)")+t("fusion_route_pair", "manual", note="a plan; BQ25792's D pair is routed, so a pair plan meets its own copper") t("fusion_render_board_3d", "daily", dict(B, side="iso", width=800, height=500, outputPath=f"{SCRATCH}/board-iso.png"), {"ok": True, "fields": ["path"]}, "shows the 3D PCB itself when it is not open, then puts the board tab back") t("fusion_remove_graphics#dry", "daily", dict(B, layer="F.SilkS", kinds=["text"], dryRun=True), {"ok": True, "fields": ["removed", "count"]}, "lists ids, changes nothing") t("fusion_align_parts#dry", "daily", dict(B, refs=["C19", "C20", "C21"], mode="top", dryRun=True), {"ok": True, "fields": ["targets"]})@@ -260,6 +262,8 @@ t("fusion_auto_classes", "daily", dict(B), {"ok": True, "fields": ["proposal", " t("fusion_silk_designators", "daily", dict(B, dryRun=True), {"ok": True, "fields": ["planned", "applyArgs", "engine"]}, "dryRun: a plan only") t("fusion_net_lengths", "daily", dict(B), {"ok": True, "fields": ["nets", "pairs", "engine"]}) t("fusion_assembly_check", "daily", dict(B), {"ok": True, "fields": ["economic", "standard", "engine"]})+t("fusion_review_board", "daily", dict(B), {"ok": True, "fields": ["counts", "engine"]})+t("fusion_board_report", "daily", dict(B, outputDir="C:/Users/olive/adom-tests/report"), {"ok": True, "fields": ["path", "engine"]}, "writes one HTML page; Fusion stores its DRC result in the board") t("fusion_jlcpcb_order", "daily", dict(B, outputDir="C:/Users/olive/adom-tests/jlc-order"), {"ok": True, "fields": ["ready", "issues", "gerberZip", "bomFiles", "orderForm"]}, "writes the order folder; BQ25792 is not ready (13 BOM lines without LCSC)") t("fusion_sch_zoom", "daily", dict(B, refs=["IC1", "C11"]), {"ok": True, "fields": ["found", "rectMm", "image"]}, "SHOW + WINDOW + EXPORT IMAGE WINDOW: nothing changes") t("fusion_export_spice", "daily", dict(B), {"ok": True, "fields": ["path", "elements", "unmapped"]}, "EAGLE's EXPORT SPICENETLIST: BQ25792's ICs have no SPICE mapping")@@ -283,6 +287,8 @@ t("fusion_project_info", "daily", dict(B), {"ok": True, "fields": ["files", "boa 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_schematic_review", "daily", dict(B), {"ok": True, "fields": ["engine"]})+t("fusion_pin_pad_check", "daily", dict(B), {"ok": True, "fields": ["engine"]}) 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})@@ -510,8 +516,8 @@ _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_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_estimate_cost": "routing", "fusion_read_board": "routing", "fusion_diff_pairs": "routing", "fusion_auto_classes": "routing", "fusion_silk_designators": "routing", "fusion_net_lengths": "routing", "fusion_assembly_check": "routing", "fusion_jlcpcb_order": "exports", "fusion_sch_place_part": "routing", "fusion_export_spice": "routing", "fusion_sch_zoom": "routing", "fusion_simulate": "routing", "fusion_sch_connect": "routing", "fusion_sch_swap_part": "routing", "fusion_add_rule": "routing", "fusion_height_check": "routing", "fusion_placement_check": "routing", "fusion_find_space": "routing", "fusion_check_orientation": "routing", "fusion_check_clearance": "routing", "fusion_place_decoupling": "routing", "fusion_plan_fanout": "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_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_schematic_review": "routing", "fusion_pin_pad_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_estimate_cost": "routing", "fusion_read_board": "routing", "fusion_diff_pairs": "routing", "fusion_auto_classes": "routing", "fusion_silk_designators": "routing", "fusion_net_lengths": "routing", "fusion_assembly_check": "routing", "fusion_review_board": "routing", "fusion_board_report": "exports", "fusion_jlcpcb_order": "exports", "fusion_sch_place_part": "routing", "fusion_export_spice": "routing", "fusion_sch_zoom": "routing", "fusion_simulate": "routing", "fusion_sch_connect": "routing", "fusion_sch_swap_part": "routing", "fusion_add_rule": "routing", "fusion_height_check": "routing", "fusion_placement_check": "routing", "fusion_find_space": "routing", "fusion_check_orientation": "routing", "fusion_check_clearance": "routing", "fusion_place_decoupling": "routing", "fusion_plan_fanout": "routing", "fusion_route_pair": "routing", "fusion_length_tune": "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", "fusion_create_library": "cad-3d-bom", "fusion_install_library_bundle": "cad-3d-bom",