main
Caleb Publish 0.5.0 1984b40
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757
//! `nets draw`: the nets as pictures, under reverse-flow/nets/.
//!
//! - `confidence.jpg`: both faces, every piece of copper in the colour and pattern of how well its net is proven
//!   (`netconf.rs`: the weakest link joining the net's pins), with a legend and the counts. `confidence-top.jpg` and
//!   `confidence-bottom.jpg` are the same at the full 40 px/mm for looking closely.
//! - `attribution.jpg`: the evidence under it, each piece by its own rung, so the two can be compared.
//! - `confidence-table-<n>.jpg`: every net with its level, pins seen and weakest link.
//! - `net/<name>.jpg`: one picture per net, its copper, vias and pins lit on both faces and the rest of the board dimmed.
//! - `sections/<nn>-<name>.jpg`: the nets in groups (`netsections.rs`), up to eight to a sheet, each in its own hue.
//! - `index.md`: links to all of it. `sections.json`: the same for a program.
//!
//! Everything is drawn in the board frame over the face photo. The bottom photo is already turned over to line up with
//! the top, so a via sits at the same place in both panels. Nothing here reads copper from a photo: the shapes are
//! copper.json's, the nets and rungs are attribution's.
use crate::netconf::{self, nat_cmp, rank, LADDER};
use crate::netpaint::*;
use crate::netsections::{self, Section};
use image::RgbImage;
use serde_json::{json, Value};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::Path;

const RES: f64 = crate::board::RES;
const ORIGIN: f64 = crate::board::ORIGIN;
const SIDES: [&str; 2] = ["top", "bottom"];
const SIDE_TITLES: [&str; 2] = ["TOP FACE", "BOTTOM FACE, turned over to line up with the top"];
/// Nets to a section sheet: one hue each.
const PER_SHEET: usize = 8;
/// A net that covers more of the board than this is shown on the whole board, not zoomed.
const ZOOM_MAX_SHARE: f64 = 0.45;

pub struct Zone { pub(crate) net: usize, pub(crate) rung: usize, pub(crate) side: usize, pub(crate) runs: Vec<(u32, u32, u32)>, bb: (f64, f64, f64, f64), area: f64, anchor: (f64, f64) }
pub struct Track { pub(crate) net: usize, pub(crate) rung: usize, pub(crate) side: usize, pub(crate) pts: Vec<(f64, f64)>, pub(crate) width: f64 }
pub struct Via { pub(crate) net: usize, pub(crate) rung: usize, pub(crate) x: f64, pub(crate) y: f64, pub(crate) d: f64 }
#[derive(Clone)]
pub struct Pin { part: String, pub(crate) x: f64, pub(crate) y: f64, w: f64, h: f64, rot: f64, round: bool, pub(crate) sides: [bool; 2] }
pub struct Part { name: String, side: usize, corners: [(f64, f64); 4] }
pub struct Scene {
    pub(crate) name: String, pub(crate) wmm: f64, pub(crate) hmm: f64, pub(crate) faces: [Option<Vec<RgbImage>>; 2],
    pub(crate) zones: Vec<Zone>, pub(crate) tracks: Vec<Track>, pub(crate) vias: Vec<Via>, pub(crate) pins: HashMap<String, Pin>, parts: Vec<Part>,
    pub(crate) net_ix: HashMap<String, usize>,
    /// Per face: the words to print over it when its copper failed `check copper` (`notread`). Its copper is not in `zones` or `tracks`.
    pub(crate) not_read: [Option<String>; 2],
}

/// The photo dimmed and greyed, so colour on it means something, and halved a few times for drawing small.
fn dimmed(img: &RgbImage) -> Vec<RgbImage> {
    let mut base = img.clone();
    for p in base.pixels_mut() { let g = 0.299 * p[0] as f32 + 0.587 * p[1] as f32 + 0.114 * p[2] as f32; for k in 0..3 { p[k] = ((0.72 * g + 0.28 * p[k] as f32) * 0.58 + 12.0) as u8; } }
    let mut out = vec![base];
    for _ in 0..3 {
        let a = out.last().unwrap(); let (w, h) = (a.width() / 2, a.height() / 2); if w < 8 || h < 8 { break; }
        out.push(RgbImage::from_fn(w, h, |x, y| { let mut s = [0u32; 3]; for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] { let p = a.get_pixel(x * 2 + dx, y * 2 + dy); for k in 0..3 { s[k] += p[k] as u32; } } image::Rgb([(s[0] / 4) as u8, (s[1] / 4) as u8, (s[2] / 4) as u8]) }));
    }
    out
}

fn pts_of(v: &Value) -> Vec<(f64, f64)> { v.as_array().into_iter().flatten().map(|q| (q[0].as_f64().unwrap_or(0.0), q[1].as_f64().unwrap_or(0.0))).collect() }

pub fn load(flow: &Path) -> Result<Scene, String> {
    let copper = crate::items::read_json(&flow.join("copper.json"))?;
    let mut faces: [Option<Vec<RgbImage>>; 2] = [None, None];
    let (mut wmm, mut hmm) = (0.0f64, 0.0f64);
    for (k, side) in SIDES.iter().enumerate() {
        let p = flow.join(format!("board/{side}.jpg"));
        if !p.is_file() { continue; }
        let img = image::open(&p).map_err(|e| format!("{}: {e}", p.display()))?.to_rgb8();
        wmm = wmm.max(img.width() as f64 / RES); hmm = hmm.max(img.height() as f64 / RES);
        faces[k] = Some(dimmed(&img));
    }
    if faces[0].is_none() && faces[1].is_none() { return Err("no board/top.jpg or board/bottom.jpg: `scale` writes them".into()); }
    let mut net_ix: HashMap<String, usize> = HashMap::new();
    let mut ix = |n: &str| -> usize { let k = net_ix.len(); *net_ix.entry(n.to_string()).or_insert(k) };
    let side_of = |layer: Option<&str>| if layer == Some("B.Cu") { 1 } else { 0 };
    let rung_of = |v: &Value| rank(v["netEvidence"]["rung"].as_str().unwrap_or("none"));
    let (mut zones, mut tracks, mut vias) = (vec![], vec![], vec![]);
    // a face whose copper failed `check copper` is not drawn as copper of any grade: nothing on it was proven, and the panel says so
    let unread = crate::notread::faces(flow);
    let not_read: [Option<String>; 2] = [0, 1].map(|k| unread.iter().find(|f| f.side == SIDES[k]).map(crate::notread::short));
    for z in copper["zones"].as_array().into_iter().flatten() {
        if !matches!(z["layer"].as_str(), Some("F.Cu") | Some("B.Cu")) || crate::notread::skips_layer(&unread, z["layer"].as_str().unwrap_or("")) { continue; }
        let mut runs = vec![]; let (mut bb, mut best) = ((f64::MAX, f64::MAX, f64::MIN, f64::MIN), (0u32, (0.0, 0.0)));
        for r in z["rle_px"].as_array().into_iter().flatten() {
            let Some(a) = r.as_array() else { continue }; let y = a[0].as_u64().unwrap_or(0) as u32;
            for s in a[1..].chunks(2) { if s.len() != 2 { continue; } let (x0, x1) = (s[0].as_u64().unwrap_or(0) as u32, s[1].as_u64().unwrap_or(0) as u32); if x1 <= x0 { continue; }
                runs.push((y, x0, x1));
                let (mx0, mx1, my) = (ORIGIN + x0 as f64 / RES, ORIGIN + x1 as f64 / RES, ORIGIN + y as f64 / RES);
                bb = (bb.0.min(mx0), bb.1.min(my), bb.2.max(mx1), bb.3.max(my + 1.0 / RES));
                if x1 - x0 > best.0 { best = (x1 - x0, ((mx0 + mx1) / 2.0, my)); } }
        }
        if runs.is_empty() { continue; }
        zones.push(Zone { net: ix(z["net"].as_str().unwrap_or("")), rung: rung_of(z), side: side_of(z["layer"].as_str()), runs, bb, area: z["areaMm2"].as_f64().unwrap_or(0.0), anchor: best.1 });
    }
    for t in copper["traces"].as_array().into_iter().flatten() {
        if !matches!(t["layer"].as_str(), Some("F.Cu") | Some("B.Cu")) || crate::notread::skips_layer(&unread, t["layer"].as_str().unwrap_or("")) { continue; }
        let pts = pts_of(&t["path_mm"]); if pts.len() < 2 { continue; }
        tracks.push(Track { net: ix(t["net"].as_str().unwrap_or("")), rung: rung_of(t), side: side_of(t["layer"].as_str()), pts, width: t["width_mm"].as_f64().unwrap_or(0.15) });
    }
    for v in copper["vias"].as_array().into_iter().flatten() {
        vias.push(Via { net: ix(v["net"].as_str().unwrap_or("")), rung: rung_of(v), x: v["pos_mm"][0].as_f64().unwrap_or(0.0), y: v["pos_mm"][1].as_f64().unwrap_or(0.0), d: v["diameter_mm"].as_f64().unwrap_or(0.5) });
    }
    // pads as `connect measure` read them: the library outline at the place the photo shows the metal
    let mut pins: HashMap<String, Pin> = HashMap::new();
    if let Ok(ms) = crate::items::read_json(&flow.join("detect/attrib/measure.json")) { for p in ms["pads"].as_array().into_iter().flatten() {
        let num = p["num"].as_str().unwrap_or(""); if num.is_empty() { continue; }
        let layers: Vec<&str> = p["layers"].as_array().into_iter().flatten().filter_map(|l| l.as_str()).collect();
        let both = layers.iter().any(|l| *l == "*.Cu" || *l == "F&B.Cu");
        let sides = [both || layers.contains(&"F.Cu"), both || layers.contains(&"B.Cu")];
        if !sides[0] && !sides[1] { continue; }
        let c = if p["core"].is_object() && p["drill"].as_f64().unwrap_or(0.0) <= 0.0 { &p["core"] } else { p };
        let f = |k: &str| c[k].as_f64().unwrap_or(0.0);
        let part = p["part"].as_str().unwrap_or("").to_string();
        let e = pins.entry(format!("{part}.{num}")).or_insert(Pin { part, x: f("x"), y: f("y"), w: f("w").max(0.2), h: f("h").max(0.2), rot: f("rot"), round: p["round"].as_bool().unwrap_or(false), sides: [false, false] });
        e.sides = [e.sides[0] || sides[0], e.sides[1] || sides[1]];
    } }
    let parts_doc = crate::items::read_json(&flow.join("parts.json")).unwrap_or(json!({}));
    let parts = crate::kicad_out::bodies_of(&crate::kicad_out::cache_dir(flow), parts_doc["parts"].as_array().map(|v| v.as_slice()).unwrap_or(&[])).into_iter()
        .map(|b| Part { corners: b.corners(), name: b.part, side: if b.layer == "B.Cu" { 1 } else { 0 } }).collect();
    let name = flow.parent().and_then(|p| p.file_name()).map(|f| f.to_string_lossy().to_string()).unwrap_or_default();
    Ok(Scene { name, wmm, hmm, faces, zones, tracks, vias, pins, parts, net_ix, not_read })
}

