master
John Lauer 1.0.20: NATIVE (Rust) build. 1.0.20: reservePlanes defaults to false (ESC fixture: reserved 30 unconnected, open 3), re… 6f8e15e 1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656
//! Specctra DSN writer and SES reader for the Freerouting engine of `kicad_autoroute`.
//!
//! The writer follows pcbnew's own exporter (KiCad `pcbnew/specctra_export.cpp`): units
//! are micrometres with `(resolution um 10)` declaring a tenth of a micrometre of precision
//! (so a clearance can be `200.1`), the y axis is flipped (`dsn_y = -board_y`), pins are `REF-PAD`, the
//! through via is `Via[0-1]_<size>:<drill>_um`, and existing copper goes into `(wiring)`
//! as protected wires so the router keeps it. Where this writer is simpler than pcbnew:
//!
//! - one `(image)` per footprint INSTANCE, placed `front 0`, with every pin already in the
//!   footprint's board-frame orientation. Rotation and side are baked into the pins and
//!   padstacks, so the DSN place semantics (counter-clockwise, mirrored back side) never
//!   come into play;
//! - circle and rect pads are exact (rect as a rotated polygon); oval, roundrect and every
//!   other shape are written as the rect of their bounding size and listed in
//!   `approximatedPads`, which is conservative (never smaller than the real pad);
//! - the boundary is the largest closed loop on Edge.Cuts (arcs and circles as segments),
//!   else the outline's bounding box, else the pad bounding box plus a margin (`notes`
//!   says which).
//!
//! The parser header says `host_cad "KiCad's Pcbnew"` on purpose: this IS pcbnew's
//! dialect, and Freerouting keys compatibility behaviour off that string (any other host
//! is treated as a pre-6 KiCad export with a warning).
//!
//! The reader takes Freerouting's `.ses` (`routes > network_out > net > wire | via`) back
//! to millimetre segments and through vias in KiCad's frame, honouring the session's own
//! `(resolution ...)` (Freerouting writes tenths of a micrometre there, ten times the DSN
//! numbers) and the `(string_quote ")` parser quirk.

use std::collections::BTreeMap;
use std::path::Path;

use serde::Serialize;
use serde_json::Value;

use crate::model_check::{parse_sexpr, Sx};
use crate::pcb::{self, Board, PlanSegment, PlanVia};

/// KiCad's routing defaults when the board has no project file beside it.
pub const DEFAULT_CLEARANCE_MM: f64 = 0.2;
pub const DEFAULT_TRACK_MM: f64 = 0.25;
pub const DEFAULT_VIA_SIZE_MM: f64 = 0.8;
pub const DEFAULT_VIA_DRILL_MM: f64 = 0.4;

/// DSN numbers are micrometres, written with at most one decimal (`(resolution um 10)`).
const UM_PER_MM: f64 = 1000.0;
/// Endpoints closer than this (mm) are the same point when chaining the outline.
const CHAIN_TOL: f64 = 0.01;

// ---------------------------------------------------------------------------
// Rules
// ---------------------------------------------------------------------------

/// One netclass as the DSN carries it: mm.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct NetClass {
    pub name: String,
    pub clearance: f64,
    #[serde(rename = "trackWidth")]
    pub track_width: f64,
    #[serde(rename = "viaSize")]
    pub via_size: f64,
    #[serde(rename = "viaDrill")]
    pub via_drill: f64,
    /// Exact net names assigned to this class (resolved against the board's nets).
    pub nets: Vec<String>,
}

/// The Default class plus every other class that owns at least one net, and where the
/// numbers came from.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct Rules {
    pub clearance: f64,
    #[serde(rename = "trackWidth")]
    pub track_width: f64,
    #[serde(rename = "viaSize")]
    pub via_size: f64,
    #[serde(rename = "viaDrill")]
    pub via_drill: f64,
    pub classes: Vec<NetClass>,
    pub source: String,
}

impl Default for Rules {
    fn default() -> Self {
        Rules { clearance: DEFAULT_CLEARANCE_MM, track_width: DEFAULT_TRACK_MM, via_size: DEFAULT_VIA_SIZE_MM, via_drill: DEFAULT_VIA_DRILL_MM, classes: Vec::new(), source: "kicad-defaults".into() }
    }
}

impl Rules {
    /// The class a net belongs to (Default when unassigned).
    pub fn class_for(&self, net: &str) -> (f64, f64, f64, f64) {
        for c in &self.classes {
            if c.nets.iter().any(|n| n == net) {
                return (c.clearance, c.track_width, c.via_size, c.via_drill);
            }
        }
        (self.clearance, self.track_width, self.via_size, self.via_drill)
    }
}

/// Rules for a board: the sibling `.kicad_pro`'s Default netclass (floored by the
/// board's minimum constraints), plus any other class that owns a net on this board.
/// KiCad's defaults when there is no project file or it does not say.
pub fn rules_for(board_path: &Path, board: &Board) -> Rules {
    let mut rules = Rules::default();
    let pro = board_path.with_extension("kicad_pro");
    let Some(doc) = std::fs::read_to_string(&pro).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()) else {
        return rules;
    };
    let f = |v: Option<&Value>| v.and_then(Value::as_f64).filter(|x| x.is_finite() && *x > 0.0);
    let mins = &doc["board"]["design_settings"]["rules"];
    let min_clearance = f(mins.get("min_clearance")).unwrap_or(0.0);
    let min_track = f(mins.get("min_track_width")).unwrap_or(0.0);
    let min_via = f(mins.get("min_via_diameter")).unwrap_or(0.0);
    let min_hole = f(mins.get("min_through_hole_diameter")).unwrap_or(0.0);
    let classes = doc["net_settings"]["classes"].as_array().cloned().unwrap_or_default();
    let default = classes.iter().find(|c| c["name"].as_str() == Some("Default"));
    if let Some(d) = default {
        rules.clearance = f(d.get("clearance")).unwrap_or(rules.clearance);
        rules.track_width = f(d.get("track_width")).unwrap_or(rules.track_width);
        rules.via_size = f(d.get("via_diameter")).unwrap_or(rules.via_size);
        rules.via_drill = f(d.get("via_drill")).unwrap_or(rules.via_drill);
        rules.source = pro.to_string_lossy().replace('\\', "/");
    }
    rules.clearance = rules.clearance.max(min_clearance);
    rules.track_width = rules.track_width.max(min_track);
    rules.via_size = rules.via_size.max(min_via);
    rules.via_drill = rules.via_drill.max(min_hole);
    if rules.via_drill >= rules.via_size {
        rules.via_drill = (rules.via_size / 2.0 * 1000.0).round() / 1000.0;
    }
    // Net to class: KiCad 8+ exact assignments, KiCad 7 patterns (exact or a trailing `*`).
    let mut assigned: BTreeMap<String, String> = BTreeMap::new();
    if let Some(map) = doc["net_settings"]["netclass_assignments"].as_object() {
        for (net, class) in map {
            if let Some(c) = class.as_str() {
                assigned.insert(net.clone(), c.to_string());
            }
        }
    }
    if let Some(pats) = doc["net_settings"]["netclass_patterns"].as_array() {
        for p in pats {
            let (Some(pattern), Some(class)) = (p["pattern"].as_str(), p["netclass"].as_str()) else { continue };
            for n in &board.nets {
                if n.name.is_empty() || assigned.contains_key(&n.name) {
                    continue;
                }
                let hit = match pattern.strip_suffix('*') {
                    Some(prefix) => n.name.starts_with(prefix),
                    None => n.name == pattern,
                };
                if hit {
                    assigned.insert(n.name.clone(), class.to_string());
                }
            }
        }
    }
    for c in &classes {
        let Some(name) = c["name"].as_str().filter(|n| *n != "Default") else { continue };
        let nets: Vec<String> = assigned.iter().filter(|(n, cl)| cl.as_str() == name && board.nets.iter().any(|b| &b.name == *n)).map(|(n, _)| n.clone()).collect();
        if nets.is_empty() {
            continue;
        }
        let mut nc = NetClass {
            name: name.to_string(),
            clearance: f(c.get("clearance")).unwrap_or(rules.clearance).max(min_clearance),
            track_width: f(c.get("track_width")).unwrap_or(rules.track_width).max(min_track),
            via_size: f(c.get("via_diameter")).unwrap_or(rules.via_size).max(min_via),
            via_drill: f(c.get("via_drill")).unwrap_or(rules.via_drill).max(min_hole),
            nets,
        };
        if nc.via_drill >= nc.via_size {
            nc.via_drill = (nc.via_size / 2.0 * 1000.0).round() / 1000.0;
        }
        rules.classes.push(nc);
    }
    rules
}

// ---------------------------------------------------------------------------
// Units, quoting, geometry
// ---------------------------------------------------------------------------

fn u(mm: f64) -> String {
    let tenths = (mm * UM_PER_MM * 10.0).round();
    if tenths % 10.0 == 0.0 {
        format!("{}", (tenths / 10.0) as i64)
    } else {
        format!("{:.1}", tenths / 10.0)
    }
}

/// A board point (mm, +y down) as DSN units with the y axis flipped.
fn pt(x: f64, y: f64) -> String {
    format!("{} {}", u(x), u(-y))
}

/// Quote the way pcbnew does: bare when every character is safe, else in double quotes.
pub fn q(s: &str) -> String {
    let safe = |c: char| c.is_ascii_alphanumeric() || "_-./+:@#$&%!~^*?=<>|[]".contains(c);
    if !s.is_empty() && s.chars().all(safe) {
        s.to_string()
    } else {
        format!("\"{}\"", s.replace('"', "'"))
    }
}

/// KiCad's pad rotation in a y-down frame (trigo.cpp RotatePoint): the same formula
/// `pcb::pads` uses for footprint rotation.
fn rotate(x: f64, y: f64, deg: f64) -> (f64, f64) {
    let (s, c) = deg.to_radians().sin_cos();
    (x * c + y * s, -x * s + y * c)
}

fn atom_f64(sx: &Sx, i: usize) -> Option<f64> {
    sx.atom_at(i)?.parse::<f64>().ok()
}

fn xy(node: Option<&Sx>) -> Option<(f64, f64)> {
    let n = node?;
    Some((atom_f64(n, 1)?, atom_f64(n, 2)?))
}

fn via_name(size: f64, drill: f64) -> String {
    format!("Via[0-1]_{}:{}_um", (size * 1000.0).round() as i64, (drill * 1000.0).round() as i64)
}

