← Commit history

1.0.29: NATIVE (Rust) build. 1.0.29: merges Astra PR #19: KiCad's PCB_FIELD_T refusal is classified as native_field_update_unsupported (kicadVersion, retryable:false) after a confirmed rollback; doc records the 10.0.5 limitation (wiki #104).

John Lauer ·7e18d908dd ·1mo ago ·parent 99b778d
3 files changed +259−4
rust/Cargo.lock+3−3
@@ -244,7 +244,7 @@ dependencies = [  [[package]] name = "kicad-bridge"-version = "1.0.28"+version = "1.0.29" dependencies = [  "kicad-core",  "kicad-platform",@@ -255,7 +255,7 @@ dependencies = [  [[package]] name = "kicad-core"-version = "1.0.28"+version = "1.0.29" dependencies = [  "kicad-ipc-rs",  "kicad-platform",@@ -281,7 +281,7 @@ dependencies = [  [[package]] name = "kicad-platform"-version = "1.0.28"+version = "1.0.29" dependencies = [  "serde",  "serde_json",
rust/Cargo.toml+1−1
@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"]  [workspace.package]-version = "1.0.28"+version = "1.0.29" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-core/src/ipc/silk_fields.rs.origadded+255
@@ -0,0 +1,255 @@+use super::*;+use crate::silk_fields as fields;+use kicad_ipc_rs::FieldItem;++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))+}++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("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()?)?;+    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(())+    })?;+    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,"_hint":"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."});+    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 = match (+                fields::preserved_geometry(&text, &set),+                fields::preserved_geometry(&current, &set),+            ) {+                (Ok(before), Ok(after)) => before == after,+                _ => false,+            };+            out["otherBoardDataVerified"] = json!(preserved);+            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 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());+    }+}