main
Caleb Publish 0.5.0 1984b40
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797
//! The visible layers, read from the photos instead of library footprints. `layers trace` writes
//! photo-layers.json: silkscreen as exact polygons on both faces, the shape of every exposed pad, exposed metal no
//! footprint explains, every hole, and the outline. `build` draws from it: footprints keep their pad numbers so nets
//! still work, but a pad the photo shows takes the photographed shape, and library silk goes.
use crate::gerber::Frame;
use crate::kicad_out::{self, Pad};
use crate::photomat::{self, Face};
use crate::raster::{self, Mask, Plane};
use crate::sexp::{a, f, l, q, N};
use crate::{board, geom};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::path::Path;

type P = (f64, f64);
pub const FILE: &str = "photo-layers.json";

/// Closed curves where `f` crosses zero, inside on the screen left, in pixel coordinates (pixel (u, v) has its value at
/// (u + 0.5, v + 0.5)). Outer curves come out with a negative shoelace sum on y-down axes, holes positive.
pub fn contours(fp: &Plane) -> Vec<Vec<P>> {
    let (w, h) = (fp.w as i64, fp.h as i64);
    let at = |x: i64, y: i64| -> f32 { if x < 0 || y < 0 || x >= w || y >= h { -1.0 } else { fp.d[(y * w + x) as usize] } };
    // an edge id: horizontal edge from (x, y) to (x + 1, y), vertical edge from (x, y) to (x, y + 1)
    let hid = |x: i64, y: i64| ((x + 1) * (h + 2) + (y + 1)) as u64 * 2;
    let vid = |x: i64, y: i64| ((x + 1) * (h + 2) + (y + 1)) as u64 * 2 + 1;
    let mut next: HashMap<u64, u64> = HashMap::new();
    let mut extra: Vec<(u64, u64)> = vec![];
    let mut pos: HashMap<u64, P> = HashMap::new();
    let cross = |a: f32, b: f32| -> f64 { let t = a as f64 / (a as f64 - b as f64); t.clamp(0.02, 0.98) };
    for y in -1..h { for x in -1..w {
        let (fa, fb, fc, fd) = (at(x, y), at(x + 1, y), at(x + 1, y + 1), at(x, y + 1));
        let k = (fa > 0.0) as u8 | ((fb > 0.0) as u8) << 1 | ((fc > 0.0) as u8) << 2 | ((fd > 0.0) as u8) << 3;
        if k == 0 || k == 15 { continue; }
        let (t, r, b, lf) = (hid(x, y), vid(x + 1, y), hid(x, y + 1), vid(x, y));
        let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
        let mut put = |id: u64, p: P| { pos.entry(id).or_insert(p); };
        if (fa > 0.0) != (fb > 0.0) { put(t, (px + cross(fa, fb), py)); }
        if (fb > 0.0) != (fc > 0.0) { put(r, (px + 1.0, py + cross(fb, fc))); }
        if (fd > 0.0) != (fc > 0.0) { put(b, (px + cross(fd, fc), py + 1.0)); }
        if (fa > 0.0) != (fd > 0.0) { put(lf, (px, py + cross(fa, fd))); }
        let centre = (fa + fb + fc + fd) > 0.0;
        let segs: &[(u64, u64)] = &match k {
            1 => vec![(lf, t)], 2 => vec![(t, r)], 3 => vec![(lf, r)], 4 => vec![(r, b)],
            5 => if centre { vec![(r, t), (lf, b)] } else { vec![(lf, t), (r, b)] },
            6 => vec![(t, b)], 7 => vec![(lf, b)], 8 => vec![(b, lf)], 9 => vec![(b, t)],
            10 => if centre { vec![(t, lf), (b, r)] } else { vec![(t, r), (b, lf)] },
            11 => vec![(b, r)], 12 => vec![(r, lf)], 13 => vec![(r, t)], _ => vec![(t, lf)],
        };
        for (s, e) in segs { if next.insert(*s, *e).is_some() { extra.push((*s, *e)); } }
    } }
    let mut rings = vec![];
    let mut starts: Vec<u64> = next.keys().copied().collect(); starts.sort_unstable();
    for s in starts {
        if !next.contains_key(&s) { continue; }
        let mut ring = vec![]; let mut cur = s;
        while let Some(n) = next.remove(&cur) { if let Some(p) = pos.get(&cur) { ring.push(*p); } cur = n; if cur == s { break; } }
        if ring.len() >= 3 { rings.push(ring); }
    }
    rings
}

pub fn shoelace(r: &[P]) -> f64 { (0..r.len()).map(|i| { let (a, b) = (r[i], r[(i + 1) % r.len()]); a.0 * b.1 - b.0 * a.1 }).sum::<f64>() / 2.0 }

fn point_in(r: &[P], p: P) -> bool {
    let mut c = false;
    for i in 0..r.len() { let (a, b) = (r[i], r[(i + 1) % r.len()]); if (a.1 > p.1) != (b.1 > p.1) && p.0 < a.0 + (p.1 - a.1) / (b.1 - a.1) * (b.0 - a.0) { c = !c; } }
    c
}

fn px_mm(p: P) -> P { let m = board::to_mm(p.0, p.1); ((m.0 * 10000.0).round() / 10000.0, (m.1 * 10000.0).round() / 10000.0) }

/// Rings to mm polygons with their holes bridged in, smallest features under `min_area` px dropped.
fn polygons(rings: Vec<Vec<P>>, tol: f64, min_area: f64) -> Vec<Vec<P>> {
    let (mut outers, mut holes): (Vec<Vec<P>>, Vec<Vec<P>>) = (vec![], vec![]);
    for r in rings { let s = shoelace(&r); if s.abs() < min_area { continue; } if s < 0.0 { outers.push(r); } else { holes.push(r); } }
    let mut owned: Vec<Vec<Vec<P>>> = vec![vec![]; outers.len()];
    let box_of = |r: &[P]| r.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, p| (b.0.min(p.0), b.1.min(p.1), b.2.max(p.0), b.3.max(p.1)));
    let boxes: Vec<_> = outers.iter().map(|r| box_of(r)).collect();
    let areas: Vec<f64> = outers.iter().map(|r| shoelace(r).abs()).collect();
    for h in holes {
        let p = h[0];
        let mut best: Option<usize> = None;
        for (i, r) in outers.iter().enumerate() { let b = boxes[i]; if p.0 < b.0 || p.0 > b.2 || p.1 < b.1 || p.1 > b.3 { continue; } if point_in(r, p) && best.map(|k| areas[i] < areas[k]).unwrap_or(true) { best = Some(i); } }
        if let Some(i) = best { owned[i].push(h); }
    }
    outers.into_iter().zip(owned).map(|(o, hs)| {
        let o = geom::simplify(&o, tol);
        let hs: Vec<Vec<P>> = hs.into_iter().map(|h| geom::simplify(&h, tol)).filter(|h| h.len() >= 3).collect();
        kicad_out::keyhole(o, hs).into_iter().map(px_mm).collect()
    }).filter(|r: &Vec<P>| r.len() >= 3).collect()
}

fn ring_json(r: &[P]) -> Value { json!(r.iter().map(|p| json!([p.0, p.1])).collect::<Vec<_>>()) }
fn ring_of(v: &Value) -> Vec<P> { v.as_array().into_iter().flatten().map(|p| (p[0].as_f64().unwrap_or(0.0), p[1].as_f64().unwrap_or(0.0))).collect() }

/// Silk polygons: the luminance half way between the mask and the silk, inside each kept component.
fn silk_polys(fc: &Face) -> Vec<Vec<P>> {
    let mut field = Plane::new(fc.w, fc.h, -1.0);
    for i in 0..fc.w * fc.h {
        let v = fc.y.d[i] - (fc.ym.d[i] + 0.5 * (fc.silk_level - fc.ym.d[i]));
        field.d[i] = if fc.cls[i] == photomat::SILK { v.max(1.0) } else { v.min(-1.0) };
    }
    // A hair-thin saddle should not join two letters: the rings below are what the class map draws, with the edge
    // placed by luminance between neighbouring pixels.
    polygons(contours(&field), 0.3, 12.0)
}

/// Where the classifier sees metal, with holes filled in so a pad's outline is its outer edge.
fn metal_mask(fc: &Face) -> Mask {
    let m = Mask { w: fc.w, h: fc.h, d: fc.cls.iter().map(|c| *c == photomat::GOLD || *c == photomat::SILVER).collect() };
    let hole = Mask { w: fc.w, h: fc.h, d: fc.cls.iter().map(|c| *c == photomat::HOLE).collect() };
    m.or(&hole).close(1.0)
}

#[derive(Clone, Debug)]
pub struct HoleM { pub x: f64, pub y: f64, pub d: f64, pub faces: u8, pub ringed: bool }