/// `Via[0-1]_800:400_um` -> (0.8, 0.4).
pub fn via_dims(name: &str) -> Option<(f64, f64)> {
    let rest = name.strip_prefix("Via[")?;
    let (_, rest) = rest.split_once("]_")?;
    let rest = rest.strip_suffix("_um")?;
    let (a, b) = rest.split_once(':')?;
    Some((a.parse::<f64>().ok()? / 1000.0, b.parse::<f64>().ok()? / 1000.0))
}

// ---------------------------------------------------------------------------
// Board outline
// ---------------------------------------------------------------------------

/// Arc through three points as a polyline (start and end inclusive).
fn arc_points(a: (f64, f64), m: (f64, f64), b: (f64, f64)) -> Vec<(f64, f64)> {
    let (ax, ay) = a;
    let (mx, my) = m;
    let (bx, by) = b;
    let d = 2.0 * (ax * (my - by) + mx * (by - ay) + bx * (ay - my));
    if d.abs() < 1e-9 {
        return vec![a, b];
    }
    let a2 = ax * ax + ay * ay;
    let m2 = mx * mx + my * my;
    let b2 = bx * bx + by * by;
    let cx = (a2 * (my - by) + m2 * (by - ay) + b2 * (ay - my)) / d;
    let cy = (a2 * (bx - mx) + m2 * (ax - bx) + b2 * (mx - ax)) / d;
    let r = ((ax - cx).powi(2) + (ay - cy).powi(2)).sqrt();
    let t0 = (ay - cy).atan2(ax - cx);
    let tm = (my - cy).atan2(mx - cx);
    let t1 = (by - cy).atan2(bx - cx);
    let two_pi = std::f64::consts::TAU;
    let ccw = |from: f64, to: f64| ((to - from) % two_pi + two_pi) % two_pi;
    // Sweep counter-clockwise from start unless the mid point is not on that way.
    let (sweep, sign) = if ccw(t0, tm) <= ccw(t0, t1) { (ccw(t0, t1), 1.0) } else { (ccw(t1, t0), -1.0) };
    let n = ((sweep.to_degrees() / 5.0).ceil() as usize).clamp(4, 180);
    let mut out = Vec::with_capacity(n + 1);
    for i in 0..=n {
        let t = t0 + sign * sweep * (i as f64) / (n as f64);
        out.push((cx + r * t.cos(), cy + r * t.sin()));
    }
    out[0] = a;
    out[n] = b;
    out
}

fn circle_points(c: (f64, f64), r: f64) -> Vec<(f64, f64)> {
    let n = 64;
    let mut out: Vec<(f64, f64)> = (0..n).map(|i| {
        let t = std::f64::consts::TAU * (i as f64) / (n as f64);
        (c.0 + r * t.cos(), c.1 + r * t.sin())
    }).collect();
    out.push(out[0]);
    out
}

/// Every Edge.Cuts primitive as a polyline in board mm (footprint ones transformed).
fn edge_polylines(tree: &Sx) -> Vec<Vec<(f64, f64)>> {
    let mut out = Vec::new();
    let on_edge = |node: &Sx| node.find("layer").and_then(Sx::value) == Some("Edge.Cuts");
    let mut collect = |node: &Sx, tag: &str, tf: &dyn Fn((f64, f64)) -> (f64, f64)| {
        if !on_edge(node) {
            return;
        }
        let poly: Option<Vec<(f64, f64)>> = (|| match tag {
            "gr_line" | "fp_line" => Some(vec![tf(xy(node.find("start"))?), tf(xy(node.find("end"))?)]),
            "gr_rect" | "fp_rect" => {
                let (x1, y1) = xy(node.find("start"))?;
                let (x2, y2) = xy(node.find("end"))?;
                Some(vec![tf((x1, y1)), tf((x2, y1)), tf((x2, y2)), tf((x1, y2)), tf((x1, y1))])
            }
            "gr_arc" | "fp_arc" => {
                let a = xy(node.find("start"))?;
                let b = xy(node.find("end"))?;
                match xy(node.find("mid")) {
                    Some(m) => Some(arc_points(a, m, b).into_iter().map(tf).collect()),
                    None => Some(vec![tf(a), tf(b)]),
                }
            }
            "gr_circle" | "fp_circle" => {
                let c = xy(node.find("center"))?;
                let e = xy(node.find("end"))?;
                Some(circle_points(c, pcb::dist(c, e)).into_iter().map(tf).collect())
            }
            "gr_poly" | "fp_poly" => {
                let pts = node.find("pts")?;
                let mut v: Vec<(f64, f64)> = pts.find_all("xy").filter_map(|p| Some(tf((atom_f64(p, 1)?, atom_f64(p, 2)?)))).collect();
                if v.len() >= 3 {
                    v.push(v[0]);
                }
                Some(v)
            }
            _ => None,
        })()
        .filter(|v| v.len() >= 2);
        if let Some(p) = poly {
            out.push(p);
        }
    };
    for tag in ["gr_line", "gr_rect", "gr_arc", "gr_circle", "gr_poly"] {
        for node in tree.find_all(tag) {
            collect(node, tag, &|p| p);
        }
    }
    for fp in tree.find_all("footprint") {
        let (fx, fy, rot) = fp.find("at").map(|a| (atom_f64(a, 1).unwrap_or(0.0), atom_f64(a, 2).unwrap_or(0.0), atom_f64(a, 3).unwrap_or(0.0))).unwrap_or((0.0, 0.0, 0.0));
        let tf = move |p: (f64, f64)| {
            let (x, y) = rotate(p.0, p.1, rot);
            (fx + x, fy + y)
        };
        for tag in ["fp_line", "fp_rect", "fp_arc", "fp_circle", "fp_poly"] {
            for node in fp.find_all(tag) {
                collect(node, tag, &tf);
            }
        }
    }
    out
}

fn same_point(a: (f64, f64), b: (f64, f64)) -> bool {
    pcb::dist(a, b) <= CHAIN_TOL
}

fn polygon_area(p: &[(f64, f64)]) -> f64 {
    let mut s = 0.0;
    for i in 0..p.len() {
        let (x1, y1) = p[i];
        let (x2, y2) = p[(i + 1) % p.len()];
        s += x1 * y2 - x2 * y1;
    }
    s.abs() / 2.0
}

/// Chain the outline primitives into closed loops and keep the largest one.
fn outline_loop(mut pieces: Vec<Vec<(f64, f64)>>) -> Option<Vec<(f64, f64)>> {
    let mut loops: Vec<Vec<(f64, f64)>> = Vec::new();
    while let Some(mut chain) = pieces.pop() {
        loop {
            let head = chain[0];
            let tail = chain[chain.len() - 1];
            if chain.len() >= 4 && same_point(head, tail) {
                chain.pop();
                loops.push(chain);
                break;
            }
            let mut joined = false;
            for i in 0..pieces.len() {
                let p = &pieces[i];
                let (ph, pt_) = (p[0], p[p.len() - 1]);
                if same_point(tail, ph) {
                    let p = pieces.remove(i);
                    chain.extend(p.into_iter().skip(1));
                    joined = true;
                } else if same_point(tail, pt_) {
                    let p = pieces.remove(i);
                    chain.extend(p.into_iter().rev().skip(1));
                    joined = true;
                } else if same_point(head, pt_) {
                    let mut p = pieces.remove(i);
                    p.pop();
                    p.extend(chain.iter().copied());
                    chain = p;
                    joined = true;
                } else if same_point(head, ph) {
                    let p = pieces.remove(i);
                    let mut p: Vec<(f64, f64)> = p.into_iter().rev().collect();
                    p.pop();
                    p.extend(chain.iter().copied());
                    chain = p;
                    joined = true;
                }
                if joined {
                    break;
                }
            }
            if !joined {
                break; // an open chain: not an outline
            }
        }
    }
    loops.into_iter().filter(|l| l.len() >= 3 && polygon_area(l) > 0.0).max_by(|a, b| polygon_area(a).partial_cmp(&polygon_area(b)).unwrap_or(std::cmp::Ordering::Equal))
}

// ---------------------------------------------------------------------------
// The writer
// ---------------------------------------------------------------------------

#[derive(Clone, Debug, Default, Serialize)]
pub struct DsnStats {
    pub components: usize,
    pub pins: usize,
    pub nets: usize,
    /// Nets the router is asked to connect (all nets with 2+ pins, or the requested subset).
    #[serde(rename = "routedNets")]
    pub routed_nets: Vec<String>,
    /// Requested nets the board does not have.
    #[serde(rename = "unknownNets")]
    pub unknown_nets: Vec<String>,
    #[serde(rename = "existingWires")]
    pub existing_wires: usize,
    #[serde(rename = "existingVias")]
    pub existing_vias: usize,
    #[serde(rename = "boundaryPoints")]
    pub boundary_points: usize,
    #[serde(rename = "boundarySource")]
    pub boundary_source: String,
    pub layers: Vec<String>,
    /// `REF.PAD (shape)` for every pad written as its bounding rect.
    #[serde(rename = "approximatedPads")]
    pub approximated_pads: Vec<String>,
    pub planes: usize,
    pub keepouts: usize,
    /// Inner layers written as `(type power)`: the router only drops vias into them.
    #[serde(rename = "powerLayers")]
    pub power_layers: Vec<String>,
    /// Inner layers that ARE planes (declared power, or half the board covered by one zone),
    /// whether or not they were reserved.
    #[serde(rename = "planeLayers")]
    pub plane_layers: Vec<String>,
    pub notes: Vec<String>,
}

pub struct Dsn {
    pub text: String,
    pub stats: DsnStats,
    /// The padstack the Default class routes with.
    pub via_padstack: String,
}

struct PadDef {
    number: String,
    kind: String,
    shape: String,
    x: f64,
    y: f64,
    rot: f64,
    w: f64,
    h: f64,
    drill: Option<f64>,
    offset: (f64, f64),
    layers: Vec<String>,
    net: String,
    /// A custom pad's drawn copper, as points in the pad's own frame (before its rotation).
    /// Empty for every other shape. `custom_exact` is false when a primitive kind was not
    /// understood, so the pad is reported as approximated.
    primitives: Vec<(f64, f64)>,
    custom_exact: bool,
}

