app
AI Flow: Routing
Public Made by Adomby adom
The routing stage of AI Flow: routes a placed KiCad board connection by connection and reports what it made, what it could not and why. A guide, never a gate.
main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
//! 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()))
}