main
John Lauer Publish 0.2.3 6e27c4b
//! 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, &reg.poly) && geom::point_poly_distance(v.0, v.1, &reg.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"));
    }
}