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.
← Commit history
Publish 0.2.7
10 files changed
+165−26
Cargo.lock+10−10@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow"-version = "0.2.6"+version = "0.2.7" dependencies = [ "aiflow-analyze", "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [ [[package]] name = "aiflow-analyze"-version = "0.2.6"+version = "0.2.7" dependencies = [ "aiflow-board", "serde",@@ -35,7 +35,7 @@ dependencies = [ [[package]] name = "aiflow-board"-version = "0.2.6"+version = "0.2.7" dependencies = [ "roxmltree", "serde",@@ -44,7 +44,7 @@ dependencies = [ [[package]] name = "aiflow-bridge"-version = "0.2.6"+version = "0.2.7" dependencies = [ "aiflow-board", "serde",@@ -53,7 +53,7 @@ dependencies = [ [[package]] name = "aiflow-copper"-version = "0.2.6"+version = "0.2.7" dependencies = [ "aiflow-board", "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [ [[package]] name = "aiflow-grid"-version = "0.2.6"+version = "0.2.7" dependencies = [ "aiflow-board", "serde",@@ -72,7 +72,7 @@ dependencies = [ [[package]] name = "aiflow-place"-version = "0.2.6"+version = "0.2.7" dependencies = [ "aiflow-board", "serde",@@ -81,7 +81,7 @@ dependencies = [ [[package]] name = "aiflow-pours"-version = "0.2.6"+version = "0.2.7" dependencies = [ "aiflow-board", "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [ [[package]] name = "aiflow-router"-version = "0.2.6"+version = "0.2.7" dependencies = [ "aiflow-board", "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [ [[package]] name = "aiflow-run"-version = "0.2.6"+version = "0.2.7" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.2.6"+version = "0.2.7" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
bin/adom-aiflow⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/checks.rs+33−4@@ -276,11 +276,17 @@ pub fn molecule_check(b: &Board, spec: &Value, before: Option<&Board>) -> Findin if b.outline.len() >= 3 { for r in pins.keys().chain(contacts.iter()) { let Some(fp) = b.footprints.get(r) else { continue };+ // the outline is the outer Edge.Cuts loop; inner loops (a bore, a slot) are holes the+ // margin applies to as well (issue #77)+ let edge = |poly: &[aiflow_board::Pt]| { let n = poly.len(); (0..n).map(|k| { let (a, c) = (poly[k], poly[(k + 1) % n]); geom::seg_distance(fp.x, fp.y, a.0, a.1, c.0, c.1) }).fold(f64::INFINITY, f64::min) }; let inside = geom::point_in_poly(fp.x, fp.y, &b.outline);- let n = b.outline.len();- let d = (0..n).map(|k| { let (a, c) = (b.outline[k], b.outline[(k + 1) % n]); geom::seg_distance(fp.x, fp.y, a.0, a.1, c.0, c.1) }).fold(f64::INFINITY, f64::min);- if !inside || d < margin - 0.02 { // 0.02: rounded corners are read as chords, a hair inside the true arc- f.error(format!("{r} centre is {}{d:.2} mm from the board edge; the molecule edge margin is {margin} mm", if inside { "" } else { "OUTSIDE the outline, " }), &format!("Keep every machine pin and contact centre {margin} mm inside the outline (spec.molecule.edgeMarginMm): grow the outline or move the part inward on the grid."));+ let in_cutout = b.outline_cutouts.iter().any(|c| geom::point_in_poly(fp.x, fp.y, c));+ let d_outer = edge(&b.outline);+ let d_cut = b.outline_cutouts.iter().map(|c| edge(c)).fold(f64::INFINITY, f64::min);+ let d = d_outer.min(d_cut);+ if !inside || in_cutout || d < margin - 0.02 { // 0.02: rounded corners are read as chords, a hair inside the true arc+ let (what, place) = if !inside { ("OUTSIDE the outline, ", "the board edge") } else if in_cutout { ("INSIDE a cutout, ", "the cutout edge") } else if d_cut < d_outer { ("", "a cutout edge") } else { ("", "the board edge") };+ f.error(format!("{r} centre is {what}{d:.2} mm from {place}; the molecule edge margin is {margin} mm"), &format!("Keep every machine pin and contact centre {margin} mm inside the outline and {margin} mm from every Edge.Cuts cutout (spec.molecule.edgeMarginMm): grow the outline or move the part on the grid.")); } } }@@ -704,6 +710,29 @@ mod tests { assert!(f.errors.iter().any(|e| e.contains("front-left")), "{:?}", f.lines); } + #[test]+ fn molecule_outline_is_the_outer_loop_and_a_bore_is_a_cutout() {+ // issue #77: an outer outline drawn in lines plus a round bore (gr_circle) on Edge.Cuts. The+ // bore used to be taken as the whole outline, so every pin read as OUTSIDE it.+ let lines = r#" (gr_line (start 98 82) (end 126 82) (layer "Edge.Cuts"))+ (gr_line (start 126 82) (end 126 102) (layer "Edge.Cuts"))+ (gr_line (start 126 102) (end 98 102) (layer "Edge.Cuts"))+ (gr_line (start 98 102) (end 98 82) (layer "Edge.Cuts"))"#;+ let outer = BOARD.replace(r#" (gr_rect (start 98 82) (end 126 102) (layer "Edge.Cuts"))"#, lines);+ assert_ne!(outer, BOARD);+ let spec = json!({"molecule": {"gridMm": 2, "edgeMarginMm": 2}});+ let bore = outer.replace(lines, &format!("{lines}\n (gr_circle (center 112 92) (end 116.6 92) (layer \"Edge.Cuts\"))"));+ let b = board(&bore);+ assert_eq!(b.outline_cutouts.len(), 1, "the bore is a cutout");+ assert!(geom::point_in_poly(100.0, 100.0, &b.outline), "MP1 is inside the outer outline");+ let f = molecule_check(&b, &spec, None);+ assert!(f.errors.is_empty(), "{:?}", f.lines);+ // the margin applies to a cutout edge too: a 1 mm hole 1.5 mm from J1+ let near = outer.replace(lines, &format!("{lines}\n (gr_circle (center 102.5 88) (end 103.5 88) (layer \"Edge.Cuts\"))"));+ let f = molecule_check(&board(&near), &spec, None);+ assert!(f.errors.iter().any(|e| e.contains("J1") && e.contains("cutout edge")), "{:?}", f.lines);+ }+ #[test] fn medium_scaffold_rejects_four_mm_spans_even_on_signal_grid() { let spec = json!({"molecule": {"gridMm": 2, "edgeMarginMm": 2}});
crates/aiflow-board/src/geom.rs+61@@ -112,6 +112,45 @@ pub fn chain_polylines(mut pieces: Vec<Vec<Pt>>, tol: f64) -> Result<Vec<Pt>, St Ok(looped) } +/// Chain pieces into every closed loop they form (an outer outline plus inner cutouts such as a+/// bore or a slot). A piece that cannot be continued back to its loop's start is an open outline.+pub fn chain_loops(mut pieces: Vec<Vec<Pt>>, tol: f64) -> Result<Vec<Vec<Pt>>, String> {+ pieces.retain(|p| p.len() >= 2);+ let mut loops = Vec::new();+ while !pieces.is_empty() {+ let mut looped = pieces.remove(0);+ while dist(looped[0], *looped.last().unwrap()) >= tol {+ let end = *looped.last().unwrap();+ let mut best: Option<(usize, bool)> = None;+ for (idx, p) in pieces.iter().enumerate() {+ if dist(end, p[0]) < tol {+ best = Some((idx, false));+ break;+ }+ if dist(end, *p.last().unwrap()) < tol {+ best = Some((idx, true));+ break;+ }+ }+ let Some((idx, rev)) = best else {+ return Err(format!("Edge.Cuts outline is not closed (gap at {:.3}, {:.3})", end.0, end.1));+ };+ let mut p = pieces.remove(idx);+ if rev {+ p.reverse();+ }+ looped.extend(p.into_iter().skip(1));+ }+ if looped.len() > 1 && dist(looped[0], *looped.last().unwrap()) < tol {+ looped.pop();+ }+ if looped.len() >= 3 {+ loops.push(looped);+ }+ }+ Ok(loops)+}+ pub fn polygon_area(poly: &[Pt]) -> f64 { let n = poly.len(); if n < 3 {@@ -193,3 +232,25 @@ pub fn bbox(pts: &[Pt]) -> (f64, f64, f64, f64) { } b }++#[cfg(test)]+mod chain_loop_tests {+ use super::*;++ #[test]+ fn outer_loop_and_slot_drawn_in_lines_both_close() {+ // an outer square and an inner slot, each four lines, pieces shuffled together (issue #77)+ let seg = |a: Pt, b: Pt| vec![a, b];+ let pieces = vec![+ seg((0.0, 0.0), (10.0, 0.0)), seg((4.0, 4.0), (6.0, 4.0)), seg((10.0, 0.0), (10.0, 10.0)),+ seg((6.0, 4.0), (6.0, 6.0)), seg((0.0, 10.0), (10.0, 10.0)), seg((6.0, 6.0), (4.0, 6.0)),+ seg((0.0, 10.0), (0.0, 0.0)), seg((4.0, 6.0), (4.0, 4.0)),+ ];+ let loops = chain_loops(pieces, 0.01).unwrap();+ assert_eq!(loops.len(), 2);+ let mut areas: Vec<f64> = loops.iter().map(|l| polygon_area(l).abs()).collect();+ areas.sort_by(|a, b| a.total_cmp(b));+ assert!((areas[0] - 4.0).abs() < 1e-9 && (areas[1] - 100.0).abs() < 1e-9, "{areas:?}");+ assert!(chain_loops(vec![seg((0.0, 0.0), (1.0, 0.0)), seg((1.0, 0.0), (1.0, 1.0))], 0.01).is_err(), "an open outline is refused");+ }+}
crates/aiflow-board/src/lib.rs+19−8@@ -130,7 +130,10 @@ pub struct Board { pub net_format: NetFormat, pub pads: Vec<Pad>, pub footprints: BTreeMap<String, Footprint>,+ /// The board outline: the outermost closed Edge.Cuts loop. pub outline: Vec<Pt>,+ /// Closed Edge.Cuts loops inside the outline (a bore, a slot, a cutout): holes in the board.+ pub outline_cutouts: Vec<Vec<Pt>>, pub bbox: (f64, f64, f64, f64), pub nets: BTreeMap<String, Vec<usize>>, pub copper_layers: Vec<String>,@@ -265,9 +268,9 @@ impl Board { } } let net_format = if net_table.is_empty() { NetFormat::Name } else { NetFormat::Number };- let mut b = Board { path: path.to_string(), text: String::new(), root, net_table, net_format, pads: Vec::new(), footprints: BTreeMap::new(), outline: Vec::new(), bbox: (0.0, 0.0, 0.0, 0.0), nets: BTreeMap::new(), copper_layers: Vec::new(), holes: Vec::new(), pad_index: HashMap::new() };+ let mut b = Board { path: path.to_string(), text: String::new(), root, net_table, net_format, pads: Vec::new(), footprints: BTreeMap::new(), outline: Vec::new(), outline_cutouts: Vec::new(), bbox: (0.0, 0.0, 0.0, 0.0), nets: BTreeMap::new(), copper_layers: Vec::new(), holes: Vec::new(), pad_index: HashMap::new() }; b.read_footprints();- b.outline = b.read_outline()?;+ (b.outline, b.outline_cutouts) = b.read_outline()?; b.bbox = geom::bbox(&b.outline); for (i, p) in b.pads.iter().enumerate() { if !p.net.is_empty() {@@ -368,8 +371,11 @@ impl Board { } } - fn read_outline(&self) -> Result<Vec<Pt>, String> {+ /// The outermost closed Edge.Cuts loop and the closed loops inside it (issue #77: a bore drawn as a+ /// gr_circle used to be taken as the whole outline).+ fn read_outline(&self) -> Result<(Vec<Pt>, Vec<Vec<Pt>>), String> { let mut pieces: Vec<Vec<Pt>> = Vec::new();+ let mut loops: Vec<Vec<Pt>> = Vec::new(); for node in self.root.nodes() { if !node.tag.starts_with("gr_") { continue;@@ -391,26 +397,31 @@ impl Board { } "gr_rect" => { if let (Some(s), Some(e)) = (p2(node.find("start")), p2(node.find("end"))) {- return Ok(vec![s, (e.0, s.1), e, (s.0, e.1)]);+ loops.push(vec![s, (e.0, s.1), e, (s.0, e.1)]); } } "gr_circle" => { if let (Some(c), Some(e)) = (p2(node.find("center")), p2(node.find("end"))) {- return Ok(geom::circle_poly(c.0, c.1, (e.0 - c.0).hypot(e.1 - c.1), 64));+ loops.push(geom::circle_poly(c.0, c.1, (e.0 - c.0).hypot(e.1 - c.1), 64)); } } "gr_poly" => { if let Some(pts) = node.find("pts") {- return Ok(pts.find_all("xy").filter_map(|xy| Some((xy.number(1)?, xy.number(2)?))).collect());+ let poly: Vec<Pt> = pts.find_all("xy").filter_map(|xy| Some((xy.number(1)?, xy.number(2)?))).collect();+ if poly.len() >= 3 { loops.push(poly); } } } _ => {} } }- if pieces.is_empty() {+ loops.extend(geom::chain_loops(pieces, 0.01)?);+ if loops.is_empty() { return Err("no Edge.Cuts outline".into()); }- geom::chain_polylines(pieces, 0.01)+ // the outline is the loop with the largest area; every other closed loop is a cutout+ let outer = (0..loops.len()).max_by(|&a, &b| geom::polygon_area(&loops[a]).abs().total_cmp(&geom::polygon_area(&loops[b]).abs())).unwrap();+ let outline = loops.remove(outer);+ Ok((outline, loops)) } /// The `(net ...)` line for copper on this board (matches the bridge's net_sexpr).
crates/aiflow-router/src/lib.rs+13−1@@ -237,7 +237,19 @@ impl Router { let mut refs: Vec<&String> = board.footprints.keys().collect(); refs.sort(); let fp_index: HashMap<String, usize> = refs.iter().enumerate().map(|(i, r)| ((*r).clone(), i)).collect();- let (inside, edge_dist) = grid.outline_distance(&board.outline);+ let (mut inside, mut edge_dist) = grid.outline_distance(&board.outline);+ // Edge.Cuts cutouts (a bore, a slot) are outside the board, and their edges keep the same+ // edge clearance as the outline (issue #77)+ for cut in &board.outline_cutouts {+ let (in_cut, d_cut) = grid.outline_distance(cut);+ for j in 0..grid.h {+ for i in 0..grid.w {+ if in_cut.get(i, j) { inside.set(i, j, false); }+ let k = j * grid.w + i;+ if d_cut[k] < edge_dist[k] { edge_dist[k] = d_cut[k]; }+ }+ }+ } let mut r = Router { grid: grid.clone(), rules, verbose, nets, net_ids, pad_raw: [vec![-1; n], vec![-1; n]], pad_owner: [vec![-1; n], vec![-1; n]], route_raw: [vec![-1; n], vec![-1; n]],
crates/aiflow-router/tests/obstacles.rs+26@@ -69,3 +69,29 @@ fn unnumbered_npth_mounting_hole_blocks_vias_and_tracks() { assert!(orth[0].get(i as usize, j as usize) && orth[1].get(i as usize, j as usize), "{layers}: track allowed through the hole"); } }++#[test]+fn edge_cuts_bore_is_outside_the_board_and_keeps_edge_clearance() {+ // issue #77: an outline drawn in lines with a round bore (gr_circle) on Edge.Cuts. The outline is+ // the outer loop; the bore is a cutout the router may not route through or hug.+ let board = Board::from_text("bore.kicad_pcb", r#"(kicad_pcb+ (layers (0 "F.Cu" signal) (31 "B.Cu" signal))+ (net 1 "SIG")+ (gr_line (start 0 0) (end 20 0) (layer "Edge.Cuts"))+ (gr_line (start 20 0) (end 20 12) (layer "Edge.Cuts"))+ (gr_line (start 20 12) (end 0 12) (layer "Edge.Cuts"))+ (gr_line (start 0 12) (end 0 0) (layer "Edge.Cuts"))+ (gr_circle (center 10 6) (end 12 6) (layer "Edge.Cuts"))+ (footprint "test" (layer "F.Cu") (at 3 6) (property "Reference" "J1")+ (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "SIG"))))"#.into()).unwrap();+ assert_eq!(board.outline_cutouts.len(), 1);+ assert!(aiflow_board::geom::polygon_area(&board.outline).abs() > 200.0, "the outline is the outer rectangle");+ let r = Router::new(board, Rules::default(), false);+ let (i, j) = r.grid.to_cell(10.0, 6.0);+ assert!(!r.inside.get(i as usize, j as usize), "the bore's centre is not board");+ let (i, j) = r.grid.to_cell(12.3, 6.0);+ assert!(r.inside.get(i as usize, j as usize), "just outside the bore is board");+ assert!(r.edge_dist[j as usize * r.grid.w + i as usize] < 0.5, "and it is close to an edge");+ let (i, j) = r.grid.to_cell(3.0, 3.0);+ assert!(r.inside.get(i as usize, j as usize));+}
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.6",+ "version": "0.2.7", "title": "AI Flow", "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.", "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
page.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.6",+ "version": "0.2.7", "title": "AI Flow", "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.", "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",