master
John Lauer 1.0.61: Every Konnect v0.13.0 capability, 211 verbs (was 133; docs/konnect-gap.md maps all 228 Konnect tools). Schemati… 0009b41 14d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633
//! Planning for the copper edit verbs that `ipc::route_edit` commits live:
//! `kicad_modify_track` (width, layer or endpoints of existing tracks),
//! `kicad_route_diff_pair` (two coupled paths offset from one centerline) and
//! `kicad_copy_routing` (tracks and vias in a box, translated, nets remapped). Pure: every
//! plan carries the candidate board text the DRC preflight checks, and nothing here talks
//! to KiCad.
//!
//! Coordinates are millimetres in KiCad's board frame (+x right, +y DOWN).

use std::collections::BTreeMap;

use serde::Serialize;
use serde_json::{json, Value};

use crate::pcb::{self, Board, NetFormat, NetRef, Plan, PlanSegment, PlanVia, RoutingError};
use crate::project_rules;

type R<T> = Result<T, RoutingError>;

/// Most copper items one call may create or change (one undo step).
pub const MAX_ITEMS: usize = 1024;

// ---------------------------------------------------------------------------
// Board text helpers
// ---------------------------------------------------------------------------

/// Byte spans of the root list's direct children, quotes respected.
pub fn top_level_spans(text: &str) -> Vec<(usize, usize)> {
    let mut spans = Vec::new();
    let (mut depth, mut quoted, mut escaped, mut start) = (0i32, false, false, 0usize);
    for (i, c) in text.char_indices() {
        if quoted {
            if escaped {
                escaped = false
            } else if c == '\\' {
                escaped = true
            } else if c == '"' {
                quoted = false
            }
            continue;
        }
        match c {
            '"' => quoted = true,
            '(' => {
                depth += 1;
                if depth == 2 {
                    start = i;
                }
            }
            ')' => {
                if depth == 2 {
                    spans.push((start, i + 1));
                }
                depth -= 1;
            }
            _ => {}
        }
    }
    spans
}

/// The span of the top-level item of one of `kinds` carrying this uuid.
pub fn item_span(text: &str, kinds: &[&str], id: &str) -> Option<(usize, usize)> {
    let a = format!("(uuid \"{id}\")");
    let b = format!("(tstamp \"{id}\")");
    top_level_spans(text).into_iter().find(|(s, e)| {
        let item = &text[*s..*e];
        kinds.iter().any(|k| item.starts_with(&format!("({k}")) && item[k.len() + 1..].starts_with(|c: char| c.is_whitespace())) && (item.contains(&a) || item.contains(&b))
    })
}

/// A net this board can write, from a name (KiCad 10) or a number (older boards).
pub fn net_ref(board: &Board, number: Option<i64>, name: Option<&str>) -> NetRef {
    let name = name.map(str::to_string).or_else(|| number.and_then(|n| board.nets.iter().find(|x| x.number == Some(n)).map(|x| x.name.clone()))).unwrap_or_default();
    let number = number.or_else(|| board.nets.iter().find(|x| x.name == name).and_then(|x| x.number));
    NetRef { format: board.net_format, number, name: name.clone(), key: pcb::net_key(board, number, Some(&name)) }
}

fn named_net(board: &Board, name: &str) -> R<NetRef> {
    let hits: Vec<&pcb::Net> = board.nets.iter().filter(|n| n.name == name).collect();
    if hits.len() != 1 {
        let mut near: Vec<&str> = board.nets.iter().map(|n| n.name.as_str()).filter(|n| n.to_lowercase().contains(&name.to_lowercase())).collect();
        near.truncate(20);
        return Err(RoutingError::new("unknown_net", format!("No unique net named '{name}'")).with("candidates", json!(near)));
    }
    Ok(net_ref(board, hits[0].number, Some(name)))
}

fn point(v: &Value, pads: &[pcb::Pad], label: &str) -> R<(f64, f64)> {
    pcb::resolve_point(v, pads).map(|(p, _)| p).map_err(|e| RoutingError::new(e.get("errorCode").and_then(Value::as_str).unwrap_or("bad_point"), format!("{label}: {}", e.get("error").and_then(Value::as_str).unwrap_or("bad point"))))
}

