main
Caleb Publish 0.5.0 1984b40
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579
//! Vias by what each photo shows of them, decided spot by spot and not face by face.
//!
//! `domes` reads a tented via by the shading of its dome, which needs light from one side. Under even light nothing is shaded,
//! and what is left of a via is its hole: a small round dark centre where the mask sinks in or the drill is open. Round it the
//! photo may show the pad (a pale or light annulus, a dark clearance circle in a pour) or nothing at all when the via sits in a
//! pour under flat mask. A dark centre alone is weak evidence, because grain, part shadows and print counters are dark too.
//! So every dark centre is measured, and each one is kept on its own evidence:
//!   - it shows on both faces at the same place once the faces are laid on each other through the spots themselves, or
//!   - it has a ring of its own (pad edge or clearance circle, round most of the way) and the other face cannot contradict it.
//! Which bars apply is read from the photo: the faces are registered from the spots, and the depth a centre needs is the
//! depth at which this pair of photos stops agreeing beyond chance.
use crate::board::RES;
use crate::copper::line_filter;
use crate::geom::{self, P};
use crate::raster::{self, Plane};
use image::RgbImage;
use serde_json::{json, Value};

const RAYS: usize = 24;
/// How far out a spot is measured, mm: past the pad of a 0.8 mm via.
const REACH_MM: f64 = 0.5;

/// A dark centre and everything measured on it. Lengths in mm, brightness in 8 bit luma, positions in board pixels.
#[derive(Clone, Debug)]
pub struct Spot {
    pub x: f64, pub y: f64,
    /// luma at the centre, at the brightest radius within 0.2 mm, and the difference; that difference over the rim; and over the local grain
    pub centre: f32, pub rim: f32, pub depth: f32, pub share: f32, pub z: f32,
    /// the rim against the median brightness of this photo's mask: far under 1 inside a slot, a part or a shadow, where a small number over a small number still makes a large share
    pub rim_rel: f32,
    /// the core at half depth: diameter of a disc of its area, long axis over short axis, and whether it closed inside 0.3 mm
    pub core_mm: f32, pub aspect: f32, pub closed: bool,
    /// shares of the surroundings (0.125 to 0.3 mm out) that are pale (print, solder, bare metal), of a colour that is not the mask's (gold, copper, a tan part) and as dark as the core (a part, a gap)
    pub pale: f32, pub off: f32, pub dark: f32,
    /// share of the same surroundings that is mask
    pub mask: f32,
    /// share of what lies 0.4 to 0.5 mm out that is mask: a via's pad has ended there, the pad of a part's pin has not
    pub far_mask: f32,
    /// the ring round it: strength against the grain, radius, the share of the circle that shows it, and what it is (pad: a disc lighter than what lies outside; gap: a dark circle with lighter both sides)
    pub ring: f32, pub ring_mm: f32, pub ring_round: f32, pub ring_kind: &'static str,
    /// the same spot on the other face: index there and distance after registration (mm), and what the other face shows at the place if it is not a spot
    pub pair: Option<usize>, pub pair_mm: f32, pub other: &'static str,
    /// where this place lies on the other face's picture, pixels, once the faces are laid on each other
    pub at_other: Option<P>,
    pub keep: bool, pub why: &'static str,
}

fn median(v: &mut [f32]) -> f32 { if v.is_empty() { return 0.0; } let k = v.len() / 2; *v.select_nth_unstable_by(k, |a, b| a.partial_cmp(b).unwrap()).1 }

/// Mean of the middle of a sorted copy: the fifth at each end is left out, so a track leaving a via or a part beside it does not decide.
fn trimmed(v: &[f32]) -> f32 { let mut s = v.to_vec(); s.sort_by(|a, b| a.partial_cmp(b).unwrap()); let t = s.len() / 5; s[t..s.len() - t].iter().sum::<f32>() / (s.len() - 2 * t).max(1) as f32 }

/// What a pixel is, told by colour alone against the mask colour of its own photo.
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum Kind { /// the mask, at any brightness
    Mask, /// print, solder, bare tin: bright with little colour
    Pale, /// a part body, a hole, a shadow: dark with little colour
    Black, /// a colour that is not the mask's: gold, copper, a tan part
    Off }

/// The mask colour of a photo: the hue most of its coloured pixels share, and how saturated the mask is there. Any mask colour works, because nothing is assumed about which hue it is.
#[derive(Clone, Copy, Debug)]
pub struct Tone { pub hue: f32, /// saturation under which a pixel of this photo has no colour to speak of: half the mask's own median
    pub sat: f32, /// median luma of the mask, 8 bit
    pub luma: f32 }

impl Tone {
    pub fn of(img: &RgbImage) -> Tone {
        let mut hist = [0f32; 72];
        let step = ((img.width() as usize * img.height() as usize) / 400_000).max(1);
        let px: Vec<(f32, f32, f32)> = img.pixels().step_by(step).map(raster::hsv).collect();
        // well coloured pixels vote for the hue: weakly coloured ones are mostly parts and shadows, of any hue
        for (h, s, v) in &px { if *s > TONE_SAT.1 && *v > TONE_DARK { hist[((h / 5.0) as usize).min(71)] += 1.0; } }
        // the peak of the histogram smoothed over 15 degrees, so a mask that straddles two bins is not split
        let k = (0..72).max_by(|a, b| { let f = |i: usize| hist[(i + 71) % 72] + hist[i] + hist[(i + 1) % 72]; f(*a).partial_cmp(&f(*b)).unwrap() }).unwrap_or(0);
        let hue = 5.0 * k as f32 + 2.5;
        let mut sats: Vec<f32> = px.iter().filter(|(h, s, v)| *s > TONE_SAT.0 && *v > TONE_DARK && { let d = (h - hue).abs(); d.min(360.0 - d) <= TONE_HUE }).map(|q| q.1).collect();
        let mut t = Tone { hue, sat: (0.5 * median(&mut sats)).clamp(TONE_SAT.0, TONE_SAT.1), luma: 0.0 };
        let mut ys: Vec<f32> = img.pixels().step_by(step).filter(|p| t.kind(p) == Kind::Mask).map(|p| 0.299 * p[0] as f32 + 0.587 * p[1] as f32 + 0.114 * p[2] as f32).collect();
        t.luma = median(&mut ys);
        t
    }
    pub fn kind(&self, p: &image::Rgb<u8>) -> Kind {
        let (h, s, v) = raster::hsv(p);
        if s <= self.sat { return if v > TONE_PALE { Kind::Pale } else { Kind::Black }; }
        if v <= TONE_DARK { return Kind::Black; }
        let d = (h - self.hue).abs(); let d = d.min(360.0 - d);
        if d <= TONE_HUE { Kind::Mask } else { Kind::Off }
    }
}
/// The saturation bar lies between these: half the mask's own median saturation, which is 0.84 and 0.69 on board 10 (pour, laminate) but only 0.3 to 0.4 on the CM5 photos, whose mask a fixed 0.3 would call grey.
/// Value over which a pixel without colour is pale and under which any pixel is black, and how far a hue may lie from the mask's (degrees): read on board 10 (2026-11-10),
/// print 0.02 saturated, the pale pad of a via 0.26 at hue 80, gold plating at hue 30 to 45, mask at 135 to 138.
const TONE_SAT: (f32, f32) = (0.12, 0.3); const TONE_PALE: f32 = 0.55; const TONE_DARK: f32 = 0.15; const TONE_HUE: f32 = 35.0;