/// Every hole either face shows, in mm, centred by area. Seen on both faces, the mean.
fn holes_of(top: &Face, bot: &Face) -> Vec<HoleM> {
    let grab = |fc: &Face| -> Vec<(f64, f64, f64, bool)> {
        let m = Mask { w: fc.w, h: fc.h, d: fc.cls.iter().map(|c| *c == photomat::HOLE).collect() };
        let metal = Mask { w: fc.w, h: fc.h, d: fc.cls.iter().map(|c| *c == photomat::GOLD || *c == photomat::SILVER).collect() }.dilate(2.0);
        let (_, cs) = raster::components(&m);
        cs.iter().map(|c| {
            let (cx, cy) = (c.cx + 0.5, c.cy + 0.5); let r = (c.area as f64 / std::f64::consts::PI).sqrt();
            let on = (0..32).filter(|k| { let t = *k as f64 / 32.0 * std::f64::consts::TAU; let (u, v) = (cx + (r + 3.0) * t.cos(), cy + (r + 3.0) * t.sin()); u >= 0.0 && v >= 0.0 && (u as usize) < fc.w && (v as usize) < fc.h && metal.at(u as usize, v as usize) }).count();
            let (x, y) = board::to_mm(cx, cy);
            (x, y, 2.0 * r / board::RES, on >= 16)
        }).collect()
    };
    let (ta, tb) = (grab(top), grab(bot));
    let mut out = vec![]; let mut used = vec![false; tb.len()];
    for t in &ta {
        let m = (0..tb.len()).filter(|i| !used[*i]).min_by(|i, j| geom::dist((t.0, t.1), (tb[*i].0, tb[*i].1)).partial_cmp(&geom::dist((t.0, t.1), (tb[*j].0, tb[*j].1))).unwrap());
        match m.filter(|i| geom::dist((t.0, t.1), (tb[*i].0, tb[*i].1)) < 0.6) {
            Some(i) => { used[i] = true; let b = tb[i]; out.push(HoleM { x: (t.0 + b.0) / 2.0, y: (t.1 + b.1) / 2.0, d: (t.2 + b.2) / 2.0, faces: 3, ringed: t.3 || b.3 }); }
            None => out.push(HoleM { x: t.0, y: t.1, d: t.2, faces: 1, ringed: t.3 }),
        }
    }
    for (i, b) in tb.iter().enumerate() { if !used[i] { out.push(HoleM { x: b.0, y: b.1, d: b.2, faces: 2, ringed: b.3 }); } }
    out
}

/// One board edge read from a photo with a margin around it: the place wood turns to board, every 5 px, as a line.
/// Under this share of samples agreeing on a line, an edge is not read. The CM5 dark-mat photos with the wood test gave under 0.02 on every edge; read edges on the Driver give over 0.8.
const EDGE_MIN_SHARE: f64 = 0.3;

/// What the table and the board look like in this photo: the median colour of the margin outside the frame and of a band inside the edges.
/// The table can be wood, a dark mat or anything else; each pixel then goes with whichever it is nearer to.
fn side_model(img: &image::RgbImage, m: i64, wf: f64, hf: f64) -> ([f32; 3], [f32; 3]) {
    let (w, h) = (img.width() as i64, img.height() as i64);
    let (mut out, mut inn): (Vec<[f32; 3]>, Vec<[f32; 3]>) = (vec![], vec![]);
    let (x1, y1) = (m + wf as i64, m + hf as i64);
    let mut y = 0; while y < h { let mut x = 0; while x < w {
        let p = img.get_pixel(x as u32, y as u32); let c = [p[0] as f32, p[1] as f32, p[2] as f32];
        let outside = x < m - 12 || y < m - 12 || x > x1 + 12 || y > y1 + 12;
        let d_in = (x - m).min(y - m).min(x1 - x).min(y1 - y);
        // the warp leaves pure black past the photo's own edge: that is no table
        if outside && c.iter().sum::<f32>() > 6.0 { out.push(c); } else if (40..=120).contains(&d_in) { inn.push(c); }
        x += 3; } y += 3; }
    let med = |v: &mut Vec<[f32; 3]>| -> [f32; 3] { if v.is_empty() { return [0.0; 3]; } let mut r = [0.0; 3]; for k in 0..3 { let mut c: Vec<f32> = v.iter().map(|q| q[k]).collect(); c.sort_by(|a, b| a.partial_cmp(b).unwrap()); r[k] = c[c.len() / 2]; } r };
    (med(&mut out), med(&mut inn))
}

/// Mean colour of the 3 x 3 round a point, nearer the board's colour than the table's.
fn board_like(img: &image::RgbImage, x: i64, y: i64, model: &([f32; 3], [f32; 3])) -> Option<bool> {
    let (w, h) = (img.width() as i64, img.height() as i64);
    if x < 1 || y < 1 || x + 1 >= w || y + 1 >= h { return None; }
    let mut c = [0f32; 3]; for dy in -1..=1 { for dx in -1..=1 { let p = img.get_pixel((x + dx) as u32, (y + dy) as u32); for k in 0..3 { c[k] += p[k] as f32 / 9.0; } } }
    if c.iter().sum::<f32>() <= 6.0 { return None; }
    let d = |q: &[f32; 3]| (0..3).map(|k| (c[k] - q[k]).powi(2)).sum::<f32>();
    Some(d(&model.1) < d(&model.0))
}

/// Both tests read each edge, wood (table redder than the board) and the photo's own colour model; the one more samples agree on stands.
/// Where wood reads well, as on the Driver photos, the result is the wood reading.
fn edge_line(img: &image::RgbImage, m: i64, along_x: bool, near: bool, wf: f64, hf: f64) -> Option<(f64, f64, usize, usize, f64)> {
    let model = side_model(img, m, wf, hf);
    let wood = edge_line_by(img, m, along_x, near, wf, hf, &|x, y| { let (w, h) = (img.width() as i64, img.height() as i64); if x < 0 || y < 0 || x >= w || y >= h { return None; } let mut s = 0.0; for dy in -1..=1 { for dx in -1..=1 { let (u, v) = ((x + dx).clamp(0, w - 1), (y + dy).clamp(0, h - 1)); let p = img.get_pixel(u as u32, v as u32); s += p[0] as f32 - p[1] as f32; } } Some(s / 9.0 < 15.0) }, false);
    let own = edge_line_by(img, m, along_x, near, wf, hf, &|x, y| board_like(img, x, y, &model), true);
    match (wood, own) { (Some(a), Some(b)) => Some(if a.2 >= b.2 { a } else { b }), (a, b) => a.or(b) }
}

fn edge_line_by(img: &image::RgbImage, m: i64, along_x: bool, near: bool, wf: f64, hf: f64, like: &dyn Fn(i64, i64) -> Option<bool>, need_run: bool) -> Option<(f64, f64, usize, usize, f64)> {
    let _ = img;
    let span = if along_x { wf } else { hf };
    let mut pts = vec![];
    let (ends, wall) = (120.0, if along_x { if near { 0.0 } else { hf } } else if near { 0.0 } else { wf });
    let mut t = ends;
    while t < span - ends {
        // walk from outside the board inwards to the first place wood stops
        let mut found = None;
        let dir = if near { 1.0 } else { -1.0 };
        let mut s = -(m as f64) + 4.0;
        let mut prev = None;
        while s < 40.0 {
            let q = wall + dir * s;
            let (x, y) = if along_x { (t + m as f64, q + m as f64) } else { (q + m as f64, t + m as f64) };
            let now = like(x as i64, y as i64);
            // table, then board three pixels running: the edge
            if prev == Some(false) && now == Some(true) {
                let run = !need_run || (1..=3).all(|k| { let q2 = q + dir * k as f64; let (x2, y2) = if along_x { (t + m as f64, q2 + m as f64) } else { (q2 + m as f64, t + m as f64) }; like(x2 as i64, y2 as i64) == Some(true) });
                if run { found = Some(q); break; }
            }
            prev = now;
            s += 1.0;
        }
        if let Some(q) = found { pts.push((t, q)); }
        t += 5.0;
    }
    let tot = (((span - 2.0 * ends) / 5.0) as usize).max(1);
    let (sl, ic) = geom::robust_line(&pts, 2.0)?;
    let keep: Vec<P> = pts.iter().filter(|p| (p.1 - (sl * p.0 + ic)).abs() < 3.0).copied().collect();
    let bow = bow_of(&keep);
    Some((sl, ic, keep.len(), tot, bow))
}

/// How far the middle of an edge stands off the straight line through its ends, in mm: what lens distortion does to a straight edge.
fn bow_of(pts: &[P]) -> f64 {
    if pts.len() < 10 { return 0.0; }
    let (t0, t1) = (pts[0].0, pts[pts.len() - 1].0);
    let third = (t1 - t0) / 3.0;
    let mean = |a: f64, b: f64| { let v: Vec<f64> = pts.iter().filter(|p| p.0 >= a && p.0 < b).map(|p| p.1).collect(); v.iter().sum::<f64>() / v.len().max(1) as f64 };
    (mean(t0 + third, t1 - third) - (mean(t0, t0 + third) + mean(t1 - third, t1 + 1.0)) / 2.0) * 9.0 / 8.0 / board::RES
}

/// Read the outline from one side's photo, registered to the top frame, with a margin kept.
fn edges_from(flow: &Path, side: &str, q: Option<&crate::regframe::Quad>, prep: &Value, scale: &Value) -> Result<Vec<(String, (f64, f64, usize, usize, f64))>, String> {
    let file = prep["primary"][side].as_str().ok_or("no primary photo")?;
    edges_of_file(flow, file, side, q, prep, scale).map(|x| x.0)
}

