← Commit history
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"