// ---------------------------------------------------------------- a panel: one face through a window

#[derive(Clone, Debug)]
pub struct View { side: usize, win: (f64, f64, f64, f64), x: f64, y: f64, w: usize, h: usize }
impl View {
    fn scale(&self) -> f64 { self.w as f64 / (self.win.2 - self.win.0) }
    fn local(&self, p: (f64, f64)) -> (f64, f64) { ((p.0 - self.win.0) * self.scale(), (p.1 - self.win.1) * self.scale()) }
    fn at(&self, p: (f64, f64)) -> (f64, f64) { let q = self.local(p); (self.x + q.0, self.y + q.1) }
    fn holds(&self, p: (f64, f64)) -> bool { p.0 >= self.win.0 && p.0 <= self.win.2 && p.1 >= self.win.1 && p.1 <= self.win.3 }
    /// The largest panel showing `win` whole inside the room given, centred in it.
    fn fit(side: usize, win: (f64, f64, f64, f64), room: (f64, f64, f64, f64)) -> View {
        let s = (room.2 / (win.2 - win.0)).min(room.3 / (win.3 - win.1));
        let (w, h) = (((win.2 - win.0) * s).floor().max(1.0), ((win.3 - win.1) * s).floor().max(1.0));
        View { side, win, x: (room.0 + (room.2 - w) / 2.0).round(), y: (room.1 + (room.3 - h) / 2.0).round(), w: w as usize, h: h as usize }
    }
}

fn whole(s: &Scene) -> (f64, f64, f64, f64) { (ORIGIN, ORIGIN, ORIGIN + s.wmm, ORIGIN + s.hmm) }

/// The window for a set of points: the whole board when they cover much of it, else their box with room round it, in the
/// panel's shape so the panel is filled.
pub fn window(board: (f64, f64, f64, f64), bb: Option<(f64, f64, f64, f64)>, aspect: f64) -> ((f64, f64, f64, f64), bool) {
    let Some(b) = bb else { return (board, false) };
    let pad = (0.14 * (b.2 - b.0).max(b.3 - b.1)).max(2.5);
    let (mut w, mut h) = ((b.2 - b.0 + 2.0 * pad).max(12.0), (b.3 - b.1 + 2.0 * pad).max(12.0));
    if w / h < aspect { w = h * aspect; } else { h = w / aspect; }
    let (bw, bh) = (board.2 - board.0, board.3 - board.1);
    if w * h > ZOOM_MAX_SHARE * bw * bh || w > bw * 1.05 || h > bh * 1.05 { return (board, false); }
    // keep the window on the board where it can be
    let (mut cx, mut cy) = ((b.0 + b.2) / 2.0, (b.1 + b.3) / 2.0);
    if w <= bw { cx = cx.clamp(board.0 + w / 2.0, board.2 - w / 2.0); } else { cx = (board.0 + board.2) / 2.0; }
    if h <= bh { cy = cy.clamp(board.1 + h / 2.0, board.3 - h / 2.0); } else { cy = (board.1 + board.3) / 2.0; }
    ((cx - w / 2.0, cy - h / 2.0, cx + w / 2.0, cy + h / 2.0), true)
}

fn base(img: &mut RgbImage, s: &Scene, v: &View) {
    let sc = v.scale();
    let Some(levels) = &s.faces[v.side] else { fill_rect(img, v.x, v.y, v.x + v.w as f64, v.y + v.h as f64, [30, 33, 38], 1.0); return };
    let mut k = 0; while k + 1 < levels.len() && RES / (1 << (k + 1)) as f64 >= sc { k += 1; }
    let (lv, res) = (&levels[k], RES / (1 << k) as f64);
    let get = |u: f64, w: f64| -> Option<[f32; 3]> {
        if u < -0.5 || w < -0.5 || u > lv.width() as f64 - 0.5 || w > lv.height() as f64 - 0.5 { return None; }
        let (u, w) = (u.clamp(0.0, lv.width() as f64 - 1.001), w.clamp(0.0, lv.height() as f64 - 1.001));
        let (x0, y0) = (u.floor() as u32, w.floor() as u32); let (fx, fy) = ((u - x0 as f64) as f32, (w - y0 as f64) as f32);
        let p = |x: u32, y: u32| lv.get_pixel(x.min(lv.width() - 1), y.min(lv.height() - 1));
        let (a, b, c, d) = (p(x0, y0), p(x0 + 1, y0), p(x0, y0 + 1), p(x0 + 1, y0 + 1));
        Some([0, 1, 2].map(|i| (a[i] as f32 * (1.0 - fx) + b[i] as f32 * fx) * (1.0 - fy) + (c[i] as f32 * (1.0 - fx) + d[i] as f32 * fx) * fy))
    };
    for py in 0..v.h { for px in 0..v.w {
        let mut acc = [0f32; 3]; let mut n = 0f32;
        for (dx, dy) in [(0.25, 0.25), (0.75, 0.25), (0.25, 0.75), (0.75, 0.75)] {
            let (mx, my) = (v.win.0 + (px as f64 + dx) / sc, v.win.1 + (py as f64 + dy) / sc);
            if let Some(c) = get((mx - ORIGIN) * res - 0.5, (my - ORIGIN) * res - 0.5) { for i in 0..3 { acc[i] += c[i]; } n += 1.0; }
        }
        let c = if n > 0.0 { [0, 1, 2].map(|i| (acc[i] / n * (n / 4.0) + PAPER[i] as f32 * (1.0 - n / 4.0)) as u8) } else { PAPER };
        img.put_pixel((v.x as i64 + px as i64) as u32, (v.y as i64 + py as i64) as u32, image::Rgb(c));
    } }
}

/// Paint the copper `pick` chooses, each piece in the style whose index it returns. Pours carry the pattern, tracks
/// are dashed for the weaker styles and never thinner than `min_half` so they stay visible when the board is small.
fn copper(img: &mut RgbImage, s: &Scene, v: &View, unit: f64, styles: &[Style], pick: &dyn Fn(usize, usize) -> Option<usize>, alpha: f32, min_half: f64) {
    let sc = v.scale();
    let hit = |bb: (f64, f64, f64, f64)| bb.2 >= v.win.0 && bb.0 <= v.win.2 && bb.3 >= v.win.1 && bb.1 <= v.win.3;
    let mut zs: Vec<Vec<&Zone>> = vec![vec![]; styles.len()]; let mut ts: Vec<Vec<&Track>> = vec![vec![]; styles.len()];
    for z in s.zones.iter().filter(|z| z.side == v.side && hit(z.bb)) { if let Some(k) = pick(z.net, z.rung) { zs[k].push(z); } }
    for t in s.tracks.iter().filter(|t| t.side == v.side) { if let Some(k) = pick(t.net, t.rung) { if t.pts.iter().any(|p| v.holds(*p)) { ts[k].push(t); } } }
    let mut cov = Cover::new(v.w, v.h);
    // the weakest styles first, so what is better proven is never hidden under what is less
    for k in (0..styles.len()).rev() {
        if !zs[k].is_empty() {
            cov.clear();
            for z in &zs[k] { for (y, x0, x1) in &z.runs {
                let a = v.local((ORIGIN + *x0 as f64 / RES, ORIGIN + *y as f64 / RES)); let b = v.local((ORIGIN + *x1 as f64 / RES, ORIGIN + (*y + 1) as f64 / RES));
                cov.add_rect(a.0, a.1, b.0, b.1);
            } }
            cov.paint(img, v.x as i64, v.y as i64, &styles[k], alpha, unit, true);
        }
        if !ts[k].is_empty() {
            cov.clear();
            for t in &ts[k] {
                let half = (t.width * sc / 2.0).max(min_half);
                let pts: Vec<(f64, f64)> = t.pts.iter().map(|p| v.local(*p)).collect();
                match styles[k].dash { Some((on, off)) => for (a, b) in dashes(&pts, on * unit, off * unit) { cov.capsule(a, b, half); }, None => for q in pts.windows(2) { cov.capsule(q[0], q[1], half); } }
            }
            cov.paint(img, v.x as i64, v.y as i64, &styles[k], alpha.max(0.92), unit, false);
        }
    }
    for via in s.vias.iter().filter(|q| v.holds((q.x, q.y))) {
        let Some(k) = pick(via.net, via.rung) else { continue };
        let (m, r) = (v.at((via.x, via.y)), (via.d * sc / 2.0).max(2.3 * unit));
        disc(img, m, r + 1.1 * unit, INK, 0.9); disc(img, m, r, styles[k].color, 1.0); disc(img, m, r * 0.36, INK, 0.9);
    }
    not_read_banner(img, s, v);
}