/// Points of a custom pad's `(primitives ...)`: filled polygons and rects exactly, circles
/// as 16-gons, stroked lines and arcs by their end points widened by half the stroke. The
/// caller takes the convex hull, which is a superset of the real copper (wiki #93: writing
/// only the 1x1 mm anchor let Freerouting route across the MOSFETs' 4.75 mm drain tabs).
fn custom_primitives(pad: &Sx) -> (Vec<(f64, f64)>, bool) {
    let mut pts = Vec::new();
    let mut exact = true;
    let Some(prims) = pad.find("primitives") else { return (pts, true) };
    for p in prims.as_list().into_iter().flatten().skip(1) {
        let width = p.find("width").and_then(|w| atom_f64(w, 1)).unwrap_or(0.0);
        let grow = |pts: &mut Vec<(f64, f64)>, x: f64, y: f64| {
            let r = width / 2.0;
            if r > 0.0 {
                pts.extend([(x - r, y - r), (x + r, y - r), (x + r, y + r), (x - r, y + r)]);
            } else {
                pts.push((x, y));
            }
        };
        match p.tag() {
            Some("gr_poly") => {
                for q in p.find("pts").into_iter().flat_map(|l| l.find_all("xy")) {
                    if let (Some(x), Some(y)) = (atom_f64(q, 1), atom_f64(q, 2)) {
                        grow(&mut pts, x, y);
                    }
                }
            }
            Some("gr_rect") => {
                if let (Some((x1, y1)), Some((x2, y2))) = (xy(p.find("start")), xy(p.find("end"))) {
                    for (x, y) in [(x1, y1), (x2, y1), (x2, y2), (x1, y2)] {
                        grow(&mut pts, x, y);
                    }
                }
            }
            Some("gr_circle") => {
                if let (Some((cx, cy)), Some((ex, ey))) = (xy(p.find("center")), xy(p.find("end"))) {
                    let r = ((ex - cx).powi(2) + (ey - cy).powi(2)).sqrt() + width / 2.0;
                    // Circumscribed 16-gon, so the polygon contains the circle.
                    let rr = r / (std::f64::consts::PI / 16.0).cos();
                    for k in 0..16 {
                        let a = k as f64 * std::f64::consts::PI / 8.0;
                        pts.push((cx + rr * a.cos(), cy + rr * a.sin()));
                    }
                }
            }
            Some("gr_line") | Some("gr_arc") | Some("gr_curve") | Some("gr_bbox") => {
                exact = false;
                for tag in ["start", "mid", "end"] {
                    if let Some((x, y)) = xy(p.find(tag)) {
                        grow(&mut pts, x, y);
                    }
                }
            }
            _ => exact = false,
        }
    }
    (pts, exact)
}

/// Andrew's monotone chain; returns the hull counter-clockwise, no repeated first point.
fn convex_hull(mut pts: Vec<(f64, f64)>) -> Vec<(f64, f64)> {
    pts.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal).then(a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)));
    pts.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-9 && (a.1 - b.1).abs() < 1e-9);
    if pts.len() < 3 {
        return pts;
    }
    let cross = |o: (f64, f64), a: (f64, f64), b: (f64, f64)| (a.0 - o.0) * (b.1 - o.1) - (a.1 - o.1) * (b.0 - o.0);
    let mut lower: Vec<(f64, f64)> = Vec::new();
    for &p in &pts {
        while lower.len() >= 2 && cross(lower[lower.len() - 2], lower[lower.len() - 1], p) <= 0.0 {
            lower.pop();
        }
        lower.push(p);
    }
    let mut upper: Vec<(f64, f64)> = Vec::new();
    for &p in pts.iter().rev() {
        while upper.len() >= 2 && cross(upper[upper.len() - 2], upper[upper.len() - 1], p) <= 0.0 {
            upper.pop();
        }
        upper.push(p);
    }
    lower.pop();
    upper.pop();
    lower.extend(upper);
    lower
}

fn net_name_of(node: Option<&Sx>, board: &Board) -> String {
    let Some(node) = node else { return String::new() };
    let Some(first) = node.atom_at(1) else { return String::new() };
    if let Some(name) = node.atom_at(2) {
        return name.to_string();
    }
    if board.net_format == pcb::NetFormat::Number {
        if let Ok(n) = first.parse::<i64>() {
            return board.nets.iter().find(|x| x.number == Some(n)).map(|x| x.name.clone()).unwrap_or_default();
        }
    }
    first.to_string()
}

fn pad_defs(fp: &Sx, board: &Board) -> Vec<PadDef> {
    fp.find_all("pad")
        .filter_map(|pad| {
            let at = pad.find("at")?;
            let size = pad.find("size");
            let drill = pad.find("drill");
            let (drill_d, offset) = match drill {
                Some(d) => {
                    let dia = if d.atom_at(1) == Some("oval") { match (atom_f64(d, 2), atom_f64(d, 3)) { (Some(a), Some(b)) => Some(a.max(b)), (a, b) => a.or(b) } } else { atom_f64(d, 1) };
                    (dia, xy(d.find("offset")).unwrap_or((0.0, 0.0)))
                }
                None => (None, (0.0, 0.0)),
            };
            Some(PadDef {
                number: pad.atom_at(1).unwrap_or("").to_string(),
                kind: pad.atom_at(2).unwrap_or("").to_string(),
                shape: pad.atom_at(3).unwrap_or("").to_string(),
                x: atom_f64(at, 1)?,
                y: atom_f64(at, 2)?,
                rot: atom_f64(at, 3).unwrap_or(0.0),
                w: size.and_then(|s| atom_f64(s, 1)).unwrap_or(0.0),
                h: size.and_then(|s| atom_f64(s, 2)).unwrap_or(0.0),
                drill: drill_d,
                offset,
                layers: pad.find("layers").and_then(Sx::as_list).map(|l| l.iter().skip(1).filter_map(Sx::as_atom).map(str::to_string).collect()).unwrap_or_default(),
                net: net_name_of(pad.find("net"), board),
                primitives: if pad.atom_at(3) == Some("custom") { custom_primitives(pad).0 } else { Vec::new() },
                custom_exact: pad.atom_at(3) != Some("custom") || custom_primitives(pad).1,
            })
        })
        .collect()
}

/// The copper layers a pad's `(layers ...)` list reaches, in board order.
fn pad_copper(layers: &[String], copper: &[String]) -> Vec<String> {
    let mut out = Vec::new();
    for c in copper {
        let hit = layers.iter().any(|l| l == c || l == "*.Cu" || (l.ends_with("*.Cu") && c.starts_with(&l[..l.len() - 4])) || (l == "F&B.Cu" && (c == "F.Cu" || c == "B.Cu")));
        if hit {
            out.push(c.clone());
        }
    }
    out
}

fn layer_tag(on: &[String], copper: &[String]) -> String {
    if on.len() == copper.len() {
        "A".into()
    } else if on.len() == 1 && on[0] == "F.Cu" {
        "T".into()
    } else if on.len() == 1 && on[0] == "B.Cu" {
        "B".into()
    } else {
        format!("L{}", on.iter().map(|l| copper.iter().position(|c| c == l).unwrap_or(0).to_string()).collect::<Vec<_>>().join("-"))
    }
}

fn fmt_mm_um(v: f64) -> i64 {
    (v * 1000.0).round() as i64
}

/// Write the DSN for `board` (parsed from `text`). `nets`: route only these (None: every
/// net with two or more pins); the other nets' pads and copper stay as obstacles.
pub fn write_dsn(board: &Board, text: &str, name: &str, rules: &Rules, nets: Option<&[String]>) -> Result<Dsn, String> {
    write_dsn_with(board, text, name, rules, nets, true)
}

