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 27 file(s)
27 files changed
+1012−38
BRIDGE_VERSION+1−1CHANGELOG.md+23bridge.json+14−2contracts/describe.json+153dashboard/Cargo.lock+1−1dashboard/Cargo.toml+1−1describe.py+24install.sh+1−1package.json+1−1page.json+5−11rust/Cargo.lock+6−6rust/Cargo.toml+1−1rust/bridge/src/checks.rs+71−1rust/bridge/src/main.rs+2−1rust/bridge/src/order.rs+65rust/bridge/src/routing_script.py+21−2rust/bridge/src/sch.rs+1−1rust/bridge/src/sch_script.py+53rust/vendor/adom-eda-checks/Cargo.toml+1−1rust/vendor/adom-eda-checks/README.md+5−1rust/vendor/adom-eda-checks/VENDOR.md+3−3rust/vendor/adom-eda-checks/src/autoclass.rs+84rust/vendor/adom-eda-checks/src/lib.rs+8−1rust/vendor/adom-eda-checks/src/silkplace.rs+220rust/vendor/adom-eda-checks/src/space.rs+239rust/vendor/adom-eda-checks/src/writers/ipc2581.rs+1−1tests/run_verb_tests.py+7−1BRIDGE_VERSION+1−1@@ -1 +1 @@-1.13.10+1.13.11
CHANGELOG.md+23@@ -3,6 +3,29 @@ All notable changes to the Adom Bridge Fusion 360 bridge, newest first. Canonical: https://wiki.adom.inc/adom/fusion-bridge · GitHub backup: `adom-inc/fusion-bridge` +## 1.13.11 (2026-10-29)++Six more of the standard's verbs, engine 0.14.1 (standard 0.17.1). 320 verbs.++- From the shared engine:+ - `fusion_auto_classes`: GND, Power, Clock, HighSpeed classes and pairs from the net names; a proposal, and+ apply:true writes the classes that carry a width as Fusion net classes (fusion_add_rule);+ - `fusion_silk_designators`: every designator one size, upright, beside its part and clear of pads, bodies and+ each other; landed with fusion_apply_silkscreen unless dryRun (Fusion's designator boxes are computed from the+ text for the engine);+ - `fusion_find_space`: free spots for a part near another, nearest first, or check:true for overlaps;+ - `fusion_check_orientation`: 2-pad parts whose turn would shorten their airlines.+- Native:+ - `fusion_jlcpcb_order`: the JLCPCB fab check, Gerber zip, JLCPCB BOM and CPL, assembly-tier check and the order+ form values, in one folder with every issue in one list;+ - `fusion_sch_zoom`: parts highlighted and framed on their sheet, with a picture of the view (no foreground).+- `fusion_apply_silkscreen` resizes a text where it stands, before moving it (picked at its new place, the size+ did not land: BQ25792's SW1), and undoes every command when any text does not read back as asked (before, only a+ copper change was undone).+- The standard's app verbs Fusion answers under its own names are mapped in the standard's equivalents:+ animate_view (fusion_view), window_info (fusion_check_dialogs), license (fusion_account), eval+ (fusion_run_modeling_script, Python with Fusion's API), open_project (fusion_open_cloud_file).+ ## 1.13.10 (2026-10-29) Two more of the standard's checks from the shared engine 0.12.2 (standard 0.15.2). 314 verbs.
bridge.json+14−2@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.13.10",+ "version": "1.13.11", "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.",@@ -51,6 +51,7 @@ "fusion_assembly_bom", "fusion_assembly_check", "fusion_attach_3d_package",+ "fusion_auto_classes", "fusion_autoroute", "fusion_bind_library_3d", "fusion_board_bom",@@ -69,6 +70,7 @@ "fusion_change_pours", "fusion_check_clearance", "fusion_check_dialogs",+ "fusion_check_orientation", "fusion_check_recovery", "fusion_claim_seat", "fusion_cleanup_cloud_files",@@ -141,6 +143,7 @@ "fusion_export_usdz", "fusion_fab_check", "fusion_fetch_optimized_glb",+ "fusion_find_space", "fusion_find_symbol", "fusion_footprint_primitives", "fusion_generate_body",@@ -159,6 +162,7 @@ "fusion_install_fusion", "fusion_install_library_bundle", "fusion_interactive",+ "fusion_jlcpcb_order", "fusion_kill", "fusion_launch", "fusion_layer_stack",@@ -285,6 +289,7 @@ "fusion_sch_text", "fusion_sch_trace_point", "fusion_sch_update_from_library",+ "fusion_sch_zoom", "fusion_schematic", "fusion_schematic_data", "fusion_schematic_plan",@@ -318,6 +323,7 @@ "fusion_signin", "fusion_signin_2fa", "fusion_signin_status",+ "fusion_silk_designators", "fusion_silk_state", "fusion_simulate", "fusion_start",@@ -517,7 +523,13 @@ "export_spice": 120, "simulate": 300, "assembly_check": 150,- "net_lengths": 120+ "net_lengths": 120,+ "sch_zoom": 90,+ "jlcpcb_order": 300,+ "auto_classes": 120,+ "silk_designators": 180,+ "find_space": 150,+ "check_orientation": 120 }, "prefixes": [ {
contracts/describe.json+153@@ -593,6 +593,29 @@ "summary": "LOW-LEVEL primitive: attaches ONE model to ONE package. Prefer fusion_create_library with parts[]. Attach a real 3D chip model to a library package, end to end: opens the .lbr (library active), runs Electron.Create3DPackage to enter the Package3DEnvironment showing the footprint, imports the STEP + auto-orients it flat on the footprint, then executes Package3DStop (FINISH). This BINDS the 3D onto the deviceset (Content Manager shows the 3D nested under the package; the Packages 'Package' column flips Placeholder->part-name). ONE desktop-side step remains: Fusion pops a modal Save dialog (an OWNED popup) - the response _hint gives the exact desktop_ui_click to confirm it. AFTER: re-grab desktop_screenshot_window and check ownedPopupCount for errors; the 3D preview LAGS a beat before showing the real chip. Both paths (filePath/modelPath) are WINDOWS paths - stage from the container first.", "timeoutSeconds": 240 },+ {+ "example": {+ "powerWidthMm": 0.6+ },+ "input": {+ "apply": "bool, default false",+ "expectDocument": "required: the board's name",+ "powerWidthMm": "default 0.5"+ },+ "longRunning": false,+ "name": "fusion_auto_classes",+ "output": {+ "applied": "bool",+ "applyArgs": "{net_classes, diff_pairs}",+ "engine": "str",+ "proposal": "{classes, pairs, unclassified}",+ "skipped": "[{name, why}]",+ "written": "[{name, success, class}]"+ },+ "statusVerb": null,+ "summary": "Net classes from the nets' own names (the standard's auto_classes), from the shared checks engine: GND, Power (VCC, VDD, 3V3, VBAT...), Clock, HighSpeed (USB, Ethernet, HDMI, PCIe, DDR, MIPI, LVDS, SATA...) and the differential pairs their names spell. A proposal first; apply:true writes the classes that carry a rule (Power's width, powerWidthMm) as Fusion net classes through fusion_add_rule. Fusion pairs nets by their names, so the pairs need no writing.",+ "timeoutSeconds": 120+ }, { "example": { "nets": [@@ -974,6 +997,26 @@ "summary": "List every Fusion dialog open now, each with its title, text and buttons read through UI Automation (no screenshot needed). The bridge expires unknown dialogs by itself before the next add-in verb; in-canvas alerts that have no window are in fusion_alerts.", "timeoutSeconds": 30 },+ {+ "example": {+ "minImprovementMm": 0.5+ },+ "input": {+ "expectDocument": "required: the board's name",+ "minImprovementMm": "number",+ "parts": "[ref] (default every 2-pad part)"+ },+ "longRunning": false,+ "name": "fusion_check_orientation",+ "output": {+ "checked": "int",+ "engine": "str",+ "suggestions": "[{ref, from, to, savesMm}]"+ },+ "statusVerb": null,+ "summary": "Advisory (the standard's check_orientation): 2-pad parts whose rotation could be better, each orthogonal turn scored in place by airline length (a routable net's MST with the pad there; a plane-like net's distance to its nearest pad); parts whose best turn saves minImprovementMm are listed. Nothing moves: the shared checks engine on the live board.",+ "timeoutSeconds": 120+ }, { "example": { "fileName": "BQ25792",@@ -2485,6 +2528,39 @@ "summary": "Finish a fusion_export_optimized_glb job: bounded-poll the optimizer by jobId and write the GLB to outputPath when complete. Re-call until status=complete (big boards tessellate a few minutes). No Fusion needed.", "timeoutSeconds": 150 },+ {+ "example": {+ "count": 3,+ "near": "IC1",+ "sizeMm": [+ 2,+ 1.25+ ]+ },+ "input": {+ "at": "[x, y] instead of near",+ "check": "bool",+ "clearanceMm": "number",+ "count": "how many spots",+ "edgeMm": "number",+ "expectDocument": "required: the board's name",+ "near": "a designator to search round",+ "part": "the part to place (its size)",+ "side": "top|bottom",+ "sizeMm": "[w, h] instead of part"+ },+ "longRunning": false,+ "name": "fusion_find_space",+ "output": {+ "bodiesUsed": "bool",+ "engine": "str",+ "overlaps": "[...] (check)",+ "spots": "[{x, y, rotation?, distanceMm}]"+ },+ "statusVerb": null,+ "summary": "Free spots on the board for a part (the standard's find_space), from the shared checks engine on the live board and fusion_bodies: near a part (or at a point), for a part or a size, clear of every other part on that side, of that side's tracks and vias, and inside the outline, nearest first; pour hatch does not block a spot. check:true lists parts that already overlap or leave the outline.",+ "timeoutSeconds": 150+ }, { "example": { "folder": "C:/libs",@@ -2886,6 +2962,27 @@ "summary": "Is Fusion taking input RIGHT NOW? Reads the add-in's busy flag (a long bridge command in flight), Fusion's own hasActiveJobs, and the main thread's latency on a trivial script (probeMainThread:false skips that and costs nothing). interactive:true means drive it; false means a load, a cloud open, or a bind is still running. The guided tour's narrator polls this every 1.5s and stamps fusion-busy / fusion-interactive transitions into the tour record, so a recording can be cut or sped up on them.", "timeoutSeconds": 20 },+ {+ "example": {},+ "input": {+ "expectDocument": "required: the board's name",+ "outputDir": "the order folder (default %TEMP%/JLCPCB order <board>; emptied first)"+ },+ "longRunning": false,+ "name": "fusion_jlcpcb_order",+ "output": {+ "assemblyService": "economic | standard | null",+ "bomFiles": "[path] incl. bom-jlcpcb.csv, cpl-jlcpcb.csv",+ "folder": "path",+ "gerberZip": "{path, files}",+ "issues": "[{from, what, at?}]",+ "orderForm": "{layers, dimensionsMm, outerCopperOz, minTrackSpaceMm, minHoleMm}",+ "ready": "bool: fab fits, an assembly tier passes, no issues"+ },+ "statusVerb": null,+ "summary": "A JLCPCB order in one call (the standard's jlcpcb_order): the board against JLCPCB's fab limits (adom/pcb-design-rules), the Gerbers and drill zipped by the bundled JLCPCB CAM job, the JLCPCB BOM and pick-and-place (CPL), the assembly-tier check and the order-form values (layers, size, outer copper, smallest track/space and hole), in one folder written fresh, with every issue in one list. Fusion has no Silkscreen Preparation: silk goes out as drawn.",+ "timeoutSeconds": 300+ }, { "example": {}, "input": {@@ -19647,6 +19744,33 @@ "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": {+ "refs": [+ "U1",+ "C3"+ ]+ },+ "input": {+ "expectDocument": "the design",+ "marginMm": "default 5",+ "outputPath": "the .png (default %TEMP%/adom-sch-zoom/sch_zoom.png)",+ "refs": "[designator] (or ref)",+ "stay": "bool: leave the schematic in front"+ },+ "longRunning": false,+ "name": "fusion_sch_zoom",+ "output": {+ "found": "[ref]",+ "image": "path",+ "missing": "[ref]",+ "rectMm": "[x1, y1, x2, y2]",+ "sheet": "int"+ },+ "statusVerb": null,+ "summary": "Find parts on the schematic (the standard's sch_zoom, the schematic half of cross-probing): highlighted with SHOW, the view framed on them round their pins (marginMm), and a picture of that view written as the proof frame (EXPORT IMAGE WINDOW, no foreground). Parts on several sheets: the first sheet holding any of them. The user's tab is put back unless stay:true.",+ "timeoutSeconds": 90+ }, { "example": { "action": "open",@@ -20359,6 +20483,35 @@ "summary": "The EDA Bridge Standard's name for fusion_account: the same verb, same arguments and reply (marked aliasOf). One name across Altium, KiCad and Fusion bridges.", "timeoutSeconds": 120 },+ {+ "example": {+ "dryRun": true,+ "size": 1.0+ },+ "input": {+ "dryRun": "bool",+ "expectDocument": "required: the board's name",+ "gap": "mm beside the part, default 0.2",+ "minSize": "smallest size to fall back to",+ "padClearance": "default 0.15",+ "refs": "[ref] (default all)",+ "size": "text height mm, default 1.0"+ },+ "longRunning": false,+ "name": "fusion_silk_designators",+ "output": {+ "applied": "int",+ "engine": "str",+ "failed": "int",+ "onPadsBefore": "[ref]",+ "planned": "[{ref, x, y, size, rotation}]",+ "revision": "str",+ "unplaced": "[{ref, why}]"+ },+ "statusVerb": null,+ "summary": "Place every reference designator the way assembly houses want (the standard's silk_designators), planned by the shared checks engine: one size, upright (or turned 90 beside the part), beside its own part, clear of every pad (padClearance, JLCPCB 0.15 mm), other bodies and the other designators, inside the board. Landed with fusion_apply_silkscreen (read back, undone on a mismatch) unless dryRun; parts with no clear spot are listed, not forced.",+ "timeoutSeconds": 180+ }, { "example": { "expectDocument": "fusion-routing"
dashboard/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "fusion-dashboard"-version = "1.13.10"+version = "1.13.11" dependencies = [ "serde_json", ]
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.13.10"+version = "1.13.11" edition = "2021" [dependencies]
describe.py+24@@ -262,6 +262,10 @@ _T = [ {"expectDocument": "required: the board's name", "vendor": "jlcpcb|pcbway|oshpark ... (adom/pcb-design-rules)", "process": "optional record process", "fab": "a record instead", "limits": "{minTrack, minClearance, ...} mm"}, {"measured": "{...}", "outsideLimits": "[{limit, boardMm, limitMm, at, source}]", "fab": "the record", "engine": "str"}, 120, None, False, {'vendor': 'jlcpcb'}),+ ('fusion_jlcpcb_order', "A JLCPCB order in one call (the standard's jlcpcb_order): the board against JLCPCB's fab limits (adom/pcb-design-rules), the Gerbers and drill zipped by the bundled JLCPCB CAM job, the JLCPCB BOM and pick-and-place (CPL), the assembly-tier check and the order-form values (layers, size, outer copper, smallest track/space and hole), in one folder written fresh, with every issue in one list. Fusion has no Silkscreen Preparation: silk goes out as drawn.",+ {"expectDocument": "required: the board's name", "outputDir": "the order folder (default %TEMP%/JLCPCB order <board>; emptied first)"},+ {"ready": "bool: fab fits, an assembly tier passes, no issues", "issues": "[{from, what, at?}]", "folder": "path", "gerberZip": "{path, files}", "bomFiles": "[path] incl. bom-jlcpcb.csv, cpl-jlcpcb.csv", "assemblyService": "economic | standard | null", "orderForm": "{layers, dimensionsMm, outerCopperOz, minTrackSpaceMm, minHoleMm}"},+ 300, None, False, {}), ('fusion_assembly_check', "Ready for JLCPCB assembly (the standard's assembly_check)? The board against both JLCPCB PCBA tiers as published (Economic and Standard): size, thickness and layers, parts on one or both sides, the smallest package and finest pin pitch, fiducials, and the BOM lines with no LCSC number (from the board's own part attributes); answers the cheapest tier every rule allows. The shared checks engine on the live board.", {"expectDocument": "required: the board's name", "panelMm": "[w, h] when it ships in a panel", "lcscMissing": "the BOM lines without an LCSC number (default: read from the board's parts)"}, {"service": "economic | standard | null", "economic": "{ok, rules:[{rule, ok, detail}]}", "standard": "{ok, rules}", "board": "{parts, bottomSideParts, finestPitch, smallestChip, fiducials, ...}", "linesWithoutLcsc": "[...]", "engine": "str"},@@ -282,6 +286,14 @@ _T = [ {"expectDocument": "required: the board's name", "nets": "[net] (default every routed net)", "pairs": "[{name?, pos, neg}]", "groups": "[{nets, toleranceMm?}]"}, {"nets": "[{net, lengthMm, byLayer, vias, segments}]", "pairs": "[{name, pos, neg, posMm, negMm, skewMm}]", "groups": "[{nets, spreadMm, withinTolerance, longest}]", "engine": "str"}, 120, None, False, {'nets': ['D_P', 'D_N']}),+ ('fusion_auto_classes', "Net classes from the nets' own names (the standard's auto_classes), from the shared checks engine: GND, Power (VCC, VDD, 3V3, VBAT...), Clock, HighSpeed (USB, Ethernet, HDMI, PCIe, DDR, MIPI, LVDS, SATA...) and the differential pairs their names spell. A proposal first; apply:true writes the classes that carry a rule (Power's width, powerWidthMm) as Fusion net classes through fusion_add_rule. Fusion pairs nets by their names, so the pairs need no writing.",+ {"expectDocument": "required: the board's name", "powerWidthMm": "default 0.5", "apply": "bool, default false"},+ {"proposal": "{classes, pairs, unclassified}", "applyArgs": "{net_classes, diff_pairs}", "applied": "bool", "written": "[{name, success, class}]", "skipped": "[{name, why}]", "engine": "str"},+ 120, None, False, {'powerWidthMm': 0.6}),+ ('fusion_silk_designators', "Place every reference designator the way assembly houses want (the standard's silk_designators), planned by the shared checks engine: one size, upright (or turned 90 beside the part), beside its own part, clear of every pad (padClearance, JLCPCB 0.15 mm), other bodies and the other designators, inside the board. Landed with fusion_apply_silkscreen (read back, undone on a mismatch) unless dryRun; parts with no clear spot are listed, not forced.",+ {"expectDocument": "required: the board's name", "size": "text height mm, default 1.0", "minSize": "smallest size to fall back to", "gap": "mm beside the part, default 0.2", "padClearance": "default 0.15", "refs": "[ref] (default all)", "dryRun": "bool"},+ {"planned": "[{ref, x, y, size, rotation}]", "unplaced": "[{ref, why}]", "onPadsBefore": "[ref]", "applied": "int", "failed": "int", "revision": "str", "engine": "str"},+ 180, None, False, {'size': 1.0, 'dryRun': True}), ('fusion_add_rule', "The standard's add_rule as EAGLE net classes, the board's scoped rules: kind width (min), clearance (gap, to every class and within the class) or via (drill). scope1 InNetClass('X') edits that class; InNet('A') (or nets) puts the nets in a class of their own, Adom_<name>, each joined with one pick on its wire. Read back from the board; every change undone on a mismatch. Length, matched, diffpair and polyconnect rules have no class equivalent and are refused.", {"expectDocument": "required: the board's name", "name": "required: the class is Adom_<name> for a net scope", "kind": "width|clearance|via", "scope1": "InNet('SW') | InNetClass('pwr') (nets / netClass also work)", "min": "mm (width)", "gap": "mm (clearance)", "drill": "mm (via)", "remove": "bool: the Adom_<name> class's nets back to the default class, the class zeroed"}, {"name": "the class", "class": "{name, number, widthMm, drillMm, clearances, nets}", "joined": "[net]", "released": "[net] (remove)", "command": "str", "revision": "str"},@@ -294,6 +306,14 @@ _T = [ {"expectDocument": "required: the board's name", "marginMm": "courtyard margin, default 0.1", "edgeMm": "pad to edge, default 0.5"}, {"ok": "bool", "parts": "int", "courtyardOverlaps": "[{parts, side, overlapMm}]", "offOrOverEdge": "[{ref, where}]", "nearEdge": "[{ref, padToEdgeMm}]", "onMountingHoles": "[{ref, hole}]", "bodiesUsed": "bool", "engine": "str"}, 150, None, False, {'marginMm': 0.25}),+ ('fusion_find_space', "Free spots on the board for a part (the standard's find_space), from the shared checks engine on the live board and fusion_bodies: near a part (or at a point), for a part or a size, clear of every other part on that side, of that side's tracks and vias, and inside the outline, nearest first; pour hatch does not block a spot. check:true lists parts that already overlap or leave the outline.",+ {"expectDocument": "required: the board's name", "near": "a designator to search round", "at": "[x, y] instead of near", "part": "the part to place (its size)", "sizeMm": "[w, h] instead of part", "side": "top|bottom", "clearanceMm": "number", "edgeMm": "number", "count": "how many spots", "check": "bool"},+ {"spots": "[{x, y, rotation?, distanceMm}]", "overlaps": "[...] (check)", "bodiesUsed": "bool", "engine": "str"},+ 150, None, False, {'near': 'IC1', 'sizeMm': [2, 1.25], 'count': 3}),+ ('fusion_check_orientation', "Advisory (the standard's check_orientation): 2-pad parts whose rotation could be better, each orthogonal turn scored in place by airline length (a routable net's MST with the pad there; a plane-like net's distance to its nearest pad); parts whose best turn saves minImprovementMm are listed. Nothing moves: the shared checks engine on the live board.",+ {"expectDocument": "required: the board's name", "parts": "[ref] (default every 2-pad part)", "minImprovementMm": "number"},+ {"suggestions": "[{ref, from, to, savesMm}]", "checked": "int", "engine": "str"},+ 120, None, False, {'minImprovementMm': 0.5}), ('fusion_check_clearance', "Part-to-part clearance (the standard's check_clearance), from the shared checks engine on the live board and fusion_bodies: courtyard mode compares part boxes (pads and 3D bodies) on the same side; pads mode compares pad copper of different parts sharing a copper layer, by its real outline. Pairs closer than thresholdMm, nearest first.", {"expectDocument": "required: the board's name", "refs": "[ref] limit to these parts (default all)", "mode": "courtyard|pads, default courtyard", "thresholdMm": "report pairs closer than this", "includeSameNet": "bool (pads mode)", "maxPairs": "int"}, {"ok": "bool", "pairs": "[{parts, gapMm, at, side|layer}]", "mode": "str", "bodiesUsed": "bool", "engine": "str"},@@ -426,6 +446,10 @@ _T = [ {"expectDocument": "the design", "designators": "['R1','R2'] or 'R1,R2' (required)", "params": "{MPN: ...} or 'Key=Value;...' (required)", "dryRun": "bool"}, {"updated": "[ref]", "notFound": "[ref]"}, 120, None, False, {'designators': ['R1'], 'params': {'MPN': '0603WAF1002T5E'}, 'dryRun': True}),+ ('fusion_sch_zoom', "Find parts on the schematic (the standard's sch_zoom, the schematic half of cross-probing): highlighted with SHOW, the view framed on them round their pins (marginMm), and a picture of that view written as the proof frame (EXPORT IMAGE WINDOW, no foreground). Parts on several sheets: the first sheet holding any of them. The user's tab is put back unless stay:true.",+ {"expectDocument": "the design", "refs": "[designator] (or ref)", "marginMm": "default 5", "outputPath": "the .png (default %TEMP%/adom-sch-zoom/sch_zoom.png)", "stay": "bool: leave the schematic in front"},+ {"sheet": "int", "found": "[ref]", "missing": "[ref]", "rectMm": "[x1, y1, x2, y2]", "image": "path"},+ 90, None, False, {'refs': ['U1', 'C3']}), ('fusion_export_spice', "The design's SPICE netlist (the standard's export_spice): EAGLE's own EXPORT SPICENETLIST of the open design's schematic, written on the Fusion machine with no dialog, in ngspice's dialect. Parts with no SPICE mapping come out as comments (unmapped); analyses and includes (.include/.lib/.model/.subckt) are listed.", {"expectDocument": "the design", "outputPath": "the .net to write (default %TEMP%/adom-spice/<design>.net)"}, {"path": "str", "dialect": "ngspice", "elements": "int", "unmapped": "[part]", "analyses": "[line]", "includes": "[line]"},
install.sh+1−1@@ -21,7 +21,7 @@ echo "Installed fusion-bridge skills: $(ls "$PKG_DIR/skills" 2>/dev/null | tr '\ # binary (the three-artifact pattern: bridge zip -> ab, THIS pkg -> container skills + # this download, page media -> wiki). Per the wiki's PKG_VS_RELEASE policy, binaries are # platform-tagged RELEASE assets, never tarball content - the installer fetches them.-DASH_VERSION="1.13.10"+DASH_VERSION="1.13.11" DASH_URL="https://wiki.adom.inc/download/adom/fusion-bridge/${DASH_VERSION}/fusion-dashboard" DEST="" if [ -w /usr/local/bin ]; then DEST=/usr/local/bin
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.13.10",+ "version": "1.13.11", "type": "app", "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.", "dependencies": {
page.json+5−11@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.13.10",+ "version": "1.13.11", "type": "app", "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.", "dependencies": {@@ -17,9 +17,7 @@ "pcb", "exports", "aps",- "modeling",- "mechanical",- "assemblies"+ "modeling" ], "org": "adom", "title": "Fusion - the Fusion 360 Bridge",@@ -92,13 +90,9 @@ "pin electrical types from a schematic", "part heights on my fusion board", "which fusion parts have no 3d model",- "will my fusion board fit the enclosure",- "build a parametric part in fusion",- "assemble mechanical parts in fusion",- "insert a McMaster part in fusion",- "generate a mechanical assembly BOM"+ "will my fusion board fit the enclosure" ],- "discovery_pitch": "Use the Adom Bridge Fusion 360 bridge when the user wants to DRIVE Autodesk Fusion 360 from the cloud: open/search their team's Fusion cloud designs, author parametric mechanical parts and internal assemblies, ingest McMaster CAD and generate mechanical BOMs, lay out a PCB or do electronics, export STEP/IGES/STL/Gerbers/BOM, or build a component library with real rendering 3D. It runs Fusion on the user's own machine via Adom Bridge. NOT for web/browser/login/form-filling (that's the native-browser extension) and NOT for KiCad (that's the kicad bridge).",+ "discovery_pitch": "Use the Adom Bridge Fusion 360 bridge when the user wants to DRIVE Autodesk Fusion 360 from the cloud: open/search their team's Fusion cloud designs, lay out a PCB or do electronics, export STEP/IGES/STL/Gerbers/BOM, or build a component library with real rendering 3D. It runs Fusion on the user's own machine via Adom Bridge. NOT for web/browser/login/form-filling (that's the native-browser extension) and NOT for KiCad (that's the kicad bridge).", "brief": "Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.", "sample_prompts": [ {@@ -149,4 +143,4 @@ "name": "John Lauer", "email": "[email protected]" }-}+}
rust/Cargo.lock+6−6@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-eda-checks"-version = "0.12.2"+version = "0.14.1" dependencies = [ "roxmltree", "serde_json",@@ -19,7 +19,7 @@ dependencies = [ [[package]] name = "adom-fusion-bridge"-version = "1.13.10"+version = "1.13.11" dependencies = [ "adom-eda-checks", "base64",@@ -299,7 +299,7 @@ dependencies = [ [[package]] name = "fusion-core"-version = "1.13.10"+version = "1.13.11" dependencies = [ "serde", "serde_json",@@ -310,7 +310,7 @@ dependencies = [ [[package]] name = "fusion-lbr"-version = "1.13.10"+version = "1.13.11" dependencies = [ "regex", "roxmltree",@@ -322,14 +322,14 @@ dependencies = [ [[package]] name = "fusion-os-mac"-version = "1.13.10"+version = "1.13.11" dependencies = [ "fusion-core", ] [[package]] name = "fusion-os-win"-version = "1.13.10"+version = "1.13.11" dependencies = [ "fusion-core", "windows-sys 0.59.0",
rust/Cargo.toml+1−1@@ -4,7 +4,7 @@ members = ["core", "lbr", "os-win", "os-mac", "bridge"] # One version for the whole bridge, in lockstep with BRIDGE_VERSION (tools/release.sh bumps both). [workspace.package]-version = "1.13.10"+version = "1.13.11" edition = "2021" license = "MIT"
rust/bridge/src/checks.rs+71−1@@ -56,7 +56,8 @@ pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Val bundle["args"]["lcscMissing"] = json!(missing); } }- if matches!(cmd, "height_check" | "placement_check" | "check_clearance" | "place_decoupling") {+ if cmd == "silk_designators" { silk_boxes(&mut bundle["silk"]); }+ if matches!(cmd, "height_check" | "placement_check" | "check_clearance" | "place_decoupling" | "find_space") { let bodies = crate::bodies::bodies(args, pass); if bodies["success"].as_bool() == Some(true) { bundle["bodies"] = bodies; } else if cmd == "height_check" { return bodies; }@@ -79,6 +80,36 @@ pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Val r["mutated"] = json!(false); r["_hint"] = json!("A plan: send each call to fusion_route_net in turn, with expectedRevision set to revision for the first and to the previous reply's revision after that. Drop or shorten whatever conflicts lists first. fusion_route_net undoes a call that splits another net's pour."); }+ if cmd == "silk_designators" && r["success"].as_bool() == Some(true) {+ r["mutated"] = json!(false);+ if args["dryRun"].as_bool() != Some(true) && r["applyArgs"]["names"].as_array().map(|a| !a.is_empty()).unwrap_or(false) {+ let mut a = json!({"expectDocument": args["expectDocument"], "names": r["applyArgs"]["names"]});+ if !inputs["revision"].is_null() { a["expectedRevision"] = inputs["revision"].clone(); }+ let applied = crate::routing::call("apply_silkscreen", &a, pass);+ if applied["success"].as_bool() != Some(true) { let mut e = applied; e["planned"] = r["planned"].clone(); return e; }+ for k in ["applied", "failed", "results", "smashed", "revision"] { r[k] = applied[k].clone(); }+ r["mutated"] = json!(true);+ }+ }+ if cmd == "auto_classes" && r["success"].as_bool() == Some(true) && args["apply"].as_bool() == Some(true) {+ // Fusion's scoped rules are its net classes, and a class holds a width: a class the proposal gives+ // a width becomes one (add_rule); the others carry no rule to write, and Fusion pairs nets by their names+ let mut written = Vec::new(); let mut skipped = Vec::new();+ for c in r["applyArgs"]["net_classes"].as_array().cloned().unwrap_or_default() {+ match c["width"].as_f64() {+ Some(w) => {+ let a = json!({"expectDocument": args["expectDocument"], "name": c["name"], "kind": "width", "min": w, "nets": c["nets"]});+ let out = crate::routing::call("add_rule", &a, pass);+ written.push(json!({"name": c["name"], "success": out["success"], "class": out["class"], "error": out["error"], "errorCode": out["errorCode"]}));+ }+ None => skipped.push(json!({"name": c["name"], "why": "no width, clearance or drill to hold: Fusion classes carry rules, not tags"})),+ }+ }+ r["applied"] = json!(written.iter().any(|w| w["success"] == true));+ r["mutated"] = r["applied"].clone();+ r["written"] = json!(written); r["skipped"] = json!(skipped);+ r["pairsNote"] = json!("Fusion pairs nets by their names (NAME_P/NAME_N): the proposed pairs are already pairs there (fusion_diff_pairs lists them)");+ } if cmd == "place_decoupling" && r["moveArgs"].is_object() { r["mutated"] = json!(false); r["_hint"] = json!("A plan: pass moveArgs to fusion_move_parts unchanged (it carries boardId and expectedRevision); validation is placement_check on the planned spots. Then route the caps short to the power pins.");@@ -116,3 +147,42 @@ fn place(mut r: Value, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Val else { "Open vias in a pad wick solder away during reflow: tent or plug them, or keep drills at 0.3 mm or less. Pour the pad's net on the far side (fusion_apply_pours) so the heat has somewhere to go." }); r }++/// Fusion's silk_state gives each designator's origin, size and turn; the shared engine's silk+/// planner reads a box. The box of EAGLE's vector text from its origin (lower left before the turn):+/// characters about 0.8 of the size wide, mirrored on the bottom side.+fn silk_boxes(silk: &mut Value) {+ let Some(comps) = silk["components"].as_array_mut() else { return };+ for c in comps.iter_mut() {+ let n = &c["name"];+ let (Some(x), Some(y)) = (n["x"].as_f64(), n["y"].as_f64()) else { continue };+ let h = n["size"].as_f64().unwrap_or(1.27);+ let w = n["text"].as_str().map(|t| t.chars().count()).unwrap_or(2).max(1) as f64 * h * 0.8;+ let rot = ((n["rotation"].as_f64().unwrap_or(0.0) % 360.0) + 360.0) % 360.0;+ let (mut x1, y1, mut x2, y2) = match rot.round() as i64 {+ 90 => (x - h, y, x, y + w),+ 180 => (x - w, y - h, x, y),+ 270 => (x, y - w, x + h, y),+ _ => (x, y, x + w, y + h),+ };+ if n["side"].as_str() == Some("bottom") || c["side"].as_str() == Some("bottom") { let (a, b) = (2.0 * x - x2, 2.0 * x - x1); x1 = a; x2 = b; }+ let r = |v: f64| (v * 1000.0).round() / 1000.0;+ c["name"]["box"] = json!([r(x1), r(y1), r(x2), r(y2)]);+ }+}++#[cfg(test)]+mod silk_tests {+ use super::*;+ #[test]+ fn designator_boxes_follow_turn_and_side() {+ let mut s = json!({"components": [+ {"ref": "R1", "side": "top", "name": {"x": 10.0, "y": 5.0, "size": 1.0, "rotation": 0.0, "text": "R1"}},+ {"ref": "R2", "side": "top", "name": {"x": 10.0, "y": 5.0, "size": 1.0, "rotation": 90.0, "text": "R2"}},+ {"ref": "R3", "side": "bottom", "name": {"x": 10.0, "y": 5.0, "size": 1.0, "rotation": 0.0, "text": "R3"}}]});+ silk_boxes(&mut s);+ assert_eq!(s["components"][0]["name"]["box"], json!([10.0, 5.0, 11.6, 6.0]));+ assert_eq!(s["components"][1]["name"]["box"], json!([9.0, 5.0, 10.0, 6.6]));+ assert_eq!(s["components"][2]["name"]["box"], json!([8.4, 5.0, 10.0, 6.0]));+ }+}
rust/bridge/src/main.rs+2−1@@ -16,6 +16,7 @@ mod chains; mod writers; mod body; mod spice;+mod order; mod pdf; mod small_verbs; mod addin;@@ -502,7 +503,7 @@ fn main() { 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)), "simulate" => spice::simulate(&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)), c if docs::owns(c) => docs::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if writers::owns(c) => writers::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if checks::owns(c) => checks::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)) };+ let mut r=match cmd { "bodies" => bodies::bodies(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "simulate" => spice::simulate(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "jlcpcb_order" => order::jlcpcb_order(&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)), c if docs::owns(c) => docs::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if writers::owns(c) => writers::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if checks::owns(c) => checks::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/order.rsadded+65@@ -0,0 +1,65 @@+//! `fusion_jlcpcb_order`: the standard's jlcpcb_order in one call. The board against JLCPCB's fab+//! limits (fab_check vendor jlcpcb), the Gerbers and drill zipped by the bundled JLCPCB CAM job, the+//! JLCPCB BOM and pick-and-place, the assembly-tier check and the order-form values, in one folder,+//! with every issue in one list. Fusion has no Silkscreen Preparation: the silk goes out as drawn.+use serde_json::{json, Value};++fn data(r: &Value) -> Value { if r["data"].is_object() { r["data"].clone() } else { r.clone() } }++pub fn jlcpcb_order(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let st = crate::routing::call("routing_state", args, pass);+ if st["success"].as_bool() == Some(false) { return st; }+ let doc = st["documentName"].as_str().unwrap_or("board").to_string();+ let safe: String = doc.chars().map(|c| if c.is_ascii_alphanumeric() || "-_. ".contains(c) { c } else { '_' }).collect();+ let folder = args["outputDir"].as_str().map(|s| s.replace('\\', "/"))+ .unwrap_or_else(|| std::env::temp_dir().join(format!("JLCPCB order {safe}")).to_string_lossy().replace('\\', "/"));+ // written fresh each time+ let _ = std::fs::remove_dir_all(&folder);+ if let Err(e) = std::fs::create_dir_all(&folder) { return json!({"success": false, "errorCode": "write_failed", "error": format!("{folder}: {e}")}); }+ let mut issues: Vec<Value> = Vec::new();+ let mut a = args.clone(); a["vendor"] = json!("jlcpcb");+ let fab = crate::checks::call("fab_check", &a, pass);+ if fab["success"].as_bool() == Some(false) { issues.push(json!({"from": "fab_check", "what": fab["error"]})); }+ for o in fab["outsideLimits"].as_array().cloned().unwrap_or_default() {+ issues.push(json!({"from": "fab_check", "what": format!("{} {} mm against JLCPCB's {} mm", o["limit"].as_str().unwrap_or("?"), o["boardMm"], o["limitMm"]), "at": o["at"]}));+ }+ let g = data(&pass("export_gerbers", json!({"outputDir": folder})));+ let gerber_zip = json!({"path": g["zipPath"], "files": g["fileCount"]});+ if g["zipPath"].is_null() { issues.push(json!({"from": "export_gerbers", "what": g["error"].as_str().unwrap_or("the CAM job wrote no zip")})); }+ let bom = crate::routing::call("board_bom", &json!({"expectDocument": args["expectDocument"], "outputDir": folder}), pass);+ let mut bom_files: Vec<Value> = bom["files"].as_object().map(|o| o.values().cloned().collect()).unwrap_or_default();+ if bom["success"].as_bool() == Some(false) { issues.push(json!({"from": "board_bom", "what": bom["error"]})); }+ let cpl_path = format!("{folder}/cpl-jlcpcb.csv");+ let cpl = data(&pass("export_cpl", json!({"outputPath": cpl_path, "side": "all"})));+ if std::path::Path::new(&cpl_path).is_file() { bom_files.push(json!(cpl_path)); } else { issues.push(json!({"from": "export_cpl", "what": cpl["error"].as_str().unwrap_or("no pick-and-place file was written")})); }+ let asm = crate::checks::call("assembly_check", args, pass);+ // no tier passes: the cheaper tier's failing rules are what stand between the board and assembly+ if asm["service"].is_null() {+ for r in asm["economic"]["rules"].as_array().cloned().unwrap_or_default().iter().filter(|r| r["ok"] == false) {+ issues.push(json!({"from": "assembly_check", "what": format!("{}: {}", r["rule"].as_str().unwrap_or("?"), r["detail"].as_str().unwrap_or(""))}));+ }+ }+ // the order form: what JLCPCB's page asks for+ let pts: Vec<(f64, f64)> = st["outline"].as_array().into_iter().flatten().filter_map(|p| Some((p[0].as_f64()?, p[1].as_f64()?))).collect();+ let dims = if pts.is_empty() { Value::Null } else {+ let (x0, x1) = pts.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.0), a.1.max(p.0)));+ let (y0, y1) = pts.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.1), a.1.max(p.1)));+ json!([((x1 - x0) * 100.0).round() / 100.0, ((y1 - y0) * 100.0).round() / 100.0])+ };+ let stack = crate::routing::call("layer_stack", args, pass);+ // outer copper in oz from the stack-up's first layer (35 um = 1 oz), to the half ounce+ let top_cu = stack["layers"].as_array().and_then(|l| l.first()).and_then(|x| x["copperUm"].as_f64()).map(|um| ((um / 35.0) * 2.0).round() / 2.0);+ let m = &fab["measured"];+ let mm = |k: &str| m[k]["mm"].as_f64().or_else(|| m[k].as_f64());+ let hole = [mm("padHole"), mm("viaDrill")].into_iter().flatten().fold(None::<f64>, |a, v| Some(a.map_or(v, |a| a.min(v))));+ let order_form = json!({"layers": st["copperLayers"].as_array().map(|l| l.len()), "dimensionsMm": dims, "outerCopperOz": top_cu,+ "minTrackSpaceMm": [mm("track"), mm("clearanceRule")], "minHoleMm": hole,+ "_note": "minTrackSpaceMm is the narrowest track and the board's copper clearance rule"});+ let fab_ok = fab["success"].as_bool() != Some(false) && fab["outsideLimits"].as_array().map(|a| a.is_empty()).unwrap_or(false);+ let service = asm["service"].clone();+ let ready = fab_ok && !service.is_null() && !g["zipPath"].is_null() && issues.is_empty();+ json!({"success": true, "mutated": false, "documentName": doc, "folder": folder, "ready": ready, "issues": issues,+ "gerberZip": gerber_zip, "bomFiles": bom_files, "assemblyService": service, "orderForm": order_form,+ "fab": {"ok": fab_ok, "record": fab["fab"]["name"], "engine": fab["engine"]}, "assembly": {"economic": asm["economic"]["ok"], "standard": asm["standard"]["ok"]},+ "_hint": "Fix what issues lists and run it again; when ready, upload the zip, then the BOM and CPL for assembly and check the rotations in JLCPCB's preview. Fusion has no Silkscreen Preparation: silk goes out as drawn (fusion_apply_silkscreen tidies it)."})+}
rust/bridge/src/routing_script.py+21−2@@ -931,7 +931,8 @@ def apply_silkscreen(app, root, board, meta, args): for ref in to_smash: run("SMASH '%s';" % ref) if to_smash: root, board, _ = snapshot(app, {**args, 'expectedRevision': None})- silk = {c['ref']: c for c in rd_silk_state(root, board, {})['components']}+ silk_before = rd_silk_state(root, board, {})+ silk = {c['ref']: c for c in silk_before['components']} try: for kind, it in want: ref = it['ref']; key = 'name' if kind == 'NAME' else 'comment'@@ -941,8 +942,10 @@ def apply_silkscreen(app, root, board, meta, args): layer = cur['layer'] x, y = number(it['x']), number(it['y']) run('DISPLAY NONE %d;' % layer)+ # size first, picked where the text is now: picked at its new place, CHANGE SIZE took no+ # text there (BQ25792's SW1 name, 2026-10-29) and the move stood with the old size+ if it.get('size') is not None: run('CHANGE SIZE %.6fmm (%.6fmm %.6fmm);' % (number(it['size'], True), cur['x'], cur['y'])) run('MOVE (%.6fmm %.6fmm) (%.6fmm %.6fmm);' % (cur['x'], cur['y'], x, y))- if it.get('size') is not None: run('CHANGE SIZE %.6fmm (%.6fmm %.6fmm);' % (number(it['size'], True), x, y)) if it.get('rotation') is not None: mirror = 'M' if layer in (22, 26, 28) else '' run('ROTATE =%sR%g (%.6fmm %.6fmm);' % (mirror, float(it['rotation']) % 360, x, y))@@ -997,6 +1000,22 @@ def apply_silkscreen(app, root, board, meta, args): if bad: results[i] = 'err:%s %s landed %s' % (it['ref'], kind, ', '.join(bad)) i += 1 failed = sum(1 for r_ in results if r_ != 'ok')+ if failed and not args.get('keepPartial'):+ # one text not as asked: take every command back, so the board is as it was (checked against+ # the silkscreen read before the edit), and say which texts failed+ def sig(st_):+ return sorted((c['ref'], tuple(sorted((k, round(v, 3) if isinstance(v, float) else v) for k, v in (c.get('name') or {}).items())),+ tuple(sorted((k, round(v, 3) if isinstance(v, float) else v) for k, v in (c.get('comment') or {}).items()))) for c in st_['components'])+ start = sig(silk_before)+ for _ in range(len(commands) + 2):+ app.executeTextCommand('Electron.run UNDO;')+ r_, b_, _m = snapshot(app, {**args, 'expectedRevision': None})+ if sig(rd_silk_state(r_, b_, {})) == start: break+ r_, b_, m_ = snapshot(app, {**args, 'expectedRevision': None})+ back = sig(rd_silk_state(r_, b_, {})) == start+ return {'success': False, 'mutated': not back, 'errorCode': 'readback_mismatch', 'applied': 0, 'failed': failed, 'results': results,+ 'error': '%d text(s) did not land as asked; %s' % (failed, 'every command was undone' if back else 'the undo did not restore the silkscreen exactly: read fusion_silk_state'),+ 'revision': m_['revision'], 'commands': commands} return {'success': failed == 0, 'mutated': True, 'applied': len(results) - failed, 'failed': failed, 'results': results, 'smashed': to_smash, 'commands': len(commands), **meta2, 'after': st, '_hint': 'Read back from the board: every result is ok or names what landed where. Each EAGLE command is its own undo step in Fusion (no single-step undo for a plan). Free texts and lines are removed by the raw handle silk_state gives.'}
rust/bridge/src/sch.rs+1−1@@ -2,7 +2,7 @@ //! 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" | "cross_probe" | "cross_check" | "schematic_data" | "part_parameters" | "doc_params" | "sheet_params" | "sch_edit" | "annotate" | "sch_connect" | "sch_place_part" | "sch_swap_part" | "export_spice" | "simulate") }+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" | "cross_probe" | "cross_check" | "schematic_data" | "part_parameters" | "doc_params" | "sheet_params" | "sch_edit" | "annotate" | "sch_connect" | "sch_place_part" | "sch_swap_part" | "export_spice" | "simulate" | "sch_zoom" | "jlcpcb_order") } 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",
rust/bridge/src/sch_script.py+53@@ -2098,6 +2098,57 @@ def sch_render(app, args): 'extentMm': [_r4(x0), _r4(y0), _r4(x1), _r4(y1)], 'fusionVersion': app.version} +def sch_zoom(app, args):+ """The standard's sch_zoom: the parts found on their sheet, highlighted (SHOW), the schematic+ view framed on them (WINDOW round their pins, margin mm) and a picture of that view written as+ the proof frame (EXPORT IMAGE ... WINDOW, no foreground). The user's tab is put back."""+ import os+ refs = args.get('refs') or ([args['ref']] if args.get('ref') else [])+ if not refs:+ sfail('bad_args', 'refs is [designator] (or ref)')+ root, meta, tabs = sch_snapshot(app, args)+ i, front = tabs+ found, missing, box, at_sheet = [], [], None, None+ for n_, sh in enumerate(sheets_of(root), start=1):+ pins = pins_with_connections(root, sh, refs, connections=False)+ here = sorted({p_['ref'] for p_ in pins})+ if not here: continue+ if at_sheet is None: at_sheet = n_+ if n_ != at_sheet: continue+ found += here+ for p_ in pins:+ x, y = p_['at']+ box = [x, y, x, y] if box is None else [min(box[0], x), min(box[1], y), max(box[2], x), max(box[3], y)]+ for inst in sh.findall('instances/instance'):+ if inst.get('part') in refs:+ x, y = _sf(inst, 'x'), _sf(inst, 'y')+ box = [min(box[0], x), min(box[1], y), max(box[2], x), max(box[3], y)]+ missing = [r for r in refs if r not in found]+ if not found:+ sfail('designator_not_found', 'None of %s is on the schematic' % ', '.join(refs))+ m = float(args.get('marginMm', 5))+ box = [box[0] - m, box[1] - m, box[2] + m, box[3] + m]+ out = args.get('outputPath') or os.path.join(tempfile.gettempdir(), 'adom-sch-zoom', 'sch_zoom.png')+ out = out.replace('\\', '/'); os.makedirs(os.path.dirname(out), exist_ok=True)+ if os.path.exists(out):+ try: os.remove(out)+ except OSError: pass+ try:+ app.executeTextCommand('UI.SwitchToTab %d' % i)+ if len(sheets_of(root)) > 1:+ app.executeTextCommand('Electron.run SET CONFIRM NO; EDIT .s%d; SET CONFIRM OFF;' % at_sheet)+ app.executeTextCommand('Electron.run SHOW %s;' % ' '.join(quote(r) for r in found))+ app.executeTextCommand('Electron.run WINDOW %s %s;' % (mm((box[0], box[1])), mm((box[2], box[3]))))+ app.executeTextCommand('Electron.ImmediateRepaint /full')+ app.executeTextCommand("Electron.run SET CONFIRM YES; EXPORT IMAGE '%s' WINDOW 150; SET CONFIRM OFF;" % out)+ finally:+ if args.get('stay') is not True and front is not None and front != i:+ app.executeTextCommand('UI.SwitchToTab %d' % front)+ return {'success': True, 'mutated': False, **meta, 'sheet': at_sheet, 'found': found, 'missing': missing,+ 'rectMm': [round(v, 3) for v in box], 'image': out if os.path.isfile(out) else None,+ '_hint': 'The parts are highlighted and the schematic view framed on them; image is that view. stay:true leaves the schematic in front. fusion_cross_probe finds the same parts on the board.'}++ def export_spice(app, args): """The standard's export_spice: EAGLE's own SPICE netlist (EXPORT SPICENETLIST, no dialog) of the design's schematic, written on this machine and parsed: device lines, the parts with no@@ -2141,6 +2192,8 @@ def handle(app, args, action): return EDITS[action](app, args) if action == 'export_spice': return export_spice(app, args)+ if action == 'sch_zoom':+ return sch_zoom(app, args) if action == 'sch_render': return sch_render(app, args) if action == 'cross_probe':
rust/vendor/adom-eda-checks/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.12.2"+version = "0.14.1" edition = "2021" description = "The EDA Bridge Standard's checks and board-file writers, computed from the board model JSON: no bridge calls, no tool." license = "MIT"
rust/vendor/adom-eda-checks/README.md+5−1@@ -12,10 +12,13 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. | Check | Reads | What it answers | |---|---|---| | `assembly_check` | board, stack? | the board against JLCPCB's two PCBA tiers as published (with the page and its read date): size (or `panelMm`), thickness and layers from the stack, one or two sides, the smallest two-pad part and the finest IC pitch, fiducials, and parts without an LCSC number (`lcscMissing` from the bridge's BOM). Pads sharing one centre are one land, and a component with no pads is no part. `service` is the cheaper tier every rule allows. Matches the Altium bridge's answers on its test boards. From its assembly.rs |+| `auto_classes` | board | net classes from the names the nets already have (GND, Power, Clock, HighSpeed) and differential pairs from their suffixes (`_P`/`_N`, `DP`/`DN`, `+`/`-`), with `applyArgs`: the net_classes and diff_pairs calls that write them (Power with a `powerWidthMm` width rule). From the Altium bridge's autoclass.rs | | `check_clearance` | board, bodies? | pairs of parts closer than `thresholdMm`, closest first: `mode: courtyard` (default 0.25) measures part boxes (pads, and 3D bodies when given) on the same side; `mode: pads` (default 0.2) measures pad copper of different parts that shares a layer, by its real outline, skipping same-net pairs unless `includeSameNet`; `refs` keeps pairs involving those parts. A part under a raised one (pads apart, bodies apart in 3D) is listed in `stacked`, not `pairs`, as placement_check lists it. From the KiCad bridge's place_tools |+| `check_orientation` | board | advisory: each two-pad part's quarter turns scored by airline length (MST of a routable net with the pad there, distance to the nearest pad of a plane-like net such as GND); turns saving `minImprovementMm` (0.6) or more are suggested, nothing moves. From the Altium bridge's design.rs | | `copper_balance` | board, stack, pours | copper per layer and how evenly mirrored layers match (bow and twist) | | `return_path` | board, stack, pours | signal tracks running over a gap in the neighbouring layer's reference copper, with a pour plan that closes each gap (`apply_pours`) | | `estimate_cost` | board, stack?, rules?, prices? | the numbers a fab quote needs (size, copper layers, thickness from the stack, smallest track, gap from the clearance rule, smallest drill, holes, SMD and through-hole parts and joints, unique parts, assembled sides) and, with a `prices` record, a rough table at `quantities` (default 5, 10, 30, 100), with assembly when asked, every assumption stated and the record named in `source`. The engine carries no rates: without `prices` the reply is the numbers only. From the KiCad bridge's fab.rs |+| `find_space` | board, bodies? | free spots for a part (`part`, or `sizeMm`) near another (`near`) or a point (`at`): clear of other part boxes on that side by `clearanceMm`, of that side's routed tracks and the vias, `edgeMm` inside the outline (arcs sampled), nearest first, as where the part's origin goes; `check: true` lists parts that overlap (and, with bodies, those `stacked` under a raised one) or have pads off the outline, as placement_check judges them. Pours do not block (they re-pour). From the Altium bridge's design.rs | | `fab_check` | board, stack?, rules?, silk?, fab? | the board's smallest features, compared with a fab's limits from adom/pcb-design-rules; each finding names the fab's page and the date it was read | | `impedance` | stack | the track width (and pair gap) for a target impedance on each copper layer; `apply` answers `add_rule` arguments | | `current_capacity` | board, stack? | per net and layer, the narrowest copper against the width the current needs (IPC-2221), and the vias a layer change needs |@@ -25,6 +28,7 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. | `placement_check` | board, bodies? | courtyard overlaps (pads and bodies, grown by `marginMm`), parts off or over the edge, pads within `edgeMm` of the edge, parts on mounting holes; `stacked` lists plan-view overlaps where one part sits wholly above the other (a part under a raised molecule: pads apart, no bodies meeting in 3D); `bodiesUsed: false` when no bodies were given | | `plan_fanout` | board, stack?, rules? | the fanout of a BGA or QFN/QFP (`ref`): pitch and package from its pads; a BGA's outer ring escapes on the surface and each inner ball takes a dogbone via, or a via in the pad when the dogbone does not fit (`style` auto); a perimeter pad takes an outward stub, with a via at its end for ground or `viaPerimeter`, and an exposed pad a via grid. `calls` are route_net arguments, one per net; `conflicts` lists each via and stub closer than the clearance to another net's pad, track, arc or via anywhere on the board. A pin its net's copper already reaches is skipped (`includeRouted` plans it anyway), the exposed-pad grid keeps clear of holes already there, and `crossesPours` names other nets' pours the plan runs into (repour before DRC). From the KiCad bridge's place_tools | | `plan_route` | board, rules? | a path between two pads of a net (`from`, `to` as Ref.Pad) on any listed copper `layers`, with through vias between them: a bounded orthogonal A* on `gridMm`, width and via from the args, else the board's width and via rules (`planner.widthFrom`, `viaFrom`), keeping `widthMm/2` + the board's strictest board-wide copper clearance rule from other nets' copper (pads by their real outline), inside the outline, no via in a pad; a through via is checked on every copper layer of the board (`planner.viasCheckedOn`), not only the planned ones. `routeNet` is route_net's arguments, unchanged. Pours are not obstacles (repour after), but `crossesPours` names each other net's pour the route runs through, since that can split it; `avoidPours: true` keeps the route out of them (or a list of nets: out of theirs only), and `pourCost` makes a mm inside one cost 1 + pourCost mm, a preference where a hard block finds no path; a pour that holds the route's own end pad is exempt and named (`planner.avoidExempt`); with no path, `ends` names the avoided pours at each end pad and whether they are exempt, and `withoutAvoid` says whether a path exists through them. The grid path is smoothed to straight and 45-degree segments where they keep the same clearance (`smooth: false` keeps it) |+| `silk_designators` | board, silk | a place for each reference designator: one size (`size`, stepping down to `minSize`), beside its own part, clear of every pad by `padClearance`, of other parts' bodies and of other labels, inside the board. `applyArgs` is apply_silkscreen's arguments; parts with no clear spot are listed, not forced. From the Altium bridge's silkplace.rs | | `thermal_vias` | board, rules? | a grid of vias in a part's exposed pad (`ref`, `pad?`), clear of vias already there; `apply` is `place_vias` arguments | | `via_fence` | board, rules? | vias of `viaNet` both sides of the named nets' tracks and/or round the board edge (`edge`), at `pitchMm` or lambda/20 at `freqGHz`, clear of other copper; `apply` is `place_vias` arguments | @@ -98,7 +102,7 @@ adom-eda-checks import_svg --svg logo.svg --args '{"at": [10, 10], "widthMm": 20 Replies keep the standard envelope (`success`, `errorCode`, `error`, `_hint`) and name verbs without a prefix (`apply_pours`, `add_rule`, `place_vias`): a bridge adds its own. Every reply also carries `engine`-(`"adom-eda-checks 0.12.2"`), so a bridge reports the engine it runs without writing the version by hand.+(`"adom-eda-checks 0.14.1"`), so a bridge reports the engine it runs without writing the version by hand. ## Tests
rust/vendor/adom-eda-checks/VENDOR.md+3−3@@ -3,9 +3,9 @@ The EDA Bridge Standard's shared checks engine, copied unchanged from adom/eda-bridge-standard `engine/` (the wiki has no git protocol, so Cargo cannot fetch it). -- Standard version: 0.15.2-- Standard commit: 275a6ef2460c3763589827cf828403aa75570fa1-- Crate version: 0.12.2+- Standard version: 0.17.1+- Standard commit: 337fb00c55e052ad7c3918bf4f3a097f5b772158+- Crate version: 0.14.1 - Copied: 2026-10-28, by fusion-lane To update: clone the standard fresh (`adom-wiki repo clone adom/eda-bridge-standard`), copy `engine/src`,
rust/vendor/adom-eda-checks/src/autoclass.rsadded+84@@ -0,0 +1,84 @@+//! Net classes from the names a designer already gave the nets: ground, power, clocks, the fast interfaces (USB,+//! Ethernet, HDMI, PCIe, DDR, MIPI, LVDS, SATA), and the differential pairs their _P/_N (+/-, DP/DN) suffixes spell;+//! proposed, with the net_classes and diff_pairs arguments that write them. From the Altium bridge's autoclass.rs.+use serde_json::{json, Value};+use std::collections::BTreeMap;++/// The class a net's name puts it in, if any. Pure.+pub fn classify(net: &str) -> Option<&'static str> {+ let n = net.to_ascii_uppercase();+ let core = n.trim_start_matches(['+', '-', '/', '\\']);+ if crate::placetools::is_ground(&n) || core.starts_with("GND") { return Some("GND"); }+ let power = core.contains("VBUS") || core.starts_with("VCC") || core.starts_with("VDD") || core.starts_with("VBAT") || core.starts_with("VIN") || core.starts_with("VBUS")+ || core.starts_with("VSYS") || core.starts_with("VREF") || core.starts_with("PWR") || core.starts_with("VCORE") || core.starts_with("VOUT")+ || (core.starts_with('V') && core.len() > 1 && core.as_bytes()[1].is_ascii_digit())+ || core.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) && (core.contains('V'));+ // a supply's name wins over what it feeds (VDD_XTAL is the crystal's supply, not a clock)+ if power { return Some("Power"); }+ let fast = ["USB", "ETH", "RGMII", "RMII", "SGMII", "HDMI", "PCIE", "DDR", "MIPI", "CSI", "DSI", "LVDS", "SATA", "SERDES", "QSPI", "SDIO", "EMMC"];+ if fast.iter().any(|f| core.contains(f)) { return Some("HighSpeed"); }+ if core.contains("CLK") || core.contains("XTAL") || core.contains("OSC") || core.ends_with("SCK") { return Some("Clock"); }+ None+}++/// Differential pairs by name: X_P/X_N, XP/XN, X+/X-, X_DP/X_DM. Pure.+pub fn pairs(nets: &[String]) -> Vec<(String, String, String)> {+ let set: BTreeMap<String, &String> = nets.iter().map(|n| (n.to_ascii_uppercase(), n)).collect();+ let mut out = Vec::new();+ for (u, orig) in &set {+ // longest suffixes first: USB_DP must pair as USB (DP/DN), not as USB_D (P/N)+ for (p, q) in [("_DP", "_DN"), ("_DP", "_DM"), ("DP", "DN"), ("DP", "DM"), ("_P", "_N"), ("+", "-"), ("P", "N")] {+ if let Some(stem) = u.strip_suffix(p) {+ if stem.is_empty() { continue; }+ if let Some(neg) = set.get(&format!("{stem}{q}")) {+ let name = stem.trim_end_matches('_').to_string();+ if !out.iter().any(|(n, _, _): &(String, String, String)| *n == name) { out.push((name, (*orig).clone(), (*neg).clone())); }+ break;+ }+ }+ }+ }+ out+}++/// auto_classes: input {board, args: {powerWidthMm?}}. A proposal from the net names, and `applyArgs`: one net_classes+/// call per class (Power with a width rule) and one diff_pairs call per pair, for the bridge to run.+pub fn auto_classes(input: &Value) -> Value {+ let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+ let nets: Vec<String> = st["nets"].as_array().cloned().unwrap_or_default().iter().filter_map(|n| n.as_str().map(String::from)).collect();+ let mut classes: BTreeMap<&str, Vec<String>> = BTreeMap::new();+ for n in &nets { if let Some(c) = classify(n) { classes.entry(c).or_default().push(n.clone()); } }+ let prs = pairs(&nets);+ let width = input["args"]["powerWidthMm"].as_f64().unwrap_or(0.5);+ let net_classes: Vec<Value> = classes.iter().map(|(c, members)| { let mut a = json!({"name": c, "nets": members}); if *c == "Power" { a["width"] = json!(width); } a }).collect();+ let diff_pairs: Vec<Value> = prs.iter().map(|(n, p, q)| json!({"name": n, "pos": p, "neg": q})).collect();+ json!({"success": true, "applied": false, "proposal": {"classes": classes, "pairs": diff_pairs, "unclassified": nets.iter().filter(|n| classify(n).is_none()).count()},+ "applyArgs": {"net_classes": net_classes, "diff_pairs": diff_pairs},+ "_hint": "A proposal from the net names. Check it, then run each applyArgs.net_classes entry through net_classes (Power gets a width rule, powerWidthMm) and each applyArgs.diff_pairs entry through diff_pairs. Then impedance sizes the pairs and current_capacity checks the power nets."})+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn names_sort_into_classes_and_pairs() {+ for (n, c) in [("GND", Some("GND")), ("AGND", Some("GND")), ("+3V3", Some("Power")), ("VDD_CORE", Some("Power")), ("5V", Some("Power")), ("VBUS", Some("Power")),+ ("USB_DP", Some("HighSpeed")), ("ETH_TX_P", Some("HighSpeed")), ("MCLK", Some("Clock")), ("VDD_XTAL", Some("Power")), ("USB_VBUS", Some("Power")), ("XTAL_IN", Some("Clock")), ("SPI_SCK", Some("Clock")), ("N1", None), ("LED_RED", None)] {+ assert_eq!(classify(n), c, "{n}");+ }+ let nets: Vec<String> = ["USB_DP", "USB_DN", "ETH_TX_P", "ETH_TX_N", "LVDS0+", "LVDS0-", "SDA"].iter().map(|s| s.to_string()).collect();+ let p = pairs(&nets);+ assert_eq!(p.len(), 3);+ assert!(p.iter().any(|(n, a, b)| n == "USB" && a == "USB_DP" && b == "USB_DN"));+ assert!(p.iter().any(|(n, a, b)| n == "LVDS0" && a == "LVDS0+" && b == "LVDS0-"));+ }++ #[test]+ fn the_verb_answers_the_arguments_that_write_them() {+ let r = auto_classes(&json!({"board": {"nets": ["GND", "+3V3", "USB_DP", "USB_DN", "LED"]}, "args": {"powerWidthMm": 0.6}}));+ assert_eq!(r["proposal"]["unclassified"], json!(1));+ assert!(r["applyArgs"]["net_classes"].as_array().unwrap().iter().any(|c| c["name"] == "Power" && c["width"] == json!(0.6)));+ assert_eq!(r["applyArgs"]["diff_pairs"], json!([{"name": "USB", "pos": "USB_DP", "neg": "USB_DN"}]));+ }+}
rust/vendor/adom-eda-checks/src/lib.rs+8−1@@ -8,6 +8,7 @@ use serde_json::Value; pub mod assembly;+pub mod autoclass; pub mod bodies; pub mod coppermap; pub mod cost;@@ -21,6 +22,8 @@ pub mod lengths; pub mod placement; pub mod placetools; pub mod route;+pub mod silkplace;+pub mod space; pub mod writers; /// This engine's version. Every reply carries it as `engine` ("adom-eda-checks 0.3.0"), so a bridge never writes it by hand@@ -28,7 +31,7 @@ pub mod writers; pub const VERSION: &str = env!("CARGO_PKG_VERSION"); /// The checks this build answers, by standard verb name.-pub const CHECKS: &[&str] = &["assembly_check", "check_clearance", "copper_balance", "current_capacity", "estimate_cost", "fab_check", "height_check", "impedance", "net_lengths", "place_decoupling", "placement_check", "plan_fanout", "plan_route", "return_path", "thermal_vias", "via_fence"];+pub const CHECKS: &[&str] = &["assembly_check", "auto_classes", "check_clearance", "check_orientation", "copper_balance", "current_capacity", "estimate_cost", "fab_check", "find_space", "height_check", "impedance", "net_lengths", "place_decoupling", "placement_check", "plan_fanout", "plan_route", "return_path", "silk_designators", "thermal_vias", "via_fence"]; /// Run one check or writer by its standard name. The reply carries `engine`. pub fn run(check: &str, input: &Value) -> Value {@@ -41,10 +44,13 @@ fn answer(check: &str, input: &Value) -> Value { if let Some(out) = writers::run(check, input) { return out; } match check { "assembly_check" => assembly::assembly_check(input),+ "auto_classes" => autoclass::auto_classes(input), "check_clearance" => placetools::check_clearance(input),+ "check_orientation" => space::check_orientation(input), "copper_balance" => coppermap::copper_balance(input), "current_capacity" => current::current_capacity(input), "estimate_cost" => cost::estimate_cost(input),+ "find_space" => space::find_space(input), "fab_check" => fab::fab_check(input), "height_check" => bodies::height_check(input), "impedance" => impedance::impedance(input),@@ -54,6 +60,7 @@ fn answer(check: &str, input: &Value) -> Value { "placement_check" => placement::placement_check(input), "plan_route" => route::plan_route(input), "return_path" => coppermap::return_path(input),+ "silk_designators" => silkplace::silk_designators(input), "thermal_vias" => features::thermal_vias(input), "via_fence" => features::via_fence(input), _ => input::refuse("not_supported", &format!("{check} is not a verb this engine answers (checks: {}; writers: {})", CHECKS.join(", "), writers::WRITERS.join(", "))),
rust/vendor/adom-eda-checks/src/silkplace.rsadded+220@@ -0,0 +1,220 @@+//! Reference designators placed the way assembly houses want them: one size for all, upright, beside their own part,+//! clear of every pad (JLCPCB: silkscreen 0.15 mm from pads), every other part's body and every designator already+//! placed, and inside the board. From the Altium bridge's silkplace.rs: a plan here, which the bridge applies with+//! apply_silkscreen and checks against the tool's own text boxes after.+use serde_json::{json, Value};++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }++type Rect = (f64, f64, f64, f64);++fn rect_of(v: &Value) -> Option<Rect> {+ let a = v.as_array()?;+ if a.len() != 4 { return None; }+ let n: Vec<f64> = a.iter().map(f).collect();+ Some((n[0].min(n[2]), n[1].min(n[3]), n[0].max(n[2]), n[1].max(n[3])))+}+fn grow(r: Rect, d: f64) -> Rect { (r.0 - d, r.1 - d, r.2 + d, r.3 + d) }+fn hit(a: Rect, b: Rect) -> bool { a.0 < b.2 && b.0 < a.2 && a.1 < b.3 && b.1 < a.3 }+fn union(a: Rect, b: Rect) -> Rect { (a.0.min(b.0), a.1.min(b.1), a.2.max(b.2), a.3.max(b.3)) }++/// A pad's copper as a rectangle (its rotated extent).+fn pad_rect(p: &Value) -> Rect {+ let (c, s) = (f(&p["rotation"]).to_radians().cos().abs(), f(&p["rotation"]).to_radians().sin().abs());+ let (sx, sy) = (f(&p["sizeX"]), f(&p["sizeY"]));+ let (hx, hy) = ((sx * c + sy * s) / 2.0, (sx * s + sy * c) / 2.0);+ (f(&p["x"]) - hx, f(&p["y"]) - hy, f(&p["x"]) + hx, f(&p["y"]) + hy)+}++fn on_side(layer: &Value, bottom: bool) -> bool {+ match layer.as_str() { Some("*.Cu") => true, Some("B.Cu") => bottom, Some("F.Cu") => !bottom, _ => false }+}++/// Where each designator goes: [{ref, x, y, size, rotation, side, at}] (x, y the text's lower-left, its right on the+/// bottom side where Altium mirrors it) and the parts with no clear spot. Pure: silk_state's components, the board's+/// pads and outline bounding box, and the boxes of labels that stay put.+pub fn plan(comps: &[Value], pads: &[Value], board: Rect, size: f64, min_size: f64, gap: f64, pad_clear: f64, fixed: &[(bool, Rect)]) -> (Vec<Value>, Vec<Value>) {+ // labels that stay where they are (parts left out, or with no spot) are obstacles like the ones placed+ let mut placed: Vec<(bool, Rect)> = fixed.to_vec();+ let (mut out, mut unplaced) = (Vec::new(), Vec::new());+ // the part's own outline: its body box and its own pads+ let own = |c: &Value| -> Option<Rect> {+ let r = c["ref"].as_str().unwrap_or("");+ let mut b = rect_of(&c["body"]);+ for p in pads.iter().filter(|p| p["ref"].as_str() == Some(r)) { b = Some(b.map(|x| union(x, pad_rect(p))).unwrap_or(pad_rect(p))); }+ b+ };+ let bodies: Vec<(String, bool, Rect)> = comps.iter().filter_map(|c| own(c).map(|b| (c["ref"].as_str().unwrap_or("").to_string(), c["side"] == "bottom", b))).collect();+ // big parts first: they have the fewest places to put a label and the small ones fit round them+ let mut order: Vec<&Value> = comps.iter().filter(|c| c["name"]["shown"].as_bool() != Some(false)).collect();+ order.sort_by(|a, b| {+ let area = |c: &Value| own(c).map(|r| (r.2 - r.0) * (r.3 - r.1)).unwrap_or(0.0);+ area(b).partial_cmp(&area(a)).unwrap()+ });+ let inside = grow(board, -0.2);+ for c in order {+ let rf = c["ref"].as_str().unwrap_or("").to_string();+ let bottom = c["side"] == "bottom";+ let Some(b) = own(c) else { unplaced.push(json!({"ref": rf, "why": "no body or pads to place it by"})); continue };+ if b.2 < board.0 || b.0 > board.2 || b.3 < board.1 || b.1 > board.3 {+ unplaced.push(json!({"ref": rf, "why": "the part is off the board: place it first (move_parts)"}));+ continue;+ }+ // the label's size at the new height, from Altium's own box at the old one+ // Altium's box is the text plus half its stroke all round (a 1.0 mm designator measures 1.285 mm tall on the+ // Bluetooth Sentinel example): plan the text, check the box+ let old = f(&c["name"]["size"]);+ let turned = (f(&c["name"]["rotation"]).rem_euclid(180.0) - 90.0).abs() < 1.0;+ let (m, w) = match rect_of(&c["name"]["box"]) {+ Some(r) if old > 0.0 && r.2 > r.0 => {+ let (bw, bh) = if turned { (r.3 - r.1, r.2 - r.0) } else { (r.2 - r.0, r.3 - r.1) };+ let m = ((bh - old) / 2.0).clamp(0.0, 0.3);+ (m, (bw - 2.0 * m).max(old * 0.5) * size / old)+ }+ _ => (0.15, rf.len() as f64 * size * 0.8),+ };+ let free = |text: Rect| -> bool {+ let r = grow(text, m);+ r.0 >= inside.0 && r.1 >= inside.1 && r.2 <= inside.2 && r.3 <= inside.3+ && !pads.iter().any(|p| on_side(&p["layer"], bottom) && hit(r, grow(pad_rect(p), pad_clear)))+ && !bodies.iter().any(|(o, side, ob)| *o != rf && *side == bottom && hit(r, *ob))+ && !placed.iter().any(|(side, pr)| *side == bottom && hit(r, grow(*pr, 0.1)))+ };+ // the sizes to try: `size`, then down in 0.1 mm steps to `min_size`+ let mut found = None;+ let mut s = size;+ while found.is_none() && s >= min_size - 1e-9 {+ found = spots(b, w * s / size, s, gap, bottom).into_iter().find(|(r, _, _)| free(*r)).map(|(r, rot, at)| (r, rot, at, s));+ s -= 0.1;+ }+ match found {+ Some((r, rot, at, s)) => {+ placed.push((bottom, grow(r, m)));+ // the text's anchor: lower left upright; turned 90 its letters lie left of the anchor (lower right of the+ // box); mirrored on the bottom it is the text's right end+ let (ax, ay) = if rot == 90 { (r.2, r.1) } else if bottom { (r.2, r.1) } else { (r.0, r.1) };+ out.push(json!({"ref": rf, "x": r3(ax), "y": r3(ay), "size": r3(s), "rotation": rot, "side": if bottom { "bottom" } else { "top" }, "at": at}));+ }+ None => unplaced.push(json!({"ref": rf, "why": "no clear spot beside the part", "triedSizes": [size, min_size]})),+ }+ }+ (out, unplaced)+}++/// Places a w x h label could go round the part box b, nearest first: centred on each side, then slid along it, at+/// `gap` and then 3 x `gap`; turned 90 degrees beside the left and right sides (top side only: Altium mirrors bottom+/// text). Each is (box, rotation, where).+fn spots(b: Rect, w: f64, h: f64, gap: f64, bottom: bool) -> Vec<(Rect, i64, String)> {+ let mut v = Vec::new();+ let (cx, cy) = ((b.0 + b.2) / 2.0, (b.1 + b.3) / 2.0);+ for g in [gap, 3.0 * gap] {+ // slide offsets along a side: centre first, then outwards both ways in 0.5 mm steps+ let slide = |len: f64| -> Vec<f64> {+ let mut o = vec![0.0];+ let mut k = 0.5;+ while k <= len / 2.0 + 0.5 { o.push(-k); o.push(k); k += 0.5; }+ o+ };+ for d in slide(b.2 - b.0) {+ v.push(((cx - w / 2.0 + d, b.3 + g, cx + w / 2.0 + d, b.3 + g + h), 0, "above".to_string()));+ v.push(((cx - w / 2.0 + d, b.1 - g - h, cx + w / 2.0 + d, b.1 - g), 0, "below".to_string()));+ }+ for d in slide(b.3 - b.1) {+ v.push(((b.2 + g, cy - h / 2.0 + d, b.2 + g + w, cy + h / 2.0 + d), 0, "right".to_string()));+ v.push(((b.0 - g - w, cy - h / 2.0 + d, b.0 - g, cy + h / 2.0 + d), 0, "left".to_string()));+ if !bottom {+ v.push(((b.2 + g, cy - w / 2.0 + d, b.2 + g + h, cy + w / 2.0 + d), 90, "right, turned".to_string()));+ v.push(((b.0 - g - h, cy - w / 2.0 + d, b.0 - g, cy + w / 2.0 + d), 90, "left, turned".to_string()));+ }+ }+ }+ v+}++/// Designators whose box (Altium's own) still touches a pad, closer than `pad_clear`. Pure.+pub fn on_pads(comps: &[Value], pads: &[Value], pad_clear: f64) -> Vec<Value> {+ comps.iter().filter(|c| c["name"]["shown"].as_bool() != Some(false)).filter_map(|c| {+ let r = rect_of(&c["name"]["box"])?;+ let bottom = c["side"] == "bottom";+ let p = pads.iter().find(|p| on_side(&p["layer"], bottom) && hit(r, grow(pad_rect(p), pad_clear - 0.01)))?;+ Some(json!({"ref": c["ref"], "pad": format!("{}.{}", p["ref"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or(""))}))+ }).collect()+}++/// silk_designators: input {board, silk, args: {size?, minSize?, gap?, padClearance?, refs?}}. The plan: `planned`,+/// and `applyArgs` ready for apply_silkscreen (x, y the text's lower-left; its right end on the bottom side, where the+/// text is mirrored), the parts with no clear spot, and the labels on pads before.+pub fn silk_designators(input: &Value) -> Value {+ let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+ let args = &input["args"];+ let num = |k: &str, d: f64| args.get(k).and_then(|v| v.as_f64()).unwrap_or(d);+ let (size, gap, clear) = (num("size", 1.0), num("gap", 0.2), num("padClearance", 0.15));+ if size < 0.5 { return crate::input::refuse("bad_args", "size is the text height in mm, 0.5 or more (JLCPCB reads 1.0 mm and up reliably)"); }+ let Some(bb) = crate::geom::outline_bbox(&st) else { return crate::input::refuse("no_outline", "the board has no outline") };+ let Some(silk) = crate::input::part(input, "silk") else { return crate::input::refuse("bad_args", "pass silk_state as silk: each part's body box and designator") };+ let all = silk["components"].as_array().cloned().unwrap_or_default();+ let mut comps = all.clone();+ if let Some(want) = args.get("refs").and_then(|v| v.as_array()) {+ let w: Vec<&str> = want.iter().filter_map(|v| v.as_str()).collect();+ comps.retain(|c| c["ref"].as_str().map(|r| w.iter().any(|x| x.eq_ignore_ascii_case(r))).unwrap_or(false));+ }+ let pads = st["pads"].as_array().cloned().unwrap_or_default();+ let before = on_pads(&comps, &pads, clear);+ let min_size = num("minSize", size).min(size);+ // the labels of parts left out stay put; so do the labels of parts with no spot, so plan again round those+ let label = |c: &Value| -> Option<(bool, Rect)> { if c["name"]["shown"].as_bool() == Some(false) { None } else { rect_of(&c["name"]["box"]).map(|r| (c["side"] == "bottom", r)) } };+ let mut fixed: Vec<(bool, Rect)> = all.iter().filter(|c| !comps.iter().any(|k| k["ref"] == c["ref"])).filter_map(label).collect();+ let mut moving = comps.clone();+ let (mut names, mut unplaced) = (Vec::new(), Vec::new());+ // every round pins at least one more label, so this ends; the last round leaves nothing unpinned+ for _ in 0..=comps.len() {+ let (n, u) = plan(&moving, &pads, bb, size, min_size, gap, clear, &fixed);+ names = n;+ if u.is_empty() { break; }+ let stuck: Vec<Value> = u.iter().map(|x| x["ref"].clone()).collect();+ fixed.extend(moving.iter().filter(|c| stuck.contains(&c["ref"])).filter_map(label));+ moving.retain(|c| !stuck.contains(&c["ref"]));+ unplaced.extend(u);+ }+ let apply: Vec<Value> = names.iter().map(|n| json!({"ref": n["ref"], "x": n["x"], "y": n["y"], "size": n["size"], "rotation": n["rotation"]})).collect();+ json!({"success": true, "planned": names, "unplaced": unplaced, "onPadsBefore": before, "sizeMm": size, "applyArgs": {"names": apply},+ "_hint": "A plan: every label upright (or turned 90 beside the part), one size, beside its own part, clear of pads by padClearance and of other bodies and labels. Send applyArgs to apply_silkscreen, then read silk_state back and check no label touches a pad with the tool's own text boxes. unplaced parts had no clear spot: try minSize for them (refs), move the part, or place the label by hand."})+}++#[cfg(test)]+mod tests {+ use super::*;++ fn comp(r: &str, body: [f64; 4]) -> Value {+ json!({"ref": r, "side": "top", "body": body, "name": {"text": r, "size": 1.0, "shown": true, "box": [0.0, 0.0, 2.0, 1.0]}})+ }++ #[test]+ fn labels_go_beside_their_part_clear_of_pads_and_each_other() {+ // C1 sits just above U1, where U1's label would go+ let comps = vec![comp("U1", [10.0, 10.0, 16.0, 16.0]), comp("C1", [12.0, 16.4, 14.0, 17.4])];+ let pads = vec![json!({"ref": "C1", "name": "1", "x": 12.5, "y": 16.9, "sizeX": 0.8, "sizeY": 0.8, "rotation": 0, "layer": "F.Cu"}),+ json!({"ref": "C1", "name": "2", "x": 13.5, "y": 16.9, "sizeX": 0.8, "sizeY": 0.8, "rotation": 0, "layer": "F.Cu"})];+ let (out, un) = plan(&comps, &pads, (0.0, 0.0, 40.0, 40.0), 1.0, 1.0, 0.2, 0.15, &[]);+ assert!(un.is_empty());+ // U1 above would land on C1's body and pads: it goes below+ assert_eq!(out[0]["ref"], "U1");+ assert_eq!(out[0]["at"], "below");+ assert_eq!(out[1]["ref"], "C1");+ assert_eq!(out[1]["at"], "above");+ assert_eq!(out[1]["y"], 17.6);+ assert_eq!(out[0]["y"], 8.8);+ }++ #[test]+ fn a_part_boxed_in_is_reported_not_forced() {+ let comps = vec![comp("R1", [1.0, 1.0, 3.0, 2.0])];+ let (out, un) = plan(&comps, &[], (0.0, 0.0, 4.0, 3.0), 1.0, 1.0, 0.2, 0.15, &[]);+ assert!(out.is_empty());+ assert_eq!(un[0]["ref"], "R1");+ let on = on_pads(&[json!({"ref": "R1", "side": "top", "name": {"shown": true, "box": [0.0, 0.0, 2.0, 1.0]}})],+ &[json!({"ref": "R1", "name": "1", "x": 1.0, "y": 0.5, "sizeX": 0.5, "sizeY": 0.5, "rotation": 0, "layer": "F.Cu"})], 0.15);+ assert_eq!(on.len(), 1);+ }+}
rust/vendor/adom-eda-checks/src/space.rsadded+239@@ -0,0 +1,239 @@+//! Room on the board: free spots for a part near another (clear of every other part on that side, of that side's+//! copper, and inside the outline), and two-pad parts a quarter turn would shorten the airlines of. From the Altium+//! bridge's design.rs; a part is the box of its pads and, with the bodies companion, of its 3D body.+use serde_json::{json, Value};+use std::collections::{BTreeMap, HashMap};++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }+type Rect = (f64, f64, f64, f64);+fn grow(r: Rect, g: f64) -> Rect { (r.0 - g, r.1 - g, r.2 + g, r.3 + g) }+fn hits(a: Rect, b: Rect) -> bool { a.0 < b.2 && b.0 < a.2 && a.1 < b.3 && b.1 < a.3 }+fn dist(a: (f64, f64), b: (f64, f64)) -> f64 { ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt() }++/// The parts' boxes and sides, with their bodies when the companion came.+fn part_boxes(st: &Value, input: &Value) -> BTreeMap<String, (bool, Rect)> {+ let mut bodies: BTreeMap<String, Rect> = BTreeMap::new();+ for b in crate::bodies::input_boxes(input).unwrap_or_default() {+ let e = bodies.entry(b.part.clone()).or_insert((b.x0, b.y0, b.x1, b.y1));+ *e = (e.0.min(b.x0), e.1.min(b.y0), e.2.max(b.x1), e.3.max(b.y1));+ }+ crate::placement::part_boxes(st, &bodies)+}++/// find_space: input {board, bodies?, args: {near? | at?: {x, y}, part? | sizeMm?: [w, h], side?, clearanceMm?, edgeMm?,+/// count?, radiusMm?, copper?, check?}}. Free spots nearest first, as where the part's origin goes; with check: true,+/// the parts that already overlap or leave the outline.+pub fn find_space(input: &Value) -> Value {+ let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+ let args = &input["args"];+ let s = |k: &str| args[k].as_str().map(String::from);+ let ring = crate::coppermap::outline_ring(&st);+ let boxes = part_boxes(&st, input);+ let parts: Vec<(String, Rect, bool)> = boxes.iter().map(|(r, (b, x))| (r.clone(), *x, *b)).collect();+ let clr = args["clearanceMm"].as_f64().unwrap_or(0.25);+ let edge = args["edgeMm"].as_f64().unwrap_or(0.3);+ let seg = |p: (f64, f64), a: (f64, f64), b: (f64, f64)| {+ let (dx, dy) = (b.0 - a.0, b.1 - a.1);+ let l2 = dx * dx + dy * dy;+ let t = if l2 == 0.0 { 0.0 } else { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / l2).clamp(0.0, 1.0) };+ dist(p, (a.0 + t * dx, a.1 + t * dy))+ };+ let in_outline = |r: Rect| -> bool {+ if ring.len() < 3 { return true; }+ [(r.0, r.1), (r.2, r.1), (r.2, r.3), (r.0, r.3)].iter().all(|&c| crate::route::inside(c, &ring) && (0..ring.len()).all(|i| seg(c, ring[i], ring[(i + 1) % ring.len()]) >= edge))+ };+ if args["check"].as_bool() == Some(true) {+ // the same tests placement_check makes (fusion-lane): a part under a raised one (an Adom molecule on pins) whose+ // pads stay apart and whose bodies do not meet in 3D is stacked, not overlapping; a part is off the outline by+ // its pads' centres, not its box (a round board's edge cuts a box corner off a part well inside it)+ let pads = st["pads"].as_array().cloned().unwrap_or_default();+ let bodies = crate::bodies::input_boxes(input);+ let (mut overlaps, mut stacked) = (Vec::new(), Vec::new());+ for i in 0..parts.len() { for j in i + 1..parts.len() {+ if parts[i].2 != parts[j].2 || !hits(parts[i].1, parts[j].1) { continue; }+ let pair = json!([parts[i].0, parts[j].0]);+ match &bodies { Some(bs) if crate::placement::is_stacked(&pads, bs, &parts[i].0, &parts[j].0, 0.0) => stacked.push(pair), _ => overlaps.push(pair) }+ } }+ let outside: Vec<&String> = parts.iter().filter(|p| {+ let own: Vec<&Value> = pads.iter().filter(|q| q["ref"].as_str() == Some(p.0.as_str())).collect();+ ring.len() >= 3 && !own.is_empty() && own.iter().any(|q| !crate::route::inside((f(&q["x"]), f(&q["y"])), &ring))+ }).map(|p| &p.0).collect();+ return json!({"success": true, "overlaps": overlaps, "stacked": stacked, "outsideOutline": outside, "parts": parts.len(), "bodiesUsed": bodies.is_some(),+ "_hint": "Overlaps compare part boxes (pads, and 3D bodies when given) on the same side; placement_check adds margins, stacked parts and mounting holes. find_space {near, part} finds a free spot; move_parts moves it."});+ }+ let comp = |d: &str| st["components"].as_array().and_then(|a| a.iter().find(|c| c["ref"].as_str().map(|r| r.eq_ignore_ascii_case(d)).unwrap_or(false)).cloned());+ // what to place: an existing part (its extent and origin) or a size+ let me = s("part").and_then(|p| parts.iter().find(|x| x.0.eq_ignore_ascii_case(&p)).cloned());+ let (w, h, off, my_bottom) = match (&me, args["sizeMm"].as_array()) {+ (Some((d, r, b)), _) => {+ let c = comp(d).unwrap_or(Value::Null);+ let (ox, oy) = (c["x"].as_f64().unwrap_or((r.0 + r.2) / 2.0), c["y"].as_f64().unwrap_or((r.1 + r.3) / 2.0));+ (r.2 - r.0, r.3 - r.1, (ox - (r.0 + r.2) / 2.0, oy - (r.1 + r.3) / 2.0), *b)+ }+ (None, Some(sz)) if sz.len() == 2 => (f(&sz[0]).max(0.01), f(&sz[1]).max(0.01), (0.0, 0.0), false),+ _ => return crate::input::refuse("bad_args", "part (a designator on the board) or sizeMm [w, h] is required"),+ };+ let bottom = match s("side").as_deref() { Some("bottom") => true, Some("top") => false, _ => my_bottom };+ let (cx, cy) = if let Some(n) = s("near") {+ match parts.iter().find(|x| x.0.eq_ignore_ascii_case(&n)) { Some((_, r, _)) => ((r.0 + r.2) / 2.0, (r.1 + r.3) / 2.0), None => return crate::input::refuse("no_such_part", &format!("{n} is not on the board")) }+ } else if args["at"].is_object() {+ (f(&args["at"]["x"]), f(&args["at"]["y"]))+ } else { return crate::input::refuse("bad_args", "near (a designator) or at {x, y} is required") };+ let mine = me.as_ref().map(|m| m.0.clone()).unwrap_or_default();+ let mut blocks: Vec<Rect> = parts.iter().filter(|p| p.2 == bottom && !p.0.eq_ignore_ascii_case(&mine)).map(|p| grow(p.1, clr)).collect();+ if args["copper"].as_bool() != Some(false) {+ let layer = if bottom { "B.Cu" } else { "F.Cu" };+ // net-less tracks are a hatched pour's pieces, which re-pour round a new part (6473 of them on the Bluetooth+ // Sentinel left no spot anywhere)+ for t in st["tracks"].as_array().into_iter().flatten().filter(|t| t["layer"] == layer && t["net"].as_str().map(|n| !n.is_empty()).unwrap_or(false)) {+ let (a, b, hw) = ((f(&t["start"]["x"]), f(&t["start"]["y"])), (f(&t["end"]["x"]), f(&t["end"]["y"])), f(&t["width"]) / 2.0);+ blocks.push(grow((a.0.min(b.0), a.1.min(b.1), a.0.max(b.0), a.1.max(b.1)), hw + clr));+ }+ for v in st["vias"].as_array().into_iter().flatten() {+ let (x, y, r) = (f(&v["position"]["x"]), f(&v["position"]["y"]), f(&v["size"]) / 2.0);+ blocks.push(grow((x - r, y - r, x + r, y + r), clr));+ }+ }+ let count = args["count"].as_u64().unwrap_or(5) as usize;+ let radius = args["radiusMm"].as_f64().unwrap_or(10.0);+ let step = 0.25;+ let n = (radius / step).ceil() as i64;+ let mut cands: Vec<(f64, f64, f64)> = Vec::new();+ for i in -n..=n { for j in -n..=n {+ let (x, y) = (cx + i as f64 * step, cy + j as f64 * step);+ let d = dist((x, y), (cx, cy));+ if d <= radius { cands.push((d, x, y)); }+ } }+ cands.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());+ let (mut spots, mut taken): (Vec<Value>, Vec<Rect>) = (Vec::new(), Vec::new());+ for (d, x, y) in cands {+ let r = (x - w / 2.0, y - h / 2.0, x + w / 2.0, y + h / 2.0);+ if blocks.iter().any(|b| hits(r, *b)) || taken.iter().any(|t| hits(r, *t)) || !in_outline(r) { continue; }+ spots.push(json!({"x": r3(x + off.0), "y": r3(y + off.1), "distanceMm": r3(d), "extent": [r3(r.0), r3(r.1), r3(r.2), r3(r.3)]}));+ taken.push(grow(r, clr));+ if spots.len() >= count { break; }+ }+ if spots.is_empty() { return json!({"success": false, "errorCode": "no_space", "error": format!("no free {} x {} mm spot within {radius} mm", r3(w), r3(h)), "spots": []}); }+ json!({"success": true, "spots": spots, "part": if mine.is_empty() { Value::Null } else { json!(mine) }, "sizeMm": [r3(w), r3(h)], "side": if bottom { "bottom" } else { "top" },+ "_hint": "spots[].x/y are where the part's origin goes: move_parts {parts:[{ref, x, y, rotation}]}, then repour (pours are not avoided: they clear round the new pads). Each spot keeps clearanceMm from other part boxes on that side, from that side's tracks and vias, and edgeMm from the outline; nearest first. find_space {check: true} lists parts that already overlap."})+}++/// Minimum spanning tree length of points (Prim's, O(n^2): nets are small).+fn mst(pts: &[(f64, f64)]) -> f64 {+ if pts.len() < 2 { return 0.0; }+ let (mut inside, mut best) = (vec![false; pts.len()], vec![f64::MAX; pts.len()]);+ best[0] = 0.0;+ let mut total = 0.0;+ for _ in 0..pts.len() {+ let mut u = usize::MAX;+ for i in 0..pts.len() { if !inside[i] && (u == usize::MAX || best[i] < best[u]) { u = i; } }+ inside[u] = true;+ total += best[u];+ for v in 0..pts.len() { if !inside[v] { let d = dist(pts[u], pts[v]); if d < best[v] { best[v] = d; } } }+ }+ total+}++/// check_orientation: input {board, args: {parts?, minImprovementMm?, planeNetPads?}}. Advisory: each two-pad part's+/// quarter turns scored in place, a routable net by its airline (MST) length with the pad there, a plane-like net+/// (more than planeNetPads pads, GND) by the distance to its nearest same-net pad. Turns that save minImprovementMm or+/// more are reported; nothing moves.+pub fn check_orientation(input: &Value) -> Value {+ let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+ let args = &input["args"];+ let min_gain = args["minImprovementMm"].as_f64().unwrap_or(0.6);+ let plane_pads = args["planeNetPads"].as_u64().unwrap_or(12) as usize;+ let want: Vec<String> = args["parts"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(|s| s.to_ascii_uppercase())).collect()).unwrap_or_default();+ let pads = st["pads"].as_array().cloned().unwrap_or_default();+ let mut net_pads: HashMap<String, Vec<(String, (f64, f64))>> = HashMap::new();+ for p in &pads {+ let n = p["net"].as_str().unwrap_or("");+ if !n.is_empty() { net_pads.entry(n.to_string()).or_default().push((p["ref"].as_str().unwrap_or("").to_string(), (f(&p["x"]), f(&p["y"])))); }+ }+ let (mut findings, mut checked) = (Vec::new(), 0);+ for c in st["components"].as_array().into_iter().flatten() {+ let r = c["ref"].as_str().unwrap_or("").to_string();+ if !want.is_empty() && !want.contains(&r.to_ascii_uppercase()) { continue; }+ let mine: Vec<&Value> = pads.iter().filter(|p| p["ref"].as_str() == Some(&r)).collect();+ if mine.len() != 2 { continue; }+ checked += 1;+ let (cx, cy, rot) = (f(&c["x"]), f(&c["y"]), f(&c["rotation"]));+ // the airlines with the part turned by delta degrees (counterclockwise, y up)+ let score = |delta: f64| -> f64 {+ let a = delta.to_radians();+ let mut total = 0.0;+ for p in &mine {+ let net = p["net"].as_str().unwrap_or("");+ if net.is_empty() { continue; }+ let (px, py) = (f(&p["x"]) - cx, f(&p["y"]) - cy);+ let at = (cx + px * a.cos() - py * a.sin(), cy + px * a.sin() + py * a.cos());+ let others: Vec<(f64, f64)> = net_pads.get(net).map(|v| v.iter().filter(|(o, _)| o != &r).map(|(_, q)| *q).collect()).unwrap_or_default();+ if others.is_empty() { continue; }+ total += if others.len() + 1 > plane_pads { others.iter().map(|q| dist(at, *q)).fold(f64::MAX, f64::min) } else { let mut pts = others.clone(); pts.push(at); mst(&pts) };+ }+ total+ };+ let now = score(0.0);+ let mut best = (0.0, now);+ for d in [90.0, 180.0, 270.0] { let sc = score(d); if sc < best.1 - 1e-9 { best = (d, sc); } }+ if now - best.1 >= min_gain {+ findings.push(json!({"ref": r, "rotation": rot, "suggested": (rot + best.0).rem_euclid(360.0), "airlineNowMm": r3(now), "airlineThenMm": r3(best.1), "savesMm": r3(now - best.1)}));+ }+ }+ findings.sort_by(|a, b| f(&b["savesMm"]).partial_cmp(&f(&a["savesMm"])).unwrap_or(std::cmp::Ordering::Equal));+ json!({"success": true, "checked": checked, "suggestions": findings,+ "_hint": "Advisory only: each two-pad part's quarter turns scored by airline length (MST of a routable net with the pad there; distance to the nearest pad of a plane-like net such as GND). Turn with move_parts {parts:[{ref, rotation}]} (position kept), then reroute those nets. Routed copper is not considered: on a routed board, treat these as hints."})+}++#[cfg(test)]+mod tests {+ use super::*;++ fn pad(r: &str, n: &str, x: f64, y: f64, net: &str) -> Value {+ json!({"ref": r, "name": n, "x": x, "y": y, "sizeX": 0.6, "sizeY": 0.6, "rotation": 0, "layer": "F.Cu", "drill": 0, "net": net, "shape": "rect"})+ }++ fn board() -> Value {+ // R1 lies along x, but its nets go up and down: a quarter turn shortens both airlines+ json!({"outline": [[0, 0], [20, 0], [20, 20], [0, 20]],+ "components": [{"ref": "R1", "x": 10.0, "y": 10.0, "rotation": 0.0, "side": "top"}, {"ref": "U1", "x": 10.0, "y": 15.0, "rotation": 0.0, "side": "top"}, {"ref": "U2", "x": 10.0, "y": 5.0, "rotation": 0.0, "side": "top"}],+ "pads": [pad("R1", "1", 9.0, 10.0, "A"), pad("R1", "2", 11.0, 10.0, "B"), pad("U1", "1", 10.0, 15.0, "A"), pad("U1", "2", 11.0, 15.0, "X"), pad("U2", "1", 10.0, 5.0, "B"), pad("U2", "2", 11.0, 5.0, "Y")]})+ }++ #[test]+ fn a_quarter_turn_that_shortens_the_airlines_is_suggested() {+ let r = check_orientation(&json!({"board": board(), "args": {}}));+ // U1 and U2 have two pads too, but turning them about the pad at their origin gains nothing+ assert_eq!((r["checked"].clone(), r["suggestions"].as_array().unwrap().len()), (json!(3), 1), "{r}");+ let s = &r["suggestions"][0];+ // pad 1 (net A) has to come up to U1: a quarter turn clockwise, 270 counterclockwise+ assert_eq!((s["ref"].as_str(), s["suggested"].as_f64()), (Some("R1"), Some(270.0)), "{r}");+ assert!(f(&s["savesMm"]) > 0.6);+ }++ #[test]+ fn free_spots_avoid_parts_copper_and_the_edge() {+ let mut b = board();+ b["tracks"] = json!([{"layer": "F.Cu", "net": "A", "width": 0.25, "start": {"x": 12.0, "y": 0.0}, "end": {"x": 12.0, "y": 20.0}}]);+ let r = find_space(&json!({"board": b, "args": {"near": "R1", "sizeMm": [2.0, 1.0], "count": 3}}));+ assert_eq!(r["success"], json!(true), "{r}");+ for s in r["spots"].as_array().unwrap() {+ let e: Vec<f64> = s["extent"].as_array().unwrap().iter().map(f).collect();+ // clear of R1's box (8.7..11.3 x 9.7..10.3) by 0.25, of the track at x 12 by 0.125 + 0.25+ assert!(e[2] <= 8.7 - 0.25 + 1e-9 || e[0] >= 11.3 + 0.25 - 1e-9 || e[3] <= 9.7 - 0.25 + 1e-9 || e[1] >= 10.3 + 0.25 - 1e-9, "{s}");+ assert!(e[2] <= 12.0 - 0.375 + 1e-9 || e[0] >= 12.0 + 0.375 - 1e-9, "{s}");+ }+ let chk = find_space(&json!({"board": board(), "args": {"check": true}}));+ assert_eq!((chk["overlaps"].as_array().unwrap().len(), chk["outsideOutline"].as_array().unwrap().len()), (0, 0));+ // a part under a raised molecule: pads apart, bodies apart in height, so stacked, not an overlap (fusion-lane)+ let mut m = board();+ m["components"].as_array_mut().unwrap().push(json!({"ref": "M1", "x": 10.0, "y": 10.0, "rotation": 0.0, "side": "top"}));+ m["pads"].as_array_mut().unwrap().extend([pad("M1", "1", 6.0, 10.0, "P"), pad("M1", "2", 14.0, 10.0, "Q")]);+ let bodies = json!({"bodies": [{"ref": "M1", "side": "top", "x0": 5.5, "y0": 8.0, "x1": 14.5, "y1": 12.0, "z0": 3.0, "z1": 4.0},+ {"ref": "R1", "side": "top", "x0": 8.7, "y0": 9.7, "x1": 11.3, "y1": 10.3, "z0": 0.0, "z1": 0.5}]});+ let s = find_space(&json!({"board": m, "bodies": bodies, "args": {"check": true}}));+ assert_eq!((s["overlaps"].as_array().unwrap().len(), s["stacked"].clone(), s["bodiesUsed"].clone()), (0, json!([["M1", "R1"]]), json!(true)), "{s}");+ assert_eq!(find_space(&json!({"board": board(), "args": {"near": "Q9", "sizeMm": [1, 1]}}))["errorCode"], "no_such_part");+ }+}
rust/vendor/adom-eda-checks/src/writers/ipc2581.rs+1−1@@ -17,7 +17,7 @@ fn n(v: f64) -> String { fn ascii(t: &str) -> String { t.chars().map(|c| match c { c if (c as u32) < 128 => c.to_string(),- 'Ω' | 'Ω' => "ohm".into(), 'µ' | 'μ' => "u".into(), '±' => "+/-".into(), '°' => "deg".into(), '–' | '—' => "-".into(),+ '\u{03A9}' | '\u{2126}' => "ohm".into(), '\u{00B5}' | '\u{03BC}' => "u".into(), '\u{00B1}' => "+/-".into(), '\u{00B0}' => "deg".into(), '\u{2013}' | '\u{2014}' => "-".into(), _ => "?".into(), }).collect() }
tests/run_verb_tests.py+7−1@@ -256,8 +256,12 @@ t("fusion_fab_check", "daily", dict(B, vendor="jlcpcb"), {"ok": True, "fields": t("fusion_estimate_cost", "daily", dict(B, quantities=[5, 30], assembly=True), {"ok": True, "fields": ["prices", "source", "engine"]}) t("fusion_read_board", "daily", dict(B, sections="components,nets,rules,outline", limit=5), {"ok": True, "fields": ["data", "counts"]}) t("fusion_diff_pairs", "daily", dict(B), {"ok": True, "fields": ["pairs"]}, "BQ25792 pairs D_P/D_N by name")+t("fusion_auto_classes", "daily", dict(B), {"ok": True, "fields": ["proposal", "applyArgs", "engine"]}, "a proposal: nothing is written")+t("fusion_silk_designators", "daily", dict(B, dryRun=True), {"ok": True, "fields": ["planned", "applyArgs", "engine"]}, "dryRun: a plan only") t("fusion_net_lengths", "daily", dict(B), {"ok": True, "fields": ["nets", "pairs", "engine"]}) t("fusion_assembly_check", "daily", dict(B), {"ok": True, "fields": ["economic", "standard", "engine"]})+t("fusion_jlcpcb_order", "daily", dict(B, outputDir="C:/Users/olive/adom-tests/jlc-order"), {"ok": True, "fields": ["ready", "issues", "gerberZip", "bomFiles", "orderForm"]}, "writes the order folder; BQ25792 is not ready (13 BOM lines without LCSC)")+t("fusion_sch_zoom", "daily", dict(B, refs=["IC1", "C11"]), {"ok": True, "fields": ["found", "rectMm", "image"]}, "SHOW + WINDOW + EXPORT IMAGE WINDOW: nothing changes") t("fusion_export_spice", "daily", dict(B), {"ok": True, "fields": ["path", "elements", "unmapped"]}, "EAGLE's EXPORT SPICENETLIST: BQ25792's ICs have no SPICE mapping") t("fusion_simulate", "negative", dict(B, analysis={"type": "op"}), {"errorCode": "no_model"}, "BQ25792 has unmapped parts: refused before ngspice runs") for v in ("fusion_sch_place_part", "fusion_sch_connect", "fusion_sch_swap_part"):@@ -265,6 +269,8 @@ for v in ("fusion_sch_place_part", "fusion_sch_connect", "fusion_sch_swap_part") t("fusion_add_rule", "manual", note="edits the board's net classes: tested live on lane-BQ25792-copy and put back") t("fusion_height_check", "daily", dict(B, maxTopMm=8, minGapMm=0.1), {"ok": True, "fields": ["heights", "noBody", "engine"]}, "reads fusion_bodies (switches to the 3D PCB and back)") t("fusion_placement_check", "daily", dict(B, marginMm=0.1), {"ok": True, "fields": ["parts", "courtyardOverlaps", "engine"]})+t("fusion_find_space", "daily", dict(B, near="IC1", sizeMm=[1, 0.5], count=3, radiusMm=25), {"ok": True, "fields": ["spots", "engine"]}, "a 2 x 1.25 mm spot has none within 10 mm of IC1 on this dense board")+t("fusion_check_orientation", "daily", dict(B), {"ok": True, "fields": ["engine"]}) t("fusion_check_clearance", "daily", dict(B, mode="pads", thresholdMm=0.2), {"ok": True, "fields": ["engine"]}) t("fusion_place_decoupling", "daily", dict(B, ic="IC1"), {"ok": True, "fields": ["engine"]}, "a plan only: nothing moves") t("fusion_plan_fanout", "daily", dict(B, ref="IC1"), {"ok": True, "fields": ["calls", "conflicts", "engine"]}, "a plan only: nothing is drawn")@@ -505,7 +511,7 @@ _SECTION_BY_VERB = { "fusion_verb_times": "bridge-lifecycle", "fusion_live_feed": "bridge-lifecycle", "fusion_account": "auth-tokens", "fusion_claim_seat": "auth-tokens", "fusion_release_seat": "auth-tokens", "fusion_prewarm": "bridge-lifecycle", "fusion_prove_result": "cad-3d-bom", "fusion_set_driving_badge": "bridge-lifecycle", "fusion_set_window_sizing": "gui-driving", "fusion_cancel_run": "cad-3d-bom", "fusion_preflight_lbr": "cad-3d-bom", "fusion_library_parts": "cad-3d-bom", "fusion_export_library_part": "cad-3d-bom", "fusion_edit_footprint": "cad-3d-bom", "fusion_create_footprint": "cad-3d-bom", "fusion_create_symbol": "cad-3d-bom", "fusion_read_symbol": "cad-3d-bom", "fusion_find_symbol": "cad-3d-bom", "fusion_copy_footprint": "cad-3d-bom", "fusion_rename_footprint": "cad-3d-bom", "fusion_remove_footprint": "cad-3d-bom", "fusion_xcopy_footprint": "cad-3d-bom", "fusion_clone_symbol": "cad-3d-bom", "fusion_rename_symbol": "cad-3d-bom", "fusion_remove_symbol": "cad-3d-bom", "fusion_set_params": "cad-3d-bom", "fusion_read_footprint": "cad-3d-bom", "fusion_cross_probe": "routing", "fusion_project_info": "routing", "fusion_schematic_data": "routing", "fusion_part_parameters": "routing", "fusion_cross_check": "routing", "fusion_doc_params": "routing", "fusion_sheet_params": "routing", "fusion_sch_edit": "routing", "fusion_annotate": "routing", "fusion_footprint_primitives": "routing", "fusion_thermal_vias": "routing", "fusion_via_fence": "routing", "fusion_board_features": "routing", "fusion_export_pdf": "routing", "fusion_project_create": "routing", "fusion_project_rename": "routing", "fusion_project_snapshot": "routing", "fusion_import_svg": "routing", "fusion_copy_routing": "routing", "fusion_set_layer_count": "routing", "fusion_take_screenshot": "gui-driving",- "fusion_schematic": "gui-driving", "fusion_show_attributes": "cad-3d-bom", "fusion_make_showcase_project": "cad-3d-bom", "fusion_eagle_import_status": "cad-3d-bom", "fusion_routing_state": "routing", "fusion_route_net": "routing", "fusion_routing_drc": "routing", "fusion_plan_route": "routing", "fusion_move_parts": "routing", "fusion_place_vias": "routing", "fusion_apply_pours": "routing", "fusion_pour_state": "routing", "fusion_board_source": "routing", "fusion_ripup_nets": "routing", "fusion_schematic_run": "routing", "fusion_board_bom": "routing", "fusion_change_pours": "routing", "fusion_board_seed": "routing", "fusion_schematic_plan": "routing", "fusion_remove_pours": "routing", "fusion_layer_stack": "routing", "fusion_netlist": "routing", "fusion_read_sheet_file": "routing", "fusion_bodies": "routing", "fusion_save_document": "aps", "fusion_apply_silkscreen": "routing", "fusion_component_data": "routing", "fusion_component_pins": "routing", "fusion_list_nets": "routing", "fusion_net_connections": "routing", "fusion_list_designators": "routing", "fusion_layers": "routing", "fusion_copper_layers": "routing", "fusion_layers_in_use": "routing", "fusion_read_rules": "routing", "fusion_rules_by_kind": "routing", "fusion_silk_state": "routing", "fusion_net_classes": "routing", "fusion_run_drc": "routing", "fusion_repour": "routing", "fusion_zoom_to": "routing", "fusion_launch": "bridge-lifecycle", "fusion_export_gerber": "exports", "fusion_dismiss_dialogs": "gui-driving", "fusion_board_summary": "routing", "fusion_install": "bridge-lifecycle", "fusion_sch_items": "routing", "fusion_sch_pins": "routing", "fusion_sch_trace_point": "routing", "fusion_sch_page": "routing", "fusion_sch_hierarchy": "routing", "fusion_sch_sheet_pins": "routing", "fusion_sch_connect_pins": "routing", "fusion_sch_fix_connectivity": "routing", "fusion_render_schematic": "routing", "fusion_sch_update_from_library": "routing", "fusion_sch_text": "routing", "fusion_sch_junction": "routing", "fusion_sch_delete_wire": "routing", "fusion_sch_split_wire": "routing", "fusion_sch_bus": "routing", "fusion_sch_edit_label": "routing", "fusion_sch_move_region": "routing", "fusion_sch_bus_entry": "routing", "fusion_sch_connect_to_bus": "routing", "fusion_sch_reset_fields": "routing", "fusion_sch_create": "routing", "fusion_update_board_from_eagle": "routing", "fusion_render_board_3d": "cad-3d-bom", "fusion_generate_body": "cad-3d-bom", "fusion_library_views": "gui-driving", "fusion_edit_parts": "routing", "fusion_align_parts": "routing", "fusion_remove_graphics": "routing", "fusion_remove_parts": "routing", "fusion_copper_balance": "routing", "fusion_return_path": "routing", "fusion_impedance": "routing", "fusion_current_capacity": "routing", "fusion_fab_check": "routing", "fusion_estimate_cost": "routing", "fusion_read_board": "routing", "fusion_diff_pairs": "routing", "fusion_net_lengths": "routing", "fusion_assembly_check": "routing", "fusion_sch_place_part": "routing", "fusion_export_spice": "routing", "fusion_simulate": "routing", "fusion_sch_connect": "routing", "fusion_sch_swap_part": "routing", "fusion_add_rule": "routing", "fusion_height_check": "routing", "fusion_placement_check": "routing", "fusion_check_clearance": "routing", "fusion_place_decoupling": "routing", "fusion_plan_fanout": "routing", "fusion_export_odb": "exports", "fusion_export_ipc356": "exports", "fusion_export_gencad": "exports", "fusion_export_ipc2581": "exports", "fusion_select_net": "routing", "fusion_set_layer_visibility": "routing", "fusion_board_outline": "routing", "fusion_run_erc": "routing", "fusion_set_track_width": "routing", "fusion_remove_items": "routing", "fusion_set_outline": "routing", "fusion_board_shape": "routing", "fusion_autoroute": "routing", "fusion_check_dialogs": "gui-driving", "fusion_status": "bridge-lifecycle", "fusion_save": "aps", "fusion_run_script": "gui-driving", "fusion_run_command": "gui-driving", "fusion_list_commands": "gui-driving", "fusion_dialog_log": "gui-driving", "fusion_signin_status": "bridge-lifecycle", "fusion_license_status": "bridge-lifecycle", "fusion_open_document": "cad-3d-bom", "fusion_show_3d_chip": "cad-3d-bom",+ "fusion_schematic": "gui-driving", "fusion_show_attributes": "cad-3d-bom", "fusion_make_showcase_project": "cad-3d-bom", "fusion_eagle_import_status": "cad-3d-bom", "fusion_routing_state": "routing", "fusion_route_net": "routing", "fusion_routing_drc": "routing", "fusion_plan_route": "routing", "fusion_move_parts": "routing", "fusion_place_vias": "routing", "fusion_apply_pours": "routing", "fusion_pour_state": "routing", "fusion_board_source": "routing", "fusion_ripup_nets": "routing", "fusion_schematic_run": "routing", "fusion_board_bom": "routing", "fusion_change_pours": "routing", "fusion_board_seed": "routing", "fusion_schematic_plan": "routing", "fusion_remove_pours": "routing", "fusion_layer_stack": "routing", "fusion_netlist": "routing", "fusion_read_sheet_file": "routing", "fusion_bodies": "routing", "fusion_save_document": "aps", "fusion_apply_silkscreen": "routing", "fusion_component_data": "routing", "fusion_component_pins": "routing", "fusion_list_nets": "routing", "fusion_net_connections": "routing", "fusion_list_designators": "routing", "fusion_layers": "routing", "fusion_copper_layers": "routing", "fusion_layers_in_use": "routing", "fusion_read_rules": "routing", "fusion_rules_by_kind": "routing", "fusion_silk_state": "routing", "fusion_net_classes": "routing", "fusion_run_drc": "routing", "fusion_repour": "routing", "fusion_zoom_to": "routing", "fusion_launch": "bridge-lifecycle", "fusion_export_gerber": "exports", "fusion_dismiss_dialogs": "gui-driving", "fusion_board_summary": "routing", "fusion_install": "bridge-lifecycle", "fusion_sch_items": "routing", "fusion_sch_pins": "routing", "fusion_sch_trace_point": "routing", "fusion_sch_page": "routing", "fusion_sch_hierarchy": "routing", "fusion_sch_sheet_pins": "routing", "fusion_sch_connect_pins": "routing", "fusion_sch_fix_connectivity": "routing", "fusion_render_schematic": "routing", "fusion_sch_update_from_library": "routing", "fusion_sch_text": "routing", "fusion_sch_junction": "routing", "fusion_sch_delete_wire": "routing", "fusion_sch_split_wire": "routing", "fusion_sch_bus": "routing", "fusion_sch_edit_label": "routing", "fusion_sch_move_region": "routing", "fusion_sch_bus_entry": "routing", "fusion_sch_connect_to_bus": "routing", "fusion_sch_reset_fields": "routing", "fusion_sch_create": "routing", "fusion_update_board_from_eagle": "routing", "fusion_render_board_3d": "cad-3d-bom", "fusion_generate_body": "cad-3d-bom", "fusion_library_views": "gui-driving", "fusion_edit_parts": "routing", "fusion_align_parts": "routing", "fusion_remove_graphics": "routing", "fusion_remove_parts": "routing", "fusion_copper_balance": "routing", "fusion_return_path": "routing", "fusion_impedance": "routing", "fusion_current_capacity": "routing", "fusion_fab_check": "routing", "fusion_estimate_cost": "routing", "fusion_read_board": "routing", "fusion_diff_pairs": "routing", "fusion_auto_classes": "routing", "fusion_silk_designators": "routing", "fusion_net_lengths": "routing", "fusion_assembly_check": "routing", "fusion_jlcpcb_order": "exports", "fusion_sch_place_part": "routing", "fusion_export_spice": "routing", "fusion_sch_zoom": "routing", "fusion_simulate": "routing", "fusion_sch_connect": "routing", "fusion_sch_swap_part": "routing", "fusion_add_rule": "routing", "fusion_height_check": "routing", "fusion_placement_check": "routing", "fusion_find_space": "routing", "fusion_check_orientation": "routing", "fusion_check_clearance": "routing", "fusion_place_decoupling": "routing", "fusion_plan_fanout": "routing", "fusion_export_odb": "exports", "fusion_export_ipc356": "exports", "fusion_export_gencad": "exports", "fusion_export_ipc2581": "exports", "fusion_select_net": "routing", "fusion_set_layer_visibility": "routing", "fusion_board_outline": "routing", "fusion_run_erc": "routing", "fusion_set_track_width": "routing", "fusion_remove_items": "routing", "fusion_set_outline": "routing", "fusion_board_shape": "routing", "fusion_autoroute": "routing", "fusion_check_dialogs": "gui-driving", "fusion_status": "bridge-lifecycle", "fusion_save": "aps", "fusion_run_script": "gui-driving", "fusion_run_command": "gui-driving", "fusion_list_commands": "gui-driving", "fusion_dialog_log": "gui-driving", "fusion_signin_status": "bridge-lifecycle", "fusion_license_status": "bridge-lifecycle", "fusion_open_document": "cad-3d-bom", "fusion_show_3d_chip": "cad-3d-bom", "fusion_apply_fusion_update": "bridge-lifecycle", "fusion_resolve_seat_dialog": "bridge-lifecycle", "fusion_interactive": "gui-driving", "fusion_narrate": "gui-driving", "fusion_view": "gui-driving", "fusion_electronics_query": "cad-3d-bom", "fusion_activate_document": "gui-driving", "fusion_library_manager": "gui-driving", "fusion_plan_library_import": "cad-3d-bom", "fusion_create_library": "cad-3d-bom", "fusion_install_library_bundle": "cad-3d-bom",