master
Colby Knox Merge PR #307: kicad-lane: 1.0.64, live adds through KiCad's protobuf API, #126/#129, 17 standard core verbs a8fbf9f 14d ago
//! Reference/value field geometry, with file-local coordinates kept out of the API.
use crate::schematic::{parse_node, Document, Kind, Node};
use crate::{pcb::RoutingError, silk_text::Text};
use serde_json::{json, Value};
use std::collections::HashSet;
fn fail(s: impl Into<String>) -> RoutingError {
    RoutingError::new("silk_field_refused", s).with("mutated", json!(false))
}
pub struct Edit {
    pub text: Text,
    pub visible: bool,
    pub footprint_id: String,
    pub name: String,
}
pub struct Plan {
    pub candidate: String,
    pub edits: Vec<Edit>,
}
fn locked(n: &Node) -> bool {
    n.child("locked")
        .map(|x| x.arg(0).as_deref() != Some("no"))
        .unwrap_or(false)
}
pub fn prepare(source: &str, args: &Value) -> Result<Plan, RoutingError> {
    let rows = args["update"]
        .as_array()
        .filter(|a| !a.is_empty() && a.len() <= 2000)
        .ok_or_else(|| fail("update requires 1..2000 complete geometry definitions"))?;
    for k in ["create", "remove"] {
        if args.get(k).is_some() {
            return Err(fail(
                "fields are updated, never created or removed by this verb",
            ));
        }
    }
    let mut doc = Document::parse(source).map_err(fail)?;
    let mut edits = Vec::new();
    let mut seen = HashSet::new();
    for row in rows {
        let id = row["id"]
            .as_str()
            .ok_or_else(|| fail("field UUID is required"))?;
        if !seen.insert(id.to_string()) {
            return Err(fail("duplicate field UUID"));
        }
        if row.get("text").is_some() {
            return Err(fail(
                "reference/value content is preserved; text is not editable here",
            ));
        }
        let visible = row
            .get("visible")
            .map(Value::as_bool)
            .unwrap_or(Some(true))
            .ok_or_else(|| fail("visible must be boolean"))?;
        let fp = doc
            .root
            .children_mut()
            .unwrap()
            .iter_mut()
            .find(|fp| {
                fp.head() == Some("footprint")
                    && fp.children().iter().any(|p| {
                        p.head() == Some("property") && p.child_value("uuid").as_deref() == Some(id)
                    })
            })
            .ok_or_else(|| fail("UUID is not a footprint reference/value property"))?;
        if locked(fp) {
            return Err(fail("footprint is locked"));
        }
        let fp_id = fp
            .child_value("uuid")
            .ok_or_else(|| fail("footprint has no UUID"))?;
        let at = fp
            .child("at")
            .ok_or_else(|| fail("footprint has no pose"))?;
        let number = |i| at.arg(i).and_then(|x| x.parse::<f64>().ok());
        let (x, y, rot) = (
            number(0).ok_or_else(|| fail("invalid footprint x"))?,
            number(1).ok_or_else(|| fail("invalid footprint y"))?,
            number(2).unwrap_or(0.),
        );
        let p = fp
            .children_mut()
            .unwrap()
            .iter_mut()
            .find(|p| p.head() == Some("property") && p.child_value("uuid").as_deref() == Some(id))
            .unwrap();
        let name = p.arg(0).unwrap_or_default();
        if !matches!(name.as_str(), "Reference" | "Value") || locked(p) {
            return Err(fail("only unlocked Reference/Value fields are supported"));
        }
        let original = p.arg(1).ok_or_else(|| fail("field has no text"))?;
        let mut v = row.clone();
        v.as_object_mut()
            .ok_or_else(|| fail("update row must be an object"))?
            .remove("visible");
        v["text"] = json!(original);
        let t = Text::parse(&v, id.into())?;
        let local = crate::placement::to_board((0., 0.), -rot, (t.x - x, t.y - y));
        let node = t.node();
        let children = p.children_mut().unwrap();
        children.retain(|n| !matches!(n.head(), Some("at" | "layer" | "effects" | "hide")));
        children.push(parse_node(&format!("(at {} {} {})", local.0, local.1, t.rotation)).unwrap());
        children.push(node.child("layer").unwrap().clone());
        children.push(node.child("effects").unwrap().clone());
        if !visible {
            children.push(parse_node("(hide yes)").unwrap());
        }
        edits.push(Edit {
            text: t,
            visible,
            footprint_id: fp_id,
            name,
        });
    }
    Ok(Plan {
        candidate: doc.to_text(),
        edits,
    })
}
/// True for a `(pts ...)` list whose last point repeats its first: a closed polygon written
/// with its closing vertex, as KiCad's libraries and file format keep it.
fn repeats_first_point(n: &Node) -> bool {
    let pts = n.children();
    if n.head() != Some("pts") || pts.len() < 5 {
        return false;
    }
    let (first, last) = (&pts[1], &pts[pts.len() - 1]);
    first.head() == Some("xy") && last.head() == Some("xy") && first.arg(0) == last.arg(0) && first.arg(1) == last.arg(1)
}

