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.
main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
//! Length tuning: a serpentine in the net's longest straight track that has room beside it, its bumps sized to add
//! exactly the length a target (or another net's length) asks for, every bump clear of other copper and inside the
//! board, or a refusal that says why. From the Altium bridge's lengthtune.rs; the plan is answered as the track to
//! remove and the route_net arguments that replace it, for the bridge to apply in one undo step.
use crate::route::{pad_shape, Shape};
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 P = (f64, f64);
/// The serpentine's points replacing a track a->b: `n` rectangular bumps of height `amp` to the side `side` (+1 left,
/// -1 right), each `pitch` long along the track, centred on it. Pure.
pub fn meander(a: P, b: P, n: usize, amp: f64, pitch: f64, side: f64) -> Vec<P> {
let len = ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt();
let (ux, uy) = ((b.0 - a.0) / len, (b.1 - a.1) / len);
let (nx, ny) = (-uy * side, ux * side);
let at = |s: f64, h: f64| (a.0 + ux * s + nx * h, a.1 + uy * s + ny * h);
let start = (len - n as f64 * pitch) / 2.0;
let mut pts = vec![a];
for k in 0..n {
let s = start + k as f64 * pitch;
pts.push(at(s, 0.0));
pts.push(at(s, amp));
pts.push(at(s + pitch / 2.0, amp));
pts.push(at(s + pitch / 2.0, 0.0));
}
pts.push(b);
pts.dedup_by(|p, q| (p.0 - q.0).abs() < 1e-9 && (p.1 - q.1).abs() < 1e-9);
pts
}
/// How many bumps and how tall, to add `extra` mm along a track `len` long: as few bumps as fit at `max_amp`, each
/// `pitch` long, leaving `margin` at both ends. None when it cannot fit. Pure.
pub fn sizing(extra: f64, len: f64, pitch: f64, max_amp: f64, margin: f64) -> Option<(usize, f64)> {
if extra <= 0.0 { return None; }
let room = ((len - 2.0 * margin) / pitch).floor() as usize;
if room == 0 { return None; }
let n = ((extra / (2.0 * max_amp)).ceil() as usize).max(1);
if n > room { return None; }
Some((n, extra / (2.0 * n as f64)))
}
/// length_tune: input {board, rules?, args: {net, targetMm? | matchTo?, maxAmplitudeMm?, pitchMm?, clearance?}}.
pub fn length_tune(input: &Value) -> Value {
let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };
let args = &input["args"];
let Some(net) = args["net"].as_str().map(String::from) else { return crate::input::refuse("bad_args", "net is the net to lengthen") };
let lens = crate::lengths::lengths(&st);
let len_of = |n: &str| lens.iter().find(|(k, _)| k.eq_ignore_ascii_case(n)).map(|(_, v)| f(&v["lengthMm"]));
let Some(now) = len_of(&net) else { return crate::input::refuse("not_routed", &format!("{net} has no routed track")) };
let target = match (args["targetMm"].as_f64(), args["matchTo"].as_str()) {
(Some(t), _) => t,
(None, Some(m)) => match len_of(m) { Some(l) => l, None => return crate::input::refuse("not_routed", &format!("{m} has no routed track to match")) },
_ => return crate::input::refuse("bad_args", "targetMm (mm) or matchTo (a net) says how long it should be"),
};
let extra = target - now;
if extra <= 0.01 { return json!({"success": true, "changed": false, "net": net, "lengthMm": r3(now), "targetMm": r3(target), "_hint": "Already at or past the target: tuning only lengthens. Shorten the other net, or route this one more directly."}); }
let num = |k: &str, d: f64| args[k].as_f64().unwrap_or(d);
let rule = crate::fab::routing_clearance(&crate::input::rules(input));
let clearance = num("clearance", 0.0).max(rule.unwrap_or(0.2));
let same = |v: &Value| v["net"].as_str().map(|n| n.eq_ignore_ascii_case(&net)).unwrap_or(false);
// other nets' copper by layer ("*" every layer): routed tracks (a hatched pour's net-less pieces re-pour round the
// bumps), vias, and pads by their real outline
let mut other: Vec<(String, Shape)> = Vec::new();
for t in st["tracks"].as_array().into_iter().flatten().filter(|t| !same(t) && t["net"].as_str().map(|n| !n.is_empty()).unwrap_or(false)) {
other.push((t["layer"].as_str().unwrap_or("").into(), Shape::Cap((f(&t["start"]["x"]), f(&t["start"]["y"])), (f(&t["end"]["x"]), f(&t["end"]["y"])), f(&t["width"]) / 2.0)));
}
for v in st["vias"].as_array().into_iter().flatten().filter(|v| !same(v)) {
let c = (f(&v["position"]["x"]), f(&v["position"]["y"]));
other.push(("*".into(), Shape::Cap(c, c, f(&v["size"]) / 2.0)));
}
for p in st["pads"].as_array().into_iter().flatten().filter(|p| !same(p)) {
let l = if p["layer"] == "*.Cu" || f(&p["drill"]) > 0.0 { "*".to_string() } else { p["layer"].as_str().unwrap_or("").to_string() };
other.push((l, pad_shape(p)));
}
let ring = crate::coppermap::outline_ring(&st);
// the net's tracks, longest first; for each, a side where the serpentine fits
let mut tracks: Vec<Value> = st["tracks"].as_array().cloned().unwrap_or_default().into_iter().filter(same).collect();
let tlen = |t: &Value| ((f(&t["end"]["x"]) - f(&t["start"]["x"])).powi(2) + (f(&t["end"]["y"]) - f(&t["start"]["y"])).powi(2)).sqrt();
tracks.sort_by(|a, b| tlen(b).partial_cmp(&tlen(a)).unwrap());
let mut why = Vec::new();
for t in &tracks {
let (a, b) = ((f(&t["start"]["x"]), f(&t["start"]["y"])), (f(&t["end"]["x"]), f(&t["end"]["y"])));
let w = f(&t["width"]);
let layer = t["layer"].as_str().unwrap_or("F.Cu").to_string();
let pitch = num("pitchMm", (4.0 * w).max(4.0 * clearance + 2.0 * w));
let Some((n, amp)) = sizing(extra, tlen(t), pitch, num("maxAmplitudeMm", 3.0), 2.0 * w) else { why.push(json!({"track": [[r3(a.0), r3(a.1)], [r3(b.0), r3(b.1)]], "why": "too short for the bumps this needs at this amplitude"})); continue };
for side in [1.0, -1.0] {
let pts = meander(a, b, n, amp, pitch, side);
let clear = pts.windows(2).all(|s| other.iter().filter(|o| o.0 == layer || o.0 == "*").all(|o| o.1.dist(s[0], s[1]) >= w / 2.0 + clearance - 1e-9))
&& (ring.len() < 3 || pts.iter().all(|p| crate::route::inside(*p, &ring)));
if !clear { continue; }
let added: f64 = pts.windows(2).map(|s| ((s[1].0 - s[0].0).powi(2) + (s[1].1 - s[0].1).powi(2)).sqrt()).sum::<f64>() - tlen(t);
let way: Vec<Value> = pts.iter().map(|p| json!({"x": r3(p.0), "y": r3(p.1), "layer": layer})).collect();
return json!({"success": true, "changed": false, "net": net, "lengthMm": r3(now), "targetMm": r3(target), "expectedMm": r3(now + added), "clearanceMm": clearance,
"plan": {"layer": layer, "replaces": [[r3(a.0), r3(a.1)], [r3(b.0), r3(b.1)]], "bumps": n, "amplitudeMm": r3(amp), "pitchMm": r3(pitch), "side": if side > 0.0 { "left" } else { "right" }, "addsMm": r3(added)},
"remove": {"tracks": [{"net": net, "layer": layer, "start": {"x": a.0, "y": a.1}, "end": {"x": b.0, "y": b.1}}]},
"routeNet": {"net": net, "layer": layer, "width": w, "paths": [way]}, "revision": st["revision"],
"_hint": "A plan: remove the one track in `remove` and route `routeNet` in its place, in one undo step, with expectedRevision: revision. net_lengths then shows the new length; every bump keeps clearanceMm from other nets' pads, routed tracks and vias."});
}
why.push(json!({"track": [[r3(a.0), r3(a.1)], [r3(b.0), r3(b.1)]], "why": "no clear room on either side for the bumps"}));
}
json!({"success": false, "errorCode": "no_room", "error": format!("no track of {net} has room for {:.2} mm more", extra), "lengthMm": r3(now), "targetMm": r3(target), "tried": why,
"_hint": "Lower maxAmplitudeMm for more, shorter bumps, raise it for fewer, or reroute the net with a longer straight run in open space."})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_meander_adds_exactly_twice_its_height_per_bump() {
let (n, amp) = sizing(6.0, 30.0, 1.2, 1.0, 0.4).unwrap();
assert_eq!(n, 3);
assert!((amp - 1.0).abs() < 1e-9);
let pts = meander((0.0, 0.0), (30.0, 0.0), n, amp, 1.2, 1.0);
let len: f64 = pts.windows(2).map(|s| ((s[1].0 - s[0].0).powi(2) + (s[1].1 - s[0].1).powi(2)).sqrt()).sum();
assert!((len - 36.0).abs() < 1e-9, "{len}");
assert!(sizing(100.0, 5.0, 1.2, 1.0, 0.4).is_none());
assert!(sizing(-1.0, 30.0, 1.2, 1.0, 0.4).is_none());
}
#[test]
fn the_verb_plans_round_other_copper_to_match_a_net() {
let t = |net: &str, y: f64, x1: f64| json!({"net": net, "layer": "F.Cu", "width": 0.2, "start": {"x": 2.0, "y": y}, "end": {"x": x1, "y": y}});
// A is 30 mm, B 24 mm; another net runs just above A, so the bumps go below it
let b = json!({"outline": [[0, -20], [40, -20], [40, 20], [0, 20]], "revision": "r3",
"tracks": [t("A", 0.0, 32.0), t("B", 10.0, 26.0), json!({"net": "X", "layer": "F.Cu", "width": 0.2, "start": {"x": 2.0, "y": 0.6}, "end": {"x": 32.0, "y": 0.6}})]});
let r = length_tune(&json!({"board": b, "args": {"net": "B", "matchTo": "A"}}));
assert_eq!(r["success"], json!(true), "{r}");
assert!((f(&r["expectedMm"]) - 30.0).abs() < 1e-6, "{r}");
let a = length_tune(&json!({"board": b, "args": {"net": "A", "targetMm": 34.0}}));
assert_eq!(a["plan"]["side"], "right", "{a}");
assert_eq!(length_tune(&json!({"board": b, "args": {"net": "A", "targetMm": 20.0}}))["changed"], json!(false));
assert_eq!(length_tune(&json!({"board": b, "args": {"net": "Q", "targetMm": 20.0}}))["errorCode"], "not_routed");
}
}