← Commit history

Silkscreen audit obstacles are checked against the board's own pads and vias, at record and at finish (0.1.97)

John Lauer ·84dac3d8fc · ·parent d2d79b9
8 files changed +91−14
Cargo.lock+10−10
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow"-version = "0.1.96"+version = "0.1.97" dependencies = [  "aiflow-analyze",  "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [  [[package]] name = "aiflow-analyze"-version = "0.1.96"+version = "0.1.97" dependencies = [  "aiflow-board",  "serde",@@ -35,7 +35,7 @@ dependencies = [  [[package]] name = "aiflow-board"-version = "0.1.96"+version = "0.1.97" dependencies = [  "roxmltree",  "serde",@@ -44,7 +44,7 @@ dependencies = [  [[package]] name = "aiflow-bridge"-version = "0.1.96"+version = "0.1.97" dependencies = [  "aiflow-board",  "serde",@@ -53,7 +53,7 @@ dependencies = [  [[package]] name = "aiflow-copper"-version = "0.1.96"+version = "0.1.97" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [  [[package]] name = "aiflow-grid"-version = "0.1.96"+version = "0.1.97" dependencies = [  "aiflow-board",  "serde",@@ -72,7 +72,7 @@ dependencies = [  [[package]] name = "aiflow-place"-version = "0.1.96"+version = "0.1.97" dependencies = [  "aiflow-board",  "serde",@@ -81,7 +81,7 @@ dependencies = [  [[package]] name = "aiflow-pours"-version = "0.1.96"+version = "0.1.97" dependencies = [  "aiflow-board",  "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [  [[package]] name = "aiflow-router"-version = "0.1.96"+version = "0.1.97" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [  [[package]] name = "aiflow-run"-version = "0.1.96"+version = "0.1.97" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -14,7 +14,7 @@ members = [ ]  [workspace.package]-version = "0.1.96"+version = "0.1.97" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
SKILL.md+1
@@ -288,6 +288,7 @@ The gates read the board and the tools' own output, so a file written to look li - **3D models are the parts.** A renamed or resized laser-etch proxy box is refused by `models` and `finish`. Use the wiki component page's model, the vendor STEP, or a model built from the datasheet. - **Silkscreen is printed.** `finish` refuses a board with fewer than 0.5 visible silkscreen labels per footprint (finished boards carry about 1 to 3.5): references, values and contact labels go on the silkscreen. - `step <name> --back` only returns to an earlier step.+- **Silkscreen audit geometry is the board's (0.1.97).** `silkscreen-audit --record` and `finish` check the supplied obstacle boxes against the saved board: every pad of every footprint must fall inside one of that footprint's obstacle boxes (ids `U1`, `U1:body`, `U1:pad:1`), and every via inside a via/hole box as large as the via. Measure the boxes from the board; placeholder or point-sized boxes are refused. - **Thermal counts the board's vias (0.1.96).** `analyze thermal` counts the vias on each tab net in the saved board as well as the routing plan, so thermal vias landed after routing count once the board is saved. - **Budgets are fixed once placement begins (0.1.95).** The spec's budgets and physical models (`loads`, `hot` watts and `tabNet`, `hotMaxRiseC`/`maxRiseC`, `switchNodeMaxLengthMm`, `wideNets`/`midNets`, `fab` and copper weights, via/drill power, package cooling) freeze at placement in `<run>/spec-budgets.json`. When a check fails, change the board: more copper, vias, a tighter placement. Every gate refuses a spec whose budgets moved since. Layout keys (`pours`, `thermalVias`, fixed coordinates, `planeUnder`) stay editable. If a budget really is wrong (a misread datasheet value), `adom-aiflow spec-amend --why "<datasheet page and what was misread>"` records the change; amendments are listed in the run ledger and the finish summary. 
bin/adom-aiflow
⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/main.rs+8
@@ -3433,6 +3433,14 @@ fn main() {                 if !sharp.is_empty() && r.data["sequence"].is_array() { missing.push(format!("outline: {} sharp corner(s) (outline round)", sharp.len())); }             }             for f in switchnode::findings(&spec_f, &board_now()) { missing.push(format!("switch node: {f} (back to placement)")); }+            // a recorded silkscreen proof is re-checked against the board's own pads and vias (the audit's geometry is supplied)+            if let Some(inp) = o.get("silkscreen").and_then(|p| p["input"].as_str()) {+                if let Ok(t) = std::fs::read_to_string(inp) {+                    if let Ok(v) = serde_json::from_str::<Value>(&t) {+                        for g in silkproof::geometry_gaps(&v, &board_now()) { missing.push(format!("silkscreen audit: {g} (silkscreen-audit --record again on real geometry)")); }+                    }+                }+            }             // the test plan again, on the finished board: every measurement still has a landing             if let Some(tp) = r.data["testPlan"].as_str().map(str::to_string) {                 let list = testplan::tests_of(&read_json(&tp));
crates/adom-aiflow/src/silkproof.rs+69−1
@@ -26,6 +26,7 @@ pub fn proof(audit: &Value, input: &Value, board: &Board) -> Result<Value, Vec<S     let vias = board.root.find_all("via").count();     let via_items = inventory.iter().filter(|i| matches!(i["kind"].as_str(), Some("via" | "hole"))).count();     if via_items < vias { why.push(format!("the audit inventory has {via_items} via/hole item(s), the board {vias} vias")); }+    why.extend(geometry_gaps(input, board));     let (labels, fps) = crate::provenance::silk_labels(board);     if fps > 0 && (labels as f64) < crate::provenance::MIN_SILK_PER_FOOTPRINT * fps as f64 {         why.push(format!("{labels} visible silkscreen label(s) for {fps} footprints (at least {:.1} per footprint)", crate::provenance::MIN_SILK_PER_FOOTPRINT));@@ -39,6 +40,50 @@ pub fn proof(audit: &Value, input: &Value, board: &Board) -> Result<Value, Vec<S     })) } +/// How far a supplied box may fall short of the board's own geometry (mm).+const TOL: f64 = 0.05;++fn boxes_for<'a>(obstacles: &'a [Value], f: impl Fn(&Value) -> bool + 'a) -> impl Iterator<Item = (f64, f64, f64, f64)> + 'a {+    obstacles.iter().filter(move |o| f(o)).filter_map(|o| {+        let b = o["box"].as_array()?;+        let v: Vec<f64> = b.iter().filter_map(|x| x.as_f64()).collect();+        (v.len() == 4).then(|| (v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])))+    })+}++fn inside(p: (f64, f64), b: (f64, f64, f64, f64)) -> bool {+    p.0 >= b.0 - TOL && p.0 <= b.2 + TOL && p.1 >= b.1 - TOL && p.1 <= b.3 + TOL+}++/// The supplied obstacle geometry checked against the board itself (2026-10-15: a run passed the audit+/// with every body a 0.4 mm square and every label a 0.6 mm square, so nothing could collide): every+/// pad corner must fall inside an obstacle box of its own footprint, and every via must sit inside a+/// via/hole box as large as the via.+pub fn geometry_gaps(input: &Value, board: &Board) -> Vec<String> {+    let obstacles = input["obstacles"].as_array().cloned().unwrap_or_default();+    let mut short: Vec<String> = Vec::new();+    for reference in board.footprints.keys() {+        let mine: Vec<_> = boxes_for(&obstacles, |o| o["id"].as_str().map(|id| covers(id, reference)).unwrap_or(false)).collect();+        let pads: Vec<_> = board.pads.iter().filter(|p| &p.reference == reference).collect();+        if pads.is_empty() { continue; }+        let missed = pads.iter().flat_map(|p| p.polys.iter().flatten().copied().collect::<Vec<_>>()).filter(|&pt| !mine.iter().any(|&b| inside(pt, b))).count();+        if missed > 0 { short.push(reference.clone()); }+    }+    let via_boxes: Vec<_> = boxes_for(&obstacles, |o| o["kind"].as_str().map(|k| k.contains("via") || k.contains("hole")).unwrap_or(false)).collect();+    let uncovered_vias = board.vias().iter().filter(|v| {+        let r = v.size / 2.0;+        !via_boxes.iter().any(|&b| inside((v.x - r, v.y - r), b) && inside((v.x + r, v.y + r), b))+    }).count();+    let mut why = Vec::new();+    if !short.is_empty() {+        why.push(format!("the obstacle boxes of {} footprint(s) do not cover their own pads on the board ({}{}): obstacles must be the parts' real extents measured from the board: every pad's copper, mask and holes, and each part's fitted body", short.len(), short.iter().take(12).cloned().collect::<Vec<_>>().join(", "), if short.len() > 12 { ", ..." } else { "" }));+    }+    if uncovered_vias > 0 {+        why.push(format!("{uncovered_vias} of the board's vias have no via/hole obstacle box covering them"));+    }+    why+}+ #[cfg(test)] mod tests {     use super::*;@@ -55,7 +100,8 @@ mod tests {     #[test]     fn records_only_a_passing_audit_that_covers_the_board() {         let b = Board::from_text("t.kicad_pcb", BOARD.to_string()).unwrap();-        let input = json!({"inventory": [{"id": "R1", "kind": "body", "layers": ["F.SilkS"]}, {"id": "C1:body", "kind": "body", "layers": ["F.SilkS"]}, {"id": "v1", "kind": "via", "layers": ["F.SilkS", "B.SilkS"]}]});+        let input = json!({"inventory": [{"id": "R1", "kind": "body", "layers": ["F.SilkS"]}, {"id": "C1:body", "kind": "body", "layers": ["F.SilkS"]}, {"id": "v1", "kind": "via", "layers": ["F.SilkS", "B.SilkS"]}],+            "obstacles": [{"id": "R1", "kind": "body", "box": [1.4, 1.4, 2.6, 2.6]}, {"id": "C1:body", "kind": "body", "box": [5.4, 1.4, 6.6, 2.6]}, {"id": "v1", "kind": "via", "box": [4.7, 4.7, 5.3, 5.3]}]});         let passed = json!({"passed": true, "inventoryItems": 3, "obstacles": 4, "marks": 4});         let p = proof(&passed, &input, &b).unwrap();         assert_eq!(p["footprints"], 2);@@ -67,6 +113,28 @@ mod tests {         assert!(why.contains("does not name 1") && why.contains(": R1") && why.contains("0 via/hole"), "{why}");     } +    /// Opt-in check on a real audit: AIFLOW_SILK_INPUT=<audit-input.json> AIFLOW_SILK_BOARD=<board.kicad_pcb>+    #[test]+    #[ignore]+    fn geometry_on_a_real_audit() {+        let input = serde_json::from_str::<Value>(&std::fs::read_to_string(std::env::var("AIFLOW_SILK_INPUT").unwrap()).unwrap()).unwrap();+        let b = Board::load(&std::env::var("AIFLOW_SILK_BOARD").unwrap()).unwrap();+        let gaps = geometry_gaps(&input, &b);+        eprintln!("gaps: {gaps:?}");+        assert_eq!(gaps.is_empty(), std::env::var("AIFLOW_SILK_WANT").map(|w| w == "pass").unwrap_or(true));+    }++    #[test]+    fn point_sized_obstacles_are_refused() {+        // what the 2026-10-15 run supplied: 0.4 mm boxes at each part, nothing for the via's real size+        let b = Board::from_text("t.kicad_pcb", BOARD.to_string()).unwrap();+        let input = json!({"inventory": [{"id": "R1", "kind": "body"}, {"id": "C1", "kind": "body"}, {"id": "v1", "kind": "via"}],+            "obstacles": [{"id": "R1", "kind": "body", "box": [1.8, 1.8, 2.2, 2.2]}, {"id": "C1", "kind": "body", "box": [5.4, 1.4, 6.6, 2.6]}, {"id": "v1", "kind": "via", "box": [4.9, 4.9, 5.1, 5.1]}]});+        let why = proof(&json!({"passed": true}), &input, &b).unwrap_err().join("; ");+        assert!(why.contains("1 footprint(s) do not cover their own pads on the board (R1)"), "{why}");+        assert!(why.contains("1 of the board's vias"), "{why}");+    }+     #[test]     fn a_bare_board_is_not_a_silkscreen() {         let bare = BOARD.replace("(layer \"F.SilkS\")", "(layer \"F.Fab\")");
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.1.96",+  "version": "0.1.97",   "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 today; Altium, Fusion and 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.1.96",+  "version": "0.1.97",   "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 today; Altium, Fusion and 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.",