/// Every dark centre on a face, measured. Nothing is judged here beyond a floor that keeps flat grain out of the list.
pub fn measure(img: &RgbImage) -> Vec<Spot> {
    let (w, h) = (img.width() as usize, img.height() as usize);
    let tone = Tone::of(img);
    let l = raster::luma(img);
    let (fine, coarse) = (raster::blur(&l, 1, 1), raster::blur(&l, 1, 2));
    let (s, b) = (raster::blur(&l, 2, 2), raster::blur(&l, 8, 2));
    let dog = Plane { w, h, d: b.d.iter().zip(&s.d).map(|(x, y)| x - y).collect() };
    let mut abs: Vec<f32> = dog.d.iter().step_by(7).map(|v| v.abs()).collect();
    let clip = 3.0 * median(&mut abs);
    let noise = raster::blur(&Plane { w, h, d: dog.d.iter().map(|v| v.abs().min(clip)).collect() }, (1.5 * RES) as usize, 1);
    let neg = Plane { w, h, d: coarse.d.iter().map(|v| -*v).collect() };
    let k = 2 * (0.1 * RES) as usize + 1;
    let lowest = line_filter(&line_filter(&neg, 1, 0, k, true), 0, 1, k, true);
    let nr = (REACH_MM * RES) as usize + 1;
    let rays: Vec<(f32, f32)> = (0..RAYS).map(|k| { let a = k as f32 / RAYS as f32 * std::f32::consts::TAU; (a.cos(), a.sin()) }).collect();
    let mm = |v: f64| (v * RES).round() as usize;
    let (rim_lo, rim_hi) = (mm(0.05), mm(0.2));
    let (sur_lo, sur_hi) = (mm(0.125), mm(0.3)); let far_lo = mm(0.4);
    let (ring_lo, ring_hi) = (mm(0.175), nr - 4);
    let win = mm(0.3) as i64;
    let mut out: Vec<Spot> = vec![];
    let m = nr + 2;
    if w <= 2 * m || h <= 2 * m { return out; }
    for y in m..h - m { for x in m..w - m {
        if neg.at(x, y) < lowest.at(x, y) { continue; }
        // brightness along each ray, and its median at each radius
        let prof: Vec<Vec<f32>> = rays.iter().map(|(c, si)| (0..nr).map(|r| fine.sample(x as f32 + r as f32 * c, y as f32 + r as f32 * si)).collect()).collect();
        let med: Vec<f32> = (0..nr).map(|r| { let mut v: Vec<f32> = prof.iter().map(|p| p[r]).collect(); median(&mut v) }).collect();
        let centre = (med[0] + med[1]) / 2.0;
        let rim = med[rim_lo..=rim_hi].iter().cloned().fold(f32::MIN, f32::max);
        let depth = rim - centre;
        if depth < FLOOR_DEPTH { continue; }
        let half = centre + depth / 2.0;
        if fine.at(x, y) > half { continue; }
        // the core at half depth
        let idx = |dx: i64, dy: i64| ((dy + win) * (2 * win + 1) + dx + win) as usize;
        let mut mark = vec![false; ((2 * win + 1) * (2 * win + 1)) as usize]; mark[idx(0, 0)] = true;
        let mut st = vec![(0i64, 0i64)];
        let (mut n, mut sx, mut sy, mut sxx, mut syy, mut sxy, mut closed) = (0f32, 0f32, 0f32, 0f32, 0f32, 0f32, true);
        while let Some((dx, dy)) = st.pop() {
            n += 1.0; let (fx, fy) = (dx as f32, dy as f32); sx += fx; sy += fy; sxx += fx * fx; syy += fy * fy; sxy += fx * fy;
            for (ax, ay) in [(1i64, 0i64), (-1, 0), (0, 1), (0, -1)] { let (nx, ny) = (dx + ax, dy + ay);
                if nx.abs() > win || ny.abs() > win { closed = false; continue; }
                if mark[idx(nx, ny)] { continue; }
                if fine.at((x as i64 + nx) as usize, (y as i64 + ny) as usize) <= half { mark[idx(nx, ny)] = true; st.push((nx, ny)); } }
        }
        let (m0, m1) = (sx / n, sy / n); let (cxx, cyy, cxy) = (sxx / n - m0 * m0 + 1.0 / 12.0, syy / n - m1 * m1 + 1.0 / 12.0, sxy / n - m0 * m1);
        let tr = cxx + cyy; let det = (cxx * cyy - cxy * cxy).max(0.0); let disc = (tr * tr / 4.0 - det).max(0.0).sqrt();
        let (l1, l2) = ((tr / 2.0 + disc).max(1e-3), (tr / 2.0 - disc).max(1e-3));
        // what surrounds it
        let (mut pale, mut off, mut dark, mut tot, mut masked) = (0u32, 0u32, 0u32, 0u32, 0u32);
        for (c, si) in &rays { for r in sur_lo..=sur_hi {
            let (u, v) = ((x as f32 + r as f32 * c) as u32, (y as f32 + r as f32 * si) as u32); tot += 1;
            match tone.kind(img.get_pixel(u, v)) { Kind::Pale => pale += 1, Kind::Off => off += 1, Kind::Mask => masked += 1, _ => {} }
            if fine.at(u as usize, v as usize) <= half { dark += 1; }
        } }
        let (mut far, mut far_n) = (0u32, 0u32);
        for (c, si) in &rays { for r in far_lo..nr { far_n += 1; if tone.kind(img.get_pixel((x as f32 + r as f32 * c) as u32, (y as f32 + r as f32 * si) as u32)) == Kind::Mask { far += 1; } } }
        // the ring: a pad's edge (lighter inside than outside) or a clearance circle (darker than both sides)
        let nz = noise.at(x, y) + 1.0;
        let (mut ring, mut ring_r, mut ring_round, mut ring_kind) = (0f32, 0usize, 0f32, "");
        for r in ring_lo..=ring_hi {
            let pad: Vec<f32> = prof.iter().map(|p| (p[r - 3] + p[r - 2] + p[r - 1]) / 3.0 - (p[r + 1] + p[r + 2] + p[r + 3]) / 3.0).collect();
            let gap: Vec<f32> = prof.iter().map(|p| (p[r - 3] + p[r + 3]) / 2.0 - p[r]).collect();
            for (v, kind) in [(pad, "pad"), (gap, "gap")] {
                let t = trimmed(&v);
                if t / nz > ring { ring = t / nz; ring_r = r; ring_kind = kind; ring_round = v.iter().filter(|q| **q > 0.4 * t).count() as f32 / RAYS as f32; }
            }
        }
        out.push(Spot { x: x as f64 + 0.5 + m0 as f64, y: y as f64 + 0.5 + m1 as f64, centre, rim, depth, share: depth / rim.max(1.0), z: depth / nz, rim_rel: rim / tone.luma.max(1.0),
            core_mm: (2.0 * (n as f64 / std::f64::consts::PI).sqrt() / RES) as f32, aspect: (l1 / l2).sqrt(), closed,
            pale: pale as f32 / tot as f32, off: off as f32 / tot as f32, dark: dark as f32 / tot as f32, mask: masked as f32 / tot as f32, far_mask: far as f32 / far_n.max(1) as f32,
            ring, ring_mm: ring_r as f32 / RES as f32, ring_round, ring_kind, pair: None, pair_mm: 0.0, other: "", at_other: None, keep: false, why: "" });
    } }
    // close minima of one spot: the deepest stands
    out.sort_by(|a, b| b.depth.partial_cmp(&a.depth).unwrap());
    let mut uniq: Vec<Spot> = vec![];
    let cell = (SAME_SPOT_MM * RES).ceil().max(1.0);
    let mut grid: std::collections::HashMap<(i64, i64), Vec<usize>> = std::collections::HashMap::new();
    for sp in out {
        let (gx, gy) = ((sp.x / cell) as i64, (sp.y / cell) as i64);
        let near = (-1..=1).flat_map(|a| (-1..=1).map(move |b| (a, b))).any(|(a, b)| grid.get(&(gx + a, gy + b)).is_some_and(|v| v.iter().any(|i| geom::dist((uniq[*i].x, uniq[*i].y), (sp.x, sp.y)) < SAME_SPOT_MM * RES)));
        if near { continue; }
        grid.entry((gx, gy)).or_default().push(uniq.len()); uniq.push(sp);
    }
    uniq
}

