← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.13.4\ No newline at end of file+1.13.5\ No newline at end of file
CHANGELOG.md+22−1
@@ -3,7 +3,28 @@ 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.4 (unreleased)+## 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+any board. 303 verbs.++- `fusion_plan_route` is the shared engine's (#21): A* on any copper layers listed, pads by their real outline, planned to+  the board's strictest copper clearance and its copper-to-edge rule, so the plan is one `fusion_route_net` accepts.+  `routeNet` carries `boardId` and `expectedRevision`. Fine-pitch pads need `gridMm` 0.1.+- New, read only, from the shared engine (#22):+  - `fusion_check_clearance`: part-to-part clearance by courtyard (with 3D bodies) or by pad copper;+  - `fusion_place_decoupling`: a plan placing an IC's decoupling caps beside its power pins, as `fusion_move_parts`+    arguments, checked with placement_check;+  - `fusion_plan_fanout`: a part's fanout as `fusion_route_net` calls, with the conflicts against other nets' pads.+- `fusion_route_net` works on any board, not only two-layer ones without pours:+  - any copper layer, by name (In1.Cu) or number; a through pad or via is on every layer of the stack;+  - pours are repoured round the new copper, and a route that cuts another net's pour in two is undone (`splits_net`;+    `allowSplit` keeps it). On BQ25792 a planned N$15 cut VBAT's pour and was undone;+  - curved tracks, footprint and board copper, keepouts (tRestrict, bRestrict) and footprint mounting holes are+    obstacles; pads are measured by their real outline;+  - `viaSize` is accepted for `viaDiameter`; a clearance refusal names the copper it would hit and where.++## 1.13.4 (2026-10-28, public)  The standard's shared engine 0.5.1 (standard 0.8.1) makes 3D bodies, and placement checks know about molecules. 
bridge.json+8−2
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.13.4",+  "version": "1.13.5",   "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.",@@ -65,6 +65,7 @@     "fusion_cancel_run",     "fusion_capture_library_views",     "fusion_change_pours",+    "fusion_check_clearance",     "fusion_check_dialogs",     "fusion_check_recovery",     "fusion_claim_seat",@@ -203,8 +204,10 @@     "fusion_open_schematic",     "fusion_part_parameters",     "fusion_physical_properties",+    "fusion_place_decoupling",     "fusion_place_vias",     "fusion_placement_check",+    "fusion_plan_fanout",     "fusion_plan_library_import",     "fusion_plan_route",     "fusion_pour_state",@@ -489,7 +492,10 @@       "via_fence": 180,       "board_features": 180,       "height_check": 150,-      "placement_check": 150+      "placement_check": 150,+      "check_clearance": 150,+      "place_decoupling": 150,+      "plan_fanout": 150     },     "prefixes": [       {
contracts/describe.json+111−17
@@ -882,6 +882,32 @@       "summary": "Change existing pours in place: the pen width (the narrowest copper the fill can reach; a pen wider than a throat leaves it empty) and the isolation. Each pour is named by net, layer and outline, picked on the midpoint of its longest edge, then RATSNEST refills. The readback proves each pour took its new width/isolate and that no track or via changed, and reports the filled area before and after.",       "timeoutSeconds": 120     },+    {+      "example": {+        "mode": "courtyard",+        "thresholdMm": 0.2+      },+      "input": {+        "expectDocument": "required: the board's name",+        "includeSameNet": "bool (pads mode)",+        "maxPairs": "int",+        "mode": "courtyard|pads, default courtyard",+        "refs": "[ref] limit to these parts (default all)",+        "thresholdMm": "report pairs closer than this"+      },+      "longRunning": false,+      "name": "fusion_check_clearance",+      "output": {+        "bodiesUsed": "bool",+        "engine": "str",+        "mode": "str",+        "ok": "bool",+        "pairs": "[{parts, gapMm, at, side|layer}]"+      },+      "statusVerb": null,+      "summary": "Part-to-part clearance (the standard's check_clearance), from the shared checks engine on the live board and fusion_bodies: courtyard mode compares part boxes (pads and 3D bodies) on the same side; pads mode compares pad copper of different parts sharing a copper layer, by its real outline. Pairs closer than thresholdMm, nearest first.",+      "timeoutSeconds": 150+    },     {       "example": {},       "input": {},@@ -17672,6 +17698,31 @@       "summary": "Per-component physical properties (volume cm3, mass kg, density, area, center of mass) for the active design. Reads them directly so BOM/costing tools no longer have to run getPhysicalProperties inside a modeling script. Pass names to scope to specific components.",       "timeoutSeconds": 120     },+    {+      "example": {+        "ic": "U1"+      },+      "input": {+        "caps": "[ref] (default the caps on its power nets)",+        "expectDocument": "required: the board's name",+        "gapMm": "cap to IC box, default the engine's",+        "ic": "the IC (required)"+      },+      "longRunning": false,+      "name": "fusion_place_decoupling",+      "output": {+        "engine": "str",+        "leftOut": "[{cap, net, why, detail}]",+        "moveArgs": "fusion_move_parts' arguments",+        "placements": "[{cap, net, pin, powerPad, x, y, rotation, side, padToPinMm, from}]",+        "revision": "str",+        "skipped": "[{cap, pin, why}]",+        "validation": "placement_check with the caps moved"+      },+      "statusVerb": null,+      "summary": "Plan decoupling capacitors beside an IC's power pins (the standard's place_decoupling), from the shared checks engine: each cap gapMm outside the IC's box on the side of the power pin it serves, turned so its power pad faces the pin, slid along that side when the spot is taken; the plan is checked with placement_check. A plan: moveArgs is fusion_move_parts' arguments, boardId and expectedRevision included.",+      "timeoutSeconds": 150+    },     {       "example": {},       "input": {@@ -17717,6 +17768,39 @@       "summary": "Review the placement before routing (the standard's placement_check): parts whose courtyards (pads and 3D body, grown by marginMm) overlap on the same side, parts off or over the board edge, pads closer than edgeMm to the edge, and parts on a mounting hole: the shared checks engine on the live board and fusion_bodies (pads only, bodiesUsed:false, when the bodies cannot be read).",       "timeoutSeconds": 150     },+    {+      "example": {+        "ref": "U1"+      },+      "input": {+        "clearanceMm": "can only raise the board's",+        "escapeLayer": "copper layer",+        "expectDocument": "required: the board's name",+        "ref": "the part (required)",+        "stubMm": "stub length",+        "style": "BGA: dogbone|via-in-pad",+        "viaDrillMm": "mm",+        "viaMm": "via size",+        "viaPerimeter": "bool: a via at every perimeter stub",+        "widthMm": "stub width"+      },+      "longRunning": false,+      "name": "fusion_plan_fanout",+      "output": {+        "callCount": "int",+        "calls": "[fusion_route_net arguments]",+        "conflicts": "[{from, what, pad, padNet, gapMm}]",+        "engine": "str",+        "package": "str",+        "pads": "[{pad, net, action, ...}]",+        "revision": "str",+        "viaCount": "int",+        "warnings": "[str]"+      },+      "statusVerb": null,+      "summary": "Plan a part's fanout (the standard's plan_fanout), from the shared checks engine on the live board and its stack-up: a BGA's outer ring escapes on the surface and each inner ball takes a dogbone via (or a via in the pad); a perimeter package's pads take an outward stub (a via at its end for ground, or with viaPerimeter); an exposed pad a via grid. calls are fusion_route_net arguments, one per net; conflicts lists vias and stubs that would come too close to another net's pad.",+      "timeoutSeconds": 150+    },     {       "example": {         "parts": [@@ -17749,40 +17833,48 @@     {       "example": {         "from": "U1.12",+        "layers": [+          "F.Cu",+          "In1.Cu",+          "In2.Cu",+          "B.Cu"+        ],         "net": "SDA",         "to": "J3.4"       },       "input": {         "allowVias": "bool default true",-        "boardId": "optional: checked when given",+        "clearanceMm": "can only raise the board's",+        "edgeClearanceMm": "can only raise the board's",         "expectDocument": "required: the board's name",-        "expectedRevision": "optional: checked when given",-        "from": "the start pad as Ref.Pad (fromPad also works)",-        "gridMm": "0.1..5, default 0.5 (grid also works)",-        "maxNodes": "int 1..100000 default 25000",+        "from": "the start pad as Ref.Pad",+        "gridMm": "0.05..5, default 0.5",+        "layers": "[copper layer] default [F.Cu, B.Cu]; list them all (e.g. [F.Cu, In1.Cu, In2.Cu, B.Cu]) so a via is checked on every layer it passes",+        "maxNodes": "default 50000",         "net": "required str",-        "timeoutMs": "int 10..5000 default 3000",-        "to": "the end pad (toPad also works)",+        "to": "the end pad",         "viaCost": "a via costs as much as this many mm, default 5",-        "viaDrillMm": "default 0.3 (viaDrill also works)",-        "viaSizeMm": "default 0.6 (viaDiameter also works)",-        "widthMm": "track width, default 0.25 (width also works)"+        "viaDrillMm": "default the board's via rule",+        "viaSizeMm": "default the board's via rule",+        "widthMm": "default the board's width rule, else 0.25"       },       "longRunning": false,       "name": "fusion_plan_route",       "output": {+        "engine": "str",         "found": "bool",         "lengthMm": "number",         "mutated": "false",-        "plan": "{items,route,planner}",+        "planner": "{algorithm, gridMm, layers, clearanceMm, edgeClearanceMm, widthFrom, viaFrom}",         "reason": "str (found false)",         "revision": "str",         "routeNet": "fusion_route_net's arguments",         "success": "bool",+        "viaAt": "[[x,y]]",         "vias": "int"       },       "statusVerb": "fusion_routing_state",-      "summary": "Plan a path for one net between two pads on the live board without changing it (the standard's plan_route): a bounded orthogonal two-layer A* search around the existing copper and the shared conservative clearance model, with vias where they help. Answered as routeNet, this bridge's fusion_route_net arguments, to pass on unchanged; found:false with a reason when no path fits the search limits. Native DRC is still the proof after the route.",+      "summary": "Plan a path for one net between two pads on the live board without changing it (the standard's plan_route), from the shared checks engine (adom-eda-checks): a bounded orthogonal A* on any copper layers listed (default F.Cu and B.Cu), with through vias between them; pads by their real outline, the board's strictest copper clearance and copper-to-edge rule. Answered as routeNet, fusion_route_net's arguments (boardId and expectedRevision included), to pass on unchanged; found:false with a reason when no path fits. Pours are not obstacles: fusion_route_net undoes a route that cuts another net's pour in two. Fine-pitch pads need gridMm 0.1.",       "timeoutSeconds": 60     },     {@@ -18466,16 +18558,17 @@     {       "example": {},       "input": {+        "allowSplit": "bool: keep a route that splits another net",         "boardId": "required from routing_state",-        "clearance": "mm lower bound, default .2",+        "clearance": "mm, can only raise the board's rules",         "dryRun": "optional bool",         "expectDocument": "required exact board name",         "expectedRevision": "required from routing_state",-        "layer": "F.Cu or 1, B.Cu or 16; default F.Cu",+        "layer": "the first copper layer, by name or number; default F.Cu",         "net": "required exact name",         "paths": "optional list of paths instead of points",-        "points": "waypoints [x,y] or {pad} or {x,y,layer}",-        "viaDiameter": "mm, default .6",+        "points": "waypoints [x,y] or {pad} or {x,y,layer}; a waypoint's layer is the layer after it (a via where it changes)",+        "viaDiameter": "mm, default .6 (viaSize also works: fusion_plan_route's routeNet)",         "viaDrill": "mm, default .3",         "width": "mm, default .25"       },@@ -18486,11 +18579,12 @@         "plan": "obj",         "remainingNets": "list",         "revision": "str",+        "splitNets": "[net] (splits_net)",         "success": "bool",         "verified": "bool"       },       "statusVerb": "fusion_routing_state",-      "summary": "Create explicit net copper in the live Fusion canvas. Conservative clearance preflight, board/revision guards and exact copper readback. One or several trace segments per call; no general autorouting. Use routing_drc for native DRC.",+      "summary": "Create explicit net copper in the live Fusion canvas, on any copper layer of the board (F.Cu, In1.Cu ... B.Cu, or EAGLE's numbers). Clearance preflight against pads by their real outline, tracks and arcs, vias, holes (footprint mounting holes too), footprint and board copper and keepouts (tRestrict/bRestrict), with board/revision guards and exact copper readback. Pours are repoured round the new copper; a route that cuts another net's pour in two is undone (splits_net) unless allowSplit. One or several paths per call; no general autorouting. Use routing_drc for native DRC.",       "timeoutSeconds": 120     },     {
dashboard/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "fusion-dashboard"-version = "1.13.4"+version = "1.13.5" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.13.4"+version = "1.13.5" edition = "2021"  [dependencies]
describe.py+18−6
@@ -27,12 +27,12 @@ _T = [     ("fusion_routing_state", "Read the live board as the EDA Bridge Standard's board model, from a fresh Fusion export in the background: millimetres in Fusion's board frame, copper layers named F.Cu / In1.Cu / B.Cu, tracks, arcs (center, radius, angles), vias, every pad with its size, shape, rotation and net (a custom-shape pad as its bounding rectangle), parts with side and footprint, pours, net-less copper kept with an empty net, the outline as edges with its arcs, and unconnected / splitNets computed from the copper itself. The revision changes only with copper, parts or outline: edit verbs take it back as expectedRevision. Also carries the AI flow's keys (segments, elements, airwireCount, remainingNets, each pad's key REF.PAD).",      {"expectDocument":"required exact active board name","boardId":"optional document creationId guard"},      {"model":"'eda-bridge-standard/board 0.1'","boardId":"str","revision":"str","units":"mm","copperLayers":"[F.Cu..B.Cu]","nets":"[name]","tracks":"[{start,end,width,layer,net}]","arcs":"[{center,radius,startAngle,endAngle,width,layer,net}]","vias":"[{position,size,drill,layers,net}]","pads":"[{ref,name,key,x,y,sizeX,sizeY,rotation,layer,drill,net,shape,customShape?}]","components":"[{ref,x,y,rotation,side,pattern,has3d}]","polygons":"[{net,layer,points}]","outline":"[[x,y]] (arcs sampled every 2 degrees)","outlineEdges":"[{kind:line|arc,start,end,center?,radius?,angle1?,angle2?}]","unconnected":"int from the copper","splitNets":"[{net,groups}]","fusionAirwires":"int, Fusion's own ratsnest","segments":"list (AI flow)","remainingNets":"list (AI flow)"},60,"fusion_routing_state",False,{"expectDocument":"fusion-routing"}),-    ("fusion_plan_route", "Plan a path for one net between two pads on the live board without changing it (the standard's plan_route): a bounded orthogonal two-layer A* search around the existing copper and the shared conservative clearance model, with vias where they help. Answered as routeNet, this bridge's fusion_route_net arguments, to pass on unchanged; found:false with a reason when no path fits the search limits. Native DRC is still the proof after the route.",-     {"expectDocument":"required: the board's name","net":"required str","from":"the start pad as Ref.Pad (fromPad also works)","to":"the end pad (toPad also works)","widthMm":"track width, default 0.25 (width also works)","viaSizeMm":"default 0.6 (viaDiameter also works)","viaDrillMm":"default 0.3 (viaDrill also works)","gridMm":"0.1..5, default 0.5 (grid also works)","allowVias":"bool default true","viaCost":"a via costs as much as this many mm, default 5","maxNodes":"int 1..100000 default 25000","timeoutMs":"int 10..5000 default 3000","boardId":"optional: checked when given","expectedRevision":"optional: checked when given"},-     {"success":"bool","mutated":"false","found":"bool","routeNet":"fusion_route_net's arguments","lengthMm":"number","vias":"int","reason":"str (found false)","plan":"{items,route,planner}","revision":"str"},60,"fusion_routing_state",False,{"net":"SDA","from":"U1.12","to":"J3.4"}),-    ("fusion_route_net", "Create explicit net copper in the live Fusion canvas. Conservative clearance preflight, board/revision guards and exact copper readback. One or several trace segments per call; no general autorouting. Use routing_drc for native DRC.",-     {"expectDocument":"required exact board name","boardId":"required from routing_state","expectedRevision":"required from routing_state","net":"required exact name","points":"waypoints [x,y] or {pad} or {x,y,layer}","paths":"optional list of paths instead of points","width":"mm, default .25","layer":"F.Cu or 1, B.Cu or 16; default F.Cu","viaDiameter":"mm, default .6","viaDrill":"mm, default .3","clearance":"mm lower bound, default .2","dryRun":"optional bool"},-     {"success":"bool","mutated":"bool","verified":"bool","revision":"str","plan":"obj","remainingNets":"list"},120,"fusion_routing_state",False,{}),+    ("fusion_plan_route", "Plan a path for one net between two pads on the live board without changing it (the standard's plan_route), from the shared checks engine (adom-eda-checks): a bounded orthogonal A* on any copper layers listed (default F.Cu and B.Cu), with through vias between them; pads by their real outline, the board's strictest copper clearance and copper-to-edge rule. Answered as routeNet, fusion_route_net's arguments (boardId and expectedRevision included), to pass on unchanged; found:false with a reason when no path fits. Pours are not obstacles: fusion_route_net undoes a route that cuts another net's pour in two. Fine-pitch pads need gridMm 0.1.",+     {"expectDocument":"required: the board's name","net":"required str","from":"the start pad as Ref.Pad","to":"the end pad","layers":"[copper layer] default [F.Cu, B.Cu]; list them all (e.g. [F.Cu, In1.Cu, In2.Cu, B.Cu]) so a via is checked on every layer it passes","widthMm":"default the board's width rule, else 0.25","viaSizeMm":"default the board's via rule","viaDrillMm":"default the board's via rule","gridMm":"0.05..5, default 0.5","allowVias":"bool default true","viaCost":"a via costs as much as this many mm, default 5","clearanceMm":"can only raise the board's","edgeClearanceMm":"can only raise the board's","maxNodes":"default 50000"},+     {"success":"bool","mutated":"false","found":"bool","routeNet":"fusion_route_net's arguments","lengthMm":"number","vias":"int","viaAt":"[[x,y]]","reason":"str (found false)","planner":"{algorithm, gridMm, layers, clearanceMm, edgeClearanceMm, widthFrom, viaFrom}","revision":"str","engine":"str"},60,"fusion_routing_state",False,{"net":"SDA","from":"U1.12","to":"J3.4","layers":["F.Cu","In1.Cu","In2.Cu","B.Cu"]}),+    ("fusion_route_net", "Create explicit net copper in the live Fusion canvas, on any copper layer of the board (F.Cu, In1.Cu ... B.Cu, or EAGLE's numbers). Clearance preflight against pads by their real outline, tracks and arcs, vias, holes (footprint mounting holes too), footprint and board copper and keepouts (tRestrict/bRestrict), with board/revision guards and exact copper readback. Pours are repoured round the new copper; a route that cuts another net's pour in two is undone (splits_net) unless allowSplit. One or several paths per call; no general autorouting. Use routing_drc for native DRC.",+     {"expectDocument":"required exact board name","boardId":"required from routing_state","expectedRevision":"required from routing_state","net":"required exact name","points":"waypoints [x,y] or {pad} or {x,y,layer}; a waypoint's layer is the layer after it (a via where it changes)","paths":"optional list of paths instead of points","width":"mm, default .25","layer":"the first copper layer, by name or number; default F.Cu","viaDiameter":"mm, default .6 (viaSize also works: fusion_plan_route's routeNet)","viaDrill":"mm, default .3","clearance":"mm, can only raise the board's rules","allowSplit":"bool: keep a route that splits another net","dryRun":"optional bool"},+     {"success":"bool","mutated":"bool","verified":"bool","revision":"str","plan":"obj","remainingNets":"list","splitNets":"[net] (splits_net)"},120,"fusion_routing_state",False,{}),     ("fusion_move_parts", "Place parts on the live board: one MOVE (absolute, mm) and ROTATE per part, each its own undo step, then a fresh export reads every element back. The layout half of the AI flow's Fusion lane (adom-aiflow place land). Coordinates are Fusion's (y up).",      {"expectDocument":"required exact board name","boardId":"required from routing_state","expectedRevision":"required from routing_state","parts":"required [{ref, x, y, rotation}] rotation in degrees or an EAGLE rotation like R90 / MR180"},      {"success":"bool","verified":"bool","moved":"[ref]","mismatched":"[{ref,want,got}]","revision":"str"},120,"fusion_routing_state",False,{}),@@ -270,6 +270,18 @@ _T = [      {"expectDocument": "required: the board's name", "marginMm": "courtyard margin, default 0.1", "edgeMm": "pad to edge, default 0.5"},      {"ok": "bool", "parts": "int", "courtyardOverlaps": "[{parts, side, overlapMm}]", "offOrOverEdge": "[{ref, where}]", "nearEdge": "[{ref, padToEdgeMm}]", "onMountingHoles": "[{ref, hole}]", "bodiesUsed": "bool", "engine": "str"},      150, None, False, {'marginMm': 0.25}),+    ('fusion_check_clearance', "Part-to-part clearance (the standard's check_clearance), from the shared checks engine on the live board and fusion_bodies: courtyard mode compares part boxes (pads and 3D bodies) on the same side; pads mode compares pad copper of different parts sharing a copper layer, by its real outline. Pairs closer than thresholdMm, nearest first.",+     {"expectDocument": "required: the board's name", "refs": "[ref] limit to these parts (default all)", "mode": "courtyard|pads, default courtyard", "thresholdMm": "report pairs closer than this", "includeSameNet": "bool (pads mode)", "maxPairs": "int"},+     {"ok": "bool", "pairs": "[{parts, gapMm, at, side|layer}]", "mode": "str", "bodiesUsed": "bool", "engine": "str"},+     150, None, False, {'mode': 'courtyard', 'thresholdMm': 0.2}),+    ('fusion_place_decoupling', "Plan decoupling capacitors beside an IC's power pins (the standard's place_decoupling), from the shared checks engine: each cap gapMm outside the IC's box on the side of the power pin it serves, turned so its power pad faces the pin, slid along that side when the spot is taken; the plan is checked with placement_check. A plan: moveArgs is fusion_move_parts' arguments, boardId and expectedRevision included.",+     {"expectDocument": "required: the board's name", "ic": "the IC (required)", "caps": "[ref] (default the caps on its power nets)", "gapMm": "cap to IC box, default the engine's"},+     {"placements": "[{cap, net, pin, powerPad, x, y, rotation, side, padToPinMm, from}]", "skipped": "[{cap, pin, why}]", "leftOut": "[{cap, net, why, detail}]", "moveArgs": "fusion_move_parts' arguments", "validation": "placement_check with the caps moved", "revision": "str", "engine": "str"},+     150, None, False, {'ic': 'U1'}),+    ('fusion_plan_fanout', "Plan a part's fanout (the standard's plan_fanout), from the shared checks engine on the live board and its stack-up: a BGA's outer ring escapes on the surface and each inner ball takes a dogbone via (or a via in the pad); a perimeter package's pads take an outward stub (a via at its end for ground, or with viaPerimeter); an exposed pad a via grid. calls are fusion_route_net arguments, one per net; conflicts lists vias and stubs that would come too close to another net's pad.",+     {"expectDocument": "required: the board's name", "ref": "the part (required)", "style": "BGA: dogbone|via-in-pad", "escapeLayer": "copper layer", "viaMm": "via size", "viaDrillMm": "mm", "widthMm": "stub width", "clearanceMm": "can only raise the board's", "stubMm": "stub length", "viaPerimeter": "bool: a via at every perimeter stub"},+     {"package": "str", "pads": "[{pad, net, action, ...}]", "calls": "[fusion_route_net arguments]", "callCount": "int", "viaCount": "int", "conflicts": "[{from, what, pad, padNet, gapMm}]", "warnings": "[str]", "revision": "str", "engine": "str"},+     150, None, False, {'ref': 'U1'}),     ('fusion_export_odb', "The board as ODB++ through Fusion's own exporter (the standard's export_odb): copper, mask, paste, silkscreen, dielectrics, drill and rout, the netlist (eda/data) and the components in one tree, packed as asked. Fusion reports an error after writing the tree; the bridge finds the tree by its matrix, checks it and packs it.",      {"expectDocument": "required: the board's name", "outputDir": "folder on the Fusion machine (default %TEMP%/adom-odb)", "compression": "zip|tgz|none, default zip"},      {"path": "str", "layers": "[str] (the matrix)", "files": "int", "nets": "int", "camJob": "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.4"+DASH_VERSION="1.13.5" 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.4",+  "version": "1.13.5",   "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+1−1
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.13.4",+  "version": "1.13.5",   "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": {
rust/Cargo.lock+6−6
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-eda-checks"-version = "0.5.1"+version = "0.8.0" dependencies = [  "roxmltree",  "serde_json",@@ -19,7 +19,7 @@ dependencies = [  [[package]] name = "adom-fusion-bridge"-version = "1.13.4"+version = "1.13.5" dependencies = [  "adom-eda-checks",  "base64",@@ -299,7 +299,7 @@ dependencies = [  [[package]] name = "fusion-core"-version = "1.13.4"+version = "1.13.5" dependencies = [  "serde",  "serde_json",@@ -310,7 +310,7 @@ dependencies = [  [[package]] name = "fusion-lbr"-version = "1.13.4"+version = "1.13.5" dependencies = [  "regex",  "roxmltree",@@ -322,14 +322,14 @@ dependencies = [  [[package]] name = "fusion-os-mac"-version = "1.13.4"+version = "1.13.5" dependencies = [  "fusion-core", ]  [[package]] name = "fusion-os-win"-version = "1.13.4"+version = "1.13.5" 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.4"+version = "1.13.5" edition = "2021" license = "MIT" 
rust/bridge/src/checks.rs+36−1
@@ -38,7 +38,16 @@ pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Val             }         }     }-    if matches!(cmd, "height_check" | "placement_check") {+    if matches!(cmd, "plan_route" | "plan_fanout") {+        // route_net's preflight holds the route to the board's strictest copper clearance (the+        // engine takes the smallest rule) and its copper-to-edge rule: plan to the same, so the+        // plan is one route_net accepts. A caller's larger value still wins.+        for k in ["clearanceMm", "edgeClearanceMm"] {+            let lim = inputs["routeLimits"][k].as_f64().unwrap_or(0.0);+            if args[k].as_f64().map_or(true, |v| v < lim) && lim > 0.0 { bundle["args"][k] = json!(lim); }+        }+    }+    if matches!(cmd, "height_check" | "placement_check" | "check_clearance" | "place_decoupling") {         let bodies = crate::bodies::bodies(args, pass);         if bodies["success"].as_bool() == Some(true) { bundle["bodies"] = bodies; }         else if cmd == "height_check" { return bodies; }@@ -50,6 +59,32 @@ pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Val     }     for k in ["boardId", "documentName", "revision"] { if !inputs[k].is_null() { r[k] = inputs[k].clone(); } }     if matches!(cmd, "thermal_vias" | "via_fence") { return place(r, args, pass); }+    if cmd == "plan_fanout" {+        // each call is one fusion_route_net; every route changes the revision, so the next call+        // takes the revision the previous reply answered+        if let Some(calls) = r["calls"].as_array_mut() {+            for c in calls.iter_mut() {+                for (k, v) in [("boardId", inputs["boardId"].clone()), ("expectDocument", args["expectDocument"].clone())] { if !v.is_null() { c[k] = v; } }+            }+        }+        r["mutated"] = json!(false);+        r["_hint"] = json!("A plan: send each call to fusion_route_net in turn, with expectedRevision set to revision for the first and to the previous reply's revision after that. Drop or shorten whatever conflicts lists first. fusion_route_net undoes a call that splits another net's pour.");+    }+    if cmd == "place_decoupling" && r["moveArgs"].is_object() {+        r["mutated"] = json!(false);+        r["_hint"] = json!("A plan: pass moveArgs to fusion_move_parts unchanged (it carries boardId and expectedRevision); validation is placement_check on the planned spots. Then route the caps short to the power pins.");+    }+    // the follow-up verb's guards, so its arguments pass on unchanged+    for key in ["routeNet", "moveArgs"] {+        if !r[key].is_object() { continue; }+        for (k, v) in [("boardId", inputs["boardId"].clone()), ("expectedRevision", inputs["revision"].clone()), ("expectDocument", args["expectDocument"].clone())] {+            if !v.is_null() { r[key][k] = v; }+        }+    }+    if cmd == "plan_route" && r["routeNet"].is_object() {+        r["mutated"] = json!(false);+        r["_hint"] = json!("Pass routeNet to fusion_route_net unchanged (it carries boardId and expectedRevision), then fusion_repour: pours are not obstacles to the plan, and Fusion repours them round the new copper. fusion_run_drc is still the proof. Fine-pitch pads need gridMm 0.1.");+    }     r } 
rust/bridge/src/routing.rs+1−1
@@ -3,7 +3,7 @@ use serde_json::{json, Value}; const SOURCE: &str = concat!(include_str!("routing_script.py"), "\n", include_str!("routing_planner.py"), "\n", include_str!("board_model.py"),     "\nFUSION_DEFAULT_RULES = r'''", include_str!("fusion_default_designrules.xml"), "'''\n");-pub fn owns(cmd: &str) -> bool { matches!(cmd, "routing_state" | "route_net" | "routing_drc" | "plan_route" | "move_parts" | "place_vias" | "apply_pours" | "pour_state" | "board_source" | "ripup_nets" | "schematic_run" | "board_bom" | "change_pours" | "schematic_plan" | "board_seed" | "remove_pours" | "layer_stack" | "netlist" | "bodies" | "component_data" | "component_pins" | "list_nets" | "net_connections" | "list_designators" | "layers" | "copper_layers" | "layers_in_use" | "read_rules" | "rules_by_kind" | "silk_state" | "net_classes" | "run_drc" | "repour" | "zoom_to" | "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") } 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")); }
rust/bridge/src/routing_script.py+118−26
@@ -279,14 +279,62 @@ def distance(a,b,c,d):   def poly_distance(a,b,poly):-    # Segment to convex polygon: 0 when the segment touches or enters it.-    def inside(p):-        sides=[(q[0]-p0[0])*(p[1]-p0[1])-(q[1]-p0[1])*(p[0]-p0[0]) for p0,q in zip(poly,poly[1:]+poly[:1])]-        return all(v>=0 for v in sides) or all(v<=0 for v in sides)-    if inside(a) or inside(b): return 0+    # Segment to polygon (convex or not, e.g. a comb-shaped footprint copper): 0 when the segment+    # touches or enters it, else the nearest edge.+    if point_in_loop(a,poly) or point_in_loop(b,poly): return 0     return min(distance(a,b,p0,q) for p0,q in zip(poly,poly[1:]+poly[:1]))  +def artwork_obstacles(board,pads,stack):+    """Free copper and keepouts as route obstacles, from the board and from each placed footprint:+    wires (arcs as chords), circles, rectangles and polygons on copper; tRestrict (41) and+    bRestrict (42) keep every net's copper off their side. Footprint copper takes the net of the+    part's pad it touches, so the pad's own net may run through it."""+    out=[]+    def add(n,xf,mirrored,ref):+        layer=n.get('layer')+        num={'41':1,'42':16}.get(layer) or int(layer)+        if mirrored and num in (1,16): num=17-num+        keepout=layer in ('41','42')+        w=float(n.get('width','0') or 0)/2+        f=lambda x,y: list(xf(float(x),float(y)))+        def owner(ring):+            if keepout or ref is None: return None+            for k,p_ in pads.items():+                if k.split('.',1)[0]==ref and num in p_['layers'] and (point_in_loop(p_['at'],ring) or any(point_in_loop(v,ring) for v in (p_.get('poly') or []))):+                    return p_['net']+            return None+        if n.tag=='wire':+            pts,sag=arc_points(f(n.get('x1'),n.get('y1')),f(n.get('x2'),n.get('y2')),float(n.get('curve','0') or 0)*(-1 if mirrored else 1))+            for a_,b_ in zip(pts,pts[1:]): out.append((None if keepout else owner([a_,b_,a_]),a_,b_,w+sag,[num],None))+        elif n.tag=='circle':+            c=f(n.get('x'),n.get('y')); r=float(n.get('radius'))+            # a filled disc (width 0) or a ring; a ring's hole is not trusted as free space+            out.append((owner([[c[0]+r*math.cos(k*math.pi/8),c[1]+r*math.sin(k*math.pi/8)] for k in range(16)]),c,c,r+w,[num],None))+        else:+            if n.tag=='rectangle':+                x1,y1,x2,y2=(float(n.get(k)) for k in ('x1','y1','x2','y2'))+                cx,cy=(x1+x2)/2,(y1+y2)/2; rot=math.radians(float(re.sub(r'[^0-9.\-]','',n.get('rot','R0')) or 0))+                loc=[(cx+(x-cx)*math.cos(rot)-(y-cy)*math.sin(rot),cy+(x-cx)*math.sin(rot)+(y-cy)*math.cos(rot)) for x,y in ((x1,y1),(x2,y1),(x2,y2),(x1,y2))]+                ring=[f(x,y) for x,y in loc]+            else:+                ring=[f(v.get('x'),v.get('y')) for v in shape_vertices(n)]+            if len(ring)<3: return+            c=ring[0]; out.append((owner(ring),c,c,w,[num],ring))+    for n in board.findall('plain/*'):+        if n.tag in ('wire','circle','rectangle','polygon','polygonshape') and n.get('layer') in set(str(x) for x in stack)|{'41','42'}:+            add(n,lambda x,y:(x,y),False,None)+    packages={(l.get('name'),p_.get('name')):p_ for l in board.findall('libraries/library') for p_ in l.findall('packages/package')}+    for el in board.findall('elements/element'):+        pkg=packages.get((el.get('library'),el.get('package')))+        if pkg is None: continue+        xf,_,mirrored=placement(el)+        for n in pkg:+            if n.tag in ('wire','circle','rectangle','polygon','polygonshape') and n.get('layer') in ('1','16','41','42'):+                add(n,xf,mirrored,el.get('name'))+    return out++ def arc_points(p1,p2,curve):     """An EAGLE wire from p1 to p2 sweeping `curve` degrees (positive = counterclockwise), as     points p1..p2 at most 5 degrees apart, plus the largest gap between chord and arc."""@@ -351,12 +399,16 @@ def plan(board,args):     nets,pads,wires,vias=objects(board)     net=identifier(args.get('net'))     if net not in nets: fail('unknown_net','No exact net named '+net)+    # any copper layer of the board's stack, by number or by the standard's name (F.Cu, In1.Cu, B.Cu)+    L=Layers(board); by_name={v.upper():k for k,v in L.names.items()}     def layer(v):+        if isinstance(v,str) and v.strip().upper() in by_name: v=by_name[v.strip().upper()]         v=eagle_layer(v)-        if v not in (1,16): fail('invalid_layer','Only copper layers 1 (F.Cu) and 16 (B.Cu) are supported')+        if v not in L.order: fail('invalid_layer','Layer %s is not a copper layer of this board (%s)'%(v,', '.join(L.name(n) for n in L.order)))         return v     width=number(args.get('width',0.25),True)-    dia=number(args.get('viaDiameter',0.6),True); drill=number(args.get('viaDrill',0.3),True)+    # viaSize is the standard's plan_route name (its routeNet passes through unchanged)+    dia=number(args.get('viaDiameter',args.get('viaSize',0.6)),True); drill=number(args.get('viaDrill',0.3),True)     if drill>=dia: fail('invalid_geometry','Via drill must be smaller than its diameter')     paths=args.get('paths',[args.get('points')])     if not isinstance(paths,list) or not 1<=len(paths)<=32: fail('invalid_path','Supply 1..32 paths')@@ -377,7 +429,7 @@ def plan(board,args):             elif isinstance(spec,list) and len(spec)==2: xy=spec             else: fail('invalid_path','Waypoints are [x,y], {x,y,layer?}, or {pad,layer?}')             xy=[grid(number(v)) for v in xy]   # on Fusion's grid, so the readback matches exactly-            if pad and current not in pad['layers']: fail('pad_layer_mismatch','Pad has no copper on incoming layer')+            if pad and current not in (L.order if pad['layers']==[1,16] else pad['layers']): fail('pad_layer_mismatch','Pad %s has no copper on %s'%(spec['pad'],L.name(current)))             if previous is not None and previous!=xy:                 planned.append({'kind':'segment','net':net,'start':previous,'end':xy,'layer':current,'width':width})             if outgoing!=current:@@ -397,27 +449,31 @@ def plan(board,args):     clearance=ctx['clearance']; obstacles=ctx['obstacles']     for item in planned:         via=item['kind']=='via'; a=item['at'] if via else item['start']; b=a if via else item['end']-        radius=ctx['via_dia'](item['diameter'],item['drill'])/2 if via else item['width']/2; layers=[1,16] if via else [item['layer']]+        radius=ctx['via_dia'](item['diameter'],item['drill'])/2 if via else item['width']/2; layers=Layers(board).order if via else [item['layer']]         why=outline_problem(ctx,a,b,radius)         if why: fail('clearance_violation',why)         for other,c,d,r,ls,poly in obstacles:             if other==net or not set(layers).intersection(ls): continue-            gap=(poly_distance(a,b,poly) if poly else distance(a,b,c,d)-r)-radius-            if gap<clearance-1e-6: fail('clearance_violation','Candidate copper approaches another net or hole closer than %.3f mm'%clearance)+            gap=(min(prim_distance(('seg',tuple(a),tuple(b),0.0),q) for q in poly[1]) if poly and poly[0]=='prims' else poly_distance(a,b,poly) if poly else distance(a,b,c,d)-r)-radius+            if gap<clearance-1e-6:+                what=('net %s'%other) if other else 'a hole, keepout or free copper'+                fail('clearance_violation','Candidate copper (%s at (%.3f, %.3f)%s on %s) comes %.3f mm from %s at (%.3f, %.3f); the clearance is %.3f mm'%(+                    'via' if via else 'track',a[0],a[1],'' if via else '-(%.3f, %.3f)'%(b[0],b[1]),'all layers' if via else Layers(board).name(item['layer']),max(gap,0),what,c[0],c[1],clearance))     return {'net':net,'items':planned,'clearance':clearance,'preflight':'Conservative geometry only; run native Autodesk DRC after edits'}   def routing_context(board,args,net,width,drill):     nets,pads,wires,vias=objects(board)-    setup=board.find("designrules/param[@name='layerSetup']")-    if setup is None or setup.get('value') != '(1*16)':-        fail('unsupported_board','Initial routing supports two-layer (1*16) boards')-    # Do not silently overlook obstacles this conservative preflight cannot represent.-    if board.findall('.//polygon') or any(w['curve'] for w in wires):-        fail('unsupported_board','Polygons and curved copper require a richer clearance preflight')+    # any stack-up: a through pad, via or hole is on every copper layer of it. Pours are not+    # obstacles: Fusion repours them round the new copper (RATSNEST after the route).+    stack=Layers(board).order+    # artwork the preflight models (artwork_obstacles); a via keepout, milling or copper text it does not+    copper=set(str(n) for n in stack)     for n in board.findall('plain/*') + board.findall('libraries/library/packages/package/*'):-        if n.tag not in ('pad','smd') and n.get('layer') in ('1','16','41','42','43','46'):-            fail('unsupported_board','Copper, restrict or milling artwork is not supported by this preflight')+        if n.tag in ('pad','smd','hole'): continue+        modelled=n.tag in ('wire','circle','rectangle','polygon','polygonshape') and n.get('layer') in copper|{'41','42'}+        if n.get('layer') in copper|{'41','42','43','46'} and not modelled:+            fail('unsupported_board','%s on layer %s (a via keepout, milling or copper text) is not modelled by this preflight'%(n.tag,n.get('layer')))     def rule(name,default):         node=board.find("designrules/param[@name='%s']"%name)         if node is None: return default@@ -443,8 +499,18 @@ def routing_context(board,args,net,width,drill):     # stays conservative on a concave curve.     loops,sagitta=outline_loops(board)     obstacles=[]-    for p in pads.values(): obstacles.append((p['net'],p['at'],p['at'],p['radius'],p['layers'],p.get('poly')))-    for w in wires: obstacles.append((w['net'],w['start'],w['end'],w['width']/2,[w['layer']],None))+    # pads by their real outline (board_model's primitives: a round or long pad a fat segment, the+    # rest its polygon), as the engine's plan_route measures them; the bounding circle refused a via+    # 1.3 mm from a long pad's centre on BQ25792+    contacts={(c.get('element'),c.get('pad')):sg.get('name') for sg in board.findall('signals/signal') for c in sg.findall('contactref')}+    exact={row['ref']+'.'+row['name']:pad_prims(row) for row in pads_of(board,Layers(board),contacts)[0].values()}+    for k,p in pads.items():+        prims=exact.get(k)+        obstacles.append((p['net'],p['at'],p['at'],p['radius'],stack if p['layers']==[1,16] else p['layers'],('prims',prims) if prims else p.get('poly')))+    for w in wires:+        # a curved track is its arc as short chords, each grown by the chord-to-arc gap+        pts,sag=arc_points(w['start'],w['end'],w['curve'])+        for a_,b_ in zip(pts,pts[1:]): obstacles.append((w['net'],a_,b_,w['width']/2+sag,[w['layer']],None))     # a via's copper is its diameter, or the drill plus the board's via restring when the     # diameter is left to the rules (0); the old drill*2+0.6 doubled a 0.6 mm via     def ratio(name,default):@@ -454,11 +520,18 @@ def routing_context(board,args,net,width,drill):     def via_dia(d,drill):         ring=min(max(drill*ratio('rvViaOuter',0.25),rule('rlMinViaOuter',0.2032)),rule('rlMaxViaOuter',0.508))         return max(d,drill+2*ring)-    for v in vias: obstacles.append((v['net'],v['at'],v['at'],via_dia(v['diameter'],v['drill'])/2,[1,16],None))+    for v in vias: obstacles.append((v['net'],v['at'],v['at'],via_dia(v['diameter'],v['drill'])/2,stack,None))     for h in board.findall('plain/hole'):-        xy=[float(h.get('x')),float(h.get('y'))]; obstacles.append((None,xy,xy,float(h.get('drill'))/2,[1,16],None))-    if board.findall('libraries/library/packages/package/hole'):-        fail('unsupported_board','Footprint mounting holes require transformed obstacle support')+        xy=[float(h.get('x')),float(h.get('y'))]; obstacles.append((None,xy,xy,float(h.get('drill'))/2,stack,None))+    obstacles+=artwork_obstacles(board,pads,stack)+    # a footprint's mounting holes, placed with their part+    packages={(l.get('name'),p_.get('name')):p_ for l in board.findall('libraries/library') for p_ in l.findall('packages/package')}+    for el in board.findall('elements/element'):+        pkg=packages.get((el.get('library'),el.get('package')))+        if pkg is None: continue+        xf,_,_=placement(el)+        for h in pkg.findall('hole'):+            xy=list(xf(float(h.get('x')),float(h.get('y')))); obstacles.append((None,xy,xy,float(h.get('drill'))/2,stack,None))     return {'via_dia':via_dia,'clearance':clearance,'edgeClearance':rule('mdCopperDimension',0.5)+sagitta,'outline':loops,'bounds':(min(p[0] for p in loops[0]),max(p[0] for p in loops[0]),min(p[1] for p in loops[0]),max(p[1] for p in loops[0])),'obstacles':obstacles,'pads':pads}  @@ -2711,6 +2784,10 @@ def check_inputs(app, root, board, meta, args):             # only the copper clearances: Fusion's other rule kinds also say "Clearance" (Dimension,             # Solder-Mask, Restrict, Keepout) and the engine takes any such kind's value             'pours': {'zones': zones(board)}, 'rules': {'rules': std},+            # what route_net's own preflight holds a route to: the strictest copper clearance and the+            # copper-to-edge rule, so a plan the engine finds is one route_net accepts+            'routeLimits': {'clearanceMm': max([r_['clearanceMm'] for r_ in std if r_['kind'] == 'clearance'] or [0.2]),+                            'edgeClearanceMm': round(_rule_len(board, 'mdCopperDimension', 0.5), 4)},             'silk': rd_silk_state(root, board, {})}  @@ -3841,6 +3918,8 @@ def handle(app,args,action):         if 'dryRun' in args and not isinstance(args['dryRun'],bool): fail('invalid_args','dryRun must be boolean')         if args.get('dryRun') is True: return {'success':True,'mutated':False,**meta,'plan':candidate}         before=objects(board); before_items=copper_signature(before[2]+before[3])+        # nets already in pieces before the route (a pour cut by new copper splits its net)+        split_before={x['net'] for x in state(board,meta).get('splitNets') or []}         # Every emitted edit is complete (no mouse-bound interactive operation).         # Explicit mm coordinates/width suffixes avoid changing the document grid.         for item in candidate['items']:@@ -3861,10 +3940,23 @@ def handle(app,args,action):         expected=copper_signature(before[2]+before[3]+candidate['items'])         # Compare full copper coverage: a branch may split an existing straight wire.         verified=actual==expected+        after_state=state(after,after_meta)+        # Pours are not obstacles to a plan: Fusion repours round the new copper, and a track that+        # cuts another net's pour in two disconnects that net (BQ25792's VBAT, 2026-10-28). Undo it.+        split_new=sorted({x['net'] for x in after_state.get('splitNets') or []}-split_before-{candidate['net']})+        if verified and split_new and not args.get('allowSplit'):+            for _ in commands: app.executeTextCommand('Electron.run UNDO;')+            app.executeTextCommand('Electron.run RATSNEST;')+            _,back,back_meta=snapshot(app,args); back_objs=objects(back)+            undone=copper_signature(back_objs[2]+back_objs[3])==before_items+            return {'success':False,'mutated':not undone,'verified':undone,'errorCode':'splits_net','splitNets':split_new,+                    'error':'The route cuts %s in two (a pour split by the new copper)%s'%(', '.join(split_new),'; it was undone' if undone else '; the undo did not restore the copper exactly: read fusion_routing_state'),+                    '_hint':'Plan it on another layer (fusion_plan_route layers:[...]), or around the pour; allowSplit:true keeps a route that splits a net.',+                    'commands':commands,'plan':candidate,**state(back,back_meta)}         return {'success':verified,'mutated':actual!=before_items,'verified':verified,                 'errorCode':None if verified else 'readback_mismatch',                 'error':None if verified else 'Fusion copper differs from the exact requested delta; inspect current state before continuing',-                'commands':commands,'plan':candidate,**state(after,after_meta)}+                'commands':commands,'plan':candidate,**after_state}     except Exception as exc:         return {'success':False,'errorCode':getattr(exc,'code','routing_failed'),'error':str(exc),                 'mutationAttempted':attempted,'completedCommands':commands,
rust/vendor/adom-eda-checks/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.5.1"+version = "0.8.0" edition = "2021" description = "The EDA Bridge Standard's checks and board-file writers, computed from the board model JSON: no bridge calls, no tool." license = "MIT"
rust/vendor/adom-eda-checks/README.md+7−3
@@ -11,13 +11,17 @@ 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 | | `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`) | | `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 | | `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) | | `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 | @@ -25,8 +29,8 @@ The via plans size their vias from `args.size`/`drill`, else the board's via rul or Altium's Routing Via descriptor), else 0.6/0.3, and say which in `planned.via.from`.  The other checks-tier verbs (assembly, pair routing, length tuning,-designators, panels, the board report, schematic review, and the planners estimate_cost, check_clearance, place_decoupling, plan_fanout-and plan_route) come over a module at a time.+designators, panels, the board report, schematic review, and the planners estimate_cost, check_clearance, place_decoupling and plan_fanout)+come over a module at a time.  ## Writers @@ -89,7 +93,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.5.1"`), so a bridge reports the engine it runs without writing the version by hand.+(`"adom-eda-checks 0.8.0"`), so a bridge reports the engine it runs without writing the version by hand.  ## Tests 
rust/vendor/adom-eda-checks/VENDOR.md+3−3
@@ -3,9 +3,9 @@ The EDA Bridge Standard's shared checks engine, copied unchanged from adom/eda-bridge-standard `engine/` (the wiki has no git protocol, so Cargo cannot fetch it). -- Standard version: 0.8.1-- Standard commit: 99b602db3c790d84c2c7e7ed285f06ddbda260f3-- Crate version: 0.5.1+- Standard version: 0.11.0+- Standard commit: 4ad294c79b924d942e71e296b573d65f3fe7b86d+- Crate version: 0.8.0 - Copied: 2026-10-28, by fusion-lane  To update: clone the standard fresh (`adom-wiki repo clone adom/eda-bridge-standard`), copy `engine/src`,
rust/vendor/adom-eda-checks/src/features.rs+29
@@ -188,6 +188,26 @@ pub fn via_style(rules: &[Value]) -> Option<(f64, f64)> { }  +/// The board's preferred track width in mm. A standard rule gives it as `{kind: "width", preferredMm}` (or `widthMm`);+/// Altium's Width rule descriptor is read too, e.g. "Width Constraint (Min=10mil) (Max=10mil) (Preferred=10mil) (All)":+/// the board-wide one (scope All), highest priority first, its Preferred else its Min. Pure.+pub fn width_style(rules: &[Value]) -> Option<f64> {+    if let Some(r) = rules.iter().filter(|r| r["enabled"] != json!(false))+        .find(|r| r["kind"].as_str().map(|k| k.eq_ignore_ascii_case("width")).unwrap_or(false)) {+        if let Some(v) = r["preferredMm"].as_f64().or_else(|| r["widthMm"].as_f64()) { return Some(v); }+    }+    let num = |d: &str, key: &str| -> Option<f64> {+        let i = d.find(&format!("({key}="))? + key.len() + 2;+        let s = &d[i..d[i..].find(')')? + i];+        let v: f64 = s.trim_end_matches(char::is_alphabetic).parse().ok()?;+        Some(if s.ends_with("mil") { v * 0.0254 } else { v })+    };+    let mut rs: Vec<&Value> = rules.iter().filter(|r| r["enabled"] != json!(false) && r["descriptor"].as_str().map(|d| d.starts_with("Width")).unwrap_or(false)).collect();+    rs.sort_by_key(|r| (r["scope1"].as_str() != Some("All"), r["priority"].as_i64().unwrap_or(99)));+    let d = rs.first()?["descriptor"].as_str()?;+    num(d, "Preferred").or_else(|| num(d, "Min")).map(|v| (v * 10000.0).round() / 10000.0)+}+ /// Thermal via grid points inside a pad (x, y, sx, sy, rotation mm): `pitch` apart, `inset` from the pad's edge plus /// the via's radius, centred. Pure. pub fn thermal_grid(x: f64, y: f64, sx: f64, sy: f64, rot: f64, pitch: f64, size: f64, inset: f64) -> Vec<(f64, f64)> {@@ -408,4 +428,13 @@ mod tests {         assert_eq!(via_fence(&json!({"board": st, "args": {"viaNet": "NOPE", "nets": ["RF"]}}))["errorCode"], "no_such_net");         assert_eq!(thermal_vias(&json!({"board": st, "args": {"ref": "U9"}}))["errorCode"], "part_not_found");     }+    #[test]+    fn width_style_reads_the_standard_rule_and_altiums_descriptor() {+        assert_eq!(width_style(&[json!({"kind": "width", "preferredMm": 0.2})]), Some(0.2));+        let alt = vec![json!({"enabled": true, "scope1": "InNet('VIN')", "priority": 1, "descriptor": "Width Constraint (Min=20mil) (Max=40mil) (Preferred=30mil) (InNet('VIN'))"}),+                       json!({"enabled": true, "scope1": "All", "priority": 2, "descriptor": "Width Constraint (Min=10mil) (Max=10mil) (Preferred=10mil) (All)"})];+        assert_eq!(width_style(&alt), Some(0.254), "the board-wide rule, not a net's");+        assert_eq!(width_style(&[json!({"descriptor": "Width Constraint (Min=0.15mm) (Max=1mm) (All)", "scope1": "All"})]), Some(0.15));+        assert_eq!(width_style(&[]), None);+    } }
rust/vendor/adom-eda-checks/src/lib.rs+7−1
@@ -16,6 +16,8 @@ pub mod geom; pub mod impedance; pub mod input; pub mod placement;+pub mod placetools;+pub mod route; pub mod writers;  /// This engine's version. Every reply carries it as `engine` ("adom-eda-checks 0.3.0"), so a bridge never writes it by hand@@ -23,7 +25,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] = &["copper_balance", "current_capacity", "fab_check", "height_check", "impedance", "placement_check", "return_path", "thermal_vias", "via_fence"];+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"];  /// Run one check or writer by its standard name. The reply carries `engine`. pub fn run(check: &str, input: &Value) -> Value {@@ -35,12 +37,16 @@ pub fn run(check: &str, input: &Value) -> Value { fn answer(check: &str, input: &Value) -> Value {     if let Some(out) = writers::run(check, input) { return out; }     match check {+        "check_clearance" => placetools::check_clearance(input),         "copper_balance" => coppermap::copper_balance(input),         "current_capacity" => current::current_capacity(input),         "fab_check" => fab::fab_check(input),         "height_check" => bodies::height_check(input),         "impedance" => impedance::impedance(input),+        "place_decoupling" => placetools::place_decoupling(input),+        "plan_fanout" => placetools::plan_fanout(input),         "placement_check" => placement::placement_check(input),+        "plan_route" => route::plan_route(input),         "return_path" => coppermap::return_path(input),         "thermal_vias" => features::thermal_vias(input),         "via_fence" => features::via_fence(input),
rust/vendor/adom-eda-checks/src/placetools.rsadded+650
@@ -0,0 +1,650 @@+//! Placement helpers from the KiCad bridge's place_tools.rs: the gaps between parts (their boxes or their pad copper)+//! and a plan for decoupling capacitors beside an IC's power pins, answered as move_parts arguments. The board model has+//! no courtyards, so a part is the box of its pads and, with the bodies companion, of its 3D body, as placement_check+//! measures it. Pure.+use crate::route::{pad_shape, Shape};+use serde_json::{json, Value};+use std::collections::BTreeMap;++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }+type P = (f64, f64);+type Rect = (f64, f64, f64, f64);++/// The gap between two boxes, 0 when they touch or overlap.+fn rect_gap(a: Rect, b: Rect) -> f64 {+    let dx = (b.0 - a.2).max(a.0 - b.2).max(0.0);+    let dy = (b.1 - a.3).max(a.1 - b.3).max(0.0);+    (dx * dx + dy * dy).sqrt()+}++fn union(a: Rect, b: Rect) -> Rect { (a.0.min(b.0), a.1.min(b.1), a.2.max(b.2), a.3.max(b.3)) }++/// References and pad names in natural order: C2 before C10.+fn ref_key(r: &str) -> (String, u64, String) {+    let head: String = r.chars().take_while(|c| !c.is_ascii_digit()).collect();+    let rest = &r[head.len()..];+    let num: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();+    (head.to_uppercase(), num.parse().unwrap_or(0), rest[num.len()..].to_string())+}++fn prefix(r: &str) -> String { r.chars().take_while(|c| c.is_ascii_alphabetic()).collect::<String>().to_uppercase() }++pub fn is_ground(net: &str) -> bool {+    let n = net.trim_start_matches('/').to_uppercase();+    n.contains("GND") || n.starts_with("VSS") || n == "0V" || n.ends_with("_VSS")+}++/// A net named like a supply: +5V, VCC, VDD_CORE, VBAT, 3V3, 1V8.+fn power_name(net: &str) -> bool {+    let n = net.trim_start_matches('/').to_uppercase();+    let c: Vec<char> = n.chars().collect();+    n.starts_with('+') || n.starts_with('V') || ["VCC", "VDD", "VBAT", "VIN", "VBUS", "PWR", "SUPPLY"].iter().any(|k| n.contains(k))+        || (1..c.len()).any(|i| c[i] == 'V' && c[i - 1].is_ascii_digit())+}++/// The pad's box, turned with it.+fn pad_box(at: P, sx: f64, sy: f64, rot: f64) -> Rect {+    let (c, s) = (rot.to_radians().cos().abs(), rot.to_radians().sin().abs());+    let (hx, hy) = ((sx * c + sy * s) / 2.0, (sx * s + sy * c) / 2.0);+    (at.0 - hx, at.1 - hy, at.0 + hx, at.1 + hy)+}++fn turn(p: P, deg: f64) -> P {+    let (c, s) = (deg.to_radians().cos(), deg.to_radians().sin());+    (p.0 * c - p.1 * s, p.0 * s + p.1 * c)+}++/// One part: where it is, its pads and its bodies' boxes.+struct Part { at: P, rot: f64, bottom: bool, pads: Vec<Value>, bodies: Vec<Rect> }++impl Part {+    /// A point of the part, after moving the part to `at` turned to `rot` (counterclockwise as seen).+    fn moved(&self, p: P, at: P, rot: f64) -> P {+        let q = turn((p.0 - self.at.0, p.1 - self.at.1), rot - self.rot);+        (at.0 + q.0, at.1 + q.1)+    }+    /// The part's box at `at` turned to `rot`: its pads, and its bodies' boxes turned with it.+    fn box_at(&self, at: P, rot: f64) -> Option<Rect> {+        let d = rot - self.rot;+        let mut b: Option<Rect> = None;+        for p in &self.pads {+            let r = pad_box(self.moved((f(&p["x"]), f(&p["y"])), at, rot), f(&p["sizeX"]), f(&p["sizeY"]), f(&p["rotation"]) + d);+            b = Some(b.map(|x| union(x, r)).unwrap_or(r));+        }+        for x in &self.bodies {+            for c in [(x.0, x.1), (x.2, x.1), (x.2, x.3), (x.0, x.3)] {+                let q = self.moved(c, at, rot);+                let r = (q.0, q.1, q.0, q.1);+                b = Some(b.map(|x| union(x, r)).unwrap_or(r));+            }+        }+        b+    }+}++/// The board's parts by reference (as given), with their bodies when the companion came.+fn parts(st: &Value, input: &Value) -> BTreeMap<String, Part> {+    let mut m: BTreeMap<String, Part> = BTreeMap::new();+    for c in st["components"].as_array().cloned().unwrap_or_default() {+        let Some(r) = c["ref"].as_str().filter(|r| !r.is_empty()) else { continue };+        m.insert(r.to_string(), Part { at: (f(&c["x"]), f(&c["y"])), rot: f(&c["rotation"]), bottom: c["side"] == "bottom", pads: Vec::new(), bodies: Vec::new() });+    }+    for p in st["pads"].as_array().cloned().unwrap_or_default() {+        if let Some(part) = p["ref"].as_str().and_then(|r| m.get_mut(r)) { part.pads.push(p); }+    }+    for b in crate::bodies::input_boxes(input).unwrap_or_default() {+        if let Some(part) = m.get_mut(&b.part) { part.bodies.push((b.x0, b.y0, b.x1, b.y1)); }+    }+    m+}++fn find<'a>(m: &'a BTreeMap<String, Part>, r: &str) -> Option<(&'a String, &'a Part)> { m.iter().find(|(k, _)| k.eq_ignore_ascii_case(r)) }++/// check_clearance: input {board, bodies?, args: {refs?, mode?: courtyard|pads, thresholdMm?, includeSameNet?, maxPairs?}}.+/// courtyard: part boxes (pads, and 3D bodies when given) on the same side; pads: pad copper of different parts that+/// shares a copper layer, by its real outline.+pub fn check_clearance(input: &Value) -> Value {+    let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+    let args = &input["args"];+    let mode = args["mode"].as_str().unwrap_or("courtyard");+    if mode != "courtyard" && mode != "pads" { return crate::input::refuse("bad_args", &format!("mode is courtyard or pads, not {mode}")); }+    let threshold = args.get("thresholdMm").or_else(|| args.get("threshold")).and_then(|v| v.as_f64()).unwrap_or(if mode == "pads" { 0.2 } else { 0.25 }).max(0.0);+    let limit = args["maxPairs"].as_u64().unwrap_or(200) as usize;+    let m = parts(&st, input);+    let refs: Option<Vec<String>> = args["refs"].as_array().map(|a| a.iter().filter_map(|v| v.as_str()).map(|s| s.trim().to_uppercase()).collect());+    if let Some(rs) = &refs {+        let unknown: Vec<&String> = rs.iter().filter(|r| find(&m, r).is_none()).collect();+        if !unknown.is_empty() { return crate::input::refuse("no_such_part", &format!("not parts on this board: {unknown:?}")); }+    }+    let wanted = |r: &str| refs.as_ref().map(|rs| rs.iter().any(|x| x.eq_ignore_ascii_case(r))).unwrap_or(true);+    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;+    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();+        for i in 0..boxes.len() {+            for j in i + 1..boxes.len() {+                let ((ra, sa, a), (rb, sb, b)) = (boxes[i], boxes[j]);+                if sa != sb || !(wanted(ra) || wanted(rb)) { continue; }+                checked += 1;+                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}));+                }+            }+        }+        measured = if crate::bodies::input_boxes(input).is_some() { "part box: its pads and 3D body" } else { "part box: its pads only (pass bodies to add the 3D bodies)" };+    } else {+        let same_net = args["includeSameNet"].as_bool().unwrap_or(false);+        let pads: Vec<&Value> = m.values().flat_map(|p| p.pads.iter()).collect();+        let shapes: Vec<(Shape, Rect)> = pads.iter().map(|p| { let s = pad_shape(p); let b = s.bounds(); (s, b) }).collect();+        let through = |p: &Value| p["layer"] == "*.Cu" || f(&p["drill"]) > 0.0;+        for i in 0..pads.len() {+            for j in i + 1..pads.len() {+                let (a, b) = (pads[i], pads[j]);+                let (ra, rb) = (a["ref"].as_str().unwrap_or(""), b["ref"].as_str().unwrap_or(""));+                if ra == rb || !(wanted(ra) || wanted(rb)) { continue; }+                let (na, nb) = (a["net"].as_str().unwrap_or(""), b["net"].as_str().unwrap_or(""));+                if !same_net && !na.is_empty() && na == nb { continue; }+                if !(through(a) || through(b) || a["layer"] == b["layer"]) { continue; }+                checked += 1;+                if rect_gap(shapes[i].1, shapes[j].1) >= threshold { continue; }+                let d = shapes[i].0.gap(&shapes[j].0);+                if d < threshold {+                    let (ka, kb) = (format!("{ra}.{}", a["name"].as_str().unwrap_or("")), format!("{rb}.{}", b["name"].as_str().unwrap_or("")));+                    let ((x, nx), (y, ny)) = if ref_key(&ka) <= ref_key(&kb) { ((ka, na), (kb, nb)) } else { ((kb, nb), (ka, na)) };+                    pairs.push(json!({"a": x, "b": y, "netA": nx, "netB": ny, "distanceMm": r3(d), "touching": d == 0.0}));+                }+            }+        }+        measured = "pads: their real copper (copperPoints when given, round pads as circles or stadiums, others as their turned rectangle)";+    }+    pairs.sort_by(|x, y| f(&x["distanceMm"]).partial_cmp(&f(&y["distanceMm"])).unwrap_or(std::cmp::Ordering::Equal));+    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,+        "_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." }})+}++/// The side of the box nearest a pin: its outward normal and name (y up: top is +y).+fn side_of(b: Rect, pin: P) -> (P, &'static str) {+    let d = [(pin.0 - b.0, (-1.0, 0.0), "left"), (b.2 - pin.0, (1.0, 0.0), "right"), (pin.1 - b.1, (0.0, -1.0), "bottom"), (b.3 - pin.1, (0.0, 1.0), "top")];+    let best = d.iter().min_by(|a, b| a.0.partial_cmp(&b.0).unwrap()).unwrap();+    (best.1, best.2)+}++/// A capacitor across ground and one other net: (its pad on that net, the net).+fn decoupling_role(r: &str, p: &Part) -> Option<(Value, String)> {+    if prefix(r) != "C" || p.pads.len() != 2 { return None; }+    let net = |q: &Value| q["net"].as_str().unwrap_or("").to_string();+    if p.pads.iter().filter(|q| is_ground(&net(q))).count() != 1 { return None; }+    p.pads.iter().find(|q| !net(q).is_empty() && !is_ground(&net(q))).map(|q| (q.clone(), net(q)))+}++/// place_decoupling: input {board, bodies?, args: {ic, caps?, gapMm?}}. Each cap goes `gapMm` outside the IC's box on the+/// side of the power pin it serves, turned so its power pad faces the pin and lined up with it; a spot taken by another+/// part slides along that side. Answers `moveArgs` for move_parts and checks the plan with placement_check.+pub fn place_decoupling(input: &Value) -> Value {+    let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+    let args = &input["args"];+    let Some(ic_ref) = args.get("ic").or_else(|| args.get("ref")).and_then(|v| v.as_str()).map(str::trim).filter(|s| !s.is_empty()) else {+        return crate::input::refuse("bad_args", "ic is required: the IC's reference (\"U1\")");+    };+    let gap = args.get("gapMm").or_else(|| args.get("gap")).and_then(|v| v.as_f64()).unwrap_or(0.3).max(0.0);+    let m = parts(&st, input);+    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 roles: BTreeMap<&String, (Value, String)> = m.iter().filter_map(|(r, p)| Some((r, decoupling_role(r, p)?))).collect();+    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 {+                    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;+                }+                let near = |p: &Part| p.pads.iter().filter(|q| net(q) == *n).map(|q| { let (a, b) = (pad_at(q), pad_at(pp)); ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt() }).fold(f64::MAX, f64::min);+                let own = near(ic);+                if let Some((other, _)) = ics.iter().filter(|(o, _)| *o != ic_ref).map(|(o, p)| (o, near(p))).filter(|(_, d)| *d < own).min_by(|a, b| a.1.partial_cmp(&b.1).unwrap()) {+                    left_out.push(json!({"cap": r, "net": n, "why": "nearer_another_ic", "detail": format!("{r} is nearer {other}'s {n} pin; it decouples that IC. Pass caps to place it here anyway")}));+                    continue;+                }+                cap_refs.push(r.to_string());+            }+        }+    }+    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"]))); }+    }+    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;+        }+        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; }+        }+        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 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,+        "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."})+}++/// 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"}); }+    let mut board = st.clone();+    let mut bodies = crate::input::part(input, "bodies");+    for (r, at, rot) in moves {+        let p = &m[r];+        let d = rot - p.rot;+        for c in board["components"].as_array_mut().into_iter().flatten().filter(|c| c["ref"] == json!(r)) {+            c["x"] = json!(at.0); c["y"] = json!(at.1); c["rotation"] = json!(rot);+        }+        for q in board["pads"].as_array_mut().into_iter().flatten().filter(|q| q["ref"] == json!(r)) {+            let n = p.moved((f(&q["x"]), f(&q["y"])), *at, *rot);+            q["x"] = json!(n.0); q["y"] = json!(n.1); q["rotation"] = json!((f(&q["rotation"]) + d).rem_euclid(360.0));+            q.as_object_mut().map(|o| o.remove("copperPoints"));+        }+        if let Some(b) = bodies.as_mut() {+            let rows = if b.get("bodies").is_some() { b["bodies"].as_array_mut() } else { b.as_array_mut() };+            for x in rows.into_iter().flatten().filter(|x| x["ref"] == json!(r)) {+                let cs: Vec<P> = [(f(&x["x0"]), f(&x["y0"])), (f(&x["x1"]), f(&x["y0"])), (f(&x["x1"]), f(&x["y1"])), (f(&x["x0"]), f(&x["y1"]))].iter().map(|c| p.moved(*c, *at, *rot)).collect();+                x["x0"] = json!(cs.iter().map(|c| c.0).fold(f64::MAX, f64::min)); x["x1"] = json!(cs.iter().map(|c| c.0).fold(f64::MIN, f64::max));+                x["y0"] = json!(cs.iter().map(|c| c.1).fold(f64::MAX, f64::min)); x["y1"] = json!(cs.iter().map(|c| c.1).fold(f64::MIN, f64::max));+            }+        }+    }+    let r = crate::placement::placement_check(&json!({"board": board, "bodies": bodies, "args": {}}));+    if r["success"] != json!(true) { return json!({"ok": false, "check": r}); }+    let ours = |e: &Value| moves.iter().any(|(m, _, _)| e["ref"] == json!(m) || e["parts"].as_array().map(|a| a.contains(&json!(m))).unwrap_or(false));+    let keep = |k: &str| -> Vec<Value> { r[k].as_array().cloned().unwrap_or_default().into_iter().filter(|e| ours(e)).collect() };+    let (ov, off, holes, near) = (keep("courtyardOverlaps"), keep("offOrOverEdge"), keep("onMountingHoles"), keep("nearEdge"));+    json!({"ok": ov.is_empty() && off.is_empty() && holes.is_empty(), "by": "placement_check on the board with the caps moved", "courtyardOverlaps": ov, "offOrOverEdge": off, "onMountingHoles": holes, "nearEdge": near})+}++fn median(mut v: Vec<f64>) -> Option<f64> {+    if v.is_empty() { return None; }+    v.sort_by(|a, b| a.partial_cmp(b).unwrap());+    Some(v[v.len() / 2])+}++/// 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> {+    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")));+        v.push("B.Cu".into());+        return v;+    }+    let mut inner: Vec<u32> = Vec::new();+    for k in ["tracks", "arcs", "pads", "polygons"] {+        for x in st[k].as_array().into_iter().flatten() {+            if let Some(i) = x["layer"].as_str().and_then(|l| l.strip_prefix("In")).and_then(|r| r.strip_suffix(".Cu")).and_then(|n| n.parse().ok()) {+                if !inner.contains(&i) { inner.push(i); }+            }+        }+    }+    inner.sort();+    std::iter::once("F.Cu".to_string()).chain(inner.iter().map(|i| format!("In{i}.Cu"))).chain(std::iter::once("B.Cu".to_string())).collect()+}++/// plan_fanout: input {board, stack?, rules?, args: {ref, style?, escapeLayer?, viaMm?, viaDrillMm?, widthMm?, clearanceMm?,+/// pitchMm?, stubMm?, surfaceOuterRing?, viaPerimeter?}}. A BGA's outer ring escapes on the surface and each inner ball+/// takes a dogbone via (or a via in the pad); a perimeter package's pads take an outward stub (with a via at its end for+/// ground or `viaPerimeter`), an exposed pad a via grid. `calls` are route_net arguments, one per net. Every via and+/// stub is measured against other nets' pads on the whole board (`conflicts`).+pub fn plan_fanout(input: &Value) -> Value {+    let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+    let args = &input["args"];+    let Some(r) = args["ref"].as_str().map(str::trim).filter(|s| !s.is_empty()) else { return crate::input::refuse("bad_args", "ref is required: the BGA or QFN/QFP (\"U3\")") };+    let m = parts(&st, input);+    let Some((r, part)) = find(&m, r) else { return crate::input::refuse("no_such_part", &format!("{r} is not a part on this board")) };+    let side = if part.bottom { "B.Cu" } else { "F.Cu" };+    let copper = copper_layers(&st, input);+    let escape = match args["escapeLayer"].as_str() {+        Some(l) if copper.iter().any(|c| c == l) => l.to_string(),+        Some(l) => return json!({"success": false, "errorCode": "no_such_layer", "error": format!("{l} is not a copper layer of this board"), "copperLayers": copper}),+        None => copper.iter().find(|l| l.starts_with("In")).cloned().unwrap_or_else(|| if part.bottom { "F.Cu".into() } else { "B.Cu".into() }),+    };+    let pads: Vec<&Value> = part.pads.iter().filter(|p| f(&p["drill"]) == 0.0).collect();+    if pads.len() < 4 { return crate::input::refuse("not_a_package", &format!("{r} has {} surface pads: fanout is for a BGA or a QFN/QFP", pads.len())); }+    let at = |p: &Value| (f(&p["x"]), f(&p["y"]));+    let net = |p: &Value| p["net"].as_str().unwrap_or("").to_string();+    let name = |p: &Value| format!("{r}.{}", p["name"].as_str().unwrap_or(""));+    let areas: Vec<f64> = pads.iter().map(|p| f(&p["sizeX"]) * f(&p["sizeY"])).collect();+    let med_area = median(areas.clone()).unwrap_or(0.0);+    let (thermal, signal): (Vec<usize>, Vec<usize>) = (0..pads.len()).partition(|i| areas[*i] > 4.0 * med_area);+    let d = |a: P, b: P| ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt();+    let nn: Vec<f64> = signal.iter().map(|&i| signal.iter().filter(|&&j| j != i).map(|&j| d(at(pads[i]), at(pads[j]))).fold(f64::MAX, f64::min)).collect();+    let pitch = match args["pitchMm"].as_f64() { Some(v) if v > 0.0 => v, _ => match median(nn) { Some(v) => r3(v), None => return crate::input::refuse("not_a_package", "no pad pitch") } };+    let (cx, cy) = (signal.iter().map(|&i| at(pads[i]).0).sum::<f64>() / signal.len() as f64, signal.iter().map(|&i| at(pads[i]).1).sum::<f64>() / signal.len() as f64);+    let bx = signal.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, &i| { let p = at(pads[i]); (b.0.min(p.0), b.1.min(p.1), b.2.max(p.0), b.3.max(p.1)) });+    let interior = signal.iter().filter(|&&i| { let p = at(pads[i]); (p.0 - bx.0).min(bx.2 - p.0).min(p.1 - bx.1).min(bx.3 - p.1) > pitch * 0.5 }).count();+    let kind = if interior > 0 { "bga" } else { "perimeter" };+    let fine = pitch < 0.8 - 1e-9;+    let via_size = args.get("viaMm").or_else(|| args.get("viaSizeMm")).and_then(|v| v.as_f64()).unwrap_or(if fine { 0.4 } else if pitch < 1.0 - 1e-9 { 0.45 } else { 0.6 });+    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 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);+    let fits = room + 1e-9 >= clearance;+    let want = args["style"].as_str().unwrap_or("auto");+    let style = match want {+        "auto" => if fits { "dogbone" } else { "via-in-pad" },+        "dogbone" | "via-in-pad" => want,+        o => return crate::input::refuse("bad_args", &format!("style is auto, dogbone or via-in-pad, not {o}")),+    };+    let mut warnings = Vec::new();+    if kind == "bga" && style == "dogbone" && !fits {+        warnings.push(format!("a {via_size} mm dogbone via leaves {room} mm to the balls at {pitch} mm pitch, under the {clearance} mm clearance: use via-in-pad or a smaller via"));+    }+    let via_perimeter = args["viaPerimeter"].as_bool().unwrap_or(false);+    let stub = args["stubMm"].as_f64().unwrap_or(0.4);+    let wp = |p: P, l: &str| json!({"x": r3(p.0), "y": r3(p.1), "layer": l});+    let sign = |v: f64| if v < -1e-9 { -1.0 } else { 1.0 };+    let (mut rows, mut calls): (Vec<Value>, BTreeMap<String, Vec<Value>>) = (Vec::new(), BTreeMap::new());+    // what the plan puts down, to measure: (pad, net, via or stub as a capsule)+    let mut pieces: Vec<(String, String, &'static str, Shape)> = Vec::new();+    type Pieces = Vec<(String, String, &'static str, Shape)>;+    fn via(pieces: &mut Pieces, calls: &mut BTreeMap<String, Vec<Value>>, rows: &mut Vec<Value>, from: String, n: &str, v: P, size: f64, path: Vec<Value>, row: Value) {+        pieces.push((from, n.to_string(), "via", Shape::Cap(v, v, size / 2.0)));+        calls.entry(n.to_string()).or_default().push(json!(path));+        rows.push(row);+    }+    for &i in &signal {+        let p = pads[i];+        let (pa, n) = (at(p), net(p));+        if n.is_empty() { rows.push(json!({"pad": name(p), "net": "", "action": "skip", "why": "no net"})); continue; }+        if kind == "bga" {+            let ring = ((pa.0 - bx.0) / pitch).round().min(((bx.2 - pa.0) / pitch).round()).min(((pa.1 - bx.1) / pitch).round()).min(((bx.3 - pa.1) / pitch).round()) as i64;+            if ring == 0 && args["surfaceOuterRing"].as_bool() != Some(false) {+                let (nv, _) = side_of(bx, pa);+                let end = (pa.0 + nv.0 * pitch, pa.1 + nv.1 * pitch);+                pieces.push((name(p), n.clone(), "stub", Shape::Cap(pa, end, width / 2.0)));+                calls.entry(n.clone()).or_default().push(json!([{"pad": name(p)}, wp(end, side)]));+                rows.push(json!({"pad": name(p), "net": n, "ring": ring, "action": "surface", "layer": side, "stubEnd": [r3(end.0), r3(end.1)]}));+            } else if style == "dogbone" {+                let v = (pa.0 + sign(pa.0 - cx) * pitch / 2.0, pa.1 + sign(pa.1 - cy) * pitch / 2.0);+                pieces.push((name(p), n.clone(), "stub", Shape::Cap(pa, v, width / 2.0)));+                via(&mut pieces, &mut calls, &mut rows, name(p), &n, v, via_size, vec![json!({"pad": name(p)}), wp(v, side), wp(v, &escape)],+                    json!({"pad": name(p), "net": n, "ring": ring, "action": "dogbone", "layer": escape, "via": [r3(v.0), r3(v.1)]}));+            } else {+                via(&mut pieces, &mut calls, &mut rows, name(p), &n, pa, via_size, vec![wp(pa, side), wp(pa, &escape)],+                    json!({"pad": name(p), "net": n, "ring": ring, "action": "via-in-pad", "layer": escape, "via": [r3(pa.0), r3(pa.1)], "note": "via-in-pad needs filled and capped vias (IPC-4761 type VII) from the fab"}));+            }+        } else {+            let (nv, edge) = side_of(bx, pa);+            let (c, s) = (f(&p["rotation"]).to_radians().cos().abs(), f(&p["rotation"]).to_radians().sin().abs());+            let half = if nv.0 != 0.0 { f(&p["sizeX"]) * c + f(&p["sizeY"]) * s } else { f(&p["sizeX"]) * s + f(&p["sizeY"]) * c } / 2.0;+            let with_via = via_perimeter || is_ground(&n);+            let reach = half + stub + if with_via { via_size / 2.0 } else { 0.0 };+            let end = (pa.0 + nv.0 * reach, pa.1 + nv.1 * reach);+            pieces.push((name(p), n.clone(), "stub", Shape::Cap(pa, end, width / 2.0)));+            if with_via {+                via(&mut pieces, &mut calls, &mut rows, name(p), &n, end, via_size, vec![json!({"pad": name(p)}), wp(end, side), wp(end, &escape)],+                    json!({"pad": name(p), "net": n, "edge": edge, "action": "stub+via", "layer": escape, "via": [r3(end.0), r3(end.1)]}));+            } else {+                calls.entry(n.clone()).or_default().push(json!([{"pad": name(p)}, wp(end, side)]));+                rows.push(json!({"pad": name(p), "net": n, "edge": edge, "action": "surface", "layer": side, "stubEnd": [r3(end.0), r3(end.1)]}));+            }+        }+    }+    for &i in &thermal {+        let p = pads[i];+        let n = net(p);+        if n.is_empty() { continue; }+        let (pa, w, h) = (at(p), f(&p["sizeX"]), f(&p["sizeY"]));+        let (w, h) = if (f(&p["rotation"]).rem_euclid(180.0) - 90.0).abs() < 1.0 { (h, w) } else { (w, h) };+        let vp = (via_size + 0.5).max(1.0);+        let (nx, ny) = ((((w - via_size) / vp).floor() as i64 + 1).max(1), (((h - via_size) / vp).floor() as i64 + 1).max(1));+        let mut grid = Vec::new();+        for a in 0..nx {+            for b in 0..ny {+                let v = (pa.0 + (a as f64 - (nx - 1) as f64 / 2.0) * vp, pa.1 + (b as f64 - (ny - 1) as f64 / 2.0) * vp);+                pieces.push((name(p), n.clone(), "via", Shape::Cap(v, v, via_size / 2.0)));+                calls.entry(n.clone()).or_default().push(json!([wp(v, side), wp(v, &escape)]));+                grid.push(json!([r3(v.0), r3(v.1)]));+            }+        }+        rows.push(json!({"pad": name(p), "net": n, "action": "thermal-via-grid", "layer": escape, "vias": grid, "viaPitchMm": vp}));+    }+    // each via and stub against every other net's pad on the board (vias on every layer, stubs on the part's side)+    let all: Vec<(Value, Shape)> = st["pads"].as_array().cloned().unwrap_or_default().into_iter().map(|p| { let s = pad_shape(&p); (p, s) }).collect();+    let (mut worst, mut conflicts) = (f64::MAX, Vec::new());+    for (from, n, what, s) in &pieces {+        for (p, ps) in &all {+            if net(p) == *n && !n.is_empty() { continue; }+            if *what == "stub" && !(p["layer"] == side || p["layer"] == "*.Cu" || f(&p["drill"]) > 0.0) { continue; }+            let g = s.gap(ps);+            if *what == "via" { worst = worst.min(g); }+            if g < clearance - 1e-9 {+                conflicts.push(json!({"from": from, "what": what, "pad": format!("{}.{}", p["ref"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or("")), "padNet": p["net"], "gapMm": r3(g)}));+            }+        }+    }+    let via_count = pieces.iter().filter(|x| x.2 == "via").count();+    let calls: Vec<Value> = calls.into_iter().map(|(n, paths)| json!({"net": n, "layer": side, "width": width, "viaSize": via_size, "viaDrill": via_drill, "paths": paths})).collect();+    json!({"success": true, "ref": r, "package": kind, "padCount": pads.len(), "pitchMm": pitch, "side": side, "escapeLayer": escape, "copperLayers": copper,+        "style": if kind == "bga" { style } else { "stubs" }, "requestedStyle": want, "dogboneRoomMm": room, "dogboneFits": fits,+        "via": {"size": via_size, "drill": via_drill}, "widthMm": width, "clearanceMm": clearance,+        "pads": rows, "calls": calls, "callCount": calls.len(), "viaCount": via_count,+        "minViaToPadMm": if worst < f64::MAX { json!(r3(worst)) } else { Value::Null }, "conflicts": conflicts, "warnings": warnings, "revision": st["revision"],+        "_hint": "A plan only: send each call to route_net with expectedRevision: revision. conflicts lists vias and stubs closer than clearanceMm to another net's pad on the board (vias on every layer, stubs on the part's side): drop or shorten those before routing. Tracks and pours are not checked: run DRC after. Via-in-pad needs filled and capped vias: tell the user first."})+}++#[cfg(test)]+mod tests {+    use super::*;++    fn pad(r: &str, name: &str, x: f64, y: f64, net: &str) -> Value {+        json!({"ref": r, "name": name, "x": x, "y": y, "sizeX": 0.6, "sizeY": 0.6, "rotation": 0, "layer": "F.Cu", "drill": 0, "net": net, "shape": "rect"})+    }++    /// U1: a 4-pin part at the origin with VDD on its right; C1 (VDD/GND) far away, C2 on a crystal net, C3 near U2.+    fn board() -> Value {+        json!({"revision": "r1", "outline": [[-20, -20], [20, -20], [20, 20], [-20, 20]], "nets": ["VDD", "GND", "XIN"],+            "components": [{"ref": "U1", "x": 0, "y": 0, "rotation": 0, "side": "top"}, {"ref": "U2", "x": 12, "y": 0, "rotation": 0, "side": "top"},+                           {"ref": "C1", "x": -10, "y": 10, "rotation": 0, "side": "top"}, {"ref": "C2", "x": -10, "y": -10, "rotation": 0, "side": "top"},+                           {"ref": "C3", "x": 12, "y": 3, "rotation": 0, "side": "top"}],+            "pads": [pad("U1", "1", -2.0, 1.0, "XIN"), pad("U1", "2", -2.0, -1.0, "GND"), pad("U1", "3", 2.0, -1.0, "GND"), pad("U1", "4", 2.0, 1.0, "VDD"),+                     pad("U2", "1", 11.0, 1.0, "VDD"), pad("U2", "2", 11.0, -1.0, "GND"), pad("U2", "3", 13.0, 0.0, "GND"),+                     pad("C1", "1", -10.5, 10.0, "VDD"), pad("C1", "2", -9.5, 10.0, "GND"),+                     pad("C2", "1", -10.5, -10.0, "XIN"), pad("C2", "2", -9.5, -10.0, "GND"),+                     pad("C3", "1", 11.5, 3.0, "VDD"), pad("C3", "2", 12.5, 3.0, "GND")]})+    }++    #[test]+    fn a_cap_lands_beside_its_pin_with_the_power_pad_facing_it() {+        let r = place_decoupling(&json!({"board": board(), "args": {"ic": "U1"}}));+        assert_eq!(r["success"], json!(true), "{r}");+        // C1 only: C2 sits on a crystal net, C3 is nearer U2's VDD pin+        assert_eq!(r["placements"].as_array().unwrap().len(), 1, "{r}");+        let p = &r["placements"][0];+        assert_eq!((p["cap"].as_str(), p["pin"].as_str(), p["side"].as_str()), (Some("C1"), Some("U1.4"), Some("right")));+        // U1's box ends at x 2.3; 0.3 out, the power pad (now the cap's left pad) at 2.9, level with the pin+        assert_eq!(p["rotation"], json!(0.0));+        assert!((f(&p["x"]) - 3.4).abs() < 1e-9 && (f(&p["y"]) - 1.0).abs() < 1e-9, "{p}");+        assert!((f(&p["padToPinMm"]) - 0.9).abs() < 1e-9);+        let why: Vec<&str> = r["leftOut"].as_array().unwrap().iter().map(|l| l["why"].as_str().unwrap()).collect();+        assert_eq!(why, vec!["signal_net", "nearer_another_ic"]);+        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}");+    }++    #[test]+    fn a_cap_turns_to_face_the_pin_and_slides_off_a_taken_spot() {+        // C1 turned round (power pad on its right) must turn 180 to face U1's right side; a part on the spot pushes it along+        let mut b = board();+        b["pads"][7]["x"] = json!(-9.5); b["pads"][8]["x"] = json!(-10.5);+        b["components"].as_array_mut().unwrap().push(json!({"ref": "R9", "x": 3.4, "y": 1.0, "rotation": 0, "side": "top"}));+        b["pads"].as_array_mut().unwrap().push(pad("R9", "1", 3.4, 1.0, ""));+        let r = place_decoupling(&json!({"board": b, "args": {"ic": "U1", "caps": ["C1"]}}));+        let p = &r["placements"][0];+        assert_eq!(p["rotation"], json!(180.0), "{r}");+        assert!(f(&p["slidMm"]).abs() > 0.0, "{r}");+        assert_eq!(r["validation"]["ok"], json!(true), "{r}");+        // a bottom part's through-hole pad on the spot pushes it along too; its surface pads do not+        let mut b = board();+        b["components"].as_array_mut().unwrap().push(json!({"ref": "J9", "x": 3.4, "y": 1.0, "rotation": 0, "side": "bottom"}));+        b["pads"].as_array_mut().unwrap().push(pad("J9", "1", 3.4, 1.0, ""));+        assert_eq!(place_decoupling(&json!({"board": b, "args": {"ic": "U1", "caps": ["C1"]}}))["placements"][0]["slidMm"], json!(0.0));+        b["pads"][13]["layer"] = json!("*.Cu");+        b["pads"][13]["drill"] = json!(0.4);+        assert!(f(&place_decoupling(&json!({"board": b, "args": {"ic": "U1", "caps": ["C1"]}}))["placements"][0]["slidMm"]).abs() > 0.0);+    }++    #[test]+    fn the_verb_names_what_it_cannot_do() {+        assert_eq!(place_decoupling(&json!({"board": board(), "args": {}}))["errorCode"], "bad_args");+        assert_eq!(place_decoupling(&json!({"board": board(), "args": {"ic": "U7"}}))["errorCode"], "no_such_part");+        let mut b = board();+        b["components"][2]["side"] = json!("bottom");+        let r = place_decoupling(&json!({"board": b, "args": {"ic": "U1", "caps": ["C1"]}}));+        assert_eq!(r["skipped"][0]["why"], "other_side");+    }++    #[test]+    fn clearance_by_part_box_and_by_pad_copper() {+        // C3's box sits 1.4 mm above U2's+        let r = check_clearance(&json!({"board": board(), "args": {"thresholdMm": 1.5}}));+        assert_eq!(r["pairs"][0]["a"], "C3");+        assert_eq!(r["pairs"][0]["b"], "U2");+        assert!((f(&r["pairs"][0]["distanceMm"]) - 1.4).abs() < 1e-9, "{r}");+        let p = check_clearance(&json!({"board": board(), "args": {"mode": "pads", "refs": ["C3"], "thresholdMm": 2.0}}));+        // 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");+        assert_eq!(check_clearance(&json!({"board": board(), "args": {"mode": "x"}}))["errorCode"], "bad_args");+    }++    fn bga(pitch: f64, ball: f64) -> Value {+        // a 4 x 4 BGA at the origin, nets N0..N15, plus a stray resistor pad+        let mut pads = Vec::new();+        for i in 0..4 { for j in 0..4 {+            let (x, y) = ((i as f64 - 1.5) * pitch, (j as f64 - 1.5) * pitch);+            pads.push(json!({"ref": "U1", "name": format!("{}{}", (b'A' + j as u8) as char, i + 1), "x": x, "y": y, "sizeX": ball, "sizeY": ball, "rotation": 0, "layer": "F.Cu", "drill": 0, "net": format!("N{}", i * 4 + j), "shape": "round"}));+        } }+        json!({"revision": "r2", "outline": [[-10, -10], [10, -10], [10, 10], [-10, 10]], "components": [{"ref": "U1", "x": 0, "y": 0, "rotation": 0, "side": "top"}], "pads": pads})+    }++    #[test]+    fn a_bga_escapes_its_outer_ring_and_dogbones_the_rest() {+        let r = plan_fanout(&json!({"board": bga(1.0, 0.45), "stack": {"copperLayers": 4}, "args": {"ref": "U1"}}));+        assert_eq!((r["package"].as_str(), r["style"].as_str(), r["escapeLayer"].as_str()), (Some("bga"), Some("dogbone"), Some("In1.Cu")), "{r}");+        let count = |a: &str| r["pads"].as_array().unwrap().iter().filter(|p| p["action"] == a).count();+        assert_eq!((count("surface"), count("dogbone")), (12, 4));+        // B2 sits at (-0.5, -0.5): its via goes half a pitch out, away from the centre, and changes layer there+        let b2 = r["calls"].as_array().unwrap().iter().find(|c| c["paths"][0][0]["pad"] == "U1.B2").unwrap();+        assert_eq!(b2["paths"][0][1], json!({"x": -1.0, "y": -1.0, "layer": "F.Cu"}));+        assert_eq!(b2["paths"][0][2], json!({"x": -1.0, "y": -1.0, "layer": "In1.Cu"}));+        assert_eq!(r["conflicts"], json!([]), "{r}");+        // at 0.5 mm pitch the dogbone does not fit: via in pad+        let fine = plan_fanout(&json!({"board": bga(0.5, 0.3), "args": {"ref": "U1"}}));+        assert_eq!((fine["style"].as_str(), fine["escapeLayer"].as_str()), (Some("via-in-pad"), Some("B.Cu")), "{fine}");+        assert_eq!(plan_fanout(&json!({"board": bga(0.5, 0.3), "args": {"ref": "U1", "style": "dogbone"}}))["warnings"].as_array().unwrap().len(), 1);+    }++    #[test]+    fn a_qfn_takes_stubs_a_ground_via_and_a_via_grid_and_names_what_it_would_hit() {+        let mut pads = Vec::new();+        for k in 0..4 { pads.push(json!({"ref": "U2", "name": format!("{}", k + 1), "x": -2.0, "y": 0.75 - k as f64 * 0.5, "sizeX": 0.8, "sizeY": 0.25, "rotation": 0, "layer": "F.Cu", "drill": 0, "net": if k == 3 { "GND".to_string() } else { format!("S{k}") }, "shape": "rect"})); }+        for k in 0..4 { pads.push(json!({"ref": "U2", "name": format!("{}", k + 5), "x": 2.0, "y": -0.75 + k as f64 * 0.5, "sizeX": 0.8, "sizeY": 0.25, "rotation": 0, "layer": "F.Cu", "drill": 0, "net": format!("T{k}"), "shape": "rect"})); }+        pads.push(json!({"ref": "U2", "name": "9", "x": 0.0, "y": 0.0, "sizeX": 2.6, "sizeY": 2.6, "rotation": 0, "layer": "F.Cu", "drill": 0, "net": "GND", "shape": "rect"}));+        // R1's pad right where U2.5's stub ends+        pads.push(json!({"ref": "R1", "name": "1", "x": 2.9, "y": -0.75, "sizeX": 0.5, "sizeY": 0.5, "rotation": 0, "layer": "F.Cu", "drill": 0, "net": "X", "shape": "rect"}));+        let b = json!({"components": [{"ref": "U2", "x": 0, "y": 0, "rotation": 0, "side": "top"}, {"ref": "R1", "x": 2.9, "y": -0.75, "rotation": 0, "side": "top"}], "pads": pads});+        let r = plan_fanout(&json!({"board": b, "args": {"ref": "U2"}}));+        assert_eq!((r["package"].as_str(), r["pitchMm"].as_f64()), (Some("perimeter"), Some(0.5)), "{r}");+        let action = |pad: &str| r["pads"].as_array().unwrap().iter().find(|p| p["pad"] == pad).unwrap()["action"].clone();+        assert_eq!((action("U2.1"), action("U2.4"), action("U2.9")), (json!("surface"), json!("stub+via"), json!("thermal-via-grid")));+        // U2.1 at x -2 with a 0.8 mm pad: 0.4 + the 0.4 stub reaches x -2.8+        assert_eq!(r["pads"][0]["stubEnd"], json!([-2.8, 0.75]));+        assert_eq!(r["conflicts"].as_array().unwrap().iter().map(|c| c["pad"].as_str().unwrap()).collect::<Vec<_>>(), vec!["R1.1"], "{r}");+        assert_eq!(plan_fanout(&json!({"board": b, "args": {"ref": "R1"}}))["errorCode"], "not_a_package");+        assert_eq!(plan_fanout(&json!({"board": b, "args": {"ref": "U2", "escapeLayer": "In3.Cu"}}))["errorCode"], "no_such_layer");+    }++    #[test]+    fn pad_copper_gaps_are_exact() {+        let a = pad_shape(&json!({"x": 0.0, "y": 0.0, "sizeX": 1.0, "sizeY": 1.0, "shape": "rect"}));+        let b = pad_shape(&json!({"x": 2.0, "y": 0.0, "sizeX": 1.0, "sizeY": 1.0, "shape": "round"}));+        assert!((a.gap(&b) - 1.0).abs() < 1e-9);+        let inner = pad_shape(&json!({"x": 0.0, "y": 0.0, "sizeX": 0.2, "sizeY": 0.2, "shape": "rect"}));+        assert_eq!(a.gap(&inner), 0.0);+        assert_eq!(inner.gap(&a), 0.0);+    }+}
rust/vendor/adom-eda-checks/src/route.rsadded+353
@@ -0,0 +1,353 @@+//! A route between two pads of a net, planned on the board model and answered as route_net's arguments: a bounded+//! orthogonal A* over a grid on any copper layers the caller lists, with through vias between them. Every step keeps+//! `width/2 + clearance` from copper of other nets on its layer (pads by their real outline, tracks, arcs, vias), stays+//! inside the outline by the edge clearance, and no via lands in a pad. From the Fusion bridge's routing_planner.py+//! (fusion-lane, #12), which searched two layers of an EAGLE board; this one reads the standard board model. A plan+//! only: nothing changes until the bridge runs route_net with `routeNet`.+use serde_json::{json, Value};+use std::cmp::Reverse;+use std::collections::{BinaryHeap, HashMap};++type P = (f64, f64);++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }++fn seg_point(p: P, a: P, b: P) -> f64 {+    let (dx, dy) = (b.0 - a.0, b.1 - a.1);+    let l2 = dx * dx + dy * dy;+    let t = if l2 > 0.0 { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 };+    ((a.0 + t * dx - p.0).powi(2) + (a.1 + t * dy - p.1).powi(2)).sqrt()+}++/// The distance between two segments (0 when they cross).+pub(crate) fn seg_seg(a: P, b: P, c: P, d: P) -> f64 {+    let cross = |p: P, q: P, r: P| (q.0 - p.0) * (r.1 - p.1) - (q.1 - p.1) * (r.0 - p.0);+    if cross(a, b, c) * cross(a, b, d) < 0.0 && cross(c, d, a) * cross(c, d, b) < 0.0 { return 0.0; }+    seg_point(a, c, d).min(seg_point(b, c, d)).min(seg_point(c, a, b)).min(seg_point(d, a, b))+}++pub(crate) fn inside(p: P, ring: &[P]) -> bool {+    let mut c = false;+    for i in 0..ring.len() {+        let (a, b) = (ring[i], ring[(i + 1) % ring.len()]);+        if (a.1 > p.1) != (b.1 > p.1) && p.0 < (b.0 - a.0) * (p.1 - a.1) / (b.1 - a.1) + a.0 { c = !c; }+    }+    c+}++/// Copper as a shape: a capsule (a segment with a half-width: tracks, round pads, vias) or a polygon (rect pads, a+/// custom pad's copperPoints).+#[derive(Clone)]+pub(crate) enum Shape { Cap(P, P, f64), Poly(Vec<P>) }++impl Shape {+    /// Edge-to-edge distance from the segment a..b (its centreline) to this copper.+    fn dist(&self, a: P, b: P) -> f64 {+        match self {+            Shape::Cap(c, d, r) => seg_seg(a, b, *c, *d) - r,+            Shape::Poly(ring) => {+                if inside(a, ring) || inside(b, ring) { return 0.0; }+                (0..ring.len()).map(|i| seg_seg(a, b, ring[i], ring[(i + 1) % ring.len()])).fold(f64::MAX, f64::min)+            }+        }+    }+    /// Edge-to-edge distance between two pieces of copper, 0 when they touch or one holds the other.+    pub(crate) fn gap(&self, o: &Shape) -> f64 {+        match o {+            Shape::Cap(c, d, r) => (self.dist(*c, *d) - r).max(0.0),+            Shape::Poly(ring) => {+                let held = match self { Shape::Cap(c, _, _) => inside(*c, ring), Shape::Poly(own) => own.first().map(|p| inside(*p, ring)).unwrap_or(false) };+                if held { return 0.0; }+                (0..ring.len()).map(|i| self.dist(ring[i], ring[(i + 1) % ring.len()])).fold(f64::MAX, f64::min).max(0.0)+            }+        }+    }+    pub(crate) fn bounds(&self) -> (f64, f64, f64, f64) {+        match self {+            Shape::Cap(c, d, r) => (c.0.min(d.0) - r, c.1.min(d.1) - r, c.0.max(d.0) + r, c.1.max(d.1) + r),+            Shape::Poly(ring) => ring.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, p| (b.0.min(p.0), b.1.min(p.1), b.2.max(p.0), b.3.max(p.1))),+        }+    }+}++/// A pad's copper: copperPoints when the bridge sent them, else its shape turned with it.+pub(crate) fn pad_shape(p: &Value) -> Shape {+    let (x, y, sx, sy, rot) = (f(&p["x"]), f(&p["y"]), f(&p["sizeX"]), f(&p["sizeY"]), f(&p["rotation"]).to_radians());+    let pts: Vec<P> = p["copperPoints"].as_array().cloned().unwrap_or_default().iter().map(|q| (f(&q[0]), f(&q[1]))).collect();+    if pts.len() >= 3 { return Shape::Poly(pts); }+    let (c, s) = (rot.cos(), rot.sin());+    match p["shape"].as_str() {+        Some("round") | Some("circle") => {+            let (l, w) = ((sx - sy).abs() / 2.0, sx.min(sy) / 2.0);+            let (ux, uy) = if sx >= sy { (c * l, s * l) } else { (-s * l, c * l) };+            Shape::Cap((x - ux, y - uy), (x + ux, y + uy), w)+        }+        _ => Shape::Poly([(-sx, -sy), (sx, -sy), (sx, sy), (-sx, sy)].iter().map(|(u, v)| (x + u / 2.0 * c - v / 2.0 * s, y + u / 2.0 * s + v / 2.0 * c)).collect()),+    }+}++/// The copper layers a pad is on: every one for a through-hole pad (`*.Cu` or a drill), else its own.+pub(crate) fn pad_layers(p: &Value, all: &[String]) -> Vec<String> {+    let l = p["layer"].as_str().unwrap_or("");+    if l == "*.Cu" || f(&p["drill"]) > 0.0 { all.to_vec() } else { vec![l.to_string()] }+}++/// plan_route: input {board, rules?, args: {net, from, to, widthMm?, layers?, allowVias?, viaSizeMm?, viaDrillMm?, gridMm?,+/// viaCost?, clearanceMm?, edgeClearanceMm?, maxNodes?}}. `from` and `to` are pads as Ref.Pad. The width and via come from+/// the args, else the board's width and via rules (`rules`), else 0.25 mm and 0.6/0.3; `planner` says which.+pub fn plan_route(input: &Value) -> Value {+    let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+    let args = &input["args"];+    let num = |k: &str, d: f64| args.get(k).and_then(|v| v.as_f64()).unwrap_or(d);+    let Some(net) = args["net"].as_str() else { return crate::input::refuse("bad_args", "net is required") };+    if !st["nets"].as_array().map(|a| a.iter().any(|n| n.as_str().map(|s| s.eq_ignore_ascii_case(net)).unwrap_or(false))).unwrap_or(false) {+        return crate::input::refuse("no_such_net", &format!("{net} is not a net on this board"));+    }+    let pads = st["pads"].as_array().cloned().unwrap_or_default();+    let key = |p: &Value| format!("{}.{}", p["ref"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or(""));+    let mut ends = Vec::new();+    for k in ["from", "to"] {+        let Some(want) = args[k].as_str() else { return crate::input::refuse("bad_args", &format!("{k} is a pad as Ref.Pad (U1.3)")) };+        let Some(p) = pads.iter().find(|p| key(p).eq_ignore_ascii_case(want)) else { return crate::input::refuse("no_such_pad", &format!("{want} is not a pad on this board")) };+        if !p["net"].as_str().map(|n| n.eq_ignore_ascii_case(net)).unwrap_or(false) {+            return json!({"success": true, "found": false, "reason": format!("{want} is on {}, not {net}", p["net"].as_str().unwrap_or("no net"))});+        }+        ends.push(p.clone());+    }+    if key(&ends[0]).eq_ignore_ascii_case(&key(&ends[1])) { return crate::input::refuse("bad_args", "from and to are the same pad"); }+    let layers: Vec<String> = args["layers"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()).unwrap_or_else(|| vec!["F.Cu".into(), "B.Cu".into()]);+    if layers.is_empty() || layers.iter().any(|l| !l.ends_with(".Cu") || l == "*.Cu") { return crate::input::refuse("bad_args", "layers are copper layers by name: [\"F.Cu\", \"In1.Cu\", \"B.Cu\"]"); }+    // the board's own width and via rules, so the route passes its DRC (a 0.25 mm default broke a 10 mil Width rule on+    // Altium's lane board, 2026-10-28); args still win+    let rules = crate::input::rules(input);+    let (width_rule, via_rule) = (crate::features::width_style(&rules), crate::features::via_style(&rules));+    let width = num("widthMm", width_rule.unwrap_or(0.25));+    let width_from = if args.get("widthMm").is_some() { "widthMm" } else if width_rule.is_some() { "the board's width rule" } else { "default 0.25" };+    let (via_size, via_drill) = (num("viaSizeMm", via_rule.map(|v| v.0).unwrap_or(0.6)), num("viaDrillMm", via_rule.map(|v| v.1).unwrap_or(0.3)));+    let via_from = if args.get("viaSizeMm").is_some() { "viaSizeMm/viaDrillMm" } else if via_rule.is_some() { "the board's via rule" } else { "default 0.6/0.3" };+    if via_drill >= via_size { return crate::input::refuse("bad_args", "viaDrillMm must be smaller than viaSizeMm"); }+    let grid = num("gridMm", 0.5);+    if !(0.05..=5.0).contains(&grid) { return crate::input::refuse("bad_args", "gridMm is 0.05 to 5"); }+    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);+    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"); }++    // 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 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()]);+    }+    for a in st["arcs"].as_array().cloned().unwrap_or_default().iter().filter(|a| !same(a)) {+        let (cx, cy, r) = (f(&a["center"]["x"]), f(&a["center"]["y"]), f(&a["radius"]));+        let (a1, mut a2) = (f(&a["startAngle"]), f(&a["endAngle"]));+        if a2 <= a1 { a2 += 360.0; }+        let n = ((a2 - a1) / 10.0).ceil().max(1.0) as usize;+        let p = |deg: f64| (cx + r * deg.to_radians().cos(), cy + r * deg.to_radians().sin());+        for i in 0..n { push(Shape::Cap(p(a1 + (a2 - a1) * i as f64 / n as f64), p(a1 + (a2 - a1) * (i + 1) as f64 / n as f64), f(&a["width"]) / 2.0), vec![a["layer"].as_str().unwrap_or("").to_string()]); }+    }+    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());+    }+    let all_pads: Vec<Shape> = pads.iter().map(pad_shape).collect();++    // the grid: lines every gridMm over the outline, plus lines through both pad centres+    let (x0, x1) = ring.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.0), a.1.max(p.0)));+    let (y0, y1) = ring.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.1), a.1.max(p.1)));+    let cells = ((x1 - x0) / grid + 3.0) * ((y1 - y0) / grid + 3.0) * layers.len() as f64;+    if cells > 2_000_000.0 { return crate::input::refuse("search_budget", "the grid has too many cells: use a coarser gridMm"); }+    let axis = |lo: f64, hi: f64, extra: [f64; 2]| -> Vec<f64> {+        let mut v: Vec<f64> = (0..=((hi - lo) / grid).floor() as usize).map(|i| r3(lo + i as f64 * grid)).collect();+        v.extend(extra.iter().map(|e| r3(*e)));+        v.sort_by(|a, b| a.partial_cmp(b).unwrap());+        v.dedup_by(|a, b| (*a - *b).abs() < 1e-9);+        v+    };+    let (sp, gp) = ((f(&ends[0]["x"]), f(&ends[0]["y"])), (f(&ends[1]["x"]), f(&ends[1]["y"])));+    let xs = axis(x0, x1, [sp.0, gp.0]);+    let ys = axis(y0, y1, [sp.1, gp.1]);+    let idx = |v: &[f64], t: f64| v.iter().position(|x| (x - r3(t)).abs() < 1e-9).unwrap();+    let (start, goal) = ((idx(&xs, sp.0), idx(&ys, sp.1)), (idx(&xs, gp.0), idx(&ys, gp.1)));+    let start_layers: Vec<usize> = pad_layers(&ends[0], &layers).iter().filter_map(|l| layers.iter().position(|x| x == l)).collect();+    let goal_layers: Vec<usize> = pad_layers(&ends[1], &layers).iter().filter_map(|l| layers.iter().position(|x| x == l)).collect();+    if start_layers.is_empty() || goal_layers.is_empty() {+        return json!({"success": true, "found": false, "reason": "a pad is on no layer the route may use: add its layer to layers"});+    }+    if !allow_vias && !start_layers.iter().any(|l| goal_layers.contains(l)) {+        return json!({"success": true, "found": false, "reason": "the pads share no layer and allowVias is false"});+    }++    let edge_ok = |a: P, b: P, r: f64| inside(a, &ring) && inside(b, &ring)+        && (0..ring.len()).all(|k| seg_seg(a, b, ring[k], ring[(k + 1) % ring.len()]) >= edge + r - 1e-9);+    let clear_on = |a: P, b: P, r: f64, layer: &str| {+        let (bx0, by0, bx1, by1) = (a.0.min(b.0) - r - clearance, a.1.min(b.1) - r - clearance, a.0.max(b.0) + r + clearance, a.1.max(b.1) + r + clearance);+        ob.iter().filter(|(_, ls, bb)| ls.iter().any(|l| l == layer) && !(bb.2 < bx0 || bb.0 > bx1 || bb.3 < by0 || bb.1 > by1))+            .all(|(s, _, _)| s.dist(a, b) >= r + clearance - 1e-9)+    };+    let mut cache: HashMap<(usize, usize, usize, usize, usize, usize), bool> = HashMap::new();+    let mut step_ok = |u: (usize, usize, usize), v: (usize, usize, usize)| -> bool {+        let k = if u <= v { (u.0, u.1, u.2, v.0, v.1, v.2) } else { (v.0, v.1, v.2, u.0, u.1, u.2) };+        if let Some(b) = cache.get(&k) { return *b; }+        let (a, b) = ((xs[u.0], ys[u.1]), (xs[v.0], ys[v.1]));+        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)+        } else {+            edge_ok(a, b, width / 2.0) && clear_on(a, b, width / 2.0, &layers[u.2])+        };+        cache.insert(k, ok);+        ok+    };+    let h = |u: (usize, usize, usize)| (xs[u.0] - gp.0).abs() + (ys[u.1] - gp.1).abs() + if goal_layers.contains(&u.2) { 0.0 } else { via_cost };+    let q = |v: f64| (v * 1e6).round() as i64;+    let mut heap: BinaryHeap<Reverse<(i64, i64, (usize, usize, usize))>> = BinaryHeap::new();+    let mut cost: HashMap<(usize, usize, usize), f64> = HashMap::new();+    let mut parent: HashMap<(usize, usize, usize), (usize, usize, usize)> = HashMap::new();+    for &l in &start_layers { let u = (start.0, start.1, l); cost.insert(u, 0.0); heap.push(Reverse((q(h(u)), 0, u))); }+    let mut seen: std::collections::HashSet<(usize, usize, usize)> = Default::default();+    let mut found = None;+    while let Some(Reverse((_, _, u))) = heap.pop() {+        if !seen.insert(u) { continue; }+        if (u.0, u.1) == goal && goal_layers.contains(&u.2) { found = Some(u); break; }+        if seen.len() >= max_nodes { break; }+        let g = cost[&u];+        let mut nb = Vec::new();+        if u.0 + 1 < xs.len() { nb.push((u.0 + 1, u.1, u.2)); }+        if u.0 > 0 { nb.push((u.0 - 1, u.1, u.2)); }+        if u.1 + 1 < ys.len() { nb.push((u.0, u.1 + 1, u.2)); }+        if u.1 > 0 { nb.push((u.0, u.1 - 1, u.2)); }+        if allow_vias { for l in 0..layers.len() { if l != u.2 { nb.push((u.0, u.1, l)); } } }+        for v in nb {+            if seen.contains(&v) || !step_ok(u, v) { continue; }+            let step = if v.2 != u.2 { via_cost } else { (xs[v.0] - xs[u.0]).abs() + (ys[v.1] - ys[u.1]).abs() };+            let ng = g + step;+            if ng >= cost.get(&v).copied().unwrap_or(f64::MAX) - 1e-9 { continue; }+            cost.insert(v, ng);+            parent.insert(v, u);+            heap.push(Reverse((q(ng + h(v)), q(ng), v)));+        }+    }+    let revision = st["revision"].clone();+    let Some(goal_node) = found else {+        let why = if seen.len() >= max_nodes { "no path within the search limit (maxNodes): try a coarser gridMm or a higher maxNodes" } else { "no legal path on this grid: other copper, the outline or the clearance blocks every way. A fine-pitch pad (0.5 mm pitch) usually needs gridMm 0.1 to escape; more layers give more room" };+        return json!({"success": true, "found": false, "reason": why, "expandedNodes": seen.len(), "revision": revision});+    };+    let mut path = vec![goal_node];+    while let Some(p) = parent.get(path.last().unwrap()) { path.push(*p); }+    path.reverse();+    // keep the corners and the layer changes+    let mut keep = vec![path[0]];+    for w in path.windows(3) {+        let (a, b, c) = (w[0], w[1], w[2]);+        if a.2 == b.2 && b.2 == c.2 && ((a.0 == b.0 && b.0 == c.0) || (a.1 == b.1 && b.1 == c.1)) { continue; }+        keep.push(b);+    }+    if path.len() > 1 { keep.push(*path.last().unwrap()); }+    let mut way = vec![json!({"pad": key(&ends[0])})];+    let mut vias = Vec::new();+    let mut length = 0.0;+    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(); }+        // 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."})+}++#[cfg(test)]+mod tests {+    use super::*;++    fn board(extra_tracks: Value) -> Value {+        json!({"nets": ["SIG", "GND"], "revision": "r1", "outline": [[0, 0], [20, 0], [20, 10], [0, 10]],+               "outlineEdges": [{"kind": "line", "start": [0.0, 0.0]}, {"kind": "line", "start": [20.0, 0.0]}, {"kind": "line", "start": [20.0, 10.0]}, {"kind": "line", "start": [0.0, 10.0]}],+               "pads": [{"ref": "U1", "name": "1", "x": 2.0, "y": 5.0, "sizeX": 1.0, "sizeY": 1.0, "rotation": 0.0, "shape": "rect", "layer": "F.Cu", "drill": 0.0, "net": "SIG"},+                        {"ref": "J1", "name": "1", "x": 18.0, "y": 5.0, "sizeX": 1.0, "sizeY": 1.0, "rotation": 0.0, "shape": "rect", "layer": "F.Cu", "drill": 0.0, "net": "SIG"},+                        {"ref": "J1", "name": "2", "x": 18.0, "y": 2.0, "sizeX": 1.0, "sizeY": 1.0, "rotation": 0.0, "shape": "rect", "layer": "B.Cu", "drill": 0.0, "net": "SIG"}],+               "tracks": extra_tracks, "arcs": [], "vias": []})+    }++    #[test]+    fn a_clear_board_is_one_straight_track() {+        let r = plan_route(&json!({"board": board(json!([])), "args": {"net": "SIG", "from": "U1.1", "to": "J1.1"}}));+        assert_eq!(r["found"], json!(true), "{r}");+        assert_eq!(r["vias"], 0);+        assert!((r["lengthMm"].as_f64().unwrap() - 16.0).abs() < 1e-9);+        assert_eq!(r["routeNet"]["paths"][0][0], json!({"pad": "U1.1"}));+        assert_eq!(r["routeNet"]["paths"][0].as_array().unwrap().last().unwrap(), &json!({"pad": "J1.1"}));+        assert_eq!(r["routeNet"]["layer"], "F.Cu");+    }++    #[test]+    fn a_wall_on_top_is_crossed_on_the_bottom_or_not_at_all() {+        // a GND track across the whole board on F.Cu+        let wall = json!([{"net": "GND", "layer": "F.Cu", "width": 0.5, "start": {"x": 10.0, "y": 0.0}, "end": {"x": 10.0, "y": 10.0}}]);+        let r = plan_route(&json!({"board": board(wall.clone()), "args": {"net": "SIG", "from": "U1.1", "to": "J1.1"}}));+        assert_eq!(r["found"], json!(true), "{r}");+        assert_eq!(r["vias"], 2, "down to B.Cu and back up: {r}");+        // every point of the path clears the wall on F.Cu+        for w in r["routeNet"]["paths"][0].as_array().unwrap() { if w["layer"] == "F.Cu" { assert!((f(&w["x"]) - 10.0).abs() > 0.25 + 0.2 + 0.125 - 1e-9, "{w}"); } }+        let none = plan_route(&json!({"board": board(wall), "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "allowVias": false}}));+        assert_eq!(none["found"], json!(false));+    }++    #[test]+    fn pads_on_different_layers_take_a_via_and_errors_are_named() {+        let r = plan_route(&json!({"board": board(json!([])), "args": {"net": "SIG", "from": "U1.1", "to": "J1.2"}}));+        assert_eq!(r["found"], json!(true), "{r}");+        assert_eq!(r["vias"], 1);+        // the via is clear of every pad+        let v = &r["viaAt"][0];+        for (px, py) in [(2.0, 5.0), (18.0, 5.0), (18.0, 2.0)] { assert!(((f(&v[0]) - px).powi(2) + (f(&v[1]) - py).powi(2)).sqrt() > 0.3 + 0.5, "{v}"); }+        assert_eq!(plan_route(&json!({"board": board(json!([])), "args": {"net": "SIG", "from": "U9.1", "to": "J1.1"}}))["errorCode"], "no_such_pad");+        assert_eq!(plan_route(&json!({"board": board(json!([])), "args": {"net": "NOPE", "from": "U1.1", "to": "J1.1"}}))["errorCode"], "no_such_net");+        let mut b = board(json!([]));+        b["pads"][1]["net"] = json!("GND");+        assert_eq!(plan_route(&json!({"board": b, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1"}}))["found"], json!(false));+    }++    #[test]+    fn three_layers_when_asked() {+        // walls on F.Cu and B.Cu: only In1.Cu crosses+        let walls = 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": 10.0, "y": 0.0}, "end": {"x": 10.0, "y": 10.0}}]);+        let two = plan_route(&json!({"board": board(walls.clone()), "args": {"net": "SIG", "from": "U1.1", "to": "J1.1"}}));+        assert_eq!(two["found"], json!(false));+        let three = plan_route(&json!({"board": board(walls), "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "layers": ["F.Cu", "In1.Cu", "B.Cu"]}}));+        assert_eq!(three["found"], json!(true), "{three}");+        assert!(three["routeNet"]["paths"][0].as_array().unwrap().iter().any(|w| w["layer"] == "In1.Cu"));+    }+    #[test]+    fn the_track_and_via_follow_the_boards_rules() {+        // Altium's lane board: a 10 mil Width rule and a 0.5/0.3 Routing Via rule+        let rules = json!([{"enabled": true, "scope1": "All", "priority": 1, "descriptor": "Width Constraint (Min=10mil) (Max=10mil) (Preferred=10mil) (All)"},+                           {"enabled": true, "scope1": "All", "priority": 1, "descriptor": "Routing Via (PreferredHoleWidth=0.3mm) (PreferedWidth=0.5mm) (All)"}]);+        let r = plan_route(&json!({"board": board(json!([])), "rules": rules, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1"}}));+        assert_eq!((r["routeNet"]["width"].clone(), r["routeNet"]["viaSize"].clone()), (json!(0.254), json!(0.5)), "{r}");+        assert_eq!(r["planner"]["widthFrom"], "the board's width rule");+        let given = plan_route(&json!({"board": board(json!([])), "rules": rules, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "widthMm": 0.4}}));+        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");+    }+}
tests/run_verb_tests.py+5−2
@@ -239,7 +239,7 @@ for v in ("fusion_sch_text", "fusion_sch_junction", "fusion_sch_delete_wire", "f t("fusion_update_board_from_eagle#guard", "negative", dict(B, brdPath="C:/nonexistent/none.brd"), {"pred": "must_refuse"}, "a missing file is refused before anything changes") t("fusion_update_board_from_eagle", "manual", note="writes a saved design: tested live on fl-sync-test in Default Project / fusion-lane-tests (#125 item 4)") t("fusion_export_dxf#board", "daily", dict(B, layers=["Edge.Cuts", "F.Cu", "F.SilkS"], outputDir=f"{SCRATCH}/dxf"), {"ok": True, "fields": ["files"]}, "board layers to DXF (the standard's export_dxf)")-t("fusion_plan_route#standard", "negative", dict(B, net="SDA", **{"from": "IC1.15", "to": "R18.2"}), {"pred": "must_refuse"}, "the standard's names in; BQ25792 has four layers and the planner takes two: refused with unsupported_board (the shared engine's plan_route will cover more)")+t("fusion_plan_route#standard", "daily", dict(B, net="N$15", layers=["F.Cu", "In1.Cu", "In2.Cu", "B.Cu"], gridMm=0.25, **{"from": "SW1.2", "to": "JP5.P1"}), {"ok": True, "fields": ["found", "routeNet", "planner", "engine"]}, "the shared engine plans on all four of BQ25792's layers (read only)") t("fusion_render_board_3d", "daily", dict(B, side="iso", width=800, height=500, outputPath=f"{SCRATCH}/board-iso.png"), {"ok": True, "fields": ["path"]}, "shows the 3D PCB itself when it is not open, then puts the board tab back") t("fusion_remove_graphics#dry", "daily", dict(B, layer="F.SilkS", kinds=["text"], dryRun=True), {"ok": True, "fields": ["removed", "count"]}, "lists ids, changes nothing") t("fusion_align_parts#dry", "daily", dict(B, refs=["C19", "C20", "C21"], mode="top", dryRun=True), {"ok": True, "fields": ["targets"]})@@ -255,6 +255,9 @@ t("fusion_current_capacity", "daily", dict(B, nets={"VBUS": 3}), {"ok": True, "f 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_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"]})+t("fusion_place_decoupling", "daily", dict(B, ic="IC1"), {"ok": True, "fields": ["engine"]}, "a plan only: nothing moves")+t("fusion_plan_fanout", "daily", dict(B, ref="IC1"), {"ok": True, "fields": ["calls", "conflicts", "engine"]}, "a plan only: nothing is drawn") t("fusion_export_odb", "daily", dict(B, outputDir=f"{SCRATCH}/odb"), {"ok": True, "fields": ["path", "layers", "nets"]}, "Fusion's own ODB++ tree, found by its matrix and zipped") t("fusion_export_ipc356", "daily", dict(B, outputDir=f"{SCRATCH}/ipc356"), {"ok": True, "fields": ["path", "records"]}) t("fusion_export_gencad", "daily", dict(B, outputDir=f"{SCRATCH}/gencad"), {"ok": True, "fields": ["path", "components", "signals"]})@@ -492,7 +495,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_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_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",
tests/test_routing.py+60−2
@@ -113,9 +113,67 @@ class RoutingTests(unittest.TestCase):         self.board.find("plain/wire").set('x2','53');self.rejected('unsupported_board')     def test_rules(self):         self.args['width']=.1;self.rejected('design_rule_violation')-    def test_polygon_not_silently_ignored(self):-        ET.SubElement(self.board.find('signals/signal'),'polygon',width='.2',layer='1')+    def test_pours_are_not_obstacles(self):+        # Fusion repours another net's pour round new copper (RATSNEST after the route), as the+        # standard's plan_route assumes; a pour no longer refuses the board+        poly=ET.SubElement(self.board.find("signals/signal[@name='NET_2']"),'polygon',width='.2',layer='1')+        for x,y in ((10,4),(45,4),(45,10),(10,10)): ET.SubElement(poly,'vertex',x=str(x),y=str(y))+        self.assertGreater(len(r.plan(self.board,self.args)['items']),0)+    def _four_layers(self):+        self.board.find("designrules/param[@name='layerSetup']").set('value','(1*2*15*16)')+    def test_inner_layer_by_name_on_a_four_layer_board(self):+        self._four_layers()+        self.args.update(viaSize=.7,points=[{'pad':'J1.1'},{'x':14,'y':7,'layer':'In1.Cu'},{'x':40,'y':7,'layer':'F.Cu'},{'pad':'J2.1'}])+        items=r.plan(self.board,self.args)['items']+        self.assertEqual([1,2,1],[i['layer'] for i in items if i['kind']=='segment'])+        self.assertEqual([.7,.7],[i['diameter'] for i in items if i['kind']=='via'],'viaSize is the standard routeNet name')+    def test_inner_copper_of_another_net_blocks(self):+        self._four_layers()+        ET.SubElement(self.board.find("signals/signal[@name='NET_2']"),'wire',x1='20',y1='5',x2='20',y2='10',width='.25',layer='2')+        self.args['points']=[{'pad':'J1.1'},{'x':14,'y':7,'layer':'In1.Cu'},{'x':40,'y':7,'layer':'F.Cu'},{'pad':'J2.1'}]+        self.rejected('clearance_violation')+    def test_a_through_pad_takes_an_inner_layer_track(self):+        self._four_layers()+        pad=next(k for k,v in r.objects(self.board)[1].items() if v['layers']==[1,16] and v['net']=='NET_1') if any(v['layers']==[1,16] and v['net']=='NET_1' for v in r.objects(self.board)[1].values()) else None+        if pad is None: self.skipTest('the fixture has no through pad on NET_1')+        at=r.objects(self.board)[1][pad]['at']+        self.args.update(layer='In1.Cu',points=[[at[0]+3,at[1]],{'pad':pad}])+        self.assertGreater(len(r.plan(self.board,self.args)['items']),0)+    def test_an_inner_layer_the_board_lacks_is_refused(self):+        self.args['layer']='In1.Cu';self.rejected('invalid_layer')+    def test_a_curved_track_is_an_obstacle_along_its_arc(self):+        # a half circle of another net rising from y 3 to y 8, across the straight path at y 7+        ET.SubElement(self.board.find("signals/signal[@name='NET_2']"),'wire',x1='22',y1='3',x2='32',y2='3',width='.25',layer='1',curve='-180')+        self.rejected('clearance_violation')+        self.board.find("signals/signal[@name='NET_2']")[-1].set('curve','-20')+        self.assertGreater(len(r.plan(self.board,self.args)['items']),0,'a shallow arc stays clear')+    def test_board_copper_and_keepouts_are_obstacles(self):+        plain=self.board.find('plain')+        dot=ET.SubElement(plain,'circle',x='20',y='7',radius='.5',width='0',layer='1')+        self.rejected('clearance_violation')+        dot.set('layer','16')+        self.assertGreater(len(r.plan(self.board,self.args)['items']),0,'bottom copper does not block a top track')+        dot.set('layer','41');self.rejected('clearance_violation')+        plain.remove(dot)+        poly=ET.SubElement(plain,'polygonshape',layer='42')+        outline=ET.SubElement(poly,'polygonoutlineobjects')+        for x,y in ((18,5),(22,5),(22,9),(18,9)): ET.SubElement(outline,'vertex',x=str(x),y=str(y))+        self.assertGreater(len(r.plan(self.board,self.args)['items']),0,'a bottom keepout does not block a top track')+        poly.set('layer','41');self.rejected('clearance_violation')+    def test_milling_is_still_refused(self):+        ET.SubElement(self.board.find('plain'),'wire',x1='1',y1='1',x2='2',y2='1',width='.5',layer='46')         self.rejected('unsupported_board')+    def test_footprint_hole_is_an_obstacle(self):+        # a mounting hole in a part's footprint, placed with the part, under the track at y 7+        el=self.board.find("elements/element[@name='J1']")+        pkg=self.board.find("libraries/library[@name='%s']/packages/package[@name='%s']"%(el.get('library'),el.get('package')))+        xf,_,_=r.placement(el)+        # find the local point that lands on (20, 7): the transform is rigid, so solve with two probes+        ox,oy=xf(0,0); ux=xf(1,0); uy=xf(0,1)+        a,b=ux[0]-ox,uy[0]-ox; c,d=ux[1]-oy,uy[1]-oy; det=a*d-b*c+        lx=((20-ox)*d-b*(7-oy))/det; ly=(a*(7-oy)-c*(20-ox))/det+        ET.SubElement(pkg,'hole',x=str(lx),y=str(ly),drill='1.0')+        self.rejected('clearance_violation')     def test_revision_guard_before_mutation(self):         saved=r.snapshot         r.snapshot=lambda app,args:(None,self.board,{'boardId':'board','revision':'now'})