master
John Lauer Add native stable-ID silkscreen leaders and borders with DRC and Undo da12d65 1mo ago
//! Standalone silkscreen graphics, with stable UUIDs and matching DRC/IPC geometry.
use crate::pcb::RoutingError;
use crate::placement::{distance_bytes, parse_fields, vector2_bytes, wire_message, wire_varint};
use crate::schematic::{new_uuid, parse_node, Document, Node};
use serde_json::{json, Value};
use std::collections::HashSet;

pub const TYPE_URL: &str = "type.googleapis.com/kiapi.board.types.BoardGraphicShape";
fn fail(s: impl Into<String>) -> RoutingError {
    RoutingError::new("silk_graphics_refused", s).with("mutated", json!(false))
}
#[derive(Clone, Debug)]
pub struct Graphic {
    pub id: String,
    pub layer: String,
    pub kind: String,
    pub points: Vec<(f64, f64)>,
    pub stroke: f64,
}
impl Graphic {
    fn parse(v: &Value, id: String) -> Result<Self, RoutingError> {
        let o = v
            .as_object()
            .ok_or_else(|| fail("graphic must be an object"))?;
        if o.keys()
            .any(|k| !["id", "layer", "kind", "points", "stroke"].contains(&k.as_str()))
        {
            return Err(fail("unsupported graphic property"));
        }
        let layer = v["layer"]
            .as_str()
            .filter(|s| matches!(*s, "F.SilkS" | "B.SilkS"))
            .ok_or_else(|| fail("silkscreen layer required"))?;
        let kind = v["kind"].as_str().ok_or_else(|| fail("kind required"))?;
        let count = match kind {
            "line" | "rect" => 2,
            "arc" => 3,
            "bezier" => 4,
            _ => return Err(fail("kind must be line, rect, arc or bezier")),
        };
        let input = v["points"]
            .as_array()
            .filter(|a| a.len() == count)
            .ok_or_else(|| fail("wrong point count"))?;
        let mut points = Vec::new();
        for p in input {
            let a = p
                .as_array()
                .filter(|a| a.len() == 2)
                .ok_or_else(|| fail("point requires x,y"))?;
            let coord = |v: &Value| {
                v.as_f64()
                    .filter(|n| n.is_finite() && n.abs() <= 10000.)
                    .ok_or_else(|| fail("invalid coordinate"))
            };
            points.push((coord(&a[0])?, coord(&a[1])?));
        }
        let stroke = v["stroke"]
            .as_f64()
            .filter(|n| n.is_finite() && *n > 0. && *n <= 5.)
            .ok_or_else(|| fail("stroke must be positive mm, at most 5"))?;
        if points.first() == points.last() {
            return Err(fail("degenerate geometry"));
        }
        if kind == "rect" && (points[0].0 == points[1].0 || points[0].1 == points[1].1) {
            return Err(fail("degenerate rectangle"));
        }
        if kind == "arc" {
            let (a, b, c) = (points[0], points[1], points[2]);
            if ((b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0)).abs() < 1e-12 {
                return Err(fail("collinear arc"));
            }
        }
        Ok(Self {
            id,
            layer: layer.into(),
            kind: kind.into(),
            points,
            stroke,
        })
    }
    pub fn node(&self) -> Node {
        let point = |tag: &str, p: (f64, f64)| format!("({tag} {} {})", p.0, p.1);
        let geometry = match self.kind.as_str() {
            "bezier" => format!(
                "(pts {})",
                self.points
                    .iter()
                    .map(|p| point("xy", *p))
                    .collect::<Vec<_>>()
                    .join(" ")
            ),
            "arc" => format!(
                "{} {} {}",
                point("start", self.points[0]),
                point("mid", self.points[1]),
                point("end", self.points[2])
            ),
            _ => format!(
                "{} {}",
                point("start", self.points[0]),
                point("end", self.points[1])
            ),
        };
        let kind = if self.kind == "bezier" {
            "curve"
        } else {
            &self.kind
        };
        parse_node(&format!(
            "(gr_{kind} {geometry} (stroke (width {}) (type solid)) {} (layer {}) (uuid {}))",
            self.stroke,
            if self.kind == "rect" {
                "(fill none)"
            } else {
                ""
            },
            serde_json::to_string(&self.layer).unwrap(),
            serde_json::to_string(&self.id).unwrap()
        ))
        .unwrap()
    }
    pub fn bytes(&self, layer: i32) -> Vec<u8> {
        let mut stroke = Vec::new();
        wire_message(
            &mut stroke,
            1,
            &distance_bytes((self.stroke * 1e6).round() as i64),
        );
        wire_varint(&mut stroke, 2, 2);
        let mut fill = Vec::new();
        wire_varint(&mut fill, 1, 1);
        let mut attrs = Vec::new();
        wire_message(&mut attrs, 1, &stroke);
        wire_message(&mut attrs, 2, &fill);
        let mut geometry = Vec::new();
        for (i, p) in self.points.iter().enumerate() {
            wire_message(
                &mut geometry,
                (i + 1) as u32,
                &vector2_bytes((p.0 * 1e6).round() as i64, (p.1 * 1e6).round() as i64),
            );
        }
        let mut shape = Vec::new();
        wire_message(&mut shape, 3, &attrs);
        wire_message(
            &mut shape,
            match self.kind.as_str() {
                "line" => 4,
                "rect" => 5,
                "arc" => 6,
                _ => 9,
            },
            &geometry,
        );
        let mut id = Vec::new();
        wire_message(&mut id, 1, self.id.as_bytes());
        let mut out = Vec::new();
        wire_message(&mut out, 1, &shape);
        wire_varint(&mut out, 2, layer as u64);
        wire_message(&mut out, 4, &id);
        wire_varint(&mut out, 5, 1);
        out
    }
}
pub fn id(bytes: &[u8]) -> Option<String> {
    let fields = parse_fields(bytes).ok()?;
    let id = fields.iter().find(|f| f.number == 4)?;
    let nested = parse_fields(id.data).ok()?;
    String::from_utf8(nested.iter().find(|f| f.number == 1)?.data.to_vec()).ok()
}
pub struct Plan {
    pub candidate: String,
    pub create: Vec<Graphic>,
    pub update: Vec<Graphic>,
    pub remove: Vec<String>,
}
pub fn prepare(text: &str, args: &Value) -> Result<Plan, RoutingError> {
    let mut doc = Document::parse(text).map_err(fail)?;
    let mut touched = HashSet::new();
    let editable = |id: &str| -> Result<(), RoutingError> {
        let n = doc
            .root
            .children()
            .iter()
            .find(|n| n.child_value("uuid").as_deref() == Some(id))
            .ok_or_else(|| fail("unknown standalone graphic ID"))?;
        if !matches!(
            n.head(),
            Some("gr_line" | "gr_rect" | "gr_arc" | "gr_curve")
        ) || !matches!(
            n.child_value("layer").as_deref(),
            Some("F.SilkS" | "B.SilkS")
        ) || n
            .child("locked")
            .map(|c| c.arg(0).as_deref() != Some("no"))
            .unwrap_or(false)
        {
            return Err(fail(
                "ID is locked or is not a supported standalone silkscreen graphic",
            ));
        }
        Ok(())
    };
    let rows = |key: &str| -> Result<Vec<Value>, RoutingError> {
        match args.get(key) {
            None => Ok(vec![]),
            Some(v) => v
                .as_array()
                .filter(|a| a.len() <= 2000)
                .cloned()
                .ok_or_else(|| fail("batch must be an array of at most 2000 items")),
        }
    };
    let mut create = Vec::new();
    let mut update = Vec::new();
    let mut remove = Vec::new();
    for v in rows("create")? {
        if v.get("id").is_some() {
            return Err(fail("create IDs are bridge-assigned"));
        }
        create.push(Graphic::parse(&v, new_uuid())?);
    }
    for v in rows("update")? {
        let id = v["id"].as_str().ok_or_else(|| fail("update needs id"))?;
        editable(id)?;
        if !touched.insert(id.to_string()) {
            return Err(fail("duplicate ID"));
        }
        update.push(Graphic::parse(&v, id.into())?);
    }
    for v in rows("remove")? {
        let id = v.as_str().ok_or_else(|| fail("remove needs IDs"))?;
        editable(id)?;
        if !touched.insert(id.to_string()) {
            return Err(fail("duplicate ID"));
        }
        remove.push(id.into());
    }
    if create.is_empty() && update.is_empty() && remove.is_empty() {
        return Err(fail("empty batch"));
    }
    let children = doc.root.children_mut().unwrap();
    children.retain(|n| {
        !n.child_value("uuid")
            .map(|id| touched.contains(&id))
            .unwrap_or(false)
    });
    children.extend(create.iter().chain(&update).map(Graphic::node));
    Ok(Plan {
        candidate: doc.to_text(),
        create,
        update,
        remove,
    })
}
#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn curves_and_borders_preserve_ids_and_coordinates() {
        for (kind, points) in [
            ("line", json!([[-2, 3], [4, 5]])),
            ("rect", json!([[-2, 3], [4, 5]])),
            ("arc", json!([[-2, 3], [0, 5], [4, 5]])),
            ("bezier", json!([[-2, 3], [0, 5], [3, 6], [4, 5]])),
        ] {
            let g = Graphic::parse(
                &json!({"kind":kind,"layer":"B.SilkS","points":points,"stroke":0.1}),
                "same-id".into(),
            )
            .unwrap();
            assert_eq!(id(&g.bytes(38)).as_deref(), Some("same-id"));
            assert_eq!(g.node().child_value("uuid").as_deref(), Some("same-id"));
            assert!(g.node().to_text().contains("-2 3"));
        }
    }
    #[test]
    fn refuses_foreign_locked_and_degenerate_shapes() {
        let text="(kicad_pcb (gr_line (uuid a) (layer F.Cu)) (gr_rect (uuid b) (layer F.SilkS) (locked yes)))";
        for id in ["a", "b", "unknown"] {
            assert!(prepare(text, &json!({"remove":[id]})).is_err());
        }
        assert!(Graphic::parse(
            &json!({"kind":"arc","layer":"F.SilkS","points":[[0,0],[1,1],[2,2]],"stroke":0.1}),
            "x".into()
        )
        .is_err());
    }
}