/// Over a face whose copper was not read: a band across the panel with the words, so no picture of that face reads as "no copper here".
fn not_read_banner(img: &mut RgbImage, s: &Scene, v: &View) {
    let Some(words) = &s.not_read[v.side] else { return };
    let size = (v.w as f64 / 52.0).clamp(11.0, 44.0);
    let lines = wrap(words, size, v.w as f64 - 2.0 * size, true);
    let lh = font(true).line_height(size) * 1.15;
    let (bh, y0) = (lh * lines.len() as f64 + size, v.y + (v.h as f64 - lh * lines.len() as f64 - size) / 2.0);
    fill_rect(img, v.x, y0.max(v.y), v.x + v.w as f64, (y0 + bh).min(v.y + v.h as f64), INK, 0.82);
    for (i, l) in lines.iter().enumerate() { let tw = text_width(size, l, true); text(img, v.x + (v.w as f64 - tw) / 2.0, y0 + size / 2.0 + lh * i as f64, size, l, [240, 80, 20], true); }
}

fn pin_shape(v: &View, p: &Pin, unit: f64) -> Vec<(f64, f64)> {
    let sc = v.scale(); let m = v.at((p.x, p.y));
    let (hw, hh) = ((p.w * sc / 2.0).max(3.6 * unit), (p.h * sc / 2.0).max(3.6 * unit));
    let (c, s) = (p.rot.to_radians().cos(), p.rot.to_radians().sin());
    [(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)].iter().map(|(lx, ly)| (m.0 + lx * c + ly * s, m.1 - lx * s + ly * c)).collect()
}
/// A pin: filled when it is seen joined to the net's copper, hollow when it is on the net by inference only. A large
/// pad is not filled whole, since that would hide the copper under it: it gets a heavy outline and a filled middle.
fn pin_mark(img: &mut RgbImage, v: &View, p: &Pin, seen: bool, color: C3, unit: f64) {
    if !v.holds((p.x, p.y)) { return; }
    let m = v.at((p.x, p.y)); let big = p.w.min(p.h) * v.scale() > 20.0 * unit;
    if p.round {
        let r = (p.w.max(p.h) * v.scale() / 2.0).max(4.2 * unit);
        if !seen { ring(img, m, r, 4.6 * unit, INK, 0.95); ring(img, m, r, 2.2 * unit, color, 1.0); }
        else if big { ring(img, m, r, 6.4 * unit, INK, 0.95); ring(img, m, r, 4.0 * unit, color, 1.0); disc(img, m, 6.2 * unit, INK, 0.95); disc(img, m, 5.0 * unit, color, 1.0); }
        else { disc(img, m, r + 1.2 * unit, INK, 0.95); disc(img, m, r, color, 1.0); }
        return;
    }
    let q = pin_shape(v, p, unit);
    let room = (v.x, v.y, v.x + v.w as f64, v.y + v.h as f64);
    let edge = |img: &mut RgbImage, half: f64, c: C3| for i in 0..q.len() { if let Some((a, b)) = clip(q[i], q[(i + 1) % q.len()], room) { capsule(img, a, b, half, c, if c == INK { 0.95 } else { 1.0 }); } };
    if !seen { edge(img, 2.4 * unit, INK); edge(img, 1.15 * unit, color); }
    else if big { edge(img, 3.3 * unit, INK); edge(img, 2.1 * unit, color); disc(img, m, 6.2 * unit, INK, 0.95); disc(img, m, 5.0 * unit, color, 1.0); }
    else { edge(img, 1.6 * unit, INK); fill_convex(img, &q, color, 1.0); }
}

/// Labels that keep out of each other's way and inside their panel.
struct Labels { taken: Vec<(f64, f64, f64, f64)>, clip: (f64, f64, f64, f64) }
impl Labels {
    fn new(v: &View) -> Labels { Labels { taken: vec![], clip: (v.x + 2.0, v.y + 2.0, v.x + v.w as f64 - 2.0, v.y + v.h as f64 - 2.0) } }
    fn keep(&mut self, b: (f64, f64, f64, f64)) { self.taken.push(b); }
    fn place(&mut self, img: &mut RgbImage, at: (f64, f64), off: f64, s: &str, px: f64, c: C3, bold: bool) -> bool {
        let (w, h) = (text_width(px, s, bold), font(bold).line_height(px));
        for (x, y) in [(at.0 + off, at.1 - h / 2.0), (at.0 - off - w, at.1 - h / 2.0), (at.0 - w / 2.0, at.1 - off - h), (at.0 - w / 2.0, at.1 + off), (at.0 + off, at.1 - off - h), (at.0 - off - w, at.1 + off),
            (at.0 + off, at.1 + off), (at.0 - off - w, at.1 - off - h), (at.0 - w / 2.0, at.1 - 2.2 * off - 2.0 * h), (at.0 - w / 2.0, at.1 + 2.2 * off + h), (at.0 + off, at.1 - 2.2 * off - 2.0 * h), (at.0 - off - w, at.1 + 2.2 * off + h)] {
            let b = (x - 2.0, y, x + w + 2.0, y + h);
            if b.0 < self.clip.0 || b.1 < self.clip.1 || b.2 > self.clip.2 || b.3 > self.clip.3 { continue; }
            if self.taken.iter().any(|t| b.0 < t.2 && b.2 > t.0 && b.1 < t.3 && b.3 > t.1) { continue; }
            text_halo(img, x, y, px, s, c, bold); self.taken.push(b); return true;
        }
        false
    }
}

fn panel_frame(img: &mut RgbImage, v: &View, unit: f64, zoomed: bool) {
    frame(img, v.x - unit, v.y - unit, v.x + v.w as f64 + unit, v.y + v.h as f64 + unit, (1.5 * unit).max(1.0), [120, 126, 134], 1.0);
    let label = if zoomed { format!("{}  (zoomed in)", SIDE_TITLES[v.side]) } else { SIDE_TITLES[v.side].to_string() };
    let px = 17.0 * unit; let w = text_width(px, &label, true) + 16.0 * unit;
    fill_rect(img, v.x, v.y, v.x + w, v.y + 27.0 * unit, INK, 0.82);
    text(img, v.x + 8.0 * unit, v.y + 3.0 * unit, px, &label, WHITE, true);
}

fn part_outlines(img: &mut RgbImage, s: &Scene, v: &View, unit: f64, only: Option<&BTreeSet<&str>>, alpha: f32) {
    for p in s.parts.iter().filter(|p| p.side == v.side && only.map(|o| o.contains(p.name.as_str())).unwrap_or(true)) {
        if !p.corners.iter().any(|c| v.holds(*c)) { continue; }
        let q: Vec<(f64, f64)> = p.corners.iter().map(|c| v.at(*c)).collect();
        // a connector that hangs over the board's edge is cut at the panel's edge
        let room = (v.x, v.y, v.x + v.w as f64, v.y + v.h as f64);
        for i in 0..q.len() { if let Some((a, b)) = clip(q[i], q[(i + 1) % q.len()], room) { capsule(img, a, b, 0.75 * unit, SOFT, alpha); } }
    }
}

/// The part of a line inside a rectangle.
pub fn clip(a: (f64, f64), b: (f64, f64), r: (f64, f64, f64, f64)) -> Option<((f64, f64), (f64, f64))> {
    let (dx, dy) = (b.0 - a.0, b.1 - a.1); let (mut t0, mut t1) = (0.0f64, 1.0f64);
    for (p, q) in [(-dx, a.0 - r.0), (dx, r.2 - a.0), (-dy, a.1 - r.1), (dy, r.3 - a.1)] {
        if p == 0.0 { if q < 0.0 { return None; } continue; }
        let t = q / p;
        if p < 0.0 { if t > t1 { return None; } t0 = t0.max(t); } else { if t < t0 { return None; } t1 = t1.min(t); }
    }
    Some(((a.0 + t0 * dx, a.1 + t0 * dy), (a.0 + t1 * dx, a.1 + t1 * dy)))
}

fn level_name(l: &str) -> &'static str { match l { "copper" => "copper", "via" => "via", "inferred" => "inferred", "group" => "group", "conflict" => "conflict", _ => "none" } }
fn level_words(l: &str) -> &'static str {
    match l { "copper" => "every pin seen joined by continuous copper", "via" => "joined through verified vias at the weakest point", "inferred" => "the weakest link joining its pins is inferred", "group" => "unnamed copper joined into a group", "conflict" => "two nets meet and nothing settles it", _ => "no evidence" }
}

/// A level as a tag: its pattern, then its name.
fn badge(img: &mut RgbImage, x: f64, y: f64, level: &str, px: f64, unit: f64) -> f64 {
    let h = font(true).line_height(px); let sw = h * 0.82;
    let w = sw + 10.0 * unit + text_width(px, level_name(level), true) + 20.0 * unit;
    fill_rect(img, x, y, x + w, y + h + 6.0 * unit, [44, 48, 54], 1.0);
    swatch(img, x + 6.0 * unit, y + (h + 6.0 * unit - sw) / 2.0, sw, &RUNGS[rank(level)], unit);
    text(img, x + sw + 14.0 * unit, y + 3.0 * unit, px, level_name(level), RUNGS[rank(level)].color, true);
    x + w
}

fn s<'a>(v: &'a Value, k: &str) -> &'a str { v[k].as_str().unwrap_or("") }
fn n(v: &Value, k: &str) -> usize { v[k].as_u64().unwrap_or(0) as usize }
fn pt(v: &Value) -> Option<(f64, f64)> { Some((v[0].as_f64()?, v[1].as_f64()?)) }

