skill
EDA Bridge Standard: one way for an AI to drive every PCB tool
Public Made by Adomby adom
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.
← Commit history
standard 0.16.0: engine 0.13.0 adds auto_classes and silk_designators
10 files changed
+320−7
README.md+3@@ -158,6 +158,9 @@ Decisions so far (proposals from the KiCad and Fusion bridges, 2026-10-27; issue ## Status +Version 0.16.0: the engine (0.13.0) adds `auto_classes` and `silk_designators` from the Altium bridge. On the Bluetooth Sentinel+every planned designator keeps 0.15 mm from every pad, overlaps no other label and stays on the board (checked+independently). Version 0.15.2: the engine (0.12.2) fixes fusion-lane's findings: - `assembly_check` counts pads sharing one centre as one land, and leaves out parts with no pads (#24). - A `plan_route` that finds no path while avoiding pours says whether a path exists through them (`withoutAvoid`, #21).
engine/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-eda-checks"-version = "0.12.2"+version = "0.13.0" dependencies = [ "roxmltree", "serde_json",
engine/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.12.2"+version = "0.13.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@@ -12,6 +12,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 |+| `auto_classes` | board | net classes from the names the nets already have (GND, Power, Clock, HighSpeed) and differential pairs from their suffixes (`_P`/`_N`, `DP`/`DN`, `+`/`-`), with `applyArgs`: the net_classes and diff_pairs calls that write them (Power with a `powerWidthMm` width rule). From the Altium bridge's autoclass.rs | | `check_clearance` | board, bodies? | pairs of parts closer than `thresholdMm`, closest first: `mode: courtyard` (default 0.25) measures part boxes (pads, and 3D bodies when given) on the same side; `mode: pads` (default 0.2) measures pad copper of different parts that shares a layer, by its real outline, skipping same-net pairs unless `includeSameNet`; `refs` keeps pairs involving those parts. A part under a raised one (pads apart, bodies apart in 3D) is listed in `stacked`, not `pairs`, as placement_check lists it. From the KiCad bridge's place_tools | | `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`) |@@ -25,6 +26,7 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. | `placement_check` | board, bodies? | courtyard overlaps (pads and bodies, grown by `marginMm`), parts off or over the edge, pads within `edgeMm` of the edge, parts on mounting holes; `stacked` lists plan-view overlaps where one part sits wholly above the other (a part under a raised molecule: pads apart, no bodies meeting in 3D); `bodiesUsed: false` when no bodies were given | | `plan_fanout` | board, stack?, rules? | the fanout of a BGA or QFN/QFP (`ref`): pitch and package from its pads; a BGA's outer ring escapes on the surface and each inner ball takes a dogbone via, or a via in the pad when the dogbone does not fit (`style` auto); a perimeter pad takes an outward stub, with a via at its end for ground or `viaPerimeter`, and an exposed pad a via grid. `calls` are route_net arguments, one per net; `conflicts` lists each via and stub closer than the clearance to another net's pad, track, arc or via anywhere on the board. A pin its net's copper already reaches is skipped (`includeRouted` plans it anyway), the exposed-pad grid keeps clear of holes already there, and `crossesPours` names other nets' pours the plan runs into (repour before DRC). From the KiCad bridge's place_tools | | `plan_route` | board, rules? | a path between two pads of a net (`from`, `to` as Ref.Pad) on any listed copper `layers`, with through vias between them: a bounded orthogonal A* on `gridMm`, width and via from the args, else the board's width and via rules (`planner.widthFrom`, `viaFrom`), keeping `widthMm/2` + the board's strictest board-wide copper clearance rule from other nets' copper (pads by their real outline), inside the outline, no via in a pad; a through via is checked on every copper layer of the board (`planner.viasCheckedOn`), not only the planned ones. `routeNet` is route_net's arguments, unchanged. Pours are not obstacles (repour after), but `crossesPours` names each other net's pour the route runs through, since that can split it; `avoidPours: true` keeps the route out of them (or a list of nets: out of theirs only), and `pourCost` makes a mm inside one cost 1 + pourCost mm, a preference where a hard block finds no path; a pour that holds the route's own end pad is exempt and named (`planner.avoidExempt`); with no path, `ends` names the avoided pours at each end pad and whether they are exempt, and `withoutAvoid` says whether a path exists through them. The grid path is smoothed to straight and 45-degree segments where they keep the same clearance (`smooth: false` keeps it) |+| `silk_designators` | board, silk | a place for each reference designator: one size (`size`, stepping down to `minSize`), beside its own part, clear of every pad by `padClearance`, of other parts' bodies and of other labels, inside the board. `applyArgs` is apply_silkscreen's arguments; parts with no clear spot are listed, not forced. From the Altium bridge's silkplace.rs | | `thermal_vias` | board, rules? | a grid of vias in a part's exposed pad (`ref`, `pad?`), clear of vias already there; `apply` is `place_vias` arguments | | `via_fence` | board, rules? | vias of `viaNet` both sides of the named nets' tracks and/or round the board edge (`edge`), at `pitchMm` or lambda/20 at `freqGHz`, clear of other copper; `apply` is `place_vias` arguments | @@ -98,7 +100,7 @@ adom-eda-checks import_svg --svg logo.svg --args '{"at": [10, 10], "widthMm": 20 Replies keep the standard envelope (`success`, `errorCode`, `error`, `_hint`) and name verbs without a prefix (`apply_pours`, `add_rule`, `place_vias`): a bridge adds its own. Every reply also carries `engine`-(`"adom-eda-checks 0.12.2"`), so a bridge reports the engine it runs without writing the version by hand.+(`"adom-eda-checks 0.13.0"`), so a bridge reports the engine it runs without writing the version by hand. ## Tests
engine/src/autoclass.rsadded+84@@ -0,0 +1,84 @@+//! Net classes from the names a designer already gave the nets: ground, power, clocks, the fast interfaces (USB,+//! Ethernet, HDMI, PCIe, DDR, MIPI, LVDS, SATA), and the differential pairs their _P/_N (+/-, DP/DN) suffixes spell;+//! proposed, with the net_classes and diff_pairs arguments that write them. From the Altium bridge's autoclass.rs.+use serde_json::{json, Value};+use std::collections::BTreeMap;++/// The class a net's name puts it in, if any. Pure.+pub fn classify(net: &str) -> Option<&'static str> {+ let n = net.to_ascii_uppercase();+ let core = n.trim_start_matches(['+', '-', '/', '\\']);+ if crate::placetools::is_ground(&n) || core.starts_with("GND") { return Some("GND"); }+ let power = core.contains("VBUS") || core.starts_with("VCC") || core.starts_with("VDD") || core.starts_with("VBAT") || core.starts_with("VIN") || core.starts_with("VBUS")+ || core.starts_with("VSYS") || core.starts_with("VREF") || core.starts_with("PWR") || core.starts_with("VCORE") || core.starts_with("VOUT")+ || (core.starts_with('V') && core.len() > 1 && core.as_bytes()[1].is_ascii_digit())+ || core.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) && (core.contains('V'));+ // a supply's name wins over what it feeds (VDD_XTAL is the crystal's supply, not a clock)+ if power { return Some("Power"); }+ let fast = ["USB", "ETH", "RGMII", "RMII", "SGMII", "HDMI", "PCIE", "DDR", "MIPI", "CSI", "DSI", "LVDS", "SATA", "SERDES", "QSPI", "SDIO", "EMMC"];+ if fast.iter().any(|f| core.contains(f)) { return Some("HighSpeed"); }+ if core.contains("CLK") || core.contains("XTAL") || core.contains("OSC") || core.ends_with("SCK") { return Some("Clock"); }+ None+}++/// Differential pairs by name: X_P/X_N, XP/XN, X+/X-, X_DP/X_DM. Pure.+pub fn pairs(nets: &[String]) -> Vec<(String, String, String)> {+ let set: BTreeMap<String, &String> = nets.iter().map(|n| (n.to_ascii_uppercase(), n)).collect();+ let mut out = Vec::new();+ for (u, orig) in &set {+ // longest suffixes first: USB_DP must pair as USB (DP/DN), not as USB_D (P/N)+ for (p, q) in [("_DP", "_DN"), ("_DP", "_DM"), ("DP", "DN"), ("DP", "DM"), ("_P", "_N"), ("+", "-"), ("P", "N")] {+ if let Some(stem) = u.strip_suffix(p) {+ if stem.is_empty() { continue; }+ if let Some(neg) = set.get(&format!("{stem}{q}")) {+ let name = stem.trim_end_matches('_').to_string();+ if !out.iter().any(|(n, _, _): &(String, String, String)| *n == name) { out.push((name, (*orig).clone(), (*neg).clone())); }+ break;+ }+ }+ }+ }+ out+}++/// auto_classes: input {board, args: {powerWidthMm?}}. A proposal from the net names, and `applyArgs`: one net_classes+/// call per class (Power with a width rule) and one diff_pairs call per pair, for the bridge to run.+pub fn auto_classes(input: &Value) -> Value {+ let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+ let nets: Vec<String> = st["nets"].as_array().cloned().unwrap_or_default().iter().filter_map(|n| n.as_str().map(String::from)).collect();+ let mut classes: BTreeMap<&str, Vec<String>> = BTreeMap::new();+ for n in &nets { if let Some(c) = classify(n) { classes.entry(c).or_default().push(n.clone()); } }+ let prs = pairs(&nets);+ let width = input["args"]["powerWidthMm"].as_f64().unwrap_or(0.5);+ let net_classes: Vec<Value> = classes.iter().map(|(c, members)| { let mut a = json!({"name": c, "nets": members}); if *c == "Power" { a["width"] = json!(width); } a }).collect();+ let diff_pairs: Vec<Value> = prs.iter().map(|(n, p, q)| json!({"name": n, "pos": p, "neg": q})).collect();+ json!({"success": true, "applied": false, "proposal": {"classes": classes, "pairs": diff_pairs, "unclassified": nets.iter().filter(|n| classify(n).is_none()).count()},+ "applyArgs": {"net_classes": net_classes, "diff_pairs": diff_pairs},+ "_hint": "A proposal from the net names. Check it, then run each applyArgs.net_classes entry through net_classes (Power gets a width rule, powerWidthMm) and each applyArgs.diff_pairs entry through diff_pairs. Then impedance sizes the pairs and current_capacity checks the power nets."})+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn names_sort_into_classes_and_pairs() {+ for (n, c) in [("GND", Some("GND")), ("AGND", Some("GND")), ("+3V3", Some("Power")), ("VDD_CORE", Some("Power")), ("5V", Some("Power")), ("VBUS", Some("Power")),+ ("USB_DP", Some("HighSpeed")), ("ETH_TX_P", Some("HighSpeed")), ("MCLK", Some("Clock")), ("VDD_XTAL", Some("Power")), ("USB_VBUS", Some("Power")), ("XTAL_IN", Some("Clock")), ("SPI_SCK", Some("Clock")), ("N1", None), ("LED_RED", None)] {+ assert_eq!(classify(n), c, "{n}");+ }+ let nets: Vec<String> = ["USB_DP", "USB_DN", "ETH_TX_P", "ETH_TX_N", "LVDS0+", "LVDS0-", "SDA"].iter().map(|s| s.to_string()).collect();+ let p = pairs(&nets);+ assert_eq!(p.len(), 3);+ assert!(p.iter().any(|(n, a, b)| n == "USB" && a == "USB_DP" && b == "USB_DN"));+ assert!(p.iter().any(|(n, a, b)| n == "LVDS0" && a == "LVDS0+" && b == "LVDS0-"));+ }++ #[test]+ fn the_verb_answers_the_arguments_that_write_them() {+ let r = auto_classes(&json!({"board": {"nets": ["GND", "+3V3", "USB_DP", "USB_DN", "LED"]}, "args": {"powerWidthMm": 0.6}}));+ assert_eq!(r["proposal"]["unclassified"], json!(1));+ assert!(r["applyArgs"]["net_classes"].as_array().unwrap().iter().any(|c| c["name"] == "Power" && c["width"] == json!(0.6)));+ assert_eq!(r["applyArgs"]["diff_pairs"], json!([{"name": "USB", "pos": "USB_DP", "neg": "USB_DN"}]));+ }+}
engine/src/lib.rs+5−1@@ -8,6 +8,7 @@ use serde_json::Value; pub mod assembly;+pub mod autoclass; pub mod bodies; pub mod coppermap; pub mod cost;@@ -21,6 +22,7 @@ pub mod lengths; pub mod placement; pub mod placetools; pub mod route;+pub mod silkplace; pub mod writers; /// This engine's version. Every reply carries it as `engine` ("adom-eda-checks 0.3.0"), so a bridge never writes it by hand@@ -28,7 +30,7 @@ pub mod writers; pub const VERSION: &str = env!("CARGO_PKG_VERSION"); /// The checks this build answers, by standard verb name.-pub const CHECKS: &[&str] = &["assembly_check", "check_clearance", "copper_balance", "current_capacity", "estimate_cost", "fab_check", "height_check", "impedance", "net_lengths", "place_decoupling", "placement_check", "plan_fanout", "plan_route", "return_path", "thermal_vias", "via_fence"];+pub const CHECKS: &[&str] = &["assembly_check", "auto_classes", "check_clearance", "copper_balance", "current_capacity", "estimate_cost", "fab_check", "height_check", "impedance", "net_lengths", "place_decoupling", "placement_check", "plan_fanout", "plan_route", "return_path", "silk_designators", "thermal_vias", "via_fence"]; /// Run one check or writer by its standard name. The reply carries `engine`. pub fn run(check: &str, input: &Value) -> Value {@@ -41,6 +43,7 @@ fn answer(check: &str, input: &Value) -> Value { if let Some(out) = writers::run(check, input) { return out; } match check { "assembly_check" => assembly::assembly_check(input),+ "auto_classes" => autoclass::auto_classes(input), "check_clearance" => placetools::check_clearance(input), "copper_balance" => coppermap::copper_balance(input), "current_capacity" => current::current_capacity(input),@@ -54,6 +57,7 @@ fn answer(check: &str, input: &Value) -> Value { "placement_check" => placement::placement_check(input), "plan_route" => route::plan_route(input), "return_path" => coppermap::return_path(input),+ "silk_designators" => silkplace::silk_designators(input), "thermal_vias" => features::thermal_vias(input), "via_fence" => features::via_fence(input), _ => input::refuse("not_supported", &format!("{check} is not a verb this engine answers (checks: {}; writers: {})", CHECKS.join(", "), writers::WRITERS.join(", "))),
engine/src/silkplace.rsadded+220@@ -0,0 +1,220 @@+//! Reference designators placed the way assembly houses want them: one size for all, upright, beside their own part,+//! clear of every pad (JLCPCB: silkscreen 0.15 mm from pads), every other part's body and every designator already+//! placed, and inside the board. From the Altium bridge's silkplace.rs: a plan here, which the bridge applies with+//! apply_silkscreen and checks against the tool's own text boxes after.+use serde_json::{json, Value};++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }++type Rect = (f64, f64, f64, f64);++fn rect_of(v: &Value) -> Option<Rect> {+ let a = v.as_array()?;+ if a.len() != 4 { return None; }+ let n: Vec<f64> = a.iter().map(f).collect();+ Some((n[0].min(n[2]), n[1].min(n[3]), n[0].max(n[2]), n[1].max(n[3])))+}+fn grow(r: Rect, d: f64) -> Rect { (r.0 - d, r.1 - d, r.2 + d, r.3 + d) }+fn hit(a: Rect, b: Rect) -> bool { a.0 < b.2 && b.0 < a.2 && a.1 < b.3 && b.1 < a.3 }+fn union(a: Rect, b: Rect) -> Rect { (a.0.min(b.0), a.1.min(b.1), a.2.max(b.2), a.3.max(b.3)) }++/// A pad's copper as a rectangle (its rotated extent).+fn pad_rect(p: &Value) -> Rect {+ let (c, s) = (f(&p["rotation"]).to_radians().cos().abs(), f(&p["rotation"]).to_radians().sin().abs());+ let (sx, sy) = (f(&p["sizeX"]), f(&p["sizeY"]));+ let (hx, hy) = ((sx * c + sy * s) / 2.0, (sx * s + sy * c) / 2.0);+ (f(&p["x"]) - hx, f(&p["y"]) - hy, f(&p["x"]) + hx, f(&p["y"]) + hy)+}++fn on_side(layer: &Value, bottom: bool) -> bool {+ match layer.as_str() { Some("*.Cu") => true, Some("B.Cu") => bottom, Some("F.Cu") => !bottom, _ => false }+}++/// Where each designator goes: [{ref, x, y, size, rotation, side, at}] (x, y the text's lower-left, its right on the+/// bottom side where Altium mirrors it) and the parts with no clear spot. Pure: silk_state's components, the board's+/// pads and outline bounding box, and the boxes of labels that stay put.+pub fn plan(comps: &[Value], pads: &[Value], board: Rect, size: f64, min_size: f64, gap: f64, pad_clear: f64, fixed: &[(bool, Rect)]) -> (Vec<Value>, Vec<Value>) {+ // labels that stay where they are (parts left out, or with no spot) are obstacles like the ones placed+ let mut placed: Vec<(bool, Rect)> = fixed.to_vec();+ let (mut out, mut unplaced) = (Vec::new(), Vec::new());+ // the part's own outline: its body box and its own pads+ let own = |c: &Value| -> Option<Rect> {+ let r = c["ref"].as_str().unwrap_or("");+ let mut b = rect_of(&c["body"]);+ for p in pads.iter().filter(|p| p["ref"].as_str() == Some(r)) { b = Some(b.map(|x| union(x, pad_rect(p))).unwrap_or(pad_rect(p))); }+ b+ };+ let bodies: Vec<(String, bool, Rect)> = comps.iter().filter_map(|c| own(c).map(|b| (c["ref"].as_str().unwrap_or("").to_string(), c["side"] == "bottom", b))).collect();+ // big parts first: they have the fewest places to put a label and the small ones fit round them+ let mut order: Vec<&Value> = comps.iter().filter(|c| c["name"]["shown"].as_bool() != Some(false)).collect();+ order.sort_by(|a, b| {+ let area = |c: &Value| own(c).map(|r| (r.2 - r.0) * (r.3 - r.1)).unwrap_or(0.0);+ area(b).partial_cmp(&area(a)).unwrap()+ });+ let inside = grow(board, -0.2);+ for c in order {+ let rf = c["ref"].as_str().unwrap_or("").to_string();+ let bottom = c["side"] == "bottom";+ let Some(b) = own(c) else { unplaced.push(json!({"ref": rf, "why": "no body or pads to place it by"})); continue };+ if b.2 < board.0 || b.0 > board.2 || b.3 < board.1 || b.1 > board.3 {+ unplaced.push(json!({"ref": rf, "why": "the part is off the board: place it first (move_parts)"}));+ continue;+ }+ // the label's size at the new height, from Altium's own box at the old one+ // Altium's box is the text plus half its stroke all round (a 1.0 mm designator measures 1.285 mm tall on the+ // Bluetooth Sentinel example): plan the text, check the box+ let old = f(&c["name"]["size"]);+ let turned = (f(&c["name"]["rotation"]).rem_euclid(180.0) - 90.0).abs() < 1.0;+ let (m, w) = match rect_of(&c["name"]["box"]) {+ Some(r) if old > 0.0 && r.2 > r.0 => {+ let (bw, bh) = if turned { (r.3 - r.1, r.2 - r.0) } else { (r.2 - r.0, r.3 - r.1) };+ let m = ((bh - old) / 2.0).clamp(0.0, 0.3);+ (m, (bw - 2.0 * m).max(old * 0.5) * size / old)+ }+ _ => (0.15, rf.len() as f64 * size * 0.8),+ };+ let free = |text: Rect| -> bool {+ let r = grow(text, m);+ r.0 >= inside.0 && r.1 >= inside.1 && r.2 <= inside.2 && r.3 <= inside.3+ && !pads.iter().any(|p| on_side(&p["layer"], bottom) && hit(r, grow(pad_rect(p), pad_clear)))+ && !bodies.iter().any(|(o, side, ob)| *o != rf && *side == bottom && hit(r, *ob))+ && !placed.iter().any(|(side, pr)| *side == bottom && hit(r, grow(*pr, 0.1)))+ };+ // the sizes to try: `size`, then down in 0.1 mm steps to `min_size`+ let mut found = None;+ let mut s = size;+ while found.is_none() && s >= min_size - 1e-9 {+ found = spots(b, w * s / size, s, gap, bottom).into_iter().find(|(r, _, _)| free(*r)).map(|(r, rot, at)| (r, rot, at, s));+ s -= 0.1;+ }+ match found {+ Some((r, rot, at, s)) => {+ placed.push((bottom, grow(r, m)));+ // the text's anchor: lower left upright; turned 90 its letters lie left of the anchor (lower right of the+ // box); mirrored on the bottom it is the text's right end+ let (ax, ay) = if rot == 90 { (r.2, r.1) } else if bottom { (r.2, r.1) } else { (r.0, r.1) };+ out.push(json!({"ref": rf, "x": r3(ax), "y": r3(ay), "size": r3(s), "rotation": rot, "side": if bottom { "bottom" } else { "top" }, "at": at}));+ }+ None => unplaced.push(json!({"ref": rf, "why": "no clear spot beside the part", "triedSizes": [size, min_size]})),+ }+ }+ (out, unplaced)+}++/// Places a w x h label could go round the part box b, nearest first: centred on each side, then slid along it, at+/// `gap` and then 3 x `gap`; turned 90 degrees beside the left and right sides (top side only: Altium mirrors bottom+/// text). Each is (box, rotation, where).+fn spots(b: Rect, w: f64, h: f64, gap: f64, bottom: bool) -> Vec<(Rect, i64, String)> {+ let mut v = Vec::new();+ let (cx, cy) = ((b.0 + b.2) / 2.0, (b.1 + b.3) / 2.0);+ for g in [gap, 3.0 * gap] {+ // slide offsets along a side: centre first, then outwards both ways in 0.5 mm steps+ let slide = |len: f64| -> Vec<f64> {+ let mut o = vec![0.0];+ let mut k = 0.5;+ while k <= len / 2.0 + 0.5 { o.push(-k); o.push(k); k += 0.5; }+ o+ };+ for d in slide(b.2 - b.0) {+ v.push(((cx - w / 2.0 + d, b.3 + g, cx + w / 2.0 + d, b.3 + g + h), 0, "above".to_string()));+ v.push(((cx - w / 2.0 + d, b.1 - g - h, cx + w / 2.0 + d, b.1 - g), 0, "below".to_string()));+ }+ for d in slide(b.3 - b.1) {+ v.push(((b.2 + g, cy - h / 2.0 + d, b.2 + g + w, cy + h / 2.0 + d), 0, "right".to_string()));+ v.push(((b.0 - g - w, cy - h / 2.0 + d, b.0 - g, cy + h / 2.0 + d), 0, "left".to_string()));+ if !bottom {+ v.push(((b.2 + g, cy - w / 2.0 + d, b.2 + g + h, cy + w / 2.0 + d), 90, "right, turned".to_string()));+ v.push(((b.0 - g - h, cy - w / 2.0 + d, b.0 - g, cy + w / 2.0 + d), 90, "left, turned".to_string()));+ }+ }+ }+ v+}++/// Designators whose box (Altium's own) still touches a pad, closer than `pad_clear`. Pure.+pub fn on_pads(comps: &[Value], pads: &[Value], pad_clear: f64) -> Vec<Value> {+ comps.iter().filter(|c| c["name"]["shown"].as_bool() != Some(false)).filter_map(|c| {+ let r = rect_of(&c["name"]["box"])?;+ let bottom = c["side"] == "bottom";+ let p = pads.iter().find(|p| on_side(&p["layer"], bottom) && hit(r, grow(pad_rect(p), pad_clear - 0.01)))?;+ Some(json!({"ref": c["ref"], "pad": format!("{}.{}", p["ref"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or(""))}))+ }).collect()+}++/// silk_designators: input {board, silk, args: {size?, minSize?, gap?, padClearance?, refs?}}. The plan: `planned`,+/// and `applyArgs` ready for apply_silkscreen (x, y the text's lower-left; its right end on the bottom side, where the+/// text is mirrored), the parts with no clear spot, and the labels on pads before.+pub fn silk_designators(input: &Value) -> Value {+ let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+ let args = &input["args"];+ let num = |k: &str, d: f64| args.get(k).and_then(|v| v.as_f64()).unwrap_or(d);+ let (size, gap, clear) = (num("size", 1.0), num("gap", 0.2), num("padClearance", 0.15));+ if size < 0.5 { return crate::input::refuse("bad_args", "size is the text height in mm, 0.5 or more (JLCPCB reads 1.0 mm and up reliably)"); }+ let Some(bb) = crate::geom::outline_bbox(&st) else { return crate::input::refuse("no_outline", "the board has no outline") };+ let Some(silk) = crate::input::part(input, "silk") else { return crate::input::refuse("bad_args", "pass silk_state as silk: each part's body box and designator") };+ let all = silk["components"].as_array().cloned().unwrap_or_default();+ let mut comps = all.clone();+ if let Some(want) = args.get("refs").and_then(|v| v.as_array()) {+ let w: Vec<&str> = want.iter().filter_map(|v| v.as_str()).collect();+ comps.retain(|c| c["ref"].as_str().map(|r| w.iter().any(|x| x.eq_ignore_ascii_case(r))).unwrap_or(false));+ }+ let pads = st["pads"].as_array().cloned().unwrap_or_default();+ let before = on_pads(&comps, &pads, clear);+ let min_size = num("minSize", size).min(size);+ // the labels of parts left out stay put; so do the labels of parts with no spot, so plan again round those+ let label = |c: &Value| -> Option<(bool, Rect)> { if c["name"]["shown"].as_bool() == Some(false) { None } else { rect_of(&c["name"]["box"]).map(|r| (c["side"] == "bottom", r)) } };+ let mut fixed: Vec<(bool, Rect)> = all.iter().filter(|c| !comps.iter().any(|k| k["ref"] == c["ref"])).filter_map(label).collect();+ let mut moving = comps.clone();+ let (mut names, mut unplaced) = (Vec::new(), Vec::new());+ // every round pins at least one more label, so this ends; the last round leaves nothing unpinned+ for _ in 0..=comps.len() {+ let (n, u) = plan(&moving, &pads, bb, size, min_size, gap, clear, &fixed);+ names = n;+ if u.is_empty() { break; }+ let stuck: Vec<Value> = u.iter().map(|x| x["ref"].clone()).collect();+ fixed.extend(moving.iter().filter(|c| stuck.contains(&c["ref"])).filter_map(label));+ moving.retain(|c| !stuck.contains(&c["ref"]));+ unplaced.extend(u);+ }+ let apply: Vec<Value> = names.iter().map(|n| json!({"ref": n["ref"], "x": n["x"], "y": n["y"], "size": n["size"], "rotation": n["rotation"]})).collect();+ json!({"success": true, "planned": names, "unplaced": unplaced, "onPadsBefore": before, "sizeMm": size, "applyArgs": {"names": apply},+ "_hint": "A plan: every label upright (or turned 90 beside the part), one size, beside its own part, clear of pads by padClearance and of other bodies and labels. Send applyArgs to apply_silkscreen, then read silk_state back and check no label touches a pad with the tool's own text boxes. unplaced parts had no clear spot: try minSize for them (refs), move the part, or place the label by hand."})+}++#[cfg(test)]+mod tests {+ use super::*;++ fn comp(r: &str, body: [f64; 4]) -> Value {+ json!({"ref": r, "side": "top", "body": body, "name": {"text": r, "size": 1.0, "shown": true, "box": [0.0, 0.0, 2.0, 1.0]}})+ }++ #[test]+ fn labels_go_beside_their_part_clear_of_pads_and_each_other() {+ // C1 sits just above U1, where U1's label would go+ let comps = vec![comp("U1", [10.0, 10.0, 16.0, 16.0]), comp("C1", [12.0, 16.4, 14.0, 17.4])];+ let pads = vec![json!({"ref": "C1", "name": "1", "x": 12.5, "y": 16.9, "sizeX": 0.8, "sizeY": 0.8, "rotation": 0, "layer": "F.Cu"}),+ json!({"ref": "C1", "name": "2", "x": 13.5, "y": 16.9, "sizeX": 0.8, "sizeY": 0.8, "rotation": 0, "layer": "F.Cu"})];+ let (out, un) = plan(&comps, &pads, (0.0, 0.0, 40.0, 40.0), 1.0, 1.0, 0.2, 0.15, &[]);+ assert!(un.is_empty());+ // U1 above would land on C1's body and pads: it goes below+ assert_eq!(out[0]["ref"], "U1");+ assert_eq!(out[0]["at"], "below");+ assert_eq!(out[1]["ref"], "C1");+ assert_eq!(out[1]["at"], "above");+ assert_eq!(out[1]["y"], 17.6);+ assert_eq!(out[0]["y"], 8.8);+ }++ #[test]+ fn a_part_boxed_in_is_reported_not_forced() {+ let comps = vec![comp("R1", [1.0, 1.0, 3.0, 2.0])];+ let (out, un) = plan(&comps, &[], (0.0, 0.0, 4.0, 3.0), 1.0, 1.0, 0.2, 0.15, &[]);+ assert!(out.is_empty());+ assert_eq!(un[0]["ref"], "R1");+ let on = on_pads(&[json!({"ref": "R1", "side": "top", "name": {"shown": true, "box": [0.0, 0.0, 2.0, 1.0]}})],+ &[json!({"ref": "R1", "name": "1", "x": 1.0, "y": 0.5, "sizeX": 0.5, "sizeY": 0.5, "rotation": 0, "layer": "F.Cu"})], 0.15);+ assert_eq!(on.len(), 1);+ }+}
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "eda-bridge-standard",- "version": "0.15.2",+ "version": "0.16.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+1−1@@ -1,5 +1,5 @@ {- "standard": "0.15.2",+ "standard": "0.16.0", "reference": { "bridge": "altium", "version": "0.2.80"
verbs/standard.json+1−1@@ -1,5 +1,5 @@ {- "standard": "0.15.2",+ "standard": "0.16.0", "reference": { "bridge": "altium", "version": "0.2.80"