/// A centre must stand this far below its rim to be listed at all (8 bit luma). Well under any bar that decides: it only keeps flat grain out of the files.
const FLOOR_DEPTH: f32 = 8.0;
/// Two minima closer than this are one spot (mm): half the closest via pitch seen on the test photos (Driver fields, about 0.5 mm, see `domes`).
const SAME_SPOT_MM: f64 = 0.15;

pub fn spot_json(s: &Spot, side: &str) -> Value {
    let (mx, my) = crate::board::to_mm(s.x, s.y); let r = |v: f32| (v as f64 * 1000.0).round() / 1000.0;
    json!({"side": side, "pos_mm": [(mx * 1000.0).round() / 1000.0, (my * 1000.0).round() / 1000.0], "centre": r(s.centre), "rim": r(s.rim), "depth": r(s.depth), "share": r(s.share), "z": r(s.z), "rimRel": r(s.rim_rel),
        "core_mm": r(s.core_mm), "aspect": r(s.aspect), "closed": s.closed, "pale": r(s.pale), "off": r(s.off), "dark": r(s.dark), "mask": r(s.mask), "farMask": r(s.far_mask),
        "ring": r(s.ring), "ring_mm": r(s.ring_mm), "ringRound": r(s.ring_round), "ringKind": s.ring_kind,
        "pair": s.pair, "pair_mm": r(s.pair_mm), "otherFace": s.other, "otherFace_mm": s.at_other.map(|p| { let m = crate::board::to_mm(p.0, p.1); json!([(m.0 * 1000.0).round() / 1000.0, (m.1 * 1000.0).round() / 1000.0]) }), "keep": s.keep, "why": s.why})
}

/// How the bottom frame lies on the top frame, read from the spots themselves: a shift found by letting every pair of strong spots vote, then a quadratic map once enough pairs hold it.
#[derive(Clone, Debug)]
pub struct Lay { pub shift: P, pub quad: Option<crate::regframe::Quad>, pub pairs: usize, /// the pairs the map rests on: (top, bottom), pixels
    pub prs: Vec<(P, P)>, /// votes at the best shift, and at the best shift once the bottom is mirrored (what chance gives)
    pub votes: f64, pub chance: f64 }

impl Lay {
    /// Where a point of the top frame lies in the bottom frame.
    pub fn to_bottom(&self, p: P) -> P { match &self.quad { Some(q) => q.apply(p), None => (p.0 + self.shift.0, p.1 + self.shift.1) } }
}

/// The faces may be off by this much after the hole fit (mm): the worst seen is 0.58 mm between the Driver faces (`regframe`), and board 10 comes out 0.35 mm off.
const LAY_REACH_MM: f64 = 1.0;
/// Votes are counted within this of a shift (mm): the warp left over inside one board is a few hundredths of a mm per cm.
const LAY_VOTE_MM: f64 = 0.075;
/// The strongest spots of each face vote: enough to hold every via of a small board, few enough that grain does not flood the count.
const LAY_VOTERS: [usize; 4] = [100, 200, 400, 800];
/// A shift counts when it gathers this many votes and this many times what a mirrored bottom gathers. A judgement: board 10 gives 66 against 7 with 300 voters a face.
const LAY_MIN_VOTES: f64 = 12.0; const LAY_OVER_CHANCE: f64 = 4.0;
/// A quadratic over the whole board needs this many pairs to be held (same bar as `board::PIT_PAIRS`).
const LAY_QUAD_PAIRS: usize = 40;

/// A spot that could be the hole of a via at all: its core closes, is roundish and has the size of a hole. Used for registration and as the floor for everything kept.
pub fn plausible(s: &Spot) -> bool { s.rim_rel >= MIN_RIM && s.closed && s.aspect <= (if ringed(s) { MAX_ASPECT_RINGED } else { MAX_ASPECT }) && s.core_mm >= MIN_CORE_MM && s.core_mm <= MAX_CORE_MM }

/// The core of a via's hole is round. Read on the development half of board 10 (left half, 2026-11-10): spots that pair across the faces have a long axis under 1.5 times the short one, and gaps between pads start near 2.
const MAX_ASPECT: f32 = 1.8;
/// Inside a round ring the core may be longer: where the mask nearly closes a hole what is left of it is a sliver (board 10, development half: 1.9 on a via whose ring stands 6.6 over the grain).
const MAX_ASPECT_RINGED: f32 = 2.5;
/// The dark core is the hole's dimple or its open drill: between a speck of grain (under 0.06 mm) and the drill of a part's pin (0.5 mm and up on the test boards' headers).
const MIN_CORE_MM: f32 = 0.06; const MAX_CORE_MM: f32 = 0.3;

