main
Caleb Publish 0.5.0 1984b40
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446
//! The schematic, generated from the run's own files (parts.json, blocks.json, nets.json,
//! net-evidence.json), so the schematic and the board cannot disagree: every symbol pin is a footprint
//! pad number and every connection is a net of nets.json.
//!
//! What a reader gets: a root sheet with the hierarchy and a legend, and one sheet per functional block
//! (A4 when it fits, A3 at most, split when it does not). Parts are real symbols by type (`schsym`).
//! Inside a block the circuit is drawn with wires at right angles: every pin of an IC or connector
//! starts a strip that runs straight out from the pin, parts in series sit in line on it, parts to a rail
//! hang from it (down to ground, up to a supply), and decoupling capacitors hang at the supply pin of
//! the IC they sit nearest on the board. A net that leaves the strip ends in a label at the end of its
//! wire, the text running back along the wire. Rails use power symbols.
//!
//! Evidence is on the sheet: a wire is solid when the pad it leads to was seen joined to that net's
//! copper in the photos and dashed when the join is inferred. A pin whose net is not known ends in a "?"
//! marker (not a no-connect flag, which would claim the pin is unused). No-connect flags appear only
//! for pins nets.json lists under `noConnect`. Each symbol carries Evidence, Test and Marking fields.
//!
//! Optional part fields the AI may fill in parts.json: `symbol` ("Library:Name" in KiCad's standard
//! libraries, used when its pin numbers are exactly the footprint's pads) and `pins` (pad number to pin
//! name, or to {"name", "type"}; a transistor named G/S/D or B/C/E gets its drawing with those pads, an
//! IC gets a box grouped by function).
use crate::kicad_out::{self, uuid};
use crate::schsym::{self, ascii, esc, safe, BoxPin, Libs, Sym, Term, STEP};
use serde_json::{json, Value};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::path::Path;

/// Pad numbers of a footprint text, in order, without repeats or unnamed (mechanical) pads.
pub fn pad_numbers(text: &str) -> Vec<String> {
    let mut v: Vec<String> = vec![];
    if let Ok(n) = crate::sexp::parse(text) {
        for p in n.children("pad") { let k = p.arg(0).unwrap_or_default(); if !k.is_empty() && !v.contains(&k) { v.push(k); } }
    }
    v.sort_by(|a, b| natural(a, b));
    v
}

pub fn natural(a: &str, b: &str) -> std::cmp::Ordering {
    let split = |s: &str| -> (String, u64, String) { let i = s.find(|c: char| c.is_ascii_digit()).unwrap_or(s.len()); let j = s[i..].find(|c: char| !c.is_ascii_digit()).map(|k| i + k).unwrap_or(s.len()); (s[..i].to_string(), s[i..j].parse().unwrap_or(u64::MAX), s[j..].to_string()) };
    split(a).cmp(&split(b))
}

#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Kind { R, C, L, Fb, D, Led, Xtal, Q, Conn, Tp, Sw, Hole, Fid, Fuse, Ic, Other }
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Rail { No, Supply, Ground }
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Role { Anchor, Two, One, Zero }

pub fn kind_of(r: &str, footprint: &str) -> Kind {
    let p: String = r.chars().take_while(|c| c.is_ascii_alphabetic()).collect::<String>().to_uppercase();
    match p.as_str() {
        "R" | "RN" => Kind::R, "C" => Kind::C, "L" => Kind::L, "FB" => Kind::Fb, "LED" => Kind::Led,
        "D" | "Z" | "TVS" => if footprint.contains("LED") { Kind::Led } else { Kind::D },
        "Y" | "X" | "XTAL" => Kind::Xtal, "Q" | "T" => Kind::Q, "J" | "JP" | "P" | "CN" | "CON" => Kind::Conn, "TP" => Kind::Tp,
        "SW" | "S" | "BTN" => Kind::Sw, "H" | "MH" => Kind::Hole, "FID" => Kind::Fid, "F" => Kind::Fuse, "U" | "IC" => Kind::Ic, _ => Kind::Other,
    }
}

fn is_ground(n: &str) -> bool { let u = n.to_uppercase(); u == "GND" || u == "VSS" || u == "0V" || u == "EP" || u == "PAD" || u.ends_with("GND") || u.starts_with("GND") || u.starts_with("VSS") }
fn is_supply(n: &str) -> bool {
    let u = n.to_uppercase(); let b = u.as_bytes();
    // VBUS_EN, VIN_SENSE and the like are signals about a rail, not the rail
    let last = u.rsplit(['_', '.']).next().unwrap_or("").trim_start_matches('N');
    if u.contains('_') && ["EN", "ENABLE", "SENSE", "SNS", "MON", "ADC", "DET", "OK", "PG", "PGOOD", "FAULT", "FLT", "OVERRIDE", "ON", "OFF", "CTRL", "SEL", "FB"].contains(&last) { return false; }
    if u.starts_with('+') || ["VCC", "VDD", "VIN", "VBAT", "VBUS", "VREF", "AVDD", "DVDD", "VSYS", "VPP"].iter().any(|k| u.starts_with(k)) || u == "VM" { return true; }
    // a voltage in the name: 3V3, 5V, 3.3V
    (0..b.len()).any(|i| b[i] == b'V' && i > 0 && b[i - 1].is_ascii_digit() && (i + 1 == b.len() || b[i + 1].is_ascii_digit() || b[i + 1] == b'_'))
}
pub fn rail_of(name: &str) -> Rail { if is_ground(name) { Rail::Ground } else if is_supply(name) { Rail::Supply } else { Rail::No } }

/// A value as the sheet rules write it: capacitors without the F (100n, 10u), resistors with R, K, M
/// (0R002, 22R, 2.2K), everything in ASCII; a note in brackets stays in the Description.
pub fn sheet_value(kind: Kind, raw: &str) -> String {
    let t = raw.split(" (").next().unwrap_or("").trim();
    if t.is_empty() { return "?".into(); }
    let s: String = t.replace(' ', "");
    let num_end = s.find(|c: char| !(c.is_ascii_digit() || c == '.')).unwrap_or(s.len());
    let (num, unit) = (&s[..num_end], &s[num_end..]);
    let ok = !num.is_empty() && num.parse::<f64>().is_ok();
    match kind {
        Kind::C if ok => { let u = ascii(unit); let u = u.trim_end_matches(['F', 'f']); if matches!(u, "p" | "n" | "u" | "m" | "") { return format!("{num}{u}"); } }
        Kind::R if ok => {
            let x: f64 = num.parse().unwrap_or(0.0);
            match unit { "mΩ" | "mR" | "mOhm" => { let ohm = x / 1000.0; let frac = format!("{:.6}", ohm.fract()); return format!("{}R{}", ohm.trunc() as u64, frac[2..].trim_end_matches('0')); }
                "Ω" | "R" | "Ohm" | "" => return format!("{num}R"), "kΩ" | "k" | "K" | "kOhm" => return format!("{num}K"), "MΩ" | "M" | "MOhm" => return format!("{num}M"), _ => {} }
        }
        _ => {}
    }
    let a = ascii(t); if a.chars().count() > 30 { a.chars().take(29).collect::<String>() + "~" } else { a }
}

#[derive(Clone, Debug)]
struct BoxSpec { left: Vec<BoxPin>, right: Vec<BoxPin> }

struct Part { r: String, kind: Kind, pads: Vec<String>, sym: Option<Sym>, boxed: Option<BoxSpec>, value: String, fields: Vec<(String, String)>, pos: (f64, f64), role: Role }
struct Net { name: String, rail: Rail, pins: Vec<(usize, String)>, named: bool, measured: bool, fixed_rail: bool }
struct Block { id: String, name: String, parts: Vec<usize> }

pub struct Design {
    name: String, parts: Vec<Part>, nets: Vec<Net>, blocks: Vec<Block>,
    pin_net: HashMap<(usize, String), usize>, seen: HashSet<(usize, String)>, nc: HashSet<(usize, String)>, pad_pos: HashMap<(usize, String), (f64, f64)>,
    /// footprints the program draws itself (lugs, holes), by their own names, for the project's footprint library
    generated: BTreeMap<String, String>,
    pub notes: Vec<String>,
    /// What the root sheet must say in words under its legend: a face whose copper was not read (`notread`), so a reader of the
    /// sheets knows why pins on that face end in a ? marker.
    pub carried: Vec<String>,
}

fn pins_field(v: &Value) -> HashMap<String, (String, String)> {
    v["pins"].as_object().map(|o| o.iter().map(|(k, p)| (k.clone(), match p { Value::String(s) => (s.clone(), String::new()), _ => (p["name"].as_str().unwrap_or("").to_string(), p["type"].as_str().unwrap_or("").to_string()) })).collect()).unwrap_or_default()
}

const ETYPES: [&str; 11] = ["input", "output", "bidirectional", "tri_state", "passive", "free", "unspecified", "power_in", "power_out", "open_collector", "open_emitter"];

impl Design {
    /// Everything the generator draws from, read from the run's documents.
    pub fn load(cache: &Path, libs: &mut Libs, parts: &[Value], blocks: &Value, nets_doc: &Value, evidence: &Value, name: &str) -> Design {
        let mut d = Design { name: name.to_string(), parts: vec![], nets: vec![], blocks: vec![], pin_net: HashMap::new(), seen: HashSet::new(), nc: HashSet::new(), pad_pos: HashMap::new(), generated: BTreeMap::new(), notes: vec![], carried: vec![] };
        let mut idx: HashMap<String, usize> = HashMap::new();
        for p in parts {
            let r = p["ref"].as_str().unwrap_or("?").to_string();
            let mut fp = p["footprint"].as_str().unwrap_or("").to_string();
            let pads = match kicad_out::footprint_text(cache, &fp, p) {
                Ok(t) => {
                    // a generated footprint is filed under its own name, so the Footprint field names a real library entry
                    if let (Some(lib), Some(n)) = (fp.strip_suffix(":lug").or(fp.strip_suffix(":hole")), crate::sexp::parse(&t).ok().and_then(|n| n.arg(0))) { fp = format!("{lib}:{n}"); d.generated.insert(n, t.clone()); }
                    pad_numbers(&t)
                }
                Err(e) => { d.notes.push(format!("{r}: {e}; drawn without pins")); vec![] }
            };
            let kind = kind_of(&r, &fp);
            let raw = p["value"].as_str().unwrap_or("");
            let mut fields = vec![("Footprint".to_string(), fp.clone()), ("Datasheet".into(), "~".into())];
            let desc: Vec<&str> = [p["function"].as_str().unwrap_or(""), raw, p["package"].as_str().unwrap_or("")].into_iter().filter(|s| !s.is_empty()).collect();
            fields.push(("Description".into(), desc.join("; ")));
            let mk = p["marking"].as_str().unwrap_or("");
            if !mk.is_empty() && mk != "none" { fields.push(("Marking".into(), mk.to_string())); }
            fields.push(("Evidence".into(), p["tag"].as_str().unwrap_or("UNKNOWN").to_string()));
            let tests: Vec<&str> = p["tests"].as_array().into_iter().flatten().filter_map(|t| t.as_str()).collect();
            if !tests.is_empty() { fields.push(("Test".into(), tests.join(" "))); }
            idx.insert(r.clone(), d.parts.len());
            d.parts.push(Part { r, kind, pads, sym: None, boxed: None, value: sheet_value(kind, raw), fields, pos: (p["pos_mm"][0].as_f64().unwrap_or(0.0), p["pos_mm"][1].as_f64().unwrap_or(0.0)), role: Role::Zero });
        }
        for pd in kicad_out::pads_of(cache, parts) { if let Some(&i) = idx.get(&pd.part) { d.pad_pos.entry((i, pd.num.clone())).or_insert((pd.x, pd.y)); } }
        let mut used: HashSet<String> = HashSet::new();
        for n in nets_doc["nets"].as_array().into_iter().flatten() {
            let raw = n["name"].as_str().unwrap_or("").to_string();
            let mut name: String = ascii(&raw).chars().map(|c| match c { ' ' | '/' | '\t' => '_', '{' => '(', '}' => ')', c => c }).collect();
            if name.is_empty() { name = format!("N{}", d.nets.len()); }
            while !used.insert(name.clone()) { name.push('_'); }
            let mut pins = vec![];
            for pin in n["pins"].as_array().into_iter().flatten().filter_map(|p| p.as_str()) {
                let Some((r, pad)) = pin.rsplit_once('.') else { continue };
                let Some(&i) = idx.get(r) else { continue };   // PHOTO1 and the like: metal no part explains
                if !d.parts[i].pads.iter().any(|q| q == pad) { d.notes.push(format!("net {raw}: {pin} is no pad of {r}'s footprint; left off the sheet")); continue; }
                if d.pin_net.insert((i, pad.to_string()), d.nets.len()).is_none() { pins.push((i, pad.to_string())); }
            }
            let unnamed = n["nameSource"].as_str().map(|s| s.starts_with("none")).unwrap_or(false);
            // a net of one pin that nobody named says nothing a reader can use: the pin reads as unknown
            if unnamed && pins.len() == 1 { d.pin_net.remove(&pins[0]); pins.clear(); }
            let tag = n["tag"].as_str().unwrap_or("");
            // nets.json may say outright what the name only suggests: "rail": "supply" | "ground" | "signal"
            let rail = match n["rail"].as_str() { Some("supply") => Rail::Supply, Some("ground") => Rail::Ground, Some("signal") => Rail::No, _ => rail_of(&name) };
            d.nets.push(Net { rail, name, pins, named: !unnamed, measured: matches!(tag, "SEEN" | "MEASURED"), fixed_rail: n["rail"].is_string() });
        }
        for p in nets_doc["noConnect"].as_array().into_iter().flatten() {
            if let Some((r, pad)) = p.as_str().or_else(|| p["pin"].as_str()).and_then(|s| s.rsplit_once('.')) { if let Some(&i) = idx.get(r) { d.nc.insert((i, pad.to_string())); } }
        }
        // how each pin's join is known: the copper attribution's per-pin verdict, else the net's own tag
        let mut have_ev = false;
        for n in evidence["nets"].as_array().into_iter().flatten() {
            for (pin, st) in n["pins"].as_object().into_iter().flatten() {
                have_ev = true;
                if matches!(st.as_str(), Some("copper") | Some("via")) { if let Some((r, pad)) = pin.rsplit_once('.') { if let Some(&i) = idx.get(r) { d.seen.insert((i, pad.to_string())); } } }
            }
        }
        if !have_ev { for n in &d.nets { if n.measured { for p in &n.pins { d.seen.insert(p.clone()); } } } }
        // symbols
        for i in 0..d.parts.len() { d.choose_symbol(libs, i, &parts[i]); }
        // a net that feeds a supply input is a rail whatever it is called
        for i in 0..d.parts.len() {
            if let Some(s) = &d.parts[i].sym { for t in &s.terms { if t.etype == "power_in" { if let Some(&n) = d.pin_net.get(&(i, t.nums[0].clone())) { if d.nets[n].rail == Rail::No && !d.nets[n].fixed_rail { d.nets[n].rail = Rail::Supply; } } } } }
            if let Some(b) = &d.parts[i].boxed { for t in b.left.iter().chain(b.right.iter()) { if t.etype == "power_in" { if let Some(&n) = d.pin_net.get(&(i, t.num.clone())) { if d.nets[n].rail == Rail::No && !d.nets[n].fixed_rail { d.nets[n].rail = Rail::Supply; } } } } }
        }
        // blocks; parts in none get one of their own
        let mut placed: HashSet<usize> = HashSet::new();
        for b in blocks["blocks"].as_array().into_iter().flatten() {
            let ps: Vec<usize> = b["parts"].as_array().into_iter().flatten().filter_map(|r| r.as_str()).filter_map(|r| idx.get(r).copied()).filter(|i| placed.insert(*i)).collect();
            if ps.is_empty() { continue; }
            let id = safe(b["id"].as_str().unwrap_or("block"));
            d.blocks.push(Block { name: ascii(b["name"].as_str().unwrap_or(&id)).to_uppercase(), id, parts: ps });
        }
        let rest: Vec<usize> = (0..d.parts.len()).filter(|i| !placed.contains(i)).collect();
        if !rest.is_empty() { d.blocks.push(Block { id: "unassigned".into(), name: "PARTS IN NO BLOCK".into(), parts: rest }); }
        d
    }

