main
Caleb Publish 0.2.0 cbe04a0 11h ago
//! The board's own KiCad project rules (.kicad_pro and .kicad_dru beside the .kicad_pcb). KiCad's DRC
//! judges the board by these, so the router must meet them too: a rule that lives only in the
//! router's spec is how a "clean" route fails DRC (AI_Mistakes_Log R5).
//!
//! Honoured from .kicad_pro: net_settings classes (clearance, track_width, via_diameter, via_drill,
//! diff_pair_width, diff_pair_gap, priority), netclass_patterns (wildcards), netclass_assignments;
//! design_settings rules min_clearance, min_track_width, min_hole_clearance, min_hole_to_hole,
//! min_copper_edge_clearance, min_via_diameter, min_through_hole_diameter, min_via_annular_width,
//! solder_mask_to_copper_clearance; rule_severities. Not honoured: regular-expression patterns.
//!
//! Honoured from .kicad_dru: only `min` values of track_width, clearance, hole_clearance,
//! hole_to_hole, edge_clearance, via_diameter, hole_size and annular_width in rules with no
//! condition and no layer, and clearance in rules conditioned exactly on `A.NetClass == 'X'`. They
//! only ever raise a limit: an unconditional 0.09 mm clearance would lower KiCad's own answer but
//! does not lower the designer's 0.15 (2.2). Rules lowering one net's clearance at one pad (`PadRule`) are
//! honoured down to that same minimum. Every other rule is listed in a note, so nothing is silently
//! dropped.

use crate::geom::*;
use crate::rules::{NetClass, PadRule, Rules};
use crate::sx;
use serde_json::Value;

/// Applies the project's rules on top of `r`. Where the project is stricter than the standing rule
/// the project wins, and the difference is reported so it can be fixed in the board setup.
pub fn apply(board_path: &str, r: &mut Rules) -> Vec<String> {
    let base = std::path::Path::new(board_path);
    let pro = base.with_extension("kicad_pro");
    let dru = base.with_extension("kicad_dru");
    apply_files(&pro, &dru, r)
}

/// What KiCad applies to a board with no project file beside it.
const DEFAULT_PROJECT: &str = r#"{"board":{"design_settings":{"rules":{"min_clearance":0.0,"min_track_width":0.2,"min_copper_edge_clearance":0.5,"min_hole_clearance":0.25,"min_hole_to_hole":0.25}}},
"net_settings":{"classes":[{"name":"Default","clearance":0.2,"track_width":0.2,"via_diameter":0.6,"via_drill":0.3,"diff_pair_width":0.2,"diff_pair_gap":0.25}]}}"#;

