master
Colby Knox Merge PR #315: kicad-lane: 1.0.69, length tuning, mouse-bite panels, silkscreen prep, component classes, build/test boa… df9e892 13d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647
//! A manufacturing panel from one board: the board stepped into rows x columns on a V-score
//! grid (the copies abut), each copy's nets renamed `B<n>-<net>` so the copies stay separate nets,
//! every copy's own outline replaced by one panel outline with rails above and below, V-cut lines on
//! Cmts.User along every seam, three tooling holes and three fiducials on the rails.
//!
//! With `tabs` the copies stand `gap_mm` apart inside a frame with rails on all four sides, and come
//! apart at mouse-bite tabs: each board's edges are broken at its tabs on Edge.Cuts (the rout path),
//! the tab sides cross the gap, and every tab carries five 0.6 mm holes on the board side. The tab
//! plan and JLCPCB's rules for both separations are the Altium bridge's (bridge-rs/src/panel.rs,
//! 0.2.77; jlcpcb.com/blog/v-cut-panelization-standards and /blog/mouse-bite-panelization-guide, read
//! 2026-10-23), so both tools cut a panel the same way.
//!
//! Only a rectangular board (its Edge.Cuts a rectangle) is panelised here.

use std::collections::HashMap;

use serde_json::{json, Value};

use crate::schematic::{new_uuid, Document, Node};

pub struct Opts {
    pub rows: usize,
    pub cols: usize,
    pub rail_mm: f64,
    pub holes: bool,
    pub fiducials: bool,
    /// mouse-bite tabs instead of V-score
    pub tabs: bool,
    /// the gap between boards with tabs (JLCPCB: 1.2 mm or more)
    pub gap_mm: f64,
}

/// A mouse-bite tab: five 0.6 mm holes 0.4 mm apart edge to edge (JLCPCB: 5 to 8 a set, 0.35 to 0.4 mm).
const BITE_HOLE: f64 = 0.6;
const BITE_PITCH: f64 = 1.0;
const BITES: i64 = 5;
const TAB: f64 = 5.0;

fn r3(v: f64) -> f64 {
    (v * 1000.0).round() / 1000.0 + 0.0
}

fn rule(name: &str, ok: Option<bool>, detail: String) -> Value {
    json!({"rule": name, "ok": ok, "detail": detail})
}

/// Where the tabs go along an edge of length `l`: their centres from the edge's start.
fn tab_centres(l: f64) -> Vec<f64> {
    if l < TAB + 1.0 {
        return vec![];
    }
    let n = if l < 15.0 { 1 } else { ((l / 55.0).ceil() as usize).max(2) };
    (0..n).map(|k| l * (k as f64 + 0.5) / n as f64).collect()
}

/// The span [a, b] with the `cuts` spans taken out.
fn broken(a: f64, b: f64, cuts: &[(f64, f64)]) -> Vec<(f64, f64)> {
    let mut out = Vec::new();
    let mut s = a;
    let mut cs = cuts.to_vec();
    cs.sort_by(|p, q| p.0.partial_cmp(&q.0).unwrap_or(std::cmp::Ordering::Equal));
    for (c0, c1) in cs {
        if c0 > s {
            out.push((s, c0.min(b)));
        }
        s = s.max(c1);
    }
    if s < b {
        out.push((s, b));
    }
    out
}

/// Mouse-bite tabs in panel mm from the panel's corner: the rout path (each board's edges broken at
/// its tabs, the tab sides across the gap, the rails' inner edge broken where tabs meet it), the
/// holes, and the tab count. Boards stand `rail + gap` in from the panel's edge and `gap` apart.
pub fn tab_plan(w: f64, h: f64, rows: usize, cols: usize, gap: f64, rail: f64) -> (Vec<[f64; 4]>, Vec<[f64; 2]>, usize) {
    let m = rail + if rail > 0.0 { gap } else { 0.0 };
    let pw = 2.0 * m + cols as f64 * w + (cols as f64 - 1.0) * gap;
    let ph = 2.0 * m + rows as f64 * h + (rows as f64 - 1.0) * gap;
    let (mut lines, mut holes, mut tabs) = (Vec::new(), Vec::new(), 0usize);
    let mut rail_cuts: [Vec<(f64, f64)>; 4] = Default::default();
    let off = BITE_HOLE / 2.0 - BITE_HOLE / 3.0;
    for r in 0..rows {
        for c in 0..cols {
            let (bx, by) = (m + c as f64 * (w + gap), m + r as f64 * (h + gap));
            // (side, outer, along x?, fixed coordinate, start, length, outward sign)
            for (side, outer, horiz, fixed, start, len, out) in [
                (0usize, r == 0, true, by, bx, w, -1.0), (1, r == rows - 1, true, by + h, bx, w, 1.0),
                (2, c == 0, false, bx, by, h, -1.0), (3, c == cols - 1, false, bx + w, by, h, 1.0)] {
                if outer && rail <= 0.0 {
                    continue; // the panel's own edge: the frame covers it
                }
                let spans: Vec<(f64, f64)> = tab_centres(len).iter().map(|t| (start + t - TAB / 2.0, start + t + TAB / 2.0)).collect();
                for (a, z) in broken(start, start + len, &spans) {
                    lines.push(if horiz { [a, fixed, z, fixed] } else { [fixed, a, fixed, z] });
                }
                let far = fixed + out * gap;
                for &(a, z) in &spans {
                    tabs += 1;
                    // the tab's two sides across the gap: drawn from the far-side edges (the neighbour's near
                    // edges are the same lines) and from every edge that faces a rail
                    if side == 1 || side == 3 || outer {
                        for p in [a, z] {
                            lines.push(if horiz { [p, fixed, p, far] } else { [fixed, p, far, p] });
                        }
                    }
                    if outer {
                        rail_cuts[side].push((a, z));
                    }
                    let mid = (a + z) / 2.0;
                    for k in 0..BITES {
                        let along = mid + (k - BITES / 2) as f64 * BITE_PITCH;
                        let across = fixed + out * off;
                        holes.push(if horiz { [r3(along), r3(across)] } else { [r3(across), r3(along)] });
                    }
                }
            }
        }
    }
    if rail > 0.0 {
        let (ix0, iy0, ix1, iy1) = (rail, rail, pw - rail, ph - rail);
        for (side, fixed, a, z, horiz) in [(0usize, iy0, ix0, ix1, true), (1, iy1, ix0, ix1, true), (2, ix0, iy0, iy1, false), (3, ix1, iy0, iy1, false)] {
            for (p, q) in broken(a, z, &rail_cuts[side]) {
                lines.push(if horiz { [p, fixed, q, fixed] } else { [fixed, p, fixed, q] });
            }
        }
    }
    (lines.into_iter().map(|l| [r3(l[0]), r3(l[1]), r3(l[2]), r3(l[3])]).collect(), holes, tabs)
}

