← All Pull Requests

AI Flow 0.2.69: Fields narration in the video, re-lay only what fails, the silkscreen as it prints #88

Merged Released in 0.2.69 on 11 Nov 2026 opened by Colby Knox
Merges aiflow-0.2.69 → main

AI Flow 0.2.69: the Fields narration in the video, a re-lay that keeps the designer's names, and a Gerber silkscreen audit. Closes #146, closes #134.

  • #146: tour fields keeps the viewer's explanations (/fields/narration) on the clip; compose says them over the Fields segment, fitted to its length.
  • #134 part 3: fusion silkscreen --relayout keeps every placed name without a finding and plans only those with one; --all re-lays them all.
  • #134 part 4: adom-aiflow silkscreen gerber-audit: an RS-274X reader and 25 um raster of the fab's own Gerbers (any EDA): silkscreen on exposed copper, off the board or near the edge, and features narrower than the minimum (morphological opening on what prints). export notes its findings.

Tests: 696 workspace tests (new: Gerber parse, audit, hatch strokes, outline chaining, file roles; Fields narration; relayout keeps), tool and workflow suites pass. Checked on a real Altium Gerber set: real 0.086 mm lines at the corner pins and silkscreen in mask openings, no false thin-line alarms from Altium's hatched TrueType. Preflight clean.

🤖 Generated with Claude Code

Diff Skip to comments