/// `reserve_planes`: write plane layers as `(type power)` (wiki #93). False lets the router
/// use them for signals, which completes more of a dense board at the cost of cutting
/// through the planes; the caller chooses, the reply says which it got. Measured on the ESC
/// G431 fixture (2026-09-18): reserved left 30 connections unrouted, open left 3, with the
/// same six clearance errors, so kicad_autoroute defaults to open and reports the crossings.
pub fn write_dsn_with(board: &Board, text: &str, name: &str, rules: &Rules, nets: Option<&[String]>, reserve_planes: bool) -> Result<Dsn, String> {
    let tree = parse_sexpr(text).ok_or("empty board text")?;
    let copper = pcb::copper_layers(board);
    if copper.is_empty() {
        return Err("the board has no copper layers".into());
    }
    let mut stats = DsnStats { layers: copper.clone(), ..Default::default() };

    // ---- which nets get routed
    let board_nets: Vec<&str> = board.nets.iter().map(|n| n.name.as_str()).filter(|n| !n.is_empty()).collect();
    let mut unknown: Vec<String> = Vec::new();
    let wanted: Option<Vec<String>> = nets.map(|list| {
        let mut ok = Vec::new();
        for n in list {
            if board_nets.contains(&n.as_str()) {
                if !ok.contains(n) {
                    ok.push(n.clone());
                }
            } else {
                unknown.push(n.clone());
            }
        }
        ok
    });
    stats.unknown_nets = unknown;
    let routed = |net: &str| -> bool {
        !net.is_empty() && wanted.as_ref().map(|w| w.iter().any(|x| x == net)).unwrap_or(true)
    };

    // ---- components, images, padstacks, pins per net
    let mut padstacks: BTreeMap<String, String> = BTreeMap::new();
    let mut images = String::new();
    let mut placement = String::new();
    let mut pins_by_net: BTreeMap<String, Vec<String>> = BTreeMap::new();
    let mut seen_refs: BTreeMap<String, usize> = BTreeMap::new();
    for (index, fp) in tree.find_all("footprint").enumerate() {
        let reference = fp.find_all("property").find(|p| p.atom_at(1) == Some("Reference")).and_then(|p| p.atom_at(2)).unwrap_or("").to_string();
        let mut image_name = if reference.is_empty() { format!("FP{index}") } else { reference.clone() };
        let n = seen_refs.entry(image_name.clone()).or_insert(0);
        *n += 1;
        if *n > 1 {
            image_name = format!("{image_name}#{}", *n);
            stats.notes.push(format!("duplicate reference {reference}: pins named {image_name}"));
        }
        let (fx, fy, rot) = fp.find("at").map(|a| (atom_f64(a, 1).unwrap_or(0.0), atom_f64(a, 2).unwrap_or(0.0), atom_f64(a, 3).unwrap_or(0.0))).unwrap_or((0.0, 0.0, 0.0));
        let pads = pad_defs(fp, board);
        let mut pin_lines = String::new();
        let mut pin_counts: BTreeMap<String, usize> = BTreeMap::new();
        for pad in &pads {
            let on = pad_copper(&pad.layers, &copper);
            let is_npth = pad.kind == "np_thru_hole";
            // A pad with no copper is a hole (obstacle) or a paste/mask-only pad (nothing).
            let (shape, w, h, on) = if on.is_empty() {
                match (is_npth, pad.drill) {
                    (true, Some(d)) if d > 0.0 => ("circle".to_string(), d, d, copper.clone()),
                    _ => continue,
                }
            } else {
                (pad.shape.clone(), pad.w, pad.h, on)
            };
            if w <= 0.0 || h <= 0.0 {
                continue;
            }
            // A custom pad is written as the hull of its anchor and drawn copper: a superset,
            // so the router cannot cross it; exact enough to leave off the approximated list
            // when every primitive was understood.
            let is_custom = shape == "custom" && !pad.primitives.is_empty();
            let exact = shape == "circle" || shape == "rect" || (is_custom && pad.custom_exact);
            if !exact {
                stats.approximated_pads.push(format!("{}.{} ({shape})", reference, pad.number));
            }
            let tag = layer_tag(&on, &copper);
            let rot_key = if shape == "circle" { 0 } else { (pad.rot.rem_euclid(360.0) * 10.0).round() as i64 };
            let (ox, oy) = pad.offset;
            let mut offset_key = if ox == 0.0 && oy == 0.0 { String::new() } else { format!("_o{}x{}", fmt_mm_um(ox), fmt_mm_um(oy)) };
            // Two custom pads with the same anchor size but different copper (a MOSFET's gate
            // and drain) must not share one padstack.
            let hull: Vec<(f64, f64)> = if is_custom {
                let mut all = pad.primitives.clone();
                all.extend([(-w / 2.0, -h / 2.0), (w / 2.0, -h / 2.0), (w / 2.0, h / 2.0), (-w / 2.0, h / 2.0)]);
                convex_hull(all)
            } else {
                Vec::new()
            };
            if is_custom {
                let mut hash: u64 = 1469598103934665603;
                for (x, y) in &hull {
                    for v in [(*x * 1000.0).round() as i64, (*y * 1000.0).round() as i64] {
                        hash ^= v as u64;
                        hash = hash.wrapping_mul(1099511628211);
                    }
                }
                offset_key.push_str(&format!("_p{:08x}", hash as u32));
            }
            let kind_name = match shape.as_str() {
                "circle" => "Round",
                "rect" => "Rect",
                "oval" => "Oval",
                "roundrect" => "RoundRect",
                "trapezoid" => "Trapz",
                "custom" => "Custom",
                other => other,
            };
            let ps_name = if shape == "circle" {
                format!("{kind_name}[{tag}]Pad_{}_um{offset_key}", fmt_mm_um(w))
            } else if rot_key == 0 {
                format!("{kind_name}[{tag}]Pad_{}x{}_um{offset_key}", fmt_mm_um(w), fmt_mm_um(h))
            } else {
                format!("{kind_name}[{tag}]Pad_{}x{}_r{}_um{offset_key}", fmt_mm_um(w), fmt_mm_um(h), rot_key as f64 / 10.0)
            };
            padstacks.entry(ps_name.clone()).or_insert_with(|| {
                let mut s = format!("    (padstack {}\n", q(&ps_name));
                for layer in &on {
                    if shape == "circle" {
                        s.push_str(&format!("      (shape (circle {} {} {} {}))\n", layer, u(w), u(ox), u(-oy)));
                    } else {
                        // Corners in the pad's own orientation (absolute in a board file),
                        // y flipped afterwards. Oval, roundrect and the rest use the rect.
                        let rect = [(-w / 2.0, -h / 2.0), (w / 2.0, -h / 2.0), (w / 2.0, h / 2.0), (-w / 2.0, h / 2.0)];
                        let corners: &[(f64, f64)] = if is_custom { &hull } else { &rect };
                        let pts: Vec<String> = corners.iter().map(|(cx, cy)| {
                            let (rx, ry) = rotate(*cx, *cy, pad.rot);
                            pt(rx + ox, ry + oy)
                        }).collect();
                        s.push_str(&format!("      (shape (polygon {} 0 {}))\n", layer, pts.join(" ")));
                    }
                }
                s.push_str("      (attach off)\n    )\n");
                s
            });
            // Pin id: REF-PAD, made unique when a footprint repeats a pad number.
            let c = pin_counts.entry(pad.number.clone()).or_insert(0);
            *c += 1;
            let base = if pad.number.is_empty() { "NP".to_string() } else { pad.number.clone() };
            let pin_id = if *c == 1 && !pad.number.is_empty() { base } else { format!("{base}{}{}", if pad.number.is_empty() { "" } else { "@" }, *c) };
            let (px, py) = rotate(pad.x, pad.y, rot);
            pin_lines.push_str(&format!("      (pin {} {} {})\n", q(&ps_name), q(&pin_id), pt(px, py)));
            stats.pins += 1;
            if routed(&pad.net) {
                pins_by_net.entry(pad.net.clone()).or_default().push(format!("{image_name}-{pin_id}"));
            }
        }
        images.push_str(&format!("    (image {}\n{}    )\n", q(&image_name), pin_lines));
        placement.push_str(&format!("    (component {}\n      (place {} {} front 0)\n    )\n", q(&image_name), q(&image_name), pt(fx, fy)));
        stats.components += 1;
    }

    // ---- via padstacks: the rules' vias plus every size already on the board
    let default_via = via_name(rules.via_size, rules.via_drill);
    let mut via_names: Vec<String> = vec![default_via.clone()];
    for c in &rules.classes {
        let n = via_name(c.via_size, c.via_drill);
        if !via_names.contains(&n) {
            via_names.push(n);
        }
    }
    let via_ps = |name: &str, size: f64| {
        let mut s = format!("    (padstack {}\n", q(name));
        for layer in &copper {
            s.push_str(&format!("      (shape (circle {} {}))\n", layer, u(size)));
        }
        s.push_str("      (attach off)\n    )\n");
        s
    };
    padstacks.insert(default_via.clone(), via_ps(&default_via, rules.via_size));
    for c in &rules.classes {
        let n = via_name(c.via_size, c.via_drill);
        padstacks.entry(n.clone()).or_insert_with(|| via_ps(&n, c.via_size));
    }

    // ---- existing copper as protected wiring
    let net_of = |num: Option<i64>, name: Option<&str>| -> String {
        match board.net_format {
            pcb::NetFormat::Name => name.unwrap_or("").to_string(),
            pcb::NetFormat::Number => name.map(str::to_string).or_else(|| num.and_then(|n| board.nets.iter().find(|x| x.number == Some(n)).map(|x| x.name.clone()))).unwrap_or_default(),
        }
    };
    let mut wiring = String::new();
    for s in &board.segments {
        let (Some(a), Some(b), Some(w), Some(layer)) = (&s.start, &s.end, s.width, &s.layer) else { continue };
        if !copper.contains(layer) {
            continue;
        }
        let net = net_of(s.net, s.net_name.as_deref());
        let net_part = if net.is_empty() { String::new() } else { format!(" (net {})", q(&net)) };
        wiring.push_str(&format!("    (wire (path {} {} {} {}){} (type protect))\n", layer, u(w), pt(a.x, a.y), pt(b.x, b.y), net_part));
        stats.existing_wires += 1;
    }
    for v in &board.vias {
        let (Some(p), Some(size)) = (&v.position, v.size) else { continue };
        let drill = v.drill.unwrap_or(size / 2.0);
        let name = via_name(size, drill);
        padstacks.entry(name.clone()).or_insert_with(|| via_ps(&name, size));
        let net = net_of(v.net, v.net_name.as_deref());
        let net_part = if net.is_empty() { String::new() } else { format!(" (net {})", q(&net)) };
        wiring.push_str(&format!("    (via {} {}{} (type protect))\n", q(&name), pt(p.x, p.y), net_part));
        stats.existing_vias += 1;
    }

    // ---- zones: copper zones as planes, rule areas as keepouts
    let mut planes = String::new();
    // Largest copper-plane polygon per layer, in mm^2, to decide which inner layers are planes.
    let mut plane_area: BTreeMap<String, f64> = BTreeMap::new();
    for z in tree.find_all("zone") {
        let mut layers: Vec<String> = z.find("layers").and_then(Sx::as_list).map(|l| l.iter().skip(1).filter_map(Sx::as_atom).map(str::to_string).collect()).unwrap_or_default();
        if let Some(l) = z.find("layer").and_then(Sx::value) {
            layers.push(l.to_string());
        }
        let layers: Vec<String> = layers.iter().flat_map(|l| pad_copper(std::slice::from_ref(l), &copper)).collect();
        if layers.is_empty() {
            continue;
        }
        let Some(pts) = z.find("polygon").and_then(|p| p.find("pts")) else { continue };
        let coords: Vec<(f64, f64)> = pts.find_all("xy").filter_map(|p| Some((atom_f64(p, 1)?, atom_f64(p, 2)?))).collect();
        let points: Vec<String> = coords.iter().map(|(x, y)| pt(*x, *y)).collect();
        if points.len() < 3 {
            continue;
        }
        let keepout = z.find("keepout").map(|k| k.find("tracks").and_then(Sx::value) == Some("not_allowed") || k.find("vias").and_then(Sx::value) == Some("not_allowed")).unwrap_or(false);
        let net = z.find("net_name").and_then(Sx::value).map(str::to_string).unwrap_or_else(|| net_name_of(z.find("net"), board));
        for layer in &layers {
            if keepout {
                planes.push_str(&format!("    (keepout \"\" (polygon {} 0 {}))\n", layer, points.join(" ")));
                stats.keepouts += 1;
            } else if !net.is_empty() {
                planes.push_str(&format!("    (plane {} (polygon {} 0 {}))\n", q(&net), layer, points.join(" ")));
                stats.planes += 1;
                let a = polygon_area(&coords);
                let e = plane_area.entry(layer.clone()).or_insert(0.0);
                *e = e.max(a);
            }
        }
    }

    // ---- boundary
    let pieces = edge_polylines(&tree);
    let (boundary, source): (Vec<(f64, f64)>, &str) = match outline_loop(pieces.clone()) {
        Some(l) => (l, "Edge.Cuts"),
        None => {
            let flat: Vec<(f64, f64)> = pieces.iter().flatten().copied().collect();
            let (points, source) = if !flat.is_empty() {
                (flat, "Edge.Cuts bounding box (outline is not one closed loop)")
            } else {
                let pads = pcb::pads(board);
                (pads.iter().map(|p| (p.x, p.y)).collect(), "pad bounding box plus 5 mm (no Edge.Cuts outline)")
            };
            if points.is_empty() {
                return Err("no Edge.Cuts outline and no pads: nothing to route inside".into());
            }
            let margin = if source.starts_with("pad") { 5.0 } else { 0.0 };
            let (mut x1, mut y1, mut x2, mut y2) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
            for (x, y) in points {
                x1 = x1.min(x);
                y1 = y1.min(y);
                x2 = x2.max(x);
                y2 = y2.max(y);
            }
            (vec![(x1 - margin, y1 - margin), (x2 + margin, y1 - margin), (x2 + margin, y2 + margin), (x1 - margin, y2 + margin)], source)
        }
    };
    stats.boundary_points = boundary.len();
    stats.boundary_source = source.to_string();
    if source != "Edge.Cuts" {
        stats.notes.push(format!("boundary: {source}"));
    }
    // Wiki #93: an inner layer that IS a plane must not carry signal traces. Freerouting
    // honours `(type power)` by only dropping vias into it; written as `signal`, it happily
    // routed /nRST and /HSE_OUT across the +3V3 plane. A layer counts as a plane when KiCad
    // declares it `power`, or when one copper zone on it covers at least half the board.
    // Outer layers never do: parts sit there, and a full GND pour on B.Cu is still routable.
    let board_area = polygon_area(&boundary);
    let outer = [copper.first().cloned(), copper.last().cloned()];
    for layer in &copper {
        if outer.iter().any(|o| o.as_deref() == Some(layer.as_str())) {
            continue;
        }
        let declared = board.layers.iter().any(|l| &l.name == layer && l.kind == "power");
        let covered = board_area > 0.0 && plane_area.get(layer).map(|a| *a >= 0.5 * board_area).unwrap_or(false);
        if declared || covered {
            stats.plane_layers.push(layer.clone());
        }
        if reserve_planes && (declared || covered) {
            stats.power_layers.push(layer.clone());
        } else if declared || covered {
            stats.notes.push(format!("{layer} is a plane but reservePlanes is false: signals may cross it"));
        }
    }
    if !stats.power_layers.is_empty() {
        stats.notes.push(format!("plane layers reserved (type power, vias only): {}", stats.power_layers.join(", ")));
    }
    let mut boundary_pts: Vec<String> = boundary.iter().map(|(x, y)| pt(*x, *y)).collect();
    boundary_pts.push(boundary_pts[0].clone());

    // ---- network
    let mut network = String::new();
    let mut class_default_nets: Vec<String> = Vec::new();
    let mut class_nets: BTreeMap<String, Vec<String>> = BTreeMap::new();
    for (net, pins) in &pins_by_net {
        if pins.len() < 2 {
            continue;
        }
        network.push_str(&format!("    (net {}\n      (pins {})\n    )\n", q(net), pins.iter().map(|p| q(p)).collect::<Vec<_>>().join(" ")));
        stats.routed_nets.push(net.clone());
        match rules.classes.iter().find(|c| c.nets.iter().any(|n| n == net)) {
            Some(c) => class_nets.entry(c.name.clone()).or_default().push(net.clone()),
            None => class_default_nets.push(net.clone()),
        }
    }
    stats.nets = board_nets.len();
    // Clearance carries pcbnew's 0.1 um safety margin so KiCad's own DRC never sees an
    // exact-touch case as a violation.
    let clearance_u = |mm: f64| u(mm + 0.0001);
    network.push_str(&format!(
        "    (class kicad_default {}\n      (circuit (use_via {}))\n      (rule (width {}) (clearance {}))\n    )\n",
        class_default_nets.iter().map(|n| q(n)).collect::<Vec<_>>().join(" "),
        q(&default_via),
        u(rules.track_width),
        clearance_u(rules.clearance)
    ));
    for c in &rules.classes {
        let Some(list) = class_nets.get(&c.name) else { continue };
        network.push_str(&format!(
            "    (class {} {}\n      (circuit (use_via {}))\n      (rule (width {}) (clearance {}))\n    )\n",
            q(&c.name),
            list.iter().map(|n| q(n)).collect::<Vec<_>>().join(" "),
            q(&via_name(c.via_size, c.via_drill)),
            u(c.track_width),
            clearance_u(c.clearance)
        ));
    }

    // ---- assemble
    let mut out = String::new();
    out.push_str(&format!("(pcb {}\n", q(&format!("{name}.dsn"))));
    let host_version = tree.find("generator_version").and_then(Sx::value).filter(|v| v.split('.').next().and_then(|m| m.parse::<u32>().ok()).map(|m| m >= 6).unwrap_or(false)).unwrap_or("10.0");
    out.push_str(&format!("  (parser\n    (string_quote \")\n    (space_in_quoted_tokens on)\n    (host_cad \"KiCad's Pcbnew\")\n    (host_version \"{host_version}\")\n  )\n"));
    out.push_str("  (resolution um 10)\n  (unit um)\n");
    out.push_str("  (structure\n");
    for (i, layer) in copper.iter().enumerate() {
        let kind = if stats.power_layers.contains(layer) { "power" } else { "signal" };
        out.push_str(&format!("    (layer {}\n      (type {kind})\n      (property\n        (index {i})\n      )\n    )\n", layer));
    }
    out.push_str(&format!("    (boundary\n      (path pcb 0 {})\n    )\n", boundary_pts.join(" ")));
    out.push_str(&planes);
    for n in &via_names {
        out.push_str(&format!("    (via {})\n", q(n)));
    }
    out.push_str(&format!("    (rule\n      (width {})\n      (clearance {})\n      (clearance {} (type default_smd))\n      (clearance {} (type smd_smd))\n    )\n", u(rules.track_width), clearance_u(rules.clearance), clearance_u(rules.clearance), u(rules.clearance / 4.0)));
    out.push_str("  )\n");
    out.push_str(&format!("  (placement\n{}  )\n", placement));
    out.push_str("  (library\n");
    out.push_str(&images);
    for (_, def) in &padstacks {
        out.push_str(def);
    }
    out.push_str("  )\n");
    out.push_str(&format!("  (network\n{}  )\n", network));
    if !wiring.is_empty() {
        out.push_str(&format!("  (wiring\n{}  )\n", wiring));
    }
    out.push_str(")\n");
    Ok(Dsn { text: out, stats, via_padstack: default_via })
}