fn best_shift(a: &[P], b: &[P]) -> (P, f64) {
    let reach = (LAY_REACH_MM * RES) as i64; let n = (2 * reach + 1) as usize;
    let mut hist = vec![0f32; n * n];
    for p in a { for q in b { let (dx, dy) = ((q.0 - p.0).round() as i64, (q.1 - p.1).round() as i64); if dx.abs() <= reach && dy.abs() <= reach { hist[((dy + reach) as usize) * n + (dx + reach) as usize] += 1.0; } } }
    let k = (LAY_VOTE_MM * RES).round() as usize;
    let sum = raster::blur(&Plane { w: n, h: n, d: hist }, k, 1);
    let area = ((2 * k + 1) * (2 * k + 1)) as f32;
    let (i, v) = sum.d.iter().enumerate().max_by(|x, y| x.1.partial_cmp(y.1).unwrap()).map(|(i, v)| (i, *v)).unwrap_or((0, 0.0));
    (((i % n) as f64 - reach as f64, (i / n) as f64 - reach as f64), (v * area) as f64)
}

/// Lay the bottom on the top through the spots both show. None when no shift stands out from chance: then the faces say nothing about each other.
pub fn lay(top: &[Spot], bottom: &[Spot], wh: (f64, f64)) -> Option<Lay> {
    let strong = |v: &[Spot], k: usize| -> Vec<P> { let mut s: Vec<&Spot> = v.iter().filter(|s| plausible(s)).collect(); s.sort_by(|a, b| b.share.partial_cmp(&a.share).unwrap()); s.iter().take(k).map(|s| (s.x, s.y)).collect() };
    // the deepest spots vote; how many of them are vias is not known yet, so several counts are tried and the clearest stands
    let mut best: Option<(P, f64, f64, usize)> = None;
    for k in LAY_VOTERS {
        let (a, b) = (strong(top, k), strong(bottom, k));
        let (shift, votes) = best_shift(&a, &b);
        let mirrored: Vec<P> = b.iter().map(|q| (wh.0 - q.0, wh.1 - q.1)).collect();
        let chance = best_shift(&a, &mirrored).1.max(1.0);
        if best.as_ref().is_none_or(|q| votes / chance > q.1 / q.2) { best = Some((shift, votes, chance, k)); }
    }
    let (shift, votes, chance, k) = best?;
    if votes < LAY_MIN_VOTES || votes < LAY_OVER_CHANCE * chance { return None; }
    let (a, b) = (strong(top, 2 * k), strong(bottom, 2 * k));
    let mut l = Lay { shift, quad: None, pairs: 0, prs: vec![], votes, chance };
    for reach_mm in [0.2, 0.15, 0.1, 0.075, 0.075] {
        let prs: Vec<(P, P)> = a.iter().filter_map(|t| { let m = l.to_bottom(*t); b.iter().map(|o| (o, geom::dist(m, *o))).min_by(|x, y| x.1.partial_cmp(&y.1).unwrap()).filter(|x| x.1 < reach_mm * RES).map(|x| (*t, *x.0)) }).collect();
        l.pairs = prs.len(); l.prs = prs.clone();
        if prs.len() >= LAY_QUAD_PAIRS { l.quad = crate::regframe::Quad::fit(&prs).or(l.quad); }
        else if !prs.is_empty() { let n = prs.len() as f64; l.shift = (prs.iter().map(|p| p.1 .0 - p.0 .0).sum::<f64>() / n, prs.iter().map(|p| p.1 .1 - p.0 .1).sum::<f64>() / n); }
    }
    Some(l)
}

/// Pair every plausible spot of the top with the nearest plausible spot of the bottom within `PAIR_MM` of where it should lie, each used once, nearest first.
pub fn pair(top: &mut [Spot], bottom: &mut [Spot], l: &Lay) {
    let mut cand: Vec<(f64, usize, usize)> = vec![];
    let cell = PAIR_MM * RES; let mut grid: std::collections::HashMap<(i64, i64), Vec<usize>> = std::collections::HashMap::new();
    for (j, s) in bottom.iter().enumerate() { if plausible(s) { grid.entry(((s.x / cell).floor() as i64, (s.y / cell).floor() as i64)).or_default().push(j); } }
    for (i, s) in top.iter().enumerate() { if !plausible(s) { continue; } let m = l.to_bottom((s.x, s.y)); let (gx, gy) = ((m.0 / cell).floor() as i64, (m.1 / cell).floor() as i64);
        for a in -1..=1 { for b in -1..=1 { for j in grid.get(&(gx + a, gy + b)).into_iter().flatten() { let d = geom::dist(m, (bottom[*j].x, bottom[*j].y)); if d < cell { cand.push((d, i, *j)); } } } } }
    cand.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
    for (d, i, j) in cand { if top[i].pair.is_some() || bottom[j].pair.is_some() { continue; } top[i].pair = Some(j); bottom[j].pair = Some(i); top[i].pair_mm = (d / RES) as f32; bottom[j].pair_mm = (d / RES) as f32; }
}
/// The same via lies within this on both faces once they are registered (mm): the reach `domes::pits_confirmed` uses.
pub const PAIR_MM: f64 = 0.1;
/// A hole this close to a dome read on the same face is the same via (mm): `domes` merges at the same distance.
pub const SAME_VIA_MM: f64 = 0.3;

impl Lay {
    /// Where a point of the bottom frame lies in the top frame: the map is close to a shift, so stepping back by the miss converges at once.
    pub fn to_top(&self, p: P) -> P { let mut t = (p.0 - self.shift.0, p.1 - self.shift.1); for _ in 0..4 { let m = self.to_bottom(t); t = (t.0 + p.0 - m.0, t.1 + p.1 - m.1); } t }
}

/// What a face shows at a place where it has no spot: mask (it can see, and sees no hole) or something lying over the board (a part, print, a pad).
fn covered(img: &RgbImage, tone: &Tone, at: P) -> bool {
    let r = (COVER_MM * RES) as i64; let (mut mask, mut n) = (0u32, 0u32);
    for dy in -r..=r { for dx in -r..=r { if dx * dx + dy * dy > r * r { continue; } let (u, v) = (at.0 as i64 + dx, at.1 as i64 + dy);
        if u < 0 || v < 0 || u >= img.width() as i64 || v >= img.height() as i64 { continue; } n += 1; if tone.kind(img.get_pixel(u as u32, v as u32)) == Kind::Mask { mask += 1; } } }
    n == 0 || (mask as f32 / n as f32) < COVER_MASK
}
/// A face sees the board at a place when this share of a disc of this radius there is mask. The radius is a via pad's; the share leaves room for a track's glint or a letter's edge.
const COVER_MM: f64 = 0.3; const COVER_MASK: f32 = 0.7;

