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
Merge PR #477: AI Flow 0.2.71: pads entered on their own axis, jogs slid away, pin reach for impedance targets (aiflow-0.2.71 -> main) merge
Showing changes introduced by the merge (against its first parent).
8 files changed
+442−19
Cargo.lock+10−10@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow"-version = "0.2.70"+version = "0.2.71" dependencies = [ "aiflow-analyze", "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [ [[package]] name = "aiflow-analyze"-version = "0.2.70"+version = "0.2.71" dependencies = [ "aiflow-board", "serde",@@ -35,7 +35,7 @@ dependencies = [ [[package]] name = "aiflow-board"-version = "0.2.70"+version = "0.2.71" dependencies = [ "roxmltree", "serde",@@ -44,7 +44,7 @@ dependencies = [ [[package]] name = "aiflow-bridge"-version = "0.2.70"+version = "0.2.71" dependencies = [ "aiflow-board", "serde",@@ -53,7 +53,7 @@ dependencies = [ [[package]] name = "aiflow-copper"-version = "0.2.70"+version = "0.2.71" dependencies = [ "aiflow-board", "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [ [[package]] name = "aiflow-grid"-version = "0.2.70"+version = "0.2.71" dependencies = [ "aiflow-board", "serde",@@ -72,7 +72,7 @@ dependencies = [ [[package]] name = "aiflow-place"-version = "0.2.70"+version = "0.2.71" dependencies = [ "aiflow-board", "serde",@@ -81,7 +81,7 @@ dependencies = [ [[package]] name = "aiflow-pours"-version = "0.2.70"+version = "0.2.71" dependencies = [ "aiflow-board", "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [ [[package]] name = "aiflow-router"-version = "0.2.70"+version = "0.2.71" dependencies = [ "aiflow-board", "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [ [[package]] name = "aiflow-run"-version = "0.2.70"+version = "0.2.71" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.2.70"+version = "0.2.71" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
bin/adom-aiflow⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/impedance_cmd.rs+31−1@@ -251,6 +251,8 @@ pub fn after_route(choice: &Option<Value>, board: &Board, spec: &Value, board_pa lines.push(format!("impedance {}: routed on {} at {size}; the plan's copper gives {} ohm{}", r.label(), ch["layer"].as_str().unwrap_or(""), c["ohms"], if r.diff { format!(", coupled {}% of each net, skew {} mm", c["coupledPercent"], c["skewMm"]) } else { String::new() })); } else { let why: Vec<&str> = c["reasons"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect();+ // issue #52: say it when the width is what kept the pair off its pins+ if let Some(w) = ch["widthMm"].as_f64().and_then(|w| pin_reach(board, r, w)) { lines.push(format!("WARNING impedance {w}")); continue; } lines.push(format!("WARNING impedance {}: routed on {} at {size}, but the plan's copper misses the target: {}. {}", r.label(), ch["layer"].as_str().unwrap_or(""), why.join("; "), if r.diff { "The grid router routes the two nets one after the other (the second follows the first at the solved pitch where it can); here it could not keep them together. Route this pair yourself: `adom-aiflow take route=ai` (or move what splits it), and `analyze impedance` will judge the copper you land." } else { "Route it yourself (`take route=ai`) or move what forced the detour." })); }@@ -260,6 +262,16 @@ pub fn after_route(choice: &Option<Value>, board: &Board, spec: &Value, board_pa } +/// Issue #52 (a real board, StickHub: eight USB pairs, 2 layers, 1.6 mm): the width a target solves to can be far+/// wider than the pins it has to reach (90 ohm over 1.51 mm needs 1.38 mm; the hub's pins are 0.3 mm), and then the+/// grid router lands no copper on the pair at all. Names the narrowest pin and what would work, or None when it fits.+pub fn pin_reach(board: &Board, t: &Target, width: f64) -> Option<String> {+ let pads: Vec<&aiflow_board::Pad> = board.pads.iter().filter(|p| t.nets.iter().any(|n| *n == p.net)).collect();+ let narrow = pads.iter().min_by(|a, b| a.min_dim().total_cmp(&b.min_dim()))?;+ if width <= narrow.min_dim() + 0.1 { return None; }+ Some(format!("{}: the solved width {width:.2} mm is wider than the pins it must reach ({} pad {}: {:.2} mm), so the grid router cannot enter them at that width and may land no copper on it. What works: neck down to the pin over at most maxUncontrolledMm ({:.1} mm) at each end (route it yourself: `take route=ai`); a thinner dielectric under the outer layer (a 4-layer stackup's prepreg, about 0.2 mm, solves to a few tenths of a millimetre: put it in stackupMm and run the table again); or a ground pour beside the pair (coplanar), which these formulas do not model", t.label(), narrow.reference, narrow.name, narrow.min_dim(), t.max_uncontrolled))+}+ type Reply = Result<(String, Vec<String>), (String, Vec<String>)>; fn signals_hint(p: &Plan, rows: &[Value], spec: &Value) -> Option<String> {@@ -333,7 +345,10 @@ pub fn command(action: &str, board_file: &str, spec: &Value, plan_path: Option<& let mut route_lines = Vec::new(); for t in &p.targets { match router_layer(&p, t, &board) {- Ok(r) => route_lines.push(format!("route sizes {} on {}: width {} mm{}", t.label(), r["layer"].as_str().unwrap_or(""), r["widthMm"], if t.diff { format!(", gap {} mm", r["gapMm"]) } else { String::new() })),+ Ok(r) => {+ route_lines.push(format!("route sizes {} on {}: width {} mm{}", t.label(), r["layer"].as_str().unwrap_or(""), r["widthMm"], if t.diff { format!(", gap {} mm", r["gapMm"]) } else { String::new() }));+ if let Some(w) = r["widthMm"].as_f64().and_then(|w| pin_reach(&board, t, w)) { route_lines.push(format!("FINDING {w}")); }+ } Err(e) => route_lines.push(format!("route refuses: {e}")), } }@@ -359,6 +374,21 @@ pub const HINT_SPEC: &str = "Targets go under spec impedanceTargets: {\"USB_D\": mod tests { use super::*; + /// Issue #52: StickHub's 90 ohm pairs solve to 1.38 mm on a 2-layer 1.6 mm board; its hub pins are 0.3 mm.+ #[test]+ fn a_width_wider_than_the_pins_is_named_with_what_works() {+ let b = Board::from_text("t.kicad_pcb", r#"(kicad_pcb (version 20240108) (generator "t")+ (layers (0 "F.Cu" signal) (31 "B.Cu" signal) (44 "Edge.Cuts" user))+ (net 0 "") (net 1 "DP") (net 2 "DN")+ (gr_rect (start 0 0) (end 20 20) (layer "Edge.Cuts") (stroke (width 0.1) (type default)))+ (footprint "QFN" (layer "F.Cu") (at 10 10) (property "Reference" "U1" (at 0 0 0) (layer "F.SilkS"))+ (pad "10" smd rect (at 0 0) (size 0.3 0.8) (layers "F.Cu") (net 1 "DP")) (pad "11" smd rect (at 0.5 0) (size 0.3 0.8) (layers "F.Cu") (net 2 "DN"))))"#.to_string()).unwrap();+ let ts = imp::targets(&json!({"impedanceTargets": {"USB": {"pair": ["DP", "DN"], "targetDiffOhms": 90}}})).unwrap();+ let w = pin_reach(&b, &ts[0], 1.38).unwrap();+ assert!(w.contains("1.38 mm is wider than the pins it must reach (U1 pad 10: 0.30 mm)") && w.contains("maxUncontrolledMm") && w.contains("4-layer"), "{w}");+ assert!(pin_reach(&b, &ts[0], 0.35).is_none(), "a width that fits the pins says nothing");+ }+ #[test] fn fields_values_are_compared_and_can_fail_the_target() { let ts = imp::targets(&json!({"impedanceTargets": {"USB": {"pair": ["DP", "DN"], "targetDiffOhms": 90}}})).unwrap();
crates/aiflow-router/src/simplify.rs+381−5@@ -161,6 +161,235 @@ pub fn square_segments(pts: &[Pt], free: &[bool], clear: &mut dyn FnMut(Pt, Pt) (p, f, done) } +/// Wiggles in a polyline, as the route-quality report counts them: a segment of at most JOG_MM whose+/// two end turns go opposite ways.+fn wiggles_in(p: &[Pt]) -> usize {+ (1..p.len().saturating_sub(2)).filter(|&i| {+ let d = len(p[i], p[i + 1]);+ if d < EPS || d > JOG_MM + EPS { return false; }+ let (t1, t2) = (turn_deg(p[i - 1], p[i], p[i + 1]), turn_deg(p[i], p[i + 1], p[i + 2]));+ t1.abs() >= 1.0 && t2.abs() >= 1.0 && t1.signum() != t2.signum()+ }).count()+}++fn path_len(p: &[Pt]) -> f64 { p.windows(2).map(|w| len(w[0], w[1])).sum() }++/// A direction snapped to the nearest multiple of 45 degrees, when it is within half a degree of one.+fn snap45(u: Pt) -> Option<Pt> {+ let ang = u.1.atan2(u.0).to_degrees();+ let k = (ang / 45.0).round();+ if (ang - k * 45.0).abs() > 0.5 { return None; }+ let r = (k * 45.0).to_radians();+ let c = |v: f64| if v.abs() < 1e-9 { 0.0 } else { v };+ Some((c(r.cos()), c(r.sin())))+}++/// Issue #127: a pad at the end of a chain, with the directions a trace may leave it along and how far+/// the pad's copper reaches along each (its edge). A long pad has its long axis; a square pad both of its+/// axes; a round pad every multiple of 45 degrees. Only directions at a multiple of 45 degrees, and none+/// pointing back into its own part (an IC pin is entered from outside the package).+#[derive(Debug, Clone)]+pub struct PadEnd { pub at: Pt, pub axes: Vec<(Pt, f64)>, pub long: Pt, pub half: (f64, f64), pub round: bool }++impl PadEnd {+ /// Whether a point lies inside the pad's copper (its own rectangle or circle); its edge is outside.+ pub fn inside(&self, q: Pt) -> bool {+ let d = (q.0 - self.at.0, q.1 - self.at.1);+ if self.round { return d.0.hypot(d.1) < self.half.0 - 1e-6; }+ dot(d, self.long).abs() < self.half.0 - 1e-6 && (d.0 * self.long.1 - d.1 * self.long.0).abs() < self.half.1 - 1e-6+ }+}++/// A long pad is this much longer than wide; anything squarer may be entered along either axis.+pub const SQUARE_PAD: f64 = 1.15;++pub fn pad_end(p: &aiflow_board::Pad) -> PadEnd {+ let n = p.long_axis.0.hypot(p.long_axis.1);+ let (ax, ay) = if n > 1e-9 { (p.long_axis.0 / n, p.long_axis.1 / n) } else { (1.0, 0.0) };+ let round = p.shape == "circle";+ let mut dirs: Vec<Pt> = vec![(ax, ay), (-ax, -ay)];+ if round || p.half_long <= p.half_short * SQUARE_PAD { dirs.extend([(-ay, ax), (ay, -ax)]); }+ if round {+ let h = std::f64::consts::FRAC_1_SQRT_2;+ dirs.extend([(h, h), (h, -h), (-h, h), (-h, -h)]);+ }+ let out = (p.x - p.fp_x, p.y - p.fp_y);+ let axes = dirs.into_iter().filter_map(snap45).filter(|u| out.0 * u.0 + out.1 * u.1 >= -0.05).map(|u| {+ let along_long = (u.0 * ax + u.1 * ay).abs() > 0.5;+ let edge = p.polys.iter().flatten().map(|q| (q.0 - p.x) * u.0 + (q.1 - p.y) * u.1).fold(f64::NEG_INFINITY, f64::max);+ (u, if edge.is_finite() { edge.max(0.0) } else if along_long { p.half_long } else { p.half_short })+ }).collect();+ PadEnd { at: (p.x, p.y), axes, long: (ax, ay), half: (p.half_long, p.half_short), round }+}++/// How far along the chain a pad entry may reach to join the route again.+const ENTRY_REACH_MM: f64 = 6.0;+/// How much longer than the copper it replaces a pad entry may be.+const ENTRY_SLACK_MM: f64 = 0.5;++const DIR8: [Pt; 8] = [(1.0, 0.0), (-1.0, 0.0), (0.0, 1.0), (0.0, -1.0),+ (std::f64::consts::FRAC_1_SQRT_2, std::f64::consts::FRAC_1_SQRT_2), (std::f64::consts::FRAC_1_SQRT_2, -std::f64::consts::FRAC_1_SQRT_2),+ (-std::f64::consts::FRAC_1_SQRT_2, std::f64::consts::FRAC_1_SQRT_2), (-std::f64::consts::FRAC_1_SQRT_2, -std::f64::consts::FRAC_1_SQRT_2)];++fn dot(a: Pt, b: Pt) -> f64 { a.0 * b.0 + a.1 * b.1 }+fn add(a: Pt, b: Pt, k: f64) -> Pt { (a.0 + b.0 * k, a.1 + b.1 * k) }++/// Whether a chain that starts on a pad already leaves the pad's copper along one of its axes (or never+/// leaves it: a via or a join inside the pad).+pub fn enters_on_axis(pts: &[Pt], pad: &PadEnd) -> bool {+ let Some(e) = (0..pts.len().saturating_sub(1)).find(|&i| !pad.inside(pts[i + 1])) else { return true };+ if len(pts[e], pts[e + 1]) < EPS { return false; }+ let d = unit(pts[e], pts[e + 1]);+ pad.axes.iter().any(|&(u, _)| dot(d, u) > (0.5f64).to_radians().cos())+}++/// Issue #127: the last leg into a pad runs along the pad's own axis. The chain's first point is the pad;+/// its start is rebuilt as a straight stub from the pad out past its edge along an axis, then one leg at a+/// multiple of 45 degrees back onto the route: to a vertex of the chain, or onto one of its segments,+/// which it then follows. Candidates turn at most 45 degrees inside the new legs, have no more hard corners+/// or wiggles than the copper they replace (up to two vertices past the join) and are at most+/// ENTRY_SLACK_MM longer; they are tried shortest first; the first one `clear` passes wins. The stub's end is+/// returned fixed, so later passes keep the entry. None when the entry is already on an axis or nothing fits.+pub fn enter_on_axis(pts: &[Pt], free: &[bool], pad: &PadEnd, width: f64, clear: &mut dyn FnMut(Pt, Pt) -> bool) -> Option<(Vec<Pt>, Vec<bool>)> {+ let n = pts.len();+ if n < 2 || pad.axes.is_empty() || enters_on_axis(pts, pad) { return None; }+ let p0 = pts[0];+ // the vertices a new leg may land on: up to the first fixed one, within reach+ let mut last = 1;+ let mut reach = 0.0;+ for j in 1..n {+ reach += len(pts[j - 1], pts[j]);+ last = j;+ if !free[j] || reach > ENTRY_REACH_MM { break; }+ }+ // (new prefix from the pad, index of the chain vertex it continues from)+ let mut cands: Vec<(Vec<Pt>, usize)> = Vec::new();+ for &(u, edge) in &pad.axes {+ let t0 = edge + width / 2.0;+ // one stub and one 45: onto a vertex+ for j in 1..=last {+ let d = (pts[j].0 - p0.0, pts[j].1 - p0.1);+ let cross = u.0 * d.1 - u.1 * d.0;+ if cross.abs() < 1e-7 && dot(d, u) + EPS >= edge { cands.push((vec![p0, pts[j]], j)); continue; }+ for &v in &DIR8 {+ let det = u.0 * v.1 - u.1 * v.0;+ if det.abs() < 1e-9 { continue; }+ let (t, s) = ((d.0 * v.1 - d.1 * v.0) / det, (u.0 * d.1 - u.1 * d.0) / det);+ if t + EPS >= edge && s > EPS { cands.push((vec![p0, add(p0, u, t), pts[j]], j)); }+ }+ }+ // one stub and one 45 onto a segment k (pts[k] to pts[k + 1]), then along it; or the stub straight onto it+ for k in 1..last {+ let (a, b) = (pts[k], pts[k + 1]);+ let lk = len(a, b);+ if lk < EPS { continue; }+ let w = unit(a, b);+ let nn = (-w.1, w.0);+ let pa = (p0.0 - a.0, p0.1 - a.1);+ if dot(nn, u).abs() > 1e-9 {+ let t = -dot(nn, pa) / dot(nn, u);+ let e = add(p0, u, t);+ if t + EPS >= t0 && dot(w, (e.0 - a.0, e.1 - a.1)) < lk - EPS { cands.push((vec![p0, e, b], k + 1)); }+ }+ for &v in &DIR8 {+ let nv = dot(nn, v);+ if nv.abs() < 1e-9 || (u.0 * v.1 - u.1 * v.0).abs() < 1e-9 { continue; }+ // s(t) = s0 + s1 t puts the leg's end on the segment's line; l(t) = l0 + l1 t is where along it+ let (s0, s1) = (-dot(nn, pa) / nv, -dot(nn, u) / nv);+ let (l0, l1) = (dot(w, pa) + s0 * dot(w, v), dot(w, u) + s1 * dot(w, v));+ let (mut lo, mut hi) = (t0, f64::INFINITY);+ let mut ok = true;+ for (c, m) in [(s0, s1), (lk - EPS - l0, -l1)] {+ if m.abs() < 1e-12 { if c < 0.0 { ok = false; } } else if m > 0.0 { lo = lo.max(-c / m); } else { hi = hi.min(-c / m); }+ }+ if !ok || lo > hi + 1e-12 { continue; }+ let s = s0 + s1 * lo;+ let e = add(p0, u, lo);+ let f = add(e, v, s);+ cands.push((if s < EPS { vec![p0, e, b] } else { vec![p0, e, f, b] }, k + 1));+ }+ }+ }+ // the comparison runs two vertices past the join, so a wiggle made just beyond it counts+ let window = |pre: &[Pt], keep: usize| -> Vec<Pt> { let mut w = pre.to_vec(); w.extend(pts.iter().skip(keep + 1).take(2).copied()); w };+ let mut scored: Vec<((usize, usize, f64), Vec<Pt>, usize)> = cands.into_iter().filter_map(|(mut pre, keep)| {+ pre.dedup_by(|a, b| len(*a, *b) < EPS);+ if pre.len() < 2 || pre.windows(2).any(|w| !on45(w[0], w[1]) && w[1] != pts[keep]) { return None; }+ // the stub's end is fixed from here on, so nothing would cut a hard corner there later: none at all inside the new legs+ if pre.windows(3).any(|w| turn_deg(w[0], w[1], w[2]).abs() > 46.0) { return None; }+ let (old, new) = (window(&pts[..=keep], keep), window(&pre, keep));+ let (ho, hn) = (hard_corners(None, &old, None), hard_corners(None, &new, None));+ let (wo, wn) = (wiggles_in(&old), wiggles_in(&new));+ let (lo, ln) = (path_len(&pts[..=keep]), path_len(&pre));+ (hn <= ho && wn <= wo && ln <= lo + ENTRY_SLACK_MM).then_some(((hn, wn, ln), pre, keep))+ }).collect();+ scored.sort_by(|a, b| a.0.0.cmp(&b.0.0).then(a.0.1.cmp(&b.0.1)).then(a.0.2.total_cmp(&b.0.2)));+ for (_, pre, keep) in scored {+ if !pre.windows(2).all(|w| clear(w[0], w[1])) { continue; }+ let mut p = pre.clone();+ let mut f: Vec<bool> = (0..pre.len()).map(|q| q >= 2).collect();+ // the continuing vertex keeps its own state (the prefix's last point is pts[keep])+ p.pop(); f.pop();+ p.extend_from_slice(&pts[keep..]);+ f.extend_from_slice(&free[keep..]);+ if p.len() >= 2 && f.len() == p.len() { f[1] = false; }+ return Some((p, f));+ }+ None+}++/// Issue #127: both pad ends of a chain entered on their axes where they can be (see enter_on_axis).+pub fn enter_pads(pts: &[Pt], free: &[bool], start: Option<&PadEnd>, end: Option<&PadEnd>, width: f64, clear: &mut dyn FnMut(Pt, Pt) -> bool) -> (Vec<Pt>, Vec<bool>, usize) {+ let (mut p, mut f) = (pts.to_vec(), free.to_vec());+ let mut done = 0;+ if let Some(pad) = start {+ if let Some((np, nf)) = enter_on_axis(&p, &f, pad, width, clear) { p = np; f = nf; done += 1; }+ }+ if let Some(pad) = end {+ p.reverse(); f.reverse();+ if let Some((np, nf)) = enter_on_axis(&p, &f, pad, width, clear) { p = np; f = nf; done += 1; }+ p.reverse(); f.reverse();+ }+ (p, f, done)+}++/// Issue #127: the wiggles the pull and the step leave are mostly short legs between two parallel runs (a+/// 45 degree lane change, or a zigzag of straight and diagonal legs). A free corner is flipped across the+/// parallelogram of its two legs (the same two legs in the other order, the same length): the short leg+/// slides along until its neighbour runs on in the same direction and the two merge. A flip is kept only+/// when `clear` passes both new legs, it adds no wiggle and no hard corner, and it removes a wiggle or a+/// corner. Only chains with a wiggle are touched; fixed points stay. `keep_ends` (start, end) keeps the+/// direction of the chain's first or last leg: a pad entered on its axis, or an end another path meets+/// (their turn there is a corner this chain cannot see).+pub fn slide_jogs(pts: &[Pt], free: &[bool], keep_ends: (bool, bool), clear: &mut dyn FnMut(Pt, Pt) -> bool) -> (Vec<Pt>, Vec<bool>, usize) {+ let (mut p, mut f) = (pts.to_vec(), free.to_vec());+ let score = |p: &[Pt]| (wiggles_in(p), hard_corners(None, p, None), p.len());+ let mut done = 0;+ let mut guard = 0;+ 'outer: while guard < 4 * pts.len() + 8 {+ guard += 1;+ let s0 = score(&p);+ if s0.0 == 0 { break; }+ for i in 1..p.len().saturating_sub(1) {+ if !f[i] || (keep_ends.0 && i == 1) || (keep_ends.1 && i + 2 == p.len()) { continue; }+ let q = (p[i - 1].0 + p[i + 1].0 - p[i].0, p[i - 1].1 + p[i + 1].1 - p[i].1);+ if len(q, p[i]) < EPS { continue; }+ let (mut np, mut nf) = (p.clone(), f.clone());+ np[i] = q;+ merge_collinear(&mut np, &mut nf);+ let s1 = score(&np);+ if s1.0 > s0.0 || s1.1 > s0.1 || (s1.0 == s0.0 && s1.2 >= s0.2) { continue; }+ if !(clear(p[i - 1], q) && clear(q, p[i + 1])) { continue; }+ p = np;+ f = nf;+ done += 1;+ continue 'outer;+ }+ break;+ }+ (p, f, done)+}+ /// A net is a tree: another path of the net can end anywhere along this one, and a pad of the net can /// sit under it. Those joins hold the net together, so they are made fixed points before anything moves: /// a vertex at the join is fixed, a join along a segment gets a fixed vertex of its own on that line.@@ -206,6 +435,9 @@ pub fn tighten_chain(pts: &[Pt], free: &[bool], clear: &mut dyn FnMut(Pt, Pt) -> // never trades a short step for a hard corner let (before, after) = (i.checked_sub(1).map(|q| p[q]), p.get(k + 1).copied()); if hard_corners(before, c, after) > hard_corners(before, &p[i..=k], after) { return false; }+ // nor makes a hard corner at a fixed point (a pad's entry stub), where nothing can cut it later+ if let Some(b) = before.filter(|_| !f[i]) { if turn_deg(b, c[0], c[1]).abs() > 60.0 && turn_deg(b, p[i], p[i + 1]).abs() <= 60.0 { return false; } }+ if let Some(a) = after.filter(|_| !f[k]) { let m = c.len(); if turn_deg(c[m - 2], c[m - 1], a).abs() > 60.0 && turn_deg(p[k - 1], p[k], a).abs() <= 60.0 { return false; } } c.windows(2).all(|w| clear(w[0], w[1])) }); if let Some(c) = hit {@@ -337,6 +569,13 @@ pub fn simplify_plan(router: &mut Router, plan: &mut [Value]) -> Value { m.entry(p.net.clone()).or_insert_with(Vec::new).push(((p.x, p.y), p.size.0.min(p.size.1) / 2.0)); m });+ // issue #127: the pads of each net, to find a chain that ends on one+ let net_pads: HashMap<String, Vec<usize>> = router.board.pads.iter().enumerate().fold(HashMap::new(), |mut m, (k, p)| {+ m.entry(p.net.clone()).or_insert_with(Vec::new).push(k);+ m+ });+ let mut entered = 0usize;+ let mut slid = 0usize; let mut path_no: HashMap<String, usize> = HashMap::new(); for e in plan.iter_mut() { let net = e["net"].as_str().unwrap_or("").to_string();@@ -380,7 +619,21 @@ pub fn simplify_plan(router: &mut Router, plan: &mut [Value]) -> Value { let l = match seg_layer[i].as_str() { "F.Cu" => Some(F_CU), "B.Cu" => Some(B_CU), _ => None }; let pts: Vec<Pt> = rs[i..=k].iter().map(|w| w.xy).collect(); let free: Vec<bool> = (i..=k).map(|q| q != i && q != k).collect();- if let (Some(l), true) = (l, k - i >= 2) {+ // issue #127: a chain end on a pad of the net, on this layer. Where exactly one other path+ // ends on the same pad, the two meet there as a corner: its turn is not this chain's to change.+ let others_at = |at: Pt| path_joins.get(&net).into_iter().flatten().enumerate().filter(|(q, _)| *q != this_path).flat_map(|(_, ends)| ends.iter()).filter(|j| len(**j, at) < 1e-4).count();+ let pad_at = |at: Pt| -> Option<PadEnd> {+ let l = l?;+ net_pads.get(&net).into_iter().flatten().map(|&q| &router.board.pads[q]).find(|p| len((p.x, p.y), at) < 1e-4 && (p.thru || p.on_layer(l))).map(pad_end)+ };+ // only an end the plan names as a pad (not a via or a join that happens to sit on one)+ let names_pad = |v: &Value| v.is_string() || v.get("pad").is_some();+ let (start_pad, end_pad) = if pair { (None, None) } else { (pad_at(pts[0]).filter(|_| names_pad(&raw[i])), pad_at(pts[pts.len() - 1]).filter(|_| names_pad(&raw[k]))) };+ // a pad end met there by exactly one other path is not rebuilt (their turn at the pad is a corner)+ let shared = (others_at(pts[0]) == 1, others_at(pts[pts.len() - 1]) == 1);+ let (start_any, end_any) = (start_pad.clone(), end_pad.clone());+ let (start_pad, end_pad) = (start_pad.filter(|_| !shared.0), end_pad.filter(|_| !shared.1));+ if let (Some(l), true) = (l, k - i >= 2 || start_pad.is_some() || end_pad.is_some()) { let mut accepted: Vec<(Pt, Pt)> = Vec::new(); let (np, c) = { let r = &mut *router;@@ -389,10 +642,17 @@ pub fn simplify_plan(router: &mut Router, plan: &mut [Value]) -> Value { let (pts, free) = pin_joins(&pts, &free, &joins); let (np, nf, c) = simplify_chain(&pts, &free, width, &mut chk); if pair { (np, c) } else {- let (qp, qf, squared) = square_segments(&np, &nf, &mut chk);+ let (ep, ef, en) = enter_pads(&np, &nf, start_pad.as_ref(), end_pad.as_ref(), width, &mut chk);+ let (qp, qf, squared) = square_segments(&ep, &ef, &mut chk); let (tp, tf, tightened) = tighten_chain(&qp, &qf, &mut chk);- let (sp, _, sc) = smooth_chain(&tp, &tf, width, &mut chk);+ // a sliding jog keeps an end's leg where it meets exactly one other path's (their turn is a corner+ // this chain cannot see) or where it enters a pad on the pad's axis+ let rev: Vec<Pt> = tp.iter().rev().copied().collect();+ let keep_ends = (shared.0 || start_any.as_ref().is_some_and(|p| enters_on_axis(&tp, p)), shared.1 || end_any.as_ref().is_some_and(|p| enters_on_axis(&rev, p)));+ let (lp, lf, sl) = slide_jogs(&tp, &tf, keep_ends, &mut chk);+ let (sp, _, sc) = smooth_chain(&lp, &lf, width, &mut chk); smooth.chamfered += sc.chamfered; smooth.jogs += sc.jogs; smooth.kept += sc.kept; smooth.squared += squared; smooth.tightened += tightened;+ entered += en; slid += sl; (sp, c) } };@@ -416,8 +676,8 @@ pub fn simplify_plan(router: &mut Router, plan: &mut [Value]) -> Value { } let nets: serde_json::Map<String, Value> = per_net.iter().map(|(n, (b, a))| (n.clone(), json!({"before": b, "after": a}))).collect(); let (b, a): (usize, usize) = per_net.values().fold((0, 0), |s, v| (s.0 + v.0, s.1 + v.1));- json!({"verticesBefore": b, "verticesAfter": a, "staircasesReplaced": total.replaced, "staircasesKept": total.kept, "collinearMerged": total.merged, "cornersChamfered": smooth.chamfered, "segmentsSquared": smooth.squared, "runsTightened": smooth.tightened, "wigglesStepped": smooth.jogs, "smoothKept": smooth.kept, "nets": nets,- "rule": format!("staircase = {STAIR_MIN_STEPS}+ alternating 0/90 degree segments each <= {STAIR_FACTOR} x track width; replaced by a 45 degree segment plus at most one orthogonal leg only where the router's obstacle check keeps clearance. Then (not on impedance pairs) a sideways step <= {JOG_MM} mm between two opposite right angles becomes one 45 degree step, and a right-angle corner at a free point is cut into two 45 degree turns (leg <= {CHAMFER_FACTOR} x width and half of either leg), each only where the same check keeps clearance")})+ json!({"verticesBefore": b, "verticesAfter": a, "staircasesReplaced": total.replaced, "staircasesKept": total.kept, "collinearMerged": total.merged, "cornersChamfered": smooth.chamfered, "segmentsSquared": smooth.squared, "runsTightened": smooth.tightened, "wigglesStepped": smooth.jogs, "smoothKept": smooth.kept, "padsEnteredOnAxis": entered, "jogsSlid": slid, "nets": nets,+ "rule": format!("staircase = {STAIR_MIN_STEPS}+ alternating 0/90 degree segments each <= {STAIR_FACTOR} x track width; replaced by a 45 degree segment plus at most one orthogonal leg only where the router's obstacle check keeps clearance. Then (not on impedance pairs) a sideways step <= {JOG_MM} mm between two opposite right angles becomes one 45 degree step, and a right-angle corner at a free point is cut into two 45 degree turns (leg <= {CHAMFER_FACTOR} x width and half of either leg), each only where the same check keeps clearance. A trace into a pad is rebuilt as a straight stub along the pad's own axis out past its edge and one 45 degree leg back onto the route (no new hard corner or wiggle, at most {ENTRY_SLACK_MM} mm longer), and a short leg between two parallel runs is slid along them (the same two legs in the other order) until it merges, both only where the same check keeps clearance")}) } /// Staircases left in a board's copper: track chains per net and layer joined end to end through@@ -614,6 +874,122 @@ mod tests { assert!(p.windows(3).all(|w| !right(turn_deg(w[0], w[1], w[2]))), "{p:?}"); } + /// A pad at the origin, 1.0 mm long along y: entered along -y or +y, edge 0.5 mm.+ fn tall_pad() -> PadEnd { PadEnd { at: (0.0, 0.0), axes: vec![((0.0, -1.0), 0.5), ((0.0, 1.0), 0.5)], long: (0.0, 1.0), half: (0.5, 0.3), round: false } }++ #[test]+ fn a_pad_is_entered_along_its_own_axis() {+ // the route comes in at 45 degrees straight to the pad centre, across the pad's long side+ let pts = vec![(0.0, 0.0), (1.5, -1.5), (5.0, -1.5)];+ let (p, f, n) = enter_pads(&pts, &mid_free(3), Some(&tall_pad()), None, 0.25, &mut |_, _| true);+ assert_eq!(n, 1);+ // a straight stub up the pad's axis out past its edge (0.5 + half the width), one 45 back onto the run+ assert!(close(&p, &[(0.0, 0.0), (0.0, -0.625), (0.875, -1.5), (5.0, -1.5)]), "{p:?}");+ assert!(enters_on_axis(&p, &tall_pad()));+ assert!(p.windows(2).all(|w| on45(w[0], w[1])) && hard_corners(None, &p, None) == 0, "{p:?}");+ assert!(!f[1], "the stub's end is fixed so later passes keep the entry");+ assert_eq!(*p.last().unwrap(), (5.0, -1.5));+ // an off-grid pad centre entered at an odd angle gets the same: the stub, then a 45+ let pts = vec![(0.03, 0.02), (0.6, -1.9), (4.0, -1.9)];+ let pad = PadEnd { at: (0.03, 0.02), ..tall_pad() };+ let (p, _, n) = enter_pads(&pts, &mid_free(3), Some(&pad), None, 0.25, &mut |_, _| true);+ assert_eq!(n, 1);+ assert!(enters_on_axis(&p, &pad) && p.windows(2).all(|w| on45(w[0], w[1])), "{p:?}");+ // the end of a chain too+ let rev: Vec<Pt> = vec![(5.0, -1.5), (1.5, -1.5), (0.0, 0.0)];+ let (p, _, n) = enter_pads(&rev, &mid_free(3), None, Some(&tall_pad()), 0.25, &mut |_, _| true);+ assert_eq!(n, 1);+ assert!(close(&p, &[(5.0, -1.5), (0.875, -1.5), (0.0, -0.625), (0.0, 0.0)]), "{p:?}");+ }++ #[test]+ fn a_pad_entry_stays_when_nothing_clears_or_it_is_already_on_axis() {+ let pts = vec![(0.0, 0.0), (1.5, -1.5), (5.0, -1.5)];+ let (p, _, n) = enter_pads(&pts, &mid_free(3), Some(&tall_pad()), None, 0.25, &mut |_, _| false);+ assert_eq!((n, p.clone()), (0, pts.clone()));+ // already leaving along the axis past the edge: untouched+ let on = vec![(0.0, 0.0), (0.0, -1.0), (2.0, -3.0)];+ assert_eq!(enter_pads(&on, &mid_free(3), Some(&tall_pad()), None, 0.25, &mut |_, _| true).2, 0);+ // a fixed point (another path joins there) is never skipped over+ let fixed = [false, false, true];+ let (p, _, _) = enter_pads(&pts, &fixed, Some(&tall_pad()), None, 0.25, &mut |_, _| true);+ assert!(p.contains(&(1.5, -1.5)), "{p:?}");+ // a chain that never leaves the pad (a via in it) is left alone+ let via_in_pad = vec![(0.0, 0.0), (0.2, 0.1)];+ assert!(enters_on_axis(&via_in_pad, &tall_pad()));+ assert_eq!(enter_pads(&via_in_pad, &[false, false], Some(&tall_pad()), None, 0.25, &mut |_, _| true).2, 0);+ // a short jog inside the pad, then out along the axis, is already on axis+ let jog_in = vec![(0.0, 0.0), (0.05, -0.05), (0.05, -2.0), (3.0, -4.95)];+ assert!(enters_on_axis(&jog_in, &tall_pad()));+ // no shape without a hard corner (the route runs on along the pad's centre line): untouched+ let side = vec![(0.0, 0.0), (3.0, 0.0), (6.0, 0.0)];+ assert_eq!(enter_pads(&side, &mid_free(3), Some(&tall_pad()), None, 0.25, &mut |_, _| true).0, side);+ }++ #[test]+ fn pad_axes_follow_the_pad_and_never_point_into_its_part() {+ let b = crate::board_copper::tests::board(&[("J1", "A", 2.0, 5.0, true), ("K1", "B", 15.0, 2.0, false)], "");+ // a round through-hole pad: all eight directions; a square one: its four axes+ assert_eq!(pad_end(b.pad_by_key("J1.1").unwrap()).axes.len(), 8);+ let k = pad_end(b.pad_by_key("K1.1").unwrap());+ assert_eq!(k.axes.len(), 4);+ assert!(k.axes.iter().all(|(_, e)| (e - 0.5).abs() < 1e-6), "{k:?}");+ // a long pin at the left of its part, long axis along x: only outward (-x)+ let mut p = b.pad_by_key("K1.1").unwrap().clone();+ p.half_long = 0.6;+ p.half_short = 0.15;+ p.long_axis = (1.0, 0.0);+ p.fp_x = p.x + 3.0;+ let e = pad_end(&p);+ assert_eq!(e.axes.len(), 1);+ assert!(close(&[e.axes[0].0], &[(-1.0, 0.0)]), "{e:?}");+ // a rotated pad off every multiple of 45 degrees has no axis to enter along+ p.long_axis = (30f64.to_radians().cos(), 30f64.to_radians().sin());+ assert!(pad_end(&p).axes.is_empty());+ }++ #[test]+ fn a_short_leg_between_parallel_runs_slides_until_it_merges() {+ // east, a 0.42 mm diagonal lane change, east, then a long diagonal: the lane change is a wiggle+ let pts = vec![(0.0, 0.0), (3.0, 0.0), (3.3, 0.3), (6.0, 0.3), (8.0, 2.3)];+ assert_eq!(wiggles_in(&pts), 1);+ let (p, _, n) = slide_jogs(&pts, &mid_free(5), (true, true), &mut |_, _| true);+ assert!(n >= 1);+ assert_eq!(wiggles_in(&p), 0, "{p:?}");+ assert!(close(&p, &[(0.0, 0.0), (5.7, 0.0), (8.0, 2.3)]), "{p:?}");+ assert!((path_len(&p) - path_len(&pts)).abs() < 1e-9, "a slide keeps the length");+ // a zigzag of straight and diagonal legs becomes one straight and one diagonal+ let zig = vec![(0.0, 0.0), (0.0, 2.5), (-0.6, 3.1), (-0.6, 3.9), (-1.2, 4.5), (-1.2, 6.0)];+ assert!(wiggles_in(&zig) >= 2);+ let (p, _, _) = slide_jogs(&zig, &mid_free(6), (true, true), &mut |_, _| true);+ assert_eq!(wiggles_in(&p), 0, "{p:?}");+ assert!(p.len() < zig.len() && hard_corners(None, &p, None) == 0, "{p:?}");+ // blocked: it stays; fixed points never move+ assert_eq!(slide_jogs(&pts, &mid_free(5), (true, true), &mut |_, _| false).0, pts);+ assert_eq!(slide_jogs(&pts, &[false; 5], (true, true), &mut |_, _| true).0, pts);+ // no wiggle, nothing to do: a plain corner is left alone+ let l = vec![(0.0, 0.0), (3.0, 0.0), (5.0, 2.0)];+ assert_eq!(slide_jogs(&l, &mid_free(3), (true, true), &mut |_, _| true).2, 0);+ }++ #[test]+ fn plan_simplifier_enters_a_pad_on_its_axis_and_keeps_both_ends() {+ // net A from J1 (a round pad at 2, 5) to K1 (a square pad at 8, 3.03); the route meets K1 on a diagonal+ let b = crate::board_copper::tests::board(&[("J1", "A", 2.0, 5.0, true), ("K1", "A", 8.0, 3.03, false), ("Z1", "B", 15.0, 8.0, true)], "");+ let mut r = Router::new(b, Rules::default(), false);+ let mut plan = vec![json!({"net": "A", "width": 0.25, "paths": [["J1.1", [6.0, 5.0], [7.4, 3.6], "K1.1"]]})];+ let rep = simplify_plan(&mut r, &mut plan);+ assert!(rep["padsEnteredOnAxis"].as_u64().unwrap() >= 1, "{rep}");+ let path = plan[0]["paths"][0].as_array().unwrap();+ assert_eq!(path[0], "J1.1");+ assert_eq!(path[path.len() - 1], "K1.1");+ let pts: Vec<Pt> = path.iter().map(|v| resolve(&r.board, v).unwrap().xy).collect();+ assert!(pts.windows(2).all(|w| on45(w[0], w[1])), "{pts:?}");+ let n = pts.len();+ let last = unit(pts[n - 2], pts[n - 1]);+ assert!(last.0.abs() < 1e-6 || last.1.abs() < 1e-6, "the last leg runs along one of the square pad's axes: {pts:?}");+ }+ #[test] fn a_staircase_is_kept_when_no_replacement_clears() { let pts = stair(4, 0.3, (0.0, 0.0));
docs/release-0.2.71.mdadded+17@@ -0,0 +1,17 @@+# AI Flow 0.2.71++- **#127 (Noah): pads entered along their own axis, and the jogs slid away.** The plan simplifier now rebuilds a+ route's end at a pad it names when the copper leaves at an odd angle: a straight stub along the pad's own axis+ (a long pad's long axis, either axis of a square one, any 45 of a round one; never into its own part), then one+ 45 to the route, kept only where it clears, adds no hard corner or wiggle and is at most 0.5 mm longer; the stub+ end is then fixed. A second pass slides a short leg between two opposite 45 turns (a lane change, a zigzag)+ along its parallelogram until it merges with a neighbour, at the same length, only where it clears and removes a+ wiggle or a vertex. `tighten` no longer makes a hard corner at a fixed point. The simplify report adds+ `padsEnteredOnAxis` and `jogsSlid`. Benchmark, copper stripped, `route` alone (0.2.70 -> 0.2.71): ESC wiggles+ 127 -> 118, right angles 49 -> 46; StickHub wiggles 73 -> 60; pic_programmer 20 -> 19; the buck unchanged; pads+ left on axis up on all four (ESC 50 -> 58 %); open connections, vias and KiCad DRC identical on every board.+- **#52 (Drew), from a real board's run (StickHub: eight USB pairs, 2 layers, 1.6 mm):** 90 ohm there solves to+ 1.38 mm against 0.30 mm hub pins, and the router landed no copper on the pairs. `impedance table` now names the+ narrowest pin a target's width cannot enter and what works (neck down within maxUncontrolledMm by routing the+ pair yourself, a thinner outer dielectric, a coplanar ground), and `route` gives that reason when a pair gets no+ copper.
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.70",+ "version": "0.2.71", "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.70",+ "version": "0.2.71", "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.",