// ---------------------------------------------------------------------------
// SES reader
// ---------------------------------------------------------------------------

/// Specctra tokenizer: parentheses, quoted strings (no escapes), bare atoms, and the
/// `(string_quote ")` quirk where the quote character itself is the atom.
pub fn parse_specctra(text: &str) -> Result<Sx, String> {
    let chars: Vec<char> = text.chars().collect();
    let mut i = 0;
    let mut stack: Vec<Vec<Sx>> = vec![Vec::new()];
    let mut quote = '"';
    let mut last_atom_was_string_quote = false;
    while i < chars.len() {
        let c = chars[i];
        if c.is_whitespace() {
            i += 1;
            continue;
        }
        if c == '(' {
            stack.push(Vec::new());
            i += 1;
            last_atom_was_string_quote = false;
            continue;
        }
        if c == ')' {
            let list = stack.pop().ok_or("unbalanced ')'")?;
            let parent = stack.last_mut().ok_or("unbalanced ')'")?;
            parent.push(Sx::List(list));
            i += 1;
            last_atom_was_string_quote = false;
            continue;
        }
        if last_atom_was_string_quote {
            // `(string_quote ")`: the next non-blank character is the atom.
            quote = c;
            stack.last_mut().unwrap().push(Sx::Atom(c.to_string()));
            i += 1;
            last_atom_was_string_quote = false;
            continue;
        }
        if c == quote {
            let mut j = i + 1;
            let mut s = String::new();
            while j < chars.len() && chars[j] != quote {
                s.push(chars[j]);
                j += 1;
            }
            if j >= chars.len() {
                return Err("unterminated quoted string".into());
            }
            stack.last_mut().unwrap().push(Sx::Atom(s));
            i = j + 1;
            continue;
        }
        let mut j = i;
        let mut s = String::new();
        while j < chars.len() && !chars[j].is_whitespace() && chars[j] != '(' && chars[j] != ')' {
            s.push(chars[j]);
            j += 1;
        }
        last_atom_was_string_quote = s == "string_quote";
        stack.last_mut().unwrap().push(Sx::Atom(s));
        i = j;
    }
    if stack.len() != 1 {
        return Err("unbalanced '('".into());
    }
    let mut top = stack.pop().unwrap();
    match top.len() {
        1 => Ok(top.pop().unwrap()),
        0 => Err("empty session".into()),
        _ => Ok(Sx::List(top)),
    }
}

#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct SesWire {
    pub net: String,
    pub layer: String,
    /// mm
    pub width: f64,
    /// mm, KiCad frame (+y down)
    pub points: Vec<[f64; 2]>,
}

#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct SesVia {
    pub net: String,
    pub padstack: String,
    pub at: [f64; 2],
    /// mm, from the session's `library_out` or the padstack name.
    pub size: Option<f64>,
    pub drill: Option<f64>,
}

#[derive(Clone, Debug, Default, PartialEq, Serialize)]
pub struct Session {
    pub wires: Vec<SesWire>,
    pub vias: Vec<SesVia>,
    pub unit: String,
    pub resolution: f64,
    pub nets: Vec<String>,
}

fn unit_mm(unit: &str) -> Option<f64> {
    Some(match unit {
        "um" => 0.001,
        "mm" => 1.0,
        "cm" => 10.0,
        "mil" => 0.0254,
        "inch" | "in" => 25.4,
        _ => return None,
    })
}

/// Walk every list in the tree, depth first.
fn walk<'a>(node: &'a Sx, f: &mut dyn FnMut(&'a Sx)) {
    if let Some(list) = node.as_list() {
        f(node);
        for c in list {
            walk(c, f);
        }
    }
}

