master
Colby Knox Merge PR #313: kicad-lane: 1.0.68, BOM, JLCPCB assembly and order, land-pattern footprints, designators e1c2dac 13d ago
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(&current, &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, &current, &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());
    }
}