/// The same reading for any photo of a side, with the corner radius seen along each diagonal.
fn edges_of_file(flow: &Path, file: &str, side: &str, q: Option<&crate::regframe::Quad>, prep: &Value, scale: &Value) -> Result<(Vec<(String, (f64, f64, usize, usize, f64))>, Vec<f64>), String> {
    let rec = prep["photos"].as_array().into_iter().flatten().find(|p| p["file"].as_str() == Some(file)).ok_or("photo not in prep.json")?;
    let c = rec["corners"].as_array().ok_or("no corners")?;
    let cn: [P; 4] = [0, 1, 2, 3].map(|i| (c[i][0].as_f64().unwrap_or(0.0), c[i][1].as_f64().unwrap_or(0.0)));
    let (wmm, hmm) = (scale["board_mm"][0].as_f64().unwrap_or(0.0), scale["board_mm"][1].as_f64().unwrap_or(0.0));
    let axis = scale["frame"]["bottomFlip"].as_str().unwrap_or("y");
    let shift = if side == "bottom" { (scale["alignment"]["shift_mm"][0].as_f64().unwrap_or(0.0) * board::RES, scale["alignment"]["shift_mm"][1].as_f64().unwrap_or(0.0) * board::RES) } else { (0.0, 0.0) };
    let dst = board::frame_corners(wmm, hmm, side, axis).map(|p| (p.0 + shift.0, p.1 + shift.1));
    let m = 60i64;
    let (fw, fh) = (wmm * board::RES, hmm * board::RES);
    let img = crate::regframe::warp(&flow.join("photos").join(file), &cn, &dst, q, (fw.round() as i64 + 2 * m) as u32, (fh.round() as i64 + 2 * m) as u32, m)?;
    let mut out = vec![];
    for (name, ax, near) in [("top", true, true), ("bottom", true, false), ("left", false, true), ("right", false, false)] {
        if let Some(l) = edge_line(&img, m, ax, near, fw, fh) { out.push((name.to_string(), l)); }
    }
    // each corner: where the fitted edges meet, then along the diagonal to the first board pixel
    let model = side_model(&img, m, fw, fh);
    // the corner walk uses the wood test where wood reads this photo's edges well, the photo's own colour model elsewhere
    let wood_ok = { let wl = |p: &image::Rgb<u8>| p[0] as f32 - p[1] as f32; let _ = wl; let mut n = 0; for (ax, near) in [(true, true), (true, false), (false, true), (false, false)] { if let Some(l) = edge_line_by(&img, m, ax, near, fw, fh, &|x, y| { let (w, h) = (img.width() as i64, img.height() as i64); if x < 0 || y < 0 || x >= w || y >= h { return None; } let mut s = 0.0; for dy in -1..=1 { for dx in -1..=1 { let (u, v) = ((x + dx).clamp(0, w - 1), (y + dy).clamp(0, h - 1)); let p = img.get_pixel(u as u32, v as u32); s += p[0] as f32 - p[1] as f32; } } Some(s / 9.0 < 15.0) }, false) { if l.2 as f64 >= 0.6 * l.3 as f64 { n += 1; } } } n >= 2 };
    let line = |n: &str| out.iter().find(|e| e.0 == n).map(|e| (e.1 .0, e.1 .1));
    let mut radii = vec![];
    if let (Some(t), Some(bo), Some(lf), Some(r)) = (line("top"), line("bottom"), line("left"), line("right")) {
        let meet = |h: (f64, f64), v: (f64, f64)| -> P { let x = (v.0 * h.1 + v.1) / (1.0 - v.0 * h.0); (x, h.0 * x + h.1) };
        for (c, sx, sy) in [(meet(t, lf), 1.0, 1.0), (meet(t, r), -1.0, 1.0), (meet(bo, r), -1.0, -1.0), (meet(bo, lf), 1.0, -1.0)] {
            let mut d = 0.0; let mut hit = None;
            while d < 120.0 {
                let (u, v) = (c.0 + sx * d * std::f64::consts::FRAC_1_SQRT_2 + m as f64, c.1 + sy * d * std::f64::consts::FRAC_1_SQRT_2 + m as f64);
                if wood_ok { if u >= 1.0 && v >= 1.0 && (u as u32) + 1 < img.width() && (v as u32) + 1 < img.height() { let mut wn = 0.0; for dy in 0..3u32 { for dx in 0..3u32 { let p = img.get_pixel(u as u32 + dx - 1, v as u32 + dy - 1); wn += p[0] as f64 - p[1] as f64; } } if wn / 9.0 < 15.0 { hit = Some(d); break; } } }
                else if board_like(&img, u as i64, v as i64, &model) == Some(true) { hit = Some(d); break; }
                d += 1.0;
            }
            if let Some(h) = hit { radii.push(((h / (std::f64::consts::SQRT_2 - 1.0)) / board::RES * 100.0).round() / 100.0); }
        }
    }
    Ok((out, radii))
}

/// The drawn Edge.Cuts against each photo's edges, the held-out second photos included.
pub fn outline_check(flow: &Path, edge_gerber: &str, prep: &Value, scale: &Value) -> Result<Value, String> {
    let m = 60.0f64;
    let (wmm, hmm) = (scale["board_mm"][0].as_f64().unwrap_or(0.0), scale["board_mm"][1].as_f64().unwrap_or(0.0));
    let (fw, fh) = (wmm * board::RES, hmm * board::RES);
    let fr = Frame { w: (fw + 2.0 * m) as usize, h: (fh + 2.0 * m) as usize, res: board::RES, ox: board::ORIGIN - m / board::RES, oy: board::ORIGIN - m / board::RES };
    let drawn = crate::gerber::rasterize(edge_gerber, &fr)?;
    let (w, h) = (fr.w as i64, fr.h as i64);
    // first drawn pixel from outside along a line at t on an edge
    let hit = |name: &str, t: f64| -> Option<f64> {
        let (along_x, near) = match name { "top" => (true, true), "bottom" => (true, false), "left" => (false, true), _ => (false, false) };
        let (lim, wall) = if along_x { (h, if near { 0.0 } else { fh }) } else { (w, if near { 0.0 } else { fw }) };
        let dir = if near { 1.0 } else { -1.0 };
        let mut s = -m + 1.0;
        while s < 60.0 { let q = wall + dir * s + m; let (x, y) = if along_x { (t + m, q) } else { (q, t + m) }; if q >= 0.0 && (q as i64) < lim && x >= 0.0 && y >= 0.0 && (x as i64) < w && (y as i64) < h && drawn.at(x as usize, y as usize) { return Some(wall + dir * s); } s += 1.0; }
        None
    };
    let mut out = serde_json::Map::new();
    let mut files: Vec<(String, String, Option<crate::regframe::Quad>)> = vec![];
    for p in prep["photos"].as_array().into_iter().flatten() {
        let (file, side) = (p["file"].as_str().unwrap_or(""), p["side"].as_str().unwrap_or("top"));
        let q = photo_quad(flow, file, side, prep, scale, None).map(|x| x.1);
        files.push((file.to_string(), side.to_string(), q));
    }
    let mut worst = 0f64;
    for (file, side, q) in &files {
        let (edges, radii) = edges_of_file(flow, file, side, q.as_ref(), prep, scale)?;
        let mut row = serde_json::Map::new();
        for (name, l) in &edges {
            if (l.2 as f64) < 0.5 * l.3 as f64 { row.insert(name.clone(), json!({"blocked": true})); continue; }
            let span = if name == "top" || name == "bottom" { fw } else { fh };
            let mut v: Vec<f64> = (1..10).filter_map(|k| { let t = span * k as f64 / 10.0; hit(name, t).map(|d| (l.0 * t + l.1 - d).abs() / board::RES) }).collect();
            v.sort_by(|a, b| a.partial_cmp(b).unwrap());
            if !v.is_empty() { let e = v[v.len() / 2]; worst = worst.max(e); row.insert(name.clone(), json!({"medianGapMm": (e * 1000.0).round() / 1000.0, "maxGapMm": (v[v.len() - 1] * 1000.0).round() / 1000.0})); }
        }
        row.insert("cornerRadiiMm".into(), json!(radii));
        out.insert(file.clone(), Value::Object(row));
    }
    Ok(json!({"perPhoto": out, "worstMedianGapMm": (worst * 1000.0).round() / 1000.0}))
}

/// The quadratic that registers the bottom photo, recovered from the two hole lists in scale's own way.
pub fn bottom_quad(flow: &Path, prep: &Value, scale: &Value) -> Option<crate::regframe::Quad> { photo_quad(flow, prep["primary"]["bottom"].as_str()?, "bottom", prep, scale, None).map(|x| x.1) }