pub fn apply_files(pro: &std::path::Path, dru: &std::path::Path, r: &mut Rules) -> Vec<String> {
    let mut notes = Vec::new();
    let standing = r.clearance;
    r.floor = standing;
    let text = match std::fs::read_to_string(pro) {
        Ok(t) => t,
        Err(_) => {
            // KiCad judges a board that has no project by its own defaults, so the router works to
            // the same ones. Read off KiCad 10's DRC on wiki boards without a project (2026-11-10):
            // "netclass clearance 0.2", "board setup constraints min width 0.2", "edge clearance
            // 0.5", "hole clearance 0.25". Routing those boards at the standing 0.15 mm neck and
            // 0.3 mm edge drew track_width and copper_edge_clearance errors on seven of them.
            notes.push(format!("no project file at {}: using KiCad's defaults for a board without one (0.2 mm clearance and minimum track, 0.5 mm to the board edge, 0.25 mm to holes)", pro.display()));
            DEFAULT_PROJECT.to_string()
        }
    };
    let Ok(v) = serde_json::from_str::<Value>(&text) else {
        notes.push(format!("{} did not parse: using the standing rules", pro.display()));
        return notes;
    };
    read_classes(&v, r, &mut notes);
    // A setting the user named replaces the Default class's value; other classes keep theirs, since
    // those are the designer's deliberate exceptions (a 0.5 mm high-voltage class stays 0.5 mm).
    let ex = r.explicit;
    for c in r.classes.iter_mut().filter(|c| c.name == "Default") {
        if ex.clearance.is_some() { c.clearance = ex.clearance; }
        if ex.track.is_some() { c.track = ex.track; }
        if ex.via.is_some() { c.via_dia = ex.via; }
        if ex.drill.is_some() { c.via_drill = ex.drill; }
    }
    let strictest = r.classes.iter().filter_map(|c| c.clearance).max().unwrap_or(0);
    if strictest > standing {
        // The router asks clearance_for per pair of nets; this number is the loosest class, used as
        // an optimistic floor by the coarse map, so a stricter class can never wrongly close a lane.
        let loosest = r.classes.iter().filter_map(|c| c.clearance).min().unwrap_or(strictest);
        r.clearance = loosest.max(standing);
        notes.push(format!("project net classes require {:.3} to {:.3} mm clearance by net pair, with a {:.3} mm standing minimum; each pair is judged by its classes, and `clearance` holds the loosest, {:.3} mm, for the coarse map", to_mm(loosest), to_mm(strictest), to_mm(standing), to_mm(r.clearance)));
    } else if strictest > 0 && strictest < standing {
        notes.push(format!("project net classes allow {:.3} mm clearance, below the {:.3} mm standing minimum; routing at the standard", to_mm(strictest), to_mm(standing)));
    }
    if let Some(rules) = v.pointer("/board/design_settings/rules") {
        let val = |k: &str| rules.get(k).and_then(|x| x.as_f64()).map(mm);
        if let Some(x) = val("min_copper_edge_clearance") {
            r.edge = r.edge.max(x);
        }
        if let Some(x) = val("min_hole_to_hole") {
            r.hole_hole = r.hole_hole.max(x);
            r.min.hole_to_hole = x;
        }
        r.min.clearance = val("min_clearance").unwrap_or(0);
        r.min.track = val("min_track_width").unwrap_or(0);
        if let Some(n) = r.explicit.neck {
            r.min.track = r.min.track.min(n);
        }
        if let Some(c) = r.explicit.clearance {
            r.min.clearance = r.min.clearance.min(c);
        }
        r.min.hole_clearance = val("min_hole_clearance").unwrap_or(0);
        r.min.via_dia = val("min_via_diameter").unwrap_or(0);
        r.min.through_drill = val("min_through_hole_diameter").unwrap_or(0);
        r.min.annular = val("min_via_annular_width").unwrap_or(0);
        r.min.mask_to_copper = val("solder_mask_to_copper_clearance").unwrap_or(0);
    }
    if let Some(o) = v.pointer("/board/design_settings/rule_severities").and_then(|x| x.as_object()) {
        for (k, s) in o {
            if let Some(s) = s.as_str() {
                r.severities.insert(k.clone(), s.to_string());
            }
        }
    }
    apply_dru(dru, r, &mut notes);
    notes
}

fn read_classes(v: &Value, r: &mut Rules, notes: &mut Vec<String>) {
    let Some(classes) = v.pointer("/net_settings/classes").and_then(|c| c.as_array()) else { return };
    for c in classes {
        let val = |k: &str| c.get(k).and_then(|x| x.as_f64()).map(mm);
        let name = c.get("name").and_then(|n| n.as_str()).unwrap_or("").to_string();
        if name == "Default" {
            if let (Some(d), Some(h)) = (val("via_diameter"), val("via_drill")) {
                if d != r.via || h != r.drill {
                    notes.push(format!("project via is {}/{} (drill/diameter); the standard is {}/{}", to_mm(h), to_mm(d), to_mm(r.drill), to_mm(r.via)));
                }
            }
        }
        r.classes.push(NetClass {
            priority: c.get("priority").and_then(|x| x.as_i64()).unwrap_or(i64::MAX),
            clearance: val("clearance"),
            track: val("track_width"),
            via_dia: val("via_diameter"),
            via_drill: val("via_drill"),
            pair_width: val("diff_pair_width"),
            pair_gap: val("diff_pair_gap"),
            name,
        });
    }
    if let Some(ps) = v.pointer("/net_settings/netclass_patterns").and_then(|c| c.as_array()) {
        for p in ps {
            let (Some(pat), Some(class)) = (p.get("pattern").and_then(|x| x.as_str()), p.get("netclass").and_then(|x| x.as_str())) else { continue };
            if pat.starts_with('/') && pat.ends_with('/') && pat.len() > 1 {
                notes.push(format!("netclass pattern {pat} is a regular expression; not honoured, so {class} may be missing nets"));
                continue;
            }
            r.patterns.push((pat.to_string(), class.to_string()));
        }
    }
    if let Some(o) = v.pointer("/net_settings/netclass_assignments").and_then(|c| c.as_object()) {
        for (net, cls) in o {
            let list: Vec<String> = match cls {
                Value::String(s) => vec![s.clone()],
                Value::Array(a) => a.iter().filter_map(|x| x.as_str().map(String::from)).collect(),
                _ => Vec::new(),
            };
            r.assignments.insert(net.clone(), list);
        }
    }
}