/// Per footprint uuid, how many of its polygons carry the repeated closing vertex. KiCad's IPC
/// API rebuilds every footprint it updates (a move, a field edit) and stores each closed polygon
/// without that vertex: the same geometry, but KiCad's DRC then reports the part as differing from
/// its library (wiki #126). Comparing this count before and after an update names those parts.
pub fn closing_vertices(source: &str) -> std::collections::HashMap<String, usize> {
    fn count(n: &Node) -> usize {
        usize::from(repeats_first_point(n)) + n.children().iter().map(count).sum::<usize>()
    }
    let mut out = std::collections::HashMap::new();
    if let Ok(doc) = Document::parse(source) {
        for fp in doc.root.children().iter().filter(|c| c.head() == Some("footprint")) {
            if let Some(id) = fp.child_value("uuid") {
                out.insert(id, count(fp));
            }
        }
    }
    out
}

/// What a reply says when KiCad's IPC API dropped closing vertices from footprints it rebuilt.
pub const CLOSING_VERTEX_HINT: &str = "KiCad's IPC API rebuilds every footprint it updates and stores each closed polygon without its repeated closing point: the same geometry, KiCad's behaviour (not an edit by the bridge). KiCad's DRC may then report lib_footprint_mismatch for these footprints. Tools > Update Footprints from Library in the PCB editor (or kicad_update_footprints_from_library on the saved, closed board) brings back the library's copy; check its options so it keeps your placement and fields.";

/// The footprints among `ids` that lost closing vertices between `before` and `after`, with how
/// many: `[{id, dropped}]`, empty when none did.
pub fn dropped_closing_vertices(before: &str, after: &str, ids: &[String]) -> Vec<Value> {
    let (b, a) = (closing_vertices(before), closing_vertices(after));
    ids.iter()
        .filter_map(|id| {
            let lost = b.get(id).copied().unwrap_or(0).saturating_sub(a.get(id).copied().unwrap_or(0));
            (lost > 0).then(|| json!({"id": id, "dropped": lost}))
        })
        .collect()
}