/// A photo drawn into the frame through its corners, its holes paired with the top frame's, and the quadratic that
/// pins it. Used for the bottom primary (again, so the outline reader needs no stored map) and for held-out photos.
pub fn photo_quad(flow: &Path, file: &str, side: &str, prep: &Value, scale: &Value, primary_for_refine: Option<&image::RgbImage>) -> Option<(image::RgbImage, crate::regframe::Quad, Value)> {
    let rec = prep["photos"].as_array()?.iter().find(|p| p["file"].as_str() == Some(file))?;
    let c = rec["corners"].as_array()?;
    let cn: [P; 4] = [0, 1, 2, 3].map(|i| (c[i][0].as_f64().unwrap_or(0.0), c[i][1].as_f64().unwrap_or(0.0)));
    let (wmm, hmm) = (scale["board_mm"][0].as_f64()?, scale["board_mm"][1].as_f64()?);
    let axis = scale["frame"]["bottomFlip"].as_str().unwrap_or("y");
    let shift = if side == "bottom" { (scale["alignment"]["shift_mm"][0].as_f64().unwrap_or(0.0) * board::RES, scale["alignment"]["shift_mm"][1].as_f64().unwrap_or(0.0) * board::RES) } else { (0.0, 0.0) };
    let dst = board::frame_corners(wmm, hmm, side, axis).map(|p| (p.0 + shift.0, p.1 + shift.1));
    let (w, h) = ((wmm * board::RES).round() as u32, (hmm * board::RES).round() as u32);
    let path = flow.join("photos").join(file);
    let first = crate::regframe::warp(&path, &cn, &dst, None, w, h, 0).ok()?;
    let hp = |i: &image::RgbImage| -> Vec<P> { crate::prep::holes(i, 0.45 * board::RES, 4.5 * board::RES).into_iter().map(|h| (h["x"].as_f64().unwrap_or(0.0), h["y"].as_f64().unwrap_or(0.0))).collect() };
    let top_h: Vec<P> = crate::items::read_json(&flow.join("detect/holes-top.json")).ok()?["holes"].as_array()?.iter().map(|h| board::to_px(h["x"].as_f64().unwrap_or(0.0), h["y"].as_f64().unwrap_or(0.0))).collect();
    let prs = crate::regframe::pairs(&top_h, &hp(&first), 1.0 * board::RES);
    let qd = crate::regframe::Quad::fit(&prs)?;
    let rep = crate::regframe::report(&prs, &qd, board::RES);
    let img = crate::regframe::warp(&path, &cn, &dst, Some(&qd), w, h, 0).ok()?;
    // the held-out photo: holes alone leave about a pixel, and thin silk cannot be compared at that. Lay it on the
    // primary photo patch by patch (photos only, no layer is involved), robustly, with the same quadratic form.
    if let Some(pr) = primary_for_refine {
        let ps = crate::regframe::patch_shifts(pr, &img);
        let mut kept = ps.clone();
        let mut fit = crate::regframe::Quad::fit(&kept)?;
        for _ in 0..3 { let mut res: Vec<f64> = kept.iter().map(|x| geom::dist(fit.apply(x.0), x.1)).collect(); res.sort_by(|a, b| a.partial_cmp(b).unwrap()); let lim = (res[res.len() / 2] * 3.0).max(0.5); kept.retain(|x| geom::dist(fit.apply(x.0), x.1) <= lim); fit = crate::regframe::Quad::fit(&kept)?; }
        let comp: Vec<(P, P)> = kept.iter().map(|x| (x.0, qd.apply(fit.apply(x.0)))).collect();
        let q2 = crate::regframe::Quad::fit(&comp)?;
        let mut res: Vec<f64> = kept.iter().map(|x| geom::dist(fit.apply(x.0), x.1)).collect(); res.sort_by(|a, b| a.partial_cmp(b).unwrap());
        let img2 = crate::regframe::warp(&path, &cn, &dst, Some(&q2), w, h, 0).ok()?;
        let mut rp = rep; rp["patches"] = json!(ps.len()); rp["patchesKept"] = json!(kept.len()); rp["patchResidualMedianPx"] = json!((res[res.len() / 2] * 100.0).round() / 100.0);
        return Some((img2, q2, rp));
    }
    Some((img, qd, rep))
}

/// A set of pixels: the window it lies in, so a pad costs its own size and not the board's.
#[derive(Clone)]
pub struct Px(pub Vec<usize>);

fn window_mask(px: &[usize], w: usize, pad: usize) -> Option<(Mask, usize, usize)> {
    if px.is_empty() { return None; }
    let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0, 0);
    for i in px { let (x, y) = (i % w, i / w); x0 = x0.min(x); y0 = y0.min(y); x1 = x1.max(x); y1 = y1.max(y); }
    let (x0, y0) = (x0.saturating_sub(pad), y0.saturating_sub(pad));
    let mut m = Mask::new(x1 - x0 + pad + 1, y1 - y0 + pad + 1);
    for i in px { m.set(i % w - x0, i / w - y0, true); }
    Some((m, x0, y0))
}

/// Metal on one face, assigned to pads. Returns per-pad pixels and what no pad explains.
fn assign_pads(fc: &Face, pads: &[(usize, &Pad)], fr: &Frame) -> (HashMap<usize, Vec<usize>>, Vec<Vec<usize>>, Value) {
    let metal = metal_mask(fc);
    let (lab, cs) = raster::components(&metal);
    let n = cs.len() + 1;
    let (w, h) = (fc.w, fc.h);
    let mut members: Vec<Vec<usize>> = vec![vec![]; n];
    for i in 0..w * h { if lab[i] != 0 { members[lab[i] as usize].push(i); } }
    let mut touch: Vec<Vec<usize>> = vec![vec![]; n];
    let mut centres: HashMap<usize, P> = HashMap::new();
    let mut own_px: HashMap<usize, Vec<usize>> = HashMap::new();
    for (ix, p) in pads {
        centres.insert(*ix, (p.x, p.y));
        let rect = grown_rect(p, 0.15);
        let (x0, y0, x1, y1) = rect.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, q| (b.0.min(q.0), b.1.min(q.1), b.2.max(q.0), b.3.max(q.1)));
        let (u0, v0) = fr.px(x0, y0); let (u1, v1) = fr.px(x1, y1);
        let (u0, v0, u1, v1) = (u0.max(0.0) as usize, v0.max(0.0) as usize, (u1.max(0.0) as usize).min(w - 1), (v1.max(0.0) as usize).min(h - 1));
        let mut seen: HashMap<u32, usize> = HashMap::new();
        let mut own = vec![];
        for v in v0..=v1 { for u in u0..=u1 { let m = (fr.ox + (u as f64 + 0.5) / fr.res, fr.oy + (v as f64 + 0.5) / fr.res); if !point_in(&rect, m) { continue; } own.push(v * w + u); let l = lab[v * w + u]; if l != 0 { *seen.entry(l).or_default() += 1; } } }
        for (l, c) in seen { if c >= 8 { touch[l as usize].push(*ix); } }
        own_px.insert(*ix, own);
    }
    let mut masks: HashMap<usize, Vec<usize>> = HashMap::new();
    let mut lone = vec![];
    let (mut single, mut shared, mut none) = (0, 0, 0);
    for c in &cs {
        let l = c.label as usize;
        let tl = &touch[l];
        if tl.is_empty() {
            none += 1;
            // metal on no pad counts only if it is plainly plating and not a glint beside a part: 0.1 mm2, none of its edge on a part
            let beside_part = members[l].iter().any(|i| { let (x, y) = (i % w, i / w); [(12i64, 0i64), (-12, 0), (0, 12), (0, -12)].iter().any(|(dx, dy)| { let (u, v) = (x as i64 + dx, y as i64 + dy); u >= 0 && v >= 0 && (u as usize) < w && (v as usize) < h && fc.cls[v as usize * w + u as usize] == photomat::OBJECT }) });
            if (c.area as f64) >= 0.1 * photomat::MM2 && !beside_part { lone.push(members[l].clone()); }
            continue;
        }
        if tl.len() == 1 { single += 1; } else { shared += 1; }
        // every pixel of this metal goes to the nearest pad touching it
        for i in &members[l] {
            let mm = (fr.ox + ((i % w) as f64 + 0.5) / fr.res, fr.oy + ((i / w) as f64 + 0.5) / fr.res);
            let k = if tl.len() == 1 { tl[0] } else { *tl.iter().min_by(|a, b| geom::dist(mm, centres[*a]).partial_cmp(&geom::dist(mm, centres[*b])).unwrap()).unwrap() };
            masks.entry(k).or_default().push(*i);
        }
    }
    // a pad's hidden part stays as the library drew it: under a body the photo says nothing
    for (ix, own) in &own_px {
        if let Some(m) = masks.get_mut(ix) { for i in own { if fc.cls[*i] == photomat::OBJECT { m.push(*i); } } }
    }
    (masks, lone, json!({"metalBlobs": cs.len(), "onOnePad": single, "sharedByPads": shared, "noPad": none}))
}

/// The outer ring of a pixel set in mm and the point inside it farthest from its edge, smoothed by a one-pixel blur so a staircase does not reach the board.
fn outer_ring_px(px: &[usize], w: usize) -> Option<(Vec<P>, P)> {
    let (m, x0, y0) = window_mask(px, w, 4)?;
    let bl = raster::blur(&Plane { w: m.w, h: m.h, d: m.d.iter().map(|b| if *b { 1.0 } else { 0.0 }).collect() }, 1, 1);
    let fld = Plane { w: bl.w, h: bl.h, d: bl.d.iter().map(|v| v - 0.5).collect() };
    let rings = contours(&fld);
    let big = rings.into_iter().filter(|r| shoelace(r) < 0.0).max_by(|a, b| shoelace(a).abs().partial_cmp(&shoelace(b).abs()).unwrap())?;
    let s = geom::simplify(&big, 0.3);
    if s.len() < 3 { return None; }
    let d = m.not().distance_to_set();
    let mut best = (0usize, -1f32);
    for i in 0..m.d.len() { if m.d[i] && d.d[i] > best.1 { best = (i, d.d[i]); } }
    let inside = px_mm(((best.0 % m.w + x0) as f64 + 0.5, (best.0 / m.w + y0) as f64 + 0.5));
    Some((s.into_iter().map(|p| px_mm((p.0 + x0 as f64, p.1 + y0 as f64))).collect(), inside))
}

