← Commit history

1.0.4: 1.0.4: kicad_add_zone (copper pours as one Undo step with a filled DRC preflight) and kicad_zone_state (filled copper per layer, the ablation metric)

John Lauer ·3efaab041b ·1mo ago ·parent 472362a
2 files changed +505−6
rust/crates/kicad-core/src/placement.rs+6−6
@@ -840,10 +840,10 @@ fn kind(msg: Msg, number: u32) -> Kind {  /// One field on the wire: its number, wire type and the raw bytes after the tag (a /// varint's own bytes, the 8 or 4 fixed bytes, or a length-delimited payload).-struct WireField<'a> {-    number: u32,-    wire: u8,-    data: &'a [u8],+pub(crate) struct WireField<'a> {+    pub(crate) number: u32,+    pub(crate) wire: u8,+    pub(crate) data: &'a [u8], }  fn read_varint(b: &[u8], i: &mut usize) -> Result<u64, String> {@@ -869,7 +869,7 @@ pub(crate) fn put_varint(out: &mut Vec<u8>, mut v: u64) {     out.push(v as u8); } -fn parse_fields(b: &[u8]) -> Result<Vec<WireField<'_>>, String> {+pub(crate) fn parse_fields(b: &[u8]) -> Result<Vec<WireField<'_>>, String> {     let mut i = 0;     let mut out = Vec::new();     while i < b.len() {@@ -962,7 +962,7 @@ pub(crate) fn distance_bytes(nm: i64) -> Vec<u8> {     out } -fn varint_value(data: &[u8]) -> Result<u64, String> {+pub(crate) fn varint_value(data: &[u8]) -> Result<u64, String> {     read_varint(data, &mut 0) } 
rust/crates/kicad-core/src/zones.rsadded+499
@@ -0,0 +1,499 @@+//! Copper zones (pours) on the live board.+//!+//! `kicad_add_zone` creates one or more copper zones through KiCad's IPC API+//! (`CreateItems` with `kiapi.board.types.Zone`) as ONE native Undo step, after the same+//! offline DRC preflight `kicad_route_net` uses: the zone's s-expression is spliced into+//! the editor's snapshot and KiCad's DRC (zones refilled) must add no error, or nothing+//! lands. `kicad_zone_state` reads every zone back with its filled copper area per layer,+//! which is the number a copper-ablation process cares about: what stays on the board.+//!+//! This module has no IPC dependency of its own: it parses the request, writes the+//! s-expression for the preflight, encodes the protobuf bytes for `CreateItems` and+//! decodes the bytes KiCad returns. `ipc.rs` does the talking.+use std::collections::BTreeMap;++use serde_json::{json, Value};++use crate::pcb::{self, Board, NetRef, RoutingError};+use crate::placement::{distance_bytes, parse_fields, varint_value, vector2_bytes, wire_message, wire_varint};+use crate::schematic::new_uuid;++pub const ZONE_TYPE_URL: &str = "type.googleapis.com/kiapi.board.types.Zone";++/// One copper zone as the caller asked for it.+#[derive(Clone, Debug)]+pub struct ZoneSpec {+    pub net: NetRef,+    pub layers: Vec<String>,+    pub polygon: Vec<(f64, f64)>,+    pub priority: u32,+    pub clearance: f64,+    pub min_thickness: f64,+    /// Pad connection: solid (true) or thermal reliefs (false, KiCad's default).+    pub solid: bool,+    pub thermal_gap: f64,+    pub thermal_bridge: f64,+    pub name: String,+}++impl ZoneSpec {+    pub fn public(&self) -> Value {+        json!({+            "net": self.net.name, "layers": self.layers, "points": self.polygon.len(), "priority": self.priority,+            "clearance": self.clearance, "minThickness": self.min_thickness,+            "connection": if self.solid { "solid" } else { "thermal" }, "name": self.name,+            "outlineAreaMm2": round2(polygon_area(&self.polygon).abs()),+        })+    }+}++fn round2(v: f64) -> f64 {+    (v * 100.0).round() / 100.0+}++/// Shoelace area of a closed polygon (signed: positive when counter-clockwise in a y-up frame).+pub fn polygon_area(pts: &[(f64, f64)]) -> f64 {+    if pts.len() < 3 {+        return 0.0;+    }+    let mut a = 0.0;+    for i in 0..pts.len() {+        let (x1, y1) = pts[i];+        let (x2, y2) = pts[(i + 1) % pts.len()];+        a += x1 * y2 - x2 * y1;+    }+    a / 2.0+}++fn mm(v: Option<&Value>, label: &str, default: f64) -> Result<f64, RoutingError> {+    match v {+        None | Some(Value::Null) => Ok(default),+        Some(x) => pcb::number(Some(x), label, true),+    }+}++/// `kicad_add_zone` arguments: `zones: [ {...} ]` or one zone's fields at the top level.+/// Each zone: `net`, `layers` (or `layer`), `polygon` [[x, y], ...] in mm (3+ points),+/// optional `priority`, `clearance`, `minThickness`, `connection` ("thermal" | "solid"),+/// `thermalGap`, `thermalBridge`, `name`.+pub fn parse_specs(board: &Board, args: &Value) -> Result<Vec<ZoneSpec>, RoutingError> {+    let list: Vec<Value> = match args.get("zones") {+        Some(Value::Array(a)) => a.clone(),+        Some(_) => return Err(RoutingError::new("invalid_argument", "zones must be a list of zone objects")),+        None => vec![args.clone()],+    };+    if list.is_empty() {+        return Err(RoutingError::new("invalid_argument", "zones is empty"));+    }+    let copper = pcb::copper_layers(board);+    let mut out = Vec::with_capacity(list.len());+    for (k, z) in list.iter().enumerate() {+        let net = pcb::resolve_net(board, z.get("net")).map_err(|resp| {+            RoutingError::new("unknown_net", format!("zone {}: {}", k + 1, resp.get("message").and_then(Value::as_str).unwrap_or("net not found on this board")))+                .with("availableNets", json!(board.nets.iter().map(|n| n.name.clone()).filter(|n| !n.is_empty()).collect::<Vec<_>>()))+        })?;+        if net.name.is_empty() {+            return Err(RoutingError::new("invalid_argument", format!("zone {}: a copper zone needs a net", k + 1)));+        }+        let mut layers: Vec<String> = match (z.get("layers"), z.get("layer")) {+            (Some(Value::Array(a)), _) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),+            (Some(Value::String(s)), _) | (None, Some(Value::String(s))) => vec![s.clone()],+            _ => Vec::new(),+        };+        layers.dedup();+        if layers.is_empty() {+            return Err(RoutingError::new("invalid_argument", format!("zone {}: layers is required (copper layer names such as \"F.Cu\")", k + 1)).with("copperLayers", json!(copper)));+        }+        for l in &layers {+            if !copper.iter().any(|c| c == l) {+                return Err(RoutingError::new("unknown_layer", format!("zone {}: {} is not a copper layer of this board", k + 1, l)).with("copperLayers", json!(copper)));+            }+        }+        let mut polygon = Vec::new();+        if let Some(Value::Array(pts)) = z.get("polygon") {+            for p in pts {+                let (x, y) = match p {+                    Value::Array(xy) if xy.len() == 2 => (xy[0].as_f64(), xy[1].as_f64()),+                    Value::Object(o) => (o.get("x").and_then(Value::as_f64), o.get("y").and_then(Value::as_f64)),+                    _ => (None, None),+                };+                match (x, y) {+                    (Some(x), Some(y)) if x.is_finite() && y.is_finite() => polygon.push((x, y)),+                    _ => return Err(RoutingError::new("invalid_argument", format!("zone {}: every polygon point is [x, y] in mm", k + 1))),+                }+            }+        }+        if polygon.len() >= 2 && polygon.first() == polygon.last() {+            polygon.pop();+        }+        if polygon.len() < 3 {+            return Err(RoutingError::new("invalid_argument", format!("zone {}: polygon needs at least three points", k + 1)));+        }+        if polygon_area(&polygon).abs() < 0.01 {+            return Err(RoutingError::new("invalid_argument", format!("zone {}: polygon has no area", k + 1)));+        }+        let connection = z.get("connection").and_then(Value::as_str).unwrap_or("thermal");+        let solid = match connection {+            "thermal" | "thermal_relief" | "reliefs" => false,+            "solid" | "full" => true,+            other => return Err(RoutingError::new("invalid_argument", format!("zone {}: connection {other:?} is not \"thermal\" or \"solid\"", k + 1))),+        };+        let priority = match z.get("priority") {+            None | Some(Value::Null) => 0,+            Some(v) => v.as_u64().filter(|p| *p <= 255).ok_or_else(|| RoutingError::new("invalid_argument", format!("zone {}: priority is an integer 0..255", k + 1)))? as u32,+        };+        let name = z.get("name").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| format!("{} pour", net.name));+        out.push(ZoneSpec {+            net,+            layers,+            polygon,+            priority,+            clearance: mm(z.get("clearance"), "clearance", 0.2)?,+            min_thickness: mm(z.get("minThickness"), "minThickness", 0.25)?,+            solid,+            thermal_gap: mm(z.get("thermalGap"), "thermalGap", 0.5)?,+            thermal_bridge: mm(z.get("thermalBridge"), "thermalBridge", 0.5)?,+            name,+        });+    }+    Ok(out)+}++/// The zone as a `.kicad_pcb` block, for the DRC preflight splice (KiCad fills it when it+/// loads the snapshot; no filled polygons are written).+pub fn zone_sexpr(z: &ZoneSpec) -> String {+    let mut s = String::from("\t(zone\n");+    s.push_str(&pcb::net_sexpr(&z.net));+    if z.net.format == pcb::NetFormat::Number {+        s.push_str(&format!("\t\t(net_name {})\n", serde_json::to_string(&z.net.name).unwrap_or_default()));+    }+    if z.layers.len() == 1 {+        s.push_str(&format!("\t\t(layer \"{}\")\n", z.layers[0]));+    } else {+        s.push_str(&format!("\t\t(layers {})\n", z.layers.iter().map(|l| format!("\"{l}\"")).collect::<Vec<_>>().join(" ")));+    }+    s.push_str(&format!("\t\t(uuid \"{}\")\n", new_uuid()));+    s.push_str(&format!("\t\t(name {})\n", serde_json::to_string(&z.name).unwrap_or_default()));+    s.push_str("\t\t(hatch edge 0.5)\n");+    if z.priority > 0 {+        s.push_str(&format!("\t\t(priority {})\n", z.priority));+    }+    if z.solid {+        s.push_str(&format!("\t\t(connect_pads yes\n\t\t\t(clearance {})\n\t\t)\n", pcb::num(z.clearance)));+    } else {+        s.push_str(&format!("\t\t(connect_pads\n\t\t\t(clearance {})\n\t\t)\n", pcb::num(z.clearance)));+    }+    s.push_str(&format!("\t\t(min_thickness {})\n", pcb::num(z.min_thickness)));+    s.push_str("\t\t(filled_areas_thickness no)\n");+    s.push_str(&format!("\t\t(fill yes\n\t\t\t(thermal_gap {})\n\t\t\t(thermal_bridge_width {})\n\t\t)\n", pcb::num(z.thermal_gap), pcb::num(z.thermal_bridge)));+    let pts: Vec<String> = z.polygon.iter().map(|(x, y)| format!("(xy {} {})", pcb::num(*x), pcb::num(*y))).collect();+    s.push_str(&format!("\t\t(polygon\n\t\t\t(pts\n\t\t\t\t{}\n\t\t\t)\n\t\t)\n", pts.join(" ")));+    s.push_str("\t)");+    s+}++fn nm(v: f64) -> i64 {+    (v * 1_000_000.0).round() as i64+}++fn polyset_bytes(polygon: &[(f64, f64)]) -> Vec<u8> {+    let mut line = Vec::new();+    for (x, y) in polygon {+        let mut node = Vec::new();+        wire_message(&mut node, 1, &vector2_bytes(nm(*x), nm(*y)));+        wire_message(&mut line, 1, &node);+    }+    wire_varint(&mut line, 2, 1); // closed+    let mut poly = Vec::new();+    wire_message(&mut poly, 1, &line);+    let mut set = Vec::new();+    wire_message(&mut set, 1, &poly);+    set+}++/// `kiapi.board.types.Zone` for `CreateItems`: no id (KiCad assigns one), copper type,+/// the layers, the outline, the copper settings with the net, priority, unlocked, a+/// diagonal-edge border. Never filled here: the fill is KiCad's, on request.+pub fn zone_bytes(z: &ZoneSpec, layer_ids: &[i32], net_code: i32) -> Vec<u8> {+    let mut out = Vec::new();+    wire_varint(&mut out, 2, 1); // ZT_COPPER+    for id in layer_ids {+        wire_varint(&mut out, 3, *id as u64);+    }+    wire_message(&mut out, 4, &polyset_bytes(&z.polygon));+    wire_message(&mut out, 5, z.name.as_bytes());+    let mut settings = Vec::new();+    {+        let mut conn = Vec::new();+        wire_varint(&mut conn, 1, if z.solid { 4 } else { 3 }); // ZCS_FULL | ZCS_THERMAL+        let mut spokes = Vec::new();+        wire_message(&mut spokes, 1, &distance_bytes(nm(z.thermal_bridge)));+        wire_message(&mut spokes, 3, &distance_bytes(nm(z.thermal_gap)));+        wire_message(&mut conn, 2, &spokes);+        wire_message(&mut settings, 1, &conn);+        wire_message(&mut settings, 2, &distance_bytes(nm(z.clearance)));+        wire_message(&mut settings, 3, &distance_bytes(nm(z.min_thickness)));+        wire_varint(&mut settings, 4, 1); // IRM_ALWAYS: no floating islands+        wire_varint(&mut settings, 6, 1); // ZFM_SOLID+        let mut net = Vec::new();+        let mut code = Vec::new();+        if net_code != 0 {+            wire_varint(&mut code, 1, net_code as u64);+        }+        wire_message(&mut net, 1, &code);+        wire_message(&mut net, 2, z.net.name.as_bytes());+        wire_message(&mut settings, 8, &net);+    }+    wire_message(&mut out, 6, &settings);+    if z.priority > 0 {+        wire_varint(&mut out, 8, z.priority as u64);+    }+    let mut border = Vec::new();+    wire_varint(&mut border, 1, 3); // ZBS_DIAGONAL_EDGE+    wire_message(&mut border, 2, &distance_bytes(500_000));+    wire_message(&mut out, 11, &border);+    wire_varint(&mut out, 12, 1); // LS_UNLOCKED+    out+}++/// Copper layer names for the `BoardLayer` enum values (F.Cu = 3, In1..In30 = 4..33, B.Cu = 34).+pub fn copper_layer_name(id: i32) -> String {+    match id {+        3 => "F.Cu".to_string(),+        34 => "B.Cu".to_string(),+        4..=33 => format!("In{}.Cu", id - 3),+        other => format!("layer{other}"),+    }+}++/// What `kicad_zone_state` reads back from one zone.+#[derive(Clone, Debug, Default)]+pub struct ZoneRead {+    pub id: String,+    pub name: String,+    pub net: String,+    pub layers: Vec<i32>,+    pub priority: u32,+    pub filled: bool,+    pub outline_area_mm2: f64,+    /// (layer id, polygon count, filled area in mm2) per filled layer.+    pub fills: Vec<(i32, usize, f64)>,+}++fn string_of(data: &[u8]) -> String {+    String::from_utf8_lossy(data).into_owned()+}++/// Points of a PolyLine (arcs contribute their start, mid and end).+fn polyline_points(b: &[u8]) -> Result<Vec<(f64, f64)>, String> {+    let mut pts = Vec::new();+    for f in parse_fields(b)? {+        if f.number != 1 || f.wire != 2 {+            continue;+        }+        for g in parse_fields(f.data)? {+            match (g.number, g.wire) {+                (1, 2) => {+                    let (x, y) = decode_point(g.data)?;+                    pts.push((x, y));+                }+                (2, 2) => {+                    for h in parse_fields(g.data)? {+                        if h.wire == 2 && (1..=3).contains(&h.number) {+                            let (x, y) = decode_point(h.data)?;+                            pts.push((x, y));+                        }+                    }+                }+                _ => {}+            }+        }+    }+    Ok(pts)+}++fn decode_point(b: &[u8]) -> Result<(f64, f64), String> {+    let (mut x, mut y) = (0i64, 0i64);+    for f in parse_fields(b)? {+        match (f.number, f.wire) {+            (1, 0) => x = varint_value(f.data)? as i64,+            (2, 0) => y = varint_value(f.data)? as i64,+            _ => {}+        }+    }+    Ok((x as f64 / 1e6, y as f64 / 1e6))+}++/// Area of a PolySet in mm2: each polygon's outline minus its holes. (KiCad's filled+/// polygons of one zone do not overlap each other, so the sum is the copper.)+fn polyset_area(b: &[u8]) -> Result<(usize, f64), String> {+    let mut count = 0;+    let mut area = 0.0;+    for f in parse_fields(b)? {+        if f.number != 1 || f.wire != 2 {+            continue;+        }+        count += 1;+        for g in parse_fields(f.data)? {+            if g.wire != 2 {+                continue;+            }+            let a = polygon_area(&polyline_points(g.data)?).abs();+            match g.number {+                1 => area += a,+                2 => area -= a,+                _ => {}+            }+        }+    }+    Ok((count, area))+}++/// Decode the zone id from the bytes KiCad returns for a created zone.+pub fn zone_id(b: &[u8]) -> Option<String> {+    decode_zone(b).ok().map(|z| z.id).filter(|s| !s.is_empty())+}++pub fn decode_zone(b: &[u8]) -> Result<ZoneRead, String> {+    let mut z = ZoneRead::default();+    for f in parse_fields(b)? {+        match (f.number, f.wire) {+            (1, 2) => {+                for g in parse_fields(f.data)? {+                    if g.number == 1 && g.wire == 2 {+                        z.id = string_of(g.data);+                    }+                }+            }+            (3, 0) => z.layers.push(varint_value(f.data)? as i32),+            (3, 2) => {+                // packed repeated enum+                let mut i = 0;+                while i < f.data.len() {+                    let mut v: u64 = 0;+                    let mut shift = 0;+                    loop {+                        let byte = *f.data.get(i).ok_or("truncated packed varint")?;+                        i += 1;+                        v |= ((byte & 0x7f) as u64) << shift;+                        shift += 7;+                        if byte & 0x80 == 0 {+                            break;+                        }+                    }+                    z.layers.push(v as i32);+                }+            }+            (4, 2) => z.outline_area_mm2 = polyset_area(f.data)?.1,+            (5, 2) => z.name = string_of(f.data),+            (6, 2) => {+                for g in parse_fields(f.data)? {+                    if g.number == 8 && g.wire == 2 {+                        for h in parse_fields(g.data)? {+                            if h.number == 2 && h.wire == 2 {+                                z.net = string_of(h.data);+                            }+                        }+                    }+                }+            }+            (8, 0) => z.priority = varint_value(f.data)? as u32,+            (9, 0) => z.filled = varint_value(f.data)? != 0,+            (10, 2) => {+                let mut layer = 0i32;+                let mut fill = (0usize, 0.0f64);+                for g in parse_fields(f.data)? {+                    match (g.number, g.wire) {+                        (1, 0) => layer = varint_value(g.data)? as i32,+                        (2, 2) => fill = polyset_area(g.data)?,+                        _ => {}+                    }+                }+                z.fills.push((layer, fill.0, fill.1));+            }+            _ => {}+        }+    }+    Ok(z)+}++/// The `kicad_zone_state` body from decoded zones and the board's outline box.+pub fn state_json(zones: &[ZoneRead], board: &Board) -> Value {+    let mut by_layer: BTreeMap<String, f64> = BTreeMap::new();+    let mut list = Vec::with_capacity(zones.len());+    for z in zones {+        let fills: Vec<Value> = z+            .fills+            .iter()+            .map(|(layer, polys, area)| {+                let name = copper_layer_name(*layer);+                *by_layer.entry(name.clone()).or_insert(0.0) += area;+                json!({"layer": name, "polygons": polys, "areaMm2": round2(*area)})+            })+            .collect();+        list.push(json!({+            "id": z.id, "name": z.name, "net": z.net, "layers": z.layers.iter().map(|l| copper_layer_name(*l)).collect::<Vec<_>>(),+            "priority": z.priority, "filled": z.filled, "outlineAreaMm2": round2(z.outline_area_mm2), "fills": fills,+        }));+    }+    let board_area = board.dimensions.as_ref().map(|d| d.width * d.height).filter(|a| *a > 0.0);+    let coverage: BTreeMap<String, f64> = match board_area {+        Some(a) => by_layer.iter().map(|(l, v)| (l.clone(), round2(100.0 * v / a))).collect(),+        None => BTreeMap::new(),+    };+    json!({+        "zones": list, "zoneCount": zones.len(),+        "filledAreaByLayerMm2": by_layer.iter().map(|(l, v)| (l.clone(), json!(round2(*v)))).collect::<serde_json::Map<_, _>>(),+        "boardBoxAreaMm2": board_area.map(round2),+        "filledCoverageByLayerPercent": coverage,+        "_note": "Areas are KiCad's own filled polygons of every zone (outline minus holes), summed per layer; the coverage is against the outline's bounding box, so a non-rectangular board reads a little low. Pads, tracks and vias outside zones are not counted.",+    })+}++#[cfg(test)]+mod tests {+    use super::*;++    fn board() -> Board {+        pcb::parse_pcb_text(+            "(kicad_pcb (version 20240108) (generator \"pcbnew\") (layers (0 \"F.Cu\" signal) (31 \"B.Cu\" signal)) (footprint \"X\" (at 1 2) (pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\") (net \"GND\"))) (gr_rect (start 0 0) (end 50 40) (layer \"Edge.Cuts\")))",+            "t.kicad_pcb",+        )+        .unwrap()+    }++    #[test]+    fn parses_encodes_and_decodes_a_zone() {+        let b = board();+        let specs = parse_specs(&b, &json!({"net": "GND", "layers": ["F.Cu", "B.Cu"], "polygon": [[1, 1], [10, 1], [10, 8], [1, 8]], "priority": 2, "connection": "solid"})).unwrap();+        assert_eq!(specs.len(), 1);+        let z = &specs[0];+        assert!(z.solid && z.priority == 2 && z.layers.len() == 2);+        let s = zone_sexpr(z);+        assert!(s.contains("(layers \"F.Cu\" \"B.Cu\")") && s.contains("(priority 2)") && s.contains("(connect_pads yes") && s.contains("(xy 10 8)"));+        let bytes = zone_bytes(z, &[3, 34], 7);+        let back = decode_zone(&bytes).unwrap();+        assert_eq!(back.layers, vec![3, 34]);+        assert_eq!(back.net, "GND");+        assert_eq!(back.priority, 2);+        assert!((back.outline_area_mm2 - 63.0).abs() < 1e-6);+        assert!(back.id.is_empty() && !back.filled);+    }++    #[test]+    fn refuses_bad_zones() {+        let b = board();+        assert_eq!(parse_specs(&b, &json!({"net": "GND", "layers": ["F.Cu"], "polygon": [[0, 0], [1, 0]]})).unwrap_err().code, "invalid_argument");+        assert_eq!(parse_specs(&b, &json!({"net": "GND", "layers": ["F.Mask"], "polygon": [[0, 0], [1, 0], [1, 1]]})).unwrap_err().code, "unknown_layer");+        assert_eq!(parse_specs(&b, &json!({"net": "NOPE", "layers": ["F.Cu"], "polygon": [[0, 0], [1, 0], [1, 1]]})).unwrap_err().code, "unknown_net");+    }++    #[test]+    fn state_sums_fill_area_per_layer() {+        let b = board();+        let z = ZoneRead { id: "a".into(), name: "GND pour".into(), net: "GND".into(), layers: vec![3], priority: 0, filled: true, outline_area_mm2: 100.0, fills: vec![(3, 2, 80.0)] };+        let v = state_json(&[z], &b);+        assert_eq!(v["filledAreaByLayerMm2"]["F.Cu"], json!(80.0));+        assert_eq!(v["filledCoverageByLayerPercent"]["F.Cu"], json!(4.0));+    }+}