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
//! Live commits for the copper edit verbs planned in `crate::route_tools`:
//! `kicad_modify_track`, `kicad_route_diff_pair` and `kicad_copy_routing`. Each one reads
//! the live board, checks `expectedRevision`, plans, runs KiCad's DRC on a snapshot with
//! and without the change (refusing new errors), re-checks the revision and lands the
//! whole edit as ONE native Undo step.

use super::*;
use crate::route_tools::{self, TrackChange};

fn preflight(ctx: &Ctx, s: &Session, before_text: &str, after_text: &str, args: &Value, what: &str) -> RResult<Value> {
    if args.get("validate") == Some(&json!(false)) {
        return Ok(json!({"skipped": true, "why": "validate:false"}));
    }
    let source = s.source_path();
    let before = drc_snapshot(ctx.kicad_cli.as_deref(), &source, before_text)?;
    let after = drc_snapshot(ctx.kicad_cli.as_deref(), &source, after_text)?;
    let added = new_errors(&before, &after)?;
    if !added.is_empty() {
        return Err(RoutingError::new("drc_rejected", format!("{what} adds DRC errors; no live copper was changed")).with("violations", json!(added)).with("mutated", json!(false)));
    }
    Ok(after)
}

/// The project file beside the live board, for netclass defaults.
fn project_of(s: &Session) -> Option<Value> {
    let pro = s.source_path().with_extension("kicad_pro");
    crate::project_rules::read_json(&pro).ok()
}

/// Apply one planned change to the editor's own track proto: width, layer and endpoints;
/// every other field (net, locked state, id) stays as KiCad sent it.
pub fn apply_track_change(track: &mut TrackItem, change: &TrackChange) -> RResult<()> {
    let layer = BoardLayerInfo::id_from_name(&change.after.layer).ok_or_else(|| RoutingError::new("unknown_layer", format!("KiCad has no layer named {}", change.after.layer)))?;
    track.set_start_nm(vector(change.after.start));
    track.set_end_nm(vector(change.after.end));
    track.set_layer_id(layer);
    let p = track.proto_mut();
    p.width = Some(Default::default());
    if let Some(w) = p.width.as_mut() {
        w.value_nm = nm(change.after.width);
    }
    Ok(())
}