/// "0.127mm", "5mil" or a bare mm number.
fn dru_value(s: &str) -> Option<i64> {
    if let Some(x) = s.strip_suffix("mm") {
        return x.parse::<f64>().ok().map(mm);
    }
    if let Some(x) = s.strip_suffix("mil") {
        return x.parse::<f64>().ok().map(|m| mm(m * 0.0254));
    }
    s.parse::<f64>().ok().map(mm)
}

fn apply_dru(path: &std::path::Path, r: &mut Rules, notes: &mut Vec<String>) {
    let Ok(text) = std::fs::read_to_string(path) else { return };
    // Rules sit side by side with # comments, so give the reader a single list without them.
    let body: String = text.lines().filter(|l| !l.trim_start().starts_with('#')).collect::<Vec<_>>().join("\n");
    let Ok(root) = sx::parse(&format!("(dru {body})")) else {
        notes.push(format!("{} did not parse: its rules are not honoured", path.display()));
        return;
    };
    for rule in root.children("rule") {
        let name = rule.atom(1).unwrap_or("").to_string();
        let cond = rule.child("condition").and_then(|c| c.atom(1));
        let layered = rule.child("layer").is_some();
        for c in rule.children("constraint") {
            let kind = c.atom(1).unwrap_or("");
            let Some(min) = c.child("min").and_then(|m| m.atom(1)).and_then(dru_value) else {
                notes.push(format!(".kicad_dru rule \"{name}\": constraint {kind} has no min value; not honoured"));
                continue;
            };
            // A rule scoped to one net at one pad is a recorded exception: KiCad's DRC honours it,
            // so the router and checker must too. The standing minimum still holds (Rules::bind).
            if let (Some(rule), "clearance") = (cond.and_then(|x| PadRule::parse(x, min)), kind) {
                r.pad_rules.push(rule);
                continue;
            }
            let class = cond.and_then(|x| x.strip_prefix("A.NetClass == '")).and_then(|x| x.strip_suffix('\''));
            match (cond, layered, kind) {
                (Some(_), false, "clearance") if class.is_some() => {
                    if let Some(k) = r.classes.iter_mut().find(|k| Some(k.name.as_str()) == class) {
                        k.clearance = Some(k.clearance.unwrap_or(0).max(min));
                    }
                }
                (None, false, "clearance") => r.min.clearance = r.min.clearance.max(min),
                (None, false, "track_width") => r.min.track = r.min.track.max(min),
                (None, false, "hole_clearance") => r.min.hole_clearance = r.min.hole_clearance.max(min),
                (None, false, "hole_to_hole") => r.min.hole_to_hole = r.min.hole_to_hole.max(min),
                (None, false, "edge_clearance") => r.edge = r.edge.max(min),
                (None, false, "via_diameter") => r.min.via_dia = r.min.via_dia.max(min),
                (None, false, "hole_size") => r.min.through_drill = r.min.through_drill.max(min),
                (None, false, "annular_width") => r.min.annular = r.min.annular.max(min),
                _ => notes.push(format!(".kicad_dru rule \"{name}\": {kind} with its condition or layer is not honoured")),
            }
        }
    }
}