fn path_length(segments: &[PlanSegment]) -> f64 {
    segments.iter().map(|s| pcb::dist((s.start[0], s.start[1]), (s.end[0], s.end[1]))).sum()
}

fn r4(v: f64) -> f64 {
    (v * 10_000.0).round() / 10_000.0
}

// ---------------------------------------------------------------------------
// kicad_modify_track
// ---------------------------------------------------------------------------

#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct TrackGeom {
    pub start: [f64; 2],
    pub end: [f64; 2],
    pub width: f64,
    pub layer: String,
}

#[derive(Clone, Debug, Serialize)]
pub struct TrackChange {
    pub id: String,
    pub net: String,
    pub before: TrackGeom,
    pub after: TrackGeom,
    pub changed: Vec<&'static str>,
}

/// Read `changes: [{id, width?, layer?, start?, end?}]`, or `itemIds` (or `id`) with a
/// shared `width` / `layer` (and `start` / `end` for a single id). Endpoints take [x,y],
/// {x,y} or a pad name. Returns the changes and the candidate board text with every
/// changed segment rewritten in place (same uuid, same net).
pub fn plan_track_changes(board: &Board, text: &str, args: &Value) -> R<(Vec<TrackChange>, String)> {
    let rows: Vec<Value> = if let Some(Value::Array(c)) = args.get("changes") {
        c.clone()
    } else {
        let ids: Vec<String> = match (args.get("itemIds"), args.get("id")) {
            (Some(Value::Array(a)), _) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),
            (_, Some(Value::String(s))) => vec![s.clone()],
            _ => Vec::new(),
        };
        if ids.len() > 1 && (args.get("start").is_some() || args.get("end").is_some()) {
            return Err(RoutingError::new("invalid_changes", "start and end apply to one track; use changes:[{id,start,end}] for several"));
        }
        ids.into_iter().map(|id| json!({"id": id, "width": args.get("width"), "layer": args.get("layer"), "start": args.get("start"), "end": args.get("end")})).collect()
    };
    if rows.is_empty() || rows.len() > MAX_ITEMS {
        return Err(RoutingError::new("invalid_changes", format!("Pass changes:[{{id, width?, layer?, start?, end?}}] (1..{MAX_ITEMS}) or itemIds with width/layer")));
    }
    let pads = pcb::pads(board);
    let copper = pcb::copper_layers(board);
    let mut changes: Vec<TrackChange> = Vec::new();
    let mut out = text.to_string();
    for row in &rows {
        let id = row.get("id").and_then(Value::as_str).filter(|s| !s.is_empty()).ok_or_else(|| RoutingError::new("invalid_changes", "every change needs the track id (kicad_routing_state segments[].uuid)"))?;
        if changes.iter().any(|c| c.id == id) {
            return Err(RoutingError::new("invalid_changes", format!("track {id} appears twice")));
        }
        let Some(seg) = board.segments.iter().find(|s| s.uuid.as_deref() == Some(id)) else {
            let is_arc = item_span(text, &["arc"], id).is_some();
            let is_via = board.vias.iter().any(|v| v.uuid.as_deref() == Some(id));
            let why = if is_arc { "is an arc; only straight tracks are modified (remove and route it again)" } else if is_via { "is a via, not a track" } else { "is not a track on this board" };
            return Err(RoutingError::new("unknown_track", format!("{id} {why}")).with("mutated", json!(false)));
        };
        let (Some(s), Some(e), Some(w), Some(l)) = (&seg.start, &seg.end, seg.width, &seg.layer) else {
            return Err(RoutingError::new("unknown_track", format!("{id} has incomplete geometry")));
        };
        let before = TrackGeom { start: [s.x, s.y], end: [e.x, e.y], width: w, layer: l.clone() };
        let mut after = before.clone();
        let mut changed = Vec::new();
        if let Some(v) = row.get("width").filter(|v| !v.is_null()) {
            after.width = pcb::number(Some(v), "width", true)?;
        }
        if let Some(v) = row.get("layer").filter(|v| !v.is_null()) {
            let layer = v.as_str().ok_or_else(|| RoutingError::new("unknown_layer", "layer is a copper layer name"))?;
            if !copper.iter().any(|c| c == layer) {
                return Err(RoutingError::new("unknown_layer", format!("Unknown copper layer: {layer}")).with("copperLayers", json!(copper)));
            }
            after.layer = layer.to_string();
        }
        if let Some(v) = row.get("start").filter(|v| !v.is_null()) {
            let p = point(v, &pads, "start")?;
            after.start = [pcb::round6(p.0), pcb::round6(p.1)];
        }
        if let Some(v) = row.get("end").filter(|v| !v.is_null()) {
            let p = point(v, &pads, "end")?;
            after.end = [pcb::round6(p.0), pcb::round6(p.1)];
        }
        if after.start == after.end {
            return Err(RoutingError::new("zero_length", format!("track {id} would have zero length")));
        }
        if after.width != before.width {
            changed.push("width");
        }
        if after.layer != before.layer {
            changed.push("layer");
        }
        if after.start != before.start {
            changed.push("start");
        }
        if after.end != before.end {
            changed.push("end");
        }
        if changed.is_empty() {
            return Err(RoutingError::new("no_change", format!("track {id} already has that geometry")).with("track", json!(before)).with("mutated", json!(false)));
        }
        let span = item_span(&out, &["segment"], id).ok_or_else(|| RoutingError::new("unknown_track", format!("{id} was not found in the board text")))?;
        let old = &out[span.0..span.1];
        if old.contains("(locked yes)") || old.contains("(locked)") {
            if args.get("allowLocked") != Some(&json!(true)) {
                return Err(RoutingError::new("track_locked", format!("track {id} is locked; pass allowLocked:true to change it anyway")).with("mutated", json!(false)));
            }
        }
        let net = net_ref(board, seg.net, seg.net_name.as_deref());
        let mut block = pcb::segment_sexpr((after.start[0], after.start[1]), (after.end[0], after.end[1]), after.width, &after.layer, &net);
        if let Some(i) = block.find("(uuid \"") {
            let j = block[i + 7..].find('"').map(|j| i + 7 + j).unwrap_or(block.len());
            block.replace_range(i + 7..j, id);
        }
        out.replace_range(span.0..span.1, block.trim_start());
        changes.push(TrackChange { id: id.to_string(), net: net.name.clone(), before, after, changed });
    }
    Ok((changes, out))
}