Cargo.lock+10−10
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow"-version = "0.2.68"+version = "0.2.69" dependencies = [  "aiflow-analyze",  "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [  [[package]] name = "aiflow-analyze"-version = "0.2.68"+version = "0.2.69" dependencies = [  "aiflow-board",  "serde",@@ -35,7 +35,7 @@ dependencies = [  [[package]] name = "aiflow-board"-version = "0.2.68"+version = "0.2.69" dependencies = [  "roxmltree",  "serde",@@ -44,7 +44,7 @@ dependencies = [  [[package]] name = "aiflow-bridge"-version = "0.2.68"+version = "0.2.69" dependencies = [  "aiflow-board",  "serde",@@ -53,7 +53,7 @@ dependencies = [  [[package]] name = "aiflow-copper"-version = "0.2.68"+version = "0.2.69" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [  [[package]] name = "aiflow-grid"-version = "0.2.68"+version = "0.2.69" dependencies = [  "aiflow-board",  "serde",@@ -72,7 +72,7 @@ dependencies = [  [[package]] name = "aiflow-place"-version = "0.2.68"+version = "0.2.69" dependencies = [  "aiflow-board",  "serde",@@ -81,7 +81,7 @@ dependencies = [  [[package]] name = "aiflow-pours"-version = "0.2.68"+version = "0.2.69" dependencies = [  "aiflow-board",  "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [  [[package]] name = "aiflow-router"-version = "0.2.68"+version = "0.2.69" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [  [[package]] name = "aiflow-run"-version = "0.2.68"+version = "0.2.69" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -14,7 +14,7 @@ members = [ ]  [workspace.package]-version = "0.2.68"+version = "0.2.69" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
bin/adom-aiflow
⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/gerber_silk.rsadded+449
@@ -0,0 +1,449 @@+//! Issue #134 (Glenn): audit the silkscreen that will actually print, from the fab's own Gerbers, for every+//! lane. The silkscreen Gerber (KiCad's F_Silkscreen, Fusion's GTO, any `Legend` file) holds the real glyph+//! outlines, so nothing is estimated from a font size. Read with the solder-mask Gerber and the board outline:+//!+//! - silkscreen on exposed copper (inside a mask opening): the fab clips it there, so the text is cut;+//! - silkscreen off the board or within the edge clearance;+//! - lines drawn thinner than the fab's minimum.+//!+//! A small RS-274X reader (format, units, C/R/O/P apertures, D01/D02/D03, linear and circular interpolation,+//! G36/G37 regions, LPD/LPC polarity) and a raster at 25 um. Findings are suggestions, never a stop.+use serde_json::{json, Value};++type Pt = (f64, f64);+const RES: f64 = 0.025;++#[derive(Clone, Debug)]+enum Ap { Circle(f64), Rect(f64, f64), Obround(f64, f64) }++impl Ap {+    fn width(&self) -> f64 { match self { Ap::Circle(d) => *d, Ap::Rect(w, h) | Ap::Obround(w, h) => w.min(*h) } }+}++#[derive(Clone, Debug)]+pub enum Shape {+    /// a stroke: from, to, width (round ends)+    Line(Pt, Pt, f64),+    /// a flash+    Flash(Pt, f64, f64, bool),+    /// a region contour+    Region(Vec<Pt>),+}++/// The image: shapes in order, each dark (true) or clear (false), and the thinnest drawn line (mm).+#[derive(Default, Debug)]+pub struct Image { pub shapes: Vec<(Shape, bool)>, pub min_line: Option<f64> }++impl Image {+    /// The drawn lines thinner than `min`: (count, total length mm).+    pub fn thin(&self, min: f64) -> (usize, f64) {+        self.shapes.iter().filter(|(_, d)| *d).filter_map(|(sh, _)| match sh { Shape::Line(a, b, w) if *w + 1e-6 < min => Some(((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt()), _ => None })+            .fold((0, 0.0), |(n, l), x| (n + 1, l + x))+    }+    /// The extent of what is drawn.+    pub fn extent(&self) -> Option<(f64, f64, f64, f64)> {+        let mut b = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);+        let mut any = false;+        for (sh, _) in &self.shapes {+            let pts: Vec<Pt> = match sh { Shape::Line(a, c, _) => vec![*a, *c], Shape::Flash(c, w, h, _) => vec![(c.0 - w / 2.0, c.1 - h / 2.0), (c.0 + w / 2.0, c.1 + h / 2.0)], Shape::Region(p) => p.clone() };+            for p in pts { any = true; b = (b.0.min(p.0), b.1.min(p.1), b.2.max(p.0), b.3.max(p.1)); }+        }+        any.then_some(b)+    }+}++fn num(s: &str, int_d: usize, dec_d: usize, scale: f64) -> f64 {+    let _ = int_d;+    if s.contains('.') { return s.parse::<f64>().unwrap_or(0.0) * scale; }+    let neg = s.starts_with('-');+    let digits = s.trim_start_matches(['-', '+']);+    let v = digits.parse::<f64>().unwrap_or(0.0) / 10f64.powi(dec_d as i32);+    (if neg { -v } else { v }) * scale+}++fn field(cmd: &str, key: char) -> Option<&str> {+    let i = cmd.find(key)?;+    let rest = &cmd[i + 1..];+    let end = rest.find(|c: char| c.is_ascii_alphabetic()).unwrap_or(rest.len());+    Some(&rest[..end])+}++/// Read a Gerber into shapes (mm).+pub fn parse(text: &str) -> Image {+    let (mut int_d, mut dec_d, mut scale) = (3usize, 6usize, 1.0f64);+    let mut aps: std::collections::HashMap<u32, Ap> = Default::default();+    let mut img = Image::default();+    let (mut cur, mut ap, mut mode, mut dark) = ((0.0, 0.0), None::<u32>, 1u8, true);+    let mut region: Option<Vec<Vec<Pt>>> = None;+    // extended commands between % ... %, the rest split on '*'+    let mut rest = text;+    let mut words: Vec<(bool, String)> = Vec::new();+    while !rest.is_empty() {+        if let Some(stripped) = rest.strip_prefix('%') {+            let end = stripped.find('%').unwrap_or(stripped.len());+            for w in stripped[..end].split('*') { let w = w.trim(); if !w.is_empty() { words.push((true, w.to_string())); } }+            rest = if end < stripped.len() { &stripped[end + 1..] } else { "" };+        } else {+            let end = rest.find('%').unwrap_or(rest.len());+            for w in rest[..end].split('*') { let w: String = w.chars().filter(|c| !c.is_whitespace()).collect(); if !w.is_empty() { words.push((false, w)); } }+            rest = &rest[end..];+        }+    }+    for (ext, w) in words {+        if ext {+            if let Some(f) = w.strip_prefix("FS") {+                if let Some(x) = f.find('X') { let d: Vec<usize> = f[x + 1..].chars().take(2).filter_map(|c| c.to_digit(10).map(|d| d as usize)).collect(); if d.len() == 2 { int_d = d[0]; dec_d = d[1]; } }+            } else if w == "MOIN" { scale = 25.4; } else if w == "MOMM" { scale = 1.0; }+            else if w == "LPD" { dark = true; } else if w == "LPC" { dark = false; }+            else if let Some(a) = w.strip_prefix("ADD") {+                let n: String = a.chars().take_while(|c| c.is_ascii_digit()).collect();+                let rest = &a[n.len()..];+                let (kind, params) = rest.split_once(',').unwrap_or((rest, ""));+                let p: Vec<f64> = params.split('X').filter_map(|x| x.parse::<f64>().ok()).map(|x| x * scale).collect();+                let g = |i: usize| p.get(i).copied().unwrap_or(0.1);+                let shape = match kind { "C" => Ap::Circle(g(0)), "R" => Ap::Rect(g(0), g(1)), "O" => Ap::Obround(g(0), g(1)), "P" => Ap::Circle(g(0)), _ => Ap::Circle(p.first().copied().unwrap_or(0.1)) };+                if let Ok(k) = n.parse() { aps.insert(k, shape); }+            }+            continue;+        }+        let mut w = w.as_str();+        // G codes ahead of the coordinates+        while let Some(g) = w.strip_prefix('G') {+            let n: String = g.chars().take_while(|c| c.is_ascii_digit()).collect();+            match n.as_str() {+                "01" | "1" => mode = 1, "02" | "2" => mode = 2, "03" | "3" => mode = 3,+                "36" => region = Some(vec![Vec::new()]),+                "37" => { if let Some(cs) = region.take() { for c in cs.into_iter().filter(|c| c.len() >= 3) { img.shapes.push((Shape::Region(c), dark)); } } }+                _ => {}+            }+            w = &g[n.len()..];+        }+        if w.starts_with("M02") { break; }+        // aperture select+        if let Some(d) = w.strip_prefix('D') { if let Ok(k) = d.parse::<u32>() { if k >= 10 { ap = Some(k); continue; } } }+        if !(w.contains('X') || w.contains('Y') || w.contains('D')) { continue; }+        let x = field(w, 'X').map(|s| num(s, int_d, dec_d, scale)).unwrap_or(cur.0);+        let y = field(w, 'Y').map(|s| num(s, int_d, dec_d, scale)).unwrap_or(cur.1);+        let i = field(w, 'I').map(|s| num(s, int_d, dec_d, scale)).unwrap_or(0.0);+        let j = field(w, 'J').map(|s| num(s, int_d, dec_d, scale)).unwrap_or(0.0);+        let d = field(w, 'D').and_then(|s| s.parse::<u32>().ok()).unwrap_or(1);+        let to = (x, y);+        let path: Vec<Pt> = if mode == 1 { vec![to] } else {+            let c = (cur.0 + i, cur.1 + j);+            let r = ((cur.0 - c.0).powi(2) + (cur.1 - c.1).powi(2)).sqrt();+            let (a0, mut a1) = ((cur.1 - c.1).atan2(cur.0 - c.0), (to.1 - c.1).atan2(to.0 - c.0));+            if mode == 3 { while a1 <= a0 + 1e-9 { a1 += std::f64::consts::TAU; } } else { while a1 >= a0 - 1e-9 { a1 -= std::f64::consts::TAU; } }+            let n = ((a1 - a0).abs() * r / 0.05).ceil().clamp(2.0, 720.0) as usize;+            (1..=n).map(|k| { let a = a0 + (a1 - a0) * k as f64 / n as f64; (c.0 + r * a.cos(), c.1 + r * a.sin()) }).collect()+        };+        match d {+            1 => {+                if let Some(cs) = region.as_mut() {+                    let c = cs.last_mut().unwrap();+                    if c.is_empty() { c.push(cur); }+                    c.extend(path.iter().copied());+                } else if let Some(a) = ap.and_then(|k| aps.get(&k)) {+                    let wd = a.width();+                    let mut p0 = cur;+                    for p in &path { img.shapes.push((Shape::Line(p0, *p, wd), dark)); p0 = *p; }+                    if dark { img.min_line = Some(img.min_line.map_or(wd, |m| m.min(wd))); }+                }+            }+            2 => { if let Some(cs) = region.as_mut() { if !cs.last().unwrap().is_empty() { cs.push(Vec::new()); } } }+            3 => {+                if let Some(a) = ap.and_then(|k| aps.get(&k)) {+                    let s = match a { Ap::Circle(dd) => Shape::Flash(to, *dd, *dd, true), Ap::Rect(a_, b_) => Shape::Flash(to, *a_, *b_, false), Ap::Obround(a_, b_) => Shape::Flash(to, *a_, *b_, true) };+                    img.shapes.push((s, dark));+                }+            }+            _ => {}+        }+        cur = to;+    }+    img+}++/// A bitmap over a window of the board, RES mm per cell.+pub struct Raster { pub x0: f64, pub y0: f64, pub w: usize, pub h: usize, pub bits: Vec<bool> }++impl Raster {+    pub fn new(bb: (f64, f64, f64, f64)) -> Raster {+        let w = (((bb.2 - bb.0) / RES).ceil() as usize + 2).min(20000);+        let h = (((bb.3 - bb.1) / RES).ceil() as usize + 2).min(20000);+        Raster { x0: bb.0 - RES, y0: bb.1 - RES, w, h, bits: vec![false; w * h] }+    }+    fn cell(&self, p: Pt) -> (i64, i64) { (((p.0 - self.x0) / RES).floor() as i64, ((p.1 - self.y0) / RES).floor() as i64) }+    pub fn centre(&self, i: usize, j: usize) -> Pt { (self.x0 + (i as f64 + 0.5) * RES, self.y0 + (j as f64 + 0.5) * RES) }+    fn set(&mut self, i: i64, j: i64, v: bool) { if i >= 0 && j >= 0 && (i as usize) < self.w && (j as usize) < self.h { let k = j as usize * self.w + i as usize; self.bits[k] = v; } }+    pub fn get(&self, i: usize, j: usize) -> bool { self.bits[j * self.w + i] }+    fn span(&self, lo: Pt, hi: Pt) -> (i64, i64, i64, i64) { let (a, b) = self.cell(lo); let (c, d) = self.cell(hi); (a.max(0), b.max(0), c.min(self.w as i64 - 1), d.min(self.h as i64 - 1)) }++    pub fn paint(&mut self, s: &Shape, v: bool) {+        match s {+            Shape::Line(a, b, wd) => {+                let r = (wd / 2.0).max(RES / 2.0);+                let (i0, j0, i1, j1) = self.span((a.0.min(b.0) - r, a.1.min(b.1) - r), (a.0.max(b.0) + r, a.1.max(b.1) + r));+                for j in j0..=j1 { for i in i0..=i1 { if seg_dist(self.centre(i as usize, j as usize), *a, *b) <= r { self.set(i, j, v); } } }+            }+            Shape::Flash(c, w_, h_, round) => {+                let (i0, j0, i1, j1) = self.span((c.0 - w_ / 2.0, c.1 - h_ / 2.0), (c.0 + w_ / 2.0, c.1 + h_ / 2.0));+                for j in j0..=j1 { for i in i0..=i1 {+                    let p = self.centre(i as usize, j as usize);+                    let inside = if *round {+                        let r = w_.min(*h_) / 2.0;+                        let (hx, hy) = ((w_ / 2.0 - r).max(0.0), (h_ / 2.0 - r).max(0.0));+                        seg_dist(p, (c.0 - hx, c.1 - hy), (c.0 + hx, c.1 + hy)) <= r+                    } else { (p.0 - c.0).abs() <= w_ / 2.0 && (p.1 - c.1).abs() <= h_ / 2.0 };+                    if inside { self.set(i, j, v); }+                } }+            }+            Shape::Region(pts) => {+                let (lo, hi) = pts.iter().fold(((f64::MAX, f64::MAX), (f64::MIN, f64::MIN)), |(l, h), p| ((l.0.min(p.0), l.1.min(p.1)), (h.0.max(p.0), h.1.max(p.1))));+                let (i0, j0, i1, j1) = self.span(lo, hi);+                for j in j0..=j1 {+                    let y = self.centre(0, j as usize).1;+                    let mut xs: Vec<f64> = Vec::new();+                    for k in 0..pts.len() {+                        let (a, b) = (pts[k], pts[(k + 1) % pts.len()]);+                        if (a.1 <= y) != (b.1 <= y) { xs.push(a.0 + (y - a.1) * (b.0 - a.0) / (b.1 - a.1)); }+                    }+                    xs.sort_by(|a, b| a.total_cmp(b));+                    for pair in xs.chunks(2) {+                        if pair.len() < 2 { break; }+                        for i in i0..=i1 { let x = self.centre(i as usize, 0).0; if x >= pair[0] && x <= pair[1] { self.set(i, j, v); } }+                    }+                }+            }+        }+    }++    pub fn draw(&mut self, img: &Image) { for (s, dark) in &img.shapes { self.paint(s, *dark); } }+}++fn seg_dist(p: Pt, a: Pt, b: Pt) -> f64 {+    let (dx, dy) = (b.0 - a.0, b.1 - a.1);+    let l2 = dx * dx + dy * dy;+    let t = if l2 < 1e-12 { 0.0 } else { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / l2).clamp(0.0, 1.0) };+    ((p.0 - a.0 - t * dx).powi(2) + (p.1 - a.1 - t * dy).powi(2)).sqrt()+}++fn inside(poly: &[Pt], p: Pt) -> bool {+    let mut c = false;+    for k in 0..poly.len() {+        let (a, b) = (poly[k], poly[(k + 1) % poly.len()]);+        if (a.1 > p.1) != (b.1 > p.1) && p.0 < a.0 + (p.1 - a.1) * (b.0 - a.0) / (b.1 - a.1) { c = !c; }+    }+    c+}++/// The printed parts narrower than about `min_mm`: what a morphological opening (erode, then dilate, by a disc a+/// little under half the minimum) removes. Measures what prints, so a TrueType glyph drawn as many hairline+/// hatch strokes (Altium) counts by its real width, not by its strokes'.+fn thin_parts(r: &Raster, min_mm: f64) -> Vec<bool> {+    let rad = ((min_mm / RES) / 2.0 + 1e-6).floor() as i64 - 1;+    if rad < 1 { return vec![false; r.bits.len()]; }+    let offs: Vec<(i64, i64)> = (-rad..=rad).flat_map(|dj| (-rad..=rad).map(move |di| (di, dj))).filter(|(di, dj)| di * di + dj * dj <= rad * rad).collect();+    let at = |i: i64, j: i64| i >= 0 && j >= 0 && (i as usize) < r.w && (j as usize) < r.h && r.bits[j as usize * r.w + i as usize];+    let mut eroded = vec![false; r.bits.len()];+    for j in 0..r.h { for i in 0..r.w {+        if r.bits[j * r.w + i] && offs.iter().all(|(di, dj)| at(i as i64 + di, j as i64 + dj)) { eroded[j * r.w + i] = true; }+    } }+    let mut opened = vec![false; r.bits.len()];+    for j in 0..r.h { for i in 0..r.w {+        if !eroded[j * r.w + i] { continue; }+        for (di, dj) in &offs { let (ii, jj) = (i as i64 + di, j as i64 + dj); if ii >= 0 && jj >= 0 && (ii as usize) < r.w && (jj as usize) < r.h { opened[jj as usize * r.w + ii as usize] = true; } }+    } }+    r.bits.iter().zip(&opened).map(|(b, o)| *b && !o).collect()+}++/// Group the marked cells into blobs (4-connected): (centre, area mm2), largest first.+fn blobs(r: &Raster, mark: &[bool]) -> Vec<(Pt, f64)> {+    let mut seen = vec![false; mark.len()];+    let mut out = Vec::new();+    for start in 0..mark.len() {+        if !mark[start] || seen[start] { continue; }+        let (mut n, mut sx, mut sy) = (0usize, 0.0, 0.0);+        let mut stack = vec![start];+        seen[start] = true;+        while let Some(k) = stack.pop() {+            let (i, j) = (k % r.w, k / r.w);+            let c = r.centre(i, j);+            n += 1; sx += c.0; sy += c.1;+            let mut push = |ii: i64, jj: i64| { if ii >= 0 && jj >= 0 && (ii as usize) < r.w && (jj as usize) < r.h { let kk = jj as usize * r.w + ii as usize; if mark[kk] && !seen[kk] { seen[kk] = true; stack.push(kk); } } };+            push(i as i64 - 1, j as i64); push(i as i64 + 1, j as i64); push(i as i64, j as i64 - 1); push(i as i64, j as i64 + 1);+        }+        out.push(((sx / n as f64, sy / n as f64), n as f64 * RES * RES));+    }+    out.sort_by(|a, b| b.1.total_cmp(&a.1));+    out+}++/// One side's audit. `outline` is the board outline (mm, the Gerbers' frame), `near` names the part+/// nearest a point. Returns (record, finding lines).+pub fn audit_side(side: &str, silk: &str, mask: Option<&str>, outline: &[Pt], edge_mm: f64, min_line_mm: f64, near: &dyn Fn(Pt) -> String) -> (Value, Vec<String>) {+    let s = parse(silk);+    let bb = if outline.len() >= 3 {+        outline.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, p| (b.0.min(p.0), b.1.min(p.1), b.2.max(p.0), b.3.max(p.1)))+    } else { s.extent().unwrap_or((0.0, 0.0, 1.0, 1.0)) };+    let pad = 2.0;+    let mut sr = Raster::new((bb.0 - pad, bb.1 - pad, bb.2 + pad, bb.3 + pad));+    sr.draw(&s);+    let printed = sr.bits.iter().filter(|b| **b).count();+    let mut lines = Vec::new();+    let mut rec = json!({"side": side, "printedMm2": (printed as f64 * RES * RES * 10.0).round() / 10.0, "minLineMm": s.min_line});+    // silk on exposed copper: inside a mask opening+    if let Some(m) = mask {+        let mi = parse(m);+        let mut mr = Raster { x0: sr.x0, y0: sr.y0, w: sr.w, h: sr.h, bits: vec![false; sr.bits.len()] };+        mr.draw(&mi);+        let both: Vec<bool> = sr.bits.iter().zip(&mr.bits).map(|(a, b)| *a && *b).collect();+        let bl: Vec<(Pt, f64)> = blobs(&sr, &both).into_iter().filter(|b| b.1 >= 0.005).collect();+        rec["onExposedCopper"] = json!(bl.iter().take(30).map(|(c, a)| json!({"at": [r3(c.0), r3(c.1)], "mm2": r3(*a), "near": near(*c)})).collect::<Vec<_>>());+        for (c, a) in bl.iter().take(12) { lines.push(format!("{side} silkscreen on exposed copper at ({:.2}, {:.2}), {:.3} mm2, near {}: the fab clips it there", c.0, c.1, a, near(*c))); }+        if bl.len() > 12 { lines.push(format!("{side}: {} more spots of silkscreen on exposed copper (the record lists them)", bl.len() - 12)); }+    } else {+        rec["onExposedCopper"] = Value::Null;+        lines.push(format!("{side}: no solder-mask Gerber found, so silkscreen over exposed copper was not checked"));+    }+    // off the board or within the edge clearance+    if outline.len() >= 3 {+        let mut off = vec![false; sr.bits.len()];+        for j in 0..sr.h { for i in 0..sr.w {+            let k = j * sr.w + i;+            if !sr.bits[k] { continue; }+            let p = sr.centre(i, j);+            let near_edge = (0..outline.len()).any(|q| seg_dist(p, outline[q], outline[(q + 1) % outline.len()]) < edge_mm);+            if !inside(outline, p) || near_edge { off[k] = true; }+        } }+        let bl: Vec<(Pt, f64)> = blobs(&sr, &off).into_iter().filter(|b| b.1 >= 0.005).collect();+        rec["nearOrOffEdge"] = json!(bl.iter().take(30).map(|(c, a)| json!({"at": [r3(c.0), r3(c.1)], "mm2": r3(*a), "near": near(*c)})).collect::<Vec<_>>());+        for (c, a) in bl.iter().take(8) { lines.push(format!("{side} silkscreen off the board or within {edge_mm} mm of the edge at ({:.2}, {:.2}), {:.3} mm2, near {}", c.0, c.1, a, near(*c))); }+    }+    // thin printed features: measured on what prints (strokes that merge into a wide glyph are not thin)+    let thin = thin_parts(&sr, min_line_mm);+    let bl: Vec<(Pt, f64)> = blobs(&sr, &thin).into_iter().filter(|b| b.1 >= 0.02).collect();+    let (n, len) = s.thin(min_line_mm);+    rec["thinFeatures"] = json!(bl.iter().take(30).map(|(c, a)| json!({"at": [r3(c.0), r3(c.1)], "mm2": r3(*a), "near": near(*c)})).collect::<Vec<_>>());+    rec["strokesUnderMinimum"] = json!({"count": n, "lengthMm": (len * 10.0).round() / 10.0, "thinnestMm": s.min_line});+    for (c, a) in bl.iter().take(10) { lines.push(format!("{side} silkscreen narrower than {min_line_mm:.2} mm at ({:.2}, {:.2}), {:.3} mm2, near {}: it may not print", c.0, c.1, a, near(*c))); }+    if bl.len() > 10 { lines.push(format!("{side}: {} more thin spots (the record lists them)", bl.len() - 10)); }+    (rec, lines)+}++fn r3(x: f64) -> f64 { (x * 1000.0).round() / 1000.0 }++/// The board outline from the outline Gerber (Profile / Edge_Cuts / GKO / GM1): its strokes chained into the+/// longest closed loop.+pub fn outline_of(profile: &str) -> Vec<Pt> {+    let img = parse(profile);+    let mut segs: Vec<(Pt, Pt)> = img.shapes.iter().filter_map(|s| match &s.0 { Shape::Line(a, b, _) => Some((*a, *b)), _ => None }).collect();+    for s in &img.shapes { if let Shape::Region(p) = &s.0 { if p.len() >= 3 { return p.clone(); } } }+    let close = |a: Pt, b: Pt| (a.0 - b.0).abs() < 0.01 && (a.1 - b.1).abs() < 0.01;+    let mut best: Vec<Pt> = Vec::new();+    while let Some((a, b)) = segs.pop() {+        let mut poly = vec![a, b];+        loop {+            let end = *poly.last().unwrap();+            let Some(k) = segs.iter().position(|(p, q)| close(*p, end) || close(*q, end)) else { break };+            let (p, q) = segs.remove(k);+            poly.push(if close(p, end) { q } else { p });+        }+        if poly.len() > best.len() { best = poly; }+    }+    if best.len() >= 2 && close(best[0], *best.last().unwrap()) { best.pop(); }+    best+}++/// Which file is the outline.+pub fn is_outline(name: &str, text: &str) -> bool {+    if let Some(f) = crate::export::gerber_function(text) { return f.to_ascii_lowercase().starts_with("profile"); }+    let n = name.to_ascii_lowercase();+    let ext = n.rsplit('.').next().unwrap_or("");+    n.contains("edge_cuts") || n.contains("edge.cuts") || ext == "gko" || ext == "gm1" || n.contains("outline") || n.contains("profile")+}++/// Which file is which: (side, role) from the X2 file function, else the name (KiCad, Eagle/Fusion, Altium).+pub fn role(name: &str, text: &str) -> Option<(&'static str, &'static str)> {+    if let Some(f) = crate::export::gerber_function(text) {+        let f = f.to_ascii_lowercase();+        let side = if f.contains("top") { "top" } else if f.contains("bot") { "bottom" } else { return None };+        if f.starts_with("legend") { return Some((side, "silk")); }+        if f.starts_with("soldermask") { return Some((side, "mask")); }+        return None;+    }+    let n = name.to_ascii_lowercase();+    let ext = n.rsplit('.').next().unwrap_or("");+    if n.contains("f_silks") || n.contains("f.silks") || ext == "gto" || n.contains("silkscreen_top") || n.contains("toplegend") { return Some(("top", "silk")); }+    if n.contains("b_silks") || n.contains("b.silks") || ext == "gbo" || n.contains("silkscreen_bottom") || n.contains("bottomlegend") { return Some(("bottom", "silk")); }+    if n.contains("f_mask") || n.contains("f.mask") || ext == "gts" || n.contains("soldermask_top") { return Some(("top", "mask")); }+    if n.contains("b_mask") || n.contains("b.mask") || ext == "gbs" || n.contains("soldermask_bottom") { return Some(("bottom", "mask")); }+    None+}++#[cfg(test)]+mod tests {+    use super::*;++    const HEAD: &str = "%FSLAX46Y46*%\n%MOMM*%\n%LPD*%\n%ADD10C,0.150000*%\n%ADD11R,1.000000X1.000000*%\n%ADD12C,0.080000*%\n";++    #[test]+    fn reads_lines_flashes_regions_and_arcs() {+        let g = format!("{HEAD}D10*\nX1000000Y1000000D02*\nX3000000Y1000000D01*\nD11*\nX5000000Y5000000D03*\nG36*\nX0Y0D02*\nX1000000Y0D01*\nX1000000Y1000000D01*\nX0Y0D01*\nG37*\nD10*\nG75*\nG03*\nX2000000Y2000000D02*\nX0Y2000000I-1000000J0D01*\nM02*\n");+        let img = parse(&g);+        assert!(img.shapes.iter().any(|s| matches!(s.0, Shape::Line(a, b, w) if (a.0 - 1.0).abs() < 1e-9 && (b.0 - 3.0).abs() < 1e-9 && (w - 0.15).abs() < 1e-9)));+        assert!(img.shapes.iter().any(|s| matches!(s.0, Shape::Flash(c, 1.0, 1.0, false) if (c.0 - 5.0).abs() < 1e-9)));+        assert!(img.shapes.iter().any(|s| matches!(&s.0, Shape::Region(p) if p.len() >= 3)));+        // the half-circle arc from (2,2) to (0,2) around (1,2) goes through (1,3)+        let top = img.shapes.iter().filter_map(|s| match s.0 { Shape::Line(_, b, _) => Some(b.1), _ => None }).fold(0.0f64, f64::max);+        assert!((top - 3.0).abs() < 0.01, "{top}");+        assert_eq!(img.min_line, Some(0.15));+    }++    #[test]+    fn finds_text_on_a_pad_off_the_edge_and_thin_lines() {+        // a 20 x 10 board; one stroke across a mask opening at (5, 5); one 0.2 mm from the edge; one 0.08 mm line+        let silk = format!("{HEAD}D10*\nX3000000Y5000000D02*\nX7000000Y5000000D01*\nX12000000Y9800000D02*\nX14000000Y9800000D01*\nD12*\nX12000000Y3000000D02*\nX14000000Y3000000D01*\nM02*\n");+        let mask = format!("{HEAD}D11*\nX5000000Y5000000D03*\nM02*\n");+        let outline = vec![(0.0, 0.0), (20.0, 0.0), (20.0, 10.0), (0.0, 10.0)];+        let (rec, lines) = audit_side("top", &silk, Some(&mask), &outline, 0.3, 0.15, &|p| if p.0 < 10.0 { "R1".into() } else { "U2".into() });+        let all = lines.join("\n");+        assert!(all.contains("top silkscreen on exposed copper at (5.00, 5.00)") && all.contains("near R1"), "{all}");+        assert!(all.contains("within 0.3 mm of the edge") && all.contains("near U2"), "{all}");+        assert!(all.contains("narrower than 0.15 mm at (13.00, 3.00)"), "{all}");+        assert_eq!(rec["onExposedCopper"].as_array().unwrap().len(), 1);+        // a clean board says nothing+        let clean = format!("{HEAD}D10*\nX12000000Y5000000D02*\nX14000000Y5000000D01*\nM02*\n");+        let (_, l2) = audit_side("top", &clean, Some(&mask), &outline, 0.3, 0.15, &|_| "R1".into());+        assert!(l2.is_empty(), "{l2:?}");+    }++    /// Hairline strokes side by side make one wide printed bar (Altium's TrueType fill): not thin.+    #[test]+    fn hatch_strokes_that_merge_are_measured_by_what_prints() {+        let mut g = format!("{HEAD}%ADD13C,0.050000*%\nD13*\n");+        for k in 0..12 { let y = 5000000 + k * 40000; g.push_str(&format!("X3000000Y{y}D02*\nX6000000Y{y}D01*\n")); }+        g.push_str("M02*\n");+        let outline = vec![(0.0, 0.0), (20.0, 0.0), (20.0, 10.0), (0.0, 10.0)];+        let (rec, lines) = audit_side("top", &g, None, &outline, 0.3, 0.15, &|_| "R1".into());+        assert!(!lines.iter().any(|l| l.contains("narrower")), "{lines:?}");+        assert_eq!(rec["strokesUnderMinimum"]["count"], 12, "the strokes are counted, but not reported as thin");+    }++    #[test]+    fn the_outline_is_chained_from_the_profile_strokes() {+        let g = format!("{HEAD}D10*\nX0Y0D02*\nX20000000Y0D01*\nX20000000Y10000000D01*\nX0Y10000000D02*\nX0Y0D01*\nX20000000Y10000000D02*\nX0Y10000000D01*\nM02*\n");+        let o = outline_of(&g);+        assert_eq!(o.len(), 4, "{o:?}");+        assert!(is_outline("b-Edge_Cuts.gbr", "") && is_outline("x.gbr", "%TF.FileFunction,Profile,NP*%") && !is_outline("b.GTO", ""));+    }++    #[test]+    fn names_the_files_by_function_or_name() {+        assert_eq!(role("x.gbr", "%TF.FileFunction,Legend,Top*%"), Some(("top", "silk")));+        assert_eq!(role("x.gbr", "%TF.FileFunction,Soldermask,Bot*%"), Some(("bottom", "mask")));+        assert_eq!(role("board.GTO", ""), Some(("top", "silk")));+        assert_eq!(role("board-F_Silkscreen.gbr", ""), Some(("top", "silk")));+        assert_eq!(role("board.GBS", ""), Some(("bottom", "mask")));+        assert_eq!(role("board.GTL", ""), None);+    }+}
crates/adom-aiflow/src/main.rs+148−4
@@ -3,6 +3,7 @@ mod continuation; mod supply; mod priorities;+mod gerber_silk; mod copper_carry; mod artifact_store; mod pourverify;@@ -117,10 +118,13 @@ enum Cmd {     #[arg(long)] record: bool, /// the board the inventory covers (default: the run's current board)     #[arg(long)] board: Option<String> },     /// `silkscreen fab-check|fab-tidy [--board B] [--out O] [--size MM]`: the Fab layer standard (issue #163). fab-check reports Fab texts off their parts, values shown and mixed reference sizes; fab-tidy writes a copy of the board with each reference centred on its body, upright along its long axis, one size (shrunk only to fit, floor 0.3 mm), values hidden. Only Fab texts change.-    Silkscreen { #[arg(value_parser = ["fab-check", "fab-tidy"])] action: String, /// default: the run's current board+    Silkscreen { #[arg(value_parser = ["fab-check", "fab-tidy", "gerber-audit"])] action: String, /// default: the run's current board     #[arg(long)] board: Option<String>, /// fab-tidy: where to write the tidied board (default: <board>-fab.kicad_pcb beside it)     #[arg(long)] out: Option<String>, /// the board's Fab reference size in mm (default: the most common one on the board)-    #[arg(long)] size: Option<f64> },+    #[arg(long)] size: Option<f64>, /// gerber-audit (issue #134): the folder with the fab's Gerbers, any EDA (default: the run's export, <run>/manufacturing/gerbers)+    #[arg(long)] dir: Option<String>, /// gerber-audit: the edge clearance in mm+    #[arg(long, default_value_t = 0.3)] edge: f64, /// gerber-audit: the thinnest silkscreen line the fab prints, mm+    #[arg(long, default_value_t = 0.15)] min_line: f64 },     SilkscreenSections { #[arg(long)] input: PathBuf, #[arg(long)] out: PathBuf },     SilkscreenPreflight { #[arg(long)] input: PathBuf, #[arg(long)] out: PathBuf },     SilkscreenMask { #[arg(long)] board: PathBuf, #[arg(long)] out: PathBuf },@@ -2123,6 +2127,51 @@ fn clip_artifacts_with(r: &mut Run, step: &Value, file: &str, speed: u32, tools: /// What goes into the final video, in run order: a step visit's motion, or a drawing the AI made. enum Piece { Library { file:String, caption:String, at:u64, seconds:f64 }, Clip { cap: Value, idx: usize, motion: f64 }, Still { file: String, caption: String, step: String, visit: u64, at: u64 } } +/// Issue #146: the Fields viewer explains each view by voice (adom-tts, cached under <fields>/narration/ as+/// `<view>-<hash>.mp3` with a `.json` holding the text). The window recorder takes picture only, so the video+/// says the viewer's own explanation over its segment instead: these are the explanations, in the tour's order.+const FIELDS_VIEW_ORDER: [&str; 14] = ["current", "heat", "temperature", "flow", "river", "energy", "light", "cycle", "charge", "magnetic", "ground", "noise", "pdn", "emc"];++fn fields_narration(run_dir: &Path) -> Vec<Value> {+    let mut dirs = vec![run_dir.join("fields").join("narration")];+    if let Ok(d) = std::env::var("AIFLOW_FIELDS_DIR") { dirs.insert(0, PathBuf::from(d).join("narration")); }+    let mut out: Vec<(usize, std::time::SystemTime, Value)> = Vec::new();+    for d in dirs {+        let Ok(rd) = std::fs::read_dir(&d) else { continue };+        for e in rd.flatten() {+            let p = e.path();+            if p.extension().is_none_or(|x| x != "json") { continue; }+            let Some(v) = std::fs::read_to_string(&p).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()) else { continue };+            let Some(text) = v["text"].as_str().filter(|t| !t.trim().is_empty()) else { continue };+            let stem = p.file_stem().and_then(|n| n.to_str()).unwrap_or("");+            let view = stem.rsplit_once('-').map(|x| x.0).unwrap_or(stem).to_string();+            let rank = FIELDS_VIEW_ORDER.iter().position(|q| *q == view).unwrap_or(FIELDS_VIEW_ORDER.len());+            let mtime = e.metadata().and_then(|m| m.modified()).unwrap_or(std::time::UNIX_EPOCH);+            out.push((rank, mtime, json!({"view": view, "text": text, "mp3": p.with_extension("mp3").display().to_string()})));+        }+        if !out.is_empty() { break; }+    }+    out.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));+    out.into_iter().map(|x| x.2).collect()+}++fn first_sentence(s: &str) -> String {+    let t = s.trim();+    t.find(". ").map(|i| t[..=i].to_string()).unwrap_or_else(|| t.to_string())+}++/// The narration tiers for the Fields walkthrough's segment: the viewer's own explanations (fullest first),+/// then the step's usual line. None when the viewer said nothing the run kept.+fn fields_tiers(narr: &[Value], usual: &[String]) -> Option<Vec<String>> {+    let texts: Vec<&str> = narr.iter().filter_map(|n| n["text"].as_str()).collect();+    if texts.is_empty() { return None; }+    let firsts: Vec<String> = texts.iter().map(|t| first_sentence(t)).collect();+    let mut tiers = vec![texts.iter().take(2).cloned().collect::<Vec<_>>().join(" "), firsts.iter().take(3).cloned().collect::<Vec<_>>().join(" "), texts[0].to_string(), firsts[0].clone()];+    tiers.dedup();+    tiers.extend(usual.iter().cloned());+    Some(tiers)+}+ /// Say `tiers` (the fullest first) and keep the first that fits `vlen` seconds; the last tier is /// said faster when even it runs long. The picture sets the length; the words are cut to it. fn narrate(workdir: &Path, idx: usize, tiers: &[String], vlen: f64, has_tts: bool) -> Option<(PathBuf, f64, String)> {@@ -2265,7 +2314,11 @@ fn compose_run(r: &mut Run, dir: &Path, outp: &Path, max_seconds: f64, still_sec                 let why_or_does = c["why"].as_str().map(|w| format!("The AI came back here because {w}")).unwrap_or_else(|| step_does(step));                 let mins = format!("{:.0} minute{} of real time", (real / 60.0).max(1.0), if real >= 90.0 { "s" } else { "" });                 n_clips += 1;-                (vlen, vec![format!("{said}{visit_words}. {why_or_does}. {mins}."), format!("{said}{visit_words}. {}.", first_clause(&why_or_does)), format!("{said}{visit_words}.")])+                let usual = vec![format!("{said}{visit_words}. {why_or_does}. {mins}."), format!("{said}{visit_words}. {}.", first_clause(&why_or_does)), format!("{said}{visit_words}.")];+                // issue #146: the Fields walkthrough says what the viewer explained (its own words, this board's numbers)+                let narr: Vec<Value> = c["narration"].as_array().cloned().filter(|a| !a.is_empty()).unwrap_or_else(|| if c["tour"] == "fields" { fields_narration(dir) } else { Vec::new() });+                let tiers = if c["tour"] == "fields" { fields_tiers(&narr, &usual).unwrap_or(usual) } else { usual };+                (vlen, tiers)             }             Piece::Still { file, caption, step, visit, at } => {                 let offset = r.active_offset(*at) as f64;@@ -3308,6 +3361,48 @@ fn record_verified(r: &mut Run, by: &str) {     r.save().unwrap(); } +/// Issue #134: the printed-silkscreen audit over a Gerber set (any EDA). The outline comes from the outline+/// Gerber; the board, when given, only names the part nearest each finding (matched to the Gerbers' frame,+/// with KiCad's flipped Y or not, by the outline's box).+fn silk_gerber_audit(files: &[(String, String)], board: Option<&Board>, edge: f64, min_line: f64) -> (Value, Vec<String>) {+    // the outline: the longest loop among the outline candidates (Altium may leave the GKO empty and draw it on a mechanical layer)+    let mut outline = files.iter().filter(|(n, t)| gerber_silk::is_outline(n, t)).map(|(_, t)| gerber_silk::outline_of(t)).max_by_key(|o| o.len()).unwrap_or_default();+    let mut outline_from = "the outline Gerber";+    // no outline Gerber (an Altium set may have none): the board's own outline, in the frame the silkscreen fits+    if outline.len() < 3 {+        if let (Some(b), Some(ext)) = (board.filter(|b| b.outline.len() >= 3), files.iter().find(|(n, t)| gerber_silk::role(n, t).map(|r| r.1) == Some("silk")).and_then(|(_, t)| gerber_silk::parse(t).extent())) {+            let fits = |f: bool| { let pts: Vec<(f64, f64)> = b.outline.iter().map(|p| (p.0, if f { -p.1 } else { p.1 })).collect(); let k = pts.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |q, p| (q.0.min(p.0), q.1.min(p.1), q.2.max(p.0), q.3.max(p.1))); (k.0 <= ext.0 + 1.0 && k.1 <= ext.1 + 1.0 && k.2 >= ext.2 - 1.0 && k.3 >= ext.3 - 1.0 && (k.2 - k.0) < (ext.2 - ext.0) + 20.0, pts) };+            if let Some((_, pts)) = [false, true].into_iter().map(fits).find(|x| x.0) { outline = pts; outline_from = "the board file's outline"; }+        }+    }+    let obox = |pts: &[(f64, f64)]| pts.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, p| (b.0.min(p.0), b.1.min(p.1), b.2.max(p.0), b.3.max(p.1)));+    let flip: Option<bool> = board.filter(|b| b.outline.len() >= 3 && outline.len() >= 3).and_then(|b| {+        let g = obox(&outline);+        [false, true].into_iter().find(|f| {+            let pts: Vec<(f64, f64)> = b.outline.iter().map(|p| (p.0, if *f { -p.1 } else { p.1 })).collect();+            let k = obox(&pts);+            (k.0 - g.0).abs() < 0.5 && (k.1 - g.1).abs() < 0.5 && (k.2 - g.2).abs() < 0.5 && (k.3 - g.3).abs() < 0.5+        })+    });+    let near = |p: (f64, f64)| -> String {+        let (Some(b), Some(f)) = (board, flip) else { return "(no board to name the part)".into() };+        b.pads.iter().map(|q| (q, ((q.x - p.0).powi(2) + ((if f { -q.y } else { q.y }) - p.1).powi(2)).sqrt())).min_by(|a, c| a.1.total_cmp(&c.1)).map(|(q, d)| format!("{} pad {} ({:.1} mm)", q.reference, q.name, d)).unwrap_or_default()+    };+    let mut sides = Vec::new();+    let mut lines = Vec::new();+    for side in ["top", "bottom"] {+        let Some((_, silk)) = files.iter().find(|(n, t)| gerber_silk::role(n, t) == Some((side, "silk"))) else { continue };+        let mask = files.iter().find(|(n, t)| gerber_silk::role(n, t) == Some((side, "mask"))).map(|(_, t)| t.as_str());+        // a side whose silkscreen draws nothing is left out+        if !(silk.contains("D01") || silk.contains("D03") || silk.contains("G36")) { continue; }+        let (v, l) = gerber_silk::audit_side(side, silk, mask, &outline, edge, min_line, &near);+        sides.push(v);+        lines.extend(l);+    }+    if outline.len() < 3 { lines.push("no board outline (no outline Gerber: Profile, Edge_Cuts, GKO or GM1; and no --board that fits), so the edge was not checked".into()); }+    (json!({"sides": sides, "outlinePoints": outline.len(), "outlineFrom": if outline.len() >= 3 { outline_from } else { "none" }, "edgeMm": edge, "minLineMm": min_line, "findings": lines, "blocking": false}), lines)+}+ /// Issue #193: the BOM references of an MPN, from the parts record. fn part_refs(r: &Run, mpn: &str) -> Vec<String> {     r.data["outcomes"]["parts"]["parts"].as_array().into_iter().flatten().filter(|p| p["mpn"].as_str().map(|m| m.eq_ignore_ascii_case(mpn)).unwrap_or(false))@@ -3717,7 +3812,20 @@ fn main() {             }             ok(&String::from_utf8_lossy(&result.stdout), &["Geometry audit only; native CAD review remains required. `--record` records the silkscreen step proof from a passing audit that covers the board.".into(), rect]);         }-        Cmd::Silkscreen { action, board, out, size } => {+        Cmd::Silkscreen { action, board, out, size, dir: gdir, edge, min_line } if action == "gerber-audit" => {+            let _ = (out, size);+            let r = Run::open(&run_dir(&cli));+            let gd = gdir.clone().map(PathBuf::from).or_else(|| r.as_ref().map(|r| r.dir.join("manufacturing").join("gerbers"))).unwrap_or_else(|| err("gerber-audit needs --dir <the Gerber folder>", &[]));+            let files: Vec<(String, String)> = std::fs::read_dir(&gd).map(|d| d.filter_map(|e| e.ok()).filter(|e| e.path().is_file()).filter_map(|e| Some((e.file_name().to_string_lossy().to_string(), std::fs::read_to_string(e.path()).ok()?))).collect()).unwrap_or_else(|e| err(&format!("{}: {e}", gd.display()), &["Export first (`adom-aiflow export`), or pass --dir <the folder with the fab's Gerbers> (Fusion's CAM output, an Altium Gerber set, any EDA).".into()]));+            let bp = board.clone().or_else(|| r.as_ref().map(|r| r.current_board()));+            let b = bp.as_deref().and_then(|p| Board::load(p).ok());+            let (v, lines) = silk_gerber_audit(&files, b.as_ref(), *edge, *min_line);+            if let Some(mut r) = r { r.outcome("silk-gerber-audit", v.clone()); let _ = r.save(); }+            if v["sides"].as_array().is_none_or(|a| a.is_empty()) { err(&format!("no silkscreen Gerber in {}", gd.display()), &["The audit reads the Legend (silkscreen) Gerber: KiCad's F_Silkscreen, a .GTO/.GBO, or any file whose X2 FileFunction is Legend.".into()]); }+            if lines.is_empty() { ok(&format!("the printed silkscreen in {} has no finding: nothing on exposed copper, nothing off the board or within {edge} mm of the edge, no line under {min_line} mm", gd.display()), &[]); return; }+            findings(&format!("the printed silkscreen (from the Gerbers in {}, the real glyph outlines):\n  {}", gd.display(), lines.join("\n  ")), &["Move or shrink the text named, in the board, and export again; every spot has its coordinates in the Gerbers' frame and the part it is nearest. The fab clips silkscreen on exposed copper, so text there loses its pieces.".into()]);+        }+        Cmd::Silkscreen { action, board, out, size, .. } => {             // issue #163: findings and an offline tidy the AI may run; never a gate             let (r, bp) = match board.clone() { Some(b) => (Run::open(&run_dir(&cli)), b), None => { let r = load_run(&cli); let b = r.current_board(); (Some(r), b) } };             let b = Board::load(&bp).unwrap_or_else(|e| err(&e, &["Pass --board <a saved .kicad_pcb>.".into()]));@@ -3792,6 +3900,17 @@ fn main() {                     write_json(&out_dir.join("export-check.json"), &report);                     r.outcome("export", report.clone());                     record_verified(&mut r, "export");+                    // issue #134: the silkscreen as it will print, from the Gerbers just written (a note, never a stop)+                    {+                        let gd = out_dir.join("gerbers");+                        let files: Vec<(String, String)> = std::fs::read_dir(&gd).map(|d| d.filter_map(|e| e.ok()).filter(|e| e.path().is_file()).filter_map(|e| Some((e.file_name().to_string_lossy().to_string(), std::fs::read_to_string(e.path()).ok()?))).collect()).unwrap_or_default();+                        if !files.is_empty() {+                            let (v, lines) = silk_gerber_audit(&files, Board::load(&board_path).ok().as_ref(), 0.3, 0.15);+                            r.outcome("silk-gerber-audit", v);+                            let _ = r.save();+                            if !lines.is_empty() { crate::note(&format!("the silkscreen as it will print ({} finding(s) from the Gerbers):\n  {}", lines.len(), lines.iter().take(8).cloned().collect::<Vec<_>>().join("\n  ")), &["`adom-aiflow silkscreen gerber-audit` lists them all; move or shrink the text named and export again.".into()]); }+                        }+                    }                     let mut hints = order_hints;                     hints.extend(report["files"].as_array().into_iter().flatten().filter_map(|f| f.as_str()).map(|f| format!("file: {f}")));                     hints.push(format!("file: {}", out_dir.join(export::ORDER_NOTE).display()));@@ -7067,6 +7186,12 @@ fn main() {                         let local = settle_clip_stop(&mut r, pos, &stop, &dir);                         r.log("clip-stop", clip_stop_event(json!({"step": step, "visit": visit, "recordingId": rid}), &stop));                         if let Some(f) = local.filter(|f| Path::new(f).is_file()) { clip_artifacts(&mut r, &json!(format!("{step}-{visit}")), &f, 10); clip_file = Some(f); }+                        // issue #146: what the viewer said during the take goes with the clip, for compose+                        let narr = fields_narration(&dir);+                        if !narr.is_empty() {+                            if let Some(c) = r.data["captures"].as_array_mut().and_then(|a| a.get_mut(pos)) { c["narration"] = json!(narr); }+                            r.log("fields-narration", json!({"step": step, "visit": visit, "views": narr.iter().map(|n| n["view"].clone()).collect::<Vec<_>>()}));+                        }                     }                     if raised { let _ = br.call("pup_lower_os_window", &json!({"sessionId": sid, "reason": "aiflow fields walkthrough take ended"})); }                     let _ = br.call("pup_close_window", &json!({"sessionId": sid}));@@ -7873,6 +7998,25 @@ mod media_tools_tests {         assert_eq!(removed, vec!["mediaDeferred".to_string()]);     } +    /// Issue #146: the Fields segment's narration is the viewer's own explanation, in the tour's view order.+    #[test]+    fn the_fields_segment_says_what_the_viewer_explained() {+        let dir = std::env::temp_dir().join(format!("aiflow-narr-{}", std::process::id()));+        let n = dir.join("fields").join("narration");+        std::fs::create_dir_all(&n).unwrap();+        std::fs::write(n.join("heat-bbb.json"), json!({"text": "This is temperature rise. U1 runs 31 degrees over ambient."}).to_string()).unwrap();+        std::fs::write(n.join("current-aaa.json"), json!({"text": "This is current density: amps per square millimetre. The busiest spot is on SW, at 26."}).to_string()).unwrap();+        std::fs::write(n.join("current-aaa.mp3"), b"x").unwrap();+        let narr = fields_narration(&dir);+        assert_eq!(narr.iter().map(|v| v["view"].as_str().unwrap()).collect::<Vec<_>>(), vec!["current", "heat"]);+        let t = fields_tiers(&narr, &["Fields.".to_string()]).unwrap();+        assert!(t[0].starts_with("This is current density") && t[0].contains("31 degrees"), "{t:?}");+        assert_eq!(t[1], "This is current density: amps per square millimetre. This is temperature rise.");+        assert_eq!(t.last().unwrap(), "Fields.");+        assert!(fields_tiers(&[], &["Fields.".into()]).is_none());+        let _ = std::fs::remove_dir_all(dir);+    }+     /// Changing footage still gets its cuts.     #[test]     fn healthy_motion_clip_still_generates_video_derivatives() {
docs/release-0.2.69.mdadded+19
@@ -0,0 +1,19 @@+# AI Flow 0.2.69++- **#146 (Glenn): the Fields walkthrough's narration is the viewer's own.** The window recorder takes picture+  only, so the Fields viewer's spoken explanations never reached the video. `tour fields` now keeps what the+  viewer explained during the take (each view's text and mp3 from `<run>/fields/narration`, in the tour's+  order) on the clip, and `compose` says those words over the Fields segment, fitted to its length (the+  fullest that fits: two views' explanations, the first sentence of three, one view's), before its generic+  line. Older runs read the folder directly. `AIFLOW_FIELDS_DIR` points elsewhere.+- **#134 (Glenn), the two parts left:**+  - **`fusion silkscreen --relayout` starts from the designer's spots:** every placed name with no finding+    (over a pad or via, off the board, overlapping another) stays where it is; only the names with a finding+    are planned again. `--relayout --all` re-lays them all. The plan and the proof list the names kept.+  - **`adom-aiflow silkscreen gerber-audit`: the silkscreen as it prints, for every lane.** A small RS-274X+    reader (apertures, lines, arcs, flashes, regions, polarity) and a 25 um raster of the fab's own Gerbers+    (KiCad, Fusion, Altium, any Legend file): silkscreen on exposed copper (inside a mask opening), off the+    board or within the edge clearance, and printed features narrower than the minimum line, measured by a+    morphological opening on what prints (a TrueType glyph drawn as hairline strokes counts by its real+    width). Each finding has its place and, with `--board`, the nearest part. `export` runs it on the Gerbers+    it writes and notes the findings. Suggestions, never a stop.
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.2.68",+  "version": "0.2.69",   "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.68",+  "version": "0.2.69",   "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.",
skills/adom-aiflow/SKILL.md+4−1
@@ -248,7 +248,10 @@ the library, simulation, current, thermal, fields and export, Fusion for everyth     board framed; name each one in the chat. 14. **Silkscreen.** `fusion silkscreen --title "<board>"`: every part name readable (R0, else R90), clear     of pads, bodies, vias, the edge and each other; each machine contact's net on its edge side; the-    board identity on the bottom. Names it cannot fit are listed, never dropped silently.+    board identity on the bottom. Names it cannot fit are listed, never dropped silently. A silkscreen the+    designer placed is audited where it stands (`--adopt`, the default then); `--relayout` moves only the+    names with a finding, `--relayout --all` all of them. After `fusion export`, `adom-aiflow silkscreen+    gerber-audit --dir <its Gerbers> --board <the KiCad copy>` checks what will actually print (aiflow-silkscreen). 15. **3D, finish, export.** `fusion view3d` (read the still: every part flat on its pads), `finish`,     then `fusion export` writes and checks the Gerbers, BOM and CPL; answer with each file's path and     say they are good to go. That is the finish line.
skills/aiflow-silkscreen/SKILL.md+6
@@ -165,3 +165,9 @@ F.Fab (B.Fab) is not plotted for the fab, but it is the assembly drawing, the 3D - values hidden on Fab (they are in the BOM).  `adom-aiflow silkscreen fab-check [--board B]` reports Fab texts outside their part's courtyard, values shown, references off their body, duplicates and mixed sizes, naming the worst offenders. `silkscreen-audit --record` records the same check with the proof (`fab`) and `finish` prints it as a warning; neither is a condition. `adom-aiflow silkscreen fab-tidy [--board B] [--out O] [--size MM]` applies the standard to a copy of the board (default `<board>-fab.kicad_pcb`): only Fab texts change, copper, zones, fills and the silkscreen stay as they are, and the change is logged in the run (`fab-tidy`). Review it in KiCad, then `adopt-board` it (the copper evidence carries over; record the silkscreen proof again). You may run it without asking: it is tidiness, not a design change.++## The silkscreen as it prints: `silkscreen gerber-audit`++`adom-aiflow silkscreen gerber-audit [--dir <Gerber folder>] [--board <board>] [--edge 0.3] [--min-line 0.15]` reads the fab's own Gerbers, from any EDA (KiCad's F/B_Silkscreen, Fusion's or Altium's GTO/GBO, any file whose X2 FileFunction is Legend), so it measures the real glyph outlines, not boxes estimated from a font size (issue #134). It finds silkscreen on exposed copper (inside a solder-mask opening: the fab clips it), silkscreen off the board or within the edge clearance (the outline Gerber, or the board's outline when there is none), and printed features narrower than the minimum line (measured on what prints, so a TrueType glyph drawn as many hairline strokes counts by its real width). Each finding has its place in the Gerbers' frame and, with `--board`, the part it is nearest. `export` runs it on the Gerbers it writes and notes what it found. Findings are suggestions.++`fusion silkscreen --relayout` moves only the placed names with a finding (over a pad or via, off the board, overlapping another); every other name stays where the designer put it. `--relayout --all` re-lays them all.
tools/fusion-project.py+46−19
@@ -1575,14 +1575,8 @@ def board_model(text):                 e['nameBox'] = (x0, y0, x0 + w, y0 + h) if '90' not in str(_num(a_, 'rot', 'R0')) else (x - h, y0, x, y0 + w)     return els, outline, vias, texts -def silkscreen_audit(a, name, text, els, outline, vias, texts, nameless, by_hand, n_top):-    """Read the silkscreen as placed, move nothing, and report what is worth a look (issue #134). Name boxes-    are estimated from each NAME's size and length; the fab's Gerber is the exact measure of what prints."""-    findings = []-    edge = 0.3-    inner = (outline[0] + edge, outline[1] + edge, outline[2] - edge, outline[3] - edge) if outline else None-    pads = [(e['name'], p) for e in els for p in e['pads']]-    viab = [(x - r, y - r, x + r, y + r) for (x, y, r) in vias]+def placed_name_boxes(els, nameless):+    """Each top-side part's name as placed: (ref, box). Boxes are estimated from the NAME's size and length."""     boxes = []     for e in els:         if e['bottom'] or e['name'] in nameless: continue@@ -1601,23 +1595,43 @@ def silkscreen_audit(a, name, text, els, outline, vias, texts, nameless, by_hand         x0 = x - w / 2 if 'center' in al and not al.endswith('left') and not al.endswith('right') else (x - w if al.endswith('right') else x)         y0 = y - h / 2 if al.startswith('center') else (y - h if al.startswith('top') else y)         boxes.append((e['name'], (x0, y0, x0 + w, y0 + h)))+    return boxes+++def name_problems(boxes, els, outline, vias, edge=0.3):+    """What is wrong with each placed name: {ref: [problem, ...]}. Of two names that overlap, the second one+    listed carries the problem (so a re-lay moves one of them, not both)."""+    probs = {}+    inner = (outline[0] + edge, outline[1] + edge, outline[2] - edge, outline[3] - edge) if outline else None+    pads = [(e['name'], p) for e in els for p in e['pads']]+    viab = [(x - r, y - r, x + r, y + r) for (x, y, r) in vias]     for ref, bx in boxes:         if inner and not (bx[0] >= inner[0] and bx[1] >= inner[1] and bx[2] <= inner[2] and bx[3] <= inner[3]):-            findings.append('%s: name off the board or within %.1f mm of the edge' % (ref, edge))+            probs.setdefault(ref, []).append('%s: name off the board or within %.1f mm of the edge' % (ref, edge))         hit = next((r for r, p in pads if _ov(bx, p, 0.0)), None)-        if hit: findings.append('%s: name over a pad of %s (the fab clips silkscreen on pads)' % (ref, hit))-        if any(_ov(bx, v, 0.0) for v in viab): findings.append('%s: name over a via' % ref)+        if hit: probs.setdefault(ref, []).append('%s: name over a pad of %s (the fab clips silkscreen on pads)' % (ref, hit))+        if any(_ov(bx, v, 0.0) for v in viab): probs.setdefault(ref, []).append('%s: name over a via' % ref)     for i, (ra, ba) in enumerate(boxes):         for rb, bb in boxes[i + 1:]:-            if _ov(ba, bb, 0.0): findings.append('%s and %s: names overlap' % (ra, rb))+            if _ov(ba, bb, 0.0): probs.setdefault(rb, []).append('%s and %s: names overlap' % (ra, rb))+    return probs+++def silkscreen_audit(a, name, text, els, outline, vias, texts, nameless, by_hand, n_top):+    """Read the silkscreen as placed, move nothing, and report what is worth a look (issue #134). Name boxes+    are estimated from each NAME's size and length; the fab's Gerber is the exact measure of what prints."""+    edge = 0.3+    boxes = placed_name_boxes(els, nameless)+    probs = name_problems(boxes, els, outline, vias, edge)+    findings = [p for ref, _ in boxes for p in probs.get(ref, [])]     unnamed = sorted(r for r in nameless) + sorted(e['name'] for e in els if not e['bottom'] and 'display="off"' in (e.get('nameAttr') or ''))     if unnamed: findings.append('no visible name: %s (fine when intended)' % ', '.join(unnamed[:20]))     prove('silkscreen', {'mode': 'adopt', 'labels': len(boxes), 'moved': 0, 'findings': findings, 'placedByDesigner': by_hand})     head = 'silkscreen audited as placed (%d of %d top-side names placed by the designer), nothing moved: %s' % (         by_hand, n_top, ('%d finding(s) worth a look:\n  ' % len(findings)) + '\n  '.join(findings[:40]) if findings else 'nothing found')     ok(head, ['Each finding is a suggestion: fix the ones you agree with in Fusion, then run this again.',-              'Name boxes are estimated from size and length; the Gerber silkscreen layer is the exact measure of what prints.',-              'To have AI Flow re-lay the labels instead: `fusion silkscreen --relayout` (values: --values none|where-room|required).'])+              'Name boxes are estimated from size and length; for the exact measure of what prints, export the Gerbers and run `adom-aiflow silkscreen gerber-audit --dir <the Gerber folder> --board <the KiCad copy>` (silkscreen on exposed copper, off the edge, too thin).',+              'To have AI Flow move only the names with a finding: `fusion silkscreen --relayout` (every name: --relayout --all; values: --values none|where-room|required).'])   def cmd_silkscreen(a):@@ -1670,7 +1684,17 @@ def cmd_silkscreen(a):     grouped = {g['ref'] for g in groups}     frames = [(g['ref'], g['frame']) for g in groups]     fixed = [t['box'] for t in texts if t['layer'] == 21] + [g['outer'] for g in groups]-    labels, unresolved, _ = plan_labels(els, outline, vias, fixed=fixed, existing=texts, skip=grouped)+    # issue #134: a re-lay starts from the designer's own spots: every placed name with no finding stays where+    # it is (a fixed obstacle, nothing landed for it) and only the names with a finding are planned again;+    # --all plans every name from scratch+    kept = []+    if a.relayout and not getattr(a, 'all', False) and not a.fresh:+        boxes = placed_name_boxes(els, nameless)+        probs = name_problems(boxes, els, outline, vias)+        smashed = {r for r, v in placed.items() if (v or {}).get('smashed')}+        kept = [(r, b) for r, b in boxes if r in smashed and r not in probs and r not in grouped]+        fixed = fixed + [b for _, b in kept]+    labels, unresolved, _ = plan_labels(els, outline, vias, fixed=fixed, existing=texts, skip=set(grouped) | {r for r, _ in kept})     labels = [t for g in groups for t in g['texts']] + labels     unresolved = frame_bad + unresolved     # what has no room beside its part goes further out with a leader line (contacts as framed tags)@@ -1874,10 +1898,10 @@ def cmd_silkscreen(a):         if l['ref'] in fsteps: ordered.append(fsteps[l['ref']])         ordered += [st for st in rest if st[0] in ('%s name' % l['ref'], '%s net' % l['ref'], '%s leader' % l['ref'])]     steps = ordered + [st for st in rest if st not in ordered]-    plan = {'frames': frames, 'leaders': leaders, 'labels': labels, 'unresolved': unresolved, 'identity': {'lines': lines, 'at': spot}, 'fresh': fresh_cmds, 'steps': steps}+    plan = {'frames': frames, 'leaders': leaders, 'labels': labels, 'unresolved': unresolved, 'identity': {'lines': lines, 'at': spot}, 'fresh': fresh_cmds, 'steps': steps, 'kept': sorted(r for r, _ in kept)}     STATE.mkdir(parents=True, exist_ok=True); (STATE / 'silkscreen-plan.json').write_text(json.dumps(plan, indent=1))     if a.dry:-        ok('silkscreen plan (not landed): %d contact frames, %d labels, %d unresolved%s; %s' % (len(frames), len(labels), len(unresolved), (' (' + ', '.join(unresolved) + ')') if unresolved else '', STATE / 'silkscreen-plan.json'))+        ok('silkscreen plan (not landed): %d contact frames, %d labels, %d unresolved%s%s; %s' % (len(frames), len(labels), len(unresolved), (' (' + ', '.join(unresolved) + ')') if unresolved else '', ('; %d placed name(s) kept where the designer put them' % len(kept)) if kept else '', STATE / 'silkscreen-plan.json'))     if not a.no_switch: ab('fusion_show_2d_board', {'documentName': name})     # each frame and label is its own small script run by Fusion's SCRIPT command, one after another with a     # pause, so the board is seen being drawn (the bridge refuses a bare MOVE; a script lands every item as@@ -1925,7 +1949,9 @@ def cmd_silkscreen(a):     nets_written = sum(1 for L in labels if L['kind'] == 'net')     nets_kept = sum(1 for t in texts if t['layer'] == 21)     identity_kept = any(t['layer'] == 22 and t['text'] == title for t in texts)-    prove('silkscreen', {'frames': len(frames), 'labels': len(labels), 'moved': moved, 'unresolved': unresolved, 'failed': len(failed), 'identity': bool(spot)})+    prove('silkscreen', {'frames': len(frames), 'labels': len(labels), 'moved': moved, 'unresolved': unresolved, 'failed': len(failed), 'identity': bool(spot), 'kept': [r for r, _ in kept]})+    if kept:+        print('Note: %d placed name(s) had no finding and stayed where the designer put them (--relayout --all re-lays them too): %s' % (len(kept), ', '.join(r for r, _ in kept[:30])))     if SKIPPED_VALUES:         print('Note: %d value(s) had no room beside their part and were left off the silkscreen (--values where-room): %s' % (len(SKIPPED_VALUES), ', '.join(SKIPPED_VALUES[:20])))     if failed or off:@@ -2130,7 +2156,8 @@ def main():     p = sub.add_parser('view3d', help='the board in 3D: a still to check and a live orbit'); p.add_argument('--design'); p.add_argument('--steps', type=int, default=24); p.add_argument('--pause', type=float, default=0.6); p.add_argument('--settle', type=float, default=8)     p = sub.add_parser('silkscreen', help='every part name readable and clear of copper, contact nets on the edge side, the board identity on the bottom'); p.add_argument('--design'); p.add_argument('--title'); p.add_argument('--dry', action='store_true')     p.add_argument('--adopt', action='store_true', help='audit the silkscreen as placed and move nothing (the default when the designer already placed most names)')-    p.add_argument('--relayout', action='store_true', help='re-lay the labels even though the designer already placed them')+    p.add_argument('--relayout', action='store_true', help='re-lay the names that have a finding (over a pad or via, off the board, overlapping) even though the designer placed them; the others stay')+    p.add_argument('--all', action='store_true', help='with --relayout: plan every name from scratch, the designer\'s spots too')     p.add_argument('--values', choices=['none', 'where-room', 'required'], default='where-room', help='values on the silkscreen: none, where there is room (default), or every one')     p.add_argument('--fresh', action='store_true', help='take an earlier pass off first (its frames and contact net labels; names back to their library spots) so this pass is drawn from the start')     p.add_argument('--no-switch', action='store_true', help='the 2D board is already showing: do not ask the bridge to switch views (its progress panel would sit over the board on camera)')
tools/test_fusion_project.py+21
@@ -439,5 +439,26 @@ class SheetLimit(unittest.TestCase):         self.assertEqual(fp.off_sheet(groups), [('R2', 1980.946, 139.7, 'g'), ('R3', 10.0, -2000.0, 'g')])  ++class RelayoutKeeps(unittest.TestCase):+    """Issue #134: a re-lay moves only the placed names with a finding."""+    def test_only_names_with_a_finding_are_planned_again(self):+        # the lane runs silkscreen-plan.py and fusion-project.py as one script: give the helpers the plan's names+        sp = importlib.util.module_from_spec(importlib.util.spec_from_file_location('silk_plan', Path(__file__).with_name('silkscreen-plan.py')))+        sp.__spec__.loader.exec_module(sp)+        for k in ('tw_', '_ov', 'TEXT_H'):+            if hasattr(sp, k) and not hasattr(fp, k): setattr(fp, k, getattr(sp, k))+        els = [+            {'name': 'R1', 'bottom': False, 'nameAttr': 'x="10" y="10" size="0.8" layer="25"', 'nameAlign': 'bottom-left', 'homeName': (10, 10), 'pads': [(0, 0, 1, 1)], 'body': (0, 0, 2, 2)},+            {'name': 'R2', 'bottom': False, 'nameAttr': 'x="0.2" y="0.2" size="0.8" layer="25"', 'nameAlign': 'bottom-left', 'homeName': (0, 0), 'pads': [(0, 0, 1, 1)], 'body': (0, 0, 2, 2)},+            {'name': 'R3', 'bottom': False, 'nameAttr': 'x="10.5" y="10.2" size="0.8" layer="25"', 'nameAlign': 'bottom-left', 'homeName': (10, 10), 'pads': [(20, 20, 21, 21)], 'body': (20, 20, 22, 22)},+        ]+        boxes = fp.placed_name_boxes(els, set())+        probs = fp.name_problems(boxes, els, (-5, -5, 40, 40), [])+        self.assertNotIn('R1', probs, probs)+        self.assertTrue(any('over a pad' in p for p in probs['R2']), probs)+        self.assertTrue(any('overlap' in p for p in probs['R3']), 'of two overlapping names only the second moves')++ if __name__ == '__main__':     unittest.main()

Comments

No comments yet.

Log in to comment.