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));
    }
}