// ---------------------------------------------------------------------------
// kicad_route_diff_pair
// ---------------------------------------------------------------------------

/// The P and N nets of a pair named by its base (`USB_D` -> `USB_D+`/`USB_D-`, `_P`/`_N`,
/// `P`/`N`, `_DP`/`_DN`), or the partner of a P net.
pub fn pair_nets(board: &Board, args: &Value) -> R<(String, String)> {
    let has = |n: &str| board.nets.iter().any(|x| x.name == n);
    let p = args.get("netP").and_then(Value::as_str);
    let n = args.get("netN").and_then(Value::as_str);
    if let (Some(p), Some(n)) = (p, n) {
        return Ok((p.to_string(), n.to_string()));
    }
    const SUFFIXES: &[(&str, &str)] = &[("+", "-"), ("_P", "_N"), ("_DP", "_DN"), ("P", "N"), ("_p", "_n")];
    if let Some(p) = p {
        for (a, b) in SUFFIXES {
            if let Some(base) = p.strip_suffix(a) {
                let cand = format!("{base}{b}");
                if has(&cand) {
                    return Ok((p.to_string(), cand));
                }
            }
        }
        return Err(RoutingError::new("pair_not_found", format!("no N partner found for {p}; pass netN")));
    }
    if let Some(base) = args.get("pair").and_then(Value::as_str) {
        for (a, b) in SUFFIXES {
            let (cp, cn) = (format!("{base}{a}"), format!("{base}{b}"));
            if has(&cp) && has(&cn) {
                return Ok((cp, cn));
            }
        }
        return Err(RoutingError::new("pair_not_found", format!("no P/N nets found for the pair {base} (tried +/-, _P/_N, _DP/_DN, P/N)")));
    }
    Err(RoutingError::new("missing_net", "Pass netP and netN, netP alone, or pair (the base name)"))
}

