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 21 file(s)
21 files changed
+442−34
BRIDGE_VERSION+1−1CHANGELOG.md+10bridge.json+6−2contracts/describe.json+46dashboard/Cargo.lock+1−1dashboard/Cargo.toml+1−1describe.py+8install.sh+1−1package.json+1−1page.json+5−11rust/Cargo.lock+6−6rust/Cargo.toml+1−1rust/bridge/src/checks.rs+9rust/vendor/adom-eda-checks/Cargo.toml+1−1rust/vendor/adom-eda-checks/README.md+4−2rust/vendor/adom-eda-checks/VENDOR.md+3−3rust/vendor/adom-eda-checks/src/assembly.rs+158rust/vendor/adom-eda-checks/src/lengths.rs+135rust/vendor/adom-eda-checks/src/lib.rs+5−1rust/vendor/adom-eda-checks/src/route.rs+37−1tests/run_verb_tests.py+3−1BRIDGE_VERSION+1−1@@ -1 +1 @@-1.13.9+1.13.10
CHANGELOG.md+10@@ -3,6 +3,16 @@ 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.10 (2026-10-29)++Two more of the standard's checks from the shared engine 0.12.2 (standard 0.15.2). 314 verbs.++- `fusion_net_lengths`: each routed net's length by layer with its vias, the skew of each differential pair (by the+ nets' names) and the spread of matched-length groups.+- `fusion_assembly_check`: the board against JLCPCB's Economic and Standard assembly tiers; the BOM lines with no+ LCSC number come from the board's own part attributes.+- Engine 0.12.1-0.12.2: a route with no path says why at each end (`ends`, `avoidExempt`) and, when only an avoided pour blocks it, gives the path through it (`withoutAvoid`); assembly_check counts pads sharing one centre as one land and leaves pad-less parts out.+ ## 1.13.9 (2026-10-29) Circuit simulation with the ngspice Fusion bundles (the standard's #23). 312 verbs.
bridge.json+6−2@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.13.9",+ "version": "1.13.10", "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.",@@ -49,6 +49,7 @@ "fusion_aps_upload", "fusion_aps_versions", "fusion_assembly_bom",+ "fusion_assembly_check", "fusion_attach_3d_package", "fusion_autoroute", "fusion_bind_library_3d",@@ -194,6 +195,7 @@ "fusion_narrate", "fusion_net_classes", "fusion_net_connections",+ "fusion_net_lengths", "fusion_netlist", "fusion_new_electronics_from_eagle", "fusion_notify_owner",@@ -513,7 +515,9 @@ "sch_connect": 120, "sch_swap_part": 120, "export_spice": 120,- "simulate": 300+ "simulate": 300,+ "assembly_check": 150,+ "net_lengths": 120 }, "prefixes": [ {
contracts/describe.json+46@@ -549,6 +549,27 @@ "summary": "Structured, kit-aware MECHANICAL bill of materials for the active Design assembly (the counterpart to the electronics-only fusion_export_bom). Recurses organizational subassemblies but counts a physical part or a purchased kit/unit ONCE, so hardware modeled inside a kit (a '... with fasteners' bracket, a bearing/pulley) is NOT double-counted the way a flat allOccurrences walk counts it. Matches Fusion's Manage -> BOM quantities. Optionally writes a CSV.", "timeoutSeconds": 120 },+ {+ "example": {},+ "input": {+ "expectDocument": "required: the board's name",+ "lcscMissing": "the BOM lines without an LCSC number (default: read from the board's parts)",+ "panelMm": "[w, h] when it ships in a panel"+ },+ "longRunning": false,+ "name": "fusion_assembly_check",+ "output": {+ "board": "{parts, bottomSideParts, finestPitch, smallestChip, fiducials, ...}",+ "economic": "{ok, rules:[{rule, ok, detail}]}",+ "engine": "str",+ "linesWithoutLcsc": "[...]",+ "service": "economic | standard | null",+ "standard": "{ok, rules}"+ },+ "statusVerb": null,+ "summary": "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.",+ "timeoutSeconds": 150+ }, { "example": { "filePath": "C:/Users/<user>/adom-lib/ADS8588SIPM.fusion.lbr",@@ -17528,6 +17549,31 @@ "summary": "Every pad with its net as Designator.Pad and position, and per net its span and airline length (the minimum spanning tree over its pads, the shortest copper that could join them). net filters to one net.", "timeoutSeconds": 60 },+ {+ "example": {+ "nets": [+ "D_P",+ "D_N"+ ]+ },+ "input": {+ "expectDocument": "required: the board's name",+ "groups": "[{nets, toleranceMm?}]",+ "nets": "[net] (default every routed net)",+ "pairs": "[{name?, pos, neg}]"+ },+ "longRunning": false,+ "name": "fusion_net_lengths",+ "output": {+ "engine": "str",+ "groups": "[{nets, spreadMm, withinTolerance, longest}]",+ "nets": "[{net, lengthMm, byLayer, vias, segments}]",+ "pairs": "[{name, pos, neg, posMm, negMm, skewMm}]"+ },+ "statusVerb": null,+ "summary": "Routed length of every net (or the ones named) on the live board, by layer with its vias; the skew inside each differential pair (default the pairs the net names spell, as fusion_diff_pairs); and the spread of matched-length groups against a tolerance (the standard's net_lengths): the shared checks engine.",+ "timeoutSeconds": 120+ }, { "example": { "expectDocument": "fusion-routing"
dashboard/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "fusion-dashboard"-version = "1.13.9"+version = "1.13.10" dependencies = [ "serde_json", ]
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.13.9"+version = "1.13.10" edition = "2021" [dependencies]
describe.py+8@@ -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_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"},+ 150, None, False, {}), ('fusion_estimate_cost', "The board's numbers a fab quote needs (size, layers, thickness, smallest track, gap and drill, holes, SMD and through-hole parts and joints, unique parts) and a rough price table at the quantities asked, every assumption stated and the price source named with the date it was read: the shared checks engine on the live board.", {"expectDocument": "required: the board's name", "quantities": "[5, 10, 30, 100] (default)", "assembly": "bool, default false: add an assembly estimate", "basicParts": "[ref] parts on the fab's basic list"}, {"board": "{sizeMm, layers, thicknessMm, minTrackMm, minGapMm, minDrillMm, holes, smdParts, thtParts, ...}", "prices": "[{quantity, board, assembly, total, currency}]", "assumptions": "[str]", "source": "{name, url, read}", "engine": "str"},@@ -274,6 +278,10 @@ _T = [ {"expectDocument": "required: the board's name", "name": "one pair by its name (omit to list)"}, {"count": "int", "pairs": "[{name, pos, neg, by}]"}, 60, None, False, {}),+ ('fusion_net_lengths', "Routed length of every net (or the ones named) on the live board, by layer with its vias; the skew inside each differential pair (default the pairs the net names spell, as fusion_diff_pairs); and the spread of matched-length groups against a tolerance (the standard's net_lengths): the shared checks engine.",+ {"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_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"},
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.9"+DASH_VERSION="1.13.10" 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.9",+ "version": "1.13.10", "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.9",+ "version": "1.13.10", "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.11.0"+version = "0.12.2" dependencies = [ "roxmltree", "serde_json",@@ -19,7 +19,7 @@ dependencies = [ [[package]] name = "adom-fusion-bridge"-version = "1.13.9"+version = "1.13.10" dependencies = [ "adom-eda-checks", "base64",@@ -299,7 +299,7 @@ dependencies = [ [[package]] name = "fusion-core"-version = "1.13.9"+version = "1.13.10" dependencies = [ "serde", "serde_json",@@ -310,7 +310,7 @@ dependencies = [ [[package]] name = "fusion-lbr"-version = "1.13.9"+version = "1.13.10" dependencies = [ "regex", "roxmltree",@@ -322,14 +322,14 @@ dependencies = [ [[package]] name = "fusion-os-mac"-version = "1.13.9"+version = "1.13.10" dependencies = [ "fusion-core", ] [[package]] name = "fusion-os-win"-version = "1.13.9"+version = "1.13.10" 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.9"+version = "1.13.10" edition = "2021" license = "MIT"
rust/bridge/src/checks.rs+9@@ -47,6 +47,15 @@ pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Val if args[k].as_f64().map_or(true, |v| v < lim) && lim > 0.0 { bundle["args"][k] = json!(lim); } } }+ if cmd == "assembly_check" && args["lcscMissing"].is_null() {+ // the BOM lines with no LCSC number, from the board's own part attributes (fusion_board_bom)+ let bom = crate::routing::call("board_bom", args, pass);+ if let Some(lines) = bom["lines"].as_array() {+ let missing: Vec<Value> = lines.iter().filter(|l| l["lcsc"].as_str().map(|s| s.trim().is_empty()).unwrap_or(true))+ .map(|l| json!({"designators": l["designators"], "value": l["value"], "package": l["package"], "mpn": l["mpn"]})).collect();+ bundle["args"]["lcscMissing"] = json!(missing);+ }+ } if matches!(cmd, "height_check" | "placement_check" | "check_clearance" | "place_decoupling") { let bodies = crate::bodies::bodies(args, pass); if bodies["success"].as_bool() == Some(true) { bundle["bodies"] = bodies; }
rust/vendor/adom-eda-checks/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.11.0"+version = "0.12.2" 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+4−2@@ -11,6 +11,7 @@ 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 | | `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 | | `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`) |@@ -20,9 +21,10 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. | `current_capacity` | board, stack? | per net and layer, the narrowest copper against the width the current needs (IPC-2221), and the vias a layer change needs | | `height_check` | bodies, board? | each part's height on its side, parts over `maxTopMm`/`maxBottomMm` or under a lid region (`regions`), body collisions and near misses (`minGapMm`); `noBody` and `complete: false` when a part has no 3D body | | `place_decoupling` | board, bodies? | positions for an IC's decoupling caps (`ic`, `caps?`, `gapMm` 0.3): each just outside the IC's box on the side of the power pin it serves, turned so its power pad faces the pin and level with it, sliding along when the spot is taken (by a part on the same side, or a through-hole pad from the other). Without `caps` it takes caps across ground and a supply of the IC, leaving out (`leftOut`) a signal net with one cap (a crystal's load) and a cap nearer another IC's pin on that supply. Each cap's pads keep clear of the stubs and vias plan_fanout would give the IC's other pins (`leaveFanout`, default true), so the fanout still fits after. A cap it cannot place stays where it is and keeps its spot. `icPowerNets` lists the IC's supplies. `moveArgs` is move_parts' arguments; `validation` is placement_check on the moved board. From the KiCad bridge's place_tools |+| `net_lengths` | board | the routed length of each net (tracks and arcs, by layer, with its vias), the skew of each differential pair (`pairs`, else found by net name: `_P`/`_N`, `+`/`-`) and the spread of each matched-length group against its tolerance (`groups`). From the Altium bridge's lengths.rs | | `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`). The grid path is smoothed to straight and 45-degree segments where they keep the same clearance (`smooth: false` keeps it) |+| `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) | | `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 | @@ -96,7 +98,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.11.0"`), so a bridge reports the engine it runs without writing the version by hand.+(`"adom-eda-checks 0.12.2"`), 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.14.0-- Standard commit: 2421e66b3bf499df02e9771db51dd56103b5c2a8-- Crate version: 0.11.0+- Standard version: 0.15.2+- Standard commit: 275a6ef2460c3763589827cf828403aa75570fa1+- Crate version: 0.12.2 - 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/assembly.rsadded+158@@ -0,0 +1,158 @@+//! Ready for JLCPCB assembly? The board against both of JLCPCB's PCBA tiers as published+//! (jlcpcb.com/capabilities/pcb-assembly-capabilities, read 2026-10-26): size, thickness and layers, one or two sides,+//! the smallest package and the finest pin pitch, fiducials, and which parts have no LCSC number. It answers the tier+//! the board can be ordered in. From the Altium bridge's assembly.rs.+use serde_json::{json, Value};+use std::collections::BTreeMap;++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }++/// What the board offers the assembly line, from the board model. Pure.+pub fn facts(st: &Value) -> Value {+ let pads = st["pads"].as_array().cloned().unwrap_or_default();+ let comps = st["components"].as_array().cloned().unwrap_or_default();+ let mut by: BTreeMap<String, Vec<(f64, f64)>> = BTreeMap::new();+ for p in pads.iter().filter(|p| f(&p["drill"]) == 0.0) {+ if let Some(r) = p["ref"].as_str().filter(|r| !r.is_empty()) { by.entry(r.to_string()).or_default().push((f(&p["x"]), f(&p["y"]))); }+ }+ // the finest pitch: the closest pad centres within one part with 3 or more SMD pads (an IC, a BGA, a connector)+ let mut pitch: Option<(f64, String)> = None;+ // the smallest two-pad part: its pad centres (an 0402 is about 1.0 mm apart, an 0201 0.5, an 01005 0.35)+ let mut chip: Option<(f64, String)> = None;+ for (r, ps) in &by {+ let mut d = f64::MAX;+ // pads with one centre are one land (a MOSFET's drain pads drawn as one polygon, fusion-lane #24), not a 0 mm pitch+ for i in 0..ps.len() { for j in i + 1..ps.len() { let e = ((ps[i].0 - ps[j].0).powi(2) + (ps[i].1 - ps[j].1).powi(2)).sqrt(); if e > 0.01 { d = d.min(e); } } }+ if d == f64::MAX { continue; }+ if ps.len() >= 3 && pitch.as_ref().map(|p| d < p.0).unwrap_or(true) { pitch = Some((d, r.clone())); }+ if ps.len() == 2 && chip.as_ref().map(|p| d < p.0).unwrap_or(true) { chip = Some((d, r.clone())); }+ }+ let named = |c: &Value, pat: &[&str]| { let p = c["pattern"].as_str().unwrap_or("").to_ascii_uppercase(); pat.iter().any(|x| p.contains(x)) };+ let tiny: Vec<Value> = comps.iter().filter(|c| named(c, &["0201", "01005", "0603M", "0402M"])).map(|c| json!({"ref": c["ref"], "footprint": c["pattern"]})).collect();+ let fid = |s: &str| s.to_ascii_uppercase().starts_with("FID");+ let fiducials = comps.iter().filter(|c| c["ref"].as_str().map(fid).unwrap_or(false) || named(c, &["FIDUCIAL"])).count()+ + pads.iter().filter(|p| p["ref"].as_str().map(|r| r.is_empty()).unwrap_or(true) && p["name"].as_str().map(fid).unwrap_or(false)).count();+ // a part is a component with pads: a logo or an icon has none and is never placed (fusion-lane #24)+ let placed = |c: &Value| pads.iter().any(|p| p["ref"] == c["ref"]);+ let bottom: Vec<Value> = comps.iter().filter(|c| c["side"] == "bottom" && placed(c)).map(|c| c["ref"].clone()).collect();+ let tht = pads.iter().filter(|p| f(&p["drill"]) > 0.0 && p["ref"].as_str().map(|r| !r.is_empty()).unwrap_or(false))+ .filter_map(|p| p["ref"].as_str().map(String::from)).collect::<std::collections::BTreeSet<_>>();+ json!({"parts": comps.iter().filter(|c| placed(c)).count(), "bottomSideParts": bottom, "finestPitch": pitch.map(|(d, r)| json!({"mm": r3(d), "ref": r})),+ "smallestChip": chip.map(|(d, r)| json!({"padCentresMm": r3(d), "ref": r})), "tinyFootprints": tiny, "fiducials": fiducials,+ "throughHoleParts": tht.into_iter().collect::<Vec<_>>()})+}++fn rule(name: &str, ok: Option<bool>, detail: String) -> Value { json!({"rule": name, "ok": ok, "detail": detail}) }++/// The board against one tier ("economic" or "standard"). `size` is [w, h] mm (the board, or the panel it ships in),+/// `lcsc_missing` the purchased lines with no LCSC number (None: not checked). Pure.+pub fn tier(name: &str, fa: &Value, size: (f64, f64), thick: Option<f64>, layers: Option<i64>, lcsc_missing: Option<usize>, panel: bool) -> Value {+ let eco = name == "economic";+ let mut rs = Vec::new();+ let (w, h) = (size.0.min(size.1), size.0.max(size.1));+ let (lo, hi): ((f64, f64), (f64, f64)) = match (eco, panel) { (true, false) => ((10.0, 10.0), (470.0, 500.0)), (true, true) => ((10.0, 10.0), (250.0, 250.0)), (false, false) => ((70.0, 70.0), (460.0, 500.0)), (false, true) => ((70.0, 70.0), (250.0, 250.0)) };+ let ok = w >= lo.0 && h >= lo.1 && w <= hi.0.min(hi.1) && h <= hi.0.max(hi.1);+ rs.push(rule(&format!("{} {} x {} to {} x {} mm", if panel { "panel" } else { "board" }, lo.0, lo.1, hi.0, hi.1), Some(ok), format!("{} x {} mm{}", r3(size.0), r3(size.1), if !ok && !eco && !panel { ": a panel (panelize) can bring it to 70 x 70" } else { "" })));+ if eco {+ rs.push(match thick { Some(t) => rule("board 0.8 to 1.6 mm thick", Some((0.8 - 1e-6..=1.6 + 1e-6).contains(&t)), format!("{} mm", r3(t))), None => rule("board 0.8 to 1.6 mm thick", None, "thickness not read: pass the stack".into()) });+ rs.push(match layers { Some(n) => rule("2, 4 or 6 layers", Some([2, 4, 6].contains(&n)), format!("{n} layers")), None => rule("2, 4 or 6 layers", None, "layers not read: pass the stack".into()) });+ let bot = fa["bottomSideParts"].as_array().map(|a| a.len()).unwrap_or(0);+ rs.push(rule("parts on one side only", Some(bot == 0), if bot == 0 { "all on top".into() } else { format!("{bot} part(s) on the bottom: {}", fa["bottomSideParts"]) }));+ }+ let (chip_min, pitch_min) = if eco { (0.8, 0.4) } else { (0.3, 0.35) };+ let tiny = fa["tinyFootprints"].as_array().map(|a| a.len()).unwrap_or(0);+ let chip = fa["smallestChip"]["padCentresMm"].as_f64();+ let chip_ok = (eco && tiny == 0 || !eco) && chip.map(|c| c >= chip_min).unwrap_or(true);+ rs.push(rule(if eco { "smallest package 0402" } else { "smallest package 01005" }, Some(chip_ok),+ format!("smallest two-pad part {} (pad centres {} mm){}", fa["smallestChip"]["ref"].as_str().unwrap_or("-"), chip.map(r3).unwrap_or(0.0), if tiny > 0 { format!("; footprints named under 0402: {}", fa["tinyFootprints"]) } else { String::new() })));+ let pitch = fa["finestPitch"]["mm"].as_f64();+ rs.push(rule(&format!("IC pin pitch {pitch_min} mm or more (BGA {} mm)", if eco { 0.5 } else { 0.3 }), Some(pitch.map(|p| p + 1e-6 >= pitch_min).unwrap_or(true)),+ format!("finest {} mm on {}", pitch.map(r3).unwrap_or(0.0), fa["finestPitch"]["ref"].as_str().unwrap_or("-"))));+ if !eco {+ let n = fa["fiducials"].as_u64().unwrap_or(0);+ rs.push(rule("fiducials and edge rails", Some(n >= 2 || panel), if panel { "on the panel rails (panelize adds three)".into() } else { format!("{n} fiducial(s) on the board: add 3 (board_features fiducials) or ship it in a panel with rails") }));+ }+ rs.push(match lcsc_missing { Some(0) => rule("every purchased part has an LCSC number", Some(true), "all lines".into()),+ Some(n) => rule("every purchased part has an LCSC number", Some(false), format!("{n} line(s) without one: find them (adom-parts-search, adom-jlcpcb) or consign them")),+ None => rule("every purchased part has an LCSC number", None, "not checked: pass lcscMissing from the bridge's bom".into()) });+ let ok = rs.iter().all(|r| r["ok"] != json!(false));+ json!({"tier": name, "ok": ok, "rules": rs})+}++/// assembly_check: input {board, stack?, args: {panelMm?: [w, h], lcscMissing?: count or the BOM lines without one}}.+pub fn assembly_check(input: &Value) -> Value {+ let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+ let args = &input["args"];+ let Some((x0, y0, x1, y1)) = crate::geom::outline_bbox(&st) else { return crate::input::refuse("no_outline", "the board has no outline") };+ let panel = args["panelMm"].as_array().filter(|a| a.len() == 2).map(|a| (f(&a[0]), f(&a[1])));+ let size = panel.unwrap_or((x1 - x0, y1 - y0));+ let stack = crate::input::stack(input);+ let thick = stack.as_ref().and_then(|s| s["totalUm"].as_f64()).map(|u| u / 1000.0);+ let layers = stack.as_ref().and_then(|s| s["copperLayers"].as_i64());+ let (lcsc_missing, lcsc_lines) = match &args["lcscMissing"] {+ Value::Array(a) => (Some(a.len()), Value::Array(a.clone())),+ v if v.as_u64().is_some() => (v.as_u64().map(|n| n as usize), Value::Null),+ _ => (None, Value::Null),+ };+ let fa = facts(&st);+ let eco = tier("economic", &fa, size, thick, layers, lcsc_missing, panel.is_some());+ let std_ = tier("standard", &fa, size, thick, layers, lcsc_missing, panel.is_some());+ let service = if eco["ok"] == json!(true) { json!("economic") } else if std_["ok"] == json!(true) { json!("standard") } else { Value::Null };+ json!({"success": true, "fab": {"name": "JLCPCB", "source": "https://jlcpcb.com/capabilities/pcb-assembly-capabilities", "read": "2026-10-26"},+ "service": service, "economic": eco, "standard": std_, "board": fa, "linesWithoutLcsc": lcsc_lines,+ "_hint": "service is the cheapest JLCPCB assembly tier every rule allows (null: fix what the rules mark false). Rotation: JLCPCB's part orientation can differ from the tool's for some packages; check every part in JLCPCB's placement preview after uploading the BOM and pick-and-place."})+}++#[cfg(test)]+mod tests {+ use super::*;++ fn board() -> Value {+ json!({"components": [{"ref": "U1", "side": "top", "pattern": "QFN-20"}, {"ref": "C1", "side": "top", "pattern": "C0402"}, {"ref": "R9", "side": "bottom", "pattern": "R0603"}],+ "pads": [{"ref": "U1", "name": "1", "x": 0.0, "y": 0.0, "drill": 0.0}, {"ref": "U1", "name": "2", "x": 0.5, "y": 0.0, "drill": 0.0}, {"ref": "U1", "name": "3", "x": 1.0, "y": 0.0, "drill": 0.0},+ {"ref": "C1", "name": "1", "x": 5.0, "y": 0.0, "drill": 0.0}, {"ref": "C1", "name": "2", "x": 6.0, "y": 0.0, "drill": 0.0},+ {"ref": "R9", "name": "1", "x": 9.0, "y": 0.0, "drill": 0.0}, {"ref": "R9", "name": "2", "x": 10.5, "y": 0.0, "drill": 0.0}],+ "outline": [[0, 0], [40, 0], [40, 20], [0, 20]]})+ }++ #[test]+ fn facts_find_pitch_the_smallest_chip_and_the_bottom_side() {+ let fa = facts(&board());+ assert_eq!(fa["finestPitch"], json!({"mm": 0.5, "ref": "U1"}));+ assert_eq!(fa["smallestChip"], json!({"padCentresMm": 1.0, "ref": "C1"}));+ assert_eq!(fa["bottomSideParts"], json!(["R9"]));+ // fusion-lane #24: pads sharing one centre are one land, and a pad-less icon on the bottom is no part+ let mut b = board();+ b["components"].as_array_mut().unwrap().push(json!({"ref": "Q1", "side": "top", "pattern": "PowerPAK"}));+ b["components"].as_array_mut().unwrap().push(json!({"ref": "LOGO1", "side": "bottom", "pattern": "ICON"}));+ for k in 5..=8 { b["pads"].as_array_mut().unwrap().push(json!({"ref": "Q1", "name": k.to_string(), "x": 20.0, "y": 0.0, "drill": 0.0})); }+ b["pads"].as_array_mut().unwrap().push(json!({"ref": "Q1", "name": "1", "x": 20.0, "y": 1.27, "drill": 0.0}));+ let fa = facts(&b);+ assert_eq!(fa["finestPitch"], json!({"mm": 0.5, "ref": "U1"}));+ assert_eq!((fa["bottomSideParts"].clone(), fa["parts"].clone()), (json!(["R9"]), json!(4)));+ }++ #[test]+ fn a_small_two_sided_board_needs_standard_in_a_panel() {+ let fa = facts(&board());+ let eco = tier("economic", &fa, (40.0, 20.0), Some(1.6), Some(2), Some(0), false);+ assert_eq!(eco["ok"], json!(false)); // R9 on the bottom+ let std_ = tier("standard", &fa, (40.0, 20.0), Some(1.6), Some(2), Some(0), false);+ assert_eq!(std_["ok"], json!(false)); // under 70 x 70 and no fiducials+ let std_panel = tier("standard", &fa, (90.0, 80.0), Some(1.6), Some(2), Some(0), true);+ assert_eq!(std_panel["ok"], json!(true));+ assert_eq!(tier("economic", &fa, (40.0, 20.0), Some(0.4), Some(2), None, false)["rules"][1]["ok"], json!(false));+ }++ #[test]+ fn the_verb_reads_the_stack_and_the_bridges_lcsc_lines() {+ let r = assembly_check(&json!({"board": board(), "stack": {"totalUm": 1600, "copperLayers": 2}, "args": {"panelMm": [90, 80], "lcscMissing": [{"comment": "10k", "designators": "R9"}]}}));+ assert_eq!(r["service"], Value::Null, "{r}");+ assert_eq!(r["linesWithoutLcsc"][0]["designators"], "R9");+ let lcsc = r["standard"]["rules"].as_array().unwrap().last().unwrap().clone();+ assert_eq!(lcsc["ok"], json!(false));+ assert_eq!(assembly_check(&json!({"board": board(), "stack": {"totalUm": 1600, "copperLayers": 2}, "args": {"panelMm": [90, 80], "lcscMissing": 0}}))["service"], "standard");+ assert_eq!(assembly_check(&json!({"board": {"pads": []}, "args": {}}))["errorCode"], "no_outline");+ }+}
rust/vendor/adom-eda-checks/src/lengths.rsadded+135@@ -0,0 +1,135 @@+//! Routed length per net from the board model (tracks and arcs, per layer, with the vias), the skew inside each+//! differential pair, and the spread of a matched-length group against its tolerance. From the Altium bridge's lengths.rs.+use serde_json::{json, Map, Value};+use std::collections::BTreeMap;++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }++/// Per net: total routed length (mm), length by layer, via count and segment count. Pure, for the tests.+pub fn lengths(state: &Value) -> BTreeMap<String, Value> {+ let mut acc: BTreeMap<String, (f64, BTreeMap<String, f64>, usize, usize)> = BTreeMap::new();+ let mut add = |net: &str, layer: &str, len: f64| {+ if net.is_empty() { return; }+ let e = acc.entry(net.to_string()).or_default();+ e.0 += len;+ *e.1.entry(layer.to_string()).or_insert(0.0) += len;+ e.3 += 1;+ };+ for t in state["tracks"].as_array().cloned().unwrap_or_default() {+ let len = ((f(&t["end"]["x"]) - f(&t["start"]["x"])).powi(2) + (f(&t["end"]["y"]) - f(&t["start"]["y"])).powi(2)).sqrt();+ add(t["net"].as_str().unwrap_or(""), t["layer"].as_str().unwrap_or("?"), len);+ }+ for a in state["arcs"].as_array().cloned().unwrap_or_default() {+ let mut span = f(&a["endAngle"]) - f(&a["startAngle"]);+ if span <= 0.0 { span += 360.0; }+ add(a["net"].as_str().unwrap_or(""), a["layer"].as_str().unwrap_or("?"), f(&a["radius"]) * span.to_radians());+ }+ for v in state["vias"].as_array().cloned().unwrap_or_default() {+ if let Some(n) = v["net"].as_str().filter(|n| !n.is_empty()) { acc.entry(n.to_string()).or_default().2 += 1; }+ }+ acc.into_iter().map(|(n, (len, by, vias, segs))| {+ let by: Map<String, Value> = by.into_iter().map(|(k, v)| (k, json!(r3(v)))).collect();+ (n.clone(), json!({"net": n, "lengthMm": r3(len), "byLayer": by, "vias": vias, "segments": segs}))+ }).collect()+}++fn len_of(l: &BTreeMap<String, Value>, net: &str) -> f64 {+ l.iter().find(|(k, _)| k.eq_ignore_ascii_case(net)).map(|(_, v)| f(&v["lengthMm"])).unwrap_or(0.0)+}++/// Skew of each pair (pos minus neg). Pure, for the tests.+pub fn pair_skew(l: &BTreeMap<String, Value>, pairs: &[Value]) -> Vec<Value> {+ pairs.iter().map(|p| {+ let (pos, neg) = (p["pos"].as_str().unwrap_or(""), p["neg"].as_str().unwrap_or(""));+ let (a, b) = (len_of(l, pos), len_of(l, neg));+ json!({"name": p.get("name").cloned().unwrap_or(json!(format!("{pos}/{neg}"))), "pos": pos, "neg": neg, "posMm": r3(a), "negMm": r3(b), "skewMm": r3(a - b)})+ }).collect()+}++/// A matched-length group: its shortest and longest net and whether the spread is within the tolerance. Pure.+pub fn group(l: &BTreeMap<String, Value>, nets: &[String], tol: f64) -> Value {+ let vals: Vec<(String, f64)> = nets.iter().map(|n| (n.clone(), len_of(l, n))).collect();+ let (min, max) = vals.iter().fold((f64::MAX, f64::MIN), |a, v| (a.0.min(v.1), a.1.max(v.1)));+ let spread = if vals.is_empty() { 0.0 } else { max - min };+ let longest = vals.iter().max_by(|a, b| a.1.partial_cmp(&b.1).unwrap()).map(|v| v.0.clone());+ json!({"nets": vals.iter().map(|(n, v)| json!({"net": n, "lengthMm": r3(*v), "shortByMm": r3(max - v)})).collect::<Vec<_>>(),+ "minMm": r3(if vals.is_empty() { 0.0 } else { min }), "maxMm": r3(if vals.is_empty() { 0.0 } else { max }), "spreadMm": r3(spread),+ "toleranceMm": tol, "withinTolerance": spread <= tol + 1e-9, "longest": longest})+}++/// Differential pairs by their net names: X_P/X_N, XP/XN (after a separator), X+/X-. Pure.+pub fn pairs_by_name(l: &BTreeMap<String, Value>) -> Vec<Value> {+ let mut out = Vec::new();+ for n in l.keys() {+ let u = n.to_uppercase();+ for (p, q) in [("_P", "_N"), ("+", "-"), ("_DP", "_DM"), ("_D+", "_D-")] {+ if let Some(base) = u.strip_suffix(p) {+ let want = format!("{base}{q}");+ if let Some(neg) = l.keys().find(|k| k.to_uppercase() == want) {+ out.push(json!({"name": &n[..base.len()], "pos": n, "neg": neg}));+ }+ }+ }+ }+ out+}++/// net_lengths: input {board, args: {nets?, pairs?: [{name?, pos, neg}], groups?: [{nets, toleranceMm}]}}. Without+/// pairs, the pairs come from the net names (pairsFrom says which).+pub fn net_lengths(input: &Value) -> Value {+ let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+ let args = &input["args"];+ let all = lengths(&st);+ let want: Vec<String> = args.get("nets").and_then(|n| n.as_array()).map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();+ let nets: Vec<Value> = if want.is_empty() { all.values().cloned().collect() } else {+ want.iter().map(|n| all.iter().find(|(k, _)| k.eq_ignore_ascii_case(n)).map(|(_, v)| v.clone()).unwrap_or(json!({"net": n, "lengthMm": 0.0, "unrouted": true}))).collect()+ };+ let (pairs, from): (Vec<Value>, &str) = match args.get("pairs").and_then(|p| p.as_array()) {+ Some(p) => (p.clone(), "args"),+ None => (pairs_by_name(&all), "net names (_P/_N, +/-): pass pairs for the board's own differential pairs"),+ };+ let groups: Vec<Value> = args.get("groups").and_then(|g| g.as_array()).cloned().unwrap_or_default().iter().map(|g| {+ let n: Vec<String> = g["nets"].as_array().cloned().unwrap_or_default().iter().filter_map(|x| x.as_str().map(str::to_string)).collect();+ group(&all, &n, g.get("toleranceMm").and_then(|t| t.as_f64()).unwrap_or(0.5))+ }).collect();+ let out_of_tol = groups.iter().filter(|g| g["withinTolerance"] == json!(false)).count();+ json!({"success": true, "units": "mm", "nets": nets, "pairs": pair_skew(&all, &pairs), "pairsFrom": from, "groups": groups, "groupsOutOfTolerance": out_of_tol,+ "_hint": "Lengths are the routed copper of each net (tracks and arcs; vias counted, not added). A net with branches sums all of them, so read a multi-pin net as total copper, not pin to pin. Lengthen the short nets of a group (length_tune, or route_net with a detour) until spreadMm is within toleranceMm; add a matched-length rule with add_rule."})+}++#[cfg(test)]+mod tests {+ use super::*;++ fn board() -> Value {+ json!({"tracks": [+ {"start": {"x": 0.0, "y": 0.0}, "end": {"x": 10.0, "y": 0.0}, "layer": "F.Cu", "net": "USB_P"},+ {"start": {"x": 10.0, "y": 0.0}, "end": {"x": 10.0, "y": 5.0}, "layer": "B.Cu", "net": "USB_P"},+ {"start": {"x": 0.0, "y": 1.0}, "end": {"x": 12.0, "y": 1.0}, "layer": "F.Cu", "net": "USB_N"}],+ "arcs": [{"center": {"x": 0.0, "y": 0.0}, "radius": 2.0, "startAngle": 0.0, "endAngle": 90.0, "layer": "F.Cu", "net": "CLK"}],+ "vias": [{"position": {"x": 10.0, "y": 0.0}, "net": "USB_P"}]})+ }++ #[test]+ fn lengths_add_tracks_and_arcs_per_layer() {+ let l = lengths(&board());+ assert_eq!(l["USB_P"]["lengthMm"], json!(15.0));+ assert_eq!(l["USB_P"]["byLayer"]["B.Cu"], json!(5.0));+ assert_eq!(l["USB_P"]["vias"], json!(1));+ assert_eq!(l["CLK"]["lengthMm"], json!(3.142));+ }++ #[test]+ fn pairs_report_skew_and_groups_their_spread() {+ let l = lengths(&board());+ let s = pair_skew(&l, &[json!({"name": "USB", "pos": "USB_P", "neg": "USB_N"})]);+ assert_eq!(s[0]["skewMm"], json!(3.0));+ let g = group(&l, &["USB_P".into(), "usb_n".into()], 2.0);+ assert_eq!((g["spreadMm"].clone(), g["withinTolerance"].clone(), g["longest"].clone()), (json!(3.0), json!(false), json!("USB_P")));+ assert_eq!(g["nets"][1]["shortByMm"], json!(3.0));+ // the verb finds USB_P/USB_N by name when no pairs are given+ let r = net_lengths(&json!({"board": board(), "args": {}}));+ assert_eq!((r["pairs"][0]["name"].clone(), r["pairs"][0]["skewMm"].clone()), (json!("USB"), json!(3.0)), "{r}");+ }+}
rust/vendor/adom-eda-checks/src/lib.rs+5−1@@ -7,6 +7,7 @@ //! Every check takes one input bundle, `{board, stack?, pours?, rules?, silk?, fab?, bodies?, prices?, sheetParts?, netlist?, args?}` (see `input`). use serde_json::Value; +pub mod assembly; pub mod bodies; pub mod coppermap; pub mod cost;@@ -16,6 +17,7 @@ pub mod features; pub mod geom; pub mod impedance; pub mod input;+pub mod lengths; pub mod placement; pub mod placetools; pub mod route;@@ -26,7 +28,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] = &["check_clearance", "copper_balance", "current_capacity", "estimate_cost", "fab_check", "height_check", "impedance", "place_decoupling", "placement_check", "plan_fanout", "plan_route", "return_path", "thermal_vias", "via_fence"];+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"]; /// Run one check or writer by its standard name. The reply carries `engine`. pub fn run(check: &str, input: &Value) -> Value {@@ -38,6 +40,7 @@ pub fn run(check: &str, input: &Value) -> Value { fn answer(check: &str, input: &Value) -> Value { if let Some(out) = writers::run(check, input) { return out; } match check {+ "assembly_check" => assembly::assembly_check(input), "check_clearance" => placetools::check_clearance(input), "copper_balance" => coppermap::copper_balance(input), "current_capacity" => current::current_capacity(input),@@ -45,6 +48,7 @@ fn answer(check: &str, input: &Value) -> Value { "fab_check" => fab::fab_check(input), "height_check" => bodies::height_check(input), "impedance" => impedance::impedance(input),+ "net_lengths" => lengths::net_lengths(input), "place_decoupling" => placetools::place_decoupling(input), "plan_fanout" => placetools::plan_fanout(input), "placement_check" => placement::placement_check(input),
rust/vendor/adom-eda-checks/src/route.rs+37−1@@ -280,7 +280,31 @@ pub fn plan_route(input: &Value) -> Value { let revision = st["revision"].clone(); let Some(goal_node) = found else { let why = if seen.len() >= max_nodes { "no path within the search limit (maxNodes): try a coarser gridMm or a higher maxNodes" } else { "no legal path on this grid: other copper, the outline or the clearance blocks every way. A fine-pitch pad (0.5 mm pitch) usually needs gridMm 0.1 to escape; more layers give more room" };- return json!({"success": true, "found": false, "reason": why, "expandedNodes": seen.len(), "revision": revision});+ // what an avoided pour does at each end: a pour the end pad sits in is exempt; one that only touches the pad's+ // first step out still walls it in (fusion-lane #21: avoidPours ["VBAT"] found no path from SW1.2)+ let near_ends: Vec<Value> = ends.iter().map(|p| {+ let c = (f(&p["x"]), f(&p["y"]));+ let reach = grid + width / 2.0 + clearance;+ let touching: Vec<Value> = (0..pours.len()).filter(|&i| avoids(&pours[i].0) && pad_layers(p, &copper).contains(&pours[i].1) && pours[i].2.dist(c, c) <= reach)+ .map(|i| json!({"net": pours[i].0, "layer": pours[i].1, "exempt": held.contains(&i)})).collect();+ json!({"pad": key(p), "avoidedPoursHere": touching})+ }).collect();+ let walled = near_ends.iter().any(|e| e["avoidedPoursHere"].as_array().map(|a| a.iter().any(|x| x["exempt"] == json!(false))).unwrap_or(false));+ // when keeping out of the pours is what fails, say so: plan again through them (fusion-lane #21: VBAT's pour wraps+ // SW1.2 in a notch it never touches)+ let mut through = Value::Null;+ if avoid_nets.is_some() && seen.len() < max_nodes {+ let mut again = input.clone();+ again["args"].as_object_mut().map(|o| o.remove("avoidPours"));+ let r = plan_route(&again);+ if r["found"] == json!(true) { through = json!({"found": true, "lengthMm": r["lengthMm"], "vias": r["vias"], "crossesPours": r["crossesPours"]}); }+ }+ let why = if avoid_nets.is_some() && walled { "no legal path: an avoided pour touches an end pad without holding it (avoidedPoursHere), so the route cannot leave the pad without entering it. Try pourCost instead of avoidPours, or drop that net from avoidPours".to_string() }+ else if !through.is_null() { format!("no path keeps out of the avoided pours: every way between the pads crosses one (a path through them exists: withoutAvoid). Use pourCost to go round them where it can, or drop a net from avoidPours") }+ else { why.to_string() };+ return json!({"success": true, "found": false, "reason": why, "expandedNodes": seen.len(), "revision": revision, "ends": near_ends, "withoutAvoid": through,+ "planner": {"algorithm": "bounded orthogonal A*", "widthFrom": width_from, "viaFrom": via_from, "gridMm": grid, "layers": layers, "viasCheckedOn": copper, "clearanceMm": clearance, "edgeClearanceMm": edge,+ "avoidPours": args["avoidPours"].clone(), "avoidExempt": exempt, "pourCost": pour_cost, "expandedNodes": seen.len()}}); }; let mut path = vec![goal_node]; while let Some(p) = parent.get(path.last().unwrap()) { path.push(*p); }@@ -466,6 +490,18 @@ mod tests { assert_eq!(held["found"], json!(true), "{held}"); assert_eq!(held["planner"]["avoidExempt"][0]["net"], "GND"); assert_eq!(held["planner"]["pourCost"], json!(0.0));+ // a pour that touches U1.1 without holding it walls the pad in: the no-path reply says so, with its planner block+ let mut touch = board(json!([]));+ touch["polygons"] = json!([{"net": "GND", "layer": "F.Cu", "points": [[2.6, 0.0], [12.0, 0.0], [12.0, 10.0], [2.6, 10.0]]}, {"net": "GND", "layer": "B.Cu", "points": [[2.6, 0.0], [12.0, 0.0], [12.0, 10.0], [2.6, 10.0]]}]);+ let walled = plan_route(&json!({"board": touch, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "avoidPours": ["GND"]}}));+ assert_eq!(walled["found"], json!(false), "{walled}");+ assert_eq!(walled["ends"][0]["avoidedPoursHere"][0], json!({"net": "GND", "layer": "F.Cu", "exempt": false}), "{walled}");+ assert!(walled["reason"].as_str().unwrap().contains("avoidedPoursHere"));+ assert_eq!(walled["planner"]["avoidPours"], json!(["GND"]));+ // a wall it never touches: the reply says a path exists through the pours+ let wall_only = plan_route(&json!({"board": wall, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "avoidPours": true}}));+ assert_eq!(wall_only["withoutAvoid"]["found"], json!(true), "{wall_only}");+ assert!(wall_only["reason"].as_str().unwrap().contains("every way between the pads crosses one")); // and with the cost a short way round a narrow pour beats crossing it let mut strip = board(json!([])); strip["polygons"] = json!([{"net": "GND", "layer": "F.Cu", "points": [[9.0, 2.0], [11.0, 2.0], [11.0, 10.0], [9.0, 10.0]]}]);
tests/run_verb_tests.py+3−1@@ -256,6 +256,8 @@ 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_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_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"):@@ -503,7 +505,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_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_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_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",