/// Writes the output board's project: a copy of the input's, with any setting the user named written
/// into the Default class and the board minimums, so KiCad's DRC judges the route by the same numbers.
/// The input's project is never changed (4.6). Returns what it changed.
pub fn write_for_output(board_in: &str, board_out: &str, ex: &crate::rules::Explicit) -> Result<Vec<String>, String> {
    let src = std::path::Path::new(board_in).with_extension("kicad_pro");
    let dst = std::path::Path::new(board_out).with_extension("kicad_pro");
    let Ok(text) = std::fs::read_to_string(&src) else {
        return Ok(if ex.any() { vec![format!("no project at {} to carry your settings: set them in the board setup", src.display())] } else { Vec::new() });
    };
    if !ex.any() {
        std::fs::write(&dst, text).map_err(|e| format!("{}: {e}", dst.display()))?;
        return Ok(Vec::new());
    }
    let mut v: Value = serde_json::from_str(&text).map_err(|e| format!("{}: {e}", src.display()))?;
    let mut changed = Vec::new();
    let num = |x: i64| Value::from(to_mm(x));
    let mut set = |node: Option<&mut Value>, key: &str, x: Option<i64>, only_down: bool, what: &str| {
        let (Some(n), Some(x)) = (node, x) else { return };
        let old = n.get(key).and_then(|o| o.as_f64());
        if let Some(o) = old {
            if (mm(o) - x).abs() < 1 || (only_down && mm(o) <= x) {
                return;
            }
        }
        changed.push(format!("{what}: {} to {:.3} mm", old.map(|o| format!("{o:.3}")).unwrap_or_else(|| "unset".into()), to_mm(x)));
        n[key] = num(x);
    };
    let classes = v.pointer_mut("/net_settings/classes").and_then(|c| c.as_array_mut());
    if let Some(c) = classes.and_then(|c| c.iter_mut().find(|c| c.get("name").and_then(|n| n.as_str()) == Some("Default"))) {
        set(Some(&mut *c), "clearance", ex.clearance, false, "Default class clearance");
        set(Some(&mut *c), "track_width", ex.track, false, "Default class track");
        set(Some(&mut *c), "via_diameter", ex.via, false, "Default class via diameter");
        set(Some(&mut *c), "via_drill", ex.drill, false, "Default class via drill");
    }
    if let Some(r) = v.pointer_mut("/board/design_settings/rules") {
        set(Some(&mut *r), "min_track_width", ex.neck, true, "board minimum track");
        set(Some(&mut *r), "min_clearance", ex.clearance, true, "board minimum clearance");
    }
    let out = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
    std::fs::write(&dst, out).map_err(|e| format!("{}: {e}", dst.display()))?;
    Ok(changed)
}

/// Writes the output board's .kicad_dru: the input's rules unchanged, then any pad rules this route
/// proposes, marked for review. The input's file is never changed (4.6). Returns the path if written.
pub fn write_dru(board_in: &str, board_out: &str, rules: &[PadRule]) -> Result<Option<String>, String> {
    let src = std::path::Path::new(board_in).with_extension("kicad_dru");
    let dst = std::path::Path::new(board_out).with_extension("kicad_dru");
    let had = std::fs::read_to_string(&src).ok();
    if had.is_none() && rules.is_empty() {
        return Ok(None);
    }
    let mut text = had.unwrap_or_else(|| "(version 1)\n".to_string());
    if !rules.is_empty() {
        text.push_str("\n# adom-route proposes the rules below FOR REVIEW: each pad's own footprint leaves no track room\n# under its net class. Delete a rule to reject it; DRC then reports that clearance again.\n");
    }
    for r in rules {
        text.push_str(&format!("(rule \"adom-route pad exit {} {} {}\"\n  (condition \"{}\")\n  (constraint clearance (min {:.3}mm)))\n", r.net, r.reference, r.pad, r.condition(), to_mm(r.min)));
    }
    std::fs::write(&dst, text).map_err(|e| format!("{}: {e}", dst.display()))?;
    Ok(Some(dst.display().to_string()))
}