/// Read everything and write photo-layers.json. `pads` are the placed library pads.
pub fn trace(flow: &Path, pads: &[Pad], ev: &Path) -> Result<Value, String> {
    let open = |n: &str| image::open(flow.join(n)).map(|i| i.to_rgb8()).map_err(|e| e.to_string());
    let (ti, bi) = (open("board/top.jpg")?, open("board/bottom.jpg")?);
    let (mut top, mut bot) = (photomat::classify(&ti, photomat::carries_parts(&ti)), photomat::classify(&bi, photomat::carries_parts(&bi)));
    photomat::link_holes(&mut top, &mut bot);
    photomat::false_colour(&ti, &top).save(ev.join("materials-top.jpg")).map_err(|e| e.to_string())?;
    photomat::false_colour(&bi, &bot).save(ev.join("materials-bottom.jpg")).map_err(|e| e.to_string())?;
    let fr = Frame { w: top.w, h: top.h, res: board::RES, ox: board::ORIGIN, oy: board::ORIGIN };
    let holes = holes_of(&top, &bot);
    let silk_f = silk_polys(&top); let silk_b = silk_polys(&bot);
    // the outline: both photos read it; the top is blocked along connectors, so the better-supported reading wins per edge
    let prep = crate::items::read_json(&flow.join("prep.json"))?; let scale = crate::items::read_json(&flow.join("scale.json"))?;
    let bq = bottom_quad(flow, &prep, &scale);
    let et = edges_from(flow, "top", None, &prep, &scale)?; let eb = edges_from(flow, "bottom", bq.as_ref(), &prep, &scale)?;
    let mut edge = HashMap::new(); let mut edge_doc = serde_json::Map::new();
    for name in ["top", "bottom", "left", "right"] {
        let (a, b) = (et.iter().find(|e| e.0 == name).map(|e| e.1), eb.iter().find(|e| e.0 == name).map(|e| e.1));
        let share = |x: Option<(f64, f64, usize, usize, f64)>| x.map(|l| l.2 as f64 / l.3 as f64).unwrap_or(0.0);
        let mut pick = if share(a) >= 0.6 { a } else if share(b) > share(a) { b } else { a };
        // neither photo reads this edge: the frame came from the stated board, so its edge stands, and the document says so
        let stated = share(a).max(share(b)) < EDGE_MIN_SHARE;
        if stated { let wall = match name { "top" | "left" => 0.0, "bottom" => top.h as f64, _ => top.w as f64 }; pick = Some((0.0, wall, 0, 1, 0.0)); }
        let at_mid = |x: Option<(f64, f64, usize, usize, f64)>, mid: f64| x.map(|l| ((l.0 * mid + l.1) / board::RES * 1000.0).round() / 1000.0);
        let mid = if name == "top" || name == "bottom" { top.w as f64 / 2.0 } else { top.h as f64 / 2.0 };
        edge_doc.insert(name.into(), json!({"topPhotoMm": at_mid(a, mid), "bottomPhotoMm": at_mid(b, mid), "topInlierShare": share(a), "bottomInlierShare": share(b), "tiltMradTop": a.map(|l| (l.0 * 1e6).round() / 1e3), "tiltMradBottom": b.map(|l| (l.0 * 1e6).round() / 1e3), "bowMmTop": a.map(|l| (l.4 * 1000.0).round() / 1000.0), "bowMmBottom": b.map(|l| (l.4 * 1000.0).round() / 1000.0)}));
        if stated { edge_doc.get_mut(name).unwrap()["stated"] = json!(true); }
        if let Some(l) = pick { edge.insert(name, (l.0, l.1)); }
    }
    // corners where the fitted edges meet, in board px
    let meet = |h: (f64, f64), v: (f64, f64)| -> P { let x = (v.0 * h.1 + v.1) / (1.0 - v.0 * h.0); (x, h.0 * x + h.1) };
    let outline = if edge.len() == 4 {
        let (t, bo, lf, r) = (edge["top"], edge["bottom"], edge["left"], edge["right"]);
        let c = [meet(t, lf), meet(t, r), meet(bo, r), meet(bo, lf)];
        json!(c.iter().map(|p| { let m = px_mm(*p); json!([m.0, m.1]) }).collect::<Vec<_>>())
    } else { Value::Null };
    // the corner radius: the median of the corners both primary photos show
    let radius = { let mut r: Vec<f64> = vec![]; for (side, q) in [("top", None), ("bottom", bq.as_ref())] { if let Some(file) = prep["primary"][side].as_str() { if let Ok((_, x)) = edges_of_file(flow, file, side, q, &prep, &scale) { r.extend(x.into_iter().filter(|v| *v > 0.5 && *v < 6.0)); } } } r.sort_by(|a, b| a.partial_cmp(b).unwrap()); if r.is_empty() { crate::items::read_json(&flow.join("copper.json")).ok().and_then(|c| c["outline"]["corner_mm"].as_f64()).unwrap_or(0.0) } else { r[r.len() / 2] } };
    // pads, per face
    let on_face = |layer: &str| -> Vec<(usize, &Pad)> { pads.iter().enumerate().filter(|(_, p)| p.on(layer)).collect() };
    let (ftop, fbot) = (on_face("F.Cu"), on_face("B.Cu"));
    let (tm, tlone, ts) = assign_pads(&top, &ftop, &fr);
    let (bm, blone, bs) = assign_pads(&bot, &fbot, &fr);
    let mut pad_doc = vec![];
    let mut used_holes = vec![false; holes.len()];
    let mut moved: Vec<f64> = vec![];
    let mut kcount: HashMap<String, usize> = HashMap::new();
    let mut part_offsets: HashMap<String, Vec<f64>> = HashMap::new();
    let w = top.w;
    for (ix, p) in pads.iter().enumerate() {
        let key = format!("{}.{}", p.part, p.num);
        let k = { let e = kcount.entry(key.clone()).or_default(); let v = *e; *e += 1; v };
        // a through pad takes its shape from the face that really shows it: a glint beside a header must not outvote a whole joint
        let shown = |fc: &Face, px: Option<&Vec<usize>>| -> usize { px.map(|v| v.iter().filter(|i| fc.cls[**i] == photomat::GOLD || fc.cls[**i] == photomat::SILVER).count()).unwrap_or(0) };
        let m = if p.drill > 0.0 { if shown(&top, tm.get(&ix)) >= shown(&bot, bm.get(&ix)) { tm.get(&ix).or_else(|| bm.get(&ix)) } else { bm.get(&ix) } } else if p.on("F.Cu") { tm.get(&ix) } else { bm.get(&ix) };
        // a photographed hole near a through pad gives the pad its position and drill
        let mut hole_at = None;
        if p.drill > 0.0 {
            let found = (0..holes.len()).filter(|i| !used_holes[*i]).min_by(|i, j| geom::dist((holes[*i].x, holes[*i].y), (p.x, p.y)).partial_cmp(&geom::dist((holes[*j].x, holes[*j].y), (p.x, p.y))).unwrap());
            if let Some(i) = found.filter(|i| geom::dist((holes[*i].x, holes[*i].y), (p.x, p.y)) < 0.9) { used_holes[i] = true; moved.push(geom::dist((holes[i].x, holes[i].y), (p.x, p.y))); hole_at = Some((holes[i].x, holes[i].y, holes[i].d)); }
        }
        // a pad cannot wander: keep what lies within a millimetre of where the library put it
        let near = grown_rect(p, 1.0);
        let shaped = m.and_then(|px| { let kept: Vec<usize> = px.iter().copied().filter(|i| point_in(&near, (fr.ox + ((i % w) as f64 + 0.5) / fr.res, fr.oy + ((i / w) as f64 + 0.5) / fr.res))).collect(); outer_ring_px(&kept, w) });
        let (ring, inside) = match shaped { Some((r, i)) => (Some(r), Some(i)), None => (None, None) };
        // A drill stays where the hole is. A through pad whose hole the photo did not find keeps the library's place: its outline from the photo is taken only when it holds that place, and
        // the anchor, which carries the drill, does not move into the outline (test board 1: a mounting hole's drill ended 2.3 mm from its ring's centre, header pins up to 1.5 mm).
        let (ring, inside) = if p.drill > 0.0 && hole_at.is_none() { match &ring { Some(r) if point_in(r, (p.x, p.y)) => (ring, Some((p.x, p.y))), _ => (None, None) } } else { (ring, inside) };
        let anchor: Option<P> = hole_at.map(|h| (h.0, h.1)).or(inside);
        // a pad where the photo plainly shows mask: the part's footprint does not fit here. The pad stays for its net, but
        // it opens no mask the photo does not show. Half or more of it must be in plain view for that.
        let plain_mask = |fc: &Face| -> f64 { let rect = grown_rect(p, 0.0); let (x0, y0, x1, y1) = rect.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, q| (b.0.min(q.0), b.1.min(q.1), b.2.max(q.0), b.3.max(q.1))); let (u0, v0) = fr.px(x0, y0); let (u1, v1) = fr.px(x1, y1); let (mut on, mut t) = (0, 0); for v in (v0.max(0.0) as usize)..=(v1.max(0.0) as usize).min(fc.h - 1) { for u in (u0.max(0.0) as usize)..=(u1.max(0.0) as usize).min(fc.w - 1) { if !point_in(&rect, (fr.ox + (u as f64 + 0.5) / fr.res, fr.oy + (v as f64 + 0.5) / fr.res)) { continue; } t += 1; if fc.cls[v * fc.w + u] == photomat::MASK { on += 1; } } } if t == 0 { 0.0 } else { on as f64 / t as f64 } };
        if ring.is_none() && hole_at.is_none() {
            let (f_open, b_open) = (p.on("F.Cu") && plain_mask(&top) > 0.5, p.on("B.Cu") && plain_mask(&bot) > 0.5);
            if f_open || b_open { pad_doc.push(json!({"part": p.part, "num": p.num, "k": k, "noMaskF": f_open, "noMaskB": b_open})); }
            continue;
        }
        // does the part sit where its pads show? Only pads the photo shows nearly whole count.
        if let Some(rg) = &ring { let area = shoelace(rg).abs(); if area > 0.4 * p.w * p.h && area < 2.5 * p.w * p.h { let c = (rg.iter().map(|q| q.0).sum::<f64>() / rg.len() as f64, rg.iter().map(|q| q.1).sum::<f64>() / rg.len() as f64); part_offsets.entry(p.part.clone()).or_default().push(geom::dist(c, (p.x, p.y))); } }
        let seen_at = hole_at.map(|h| (h.0, h.1)).or_else(|| ring.as_ref().map(|r| (r.iter().map(|q| q.0).sum::<f64>() / r.len() as f64, r.iter().map(|q| q.1).sum::<f64>() / r.len() as f64)));
        pad_doc.push(json!({"part": p.part, "num": p.num, "k": k, "anchor": anchor.map(|a| json!([a.0, a.1])), "poly": ring.as_ref().map(|r| ring_json(r)), "drill": hole_at.map(|h| h.2), "lib": [p.x, p.y], "libSize": [p.w, p.h], "seenAt": seen_at.map(|a| json!([a.0, a.1]))}));
    }
    // a hole inside a photographed pad belongs to that pad
    for (i, h) in holes.iter().enumerate() {
        if used_holes[i] { continue; }
        for row in pad_doc.iter_mut() {
            let r = ring_of(&row["poly"]);
            if r.len() >= 3 && point_in(&r, (h.x, h.y)) {
                if !row["extraHoles"].is_array() { row["extraHoles"] = json!([]); }
                row["extraHoles"].as_array_mut().unwrap().push(json!({"x": h.x, "y": h.y, "d": h.d}));
                used_holes[i] = true; break;
            }
        }
    }
    // holes no pad claimed, and metal no pad claimed
    let free_holes: Vec<Value> = holes.iter().enumerate().filter(|(i, _)| !used_holes[*i]).map(|(_, h)| json!({"x": h.x, "y": h.y, "d": h.d, "plated": h.ringed, "faces": h.faces})).collect();
    let free = |lone: &[Vec<usize>], face: &str| -> Vec<Value> { lone.iter().filter_map(|m| outer_ring_px(m, w).map(|(r, ins)| json!({"face": face, "poly": ring_json(&r), "inside": [ins.0, ins.1], "areaMm2": ((m.len() as f64 / photomat::MM2) * 1000.0).round() / 1000.0}))).collect() };
    let scale_check = scale_from_pads(&pad_doc);
    let mut metal = free(&tlone, "F"); metal.extend(free(&blone, "B"));
    let offs: Vec<f64> = part_offsets.values().map(|v| { let mut w = v.clone(); w.sort_by(|a, b| a.partial_cmp(b).unwrap()); w[w.len() / 2] }).collect();
    let within = offs.iter().filter(|d| **d < 0.25).count();
    let area = |rs: &Vec<Vec<P>>| rs.iter().map(|r| shoelace(r).abs()).sum::<f64>();
    let doc = json!({"generatedBy": "layers trace", "note": "polygons in board mm; thresholds from the Driver photos, see photomat.rs",
        "silk": {"F": silk_f.iter().map(|r| ring_json(r)).collect::<Vec<_>>(), "B": silk_b.iter().map(|r| ring_json(r)).collect::<Vec<_>>()},
        "silkAreaMm2": {"F": (area(&silk_f) * 100.0).round() / 100.0, "B": (area(&silk_b) * 100.0).round() / 100.0},
        "pads": pad_doc, "freeHoles": free_holes, "freeMetal": metal,
        "holesSeen": holes.len(), "holeMoveFromLibraryMm": moved,
        "outline": {"points_mm": outline, "corner_mm": radius, "edges": edge_doc},
        "padAssignment": {"top": ts, "bottom": bs},
        "scaleFromPads": scale_check,
        "libraryPadsAgainstPhoto": {"partsChecked": offs.len(), "within025mm": within, "share": if offs.is_empty() { 0.0 } else { within as f64 / offs.len() as f64 }}});
    Ok(doc)
}