/// Offset a polyline sideways by `d[i]` at vertex i (positive = left of the direction of
/// travel, in the y-down frame). Interior vertices use the miter of the two adjacent
/// normals; a corner sharper than about 130 degrees of turn is refused.
pub fn offset_path(points: &[(f64, f64)], d: &[f64]) -> R<Vec<(f64, f64)>> {
    let n = points.len();
    let normal = |a: (f64, f64), b: (f64, f64)| -> R<(f64, f64)> {
        let (dx, dy) = (b.0 - a.0, b.1 - a.1);
        let len = (dx * dx + dy * dy).sqrt();
        if len < 1e-9 {
            return Err(RoutingError::new("invalid_geometry", "the centerline repeats a point"));
        }
        Ok((dy / len, -dx / len))
    };
    let mut out = Vec::with_capacity(n);
    for i in 0..n {
        let (nx, ny) = if i == 0 {
            normal(points[0], points[1])?
        } else if i == n - 1 {
            normal(points[n - 2], points[n - 1])?
        } else {
            let a = normal(points[i - 1], points[i])?;
            let b = normal(points[i], points[i + 1])?;
            let (mx, my) = (a.0 + b.0, a.1 + b.1);
            let ml = (mx * mx + my * my).sqrt();
            if ml < 0.85 {
                return Err(RoutingError::new("corner_too_sharp", format!("the centerline turns too sharply at point {i}; split the corner into two 45-degree bends")));
            }
            let (ux, uy) = (mx / ml, my / ml);
            let scale = 1.0 / (ux * a.0 + uy * a.1);
            (ux * scale, uy * scale)
        };
        out.push((pcb::round6(points[i].0 + nx * d[i]), pcb::round6(points[i].1 + ny * d[i])));
    }
    Ok(out)
}

#[derive(Clone, Debug)]
pub struct DiffPairPlan {
    pub p: Plan,
    pub n: Plan,
    pub width: f64,
    pub gap: f64,
    pub via_gap: f64,
    pub length_p: f64,
    pub length_n: f64,
    pub source: &'static str,
}

impl DiffPairPlan {
    pub fn public(&self) -> Value {
        let skew = r4((self.length_p - self.length_n).abs());
        json!({
            "netP": self.p.net_name, "netN": self.n.net_name,
            "width": self.width, "gap": self.gap, "viaGap": self.via_gap, "sizesFrom": self.source,
            "p": self.p.public(), "n": self.n.public(),
            "lengthP": r4(self.length_p), "lengthN": r4(self.length_n), "skewMm": skew,
            "longer": if skew == 0.0 { Value::Null } else if self.length_p > self.length_n { json!(self.p.net_name) } else { json!(self.n.net_name) },
            "viaPairs": self.p.vias.len(),
        })
    }
    pub fn blocks(&self) -> Vec<String> {
        self.p.blocks.iter().chain(self.n.blocks.iter()).cloned().collect()
    }
}