/// The bars one face's spots are held to, read from how they pair with the other face.
#[derive(Clone, Debug, Default)]
pub struct Bars { /// the band of depth share in which this face's spots pair with the other face beyond chance; None: no depth does
    pub band: Option<(f32, f32)>, /// the depths at which a spot may stand on this face alone
    pub alone: Option<(f32, f32)>, /// per step of depth share: spots, of them paired, and pairs chance would give
    pub table: Vec<(f32, usize, usize, f64)>, /// per step: spots whose place the other face can see, and of them paired
    pub seen: Vec<(usize, usize)> }

/// Metal all round: this share of the surroundings is pale or of a colour that is not the mask's, while under this share of what lies 0.4 to 0.5 mm out is mask.
/// Board 10, development half: header pins and standoffs give 0.6 to 1.0 and under 0.1; vias with a pale pad 0.3 to 0.6 and 0.55 and up.
const METAL_SHARE: f32 = 0.5; const METAL_FAR_MASK: f32 = 0.3;
/// At this share it is metal whatever follows: a blob off centre in a pin's hole reaches mask on one side (board 10, development half: 0.97 there, 0.78 at most round a via with a pale pad).
const METAL_ALL: f32 = 0.9;
/// Print all round a spot: the counter of a letter or a stroke knocked out of a label. There a ring means nothing, because the print's own edges make one.
const PRINT_PALE: f32 = 0.85;
/// A spot with no ring, kept on one face alone, must have this share of mask round it (0.125 to 0.3 mm out). Set on the CM5 run after the first comparison with its old via list: of 54 vias only the new reader kept,
/// about 18 were shadows and gaps among parts, nearly all with no ring; the unringed single-face vias on the development half of board 10 have 0.55 and up.
const ALONE_MASK: f32 = 0.5;
/// A spot kept on one face alone must have this share of mask 0.4 to 0.5 mm out: a hole inside a connector shell or a part has none (board 10, development half: under 0.1 there, 0.55 and up round vias).
const ALONE_FAR_MASK: f32 = 0.3;
/// A ring counts when it stands this far over the grain round this share of the circle. Read on the development half of board 10: pads on laminate give 4 to 8 round 0.6 to 0.95 of the circle, vias in pours and grain under 2.
const RING_MIN: f32 = 3.0; const RING_ROUND: f32 = 0.6;
/// With this much pale round a spot the ring may be the edge of print: the counters of the letters R and e on the CM5 top read as rings of 6 and 8 with 0.77 and 0.68 pale (audit items a011 and a047 of the CM5 run),
/// where the pale pad of a via on board 10 gives 0.39 at most.
const RING_MAX_PALE: f32 = 0.5;
/// A depth counts as pairing beyond chance when its spots pair this many times more often than chance, with at least this many pairs (the factor is the one `domes::pits_confirmed` uses).
const OVER_CHANCE: f64 = 5.0; const MIN_PAIRS: usize = 2;
/// And so many pairs must be this unlikely by chance alone (Poisson): with few spots in a step, five times a small number is still a small number.
const CHANCE_P: f64 = 0.01;
/// Depth share is judged in this many steps.
const STEPS: usize = 20;
/// A spot may stand on one face alone at a depth where this share of the spots the other face can see are paired, step by step.
const ALONE_PAIRED: f64 = 0.8;

/// Metal or print all round with no mask further out: solder, tin or gold round a part's pin, hardware, or a label. A via's own pad may be pale or gilt too, but it ends within 0.4 mm and mask follows.
/// A via under print looks the same on that face, so this only decides where the other face does not show mask round its spot.
fn metal_round(s: &Spot) -> bool { let m = s.pale + s.off; m >= METAL_ALL || (m >= METAL_SHARE && (s.far_mask < METAL_FAR_MASK || !ringed(s))) }
fn ringed(s: &Spot) -> bool { s.ring >= RING_MIN && s.ring_round >= RING_ROUND && s.pale < RING_MAX_PALE }
/// Why a spot cannot be a via's hole whatever the other face shows, if so.
fn unfit(s: &Spot) -> Option<&'static str> {
    if !s.closed { Some("the dark core runs on: a gap or a part's edge") } else if s.aspect > (if ringed(s) { MAX_ASPECT_RINGED } else { MAX_ASPECT }) { Some("not round") } else if s.core_mm < MIN_CORE_MM { Some("a speck") } else if s.core_mm > MAX_CORE_MM { Some("too wide for a via's hole") }
    else if s.rim_rel < MIN_RIM { Some("dark all round: inside a slot, a part or a shadow") } else { None }
}
/// The rim of a via's hole is mask or pad, so it is about as bright as the photo's mask. Set after the held-out run on board 10, where the one plainly false keep sat inside a slot (rim 0.2 of the mask); vias there give 0.9 and up.
const MIN_RIM: f32 = 0.5;

/// The chance of `k` or more events where `lambda` are expected.
fn poisson_tail(k: usize, lambda: f64) -> f64 { let (mut term, mut below) = ((-lambda).exp(), 0.0); for i in 0..k { below += term; term *= lambda / (i + 1) as f64; } (1.0 - below).max(0.0) }

