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.
master
Colby Knox
Merge PR #365: kicad-lane: 1.0.94, export_3d, import_design, edit_pours, tool_update, import_step; schematic part verbs…
2cd78c4
11d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724
//! 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,
/// A rule area (keepout) instead of copper: what it keeps out.
pub keepout: Option<Keepout>,
}
/// Rule-area flags (`kicad_add_zone {"type": "keepout", "keepout": {"copperPour": true, ...}}`).
#[derive(Clone, Debug, Default)]
pub struct Keepout {
pub copper_pour: bool,
pub tracks: bool,
pub vias: bool,
pub pads: bool,
pub footprints: bool,
}
impl ZoneSpec {
pub fn public(&self) -> Value {
if let Some(k) = &self.keepout {
return json!({
"type": "keepout", "layers": self.layers, "points": self.polygon.len(), "name": self.name,
"keepout": {"copperPour": k.copper_pour, "tracks": k.tracks, "vias": k.vias, "pads": k.pads, "footprints": k.footprints},
"outlineAreaMm2": round2(polygon_area(&self.polygon).abs()),
});
}
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 is_keepout = z.get("type").and_then(Value::as_str) == Some("keepout") || z.get("keepout").is_some();
let keepout = if is_keepout {
let flags = z.get("keepout").cloned().unwrap_or_else(|| json!({"copperPour": true}));
let f = |name: &str| flags.get(name).and_then(Value::as_bool).unwrap_or(false);
let ko = Keepout { copper_pour: f("copperPour"), tracks: f("tracks"), vias: f("vias"), pads: f("pads"), footprints: f("footprints") };
if !(ko.copper_pour || ko.tracks || ko.vias || ko.pads || ko.footprints) {
return Err(RoutingError::new("invalid_argument", format!("zone {}: a keepout needs at least one of copperPour, tracks, vias, pads, footprints", k + 1)));
}
Some(ko)
} else {
None
};
let net = if is_keepout {
NetRef { format: board.net_format, number: None, name: String::new(), key: Value::Null }
} else {
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() && !is_keepout {
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(|| if is_keepout { "keepout".to_string() } 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,
keepout,
});
}
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");
if let Some(k) = &z.keepout {
// a rule area: no net, the layers, the keepout flags, the outline
if z.net.format == pcb::NetFormat::Number {
s.push_str("\t\t(net 0)\n\t\t(net_name \"\")\n");
}
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");
let a = |b: bool| if b { "not_allowed" } else { "allowed" };
s.push_str(&format!("\t\t(keepout\n\t\t\t(tracks {})\n\t\t\t(vias {})\n\t\t\t(pads {})\n\t\t\t(copperpour {})\n\t\t\t(footprints {})\n\t\t)\n", a(k.tracks), a(k.vias), a(k.pads), a(k.copper_pour), a(k.footprints)));
s.push_str("\t\t(fill\n\t\t\t(thermal_gap 0.5)\n\t\t\t(thermal_bridge_width 0.5)\n\t\t)\n");
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)");
return s;
}
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(island_removal_mode 0)\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();
if let Some(k) = &z.keepout {
wire_varint(&mut out, 2, 3); // ZT_RULE_AREA
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 ra = Vec::new();
for (n, b) in [(1u32, k.copper_pour), (2, k.vias), (3, k.tracks), (4, k.pads), (5, k.footprints)] {
if b {
wire_varint(&mut ra, n, 1);
}
}
wire_message(&mut out, 7, &ra);
let mut border = Vec::new();
wire_varint(&mut border, 1, 3);
wire_message(&mut border, 2, &distance_bytes(500_000));
wire_message(&mut out, 11, &border);
wire_varint(&mut out, 12, 1);
return out;
}
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
}
/// One protobuf message with field `number` replaced: every other field is copied byte for byte, and `with` (the
/// replacement field's complete encoding, or nothing to drop it) is appended. Field order does not matter in protobuf.
fn replace_field(msg: &[u8], number: u32, with: Option<Vec<u8>>) -> Result<Vec<u8>, String> {
let mut out = Vec::with_capacity(msg.len() + 16);
for f in parse_fields(msg)? {
if f.number == number {
continue;
}
crate::placement::put_varint(&mut out, ((f.number as u64) << 3) | f.wire as u64);
if f.wire == 2 {
crate::placement::put_varint(&mut out, f.data.len() as u64);
}
out.extend_from_slice(f.data);
}
if let Some(w) = with {
out.extend_from_slice(&w);
}
Ok(out)
}
/// A nested message field's body (empty when absent).
fn sub_message<'a>(msg: &'a [u8], number: u32) -> Result<&'a [u8], String> {
Ok(parse_fields(msg)?.into_iter().find(|f| f.number == number && f.wire == 2).map(|f| f.data).unwrap_or(&[]))
}
fn enc_message(number: u32, body: &[u8]) -> Vec<u8> {
let mut v = Vec::new();
wire_message(&mut v, number, body);
v
}
fn enc_varint(number: u32, value: u64) -> Vec<u8> {
let mut v = Vec::new();
wire_varint(&mut v, number, value);
v
}
/// `kicad_edit_pours` live: a zone KiCad returned (`kiapi.board.types.Zone`, id and outline kept) with the requested
/// settings changed, for `UpdateItems`. `p` is one pours[] entry in the standard's names; `net_code` is the live code
/// of `p.net` when it changes the net. Only the fields `p` names are touched.
pub fn rewrite_zone(zone: &[u8], p: &Value, net_code: Option<i32>) -> Result<Vec<u8>, String> {
let mm = |k: &str| p.get(k).and_then(Value::as_f64);
let th = p.get("thermals").filter(|t| t.is_object());
let thm = |k: &str| th.and_then(|t| t.get(k)).and_then(Value::as_f64);
let mut settings = sub_message(zone, 6)?.to_vec();
// connection {1 style, 2 thermal_spokes {1 width, 3 gap}}
let mut conn = sub_message(&settings, 1)?.to_vec();
let style_now = parse_fields(&conn)?.into_iter().find(|f| f.number == 1 && f.wire == 0).map(|f| varint_value(f.data)).transpose()?.unwrap_or(0);
let style = match p.get("connection").and_then(Value::as_str) {
Some("thermal") => Some(3),
Some("solid") => Some(4),
Some("none") => Some(2),
Some("reliefsOnThroughHole") => Some(5),
Some(o) => return Err(format!("connection {o} is not thermal, solid, none or reliefsOnThroughHole")),
None => match th.and_then(|t| t.get("on")).and_then(Value::as_bool) {
Some(true) => Some(if style_now == 5 { 5 } else { 3 }),
Some(false) => Some(4),
None => None,
},
};
if let Some(st) = style {
conn = replace_field(&conn, 1, Some(enc_varint(1, st)))?;
}
if thm("spokeMm").is_some() || thm("gapMm").is_some() {
let mut spokes = sub_message(&conn, 2)?.to_vec();
if let Some(w) = thm("spokeMm") {
spokes = replace_field(&spokes, 1, Some(enc_message(1, &distance_bytes(nm(w)))))?;
}
if let Some(g) = thm("gapMm") {
spokes = replace_field(&spokes, 3, Some(enc_message(3, &distance_bytes(nm(g)))))?;
}
conn = replace_field(&conn, 2, Some(enc_message(2, &spokes)))?;
}
settings = replace_field(&settings, 1, Some(enc_message(1, &conn)))?;
if let Some(c) = mm("clearanceMm") {
settings = replace_field(&settings, 2, Some(enc_message(2, &distance_bytes(nm(c)))))?;
}
if let Some(w) = mm("minWidthMm") {
settings = replace_field(&settings, 3, Some(enc_message(3, &distance_bytes(nm(w)))))?;
}
if let Some(keep) = p.get("keepIslands").and_then(Value::as_bool) {
settings = replace_field(&settings, 4, Some(enc_varint(4, if keep { 2 } else { 1 })))?; // IRM_NEVER | IRM_ALWAYS
}
if let Some(net) = p.get("net").and_then(Value::as_str) {
let mut code = Vec::new();
if let Some(c) = net_code.filter(|c| *c != 0) {
wire_varint(&mut code, 1, c as u64);
}
let mut n = Vec::new();
wire_message(&mut n, 1, &code);
wire_message(&mut n, 2, net.as_bytes());
settings = replace_field(&settings, 8, Some(enc_message(8, &n)))?;
}
let mut out = replace_field(zone, 6, Some(enc_message(6, &settings)))?;
if let Some(pr) = p.get("priority").and_then(Value::as_u64) {
out = replace_field(&out, 8, if pr > 0 { Some(enc_varint(8, pr)) } else { None })?;
}
Ok(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 rule_area: bool,
/// ZoneType: 1 copper, 2 graphical, 3 rule area, 4 teardrop (KiCad makes those itself at pad entries).
pub zone_type: u32,
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);
}
}
}
(2, 0) => {
z.zone_type = varint_value(f.data)? as u32;
z.rule_area = z.zone_type == 3;
}
(7, 2) => z.rule_area = true,
(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<_>>(),
"type": match z.zone_type { 3 => "keepout", 4 => "teardrop", 2 => "graphical", _ => "copper" },
"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 rewrite_zone_changes_only_the_requested_settings() {
let b = board();
let specs = parse_specs(&b, &json!({"net": "GND", "layers": ["F.Cu"], "polygon": [[1, 1], [10, 1], [10, 8], [1, 8]], "priority": 2, "connection": "solid"})).unwrap();
let bytes = zone_bytes(&specs[0], &[3], 7);
let out = rewrite_zone(&bytes, &json!({"clearanceMm": 0.35, "priority": 4, "thermals": {"on": true, "spokeMm": 0.6}, "keepIslands": true, "net": "VCC"}), Some(9)).unwrap();
let back = decode_zone(&out).unwrap();
assert_eq!((back.priority, back.net.as_str(), back.layers.clone()), (4, "VCC", vec![3]));
assert!((back.outline_area_mm2 - 63.0).abs() < 1e-6, "the outline is untouched");
let settings = sub_message(&out, 6).unwrap();
let dist = |m: &[u8], n: u32| varint_value(parse_fields(sub_message(m, n).unwrap()).unwrap()[0].data).unwrap();
assert_eq!(dist(settings, 2), 350_000);
assert_eq!(dist(settings, 3), nm(specs[0].min_thickness) as u64, "min width kept");
let conn = sub_message(settings, 1).unwrap();
let field = |m: &[u8], n: u32| parse_fields(m).unwrap().into_iter().find(|f| f.number == n).map(|f| varint_value(f.data).unwrap());
assert_eq!(field(conn, 1), Some(3), "thermal reliefs on");
let spokes = sub_message(conn, 2).unwrap();
assert_eq!((dist(spokes, 1), dist(spokes, 3)), (600_000, nm(specs[0].thermal_gap) as u64), "spoke changed, gap kept");
assert_eq!(field(settings, 4), Some(2), "IRM_NEVER keeps islands");
assert_eq!(sub_message(&out, 5).unwrap(), sub_message(&bytes, 5).unwrap(), "name kept");
// priority 0 drops the field; nothing asked changes nothing
assert!(decode_zone(&rewrite_zone(&bytes, &json!({"priority": 0}), None).unwrap()).unwrap().priority == 0);
assert_eq!(decode_zone(&rewrite_zone(&bytes, &json!({}), None).unwrap()).unwrap().net, "GND");
}
#[test]
fn keepout_zone_round_trips() {
let b = board();
let specs = parse_specs(&b, &json!({"type": "keepout", "layers": ["F.Cu", "B.Cu"], "polygon": [[1, 1], [5, 1], [5, 3], [1, 3]], "keepout": {"copperPour": true}, "name": "kelvin"})).unwrap();
let z = &specs[0];
assert!(z.keepout.as_ref().unwrap().copper_pour && z.net.name.is_empty());
let s = zone_sexpr(z);
assert!(s.contains("(copperpour not_allowed)") && s.contains("(tracks allowed)") && !s.contains("(net "));
let back = decode_zone(&zone_bytes(z, &[3, 34], 0)).unwrap();
assert!(back.rule_area && back.net.is_empty() && back.layers == vec![3, 34]);
assert_eq!(z.public()["type"], json!("keepout"));
}
#[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, rule_area: false, zone_type: 1, 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));
}
}