app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
main
John Lauer
Publish 0.2.59
82daddc
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
//! Issue #183: single-face / no-via boards (a CNC-milled one-layer board of through-hole parts).
//!
//! Two optional spec keys, both defaulting to the two-layer router with vias:
//! - `"routeLayers": ["B.Cu"]` (or `["F.Cu"]`, or both): the copper layers the router may use;
//! - `"vias": false`: no via anywhere. A layer change is made only on a through-hole pad of the same
//! net, where the component lead joins both faces. With both layers listed, F.Cu copper is a wire
//! jumper on the component side and B.Cu the milled face.
//!
//! When the spec says nothing, the project's `.kicad_dru` decides: `(constraint disallow via)` turns
//! vias off and `(constraint disallow track)` on one layer leaves only the other. A rule with a
//! `condition` applies to some items only and is not read as a board-wide rule.
//!
//! This module resolves the mode, splits plan paths that change layer on a through-hole pad (one path
//! per face, no via), lists the jumpers, and names whatever in a plan the mode cannot make.
use aiflow_board::{geom, sx, Board, B_CU, F_CU, LAYER_NAMES};
use serde_json::{json, Value};
use crate::{plan_entries, point};
#[derive(Clone, Debug, PartialEq)]
pub struct CopperMode {
/// May copper go on F.Cu, B.Cu?
pub layers: [bool; 2],
/// Vias anywhere (the default). Always false when only one layer is allowed.
pub vias: bool,
/// Where each setting came from: "default", "spec" or "kicad_dru".
pub layers_from: &'static str,
pub vias_from: &'static str,
/// What the resolution did that the human should know (dru rules applied, a spec key overriding
/// the dru, an unknown layer name ignored).
pub notes: Vec<String>,
}
impl Default for CopperMode {
fn default() -> CopperMode {
CopperMode { layers: [true, true], vias: true, layers_from: "default", vias_from: "default", notes: Vec::new() }
}
}
/// What a `.kicad_dru` disallows board-wide: vias, and tracks per layer (F.Cu, B.Cu).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct DruDisallow {
pub via: bool,
pub track: [bool; 2],
/// Rules that disallow something but only under a condition (not applied here), by name.
pub conditional: Vec<String>,
}
/// Read the `disallow` constraints of a `.kicad_dru` text. A file that does not parse disallows nothing.
pub fn dru_disallow(text: &str) -> DruDisallow {
let mut out = DruDisallow::default();
let mut clean = String::with_capacity(text.len());
for line in text.lines() {
let mut quoted = false;
let mut cut = line.len();
for (i, c) in line.char_indices() {
if c == '"' { quoted = !quoted; }
if c == '#' && !quoted { cut = i; break; }
}
clean.push_str(&line[..cut]);
clean.push('\n');
}
let Ok(root) = sx::parse(&format!("(dru {clean})")) else { return out };
for rule in root.find_all("rule") {
let kinds: Vec<String> = rule.find_all("constraint").filter(|c| c.atom(1) == Some("disallow"))
.flat_map(|c| (2..c.items.len() + 1).filter_map(|i| c.atom(i).map(str::to_string)).collect::<Vec<_>>()).collect();
if kinds.is_empty() { continue; }
if rule.find("condition").is_some() {
out.conditional.push(rule.atom(1).unwrap_or("?").to_string());
continue;
}
if kinds.iter().any(|k| k == "via" || k == "through_via") { out.via = true; }
if kinds.iter().any(|k| k == "track") {
match rule.find("layer").and_then(|l| l.value()) {
Some("F.Cu") => out.track[F_CU] = true,
Some("B.Cu") => out.track[B_CU] = true,
// no layer (every layer) or `outer`: not a routing rule a board can follow
_ => {}
}
}
}
out
}
impl CopperMode {
/// Both layers and vias: the two-layer router as it always was.
pub fn is_default(&self) -> bool {
self.layers == [true, true] && self.vias
}
pub fn layer_names(&self) -> Vec<&'static str> {
(0..2).filter(|&l| self.layers[l]).map(|l| LAYER_NAMES[l]).collect()
}
/// With both layers and no vias, F.Cu is the jumper layer (wires on the component side).
pub fn jumper_layer(&self) -> Option<usize> {
(!self.vias && self.layers == [true, true]).then_some(F_CU)
}
/// The milled face: the one allowed layer, or B.Cu when F.Cu is jumpers.
pub fn primary_layer(&self) -> usize {
if !self.layers[B_CU] { F_CU } else { B_CU }
}
/// The mode from the spec, then the project's `.kicad_dru` text for what the spec leaves unsaid.
pub fn resolve(spec: &Value, dru: Option<&str>) -> CopperMode {
let mut m = CopperMode::default();
let d = dru.map(dru_disallow).unwrap_or_default();
// routeLayers
match spec.get("routeLayers") {
Some(Value::Array(a)) => {
let mut layers = [false, false];
for v in a {
match v.as_str() {
Some("F.Cu") => layers[F_CU] = true,
Some("B.Cu") => layers[B_CU] = true,
other => m.notes.push(format!("spec routeLayers: {} is not a layer the router uses (F.Cu, B.Cu); ignored", other.map(str::to_string).unwrap_or_else(|| v.to_string()))),
}
}
if layers == [false, false] {
m.notes.push("spec routeLayers names no layer the router uses; both F.Cu and B.Cu are routed".into());
} else {
m.layers = layers;
m.layers_from = "spec";
}
}
Some(v) => m.notes.push(format!("spec routeLayers should be a list such as [\"B.Cu\"], not {v}; both layers are routed")),
None => {}
}
if m.layers_from == "default" && d.track[F_CU] != d.track[B_CU] {
let keep = if d.track[F_CU] { B_CU } else { F_CU };
m.layers = [keep == F_CU, keep == B_CU];
m.layers_from = "kicad_dru";
m.notes.push(format!("the project's .kicad_dru disallows tracks on {}: routing on {} only (say \"routeLayers\" in the spec to choose otherwise)", LAYER_NAMES[1 - keep], LAYER_NAMES[keep]));
} else if m.layers_from == "spec" {
for l in 0..2 {
if m.layers[l] && d.track[l] {
m.notes.push(format!("spec routeLayers allows {} but the project's .kicad_dru disallows tracks there: KiCad's DRC (the gate) will report every {} track{}", LAYER_NAMES[l], LAYER_NAMES[l], if !m.vias || d.via { " (the jumpers); drop that rule from the .kicad_dru when the jumpers are wires on purpose" } else { "" }));
}
}
}
// vias
match spec.get("vias") {
Some(Value::Bool(b)) => {
m.vias = *b;
m.vias_from = "spec";
if *b && d.via { m.notes.push("spec \"vias\": true, but the project's .kicad_dru has (constraint disallow via): the router plans vias and KiCad's DRC (the gate) will report each one".into()); }
}
Some(v) => m.notes.push(format!("spec vias should be true or false, not {v}; ignored")),
None => {
if d.via {
m.vias = false;
m.vias_from = "kicad_dru";
m.notes.push("the project's .kicad_dru has (constraint disallow via): no vias are planned, and a layer change is made only on a through-hole pad of the same net (say \"vias\": true in the spec to choose otherwise)".into());
}
}
}
if !d.conditional.is_empty() {
m.notes.push(format!("the project's .kicad_dru has disallow rule(s) with a condition ({}): they apply to some items only and do not change the router's layers or vias; KiCad's DRC still checks them", d.conditional.join(", ")));
}
if m.layers != [true, true] && m.vias {
// one face: a via has nothing to join
if m.vias_from == "spec" { m.notes.push(format!("spec \"vias\": true with routeLayers {}: one layer has nothing for a via to join; no vias are planned", m.layer_names().join(", "))); }
m.vias = false;
if m.vias_from == "default" { m.vias_from = m.layers_from; }
}
m
}
/// The mode for a board: the spec, then `<board>.kicad_dru` beside it.
pub fn for_board(spec: &Value, board_path: &str) -> CopperMode {
let dru = std::fs::read_to_string(std::path::Path::new(board_path).with_extension("kicad_dru")).ok();
CopperMode::resolve(spec, dru.as_deref())
}
/// The spec with the resolved mode written in (`routeLayers`, `vias`), so every step that reads the
/// spec (the router rules, pours, heatsink vias) sees what the .kicad_dru decided. A default mode
/// returns the spec unchanged.
pub fn apply_to_spec(&self, spec: &Value) -> Value {
let mut s = spec.clone();
if self.is_default() || !s.is_object() { return s; }
s["routeLayers"] = json!(self.layer_names());
s["vias"] = json!(false);
s
}
/// The words for the rules-used line: "layers B.Cu, vias off (kicad_dru)".
pub fn describe(&self) -> String {
let src = |f: &str| if f == "default" { String::new() } else { format!(" ({})", if f == "spec" { "spec" } else { "project .kicad_dru" }) };
let layers = format!("layers {}{}", self.layer_names().join(" + "), src(self.layers_from));
let vias = if self.vias { "vias on".to_string() } else {
format!("vias off{}: a layer change only on a through-hole pad of the same net{}", src(self.vias_from), match self.jumper_layer() { Some(j) => format!("; {} copper is wire jumpers on the component side", LAYER_NAMES[j]), None => String::new() })
};
format!("{layers}, {vias}")
}
}
/// Is (x, y) on a through-hole pad of `net` (inside its copper or its drill)? Returns the pad index.
pub fn pth_pad_at(board: &Board, net: &str, x: f64, y: f64) -> Option<usize> {
board.pads.iter().position(|p| p.thru && p.net == net && (p.polys.iter().any(|poly| geom::point_in_poly(x, y, poly)) || p.drill.is_some_and(|d| (p.x - x).hypot(p.y - y) <= d / 2.0 + 1e-6)))
}
/// Split every path of a no-via plan where it changes layer on a through-hole pad of its net: the
/// path ends on the pad on one face and the next starts on the pad on the other, so no via is made
/// there (the component lead joins the faces). A layer change anywhere else is left for the gate to
/// report. Returns one note per split.
pub fn split_at_pads(board: &Board, plan: &mut Value) -> Vec<String> {
let mut notes = Vec::new();
let Some(entries) = plan.get_mut("nets").and_then(|n| n.as_array_mut()) else { return notes };
for (ei, e) in entries.iter_mut().enumerate() {
let net = e.get("net").and_then(|n| n.as_str()).unwrap_or("").to_string();
let entry_layer = e.get("layer").and_then(|l| l.as_str()).map(str::to_string);
let key = if e.get("paths").map(|p| p.is_array()).unwrap_or(false) { "paths" } else if e.get("points").map(|p| p.is_array()).unwrap_or(false) { "points" } else { continue };
let paths: Vec<Value> = if key == "paths" { e["paths"].as_array().cloned().unwrap_or_default() } else { vec![e["points"].clone()] };
let mut out: Vec<Value> = Vec::new();
let mut changed = 0;
for path in paths {
let Some(pts) = path.as_array() else { out.push(path); continue };
let Ok(res) = pts.iter().map(|w| point(w, board)).collect::<Result<Vec<_>, _>>() else { out.push(path.clone()); continue };
let mut layer = entry_layer.clone().or_else(|| res.first().and_then(|r| r.3.clone()))
.unwrap_or_else(|| Board::path_start_layer(res.iter().map(|(_, _, pad, ex)| (pad.map(|i| &board.pads[i]), ex.is_some()))).to_string());
let mut cur: Vec<Value> = Vec::new();
for (k, (wp, (x, y, pad, explicit))) in pts.iter().zip(&res).enumerate() {
let nxt = explicit.clone().unwrap_or_else(|| layer.clone());
let on_pth = pad.filter(|&i| board.pads[i].thru && board.pads[i].net == net).or_else(|| pth_pad_at(board, &net, *x, *y));
if k > 0 && nxt != layer && on_pth.is_some() {
let pi = on_pth.unwrap();
let pk = board.pads[pi].key.clone();
let ambiguous = board.key_is_ambiguous(&pk);
let here = |l: Option<&str>| -> Value {
match (ambiguous || pad.is_none(), l) {
(true, Some(l)) => json!({"x": x, "y": y, "layer": l}),
(true, None) => json!([x, y]),
(false, Some(l)) => json!({"pad": pk, "layer": l}),
(false, None) => json!(pk),
}
};
cur.push(here(None));
out.push(Value::Array(std::mem::take(&mut cur)));
cur.push(here(Some(&nxt)));
changed += 1;
} else {
cur.push(wp.clone());
}
layer = nxt;
}
out.push(Value::Array(cur));
}
if changed > 0 {
notes.push(format!("{net} (entry {}): {changed} layer change(s) on a through-hole pad of the net made without a via (vias off: the path is cut on the pad, one path per face)", ei + 1));
out.retain(|p| p.as_array().map(|a| a.len() >= 2).unwrap_or(true));
if key == "paths" { e["paths"] = Value::Array(out); } else {
e.as_object_mut().unwrap().remove("points");
e["paths"] = Value::Array(out);
}
}
}
notes
}
/// One jumper: a run of copper on the jumper layer, end to end.
#[derive(Clone, Debug, PartialEq)]
pub struct Jumper {
pub net: String,
pub from: String,
pub to: String,
pub length_mm: f64,
}
fn place_name(board: &Board, net: &str, x: f64, y: f64, pad: Option<usize>) -> String {
match pad.or_else(|| pth_pad_at(board, net, x, y)) {
Some(i) => board.pads[i].key.clone(),
None => format!("({:.2}, {:.2})", x, y),
}
}
/// Every run of plan copper on `layer` (the jumper layer): one per path stretch on that layer.
pub fn jumpers(board: &Board, plan: &Value, layer: usize) -> Vec<Jumper> {
let mut out = Vec::new();
let want = LAYER_NAMES[layer];
for e in plan_entries(plan).unwrap_or_default() {
let net = e.get("net").and_then(|n| n.as_str()).unwrap_or("").to_string();
let paths: Vec<Value> = match e.get("paths").and_then(|p| p.as_array()) { Some(p) => p.clone(), None => e.get("points").cloned().into_iter().collect() };
for path in paths {
let Some(pts) = path.as_array() else { continue };
let Ok(res) = pts.iter().map(|w| point(w, board)).collect::<Result<Vec<_>, _>>() else { continue };
let mut l = e.get("layer").and_then(|l| l.as_str()).map(str::to_string).or_else(|| res.first().and_then(|r| r.3.clone()))
.unwrap_or_else(|| Board::path_start_layer(res.iter().map(|(_, _, pad, ex)| (pad.map(|i| &board.pads[i]), ex.is_some()))).to_string());
let mut run: Option<(usize, f64)> = None;
for k in 0..res.len() {
let (x, y, _, ex) = &res[k];
if k > 0 {
let (px, py) = (res[k - 1].0, res[k - 1].1);
if l == want {
let r = run.get_or_insert((k - 1, 0.0));
r.1 += (x - px).hypot(y - py);
}
}
let nxt = ex.clone().unwrap_or_else(|| l.clone());
if nxt != l && l == want {
if let Some((s, len)) = run.take() { if len > 1e-6 { out.push(Jumper { net: net.clone(), from: place_name(board, &net, res[s].0, res[s].1, res[s].2), to: place_name(board, &net, *x, *y, res[k].2), length_mm: len }); } }
}
l = nxt;
}
if let Some((s, len)) = run.take() {
let k = res.len() - 1;
if len > 1e-6 { out.push(Jumper { net: net.clone(), from: place_name(board, &net, res[s].0, res[s].1, res[s].2), to: place_name(board, &net, res[k].0, res[k].1, res[k].2), length_mm: len }); }
}
}
}
out
}
/// The jumpers as JSON for reports: count, total length, and the list.
pub fn jumpers_json(js: &[Jumper]) -> Value {
json!({"count": js.len(), "lengthMm": (js.iter().map(|j| j.length_mm).sum::<f64>() * 100.0).round() / 100.0,
"list": js.iter().map(|j| json!({"net": j.net, "from": j.from, "to": j.to, "lengthMm": (j.length_mm * 100.0).round() / 100.0})).collect::<Vec<_>>()})
}
/// What a plan has that this mode cannot make: vias (with vias off), and copper on a layer the mode
/// leaves out. One line per finding; empty when the plan fits the process (or the mode is the default).
pub fn plan_findings(board: &Board, plan: &Value, mode: &CopperMode) -> Vec<String> {
let mut out = Vec::new();
if mode.is_default() { return out; }
for e in plan_entries(plan).unwrap_or_default() {
let net = e.get("net").and_then(|n| n.as_str()).unwrap_or("?");
let Ok((segs, vias)) = crate::plan_to_copper(board, &e) else { continue };
if !mode.vias {
for v in &vias {
out.push(format!("{net}: a via at ({:.2}, {:.2}); this board has no vias (move the layer change onto a through-hole pad of {net}, or keep the path on one face)", v.x, v.y));
}
}
for l in 0..2 {
if mode.layers[l] { continue; }
let n = segs.iter().filter(|s| s.layer == LAYER_NAMES[l]).count();
if n > 0 {
out.push(format!("{net}: {n} track segment(s) on {}, which this board does not route (routeLayers {})", LAYER_NAMES[l], mode.layer_names().join(", ")));
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dru_disallow_via_and_track_on_a_layer() {
let d = dru_disallow("(version 1)\n# milled\n(rule \"no vias\" (constraint disallow via))\n(rule \"no top copper\" (layer F.Cu) (constraint disallow track zone))\n(rule \"cond\" (condition \"A.NetClass == 'HV'\") (constraint disallow via))\n");
assert!(d.via);
assert_eq!(d.track, [true, false]);
assert_eq!(d.conditional, vec!["cond".to_string()]);
assert_eq!(dru_disallow("(version 1)\n(rule \"c\" (constraint clearance (min 0.2mm)))"), DruDisallow::default());
assert_eq!(dru_disallow("not a rules file"), DruDisallow::default());
}
#[test]
fn resolve_default_spec_and_dru() {
assert!(CopperMode::resolve(&json!({}), None).is_default());
assert!(CopperMode::resolve(&json!({"routeLayers": ["F.Cu", "B.Cu"], "vias": true}), None).is_default());
let m = CopperMode::resolve(&json!({"routeLayers": ["B.Cu"]}), None);
assert_eq!((m.layers, m.vias), ([false, true], false));
let m = CopperMode::resolve(&json!({"vias": false}), None);
assert_eq!((m.layers, m.vias, m.jumper_layer()), ([true, true], false, Some(F_CU)));
// the dru when the spec is silent, with a note
let dru = "(version 1)\n(rule \"v\" (constraint disallow via))\n(rule \"t\" (layer \"F.Cu\") (constraint disallow track zone))\n";
let m = CopperMode::resolve(&json!({}), Some(dru));
assert_eq!((m.layers, m.vias, m.layers_from, m.vias_from), ([false, true], false, "kicad_dru", "kicad_dru"));
assert_eq!(m.notes.len(), 2, "{:?}", m.notes);
// the spec wins over the dru, and says what DRC will report
let m = CopperMode::resolve(&json!({"routeLayers": ["F.Cu", "B.Cu"]}), Some(dru));
assert_eq!((m.layers, m.vias, m.jumper_layer()), ([true, true], false, Some(F_CU)));
assert!(m.notes.iter().any(|n| n.contains("will report every F.Cu track")), "{:?}", m.notes);
let m = CopperMode::resolve(&json!({"vias": true}), Some("(version 1)\n(rule \"v\" (constraint disallow via))"));
assert!(m.is_default() && m.notes.iter().any(|n| n.contains("will report each one")));
assert!(CopperMode::resolve(&json!({"routeLayers": ["In1.Cu"]}), None).is_default());
assert_eq!(CopperMode::resolve(&json!({"routeLayers": ["B.Cu"]}), None).apply_to_spec(&json!({"a": 1})), json!({"a": 1, "routeLayers": ["B.Cu"], "vias": false}));
assert_eq!(CopperMode::default().apply_to_spec(&json!({"a": 1})), json!({"a": 1}));
}
}