app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
← Commit history
Publish 0.2.24
23 files changed
+1475−155
Cargo.lock+10−10Cargo.toml+1−1SKILL.md+5−2bin/adom-aiflowcrates/adom-aiflow/src/clipwatch.rs+89crates/adom-aiflow/src/main.rs+11−2crates/adom-aiflow/src/preboard.rs+12crates/adom-aiflow/src/schematic_symbols.rs+105−10crates/aiflow-analyze/src/branches.rs+204−32crates/aiflow-analyze/src/lib.rs+73−12crates/aiflow-analyze/src/pour_cover.rs+240crates/aiflow-analyze/src/solved_ampacity.rs+109crates/aiflow-board/src/lib.rs+104−5crates/aiflow-bridge/src/lib.rs+1crates/aiflow-bridge/src/recorder.rs+115crates/aiflow-router/src/board_copper.rs+356−73crates/aiflow-router/src/drive.rs+1−1docs/branch-current-screen.md+28−3package.json+1−1page.json+1−1skills/adom-aiflow/SKILL.md+5−2skills/aiflow-live-clips/SKILL.md+2skills/aiflow-power-layout/SKILL.md+2Cargo.lock+10−10@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow"-version = "0.2.23"+version = "0.2.24" dependencies = [ "aiflow-analyze", "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [ [[package]] name = "aiflow-analyze"-version = "0.2.23"+version = "0.2.24" dependencies = [ "aiflow-board", "serde",@@ -35,7 +35,7 @@ dependencies = [ [[package]] name = "aiflow-board"-version = "0.2.23"+version = "0.2.24" dependencies = [ "roxmltree", "serde",@@ -44,7 +44,7 @@ dependencies = [ [[package]] name = "aiflow-bridge"-version = "0.2.23"+version = "0.2.24" dependencies = [ "aiflow-board", "serde",@@ -53,7 +53,7 @@ dependencies = [ [[package]] name = "aiflow-copper"-version = "0.2.23"+version = "0.2.24" dependencies = [ "aiflow-board", "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [ [[package]] name = "aiflow-grid"-version = "0.2.23"+version = "0.2.24" dependencies = [ "aiflow-board", "serde",@@ -72,7 +72,7 @@ dependencies = [ [[package]] name = "aiflow-place"-version = "0.2.23"+version = "0.2.24" dependencies = [ "aiflow-board", "serde",@@ -81,7 +81,7 @@ dependencies = [ [[package]] name = "aiflow-pours"-version = "0.2.23"+version = "0.2.24" dependencies = [ "aiflow-board", "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [ [[package]] name = "aiflow-router"-version = "0.2.23"+version = "0.2.24" dependencies = [ "aiflow-board", "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [ [[package]] name = "aiflow-run"-version = "0.2.23"+version = "0.2.24" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.2.23"+version = "0.2.24" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
SKILL.md+5−2@@ -247,6 +247,8 @@ Clip checks and cuts need ffmpeg and ffprobe. When either is missing, every clip No recording may outlive an hour (a hard cap on every recording), `finish` and `deliver` stop the running clip, and every command stops a clip that has run past twice its step's budget: a recorder left running is a mistake, not evidence. +The first `step` (or `capture start`) asks the test box once which recorder its Adom Bridge has, with a read-only `desktop_record_status {}`, and keeps the answer in the run (`recorder` in run.json). A box whose Adom Bridge does not know the recorder verbs (an Adom Bridge before 2.1.35, such as 1.9.224 on macOS, which offers only `desktop_record_window_start` and `desktop_record_window_status`) records no clips: the run says "no recorder on <box>" once, starts no take (a take there could never be stopped), and each step keeps its start and end screenshots. The clips page and `deliver` say the same line once instead of a missing clip per step. `capture start` and the walkthrough tours refuse on such a box. An unclear answer (the box offline, a timeout) is not kept: the step records as before and asks again next time.+ `capture open` puts the board on screen the same way for every engine (foreground OpenGL on a test box we own, maximised, zoom to fit). `capture start` records the PCB editor window itself through Adom Bridge's window recorder (Windows Graphics Capture, background-capturable: whatever another thread or an updater puts in front of KiCad on a shared box does not reach the take, and nobody at the box is disturbed; the monitor is recorded only when no editor is open), every stage command writes a marker with its timestamp into the manifest, `capture mark <label>` adds yours, `capture stop` pulls the MP4 into the run. The split-screen comparison (gang-takes split mode) aligns the two engines on those markers. ## The spec@@ -482,7 +484,7 @@ Do not present a saved capture as live state or a workflow completion as hardwar ## Explicit branch currents -When a narrow sense or parallel branch causes a full-net width warning, read `docs/branch-current-screen.md`. Do not widen a signal branch blindly or waive the current gate. The optional resistive-branches model requires explicit balanced pin currents, every pad accounted for, stated copper/barrel assumptions and actual saved-board geometry. It still requires a matching, converged filled-copper Fields solve with the same explicit terminals. Test genuine narrow-main-path failures and preserve original width results. Prototype assumptions are not fabrication measurements or hardware qualification.+When a narrow sense or parallel branch causes a full-net width warning, read `docs/branch-current-screen.md`. Do not widen a signal branch blindly or waive the current gate. The optional resistive-branches model requires explicit balanced pin currents, every pad accounted for, stated copper/barrel assumptions and actual saved-board geometry. It still requires a matching, converged filled-copper Fields solve with the same explicit terminals. Test genuine narrow-main-path failures and preserve original width results. Prototype assumptions are not fabrication measurements or hardware qualification. Since 0.2.23 (issue #107) the branch model runs on multilayer boards (outer-layer nets as on two layers; inner-layer tracks need the board's saved physical stackup). A pour passes the whole-net screen only when it covers the narrow tracks on their layer or one pour reaches every terminal of the net (declare zero-current pads in `loads.<net>.terminals`). With a same-input Fields solve that passes the field screen, a net that failed only the width approximation is judged by the solved rise at its narrowest neck (`review.checks.ampacity`), recorded as `judgedBy: fields-solve`. ### Unclipped Fields current maxima @@ -525,7 +527,8 @@ A drilled pad with no copper layer (an NPTH that lists only `*.Mask`, such as a - The heatsink via planner (`thermal-vias plan`) and the small-pin ring of `thermalVias` keep every via clear of visible silkscreen on both sides: board and footprint text (the printed text's extent) and silk graphics, by `silkClearance` (default 0.1 mm). A part short of its target says how many spots the silk took; move the label to free them. - A `stitchVias` entry takes `size` and `drill` in mm. Without them it gets the spec's `viaSignal` (and `drillSignal`), else the board's most common via, else 0.6 / 0.3 mm (it was 0.8 / 0.4). - Via-to-via copper clearance and the hole-to-hole minimum come from the spec (`viaClearance`, else `clearance`; `holeToHole`); when the spec names neither, from the project's `.kicad_dru` beside the board (its unconditioned or via-only clearance and hole_to_hole rules, the last one winning as in KiCad); else 0.2 mm. This applies to `land vias`, `pour` and `thermal-vias plan`; zones keep `clearance`.-- A pad number used twice on one footprint (a connector's two `MP` pads, a ring drawn as two custom pads) is ambiguous as a key. The router writes such a pad as its coordinates, and the gate and `land route` both replace an ambiguous key in any plan by the coordinates of the pad nearest the path's neighbouring point (a note says so), so they land on the same pad. `route` also keeps the two pads apart as clusters, joins existing copper that crosses a custom pad's polygon anywhere along a track, and treats overlapping pads of one footprint and net as one piece of copper.+- A pad number used twice on one footprint (a connector's two `MP` pads, a ring drawn as two custom pads) is ambiguous as a key. The router writes such a pad as its coordinates, and the gate and `land route` both replace an ambiguous key in any plan by the coordinates of the pad nearest the path's neighbouring point (a note says so), so they land on the same pad. `route` also keeps the two pads apart as clusters, joins existing copper that crosses a custom pad's polygon anywhere along a track, and treats overlapping pads of one net as one piece of copper.+- `route` reads the board's own copper the way KiCad's connectivity does (issue #103): two items of one net join when their copper overlaps on a layer they share, so a track ending inside another track's width, a track end on a via's annulus off its centre, collinear overlapping pieces, overlapping zone fills of one net and a pad's real outline all count. It reads every copper layer for this (inner-layer tracks and plane fills joined through through-hole pads and vias; blind and buried vias only on the layers they span), stamps only F.Cu and B.Cu, and keeps the router's vias clear of inner-layer tracks of other nets (a long track counts along its whole length). A pad's angle in the board file is absolute and 0 when left out, also in a rotated footprint. On a fully routed board `route` writes an empty plan; the nets it still plans are the ones KiCad's DRC lists as unconnected. - An edge connector may overhang the outline on purpose: spec `placement.edgeOverhang: [{"ref": "J13", "why": "USB-C mouth proud of the edge"}]` or `--allow-outside J13` on `place check`, `place land` and `land moves`. `place check` reports it and no longer lists the part as outside; `place land` and `land moves` send `allowOutside: true` to kicad_move_footprint for those parts only. Any pad or footprint copper outside the outline is still refused before anything lands; edge clearance stays a DRC matter. A refusal with no `offendingRefs` names the part being moved. **Edge connectors, heatsink via count, lost zones, switch nodes and zones-only adoption (0.2.23).**
bin/adom-aiflow⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/clipwatch.rs+89@@ -297,3 +297,92 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } }++// -- the box's recorder (issue #106) -------------------------------------------------------------++/// Whether the run's box can record clips, asked once per box and kept in `data["recorder"]`+/// (see aiflow_bridge::recorder). `probe` is the box's reply to `desktop_record_status {}`.+/// Returns (can record, the one-time notice when the box has no recorder). An unclear answer is+/// not kept: the run records as before and asks again at the next step.+pub fn recorder_with(data: &mut Value, target: &str, now: &str, probe: impl FnOnce() -> Value) -> (bool, Option<String>) {+ if data["recorder"]["target"].as_str() == Some(target) {+ return (data["recorder"]["kind"] != "none", None);+ }+ let kind = aiflow_bridge::recorder::classify(&probe());+ let Some(rec) = aiflow_bridge::recorder::record_of(&kind, target, now) else { return (true, None) };+ data["recorder"] = rec.clone();+ if rec["kind"] == "none" { (false, Some(aiflow_bridge::recorder::no_recorder_line(&rec))) } else { (true, None) }+}++/// `recorder_with` on the live box, logged once as a `recorder` event.+pub fn recorder(r: &mut aiflow_run::Run, br: &aiflow_bridge::Bridge) -> (bool, Option<String>) {+ let known = r.data["recorder"]["target"].as_str() == Some(br.target.as_str());+ let got = recorder_with(&mut r.data, &br.target, &aiflow_run::now(), || br.raw_call_bounded("desktop_record_status", &json!({}), std::time::Duration::from_secs(60)));+ if !known && r.data["recorder"].is_object() { r.log("recorder", r.data["recorder"].clone()); }+ got+}++/// The note for a run whose box has no recorder (the clips page, deliver), or None.+pub fn no_recorder_note(data: &Value) -> Option<String> {+ (data["recorder"]["kind"] == "none").then(|| aiflow_bridge::recorder::no_recorder_line(&data["recorder"]))+}++/// A step on a box with no recorder: the previous step's closing screenshot, this step's opening+/// one, and a short note (the full notice is said once, by `recorder`).+pub fn no_recorder_step(r: &mut aiflow_run::Run, br: &aiflow_bridge::Bridge, dir: &std::path::Path, step: &str, hwnd: Option<i64>) -> String {+ let save_to = std::fs::canonicalize(dir).unwrap_or(dir.to_path_buf()).display().to_string();+ if let Some(prev) = r.data["stepShots"].as_array().and_then(|a| a.last()).filter(|p| p.get("end").is_none()).cloned() {+ let n = r.data["stepShots"].as_array().map(|a| a.len() - 1).unwrap_or(0);+ if let Some(h) = prev["hwnd"].as_i64() {+ let tag = format!("shot-{}-{}-end", prev["step"].as_str().unwrap_or("step"), prev["visit"].as_u64().unwrap_or(1));+ if let Some(p) = br.screenshot_window(h, &save_to, &tag) { r.log("artifact", json!({"step": prev["step"], "visit": prev["visit"], "kind": "screenshot", "when": "end", "file": p})); }+ }+ r.data["stepShots"][n]["end"] = json!(aiflow_run::now());+ }+ let visit = r.data["steps"][step]["visits"].as_array().map(|a| a.len()).unwrap_or(1);+ if !r.data["stepShots"].is_array() { r.data["stepShots"] = json!([]); }+ r.data["stepShots"].as_array_mut().unwrap().push(json!({"step": step, "visit": visit, "hwnd": hwnd, "start": aiflow_run::now()}));+ let mut shot = false;+ if let Some(h) = hwnd {+ if let Some(p) = br.screenshot_window(h, &save_to, &format!("shot-{step}-{visit}-start")) {+ r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "when": "start", "file": p}));+ shot = true;+ }+ }+ r.log("clip-skipped", json!({"step": step, "visit": visit, "why": "no recorder on the box"}));+ format!("no clip for {step}-{visit}: no recorder on {}{}", br.target, if shot { " (start screenshot kept)" } else { "" })+}++#[cfg(test)]+mod recorder_tests {+ use super::*;++ #[test]+ fn the_recorder_is_asked_once_per_box_and_kept() {+ let mut d = json!({});+ let mut asked = 0;+ let mac = json!({"success": false, "error": "'desktop_record_status' is not a built-in desktop verb. Did you mean: desktop_record_window_status, desktop_permission_status, desktop_record_window_start?"});+ let (can, notice) = recorder_with(&mut d, "mac", "t0", || { asked += 1; mac.clone() });+ assert!(!can);+ assert!(notice.unwrap().starts_with("no recorder on mac:"));+ // the next step: cached, no second probe, no second notice+ let (can, notice) = recorder_with(&mut d, "mac", "t1", || { asked += 1; mac.clone() });+ assert!(!can && notice.is_none());+ assert_eq!(asked, 1);+ assert!(no_recorder_note(&d).unwrap().contains("start and end screenshots"));+ // another box is asked again+ let (can, notice) = recorder_with(&mut d, "win", "t2", || { asked += 1; json!({"success": true, "recordings": []}) });+ assert!(can && notice.is_none());+ assert_eq!((asked, d["recorder"]["kind"].as_str()), (2, Some("desktop")));+ assert!(no_recorder_note(&d).is_none());+ }++ #[test]+ fn an_unclear_answer_records_as_before_and_asks_again() {+ let mut d = json!({});+ let (can, notice) = recorder_with(&mut d, "box", "t0", || json!({"success": false, "errorCode": "aiflow_timeout"}));+ assert!(can && notice.is_none() && d["recorder"].is_null());+ let (can, _) = recorder_with(&mut d, "box", "t1", || json!({"success": true, "recordings": []}));+ assert!(can && d["recorder"]["kind"] == "desktop");+ }+}
crates/adom-aiflow/src/main.rs+11−2@@ -4086,6 +4086,8 @@ fn main() { let s = copper_carry::field_screen_with_acks(&r.data,&f,&spec,Path::new(&solved_board),&spec_bytes,what,&live_acks).map(|(n, c)| { if let Some(n) = n { eprintln!("{n}"); } c }).map_err(|e| if e.contains("identity") { format!("{e}: adom-fields analyze --board {solved_board} --spec {} --out {}",r.data["spec"].as_str().unwrap_or(""),dir.join("fields").display()) } else { e }); // issue #67: the same-input solve with a declared board-sink block stands in for the estimate if what == "thermal" && s.is_ok() { sink_notes = aiflow_analyze::board_sink::accept_solve(&mut res, &spec, &f); heuristic_passed = res.iter().all(|x| x["status"] == "pass"); }+ // issue #107: the passed solve's rise at each net's narrowest neck judges the whole-net width screen+ if what == "current" && s.is_ok() { aiflow_analyze::solved_ampacity::accept_solve(&mut res, &spec, &f); heuristic_passed = res.iter().all(|x| x["status"] == "pass"); } s } else { Err(format!("no field solve at {}; run adom-fields analyze --board {} --spec {} --out {}, then analyze {what} --fields {}",fields_file.display(),solved_board,r.data["spec"].as_str().unwrap_or(""),dir.join("fields").display(),dir.join("fields/fields.json").display())) }; let passed=heuristic_passed && screen.is_ok();@@ -4437,6 +4439,7 @@ fn main() { // the PCB editor window itself, background-capturable; the monitor only when no editor is open // the editor open NOW first: a stored handle dies when the editor is closed and reopened (an offline edit) let hwnd = br.pcb_editor_hwnd().or_else(|| r.data["captures"].as_array().and_then(|c| c.iter().rev().find_map(|e| e.get("hwnd").and_then(|h| h.as_i64()))));+ if !clipwatch::recorder(&mut r, &br).0 { r.save().unwrap(); err(&clipwatch::no_recorder_note(&r.data).unwrap_or_default(), &["Steps still keep their start and end screenshots; `deliver` works without clips.".into()]); } let (rid, kind) = match hwnd { Some(h) => (br.record_window_start(h, &why).unwrap_or_else(|rep| err(&format!("window recorder did not start: {}", rep.to_string().chars().take(300).collect::<String>()), &[])), json!({"kind": "window", "hwnd": h})), None => (br.record_start(&why).unwrap_or_else(|rep| err(&format!("recorder did not start: {}", rep.to_string().chars().take(300).collect::<String>()), &[])), json!({"kind": "monitor"})),@@ -5245,6 +5248,7 @@ fn main() { r.save().unwrap(); err("the 3D Viewer's canvas never painted (a white window), so nothing was filmed", &["Look at kicad_screenshot_all on the test box: a GPU-less host needs kicad_enable_software_opengl; a hidden window needs to be shown once.".into()]); }+ if !clipwatch::recorder(&mut r, &br).0 { r.save().unwrap(); err(&format!("no walkthrough clip: {}", clipwatch::no_recorder_note(&r.data).unwrap_or_default()), &[]); } let rid = br.record_window_start(viewer, &format!("aiflow 3D walkthrough")).unwrap_or_else(|rep| err(&format!("viewer recording did not start: {}", rep["errorCode"].as_str().unwrap_or("?")), &[])); r.data["captures"].as_array_mut().unwrap().push(json!({"recordingId": rid, "start": now(), "kind": "window", "hwnd": viewer, "step": step, "visit": visit, "back": false, "tour": "3d"})); r.log("clip-start", json!({"step": step, "visit": visit, "recordingId": rid, "window": "3D Viewer"}));@@ -5393,6 +5397,7 @@ fn main() { } std::thread::sleep(std::time::Duration::from_secs(4)); // issue #98: the walkthrough take is capped in length as well as in bit rate+ if !clipwatch::recorder(&mut r, &br).0 { r.save().unwrap(); err(&format!("no walkthrough clip: {}", clipwatch::no_recorder_note(&r.data).unwrap_or_default()), &[]); } let rid = br.record_window_start_capped(win, "aiflow fields walkthrough", FIELDS_CLIP_MAX_MS).unwrap_or_else(|rep| err(&format!("recording of the app window did not start: {}", rep["errorCode"].as_str().unwrap_or("?")), &[])); r.data["captures"].as_array_mut().unwrap().push(json!({"recordingId": rid, "start": now(), "kind": "window", "hwnd": win, "step": step, "visit": visit, "back": false, "tour": "fields"})); r.log("clip-start", json!({"step": step, "visit": visit, "recordingId": rid, "window": "Adom Fields"}));@@ -5626,13 +5631,17 @@ fn main() { let pre_cam = (name != "fields" && clipwatch::preboard_step(name)).then(|| clipwatch::choose_preboard(&br, &*cam, name, r.data["engine"].as_str().unwrap_or(""))); if let Some(clipwatch::Camera::Window { what, .. }) = &pre_cam { if what.starts_with("Hydrogen") { ensure_dashboard(&dir, &thread(&cli), true); } } let hwnd = match &pre_cam { Some(clipwatch::Camera::Window { hwnd, .. }) => Some(*hwnd), Some(clipwatch::Camera::Skip(_)) => None, None => if name == "fields" { None } else { cam.camera_window(&tx, name, cached) } };- let disk = disk_check(&dir, &br, name);+ // issue #106: a box whose Adom Bridge has no recorder is said once, then each step keeps screenshots+ let (can_record, rec_notice) = clipwatch::recorder(&mut r, &br);+ if let Some(n) = &rec_notice { clip_note.push_str(&format!("{n}; ")); }+ let disk = if can_record { disk_check(&dir, &br, name) } else { Ok(String::new()) }; if let Err(e) = &disk { r.log("clip-refused", json!({"step": name, "why": e})); r.save().unwrap(); err(&format!("step {name} recorded, but no clip: {e}"), &["The step is charged from now; start its clip once there is room: `step <name>` again, or `capture start`.".into()]); } match hwnd {+ _ if !can_record => clip_note.push_str(&clipwatch::no_recorder_step(&mut r, &br, &dir, name, hwnd)), Some(h) => match br.record_window_start(h, &format!("aiflow step {name}{}", if *back { " (rework)" } else { "" })) { Ok(rid) => { // every visit is its own clip: placement-1, then placement-2 when a later step sends the AI back@@ -5746,7 +5755,7 @@ fn main() { report_note.push_str(&format!(" Run page: {url} ({})", notes.join("; "))); } let verified_text = verified::message(&checked);- ok(&format!("delivered: {} from the prompt to \"done, here is your video\", {:.1} min (finish was at {:.1} min)\n{}\n{verified_text}", r.data["engine"], r.delivered_minutes().unwrap_or(0.0), r.elapsed_minutes(), step_table(&r)), &["Your message to the human must state the verified list as it stands; no \"ready to fabricate\" unless every item is yes, and still advise a human review.".into(), "This is the number on the chart. The per-step table is the run's shape: thinking is the AI, binary is the tool, returns are the reworks.".into(), report_note, give_back_hint()]);+ ok(&format!("delivered: {} from the prompt to \"done, here is your video\", {:.1} min (finish was at {:.1} min)\n{}\n{verified_text}{}", r.data["engine"], r.delivered_minutes().unwrap_or(0.0), r.elapsed_minutes(), step_table(&r), clipwatch::no_recorder_note(&r.data).map(|n| format!("\n{n}")).unwrap_or_default()), &["Your message to the human must state the verified list as it stands; no \"ready to fabricate\" unless every item is yes, and still advise a human review.".into(), "This is the number on the chart. The per-step table is the run's shape: thinking is the AI, binary is the tool, returns are the reworks.".into(), report_note, give_back_hint()]); } } }
crates/adom-aiflow/src/preboard.rs+12@@ -384,6 +384,11 @@ pub fn clips_page(data: &Value, dir: &Path, notes: &Value, updated: &str, page: format!("Last updated {}. {} clip(s).", when(updated), caps.len()), String::new(), "| # | step | visit | recorded | links | state |".to_string(), "|---|---|---|---|---|---|".to_string(), ];+ // issue #106: a box with no recorder is said once here, not as a missing clip per step+ if let Some(n) = crate::clipwatch::no_recorder_note(data) {+ md.insert(md.len() - 2, format!("> {}.", n.replacen("no recorder", "No recorder", 1)));+ md.insert(md.len() - 2, String::new());+ } md.extend(rows); md.push(String::new()); md.extend(blocks);@@ -622,6 +627,13 @@ mod tests { assert!(md.contains("(not in the manifest)"), "orphan window-9 listed"); assert!(!md.contains("window-2"), "discarded clip left out"); assert_eq!(media.len(), 3);+ // issue #106: a box with no recorder says so once, above the table+ let mut none = data.clone();+ none["recorder"] = json!({"kind": "none", "target": "MacBook-Air", "offered": ["desktop_record_window_start"]});+ let (nmd, _) = clips_page(&none, &dir, ¬es, "2026-09-29T03:00:00Z", None, None);+ assert_eq!(nmd.matches("No recorder on MacBook-Air").count(), 1, "{nmd}");+ assert!(nmd.find("No recorder").unwrap() < nmd.find("| # | step").unwrap());+ assert!(!md.contains("recorder on")); assert!(scrub(&md, None).is_ok()); std::fs::remove_dir_all(dir).unwrap(); }
crates/adom-aiflow/src/schematic_symbols.rs+105−10@@ -57,7 +57,7 @@ enum Sev { Error, Warn } struct Pin { name: String, number: String, etype: String, at: Pt, inner: Pt, unit: u32 } #[derive(Debug, Clone)]-struct Shape { kind: &'static str, pts: Vec<Pt>, closed: bool, unit: u32 }+struct Shape { kind: &'static str, pts: Vec<Pt>, closed: bool, unit: u32, /** stroke width, mm (0 = KiCad's default) */ width: f64 } #[derive(Debug, Clone)] struct Txt { text: String, at: Pt, unit: u32 }@@ -99,6 +99,7 @@ fn parse_lib(sym: &Node) -> LibSym { } for (n, unit) in parts { for c in n.nodes() {+ let width = c.find("stroke").and_then(|s| s.find("width")).and_then(|w| w.number(1)).unwrap_or(0.0); match c.tag.as_str() { "pin" => { let Some((at, ang)) = at_of(c) else { continue };@@ -116,22 +117,22 @@ fn parse_lib(sym: &Node) -> LibSym { let filled = c.find("fill").and_then(|f| f.find("type")).and_then(|t| t.value()).map(|t| t != "none").unwrap_or(false); let mut closed = filled; if pts.len() > 2 && close(pts[0], *pts.last().unwrap()) { pts.pop(); closed = true; }- out.shapes.push(Shape { kind: "polyline", pts, closed, unit });+ out.shapes.push(Shape { kind: "polyline", pts, closed, unit, width }); } "rectangle" => { if let (Some(a), Some(b)) = (c.find("start").and_then(xy), c.find("end").and_then(xy)) {- out.shapes.push(Shape { kind: "rectangle", pts: vec![a, (b.0, a.1), b, (a.0, b.1)], closed: true, unit });+ out.shapes.push(Shape { kind: "rectangle", pts: vec![a, (b.0, a.1), b, (a.0, b.1)], closed: true, unit, width }); } } "circle" => { if let (Some(ce), Some(r)) = (c.find("center").and_then(xy), c.find("radius").and_then(|x| x.number(1))) { let pts = (0..8).map(|i| { let t = i as f64 * std::f64::consts::FRAC_PI_4; (ce.0 + r * t.cos(), ce.1 + r * t.sin()) }).collect();- out.shapes.push(Shape { kind: "circle", pts, closed: true, unit });+ out.shapes.push(Shape { kind: "circle", pts, closed: true, unit, width }); } } "arc" => { let pts: Vec<Pt> = ["start", "mid", "end"].iter().filter_map(|k| c.find(k).and_then(xy)).collect();- if pts.len() >= 2 { out.shapes.push(Shape { kind: "arc", pts, closed: false, unit }); }+ if pts.len() >= 2 { out.shapes.push(Shape { kind: "arc", pts, closed: false, unit, width }); } } "text" => { if let (Some(t), Some((at, _))) = (c.atom(1), at_of(c)) { out.texts.push(Txt { text: t.to_string(), at, unit }); }@@ -194,6 +195,37 @@ fn named<'a>(pins: &[&'a Pin], names: &[&str]) -> Vec<&'a Pin> { pins.iter().copied().filter(|p| names.contains(&norm(&p.name).as_str())).collect() } +/// Issue #108: open polylines that share end points, joined into chains. A chain that comes back to+/// its start is a closed outline (an open triangle plus a separate base line is a triangle). Repeated+/// and collinear corners are dropped, so the outline keeps only its real corners.+fn joined_outlines(shapes: &[&Shape]) -> Vec<Vec<Pt>> {+ let mut pieces: Vec<Vec<Pt>> = shapes.iter().filter(|s| s.kind == "polyline" && !s.closed).map(|s| s.pts.clone()).collect();+ let mut out = Vec::new();+ while let Some(mut chain) = pieces.pop() {+ loop {+ let (head, tail) = (chain[0], *chain.last().unwrap());+ let Some(i) = pieces.iter().position(|p| [p[0], *p.last().unwrap()].iter().any(|e| close(*e, head) || close(*e, tail))) else { break };+ let mut p = pieces.remove(i);+ if close(p[0], tail) { chain.extend(p.drain(1..)); }+ else if close(*p.last().unwrap(), tail) { p.reverse(); chain.extend(p.drain(1..)); }+ else if close(*p.last().unwrap(), head) { p.pop(); p.extend(chain); chain = p; }+ else { p.reverse(); p.pop(); p.extend(chain); chain = p; }+ if chain.len() > 2 && close(chain[0], *chain.last().unwrap()) { break; }+ }+ if chain.len() < 4 || !close(chain[0], *chain.last().unwrap()) { continue; }+ chain.pop();+ let mut corners: Vec<Pt> = Vec::new();+ for q in chain { if corners.last().is_none_or(|l| !close(*l, q)) { corners.push(q); } }+ let n = corners.len();+ let kept: Vec<Pt> = (0..n).filter(|&i| {+ let (a, b, c) = (corners[(i + n - 1) % n], corners[i], corners[(i + 1) % n]);+ ((b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0)).abs() > 1e-6+ }).map(|i| corners[i]).collect();+ out.push(kept);+ }+ out+}+ fn diode_check(pins: &[&Pin], shapes: &[&Shape]) -> Option<(Sev, String)> { let (k, a) = (named(pins, &["K", "CATHODE", "KATHODE"]), named(pins, &["A", "ANODE"])); if k.len() != 1 || a.len() != 1 { return None; }@@ -206,16 +238,18 @@ fn diode_check(pins: &[&Pin], shapes: &[&Shape]) -> Option<(Sev, String)> { let who = format!("pin {} ({}) is the cathode, pin {} ({}) the anode", k.number, k.name, a.number, a.name); // the body triangle: a base across the axis, the apex on it, pointing at the cathode let mut tri: Option<(f64, f64, f64)> = None;- for sh in shapes.iter().filter(|s| s.kind == "polyline" && s.closed && s.pts.len() == 3) {+ let triangles: Vec<Vec<Pt>> = shapes.iter().filter(|s| s.kind == "polyline" && s.closed && s.pts.len() == 3).map(|s| s.pts.clone())+ .chain(joined_outlines(shapes).into_iter().filter(|p| p.len() == 3)).collect();+ for pts in &triangles { for i in 0..3 {- let (ap, pj, pk) = (sh.pts[i], sh.pts[(i + 1) % 3], sh.pts[(i + 2) % 3]);+ let (ap, pj, pk) = (pts[i], pts[(i + 1) % 3], pts[(i + 2) % 3]); let width = (s(pj) - s(pk)).abs(); if width < 0.3 || (t(pj) - t(pk)).abs() * l > 0.25 * width || s(ap).abs() > 0.25 * width || ((s(pj) + s(pk)) / 2.0).abs() > 0.25 * width { continue; } if tri.map(|x| width > x.2).unwrap_or(true) { tri = Some(((t(pj) + t(pk)) / 2.0, t(ap), width)); } } } // bars: a stroke across the axis (open polyline or a thin filled rectangle)- let mut bars: Vec<(f64, f64)> = Vec::new(); // (t, across extent)+ let mut bars: Vec<(f64, f64, f64)> = Vec::new(); // (t, across extent, stroke width) for sh in shapes.iter().filter(|s| (s.kind == "polyline" && !s.closed) || (s.kind == "rectangle" || s.kind == "polyline") && s.closed && s.pts.len() == 4) { let ts: Vec<f64> = sh.pts.iter().map(|p| t(*p)).collect(); let ss: Vec<f64> = sh.pts.iter().map(|p| s(*p)).collect();@@ -228,11 +262,28 @@ fn diode_check(pins: &[&Pin], shapes: &[&Shape]) -> Option<(Sev, String)> { let n = sh.pts.len(); let seg = (0..n - if sh.closed { 0 } else { 1 }).max_by(|&i, &j| dist(sh.pts[i], sh.pts[(i + 1) % n]).total_cmp(&dist(sh.pts[j], sh.pts[(j + 1) % n]))).unwrap_or(0); let mid = ((sh.pts[seg].0 + sh.pts[(seg + 1) % n].0) / 2.0, (sh.pts[seg].1 + sh.pts[(seg + 1) % n].1) / 2.0);- bars.push((t(mid), across));+ bars.push((t(mid), across, if sh.width > 0.0 { sh.width } else { 0.1524 })); } let bar = match tri { Some((_, apex, _)) => bars.iter().min_by(|x, y| (x.0 - apex).abs().total_cmp(&(y.0 - apex).abs())).map(|b| b.0),- None => bars.iter().max_by(|x, y| x.1.total_cmp(&y.1)).map(|b| b.0),+ None => {+ // issue #108: the longest bar; between equally long bars at different ends, the thicker+ // stroke; equal strokes cannot say which end is the cathode+ let longest = bars.iter().map(|b| b.1).fold(0.0, f64::max);+ let mut tied: Vec<&(f64, f64, f64)> = bars.iter().filter(|b| b.1 >= longest - 1e-3).collect();+ tied.sort_by(|x, y| y.2.total_cmp(&x.2));+ match tied.as_slice() {+ [] => None,+ [one] => Some(one.0),+ [first, rest @ ..] => {+ let rivals: Vec<_> = rest.iter().filter(|b| (b.0 - first.0).abs() > 0.05).collect();+ match rivals.first() {+ Some(r) if r.2 >= first.2 - 1e-6 => return Some((Sev::Warn, format!("cannot tell which end is the cathode: two equal bars across the axis and no triangle recognised; check by eye that the bar is at the cathode ({who})"))),+ _ => Some(first.0),+ }+ }+ }+ } }; match (tri, bar) { (Some((base, apex, _)), b) if apex < base => {@@ -673,6 +724,50 @@ mod tests { assert!(rep["errors"].as_array().unwrap().iter().any(|e| e.as_str().unwrap().contains("cathode bar is drawn at the pin 2 end")), "{rep}"); } + /// Issue #108, Drew's BPW34S drawing: the cathode bar, an open triangle pointing at it and the+ /// triangle's base as a separate line. `k_left` puts pin 1 (K) at the bar's end.+ fn open_triangle_lib(name: &str, k_left: bool) -> String {+ let (k, a) = if k_left { ("(at -5.08 0 0)", "(at 2.54 0 180)") } else { ("(at 2.54 0 180)", "(at -5.08 0 0)") };+ format!(r#"(symbol "{name}" (pin_names (hide yes))+ (symbol "BPW34S_0_1"+ (polyline (pts (xy -2.54 1.27) (xy -2.54 -1.27)) (stroke (width 0.1524)))+ (polyline (pts (xy 0 1.27) (xy -2.54 0) (xy 0 -1.27)))+ (polyline (pts (xy 0 -1.27) (xy 0 1.27))))+ (symbol "BPW34S_1_1" (pin passive line {k} (length 2.54) (name "K") (number "1")) (pin passive line {a} (length 2.54) (name "A") (number "2"))))"#)+ }++ #[test]+ fn an_open_triangle_with_a_separate_base_is_a_triangle() {+ let f = Fixture::new();+ let p = f.write("s.kicad_sch", &sheet(&[open_triangle_lib("smart_laser_target:BPW34S-Z", true)], &placed("smart_laser_target:BPW34S-Z", "D1", 100.0, 80.0)));+ let rep = check(&p, &Value::Null).unwrap();+ assert_eq!(rep["ok"], true, "{rep}");+ assert!(rep["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap().contains("polarity")), "{rep}");+ // the same drawing with K and A swapped is a genuinely reversed diode+ let p = f.write("r.kicad_sch", &sheet(&[open_triangle_lib("smart_laser_target:BPW34S-Z", false)], &placed("smart_laser_target:BPW34S-Z", "D1", 100.0, 80.0)));+ let rep = check(&p, &Value::Null).unwrap();+ assert_eq!(rep["ok"], false, "{rep}");+ assert!(rep["errors"][0].as_str().unwrap().contains("pointing at the anode"), "{rep}");+ }++ #[test]+ fn two_equal_bars_without_a_triangle_warn_unless_one_is_thicker() {+ let f = Fixture::new();+ let lib = |kw: f64, aw: f64| format!(r#"(symbol "My:D2" (symbol "D2_0_1"+ (polyline (pts (xy -2.54 1.27) (xy -2.54 -1.27)) (stroke (width {kw})))+ (polyline (pts (xy 0 -1.27) (xy 0 1.27)) (stroke (width {aw}))))+ (symbol "D2_1_1" (pin passive line (at -5.08 0 0) (length 2.54) (name "K") (number "1")) (pin passive line (at 2.54 0 180) (length 2.54) (name "A") (number "2"))))"#);+ let rep = check(&f.write("t.kicad_sch", &sheet(&[lib(0.2, 0.2)], &placed("My:D2", "D1", 100.0, 80.0))), &Value::Null).unwrap();+ assert_eq!(rep["ok"], true, "{rep}");+ assert!(rep["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("cannot tell which end is the cathode")), "{rep}");+ // the thicker stroke is the bar: at the cathode it passes, at the anode it is an error+ let rep = check(&f.write("k.kicad_sch", &sheet(&[lib(0.4, 0.2)], &placed("My:D2", "D1", 100.0, 80.0))), &Value::Null).unwrap();+ assert_eq!(rep["ok"], true, "{rep}");+ assert!(rep["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap().contains("cathode")), "{rep}");+ let rep = check(&f.write("a.kicad_sch", &sheet(&[lib(0.2, 0.4)], &placed("My:D2", "D1", 100.0, 80.0))), &Value::Null).unwrap();+ assert!(rep["errors"][0].as_str().unwrap().contains("cathode bar is drawn at the pin 2 end"), "{rep}");+ }+ fn opamp_lib(name: &str, in_type: &str, plus_text_y: f64) -> String { format!(r#"(symbol "{name}" (pin_names (offset 0.127)) (property "Reference" "U" (at 0 5 0) (effects (font (size 1.27 1.27)))) (property "ki_keywords" "dual opamp" (at 0 0 0) (effects (font (size 1.27 1.27)) (hide yes))) (symbol "OPA_1_1"
crates/aiflow-analyze/src/branches.rs+204−32@@ -1,5 +1,9 @@-//! Optional centerline resistor screen for explicit pin currents on unpoured two-layer nets.+//! Optional centerline resistor screen for explicit pin currents on unpoured nets. //! Filled-copper Fields remains mandatory. This is not a pad/contact thermal model.+//!+//! Layers are numbered through the stack, 0 = F.Cu to n-1 = B.Cu (issue #107: multilayer boards).+//! A net whose copper is only on the outer layers needs nothing beyond the declared board thickness;+//! a net with tracks on an inner layer needs the board's own physical stackup to place each barrel tap. use aiflow_board::{Board, Pt}; use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet};@@ -108,24 +112,80 @@ fn solve(edges: &[Edge], injections: &BTreeMap<Key, f64>) -> Result<Vec<f64>, St .collect()) } -pub fn screen(board: &Board, net: &str, load: &Value, spec: &Value) -> Result<Value, String> {- let layers: Vec<_> = board- .root+/// The board's copper layers in physical order: F.Cu, In1.Cu .. InN.Cu, B.Cu.+pub fn copper_stack(board: &Board) -> Result<Vec<String>, String> {+ copper_stack_of(&board.root)+}++/// `copper_stack` from a parsed board file's root node.+pub fn copper_stack_of(root: &aiflow_board::Node) -> Result<Vec<String>, String> {+ let names: Vec<String> = root .find("layers") .ok_or("missing physical copper layer table")? .nodes() .filter_map(|n| n.atom(1)) .filter(|l| l.ends_with(".Cu"))+ .map(str::to_string) .collect();- if layers != ["F.Cu", "B.Cu"] {- return Err("branch model requires exactly F.Cu and B.Cu".into());+ let mut stack = vec!["F.Cu".to_string()];+ stack.extend((1..=names.len().saturating_sub(2)).map(|k| format!("In{k}.Cu")));+ stack.push("B.Cu".into());+ let (mut a, mut b) = (names.clone(), stack.clone());+ a.sort();+ b.sort();+ if names.len() < 2 || a != b {+ return Err(format!("unrecognised copper layer table {names:?}; expected F.Cu, In1.Cu.., B.Cu"));+ }+ Ok(stack)+}++/// Where each copper layer's middle sits through the board, 0 at F.Cu and 1 at B.Cu, from the board's+/// own physical stackup (every copper and dielectric thickness between F.Cu and B.Cu), with each copper+/// layer's thickness in um.+fn stack_fractions(board: &Board, stack: &[String]) -> Result<(Vec<f64>, Vec<f64>), String> {+ let missing = || "the board file states no complete physical stackup (Board Setup > Physical Stackup: every copper and dielectric thickness)".to_string();+ let layers = board.root.find("setup").and_then(|s| s.find("stackup")).ok_or_else(missing)?;+ let (mut z, mut started) = (0.0, false);+ let mut mid: BTreeMap<String, f64> = BTreeMap::new();+ let mut cu: BTreeMap<String, f64> = BTreeMap::new();+ for l in layers.find_all("layer") {+ let name = l.atom(1).unwrap_or("");+ started |= name == "F.Cu";+ if !started {+ continue;+ }+ let t: f64 = l.find_all("thickness").filter_map(|t| t.number(1)).sum();+ if !t.is_finite() || t <= 0. {+ return Err(missing());+ }+ if name.ends_with(".Cu") {+ mid.insert(name.to_string(), z + t / 2.);+ cu.insert(name.to_string(), t * 1000.);+ }+ z += t;+ if name == "B.Cu" {+ break;+ } }- for v in board.root.find_all("via") {+ let (Some(f), Some(b)) = (mid.get("F.Cu").copied(), mid.get("B.Cu").copied()) else { return Err(missing()) };+ let fractions = stack+ .iter()+ .map(|n| mid.get(n).map(|z| (z - f) / (b - f)).ok_or_else(missing))+ .collect::<Result<Vec<_>, _>>()?;+ Ok((fractions, stack.iter().map(|n| cu[n]).collect()))+}++pub fn screen(board: &Board, net: &str, load: &Value, spec: &Value) -> Result<Value, String> {+ let stack = copper_stack(board)?;+ let n_layers = stack.len();+ let outer = |l: usize| l == 0 || l + 1 == n_layers;+ let layer_index = |name: &str| stack.iter().position(|s| s == name);+ for v in board.root.find_all("via").filter(|v| board.item_net(v) == net) { if v.atom(1).is_some_and(|x| x == "blind" || x == "micro") || v.find("layers") .is_some_and(|l| l.atom(1) != Some("F.Cu") || l.atom(2) != Some("B.Cu")) {- return Err("branch model supports only through vias".into());+ return Err(format!("branch model supports only through vias; {net} has a blind, buried or micro via")); } } let model = &spec["branchCurrentModel"];@@ -153,8 +213,23 @@ pub fn screen(board: &Board, net: &str, load: &Value, spec: &Value) -> Result<Va &model["maxBarrelDensityAperMm2"], "branchCurrentModel.maxBarrelDensityAperMm2", )?;- if board.zones().iter().any(|z| z.net == net) {- return Err("branch model does not support pours; use filled-copper analysis".into());+ let zone_layers: BTreeSet<String> = board+ .root+ .find_all("zone")+ .filter(|z| z.find("keepout").is_none() && board.item_net(z) == net)+ .flat_map(|z| {+ let mut v: Vec<String> = z.find("layer").and_then(|l| l.value()).map(|l| vec![l.to_string()]).unwrap_or_default();+ if let Some(ls) = z.find("layers") {+ v.extend((1..).map_while(|i| ls.atom(i)).map(str::to_string));+ }+ v+ })+ .collect();+ if !zone_layers.is_empty() {+ return Err(format!(+ "branch model does not support pours or planes ({net} has a zone on {}); use filled-copper analysis",+ zone_layers.into_iter().collect::<Vec<_>>().join(", ")+ )); } if board.root.find_all("arc").next().is_some() { return Err("branch model does not support boards with copper arcs".into());@@ -221,27 +296,52 @@ pub fn screen(board: &Board, net: &str, load: &Value, spec: &Value) -> Result<Va )); } }- let tracks: Vec<_> = board- .tracks()- .into_iter()- .filter(|t| t.net == net)- .collect();+ struct Track {+ layer: usize,+ a: Pt,+ b: Pt,+ width: f64,+ }+ let mut tracks = Vec::new();+ for node in board.root.find_all("segment").filter(|s| board.item_net(s) == net) {+ let name = node.find("layer").and_then(|l| l.value()).unwrap_or("");+ let layer = layer_index(name).ok_or_else(|| format!("{net} has a track on {name:?}, which is not in the copper stack {stack:?}"))?;+ let p = |tag: &str| node.find(tag).and_then(|n| Some((n.number(1)?, n.number(2)?)));+ let (Some(a), Some(b)) = (p("start"), p("end")) else { return Err("invalid track geometry".into()) };+ tracks.push(Track { layer, a, b, width: node.find("width").and_then(|w| w.number(1)).unwrap_or(0.) });+ } if tracks.is_empty() { return Err("no tracks for branch screen".into()); }+ // barrel taps: the outer layers, plus every inner layer this net has tracks on+ let mut taps: Vec<usize> = vec![0, n_layers - 1];+ taps.extend(tracks.iter().map(|t| t.layer).filter(|l| !outer(*l)));+ taps.sort();+ taps.dedup();+ let fraction = if taps.len() > 2 {+ let inner: Vec<_> = taps[1..taps.len() - 1].iter().map(|l| stack[*l].as_str()).collect();+ let (f, cu) = stack_fractions(board, &stack).map_err(|e| format!("{net} has tracks on {}; placing their barrel taps needs {e}", inner.join(", ")))?;+ if let Some(l) = taps.iter().find(|l| (cu[**l] - copper).abs() > 0.5) {+ return Err(format!("{} is {:.1} um in the board stackup but the spec declares copperUm {copper}; the branch model needs one uniform copper thickness on the layers it models", stack[*l], cu[*l]));+ }+ f+ } else {+ (0..n_layers).map(|l| if l == 0 { 0. } else { 1. }).collect()+ };+ let pad_on = |p: &aiflow_board::Pad, l: usize| (p.thru && taps.contains(&l)) || (l == 0 && p.on_f) || (l + 1 == n_layers && p.on_b); let mut candidates: BTreeSet<Key> = tracks .iter() .flat_map(|t| [key(t.layer, t.a), key(t.layer, t.b)]) .collect(); for p in &pads {- for l in 0..2 {- if p.on_layer(l) {+ for &l in &taps {+ if pad_on(p, l) { candidates.insert(key(l, (p.x, p.y))); } } } for v in board.vias().iter().filter(|v| v.net == net) {- for l in 0..2 {+ for &l in &taps { candidates.insert(key(l, (v.x, v.y))); } }@@ -291,7 +391,7 @@ pub fn screen(board: &Board, net: &str, load: &Value, spec: &Value) -> Result<Va if len < 1e-8 { continue; }- edges.push(Edge{a:pair[0],b:pair[1],r:len/(58000.*t.width*copper/1000.),width:Some(t.width),meta:json!({"kind":"track","trackIndex":i,"layer":if t.layer==0{"F.Cu"}else{"B.Cu"},"start":pt(pair[0]),"end":pt(pair[1]),"widthMm":t.width})});+ edges.push(Edge{a:pair[0],b:pair[1],r:len/(58000.*t.width*copper/1000.),width:Some(t.width),meta:json!({"kind":"track","trackIndex":i,"layer":stack[t.layer],"external":outer(t.layer),"start":pt(pair[0]),"end":pt(pair[1]),"widthMm":t.width})}); } } let mut parents = BTreeMap::new();@@ -299,8 +399,8 @@ pub fn screen(board: &Board, net: &str, load: &Value, spec: &Value) -> Result<Va if !["circle", "rect", "roundrect", "oval"].contains(&p.shape.as_str()) { return Err("unsupported pad shape for branch contact approximation".into()); }- for l in 0..2 {- if p.on_layer(l) {+ for &l in &taps {+ if pad_on(p, l) { let center = key(l, (p.x, p.y)); for k in &candidates { if k.0 == l@@ -327,13 +427,23 @@ pub fn screen(board: &Board, net: &str, load: &Value, spec: &Value) -> Result<Va } let area = std::f64::consts::PI * ((drill / 2. + plating).powi(2) - (drill / 2.).powi(2)); meta["barrelAreaMm2"] = json!(area);- edges.push(Edge {- a: key(0, (x, y)),- b: key(1, (x, y)),- r: thick / (58000. * area),- width: None,- meta,- });+ // one barrel piece between each pair of adjacent taps, its length from the stack position+ for pair in taps.windows(2) {+ let length = thick * (fraction[pair[1]] - fraction[pair[0]]);+ if !length.is_finite() || length <= 0. {+ return Err("physical stackup gives a non-positive barrel length".into());+ }+ let mut m = meta.clone();+ m["from"] = json!(stack[pair[0]]);+ m["to"] = json!(stack[pair[1]]);+ edges.push(Edge {+ a: key(pair[0], (x, y)),+ b: key(pair[1], (x, y)),+ r: length / (58000. * area),+ width: None,+ meta: m,+ });+ } Ok(()) }; for v in board.vias().iter().filter(|v| v.net == net) {@@ -372,13 +482,13 @@ pub fn screen(board: &Board, net: &str, load: &Value, spec: &Value) -> Result<Va let mut j = 0; for (i, p) in &active { let l = if p.thru {- let l = (mask >> j) & 1;+ let l = if (mask >> j) & 1 == 0 { 0 } else { n_layers - 1 }; j += 1; l } else if p.on_f { 0 } else {- 1+ n_layers - 1 }; *injection .entry(root(key(l, (p.x, p.y)), &parents))@@ -390,7 +500,7 @@ pub fn screen(board: &Board, net: &str, load: &Value, spec: &Value) -> Result<Va let mut r = e.meta.clone(); r["amps"] = json!(current.abs()); if let Some(width) = e.width {- let rise = crate::rise_for(width, copper, current.abs(), true);+ let rise = crate::rise_for(width, copper, current.abs(), e.meta["external"] != false); worst = worst.max(rise); r["riseEstimateC"] = json!(rise); } else {@@ -406,7 +516,7 @@ pub fn screen(board: &Board, net: &str, load: &Value, spec: &Value) -> Result<Va cases.push(json!({"throughPadLayerCase":mask,"conductors":conductors})); } Ok(- json!({"net":net,"status":if worst<=max_rise && peak_barrel<=barrel_limit{"pass"}else{"fail"},"method":"explicit-pin resistive centerlines plus mandatory same-input Fields","peakTrackRiseC":worst,"peakBarrelDensityAperMm2":peak_barrel,"barrelDensityLimitAperMm2":barrel_limit,"maxRiseC":max_rise,"cases":cases,"how":format!("branch currents give {worst:.2} C maximum track estimate against {max_rise:.2} C; barrel density {peak_barrel:.2} against {barrel_limit:.2} A/mm2; all through-terminal layer extremes checked"),"limits":"Nominal straight-track and barrel resistances, ideal rectangular pad outlines and conservative rounded-pad central regions; no pad/contact thermal qualification. Filled-copper Fields and physical stack validation remain mandatory."}),+ json!({"net":net,"status":if worst<=max_rise && peak_barrel<=barrel_limit{"pass"}else{"fail"},"method":"explicit-pin resistive centerlines plus mandatory same-input Fields","peakTrackRiseC":worst,"peakBarrelDensityAperMm2":peak_barrel,"barrelDensityLimitAperMm2":barrel_limit,"maxRiseC":max_rise,"copperStack":stack,"barrelTaps":taps.iter().map(|l| stack[*l].clone()).collect::<Vec<_>>(),"cases":cases,"how":format!("branch currents give {worst:.2} C maximum track estimate against {max_rise:.2} C; barrel density {peak_barrel:.2} against {barrel_limit:.2} A/mm2; all through-terminal layer extremes checked"),"limits":"Nominal straight-track and barrel resistances (inner-layer tracks by IPC-2221's internal curve; barrel pieces between taps placed by the board's own stackup), through-pad current injected at the F.Cu or B.Cu end, ideal rectangular pad outlines and conservative rounded-pad central regions; no pad/contact thermal qualification. Filled-copper Fields and physical stack validation remain mandatory."}), ) } @@ -540,4 +650,66 @@ mod geometry_tests { missing["loads"]["V"]["terminals"][1]["amps"] = json!(-3); assert!(screen(&b, "V", &missing["loads"]["V"], &missing).unwrap_err().contains("declare current")); }++ // issue #107: a four-layer board (In1 GND plane, In2 3V3A plane of other nets)+ const STACKUP: &str = r#"(setup (stackup (layer "F.Mask" (type "Top Solder Mask") (thickness 0.01)) (layer "F.Cu" (type "copper") (thickness 0.035)) (layer "dielectric 1" (type "prepreg") (thickness 0.2)) (layer "In1.Cu" (type "copper") (thickness 0.035)) (layer "dielectric 2" (type "core") (thickness 1.06)) (layer "In2.Cu" (type "copper") (thickness 0.035)) (layer "dielectric 3" (type "prepreg") (thickness 0.2)) (layer "B.Cu" (type "copper") (thickness 0.035)) (layer "B.Mask" (type "Bottom Solder Mask") (thickness 0.01))))"#;+ fn four_layer(inner_width: Option<f64>, stackup: bool) -> Board {+ let route = match inner_width {+ None => "(segment (start 2 2) (end 12 2) (width 2) (layer \"F.Cu\") (net 1))".to_string(),+ Some(w) => format!("(segment (start 2 2) (end 4 2) (width 2) (layer \"F.Cu\") (net 1)) (via (at 4 2) (size 1.6) (drill 1) (layers \"F.Cu\" \"B.Cu\") (net 1)) (segment (start 4 2) (end 10 2) (width {w}) (layer \"In1.Cu\") (net 1)) (via (at 10 2) (size 1.6) (drill 1) (layers \"F.Cu\" \"B.Cu\") (net 1)) (segment (start 10 2) (end 12 2) (width 2) (layer \"F.Cu\") (net 1))"),+ };+ let text = format!(+ r#"(kicad_pcb (version 20260101) (layers (0 "F.Cu" signal) (4 "In1.Cu" power) (6 "In2.Cu" power) (2 "B.Cu" signal)) {} (net 1 "V") (net 2 "GND") (net 3 "3V3A")+ (gr_rect (start 0 0) (end 20 20) (layer "Edge.Cuts") (width 0.05))+ (footprint "A" (layer "F.Cu") (at 2 2) (property "Reference" "J1") (pad "1" smd circle (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "V")))+ (footprint "A" (layer "F.Cu") (at 12 2) (property "Reference" "F1") (pad "1" smd circle (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "V")))+ {route}+ (zone (net 2) (net_name "GND") (layer "In1.Cu") (polygon (pts (xy 0 0) (xy 20 0) (xy 20 20) (xy 0 20))))+ (zone (net 3) (net_name "3V3A") (layer "In2.Cu") (polygon (pts (xy 0 0) (xy 20 0) (xy 20 20) (xy 0 20)))))"#,+ if stackup { STACKUP } else { "" }+ );+ Board::from_text("four.kicad_pcb", text).unwrap()+ }+ fn four_spec() -> Value {+ json!({"copperUm":35,"branchCurrentModel":{"boardThicknessMm":1.6,"barrelPlatingUm":25,"maxBarrelDensityAperMm2":60},"loads":{"V":{"amps":3,"maxRiseC":10,"terminals":[{"reference":"J1","pad":"1","amps":3},{"reference":"F1","pad":"1","amps":-3}]}}})+ }+ #[test]+ fn outer_layer_net_on_a_four_layer_board_is_modelled_like_two_layers() {+ let s = four_spec();+ let r = screen(&four_layer(None, false), "V", &s["loads"]["V"], &s).unwrap();+ assert_eq!(r["status"], "pass", "{r}");+ assert_eq!(r["copperStack"], json!(["F.Cu", "In1.Cu", "In2.Cu", "B.Cu"]));+ assert_eq!(r["barrelTaps"], json!(["F.Cu", "B.Cu"]));+ }+ #[test]+ fn inner_layer_track_uses_the_stackup_and_the_internal_curve() {+ let s = four_spec();+ // 4 mm on In1.Cu carries 3 A within 10 C on the internal curve; 2 mm does not (it would externally)+ let r = screen(&four_layer(Some(4.), true), "V", &s["loads"]["V"], &s).unwrap();+ assert_eq!(r["status"], "pass", "{r}");+ assert_eq!(r["barrelTaps"], json!(["F.Cu", "In1.Cu", "B.Cu"]));+ let inner = r["cases"][0]["conductors"].as_array().unwrap().iter().find(|c| c["layer"] == "In1.Cu").unwrap().clone();+ assert_eq!(inner["external"], false);+ let r = screen(&four_layer(Some(2.), true), "V", &s["loads"]["V"], &s).unwrap();+ assert_eq!(r["status"], "fail", "{r}");+ assert!(crate::rise_for(2., 35., 3., true) < 10.);+ // the F.Cu to In1.Cu barrel piece is 0.2 + 0.035 mm of the 1.6 mm board, scaled to F-B centres+ let barrels: Vec<_> = r["cases"][0]["conductors"].as_array().unwrap().iter().filter(|c| c["kind"] == "via").cloned().collect();+ assert!(barrels.iter().any(|b| b["from"] == "F.Cu" && b["to"] == "In1.Cu"));+ }+ #[test]+ fn inner_copper_without_a_stackup_or_a_same_net_plane_refuses_and_says_which() {+ let s = four_spec();+ let e = screen(&four_layer(Some(4.), false), "V", &s["loads"]["V"], &s).unwrap_err();+ assert!(e.contains("In1.Cu") && e.contains("stackup"), "{e}");+ let mut text = four_layer(None, false).text;+ text.pop();+ text.push_str(r#"(zone (net 1) (net_name "V") (layer "In2.Cu") (polygon (pts (xy 0 0) (xy 5 0) (xy 5 5) (xy 0 5)))))"#);+ let e = screen(&Board::from_text("p.kicad_pcb", text).unwrap(), "V", &s["loads"]["V"], &s).unwrap_err();+ assert!(e.contains("In2.Cu") && e.contains("pours or planes"), "{e}");+ // half-ounce inner copper against a 35 um declaration+ let b = four_layer(Some(4.), true);+ let b = Board::from_text("h.kicad_pcb", b.text.replacen(r#"(layer "In1.Cu" (type "copper") (thickness 0.035))"#, r#"(layer "In1.Cu" (type "copper") (thickness 0.0175))"#, 1)).unwrap();+ assert!(screen(&b, "V", &s["loads"]["V"], &s).unwrap_err().contains("uniform copper"));+ } }
crates/aiflow-analyze/src/lib.rs+73−12@@ -9,6 +9,8 @@ pub mod branches; pub mod current_acks; pub mod fields_report; pub mod package_cooling;+pub mod pour_cover;+pub mod solved_ampacity; use std::collections::BTreeMap; use serde_json::{json, Value};@@ -82,6 +84,10 @@ fn layers_of(zs: &[Value]) -> Vec<String> { /// `pad_layers`: net -> the outer layers its SMD pads sit on (through-hole pads count for neither). /// When every pad of a loaded net is on one outer layer and that layer is poured, the pour on that /// layer carries the current between them; vias on the net then only spread heat.+/// A pour pass here is provisional (`pourCarries`): `analyze_current_on_board` keeps it only when the+/// pour covers the narrow copper or joins every pad (pour_cover, issue #107). A failure names its+/// kind (`failure`: no-copper, bottleneck, via-stitching), so a same-input Fields solve can judge the+/// width approximation (solved_ampacity). pub fn analyze_current(plan: &Value, spec: &Value, zone_state: Option<&Value>, pad_layers: Option<&BTreeMap<String, Vec<String>>>) -> (bool, Vec<Value>) { let t_um = spec.get("copperUm").and_then(|v| v.as_f64()).unwrap_or(35.0); let max_rise_default = spec.get("maxRiseC").and_then(|v| v.as_f64()).unwrap_or(10.0);@@ -93,7 +99,7 @@ pub fn analyze_current(plan: &Value, spec: &Value, zone_state: Option<&Value>, p let amps = ld.get("amps").and_then(|v| v.as_f64()).unwrap_or(0.0); let max_rise = ld.get("maxRiseC").and_then(|v| v.as_f64()).unwrap_or(max_rise_default); let Some(w) = widths.get(&net) else {- results.push(json!({"net": net, "amps": amps, "status": "no-copper", "hint": format!("net {net} carries {amps:.1} A but the routing plan has no copper for it")}));+ results.push(json!({"net": net, "amps": amps, "status": "no-copper", "failure": "no-copper", "hint": format!("net {net} carries {amps:.1} A but the routing plan has no copper for it")})); ok = false; continue; };@@ -116,17 +122,21 @@ pub fn analyze_current(plan: &Value, spec: &Value, zone_state: Option<&Value>, p let one_layer = pad_layers.and_then(|m| m.get(&net)).filter(|l| l.len() == 1).map(|l| l[0].clone()).filter(|l| pour_layers.contains(l)); if let (true, Some(l)) = (pour_layers.len() >= 2 && via_capacity < amps, one_layer.clone()) { r["status"] = json!("pass");+ r["pourCarries"] = json!(true); r["how"] = json!(format!("every pad of {net} is on {l} and the {l} pour ({pour_area:.0} mm2 on {}) spans them; the other layer and its {} vias spread heat", pour_layers.join("+"), w.vias)); } else if pour_layers.len() >= 2 && via_capacity < amps { r["status"] = json!("fail");+ r["failure"] = json!("via-stitching"); r["hint"] = json!(format!("{net} is carried by pours on {} but only {} vias ({via_capacity:.0} A) join the layers for {amps:.1} A: add stitching vias (0.8/0.4 mm, about 1.5 A each)", pour_layers.join("+"), w.vias)); ok = false; } else { r["status"] = json!("pass");+ r["pourCarries"] = json!(true); r["how"] = json!(format!("pour on {} ({pour_area:.0} mm2 filled) carries the load; tracks are {widest:.2} mm at most", pour_layers.join("+"))); } } else { r["status"] = json!("fail");+ r["failure"] = json!("bottleneck"); let need = [0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0].iter().find(|w| rise_for(**w, t_um, amps, true) <= max_rise).map(|w| format!("{w}")).unwrap_or_else(|| ">6".into()); r["hint"] = json!(format!("{net} carries {amps:.1} A on a {narrowest:.2} mm bottleneck ({rise_narrow:.0} C rise, limit {max_rise:.0} C) and has no pour: pour it on the outer layers or widen to {need} mm")); ok = false;@@ -143,7 +153,19 @@ pub fn analyze_current_on_board(plan: &Value, spec: &Value, zone_state: Option<& for result in &mut results { let net = result["net"].as_str().unwrap_or("").to_string(); let load = &spec["loads"][&net];- if load["currentModel"] != "resistive-branches" { continue; }+ if load["currentModel"] != "resistive-branches" {+ // issue #107: a pour passes only where it carries the current+ if result["pourCarries"] == true && result["status"] == "pass" {+ let t_um = spec["copperUm"].as_f64().unwrap_or(35.0);+ let amps = result["amps"].as_f64().unwrap_or(0.0);+ let max_rise = result["maxRiseC"].as_f64().unwrap_or(10.0);+ match pour_cover::check(board, &net, spec, amps, max_rise, t_um) {+ Ok(why) => { result["how"] = json!(format!("{}; {why}", result["how"].as_str().unwrap_or(""))); }+ Err(why) => { result["status"] = json!("fail"); result["failure"] = json!("pour-coverage"); result["hint"] = json!(why); if let Some(o) = result.as_object_mut() { o.remove("how"); } }+ }+ }+ continue;+ } let planned = plan["nets"].as_array().into_iter().flatten().any(|e| e["net"] == net && e["source"] != "board"); let branch = if planned { Err("land and adopt planned copper before branch analysis".into()) } else { branches::screen(board, &net, load, spec) }; match branch {@@ -254,6 +276,24 @@ mod regression_tests { let (pass,r)=analyze_thermal_on_board(&plan,&spec,Some(&zs),&b); assert_eq!(r[0]["viasOnNet"],2); assert!(pass,"{r:?}"); }+ #[test] fn on_board_a_token_pour_no_longer_carries_the_load() {+ // issue #107: 1.5 A on a 0.30 mm track between two pads; a pour on the net passes only where it covers it+ let plan=json!({"nets":[{"net":"V","width":0.3,"source":"board"}]});+ let spec=json!({"loads":{"V":{"amps":1.5,"maxRiseC":10.0}},"copperUm":35.0});+ let zs=json!({"zones":[{"type":"copper","net":"V","layers":["F.Cu"],"fills":[{"areaMm2":2.0}]}]});+ let board=|x0:f64,x1:f64,y0:f64,y1:f64| aiflow_board::Board::from_text("t.kicad_pcb",format!(r#"(kicad_pcb (version 20260101) (layers (0 "F.Cu" signal) (2 "B.Cu" signal)) (net 0 "") (net 1 "V")+ (gr_rect (start 0 0) (end 30 20) (layer "Edge.Cuts") (width 0.05))+ (footprint "A" (layer "F.Cu") (at 5 10) (property "Reference" "J1") (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "V")))+ (footprint "A" (layer "F.Cu") (at 25 10) (property "Reference" "U1") (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "V")))+ (segment (start 5 10) (end 25 10) (width 0.3) (layer "F.Cu") (net 1))+ (zone (net 1) (net_name "V") (layer "F.Cu") (polygon (pts (xy {x0} {y0}) (xy {x1} {y0}) (xy {x1} {y1}) (xy {x0} {y1}))) (filled_polygon (layer "F.Cu") (pts (xy {x0} {y0}) (xy {x1} {y0}) (xy {x1} {y1}) (xy {x0} {y1})))))"#)).unwrap();+ // the board-less screen still takes any pour; the board screen does not+ assert!(analyze_current(&plan,&spec,Some(&zs),None).0);+ let (pass,r)=analyze_current_on_board(&plan,&spec,Some(&zs),None,&board(10.0,11.0,9.0,11.0));+ assert!(!pass); assert_eq!(r[0]["failure"],"pour-coverage"); assert!(r[0]["hint"].as_str().unwrap().contains("uncovered"));+ let (pass,r)=analyze_current_on_board(&plan,&spec,Some(&zs),None,&board(4.0,26.0,8.0,12.0));+ assert!(pass,"{r:?}"); assert!(r[0]["how"].as_str().unwrap().contains("covers every piece"));+ } #[test] fn wide_series_segment_cannot_hide_a_narrow_bottleneck() { let plan=json!({"nets":[{"net":"POWER","width":0.25},{"net":"POWER","width":40.0}]}); let spec=json!({"loads":{"POWER":{"amps":20.0,"maxRiseC":10.0}},"copperUm":35.0});@@ -396,7 +436,13 @@ pub fn field_screen_with_acks(fields: &Value, spec: &Value, board_bytes: &[u8], if !same_boundary { return Err(format!("Fields {name} terminal assignments differ from the branch model, including physical pad selectors")); }- if fields["board"]["layers"] != json!(["F.Cu","B.Cu"]) { return Err("branch screening requires a physical two-layer Fields solve".into()); }+ // the solve must model this board's own copper stack (adom-fields 0.10 reports it as+ // copperLayers beside a fixed six-slot `layers`; older reports put it in `layers`)+ let stack = aiflow_board::sx::parse(&String::from_utf8_lossy(board_bytes))+ .and_then(|root| branches::copper_stack_of(&root))+ .map_err(|e| format!("branch screening cannot read the solved board's copper stack: {e}"))?;+ let solved = fields["board"].get("copperLayers").unwrap_or(&fields["board"]["layers"]);+ if *solved != json!(stack) { return Err(format!("branch screening requires a Fields solve of this board's physical copper stack {stack:?}; the solve modelled {solved}")); } for (report_key, expected) in [("thicknessMm",spec["branchCurrentModel"]["boardThicknessMm"].as_f64()),("copperUm",spec["copperUm"].as_f64())] { if !expected.is_some_and(|e| fields["board"][report_key].as_f64().is_some_and(|a| (a-e).abs()<1e-9)) { return Err(format!("Fields {report_key} differs from the branch model"));@@ -529,16 +575,19 @@ mod field_screen_tests { assert!(field_screen(&f,&spec,b"board",b"spec","current").is_err()); } }+ const B2: &[u8] = br#"(kicad_pcb (version 20260101) (layers (0 "F.Cu" signal) (2 "B.Cu" signal)))"#;+ const B4: &[u8] = br#"(kicad_pcb (version 20260101) (layers (0 "F.Cu" signal) (4 "In1.Cu" power) (6 "In2.Cu" power) (2 "B.Cu" signal)))"#;+ fn branch_report() -> Value { let mut f=report(); f["inputIdentity"]["board"]=json!(format!("{:x}",Sha256::digest(B2))); f } #[test] fn explicit_branch_screen_requires_matching_pin_boundary_conditions() { let pins=json!([{"reference":"J1","pad":"1","amps":2},{"reference":"U1","pad":"1","amps":-2}]); let spec=json!({"copperUm":35,"branchCurrentModel":{"boardThicknessMm":1.6},"loads":{"VCC":{"amps":2,"currentModel":"resistive-branches","terminals":pins}}});- let mut f=report();assert!(field_screen(&f,&spec,b"board",b"spec","current").is_err());+ let mut f=branch_report();assert!(field_screen(&f,&spec,B2,b"spec","current").is_err()); f["terminalAssignments"]=json!({"VCC":{"mode":"explicit","pins":pins}}); f["board"]=json!({"layers":["F.Cu","B.Cu"],"copperUm":35,"thicknessMm":1.6});- assert!(field_screen(&f,&spec,b"board",b"spec","current").is_ok());+ assert!(field_screen(&f,&spec,B2,b"spec","current").is_ok()); f["terminalAssignments"]["VCC"]["pins"][1]["pad"]=json!("2");- assert!(field_screen(&f,&spec,b"board",b"spec","current").is_err());+ assert!(field_screen(&f,&spec,B2,b"spec","current").is_err()); } #[test] fn repeated_pin_boundaries_require_each_physical_selector_exactly_once() {@@ -546,20 +595,32 @@ mod field_screen_tests { {"reference":"D1","pad":"2","amps":-1,"at":[1.0,2.0]}, {"reference":"D1","pad":"2","amps":-1,"at":[1.0,4.0]}]); let spec=json!({"copperUm":35,"branchCurrentModel":{"boardThicknessMm":1.6},"loads":{"VCC":{"amps":2,"currentModel":"resistive-branches","terminals":pins}}});- let mut f=report();f["terminalAssignments"]=json!({"VCC":{"mode":"explicit","pins":pins}});+ let mut f=branch_report();f["terminalAssignments"]=json!({"VCC":{"mode":"explicit","pins":pins}}); f["board"]=json!({"layers":["F.Cu","B.Cu"],"copperUm":35,"thicknessMm":1.6});- field_screen(&f,&spec,b"board",b"spec","current").unwrap();+ field_screen(&f,&spec,B2,b"spec","current").unwrap(); f["terminalAssignments"]["VCC"]["pins"].as_array_mut().unwrap().reverse();- field_screen(&f,&spec,b"board",b"spec","current").unwrap();+ field_screen(&f,&spec,B2,b"spec","current").unwrap(); f["terminalAssignments"]["VCC"]["pins"][1]["at"]=json!([1,2]);- field_screen(&f,&spec,b"board",b"spec","current").unwrap();+ field_screen(&f,&spec,B2,b"spec","current").unwrap(); for at in [Value::Null,json!([1.0,4.0]),json!([1.0,2.1])] { let mut bad=f.clone();bad["terminalAssignments"]["VCC"]["pins"][1]["at"]=at;- assert!(field_screen(&bad,&spec,b"board",b"spec","current").is_err());+ assert!(field_screen(&bad,&spec,B2,b"spec","current").is_err()); } let mut invalid=spec.clone();invalid["loads"]["VCC"]["terminals"][1]["at"]=json!(["x",2]); f["terminalAssignments"]["VCC"]["pins"]=invalid["loads"]["VCC"]["terminals"].clone();- assert!(field_screen(&f,&invalid,b"board",b"spec","current").is_err());+ assert!(field_screen(&f,&invalid,B2,b"spec","current").is_err());+ }+ #[test]+ fn branch_screen_accepts_a_solve_of_the_boards_own_multilayer_stack() {+ let pins=json!([{"reference":"J1","pad":"1","amps":2},{"reference":"U1","pad":"1","amps":-2}]);+ let spec=json!({"copperUm":35,"branchCurrentModel":{"boardThicknessMm":1.6},"loads":{"VCC":{"amps":2,"currentModel":"resistive-branches","terminals":pins}}});+ let mut f=report();f["inputIdentity"]["board"]=json!(format!("{:x}",Sha256::digest(B4)));+ f["terminalAssignments"]=json!({"VCC":{"mode":"explicit","pins":pins}});+ // adom-fields 0.10: six fixed slots in `layers`, the real stack in `copperLayers`+ f["board"]=json!({"layers":["F.Cu","In1.Cu","In2.Cu","In3.Cu","In4.Cu","B.Cu"],"copperLayers":["F.Cu","In1.Cu","In2.Cu","B.Cu"],"copperUm":35,"thicknessMm":1.6});+ field_screen(&f,&spec,B4,b"spec","current").unwrap();+ f["board"]["copperLayers"]=json!(["F.Cu","B.Cu"]);+ assert!(field_screen(&f,&spec,B4,b"spec","current").unwrap_err().contains("copper stack")); } #[test] fn fresh_solve_requires_identity_coverage_and_no_relevant_findings() {
crates/aiflow-analyze/src/pour_cover.rsadded+240@@ -0,0 +1,240 @@+//! Issue #107: a pour passes the whole-net current screen only where it carries the current.+//!+//! The whole-net screen judges the narrowest track on a loaded net at the full load. A pour on the+//! net used to pass that screen by existing anywhere on the net, even as a small patch beside none of+//! the narrow copper. Now the pour counts only when (a) every narrow piece the screen judged (each+//! track too narrow for the full load) lies inside a same-net fill on its own layer, or (b) one pour+//! (fill islands joined by the net's own vias and through pads) reaches every terminal of the net, so+//! the narrow tracks run beside a pour that joins them. The terminals are every pad of the net, less+//! the pads the load declares at zero current (`loads.<net>.terminals`, as the branch model takes+//! them) and the pads that cannot pass load current by the spec's own declaration: a part whose other+//! pads are all on this net, on no net, or on nets the spec gives no load and that are not ground (a+//! test point, a feedback divider, a bootstrap or feed-forward capacitor). Anything else is not "the+//! pour carries the load"; a same-input Fields solve can still judge the net by its solved neck rise+//! (solved_ampacity).+use aiflow_board::{geom, sx::Node, Board, Pt};++/// A point within this distance of one of the net's own pads on that layer counts as covered: the+/// thermal-relief gap a pour leaves around a pad it connects to.+pub const RELIEF_GAP_MM: f64 = 0.5;+const STEP_MM: f64 = 0.1;+const TOUCH_MM: f64 = 0.01;++pub struct Piece { pub layer: String, pub a: Pt, pub b: Pt, pub width: f64 }++struct Fill { layer: String, poly: Vec<Pt>, bbox: (f64, f64, f64, f64), outline: bool }++fn pts_of(n: &Node) -> Vec<Pt> {+ n.find("pts").map(|p| p.find_all("xy").filter_map(|xy| Some((xy.number(1)?, xy.number(2)?))).collect()).unwrap_or_default()+}++/// Every copper segment of `net` on any copper layer, arcs as short chords.+pub fn net_pieces(board: &Board, net: &str) -> Vec<Piece> {+ let p2 = |n: Option<&Node>| -> Option<Pt> { let n = n?; Some((n.number(1)?, n.number(2)?)) };+ let mut out = Vec::new();+ for node in board.root.nodes().filter(|n| n.tag == "segment" || n.tag == "arc") {+ if board.item_net(node) != net { continue; }+ let Some(layer) = node.find("layer").and_then(|l| l.value()).filter(|l| l.ends_with(".Cu")) else { continue };+ let width = node.find("width").and_then(|w| w.number(1)).unwrap_or(0.0);+ let (Some(s), Some(e)) = (p2(node.find("start")), p2(node.find("end"))) else { continue };+ let pts = match (node.tag.as_str(), p2(node.find("mid"))) { ("arc", Some(m)) => geom::arc_points(s, m, e, 0.1), _ => vec![s, e] };+ for w in pts.windows(2) { out.push(Piece { layer: layer.to_string(), a: w[0], b: w[1], width }); }+ }+ out+}++fn net_fills(board: &Board, net: &str) -> Vec<Fill> {+ let copper: Vec<String> = board.root.find("layers").map(|l| l.nodes().filter_map(|n| n.atom(1)).filter(|l| l.ends_with(".Cu")).map(str::to_string).collect()).unwrap_or_default();+ let mut out = Vec::new();+ for z in board.root.find_all("zone") {+ if z.find("keepout").is_some() || board.item_net(z) != net { continue; }+ let mut any = false;+ for f in z.find_all("filled_polygon") {+ let Some(layer) = f.find("layer").and_then(|l| l.value()) else { continue };+ let poly = pts_of(f);+ if poly.len() >= 3 { out.push(Fill { layer: layer.to_string(), bbox: geom::bbox(&poly), poly, outline: false }); any = true; }+ }+ if any { continue; }+ // a zone the saved file holds unfilled: its outline is the most it could cover+ let outline = z.find("polygon").map(pts_of).unwrap_or_default();+ if outline.len() < 3 { continue; }+ let mut names: Vec<String> = z.find("layer").and_then(|l| l.value()).map(|l| vec![l.to_string()]).unwrap_or_default();+ if let Some(ls) = z.find("layers") { names.extend((1..).map_while(|i| ls.atom(i)).map(str::to_string)); }+ let mut layers: Vec<String> = Vec::new();+ for n in names {+ let expand: Vec<String> = match n.as_str() { "*.Cu" => copper.clone(), "F&B.Cu" => vec!["F.Cu".into(), "B.Cu".into()], _ => vec![n] };+ for l in expand { if !layers.contains(&l) { layers.push(l); } }+ }+ for layer in layers { out.push(Fill { layer, bbox: geom::bbox(&outline), poly: outline.clone(), outline: true }); }+ }+ out+}++fn in_box(p: Pt, b: (f64, f64, f64, f64), m: f64) -> bool { p.0 >= b.0 - m && p.0 <= b.2 + m && p.1 >= b.1 - m && p.1 <= b.3 + m }++fn pad_on(p: &aiflow_board::Pad, layer: &str) -> bool {+ p.thru || (layer == "F.Cu" && p.on_f) || (layer == "B.Cu" && p.on_b)+}++fn rd(v: f64) -> f64 { (v * 100.0).round() / 100.0 }++/// A ground or return net by name: a part bridging to it can carry the load even when the spec+/// declares no load on it.+pub fn ground_like(net: &str) -> bool {+ let n = net.trim_start_matches('/').to_ascii_uppercase();+ n.contains("GND") || n.starts_with("VSS") || n.starts_with("VEE") || n == "0" || n.contains("EARTH") || n.contains("CHASSIS") || n.contains("RTN") || n.contains("RETURN")+}++/// Whether the net's pour carries its load. Ok(how) when it does; Err(why not and what to do).+pub fn check(board: &Board, net: &str, spec: &serde_json::Value, amps: f64, max_rise: f64, t_um: f64) -> Result<String, String> {+ let load = &spec["loads"][net];+ let fills = net_fills(board, net);+ let vias: Vec<_> = board.vias().into_iter().filter(|v| v.net == net).collect();+ if fills.is_empty() {+ return Err(format!("the saved board has no {net} zone to check the pour against; save the board after `measure` and analyze again"));+ }+ let outline_note = if fills.iter().any(|f| f.outline) { " (an unfilled zone counted by its outline)" } else { "" };+ let pads: Vec<_> = board.pads.iter().filter(|p| p.net == net).collect();+ let all = net_pieces(board, net);+ let wide = all.iter().any(|p| p.width > 0.2);+ let judged: Vec<&Piece> = all.iter().filter(|p| (!wide || p.width > 0.2) && crate::rise_for(p.width, t_um, amps, true) > max_rise).collect();+ let covered = |p: Pt, layer: &str| -> bool {+ fills.iter().any(|f| f.layer == layer && in_box(p, f.bbox, TOUCH_MM) && geom::point_poly_distance(p.0, p.1, &f.poly) <= TOUCH_MM)+ || pads.iter().any(|q| pad_on(q, layer) && q.polys.iter().any(|poly| geom::point_poly_distance(p.0, p.1, poly) <= RELIEF_GAP_MM))+ || vias.iter().any(|v| (p.0 - v.x).hypot(p.1 - v.y) <= v.size / 2. + RELIEF_GAP_MM)+ };+ let first_gap = judged.iter().find_map(|piece| {+ let len = (piece.b.0 - piece.a.0).hypot(piece.b.1 - piece.a.1);+ let n = (len / STEP_MM).ceil().max(1.0) as usize;+ (0..=n).map(|i| { let t = i as f64 / n as f64; (piece.a.0 + t * (piece.b.0 - piece.a.0), piece.a.1 + t * (piece.b.1 - piece.a.1)) })+ .find(|p| !covered(*p, &piece.layer)).map(|p| (piece, p))+ });+ if first_gap.is_none() && !all.is_empty() {+ return Ok(if judged.is_empty() {+ format!("no {net} track on the saved board is too narrow for {amps:.1} A; the pour joins the rest{outline_note}")+ } else {+ format!("the {net} pour covers every piece too narrow for {amps:.1} A on its own layer ({} pieces){outline_note}", judged.len())+ });+ }+ // (b) one pour (islands joined by the net's vias and through pads) that reaches every terminal+ let touches = |f: &Fill, q: &aiflow_board::Pad| pad_on(q, &f.layer) && q.polys.iter().any(|poly| {+ (0..poly.len()).any(|k| geom::seg_poly_distance(poly[k], poly[(k + 1) % poly.len()], &f.poly) <= TOUCH_MM)+ });+ let mut group: Vec<usize> = (0..fills.len()).collect();+ fn find(g: &mut [usize], mut i: usize) -> usize { while g[i] != i { g[i] = g[g[i]]; i = g[i]; } i }+ let joins: Vec<(Pt, f64)> = vias.iter().map(|v| ((v.x, v.y), v.size / 2.))+ .chain(pads.iter().filter(|q| q.thru).map(|q| ((q.x, q.y), q.half_short))).collect();+ for (at, r) in &joins {+ let hit: Vec<usize> = fills.iter().enumerate().filter(|(_, f)| in_box(*at, f.bbox, r + TOUCH_MM) && geom::point_poly_distance(at.0, at.1, &f.poly) <= r + TOUCH_MM).map(|(i, _)| i).collect();+ for w in hit.windows(2) { let (a, b) = (find(&mut group, w[0]), find(&mut group, w[1])); group[a.max(b)] = a.min(b); }+ }+ let zero = |q: &aiflow_board::Pad| load["terminals"].as_array().into_iter().flatten().any(|t| {+ t["reference"].as_str() == Some(q.reference.as_str()) && t["pad"].as_str() == Some(q.name.as_str()) && t["amps"].as_f64() == Some(0.0)+ && (t["at"].is_null() || (t["at"][0].as_f64() == Some(q.x) && t["at"][1].as_f64() == Some(q.y)))+ });+ let loaded = |n: &str| spec["loads"][n]["amps"].as_f64().is_some_and(|a| a > 0.0);+ let side_part = |q: &aiflow_board::Pad| board.pads.iter().filter(|o| o.reference == q.reference && o.net != net)+ .all(|o| o.net.is_empty() || (!loaded(&o.net) && !ground_like(&o.net)));+ let terminals: Vec<_> = pads.iter().filter(|q| !zero(q) && !side_part(q)).collect();+ let roots: std::collections::BTreeSet<usize> = (0..fills.len()).map(|i| find(&mut group, i)).collect();+ let mut best: Option<(String, Vec<String>)> = None;+ for root in roots {+ let members: Vec<&Fill> = (0..fills.len()).filter(|i| find(&mut group, *i) == root).map(|i| &fills[i]).collect();+ let missed: Vec<String> = terminals.iter().filter(|q| !members.iter().any(|f| touches(f, q))).map(|q| format!("{}.{}", q.reference, q.name)).collect();+ let mut layers: Vec<&str> = members.iter().map(|f| f.layer.as_str()).collect();+ layers.sort();+ layers.dedup();+ if terminals.len() >= 2 && missed.is_empty() {+ return Ok(format!("one {} pour reaches all {} {net} terminals, so the narrow tracks run beside it{outline_note}", layers.join("+"), terminals.len()));+ }+ if best.as_ref().is_none_or(|b| missed.len() < b.1.len()) { best = Some((layers.join("+"), missed)); }+ }+ let unreached = best.map(|(l, m)| format!("no one pour reaches every terminal (the best, on {l}, misses {}{})", m.iter().take(4).cloned().collect::<Vec<_>>().join(", "), if m.len() > 4 { ", ..." } else { "" })).unwrap_or_default();+ let gap = match first_gap {+ Some((piece, gap)) => format!("it leaves the {:.2} mm piece on {} from ({:.2}, {:.2}) to ({:.2}, {:.2}) uncovered at ({:.2}, {:.2}) ({:.0} C at {amps:.1} A, limit {max_rise:.0} C)",+ piece.width, piece.layer, rd(piece.a.0), rd(piece.a.1), rd(piece.b.0), rd(piece.b.1), rd(gap.0), rd(gap.1), crate::rise_for(piece.width, t_um, amps, true)),+ None => format!("the saved board has no {net} tracks to check it against"),+ };+ Err(format!("the {net} pour does not carry the load: {gap}, and {unreached}. Extend the pour over the narrow copper, widen it, declare the pads that carry no load (`loads.{net}.terminals`, zero amps), or solve it with Adom Fields (analyze current then judges the net by the solved rise at its narrowest neck)"))+}++#[cfg(test)]+mod tests {+ use super::*;+ // a buck-style net: two pads joined by a 0.3 mm track, 1.5 A, poured on F.Cu; J1 and U1 also+ // have a ground pin, so either can be where the load enters or leaves+ fn board(zone: &str) -> Board {+ let text = format!(r#"(kicad_pcb (version 20260101) (layers (0 "F.Cu" signal) (2 "B.Cu" signal)) (net 0 "") (net 1 "V") (net 2 "GND")+ (gr_rect (start 0 0) (end 30 20) (layer "Edge.Cuts") (width 0.05))+ (footprint "A" (layer "F.Cu") (at 5 10) (property "Reference" "J1") (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "V")) (pad "2" smd rect (at 0 -6) (size 1 1) (layers "F.Cu") (net 2 "GND")))+ (footprint "A" (layer "F.Cu") (at 25 10) (property "Reference" "U1") (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "V")) (pad "2" smd rect (at 0 -6) (size 1 1) (layers "F.Cu") (net 2 "GND")))+ (segment (start 5 10) (end 25 10) (width 0.3) (layer "F.Cu") (net 1))+ {zone})"#);+ Board::from_text("t.kicad_pcb", text).unwrap()+ }+ fn zone(layer: &str, x0: f64, y0: f64, x1: f64, y1: f64) -> String {+ format!(r#"(zone (net 1) (net_name "V") (layer "{layer}") (polygon (pts (xy {x0} {y0}) (xy {x1} {y0}) (xy {x1} {y1}) (xy {x0} {y1}))) (filled_polygon (layer "{layer}") (pts (xy {x0} {y0}) (xy {x1} {y0}) (xy {x1} {y1}) (xy {x0} {y1}))))"#)+ }+ #[test]+ fn a_pour_over_the_narrow_track_carries_it() {+ let b = board(&zone("F.Cu", 6.0, 8.0, 24.0, 12.0));+ let how = check(&b, "V", &serde_json::Value::Null, 1.5, 10.0, 35.0).unwrap();+ assert!(how.contains("covers every piece"), "{how}");+ }+ #[test]+ fn a_token_patch_does_not_carry_the_load() {+ let b = board(&zone("F.Cu", 10.0, 9.0, 11.0, 11.0));+ let e = check(&b, "V", &serde_json::Value::Null, 1.5, 10.0, 35.0).unwrap_err();+ assert!(e.contains("does not carry the load") && e.contains("0.30 mm piece"), "{e}");+ // the same patch on the other layer covers nothing on F.Cu either+ let b = board(&zone("B.Cu", 4.0, 8.0, 26.0, 12.0));+ assert!(check(&b, "V", &serde_json::Value::Null, 1.5, 10.0, 35.0).is_err());+ }+ #[test]+ fn a_pour_joining_every_pad_carries_the_load_beside_a_narrow_track() {+ // the track runs outside the pour, but the pour reaches both pads+ let text = board("").text.replace("(segment (start 5 10) (end 25 10)", "(segment (start 5 10) (end 5 15) (width 0.3) (layer \"F.Cu\") (net 1)) (segment (start 5 15) (end 25 15) (width 0.3) (layer \"F.Cu\") (net 1)) (segment (start 25 15) (end 25 10)");+ let mut t = text; t.pop(); t.push_str(&zone("F.Cu", 4.6, 9.0, 25.4, 11.0)); t.push(')');+ let b = Board::from_text("t.kicad_pcb", t).unwrap();+ let how = check(&b, "V", &serde_json::Value::Null, 1.5, 10.0, 35.0).unwrap();+ assert!(how.contains("reaches all 2"), "{how}");+ }+ #[test]+ fn wide_enough_tracks_need_no_cover() {+ let b = board(&zone("F.Cu", 10.0, 9.0, 11.0, 11.0));+ assert!(check(&b, "V", &serde_json::Value::Null, 0.3, 10.0, 35.0).unwrap().contains("too narrow"));+ }+ #[test]+ fn a_sense_tap_declared_at_zero_current_does_not_need_the_pour() {+ // pour joins J1 and U1; a 0.3 mm tap runs off to R1 (a feedback divider) outside the pour+ let mut t = board(&zone("F.Cu", 4.6, 9.0, 25.4, 11.0)).text;+ // R1 bridges V to GND (it could be the load), so it is a terminal until the spec says otherwise+ t = t.replacen("(segment", r#"(footprint "A" (layer "F.Cu") (at 15 18) (property "Reference" "R1") (pad "1" smd rect (at 0 0) (size 0.6 0.6) (layers "F.Cu") (net 1 "V")) (pad "2" smd rect (at 1 0) (size 0.6 0.6) (layers "F.Cu") (net 2 "GND"))) (segment (start 15 10) (end 15 18) (width 0.3) (layer "F.Cu") (net 1)) (segment"#, 1)+ .replacen(r#"(net 2 "GND")"#, r#"(net 2 "GND") (net 3 "FB")"#, 1);+ let b = Board::from_text("t.kicad_pcb", t.clone()).unwrap();+ let e = check(&b, "V", &serde_json::Value::Null, 1.5, 10.0, 35.0).unwrap_err();+ assert!(e.contains("misses R1.1") && e.contains("terminals"), "{e}");+ let spec = serde_json::json!({"loads": {"V": {"amps": 1.5, "terminals": [{"reference": "J1", "pad": "1", "amps": 1.5}, {"reference": "U1", "pad": "1", "amps": -1.5}, {"reference": "R1", "pad": "1", "amps": 0}]}}});+ assert!(check(&b, "V", &spec, 1.5, 10.0, 35.0).unwrap().contains("reaches all 2"));+ // a divider to FB, a net the spec gives no load, cannot pass the load: no declaration needed+ let b = Board::from_text("t.kicad_pcb", t.replace(r#"(at 1 0) (size 0.6 0.6) (layers "F.Cu") (net 2 "GND")"#, r#"(at 1 0) (size 0.6 0.6) (layers "F.Cu") (net 3 "FB")"#)).unwrap();+ assert!(check(&b, "V", &serde_json::json!({"loads": {"V": {"amps": 1.5}}}), 1.5, 10.0, 35.0).unwrap().contains("reaches all 2"));+ // unless the spec loads FB+ assert!(check(&b, "V", &serde_json::json!({"loads": {"V": {"amps": 1.5}, "FB": {"amps": 1.0}}}), 1.5, 10.0, 35.0).is_err());+ }+ #[test]+ fn islands_on_both_layers_joined_by_a_via_are_one_pour() {+ // J1 poured on F.Cu, U1 on B.Cu (a through pad), the islands joined by a via; the track runs outside both+ let text = board("").text+ .replace(r#"(at 25 10) (property "Reference" "U1") (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu")"#, r#"(at 25 10) (property "Reference" "U1") (pad "1" smd rect (at 0 0) (size 1 1) (layers "B.Cu")"#)+ .replace("(segment (start 5 10) (end 25 10) (width 0.3) (layer \"F.Cu\") (net 1))", "(segment (start 5 10) (end 5 16) (width 0.3) (layer \"F.Cu\") (net 1)) (segment (start 5 16) (end 25 16) (width 0.3) (layer \"F.Cu\") (net 1)) (via (at 25 16) (size 0.6) (drill 0.3) (layers \"F.Cu\" \"B.Cu\") (net 1)) (segment (start 25 16) (end 25 10) (width 0.3) (layer \"B.Cu\") (net 1))");+ let mut t = text; t.pop();+ t.push_str(&zone("F.Cu", 4.0, 9.0, 16.0, 11.0));+ t.push_str(&zone("B.Cu", 15.0, 9.0, 26.0, 11.0));+ let lone = format!("{t})");+ assert!(check(&Board::from_text("t.kicad_pcb", lone).unwrap(), "V", &serde_json::Value::Null, 1.5, 10.0, 35.0).is_err());+ t.push_str("(via (at 15.5 10) (size 0.6) (drill 0.3) (layers \"F.Cu\" \"B.Cu\") (net 1)))");+ let how = check(&Board::from_text("t.kicad_pcb", t).unwrap(), "V", &serde_json::Value::Null, 1.5, 10.0, 35.0).unwrap();+ assert!(how.contains("B.Cu+F.Cu pour reaches all 2"), "{how}");+ }+}
crates/aiflow-analyze/src/solved_ampacity.rsadded+109@@ -0,0 +1,109 @@+//! Issue #107: with a same-input Fields solve, the solved rise at each net's narrowest neck judges the+//! whole-net IPC-2221 screen.+//!+//! The whole-net screen takes the narrowest track over 0.2 mm anywhere on a loaded net and judges it at+//! the full load. Side strips, short branches and necks into small pins never carry the full load, so+//! a careful design can fail that approximation. adom-fields (0.9+) reports `review.checks.ampacity`:+//! per loaded net, the neck where the solved current density peaks, its width and layer, and the+//! solved temperature rise there with every heat source on the board. When that solve has already+//! passed the same-input field screen (exact board and spec hashes, converged at the full declared+//! current, density and barrel limits), its solved rise replaces the approximation for the nets whose+//! only failure was the IPC width (or a pour that does not cover the narrow copper). The heuristic+//! stays the verdict without a solve.+use serde_json::{json, Value};++/// Fields rounds the solved rise to 0.1 C; reserve half a display step.+const ROUNDING_C: f64 = 0.05;++/// Rewrite the failing whole-net width results that the solve can judge. Call only after the field+/// screen of the same report passed. Returns one line per net it judged.+pub fn accept_solve(results: &mut [Value], spec: &Value, fields: &Value) -> Vec<String> {+ let rows = fields["review"]["checks"]["ampacity"].as_array().cloned().unwrap_or_default();+ let max_rise_default = spec["maxRiseC"].as_f64().unwrap_or(10.0);+ let mut notes = Vec::new();+ for r in results.iter_mut() {+ if r["status"] != "fail" || !matches!(r["failure"].as_str(), Some("bottleneck" | "pour-coverage")) { continue; }+ let net = r["net"].as_str().unwrap_or("").to_string();+ let load = &spec["loads"][&net];+ let amps = load["amps"].as_f64().unwrap_or(0.0);+ let limit = load["maxRiseC"].as_f64().unwrap_or(max_rise_default);+ let Some(row) = rows.iter().find(|x| x["net"].as_str() == Some(net.as_str())) else {+ r["hint"] = json!(format!("{}; the Fields report has no review.checks.ampacity row for {net} (adom-fields 0.9 or later reports the solved rise at its narrowest neck)", r["hint"].as_str().unwrap_or("")));+ continue;+ };+ let row_amps = row["amps"].as_f64().unwrap_or(f64::NAN);+ let rise = row["solvedRiseC"].as_f64().filter(|v| v.is_finite() && *v >= 0.0);+ if !row_amps.is_finite() || (row_amps - amps).abs() > 1e-8 * amps.max(1.0) || rise.is_none() {+ r["hint"] = json!(format!("{}; the Fields ampacity row for {net} is not a finite solved rise at the full {amps} A", r["hint"].as_str().unwrap_or("")));+ continue;+ }+ let rise = rise.unwrap();+ let neck = format!("{:.2} mm on {} at ({:.2}, {:.2})", row["neckMm"].as_f64().unwrap_or(f64::NAN), row["layer"].as_str().unwrap_or("?"), row["at"][0].as_f64().unwrap_or(f64::NAN), row["at"][1].as_f64().unwrap_or(f64::NAN));+ let fields_judgement = json!({"solvedRiseC": rise, "limitC": limit, "neck": row.clone()});+ if rise + ROUNDING_C <= limit {+ let ipc = r.clone();+ r["status"] = json!("pass");+ r["judgedBy"] = json!("fields-solve");+ r["fieldsAmpacity"] = fields_judgement;+ r["ipcWholeNetScreen"] = ipc;+ r["how"] = json!(format!("judged by the Fields solve: {rise:.1} C solved rise at the narrowest neck (about {neck}) at {amps:.1} A, within {limit:.0} C; the whole-net IPC-2221 approximation alone failed it"));+ if let Some(o) = r.as_object_mut() { o.remove("hint"); }+ notes.push(format!("{net}: judged by the Fields solve ({rise:.1} C at its narrowest neck, limit {limit:.0} C)"));+ } else {+ r["fieldsAmpacity"] = fields_judgement;+ r["hint"] = json!(format!("{}; the same-input Fields solve agrees: {rise:.1} C at its narrowest neck (about {neck}), limit {limit:.0} C", r["hint"].as_str().unwrap_or("")));+ }+ }+ notes+}++#[cfg(test)]+mod tests {+ use super::*;+ fn spec() -> Value { json!({"maxRiseC": 10, "loads": {"VBUS": {"amps": 1.5}, "VSYS": {"amps": 1.2, "maxRiseC": 5}}}) }+ fn fields(rise: Value) -> Value {+ json!({"review": {"checks": {"ampacity": [+ {"net": "VBUS", "amps": 1.5, "neckMm": 0.31, "layer": "F.Cu", "copperUm": 35, "ipc2221_10C_A": 1.1, "solvedRiseC": rise, "at": [12.0, 4.0]},+ {"net": "VSYS", "amps": 1.2, "neckMm": 0.25, "layer": "F.Cu", "copperUm": 35, "ipc2221_10C_A": 0.9, "solvedRiseC": 6.0, "at": [3.0, 4.0]}]}}})+ }+ fn failing() -> Vec<Value> {+ vec![json!({"net": "VBUS", "amps": 1.5, "status": "fail", "failure": "bottleneck", "hint": "VBUS carries 1.5 A on a 0.30 mm bottleneck"}),+ json!({"net": "VSYS", "amps": 1.2, "status": "fail", "failure": "bottleneck", "hint": "VSYS ..."})]+ }+ #[test]+ fn a_solved_rise_within_the_limit_judges_the_net() {+ let mut r = failing();+ let notes = accept_solve(&mut r, &spec(), &fields(json!(3.5)));+ assert_eq!(r[0]["status"], "pass");+ assert_eq!(r[0]["judgedBy"], "fields-solve");+ assert!(r[0]["how"].as_str().unwrap().contains("judged by the Fields solve"));+ assert_eq!(r[0]["ipcWholeNetScreen"]["status"], "fail");+ // VSYS: 6.0 C solved against its own 5 C limit still fails, and says so+ assert_eq!(r[1]["status"], "fail");+ assert!(r[1]["hint"].as_str().unwrap().contains("6.0 C"));+ assert_eq!(notes.len(), 1);+ }+ #[test]+ fn rounding_allowance_missing_rows_and_other_failures_stay_failed() {+ let mut r = failing();+ accept_solve(&mut r, &spec(), &fields(json!(9.96)));+ assert_eq!(r[0]["status"], "fail");+ for rise in [Value::Null, json!(-1.0), json!("3")] {+ let mut r = failing();+ accept_solve(&mut r, &spec(), &fields(rise));+ assert_eq!(r[0]["status"], "fail");+ }+ let mut r = failing();+ accept_solve(&mut r, &spec(), &json!({"review": {}}));+ assert!(r[0]["hint"].as_str().unwrap().contains("no review.checks.ampacity row"));+ // a solve at another current does not judge the declared load+ let mut f = fields(json!(3.5)); f["review"]["checks"]["ampacity"][0]["amps"] = json!(1.0);+ let mut r = failing();+ accept_solve(&mut r, &spec(), &f);+ assert_eq!(r[0]["status"], "fail");+ // via stitching and missing copper are not the width approximation+ let mut r = vec![json!({"net": "VBUS", "status": "fail", "failure": "via-stitching", "hint": "x"})];+ accept_solve(&mut r, &spec(), &fields(json!(1.0)));+ assert_eq!(r[0]["status"], "fail");+ }+}
crates/aiflow-board/src/lib.rs+104−5@@ -17,6 +17,24 @@ pub const F_CU: usize = 0; pub const B_CU: usize = 1; pub const LAYER_NAMES: [&str; 2] = ["F.Cu", "B.Cu"]; +/// A copper layer by name for connectivity reads that see every layer: F.Cu 0, B.Cu 1, InN.Cu 1 + N.+/// Outer layers keep F_CU and B_CU, so outer-layer items read the same as before.+pub fn copper_layer(name: &str) -> Option<usize> {+ match name {+ "F.Cu" => Some(F_CU),+ "B.Cu" => Some(B_CU),+ _ => name.strip_prefix("In").and_then(|r| r.strip_suffix(".Cu")).and_then(|n| n.parse::<usize>().ok()).filter(|&n| n >= 1).map(|n| 1 + n),+ }+}++/// How deep a `copper_layer` index sits in the stack, from 0 at F.Cu down to COPPER_BOTTOM at B.Cu.+pub fn copper_depth(layer: usize) -> usize {+ match layer { F_CU => 0, B_CU => COPPER_BOTTOM, k => k - 1 }+}++/// The depth of B.Cu in `copper_depth`.+pub const COPPER_BOTTOM: usize = 1000;+ #[derive(Debug, Clone)] pub struct Pad { pub reference: String,@@ -50,6 +68,11 @@ impl Pad { pub fn on_layer(&self, layer: usize) -> bool { if layer == F_CU { self.on_f } else { self.on_b } }+ /// Whether the pad has copper on a `copper_layer` index: outer layers as `on_layer`, inner+ /// layers only for a plated through-hole pad.+ pub fn on_copper(&self, layer: usize) -> bool {+ if layer <= B_CU { self.on_layer(layer) } else { self.thru }+ } } #[derive(Debug, Clone)]@@ -81,6 +104,17 @@ pub struct Via { pub y: f64, pub size: f64, pub drill: f64,+ /// The copper depths it spans (`copper_depth`): 0..=COPPER_BOTTOM for a through via, less for+ /// a blind or buried one.+ pub span: (usize, usize),+}++impl Via {+ /// Whether the via has copper on a `copper_layer` index.+ pub fn on_copper(&self, layer: usize) -> bool {+ let d = copper_depth(layer);+ self.span.0 <= d && d <= self.span.1+ } } /// A copper zone: its outline and its filled islands per layer (empty when never filled).@@ -226,7 +260,9 @@ fn drill_hole(pn: &Node, reference: &str, name: &str, ptype: &str, fx: f64, fy: let pat = pn.find("at"); let px = pat.and_then(|a| a.number(1)).unwrap_or(0.0); let py = pat.and_then(|a| a.number(2)).unwrap_or(0.0);- let pang = pat.and_then(|a| a.number(3)).unwrap_or(rot);+ // a pad's angle in a .kicad_pcb is absolute (it already holds the footprint's rotation); KiCad+ // leaves it out when that absolute angle is 0, even in a rotated footprint (issue #103)+ let pang = pat.and_then(|a| a.number(3)).unwrap_or(0.0); let (rx, ry) = geom::rot_kicad(px, py, rot); let (x, y) = (fx + rx, fy + ry); let local = if (w - h).abs() < 1e-9 { geom::circle_poly(ox, oy, w / 2.0, 32) } else { geom::stadium_poly(w, h, 10).into_iter().map(|(a, b)| (a + ox, b + oy)).collect() };@@ -332,7 +368,8 @@ impl Board { let pat = pn.find("at"); let px = pat.and_then(|a| a.number(1)).unwrap_or(0.0); let py = pat.and_then(|a| a.number(2)).unwrap_or(0.0);- let pang = pat.and_then(|a| a.number(3)).unwrap_or(rot);+ // absolute, and 0 when left out (see drill_hole; issue #103)+ let pang = pat.and_then(|a| a.number(3)).unwrap_or(0.0); let size = pn.find("size"); let sx_ = size.and_then(|s| s.number(1)).unwrap_or(0.0); let sy_ = size.and_then(|s| s.number(2)).unwrap_or(0.0);@@ -473,13 +510,23 @@ impl Board { /// The copper tracks already on the board: every `segment`, and every `arc` as short straight /// pieces, on F.Cu or B.Cu. pub fn tracks(&self) -> Vec<Track> {+ self.tracks_on(false)+ }++ /// Every copper track on every copper layer, for connectivity: `layer` is a `copper_layer`+ /// index (F.Cu 0, B.Cu 1, InN.Cu 1 + N).+ pub fn copper_tracks(&self) -> Vec<Track> {+ self.tracks_on(true)+ }++ fn tracks_on(&self, inner: bool) -> Vec<Track> { let mut out = Vec::new(); let p2 = |n: Option<&Node>| -> Option<Pt> { let n = n?; Some((n.number(1)?, n.number(2)?)) }; for node in self.root.nodes() { if node.tag != "segment" && node.tag != "arc" { continue; }- let layer = match node.find("layer").and_then(|l| l.value()) { Some("F.Cu") => F_CU, Some("B.Cu") => B_CU, _ => continue };+ let Some(layer) = node.find("layer").and_then(|l| l.value()).and_then(copper_layer).filter(|&l| inner || l <= B_CU) else { continue }; let width = node.find("width").and_then(|w| w.number(1)).unwrap_or(0.0); let net = self.item_net(node); let (Some(s), Some(e)) = (p2(node.find("start")), p2(node.find("end"))) else { continue };@@ -499,13 +546,26 @@ impl Board { let (Some(x), Some(y)) = (at.and_then(|a| a.number(1)), at.and_then(|a| a.number(2))) else { continue }; let size = v.find("size").and_then(|s| s.number(1)).unwrap_or(0.6); let drill = v.find("drill").and_then(|d| d.number(1)).unwrap_or(0.3);- out.push(Via { net: self.item_net(v), x, y, size, drill });+ // a blind or buried via names its two end layers; a through via spans the whole stack+ let ends: Vec<usize> = v.find("layers").map(|ls| ls.items.iter().filter_map(|it| if let sx::Item::Atom { text, .. } = it { copper_layer(text) } else { None }).map(copper_depth).collect()).unwrap_or_default();+ let span = match (ends.iter().min(), ends.iter().max()) { (Some(&a), Some(&b)) if a < b => (a, b), _ => (0, COPPER_BOTTOM) };+ out.push(Via { net: self.item_net(v), x, y, size, drill, span }); } out } /// Copper zones with their net, layers, outline and filled islands (one polygon per island). pub fn zones(&self) -> Vec<Zone> {+ self.zones_on(false)+ }++ /// Every filled zone island on every copper layer, for connectivity: (net, `copper_layer`+ /// index, island polygon).+ pub fn copper_fills(&self) -> Vec<(String, usize, Vec<Pt>)> {+ self.zones_on(true).into_iter().flat_map(|z| { let net = z.net; z.fills.into_iter().map(move |(l, p)| (net.clone(), l, p)) }).collect()+ }++ fn zones_on(&self, inner: bool) -> Vec<Zone> { let mut out = Vec::new(); let pts_of = |n: &Node| -> Vec<Pt> { n.find("pts").map(|p| p.find_all("xy").filter_map(|xy| Some((xy.number(1)?, xy.number(2)?))).collect()).unwrap_or_default() }; for z in self.root.find_all("zone") {@@ -526,7 +586,7 @@ impl Board { let outline = z.find("polygon").map(pts_of).unwrap_or_default(); let mut fills = Vec::new(); for f in z.find_all("filled_polygon") {- let layer = match f.find("layer").and_then(|l| l.value()) { Some("F.Cu") => F_CU, Some("B.Cu") => B_CU, _ => continue };+ let Some(layer) = f.find("layer").and_then(|l| l.value()).and_then(copper_layer).filter(|&l| inner || l <= B_CU) else { continue }; let poly = pts_of(f); if poly.len() >= 3 { fills.push((layer, poly)); } }@@ -541,3 +601,42 @@ impl Board { if pts.is_empty() { None } else { Some(geom::bbox(&pts)) } } }++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn a_pad_angle_is_absolute_and_zero_when_left_out() {+ // KiCad writes a pad's absolute angle and omits it at 0, even in a footprint turned 90 degrees+ // (issue #103): pad 1 lies along x on the board, pad 2 (written at 90) along y+ let t = r#"(kicad_pcb (layers (0 "F.Cu" signal) (2 "B.Cu" signal) (25 "Edge.Cuts" user))+ (footprint "X" (layer "F.Cu") (at 10 10 90) (property "Reference" "X1")+ (pad "1" smd rect (at 0 0) (size 2 0.4) (layers "F.Cu") (net "A"))+ (pad "2" smd rect (at 0 3 90) (size 2 0.4) (layers "F.Cu") (net "A")))+ (gr_rect (start 0 0) (end 20 20) (layer "Edge.Cuts")))"#;+ let b = Board::from_text("t.kicad_pcb", t.to_string()).unwrap();+ let ext = |k: &str| { let bb = geom::bbox(&b.pad_by_key(k).unwrap().polys[0]); ((bb.2 - bb.0).abs(), (bb.3 - bb.1).abs()) };+ let (w1, h1) = ext("X1.1");+ assert!((w1 - 2.0).abs() < 1e-6 && (h1 - 0.4).abs() < 1e-6, "{w1} x {h1}");+ let (w2, h2) = ext("X1.2");+ assert!((w2 - 0.4).abs() < 1e-6 && (h2 - 2.0).abs() < 1e-6, "{w2} x {h2}");+ }++ #[test]+ fn copper_layers_read_inner_tracks_fills_and_via_spans() {+ assert_eq!((copper_layer("F.Cu"), copper_layer("B.Cu"), copper_layer("In1.Cu"), copper_layer("In30.Cu"), copper_layer("F.SilkS")), (Some(0), Some(1), Some(2), Some(31), None));+ let t = r#"(kicad_pcb (layers (0 "F.Cu" signal) (4 "In1.Cu" signal) (6 "In2.Cu" signal) (2 "B.Cu" signal) (25 "Edge.Cuts" user))+ (segment (start 0 0) (end 1 0) (width 0.2) (layer "In2.Cu") (net "A"))+ (segment (start 0 0) (end 1 0) (width 0.2) (layer "F.Cu") (net "A"))+ (via (at 1 1) (size 0.6) (drill 0.3) (layers "F.Cu" "B.Cu") (net "A"))+ (via blind (at 2 2) (size 0.6) (drill 0.3) (layers "F.Cu" "In1.Cu") (net "A"))+ (gr_rect (start 0 0) (end 20 20) (layer "Edge.Cuts")))"#;+ let b = Board::from_text("t.kicad_pcb", t.to_string()).unwrap();+ assert_eq!(b.tracks().len(), 1, "the router's two layers only");+ assert_eq!(b.copper_tracks().iter().map(|t| t.layer).collect::<Vec<_>>(), vec![3, 0]);+ let v = b.vias();+ assert!(v[0].on_copper(3) && v[0].on_copper(B_CU));+ assert!(v[1].on_copper(F_CU) && v[1].on_copper(2) && !v[1].on_copper(3) && !v[1].on_copper(B_CU));+ }+}
crates/aiflow-bridge/src/lib.rs+1@@ -11,6 +11,7 @@ pub mod altium; pub mod eda; pub mod fusion; pub mod landing;+pub mod recorder; pub use landing::Landing; /// Pulls of one clip before the stop gives up and flags it `pullFailed`: the first and two retries.
crates/aiflow-bridge/src/recorder.rsadded+115@@ -0,0 +1,115 @@+//! Which recorder the desktop has (issue #106). AI Flow films each step with Adom Bridge's native+//! recorder: `desktop_record_window_start` / `desktop_record_start` to begin a take,+//! `desktop_record_status` to watch it and `desktop_record_stop` to finish it (the Windows Adom+//! Bridge 2.x names). An older or macOS Adom Bridge (1.9.224 on macOS) has none of the stop and+//! status verbs: it offers `desktop_record_window_start` and `desktop_record_window_status` instead,+//! and answers a 2.x name with "is not a built-in desktop verb. Did you mean: ...". Started there, a+//! take can never be stopped, so every step warned at its stop and no step had a clip.+//!+//! The run asks once, with a read-only `desktop_record_status {}` (no take id: it lists the takes,+//! starts and stops nothing), and keeps the answer in the run:+//! - `Desktop`: the 2.x verbs answer; clips work as before.+//! - `None`: the box does not know the 2.x verbs. No take is started, the run says so once, and+//! each step keeps its start and end screenshots. The macOS window-recorder verbs the box names+//! are recorded, but not used: their stop verb and reply shape are not documented anywhere AI+//! Flow can confirm them, and a take that cannot be stopped is worse than no take.+//! - `Unknown`: the probe got no clear answer (the box is offline, the call timed out). Nothing is+//! cached and the run behaves as before.++use serde_json::{json, Value};++/// What `desktop_record_status {}` said about the desktop's recorder.+#[derive(Debug, Clone, PartialEq)]+pub enum RecorderKind {+ /// The Windows 2.x recorder verbs answer.+ Desktop,+ /// The box does not know the recorder verbs AI Flow uses. `offered` lists the recorder verbs+ /// the box suggested instead (from its "Did you mean" hint), `said` its own words.+ None { offered: Vec<String>, said: String },+ /// No clear answer: try again later.+ Unknown(String),+}++/// The reply of a verb the box does not have ("is not a built-in desktop verb", "Unknown desktop+/// command", an unknown-verb error code).+pub fn unknown_verb(reply: &Value) -> bool {+ let code = reply["errorCode"].as_str().or(reply["code"].as_str()).unwrap_or("").to_ascii_lowercase();+ if matches!(code.as_str(), "unknown_command" | "unknown_verb" | "verb_not_found" | "not_a_builtin_verb" | "unknown_desktop_command") { return true; }+ let text = reply.to_string().to_ascii_lowercase();+ text.contains("not a built-in desktop verb") || text.contains("unknown desktop command") || text.contains("unknown command") || text.contains("unknown verb")+}++/// The verb names of a "Did you mean: a, b, c?" hint.+pub fn did_you_mean(reply: &Value) -> Vec<String> {+ let text = reply.to_string();+ let Some(i) = text.find("Did you mean") else { return Vec::new() };+ text[i..].split(|c: char| !(c.is_ascii_alphanumeric() || c == '_')).filter(|w| w.contains('_')).map(str::to_string).collect()+}++/// Read the recorder from the reply of `desktop_record_status {}`.+pub fn classify(status: &Value) -> RecorderKind {+ if unknown_verb(status) {+ let mut offered: Vec<String> = did_you_mean(status).into_iter().filter(|v| v.contains("record")).collect();+ offered.dedup();+ let said = status["error"].as_str().or(status["message"].as_str()).map(str::to_string).unwrap_or_else(|| status.to_string().chars().take(240).collect());+ return RecorderKind::None { offered, said };+ }+ let f = crate::reply_fields(status);+ let answered = crate::Bridge::ok(status) || f.get("recordings").map(|v| v.is_array()).unwrap_or(false) || status.get("recordings").map(|v| v.is_array()).unwrap_or(false) || f.get("active").is_some();+ if answered { return RecorderKind::Desktop; }+ RecorderKind::Unknown(status["error"].as_str().or(status["errorCode"].as_str()).or(status["status"].as_str()).map(str::to_string).unwrap_or_else(|| status.to_string().chars().take(160).collect()))+}++/// What a run keeps about the box's recorder (`run.json` "recorder"), or None for `Unknown`.+pub fn record_of(kind: &RecorderKind, target: &str, at: &str) -> Option<Value> {+ match kind {+ RecorderKind::Desktop => Some(json!({"kind": "desktop", "target": target, "checked": at})),+ RecorderKind::None { offered, said } => Some(json!({"kind": "none", "target": target, "checked": at, "offered": offered, "said": said})),+ RecorderKind::Unknown(_) => None,+ }+}++/// The one line that says a box cannot record clips.+pub fn no_recorder_line(rec: &Value) -> String {+ let box_ = rec["target"].as_str().unwrap_or("the test box");+ let offered: Vec<&str> = rec["offered"].as_array().map(|a| a.iter().filter_map(|v| v.as_str()).collect()).unwrap_or_default();+ let other = if offered.is_empty() { String::new() } else { format!(" (it offers {} instead, which AI Flow does not drive yet)", offered.join(", ")) };+ format!("no recorder on {box_}: its Adom Bridge does not answer desktop_record_status / desktop_record_stop{other}, so this run records no clips; each step keeps its start and end screenshots. Update Adom Bridge on {box_} to 2.1.35 or later for clips")+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn a_windows_2x_box_has_the_desktop_recorder() {+ assert_eq!(classify(&json!({"success": true, "recordings": [], "active": 0})), RecorderKind::Desktop);+ assert_eq!(classify(&json!({"success": true, "data": {"recordings": [{"recordingId": "rec-1", "running": true}]}})), RecorderKind::Desktop);+ }++ #[test]+ fn a_macos_1_9_box_has_none_and_names_its_window_verbs() {+ // Adom Bridge 1.9.224 on macOS (issue #106, Drew's reply)+ let mac = json!({"success": false, "error": "'desktop_record_status' is not a built-in desktop verb. Did you mean: desktop_record_window_status, desktop_permission_status, desktop_record_window_start?"});+ let RecorderKind::None { offered, said } = classify(&mac) else { panic!("{:?}", classify(&mac)) };+ assert_eq!(offered, vec!["desktop_record_window_status", "desktop_record_window_start"]);+ assert!(said.contains("not a built-in desktop verb"));+ let rec = record_of(&classify(&mac), "MacBook-Air", "2026-10-27T10:00:00Z").unwrap();+ assert_eq!(rec["kind"], "none");+ let line = no_recorder_line(&rec);+ assert!(line.starts_with("no recorder on MacBook-Air:") && line.contains("desktop_record_window_start") && line.contains("start and end screenshots"), "{line}");+ // the older wording of the same answer+ assert!(matches!(classify(&json!({"status": "error", "error": "Unknown desktop command: desktop_record_status"})), RecorderKind::None { .. }));+ }++ #[test]+ fn no_clear_answer_is_unknown_and_never_cached() {+ for r in [json!({"success": false, "errorCode": "aiflow_timeout", "error": "adom-bridge desktop_record_status gave no answer in 60 s"}),+ json!({"status": "parse_error", "raw": ""}),+ json!({"status": "error", "error": "target offline"})] {+ let k = classify(&r);+ assert!(matches!(k, RecorderKind::Unknown(_)), "{r}: {k:?}");+ assert!(record_of(&k, "box", "t").is_none());+ }+ }+}
crates/aiflow-router/src/board_copper.rs+356−73@@ -36,22 +36,194 @@ pub struct CompCopper { const TOUCH: f64 = 1e-3; +/// Bucket size of the spatial index over pads, tracks and vias, in mm.+const CELL: f64 = 1.0;++/// Join the board's copper the way KiCad's connectivity does: two items of one net connect when+/// their copper overlaps on a layer they share. Tracks overlap when the distance between their+/// centre lines is at most the sum of their half widths (a T-junction ending inside the other+/// track's width, collinear overlaps, an end off a via's centre but on its annulus); a track, via or+/// pad touches a pad when it reaches the pad's polygon; a via joins every layer it spans; a filled+/// zone island joins what reaches inside it, and overlapping islands of one net join each other. Arcs arrive as short straight pieces. Nodes: pads, then+/// tracks, then vias, then islands. A uniform grid keeps it near linear in the item count.+fn copper_groups(pads: &[aiflow_board::Pad], tracks: &[Track], vias: &[Via], islands: &[(String, usize, Vec<aiflow_board::Pt>)]) -> Dsu {+ let (np, nt, nv) = (pads.len(), tracks.len(), vias.len());+ let mut dsu = Dsu((0..np + nt + nv + islands.len()).collect());+ // nets as small integers: no string compare in the pair loop+ let mut net_ix: HashMap<String, u32> = HashMap::new();+ let mut nid = |n: &str| -> u32 {+ if n.is_empty() { return u32::MAX; }+ if let Some(&k) = net_ix.get(n) { return k; }+ let k = net_ix.len() as u32;+ net_ix.insert(n.to_string(), k);+ k+ };+ let mut net: Vec<u32> = Vec::with_capacity(np + nt + nv);+ let mut bb: Vec<(f64, f64, f64, f64)> = Vec::with_capacity(np + nt + nv);+ for p in pads {+ net.push(nid(&p.net));+ bb.push(geom::bbox(&p.polys.iter().flatten().copied().collect::<Vec<_>>()));+ }+ for t in tracks {+ net.push(nid(&t.net));+ let r = t.width / 2.0;+ bb.push((t.a.0.min(t.b.0) - r, t.a.1.min(t.b.1) - r, t.a.0.max(t.b.0) + r, t.a.1.max(t.b.1) + r));+ }+ for v in vias {+ net.push(nid(&v.net));+ let r = v.size / 2.0;+ bb.push((v.x - r, v.y - r, v.x + r, v.y + r));+ }+ let n = net.len();+ let finite: Vec<bool> = bb.iter().map(|b| b.0.is_finite() && b.1.is_finite() && b.2.is_finite() && b.3.is_finite()).collect();+ let (mut x0, mut y0) = (f64::INFINITY, f64::INFINITY);+ let (mut x1, mut y1) = (f64::NEG_INFINITY, f64::NEG_INFINITY);+ for (b, _) in bb.iter().zip(&finite).filter(|(_, f)| **f) {+ x0 = x0.min(b.0); y0 = y0.min(b.1); x1 = x1.max(b.2); y1 = y1.max(b.3);+ }+ if !x0.is_finite() { return dsu; }+ let (gw, gh) = (((x1 - x0) / CELL) as usize + 1, ((y1 - y0) / CELL) as usize + 1);+ let span = |b: &(f64, f64, f64, f64)| -> (usize, usize, usize, usize) {+ let c = |v: f64, o: f64, m: usize| (((v - o - TOUCH) / CELL).floor().max(0.0) as usize).min(m - 1);+ let d = |v: f64, o: f64, m: usize| (((v - o + TOUCH) / CELL).floor().max(0.0) as usize).min(m - 1);+ (c(b.0, x0, gw), c(b.1, y0, gh), d(b.2, x0, gw), d(b.3, y0, gh))+ };+ let mut cells: Vec<Vec<u32>> = vec![Vec::new(); gw * gh];+ for k in 0..n {+ if net[k] == u32::MAX || !finite[k] { continue; }+ let (i0, j0, i1, j1) = span(&bb[k]);+ for j in j0..=j1 { for i in i0..=i1 { cells[j * gw + i].push(k as u32); } }+ }+ let pad_pad = |p: &aiflow_board::Pad, q: &aiflow_board::Pad| -> bool {+ let shared = (p.on_f && q.on_f) || (p.on_b && q.on_b) || (p.thru && q.thru);+ shared && p.polys.iter().any(|pp| q.polys.iter().any(|qq| {+ (0..pp.len()).any(|k| geom::seg_poly_distance(pp[k], pp[(k + 1) % pp.len()], qq) <= TOUCH)+ || pp.first().map(|&(x, y)| geom::point_in_poly(x, y, qq)).unwrap_or(false)+ || qq.first().map(|&(x, y)| geom::point_in_poly(x, y, pp)).unwrap_or(false)+ }))+ };+ let touch = |a: usize, b: usize| -> bool {+ let (a, b) = if a < b { (a, b) } else { (b, a) };+ if a < np {+ let p = &pads[a];+ if b < np { return pad_pad(p, &pads[b]); }+ if b < np + nt {+ let t = &tracks[b - np];+ return p.on_copper(t.layer) && p.polys.iter().any(|poly| geom::seg_poly_distance(t.a, t.b, poly) <= t.width / 2.0 + TOUCH);+ }+ let v = &vias[b - np - nt];+ let shared = (p.on_f && v.on_copper(F_CU)) || (p.on_b && v.on_copper(B_CU)) || p.thru;+ return shared && p.polys.iter().any(|poly| geom::point_poly_distance(v.x, v.y, poly) <= v.size / 2.0 + TOUCH);+ }+ if a < np + nt {+ let t = &tracks[a - np];+ if b < np + nt {+ let u = &tracks[b - np];+ // segment to segment: a 2-point polygon is the segment itself+ return t.layer == u.layer && geom::seg_poly_distance(t.a, t.b, &[u.a, u.b]) <= (t.width + u.width) / 2.0 + TOUCH;+ }+ let v = &vias[b - np - nt];+ return v.on_copper(t.layer) && geom::seg_distance(v.x, v.y, t.a.0, t.a.1, t.b.0, t.b.1) <= (v.size + t.width) / 2.0 + TOUCH;+ }+ let (v, w) = (&vias[a - np - nt], &vias[b - np - nt]);+ v.span.0 <= w.span.1 && w.span.0 <= v.span.1 && (v.x - w.x).hypot(v.y - w.y) <= (v.size + w.size) / 2.0 + TOUCH+ };+ // each pair once: a candidate is checked from the lower index, and `seen` drops repeats across cells+ let mut seen: Vec<u32> = vec![u32::MAX; n];+ for k in 0..n {+ if net[k] == u32::MAX || !finite[k] { continue; }+ let (i0, j0, i1, j1) = span(&bb[k]);+ for j in j0..=j1 {+ for i in i0..=i1 {+ for &m in &cells[j * gw + i] {+ let m = m as usize;+ if m <= k || seen[m] == k as u32 || net[m] != net[k] { continue; }+ seen[m] = k as u32;+ let (a, b) = (&bb[k], &bb[m]);+ if a.0 > b.2 + TOUCH || b.0 > a.2 + TOUCH || a.1 > b.3 + TOUCH || b.1 > a.3 + TOUCH { continue; }+ if dsu.find(k) == dsu.find(m) { continue; }+ if touch(k, m) { dsu.join(k, m); }+ }+ }+ }+ }+ // filled zone islands: per island, only items of its net on its layer, inside its box+ let mut by_net: HashMap<u32, Vec<usize>> = HashMap::new();+ for k in 0..n {+ if net[k] != u32::MAX && finite[k] { by_net.entry(net[k]).or_default().push(k); }+ }+ for (z, (zn, l, poly)) in islands.iter().enumerate() {+ let Some(&zid) = net_ix.get(zn) else { continue };+ let node = np + nt + nv + z;+ let zb = geom::bbox(poly);+ let inside = |x: f64, y: f64| geom::point_in_poly(x, y, poly);+ for &k in by_net.get(&zid).map(|v| v.as_slice()).unwrap_or(&[]) {+ let b = &bb[k];+ if b.0 > zb.2 || zb.0 > b.2 || b.1 > zb.3 || zb.1 > b.3 { continue; }+ let hit = if k < np {+ let p = &pads[k];+ // a thermal relief leaves the pad centre bare: its spokes reach into the pad+ p.on_copper(*l) && p.polys.iter().any(|pp| pp.iter().any(|&(x, y)| inside(x, y)) || poly.iter().any(|&(x, y)| geom::point_in_poly(x, y, pp)))+ } else if k < np + nt {+ let t = &tracks[k - np];+ t.layer == *l && (inside(t.a.0, t.a.1) || inside(t.b.0, t.b.1) || geom::seg_poly_distance(t.a, t.b, poly) <= t.width / 2.0)+ } else {+ let v = &vias[k - np - nt];+ v.on_copper(*l) && geom::point_poly_distance(v.x, v.y, poly) <= v.size / 2.0+ };+ if hit { dsu.join(k, node); }+ }+ }+ // two islands of one net on one layer that overlap are one piece of copper (two zones of a net+ // drawn over each other, a teardrop zone on a pour): KiCad joins them+ let zb: Vec<(f64, f64, f64, f64)> = islands.iter().map(|z| geom::bbox(&z.2)).collect();+ for a in 0..islands.len() {+ for b in a + 1..islands.len() {+ let (p, q) = (&islands[a], &islands[b]);+ if p.0.is_empty() || p.0 != q.0 || p.1 != q.1 { continue; }+ let (u, v) = (&zb[a], &zb[b]);+ if u.0 > v.2 || v.0 > u.2 || u.1 > v.3 || v.1 > u.3 { continue; }+ let (na, nb) = (np + nt + nv + a, np + nt + nv + b);+ if dsu.find(na) == dsu.find(nb) { continue; }+ let ov = (u.0.max(v.0) - TOUCH, u.1.max(v.1) - TOUCH, u.2.min(v.2) + TOUCH, u.3.min(v.3) + TOUCH);+ if polys_overlap(&p.2, &q.2, ov) { dsu.join(na, nb); }+ }+ }+ dsu+}++/// Whether two filled polygons share copper, looking only inside `ov` (where their boxes meet):+/// a vertex of one inside the other, or two edges within TOUCH.+fn polys_overlap(p: &[aiflow_board::Pt], q: &[aiflow_board::Pt], ov: (f64, f64, f64, f64)) -> bool {+ let inb = |&(x, y): &aiflow_board::Pt| x >= ov.0 && x <= ov.2 && y >= ov.1 && y <= ov.3;+ if p.iter().filter(|v| inb(v)).any(|&(x, y)| geom::point_in_poly(x, y, q)) { return true; }+ if q.iter().filter(|v| inb(v)).any(|&(x, y)| geom::point_in_poly(x, y, p)) { return true; }+ let edges = |poly: &[aiflow_board::Pt]| -> Vec<(aiflow_board::Pt, aiflow_board::Pt)> {+ (0..poly.len()).map(|k| (poly[k], poly[(k + 1) % poly.len()]))+ .filter(|(a, b)| a.0.max(b.0) >= ov.0 && a.0.min(b.0) <= ov.2 && a.1.max(b.1) >= ov.1 && a.1.min(b.1) <= ov.3).collect()+ };+ let (pe, qe) = (edges(p), edges(q));+ pe.iter().any(|&(a, b)| qe.iter().any(|&(c, d)| geom::seg_poly_distance(a, b, &[c, d]) <= TOUCH))+}+ impl Router { /// Stamp the board's own tracks and vias as fixed copper and work out which pads they already join. pub(crate) fn stamp_board_copper(&mut self) { let np = self.board.pads.len(); self.pad_comp = (0..np).collect();- let tracks: Vec<Track> = self.board.tracks().into_iter().filter(|t| t.width > 0.0).collect();+ // connectivity reads every copper layer (inner-layer tracks and plane fills join pads through+ // vias, issue #103); only F.Cu and B.Cu are stamped, since the router plans on those two+ let tracks: Vec<Track> = self.board.copper_tracks().into_iter().filter(|t| t.width > 0.0).collect(); let vias: Vec<Via> = self.board.vias();- let zones = self.board.zones();+ let islands = self.board.copper_fills(); // a board whose only copper is filled zones (a chassis pour joining through-hole pins, no // tracks or vias) still has connections made: read its islands too (issue #84)- if tracks.is_empty() && vias.is_empty() && zones.iter().all(|z| z.fills.is_empty()) {+ if tracks.is_empty() && vias.is_empty() && islands.is_empty() { return; } let g = self.grid.clone(); let nid_of = |r: &Router, net: &str| -> i16 { r.net_ids.get(net).map(|&k| k as i16).unwrap_or(-2) };- for t in &tracks {+ for t in tracks.iter().filter(|t| t.layer <= B_CU) { let nid = nid_of(self, &t.net); for c in g.segment_cells(t.a, t.b, t.width / 2.0, Some(1e-6)) { self.fixed_raw[t.layer][c] = nid;@@ -60,81 +232,22 @@ impl Router { for v in &vias { let nid = nid_of(self, &v.net); for c in g.disk_cells((v.x, v.y), v.size / 2.0, Some(1e-6)) {- self.fixed_raw[F_CU][c] = nid;- self.fixed_raw[B_CU][c] = nid;+ if v.on_copper(F_CU) { self.fixed_raw[F_CU][c] = nid; }+ if v.on_copper(B_CU) { self.fixed_raw[B_CU][c] = nid; } } if nid >= 0 { let (i, j) = g.to_cell(v.x, v.y); self.vias.push((i as i32, j as i32, nid as usize, v.size, v.drill)); } }- // connectivity: pads, then tracks, then vias, then filled zone islands, one node each- let islands: Vec<(String, usize, Vec<aiflow_board::Pt>)> = zones.iter().flat_map(|z| z.fills.iter().map(move |(l, p)| (z.net.clone(), *l, p.clone()))).collect(); let (nt, nv) = (tracks.len(), vias.len());- let mut dsu = Dsu((0..np + nt + nv + islands.len()).collect());+ let pads = self.board.pads.clone();+ let tc = std::time::Instant::now();+ let mut dsu = copper_groups(&pads, &tracks, &vias, &islands);+ let conn_s = tc.elapsed().as_secs_f64(); let mut kind = vec![CompCopper::default(); np + nt + nv + islands.len()]; for k in 0..nt { kind[np + k] = CompCopper { copper: true, via_or_zone: false }; } for k in 0..nv + islands.len() { kind[np + nt + k] = CompCopper { copper: true, via_or_zone: true }; }- let pads = self.board.pads.clone();- let pad_touch = |p: &aiflow_board::Pad, x: f64, y: f64, r: f64| p.polys.iter().any(|poly| geom::point_poly_distance(x, y, poly) <= r + TOUCH);- // pads of one footprint and net whose copper overlaps on a layer they share are one piece of- // copper: a track on either reaches both (an exposed pad and its in-pad via pads, a ring drawn as- // several custom pads of one number)- for a in 0..np {- for b in a + 1..np {- let (p, q) = (&pads[a], &pads[b]);- if p.net.is_empty() || p.net != q.net || p.reference != q.reference || !((p.on_f && q.on_f) || (p.on_b && q.on_b)) { continue; }- let touch = p.polys.iter().any(|pp| q.polys.iter().any(|qq| (0..pp.len()).any(|k| geom::seg_poly_distance(pp[k], pp[(k + 1) % pp.len()], qq) <= TOUCH)));- if touch { dsu.join(a, b); }- }- }- for (k, t) in tracks.iter().enumerate() {- if t.net.is_empty() { continue; }- // the whole segment, not only its ends: a feeder may cross a large custom pad (a sensor ring- // drawn as gr_poly primitives) with both ends off its copper (issue #93)- for (pi, p) in pads.iter().enumerate() {- if p.net == t.net && p.on_layer(t.layer) && p.polys.iter().any(|poly| geom::seg_poly_distance(t.a, t.b, poly) <= t.width / 2.0 + TOUCH) {- dsu.join(pi, np + k);- }- }- for (m, u) in tracks.iter().enumerate().skip(k + 1) {- if u.net != t.net || u.layer != t.layer { continue; }- let near = |p: aiflow_board::Pt, s: &Track| geom::seg_distance(p.0, p.1, s.a.0, s.a.1, s.b.0, s.b.1) <= s.width / 2.0 + TOUCH;- if near(t.a, u) || near(t.b, u) || near(u.a, t) || near(u.b, t) {- dsu.join(np + k, np + m);- }- }- for (m, v) in vias.iter().enumerate() {- if v.net == t.net && geom::seg_distance(v.x, v.y, t.a.0, t.a.1, t.b.0, t.b.1) <= v.size / 2.0 + t.width / 2.0 {- dsu.join(np + k, np + nt + m);- }- }- }- for (m, v) in vias.iter().enumerate() {- if v.net.is_empty() { continue; }- for (pi, p) in pads.iter().enumerate() {- if p.net == v.net && pad_touch(p, v.x, v.y, v.size / 2.0) {- dsu.join(pi, np + nt + m);- }- }- }- for (z, (net, l, poly)) in islands.iter().enumerate() {- if net.is_empty() { continue; }- let node = np + nt + nv + z;- let inside = |x: f64, y: f64| geom::point_in_poly(x, y, poly);- for (pi, p) in pads.iter().enumerate() {- if &p.net != net || !p.on_layer(*l) { continue; }- // a thermal relief leaves the pad centre bare: its spokes reach into the pad- let hit = p.polys.iter().any(|pp| pp.iter().any(|&(x, y)| inside(x, y)) || poly.iter().any(|&(x, y)| geom::point_in_poly(x, y, pp)));- if hit { dsu.join(pi, node); }- }- for (k, t) in tracks.iter().enumerate() {- if &t.net == net && t.layer == *l && (inside(t.a.0, t.a.1) || inside(t.b.0, t.b.1)) { dsu.join(np + k, node); }- }- for (m, v) in vias.iter().enumerate() {- if &v.net == net && geom::point_poly_distance(v.x, v.y, poly) <= v.size / 2.0 { dsu.join(np + nt + m, node); }- }- } let mut comp_copper: HashMap<usize, CompCopper> = HashMap::new(); for (n, kd) in kind.iter().enumerate() { let root = dsu.find(n);@@ -148,6 +261,7 @@ impl Router { // landing cells on the fixed copper, per net and group, for groups that reach a pad let with_pad: std::collections::HashSet<usize> = self.pad_comp.iter().copied().collect(); for (k, t) in tracks.iter().enumerate() {+ if t.layer > B_CU { continue; } let root = dsu.find(np + k); let Some(&net) = self.net_ids.get(&t.net) else { continue }; if !with_pad.contains(&root) { continue; }@@ -163,11 +277,16 @@ impl Router { if !with_pad.contains(&root) { continue; } let (i, j) = g.to_cell(v.x, v.y); let e = self.fixed_targets.entry(net).or_default();- e.push((root, Term::new(F_CU, i, j)));- e.push((root, Term::new(B_CU, i, j)));+ for l in [F_CU, B_CU] {+ if v.on_copper(l) { e.push((root, Term::new(l, i, j))); }+ } } self.comp_copper = comp_copper;- self.log(format!("board copper: {} track pieces and {} vias kept as fixed copper, {} filled zone islands read for connectivity", nt, nv, islands.len()));+ let (it, iz) = (tracks.iter().filter(|t| t.layer > B_CU).count(), islands.iter().filter(|z| z.1 > B_CU).count());+ let inner_note = if it + iz > 0 { format!(" ({it} track pieces and {iz} islands on inner layers, read for connectivity only)") } else { String::new() };+ self.log(format!("board copper: {} track pieces and {} vias kept as fixed copper, {} filled zone islands read for connectivity{inner_note}, joined in {conn_s:.2}s", nt, nv, islands.len()));+ // inner-layer tracks stay in the list: a via of another net must keep clear of them, while a+ // track query on F.Cu or B.Cu never matches their layer index self.fixed_tracks = tracks; self.fixed_vias = vias; }@@ -212,7 +331,9 @@ impl Router { pub fn fixed_blocks(&self, net_name: &str, layer: Option<usize>, x: f64, y: f64, need: f64) -> bool { for t in &self.fixed_tracks { if t.net == net_name || layer.map(|l| l != t.layer).unwrap_or(false) { continue; }- if (t.a.0 - x).abs() > 3.0 + t.width && (t.b.0 - x).abs() > 3.0 + t.width { continue; }+ // a box test: a long track passes near x even when both its ends are far from it+ let r = need + t.width;+ if x < t.a.0.min(t.b.0) - r || x > t.a.0.max(t.b.0) + r || y < t.a.1.min(t.b.1) - r || y > t.a.1.max(t.b.1) + r { continue; } if geom::seg_distance(x, y, t.a.0, t.a.1, t.b.0, t.b.1) < need + t.width / 2.0 { return true; } } self.fixed_vias.iter().any(|v| v.net != net_name && (v.x - x).hypot(v.y - y) < need + v.size / 2.0)@@ -440,6 +561,136 @@ pub(crate) mod tests { } } }++ // issue #103: existing copper joins the way KiCad's connectivity does (overlapping copper)++ fn seg_w(net: &str, layer: &str, a: (f64, f64), b: (f64, f64), w: f64) -> String {+ format!("(segment (start {} {}) (end {} {}) (width {w}) (layer \"{layer}\") (net \"{net}\"))\n", a.0, a.1, b.0, b.1)+ }+ fn via_at(net: &str, at: (f64, f64), size: f64) -> String {+ format!("(via (at {} {}) (size {size}) (drill 0.3) (layers \"F.Cu\" \"B.Cu\") (net \"{net}\"))\n", at.0, at.1)+ }+ const AB: [(&str, &str, f64, f64, bool); 3] = [("J1", "A", 2.0, 5.0, true), ("J2", "A", 18.0, 5.0, true), ("J5", "A", 10.0, 1.0, true)];++ fn done(extra: &str, pads: &[(&str, &str, f64, f64, bool)]) -> bool {+ let r = Router::new(board(pads, extra), Rules::default(), false);+ r.nets[r.net_ids["A"]].done_by_board+ }++ #[test]+ fn a_t_junction_ending_inside_the_other_tracks_width_joins() {+ // the branch ends 0.2 mm off the trunk's centre line: past the trunk's own half width (0.125)+ // but its round end still overlaps the trunk's copper (0.2 <= 0.125 + 0.125)+ let trunk = seg_w("A", "F.Cu", (2.0, 5.0), (18.0, 5.0), 0.25);+ assert!(done(&(trunk.clone() + &seg_w("A", "F.Cu", (10.0, 1.0), (10.0, 4.8), 0.25)), &AB));+ // the same branch stopped 0.1 mm short of touching: still split+ assert!(!done(&(trunk + &seg_w("A", "F.Cu", (10.0, 1.0), (10.0, 4.65), 0.25)), &AB));+ }++ #[test]+ fn a_track_end_on_a_via_annulus_off_centre_joins() {+ let pads = &AB[..2];+ // F.Cu ends 0.35 mm left of the via centre, B.Cu starts 0.4 mm right of it (annulus 0.3 + half width 0.125)+ let x = seg_w("A", "F.Cu", (2.0, 5.0), (9.65, 5.0), 0.25) + &via_at("A", (10.0, 5.0), 0.6) + &seg_w("A", "B.Cu", (10.4, 5.0), (18.0, 5.0), 0.25);+ assert!(done(&x, pads));+ // moved 0.5 mm off the via: no copper overlap+ let y = seg_w("A", "F.Cu", (2.0, 5.0), (9.65, 5.0), 0.25) + &via_at("A", (10.0, 5.0), 0.6) + &seg_w("A", "B.Cu", (10.9, 5.0), (18.0, 5.0), 0.25);+ assert!(!done(&y, pads));+ }++ #[test]+ fn collinear_overlapping_segments_join_and_a_gap_stays_split() {+ let pads = &AB[..2];+ assert!(done(&(seg_w("A", "F.Cu", (2.0, 5.0), (11.0, 5.0), 0.25) + &seg_w("A", "F.Cu", (9.0, 5.0), (18.0, 5.0), 0.25)), pads));+ // ends 0.4 mm apart, copper gap 0.15 mm: close but not touching+ assert!(!done(&(seg_w("A", "F.Cu", (2.0, 5.0), (9.6, 5.0), 0.25) + &seg_w("A", "F.Cu", (10.0, 5.0), (18.0, 5.0), 0.25)), pads));+ // touching copper of another net never joins+ let other = seg_w("A", "F.Cu", (2.0, 5.0), (9.5, 5.0), 0.25) + &seg_w("B", "F.Cu", (9.5, 5.0), (10.5, 5.0), 0.25) + &seg_w("A", "F.Cu", (10.5, 5.0), (18.0, 5.0), 0.25);+ assert!(!done(&other, pads));+ }++ #[test]+ fn inner_layer_tracks_join_through_hole_pads_and_vias() {+ // a 4-layer board routed on In1.Cu: through-hole pads and through vias reach it+ let thru = &AB[..2];+ assert!(done(&seg_w("A", "In1.Cu", (2.0, 5.0), (18.0, 5.0), 0.25), thru));+ let smd = [("J1", "A", 2.0, 5.0, false), ("J2", "A", 18.0, 5.0, false)];+ assert!(!done(&seg_w("A", "In1.Cu", (2.0, 5.0), (18.0, 5.0), 0.25), &smd), "an SMD pad has no inner-layer copper");+ let x = via_at("A", (2.0, 5.0), 0.6) + &via_at("A", (18.0, 5.0), 0.6) + &seg_w("A", "In2.Cu", (2.0, 5.0), (18.0, 5.0), 0.25);+ assert!(done(&x, &smd), "vias in the pads carry the inner track up to them");+ // inner copper is never stamped on the router's two layers+ let r = Router::new(board(&smd, &x), Rules::default(), false);+ let (i, j) = r.grid.to_cell(10.0, 5.0);+ assert_eq!(r.fixed_raw[F_CU][j as usize * r.grid.w + i as usize], -1);+ assert_eq!(r.fixed_raw[B_CU][j as usize * r.grid.w + i as usize], -1);+ }++ fn zone_box(net: &str, layer: &str, x0: f64, x1: f64) -> String {+ let pts = format!("(xy {x0} 3) (xy {x1} 3) (xy {x1} 7) (xy {x0} 7)");+ format!("(zone (net \"{net}\") (layer \"{layer}\") (polygon (pts {pts})) (filled_polygon (layer \"{layer}\") (pts {pts})))\n")+ }++ #[test]+ fn overlapping_fills_of_one_net_join_and_an_inner_plane_reaches_through_vias() {+ let pads = &AB[..2];+ // two zones of A on F.Cu, one under each pad, overlapping from x 9 to 11+ assert!(done(&(zone_box("A", "F.Cu", 1.0, 11.0) + &zone_box("A", "F.Cu", 9.0, 19.0)), pads));+ assert!(!done(&(zone_box("A", "F.Cu", 1.0, 9.0) + &zone_box("A", "F.Cu", 11.0, 19.0)), pads), "a 2 mm gap between the fills");+ // an In1.Cu fill joins the two through-hole pads+ assert!(done(&zone_box("A", "In1.Cu", 1.0, 19.0), pads));+ }++ #[test]+ fn a_via_keeps_clear_of_another_nets_inner_track() {+ // B runs along In1.Cu right under the middle of the board, its ends far from x = 10+ let pads = [("J1", "A", 2.0, 2.0, true), ("J2", "A", 18.0, 2.0, true), ("J3", "B", 1.0, 5.0, true), ("J4", "B", 19.0, 5.0, true)];+ let r = Router::new(board(&pads, &seg_w("B", "In1.Cu", (1.0, 5.0), (19.0, 5.0), 0.25)), Rules::default(), false);+ assert!(r.fixed_blocks("A", None, 10.0, 5.0, 0.5), "a via of A on B's inner track");+ assert!(!r.fixed_blocks("A", Some(F_CU), 10.0, 5.0, 0.5), "an F.Cu track of A crosses it freely");+ assert!(!r.fixed_blocks("A", None, 10.0, 8.0, 0.5));+ }++ #[test]+ fn a_large_routed_board_joins_fast() {+ // the size of the board in issue #103: about 13k track pieces, 1.4k vias, 960 pads; 480 two-pad+ // nets, each a chain of 27 collinear pieces changing layer through 3 vias+ let (mut pads, mut extra) = (Vec::new(), String::new());+ let names: Vec<(String, String, String)> = (0..480).map(|k| (format!("N{k}"), format!("P{k}"), format!("Q{k}"))).collect();+ for (k, (net, p, q)) in names.iter().enumerate() {+ let (x0, y0) = (2.0 + (k % 20) as f64 * 7.5, 2.0 + (k / 20) as f64 * 6.0);+ pads.push((p.clone(), net.clone(), x0, y0));+ pads.push((q.clone(), net.clone(), x0 + 6.0, y0));+ let step = 6.0 / 27.0;+ for i in 0..27 {+ let layer = if (i / 7) % 2 == 0 { "F.Cu" } else { "B.Cu" };+ extra += &seg_w(net, layer, (x0 + i as f64 * step, y0), (x0 + (i + 1) as f64 * step, y0), 0.2);+ if i % 7 == 6 && i < 21 { extra += &via_at(net, (x0 + (i + 1) as f64 * step, y0), 0.5); }+ }+ }+ let refs: Vec<(&str, &str, f64, f64, bool)> = pads.iter().map(|(r, n, x, y)| (r.as_str(), n.as_str(), *x, *y, true)).collect();+ // the test helper's outline is 20 x 10 mm: widen it to the 155 x 150 mm the nets sit on+ let text = board(&refs, &extra).text.replace("(end 20 10)", "(end 155 150)");+ let b = Board::from_text("big.kicad_pcb", text).unwrap();+ let (tracks, vias) = (b.copper_tracks(), b.vias());+ assert_eq!((tracks.len(), vias.len(), b.pads.len()), (12960, 1440, 960));+ let t0 = std::time::Instant::now();+ let mut dsu = copper_groups(&b.pads, &tracks, &vias, &[]);+ let s = t0.elapsed().as_secs_f64();+ for k in 0..480 { assert_eq!(dsu.find(2 * k), dsu.find(2 * k + 1), "net N{k}"); }+ assert_ne!(dsu.find(0), dsu.find(2));+ eprintln!("copper_groups on 12960 tracks, 1440 vias, 960 pads: {s:.3}s");+ // generous for an unoptimised test build; the release build takes well under a second+ assert!(s < if cfg!(debug_assertions) { 20.0 } else { 2.0 }, "{s}s");+ if !cfg!(debug_assertions) {+ // the whole router init on that board (grid, pads, copper, clusters): every net complete+ let t1 = std::time::Instant::now();+ let r = Router::new(b, Rules::default(), false);+ let init = t1.elapsed().as_secs_f64();+ eprintln!("Router::new on it: {init:.2}s ({} x {} cells)", r.grid.w, r.grid.h);+ assert!(r.nets.iter().all(|n| n.done_by_board), "every net is complete on its own copper");+ assert!(init < 10.0, "{init}s");+ }+ } } #[cfg(test)]@@ -537,3 +788,35 @@ mod issue93_tests { assert!(r.nets[r.net_ids["RXA1"]].done_by_board); } }++#[cfg(test)]+mod probe_real_board {+ use crate::{Router, Rules};++ /// `AIFLOW_PROBE_BOARD=path cargo test -p aiflow-router probe_routed_board -- --ignored --nocapture`:+ /// how a real board's own copper reads (init time, nets left open, their clusters).+ #[test]+ #[ignore]+ fn probe_routed_board() {+ let Ok(path) = std::env::var("AIFLOW_PROBE_BOARD") else { return };+ let b = aiflow_board::Board::load(&path).unwrap();+ let t0 = std::time::Instant::now();+ let mut r = Router::new(b, Rules::default(), false);+ let init = t0.elapsed().as_secs_f64();+ if std::env::var("AIFLOW_PROBE_ROUTE").is_ok() {+ let (plan, report, failed) = crate::drive::route_board(&mut r, 2, 4, 50);+ println!("route: {} plan entries, {failed} failed, open connections {}", plan.len(), report["boardCopper"]["openConnections"]);+ }+ for l in &r.log_lines { println!("{l}"); }+ let open: Vec<String> = r.nets.iter().filter(|n| n.clusters.len() >= 2 && !n.done_by_board && n.plane.is_none())+ .map(|n| format!("{} ({} clusters, {} groups)", n.name, n.clusters.len(), (0..n.clusters.len()).map(|c| r.comp_of(n.idx, c)).collect::<std::collections::HashSet<_>>().len())).collect();+ println!("init {init:.2}s; {} nets open: {}", open.len(), open.join(", "));+ if let Ok(name) = std::env::var("AIFLOW_PROBE_NET") {+ let n = r.net_ids[&name];+ for c in 0..r.nets[n].clusters.len() {+ let keys: Vec<String> = r.nets[n].clusters[c].pads.iter().map(|&p| format!("{} @({:.2},{:.2})", r.board.pads[p].key, r.board.pads[p].x, r.board.pads[p].y)).collect();+ println!(" group {} : {}", r.comp_of(n, c), keys.join(" "));+ }+ }+ }+}
crates/aiflow-router/src/drive.rs+1−1@@ -1221,7 +1221,7 @@ impl Router { // a zone of a net the plan still tracks, with no fill in the board file: once refilled it // may join those pads itself, so say so instead of tracking across it in silence (issue #84) let zones = self.board.zones();- let islands: usize = zones.iter().map(|z| z.fills.len()).sum();+ let islands = self.board.copper_fills().len(); let mut warnings: Vec<String> = Vec::new(); for net in &self.nets { if net.done_by_board || net.routes.is_empty() { continue; }
docs/branch-current-screen.md+28−3@@ -1,11 +1,36 @@ # Explicit branch-current screening -A voltage-sense branch must not inherit the entire net's load current. For a saved, unpoured two-layer net, a load can opt into `"currentModel": "resistive-branches"` and declare signed `terminals` with `reference`, `pad`, and `amps`. Positive current enters copper; negative current leaves it. Declare every pad on that net, including zero-current test pads, and balance both source and sink totals to the load's `amps`.+A voltage-sense branch must not inherit the entire net's load current. For a saved, unpoured net, a load can opt into `"currentModel": "resistive-branches"` and declare signed `terminals` with `reference`, `pad`, and `amps`. Positive current enters copper; negative current leaves it. Declare every pad on that net, including zero-current test pads, and balance both source and sink totals to the load's `amps`. -The spec must state `copperUm` and `branchCurrentModel` with `boardThicknessMm`, `barrelPlatingUm` and `maxBarrelDensityAperMm2`. These are engineering assumptions that require fabrication verification. Barrel plating must currently be 25 um to match the paired Fields solver; other plating is refused until that solver supports it. The screen constructs a resistive track/barrel graph, applies the declared terminal currents, checks Kirchhoff residuals, estimates each track's IPC-2221 rise at its calculated branch current and checks barrel density. It evaluates every layer-injection extreme for up to four active through-hole terminals. A narrow main series segment still fails; a lightly loaded sense branch is evaluated at its own current.+The spec must state `copperUm` and `branchCurrentModel` with `boardThicknessMm`, `barrelPlatingUm` and `maxBarrelDensityAperMm2`. These are engineering assumptions that require fabrication verification. Barrel plating must currently be 25 um to match the paired Fields solver; other plating is refused until that solver supports it. The screen constructs a resistive track/barrel graph, applies the declared terminal currents, checks Kirchhoff residuals, estimates each track's IPC-2221 rise at its calculated branch current and checks barrel density. It evaluates every layer-injection extreme (the F.Cu and B.Cu ends) for up to four active through-hole terminals. A narrow main series segment still fails; a lightly loaded sense branch is evaluated at its own current.++## Multilayer boards (0.2.23, issue #107)++The model runs on any F.Cu, In1.Cu .. InN.Cu, B.Cu stack. Planes and pours of other nets on inner layers do not matter to it.++- A net whose tracks are all on F.Cu and B.Cu, joined by through vias and through pads, is modelled exactly as on a two-layer board: each barrel spans the declared `boardThicknessMm`.+- A net with tracks on an inner layer needs the board's own physical stackup (Board Setup > Physical Stackup, saved in the board file with every copper and dielectric thickness). Each barrel is split into pieces between the layers the net uses, each piece as long as the stackup puts those layers apart, scaled to `boardThicknessMm`. Inner-layer tracks are judged on IPC-2221's internal curve (half the external constant). The stackup copper on every modelled layer must equal `copperUm`; mixed copper weights are refused.+- Refused, with the reason named: inner-layer copper without a stackup, a pour or plane on the net itself (on any layer), blind, buried or micro vias on the net.++The paired Fields solve must model the same copper stack: aiflow compares Fields' `board.copperLayers` (adom-fields 0.10; older reports: `board.layers`) with the saved board's copper layers. Only actual saved-board tracks may use this model. Planned copper must be landed and adopted first. Pours, unsupported layers/pad shapes/vias/arcs, overlapping collinear tracks, unsegmented interior crossings, missing declarations and disconnected graphs refuse the branch result. Existing full-net width results remain attached for comparison. Other nets retain the existing conservative screen. -This is a nominal centerline resistor model with equipotential central pad regions. It does not resolve pad necks, contact resistance, copper overlap redistribution, package contacts, temperature-dependent conductivity or transient heating. An independent filled-copper Fields solve remains mandatory, with exact input hashes, finite converged results, full specified current, explicit matching terminals and the actual two-layer stack. Current density is independently checked against `maxCurrentDensityAperMm2` (60 A/mm² default), even if a Fields invocation suppressed its neck issue by using a larger threshold.+This is a nominal centerline resistor model with equipotential central pad regions. It does not resolve pad necks, contact resistance, copper overlap redistribution, package contacts, temperature-dependent conductivity or transient heating. An independent filled-copper Fields solve remains mandatory, with exact input hashes, finite converged results, full specified current, explicit matching terminals and the actual copper stack. Current density is independently checked against `maxCurrentDensityAperMm2` (60 A/mm² default), even if a Fields invocation suppressed its neck issue by using a larger threshold.++## The whole-net screen, pours and the Fields solve (issue #107)++Without the branch model, `analyze current` judges the narrowest track over 0.2 mm on each loaded net at the full load (IPC-2221, external curve).++**A pour passes only where it carries the current.** A pour on the net used to pass the screen by existing anywhere on the net. Now it counts only when either:++1. every track the screen would fail at the full load lies inside a filled zone of the same net on its own layer (a point within 0.5 mm of one of the net's own pads or vias counts, for the thermal-relief gap), or+2. one pour (fill islands joined by the net's own vias and through pads) reaches every terminal of the net, so the narrow tracks run beside it.++The terminals are every pad of the net except the pads `loads.<net>.terminals` declares at zero amps and the pads of parts that cannot pass load current by the spec's own declaration: a part whose other pads are all on this net, on no net, or on nets with no load in `spec.loads` that are not ground by name (a test point, a feedback divider, a bootstrap or feed-forward capacitor). A part bridging to a ground net, or to a net the spec loads, stays a terminal. A zone saved unfilled is counted by its outline, and the result says so. Otherwise the net fails with the uncovered piece, the terminals no pour reaches, and the ways out: extend the pour, widen the copper, declare the zero-current pads, or solve with Fields.++**With a same-input Fields solve, its solved rise judges the width approximation.** When the Fields report passes the same-input field screen (exact board and spec hashes, converged at the full declared current, density and barrel limits), each net that failed only the width approximation (`failure: bottleneck` or `pour-coverage`) is judged by `review.checks.ampacity` (adom-fields 0.9 or later): the solved temperature rise at the net's narrowest neck, at the full current. The net passes when that rise plus a 0.05 C rounding allowance is within its `maxRiseC`. The result says `judgedBy: fields-solve`, keeps the original screen as `ipcWholeNetScreen` and the Fields row as `fieldsAmpacity`. A solved rise over the limit keeps the failure and adds the number. Missing copper and too few stitching vias are not width questions and are never judged this way.++The solved rise is the board temperature at that neck with every heat source on (a hot part next to the neck counts), so it is never lower than the copper's own heating: a pass on it is conservative. It is one point per net, the neck where the solved current density peaks; Fields' density map shows the rest. Without a solve, the heuristic stays the verdict. Do not infer a hardware rating or a thermal pass. A model passing under stated assumptions does not validate those assumptions. A software release or an isolated passing run does not close the live project gates; review its actual boundary conditions, native state and complete deliverables separately.
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.23",+ "version": "0.2.24", "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.23",+ "version": "0.2.24", "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+5−2@@ -300,6 +300,8 @@ Clip checks and cuts need ffmpeg and ffprobe. When either is missing, every clip No recording may outlive an hour (a hard cap on every recording), `finish` and `deliver` stop the running clip, and every command stops a clip that has run past twice its step's budget: a recorder left running is a mistake, not evidence. +The first `step` (or `capture start`) asks the test box once which recorder its Adom Bridge has, with a read-only `desktop_record_status {}`, and keeps the answer in the run (`recorder` in run.json). A box whose Adom Bridge does not know the recorder verbs (an Adom Bridge before 2.1.35, such as 1.9.224 on macOS, which offers only `desktop_record_window_start` and `desktop_record_window_status`) records no clips: the run says "no recorder on <box>" once, starts no take (a take there could never be stopped), and each step keeps its start and end screenshots. The clips page and `deliver` say the same line once instead of a missing clip per step. `capture start` and the walkthrough tours refuse on such a box. An unclear answer (the box offline, a timeout) is not kept: the step records as before and asks again next time.+ `capture open` puts the board on screen the same way for every engine (foreground OpenGL on a test box we own, maximised, zoom to fit). `capture start` records the PCB editor window itself through Adom Bridge's window recorder (Windows Graphics Capture, background-capturable: whatever another thread or an updater puts in front of KiCad on a shared box does not reach the take, and nobody at the box is disturbed; the monitor is recorded only when no editor is open), every stage command writes a marker with its timestamp into the manifest, `capture mark <label>` adds yours, `capture stop` pulls the MP4 into the run. The split-screen comparison (gang-takes split mode) aligns the two engines on those markers. ## The spec@@ -503,7 +505,7 @@ Refill acknowledgement is asynchronous. Request it once, then read without refil ## Explicit branch currents -When a narrow sense or parallel branch causes a full-net width warning, read `docs/branch-current-screen.md`. Do not widen a signal branch blindly or waive the current gate. The optional resistive-branches model requires explicit balanced pin currents, every pad accounted for, stated copper/barrel assumptions and actual saved-board geometry. It still requires a matching, converged filled-copper Fields solve with the same explicit terminals. Test genuine narrow-main-path failures and preserve original width results. Prototype assumptions are not fabrication measurements or hardware qualification.+When a narrow sense or parallel branch causes a full-net width warning, read `docs/branch-current-screen.md`. Do not widen a signal branch blindly or waive the current gate. The optional resistive-branches model requires explicit balanced pin currents, every pad accounted for, stated copper/barrel assumptions and actual saved-board geometry. It still requires a matching, converged filled-copper Fields solve with the same explicit terminals. Test genuine narrow-main-path failures and preserve original width results. Prototype assumptions are not fabrication measurements or hardware qualification. Since 0.2.23 (issue #107) the branch model runs on multilayer boards (outer-layer nets as on two layers; inner-layer tracks need the board's saved physical stackup). A pour passes the whole-net screen only when it covers the narrow tracks on their layer or one pour reaches every terminal of the net (declare zero-current pads in `loads.<net>.terminals`). With a same-input Fields solve that passes the field screen, a net that failed only the width approximation is judged by the solved rise at its narrowest neck (`review.checks.ampacity`), recorded as `judgedBy: fields-solve`. ### Unclipped Fields current maxima @@ -598,7 +600,8 @@ A drilled pad with no copper layer (an NPTH that lists only `*.Mask`, such as a - The heatsink via planner (`thermal-vias plan`) and the small-pin ring of `thermalVias` keep every via clear of visible silkscreen on both sides: board and footprint text (the printed text's extent) and silk graphics, by `silkClearance` (default 0.1 mm). A part short of its target says how many spots the silk took; move the label to free them. - A `stitchVias` entry takes `size` and `drill` in mm. Without them it gets the spec's `viaSignal` (and `drillSignal`), else the board's most common via, else 0.6 / 0.3 mm (it was 0.8 / 0.4). - Via-to-via copper clearance and the hole-to-hole minimum come from the spec (`viaClearance`, else `clearance`; `holeToHole`); when the spec names neither, from the project's `.kicad_dru` beside the board (its unconditioned or via-only clearance and hole_to_hole rules, the last one winning as in KiCad); else 0.2 mm. This applies to `land vias`, `pour` and `thermal-vias plan`; zones keep `clearance`.-- A pad number used twice on one footprint (a connector's two `MP` pads, a ring drawn as two custom pads) is ambiguous as a key. The router writes such a pad as its coordinates, and the gate and `land route` both replace an ambiguous key in any plan by the coordinates of the pad nearest the path's neighbouring point (a note says so), so they land on the same pad. `route` also keeps the two pads apart as clusters, joins existing copper that crosses a custom pad's polygon anywhere along a track, and treats overlapping pads of one footprint and net as one piece of copper.+- A pad number used twice on one footprint (a connector's two `MP` pads, a ring drawn as two custom pads) is ambiguous as a key. The router writes such a pad as its coordinates, and the gate and `land route` both replace an ambiguous key in any plan by the coordinates of the pad nearest the path's neighbouring point (a note says so), so they land on the same pad. `route` also keeps the two pads apart as clusters, joins existing copper that crosses a custom pad's polygon anywhere along a track, and treats overlapping pads of one net as one piece of copper.+- `route` reads the board's own copper the way KiCad's connectivity does (issue #103): two items of one net join when their copper overlaps on a layer they share, so a track ending inside another track's width, a track end on a via's annulus off its centre, collinear overlapping pieces, overlapping zone fills of one net and a pad's real outline all count. It reads every copper layer for this (inner-layer tracks and plane fills joined through through-hole pads and vias; blind and buried vias only on the layers they span), stamps only F.Cu and B.Cu, and keeps the router's vias clear of inner-layer tracks of other nets (a long track counts along its whole length). A pad's angle in the board file is absolute and 0 when left out, also in a rotated footprint. On a fully routed board `route` writes an empty plan; the nets it still plans are the ones KiCad's DRC lists as unconnected. - An edge connector may overhang the outline on purpose: spec `placement.edgeOverhang: [{"ref": "J13", "why": "USB-C mouth proud of the edge"}]` or `--allow-outside J13` on `place check`, `place land` and `land moves`. `place check` reports it and no longer lists the part as outside; `place land` and `land moves` send `allowOutside: true` to kicad_move_footprint for those parts only. Any pad or footprint copper outside the outline is still refused before anything lands; edge clearance stays a DRC matter. A refusal with no `offendingRefs` names the part being moved. **Edge connectors, heatsink via count, lost zones, switch nodes and zones-only adoption (0.2.23).**
skills/aiflow-live-clips/SKILL.md+2@@ -37,6 +37,8 @@ When a clip stops (`step <next>`, `capture stop`, `finish`, `deliver`, or the cl ## 3. Mechanics that bite +- **A box with no recorder** (an Adom Bridge before 2.1.35, such as 1.9.224 on macOS): the first `step` says "no recorder on <box>" once and the run takes no clips, only each step's start and end screenshots. The clips page says so once above its table; there is nothing to note per step. Do not start takes by hand there: the box has no verb that stops them.+ - **Publish with `adom-wiki repo push --files`**, not the files blob API (it does not update the git repo the Files tab reads). - **Media paths are repo-root-relative**, even inside `docs/clips.md`: `docs/clips/<stem>-10x.mp4`, `docs/clips/<stem>-sheet.png`. The files viewer prepends `files/` to the ref as written; a path relative to the doc's own folder (`clips/x.mp4`) points at the wrong place. Use the same form for plain links to the action cuts. - **Every video gets a poster.** The wiki renders `<video>` with `preload="none"`, so a clip without a `poster` is an empty black box until clicked and the page looks broken. `clips` writes `<stem>-poster.jpg` (a frame 40 percent into the 10x cut) and emits `poster="docs/clips/<stem>-poster.jpg" preload="metadata"`; do the same for any video you embed by hand (the README's build video too). Check by looking: every video shows a still before it is played.
skills/aiflow-power-layout/SKILL.md+2@@ -68,6 +68,8 @@ adom-fields analyze --board <current saved board> --spec aiflow/spec.json --out adom-aiflow --ai-thread "<t>" analyze current --fields <run>/fields/fields.json ``` +Two things changed in 0.2.23 (issue #107). A pour passes the width screen only when it covers the net's narrow tracks on their layer, or one pour reaches every terminal of the net: a small patch beside the neck no longer passes, and a sense tap to a part with no load on its other side (feedback divider, bootstrap cap, test point) needs no cover. And the passed same-input solve judges a net that failed only the width approximation by the solved rise at its narrowest neck (`judgedBy: fields-solve`); that rise includes the heat of nearby parts, so a neck beside a hot IC may still fail it. Read `docs/branch-current-screen.md`.+ On a converter the solve must model it: `fields.json` `review.converter` carries the hot loop, the emissions margin and the ripple. When it is null (no switching node, frequency, inductor, rails or output load) or `review.pdn.rails` checked no rail, `tour fields` and `finish` refuse; fix the spec's `voltages`/`sources`/`switching` (and `loads`) and solve again. The first solve showed 215 A/mm2 at the SW pin and U1 at +51 C (Kelvin keepouts had fenced its GND pin off the pour, no thermal vias). Back to pours (`step pours --back --why "..."`): SW patch, solid GND and vias at U1.1, keepouts on the sense layer: 60 A/mm2 at the patch throat. Opened the throat: 50 A/mm2. Each pass: change the spec, `pour`, `land pours`, `measure`, solve, analyze. Draw the final map and register it (`artifact --kind analysis-image`).