    fn same_net(&self, pi: usize, nums: &[String]) -> bool {
        match self.pin_net.get(&(pi, nums[0].clone())) { Some(n) => nums.iter().all(|k| self.pin_net.get(&(pi, k.clone())) == Some(n)), None => nums.len() == 1 }
    }

    /// A library symbol fits when its pin numbers are exactly the footprint's pads, and pads it stacks on one point share one known net.
    fn fits(&self, pi: usize, s: &Sym) -> Result<(), String> {
        let have: BTreeSet<&str> = s.terms.iter().flat_map(|t| t.nums.iter().map(|x| x.as_str())).collect();
        let want: BTreeSet<&str> = self.parts[pi].pads.iter().map(|x| x.as_str()).collect();
        if have != want { return Err(format!("its pins ({}) are not the footprint's pads ({})", have.iter().take(12).cloned().collect::<Vec<_>>().join(" "), want.iter().take(12).cloned().collect::<Vec<_>>().join(" "))); }
        for t in &s.terms { if !self.same_net(pi, &t.nums) { return Err(format!("it draws pads {} as one lead, and nets.json does not put them on one net", t.nums.join(", "))); } }
        Ok(())
    }

    fn choose_symbol(&mut self, libs: &mut Libs, pi: usize, v: &Value) {
        let (kind, pads, r) = (self.parts[pi].kind, self.parts[pi].pads.clone(), self.parts[pi].r.clone());
        let named = pins_field(v);
        let text = format!("{} {} {}", v["value"].as_str().unwrap_or(""), v["function"].as_str().unwrap_or(""), v["footprint"].as_str().unwrap_or("")).to_lowercase();
        let two = pads.len() == 2 && pads[0] == "1" && pads[1] == "2";
        let mut tries: Vec<(String, String, String, Option<HashMap<String, Vec<String>>>, bool)> = vec![];
        if let Some((lib, name)) = v["symbol"].as_str().and_then(|s| s.split_once(':')) { tries.push((lib.into(), name.into(), safe(name), None, false)); }
        let dev = |n: &str, hide: bool| ("Device".to_string(), n.to_string(), n.to_string(), None, hide);
        match kind {
            Kind::R if two => tries.push(dev("R", true)),
            Kind::C if two => tries.push(dev(if v["polarised"] == json!(true) || text.contains("cp_") || text.contains("tantal") || text.contains("elec") { "C_Polarized" } else { "C" }, true)),
            Kind::L if two => tries.push(dev("L", true)), Kind::Fb if two => tries.push(dev("FerriteBead", true)), Kind::Fuse if two => tries.push(dev("Fuse", true)),
            Kind::D if two => tries.push(dev(if text.contains("schottky") { "D_Schottky" } else if text.contains("zener") { "D_Zener" } else if text.contains("tvs") { "D_TVS" } else { "D" }, true)),
            Kind::Led if two => tries.push(dev("LED", true)),
            Kind::Xtal if two => tries.push(dev("Crystal", true)),
            Kind::Sw if two => tries.push(("Switch".into(), "SW_Push".into(), "SW_Push".into(), None, true)),
            Kind::Tp if pads.len() == 1 => tries.push(("Connector".into(), "TestPoint".into(), "TestPoint".into(), Some([("1".to_string(), pads.clone())].into_iter().collect()), true)),
            Kind::Hole | Kind::Fid if pads.is_empty() => tries.push(("Mechanical".into(), if kind == Kind::Fid { "Fiducial" } else { "MountingHole" }.into(), if kind == Kind::Fid { "Fiducial" } else { "MountingHole" }.into(), None, true)),
            Kind::Hole if pads.len() == 1 => tries.push(("Mechanical".into(), "MountingHole_Pad".into(), "MountingHole_Pad".into(), Some([("1".to_string(), pads.clone())].into_iter().collect()), true)),
            Kind::Q if !named.is_empty() => {
                // a transistor drawing on the pads the run names: all leads named, one drawing per pad map
                let mut by: BTreeMap<String, Vec<String>> = BTreeMap::new();
                for p in &pads { if let Some((n, _)) = named.get(p) { by.entry(n.to_uppercase()).or_default().push(p.clone()); } }
                let leads: Vec<&str> = by.keys().map(|s| s.as_str()).collect();
                let shape = if leads == ["D", "G", "S"] { if text.contains("p-mos") || text.contains("pmos") || text.contains("p-ch") { Some("Q_PMOS") } else if text.contains("n-mos") || text.contains("nmos") || text.contains("n-ch") { Some("Q_NMOS") } else { None } }
                    else if leads == ["B", "C", "E"] { if text.contains("pnp") { Some("Q_PNP") } else if text.contains("npn") { Some("Q_NPN") } else { None } } else { None };
                if let (Some(s), true) = (shape, by.values().map(|v| v.len()).sum::<usize>() == pads.len()) {
                    let tag = by.iter().map(|(k, v)| format!("{k}{}", v.join("."))).collect::<Vec<_>>().join("_");
                    tries.push(("Device".into(), s.into(), format!("{s}_{}", safe(&tag)), Some(by.into_iter().collect()), false));
                }
            }
            Kind::Conn if !pads.is_empty() => {
                let nums: Vec<&String> = pads.iter().filter(|p| p.parse::<usize>().is_ok()).collect();
                let n = nums.len(); let mp = pads.len() == n + 1 && pads.iter().any(|p| p == "MP");
                let in_order = (1..=n).all(|k| nums.iter().any(|p| p.parse::<usize>().ok() == Some(k)));
                if in_order && n >= 1 && n <= 40 && (pads.len() == n || mp) {
                    let two_rows = n % 2 == 0 && n >= 4 && (text.contains(&format!("_2x{:02}", n / 2)) || text.contains(&format!("_02x{:02}", n / 2)));
                    if mp { let s = format!("Conn_01x{n:02}_MountingPin"); tries.push(("Connector_Generic_MountingPin".into(), s.clone(), s, None, true)); }
                    else if two_rows { let s = format!("Conn_02x{:02}_Odd_Even", n / 2); tries.push(("Connector_Generic".into(), s.clone(), s, None, true)); }
                    else { let s = format!("Conn_01x{n:02}"); tries.push(("Connector_Generic".into(), s.clone(), s, None, true)); }
                }
            }
            _ => {}
        }
        for (lib, name, as_name, ren, hide) in tries {
            match libs.symbol(&lib, &name, &as_name, ren.as_ref(), hide) {
                Ok(s) => match self.fits(pi, &s) { Ok(()) => { self.set_role(pi, Some(s)); return; } Err(e) => self.notes.push(format!("{r}: {lib}:{name} not used: {e}")) },
                Err(e) => self.notes.push(format!("{r}: {lib}:{name} not used: {e}")),
            }
        }
        // a box: by function when the run names the pins, by pad order otherwise
        let etype = |pad: &str| -> String { match named.get(pad) { Some((n, t)) => if ETYPES.contains(&t.as_str()) { t.clone() } else if is_ground(n) || is_supply(n) { "power_in".into() } else { "passive".into() }, None => "passive".into() } };
        let pin = |pad: &String, group: i32| BoxPin { num: pad.clone(), name: named.get(pad).map(|x| ascii(&x.0)).unwrap_or_default(), etype: etype(pad), y: group };
        let mut spec = BoxSpec { left: vec![], right: vec![] };
        if pads.iter().all(|p| named.contains_key(p)) && !pads.is_empty() {
            // power on top, ground at the bottom, inputs left, outputs right, a gap between groups
            for p in &pads {
                let (n, t) = (&named[p].0, etype(p));
                let out = matches!(t.as_str(), "output" | "power_out" | "open_collector" | "open_emitter" | "tri_state") || (named[p].1.is_empty() && n.to_uppercase().contains("OUT"));
                if is_ground(n) { spec.left.push(pin(p, 3)); } else if t == "power_in" || is_supply(n) { spec.left.push(pin(p, 0)); }
                else if out { spec.right.push(pin(p, 1)); } else if t == "input" { spec.left.push(pin(p, 1)); } else { spec.right.push(pin(p, 2)); }
            }
            if spec.right.is_empty() || spec.left.len() > 3 * spec.right.len().max(1) { let mid: Vec<BoxPin> = spec.left.iter().filter(|p| p.y == 1).cloned().collect(); if mid.len() > 3 { spec.left.retain(|p| p.y != 1); spec.right.splice(0..0, mid); } }
            spec.left.sort_by_key(|p| p.y); spec.right.sort_by_key(|p| p.y);
        } else {
            // around the package the way the pads are numbered: down the left, up the right; a centre pad last
            let n = pads.len(); let body = n - n % 2; let half = body / 2;
            let wide = kind == Kind::Conn && n > 8;
            for (k, p) in pads.iter().enumerate() {
                if k < half || (!wide && n <= 3) { spec.left.push(pin(p, 0)); } else if k < body { spec.right.push(pin(p, 0)); } else { spec.left.push(pin(p, 3)); }
            }
            if !wide { spec.right.reverse(); }
        }
        self.parts[pi].boxed = Some(spec);
        self.set_role(pi, None);
    }

    fn set_role(&mut self, pi: usize, s: Option<Sym>) {
        let p = &mut self.parts[pi];
        p.role = match &s {
            Some(s) => match s.terms.len() {
                0 => Role::Zero,
                1 if p.kind != Kind::Conn => Role::One,
                2 if p.kind != Kind::Conn && s.terms[0].dir == (-s.terms[1].dir.0, -s.terms[1].dir.1) && (s.terms[0].x == s.terms[1].x || s.terms[0].y == s.terms[1].y) => Role::Two,
                _ => Role::Anchor,
            },
            None => if p.pads.is_empty() { Role::Zero } else { Role::Anchor },
        };
        p.sym = s;
    }

    fn net_of(&self, pi: usize, pad: &str) -> Option<usize> { self.pin_net.get(&(pi, pad.to_string())).copied() }
    fn pos_of(&self, pi: usize, pad: &str) -> (f64, f64) { self.pad_pos.get(&(pi, pad.to_string())).copied().unwrap_or(self.parts[pi].pos) }
}

// ---------------------------------------------------------------------------------------------
// The drawing: everything in steps of 1.27 mm, y down.
// ---------------------------------------------------------------------------------------------

type Bx = (f64, f64, f64, f64);
#[derive(Clone, Debug)]
struct Wire { a: (i32, i32), b: (i32, i32), seen: bool }
#[derive(Clone, Debug)]
struct Lab { net: usize, x: i32, y: i32, run: (i32, i32) }
#[derive(Clone, Debug)]
struct Txt { s: String, x: f64, y: f64, size: f64, bold: bool, just: i32 }
#[derive(Clone, Debug)]
struct Inst { sym: String, x: i32, y: i32, rot: i32, part: Option<usize>, /** the net a rail symbol stands for */ rail: Option<usize>, value: String, ref_at: Option<(f64, f64)>, val_at: Option<(f64, f64)> }

#[derive(Clone, Debug, Default)]
struct Cv { insts: Vec<Inst>, wires: Vec<Wire>, labels: Vec<Lab>, texts: Vec<Txt>, ncs: Vec<(i32, i32)>, boxes: Vec<Bx> }

/// Width of a text in steps at 50 mil, on the generous side so wires are never shorter than their label.
fn tw(s: &str) -> f64 { s.chars().count() as f64 * 0.92 }
fn lab_len(s: &str) -> i32 { let n = tw(s).ceil() as i32 + 2; n + n % 2 }
fn rot(v: (i32, i32), r: i32) -> (i32, i32) { match r.rem_euclid(360) { 0 => v, 90 => (v.1, -v.0), 180 => (-v.0, -v.1), _ => (-v.1, v.0) } }
fn rotf(v: (f64, f64), r: i32) -> (f64, f64) { match r.rem_euclid(360) { 0 => v, 90 => (v.1, -v.0), 180 => (-v.0, -v.1), _ => (-v.1, v.0) } }
fn hit(a: &Bx, b: &Bx, m: f64) -> bool { a.0 < b.2 + m && b.0 < a.2 + m && a.1 < b.3 + m && b.1 < a.3 + m }

impl Cv {
    fn bounds(&self) -> Bx { self.boxes.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |a, b| (a.0.min(b.0), a.1.min(b.1), a.2.max(b.2), a.3.max(b.3))) }
    fn wire(&mut self, a: (i32, i32), b: (i32, i32), seen: bool) {
        if a == b { return; }
        self.boxes.push((a.0.min(b.0) as f64 - 0.1, a.1.min(b.1) as f64 - 0.1, a.0.max(b.0) as f64 + 0.1, a.1.max(b.1) as f64 + 0.1));
        self.wires.push(Wire { a, b, seen });
    }
    fn label(&mut self, d: &Design, net: usize, x: i32, y: i32, run: (i32, i32)) {
        let len = tw(&d.nets[net].name) + 0.5;
        let (x, y) = (x as f64, y as f64);
        self.boxes.push(match run { (1, 0) => (x, y - 1.4, x + len, y - 0.25), (-1, 0) => (x - len, y - 1.4, x, y - 0.25), (0, -1) => (x - 1.4, y - len, x - 0.25, y), _ => (x - 1.4, y, x - 0.25, y + len) });
        self.labels.push(Lab { net, x: x as i32, y: y as i32, run });
    }
    fn text(&mut self, s: &str, x: f64, y: f64, size: f64, bold: bool, just: i32) {
        let w = tw(s) * size / 1.27;
        let x0 = match just { -1 => x, 0 => x - w / 2.0, _ => x - w };
        self.boxes.push((x0, y - 0.7 * size / 1.27, x0 + w, y + 0.7 * size / 1.27));
        self.texts.push(Txt { s: s.to_string(), x, y, size, bold, just });
    }
    fn merge(&mut self, o: &Cv, dx: i32, dy: i32) {
        let (fx, fy) = (dx as f64, dy as f64);
        self.insts.extend(o.insts.iter().map(|i| Inst { x: i.x + dx, y: i.y + dy, ref_at: i.ref_at.map(|p| (p.0 + fx, p.1 + fy)), val_at: i.val_at.map(|p| (p.0 + fx, p.1 + fy)), ..i.clone() }));
        self.wires.extend(o.wires.iter().map(|w| Wire { a: (w.a.0 + dx, w.a.1 + dy), b: (w.b.0 + dx, w.b.1 + dy), seen: w.seen }));
        self.labels.extend(o.labels.iter().map(|l| Lab { x: l.x + dx, y: l.y + dy, ..l.clone() }));
        self.texts.extend(o.texts.iter().map(|t| Txt { x: t.x + fx, y: t.y + fy, ..t.clone() }));
        self.ncs.extend(o.ncs.iter().map(|p| (p.0 + dx, p.1 + dy)));
        self.boxes.extend(o.boxes.iter().map(|b| (b.0 + fx, b.1 + fy, b.2 + fx, b.3 + fy)));
    }
    fn clashes(&self, o: &Cv, dx: i32, dy: i32, m: f64) -> bool {
        let (fx, fy) = (dx as f64, dy as f64);
        o.boxes.iter().any(|b| { let b = (b.0 + fx, b.1 + fy, b.2 + fx, b.3 + fy); self.boxes.iter().any(|a| hit(a, &b, m)) })
    }
}