/// JLCPCB's mouse-bite rules against this panel.
pub fn jlc_tabs(gap: f64, pw: f64, ph: f64, clear: Option<f64>) -> Vec<Value> {
    let mut out = vec![
        rule("gap between boards 1.2 mm or more (1.6 or 2.0 typical)", Some(gap >= 1.2), format!("{gap} mm")),
        rule("0.6 mm holes, 5 to a set, 0.35 to 0.4 mm apart", Some(true), "five 0.6 mm holes at 1.0 mm pitch (0.4 mm between them), centred a third of a hole into the board".into()),
        rule("at least 2 sets an edge, one every 50 to 60 mm", Some(true), "2 tabs on edges of 15 mm and longer, one more each 55 mm; 1 on shorter edges".into()),
        rule("no mouse bites on the rail side", Some(true), "holes on the board side only".into()),
        rule("longest panel side under 250 mm (recommended)", Some(pw.max(ph) < 250.0), format!("{} mm", r3(pw.max(ph)))),
    ];
    if let Some(c) = clear {
        out.push(rule("copper clear of the bitten edge", Some(c >= 0.5), format!("nearest copper {c} mm from the board edge (mouse bites leave about 0.5 mm of rough edge)")));
    }
    out
}

/// JLCPCB's V-cut rules against this panel. `ok: null` is a note, not a pass or a fail.
#[allow(clippy::too_many_arguments)]
pub fn jlc_vscore(pw: f64, ph: f64, thick: Option<f64>, clear: Option<f64>, cuts: (usize, usize), rail: f64, w: f64, h: f64) -> Vec<Value> {
    let mut out = Vec::new();
    let size_ok = pw >= 70.0 && ph >= 70.0 && pw <= 475.0 && ph <= 475.0;
    out.push(rule("panel 70 x 70 mm to 475 x 475 mm", Some(size_ok), if size_ok { format!("{} x {} mm", r3(pw), r3(ph)) } else {
        format!("{} x {} mm: add rows or columns, or widen the rails (railMm), to reach 70 mm each way", r3(pw), r3(ph)) }));
    out.push(match thick {
        Some(t) => rule("board 0.6 mm thick or more", Some(t >= 0.6), format!("{} mm (V-cut is not offered on 0.4 mm boards)", r3(t))),
        None => rule("board 0.6 mm thick or more", None, "the stack-up's thickness could not be read".into()),
    });
    out.push(match clear {
        Some(c) => rule("copper 0.4 mm or more from every cut (1.0 mm recommended, 2.0 mm by tall parts)", Some(c >= 0.4),
            format!("nearest copper {c} mm from the board edge{}", if (0.4..1.0).contains(&c) { ": passes, under the 1.0 mm JLCPCB recommends" } else { "" })),
        None => rule("copper 0.4 mm or more from every cut", None, "no copper on the board".into()),
    });
    out.push(rule("at most 25 cuts each way", Some(cuts.0 <= 25 && cuts.1 <= 25), format!("{} vertical, {} horizontal", cuts.0, cuts.1)));
    let span = if rail > 0.0 { w.min(h).min(rail) } else { w.min(h) };
    out.push(rule("parallel cuts 2 mm apart or more", Some(span >= 2.0), format!("closest pair {} mm apart", r3(span))));
    let need = if thick.map(|t| t <= 0.8).unwrap_or(false) { 5.0 } else { 3.0 };
    out.push(if rail > 0.0 { rule("rails 3 mm wide or more (5 mm on 0.8 mm boards and thinner)", Some(rail >= need), format!("{rail} mm, {need} mm needed")) }
             else { rule("rails", None, "none: fine for bare boards, assembly needs rails (JLCPCB adds tooling holes and fiducials on them)".into()) });
    if w.min(h) < 15.0 {
        out.push(rule("pieces under 15 mm on a side cost extra", None, format!("{} x {} mm boards", r3(w), r3(h))));
    }
    out.push(rule("V-cut is not offered with Economic Assembly", None, "order Standard Assembly for an assembled V-cut panel".into()));
    out
}