pub fn read_ses(text: &str) -> Result<Session, String> {
    let tree = parse_specctra(text)?;
    if tree.tag() != Some("session") {
        return Err(format!("not a Specctra session file (top-level tag {:?})", tree.tag()));
    }
    let routes = tree.find("routes").ok_or("session has no (routes) section")?;
    // The routes section's own resolution wins; fall back to placement or the top level.
    let res_node = routes.find("resolution").or_else(|| tree.find("placement").and_then(|p| p.find("resolution"))).or_else(|| tree.find("resolution"));
    let (unit, resolution) = match res_node {
        Some(r) => (r.atom_at(1).unwrap_or("um").to_string(), atom_f64(r, 2).unwrap_or(1.0)),
        None => ("um".to_string(), 10.0),
    };
    let per_unit = unit_mm(&unit).ok_or_else(|| format!("unknown session unit {unit}"))?;
    let to_mm = |v: f64| pcb::round6(v / resolution * per_unit) + 0.0;
    // Via sizes from library_out (circle diameter) and drills from the padstack names.
    let mut via_sizes: BTreeMap<String, f64> = BTreeMap::new();
    if let Some(lib) = routes.find("library_out") {
        for ps in lib.find_all("padstack") {
            let Some(name) = ps.atom_at(1) else { continue };
            let mut best: Option<f64> = None;
            for shape in ps.find_all("shape") {
                if let Some(circle) = shape.find("circle") {
                    if let Some(d) = atom_f64(circle, 2) {
                        best = Some(best.map_or(d, |b: f64| b.max(d)));
                    }
                }
            }
            if let Some(d) = best {
                via_sizes.insert(name.to_string(), to_mm(d));
            }
        }
    }
    let mut session = Session { unit: unit.clone(), resolution, ..Default::default() };
    let network = routes.find("network_out").ok_or("session has no (network_out) section")?;
    for net_node in network.find_all("net") {
        let net = net_node.atom_at(1).unwrap_or("").to_string();
        if !session.nets.contains(&net) {
            session.nets.push(net.clone());
        }
        let mut items: Vec<&Sx> = Vec::new();
        walk(net_node, &mut |n| {
            if matches!(n.tag(), Some("wire") | Some("via")) {
                items.push(n);
            }
        });
        for item in items {
            let item_net = item.find("net").and_then(Sx::value).map(str::to_string).unwrap_or_else(|| net.clone());
            match item.tag() {
                Some("wire") => {
                    let Some(path) = item.find("path").or_else(|| item.find("polyline_path")) else { continue };
                    let Some(layer) = path.atom_at(1) else { continue };
                    let Some(width) = atom_f64(path, 2) else { continue };
                    let nums: Vec<f64> = path.as_list().map(|l| l.iter().skip(3).filter_map(Sx::as_atom).filter_map(|a| a.parse::<f64>().ok()).collect()).unwrap_or_default();
                    let mut points: Vec<[f64; 2]> = Vec::new();
                    for pair in nums.chunks(2) {
                        if pair.len() == 2 {
                            let p = [to_mm(pair[0]), to_mm(-pair[1])];
                            if points.last() != Some(&p) {
                                points.push(p);
                            }
                        }
                    }
                    if points.len() >= 2 {
                        session.wires.push(SesWire { net: item_net, layer: layer.to_string(), width: to_mm(width), points });
                    }
                }
                Some("via") => {
                    let Some(padstack) = item.atom_at(1) else { continue };
                    let (Some(x), Some(y)) = (atom_f64(item, 2), atom_f64(item, 3)) else { continue };
                    let dims = via_dims(padstack);
                    session.vias.push(SesVia {
                        net: item_net,
                        padstack: padstack.to_string(),
                        at: [to_mm(x), to_mm(-y)],
                        size: via_sizes.get(padstack).copied().or(dims.map(|d| d.0)),
                        drill: dims.map(|d| d.1),
                    });
                }
                _ => {}
            }
        }
    }
    Ok(session)
}

/// The copper of one net as the applier lays it: `PlanSegment`/`PlanVia` in mm.
#[derive(Clone, Debug, Default, Serialize)]
pub struct NetCopper {
    pub net: String,
    pub segments: Vec<PlanSegment>,
    pub vias: Vec<PlanVia>,
    /// Session items identical to copper already on the board (protected wiring echoed back).
    #[serde(rename = "skippedExisting")]
    pub skipped_existing: usize,
    /// segments widened to the net class minimum because the router returned them thinner (wiki #93)
    pub widened: usize,
}

fn same_xy(a: [f64; 2], b: [f64; 2]) -> bool {
    (a[0] - b[0]).abs() < 1e-4 && (a[1] - b[1]).abs() < 1e-4
}