/// Seat a symbol so that one of its terminals sits at `at` with its wire leaving along `want`.
fn seat(s: &Sym, t: usize, at: (i32, i32), want: (i32, i32)) -> (i32, i32, i32) {
    let r = [0, 90, 180, 270].into_iter().find(|r| rot(s.terms[t].dir, *r) == want).unwrap_or(0);
    let o = rot((s.terms[t].x, s.terms[t].y), r);
    (at.0 - o.0, at.1 - o.1, r)
}
fn term_at(s: &Sym, t: usize, x: i32, y: i32, r: i32) -> (i32, i32) { let o = rot((s.terms[t].x, s.terms[t].y), r); (x + o.0, y + o.1) }
fn body_box(s: &Sym, x: i32, y: i32, r: i32) -> Bx {
    let (a, b) = (rotf((s.bb.0, s.bb.1), r), rotf((s.bb.2, s.bb.3), r));
    (x as f64 + a.0.min(b.0), y as f64 + a.1.min(b.1), x as f64 + a.0.max(b.0), y as f64 + a.1.max(b.1))
}

#[derive(Clone, Debug)]
enum Far { Rail(usize), Unknown, Label(usize) }
#[derive(Clone, Debug)]
enum Item { Shunt { part: usize, near: String, far: String, to: Far }, Tap { part: usize, pad: String }, Series { part: usize, near: String, far: String, next: Box<Chain> } }
#[derive(Clone, Debug, PartialEq)]
enum End { None, Label(usize), Rail(usize), Unknown(bool), Solo(usize), Nc }
/// One strip: a net drawn straight out from a pin (or from a label when it starts nowhere), with what hangs on it.
#[derive(Clone, Debug)]
struct Chain { net: Option<usize>, from: Option<(usize, String)>, items: Vec<Item>, end: End, lead_label: bool, lead_rail: bool }

#[derive(Clone, Copy, PartialEq)]
enum TextAt { Right, Above, Ic }

struct Lay<'a> {
    d: &'a Design, syms: BTreeMap<String, Sym>, box_syms: HashMap<usize, Sym>, claimed: HashSet<usize>,
    /// satellite part to (its pad on the shared net, anchor part, anchor terminal)
    attach: HashMap<usize, (String, usize, usize)>,
    in_block: HashSet<usize>, free: bool, stats: BTreeMap<&'static str, usize>,
}

/// The widest a strip may grow from a pin, and from a label, in steps.
const PIN_STRIP: i32 = 76;
const FREE_STRIP: i32 = 150;

