← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.13.5\ No newline at end of file+1.13.6\ No newline at end of file
CHANGELOG.md+15
@@ -3,6 +3,21 @@ 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.6 (2026-10-28)++Engine 0.10.1 (standard 0.13.1) and three more of the standard's verbs. 306 verbs.++- New:+  - `fusion_read_board`: the live board in one round trip, as sections (components, nets and classes, layers, copper,+    rules, occupancy, outline), each list capped at `limit`;+  - `fusion_add_rule`: a scoped width, clearance or via-drill rule as an EAGLE net class. A net scope gets its own+    class (`Adom_<name>`), each net joined with one pick on its wire; read back, undone on a mismatch; `remove` puts+    the nets back in the default class;+  - `fusion_estimate_cost`: the board's quote numbers and a rough price table, from the shared engine.+- Engine 0.10.1: `fusion_plan_route` checks a through via on every copper layer, names the pours a route crosses+  (`crossesPours`) and can keep out of them (`avoidPours`); `fusion_check_clearance` lists stacked parts apart;+  `fusion_place_decoupling` takes supply nets only and leaves room for the IC's fanout.+ ## 1.13.5 (2026-10-28)  Routing and placement from the standard's shared engine (adom-eda-checks 0.8.0, standard 0.11.0), and `fusion_route_net` on
bridge.json+8−2
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.13.5",+  "version": "1.13.6",   "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.",@@ -24,6 +24,7 @@     "fusion_account",     "fusion_action_ledger",     "fusion_activate_document",+    "fusion_add_rule",     "fusion_addin_status",     "fusion_alerts",     "fusion_align_parts",@@ -105,6 +106,7 @@     "fusion_electron_select",     "fusion_electron_zoom",     "fusion_electronics_query",+    "fusion_estimate_cost",     "fusion_execute_text_command",     "fusion_export_3mf",     "fusion_export_board_image",@@ -220,6 +222,7 @@     "fusion_project_rename",     "fusion_project_snapshot",     "fusion_prove_result",+    "fusion_read_board",     "fusion_read_footprint",     "fusion_read_rules",     "fusion_read_sheet_file",@@ -495,7 +498,10 @@       "placement_check": 150,       "check_clearance": 150,       "place_decoupling": 150,-      "plan_fanout": 150+      "plan_fanout": 150,+      "estimate_cost": 150,+      "read_board": 120,+      "add_rule": 120     },     "prefixes": [       {
contracts/describe.json+80
@@ -55,6 +55,37 @@       "summary": "Make a named open document the ACTIVE one before driving it. EDIT and most modelling verbs act on whatever is active, so in a batch with several documents open this is how you stop a verb landing in the wrong file (page issue #47). Prefer passing expectDocument on the verb itself, which fails closed instead of merely re-targeting. An electronics design's schematic, board and 3D view are separate tabs with ONE name (type unknown): the reply says so in sameNameDocuments, and fusion_show_schematic / fusion_show_2d_board / fusion_show_3d_board {documentName} reach a specific view.",       "timeoutSeconds": 60     },+    {+      "example": {+        "gap": 0.3,+        "kind": "clearance",+        "name": "SW",+        "scope1": "InNet('SW1')"+      },+      "input": {+        "drill": "mm (via)",+        "expectDocument": "required: the board's name",+        "gap": "mm (clearance)",+        "kind": "width|clearance|via",+        "min": "mm (width)",+        "name": "required: the class is Adom_<name> for a net scope",+        "remove": "bool: the Adom_<name> class's nets back to the default class, the class zeroed",+        "scope1": "InNet('SW') | InNetClass('pwr') (nets / netClass also work)"+      },+      "longRunning": false,+      "name": "fusion_add_rule",+      "output": {+        "class": "{name, number, widthMm, drillMm, clearances, nets}",+        "command": "str",+        "joined": "[net]",+        "name": "the class",+        "released": "[net] (remove)",+        "revision": "str"+      },+      "statusVerb": null,+      "summary": "The standard's add_rule as EAGLE net classes, the board's scoped rules: kind width (min), clearance (gap, to every class and within the class) or via (drill). scope1 InNetClass('X') edits that class; InNet('A') (or nets) puts the nets in a class of their own, Adom_<name>, each joined with one pick on its wire. Read back from the board; every change undone on a mismatch. Length, matched, diffpair and polyconnect rules have no class equivalent and are refused.",+      "timeoutSeconds": 120+    },     {       "example": {},       "input": {},@@ -1759,6 +1790,33 @@       "summary": "TYPED electronics read model: rows for one of Autodesk's 49 electronics entity classes (Element, Signal, Net, Layer, Via, Smd, Wire, Pad, Part, Instance, Pin, Sheet, Package, Library, Error, Variant...) over the ACTIVE electronics document, with filters, field projection and pagination. Served by Autodesk's own MCP electronics_read when its server answers (fusion_mcp_enable turns it on), else by the board_info geometry export for Board/Element/Library/Package/Layer/Signal/Wire/Error (coordinates in mm). `source` names which. Replaces reading board_info XML by hand.",       "timeoutSeconds": 120     },+    {+      "example": {+        "assembly": true,+        "quantities": [+          5,+          30+        ]+      },+      "input": {+        "assembly": "bool, default false: add an assembly estimate",+        "basicParts": "[ref] parts on the fab's basic list",+        "expectDocument": "required: the board's name",+        "quantities": "[5, 10, 30, 100] (default)"+      },+      "longRunning": false,+      "name": "fusion_estimate_cost",+      "output": {+        "assumptions": "[str]",+        "board": "{sizeMm, layers, thicknessMm, minTrackMm, minGapMm, minDrillMm, holes, smdParts, thtParts, ...}",+        "engine": "str",+        "prices": "[{quantity, board, assembly, total, currency}]",+        "source": "{name, url, read}"+      },+      "statusVerb": null,+      "summary": "The board's numbers a fab quote needs (size, layers, thickness, smallest track, gap and drill, holes, SMD and through-hole parts and joints, unique parts) and a rough price table at the quantities asked, every assumption stated and the price source named with the date it was read: the shared checks engine on the live board.",+      "timeoutSeconds": 150+    },     {       "example": {         "command": "Electron.ImmediateRepaint /full"@@ -18090,6 +18148,28 @@       "summary": "POLL a background run started by fusion_create_library or the detached fusion_build_library_3d. Returns stage + stageLabel, plan[] with measured per-stage estimates, stagesDone, percent, etaSec, confidence, and evidence[] filling in AS EACH ARTIFACT LANDS (symbol -> footprint -> 3d -> ganged). For a 3D build it carries per-part progress (minting X (3/7)), partsDone/partsTotal and parts[] with each wip_urn. stage:done only means the chain FINISHED - the top-level verdict (proven/not_proven) is the score, and a run that captured zero frames lands stage:failed with blocked_by_dialog (carrying the dialog text) or no_evidence_captured rather than reporting done. Pass inlineFrames:true on the final fetch for base64 thumbnails a browser can render. restarts[] {at, why, kind} lists any restart the chain performed (only with allowRestart). prove.planned / prove.skipped say whether evidence frames were captured and why (AI threads bind only by default).",       "timeoutSeconds": 20     },+    {+      "example": {+        "limit": 20,+        "sections": "components,nets"+      },+      "input": {+        "expectDocument": "required: the board's name",+        "limit": "int: cap each list",+        "sections": "csv of components,nets,layers,copper,rules,occupancy,outline (default all)"+      },+      "longRunning": false,+      "name": "fusion_read_board",+      "output": {+        "counts": "{section.list: n}",+        "data": "{section: {...}}",+        "revision": "str",+        "roundtripMs": "int"+      },+      "statusVerb": null,+      "summary": "The standard's read_board: the live board in one round trip, as sections: components, nets (with net classes), layers (copper and stack-up), copper (tracks, arcs, vias, pads, pours), rules, occupancy (layers in use) and outline. sections picks some (csv); limit caps each list, with the full counts beside it.",+      "timeoutSeconds": 120+    },     {       "example": {},       "input": {
dashboard/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "fusion-dashboard"-version = "1.13.5"+version = "1.13.6" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.13.5"+version = "1.13.6" edition = "2021"  [dependencies]
describe.py+12
@@ -262,6 +262,18 @@ _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_estimate_cost', "The board's numbers a fab quote needs (size, layers, thickness, smallest track, gap and drill, holes, SMD and through-hole parts and joints, unique parts) and a rough price table at the quantities asked, every assumption stated and the price source named with the date it was read: the shared checks engine on the live board.",+     {"expectDocument": "required: the board's name", "quantities": "[5, 10, 30, 100] (default)", "assembly": "bool, default false: add an assembly estimate", "basicParts": "[ref] parts on the fab's basic list"},+     {"board": "{sizeMm, layers, thicknessMm, minTrackMm, minGapMm, minDrillMm, holes, smdParts, thtParts, ...}", "prices": "[{quantity, board, assembly, total, currency}]", "assumptions": "[str]", "source": "{name, url, read}", "engine": "str"},+     150, None, False, {'quantities': [5, 30], 'assembly': True}),+    ('fusion_read_board', "The standard's read_board: the live board in one round trip, as sections: components, nets (with net classes), layers (copper and stack-up), copper (tracks, arcs, vias, pads, pours), rules, occupancy (layers in use) and outline. sections picks some (csv); limit caps each list, with the full counts beside it.",+     {"expectDocument": "required: the board's name", "sections": "csv of components,nets,layers,copper,rules,occupancy,outline (default all)", "limit": "int: cap each list"},+     {"data": "{section: {...}}", "counts": "{section.list: n}", "roundtripMs": "int", "revision": "str"},+     120, None, False, {'sections': 'components,nets', 'limit': 20}),+    ('fusion_add_rule', "The standard's add_rule as EAGLE net classes, the board's scoped rules: kind width (min), clearance (gap, to every class and within the class) or via (drill). scope1 InNetClass('X') edits that class; InNet('A') (or nets) puts the nets in a class of their own, Adom_<name>, each joined with one pick on its wire. Read back from the board; every change undone on a mismatch. Length, matched, diffpair and polyconnect rules have no class equivalent and are refused.",+     {"expectDocument": "required: the board's name", "name": "required: the class is Adom_<name> for a net scope", "kind": "width|clearance|via", "scope1": "InNet('SW') | InNetClass('pwr') (nets / netClass also work)", "min": "mm (width)", "gap": "mm (clearance)", "drill": "mm (via)", "remove": "bool: the Adom_<name> class's nets back to the default class, the class zeroed"},+     {"name": "the class", "class": "{name, number, widthMm, drillMm, clearances, nets}", "joined": "[net]", "released": "[net] (remove)", "command": "str", "revision": "str"},+     120, None, False, {'name': 'SW', 'kind': 'clearance', 'scope1': "InNet('SW1')", 'gap': 0.3}),     ('fusion_height_check', "Will it fit the enclosure (the standard's height_check)? Each part's height from its 3D body (fusion_bodies), the parts over a top or bottom limit or under a lid region, and bodies that collide or come closer than minGapMm: the shared checks engine. Parts without a body are listed in noBody and the answer is complete:false, never a pass for them.",      {"expectDocument": "required: the board's name", "maxTopMm": "height limit above the top side", "maxBottomMm": "below the bottom side", "regions": "[{rect:[x1,y1,x2,y2], maxMm, side?, name?}] lid features", "minGapMm": "default 0: collisions only", "limit": "tallest parts listed, default 25"},      {"ok": "bool", "complete": "bool", "heights": "[{ref, side, heightMm, rect}]", "tallest": "{top, bottom}", "overLimit": "[...]", "regionHits": "[...]", "collisions": "[...]", "tooClose": "[...]", "noBody": "[ref]", "engine": "str"},
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.5"+DASH_VERSION="1.13.6" 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.5",+  "version": "1.13.6",   "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.5",+  "version": "1.13.6",   "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]"   }-}\ No newline at end of file+}
rust/Cargo.lock+6−6
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-eda-checks"-version = "0.8.0"+version = "0.10.1" dependencies = [  "roxmltree",  "serde_json",@@ -19,7 +19,7 @@ dependencies = [  [[package]] name = "adom-fusion-bridge"-version = "1.13.5"+version = "1.13.6" dependencies = [  "adom-eda-checks",  "base64",@@ -299,7 +299,7 @@ dependencies = [  [[package]] name = "fusion-core"-version = "1.13.5"+version = "1.13.6" dependencies = [  "serde",  "serde_json",@@ -310,7 +310,7 @@ dependencies = [  [[package]] name = "fusion-lbr"-version = "1.13.5"+version = "1.13.6" dependencies = [  "regex",  "roxmltree",@@ -322,14 +322,14 @@ dependencies = [  [[package]] name = "fusion-os-mac"-version = "1.13.5"+version = "1.13.6" dependencies = [  "fusion-core", ]  [[package]] name = "fusion-os-win"-version = "1.13.5"+version = "1.13.6" 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.5"+version = "1.13.6" edition = "2021" license = "MIT" 
rust/bridge/src/routing.rs+2−2
@@ -3,7 +3,7 @@ use serde_json::{json, Value}; const SOURCE: &str = concat!(include_str!("routing_script.py"), "\n", include_str!("routing_planner.py"), "\n", include_str!("board_model.py"),     "\nFUSION_DEFAULT_RULES = r'''", include_str!("fusion_default_designrules.xml"), "'''\n");-pub fn owns(cmd: &str) -> bool { matches!(cmd, "routing_state" | "route_net" | "routing_drc" | "move_parts" | "place_vias" | "apply_pours" | "pour_state" | "board_source" | "ripup_nets" | "schematic_run" | "board_bom" | "change_pours" | "schematic_plan" | "board_seed" | "remove_pours" | "layer_stack" | "netlist" | "bodies" | "component_data" | "component_pins" | "list_nets" | "net_connections" | "list_designators" | "layers" | "copper_layers" | "layers_in_use" | "read_rules" | "rules_by_kind" | "silk_state" | "net_classes" | "run_drc" | "repour" | "zoom_to" | "export_bom" | "save_document" | "save_to_cloud" | "apply_silkscreen" | "set_layer_visibility" | "select_net" | "set_track_width" | "remove_items" | "set_outline" | "board_shape" | "board_outline" | "run_erc" | "autoroute" | "update_board_from_eagle" | "board_dxf" | "edit_parts" | "align_parts" | "remove_graphics" | "remove_parts" | "check_inputs" | "export_odb" | "export_ipc356" | "export_gencad" | "export_ipc2581" | "import_svg" | "copy_routing" | "set_layer_count" | "board_info_compat" | "footprint_primitives" | "place_planned_vias" | "board_features") }+pub fn owns(cmd: &str) -> bool { matches!(cmd, "routing_state" | "route_net" | "routing_drc" | "move_parts" | "place_vias" | "apply_pours" | "pour_state" | "board_source" | "ripup_nets" | "schematic_run" | "board_bom" | "change_pours" | "schematic_plan" | "board_seed" | "remove_pours" | "layer_stack" | "netlist" | "bodies" | "component_data" | "component_pins" | "list_nets" | "net_connections" | "list_designators" | "layers" | "copper_layers" | "layers_in_use" | "read_rules" | "rules_by_kind" | "silk_state" | "net_classes" | "run_drc" | "repour" | "zoom_to" | "export_bom" | "save_document" | "save_to_cloud" | "apply_silkscreen" | "set_layer_visibility" | "select_net" | "set_track_width" | "remove_items" | "set_outline" | "board_shape" | "board_outline" | "run_erc" | "autoroute" | "update_board_from_eagle" | "board_dxf" | "edit_parts" | "align_parts" | "remove_graphics" | "remove_parts" | "check_inputs" | "export_odb" | "export_ipc356" | "export_gencad" | "export_ipc2581" | "import_svg" | "copy_routing" | "set_layer_count" | "board_info_compat" | "footprint_primitives" | "place_planned_vias" | "board_features" | "add_rule" | "read_board") } pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {     let mut args_v = args.clone();     if args_v.is_object() { args_v["_bridgeVersion"] = json!(env!("CARGO_PKG_VERSION")); }@@ -32,7 +32,7 @@ pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Val             }             out         }-        None => json!({"success":false,"errorCode":"invalid_routing_response","error":"Fusion did not return a structured routing result", "mutationMayHaveOccurred":matches!(cmd, "route_net" | "move_parts" | "place_vias" | "apply_pours" | "apply_silkscreen" | "set_track_width" | "remove_items" | "set_outline" | "board_shape" | "autoroute" | "update_board_from_eagle" | "edit_parts" | "align_parts" | "remove_graphics" | "remove_parts" | "import_svg" | "copy_routing" | "set_layer_count" | "place_planned_vias" | "board_features")}),+        None => json!({"success":false,"errorCode":"invalid_routing_response","error":"Fusion did not return a structured routing result", "mutationMayHaveOccurred":matches!(cmd, "route_net" | "move_parts" | "place_vias" | "apply_pours" | "apply_silkscreen" | "set_track_width" | "remove_items" | "set_outline" | "board_shape" | "autoroute" | "update_board_from_eagle" | "edit_parts" | "align_parts" | "remove_graphics" | "remove_parts" | "import_svg" | "copy_routing" | "set_layer_count" | "place_planned_vias" | "board_features" | "add_rule")}),     } } 
rust/bridge/src/routing_script.py+163
@@ -1101,6 +1101,167 @@ def _wire_key(x):     return (x['net'], x['layer'], min(a, b), max(a, b), round(x['curve'], 3))  +def read_board(app, root, board, meta, args):+    """The standard's read_board: the board in one round trip, as sections (components, nets,+    layers, copper, rules, occupancy, outline; sections picks some), each list capped at limit+    with its full count beside it."""+    import time as _t+    t0 = _t.time()+    want = {x.strip() for x in str(args.get('sections') or '').split(',') if x.strip()} or \+        {'components', 'nets', 'layers', 'copper', 'rules', 'occupancy', 'outline'}+    limit = int(args.get('limit') or 0)+    model = state(board, meta)+    def cap(v):+        return v[:limit] if limit and isinstance(v, list) else v+    data, counts = {}, {}+    def put(sec, key, v):+        if isinstance(v, list): counts['%s.%s' % (sec, key)] = len(v)+        data.setdefault(sec, {})[key] = cap(v)+    if 'components' in want: put('components', 'components', model.get('components') or [])+    if 'nets' in want:+        put('nets', 'nets', model.get('nets') or []); put('nets', 'classes', rd_net_classes(root, board, {})['classes'])+    if 'layers' in want:+        put('layers', 'copperLayers', model.get('copperLayers') or []); put('layers', 'stack', layer_stack(board))+    if 'copper' in want:+        for k in ('tracks', 'arcs', 'vias', 'pads', 'polygons'): put('copper', k, model.get(k) or [])+    if 'rules' in want: data['rules'] = rd_read_rules(root, board, {})+    if 'occupancy' in want: data['occupancy'] = rd_layers_in_use(root, board, {})+    if 'outline' in want:+        for k in ('outline', 'outlineClosed', 'outlineCutouts'): data.setdefault('outline', {})[k] = model.get(k)+    unknown = sorted(want - {'components', 'nets', 'layers', 'copper', 'rules', 'occupancy', 'outline'})+    return {'success': True, 'mutated': False, **meta, 'data': data, 'counts': counts, 'limit': limit or None,+            'unknownSections': unknown, 'roundtripMs': int((_t.time() - t0) * 1000),+            '_hint': 'Every section from one export of the live board; counts has each list\'s full length when limit cut it.'}+++def _classes_sig(board):+    """Every net class (number, name, width, drill, clearances) and every net's class."""+    cls = tuple(sorted((c.get('number'), c.get('name'), round(float(c.get('width', '0') or 0), 4), round(float(c.get('drill', '0') or 0), 4),+                        tuple(sorted((k.get('class'), round(float(k.get('value')), 4)) for k in c.findall('clearance'))))+                       for c in board.findall('classes/class')))+    nets = tuple(sorted((s_.get('name'), s_.get('class', '0')) for s_ in board.findall('signals/signal')))+    return cls, nets+++def _remove_rule(app, root, board, meta, args):+    """add_rule {remove}: the Adom_<name> class's nets go back to the default class and the class is+    zeroed (EAGLE keeps a class once made; an empty, zeroed one constrains nothing)."""+    cname = ('Adom_' + re.sub(r'[^A-Za-z0-9_]', '_', str(args.get('name') or '').strip()))[:30]+    cls = next((c for c in board.findall('classes/class') if c.get('name') == cname), None)+    if cls is None: fail('no_such_rule', 'No class named %s on this board' % cname)+    num = cls.get('number')+    nets, pads, wires, _ = objects(board)+    members = sorted(n for n, s_ in nets.items() if s_.get('class', '0') == num)+    before = _classes_sig(board); n_cmd = 0+    zero = ' '.join('%s:0mm' % k.get('class') for k in cls.findall('clearance') if k.get('class') != num)+    app.executeTextCommand("Electron.run CLASS %s '%s' 0mm 0mm 0mm %s;" % (num, cname, zero)); n_cmd += 1+    visible = _visible_layers(root)+    try:+        for n in members:+            x = next((w for w in wires if w['net'] == n and not w['curve']), None)+            if x is None: continue+            others = [o for o in wires if o['layer'] == x['layer'] and _wire_key(o) != _wire_key(x)]+            px, py = _pick_points(x, others, [p['at'] for p in pads.values() if x['layer'] in p['layers']])[0]+            app.executeTextCommand('Electron.run DISPLAY NONE %d;' % x['layer'])+            app.executeTextCommand('Electron.run CHANGE CLASS 0 (%.6fmm %.6fmm);' % (px, py)); n_cmd += 1+    finally:+        _restore_display(app, visible)+    _, after, meta2 = snapshot(app, args)+    left = sorted(n for n, c in _classes_sig(after)[1] if c == num)+    if left:+        for _ in range(n_cmd + 1):+            app.executeTextCommand('Electron.run UNDO;')+            _, back, _m = snapshot(app, args)+            if _classes_sig(back) == before: break+        fail('readback_mismatch', '%s still in %s after the removal; every change was undone' % (', '.join(left), cname))+    return {'success': True, 'mutated': True, **meta2, 'name': cname, 'removed': True, 'released': members,+            '_hint': 'The nets are back in the default class; the zeroed class stays on the board (EAGLE keeps a class once made) and constrains nothing.'}+++def add_rule(app, root, board, meta, args):+    """The standard's add_rule as EAGLE net classes, the board's only scoped rules: a class holds a+    track width, a drill and clearances (to each class and inside itself). scope1 InNetClass('X')+    edits that class; InNet('A') (or nets) puts the nets in a class of their own, 'Adom <name>',+    each joined with one CHANGE CLASS pick on its wire (only that wire's layer displayed). Read back+    from the board, every change undone on a mismatch."""+    if args.get('remove'):+        return _remove_rule(app, root, board, meta, args)+    kind = str(args.get('kind') or '').lower()+    if kind not in ('width', 'clearance', 'via'):+        fail('not_supported', "kind %r: an EAGLE net class holds a width, a drill and clearances (kind width|clearance|via); length, matched, diffpair and polyconnect have no class equivalent in Fusion" % kind)+    name = str(args.get('name') or '').strip()+    if not name: fail('bad_args', 'name is required (the class is named Adom <name>)')+    scope = str(args.get('scope1') or '').strip()+    m_net = re.findall(r"InNet\('([^']+)'\)", scope); m_cls = re.match(r"InNetClass\('([^']+)'\)", scope)+    nets_arg = list(args.get('nets') or []) + m_net+    nets, pads, wires, _ = objects(board)+    classes = {c.get('number'): c for c in board.findall('classes/class')}+    if m_cls or args.get('netClass'):+        want = (m_cls.group(1) if m_cls else str(args['netClass'])).lower()+        target = next((c for c in classes.values() if c.get('name', '').lower() == want), None)+        if target is None: fail('no_such_class', 'No net class named %s (fusion_net_classes lists them)' % want)+        num, cname, join = target.get('number'), target.get('name'), []+    elif nets_arg:+        unknown = [n for n in nets_arg if n not in nets]+        if unknown: fail('unknown_net', 'No net named %s' % ', '.join(unknown))+        cname = ('Adom_' + re.sub(r'[^A-Za-z0-9_]', '_', name))[:30]+        target = next((c for c in classes.values() if c.get('name') == cname), None)+        if target is not None: num = target.get('number')+        else:+            free = [str(i) for i in range(1, 8) if str(i) not in classes]+            if not free: fail('no_free_class', 'EAGLE boards have 8 net classes (0..7) and all are used: scope the rule to an existing class with InNetClass')+            num = free[0]+        join = [n for n in nets_arg if nets[n].get('class', '0') != num]+        bare = [n for n in join if not any(w['net'] == n and not w['curve'] for w in wires)]+        if bare: fail('no_track', '%s has no straight track to pick: route it first, or set its class in the schematic' % ', '.join(bare))+    else:+        fail('bad_args', "scope1 is InNet('A'), InNet('A') or InNet('B'), or InNetClass('X') (nets / netClass also work)")+    cur = classes.get(num)+    width = float(cur.get('width', '0') or 0) if cur is not None else 0.0+    drill = float(cur.get('drill', '0') or 0) if cur is not None else 0.0+    clear = {k.get('class'): float(k.get('value')) for k in cur.findall('clearance')} if cur is not None else {}+    if kind == 'width':+        width = number(args.get('min', args.get('preferred', args.get('width'))), True)+    elif kind == 'via':+        drill = number(args.get('drill', args.get('minDrill')), True)+    else:+        gap = number(args.get('gap'), True)+        for k in set(classes) | {num}: clear[k] = gap+    own = clear.pop(num, 0.0)+    tokens = ' '.join('%s:%.6fmm' % (k, v) for k, v in sorted(clear.items(), key=lambda kv: int(kv[0])))+    cmd = "CLASS %s '%s' %.6fmm %.6fmm %.6fmm %s;" % (num, cname, width, own, drill, tokens)+    before = _classes_sig(board); n_cmd = 0+    app.executeTextCommand('Electron.run ' + cmd); n_cmd += 1+    visible = _visible_layers(root)+    try:+        for n in join:+            x = next(w for w in wires if w['net'] == n and not w['curve'])+            others = [o for o in wires if o['layer'] == x['layer'] and _wire_key(o) != _wire_key(x)]+            px, py = _pick_points(x, others, [p['at'] for p in pads.values() if x['layer'] in p['layers']])[0]+            app.executeTextCommand('Electron.run DISPLAY NONE %d;' % x['layer'])+            app.executeTextCommand("Electron.run CHANGE CLASS %s (%.6fmm %.6fmm);" % (num, px, py)); n_cmd += 1+    finally:+        _restore_display(app, visible)+    _, after, meta2 = snapshot(app, args)+    got = {c.get('number'): c for c in after.findall('classes/class')}.get(num)+    sig_cls, sig_nets = _classes_sig(after)+    nets_after = dict(sig_nets); nets_before = dict(before[1])+    moved = sorted(n for n in nets_after if nets_after[n] != nets_before.get(n))+    ok = got is not None and abs(float(got.get('width', '0') or 0) - width) < 1e-4 and abs(float(got.get('drill', '0') or 0) - drill) < 1e-4 \+        and set(moved) == set(join) and all(nets_after[n] == num for n in join) \+        and _classes_sig(after)[0] != () and dict((k.get('class'), round(float(k.get('value')), 4)) for k in got.findall('clearance')).get(num, 0.0) == round(own, 4)+    if not ok:+        for _ in range(n_cmd + 1):+            app.executeTextCommand('Electron.run UNDO;')+            _, back, _m = snapshot(app, args)+            if _classes_sig(back) == before: break+        fail('readback_mismatch', 'The board\'s classes after %s were not as asked (moved nets: %s); every change was undone' % (cmd, ', '.join(moved) or 'none'))+    row = rd_net_classes(None, after, {})+    cls_row = next((c for c in row['classes'] if str(c['number']) == num), None)+    return {'success': True, 'mutated': True, **meta2, 'name': cname, 'class': cls_row, 'joined': join, 'command': cmd,+            '_hint': 'An EAGLE net class: Fusion\'s DRC holds its nets to this width, drill and clearance. fusion_net_classes lists every class; fusion_run_drc checks the board.'}++ def set_track_width(app, root, board, meta, args):     """Change the width of existing tracks on nets (or a net class), optionally one layer. Each     wire is picked with only its layer displayed, at the point along it farthest from other copper,@@ -3683,6 +3844,8 @@ def handle(app,args,action):         if action=='set_layer_visibility': return set_layer_visibility(app,root,board,meta,args)         if action=='select_net': return select_net(app,root,board,meta,args)         if action=='set_track_width': return set_track_width(app,root,board,meta,args)+        if action=='add_rule': return add_rule(app,root,board,meta,args)+        if action=='read_board': return read_board(app,root,board,meta,args)         if action=='remove_items': return remove_items(app,root,board,meta,args)         if action=='board_outline': return board_outline_read(app,root,board,meta,args)         if action in ('set_outline','board_shape'):
rust/vendor/adom-eda-checks/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.8.0"+version = "0.10.1" 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+6−4
@@ -11,17 +11,18 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives.  | Check | Reads | What it answers | |---|---|---|-| `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. From the KiCad bridge's place_tools |+| `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 | | `copper_balance` | board, stack, pours | copper per layer and how evenly mirrored layers match (bow and twist) | | `return_path` | board, stack, pours | signal tracks running over a gap in the neighbouring layer's reference copper, with a pour plan that closes each gap (`apply_pours`) |+| `estimate_cost` | board, stack?, rules?, prices? | the numbers a fab quote needs (size, copper layers, thickness from the stack, smallest track, gap from the clearance rule, smallest drill, holes, SMD and through-hole parts and joints, unique parts, assembled sides) and, with a `prices` record, a rough table at `quantities` (default 5, 10, 30, 100), with assembly when asked, every assumption stated and the record named in `source`. The engine carries no rates: without `prices` the reply is the numbers only. From the KiCad bridge's fab.rs | | `fab_check` | board, stack?, rules?, silk?, fab? | the board's smallest features, compared with a fab's limits from adom/pcb-design-rules; each finding names the fab's page and the date it was read | | `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 |-| `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. `moveArgs` is move_parts' arguments; `validation` is placement_check on the moved board. From the KiCad bridge's place_tools |+| `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 | | `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 anywhere on the board. 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 clearance from other nets' copper (pads by their real outline), inside the outline, no via in a pad. `routeNet` is route_net's arguments, unchanged; pours are not obstacles (repour after) |+| `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 | | `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 | @@ -62,6 +63,7 @@ Every check takes one JSON object: | `rules` | the board's rules, `<prefix>_read_rules`: a clearance rule as `{kind: "clearance", clearanceMm}` (a `value` with its unit, `"0.1mm"`, works too). Only copper-to-copper clearance counts: a rule whose kind or name names the board edge, mask, silk, paste, holes, a keepout or restrict area, the courtyard or the same net is skipped | | `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 | | `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`.@@ -93,7 +95,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.8.0"`), so a bridge reports the engine it runs without writing the version by hand.+(`"adom-eda-checks 0.10.1"`), 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.11.0-- Standard commit: 4ad294c79b924d942e71e296b573d65f3fe7b86d-- Crate version: 0.8.0+- Standard version: 0.13.1+- Standard commit: 8c9e7d8d3d09f5bc6c2dce8aad2544c2e003d0c5+- Crate version: 0.10.1 - 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/cost.rsadded+182
@@ -0,0 +1,182 @@+//! The numbers a fab quote needs, from the board model (size, copper layers, thickness, smallest track, gap and drill,+//! holes, parts and joints), and a rough price table at the quantities asked. From the KiCad bridge's fab.rs, with one+//! change: the engine has no rates of its own. Prices come in as a record read from the fab's own page with its date+//! (`prices`), and without one the reply is the numbers only. Pure.+use serde_json::{json, Value};+use std::collections::BTreeSet;++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r2(v: f64) -> f64 { (v * 100.0).round() / 100.0 }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }++/// The board's numbers. `layers` from the stack when given, else the copper layers the board uses; the thickness from+/// the stack only.+pub fn board_numbers(st: &Value, stack: Option<&Value>, rules: &[Value]) -> Value {+    let (w, h) = crate::geom::outline_bbox(st).map(|(x0, y0, x1, y1)| (x1 - x0, y1 - y0)).unwrap_or((0.0, 0.0));+    let mut used: BTreeSet<String> = BTreeSet::new();+    for k in ["tracks", "arcs", "pads", "polygons"] {+        for x in st[k].as_array().into_iter().flatten() {+            if let Some(l) = x["layer"].as_str().filter(|l| l.ends_with(".Cu") && *l != "*.Cu") { used.insert(l.to_string()); }+        }+    }+    let layers = stack.and_then(|s| s["copperLayers"].as_u64()).unwrap_or((used.len() as u64).max(2));+    let thickness = stack.and_then(|s| s["totalUm"].as_f64()).filter(|t| *t > 0.0).map(|t| r3(t / 1000.0));+    let m = crate::fab::measure(st);+    let mm = |k: &str| m.get(k).and_then(|v| v["mm"].as_f64());+    let min_drill = [mm("viaDrill"), mm("padHole")].into_iter().flatten().fold(None, |a: Option<f64>, b| Some(a.map(|x| x.min(b)).unwrap_or(b)));+    let pads = st["pads"].as_array().cloned().unwrap_or_default();+    let vias = st["vias"].as_array().map(|a| a.len()).unwrap_or(0);+    let drilled = pads.iter().filter(|p| f(&p["drill"]) > 0.0).count();+    // a part is a component with pads (a logo or a drawing has none); through-hole when any pad is drilled+    let (mut smd, mut tht, mut smd_j, mut tht_j, mut sides, mut unique) = (0, 0, 0, 0, BTreeSet::new(), BTreeSet::new());+    for c in st["components"].as_array().into_iter().flatten() {+        let r = c["ref"].as_str().unwrap_or("");+        let own: Vec<&Value> = pads.iter().filter(|p| p["ref"].as_str() == Some(r)).collect();+        if own.is_empty() { continue; }+        let holes = own.iter().filter(|p| f(&p["drill"]) > 0.0).count();+        if holes > 0 { tht += 1; } else { smd += 1; }+        smd_j += own.len() - holes;+        tht_j += holes;+        sides.insert(c["side"].as_str().unwrap_or("top").to_string());+        unique.insert(c["pattern"].as_str().unwrap_or(r).to_string());+    }+    json!({"widthMm": r3(w), "heightMm": r3(h), "areaMm2": r2(w * h), "layers": layers, "thicknessMm": thickness,+        "minTrackMm": mm("track"), "minGapMm": crate::fab::min_clearance(rules), "minDrillMm": min_drill,+        "holes": vias + drilled, "vias": vias, "drilledPads": drilled, "smdParts": smd, "thtParts": tht, "totalParts": smd + tht,+        "smdJoints": smd_j, "thtJoints": tht_j, "uniqueParts": unique.len(), "assemblySides": sides.into_iter().collect::<Vec<_>>()})+}++/// The price table from a price record: `{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 is the one for the fewest copper layers that still+/// covers the board.+pub fn price_table(n: &Value, prices: &Value, quantities: &[u64], assembly: bool, basic_parts: Option<u64>) -> Result<Value, String> {+    let layers = n["layers"].as_u64().unwrap_or(2);+    let rows = prices["pcb"].as_array().ok_or("the price record has no pcb rows")?;+    let row = rows.iter().filter(|r| r["layers"].as_u64().map(|l| l >= layers).unwrap_or(false)).min_by_key(|r| r["layers"].as_u64())+        .ok_or_else(|| format!("the price record has no row for {layers} copper layers"))?;+    let (setup, per_m2) = (f(&row["setup"]), f(&row["perM2"]));+    let (w, h) = (f(&n["widthMm"]), f(&n["heightMm"]));+    let area_m2 = w * h / 1_000_000.0;+    let promo = row.get("promo").filter(|p| {+        let max = (f(&p["maxMm"][0]), f(&p["maxMm"][1]));+        (w <= max.0 && h <= max.1) || (w <= max.1 && h <= max.0)+    });+    let factor = match (prices.get("thickness"), n["thicknessMm"].as_f64()) {+        (Some(t), Some(mm)) if (mm - f(&t["standardMm"])).abs() > 0.05 => f(&t["factor"]).max(1.0),+        _ => 1.0,+    };+    let a = &prices["assembly"];+    if assembly && a.is_null() { return Err("assembly was asked for, but the price record has no assembly rates".into()); }+    let sides = n["assemblySides"].as_array().map(|s| s.len()).unwrap_or(1).max(1) as f64;+    let unique = n["uniqueParts"].as_u64().unwrap_or(0);+    let extended = basic_parts.map(|b| unique.saturating_sub(b)).unwrap_or(unique);+    let mut out = Vec::new();+    for &q in quantities {+        let qf = q as f64;+        // the promotional price for the smallest quantity it covers+        let promo_price = promo.and_then(|p| p["prices"].as_array().and_then(|ps| ps.iter().filter(|e| e[0].as_u64().map(|x| x >= q).unwrap_or(false)).min_by_key(|e| e[0].as_u64()).map(|e| f(&e[1]))));+        let pcb = r2(promo_price.unwrap_or(setup + per_m2 * area_m2 * qf) * factor);+        let mut r = json!({"quantity": q, "board": pcb});+        let mut total = pcb;+        if assembly && n["totalParts"].as_u64().unwrap_or(0) > 0 {+            let asm = r2((f(&a["setupPerSide"]) + f(&a["stencilPerSide"])) * sides + (f(&n["smdJoints"]) * f(&a["perSmdJoint"]) + f(&n["thtJoints"]) * f(&a["perThtJoint"])) * qf + f(&a["perExtendedPart"]) * extended as f64);+            r["assembly"] = json!(asm);+            total += asm;+        }+        r["total"] = json!(r2(total));+        r["perBoard"] = json!(r2(total / qf.max(1.0)));+        r["currency"] = prices["currency"].clone();+        out.push(r);+    }+    let mut assumptions = vec![format!("Board: {setup} setup plus {per_m2} per square metre of boards, from the {}-layer row.", row["layers"])];+    if let Some(p) = promo { assumptions.push(format!("Boards up to {} x {} mm take the promotional prices {} where they cover the quantity.", p["maxMm"][0], p["maxMm"][1], p["prices"])); }+    if factor > 1.0 { assumptions.push(format!("A thickness other than {} mm multiplies the board price by {factor}.", prices["thickness"]["standardMm"])); }+    if assembly {+        assumptions.push(format!("Assembly: setup and stencil per assembled side ({sides} side(s)), per joint, and a loading fee per extended part ({extended} of {unique} unique parts counted as extended{}).", if basic_parts.is_some() { "" } else { "; pass basicParts to count the basic ones" }));+        assumptions.push("Component cost is not included: price the BOM with adom-parts-search.".into());+    }+    for e in prices["excludes"].as_array().into_iter().flatten() { if let Some(s) = e.as_str() { assumptions.push(format!("Not included: {s}.")); } }+    Ok(json!({"prices": out, "assumptions": assumptions}))+}++/// estimate_cost: input {board, stack?, rules?, prices?, args: {quantities?, assembly?, basicParts?}}.+pub fn estimate_cost(input: &Value) -> Value {+    let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+    let args = &input["args"];+    let quantities: Vec<u64> = args["quantities"].as_array().map(|a| a.iter().filter_map(|v| v.as_u64()).filter(|q| *q > 0).collect()).unwrap_or_else(|| vec![5, 10, 30, 100]);+    if quantities.is_empty() { return crate::input::refuse("bad_args", "quantities are whole numbers above 0, e.g. [5, 10, 30, 100]"); }+    let assembly = args["assembly"].as_bool().unwrap_or(false);+    let stack = crate::input::stack(input);+    let n = board_numbers(&st, stack.as_ref(), &crate::input::rules(input));+    let mut notes = Vec::new();+    if n["thicknessMm"].is_null() { notes.push("thicknessMm: no stack in the input, so the thickness is unknown and no thickness surcharge is applied"); }+    if n["minGapMm"].is_null() { notes.push("minGapMm: the board's clearance rule, and no rules came in the input"); }+    let Some(prices) = crate::input::part(input, "prices") else {+        return json!({"success": true, "board": n, "prices": Value::Null, "source": Value::Null, "notes": notes, "quantities": quantities,+            "_hint": "The board's numbers only: the engine carries no rates. Pass a price record as prices ({vendor, url, read, currency, pcb: [{layers, setup, perM2, promo?}], thickness?, assembly?, excludes?}), read from the fab's own page with the date, and the reply prices these quantities from it."});+    };+    match price_table(&n, &prices, &quantities, assembly, args["basicParts"].as_u64()) {+        Ok(t) => json!({"success": true, "board": n, "prices": t["prices"], "assumptions": t["assumptions"], "notes": notes, "rough": true,+            "source": {"name": prices["vendor"], "url": prices["url"], "read": prices["read"]},+            "_hint": "A rough estimate from the price record named in source, as of its read date: get a real quote from the fab before ordering."}),+        Err(e) => crate::input::refuse("bad_prices", &e),+    }+}++#[cfg(test)]+mod tests {+    use super::*;++    fn board() -> Value {+        json!({"outline": [[0, 0], [50, 0], [50, 40], [0, 40]],+            "tracks": [{"layer": "F.Cu", "width": 0.2, "net": "A", "start": {"x": 1, "y": 1}, "end": {"x": 5, "y": 1}}, {"layer": "B.Cu", "width": 0.15, "net": "A", "start": {"x": 1, "y": 2}, "end": {"x": 5, "y": 2}}],+            "vias": [{"drill": 0.3, "size": 0.6, "net": "A", "position": {"x": 5, "y": 1.5}}],+            "components": [{"ref": "U1", "side": "top", "pattern": "SOIC8"}, {"ref": "J1", "side": "top", "pattern": "HDR2"}, {"ref": "C1", "side": "bottom", "pattern": "0603"}, {"ref": "LOGO", "side": "top", "pattern": "LOGO"}],+            "pads": [{"ref": "U1", "drill": 0, "layer": "F.Cu"}, {"ref": "U1", "drill": 0, "layer": "F.Cu"}, {"ref": "J1", "drill": 1.0, "layer": "*.Cu", "x": 10, "y": 10, "sizeX": 1.7, "sizeY": 1.7},+                     {"ref": "J1", "drill": 1.0, "layer": "*.Cu", "x": 12.54, "y": 10, "sizeX": 1.7, "sizeY": 1.7}, {"ref": "C1", "drill": 0, "layer": "B.Cu"}, {"ref": "C1", "drill": 0, "layer": "B.Cu"}]})+    }++    fn record() -> Value {+        json!({"vendor": "Example Fab", "url": "https://fab.example/prices", "read": "2026-10-29", "currency": "USD",+            "pcb": [{"layers": 2, "setup": 2.0, "perM2": 30.0, "promo": {"maxMm": [100, 100], "prices": [[5, 2.0], [10, 5.0]]}}, {"layers": 4, "setup": 8.0, "perM2": 90.0}],+            "thickness": {"standardMm": 1.6, "factor": 1.15},+            "assembly": {"setupPerSide": 8.0, "stencilPerSide": 7.0, "perSmdJoint": 0.0017, "perThtJoint": 0.0173, "perExtendedPart": 3.0},+            "excludes": ["shipping", "tax"]})+    }++    #[test]+    fn the_numbers_come_from_the_board() {+        let n = board_numbers(&board(), None, &[json!({"kind": "clearance", "clearanceMm": 0.15})]);+        assert_eq!((n["widthMm"].as_f64(), n["heightMm"].as_f64(), n["layers"].as_u64()), (Some(50.0), Some(40.0), Some(2)));+        assert_eq!((n["minTrackMm"].as_f64(), n["minGapMm"].as_f64(), n["minDrillMm"].as_f64()), (Some(0.15), Some(0.15), Some(0.3)));+        // the logo has no pads: three parts, J1 through-hole, two sides+        assert_eq!((n["smdParts"].as_u64(), n["thtParts"].as_u64(), n["smdJoints"].as_u64(), n["thtJoints"].as_u64(), n["holes"].as_u64()), (Some(2), Some(1), Some(4), Some(2), Some(3)));+        assert_eq!(n["assemblySides"], json!(["bottom", "top"]));+        assert!(n["thicknessMm"].is_null());+        assert_eq!(board_numbers(&board(), Some(&json!({"copperLayers": 4, "totalUm": 1600})), &[])["thicknessMm"], json!(1.6));+    }++    #[test]+    fn without_a_price_record_it_answers_the_numbers_only() {+        let r = estimate_cost(&json!({"board": board(), "args": {}}));+        assert_eq!(r["success"], json!(true));+        assert!(r["prices"].is_null() && r["source"].is_null());+        assert_eq!(r["board"]["layers"], json!(2));+    }++    #[test]+    fn prices_come_from_the_record_and_name_it() {+        let r = estimate_cost(&json!({"board": board(), "prices": record(), "args": {"quantities": [5, 30], "assembly": true}}));+        assert_eq!(r["source"], json!({"name": "Example Fab", "url": "https://fab.example/prices", "read": "2026-10-29"}));+        // 5 boards: the promotional 2.00; 30: 2 + 30 * 0.002 m2 * 30 = 3.80+        assert_eq!((r["prices"][0]["board"].as_f64(), r["prices"][1]["board"].as_f64()), (Some(2.0), Some(3.8)), "{r}");+        // assembly at 5: (8 + 7) * 2 sides + (4 * 0.0017 + 2 * 0.0173) * 5 + 3 * 3 unique = 39.21+        assert_eq!(r["prices"][0]["assembly"].as_f64(), Some(39.21), "{r}");+        assert!(r["assumptions"].as_array().unwrap().iter().any(|a| a == "Not included: shipping."));+        // a 4-layer stack takes the 4-layer row; 6 layers has none+        let four = estimate_cost(&json!({"board": board(), "stack": {"copperLayers": 4, "totalUm": 1200}, "prices": record(), "args": {"quantities": [10]}}));+        assert_eq!(four["prices"][0]["board"].as_f64(), Some(r2((8.0 + 90.0 * 0.002 * 10.0) * 1.15)), "{four}");+        assert_eq!(estimate_cost(&json!({"board": board(), "stack": {"copperLayers": 6}, "prices": record(), "args": {}}))["errorCode"], "bad_prices");+    }+}
rust/vendor/adom-eda-checks/src/fab.rs+28−10
@@ -135,27 +135,45 @@ fn length_mm(text: &str) -> Option<f64> { /// A clearance rule that names another object than copper of another net: the board edge, solder mask, silkscreen, /// holes, keepouts, the courtyard, or the same net (Fusion's Solder-Mask Clearance is a mask expansion, often 0.05 mm). const NOT_COPPER_CLEARANCE: &[&str] = &["dimension", "edge", "board", "mask", "silk", "paste", "hole", "restrict", "keepout", "keep-out",-                                        "courtyard", "component", "body", "same net", "samenet", "same-net"];+                                        "courtyard", "component", "body", "plane", "same net", "samenet", "same-net"];  /// The smallest copper-to-copper clearance among the board's enabled rules, in mm. A rule gives it as a number /// (`clearanceMm`, `gapMm` or `minMm`), as a `value` with its unit ("0.1mm": Fusion's form), or in a descriptor the tool /// wrote ("Gap=10mil", "Gap=0.2mm": Altium's form). Clearance rules about anything but copper of another net are left out. pub fn min_clearance(rules: &[Value]) -> Option<f64> {+    clearances(rules).into_iter().map(|c| c.0).reduce(f64::min).map(r3)+}++/// The copper clearance a route or a via must keep: the strictest board-wide copper rule (a pad-to-via 0.15 beside a+/// wire-to-wire 0.1 means 0.15 near a pad), so a plan passes the board's DRC. A rule scoped to some nets only (a high+/// voltage class) is left out unless no board-wide rule exists, then the smallest scoped one counts (fusion-lane #21).+pub fn routing_clearance(rules: &[Value]) -> Option<f64> {+    let all = clearances(rules);+    all.iter().filter(|c| c.1).map(|c| c.0).reduce(f64::max).or_else(|| all.iter().map(|c| c.0).reduce(f64::min)).map(r3)+}++/// Every enabled copper-to-copper clearance rule as (mm, board-wide).+fn clearances(rules: &[Value]) -> Vec<(f64, bool)> {     let text = |r: &Value, k: &str| r[k].as_str().map(|s| s.to_lowercase()).unwrap_or_default();+    // board-wide: no scope, or Altium's All on both sides+    let wide = |r: &Value| ["scope1", "scope2", "scope"].iter().all(|k| r[*k].as_str().map(|s| s.is_empty() || s.eq_ignore_ascii_case("all")).unwrap_or(true));     rules.iter().filter(|r| r["kind"].as_i64() == Some(0) || text(r, "kind").contains("clearance")             || r["kindName"].as_str() == Some("Clearance") || text(r, "name").contains("clearance"))         .filter(|r| !["kind", "name", "kindName"].iter().any(|k| { let t = text(r, k); NOT_COPPER_CLEARANCE.iter().any(|w| t.contains(w)) }))         .filter(|r| r["enabled"].as_bool() != Some(false))         .filter_map(|r| {-            if let Some(v) = ["clearanceMm", "gapMm", "minMm"].iter().find_map(|k| r[*k].as_f64()) { return Some(v); }-            if let Some(v) = r["value"].as_str().and_then(length_mm) { return Some(v); }-            let d = r["descriptor"].as_str()?;-            let i = d.find("Gap=")? + 4;-            let num: String = d[i..].chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect();-            let v: f64 = num.parse().ok()?;-            let unit = &d[i + num.len()..];-            Some(if unit.starts_with("mil") { v * 0.0254 } else { v })-        }).reduce(f64::min).map(r3)+            let v = if let Some(v) = ["clearanceMm", "gapMm", "minMm"].iter().find_map(|k| r[*k].as_f64()) { v }+            else if let Some(v) = r["value"].as_str().and_then(length_mm) { v }+            else {+                let d = r["descriptor"].as_str()?;+                let i = d.find("Gap=")? + 4;+                let num: String = d[i..].chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect();+                let v: f64 = num.parse().ok()?;+                let unit = &d[i + num.len()..];+                if unit.starts_with("mil") { v * 0.0254 } else { v }+            };+            Some((v, wide(r)))+        }).collect() }  /// A pcb-design-rules record (one entry of `rules/records/<vendor>.json`'s `records`, with the file's `vendor`) as the
rust/vendor/adom-eda-checks/src/lib.rs+4−2
@@ -4,11 +4,12 @@ //! answers the verb under its own prefix (`kicad_return_path`). Replies keep the standard envelope (`success`, //! `errorCode`, `error`, `_hint`) and name verbs without a prefix. //!-//! Every check takes one input bundle, `{board, stack?, pours?, rules?, silk?, fab?, bodies?, sheetParts?, netlist?, args?}` (see `input`).+//! Every check takes one input bundle, `{board, stack?, pours?, rules?, silk?, fab?, bodies?, prices?, sheetParts?, netlist?, args?}` (see `input`). use serde_json::Value;  pub mod bodies; pub mod coppermap;+pub mod cost; pub mod current; pub mod fab; pub mod features;@@ -25,7 +26,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] = &["check_clearance", "copper_balance", "current_capacity", "fab_check", "height_check", "impedance", "place_decoupling", "placement_check", "plan_fanout", "plan_route", "return_path", "thermal_vias", "via_fence"];+pub const CHECKS: &[&str] = &["check_clearance", "copper_balance", "current_capacity", "estimate_cost", "fab_check", "height_check", "impedance", "place_decoupling", "placement_check", "plan_fanout", "plan_route", "return_path", "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 {@@ -40,6 +41,7 @@ fn answer(check: &str, input: &Value) -> Value {         "check_clearance" => placetools::check_clearance(input),         "copper_balance" => coppermap::copper_balance(input),         "current_capacity" => current::current_capacity(input),+        "estimate_cost" => cost::estimate_cost(input),         "fab_check" => fab::fab_check(input),         "height_check" => bodies::height_check(input),         "impedance" => impedance::impedance(input),
rust/vendor/adom-eda-checks/src/main.rs+3−3
@@ -1,5 +1,5 @@ //! adom-eda-checks <check> [bundle.json | -]-//! adom-eda-checks <check> --board b.json [--stack s.json] [--pours p.json] [--args '{...}']+//! adom-eda-checks <check> --board b.json [--stack s.json] [--pours p.json] [--prices r.json] [--args '{...}'] //! adom-eda-checks import_svg --svg art.svg --args '{"at": [10, 10], "widthMm": 20}' //! Prints the check's reply as JSON. `adom-eda-checks list` names the checks. use serde_json::{json, Value};@@ -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" | "--sheet-parts" | "--netlist" => {+            "--board" | "--stack" | "--pours" | "--rules" | "--silk" | "--fab" | "--bodies" | "--prices" | "--sheet-parts" | "--netlist" => {                 let key = match flag.as_str() { "--sheet-parts" => "sheetParts", f => &f[2..] };                 b[key] = read(val)?;             }@@ -39,7 +39,7 @@ fn bundle(rest: &[String]) -> Result<Value, String> { fn main() {     let argv: Vec<String> = std::env::args().skip(1).collect();     let out = match argv.first().map(String::as_str) {-        None | Some("-h") | Some("--help") => json!({"success": true, "usage": "adom-eda-checks <check> [bundle.json | -] | <check> --board b.json [--stack s.json] [--pours p.json] [--args '{...}']", "checks": adom_eda_checks::CHECKS, "writers": adom_eda_checks::writers::WRITERS}),+        None | Some("-h") | Some("--help") => json!({"success": true, "usage": "adom-eda-checks <check> [bundle.json | -] | <check> --board b.json [--stack s.json] [--pours p.json] [--prices r.json] [--args '{...}']", "checks": adom_eda_checks::CHECKS, "writers": adom_eda_checks::writers::WRITERS}),         Some("list") => json!({"success": true, "checks": adom_eda_checks::CHECKS, "writers": adom_eda_checks::writers::WRITERS}),         Some(check) => match bundle(&argv[1..]) {             Ok(b) => adom_eda_checks::run(check, &b),
rust/vendor/adom-eda-checks/src/placement.rs+18−14
@@ -97,6 +97,23 @@ pub fn review(boxes: &BTreeMap<String, (bool, Rect)>, pads: &[Value], ring: &[(f     json!({"parts": boxes.len(), "ok": ok, "courtyardOverlaps": overlaps, "offOrOverEdge": off, "onMountingHoles": on_holes, "nearEdge": near, "marginMm": margin, "edgeMm": edge}) } +/// Whether two parts that overlap in plan view are a stack (a part under a raised one: an Adom molecule on pins, a+/// connector's overhang): their pads stay `margin` apart and no body of one meets a body of the other in 3D. A part with+/// no body cannot be shown stacked. Pure.+pub fn is_stacked(pads: &[Value], bs: &[crate::bodies::BodyBox], a: &str, b: &str, margin: f64) -> bool {+    // each pad's own box, turned with it: a molecule's pins span the part under it, so the union of its pads would too+    let pad_rects = |r: &str| -> Vec<Rect> { pads.iter().filter(|p| p["ref"].as_str() == Some(r)).map(|p| {+        let (c, s) = (f(&p["rotation"]).to_radians().cos().abs(), f(&p["rotation"]).to_radians().sin().abs());+        let (hx, hy) = ((f(&p["sizeX"]) * c + f(&p["sizeY"]) * s) / 2.0, (f(&p["sizeX"]) * s + f(&p["sizeY"]) * c) / 2.0);+        (f(&p["x"]) - hx, f(&p["y"]) - hy, f(&p["x"]) + hx, f(&p["y"]) + hy)+    }).collect() };+    let (pa, pb) = (pad_rects(a), pad_rects(b));+    if pa.is_empty() || pb.is_empty() || pa.iter().any(|x| pb.iter().any(|y| hit(grow(*x, margin), grow(*y, margin)))) { return false; }+    let (ba, bb): (Vec<_>, Vec<_>) = (bs.iter().filter(|x| x.part == a).collect(), bs.iter().filter(|x| x.part == b).collect());+    if ba.is_empty() || bb.is_empty() { return false; }+    !ba.iter().any(|p| bb.iter().any(|q| hit(grow((p.x0, p.y0, p.x1, p.y1), margin), grow((q.x0, q.y0, q.x1, q.y1), margin)) && p.z0.max(q.z0) < p.z1.min(q.z1)))+}+ /// placement_check: input {board, bodies?, args: {marginMm?, edgeMm?}}. Without bodies the parts' boxes are their pads /// only, and the reply says so (`bodiesUsed: false`). pub fn placement_check(input: &Value) -> Value {@@ -122,23 +139,10 @@ pub fn placement_check(input: &Value) -> Value {     // their pads stay clear and no body of one meets a body of the other in 3D, it is a stack, listed apart, not an     // overlap (fusion-lane #19). A part with no body cannot be shown stacked, so its overlaps stand.     if let Some(bs) = &given {-        // each pad's own box, turned with it: a molecule's pins span the part under it, so the union of its pads would too-        let pad_rects = |r: &str| -> Vec<Rect> { pads.iter().filter(|p| p["ref"].as_str() == Some(r)).map(|p| {-            let (c, s) = (f(&p["rotation"]).to_radians().cos().abs(), f(&p["rotation"]).to_radians().sin().abs());-            let (hx, hy) = ((f(&p["sizeX"]) * c + f(&p["sizeY"]) * s) / 2.0, (f(&p["sizeX"]) * s + f(&p["sizeY"]) * c) / 2.0);-            (f(&p["x"]) - hx, f(&p["y"]) - hy, f(&p["x"]) + hx, f(&p["y"]) + hy)-        }).collect() };-        let meets = |a: &str, b: &str| {-            let (ba, bb): (Vec<_>, Vec<_>) = (bs.iter().filter(|x| x.part == a).collect(), bs.iter().filter(|x| x.part == b).collect());-            if ba.is_empty() || bb.is_empty() { return true; }-            ba.iter().any(|p| bb.iter().any(|q| hit(grow((p.x0, p.y0, p.x1, p.y1), margin), grow((q.x0, q.y0, q.x1, q.y1), margin)) && p.z0.max(q.z0) < p.z1.min(q.z1)))-        };         let (mut real, mut stacked) = (Vec::new(), Vec::new());         for o in out["courtyardOverlaps"].as_array().cloned().unwrap_or_default() {             let (a, b) = (o["parts"][0].as_str().unwrap_or(""), o["parts"][1].as_str().unwrap_or(""));-            let (pa, pb) = (pad_rects(a), pad_rects(b));-            let pads_meet = pa.is_empty() || pb.is_empty() || pa.iter().any(|x| pb.iter().any(|y| hit(grow(*x, margin), grow(*y, margin))));-            if pads_meet || meets(a, b) { real.push(o) } else { stacked.push(o) }+            if is_stacked(&pads, bs, a, b, margin) { stacked.push(o) } else { real.push(o) }         }         if !stacked.is_empty() {             out["ok"] = json!(real.is_empty() && out["offOrOverEdge"].as_array().map(|a| a.is_empty()).unwrap_or(true) && out["onMountingHoles"].as_array().map(|a| a.is_empty()).unwrap_or(true));
rust/vendor/adom-eda-checks/src/placetools.rs+150−67
@@ -121,8 +121,11 @@ pub fn check_clearance(input: &Value) -> Value {     let order = |a: &str, b: &str| if ref_key(a) <= ref_key(b) { (a.to_string(), b.to_string()) } else { (b.to_string(), a.to_string()) };     let (mut pairs, mut checked) = (Vec::new(), 0usize);     let measured;+    let mut stacked = Vec::new();     if mode == "courtyard" {         let boxes: Vec<(&String, bool, Rect)> = m.iter().filter_map(|(r, p)| Some((r, p.bottom, p.box_at(p.at, p.rot)?))).collect();+        let bodies = crate::bodies::input_boxes(input).unwrap_or_default();+        let pads = st["pads"].as_array().cloned().unwrap_or_default();         for i in 0..boxes.len() {             for j in i + 1..boxes.len() {                 let ((ra, sa, a), (rb, sb, b)) = (boxes[i], boxes[j]);@@ -131,7 +134,9 @@ pub fn check_clearance(input: &Value) -> Value {                 let d = rect_gap(a, b);                 if d < threshold {                     let (x, y) = order(ra, rb);-                    pairs.push(json!({"a": x, "b": y, "side": if sa { "bottom" } else { "top" }, "distanceMm": r3(d), "overlapping": d == 0.0}));+                    let row = json!({"a": x, "b": y, "side": if sa { "bottom" } else { "top" }, "distanceMm": r3(d), "overlapping": d == 0.0});+                    // a part under a raised one (pads apart, no bodies meeting in 3D) is a stack, as placement_check says (fusion-lane #22)+                    if crate::placement::is_stacked(&pads, &bodies, ra, rb, threshold / 2.0) { stacked.push(row) } else { pairs.push(row) }                 }             }         }@@ -165,7 +170,7 @@ pub fn check_clearance(input: &Value) -> Value {     let total = pairs.len();     pairs.truncate(limit);     json!({"success": true, "mode": mode, "thresholdMm": threshold, "measuredOn": measured, "pairsChecked": checked, "violationCount": total,-        "pairs": pairs, "truncated": total > limit, "clean": total == 0,+        "pairs": pairs, "truncated": total > limit, "clean": total == 0, "stacked": stacked,         "_hint": if total == 0 { "No pair is closer than thresholdMm. The tool's DRC (run_drc) is the authority on copper clearance." }                  else { "pairs are closest first. Move parts apart with move_parts (find_space finds room); overlapping or touching pairs come first. The tool's DRC (run_drc) is the authority on copper clearance." }}) }@@ -199,24 +204,28 @@ pub fn place_decoupling(input: &Value) -> Value {     let Some((ic_ref, ic)) = find(&m, ic_ref) else { return crate::input::refuse("no_such_part", &format!("{ic_ref} is not a part on this board")) };     let Some(ic_box) = ic.box_at(ic.at, ic.rot) else { return crate::input::refuse("no_pads", &format!("{ic_ref} has no pads in the board model")) };     let net = |q: &Value| q["net"].as_str().unwrap_or("").to_string();-    let mut power: Vec<String> = ic.pads.iter().map(net).filter(|n| !n.is_empty() && !is_ground(n)).collect();-    power.sort();-    power.dedup();+    let mut ic_nets: Vec<String> = ic.pads.iter().map(net).filter(|n| !n.is_empty() && !is_ground(n)).collect();+    ic_nets.sort();+    ic_nets.dedup();     let roles: BTreeMap<&String, (Value, String)> = m.iter().filter_map(|(r, p)| Some((r, decoupling_role(r, p)?))).collect();+    // a supply: named like one, or two or more caps across it to ground (one cap on a signal is a crystal's load or a+    // filter, not decoupling)+    let mut across: BTreeMap<&str, usize> = BTreeMap::new();+    for (_, n) in roles.values() { *across.entry(n.as_str()).or_insert(0) += 1; }+    let supply = |n: &str| power_name(n) || across.get(n).copied().unwrap_or(0) >= 2;+    let supplies: Vec<String> = ic_nets.iter().filter(|n| supply(n)).cloned().collect();+    // caps given by name may sit on any of the IC's nets+    let power = if args["caps"].is_array() { ic_nets.clone() } else { supplies.clone() };     let pad_at = |q: &Value| (f(&q["x"]), f(&q["y"]));     let (mut left_out, mut cap_refs): (Vec<Value>, Vec<String>) = (Vec::new(), Vec::new());     match args["caps"].as_array() {         Some(a) => cap_refs = a.iter().filter_map(|v| v.as_str()).map(String::from).collect(),         None => {-            // a supply: named like one, or two or more caps across it to ground (one cap on a signal is a crystal's-            // load or a filter, not decoupling)-            let mut across: BTreeMap<&str, usize> = BTreeMap::new();-            for (_, n) in roles.values() { *across.entry(n.as_str()).or_insert(0) += 1; }             // the other ICs, for caps that serve the same supply nearer another one             let ics: Vec<(&String, &Part)> = m.iter().filter(|(r, p)| matches!(prefix(r).as_str(), "U" | "IC") && p.pads.len() >= 3 && p.bottom == ic.bottom).collect();             for (r, (pp, n)) in &roles {-                if !power.contains(n) { continue; }-                if !power_name(n) && across.get(n.as_str()).copied().unwrap_or(0) < 2 {+                if !ic_nets.contains(n) { continue; }+                if !supply(n) {                     left_out.push(json!({"cap": r, "net": n, "why": "signal_net", "detail": format!("{n} has one cap to ground and is not named like a supply: a crystal's load or a filter. Pass caps to place it anyway")}));                     continue;                 }@@ -232,73 +241,113 @@ pub fn place_decoupling(input: &Value) -> Value {     }     cap_refs.sort_by_key(|r| ref_key(r));     if cap_refs.is_empty() {-        return json!({"success": false, "errorCode": "no_caps", "error": format!("no capacitor across ground and a supply of {ic_ref}: pass caps:[...]"), "icPowerNets": power, "leftOut": left_out});-    }-    let moving: Vec<String> = cap_refs.iter().map(|r| r.to_uppercase()).collect();-    let mut taken: Vec<Rect> = m.iter().filter(|(r, p)| p.bottom == ic.bottom && !moving.contains(&r.to_uppercase())).filter_map(|(_, p)| p.box_at(p.at, p.rot)).collect();-    // a part on the other side still takes the spot of its through-hole pads, and so do the board's own holes-    for p in st["pads"].as_array().into_iter().flatten() {-        let through = p["layer"] == "*.Cu" || f(&p["drill"]) > 0.0;-        let other_side = p["ref"].as_str().and_then(|r| m.get(r)).map(|q| q.bottom != ic.bottom).unwrap_or(true);-        if through && other_side { taken.push(pad_box(pad_at(p), f(&p["sizeX"]).max(f(&p["drill"])), f(&p["sizeY"]).max(f(&p["drill"])), f(&p["rotation"]))); }+        return json!({"success": false, "errorCode": "no_caps", "error": format!("no capacitor across ground and a supply of {ic_ref}: pass caps:[...]"), "icPowerNets": supplies, "leftOut": left_out});     }+    // a cap the plan cannot place stays where it is, so its spot stays taken: plan again with it fixed until none is new+    // (fusion-lane #22: C32 was planned onto C29, which had no free spot and never moved)     let ring = crate::coppermap::outline_ring(&st);     let on_board = |b: Rect| ring.len() < 3 || [(b.0, b.1), (b.2, b.1), (b.2, b.3), (b.0, b.3)].iter().all(|c| crate::route::inside(*c, &ring));-    let (mut used, mut rows, mut moves, mut skipped) = (Vec::<String>::new(), Vec::new(), Vec::new(), Vec::new());-    for cr in &cap_refs {-        let Some((cr, cap)) = find(&m, cr) else { skipped.push(json!({"cap": cr, "why": "no_such_part"})); continue };-        if cap.bottom != ic.bottom {-            skipped.push(json!({"cap": cr, "why": "other_side", "detail": format!("{cr} is on the other side from {ic_ref}: move it over first (move_parts with side)")}));-            continue;+    // room for the IC's fanout: a cap's pads keep clear of the stubs and vias plan_fanout would give the pins of other+    // nets (altium-lane: caps placed first sat on 5 of U4's escape stubs and vias)+    let (fan, fan_clear) = if args["leaveFanout"].as_bool() == Some(false) { (Vec::new(), 0.0) } else { fanout_room(input, ic_ref) };+    let pads_clear = |cap: &Part, at: P, rot: f64| cap.pads.iter().all(|q| {+        let mut moved = q.clone();+        let c = cap.moved(pad_at(q), at, rot);+        moved["x"] = json!(c.0); moved["y"] = json!(c.1); moved["rotation"] = json!(f(&q["rotation"]) + rot - cap.rot);+        moved.as_object_mut().map(|o| o.remove("copperPoints"));+        let s = pad_shape(&moved);+        fan.iter().all(|(n, piece)| *n == net(q) || s.gap(piece) >= fan_clear - 1e-6)+    });+    let mut stay: Vec<String> = Vec::new();+    let (mut used, mut rows, mut moves, mut skipped): (Vec<String>, Vec<Value>, Vec<(String, P, f64)>, Vec<Value>);+    loop {+        let moving: Vec<String> = cap_refs.iter().map(|r| r.to_uppercase()).filter(|r| !stay.contains(r)).collect();+        let mut taken: Vec<Rect> = m.iter().filter(|(r, p)| p.bottom == ic.bottom && !moving.contains(&r.to_uppercase())).filter_map(|(_, p)| p.box_at(p.at, p.rot)).collect();+        // a part on the other side still takes the spot of its through-hole pads, and so do the board's own holes+        for p in st["pads"].as_array().into_iter().flatten() {+            let through = p["layer"] == "*.Cu" || f(&p["drill"]) > 0.0;+            let other_side = p["ref"].as_str().and_then(|r| m.get(r)).map(|q| q.bottom != ic.bottom).unwrap_or(true);+            if through && other_side { taken.push(pad_box(pad_at(p), f(&p["sizeX"]).max(f(&p["drill"])), f(&p["sizeY"]).max(f(&p["drill"])), f(&p["rotation"]))); }         }-        let Some(pp) = cap.pads.iter().find(|q| power.contains(&net(q))) else { skipped.push(json!({"cap": cr, "why": "no_shared_power_net"})); continue };-        let n = net(pp);-        let mut pins: Vec<&Value> = ic.pads.iter().filter(|q| net(q) == n).collect();-        pins.sort_by_key(|q| (used.contains(&q["name"].as_str().unwrap_or("").to_string()), ref_key(q["name"].as_str().unwrap_or(""))));-        let pin = pins[0];-        let pin_name = pin["name"].as_str().unwrap_or("").to_string();-        used.push(pin_name.clone());-        let pin_at = pad_at(pin);-        let (nv, side) = side_of(ic_box, pin_at);-        // the turn that points the power pad at the IC-        let rel = |rot: f64| cap.moved(pad_at(pp), (0.0, 0.0), rot);-        let rot = [0.0, 90.0, 180.0, 270.0].into_iter().max_by(|a, b| {-            let (va, vb) = (rel(*a), rel(*b));-            (-(va.0 * nv.0 + va.1 * nv.1)).partial_cmp(&(-(vb.0 * nv.0 + vb.1 * nv.1))).unwrap()-        }).unwrap();-        let Some(cy) = cap.box_at((0.0, 0.0), rot) else { skipped.push(json!({"cap": cr, "why": "no_pads"})); continue };-        let v = rel(rot);-        let base = match side {-            "left" => (ic_box.0 - gap - cy.2, pin_at.1 - v.1),-            "right" => (ic_box.2 + gap - cy.0, pin_at.1 - v.1),-            "top" => (pin_at.0 - v.0, ic_box.3 + gap - cy.1),-            _ => (pin_at.0 - v.0, ic_box.1 - gap - cy.3),-        };-        let along = if nv.0 != 0.0 { (0.0, 1.0) } else { (1.0, 0.0) };-        let step = if nv.0 != 0.0 { cy.3 - cy.1 } else { cy.2 - cy.0 } + gap;-        let mut chosen = None;-        for k in 0..13 {-            let s = if k == 0 { 0.0 } else if k % 2 == 1 { ((k + 1) / 2) as f64 * step } else { -((k / 2) as f64) * step };-            let at = (r3(base.0 + along.0 * s), r3(base.1 + along.1 * s));-            let Some(b) = cap.box_at(at, rot) else { continue };-            if taken.iter().all(|t| rect_gap(*t, b) >= gap - 1e-3) && on_board(b) { chosen = Some((at, b, s)); break; }+        (used, rows, moves, skipped) = (Vec::new(), Vec::new(), Vec::new(), Vec::new());+        for cr in &cap_refs {+            let Some((cr, cap)) = find(&m, cr) else { skipped.push(json!({"cap": cr, "why": "no_such_part"})); continue };+            if cap.bottom != ic.bottom {+                skipped.push(json!({"cap": cr, "why": "other_side", "detail": format!("{cr} is on the other side from {ic_ref}: move it over first (move_parts with side)")}));+                continue;+            }+            let Some(pp) = cap.pads.iter().find(|q| power.contains(&net(q))) else { skipped.push(json!({"cap": cr, "why": "no_shared_power_net"})); continue };+            let n = net(pp);+            let mut pins: Vec<&Value> = ic.pads.iter().filter(|q| net(q) == n).collect();+            pins.sort_by_key(|q| (used.contains(&q["name"].as_str().unwrap_or("").to_string()), ref_key(q["name"].as_str().unwrap_or(""))));+            let pin = pins[0];+            let pin_name = pin["name"].as_str().unwrap_or("").to_string();+            used.push(pin_name.clone());+            let pin_at = pad_at(pin);+            let (nv, side) = side_of(ic_box, pin_at);+            // the turn that points the power pad at the IC+            let rel = |rot: f64| cap.moved(pad_at(pp), (0.0, 0.0), rot);+            let rot = [0.0, 90.0, 180.0, 270.0].into_iter().max_by(|a, b| {+                let (va, vb) = (rel(*a), rel(*b));+                (-(va.0 * nv.0 + va.1 * nv.1)).partial_cmp(&(-(vb.0 * nv.0 + vb.1 * nv.1))).unwrap()+            }).unwrap();+            let Some(cy) = cap.box_at((0.0, 0.0), rot) else { skipped.push(json!({"cap": cr, "why": "no_pads"})); continue };+            let v = rel(rot);+            let base = match side {+                "left" => (ic_box.0 - gap - cy.2, pin_at.1 - v.1),+                "right" => (ic_box.2 + gap - cy.0, pin_at.1 - v.1),+                "top" => (pin_at.0 - v.0, ic_box.3 + gap - cy.1),+                _ => (pin_at.0 - v.0, ic_box.1 - gap - cy.3),+            };+            let along = if nv.0 != 0.0 { (0.0, 1.0) } else { (1.0, 0.0) };+            let step = if nv.0 != 0.0 { cy.3 - cy.1 } else { cy.2 - cy.0 } + gap;+            let mut chosen = None;+            for k in 0..13 {+                let s = if k == 0 { 0.0 } else if k % 2 == 1 { ((k + 1) / 2) as f64 * step } else { -((k / 2) as f64) * step };+                let at = (r3(base.0 + along.0 * s), r3(base.1 + along.1 * s));+                let Some(b) = cap.box_at(at, rot) else { continue };+                if taken.iter().all(|t| rect_gap(*t, b) >= gap - 1e-3) && on_board(b) && pads_clear(cap, at, rot) { chosen = Some((at, b, s)); break; }+            }+            let Some((at, b, slid)) = chosen else { skipped.push(json!({"cap": cr, "why": "no_free_spot", "pin": format!("{ic_ref}.{pin_name}"), "side": side})); continue };+            taken.push(b);+            let pa = cap.moved(pad_at(pp), at, rot);+            rows.push(json!({"cap": cr, "net": n, "pin": format!("{ic_ref}.{pin_name}"), "side": side, "x": at.0, "y": at.1, "rotation": rot,+                "powerPad": format!("{cr}.{}", pp["name"].as_str().unwrap_or("")), "padToPinMm": r3(((pa.0 - pin_at.0).powi(2) + (pa.1 - pin_at.1).powi(2)).sqrt()),+                "slidMm": r3(slid), "from": {"x": cap.at.0, "y": cap.at.1, "rotation": cap.rot}}));+            moves.push((cr.clone(), at, rot));         }-        let Some((at, b, slid)) = chosen else { skipped.push(json!({"cap": cr, "why": "no_free_spot", "pin": format!("{ic_ref}.{pin_name}"), "side": side})); continue };-        taken.push(b);-        let pa = cap.moved(pad_at(pp), at, rot);-        rows.push(json!({"cap": cr, "net": n, "pin": format!("{ic_ref}.{pin_name}"), "side": side, "x": at.0, "y": at.1, "rotation": rot,-            "powerPad": format!("{cr}.{}", pp["name"].as_str().unwrap_or("")), "padToPinMm": r3(((pa.0 - pin_at.0).powi(2) + (pa.1 - pin_at.1).powi(2)).sqrt()),-            "slidMm": r3(slid), "from": {"x": cap.at.0, "y": cap.at.1, "rotation": cap.rot}}));-        moves.push((cr.clone(), at, rot));+        let new: Vec<String> = skipped.iter().filter_map(|x| x["cap"].as_str()).filter(|c| find(&m, c).is_some()).map(|c| c.to_uppercase()).filter(|c| !stay.contains(c)).collect();+        if new.is_empty() { break; }+        stay.extend(new);     }     let validation = validate(&st, input, &m, &moves);     let parts_arg: Vec<Value> = moves.iter().map(|(r, at, rot)| json!({"ref": r, "x": at.0, "y": at.1, "rotation": rot})).collect();-    json!({"success": true, "ic": ic_ref, "icPowerNets": power, "gapMm": gap, "placements": rows, "skipped": skipped, "leftOut": left_out,+    json!({"success": true, "ic": ic_ref, "icPowerNets": supplies, "gapMm": gap, "placements": rows, "skipped": skipped, "leftOut": left_out,         "moveArgs": {"parts": parts_arg, "expectedRevision": st["revision"]}, "validation": validation, "revision": st["revision"],         "measuredOn": if crate::bodies::input_boxes(input).is_some() { "part boxes: pads and 3D bodies" } else { "part boxes: pads only (pass bodies to keep clear of 3D bodies too)" },         "_hint": "Read the plan, then send moveArgs to move_parts. The caps' old tracks stay where they were: route the stubs again (plan_route, route_net) and repour. leftOut lists caps the default left for another IC or a signal net; name them in caps to place them here."}) } +/// The copper plan_fanout would put down for an IC's pins, by net, and the clearance it keeps; nothing when the IC is no+/// package fanout plans (fewer than four surface pads).+fn fanout_room(input: &Value, ic: &str) -> (Vec<(String, Shape)>, f64) {+    let mut q = input.clone();+    q["args"] = json!({"ref": ic});+    let r = plan_fanout(&q);+    if r["success"] != json!(true) { return (Vec::new(), 0.0); }+    let st = crate::input::board(input).unwrap_or_default();+    let at = |name: &str| st["pads"].as_array().into_iter().flatten().find(|p| format!("{}.{}", p["ref"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or("")) == name).map(|p| (f(&p["x"]), f(&p["y"])));+    let (w, v) = (f(&r["widthMm"]) / 2.0, f(&r["via"]["size"]) / 2.0);+    let mut out = Vec::new();+    for row in r["pads"].as_array().into_iter().flatten() {+        let (Some(pa), Some(n)) = (row["pad"].as_str().and_then(at), row["net"].as_str()) else { continue };+        let pt = |x: &Value| (f(&x[0]), f(&x[1]));+        if row["stubEnd"].is_array() { out.push((n.to_string(), Shape::Cap(pa, pt(&row["stubEnd"]), w))); }+        if row["via"].is_array() { let e = pt(&row["via"]); out.push((n.to_string(), Shape::Cap(pa, e, w))); out.push((n.to_string(), Shape::Cap(e, e, v))); }+    }+    (out, f(&r["clearanceMm"]))+}+ /// placement_check on the board with the caps moved, keeping what concerns them. fn validate(st: &Value, input: &Value, m: &BTreeMap<String, Part>, moves: &[(String, P, f64)]) -> Value {     if moves.is_empty() { return json!({"ok": false, "reason": "nothing to place"}); }@@ -339,7 +388,7 @@ fn median(mut v: Vec<f64>) -> Option<f64> { }  /// The board's copper layers, top to bottom: from the stack's count when given, else the layers its copper uses.-fn copper_layers(st: &Value, input: &Value) -> Vec<String> {+pub(crate) fn copper_layers(st: &Value, input: &Value) -> Vec<String> {     if let Some(n) = crate::input::stack(input).and_then(|s| s["copperLayers"].as_u64()).filter(|n| *n >= 2) {         let mut v = vec!["F.Cu".to_string()];         v.extend((1..n - 1).map(|i| format!("In{i}.Cu")));@@ -396,7 +445,7 @@ pub fn plan_fanout(input: &Value) -> Value {     let via_drill = args["viaDrillMm"].as_f64().unwrap_or(if via_size <= 0.45 { 0.2 } else { 0.3 });     if via_drill >= via_size { return crate::input::refuse("bad_args", "viaDrillMm must be smaller than viaMm"); }     let width = args["widthMm"].as_f64().unwrap_or(if fine { 0.1 } else { 0.15 });-    let rule = crate::fab::min_clearance(&crate::input::rules(input));+    let rule = crate::fab::routing_clearance(&crate::input::rules(input));     let clearance = args["clearanceMm"].as_f64().unwrap_or(0.0).max(rule.unwrap_or(0.1));     let pad_d = median(signal.iter().map(|&i| f(&pads[i]["sizeX"]).max(f(&pads[i]["sizeY"]))).collect()).unwrap_or(0.0);     let room = r3(pitch / 2f64.sqrt() - pad_d / 2.0 - via_size / 2.0);@@ -543,6 +592,32 @@ mod tests {         assert_eq!(r["moveArgs"]["parts"][0], json!({"ref": "C1", "x": 3.4, "y": 1.0, "rotation": 0.0}));         assert_eq!(r["moveArgs"]["expectedRevision"], "r1");         assert_eq!(r["validation"]["ok"], json!(true), "{r}");+        // U1's supplies only: XIN has one cap and is not named like a supply (fusion-lane #22)+        assert_eq!(r["icPowerNets"], json!(["VDD"]));+    }++    #[test]+    fn a_cap_leaves_room_for_the_ics_fanout() {+        // a second ground pin, U1.5, just under the VDD pin: its fanout via lands where C1 would go (altium-lane)+        let mut b = board();+        b["pads"].as_array_mut().unwrap().push(pad("U1", "5", 2.0, 0.4, "GND"));+        let r = place_decoupling(&json!({"board": b, "args": {"ic": "U1", "caps": ["C1"]}}));+        assert!(f(&r["placements"][0]["slidMm"]).abs() > 0.0, "{r}");+        let close = place_decoupling(&json!({"board": b, "args": {"ic": "U1", "caps": ["C1"], "leaveFanout": false}}));+        assert_eq!(close["placements"][0]["slidMm"], json!(0.0), "{close}");+    }++    #[test]+    fn a_cap_that_cannot_move_keeps_its_spot() {+        // C2 sits where C1 would go, and cannot be placed (its net is none of U1's): C1 slides past it (fusion-lane #22)+        let mut b = board();+        b["components"][3]["x"] = json!(3.4); b["components"][3]["y"] = json!(1.0);+        b["pads"][9] = pad("C2", "1", 2.9, 1.0, "OTHER");+        b["pads"][10] = pad("C2", "2", 3.9, 1.0, "GND");+        let r = place_decoupling(&json!({"board": b, "args": {"ic": "U1", "caps": ["C1", "C2"]}}));+        assert_eq!(r["skipped"][0]["why"], "no_shared_power_net", "{r}");+        assert!(f(&r["placements"][0]["slidMm"]).abs() > 0.0, "{r}");+        assert_eq!(r["validation"]["ok"], json!(true), "{r}");     }      #[test]@@ -588,6 +663,14 @@ mod tests {         // same-net VDD pads are skipped; C3.2 (GND) to U2.1 (VDD) is the closest         assert_eq!((p["pairs"][0]["a"].as_str(), p["pairs"][0]["b"].as_str()), (Some("C3.2"), Some("U2.1")), "{p}");         assert_eq!(check_clearance(&json!({"board": board(), "args": {"refs": ["Q1"]}}))["errorCode"], "no_such_part");+        // a part under a raised one: pads apart, bodies apart in height, listed as stacked like placement_check does+        let st = json!({"components": [{"ref": "M1", "x": 0, "y": 0, "rotation": 0, "side": "top"}, {"ref": "R1", "x": 0, "y": 0, "rotation": 0, "side": "top"}],+            "pads": [pad("M1", "1", -3.0, 0.0, "A"), pad("M1", "2", 3.0, 0.0, "B"), pad("R1", "1", -0.5, 0.0, "C"), pad("R1", "2", 0.5, 0.0, "D")]});+        let bodies = json!({"bodies": [{"ref": "M1", "side": "top", "x0": -3.5, "y0": -2.0, "x1": 3.5, "y1": 2.0, "z0": 2.0, "z1": 3.0},+                                       {"ref": "R1", "side": "top", "x0": -0.8, "y0": -0.4, "x1": 0.8, "y1": 0.4, "z0": 0.0, "z1": 0.5}]});+        let c = check_clearance(&json!({"board": st, "bodies": bodies, "args": {}}));+        assert_eq!((c["pairs"].as_array().unwrap().len(), c["stacked"].as_array().unwrap().len()), (0, 1), "{c}");+        assert_eq!(check_clearance(&json!({"board": st, "args": {}}))["pairs"].as_array().unwrap().len(), 1);         assert_eq!(check_clearance(&json!({"board": board(), "args": {"mode": "x"}}))["errorCode"], "bad_args");     } 
rust/vendor/adom-eda-checks/src/route.rs+63−9
@@ -132,20 +132,34 @@ pub fn plan_route(input: &Value) -> Value {     let allow_vias = args["allowVias"].as_bool().unwrap_or(true) && layers.len() > 1;     let via_cost = num("viaCost", 5.0);     let max_nodes = args["maxNodes"].as_u64().unwrap_or(50_000).clamp(1, 500_000) as usize;-    // the board's own clearance decides; a caller's clearanceMm can only raise it-    let rule = crate::fab::min_clearance(&rules);+    // the board's strictest board-wide copper rule decides (fusion-lane #21: a via planned at the 0.1 wire rule landed+    // 0.109 mm from a pad where 0.15 applies); a caller's clearanceMm can only raise it+    let rule = crate::fab::routing_clearance(&rules);     let clearance = num("clearanceMm", 0.0).max(rule.unwrap_or(0.2));     let edge = num("edgeClearanceMm", 0.3);     let ring = crate::coppermap::outline_ring(&st);     if ring.len() < 3 { return crate::input::refuse("no_outline", "the board has no outline"); } +    // a through via passes every copper layer of the board, not only the ones the route may use (fusion-lane #21: a via+    // planned on F.Cu and In2.Cu landed 0.094 mm from a B.Cu track)+    let mut copper = crate::placetools::copper_layers(&st, input);+    for l in &layers { if !copper.contains(l) { copper.push(l.clone()); } }+    // other nets' pours: not obstacles (they re-pour round the route), but a route across one can cut it in two, so the+    // reply names each one it crosses, and avoidPours keeps the route out of them+    let avoid_pours = args["avoidPours"].as_bool().unwrap_or(false);+    let pours: Vec<(String, String, Shape)> = st["polygons"].as_array().cloned().unwrap_or_default().iter()+        .filter(|p| p["net"].as_str().map(|n| !n.is_empty() && !n.eq_ignore_ascii_case(net)).unwrap_or(false))+        .filter_map(|p| {+            let ring: Vec<P> = p["points"].as_array()?.iter().map(|q| (f(&q[0]), f(&q[1]))).collect();+            (ring.len() >= 3).then(|| (p["net"].as_str().unwrap_or("").to_string(), p["layer"].as_str().unwrap_or("").to_string(), Shape::Poly(ring)))+        }).collect();     // copper of other nets, by layer; every pad for the via-in-pad rule. Net-less tracks and arcs are a hatched pour's     // pieces (Altium lists 8777 of the Bluetooth Sentinel's 9378 tracks with no net) and pours re-pour round new copper,     // so neither blocks a route: repour after placing it (the reply says so)     let same = |v: &Value| v["net"].as_str().map(|n| n.eq_ignore_ascii_case(net) || (n.is_empty() && v.get("start").or(v.get("center")).is_some())).unwrap_or(false);     let mut ob: Vec<(Shape, Vec<String>, (f64, f64, f64, f64))> = Vec::new();     let mut push = |s: Shape, ls: Vec<String>| { let b = s.bounds(); ob.push((s, ls, b)); };-    for p in pads.iter().filter(|p| !same(p)) { push(pad_shape(p), pad_layers(p, &layers)); }+    for p in pads.iter().filter(|p| !same(p)) { push(pad_shape(p), pad_layers(p, &copper)); }     for t in st["tracks"].as_array().cloned().unwrap_or_default().iter().filter(|t| !same(t)) {         push(Shape::Cap((f(&t["start"]["x"]), f(&t["start"]["y"])), (f(&t["end"]["x"]), f(&t["end"]["y"])), f(&t["width"]) / 2.0), vec![t["layer"].as_str().unwrap_or("").to_string()]);     }@@ -159,7 +173,7 @@ pub fn plan_route(input: &Value) -> Value {     }     for v in st["vias"].as_array().cloned().unwrap_or_default().iter().filter(|v| !same(v)) {         let c = (f(&v["position"]["x"]), f(&v["position"]["y"]));-        push(Shape::Cap(c, c, f(&v["size"]) / 2.0), layers.clone());+        push(Shape::Cap(c, c, f(&v["size"]) / 2.0), copper.clone());     }     let all_pads: Vec<Shape> = pads.iter().map(pad_shape).collect(); @@ -204,9 +218,10 @@ pub fn plan_route(input: &Value) -> Value {         let ok = if u.2 != v.2 {             // a through via: clear on every layer, inside the outline, in no pad             let r = via_size / 2.0;-            edge_ok(a, a, r) && layers.iter().all(|l| clear_on(a, a, r, l)) && all_pads.iter().all(|s| s.dist(a, a) >= r + clearance - 1e-9)+            edge_ok(a, a, r) && copper.iter().all(|l| clear_on(a, a, r, l)) && all_pads.iter().all(|s| s.dist(a, a) >= r + clearance - 1e-9)         } else {             edge_ok(a, b, width / 2.0) && clear_on(a, b, width / 2.0, &layers[u.2])+                && !(avoid_pours && pours.iter().any(|(_, l, s)| *l == layers[u.2] && s.dist(a, b) < width / 2.0))         };         cache.insert(k, ok);         ok@@ -259,20 +274,27 @@ pub fn plan_route(input: &Value) -> Value {     let mut way = vec![json!({"pad": key(&ends[0])})];     let mut vias = Vec::new();     let mut length = 0.0;+    let mut crosses: Vec<Value> = Vec::new();     let last = keep.len() - 1;     for (i, w) in keep.windows(2).enumerate() {         let (a, b) = (w[0], w[1]);         let (pa, pb) = ((xs[a.0], ys[a.1]), (xs[b.0], ys[b.1]));-        if a.2 != b.2 { vias.push(json!([pa.0, pa.1])); } else { length += ((pb.0 - pa.0).powi(2) + (pb.1 - pa.1).powi(2)).sqrt(); }+        if a.2 != b.2 { vias.push(json!([pa.0, pa.1])); } else {+            length += ((pb.0 - pa.0).powi(2) + (pb.1 - pa.1).powi(2)).sqrt();+            for (n, l, s) in &pours {+                if *l == layers[a.2] && s.dist(pa, pb) < width / 2.0 && !crosses.iter().any(|c: &Value| c["net"] == json!(n) && c["layer"] == json!(l)) { crosses.push(json!({"net": n, "layer": l})); }+            }+        }         // every corner and layer change; the goal itself is the closing {pad} waypoint         if i + 1 < last { way.push(json!({"x": pb.0, "y": pb.1, "layer": layers[b.2]})); }     }     way.push(json!({"pad": key(&ends[1])}));     json!({"success": true, "found": true, "lengthMm": r3(length), "vias": vias.len(), "viaAt": vias,         "routeNet": {"net": net, "layer": layers[path[0].2], "width": width, "viaSize": via_size, "viaDrill": via_drill, "paths": [way]},-        "planner": {"algorithm": "bounded orthogonal A*", "widthFrom": width_from, "viaFrom": via_from, "gridMm": grid, "layers": layers, "clearanceMm": clearance, "edgeClearanceMm": edge, "expandedNodes": seen.len()},-        "revision": revision,-        "_hint": "A plan only: pass routeNet to route_net unchanged (with expectedRevision: revision). A layer change is two waypoints at the same point, where route_net places a via. Pours (and a hatched pour's net-less pieces) are not obstacles: repour after placing the route. The path is orthogonal on the grid: a finer gridMm finds tighter paths and searches longer."})+        "planner": {"algorithm": "bounded orthogonal A*", "widthFrom": width_from, "viaFrom": via_from, "gridMm": grid, "layers": layers, "viasCheckedOn": copper, "clearanceMm": clearance, "edgeClearanceMm": edge, "expandedNodes": seen.len()},+        "crossesPours": crosses, "avoidPours": avoid_pours, "revision": revision,+        "_hint": if crosses.is_empty() { "A plan only: pass routeNet to route_net unchanged (with expectedRevision: revision). A layer change is two waypoints at the same point, where route_net places a via. Pours (and a hatched pour's net-less pieces) are not obstacles: repour after placing the route. The path is orthogonal on the grid: a finer gridMm finds tighter paths and searches longer." }+                 else { "A plan only: pass routeNet to route_net unchanged (with expectedRevision: revision). The route runs through the pours in crossesPours: after the repour, check that none of those nets split in two (routing_state's splitNets), or plan again with avoidPours: true to keep out of them. A layer change is two waypoints at the same point, where route_net places a via." }}) }  #[cfg(test)]@@ -350,4 +372,36 @@ mod tests {         assert_eq!(given["routeNet"]["width"], json!(0.4));         assert_eq!(plan_route(&json!({"board": board(json!([])), "args": {"net": "SIG", "from": "U1.1", "to": "J1.1"}}))["planner"]["widthFrom"], "default 0.25");     }+    #[test]+    fn a_through_via_clears_every_copper_layer_not_only_the_planned_ones() {+        // a wall on F.Cu forces two vias to In1.Cu; a GND track on B.Cu along y 5 is on no planned layer, but every via+        // passes through B.Cu (fusion-lane #21)+        let t = json!([{"net": "GND", "layer": "F.Cu", "width": 0.5, "start": {"x": 10.0, "y": 0.0}, "end": {"x": 10.0, "y": 10.0}},+                       {"net": "GND", "layer": "B.Cu", "width": 0.5, "start": {"x": 1.0, "y": 5.0}, "end": {"x": 19.0, "y": 5.0}}]);+        let r = plan_route(&json!({"board": board(t), "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "layers": ["F.Cu", "In1.Cu"]}}));+        assert_eq!(r["found"], json!(true), "{r}");+        assert_eq!(r["vias"], 2);+        for v in r["viaAt"].as_array().unwrap() { assert!((f(&v[1]) - 5.0).abs() >= 0.25 + 0.3 + 0.2 - 1e-9, "{v}"); }+        assert!(r["planner"]["viasCheckedOn"].as_array().unwrap().contains(&json!("B.Cu")));+    }++    #[test]+    fn the_strictest_board_wide_clearance_rule_decides() {+        let rules = json!([{"kind": "clearance", "name": "wire-wire", "clearanceMm": 0.1}, {"kind": "clearance", "name": "pad-via", "clearanceMm": 0.15},+                           {"name": "Clearance HV", "scope1": "InNet('HV')", "scope2": "All", "descriptor": "Clearance Constraint (Gap=0.5mm) (InNet('HV')),(All)"}]);+        let r = plan_route(&json!({"board": board(json!([])), "rules": rules, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1"}}));+        assert_eq!(r["planner"]["clearanceMm"], json!(0.15), "{r}");+        // fab_check still compares the smallest with a fab's limits+        assert_eq!(crate::fab::min_clearance(rules.as_array().unwrap()), Some(0.1));+    }++    #[test]+    fn a_route_names_the_pours_it_crosses_or_keeps_out_of_them() {+        let mut b = board(json!([]));+        b["polygons"] = json!([{"net": "GND", "layer": "F.Cu", "points": [[8.0, 0.0], [12.0, 0.0], [12.0, 10.0], [8.0, 10.0]]}]);+        let r = plan_route(&json!({"board": b, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1"}}));+        assert_eq!((r["vias"].as_u64(), r["crossesPours"].clone()), (Some(0), json!([{"net": "GND", "layer": "F.Cu"}])), "{r}");+        let around = plan_route(&json!({"board": b, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "avoidPours": true}}));+        assert_eq!((around["vias"].as_u64(), around["crossesPours"].clone()), (Some(2), json!([])), "{around}");+    } }
tests/run_verb_tests.py+4−1
@@ -253,6 +253,9 @@ t("fusion_return_path", "daily", B, {"ok": True, "fields": ["engine"]}) t("fusion_impedance", "daily", dict(B, z0=50), {"ok": True, "fields": ["layers"]}) t("fusion_current_capacity", "daily", dict(B, nets={"VBUS": 3}), {"ok": True, "fields": ["nets"]}) t("fusion_fab_check", "daily", dict(B, vendor="jlcpcb"), {"ok": True, "fields": ["measured", "outsideLimits", "fab"]}, "fetches JLCPCB's record from adom/pcb-design-rules")+t("fusion_estimate_cost", "daily", dict(B, quantities=[5, 30], assembly=True), {"ok": True, "fields": ["prices", "source", "engine"]})+t("fusion_read_board", "daily", dict(B, sections="components,nets,rules,outline", limit=5), {"ok": True, "fields": ["data", "counts"]})+t("fusion_add_rule", "manual", note="edits the board's net classes: tested live on lane-BQ25792-copy and put back") t("fusion_height_check", "daily", dict(B, maxTopMm=8, minGapMm=0.1), {"ok": True, "fields": ["heights", "noBody", "engine"]}, "reads fusion_bodies (switches to the 3D PCB and back)") t("fusion_placement_check", "daily", dict(B, marginMm=0.1), {"ok": True, "fields": ["parts", "courtyardOverlaps", "engine"]}) t("fusion_check_clearance", "daily", dict(B, mode="pads", thresholdMm=0.2), {"ok": True, "fields": ["engine"]})@@ -495,7 +498,7 @@ _SECTION_BY_VERB = {     "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_height_check": "routing", "fusion_placement_check": "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_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_add_rule": "routing", "fusion_height_check": "routing", "fusion_placement_check": "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_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",