app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
← 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)
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));+ }+}