app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
main
John Lauer
Publish 0.2.3
6e27c4b
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460
//! Placement intent from the spec (issue #53): `placement.proximity`, `placement.cluster` and
//! `placement.anchor`.
//!
//! The AI keeps its freedom to place. These entries say what the placement must honour, and
//! `place check` / `place verify` fail when it does not, naming the parts, the pin and the distance.
//! `place pack` honours an anchor only in the simple case (members without their own wish follow
//! the anchor's wish, offsets kept); every other entry is checked, never acted on.
//!
//! ```json
//! "placement": {
//! "proximity": [{"ref": "D2", "nearRef": "U4", "pin": "5", "maxMm": 5}],
//! "cluster": [{"name": "buck", "refs": ["U4", "C7", "L1"], "radiusMm": 6, "anchor": "U4"},
//! {"refs": ["J1", "D1"], "region": "connector-edge"}],
//! "regions": {"connector-edge": [[0, 0], [8, 0], [8, 30], [0, 30]]},
//! "anchor": [{"ref": "U4", "cluster": "buck"}]
//! }
//! ```
//!
//! - proximity: pad edge to pad edge, the closest copper of `ref` to `nearRef`'s pad `pin` (or to
//! any pad of `nearRef` when `pin` is absent).
//! - cluster with `radiusMm`: every member's pad-box centre within the radius of the anchor's
//! pad-box centre, or of the members' mean centre when there is no anchor.
//! - cluster with `region`: every pad of every member inside the region (a name in
//! `placement.regions`, or a polygon `[[x, y], ...]` in board mm).
use aiflow_board::{geom, Board, Pt};
use serde_json::{json, Value};
use crate::Wish;
#[derive(Clone, Debug, PartialEq)]
pub struct Proximity {
pub reference: String,
pub near_ref: String,
pub pin: Option<String>,
pub max_mm: f64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Region {
pub name: String,
pub poly: Vec<Pt>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Cluster {
pub name: String,
pub refs: Vec<String>,
pub radius_mm: Option<f64>,
pub region: Option<Region>,
pub anchor: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Intent {
pub proximity: Vec<Proximity>,
pub clusters: Vec<Cluster>,
}
impl Intent {
pub fn is_empty(&self) -> bool {
self.proximity.is_empty() && self.clusters.is_empty()
}
}
/// One checked entry.
#[derive(Clone, Debug)]
pub struct Verdict {
pub kind: &'static str,
pub name: String,
pub pass: bool,
pub measured_mm: Option<f64>,
pub limit_mm: Option<f64>,
pub text: String,
}
pub const SHAPE: &str = "placement.proximity: [{\"ref\": \"D2\", \"nearRef\": \"U4\", \"pin\": \"5\", \"maxMm\": 5}] (pad edge to pad edge); placement.cluster: [{\"name\": \"buck\", \"refs\": [\"U4\", \"C7\"], \"radiusMm\": 6 | \"region\": \"name\" or [[x, y], ...], \"anchor\": \"U4\"}]; placement.regions: {\"name\": [[x, y], ...]}; placement.anchor: [{\"ref\": \"U4\", \"cluster\": \"buck\"}].";
fn str_list(v: &Value) -> Option<Vec<String>> {
v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect())
}
fn as_items(v: Option<&Value>) -> Vec<Value> {
match v {
Some(Value::Array(a)) => a.clone(),
Some(o @ Value::Object(_)) => vec![o.clone()],
_ => Vec::new(),
}
}
fn poly_of(v: &Value) -> Option<Vec<Pt>> {
let a = v.as_array()?;
let pts: Vec<Pt> = a.iter().filter_map(|p| Some((p.get(0)?.as_f64()?, p.get(1)?.as_f64()?))).collect();
(pts.len() >= 3 && pts.len() == a.len()).then_some(pts)
}
/// The placement intent of a spec. An entry that does not parse, or names a region or cluster that
/// does not exist, is an error naming it.
pub fn from_spec(spec: &Value) -> Result<Intent, String> {
let p = spec.get("placement").cloned().unwrap_or(Value::Null);
let mut out = Intent::default();
for (k, e) in as_items(p.get("proximity")).iter().enumerate() {
let s = |key: &str| e.get(key).and_then(|v| v.as_str()).map(str::to_string);
let reference = s("ref").ok_or(format!("placement.proximity[{k}]: ref is required"))?;
let near_ref = s("nearRef").ok_or(format!("placement.proximity[{k}]: nearRef is required"))?;
let pin = match e.get("pin") {
None | Some(Value::Null) => None,
Some(Value::String(x)) => Some(x.clone()),
Some(Value::Number(n)) => Some(n.to_string()),
Some(_) => return Err(format!("placement.proximity[{k}]: pin is a pad name such as \"5\"")),
};
let max_mm = e.get("maxMm").and_then(|v| v.as_f64()).filter(|m| *m > 0.0).ok_or(format!("placement.proximity[{k}]: maxMm must be a positive distance in mm"))?;
out.proximity.push(Proximity { reference, near_ref, pin, max_mm });
}
let regions = p.get("regions").and_then(|r| r.as_object()).cloned().unwrap_or_default();
let cluster_items = as_items(p.get("cluster").or_else(|| p.get("clusters")));
for (k, e) in cluster_items.iter().enumerate() {
let refs = e.get("refs").and_then(str_list).filter(|r| r.len() >= 2).ok_or(format!("placement.cluster[{k}]: refs needs two or more parts"))?;
let name = e.get("name").and_then(|v| v.as_str()).map(str::to_string).unwrap_or_else(|| refs.join("+"));
let radius_mm = match e.get("radiusMm") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_f64().filter(|r| *r > 0.0).ok_or(format!("placement.cluster[{k}] {name}: radiusMm must be a positive distance in mm"))?),
};
let region = match e.get("region") {
None | Some(Value::Null) => None,
Some(Value::String(rn)) => {
let poly = regions.get(rn).and_then(poly_of).ok_or(format!("placement.cluster[{k}] {name}: region {rn:?} is not a polygon in placement.regions"))?;
Some(Region { name: rn.clone(), poly })
}
Some(v) => Some(Region { name: format!("{name} region"), poly: poly_of(v).ok_or(format!("placement.cluster[{k}] {name}: region is a name in placement.regions or a polygon [[x, y], ...]"))? }),
};
if radius_mm.is_none() && region.is_none() && e.get("anchor").is_none() {
return Err(format!("placement.cluster[{k}] {name}: give radiusMm or region (or an anchor)"));
}
let anchor = e.get("anchor").and_then(|v| v.as_str()).map(str::to_string);
if let Some(a) = &anchor {
if !refs.contains(a) {
return Err(format!("placement.cluster[{k}] {name}: anchor {a} is not one of its refs"));
}
}
out.clusters.push(Cluster { name, refs, radius_mm, region, anchor });
}
// placement.anchor: [{"ref", "cluster"}] or {"U4": "buck"}; {"ref", "refs": [...]} declares a
// group that follows the anchor in `place pack` without a radius to verify
let anchors: Vec<Value> = match p.get("anchor") {
Some(Value::Object(m)) if !m.contains_key("ref") => m.iter().map(|(r, c)| json!({"ref": r, "cluster": c})).collect(),
other => as_items(other),
};
for (k, e) in anchors.iter().enumerate() {
let a = e.get("ref").and_then(|v| v.as_str()).ok_or(format!("placement.anchor[{k}]: ref is required"))?.to_string();
if let Some(refs) = e.get("refs").and_then(str_list) {
let mut refs = refs;
if !refs.contains(&a) {
refs.insert(0, a.clone());
}
out.clusters.push(Cluster { name: format!("{a} group"), refs, radius_mm: None, region: None, anchor: Some(a) });
continue;
}
let c = e.get("cluster").and_then(|v| v.as_str()).ok_or(format!("placement.anchor[{k}] {a}: name its cluster (\"cluster\": \"buck\") or list \"refs\""))?;
let cl = out.clusters.iter_mut().find(|x| x.name == c).ok_or(format!("placement.anchor[{k}] {a}: no placement.cluster named {c:?}"))?;
if !cl.refs.contains(&a) {
return Err(format!("placement.anchor[{k}]: {a} is not in cluster {c}"));
}
if cl.anchor.as_deref().map(|x| x != a).unwrap_or(false) {
return Err(format!("placement.anchor[{k}]: cluster {c} already has anchor {}", cl.anchor.as_deref().unwrap_or("")));
}
cl.anchor = Some(a);
}
Ok(out)
}
fn r2(x: f64) -> f64 {
(x * 100.0).round() / 100.0
}
fn segs_cross(a: Pt, b: Pt, c: Pt, d: Pt) -> bool {
let o = |p: Pt, q: Pt, r: Pt| (q.0 - p.0) * (r.1 - p.1) - (q.1 - p.1) * (r.0 - p.0);
let (d1, d2, d3, d4) = (o(c, d, a), o(c, d, b), o(a, b, c), o(a, b, d));
((d1 > 0.0) != (d2 > 0.0)) && ((d3 > 0.0) != (d4 > 0.0))
}
/// Edge-to-edge distance of two polygons (0 when they touch or overlap).
pub fn poly_gap(pa: &[Pt], pb: &[Pt]) -> f64 {
let mut best = f64::INFINITY;
for v in pa {
best = best.min(geom::point_poly_distance(v.0, v.1, pb));
}
for v in pb {
best = best.min(geom::point_poly_distance(v.0, v.1, pa));
}
if best > 0.0 {
for i in 0..pa.len() {
for j in 0..pb.len() {
if segs_cross(pa[i], pa[(i + 1) % pa.len()], pb[j], pb[(j + 1) % pb.len()]) {
return 0.0;
}
}
}
}
best
}
fn centre(board: &Board, r: &str) -> Option<Pt> {
if let Some((x0, y0, x1, y1)) = board.pad_box(r) {
return Some(((x0 + x1) / 2.0, (y0 + y1) / 2.0));
}
board.footprints.get(r).map(|f| (f.x, f.y))
}
/// Check every intent entry on a board.
pub fn verify(board: &Board, intent: &Intent) -> Vec<Verdict> {
let mut out = Vec::new();
for p in &intent.proximity {
let target = match &p.pin { Some(pin) => format!("{} pin {pin}", p.near_ref), None => p.near_ref.clone() };
let name = format!("{} near {target}", p.reference);
let fail = |text: String| Verdict { kind: "proximity", name: name.clone(), pass: false, measured_mm: None, limit_mm: Some(p.max_mm), text };
let mine = board.pads_of(&p.reference);
if mine.is_empty() {
out.push(fail(format!("proximity {name}: {} is not on the board (or has no pads)", p.reference)));
continue;
}
let near: Vec<_> = board.pads_of(&p.near_ref).into_iter().filter(|q| p.pin.as_ref().map(|n| &q.name == n).unwrap_or(true)).collect();
if near.is_empty() {
out.push(fail(if board.pads_of(&p.near_ref).is_empty() { format!("proximity {name}: {} is not on the board", p.near_ref) } else { format!("proximity {name}: {} has no pad {}", p.near_ref, p.pin.as_deref().unwrap_or("")) }));
continue;
}
let mut best: Option<(f64, String, String)> = None;
for a in &mine {
for b in &near {
for pa in &a.polys {
for pb in &b.polys {
let d = poly_gap(pa, pb);
if best.as_ref().map(|x| d < x.0).unwrap_or(true) {
best = Some((d, a.key.clone(), b.key.clone()));
}
}
}
}
}
let (d, ka, kb) = best.unwrap_or((f64::INFINITY, String::new(), String::new()));
let pass = d <= p.max_mm + 1e-6;
let text = format!("proximity {name}: {:.2} mm pad edge to pad edge ({ka} to {kb}), {} maxMm {}", d, if pass { "within" } else { "over" }, p.max_mm);
out.push(Verdict { kind: "proximity", name, pass, measured_mm: Some(r2(d)), limit_mm: Some(p.max_mm), text });
}
for c in &intent.clusters {
let missing: Vec<&String> = c.refs.iter().filter(|r| centre(board, r).is_none()).collect();
if !missing.is_empty() {
out.push(Verdict { kind: "cluster", name: c.name.clone(), pass: false, measured_mm: None, limit_mm: c.radius_mm, text: format!("cluster {}: {} not on the board", c.name, missing.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")) });
continue;
}
if let Some(rad) = c.radius_mm {
let pts: Vec<(String, Pt)> = c.refs.iter().map(|r| (r.clone(), centre(board, r).unwrap())).collect();
let (ctr, from) = match &c.anchor {
Some(a) => (centre(board, a).unwrap(), format!("anchor {a}")),
None => ((pts.iter().map(|p| p.1 .0).sum::<f64>() / pts.len() as f64, pts.iter().map(|p| p.1 .1).sum::<f64>() / pts.len() as f64), "the members' mean centre".to_string()),
};
let dist: Vec<(String, f64)> = pts.iter().map(|(r, p)| (r.clone(), (p.0 - ctr.0).hypot(p.1 - ctr.1))).collect();
let worst = dist.iter().cloned().fold((String::new(), 0.0), |w, x| if x.1 > w.1 { x } else { w });
let over: Vec<String> = dist.iter().filter(|x| x.1 > rad + 1e-6).map(|x| format!("{} at {:.2} mm", x.0, x.1)).collect();
let pass = over.is_empty();
let text = if pass {
format!("cluster {}: every member within radiusMm {rad} of {from} (farthest {} at {:.2} mm)", c.name, worst.0, worst.1)
} else {
format!("cluster {}: {} outside radiusMm {rad} of {from} (pad-box centres)", c.name, over.join(", "))
};
out.push(Verdict { kind: "cluster", name: c.name.clone(), pass, measured_mm: Some(r2(worst.1)), limit_mm: Some(rad), text });
}
if let Some(reg) = &c.region {
let mut outside = Vec::new();
for r in &c.refs {
let bad: Vec<String> = board.pads_of(r).iter().filter(|p| p.polys.iter().flatten().any(|v| !geom::point_in_poly(v.0, v.1, ®.poly) && geom::point_poly_distance(v.0, v.1, ®.poly) > 1e-6)).map(|p| p.key.clone()).collect();
if !bad.is_empty() {
outside.push(format!("{r} ({})", bad.join(", ")));
}
}
let pass = outside.is_empty();
let text = if pass { format!("cluster {}: every member inside region {}", c.name, reg.name) } else { format!("cluster {}: pads outside region {}: {}", c.name, reg.name, outside.join("; ")) };
out.push(Verdict { kind: "region", name: c.name.clone(), pass, measured_mm: None, limit_mm: None, text });
}
if c.radius_mm.is_none() && c.region.is_none() {
out.push(Verdict { kind: "anchor", name: c.name.clone(), pass: true, measured_mm: None, limit_mm: None, text: format!("anchor group {}: intent only (no radius or region to verify)", c.name) });
}
}
out
}
/// The verdicts as a JSON report.
pub fn report(v: &[Verdict]) -> Value {
let failed = v.iter().filter(|x| !x.pass).count();
json!({"pass": failed == 0, "entries": v.len(), "failed": failed,
"verdicts": v.iter().map(|x| json!({"kind": x.kind, "name": x.name, "pass": x.pass, "measuredMm": x.measured_mm, "limitMm": x.limit_mm, "text": x.text})).collect::<Vec<_>>(),
"method": "proximity: closest pad copper edge to edge; cluster radius: pad-box centres from the anchor (or the members' mean centre); region: every pad corner inside the polygon"})
}
/// `place pack`: a member of an anchored cluster that has no wish of its own follows the anchor's
/// wish, keeping its present offset from the anchor (turned with the anchor when the anchor's wish
/// names one rotation). Members with their own wish keep it. Anything else is left to the AI.
pub fn apply_anchors(board: &Board, wishes: &[(String, Wish)], intent: &Intent) -> (Vec<(String, Wish)>, Vec<String>) {
let mut out = wishes.to_vec();
let mut lines = Vec::new();
for c in &intent.clusters {
let Some(a) = &c.anchor else { continue };
let Some(pos) = out.iter().position(|(k, _)| k == a) else {
lines.push(format!("anchor {a} ({}): not applied, the anchor has no wish", c.name));
continue;
};
let aw = out[pos].1.clone();
let Some(afp) = board.footprints.get(a) else { continue };
if aw.rot.len() != 1 {
lines.push(format!("anchor {a} ({}): not applied, its wish has {} rotations; give it one so the group can turn with it", c.name, aw.rot.len()));
continue;
}
let turn = aw.rot[0] - afp.rot;
let mut dragged = Vec::new();
let mut insert_at = pos + 1;
for m in &c.refs {
if m == a || out.iter().any(|(k, _)| k == m) {
continue;
}
let Some(fp) = board.footprints.get(m) else { continue };
let (dx, dy) = geom::rot_kicad(fp.x - afp.x, fp.y - afp.y, turn);
let rot = ((fp.rot + turn) % 360.0 + 360.0) % 360.0;
let w = Wish { x: r2(aw.x + dx), y: r2(aw.y + dy), rot: vec![rot], radius: 2.0, keepout: Vec::new(), gap: aw.gap };
out.insert(insert_at, (m.clone(), w));
insert_at += 1;
dragged.push(m.clone());
}
if dragged.is_empty() {
lines.push(format!("anchor {a} ({}): every member has its own wish; nothing dragged", c.name));
} else {
lines.push(format!("anchor {a} ({}): drags {} with it (offsets kept{})", c.name, dragged.join(", "), if turn.abs() > 1e-9 { format!(", turned {turn}") } else { String::new() }));
}
}
(out, lines)
}
#[cfg(test)]
mod tests {
use super::*;
/// A 40 x 30 mm board. U4: a 6-pad part at (10, 10), pads 1..6 at x = -1.25 / 1.25, y = -1, 0, 1.
/// Other parts: two pads 1 mm apart on x.
fn board(parts: &[(&str, f64, f64)]) -> Board {
let mut t = String::from("(kicad_pcb\n\t(version 20260206)\n\t(generator \"test\")\n\t(layers\n\t\t(0 \"F.Cu\" signal)\n\t\t(2 \"B.Cu\" signal)\n\t\t(25 \"Edge.Cuts\" user)\n\t)\n");
let pad = |name: &str, dx: f64, dy: f64| format!("\t\t(pad \"{name}\" smd rect\n\t\t\t(at {dx} {dy})\n\t\t\t(size 0.6 0.6)\n\t\t\t(layers \"F.Cu\")\n\t\t\t(net \"N{name}\")\n\t\t)\n");
t.push_str("\t(footprint \"T:U\"\n\t\t(layer \"F.Cu\")\n\t\t(at 10 10)\n\t\t(property \"Reference\" \"U4\")\n");
for (k, (dx, dy)) in [(-1.25, -1.0), (-1.25, 0.0), (-1.25, 1.0), (1.25, 1.0), (1.25, 0.0), (1.25, -1.0)].iter().enumerate() {
t.push_str(&pad(&(k + 1).to_string(), *dx, *dy));
}
t.push_str("\t)\n");
for (r, x, y) in parts {
t.push_str(&format!("\t(footprint \"T:R\"\n\t\t(layer \"F.Cu\")\n\t\t(at {x} {y})\n\t\t(property \"Reference\" \"{r}\")\n{}{}\t)\n", pad("1", -0.5, 0.0), pad("2", 0.5, 0.0)));
}
t.push_str("\t(gr_rect\n\t\t(start 0 0)\n\t\t(end 40 30)\n\t\t(layer \"Edge.Cuts\")\n\t)\n)\n");
Board::from_text("test.kicad_pcb", t).unwrap()
}
fn spec_d2() -> Value {
json!({"placement": {"proximity": [{"ref": "D2", "nearRef": "U4", "pin": "5", "maxMm": 5}]}})
}
#[test]
fn proximity_fails_with_the_distance_and_the_pin_named() {
let intent = from_spec(&spec_d2()).unwrap();
// U4 pin 5 is at (11.25, 10), its right edge at 11.55. D2 pad 1 left edge at x - 0.8.
// D2 at x = 20.35: pad 1 left edge 19.55, 8.00 mm from pin 5's edge
let v = verify(&board(&[("D2", 20.35, 10.0)]), &intent);
assert_eq!(v.len(), 1);
assert!(!v[0].pass, "{:?}", v[0]);
assert_eq!(v[0].measured_mm, Some(8.0));
assert!(v[0].text.contains("8.00 mm") && v[0].text.contains("U4 pin 5") && v[0].text.contains("U4.5") && v[0].text.contains("over maxMm 5"), "{}", v[0].text);
// at 4 mm it passes
let v = verify(&board(&[("D2", 16.35, 10.0)]), &intent);
assert!(v[0].pass, "{}", v[0].text);
assert_eq!(v[0].measured_mm, Some(4.0));
// the pin matters: pin 2 is on U4's far side
let far = from_spec(&json!({"placement": {"proximity": [{"ref": "D2", "nearRef": "U4", "pin": 2, "maxMm": 5}]}})).unwrap();
let v = verify(&board(&[("D2", 16.35, 10.0)]), &far);
assert!(!v[0].pass && v[0].text.contains("U4 pin 2"), "{}", v[0].text);
// a missing pin or part is a failure that says so
let bad = from_spec(&json!({"placement": {"proximity": [{"ref": "D2", "nearRef": "U4", "pin": "9", "maxMm": 5}, {"ref": "D9", "nearRef": "U4", "maxMm": 5}]}})).unwrap();
let v = verify(&board(&[("D2", 16.35, 10.0)]), &bad);
assert!(v[0].text.contains("has no pad 9") && !v[0].pass);
assert!(v[1].text.contains("D9 is not on the board") && !v[1].pass);
}
#[test]
fn overlapping_pads_are_zero_apart() {
let a = vec![(0.0, -1.0), (1.0, -1.0), (1.0, 1.0), (0.0, 1.0)];
let b = vec![(-1.0, 0.0), (2.0, 0.0), (2.0, 0.5), (-1.0, 0.5)];
assert_eq!(poly_gap(&a, &b), 0.0);
let c = vec![(3.0, 0.0), (4.0, 0.0), (4.0, 1.0), (3.0, 1.0)];
assert!((poly_gap(&a, &c) - 2.0).abs() < 1e-9);
}
#[test]
fn clusters_by_radius_and_region() {
let spec = json!({"placement": {
"cluster": [{"name": "buck", "refs": ["U4", "C1", "C2"], "radiusMm": 4, "anchor": "U4"},
{"name": "edge", "refs": ["C1", "C2"], "region": "left"}],
"regions": {"left": [[0, 0], [15, 0], [15, 30], [0, 30]]}}});
let intent = from_spec(&spec).unwrap();
let v = verify(&board(&[("C1", 12.0, 13.0), ("C2", 14.0, 10.0)]), &intent);
assert!(v.iter().all(|x| x.pass), "{v:?}");
let v = verify(&board(&[("C1", 12.0, 13.0), ("C2", 16.0, 10.0)]), &intent);
let rad = v.iter().find(|x| x.kind == "cluster").unwrap();
assert!(!rad.pass && rad.text.contains("C2 at 6.00 mm") && rad.text.contains("anchor U4"), "{}", rad.text);
let reg = v.iter().find(|x| x.kind == "region").unwrap();
assert!(!reg.pass && reg.text.contains("C2 (C2.1, C2.2)") && !reg.text.contains("C1"), "{}", reg.text);
// without an anchor the centre is the members' mean
let mean = from_spec(&json!({"placement": {"cluster": [{"refs": ["C1", "C2"], "radiusMm": 2}]}})).unwrap();
let v = verify(&board(&[("C1", 10.0, 20.0), ("C2", 13.0, 20.0)]), &mean);
assert!(v[0].pass, "{}", v[0].text);
let v = verify(&board(&[("C1", 10.0, 20.0), ("C2", 15.0, 20.0)]), &mean);
assert!(!v[0].pass && v[0].text.contains("mean centre"), "{}", v[0].text);
}
#[test]
fn bad_entries_are_named() {
for (s, want) in [
(json!({"placement": {"proximity": [{"ref": "D2", "nearRef": "U4", "maxMm": 0}]}}), "maxMm"),
(json!({"placement": {"proximity": [{"nearRef": "U4", "maxMm": 2}]}}), "ref is required"),
(json!({"placement": {"cluster": [{"refs": ["A"], "radiusMm": 2}]}}), "two or more"),
(json!({"placement": {"cluster": [{"refs": ["A", "B"]}]}}), "radiusMm or region"),
(json!({"placement": {"cluster": [{"refs": ["A", "B"], "region": "nowhere"}]}}), "nowhere"),
(json!({"placement": {"cluster": [{"refs": ["A", "B"], "radiusMm": 2, "anchor": "C"}]}}), "anchor C"),
(json!({"placement": {"anchor": [{"ref": "U4", "cluster": "buck"}]}}), "no placement.cluster named"),
] {
let e = from_spec(&s).unwrap_err();
assert!(e.contains(want), "{e}");
}
assert!(from_spec(&json!({})).unwrap().is_empty());
assert!(from_spec(&json!({"placement": {"edgeConnectors": {}}})).unwrap().is_empty());
// placement.anchor names the cluster's anchor; the map form works too
let i = from_spec(&json!({"placement": {"cluster": [{"name": "buck", "refs": ["U4", "C1"], "radiusMm": 3}], "anchor": {"U4": "buck"}}})).unwrap();
assert_eq!(i.clusters[0].anchor.as_deref(), Some("U4"));
}
#[test]
fn pack_drags_unwished_members_with_the_anchor() {
let b = board(&[("C1", 12.0, 13.0), ("C2", 14.0, 10.0), ("C3", 30.0, 20.0)]);
let intent = from_spec(&json!({"placement": {"anchor": [{"ref": "U4", "refs": ["C1", "C2", "C3"]}]}})).unwrap();
let wish = |x, y, r: f64| Wish { x, y, rot: vec![r], radius: 3.0, keepout: Vec::new(), gap: None };
let wishes = vec![("U4".to_string(), wish(20.0, 15.0, 0.0)), ("C3".to_string(), wish(5.0, 5.0, 0.0))];
let (out, lines) = apply_anchors(&b, &wishes, &intent);
let get = |r: &str| out.iter().find(|(k, _)| k == r).map(|(_, w)| w.clone()).unwrap();
assert_eq!((get("C1").x, get("C1").y), (22.0, 18.0));
assert_eq!((get("C2").x, get("C2").y), (24.0, 15.0));
assert_eq!((get("C3").x, get("C3").y), (5.0, 5.0), "a member with its own wish keeps it");
assert_eq!(out[1].0, "C1", "dragged members pack right after their anchor");
assert!(lines[0].contains("drags C1, C2"), "{lines:?}");
// the anchor turned 90: the offsets turn with it (KiCad rotation, y down)
let (out, _) = apply_anchors(&b, &[("U4".to_string(), wish(20.0, 15.0, 90.0))], &intent);
let c2 = out.iter().find(|(k, _)| k == "C2").unwrap().1.clone();
assert_eq!((c2.x, c2.y, c2.rot[0]), (20.0, 11.0, 90.0));
// several rotations on the anchor: not applied, said
let (out, lines) = apply_anchors(&b, &[("U4".to_string(), Wish { x: 20.0, y: 15.0, rot: vec![0.0, 90.0], radius: 3.0, keepout: Vec::new(), gap: None })], &intent);
assert_eq!(out.len(), 1);
assert!(lines[0].contains("not applied"));
}
}