app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
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}");
}
}