/// The box round a net's copper and pins, both faces.
fn net_box(sc: &Scene, nets: &BTreeSet<usize>, pins: &[&Pin]) -> Option<(f64, f64, f64, f64)> {
    let mut b = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
    let mut add = |x: f64, y: f64| { b = (b.0.min(x), b.1.min(y), b.2.max(x), b.3.max(y)); };
    for z in sc.zones.iter().filter(|z| nets.contains(&z.net)) { add(z.bb.0, z.bb.1); add(z.bb.2, z.bb.3); }
    for t in sc.tracks.iter().filter(|t| nets.contains(&t.net)) { for p in &t.pts { add(p.0, p.1); } }
    for v in sc.vias.iter().filter(|v| nets.contains(&v.net)) { add(v.x, v.y); }
    for p in pins { add(p.x, p.y); }
    (b.0 <= b.2).then_some(b)
}

fn pins_of(row: &Value) -> Vec<(String, bool)> {
    let mut v: Vec<(String, bool)> = row["seenPins"].as_array().into_iter().flatten().filter_map(|p| p.as_str().map(|x| (x.to_string(), true))).collect();
    v.extend(row["inferredMembers"].as_array().into_iter().flatten().filter_map(|p| p["pin"].as_str().map(|x| (x.to_string(), false))));
    v
}

fn pins_line(row: &Value) -> String {
    let (p, seen) = (n(row, "pins"), n(row, "pinsSeen"));
    match p { 0 => "no pins".into(), 1 => format!("1 pin, {}", if seen == 1 { "seen on its copper" } else { "by inference only" }), _ => format!("{p} pins: {seen} seen joined to its copper, {} by inference only", p - seen) }
}