/// Plan both halves of a pair: `centerline: [[x,y] | {x,y,layer?}, ...]` (a `layer` on a
/// point changes layer there, with a via pair), `gap` and `width` (default: the P net's
/// netclass diff pair values from the project, else 0.25 / 0.2), `pSide: left|right`,
/// optional `pStart`, `nStart`, `pEnd`, `nEnd` pads joined to the offset ends, and the
/// usual `layer`, `viaSize`, `viaDrill`. Each half goes through `pcb::plan`, the planner
/// `kicad_route_net` uses, so its pad, net and layer checks hold for both.
pub fn plan_diff_pair(board: &Board, project: Option<&Value>, args: &Value) -> R<DiffPairPlan> {
    let (net_p, net_n) = pair_nets(board, args)?;
    if net_p == net_n {
        return Err(RoutingError::new("invalid_pair", "netP and netN must differ"));
    }
    named_net(board, &net_p)?;
    named_net(board, &net_n)?;
    let class = project.map(|p| project_rules::class_for_net(p, &net_p));
    let from_class = |k: &str| class.as_ref().and_then(|c| c.get(k)).and_then(Value::as_f64).filter(|v| *v > 0.0);
    let source = if class.is_some() && args.get("gap").is_none() && args.get("width").is_none() { "netclass" } else if args.get("gap").is_some() && args.get("width").is_some() { "arguments" } else if class.is_some() { "arguments+netclass" } else { "defaults" };
    let width = match args.get("width") {
        Some(v) => pcb::number(Some(v), "width", true)?,
        None => from_class("diff_pair_width").unwrap_or(0.2),
    };
    let gap = match args.get("gap") {
        Some(v) => pcb::number(Some(v), "gap", true)?,
        None => from_class("diff_pair_gap").unwrap_or(0.25),
    };
    let via_size = pcb::number(Some(args.get("viaSize").unwrap_or(&json!(0.6))), "viaSize", true)?;
    let via_drill = pcb::number(Some(args.get("viaDrill").unwrap_or(&json!(0.3))), "viaDrill", true)?;
    let via_gap = match args.get("viaGap") {
        Some(v) => pcb::number(Some(v), "viaGap", true)?,
        None => from_class("diff_pair_via_gap").unwrap_or(gap),
    };
    let pads = pcb::pads(board);
    let line = args.get("centerline").and_then(Value::as_array).filter(|a| (2..=256).contains(&a.len())).ok_or_else(|| RoutingError::new("invalid_path", "centerline needs 2..256 points: [x,y] or {x,y,layer?}"))?;
    let mut pts = Vec::with_capacity(line.len());
    let mut layers: Vec<Option<String>> = Vec::with_capacity(line.len());
    for (i, spec) in line.iter().enumerate() {
        if spec.get("pad").is_some() || spec.is_string() {
            return Err(RoutingError::new("invalid_path", "centerline points are coordinates; join pads with pStart/nStart/pEnd/nEnd"));
        }
        pts.push(point(spec, &pads, &format!("centerline[{i}]"))?);
        layers.push(spec.get("layer").and_then(Value::as_str).map(str::to_string));
    }
    let half = (gap + width) / 2.0;
    let half_via = half.max((via_size + via_gap) / 2.0);
    let sign = match args.get("pSide").and_then(Value::as_str) {
        None | Some("left") => 1.0,
        Some("right") => -1.0,
        Some(o) => return Err(RoutingError::new("invalid_side", format!("pSide is left or right, not {o}"))),
    };
    let d: Vec<f64> = (0..pts.len()).map(|i| if layers[i].is_some() { half_via } else { half }).collect();
    let left = offset_path(&pts, &d)?;
    let right = offset_path(&pts, &d.iter().map(|v| -v).collect::<Vec<_>>())?;
    let (p_pts, n_pts) = if sign > 0.0 { (left, right) } else { (right, left) };
    let build = |net: &str, offs: &[(f64, f64)], start: Option<&Value>, end: Option<&Value>| -> R<Plan> {
        let mut path: Vec<Value> = Vec::new();
        if let Some(s) = start {
            path.push(s.clone());
        }
        for (i, p) in offs.iter().enumerate() {
            let mut v = json!({"x": p.0, "y": p.1});
            if let Some(l) = &layers[i] {
                v["layer"] = json!(l);
            }
            path.push(v);
        }
        if let Some(e) = end {
            path.push(e.clone());
        }
        let mut a = json!({"net": net, "points": path, "width": width, "viaSize": via_size, "viaDrill": via_drill});
        if let Some(l) = args.get("layer") {
            a["layer"] = l.clone();
        }
        pcb::plan(board, &a).map_err(|e| {
            let mut e2 = RoutingError::new(&e.code, format!("{net}: {}", e.message));
            e2.detail = e.detail;
            e2
        })
    };
    let p = build(&net_p, &p_pts, args.get("pStart"), args.get("pEnd"))?;
    let n = build(&net_n, &n_pts, args.get("nStart"), args.get("nEnd"))?;
    if p.segments.len() + p.vias.len() + n.segments.len() + n.vias.len() > MAX_ITEMS {
        return Err(RoutingError::new("route_too_large", format!("Limit each call to {MAX_ITEMS} copper items")));
    }
    let (length_p, length_n) = (path_length(&p.segments), path_length(&n.segments));
    Ok(DiffPairPlan { p, n, width, gap, via_gap, length_p, length_n, source })
}