impl<'a> Lay<'a> {
    fn count(&mut self, k: &'static str) { *self.stats.entry(k).or_default() += 1; }
    fn sym_of(&self, pi: usize) -> Sym { self.d.parts[pi].sym.clone().or_else(|| self.box_syms.get(&pi).cloned()).expect("symbol chosen before drawing") }
    fn reg(&mut self, s: Sym) -> String { let n = s.name.clone(); self.syms.entry(n.clone()).or_insert(s); n }
    fn seen(&self, pi: usize, pad: &str) -> bool { self.d.seen.contains(&(pi, pad.to_string())) }

    /// A part's symbol with its reference and value where they read without touching the body.
    fn put(&mut self, cv: &mut Cv, pi: usize, x: i32, y: i32, r: i32, mode: TextAt, clear: &[Bx]) {
        let s = self.sym_of(pi);
        let b = body_box(&s, x, y, r);
        let p = &{ self.d }.parts[pi];
        let (wr, wv) = (tw(&p.r), tw(&p.value));
        let (ra, va) = match mode {
            TextAt::Right => ((b.2 + 0.8 + wr / 2.0, (b.1 + b.3) / 2.0 - 1.0), (b.2 + 0.8 + wv / 2.0, (b.1 + b.3) / 2.0 + 1.0)),
            TextAt::Above => { let x0 = (b.0 + b.2) / 2.0 - (wr + 1.0 + wv) / 2.0; ((x0 + wr / 2.0, b.1 - 1.1), (x0 + wr + 1.0 + wv / 2.0, b.1 - 1.1)) }
            TextAt::Ic => {
                // above the body's corner, or below it, wherever no pin's wire runs
                let w = wr + 1.0 + wv;
                let mut spots = vec![(b.0, b.1 - 1.2), (b.2 - w, b.1 - 1.2), (b.0, b.3 + 1.2), (b.2 - w, b.3 + 1.2)];
                // a transistor: under its one left lead, clear of the leads above and below
                let lefts: Vec<&Term> = s.terms.iter().filter(|t| t.dir == (-1, 0)).collect();
                if lefts.len() == 1 && !s.terms.iter().any(|t| t.dir == (1, 0)) { spots.insert(0, ((x + lefts[0].x) as f64 + 2.0 - w, (y + lefts[0].y) as f64 + 1.4)); }
                let free = |q: &(f64, f64)| { let t = (q.0 - 0.3, q.1 - 0.9, q.0 + w + 0.3, q.1 + 0.9); !clear.iter().any(|c| hit(c, &t, 0.0)) };
                let q = spots.iter().find(|q| free(q)).copied().unwrap_or((b.0, b.1 - 1.2 - clear.iter().filter(|c| c.3 <= b.1 + 0.5).map(|c| b.1 - c.1).fold(0.0, f64::max)));
                ((q.0 + wr / 2.0, q.1), (q.0 + wr + 1.0 + wv / 2.0, q.1))
            }
        };
        cv.boxes.push(b);
        cv.boxes.push((ra.0 - wr / 2.0, ra.1 - 0.7, ra.0 + wr / 2.0, ra.1 + 0.7));
        cv.boxes.push((va.0 - wv / 2.0, va.1 - 0.7, va.0 + wv / 2.0, va.1 + 0.7));
        let name = self.reg(s);
        cv.insts.push(Inst { sym: name, x, y, rot: r, part: Some(pi), rail: None, value: p.value.clone(), ref_at: Some(ra), val_at: Some(va) });
    }

    /// A rail symbol at the end of a wire, pointing along `point`.
    fn rail(&mut self, cv: &mut Cv, net: usize, at: (i32, i32), point: (i32, i32)) { self.rail_text(cv, net, at, point, 0) }

    /// The same, with the name set off to one side (`side` -1 or 1) of a symbol that points up or down, clear of a wire beside it.
    fn rail_text(&mut self, cv: &mut Cv, net: usize, at: (i32, i32), point: (i32, i32), side: i32) {
        let n = &{ self.d }.nets[net];
        let s = schsym::rail_sym(&n.name, n.rail == Rail::Ground);
        let (x, y, r) = seat(&s, 0, at, (-point.0, -point.1));
        cv.boxes.push(body_box(&s, x, y, r));
        let shown = !(n.rail == Rail::Ground && n.name == "GND");
        let reach = if n.rail == Rail::Ground { 3.2 } else { 2.0 };
        let w = tw(&n.name);
        let va = (at.0 as f64 + point.0 as f64 * (reach + w / 2.0) + side as f64 * (w / 2.0 + 0.4), at.1 as f64 + point.1 as f64 * (reach + 0.3));
        if shown { cv.boxes.push((va.0 - w / 2.0, va.1 - 0.7, va.0 + w / 2.0, va.1 + 0.7)); }
        let name = self.reg(s);
        cv.insts.push(Inst { sym: name, x, y, rot: r, part: None, rail: Some(net), value: n.name.clone(), ref_at: None, val_at: if shown { Some(va) } else { None } });
        self.count("railSymbols");
    }

    fn unknown(&mut self, cv: &mut Cv, at: (i32, i32), point: (i32, i32), supply: bool) {
        let s = schsym::unknown_sym(point, supply);
        cv.boxes.push(body_box(&s, at.0, at.1, 0));
        let name = self.reg(s);
        cv.insts.push(Inst { sym: name, x: at.0, y: at.1, rot: 0, part: None, rail: None, value: "net not known".into(), ref_at: None, val_at: None });
        self.count("unknownPins");
    }

    // ---- planning ----

    /// Satellites on `net` this chain may take: those attached to the anchor terminal it starts at, or,
    /// away from any anchor, every part still free.
    fn takers(&self, net: usize, root: Option<(usize, usize)>) -> Vec<(usize, String)> {
        let mut v: Vec<(usize, String)> = self.d.nets[net].pins.iter().filter(|(pi, pad)| {
            self.in_block.contains(pi) && !self.claimed.contains(pi) && matches!(self.d.parts[*pi].role, Role::Two | Role::One) && match root {
                Some((a, t)) => self.attach.get(pi).map(|x| x.0 == *pad && x.1 == a && x.2 == t).unwrap_or(false),
                None => self.free || !self.attach.contains_key(pi),
            }
        }).cloned().collect();
        v.sort_by(|a, b| natural(&self.d.parts[a.0].r, &self.d.parts[b.0].r));
        v.dedup_by(|a, b| a.0 == b.0);
        v
    }

    fn anchored(&self, net: usize) -> bool { self.d.nets[net].pins.iter().any(|(pi, _)| self.in_block.contains(pi) && self.d.parts[*pi].role == Role::Anchor) }

    fn plan(&mut self, net: usize, from: Option<(usize, String)>, covered: &[(usize, String)], root: Option<(usize, usize)>, depth: usize, room: i32, taken: &mut Vec<usize>) -> Chain {
        let d = self.d;
        let rail = d.nets[net].rail != Rail::No;
        let mut c = Chain { net: Some(net), from, items: vec![], end: End::None, lead_label: false, lead_rail: false };
        let mut covered: Vec<(usize, String)> = covered.to_vec();
        let cands = self.takers(net, root);
        let mut used = if rail { 4 } else { lab_len(&d.nets[net].name) + 2 };
        let mut inline: Option<Item> = None;
        let n_cands = cands.len();
        for (pi, pad) in cands {
            let p = &d.parts[pi];
            let cost = 4 + tw(&p.r).max(tw(&p.value)).ceil() as i32;
            if used + cost > room || depth > 6 { break; }
            let s = p.sym.as_ref().unwrap();
            let me = s.term(&pad).unwrap();
            if p.role == Role::One { self.claimed.insert(pi); taken.push(pi); covered.push((pi, pad.clone())); c.items.push(Item::Tap { part: pi, pad }); used += cost; continue; }
            let far = s.terms[1 - me].nums[0].clone();
            let m = d.net_of(pi, &far);
            self.claimed.insert(pi); taken.push(pi); covered.push((pi, pad.clone())); used += cost;
            match m {
                Some(m) if m == net => { covered.push((pi, far.clone())); c.items.push(Item::Shunt { part: pi, near: pad, far, to: if rail { Far::Rail(net) } else { Far::Label(net) } }); }
                Some(m) if d.nets[m].rail != Rail::No => {
                    // a lone part to a rail at the end of a series run stays in line; otherwise it hangs
                    if inline.is_none() && n_cands == 1 && depth > 0 && !rail { inline = Some(Item::Series { part: pi, near: pad, far: far.clone(), next: Box::new(Chain { net: Some(m), from: Some((pi, far)), items: vec![], end: End::Rail(m), lead_label: false, lead_rail: false }) }); }
                    else { c.items.push(Item::Shunt { part: pi, near: pad, far, to: Far::Rail(m) }); }
                }
                Some(m) => {
                    if inline.is_none() && !rail {
                        let next = if self.free || !self.anchored(m) { self.plan(m, Some((pi, far.clone())), &[(pi, far.clone())], None, depth + 1, room - used - 8, taken) }
                            else { Chain { net: Some(m), from: Some((pi, far.clone())), items: vec![], end: End::Label(m), lead_label: false, lead_rail: false } };
                        inline = Some(Item::Series { part: pi, near: pad, far, next: Box::new(next) });
                        used += 8;
                    } else { c.items.push(Item::Shunt { part: pi, near: pad, far, to: Far::Label(m) }); used += lab_len(&d.nets[m].name); }
                }
                None => c.items.push(Item::Shunt { part: pi, near: pad, far, to: Far::Unknown }),
            }
        }
        let others = d.nets[net].pins.iter().any(|p| !covered.contains(p));
        let named = d.nets[net].named;
        if rail { if c.from.is_some() { c.end = End::Rail(net); } else { c.lead_rail = true; } }
        else if inline.is_some() || c.from.is_none() { c.lead_label = others || named; }
        else if others { c.end = End::Label(net); }
        else if c.items.is_empty() { c.end = End::Solo(net); }
        else { c.lead_label = named; }
        if let Some(i) = inline { c.items.push(i); }
        c
    }

    // ---- drawing a strip ----

    fn draw_chain(&mut self, cv: &mut Cv, c: &Chain, x0: i32, y: i32, dir: i32, lead: i32) {
        let d = self.d;
        let root_seen = match &c.from { Some((pi, pad)) => self.seen(*pi, pad), None => c.net.map(|n| d.nets[n].measured).unwrap_or(false) };
        let mut cur = x0;
        // a run that starts nowhere (no pin, label or rail symbol) has no wire before its first part
        let mut started = c.from.is_some() || c.lead_label || c.lead_rail;
        let mut edge = x0 as f64 + (dir * lead) as f64;   // the strip is taken up to here
        let far_of = |a: f64, b: f64| if dir > 0 { a.max(b) } else { a.min(b) };
        if c.lead_label { let n = c.net.unwrap(); let len = lab_len(&d.nets[n].name); let off = if c.from.is_some() { dir * lead.max(1) } else { 0 }; cv.label(d, n, x0 + off, y, (dir, 0)); edge = far_of(edge, (x0 + off + dir * len) as f64); self.count("labels"); }
        if c.lead_rail { let n = c.net.unwrap(); self.rail(cv, n, (x0, y), (0, if d.nets[n].rail == Rail::Ground { 1 } else { -1 })); edge = far_of(edge, (x0 + dir * 3) as f64); }
        // the next junction on the trunk, clear of what is already drawn; `l` and `r` are the element's reach left and right
        let next_x = |edge: f64, cur: i32, l: f64, r: f64| -> i32 {
            let x = if dir > 0 { (edge + 1.0 + l).ceil() as i32 } else { (edge - 1.0 - r).floor() as i32 };
            let x = if dir > 0 { x.max(cur + 2) } else { x.min(cur - 2) };
            if x.rem_euclid(2) != 0 { x + dir } else { x }
        };
        // hanging parts in reference order as the sheet reads, left to right; the part in line comes last
        let mut order: Vec<&Item> = c.items.iter().filter(|i| !matches!(i, Item::Series { .. })).collect();
        if dir < 0 { order.reverse(); }
        order.extend(c.items.iter().filter(|i| matches!(i, Item::Series { .. })));
        for it in order {
            match it {
                Item::Shunt { part, near, far, to } => {
                    let v = if matches!(to, Far::Rail(n) if d.nets[*n].rail == Rail::Supply) { -1 } else { 1 };
                    let s = self.sym_of(*part);
                    let (tn, tf) = (s.term(near).unwrap(), s.term(far).unwrap());
                    let half = s.bb.0.abs().max(s.bb.2.abs()).max(1.0);
                    let wt = tw(&d.parts[*part].r).max(tw(&d.parts[*part].value));
                    let (mut l, mut r) = (half, half + 0.8 + wt);
                    if let Far::Label(m) = to { let len = lab_len(&d.nets[*m].name) as f64; if dir > 0 { r = r.max(len) } else { l = l.max(len) } }
                    let jx = next_x(edge, cur, l, r);
                    if started { cv.wire((cur, y), (jx, y), root_seen); }
                    started = true;
                    let a = (jx, y + 2 * v);
                    let (sx, sy, rr) = seat(&s, tn, a, (0, -v));
                    let b = term_at(&s, tf, sx, sy, rr);
                    cv.wire((jx, y), a, self.seen(*part, near));
                    self.put(cv, *part, sx, sy, rr, TextAt::Right, &[]);
                    let e = (b.0, b.1 + 2 * v);
                    let fs = self.seen(*part, far);
                    cv.wire(b, e, fs);
                    match to {
                        Far::Rail(n) => self.rail(cv, *n, e, (0, v)),
                        Far::Unknown => self.unknown(cv, e, (0, v), false),
                        Far::Label(m) => { let f = (e.0 + dir * lab_len(&d.nets[*m].name), e.1); cv.wire(e, f, fs); cv.label(d, *m, f.0, f.1, (-dir, 0)); self.count("labels"); }
                    }
                    edge = (jx as f64) + if dir > 0 { r } else { -l };
                    cur = jx;
                }
                Item::Tap { part, pad } => {
                    let s = self.sym_of(*part);
                    let half = s.bb.0.abs().max(s.bb.2.abs()).max(1.0);
                    let wt = tw(&d.parts[*part].r).max(tw(&d.parts[*part].value));
                    let jx = next_x(edge, cur, half, half + 0.8 + wt);
                    if started { cv.wire((cur, y), (jx, y), root_seen); }
                    started = true;
                    let a = (jx, y - 2);
                    let (sx, sy, rr) = seat(&s, 0, a, (0, 1));
                    cv.wire((jx, y), a, self.seen(*part, pad));
                    self.put(cv, *part, sx, sy, rr, TextAt::Right, &[]);
                    edge = (jx as f64) + if dir > 0 { half + 0.8 + wt } else { -half };
                    cur = jx;
                }
                Item::Series { part, near, far, next } => {
                    let s = self.sym_of(*part);
                    let (tn, tf) = (s.term(near).unwrap(), s.term(far).unwrap());
                    let len = (s.terms[tn].x - s.terms[tf].x).abs() + (s.terms[tn].y - s.terms[tf].y).abs();
                    let over = ((tw(&d.parts[*part].r) + 1.0 + tw(&d.parts[*part].value) - len as f64) / 2.0).max(0.0);
                    let nx = next_x(edge, cur, over, over);
                    let knee = nx - 2 * dir;
                    if !started {} else if (knee - cur) * dir > 0 { cv.wire((cur, y), (knee, y), root_seen); cv.wire((knee, y), (nx, y), self.seen(*part, near)); } else { cv.wire((cur, y), (nx, y), self.seen(*part, near)); }
                    let (sx, sy, rr) = seat(&s, tn, (nx, y), (-dir, 0));
                    let b = term_at(&s, tf, sx, sy, rr);
                    self.put(cv, *part, sx, sy, rr, TextAt::Above, &[]);
                    self.draw_chain(cv, next, b.0, y, dir, over.ceil() as i32 + 1);
                    return;
                }
            }
        }
        match &c.end {
            End::None => {}
            End::Label(n) => { let f = cur + dir * lab_len(&d.nets[*n].name).max(((edge - cur as f64) * dir as f64).ceil() as i32); let f = if f.rem_euclid(2) != 0 { f + dir } else { f }; cv.wire((cur, y), (f, y), root_seen); cv.label(d, *n, f, y, (-dir, 0)); self.count("labels"); }
            End::Solo(n) => { let f = cur + dir * lab_len(&d.nets[*n].name); cv.wire((cur, y), (f, y), root_seen); cv.label(d, *n, f, y, (-dir, 0)); self.unknown(cv, (f, y), (dir, 0), false); self.count("labels"); }
            End::Rail(n) => { let f = cur + dir * if c.items.is_empty() { (lead + lead % 2).max(2) } else { 2 }; cv.wire((cur, y), (f, y), root_seen); self.rail(cv, *n, (f, y), (dir, 0)); }
            End::Unknown(supply) => { let f = cur + dir * if c.items.is_empty() { (lead + lead % 2).max(2) } else { 2 }; cv.wire((cur, y), (f, y), false); self.unknown(cv, (f, y), (dir, 0), *supply); }
            End::Nc => { cv.ncs.push((cur, y)); cv.boxes.push((cur as f64 - 0.6, y as f64 - 0.6, cur as f64 + 0.6, y as f64 + 0.6)); self.count("noConnects"); }
        }
    }

    /// What one terminal of an anchor starts: nothing known, a no-connect, or a strip on its net.
    fn term_chain(&mut self, pi: usize, ti: usize, t: &Term, items: bool, taken: &mut Vec<usize>) -> Chain {
        let from = Some((pi, t.nums[0].clone()));
        let bare = |end: End| Chain { net: None, from: from.clone(), items: vec![], end, lead_label: false, lead_rail: false };
        if t.nums.iter().any(|n| self.d.nc.contains(&(pi, n.clone()))) { return bare(End::Nc); }
        match self.d.net_of(pi, &t.nums[0]) {
            None => bare(End::Unknown(t.etype == "power_in")),
            Some(n) => {
                let covered: Vec<(usize, String)> = t.nums.iter().map(|k| (pi, k.clone())).collect();
                if items { self.plan(n, from, &covered, Some((pi, ti)), 0, PIN_STRIP, taken) }
                else {
                    let others = self.d.nets[n].pins.iter().any(|p| !covered.contains(p));
                    let end = if self.d.nets[n].rail != Rail::No { End::Rail(n) } else if others { End::Label(n) } else { End::Solo(n) };
                    Chain { net: Some(n), from, items: vec![], end, lead_label: false, lead_rail: false }
                }
            }
        }
    }

    /// Transistors in a column. Where one's lower lead and the next one's upper lead are the same net and no
    /// other transistor of the block is on it that way, the two are drawn one above the other and wired, and
    /// the net's own parts (a diode, a test point, a snubber) run right from the node between them: a
    /// half-bridge reads as one, not as two transistors with the phase net named on each.
    fn stacks(&mut self, block: &[usize]) -> Vec<Cv> {
        let d = self.d;
        let lead_of = |pi: usize, dir: (i32, i32)| -> Option<Term> { d.parts[pi].sym.as_ref()?.terms.iter().find(|t| t.dir == dir).cloned() };
        let vert: Vec<usize> = block.iter().copied().filter(|&i| d.parts[i].role == Role::Anchor && d.parts[i].sym.as_ref().map(|s| s.terms.iter().filter(|t| t.dir == (0, -1)).count() == 1 && s.terms.iter().filter(|t| t.dir == (0, 1)).count() == 1 && !s.terms.iter().any(|t| t.dir == (1, 0))).unwrap_or(false)).collect();
        let end_net = |pi: usize, dir: (i32, i32)| -> Option<usize> { let n = d.net_of(pi, &lead_of(pi, dir)?.nums[0])?; (d.nets[n].rail == Rail::No).then_some(n) };
        let mut next: HashMap<usize, usize> = HashMap::new();
        let mut under: HashSet<usize> = HashSet::new();
        for &x in &vert {
            let Some(n) = end_net(x, (0, 1)) else { continue };
            let below: Vec<usize> = vert.iter().copied().filter(|&y| y != x && end_net(y, (0, -1)) == Some(n)).collect();
            let above = vert.iter().filter(|&&z| end_net(z, (0, 1)) == Some(n)).count();
            if below.len() == 1 && above == 1 && !under.contains(&below[0]) && next.get(&below[0]) != Some(&x) { next.insert(x, below[0]); under.insert(below[0]); }
        }
        let mut out = vec![];
        for &head in vert.iter().filter(|x| next.contains_key(x) && !under.contains(x)) {
            let mut seq = vec![head];
            while let Some(&y) = next.get(seq.last().unwrap()) { if seq.contains(&y) { break; } seq.push(y); }
            for &q in &seq { self.claimed.insert(q); }
            let mut cv = self.cluster(seq[0], false, true);
            let low = lead_of(seq[0], (0, 1)).unwrap();
            let mut tip = (low.x, low.y);
            for k in 1..seq.len() {
                let (prev, this) = (seq[k - 1], seq[k]);
                let (pl, tl) = (lead_of(prev, (0, 1)).unwrap(), lead_of(this, (0, -1)).unwrap());
                let n = end_net(prev, (0, 1)).unwrap();
                let covered: Vec<(usize, String)> = pl.nums.iter().map(|x| (prev, x.clone())).chain(tl.nums.iter().map(|x| (this, x.clone()))).collect();
                let mut taken = vec![];
                let mut chain = self.plan(n, Some((prev, pl.nums[0].clone())), &covered, None, 0, PIN_STRIP, &mut taken);
                if chain.end == End::Solo(n) { chain.end = if d.nets[n].named { End::Label(n) } else { End::None }; }
                // the node sits below whatever already hangs beside the upper transistor
                let mut run = Cv::default();
                self.draw_chain(&mut run, &chain, 0, 0, 1, 3);
                let mut jy = tip.1 + 2;
                while cv.clashes(&run, tip.0, jy, 0.6) { jy += 2; }
                let j = (tip.0, jy);
                cv.wire(tip, j, self.seen(prev, &pl.nums[0]));
                cv.merge(&run, j.0, j.1);
                let sub = self.cluster(this, true, k + 1 < seq.len());
                let dx = tip.0 - tl.x;
                let mut dy = j.1 + 2 - tl.y; dy += dy.rem_euclid(2);
                while cv.clashes(&sub, dx, dy, 0.6) { dy += 2; }
                cv.merge(&sub, dx, dy);
                cv.wire(j, (tl.x + dx, tl.y + dy), self.seen(this, &tl.nums[0]));
                let bl = lead_of(this, (0, 1)).unwrap();
                tip = (bl.x + dx, bl.y + dy);
            }
            self.count("transistorStacks");
            out.push(cv);
        }
        out
    }

    /// An IC, connector or other many-pin part with a strip at every pin. `skip_top`, `skip_bottom`: leave
    /// that lead bare, for the caller to wire (a transistor in a column).
    fn cluster(&mut self, pi: usize, skip_top: bool, skip_bottom: bool) -> Cv {
        let d = self.d;
        let fixed = d.parts[pi].sym.is_some();
        // a box's pins get their rows here; until then a terminal carries its side and its group
        let terms: Vec<Term> = match &d.parts[pi].sym {
            Some(s) => s.terms.clone(),
            None => { let b = d.parts[pi].boxed.as_ref().unwrap(); b.left.iter().map(|p| (p, -1)).chain(b.right.iter().map(|p| (p, 1))).map(|(p, side)| Term { nums: vec![p.num.clone()], name: p.name.clone(), etype: p.etype.clone(), x: side, y: p.y, dir: (side, 0) }).collect() }
        };
        let mut cv = Cv::default();
        let mut rows: Vec<i32> = vec![0; terms.len()];
        let mut strips: Vec<Option<Cv>> = vec![None; terms.len()];
        for side in [-1, 1] {
            let mut list: Vec<usize> = (0..terms.len()).filter(|&i| terms[i].dir == (side, 0)).collect();
            if fixed { list.sort_by_key(|&i| terms[i].y); }
            if fixed {
                // Pins at the symbol's own pitch. Every pin's plain stub (its label, rail symbol or marker) fits
                // the pitch. A strip with parts on it must clear the stubs of the pins around it and the strips
                // already drawn, so it starts further out; when that takes it too far the pin keeps its label
                // and the parts are drawn beside the symbol from the same label.
                let keep = self.stats.clone();
                let reach = |c: &Cv| { let b = c.bounds(); if side > 0 { b.2 } else { -b.0 } };
                let mut stubs: Vec<(usize, Cv)> = list.iter().map(|&ti| { let mut t = vec![]; let c = self.term_chain(pi, ti, &terms[ti], false, &mut t); let mut sc = Cv::default(); self.draw_chain(&mut sc, &c, 0, 0, side, 0); (ti, sc) }).collect();
                // a rail symbol or marker beside a labelled pin sits out past that label, so its text is clear of it
                let mut leads: HashMap<usize, i32> = HashMap::new();
                for k in 0..stubs.len() {
                    let (ti, sc) = (stubs[k].0, &stubs[k].1);
                    if !sc.labels.is_empty() { continue; }
                    let near = stubs.iter().filter(|(tj, o)| *tj != ti && (terms[*tj].y - terms[ti].y).abs() <= 2 && !o.labels.is_empty()).map(|(_, o)| reach(o)).fold(0.0, f64::max);
                    if near > 0.0 { leads.insert(ti, near.ceil() as i32 + 1); }
                }
                for (ti, sc) in stubs.iter_mut() { if let Some(l) = leads.get(ti) { let mut t = vec![]; let c = self.term_chain(pi, *ti, &terms[*ti], false, &mut t); *sc = Cv::default(); self.draw_chain(sc, &c, 0, 0, side, *l); } }
                self.stats = keep;
                let mut thick: Vec<(i32, Cv)> = vec![];
                for &ti in &list {
                    let y = terms[ti].y;
                    let mut taken = vec![];
                    let chain = self.term_chain(pi, ti, &terms[ti], true, &mut taken);
                    let mut scv = Cv::default();
                    let keep = self.stats.clone();
                    let (mut lead, mut fits) = (leads.get(&ti).copied().unwrap_or(0), chain.items.is_empty());
                    for _ in 0..5 {
                        scv = Cv::default(); self.stats = keep.clone();
                        self.draw_chain(&mut scv, &chain, 0, 0, side, lead);
                        if fits { break; }
                        let hits: Vec<f64> = stubs.iter().filter(|(tj, sc)| *tj != ti && sc.clashes(&scv, 0, y - terms[*tj].y, 0.15)).map(|(_, sc)| reach(sc))
                            .chain(thick.iter().filter(|(py, pcv)| pcv.clashes(&scv, 0, y - py, 0.6)).map(|(_, pcv)| reach(pcv))).collect();
                        if hits.is_empty() { fits = true; break; }
                        lead = hits.iter().cloned().fold(0.0, f64::max).ceil() as i32 + 2;
                        if lead > 64 { break; }
                    }
                    if !fits {
                        self.stats = keep;
                        for t in taken.drain(..) { self.claimed.remove(&t); }
                        let c = self.term_chain(pi, ti, &terms[ti], false, &mut taken);
                        scv = Cv::default(); self.draw_chain(&mut scv, &c, 0, 0, side, leads.get(&ti).copied().unwrap_or(0));
                        self.count("stripsMovedToLabels");
                    } else if !chain.items.is_empty() { thick.push((y, scv.clone())); }
                    rows[ti] = y; strips[ti] = Some(scv);
                }
                continue;
            }
            let mut placed: Vec<(i32, bool, Cv)> = vec![];
            let (mut y_next, mut group) = (0, list.first().map(|&i| terms[i].y).unwrap_or(0));
            for ti in list {
                // a box: the rows open up until each pin's strip has the room it needs
                let mut taken = vec![];
                let chain = self.term_chain(pi, ti, &terms[ti], true, &mut taken);
                let mut scv = Cv::default();
                self.draw_chain(&mut scv, &chain, 0, 0, side, 0);
                if terms[ti].y != group { group = terms[ti].y; y_next += 2; }
                let mut y = y_next;
                let thin = chain.items.is_empty();
                while placed.iter().any(|(py, thin2, pcv)| pcv.clashes(&scv, 0, y - py, if thin && *thin2 { 0.05 } else { 0.6 })) { y += 2; }
                rows[ti] = y; y_next = y + 2;
                placed.push((y, thin, scv.clone()));
                strips[ti] = Some(scv);
            }
        }
        let s = if fixed { d.parts[pi].sym.clone().unwrap() } else {
            let b = d.parts[pi].boxed.as_ref().unwrap();
            let at = |num: &str| rows[terms.iter().position(|t| t.nums[0] == num).unwrap()];
            let rowed = |v: &[BoxPin]| -> Vec<BoxPin> { v.iter().map(|p| BoxPin { y: at(&p.num), ..p.clone() }).collect() };
            let s = schsym::box_sym(&safe(&d.parts[pi].r), &d.parts[pi].r.chars().take_while(|c| c.is_ascii_alphabetic()).collect::<String>(), &rowed(&b.left), &rowed(&b.right), 0);
            self.box_syms.insert(pi, s.clone());
            s
        };
        for (ti, sc) in strips.iter().enumerate() { if let Some(sc) = sc { let t = &s.terms[s.term(&terms[ti].nums[0]).unwrap()]; cv.merge(sc, t.x, t.y); } }
        // supply pins on the top edge with decoupling: one rail wire above the symbol per supply, its capacitors
        // in a row on it clear of the other top pins, wired down to every pin of that supply
        let tops: Vec<usize> = (0..terms.len()).filter(|&i| terms[i].dir == (0, -1)).collect();
        let mut railed: HashSet<usize> = HashSet::new();
        let mut level = 0;
        for &ti in &tops {
            let Some(n) = d.net_of(pi, &terms[ti].nums[0]) else { continue };
            if railed.contains(&ti) || !self.attach.values().any(|a| a.1 == pi && a.2 == ti) { continue; }
            let mut taken = vec![];
            let chain = self.term_chain(pi, ti, &terms[ti], true, &mut taken);
            if chain.items.is_empty() { continue; }
            let mut group: Vec<usize> = tops.iter().copied().filter(|&k| d.net_of(pi, &terms[k].nums[0]) == Some(n) && terms[k].y == terms[ti].y).collect();
            group.sort_by_key(|&k| terms[k].x);
            let yk = terms[ti].y - 14 - 16 * level; level += 1;
            for &k in &group { let sk = self.seen(pi, &terms[k].nums[0]); cv.wire((terms[k].x, terms[k].y), (terms[k].x, yk), sk); railed.insert(k); }
            let st = self.seen(pi, &terms[ti].nums[0]);
            for w in group.windows(2) { cv.wire((terms[w[0]].x, yk), (terms[w[1]].x, yk), st); }
            let mean = group.iter().map(|&k| terms[k].x as f64).sum::<f64>() / group.len() as f64;
            let dir = if mean <= (s.bb.0 + s.bb.2) / 2.0 { -1 } else { 1 };
            let x0 = if dir < 0 { terms[group[0]].x } else { terms[*group.last().unwrap()].x };
            let far = tops.iter().map(|&k| terms[k].x * dir).max().unwrap_or(0);
            self.draw_chain(&mut cv, &chain, x0, yk, dir, far - x0 * dir + 2);
            self.count("supplyRailsOverIc");
        }
        // other pins on the top and bottom edge of a library symbol: a rail symbol, a label up the wire, or the marker
        let no_right = !terms.iter().any(|t| t.dir == (1, 0));
        for (ti, t) in terms.iter().enumerate().filter(|(i, t)| t.dir.0 == 0 && !railed.contains(i) && !(skip_top && t.dir == (0, -1)) && !(skip_bottom && t.dir == (0, 1))) {
            let mut taken = vec![];
            let chain = self.term_chain(pi, ti, t, false, &mut taken);
            let (p, v) = ((t.x, t.y), t.dir.1);
            let seen = self.seen(pi, &t.nums[0]);
            // a transistor's upper or lower lead with parts of its own on the net (a sense resistor): they run right from a short stub
            if let (End::Label(n) | End::Solo(n), true) = (&chain.end, no_right) {
                let covered: Vec<(usize, String)> = t.nums.iter().map(|k| (pi, k.clone())).collect();
                let run = self.plan(*n, chain.from.clone(), &covered, None, 0, PIN_STRIP, &mut taken);
                if !run.items.is_empty() { let j = (p.0, p.1 + 2 * v); cv.wire(p, j, seen); self.draw_chain(&mut cv, &run, j.0, j.1, 1, 3); continue; }
                for x in taken.drain(..) { self.claimed.remove(&x); }
            }
            match chain.end {
                End::Nc => { cv.ncs.push(p); self.count("noConnects"); }
                End::Rail(n) => {
                    // beside another pin of this edge the name goes to the open side
                    let near = |dx: i32| terms.iter().any(|o| o.dir == t.dir && o.y == t.y && (o.x - t.x) * dx > 0 && (o.x - t.x).abs() <= 4);
                    let side = match (near(-1), near(1)) { (true, false) => 1, (false, true) => -1, _ => 0 };
                    let e = (p.0, p.1 + 2 * v); cv.wire(p, e, seen); self.rail_text(&mut cv, n, e, (0, v), side);
                }
                End::Unknown(sup) => { let e = (p.0, p.1 + 2 * v); cv.wire(p, e, false); self.unknown(&mut cv, e, (0, v), sup); }
                End::Label(n) | End::Solo(n) => {
                    let e = (p.0, p.1 + v * lab_len(&d.nets[n].name)); cv.wire(p, e, seen); cv.label(d, n, e.0, e.1, (0, -v)); self.count("labels");
                    if chain.end == End::Solo(n) { self.unknown(&mut cv, e, (0, v), false); }
                }
                End::None => {}
            }
        }
        let mut clear = cv.boxes.clone();
        // a lead left for the caller to wire still keeps the text off its line
        for t in terms.iter().filter(|t| (skip_top && t.dir == (0, -1)) || (skip_bottom && t.dir == (0, 1))) { clear.push((t.x as f64 - 0.5, t.y as f64 + if t.dir.1 < 0 { -40.0 } else { 0.0 }, t.x as f64 + 0.5, t.y as f64 + if t.dir.1 < 0 { 0.0 } else { 40.0 })); }
        self.put(&mut cv, pi, 0, 0, 0, TextAt::Ic, &clear);
        cv
    }

    /// Parts no pin strip took: runs that start at a label or a rail, and single parts with what is known at each end.
    fn leftovers(&mut self, block: &[usize]) -> Vec<Cv> {
        let d = self.d;
        self.free = true;
        let mut out = vec![];
        let mut order: Vec<usize> = block.iter().copied().filter(|i| matches!(d.parts[*i].role, Role::Two | Role::One)).collect();
        order.sort_by(|a, b| natural(&d.parts[*a].r, &d.parts[*b].r));
        // runs on signal nets first, then rows on rails
        for want_rail in [false, true] {
            for &pi in &order {
                if self.claimed.contains(&pi) { continue; }
                let s = d.parts[pi].sym.as_ref().unwrap();
                let nets: Vec<usize> = s.terms.iter().filter_map(|t| d.net_of(pi, &t.nums[0])).collect();
                let pick = if !want_rail { nets.iter().copied().filter(|n| d.nets[*n].rail == Rail::No).max_by_key(|n| (self.takers(*n, None).len(), std::cmp::Reverse(*n))) }
                    // a part between rails goes on the supply's row (between two grounds, on the first)
                    else if nets.len() == s.terms.len() && nets.iter().all(|n| d.nets[*n].rail != Rail::No) { nets.iter().copied().find(|n| d.nets[*n].rail == Rail::Supply).or(nets.first().copied()) }
                    else { None };
                let Some(n) = pick else { continue };
                let mut taken = vec![];
                let chain = self.plan(n, None, &[], None, 0, FREE_STRIP, &mut taken);
                if chain.items.is_empty() { continue; }
                let mut cv = Cv::default();
                self.draw_chain(&mut cv, &chain, 0, 0, 1, 0);
                self.count(if want_rail { "railRows" } else { "labelStrips" });
                out.push(cv);
            }
        }
        // single parts: each end shows its rail, or the marker
        let mut cells: Vec<Cv> = vec![];
        let mut zero: Vec<usize> = block.iter().copied().filter(|i| d.parts[*i].role == Role::Zero).collect();
        zero.sort_by(|a, b| natural(&d.parts[*a].r, &d.parts[*b].r));
        for &pi in order.iter().chain(zero.iter()) {
            if self.claimed.contains(&pi) { continue; }
            self.claimed.insert(pi);
            if d.parts[pi].role == Role::Zero && d.parts[pi].sym.is_none() {
                // a part whose pads the photos do not show: a box with no pins
                let s = schsym::box_sym(&safe(&d.parts[pi].r), &d.parts[pi].r.chars().take_while(|c| c.is_ascii_alphabetic()).collect::<String>(), &[], &[], tw(&d.parts[pi].value).ceil() as i32 + 4);
                self.box_syms.insert(pi, s);
            }
            let s = self.sym_of(pi);
            let mut cv = Cv::default();
            // stand a two-pin part upright with any ground at the bottom
            let top = if s.terms.len() == 2 { let g = |t: &Term| d.net_of(pi, &t.nums[0]).map(|n| d.nets[n].rail == Rail::Ground).unwrap_or(false); if g(&s.terms[0]) && !g(&s.terms[1]) { 1 } else { 0 } } else { 0 };
            let (sx, sy, r) = if s.terms.is_empty() { (0, 0, 0) } else { seat(&s, top, (0, 0), if s.terms.len() == 1 { (0, 1) } else { (0, -1) }) };
            for (ti, t) in s.terms.iter().enumerate() {
                let p = term_at(&s, ti, sx, sy, r); let v = rot(t.dir, r);
                let e = (p.0 + 2 * v.0, p.1 + 2 * v.1);
                match d.net_of(pi, &t.nums[0]) {
                    Some(n) if d.nets[n].rail != Rail::No => { cv.wire(p, e, self.seen(pi, &t.nums[0])); self.rail(&mut cv, n, e, v); }
                    Some(n) => { let f = (p.0 + v.0 * lab_len(&d.nets[n].name), p.1 + v.1 * lab_len(&d.nets[n].name)); cv.wire(p, f, self.seen(pi, &t.nums[0])); cv.label(d, n, f.0, f.1, (-v.0, -v.1)); self.count("labels"); }
                    None => { cv.wire(p, e, false); self.unknown(&mut cv, e, v, false); }
                }
            }
            self.put(&mut cv, pi, sx, sy, r, if s.terms.is_empty() { TextAt::Above } else { TextAt::Right }, &[]);
            if d.parts[pi].role == Role::Zero && d.parts[pi].kind != Kind::Hole && d.parts[pi].kind != Kind::Fid { let b = cv.bounds(); cv.text("no pads read from the photos", (b.0 + b.2) / 2.0, b.3 + 1.2, 1.016, false, 0); }
            self.count("singleParts");
            cells.push(cv);
        }
        // the single parts in rows
        let mut row = Cv::default(); let mut x = 0;
        for c in cells {
            let b = c.bounds(); let w = (b.2 - b.0).ceil() as i32 + 3; let w = w + w % 2;
            if x + w > FREE_STRIP && x > 0 { out.push(std::mem::take(&mut row)); x = 0; }
            let dx = x - b.0.floor() as i32; row.merge(&c, dx + dx.rem_euclid(2), 0); x += w + 2;
        }
        if !row.boxes.is_empty() { out.push(row); }
        self.free = false;
        out
    }

    /// Every satellite goes to one anchor pin: on a signal net before a rail, an IC before a connector,
    /// then the nearest on the board. Decoupling goes to the supply pin it sits beside.
    fn attach_block(&mut self, block: &[usize]) {
        let d = self.d;
        self.attach.clear();
        self.in_block = block.iter().copied().collect();
        let mut anchors: HashMap<usize, Vec<(usize, usize, String)>> = HashMap::new();   // net to (part, terminal, pad)
        for &pi in block.iter().filter(|i| d.parts[**i].role == Role::Anchor) {
            let terms: Vec<Vec<String>> = match &d.parts[pi].sym { Some(s) => s.terms.iter().map(|t| t.nums.clone()).collect(), None => { let b = d.parts[pi].boxed.as_ref().unwrap(); b.left.iter().chain(b.right.iter()).map(|p| vec![p.num.clone()]).collect() } };
            let dirs: Vec<(i32, i32)> = match &d.parts[pi].sym { Some(s) => s.terms.iter().map(|t| t.dir).collect(), None => vec![(1, 0); terms.len()] };
            let mut top_rails: HashSet<usize> = HashSet::new();
            for (ti, nums) in terms.iter().enumerate() {
                let Some(n) = d.net_of(pi, &nums[0]) else { continue };
                // a side pin starts a strip; the first top pin of each supply takes that supply's decoupling
                if dirs[ti].0 != 0 || (dirs[ti] == (0, -1) && d.nets[n].rail == Rail::Supply && top_rails.insert(n)) { anchors.entry(n).or_default().push((pi, ti, nums[0].clone())); }
            }
        }
        for &pi in block.iter().filter(|i| matches!(d.parts[**i].role, Role::Two | Role::One)) {
            let s = d.parts[pi].sym.as_ref().unwrap();
            let mut best: Option<((i32, i32, i64), String, usize, usize)> = None;
            // a part with a signal on one pin belongs to that signal's run (resistor then LED then rail), never to the rail on its other pin
            let on_signal = s.terms.iter().any(|t| d.net_of(pi, &t.nums[0]).map(|n| d.nets[n].rail == Rail::No).unwrap_or(false));
            for t in &s.terms {
                let pad = &t.nums[0];
                let Some(n) = d.net_of(pi, pad) else { continue };
                let rank = match d.nets[n].rail { Rail::No => 0, Rail::Supply => 1, Rail::Ground => 2 };
                // ground alone never places a part: a capacitor to ground belongs to the net on its other pin
                if (rank == 2 && s.terms.len() == 2) || (rank > 0 && on_signal) { continue; }
                let here = d.pos_of(pi, pad);
                for (a, ti, apad) in anchors.get(&n).into_iter().flatten() {
                    let q = d.pos_of(*a, apad);
                    let dist = (((here.0 - q.0).powi(2) + (here.1 - q.1).powi(2)).sqrt() * 100.0) as i64;
                    let key = (rank, if d.parts[*a].kind == Kind::Conn { 1 } else { 0 }, dist);
                    if best.as_ref().map(|b| key < b.0).unwrap_or(true) { best = Some((key, pad.clone(), *a, *ti)); }
                }
            }
            if let Some((_, pad, a, ti)) = best { self.attach.insert(pi, (pad, a, ti)); }
        }
    }
}

// ---------------------------------------------------------------------------------------------
// Sheets
// ---------------------------------------------------------------------------------------------

struct Sheet { file: String, title: String, paper: &'static str, cv: Cv, block: usize }

/// Paper sizes in steps: (name, width, height, right edge of the block frame, bottom edge above the title block).
const PAPERS: [(&str, f64, f64, i32, i32); 2] = [("A4", 297.0, 210.0, 224, 128), ("A3", 420.0, 297.0, 320, 196)];
const FRAME: i32 = 10;

/// Each group goes to the highest free spot that takes it, leftmost first (a skyline): a tall cluster
/// does not leave the room beside it empty. A group that cannot go on this sheet starts the next.
fn pack(groups: &[Cv], w: i32, h: i32) -> (Vec<Vec<(usize, i32, i32)>>, bool) {
    let mut sheets: Vec<Vec<(usize, i32, i32)>> = vec![vec![]];
    let mut sky = vec![0i32; w.max(1) as usize];
    let mut fits = true;
    for (k, g) in groups.iter().enumerate() {
        let b = g.bounds();
        let (gw, gh) = (((b.2 - b.0).ceil() as i32 + 6).min(w), (b.3 - b.1).ceil() as i32 + 4);
        if (b.2 - b.0).ceil() as i32 + 2 > w || gh - 2 > h { fits = false; }
        let spot = |sky: &[i32]| -> Option<(i32, i32)> {
            (0..=(w - gw)).step_by(2).map(|x| (x, sky[x as usize..(x + gw) as usize].iter().copied().max().unwrap_or(0))).filter(|(_, y)| y + gh - 2 <= h).min_by_key(|(x, y)| (*y, *x))
        };
        let (x, y) = match spot(&sky) { Some(p) => p, None => { if !sheets.last().unwrap().is_empty() { sheets.push(vec![]); sky = vec![0; w.max(1) as usize]; } spot(&sky).unwrap_or((0, 0)) } };
        for c in &mut sky[x as usize..(x + gw) as usize] { *c = y + gh; }
        sheets.last_mut().unwrap().push((k, x, y));
    }
    (sheets, fits)
}

fn place(groups: &[Cv], spots: &[(usize, i32, i32)], ox: i32, oy: i32) -> Cv {
    let mut cv = Cv::default();
    for (k, x, y) in spots {
        let b = groups[*k].bounds();
        let (dx, dy) = (ox + x - b.0.floor() as i32, oy + y - b.1.floor() as i32);
        cv.merge(&groups[*k], dx + dx.rem_euclid(2), dy + dy.rem_euclid(2));
    }
    cv
}

pub struct Project { pub files: Vec<(String, String)>, pub notes: Vec<String>, pub stats: Value, /** where the drawn sheets and nets.json disagree (none, or the generator has a fault) */ pub differences: Vec<String> }

/// The connections the drawn sheets hold, worked out from their geometry alone (wire ends, pins, labels by
/// name, rail symbols by rail), against nets.json: every net's pins joined to each other and to nothing
/// else, every pin of unknown net joined to no other pin. KiCad's own netlist is the gate (`check
/// schematic`); this runs at every build, with or without KiCad, so a drawing fault never passes quietly.
fn differences(d: &Design, sheets: &[Sheet], syms: &BTreeMap<String, Sym>, box_syms: &HashMap<usize, Sym>, hier: &HashMap<usize, BTreeSet<usize>>) -> Vec<String> {
    let mut parent: Vec<usize> = vec![];
    fn find(p: &mut Vec<usize>, mut i: usize) -> usize { while p[i] != i { p[i] = p[p[i]]; i = p[i]; } i }
    #[derive(PartialEq, Eq, Hash, Clone, Copy)]
    enum Key { Pt(usize, i32, i32), Lab(usize, usize), Net(usize) }
    let mut ids: HashMap<Key, usize> = HashMap::new();
    let mut node = |k: Key, parent: &mut Vec<usize>| -> usize { *ids.entry(k).or_insert_with(|| { parent.push(parent.len()); parent.len() - 1 }) };
    let mut join = |a: usize, b: usize, parent: &mut Vec<usize>| { let (x, y) = (find(parent, a), find(parent, b)); parent[x] = y; };
    let mut pins: Vec<((usize, String), usize)> = vec![];
    for (si, s) in sheets.iter().enumerate() {
        for w in &s.cv.wires { let (a, b) = (node(Key::Pt(si, w.a.0, w.a.1), &mut parent), node(Key::Pt(si, w.b.0, w.b.1), &mut parent)); join(a, b, &mut parent); }
        for l in &s.cv.labels {
            let across = hier.get(&l.net).map(|v| v.contains(&si) && v.len() > 1).unwrap_or(false);
            let (a, b) = (node(Key::Pt(si, l.x, l.y), &mut parent), node(if across { Key::Net(l.net) } else { Key::Lab(si, l.net) }, &mut parent)); join(a, b, &mut parent);
        }
        let mut ends: Vec<(i32, i32)> = s.cv.wires.iter().flat_map(|w| [w.a, w.b]).chain(s.cv.labels.iter().map(|l| (l.x, l.y))).collect();
        for i in &s.cv.insts {
            let sym = match i.part { Some(pi) => d.parts[pi].sym.as_ref().or_else(|| box_syms.get(&pi)).unwrap(), None => &syms[&i.sym] };
            for t in 0..sym.terms.len() {
                let p = term_at(sym, t, i.x, i.y, i.rot); ends.push(p);
                let a = node(Key::Pt(si, p.0, p.1), &mut parent);
                if let Some(n) = i.rail { let b = node(Key::Net(n), &mut parent); join(a, b, &mut parent); }
                if let Some(pi) = i.part { for num in &sym.terms[t].nums { pins.push(((pi, num.clone()), a)); } }
            }
        }
        // an end that lands inside another wire joins it: the way two nets would short by accident
        for p in ends { for w in &s.cv.wires {
            let inside = (w.a.0 == w.b.0 && p.0 == w.a.0 && p.1 > w.a.1.min(w.b.1) && p.1 < w.a.1.max(w.b.1)) || (w.a.1 == w.b.1 && p.1 == w.a.1 && p.0 > w.a.0.min(w.b.0) && p.0 < w.a.0.max(w.b.0));
            if inside { let (a, b) = (node(Key::Pt(si, p.0, p.1), &mut parent), node(Key::Pt(si, w.a.0, w.a.1), &mut parent)); join(a, b, &mut parent); }
        } }
    }
    let mut out = vec![];
    let group: HashMap<(usize, String), usize> = pins.iter().map(|(k, n)| (k.clone(), find(&mut parent, *n))).collect();
    let mut members: HashMap<usize, Vec<&(usize, String)>> = HashMap::new();
    for (k, g) in &group { members.entry(*g).or_default().push(k); }
    let name = |p: &(usize, String)| format!("{}.{}", d.parts[p.0].r, p.1);
    for (pi, part) in d.parts.iter().enumerate() { for pad in &part.pads { if !group.contains_key(&(pi, pad.clone())) { out.push(format!("{}.{pad} is not on any sheet", part.r)); } } }
    for n in &d.nets {
        let gs: BTreeSet<usize> = n.pins.iter().filter_map(|p| group.get(p).copied()).collect();
        if gs.len() > 1 { out.push(format!("net {}: drawn as {} separate pieces", n.name, gs.len())); }
        for g in gs { for m in &members[&g] { if d.pin_net.get(*m).map(|x| d.nets[*x].name != n.name).unwrap_or(true) { out.push(format!("net {}: the sheet also joins {} to it", n.name, name(m))); } } }
    }
    for (p, g) in &group { if !d.pin_net.contains_key(p) && members[g].len() > 1 { out.push(format!("{} has no net in nets.json and the sheet joins it to {}", name(p), members[g].iter().filter(|m| **m != p).map(|m| name(m)).take(4).collect::<Vec<_>>().join(", "))); } }
    out.sort(); out.dedup();
    out
}

fn mm(v: f64) -> String { let s = format!("{:.3}", v * STEP); s.trim_end_matches('0').trim_end_matches('.').to_string() }
fn font(size: f64, bold: bool) -> String { format!("(font (size {size} {size}){})", if bold { " (thickness 0.381) (bold yes)" } else { "" }) }

struct Writer<'a> { d: &'a Design, root: String, virt: BTreeMap<String, usize>, hier: HashMap<usize, BTreeSet<usize>> }

impl<'a> Writer<'a> {
    fn items(&mut self, cv: &Cv, syms: &BTreeMap<String, Sym>, box_syms: &HashMap<usize, Sym>, path: &str, seed: &str, sheet: Option<usize>) -> String {
        let d = self.d;
        let mut o = String::new();
        // junction dots where three or more ends meet
        let mut ends: HashMap<(i32, i32), usize> = HashMap::new();
        for w in &cv.wires { *ends.entry(w.a).or_default() += 1; *ends.entry(w.b).or_default() += 1; }
        for i in &cv.insts {
            let s = match i.part { Some(pi) => d.parts[pi].sym.as_ref().or_else(|| box_syms.get(&pi)).unwrap(), None => &syms[&i.sym] };
            for t in 0..s.terms.len() { *ends.entry(term_at(s, t, i.x, i.y, i.rot)).or_default() += 1; }
        }
        let mut dots: Vec<(i32, i32)> = ends.iter().filter(|(_, n)| **n >= 3).map(|(p, _)| *p).collect(); dots.sort();
        for (k, p) in dots.iter().enumerate() { o += &format!("\t(junction (at {} {}) (diameter 0.9144) (color 0 0 0 0) (uuid \"{}\"))\n", mm(p.0 as f64), mm(p.1 as f64), uuid(&format!("{seed}/j{k}"))); }
        for (k, p) in cv.ncs.iter().enumerate() { o += &format!("\t(no_connect (at {} {}) (uuid \"{}\"))\n", mm(p.0 as f64), mm(p.1 as f64), uuid(&format!("{seed}/nc{k}"))); }
        for (k, w) in cv.wires.iter().enumerate() {
            o += &format!("\t(wire (pts (xy {} {}) (xy {} {})) (stroke (width 0.1524) (type {})) (uuid \"{}\"))\n", mm(w.a.0 as f64), mm(w.a.1 as f64), mm(w.b.0 as f64), mm(w.b.1 as f64), if w.seen { "solid" } else { "dash" }, uuid(&format!("{seed}/w{k}")));
        }
        for (k, l) in cv.labels.iter().enumerate() {
            let (ang, just) = match l.run { (1, 0) => (0, "left"), (-1, 0) => (180, "right"), (0, -1) => (90, "left"), _ => (270, "right") };
            let hier = sheet.map(|s| self.hier.get(&l.net).map(|v| v.contains(&s) && v.len() > 1).unwrap_or(false)).unwrap_or(false);
            if hier { o += &format!("\t(hierarchical_label \"{}\" (shape passive) (at {} {} {ang}) (effects {} (justify {just} bottom)) (uuid \"{}\"))\n", esc(&d.nets[l.net].name), mm(l.x as f64), mm(l.y as f64), font(1.27, false), uuid(&format!("{seed}/h{k}"))); }
            else { o += &format!("\t(label \"{}\" (at {} {} {ang}) (effects {} (justify {just} bottom)) (uuid \"{}\"))\n", esc(&d.nets[l.net].name), mm(l.x as f64), mm(l.y as f64), font(1.27, false), uuid(&format!("{seed}/l{k}"))); }
        }
        for (k, t) in cv.texts.iter().enumerate() {
            o += &format!("\t(text \"{}\" (exclude_from_sim no) (at {} {} 0) (effects {}{}) (uuid \"{}\"))\n", esc(&t.s), mm(t.x), mm(t.y), font(t.size, t.bold), match t.just { -1 => " (justify left)", 1 => " (justify right)", _ => "" }, uuid(&format!("{seed}/t{k}")));
        }
        for i in &cv.insts {
            let s = match i.part { Some(pi) => d.parts[pi].sym.as_ref().or_else(|| box_syms.get(&pi)).unwrap(), None => &syms[&i.sym] };
            let refd = match i.part { Some(pi) => d.parts[pi].r.clone(), None => { let n = self.virt.entry(s.prefix.clone()).or_default(); *n += 1; format!("{}{:04}", s.prefix, n) } };
            let ang = if i.rot == 90 || i.rot == 270 { 90 } else { 0 };
            let (x, y) = (mm(i.x as f64), mm(i.y as f64));
            let real = i.part.is_some();
            o += &format!("\t(symbol (lib_id \"{}\") (at {x} {y} {}) (unit 1) (exclude_from_sim {}) (in_bom {}) (on_board {}) (dnp no) (uuid \"{}\")\n", s.id(), i.rot, if real { "no" } else { "yes" }, if real { "yes" } else { "no" }, if real { "yes" } else { "no" }, uuid(&format!("{}/sym/{refd}", d.name)));
            let shown = |at: Option<(f64, f64)>| match at { Some(p) => format!("(at {} {} {ang}) (effects {})", mm(p.0), mm(p.1), font(1.27, false)), None => format!("(at {x} {y} 0) (effects {} (hide yes))", font(1.27, false)) };
            o += &format!("\t\t(property \"Reference\" \"{}\" {})\n", esc(&refd), shown(if real { i.ref_at } else { None }));
            o += &format!("\t\t(property \"Value\" \"{}\" {})\n", esc(&i.value), shown(i.val_at));
            if let Some(pi) = i.part {
                // hidden fields stacked below the body
                let b = body_box(s, i.x, i.y, i.rot);
                for (k, (name, val)) in d.parts[pi].fields.iter().enumerate() { o += &format!("\t\t(property \"{}\" \"{}\" (at {} {} 0) (effects {} (hide yes)))\n", name, esc(val), mm((b.0 + b.2) / 2.0), mm(b.3 + 2.0 + 2.0 * k as f64), font(1.27, false)); }
            } else { o += &format!("\t\t(property \"Footprint\" \"\" (at {x} {y} 0) (effects {} (hide yes)))\n\t\t(property \"Datasheet\" \"~\" (at {x} {y} 0) (effects {} (hide yes)))\n", font(1.27, false), font(1.27, false)); }
            for n in s.terms.iter().flat_map(|t| t.nums.iter()) { o += &format!("\t\t(pin \"{}\" (uuid \"{}\"))\n", esc(n), uuid(&format!("{}/pin/{refd}/{n}", d.name))); }
            o += &format!("\t\t(instances (project \"{}\" (path \"{path}\" (reference \"{}\") (unit 1))))\n\t)\n", esc(&d.name), esc(&refd));
        }
        o
    }

