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 #313: kicad-lane: 1.0.68, BOM, JLCPCB assembly and order, land-pattern footprints, designators
e1c2dac
13d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
use super::*;
use crate::silk_fields as fields;
use kicad_ipc_rs::FieldItem;
fn classify_field_failure(mut error: RoutingError, version: &str) -> RoutingError {
if (error.code == "mutation_rolled_back" || error.code == "drc_rejected")
&& error.detail.get("mutated") == Some(&json!(false))
&& error.message.contains("ISC_INVALID_TYPE")
&& error.message.contains("PCB_FIELD_T")
&& error.message.contains("unhandled")
{
error.code = "native_field_update_unsupported".into();
error = error.with("kicadVersion", json!(version))
.with("retryable", json!(false))
.with("_hint", json!("This KiCad IPC server cannot update a Field as an item of its own, and the transaction rolled back. From 1.0.57 the bridge then updates the parent footprints with only those fields changed (updateMechanism: footprint); this error reaches you only if that path is unavailable."));
}
error
}
fn id(item: &EditablePcbItem) -> Option<&str> {
match item {
EditablePcbItem::Field(f) => f
.proto()
.text
.as_ref()?
.id
.as_ref()
.map(|x| x.value.as_str()),
_ => None,
}
}
fn apply_geometry(mut field: FieldItem, e: &fields::Edit) -> RResult<EditablePcbItem> {
let f = field.proto_mut();
if f.name != e.name {
return Err(RoutingError::new(
"field_identity_mismatch",
"Native field name does not match saved reference/value property",
));
}
f.visible = e.visible;
let b = f
.text
.as_mut()
.ok_or_else(|| RoutingError::new("field_unavailable", "Native field has no board text"))?;
if b.locked == 2 || b.knockout {
return Err(RoutingError::new(
"field_locked_or_knockout",
"Locked/knockout fields are not supported",
));
}
b.layer = BoardLayerInfo::id_from_name(&e.text.layer).unwrap();
let t = b
.text
.as_mut()
.ok_or_else(|| RoutingError::new("field_unavailable", "Native field has no text"))?;
if t.text != e.text.text {
return Err(RoutingError::new(
"field_identity_mismatch",
"Native field text differs from the candidate",
));
}
let p = t.position.get_or_insert_with(Default::default);
p.x_nm = nm(e.text.x);
p.y_nm = nm(e.text.y);
let a = t.attributes.get_or_insert_with(Default::default);
if a.bold
|| a.italic
|| a.underlined
|| (!a.font_name.is_empty() && a.font_name != "KiCad Font")
{
return Err(RoutingError::new(
"field_style_unsupported",
"Only plain native stroke-font fields are supported",
));
}
a.horizontal_alignment = match e.text.align.as_str() {
"left" => 1,
"right" => 3,
_ => 2,
};
a.vertical_alignment = 2;
a.angle.get_or_insert_with(Default::default).value_degrees = e.text.rotation;
a.line_spacing = 1.;
a.stroke_width.get_or_insert_with(Default::default).value_nm = nm(e.text.stroke);
let size = a.size.get_or_insert_with(Default::default);
size.x_nm = nm(e.text.width);
size.y_nm = nm(e.text.height);
a.mirrored = e.text.mirrored;
a.multiline = t.text.contains('\n');
Ok(EditablePcbItem::Field(field))
}
/// The parent footprints with only the requested fields changed (wiki #129).
fn footprint_updates(s: &Session, plan: &fields::Plan) -> RResult<Vec<EditablePcbItem>> {
let mut fp_ids: Vec<String> = plan.edits.iter().map(|e| e.footprint_id.clone()).collect();
fp_ids.sort();
fp_ids.dedup();
let native = s.client.get_editable_items_by_id(fp_ids.clone()).map_err(map_err)?;
let mut out = Vec::new();
for fid in &fp_ids {
let Some(EditablePcbItem::Footprint(fp)) = native.iter().find(|i| i.id() == Some(fid.as_str())) else {
return Err(RoutingError::new("footprint_not_found", format!("KiCad did not return footprint {fid}; nothing was changed")).with("mutated", json!(false)));
};
let mut fp = fp.clone();
let p = fp.proto_mut();
for e in plan.edits.iter().filter(|e| &e.footprint_id == fid) {
let slot = [&mut p.reference_field, &mut p.value_field]
.into_iter()
.flatten()
.find(|f| f.text.as_ref().and_then(|t| t.id.as_ref()).map(|x| x.value.as_str()) == Some(e.text.id.as_str()))
.ok_or_else(|| RoutingError::new("native_field_unavailable", format!("Footprint {fid} has no reference/value field {}", e.text.id)).with("mutated", json!(false)))?;
let EditablePcbItem::Field(done) = apply_geometry(FieldItem::from_proto(slot.clone()), e)? else { unreachable!() };
*slot = done.proto().clone();
}
out.push(EditablePcbItem::Footprint(fp));
}
Ok(out)
}
/// The plan's parent footprints (by uuid) whose block in `text` carries a `units` list.
fn footprints_with_units(text: &str, plan: &fields::Plan) -> Vec<String> {
let Ok(root) = crate::sxedit::parse(text) else { return Vec::new() };
let wanted: HashSet<&str> = plan.edits.iter().map(|e| e.footprint_id.as_str()).collect();
root.kids_tagged("footprint").filter(|f| f.kid("units").is_some()).filter_map(|f| f.uuid().filter(|u| wanted.contains(u)).map(str::to_string)).collect()
}
pub fn silk_fields_batch(ctx: &Ctx, args: &Value) -> RResult<Value> {
if args["expectedRevision"]
.as_str()
.filter(|s| !s.is_empty())
.is_none()
{
return Err(RoutingError::new(
"revision_required",
"Read the board revision before editing fields",
));
}
let s = connect(ctx, args)?;
let (text, _, revision) = s.snapshot()?;
check_revision(args, &revision)?;
let plan = fields::prepare(&text, args)?;
let ids: Vec<_> = plan.edits.iter().map(|e| e.text.id.clone()).collect();
let native = s
.client
.get_editable_items_by_id(ids.clone())
.map_err(map_err)?;
let mut updates = Vec::new();
for e in &plan.edits {
let f = native
.iter()
.find(|f| id(f) == Some(e.text.id.as_str()))
.ok_or_else(|| {
RoutingError::new(
"native_field_unavailable",
"KiCad did not return the exact nested field UUID; no whole-footprint fallback",
)
})?;
let EditablePcbItem::Field(f) = f else {
unreachable!()
};
updates.push(apply_geometry(f.clone(), e)?);
}
let before = drc_snapshot(ctx.kicad_cli.as_deref(), &s.source_path(), &text)?;
let after = drc_snapshot(ctx.kicad_cli.as_deref(), &s.source_path(), &plan.candidate)?;
let all = |mut v: Value| {
if let Some(a) = v["violations"].as_array_mut() {
for x in a {
x["severity"] = json!("error");
}
}
v
};
let added = new_errors(&all(before), &all(after.clone()))?;
if !added.is_empty() {
return Err(
RoutingError::new("drc_rejected", "Field geometry adds native DRC findings")
.with("_hint", json!(crate::ipc::silk_drc_hint(&added)))
.with("violations", json!(added))
.with("mutated", json!(false)),
);
}
if args["dryRun"] == true {
return Ok(
json!({"success":true,"mutated":false,"revision":revision,"drc":after,"updatedIds":ids}),
);
}
check_revision(args, &s.revision()?)?;
let direct = transaction(&s, "Adom reference/value fields", || {
let reply = s.client.update_editable_items(updates.clone())?;
let actual: HashSet<_> = reply.iter().filter_map(id).collect();
if actual != ids.iter().map(String::as_str).collect() {
return Err(IpcFailure::from(
"Native field update did not preserve all field UUIDs".to_string(),
));
}
Ok(())
})
.map_err(|e| classify_field_failure(e, &s.version.full_version));
// Wiki #129: KiCad 10's API cannot update a field as an item of its own (ISC_INVALID_TYPE
// PCB_FIELD_T); a field lives inside its footprint. After a confirmed rollback, update the
// parent footprints with only those fields changed, the way kicad-python does. The pose and
// every other child go back exactly as KiCad sent them, and the preservation check below
// compares everything outside the requested fields.
let mechanism = match direct {
Ok(()) => "field",
Err(e) if e.code == "native_field_update_unsupported" => {
// KiCad 10.0.x drops a footprint's units block (its pin groups) when the footprint is sent
// back over the API: refuse rather than lose it, unless the caller accepts that
let with_units = footprints_with_units(&text, &plan);
if !with_units.is_empty() && args["allowUnitsDrop"] != true {
return Err(RoutingError::new("field_move_drops_units", format!("{} parent footprint(s) carry KiCad 10's units block, which KiCad drops when a field is moved through its footprint", with_units.len()))
.with("_hint", json!("Nothing changed. Edit the closed board instead (save, close the board in KiCad; kicad_silk_designators writes labels into the file), or pass allowUnitsDrop:true to accept losing the pin groups."))
.with("footprintIds", json!(with_units))
.with("mutated", json!(false)));
}
let fps = footprint_updates(&s, &plan)?;
let fp_ids: HashSet<String> = plan.edits.iter().map(|e| e.footprint_id.clone()).collect();
transaction(&s, "Adom reference/value fields", || {
let reply = s.client.update_editable_items(fps.clone())?;
let actual: HashSet<String> = reply.iter().filter_map(|i| i.id().map(str::to_string)).collect();
if actual != fp_ids {
return Err(IpcFailure::from("Footprint update did not return every parent footprint".to_string()));
}
Ok(())
})?;
"footprint"
}
Err(e) => return Err(e),
};
let mut out = json!({"success":true,"committed":true,"mutated":true,"undoSteps":1,"updatedIds":ids,"footprintIds":plan.edits.iter().map(|e|&e.footprint_id).collect::<Vec<_>>(),"saved":false,"drc":after,"updateMechanism":mechanism,"_hint":if mechanism == "field" {"Only nested reference/value fields were updated; no whole footprint replacement. Inspect postCommit errors before any retry, then refresh the exact linked views and inspect the board."} else {"This KiCad cannot update a field on its own, so the parent footprints were sent back with only these fields changed (pose and every other child exactly as KiCad sent them); otherBoardDataVerified and fieldsVerified confirm it. Inspect postCommit errors before any retry, then refresh the exact linked views."}});
match s.client.get_editable_items_by_id(ids.clone()) {
Ok(actual) => {
let matches = updates.iter().all(|want| {
actual.iter().any(|got| {
id(got) == id(want) && got.clone().into_any() == want.clone().into_any()
})
});
out["fieldsVerified"] = json!(matches);
if !matches {
out["success"] = json!(false);
out["postCommitFieldError"]=json!("Native field readback differs from requested geometry or preserved content; inspect before retrying");
}
}
Err(e) => {
out["success"] = json!(false);
out["postCommitFieldError"] = json!(e.to_string());
}
}
match s.snapshot() {
Ok((current, _, rev)) => {
out["revision"] = json!(rev);
let set = ids.iter().cloned().collect();
let (preserved, diff) = match (
fields::preserved_geometry(&text, &set),
fields::preserved_geometry(¤t, &set),
) {
(Ok(before), Ok(after)) => (before == after, fields::preservation_diff(&before, &after, 12)),
_ => (false, Vec::new()),
};
if !diff.is_empty() {
out["preservationDiff"] = json!(diff);
}
out["otherBoardDataVerified"] = json!(preserved);
let fps: Vec<String> = plan.edits.iter().map(|e| e.footprint_id.clone()).collect();
let dropped = fields::dropped_closing_vertices(&text, ¤t, &fps);
if !dropped.is_empty() {
out["closingVerticesDroppedByKiCad"] = json!(dropped);
out["_libraryParityHint"] = json!(fields::CLOSING_VERTEX_HINT);
}
if !preserved {
out["success"] = json!(false);
out["postCommitPreservationError"]=json!("Board data outside the requested fields changed; this may also indicate concurrent editing");
}
}
Err(e) => {
out["success"] = json!(false);
out["postCommitReadbackError"] = json!(e.message);
}
}
match s.client.get_item_bounding_boxes(ids,false){
Ok(rows)=>out["bounds"]=json!(rows.iter().map(|b|json!({"id":b.item_id,"boxMm":[b.x_nm as f64/1e6,b.y_nm as f64/1e6,(b.x_nm+b.width_nm) as f64/1e6,(b.y_nm+b.height_nm) as f64/1e6]})).collect::<Vec<_>>()),
Err(e)=>{out["success"]=json!(false);out["postCommitBoundsError"]=json!(e.to_string());}
}
if args["save"] == true && out["success"] == true {
match s.client.save_document() {
Ok(()) => out["saved"] = json!(true),
Err(e) => {
out["success"] = json!(false);
out["postCommitSaveError"] = json!(e.to_string());
}
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unsupported_field_is_permanent_only_after_confirmed_rollback() {
let message =
"ISC_INVALID_TYPE: Tried to create an item of type PCB_FIELD_T, which is unhandled";
let e = classify_field_failure(
RoutingError::new("mutation_rolled_back", message).with("mutated", json!(false)),
"10.0.5",
);
assert_eq!(e.code, "native_field_update_unsupported");
assert_eq!(e.detail["retryable"], false);
assert_eq!(e.detail["kicadVersion"], "10.0.5");
for code in ["mutation_outcome_unknown", "ipc_timeout"] {
let e = classify_field_failure(
RoutingError::new(code, message).with("mutated", Value::Null),
"10.0.5",
);
assert_eq!(e.code, code);
assert_eq!(e.detail["mutated"], Value::Null);
}
let e = classify_field_failure(
RoutingError::new("drc_rejected", format!("item request status error `{message}`")).with("mutated", json!(false)),
"10.0.6",
);
assert_eq!(e.code, "native_field_update_unsupported", "wiki #129: the shape seen on 10.0.6");
let e = classify_field_failure(
RoutingError::new("mutation_rolled_back", "ISC_INVALID_TYPE: other type")
.with("mutated", json!(false)),
"10.0.5",
);
assert_eq!(e.code, "mutation_rolled_back");
}
#[test]
fn nested_field_uuid_and_content_survive_geometry_update() {
let mut f = FieldItem::from_proto(Default::default());
let p = f.proto_mut();
p.name = "Reference".into();
p.id = Some(Default::default());
p.visible = true;
let b = p.text.get_or_insert_with(Default::default);
b.id.get_or_insert_with(Default::default).value = "field-id".into();
b.locked = 1;
let text = b.text.get_or_insert_with(Default::default);
text.text = "R1".into();
text.hyperlink = "preserve-me".into();
let e=fields::Edit{text:crate::silk_text::Text::parse(&json!({"text":"R1","layer":"B.SilkS","x":12.,"y":19.,"rotation":90.,"height":0.7,"width":0.6,"stroke":0.1,"align":"right"}),"field-id".into()).unwrap(),visible:false,footprint_id:"parent".into(),name:"Reference".into()};
let result = apply_geometry(f, &e).unwrap();
assert_eq!(id(&result), Some("field-id"));
let EditablePcbItem::Field(field) = result else {
panic!("not a field")
};
let p = field.proto();
assert_eq!(p.name, "Reference");
assert!(!p.visible);
assert_eq!(p.id.as_ref().unwrap().id, 0);
let b = p.text.as_ref().unwrap();
let t = b.text.as_ref().unwrap();
assert_eq!(t.text, "R1");
assert_eq!(t.hyperlink, "preserve-me");
assert_eq!(t.position.as_ref().unwrap().x_nm, 12_000_000);
assert_eq!(b.layer, BoardLayerInfo::id_from_name("B.SilkS").unwrap());
let a = t.attributes.as_ref().unwrap();
assert_eq!(a.horizontal_alignment, 3);
assert!(a.mirrored);
assert_eq!(a.size.as_ref().unwrap().y_nm, 700_000);
let mut bold = field.clone();
bold.proto_mut()
.text
.as_mut()
.unwrap()
.text
.as_mut()
.unwrap()
.attributes
.as_mut()
.unwrap()
.bold = true;
assert!(apply_geometry(bold, &e).is_err());
}
}