/// Ignore only the explicitly edited property nodes, never pads, model data or other text.
pub fn preserved_geometry(source: &str, ids: &HashSet<String>) -> Result<Value, RoutingError> {
    fn visit(n: &Node, ids: &HashSet<String>) -> Value {
        match &n.kind {
            Kind::Atom(a) => json!(a),
            Kind::List { children, .. } => {
                // a polygon's repeated closing vertex is the same geometry: KiCad's IPC API drops
                // it whenever it rebuilds a footprint, so it is not a change to the board
                let kept = if repeats_first_point(n) { &children[..children.len() - 1] } else { &children[..] };
                Value::Array(
                    kept.iter()
                        .filter(|c| {
                            !(c.head() == Some("property")
                                && c.child_value("uuid")
                                    .map(|id| ids.contains(&id))
                                    .unwrap_or(false))
                        })
                        .map(|c| visit(c, ids))
                        .collect(),
                )
            }
        }
    }
    Ok(visit(&Document::parse(source).map_err(fail)?.root, ids))
}
/// Where two preserved_geometry trees first differ, as readable s-expression paths (at most `limit`).
pub fn preservation_diff(a: &Value, b: &Value, limit: usize) -> Vec<String> {
    fn label(v: &Value) -> String {
        match v {
            Value::Array(x) => x.first().and_then(Value::as_str).unwrap_or("?").to_string(),
            Value::String(s) => s.clone(),
            _ => v.to_string(),
        }
    }
    fn short(v: &Value) -> String {
        let s = v.to_string();
        if s.len() > 160 { format!("{}...", &s[..160]) } else { s }
    }
    fn walk(a: &Value, b: &Value, path: &str, out: &mut Vec<String>, limit: usize) {
        if out.len() >= limit || a == b {
            return;
        }
        match (a, b) {
            (Value::Array(x), Value::Array(y)) if x.len() == y.len() => {
                for (i, (p, q)) in x.iter().zip(y).enumerate() {
                    walk(p, q, &format!("{path}/{}[{i}]", label(p)), out, limit);
                }
            }
            _ => out.push(format!("{path}: {} -> {}", short(a), short(b))),
        }
    }
    let mut out = Vec::new();
    walk(a, b, "", &mut out, limit);
    out
}
#[cfg(test)]
mod tests {
    use super::*;
    fn board(side: &str, angle: i32) -> String {
        format!(
            r#"(kicad_pcb
          (footprint "P" (uuid fp) (layer "{side}") (at 10 20 {angle})
            (property "Reference" "R1" (at 0 0) (layer "F.SilkS") (uuid field) (effects (font (size 1 1))))
            (property "Value" "10k" (at 0 1) (layer "F.Fab") (uuid value))
            (pad "1" smd rect (at 1 2) (size 1 1) (layers "F.Cu" "F.Mask") (uuid pad))
            (model "chosen.step" (offset (xyz 0 0 0)) (scale (xyz 1 1 1))))
          (gr_text "R1" (uuid other)))"#
        )
    }
    #[test]
    fn field_identity_and_rotated_both_face_coordinates() {
        for side in ["F.Cu", "B.Cu"] {
            let b = board(side, 90);
            let p=prepare(&b,&json!({"update":[{"id":"field","x":12,"y":19,"rotation":90,"layer":"B.SilkS","height":0.7,"stroke":0.1,"align":"right","visible":false}]})).unwrap();
            let d = Document::parse(&p.candidate).unwrap();
            let fp = d.root.child("footprint").unwrap();
            let f = fp
                .children()
                .iter()
                .find(|n| n.head() == Some("property"))
                .unwrap();
            let at = f.child("at").unwrap();
            assert!((at.arg(0).unwrap().parse::<f64>().unwrap() - 1.).abs() < 1e-9);
            assert!((at.arg(1).unwrap().parse::<f64>().unwrap() - 2.).abs() < 1e-9);
            assert!(p.edits[0].text.mirrored);
            assert_eq!(f.arg(1).as_deref(), Some("R1"));
            assert_eq!(f.child_value("uuid").as_deref(), Some("field"));
            let ids = HashSet::from(["field".to_string()]);
            assert_eq!(
                preserved_geometry(&b, &ids).unwrap(),
                preserved_geometry(&p.candidate, &ids).unwrap()
            );
        }
    }
    #[test]
    fn refuses_content_changes_foreign_ids_and_duplicates() {
        let b = board("F.Cu", 0);
        let row = json!({"id":"field","x":1,"y":2,"layer":"F.SilkS","height":0.5,"stroke":0.1});
        assert!(prepare(&b, &json!({"update":[row.clone(),row.clone()]})).is_err());
        for (k, v) in [
            ("id", json!("other")),
            ("text", json!("R999")),
            ("layer", json!("F.Cu")),
            ("visible", json!("yes")),
        ] {
            let mut r = row.clone();
            r[k] = v;
            assert!(prepare(&b, &json!({"update":[r]})).is_err());
        }
    }
}

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

    const BEFORE: &str = "(kicad_pcb\n\t(footprint \"X\"\n\t\t(uuid \"fp1\")\n\t\t(fp_poly\n\t\t\t(pts\n\t\t\t\t(xy -2.4 -2.4) (xy -2.9 -2.4) (xy -2.4 -2.9) (xy -2.4 -2.4)\n\t\t\t)\n\t\t)\n\t)\n)\n";

    #[test]
    fn a_dropped_closing_vertex_is_the_same_geometry_and_is_counted() {
        let after = BEFORE.replace(" (xy -2.4 -2.4)\n", "\n");
        assert_ne!(BEFORE, after);
        let none = HashSet::new();
        assert_eq!(preserved_geometry(BEFORE, &none).unwrap(), preserved_geometry(&after, &none).unwrap());
        assert_eq!(closing_vertices(BEFORE).get("fp1"), Some(&1));
        assert_eq!(closing_vertices(&after).get("fp1"), Some(&0));
        // a real change is still one
        let moved = after.replace("(xy -2.9 -2.4)", "(xy -3.0 -2.4)");
        assert_ne!(preserved_geometry(BEFORE, &none).unwrap(), preserved_geometry(&moved, &none).unwrap());
    }
}