/// `kicad_modify_track`: change width, layer or endpoints of tracks by id.
pub fn modify_track(ctx: &Ctx, args: &Value) -> RResult<Value> {
    let s = connect(ctx, args)?;
    let (text, data, revision) = s.snapshot()?;
    check_revision(args, &revision)?;
    let (changes, candidate) = route_tools::plan_track_changes(&data, &text, args)?;
    let check = preflight(ctx, &s, &text, &candidate, args, "The track change")?;
    if args.get("dryRun") == Some(&json!(true)) {
        return Ok(json!({"success": true, "dryRun": true, "mutated": false, "revision": revision, "changes": changes, "drc": check, "source": "live-editor",
            "_hint": "Checked without editing. Submit with dryRun:false and the same revision to change them as one Undo step."}));
    }
    check_revision(args, &s.revision()?)?;
    let ids: Vec<String> = changes.iter().map(|c| c.id.clone()).collect();
    let mut items = s.client.get_editable_items_by_id(ids.clone()).map_err(map_err)?;
    if items.len() != ids.len() {
        return Err(RoutingError::new("unknown_track", "KiCad did not return every track by id; nothing changed").with("mutated", json!(false)));
    }
    for item in items.iter_mut() {
        let EditablePcbItem::Track(t) = item else {
            return Err(RoutingError::new("unknown_track", "an id is not a straight track in the live editor; nothing changed").with("mutated", json!(false)));
        };
        let id = t.id().unwrap_or_default().to_string();
        let change = changes.iter().find(|c| c.id == id).ok_or_else(|| RoutingError::new("unknown_track", format!("unexpected item {id}")))?;
        apply_track_change(t, change)?;
    }
    let count = items.len();
    transaction(&s, &format!("Adom modify {} track(s)", count), || {
        let updated = s.client.update_editable_items(items)?;
        if updated.len() != count {
            return Err(IpcFailure::from("KiCad did not update every requested track".to_string()));
        }
        Ok(())
    })?;
    let mut out = json!({"success": true, "mutated": true, "source": "live-editor", "changes": changes, "undoSteps": 1, "drc": check, "saved": false,
        "_hint": "Tracks changed live as one KiCad Undo step. Moving an endpoint does not drag the tracks joined to it: read kicad_routing_validate for dangling ends."});
    match s.snapshot() {
        Ok((_, after, rev)) => {
            out["revision"] = json!(rev);
            let wrong: Vec<&str> = changes.iter().filter(|c| {
                after.segments.iter().find(|x| x.uuid.as_deref() == Some(c.id.as_str())).map(|x| {
                    let close = |a: f64, b: f64| (a - b).abs() < 1e-5;
                    !(x.width.map(|w| close(w, c.after.width)).unwrap_or(false) && x.layer.as_deref() == Some(c.after.layer.as_str())
                        && x.start.as_ref().map(|p| close(p.x, c.after.start[0]) && close(p.y, c.after.start[1])).unwrap_or(false)
                        && x.end.as_ref().map(|p| close(p.x, c.after.end[0]) && close(p.y, c.after.end[1])).unwrap_or(false))
                }).unwrap_or(true)
            }).map(|c| c.id.as_str()).collect();
            out["verified"] = json!(wrong.is_empty());
            if !wrong.is_empty() {
                out["postCommitError"] = json!(format!("read-back differs for {wrong:?}; inspect the live board, do not resubmit blindly"));
            }
            if args.get("save") == Some(&json!(true)) {
                match s.client.save_document() {
                    Ok(()) => out["saved"] = json!(true),
                    Err(e) => out["postCommitError"] = json!(e.to_string()),
                }
            }
        }
        Err(e) => out["postCommitError"] = json!(e.message),
    }
    Ok(out)
}

/// Commit several planned nets as ONE undo step.
fn commit_plans(s: &Session, plans: &[&Plan], message: &str) -> RResult<Vec<String>> {
    let nets = s.client.get_nets().map_err(map_err)?;
    let mut items = Vec::new();
    for p in plans {
        let Some(net) = nets.iter().find(|n| n.name == p.net_name) else {
            return Err(RoutingError::new("unknown_net", format!("The live board has no net named '{}'", p.net_name)).with("mutated", json!(false)));
        };
        items.extend(build_items(p, net)?);
    }
    let count = items.len();
    let created = transaction(s, message, || {
        let created = s.client.create_editable_items(items, None)?;
        if created.len() != count || created.iter().any(|i| i.id().map(str::is_empty).unwrap_or(true)) {
            return Err(IpcFailure::from("KiCad did not create every requested copper item".to_string()));
        }
        Ok(created)
    })?;
    Ok(created.iter().filter_map(|i| i.id().map(str::to_string)).collect())
}

fn finish(s: &Session, args: &Value, mut out: Value) -> Value {
    match s.revision() {
        Ok(r) => {
            out["revision"] = json!(r);
            if args.get("save") == Some(&json!(true)) {
                match s.client.save_document() {
                    Ok(()) => out["saved"] = json!(true),
                    Err(e) => out["postCommitError"] = json!(e.to_string()),
                }
            }
        }
        Err(e) => out["postCommitError"] = json!(e.message),
    }
    out
}