    fn file(&self, uid: &str, paper: &str, title: &str, sheet_no: usize, of: usize, libs: &str, body: &str, tail: &str) -> String {
        format!("(kicad_sch\n\t(version 20231120)\n\t(generator \"adom-aiflow-reverse\")\n\t(generator_version \"8.0\")\n\t(uuid \"{uid}\")\n\t(paper \"{paper}\")\n\t(title_block (title \"{}\") (comment 1 \"Solid wire: seen in copper. Dashed: inferred. ?: net not known.\") (comment 2 \"Reverse engineered from photos. Sheet {sheet_no} of {of}.\"))\n\t(lib_symbols\n{libs}\t)\n{body}{tail})\n", esc(title))
    }
}

fn lib_block(names: &BTreeSet<String>, syms: &BTreeMap<String, Sym>) -> String { names.iter().map(|n| format!("\t\t{}\n", syms[n].text(true))).collect() }

/// Lay the whole design out and write every sheet.
pub fn project(d: &Design) -> Project {
    let mut lay = Lay { d, syms: BTreeMap::new(), box_syms: HashMap::new(), claimed: HashSet::new(), attach: HashMap::new(), in_block: HashSet::new(), free: false, stats: BTreeMap::new() };
    let mut sheets: Vec<Sheet> = vec![];
    let mut notes = d.notes.clone();
    for (bi, b) in d.blocks.iter().enumerate() {
        lay.attach_block(&b.parts);
        let mut anchors: Vec<usize> = b.parts.iter().copied().filter(|i| d.parts[*i].role == Role::Anchor).collect();
        // ICs first, biggest first, then connectors: each takes the parts nearest its pins
        anchors.sort_by(|x, y| { let k = |i: &usize| (d.parts[*i].kind == Kind::Conn, std::cmp::Reverse(d.parts[*i].pads.len())); k(x).cmp(&k(y)).then(natural(&d.parts[*x].r, &d.parts[*y].r)) });
        let mut groups: Vec<Cv> = lay.stacks(&b.parts);
        anchors.retain(|pi| !lay.claimed.contains(pi));
        groups.extend(anchors.iter().map(|&pi| { lay.claimed.insert(pi); lay.cluster(pi, false, false) }));
        groups.extend(lay.leftovers(&b.parts));
        // the smallest paper that takes the block on one sheet; A3 sheets after that
        let mut chosen = None;
        for (paper, _, _, right, bottom) in PAPERS {
            let (w, h) = (right - FRAME - 6, bottom - FRAME - 10);
            let (p, fits) = pack(&groups, w, h);
            if (p.len() == 1 && fits) || paper == "A3" { chosen = Some((paper, p)); break; }
        }
        let (paper, p) = chosen.unwrap();
        let n = p.len();
        for (k, spots) in p.iter().enumerate() {
            let cv = place(&groups, spots, FRAME + 4, FRAME + 8);
            let file = format!("{}-{}{}.kicad_sch", d.name, b.id, if n > 1 { format!("-{}", k + 1) } else { String::new() });
            sheets.push(Sheet { file, title: if n > 1 { format!("{} ({} OF {n})", b.name, k + 1) } else { b.name.clone() }, paper, cv, block: bi });
        }
        if n > 1 { notes.push(format!("block {} needs {n} A3 sheets", b.name)); }
    }
    for pi in 0..d.parts.len() { if !lay.claimed.contains(&pi) { notes.push(format!("{} was not drawn (no block holds it)", d.parts[pi].r)); } }

    // which nets cross sheets: those get hierarchical labels and a pin on the root sheet
    let mut on: HashMap<usize, BTreeSet<usize>> = HashMap::new();
    for (si, s) in sheets.iter().enumerate() { for l in &s.cv.labels { on.entry(l.net).or_default().insert(si); } }
    let hier: HashMap<usize, BTreeSet<usize>> = on.into_iter().filter(|(n, v)| v.len() > 1 && d.nets[*n].rail == Rail::No).collect();
    let root_uuid = uuid(&format!("sch/{}", d.name));
    let mut w = Writer { d, root: root_uuid.clone(), virt: BTreeMap::new(), hier };
    let total = sheets.len() + 1;
    let mut files = vec![];
    let mut all_syms: BTreeSet<String> = BTreeSet::new();
    let sheet_uuid = |f: &str| uuid(&format!("sch/{}/sheet/{f}", d.name));
    for (si, s) in sheets.iter().enumerate() {
        let (_, _, _, right, bottom) = PAPERS.iter().find(|p| p.0 == s.paper).copied().unwrap();
        let mut body = format!("\t(rectangle (start {} {}) (end {} {}) (stroke (width 0.3) (type solid)) (fill (type none)) (uuid \"{}\"))\n", mm(FRAME as f64), mm(FRAME as f64), mm(right as f64), mm(bottom as f64), uuid(&format!("{}/frame", s.file)));
        body += &format!("\t(text \"{}\" (exclude_from_sim no) (at {} {} 0) (effects {}) (uuid \"{}\"))\n", esc(&s.title), mm((FRAME + right) as f64 / 2.0), mm(FRAME as f64 + 3.0), font(1.905, true), uuid(&format!("{}/title", s.file)));
        body += &w.items(&s.cv, &lay.syms, &lay.box_syms, &format!("/{root_uuid}/{}", sheet_uuid(&s.file)), &s.file, Some(si));
        let mut names: BTreeSet<String> = s.cv.insts.iter().map(|i| i.sym.clone()).collect();
        for i in &s.cv.insts { if let Some(pi) = i.part { if let Some(bs) = lay.box_syms.get(&pi) { lay.syms.entry(bs.name.clone()).or_insert(bs.clone()); names.insert(bs.name.clone()); } } }
        all_syms.extend(names.iter().cloned());
        files.push((s.file.clone(), w.file(&uuid(&format!("sch/{}/file/{}", d.name, s.file)), s.paper, &format!("{}: {}", d.name, s.title), si + 2, total, &lib_block(&names, &lay.syms), &body, "")));
        let b = s.cv.bounds();
        if b.2 > right as f64 || b.3 > bottom as f64 { notes.push(format!("sheet {} does not fit its frame", s.file)); }
    }

    // ---- root sheet: hierarchy, legend, rail flags ----
    let mut rcv = Cv::default();
    let mut rbody = String::new();
    let (paper, right, bottom) = ("A3", PAPERS[1].3, PAPERS[1].4);
    let split = bottom - 44;
    // sheet symbols on shelves
    let (mut x, mut y, mut row_h) = (FRAME + 6, FRAME + 12, 0);
    for (si, s) in sheets.iter().enumerate() {
        let mut pins: Vec<usize> = w.hier.iter().filter(|(_, v)| v.contains(&si)).map(|(n, _)| *n).collect();
        pins.sort_by(|a, b| natural(&d.nets[*a].name, &d.nets[*b].name));
        let half = (pins.len() + 1) / 2;
        let longest = |v: &[usize]| v.iter().map(|n| lab_len(&d.nets[*n].name)).max().unwrap_or(0);
        let (ll, lr) = (longest(&pins[..half]), longest(&pins[half..]));
        let name_w = tw(&s.title).max(tw(&s.file)).ceil() as i32 + 2;
        let bw = { let v = (ll + lr + 4).max(name_w).max(24); v + v % 2 };
        let bh = (half.max(1) as i32 + 1) * 2;
        let total_w = ll + bw + lr + 4;
        if x + total_w > right - 4 && x > FRAME + 6 { x = FRAME + 6; y += row_h + 10; row_h = 0; }
        let (bx, by) = (x + ll + 2, y);
        let su = sheet_uuid(&s.file);
        rbody += &format!("\t(sheet (at {} {}) (size {} {}) (fields_autoplaced yes) (stroke (width 0.1524) (type solid)) (fill (color 0 0 0 0.0000)) (uuid \"{su}\")\n", mm(bx as f64), mm(by as f64), mm(bw as f64), mm(bh as f64));
        rbody += &format!("\t\t(property \"Sheetname\" \"{}\" (at {} {} 0) (effects {} (justify left bottom)))\n\t\t(property \"Sheetfile\" \"{}\" (at {} {} 0) (effects {} (justify left top)))\n", esc(&s.title), mm(bx as f64), mm(by as f64 - 0.5), font(1.27, false), esc(&s.file), mm(bx as f64), mm((by + bh) as f64 + 0.5), font(1.27, false));
        for (k, n) in pins.iter().enumerate() {
            let left = k < half;
            let (px, py) = (if left { bx } else { bx + bw }, by + 2 + 2 * (if left { k } else { k - half }) as i32);
            rbody += &format!("\t\t(pin \"{}\" passive (at {} {} {}) (effects {} (justify {})) (uuid \"{}\"))\n", esc(&d.nets[*n].name), mm(px as f64), mm(py as f64), if left { 180 } else { 0 }, font(1.27, false), if left { "left" } else { "right" }, uuid(&format!("{}/pin/{}", s.file, d.nets[*n].name)));
            let len = lab_len(&d.nets[*n].name); let dir = if left { -1 } else { 1 };
            rcv.wire((px, py), (px + dir * len, py), true);
            rcv.label(d, *n, px + dir * len, py, (-dir, 0));
        }
        rbody += &format!("\t\t(instances (project \"{}\" (path \"/{root_uuid}\" (page \"{}\"))))\n\t)\n", esc(&d.name), si + 2);
        rcv.boxes.push((bx as f64, by as f64 - 2.0, (bx + bw) as f64, (by + bh) as f64 + 2.0));
        x += total_w + 6; row_h = row_h.max(bh);
        if y + bh > split - 2 { notes.push("the hierarchy does not fit the root sheet".into()); }
    }
    // legend and rail flags
    let ly = split + 8;
    let legend: [(&str, &str); 5] = [("solid", "Solid wire: the pad it leads to was seen joined to this net's copper in the photos."), ("dash", "Dashed wire: the join is inferred (a datasheet or the circuit), not seen. Tests in tests.json settle it."),
        ("unknown", "? marker: the net of this pin is not known. It is not a no-connect."), ("nc", "X: no-connect, used only where the run holds evidence the pin is unused."), ("", "Every symbol carries hidden fields: Evidence (how the part is known), Test (its test id) and Marking (as read from the photo).")];
    let lx = FRAME + 6;
    for (k, (kind, text)) in legend.iter().enumerate() {
        let yy = (ly + 4 * k as i32) as f64; let id = uuid(&format!("{}/legend{k}", d.name));
        match *kind {
            "solid" | "dash" => rbody += &format!("\t(polyline (pts (xy {} {}) (xy {} {})) (stroke (width 0.1524) (type {}) (color 0 132 0 1)) (uuid \"{id}\"))\n", mm(lx as f64), mm(yy), mm(lx as f64 + 10.0), mm(yy), if *kind == "solid" { "solid" } else { "dash" }),
            "unknown" => { rbody += &format!("\t(circle (center {} {}) (radius 1.27) (stroke (width 0.1524) (type solid)) (fill (type none)) (uuid \"{id}\"))\n", mm(lx as f64 + 5.0), mm(yy)); rcv.text("?", lx as f64 + 5.0, yy, 1.27, false, 0); }
            "nc" => { for (q, (a, b)) in [((-0.5, -0.5), (0.5, 0.5)), ((-0.5, 0.5), (0.5, -0.5))].iter().enumerate() { rbody += &format!("\t(polyline (pts (xy {} {}) (xy {} {})) (stroke (width 0.1524) (type solid)) (uuid \"{}\"))\n", mm(lx as f64 + 5.0 + a.0), mm(yy + a.1), mm(lx as f64 + 5.0 + b.0), mm(yy + b.1), uuid(&format!("{}/legendx{q}", d.name))); } }
            _ => {}
        }
        rcv.text(text, lx as f64 + 13.0, yy, 1.27, false, -1);
    }
    for (k, text) in d.carried.iter().enumerate() { rcv.text(text, lx as f64, (ly + 4 * (legend.len() + k) as i32) as f64, 1.27, true, -1); }
    // every rail is fed from somewhere the photos do not name: one PWR_FLAG each keeps the ERC honest about the rest
    let driven = |n: usize| d.nets[n].pins.iter().any(|(pi, pad)| { let et = |s: &Sym| s.term(pad).map(|t| s.terms[t].etype == "power_out").unwrap_or(false); d.parts[*pi].sym.as_ref().map(|s| et(s)).unwrap_or(false) || lay.box_syms.get(pi).map(|s| et(s)).unwrap_or(false) });
    let rails: Vec<usize> = (0..d.nets.len()).filter(|n| d.nets[*n].rail != Rail::No && !d.nets[*n].pins.is_empty() && !driven(*n)).collect();
    let fx0 = right - 6 - 12 * rails.len().min(12) as i32;
    for (k, n) in rails.iter().enumerate() {
        let (fx, fy) = (fx0 + 12 * (k % 12) as i32 + (fx0 % 2), ly + 6 + 14 * (k / 12) as i32);
        let fs = schsym::flag_sym();
        let (sx, sy, r) = seat(&fs, 0, (fx, fy), (0, 1));
        rcv.boxes.push(body_box(&fs, sx, sy, r));
        let name = lay.reg(fs);
        rcv.insts.push(Inst { sym: name, x: sx, y: sy, rot: r, part: None, rail: None, value: "PWR_FLAG".into(), ref_at: None, val_at: Some((fx as f64, fy as f64 - 3.2)) });
        rcv.wire((fx, fy), (fx, fy + 2), true);
        lay.rail(&mut rcv, *n, (fx, fy + 2), (0, 1));
    }
    if !rails.is_empty() { rcv.text("PWR_FLAG on each rail: where a rail is fed from is not read from the photos.", (right - 6) as f64, ly as f64, 1.27, false, 1); }
    for (k, (a, b, title)) in [((FRAME, FRAME), (right, split), "HIERARCHY"), ((FRAME, split), (right, bottom), "EVIDENCE LEGEND AND RAIL FLAGS")].iter().enumerate() {
        rbody += &format!("\t(rectangle (start {} {}) (end {} {}) (stroke (width 0.3) (type solid)) (fill (type none)) (uuid \"{}\"))\n", mm(a.0 as f64), mm(a.1 as f64), mm(b.0 as f64), mm(b.1 as f64), uuid(&format!("{}/rootframe{k}", d.name)));
        rbody += &format!("\t(text \"{title}\" (exclude_from_sim no) (at {} {} 0) (effects {}) (uuid \"{}\"))\n", mm((a.0 + b.0) as f64 / 2.0), mm(a.1 as f64 + 3.0), font(1.905, true), uuid(&format!("{}/roottitle{k}", d.name)));
    }
    rbody += &w.items(&rcv, &lay.syms, &lay.box_syms, &format!("/{root_uuid}"), "root", None);
    let names: BTreeSet<String> = rcv.insts.iter().map(|i| i.sym.clone()).collect();
    all_syms.extend(names.iter().cloned());
    let mut tail = "\t(sheet_instances (path \"/\" (page \"1\")))\n".to_string();
    let _ = &mut tail;
    files.insert(0, (format!("{}.kicad_sch", d.name), w.file(&root_uuid, paper, &format!("{} (reverse engineered from photos)", d.name), 1, total, &lib_block(&names, &lay.syms), &rbody, &tail)));
    // the project library every embedded symbol is filed under, and its table
    files.push((format!("{}.kicad_sym", schsym::LIB), format!("(kicad_symbol_lib (version 20231120) (generator \"adom-aiflow-reverse\") (generator_version \"8.0\")\n{})\n", all_syms.iter().map(|n| format!("\t{}\n", lay.syms[n].text(false))).collect::<String>())));
    files.push(("sym-lib-table".into(), format!("(sym_lib_table\n  (version 7)\n  (lib (name \"{0}\")(type \"KiCad\")(uri \"${{KIPRJMOD}}/{0}.kicad_sym\")(options \"\")(descr \"Symbols of the reverse engineered schematic\"))\n)\n", schsym::LIB)));
    // the footprints: the run's own cache (the files the board was built from) and the generated ones
    let mut table = String::from("(fp_lib_table\n  (version 7)\n");
    let mut libs: BTreeSet<String> = d.parts.iter().filter_map(|p| p.fields[0].1.split_once(':').map(|x| x.0.to_string())).collect();
    libs.remove("generated");
    for l in &libs { table += &format!("  (lib (name \"{l}\")(type \"KiCad\")(uri \"${{KIPRJMOD}}/../reverse-flow/lib/fp/{l}\")(options \"\")(descr \"Footprints this run was built from\"))\n"); }
    if !d.generated.is_empty() { table += "  (lib (name \"generated\")(type \"KiCad\")(uri \"${KIPRJMOD}/generated.pretty\")(options \"\")(descr \"Footprints drawn from the photos\"))\n"; }
    files.push(("fp-lib-table".into(), table + ")\n"));
    for (n, t) in &d.generated { files.push((format!("generated.pretty/{n}.kicad_mod"), t.clone())); }
    let _ = &w.root;
    let mut stats = json!({"sheets": total, "parts": d.parts.len(), "nets": d.nets.len(), "wires": sheets.iter().map(|s| s.cv.wires.len()).sum::<usize>(), "wiresSeen": sheets.iter().map(|s| s.cv.wires.iter().filter(|w| w.seen).count()).sum::<usize>(),
        "symbolsBoxed": d.parts.iter().filter(|p| p.sym.is_none() && !p.pads.is_empty()).count(), "hierarchicalNets": w.hier.len(), "blocks": sheets.iter().map(|s| s.block).collect::<BTreeSet<_>>().len()});
    for (k, v) in &lay.stats { stats[*k] = json!(v); }
    let differences = differences(d, &sheets, &lay.syms, &lay.box_syms, &w.hier);
    Project { files, notes, stats, differences }
}

/// Generate the schematic of a run into its kicad folder. Returns hints for the AI.
pub fn write(flow: &Path, kdir: &Path, name: &str) -> Result<Vec<String>, String> {
    let parts_doc = crate::items::read_json(&flow.join("parts.json"))?;
    let parts: Vec<Value> = parts_doc["parts"].as_array().cloned().unwrap_or_default();
    let blocks = crate::items::read_json(&flow.join("blocks.json")).unwrap_or_else(|_| json!({"blocks": [{"id": "all", "name": "All parts", "parts": parts.iter().map(|p| p["ref"].clone()).collect::<Vec<_>>()}]}));
    let nets = crate::items::read_json(&flow.join("nets.json")).unwrap_or(json!({"nets": []}));
    let ev = crate::items::read_json(&flow.join("net-evidence.json")).unwrap_or(Value::Null);
    let mut libs = Libs::new(&flow.join("lib").join("sym"));
    let mut d = Design::load(&kicad_out::cache_dir(flow), &mut libs, &parts, &blocks, &nets, &ev, name);
    d.carried = crate::notread::faces(flow).iter().map(|f| format!("{} Pins on that face end in a ? marker.", crate::notread::required(f).replace('`', "'"))).collect();
    let p = project(&d);
    // sheets of an earlier build that this one no longer writes
    if let Ok(rd) = std::fs::read_dir(kdir) {
        for e in rd.flatten() { let f = e.file_name().to_string_lossy().to_string(); if f.starts_with(&format!("{name}-")) && f.ends_with(".kicad_sch") && !p.files.iter().any(|(n, _)| *n == f) { let _ = std::fs::remove_file(e.path()); } }
    }
    for (f, text) in &p.files { let path = kdir.join(f); if let Some(dir) = path.parent() { std::fs::create_dir_all(dir).ok(); } std::fs::write(&path, text).map_err(|e| format!("{f}: {e}"))?; }
    let mut h = vec![format!("schematic: {} sheets ({} hierarchical nets), {} wires ({} seen in copper, the rest dashed), {} pins of unknown net marked ?, {} parts as boxes", p.stats["sheets"], p.stats["hierarchicalNets"], p.stats["wires"], p.stats["wiresSeen"], p.stats["unknownPins"].as_u64().unwrap_or(0), p.stats["symbolsBoxed"])];
    if !p.differences.is_empty() { h.push(format!("PROBLEM: the sheets as drawn do not hold the connections of nets.json ({} differences; a fault in the generator, not in the run): {}", p.differences.len(), p.differences.iter().take(6).cloned().collect::<Vec<_>>().join("; "))); }
    h.extend(p.notes.iter().take(12).map(|n| format!("schematic: {n}")));
    if p.notes.len() > 12 { h.push(format!("schematic: {} more notes", p.notes.len() - 12)); }
    h.push("Run `check schematic` (ERC, netlist against nets.json, sheet style). Name an identified IC's library symbol in parts.json `symbol`, or its pin names in `pins`, and build again.".into());
    Ok(h)
}

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