/// Library pitches are standards, so a footprint's pads are a ruler laid on the photo at each part. For every pair of pads
/// in a part, the distance on the photo over the distance in the library, split by direction, and summed so long
/// baselines count for more. Holes give the sharpest points; small SMD pads next (a large pad's centre is pulled by
/// solder and the part's end). The spread between parts is the honest uncertainty.
fn scale_from_pads(rows: &[Value]) -> Value {
    let mut parts: HashMap<String, Vec<(P, P, bool)>> = HashMap::new();
    for r in rows {
        let (Some(l), Some(s)) = (r["lib"].as_array(), r["seenAt"].as_array()) else { continue };
        let hole = !r["drill"].is_null();
        let lz = &r["libSize"];
        if !hole && lz[0].as_f64().unwrap_or(9.0).min(lz[1].as_f64().unwrap_or(9.0)) > 0.8 { continue; }
        parts.entry(r["part"].as_str().unwrap_or("").to_string()).or_default().push(((l[0].as_f64().unwrap_or(0.0), l[1].as_f64().unwrap_or(0.0)), (s[0].as_f64().unwrap_or(0.0), s[1].as_f64().unwrap_or(0.0)), hole));
    }
    let mut per = vec![];
    let (mut holes_x, mut holes_y, mut fine_x, mut fine_y): (Vec<(f64, f64)>, Vec<(f64, f64)>, Vec<(f64, f64)>, Vec<(f64, f64)>) = (vec![], vec![], vec![], vec![]);
    let mut names: Vec<&String> = parts.keys().collect(); names.sort();
    for name in names {
        let v = &parts[name];
        let (mut sx, mut lx, mut sy, mut ly) = (0.0, 0.0, 0.0, 0.0);
        for i in 0..v.len() { for j in i + 1..v.len() {
            let (dl, ds) = ((v[j].0 .0 - v[i].0 .0, v[j].0 .1 - v[i].0 .1), (v[j].1 .0 - v[i].1 .0, v[j].1 .1 - v[i].1 .1));
            if dl.0.abs() > 3.0 * dl.1.abs() { lx += dl.0.abs(); sx += ds.0 * dl.0.signum(); }
            else if dl.1.abs() > 3.0 * dl.0.abs() { ly += dl.1.abs(); sy += ds.1 * dl.1.signum(); }
            else { let n = (dl.0 * dl.0 + dl.1 * dl.1).sqrt(); let m = (ds.0 * dl.0 + ds.1 * dl.1) / n; lx += n / 2.0; sx += m / 2.0; ly += n / 2.0; sy += m / 2.0; }
        } }
        let hole = v.iter().filter(|q| q.2).count() * 2 >= v.len();
        if v.len() < 3 { continue; }
        let rx = if lx >= 3.0 { Some(sx / lx) } else { None }; let ry = if ly >= 3.0 { Some(sy / ly) } else { None };
        if rx.is_none() && ry.is_none() { continue; }
        if let Some(q) = rx { if hole { holes_x.push((q, lx)); } else { fine_x.push((q, lx)); } }
        if let Some(q) = ry { if hole { holes_y.push((q, ly)); } else { fine_y.push((q, ly)); } }
        per.push(json!({"name": name, "pads": v.len(), "holes": hole, "x": rx.map(|q| (q * 10000.0).round() / 10000.0), "y": ry.map(|q| (q * 10000.0).round() / 10000.0), "xBaselineMm": (lx * 10.0).round() / 10.0, "yBaselineMm": (ly * 10.0).round() / 10.0}));
    }
    // a weighted mean, and the spread of the parts about it
    let summarise = |v: &[(f64, f64)]| -> Value { if v.is_empty() { return Value::Null; } let w: f64 = v.iter().map(|q| q.1).sum(); let m = v.iter().map(|q| q.0 * q.1).sum::<f64>() / w; let var = v.iter().map(|q| q.1 * (q.0 - m).powi(2)).sum::<f64>() / w; json!({"ratio": (m * 100000.0).round() / 100000.0, "spreadBetweenRulers": (var.sqrt() * 100000.0).round() / 100000.0, "rulers": v.len()}) };
    json!({"meaning": "ratio 1.001 means the frame draws 0.1% larger than the library pitch", "holes": {"x": summarise(&holes_x), "y": summarise(&holes_y)}, "smallPads": {"x": summarise(&fine_x), "y": summarise(&fine_y)}, "perRuler": per})
}

fn grown_rect(p: &Pad, g: f64) -> Vec<P> {
    let (hw, hh) = (p.w / 2.0 + g, p.h / 2.0 + g);
    [(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)].iter().map(|(lx, ly)| kicad_out::place(p.x, p.y, p.rot, *lx, *ly)).collect()
}