/// Session wires and vias as per-net copper, KiCad frame, deduplicated against the copper
/// the board already had, restricted to `nets` when given. Vias span the outer copper pair
/// (`F.Cu`, `B.Cu`) as `pcb::write_board` requires.
pub fn copper_for(session: &Session, board: &Board, nets: Option<&[String]>, rules: &Rules) -> Vec<NetCopper> {
    let copper = pcb::copper_layers(board);
    let mut out: Vec<NetCopper> = Vec::new();
    fn entry(out: &mut Vec<NetCopper>, net: &str) -> usize {
        match out.iter().position(|c| c.net == net) {
            Some(i) => i,
            None => {
                out.push(NetCopper { net: net.to_string(), ..Default::default() });
                out.len() - 1
            }
        }
    }
    let wanted = |net: &str| !net.is_empty() && board.nets.iter().any(|n| n.name == net) && nets.map(|w| w.iter().any(|x| x == net)).unwrap_or(true);
    let existing_seg = |a: [f64; 2], b: [f64; 2], layer: &str| -> bool {
        board.segments.iter().any(|s| {
            let (Some(sa), Some(sb)) = (&s.start, &s.end) else { return false };
            s.layer.as_deref() == Some(layer) && ((same_xy([sa.x, sa.y], a) && same_xy([sb.x, sb.y], b)) || (same_xy([sa.x, sa.y], b) && same_xy([sb.x, sb.y], a)))
        })
    };
    let existing_via = |at: [f64; 2]| -> bool { board.vias.iter().any(|v| v.position.as_ref().map(|p| same_xy([p.x, p.y], at)).unwrap_or(false)) };
    for w in &session.wires {
        if !wanted(&w.net) || !copper.contains(&w.layer) {
            continue;
        }
        let i = entry(&mut out, &w.net);
        for pair in w.points.windows(2) {
            let (a, b) = (pair[0], pair[1]);
            if same_xy(a, b) {
                continue;
            }
            if existing_seg(a, b, &w.layer) {
                out[i].skipped_existing += 1;
                continue;
            }
            // wiki #93: Freerouting hands back widths a hair under what the DSN asked for
            // (0.1874 where the class says 0.2), and KiCad's DRC then calls every one of them a
            // track_width error. The class width is already floored at the board minimum, so a
            // returned wire never lands thinner than the board allows; we count what we widened.
            let (_, class_w, _, _) = rules.class_for(&w.net);
            let width = if w.width + 1e-6 < class_w { out[i].widened += 1; class_w } else { w.width };
            out[i].segments.push(PlanSegment { start: a, end: b, layer: w.layer.clone(), width });
        }
    }
    for v in &session.vias {
        if !wanted(&v.net) {
            continue;
        }
        let i = entry(&mut out, &v.net);
        if existing_via(v.at) {
            out[i].skipped_existing += 1;
            continue;
        }
        let (_, _, rs, rd) = rules.class_for(&v.net);
        let size = v.size.unwrap_or(rs);
        let mut drill = v.drill.unwrap_or(rd);
        if drill >= size {
            drill = pcb::round6(size / 2.0);
        }
        out[i].vias.push(PlanVia { at: v.at, layers: ["F.Cu".into(), "B.Cu".into()], size, drill });
    }
    out.retain(|c| !c.segments.is_empty() || !c.vias.is_empty());
    out
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    fn fixture() -> (String, Board) {
        let text = fixture_text();
        let board = pcb::parse_pcb_text(&text, "live-routing.kicad_pcb").unwrap();
        (text, board)
    }

    #[test]
    fn fixture_dsn_has_boundary_layers_pins_nets_and_rules() {
        let (text, board) = fixture();
        let rules = Rules::default();
        let dsn = write_dsn(&board, &text, "live-routing", &rules, None).unwrap();
        let t = &dsn.text;
        assert!(t.starts_with("(pcb live-routing.dsn\n  (parser\n    (string_quote \")\n    (space_in_quoted_tokens on)\n    (host_cad \"KiCad's Pcbnew\")\n    (host_version \"10.0\")"), "{t}");
        assert!(t.contains("(resolution um 10)"));
        assert!(t.contains("(layer F.Cu\n      (type signal)\n      (property\n        (index 0)"));
        assert!(t.contains("(layer B.Cu\n      (type signal)\n      (property\n        (index 1)"));
        // Edge.Cuts rect (98,78)-(152,122): y flipped, closed.
        assert!(t.contains("(boundary\n      (path pcb 0 98000 -78000 152000 -78000 152000 -122000 98000 -122000 98000 -78000)"), "{t}");
        assert_eq!(dsn.stats.boundary_source, "Edge.Cuts");
        assert_eq!(dsn.stats.components, 3);
        assert_eq!(dsn.stats.pins, 13);
        assert_eq!(dsn.stats.routed_nets, (1..=6).map(|i| format!("NET_{i}")).collect::<Vec<_>>());
        // 2 mm through-hole circle on both layers, pins in board frame relative to the origin.
        assert!(t.contains("(padstack Round[A]Pad_2000_um\n      (shape (circle F.Cu 2000 0 0))\n      (shape (circle B.Cu 2000 0 0))\n      (attach off)"));
        assert!(t.contains("(image J1\n      (pin Round[A]Pad_2000_um 1 0 0)\n      (pin Round[A]Pad_2000_um 2 0 -5000)"));
        assert!(t.contains("(component J1\n      (place J1 105000 -85000 front 0)"));
        assert!(t.contains("(net NET_6\n      (pins J1-6 J2-6 J3-1)"));
        assert!(t.contains("(via Via[0-1]_800:400_um)"));
        assert!(t.contains("(class kicad_default NET_1 NET_2 NET_3 NET_4 NET_5 NET_6\n      (circuit (use_via Via[0-1]_800:400_um))\n      (rule (width 250) (clearance 200.1))"));
        assert!(!t.contains("(wiring"), "no copper yet, no wiring section");
        assert!(dsn.stats.approximated_pads.is_empty());
        assert_eq!(dsn.via_padstack, "Via[0-1]_800:400_um");
    }

    #[test]
    fn net_subset_leaves_other_pads_as_obstacles_and_keeps_existing_copper() {
        let (text, board) = fixture();
        let net = pcb::resolve_net(&board, Some(&serde_json::json!("NET_1"))).unwrap();
        let seg = pcb::segment_sexpr((105.0, 85.0), (112.0, 85.0), 0.25, "F.Cu", &net);
        let via = pcb::via_sexpr((112.0, 85.0), 0.6, 0.3, ("F.Cu", "B.Cu"), &net);
        let text = pcb::splice(&text, &[seg, via]);
        let board = pcb::parse_pcb_text(&text, "x").unwrap();
        let dsn = write_dsn(&board, &text, "x", &Rules::default(), Some(&["NET_2".into(), "NOPE".into()])).unwrap();
        assert_eq!(dsn.stats.routed_nets, vec!["NET_2"]);
        assert_eq!(dsn.stats.unknown_nets, vec!["NOPE"]);
        assert!(dsn.text.contains("(net NET_2\n      (pins J1-2 J2-2)"));
        assert!(!dsn.text.contains("(net NET_1\n"));
        assert_eq!(dsn.stats.pins, 13, "every pad is still an obstacle");
        assert!(dsn.text.contains("(wire (path F.Cu 250 105000 -85000 112000 -85000) (net NET_1) (type protect))"));
        assert!(dsn.text.contains("(via Via[0-1]_600:300_um 112000 -85000 (net NET_1) (type protect))"));
        assert!(dsn.text.contains("(padstack Via[0-1]_600:300_um\n"));
        assert_eq!((dsn.stats.existing_wires, dsn.stats.existing_vias), (1, 1));
    }

    #[test]
    fn rect_pads_rotate_and_ovals_are_declared_approximate() {
        let text = r#"(kicad_pcb (version 20260206) (generator "pcbnew")
  (layers (0 "F.Cu" signal) (2 "B.Cu" signal) (25 "Edge.Cuts" user))
  (gr_line (start 0 0) (end 30 0) (layer "Edge.Cuts"))
  (gr_line (start 30 0) (end 30 20) (layer "Edge.Cuts"))
  (gr_arc (start 30 20) (mid 15 25) (end 0 20) (layer "Edge.Cuts"))
  (gr_line (start 0 20) (end 0 0) (layer "Edge.Cuts"))
  (footprint "X:R1" (layer "F.Cu") (at 10 10 90) (property "Reference" "R1")
    (pad "1" smd rect (at -1 0 90) (size 1 2) (layers "F.Cu" "F.Paste" "F.Mask") (net "A"))
    (pad "2" smd oval (at 1 0 90) (size 1 2) (layers "F.Cu") (net "B")))
  (footprint "X:U1" (layer "B.Cu") (at 20 10) (property "Reference" "U1")
    (pad "1" smd roundrect (at 0 0) (size 1 1) (layers "B.Cu") (net "A"))
    (pad "2" thru_hole circle (at 2 0) (size 1.5 1.5) (drill 0.8) (layers "*.Cu" "*.Mask") (net "B"))
    (pad "" np_thru_hole circle (at 4 0) (size 2 2) (drill 2) (layers "*.Cu" "*.Mask")))
  (zone (net "A") (net_name "A") (layer "B.Cu") (polygon (pts (xy 0 0) (xy 5 0) (xy 5 5))))
  (zone (net "") (net_name "") (layers "F.Cu" "B.Cu") (keepout (tracks not_allowed) (vias allowed)) (polygon (pts (xy 20 0) (xy 25 0) (xy 25 5))))
)"#;
        let board = pcb::parse_pcb_text(text, "x").unwrap();
        let dsn = write_dsn(&board, text, "x", &Rules::default(), None).unwrap();
        let t = &dsn.text;
        // Pin 1 of R1: local (-1,0) rotated 90 -> (0, 1) in a y-down frame -> DSN y = -1 mm.
        assert!(t.contains("(pin Rect[T]Pad_1000x2000_r90_um 1 0 -1000)"), "{t}");
        assert!(t.contains("(pin Oval[T]Pad_1000x2000_r90_um 2 0 1000)"), "{t}");
        // A 1x2 rect at 90 degrees is a 2x1 polygon: corners at x = +-1 mm, y = +-0.5 mm.
        assert!(t.contains("(padstack Rect[T]Pad_1000x2000_r90_um\n      (shape (polygon F.Cu 0 -1000 -500 -1000 500 1000 500 1000 -500))"), "{t}");
        assert!(t.contains("(pin Round[A]Pad_2000_um NP1 4000 0)"), "an unnamed hole still gets a pin id: {t}");
        assert!(t.contains("(padstack RoundRect[B]Pad_1000x1000_um\n      (shape (polygon B.Cu 0 -500 500 500 500 500 -500 -500 -500))"), "{t}");
        assert!(t.contains("(padstack Round[A]Pad_2000_um\n      (shape (circle F.Cu 2000 0 0))"), "NPTH hole is an obstacle on every layer: {t}");
        assert_eq!(dsn.stats.approximated_pads, vec!["R1.2 (oval)", "U1.1 (roundrect)"]);
        assert!(t.contains("(net A\n      (pins R1-1 U1-1)"));
        assert!(t.contains("(net B\n      (pins R1-2 U1-2)"));
        assert!(t.contains("(plane A (polygon B.Cu 0 0 0 5000 0 5000 -5000))"));
        assert!(t.contains("(keepout \"\" (polygon F.Cu 0 20000 0 25000 0 25000 -5000))"));
        assert!(t.contains("(keepout \"\" (polygon B.Cu 0 20000 0 25000 0 25000 -5000))"));
        assert_eq!((dsn.stats.planes, dsn.stats.keepouts), (1, 2));
        // Three lines and an arc chain into one loop; the arc became segments.
        assert_eq!(dsn.stats.boundary_source, "Edge.Cuts");
        assert!(dsn.stats.boundary_points > 10, "{}", dsn.stats.boundary_points);
        assert!(t.contains("(place U1 20000 -10000 front 0)"));
    }

    // Wiki #93: Freerouting routed /nRST and /HSE_OUT across the ESC's +3V3 plane because
    // every layer was declared `signal`. A plane layer is now `power` (vias only).
    #[test]
    fn inner_plane_layers_are_reserved_as_power_and_outer_pours_are_not() {
        let text = r#"(kicad_pcb (version 20260206) (generator "pcbnew")
  (layers (0 "F.Cu" signal) (4 "In1.Cu" signal) (6 "In2.Cu" signal) (8 "In3.Cu" power) (2 "B.Cu" signal) (25 "Edge.Cuts" user))
  (gr_rect (start 0 0) (end 40 20) (layer "Edge.Cuts"))
  (footprint "X:R1" (layer "F.Cu") (at 10 10) (property "Reference" "R1")
    (pad "1" smd rect (at -1 0) (size 1 1) (layers "F.Cu") (net "GND"))
    (pad "2" smd rect (at 1 0) (size 1 1) (layers "F.Cu") (net "SIG")))
  (footprint "X:R2" (layer "F.Cu") (at 30 10) (property "Reference" "R2")
    (pad "1" smd rect (at -1 0) (size 1 1) (layers "F.Cu") (net "GND"))
    (pad "2" smd rect (at 1 0) (size 1 1) (layers "F.Cu") (net "SIG")))
  (zone (net "GND") (net_name "GND") (layer "In1.Cu") (polygon (pts (xy 0 0) (xy 40 0) (xy 40 20) (xy 0 20))))
  (zone (net "GND") (net_name "GND") (layer "In2.Cu") (polygon (pts (xy 0 0) (xy 10 0) (xy 10 10) (xy 0 10))))
  (zone (net "GND") (net_name "GND") (layer "B.Cu") (polygon (pts (xy 0 0) (xy 40 0) (xy 40 20) (xy 0 20))))
)"#;
        let board = pcb::parse_pcb_text(text, "x").unwrap();
        let dsn = write_dsn(&board, text, "x", &Rules::default(), None).unwrap();
        let t = &dsn.text;
        let kind_of = |layer: &str| {
            let at = t.find(&format!("(layer {layer}\n")).unwrap_or_else(|| panic!("{layer} missing: {t}"));
            t[at..].lines().nth(1).unwrap().trim().to_string()
        };
        // Full-board zone on an inner layer: a plane, so vias only.
        assert_eq!(kind_of("In1.Cu"), "(type power)");
        // A quarter-board zone is a local pour, not a plane: still routable.
        assert_eq!(kind_of("In2.Cu"), "(type signal)");
        // KiCad itself declares In3 a power layer.
        assert_eq!(kind_of("In3.Cu"), "(type power)");
        // A full GND pour on an OUTER layer never reserves it: parts sit there.
        assert_eq!(kind_of("B.Cu"), "(type signal)");
        assert_eq!(kind_of("F.Cu"), "(type signal)");
        assert_eq!(dsn.stats.power_layers, vec!["In1.Cu", "In3.Cu"]);
        assert!(dsn.stats.notes.iter().any(|n| n.contains("In1.Cu, In3.Cu")), "{:?}", dsn.stats.notes);
    }

    // Wiki #93: the ESC's MOSFET drain pad is a 1x1 mm anchor plus a 4.75 x 4.61 mm drawn
    // polygon. Writing only the anchor let Freerouting route /DRV_SHA straight across +VBAT.
    #[test]
    fn custom_pads_are_written_as_the_hull_of_their_real_copper() {
        let text = r#"(kicad_pcb (version 20260206) (generator "pcbnew")
  (layers (0 "F.Cu" signal) (2 "B.Cu" signal) (25 "Edge.Cuts" user))
  (gr_rect (start 0 0) (end 40 20) (layer "Edge.Cuts"))
  (footprint "X:Q1" (layer "F.Cu") (at 10 10) (property "Reference" "Q1")
    (pad "1" smd custom (at -1.05 0) (size 1 1) (layers "F.Cu") (net "G")
      (options (clearance outline) (anchor rect))
      (primitives (gr_rect (start -0.5 -0.3) (end 0.2 0.3) (width 0) (fill yes))))
    (pad "5" smd custom (at 1.05 0) (size 1 1) (layers "F.Cu") (net "D")
      (options (clearance outline) (anchor rect))
      (primitives (gr_poly (pts (xy -2.375 -2.305) (xy 2.375 -2.305) (xy 2.375 -1.37) (xy 1.375 -1.37) (xy 1.375 1.37) (xy 2.375 1.37) (xy 2.375 2.305) (xy -2.375 2.305)) (width 0) (fill yes)))))
  (footprint "X:R1" (layer "F.Cu") (at 30 10) (property "Reference" "R1")
    (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net "D"))
    (pad "2" smd rect (at 2 0) (size 1 1) (layers "F.Cu") (net "G")))
)"#;
        let board = pcb::parse_pcb_text(text, "x").unwrap();
        let dsn = write_dsn(&board, text, "x", &Rules::default(), None).unwrap();
        let t = &dsn.text;
        // The drain pad's padstack reaches the polygon's full extent (+-2375 um in x, +-2305
        // in y), not the 1x1 mm anchor's +-500.
        let drain = t.lines().find(|l| l.contains("(pin Custom") && l.trim_end().ends_with(" 5 1050 0)")).unwrap_or_else(|| panic!("{t}"));
        let ps = drain.split_whitespace().nth(1).unwrap();
        let at = t.find(&format!("(padstack {ps}\n")).unwrap();
        let shape = t[at..].lines().nth(1).unwrap();
        assert!(shape.contains("-2375") && shape.contains("2375") && shape.contains("2305"), "{shape}");
        // Gate and drain share an anchor size but not copper, so not a padstack.
        let gate = t.lines().find(|l| l.contains("(pin Custom") && l.contains(" 1 -1050 0)")).unwrap_or_else(|| panic!("{t}"));
        assert_ne!(gate.split_whitespace().nth(1), drain.split_whitespace().nth(1), "{t}");
        // Every primitive was understood, so neither pad is reported as approximated.
        assert!(dsn.stats.approximated_pads.is_empty(), "{:?}", dsn.stats.approximated_pads);
        // The hull is a superset: every polygon vertex lies on or inside it.
        let hull = convex_hull(vec![(-2.375, -2.305), (2.375, -2.305), (2.375, -1.37), (1.375, -1.37), (1.375, 1.37), (2.375, 1.37), (2.375, 2.305), (-2.375, 2.305)]);
        assert_eq!(hull.len(), 4, "the notch is filled in: {hull:?}");
    }

    #[test]
    fn no_outline_falls_back_to_pads_with_a_note() {
        let text = r#"(kicad_pcb (version 20260206) (layers (0 "F.Cu" signal) (2 "B.Cu" signal))
  (footprint "X:R1" (layer "F.Cu") (at 10 10) (property "Reference" "R1")
    (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net "A"))))"#;
        let board = pcb::parse_pcb_text(text, "x").unwrap();
        let dsn = write_dsn(&board, text, "x", &Rules::default(), None).unwrap();
        assert!(dsn.stats.boundary_source.starts_with("pad bounding box"));
        assert!(dsn.text.contains("(path pcb 0 5000 -5000 15000 -5000 15000 -15000 5000 -15000 5000 -5000)"), "{}", dsn.text);
        assert_eq!(dsn.stats.notes.len(), 1);
    }

    #[test]
    fn quoting_matches_pcbnew() {
        assert_eq!(q("NET_1"), "NET_1");
        assert_eq!(q("/SDA"), "/SDA");
        assert_eq!(q("Net-(R1-Pad1)"), "\"Net-(R1-Pad1)\"");
        assert_eq!(q("a b"), "\"a b\"");
        assert_eq!(q(""), "\"\"");
        assert_eq!(via_dims("Via[0-1]_800:400_um"), Some((0.8, 0.4)));
        assert_eq!(via_dims("Round[A]Pad_2000_um"), None);
    }

    #[test]
    fn rules_come_from_the_project_file_or_defaults() {
        let dir = std::env::temp_dir().join(format!("adom-dsn-rules-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let (text, board) = fixture();
        let pcb_path = dir.join("b.kicad_pcb");
        std::fs::write(&pcb_path, &text).unwrap();
        let r = rules_for(&pcb_path, &board);
        assert_eq!(r, Rules::default());
        std::fs::write(dir.join("b.kicad_pro"), r#"{
          "board": {"design_settings": {"rules": {"min_clearance": 0.15, "min_track_width": 0.3}}},
          "net_settings": {
            "classes": [
              {"name": "Default", "clearance": 0.1, "track_width": 0.2, "via_diameter": 0.7, "via_drill": 0.35},
              {"name": "Power", "clearance": 0.3, "track_width": 0.5, "via_diameter": 1.0, "via_drill": 0.5},
              {"name": "Unused", "clearance": 0.3, "track_width": 0.5}
            ],
            "netclass_assignments": {"NET_6": "Power"},
            "netclass_patterns": [{"netclass": "Power", "pattern": "NET_5"}]
          }}"#).unwrap();
        let r = rules_for(&pcb_path, &board);
        assert_eq!((r.clearance, r.track_width, r.via_size, r.via_drill), (0.15, 0.3, 0.7, 0.35));
        assert_eq!(r.classes.len(), 1);
        assert_eq!(r.classes[0].name, "Power");
        assert_eq!(r.classes[0].nets, vec!["NET_5", "NET_6"]);
        assert_eq!(r.class_for("NET_6"), (0.3, 0.5, 1.0, 0.5));
        assert_eq!(r.class_for("NET_1"), (0.15, 0.3, 0.7, 0.35));
        let dsn = write_dsn(&board, &text, "b", &r, None).unwrap();
        assert!(dsn.text.contains("(class Power NET_5 NET_6\n      (circuit (use_via Via[0-1]_1000:500_um))\n      (rule (width 500) (clearance 300.1))"), "{}", dsn.text);
        assert!(dsn.text.contains("(class kicad_default NET_1 NET_2 NET_3 NET_4\n"));
        let _ = std::fs::remove_dir_all(&dir);
    }

    const SES: &str = r#"(session "live-routing.ses"
  (base_design "live-routing.dsn")
  (placement
    (resolution um 10)
    (component J1
      (place J1 105000 -85000 front 0)
    )
  )
  (was_is
  )
  (routes
    (resolution um 10)
    (parser
      (string_quote ")
      (space_in_quoted_tokens on)
      (host_cad "Adom KiCad Bridge")
      (host_version "1.0.0")
    )
    (library_out
      (padstack "Via[0-1]_800:400_um"
        (shape
          (circle F.Cu 8000 0 0)
        )
        (shape
          (circle B.Cu 8000 0 0)
        )
        (attach off)
      )
    )
    (network_out
      (net NET_3
        (wire
          (path F.Cu 2500
            1050000 -950000
            1120000 -950000
          )
        )
        (via "Via[0-1]_800:400_um" 1120000 -950000
        )
        (wire
          (path B.Cu 2500
            1120000 -950000
            1380000 -950000
            1380000 -950000
          )
        )
      )
      (net "Net-(R1-Pad1)"
        (wire (path F.Cu 2000 0 0 10000 -10000) (type protect))
      )
    )
  )
)"#;

    #[test]
    fn reads_freerouting_session_wires_and_vias_back_to_mm() {
        let s = read_ses(SES).unwrap();
        assert_eq!((s.unit.as_str(), s.resolution), ("um", 10.0));
        assert_eq!(s.nets, vec!["NET_3", "Net-(R1-Pad1)"]);
        assert_eq!(s.wires.len(), 3);
        assert_eq!(s.wires[0], SesWire { net: "NET_3".into(), layer: "F.Cu".into(), width: 0.25, points: vec![[105.0, 95.0], [112.0, 95.0]] });
        assert_eq!(s.wires[1].points, vec![[112.0, 95.0], [138.0, 95.0]], "duplicate point dropped");
        assert_eq!(s.wires[2], SesWire { net: "Net-(R1-Pad1)".into(), layer: "F.Cu".into(), width: 0.2, points: vec![[0.0, 0.0], [1.0, 1.0]] });
        assert_eq!(s.vias, vec![SesVia { net: "NET_3".into(), padstack: "Via[0-1]_800:400_um".into(), at: [112.0, 95.0], size: Some(0.8), drill: Some(0.4) }]);
        let (_, board) = fixture();
        let copper = copper_for(&s, &board, None, &Rules::default());
        assert_eq!(copper.len(), 1, "the unknown net's wire is dropped by the net filter: {copper:?}");
        assert_eq!(copper[0].net, "NET_3");
        assert_eq!(copper[0].segments.len(), 2);
        assert_eq!(copper[0].vias[0].layers, ["F.Cu", "B.Cu"]);
        assert_eq!((copper[0].vias[0].size, copper[0].vias[0].drill), (0.8, 0.4));
        // Copper already on the board is skipped, and a net subset filters.
        let net = pcb::resolve_net(&board, Some(&serde_json::json!("NET_3"))).unwrap();
        let text = pcb::splice(&fixture_text(), &[pcb::segment_sexpr((112.0, 95.0), (105.0, 95.0), 0.25, "F.Cu", &net)]);
        let board2 = pcb::parse_pcb_text(&text, "x").unwrap();
        let copper = copper_for(&s, &board2, Some(&["NET_3".into()]), &Rules::default());
        assert_eq!(copper[0].segments.len(), 1);
        assert_eq!(copper[0].skipped_existing, 1);
        assert!(copper_for(&s, &board2, Some(&["NET_1".into()]), &Rules::default()).is_empty());
    }

    #[test]
    fn router_wire_thinner_than_the_class_is_widened_to_it() {
        // wiki #93: Freerouting answers 0.1874 mm where the DSN asked for the class width, and
        // KiCad's DRC calls every such segment a track_width error. The applier widens it back.
        let (_, board) = fixture();
        let thin = Session {
            unit: "um".into(),
            resolution: 10.0,
            nets: vec!["NET_3".into()],
            wires: vec![
                SesWire { net: "NET_3".into(), layer: "F.Cu".into(), width: 0.1874, points: vec![[105.0, 95.0], [112.0, 95.0]] },
                SesWire { net: "NET_3".into(), layer: "F.Cu".into(), width: 0.25, points: vec![[112.0, 95.0], [138.0, 95.0]] },
            ],
            ..Default::default()
        };
        let copper = copper_for(&thin, &board, None, &Rules::default());
        assert_eq!(copper.len(), 1);
        assert_eq!(copper[0].widened, 1, "only the thin one is widened: {copper:?}");
        assert_eq!(copper[0].segments[0].width, Rules::default().track_width);
        assert_eq!(copper[0].segments[1].width, 0.25, "a wire at the class width is left alone");
    }

    #[test]
    fn specctra_tokenizer_handles_string_quote_and_bad_input() {
        let sx = parse_specctra("(a (string_quote \") (b \"x y\") c)").unwrap();
        assert_eq!(sx.find("string_quote").unwrap().value(), Some("\""));
        assert_eq!(sx.find("b").unwrap().value(), Some("x y"));
        assert!(parse_specctra("(a (b)").is_err());
        assert!(parse_specctra("(a))").is_err());
        assert!(read_ses("(pcb x)").is_err());
        assert!(read_ses("(session x (routes (resolution parsec 1) (network_out)))").is_err());
    }
}