    struct Tmp(std::path::PathBuf);
    impl Tmp {
        fn new() -> Self {
            static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
            let p = std::env::temp_dir().join(format!("reverse-sch-{}-{}", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)));
            std::fs::create_dir_all(&p).unwrap(); Tmp(p)
        }
        fn put(&self, rel: &str, text: &str) { let p = self.0.join(rel); std::fs::create_dir_all(p.parent().unwrap()).unwrap(); std::fs::write(p, text).unwrap(); }
    }
    impl Drop for Tmp { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } }

    fn fp(name: &str, pads: &[&str]) -> String { format!("(footprint \"{name}\" (layer \"F.Cu\")\n{})", pads.iter().enumerate().map(|(k, p)| format!(" (pad \"{p}\" smd rect (at {} 0) (size 0.5 0.5) (layers \"F.Cu\"))\n", k)).collect::<String>()) }
    fn two(name: &str, horizontal: bool) -> String {
        let (a, b) = if horizontal { ("(at -3.81 0 0)", "(at 3.81 0 180)") } else { ("(at 0 3.81 270)", "(at 0 -3.81 90)") };
        format!("(symbol \"{name}\" (property \"Reference\" \"{}\" (at 0 0 0)) (symbol \"{name}_0_1\" (rectangle (start -1.016 -1.016) (end 1.016 1.016) (stroke (width 0.254) (type default)) (fill (type none))) (polyline (pts (xy -1 0) (xy 1 0)) (stroke (width 0) (type default)) (fill (type none)))) (symbol \"{name}_1_1\" (pin passive line {a} (length 1.27) (name \"K\" (effects (font (size 1.27 1.27)))) (number \"1\" (effects (font (size 1.27 1.27))))) (pin passive line {b} (length 1.27) (name \"A\" (effects (font (size 1.27 1.27)))) (number \"2\" (effects (font (size 1.27 1.27)))))))", &name[..1])
    }

    /// A run folder with the footprints and library symbols a small board needs, so no test asks KiCad for anything.
    fn bench() -> Tmp {
        let t = Tmp::new();
        t.put("fp/T/IC8.kicad_mod", &fp("IC8", &["1", "2", "3", "4", "5", "6", "7", "8"]));
        t.put("fp/T/P2.kicad_mod", &fp("P2", &["1", "2"]));
        t.put("fp/T/J3.kicad_mod", &fp("J3", &["1", "2", "3"]));
        for n in ["R", "C"] { t.put(&format!("sym/Device/{n}.kicad_sym"), &two(n, false)); }
        t.put("sym/Device/LED.kicad_sym", &two("LED", true));
        t.put("sym/Connector_Generic/Conn_01x03.kicad_sym", "(symbol \"Conn_01x03\" (property \"Reference\" \"J\" (at 0 5 0)) (symbol \"Conn_01x03_1_1\" (rectangle (start -1.27 3.81) (end 1.27 -3.81) (stroke (width 0.254) (type default)) (fill (type background))) (pin passive line (at -5.08 2.54 0) (length 3.81) (name \"Pin_1\" (effects (font (size 1.27 1.27)))) (number \"1\" (effects (font (size 1.27 1.27))))) (pin passive line (at -5.08 0 0) (length 3.81) (name \"Pin_2\" (effects (font (size 1.27 1.27)))) (number \"2\" (effects (font (size 1.27 1.27))))) (pin passive line (at -5.08 -2.54 0) (length 3.81) (name \"Pin_3\" (effects (font (size 1.27 1.27)))) (number \"3\" (effects (font (size 1.27 1.27)))))))");
        t.put("sym/Connector/TestPoint.kicad_sym", "(symbol \"TestPoint\" (property \"Reference\" \"TP\" (at 0 5 0)) (symbol \"TestPoint_0_1\" (circle (center 0 3.302) (radius 0.762) (stroke (width 0) (type default)) (fill (type none)))) (symbol \"TestPoint_1_1\" (pin passive line (at 0 0 90) (length 2.54) (name \"1\" (effects (font (size 1.27 1.27)))) (number \"1\" (effects (font (size 1.27 1.27)))))))");
        t
    }

    fn part(r: &str, fp: &str, value: &str, x: f64) -> Value { json!({"ref": r, "footprint": fp, "value": value, "marking": "", "function": "test part", "package": "", "tag": "MEASURED", "tests": [format!("T-{r}")], "pos_mm": [x, 100.0], "rot": 0, "side": "top"}) }

    fn board() -> (Vec<Value>, Value, Value) {
        let mut parts = vec![part("U1", "T:IC8", "unknown IC", 110.0), part("R1", "T:P2", "10.0kΩ", 108.0), part("R2", "T:P2", "330Ω", 112.0), part("C1", "T:P2", "100nF", 110.5), part("C2", "T:P2", "", 130.0),
            part("LED1", "T:P2", "LED", 114.0), part("J1", "T:J3", "1x3", 120.0), part("TP1", "generated:lug", "GND", 125.0)];
        parts[7]["lug"] = json!({"pad_mm": 3.0, "drill_mm": 1.5});
        let nets = json!({"nets": [
            {"name": "+3V3", "tag": "INFERRED", "pins": ["U1.8", "C1.1", "R1.1", "LED1.2"]},
            {"name": "GND", "tag": "MEASURED", "pins": ["U1.4", "C1.2", "J1.3", "TP1.1", "C2.2", "PHOTO1.M1"]},
            {"name": "SIG", "tag": "INFERRED", "pins": ["U1.1", "R1.2", "J1.1"]},
            {"name": "LED_K", "tag": "INFERRED", "pins": ["U1.2", "R2.1"]},
            {"name": "LED_A", "tag": "INFERRED", "pins": ["R2.2", "LED1.1"]},
            {"name": "N001", "nameSource": "none: unnamed", "tag": "INFERRED", "pins": ["U1.5"]}]});
        (parts, json!({"blocks": [{"id": "core", "name": "Core", "parts": ["U1", "R1", "R2", "C1", "LED1", "TP1"]}, {"id": "io", "name": "Connector and spare", "parts": ["J1", "C2"]}]}), nets)
    }

    fn make(parts: &[Value], blocks: &Value, nets: &Value, t: &Tmp) -> (Design, Project) {
        let mut libs = Libs::new(&t.0.join("sym"));
        let ev = json!({"nets": [{"net": "GND", "pins": {"U1.4": "copper", "C1.2": "no copper seen"}}]});
        let d = Design::load(&t.0.join("fp"), &mut libs, parts, blocks, nets, &ev, "bench");
        let p = project(&d);
        (d, p)
    }

    #[test]
    fn the_drawn_sheets_hold_exactly_the_nets() {
        let t = bench();
        let (parts, blocks, nets) = board();
        let (d, p) = make(&parts, &blocks, &nets, &t);
        assert!(d.notes.is_empty(), "{:?}", d.notes);
        assert!(p.differences.is_empty(), "{:?}", p.differences);
        let names: Vec<&str> = p.files.iter().map(|f| f.0.as_str()).collect();
        for want in ["bench.kicad_sch", "bench-core.kicad_sch", "bench-io.kicad_sch", "RE.kicad_sym", "sym-lib-table", "fp-lib-table"] { assert!(names.contains(&want), "{want} in {names:?}"); }
        for (f, text) in p.files.iter().filter(|f| f.0.ends_with(".kicad_sch") || f.0.ends_with(".kicad_sym")) { assert!(crate::sexp::parse(text).is_ok(), "{f} is one well-formed list"); }
        let get = |n: &str| &p.files.iter().find(|f| f.0 == n).unwrap().1;
        let (root, core, io) = (get("bench.kicad_sch"), get("bench-core.kicad_sch"), get("bench-io.kicad_sch"));
        // SIG and the rails cross the two blocks: SIG by a hierarchical label and a pin on each sheet symbol, the rails by their symbols
        assert_eq!(root.matches("(pin \"SIG\" passive").count(), 2);
        assert!(core.contains("(hierarchical_label \"SIG\"") && io.contains("(hierarchical_label \"SIG\""));
        assert!(!root.contains("(pin \"GND\"") && core.contains("RE:PWR_GND") && io.contains("RE:PWR_GND"));
        assert!(root.contains("\"HIERARCHY\"") && root.contains("EVIDENCE LEGEND") && root.contains("RE:PWR_FLAG"));
        assert!(core.contains("(text \"CORE\"") && io.contains("(text \"CONNECTOR AND SPARE\""), "block titles in uppercase");
        // real shapes, the values as the sheet rules write them, and the evidence fields
        assert!(core.contains("(lib_id \"RE:R\")") && core.contains("(lib_id \"RE:LED\")") && core.contains("(lib_id \"RE:C\")") && core.contains("(lib_id \"RE:TestPoint\")"));
        assert!(core.contains("\"Value\" \"10.0K\"") && core.contains("\"Value\" \"100n\"") && core.contains("\"Value\" \"330R\"") && io.contains("\"Value\" \"?\""));
        assert!(core.contains("(property \"Evidence\" \"MEASURED\"") && core.contains("(property \"Test\" \"T-R1\""));
        assert!(core.contains("\"Footprint\" \"generated:Lug_3.0mm_Drill1.50mm\""), "a generated footprint is named as the library files it");
        assert!(names.contains(&"generated.pretty/Lug_3.0mm_Drill1.50mm.kicad_mod"));
        // pins of unknown net: a marker each, never a no-connect; the one-pin unnamed net is unknown too
        assert!(!core.contains("(no_connect") && !io.contains("(no_connect"));
        assert_eq!(core.matches("(lib_id \"RE:Unknown_").count(), 4, "U1.3, U1.5, U1.6, U1.7");
        assert_eq!(io.matches("(lib_id \"RE:Unknown_").count(), 2, "J1.2 and C2.1");
        // evidence: the pad seen on copper has a solid wire, the others are dashed
        assert!(core.contains("(type solid)") && core.contains("(type dash)"));
        assert_eq!(p.stats["sheets"], 3);
    }

    #[test]
    fn the_generated_sheets_pass_the_sheet_rules() {
        let t = bench();
        let (parts, blocks, nets) = board();
        let (_, p) = make(&parts, &blocks, &nets, &t);
        let (mut errors, mut warnings) = (vec![], vec![]);
        let mut at_ic = 0;
        for (f, text) in p.files.iter().filter(|f| f.0.ends_with(".kicad_sch")) {
            let v = crate::schcheck::style(text, f, &BTreeSet::new(), &mut errors, &mut warnings).unwrap();
            at_ic += v["decouplingAtIc"].as_u64().unwrap();
        }
        assert!(errors.is_empty(), "{errors:#?}");
        assert_eq!(at_ic, 1, "C1 hangs on U1's supply pin");
    }

    #[test]
    fn a_no_connect_needs_the_run_to_say_so() {
        let t = bench();
        let (parts, blocks, mut nets) = board();
        nets["noConnect"] = json!(["U1.3", {"pin": "U1.6", "why": "datasheet: NC"}]);
        let (_, p) = make(&parts, &blocks, &nets, &t);
        let core = &p.files.iter().find(|f| f.0 == "bench-core.kicad_sch").unwrap().1;
        assert_eq!(core.matches("(no_connect").count(), 2);
        assert_eq!(core.matches("(lib_id \"RE:Unknown_").count(), 2);
        assert!(p.differences.is_empty(), "{:?}", p.differences);
    }

    #[test]
    fn named_pins_group_a_box_by_function_and_a_wrong_symbol_is_refused() {
        let t = bench();
        let (mut parts, blocks, nets) = board();
        parts[0]["pins"] = json!({"1": "IN", "2": {"name": "OUT", "type": "output"}, "3": "ADJ", "4": "GND", "5": "NC", "6": "EN", "7": "FB", "8": "VIN"});
        parts[6]["symbol"] = json!("Device:R");
        let (d, p) = make(&parts, &blocks, &nets, &t);
        assert!(d.notes.iter().any(|n| n.starts_with("J1: Device:R not used")), "{:?}", d.notes);
        assert!(p.differences.is_empty(), "{:?}", p.differences);
        let lib = &p.files.iter().find(|f| f.0 == "RE.kicad_sym").unwrap().1;
        let u1 = crate::schsym::extract(lib, "U1").unwrap();
        let y = |name: &str| -> f64 { let i = u1.find(&format!("(name \"{name}\"")).unwrap(); let at = u1[..i].rfind("(at ").unwrap(); u1[at + 4..].split_whitespace().nth(1).unwrap().parse().unwrap() };
        let x = |name: &str| -> f64 { let i = u1.find(&format!("(name \"{name}\"")).unwrap(); let at = u1[..i].rfind("(at ").unwrap(); u1[at + 4..].split_whitespace().next().unwrap().parse().unwrap() };
        assert!(y("VIN") > y("IN") && y("IN") > y("GND"), "supply on top, ground at the bottom (library y is up)");
        assert!(x("OUT") > 0.0 && x("VIN") < 0.0, "outputs on the right");
        assert!(u1.contains("(pin power_in line") && u1.contains("(pin output line"), "pin types where the run gives them");
    }

    #[test]
    fn values_and_rails_read_as_the_rules_write_them() {
        assert_eq!(sheet_value(Kind::C, "100nF"), "100n");
        assert_eq!(sheet_value(Kind::C, "10 µF"), "10u");
        assert_eq!(sheet_value(Kind::C, ""), "?");
        assert_eq!(sheet_value(Kind::R, "2mΩ"), "0R002");
        assert_eq!(sheet_value(Kind::R, "22Ω"), "22R");
        assert_eq!(sheet_value(Kind::R, "2.00kΩ"), "2.00K");
        assert_eq!(sheet_value(Kind::L, "100µH"), "100uH");
        assert_eq!(sheet_value(Kind::Q, "N-MOSFET (marking unreadable)"), "N-MOSFET");
        for (n, r) in [("+3V3", Rail::Supply), ("GND", Rail::Ground), ("AGND", Rail::Ground), ("VIN_LOGIC", Rail::Supply), ("CM5_3.3V", Rail::Supply), ("VBUS_EN", Rail::No), ("VSENSE", Rail::No), ("SD_VDD_OVERRIDE", Rail::No), ("GPIO5", Rail::No)] { assert_eq!(rail_of(n), r, "{n}"); }
        assert_eq!(pad_numbers(&fp("x", &["10", "2", "1", "2", "MP", ""])), vec!["1", "2", "10", "MP"]);
    }

    #[test]
    fn a_long_row_of_capacitors_wraps_and_every_sheet_is_a3_at_most() {
        let t = bench();
        let mut parts = vec![part("J1", "T:J3", "1x3", 100.0)];
        let mut vin = vec!["J1.1".to_string()]; let mut gnd = vec!["J1.3".to_string()];
        for k in 1..=60 { parts.push(part(&format!("C{k}"), "T:P2", "", 100.0 + k as f64)); vin.push(format!("C{k}.1")); gnd.push(format!("C{k}.2")); }
        let nets = json!({"nets": [{"name": "VIN", "tag": "INFERRED", "pins": vin}, {"name": "GND", "tag": "INFERRED", "pins": gnd}]});
        let blocks = json!({"blocks": [{"id": "in", "name": "Power entry", "parts": parts.iter().map(|p| p["ref"].clone()).collect::<Vec<_>>()}]});
        let (_, p) = make(&parts, &blocks, &nets, &t);
        assert!(p.differences.is_empty(), "{:?}", p.differences);
        assert!(p.stats["railRows"].as_u64().unwrap() >= 2, "{}", p.stats);
        for (f, text) in p.files.iter().filter(|f| f.0.ends_with(".kicad_sch")) { assert!(text.contains("(paper \"A4\")") || text.contains("(paper \"A3\")"), "{f}"); }
        assert!(!p.notes.iter().any(|n| n.contains("does not fit")), "{:?}", p.notes);
    }
}