app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
← Commit history
Update 17 file(s)
17 files changed
+2657−15
BRIDGE_VERSION+1−1CHANGELOG.md+33README.md+23−2bridge.json+51−2contracts/describe.json+569dashboard/Cargo.lock+1−1dashboard/Cargo.toml+1−1describe.py+84install.sh+1−1package.json+1−1page.json+2−2rust/Cargo.toml+1−1rust/bridge/src/main.rs+3−2rust/bridge/src/sch.rs+154rust/bridge/src/sch_script.py+1590tests/run_verb_tests.py+14−1tests/test_sch.py+128BRIDGE_VERSION+1−1@@ -1 +1 @@-1.12.1+1.12.2
CHANGELOG.md+33@@ -3,6 +3,39 @@ 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.2 (2026-10-27, insiders)++The EDA Bridge Standard's schematic verbs (`sch_*`, agreed in standard 0.2.0): all 21, each read back on a live+design and undone on a mismatch. 243 verbs in the catalog.++- Reads, from Fusion's own export of the design's schematic whichever tab is in front (the tab is put back), or+ from a .sch/.fsch on the Fusion machine: `fusion_sch_items` (wires, buses, entries, labels, power ports,+ junctions, texts, module instances, each with an id built from its kind and position, since Fusion stores none),+ `fusion_sch_pins` (connection point, pad numbers, type, side, net, wires and pins on its segment),+ `fusion_sch_trace_point`, `fusion_sch_page`, `fusion_sch_hierarchy` (modules), `fusion_sch_sheet_pins`+ (module ports). Pin positions: every connected pin of BQ25792 and the routing fixture (274) sits exactly on a+ wire end of its own net segment. Fusion's ULP pin coordinates count the part's offset twice, so they are not+ the reference.+- Edits: `fusion_sch_text`, `fusion_sch_junction`, `fusion_sch_delete_wire` (junctions left joining fewer than+ three items, pins counted, go too), `fusion_sch_connect_pins`, `fusion_sch_split_wire`, `fusion_sch_bus`,+ `fusion_sch_bus_entry`, `fusion_sch_connect_to_bus`, `fusion_sch_edit_label`, `fusion_sch_move_region`,+ `fusion_sch_fix_connectivity`, `fusion_sch_reset_fields`, `fusion_sch_create`, and `fusion_render_schematic`+ (EXPORT IMAGE, with pixel baselines: drift, changed region in mm, a diff image).+- Refused with the reason: setting the page (a frame part and design attributes Fusion's commands do not reach),+ sheet-pin edits (module ports), `fusion_sch_update_from_library` (Fusion's Library Manager asks per part),+ renumbering sheets, `junction:false` on a split.+- What Fusion taught (in sch_script.py): SET CONFIRM only answers a question when it is on the same command line;+ a moved pin landing on another net makes Fusion ask to merge them, so moves answer No and refuse with+ `would_join_nets`; a group's outline snaps to the sheet grid (`box_edge_off_grid`); a scripted group MOVE leaves+ parts and global labels behind, so they are moved by name; a junction click on its centre takes a wire end;+ TEXT uses the current layer; edits run at a 0.001 mm grid and put the user's sheet and tab back.+- Undo never takes the user's own work: nothing is undone when Fusion made no change; steps are undone one short+ of the count and then one at a time, and if the starting revision never returns every step is redone and the+ edit reported as left in place.+- ab budgets: the routing and schematic edit verbs get 120 to 300 s instead of the 60 s default.+- Known: once, moving a wire end onto another net raised a Fusion "Error" box (closed by the dialog backstop; the+ verb timed out); not reproduced.+ ## 1.12.1 (2026-10-27, insiders) The EDA Bridge Standard 0.1.1 settlements (standard issue #2).
README.md+23−2@@ -527,7 +527,7 @@ The AI does the whole setup - the user never gets handed a download link: ## Full verb catalog All verbs return a rich `_hint`. `fusion_describe '{}'` is the machine-readable source of truth; this table is generated from it.-_222 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`.+_243 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`. **Lifecycle and readiness** @@ -607,6 +607,7 @@ _222 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`. - `fusion_plan_library_import` - PRE-FLIGHT for a library import: does NO work and changes nothing. - `fusion_preflight_lbr` - PRE-ANALYZE a .lbr: will Fusion open it? - `fusion_prove_result` - POLL a background run started by fusion_create_library or the detached fusion_build_library_3d.+- `fusion_sch_update_from_library` - Refused with unsupported, naming the libraries the design embeds: Fusion updates parts from their libraries in its Library Manager, which asks about each part... - `fusion_show_3d_package` - Open the 3D CHIP view (Package3DEnvironment) for a library package - and the RELIABLE way to get a 3D model into a library: pass stepPath and it is attached... - `fusion_show_attributes` - Show a component's METADATA, as data and on screen. - `fusion_show_footprint` - Open a FOOTPRINT (package) in Fusion's library editor.@@ -637,6 +638,7 @@ _222 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`. - `fusion_open_electronics` - Read whether an ELECTRONICS editor (schematic, board or library) is the active workspace, so you know if Electron.* text commands and fusion_electron_run will... - `fusion_open_schematic` - Open a LOCAL legacy EAGLE .sch in Fusion's Schematic Editor (dialog-free Document.newDesignFromLocal; a background open). - `fusion_plan_route` - Calculate a read-only, bounded orthogonal A* path between two pads on a live two-layer board.+- `fusion_render_schematic` - Render a schematic sheet to a PNG without opening a window, through Fusion's own EXPORT IMAGE, and keep visual baselines: baseline set stores the render... - `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 as the EDA Bridge Standard's board model, from a fresh Fusion export in the background: millimetres in Fusion's board frame, copper layers...@@ -777,6 +779,25 @@ _222 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`. - `fusion_run_erc` - Fusion's own ERC on the open board's schematic: the design's schematic tab is brought forward for the check and the board's put back; every message is read... - `fusion_run_script` - The EDA Bridge Standard's name for fusion_run_modeling_script: the same verb, same arguments and reply (marked aliasOf). - `fusion_save` - The EDA Bridge Standard's name for fusion_save_document: the same verb, same arguments and reply (marked aliasOf).+- `fusion_sch_bus` - Draw a bus as a polyline, optionally named by a bus label at its first point (D[0..7], or a group such as {SDA SCL}).+- `fusion_sch_bus_entry` - Add 45-degree bus entries: the short diagonal from a member wire's end onto a bus.+- `fusion_sch_connect_pins` - Wire pins to pins by Ref.Pin (matched by pad number, then pin name): one L-shaped route per pair (first leg along the from pin, or first:h|v, through via...+- `fusion_sch_connect_to_bus` - Fan pins onto a bus: per pin, a wire from the pin to beside the bus, a 45-degree entry onto it and a member label, the net named for the member (members in pin...+- `fusion_sch_create` - Add an empty sheet after the last one (Fusion numbers a schematic's sheets; it has no sheet files).+- `fusion_sch_delete_wire` - Delete schematic wires by id, by their two ends, or every wire through a point; junctions left joining fewer than three items are removed too.+- `fusion_sch_edit_label` - Edit net labels by id, point or a whole net: move to a point or by a delta, set the rotation, or rename (a label shows its net's name, so a rename renames the...+- `fusion_sch_fix_connectivity` - Find wire ends that almost touch a pin, label or other wire end (within toleranceMm) and, with dryRun:false, move each onto it.+- `fusion_sch_hierarchy` - The design's sheet tree: the root sheets in order, and under each the module instances placed on it (Fusion's sheet symbols), each with its module's ports as...+- `fusion_sch_items` - List one schematic sheet's wiring items with ids and positions (mm, y up): wires, buses, bus entries, net labels (local, global, hierarchical), power ports...+- `fusion_sch_junction` - Add junction dots at points, or remove dots by id or point.+- `fusion_sch_move_region` - Move everything inside a box by (dx, dy) as one group: parts, wires, labels, junctions and texts; wires on the pins of a moved part follow it, and wires...+- `fusion_sch_page` - A schematic sheet's page: the frame Fusion draws it in (paper size, orientation, rectangle) and the title block fields from the design's attributes.+- `fusion_sch_pins` - Every pin of the named parts (or all) on a schematic sheet: its connection point (rotation and mirror applied), symbol pin name, the pad number(s) it maps to...+- `fusion_sch_reset_fields` - Put parts' designator and value text (or other named fields) back where the library symbol places them, through each part's turn and mirror: position, rotation...+- `fusion_sch_sheet_pins` - A sheet symbol's pins: in Fusion a sheet symbol is a module instance and its pins are the module's ports.+- `fusion_sch_split_wire` - Split a schematic wire at a point into two wires with a junction dot at the cut (Fusion's way: a junction on a wire splits it), so a branch can end on a wire...+- `fusion_sch_text` - Add free text to a schematic sheet (a note, a title): size, angle and alignment, on the Info layer.+- `fusion_sch_trace_point` - Everything electrically joined at one point of a schematic sheet: the wires, junctions, labels and pins there, and each net they belong to in full (its pins... - `fusion_select_net` - Highlight one whole net on the live board (every pad, track, via and pour of it) and, with zoom, frame it. - `fusion_set_layer_visibility` - Show or hide one layer of the live 2D board (DISPLAY), read back from the board's own layer flags. - `fusion_set_outline` - Replace the board outline (Fusion's Dimension layer 20, its board edge in the CAM output) with a rectangle, rounded corners optional, or a polygon (mm).@@ -811,7 +832,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. 222 verbs -+two tiers can serve different versions. 243 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+51−2@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.12.1",+ "version": "1.12.2", "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.",@@ -185,6 +185,7 @@ "fusion_release_seat", "fusion_remove_items", "fusion_remove_pours",+ "fusion_render_schematic", "fusion_repour", "fusion_reset_workspace", "fusion_resolve_seat_dialog",@@ -202,6 +203,26 @@ "fusion_save", "fusion_save_document", "fusion_save_to_cloud",+ "fusion_sch_bus",+ "fusion_sch_bus_entry",+ "fusion_sch_connect_pins",+ "fusion_sch_connect_to_bus",+ "fusion_sch_create",+ "fusion_sch_delete_wire",+ "fusion_sch_edit_label",+ "fusion_sch_fix_connectivity",+ "fusion_sch_hierarchy",+ "fusion_sch_items",+ "fusion_sch_junction",+ "fusion_sch_move_region",+ "fusion_sch_page",+ "fusion_sch_pins",+ "fusion_sch_reset_fields",+ "fusion_sch_sheet_pins",+ "fusion_sch_split_wire",+ "fusion_sch_text",+ "fusion_sch_trace_point",+ "fusion_sch_update_from_library", "fusion_schematic", "fusion_schematic_plan", "fusion_schematic_run",@@ -347,7 +368,35 @@ "signin": 150, "prewarm": 120, "aps_search": 120,- "aps_recent": 120+ "aps_recent": 120,+ "autoroute": 300,+ "set_track_width": 180,+ "remove_items": 150,+ "set_outline": 150,+ "board_shape": 150,+ "apply_silkscreen": 180,+ "run_erc": 120,+ "run_drc": 120,+ "sch_text": 150,+ "sch_junction": 150,+ "sch_delete_wire": 150,+ "sch_connect_pins": 180,+ "sch_split_wire": 150,+ "sch_bus": 150,+ "sch_edit_label": 150,+ "sch_move_region": 180,+ "sch_fix_connectivity": 150,+ "sch_items": 90,+ "sch_pins": 90,+ "sch_trace_point": 90,+ "sch_page": 90,+ "sch_hierarchy": 90,+ "sch_bus_entry": 150,+ "sch_connect_to_bus": 180,+ "render_schematic": 180,+ "sch_reset_fields": 150,+ "sch_create": 150,+ "sch_sheet_pins": 90 }, "prefixes": [ {
contracts/describe.json+569@@ -3181,6 +3181,40 @@ "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": {+ "baseline": "compare"+ },+ "input": {+ "baseline": "set|compare",+ "drawingSheet": "true (Fusion draws the frame as a part; false is refused)",+ "expectDocument": "optional",+ "name": "file stem, default schematic (a baseline is per name and sheet)",+ "outputDir": "folder on the Fusion machine (default %TEMP%/adom-schematic-renders)",+ "sheet": "int, default 1",+ "threshold": "percent of pixels, default 0.5 (compare)",+ "width": "int px, default 2000 (dpi is chosen from the drawn extent)"+ },+ "longRunning": false,+ "name": "fusion_render_schematic",+ "output": {+ "baseline": "path",+ "changedRegionMm": "[x1, y1, x2, y2] | null (compare)",+ "diffPng": "path | null (compare)",+ "dpi": "int",+ "driftPercent": "number (compare)",+ "extentMm": "[x0, y0, x1, y1]",+ "height": "int",+ "pass": "bool (compare)",+ "png": "path",+ "rendererChanged": "bool (compare)",+ "sizeChanged": "bool (compare)",+ "width": "int"+ },+ "statusVerb": null,+ "summary": "Render a schematic sheet to a PNG without opening a window, through Fusion's own EXPORT IMAGE, and keep visual baselines: baseline set stores the render, baseline compare renders at the baseline's width and reports the pixel drift, the changed region (mm on the sheet) and a diff image with the changed pixels in red.",+ "timeoutSeconds": 180+ }, { "example": { "expectDocument": "fusion-routing"@@ -3524,6 +3558,541 @@ "summary": "First save of the ACTIVE document to Autodesk cloud storage with an explicit name and destination, dialog-free. On an electronics design it saves the DESIGN (.fprj with its .fsch and .fbrd, linked; the 3D PCB beside them), whichever of its tabs is in front, and puts the same kind of tab back in front. A design that is already saved is REFUSED (already_saved_use_save_document: a Save As by name forks the board into an unlinked .fbrd) unless asCopy:true. Missing folders on folderPath are created; a name already in the folder is refused unless overwrite:true. A mechanical document is saved as before.", "timeoutSeconds": 240 },+ {+ "example": {+ "label": "D[0..7]",+ "points": [+ [+ 20,+ 100+ ],+ [+ 80,+ 100+ ]+ ]+ },+ "input": {+ "dryRun": "bool, default false: answer the plan and change nothing",+ "expectDocument": "optional: the design's name",+ "expectedRevision": "the revision you last read (refused with stale_schematic otherwise)",+ "label": "str",+ "points": "[[x, y], ...] (required)",+ "sheet": "int, default 1"+ },+ "longRunning": false,+ "name": "fusion_sch_bus",+ "output": {+ "ids": "[bus id]",+ "label": "str|null",+ "revision": "str"+ },+ "statusVerb": null,+ "summary": "Draw a bus as a polyline, optionally named by a bus label at its first point (D[0..7], or a group such as {SDA SCL}). Read back, or undone.",+ "timeoutSeconds": 120+ },+ {+ "example": {+ "entries": [+ {+ "at": [+ 40,+ 102.54+ ],+ "direction": "down-right"+ }+ ]+ },+ "input": {+ "dryRun": "bool, default false",+ "entries": "[{at: [x, y] (the wire end), direction?: down-right|up-right|down-left|up-left, member?: str, sizeMm?: 2.54}] (required)",+ "expectDocument": "optional",+ "expectedRevision": "str",+ "sheet": "int, default 1"+ },+ "longRunning": false,+ "name": "fusion_sch_bus_entry",+ "output": {+ "added": "[{id, at, end, onBus, member}]",+ "revision": "str"+ },+ "statusVerb": null,+ "summary": "Add 45-degree bus entries: the short diagonal from a member wire's end onto a bus. In Fusion an entry is a net wire named for its bus member (drawn from the bus side, so no member menu opens); member defaults to the net of the wire ending at at. Refused with entry_off_bus when the entry misses the bus, not_a_member when the net is not in the bus. Read back, or undone.",+ "timeoutSeconds": 150+ },+ {+ "example": {+ "pairs": [+ {+ "from": "U1.1",+ "to": "R2.1"+ }+ ]+ },+ "input": {+ "dryRun": "bool, default false: answer the plan and change nothing",+ "expectDocument": "optional: the design's name",+ "expectedRevision": "the revision you last read (refused with stale_schematic otherwise)",+ "pairs": "[{from: \"U1.1\", to: \"R5.2\", first?: h|v, via?: [[x, y]]}] (required)",+ "sheet": "int, default 1"+ },+ "longRunning": false,+ "name": "fusion_sch_connect_pins",+ "output": {+ "failed": "[{from, to, error}]",+ "junctionsAdded": "[[x, y]]",+ "revision": "str",+ "routes": "[{from, to, points, net, merged}]",+ "wired": "int"+ },+ "statusVerb": null,+ "summary": "Wire pins to pins by Ref.Pin (matched by pad number, then pin name): one L-shaped route per pair (first leg along the from pin, or first:h|v, through via points), drawn as one net wire. Pins on two named nets join them (Fusion's 'Connect A and B?' is answered on the command line, and merged lists them). A route corner or end landing on another wire's middle gets a junction. Read back: each pair must end on one net, or everything is undone.",+ "timeoutSeconds": 120+ },+ {+ "example": {+ "pins": [+ "U1.1",+ "U1.2"+ ],+ "prefix": "D",+ "start": 0+ },+ "input": {+ "bus": "bus id or name (default the only bus)",+ "busLabel": "bool|str: also label the bus",+ "dryRun": "bool, default false",+ "entry": "up|down (vertical bus) or left|right (horizontal bus)",+ "expectDocument": "optional",+ "expectedRevision": "str",+ "members": "[str]",+ "pins": "[Ref.Pin] in member order (required)",+ "prefix": "str, default D",+ "sheet": "int, default 1",+ "start": "int, default 0"+ },+ "longRunning": false,+ "name": "fusion_sch_connect_to_bus",+ "output": {+ "busLabel": "str|null",+ "connected": "[{pin, member, wireId, entryId}]",+ "failed": "[{pin, error}]",+ "revision": "str"+ },+ "statusVerb": null,+ "summary": "Fan pins onto a bus: per pin, a wire from the pin to beside the bus, a 45-degree entry onto it and a member label, the net named for the member (members in pin order, or prefix plus start). Only an unconnected pin, or one already on that member net, is wired; one on another net is listed in failed (wiring it would join two nets). Read back: each pin on its member net, or undone.",+ "timeoutSeconds": 180+ },+ {+ "example": {},+ "input": {+ "dryRun": "bool, default false",+ "expectDocument": "optional",+ "sheet": "int: must be the next number (default it)"+ },+ "longRunning": false,+ "name": "fusion_sch_create",+ "output": {+ "created": "bool",+ "page": "int",+ "revision": "str",+ "sheet": "int",+ "sheetSymbol": "null"+ },+ "statusVerb": null,+ "summary": "Add an empty sheet after the last one (Fusion numbers a schematic's sheets; it has no sheet files). Page and child-sheet arguments are refused: Fusion sets a page through a frame part, and a child sheet is a module. Read back, or undone.",+ "timeoutSeconds": 150+ },+ {+ "example": {+ "ids": [+ "wire:40.64,127,40.64,132.08"+ ]+ },+ "input": {+ "at": "[x, y]",+ "dryRun": "bool, default false: answer the plan and change nothing",+ "expectDocument": "optional: the design's name",+ "expectedRevision": "the revision you last read (refused with stale_schematic otherwise)",+ "from": "[x, y]",+ "ids": "[wire id]",+ "sheet": "int, default 1",+ "to": "[x, y]"+ },+ "longRunning": false,+ "name": "fusion_sch_delete_wire",+ "output": {+ "deleted": "[wire id]",+ "junctionsRemoved": "[[x, y]]",+ "notFound": "[str]",+ "revision": "str"+ },+ "statusVerb": null,+ "summary": "Delete schematic wires by id, by their two ends, or every wire through a point; junctions left joining fewer than three items are removed too. Every other wire must read back unchanged, or the delete is undone.",+ "timeoutSeconds": 120+ },+ {+ "example": {+ "edits": [+ {+ "by": [+ 0,+ 2.54+ ],+ "net": "SDA"+ }+ ]+ },+ "input": {+ "dryRun": "bool, default false: answer the plan and change nothing",+ "edits": "[{id | at | net, moveTo?: [x, y], by?: [dx, dy], rotation?: deg, rename?: str}] (required)",+ "expectDocument": "optional: the design's name",+ "expectedRevision": "the revision you last read (refused with stale_schematic otherwise)",+ "sheet": "int, default 1"+ },+ "longRunning": false,+ "name": "fusion_sch_edit_label",+ "output": {+ "edited": "[{id, net, to, rotation, rename}]",+ "failed": "[{edit, error}]",+ "revision": "str"+ },+ "statusVerb": null,+ "summary": "Edit net labels by id, point or a whole net: move to a point or by a delta, set the rotation, or rename (a label shows its net's name, so a rename renames the label's wire segment; other segments of the old net keep their name; onto an existing name the two join, which Fusion asks about and the bridge answers on the command line). Read back, or undone.",+ "timeoutSeconds": 120+ },+ {+ "example": {+ "toleranceMm": 0.5+ },+ "input": {+ "dryRun": "bool, default TRUE",+ "expectDocument": "optional",+ "expectedRevision": "str",+ "sheet": "int, default 1",+ "toleranceMm": "default 0.5"+ },+ "longRunning": false,+ "name": "fusion_sch_fix_connectivity",+ "output": {+ "moves": "[{net, from, to, distanceMm}]",+ "revision": "str",+ "unresolved": "[{net, at, why}]"+ },+ "statusVerb": null,+ "summary": "Find wire ends that almost touch a pin, label or other wire end (within toleranceMm) and, with dryRun:false, move each onto it. dryRun defaults to true: it lists the moves and changes nothing.",+ "timeoutSeconds": 120+ },+ {+ "example": {},+ "input": {+ "expectDocument": "optional",+ "filePath": "optional .sch/.fsch"+ },+ "longRunning": false,+ "name": "fusion_sch_hierarchy",+ "output": {+ "duplicatePages": "[]",+ "modules": "[str]",+ "problems": "[str]",+ "renumbered": "false",+ "tree": "{name, sheets: [{page, description, children: [{name, module, path, at, sizeMm, pins, sheets}]}]}"+ },+ "statusVerb": null,+ "summary": "The design's sheet tree: the root sheets in order, and under each the module instances placed on it (Fusion's sheet symbols), each with its module's ports as pins and its own sheets, recursively. Read only; renumber is refused (Fusion numbers sheets by their order).",+ "timeoutSeconds": 60+ },+ {+ "example": {+ "kinds": [+ "wires",+ "labels"+ ],+ "sheet": 1+ },+ "input": {+ "expectDocument": "optional: the design's name",+ "filePath": "optional .sch/.fsch on the Fusion machine instead of the open design",+ "kinds": "[wires, buses, busEntries, labels, powerPorts, junctions, noConnects, texts, sheets] (default all)",+ "sheet": "int, default 1 (Fusion numbers a schematic's sheets; or the sheet's description)"+ },+ "longRunning": false,+ "name": "fusion_sch_items",+ "output": {+ "busEntries": "[{id, net, at, end}]",+ "buses": "[{id, bus, from, to}]",+ "counts": "{kind: int}",+ "junctions": "[{id, net, at}]",+ "labels": "[{id, text, kind, at, rotation}]",+ "noConnects": "[] (EAGLE marks an unused pin by its type, not a marker)",+ "powerPorts": "[{id, ref, net, at}]",+ "revision": "str",+ "sheetCount": "int",+ "sheets": "[{id, name, module, at, sizeMm}]",+ "texts": "[{id, text, at, sizeMm, rotation}]",+ "wires": "[{id, net, from, to}]"+ },+ "statusVerb": null,+ "summary": "List one schematic sheet's wiring items with ids and positions (mm, y up): wires, buses, bus entries, net labels (local, global, hierarchical), power ports, junctions, texts and module instances (Fusion's sheet symbols). Read from Fusion's own export of the design's schematic, whichever tab is in front (the tab is put back), or from a .sch/.fsch on the Fusion machine. Fusion stores no ids, so each id is the item's kind and position. Read only.",+ "timeoutSeconds": 60+ },+ {+ "example": {+ "points": [+ [+ 40.64,+ 132.08+ ]+ ]+ },+ "input": {+ "dryRun": "bool, default false: answer the plan and change nothing",+ "expectDocument": "optional: the design's name",+ "expectedRevision": "the revision you last read (refused with stale_schematic otherwise)",+ "force": "bool, default false",+ "points": "[[x, y]]",+ "remove": "[id | [x, y]]",+ "sheet": "int, default 1"+ },+ "longRunning": false,+ "name": "fusion_sch_junction",+ "output": {+ "added": "[[x, y]]",+ "notNeeded": "[[x, y]]",+ "removed": "[[x, y]]",+ "revision": "str",+ "skipped": "[{at, why}]"+ },+ "statusVerb": null,+ "summary": "Add junction dots at points, or remove dots by id or point. A point no wire passes is skipped; one where fewer than three items meet is listed as notNeeded and left (force:true adds it anyway). Read back, or undone.",+ "timeoutSeconds": 120+ },+ {+ "example": {+ "box": [+ 0,+ 0,+ 50,+ 50+ ],+ "by": [+ 10,+ 0+ ]+ },+ "input": {+ "box": "[x1, y1, x2, y2] mm (required)",+ "by": "[dx, dy] mm (required)",+ "dryRun": "bool, default false: answer the plan and change nothing",+ "expectDocument": "optional: the design's name",+ "expectedRevision": "the revision you last read (refused with stale_schematic otherwise)",+ "sheet": "int, default 1"+ },+ "longRunning": false,+ "name": "fusion_sch_move_region",+ "output": {+ "boxUsed": "[x1, y1, x2, y2]: the box on the sheet grid",+ "moved": "{parts, by}",+ "revision": "str"+ },+ "statusVerb": null,+ "summary": "Move everything inside a box by (dx, dy) as one group: parts, wires, labels, junctions and texts; wires on the pins of a moved part follow it, and wires crossing the edge stretch. Fusion snaps the outline of a group to the sheet grid, so the box is snapped first (boxUsed) and refused with box_edge_off_grid, naming the parts, when the snapped box would take different parts than the asked one. Read back: parts, texts and labels moved exactly, nothing outside moved, every pin on the net it was on; otherwise undone.",+ "timeoutSeconds": 120+ },+ {+ "example": {},+ "input": {+ "expectDocument": "optional",+ "filePath": "optional .sch/.fsch",+ "sheet": "int, default 1"+ },+ "longRunning": false,+ "name": "fusion_sch_page",+ "output": {+ "changed": "false",+ "frame": "{ref, symbol, rect}|null",+ "heightMm": "number|null",+ "orientation": "landscape|portrait|null",+ "paper": "A4|A3|...|A|B|C|D|E|null",+ "titleBlock": "{title, rev, date, company, attributes}",+ "widthMm": "number|null"+ },+ "statusVerb": null,+ "summary": "A schematic sheet's page: the frame Fusion draws it in (paper size, orientation, rectangle) and the title block fields from the design's attributes. Reading only for now: setting the page is refused with unsupported.",+ "timeoutSeconds": 60+ },+ {+ "example": {+ "references": [+ "U1"+ ]+ },+ "input": {+ "connections": "bool, default true",+ "expectDocument": "optional",+ "filePath": "optional .sch/.fsch",+ "references": "[ref] (default every part)",+ "sheet": "int, default 1"+ },+ "longRunning": false,+ "name": "fusion_sch_pins",+ "output": {+ "count": "int",+ "pins": "[{ref, gate, pin, number, at, side, electrical, net, wires, labels, connectedPins}]",+ "revision": "str"+ },+ "statusVerb": null,+ "summary": "Every pin of the named parts (or all) on a schematic sheet: its connection point (rotation and mirror applied), symbol pin name, the pad number(s) it maps to, electrical type, the side it leaves the body, its net, and the wires, labels and pins on its wire segment. Read only.",+ "timeoutSeconds": 60+ },+ {+ "example": {+ "references": [+ "R5",+ "C3"+ ]+ },+ "input": {+ "all": "bool: every part on the sheet",+ "dryRun": "bool, default false",+ "expectDocument": "optional",+ "expectedRevision": "str",+ "fields": "[designator, value] by default; other attribute names as Fusion names them",+ "references": "[ref]",+ "sheet": "int, default 1"+ },+ "longRunning": false,+ "name": "fusion_sch_reset_fields",+ "output": {+ "reset": "[{ref, field}]",+ "revision": "str",+ "untouched": "[{ref, field, why}]"+ },+ "statusVerb": null,+ "summary": "Put parts' designator and value text (or other named fields) back where the library symbol places them, through each part's turn and mirror: position, rotation and alignment; size and visibility stay. A field that is not smashed is already there and is listed as untouched. Read back, or undone.",+ "timeoutSeconds": 150+ },+ {+ "example": {+ "validate": true+ },+ "input": {+ "action": "list (others refused)",+ "expectDocument": "optional",+ "filePath": "optional .sch/.fsch",+ "sheet": "int, default 1",+ "sheetSymbol": "module instance name (default every one)",+ "validate": "bool"+ },+ "longRunning": false,+ "name": "fusion_sch_sheet_pins",+ "output": {+ "added": "[]",+ "pins": "[{id, sheetSymbol, name, kind, side, coord, connected}]",+ "problems": "[{sheetSymbol, pin, problem}]"+ },+ "statusVerb": null,+ "summary": "A sheet symbol's pins: in Fusion a sheet symbol is a module instance and its pins are the module's ports. Lists them with kind, side and whether a net reaches each; validate lists the unwired ones. Adding, renaming or deleting is refused (that is the module's PORT edit).",+ "timeoutSeconds": 90+ },+ {+ "example": {+ "at": [+ 30,+ 132.08+ ]+ },+ "input": {+ "at": "[x, y] (the cut; default the middle of id)",+ "dryRun": "bool, default false: answer the plan and change nothing",+ "expectDocument": "optional: the design's name",+ "expectedRevision": "the revision you last read (refused with stale_schematic otherwise)",+ "id": "wire id",+ "sheet": "int, default 1"+ },+ "longRunning": false,+ "name": "fusion_sch_split_wire",+ "output": {+ "ids": "[two wire ids]",+ "junction": "id",+ "revision": "str"+ },+ "statusVerb": null,+ "summary": "Split a schematic wire at a point into two wires with a junction dot at the cut (Fusion's way: a junction on a wire splits it), so a branch can end on a wire end. junction:false is refused.",+ "timeoutSeconds": 120+ },+ {+ "example": {+ "texts": [+ {+ "at": [+ 20,+ 150+ ],+ "text": "Power input"+ }+ ]+ },+ "input": {+ "dryRun": "bool, default false: answer the plan and change nothing",+ "expectDocument": "optional: the design's name",+ "expectedRevision": "the revision you last read (refused with stale_schematic otherwise)",+ "sheet": "int, default 1",+ "texts": "[{text, at: [x, y], sizeMm?: 1.778, angle?: 0, align?: bottom-left|center|...}] (required)"+ },+ "longRunning": false,+ "name": "fusion_sch_text",+ "output": {+ "added": "int",+ "revision": "str",+ "texts": "[{id, text, at}]"+ },+ "statusVerb": null,+ "summary": "Add free text to a schematic sheet (a note, a title): size, angle and alignment, on the Info layer. Read back exactly, or undone.",+ "timeoutSeconds": 120+ },+ {+ "example": {+ "at": [+ 40.64,+ 132.08+ ]+ },+ "input": {+ "at": "[x, y] mm (required)",+ "expectDocument": "optional",+ "filePath": "optional .sch/.fsch",+ "sheet": "int, default 1",+ "toleranceMm": "default 0.01"+ },+ "longRunning": false,+ "name": "fusion_sch_trace_point",+ "output": {+ "crossingWithoutJunction": "bool",+ "items": "[{kind, id, net}]",+ "nearest": "{kind, id, net, distanceMm} | null",+ "nets": "[{name, bus, pins, sheets}]"+ },+ "statusVerb": null,+ "summary": "Everything electrically joined at one point of a schematic sheet: the wires, junctions, labels and pins there, and each net they belong to in full (its pins and the sheets it is on). crossingWithoutJunction is true when wires of two nets meet there without connecting. Nothing there gives the nearest item. Read only.",+ "timeoutSeconds": 60+ },+ {+ "example": {},+ "input": {+ "expectDocument": "optional",+ "sheet": "int, default 1"+ },+ "longRunning": false,+ "name": "fusion_sch_update_from_library",+ "output": {+ "error": "str",+ "errorCode": "unsupported"+ },+ "statusVerb": null,+ "summary": "Refused with unsupported, naming the libraries the design embeds: Fusion updates parts from their libraries in its Library Manager, which asks about each part, so there is no command the bridge can answer up front.",+ "timeoutSeconds": 60+ }, { "example": { "action": "open",
dashboard/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "fusion-dashboard"-version = "1.12.1"+version = "1.12.2" dependencies = [ "serde_json", ]
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.12.1"+version = "1.12.2" edition = "2021" [dependencies]
describe.py+84@@ -115,6 +115,90 @@ _T = [ {"nets": "[str] (default: every net with unconnected copper)", "lockPreroutes": "bool, default true (Fusion's autorouter always keeps existing tracks)", "drc": "bool, default true", "expectedRevision": "str"}, {"success": "bool", "nets": "[str]", "routed": "[str]", "unrouted": "[str]", "added": "{tracks, vias}", "before": "{unconnected, errors}", "after": "{unconnected, errors}", "revision": "str"}, 600, None, True, {"nets": ["SDA", "SCL"]}),+ ("fusion_sch_items", "List one schematic sheet's wiring items with ids and positions (mm, y up): wires, buses, bus entries, net labels (local, global, hierarchical), power ports, junctions, texts and module instances (Fusion's sheet symbols). Read from Fusion's own export of the design's schematic, whichever tab is in front (the tab is put back), or from a .sch/.fsch on the Fusion machine. Fusion stores no ids, so each id is the item's kind and position. Read only.",+ {"sheet": "int, default 1 (Fusion numbers a schematic's sheets; or the sheet's description)", "kinds": "[wires, buses, busEntries, labels, powerPorts, junctions, noConnects, texts, sheets] (default all)", "expectDocument": "optional: the design's name", "filePath": "optional .sch/.fsch on the Fusion machine instead of the open design"},+ {"wires": "[{id, net, from, to}]", "buses": "[{id, bus, from, to}]", "busEntries": "[{id, net, at, end}]", "labels": "[{id, text, kind, at, rotation}]", "powerPorts": "[{id, ref, net, at}]", "junctions": "[{id, net, at}]", "noConnects": "[] (EAGLE marks an unused pin by its type, not a marker)", "texts": "[{id, text, at, sizeMm, rotation}]", "sheets": "[{id, name, module, at, sizeMm}]", "counts": "{kind: int}", "revision": "str", "sheetCount": "int"},+ 60, None, False, {"sheet": 1, "kinds": ["wires", "labels"]}),+ ("fusion_sch_pins", "Every pin of the named parts (or all) on a schematic sheet: its connection point (rotation and mirror applied), symbol pin name, the pad number(s) it maps to, electrical type, the side it leaves the body, its net, and the wires, labels and pins on its wire segment. Read only.",+ {"sheet": "int, default 1", "references": "[ref] (default every part)", "connections": "bool, default true", "expectDocument": "optional", "filePath": "optional .sch/.fsch"},+ {"pins": "[{ref, gate, pin, number, at, side, electrical, net, wires, labels, connectedPins}]", "count": "int", "revision": "str"},+ 60, None, False, {"references": ["U1"]}),+ ("fusion_sch_trace_point", "Everything electrically joined at one point of a schematic sheet: the wires, junctions, labels and pins there, and each net they belong to in full (its pins and the sheets it is on). crossingWithoutJunction is true when wires of two nets meet there without connecting. Nothing there gives the nearest item. Read only.",+ {"at": "[x, y] mm (required)", "toleranceMm": "default 0.01", "sheet": "int, default 1", "expectDocument": "optional", "filePath": "optional .sch/.fsch"},+ {"items": "[{kind, id, net}]", "nets": "[{name, bus, pins, sheets}]", "crossingWithoutJunction": "bool", "nearest": "{kind, id, net, distanceMm} | null"},+ 60, None, False, {"at": [40.64, 132.08]}),+ ("fusion_sch_page", "A schematic sheet's page: the frame Fusion draws it in (paper size, orientation, rectangle) and the title block fields from the design's attributes. Reading only for now: setting the page is refused with unsupported.",+ {"sheet": "int, default 1", "expectDocument": "optional", "filePath": "optional .sch/.fsch"},+ {"paper": "A4|A3|...|A|B|C|D|E|null", "widthMm": "number|null", "heightMm": "number|null", "orientation": "landscape|portrait|null", "frame": "{ref, symbol, rect}|null", "titleBlock": "{title, rev, date, company, attributes}", "changed": "false"},+ 60, None, False, {}),+ ("fusion_sch_hierarchy", "The design's sheet tree: the root sheets in order, and under each the module instances placed on it (Fusion's sheet symbols), each with its module's ports as pins and its own sheets, recursively. Read only; renumber is refused (Fusion numbers sheets by their order).",+ {"expectDocument": "optional", "filePath": "optional .sch/.fsch"},+ {"tree": "{name, sheets: [{page, description, children: [{name, module, path, at, sizeMm, pins, sheets}]}]}", "modules": "[str]", "problems": "[str]", "duplicatePages": "[]", "renumbered": "false"},+ 60, None, False, {}),+ ('fusion_sch_text', 'Add free text to a schematic sheet (a note, a title): size, angle and alignment, on the Info layer. Read back exactly, or undone.',+ {"texts": "[{text, at: [x, y], sizeMm?: 1.778, angle?: 0, align?: bottom-left|center|...}] (required)", "sheet": "int, default 1", "expectedRevision": "the revision you last read (refused with stale_schematic otherwise)", "dryRun": "bool, default false: answer the plan and change nothing", "expectDocument": "optional: the design's name"},+ {"added": "int", "texts": "[{id, text, at}]", "revision": "str"},+ 120, None, False, {'texts': [{'text': 'Power input', 'at': [20, 150]}]}),+ ('fusion_sch_junction', 'Add junction dots at points, or remove dots by id or point. A point no wire passes is skipped; one where fewer than three items meet is listed as notNeeded and left (force:true adds it anyway). Read back, or undone.',+ {"points": "[[x, y]]", "remove": "[id | [x, y]]", "force": "bool, default false", "sheet": "int, default 1", "expectedRevision": "the revision you last read (refused with stale_schematic otherwise)", "dryRun": "bool, default false: answer the plan and change nothing", "expectDocument": "optional: the design's name"},+ {"added": "[[x, y]]", "removed": "[[x, y]]", "skipped": "[{at, why}]", "notNeeded": "[[x, y]]", "revision": "str"},+ 120, None, False, {'points': [[40.64, 132.08]]}),+ ('fusion_sch_delete_wire', 'Delete schematic wires by id, by their two ends, or every wire through a point; junctions left joining fewer than three items are removed too. Every other wire must read back unchanged, or the delete is undone.',+ {"ids": "[wire id]", "from": "[x, y]", "to": "[x, y]", "at": "[x, y]", "sheet": "int, default 1", "expectedRevision": "the revision you last read (refused with stale_schematic otherwise)", "dryRun": "bool, default false: answer the plan and change nothing", "expectDocument": "optional: the design's name"},+ {"deleted": "[wire id]", "junctionsRemoved": "[[x, y]]", "notFound": "[str]", "revision": "str"},+ 120, None, False, {'ids': ['wire:40.64,127,40.64,132.08']}),+ ('fusion_sch_connect_pins', "Wire pins to pins by Ref.Pin (matched by pad number, then pin name): one L-shaped route per pair (first leg along the from pin, or first:h|v, through via points), drawn as one net wire. Pins on two named nets join them (Fusion's 'Connect A and B?' is answered on the command line, and merged lists them). A route corner or end landing on another wire's middle gets a junction. Read back: each pair must end on one net, or everything is undone.",+ {"pairs": "[{from: \"U1.1\", to: \"R5.2\", first?: h|v, via?: [[x, y]]}] (required)", "sheet": "int, default 1", "expectedRevision": "the revision you last read (refused with stale_schematic otherwise)", "dryRun": "bool, default false: answer the plan and change nothing", "expectDocument": "optional: the design's name"},+ {"wired": "int", "routes": "[{from, to, points, net, merged}]", "junctionsAdded": "[[x, y]]", "failed": "[{from, to, error}]", "revision": "str"},+ 120, None, False, {'pairs': [{'from': 'U1.1', 'to': 'R2.1'}]}),+ ('fusion_sch_split_wire', "Split a schematic wire at a point into two wires with a junction dot at the cut (Fusion's way: a junction on a wire splits it), so a branch can end on a wire end. junction:false is refused.",+ {"at": "[x, y] (the cut; default the middle of id)", "id": "wire id", "sheet": "int, default 1", "expectedRevision": "the revision you last read (refused with stale_schematic otherwise)", "dryRun": "bool, default false: answer the plan and change nothing", "expectDocument": "optional: the design's name"},+ {"ids": "[two wire ids]", "junction": "id", "revision": "str"},+ 120, None, False, {'at': [30, 132.08]}),+ ('fusion_sch_bus', 'Draw a bus as a polyline, optionally named by a bus label at its first point (D[0..7], or a group such as {SDA SCL}). Read back, or undone.',+ {"points": "[[x, y], ...] (required)", "label": "str", "sheet": "int, default 1", "expectedRevision": "the revision you last read (refused with stale_schematic otherwise)", "dryRun": "bool, default false: answer the plan and change nothing", "expectDocument": "optional: the design's name"},+ {"ids": "[bus id]", "label": "str|null", "revision": "str"},+ 120, None, False, {'points': [[20, 100], [80, 100]], 'label': 'D[0..7]'}),+ ('fusion_sch_edit_label', "Edit net labels by id, point or a whole net: move to a point or by a delta, set the rotation, or rename (a label shows its net's name, so a rename renames the label's wire segment; other segments of the old net keep their name; onto an existing name the two join, which Fusion asks about and the bridge answers on the command line). Read back, or undone.",+ {"edits": "[{id | at | net, moveTo?: [x, y], by?: [dx, dy], rotation?: deg, rename?: str}] (required)", "sheet": "int, default 1", "expectedRevision": "the revision you last read (refused with stale_schematic otherwise)", "dryRun": "bool, default false: answer the plan and change nothing", "expectDocument": "optional: the design's name"},+ {"edited": "[{id, net, to, rotation, rename}]", "failed": "[{edit, error}]", "revision": "str"},+ 120, None, False, {'edits': [{'net': 'SDA', 'by': [0, 2.54]}]}),+ ('fusion_sch_move_region', 'Move everything inside a box by (dx, dy) as one group: parts, wires, labels, junctions and texts; wires on the pins of a moved part follow it, and wires crossing the edge stretch. Fusion snaps the outline of a group to the sheet grid, so the box is snapped first (boxUsed) and refused with box_edge_off_grid, naming the parts, when the snapped box would take different parts than the asked one. Read back: parts, texts and labels moved exactly, nothing outside moved, every pin on the net it was on; otherwise undone.',+ {"box": "[x1, y1, x2, y2] mm (required)", "by": "[dx, dy] mm (required)", "sheet": "int, default 1", "expectedRevision": "the revision you last read (refused with stale_schematic otherwise)", "dryRun": "bool, default false: answer the plan and change nothing", "expectDocument": "optional: the design's name"},+ {"moved": "{parts, by}", "boxUsed": "[x1, y1, x2, y2]: the box on the sheet grid", "revision": "str"},+ 120, None, False, {'box': [0, 0, 50, 50], 'by': [10, 0]}),+ ('fusion_sch_fix_connectivity', 'Find wire ends that almost touch a pin, label or other wire end (within toleranceMm) and, with dryRun:false, move each onto it. dryRun defaults to true: it lists the moves and changes nothing.',+ {"toleranceMm": "default 0.5", "sheet": "int, default 1", "dryRun": "bool, default TRUE", "expectedRevision": "str", "expectDocument": "optional"},+ {"moves": "[{net, from, to, distanceMm}]", "unresolved": "[{net, at, why}]", "revision": "str"},+ 120, None, False, {'toleranceMm': 0.5}),+ ("fusion_sch_bus_entry", "Add 45-degree bus entries: the short diagonal from a member wire's end onto a bus. In Fusion an entry is a net wire named for its bus member (drawn from the bus side, so no member menu opens); member defaults to the net of the wire ending at at. Refused with entry_off_bus when the entry misses the bus, not_a_member when the net is not in the bus. Read back, or undone.",+ {"entries": "[{at: [x, y] (the wire end), direction?: down-right|up-right|down-left|up-left, member?: str, sizeMm?: 2.54}] (required)", "sheet": "int, default 1", "expectedRevision": "str", "dryRun": "bool, default false", "expectDocument": "optional"},+ {"added": "[{id, at, end, onBus, member}]", "revision": "str"},+ 150, None, False, {"entries": [{"at": [40, 102.54], "direction": "down-right"}]}),+ ("fusion_sch_connect_to_bus", "Fan pins onto a bus: per pin, a wire from the pin to beside the bus, a 45-degree entry onto it and a member label, the net named for the member (members in pin order, or prefix plus start). Only an unconnected pin, or one already on that member net, is wired; one on another net is listed in failed (wiring it would join two nets). Read back: each pin on its member net, or undone.",+ {"pins": "[Ref.Pin] in member order (required)", "bus": "bus id or name (default the only bus)", "members": "[str]", "prefix": "str, default D", "start": "int, default 0", "entry": "up|down (vertical bus) or left|right (horizontal bus)", "busLabel": "bool|str: also label the bus", "sheet": "int, default 1", "expectedRevision": "str", "dryRun": "bool, default false", "expectDocument": "optional"},+ {"connected": "[{pin, member, wireId, entryId}]", "failed": "[{pin, error}]", "busLabel": "str|null", "revision": "str"},+ 180, None, False, {"pins": ["U1.1", "U1.2"], "prefix": "D", "start": 0}),+ ("fusion_render_schematic", "Render a schematic sheet to a PNG without opening a window, through Fusion's own EXPORT IMAGE, and keep visual baselines: baseline set stores the render, baseline compare renders at the baseline's width and reports the pixel drift, the changed region (mm on the sheet) and a diff image with the changed pixels in red.",+ {"sheet": "int, default 1", "width": "int px, default 2000 (dpi is chosen from the drawn extent)", "outputDir": "folder on the Fusion machine (default %TEMP%/adom-schematic-renders)", "name": "file stem, default schematic (a baseline is per name and sheet)", "baseline": "set|compare", "threshold": "percent of pixels, default 0.5 (compare)", "drawingSheet": "true (Fusion draws the frame as a part; false is refused)", "expectDocument": "optional"},+ {"png": "path", "width": "int", "height": "int", "dpi": "int", "extentMm": "[x0, y0, x1, y1]", "driftPercent": "number (compare)", "pass": "bool (compare)", "changedRegionMm": "[x1, y1, x2, y2] | null (compare)", "diffPng": "path | null (compare)", "rendererChanged": "bool (compare)", "sizeChanged": "bool (compare)", "baseline": "path"},+ 180, None, False, {"baseline": "compare"}),+ ("fusion_sch_reset_fields", "Put parts' designator and value text (or other named fields) back where the library symbol places them, through each part's turn and mirror: position, rotation and alignment; size and visibility stay. A field that is not smashed is already there and is listed as untouched. Read back, or undone.",+ {"references": "[ref]", "all": "bool: every part on the sheet", "fields": "[designator, value] by default; other attribute names as Fusion names them", "sheet": "int, default 1", "expectedRevision": "str", "dryRun": "bool, default false", "expectDocument": "optional"},+ {"reset": "[{ref, field}]", "untouched": "[{ref, field, why}]", "revision": "str"},+ 150, None, False, {"references": ["R5", "C3"]}),+ ("fusion_sch_create", "Add an empty sheet after the last one (Fusion numbers a schematic's sheets; it has no sheet files). Page and child-sheet arguments are refused: Fusion sets a page through a frame part, and a child sheet is a module. Read back, or undone.",+ {"sheet": "int: must be the next number (default it)", "dryRun": "bool, default false", "expectDocument": "optional"},+ {"sheet": "int", "page": "int", "sheetSymbol": "null", "created": "bool", "revision": "str"},+ 150, None, False, {}),+ ("fusion_sch_sheet_pins", "A sheet symbol's pins: in Fusion a sheet symbol is a module instance and its pins are the module's ports. Lists them with kind, side and whether a net reaches each; validate lists the unwired ones. Adding, renaming or deleting is refused (that is the module's PORT edit).",+ {"sheetSymbol": "module instance name (default every one)", "action": "list (others refused)", "validate": "bool", "sheet": "int, default 1", "expectDocument": "optional", "filePath": "optional .sch/.fsch"},+ {"pins": "[{id, sheetSymbol, name, kind, side, coord, connected}]", "added": "[]", "problems": "[{sheetSymbol, pin, problem}]"},+ 90, None, False, {"validate": True}),+ ("fusion_sch_update_from_library", "Refused with unsupported, naming the libraries the design embeds: Fusion updates parts from their libraries in its Library Manager, which asks about each part, so there is no command the bridge can answer up front.",+ {"sheet": "int, default 1", "expectDocument": "optional"},+ {"errorCode": "unsupported", "error": "str"},+ 60, None, False, {}), ("fusion_apply_silkscreen", "Land a silkscreen plan on the live board: designators (names) and values (comments) moved to their own location, sized, turned or hidden; free silkscreen texts and lines added (bottom ones mirrored); earlier free items removed by the raw handle fusion_silk_state gives. Parts not yet smashed are smashed first. Each pick is made with only the target's layer displayed, so a pad or wire is never taken instead, and the board's layer display is restored. Read back; if copper, parts or the outline moved, every command is undone. The edit side of a designator plan (silk_designators).", {"expectDocument":"required exact board name","expectedRevision":"optional: refuse with stale_board if the board moved on","names":"[{ref, x, y, size?, rotation?, show?}] the designator text's own location (lower left before rotation), mm","comments":"[same] for the value text","texts":"[{text, x, y, size?, rotation?, side?: top|bottom}]","lines":"[{x1, y1, x2, y2, width? (0.15), side?}]","remove":"{texts:[raw], lines:[raw]} from fusion_silk_state"}, {"applied":"int","failed":"int","results":"[ok | err:...]","smashed":"[ref]","after":"silk_state","revision":"str"},120,"fusion_routing_state",False,{"expectDocument":"fusion-routing","names":[{"ref":"J1","x":10,"y":20,"size":1.5,"rotation":90}]}),
install.sh+1−1@@ -21,7 +21,7 @@ echo "Installed fusion-bridge skills: $(ls "$PKG_DIR/skills" 2>/dev/null | tr '\ # binary (the three-artifact pattern: bridge zip -> ab, THIS pkg -> container skills + # this download, page media -> wiki). Per the wiki's PKG_VS_RELEASE policy, binaries are # platform-tagged RELEASE assets, never tarball content - the installer fetches them.-DASH_VERSION="1.12.1"+DASH_VERSION="1.12.2" DASH_URL="https://wiki.adom.inc/download/adom/fusion-bridge/${DASH_VERSION}/fusion-dashboard" DEST="" if [ -w /usr/local/bin ]; then DEST=/usr/local/bin
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.12.1",+ "version": "1.12.2", "type": "app", "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.", "dependencies": {
page.json+2−2@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.12.1",+ "version": "1.12.2", "type": "app", "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.", "dependencies": {@@ -143,4 +143,4 @@ "name": "John Lauer", "email": "[email protected]" }-}+}
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.12.1"+version = "1.12.2" edition = "2021" license = "MIT"
rust/bridge/src/main.rs+3−2@@ -40,6 +40,7 @@ mod addin_install; mod janitor; mod electronics; mod routing;+mod sch; mod sheet; mod bodies; mod display;@@ -412,13 +413,13 @@ fn main() { // 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)) => {+ v if routing::owns(v.strip_prefix("fusion_").unwrap_or(v)) || sch::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=match cmd { "bodies" => bodies::bodies(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "export_bom" => routing::export_bom(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "save_document" => savedoc::save_document(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "save_to_cloud" => savedoc::save_to_cloud(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), _ => routing::call(cmd,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)) };+ let mut r=match cmd { "bodies" => bodies::bodies(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "export_bom" => routing::export_bom(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "save_document" => savedoc::save_document(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "save_to_cloud" => savedoc::save_to_cloud(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if sch::owns(c) => sch::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), _ => 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 },
rust/bridge/src/sch.rsadded+154@@ -0,0 +1,154 @@+//! The schematic verbs of the EDA Bridge Standard (sch_*): sch_script.py runs on Fusion's main+//! thread through the script transport, as the routing verbs do.+use serde_json::{json, Value};+const SOURCE: &str = include_str!("sch_script.py");+pub fn owns(cmd: &str) -> bool { matches!(cmd, "sch_items" | "sch_pins" | "sch_trace_point" | "sch_page" | "sch_hierarchy" | "sch_text" | "sch_junction" | "sch_delete_wire" | "sch_connect_pins" | "sch_split_wire" | "sch_bus" | "sch_edit_label" | "sch_move_region" | "sch_fix_connectivity" | "sch_bus_entry" | "sch_connect_to_bus" | "render_schematic" | "sch_reset_fields" | "sch_create" | "sch_sheet_pins" | "sch_update_from_library") }+pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ if cmd == "render_schematic" { return render_schematic(args, pass); }+ let script = format!("import json\nexec({})\nresult=handle(app,json.loads({}),{})\n",+ serde_json::to_string(SOURCE).unwrap(), serde_json::to_string(&args.to_string()).unwrap(), serde_json::to_string(cmd).unwrap());+ let r = pass("run_modeling_script", json!({"script": script}));+ if r["success"].as_bool() == Some(false) { return r; }+ match r["data"]["result"].as_object() {+ Some(_) => r["data"]["result"].clone(),+ None => json!({"success": false, "errorCode": "invalid_schematic_response", "error": "Fusion did not return a structured schematic result"}),+ }+}++/// `render_schematic`: the sheet as a PNG (Fusion's EXPORT IMAGE, in sch_script.py), and visual+/// baselines kept beside it: `baseline:"set"` stores the render, `baseline:"compare"` renders at the+/// baseline's width and reports the pixel drift, the changed region in mm and a diff image.+pub fn render_schematic(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let dir = args["outputDir"].as_str().map(str::to_string).unwrap_or_else(|| {+ let t = std::env::var("TEMP").unwrap_or_else(|_| std::env::temp_dir().to_string_lossy().to_string());+ format!("{}/adom-schematic-renders", t.replace('\\', "/"))+ });+ let _ = std::fs::create_dir_all(&dir);+ let sheet = args["sheet"].as_u64().unwrap_or(1);+ let stem: String = args["name"].as_str().unwrap_or("schematic").chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();+ let base = format!("{dir}/{stem}-s{sheet}");+ let (png, bpng, bjson, dpng) = (format!("{base}.png"), format!("{base}.baseline.png"), format!("{base}.baseline.json"), format!("{base}.diff.png"));+ let mode = args["baseline"].as_str().unwrap_or("");+ let mut a = args.clone();+ let mut prior = Value::Null;+ if mode == "compare" {+ prior = match std::fs::read_to_string(&bjson).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()) {+ Some(v) => v,+ None => return json!({"success": false, "errorCode": "no_baseline", "error": format!("No baseline at {bpng}"), "_hint": "Set one first: render_schematic {baseline:\"set\"} (same name, sheet and outputDir)."}),+ };+ a["width"] = prior["requestedWidth"].clone();+ }+ a["png"] = json!(png);+ let mut r = call("sch_render", &a, pass);+ if r["success"].as_bool() != Some(true) { return r; }+ match mode {+ "set" => {+ if let Err(e) = std::fs::copy(&png, &bpng) { return json!({"success": false, "errorCode": "write_failed", "error": format!("could not store the baseline: {e}")}); }+ let meta = json!({"requestedWidth": a["width"].as_u64().unwrap_or(2000), "width": r["width"], "height": r["height"], "dpi": r["dpi"], "extentMm": r["extentMm"], "fusionVersion": r["fusionVersion"], "revision": r["revision"]});+ let _ = std::fs::write(&bjson, serde_json::to_string_pretty(&meta).unwrap());+ r["baseline"] = json!(bpng);+ }+ "compare" => {+ let threshold = args["threshold"].as_f64().unwrap_or(0.5);+ let cmp = compare_png(&bpng, &png, &dpng);+ match cmp {+ Err(e) => return json!({"success": false, "errorCode": "compare_failed", "error": e}),+ Ok((drift, bbox, w, h)) => {+ let dpi = r["dpi"].as_f64().unwrap_or(100.0);+ let x0 = r["extentMm"][0].as_f64().unwrap_or(0.0);+ let y1 = r["extentMm"][3].as_f64().unwrap_or(0.0);+ let px = 25.4 / dpi;+ r["driftPercent"] = json!((drift * 1000.0).round() / 1000.0);+ r["pass"] = json!(drift <= threshold);+ r["changedRegionMm"] = match bbox {+ Some((ax, ay, bx, by)) => json!([round4(x0 + ax as f64 * px), round4(y1 - by as f64 * px), round4(x0 + bx as f64 * px), round4(y1 - ay as f64 * px)]),+ None => Value::Null,+ };+ r["diffPng"] = if bbox.is_some() { json!(dpng) } else { Value::Null };+ r["rendererChanged"] = json!(prior["fusionVersion"] != r["fusionVersion"]);+ r["sizeChanged"] = json!(prior["width"].as_u64() != Some(w as u64) || prior["height"].as_u64() != Some(h as u64));+ r["baseline"] = json!(bpng);+ }+ }+ }+ _ => {}+ }+ r["_hint"] = json!("changedRegionMm maps the changed pixels back to the sheet through the drawn extent (mm, y up), to within a text's overhang. A paper or extent change reads as a size change; rendererChanged means Fusion's version changed since the baseline.");+ r+}++fn round4(v: f64) -> f64 { (v * 10000.0).round() / 10000.0 }++fn decode_rgb(path: &str) -> Result<(usize, usize, Vec<u8>), String> {+ let f = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;+ let mut dec = png::Decoder::new(f);+ dec.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);+ let mut rd = dec.read_info().map_err(|e| format!("{path}: {e}"))?;+ let mut buf = vec![0u8; rd.output_buffer_size()];+ let info = rd.next_frame(&mut buf).map_err(|e| format!("{path}: {e}"))?;+ let (w, h) = (info.width as usize, info.height as usize);+ let ch = match info.color_type { png::ColorType::Rgba => 4, png::ColorType::Rgb => 3, png::ColorType::GrayscaleAlpha => 2, _ => 1 };+ let mut rgb = Vec::with_capacity(w * h * 3);+ for p in buf[..w * h * ch].chunks(ch) {+ match ch { 4 | 3 => rgb.extend_from_slice(&p[..3]), _ => rgb.extend_from_slice(&[p[0], p[0], p[0]]) }+ }+ Ok((w, h, rgb))+}++/// The share of pixels that differ (percent), their bounding box in pixels, and a diff image (the+/// new render with the changed pixels in red). Different sizes count as 100 percent drift.+fn compare_png(old: &str, new: &str, diff: &str) -> Result<(f64, Option<(usize, usize, usize, usize)>, usize, usize), String> {+ let (w0, h0, a) = decode_rgb(old)?;+ let (w, h, b) = decode_rgb(new)?;+ if (w0, h0) != (w, h) { return Ok((100.0, Some((0, 0, w.saturating_sub(1), h.saturating_sub(1))), w, h)); }+ let mut out = b.clone();+ let (mut n, mut bb) = (0usize, None::<(usize, usize, usize, usize)>);+ for i in 0..w * h {+ let d = (0..3).map(|k| (a[i * 3 + k] as i32 - b[i * 3 + k] as i32).abs()).max().unwrap_or(0);+ if d > 32 {+ n += 1;+ let (x, y) = (i % w, i / w);+ bb = Some(match bb { None => (x, y, x, y), Some((ax, ay, bx, by)) => (ax.min(x), ay.min(y), bx.max(x), by.max(y)) });+ out[i * 3..i * 3 + 3].copy_from_slice(&[255, 0, 0]);+ }+ }+ if bb.is_some() {+ let f = std::fs::File::create(diff).map_err(|e| format!("{diff}: {e}"))?;+ let mut enc = png::Encoder::new(std::io::BufWriter::new(f), w as u32, h as u32);+ enc.set_color(png::ColorType::Rgb);+ enc.set_depth(png::BitDepth::Eight);+ enc.write_header().and_then(|mut wr| wr.write_image_data(&out)).map_err(|e| format!("{diff}: {e}"))?;+ }+ Ok((n as f64 * 100.0 / (w * h) as f64, bb, w, h))+}++#[cfg(test)]+mod tests {+ use super::*;+ #[test]+ fn script_failure_passes_through() {+ let r = call("sch_items", &json!({}), &|_, _| json!({"success": false, "errorCode": "timeout"}));+ assert_eq!(r["errorCode"], "timeout");+ assert!(owns("sch_pins") && !owns("routing_state"));+ }+ #[test]+ fn png_compare_counts_changed_pixels() {+ let dir = std::env::temp_dir().join(format!("sch-png-{}", std::process::id()));+ std::fs::create_dir_all(&dir).unwrap();+ let write = |name: &str, red: bool| {+ let p = dir.join(name);+ let mut img = vec![255u8; 10 * 10 * 3];+ if red { img[(5 * 10 + 5) * 3..(5 * 10 + 5) * 3 + 3].copy_from_slice(&[0, 0, 0]); }+ let mut enc = png::Encoder::new(std::io::BufWriter::new(std::fs::File::create(&p).unwrap()), 10, 10);+ enc.set_color(png::ColorType::Rgb); enc.set_depth(png::BitDepth::Eight);+ enc.write_header().unwrap().write_image_data(&img).unwrap();+ p.to_string_lossy().to_string()+ };+ let (a, b) = (write("a.png", false), write("b.png", true));+ let d = dir.join("d.png").to_string_lossy().to_string();+ let (drift, bb, _, _) = compare_png(&a, &b, &d).unwrap();+ assert_eq!(1.0, drift);+ assert_eq!(Some((5, 5, 5, 5)), bb);+ assert_eq!(0.0, compare_png(&a, &a, &d).unwrap().0);+ }+}
rust/bridge/src/sch_script.pyadded+1590@@ -0,0 +1,1590 @@+"""Schematic verbs of the EDA Bridge Standard (sch_*), run on Fusion's main thread through the+script transport (sch.rs embeds this file, as routing.rs does routing_script.py).++A Fusion schematic is one document with numbered sheets; `sheet` is that number (default 1), not+a file. Reads come from Fusion's own export of the schematic (the EAGLE XML inside the .fsch), or+from a .sch/.fsch on the Fusion machine (filePath). Coordinates are Fusion's: mm, y up, which is+the standard frame. EAGLE items carry no ids, so an item's id is built from its kind and geometry+('wire:x1,y1,x2,y2'); it stays valid until that item moves."""+import hashlib+import math+import re+import tempfile+import xml.etree.ElementTree as ET+import zipfile+from pathlib import Path+++class SchError(Exception):+ def __init__(self, code, message):+ super().__init__(message)+ self.code = code+++def sfail(code, message):+ raise SchError(code, message)+++def _sf(node, key, default=0.0):+ try:+ return float(node.get(key, default))+ except (TypeError, ValueError):+ return default+++def _r4(v):+ return round(float(v), 4)+++def _pt(x, y):+ return [_r4(x), _r4(y)]+++def _key(p):+ return '%g,%g' % (round(p[0], 4), round(p[1], 4))+++def wire_id(x1, y1, x2, y2, kind='wire'):+ a, b = (_r4(x1), _r4(y1)), (_r4(x2), _r4(y2))+ a, b = min(a, b), max(a, b)+ return '%s:%g,%g,%g,%g' % (kind, a[0], a[1], b[0], b[1])+++# โโ reading the XML โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ++def sch_root_from_file(path):+ p = Path(path)+ if not p.exists():+ sfail('file_not_found', 'No file at %s on the Fusion machine' % path)+ raw = p.read_bytes()+ if zipfile.is_zipfile(p):+ with zipfile.ZipFile(p) as z:+ names = [n for n in z.namelist() if n.lower().endswith('.sch')]+ if len(names) != 1:+ sfail('invalid_file', 'Expected one schematic inside %s, found %d' % (path, len(names)))+ raw = z.read(names[0])+ try:+ root = ET.fromstring(raw)+ except ET.ParseError as e:+ sfail('invalid_file', 'Not a schematic file: %s' % e)+ if root.find('drawing/schematic') is None:+ sfail('invalid_file', '%s holds no schematic' % path)+ return root+++def sch_revision(root):+ s = root.find('drawing/schematic')+ h = hashlib.sha256()+ for tag in ('parts', 'sheets', 'modules'):+ n = s.find(tag)+ if n is not None:+ h.update(ET.tostring(n))+ return h.hexdigest()[:16]+++class Lib:+ """Symbols, gates and pin-to-pad maps of the libraries embedded in the schematic."""++ def __init__(self, sch):+ self.symbols, self.devicesets = {}, {}+ for lib in sch.findall('libraries/library'):+ ln = lib.get('name')+ for sym in lib.findall('symbols/symbol'):+ self.symbols[(ln, sym.get('name'))] = sym+ for ds in lib.findall('devicesets/deviceset'):+ self.devicesets[(ln, ds.get('name'))] = ds+ self.parts = {p.get('name'): p for p in sch.findall('parts/part')}++ def gate_symbol(self, part, gate):+ p = self.parts.get(part)+ if p is None:+ return None, None+ ds = self.devicesets.get((p.get('library'), p.get('deviceset')))+ if ds is None:+ return None, None+ g = ds.find("gates/gate[@name='%s']" % gate) if "'" not in gate else None+ if g is None:+ g = next((x for x in ds.findall('gates/gate') if x.get('name') == gate), None)+ if g is None:+ return None, None+ return self.symbols.get((p.get('library'), g.get('symbol'))), ds++ def pads(self, part, gate, pin):+ p = self.parts.get(part)+ ds = self.devicesets.get((p.get('library'), p.get('deviceset'))) if p is not None else None+ if ds is None:+ return []+ dev = next((d for d in ds.findall('devices/device') if d.get('name', '') == p.get('device', '')), None)+ if dev is None:+ return []+ out = []+ for c in dev.findall('connects/connect'):+ if c.get('gate') == gate and c.get('pin') == pin:+ out += (c.get('pad') or '').split()+ return out+++def _rot(spec):+ spec = spec or 'R0'+ m = re.search(r'R(-?[\d.]+)', spec)+ return ('M' in spec), (float(m.group(1)) if m else 0.0)+++def transform(px, py, ix, iy, spec):+ """A symbol-frame point into the sheet: rotate, then mirror x (EAGLE's order, the one the board+ verbs use and Fusion's ULP agrees with), then translate."""+ mirrored, deg = _rot(spec)+ a = math.radians(deg)+ x, y = px * math.cos(a) - py * math.sin(a), px * math.sin(a) + py * math.cos(a)+ if mirrored:+ x = -x+ return ix + x, iy + y+++SIDE_OF = {0: 'left', 90: 'bottom', 180: 'right', 270: 'top'}+++def pin_side(pin_rot, inst_spec):+ """The side of the body a pin leaves from. A pin at R0 runs from its connection point toward +x,+ so it leaves the body on the left; the instance's turn and mirror then apply to that direction."""+ _, pdeg = _rot(pin_rot)+ a = math.radians(pdeg + 180) # outward direction, symbol frame+ dx, dy = math.cos(a), math.sin(a)+ x, y = transform(dx, dy, 0, 0, inst_spec)+ if abs(x) >= abs(y):+ return 'left' if x < 0 else 'right'+ return 'bottom' if y < 0 else 'top'+++ELECTRICAL = {'in': 'input', 'out': 'output', 'io': 'io', 'oc': 'open_collector', 'pas': 'passive',+ 'hiz': 'hiz', 'pwr': 'power', 'sup': 'supply', 'nc': 'not_connected'}+++def sheets_of(root):+ return root.findall('drawing/schematic/sheets/sheet')+++def pick_sheet(root, args):+ sheets = sheets_of(root)+ if not sheets:+ sfail('no_sheet', 'The schematic has no sheets')+ v = args.get('sheet', 1)+ if isinstance(v, str) and v.strip().isdigit():+ v = int(v)+ if isinstance(v, int) and not isinstance(v, bool):+ if not 1 <= v <= len(sheets):+ sfail('no_sheet', 'Sheet %d does not exist: the schematic has %d sheet(s)' % (v, len(sheets)))+ return v, sheets[v - 1]+ if isinstance(v, str):+ for i, s in enumerate(sheets):+ if (s.findtext('description') or '').strip().lower() == v.strip().lower():+ return i + 1, s+ sfail('no_sheet', 'sheet is the sheet number (1 to %d) or its description' % len(sheets))+++def sheet_pins(lib, sheet, refs=None):+ """Every pin placed on the sheet: {ref, gate, pin (its name), number (pads), at, side, electrical}."""+ out = []+ for inst in sheet.findall('instances/instance'):+ ref, gate = inst.get('part'), inst.get('gate')+ if refs and ref not in refs:+ continue+ sym, _ = lib.gate_symbol(ref, gate)+ if sym is None:+ continue+ ix, iy, spec = _sf(inst, 'x'), _sf(inst, 'y'), inst.get('rot', 'R0')+ for p in sym.findall('pin'):+ x, y = transform(_sf(p, 'x'), _sf(p, 'y'), ix, iy, spec)+ pads = lib.pads(ref, gate, p.get('name'))+ out.append({'ref': ref, 'gate': gate, 'pin': p.get('name'), 'number': ' '.join(pads) or None,+ 'at': _pt(x, y), 'side': pin_side(p.get('rot', 'R0'), spec),+ 'electrical': ELECTRICAL.get(p.get('direction', 'io'), p.get('direction', 'io')),+ 'lengthClass': p.get('length', 'long')})+ return out+++def segments(sheet):+ """(net, segment, is_bus) for every net and bus segment on the sheet."""+ for n in sheet.findall('nets/net'):+ for s in n.findall('segment'):+ yield n.get('name'), s, False+ for b in sheet.findall('busses/bus'):+ for s in b.findall('segment'):+ yield b.get('name'), s, True+++def is_supply(lib, ref):+ p = lib.parts.get(ref)+ if p is None:+ return False+ ds = lib.devicesets.get((p.get('library'), p.get('deviceset')))+ if ds is None:+ return False+ for g in ds.findall('gates/gate'):+ sym = lib.symbols.get((p.get('library'), g.get('symbol')))+ if sym is not None and sym.findall('pin') and all(x.get('direction') == 'sup' for x in sym.findall('pin')):+ return True+ return False+++def items(root, sheet, kinds=None):+ lib = Lib(root.find('drawing/schematic'))+ want = set(kinds or ['wires', 'buses', 'busEntries', 'labels', 'powerPorts', 'junctions', 'noConnects', 'texts', 'sheets'])+ out = {k: [] for k in want}+ for name, seg, bus in segments(sheet):+ for w in seg.findall('wire'):+ x1, y1, x2, y2 = (_sf(w, k) for k in ('x1', 'y1', 'x2', 'y2'))+ if bus and 'buses' in want:+ out['buses'].append({'id': wire_id(x1, y1, x2, y2, 'bus'), 'bus': name, 'from': _pt(x1, y1), 'to': _pt(x2, y2)})+ elif not bus and 'wires' in want:+ out['wires'].append({'id': wire_id(x1, y1, x2, y2), 'net': name, 'from': _pt(x1, y1), 'to': _pt(x2, y2)})+ for lb in seg.findall('label'):+ if 'labels' in want:+ kind = 'global' if lb.get('xref') == 'yes' else 'local'+ out['labels'].append({'id': 'label:%s' % _key((_sf(lb, 'x'), _sf(lb, 'y'))), 'text': name, 'kind': kind,+ 'at': _pt(_sf(lb, 'x'), _sf(lb, 'y')), 'rotation': _rot(lb.get('rot'))[1], 'onBus': bus})+ for j in seg.findall('junction'):+ if 'junctions' in want:+ out['junctions'].append({'id': 'junction:%s' % _key((_sf(j, 'x'), _sf(j, 'y'))), 'net': name, 'at': _pt(_sf(j, 'x'), _sf(j, 'y'))})+ for pr in seg.findall('portref'):+ if 'labels' in want:+ out['labels'].append({'id': 'port:%s:%s' % (pr.get('moduleinst'), pr.get('port')), 'text': name, 'kind': 'hierarchical',+ 'moduleinst': pr.get('moduleinst'), 'port': pr.get('port'), 'at': None, 'rotation': None, 'onBus': bus})+ if 'busEntries' in want:+ # EAGLE draws a bus entry as a short diagonal net wire ending on a bus: report each such wire+ bus_pts = [((_sf(w, 'x1'), _sf(w, 'y1')), (_sf(w, 'x2'), _sf(w, 'y2'))) for _, s, b in segments(sheet) if b for w in s.findall('wire')]+ for name, seg, bus in segments(sheet):+ if bus:+ continue+ for w in seg.findall('wire'):+ a, b = (_sf(w, 'x1'), _sf(w, 'y1')), (_sf(w, 'x2'), _sf(w, 'y2'))+ if abs(abs(a[0] - b[0]) - abs(a[1] - b[1])) < 1e-6 and a != b:+ for end, other in ((a, b), (b, a)):+ if any(_seg_dist(end, p, q) < 1e-4 for p, q in bus_pts):+ out['busEntries'].append({'id': wire_id(a[0], a[1], b[0], b[1], 'entry'), 'net': name, 'at': _pt(*end), 'end': _pt(*other)})+ break+ if 'powerPorts' in want:+ for inst in sheet.findall('instances/instance'):+ ref = inst.get('part')+ if is_supply(lib, ref):+ p = lib.parts[ref]+ pins = sheet_pins(lib, sheet, {ref})+ out['powerPorts'].append({'id': 'power:%s' % ref, 'ref': ref, 'net': p.get('value') or p.get('deviceset'),+ 'at': pins[0]['at'] if pins else _pt(_sf(inst, 'x'), _sf(inst, 'y'))})+ if 'texts' in want:+ for t in sheet.findall('plain/text'):+ out['texts'].append({'id': 'text:%s' % _key((_sf(t, 'x'), _sf(t, 'y'))), 'text': t.text or '', 'at': _pt(_sf(t, 'x'), _sf(t, 'y')),+ 'sizeMm': _sf(t, 'size'), 'rotation': _rot(t.get('rot'))[1], 'layer': int(t.get('layer', '97'))})+ if 'sheets' in want:+ for mi in sheet.findall('moduleinsts/moduleinst'):+ mod = root.find("drawing/schematic/modules/module[@name='%s']" % mi.get('module')) if "'" not in (mi.get('module') or '') else None+ dx, dy = (_sf(mod, 'dx'), _sf(mod, 'dy')) if mod is not None else (0, 0)+ out['sheets'].append({'id': 'sheet:%s' % mi.get('name'), 'name': mi.get('name'), 'module': mi.get('module'),+ 'at': _pt(_sf(mi, 'x'), _sf(mi, 'y')), 'sizeMm': [_r4(dx), _r4(dy)]})+ if 'noConnects' in want:+ out['noConnects'] = [] # EAGLE has no no-connect marker: an unused pin is direction nc+ return {**{k: out[k] for k in out}, 'counts': {k: len(v) for k, v in out.items()}}+++def _seg_dist(p, a, b):+ dx, dy = b[0] - a[0], b[1] - a[1]+ L2 = dx * dx + dy * dy+ t = 0.0 if not L2 else max(0.0, min(1.0, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / L2))+ return math.hypot(p[0] - a[0] - t * dx, p[1] - a[1] - t * dy)+++def pins_with_connections(root, sheet, refs=None, connections=True):+ lib = Lib(root.find('drawing/schematic'))+ pins = sheet_pins(lib, sheet, set(refs) if refs else None)+ if not pins:+ return pins+ by = {}+ for p in pins:+ by.setdefault((p['ref'], p['gate'], p['pin']), p)+ p['net'] = None+ if connections:+ p.update(wires=[], labels=[], connectedPins=[])+ for name, seg, bus in segments(sheet):+ if bus:+ continue+ refs_here = [(pr.get('part'), pr.get('gate'), pr.get('pin')) for pr in seg.findall('pinref')]+ for k in refs_here:+ p = by.get(k)+ if p is None:+ continue+ p['net'] = name+ if connections:+ for w in seg.findall('wire'):+ a, b = (_sf(w, 'x1'), _sf(w, 'y1')), (_sf(w, 'x2'), _sf(w, 'y2'))+ if _seg_dist(p['at'], a, b) < 1e-3:+ p['wires'].append(wire_id(a[0], a[1], b[0], b[1]))+ p['labels'] = sorted(set(p['labels']) | {name for _ in seg.findall('label')})+ p['connectedPins'] += ['%s.%s' % (r, pn) for r, g, pn in refs_here if (r, g, pn) != k]+ return pins+++def trace_point(root, sheet, at, tol):+ """Everything electrically joined at one point, and the whole nets it belongs to."""+ lib = Lib(root.find('drawing/schematic'))+ p = (float(at[0]), float(at[1]))+ found, nets, bodies = [], set(), []+ for name, seg, bus in segments(sheet):+ for w in seg.findall('wire'):+ a, b = (_sf(w, 'x1'), _sf(w, 'y1')), (_sf(w, 'x2'), _sf(w, 'y2'))+ d = _seg_dist(p, a, b)+ bodies.append((d, 'bus' if bus else 'wire', wire_id(a[0], a[1], b[0], b[1], 'bus' if bus else 'wire'), name))+ if d <= tol:+ end = min(math.hypot(p[0] - a[0], p[1] - a[1]), math.hypot(p[0] - b[0], p[1] - b[1])) <= tol+ found.append({'kind': 'bus' if bus else 'wire', 'id': wire_id(a[0], a[1], b[0], b[1], 'bus' if bus else 'wire'),+ 'net': name, 'atEnd': end})+ nets.add((name, bus))+ for j in seg.findall('junction'):+ q = (_sf(j, 'x'), _sf(j, 'y'))+ if math.hypot(p[0] - q[0], p[1] - q[1]) <= tol:+ found.append({'kind': 'junction', 'id': 'junction:%s' % _key(q), 'net': name})+ nets.add((name, bus))+ for lb in seg.findall('label'):+ q = (_sf(lb, 'x'), _sf(lb, 'y'))+ if math.hypot(p[0] - q[0], p[1] - q[1]) <= tol:+ found.append({'kind': 'label', 'id': 'label:%s' % _key(q), 'net': name})+ nets.add((name, bus))+ pins = pins_with_connections(root, sheet, connections=False)+ for pn in pins:+ d = math.hypot(p[0] - pn['at'][0], p[1] - pn['at'][1])+ bodies.append((d, 'pin', '%s.%s' % (pn['ref'], pn['pin']), pn['net']))+ if d <= tol:+ found.append({'kind': 'pin', 'id': '%s.%s' % (pn['ref'], pn['pin']), 'number': pn['number'], 'net': pn['net']})+ if pn['net']:+ nets.add((pn['net'], False))+ # wires of two different nets meeting here touch on screen but do not connect (no junction joins them)+ wire_nets = {x['net'] for x in found if x['kind'] == 'wire'}+ crossing = len(wire_nets) > 1+ whole = []+ for name, bus in sorted(nets, key=lambda t: (t[0] or '', t[1])):+ pins_on = ['%s.%s' % (pn['ref'], pn['pin']) for pn in pins if pn['net'] == name] if not bus else []+ on_sheets = []+ for i, sh in enumerate(sheets_of(root)):+ if sh.find("nets/net[@name='%s']" % name) is not None if "'" not in (name or '') else False:+ on_sheets.append(i + 1)+ whole.append({'name': name, 'bus': bus, 'pins': pins_on, 'sheets': on_sheets})+ nearest = None+ if not found and bodies:+ d, kind, iid, net = min(bodies)+ nearest = {'kind': kind, 'id': iid, 'net': net, 'distanceMm': _r4(d)}+ return {'items': found, 'nets': whole, 'crossingWithoutJunction': crossing, 'nearest': nearest}+++FRAME_LAYERS = ('94',)+++def page(root, sheet):+ """The sheet's frame (EAGLE draws the page as a frame symbol placed like a part) and the title+ block fields Fusion fills from the design's attributes."""+ lib = Lib(root.find('drawing/schematic'))+ frame = None+ for inst in sheet.findall('instances/instance'):+ sym, _ = lib.gate_symbol(inst.get('part'), inst.get('gate'))+ if sym is None or sym.find('frame') is None:+ continue+ f = sym.find('frame')+ x1, y1 = transform(_sf(f, 'x1'), _sf(f, 'y1'), _sf(inst, 'x'), _sf(inst, 'y'), inst.get('rot', 'R0'))+ x2, y2 = transform(_sf(f, 'x2'), _sf(f, 'y2'), _sf(inst, 'x'), _sf(inst, 'y'), inst.get('rot', 'R0'))+ frame = {'ref': inst.get('part'), 'symbol': sym.get('name'), 'deviceset': lib.parts[inst.get('part')].get('deviceset'),+ 'rect': [_r4(min(x1, x2)), _r4(min(y1, y2)), _r4(max(x1, x2)), _r4(max(y1, y2))]}+ break+ attrs = {a.get('name'): a.get('value') for a in root.findall('drawing/schematic/attributes/attribute')}+ w = h = None+ if frame:+ w, h = frame['rect'][2] - frame['rect'][0], frame['rect'][3] - frame['rect'][1]+ paper = None+ if w:+ for name, (pw, ph) in PAPER.items():+ if (abs(w - pw) < 8 and abs(h - ph) < 8) or (abs(w - ph) < 8 and abs(h - pw) < 8):+ paper = name+ break+ return {'paper': paper, 'widthMm': _r4(w) if w else None, 'heightMm': _r4(h) if h else None,+ 'orientation': (('landscape' if w >= h else 'portrait') if w else None), 'frame': frame,+ 'titleBlock': {'title': attrs.get('DRAWING_NAME') or attrs.get('TITLE'), 'rev': attrs.get('REVISION') or attrs.get('REV'),+ 'date': attrs.get('DATE'), 'company': attrs.get('COMPANY') or attrs.get('AUTHOR'),+ 'attributes': attrs}}+++PAPER = {'A4': (297, 210), 'A3': (420, 297), 'A2': (594, 420), 'A1': (841, 594), 'A0': (1189, 841),+ 'A': (279.4, 215.9), 'B': (431.8, 279.4), 'C': (558.8, 431.8), 'D': (863.6, 558.8), 'E': (1117.6, 863.6)}+++def hierarchy(root):+ """Fusion's hierarchy is modules: a module is a sub-design with its own sheets, placed on a+ parent sheet as a module instance (the sheet symbol) whose ports are its pins."""+ sch = root.find('drawing/schematic')+ mods = {m.get('name'): m for m in sch.findall('modules/module')}+ problems = []++ def node(name, sheets, path, depth):+ out = []+ for i, sh in enumerate(sheets):+ kids = []+ for mi in sh.findall('moduleinsts/moduleinst'):+ m = mods.get(mi.get('module'))+ if m is None:+ problems.append('module instance %s names a module %s that is not in the design' % (mi.get('name'), mi.get('module')))+ continue+ if depth > 16:+ problems.append('module %s nests deeper than 16 levels (a loop?)' % mi.get('module'))+ continue+ ports = [{'name': p.get('name'), 'kind': p.get('direction', 'io'), 'side': p.get('side'), 'coord': _sf(p, 'coord')}+ for p in m.findall('ports/port')]+ kids.append({'name': mi.get('name'), 'module': mi.get('module'), 'path': path + [mi.get('name')],+ 'at': _pt(_sf(mi, 'x'), _sf(mi, 'y')), 'sizeMm': [_r4(_sf(m, 'dx')), _r4(_sf(m, 'dy'))],+ 'pins': ports, 'sheets': node(m.get('name'), m.findall('sheets/sheet'), path + [mi.get('name')], depth + 1)})+ out.append({'page': i + 1, 'description': (sh.findtext('description') or None), 'children': kids})+ return out++ return {'tree': {'name': 'root', 'sheets': node('root', sch.findall('sheets/sheet'), [], 0)},+ 'modules': sorted(mods), 'problems': problems, 'duplicatePages': [], 'renumbered': False}+++# โโ the live schematic โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ++def _pt_of(d):+ try:+ return d.products.item(0).productType if d.products.count else ''+ except Exception:+ return ''+++def sch_doc(app, args):+ """The schematic of the design in front (or named by expectDocument): the active document when+ it is a schematic, else its design's schematic tab. A saved design's documents share their cloud+ file name and folder; an unsaved one's are the same-named tabs opened with its design document."""+ docs = [app.documents.item(i) for i in range(app.documents.count)]+ anchor = app.activeDocument+ want = args.get('expectDocument')+ if want:+ named = [d for d in docs if d.name == want]+ if not named:+ sfail('wrong_document', 'No open document is named %s' % want)+ if anchor not in named:+ anchor = next((d for d in named if _pt_of(d) == 'SchematicProductType'), named[0])+ if anchor is None:+ sfail('no_document', 'No document is open: open the electronics design first')+ if _pt_of(anchor) == 'SchematicProductType':+ return docs.index(anchor), anchor+ if anchor.dataFile is not None:+ ref = anchor.dataFile+ for i, d in enumerate(docs):+ if _pt_of(d) == 'SchematicProductType' and d.dataFile is not None and d.dataFile.name == ref.name and d.dataFile.parentFolder.id == ref.parentFolder.id:+ return i, d+ k = docs.index(anchor)+ start = k+ while start > 0 and _pt_of(docs[start]) != 'EcadDesignProductType':+ start -= 1+ for i in range(start, len(docs)):+ d = docs[i]+ if i > start and _pt_of(d) == 'EcadDesignProductType':+ break+ if _pt_of(d) == 'SchematicProductType' and d.name == anchor.name and (d.dataFile is None) == (anchor.dataFile is None):+ return i, d+ sfail('no_schematic', 'The document in front (%s) has no schematic open beside it: open the electronics design (its project document)' % anchor.name)+++def sch_snapshot(app, args):+ """Export the live schematic and parse it; the user's tab is put back afterwards."""+ if args.get('filePath'):+ root = sch_root_from_file(args['filePath'])+ return root, {'source': 'file', 'filePath': args['filePath'], 'revision': sch_revision(root)}, None+ i, doc = sch_doc(app, args)+ front = app.activeDocument+ docs = [app.documents.item(k) for k in range(app.documents.count)]+ j = docs.index(front) if front in docs else None+ with tempfile.TemporaryDirectory(prefix='adom-sch-') as d:+ p = Path(d) / 'snapshot.fsch'+ try:+ app.executeTextCommand('UI.SwitchToTab %d' % i)+ app.executeTextCommand('Document.CopyToDesktop ' + p.as_posix())+ finally:+ if j is not None and j != i:+ app.executeTextCommand('UI.SwitchToTab %d' % j)+ root = sch_root_from_file(str(p))+ return root, {'source': 'live', 'documentName': doc.name, 'revision': sch_revision(root)}, (i, j)+++# โโ editing โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ+# Edits are EAGLE commands typed into the schematic editor. Learned live (2026-10-27):+# - a question SET CONFIRM should answer is only answered when SET CONFIRM is on the SAME command+# line ("SET CONFIRM YES; NAME ...; SET CONFIRM OFF;"); sent as its own command it is ignored and+# Fusion asks ("Connect FL_TEST and N$17?"), blocking the main thread;+# - a group MOVE snaps to the sheet grid (0.1 in): the edit runs at GRID MM 0.001, then GRID LAST;+# - TEXT goes on whatever layer is current: LAYER 97 (Info) first;+# - a JUNCTION placed on a wire splits the wire there; SPLIT with one point twice does nothing.++def mm(p):+ return '(%.4fmm %.4fmm)' % (float(p[0]), float(p[1]))+++def quote(t):+ return "'" + str(t).replace("'", "''") + "'"+++def find_pin(root, sheet, spec):+ """'U1.3' -> the pin on this sheet: by pad number first, then by pin name."""+ if not isinstance(spec, str) or '.' not in spec:+ sfail('bad_args', 'A pin is Ref.Pin, for example U1.3 (got %r)' % (spec,))+ ref, pn = spec.rsplit('.', 1)+ pins = pins_with_connections(root, sheet, [ref], connections=False)+ if not pins:+ sfail('designator_not_found', 'No part %s on this sheet' % ref)+ hit = [p for p in pins if p['number'] and pn in p['number'].split()] or [p for p in pins if p['pin'] == pn] \+ or [p for p in pins if p['pin'].lower() == pn.lower()]+ if not hit:+ sfail('pin_not_found', '%s has no pin %s (pins: %s)' % (ref, pn, ', '.join(sorted({p['number'] or p['pin'] for p in pins}))[:300]))+ return hit[0]+++def wire_cover(sheet, kind='nets'):+ """Each net's wires as merged intervals on their lines: a wire split in two by a junction covers+ the same copper as before, so edits are compared by cover, not by wire list."""+ import collections+ groups = collections.defaultdict(list)+ for name, seg, bus in segments(sheet):+ if bus != (kind == 'buses'):+ continue+ for w in seg.findall('wire'):+ a, b = (round(_sf(w, 'x1'), 3), round(_sf(w, 'y1'), 3)), (round(_sf(w, 'x2'), 3), round(_sf(w, 'y2'), 3))+ if a > b:+ a, b = b, a+ dx, dy = b[0] - a[0], b[1] - a[1]+ L = math.hypot(dx, dy)+ if not L:+ continue+ ux, uy = dx / L, dy / L+ key = (name, round(ux, 5), round(uy, 5), round(-uy * a[0] + ux * a[1], 3))+ groups[key].append((round(ux * a[0] + uy * a[1], 3), round(ux * b[0] + uy * b[1], 3)))+ out = {}+ for key, spans in groups.items():+ merged = []+ for a, b in sorted(spans):+ if merged and a <= merged[-1][1] + 1e-3:+ merged[-1][1] = max(merged[-1][1], b)+ else:+ merged.append([a, b])+ out[key] = [tuple(m) for m in merged]+ return out+++def _cover_by_line(cover):+ """The same cover keyed by line only, so a rename (net changes, copper does not) compares equal."""+ out = {}+ for (net, ux, uy, c), spans in cover.items():+ out.setdefault((ux, uy, c), []).extend(spans)+ return {k: sorted(v) for k, v in out.items()}+++def junction_points(sheet):+ return sorted((round(_sf(j, 'x'), 3), round(_sf(j, 'y'), 3)) for _, seg, _ in segments(sheet) for j in seg.findall('junction'))+++def text_rows(sheet):+ return sorted((round(_sf(t, 'x'), 3), round(_sf(t, 'y'), 3), t.text or '') for t in sheet.findall('plain/text'))+++def label_rows(sheet):+ return sorted((name, round(_sf(l, 'x'), 3), round(_sf(l, 'y'), 3), l.get('rot', 'R0'), l.get('xref', 'no'))+ for name, seg, _ in segments(sheet) for l in seg.findall('label'))+++def part_rows(sheet):+ return sorted((i.get('part'), i.get('gate'), round(_sf(i, 'x'), 3), round(_sf(i, 'y'), 3), i.get('rot', 'R0')) for i in sheet.findall('instances/instance'))+++def ends_at(sheet, p, tol=1e-3, pins=()):+ """How many wire ends, wire middles (two each) and pins meet at p; a junction is needed at three+ or more. pins are the sheet's pin positions (a junction joining a pin and two wires is needed)."""+ n = sum(1 for q in pins if math.hypot(p[0] - q[0], p[1] - q[1]) <= tol)+ for _, seg, bus in segments(sheet):+ if bus:+ continue+ for w in seg.findall('wire'):+ a, b = (_sf(w, 'x1'), _sf(w, 'y1')), (_sf(w, 'x2'), _sf(w, 'y2'))+ if math.hypot(p[0] - a[0], p[1] - a[1]) <= tol or math.hypot(p[0] - b[0], p[1] - b[1]) <= tol:+ n += 1+ elif _seg_dist(p, a, b) <= tol:+ n += 2+ return n+++def current_sheet(app):+ """The sheet the schematic editor shows (the ULP's sheet context), or None."""+ try:+ with tempfile.TemporaryDirectory(prefix='adom-sch-cur-') as d:+ out, ulp = Path(d) / 'c.txt', Path(d) / 'c.ulp'+ ulp.write_text('if (sheet) sheet(SH) { output("%s") { printf("%%d", SH.number); } }' % out.as_posix())+ app.executeTextCommand("Electron.run RUN '%s';" % ulp.as_posix())+ return int(out.read_text().strip()) if out.exists() else None+ except Exception:+ return None+++class Edit:+ """One edit: the commands, then a read back that must match, or every command is undone."""++ def __init__(self, app, args):+ self.app, self.args = app, args+ if args.get('filePath'):+ sfail('unsupported', 'Edits act on the open design; filePath is for reads')+ self.root, self.meta, tabs = sch_snapshot(app, args)+ self.tab, self.front = tabs+ self.n, self.sheet = pick_sheet(self.root, args)+ if args.get('expectedRevision') not in (None, self.meta['revision']):+ sfail('stale_schematic', 'expectedRevision is %s but the schematic is now %s: read it again' % (args.get('expectedRevision'), self.meta['revision']))+ self.cmds = []+ self.extra_steps = 0 # undo steps made by earlier passes of the same edit++ def run(self):+ app, sent, shown = self.app, 0, None+ try:+ app.executeTextCommand('UI.SwitchToTab %d' % self.tab)+ if len(sheets_of(self.root)) > 1:+ shown = current_sheet(app)+ app.executeTextCommand('Electron.run SET CONFIRM NO; EDIT .s%d; SET CONFIRM OFF;' % self.n)+ app.executeTextCommand('Electron.run GRID MM 0.001;')+ for c in self.cmds:+ app.executeTextCommand('Electron.run ' + c)+ sent += 1+ finally:+ try:+ app.executeTextCommand('Electron.run GRID LAST;')+ # put the editor back on the sheet the user was looking at+ if shown and shown != self.n:+ app.executeTextCommand('Electron.run SET CONFIRM NO; EDIT .s%d; SET CONFIRM OFF;' % shown)+ finally:+ if self.front is not None:+ app.executeTextCommand('UI.SwitchToTab %d' % self.front)+ self.after, self.meta2, _ = sch_snapshot(app, self.args)+ _, self.sheet2 = pick_sheet(self.after, {'sheet': self.n})+ return sent++ def again(self, cmds):+ """A further pass of the same edit: the steps of the pass before count toward its undo."""+ self.extra_steps += sum(1 for c in self.cmds if not c.startswith('GROUP'))+ self.cmds = cmds+ start, self.sheet = self.sheet, self.sheet2+ self.run()+ self.sheet = start++ def undo(self, why):+ """Put the schematic back to the revision read before the edit. Nothing is undone when it+ already reads so (a command Fusion declined changed nothing: undoing then would take the+ user's own earlier edit). Steps are undone one short of the edit's count, then one at a time,+ each checked; if the start never comes back, every step taken is redone and the edit is+ reported as left in place, rather than undoing further into the user's work."""+ app = self.app+ if self.meta2['revision'] == self.meta['revision']:+ sfail('readback_mismatch', why + '; Fusion made no change, so nothing was undone')+ steps = sum(1 for c in self.cmds if not c.startswith('GROUP')) + self.extra_steps+ taken = [0]+ def step(cmd, k):+ app.executeTextCommand('UI.SwitchToTab %d' % self.tab)+ for _ in range(k):+ app.executeTextCommand('Electron.run %s;' % cmd)+ if self.front is not None:+ app.executeTextCommand('UI.SwitchToTab %d' % self.front)+ return sch_snapshot(app, self.args)[1]['revision']+ if steps > 1:+ taken[0] += steps - 1+ if step('UNDO', steps - 1) == self.meta['revision']:+ sfail('readback_mismatch', why + '; every command was undone')+ for _ in range(3):+ taken[0] += 1+ if step('UNDO', 1) == self.meta['revision']:+ sfail('readback_mismatch', why + '; every command was undone')+ back = step('REDO', taken[0])+ sfail('readback_mismatch', why + '; UNDO did not return the schematic to its revision before the edit, so the %d steps undone were redone: the edit is %s. Check it in Fusion'+ % (taken[0], 'left in place' if back == self.meta2['revision'] else 'in an unknown state (revision %s)' % back))++ def same_except(self, wires=True, junctions=True, texts=True, labels=True, parts=True, buses=True):+ """True when everything the edit was not meant to touch reads back unchanged."""+ a, b = self.sheet, self.sheet2+ return ((not wires or wire_cover(a) == wire_cover(b)) and (not junctions or junction_points(a) == junction_points(b))+ and (not texts or text_rows(a) == text_rows(b)) and (not labels or label_rows(a) == label_rows(b))+ and (not parts or part_rows(a) == part_rows(b)) and (not buses or wire_cover(a, 'buses') == wire_cover(b, 'buses')))++ def what_changed(self, **skip):+ a, b = self.sheet, self.sheet2+ checks = {'wires': (wire_cover(a), wire_cover(b)), 'junctions': (junction_points(a), junction_points(b)),+ 'texts': (text_rows(a), text_rows(b)), 'labels': (label_rows(a), label_rows(b)),+ 'parts': (part_rows(a), part_rows(b)), 'buses': (wire_cover(a, 'buses'), wire_cover(b, 'buses'))}+ return ', '.join(k for k, (x, y) in checks.items() if skip.get(k, True) and x != y) or 'nothing else'++ def reply(self, **kw):+ out = {'success': True, 'mutated': True, **self.meta2, 'sheet': self.n, 'saved': False}+ out.update(kw)+ return out+++def dry(args):+ return args.get('dryRun') is True+++def plan_reply(e, **kw):+ return {'success': True, 'mutated': False, 'dryRun': True, **e.meta, 'sheet': e.n, 'commands': e.cmds, **kw}+++def sch_text(app, args):+ e = Edit(app, args)+ texts = args.get('texts') or ([args] if args.get('text') else [])+ if not texts:+ sfail('bad_args', 'texts is [{text, at:[x,y], sizeMm?, angle?, align?}]')+ for t in texts:+ if not str(t.get('text', '')).strip() or not t.get('at'):+ sfail('bad_args', 'Each text needs text and at')+ ang = float(t.get('angle', 0)) % 360+ align = t.get('align')+ e.cmds.append('LAYER 97; CHANGE SIZE %.4fmm; %sTEXT %s R%g %s;' % (float(t.get('sizeMm', 1.778)), ('CHANGE ALIGN %s; ' % align) if align else '', quote(t['text']), ang, mm(t['at'])))+ if dry(args):+ return plan_reply(e)+ e.run()+ want = sorted((round(float(t['at'][0]), 3), round(float(t['at'][1]), 3), str(t['text'])) for t in texts)+ added = sorted(set(text_rows(e.sheet2)) - set(text_rows(e.sheet)))+ if added != want or not e.same_except(texts=False):+ e.undo('The texts read back were not exactly the ones asked')+ return e.reply(added=len(added), texts=[{'id': 'text:%s' % _key((x, y)), 'text': t, 'at': [x, y]} for x, y, t in added])+++def junction_pick(sheet, q, r=0.35):+ """A click on a junction dot that no wire is under: a click on the dot's centre takes a wire+ end there instead (live 2026-10-27). The dot is about 0.8 mm across; r stays inside it, on+ the side farthest from every wire."""+ ws = [((_sf(w, 'x1'), _sf(w, 'y1')), (_sf(w, 'x2'), _sf(w, 'y2'))) for _, seg, _ in segments(sheet) for w in seg.findall('wire')]+ best = None+ for k in range(16):+ a = math.radians(22.5 * k + 11.25)+ p = (q[0] + r * math.cos(a), q[1] + r * math.sin(a))+ d = min([_seg_dist(p, x, y) for x, y in ws] or [9.0])+ if best is None or d > best[0]:+ best = (d, p)+ return best[1]+++def nets_at(sheet, p, tol=1e-3):+ return {name for name, seg, bus in segments(sheet) if not bus for w in seg.findall('wire')+ if _seg_dist(p, (_sf(w, 'x1'), _sf(w, 'y1')), (_sf(w, 'x2'), _sf(w, 'y2'))) <= tol}+++def pin_points(root, sheet):+ return [tuple(x['at']) for x in sheet_pins(Lib(root.find('drawing/schematic')), sheet)]+++def sch_junction(app, args):+ e = Edit(app, args)+ pp = pin_points(e.root, e.sheet)+ add_pts = [tuple(map(float, p)) for p in (args.get('points') or [])]+ rem = args.get('remove') or []+ have = junction_points(e.sheet)+ added, skipped, not_needed, removing = [], [], [], []+ for p in add_pts:+ q = (round(p[0], 3), round(p[1], 3))+ if q in have:+ skipped.append({'at': list(q), 'why': 'a junction is already there'})+ elif ends_at(e.sheet, p) == 0:+ skipped.append({'at': list(q), 'why': 'no wire passes there'})+ elif ends_at(e.sheet, p, pins=pp) < 3 and not args.get('force'):+ not_needed.append(list(q))+ elif len(nets_at(e.sheet, p)) > 1:+ skipped.append({'at': list(q), 'why': 'wires of %s cross there: a junction would join them (connect them with a wire instead if you mean to)' % ' and '.join(sorted(nets_at(e.sheet, p)))})+ else:+ added.append(q)+ e.cmds.append('SET CONFIRM NO; JUNCTION %s; SET CONFIRM OFF;' % mm(q))+ for r_ in rem:+ q = tuple(map(float, r_.split(':', 1)[1].split(','))) if isinstance(r_, str) and r_.startswith('junction:') else tuple(map(float, r_))+ q = (round(q[0], 3), round(q[1], 3))+ if q not in have:+ skipped.append({'at': list(q), 'why': 'no junction there'})+ continue+ removing.append(q)+ e.cmds.append('DELETE %s;' % mm(junction_pick(e.sheet, q)))+ if dry(args):+ return plan_reply(e, added=[list(q) for q in added], removed=[list(q) for q in removing], skipped=skipped, notNeeded=not_needed)+ if not e.cmds:+ return {'success': True, 'mutated': False, **e.meta, 'sheet': e.n, 'added': [], 'removed': [], 'skipped': skipped, 'notNeeded': not_needed, 'saved': False}+ e.run()+ import collections+ # counted per point: a design can hold two junctions drawn on one point+ after = collections.Counter(junction_points(e.sheet2))+ expect = collections.Counter(have) + collections.Counter(added) - collections.Counter(removing)+ if after != expect or not e.same_except(junctions=False):+ e.undo('The junctions read back were not the asked ones (missing %s, extra %s), or other items changed (%s)'+ % (sorted(set(expect) - set(after)), sorted(set(after) - set(expect)), e.what_changed(junctions=False)))+ return e.reply(added=[list(q) for q in added], removed=[list(q) for q in removing], skipped=skipped, notNeeded=not_needed)+++def _wire_targets(sheet, args):+ """The wires named by ids, by their two ends, or every wire through a point."""+ ws = [(name, w) for name, seg, bus in segments(sheet) for w in seg.findall('wire') if not bus]+ def wid(w):+ return wire_id(_sf(w, 'x1'), _sf(w, 'y1'), _sf(w, 'x2'), _sf(w, 'y2'))+ out, missing = [], []+ for i in args.get('ids') or []:+ hit = [x for x in ws if wid(x[1]) == i]+ (out.extend(hit) if hit else missing.append(i))+ if args.get('from') is not None and args.get('to') is not None:+ i = wire_id(*map(float, args['from']), *map(float, args['to']))+ hit = [x for x in ws if wid(x[1]) == i]+ (out.extend(hit) if hit else missing.append(i))+ if args.get('at') is not None:+ p = tuple(map(float, args['at']))+ hit = [x for x in ws if _seg_dist(p, (_sf(x[1], 'x1'), _sf(x[1], 'y1')), (_sf(x[1], 'x2'), _sf(x[1], 'y2'))) <= 1e-3]+ (out.extend(hit) if hit else missing.append('at %s' % _key(p)))+ seen, uniq = set(), []+ for name, w in out:+ if wid(w) not in seen:+ seen.add(wid(w))+ uniq.append((name, w))+ return uniq, missing, wid+++def sch_delete_wire(app, args):+ e = Edit(app, args)+ targets, missing, wid = _wire_targets(e.sheet, args)+ if missing and not targets:+ sfail('not_found', 'No wire matches %s' % ', '.join(missing))+ for name, w in targets:+ mid = ((_sf(w, 'x1') + _sf(w, 'x2')) / 2, (_sf(w, 'y1') + _sf(w, 'y2')) / 2)+ e.cmds.append('DELETE %s;' % mm(mid))+ if dry(args):+ return plan_reply(e, deleted=[wid(w) for _, w in targets], notFound=missing)+ e.run()+ # every other wire must still be there; junctions left joining fewer than three items go too+ before = {wid(w) for _, s_, b in segments(e.sheet) if not b for w in s_.findall('wire')}+ after = {wid(w) for _, s_, b in segments(e.sheet2) if not b for w in s_.findall('wire')}+ gone = {wid(w) for _, w in targets}+ if after != before - gone or not e.same_except(wires=False, junctions=False):+ e.undo('The wires read back were not the old ones minus the asked ones')+ # only the junctions at the deleted wires' own ends can have lost their reason to be there+ pp = pin_points(e.after, e.sheet2)+ ends = {(round(_sf(w, k1), 3), round(_sf(w, k2), 3)) for _, w in targets for k1, k2 in (('x1', 'y1'), ('x2', 'y2'))}+ orphans = [q for q in junction_points(e.sheet2) if q in ends and ends_at(e.sheet2, q, pins=pp) < 3]+ if orphans:+ e.extra_steps += len(e.cmds)+ e.cmds = ['DELETE %s;' % mm(q) for q in orphans]+ e.sheet = e.sheet2+ e.run()+ import collections+ if collections.Counter(junction_points(e.sheet2)) != collections.Counter(junction_points(e.sheet)) - collections.Counter(orphans) or not e.same_except(junctions=False):+ e.undo('Removing the junctions the delete left took more than those (%s)' % e.what_changed(junctions=False))+ return e.reply(deleted=sorted(gone), junctionsRemoved=[list(q) for q in orphans], notFound=missing)+++def route_points(a, b, first, via):+ pts = [tuple(a)] + [tuple(map(float, v)) for v in (via or [])]+ last = pts[-1]+ if abs(last[0] - b[0]) > 1e-6 and abs(last[1] - b[1]) > 1e-6:+ pts.append((b[0], last[1]) if first == 'h' else (last[0], b[1]))+ pts.append(tuple(b))+ out = [pts[0]]+ for p in pts[1:]:+ if math.hypot(p[0] - out[-1][0], p[1] - out[-1][1]) > 1e-6:+ out.append(p)+ return out+++def sch_connect_pins(app, args):+ e = Edit(app, args)+ pairs = args.get('pairs') or []+ if not pairs:+ sfail('bad_args', 'pairs is [{from: "U1.1", to: "R5.2", first?: h|v, via?: [[x, y]]}]')+ routes, failed = [], []+ for pr in pairs:+ try:+ a, b = find_pin(e.root, e.sheet, pr.get('from')), find_pin(e.root, e.sheet, pr.get('to'))+ except SchError as x:+ failed.append({'from': pr.get('from'), 'to': pr.get('to'), 'error': str(x)})+ continue+ first = pr.get('first') or ('h' if a['side'] in ('left', 'right') else 'v')+ pts = route_points(a['at'], b['at'], first, pr.get('via'))+ routes.append({'from': pr['from'], 'to': pr['to'], 'points': [list(map(_r4, p)) for p in pts], 'nets': [a.get('net'), b.get('net')]})+ # one command line, so SET CONFIRM answers "Connect A and B?" when the pins are on two nets+ e.cmds.append('SET CONFIRM YES; NET %s; SET CONFIRM OFF;' % ' '.join(mm(p) for p in pts))+ if dry(args):+ return plan_reply(e, routes=routes, failed=failed)+ if not e.cmds:+ sfail('nothing_to_do', 'No pair could be routed: %s' % failed)+ e.run()+ pins = {('%s.%s' % (p['ref'], p['pin'])): p for p in pins_with_connections(e.after, e.sheet2, connections=False)}+ def net_of(spec):+ p = find_pin(e.after, e.sheet2, spec)+ return pins['%s.%s' % (p['ref'], p['pin'])]['net']+ bad = [r for r in routes if not net_of(r['from']) or net_of(r['from']) != net_of(r['to'])]+ # every other pin keeps its net, unless that net was one of the joined ones (a route that+ # touched a third net on its way would join it too)+ joined = {n for r in routes for n in r['nets'] if n}+ before = {('%s.%s' % (p['ref'], p['pin'])): p['net'] for p in pins_with_connections(e.root, e.sheet, connections=False)}+ ends = {r['from'] for r in routes} | {r['to'] for r in routes}+ strays = sorted(k for k, n in before.items() if n and n not in joined and pins.get(k, {}).get('net') != n and k not in ends)+ if strays:+ e.undo('A route also joined pins of other nets (%s)' % ', '.join(strays[:8]))+ if bad or not e.same_except(wires=False, junctions=False, labels=False):+ e.undo('After the routes, %s are not on one net' % ', '.join('%s-%s' % (r['from'], r['to']) for r in bad) if bad else 'Parts, texts or buses changed while routing')+ # a T where a route's corner or end lands on another wire's middle gets a dot+ dots = []+ for r in routes:+ for p in r['points']:+ q = (round(p[0], 3), round(p[1], 3))+ if q not in junction_points(e.sheet2) and ends_at(e.sheet2, q, pins=pin_points(e.after, e.sheet2)) >= 3:+ dots.append(q)+ if dots:+ e.extra_steps += sum(1 for c in e.cmds if not c.startswith('GROUP'))+ e.cmds = ['JUNCTION %s;' % mm(q) for q in sorted(set(dots))]+ e.sheet = e.sheet2+ e.run()+ for r in routes:+ r['net'] = net_of(r['from'])+ r['merged'] = sorted({n for n in r.pop('nets') if n and n != r['net']})+ return e.reply(wired=len(routes), routes=routes, junctionsAdded=[list(q) for q in sorted(set(dots))], failed=failed)+++def sch_split_wire(app, args):+ if args.get('junction') is False:+ sfail('unsupported', 'Fusion splits a wire by placing a junction on it (SPLIT at one point does nothing); call with junction true, or delete the junction afterwards with sch_junction {remove}')+ e = Edit(app, args)+ targets, missing, wid = _wire_targets(e.sheet, {'ids': [args['id']]} if args.get('id') else {'at': args.get('at')})+ if not targets:+ sfail('not_found', 'No wire there')+ name, w = targets[0]+ a, b = (_sf(w, 'x1'), _sf(w, 'y1')), (_sf(w, 'x2'), _sf(w, 'y2'))+ p = tuple(map(float, args['at'])) if args.get('at') is not None else ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2)+ if _seg_dist(p, a, b) > 1e-3 or min(math.hypot(p[0] - a[0], p[1] - a[1]), math.hypot(p[0] - b[0], p[1] - b[1])) < 1e-3:+ sfail('bad_args', 'The cut must be inside the wire, not at an end')+ if len(nets_at(e.sheet, p)) > 1:+ sfail('would_join_nets', 'Wires of %s cross there: a junction would join them' % ' and '.join(sorted(nets_at(e.sheet, p))))+ e.cmds.append('SET CONFIRM NO; JUNCTION %s; SET CONFIRM OFF;' % mm(p))+ if dry(args):+ return plan_reply(e)+ e.run()+ ids = [wire_id(a[0], a[1], p[0], p[1]), wire_id(p[0], p[1], b[0], b[1])]+ after = {wid(x) for _, s_, bb in segments(e.sheet2) if not bb for x in s_.findall('wire')}+ if not all(i in after for i in ids) or not e.same_except(wires=False, junctions=False):+ e.undo('The wire did not read back as two wires meeting at the cut')+ return e.reply(ids=ids, junction='junction:%s' % _key(p))+++def sch_bus(app, args):+ e = Edit(app, args)+ pts = [tuple(map(float, p)) for p in (args.get('points') or [])]+ if len(pts) < 2:+ sfail('bad_args', 'points is [[x, y], [x, y], ...] (at least two)')+ label = args.get('label')+ e.cmds.append('BUS %s%s;' % ((quote(label) + ' ') if label else '', ' '.join(mm(p) for p in pts)))+ if label:+ e.cmds.append('LABEL %s %s;' % (mm(pts[0]), mm((pts[0][0], pts[0][1] + 1.27))))+ if dry(args):+ return plan_reply(e)+ e.run()+ cov = wire_cover(e.sheet2, 'buses')+ ok = e.same_except(buses=False, labels=False) and len(cov) >= len(wire_cover(e.sheet, 'buses'))+ if not ok:+ e.undo('The bus did not read back, or other items changed')+ ids = [wire_id(pts[k][0], pts[k][1], pts[k + 1][0], pts[k + 1][1], 'bus') for k in range(len(pts) - 1)]+ return e.reply(ids=ids, label=label)+++def sch_edit_label(app, args):+ e = Edit(app, args)+ labels = [(name, l) for name, seg, _ in segments(e.sheet) for l in seg.findall('label')]+ def lid(l):+ return 'label:%s' % _key((_sf(l, 'x'), _sf(l, 'y')))+ edits, failed, plan = args.get('edits') or [], [], []+ for ed in edits:+ if ed.get('net'):+ hits = [(n, l) for n, l in labels if n.lower() == str(ed['net']).lower()]+ else:+ key = ed.get('id') or ('label:%s' % _key(tuple(map(float, ed['at']))) if ed.get('at') else None)+ hits = [(n, l) for n, l in labels if lid(l) == key]+ if not hits:+ failed.append({'edit': ed, 'error': 'no label matches'})+ continue+ for n, l in hits:+ p = (_sf(l, 'x'), _sf(l, 'y'))+ q = p+ if ed.get('moveTo') is not None:+ q = tuple(map(float, ed['moveTo']))+ elif ed.get('by') is not None:+ q = (p[0] + float(ed['by'][0]), p[1] + float(ed['by'][1]))+ if q != p:+ e.cmds.append('SET CONFIRM NO; MOVE %s %s; SET CONFIRM OFF;' % (mm(p), mm(q)))+ if ed.get('rotation') is not None:+ e.cmds.append('ROTATE =R%g %s;' % (float(ed['rotation']) % 360, mm(q)))+ if ed.get('rename'):+ # the label shows its net's name: renaming it renames the net (all of it on this sheet);+ # onto an existing name Fusion asks "Connect A and B?", answered on the same line+ e.cmds.append('SET CONFIRM YES; NAME %s %s; SET CONFIRM OFF;' % (quote(ed['rename']), mm(q)))+ plan.append({'id': lid(l), 'net': n, 'to': list(q), 'rotation': ed.get('rotation'), 'rename': ed.get('rename')})+ if dry(args):+ return plan_reply(e, edited=plan, failed=failed)+ if not e.cmds:+ sfail('nothing_to_do', 'No label edit applies: %s' % failed)+ e.run()+ after = {(round(_sf(l, 'x'), 3), round(_sf(l, 'y'), 3)): (name, l.get('rot', 'R0')) for name, seg, _ in segments(e.sheet2) for l in seg.findall('label')}+ bad = []+ for pl in plan:+ got = after.get((round(pl['to'][0], 3), round(pl['to'][1], 3)))+ if got is None or (pl['rename'] and got[0] != pl['rename']) or (pl['rotation'] is not None and _rot(got[1])[1] % 360 != float(pl['rotation']) % 360):+ bad.append(pl['id'])+ renamed = any(pl['rename'] for pl in plan)+ copper_ok = (_cover_by_line(wire_cover(e.sheet)) == _cover_by_line(wire_cover(e.sheet2))) if renamed else wire_cover(e.sheet) == wire_cover(e.sheet2)+ if bad or not copper_ok or not e.same_except(wires=False, labels=False, junctions=not renamed):+ e.undo('Labels %s did not read back as asked' % ', '.join(bad) if bad else 'Wires or parts changed while editing labels')+ return e.reply(edited=plan, failed=failed)+++def sheet_grid_mm(root):+ g = root.find('drawing/grid')+ if g is None:+ return 2.54+ d = float(g.get('distance', '0.1'))+ unit = g.get('unitdist', 'inch')+ return d * {'inch': 25.4, 'mil': 0.0254, 'mm': 1.0, 'mic': 0.001}.get(unit, 25.4)+++def sch_move_region(app, args):+ """Move a box's contents as one GROUP. Fusion snaps a group's outline to the sheet grid (live+ 2026-10-27: an edge at 147.16 took a part at 147.32), so the box is snapped here first and the+ parts it takes are checked against the asked box before anything moves. Wires on the pins of a+ moved part follow it (Fusion rubber-bands them), so the check is: parts, texts and labels in the+ box moved exactly, nothing outside moved, and every pin is on the net it was on."""+ e = Edit(app, args)+ box, by = args.get('box'), args.get('by')+ if not box or len(box) != 4 or not by or len(by) != 2:+ sfail('bad_args', 'box is [x1, y1, x2, y2] and by is [dx, dy] (mm)')+ if any(args.get(k) is False for k in ('wires', 'labels', 'junctions')):+ sfail('unsupported', 'Fusion moves a group whole: wires, labels and junctions inside the box always move with it')+ x1, y1, x2, y2 = min(box[0], box[2]), min(box[1], box[3]), max(box[0], box[2]), max(box[1], box[3])+ G = sheet_grid_mm(e.root)+ snap = lambda v: round(round(v / G) * G, 4)+ sx1, sy1, sx2, sy2 = snap(x1), snap(y1), snap(x2), snap(y2)+ if sx2 <= sx1 or sy2 <= sy1:+ sfail('bad_args', 'The box is smaller than the sheet grid (%g mm)' % G)+ in_box = lambda p: x1 - 1e-6 <= p[0] <= x2 + 1e-6 and y1 - 1e-6 <= p[1] <= y2 + 1e-6+ inside = lambda p: sx1 - 1e-6 <= p[0] <= sx2 + 1e-6 and sy1 - 1e-6 <= p[1] <= sy2 + 1e-6+ asked = {r[0] for r in part_rows(e.sheet) if in_box((r[2], r[3]))}+ taken = {r[0] for r in part_rows(e.sheet) if inside((r[2], r[3]))}+ if asked != taken:+ sfail('box_edge_off_grid', 'Fusion snaps a group to the sheet grid (%g mm): this box becomes [%g, %g, %g, %g], which takes %s and leaves %s. '+ 'Give the box on the grid so it separates the parts the way you want' %+ (G, sx1, sy1, sx2, sy2, sorted(taken - asked) or 'no extra part', sorted(asked - taken) or 'no part out'))+ anchor = None+ for r in part_rows(e.sheet):+ if inside((r[2], r[3])):+ anchor = (r[2], r[3])+ break+ if anchor is None:+ for _, seg, _ in segments(e.sheet):+ for w in seg.findall('wire'):+ q = (_sf(w, 'x1'), _sf(w, 'y1'))+ if inside(q) and inside((_sf(w, 'x2'), _sf(w, 'y2'))):+ anchor = q+ break+ if anchor:+ break+ if anchor is None:+ for x, y, _ in text_rows(e.sheet):+ if inside((x, y)):+ anchor = (x, y)+ break+ if anchor is None:+ sfail('nothing_to_do', 'Nothing in that box to move')+ dx, dy = float(by[0]), float(by[1])+ e.cmds.append('GROUP %s %s %s %s %s;' % (mm((sx1, sy1)), mm((sx2, sy1)), mm((sx2, sy2)), mm((sx1, sy2)), mm((sx1, sy1))))+ # a pin landing on another net's wire makes Fusion ask "Merge net A into B?": No, on the same+ # line (asked separately it is not answered); Fusion then makes no move at all+ e.cmds.append('SET CONFIRM NO; MOVE (C>%.4fmm %.4fmm) %s; SET CONFIRM OFF;' % (anchor[0], anchor[1], mm((anchor[0] + dx, anchor[1] + dy))))+ boxed = [sx1, sy1, sx2, sy2]+ if dry(args):+ return plan_reply(e, moved={'parts': sorted(taken)}, boxUsed=boxed)+ nets_before = {('%s.%s' % (p['ref'], p['pin'])): p['net'] for p in pins_with_connections(e.root, e.sheet, connections=False)}+ e.run()+ # a group MOVE leaves global (XREF) labels where they were (live 2026-10-27): move each label+ # inside the box that the group left behind on its own+ before_l = [(n, round(x, 3), round(y, 3)) for n, x, y, _, _ in label_rows(e.sheet) if inside((x, y))]+ after_l = {(n, round(x, 3), round(y, 3)) for n, x, y, _, _ in label_rows(e.sheet2)}+ left = [(n, x, y) for n, x, y in before_l if (n, x, y) in after_l and (n, round(x + dx, 3), round(y + dy, 3)) not in after_l]+ if left:+ e.again(['SET CONFIRM NO; MOVE %s %s; SET CONFIRM OFF;' % (mm((x, y)), mm((x + dx, y + dy))) for _, x, y in left])+ import collections+ shift = lambda x, y: (round(x + dx, 3), round(y + dy, 3)) if inside((x, y)) else (round(x, 3), round(y, 3))+ # a scripted group MOVE moves wires, labels and texts but not the parts in it (live 2026-10-27):+ # each part inside that has not moved is moved by name, and the wires on its pins follow it+ after = {(r[0], r[1]): r for r in part_rows(e.sheet2)}+ gates = collections.Counter(r[0] for r in part_rows(e.sheet))+ stay = [r for r in part_rows(e.sheet) if inside((r[2], r[3])) and (round(after[(r[0], r[1])][2], 3), round(after[(r[0], r[1])][3], 3)) != shift(r[2], r[3])]+ if stay:+ e.again(['SET CONFIRM NO; MOVE %s %s; SET CONFIRM OFF;' % (quote(r[0] + (r[1] if gates[r[0]] > 1 else '')), mm(shift(r[2], r[3]))) for r in stay])+ after = {(r[0], r[1]): r for r in part_rows(e.sheet2)}+ bad = [r[0] for r in part_rows(e.sheet) if (round(after[(r[0], r[1])][2], 3), round(after[(r[0], r[1])][3], 3)) != shift(r[2], r[3])]+ texts = collections.Counter((*shift(x, y), t) for x, y, t in text_rows(e.sheet)) == collections.Counter((round(x, 3), round(y, 3), t) for x, y, t in text_rows(e.sheet2))+ labels = collections.Counter((n, *shift(x, y)) for n, x, y, _, _ in label_rows(e.sheet)) == collections.Counter((n, round(x, 3), round(y, 3)) for n, x, y, _, _ in label_rows(e.sheet2))+ nets_after = {('%s.%s' % (p['ref'], p['pin'])): p['net'] for p in pins_with_connections(e.after, e.sheet2, connections=False)}+ lost = sorted(k for k, n in nets_before.items() if nets_after.get(k) != n)+ if e.meta2['revision'] == e.meta['revision']:+ sfail('would_join_nets', 'Fusion made no move: at the new place a pin or wire end lands on another net, and joining nets is declined. Choose another offset, or clear that spot first (sch_items and sch_trace_point show what is there)')+ if bad or not texts or not labels or lost:+ e.undo('After the move: ' + '; '.join(x for x in (+ ('parts not moved exactly: %s' % ', '.join('%s %s -> %s, asked %s' % (b, [r[2], r[3]], [after[(r[0], r[1])][2], after[(r[0], r[1])][3]], list(shift(r[2], r[3])))+ for b in bad[:4] for r in part_rows(e.sheet) if r[0] == b)) if bad else '', '' if texts else 'texts did not move as asked',+ '' if labels else 'labels did not move as asked', ('pins changed net: %s' % ', '.join(lost[:8])) if lost else '') if x))+ return e.reply(moved={'parts': sorted(taken), 'by': [dx, dy]}, boxUsed=boxed)+++def sch_fix_connectivity(app, args):+ e = Edit(app, args)+ tol = float(args.get('toleranceMm', 0.5))+ pins = pins_with_connections(e.root, e.sheet, connections=False)+ targets = [tuple(p['at']) for p in pins]+ ends = []+ for name, seg, bus in segments(e.sheet):+ if bus:+ continue+ for w in seg.findall('wire'):+ for k in ('1', '2'):+ ends.append((name, (_sf(w, 'x' + k), _sf(w, 'y' + k)), w))+ # every point a wire end can join, with the net it belongs to+ pts = [(tuple(p['at']), p['net']) for p in pins] + [(p, name) for name, p, _ in ends] + \+ [((_sf(l, 'x'), _sf(l, 'y')), name) for name, seg, _ in segments(e.sheet) for l in seg.findall('label')]+ auto = lambda n: not n or re.fullmatch(r'N\$\d+', n) is not None+ moves, unresolved = [], []+ for name, p, w in ends:+ if len([q for q, _ in pts if math.hypot(p[0] - q[0], p[1] - q[1]) <= 1e-3]) > 1:+ continue # it already meets something+ near = sorted((math.hypot(p[0] - q[0], p[1] - q[1]), q, n) for q, n in pts if 1e-3 < math.hypot(p[0] - q[0], p[1] - q[1]) <= tol)+ if not near:+ continue+ if len(near) > 1 and abs(near[0][0] - near[1][0]) < 1e-3 and near[0][1] != near[1][1]:+ unresolved.append({'net': name, 'at': _pt(*p), 'why': 'two targets at the same distance'})+ continue+ d, q, other = near[0]+ if other and other != name and not auto(name) and not auto(other):+ unresolved.append({'net': name, 'at': _pt(*p), 'why': 'it almost touches net %s: joining two named nets is left to you' % other})+ continue+ # two loose ends near each other: move only one onto the other, or they swap places+ if any(m['from'] == _pt(*q) and m['to'] == _pt(*p) for m in moves):+ continue+ moves.append({'net': name, 'from': _pt(*p), 'to': _pt(*q), 'distanceMm': _r4(d), 'joins': other if other and other != name else None})+ if args.get('dryRun', True) is not False:+ return {'success': True, 'mutated': False, 'dryRun': True, **e.meta, 'sheet': e.n, 'moves': moves, 'unresolved': unresolved, 'saved': False}+ for m in moves:+ # the join is the point (only an unnamed N$ net is joined to another): answered yes on the line+ e.cmds.append('SET CONFIRM %s; MOVE %s %s; SET CONFIRM OFF;' % ('YES' if m['joins'] else 'NO', mm(m['from']), mm(m['to'])))+ if not e.cmds:+ return {'success': True, 'mutated': False, **e.meta, 'sheet': e.n, 'moves': [], 'unresolved': unresolved, 'saved': False}+ e.run()+ after_pts = [(_sf(w, 'x' + k), _sf(w, 'y' + k)) for _, seg, b in segments(e.sheet2) if not b for w in seg.findall('wire') for k in ('1', '2')]+ bad = [m for m in moves if not any(math.hypot(q[0] - m['to'][0], q[1] - m['to'][1]) <= 1e-3 for q in after_pts)]+ if bad or not e.same_except(wires=False, junctions=False, labels=False):+ e.undo('Wire ends did not land where asked')+ return e.reply(moves=moves, unresolved=unresolved)+++def bus_members(name):+ """EAGLE bus names: comma-separated members, each a name or a range such as D[0..7]."""+ out = []+ for part in re.split(r'[,\s]+', (name or '').strip('{} ')):+ m = re.fullmatch(r'(.*)\[(\d+)\.\.(\d+)\](.*)', part)+ if m:+ a, b = int(m[2]), int(m[3])+ step = 1 if b >= a else -1+ out += ['%s%d%s' % (m[1], k, m[4]) for k in range(a, b + step, step)]+ elif part:+ out.append(part)+ return out+++def bus_segments(sheet):+ return [(name, (_sf(w, 'x1'), _sf(w, 'y1')), (_sf(w, 'x2'), _sf(w, 'y2'))) for name, seg, bus in segments(sheet) if bus for w in seg.findall('wire')]+++ENTRY = {'down-right': (1, -1), 'up-right': (1, 1), 'down-left': (-1, -1), 'up-left': (-1, 1)}+++def sch_bus_entry(app, args):+ """A 45-degree entry from a member wire's end onto a bus. Fusion has no separate entry object:+ it is a short net wire named for its bus member, drawn from the bus side (a net started on a+ bus with its member named raises no member menu) in a straight bend style."""+ e = Edit(app, args)+ buses = bus_segments(e.sheet)+ added, cmds = [], []+ for en in args.get('entries') or []:+ at = tuple(map(float, en['at']))+ sx, sy = ENTRY.get(en.get('direction', 'down-right'), (None, None))+ if sx is None:+ sfail('bad_args', 'direction is up-right, down-right, up-left or down-left')+ size = float(en.get('sizeMm', 2.54))+ end = (at[0] + sx * size, at[1] + sy * size)+ on = [b for b in buses if _seg_dist(end, b[1], b[2]) <= 1e-3]+ if not on:+ sfail('entry_off_bus', 'The entry from %s would end at %s, which is on no bus: extend the bus with sch_bus first' % (_key(at), _key(end)))+ member = en.get('member') or next(iter(nets_at(e.sheet, at)), None)+ if not member:+ sfail('bad_args', 'No wire ends at %s: give member (the bus member the entry carries)' % _key(at))+ if member not in bus_members(on[0][0]):+ sfail('not_a_member', '%s is not a member of bus %s (%s)' % (member, on[0][0], ', '.join(bus_members(on[0][0]))[:200]))+ cmds.append('SET CONFIRM NO; SET WIRE_BEND 2; NET %s %s %s; SET WIRE_BEND 0; SET CONFIRM OFF;' % (quote(member), mm(end), mm(at)))+ added.append({'id': wire_id(at[0], at[1], end[0], end[1], 'entry'), 'at': list(map(_r4, at)), 'end': list(map(_r4, end)), 'onBus': True, 'member': member})+ if not added:+ sfail('bad_args', 'entries is [{at: [x, y], direction?: down-right, member?}]')+ e.cmds = cmds+ if dry(args):+ return plan_reply(e, added=added)+ e.run()+ got = {x['id'] for x in items(e.after, e.sheet2, ['busEntries'])['busEntries']}+ missing = [a['id'] for a in added if a['id'] not in got]+ if missing or not e.same_except(wires=False, junctions=False, labels=False):+ e.undo('Entries %s did not read back' % ', '.join(missing) if missing else 'Parts, texts or buses changed')+ return e.reply(added=added)+++def sch_connect_to_bus(app, args):+ """Fan pins onto a bus: per pin, a wire from the pin to beside the bus, a 45-degree entry onto+ it and a member label on the wire. Only a pin that is unconnected, or already on that member's+ net, is wired: anything else would join two nets."""+ e = Edit(app, args)+ buses = bus_segments(e.sheet)+ if args.get('bus'):+ buses = [b for b in buses if wire_id(b[1][0], b[1][1], b[2][0], b[2][1], 'bus') == args['bus'] or b[0] == args['bus']]+ names = sorted({b[0] for b in buses})+ if len(names) != 1:+ sfail('bad_args', 'Name the bus (bus: its id or name); the sheet has %s' % (', '.join(names) or 'none'))+ pins_ = args.get('pins') or []+ members = args.get('members') or ['%s%d' % (args.get('prefix', 'D'), int(args.get('start', 0)) + k) for k in range(len(pins_))]+ if len(members) != len(pins_):+ sfail('bad_args', 'members must name one member per pin')+ allowed = bus_members(names[0])+ connected, failed, cmds = [], [], []+ for spec, member in zip(pins_, members):+ try:+ pn = find_pin(e.root, e.sheet, spec)+ except SchError as x:+ failed.append({'pin': spec, 'error': str(x)})+ continue+ if member not in allowed:+ failed.append({'pin': spec, 'error': '%s is not a member of bus %s' % (member, names[0])})+ continue+ if pn['net'] and pn['net'] != member:+ failed.append({'pin': spec, 'error': 'already on net %s: wiring it to %s would join the two' % (pn['net'], member)})+ continue+ P = tuple(pn['at'])+ # the bus segment nearest the pin, and the point on it the entry reaches+ best = min(buses, key=lambda b: _seg_dist(P, b[1], b[2]))+ (ax, ay), (bx, by) = best[1], best[2]+ horizontal = abs(ay - by) < 1e-6+ lean = args.get('entry') or ('right' if horizontal else 'up')+ d = 2.54+ if horizontal:+ side = 1 if P[1] > ay else -1 # pin above or below the bus+ sx = 1 if lean == 'right' else -1+ near = (P[0], ay + side * d) # end of the member wire+ onbus = (P[0] + sx * d, ay)+ else:+ side = 1 if P[0] > ax else -1+ sy = 1 if lean == 'up' else -1+ near = (ax + side * d, P[1])+ onbus = (ax, P[1] + sy * d)+ lo, hi = (min(ax, bx), max(ax, bx)) if horizontal else (min(ay, by), max(ay, by))+ if not lo - 1e-6 <= (onbus[0] if horizontal else onbus[1]) <= hi + 1e-6:+ failed.append({'pin': spec, 'error': 'entry_off_bus: the entry would end at %s, past the bus: extend it with sch_bus first' % _key(onbus)})+ continue+ route = route_points(P, near, 'h' if pn['side'] in ('left', 'right') else 'v', None)+ pts = [onbus] + list(reversed(route))+ cmds.append('SET CONFIRM NO; SET WIRE_BEND 2; NET %s %s; SET WIRE_BEND 0; SET CONFIRM OFF;' % (quote(member), ' '.join(mm(p) for p in pts)))+ lab = ((near[0] + route[-2][0]) / 2, (near[1] + route[-2][1]) / 2) if len(route) > 1 else near+ cmds.append('LABEL %s %s;' % (mm(lab), mm(lab)))+ connected.append({'pin': spec, 'member': member, 'wireId': wire_id(near[0], near[1], route[-2][0], route[-2][1]) if len(route) > 1 else None,+ 'entryId': wire_id(onbus[0], onbus[1], near[0], near[1], 'entry')})+ if args.get('busLabel'):+ ax, ay = buses[0][1]+ cmds.append('LABEL %s %s;' % (mm((ax, ay)), mm((ax, ay + 1.27))))+ e.cmds = cmds+ if dry(args):+ return plan_reply(e, connected=connected, failed=failed, busLabel=args.get('busLabel'))+ if not connected:+ sfail('nothing_to_do', 'No pin could be wired: %s' % failed)+ e.run()+ after = {('%s.%s' % (p['ref'], p['pin'])): p['net'] for p in pins_with_connections(e.after, e.sheet2, connections=False)}+ bad = []+ for c in connected:+ p = find_pin(e.after, e.sheet2, c['pin'])+ if after.get('%s.%s' % (p['ref'], p['pin'])) != c['member']:+ bad.append(c['pin'])+ if bad or not e.same_except(wires=False, junctions=False, labels=False):+ e.undo('Pins %s did not read back on their member nets' % ', '.join(bad) if bad else 'Parts, texts or buses changed')+ return e.reply(connected=connected, failed=failed, busLabel=args.get('busLabel'))+++FIELD = {'designator': 'NAME', 'value': 'VALUE', 'name': 'NAME'}+++def field_home(lib, inst, field):+ """Where the library symbol places a field of this part: its >FIELD text, through the part's+ turn and mirror. Every field at its default on BQ25792 (155 of them) carries the part's own+ rotation and the symbol text's alignment, which is the rule used here."""+ sym, _ = lib.gate_symbol(inst.get('part'), inst.get('gate'))+ if sym is None:+ return None+ t = next((t for t in sym.findall('text') if (t.text or '').strip() == '>' + field), None)+ if t is None:+ return None+ x, y = transform(_sf(t, 'x'), _sf(t, 'y'), _sf(inst, 'x'), _sf(inst, 'y'), inst.get('rot', 'R0'))+ mi, ai = _rot(inst.get('rot'))+ mt, at_ = _rot(t.get('rot'))+ ang = (ai + (-at_ if mi else at_)) % 360+ return {'at': (x, y), 'rot': ('M' if mi != mt else '') + 'R%g' % ang, 'align': t.get('align', 'bottom-left')}+++def sch_reset_fields(app, args):+ e = Edit(app, args)+ lib = Lib(e.root.find('drawing/schematic'))+ refs = set(args.get('references') or [])+ if not refs and not args.get('all'):+ sfail('bad_args', 'Give references, or all:true')+ fields = [FIELD.get(f.lower(), f.upper()) for f in (args.get('fields') or ['designator', 'value'])]+ plan, cmds, untouched = [], [], []+ for inst in e.sheet.findall('instances/instance'):+ ref = inst.get('part')+ if refs and ref not in refs:+ continue+ smashed = {a.get('name'): a for a in inst.findall('attribute') if a.get('x') is not None}+ for f in fields:+ a = smashed.get(f)+ home = field_home(lib, inst, f)+ if a is None or home is None:+ untouched.append({'ref': ref, 'field': f, 'why': 'not smashed: already where the symbol puts it' if a is None else 'the symbol has no >%s text' % f})+ continue+ cur = (_sf(a, 'x'), _sf(a, 'y'))+ steps = []+ if math.hypot(cur[0] - home['at'][0], cur[1] - home['at'][1]) > 1e-3:+ steps.append('SET CONFIRM NO; MOVE %s %s; SET CONFIRM OFF;' % (mm(cur), mm(home['at'])))+ if (a.get('rot') or 'R0') != home['rot']:+ steps.append('ROTATE =%s %s;' % (home['rot'], mm(home['at'])))+ if (a.get('align') or 'bottom-left') != home['align']:+ steps.append('CHANGE ALIGN %s %s;' % (home['align'], mm(home['at'])))+ if steps:+ cmds += steps+ plan.append({'ref': ref, 'field': f, 'from': list(map(_r4, cur)), 'to': list(map(_r4, home['at'])), 'rot': home['rot'], 'align': home['align']})+ if refs:+ missing = sorted(refs - {i.get('part') for i in e.sheet.findall('instances/instance')})+ if missing:+ sfail('designator_not_found', 'No part %s on sheet %d' % (', '.join(missing), e.n))+ e.cmds = cmds+ if dry(args):+ return plan_reply(e, reset=plan, untouched=untouched)+ if not cmds:+ return {'success': True, 'mutated': False, **e.meta, 'sheet': e.n, 'reset': [], 'untouched': untouched, 'saved': False}+ e.run()+ got = {(i.get('part'), a.get('name')): a for i in e.sheet2.findall('instances/instance') for a in i.findall('attribute')}+ bad = [p_ for p_ in plan if (p_['ref'], p_['field']) not in got+ or abs(_sf(got[(p_['ref'], p_['field'])], 'x') - p_['to'][0]) > 1e-3 or abs(_sf(got[(p_['ref'], p_['field'])], 'y') - p_['to'][1]) > 1e-3+ or (got[(p_['ref'], p_['field'])].get('rot') or 'R0') != p_['rot'] or (got[(p_['ref'], p_['field'])].get('align') or 'bottom-left') != p_['align']]+ if bad or not e.same_except():+ e.undo('Fields %s did not read back where the symbol puts them' % ', '.join('%s.%s' % (b['ref'], b['field']) for b in bad[:8]) if bad else 'Something besides the fields changed (%s)' % e.what_changed())+ return e.reply(reset=[{'ref': p_['ref'], 'field': p_['field']} for p_ in plan], untouched=untouched)+++def sch_create(app, args):+ """A new, empty sheet after the last one (Fusion numbers sheets; it has no sheet files)."""+ for k in ('paper', 'orientation', 'widthMm', 'heightMm', 'title', 'date', 'rev', 'company', 'comments'):+ if args.get(k):+ sfail('unsupported', 'Fusion sets a page through a frame part and design attributes that its commands do not reach: create the sheet without %s' % k)+ for k in ('project', 'sheetName', 'at'):+ if args.get(k):+ sfail('unsupported', 'A child sheet under a sheet symbol is a Fusion module, which this verb does not create: leave out %s' % k)+ a = {k: v for k, v in args.items() if k != 'sheet'}+ e = Edit(app, a)+ count = len(sheets_of(e.root))+ want = args.get('sheet')+ if want not in (None, count + 1) and str(want) != str(count + 1):+ sfail('bad_args', 'Fusion adds sheets in order: the next one is %d' % (count + 1))+ e.cmds = ['SET CONFIRM YES; EDIT .s%d; SET CONFIRM OFF;' % (count + 1), 'SET CONFIRM NO; EDIT .s%d; SET CONFIRM OFF;' % e.n]+ if dry(args):+ return plan_reply(e, page=count + 1, created=False)+ e.run()+ if len(sheets_of(e.after)) != count + 1 or not e.same_except():+ e.undo('The schematic did not read back with one sheet more')+ return e.reply(sheet=count + 1, page=count + 1, sheetSymbol=None, created=True)+++EDITS = {'sch_text': sch_text, 'sch_junction': sch_junction, 'sch_delete_wire': sch_delete_wire, 'sch_connect_pins': sch_connect_pins,+ 'sch_split_wire': sch_split_wire, 'sch_bus': sch_bus, 'sch_edit_label': sch_edit_label, 'sch_move_region': sch_move_region,+ 'sch_fix_connectivity': sch_fix_connectivity, 'sch_bus_entry': sch_bus_entry, 'sch_connect_to_bus': sch_connect_to_bus, 'sch_reset_fields': sch_reset_fields, 'sch_create': sch_create}+++def drawing_extent(root, sheet):+ """The sheet's drawn extent in mm (wires, pins with their length, symbol bodies, labels,+ texts): EXPORT IMAGE crops to it, so it maps the image back to sheet coordinates (to within+ a text's overhang)."""+ lib = Lib(root.find('drawing/schematic'))+ xs, ys = [], []+ def add(x, y):+ xs.append(x)+ ys.append(y)+ for _, seg, _ in segments(sheet):+ for w in seg.findall('wire'):+ add(_sf(w, 'x1'), _sf(w, 'y1'))+ add(_sf(w, 'x2'), _sf(w, 'y2'))+ for l in seg.findall('label'):+ add(_sf(l, 'x'), _sf(l, 'y'))+ def text_box(x, y, txt, size, spec, frame=None):+ """A text's box, by the vector font's proportions (a character is about 0.8 x its size+ wide): the export includes text, and tall rotated values reach well past the parts."""+ L = max(1, len(txt or '')) * size * 0.8+ corners = [(0, 0), (L, 0), (L, size), (0, size)]+ mirrored, deg = _rot(spec)+ for cx, cy in corners:+ px_, py_ = transform(cx, cy, x, y, spec)+ if frame:+ px_, py_ = transform(px_, py_, *frame)+ add(px_, py_)+ for t in sheet.findall('plain/text'):+ text_box(_sf(t, 'x'), _sf(t, 'y'), t.text, _sf(t, 'size', 1.778), t.get('rot', 'R0'))+ for inst in sheet.findall('instances/instance'):+ sym, _ = lib.gate_symbol(inst.get('part'), inst.get('gate'))+ if sym is None:+ continue+ ix, iy, spec = _sf(inst, 'x'), _sf(inst, 'y'), inst.get('rot', 'R0')+ part = lib.parts.get(inst.get('part'))+ names = {'>NAME': inst.get('part'), '>VALUE': (part.get('value') if part is not None else '') or (part.get('deviceset') if part is not None else '')}+ smashed = {a.get('name'): a for a in inst.findall('attribute')}+ for a in smashed.values():+ if a.get('x') is not None and a.get('display', 'value') != 'off':+ text_box(_sf(a, 'x'), _sf(a, 'y'), names.get('>' + a.get('name', ''), a.get('value', '')), _sf(a, 'size', 1.778), a.get('rot', 'R0'))+ for t in sym.findall('text'):+ key = (t.text or '').strip()+ if key.startswith('>') and key[1:] in smashed:+ continue # drawn where the smashed attribute says+ text_box(_sf(t, 'x'), _sf(t, 'y'), names.get(key, key), _sf(t, 'size', 1.778), t.get('rot', 'R0'), (ix, iy, spec))+ for w in sym.findall('wire'):+ for k in ('1', '2'):+ add(*transform(_sf(w, 'x' + k), _sf(w, 'y' + k), ix, iy, spec))+ for r_ in sym.findall('rectangle'):+ for k in ('1', '2'):+ add(*transform(_sf(r_, 'x' + k), _sf(r_, 'y' + k), ix, iy, spec))+ for pn in sym.findall('pin'):+ add(*transform(_sf(pn, 'x'), _sf(pn, 'y'), ix, iy, spec))+ if not xs:+ sfail('empty_sheet', 'The sheet has nothing drawn on it')+ return min(xs), min(ys), max(xs), max(ys)+++def sheet_area(app, n):+ with tempfile.TemporaryDirectory(prefix='adom-sch-area-') as d:+ out, ulp = Path(d) / 'a.txt', Path(d) / 'a.ulp'+ ulp.write_text('schematic(S) { output("%s") { S.sheets(SH) { printf("%%d %%f %%f %%f %%f\\n", SH.number, u2mm(SH.area.x1), u2mm(SH.area.y1), u2mm(SH.area.x2), u2mm(SH.area.y2)); } } }' % out.as_posix())+ app.executeTextCommand("Electron.run RUN '%s';" % ulp.as_posix())+ for line in out.read_text().splitlines():+ k, a, b, c, e = line.split()+ if int(k) == n:+ return float(a), float(b), float(c), float(e)+ raise SchError('render_failed', 'Fusion reported no area for sheet %d' % n)+++def sch_render(app, args):+ """The sheet as a PNG through Fusion's own EXPORT IMAGE, without opening a window: dpi is+ chosen so the image is about width pixels wide."""+ root, meta, tabs = sch_snapshot(app, args)+ n, sheet = pick_sheet(root, args)+ if args.get('drawingSheet') is False:+ sfail('unsupported', 'Fusion draws the frame as a part on the sheet: drawingSheet false is not offered')+ out = args.get('png')+ if not out:+ sfail('bad_args', 'png: the file to write is required (the bridge fills it in)')+ i, j = tabs+ try:+ app.executeTextCommand('UI.SwitchToTab %d' % i)+ shown = current_sheet(app) if len(sheets_of(root)) > 1 else None+ if shown and shown != n:+ app.executeTextCommand('Electron.run SET CONFIRM NO; EDIT .s%d; SET CONFIRM OFF;' % n)+ # the sheet's drawn area as Fusion counts it (UL_SHEET.area), which is what EXPORT IMAGE+ # crops to; an estimate from the XML missed tall rotated texts by 24 mm+ try:+ x0, y0, x1, y1 = sheet_area(app, n)+ except Exception:+ x0, y0, x1, y1 = drawing_extent(root, sheet)+ width = int(args.get('width', 2000))+ dpi = max(20, min(1200, int(round(width / max((x1 - x0) / 25.4, 0.1)))))+ # SET CONFIRM on the same line answers "overwrite?" when the file exists+ app.executeTextCommand("Electron.run SET CONFIRM YES; EXPORT IMAGE %s %d; SET CONFIRM OFF;" % (quote(out), dpi))+ if shown and shown != n:+ app.executeTextCommand('Electron.run SET CONFIRM NO; EDIT .s%d; SET CONFIRM OFF;' % shown)+ finally:+ if j is not None and j != i:+ app.executeTextCommand('UI.SwitchToTab %d' % j)+ if not Path(out).exists():+ sfail('render_failed', 'Fusion did not write %s' % out)+ head = Path(out).read_bytes()[:24]+ import struct+ w, h = struct.unpack('>II', head[16:24])+ return {'success': True, 'mutated': False, **meta, 'sheet': n, 'png': out, 'width': w, 'height': h, 'dpi': dpi,+ 'extentMm': [_r4(x0), _r4(y0), _r4(x1), _r4(y1)], 'fusionVersion': app.version}+++def handle(app, args, action):+ try:+ if action in EDITS:+ return EDITS[action](app, args)+ if action == 'sch_render':+ return sch_render(app, args)+ root, meta, _ = sch_snapshot(app, args)+ n, sheet = pick_sheet(root, args) if action != 'sch_hierarchy' else (None, None)+ base = {'success': True, 'mutated': False, **meta, 'sheet': n, 'sheetCount': len(sheets_of(root))}+ if action == 'sch_items':+ return {**base, **items(root, sheet, args.get('kinds')),+ '_hint': 'Ids are built from each item\'s kind and position (Fusion stores none): pass them to the sch_* edit verbs; an id stops matching once its item moves. Parts are in schematic_data or sch_pins.'}+ if action == 'sch_pins':+ refs = args.get('references') or args.get('refs')+ if isinstance(refs, str):+ refs = [refs]+ pins = pins_with_connections(root, sheet, refs, args.get('connections', True) is not False)+ if refs:+ missing = sorted(set(refs) - {p['ref'] for p in pins})+ if missing and len(missing) == len(set(refs)):+ sfail('designator_not_found', 'No part %s on sheet %d' % (', '.join(missing), n))+ return {**base, 'pins': pins, 'count': len(pins),+ '_hint': 'pin is the symbol pin name and number the pad(s) it maps to; at is the connection point (mm, y up); side is where it leaves the body. connectedPins are the pins on the same wire segment.'}+ if action == 'sch_trace_point':+ at = args.get('at')+ if isinstance(at, dict):+ at = [at.get('x'), at.get('y')]+ if not at or len(at) != 2:+ sfail('bad_args', 'at is [x, y] in mm')+ return {**base, **trace_point(root, sheet, at, float(args.get('toleranceMm', 0.01)))}+ if action == 'sch_page':+ if any(k in args for k in ('paper', 'orientation', 'widthMm', 'heightMm', 'title', 'date', 'rev', 'company', 'comments')):+ sfail('unsupported', 'Setting the page is not built yet in the Fusion bridge: this answers the page as it is')+ return {**base, **page(root, sheet), 'changed': False}+ if action == 'sch_sheet_pins':+ if (args.get('action') or 'list') != 'list' or args.get('import'):+ sfail('unsupported', 'Fusion sheet symbols are module instances whose pins are the module ports: add, rename or delete the port in the module (Fusion\'s PORT command); this verb lists and validates')+ out, problems = [], []+ for mi in sheet.findall('moduleinsts/moduleinst'):+ if args.get('sheetSymbol') and args['sheetSymbol'] not in (mi.get('name'), 'sheet:%s' % mi.get('name')):+ continue+ mod = next((m for m in root.findall('drawing/schematic/modules/module') if m.get('name') == mi.get('module')), None)+ if mod is None:+ problems.append({'sheetSymbol': mi.get('name'), 'pin': None, 'problem': 'no_module'})+ continue+ used = {pr.get('port') for _, seg, _ in segments(sheet) for pr in seg.findall('portref') if pr.get('moduleinst') == mi.get('name')}+ for p_ in mod.findall('ports/port'):+ out.append({'id': 'port:%s:%s' % (mi.get('name'), p_.get('name')), 'sheetSymbol': mi.get('name'), 'name': p_.get('name'),+ 'kind': p_.get('direction', 'io'), 'side': p_.get('side'), 'coord': _sf(p_, 'coord'), 'connected': p_.get('name') in used})+ if args.get('validate') and p_.get('name') not in used:+ problems.append({'sheetSymbol': mi.get('name'), 'pin': p_.get('name'), 'problem': 'not_wired'})+ return {**base, 'pins': out, 'added': [], 'problems': problems, 'saved': False}+ if action == 'sch_update_from_library':+ libs = sorted({(l.get('name'), l.get('urn') or l.get('library_urn')) for l in root.findall('drawing/schematic/libraries/library')})+ sfail('unsupported', 'Fusion updates a design\'s parts from their libraries in its Library Manager, which asks about each part: there is no command this verb can answer up front. The design embeds %d libraries (%s)'+ % (len(libs), ', '.join(n for n, _ in libs)[:300]))+ if action == 'sch_hierarchy':+ if args.get('renumber'):+ sfail('unsupported', 'Fusion numbers sheets by their order; renumbering is reordering, which this verb does not do')+ return {**base, **hierarchy(root)}+ sfail('unknown_action', 'No schematic verb %s' % action)+ except SchError as e:+ return {'success': False, 'errorCode': e.code, 'error': str(e)}
tests/run_verb_tests.py+14−1@@ -213,6 +213,19 @@ for v, own in (("fusion_dialog_log", "fusion_action_ledger"), ("fusion_signin_st t("fusion_list_commands", "daily", {"filter": "Electron"}, {"ok": True, "fields": ["aliasOf", "commands"]}, "alias of fusion_list_text_commands") for v in ("fusion_save", "fusion_run_script", "fusion_run_command", "fusion_open_document", "fusion_show_3d_chip"): t(v, "manual", note="EDA Bridge Standard alias: the same verb as its target, which carries the tests")+t("fusion_sch_items", "daily", B, {"ok": True, "fields": ["wires", "labels", "junctions", "counts"]}, "the design's schematic, exported in the background from the board tab")+t("fusion_sch_pins", "daily", dict(B, references=["IC1"]), {"ok": True, "fields": ["pins", "count"]})+t("fusion_sch_trace_point", "daily", dict(B, at=[40.64, 132.08]), {"ok": True, "fields": ["items", "nets"]}, "a VBUS junction on BQ25792")+t("fusion_sch_page", "daily", B, {"ok": True, "fields": ["titleBlock"]})+t("fusion_sch_hierarchy", "daily", B, {"ok": True, "fields": ["tree", "modules"]})+t("fusion_sch_sheet_pins", "daily", dict(B, validate=True), {"ok": True, "fields": ["pins", "problems"]})+t("fusion_sch_connect_pins#dry", "daily", dict(B, pairs=[{"from": "IC1.1", "to": "IC1.2"}], dryRun=True), {"ok": True, "fields": ["routes", "commands"]}, "plan only: changes nothing")+t("fusion_sch_fix_connectivity", "daily", B, {"ok": True, "fields": ["moves", "unresolved"]}, "dryRun is its default")+t("fusion_render_schematic", "daily", dict(B, name="verbtest", width=1200), {"ok": True, "fields": ["png", "width", "dpi"]}, "EXPORT IMAGE of sheet 1, no window")+t("fusion_sch_update_from_library#refuse", "negative", B, {"pred": "must_refuse"}, "Fusion's Library Manager asks per part: refused with the reason")+for v in ("fusion_sch_text", "fusion_sch_junction", "fusion_sch_delete_wire", "fusion_sch_connect_pins", "fusion_sch_split_wire", "fusion_sch_bus",+ "fusion_sch_edit_label", "fusion_sch_move_region", "fusion_sch_bus_entry", "fusion_sch_connect_to_bus", "fusion_sch_reset_fields", "fusion_sch_create"):+ t(v, "manual", note="schematic edit: tested live on a scratch import of BQ25792 (never the cloud design)") t("fusion_export_bom#parts", "daily", {"outputPath": f"{SCRATCH}/bom-parts.csv"}, {"ok": True, "pred": "bom_has_part_numbers"}, "1.12.0: MPN/LCSC columns from the part attributes (page issue #105)") t("fusion_list_text_commands", "daily", {"filter": "Electron"}, {"ok": True, "fields": ["commands"]}, "TextCommands.List needs the electronics editor context") t("fusion_export_source", "weekly", {"outputPath": f"{SCRATCH}/verbtest.fbrd"}, {"ok": True, "fields": ["savedTo"]}, ".fbrd needs the 2D board view ACTIVE (Document.CopyToDesktop)")@@ -405,7 +418,7 @@ _SECTION_BY_VERB = { "fusion_verb_times": "bridge-lifecycle", "fusion_live_feed": "bridge-lifecycle", "fusion_account": "auth-tokens", "fusion_claim_seat": "auth-tokens", "fusion_release_seat": "auth-tokens", "fusion_prewarm": "bridge-lifecycle", "fusion_prove_result": "cad-3d-bom", "fusion_set_driving_badge": "bridge-lifecycle", "fusion_set_window_sizing": "gui-driving", "fusion_cancel_run": "cad-3d-bom", "fusion_preflight_lbr": "cad-3d-bom", "fusion_library_parts": "cad-3d-bom", "fusion_take_screenshot": "gui-driving",- "fusion_schematic": "gui-driving", "fusion_show_attributes": "cad-3d-bom", "fusion_make_showcase_project": "cad-3d-bom", "fusion_eagle_import_status": "cad-3d-bom", "fusion_routing_state": "routing", "fusion_route_net": "routing", "fusion_routing_drc": "routing", "fusion_plan_route": "routing", "fusion_move_parts": "routing", "fusion_place_vias": "routing", "fusion_apply_pours": "routing", "fusion_pour_state": "routing", "fusion_board_source": "routing", "fusion_ripup_nets": "routing", "fusion_schematic_run": "routing", "fusion_board_bom": "routing", "fusion_change_pours": "routing", "fusion_board_seed": "routing", "fusion_schematic_plan": "routing", "fusion_remove_pours": "routing", "fusion_layer_stack": "routing", "fusion_netlist": "routing", "fusion_read_sheet_file": "routing", "fusion_bodies": "routing", "fusion_save_document": "aps", "fusion_apply_silkscreen": "routing", "fusion_component_data": "routing", "fusion_component_pins": "routing", "fusion_list_nets": "routing", "fusion_net_connections": "routing", "fusion_list_designators": "routing", "fusion_layers": "routing", "fusion_copper_layers": "routing", "fusion_layers_in_use": "routing", "fusion_read_rules": "routing", "fusion_rules_by_kind": "routing", "fusion_silk_state": "routing", "fusion_net_classes": "routing", "fusion_run_drc": "routing", "fusion_repour": "routing", "fusion_zoom_to": "routing", "fusion_launch": "bridge-lifecycle", "fusion_export_gerber": "exports", "fusion_dismiss_dialogs": "gui-driving", "fusion_board_summary": "routing", "fusion_install": "bridge-lifecycle", "fusion_select_net": "routing", "fusion_set_layer_visibility": "routing", "fusion_board_outline": "routing", "fusion_run_erc": "routing", "fusion_set_track_width": "routing", "fusion_remove_items": "routing", "fusion_set_outline": "routing", "fusion_board_shape": "routing", "fusion_autoroute": "routing", "fusion_check_dialogs": "gui-driving", "fusion_status": "bridge-lifecycle", "fusion_save": "aps", "fusion_run_script": "gui-driving", "fusion_run_command": "gui-driving", "fusion_list_commands": "gui-driving", "fusion_dialog_log": "gui-driving", "fusion_signin_status": "bridge-lifecycle", "fusion_license_status": "bridge-lifecycle", "fusion_open_document": "cad-3d-bom", "fusion_show_3d_chip": "cad-3d-bom",+ "fusion_schematic": "gui-driving", "fusion_show_attributes": "cad-3d-bom", "fusion_make_showcase_project": "cad-3d-bom", "fusion_eagle_import_status": "cad-3d-bom", "fusion_routing_state": "routing", "fusion_route_net": "routing", "fusion_routing_drc": "routing", "fusion_plan_route": "routing", "fusion_move_parts": "routing", "fusion_place_vias": "routing", "fusion_apply_pours": "routing", "fusion_pour_state": "routing", "fusion_board_source": "routing", "fusion_ripup_nets": "routing", "fusion_schematic_run": "routing", "fusion_board_bom": "routing", "fusion_change_pours": "routing", "fusion_board_seed": "routing", "fusion_schematic_plan": "routing", "fusion_remove_pours": "routing", "fusion_layer_stack": "routing", "fusion_netlist": "routing", "fusion_read_sheet_file": "routing", "fusion_bodies": "routing", "fusion_save_document": "aps", "fusion_apply_silkscreen": "routing", "fusion_component_data": "routing", "fusion_component_pins": "routing", "fusion_list_nets": "routing", "fusion_net_connections": "routing", "fusion_list_designators": "routing", "fusion_layers": "routing", "fusion_copper_layers": "routing", "fusion_layers_in_use": "routing", "fusion_read_rules": "routing", "fusion_rules_by_kind": "routing", "fusion_silk_state": "routing", "fusion_net_classes": "routing", "fusion_run_drc": "routing", "fusion_repour": "routing", "fusion_zoom_to": "routing", "fusion_launch": "bridge-lifecycle", "fusion_export_gerber": "exports", "fusion_dismiss_dialogs": "gui-driving", "fusion_board_summary": "routing", "fusion_install": "bridge-lifecycle", "fusion_sch_items": "routing", "fusion_sch_pins": "routing", "fusion_sch_trace_point": "routing", "fusion_sch_page": "routing", "fusion_sch_hierarchy": "routing", "fusion_sch_sheet_pins": "routing", "fusion_sch_connect_pins": "routing", "fusion_sch_fix_connectivity": "routing", "fusion_render_schematic": "routing", "fusion_sch_update_from_library": "routing", "fusion_sch_text": "routing", "fusion_sch_junction": "routing", "fusion_sch_delete_wire": "routing", "fusion_sch_split_wire": "routing", "fusion_sch_bus": "routing", "fusion_sch_edit_label": "routing", "fusion_sch_move_region": "routing", "fusion_sch_bus_entry": "routing", "fusion_sch_connect_to_bus": "routing", "fusion_sch_reset_fields": "routing", "fusion_sch_create": "routing", "fusion_select_net": "routing", "fusion_set_layer_visibility": "routing", "fusion_board_outline": "routing", "fusion_run_erc": "routing", "fusion_set_track_width": "routing", "fusion_remove_items": "routing", "fusion_set_outline": "routing", "fusion_board_shape": "routing", "fusion_autoroute": "routing", "fusion_check_dialogs": "gui-driving", "fusion_status": "bridge-lifecycle", "fusion_save": "aps", "fusion_run_script": "gui-driving", "fusion_run_command": "gui-driving", "fusion_list_commands": "gui-driving", "fusion_dialog_log": "gui-driving", "fusion_signin_status": "bridge-lifecycle", "fusion_license_status": "bridge-lifecycle", "fusion_open_document": "cad-3d-bom", "fusion_show_3d_chip": "cad-3d-bom", "fusion_apply_fusion_update": "bridge-lifecycle", "fusion_resolve_seat_dialog": "bridge-lifecycle", "fusion_interactive": "gui-driving", "fusion_narrate": "gui-driving", "fusion_view": "gui-driving", "fusion_electronics_query": "cad-3d-bom", "fusion_activate_document": "gui-driving", "fusion_library_manager": "gui-driving", "fusion_plan_library_import": "cad-3d-bom", "fusion_create_library": "cad-3d-bom", "fusion_install_library_bundle": "cad-3d-bom",
tests/test_sch.pyadded+128@@ -0,0 +1,128 @@+import importlib.util+from pathlib import Path+import unittest++ROOT = Path(__file__).resolve().parents[1]+spec = importlib.util.spec_from_file_location('sch', ROOT / 'rust/bridge/src/sch_script.py')+s = importlib.util.module_from_spec(spec)+spec.loader.exec_module(s)+FIXTURE = ROOT / 'demo/routing/fixture/fusion-routing.sch'+++class SchReadTests(unittest.TestCase):+ def setUp(self):+ self.root = s.sch_root_from_file(str(FIXTURE))+ self.n, self.sheet = s.pick_sheet(self.root, {})++ def test_every_pin_sits_on_a_wire_end_of_its_own_segment(self):+ # The proof the transform is right: Fusion's ULP pin coordinates count the part's offset+ # twice (live 2026-10-27), but its wires do not, and a connected pin ends a wire.+ lib = s.Lib(self.root.find('drawing/schematic'))+ pins = {(p['ref'], p['gate'], p['pin']): p['at'] for p in s.sheet_pins(lib, self.sheet)}+ checked = 0+ for _, seg, _ in s.segments(self.sheet):+ ends = [(s._sf(w, 'x1'), s._sf(w, 'y1')) for w in seg.findall('wire')] + [(s._sf(w, 'x2'), s._sf(w, 'y2')) for w in seg.findall('wire')]+ for pr in seg.findall('pinref'):+ at = pins[(pr.get('part'), pr.get('gate'), pr.get('pin'))]+ self.assertTrue(any(abs(at[0] - x) < 1e-3 and abs(at[1] - y) < 1e-3 for x, y in ends), (pr.attrib, at))+ checked += 1+ self.assertEqual(13, checked)++ def test_items_and_ids(self):+ it = s.items(self.root, self.sheet)+ self.assertEqual(7, it['counts']['wires'])+ self.assertEqual(1, it['counts']['junctions'])+ w = it['wires'][0]+ self.assertEqual(s.wire_id(*w['from'], *w['to']), w['id'])+ self.assertEqual(s.wire_id(1, 2, 3, 4), s.wire_id(3, 4, 1, 2))++ def test_trace_point_and_nearest(self):+ r = s.trace_point(self.root, self.sheet, [7, 7], 0.01)+ self.assertEqual(['NET_1'], [n['name'] for n in r['nets']])+ self.assertIn('J1.1', r['nets'][0]['pins'])+ r = s.trace_point(self.root, self.sheet, [-50, -50], 0.01)+ self.assertEqual([], r['items'])+ self.assertIsNotNone(r['nearest'])++ def test_pin_side_follows_turn_and_mirror(self):+ self.assertEqual('left', s.pin_side('R0', 'R0'))+ self.assertEqual('right', s.pin_side('R0', 'MR0'))+ self.assertEqual('bottom', s.pin_side('R0', 'R90'))+ self.assertEqual('top', s.pin_side('R90', 'R180'))++ def test_sheet_choice(self):+ self.assertEqual(1, s.pick_sheet(self.root, {'sheet': '1'})[0])+ with self.assertRaises(s.SchError):+ s.pick_sheet(self.root, {'sheet': 2})+++class SchEditPlanTests(unittest.TestCase):+ def setUp(self):+ self.root = s.sch_root_from_file(str(FIXTURE))+ self.n, self.sheet = s.pick_sheet(self.root, {})++ def test_route_turns_once_along_the_first_leg(self):+ self.assertEqual([(0, 0), (10, 0), (10, 5)], s.route_points((0, 0), (10, 5), 'h', None))+ self.assertEqual([(0, 0), (0, 5), (10, 5)], s.route_points((0, 0), (10, 5), 'v', None))+ self.assertEqual([(0, 0), (10, 0)], s.route_points((0, 0), (10, 0), 'v', None))+ self.assertEqual([(0, 0), (3, 3), (10, 3), (10, 5)], s.route_points((0, 0), (10, 5), 'h', [[3, 3]]))++ def test_pins_by_number_then_name(self):+ p = s.find_pin(self.root, self.sheet, 'J1.1')+ self.assertEqual([7.0, 7.0], p['at'])+ for bad in ('J1', 'ZZ9.1', 'J1.99'):+ with self.assertRaises(s.SchError):+ s.find_pin(self.root, self.sheet, bad)++ def test_cover_ignores_how_a_wire_is_split(self):+ import copy, xml.etree.ElementTree as ET+ sh = copy.deepcopy(self.sheet)+ seg = sh.find('nets/net/segment')+ w = seg.find('wire')+ x1, y1, x2, y2 = (float(w.get(k)) for k in ('x1', 'y1', 'x2', 'y2'))+ mx, my = (x1 + x2) / 2, (y1 + y2) / 2+ w.set('x2', str(mx)); w.set('y2', str(my))+ ET.SubElement(seg, 'wire', x1=str(mx), y1=str(my), x2=str(x2), y2=str(y2), width='0.1524', layer='91')+ self.assertEqual(s.wire_cover(self.sheet), s.wire_cover(sh))++ def test_ends_at_counts_a_t(self):+ w = self.sheet.find('nets/net/segment/wire')+ a = (float(w.get('x1')), float(w.get('y1')))+ self.assertGreaterEqual(s.ends_at(self.sheet, a), 1)+ self.assertEqual(0, s.ends_at(self.sheet, (-500, -500)))++ def test_every_drawn_junction_counts_as_needed(self):+ # the rule sch_delete_wire cleans up with: a junction joining a pin and two wires is needed+ # (counting wires only once removed 25 good junctions on BQ25792, live 2026-10-27)+ pp = s.pin_points(self.root, self.sheet)+ for q in s.junction_points(self.sheet):+ self.assertGreaterEqual(s.ends_at(self.sheet, q, pins=pp), 3, q)++ def test_bus_members(self):+ self.assertEqual(['D0', 'D1', 'D2'], s.bus_members('D[0..2]'))+ self.assertEqual(['A3', 'A2', 'CLK'], s.bus_members('A[3..2],CLK'))+ self.assertEqual(['SDA', 'SCL'], s.bus_members('{SDA SCL}'))++ def test_field_home_matches_the_fields_left_in_place(self):+ bq = Path('/home/adom/project/fusion-lane/bq25792-charger/bq25792-charger.sch')+ if not bq.exists():+ self.skipTest('BQ25792 fixture not on this machine')+ root = s.sch_root_from_file(str(bq))+ _, sh = s.pick_sheet(root, {})+ lib = s.Lib(root.find('drawing/schematic'))+ n = 0+ for inst in sh.findall('instances/instance'):+ for a in inst.findall('attribute'):+ h = s.field_home(lib, inst, a.get('name'))+ if h and abs(h['at'][0] - float(a.get('x'))) < 1e-3 and abs(h['at'][1] - float(a.get('y'))) < 1e-3:+ self.assertEqual(h['rot'], a.get('rot', 'R0'))+ self.assertEqual(h['align'], a.get('align', 'bottom-left'))+ n += 1+ self.assertGreater(n, 100)++ def test_quotes_are_doubled(self):+ self.assertEqual("'it''s'", s.quote("it's"))+++if __name__ == '__main__':+ unittest.main()