/// One net: its copper, vias and pins lit on both faces.
fn net_picture(sc: &Scene, row: &Value, path: &Path) -> Result<(), String> {
    let (wd, ht, u) = (1920u32, 1080u32, 1.0f64);
    let mut img = RgbImage::from_pixel(wd, ht, px(PAPER));
    let name = s(row, "net"); let level = s(row, "level");
    let ni = sc.net_ix.get(name).copied();
    let nets: BTreeSet<usize> = ni.into_iter().collect();
    let pins = pins_of(row);
    let shapes: Vec<(&String, &Pin, bool)> = pins.iter().filter_map(|(p, seen)| sc.pins.get(p).map(|q| (p, q, *seen))).collect();
    let sides: Vec<usize> = (0..2).filter(|k| sc.faces[*k].is_some()).collect();
    let (top, bottom, gap) = (124.0, 150.0, 16.0);
    let room_w = (wd as f64 - 24.0 - gap * (sides.len() as f64 - 1.0)) / sides.len() as f64; let room_h = ht as f64 - top - bottom;
    let (win, zoomed) = window(whole(sc), net_box(sc, &nets, &shapes.iter().map(|x| x.1).collect::<Vec<_>>()), room_w / room_h);
    let views: Vec<View> = sides.iter().enumerate().map(|(i, k)| View::fit(*k, win, (12.0 + i as f64 * (room_w + gap), top, room_w, room_h))).collect();
    let parts_on: BTreeSet<&str> = shapes.iter().map(|x| x.1.part.as_str()).collect();
    let weak = &row["weakestLink"];
    for v in &views {
        base(&mut img, sc, v);
        copper(&mut img, sc, v, u, &RUNGS, &|net, rung| (Some(net) == ni).then_some(rung), 0.9, 1.5);
        if parts_on.len() <= 60 { part_outlines(&mut img, sc, v, u, Some(&parts_on), 0.75); }
        for (_, p, seen) in shapes.iter().filter(|x| x.1.sides[v.side]) { pin_mark(&mut img, v, p, *seen, WHITE, u); }
        let mut lab = Labels::new(v);
        lab.keep((v.x, v.y, v.x + 420.0, v.y + 30.0));
        // the weakest link, ringed, on the face it is on
        if level != "copper" { if let Some(at) = pt(&weak["at"]) { if v.holds(at) && weak["side"].as_str().map(|x| x == SIDES[v.side]).unwrap_or(true) {
            let m = v.at(at);
            ring(&mut img, m, 21.0 * u, 7.0 * u, INK, 0.9); ring(&mut img, m, 21.0 * u, 3.4 * u, [255, 235, 90], 1.0);
            lab.place(&mut img, m, 27.0 * u, "weakest link", 19.0 * u, [255, 235, 90], true);
        } } }
        for d in row["disputes"].as_array().into_iter().flatten() { if let Some(at) = pt(&d["at"]) { if v.holds(at) {
            let m = v.at(at); let r = 13.0 * u;
            for (a, b) in [((m.0 - r, m.1 - r), (m.0 + r, m.1 + r)), ((m.0 - r, m.1 + r), (m.0 + r, m.1 - r))] { capsule(&mut img, a, b, 3.6 * u, INK, 0.9); capsule(&mut img, a, b, 1.9 * u, RUNGS[5].color, 1.0); }
            let with: Vec<&str> = d["with"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect();
            lab.place(&mut img, m, 18.0 * u, &format!("meets {}", with.join(", ")), 17.0 * u, RUNGS[5].color, true);
        } } }
        let here: Vec<&(&String, &Pin, bool)> = shapes.iter().filter(|x| x.1.sides[v.side] && v.holds((x.1.x, x.1.y))).collect();
        if here.len() <= 18 { for (p, q, _) in here { lab.place(&mut img, v.at((q.x, q.y)), 9.0 * u + (q.w.max(q.h) * v.scale() / 2.0).min(30.0), p, 18.0 * u, WHITE, false); } }
        else if parts_on.len() <= 40 { let mut done: BTreeSet<&str> = BTreeSet::new(); for (_, q, _) in here { if done.insert(q.part.as_str()) { lab.place(&mut img, v.at((q.x, q.y)), 9.0 * u, &q.part, 17.0 * u, WHITE, false); } } }
        panel_frame(&mut img, v, u, zoomed);
    }
    // title: the net, its level, its pins, then the weakest link in words
    let x = text(&mut img, 16.0, 6.0, 46.0, &fit(name, 46.0, 760.0, true), WHITE, true);
    let x = badge(&mut img, x + 22.0, 10.0, level, 32.0, u);
    text(&mut img, x + 22.0, 20.0, 25.0, &fit(&pins_line(row), 25.0, wd as f64 - x - 40.0, false), SOFT, false);
    let what = if weak.is_object() { format!("Weakest link: {}{}", s(weak, "what"), if n(weak, "sameKindOnPath") > 1 { format!(" ({} links like it)", n(weak, "sameKindOnPath")) } else { String::new() }) } else { "No pins, so nothing to join".to_string() };
    let lines = wrap(&what, 21.0, wd as f64 - 32.0, false);
    for (i, l) in lines.iter().take(2).enumerate() { let l = if i == 1 && lines.len() > 2 { fit(&format!("{l} {}", lines[2]), 21.0, wd as f64 - 32.0, false) } else { l.clone() }; text(&mut img, 16.0, 62.0 + 26.0 * i as f64, 21.0, &l, [236, 238, 242], false); }
    // legend: what the colours and marks mean, and how much of the net's copper sits on each rung
    let y0 = ht as f64 - bottom + 14.0;
    let has_copper = row["copperByRung"].as_object().map(|m| !m.is_empty()).unwrap_or(false);
    text(&mut img, 16.0, y0, 19.0, if has_copper { "Copper of this net, by how each piece was attributed" } else { "No copper is attributed to this net" }, SOFT, true);
    let mut x = 16.0;
    if !has_copper { x = text(&mut img, 16.0, y0 + 36.0, 20.0, "Only its pins are shown: the pin map names them,", WHITE, false).max(x); text(&mut img, 16.0, y0 + 62.0, 20.0, "and no copper seen carries the net's name.", WHITE, false); }
    for (k, r) in LADDER.iter().enumerate().take(if has_copper { 3 } else { 0 }) {
        let share = row["copperByRung"][*r]["share"].as_f64();
        swatch(&mut img, x, y0 + 34.0, 34.0, &RUNGS[k], u);
        let label = match *r { "copper" => "seen touching a pad", "via" => "through verified vias", _ => "inferred" };
        let x1 = text(&mut img, x + 44.0, y0 + 30.0, 21.0, label, WHITE, true);
        text(&mut img, x + 44.0, y0 + 56.0, 18.0, &share.map(|v| if v > 0.0 && v < 0.005 { "under 1% of its copper".to_string() } else { format!("{:.0}% of its copper", v * 100.0) }).unwrap_or_else(|| "none of its copper".into()), SOFT, false);
        x = x1.max(x + 44.0 + 150.0) + 34.0;
    }
    let mx = x.max(760.0) + 10.0;
    text(&mut img, mx, y0, 19.0, "Marks", SOFT, true);
    let mark = |img: &mut RgbImage, x: f64, seen: bool| { let q = [(x, y0 + 38.0), (x + 22.0, y0 + 38.0), (x + 22.0, y0 + 60.0), (x, y0 + 60.0)]; if seen { outline(img, &q, 1.6, INK, 0.95); fill_convex(img, &q, WHITE, 1.0); } else { outline(img, &q, 2.4, INK, 0.95); outline(img, &q, 1.15, WHITE, 1.0); } };
    mark(&mut img, mx, true); let x1 = text(&mut img, mx + 32.0, y0 + 36.0, 20.0, "pin seen joined", WHITE, false);
    mark(&mut img, x1 + 28.0, false); let x2 = text(&mut img, x1 + 60.0, y0 + 36.0, 20.0, "pin by inference only", WHITE, false);
    ring(&mut img, (x2 + 44.0, y0 + 49.0), 13.0, 3.2, [255, 235, 90], 1.0); let x3 = text(&mut img, x2 + 66.0, y0 + 36.0, 20.0, "weakest link", WHITE, false);
    let _ = x3;
    if row["disputes"].as_array().map(|a| !a.is_empty()).unwrap_or(false) { let m = (mx + 11.0, y0 + 79.0); for (a, b) in [((m.0 - 10.0, m.1 - 10.0), (m.0 + 10.0, m.1 + 10.0)), ((m.0 - 10.0, m.1 + 10.0), (m.0 + 10.0, m.1 - 10.0))] { capsule(&mut img, a, b, 1.9, RUNGS[5].color, 1.0); } text(&mut img, mx + 32.0, y0 + 66.0, 20.0, "a place where this net meets another, not settled", WHITE, false); }
    let foot = format!("{}   |   level {}: {}   |   the level is the weakest link joining the net's pins, not the share of copper seen", sc.name, level_name(level), level_words(level));
    text(&mut img, 16.0, ht as f64 - 36.0, 18.0, &fit(&foot, 18.0, wd as f64 - if zoomed { 260.0 } else { 32.0 }, false), [150, 156, 164], false);
    // where on the board a zoomed picture is
    if zoomed {
        let lv = View::fit(sides[0], whole(sc), (wd as f64 - 232.0, y0 - 6.0, 220.0, bottom - 14.0));
        base(&mut img, sc, &lv);
        copper(&mut img, sc, &lv, u, &RUNGS, &|net, rung| (Some(net) == ni).then_some(rung), 1.0, 0.9);
        let (a, b) = (lv.at((win.0, win.1)), lv.at((win.2, win.3)));
        frame(&mut img, a.0.max(lv.x), a.1.max(lv.y), b.0.min(lv.x + lv.w as f64), b.1.min(lv.y + lv.h as f64), 2.0, WHITE, 1.0);
        frame(&mut img, lv.x - 1.0, lv.y - 1.0, lv.x + lv.w as f64 + 1.0, lv.y + lv.h as f64 + 1.0, 1.0, [120, 126, 134], 1.0);
        let t = "where this is"; text(&mut img, lv.x - text_width(17.0, t, false) - 12.0, lv.y + lv.h as f64 / 2.0 - 10.0, 17.0, t, SOFT, false);
    }
    save_jpeg(&img, path, 90)
}

/// A sheet of up to eight nets, each in its own hue and labelled on the board.
fn section_sheet(sc: &Scene, rows: &HashMap<&str, &Value>, title: &str, sub: &str, nets: &[String], path: &Path) -> Result<(), String> {
    let u = 2.0f64; let (wd, ht) = (3840u32, 2160u32);
    let mut img = RgbImage::from_pixel(wd, ht, px(PAPER));
    let ids: Vec<Option<usize>> = nets.iter().map(|x| sc.net_ix.get(x).copied()).collect();
    let slot: HashMap<usize, usize> = ids.iter().enumerate().filter_map(|(k, i)| i.map(|i| (i, k))).collect();
    let styles: Vec<Style> = (0..nets.len()).map(|k| Style { color: NET_HUES[k % NET_HUES.len()], pattern: Pattern::Solid, dash: None }).collect();
    let all_pins: Vec<(usize, String, bool)> = nets.iter().enumerate().flat_map(|(k, x)| rows.get(x.as_str()).map(|r| pins_of(r)).unwrap_or_default().into_iter().map(move |(p, seen)| (k, p, seen))).collect();
    let shapes: Vec<(usize, &String, &Pin, bool)> = all_pins.iter().filter_map(|(k, p, seen)| sc.pins.get(p).map(|q| (*k, p, q, *seen))).collect();
    let sides: Vec<usize> = (0..2).filter(|k| sc.faces[*k].is_some()).collect();
    let key_w = 430.0 * u; let (top, bottom, gap) = (104.0 * u, 44.0 * u, 14.0 * u);
    let room_w = (wd as f64 - key_w - 24.0 * u - gap * (sides.len() as f64 - 1.0)) / sides.len() as f64; let room_h = ht as f64 - top - bottom;
    let set: BTreeSet<usize> = slot.keys().copied().collect();
    let (win, zoomed) = window(whole(sc), net_box(sc, &set, &shapes.iter().map(|x| x.2).collect::<Vec<_>>()), room_w / room_h);
    let views: Vec<View> = sides.iter().enumerate().map(|(i, k)| View::fit(*k, win, (12.0 * u + i as f64 * (room_w + gap), top, room_w, room_h))).collect();
    for v in &views {
        base(&mut img, sc, v);
        copper(&mut img, sc, v, u, &styles, &|net, _| slot.get(&net).copied(), 0.88, 1.4 * u);
        for (k, _, p, seen) in shapes.iter().filter(|x| x.2.sides[v.side]) { pin_mark(&mut img, v, p, *seen, styles[*k].color, u); }
        // each net's name where it has most copper on this face, else at one of its pins
        let mut lab = Labels::new(v); lab.keep((v.x, v.y, v.x + 440.0 * u, v.y + 30.0 * u));
        for (k, id) in ids.iter().enumerate() {
            let zone = sc.zones.iter().filter(|z| Some(z.net) == *id && z.side == v.side && v.holds(z.anchor)).max_by(|a, b| a.area.partial_cmp(&b.area).unwrap()).map(|z| z.anchor);
            let track = || sc.tracks.iter().filter(|t| Some(t.net) == *id && t.side == v.side).map(|t| t.pts[t.pts.len() / 2]).find(|p| v.holds(*p));
            let pin = || shapes.iter().filter(|x| x.0 == k && x.2.sides[v.side]).map(|x| (x.2.x, x.2.y)).find(|p| v.holds(*p));
            if let Some(at) = zone.or_else(track).or_else(pin) { lab.place(&mut img, v.at(at), 8.0 * u, &nets[k], 19.0 * u, styles[k].color, true); }
        }
        panel_frame(&mut img, v, u, zoomed);
    }
    text(&mut img, 16.0 * u, 8.0 * u, 44.0 * u, &fit(title, 44.0 * u, wd as f64 - 40.0 * u, true), WHITE, true);
    text(&mut img, 16.0 * u, 64.0 * u, 23.0 * u, &fit(sub, 23.0 * u, wd as f64 - 40.0 * u, false), SOFT, false);
    // the key: hue, name, level, pins
    let kx = wd as f64 - key_w + 6.0 * u; let row_h = ((room_h - 40.0 * u) / PER_SHEET as f64).min(112.0 * u);
    text(&mut img, kx, top, 21.0 * u, "Nets on this sheet", SOFT, true);
    for (k, name) in nets.iter().enumerate() {
        let y = top + 40.0 * u + k as f64 * row_h;
        fill_rect(&mut img, kx, y + 4.0 * u, kx + 40.0 * u, y + 44.0 * u, styles[k].color, 1.0);
        frame(&mut img, kx, y + 4.0 * u, kx + 40.0 * u, y + 44.0 * u, 1.5 * u, WHITE, 0.6);
        text(&mut img, kx + 52.0 * u, y, 29.0 * u, &fit(name, 29.0 * u, key_w - 70.0 * u, true), WHITE, true);
        if let Some(r) = rows.get(name.as_str()) {
            let x = badge(&mut img, kx + 52.0 * u, y + 42.0 * u, s(r, "level"), 19.0 * u, u);
            text(&mut img, x + 12.0 * u, y + 44.0 * u, 19.0 * u, &format!("{} of {} pins seen", n(r, "pinsSeen"), n(r, "pins")), SOFT, false);
        }
    }
    let foot = format!("{}   |   filled mark: pin seen joined to the net's copper; hollow mark: pin on the net by inference only   |   the level is the weakest link joining a net's pins", sc.name);
    text(&mut img, 16.0 * u, ht as f64 - 34.0 * u, 17.0 * u, &fit(&foot, 17.0 * u, wd as f64 - 40.0 * u, false), [150, 156, 164], false);
    save_jpeg(&img, path, 90)
}

#[derive(Clone, Copy, PartialEq)]
enum Mode { ByNet, ByPiece }

/// Both faces (or one, at full size) with every piece of copper in the colour and pattern of its level, and the legend.
fn confidence_sheet(sc: &Scene, conf: &Value, mode: Mode, one_face: Option<usize>, path: &Path) -> Result<(), String> {
    let sides: Vec<usize> = match one_face { Some(k) => vec![k], None => (0..2).filter(|k| sc.faces[*k].is_some()).collect() };
    // the level each net is drawn at: its own for a named net, its class for copper with no name
    let mut net_rank: Vec<usize> = vec![4; sc.net_ix.len()];
    let levels: HashMap<&str, &str> = conf["nets"].as_array().into_iter().flatten().map(|r| (s(r, "net"), s(r, "level"))).collect();
    for (name, k) in &sc.net_ix { net_rank[*k] = match netconf::unnamed_class(name) { Some(c) => rank(c), None => rank(levels.get(name.as_str()).copied().unwrap_or("none")) }; }
    let pick = |net: usize, rung: usize| -> Option<usize> { Some(if mode == Mode::ByNet { rung.max(net_rank[net]) } else { rung }) };
    let (u, wd, ht, legend_h, views): (f64, u32, u32, f64, Vec<View>) = match one_face {
        None => { let u = 2.0; let (wd, ht) = (3840u32, 2160u32); let (top, lh, gap) = (108.0 * u, 238.0 * u, 16.0 * u);
            let room_w = (wd as f64 - 24.0 * u - gap * (sides.len() as f64 - 1.0)) / sides.len() as f64;
            (u, wd, ht, lh, sides.iter().enumerate().map(|(i, k)| View::fit(*k, whole(sc), (12.0 * u + i as f64 * (room_w + gap), top, room_w, ht as f64 - top - lh))).collect()) }
        Some(k) => { let (pw, ph) = ((sc.wmm * RES).round(), (sc.hmm * RES).round()); let u = (pw / 1500.0).max(1.2); let (top, lh, m) = (108.0 * u, 238.0 * u, 12.0 * u);
            (u, (pw + 2.0 * m) as u32, (ph + top + lh) as u32, lh, vec![View { side: k, win: whole(sc), x: m, y: top, w: pw as usize, h: ph as usize }]) }
    };
    let mut img = RgbImage::from_pixel(wd, ht, px(PAPER));
    for v in &views {
        base(&mut img, sc, v);
        copper(&mut img, sc, v, u, &RUNGS, &pick, 0.86, 1.25 * u);
        part_outlines(&mut img, sc, v, u, None, 0.5);
        // reference designators where the part is large enough to carry one
        let mut lab = Labels::new(v); lab.keep((v.x, v.y, v.x + 440.0 * u, v.y + 30.0 * u));
        for p in sc.parts.iter().filter(|p| p.side == v.side) {
            let q: Vec<(f64, f64)> = p.corners.iter().map(|c| v.at(*c)).collect();
            let (x0, x1, y0, y1) = (q.iter().map(|a| a.0).fold(f64::MAX, f64::min), q.iter().map(|a| a.0).fold(f64::MIN, f64::max), q.iter().map(|a| a.1).fold(f64::MAX, f64::min), q.iter().map(|a| a.1).fold(f64::MIN, f64::max));
            let size = 15.0 * u; let (tw, th) = (text_width(size, &p.name, true), font(true).line_height(size));
            if tw + 4.0 * u <= x1 - x0 && th <= y1 - y0 { let b = ((x0 + x1 - tw) / 2.0, (y0 + y1 - th) / 2.0); text_halo(&mut img, b.0, b.1, size, &p.name, WHITE, true); lab.keep((b.0, b.1, b.0 + tw, b.1 + th)); }
        }
        panel_frame(&mut img, v, u, false);
    }
    let sm = &conf["summary"];
    let (title, sub) = match mode {
        Mode::ByNet => ("Net confidence: each net is as proven as the weakest link joining its pins".to_string(),
            format!("{}   |   {} nets: {} copper, {} via, {} inferred   |   copper with no net: {} groups, {} isolated islands, {} in conflict", sc.name, n(sm, "nets"), n(&sm["levels"], "copper"), n(&sm["levels"], "via"), n(&sm["levels"], "inferred"), n(&sm["unnamed"]["group"], "names"), n(sm, "unnamedIslands"), n(&sm["unnamed"]["conflict"], "names"))),
        Mode::ByPiece => ("Attribution: how each piece of copper got its net".to_string(), format!("{}   |   the evidence under the net confidence picture: a net can be inferred as a whole while most of its copper was seen", sc.name)),
    };
    text(&mut img, 16.0 * u, 8.0 * u, 42.0 * u, &fit(&title, 42.0 * u, wd as f64 - 32.0 * u, true), WHITE, true);
    text(&mut img, 16.0 * u, 62.0 * u, 23.0 * u, &fit(&sub, 23.0 * u, wd as f64 - 32.0 * u, false), SOFT, false);
    // the legend: six levels, three to a row, each with what it means, how many and how much of the copper
    let which = if mode == Mode::ByNet { "copperAsDrawn" } else { "copperByOwnRung" };
    let total_area: f64 = LADDER.iter().map(|r| sm[which][*r]["areaMm2"].as_f64().unwrap_or(0.0)).sum();
    let pct = |r: &str| if total_area > 0.0 { format!("{:.0}% of copper area", 100.0 * sm[which][r]["areaMm2"].as_f64().unwrap_or(0.0) / total_area) } else { "no copper".to_string() };
    let entry = |r: &str| -> (&'static str, String) {
        match (mode, r) {
            (Mode::ByNet, "copper") => ("Copper", format!("every pin seen joined by continuous copper. {} nets ({} of one pin), {}", n(&sm["levels"], "copper"), n(&sm["netsOfOnePin"], "copper"), pct(r))),
            (Mode::ByNet, "via") => ("Via", format!("pins joined, the weakest join through verified vias. {} nets, {}", n(&sm["levels"], "via"), pct(r))),
            (Mode::ByNet, "inferred") => ("Inferred", format!("at least one pin or join rests on inference, however much copper was seen. {} nets, {}", n(&sm["levels"], "inferred"), pct(r))),
            (Mode::ByNet, "group") => ("Group, no name", format!("unnamed copper joined to other unnamed copper. {} groups, {}", n(&sm["unnamed"]["group"], "names"), pct(r))),
            (Mode::ByNet, "none") => ("None", format!("isolated copper: no pad, no valid via. {} unnamed islands, {}", n(sm, "unnamedIslands"), pct(r))),
            (Mode::ByNet, _) => ("Conflict", format!("two nets meet and nothing settles it; no name is forced. {} places, {}", n(&sm["unnamed"]["conflict"], "names"), pct(r))),
            (_, "copper") => ("Copper", format!("touches a pad of its net with no gap. {}", pct(r))),
            (_, "via") => ("Via", format!("reached through verified vias. {}", pct(r))),
            (_, "inferred") => ("Inferred", format!("one via track to track, or a pad whose edge does not settle it. {}", pct(r))),
            (_, "group") => ("Group, no name", format!("joined to other unnamed copper. {}", pct(r))),
            (_, "none") => ("None", format!("no pad and no valid via. {}", pct(r))),
            _ => ("Conflict", format!("two nets meet here. {}", pct(r))),
        }
    };
    let ly = ht as f64 - legend_h + 18.0 * u; let col = (wd as f64 - 32.0 * u) / 3.0;
    for (k, r) in LADDER.iter().enumerate() {
        let (x, y) = (16.0 * u + (k % 3) as f64 * col, ly + (k / 3) as f64 * 106.0 * u);
        swatch(&mut img, x, y, 62.0 * u, &RUNGS[k], u);
        let (head, words) = entry(r);
        text(&mut img, x + 76.0 * u, y - 4.0 * u, 26.0 * u, head, RUNGS[k].color, true);
        for (i, line) in wrap(&words, 19.0 * u, col - 100.0 * u, false).iter().take(3).enumerate() { text(&mut img, x + 76.0 * u, y + (28.0 + 23.0 * i as f64) * u, 19.0 * u, line, [226, 229, 234], false); }
    }
    save_jpeg(&img, path, 91)
}

fn weakest_short(row: &Value) -> String {
    let w = &row["weakestLink"]; let cnt = n(w, "sameKindOnPath");
    let pins: Vec<&str> = w["pins"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect();
    match s(w, "kind") {
        "pad-on-copper" => "all joins seen as continuous copper".to_string(),
        "verified-vias" => "verified vias between two pieces".to_string(),
        "single-via-track-to-track" => "one via, track to track".to_string(),
        "single-via-onto-pour" => "one unconfirmed via and the pin map".to_string(),
        "pad-edge-unsettled" => format!("pad edge of {} does not settle it{}", pins.first().unwrap_or(&""), if cnt > 1 { format!(" (+{})", cnt - 1) } else { String::new() }),
        "pin-map-only" => format!("{} by the pin map only{}", pins.first().unwrap_or(&""), if cnt > 1 { format!(" (+{} more)", cnt - 1) } else { String::new() }),
        _ => "no pins".to_string(),
    }
}

/// The table of every net with its level, as pictures. Returns how many pages.
fn table_sheets(sc: &Scene, conf: &Value, dir: &Path) -> Result<usize, String> {
    let mut rows: Vec<&Value> = conf["nets"].as_array().into_iter().flatten().collect();
    rows.sort_by(|a, b| rank(s(a, "level")).cmp(&rank(s(b, "level"))).then(n(b, "pins").cmp(&n(a, "pins"))).then_with(|| nat_cmp(s(a, "net"), s(b, "net"))));
    let (wd, ht) = (1920u32, 1080u32); let (per_col, cols) = (27usize, 2usize);
    let pages = rows.len().div_ceil(per_col * cols).max(1);
    for page in 0..pages {
        let mut img = RgbImage::from_pixel(wd, ht, px(PAPER));
        text(&mut img, 16.0, 8.0, 40.0, "Every net and how well it is proven", WHITE, true);
        let sm = &conf["summary"];
        let sub = format!("{}   |   {} nets: {} copper, {} via, {} inferred   |   level = the weakest link joining the net's pins   |   page {} of {pages}", sc.name, n(sm, "nets"), n(&sm["levels"], "copper"), n(&sm["levels"], "via"), n(&sm["levels"], "inferred"), page + 1);
        text(&mut img, 16.0, 58.0, 21.0, &fit(&sub, 21.0, wd as f64 - 32.0, false), SOFT, false);
        let col_w = (wd as f64 - 32.0 - 24.0) / cols as f64;
        for c in 0..cols {
            let x = 16.0 + c as f64 * (col_w + 24.0); let y = 104.0;
            fill_rect(&mut img, x, y, x + col_w, y + 32.0, [44, 48, 54], 1.0);
            for (dx, h) in [(8.0, "Net"), (268.0, "Level"), (420.0, "Pins seen"), (540.0, "Weakest link")] { text(&mut img, x + dx, y + 4.0, 19.0, h, SOFT, true); }
            for (i, r) in rows.iter().skip(page * per_col * cols + c * per_col).take(per_col).enumerate() {
                let ry = y + 38.0 + i as f64 * 34.0;
                if i % 2 == 1 { fill_rect(&mut img, x, ry - 2.0, x + col_w, ry + 32.0, [26, 30, 36], 1.0); }
                let lv = s(r, "level");
                text(&mut img, x + 8.0, ry + 1.0, 22.0, &fit(s(r, "net"), 22.0, 250.0, true), WHITE, true);
                swatch(&mut img, x + 268.0, ry + 3.0, 24.0, &RUNGS[rank(lv)], 1.0);
                text(&mut img, x + 300.0, ry + 2.0, 21.0, level_name(lv), RUNGS[rank(lv)].color, true);
                text(&mut img, x + 420.0, ry + 3.0, 20.0, &format!("{} of {}", n(r, "pinsSeen"), n(r, "pins")), WHITE, false);
                text(&mut img, x + 540.0, ry + 4.0, 18.0, &fit(&weakest_short(r), 18.0, col_w - 548.0, false), SOFT, false);
            }
        }
        save_jpeg(&img, &dir.join(format!("confidence-table-{}.jpg", page + 1)), 92)?;
    }
    Ok(pages)
}

fn slug(t: &str) -> String {
    let mut out = String::new();
    for ch in t.chars() { if ch.is_ascii_alphanumeric() { out.push(ch.to_ascii_lowercase()); } else if ch == '+' { out.push_str("plus"); } else if !out.ends_with('-') && !out.is_empty() { out.push('-'); } }
    out.trim_end_matches('-').to_string()
}

/// Run `f` over the jobs on at most four threads, and return the first error.
fn in_parallel<T: Sync>(jobs: &[T], f: &(dyn Fn(&T) -> Result<(), String> + Sync)) -> Result<(), String> {
    let next = std::sync::atomic::AtomicUsize::new(0);
    let errs: Vec<String> = std::thread::scope(|sc| {
        let hs: Vec<_> = (0..4.min(jobs.len().max(1))).map(|_| sc.spawn(|| { let mut e = vec![]; loop { let k = next.fetch_add(1, std::sync::atomic::Ordering::SeqCst); if k >= jobs.len() { break; } if let Err(x) = f(&jobs[k]) { e.push(x); } } e })).collect();
        hs.into_iter().flat_map(|h| h.join().unwrap_or_else(|_| vec!["a drawing thread stopped".to_string()])).collect()
    });
    match errs.into_iter().next() { Some(e) => Err(e), None => Ok(()) }
}

fn clear_pictures(dir: &Path) { if let Ok(rd) = std::fs::read_dir(dir) { for e in rd.flatten() { let p = e.path(); if p.extension().map(|x| x == "jpg").unwrap_or(false) { std::fs::remove_file(p).ok(); } } } }

/// Grade the nets and draw everything. `only`: draw just this net's picture (the confidence file is still written).
pub fn run(flow: &Path, only: Option<&str>) -> Result<Value, String> {
    let conf = netconf::compute(flow)?;
    let sc = load(flow)?;
    let dir = flow.join("nets");
    std::fs::create_dir_all(dir.join("net")).map_err(|e| e.to_string())?; std::fs::create_dir_all(dir.join("sections")).map_err(|e| e.to_string())?;
    let rows: Vec<&Value> = conf["nets"].as_array().into_iter().flatten().collect();
    if let Some(name) = only {
        let r = rows.iter().find(|r| s(r, "net") == name).ok_or(format!("no net named {name} in nets.json"))?;
        let p = dir.join(format!("net/{}.jpg", s(r, "file")));
        net_picture(&sc, r, &p)?;
        return Ok(json!({"only": name, "picture": p.display().to_string(), "level": r["level"]}));
    }
    clear_pictures(&dir.join("net")); clear_pictures(&dir.join("sections")); clear_pictures(&dir);
    let by_name: HashMap<&str, &Value> = rows.iter().map(|r| (s(r, "net"), *r)).collect();
    let nets_doc = crate::items::read_json(&flow.join("nets.json"))?;
    let named: Vec<(String, Vec<String>)> = nets_doc["nets"].as_array().into_iter().flatten().map(|x| (s(x, "name").to_string(), x["pins"].as_array().into_iter().flatten().filter_map(|p| p.as_str().map(str::to_string)).collect())).collect();
    let blocks = crate::items::read_json(&flow.join("blocks.json")).unwrap_or(json!({}));
    let sections: Vec<Section> = netsections::assign(&named, &blocks);
    // the overview pictures
    confidence_sheet(&sc, &conf, Mode::ByNet, None, &dir.join("confidence.jpg"))?;
    confidence_sheet(&sc, &conf, Mode::ByPiece, None, &dir.join("attribution.jpg"))?;
    for k in 0..2 { if sc.faces[k].is_some() { confidence_sheet(&sc, &conf, Mode::ByNet, Some(k), &dir.join(format!("confidence-{}.jpg", SIDES[k])))?; } }
    let pages = table_sheets(&sc, &conf, &dir)?;
    // one picture per net
    in_parallel(&rows, &|r: &&Value| net_picture(&sc, r, &dir.join(format!("net/{}.jpg", s(r, "file")))))?;
    // the section sheets
    struct Sheet { file: String, title: String, sub: String, nets: Vec<String> }
    let mut sheets: Vec<Sheet> = vec![]; let mut sec_rows = vec![];
    for (i, sec) in sections.iter().enumerate() {
        let chunks: Vec<&[String]> = sec.nets.chunks(PER_SHEET).collect(); let mut files = vec![];
        let mut tally: BTreeMap<&str, usize> = BTreeMap::new(); for x in &sec.nets { *tally.entry(by_name.get(x.as_str()).map(|r| s(r, "level")).unwrap_or("none")).or_default() += 1; }
        let lv: Vec<String> = LADDER.iter().filter_map(|l| tally.get(l).map(|c| format!("{c} {l}"))).collect();
        for (j, c) in chunks.iter().enumerate() {
            let file = if chunks.len() > 1 { format!("{:02}-{}-{}.jpg", i + 1, slug(&sec.title), j + 1) } else { format!("{:02}-{}.jpg", i + 1, slug(&sec.title)) };
            let title = if chunks.len() > 1 { format!("{} ({} of {})", sec.title, j + 1, chunks.len()) } else { sec.title.clone() };
            let sub = format!("{}   |   section {} of {}: {} net{}, {}   |   grouped because: {}", sc.name, i + 1, sections.len(), sec.nets.len(), if sec.nets.len() == 1 { "" } else { "s" }, lv.join(", "), sec.why);
            files.push(file.clone()); sheets.push(Sheet { file, title, sub, nets: c.to_vec() });
        }
        sec_rows.push(json!({"n": i + 1, "kind": sec.kind, "title": sec.title, "why": sec.why, "nets": sec.nets, "levels": tally, "sheets": files.iter().map(|f| format!("sections/{f}")).collect::<Vec<_>>()}));
    }
    in_parallel(&sheets, &|sh: &Sheet| section_sheet(&sc, &by_name, &sh.title, &sh.sub, &sh.nets, &dir.join("sections").join(&sh.file)))?;
    let doc = json!({"sections": sec_rows, "pictures": rows.iter().map(|r| (s(r, "net").to_string(), json!(format!("net/{}.jpg", s(r, "file"))))).collect::<serde_json::Map<_, _>>()});
    std::fs::write(dir.join("sections.json"), serde_json::to_string_pretty(&doc).unwrap() + "\n").map_err(|e| e.to_string())?;
    std::fs::write(dir.join("index.md"), index(&sc, &conf, &sec_rows, &by_name, pages)).map_err(|e| e.to_string())?;
    crate::netview::write(&sc, &conf, &sec_rows, &dir.join("view.html"))?;
    Ok(json!({"copperNotRead": conf["summary"]["copperNotRead"], "nets": rows.len(), "levels": conf["summary"]["levels"], "netsOfTwoOrMorePins": conf["summary"]["netsOfTwoOrMorePins"], "netsOfOnePin": conf["summary"]["netsOfOnePin"], "unnamedIslands": conf["summary"]["unnamedIslands"],
        "sections": sections.len(), "sheets": sheets.len(), "tablePages": pages, "netPictures": rows.len()}))
}

/// What was drawn, in one line.
pub fn one_line(s: &Value) -> String {
    if let Some(name) = s["only"].as_str() { return format!("net {name} drawn at level {}: {}", s["level"].as_str().unwrap_or(""), s["picture"].as_str().unwrap_or("")); }
    let l = &s["levels"];
    format!("nets drawn under reverse-flow/nets/: {} nets, {} copper, {} via, {} inferred by their weakest link; {} section sheets, {} net pictures", s["nets"], l["copper"], l["via"], l["inferred"], s["sheets"], s["netPictures"])
}
pub fn hints(s: &Value) -> Vec<String> {
    if s["only"].is_string() { return vec![]; }
    let mut v: Vec<String> = s["copperNotRead"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).map(|side| format!("The {side} face has no copper in these pictures and figures (not photographed, or read and failed `check copper`): a picture of that face says so, and no net is claimed through it.")).collect();
    v.extend(["Open nets/index.md, then look at nets/confidence.jpg against the photos: a net is as proven as the weakest link joining its pins.".to_string(),
        format!("Of the nets with two or more pins: {} copper, {} via, {} inferred. Of those with one pin: {} copper, {} inferred. {} isolated islands of copper carry no net.", s["netsOfTwoOrMorePins"]["copper"], s["netsOfTwoOrMorePins"]["via"], s["netsOfTwoOrMorePins"]["inferred"], s["netsOfOnePin"]["copper"], s["netsOfOnePin"]["inferred"], s["unnamedIslands"]),
        "net-confidence.json gives each net's weakest link and where it is: that is the place to probe first. These levels are per net, not the run's two scores: a join no photo shows between groups of pins is a hidden connection (unrouted.json), counts in the bench score, and has a continuity probe in `bench plan`.".to_string()]);
    v
}

fn index(sc: &Scene, conf: &Value, sections: &[Value], by_name: &HashMap<&str, &Value>, pages: usize) -> String {
    let sm = &conf["summary"]; let c = |v: &Value, k: &str| n(v, k);
    let mut md = format!("# Nets of {}\n\nWritten by `adom-aiflow-reverse nets draw` from the run's files. Do not edit by hand: run it again.\n\n", sc.name);
    for k in 0..2 { if let Some(words) = &sc.not_read[k] { md += &format!("**{words}.** No copper of that face is in any picture or area figure here, a picture of that face carries a band saying so, and no net is claimed through it.\n\n"); } }
    md += "A net's level is the rung of the **weakest link** in the set of links that joins its pins, strongest links first. It is not the share of its copper that was seen: one inferred join makes the net inferred. `../net-confidence.json` holds each net's level, weakest link and place, pins seen and copper by rung; docs/method.md gives the definition.\n\n";
    md += "These levels are per net and are not the run's scores: `confidence` prints the photo score (what photos can show) and the bench score (what needs the real board or outside documents). An inferred link between groups of pins is a hidden connection: it is left unrouted and listed in `../unrouted.json`, it counts in the bench score, and `bench plan` writes a continuity probe for it.\n\n";
    md += "## Confidence\n\n| Level | Nets | Of two or more pins | Of one pin | What it means |\n|---|---|---|---|---|\n";
    for l in ["copper", "via", "inferred", "none"] { md += &format!("| {l} | {} | {} | {} | {} |\n", c(&sm["levels"], l), c(&sm["netsOfTwoOrMorePins"], l), c(&sm["netsOfOnePin"], l), match l { "none" => "no pins", _ => level_words(l) }); }
    md += &format!("\n{} nets in all, {} of them also meet another net somewhere (listed as disputes, drawn with a cross). A net of one pin has nothing to join, so it is graded by how that pin meets its copper.\n\n", c(sm, "nets"), c(sm, "netsDisputed"));
    let dw = &sm["datasheetWitness"];
    if dw["chips"].as_u64().unwrap_or(0) > 0 { md += &format!("**Datasheet witness.** {} pin(s) of {} identified chip(s) were held against the copper: {} agree, {} disagree, {} not visible. {} of {} nets have a witness ({} agree, {} disagree); the column below says which. It is a second opinion beside the level and never changes it. Findings with crops: `../evidence/witness/findings.md`.\n\n", n(dw, "pins"), n(dw, "chips"), n(dw, "agree"), n(dw, "disagree"), n(dw, "notVisible"), n(dw, "netsWithWitness"), n(dw, "nets"), n(dw, "netsAgree"), n(dw, "netsDisagree")); }
    md += &format!("Copper that carries no net is not given a picture each. It is three categories in the confidence picture: **{} isolated islands** with no pad and no valid via (`none`, {} mm2), **{} groups** joined by vias that no pad names (`group`, {} mm2), and **{} pieces in conflict** where two nets meet (`conflict`).\n\n",
        c(sm, "unnamedIslands"), sm["unnamed"]["none"]["areaMm2"], c(&sm["unnamed"]["group"], "names"), sm["unnamed"]["group"]["areaMm2"], c(&sm["unnamed"]["conflict"], "names"));
    md += "| Picture | What it shows |\n|---|---|\n| [confidence.jpg](confidence.jpg) | both faces, every piece of copper by how well its net is proven, with the legend |\n";
    for k in 0..2 { if sc.faces[k].is_some() { md += &format!("| [confidence-{0}.jpg](confidence-{0}.jpg) | the {0} face alone at 40 px/mm, for looking closely |\n", SIDES[k]); } }
    md += "| [attribution.jpg](attribution.jpg) | the evidence under it: each piece of copper by its own rung |\n";
    for p in 1..=pages { md += &format!("| [confidence-table-{p}.jpg](confidence-table-{p}.jpg) | every net with its level, page {p} of {pages} |\n"); }
    md += "| [view.html](view.html) | the same in a browser: a net list beside the board, a click lights a net on both faces, with a switch between by confidence and by net |\n";
    md += "\nColours and patterns: copper is solid sky blue, via is green in level bands, inferred is orange in diagonal stripes, group is purple in a grid, none is grey dots, conflict is vermillion with dark stripes. Tracks are too thin for a pattern, so inferred, group and conflict tracks are dashed. The bottom face is shown turned over, so it lines up with the top.\n\n";
    md += "## Sections\n\n| # | Section | Nets | Levels | Sheets |\n|---|---|---|---|---|\n";
    for sec in sections {
        let lv: Vec<String> = LADDER.iter().filter_map(|l| sec["levels"][*l].as_u64().map(|k| format!("{k} {l}"))).collect();
        let sheets: Vec<String> = sec["sheets"].as_array().into_iter().flatten().filter_map(|f| f.as_str()).enumerate().map(|(i, f)| format!("[{}]({f})", i + 1)).collect();
        md += &format!("| {} | [{}](#section-{}) | {} | {} | {} |\n", sec["n"], s(sec, "title"), sec["n"], sec["nets"].as_array().map(|a| a.len()).unwrap_or(0), lv.join(", "), sheets.join(" "));
    }
    for sec in sections {
        md += &format!("\n<a id=\"section-{}\"></a>\n\n### {} {}\n\nGrouped because: {}. Sheets: {}\n\n| Net | Level | Known from | Pins seen | Weakest link | Datasheet | Picture |\n|---|---|---|---|---|---|---|\n", sec["n"], sec["n"], s(sec, "title"), s(sec, "why"),
            sec["sheets"].as_array().into_iter().flatten().filter_map(|f| f.as_str()).map(|f| format!("[{}]({f})", f.trim_start_matches("sections/"))).collect::<Vec<_>>().join(", "));
        for name in sec["nets"].as_array().into_iter().flatten().filter_map(|x| x.as_str()) {
            let Some(r) = by_name.get(name) else { continue };
            let dw = &r["datasheetWitness"];
            let witness = match s(dw, "witness") { "agree" => format!("agrees ({} pin(s))", n(dw, "agree")), "disagree" => format!("**disagrees**: {}", dw["findings"].as_array().into_iter().flatten().filter_map(|f| f["finding"].as_str()).collect::<Vec<_>>().join("; ").replace('|', "/")), _ if s(dw, "netList") == "disagree" => format!("none from copper; the net list **disagrees**: {}", dw["findings"].as_array().into_iter().flatten().filter_map(|f| f["finding"].as_str()).collect::<Vec<_>>().join("; ").replace('|', "/")), _ if s(dw, "netList") == "agree" => "none from copper; the net list follows the datasheet".to_string(), _ => "none".to_string() };
            let kf = &r["knownFrom"];
            let known = match s(kf, "from") { "pinout only" => format!("**pinout only**, {} ({})", s(kf, "tag"), kf["rules"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", ")), "" => String::new(), other => if kf["tag"].is_string() { format!("{other} ({})", s(kf, "tag")) } else { other.to_string() } };
            md += &format!("| {name} | {} | {known} | {} of {} | {} | {witness} | [{}.jpg](net/{}.jpg) |\n", s(r, "level"), n(r, "pinsSeen"), n(r, "pins"), s(&r["weakestLink"], "what").replace('|', "/"), s(r, "file"), s(r, "file"));
        }
    }
    md
}

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

    #[test]
    fn a_small_net_is_zoomed_and_a_wide_one_shows_the_whole_board() {
        let board = (100.0, 100.0, 173.65, 161.78);
        let (w, zoomed) = window(board, Some((120.0, 120.0, 124.0, 121.0)), 1.15);
        assert!(zoomed); assert!((w.2 - w.0) >= 12.0 && (w.2 - w.0) < 20.0, "{w:?}");
        assert!(((w.2 - w.0) / (w.3 - w.1) - 1.15).abs() < 1e-6, "the window has the panel's shape");
        assert!(w.0 <= 120.0 && w.2 >= 124.0 && w.1 <= 120.0 && w.3 >= 121.0, "the net is inside it");
        // near a corner the window stays on the board
        let (w, _) = window(board, Some((100.5, 100.5, 103.0, 102.0)), 1.15);
        assert!(w.0 >= 100.0 - 1e-9 && w.1 >= 100.0 - 1e-9, "{w:?}");
        assert_eq!(window(board, Some((101.0, 101.0, 170.0, 160.0)), 1.15), (board, false));
        assert_eq!(window(board, None, 1.15), (board, false));
    }

    #[test]
    fn a_view_puts_board_points_on_the_sheet() {
        let v = View::fit(0, (100.0, 100.0, 150.0, 140.0), (10.0, 20.0, 1000.0, 400.0));
        assert_eq!((v.w, v.h), (500, 400)); assert_eq!(v.scale(), 10.0);
        assert_eq!(v.at((100.0, 100.0)), (260.0, 20.0)); assert_eq!(v.at((150.0, 140.0)), (760.0, 420.0));
        assert!(v.holds((125.0, 120.0)) && !v.holds((151.0, 120.0)));
    }

    #[test]
    fn a_line_is_cut_at_the_panel() {
        let r = (0.0, 0.0, 10.0, 10.0);
        assert_eq!(clip((-5.0, 5.0), (5.0, 5.0), r), Some(((0.0, 5.0), (5.0, 5.0))));
        assert_eq!(clip((2.0, 2.0), (8.0, 8.0), r), Some(((2.0, 2.0), (8.0, 8.0))));
        assert_eq!(clip((-5.0, -1.0), (15.0, -1.0), r), None);
    }

    #[test]
    fn section_files_have_plain_names() {
        assert_eq!(slug("Supply rail +3V3"), "supply-rail-plus3v3"); assert_eq!(slug("Other nets of the block: Gate driver"), "other-nets-of-the-block-gate-driver"); assert_eq!(slug("USB3-0 nets"), "usb3-0-nets");
    }
}