/// `kicad_route_diff_pair`: both halves of a pair from one centerline, one Undo step.
pub fn route_diff_pair(ctx: &Ctx, args: &Value) -> RResult<Value> {
    let s = connect(ctx, args)?;
    let (text, data, revision) = s.snapshot()?;
    check_revision(args, &revision)?;
    let pro = project_of(&s);
    let plan = route_tools::plan_diff_pair(&data, pro.as_ref(), args)?;
    let check = preflight(ctx, &s, &text, &pcb::splice(&text, &plan.blocks()), args, "The diff pair")?;
    let mut out = plan.public();
    if args.get("dryRun") == Some(&json!(true)) {
        merge(&mut out, &json!({"success": true, "dryRun": true, "mutated": false, "revision": revision, "drc": check, "source": "live-editor",
            "_hint": "Both halves planned and checked without editing. Submit with dryRun:false and the same revision; skewMm is the length difference to tune."}));
        return Ok(out);
    }
    check_revision(args, &s.revision()?)?;
    let ids = commit_plans(&s, &[&plan.p, &plan.n], &format!("Adom diff pair {} / {}", plan.p.net_name, plan.n.net_name))?;
    merge(&mut out, &json!({"success": true, "mutated": true, "source": "live-editor", "itemIds": ids, "undoSteps": 1, "drc": check, "saved": false,
        "_hint": "Pair committed live as one KiCad Undo step. skewMm is the P/N length difference; tune it in KiCad (Route > Tune Skew) or reroute the shorter half."}));
    Ok(finish(&s, args, out))
}

/// `kicad_copy_routing`: tracks and vias in a box, translated and net-remapped, one Undo step.
pub fn copy_routing(ctx: &Ctx, args: &Value) -> RResult<Value> {
    let s = connect(ctx, args)?;
    let (text, data, revision) = s.snapshot()?;
    check_revision(args, &revision)?;
    let plan = route_tools::plan_copy(&data, args)?;
    let check = preflight(ctx, &s, &text, &pcb::splice(&text, &plan.blocks()), args, "The copied routing")?;
    let mut out = plan.public();
    if args.get("dryRun") == Some(&json!(true)) {
        merge(&mut out, &json!({"success": true, "dryRun": true, "mutated": false, "revision": revision, "drc": check, "source": "live-editor",
            "_hint": "Copy planned and checked without editing. Submit with dryRun:false and the same revision."}));
        return Ok(out);
    }
    check_revision(args, &s.revision()?)?;
    let plans: Vec<&Plan> = plan.plans.iter().collect();
    let ids = commit_plans(&s, &plans, &format!("Adom copy routing ({} nets)", plans.len()))?;
    merge(&mut out, &json!({"success": true, "mutated": true, "source": "live-editor", "itemIds": ids, "undoSteps": 1, "drc": check, "saved": false,
        "_hint": "Copied live as one KiCad Undo step. Check kicad_routing_validate: copied copper connects only where the target pads sit at the same offset."}));
    Ok(finish(&s, args, out))
}

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

    #[test]
    fn a_track_change_rewrites_width_layer_and_ends_and_keeps_the_rest() {
        let mut t = TrackItem::from_proto(Default::default());
        t.set_start_nm(Vector2Nm { x_nm: 1, y_nm: 2 });
        let p = t.proto_mut();
        p.net = Some(Default::default());
        if let Some(n) = p.net.as_mut() {
            n.name = "SIG".into();
        }
        let geom = |w: f64, l: &str| TrackGeom { start: [1.0, 2.0], end: [3.5, 2.0], width: w, layer: l.into() };
        let c = TrackChange { id: "x".into(), net: "SIG".into(), before: geom(0.2, "F.Cu"), after: geom(0.4, "B.Cu"), changed: vec!["width", "layer"] };
        apply_track_change(&mut t, &c).unwrap();
        assert_eq!(t.proto().width.as_ref().unwrap().value_nm, 400_000);
        assert_eq!(t.layer_id(), BoardLayerInfo::id_from_name("B.Cu").unwrap());
        assert_eq!(t.start_nm(), Some(Vector2Nm { x_nm: 1_000_000, y_nm: 2_000_000 }));
        assert_eq!(t.end_nm(), Some(Vector2Nm { x_nm: 3_500_000, y_nm: 2_000_000 }));
        assert_eq!(t.proto().net.as_ref().unwrap().name, "SIG", "the net is left as KiCad sent it");
        let bad = TrackChange { after: geom(0.4, "Nope.Cu"), ..c };
        assert_eq!(apply_track_change(&mut t, &bad).unwrap_err().code, "unknown_layer");
    }
}