app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
← Commit history
1.12.0 (unreleased source): board model to the EDA Bridge Standard; layer_stack, netlist, read_sheet_file, bodies; standard reads, run_drc, repour, zoom_to; DRC severity fix; standard aliases
23 files changed
+3990−64
BRIDGE_VERSION+1−1CHANGELOG.md+59README.md+69−5bridge.json+26−2contracts/describe.json+483−9contracts/eda-bridge-standard/board.schema.json+42contracts/eda-bridge-standard/companions.schema.json+51describe.py+71−4package.json+12−1rust/Cargo.lock+6−5rust/Cargo.toml+1−1rust/bridge/Cargo.toml+1rust/bridge/src/board_model.py+1120rust/bridge/src/bodies.rs+144rust/bridge/src/main.rs+44−2rust/bridge/src/routing.rs+2−2rust/bridge/src/routing_script.py+108−29rust/bridge/src/sheet.rs+302skills/fusion-routing/SKILL.md+20−1tests/reports/verb-test-2026-10-26_1626.json+1069tests/run_verb_tests.py+5−2tests/test_board_model.py+313tests/test_routing.py+41BRIDGE_VERSION+1−1@@ -1 +1 @@-1.11.44+1.12.0
CHANGELOG.md+59@@ -3,6 +3,65 @@ 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.0 (unreleased)++The board model, to the [EDA Bridge Standard](https://wiki.adom.inc/adom/eda-bridge-standard) (fusion-lane thread).++- `fusion_routing_state` returns the standard's board model (`model: "eda-bridge-standard/board 0.1"`),+ computed from Fusion's own board export in the background:+ - millimetres, Fusion's board frame, y up; copper layers named `F.Cu`, `In<n>.Cu` (by stack order) and+ `B.Cu`, `*.Cu` for a through pad; `copperLayers` lists the stack;+ - `tracks`, `arcs` (center, radius, startAngle, endAngle counterclockwise), `vias` (position, size from+ the board's restring rules when the via leaves it to them, drill, layers), `pads` (ref, name, x, y,+ sizeX, sizeY, rotation, layer, drill, net, shape), `components` (ref, x, y, rotation, side, pattern,+ has3d), `polygons`;+ - a custom-shape pad is its bounding rectangle: Fusion's footprint `polygonshape` copper joining pads+ (the CSD17581Q3A drain joins pads 5 to 9, each 0.127 mm alone) folds into every pad it touches, with+ the real outline kept as `copperPoints`;+ - net-less copper (board copper, free footprint copper) is kept with an empty net;+ - the outline as `outlineEdges` with arcs, cutouts in `outlineCutouts`, and `outline` sampled every 2+ degrees; `outlineClosed` and `outlineProblem` say when it is not closed;+ - `unconnected` and `splitNets` from the copper itself (Fusion's fill included; slivers a pour leaves+ are `floatingPourIslands`, not missing connections); Fusion's ratsnest is `fusionAirwires`, and the+ hint explains a difference;+ - `revision` hashes copper, parts and outline only (silkscreen, rules and the ratsnest leave it alone).+ - The AI flow's keys stay: `segments`, `elements`, `airwireCount`, `remainingNets`, each pad's `at`,+ `radius`, `layers` and its `key` (`U1.3`), each via's `at` and `diameter`. **Changed:** a pad's `name`+ is now its own name (`3`) as the standard says, with `REF.PAD` under `key`; the old per-net rows moved+ from `nets` (now the list of net names) to `netRows`.+ - Verified on the BQ25792 charger (109 parts, 304 pads, 4 layers, 38 pours): every pad's position, size,+ rotation, layer and drill equals Fusion's own (a ULP dump of every contact), live and offline.+- New `fusion_layer_stack`: the stack-up from Fusion's Layer Stack Manager data (copper um, dielectric um+ and Dk x 1000 below each layer, prepreg or core, total), in the background. Falls back to the design+ rules and says the dielectric constant is unknown.+- `fusion_pour_state` adds `zones`: each pour's fill island by island with its holes.+- New `fusion_netlist`: every net with its pads (`U1.3`), live from the board or from a `.sch`/`.fsch` file+ without Fusion (`schPath`); `net` for one net, `expect` to compare pin by pin.+- New `fusion_read_sheet_file`: an EAGLE `.sch` (or the `.sch` in a `.fsch`) read in the exe, no Fusion: parts+ with value, part number, library, sheet and position, and pins with pad and electrical type.+- New `fusion_bodies`: each part's 3D body as a box from the 3D PCB (footprint rectangle, height above the+ surface, leads past the far side for through-hole parts), `noBody`, and `suspect` for a model off its+ footprint, floating or sunk.+- Standard names as aliases: `fusion_launch`, `fusion_board_summary`, `fusion_export_gerber`,+ `fusion_dismiss_dialogs`, `fusion_install` (the reply carries `aliasOf`).+- Board reads in the standard's names, all from the same background export, in mm: `fusion_component_data`,+ `fusion_component_pins`, `fusion_list_nets`, `fusion_net_connections` (with each net's airline length, the+ minimum spanning tree over its pads), `fusion_list_designators`, `fusion_layers`, `fusion_copper_layers`,+ `fusion_layers_in_use`, `fusion_read_rules` and `fusion_rules_by_kind` (Fusion's rule manager plus the classic+ values), `fusion_silk_state`, `fusion_net_classes`.+- `fusion_run_drc`: Fusion's DRC on the live board with every violation (rule, severity, layer, position,+ approved), no revision guard. `fusion_repour`: refill every pour and read the copper back. `fusion_zoom_to`:+ frame a rectangle, parts or a net.+- **Fixed: DRC errors and warnings were swapped.** Fusion's ULP gives a violation's type as 1 for a warning and+ 2 for an error (and its state as 2 when approved); the bridge read type 2 as a warning, so+ `fusion_routing_drc` (and the AI flow's DRC gate) reported the BQ25792 board's 1611 errors as warnings and its+ 14 Wire Stub warnings as errors. Each violation now carries `severity` and `approved` from Fusion's own+ constants (read live from the ULP, 2026-10-26).+- Layer arguments take the standard's names: `F.Cu` / `B.Cu` as well as 1 / 16 (`fusion_route_net`, pours).+- Net names with `!` (active-low, `!CE`) are accepted by the routing verbs; they were refused as invalid.+- Tests: `tests/test_board_model.py` (16 cases over every demo board, against the standard's schemas, copied+ to `contracts/eda-bridge-standard/`), Rust tests for the sheet reader, bodies and aliases.+ ## 1.11.42 (2026-10-17) - The "ADOM is driving Fusion" panel no longer sits at 0% (page issue #121). Most steps do not know their own
README.md+69−5@@ -57,6 +57,33 @@ Short version below, full detail in each guide. | [Contributing](CONTRIBUTING.md) | Check out the source, change it, test it on your own box, and send a pull request for review. Account and CLI setup, the dialog rules, unsigned local builds, the verb suite, and how review and approval work. | +## New in 1.12: the board model, stack-up, netlist, schematic file read and 3D heights++The Fusion bridge now speaks the [EDA Bridge Standard](https://wiki.adom.inc/adom/eda-bridge-standard):+the same board model and the same verb names as the Altium and KiCad bridges, so one AI can read and+check a Fusion board exactly the way it reads an Altium or KiCad one. Every read below runs in the+background from Fusion's own data: no window, no mouse, no dialog.++| what you ask | verb | what you get |+|---|---|---|+| "read the board", "what is on this PCB", "list every pad and its net" | `fusion_routing_state` | The whole board in millimetres, Fusion's frame: tracks, arcs (center, radius, angles), vias, every pad with size, shape, rotation, layer and net, parts with side and footprint, pours, net-less copper, the outline as edges with its arcs, and the copper layers named `F.Cu`, `In1.Cu`, `B.Cu` as on every other EDA tool. A custom-shape pad (a power FET's drain drawn as a footprint polygon) is its bounding rectangle, never 0 x 0. |+| "is everything connected", "which nets are still open" | `fusion_routing_state` | `unconnected` and `splitNets` are computed from the copper itself (pads, tracks, vias and Fusion's poured fill joined where they touch), so the count is right even before Fusion recomputes its ratsnest. Fusion's own airwire count rides along as `fusionAirwires`, and the hint explains any difference. |+| "what is the stack-up", "copper thickness", "dielectric constant", "board thickness" | `fusion_layer_stack` | Copper layers top to bottom with copper micrometres, the dielectric below each (thickness, Dk, prepreg or core) and the total, from Fusion's Layer Stack Manager. What impedance and current-capacity checks read. |+| "what is poured", "copper pour area", "which pours never filled" | `fusion_pour_state` | Each pour as drawn and the copper Fusion actually poured, island by island with its holes (`zones`), plus filled area per layer and per net. |+| "netlist", "check the nets against my circuit" | `fusion_netlist` | Every net with its pads as `U1.3`, from the live board or straight from a `.sch` file with Fusion closed. `expect` compares it pin by pin and lists what is missing or extra. |+| "read the schematic file", "pin types", "which pins are power" | `fusion_read_sheet_file` | Every part of an EAGLE `.sch` (or the one inside a Fusion `.fsch`) with its value, part number, library, sheet and position, and every pin with its pad and electrical type (input, output, power, passive...). Milliseconds, no Fusion needed. |+| "how tall is it", "will it fit the enclosure", "which parts have no 3D model" | `fusion_bodies` | Each part's 3D body as a box: footprint rectangle and height above the board, how far through-hole leads stand out of the far side, the parts with no 3D model, and the ones whose model is not where the board puts the part (off its footprint, floating or sunk). |+| "list the parts", "where is U3", "what nets does Q1 touch", "pins of U1" | `fusion_component_data`, `fusion_component_pins`, `fusion_list_designators` | Each part's footprint, value, side, position, rotation, extent, pads, nets and part number; each pin's net, position, size and drill. |+| "list the nets", "how long would this net be", "which pads are on GND" | `fusion_list_nets`, `fusion_net_connections`, `fusion_net_classes` | Every net with its pad, track, via and pour counts and class; every pad by net with each net's span and airline length; the net classes with width, drill and clearances. |+| "which layers are used", "show the copper layers" | `fusion_layers`, `fusion_copper_layers`, `fusion_layers_in_use` | The layer set with kind and visibility, the copper stack in order, and how many primitives each layer carries. |+| "what are the design rules", "clearance rules", "minimum trace width" | `fusion_read_rules`, `fusion_rules_by_kind` | Every rule in Fusion's rule manager (kind, scope, value, priority, enabled) and the classic rule values. |+| "where are the designators", "silkscreen" | `fusion_silk_state` | Each part's body box and its designator and value text as placed, plus the board's free silkscreen. |+| "run DRC", "check the board for errors" | `fusion_run_drc` | Fusion's own DRC: every violation with its rule, error or warning, layer and position, and counts by rule. |+| "refill the pours", "zoom to U1", "show me the GND net" | `fusion_repour`, `fusion_zoom_to` | Refill every pour and read the copper back; frame the view on a rectangle, parts or a net. |+| the standard's names | `fusion_launch`, `fusion_board_summary`, `fusion_export_gerber`, `fusion_dismiss_dialogs`, `fusion_install` | The EDA Bridge Standard's names for verbs this bridge already has; the reply says `aliasOf`. |++Net names with `!` (active-low signals such as `!CE`, `!INT`) are accepted by every routing verb.+ ## New since 1.10 Short entries, newest behavior first in each guide. The [changelog](CHANGELOG.md) has every release.@@ -104,8 +131,9 @@ refuses a schematic whose library was re-assembled piecewise from a Fusion-expor ### Live per-net routing -Four verbs route a live two-layer board one net at a time. `fusion_routing_state` reads the board's-identity, geometry revision, pads, copper and per-net airwires. `fusion_plan_route` calculates a+Four verbs route a live two-layer board one net at a time. `fusion_routing_state` reads the board+as the EDA Bridge Standard's board model (above) with its identity and a revision that changes only+with copper, parts or outline. `fusion_plan_route` calculates a read-only, bounded orthogonal path between two pads. `fusion_route_net` creates the copper with board and revision guards and reads the exact copper back. `fusion_routing_drc` runs native Autodesk DRC, and each error carries `layer`, `x` and `y` in mm, plus a `summary` by description@@ -499,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.-_164 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`.+_200 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`. **Lifecycle and readiness** @@ -507,6 +535,7 @@ _164 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`. - `fusion_apply_fusion_update` - Readiness EXECUTOR (Rust port, Phase 3): click Fusion's update offer IN THE BACKGROUND when auto-update is on (sticky pref; fusion_set_auto_update). - `fusion_describe` - Self-describe every verb the bridge exposes (this list) for ab's Verbs tab + runner. - `fusion_get_app_state` - Current app state: running, active document, workspace, isElectronics.+- `fusion_install` - The EDA Bridge Standard's name for fusion_install_fusion: the same verb, same arguments and reply (marked aliasOf). - `fusion_install_fusion` - Install Fusion 360 FOR the user - downloads Autodesk's official Client Downloader and streams the free trial silently, with NO shell_execute and NO ab approval... - `fusion_kill` - Force-KILL Fusion 360 (taskkill /F) - the desperate path for when fusion_stop cannot close it (stuck modal / wedged process). - `fusion_readiness` - FAST readiness check (does NOT launch Fusion): is the Fusion host app installed + running + the bridge ready to drive?@@ -585,7 +614,11 @@ _164 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`. **Schematic, board and routing** +- `fusion_board_bom` - The bill of materials from the live board's own part attributes: one line per MPN with designators, quantity, value, package, manufacturer and the Mouser... - `fusion_board_info` - Raw board geometry XML (copper/arcs/lines) for the open board.+- `fusion_board_seed` - Write the design a new board starts from, as EAGLE source on this machine: the board outline, the fab's design rules (Fusion's own rules block with your...+- `fusion_board_source` - Read the live board as EAGLE XML from a fresh export: parts, copper, vias, pours with Fusion's own computed fill, and the outline.+- `fusion_board_summary` - The EDA Bridge Standard's name for fusion_board_info: the same verb, same arguments and reply (marked aliasOf). - `fusion_eagle_import_status` - The run record of a background fusion_new_electronics_from_eagle import: stage (started | done | failed), documentName, partsExpected (elements in the .brd)... - `fusion_electron_pan` - Smoothly PAN + zoom the view to frame a board-coordinate box (mm; get part positions from fusion_board_info). - `fusion_electron_run` - Run an EAGLE/Electron command in the open board (the generic extension point, e.g. RUN <ulp>).@@ -602,8 +635,10 @@ _164 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`. - `fusion_plan_route` - Calculate a read-only, bounded orthogonal A* path between two pads on a live two-layer board. - `fusion_route_net` - Create explicit net copper in the live Fusion canvas. - `fusion_routing_drc` - Run fresh native Autodesk DRC and read UL_BOARD.errors, including approved errors, warnings and airwires.-- `fusion_routing_state` - Read the live board's identity, geometry revision, pads, copper and per-net airwires from a fresh Fusion export.+- `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... - `fusion_schematic` - START HERE for a schematic. ONE verb, three actions.+- `fusion_schematic_plan` - Plan a grouped schematic from a netlist: one labelled frame per block, the block's parts in a row, every connected pin given a short stub named for its net and...+- `fusion_schematic_run` - Build a schematic live: a list of complete, non-interactive EAGLE commands run one at a time on the active schematic (ADD 'dev@lib' 'NAME' R0 (x y), VALUE, NET... - `fusion_show_2d_board` - Switch the OPEN electronics design to its 2D PCB Editor (board layout) view. - `fusion_show_3d_board` - Switch the OPEN electronics design to its 3D PCB view (populated board). - `fusion_show_project` - Eight-surfaces contract: bring the PROJECT/design to its top level.@@ -628,6 +663,7 @@ _164 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`. - `fusion_export_eagle_source` - Export the ACTIVE electronics document as plain EAGLE XML (.sch or .brd; the outputPath extension picks the format). - `fusion_export_f3d` - Export the active design as a Fusion archive (.f3d). - `fusion_export_fbx` - Export to FBX mesh.+- `fusion_export_gerber` - The EDA Bridge Standard's name for fusion_export_gerbers: the same verb, same arguments and reply (marked aliasOf). - `fusion_export_iges` - Export to IGES. - `fusion_export_lbr` - Export the electronics LIBRARY in front as an EAGLE .scr script via Electron.run EXPORT SCRIPT: a text dump of every symbol, package and device, the round-trip... - `fusion_export_obj` - Export to OBJ mesh.@@ -663,6 +699,7 @@ _164 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`. - `fusion_close_document` - Close ONE document WITHOUT the modal save dialog (save:false discards silently - the automation-safe close). - `fusion_close_window` - Close a DIALOG window by hwnd (WM_CLOSE, like clicking X) - for dialogs without Cancel/Escape handling (e.g. Recovered Documents). - `fusion_dismiss_blocking_dialogs` - Detect + classify (and optionally dismiss) blocking Fusion dialogs.+- `fusion_dismiss_dialogs` - The EDA Bridge Standard's name for fusion_dismiss_blocking_dialogs: the same verb, same arguments and reply (marked aliasOf). - `fusion_notify_owner` - LAST-RESORT human escalation: toast the user's MAIN computer (fans out cross-ab to every peer on the relay, so it reaches them even when this bridge runs on an... - `fusion_reset_workspace` - Bring Fusion back to a clean slate between runs: closes named documents AND the Untitled scratch the library/bind flow leaves behind (page issue #59). - `fusion_restart_for_addin` - ASYNC: returns immediately (started:true) and restarts Fusion in the background, SAVING the user's open documents first; poll fusion_readiness until ready with...@@ -670,6 +707,7 @@ _164 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`. **Modeling and parameters** - `fusion_assembly_bom` - Structured, kit-aware MECHANICAL bill of materials for the active Design assembly (the counterpart to the electronics-only fusion_export_bom).+- `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... - `fusion_get_design_info` - Design-level summary of the active design: components, bodies, sketches, units, root name. - `fusion_get_parameters` - All user + model parameters of the active design (name, expression, value, unit). - `fusion_import_step` - Import a STEP/.stp (or other native-importable) file into a NEW Fusion design via the native import API.@@ -688,18 +726,44 @@ _164 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`. **Other** - `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_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.+- `fusion_copper_layers` - The board's copper layers in stack order (F.Cu, In1.Cu.., B.Cu) with Fusion's layer id, signal or plane, whether displayed, and primitives on each. - `fusion_execute_text_command` - Run a raw Fusion TEXT COMMAND (app.executeTextCommand) - the escape hatch to Fusion internals (Electron.* electronics commands included). - `fusion_get_preferences` - Read current Fusion appearance/navigation preferences (theme, activeTheme, invertScrollZoom, orbitScheme, modelingOrientation, gestureNav, cameraPivot...+- `fusion_launch` - The EDA Bridge Standard's name for fusion_start: the same verb, same arguments and reply (marked aliasOf).+- `fusion_layer_stack` - The board's stack-up in the background, from Fusion's own Layer Stack Manager data in the board: copper layers top to bottom (F.Cu, In1.Cu, ..., B.Cu) with...+- `fusion_layers` - The board's layer set: id, name (copper by the standard's names, the rest by Fusion's own: SilkscreenTop, BoardOutline...), kind (copper, silkscreen, mask...+- `fusion_layers_in_use` - Which layers actually carry something: copper layers with copper (most first) and every layer with any primitive (board, footprints on their real side...+- `fusion_list_designators` - Every part designator on the live board, in natural order (C2 before C11).+- `fusion_list_nets` - Every net on the live board with its pad, track, arc, via and pour counts and its net class. - `fusion_list_text_commands` - List Fusion's internal TEXT COMMANDS (TextCommands.List) whose name contains filter, case-insensitive; the discovery tool for fusion_execute_text_command. - `fusion_mcp_call` - Call any tool on Autodesk's Fusion MCP server (fusion_mcp_read, fusion_mcp_update, fusion_mcp_execute, fusion_mcp_electronics_read). - `fusion_mcp_enable` - Turn Autodesk's Fusion MCP server ON by driving Fusion's Preferences dialog for the user. - `fusion_mcp_resources` - List Autodesk MCP resources, or read one by uri. - `fusion_mcp_status` - Is Autodesk's Fusion MCP server up, and what does it expose? - `fusion_mcp_tools` - Full tool list from Autodesk's Fusion MCP server INCLUDING input schemas, so you can construct a valid fusion_mcp_call.+- `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.+- `fusion_net_classes` - The board's net classes with their width, drill, class-to-class clearances and member nets.+- `fusion_net_connections` - Every pad with its net as Designator.Pad and position, and per net its span and airline length (the minimum spanning tree over its pads, the shortest copper...+- `fusion_netlist` - The compiled netlist: every net with its pads as Designator.Pad (U1.3).+- `fusion_place_vias` - Add vias at exact points (stitching, thermal, escape): VIA per via with its drill and diameter, then read back.+- `fusion_pour_state` - Read every pour on the live board with Fusion's own computed fill: filled area per pour, per layer and per net, and which pours were never filled (no RATSNEST... - `fusion_prefs_close` - Apply+OK (save:true, the default) or Cancel Fusion's Preferences dialog. - `fusion_prefs_open` - Open Fusion's Preferences dialog and optionally jump to a section, then hand back a SCREENSHOT + shotId you can click in. - `fusion_prewarm` - Warm the bridge ahead of the first real call, WITHOUT starting Fusion: confirms the add-in is synced into every Fusion add-in folder, readies the space-free...+- `fusion_read_rules` - Every design rule on the live board from Fusion's rule manager, disabled ones included: name, kind (Copper Clearance, Minimum Copper Width, Teardrop...)...+- `fusion_read_sheet_file` - Read a schematic straight from its file, without Fusion running: every part of an EAGLE .sch (or the .sch inside a Fusion .fsch) with its designator, library...+- `fusion_remove_pours` - Delete pours (all of them, or the ones named by net, layer and outline) so a board can be poured again after a refine.+- `fusion_repour` - Refill every copper pour on the live board (Fusion's ratsnest pass, with hidden fills shown again) and read the poured copper back: filled area per layer and...+- `fusion_ripup_nets` - Take whole nets' tracks and vias back up (RIPUP per net, one undo step each) so they can be routed again, wider or another way.+- `fusion_rules_by_kind` - The ENABLED design rules grouped by kind, each group in priority order.+- `fusion_run_drc` - Run Fusion's own design rule check on the live board in the background and read every violation back: rule text, error or warning, layer, position in mm... - `fusion_set_preference` - Set Fusion appearance/navigation preferences in the LIVE session - applies immediately, NO restart.+- `fusion_silk_state` - The silkscreen as it stands: every part's body box, its designator and value texts (position, size, rotation, side, shown; a smashed part's own text, else the...+- `fusion_zoom_to` - Frame Fusion's board view on a rectangle, a part (or several) or a whole net, with a margin. ## Two install layers (they are NOT the same thing) @@ -725,7 +789,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. 164 verbs -+two tiers can serve different versions. 200 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+26−2@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.11.44",+ "version": "1.12.0", "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.",@@ -52,6 +52,8 @@ "fusion_board_seed", "fusion_board_source", "fusion_board_stackup",+ "fusion_board_summary",+ "fusion_bodies", "fusion_build_library_3d", "fusion_cancel_run", "fusion_capture_library_views",@@ -62,6 +64,9 @@ "fusion_close_all_documents", "fusion_close_document", "fusion_close_window",+ "fusion_component_data",+ "fusion_component_pins",+ "fusion_copper_layers", "fusion_create_cloud_folder", "fusion_create_library", "fusion_delete_cloud_file",@@ -70,6 +75,7 @@ "fusion_describe", "fusion_detect_layers", "fusion_dismiss_blocking_dialogs",+ "fusion_dismiss_dialogs", "fusion_document_info", "fusion_eagle_import_status", "fusion_electron_pan",@@ -88,6 +94,7 @@ "fusion_export_eagle_source", "fusion_export_f3d", "fusion_export_fbx",+ "fusion_export_gerber", "fusion_export_gerbers", "fusion_export_iges", "fusion_export_lbr",@@ -108,14 +115,21 @@ "fusion_import_electronics", "fusion_import_step", "fusion_inspect_bodies",+ "fusion_install", "fusion_install_fusion", "fusion_install_library_bundle", "fusion_interactive", "fusion_kill",+ "fusion_launch",+ "fusion_layer_stack",+ "fusion_layers",+ "fusion_layers_in_use", "fusion_library_manager", "fusion_library_parts", "fusion_list_cloud_files", "fusion_list_cloud_projects",+ "fusion_list_designators",+ "fusion_list_nets", "fusion_list_text_commands", "fusion_live_feed", "fusion_make_3d_package",@@ -133,6 +147,9 @@ "fusion_mcp_tools", "fusion_move_parts", "fusion_narrate",+ "fusion_net_classes",+ "fusion_net_connections",+ "fusion_netlist", "fusion_new_electronics_from_eagle", "fusion_notify_owner", "fusion_open_board",@@ -153,9 +170,12 @@ "fusion_prefs_open", "fusion_prewarm", "fusion_prove_result",+ "fusion_read_rules",+ "fusion_read_sheet_file", "fusion_readiness", "fusion_release_seat", "fusion_remove_pours",+ "fusion_repour", "fusion_reset_workspace", "fusion_resolve_seat_dialog", "fusion_restart_for_addin",@@ -163,6 +183,8 @@ "fusion_route_net", "fusion_routing_drc", "fusion_routing_state",+ "fusion_rules_by_kind",+ "fusion_run_drc", "fusion_run_modeling_script", "fusion_save_to_cloud", "fusion_schematic",@@ -188,6 +210,7 @@ "fusion_show_symbol", "fusion_signin", "fusion_signin_2fa",+ "fusion_silk_state", "fusion_start", "fusion_state", "fusion_stop",@@ -196,7 +219,8 @@ "fusion_verb_times", "fusion_view", "fusion_walk_cloud_tree",- "fusion_web_signin"+ "fusion_web_signin",+ "fusion_zoom_to" ], "dependencies": {}, "category": "CAD",
contracts/describe.json+483−9@@ -1,6 +1,6 @@ { "contract": "fusion-bridge verb catalog",- "frozen": "2026-10-06",+ "frozen": "2026-10-26", "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": [@@ -565,6 +565,45 @@ "summary": "Read a board's PHYSICAL fabrication stackup (see the pcb-stackup skill): copper layer count + copper/dielectric thicknesses from the EAGLE design rules (layerSetup/mtCopper/mtIsolate) plus the measured FR4 extent. A PCB is Cu/prepreg/Cu/core/.../Cu - the FR4 is NOT one slab. Use to build the stackup table + real-thickness exploded 3D view, and put a stackup table on every exported board's wiki page.", "timeoutSeconds": 200 },+ {+ "example": {},+ "input": {+ "...": "as fusion_board_info"+ },+ "longRunning": false,+ "name": "fusion_board_summary",+ "output": {+ "...": "as fusion_board_info",+ "aliasOf": "fusion_board_info"+ },+ "statusVerb": null,+ "summary": "The EDA Bridge Standard's name for fusion_board_info: the same verb, same arguments and reply (marked aliasOf). One name across Altium, KiCad and Fusion bridges.",+ "timeoutSeconds": 120+ },+ {+ "example": {+ "expectDocument": "fusion-routing"+ },+ "input": {+ "boardId": "optional",+ "expectDocument": "required exact board name"+ },+ "longRunning": false,+ "name": "fusion_bodies",+ "output": {+ "bodies": "[{ref,side,x0,y0,x1,y1,z0,z1,package,throughHole?,leadsPastFarSideMm?}]",+ "complete": "bool",+ "noBody": "[ref]",+ "returnedTo2d": "bool",+ "revision": "str",+ "surface": "{topZ,bottomZ}",+ "suspect": "[{ref,why:[str]}]",+ "tallest": "{top:{ref,heightMm},bottom}"+ },+ "statusVerb": "fusion_routing_state",+ "summary": "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.",+ "timeoutSeconds": 120+ }, { "example": { "lbrPath": "C:/tmp/newlib/AdomBasicParts2.lbr",@@ -783,6 +822,65 @@ "summary": "Close a DIALOG window by hwnd (WM_CLOSE, like clicking X) - for dialogs without Cancel/Escape handling (e.g. Recovered Documents). NOT a document closer; refuses without hwnd. Get the hwnd from a screenshot's dialogs array or desktop_list_windows. (Doc drift caught by the verb test suite 2026-08-14: this was described as 'close the active document' while the handler required hwnd.)", "timeoutSeconds": 30 },+ {+ "example": {+ "expectDocument": "fusion-routing"+ },+ "input": {+ "boardId": "optional",+ "designator": "optional: one part (U1); omit for all",+ "expectDocument": "required exact active board name",+ "limit": "int, default 500"+ },+ "longRunning": false,+ "name": "fusion_component_data",+ "output": {+ "component": "{designator,pattern,comment,library,side,layer,xMm,yMm,rotation,box,wMm,hMm,pads,nets,has3d,locked,partNumber}",+ "components": "[...] (all)",+ "count": "int"+ },+ "statusVerb": "fusion_routing_state",+ "summary": "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 rotation in mm, its extent (box, wMm, hMm: every footprint primitive but text), pad count, the nets on its pads, part number and whether it has a 3D model.",+ "timeoutSeconds": 60+ },+ {+ "example": {+ "expectDocument": "fusion-routing"+ },+ "input": {+ "boardId": "optional",+ "designator": "required (U1)",+ "expectDocument": "required exact active board name"+ },+ "longRunning": false,+ "name": "fusion_component_pins",+ "output": {+ "count": "int",+ "designator": "str",+ "pins": "[{ref,name,key,x,y,sizeX,sizeY,rotation,layer,drill,net,shape}]"+ },+ "statusVerb": "fusion_routing_state",+ "summary": "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.",+ "timeoutSeconds": 60+ },+ {+ "example": {+ "expectDocument": "fusion-routing"+ },+ "input": {+ "boardId": "optional",+ "expectDocument": "required exact active board name"+ },+ "longRunning": false,+ "name": "fusion_copper_layers",+ "output": {+ "count": "int",+ "layers": "[{name,id,kind,displayed,primitives}]"+ },+ "statusVerb": "fusion_routing_state",+ "summary": "The board's copper layers in stack order (F.Cu, In1.Cu.., B.Cu) with Fusion's layer id, signal or plane, whether displayed, and primitives on each.",+ "timeoutSeconds": 60+ }, { "example": { "folderName": "AI-Scratch",@@ -949,6 +1047,21 @@ "summary": "Detect + classify (and optionally dismiss) blocking Fusion dialogs. Answers every one with the safe button (No/Cancel via WM_CLOSE, never Yes, never focus-stolen). {categories:[...]} narrows the sweep to those categories (e.g. [\"crash_report\"], the launch janitor's scope).", "timeoutSeconds": 30 },+ {+ "example": {},+ "input": {+ "...": "as fusion_dismiss_blocking_dialogs"+ },+ "longRunning": false,+ "name": "fusion_dismiss_dialogs",+ "output": {+ "...": "as fusion_dismiss_blocking_dialogs",+ "aliasOf": "fusion_dismiss_blocking_dialogs"+ },+ "statusVerb": null,+ "summary": "The EDA Bridge Standard's name for fusion_dismiss_blocking_dialogs: the same verb, same arguments and reply (marked aliasOf). One name across Altium, KiCad and Fusion bridges.",+ "timeoutSeconds": 120+ }, { "example": {}, "input": {},@@ -1314,6 +1427,21 @@ "summary": "Export to FBX mesh.", "timeoutSeconds": 300 },+ {+ "example": {},+ "input": {+ "...": "as fusion_export_gerbers"+ },+ "longRunning": false,+ "name": "fusion_export_gerber",+ "output": {+ "...": "as fusion_export_gerbers",+ "aliasOf": "fusion_export_gerbers"+ },+ "statusVerb": null,+ "summary": "The EDA Bridge Standard's name for fusion_export_gerbers: the same verb, same arguments and reply (marked aliasOf). One name across Altium, KiCad and Fusion bridges.",+ "timeoutSeconds": 120+ }, { "example": { "outputDir": "C:/tmp/jlc"@@ -1687,6 +1815,21 @@ "summary": "GEOMETRY READ-BACK: what actually got built, in MILLIMETRES. Per body: bbox, sizeMm, volumeMm3, areaMm2, faceCount, cylindricalFaceCount, appearance, material, isVisible. Use this to VERIFY your own modelling - a body at the wrong Z (a double-applied offset, a part buried inside another) renders perfectly and is INVISIBLE in a screenshot, and this work is often matte-black parts on a dark canvas where pixels are a weak check. cylindricalFaceCount is the cheap way to confirm holes were actually cut: a symmetric cut that reaches nothing fails SILENTLY with no exception. Pass worldSpace:true to resolve bodies through their occurrence transforms (assembly placement); optionally filter with occurrence. Fusion's API is centimetres - this converts for you.", "timeoutSeconds": 130 },+ {+ "example": {},+ "input": {+ "...": "as fusion_install_fusion"+ },+ "longRunning": false,+ "name": "fusion_install",+ "output": {+ "...": "as fusion_install_fusion",+ "aliasOf": "fusion_install_fusion"+ },+ "statusVerb": null,+ "summary": "The EDA Bridge Standard's name for fusion_install_fusion: the same verb, same arguments and reply (marked aliasOf). One name across Altium, KiCad and Fusion bridges.",+ "timeoutSeconds": 120+ }, { "example": {}, "input": {},@@ -1764,6 +1907,79 @@ "summary": "Force-KILL Fusion 360 (taskkill /F) - the desperate path for when fusion_stop cannot close it (stuck modal / wedged process). Closes docs cleanly first when reachable.", "timeoutSeconds": 30 },+ {+ "example": {},+ "input": {+ "...": "as fusion_start"+ },+ "longRunning": false,+ "name": "fusion_launch",+ "output": {+ "...": "as fusion_start",+ "aliasOf": "fusion_start"+ },+ "statusVerb": null,+ "summary": "The EDA Bridge Standard's name for fusion_start: the same verb, same arguments and reply (marked aliasOf). One name across Altium, KiCad and Fusion bridges.",+ "timeoutSeconds": 120+ },+ {+ "example": {+ "expectDocument": "fusion-routing"+ },+ "input": {+ "boardId": "optional",+ "expectDocument": "required exact active board name"+ },+ "longRunning": false,+ "name": "fusion_layer_stack",+ "output": {+ "copperLayers": "int",+ "layers": "[{name,layerId,kind:signal|plane,copperUm,dielectricUm?,dk1000?,dielectric:[{kind,um,dk,material}],mid?}]",+ "notes": "[str]",+ "revision": "str",+ "source": "str",+ "totalUm": "num"+ },+ "statusVerb": "fusion_routing_state",+ "summary": "The board's stack-up in the background, from Fusion's own Layer Stack Manager data in the board: copper layers top to bottom (F.Cu, In1.Cu, ..., B.Cu) with copper thickness in micrometres, the dielectric below each (thickness, Dk x 1000, prepreg or core, material) and the total board thickness. What impedance, current-capacity and copper-balance checks read. A board with no Layer Stack Manager data falls back to its design rules and says the dielectric constant is unknown. No view switching (fusion_board_stackup is the older 2D/3D read).",+ "timeoutSeconds": 60+ },+ {+ "example": {+ "expectDocument": "fusion-routing"+ },+ "input": {+ "boardId": "optional",+ "expectDocument": "required exact active board name"+ },+ "longRunning": false,+ "name": "fusion_layers",+ "output": {+ "count": "int",+ "layers": "[{id,name,fusionName,kind,displayed,primitives}]"+ },+ "statusVerb": "fusion_routing_state",+ "summary": "The board's layer set: id, name (copper by the standard's names, the rest by Fusion's own: SilkscreenTop, BoardOutline...), kind (copper, silkscreen, mask, paste, outline, drill, keepout, documentation), whether it is displayed, and how many primitives it carries.",+ "timeoutSeconds": 60+ },+ {+ "example": {+ "expectDocument": "fusion-routing"+ },+ "input": {+ "boardId": "optional",+ "expectDocument": "required exact active board name"+ },+ "longRunning": false,+ "name": "fusion_layers_in_use",+ "output": {+ "copperLayersInUse": "[{layerId,name,primitives}]",+ "layersWithPrimitives": "[{layerId,name,primitives}]"+ },+ "statusVerb": "fusion_routing_state",+ "summary": "Which layers actually carry something: copper layers with copper (most first) and every layer with any primitive (board, footprints on their real side, signals; a via counts on each layer it passes).",+ "timeoutSeconds": 60+ }, { "example": { "tab": "private"@@ -1835,6 +2051,42 @@ "summary": "List the cloud PROJECTS visible to the signed-in Fusion session (hub walk - slow on big teams; prefer fusion_aps_search for finding files).", "timeoutSeconds": 120 },+ {+ "example": {+ "expectDocument": "fusion-routing"+ },+ "input": {+ "boardId": "optional",+ "expectDocument": "required exact active board name"+ },+ "longRunning": false,+ "name": "fusion_list_designators",+ "output": {+ "count": "int",+ "designators": "[str]"+ },+ "statusVerb": "fusion_routing_state",+ "summary": "Every part designator on the live board, in natural order (C2 before C11).",+ "timeoutSeconds": 60+ },+ {+ "example": {+ "expectDocument": "fusion-routing"+ },+ "input": {+ "boardId": "optional",+ "expectDocument": "required exact active board name"+ },+ "longRunning": false,+ "name": "fusion_list_nets",+ "output": {+ "count": "int",+ "nets": "[{name,pads,tracks,arcs,vias,pours,class}]"+ },+ "statusVerb": "fusion_routing_state",+ "summary": "Every net on the live board with its pad, track, arc, via and pour counts and its net class.",+ "timeoutSeconds": 60+ }, { "example": { "filter": "Electron"@@ -2160,6 +2412,69 @@ "summary": "Play one bundled narration clip on the box now (the guided tour's voice): dock, dashboard, or any <beat>[-a|-b] clip in resources/demo-audio. A driver speaks the dock bar and dashboard lines with it before the tour starts. Returns startedAt (epoch seconds) so a recording can be cut on it; the tour's own narration stops it when a beat begins.", "timeoutSeconds": 15 },+ {+ "example": {+ "expectDocument": "fusion-routing"+ },+ "input": {+ "boardId": "optional",+ "expectDocument": "required exact active board name"+ },+ "longRunning": false,+ "name": "fusion_net_classes",+ "output": {+ "classes": "[{name,number,widthMm,drillMm,clearances:[{toClass,mm}],nets}]",+ "count": "int"+ },+ "statusVerb": "fusion_routing_state",+ "summary": "The board's net classes with their width, drill, class-to-class clearances and member nets.",+ "timeoutSeconds": 60+ },+ {+ "example": {+ "expectDocument": "fusion-routing"+ },+ "input": {+ "boardId": "optional",+ "expectDocument": "required exact active board name",+ "limit": "int, default 4000 pads",+ "net": "optional: one net (case-insensitive)"+ },+ "longRunning": false,+ "name": "fusion_net_connections",+ "output": {+ "count": "int",+ "nets": "[{net,pads,span:[x0,y0,x1,y1],airlineMm}]",+ "pads": "[{net,pad,x,y,layer}]"+ },+ "statusVerb": "fusion_routing_state",+ "summary": "Every pad with its net as Designator.Pad and position, and per net its span and airline length (the minimum spanning tree over its pads, the shortest copper that could join them). net filters to one net.",+ "timeoutSeconds": 60+ },+ {+ "example": {+ "expectDocument": "fusion-routing"+ },+ "input": {+ "expect": "optional {net:[\"U1.3\",...]}: compare pin by pin",+ "expectDocument": "the active board's exact name (live read)",+ "net": "optional: one net (case-insensitive)",+ "schPath": "OR an EAGLE .sch / Fusion .fsch on the Fusion machine (file read, Fusion need not run)"+ },+ "longRunning": false,+ "name": "fusion_netlist",+ "output": {+ "mismatches": "[{net,missing,extra} | {net,unexpected}] (with expect)",+ "netCount": "int",+ "nets": "{net:[ref.pad]}",+ "ok": "bool (with expect)",+ "pinsWithoutPad": "[part.gate.pin] (schPath: supply symbols and pins with no pad)",+ "revision": "str (live)"+ },+ "statusVerb": "fusion_routing_state",+ "summary": "The compiled netlist: every net with its pads as Designator.Pad (U1.3). From the live board in the background (expectDocument; Fusion keeps board and schematic in sync), or from a schematic FILE without Fusion (schPath: an EAGLE .sch or Fusion .fsch; pins resolved to pads through each device's connects). expect compares it pin by pin with the circuit you meant; net returns one net.",+ "timeoutSeconds": 60+ }, { "example": { "brdPath": "C:/Users/me/adom-lib/MyBoard.brd",@@ -2461,7 +2776,8 @@ "filledAreaByNetMm2": "{net:mm2}", "pours": "list", "revision": "str",- "unfilled": "[net]"+ "unfilled": "[net]",+ "zones": "[{net,layer,outline,filledMm2,filled,fills:[{outline,holes}]}] (the standard's pours model)" }, "statusVerb": "fusion_routing_state", "summary": "Read every pour on the live board with Fusion's own computed fill: filled area per pour, per layer and per net, and which pours were never filled (no RATSNEST since they were drawn). The measure step of the AI flow's Fusion lane.",@@ -2559,6 +2875,49 @@ "summary": "POLL a background run started by fusion_create_library or the detached fusion_build_library_3d. Returns stage + stageLabel, plan[] with measured per-stage estimates, stagesDone, percent, etaSec, confidence, and evidence[] filling in AS EACH ARTIFACT LANDS (symbol -> footprint -> 3d -> ganged). For a 3D build it carries per-part progress (minting X (3/7)), partsDone/partsTotal and parts[] with each wip_urn. stage:done only means the chain FINISHED - the top-level verdict (proven/not_proven) is the score, and a run that captured zero frames lands stage:failed with blocked_by_dialog (carrying the dialog text) or no_evidence_captured rather than reporting done. Pass inlineFrames:true on the final fetch for base64 thumbnails a browser can render. restarts[] {at, why, kind} lists any restart the chain performed (only with allowRestart). prove.planned / prove.skipped say whether evidence frames were captured and why (AI threads bind only by default).", "timeoutSeconds": 20 },+ {+ "example": {+ "expectDocument": "fusion-routing"+ },+ "input": {+ "boardId": "optional",+ "expectDocument": "required exact active board name"+ },+ "longRunning": false,+ "name": "fusion_read_rules",+ "output": {+ "count": "int",+ "params": "{mdWireWire:...}",+ "ruleSet": "str",+ "rules": "[{name,kind,enabled,priority,scope1,scope2,value,preferredValue,attrs,liveDrc,manualDrc}]"+ },+ "statusVerb": "fusion_routing_state",+ "summary": "Every design rule on the live board from Fusion's rule manager, disabled ones included: name, kind (Copper Clearance, Minimum Copper Width, Teardrop...), enabled, priority, both scopes, value and preferred value, whether live and manual DRC check its kind, plus the classic design-rule values (params) and the rule set's name.",+ "timeoutSeconds": 60+ },+ {+ "example": {+ "path": "C:/Users/me/Documents/board.sch"+ },+ "input": {+ "params": "bool, default false: every attribute with its value",+ "path": "required: EAGLE .sch or Fusion .fsch on the Fusion machine (alias filePath, schPath)",+ "pins": "bool, default true"+ },+ "longRunning": false,+ "name": "fusion_read_sheet_file",+ "output": {+ "complete": "bool (false with hierarchical modules or unknown devicesets)",+ "components": "[{designator,libReference,deviceset,device,comment,description,uniqueId,partNumber?,sourceLibrary,partCount,sheet,xMil,yMil,xMm,yMm,orientation,params?,pins:[{number,name,gate,electrical,direction}]}]",+ "count": "int",+ "elapsedMs": "int",+ "format": "str",+ "sheets": "int"+ },+ "statusVerb": null,+ "summary": "Read a schematic straight from its file, without Fusion running: every part of an EAGLE .sch (or the .sch inside a Fusion .fsch) with its designator, library reference (deviceset + device), value, description, part number, library, sheet, position and orientation, parameters on request, and its pins: number (the pad), name, gate and electrical type (input, output, io, open_collector, passive, hiz, power, supply, not_connected) for a schematic review. Milliseconds. The saved file, not the live sheet.",+ "timeoutSeconds": 30+ }, { "example": {}, "input": {},@@ -2611,6 +2970,27 @@ "summary": "Delete pours (all of them, or the ones named by net, layer and outline) so a board can be poured again after a refine. Each DELETE picks the outline point farthest from every pad (refused if none is 1 mm clear), and the readback undoes everything if a part or a track changed.", "timeoutSeconds": 120 },+ {+ "example": {+ "expectDocument": "fusion-routing"+ },+ "input": {+ "boardId": "optional",+ "expectDocument": "required exact active board name"+ },+ "longRunning": false,+ "name": "fusion_repour",+ "output": {+ "filledAreaByLayerMm2": "{layer:mm2}",+ "filledAreaByNetMm2": "{net:mm2}",+ "revision": "str",+ "unfilled": "[net]",+ "zones": "[...]"+ },+ "statusVerb": "fusion_routing_state",+ "summary": "Refill every copper pour on the live board (Fusion's ratsnest pass, with hidden fills shown again) and read the poured copper back: filled area per layer and per net, pours that poured nothing, and each pour's fill.",+ "timeoutSeconds": 120+ }, { "example": {}, "input": {@@ -2690,7 +3070,7 @@ "dryRun": "optional bool", "expectDocument": "required exact board name", "expectedRevision": "required from routing_state",- "layer": "1 or 16, default 1",+ "layer": "F.Cu or 1, B.Cu or 16; 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}",@@ -2740,18 +3120,71 @@ "longRunning": false, "name": "fusion_routing_state", "output": {+ "arcs": "[{center,radius,startAngle,endAngle,width,layer,net}]", "boardId": "str",- "nets": "list",- "pads": "list",- "remainingNets": "list",+ "components": "[{ref,x,y,rotation,side,pattern,has3d}]",+ "copperLayers": "[F.Cu..B.Cu]",+ "fusionAirwires": "int, Fusion's own ratsnest",+ "model": "'eda-bridge-standard/board 0.1'",+ "nets": "[name]",+ "outline": "[[x,y]] (arcs sampled every 2 degrees)",+ "outlineEdges": "[{kind:line|arc,start,end,center?,radius?,angle1?,angle2?}]",+ "pads": "[{ref,name,key,x,y,sizeX,sizeY,rotation,layer,drill,net,shape,customShape?}]",+ "polygons": "[{net,layer,points}]",+ "remainingNets": "list (AI flow)", "revision": "str",- "segments": "list",- "vias": "list"+ "segments": "list (AI flow)",+ "splitNets": "[{net,groups}]",+ "tracks": "[{start,end,width,layer,net}]",+ "unconnected": "int from the copper",+ "units": "mm",+ "vias": "[{position,size,drill,layers,net}]"+ },+ "statusVerb": "fusion_routing_state",+ "summary": "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).",+ "timeoutSeconds": 60+ },+ {+ "example": {+ "expectDocument": "fusion-routing"+ },+ "input": {+ "boardId": "optional",+ "expectDocument": "required exact active board name"+ },+ "longRunning": false,+ "name": "fusion_rules_by_kind",+ "output": {+ "ruleSet": "str",+ "rulesByKind": "{kind:[rule]}" }, "statusVerb": "fusion_routing_state",- "summary": "Read the live board's identity, geometry revision, pads, copper and per-net airwires from a fresh Fusion export.",+ "summary": "The ENABLED design rules grouped by kind, each group in priority order.", "timeoutSeconds": 60 },+ {+ "example": {+ "expectDocument": "fusion-routing"+ },+ "input": {+ "boardId": "optional",+ "expectDocument": "required exact active board name",+ "limit": "int, default 200 violations"+ },+ "longRunning": false,+ "name": "fusion_run_drc",+ "output": {+ "airwires": "int",+ "errors": "int",+ "summary": "{rule: n}",+ "violationCount": "int",+ "violations": "[{rule,code,severity,layer,at:[x,y],approved}]",+ "warnings": "int"+ },+ "statusVerb": "fusion_routing_state",+ "summary": "Run Fusion's own design rule check on the live board in the background and read every violation back: rule text, error or warning, layer, position in mm, approved or not, plus counts by rule. No revision guard: it changes nothing (fusion_routing_drc is the same check inside the routing guards).",+ "timeoutSeconds": 120+ }, { "example": { "script": "import adsk.fusion\n# ...build geometry..."@@ -3268,6 +3701,25 @@ "summary": "Submit Autodesk's emailed 6-DIGIT verification code to finish a Fusion sign-in, typed IN THE BACKGROUND (UIA SetValue + Invoke - never steals the user's foreground or moves their cursor). Exists so you can finish a login END TO END for the user instead of parking them on a 2FA screen: if `adom-google` is available in your container, read the code out of their Gmail (`adom-google gmail search 'from:autodesk verification code' --limit 1`), pull the 6 digits, and call this. If adom-google is NOT installed, tell the user that installing it would let you automate logins like this, then let them type it. NEVER ask the user for a code you could read yourself. The bridge ALSO toasts the user's desktop whenever it hits a human wall, so you do not need to ask it to.", "timeoutSeconds": 90 },+ {+ "example": {+ "expectDocument": "fusion-routing"+ },+ "input": {+ "boardId": "optional",+ "expectDocument": "required exact active board name"+ },+ "longRunning": false,+ "name": "fusion_silk_state",+ "output": {+ "components": "[{ref,pattern,x,y,rotation,side,body,name:{text,x,y,size,rotation,side,shown},comment:{...},smashed}]",+ "lines": "[{side,x1,y1,x2,y2,width}]",+ "texts": "[{text,x,y,size,rotation,side,layer}]"+ },+ "statusVerb": "fusion_routing_state",+ "summary": "The silkscreen as it stands: every part's body box, its designator and value texts (position, size, rotation, side, shown; a smashed part's own text, else the footprint's placeholder), and the board's free silkscreen lines and texts.",+ "timeoutSeconds": 60+ }, { "example": {}, "input": {@@ -3437,6 +3889,28 @@ "statusVerb": "fusion_get_app_state", "summary": "Sign a pup browser window into an Autodesk WEB surface (library.io, the hub portal at <hub>.autodesk360.com, Autodesk Account) via Google SSO. Fusion's DESKTOP app being signed in does NOT sign these in - they are separate web sessions. NEVER guesses a credential: a missing password or a 2FA challenge comes back as a typed stop (human_credential_required / second_factor_required / google_sso_unavailable) naming the one-time command the USER runs. The password never passes through this bridge or the AI - it is stored once with ab's credential_set and filled by pup in the background.", "timeoutSeconds": 240+ },+ {+ "example": {+ "expectDocument": "fusion-routing"+ },+ "input": {+ "boardId": "optional",+ "expectDocument": "required exact active board name",+ "margin": "mm, default 1",+ "net": "optional net",+ "rect": "optional [x1,y1,x2,y2] mm",+ "ref": "optional designator",+ "refs": "optional [designator]"+ },+ "longRunning": false,+ "name": "fusion_zoom_to",+ "output": {+ "rect": "[x0,y0,x1,y1] mm framed"+ },+ "statusVerb": "fusion_routing_state",+ "summary": "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.",+ "timeoutSeconds": 60 } ] }
contracts/eda-bridge-standard/board.schema.jsonadded+42@@ -0,0 +1,42 @@+{+ "$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.",+ "type": "object",+ "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)"}+ },+ "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"}}}}+ }+}
contracts/eda-bridge-standard/companions.schema.jsonadded+51@@ -0,0 +1,51 @@+{+ "$schema": "https://json-schema.org/draft/2020-12/schema",+ "$id": "https://wiki.adom.inc/adom/eda-bridge-standard/schema/companions.schema.json",+ "title": "Companion models: stack-up, pours, 3D bodies, sheets, netlist",+ "description": "What the checks need beside the board model. Each is one verb's reply (or its data). Millimetres unless a field's name says otherwise. Reference: altium-bridge 0.2.75.",+ "$defs": {+ "stack": {+ "title": "Stack-up (layer_stack)",+ "type": "object",+ "required": ["copperLayers", "layers"],+ "properties": {+ "copperLayers": {"type": "integer"},+ "totalUm": {"type": "number", "description": "board thickness, micrometres"},+ "layers": {"type": "array", "description": "the copper layers top to bottom, each with the dielectric BELOW it", "items": {"type": "object", "required": ["name", "kind", "copperUm"],+ "properties": {"name": {"type": "string"}, "kind": {"enum": ["signal", "plane"]}, "copperUm": {"type": "number"},+ "dielectricUm": {"type": "number", "description": "to the next copper layer; absent on the last"}, "dk1000": {"type": "number", "description": "the dielectric constant x 1000"}, "material": {"type": "string"},+ "mid": {"type": "integer", "description": "a signal layer's inner index (In<mid>.Cu)"}, "plane": {"type": "integer", "description": "an internal plane's index"}, "net": {"type": "string", "description": "a plane's net"}}}}+ }+ },+ "pours": {+ "title": "Pours as poured (pour_state)",+ "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"}}}}}}}}+ },+ "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.",+ "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"}}}+ },+ "sheetParts": {+ "title": "Placed parts of a schematic sheet (read_sheet_file components)",+ "type": "array",+ "items": {"type": "object", "required": ["designator", "pins"],+ "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"]}}}}}}+ },+ "netlist": {+ "title": "The compiled netlist (netlist nets)",+ "type": "object",+ "description": "{net: [\"U1.8\", \"C3.1\", ...]}: every net with its pins as Designator.PinNumber, across every sheet, the hierarchy resolved",+ "additionalProperties": {"type": "array", "items": {"type": "string"}}+ }+ }+}
describe.py+71−4@@ -13,14 +13,14 @@ _STATUS = "fusion_get_app_state" # Compact source table: name -> (summary, input, output, timeout, statusVerb, longRunning, example) _T = [- ("fusion_routing_state", "Read the live board's identity, geometry revision, pads, copper and per-net airwires from a fresh Fusion export.",+ ("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"},- {"boardId":"str","revision":"str","nets":"list","pads":"list","segments":"list","vias":"list","remainingNets":"list"},60,"fusion_routing_state",False,{"expectDocument":"fusion-routing"}),+ {"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", "Calculate a read-only, bounded orthogonal A* path between two pads on a live two-layer board. Uses existing copper and the shared conservative clearance model. Returns explicit route args for route_net; native DRC is still required after mutation.", {"expectDocument":"required str","boardId":"required str","expectedRevision":"required str","net":"required str","fromPad":"required str","toPad":"required str","width":"mm default 0.25","viaDiameter":"mm default 0.6","viaDrill":"mm default 0.3","grid":"mm 0.1..5 default 0.5","allowVias":"bool default true","viaCost":"mm-equivalent cost default 5","maxNodes":"int 1..100000 default 25000","timeoutMs":"int 10..5000 default 3000"}, {"success":"bool","mutated":"false","revision":"str","plan":"{items,route,planner}"},60,"fusion_routing_state",False,{}), ("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":"1 or 16, default 1","viaDiameter":"mm, default .6","viaDrill":"mm, default .3","clearance":"mm lower bound, default .2","dryRun":"optional bool"},+ {"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_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"},@@ -33,7 +33,64 @@ _T = [ {"success":"bool","verified":"bool","added":"[net]","kept":"[net]","pours":"[{net,layer,outline,filledMm2,islands,filled}]","filledAreaByLayerMm2":"{layer:mm2}","revision":"str"},120,"fusion_routing_state",False,{}), ("fusion_pour_state", "Read every pour on the live board with Fusion's own computed fill: filled area per pour, per layer and per net, and which pours were never filled (no RATSNEST since they were drawn). The measure step of the AI flow's Fusion lane.", {"expectDocument":"required exact board name","boardId":"optional"},- {"pours":"list","filledAreaByLayerMm2":"{layer:mm2}","filledAreaByNetMm2":"{net:mm2}","unfilled":"[net]","revision":"str"},60,"fusion_routing_state",False,{}),+ {"pours":"list","zones":"[{net,layer,outline,filledMm2,filled,fills:[{outline,holes}]}] (the standard's pours model)","filledAreaByLayerMm2":"{layer:mm2}","filledAreaByNetMm2":"{net:mm2}","unfilled":"[net]","revision":"str"},60,"fusion_routing_state",False,{}),+ ("fusion_layer_stack", "The board's stack-up in the background, from Fusion's own Layer Stack Manager data in the board: copper layers top to bottom (F.Cu, In1.Cu, ..., B.Cu) with copper thickness in micrometres, the dielectric below each (thickness, Dk x 1000, prepreg or core, material) and the total board thickness. What impedance, current-capacity and copper-balance checks read. A board with no Layer Stack Manager data falls back to its design rules and says the dielectric constant is unknown. No view switching (fusion_board_stackup is the older 2D/3D read).",+ {"expectDocument":"required exact active board name","boardId":"optional"},+ {"copperLayers":"int","totalUm":"num","layers":"[{name,layerId,kind:signal|plane,copperUm,dielectricUm?,dk1000?,dielectric:[{kind,um,dk,material}],mid?}]","source":"str","notes":"[str]","revision":"str"},60,"fusion_routing_state",False,{"expectDocument":"fusion-routing"}),+ ("fusion_netlist", "The compiled netlist: every net with its pads as Designator.Pad (U1.3). From the live board in the background (expectDocument; Fusion keeps board and schematic in sync), or from a schematic FILE without Fusion (schPath: an EAGLE .sch or Fusion .fsch; pins resolved to pads through each device's connects). expect compares it pin by pin with the circuit you meant; net returns one net.",+ {"expectDocument":"the active board's exact name (live read)","schPath":"OR an EAGLE .sch / Fusion .fsch on the Fusion machine (file read, Fusion need not run)","net":"optional: one net (case-insensitive)","expect":"optional {net:[\"U1.3\",...]}: compare pin by pin"},+ {"nets":"{net:[ref.pad]}","netCount":"int","mismatches":"[{net,missing,extra} | {net,unexpected}] (with expect)","ok":"bool (with expect)","pinsWithoutPad":"[part.gate.pin] (schPath: supply symbols and pins with no pad)","revision":"str (live)"},60,"fusion_routing_state",False,{"expectDocument":"fusion-routing"}),+ ('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 rotation in mm, its extent (box, wMm, hMm: every footprint primitive but text), pad count, the nets on its pads, part number and whether it has a 3D model.',+ {"expectDocument":"required exact active board name","boardId":"optional","designator":"optional: one part (U1); omit for all","limit":"int, default 500"},+ {"component":"{designator,pattern,comment,library,side,layer,xMm,yMm,rotation,box,wMm,hMm,pads,nets,has3d,locked,partNumber}","count":"int","components":"[...] (all)"},60,"fusion_routing_state",False,{"expectDocument":"fusion-routing"}),+ ('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.',+ {"expectDocument":"required exact active board name","boardId":"optional","designator":"required (U1)"},+ {"designator":"str","count":"int","pins":"[{ref,name,key,x,y,sizeX,sizeY,rotation,layer,drill,net,shape}]"},60,"fusion_routing_state",False,{"expectDocument":"fusion-routing"}),+ ('fusion_list_nets', 'Every net on the live board with its pad, track, arc, via and pour counts and its net class.',+ {"expectDocument":"required exact active board name","boardId":"optional"},+ {"count":"int","nets":"[{name,pads,tracks,arcs,vias,pours,class}]"},60,"fusion_routing_state",False,{"expectDocument":"fusion-routing"}),+ ('fusion_net_connections', 'Every pad with its net as Designator.Pad and position, and per net its span and airline length (the minimum spanning tree over its pads, the shortest copper that could join them). net filters to one net.',+ {"expectDocument":"required exact active board name","boardId":"optional","net":"optional: one net (case-insensitive)","limit":"int, default 4000 pads"},+ {"count":"int","pads":"[{net,pad,x,y,layer}]","nets":"[{net,pads,span:[x0,y0,x1,y1],airlineMm}]"},60,"fusion_routing_state",False,{"expectDocument":"fusion-routing"}),+ ('fusion_list_designators', 'Every part designator on the live board, in natural order (C2 before C11).',+ {"expectDocument":"required exact active board name","boardId":"optional"},+ {"count":"int","designators":"[str]"},60,"fusion_routing_state",False,{"expectDocument":"fusion-routing"}),+ ('fusion_layers', "The board's layer set: id, name (copper by the standard's names, the rest by Fusion's own: SilkscreenTop, BoardOutline...), kind (copper, silkscreen, mask, paste, outline, drill, keepout, documentation), whether it is displayed, and how many primitives it carries.",+ {"expectDocument":"required exact active board name","boardId":"optional"},+ {"count":"int","layers":"[{id,name,fusionName,kind,displayed,primitives}]"},60,"fusion_routing_state",False,{"expectDocument":"fusion-routing"}),+ ('fusion_copper_layers', "The board's copper layers in stack order (F.Cu, In1.Cu.., B.Cu) with Fusion's layer id, signal or plane, whether displayed, and primitives on each.",+ {"expectDocument":"required exact active board name","boardId":"optional"},+ {"count":"int","layers":"[{name,id,kind,displayed,primitives}]"},60,"fusion_routing_state",False,{"expectDocument":"fusion-routing"}),+ ('fusion_layers_in_use', 'Which layers actually carry something: copper layers with copper (most first) and every layer with any primitive (board, footprints on their real side, signals; a via counts on each layer it passes).',+ {"expectDocument":"required exact active board name","boardId":"optional"},+ {"copperLayersInUse":"[{layerId,name,primitives}]","layersWithPrimitives":"[{layerId,name,primitives}]"},60,"fusion_routing_state",False,{"expectDocument":"fusion-routing"}),+ ('fusion_read_rules', "Every design rule on the live board from Fusion's rule manager, disabled ones included: name, kind (Copper Clearance, Minimum Copper Width, Teardrop...), enabled, priority, both scopes, value and preferred value, whether live and manual DRC check its kind, plus the classic design-rule values (params) and the rule set's name.",+ {"expectDocument":"required exact active board name","boardId":"optional"},+ {"ruleSet":"str","count":"int","rules":"[{name,kind,enabled,priority,scope1,scope2,value,preferredValue,attrs,liveDrc,manualDrc}]","params":"{mdWireWire:...}"},60,"fusion_routing_state",False,{"expectDocument":"fusion-routing"}),+ ('fusion_rules_by_kind', 'The ENABLED design rules grouped by kind, each group in priority order.',+ {"expectDocument":"required exact active board name","boardId":"optional"},+ {"ruleSet":"str","rulesByKind":"{kind:[rule]}"},60,"fusion_routing_state",False,{"expectDocument":"fusion-routing"}),+ ('fusion_silk_state', "The silkscreen as it stands: every part's body box, its designator and value texts (position, size, rotation, side, shown; a smashed part's own text, else the footprint's placeholder), and the board's free silkscreen lines and texts.",+ {"expectDocument":"required exact active board name","boardId":"optional"},+ {"components":"[{ref,pattern,x,y,rotation,side,body,name:{text,x,y,size,rotation,side,shown},comment:{...},smashed}]","lines":"[{side,x1,y1,x2,y2,width}]","texts":"[{text,x,y,size,rotation,side,layer}]"},60,"fusion_routing_state",False,{"expectDocument":"fusion-routing"}),+ ('fusion_net_classes', "The board's net classes with their width, drill, class-to-class clearances and member nets.",+ {"expectDocument":"required exact active board name","boardId":"optional"},+ {"count":"int","classes":"[{name,number,widthMm,drillMm,clearances:[{toClass,mm}],nets}]"},60,"fusion_routing_state",False,{"expectDocument":"fusion-routing"}),+ ('fusion_run_drc', "Run Fusion's own design rule check on the live board in the background and read every violation back: rule text, error or warning, layer, position in mm, approved or not, plus counts by rule. No revision guard: it changes nothing (fusion_routing_drc is the same check inside the routing guards).",+ {"expectDocument":"required exact active board name","boardId":"optional","limit":"int, default 200 violations"},+ {"violationCount":"int","violations":"[{rule,code,severity,layer,at:[x,y],approved}]","errors":"int","warnings":"int","airwires":"int","summary":"{rule: n}"},120,"fusion_routing_state",False,{"expectDocument":"fusion-routing"}),+ ('fusion_repour', "Refill every copper pour on the live board (Fusion's ratsnest pass, with hidden fills shown again) and read the poured copper back: filled area per layer and per net, pours that poured nothing, and each pour's fill.",+ {"expectDocument":"required exact active board name","boardId":"optional"},+ {"filledAreaByLayerMm2":"{layer:mm2}","filledAreaByNetMm2":"{net:mm2}","unfilled":"[net]","zones":"[...]","revision":"str"},120,"fusion_routing_state",False,{"expectDocument":"fusion-routing"}),+ ('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_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"}),+ ("fusion_read_sheet_file", "Read a schematic straight from its file, without Fusion running: every part of an EAGLE .sch (or the .sch inside a Fusion .fsch) with its designator, library reference (deviceset + device), value, description, part number, library, sheet, position and orientation, parameters on request, and its pins: number (the pad), name, gate and electrical type (input, output, io, open_collector, passive, hiz, power, supply, not_connected) for a schematic review. Milliseconds. The saved file, not the live sheet.",+ {"path":"required: EAGLE .sch or Fusion .fsch on the Fusion machine (alias filePath, schPath)","pins":"bool, default true","params":"bool, default false: every attribute with its value"},+ {"components":"[{designator,libReference,deviceset,device,comment,description,uniqueId,partNumber?,sourceLibrary,partCount,sheet,xMil,yMil,xMm,yMm,orientation,params?,pins:[{number,name,gate,electrical,direction}]}]","count":"int","sheets":"int","complete":"bool (false with hierarchical modules or unknown devicesets)","elapsedMs":"int","format":"str"},30,None,False,{"path":"C:/Users/me/Documents/board.sch"}), ("fusion_ripup_nets", "Take whole nets' tracks and vias back up (RIPUP per net, one undo step each) so they can be routed again, wider or another way. Their pours stay. Readback proves no track or via is left on those nets and no pour was lost.", {"expectDocument":"required exact board name","boardId":"required","expectedRevision":"required","nets":"required [net name]"}, {"success":"bool","verified":"bool","ripped":"[net]","revision":"str"},120,"fusion_routing_state",False,{}),@@ -467,6 +524,16 @@ _T = [ 60, None, False, {"fileName": "BQ25792", "projectName": "Main", "folderPath": "Molecules/XRP/Power"}), # โโ Self-describe โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ+ ("fusion_launch", "The EDA Bridge Standard's name for fusion_start: the same verb, same arguments and reply (marked aliasOf). One name across Altium, KiCad and Fusion bridges.",+ {"...":"as fusion_start"}, {"aliasOf":"fusion_start","...":"as fusion_start"}, 120, None, False, {}),+ ("fusion_export_gerber", "The EDA Bridge Standard's name for fusion_export_gerbers: the same verb, same arguments and reply (marked aliasOf). One name across Altium, KiCad and Fusion bridges.",+ {"...":"as fusion_export_gerbers"}, {"aliasOf":"fusion_export_gerbers","...":"as fusion_export_gerbers"}, 120, None, False, {}),+ ("fusion_dismiss_dialogs", "The EDA Bridge Standard's name for fusion_dismiss_blocking_dialogs: the same verb, same arguments and reply (marked aliasOf). One name across Altium, KiCad and Fusion bridges.",+ {"...":"as fusion_dismiss_blocking_dialogs"}, {"aliasOf":"fusion_dismiss_blocking_dialogs","...":"as fusion_dismiss_blocking_dialogs"}, 120, None, False, {}),+ ("fusion_board_summary", "The EDA Bridge Standard's name for fusion_board_info: the same verb, same arguments and reply (marked aliasOf). One name across Altium, KiCad and Fusion bridges.",+ {"...":"as fusion_board_info"}, {"aliasOf":"fusion_board_info","...":"as fusion_board_info"}, 120, None, False, {}),+ ("fusion_install", "The EDA Bridge Standard's name for fusion_install_fusion: the same verb, same arguments and reply (marked aliasOf). One name across Altium, KiCad and Fusion bridges.",+ {"...":"as fusion_install_fusion"}, {"aliasOf":"fusion_install_fusion","...":"as fusion_install_fusion"}, 120, None, False, {}), ("fusion_describe", "Self-describe every verb the bridge exposes (this list) for ab's Verbs tab + runner. The reply carries `contribute` (source, mirror, issue and PR commands, the fusion-contributing skill); every failing reply carries `_contribute` with the same pointer.", {}, {"success": "bool", "verbs": "[{name,summary,input,output,timeoutSeconds,statusVerb,longRunning,example}]"}, 20, None, False, {}), ]
package.json+12−1@@ -42,7 +42,18 @@ "aps not configured", "share aps client id across my org", "set up fusion cloud search for my company",- "org settings autodesk aps"+ "org settings autodesk aps",+ "read my fusion board as data",+ "which nets are not connected in fusion",+ "fusion board stack-up and dielectric",+ "fusion pcb layer stack thickness",+ "netlist of my fusion board",+ "check the fusion netlist against my circuit",+ "read an eagle schematic file",+ "pin electrical types from a schematic",+ "part heights on my fusion board",+ "which fusion parts have no 3d model",+ "will my fusion board fit the enclosure" ], "discovery_pitch": "Use the Adom Bridge Fusion 360 bridge when the user wants to DRIVE Autodesk Fusion 360 from the cloud: open/search their team's Fusion cloud designs, lay out a PCB or do electronics, export STEP/IGES/STL/Gerbers/BOM, or build a component library with real rendering 3D. It runs Fusion on the user's own machine via Adom Bridge. NOT for web/browser/login/form-filling (that's the native-browser extension) and NOT for KiCad (that's the kicad bridge).", "sample_prompts": [
rust/Cargo.lock+6−5@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-fusion-bridge"-version = "1.11.44"+version = "1.12.0" dependencies = [ "base64", "chrono",@@ -27,6 +27,7 @@ dependencies = [ "sha2", "tiny_http", "ureq",+ "zip", ] [[package]]@@ -273,7 +274,7 @@ dependencies = [ [[package]] name = "fusion-core"-version = "1.11.44"+version = "1.12.0" dependencies = [ "serde", "serde_json",@@ -283,7 +284,7 @@ dependencies = [ [[package]] name = "fusion-lbr"-version = "1.11.44"+version = "1.12.0" dependencies = [ "regex", "roxmltree",@@ -295,14 +296,14 @@ dependencies = [ [[package]] name = "fusion-os-mac"-version = "1.11.44"+version = "1.12.0" dependencies = [ "fusion-core", ] [[package]] name = "fusion-os-win"-version = "1.11.44"+version = "1.12.0" 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.11.44"+version = "1.12.0" edition = "2021" license = "MIT"
rust/bridge/Cargo.toml+1@@ -11,6 +11,7 @@ path = "src/main.rs" [dependencies] roxmltree = "0.20"+zip = { version = "2", default-features = false, features = ["deflate"] } regex = "1" sha2 = "0.10" getrandom = "0.2"
rust/bridge/src/board_model.pyadded+1120@@ -0,0 +1,1120 @@+"""The EDA Bridge Standard board model from Fusion's own EAGLE board XML.++Embedded by routing.rs beside routing_script.py and run on Fusion's main thread; stdlib only, so+tests/test_board_model.py runs the same code offline on .brd files. Schema:+wiki.adom.inc/adom/eda-bridge-standard (schema/board.schema.json, schema/companions.schema.json).++Millimetres, Fusion's board frame (y up), absolute. Copper layers carry KiCad's names (F.Cu,+In<n>.Cu by stack order, B.Cu, *.Cu); other layers keep EAGLE's names.+"""+import hashlib+import json+import math+import re++MODEL = 'eda-bridge-standard/board 0.1'++# EAGLE's fixed layer numbers for everything that is not copper (copper is 1..16)+EAGLE_LAYERS = {17: 'Pads', 18: 'Vias', 19: 'Unrouted', 20: 'Dimension', 21: 'tPlace', 22: 'bPlace',+ 23: 'tOrigins', 24: 'bOrigins', 25: 'tNames', 26: 'bNames', 27: 'tValues', 28: 'bValues',+ 29: 'tStop', 30: 'bStop', 31: 'tCream', 32: 'bCream', 33: 'tFinish', 34: 'bFinish',+ 35: 'tGlue', 36: 'bGlue', 37: 'tTest', 38: 'bTest', 39: 'tKeepout', 40: 'bKeepout',+ 41: 'tRestrict', 42: 'bRestrict', 43: 'vRestrict', 44: 'Drills', 45: 'Holes', 46: 'Milling',+ 47: 'Measures', 48: 'Document', 49: 'Reference', 51: 'tDocu', 52: 'bDocu'}+COPPER = set(range(1, 17))+EPS = 0.001 # copper closer than this touches+ARC_STEP = 2.0 # degrees: sample arcs this finely before measuring (the standard's rule)+++def _f(node, key, default=0.0):+ try:+ return float(node.get(key, default))+ except (TypeError, ValueError):+ return default+++def _deg(rot):+ m = re.search(r'R(-?[\d.]+)', rot or 'R0')+ return float(m.group(1)) if m else 0.0+++def _norm(a):+ a = a % 360.0+ return 0.0 if abs(a - 360.0) < 1e-9 else round(a, 6)+++# ---------------------------------------------------------------- layers++def copper_stack(board):+ """Copper layer numbers top to bottom. Fusion's layer stack (layerstackup) when the board has+ one, else designrules layerSetup ("(1*16)", "(1+2*15+16)", "[2:1+2*15+16:15]")."""+ ls = board.find('.//layerstackup')+ if ls is not None:+ order = [int(d.get('layer')) for d in ls.findall('layerdef') if d.get('type') in ('Signal', 'Plane')]+ if order:+ return order+ node = board.find("designrules/param[@name='layerSetup']")+ setup = node.get('value', '(1*16)') if node is not None else '(1*16)'+ setup = re.sub(r'\[\d+:', '[', re.sub(r':\d+\]', ']', setup)) # blind/buried bounds are not layers+ order = []+ for n in re.findall(r'\d+', setup):+ if int(n) not in order:+ order.append(int(n))+ return order or [1, 16]+++class Layers:+ def __init__(self, board):+ self.order = copper_stack(board)+ inner = [n for n in self.order if n not in (1, 16)]+ self.names = {1: 'F.Cu', 16: 'B.Cu'}+ for i, n in enumerate(inner):+ self.names[n] = 'In%d.Cu' % (i + 1)++ def name(self, n):+ n = int(n)+ if n in self.names:+ return self.names[n]+ if n in COPPER: # a copper layer the stack does not list (unused): by its number+ return 'In%d.Cu' % (n - 1)+ return EAGLE_LAYERS.get(n, 'L%d' % n)++ def span(self, extent):+ """The copper layers a via of this extent ("1-16", "1-2") passes through, as numbers."""+ try:+ a, b = (int(x) for x in (extent or '1-16').split('-'))+ except ValueError:+ a, b = 1, 16+ order = self.order if a in self.order and b in self.order else sorted(COPPER)+ i, j = sorted((order.index(a), order.index(b)))+ return order[i:j + 1]+++# ---------------------------------------------------------------- transforms and rules++def placement(el):+ """The element's transform: local (x, y) -> board (x, y), its angle and whether it is mirrored.+ Rotate, then mirror x: the same order the route planner's pad model has always used."""+ rot = el.get('rot', 'R0')+ mirrored = 'M' in rot+ a = math.radians(_deg(rot))+ ex, ey = _f(el, 'x'), _f(el, 'y')+ c, s = math.cos(a), math.sin(a)++ def xf(x, y):+ x, y = x * c - y * s, x * s + y * c+ if mirrored:+ x = -x+ return (x + ex, y + ey)+ return xf, _deg(rot), mirrored+++def abs_rotation(el_angle, mirrored, local):+ return _norm(-(el_angle + local) if mirrored else el_angle + local)+++def _rule_mm(board, name, default):+ node = board.find("designrules/param[@name='%s']" % name)+ m = re.fullmatch(r'([0-9.]+)(mm|mil|in|mic)', (node.get('value', '') if node is not None else '').strip())+ if not m:+ return default+ return float(m[1]) * {'mm': 1, 'mil': 0.0254, 'in': 25.4, 'mic': 0.001}[m[2]]+++def _rule_num(board, name, default):+ node = board.find("designrules/param[@name='%s']" % name)+ try:+ return float(node.get('value')) if node is not None else default+ except (TypeError, ValueError):+ return default+++def pad_diameter(board, drill, lib_diameter):+ """A through pad's copper: its library diameter or the drill plus the board's restring,+ whichever is larger (Fusion's rule; the route planner uses the same)."""+ ring = min(max(drill * _rule_num(board, 'rvPadTop', 0.25), _rule_mm(board, 'rlMinPadTop', 0.254)),+ _rule_mm(board, 'rlMaxPadTop', 0.508))+ return max(lib_diameter, drill + 2 * ring)+++def via_diameter(board, drill, diameter):+ ring = min(max(drill * _rule_num(board, 'rvViaOuter', 0.25), _rule_mm(board, 'rlMinViaOuter', 0.2032)),+ _rule_mm(board, 'rlMaxViaOuter', 0.508))+ return max(diameter, drill + 2 * ring)+++# ---------------------------------------------------------------- geometry++def arc_of(p1, p2, curve):+ """An EAGLE wire p1 -> p2 sweeping `curve` degrees (positive counterclockwise) as center,+ radius and the counterclockwise angle range (angle1 -> angle2, angle2 - angle1 = |curve|)."""+ c = math.radians(curve)+ dx, dy = p2[0] - p1[0], p2[1] - p1[1]+ L = math.hypot(dx, dy)+ r = L / (2 * abs(math.sin(c / 2)))+ d = (L / 2) / math.tan(c / 2)+ cx, cy = (p1[0] + p2[0]) / 2 - dy / L * d, (p1[1] + p2[1]) / 2 + dx / L * d+ a1 = math.degrees(math.atan2(p1[1] - cy, p1[0] - cx))+ start = a1 if curve > 0 else a1 + curve+ start = _norm(start)+ return (cx, cy), r, start, round(start + abs(curve), 6)+++def arc_samples(center, r, a_from, sweep, step=ARC_STEP):+ """Points along an arc from angle a_from sweeping `sweep` degrees (signed), at most `step` apart."""+ n = max(1, int(math.ceil(abs(sweep) / step)))+ return [[center[0] + r * math.cos(math.radians(a_from + sweep * k / n)),+ center[1] + r * math.sin(math.radians(a_from + sweep * k / n))] for k in range(n + 1)]+++def wire_points(p1, p2, curve):+ if not curve:+ return [list(p1), list(p2)]+ center, r, _, _ = arc_of(p1, p2, curve)+ a = math.degrees(math.atan2(p1[1] - center[1], p1[0] - center[0]))+ pts = arc_samples(center, r, a, curve)+ pts[0], pts[-1] = list(p1), list(p2)+ return pts+++def vertex_ring(vertices):+ """A closed EAGLE vertex list (a vertex's curve bends the edge to the next) as points."""+ vs = [((_f(v, 'x'), _f(v, 'y')), _f(v, 'curve')) for v in vertices]+ out = []+ for i, (p, curve) in enumerate(vs):+ q = vs[(i + 1) % len(vs)][0]+ out += wire_points(p, q, curve)[:-1] if curve and p != q else [list(p)]+ return out+++def shape_vertices(node):+ """A polygon's own vertices: <polygon> holds them directly, Fusion's <polygonshape> and+ <polygonpour> inside <polygonoutlineobjects>."""+ inner = node.find('polygonoutlineobjects')+ return (inner if inner is not None else node).findall('vertex')+++def area(pts):+ return abs(sum(a[0] * b[1] - b[0] * a[1] for a, b in zip(pts, pts[1:] + pts[:1]))) / 2+++def _r(v):+ return round(v, 6)+++def pt_seg(p, a, b):+ dx, dy = b[0] - a[0], b[1] - a[1]+ t = max(0, min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / (dx * dx + dy * dy))) if dx or dy else 0+ return math.hypot(p[0] - a[0] - t * dx, p[1] - a[1] - t * dy)+++def seg_seg(a, b, c, d):+ def cross(p, q, r):+ return (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 and cross(c, d, a) * cross(c, d, b) < 0:+ return 0.0+ return min(pt_seg(a, c, d), pt_seg(b, c, d), pt_seg(c, a, b), pt_seg(d, a, b))+++class Region:+ """A polygon with holes, indexed for touch tests: its edges bucketed in a grid (distance) and+ in horizontal bands (point in polygon), so a 3000-vertex pour costs a few edges per query."""++ def __init__(self, outline, holes=()):+ self.outline = [tuple(v) for v in outline]+ self.rings = [self.outline] + [[tuple(v) for v in h] for h in holes]+ self.edges = [(a, b) for ring in self.rings for a, b in zip(ring, ring[1:] + ring[:1])]+ xs = [v[0] for v in self.outline]+ ys = [v[1] for v in self.outline]+ self.bbox = (min(xs), min(ys), max(xs), max(ys))+ span = max(self.bbox[2] - self.bbox[0], self.bbox[3] - self.bbox[1], 1e-6)+ self.cell = max(span / 64.0, 0.25) if len(self.edges) > 32 else span * 2+ self.grid, self.bands = {}, {}+ for k, (a, b) in enumerate(self.edges):+ i0, i1 = sorted((self._c(a[0]), self._c(b[0])))+ j0, j1 = sorted((self._c(a[1]), self._c(b[1])))+ for j in range(j0, j1 + 1):+ self.bands.setdefault(j, []).append(k)+ for i in range(i0, i1 + 1):+ self.grid.setdefault((i, j), []).append(k)++ def _c(self, v):+ return int(math.floor(v / self.cell))++ def contains(self, p):+ """Inside the outline and outside every hole: an odd number of crossings over all rings."""+ if not (self.bbox[0] <= p[0] <= self.bbox[2] and self.bbox[1] <= p[1] <= self.bbox[3]):+ return False+ inside = False+ for k in self.bands.get(self._c(p[1]), ()):+ a, b = self.edges[k]+ if (a[1] > p[1]) != (b[1] > p[1]) and p[0] < a[0] + (p[1] - a[1]) * (b[0] - a[0]) / (b[1] - a[1]):+ inside = not inside+ return inside++ def near(self, box):+ """The edges whose grid cells meet a bounding box."""+ out = set()+ for i in range(self._c(box[0]), self._c(box[2]) + 1):+ for j in range(self._c(box[1]), self._c(box[3]) + 1):+ out.update(self.grid.get((i, j), ()))+ return [self.edges[k] for k in out]++ def vertices_in(self, box):+ return [a for a, _ in self.near(box) if box[0] <= a[0] <= box[2] and box[1] <= a[1] <= box[3]]+++def poly(outline, holes=()):+ return ('poly', Region(outline, holes))+++FAR = 1e9+++def prim_distance(p, q):+ """Edge-to-edge distance of two primitives, a fat segment (seg, a, b, radius) or a polygon+ (poly, Region): 0 or less when they overlap. Exact up to EPS; past that it may answer FAR+ (a region's index only looks at nearby edges), which is all a touch test needs."""+ if p[0] == 'poly' and q[0] == 'seg':+ p, q = q, p+ if p[0] == 'seg' and q[0] == 'seg':+ return seg_seg(p[1], p[2], q[1], q[2]) - p[3] - q[3]+ if p[0] == 'seg':+ _, a, b, r = p+ reg = q[1]+ if reg.contains(a) or reg.contains(b):+ return -r+ box = (min(a[0], b[0]) - r - EPS, min(a[1], b[1]) - r - EPS, max(a[0], b[0]) + r + EPS, max(a[1], b[1]) + r + EPS)+ near = reg.near(box)+ return (min(seg_seg(a, b, c, d) for c, d in near) - r) if near else FAR+ r1, r2 = p[1], q[1]+ box = (max(r1.bbox[0], r2.bbox[0]) - EPS, max(r1.bbox[1], r2.bbox[1]) - EPS,+ min(r1.bbox[2], r2.bbox[2]) + EPS, min(r1.bbox[3], r2.bbox[3]) + EPS)+ if box[0] > box[2] or box[1] > box[3]:+ return FAR+ if any(r2.contains(v) for v in r1.vertices_in(box)) or any(r1.contains(v) for v in r2.vertices_in(box)):+ return 0.0+ e1, e2 = r1.near(box), r2.near(box)+ return min((seg_seg(a, b, c, d) for a, b in e1 for c, d in e2), default=FAR)+++def prim_bbox(p):+ if p[0] == 'seg':+ _, a, b, r = p+ return (min(a[0], b[0]) - r, min(a[1], b[1]) - r, max(a[0], b[0]) + r, max(a[1], b[1]) + r)+ return p[1].bbox+++def rect_corners(cx, cy, sx, sy, rot):+ a = math.radians(rot)+ c, s = math.cos(a), math.sin(a)+ return [[cx + x * c - y * s, cy + x * s + y * c] for x, y in+ ((-sx / 2, -sy / 2), (sx / 2, -sy / 2), (sx / 2, sy / 2), (-sx / 2, sy / 2))]+++def pad_prims(pad):+ """A pad as touch primitives: a circle or stadium is a fat segment, everything else its polygon."""+ x, y, sx, sy, rot = pad['x'], pad['y'], pad['sizeX'], pad['sizeY'], pad['rotation']+ if pad['shape'] == 'round':+ long_ = max(sx, sy) - min(sx, sy)+ a = math.radians(rot + (0 if sx >= sy else 90))+ dx, dy = math.cos(a) * long_ / 2, math.sin(a) * long_ / 2+ return [('seg', (x - dx, y - dy), (x + dx, y + dy), min(sx, sy) / 2)]+ if pad['shape'] == 'octagonal':+ k = min(sx, sy) * (1 - 1 / (1 + math.sqrt(2))) # an octagon's corner cut+ a = math.radians(rot)+ c, s = math.cos(a), math.sin(a)+ pts = [(-sx / 2 + k / 2, -sy / 2), (sx / 2 - k / 2, -sy / 2), (sx / 2, -sy / 2 + k / 2), (sx / 2, sy / 2 - k / 2),+ (sx / 2 - k / 2, sy / 2), (-sx / 2 + k / 2, sy / 2), (-sx / 2, sy / 2 - k / 2), (-sx / 2, -sy / 2 + k / 2)]+ return [poly([[x + px * c - py * s, y + px * s + py * c] for px, py in pts])]+ return [poly(rect_corners(x, y, sx, sy, rot))]+++# ---------------------------------------------------------------- the model++def pads_of(board, layers, contacts):+ """Every pad: the standard row (ref, name, x, y, sizeX, sizeY, rotation, layer, drill, net,+ shape) keyed by 'REF.PAD', plus the footprint copper that is not a pad (custom pad shapes are+ folded into their pad as its bounding rectangle, the rest is net-less copper)."""+ packages = {(l.get('name'), p.get('name')): p for l in board.findall('libraries/library')+ for p in l.findall('packages/package')}+ pads, extra_tracks, extra_polys, holes = {}, [], [], []+ for el in board.findall('elements/element'):+ pkg = packages.get((el.get('library'), el.get('package')))+ if pkg is None:+ continue+ ref = el.get('name')+ xf, angle, mirrored = placement(el)+ own = []+ for pad in pkg.findall('pad'):+ drill = _f(pad, 'drill')+ d = pad_diameter(board, drill, _f(pad, 'diameter'))+ shape = pad.get('shape', 'round')+ prot = _deg(pad.get('rot'))+ lx, ly = _f(pad, 'x'), _f(pad, 'y')+ sx = sy = d+ kind = {'square': 'rect', 'octagon': 'octagonal'}.get(shape, 'round')+ if shape in ('long', 'offset'):+ sx = d * (1 + _f(pad, 'elongation', 100) / 100.0)+ if shape == 'offset': # the drill sits at one end: the copper's center moves+ lx += math.cos(math.radians(prot)) * (sx - d) / 2+ ly += math.sin(math.radians(prot)) * (sx - d) / 2+ cx, cy = xf(lx, ly)+ hx, hy = xf(_f(pad, 'x'), _f(pad, 'y'))+ row = {'ref': ref, 'name': pad.get('name'), 'x': _r(cx), 'y': _r(cy), 'sizeX': _r(sx), 'sizeY': _r(sy),+ 'rotation': abs_rotation(angle, mirrored, prot), 'layer': '*.Cu', 'drill': _r(drill),+ 'net': contacts.get((ref, pad.get('name')), ''), 'shape': kind}+ if (cx, cy) != (hx, hy):+ row['holeAt'] = [_r(hx), _r(hy)]+ pads[ref + '.' + pad.get('name')] = row+ own.append(row)+ for smd in pkg.findall('smd'):+ cx, cy = xf(_f(smd, 'x'), _f(smd, 'y'))+ dx, dy, round_ = _f(smd, 'dx'), _f(smd, 'dy'), _f(smd, 'roundness')+ layer = int(smd.get('layer', '1'))+ layer = 17 - layer if mirrored and layer in (1, 16) else layer+ kind = 'rect' if round_ <= 0 else ('round' if round_ >= 100 else 'roundrect')+ row = {'ref': ref, 'name': smd.get('name'), 'x': _r(cx), 'y': _r(cy), 'sizeX': _r(dx), 'sizeY': _r(dy),+ 'rotation': abs_rotation(angle, mirrored, _deg(smd.get('rot'))), 'layer': layers.name(layer),+ 'layerId': layer, 'drill': 0, 'net': contacts.get((ref, smd.get('name')), ''), 'shape': kind}+ if kind == 'roundrect':+ row['roundness'] = round_+ pads[ref + '.' + smd.get('name')] = row+ own.append(row)+ # footprint copper that is not a pad: a polygon or rectangle on a pad is that pad's custom+ # shape (reported as the bounding rectangle of both); anything else is free copper+ for node in pkg:+ if node.tag not in ('polygon', 'polygonshape', 'rectangle', 'wire', 'circle') or int(node.get('layer', '0')) not in (1, 16):+ continue+ layer = int(node.get('layer'))+ layer = 17 - layer if mirrored else layer+ if node.tag == 'wire':+ pts = wire_points((_f(node, 'x1'), _f(node, 'y1')), (_f(node, 'x2'), _f(node, 'y2')), _f(node, 'curve'))+ pts = [list(xf(*p)) for p in pts]+ net = next((p['net'] for p in own if p['layer'] in (layers.name(layer), '*.Cu') and+ any(prim_distance(('seg', a, b, _f(node, 'width') / 2), q) <= EPS+ for a, b in zip(pts, pts[1:]) for q in pad_prims(p))), '')+ for a, b in zip(pts, pts[1:]):+ extra_tracks.append({'start': {'x': _r(a[0]), 'y': _r(a[1])}, 'end': {'x': _r(b[0]), 'y': _r(b[1])},+ 'width': _f(node, 'width'), 'layer': layers.name(layer), 'layerId': layer,+ 'net': net, 'ref': ref})+ continue+ if node.tag in ('polygon', 'polygonshape'):+ ring = [list(xf(*p)) for p in vertex_ring(shape_vertices(node))]+ elif node.tag == 'rectangle':+ x1, y1, x2, y2 = _f(node, 'x1'), _f(node, 'y1'), _f(node, 'x2'), _f(node, 'y2')+ cx0, cy0 = (x1 + x2) / 2, (y1 + y2) / 2+ ring = [list(xf(*p)) for p in rect_corners(cx0, cy0, abs(x2 - x1), abs(y2 - y1), _deg(node.get('rot')))]+ else:+ ring = [list(xf(*p)) for p in arc_samples((_f(node, 'x'), _f(node, 'y')), _f(node, 'radius'), 0, 360)[:-1]]+ hit = [p for p in own if p['layer'] == layers.name(layer) and+ any(prim_distance(poly(ring), q) <= EPS for q in pad_prims(p))]+ # a shape shared by several pads (a power FET's drain: pads 5..9 joined by one+ # polygon) becomes part of each of them+ for p in hit:+ corners = rect_corners(p['x'], p['y'], p['sizeX'], p['sizeY'], p['rotation'])+ xs = [v[0] for v in ring] + [c[0] for c in corners]+ 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])+ 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'})+ 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')})+ return pads, extra_tracks, extra_polys, holes+++def outline_edges(board):+ """The board outline (layer 20 Dimension: the board's own lines, arcs and circles, and those+ inside footprints) as closed loops of edges, largest first. Returns (loops, open_ends)."""+ raw = []++ def add(p1, p2, curve):+ if curve:+ center, r, a1, a2 = arc_of(p1, p2, curve)+ raw.append({'kind': 'arc', 'start': [_r(p1[0]), _r(p1[1])], 'end': [_r(p2[0]), _r(p2[1])],+ 'center': [_r(center[0]), _r(center[1])], 'radius': _r(r), 'angle1': a1, 'angle2': a2,+ '_ccw': curve > 0})+ else:+ raw.append({'kind': 'line', 'start': [_r(p1[0]), _r(p1[1])], 'end': [_r(p2[0]), _r(p2[1])]})++ def circle(c, r):+ raw.append({'kind': 'arc', 'start': [_r(c[0] + r), _r(c[1])], 'end': [_r(c[0] + r), _r(c[1])],+ 'center': [_r(c[0]), _r(c[1])], 'radius': _r(r), 'angle1': 0, 'angle2': 360, '_ccw': True})++ for w in board.findall("plain/wire[@layer='20']"):+ add((_f(w, 'x1'), _f(w, 'y1')), (_f(w, 'x2'), _f(w, 'y2')), _f(w, 'curve'))+ for c in board.findall("plain/circle[@layer='20']"):+ circle((_f(c, 'x'), _f(c, 'y')), _f(c, 'radius'))+ 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, angle, mirrored = placement(el)+ for w in pkg.findall("wire[@layer='20']"):+ curve = _f(w, 'curve')+ add(xf(_f(w, 'x1'), _f(w, 'y1')), xf(_f(w, 'x2'), _f(w, 'y2')), -curve if mirrored else curve)+ for c in pkg.findall("circle[@layer='20']"):+ circle(xf(_f(c, 'x'), _f(c, 'y')), _f(c, 'radius'))++ near = lambda p, q: abs(p[0] - q[0]) < 1e-3 and abs(p[1] - q[1]) < 1e-3++ def flip(e):+ e = dict(e)+ e['start'], e['end'] = e['end'], e['start']+ if e['kind'] == 'arc':+ e['_ccw'] = not e['_ccw']+ return e++ loops, open_ends = [], []+ while raw:+ loop = [raw.pop(0)]+ while not near(loop[0]['start'], loop[-1]['end']):+ for i, e in enumerate(raw):+ if near(e['start'], loop[-1]['end']):+ loop.append(raw.pop(i))+ break+ if near(e['end'], loop[-1]['end']):+ loop.append(flip(raw.pop(i)))+ break+ else:+ open_ends.append(loop[-1]['end'])+ break+ loops.append(loop)+ loops.sort(key=lambda l: area(loop_points(l)), reverse=True)+ return loops, open_ends+++def loop_points(loop):+ """A loop's vertices in order, arcs sampled every 2 degrees."""+ pts = []+ for e in loop:+ if e['kind'] == 'line':+ pts.append(list(e['start']))+ continue+ sweep = e['angle2'] - e['angle1']+ a_from = math.degrees(math.atan2(e['start'][1] - e['center'][1], e['start'][0] - e['center'][0]))+ pts += [[_r(x), _r(y)] for x, y in arc_samples(e['center'], e['radius'], a_from, sweep if e['_ccw'] else -sweep)[:-1]]+ return pts+++def public_edge(e):+ return {k: v for k, v in e.items() if not k.startswith('_')}+++def board_model(board, meta=None):+ """The board model as the standard's schema/board.schema.json defines it."""+ layers = Layers(board)+ contacts = {(c.get('element'), c.get('pad')): s.get('name')+ for s in board.findall('signals/signal') for c in s.findall('contactref')}+ pads, fp_tracks, fp_polys, holes = pads_of(board, layers, contacts)+ tracks, arcs, vias, polygons = [], [], [], []++ def wire(w, net):+ layer = int(w.get('layer'))+ p1, p2, curve = (_f(w, 'x1'), _f(w, 'y1')), (_f(w, 'x2'), _f(w, 'y2')), _f(w, 'curve')+ if curve and p1 != p2:+ center, r, a1, a2 = arc_of(p1, p2, curve)+ arcs.append({'center': {'x': _r(center[0]), 'y': _r(center[1])}, 'radius': _r(r), 'startAngle': a1,+ 'endAngle': a2, 'width': _f(w, 'width'), 'layer': layers.name(layer), 'layerId': layer, 'net': net})+ else:+ tracks.append({'start': {'x': _r(p1[0]), 'y': _r(p1[1])}, 'end': {'x': _r(p2[0]), 'y': _r(p2[1])},+ 'width': _f(w, 'width'), 'layer': layers.name(layer), 'layerId': layer, 'net': net})++ def pour(p, net):+ 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')),+ 'width': _f(p, 'width')})++ for s in board.findall('signals/signal'):+ net = s.get('name')+ for w in s.findall('wire'):+ if int(w.get('layer')) in COPPER:+ wire(w, net)+ for v in s.findall('via'):+ drill = _f(v, 'drill')+ span = layers.span(v.get('extent'))+ vias.append({'position': {'x': _f(v, 'x'), 'y': _f(v, 'y')}, 'size': _r(via_diameter(board, drill, _f(v, 'diameter'))),+ 'drill': drill, 'layers': [layers.name(span[0]), layers.name(span[-1])], 'net': net})+ for p in list(s.findall('polygonpour')) + list(s.findall('polygon')):+ if int(p.get('layer', '1')) in COPPER:+ pour(p, net)+ # free copper drawn on the board itself has no net: kept, with an empty net+ for w in board.findall('plain/wire'):+ if int(w.get('layer', '0')) in COPPER:+ wire(w, '')+ for p in board.findall('plain/polygon') + board.findall('plain/polygonshape'):+ if int(p.get('layer', '0')) in COPPER:+ pour(p, '')+ polygons[-1]['pour'] = 'drawn'+ 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'})+ 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+ polygons += fp_polys++ components = []+ for el in board.findall('elements/element'):+ rot = el.get('rot', 'R0')+ components.append({'ref': el.get('name'), 'x': _f(el, 'x'), 'y': _f(el, 'y'), 'rotation': _norm(_deg(rot)),+ 'side': 'bottom' if 'M' in rot else 'top', 'pattern': el.get('package'),+ 'has3d': bool(el.get('package3d_urn')), 'value': el.get('value'), 'library': el.get('library'),+ 'locked': el.get('locked') == 'yes'})++ loops, open_ends = outline_edges(board)+ outer = loops[0] if loops else []+ model = {+ 'model': MODEL, 'units': 'mm', 'file': (meta or {}).get('file'), 'boardId': (meta or {}).get('boardId'),+ 'revision': (meta or {}).get('revision') or board_revision(board),+ 'copperLayers': [layers.name(n) for n in layers.order],+ 'tracks': tracks, 'arcs': arcs, 'vias': vias, 'pads': list(pads.values()), 'components': components,+ 'polygons': polygons, 'holes': holes,+ 'outline': loop_points(outer), 'outlineEdges': [public_edge(e) for e in outer],+ 'outlineCutouts': [{'outline': loop_points(l), 'edges': [public_edge(e) for e in l]} for l in loops[1:]],+ 'outlineClosed': not open_ends,+ }+ if open_ends:+ model['outlineProblem'] = 'the layer-20 outline is not closed: an edge ends at %s with nothing joined to it' % open_ends+ model['nets'] = sorted({s.get('name') for s in board.findall('signals/signal') if s.get('name')})+ 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['floatingPourIslandCount'] = len(floating)+ model['floatingPourIslands'] = floating[:50]+ model['counts'] = {k: len(model[k]) for k in ('tracks', 'arcs', 'vias', 'pads', 'components', 'polygons', 'nets')}+ return model, pads+++def pour_islands(board, layers):+ """Fusion's own fill, island by island: (net, layer number, outline, holes)."""+ out = []+ for s in board.findall('signals/signal'):+ for p in list(s.findall('polygonpour')) + list(s.findall('polygon')):+ fd = p.find('polygonfilldetails')+ if fd is None:+ continue+ for sh in fd.findall('polygonshape'):+ seg = sh.find('polygonoutlinesegments')+ if seg is None:+ continue+ holes = [vertex_ring(h.findall('vertex')) for h in sh.iter('polygonholesegments')]+ out.append((s.get('name'), int(sh.get('layer', p.get('layer', '1'))), vertex_ring(seg.findall('vertex')), holes))+ return out+++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."""+ name_to_num = {v: k for k, v in layers.names.items()}+ all_cu = set(layers.order)++ def lset(name):+ if name == '*.Cu':+ return all_cu+ return {name_to_num.get(name, -1)}+ items = {}++ def add(net, prims, ls, kind):+ if net:+ items.setdefault(net, []).append({'prims': prims, 'layers': ls, 'kind': kind,+ 'bbox': tuple(f(prim_bbox(p)[i] for p in prims) for i, f in enumerate((min, min, max, max)))})+ for p in model['pads']:+ add(p['net'], pad_prims(p), lset(p['layer']), 'pad')+ for t in model['tracks']:+ add(t['net'], [('seg', (t['start']['x'], t['start']['y']), (t['end']['x'], t['end']['y']), t['width'] / 2)], {t['layerId']}, 'track')+ for a in model['arcs']:+ pts = arc_samples((a['center']['x'], a['center']['y']), a['radius'], a['startAngle'], a['endAngle'] - a['startAngle'])+ add(a['net'], [('seg', p, q, a['width'] / 2) for p, q in zip(pts, pts[1:])], {a['layerId']}, 'arc')+ for v in model['vias']:+ c = (v['position']['x'], v['position']['y'])+ span = set(layers.order[layers.order.index(name_to_num[v['layers'][0]]):layers.order.index(name_to_num[v['layers'][1]]) + 1]) \+ if all(l in name_to_num for l in v['layers']) else all_cu+ add(v['net'], [('seg', c, c, v['size'] / 2)], span, 'via')+ filled = set()+ for net, layer, outline, holes in pour_islands(board, layers):+ if len(outline) >= 3:+ add(net, [poly(outline, holes)], {layer}, 'fill')+ filled.add((net, layer))+ for g in model['polygons']:+ if g['net'] and (g['net'], g['layerId']) not in filled and len(g['points']) >= 3 and g.get('pour') != 'drawn':+ add(g['net'], [poly(g['points'])], {g['layerId']}, 'pour outline')+ elif g['net'] and g.get('pour') == 'drawn':+ add(g['net'], [poly(g['points'])], {g['layerId']}, 'copper')+ total, split, floating = 0, [], []+ for net, its in sorted(items.items()):+ n = len(its)+ parent = list(range(n))++ def find(x):+ while parent[x] != x:+ parent[x] = parent[parent[x]]+ x = parent[x]+ return x+ for i in range(n):+ a = its[i]+ for j in range(i + 1, n):+ b = its[j]+ if not (a['layers'] & b['layers']):+ continue+ if a['bbox'][0] > b['bbox'][2] + EPS or b['bbox'][0] > a['bbox'][2] + EPS or \+ a['bbox'][1] > b['bbox'][3] + EPS or b['bbox'][1] > a['bbox'][3] + EPS:+ continue+ if find(i) == find(j):+ continue+ if any(prim_distance(p, q) <= EPS for p in a['prims'] for q in b['prims']):+ parent[find(i)] = find(j)+ groups = {}+ for i in range(n):+ groups.setdefault(find(i), []).append(its[i])+ live = []+ for g in groups.values():+ if len(g) == 1 and g[0]['kind'] == 'fill':+ 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)]})+ else:+ live.append(g)+ if len(live) > 1:+ total += len(live) - 1+ split.append({'net': net, 'groups': len(live)})+ return total, split, floating+++def board_revision(board):+ """A hash of the copper, the parts and the outline, and nothing else: the edit verbs' guard.+ Silkscreen text, rules, views and Fusion's own ratsnest (layer 19) leave it alone."""+ def canonical(node):+ if node.tag == 'wire' and node.get('layer') == '19':+ return None+ if node.tag == 'attribute': # a part's name/value text placement is silkscreen+ return None+ return [node.tag, sorted(node.attrib.items()), (node.text or '').strip(),+ [v for ch in node if (v := canonical(ch)) is not None]]+ used = {(e.get('library'), e.get('package')) for e in board.findall('elements/element')}+ parts = [canonical(s) for s in board.findall('signals/signal')]+ parts += [canonical(e) for e in board.findall('elements/element')]+ parts += [canonical(n) for n in board.findall('plain/*')+ if n.tag == 'hole' or (n.get('layer') or '').isdigit() and (int(n.get('layer')) in COPPER or n.get('layer') in ('20', '46'))]+ parts += [canonical(p) for l in board.findall('libraries/library') for p in l.findall('packages/package')+ if (l.get('name'), p.get('name')) in used]+ return hashlib.sha256(json.dumps(parts, separators=(',', ':')).encode()).hexdigest()[:16]+++# ---------------------------------------------------------------- companions++def layer_stack(board):+ """The stack-up as the standard's companions schema (stack) defines it: copper top to bottom,+ each with the dielectric below it. From Fusion's Layer Stack Manager (layerstackup) when the+ board has one, else the design rules' layerSetup, mtCopper and mtIsolate."""+ layers = Layers(board)+ ls = board.find('.//layerstackup')+ rows, notes = [], []++ def um(v):+ return round(_len(v) * 1000, 3)+ if ls is not None:+ cu, dielectric = None, []+ defs = ls.findall('layerdef')+ for d in defs + [None]:+ if d is None or d.get('type') in ('Signal', 'Plane'):+ if cu is not None:+ if dielectric:+ t = sum(x[0] for x in dielectric)+ cu['dielectricUm'] = round(t, 3)+ dks = [(x[0], x[1]) for x in dielectric if x[1]]+ if dks:+ cu['dk1000'] = round(1000 * sum(t_ * k for t_, k in dks) / sum(t_ for t_, _ in dks))+ cu['dielectric'] = [{'kind': x[2], 'um': x[0], 'dk': x[1], 'material': x[3]} for x in dielectric]+ rows.append(cu)+ if d is None:+ break+ m = d.find('material')+ n = int(d.get('layer'))+ cu = {'name': layers.name(n), 'layerId': n, 'kind': 'plane' if d.get('type') == 'Plane' else 'signal',+ 'copperUm': um(m.get('thickness')) if m is not None else None, 'fusionName': d.get('name')}+ if n not in (1, 16):+ cu['mid'] = int(layers.name(n)[2:-3])+ dielectric = []+ else:+ m = d.find('material')+ dk = m.get('dielectric_constant_1g') if m is not None else None+ dielectric.append((um(m.get('thickness')) if m is not None else 0.0, float(dk) if dk else None,+ d.get('type', '').lower(), m.get('name') if m is not None else None))+ source = 'Fusion Layer Stack Manager (layerstackup)'+ else:+ node = lambda k: board.find("designrules/param[@name='%s']" % k)+ cu_t = (node('mtCopper').get('value').split() if node('mtCopper') is not None else [])+ iso = (node('mtIsolate').get('value').split() if node('mtIsolate') is not None else [])+ for i, n in enumerate(layers.order):+ row = {'name': layers.name(n), 'layerId': n, 'kind': 'signal',+ 'copperUm': um(cu_t[n - 1]) if len(cu_t) >= n else 35.0}+ if n not in (1, 16):+ row['mid'] = int(layers.name(n)[2:-3])+ if i < len(layers.order) - 1 and i < len(iso):+ row['dielectricUm'] = um(iso[i])+ rows.append(row)+ notes.append('No Layer Stack Manager data in this board: thicknesses come from the design rules '+ '(mtCopper, mtIsolate), and the dielectric constant is unknown (dk1000 absent). '+ 'Set the stack in Fusion (Layer Stack Manager) for impedance work.')+ source = 'design rules (layerSetup, mtCopper, mtIsolate)'+ total = sum((r.get('copperUm') or 0) + (r.get('dielectricUm') or 0) for r in rows)+ return {'copperLayers': len(rows), 'totalUm': round(total, 3), 'layers': rows, 'source': source, 'notes': notes}+++def _len(v):+ m = re.fullmatch(r'\s*(-?[0-9.]+)\s*(mm|mil|mic|um|in)?\s*', v or '0')+ if not m:+ return 0.0+ return float(m[1]) * {'mm': 1, None: 1, 'mil': 0.0254, 'mic': 0.001, 'um': 0.001, 'in': 25.4}[m[2]]+++def zones(board):+ """The pours model (companions schema `pours`): each pour as drawn and the copper Fusion+ actually poured, island by island with its holes."""+ layers = Layers(board)+ out = []+ for s in board.findall('signals/signal'):+ for p in list(s.findall('polygonpour')) + list(s.findall('polygon')):+ layer = int(p.get('layer', '1'))+ fd = p.find('polygonfilldetails')+ fills = []+ if fd is not None:+ for sh in fd.findall('polygonshape'):+ seg = sh.find('polygonoutlinesegments')+ if seg is None:+ continue+ fills.append({'outline': [[_r(a), _r(b)] for a, b in vertex_ring(seg.findall('vertex'))],+ 'holes': [[[_r(a), _r(b)] for a, b in vertex_ring(h.findall('vertex'))] for h in sh.iter('polygonholesegments')]})+ filled = sum(area(f['outline']) - sum(area(h) for h in f['holes']) for f in fills)+ out.append({'net': s.get('name'), 'layer': layers.name(layer), 'layerId': layer,+ 'outline': [[_r(a), _r(b)] for a, b in vertex_ring(shape_vertices(p))],+ 'filledMm2': round(filled, 3), 'filled': fd is not None, 'fills': fills,+ 'pour': p.get('pour', 'solid'), 'isolate': _f(p, 'isolate'), 'rank': int(p.get('rank', '1'))})+ return out+++def netlist(board):+ """{net: ["U1.3", ...]}: every signal with its pads as Designator.Pad, from the board (Fusion+ keeps board and schematic in sync, so this is the compiled netlist by pad)."""+ nets = {}+ for s in board.findall('signals/signal'):+ refs = sorted('%s.%s' % (c.get('element'), c.get('pad')) for c in s.findall('contactref'))+ if refs:+ nets[s.get('name')] = refs+ return nets+++# ---------------------------------------------------------------- reads served from the same export+# Each takes (root, board, args) and answers the EDA Bridge Standard's verb of the same name, in mm.++def _natural(s):+ return [int(t) if t.isdigit() else t.lower() for t in re.split(r'(\d+)', s or '')]+++def _packages(board):+ return {(l.get('name'), p.get('name')): p for l in board.findall('libraries/library') for p in l.findall('packages/package')}+++def part_box(el, pkg):+ """A part's extent on the board: every footprint primitive but text, transformed."""+ xf, _, _ = placement(el)+ pts = []+ for n in pkg:+ if n.tag in ('smd',):+ pts += [xf(*c) for c in rect_corners(_f(n, 'x'), _f(n, 'y'), _f(n, 'dx'), _f(n, 'dy'), _deg(n.get('rot')))]+ elif n.tag == 'pad':+ d = max(_f(n, 'diameter'), _f(n, 'drill') * 1.5)+ pts += [xf(_f(n, 'x') + dx, _f(n, 'y') + dy) for dx in (-d / 2, d / 2) for dy in (-d / 2, d / 2)]+ elif n.tag == 'wire':+ w = _f(n, 'width') / 2+ pts += [xf(_f(n, 'x1') + s * w, _f(n, 'y1') + t * w) for s in (-1, 1) for t in (-1, 1)]+ pts += [xf(_f(n, 'x2') + s * w, _f(n, 'y2') + t * w) for s in (-1, 1) for t in (-1, 1)]+ elif n.tag == 'rectangle':+ pts += [xf(_f(n, 'x1'), _f(n, 'y1')), xf(_f(n, 'x2'), _f(n, 'y2')), xf(_f(n, 'x1'), _f(n, 'y2')), xf(_f(n, 'x2'), _f(n, 'y1'))]+ elif n.tag == 'circle':+ r = _f(n, 'radius') + _f(n, 'width') / 2+ pts += [xf(_f(n, 'x') + s * r, _f(n, 'y') + t * r) for s in (-1, 1) for t in (-1, 1)]+ elif n.tag in ('polygon', 'polygonshape'):+ pts += [xf(_f(v, 'x'), _f(v, 'y')) for v in shape_vertices(n)]+ if not pts:+ return None+ xs, ys = [p[0] for p in pts], [p[1] for p in pts]+ return [_r(min(xs)), _r(min(ys)), _r(max(xs)), _r(max(ys))]+++def rd_component_data(root, board, args):+ model, _ = board_model(board)+ pkgs = _packages(board)+ els = {e.get('name'): e for e in board.findall('elements/element')}+ want = args.get('designator')+ if want is not None and want not in els:+ fail('designator_not_found', 'No part %s on the board' % want)+ rows = []+ for c in sorted(model['components'], key=lambda c: _natural(c['ref'])):+ if want is not None and c['ref'] != want:+ continue+ el = els[c['ref']]+ mine = [p for p in model['pads'] if p['ref'] == c['ref']]+ box = part_box(el, pkgs.get((el.get('library'), el.get('package')))) if pkgs.get((el.get('library'), el.get('package'))) is not None else None+ attrs = {a.get('name', '').upper(): a.get('value', '') for a in el.findall('attribute')}+ rows.append({'designator': c['ref'], 'pattern': c['pattern'], 'comment': c['value'], 'library': c['library'], 'side': c['side'],+ 'layer': 'B.Cu' if c['side'] == 'bottom' else 'F.Cu', 'xMm': c['x'], 'yMm': c['y'], 'rotation': c['rotation'],+ 'box': box, 'wMm': _r(box[2] - box[0]) if box else None, 'hMm': _r(box[3] - box[1]) if box else None,+ 'pads': len(mine), 'nets': sorted({p['net'] for p in mine if p['net']}), 'has3d': c['has3d'], 'locked': c['locked'],+ 'partNumber': attrs.get('MPN') or attrs.get('MANUFACTURER_PART_NUMBER') or None})+ limit = int(args.get('limit', 500))+ if want is not None:+ return {'component': rows[0]}+ return {'count': len(rows), 'components': rows[:limit], 'truncated': len(rows) > limit}+++def rd_component_pins(root, board, args):+ want = args.get('designator')+ if not want:+ fail('bad_args', 'designator is required (U1)')+ model, _ = board_model(board)+ pins = [p for p in model['pads'] if p['ref'] == want]+ if not pins and want not in {c['ref'] for c in model['components']}:+ fail('designator_not_found', 'No part %s on the board' % want)+ pins.sort(key=lambda p: _natural(p['name']))+ return {'designator': want, 'count': len(pins), 'pins': pins}+++def rd_list_nets(root, board, args):+ model, _ = board_model(board)+ classes = {c.get('number'): c.get('name') for c in board.findall('classes/class')}+ by = {n: {'name': n, 'pads': 0, 'tracks': 0, 'arcs': 0, 'vias': 0, 'pours': 0} for n in model['nets']}+ for kind, key in (('pads', 'pads'), ('tracks', 'tracks'), ('arcs', 'arcs'), ('vias', 'vias'), ('polygons', 'pours')):+ for x in model[kind]:+ if x['net'] in by:+ by[x['net']][key] += 1+ for s in board.findall('signals/signal'):+ if s.get('name') in by:+ by[s.get('name')]['class'] = classes.get(s.get('class', '0'), s.get('class', '0'))+ nets = sorted(by.values(), key=lambda n: _natural(n['name']))+ return {'count': len(nets), 'nets': nets}+++def airline(points):+ """The minimum spanning tree's length over points (Prim): the shortest copper that could join them."""+ if len(points) < 2:+ return 0.0+ inside = [False] * len(points)+ best = [float('inf')] * len(points)+ best[0] = 0.0+ total = 0.0+ for _ in points:+ i = min((k for k in range(len(points)) if not inside[k]), key=lambda k: best[k])+ inside[i] = True+ total += best[i]+ for k in range(len(points)):+ if not inside[k]:+ d = math.hypot(points[i][0] - points[k][0], points[i][1] - points[k][1])+ if d < best[k]:+ best[k] = d+ return total+++def rd_net_connections(root, board, args):+ model, _ = board_model(board)+ one = args.get('net')+ names = {n.lower(): n for n in model['nets']}+ if one is not None and one.lower() not in names:+ fail('no_such_net', 'No net named %s' % one)+ pads = [{'net': p['net'], 'pad': '%s.%s' % (p['ref'], p['name']), 'x': p['x'], 'y': p['y'], 'layer': p['layer']}+ for p in model['pads'] if p['net'] and (one is None or p['net'] == names[one.lower()])]+ nets = {}+ for p in pads:+ nets.setdefault(p['net'], []).append((p['x'], p['y']))+ rows = [{'net': n, 'pads': len(v), 'span': [_r(min(x for x, _ in v)), _r(min(y for _, y in v)), _r(max(x for x, _ in v)), _r(max(y for _, y in v))],+ 'airlineMm': round(airline(v), 3)} for n, v in sorted(nets.items(), key=lambda kv: _natural(kv[0]))]+ limit = int(args.get('limit', 4000))+ return {'count': len(pads), 'pads': pads[:limit], 'nets': rows, 'truncated': len(pads) > limit}+++def rd_list_designators(root, board, args):+ refs = sorted((e.get('name') for e in board.findall('elements/element')), key=_natural)+ return {'count': len(refs), 'designators': refs}+++def _layer_kind(n, name):+ if n in COPPER:+ return 'copper'+ return {20: 'outline', 46: 'outline', 21: 'silkscreen', 22: 'silkscreen', 25: 'silkscreen', 26: 'silkscreen', 27: 'silkscreen', 28: 'silkscreen',+ 29: 'mask', 30: 'mask', 31: 'paste', 32: 'paste', 41: 'keepout', 42: 'keepout', 43: 'keepout', 39: 'keepout', 40: 'keepout',+ 44: 'drill', 45: 'drill', 17: 'copper (pads)', 18: 'copper (vias)', 19: 'ratsnest', 51: 'documentation', 52: 'documentation',+ 47: 'documentation', 48: 'documentation', 49: 'documentation'}.get(n, 'mechanical' if n >= 100 else 'other')+++def _usage(board):+ """Primitives per EAGLE layer: the board's own, every placed footprint's (mirrored parts on the+ other side), and the signals' copper. Vias count on every copper layer they pass through."""+ layers = Layers(board)+ used = {}++ def add(n, k=1):+ used[n] = used.get(n, 0) + k+ flip = {1: 16, 16: 1, 21: 22, 22: 21, 25: 26, 26: 25, 27: 28, 28: 27, 29: 30, 30: 29, 31: 32, 32: 31, 35: 36, 36: 35,+ 39: 40, 40: 39, 41: 42, 42: 41, 51: 52, 52: 51}+ for n in board.findall('plain/*'):+ if (n.get('layer') or '').isdigit():+ add(int(n.get('layer')))+ pkgs = _packages(board)+ for el in board.findall('elements/element'):+ pkg = pkgs.get((el.get('library'), el.get('package')))+ if pkg is None:+ continue+ mirrored = 'M' in el.get('rot', 'R0')+ for n in pkg:+ if n.tag == 'pad':+ for c in layers.order:+ add(c)+ elif (n.get('layer') or '').isdigit():+ k = int(n.get('layer'))+ add(flip.get(k, k) if mirrored else k)+ for s in board.findall('signals/signal'):+ for w in s.findall('wire'):+ add(int(w.get('layer')))+ for v in s.findall('via'):+ for c in layers.span(v.get('extent')):+ add(c)+ for p in list(s.findall('polygonpour')) + list(s.findall('polygon')):+ add(int(p.get('layer', '1')))+ return layers, used+++def rd_layers(root, board, args):+ layers, used = _usage(board)+ out = []+ for l in (root.find('drawing/layers') if root is not None and root.find('drawing/layers') is not None else []):+ n = int(l.get('number'))+ if n in COPPER and n not in layers.order:+ continue # a copper layer this board's stack does not have+ out.append({'id': n, 'name': layers.name(n) if n in COPPER else l.get('name'), 'fusionName': l.get('name'), 'kind': _layer_kind(n, l.get('name')),+ 'displayed': l.get('visible') == 'yes', 'primitives': used.get(n, 0)})+ return {'count': len(out), 'layers': out}+++def rd_copper_layers(root, board, args):+ layers, used = _usage(board)+ shown = {int(l.get('number')): l.get('visible') == 'yes' for l in (root.find('drawing/layers') if root is not None and root.find('drawing/layers') is not None else [])}+ ls = board.find('.//layerstackup')+ kinds = {int(d.get('layer')): ('plane' if d.get('type') == 'Plane' else 'signal') for d in (ls.findall('layerdef') if ls is not None else []) if d.get('type') in ('Signal', 'Plane')}+ rows = [{'name': layers.name(n), 'id': n, 'kind': kinds.get(n, 'signal'), 'displayed': shown.get(n, True), 'primitives': used.get(n, 0)} for n in layers.order]+ return {'count': len(rows), 'layers': rows}+++def rd_layers_in_use(root, board, args):+ layers, used = _usage(board)+ names = {int(l.get('number')): l.get('name') for l in (root.find('drawing/layers') if root is not None and root.find('drawing/layers') is not None else [])}+ rows = [{'layerId': n, 'name': layers.name(n) if n in COPPER else names.get(n, EAGLE_LAYERS.get(n, 'L%d' % n)), 'primitives': k}+ for n, k in sorted(used.items(), key=lambda kv: -kv[1]) if k]+ return {'copperLayersInUse': [r for r in rows if r['layerId'] in COPPER], 'layersWithPrimitives': rows}+++def _rules(board):+ dr = board.find('designrules')+ rows = []+ # <rules> holds one <rulecategory> per kind (whether live and manual DRC check it) and, beside+ # them, the <rule> elements themselves+ cats = {c.get('type'): c for c in (dr.findall('rules/rulecategory') if dr is not None else [])}+ for r in (dr.findall('rules/rule') + dr.findall('rules/rulecategory/rule') if dr is not None else []):+ a = dict(r.attrib)+ cat = cats.get(a.get('type'))+ rows.append({'name': a.pop('name', ''), 'kind': a.pop('type', ''), 'enabled': a.pop('enabled', 'yes') == 'yes',+ 'priority': int(a.pop('priority', '0') or 0), 'scope1': a.pop('onescope', ''), 'scope2': a.pop('twoscope', ''),+ 'value': a.pop('value', None), 'preferredValue': a.pop('preferredvalue', None), 'attrs': a,+ 'liveDrc': cat is not None and cat.get('livedrc') == 'yes', 'manualDrc': cat is not None and cat.get('manualdrc') == 'yes'})+ params = {p.get('name'): p.get('value') for p in (dr.findall('param') if dr is not None else [])}+ return (dr.get('name') if dr is not None else None), rows, params+++def rd_read_rules(root, board, args):+ name, rows, params = _rules(board)+ return {'ruleSet': name, 'count': len(rows), 'rules': rows, 'params': params,+ 'note': 'rules are Fusion\'s rule manager (type, scope, value); params are the classic EAGLE design-rule values the same checks also read'}+++def rd_rules_by_kind(root, board, args):+ name, rows, _ = _rules(board)+ by = {}+ for r in sorted((r for r in rows if r['enabled']), key=lambda r: r['priority']):+ by.setdefault(r['kind'], []).append(r)+ return {'ruleSet': name, 'rulesByKind': by}+++def _text(node, xf=None, mirrored=False, layer_flip=False):+ x, y = (_f(node, 'x'), _f(node, 'y'))+ if xf:+ x, y = xf(x, y)+ layer = int(node.get('layer', '0') or 0)+ return {'x': _r(x), 'y': _r(y), 'size': _f(node, 'size'), 'rotation': _deg(node.get('rot')), 'layer': layer,+ 'side': 'bottom' if layer in (22, 26, 28, 52) or 'M' in (node.get('rot') or '') else 'top'}+++def rd_silk_state(root, board, args):+ """Each part's body box, designator and value text as placed (a smashed part's own text, else+ the footprint's >NAME / >VALUE placeholder, transformed), and the board's free silkscreen."""+ pkgs = _packages(board)+ comps = []+ for el in sorted(board.findall('elements/element'), key=lambda e: _natural(e.get('name'))):+ pkg = pkgs.get((el.get('library'), el.get('package')))+ xf, angle, mirrored = placement(el)+ texts = {}+ for a in el.findall('attribute'):+ if a.get('name') in ('NAME', 'VALUE') and a.get('x') is not None:+ t = _text(a)+ t['shown'] = a.get('display', 'value') != 'off'+ texts[a.get('name')] = t+ if pkg is not None:+ for t in pkg.findall('text'):+ key = {'>NAME': 'NAME', '>VALUE': 'VALUE'}.get((t.text or '').strip().upper())+ if key and key not in texts:+ row = _text(t, xf)+ row['rotation'] = abs_rotation(angle, mirrored, _deg(t.get('rot')))+ if mirrored:+ row['side'] = 'bottom'+ row['shown'] = True+ texts[key] = row+ name = dict(texts.get('NAME', {}), text=el.get('name')) if 'NAME' in texts else {'text': el.get('name'), 'shown': False}+ value = dict(texts.get('VALUE', {}), text=el.get('value')) if 'VALUE' in texts else {'text': el.get('value'), 'shown': False}+ 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')}+ 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')]+ return {'components': comps, 'lines': lines, 'texts': texts}+++def rd_net_classes(root, board, args):+ nets = {}+ for s in board.findall('signals/signal'):+ nets.setdefault(s.get('class', '0'), []).append(s.get('name'))+ rows = []+ for c in board.findall('classes/class'):+ rows.append({'name': c.get('name'), 'number': int(c.get('number')), 'widthMm': _f(c, 'width'), 'drillMm': _f(c, 'drill'),+ 'clearances': [{'toClass': int(k.get('class')), 'mm': _f(k, 'value')} for k in c.findall('clearance')],+ 'nets': sorted(nets.get(c.get('number'), []), key=_natural)})+ return {'count': len(rows), 'classes': rows}+++READS = {'component_data': rd_component_data, 'component_pins': rd_component_pins, 'list_nets': rd_list_nets,+ 'net_connections': rd_net_connections, 'list_designators': rd_list_designators, 'layers': rd_layers,+ 'copper_layers': rd_copper_layers, 'layers_in_use': rd_layers_in_use, 'read_rules': rd_read_rules,+ 'rules_by_kind': rd_rules_by_kind, 'silk_state': rd_silk_state, 'net_classes': rd_net_classes}
rust/bridge/src/bodies.rsadded+144@@ -0,0 +1,144 @@+//! `fusion_bodies`: each part's 3D body as a box (the EDA Bridge Standard's bodies companion), read+//! from the design's 3D PCB: its footprint rectangle in the board frame and its height range above+//! the surface it sits on. Fusion keeps the 3D PCB in its own document, so the read switches the+//! design to it and back to the 2D board (inside Fusion; no window or mouse is taken).+use serde_json::{json, Value};++const SCRIPT: &str = r#"+import adsk.fusion+des = adsk.fusion.Design.cast(app.activeProduct)+if des is None:+ result = {'error': 'the 3D PCB is not in front (active product: %s)' % app.activeProduct.productType}+else:+ rc = des.rootComponent+ def box(b):+ return [round(v * 10, 4) for v in (b.minPoint.x, b.minPoint.y, b.minPoint.z, b.maxPoint.x, b.maxPoint.y, b.maxPoint.z)]+ board = None+ parts = []+ for o in rc.occurrences:+ if o.component.name == 'Board':+ core = [b for b in o.bRepBodies if b.name == 'Board'] or list(o.bRepBodies)+ if core:+ board = box(core[0].boundingBox)+ if o.component.name == 'Packages':+ for c in o.childOccurrences:+ parts.append({'ref': c.name.rsplit(':', 1)[-1], 'package': c.name.rsplit(':', 1)[0], 'box': box(c.boundingBox) if c.boundingBox else None,+ 'visible': c.isLightBulbOn})+ result = {'board': board, 'parts': parts, 'document': app.activeDocument.name}+"#;++/// The 3D read turned into the standard's boxes, with what could not be seen named.+/// `through`: the parts with drilled pads, whose leads pass through the board.+pub fn shape(read: &Value, components: &[Value], through: &std::collections::HashSet<String>) -> Value {+ let Some(board) = read["board"].as_array().filter(|b| b.len() == 6) else {+ return json!({"success": false, "errorCode": "no_3d_board", "error": "the 3D PCB has no Board body: Fusion has not built the 3D board for this design",+ "_hint": "fusion_show_3d_board once (Fusion builds it), then call fusion_bodies again."});+ };+ let f = |v: &Value| v.as_f64().unwrap_or(0.0);+ let (bottom, top) = (f(&board[2]), f(&board[5]));+ let mut bodies = Vec::new();+ let mut no_body = Vec::new();+ let mut suspect = Vec::new();+ let place: std::collections::HashMap<&str, &Value> = components.iter().filter_map(|c| c["ref"].as_str().map(|r| (r, c))).collect();+ for p in read["parts"].as_array().into_iter().flatten() {+ let r = p["ref"].as_str().unwrap_or("");+ let b: Vec<f64> = p["box"].as_array().map(|a| a.iter().map(f).collect()).unwrap_or_default();+ if b.len() != 6 || (b[3] - b[0]).abs() < 1e-6 && (b[4] - b[1]).abs() < 1e-6 && (b[5] - b[2]).abs() < 1e-6 { no_body.push(json!(r)); continue; }+ // which side: the 2D board says; a part with no 2D row goes by where its body is+ let side = place.get(r).and_then(|c| c["side"].as_str()).unwrap_or(if (b[2] + b[5]) / 2.0 < (bottom + top) / 2.0 { "bottom" } else { "top" });+ let (z0, z1) = if side == "bottom" { (bottom - b[5], bottom - b[2]) } else { (b[2] - top, b[5] - top) };+ let r3 = |v: f64| (v * 1000.0).round() / 1000.0;+ let mut row = json!({"ref": r, "side": side, "x0": r3(b[0]), "y0": r3(b[1]), "x1": r3(b[3]), "y1": r3(b[4]), "z0": r3(z0), "z1": r3(z1), "package": p["package"]});+ let tht = through.contains(r);+ if tht {+ // leads through the board: how far they stand out of the far side matters to an enclosure+ let past = if side == "bottom" { b[5] - top } else { bottom - b[2] };+ row["throughHole"] = json!(true);+ row["leadsPastFarSideMm"] = json!(r3(past.max(0.0)));+ }+ bodies.push(row);+ // a body that is not where the board puts its part: the model is off its footprint, floating,+ // or sunk into the board (a library model's origin or orientation is wrong, or the 3D PCB is+ // stale after a 2D move)+ let mut why = Vec::new();+ if let Some(c) = place.get(r) {+ let (x, y) = (f(&c["x"]), f(&c["y"]));+ if x < b[0] - 0.5 || x > b[3] + 0.5 || y < b[1] - 0.5 || y > b[4] + 0.5 { why.push(format!("the part is at ({x:.3}, {y:.3}) on the board but its body spans x {:.3}..{:.3}, y {:.3}..{:.3}", b[0], b[3], b[1], b[4])); }+ }+ if z0 > 0.5 { why.push(format!("the body floats {:.3} mm above the board", z0)); }+ if z0 < -0.2 && !tht { why.push(format!("the body sinks {:.3} mm into the board", -z0)); }+ if !why.is_empty() { suspect.push(json!({"ref": r, "why": why})); }+ }+ let tallest = |s: &str| bodies.iter().filter(|b| b["side"] == s).max_by(|a, b| f(&a["z1"]).partial_cmp(&f(&b["z1"])).unwrap()).map(|b| json!({"ref": b["ref"], "heightMm": b["z1"]}));+ let n = bodies.len();+ json!({"success": true, "mutated": false, "bodies": bodies, "count": n, "noBody": no_body, "suspect": suspect,+ "tallest": {"top": tallest("top"), "bottom": tallest("bottom")},+ "surface": {"topZ": top, "bottomZ": bottom, "note": "z0/z1 are above the board's core surface on the part's side (below it for bottom parts): the copper and paste a part sits on are inside z0"},+ "complete": no_body.is_empty(),+ "_hint": if suspect.is_empty() { "One box per part: footprint rectangle and height range above the surface it sits on. Parts in noBody have no 3D model, so no height." } else { "suspect names parts whose 3D body is not where the board puts the part (off its footprint, floating or sunk): fix the library model's origin or orientation, or let Fusion update the 3D PCB, before trusting a height check on them. Parts in noBody have no 3D model." }})+}++/// `fusion_bodies {expectDocument, boardId?}`+pub fn bodies(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let mut st = crate::routing::call("routing_state", args, pass);+ if st["errorCode"] == "wrong_document" {+ // the 3D PCB (or the schematic) is in front: bring the board back and read again+ let _ = pass("show_2d_board", json!({}));+ st = crate::routing::call("routing_state", args, pass);+ }+ if st["success"].as_bool() != Some(true) { return st; }+ let shown = pass("show_3d_board", json!({}));+ if shown["success"].as_bool() == Some(false) {+ return json!({"success": false, "errorCode": "no_3d_board", "error": format!("could not switch the design to its 3D PCB: {}", shown["error"].as_str().unwrap_or("")), "show3d": shown});+ }+ let r = pass("run_modeling_script", json!({"script": SCRIPT}));+ // back to the board the caller was reading, whatever the read did+ let back = pass("show_2d_board", json!({}));+ let read = r["data"]["result"].clone();+ if !read.is_object() || read["error"].is_string() {+ return json!({"success": false, "errorCode": "no_3d_board", "error": read["error"].as_str().map(str::to_string).unwrap_or_else(|| format!("the 3D read failed: {}", r["error"].as_str().unwrap_or(""))), "returnedTo2d": back["success"]});+ }+ let through: std::collections::HashSet<String> = st["pads"].as_array().into_iter().flatten()+ .filter(|p| p["drill"].as_f64().unwrap_or(0.0) > 0.0).filter_map(|p| p["ref"].as_str().map(str::to_string)).collect();+ let mut out = shape(&read, st["components"].as_array().map(|v| v.as_slice()).unwrap_or(&[]), &through);+ out["boardId"] = st["boardId"].clone();+ out["revision"] = st["revision"].clone();+ out["returnedTo2d"] = json!(back["success"].as_bool() != Some(false));+ out+}++#[cfg(test)]+mod tests {+ use super::*;+ #[test]+ fn boxes_sides_and_suspects() {+ let read = json!({"board": [0, 0, 0, 50.4, 26.4, 1.5], "parts": [+ {"ref": "R14", "package": "RESC1005X40", "box": [42.24, 7.79, 1.543, 43.36, 8.41, 1.963]},+ {"ref": "D3", "package": "LED", "box": [-0.895, 8.202, 3.133, 0.765, 9.052, 3.973]},+ {"ref": "U9", "package": "SOT", "box": [10, 10, -1.2, 12, 12, -0.03]},+ {"ref": "TP1", "package": "PAD", "box": [0, 0, 0, 0, 0, 0]},+ {"ref": "J1", "package": "HDR", "box": [4.17, 24.22, -1.695, 8.23, 26.18, 6.045]}]});+ let comps = vec![json!({"ref": "R14", "x": 42.8, "y": 8.1, "side": "top"}), json!({"ref": "D3", "x": 3.0, "y": 8.6, "side": "top"}),+ json!({"ref": "U9", "x": 11.0, "y": 11.0, "side": "bottom"})];+ let through: std::collections::HashSet<String> = ["J1".to_string()].into_iter().collect();+ let r = shape(&read, &comps, &through);+ assert_eq!(r["success"], true);+ assert_eq!(r["bodies"][0], json!({"ref": "R14", "side": "top", "x0": 42.24, "y0": 7.79, "x1": 43.36, "y1": 8.41, "z0": 0.043, "z1": 0.463, "package": "RESC1005X40"}));+ assert_eq!(r["bodies"][2]["z0"], 0.03);+ assert_eq!(r["bodies"][2]["z1"], 1.2);+ assert_eq!(r["noBody"], json!(["TP1"]));+ let s = &r["suspect"];+ assert_eq!(s.as_array().unwrap().len(), 1);+ assert_eq!(s[0]["ref"], "D3");+ assert_eq!(s[0]["why"].as_array().unwrap().len(), 2, "off its footprint and floating");+ assert_eq!(r["tallest"]["bottom"]["ref"], "U9");+ // a through-hole header's leads are not a sunk body: they stand 1.695 mm out of the bottom+ let j1 = r["bodies"].as_array().unwrap().iter().find(|b| b["ref"] == "J1").unwrap();+ assert_eq!(j1["throughHole"], true);+ assert_eq!(j1["leadsPastFarSideMm"], 1.695);+ }+ #[test]+ fn no_board_body_is_named() {+ assert_eq!(shape(&json!({"parts": []}), &[], &Default::default())["errorCode"], "no_3d_board");+ }+}
rust/bridge/src/main.rs+44−2@@ -40,6 +40,8 @@ mod addin_install; mod janitor; mod electronics; mod routing;+mod sheet;+mod bodies; mod fusionpath; mod tour; mod palette;@@ -53,6 +55,18 @@ use tiny_http::{Header, Method, Response, Server}; const VERSION: &str = env!("CARGO_PKG_VERSION"); +/// EDA Bridge Standard names (adom/eda-bridge-standard verbs/standard.json) for jobs this bridge+/// already does under its own verb: the standard name is answered by that verb, reply marked aliasOf.+const STANDARD_ALIASES: &[(&str, &str)] = &[+ ("launch", "fusion_start"), ("export_gerber", "fusion_export_gerbers"), ("dismiss_dialogs", "fusion_dismiss_blocking_dialogs"),+ ("board_summary", "fusion_board_info"), ("install", "fusion_install_fusion"),+];++fn standard_alias(asked: &str) -> Option<&'static str> {+ let bare = asked.strip_prefix("fusion_").unwrap_or(asked);+ STANDARD_ALIASES.iter().find(|(std_name, _)| *std_name == bare).map(|(_, own)| *own)+}+ #[cfg(windows)] fn host() -> Box<dyn HostBoth> { Box::new(fusion_os_win::WindowsHost) } #[cfg(target_os = "macos")]@@ -376,7 +390,12 @@ fn main() { let mut raw = String::new(); let _ = req.as_reader().read_to_string(&mut raw); let parsed: Value = serde_json::from_str(&raw).unwrap_or(json!({}));- let verb = parsed.get("command").and_then(|c| c.as_str()).unwrap_or("").to_string();+ let asked = parsed.get("command").and_then(|c| c.as_str()).unwrap_or("").to_string();+ // the EDA Bridge Standard's name for a job this bridge does under its own name+ let (verb, raw) = match standard_alias(&asked) {+ Some(own) => { let mut p = parsed.clone(); p["command"] = json!(own); (own.to_string(), p.to_string()) }+ None => (asked.clone(), raw),+ }; let reply = match verb.as_str() { // Phase 3 shadow: the sidecar still answers; the exe computes its own readiness // from real probes and rides beside it with a diff, until parity holds.@@ -384,13 +403,17 @@ fn main() { "fusion_create_library" | "create_library" if chains_owner_is_exe(&**h) => chains::create_library(Arc::new(ExeChainHost { sidecar: sidecar.clone(), caller: caller.clone(), state_dir: h.bridge_state_dir() }), parsed["args"].clone()), "fusion_install_library_bundle" | "install_library_bundle" if chains_owner_is_exe(&**h) => chains::install_library_bundle(Arc::new(ExeChainHost { sidecar: sidecar.clone(), caller: caller.clone(), state_dir: h.bridge_state_dir() }), parsed["args"].clone()), "fusion_prove_result" | "prove_result" if chains_owner_is_exe(&**h) => { let mut r = chains::prove_result(&parsed["args"]); r["shell"] = json!("rust"); r },+ // a schematic file read in the exe: no Fusion needed+ "fusion_read_sheet_file" | "read_sheet_file" => { let mut r = sheet::read_sheet_file(&parsed["args"]); r["bridgeVersion"] = json!(VERSION); r["shell"] = json!("rust"); r },+ "fusion_netlist" | "netlist" if parsed["args"]["schPath"].is_string() => { let mut r = sheet::netlist_from_sch(&parsed["args"]); r["bridgeVersion"] = json!(VERSION); r["shell"] = json!("rust"); r }, v if routing::owns(v.strip_prefix("fusion_").unwrap_or(v)) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command":verb,"args":a}).to_string(), &ca, t); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe:h.fusion_executable(), short_path:|p|host().short_path(p), state_dir:h.bridge_state_dir(), caller:caller.clone(), pids:h.fusion_processes().iter().map(|p|p.pid).collect(), sidecar:&side, dialogs:&dlg }; let cmd=v.strip_prefix("fusion_").unwrap_or(v);- let mut r=routing::call(cmd,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx));+ let mut r=if cmd=="bodies" { bodies::bodies(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)) } else { routing::call(cmd,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)) };+ if cmd=="netlist" { r=sheet::shape_board_netlist(r,&parsed["args"]); } r["bridgeVersion"]=json!(VERSION); r["shell"]=json!("rust"); r }, v @ ("fusion_walk_cloud_tree" | "walk_cloud_tree" | "fusion_search_cloud_files" | "search_cloud_files") => {@@ -587,6 +610,7 @@ fn main() { // every failure points at the source: the bridge is open, the wiki page is the // repo, and a workaround an AI had to invent is a contribution (John, 2026-09-19) let mut reply = reply;+ if verb != asked && reply.is_object() { reply["aliasOf"] = json!(verb); } if reply.is_object() && reply["success"].as_bool() == Some(false) && reply["_contribute"].is_null() { reply["_contribute"] = json!("If this is a bridge defect or a gap, the source is open: adom-wiki repo clone adom/fusion-bridge (the wiki page is the repo; a GitHub mirror is linked from the page). File it with this reply JSON (adom-wiki issue create adom/fusion-bridge) or fix it and send a PR (adom-wiki pr create). The fusion-contributing skill has the steps, including trying your build on your own box behind a dev pin."); }@@ -1464,3 +1488,21 @@ pub(crate) fn long_op_marker() -> Option<(String, f64)> { fn chains_owner_is_exe(h: &dyn HostBoth) -> bool { owner_is_exe(h, "chainsOwner") } fn addin_owner_is_exe(h: &dyn HostBoth) -> bool { owner_is_exe(h, "addinOwner") }++#[cfg(test)]+mod standard_alias_tests {+ #[test]+ fn standard_names_answer_through_the_bridges_own_verbs() {+ assert_eq!(super::standard_alias("fusion_launch"), Some("fusion_start"));+ assert_eq!(super::standard_alias("board_summary"), Some("fusion_board_info"));+ assert_eq!(super::standard_alias("fusion_start"), None);+ assert_eq!(super::standard_alias("fusion_routing_state"), None);+ // every alias names a verb the frozen catalog serves+ let cat: serde_json::Value = serde_json::from_str(include_str!("../../../contracts/describe.json")).unwrap();+ let names: Vec<&str> = cat["verbs"].as_array().unwrap().iter().filter_map(|v| v["name"].as_str()).collect();+ for (std_name, own) in super::STANDARD_ALIASES {+ assert!(names.contains(own), "{own} is not in the catalog");+ assert!(names.contains(&format!("fusion_{std_name}").as_str()), "fusion_{std_name} is not described");+ }+ }+}
rust/bridge/src/routing.rs+2−2@@ -1,9 +1,9 @@ //! Routing operations execute together on Fusion's main thread through the existing //! script transport. Embedding the implementation avoids replacing a loaded add-in. use serde_json::{json, Value};-const SOURCE: &str = concat!(include_str!("routing_script.py"), "\n", include_str!("routing_planner.py"),+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") }+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") } 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+108−29@@ -32,10 +32,24 @@ def number(v, positive=False): return round(v, 6) +def eagle_layer(v):+ """A copper layer argument: Fusion's number (1, 16) or the standard's name (F.Cu, B.Cu), so a+ caller can pass back what fusion_routing_state reported."""+ if isinstance(v, str):+ v = {'F.CU': 1, 'B.CU': 16}.get(v.strip().upper(), v.strip())+ if isinstance(v, str) and v.isdigit():+ v = int(v)+ if isinstance(v, bool) or not isinstance(v, int):+ fail('invalid_layer', 'A layer is 1 or F.Cu (top), 16 or B.Cu (bottom): got %r' % (v,))+ return v++ def identifier(v): # The command language also interprets wildcards. Fail closed on all metacharacters.- if not isinstance(v, str) or not re.fullmatch(r'[A-Za-z0-9_+./:$-]{1,128}', v):- fail('invalid_net', 'Net names must use letters, digits, _ + . / : $ or -')+ # '!' is allowed: EAGLE uses it for an overbar (!CE, !INT are active-low nets), and inside+ # quotes it is plain text.+ if not isinstance(v, str) or not re.fullmatch(r'[A-Za-z0-9_+./:$!-]{1,128}', v):+ fail('invalid_net', 'Net names must use letters, digits, _ + . / : $ ! or -') return v @@ -60,16 +74,9 @@ def snapshot(app, args): board = root.find('drawing/board') if board is None: fail('invalid_export', 'Fusion did not export an EAGLE board')- # Exclude formatting/airwire ordering/view preferences from the revision.- # Include ALL physical geometry, constraints and net membership.- def canonical(node):- if node.tag == 'wire' and node.get('layer') == '19':- return None- return [node.tag, sorted(node.attrib.items()), (node.text or '').strip(),- [v for ch in node if (v := canonical(ch)) is not None]]- relevant = [canonical(n) for n in board if n.tag not in ('errors', 'autorouter')]- revision = hashlib.sha256(json.dumps(relevant, separators=(',', ':')).encode()).hexdigest()- return root, board, {'boardId': identity, 'documentName': doc.name, 'revision': revision}+ # The standard's revision: copper, parts and outline only (board_model.py), so a silkscreen or+ # rule edit does not invalidate a caller's plan.+ return root, board, {'boardId': identity, 'documentName': doc.name, 'revision': board_revision(board)} def _rule_len(board, name, default):@@ -143,7 +150,11 @@ def objects(board): def state(board, meta):+ """The standard board model (board_model.py) with the keys the AI flow has always read+ beside it: segments, elements, airwireCount, remainingNets, and each pad's 'key' (REF.PAD),+ 'at', 'radius' and 'layers', each via's 'at' and 'diameter'.""" nets,pads,wires,vias=objects(board)+ model,std_pads=board_model(board,meta) rows=[] for name,s in nets.items(): airwires=[{'start':[float(w.get('x1')),float(w.get('y1'))], 'end':[float(w.get('x2')),float(w.get('y2'))]}@@ -156,10 +167,18 @@ def state(board, meta): 'has3d':bool(el.get('package3d_urn')),'package3dUrn':el.get('package3d_urn'), 'mpn':next((a.get('value') for a in el.findall('attribute') if (a.get('name') or '').upper()=='MPN'),None)} for el in board.findall('elements/element')]- return {**meta,'units':'mm','nets':rows,'pads':list(pads.values()),'segments':wires,'vias':vias,'elements':elements,- 'segmentCount':len(wires),'viaCount':len(vias),'airwireCount':sum(n['airwireCount'] for n in rows),+ # one row per pad: the standard's fields (name is the pad's own name, ref the part) over the+ # planner's (key 'U1.3', at, radius, layers, poly)+ pad_rows=[]+ for key,p in pads.items():+ row={**p,'key':key}; row.update(std_pads.get(key,{})); pad_rows.append(row)+ # objects() and board_model() walk the signals in the same order, so vias pair by index+ via_rows=[{**v,**w} for v,w in zip(vias,model['vias'])] if len(vias)==len(model['vias']) else model['vias']+ airwires=sum(n['airwireCount'] for n in rows)+ return {**meta,**model,'pads':pad_rows,'vias':via_rows,'netRows':rows,'segments':wires,'elements':elements,+ 'segmentCount':len(wires),'viaCount':len(vias),'airwireCount':airwires,'fusionAirwires':airwires, 'remainingNets':[n['name'] for n in rows if n['airwireCount']],- 'connectivitySource':'Fusion exported airwires; mutation paths recompute RATSNEST',+ 'connectivitySource':'unconnected and splitNets are computed from the copper (pads, tracks, arcs, vias and Fusion\'s poured fill joined where they touch); fusionAirwires is Fusion\'s own ratsnest from the export', 'drc':{'checked':False,'note':'Use fusion_routing_drc for a fresh Autodesk DRC'}} @@ -201,7 +220,7 @@ def pour_state(board): for r in rows: by_layer[str(r['layer'])]=round(by_layer.get(str(r['layer']),0)+r['filledMm2'],3) by_net[r['net']]=round(by_net.get(r['net'],0)+r['filledMm2'],3)- return {'pours':rows,'filledAreaByLayerMm2':by_layer,'filledAreaByNetMm2':by_net,+ return {'pours':rows,'zones':zones(board),'filledAreaByLayerMm2':by_layer,'filledAreaByNetMm2':by_net, 'unfilled':[r['net'] for r in rows if not r['filled']]} @@ -308,8 +327,9 @@ def plan(board,args): net=identifier(args.get('net')) if net not in nets: fail('unknown_net','No exact net named '+net) def layer(v):- if isinstance(v,bool) or v not in (1,16): fail('invalid_layer','Only copper layers 1 and 16 are supported')- return int(v)+ 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')+ 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) if drill>=dia: fail('invalid_geometry','Via drill must be smaller than its diameter')@@ -432,16 +452,22 @@ def native_drc(app): _,code,kind,status,layer,x,y,description=line.split('\t',7) errors.append({'code':int(code),'type':int(kind),'state':int(status),'layer':int(layer), 'x':round(int(x)/U,4),'y':round(int(y)/U,4),'unit':'mm','description':description})+ # UL_ERROR.type is ERROR_TYPE_NONE 0, _WARNING 1, _ERROR 2, _CONSISTENCY 3, and .state is+ # ERROR_STATE_ACTIVE 0, _PROCESSED 1, _APPROVED 2 (read from Fusion's ULP constants, 2026-10-26;+ # before 1.12.0 the bridge read type 2 as a warning and so swapped the two counts)+ for e in errors:+ e['severity']='warning' if e['type']==1 else 'error'+ e['approved']=e['state']==2 summary={} for e in errors:- k=f"{e['description']} (code {e['code']}, {'warning' if e['type']==2 else 'error'})"+ k=f"{e['description']} (code {e['code']}, {e['severity']})" summary[k]=summary.get(k,0)+1 notes=[]- if any(e['code']==19 and e['type']==2 for e in errors):- notes.append('Overlap WARNINGS (code 19, type 2) at trace joints, pad entries and vias are same-signal overlaps: a Copper Clearance rule with samesignal="yes" and an all/all scope (for example a profile rule named "Same-net track spacing") flags every place a trace meets its own pad, via or next segment. Check the loaded rules with fusion_set_design_rules {action:"export"}; a legitimate different-signal overlap is an ERROR (type 0), not a warning.')+ if sum(e['code']==19 for e in errors)>20:+ notes.append('Many Overlap errors (code 19) at trace joints, pad entries and vias usually come from a Copper Clearance rule with samesignal="yes" and an all/all scope (for example a profile rule named "Same-net track spacing"): it flags every place a trace meets its own pad, via or next segment. Check the loaded rules with fusion_read_rules (or fusion_set_design_rules {action:"export"}) before treating them as shorts.') return {'checked':checked,'source':'Autodesk DRC + UL_BOARD.errors', 'errors':errors,'summary':summary,'notes':notes,- 'errorCount':sum(e['code']!=21 and e['type']!=2 for e in errors),- 'warningCount':sum(e['type']==2 for e in errors),+ 'errorCount':sum(e['code']!=21 and e['severity']=='error' for e in errors),+ 'warningCount':sum(e['code']!=21 and e['severity']=='warning' for e in errors), 'airwireCount':sum(e['code']==21 for e in errors), 'clean':checked and not errors} @@ -486,7 +512,7 @@ _PT = r"\(\s*-?[0-9.]+mm\s+-?[0-9.]+mm\s*\)" SCH_FORMS = [ ('ADD', re.compile(r"^ADD '([^'@]+)@([A-Za-z0-9_.-]+)' '([A-Za-z0-9_$+-]+)' (?:M?R(?:0|90|180|270)) " + _PT + r";$")), ('VALUE', re.compile(r"^VALUE '[A-Za-z0-9_$+-]+' '[^';]{1,40}';$")),- ('NET', re.compile(r"^NET '[A-Za-z0-9_+./:$-]+'(?: " + _PT + r"){2,32};$")),+ ('NET', re.compile(r"^NET '[A-Za-z0-9_+./:$!-]+'(?: " + _PT + r"){2,32};$")), ('LABEL', re.compile(r"^LABEL " + _PT + " " + _PT + r";$")), ('WIRE', re.compile(r"^WIRE [0-9.]+mm(?: " + _PT + r"){2,32};$")), ('TEXT', re.compile(r"^TEXT '[^';]{1,80}' (?:R0|R90) " + _PT + r";$")),@@ -781,9 +807,62 @@ def handle(app,args,action): if action=='schematic_plan': return schematic_plan(args) if action=='board_seed': return board_seed(args) root,board,meta=snapshot(app,args)- if action=='routing_state': return {'success':True,**state(board,meta)}+ if action=='routing_state':+ st=state(board,meta); n,fa=st['unconnected'],st['fusionAirwires']+ hint='Edit verbs take boardId and this revision as expectedRevision. unconnected (%d) is computed from the copper itself.'%n+ if fa>n: hint+=" Fusion's own ratsnest shows %d airwire(s) more: it also flags a track end that overlaps a pad without landing on the pad's anchor point (a few micrometres off), which is copper-connected. fusion_routing_drc lists them with positions; reroute that end onto the pad centre to clear them in Fusion."%(fa-n)+ elif fa<n: hint+=" Fusion's own ratsnest shows fewer (%d): it is stale (an import or an edit since Fusion last computed it). fusion_routing_drc recomputes it."%fa+ return {'success':True,**st,'_hint':hint} if action=='pour_state': return {'success':True,'mutated':False,**meta,**pour_state(board)} if action=='board_bom': return {'success':True,'mutated':False,**meta,**board_bom(board,args)}+ if action in READS: return {'success':True,'mutated':False,**meta,**READS[action](root,board,args)}+ if action=='run_drc':+ # the native check on the live board, no revision guard (it changes nothing)+ app.executeTextCommand('Electron.run RATSNEST;')+ report=native_drc(app); _,b2,m2=snapshot(app,args)+ limit=int(args.get('limit',200))+ v=[{'rule':e['description'],'code':e['code'],'severity':e['severity'],'layer':e['layer'],'at':[e['x'],e['y']],'approved':e['approved']}+ for e in report['errors'] if e['code']!=21]+ return {'success':report['checked'],'mutated':False,**m2,'violationCount':len(v),'violations':v[:limit],'truncated':len(v)>limit,+ 'errors':report['errorCount'],'warnings':report['warningCount'],'airwires':report['airwireCount'],'summary':report['summary'],'notes':report['notes'],+ 'windowEtiquette':'background','source':report['source'],+ '_hint':'Each violation carries its rule text, severity, layer and position in mm. fusion_zoom_to {rect} frames one; fusion_routing_drc is the same check inside the routing guards.'}+ if action=='repour':+ app.executeTextCommand('Commands.Start Electron::PolyShowFillAll')+ app.executeTextCommand('Electron.run RATSNEST;')+ app.executeTextCommand('Electron.ImmediateRepaint /full')+ _,b2,m2=snapshot(app,args); ps=pour_state(b2)+ return {'success':True,'mutated':True,**m2,'filledAreaByLayerMm2':ps['filledAreaByLayerMm2'],'filledAreaByNetMm2':ps['filledAreaByNetMm2'],+ 'unfilled':ps['unfilled'],'zones':ps['zones'],'_hint':'Every pour was refilled by Fusion (RATSNEST). The revision moved if the poured copper changed.'}+ if action=='zoom_to':+ model,_=board_model(board); margin=number(args.get('margin',1.0))+ pts=[]+ if args.get('rect'):+ x1,y1,x2,y2=[number(v) for v in args['rect']]; pts=[(x1,y1),(x2,y2)]+ refs=([args['ref']] if args.get('ref') else [])+list(args.get('refs') or [])+ for r_ in refs:+ mine=[(p['x'],p['y']) for p in model['pads'] if p['ref']==r_]+[(c['x'],c['y']) for c in model['components'] if c['ref']==r_]+ if not mine: fail('designator_not_found','No part %s on the board'%r_)+ pts+=mine+ if args.get('net'):+ n=args['net']; hit=[(p['x'],p['y']) for p in model['pads'] if p['net'].lower()==n.lower()]+ hit+=[(t[k]['x'],t[k]['y']) for t in model['tracks'] if t['net'].lower()==n.lower() for k in ('start','end')]+ hit+=[(v['position']['x'],v['position']['y']) for v in model['vias'] if v['net'].lower()==n.lower()]+ if not hit: fail('no_such_net','No copper on a net named %s'%n)+ pts+=hit+ if not pts: fail('bad_args','Give rect [x1,y1,x2,y2] (mm), ref, refs or net')+ box=[min(p[0] for p in pts)-margin,min(p[1] for p in pts)-margin,max(p[0] for p in pts)+margin,max(p[1] for p in pts)+margin]+ app.executeTextCommand('Electron.run WINDOW (%.4fmm %.4fmm) (%.4fmm %.4fmm);'%tuple(box))+ app.executeTextCommand('Electron.ImmediateRepaint /full')+ return {'success':True,'mutated':False,**meta,'rect':[round(v,4) for v in box],'windowEtiquette':'background'}+ if action=='layer_stack':+ st=layer_stack(board)+ return {'success':True,'mutated':False,**meta,**st,+ '_hint':'Copper top to bottom, each with the dielectric below it (dielectricUm, dk1000 = Dk x 1000). This is what impedance and current-capacity checks read. '+(' '.join(st['notes']))}+ if action=='netlist':+ nl=netlist(board)+ return {'success':True,'mutated':False,**meta,'nets':nl,'netCount':len(nl),'source':'the board (Fusion keeps board and schematic in sync): Designator.Pad',+ '_hint':'Every net with its pads as Designator.Pad. Compare with the schematic intent, or feed it to a schematic review.'} # The live board as EAGLE XML (Fusion's own fills included), for analysis offline. if action=='board_source': return {'success':True,'mutated':False,**meta,'source':ET.tostring(root,encoding='unicode')} if args.get('boardId')!=meta['boardId']: fail('board_mismatch','Mutations/checks require boardId from fusion_routing_state')@@ -825,7 +904,7 @@ def handle(app,args,action): targets=[] for q in want: pts=[[float(a),float(b)] for a,b in q.get('points',[])]- hit=next((e for e in have if e['net']==q.get('net') and e['layer']==int(q.get('layer',1)) and same_outline(e['outline'],pts)),None)+ hit=next((e for e in have if e['net']==q.get('net') and e['layer']==eagle_layer(q.get('layer',1)) and same_outline(e['outline'],pts)),None) if not hit: fail('unknown_pour','No %s pour on layer %s with that outline'%(q.get('net'),q.get('layer'))) ol=hit['outline']; edges=list(zip(ol,ol[1:]+ol[:1])) a,b=max(edges,key=lambda e:math.hypot(e[1][0]-e[0][0],e[1][1]-e[0][1]))@@ -856,7 +935,7 @@ def handle(app,args,action): want=args.get('pours') if want in (None,'all'): targets=have else:- targets=[e for e in have if any(e['net']==q.get('net') and e['layer']==int(q.get('layer',1)) and same_outline(e['outline'],[[float(a),float(b)] for a,b in q.get('points',[])]) for q in want)]+ targets=[e for e in have if any(e['net']==q.get('net') and e['layer']==eagle_layer(q.get('layer',1)) and same_outline(e['outline'],[[float(a),float(b)] for a,b in q.get('points',[])]) for q in want)] if not targets: return {'success':True,'mutated':False,'removed':0,**meta,'note':'no pour to remove'} elements_before=len(board.findall('elements/element')); cu_before=copper_signature(ws_+vs_) pad_pts=[p_['at'] for p_ in pads_.values()]@@ -926,7 +1005,7 @@ def handle(app,args,action): if not isinstance(want,list) or not want: fail('invalid_args','pours must be a non-empty list of {net,layer,points,isolate,rank}') existing=pours(board); added=[]; kept=[] for p in want:- net=identifier(p.get('net')); layer=int(p.get('layer',1))+ net=identifier(p.get('net')); layer=eagle_layer(p.get('layer',1)) if layer not in (1,16): fail('invalid_geometry','pour layer must be 1 (top) or 16 (bottom)') pts=[[number(a),number(b)] for a,b in p.get('points') or []] if len(pts)<3: fail('invalid_geometry','a pour needs at least three points')@@ -940,7 +1019,7 @@ def handle(app,args,action): app.executeTextCommand('Electron.run RATSNEST;') app.executeTextCommand('Electron.ImmediateRepaint /full') _,after,after_meta=snapshot(app,args); ps=pour_state(after)- missing=[p['net'] for p in want if not any(e['net']==p['net'] and e['layer']==int(p.get('layer',1)) and same_outline(e['outline'],[[float(a),float(b)] for a,b in p['points']]) for e in ps['pours'])]+ missing=[p['net'] for p in want if not any(e['net']==p['net'] and e['layer']==eagle_layer(p.get('layer',1)) and same_outline(e['outline'],[[float(a),float(b)] for a,b in p['points']]) for e in ps['pours'])] return {'success':not missing,'mutated':bool(added),'verified':not missing,'errorCode':None if not missing else 'readback_mismatch', 'added':added,'kept':kept,'missing':missing,'commands':commands,**ps,**state(after,after_meta)} if action!='route_net': fail('unknown_action','Unknown routing action')
rust/bridge/src/sheet.rsadded+302@@ -0,0 +1,302 @@+//! A schematic read straight from its file, without Fusion: `fusion_read_sheet_file` (the parts of+//! an EAGLE .sch with their pins and each pin's electrical type, the EDA Bridge Standard's sheet+//! parts) and `fusion_netlist {schPath}` (the compiled netlist by pad: Designator.Pad, the same+//! form the board netlist uses). A Fusion .fsch is a zip with the .sch inside; both are read.+use serde_json::{json, Map, Value};+use std::collections::{BTreeMap, BTreeSet, HashMap};+use std::io::Read;+use std::time::Instant;++/// The .sch XML from a path: the file itself, or the .sch inside a Fusion .fsch container.+fn sch_xml(path: &str) -> Result<String, Value> {+ let bytes = std::fs::read(path).map_err(|e| json!({"success": false, "errorCode": "file_not_found",+ "error": format!("cannot read {path}: {e}"), "_hint": "Pass path: an EAGLE .sch or a Fusion .fsch on this machine (fusion_export_eagle_source writes one from the open design)."}))?;+ if bytes.starts_with(b"PK") {+ let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| json!({"success": false, "errorCode": "unsupported_document", "error": format!("{path} is a zip but not a readable Fusion container: {e}")}))?;+ let names: Vec<String> = (0..z.len()).filter_map(|i| z.by_index(i).ok().map(|f| f.name().to_string())).filter(|n| n.ends_with(".sch")).collect();+ let name = match names.as_slice() { [one] => one.clone(), _ => return Err(json!({"success": false, "errorCode": "unsupported_document", "error": format!("{path} holds {} schematic files, expected one", names.len())})) };+ let mut s = String::new();+ z.by_name(&name).map_err(|e| json!({"success": false, "errorCode": "unsupported_document", "error": e.to_string()}))?.read_to_string(&mut s).map_err(|e| json!({"success": false, "errorCode": "unsupported_document", "error": e.to_string()}))?;+ return Ok(s);+ }+ String::from_utf8(bytes).map_err(|_| json!({"success": false, "errorCode": "unsupported_document", "error": format!("{path} is not an EAGLE XML schematic")}))+}++/// EAGLE pin direction as the standard's electrical type (Altium's names; supply and nc have no+/// Altium code and keep EAGLE's meaning).+fn electrical(direction: &str) -> &'static str {+ match direction { "in" => "input", "out" => "output", "io" => "io", "oc" => "open_collector", "pwr" => "power",+ "pas" => "passive", "hiz" => "hiz", "sup" => "supply", "nc" => "not_connected", _ => "io" }+}++struct Lib<'a> {+ symbols: HashMap<(String, String), roxmltree::Node<'a, 'a>>,+ devicesets: HashMap<(String, String), roxmltree::Node<'a, 'a>>,+}++fn child<'a>(n: roxmltree::Node<'a, 'a>, tag: &str) -> Option<roxmltree::Node<'a, 'a>> { n.children().find(|c| c.has_tag_name(tag)) }+fn kids<'a>(n: roxmltree::Node<'a, 'a>, tag: &'a str) -> impl Iterator<Item = roxmltree::Node<'a, 'a>> + 'a { n.children().filter(move |c| c.has_tag_name(tag)) }+fn attr(n: roxmltree::Node, k: &str) -> String { n.attribute(k).unwrap_or("").to_string() }++fn libraries<'a>(sch: roxmltree::Node<'a, 'a>) -> Lib<'a> {+ let mut lib = Lib { symbols: HashMap::new(), devicesets: HashMap::new() };+ for l in sch.descendants().filter(|n| n.has_tag_name("library")) {+ let ln = attr(l, "name");+ for s in l.descendants().filter(|n| n.has_tag_name("symbol")) { lib.symbols.insert((ln.clone(), attr(s, "name")), s); }+ for d in l.descendants().filter(|n| n.has_tag_name("deviceset")) { lib.devicesets.insert((ln.clone(), attr(d, "name")), d); }+ }+ lib+}++fn device<'a>(ds: roxmltree::Node<'a, 'a>, name: &str) -> Option<roxmltree::Node<'a, 'a>> {+ ds.descendants().find(|d| d.has_tag_name("device") && d.attribute("name").unwrap_or("") == name)+}++/// (gate, pin) -> pads, from a device's connects ("1 2 3" is a pin on three pads).+fn pin_pads(dev: Option<roxmltree::Node>) -> HashMap<(String, String), Vec<String>> {+ let mut m: HashMap<(String, String), Vec<String>> = HashMap::new();+ if let Some(d) = dev {+ for c in d.descendants().filter(|n| n.has_tag_name("connect")) {+ m.entry((attr(c, "gate"), attr(c, "pin"))).or_default().extend(attr(c, "pad").split_whitespace().map(str::to_string));+ }+ }+ m+}++const MIL: f64 = 1.0 / 0.0254;++/// Every EAGLE file opens with <!DOCTYPE eagle SYSTEM "eagle.dtd">, which roxmltree refuses unless asked.+fn parse(xml: &str) -> Result<roxmltree::Document<'_>, roxmltree::Error> {+ roxmltree::Document::parse_with_options(xml, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() })+}++/// `fusion_read_sheet_file {path, pins?, params?}`+pub fn read_sheet_file(args: &Value) -> Value {+ let t0 = Instant::now();+ let path = match args["path"].as_str().or(args["filePath"].as_str()).or(args["schPath"].as_str()) {+ Some(p) => p.to_string(),+ None => return json!({"success": false, "errorCode": "bad_args", "error": "path (an EAGLE .sch or Fusion .fsch) is required",+ "_hint": "fusion_export_eagle_source {outputPath:'C:/.../design.sch'} with the schematic in front writes one from the open design."}),+ };+ let xml = match sch_xml(&path) { Ok(x) => x, Err(e) => return e };+ let doc = match parse(&xml) { Ok(d) => d, Err(e) => return json!({"success": false, "errorCode": "unsupported_document", "error": format!("{path}: not well-formed XML ({e})")}) };+ let Some(sch) = doc.descendants().find(|n| n.has_tag_name("schematic")) else {+ return json!({"success": false, "errorCode": "not_a_schematic", "error": format!("{path} has no <schematic>: is it a board (.brd) or a library (.lbr)?")});+ };+ let lib = libraries(sch);+ let want_pins = args["pins"].as_bool().unwrap_or(true);+ let want_params = args["params"].as_bool().unwrap_or(false);+ // where each part's gates sit: (sheet number, x, y, rot) of its first instance+ let mut placed: HashMap<String, (usize, f64, f64, String, usize)> = HashMap::new();+ let sheets: Vec<roxmltree::Node> = child(sch, "sheets").map(|s| kids(s, "sheet").collect()).unwrap_or_default();+ for (i, s) in sheets.iter().enumerate() {+ for inst in s.descendants().filter(|n| n.has_tag_name("instance")) {+ let e = placed.entry(attr(inst, "part")).or_insert((i + 1, inst.attribute("x").and_then(|v| v.parse().ok()).unwrap_or(0.0),+ inst.attribute("y").and_then(|v| v.parse().ok()).unwrap_or(0.0), inst.attribute("rot").unwrap_or("R0").to_string(), 0));+ e.4 += 1;+ }+ }+ let mut parts = Vec::new();+ let mut unknown = Vec::new();+ for p in child(sch, "parts").map(|ps| kids(ps, "part").collect::<Vec<_>>()).unwrap_or_default() {+ let (name, ln, dsn, devn) = (attr(p, "name"), attr(p, "library"), attr(p, "deviceset"), attr(p, "device"));+ let ds = lib.devicesets.get(&(ln.clone(), dsn.clone())).copied();+ let mut row = Map::new();+ row.insert("designator".into(), json!(name));+ row.insert("libReference".into(), json!(format!("{dsn}{devn}")));+ row.insert("deviceset".into(), json!(dsn));+ row.insert("device".into(), json!(devn));+ row.insert("comment".into(), json!(attr(p, "value")));+ row.insert("description".into(), json!(ds.and_then(|d| child(d, "description")).and_then(|d| d.text()).unwrap_or("").trim().chars().take(240).collect::<String>()));+ row.insert("uniqueId".into(), json!(name));+ row.insert("sourceLibrary".into(), json!(ln));+ let gates: Vec<roxmltree::Node> = ds.and_then(|d| child(d, "gates")).map(|g| kids(g, "gate").collect()).unwrap_or_default();+ row.insert("partCount".into(), json!(gates.len()));+ if let Some((sheet, x, y, rot, n)) = placed.get(&name) {+ row.insert("sheet".into(), json!(sheet));+ row.insert("xMil".into(), json!((x * MIL).round()));+ row.insert("yMil".into(), json!((y * MIL).round()));+ row.insert("xMm".into(), json!(x));+ row.insert("yMm".into(), json!(y));+ row.insert("orientation".into(), json!(rot));+ row.insert("gatesPlaced".into(), json!(n));+ }+ let mpn = kids(p, "attribute").find(|a| a.attribute("name").map(|n| n.eq_ignore_ascii_case("MPN")).unwrap_or(false)).map(|a| attr(a, "value"));+ if let Some(m) = mpn { row.insert("partNumber".into(), json!(m)); }+ if want_params {+ let mut params = Map::new();+ for a in kids(p, "attribute") { params.insert(attr(a, "name"), json!(attr(a, "value"))); }+ row.insert("params".into(), Value::Object(params));+ }+ if ds.is_none() { unknown.push(name.clone()); }+ if want_pins {+ let pads = pin_pads(ds.and_then(|d| device(d, &devn)));+ let mut pins = Vec::new();+ for g in &gates {+ let Some(sym) = lib.symbols.get(&(ln.clone(), attr(*g, "symbol"))) else { continue };+ for pin in sym.descendants().filter(|n| n.has_tag_name("pin")) {+ let dir = pin.attribute("direction").unwrap_or("io");+ let pn = attr(pin, "name");+ let numbers = pads.get(&(attr(*g, "name"), pn.clone())).cloned().unwrap_or_default();+ // a pin on several pads is listed once per pad; a pin on none (a symbol-only pin) has number ""+ for num in if numbers.is_empty() { vec![String::new()] } else { numbers } {+ pins.push(json!({"number": num, "name": pn, "gate": attr(*g, "name"), "electrical": electrical(dir), "direction": dir}));+ }+ }+ }+ row.insert("pins".into(), Value::Array(pins));+ }+ parts.push(Value::Object(row));+ }+ let modules = sch.descendants().filter(|n| n.has_tag_name("module")).count();+ let mut out = json!({"success": true, "path": path, "components": parts, "count": parts.len(), "sheets": sheets.len(),+ "elapsedMs": t0.elapsed().as_millis() as u64, "format": doc.root_element().attribute("version").map(|v| format!("EAGLE {v} XML")).unwrap_or_else(|| "EAGLE XML".into()),+ "complete": modules == 0 && unknown.is_empty(),+ "_hint": "The saved file, not the live sheet: unsaved edits in an open Fusion are not in it. Pins carry electrical (input, output, io, open_collector, passive, hiz, power, supply, not_connected) for a schematic review; nets come from fusion_netlist {schPath}."});+ if modules > 0 { out["notes"] = json!([format!("{modules} hierarchical module(s): their inner parts are not listed yet (module instances read as their top-level parts only)")]); }+ if !unknown.is_empty() { out["unknownDevicesets"] = json!(unknown); }+ out+}++/// `fusion_netlist {schPath, net?, expect?}`: the compiled netlist from a schematic file, by pad.+pub fn netlist_from_sch(args: &Value) -> Value {+ let t0 = Instant::now();+ let path = args["schPath"].as_str().unwrap_or("").to_string();+ let xml = match sch_xml(&path) { Ok(x) => x, Err(e) => return e };+ let doc = match parse(&xml) { Ok(d) => d, Err(e) => return json!({"success": false, "errorCode": "unsupported_document", "error": format!("{path}: {e}")}) };+ let Some(sch) = doc.descendants().find(|n| n.has_tag_name("schematic")) else {+ return json!({"success": false, "errorCode": "not_a_schematic", "error": format!("{path} has no <schematic>")});+ };+ let lib = libraries(sch);+ let mut part_pads: HashMap<String, HashMap<(String, String), Vec<String>>> = HashMap::new();+ for p in child(sch, "parts").map(|ps| kids(ps, "part").collect::<Vec<_>>()).unwrap_or_default() {+ let ds = lib.devicesets.get(&(attr(p, "library"), attr(p, "deviceset"))).copied();+ part_pads.insert(attr(p, "name"), pin_pads(ds.and_then(|d| device(d, &attr(p, "device")))));+ }+ let mut nets: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();+ let mut padless = BTreeSet::new();+ for n in sch.descendants().filter(|n| n.has_tag_name("net")) {+ let e = nets.entry(attr(n, "name")).or_default();+ for r in n.descendants().filter(|x| x.has_tag_name("pinref")) {+ let (part, gate, pin) = (attr(r, "part"), attr(r, "gate"), attr(r, "pin"));+ match part_pads.get(&part).and_then(|m| m.get(&(gate.clone(), pin.clone()))) {+ Some(pads) if !pads.is_empty() => { for pad in pads { e.insert(format!("{part}.{pad}")); } }+ _ => { padless.insert(format!("{part}.{gate}.{pin}")); }+ }+ }+ }+ nets.retain(|_, v| !v.is_empty());+ finish(nets, args, json!({"source": format!("{path} (the schematic file, by pad)"), "pinsWithoutPad": padless, "elapsedMs": t0.elapsed().as_millis() as u64}))+}++/// The netlist reply with `net` (one net) and `expect` (compare pin by pin) applied: shared by+/// the schematic-file read and the live board read.+pub fn finish(nets: BTreeMap<String, BTreeSet<String>>, args: &Value, extra: Value) -> Value {+ let mut mismatches = Vec::new();+ if let Some(exp) = args["expect"].as_object() {+ let key = |s: &str| s.to_ascii_lowercase();+ let have: HashMap<String, (&String, &BTreeSet<String>)> = nets.iter().map(|(k, v)| (key(k), (k, v))).collect();+ for (n, pins) in exp {+ let want: BTreeSet<String> = pins.as_array().into_iter().flatten().filter_map(|p| p.as_str().map(str::to_string)).collect();+ match have.get(&key(n)) {+ None => mismatches.push(json!({"net": n, "missing": want, "extra": []})),+ Some((_, got)) => {+ let missing: Vec<&String> = want.difference(got).collect();+ let extra: Vec<&String> = got.difference(&want).collect();+ if !missing.is_empty() || !extra.is_empty() { mismatches.push(json!({"net": n, "missing": missing, "extra": extra})); }+ }+ }+ }+ let expected: BTreeSet<String> = exp.keys().map(|k| key(k)).collect();+ for k in nets.keys() { if !expected.contains(&key(k)) { mismatches.push(json!({"net": k, "unexpected": nets[k]})); } }+ }+ let shown: BTreeMap<&String, &BTreeSet<String>> = match args["net"].as_str() {+ Some(one) => nets.iter().filter(|(k, _)| k.eq_ignore_ascii_case(one)).collect(),+ None => nets.iter().collect(),+ };+ if let Some(one) = args["net"].as_str() {+ if shown.is_empty() { return json!({"success": false, "errorCode": "no_such_net", "error": format!("no net named {one}"), "netCount": nets.len()}); }+ }+ let mut out = json!({"success": true, "mutated": false, "nets": shown, "netCount": nets.len()});+ if args["expect"].is_object() { out["mismatches"] = json!(mismatches); out["ok"] = json!(mismatches.is_empty()); }+ if let Value::Object(m) = extra { for (k, v) in m { out[k] = v; } }+ out["_hint"] = json!(if args["expect"].is_object() { "mismatches lists each net whose pads differ from what you expected (missing, extra) and nets you did not expect." } else { "Every net with its pads as Designator.Pad. Pass expect {net:[\"U1.3\",...]} to compare with the circuit you meant." });+ out+}++/// The board netlist reply (from the routing script) put through the same net / expect handling.+pub fn shape_board_netlist(r: Value, args: &Value) -> Value {+ if r["success"].as_bool() != Some(true) || !(args["net"].is_string() || args["expect"].is_object()) { return r; }+ let nets: BTreeMap<String, BTreeSet<String>> = r["nets"].as_object().map(|m| m.iter().map(|(k, v)| (k.clone(), v.as_array().into_iter().flatten().filter_map(|p| p.as_str().map(str::to_string)).collect())).collect()).unwrap_or_default();+ let mut extra = r.clone();+ for k in ["nets", "netCount", "_hint", "success"] { extra.as_object_mut().map(|m| m.remove(k)); }+ finish(nets, args, extra)+}++#[cfg(test)]+mod tests {+ use super::*;+ const SCH: &str = r#"<?xml version="1.0"?>+<!DOCTYPE eagle SYSTEM "eagle.dtd">+<eagle version="9.6.2"><drawing><schematic><libraries><library name="L">+<symbols><symbol name="OPAMP"><pin name="+IN" x="0" y="0" direction="in"/><pin name="OUT" x="5" y="0" direction="out"/><pin name="V+" x="0" y="5" direction="pwr"/></symbol>+<symbol name="R"><pin name="1" x="0" y="0" direction="pas"/><pin name="2" x="5" y="0" direction="pas"/></symbol></symbols>+<devicesets><deviceset name="LM321"><description>Single op amp</description><gates><gate name="G$1" symbol="OPAMP" x="0" y="0"/></gates><devices><device name="-SOT" package="SOT23-5"><connects>+<connect gate="G$1" pin="+IN" pad="3"/><connect gate="G$1" pin="OUT" pad="1"/><connect gate="G$1" pin="V+" pad="5 6"/></connects></device></devices></deviceset>+<deviceset name="R"><gates><gate name="G$1" symbol="R" x="0" y="0"/></gates><devices><device name="0402" package="0402"><connects><connect gate="G$1" pin="1" pad="1"/><connect gate="G$1" pin="2" pad="2"/></connects></device></devices></deviceset>+</devicesets></library></libraries><parts><part name="U1" library="L" deviceset="LM321" device="-SOT" value="LM321"><attribute name="MPN" value="LM321MF"/></part>+<part name="R1" library="L" deviceset="R" device="0402" value="10k"/></parts>+<sheets><sheet><instances><instance part="U1" gate="G$1" x="25.4" y="50.8"/><instance part="R1" gate="G$1" x="0" y="0" rot="R90"/></instances>+<nets><net name="OUT"><segment><pinref part="U1" gate="G$1" pin="OUT"/><pinref part="R1" gate="G$1" pin="1"/></segment></net>+<net name="VCC"><segment><pinref part="U1" gate="G$1" pin="V+"/></segment></net></nets></sheet></sheets></schematic></drawing></eagle>"#;++ fn tmp(body: &str) -> String {+ static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);+ let n = N.fetch_add(1, std::sync::atomic::Ordering::SeqCst);+ let p = std::env::temp_dir().join(format!("sheet-test-{}-{n}.sch", std::process::id()));+ std::fs::write(&p, body).unwrap();+ p.to_string_lossy().into_owned()+ }++ #[test]+ fn parts_with_pins_and_electrical_types() {+ let r = read_sheet_file(&json!({"path": tmp(SCH), "params": true}));+ assert_eq!(r["success"], true, "{r}");+ assert_eq!(r["count"], 2);+ let u1 = &r["components"][0];+ assert_eq!(u1["designator"], "U1");+ assert_eq!(u1["libReference"], "LM321-SOT");+ assert_eq!(u1["comment"], "LM321");+ assert_eq!(u1["partNumber"], "LM321MF");+ assert_eq!(u1["xMil"], 1000.0);+ let pins = u1["pins"].as_array().unwrap();+ assert!(pins.contains(&json!({"number": "3", "name": "+IN", "gate": "G$1", "electrical": "input", "direction": "in"})));+ assert!(pins.contains(&json!({"number": "1", "name": "OUT", "gate": "G$1", "electrical": "output", "direction": "out"})));+ // a power pin on two pads is listed for each+ assert_eq!(pins.iter().filter(|p| p["name"] == "V+").count(), 2);+ assert_eq!(r["components"][1]["orientation"], "R90");+ assert_eq!(r["complete"], true);+ }++ #[test]+ fn netlist_by_pad_with_expect() {+ let p = tmp(SCH);+ let r = netlist_from_sch(&json!({"schPath": p}));+ assert_eq!(r["nets"]["OUT"], json!(["R1.1", "U1.1"]));+ assert_eq!(r["nets"]["VCC"], json!(["U1.5", "U1.6"]));+ let r = netlist_from_sch(&json!({"schPath": p, "expect": {"out": ["U1.1", "R1.2"], "VCC": ["U1.5", "U1.6"]}}));+ assert_eq!(r["ok"], false);+ assert_eq!(r["mismatches"], json!([{"net": "out", "missing": ["R1.2"], "extra": ["R1.1"]}]));+ let r = netlist_from_sch(&json!({"schPath": p, "net": "vcc"}));+ assert_eq!(r["nets"], json!({"VCC": ["U1.5", "U1.6"]}));+ assert_eq!(netlist_from_sch(&json!({"schPath": p, "net": "NOPE"}))["errorCode"], "no_such_net");+ }++ #[test]+ fn refuses_what_is_not_a_schematic() {+ assert_eq!(read_sheet_file(&json!({}))["errorCode"], "bad_args");+ assert_eq!(read_sheet_file(&json!({"path": "/no/such.sch"}))["errorCode"], "file_not_found");+ assert_eq!(read_sheet_file(&json!({"path": tmp("<eagle><drawing><board/></drawing></eagle>")}))["errorCode"], "not_a_schematic");+ }+}+
skills/fusion-routing/SKILL.md+20−1@@ -33,11 +33,30 @@ Read [electrical priorities](references/electrical-priorities.md) for power path signals, clean bends and via choices. Read [copper and thermal design](references/copper-thermal.md) when assigning pours, retaining copper for ablation, or improving heat spreading. +## Read the board: the standard board model++`fusion_routing_state {expectDocument}` returns the EDA Bridge Standard's board model, the same shape+the Altium and KiCad bridges return: millimetres in Fusion's frame (y up), copper layers named+`F.Cu`, `In1.Cu`.., `B.Cu` (`*.Cu` for a through pad), `tracks`, `arcs`, `vias`, `pads`, `components`,+`polygons`, `outlineEdges` (arcs kept) and `unconnected` / `splitNets` computed from the copper.++- A pad's `name` is its own name (`3`) and `ref` its part; `key` is `U1.3`, the form `{pad: ...}`+ waypoints take. A custom-shape pad (`customShape: true`) is its bounding rectangle; `copperPoints`+ holds the real outline.+- Net-less copper has `net: ""`. Count it as copper; do not route through it.+- `unconnected` is from the copper. `fusionAirwires` is Fusion's ratsnest: higher when a track end+ overlaps a pad without landing on its anchor, lower when Fusion has not recomputed since an edit.+- The revision changes only with copper, parts or outline.+- Companions: `fusion_layer_stack` (copper and dielectric per layer, Dk), `fusion_pour_state` `zones`+ (the poured copper, island by island), `fusion_netlist` (live, or `schPath` with Fusion closed;+ `expect` to compare), `fusion_read_sheet_file` (parts and pin electrical types from a `.sch`),+ `fusion_bodies` (3D heights, parts with no model, models off their footprint).+ ## Select the supported routing path Read `fusion_routing_state {expectDocument}` and retain its exact `boardId` and `revision`. `fusion_route_net` requires these plus the exact net and points/paths. Coordinates and widths-are millimeters. Use pad references for endpoints where supported. Layer-changing waypoints+are millimeters; a layer is `F.Cu`/`B.Cu` or Fusion's 1/16. Use pad references for endpoints where supported. Layer-changing waypoints create through vias according to the live contract. The conservative `fusion_route_net` preflight and bounded `fusion_plan_route` planner support
tests/reports/verb-test-2026-10-26_1626.jsonadded+1069@@ -0,0 +1,1069 @@+{+ "date": "2026-10-26_1626",+ "target": "DESKTOP-BAR6S0F",+ "tier": "daily",+ "pass": 63,+ "fail": 5,+ "sections": {+ "bridge-lifecycle": {+ "PASS": 12,+ "FAIL": 0,+ "MANUAL": 17,+ "SKIP": 0+ },+ "auth-tokens": {+ "PASS": 2,+ "FAIL": 1,+ "MANUAL": 5,+ "SKIP": 0+ },+ "gui-driving": {+ "PASS": 17,+ "FAIL": 0,+ "MANUAL": 22,+ "SKIP": 0+ },+ "aps": {+ "PASS": 5,+ "FAIL": 4,+ "MANUAL": 13,+ "SKIP": 0+ },+ "mcp-server": {+ "PASS": 1,+ "FAIL": 0,+ "MANUAL": 1,+ "SKIP": 0+ },+ "cad-3d-bom": {+ "PASS": 15,+ "FAIL": 0,+ "MANUAL": 7,+ "SKIP": 0+ },+ "python-addin": {+ "PASS": 6,+ "FAIL": 0,+ "MANUAL": 1,+ "SKIP": 0+ },+ "exports": {+ "PASS": 0,+ "FAIL": 0,+ "MANUAL": 2,+ "SKIP": 0+ },+ "routing": {+ "PASS": 5,+ "FAIL": 0,+ "MANUAL": 20,+ "SKIP": 0+ }+ },+ "results": [+ {+ "verb": "fusion_readiness",+ "status": "PASS",+ "why": "",+ "sec": 0.2,+ "section": "bridge-lifecycle"+ },+ {+ "verb": "fusion_prewarm",+ "status": "PASS",+ "why": "",+ "sec": 0.2,+ "section": "bridge-lifecycle"+ },+ {+ "verb": "fusion_account",+ "status": "PASS",+ "why": "",+ "sec": 3.3,+ "section": "auth-tokens"+ },+ {+ "verb": "fusion_claim_seat",+ "status": "MANUAL",+ "why": "moves the Autodesk seat between machines; suspends another box's Fusion",+ "section": "auth-tokens"+ },+ {+ "verb": "fusion_release_seat",+ "status": "MANUAL",+ "why": "closes Fusion on the box to free the seat",+ "section": "auth-tokens"+ },+ {+ "verb": "fusion_describe",+ "status": "PASS",+ "why": "",+ "sec": 0.1,+ "section": "bridge-lifecycle"+ },+ {+ "verb": "fusion_get_app_state",+ "status": "PASS",+ "why": "",+ "sec": 0.2,+ "section": "gui-driving"+ },+ {+ "verb": "fusion_search_cloud_files",+ "status": "PASS",+ "why": "",+ "sec": 0.0,+ "section": "aps"+ },+ {+ "verb": "fusion_walk_cloud_tree",+ "status": "PASS",+ "why": "",+ "sec": 0.0,+ "section": "aps"+ },+ {+ "verb": "fusion_aps_status",+ "status": "PASS",+ "why": "",+ "sec": 0.0,+ "section": "auth-tokens"+ },+ {+ "verb": "fusion_aps_get_browser",+ "status": "PASS",+ "why": "",+ "sec": 0.0,+ "section": "aps"+ },+ {+ "verb": "fusion_aps_search",+ "status": "FAIL",+ "why": "expected success, got error=Not signed in to APS. code=aps_not_signed_in; expected >0 results",+ "sec": 3.3,+ "section": "aps"+ },+ {+ "verb": "fusion_aps_recent",+ "status": "FAIL",+ "why": "expected success, got error=Not signed in to APS. code=aps_not_signed_in; expected >0 results",+ "sec": 0.0,+ "section": "aps"+ },+ {+ "verb": "fusion_aps_browse",+ "status": "FAIL",+ "why": "expected success, got error=Not signed in to APS. code=aps_not_signed_in; missing field 'hubs'",+ "sec": 0.0,+ "section": "aps"+ },+ {+ "verb": "fusion_aps_file_info",+ "status": "FAIL",+ "why": "expected success, got error=Not signed in to APS. code=aps_not_signed_in; missing field 'versionCount'",+ "sec": 0.0,+ "section": "aps"+ },+ {+ "verb": "fusion_aps_get",+ "status": "FAIL",+ "why": "expected success, got error= code=None",+ "sec": 0.0,+ "section": "auth-tokens"+ },+ {+ "verb": "fusion_uninstall",+ "status": "PASS",+ "why": "",+ "sec": 0.0,+ "section": "bridge-lifecycle"+ },+ {+ "verb": "fusion_set_seat_takeover",+ "status": "PASS",+ "why": "",+ "sec": 0.0,+ "section": "bridge-lifecycle"+ },+ {+ "verb": "fusion_aps_create_folder",+ "status": "MANUAL",+ "why": "needs projectId/parentFolderId of the test workspace",+ "section": "aps"+ },+ {+ "verb": "fusion_aps_upload",+ "status": "MANUAL",+ "why": "cloud write; exercised via build flows weekly",+ "section": "aps"+ },+ {+ "verb": "fusion_aps_set_client_id",+ "status": "MANUAL",+ "why": "would clobber org auth config",+ "section": "auth-tokens"+ },+ {+ "verb": "fusion_aps_set_browser",+ "status": "MANUAL",+ "why": "",+ "section": "aps"+ },+ {+ "verb": "fusion_aps_forget_browser",+ "status": "MANUAL",+ "why": "",+ "section": "aps"+ },+ {+ "verb": "fusion_aps_signin",+ "status": "MANUAL",+ "why": "opens a browser on the user's machine",+ "section": "auth-tokens"+ },+ {+ "verb": "fusion_start",+ "status": "PASS",+ "why": "",+ "sec": 0.1,+ "section": "gui-driving"+ },+ {+ "verb": "fusion_get_preferences",+ "status": "PASS",+ "why": "",+ "sec": 0.1,+ "section": "gui-driving"+ },+ {+ "verb": "fusion_set_preference",+ "status": "PASS",+ "why": "",+ "sec": 1.1,+ "section": "gui-driving"+ },+ {+ "verb": "fusion_mcp_status",+ "status": "PASS",+ "why": "",+ "sec": 0.0,+ "section": "mcp-server"+ },+ {+ "verb": "fusion_mcp_enable",+ "status": "MANUAL",+ "why": "drives the prefs UI, takes foreground",+ "section": "mcp-server"+ },+ {+ "verb": "fusion_prefs_open",+ "status": "MANUAL",+ "why": "opens a dialog on the user's screen",+ "section": "gui-driving"+ },+ {+ "verb": "fusion_prefs_close",+ "status": "MANUAL",+ "why": "",+ "section": "gui-driving"+ },+ {+ "verb": "fusion_set_auto_update",+ "status": "PASS",+ "why": "",+ "sec": 0.0,+ "section": "bridge-lifecycle"+ },+ {+ "verb": "fusion_assembly_bom",+ "status": "PASS",+ "why": "",+ "sec": 1.3,+ "section": "cad-3d-bom"+ },+ {+ "verb": "fusion_dismiss_blocking_dialogs",+ "status": "PASS",+ "why": "",+ "sec": 0.1,+ "section": "gui-driving"+ },+ {+ "verb": "fusion_open_cloud_file",+ "status": "PASS",+ "why": "",+ "sec": 24.7,+ "section": "gui-driving"+ },+ {+ "verb": "fusion_show_schematic",+ "status": "PASS",+ "why": "",+ "sec": 1.0,+ "section": "gui-driving"+ },+ {+ "verb": "fusion_show_2d_board",+ "status": "PASS",+ "why": "",+ "sec": 4.7,+ "section": "gui-driving"+ },+ {+ "verb": "fusion_board_info",+ "status": "PASS",+ "why": "",+ "sec": 0.5,+ "section": "gui-driving"+ },+ {+ "verb": "fusion_open_electronics",+ "status": "PASS",+ "why": "",+ "sec": 0.3,+ "section": "python-addin"+ },+ {+ "verb": "fusion_list_text_commands",+ "status": "PASS",+ "why": "",+ "sec": 0.1,+ "section": "python-addin"+ },+ {+ "verb": "fusion_execute_text_command",+ "status": "PASS",+ "why": "",+ "sec": 1.9,+ "section": "python-addin"+ },+ {+ "verb": "fusion_show_3d_board",+ "status": "PASS",+ "why": "",+ "sec": 8.3,+ "section": "gui-driving"+ },+ {+ "verb": "fusion_inspect_bodies",+ "status": "PASS",+ "why": "",+ "sec": 2.3,+ "section": "cad-3d-bom"+ },+ {+ "verb": "fusion_physical_properties",+ "status": "PASS",+ "why": "",+ "sec": 0.6,+ "section": "cad-3d-bom"+ },+ {+ "verb": "fusion_get_design_info",+ "status": "PASS",+ "why": "",+ "sec": 1.0,+ "section": "cad-3d-bom"+ },+ {+ "verb": "fusion_get_parameters",+ "status": "PASS",+ "why": "",+ "sec": 0.2,+ "section": "cad-3d-bom"+ },+ {+ "verb": "fusion_document_info",+ "status": "PASS",+ "why": "",+ "sec": 0.1,+ "section": "cad-3d-bom"+ },+ {+ "verb": "fusion_delete_cloud_file#guard",+ "status": "PASS",+ "why": "",+ "sec": 2.0,+ "section": "aps"+ },+ {+ "verb": "fusion_delete_cloud_file",+ "status": "MANUAL",+ "why": "destructive cloud delete; only against files a rehearsal created",+ "section": "aps"+ },+ {+ "verb": "fusion_create_cloud_folder",+ "status": "MANUAL",+ "why": "cloud write into the team hub",+ "section": "aps"+ },+ {+ "verb": "fusion_set_parameter#guard",+ "status": "PASS",+ "why": "",+ "sec": 1.9,+ "section": "cad-3d-bom"+ },+ {+ "verb": "fusion_set_parameter",+ "status": "MANUAL",+ "why": "mutates the active design's parameters",+ "section": "cad-3d-bom"+ },+ {+ "verb": "fusion_run_modeling_script#guard",+ "status": "PASS",+ "why": "",+ "sec": 2.3,+ "section": "python-addin"+ },+ {+ "verb": "fusion_assembly_bom#guard",+ "status": "PASS",+ "why": "",+ "sec": 1.3,+ "section": "cad-3d-bom"+ },+ {+ "verb": "fusion_run_modeling_script",+ "status": "PASS",+ "why": "",+ "sec": 1.7,+ "section": "python-addin"+ },+ {+ "verb": "fusion_electron_select",+ "status": "MANUAL",+ "why": "selection state is user-visible",+ "section": "python-addin"+ },+ {+ "verb": "fusion_open_design",+ "status": "MANUAL",+ "why": "local-file open; superseded by cloud flows",+ "section": "gui-driving"+ },+ {+ "verb": "fusion_open_board",+ "status": "MANUAL",+ "why": "local .brd open; needs a .brd on the box (make_test_project exercises the same newDesignFromLocal path)",+ "section": "gui-driving"+ },+ {+ "verb": "fusion_open_schematic",+ "status": "MANUAL",+ "why": "local .sch open; needs a .sch on the box (make_test_project exercises the same newDesignFromLocal path)",+ "section": "gui-driving"+ },+ {+ "verb": "fusion_import_electronics",+ "status": "MANUAL",+ "why": "needs a Fusion-native .fsch/.fbrd/.flbr on the box; creates a NEW unsaved document",+ "section": "gui-driving"+ },+ {+ "verb": "fusion_close_window#noargs",+ "status": "PASS",+ "why": "",+ "sec": 0.1,+ "section": "gui-driving"+ },+ {+ "verb": "fusion_close_document",+ "status": "MANUAL",+ "why": "",+ "section": "gui-driving"+ },+ {+ "verb": "fusion_close_all_documents",+ "status": "MANUAL",+ "why": "would close the user's own tabs",+ "section": "gui-driving"+ },+ {+ "verb": "fusion_export_obj",+ "status": "MANUAL",+ "why": "build/doc-type dependent (OBJ refused on the 2026-08-14 box); verify quarterly",+ "section": "bridge-lifecycle"+ },+ {+ "verb": "fusion_export_fbx",+ "status": "MANUAL",+ "why": "build/doc-type dependent (OBJ refused on the 2026-08-14 box); verify quarterly",+ "section": "bridge-lifecycle"+ },+ {+ "verb": "fusion_export_usdz",+ "status": "MANUAL",+ "why": "build/doc-type dependent (OBJ refused on the 2026-08-14 box); verify quarterly",+ "section": "bridge-lifecycle"+ },+ {+ "verb": "fusion_export_dxf",+ "status": "MANUAL",+ "why": "build/doc-type dependent (OBJ refused on the 2026-08-14 box); verify quarterly",+ "section": "bridge-lifecycle"+ },+ {+ "verb": "fusion_export_dwg",+ "status": "MANUAL",+ "why": "build/doc-type dependent (OBJ refused on the 2026-08-14 box); verify quarterly",+ "section": "bridge-lifecycle"+ },+ {+ "verb": "fusion_export_skp",+ "status": "MANUAL",+ "why": "build/doc-type dependent (OBJ refused on the 2026-08-14 box); verify quarterly",+ "section": "bridge-lifecycle"+ },+ {+ "verb": "fusion_set_design_rules",+ "status": "MANUAL",+ "why": "mutates the board's DRC rules",+ "section": "exports"+ },+ {+ "verb": "fusion_show_3d_package",+ "status": "MANUAL",+ "why": "enters Package3DEnvironment (exit needs Package3DStop + dialog); tour covers it; attach path in release rehearsals",+ "section": "bridge-lifecycle"+ },+ {+ "verb": "fusion_bind_library_3d",+ "status": "MANUAL",+ "why": "cloud f3d mint per run; release rehearsals (needs signed-in session)",+ "section": "bridge-lifecycle"+ },+ {+ "verb": "fusion_attach_3d_package",+ "status": "MANUAL",+ "why": "modal Save dialog needs a human eye",+ "section": "gui-driving"+ },+ {+ "verb": "fusion_make_3d_package",+ "status": "MANUAL",+ "why": "cloud upload; run in release rehearsals",+ "section": "gui-driving"+ },+ {+ "verb": "fusion_build_library_3d",+ "status": "MANUAL",+ "why": "long multi-part build; release rehearsals",+ "section": "gui-driving"+ },+ {+ "verb": "fusion_cleanup_cloud_files#guard",+ "status": "PASS",+ "why": "",+ "sec": 1.9,+ "section": "aps"+ },+ {+ "verb": "fusion_cleanup_cloud_files",+ "status": "MANUAL",+ "why": "real cleanup takes explicit lineage urns; used by weekly build rehearsals",+ "section": "aps"+ },+ {+ "verb": "fusion_new_electronics_from_eagle",+ "status": "MANUAL",+ "why": "drives native dialogs",+ "section": "gui-driving"+ },+ {+ "verb": "fusion_screenshot_fusion",+ "status": "PASS",+ "why": "",+ "sec": 0.5,+ "section": "bridge-lifecycle"+ },+ {+ "verb": "fusion_state",+ "status": "PASS",+ "why": "",+ "sec": 0.6,+ "section": "gui-driving"+ },+ {+ "verb": "fusion_demo_progress#unknown",+ "status": "PASS",+ "why": "",+ "sec": 0.1,+ "section": "bridge-lifecycle"+ },+ {+ "verb": "fusion_stop",+ "status": "MANUAL",+ "why": "lifecycle/human-wall verbs; manual tier",+ "section": "gui-driving"+ },+ {+ "verb": "fusion_kill",+ "status": "MANUAL",+ "why": "lifecycle/human-wall verbs; manual tier",+ "section": "gui-driving"+ },+ {+ "verb": "fusion_signin",+ "status": "MANUAL",+ "why": "lifecycle/human-wall verbs; manual tier",+ "section": "gui-driving"+ },+ {+ "verb": "fusion_signin_2fa",+ "status": "MANUAL",+ "why": "lifecycle/human-wall verbs; manual tier",+ "section": "bridge-lifecycle"+ },+ {+ "verb": "fusion_demo",+ "status": "MANUAL",+ "why": "lifecycle/human-wall verbs; manual tier",+ "section": "gui-driving"+ },+ {+ "verb": "fusion_install_fusion",+ "status": "MANUAL",+ "why": "lifecycle/human-wall verbs; manual tier",+ "section": "bridge-lifecycle"+ },+ {+ "verb": "fusion_notify_owner",+ "status": "MANUAL",+ "why": "lifecycle/human-wall verbs; manual tier",+ "section": "bridge-lifecycle"+ },+ {+ "verb": "fusion_open_by_urn_note",+ "status": "MANUAL",+ "why": "lifecycle/human-wall verbs; manual tier",+ "section": "bridge-lifecycle"+ },+ {+ "verb": "fusion_action_ledger",+ "status": "PASS",+ "why": "",+ "sec": 0.1,+ "section": "bridge-lifecycle"+ },+ {+ "verb": "fusion_addin_status",+ "status": "PASS",+ "why": "",+ "sec": 0.1,+ "section": "python-addin"+ },+ {+ "verb": "fusion_alerts",+ "status": "PASS",+ "why": "",+ "sec": 0.1,+ "section": "gui-driving"+ },+ {+ "verb": "fusion_verb_times",+ "status": "PASS",+ "why": "",+ "sec": 0.0,+ "section": "bridge-lifecycle"+ },+ {+ "verb": "fusion_live_feed",+ "status": "PASS",+ "why": "",+ "sec": 0.0,+ "section": "bridge-lifecycle"+ },+ {+ "verb": "fusion_prove_result",+ "status": "PASS",+ "why": "",+ "sec": 0.0,+ "section": "cad-3d-bom"+ },+ {+ "verb": "fusion_set_driving_badge",+ "status": "PASS",+ "why": "",+ "sec": 0.1,+ "section": "bridge-lifecycle"+ },+ {+ "verb": "fusion_set_window_sizing",+ "status": "PASS",+ "why": "",+ "sec": 0.1,+ "section": "gui-driving"+ },+ {+ "verb": "fusion_cancel_run",+ "status": "PASS",+ "why": "",+ "sec": 0.1,+ "section": "cad-3d-bom"+ },+ {+ "verb": "fusion_preflight_lbr",+ "status": "PASS",+ "why": "",+ "sec": 0.1,+ "section": "cad-3d-bom"+ },+ {+ "verb": "fusion_library_parts",+ "status": "PASS",+ "why": "",+ "sec": 0.0,+ "section": "cad-3d-bom"+ },+ {+ "verb": "fusion_create_library",+ "status": "MANUAL",+ "why": "the five-part drill covers it (fusion_install_library_bundle -> prove)",+ "section": "cad-3d-bom"+ },+ {+ "verb": "fusion_install_library_bundle",+ "status": "MANUAL",+ "why": "the five-part drill covers it; needs a bundle zip on the box",+ "section": "cad-3d-bom"+ },+ {+ "verb": "fusion_managed_library_create",+ "status": "MANUAL",+ "why": "creates a managed library on the hub (cloud write)",+ "section": "aps"+ },+ {+ "verb": "fusion_managed_library_link",+ "status": "MANUAL",+ "why": "drives LIBRARY.IO dialogs against the hub",+ "section": "aps"+ },+ {+ "verb": "fusion_managed_library_pull",+ "status": "MANUAL",+ "why": "drives LIBRARY.IO dialogs against the hub",+ "section": "aps"+ },+ {+ "verb": "fusion_managed_library_push",+ "status": "MANUAL",+ "why": "drives LIBRARY.IO dialogs; cloud write",+ "section": "aps"+ },+ {+ "verb": "fusion_managed_library_unlink",+ "status": "MANUAL",+ "why": "drives LIBRARY.IO dialogs against the hub",+ "section": "aps"+ },+ {+ "verb": "fusion_reset_workspace",+ "status": "MANUAL",+ "why": "closes the user's documents",+ "section": "gui-driving"+ },+ {+ "verb": "fusion_restart_for_addin",+ "status": "MANUAL",+ "why": "restarts Fusion",+ "section": "bridge-lifecycle"+ },+ {+ "verb": "fusion_save_to_cloud",+ "status": "MANUAL",+ "why": "cloud write; exercised by the drills",+ "section": "aps"+ },+ {+ "verb": "fusion_web_signin",+ "status": "MANUAL",+ "why": "opens a browser on the user's machine",+ "section": "auth-tokens"+ },+ {+ "verb": "fusion_routing_state#guard",+ "status": "PASS",+ "why": "",+ "sec": 1.0,+ "section": "routing"+ },+ {+ "verb": "fusion_route_net#guard",+ "status": "PASS",+ "why": "",+ "sec": 1.1,+ "section": "routing"+ },+ {+ "verb": "fusion_routing_drc#guard",+ "status": "PASS",+ "why": "",+ "sec": 1.0,+ "section": "routing"+ },+ {+ "verb": "fusion_plan_route#guard",+ "status": "PASS",+ "why": "",+ "sec": 1.0,+ "section": "routing"+ },+ {+ "verb": "fusion_routing_state",+ "status": "MANUAL",+ "why": "live routing on a fixture board: demo/routing/run_demo.py is the weekly proof (needs a board open)",+ "section": "routing"+ },+ {+ "verb": "fusion_route_net",+ "status": "MANUAL",+ "why": "live routing on a fixture board: demo/routing/run_demo.py is the weekly proof (needs a board open)",+ "section": "routing"+ },+ {+ "verb": "fusion_routing_drc",+ "status": "MANUAL",+ "why": "live routing on a fixture board: demo/routing/run_demo.py is the weekly proof (needs a board open)",+ "section": "routing"+ },+ {+ "verb": "fusion_plan_route",+ "status": "MANUAL",+ "why": "live routing on a fixture board: demo/routing/run_demo.py is the weekly proof (needs a board open)",+ "section": "routing"+ },+ {+ "verb": "fusion_move_parts",+ "status": "MANUAL",+ "why": "live routing on a fixture board: demo/routing/run_demo.py is the weekly proof (needs a board open)",+ "section": "routing"+ },+ {+ "verb": "fusion_place_vias",+ "status": "MANUAL",+ "why": "live routing on a fixture board: demo/routing/run_demo.py is the weekly proof (needs a board open)",+ "section": "routing"+ },+ {+ "verb": "fusion_apply_pours",+ "status": "MANUAL",+ "why": "live routing on a fixture board: demo/routing/run_demo.py is the weekly proof (needs a board open)",+ "section": "routing"+ },+ {+ "verb": "fusion_pour_state",+ "status": "MANUAL",+ "why": "live routing on a fixture board: demo/routing/run_demo.py is the weekly proof (needs a board open)",+ "section": "routing"+ },+ {+ "verb": "fusion_board_source",+ "status": "MANUAL",+ "why": "live routing on a fixture board: demo/routing/run_demo.py is the weekly proof (needs a board open)",+ "section": "routing"+ },+ {+ "verb": "fusion_ripup_nets",+ "status": "MANUAL",+ "why": "live routing on a fixture board: demo/routing/run_demo.py is the weekly proof (needs a board open)",+ "section": "routing"+ },+ {+ "verb": "fusion_schematic_run",+ "status": "MANUAL",+ "why": "live routing on a fixture board: demo/routing/run_demo.py is the weekly proof (needs a board open)",+ "section": "routing"+ },+ {+ "verb": "fusion_board_bom",+ "status": "MANUAL",+ "why": "live routing on a fixture board: demo/routing/run_demo.py is the weekly proof (needs a board open)",+ "section": "routing"+ },+ {+ "verb": "fusion_change_pours",+ "status": "MANUAL",+ "why": "live routing on a fixture board: demo/routing/run_demo.py is the weekly proof (needs a board open)",+ "section": "routing"+ },+ {+ "verb": "fusion_board_seed",+ "status": "MANUAL",+ "why": "live routing on a fixture board: demo/routing/run_demo.py is the weekly proof (needs a board open)",+ "section": "routing"+ },+ {+ "verb": "fusion_schematic_plan",+ "status": "MANUAL",+ "why": "live routing on a fixture board: demo/routing/run_demo.py is the weekly proof (needs a board open)",+ "section": "routing"+ },+ {+ "verb": "fusion_remove_pours",+ "status": "MANUAL",+ "why": "live routing on a fixture board: demo/routing/run_demo.py is the weekly proof (needs a board open)",+ "section": "routing"+ },+ {+ "verb": "fusion_layer_stack",+ "status": "MANUAL",+ "why": "live routing on a fixture board: demo/routing/run_demo.py is the weekly proof (needs a board open)",+ "section": "routing"+ },+ {+ "verb": "fusion_netlist",+ "status": "MANUAL",+ "why": "live routing on a fixture board: demo/routing/run_demo.py is the weekly proof (needs a board open)",+ "section": "routing"+ },+ {+ "verb": "fusion_bodies",+ "status": "MANUAL",+ "why": "live routing on a fixture board: demo/routing/run_demo.py is the weekly proof (needs a board open)",+ "section": "routing"+ },+ {+ "verb": "fusion_apply_fusion_update",+ "status": "MANUAL",+ "why": "lifecycle, seat or presentation verbs: need a human-visible Fusion state; manual tier",+ "section": "bridge-lifecycle"+ },+ {+ "verb": "fusion_resolve_seat_dialog",+ "status": "MANUAL",+ "why": "lifecycle, seat or presentation verbs: need a human-visible Fusion state; manual tier",+ "section": "bridge-lifecycle"+ },+ {+ "verb": "fusion_interactive",+ "status": "MANUAL",+ "why": "lifecycle, seat or presentation verbs: need a human-visible Fusion state; manual tier",+ "section": "gui-driving"+ },+ {+ "verb": "fusion_narrate",+ "status": "MANUAL",+ "why": "lifecycle, seat or presentation verbs: need a human-visible Fusion state; manual tier",+ "section": "gui-driving"+ },+ {+ "verb": "fusion_view",+ "status": "MANUAL",+ "why": "lifecycle, seat or presentation verbs: need a human-visible Fusion state; manual tier",+ "section": "gui-driving"+ },+ {+ "verb": "fusion_electronics_query",+ "status": "MANUAL",+ "why": "lifecycle, seat or presentation verbs: need a human-visible Fusion state; manual tier",+ "section": "cad-3d-bom"+ },+ {+ "verb": "fusion_schematic#guard",+ "status": "PASS",+ "why": "",+ "sec": 1.9,+ "section": "gui-driving"+ },+ {+ "verb": "fusion_launch",+ "status": "MANUAL",+ "why": "EDA Bridge Standard alias of an existing verb (the reply carries aliasOf); tested through the verb it names",+ "section": "bridge-lifecycle"+ },+ {+ "verb": "fusion_export_gerber",+ "status": "MANUAL",+ "why": "EDA Bridge Standard alias of an existing verb (the reply carries aliasOf); tested through the verb it names",+ "section": "exports"+ },+ {+ "verb": "fusion_dismiss_dialogs",+ "status": "MANUAL",+ "why": "EDA Bridge Standard alias of an existing verb (the reply carries aliasOf); tested through the verb it names",+ "section": "gui-driving"+ },+ {+ "verb": "fusion_board_summary",+ "status": "MANUAL",+ "why": "EDA Bridge Standard alias of an existing verb (the reply carries aliasOf); tested through the verb it names",+ "section": "routing"+ },+ {+ "verb": "fusion_install",+ "status": "MANUAL",+ "why": "EDA Bridge Standard alias of an existing verb (the reply carries aliasOf); tested through the verb it names",+ "section": "bridge-lifecycle"+ },+ {+ "verb": "fusion_read_sheet_file#guard",+ "status": "PASS",+ "why": "",+ "sec": 0.0,+ "section": "routing"+ },+ {+ "verb": "fusion_schematic#missing",+ "status": "PASS",+ "why": "",+ "sec": 2.9,+ "section": "gui-driving"+ },+ {+ "verb": "fusion_schematic",+ "status": "MANUAL",+ "why": "open a cloud design's schematic / create a blank one: exercised by the weekly tour",+ "section": "gui-driving"+ },+ {+ "verb": "fusion_make_showcase_project#guard",+ "status": "PASS",+ "why": "",+ "sec": 2.6,+ "section": "cad-3d-bom"+ },+ {+ "verb": "fusion_make_showcase_project",+ "status": "MANUAL",+ "why": "places a library's parts on a fresh sch+brd; exercised on the Adom_ToF library",+ "section": "cad-3d-bom"+ },+ {+ "verb": "fusion_eagle_import_status#guard",+ "status": "PASS",+ "why": "",+ "sec": 1.9,+ "section": "cad-3d-bom"+ },+ {+ "verb": "fusion_eagle_import_status",+ "status": "MANUAL",+ "why": "status of a background EAGLE import",+ "section": "cad-3d-bom"+ },+ {+ "verb": "fusion_show_attributes#guard",+ "status": "PASS",+ "why": "",+ "sec": 1.9,+ "section": "cad-3d-bom"+ },+ {+ "verb": "fusion_show_attributes",+ "status": "MANUAL",+ "why": "opens the device editor for a deviceset and returns its metadata",+ "section": "cad-3d-bom"+ },+ {+ "verb": "focus_invariance",+ "status": "PASS",+ "why": "",+ "section": "gui-driving"+ }+ ],+ "durationSec": 84.4+}
tests/run_verb_tests.py+5−2@@ -332,12 +332,15 @@ 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_routing_state", "fusion_route_net", "fusion_routing_drc", "fusion_plan_route", "fusion_move_parts", "fusion_place_vias", "fusion_apply_pours", "fusion_pour_state", "fusion_board_source", "fusion_ripup_nets", "fusion_schematic_run", "fusion_board_bom", "fusion_change_pours", "fusion_board_seed", "fusion_schematic_plan", "fusion_remove_pours"]:+for v in ["fusion_routing_state", "fusion_route_net", "fusion_routing_drc", "fusion_plan_route", "fusion_move_parts", "fusion_place_vias", "fusion_apply_pours", "fusion_pour_state", "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_layer_stack", "fusion_netlist", "fusion_bodies", "fusion_component_data", "fusion_component_pins", "fusion_list_nets", "fusion_net_connections", "fusion_list_designators", "fusion_layers", "fusion_copper_layers", "fusion_layers_in_use", "fusion_read_rules", "fusion_rules_by_kind", "fusion_silk_state", "fusion_net_classes", "fusion_run_drc", "fusion_repour", "fusion_zoom_to"]: 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") t("fusion_schematic#guard", "negative", {}, {"pred": "must_refuse"}, "no action: must refuse with the menu, never open a dialog")+for v in ["fusion_launch", "fusion_export_gerber", "fusion_dismiss_dialogs", "fusion_board_summary", "fusion_install"]:+ t(v, "manual", note="EDA Bridge Standard alias of an existing verb (the reply carries aliasOf); tested through the verb it names")+t("fusion_read_sheet_file#guard", "negative", {"path": "C:/no/such/file.sch"}, {"pred": "must_refuse"}, "a missing file refuses with file_not_found, never an empty parts list") t("fusion_schematic#missing", "negative", {"action": "open", "schPath": "C:/nope/missing.sch"}, {"pred": "must_refuse"}, "a missing local .sch must refuse (via open_schematic) with no dialog") t("fusion_schematic", "manual", note="open a cloud design's schematic / create a blank one: exercised by the weekly tour") t("fusion_make_showcase_project#guard", "negative", {}, {"pred": "must_refuse"}, "no libraryPath: must refuse")@@ -351,7 +354,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_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_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.pyadded+313@@ -0,0 +1,313 @@+"""The board model (rust/bridge/src/board_model.py) against the EDA Bridge Standard's schema and+its rules, on the demo boards. Offline: python3 -m unittest tests.test_board_model"""+import copy+import importlib.util+import json+import math+from pathlib import Path+import unittest+import xml.etree.ElementTree as ET++ROOT = Path(__file__).resolve().parents[1]+spec = importlib.util.spec_from_file_location('routing', ROOT / 'rust/bridge/src/routing_script.py')+r = importlib.util.module_from_spec(spec)+spec.loader.exec_module(r)+exec((ROOT / 'rust/bridge/src/routing_planner.py').read_text(), r.__dict__)+exec((ROOT / 'rust/bridge/src/board_model.py').read_text(), r.__dict__)+SCHEMA = json.loads((ROOT / 'contracts/eda-bridge-standard/board.schema.json').read_text())+COMPANIONS = json.loads((ROOT / 'contracts/eda-bridge-standard/companions.schema.json').read_text())+DEMO = ROOT / 'demo/routing'+BQ = DEMO / 'bq25792/thermal-reroute/candidate-01/filled.brd'+COPPER_NAMES = {'F.Cu', 'B.Cu', '*.Cu'} | {'In%d.Cu' % i for i in range(1, 31)}+++def board(path):+ return ET.parse(path).getroot().find('drawing/board')+++def validate(value, schema, root, where='$'):+ """The subset of JSON Schema the standard uses: $ref, type, const, enum, required, properties,+ items, minItems, maxItems."""+ if '$ref' in schema:+ node = root+ for part in schema['$ref'].lstrip('#/').split('/'):+ node = node[part]+ return validate(value, node, root, where)+ t = schema.get('type')+ types = {'object': dict, 'array': list, 'string': str, 'number': (int, float), 'integer': int, 'boolean': bool, 'null': type(None)}+ if t:+ allowed = t if isinstance(t, list) else [t]+ ok = any(isinstance(value, types[x]) and not (x in ('number', 'integer') and isinstance(value, bool)) for x in allowed)+ assert ok, '%s: %r is not %s' % (where, value if not isinstance(value, (dict, list)) else type(value).__name__, t)+ if 'const' in schema:+ assert value == schema['const'], '%s: %r != %r' % (where, value, schema['const'])+ if 'enum' in schema:+ assert value in schema['enum'], '%s: %r not in %r' % (where, value, schema['enum'])+ if isinstance(value, dict):+ for k in schema.get('required', []):+ assert k in value, '%s: missing %s' % (where, k)+ for k, sub in schema.get('properties', {}).items():+ if k in value:+ validate(value[k], sub, root, where + '.' + k)+ if isinstance(value, list):+ assert len(value) >= schema.get('minItems', 0) and len(value) <= schema.get('maxItems', 10 ** 9), where+ if 'items' in schema:+ for i, v in enumerate(value):+ validate(v, schema['items'], root, '%s[%d]' % (where, i))+++class BoardModelTests(unittest.TestCase):+ def test_every_demo_board_matches_the_schema(self):+ for f in sorted(DEMO.glob('**/*.brd')):+ with self.subTest(board=f.name):+ model, _ = r.board_model(board(f))+ validate(model, SCHEMA, SCHEMA)+ for kind in ('tracks', 'arcs', 'pads', 'polygons'):+ for item in model[kind]:+ self.assertTrue(item['layer'] in COPPER_NAMES, '%s layer %s' % (kind, item['layer']))+ for v in model['vias']:+ self.assertTrue(set(v['layers']) <= COPPER_NAMES)++ def test_routed_boards_are_connected_and_unrouted_ones_count_what_is_left(self):+ expect = {'crossbar/take-01/fusion-crossbar-24-routed.brd': 0, 'crossbar/fusion-crossbar-24.brd': 48,+ 'fixture/fusion-routing.brd': 7, 'evidence/boards/fusion-routing-routed.brd': 0,+ 'obstacles/take-04/fusion-obstacle-live-routed.brd': 0, 'bq25792/native-routed/bq25792-astra-routed.brd': 0,+ 'bq25792/thermal-reroute/candidate-01/filled.brd': 0, 'bq25792/ablation/take-02/bq25792-ablation-routed.brd': 0}+ for f, n in expect.items():+ with self.subTest(board=f):+ model, _ = r.board_model(board(DEMO / f))+ self.assertEqual(n, model['unconnected'], model['splitNets'])++ def test_a_split_net_is_found_when_a_track_is_cut(self):+ b = board(DEMO / 'crossbar/take-01/fusion-crossbar-24-routed.brd')+ sig = b.find('signals/signal')+ sig.remove(sig.find('wire'))+ model, _ = r.board_model(b)+ self.assertEqual(1, model['unconnected'])+ self.assertEqual([{'net': sig.get('name'), 'groups': 2}], model['splitNets'])++ 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))+ q1 = {p['name']: p for p in model['pads'] if p['ref'] == 'Q1'}+ for n in '56789':+ self.assertTrue(q1[n].get('customShape'))+ self.assertEqual('rect', q1[n]['shape'])+ self.assertEqual(0, q1[n]['rotation'])+ self.assertGreater(q1[n]['sizeX'] * q1[n]['sizeY'], 1.0)+ self.assertFalse(q1['1'].get('customShape'))+ for f in DEMO.glob('**/*.brd'):+ for p in r.board_model(board(f))[0]['pads']:+ self.assertGreater(p['sizeX'], 0, (f.name, p))+ self.assertGreater(p['sizeY'], 0, (f.name, p))++ def test_copper_layers_follow_the_stack_order(self):+ model, _ = r.board_model(board(BQ))+ self.assertEqual(['F.Cu', 'In1.Cu', 'In2.Cu', 'B.Cu'], model['copperLayers'])+ self.assertEqual({'F.Cu', 'In1.Cu', 'In2.Cu', 'B.Cu'}, {g['layer'] for g in model['polygons'] if g['net'] == 'PGND'})+ self.assertTrue(all(v['layers'] == ['F.Cu', 'B.Cu'] for v in model['vias']))++ def test_netless_copper_is_kept_with_an_empty_net(self):+ b = board(DEMO / 'fixture/fusion-routing.brd')+ ET.SubElement(b.find('plain'), 'wire', x1='1', y1='1', x2='3', y2='1', width='0.3', layer='16')+ ET.SubElement(b.find('plain'), 'rectangle', x1='4', y1='4', x2='6', y2='5', layer='1')+ model, _ = r.board_model(b)+ self.assertIn({'start': {'x': 1, 'y': 1}, 'end': {'x': 3, 'y': 1}, 'width': 0.3, 'layer': 'B.Cu', 'layerId': 16, 'net': ''},+ [{k: t[k] for k in ('start', 'end', 'width', 'layer', 'layerId', 'net')} for t in model['tracks']])+ self.assertTrue(any(g['net'] == '' and g['layer'] == 'F.Cu' for g in model['polygons']))+ self.assertEqual(7, model['unconnected']) # free copper is not a connection to make++ def test_curved_copper_is_an_arc(self):+ b = board(DEMO / 'fixture/fusion-routing.brd')+ sig = b.find('signals/signal')+ ET.SubElement(sig, 'wire', x1='10', y1='0', x2='0', y2='10', width='0.25', layer='1', curve='90')+ model, _ = r.board_model(b)+ a = model['arcs'][0]+ self.assertAlmostEqual(0, a['center']['x'], 6)+ self.assertAlmostEqual(0, a['center']['y'], 6)+ self.assertAlmostEqual(10, a['radius'], 6)+ self.assertEqual((0, 90), (a['startAngle'], a['endAngle']))+ ET.SubElement(sig, 'wire', x1='0', y1='10', x2='10', y2='0', width='0.25', layer='1', curve='-90')+ a = r.board_model(b)[0]['arcs'][1]+ self.assertEqual((0, 90), (a['startAngle'], a['endAngle'])) # the same arc walked clockwise++ def test_outline_keeps_its_arcs(self):+ b = board(DEMO / 'fixture/fusion-routing.brd')+ plain = b.find('plain')+ for w in plain.findall("wire[@layer='20']"):+ plain.remove(w)+ # a 40 x 20 board with 2 mm rounded corners, one corner drawn backwards+ for a in [('2', '0', '38', '0', '0'), ('40', '2', '38', '0', '-90'), ('40', '2', '40', '18', '0'), ('40', '18', '38', '20', '90'),+ ('38', '20', '2', '20', '0'), ('2', '20', '0', '18', '90'), ('0', '18', '0', '2', '0'), ('0', '2', '2', '0', '90')]:+ ET.SubElement(plain, 'wire', x1=a[0], y1=a[1], x2=a[2], y2=a[3], width='0', layer='20', curve=a[4])+ model, _ = r.board_model(b)+ arcs = [e for e in model['outlineEdges'] if e['kind'] == 'arc']+ self.assertEqual(4, len(arcs))+ self.assertTrue(model['outlineClosed'])+ self.assertEqual({2}, {round(e['radius'], 6) for e in arcs})+ self.assertIn({'kind': 'arc', 'start': [38, 0], 'end': [40, 2], 'center': [38, 2], 'radius': 2, 'angle1': 270, 'angle2': 360}, arcs)+ # the vertex list samples each arc at 2 degrees or finer and stays on the arc+ for p in model['outline']:+ self.assertTrue(-1e-6 <= p[0] <= 40 + 1e-6 and -1e-6 <= p[1] <= 20 + 1e-6)+ self.assertEqual(4 + 4 * 45, len(model['outline']))++ def test_unclosed_outline_says_so(self):+ b = board(DEMO / 'fixture/fusion-routing.brd')+ plain = b.find('plain')+ plain.remove(plain.findall("wire[@layer='20']")[0])+ model, _ = r.board_model(b)+ self.assertFalse(model['outlineClosed'])+ self.assertIn('not closed', model['outlineProblem'])++ def test_revision_moves_with_copper_parts_and_outline_only(self):+ base = board(DEMO / 'fixture/fusion-routing.brd')+ rev = r.board_revision(base)+ self.assertEqual(rev, r.board_revision(copy.deepcopy(base)))++ def changed(edit):+ b = copy.deepcopy(base)+ edit(b)+ return r.board_revision(b) != rev+ self.assertTrue(changed(lambda b: ET.SubElement(b.find('signals/signal'), 'wire', x1='1', y1='1', x2='2', y2='1', width='0.2', layer='1')))+ self.assertTrue(changed(lambda b: b.find('elements/element').set('x', '1.5')))+ self.assertTrue(changed(lambda b: b.find("plain/wire[@layer='20']").set('x1', '0.5')))+ self.assertFalse(changed(lambda b: ET.SubElement(b.find('plain'), 'text', x='1', y='1', size='1', layer='21').__setattr__('text', 'silk')))+ self.assertFalse(changed(lambda b: ET.SubElement(b.find('signals/signal'), 'wire', x1='1', y1='1', x2='2', y2='1', width='0', layer='19')))+ self.assertFalse(changed(lambda b: b.find("designrules/param[@name='layerSetup']").set('value', '(1*16)') if b.find('designrules/param') is not None else ET.SubElement(b, 'designrules')))++ def test_state_keeps_the_keys_the_ai_flow_reads(self):+ b = board(DEMO / 'evidence/boards/fusion-routing-routed.brd')+ st = r.state(b, {'boardId': 'B', 'documentName': 'fusion-routing', 'revision': r.board_revision(b)})+ validate(st, SCHEMA, SCHEMA)+ for k in ('segments', 'elements', 'airwireCount', 'remainingNets', 'segmentCount', 'viaCount'):+ self.assertIn(k, st)+ p = st['pads'][0]+ self.assertEqual(p['ref'] + '.' + p['name'], p['key'])+ self.assertEqual([p['x'], p['y']], [round(c, 6) for c in p['at']])+ v = st['vias'][0]+ self.assertEqual([v['position']['x'], v['position']['y']], v['at'])+ self.assertTrue(all(isinstance(n, str) for n in st['nets']))+ self.assertEqual(6, len(st['netRows']))+ self.assertEqual('eda-bridge-standard/board 0.1', st['model'])++ def test_planner_and_model_agree_on_every_pad_position(self):+ for f in DEMO.glob('**/*.brd'):+ b = board(f)+ _, pads, _, _ = r.objects(b)+ model, std = r.board_model(b)+ for key, p in pads.items():+ s = std[key]+ if s.get('customShape') or s.get('holeAt'):+ continue+ self.assertAlmostEqual(p['at'][0], s['x'], 5, (f.name, key))+ self.assertAlmostEqual(p['at'][1], s['y'], 5, (f.name, key))++ def test_layer_stack_from_the_layer_stack_manager(self):+ st = r.layer_stack(board(BQ))+ validate(st, COMPANIONS['$defs']['stack'], COMPANIONS)+ self.assertEqual(4, st['copperLayers'])+ self.assertEqual(['F.Cu', 'In1.Cu', 'In2.Cu', 'B.Cu'], [l['name'] for l in st['layers']])+ top = st['layers'][0]+ self.assertEqual((35, 200, 4500), (top['copperUm'], top['dielectricUm'], top['dk1000']))+ self.assertEqual(1065, st['layers'][1]['dielectricUm'])+ self.assertNotIn('dielectricUm', st['layers'][-1])+ self.assertAlmostEqual(35 + 200 + 17.5 + 1065 + 17.5 + 200 + 35, st['totalUm'], 3)++ def test_layer_stack_from_the_rules_says_what_it_lacks(self):+ st = r.layer_stack(board(DEMO / 'fixture/fusion-routing.brd'))+ validate(st, COMPANIONS['$defs']['stack'], COMPANIONS)+ self.assertEqual(['F.Cu', 'B.Cu'], [l['name'] for l in st['layers']])+ self.assertTrue(st['notes'])++ def test_no_name_is_shared_with_the_routing_scripts(self):+ # the three files run as ONE script inside Fusion: a shared top-level name silently+ # replaces the other file's function+ import ast+ def names(f):+ body = ast.parse((ROOT / 'rust/bridge/src' / f).read_text()).body+ return {n.name for n in body if isinstance(n, (ast.FunctionDef, ast.ClassDef))} | \+ {t.id for n in body if isinstance(n, ast.Assign) for t in n.targets if isinstance(t, ast.Name)}+ self.assertEqual(set(), (names('routing_script.py') | names('routing_planner.py')) & names('board_model.py'))++ def test_pours_model_and_netlist(self):+ zs = r.zones(board(BQ))+ validate({'zones': zs}, COMPANIONS['$defs']['pours'], COMPANIONS)+ pgnd = [z for z in zs if z['net'] == 'PGND' and z['layer'] == 'In1.Cu'][0]+ self.assertTrue(pgnd['filled'])+ self.assertGreater(pgnd['filledMm2'], 100)+ self.assertTrue(any(f['holes'] for f in pgnd['fills']))+ nl = r.netlist(board(DEMO / 'fixture/fusion-routing.brd'))+ validate(nl, COMPANIONS['$defs']['netlist'], COMPANIONS)+ self.assertEqual(13, sum(len(v) for v in nl.values()))+ self.assertTrue(all('.' in m for v in nl.values() for m in v))++++class BoardReadTests(unittest.TestCase):+ """The standard's read verbs served from the same export (rd_* in board_model.py)."""+ root = ET.parse(BQ).getroot()+ b = root.find('drawing/board')++ def test_component_data_and_pins(self):+ one = r.rd_component_data(self.root, self.b, {'designator': 'IC1'})['component']+ self.assertEqual(('BQ25792RQMR', 'top', 'F.Cu', 35.2, 13.2), (one['pattern'], one['side'], one['layer'], one['xMm'], one['yMm']))+ self.assertGreater(one['pads'], 20)+ # the box holds every pad (IC1's documentation outline makes it 6.5 mm for a 4 mm chip)+ x0, y0, x1, y1 = one['box']+ for p in r.rd_component_pins(self.root, self.b, {'designator': 'IC1'})['pins']:+ self.assertTrue(x0 <= p['x'] <= x1 and y0 <= p['y'] <= y1, p)+ self.assertEqual('BQ25792RQMR', one['partNumber'])+ everything = r.rd_component_data(self.root, self.b, {})+ self.assertEqual(109, everything['count'])+ self.assertEqual(['bottom'], [c['side'] for c in everything['components'] if c['designator'] == 'U$5'])+ pins = r.rd_component_pins(self.root, self.b, {'designator': 'Q1'})+ self.assertEqual(['1', '2', '3', '4', '5', '6', '7', '8', '9', 'P$1'], [p['name'] for p in pins['pins']][:10])+ with self.assertRaises(r.RoutingError) as e:+ r.rd_component_pins(self.root, self.b, {'designator': 'NOPE'})+ self.assertEqual('designator_not_found', e.exception.code)++ def test_nets_connections_and_designators(self):+ nets = r.rd_list_nets(self.root, self.b, {})+ self.assertEqual(50, nets['count'])+ pgnd = [n for n in nets['nets'] if n['name'] == 'PGND'][0]+ self.assertEqual(4, pgnd['pours'])+ nc = r.rd_net_connections(self.root, self.b, {'net': '!int'})+ self.assertEqual({'IC1.21', 'MP9.P$1', 'R19.2'}, {p['pad'] for p in nc['pads']})+ self.assertGreater(nc['nets'][0]['airlineMm'], 0)+ self.assertEqual(3.0, round(r.airline([(0, 0), (1, 0), (1, 2)]), 6))+ d = r.rd_list_designators(self.root, self.b, {})['designators']+ self.assertEqual(109, len(d))+ self.assertLess(d.index('C2'), d.index('C11')) # natural order++ def test_layers(self):+ cu = r.rd_copper_layers(self.root, self.b, {})+ self.assertEqual(['F.Cu', 'In1.Cu', 'In2.Cu', 'B.Cu'], [l['name'] for l in cu['layers']])+ self.assertTrue(all(l['primitives'] > 0 for l in cu['layers']))+ ls = r.rd_layers(self.root, self.b, {})+ kinds = {l['name']: l['kind'] for l in ls['layers']}+ self.assertEqual('copper', kinds['In2.Cu'])+ self.assertEqual('silkscreen', kinds['SilkscreenTop']) # Fusion's own layer names+ self.assertNotIn('In3.Cu', kinds) # not in this board's stack+ used = r.rd_layers_in_use(self.root, self.b, {})+ self.assertEqual({'F.Cu', 'In1.Cu', 'In2.Cu', 'B.Cu'}, {l['name'] for l in used['copperLayersInUse']})++ def test_rules_classes_and_silk(self):+ rr = r.rd_read_rules(self.root, self.b, {})+ self.assertTrue(rr['ruleSet'].endswith('.edru'))+ self.assertIn('Teardrop', {x['kind'] for x in rr['rules']})+ self.assertEqual('0.1mm', rr['params']['mdWireWire'])+ by = r.rd_rules_by_kind(self.root, self.b, {})['rulesByKind']+ widths = by['Minimum Copper Width']+ self.assertEqual(sorted(x['priority'] for x in widths), [x['priority'] for x in widths])+ nc = r.rd_net_classes(self.root, self.b, {})+ self.assertEqual(['default', 'pwr'], [c['name'] for c in nc['classes']])+ self.assertEqual(0.09, nc['classes'][1]['widthMm'])+ silk = r.rd_silk_state(self.root, self.b, {})+ ic1 = [c for c in silk['components'] if c['ref'] == 'IC1'][0]+ self.assertEqual('IC1', ic1['name']['text'])+ self.assertEqual('BQ25792RQMR', ic1['comment']['text'])+ self.assertIsNotNone(ic1['body'])+ self.assertTrue(all(c['name']['text'] == c['ref'] for c in silk['components']))++if __name__ == '__main__':+ unittest.main()
tests/test_routing.py+41@@ -8,6 +8,7 @@ ROOT=Path(__file__).resolve().parents[1] spec=importlib.util.spec_from_file_location('routing',ROOT/'rust/bridge/src/routing_script.py') r=importlib.util.module_from_spec(spec);spec.loader.exec_module(r) exec((ROOT/'rust/bridge/src/routing_planner.py').read_text(),r.__dict__)+exec((ROOT/'rust/bridge/src/board_model.py').read_text(),r.__dict__) class RoutingTests(unittest.TestCase): def setUp(self):@@ -128,4 +129,44 @@ class RoutingTests(unittest.TestCase): self.assertEqual('board_mismatch',res['errorCode']);self.assertFalse(res['mutationAttempted']) finally:r.snapshot=saved +class NativeDrcTests(unittest.TestCase):+ def test_severity_follows_fusions_constants(self):+ class App:+ def executeTextCommand(self, cmd):+ if cmd.startswith("Electron.run RUN '"):+ ulp = Path(cmd[len("Electron.run RUN '"):-2]).read_text()+ out = ulp.split('output("', 1)[1].split('"', 1)[0]+ # type 1 warning, 2 error, 3 consistency; state 2 approved; code 21 airwire+ Path(out).write_text('CHECKED\t1\n' + '\n'.join('ERROR\t%d\t%d\t%d\t1\t320000\t640000\t%s' % row for row in+ [(23, 1, 0, 'Wire Stub'), (19, 2, 0, 'Overlap'), (19, 2, 2, 'Overlap'), (5, 3, 0, 'Consistency'), (21, 2, 0, 'Airwire')]))+ rep = r.native_drc(App())+ self.assertEqual((3, 1, 1), (rep['errorCount'], rep['warningCount'], rep['airwireCount']))+ self.assertEqual(['warning', 'error', 'error', 'error', 'error'], [e['severity'] for e in rep['errors']])+ self.assertEqual([False, False, True, False, False], [e['approved'] for e in rep['errors']])+ self.assertEqual([1.0, 2.0], [rep['errors'][0]['x'], rep['errors'][0]['y']])+++class LayerArgTests(unittest.TestCase):+ def test_standard_layer_names_are_accepted(self):+ self.assertEqual([1, 16, 1, 16], [r.eagle_layer(v) for v in ('F.Cu', 'b.cu', 1, '16')])+ for v in (True, 'Top', None, 2.5):+ with self.assertRaises(r.RoutingError):+ r.eagle_layer(v)++ def test_route_net_plans_with_layer_names(self):+ b = ET.parse(ROOT/'demo/routing/fixture/fusion-routing.brd').find('drawing/board')+ by_num = r.plan(b, {'net': 'NET_1', 'layer': 1, 'points': [{'pad': 'J1.1'}, [14, 7], [40, 7], {'pad': 'J2.1'}]})+ by_name = r.plan(b, {'net': 'NET_1', 'layer': 'F.Cu', 'points': [{'pad': 'J1.1'}, [14, 7], [40, 7], {'pad': 'J2.1'}]})+ self.assertEqual(by_num['items'], by_name['items'])+++class NetNameTests(unittest.TestCase):+ def test_active_low_nets_are_accepted(self):+ for n in ('!CE', '!INT', 'N$12', 'VBUS', 'A/B.C:D+'):+ self.assertEqual(n, r.identifier(n))+ for n in ("A'B", 'A;B', 'A*', 'A?', 'A B', ''):+ with self.assertRaises(r.RoutingError):+ r.identifier(n)++ if __name__=='__main__':unittest.main()