fn poly_node(r: &[P], layer: &str, id: &str) -> String {
    let pts: String = r.iter().map(|p| format!("(xy {:.4} {:.4})", p.0, p.1)).collect::<Vec<_>>().join(" ");
    format!("\t(gr_poly (pts {pts}) (stroke (width 0) (type solid)) (fill solid) (layer \"{layer}\") (uuid \"{}\"))\n", kicad_out::uuid(id))
}

/// Everything the document draws as board graphics: silk on both faces.
pub fn graphics(doc: &Value) -> String {
    let mut out = String::new();
    for (key, layer) in [("F", "F.SilkS"), ("B", "B.SilkS")] {
        for (i, r) in doc["silk"][key].as_array().into_iter().flatten().enumerate() { out += &poly_node(&ring_of(r), layer, &format!("silk/{key}/{i}")); }
    }
    out
}

/// The measured outline, if the document has one, as Edge.Cuts: four straight edges and rounded corners.
pub fn edge_cuts(doc: &Value) -> Option<String> {
    let pts = ring_of(&doc["outline"]["points_mm"]);
    if pts.len() != 4 { return None; }
    let r = doc["outline"]["corner_mm"].as_f64().unwrap_or(0.0);
    let mut out = String::new();
    let unit = |a: P, b: P| { let d = geom::dist(a, b).max(1e-9); ((b.0 - a.0) / d, (b.1 - a.1) / d) };
    for i in 0..4 {
        let (a, b, c) = (pts[i], pts[(i + 1) % 4], pts[(i + 2) % 4]);
        let (u, v) = (unit(a, b), unit(b, c));
        let s = (a.0 + u.0 * r, a.1 + u.1 * r); let e = (b.0 - u.0 * r, b.1 - u.1 * r);
        out += &format!("\t(gr_line (start {:.4} {:.4}) (end {:.4} {:.4}) (stroke (width 0.05) (type default)) (layer \"Edge.Cuts\") (uuid \"{}\"))\n", s.0, s.1, e.0, e.1, kicad_out::uuid(&format!("photoedge/{i}")));
        if r > 0.0 {
            let ae = (b.0 + v.0 * r, b.1 + v.1 * r);
            // the arc's centre sits r in from both edges; its middle lies on the way to the corner
            let centre = (b.0 - u.0 * r + v.0 * r, b.1 - u.1 * r + v.1 * r);
            let d = geom::dist(b, centre).max(1e-9);
            let mid = (centre.0 + (b.0 - centre.0) / d * r, centre.1 + (b.1 - centre.1) / d * r);
            out += &format!("\t(gr_arc (start {:.4} {:.4}) (mid {:.4} {:.4}) (end {:.4} {:.4}) (stroke (width 0.05) (type default)) (layer \"Edge.Cuts\") (uuid \"{}\"))\n", e.0, e.1, mid.0, mid.1, ae.0, ae.1, kicad_out::uuid(&format!("photoarc/{i}")));
        }
    }
    Some(out)
}

pub fn load(flow: &Path) -> Option<Value> { crate::items::read_json(&flow.join(FILE)).ok() }

/// Take library silk off a placed footprint: reference and value move to the fabrication layer.
pub fn strip_silk(n: &mut N) {
    let on_silk = |c: &N| c.child("layer").and_then(|l| l.arg(0)).map(|s| s.ends_with(".SilkS")).unwrap_or(false);
    n.items_mut().retain(|c| !(matches!(c.head(), "fp_line" | "fp_arc" | "fp_circle" | "fp_rect" | "fp_poly" | "fp_text" | "fp_curve") && on_silk(c)));
    for c in n.items_mut().iter_mut() {
        if c.head() == "property" { if let Some(ly) = c.child_mut("layer") { if ly.arg(0).map(|s| s.ends_with(".SilkS")).unwrap_or(false) { let front = ly.arg(0).map(|s| s.starts_with('F')).unwrap_or(true); *ly = l(vec![a("layer"), q(if front { "F.Fab" } else { "B.Fab" })]); } } }
    }
}

/// The local position a footprint would need to put a child at a board position.
fn to_local(fx: f64, fy: f64, rot: f64, g: P) -> P {
    let (c, s) = (rot.to_radians().cos(), rot.to_radians().sin());
    let (dx, dy) = (g.0 - fx, g.1 - fy);
    (dx * c - dy * s, dx * s + dy * c)
}

/// Rewrite the pads of one placed footprint from the document. Pads keep name, type, layers and net; shape and hole follow the photo.
pub fn apply_pads(doc: &Value, n: &mut N, pads: &mut Vec<Pad>, part: &Value, polys: &mut HashMap<String, Vec<P>>) {
    let r = part["ref"].as_str().unwrap_or("?");
    let (fx, fy) = (part["pos_mm"][0].as_f64().unwrap_or(0.0), part["pos_mm"][1].as_f64().unwrap_or(0.0));
    let rot = part["rot"].as_f64().unwrap_or(0.0);
    let mut k: HashMap<String, usize> = HashMap::new();
    let mut pi = 0usize;
    let pad_rows: Vec<&Value> = doc["pads"].as_array().into_iter().flatten().filter(|v| v["part"].as_str() == Some(r)).collect();
    for c in n.items_mut().iter_mut() {
        if c.head() != "pad" { continue; }
        let num = c.arg(0).unwrap_or_default();
        let this = pi; pi += 1;
        let kk = { let e = k.entry(num.clone()).or_default(); let v = *e; *e += 1; v };
        let Some(row) = pad_rows.iter().find(|v| v["num"].as_str() == Some(&num) && v["k"].as_u64() == Some(kk as u64)) else { continue };
        // the photo shows mask over this pad: take the opening away on that face
        if row["noMaskF"].as_bool().unwrap_or(false) || row["noMaskB"].as_bool().unwrap_or(false) {
            let (nf, nb) = (row["noMaskF"].as_bool().unwrap_or(false), row["noMaskB"].as_bool().unwrap_or(false));
            if let Some(ly) = c.items_mut().iter_mut().find(|x| x.head() == "layers") {
                let names: Vec<String> = ly.items()[1..].iter().filter_map(|x| match x { N::A(s) => Some(crate::sexp::unq(s)), _ => None }).collect();
                let mut out = vec![];
                for n in names { if n == "*.Mask" { if !nf { out.push("F.Mask".to_string()); } if !nb { out.push("B.Mask".to_string()); } } else if (n == "F.Mask" && nf) || (n == "B.Mask" && nb) || (n == "F.Paste" && nf) { } else { out.push(n); } }
                *ly = l(std::iter::once(a("layers")).chain(out.iter().map(|x| q(x))).collect());
            }
            continue;
        }
        let poly = ring_of(&row["poly"]);
        let anchor = row["anchor"].as_array().map(|p| (p[0].as_f64().unwrap_or(0.0), p[1].as_f64().unwrap_or(0.0)));
        let drill = row["drill"].as_f64();
        let items = c.items_mut();
        if let Some(an) = anchor {
            let lp = to_local(fx, fy, rot, an);
            let ang = if poly.len() >= 3 { 0.0 } else { items.iter().find(|x| x.head() == "at").and_then(|x| x.num(2)).unwrap_or(0.0) };
            if let Some(at) = items.iter_mut().find(|x| x.head() == "at") { *at = l(vec![a("at"), f(lp.0), f(lp.1), f(ang)]); }
            if let Some(pd) = pads.get_mut(this) { pd.x = an.0; pd.y = an.1; }
        }
        if let Some(d) = drill { if let Some(dr) = items.iter_mut().find(|x| x.head() == "drill") { *dr = l(vec![a("drill"), f(d)]); } if let Some(pd) = pads.get_mut(this) { pd.drill = d; } }
        if poly.len() >= 3 {
            let an = anchor.unwrap_or(poly[0]);
            if items.len() > 3 { items[3] = a("custom"); }
            let anc = if let Some(d) = drill { (d * 0.5).max(0.1) } else { 0.1 };
            if let Some(sz) = items.iter_mut().find(|x| x.head() == "size") { *sz = l(vec![a("size"), f(anc), f(anc)]); }
            items.retain(|x| !matches!(x.head(), "roundrect_rratio" | "chamfer_ratio" | "chamfer" | "rect_delta" | "options" | "primitives"));
            // goes before the net and uuid so the file order stays what KiCad writes
            let pts = l(std::iter::once(a("pts")).chain(poly.iter().map(|p| l(vec![a("xy"), f(p.0 - an.0), f(p.1 - an.1)]))).collect());
            let prim = l(vec![a("primitives"), l(vec![a("gr_poly"), pts, l(vec![a("width"), a("0")]), l(vec![a("fill"), a("yes")])])]);
            let opt = l(vec![a("options"), l(vec![a("clearance"), a("outline")]), l(vec![a("anchor"), a("circle")])]);
            let at_net = items.iter().position(|x| x.head() == "net" || x.head() == "uuid").unwrap_or(items.len());
            items.insert(at_net, prim); items.insert(at_net, opt);
            if let Some(pd) = pads.get_mut(this) {
                let (mx, my) = poly.iter().fold((0f64, 0f64), |b, p| (b.0.max((p.0 - an.0).abs()), b.1.max((p.1 - an.1).abs())));
                pd.w = 2.0 * mx; pd.h = 2.0 * my; pd.rot = 0.0; pd.round = false;
                polys.insert(format!("{}.{}#{}", r, num, kk), poly.clone());
            }
        }
    }
    // holes inside a photographed pad (the lugs have four around the middle): more pads of the same number, so the net holds
    let mut added = vec![];
    for row in &pad_rows {
        let num = row["num"].as_str().unwrap_or("");
        let parent = n.children("pad").find(|c| c.arg(0).as_deref() == Some(num)).cloned();
        let Some(parent) = parent else { continue };
        let net = parent.child("net").cloned();
        let layers = parent.child("layers").cloned().unwrap_or(l(vec![a("layers"), q("*.Cu"), q("*.Mask")]));
        for (i, h) in row["extraHoles"].as_array().into_iter().flatten().enumerate() {
            let (x, y, d) = (h["x"].as_f64().unwrap_or(0.0), h["y"].as_f64().unwrap_or(0.0), h["d"].as_f64().unwrap_or(0.3));
            let lp = to_local(fx, fy, rot, (x, y));
            let mut node = vec![a("pad"), q(num), a("thru_hole"), a("circle"), l(vec![a("at"), f(lp.0), f(lp.1)]), l(vec![a("size"), f(d + 0.3), f(d + 0.3)]), l(vec![a("drill"), f(d)]), layers.clone()];
            if let Some(nt) = &net { node.push(nt.clone()); }
            node.push(l(vec![a("uuid"), q(&kicad_out::uuid(&format!("extrahole/{r}/{num}/{i}")))]));
            added.push(l(node));
            let net_name = pads.iter().find(|p| p.num == num).map(|p| p.net.clone()).unwrap_or_default();
            pads.push(Pad { part: r.to_string(), num: num.to_string(), x, y, w: d + 0.3, h: d + 0.3, rot: 0.0, round: true, drill: d, layers: vec!["*.Cu".into(), "*.Mask".into()], net: net_name });
        }
    }
    n.items_mut().extend(added);
}