fn bars(mine: &[Spot], see: &[bool], other_count: usize, area: f64) -> Bars {
    let p_chance = other_count as f64 / area * std::f64::consts::PI * (PAIR_MM * RES).powi(2);
    let bin = |s: &Spot| ((s.share * STEPS as f32) as usize).min(STEPS - 1);
    let mut t = vec![(0usize, 0usize, 0usize, 0usize); STEPS];
    // only spots that stand in mask are counted: a mark on a part or a blob in a pin's hole is deep too, and can never pair
    for (s, v) in mine.iter().zip(see) { if unfit(s).is_some() || metal_round(s) || s.far_mask < ALONE_FAR_MASK { continue; } let b = bin(s); t[b].0 += 1; if s.pair.is_some() { t[b].1 += 1; } if *v { t[b].2 += 1; if s.pair.is_some() { t[b].3 += 1; } } }
    let table: Vec<(f32, usize, usize, f64)> = (0..STEPS).map(|b| (b as f32 / STEPS as f32, t[b].0, t[b].1, t[b].0 as f64 * p_chance)).collect();
    // Vias are one population of depth, so they fill a band of steps: on board 10 the deepest spots of all, on the CM5 a band with a few deeper spots (gaps between parts) above it.
    // Each step stands on its own count; an empty step breaks nothing. The band is the run of passing steps that holds the most pairs.
    let mut groups: Vec<(usize, usize, bool, usize)> = vec![]; // lowest step, highest step, passes, pairs
    for b in (0..STEPS).rev() {
        if t[b].0 == 0 { continue; }
        let lambda = t[b].0 as f64 * p_chance;
        groups.push((b, b, t[b].1 >= MIN_PAIRS && t[b].1 as f64 >= OVER_CHANCE * lambda && poisson_tail(t[b].1, lambda) <= CHANCE_P, t[b].1));
    }
    let mut best: Option<(usize, usize, usize)> = None; let mut run: Option<(usize, usize, usize)> = None;
    for g in &groups {
        run = if g.2 { Some(match run { Some(r) => (g.0, r.1, r.2 + g.3), None => (g.0, g.1, g.3) }) } else { None };
        if let Some(r) = run { if best.is_none_or(|q| r.2 > q.2) { best = Some(r); } }
    }
    let band = best.map(|r| (r.0 as f32 / STEPS as f32, (r.1 + 1) as f32 / STEPS as f32));
    // alone: the steps of the band where the spots the other face can see are nearly all paired; a step the other face sees nothing of says nothing either way
    let mut alone: Option<(usize, usize, usize)> = None;
    if let Some(r) = best {
        let mut run: Option<(usize, usize, usize)> = None;
        for b in (r.0..=r.1).rev() {
            if t[b].2 == 0 { continue; }
            run = if t[b].2 >= MIN_PAIRS && t[b].3 as f64 >= ALONE_PAIRED * t[b].2 as f64 { Some(match run { Some(q) => (b, q.1, q.2 + t[b].3), None => (b, b, t[b].3) }) } else { None };
            if let Some(q) = run { if alone.is_none_or(|a| q.2 > a.2) { alone = Some(q); } }
        }
    }
    let alone = alone.map(|r| (r.0 as f32 / STEPS as f32, (r.1 + 1) as f32 / STEPS as f32));
    Bars { band, alone, table, seen: t.iter().map(|r| (r.2, r.3)).collect() }
}

/// Both faces read together.
pub struct Reading { pub top: Vec<Spot>, pub bottom: Vec<Spot>, pub lay: Option<Lay>, pub bars: [Bars; 2], pub lines: Vec<String> }

