← Commit history
CHANGELOG.md+16
@@ -3,6 +3,22 @@ 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.12.1 (unreleased)++The EDA Bridge Standard 0.1.1 settlements (standard issue #2).++- `isolatedCopper` (`{net, layer, at:{x,y}, areaMm2}`) in `fusion_routing_state`: a group of only poured copper+  (an island, or a pour that reaches no pad) is listed there and not counted in `unconnected`; a stray track or+  via still counts. `floatingPourIslands` stays beside it.+- A custom pad's `copperPoints` is one ring (the largest drawn polygon), as the schema now says; a pad that+  gathers several also lists them under `copperOutlines`. `polygons[].kind` is `zone` (a pour) or `graphic` (drawn+  copper, `source:"footprint"` inside a part).+- New `fusion_apply_silkscreen`: designators and values moved, sized, turned or hidden, free texts and lines added,+  earlier ones removed by `fusion_silk_state`'s new `raw` handles. Each pick is made with only the target's layer+  displayed; the layer display is restored one layer at a time (a DISPLAY that names the origin layers drops every+  layer after them); if copper, parts or the outline move, every command is undone. Verified on BQ25792: IC1 and+  R2 names moved, sized and turned exactly, C1's value hidden and shown, revision unchanged, display as before.+ ## 1.12.0 (2026-10-27, insiders)  The board model, to the [EDA Bridge Standard](https://wiki.adom.inc/adom/eda-bridge-standard) (fusion-lane thread).
README.md+3−2
@@ -527,7 +527,7 @@ The AI does the whole setup - the user never gets handed a download link: ## Full verb catalog  All verbs return a rich `_hint`. `fusion_describe '{}'` is the machine-readable source of truth; this table is generated from it.-_201 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`.+_202 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`.  **Lifecycle and readiness** @@ -728,6 +728,7 @@ _201 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`.  - `fusion_account` - WHO Fusion is signed in as on this machine, and whether this machine currently holds the Autodesk SEAT. - `fusion_apply_pours` - Draw copper pours from a plan: POLYGON per pour (net, layer 1 or 16, outline points, isolate, rank 1..6, width, thermals, orphans), skipping any already on the...+- `fusion_apply_silkscreen` - Land a silkscreen plan on the live board: designators (names) and values (comments) moved to their own location, sized, turned or hidden; free silkscreen texts... - `fusion_change_pours` - 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. - `fusion_component_data` - Placement data for one part or every part on the live board, in the background: footprint, value (comment), library, side and copper layer, position and... - `fusion_component_pins` - Every pad of one part with its net, position, layer, size, shape and drill (mm), the same rows fusion_routing_state gives, for one part.@@ -790,7 +791,7 @@ adom-wiki pkg install adom/fusion-bridge   # the skills pack for your AI  Auto-updates via `updateManifestUrl` (ab polls on launch + every 4h). Every release lands on the insiders manifest first, and the public manifest moves only after the pre-production gate, so the-two tiers can serve different versions. 201 verbs -+two tiers can serve different versions. 202 verbs - `fusion_describe '{}'` returns the full machine-readable catalog. More demos:  <video controls width="100%" src="https://wiki.adom.inc/api/v1/pages/fusion-bridge/files/library2-3d-montage.mp4"></video>
bridge.json+1
@@ -28,6 +28,7 @@     "fusion_alerts",     "fusion_apply_fusion_update",     "fusion_apply_pours",+    "fusion_apply_silkscreen",     "fusion_aps_browse",     "fusion_aps_create_folder",     "fusion_aps_download",
contracts/describe.json+37−1
@@ -1,6 +1,6 @@ {   "contract": "fusion-bridge verb catalog",-  "frozen": "2026-10-26",+  "frozen": "2026-10-27",   "note": "The Python bridge and the Rust bridge must both serve this catalog byte-for-byte in meaning: same names, same args, same result shapes, same timeouts. Regenerate ONLY from describe.py and diff in CI.",   "source": "describe.py",   "verbs": [@@ -121,6 +121,42 @@       "summary": "Draw copper pours from a plan: POLYGON per pour (net, layer 1 or 16, outline points, isolate, rank 1..6, width, thermals, orphans), skipping any already on the board with the same net, layer and outline, then RATSNEST fills them. The reply carries Fusion's own fill per pour (filledMm2, islands) and per layer. Removing a pour is not done here.",       "timeoutSeconds": 120     },+    {+      "example": {+        "expectDocument": "fusion-routing",+        "names": [+          {+            "ref": "J1",+            "rotation": 90,+            "size": 1.5,+            "x": 10,+            "y": 20+          }+        ]+      },+      "input": {+        "comments": "[same] for the value text",+        "expectDocument": "required exact board name",+        "expectedRevision": "optional: refuse with stale_board if the board moved on",+        "lines": "[{x1, y1, x2, y2, width? (0.15), side?}]",+        "names": "[{ref, x, y, size?, rotation?, show?}] the designator text's own location (lower left before rotation), mm",+        "remove": "{texts:[raw], lines:[raw]} from fusion_silk_state",+        "texts": "[{text, x, y, size?, rotation?, side?: top|bottom}]"+      },+      "longRunning": false,+      "name": "fusion_apply_silkscreen",+      "output": {+        "after": "silk_state",+        "applied": "int",+        "failed": "int",+        "results": "[ok | err:...]",+        "revision": "str",+        "smashed": "[ref]"+      },+      "statusVerb": "fusion_routing_state",+      "summary": "Land a silkscreen plan on the live board: designators (names) and values (comments) moved to their own location, sized, turned or hidden; free silkscreen texts and lines added (bottom ones mirrored); earlier free items removed by the raw handle fusion_silk_state gives. Parts not yet smashed are smashed first. Each pick is made with only the target's layer displayed, so a pad or wire is never taken instead, and the board's layer display is restored. Read back; if copper, parts or the outline moved, every command is undone. The edit side of a designator plan (silk_designators).",+      "timeoutSeconds": 120+    },     {       "example": {},       "input": {
contracts/eda-bridge-standard/board.schema.json+408−32
@@ -2,41 +2,417 @@   "$schema": "https://json-schema.org/draft/2020-12/schema",   "$id": "https://wiki.adom.inc/adom/eda-bridge-standard/schema/board.schema.json",   "title": "Board model (routing_state)",-  "description": "One PCB as every bridge's <prefix>_routing_state returns it. Millimetres, the tool's own frame (y up), absolute board coordinates. Copper layers carry KiCad's names whatever the tool: F.Cu, In1.Cu..In30.Cu, B.Cu, and *.Cu for a pad on every copper layer. Reference: altium-bridge 0.2.75.",+  "description": "One PCB as every bridge's <prefix>_routing_state returns it. Millimetres, absolute board coordinates in the standard frame: the tool's origin and x axis, y up (a y-down tool such as KiCad negates y), angles counterclockwise as seen. Copper layers carry KiCad's names whatever the tool: F.Cu, In1.Cu..In30.Cu, B.Cu, and *.Cu for a pad on every copper layer. Reference: altium-bridge 0.2.75; settled points: docs/board-model.md.",   "type": "object",-  "required": ["units", "revision", "nets", "tracks", "arcs", "vias", "pads", "components", "polygons", "outline", "outlineEdges", "unconnected"],+  "required": [+    "units",+    "revision",+    "nets",+    "tracks",+    "arcs",+    "vias",+    "pads",+    "components",+    "polygons",+    "outline",+    "outlineEdges",+    "unconnected"+  ],   "$defs": {-    "xy": {"type": "object", "required": ["x", "y"], "properties": {"x": {"type": "number"}, "y": {"type": "number"}}},-    "pt": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2, "description": "[x, y] mm"},-    "layer": {"type": "string", "description": "F.Cu | In<n>.Cu | B.Cu | *.Cu for copper; a non-copper layer is the tool's own name or id (L57 on Altium is Mechanical 1)"},-    "net": {"type": "string", "description": "the net's name; empty for no net (a hatched pour's pieces, free copper)"}+    "xy": {+      "type": "object",+      "required": [+        "x",+        "y"+      ],+      "properties": {+        "x": {+          "type": "number"+        },+        "y": {+          "type": "number"+        }+      }+    },+    "pt": {+      "type": "array",+      "items": {+        "type": "number"+      },+      "minItems": 2,+      "maxItems": 2,+      "description": "[x, y] mm"+    },+    "layer": {+      "type": "string",+      "description": "F.Cu | In<n>.Cu | B.Cu | *.Cu for copper; a non-copper layer is the tool's own name or id (L57 on Altium is Mechanical 1)"+    },+    "net": {+      "type": "string",+      "description": "the net's name; empty for no net (a hatched pour's pieces, free copper)"+    }   },   "properties": {-    "units": {"const": "mm"},-    "file": {"type": ["string", "null"], "description": "the board file's path"},-    "boardId": {"type": ["string", "null"], "description": "identifies the board; the file path where there is one"},-    "revision": {"type": "string", "description": "a hash of copper, parts and outline: stable for an unchanged board. Edit verbs take it back as expectedRevision and refuse with stale_board when the board moved on"},-    "counts": {"type": "object", "description": "tracks, arcs, vias, pads, components, polygons, nets"},-    "unconnected": {"type": "integer", "description": "connections still to make, computed from the copper itself (pads, tracks, vias and pours joined where they touch on a shared layer)"},-    "splitNets": {"type": "array", "description": "nets whose copper is in more than one piece"},-    "nets": {"type": "array", "items": {"type": "string"}},-    "tracks": {"type": "array", "items": {"type": "object", "required": ["start", "end", "width", "layer", "net"],-      "properties": {"start": {"$ref": "#/$defs/xy"}, "end": {"$ref": "#/$defs/xy"}, "width": {"type": "number"}, "layer": {"$ref": "#/$defs/layer"}, "layerId": {"description": "the tool's own layer id (optional)"}, "net": {"$ref": "#/$defs/net"}}}},-    "arcs": {"type": "array", "items": {"type": "object", "required": ["center", "radius", "startAngle", "endAngle", "width", "layer", "net"],-      "properties": {"center": {"$ref": "#/$defs/xy"}, "radius": {"type": "number"}, "startAngle": {"type": "number", "description": "degrees, counterclockwise from +x"}, "endAngle": {"type": "number"}, "width": {"type": "number"}, "layer": {"$ref": "#/$defs/layer"}, "net": {"$ref": "#/$defs/net"}}}},-    "vias": {"type": "array", "items": {"type": "object", "required": ["position", "size", "drill", "layers", "net"],-      "properties": {"position": {"$ref": "#/$defs/xy"}, "size": {"type": "number", "description": "diameter mm"}, "drill": {"type": "number"}, "layers": {"type": "array", "items": {"$ref": "#/$defs/layer"}, "description": "[from, to]"}, "net": {"$ref": "#/$defs/net"}}}},-    "pads": {"type": "array", "items": {"type": "object", "required": ["ref", "name", "x", "y", "sizeX", "sizeY", "rotation", "layer", "drill", "net"],-      "properties": {"ref": {"type": "string", "description": "the part's designator; empty for a free pad (a mounting hole, a fiducial)"}, "name": {"type": "string", "description": "the pad's number/name"},-        "x": {"type": "number"}, "y": {"type": "number"}, "sizeX": {"type": "number"}, "sizeY": {"type": "number"}, "rotation": {"type": "number", "description": "degrees"},-        "layer": {"$ref": "#/$defs/layer"}, "drill": {"type": "number", "description": "0 for SMD"}, "net": {"$ref": "#/$defs/net"},-        "shape": {"type": "string", "description": "round | rect | roundrect | octagonal | other. A custom-shape pad (copper drawn as regions) is reported as its bounding rectangle: rect, rotation 0"}}}},-    "components": {"type": "array", "items": {"type": "object", "required": ["ref", "x", "y", "rotation", "side"],-      "properties": {"ref": {"type": "string"}, "x": {"type": "number"}, "y": {"type": "number"}, "rotation": {"type": "number"}, "side": {"enum": ["top", "bottom"]}, "pattern": {"type": "string", "description": "the footprint's name"}, "has3d": {"type": ["boolean", "null"]}}}},-    "polygons": {"type": "array", "description": "pours as drawn (their boundary). What was actually poured is the pours model (pour_state)", "items": {"type": "object", "required": ["net", "layer", "points"],-      "properties": {"net": {"$ref": "#/$defs/net"}, "layer": {"$ref": "#/$defs/layer"}, "points": {"type": "array", "items": {"$ref": "#/$defs/pt"}}}}},-    "outline": {"type": "array", "items": {"$ref": "#/$defs/pt"}, "description": "the board outline's vertices in order"},-    "outlineEdges": {"type": "array", "description": "the outline as edges: line, or arc (center, radius, angle1, angle2 in degrees). Sample arcs finely (every 2 degrees) before measuring against them", "items": {"type": "object", "required": ["kind", "start"],-      "properties": {"kind": {"enum": ["line", "arc"]}, "start": {"$ref": "#/$defs/pt"}, "end": {"$ref": "#/$defs/pt"}, "center": {"$ref": "#/$defs/pt"}, "radius": {"type": "number"}, "angle1": {"type": "number"}, "angle2": {"type": "number"}}}}+    "units": {+      "const": "mm"+    },+    "file": {+      "type": [+        "string",+        "null"+      ],+      "description": "the board file's path"+    },+    "boardId": {+      "type": [+        "string",+        "null"+      ],+      "description": "identifies the board; the file path where there is one"+    },+    "revision": {+      "type": "string",+      "description": "a hash of copper, parts and outline: it changes when they change and only then. Edit verbs take it back as expectedRevision and refuse with stale_board when the board moved on. Independent of item order is better (KiCad's is); stable for an unchanged board is required"+    },+    "counts": {+      "type": "object",+      "description": "tracks, arcs, vias, pads, components, polygons, nets"+    },+    "unconnected": {+      "type": "integer",+      "description": "connections still to make, computed from the copper itself: per net, pads, tracks, vias and poured copper joined where they touch on a shared layer, and every group beyond the first is one connection. A group that is only poured copper (an island, or a pour that reaches no pad) is not counted: it goes in isolatedCopper. A stray track or via is counted. The tool's own count may ride along under its own key (altiumRatsnest, fusionAirwires)"+    },+    "splitNets": {+      "type": "array",+      "description": "nets whose copper is in more than one piece"+    },+    "isolatedCopper": {+      "type": "array",+      "description": "poured copper that touches nothing else of its net: listed, not counted in unconnected (KiCad's DRC calls it isolated copper, a warning)",+      "items": {+        "type": "object",+        "required": [+          "net",+          "layer"+        ],+        "properties": {+          "net": {+            "$ref": "#/$defs/net"+          },+          "layer": {+            "$ref": "#/$defs/layer"+          },+          "at": {+            "$ref": "#/$defs/xy",+            "description": "a point on or in it"+          },+          "areaMm2": {+            "type": "number"+          }+        }+      }+    },+    "nets": {+      "type": "array",+      "items": {+        "type": "string"+      }+    },+    "tracks": {+      "type": "array",+      "items": {+        "type": "object",+        "required": [+          "start",+          "end",+          "width",+          "layer",+          "net"+        ],+        "properties": {+          "start": {+            "$ref": "#/$defs/xy"+          },+          "end": {+            "$ref": "#/$defs/xy"+          },+          "width": {+            "type": "number"+          },+          "layer": {+            "$ref": "#/$defs/layer"+          },+          "layerId": {+            "description": "the tool's own layer id (optional)"+          },+          "net": {+            "$ref": "#/$defs/net"+          }+        }+      }+    },+    "arcs": {+      "type": "array",+      "items": {+        "type": "object",+        "required": [+          "center",+          "radius",+          "startAngle",+          "endAngle",+          "width",+          "layer",+          "net"+        ],+        "properties": {+          "center": {+            "$ref": "#/$defs/xy"+          },+          "radius": {+            "type": "number"+          },+          "startAngle": {+            "type": "number",+            "description": "degrees, counterclockwise from +x"+          },+          "endAngle": {+            "type": "number"+          },+          "width": {+            "type": "number"+          },+          "layer": {+            "$ref": "#/$defs/layer"+          },+          "net": {+            "$ref": "#/$defs/net"+          }+        }+      }+    },+    "vias": {+      "type": "array",+      "items": {+        "type": "object",+        "required": [+          "position",+          "size",+          "drill",+          "layers",+          "net"+        ],+        "properties": {+          "position": {+            "$ref": "#/$defs/xy"+          },+          "size": {+            "type": "number",+            "description": "diameter mm"+          },+          "drill": {+            "type": "number"+          },+          "layers": {+            "type": "array",+            "items": {+              "$ref": "#/$defs/layer"+            },+            "description": "[from, to]"+          },+          "net": {+            "$ref": "#/$defs/net"+          }+        }+      }+    },+    "pads": {+      "type": "array",+      "items": {+        "type": "object",+        "required": [+          "ref",+          "name",+          "x",+          "y",+          "sizeX",+          "sizeY",+          "rotation",+          "layer",+          "drill",+          "net"+        ],+        "properties": {+          "ref": {+            "type": "string",+            "description": "the part's designator; empty for a free pad (a mounting hole, a fiducial)"+          },+          "name": {+            "type": "string",+            "description": "the pad's number/name"+          },+          "x": {+            "type": "number"+          },+          "y": {+            "type": "number"+          },+          "sizeX": {+            "type": "number"+          },+          "sizeY": {+            "type": "number"+          },+          "rotation": {+            "type": "number",+            "description": "degrees, counterclockwise as seen"+          },+          "layer": {+            "$ref": "#/$defs/layer"+          },+          "drill": {+            "type": "number",+            "description": "0 for SMD"+          },+          "net": {+            "$ref": "#/$defs/net"+          },+          "shape": {+            "type": "string",+            "description": "round (a circle when sizeX = sizeY, otherwise a stadium: semicircle ends on the long axis) | circle | rect | roundrect | octagonal | rotated-rect | other. A tool's oval or obround is round. A custom-shape pad (copper drawn as regions) is its bounding rectangle: rect, rotation 0, with its real outline in copperPoints when the bridge has it"+          },+          "customShape": {+            "type": "boolean",+            "description": "the pad's copper is a drawn shape, not a primitive"+          },+          "copperPoints": {+            "type": "array",+            "items": {+              "$ref": "#/$defs/pt"+            },+            "description": "a custom pad's real copper outline, absolute mm. Checks use it when present and the bounding rectangle otherwise. One drawn polygon can join several pads (a power FET's drain)"+          }+        }+      }+    },+    "components": {+      "type": "array",+      "items": {+        "type": "object",+        "required": [+          "ref",+          "x",+          "y",+          "rotation",+          "side"+        ],+        "properties": {+          "ref": {+            "type": "string"+          },+          "x": {+            "type": "number"+          },+          "y": {+            "type": "number"+          },+          "rotation": {+            "type": "number"+          },+          "side": {+            "enum": [+              "top",+              "bottom"+            ]+          },+          "pattern": {+            "type": "string",+            "description": "the footprint's name"+          },+          "has3d": {+            "type": [+              "boolean",+              "null"+            ]+          }+        }+      }+    },+    "polygons": {+      "type": "array",+      "description": "pours as drawn (their boundary). What was actually poured is the pours model (pour_state)",+      "items": {+        "type": "object",+        "required": [+          "net",+          "layer",+          "points"+        ],+        "properties": {+          "net": {+            "$ref": "#/$defs/net"+          },+          "layer": {+            "$ref": "#/$defs/layer"+          },+          "points": {+            "type": "array",+            "items": {+              "$ref": "#/$defs/pt"+            }+          },+          "kind": {+            "enum": [+              "zone",+              "graphic"+            ],+            "description": "zone (a pour; the default when absent) or graphic (a filled copper shape drawn on the layer)"+          }+        }+      }+    },+    "outline": {+      "type": "array",+      "items": {+        "$ref": "#/$defs/pt"+      },+      "description": "the board outline's vertices in order"+    },+    "outlineEdges": {+      "type": "array",+      "description": "the outline as edges: line, or arc (center, radius, angle1, angle2 in degrees). Sample arcs finely (every 2 degrees) before measuring against them",+      "items": {+        "type": "object",+        "required": [+          "kind",+          "start"+        ],+        "properties": {+          "kind": {+            "enum": [+              "line",+              "arc"+            ]+          },+          "start": {+            "$ref": "#/$defs/pt"+          },+          "end": {+            "$ref": "#/$defs/pt"+          },+          "center": {+            "$ref": "#/$defs/pt"+          },+          "radius": {+            "type": "number"+          },+          "angle1": {+            "type": "number"+          },+          "angle2": {+            "type": "number"+          }+        }+      }+    }   } }
contracts/eda-bridge-standard/companions.schema.json+3−3
@@ -22,12 +22,12 @@       "type": "object",       "properties": {"zones": {"type": "array", "items": {"type": "object", "required": ["net", "layer", "fills"],         "properties": {"net": {"type": "string"}, "name": {"type": "string"}, "layer": {"type": "string"}, "outline": {"type": "array"}, "filledMm2": {"type": "number"}, "filled": {"type": "boolean"},-          "fills": {"type": "array", "description": "the copper actually poured: each region's outline and its holes", "items": {"type": "object", "properties": {"outline": {"type": "array"}, "holes": {"type": "array"}}}}}}}}+          "fills": {"type": "array", "description": "the copper actually poured, island by island: each one's outline and its holes. Filled by the even-odd rule, so a fractured fill (holes joined to the outline by zero-width cuts, holes empty, as KiCad stores it) is the same copper", "items": {"type": "object", "required": ["outline"], "properties": {"outline": {"type": "array"}, "holes": {"type": "array"}, "fractured": {"type": "boolean", "description": "the holes are cut into the outline"}}}}}}}}     },     "bodies": {       "title": "3D bodies",       "type": "array",-      "description": "Each part's 3D bodies as boxes: footprint rectangle and height range above the surface it sits on (a part with several bodies has several). Altium's agent sends rows [part, layer, overall, standoff, l, b, r, t] in internal units; the standard form is this, in mm.",+      "description": "Each part's 3D bodies as boxes: footprint rectangle and height range above the surface it sits on (a bottom part measures down from the bottom). One box per body (Altium) or one per part (Fusion) are both valid; checks take the union per part. Altium's agent sends rows [part, layer, overall, standoff, l, b, r, t] in internal units; the standard form is this, in mm.",       "items": {"type": "object", "required": ["ref", "side", "x0", "y0", "x1", "y1", "z0", "z1"],         "properties": {"ref": {"type": "string"}, "side": {"enum": ["top", "bottom"]}, "x0": {"type": "number"}, "y0": {"type": "number"}, "x1": {"type": "number"}, "y1": {"type": "number"},           "z0": {"type": "number", "description": "the body's bottom above the surface (standoff)"}, "z1": {"type": "number", "description": "its top"}}}@@ -39,7 +39,7 @@         "properties": {"designator": {"type": "string"}, "libReference": {"type": "string"}, "comment": {"type": "string"}, "uniqueId": {"type": "string"},           "pins": {"type": "array", "items": {"type": "object", "required": ["number", "name", "electrical"],             "properties": {"number": {"type": "string"}, "name": {"type": "string"},-              "electrical": {"description": "Altium's ELECTRICAL code (0 input, 1 io, 2 output, 3 open_collector, 4 passive, 5 hiz, 6 open_emitter, 7 power) or the name", "type": ["integer", "string"]}}}}}}+              "electrical": {"description": "the pin's type by name: input, io, output, open_collector, open_emitter, passive, hiz, power (a part's supply input), supply (a supply symbol's pin, which drives its net), not_connected, unknown. Altium's ELECTRICAL code (0 input, 1 io, 2 output, 3 open_collector, 4 passive, 5 hiz, 6 open_emitter, 7 power) is accepted too", "type": ["integer", "string"]}}}}}}     },     "netlist": {       "title": "The compiled netlist (netlist nets)",
describe.py+3
@@ -85,6 +85,9 @@ _T = [     ('fusion_zoom_to', "Frame Fusion's board view on a rectangle, a part (or several) or a whole net, with a margin. Inside Fusion only: no window focus or mouse.",      {"expectDocument":"required exact active board name","boardId":"optional","rect":"optional [x1,y1,x2,y2] mm","ref":"optional designator","refs":"optional [designator]","net":"optional net","margin":"mm, default 1"},      {"rect":"[x0,y0,x1,y1] mm framed"},60,"fusion_routing_state",False,{"expectDocument":"fusion-routing"}),+    ("fusion_apply_silkscreen", "Land a silkscreen plan on the live board: designators (names) and values (comments) moved to their own location, sized, turned or hidden; free silkscreen texts and lines added (bottom ones mirrored); earlier free items removed by the raw handle fusion_silk_state gives. Parts not yet smashed are smashed first. Each pick is made with only the target's layer displayed, so a pad or wire is never taken instead, and the board's layer display is restored. Read back; if copper, parts or the outline moved, every command is undone. The edit side of a designator plan (silk_designators).",+     {"expectDocument":"required exact board name","expectedRevision":"optional: refuse with stale_board if the board moved on","names":"[{ref, x, y, size?, rotation?, show?}] the designator text's own location (lower left before rotation), mm","comments":"[same] for the value text","texts":"[{text, x, y, size?, rotation?, side?: top|bottom}]","lines":"[{x1, y1, x2, y2, width? (0.15), side?}]","remove":"{texts:[raw], lines:[raw]} from fusion_silk_state"},+     {"applied":"int","failed":"int","results":"[ok | err:...]","smashed":"[ref]","after":"silk_state","revision":"str"},120,"fusion_routing_state",False,{"expectDocument":"fusion-routing","names":[{"ref":"J1","x":10,"y":20,"size":1.5,"rotation":90}]}),     ("fusion_bodies", "Each part's 3D body as a box, from the design's 3D PCB: footprint rectangle in the board frame (x0, y0, x1, y1 mm) and height range above the surface it sits on (z0 standoff, z1 top; below the board for bottom parts). Names the parts with no 3D model (noBody) and the ones whose body is not where the board puts the part (suspect: off its footprint, floating or sunk, from a wrong library model or a 3D PCB not yet updated). The tallest part per side. What a height or enclosure check reads. Switches the design to its 3D PCB and back to the 2D board inside Fusion; takes no window or mouse.",      {"expectDocument":"required exact board name","boardId":"optional"},      {"bodies":"[{ref,side,x0,y0,x1,y1,z0,z1,package,throughHole?,leadsPastFarSideMm?}]","noBody":"[ref]","suspect":"[{ref,why:[str]}]","tallest":"{top:{ref,heightMm},bottom}","surface":"{topZ,bottomZ}","complete":"bool","revision":"str","returnedTo2d":"bool"},120,"fusion_routing_state",False,{"expectDocument":"fusion-routing"}),
rust/bridge/src/board_model.py+32−12
@@ -417,10 +417,18 @@ def pads_of(board, layers, contacts):                 ys = [v[1] for v in ring] + [c[1] for c in corners]                 p.update({'x': _r((min(xs) + max(xs)) / 2), 'y': _r((min(ys) + max(ys)) / 2), 'sizeX': _r(max(xs) - min(xs)),                           'sizeY': _r(max(ys) - min(ys)), 'rotation': 0, 'shape': 'rect', 'customShape': True})-                p.setdefault('copperPoints', []).append([[_r(a), _r(b)] for a, b in ring])+                ring_r = [[_r(a), _r(b)] for a, b in ring]+                # the standard's copperPoints is one ring: the largest drawn polygon on the pad; a+                # pad that gathers several also lists them all under copperOutlines+                if 'copperPoints' not in p or area(ring_r) > area(p['copperPoints']):+                    if 'copperPoints' in p:+                        p.setdefault('copperOutlines', [p['copperPoints']])+                    p['copperPoints'] = ring_r+                if 'copperOutlines' in p:+                    p['copperOutlines'].append(ring_r)             if not hit:                 extra_polys.append({'net': '', 'layer': layers.name(layer), 'layerId': layer,-                                    'points': [[_r(a), _r(b)] for a, b in ring], 'ref': ref, 'kind': 'footprint copper'})+                                    'points': [[_r(a), _r(b)] for a, b in ring], 'ref': ref, 'kind': 'graphic', 'source': 'footprint', 'pour': 'drawn'})         for h in pkg.findall('hole'):             x, y = xf(_f(h, 'x'), _f(h, 'y'))             holes.append({'ref': ref, 'x': _r(x), 'y': _r(y), 'drill': _f(h, 'drill')})@@ -530,7 +538,7 @@ def board_model(board, meta=None):         layer = int(p.get('layer', '1'))         ring = vertex_ring(shape_vertices(p))         polygons.append({'net': net, 'layer': layers.name(layer), 'layerId': layer, 'points': [[_r(a), _r(b)] for a, b in ring],-                         'pour': p.get('pour', 'solid'), 'isolate': _f(p, 'isolate'), 'rank': int(p.get('rank', '1')),+                         'kind': 'zone', 'pour': p.get('pour', 'solid'), 'isolate': _f(p, 'isolate'), 'rank': int(p.get('rank', '1')),                          'width': _f(p, 'width')})      for s in board.findall('signals/signal'):@@ -554,12 +562,13 @@ def board_model(board, meta=None):         if int(p.get('layer', '0')) in COPPER:             pour(p, '')             polygons[-1]['pour'] = 'drawn'+            polygons[-1]['kind'] = 'graphic'     for rct in board.findall('plain/rectangle'):         layer = int(rct.get('layer', '0'))         if layer in COPPER:             x1, y1, x2, y2 = _f(rct, 'x1'), _f(rct, 'y1'), _f(rct, 'x2'), _f(rct, 'y2')             ring = rect_corners((x1 + x2) / 2, (y1 + y2) / 2, abs(x2 - x1), abs(y2 - y1), _deg(rct.get('rot')))-            polygons.append({'net': '', 'layer': layers.name(layer), 'layerId': layer, 'points': [[_r(a), _r(b)] for a, b in ring], 'pour': 'drawn'})+            polygons.append({'net': '', 'layer': layers.name(layer), 'layerId': layer, 'points': [[_r(a), _r(b)] for a, b in ring], 'kind': 'graphic', 'pour': 'drawn'})     for h in board.findall('plain/hole'):         holes.append({'ref': '', 'x': _f(h, 'x'), 'y': _f(h, 'y'), 'drill': _f(h, 'drill')})     tracks += fp_tracks@@ -591,9 +600,12 @@ def board_model(board, meta=None):     unconnected, split, floating = connectivity(board, model, layers)     model['unconnected'] = unconnected     model['splitNets'] = split-    if floating:      # slivers a pour with orphans on leaves behind: listed, not counted+    model['isolatedCopper'] = floating[:200]   # the standard's key (0.1.1): poured copper reaching nothing else+    if len(floating) > 200:+        model['isolatedCopperCount'] = len(floating)+    if floating:      # the older name, kept for callers that read it         model['floatingPourIslandCount'] = len(floating)-        model['floatingPourIslands'] = floating[:50]+        model['floatingPourIslands'] = [{'net': f['net'], 'layer': f['layer'], 'at': [f['at']['x'], f['at']['y']]} for f in floating[:50]]     model['counts'] = {k: len(model[k]) for k in ('tracks', 'arcs', 'vias', 'pads', 'components', 'polygons', 'nets')}     return model, pads @@ -619,8 +631,9 @@ def connectivity(board, model, layers):     """`unconnected` from the copper itself: per net, the pieces of copper (pads, tracks, arcs, vias,     Fusion's poured fill; a pour Fusion never filled counts as its drawn outline) joined where they     touch on a shared layer. Every group beyond the first is a connection still to make, as on-    Altium. A poured island that touches nothing else of its net is dead copper, not a missing-    connection: it is listed under floatingPourIslands instead."""+    Altium. A group that is only poured copper (an island, or a pour that reaches no pad) is dead+    copper, not a missing connection: it is listed under isolatedCopper instead (the EDA Bridge+    Standard 0.1.1 rule, KiCad's name); a stray track or via still counts."""     name_to_num = {v: k for k, v in layers.names.items()}     all_cu = set(layers.order) @@ -684,9 +697,13 @@ def connectivity(board, model, layers):             groups.setdefault(find(i), []).append(its[i])         live = []         for g in groups.values():-            if len(g) == 1 and g[0]['kind'] == 'fill':+            # only poured copper (an island, or a pour that reaches no pad): isolated copper, listed+            # and not counted (EDA Bridge Standard 0.1.1); a stray track or via still counts+            if all(x['kind'] in ('fill', 'pour outline') for x in g):+                region = g[0]['prims'][0][1]+                area_ = sum(area(list(x['prims'][0][1].rings[0])) - sum(area(list(h)) for h in x['prims'][0][1].rings[1:]) for x in g)                 floating.append({'net': net, 'layer': layers.name(min(g[0]['layers'])),-                                 'at': [_r((g[0]['bbox'][0] + g[0]['bbox'][2]) / 2), _r((g[0]['bbox'][1] + g[0]['bbox'][3]) / 2)]})+                                 'at': {'x': _r(region.outline[0][0]), 'y': _r(region.outline[0][1])}, 'areaMm2': round(area_, 4)})             else:                 live.append(g)         if len(live) > 1:@@ -1117,9 +1134,12 @@ def rd_silk_state(root, board, args):         comps.append({'ref': el.get('name'), 'pattern': el.get('package'), 'x': _f(el, 'x'), 'y': _f(el, 'y'), 'rotation': _norm(_deg(el.get('rot'))),                       'side': 'bottom' if 'M' in el.get('rot', '') else 'top', 'body': part_box(el, pkg) if pkg is not None else None,                       'name': name, 'comment': value, 'smashed': el.get('smashed') == 'yes'})-    lines = [{'side': 'bottom' if w.get('layer') == '22' else 'top', 'x1': _f(w, 'x1'), 'y1': _f(w, 'y1'), 'x2': _f(w, 'x2'), 'y2': _f(w, 'y2'), 'width': _f(w, 'width')}+    # raw: the handle apply_silkscreen's remove takes back (what the item is and where it sits)+    lines = [{'side': 'bottom' if w.get('layer') == '22' else 'top', 'x1': _f(w, 'x1'), 'y1': _f(w, 'y1'), 'x2': _f(w, 'x2'), 'y2': _f(w, 'y2'), 'width': _f(w, 'width'),+              'raw': 'wire|%s|%s|%s|%s|%s' % (w.get('layer'), w.get('x1'), w.get('y1'), w.get('x2'), w.get('y2'))}              for w in board.findall('plain/wire') if w.get('layer') in ('21', '22')]-    texts = [dict(_text(t), text=t.text or '') for t in board.findall('plain/text') if t.get('layer') in ('21', '22', '25', '26', '27', '28')]+    texts = [dict(_text(t), text=t.text or '', raw='text|%s|%s|%s|%s' % (t.get('layer'), t.get('x'), t.get('y'), t.text or ''))+             for t in board.findall('plain/text') if t.get('layer') in ('21', '22', '25', '26', '27', '28')]     return {'components': comps, 'lines': lines, 'texts': texts}  
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") }+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") } pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {     let script = format!("import json\nexec({})\nresult=handle(app,json.loads({}),{})\n",         serde_json::to_string(SOURCE).unwrap(),
rust/bridge/src/routing_script.py+106
@@ -812,6 +812,110 @@ def board_seed(args):             '_hint': 'Import it: fusion_new_electronics_from_eagle {schPath, brdPath} with an electronics document in front, poll fusion_eagle_import_status, then fusion_schematic {action:"show"} and fusion_schematic_plan / fusion_schematic_run for the rest of the circuit. Start from a clean Fusion session: a design imported while half-finished imports are open can come up with schematic/board sync off.'}  +def _silk_text_ok(t):+    if not isinstance(t, str) or not t or len(t) > 120 or any(c in t for c in "';\n"):+        fail('bad_args', 'Silkscreen text must be 1..120 characters without quotes, semicolons or line breaks: %r' % (t,))+    return t+++def apply_silkscreen(app, root, board, meta, args):+    """Land a silkscreen plan (EDA Bridge Standard apply_silkscreen): designators (names) and values+    (comments) moved, sized, turned or hidden; free texts and lines added; earlier free items removed+    by their silk_state raw handle. EAGLE's MOVE, ROTATE, CHANGE and DELETE pick the object nearest+    the point given, so only the layer holding the target is displayed while it is picked (a pad or+    a wire can never be taken instead), and the board's own layer display is put back after. Read+    back afterwards; if copper, parts or the outline moved (the revision), every command is undone."""+    if args.get('expectedRevision') not in (None, meta['revision']):+        fail('stale_board', 'expectedRevision must match the current live board')+    names, comments = args.get('names') or [], args.get('comments') or []+    texts, lines, remove = args.get('texts') or [], args.get('lines') or [], args.get('remove') or {}+    if not (names or comments or texts or lines or remove.get('texts') or remove.get('lines')):+        fail('nothing_to_do', 'Give names, comments, texts, lines or remove')+    visible = [l.get('number') for l in root.find('drawing/layers') if l.get('visible') == 'yes']+    commands, results = [], []+    def run(cmd):+        app.executeTextCommand('Electron.run ' + cmd); commands.append(cmd)+    els = {e.get('name'): e for e in board.findall('elements/element')}+    # parts whose name or value text has not been smashed yet: smash them first so the text is its own object+    want = [('NAME', n) for n in names] + [('VALUE', c) for c in comments]+    for _, item in want:+        ref = identifier(item.get('ref'))+        if ref not in els: fail('designator_not_found', 'No part %s on the board' % ref)+    to_smash = sorted({it['ref'] for kind, it in want if els[it['ref']].get('smashed') != 'yes'})+    for ref in to_smash: run("SMASH '%s';" % ref)+    if to_smash:+        root, board, _ = snapshot(app, {**args, 'expectedRevision': None})+    silk = {c['ref']: c for c in rd_silk_state(root, board, {})['components']}+    try:+        for kind, it in want:+            ref = it['ref']; key = 'name' if kind == 'NAME' else 'comment'+            cur = silk[ref][key]+            if cur.get('x') is None:+                results.append('err:%s %s has no text to move' % (ref, kind)); continue+            layer = cur['layer']+            x, y = number(it['x']), number(it['y'])+            run('DISPLAY NONE %d;' % layer)+            run('MOVE (%.6fmm %.6fmm) (%.6fmm %.6fmm);' % (cur['x'], cur['y'], x, y))+            if it.get('size') is not None: run('CHANGE SIZE %.6fmm (%.6fmm %.6fmm);' % (number(it['size'], True), x, y))+            if it.get('rotation') is not None:+                mirror = 'M' if layer in (22, 26, 28) else ''+                run('ROTATE =%sR%g (%.6fmm %.6fmm);' % (mirror, float(it['rotation']) % 360, x, y))+            if it.get('show') is False: run('CHANGE DISPLAY OFF (%.6fmm %.6fmm);' % (x, y))+            results.append('ok')+        for t in texts:+            side_b = t.get('side') == 'bottom'+            run('LAYER %d; CHANGE SIZE %.6fmm; TEXT \'%s\' %sR%g (%.6fmm %.6fmm);' % (22 if side_b else 21, number(t.get('size', 1.0), True), _silk_text_ok(t.get('text')),+                'M' if side_b else '', float(t.get('rotation', 0)) % 360, number(t['x']), number(t['y'])))+            results.append('ok')+        for l in lines:+            run('LAYER %d; WIRE %.6fmm (%.6fmm %.6fmm) (%.6fmm %.6fmm);' % (22 if l.get('side') == 'bottom' else 21, number(l.get('width', 0.15), True),+                number(l['x1']), number(l['y1']), number(l['x2']), number(l['y2'])))+            results.append('ok')+        for raw in list(remove.get('texts') or []) + list(remove.get('lines') or []):+            parts = str(raw).split('|')+            if parts[0] == 'text' and len(parts) >= 4:+                run('DISPLAY NONE %d;' % int(parts[1])); run('DELETE (%.6fmm %.6fmm);' % (float(parts[2]), float(parts[3]))); results.append('ok')+            elif parts[0] == 'wire' and len(parts) == 6:+                mx, my = (float(parts[2]) + float(parts[4])) / 2, (float(parts[3]) + float(parts[5])) / 2+                run('DISPLAY NONE %d;' % int(parts[1])); run('DELETE (%.6fmm %.6fmm);' % (mx, my)); results.append('ok')+            else:+                results.append('err:not a silk_state raw handle: %r' % (raw,))+    finally:+        # one layer per command: naming a layer Fusion will not toggle (the origin layers 23 and+        # 24 are always shown) makes it drop every layer after it in the same DISPLAY command+        app.executeTextCommand('Electron.run DISPLAY NONE;')+        for n in visible:+            if n not in ('23', '24'):+                try: app.executeTextCommand('Electron.run DISPLAY %s;' % n)+                except Exception: pass+    app.executeTextCommand('Electron.ImmediateRepaint /full')+    root2, after, meta2 = snapshot(app, {**args, 'expectedRevision': None})+    if meta2['revision'] != meta['revision']:+        # something that is not silkscreen moved: take every command back+        for _ in range(len(commands) + 2):+            app.executeTextCommand('Electron.run UNDO;')+            _, _, m3 = snapshot(app, {**args, 'expectedRevision': None})+            if m3['revision'] == meta['revision']: break+        fail('readback_mismatch', 'Copper, parts or the outline changed while the silkscreen was edited; every command was undone')+    st = rd_silk_state(root2, after, {})+    comps = {c['ref']: c for c in st['components']}+    i = 0+    for kind, it in want:+        if results[i] == 'ok':+            got = comps[it['ref']]['name' if kind == 'NAME' else 'comment']+            bad = []+            if abs(got.get('x', 1e9) - number(it['x'])) > 0.01 or abs(got.get('y', 1e9) - number(it['y'])) > 0.01: bad.append('at (%s, %s)' % (got.get('x'), got.get('y')))+            if it.get('size') is not None and abs(got.get('size', 0) - float(it['size'])) > 0.01: bad.append('size %s' % got.get('size'))+            if it.get('rotation') is not None and abs((got.get('rotation', 0) - float(it['rotation'])) % 360) > 0.01: bad.append('rotation %s' % got.get('rotation'))+            if it.get('show') is False and got.get('shown'): bad.append('still shown')+            if bad: results[i] = 'err:%s %s landed %s' % (it['ref'], kind, ', '.join(bad))+        i += 1+    failed = sum(1 for r_ in results if r_ != 'ok')+    return {'success': failed == 0, 'mutated': True, 'applied': len(results) - failed, 'failed': failed, 'results': results,+            'smashed': to_smash, 'commands': len(commands), **meta2, 'after': st,+            '_hint': 'Read back from the board: every result is ok or names what landed where. Each EAGLE command is its own undo step in Fusion (no single-step undo for a plan). Free texts and lines are removed by the raw handle silk_state gives.'}++ def handle(app,args,action):     attempted=False; commands=[]     try:@@ -837,6 +941,8 @@ def handle(app,args,action):                     'data':{'outputPath':out,**info,'nextSteps':['Run fusion_export_cpl to generate the pick-and-place CSV','Run fusion_export_gerbers to generate the Gerber fabrication ZIP']},                     'csv':text if len(text)<200000 else None,                     '_hint':'Columns: %s, then MPN, Manufacturer, LCSC, Mouser, DigiKey from each part\'s attributes. %d of %d parts carry an MPN and %d an LCSC number; data.missingMpn / missingLcsc name the rest (set them in the library or the part\'s attributes).'%('Comment, Designator, Footprint, Quantity, Library' if info['grouped'] else 'Designator, Comment, Footprint, Library, Side',info['withMpn'],n,info['withLcsc'])}+        if action=='apply_silkscreen':+            return apply_silkscreen(app,root,board,meta,args)         if action=='run_drc':             # the native check on the live board, no revision guard: it changes no copper, parts or             # outline (the revision), though Fusion keeps the DRC result in the board, which marks the
tests/run_verb_tests.py+2−2
@@ -370,7 +370,7 @@ t("fusion_route_net#guard", "negative", {"expectDocument": "ZZZ_NoSuchDoc_TEST",   "board/revision guards must refuse before any copper is drawn") t("fusion_routing_drc#guard", "negative", {"expectDocument": "ZZZ_NoSuchDoc_TEST", "boardId": "x", "expectedRevision": "x"}, {"pred": "must_refuse"}, "same guards as route_net") t("fusion_plan_route#guard", "negative", {"expectDocument": "ZZZ_NoSuchDoc_TEST", "net": "NET_1"}, {"pred": "must_refuse"}, "planner needs the live board it was asked about")-for v in ["fusion_route_net", "fusion_routing_drc", "fusion_plan_route", "fusion_move_parts", "fusion_place_vias", "fusion_apply_pours", "fusion_board_source", "fusion_ripup_nets", "fusion_schematic_run", "fusion_board_bom", "fusion_change_pours", "fusion_board_seed", "fusion_schematic_plan", "fusion_remove_pours", "fusion_run_drc", "fusion_repour"]:+for v in ["fusion_route_net", "fusion_routing_drc", "fusion_plan_route", "fusion_move_parts", "fusion_place_vias", "fusion_apply_pours", "fusion_board_source", "fusion_ripup_nets", "fusion_schematic_run", "fusion_board_bom", "fusion_change_pours", "fusion_board_seed", "fusion_schematic_plan", "fusion_remove_pours", "fusion_run_drc", "fusion_repour", "fusion_apply_silkscreen"]:     t(v, "manual", note="live routing on a fixture board: demo/routing/run_demo.py is the weekly proof (needs a board open)") for v in ["fusion_apply_fusion_update", "fusion_resolve_seat_dialog", "fusion_interactive", "fusion_narrate", "fusion_view", "fusion_electronics_query"]:     t(v, "manual", note="lifecycle, seat or presentation verbs: need a human-visible Fusion state; manual tier")@@ -393,7 +393,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_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_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_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_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_board_model.py+16
@@ -86,6 +86,22 @@ class BoardModelTests(unittest.TestCase):         self.assertEqual(1, model['unconnected'])         self.assertEqual([{'net': sig.get('name'), 'groups': 2}], model['splitNets']) +    def test_isolated_copper_is_listed_not_counted(self):+        # standard 0.1.1: a group of only poured copper is isolatedCopper; a stray track still counts+        b = board(DEMO / 'bq25792/ablation/take-02/bq25792-ablation-routed.brd')+        model, _ = r.board_model(b)+        self.assertEqual(0, model['unconnected'])+        iso = model['isolatedCopper']+        self.assertGreater(len(iso), 10)+        one = iso[0]+        self.assertEqual({'net', 'layer', 'at', 'areaMm2'}, set(one))+        self.assertGreater(one['areaMm2'], 0)+        self.assertEqual(len(iso), model['floatingPourIslandCount'])+        b2 = board(DEMO / 'crossbar/take-01/fusion-crossbar-24-routed.brd')+        sig = b2.find('signals/signal')+        ET.SubElement(sig, 'wire', x1='1', y1='1', x2='2', y2='1', width='0.2', layer='1')   # a stray track+        self.assertEqual(1, r.board_model(b2)[0]['unconnected'])+     def test_custom_shape_pads_are_their_bounding_rectangle(self):         # the power FET's drain is one footprint polygon joining pads 5..9 (0.127 mm stubs)         model, _ = r.board_model(board(BQ))