← Commit history
README.md+6−4
@@ -60,12 +60,12 @@ verb by verb: [docs/coverage.md](docs/coverage.md). <!-- coverage:start (tools/generate.py writes this) --> | Tier | Verbs | Altium | KiCad | Fusion 360 | |---|---|---|---|---|-| **core** | 93 | 93 | 90 | 79 |+| **core** | 93 | 93 | 90 | 82 | | **native** | 46 | 46 | 43 | 33 |-| **checks** | 37 | 33 | 35 | 19 |-| **app** | 62 | 60 | 36 | 37 |+| **checks** | 37 | 37 | 35 | 19 |+| **app** | 62 | 60 | 38 | 39 | | **platform** | 19 | 19 | 0 | 0 |-| standard (all but platform) | 238 | 232 | 204 | 168 |+| standard (all but platform) | 238 | 236 | 206 | 173 | <!-- coverage:end -->  The **checks** tier is where the other bridges are furthest behind, and it is the cheapest to close: those verbs are@@ -158,6 +158,8 @@ Decisions so far (proposals from the KiCad and Fusion bridges, 2026-10-27; issue  ## Status +Version 0.15.0: the engine (0.12.0) adds `assembly_check` (matching the Altium bridge's answers on its test boards) and+`net_lengths`, both from the Altium bridge. Altium 0.2.80 answers the four planners from the engine. Version 0.14.0: - The engine (0.11.0) writes IPC-2581 revision C (`export_ipc2581`, from the Fusion bridge's writer, #16). The Bluetooth   Sentinel, Altium's lane board and a board with an oval pad, an arc outline and a pour with a hole all validate against
docs/coverage.md+14−14
@@ -1,17 +1,17 @@ # Coverage: every standard verb on each bridge -Altium bridge 0.2.79 (the reference), KiCad bridge 1.0.77, Fusion 360 bridge 1.13.7. 56 verbs came from other bridges' proposals (tools/proposed/); they are marked *proposed*.+Altium bridge 0.2.80 (the reference), KiCad bridge 1.0.79, Fusion 360 bridge 1.13.9. 56 verbs came from other bridges' proposals (tools/proposed/); they are marked *proposed*. `exact` is the same job under the same or an equivalent name; `partial` does part of it or does it differently (`verbs/coverage.json` names what in `note`); `unsupported` answers `not_supported` for the whole job and is not counted; blank is missing. Generated by `tools/generate.py` from `verbs/coverage.json`; do not edit by hand.  | Tier | Verbs | Altium | KiCad | Fusion 360 | |---|---|---|---|---|-| **core** | 93 | 93 | 90 | 79 |+| **core** | 93 | 93 | 90 | 82 | | **native** | 46 | 46 | 43 | 33 |-| **checks** | 37 | 33 | 35 | 19 |-| **app** | 62 | 60 | 36 | 37 |+| **checks** | 37 | 37 | 35 | 19 |+| **app** | 62 | 60 | 38 | 39 | | **platform** | 19 | 19 | 0 | 0 |-| standard (all but platform) | 238 | 232 | 204 | 168 |+| standard (all but platform) | 238 | 236 | 206 | 173 |  ## core @@ -78,7 +78,7 @@ Read and edit the design. Every bridge provides these: they are how an AI sees a | `rules_by_kind` | The current board's ENABLED design rules grouped by rule kind (Clearance, Width, RoutingVias, ...), each group sorted by priority. Shaped from one... | `altium_rules_by_kind` | `kicad_board_rules` (partial) | `fusion_rules_by_kind` | | `sch_bus` *proposed* | Draw a bus as a polyline (one bus segment per pair of points), optionally with a bus label such as D[0..7] or a group {SDA SCL} at its first point. One undo... | `altium_sch_bus` | `kicad_sch_bus` | `fusion_sch_bus` | | `sch_bus_entry` *proposed* | Add 45-degree bus entries: the short diagonal that joins a member wire's end to a bus. One undo step. | `altium_sch_bus_entry` | `kicad_sch_bus_entry` | `fusion_sch_bus_entry` |-| `sch_connect` | Connect pins of placed parts: a wire stub from each pin's outer end plus a net label (kind label), a power port (kind power: GND-like nets get a ground... | `altium_sch_connect` | `kicad_sch_connect_to_net / kicad_sch_no_connect / kicad_sch_wire / kicad_sch_label` |  |+| `sch_connect` | Connect pins of placed parts: a wire stub from each pin's outer end plus a net label (kind label), a power port (kind power: GND-like nets get a ground... | `altium_sch_connect` | `kicad_sch_connect_to_net / kicad_sch_no_connect / kicad_sch_wire / kicad_sch_label` | `fusion_sch_connect` (partial) | | `sch_connect_pins` *proposed* | Wire pins to pins on a sheet by Ref.Pin: an L-shaped route (one horizontal and one vertical leg) for each pair, with a junction dot wherever a route meets... | `altium_sch_connect_pins` | `kicad_sch_connect_pins` | `fusion_sch_connect_pins` | | `sch_connect_to_bus` *proposed* | Fan pins onto a bus: for each pin a wire running to the bus, a 45-degree bus entry onto it and a member label (D0, D1 ...) on the wire. One undo step. | `altium_sch_connect_to_bus` | `kicad_sch_connect_to_bus` | `fusion_sch_connect_to_bus` | | `sch_create` *proposed* | Create an empty schematic sheet with a paper size and title block; optionally add it to a project as a child sheet of the root. It places no parts or nets... | `altium_sch_sheet_symbol` (partial) | `kicad_sch_create` | `fusion_sch_create` (partial) |@@ -92,12 +92,12 @@ Read and edit the design. Every bridge provides these: they are how an AI sees a | `sch_move_region` *proposed* | Move everything inside a box by (dx, dy): parts, wire ends inside the box (wires crossing its edge stretch), labels, junctions, no-connect markers, texts,... | `altium_sch_move_region` | `kicad_sch_move_region` | `fusion_sch_move_region` | | `sch_page` *proposed* | Read or set a sheet's page: paper size, orientation and title block (title, date, revision, company, comment lines). | `altium_sch_page` | `kicad_sch_page` | `fusion_sch_page` (partial) | | `sch_pins` *proposed* | Every pin of the named parts on a sheet: its absolute position (rotation and mirror applied), number, name, electrical type, the side it leaves the body,... | `altium_read_sheet_file` (partial) | `kicad_sch_pins` | `fusion_sch_pins` |-| `sch_place_part` | Place a component from a .SchLib onto an EXISTING sheet at a position, with its library parameters (MPN, Manufacturer, distributor numbers) and its... | `altium_sch_place_part` | `kicad_sch_place_symbol` |  |+| `sch_place_part` | Place a component from a .SchLib onto an EXISTING sheet at a position, with its library parameters (MPN, Manufacturer, distributor numbers) and its... | `altium_sch_place_part` | `kicad_sch_place_symbol` | `fusion_sch_place_part` (partial) | | `sch_reset_fields` *proposed* | Put parts' designator and value text (or the named fields) back where the library symbol places them, through each part's rotation and mirror. One undo step. | `altium_sch_reset_fields` | `kicad_sch_reset_fields` | `fusion_sch_reset_fields` | | `sch_sheet_pins` *proposed* | A sheet symbol's pins: list, add, edit (rename, kind, move along an edge) or delete. import:true adds a pin for every hierarchical port inside the child... | `altium_sch_sheet_symbol` (partial) | `kicad_sch_sheet_pins` | `fusion_sch_sheet_pins` (partial) | | `sch_sheet_symbol` | Make a design hierarchical: a sheet symbol on the parent sheet for a child sheet, its entries joined to the parent's nets, and matching ports on the child.... | `altium_sch_sheet_symbol` | `kicad_sch_sheet` (partial) |  | | `sch_split_wire` *proposed* | Split the wire through a point into two wires there, with a junction dot at the cut, so a new branch can end on a wire end. One undo step. | `altium_sch_split_wire` | `kicad_sch_split_wire` | `fusion_sch_split_wire` (partial) |-| `sch_swap_part` | Replace a placed part with another library part (a different value, package or symbol) at the same place, designator and rotation, keeping its connections:... | `altium_sch_swap_part` | `kicad_sch_replace_symbol` (partial) |  |+| `sch_swap_part` | Replace a placed part with another library part (a different value, package or symbol) at the same place, designator and rotation, keeping its connections:... | `altium_sch_swap_part` | `kicad_sch_replace_symbol` (partial) | `fusion_sch_swap_part` (partial) | | `sch_text` *proposed* | Add free text to a sheet (a note, a title, a design comment) with size, angle, justification, bold and italic. Text has no electrical meaning. One undo step. | `altium_sch_text` | `kicad_sch_text` | `fusion_sch_text` | | `sch_trace_point` *proposed* | Everything electrically connected at a point on a sheet: the wires, labels, pins, junctions, sheet pins and no-connect markers there, and for each net they... | `altium_sch_trace_point` | `kicad_sch_trace_point` | `fusion_sch_trace_point` | | `schematic_data` | Components on a schematic sheet (filePath, else the CURRENT sheet) with their pins (number, name, electrical type), position and orientation. designator... | `altium_schematic_data` | `kicad_sch_components` (partial) | `fusion_schematic_data` (partial) |@@ -185,7 +185,7 @@ Engineering computed from the board model, not by the tool: one shared engine, s | `copper_balance` | How much of each copper layer is copper (tracks, pads, vias and what Altium actually poured, inside the outline; a plane counts full), and how far apart... | `altium_copper_balance` | `kicad_copper_balance` | `fusion_copper_balance` | | `cross_check` | Compare one schematic sheet's designators with a board's. Altium holds ONE current document, so the verb activates the board, reads it, then activates the... | `altium_cross_check` | `kicad_analyze_connections` (partial) | `fusion_cross_check` | | `current_capacity` | Will this trace carry the current? For each power net and its current: the narrowest track on every layer it is routed on against the width IPC-2221 needs... | `altium_current_capacity` | `kicad_current_capacity` | `fusion_current_capacity` |-| `estimate_cost` *proposed* | 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)... |  | `kicad_estimate_cost` | `fusion_estimate_cost` |+| `estimate_cost` *proposed* | 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)... | `altium_estimate_cost` | `kicad_estimate_cost` | `fusion_estimate_cost` | | `fab_check` | Before ordering: the board's fab-relevant minimums measured from the live board (narrowest track, smallest via drill, diameter and annular ring, smallest... | `altium_fab_check` | `kicad_fab_check` | `fusion_fab_check` | | `find_space` | Free spots on the board for a part near another (clear of every other part on that side, of that side's tracks and vias, and inside the outline), nearest... | `altium_find_space` | `kicad_find_space` |  | | `footprint_from_land_pattern` | Build a complete footprint from the land pattern a datasheet prints: pads numbered the standard way (chip, dual SOIC/TSSOP/DFN, SOT, quad QFP/QFN, BGA), an... | `altium_footprint_from_land_pattern` | `kicad_footprint_from_land_pattern` |  |@@ -196,10 +196,10 @@ Engineering computed from the board model, not by the tool: one shared engine, s | `length_tune` | Lengthen a routed net to a target length, or to match another net (a pair's skew, a DDR byte lane): a serpentine in its longest straight track that has room... | `altium_length_tune` | `kicad_length_tune` |  | | `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; and the spread of... | `altium_net_lengths` | `kicad_net_lengths` |  | | `pin_pad_check` | Do the footprints fit their symbols? Part by part, the symbol's pin numbers (from the sheet files) against the footprint's pad names (from the board): pins... | `altium_pin_pad_check` | `kicad_pin_pad_check` |  |-| `place_decoupling` *proposed* | Plan positions for decoupling capacitors beside an IC's power pins (each cap just outside the courtyard, its power pad facing the pin), answered as... |  | `kicad_place_decoupling` | `fusion_place_decoupling` |+| `place_decoupling` *proposed* | Plan positions for decoupling capacitors beside an IC's power pins (each cap just outside the courtyard, its power pad facing the pin), answered as... | `altium_place_decoupling` | `kicad_place_decoupling` | `fusion_place_decoupling` | | `placement_check` | Review the placement before routing: parts whose courtyards (pads and body, grown by a margin) overlap on the same side, parts off the board or over its... | `altium_placement_check` | `kicad_placement_check` | `fusion_placement_check` |-| `plan_fanout` *proposed* | Plan the fanout of a BGA or QFN/QFP: the pad pitch, dogbone or via-in-pad per pad, the escape layer, surface stubs for the outer ring and a via grid for an... |  | `kicad_plan_fanout` | `fusion_plan_fanout` |-| `plan_route` *proposed* | Plan a path for one net between two pads without changing the board: a grid search around the existing copper that keeps the clearance rules, on the allowed... |  | `kicad_plan_route` | `fusion_plan_route` |+| `plan_fanout` *proposed* | Plan the fanout of a BGA or QFN/QFP: the pad pitch, dogbone or via-in-pad per pad, the escape layer, surface stubs for the outer ring and a via grid for an... | `altium_plan_fanout` | `kicad_plan_fanout` | `fusion_plan_fanout` |+| `plan_route` *proposed* | Plan a path for one net between two pads without changing the board: a grid search around the existing copper that keeps the clearance rules, on the allowed... | `altium_plan_route` | `kicad_plan_route` | `fusion_plan_route` | | `read_sheet_file` | Read a schematic sheet straight from its .SchDoc file, without Altium (it need not be installed or running): every part with its designator, library... | `altium_read_sheet_file` | `kicad_read_sheet_file` | `fusion_read_sheet_file` | | `return_path` | Where signal tracks lose their reference: for every signal net, the length of track on each layer that runs over no pour or plane copper on either... | `altium_return_path` | `kicad_return_path` | `fusion_return_path` | | `review_board` | Design-review checks (14) over one board read; schematic checks with includeSchematic. | `altium_review_board` | `kicad_lint_board` (partial) |  |@@ -236,7 +236,7 @@ Driving the application itself (launch, licence, dialogs, windows, views). Each | `document_info` *proposed* | The active document: its name, what kind it is, whether it has unsaved changes, where it is saved and which version it is. Read only; opens nothing. | `altium_document_info` | `kicad_state` (partial) | `fusion_document_info` | | `ensure_licensed` | One call to get a usable Altium: launch if needed, then claim a seat (Use License via UIA). Optional escalations: sign out to force a seat release, or start... | `altium_ensure_licensed` |  |  | | `eval` | Run a DelphiScript snippet inside Altium (optional Var block, then Begin ... End;) on the CURRENT document, or on filePath opened first. | `altium_eval` |  |  |-| `export_spice` *proposed* | Write the design's SPICE netlist with the tool's own exporter, and say what it holds. |  |  |  |+| `export_spice` *proposed* | Write the design's SPICE netlist with the tool's own exporter, and say what it holds. |  | `kicad_export_spice` | `fusion_export_spice` | | `extension_ping` | Does the AdomBridge .NET extension answer? Sends AdomBridge:Ping through the -R lane and waits up to 30 s for its result file. | `altium_extension_ping` |  |  | | `foreground` | Bring Altium in front of the user - the sanctioned exception to the never-foreground rule. | `altium_foreground` |  |  | | `install` | Install Altium Designer on this machine, STAGED, with the rules in the verb (from 0.2.29). stage: preflight / get_installer / launch / wait / license, or... | `altium_install` | `kicad_upgrade` (partial) | `fusion_install_fusion` |@@ -272,7 +272,7 @@ Driving the application itself (launch, licence, dialogs, windows, views). Each | `show_symbol` | Open a SchLib member in the Symbol editor and capture a background proof. | `altium_show_symbol` | `kicad_show_symbol` | `fusion_show_symbol` | | `signin` | Report-only alias of altium_signin_status: it does NOT sign anyone in. Signing in to Altium is a browser flow only the human can complete. | `altium_signin` |  | `fusion_signin` | | `signin_status` | Account and Altium 365 workspace connection as Altium's own UI reports it: licence state from the title, and whether the workspace menu reads 'Not Connected'. | `altium_signin_status` |  | `fusion_signin_status` |-| `simulate` *proposed* | Run a circuit simulation of the design's schematic and return the signals as data: an operating point, a DC sweep, an AC sweep or a transient, with the nets... |  |  |  |+| `simulate` *proposed* | Run a circuit simulation of the design's schematic and return the signals as data: an operating point, a DC sweep, an AC sweep or a transient, with the nets... |  | `kicad_simulate` | `fusion_simulate` | | `state` | The whole picture in one call: licence state, windows, dialogs, a screenshot of every main/dialog window, and the agent's logical state (focused document,... | `altium_state` | `kicad_state` | `fusion_state` | | `status` | Is Altium running, licensed, and is the resident agent alive? | `altium_status` | `kicad_status` | `fusion_status` | | `uninstall` | Remove the bridge's own cache directory (agent workspaces, rendered agent scripts, the Altium 365 token file). Altium Designer and the user's designs are... | `altium_uninstall` | `kicad_uninstall` | `fusion_uninstall` |
engine/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-eda-checks"-version = "0.11.0"+version = "0.12.0" dependencies = [  "roxmltree",  "serde_json",
engine/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.11.0"+version = "0.12.0" 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"
engine/README.md+3−1
@@ -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). `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,6 +21,7 @@ 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) |@@ -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.0"`), so a bridge reports the engine it runs without writing the version by hand.  ## Tests 
engine/src/assembly.rsadded+145
@@ -0,0 +1,145 @@+//! 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;+        for i in 0..ps.len() { for j in i + 1..ps.len() { d = d.min(((ps[i].0 - ps[j].0).powi(2) + (ps[i].1 - ps[j].1).powi(2)).sqrt()); } }+        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();+    let bottom: Vec<Value> = comps.iter().filter(|c| c["side"] == "bottom").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.len(), "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"]));+    }++    #[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");+    }+}
engine/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}");+    }+}
engine/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),
engine/tests/fixtures.rs+6
@@ -64,3 +64,9 @@ fn height_check_matches_altium() { fn placement_check_matches_altium() {     same("placement_check", &["ok", "parts", "courtyardOverlaps", "offOrOverEdge", "onMountingHoles", "nearEdge", "marginMm", "edgeMm"]); }++#[test]+fn assembly_check_matches_altium() {+    same("assembly_check", &["service", "fab", "board", "economic.ok", "standard.ok", "economic.rules.0.ok", "economic.rules.1.ok", "economic.rules.2.ok",+                             "economic.rules.3.ok", "economic.rules.4.ok", "economic.rules.5.ok", "standard.rules.0.ok", "standard.rules.3.ok"]);+}
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "eda-bridge-standard",-  "version": "0.14.0",+  "version": "0.15.0",   "type": "skill",   "title": "EDA Bridge Standard: one way for an AI to drive every PCB tool",   "description": "The standard every Adom EDA bridge follows so one AI can drive Altium, KiCad, Fusion 360 and any future PCB tool the same way: shared verb names in tiers, the board model JSON, reply and refusal conventions, and a coverage table of each bridge against the Altium reference.",
verbs/coverage.json+51−15
@@ -1,13 +1,13 @@ {- "standard": "0.14.0",+ "standard": "0.15.0",  "reference": {   "bridge": "altium",-  "version": "0.2.79"+  "version": "0.2.80"  },  "proposed": 56,  "compared": {-  "kicad": "1.0.77",-  "fusion360": "1.13.7"+  "kicad": "1.0.79",+  "fusion360": "1.13.9"  },  "tiers": {   "core": "Read and edit the design. Every bridge provides these: they are how an AI sees and changes a board.",@@ -1017,7 +1017,11 @@     "verb": "kicad_sch_connect_to_net / kicad_sch_no_connect / kicad_sch_wire / kicad_sch_label",     "match": "exact"    },-   "fusion360": null+   "fusion360": {+    "verb": "fusion_sch_connect",+    "match": "partial",+    "note": "power is a net label (Fusion power symbols are library parts; nets join by name), port is an XREF label, and noerc is refused (Fusion has no No-ERC marker)"+   }   },   {    "name": "sch_connect_pins",@@ -1255,7 +1259,11 @@     "verb": "kicad_sch_place_symbol",     "match": "exact"    },-   "fusion360": null+   "fusion360": {+    "verb": "fusion_sch_place_part",+    "match": "partial",+    "note": "the library is one the design holds (by name; Fusion 2706 does not add from a local .lbr through USE) and a variant named with parentheses cannot be added from a text command; save is false by default (each Fusion save is a cloud version)"+   }   },   {    "name": "sch_reset_fields",@@ -1336,7 +1344,11 @@     "verb": "kicad_sch_replace_symbol",     "match": "partial"    },-   "fusion360": null+   "fusion360": {+    "verb": "fusion_sch_swap_part",+    "match": "partial",+    "note": "REPLACE from a library the design holds; pins that lose their net are listed (lostConnections), not rewired; multi-gate devices refused"+   }   },   {    "name": "sch_text",@@ -2521,7 +2533,10 @@    "name": "estimate_cost",    "tier": "checks",    "source": "proposed",-   "altium": null,+   "altium": {+    "verb": "altium_estimate_cost",+    "match": "exact"+   },    "kicad": {     "verb": "kicad_estimate_cost",     "match": "exact"@@ -2681,7 +2696,10 @@    "name": "place_decoupling",    "tier": "checks",    "source": "proposed",-   "altium": null,+   "altium": {+    "verb": "altium_place_decoupling",+    "match": "exact"+   },    "kicad": {     "verb": "kicad_place_decoupling",     "match": "exact"@@ -2712,7 +2730,10 @@    "name": "plan_fanout",    "tier": "checks",    "source": "proposed",-   "altium": null,+   "altium": {+    "verb": "altium_plan_fanout",+    "match": "exact"+   },    "kicad": {     "verb": "kicad_plan_fanout",     "match": "exact"@@ -2726,7 +2747,10 @@    "name": "plan_route",    "tier": "checks",    "source": "proposed",-   "altium": null,+   "altium": {+    "verb": "altium_plan_route",+    "match": "exact"+   },    "kicad": {     "verb": "kicad_plan_route",     "match": "exact"@@ -3166,8 +3190,14 @@    "tier": "app",    "source": "proposed",    "altium": null,-   "kicad": null,-   "fusion360": null+   "kicad": {+    "verb": "kicad_export_spice",+    "match": "exact"+   },+   "fusion360": {+    "verb": "fusion_export_spice",+    "match": "exact"+   }   },   {    "name": "extension_ping",@@ -3696,8 +3726,14 @@    "tier": "app",    "source": "proposed",    "altium": null,-   "kicad": null,-   "fusion360": null+   "kicad": {+    "verb": "kicad_simulate",+    "match": "exact"+   },+   "fusion360": {+    "verb": "fusion_simulate",+    "match": "exact"+   }   },   {    "name": "state",
verbs/standard.json+4−4
@@ -1,13 +1,13 @@ {- "standard": "0.14.0",+ "standard": "0.15.0",  "reference": {   "bridge": "altium",-  "version": "0.2.79"+  "version": "0.2.80"  },  "proposed": 56,  "compared": {-  "kicad": "1.0.77",-  "fusion360": "1.13.7"+  "kicad": "1.0.79",+  "fusion360": "1.13.9"  },  "tiers": {   "core": "Read and edit the design. Every bridge provides these: they are how an AI sees and changes a board.",