/// The nearest copper (tracks, arcs, vias, pads, pours) to the board's outline box, in mm, with where.
fn edge_clearance(model: &Value, bb: [f64; 4]) -> Option<(f64, Value)> {
    let f = |v: &Value| v.as_f64().unwrap_or(0.0);
    let copper = |l: &Value| l.as_str().map(|s| s.ends_with(".Cu")).unwrap_or(false);
    let arr = |k: &str| model[k].as_array().cloned().unwrap_or_default();
    let xy = |p: &Value| match p { Value::Array(a) => (a.first().map(f).unwrap_or(0.0), a.get(1).map(f).unwrap_or(0.0)), _ => (f(&p["x"]), f(&p["y"])) };
    let mut pts: Vec<((f64, f64), f64, Value)> = Vec::new();
    for t in arr("tracks").iter().filter(|t| copper(&t["layer"])) {
        for p in [&t["start"], &t["end"]] {
            pts.push((xy(p), f(&t["width"]) / 2.0, json!({"track": t["net"], "layer": t["layer"]})));
        }
    }
    for v in arr("vias") {
        pts.push((xy(&v["position"]), f(&v["size"]) / 2.0, json!({"via": v["net"]})));
    }
    for p in arr("pads") {
        pts.push(((f(&p["x"]), f(&p["y"])), f(&p["sizeX"]).max(f(&p["sizeY"])) / 2.0, json!({"pad": format!("{}.{}", p["ref"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or(""))})));
    }
    for g in arr("polygons").iter().filter(|g| copper(&g["layer"])) {
        for p in g["points"].as_array().cloned().unwrap_or_default() {
            pts.push((xy(&p), 0.0, json!({"pour": g["net"], "layer": g["layer"]})));
        }
    }
    let [x0, y0, x1, y1] = bb;
    pts.into_iter().filter(|((x, y), _, _)| *x >= x0 && *x <= x1 && *y >= y0 && *y <= y1)
        .map(|((x, y), half, mut what)| {
            what["at"] = json!([r3(x), r3(y)]);
            (r3((x - x0).min(x1 - x).min(y - y0).min(y1 - y) - half), what)
        })
        .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
}

const HEADER: &[&str] = &["version", "generator", "generator_version", "general", "paper", "title_block", "layers", "setup", "property", "net", "embedded_fonts", "embedded_files"];

fn fmt(v: f64) -> String {
    let r = (v * 1e6).round() / 1e6 + 0.0;
    let s = format!("{r}");
    s
}

/// The board's outline as a rectangle [x0, y0, x1, y1], or why it is not one.
pub fn rect_outline(root: &Node) -> Result<[f64; 4], String> {
    let edges: Vec<&Node> = root.children().iter().filter(|c| c.head().map(|h| h.starts_with("gr_")).unwrap_or(false) && c.child_value("layer").as_deref() == Some("Edge.Cuts")).collect();
    if edges.is_empty() {
        return Err("the board has no Edge.Cuts outline".into());
    }
    let mut segs: Vec<((f64, f64), (f64, f64))> = Vec::new();
    for e in &edges {
        let pt = |t: &str| e.child(t).and_then(|c| Some((c.num(0)?, c.num(1)?)));
        match e.head() {
            Some("gr_line") => segs.push((pt("start").ok_or("line without start")?, pt("end").ok_or("line without end")?)),
            Some("gr_rect") => {
                let (a, b) = (pt("start").ok_or("rect without start")?, pt("end").ok_or("rect without end")?);
                segs.extend([(a, (b.0, a.1)), ((b.0, a.1), b), (b, (a.0, b.1)), ((a.0, b.1), a)]);
            }
            Some("gr_poly") => {
                let pts: Vec<(f64, f64)> = e.child("pts").map(|p| p.children().iter().filter(|c| c.head() == Some("xy")).filter_map(|c| Some((c.num(0)?, c.num(1)?))).collect()).unwrap_or_default();
                for i in 0..pts.len() {
                    segs.push((pts[i], pts[(i + 1) % pts.len()]));
                }
            }
            Some(other) => return Err(format!("the outline has a {} (curves need tabs and mouse bites, not V-score)", other.trim_start_matches("gr_"))),
            None => {}
        }
    }
    let xs = segs.iter().flat_map(|(a, b)| [a.0, b.0]);
    let ys = segs.iter().flat_map(|(a, b)| [a.1, b.1]);
    let (x0, x1) = (xs.clone().fold(f64::MAX, f64::min), xs.fold(f64::MIN, f64::max));
    let (y0, y1) = (ys.clone().fold(f64::MAX, f64::min), ys.fold(f64::MIN, f64::max));
    let near = |a: f64, b: f64| (a - b).abs() < 1e-4;
    for (a, b) in &segs {
        let on_h = near(a.1, b.1) && (near(a.1, y0) || near(a.1, y1));
        let on_v = near(a.0, b.0) && (near(a.0, x0) || near(a.0, x1));
        if !(on_h || on_v) {
            return Err(format!("the outline is not a rectangle (an edge from ({}, {}) to ({}, {}) lies inside it); a shaped board needs tabs and mouse bites", fmt(a.0), fmt(a.1), fmt(b.0), fmt(b.1)));
        }
    }
    let perimeter: f64 = segs.iter().map(|(a, b)| (b.0 - a.0).abs() + (b.1 - a.1).abs()).sum();
    if !near(perimeter, 2.0 * ((x1 - x0) + (y1 - y0))) {
        return Err("the outline's edges do not close one rectangle".into());
    }
    Ok([x0, y0, x1, y1])
}

/// Every coordinate node inside an item (not a footprint), moved by (dx, dy).
fn shift(n: &mut Node, dx: f64, dy: f64) {
    if matches!(n.head(), Some("start" | "end" | "mid" | "center" | "at" | "xy")) {
        if let (Some(x), Some(y)) = (n.num(0), n.num(1)) {
            n.set_arg(0, Node::atom(fmt(x + dx)));
            n.set_arg(1, Node::atom(fmt(y + dy)));
        }
        return;
    }
    if let Some(kids) = n.children_mut() {
        for k in kids.iter_mut() {
            if k.is_list() {
                shift(k, dx, dy);
            }
        }
    }
}

/// Every `(uuid ..)`/`(tstamp ..)` in a copy made fresh (ids must be unique across the panel).
fn fresh_ids(n: &mut Node) {
    if matches!(n.head(), Some("uuid" | "tstamp")) {
        n.set_arg(0, Node::quoted(&new_uuid()));
        return;
    }
    if let Some(kids) = n.children_mut() {
        for k in kids.iter_mut() {
            if k.is_list() {
                fresh_ids(k);
            }
        }
    }
}

/// Nets of one copy renamed: `(net "X")` (and a zone's `(net_name "X")`) to the copy's name;
/// numbered nets `(net 3)` / `(net 3 "X")` to the copy's number and name.
fn rename_nets(n: &mut Node, copy: usize, numbers: &HashMap<i64, i64>, names: &HashMap<String, String>) {
    match n.head() {
        Some("net") => {
            let a0 = n.arg(0);
            match a0.as_deref().and_then(|s| s.parse::<i64>().ok()) {
                Some(code) => {
                    if let Some(new) = numbers.get(&code) {
                        n.set_arg(0, Node::atom(new.to_string()));
                    }
                    if let Some(name) = n.arg(1).and_then(|x| names.get(&x).cloned()) {
                        n.set_arg(1, Node::quoted(&name));
                    }
                }
                None => {
                    if let Some(name) = a0.and_then(|x| names.get(&x).cloned()) {
                        n.set_arg(0, Node::quoted(&name));
                    }
                }
            }
            return;
        }
        Some("net_name") => {
            if let Some(name) = n.arg(0).and_then(|x| names.get(&x).cloned()) {
                n.set_arg(0, Node::quoted(&name));
            }
            return;
        }
        _ => {}
    }
    let _ = copy;
    if let Some(kids) = n.children_mut() {
        for k in kids.iter_mut() {
            if k.is_list() {
                rename_nets(k, copy, numbers, names);
            }
        }
    }
}

fn parse(text: &str) -> Result<Node, String> {
    crate::schematic::parse_node(text.trim())
}

fn tool_footprint(reference: &str, x: f64, y: f64, fiducial: bool, hole_mm: f64) -> Result<Node, String> {
    let pad = if fiducial {
        format!("(pad \"1\" smd circle (at 0 0) (size 1 1) (layers \"F.Cu\" \"F.Mask\") (solder_mask_margin 0.5) (uuid \"{}\"))", new_uuid())
    } else {
        format!("(pad \"\" np_thru_hole circle (at 0 0) (size {h} {h}) (drill {h}) (layers \"*.Cu\" \"*.Mask\") (uuid \"{}\"))", new_uuid(), h = fmt(hole_mm))
    };
    let name = if fiducial { "Fiducial_1mm_Mask2mm" } else { "ToolingHole" };
    parse(&format!(
        "(footprint \"Panel:{name}\" (layer \"F.Cu\") (uuid \"{}\") (at {} {}) \
         (property \"Reference\" \"{reference}\" (at 0 -2 0) (layer \"F.SilkS\") (hide yes) (uuid \"{}\") (effects (font (size 1 1) (thickness 0.15)))) \
         (property \"Value\" \"{name}\" (at 0 2 0) (layer \"F.Fab\") (hide yes) (uuid \"{}\") (effects (font (size 1 1) (thickness 0.15)))) \
         (attr {}exclude_from_pos_files exclude_from_bom) {pad})",
        new_uuid(), fmt(x), fmt(y), new_uuid(), new_uuid(), if fiducial { "smd " } else { "" }
    ))
}

fn line(layer: &str, a: (f64, f64), b: (f64, f64), width: f64) -> Result<Node, String> {
    parse(&format!("(gr_line (start {} {}) (end {} {}) (stroke (width {}) (type solid)) (layer \"{layer}\") (uuid \"{}\"))", fmt(a.0), fmt(a.1), fmt(b.0), fmt(b.1), fmt(width), new_uuid()))
}

/// The panel's board text and a report.
pub fn panelize(text: &str, o: &Opts) -> Result<(String, Value), String> {
    if o.rows == 0 || o.cols == 0 || o.rows * o.cols > 200 {
        return Err("rows and cols must be at least 1, at most 200 boards".into());
    }
    let mut doc = Document::parse(text)?;
    if doc.root.head() != Some("kicad_pcb") {
        return Err("not a board".into());
    }
    let [x0, y0, x1, y1] = rect_outline(&doc.root)?;
    let (w, h) = (x1 - x0, y1 - y0);
    if o.tabs && o.gap_mm < 1.2 {
        return Err("mouse bites need a gap of 1.2 mm or more between boards (JLCPCB: 1.6 or 2.0 typical)".into());
    }
    let gap = if o.tabs { o.gap_mm } else { 0.0 };
    // what JLCPCB's rules need from the board itself: its thickness and its copper nearest the edge
    let thick = crate::board_model::stackup(text).ok().and_then(|s| s["totalUm"].as_f64()).map(|u| u / 1000.0);
    let clear = crate::board_model::build(text, None, &crate::board_model::Opts { y_up: false }).ok().and_then(|m| edge_clearance(&m, [x0, y0, x1, y1]));
    let kids: Vec<Node> = doc.root.children().to_vec();
    // numbered nets: the declaration table
    let decls: Vec<(i64, String)> = kids.iter().filter(|c| c.head() == Some("net")).filter_map(|c| Some((c.arg(0)?.parse().ok()?, c.arg(1).unwrap_or_default()))).collect();
    let span = decls.iter().map(|(c, _)| *c).max().unwrap_or(0) + 1;
    // every net name in use (numbered or by name), for the name-style boards
    let mut all_names: Vec<String> = decls.iter().map(|(_, n)| n.clone()).collect();
    fn collect_names(n: &Node, out: &mut Vec<String>) {
        if matches!(n.head(), Some("net" | "net_name")) {
            let name = if n.head() == Some("net_name") { n.arg(0) } else if n.arg(0).and_then(|s| s.parse::<i64>().ok()).is_some() { n.arg(1) } else { n.arg(0) };
            if let Some(x) = name {
                if !out.contains(&x) {
                    out.push(x);
                }
            }
            return;
        }
        for k in n.children().iter().filter(|k| k.is_list()) {
            collect_names(k, out);
        }
    }
    for k in kids.iter().filter(|c| !c.head().map(|h| HEADER.contains(&h)).unwrap_or(true)) {
        collect_names(k, &mut all_names);
    }
    let items: Vec<&Node> = kids.iter().filter(|c| c.is_list() && !c.head().map(|h| HEADER.contains(&h)).unwrap_or(true)).collect();
    let mut out_items: Vec<Node> = Vec::new();
    let mut new_decls: Vec<Node> = Vec::new();
    let mut n = 0usize;
    for r in 0..o.rows {
        for c in 0..o.cols {
            n += 1;
            let (dx, dy) = (c as f64 * (w + gap), r as f64 * (h + gap));
            let names: HashMap<String, String> = all_names.iter().filter(|x| !x.is_empty()).map(|x| (x.clone(), format!("B{n}-{x}"))).collect();
            let numbers: HashMap<i64, i64> = decls.iter().map(|(code, _)| (*code, if *code == 0 { 0 } else { code + span * (n as i64 - 1) })).collect();
            for (code, name) in &decls {
                if *code == 0 {
                    continue;
                }
                new_decls.push(parse(&format!("(net {} \"{}\")", numbers[code], names.get(name).cloned().unwrap_or_else(|| name.clone()).replace('"', "\\\"")))?);
            }
            for it in &items {
                let head = it.head().unwrap_or("");
                if head == "group" || (head.starts_with("gr_") && it.child_value("layer").as_deref() == Some("Edge.Cuts")) {
                    continue;
                }
                let mut cp = (*it).clone();
                if head == "footprint" {
                    if let Some(at) = cp.child_mut("at") {
                        shift(at, dx, dy);
                    }
                } else {
                    if head == "zone" {
                        // fills are recomputed by KiCad (kicad_refill_zones or kicad-cli --refill-zones)
                        if let Some(k) = cp.children_mut() {
                            k.retain(|c| !matches!(c.head(), Some("filled_polygon" | "fill_segments")));
                        }
                    }
                    shift(&mut cp, dx, dy);
                }
                fresh_ids(&mut cp);
                rename_nets(&mut cp, n, &numbers, &names);
                out_items.push(cp);
            }
        }
    }
    // the panel: outline with rails, the separation (V-cuts or tabs), tooling holes, fiducials
    let rail = o.rail_mm.max(0.0);
    let mut panel_items: Vec<Node> = Vec::new();
    let report;
    if o.tabs {
        let m = rail + if rail > 0.0 { gap } else { 0.0 };
        let (pw, ph) = (2.0 * m + o.cols as f64 * w + (o.cols as f64 - 1.0) * gap, 2.0 * m + o.rows as f64 * h + (o.rows as f64 - 1.0) * gap);
        let (ox, oy) = (x0 - m, y0 - m);
        for (a, b) in [((ox, oy), (ox + pw, oy)), ((ox + pw, oy), (ox + pw, oy + ph)), ((ox + pw, oy + ph), (ox, oy + ph)), ((ox, oy + ph), (ox, oy))] {
            panel_items.push(line("Edge.Cuts", a, b, 0.1)?);
        }
        let (rout, bites, tabs) = tab_plan(w, h, o.rows, o.cols, gap, rail);
        for l in &rout {
            panel_items.push(line("Edge.Cuts", (ox + l[0], oy + l[1]), (ox + l[2], oy + l[3]), 0.1)?);
        }
        for (i, hpt) in bites.iter().enumerate() {
            panel_items.push(tool_footprint(&format!("MB{}", i + 1), ox + hpt[0], oy + hpt[1], false, BITE_HOLE)?);
        }
        let (mut holes, mut fids) = (0, 0);
        if rail >= 5.0 {
            // 3 tooling holes (the fourth corner bare so the panel loads one way) and 3 fiducials 5 mm in from them
            let k = rail / 2.0;
            if o.holes {
                for (i, (x, y)) in [(k, k), (pw - k, k), (k, ph - k)].into_iter().enumerate() {
                    panel_items.push(tool_footprint(&format!("TH{}", i + 1), ox + x, oy + y, false, 2.0)?);
                    holes += 1;
                }
            }
            if o.fiducials {
                for (i, (x, y)) in [(k + 5.0, k), (pw - k - 5.0, k), (k + 5.0, ph - k)].into_iter().enumerate() {
                    panel_items.push(tool_footprint(&format!("FID{}", i + 1), ox + x, oy + y, true, 0.0)?);
                    fids += 1;
                }
            }
        }
        let rules = jlc_tabs(gap, pw, ph, clear.as_ref().map(|c| c.0));
        let fits = rules.iter().all(|r| r["ok"] != false);
        let boards_at: Vec<Value> = (0..o.rows).flat_map(|r| (0..o.cols).map(move |c| (r, c))).map(|(r, c)| json!([r3(m + c as f64 * (w + gap)), r3(m + r as f64 * (h + gap))])).collect();
        report = json!({"panelSizeMm": [r3(pw), r3(ph)], "panelMm": [r3(pw), r3(ph)], "boardsAt": boards_at, "toolingHoles": holes, "fiducials": fids, "gapMm": gap,
            "separation": {"kind": "tabs", "tabs": tabs, "holes": bites.len(), "routLines": rout.len(), "layer": "Edge.Cuts"}, "jlcpcb": {"rules": rules, "fits": fits}});
    } else {
        let (pw, ph) = (o.cols as f64 * w, o.rows as f64 * h);
        let (top, bottom) = (y0 - rail, y0 + ph + rail);
        for (a, b) in [((x0, top), (x0 + pw, top)), ((x0 + pw, top), (x0 + pw, bottom)), ((x0 + pw, bottom), (x0, bottom)), ((x0, bottom), (x0, top))] {
            panel_items.push(line("Edge.Cuts", a, b, 0.1)?);
        }
        let mut vcuts: usize = 0;
        for r in 0..=o.rows {
            let y = y0 + r as f64 * h;
            if rail <= 0.0 && (r == 0 || r == o.rows) {
                continue;
            }
            panel_items.push(line("Cmts.User", (x0 - 2.0, y), (x0 + pw + 2.0, y), 0.1)?);
            vcuts += 1;
        }
        for c in 1..o.cols {
            let x = x0 + c as f64 * w;
            panel_items.push(line("Cmts.User", (x, top - 2.0), (x, bottom + 2.0), 0.1)?);
            vcuts += 1;
        }
        panel_items.push(parse(&format!("(gr_text \"V-CUT\" (at {} {} 0) (layer \"Cmts.User\") (uuid \"{}\") (effects (font (size 1 1) (thickness 0.15)) (justify left)))", fmt(x0 + pw + 3.0), fmt(y0), new_uuid()))?);
        let (mut holes, mut fids) = (0, 0);
        if rail >= 3.0 {
            let hole = if rail >= 6.0 { 3.0 } else { 2.0 };
            let inset = (rail / 2.0).max(2.5);
            let mid_top = top + rail / 2.0;
            let mid_bot = bottom - rail / 2.0;
            if o.holes {
                for (i, (x, y)) in [(x0 + inset, mid_top), (x0 + pw - inset, mid_top), (x0 + inset, mid_bot)].into_iter().enumerate() {
                    panel_items.push(tool_footprint(&format!("TH{}", i + 1), x, y, false, hole)?);
                    holes += 1;
                }
            }
            if o.fiducials && pw > 4.0 * inset + 6.0 {
                for (i, (x, y)) in [(x0 + 3.0 * inset, mid_top), (x0 + pw - 3.0 * inset, mid_top), (x0 + 3.0 * inset, mid_bot)].into_iter().enumerate() {
                    panel_items.push(tool_footprint(&format!("FID{}", i + 1), x, y, true, 0.0)?);
                    fids += 1;
                }
            }
        }
        let rules = jlc_vscore(pw, ph + 2.0 * rail, thick, clear.as_ref().map(|c| c.0), (o.cols.saturating_sub(1), vcuts.saturating_sub(o.cols.saturating_sub(1))), rail, w, h);
        let fits = rules.iter().all(|r| r["ok"] != false);
        let boards_at: Vec<Value> = (0..o.rows).flat_map(|r| (0..o.cols).map(move |c| (r, c))).map(|(r, c)| json!([r3(c as f64 * w), r3(rail + r as f64 * h)])).collect();
        report = json!({"panelSizeMm": [pw, ph + 2.0 * rail], "panelMm": [r3(pw), r3(ph + 2.0 * rail)], "boardsAt": boards_at, "toolingHoles": holes, "fiducials": fids,
            "separation": {"kind": "vscore", "cutsX": o.cols.saturating_sub(1), "cutsY": vcuts.saturating_sub(o.cols.saturating_sub(1)), "layer": "Cmts.User"}, "vCuts": vcuts, "vCutLayer": "Cmts.User",
            "jlcpcb": {"rules": rules, "fits": fits}});
    }
    // rebuild: the header, the renamed net table, the copies, the panel
    let mut root_kids: Vec<Node> = Vec::new();
    let lead = kids.iter().find(|c| c.is_list()).map(|c| c.lead.clone()).unwrap_or_else(|| "\n\t".into());
    let mut decl_done = false;
    for k in &kids {
        let head = k.head();
        if head == Some("net") {
            if !decl_done {
                root_kids.push(k.clone()); // net 0
                for mut d in new_decls.drain(..) {
                    d.lead = lead.clone();
                    root_kids.push(d);
                }
                decl_done = true;
            }
            continue;
        }
        if !k.is_list() || head.map(|h| HEADER.contains(&h)).unwrap_or(true) {
            root_kids.push(k.clone());
        }
    }
    for mut it in out_items.into_iter().chain(panel_items) {
        it.lead = lead.clone();
        root_kids.push(it);
    }
    if let Some(r) = doc.root.children_mut() {
        *r = root_kids;
    }
    let text_out = doc.to_text();
    let mut out = json!({
        "boards": o.rows * o.cols, "rows": o.rows, "cols": o.cols, "boardSizeMm": [w, h], "boardMm": [r3(w), r3(h)], "railMm": rail,
        "nearestCopperToEdge": clear.map(|(mm, at)| json!({"mm": mm, "at": at})),
        "netsRenamed": all_names.iter().filter(|x| !x.is_empty()).count() * o.rows * o.cols,
    });
    for (k, v) in report.as_object().cloned().unwrap_or_default() {
        out[k] = v;
    }
    Ok((text_out, out))
}

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

    fn fixture(name: &str) -> String {
        std::fs::read_to_string(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(name)).unwrap()
    }

    #[test]
    fn tab_plan_follows_jlcpcb() {
        // the Altium bridge's case: two 40 x 20 boards, 2 mm apart, 5 mm rails
        let (lines, holes, tabs) = tab_plan(40.0, 20.0, 1, 2, 2.0, 5.0);
        assert_eq!(tabs, 16, "4 edges each with 2 tabs, two boards");
        assert_eq!(holes.len(), 16 * 5);
        // the tab between the boards is bridged: no rout line runs along x 47..49 at the tab's height
        assert!(!lines.iter().any(|l| l[1] == l[3] && l[0].min(l[2]) < 48.0 && l[0].max(l[2]) > 48.0 && (l[1] - 7.0 - 5.0).abs() < 1.0), "{lines:?}");
        assert_eq!(tab_centres(40.0), vec![10.0, 30.0]);
        assert_eq!(tab_centres(120.0).len(), 3);
        assert!(tab_centres(5.0).is_empty());
    }

    #[test]
    fn a_mouse_bite_panel_spaces_the_copies_and_routes_round_them() {
        let t = fixture("tests/fixtures/konnect-route/route-fixture.kicad_pcb");
        let (out, rep) = panelize(&t, &Opts { rows: 2, cols: 2, rail_mm: 5.0, holes: true, fiducials: true, tabs: true, gap_mm: 2.0 }).unwrap();
        assert_eq!(rep["separation"]["kind"], "tabs");
        let bites = rep["separation"]["holes"].as_u64().unwrap() as usize;
        assert!(bites > 0 && bites % 5 == 0, "{rep}");
        let doc = Document::parse(&out).unwrap();
        let fps = doc.root.children().iter().filter(|c| c.head() == Some("footprint")).count();
        let src = Document::parse(&t).unwrap().root.children().iter().filter(|c| c.head() == Some("footprint")).count();
        assert_eq!(fps, src * 4 + 6 + bites, "four copies, tooling holes and fiducials, and every bite hole");
        // the frame plus the rout path, all on Edge.Cuts
        let edges = doc.root.children().iter().filter(|c| c.child_value("layer").as_deref() == Some("Edge.Cuts")).count();
        assert_eq!(edges, 4 + rep["separation"]["routLines"].as_u64().unwrap() as usize);
        // the second column stands one board width plus the gap from the first
        let w = rep["boardMm"][0].as_f64().unwrap();
        assert!((rep["boardsAt"][1][0].as_f64().unwrap() - rep["boardsAt"][0][0].as_f64().unwrap() - (w + 2.0)).abs() < 1e-6, "{rep}");
        assert!(rep["jlcpcb"]["rules"].as_array().unwrap().iter().any(|r| r["rule"].as_str().unwrap().starts_with("gap between boards")));
        assert!(panelize(&t, &Opts { rows: 1, cols: 2, rail_mm: 5.0, holes: true, fiducials: true, tabs: true, gap_mm: 0.5 }).unwrap_err().contains("1.2 mm"));
    }

    #[test]
    fn a_rectangular_board_panelises_with_separate_nets() {
        let t = fixture("tests/fixtures/konnect-route/route-fixture.kicad_pcb");
        let (out, rep) = panelize(&t, &Opts { rows: 2, cols: 3, rail_mm: 5.0, holes: true, fiducials: true, tabs: false, gap_mm: 0.0 }).unwrap();
        assert_eq!(rep["boards"], 6);
        let doc = Document::parse(&out).unwrap();
        let fps = doc.root.children().iter().filter(|c| c.head() == Some("footprint")).count();
        let src = Document::parse(&t).unwrap().root.children().iter().filter(|c| c.head() == Some("footprint")).count();
        assert_eq!(fps, src * 6 + 6, "six copies plus three holes and three fiducials");
        assert!(out.contains("\"B6-GND\"") && out.contains("\"B1-GND\""), "nets renamed per copy");
        assert!(!out.contains("(net \"GND\")"), "no copy keeps the shared name");
        // ids unique across the panel
        let ids: Vec<&str> = out.match_indices("(uuid \"").map(|(i, _)| &out[i + 7..i + 43]).collect();
        let set: std::collections::HashSet<&&str> = ids.iter().collect();
        assert_eq!(set.len(), ids.len(), "every uuid unique");
        // one outline: four Edge.Cuts lines
        let edges = doc.root.children().iter().filter(|c| c.child_value("layer").as_deref() == Some("Edge.Cuts")).count();
        assert_eq!(edges, 4);
        assert!(crate::schematic::Document::parse(&out).is_ok());
    }

    #[test]
    fn a_shaped_board_is_refused() {
        let t = "(kicad_pcb (version 20250907) (gr_line (start 0 0) (end 10 0) (layer \"Edge.Cuts\")) (gr_arc (start 10 0) (mid 12 5) (end 10 10) (layer \"Edge.Cuts\")) (gr_line (start 10 10) (end 0 10) (layer \"Edge.Cuts\")) (gr_line (start 0 10) (end 0 0) (layer \"Edge.Cuts\")))";
        let e = panelize(t, &Opts { rows: 1, cols: 2, rail_mm: 5.0, holes: true, fiducials: true, tabs: false, gap_mm: 0.0 }).unwrap_err();
        assert!(e.contains("arc"), "{e}");
        let notched = "(kicad_pcb (version 20250907) (gr_line (start 0 0) (end 10 0) (layer \"Edge.Cuts\")) (gr_line (start 10 0) (end 10 10) (layer \"Edge.Cuts\")) (gr_line (start 10 10) (end 5 10) (layer \"Edge.Cuts\")) (gr_line (start 5 10) (end 5 8) (layer \"Edge.Cuts\")) (gr_line (start 5 8) (end 0 8) (layer \"Edge.Cuts\")) (gr_line (start 0 8) (end 0 0) (layer \"Edge.Cuts\")))";
        assert!(panelize(notched, &Opts { rows: 1, cols: 2, rail_mm: 5.0, holes: true, fiducials: true, tabs: false, gap_mm: 0.0 }).is_err());
    }

    #[test]
    fn numbered_nets_get_a_table_entry_per_copy() {
        let t = "(kicad_pcb (version 20250907)\n\t(net 0 \"\")\n\t(net 1 \"GND\")\n\t(net 2 \"VCC\")\n\t(footprint \"L:R\" (layer \"F.Cu\") (uuid \"f1\") (at 5 5) (pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\") (net 1 \"GND\") (uuid \"p1\")))\n\t(segment (start 1 1) (end 2 1) (width 0.2) (layer \"F.Cu\") (net 2) (uuid \"s1\"))\n\t(gr_rect (start 0 0) (end 10 10) (layer \"Edge.Cuts\") (uuid \"e1\"))\n)\n";
        let (out, _) = panelize(t, &Opts { rows: 1, cols: 2, rail_mm: 0.0, holes: false, fiducials: false, tabs: false, gap_mm: 0.0 }).unwrap();
        assert!(out.contains("(net 1 \"B1-GND\")") && out.contains("(net 4 \"B2-GND\")") && out.contains("(net 5 \"B2-VCC\")"), "{out}");
        assert!(out.contains("(net 4 \"B2-GND\"))") || out.contains("(net 4 \"B2-GND\") (uuid"), "the second copy's pad uses the new number: {out}");
        assert!(out.contains("(start 11 1)"), "the second copy moved 10 mm right: {out}");
    }
}