// ---------------------------------------------------------------------------
// kicad_copy_routing
// ---------------------------------------------------------------------------

#[derive(Clone, Debug)]
pub struct CopyPlan {
    pub plans: Vec<Plan>,
    pub offset: (f64, f64),
    pub net_map: BTreeMap<String, String>,
    pub skipped: Vec<Value>,
    pub source_ids: Vec<String>,
    pub ref_map: BTreeMap<String, String>,
}

impl CopyPlan {
    pub fn blocks(&self) -> Vec<String> {
        self.plans.iter().flat_map(|p| p.blocks.iter().cloned()).collect()
    }
    pub fn public(&self) -> Value {
        json!({
            "offset": {"dx": self.offset.0, "dy": self.offset.1},
            "netMap": self.net_map, "refMap": self.ref_map,
            "segmentCount": self.plans.iter().map(|p| p.segments.len()).sum::<usize>(),
            "viaCount": self.plans.iter().map(|p| p.vias.len()).sum::<usize>(),
            "sourceItemIds": self.source_ids,
            "skipped": self.skipped,
            "byNet": self.plans.iter().map(|p| p.public()).collect::<Vec<_>>(),
        })
    }
}

/// `*` in `from` captures; the capture fills the `*` in `to`. Without a star, a plain
/// substring replacement.
pub fn map_ref(reference: &str, from: &str, to: &str) -> Option<String> {
    match from.split_once('*') {
        Some((pre, post)) => {
            if reference.len() >= pre.len() + post.len() && reference.starts_with(pre) && reference.ends_with(post) {
                let mid = &reference[pre.len()..reference.len() - post.len()];
                Some(if to.contains('*') { to.replacen('*', mid, 1) } else { format!("{to}{mid}") })
            } else {
                None
            }
        }
        None => reference.contains(from).then(|| reference.replacen(from, to, 1)),
    }
}

fn rect_arg(v: Option<&Value>) -> R<(f64, f64, f64, f64)> {
    let v = v.ok_or_else(|| RoutingError::new("missing_arg", "source is the box to copy from: {minX, minY, maxX, maxY} in mm"))?;
    let g = |k: &str| pcb::number(v.get(k), k, false);
    let (a, b, c, d) = (g("minX")?, g("minY")?, g("maxX")?, g("maxY")?);
    if c <= a || d <= b {
        return Err(RoutingError::new("invalid_geometry", "source box needs maxX > minX and maxY > minY"));
    }
    Ok((a, b, c, d))
}

