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
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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
//! 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");
}
}