/// Measure both faces, lay them on each other, and decide every spot. Each spot leaves with `keep` and `why` set.
pub fn read(top: &RgbImage, bottom: &RgbImage) -> Reading {
    let (mut t, mut b) = (measure(top), measure(bottom));
    let (tt, tb) = (Tone::of(top), Tone::of(bottom));
    let wh = (top.width() as f64, top.height() as f64);
    let l = lay(&t, &b, wh);
    let mut lines = vec![];
    let Some(l) = l else {
        for s in t.iter_mut().chain(b.iter_mut()) { s.why = unfit(s).unwrap_or("the faces could not be laid on each other through their spots, so nothing confirms this one"); }
        lines.push(format!("dark centres: {} on the top, {} on the bottom; no shift of the bottom makes them line up beyond chance, so this pair of photos does not show vias as holes", t.len(), b.len()));
        return Reading { top: t, bottom: b, lay: None, bars: [Bars::default(), Bars::default()], lines };
    };
    pair(&mut t, &mut b, &l);
    // what the other face shows where a spot has no partner
    let see_t: Vec<bool> = t.iter().map(|s| s.pair.is_some() || !covered(bottom, &tb, l.to_bottom((s.x, s.y)))).collect();
    let see_b: Vec<bool> = b.iter().map(|s| s.pair.is_some() || !covered(top, &tt, l.to_top((s.x, s.y)))).collect();
    for s in t.iter_mut() { s.at_other = Some(l.to_bottom((s.x, s.y))); } for s in b.iter_mut() { s.at_other = Some(l.to_top((s.x, s.y))); }
    for (s, v) in t.iter_mut().zip(&see_t).chain(b.iter_mut().zip(&see_b)) { s.other = if s.pair.is_some() { "spot" } else if *v { "bare" } else { "covered" }; }
    let area = wh.0 * wh.1;
    let fit = |v: &[Spot]| v.iter().filter(|s| plausible(s)).count();
    let bt = bars(&t, &see_t, fit(&b), area); let bb = bars(&b, &see_b, fit(&t), area);
    // pairs first: both partners pass their face's bar, or one stands deep enough to stand alone
    let fair = |s: &Spot, br: &Bars| br.band.is_some_and(|a| s.share >= a.0 && s.share < a.1);
    let deep = |s: &Spot, br: &Bars| br.alone.is_some_and(|a| s.share >= a.0 && s.share < a.1);
    // a spot that stands on its own evidence: deep for this photo (or fair with a ring), in mask, not in metal or print
    let stands = |s: &Spot, br: &Bars| -> Result<(), &'static str> {
        if unfit(s).is_some() { return Err("not a hole"); }
        if metal_round(s) || s.far_mask < ALONE_FAR_MASK { return Err("stands in metal, solder or a part, and the other face does not confirm it"); }
        if s.pale >= PRINT_PALE { return Err("stands in print, and the other face does not confirm it"); }
        if !ringed(s) && s.mask < ALONE_MASK { return Err("stands among parts with no ring of its own, and the other face does not confirm it"); }
        if deep(s, br) || (fair(s, br) && ringed(s)) { Ok(()) } else { Err("too shallow to stand on one face alone") }
    };
    let verdict = |s: &Spot, o: Option<&Spot>, mine: &Bars, theirs: &Bars| -> (bool, &'static str) {
        if let Some(w) = unfit(s) { return (false, w); }
        if let Some(o) = o {
            if fair(s, mine) && fair(o, theirs) { return if metal_round(s) && metal_round(o) { (false, "metal all round on both faces: the pad of a part's pin or hardware") } else { (true, "seen on both faces") }; }
            if stands(o, theirs).is_ok() && !metal_round(s) { return (true, "faint here, a deep hole on the other face at the same place"); }
        }
        match (stands(s, mine), s.other) {
            (Err(w), _) => (false, if o.is_some() && w == "too shallow to stand on one face alone" { "pairs with the other face, but at a depth where pairs are no more than chance" } else { w }),
            (Ok(()), "spot") => (true, "deep hole; the other face shows a faint spot at the same place"),
            (Ok(()), "bare") => if ringed(s) { (true, "deep hole with a ring; the other face shows mask here") } else { (false, "the other face is bare here and shows no hole") },
            (Ok(()), _) => (true, if ringed(s) { "hole with a ring; the other face is covered here" } else { "deep hole; the other face is covered here" }),
        }
    };
    let vt: Vec<(bool, &'static str)> = t.iter().map(|s| verdict(s, s.pair.map(|j| &b[j]), &bt, &bb)).collect();
    let vb: Vec<(bool, &'static str)> = b.iter().map(|s| verdict(s, s.pair.map(|j| &t[j]), &bb, &bt)).collect();
    for (s, v) in t.iter_mut().zip(vt).chain(b.iter_mut().zip(vb)) { s.keep = v.0; s.why = v.1; }
    lines.push(format!("faces laid on each other through {} spots both show: the bottom as drawn lies {:.2}, {:.2} mm off the top{} ({:.0} votes against {:.0} for a mirrored bottom)", l.pairs, l.shift.0 / RES, l.shift.1 / RES, if l.quad.is_some() { ", with a quadratic warp" } else { "" }, l.votes, l.chance));
    for (side, v, br) in [("top", &t, &bt), ("bottom", &b, &bb)] {
        let kept = v.iter().filter(|s| s.keep).count(); let both = v.iter().filter(|s| s.keep && s.pair.is_some()).count();
        let bar = |x: Option<(f32, f32)>| x.map_or("none".to_string(), |a| format!("{:.2} to {:.2}", a.0, a.1));
        lines.push(format!("{side}: {} dark centres, {kept} kept as vias ({both} seen on both faces, {} on this face alone); depth share {} pairs with the other face beyond chance, {} may stand alone", v.len(), kept - both, bar(br.band), bar(br.alone)));
    }
    Reading { top: t, bottom: b, lay: Some(l), bars: [bt, bb], lines }
}

/// The width to write for a kept spot: its pad where a ring shows it, else the hole's own width.
pub fn width_mm(s: &Spot) -> (f64, &'static str) { if ringed(s) && s.ring_kind == "pad" { (2.0 * s.ring_mm as f64, "hole-ring") } else { (s.core_mm as f64, "hole") } }

pub fn bars_json(b: &Bars) -> Value { json!({"band": b.band.map(|a| [a.0, a.1]), "alone": b.alone.map(|a| [a.0, a.1]), "byDepthShare": b.table.iter().zip(&b.seen).map(|(r, v)| json!({"from": r.0, "spots": r.1, "paired": r.2, "byChance": (r.3 * 100.0).round() / 100.0, "otherFaceSees": v.0})).collect::<Vec<_>>()}) }

/// Hold the bottom photo to the top through the vias both show as holes. `bottom` is the bottom as drawn so far, through `prev` (the map from the top frame to the frame the photo was first drawn in, as `regframe::warp` takes it) or through its corners alone.
/// Returns the map to draw it with instead, and what it achieved. None unless a quadratic is held by enough pairs.
pub fn relay(top: &RgbImage, bottom: &RgbImage, prev: Option<&crate::regframe::Quad>) -> Option<(crate::regframe::Quad, Value)> {
    let (t, b) = (measure(top), measure(bottom));
    let l = lay(&t, &b, (top.width() as f64, top.height() as f64))?;
    l.quad.as_ref()?;
    // a via the top shows at t lies at b in the bottom as drawn, which was sampled from the first frame at prev(b)
    let mut prs: Vec<(P, P)> = l.prs.iter().map(|(t, b)| (*t, prev.map_or(*b, |q| q.apply(*b)))).collect();
    let mut q = crate::regframe::Quad::fit(&prs)?;
    for _ in 0..3 { let mut res: Vec<f64> = prs.iter().map(|x| geom::dist(q.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(1.5); prs.retain(|x| geom::dist(q.apply(x.0), x.1) <= lim); q = crate::regframe::Quad::fit(&prs)?; }
    if prs.len() < LAY_QUAD_PAIRS { return None; }
    let mut r = crate::regframe::report(&prs, &q, RES); r["through"] = json!("vias showing on both faces as holes"); r["offBeforeMm"] = json!([(l.shift.0 / RES * 1000.0).round() / 1000.0, (l.shift.1 / RES * 1000.0).round() / 1000.0]);
    Some((q, r))
}

#[cfg(test)]
mod tests {
    use super::*;
    use image::Rgb;

    const MASK: [f32; 3] = [40.0, 150.0, 70.0];
    /// A mask-coloured face with grain, and what `f` draws on it.
    fn face(w: u32, h: u32, f: impl Fn(f64, f64) -> Option<[f32; 3]>) -> RgbImage {
        RgbImage::from_fn(w, h, |x, y| {
            let g = (((x * 7919 + y * 104729) % 13) as f32) - 6.0;
            let c = f(x as f64, y as f64).unwrap_or(MASK);
            Rgb([(c[0] + g * 0.3).clamp(0.0, 255.0) as u8, (c[1] + g).clamp(0.0, 255.0) as u8, (c[2] + g * 0.5).clamp(0.0, 255.0) as u8])
        })
    }
    fn disc(at: &[(f64, f64)], r: f64, x: f64, y: f64) -> bool { at.iter().any(|p| (p.0 - x).powi(2) + (p.1 - y).powi(2) <= r * r) }
    /// Places spread over the frame, none closer than 30 px to another.
    fn places(n: usize, seed: u64, w: f64, h: f64) -> Vec<(f64, f64)> {
        let (mut out, mut s) = (vec![], seed);
        while out.len() < n {
            s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
            let p = (60.0 + ((s >> 33) % (w as u64 - 120)) as f64, 60.0 + ((s >> 13) % (h as u64 - 120)) as f64);
            if out.iter().all(|q: &(f64, f64)| (q.0 - p.0).abs().max((q.1 - p.1).abs()) >= 30.0) { out.push(p); }
        }
        out
    }
    const HOLE: [f32; 3] = [18.0, 24.0, 14.0];
    const SPECK: [f32; 3] = [34.0, 118.0, 58.0];

    #[test]
    fn a_hole_is_measured_and_a_gap_line_is_not_a_hole() {
        let img = face(400, 300, |x, y| if disc(&[(100.0, 100.0)], 3.5, x, y) { Some(HOLE) } else if (y - 200.0).abs() < 2.5 && x > 50.0 && x < 350.0 { Some(HOLE) } else { None });
        let sp = measure(&img);
        let v = sp.iter().find(|s| geom::dist((s.x, s.y), (100.5, 100.5)) < 2.0).expect("the hole is listed");
        assert!(v.share > 0.6 && plausible(v) && v.mask > 0.9 && v.far_mask > 0.9, "{v:?}");
        assert!((v.core_mm - 7.0 / RES as f32).abs() < 0.06, "{}", v.core_mm);
        assert!(sp.iter().filter(|s| (s.y - 200.0).abs() < 6.0).all(|s| !plausible(s)), "a line has no round closed core");
    }

    #[test]
    fn the_mask_colour_is_read_from_the_photo_whatever_it_is() {
        for mask in [[170.0, 30.0, 40.0], [30.0, 60.0, 170.0], [40.0, 150.0, 70.0]] {
            let img = RgbImage::from_fn(300, 200, |x, _| if x < 40 { Rgb([240, 240, 236]) } else if x < 70 { Rgb([200, 160, 60]) } else if x < 90 { Rgb([25, 25, 28]) } else { Rgb([mask[0] as u8, mask[1] as u8, mask[2] as u8]) });
            let t = Tone::of(&img);
            assert_eq!(t.kind(&Rgb([mask[0] as u8, mask[1] as u8, mask[2] as u8])), Kind::Mask);
            assert_eq!(t.kind(&Rgb([240, 240, 236])), Kind::Pale);
            assert_eq!(t.kind(&Rgb([25, 25, 28])), Kind::Black);
            assert_eq!(t.kind(&Rgb([200, 160, 60])), Kind::Off, "gold is not the mask on {mask:?}");
        }
    }

    #[test]
    fn chance_is_counted_as_a_poisson_tail() {
        assert!((poisson_tail(0, 2.0) - 1.0).abs() < 1e-9);
        assert!((poisson_tail(1, 0.5) - (1.0 - (-0.5f64).exp())).abs() < 1e-9);
        assert!(poisson_tail(2, 0.36) > 0.01 && poisson_tail(3, 0.14) < 0.01);
    }

    /// Two faces of one board: 60 through vias, the bottom drawn 3 px right and 12 px down of the top, grain specks that differ per face, a part over five of the vias on the bottom,
    /// a dark dot inside a printed label and a pin's pad on the top.
    fn board() -> (RgbImage, RgbImage, Vec<(f64, f64)>) {
        let (w, h) = (1200.0, 900.0);
        let vias = places(60, 7, w, h);
        let specks_t = places(50, 11, w, h); let specks_b = places(50, 13, w, h);
        let hidden: Vec<(f64, f64)> = vias[..5].to_vec();
        let shifted: Vec<(f64, f64)> = vias.iter().map(|p| (p.0 + 3.0, p.1 + 12.0)).collect();
        let top = face(w as u32, h as u32, |x, y| {
            if disc(&vias, 3.5, x, y) { Some(HOLE) } else if disc(&[(30.0, 30.0)], 3.0, x, y) { Some(HOLE) } else if x < 60.0 && y < 60.0 { Some([240.0, 240.0, 236.0]) }
            else if disc(&[(1150.0, 40.0)], 8.0, x, y) { Some(HOLE) } else if disc(&[(1150.0, 40.0)], 24.0, x, y) { Some([225.0, 225.0, 220.0]) }
            else if disc(&specks_t, 2.0, x, y) { Some(SPECK) } else { None } });
        let bottom = face(w as u32, h as u32, |x, y| {
            if hidden.iter().any(|p| (x - p.0 - 3.0).abs() < 22.0 && (y - p.1 - 12.0).abs() < 22.0) { Some([60.0, 58.0, 62.0]) }
            else if disc(&shifted, 3.5, x, y) { Some(HOLE) } else if disc(&specks_b, 2.0, x, y) { Some(SPECK) } else { None } });
        (top, bottom, vias)
    }

    #[test]
    fn vias_are_kept_spot_by_spot_and_what_only_one_face_shows_is_judged_on_its_own() {
        let (top, bottom, vias) = board();
        let r = read(&top, &bottom);
        let l = r.lay.as_ref().expect("the faces lay on each other");
        let m = l.to_bottom((600.0, 450.0));
        assert!((m.0 - 603.0).abs() < 1.0 && (m.1 - 462.0).abs() < 1.0, "{m:?}");
        let kept_t: Vec<&Spot> = r.top.iter().filter(|s| s.keep).collect();
        let kept_b: Vec<&Spot> = r.bottom.iter().filter(|s| s.keep).collect();
        // every via on the top, those under the part included: deep holes whose other face is covered
        for v in &vias { assert!(kept_t.iter().any(|s| geom::dist((s.x, s.y), (v.0 + 0.5, v.1 + 0.5)) < 2.0), "top via at {v:?}"); }
        assert_eq!(kept_t.len(), 60, "{:?}", kept_t.iter().map(|s| (s.x, s.y, s.why)).collect::<Vec<_>>());
        assert_eq!(kept_b.len(), 55, "the part hides five on the bottom");
        assert_eq!(kept_t.iter().filter(|s| s.pair.is_none()).count(), 5);
        assert!(kept_t.iter().filter(|s| s.pair.is_none()).all(|s| s.other == "covered"));
        // every spot leaves with a reason, kept or not
        assert!(r.top.iter().chain(&r.bottom).all(|s| !s.why.is_empty()));
        // the dot in print and the pin's hole are listed and dropped
        let dot = r.top.iter().find(|s| geom::dist((s.x, s.y), (30.5, 30.5)) < 3.0).expect("the dot in print is listed");
        assert!(!dot.keep, "{}", dot.why);
        assert!(r.top.iter().filter(|s| geom::dist((s.x, s.y), (1150.5, 40.5)) < 6.0).all(|s| !s.keep));
        let (bt, bb) = (&r.bars[0], &r.bars[1]);
        assert!(bt.band.is_some_and(|b| b.0 >= 0.5) && bb.band.is_some_and(|b| b.0 >= 0.5), "grain is not in the band: {:?} {:?}", bt.band, bb.band);
    }

    #[test]
    fn faces_that_share_nothing_keep_nothing() {
        let (w, h) = (1200.0, 900.0);
        let (a, b) = (places(60, 21, w, h), places(60, 22, w, h));
        let top = face(w as u32, h as u32, |x, y| if disc(&a, 3.5, x, y) { Some(HOLE) } else { None });
        let bottom = face(w as u32, h as u32, |x, y| if disc(&b, 3.5, x, y) { Some(HOLE) } else { None });
        let r = read(&top, &bottom);
        assert!(r.lay.is_none(), "{:?}", r.lay.map(|l| (l.votes, l.chance)));
        assert!(r.top.iter().chain(&r.bottom).all(|s| !s.keep && !s.why.is_empty()));
    }

    #[test]
    fn the_bottom_is_held_to_the_top_through_the_vias_both_show() {
        let (top, bottom, _) = board();
        let (q, rep) = relay(&top, &bottom, None).expect("enough vias to hold a quadratic");
        let m = q.apply((300.0, 700.0));
        assert!((m.0 - 303.0).abs() < 1.0 && (m.1 - 712.0).abs() < 1.0, "{m:?}");
        assert!(rep["pairs"].as_u64().unwrap() >= 40 && rep["quadraticRmsMm"].as_f64().unwrap() < 0.03, "{rep}");
    }
}