/// Copy every track (both ends inside) and via (centre inside) of `source` by `offset`
/// ({dx, dy} or [dx, dy]). Nets: `netMap` {from: to} wins; else `refMap` {"U1": "U3"} or
/// `refPattern` {from: "CH1_*", to: "CH2_*"} pairs footprints and maps every source pad's
/// net to the same pad number's net on its partner. With a remap in play an item whose
/// net has no mapping is skipped (`keepUnmappedNets:true` keeps its net).
pub fn plan_copy(board: &Board, args: &Value) -> R<CopyPlan> {
    let (x0, y0, x1, y1) = rect_arg(args.get("source"))?;
    let off = args.get("offset").ok_or_else(|| RoutingError::new("missing_arg", "offset is {dx, dy} in mm"))?;
    let (dx, dy) = match off {
        Value::Array(a) if a.len() == 2 => (pcb::number(a.first(), "dx", false)?, pcb::number(a.get(1), "dy", false)?),
        _ => (pcb::number(off.get("dx"), "dx", false)?, pcb::number(off.get("dy"), "dy", false)?),
    };
    if dx == 0.0 && dy == 0.0 {
        return Err(RoutingError::new("invalid_geometry", "offset is zero: the copy would sit on the original"));
    }
    let layers: Option<Vec<String>> = args.get("layers").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
    // footprint pairs
    let mut ref_map: BTreeMap<String, String> = BTreeMap::new();
    if let Some(m) = args.get("refMap").and_then(Value::as_object) {
        for (k, v) in m {
            if let Some(t) = v.as_str() {
                ref_map.insert(k.clone(), t.to_string());
            }
        }
    }
    if let Some(p) = args.get("refPattern") {
        let from = p.get("from").and_then(Value::as_str).ok_or_else(|| RoutingError::new("invalid_ref_pattern", "refPattern is {from: \"CH1_*\", to: \"CH2_*\"}"))?;
        let to = p.get("to").and_then(Value::as_str).ok_or_else(|| RoutingError::new("invalid_ref_pattern", "refPattern is {from, to}"))?;
        for fp in &board.footprints {
            if let Some(t) = map_ref(&fp.reference, from, to) {
                if t != fp.reference && board.footprints.iter().any(|f| f.reference == t) {
                    ref_map.entry(fp.reference.clone()).or_insert(t);
                }
            }
        }
        if ref_map.is_empty() {
            return Err(RoutingError::new("no_ref_pairs", format!("refPattern {from} -> {to} pairs no two footprints on this board")));
        }
    }
    let pads = pcb::pads(board);
    let mut net_map: BTreeMap<String, String> = BTreeMap::new();
    let mut conflicts: Vec<Value> = Vec::new();
    for (src, dst) in &ref_map {
        if !board.footprints.iter().any(|f| &f.reference == dst) {
            return Err(RoutingError::new("unknown_footprint", format!("refMap target {dst} is not on this board")));
        }
        for p in pads.iter().filter(|p| &p.reference == src && !p.net_name.is_empty()) {
            let Some(q) = pads.iter().find(|q| &q.reference == dst && q.pad == p.pad) else { continue };
            match net_map.get(&p.net_name) {
                Some(existing) if existing != &q.net_name => conflicts.push(json!({"net": p.net_name, "maps": [existing, q.net_name], "via": format!("{src}.{} -> {dst}.{}", p.pad, q.pad)})),
                Some(_) => {}
                None => {
                    if !q.net_name.is_empty() {
                        net_map.insert(p.net_name.clone(), q.net_name.clone());
                    }
                }
            }
        }
    }
    if let Some(m) = args.get("netMap").and_then(Value::as_object) {
        for (k, v) in m {
            if let Some(t) = v.as_str() {
                named_net(board, t)?;
                net_map.insert(k.clone(), t.to_string());
            }
        }
    }
    if !conflicts.is_empty() && args.get("netMap").is_none() {
        return Err(RoutingError::new("net_map_conflict", "the footprint pairs map one source net to more than one target net").with("conflicts", json!(conflicts)).with("_hint", json!("Pass netMap {from: to} for the nets named in conflicts; it overrides the pad-derived mapping.")));
    }
    let remapping = !ref_map.is_empty() || args.get("netMap").is_some();
    let keep = args.get("keepUnmappedNets").and_then(Value::as_bool).unwrap_or(!remapping);
    let inside = |x: f64, y: f64| x >= x0 - 1e-9 && x <= x1 + 1e-9 && y >= y0 - 1e-9 && y <= y1 + 1e-9;
    let layer_ok = |l: &str| layers.as_ref().map(|ls| ls.iter().any(|x| x == l)).unwrap_or(true);
    let target_net = |name: &str| -> Option<String> {
        match net_map.get(name) {
            Some(t) => Some(t.clone()),
            None if keep => Some(name.to_string()),
            None => None,
        }
    };
    let mut by_net: BTreeMap<String, Plan> = BTreeMap::new();
    let mut skipped = Vec::new();
    let mut source_ids = Vec::new();
    let mut count = 0usize;
    for s in &board.segments {
        let (Some(a), Some(b), Some(w), Some(l)) = (&s.start, &s.end, s.width, &s.layer) else { continue };
        if !(inside(a.x, a.y) && inside(b.x, b.y)) || !layer_ok(l) {
            continue;
        }
        let name = s.net_name.clone().unwrap_or_default();
        let id = s.uuid.clone().unwrap_or_default();
        let Some(t) = target_net(&name) else {
            skipped.push(json!({"id": id, "kind": "track", "net": name, "reason": "unmapped_net"}));
            continue;
        };
        let net = net_ref(board, None, Some(&t));
        let (na, nb) = ((pcb::round6(a.x + dx), pcb::round6(a.y + dy)), (pcb::round6(b.x + dx), pcb::round6(b.y + dy)));
        let plan = by_net.entry(t.clone()).or_insert_with(|| Plan { net_name: t.clone(), segments: Vec::new(), vias: Vec::new(), blocks: Vec::new() });
        plan.segments.push(PlanSegment { start: [na.0, na.1], end: [nb.0, nb.1], layer: l.clone(), width: w });
        plan.blocks.push(pcb::segment_sexpr(na, nb, w, l, &net));
        source_ids.push(id);
        count += 1;
    }
    for v in &board.vias {
        let (Some(p), Some(size), Some(drill)) = (&v.position, v.size, v.drill) else { continue };
        if !inside(p.x, p.y) {
            continue;
        }
        let name = v.net_name.clone().unwrap_or_default();
        let id = v.uuid.clone().unwrap_or_default();
        let mut ls = v.layers.clone().unwrap_or_default();
        ls.sort();
        if ls != ["B.Cu".to_string(), "F.Cu".to_string()] || v.kind.as_deref().map(|k| k != "through" && !k.is_empty()).unwrap_or(false) {
            skipped.push(json!({"id": id, "kind": "via", "net": name, "reason": "not_a_through_via"}));
            continue;
        }
        let Some(t) = target_net(&name) else {
            skipped.push(json!({"id": id, "kind": "via", "net": name, "reason": "unmapped_net"}));
            continue;
        };
        let net = net_ref(board, None, Some(&t));
        let at = (pcb::round6(p.x + dx), pcb::round6(p.y + dy));
        let plan = by_net.entry(t.clone()).or_insert_with(|| Plan { net_name: t.clone(), segments: Vec::new(), vias: Vec::new(), blocks: Vec::new() });
        plan.vias.push(PlanVia { at: [at.0, at.1], layers: ["F.Cu".into(), "B.Cu".into()], size, drill });
        plan.blocks.push(pcb::via_sexpr(at, size, drill, ("F.Cu", "B.Cu"), &net));
        source_ids.push(id);
        count += 1;
    }
    if count == 0 {
        return Err(RoutingError::new("nothing_to_copy", "no track or via lies inside the source box").with("skipped", json!(skipped)));
    }
    if count > MAX_ITEMS {
        return Err(RoutingError::new("route_too_large", format!("{count} items; limit each call to {MAX_ITEMS}")));
    }
    if board.net_format == NetFormat::Number {
        for t in by_net.keys() {
            if !t.is_empty() && !board.nets.iter().any(|n| &n.name == t) {
                return Err(RoutingError::new("unknown_net", format!("target net {t} is not on this board")));
            }
        }
    }
    Ok(CopyPlan { plans: by_net.into_values().collect(), offset: (dx, dy), net_map, skipped, source_ids, ref_map })
}

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

    #[test]
    fn offsets_keep_a_constant_distance_on_straight_runs_and_miter_at_corners() {
        let pts = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)];
        let left = offset_path(&pts, &[1.0, 1.0, 1.0]).unwrap();
        // left of +x travel in a y-down frame is -y
        assert_eq!(left[0], (0.0, -1.0));
        assert_eq!(left[1], (11.0, -1.0));
        assert_eq!(left[2], (11.0, 10.0));
        let right = offset_path(&pts, &[-1.0, -1.0, -1.0]).unwrap();
        assert_eq!(right[1], (9.0, 1.0));
        assert!(offset_path(&[(0.0, 0.0), (10.0, 0.0), (0.0, 0.1)], &[1.0; 3]).is_err());
    }

    #[test]
    fn reference_patterns_capture_the_star() {
        assert_eq!(map_ref("CH1_R3", "CH1_*", "CH2_*").as_deref(), Some("CH2_R3"));
        assert_eq!(map_ref("R13", "R1*", "R2*").as_deref(), Some("R23"));
        assert_eq!(map_ref("U1", "CH1_*", "CH2_*"), None);
        assert_eq!(map_ref("U1A", "1", "2").as_deref(), Some("U2A"));
    }
}