/// A footprint carrying every hole and exposed patch no part explains. It has no net.
pub fn extras_footprint(doc: &Value) -> Option<(String, Vec<Pad>)> {
    let (holes, metal) = (doc["freeHoles"].as_array().cloned().unwrap_or_default(), doc["freeMetal"].as_array().cloned().unwrap_or_default());
    if holes.is_empty() && metal.is_empty() { return None; }
    let mut s = format!("(footprint \"Generated:PhotoOnly\" (layer \"F.Cu\") (at {o} {o}) (uuid \"{}\") (attr exclude_from_pos_files exclude_from_bom)\n (property \"Reference\" \"PHOTO1\" (at 0 0) (layer \"F.Fab\") (effects (font (size 1 1) (thickness 0.15))))\n (property \"Value\" \"seen on the photos, no part explains it\" (at 0 0) (layer \"F.Fab\") (effects (font (size 1 1) (thickness 0.15))))\n", kicad_out::uuid("fp/PHOTO1"), o = board::ORIGIN);
    let mut pads = vec![];
    for (i, h) in holes.iter().enumerate() {
        let (x, y, d) = (h["x"].as_f64().unwrap_or(0.0), h["y"].as_f64().unwrap_or(0.0), h["d"].as_f64().unwrap_or(0.3));
        let (lx, ly) = (x - board::ORIGIN, y - board::ORIGIN);
        if h["plated"].as_bool().unwrap_or(false) {
            let pd = d + 0.2;
            s += &format!(" (pad \"\" thru_hole circle (at {lx:.4} {ly:.4}) (size {pd:.4} {pd:.4}) (drill {d:.4}) (layers \"*.Cu\" \"*.Mask\") (uuid \"{}\"))\n", kicad_out::uuid(&format!("photohole/{i}")));
            pads.push(Pad { part: "PHOTO1".into(), num: format!("H{i}"), x, y, w: pd, h: pd, rot: 0.0, round: true, drill: d, layers: vec!["*.Cu".into(), "*.Mask".into()], net: String::new() });
        } else {
            s += &format!(" (pad \"\" np_thru_hole circle (at {lx:.4} {ly:.4}) (size {d:.4} {d:.4}) (drill {d:.4}) (layers \"*.Cu\" \"*.Mask\") (uuid \"{}\"))\n", kicad_out::uuid(&format!("photohole/{i}")));
        }
    }
    for (i, m) in metal.iter().enumerate() {
        let r = ring_of(&m["poly"]); if r.len() < 3 { continue; }
        let front = m["face"].as_str() == Some("F");
        let (cu, ma) = if front { ("F.Cu", "F.Mask") } else { ("B.Cu", "B.Mask") };
        let (cx, cy) = (m["inside"][0].as_f64().unwrap_or(r[0].0), m["inside"][1].as_f64().unwrap_or(r[0].1));
        let pts: String = r.iter().map(|p| format!("(xy {:.4} {:.4})", p.0 - cx, p.1 - cy)).collect::<Vec<_>>().join(" ");
        s += &format!(" (pad \"\" smd custom (at {:.4} {:.4}) (size 0.05 0.05) (layers \"{cu}\" \"{ma}\") (options (clearance outline) (anchor circle)) (primitives (gr_poly (pts {pts}) (width 0) (fill yes))) (uuid \"{}\"))\n", cx - board::ORIGIN, cy - board::ORIGIN, kicad_out::uuid(&format!("photometal/{i}")));
        let (mx, my) = r.iter().fold((0f64, 0f64), |b, p| (b.0.max((p.0 - cx).abs()), b.1.max((p.1 - cy).abs())));
        pads.push(Pad { part: "PHOTO1".into(), num: format!("M{i}"), x: cx, y: cy, w: 2.0 * mx, h: 2.0 * my, rot: 0.0, round: false, drill: 0.0, layers: vec![cu.into(), ma.into()], net: String::new() });
    }
    s += ")\n";
    Some((s, pads))
}

#[cfg(test)]
mod tests {
    #[test]
    fn an_edge_reads_on_a_dark_mat_as_well_as_on_wood() {
        // a straightened board with a 60 px margin; the table is a dark teal mat in one, wood in the other
        for table in [image::Rgb([28u8, 52, 50]), image::Rgb([150, 100, 60])] {
            let (m, fw, fh) = (60i64, 800.0, 600.0);
            let img = image::RgbImage::from_fn((fw as i64 + 2 * m) as u32, (fh as i64 + 2 * m) as u32, |x, y| {
                let (x, y) = (x as i64 - m, y as i64 - m);
                if x >= 3 && y >= -2 && x < fw as i64 && y < fh as i64 { image::Rgb([60, 150, 80]) } else { table } });
            let top = edge_line(&img, m, true, true, fw, fh).expect("top edge");
            let left = edge_line(&img, m, false, true, fw, fh).expect("left edge");
            assert!(top.2 as f64 / top.3 as f64 > 0.8 && (top.1 + 2.0).abs() < 1.5, "top {top:?}");
            assert!(left.2 as f64 / left.3 as f64 > 0.8 && (left.1 - 3.0).abs() < 1.5, "left {left:?}");
        }
    }

    use super::*;
    #[test]
    fn contours_wind_outers_and_holes_oppositely() {
        let mut p = Plane::new(40, 40, -1.0);
        for y in 5..35 { for x in 5..35 { p.d[y * 40 + x] = 1.0; } }
        for y in 15..25 { for x in 15..25 { p.d[y * 40 + x] = -1.0; } }
        let r = contours(&p);
        assert_eq!(r.len(), 2);
        let (mut o, mut h) = (0, 0);
        for q in &r { if shoelace(q) < 0.0 { o += 1; assert!((shoelace(q).abs() - 900.0).abs() < 40.0, "{}", shoelace(q)); } else { h += 1; assert!((shoelace(q) - 100.0).abs() < 25.0, "{}", shoelace(q)); } }
        assert_eq!((o, h), (1, 1));
    }
    #[test]
    fn pads_become_photographed_shapes() {
        let doc = json!({"pads": [{"part": "R1", "num": "1", "k": 0, "anchor": [10.5, 10.0], "poly": [[10.0, 9.5], [11.0, 9.5], [11.0, 10.5], [10.0, 10.5]], "drill": null}]});
        let mut n = crate::sexp::parse("(footprint \"x\" (layer \"F.Cu\") (at 10 10 90) (pad \"1\" smd roundrect (at 0.5 0 90) (size 1 1) (layers \"F.Cu\" \"F.Mask\") (roundrect_rratio 0.25) (uuid \"u\")))").unwrap();
        let part = json!({"ref": "R1", "pos_mm": [10.0, 10.0], "rot": 90.0});
        let mut pads = vec![Pad { part: "R1".into(), num: "1".into(), x: 10.0, y: 9.5, w: 1.0, h: 1.0, rot: 90.0, round: false, drill: 0.0, layers: vec!["F.Cu".into()], net: "".into() }];
        let mut polys = HashMap::new();
        apply_pads(&doc, &mut n, &mut pads, &part, &mut polys);
        let t = n.to_string();
        assert!(t.contains("custom") && t.contains("primitives") && !t.contains("roundrect_rratio"), "{t}");
        // the anchor goes back through the inverse of KiCad's placement
        let at = n.child("pad").unwrap().child("at").unwrap();
        let g = kicad_out::place(10.0, 10.0, 90.0, at.num(0).unwrap(), at.num(1).unwrap());
        assert!(geom::dist(g, (10.5, 10.0)) < 1e-3, "{g:?}");
    }
}