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
AI Flow 0.2.62: fixes #190 #191 #188 #189 #186 #187 and the #177 remote-model remainder
Co-Authored-By: Claude Opus 5.5 <[email protected]>
18 files changed
+304−47
Cargo.lock+10−10Cargo.toml+1−1bin/adom-aiflowcrates/adom-aiflow/src/checks.rs+2−2crates/adom-aiflow/src/main.rs+30−6crates/adom-aiflow/src/pourverify.rs+24−3crates/aiflow-analyze/src/branches.rs+12−5crates/aiflow-analyze/src/lib.rs+49−2crates/aiflow-analyze/src/pour_cover.rs+24−3crates/aiflow-pours/src/lib.rs+28−1docs/release-0.2.62.md+26package.json+1−1page.json+1−1skills/adom-aiflow/SKILL.md+3−3tools/kicad-cli-remote+22−3tools/laser-etch/mark_geom.py+18−3tools/laser-etch/test_reading_axis.py+40tools/test-kicad-cli-remote.py+13−3Cargo.lock+10−10@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow"-version = "0.2.61"+version = "0.2.62" dependencies = [ "aiflow-analyze", "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [ [[package]] name = "aiflow-analyze"-version = "0.2.61"+version = "0.2.62" dependencies = [ "aiflow-board", "serde",@@ -35,7 +35,7 @@ dependencies = [ [[package]] name = "aiflow-board"-version = "0.2.61"+version = "0.2.62" dependencies = [ "roxmltree", "serde",@@ -44,7 +44,7 @@ dependencies = [ [[package]] name = "aiflow-bridge"-version = "0.2.61"+version = "0.2.62" dependencies = [ "aiflow-board", "serde",@@ -53,7 +53,7 @@ dependencies = [ [[package]] name = "aiflow-copper"-version = "0.2.61"+version = "0.2.62" dependencies = [ "aiflow-board", "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [ [[package]] name = "aiflow-grid"-version = "0.2.61"+version = "0.2.62" dependencies = [ "aiflow-board", "serde",@@ -72,7 +72,7 @@ dependencies = [ [[package]] name = "aiflow-place"-version = "0.2.61"+version = "0.2.62" dependencies = [ "aiflow-board", "serde",@@ -81,7 +81,7 @@ dependencies = [ [[package]] name = "aiflow-pours"-version = "0.2.61"+version = "0.2.62" dependencies = [ "aiflow-board", "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [ [[package]] name = "aiflow-router"-version = "0.2.61"+version = "0.2.62" dependencies = [ "aiflow-board", "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [ [[package]] name = "aiflow-run"-version = "0.2.61"+version = "0.2.62" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.2.61"+version = "0.2.62" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
bin/adom-aiflow⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/checks.rs+2−2@@ -350,7 +350,7 @@ pub fn molecule_check_bom(b: &Board, spec: &Value, before: Option<&Board>, bom: moved_refs.push(r); } } else {- f.error(format!("fixed reference {r} missing from current or starting board"), "Restore the reference and its reviewed interface binding; absence is not an unchanged position.");+ f.error(format!("fixed reference {r} missing from current or starting board"), "Restore the reference and its reviewed interface binding; absence is not an unchanged position. If the human changed the interface, record it: `prompt --text \"<their words>\" --rebaseline <new board>`."); } } // A rename in place is not a move (the molecule rule itself asks for one when the start board@@ -369,7 +369,7 @@ pub fn molecule_check_bom(b: &Board, spec: &Value, before: Option<&Board>, bom: }); if moved.is_empty() { f.ok(format!("{} fixed references match explicit positions or starting-board locks; anchor and rotations retained", refs.len())); } else if rename_only { f.ok(format!("{} fixed references renamed in place, nothing moved (same footprints, positions and rotations as the start board): {}", renamed.len(), renamed.join("; "))); }- else { f.error(format!("fixed references moved since the run started: {}", moved.join("; ")), "Put them back where the start board had them (spec.fixedRefs); placement must not move the molecule interface."); }+ else { f.error(format!("fixed references moved since the run started: {}", moved.join("; ")), "Put them back where the start board had them (spec.fixedRefs); placement must not move the molecule interface. If the human changed the interface, record it: `prompt --text \"<their words>\" --rebaseline <new board>`."); } } pin_class_check(b, spec, &m, bom, &mut f); f
crates/adom-aiflow/src/main.rs+30−6@@ -340,7 +340,10 @@ enum Cmd { /// Where the run stands Status, /// A new human prompt on this run (a follow-up: "add the walkthroughs", "recut the video"): the AI's time for it counts from here to its `done`. --at is the paste time when you start late.- Prompt { #[arg(long)] text: String, #[arg(long)] at: Option<String> },+ Prompt { #[arg(long)] text: String, #[arg(long)] at: Option<String>,+ /// The human changed the requirements and the molecule interface with them (issue #187): this board's+ /// fixed references (machine pins, contacts) become the run's baseline, in the same run, clock and ledger+ #[arg(long)] rebaseline: Option<String> }, /// Stop session accounting after the requested scope is complete or a genuine blocking handoff. This does not complete the engineering run. Done { #[arg(long)] message: String, /// JSON evidence of an explicit user pause or an unavoidable external dependency. Never a gate waiver.@@ -825,9 +828,18 @@ fn clips_page_of(r: &Run) -> Option<(String, Option<String>)> { pagedir::clips_p /// `push`, put it and the changed clip media on the page (`adom-wiki repo push --files`, batches of /// about 20 MB). Refuses to push a page with an em-dash or a match of $AIFLOW_BANNED_RE. Returns /// (what happened, the files).+/// A captured window title in plain text: KiCad's em-dash separator becomes " - ".+fn plain_title(t: &str) -> String { t.replace(" \u{2014} ", " - ").replace('\u{2014}', "-") }+ fn publish_clips(r: &Run, dir: &Path, page: &str, page_dir: Option<&str>, push: bool) -> Result<(String, Vec<String>), String> { let notes: Value = std::fs::read_to_string(dir.join("clip-notes.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);- let (md, media) = preboard::clips_page(&r.data, dir, ¬es, &now(), Some(page), page_dir);+ // issue #186: KiCad writes its window titles with an em-dash ("board \u{2014} PCB Editor"); a recorded+ // camera title is captured text, not prose, so it is cleaned here (older runs) as well as when recorded+ let mut data = r.data.clone();+ for c in data["captures"].as_array_mut().into_iter().flatten() {+ if let Some(t) = c["camera"].as_str().map(plain_title) { c["camera"] = json!(t); }+ }+ let (md, media) = preboard::clips_page(&data, dir, ¬es, &now(), Some(page), page_dir); let stage = dir.join("clips-page"); let index_doc = pagedir::join(page_dir, "docs/clips.md"); std::fs::create_dir_all(stage.join(pagedir::join(page_dir, "docs/clips"))).map_err(|e| format!("{}: {e}", stage.display()))?;@@ -2985,8 +2997,10 @@ fn export_sch_netlist(sch: &str, out: &Path) -> Result<String, String> { Ok(cli) } -/// The run's first board (what `start` copied): the reference for fixed parts.+/// The reference for fixed parts: the run's first board (what `start` copied), or the board a human-approved+/// interface change re-baselined to (`prompt --rebaseline`, issue #187). fn first_board(r: &Run) -> Option<Board> {+ if let Some(b) = r.data["interfaceBaseline"]["board"].as_str().and_then(|p| Board::load(p).ok()) { return Some(b); } r.data["boards"].as_array().and_then(|b| b.first()).and_then(|b| b.as_str()).and_then(|p| Board::load(p).ok()) } @@ -5692,13 +5706,23 @@ fn main() { } ok(&format!("comparison page built: {url}{}", if notes.is_empty() { String::new() } else { format!(" ({})", notes.join("; ")) }), &["Link it from the page's README so people find it; the two run pages are linked from it.".into()]); }- Cmd::Prompt { text, at } => {+ Cmd::Prompt { text, at, rebaseline } => { thread(&cli); let mut r = load_run(&cli); let at = at.clone().unwrap_or_else(now); if aiflow_run::parse(&at).is_none() { err(&format!("--at {at} is not a time (RFC 3339, e.g. 2026-09-14T15:04:05Z)"), &[]); } let n = r.sessions().len() + 1; r.log("prompt", json!({"n": n, "text": text, "at": at}));+ if let Some(b) = rebaseline {+ if let Err(e) = Board::load(b) { err(&format!("--rebaseline {b}: {e}"), &[]); }+ // a copy in the run folder, so later edits to the board do not move the baseline+ let keep = dir.join(format!("interface-baseline-{n}.kicad_pcb"));+ if let Err(e) = std::fs::copy(b, &keep) { err(&format!("--rebaseline: copy {b} to {}: {e}", keep.display()), &[]); }+ let keep = std::fs::canonicalize(&keep).unwrap_or(keep);+ r.data["interfaceBaseline"] = json!({"board": keep.display().to_string(), "from": b, "prompt": n, "why": text, "at": at});+ r.log("interface-rebaseline", json!({"prompt": n, "board": keep, "from": b, "why": text}));+ note(&format!("the molecule interface baseline is now {b} (copied to {}): the fixed references are checked against it from here", keep.display()), &["The change is the human's, recorded with their words in this prompt; the run, its clock and its ledger carry on.".into()]);+ } r.data["clock"]["lastPromptTime"] = json!(at); // issue #171: a new prompt resumes the run: a recorded stop no longer holds r.data["stopEvidence"] = Value::Null; // null, not removed: Run::save keeps keys missing from memory@@ -7024,9 +7048,9 @@ fn main() { // every visit is its own clip: placement-1, then placement-2 when a later step sends the AI back let visit = r.data["steps"][name]["visits"].as_array().map(|a| a.len()).unwrap_or(1); r.data["captures"].as_array_mut().unwrap().push(json!({"recordingId": rid, "start": now(), "kind": "window", "hwnd": h, "step": name, "visit": visit, "back": *back, "why": why}));- if let Some(clipwatch::Camera::Window { what, .. }) = &pre_cam { if let Some(c) = r.data["captures"].as_array_mut().and_then(|a| a.last_mut()) { c["camera"] = json!(what); } clip_note.push_str(&format!("filming {what}; ")); }+ if let Some(clipwatch::Camera::Window { what, .. }) = &pre_cam { if let Some(c) = r.data["captures"].as_array_mut().and_then(|a| a.last_mut()) { c["camera"] = json!(plain_title(what)); } clip_note.push_str(&format!("filming {what}; ")); } // issue #155: a clip on a KiCad editor says which one, so a wrong window shows at once- if pre_cam.is_none() && cam.id() == "kicad" { if let Some(t) = aiflow_bridge::editor_title(&br.raw_call("kicad_window_info", &json!({})), h) { if let Some(c) = r.data["captures"].as_array_mut().and_then(|a| a.last_mut()) { c["camera"] = json!(t); } clip_note.push_str(&format!("filming KiCad's \"{t}\"; ")); } }+ if pre_cam.is_none() && cam.id() == "kicad" { if let Some(t) = aiflow_bridge::editor_title(&br.raw_call("kicad_window_info", &json!({})), h) { if let Some(c) = r.data["captures"].as_array_mut().and_then(|a| a.last_mut()) { c["camera"] = json!(plain_title(&t)); } clip_note.push_str(&format!("filming KiCad's \"{t}\"; ")); } } r.mark(&format!("clip-start:{name}-{visit}")); let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string(); if let Some(pth) = br.screenshot_window(h, &save_to, &format!("shot-{name}-{visit}-start")) {
crates/adom-aiflow/src/pourverify.rs+24−3@@ -436,13 +436,17 @@ pub fn check_with(board: &Board, plan: &Value, keepouts_landed: bool) -> Result< } groups.push(json!({"name":label,"boardZoneIndex":i,"boardZoneUuid":zone_id(n),"ok":true})); }- // No hidden extra copper or keepout: every saved zone needs an explicit plan entry.+ // No hidden extra copper: every saved copper zone needs an explicit plan entry. A keepout rule area the+ // plan does not own (issue #189: drawn by the human to keep copper off a spot) is theirs: it adds no copper,+ // so it is recorded, not failed.+ let mut board_keepouts = Vec::new(); for (i, n) in actual.iter().enumerate().filter(|(i, _)| !used.contains(i)) {- failed.push(format!("unplanned board zone {i}: {}", zone_name(n)));+ if n.find("keepout").is_some() { board_keepouts.push(json!({"boardZoneIndex": i, "name": zone_name(n)})); }+ else { failed.push(format!("unplanned board zone {i}: {}", zone_name(n))); } } Ok( json!({"mode":"verified-existing","pass":failed.is_empty(),"failed":failed,"groups":groups,"differences":report,- "keepoutsCheckedClear":cleared,+ "keepoutsCheckedClear":cleared, "boardKeepoutsNotInPlan":board_keepouts, "ownershipGranted":false,"scope":if keepouts_landed { "saved filled polygons and planned zone geometry/settings; native DRC, measured copper and electrical/thermal checks remain separate" } else { "saved filled polygons and planned zone geometry/settings; keepouts (not landed by this tool) checked clear of saved pour fill; native DRC, measured copper and electrical/thermal checks remain separate" }}), ) }@@ -1013,6 +1017,23 @@ mod tests { assert!(check(&board,&q).is_err()); } #[test]+ fn a_human_keepout_outside_the_plan_is_recorded_not_failed() {+ // issue #189: a rule area (no copper) the human drew to keep copper off a spot+ let (b, p) = fixture();+ let keepout = r#"(zone (net 0) (net_name "") (layers "F.Cu" "B.Cu") (name "ESC tether") (hatch edge 0.5) (keepout (tracks not_allowed) (vias not_allowed) (pads not_allowed) (copperpour not_allowed) (footprints allowed)) (polygon (pts (xy 8 8) (xy 9 8) (xy 9 9) (xy 8 9))))"#;+ let mut t = b.text[..b.text.len() - 1].to_string(); t.push_str(keepout); t.push(')');+ let r = check(&Board::from_text("test", t).unwrap(), &p).unwrap();+ assert_eq!(r["pass"], true, "{r}");+ assert_eq!(r["boardKeepoutsNotInPlan"][0]["name"], "ESC tether");+ // unplanned copper still fails+ let n = b.root.find("zone").unwrap();+ let copper = b.text[n.start..n.end].replace(&zone_id(n).unwrap(), "extra-copper-zone");+ let mut t = b.text[..b.text.len() - 1].to_string(); t.push_str(&copper); t.push(')');+ let r = check(&Board::from_text("test", t).unwrap(), &p).unwrap();+ assert_eq!(r["pass"], false);+ assert!(r["failed"].to_string().contains("unplanned board zone"), "{r}");+ }+ #[test] fn native_hole_loops_are_verified_without_flattening() { let (b, mut p) = fixture(); let hole = "(polygon (pts (xy 1 1) (xy 2 1) (xy 2 2)))";
crates/aiflow-analyze/src/branches.rs+12−5@@ -513,12 +513,19 @@ pub fn screen(board: &Board, net: &str, load: &Value, spec: &Value) -> Result<Va worst = worst.max(rise); r["riseEstimateC"] = json!(rise); } else {- let density = current.abs()- / e.meta["barrelAreaMm2"]- .as_f64()- .ok_or("missing barrel area")?;- peak_barrel = peak_barrel.max(density);+ let area = e.meta["barrelAreaMm2"].as_f64().ok_or("missing barrel area")?;+ // issue #191: a machine contact or pin hole carries an inserted conductor (spec.contacts.<REF>.insertMm2)+ let rf = e.meta["reference"].as_str().unwrap_or("");+ let insert = spec["contacts"][rf]["insertMm2"].as_f64().filter(|v| v.is_finite() && *v > 0.0);+ let machine = (rf.starts_with("MC") || rf.starts_with("MP")) && rf.len() > 2 && rf[2..].chars().all(|c| c.is_ascii_digit());+ let density = current.abs() / (area + insert.unwrap_or(0.0)); r["barrelDensityAperMm2"] = json!(density);+ if let Some(i) = insert { r["insertMm2"] = json!(i); }+ if machine && insert.is_none() {+ r["insertedConductor"] = json!("machine contact or pin: an inserted conductor the model does not include; not screened on plating alone (declare spec.contacts.<REF>.insertMm2 to screen it)");+ } else {+ peak_barrel = peak_barrel.max(density);+ } } conductors.push(r); }
crates/aiflow-analyze/src/lib.rs+49−2@@ -457,6 +457,15 @@ pub fn field_screen_with_acks(fields: &Value, spec: &Value, board_bytes: &[u8], if connections.len() as u64!=expected {return Err(format!("Fields {name} omitted plated connections"));} let barrel_limit=spec["branchCurrentModel"]["maxBarrelDensityAperMm2"].as_f64().unwrap_or(density_limit); if !barrel_limit.is_finite() || barrel_limit<=0.0 {return Err("barrel density limit must be positive".into());}+ // issue #191: a machine contact or pin hole carries an inserted conductor (a battery lead, a needle)+ // the solver does not model, so its plating alone is not the current path+ let holes: Vec<(String, f64, f64)> = aiflow_board::Board::from_text("screen.kicad_pcb", String::from_utf8_lossy(board_bytes).to_string())+ .map(|b| b.pads.iter().filter(|p| p.thru).map(|p| (p.reference.clone(), p.x, p.y)).collect()).unwrap_or_default();+ let hole_ref = |c: &Value| -> Option<String> {+ let (x, y) = (c["at"][0].as_f64()?, c["at"][1].as_f64()?);+ holes.iter().find(|(_, hx, hy)| (hx - x).abs() < 0.01 && (hy - y).abs() < 0.01).map(|(r, _, _)| r.clone())+ };+ let machine = |r: &str| (r.starts_with("MC") || r.starts_with("MP")) && r.len() > 2 && r[2..].chars().all(|c| c.is_ascii_digit()); for c in connections { let amps=c["amps"].as_f64().filter(|v|v.is_finite()); let area=c["barrelAreaMm2"].as_f64().filter(|v|v.is_finite() && *v>0.0);@@ -467,8 +476,23 @@ pub fn field_screen_with_acks(fields: &Value, spec: &Value, board_bytes: &[u8], return Err(format!("Fields {name} has an incomplete plated-connection solve")); } let actual=amps.unwrap().abs()/area.unwrap();- if (actual-density.unwrap()).abs()>1e-8*actual.max(1.0) || actual>barrel_limit {- return Err(format!("Fields {name} barrel density is inconsistent or exceeds {barrel_limit} A/mm2"));+ if (actual-density.unwrap()).abs()>1e-8*actual.max(1.0) {+ return Err(format!("Fields {name} barrel density is inconsistent (|amps|/area differs from meanBarrelJ)"));+ }+ if actual>barrel_limit {+ let r = hole_ref(c);+ let insert = r.as_deref().and_then(|r| spec["contacts"][r]["insertMm2"].as_f64()).filter(|v| v.is_finite() && *v > 0.0);+ match (r, insert) {+ (Some(r), Some(ins)) => {+ let with = amps.unwrap().abs() / (area.unwrap() + ins);+ if with > barrel_limit { return Err(format!("Fields {name} carries {:.1} A through {r}: {with:.1} A/mm2 over plating plus the declared {ins} mm2 insert, above {barrel_limit} A/mm2", amps.unwrap().abs())); }+ covered.push(json!({"net": name, "peakJ": with, "at": c["at"], "layer": format!("{}-{}", c["from"].as_str().unwrap_or(""), c["to"].as_str().unwrap_or("")), "ack": r, "why": format!("barrel plus the declared {ins} mm2 inserted conductor (spec.contacts.{r}.insertMm2)")}));+ }+ (Some(r), None) if machine(&r) => {+ covered.push(json!({"net": name, "peakJ": actual, "at": c["at"], "layer": format!("{}-{}", c["from"].as_str().unwrap_or(""), c["to"].as_str().unwrap_or("")), "ack": r, "why": format!("machine contact or pin hole: it carries an inserted conductor the solver does not model, so plating alone ({actual:.0} A/mm2) is not the current path; declare spec.contacts.{r}.insertMm2 to screen it with the conductor")}));+ }+ (r, _) => return Err(format!("Fields {name} barrel density exceeds {barrel_limit} A/mm2 ({actual:.1} A/mm2 in the hole at ({:.2}, {:.2}){}); a hole that carries an inserted conductor takes spec.contacts.<REF>.insertMm2", c["at"][0].as_f64().unwrap_or(f64::NAN), c["at"][1].as_f64().unwrap_or(f64::NAN), r.map(|r| format!(", {r}")).unwrap_or_default())),+ } } } if value["currentModel"] == "resistive-branches" {@@ -535,6 +559,29 @@ mod field_screen_tests { let mut f=report();f["nets"][0]["layers"]=json!(["F.Cu","B.Cu"]);f["nets"][0]["vias"]=json!(1); assert!(field_screen(&f,&spec,b"board",b"spec","current").unwrap_err().contains("omitted plated")); }+ #[test]+ fn machine_contact_holes_are_not_failed_on_plating_alone() {+ // issue #191: 16 A through a 2.62 mm MachineContactLarge hole is about 77 A/mm2 of plating; the battery+ // lead inserted in it carries the current, so the screen lists it as covered instead of failing+ let board=br#"(kicad_pcb (version 20260101) (layers (0 "F.Cu" signal) (2 "B.Cu" signal)) (net 0 "") (net 1 "VCC")+ (gr_rect (start 0 0) (end 30 20) (layer "Edge.Cuts") (width 0.05))+ (footprint "Molecule:MachineContactLarge" (layer "F.Cu") (at 10 10) (property "Reference" "MC13") (pad "1" thru_hole circle (at 0 0) (size 4.4 4.4) (drill 2.62) (layers "*.Cu" "*.Mask") (net 1 "VCC")))+ (footprint "A" (layer "F.Cu") (at 20 10) (property "Reference" "J1") (pad "1" thru_hole circle (at 0 0) (size 4.4 4.4) (drill 2.62) (layers "*.Cu" "*.Mask") (net 1 "VCC"))))"#;+ let area=std::f64::consts::PI*((1.31f64+0.025).powi(2)-1.31f64.powi(2));+ let conn=|x:f64|json!({"at":[x,10.0],"from":"F.Cu","to":"B.Cu","amps":16.0,"barrelAreaMm2":area,"meanBarrelJ":16.0/area,"resolvedPerimeterFraction":1.0});+ let mut f=report();+ f["inputIdentity"]["board"]=json!(format!("{:x}",Sha256::digest(board)));+ f["nets"][0]["amps"]=json!(16);f["nets"][0]["layers"]=json!(["F.Cu","B.Cu"]);f["nets"][0]["platedPads"]=json!(1);f["nets"][0]["platedConnections"]=json!([conn(10.0)]);+ let spec=json!({"loads":{"VCC":{"amps":16}}});+ let covered=field_screen_with_acks(&f,&spec,board,b"spec","current",&[]).unwrap();+ assert!(covered.iter().any(|c|c["ack"]=="MC13" && c["why"].as_str().unwrap().contains("inserted conductor")),"{covered:?}");+ // with the 14 AWG lead declared (2.08 mm2) it is screened on plating plus the lead: 16 / 2.29 = 7 A/mm2+ let declared=json!({"loads":{"VCC":{"amps":16}},"contacts":{"MC13":{"insertMm2":2.08}}});+ assert!(field_screen_with_acks(&f,&declared,board,b"spec","current",&[]).unwrap().iter().any(|c|c["why"].as_str().unwrap().contains("2.08")));+ // the same hole on an ordinary part (J1) still fails the screen+ f["nets"][0]["platedConnections"]=json!([conn(20.0)]);+ assert!(field_screen(&f,&spec,board,b"spec","current").unwrap_err().contains("J1"));+ } fn thermal_report() -> Value { let mut f=report(); f["thermalConvergence"]=json!({"stillResidual":1e-8,"movingResidual":1e-8});
crates/aiflow-analyze/src/pour_cover.rs+24−3@@ -189,11 +189,16 @@ pub fn check(board: &Board, net: &str, spec: &serde_json::Value, amps: f64, max_ && (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)));+ // issue #190: a part with no pads on other nets (a machine contact, a machine pin) is never a side part;+ // `.all()` of nothing is true, which dropped every single-pad contact from the terminals+ let side_part = |q: &aiflow_board::Pad| {+ let mut others = board.pads.iter().filter(|o| o.reference == q.reference && o.net != net).peekable();+ others.peek().is_some() && others.all(|o| o.net.is_empty() || (!loaded(&o.net) && !ground_like(&o.net)))+ }; // issue #176: a load that declares its terminals with current names them; the heuristic is for the rest+ // a terminal that names only the reference (a single-pad contact) matches that part's pads let declared = |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().is_some_and(|a| a != 0.0)+ t["reference"].as_str() == Some(q.reference.as_str()) && (t["pad"].is_null() || t["pad"].as_str() == Some(q.name.as_str())) && t["amps"].as_f64().is_some_and(|a| a != 0.0) && (t["at"].is_null() || (t["at"][0].as_f64() == Some(q.x) && t["at"][1].as_f64() == Some(q.y))) }); let explicit = pads.iter().any(|q| declared(q));@@ -313,6 +318,22 @@ mod tests { // 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 a_single_pad_contact_is_a_terminal_not_a_side_part() {+ // issue #190: U1 becomes a one-pad machine contact; the pour joins it to J1 beside the narrow track+ let mut t = board("").text.replace(r#"(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"#, r#"(pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "V")))+ (segment"#);+ t = t.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)");+ 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();+ assert_eq!(b.pads.iter().filter(|p| p.reference == "U1").count(), 1);+ let how = check(&b, "V", &serde_json::Value::Null, 1.5, 10.0, 35.0).unwrap();+ assert!(how.contains("reaches all 2"), "{how}");+ // declared by reference only (no pad name): still the contact's terminal+ let spec = serde_json::json!({"loads": {"V": {"amps": 1.5, "terminals": [{"reference": "J1", "pad": "1", "amps": 1.5}, {"reference": "U1", "amps": -1.5}]}}});+ assert!(check(&b, "V", &spec, 1.5, 10.0, 35.0).unwrap().contains("reaches all 2"));+ } /// Issue #176: J1 to U1 on a 2 mm track (wide enough), a token V patch, and a 0.25 mm sense trace from /// U1.1 to S1.1 (S1's other pad on GND, so the heuristic takes S1.1 for a load terminal). fn sense_board() -> Board {
crates/aiflow-pours/src/lib.rs+28−1@@ -249,7 +249,21 @@ pub fn build_with(board: &Board, spec: &Value, plan: Option<&Value>, planned: &[ let edge = spec.get("edgeClearance").and_then(|v| v.as_f64()).unwrap_or(0.5); let clearance = spec.get("clearance").and_then(|v| v.as_f64()).unwrap_or(0.2); let mut zones: Vec<Value> = Vec::new();+ // issue #189: a net-less keepout in spec.pours ({"keepout": {"tracks": true, ...}, "polygon": [...], "layers": [...]})+ // goes into the plan as the rule area it is, so the plan owns the human's keepouts+ let mut spec_keepouts: Vec<Value> = Vec::new(); for e in spec.get("pours").and_then(|p| p.as_array()).cloned().unwrap_or_default() {+ if e.get("net").is_none() && e.get("keepout").is_some() {+ let poly = e.get("polygon").cloned().ok_or("a keepout in spec.pours needs a polygon ([[x, y], ...])")?;+ if poly.as_array().map(|a| a.len()).unwrap_or(0) < 3 { return Err("a keepout in spec.pours needs a polygon of three or more points".into()); }+ let layers = e.get("layers").cloned().unwrap_or(json!(["F.Cu", "B.Cu"]));+ let name = e.get("name").and_then(|n| n.as_str()).map(str::to_string).unwrap_or_else(|| format!("keepout {}", spec_keepouts.len() + 1));+ let k = e["keepout"].clone();+ let k = if k.is_object() { k } else { json!({"tracks": true, "vias": true, "copperPour": true}) };+ let k = { let mut k = k; if let Some(v) = k.get("pours").cloned() { k["copperPour"] = v; k.as_object_mut().unwrap().remove("pours"); } k };+ spec_keepouts.push(json!({"type": "keepout", "layers": layers, "polygon": poly, "keepout": k, "name": name}));+ continue;+ } let named = e.get("net").and_then(|n| n.as_str()).ok_or("pour needs net")?.to_string(); // `GND` in the spec is the board's `/GND` when that is the only match (exact names win) let Some(net) = aiflow_board::netname::resolve(&named, board.nets.keys()) else {@@ -419,7 +433,8 @@ pub fn build_with(board: &Board, spec: &Value, plan: Option<&Value>, planned: &[ holes.push((x, y, size, drill, net.to_string())); } }- let mut keepouts = 0;+ let mut keepouts = spec_keepouts.len();+ zones.extend(spec_keepouts); // the sense pad of every Kelvin pair (spec.kelvin: {"U6.3": "R32.2"}) gets its own keepout: a // pour on the same net must never touch it, or the tap measures the pour's drop instead of the // shunt's (and KiCad's DRC reports the starved thermal relief the keepout along the tap leaves)@@ -756,6 +771,18 @@ mod net_name_tests { (footprint "R" (layer "F.Cu") (at 10 10) (property "Reference" "R1") (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "/GND"))))"#.to_string()).unwrap() } #[test]+ fn a_net_less_keepout_in_the_spec_goes_into_the_plan() {+ // issue #189: the human's keepout over a tether contact, declared in spec.pours+ let spec = json!({"pours": [{"net": "GND", "outline": true, "layers": ["F.Cu"]},+ {"name": "ESC tether", "keepout": {"tracks": true, "vias": true, "pours": true}, "polygon": [[2, 2], [4, 2], [4, 4], [2, 4]], "layers": ["F.Cu", "B.Cu"]}]});+ let (zones, _, keepouts) = build(&board(), &spec, None).unwrap();+ assert_eq!(keepouts, 1);+ let k = zones.iter().find(|z| z["type"] == "keepout").unwrap();+ assert_eq!(k["name"], "ESC tether");+ assert_eq!(k["keepout"], json!({"tracks": true, "vias": true, "copperPour": true}));+ assert!(build(&board(), &json!({"pours": [{"keepout": true, "polygon": [[0, 0], [1, 1]]}]}), None).is_err());+ }+ #[test] fn spec_gnd_pours_the_board_hierarchical_gnd() { let (zones, _, _) = build(&board(), &json!({"pours": [{"net": "GND", "outline": true, "layers": ["F.Cu"]}]}), None).unwrap(); assert_eq!(zones[0]["net"], "/GND");
docs/release-0.2.62.mdadded+26@@ -0,0 +1,26 @@+# AI Flow 0.2.62++Fixes from Noah's reports on the ESC, flight-controller and nameplate runs.++- **`analyze current` keeps single-pad contacts as terminals** (#190). A machine contact or pin has no pads on+ other nets, and the side-part test (`.all()` of nothing) dropped it, so a contact-fed net could never pass the+ pour check. A terminal in `loads.<net>.terminals` may also name the reference alone.+- **The Fields barrel screen no longer fails machine-contact holes on plating alone** (#191). `MC<n>` and `MP<n>`+ holes carry an inserted conductor the solver does not model; they are listed as covered with the reason.+ `spec.contacts.<REF>.insertMm2` (14 AWG = 2.08) screens them on plating plus the conductor. The branch model+ does the same. Any other hole over the limit still fails, and now names its position and part.+- **The laser-etch check reads a near-square two-line mark the right way** (#188). The reading axis came from the+ spread of the glyph centres, which a 3 x 2 grid ("BMI" over "270") cannot settle; it now falls back to the+ glyphs' own shape (taller than wide).+- **Keepouts the human drew** (#189). `verify-pours` lists a keepout rule area the plan does not name+ (`boardKeepoutsNotInPlan`) instead of failing on it; unplanned copper still fails. `spec.pours` takes a net-less+ keepout (`{"keepout": {"tracks": true, "vias": true, "pours": true}, "polygon": [...], "layers": [...]}`) that+ `pour` puts in its plan.+- **The clips page pushes while KiCad is filmed** (#186). KiCad's window titles carry an em-dash; recorded camera+ titles are now plain (" - "), when recorded and when the page is built (older runs too).+- **A human interface change continues in the same run** (#187). `prompt --text "<their words>" --rebaseline+ <board>` records the change and makes that board's fixed references (machine pins, contacts) the baseline, with+ the same clock and ledger. The fixed-reference findings point to it.+- **kicad-cli-remote carries 3rd-party models** (#177). Models under `${KICAD<N>_3RD_PARTY}` that sit in the+ project (step-export points it at `kicad/3rdparty`) travel with the board, and the variable is set to them on the+ desktop, so a STEP exported through the desktop has them.
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.61",+ "version": "0.2.62", "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.61",+ "version": "0.2.62", "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+3−3@@ -347,7 +347,7 @@ add a load only to get a solve. ## Time: sessions, not the wall clock -The AI's time on a run is the sum of its sessions: one per human prompt, from the prompt to the AI's done, with idle gaps (over 15 min, no command running) cut out. The first prompt is `start --prompt-time`; every follow-up prompt is `prompt --text "..."` (`--at <time>` when you start late); the answer's end is `done --message "..."` (`deliver` is the done of the first task). Say `done` the moment an answer is complete, so nothing idle is charged; work after a done with no `prompt` mark is listed as an unmarked follow-up, one row per idle gap. The delivery number on the page is the AI time from the first prompt to `deliver`; follow-ups have their own rows. `sessions` prints the table. The per-step table and the video's run clock ("AI TIME, THIS RUN") count active seconds only.+The AI's time on a run is the sum of its sessions: one per human prompt, from the prompt to the AI's done, with idle gaps (over 15 min, no command running) cut out. The first prompt is `start --prompt-time`; every follow-up prompt is `prompt --text "..."` (`--at <time>` when you start late; `--rebaseline <board>` when the human's change moves the molecule interface: that board's fixed references become the baseline, in the same run, clock and ledger); the answer's end is `done --message "..."` (`deliver` is the done of the first task). Say `done` the moment an answer is complete, so nothing idle is charged; work after a done with no `prompt` mark is listed as an unmarked follow-up, one row per idle gap. The delivery number on the page is the AI time from the first prompt to `deliver`; follow-ups have their own rows. `sessions` prints the table. The per-step table and the video's run clock ("AI TIME, THIS RUN") count active seconds only. ## Landing is one at a time, on purpose @@ -546,7 +546,7 @@ Custom manual stages default to AI. `take custom-stage=ai` or `take custom-stage ### Trustworthy current and thermal screens -A pour's area or presence is not proof of current capacity or acceptable temperature. `analyze current` and `analyze thermal` now require both the heuristic checks and a matching Adom Fields solve. Run `adom-fields analyze --board <current saved board> --spec <run spec> --out <run>/fields`, then `analyze current --fields <run>/fields/fields.json` and `analyze thermal --fields <run>/fields/fields.json`. Fields records SHA-256 identities for the exact inputs. Changed boards or specs invalidate the result; unsolved loaded nets or hot parts are not a pass. `finish` rechecks the evidence. This is an engineering screen under the solver's documented assumptions, not junction-temperature certification.+A pour's area or presence is not proof of current capacity or acceptable temperature. `analyze current` and `analyze thermal` now require both the heuristic checks and a matching Adom Fields solve. Run `adom-fields analyze --board <current saved board> --spec <run spec> --out <run>/fields`, then `analyze current --fields <run>/fields/fields.json` and `analyze thermal --fields <run>/fields/fields.json`. Fields records SHA-256 identities for the exact inputs. Changed boards or specs invalidate the result; unsolved loaded nets or hot parts are not a pass. `finish` rechecks the evidence. This is an engineering screen under the solver's documented assumptions, not junction-temperature certification. A machine contact or pin hole (`MC<n>`, `MP<n>`) carries an inserted conductor (a battery lead, a needle) the solver does not model, so its barrel is not failed on plating alone: the screen lists it as covered; declare the conductor with `spec.contacts.<REF>.insertMm2` (14 AWG is 2.08) to screen it on plating plus the conductor. A single-pad part (a contact) is always a load terminal, and a terminal in `loads.<net>.terminals` may name the reference alone. Placement checks compare individual native DRC findings with the baseline, not just their counts. Conservative courtyard bounding boxes guide packing but do not override native courtyard checks. Project `.kicad_pro` and `.kicad_dru` constraints follow board snapshots. When present, they require a native `kicad-cli` (optionally set `ADOM_AIFLOW_KICAD_CLI`); the single-file headless service must not silently discard them. In a container without native KiCad, `ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote` (installed with AI Flow) runs `pcb drc`, `sch erc`, `sch export netlist` and `pcb export gerbers|drill|pos` on a desktop's KiCad through Adom Bridge (`KICAD_REMOTE_TARGET` names the desktop; Windows or Linux, detected from the bridge; on Linux the job folder is `~/.adom-aiflow-kicad`, removed after each call; relative output paths are fine); a stock distro kicad-cli that lacks `pcb drc --refill-zones` is ignored. The gate refills zones natively when it has a native kicad-cli, so zone fills are KiCad's own, not the raster estimate, and the current and thermal analyses read the board's own copper when the routing plan leaves nets out (an adopted, already-routed board). Manufacturing-rule warnings, including hole spacing, remain blocking. @@ -650,7 +650,7 @@ A USB or other edge connector may intentionally project its mechanical courtyard ### Existing pours after board adoption -`verify-pours --plan <plan.json>` compares saved, filled zones with an explicit plan without landing them. Read `docs/existing-pours.md`. A failure names, per zone, the closest board zone and every field that differs: geometry, net, layer, priority, clearance, min thickness, pad connection, thermal gap, thermal bridge and island removal. `spec.pours` (and so the plan `pour` writes) can carry `connection` (thermal, solid or none), `thermalGap`, `thermalBridge`, `islandRemoval` (always, never or area) and `islandAreaMin`, so zones written outside the flow can be described exactly. `land pours` refuses pad connection none and island removal other than always before any bridge call, because the KiCad bridge cannot land them faithfully.+`verify-pours --plan <plan.json>` compares saved, filled zones with an explicit plan without landing them. Read `docs/existing-pours.md`. A failure names, per zone, the closest board zone and every field that differs: geometry, net, layer, priority, clearance, min thickness, pad connection, thermal gap, thermal bridge and island removal. `spec.pours` (and so the plan `pour` writes) can carry `connection` (thermal, solid or none), `thermalGap`, `thermalBridge`, `islandRemoval` (always, never or area) and `islandAreaMin`, so zones written outside the flow can be described exactly. A net-less keepout goes in `spec.pours` too: `{"name": "...", "keepout": {"tracks": true, "vias": true, "pours": true}, "polygon": [[x, y], ...], "layers": [...]}`, and `pour` puts it in its plan. A keepout rule area on the board that the plan does not name is the human's: `verify-pours` lists it (`boardKeepoutsNotInPlan`) and does not fail on it. `land pours` refuses pad connection none and island removal other than always before any bridge call, because the KiCad bridge cannot land them faithfully. The plan needs provenance: the plan `pour` wrote (its hash is recorded in the run), or a reviewed plan recorded with `verify-pours --plan <file> --record-plan` before the board's zones changed. Any other plan, such as one written from the board after the zones changed, is refused unless `--why "<reason>"` explains why it is independent of the board; the why is stored with the proof. Never write a plan from the board to make the check pass. The one exception is the human's own pours (hands-on human step): `verify-pours --from-board --adopt --why "poured by the human"` writes the plan from the saved zones itself (`plan-pours-from-board.json`) and records its provenance as `from-board`: the zones as the human drew them, not an independent design (a note says so when pours is not a human step).
tools/kicad-cli-remote+22−3@@ -196,15 +196,17 @@ send_schematic() { } # Run the desktop's kicad-cli: <timeout> <reason> <mkdir-or-empty> <args...>. Remote paths use forward # slashes; Windows gets backslashes and quotes, Linux gets bash quoting. Prints the bridge reply.+RENV=() # NAME=value pairs set on the desktop for the next run_cli (issue #177: the 3rd-party model folder) run_cli() { local to="$1" why="$2" mk="$3"; shift 3 if [[ "$ROS" == linux ]]; then- lx_script "${mk:+mkdir -p $(printf '%q' "$mk") && }$(lx_cmd "$CLI" "$@")" "$to" "$why"+ local envp=""; [[ ${#RENV[@]} -gt 0 ]] && envp="env $(lx_cmd "${RENV[@]}")"+ lx_script "${mk:+mkdir -p $(printf '%q' "$mk") && }${envp}$(lx_cmd "$CLI" "$@")" "$to" "$why" else # every path quoted; a folder output keeps its trailing separator, doubled before the closing quote so # Windows does not read \" as a quote- ab shell_execute "$(python3 - "$to" "$why" "$mk" "$CLI" "$@" <<'PY'-import json, sys+ ab shell_execute "$(RENV_JSON="$(python3 -c 'import json,sys;print(json.dumps(sys.argv[1:]))' "${RENV[@]}")" python3 - "$to" "$why" "$mk" "$CLI" "$@" <<'PY'+import json, os, sys to, why, mk, cli, args = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5:] def q(a): a = a.replace("/", "\\")@@ -212,6 +214,9 @@ def q(a): return '"' + (a + "\\" if a.endswith("\\") else a) + '"' return a c = q(cli) + " " + " ".join(q(a) for a in args)+for kv in json.loads(os.environ.get("RENV_JSON") or "[]"):+ k, _, v = kv.partition("=")+ c = 'set "' + k + '=' + v.replace("/", "\\") + '" && ' + c if mk: c = 'mkdir ' + q(mk) + ' 2>nul & ' + c print(json.dumps({"command": c, "timeoutSeconds": int(to), "reason": why})) PY@@ -355,7 +360,11 @@ if [[ "${1:-}" == "pcb" && "${2:-}" == "export" && "${3:-}" == "step" ]]; then base="$(basename "$board" .kicad_pcb)" new_job step # the board, its project files and every ${KIPRJMOD} model, in their folders relative to the project+ RENV=() while IFS= read -r line; do+ if [[ "$line" == ENV$'\t'* ]]; then+ kv="${line#ENV$'\t'}"; RENV+=("${kv%%$'\t'*}=$JOB/${kv#*$'\t'}"); continue+ fi sub="${line%%$'\t'*}"; list="${line#*$'\t'}" d="$JOB"; [[ -n "$sub" ]] && d="$JOB/$sub" mapfile -t files < <(python3 -c 'import json,sys;[print(f) for f in json.loads(sys.argv[1])]' "$list")@@ -366,11 +375,21 @@ b = sys.argv[1]; proj = os.path.dirname(b); stem = os.path.splitext(b)[0] files = {b} for ext in (".kicad_pro", ".kicad_dru"): if os.path.isfile(stem + ext): files.add(stem + ext)+# ${KICAD<N>_3RD_PARTY} set here (step-export points it at the project's kicad/3rdparty) and inside the+# project: those models travel too, and the variable is set to their folder on the desktop (issue #177)+third = {k: os.path.normpath(v) for k, v in os.environ.items() if re.fullmatch(r"KICAD\d+_3RD_PARTY", k) and os.path.isdir(v)}+renv = {} for m in re.findall(r'\(model\s+"([^"]+)"', open(b, encoding="utf-8", errors="replace").read()):+ p = None if "${KIPRJMOD}" in m: p = os.path.normpath(m.replace("${KIPRJMOD}", proj))+ for k, v in third.items():+ if "${" + k + "}" in m and not os.path.relpath(v, proj).startswith(".."):+ p = os.path.normpath(m.replace("${" + k + "}", v)); renv[k] = os.path.relpath(v, proj).replace(os.sep, "/")+ if p: for c in (p, os.path.splitext(p)[0] + ".step", os.path.splitext(p)[0] + ".stp"): if os.path.isfile(c): files.add(c)+for k, rel in renv.items(): print("ENV\t" + k + "\t" + rel) groups = {} for f in sorted(files): rel = os.path.relpath(os.path.dirname(f), proj)
tools/laser-etch/mark_geom.py+18−3@@ -8,6 +8,23 @@ import step_text as ST MARK_NAMES = ("Adom.LaserEtch", "laser_etch") +def reading_axis(faces, bb):+ """The axis the mark reads along, from its glyph faces (each with bbox and centre c).++ Mostly the spread of the glyph centres. Issue #188: a multi-line mark can be a near-square grid of glyphs+ ("BMI" over "270"), where the spread cannot tell rows from columns; glyphs are taller than they are wide, so+ then the reading axis is the one the median glyph is narrower along."""+ sx = statistics.pstdev([f["c"][0] for f in faces]) if len(faces) > 1 else bb[3] - bb[0]+ sy = statistics.pstdev([f["c"][1] for f in faces]) if len(faces) > 1 else bb[4] - bb[1]+ axis = "x" if sx >= sy else "y"+ if len(faces) > 2 and max(sx, sy) < 1.35 * min(sx, sy):+ wx = statistics.median(f["bbox"][3] - f["bbox"][0] for f in faces)+ wy = statistics.median(f["bbox"][4] - f["bbox"][1] for f in faces)+ if max(wx, wy) > 1.15 * min(wx, wy):+ axis = "x" if wx < wy else "y"+ return axis++ def mark_name(step): names = [n for _, n in step.products()] for n in MARK_NAMES:@@ -32,9 +49,7 @@ def analyse(step, name=None): return None allp = [f["bbox"] for f in faces] bb = [min(b[0] for b in allp), min(b[1] for b in allp), min(b[2] for b in allp), max(b[3] for b in allp), max(b[4] for b in allp), max(b[5] for b in allp)]- sx = statistics.pstdev([f["c"][0] for f in faces]) if len(faces) > 1 else bb[3] - bb[0]- sy = statistics.pstdev([f["c"][1] for f in faces]) if len(faces) > 1 else bb[4] - bb[1]- axis = "x" if sx >= sy else "y"+ axis = reading_axis(faces, bb) ai = 0 if axis == "x" else 1 mid = (bb[ai] + bb[ai + 3]) / 2 first = faces[0]["c"][ai]
tools/laser-etch/test_reading_axis.pyadded+40@@ -0,0 +1,40 @@+"""Issue #188, offline: the reading axis of a two-line, near-square mark ("BMI" over "270") comes from the glyphs'+own shape (taller than wide), not from the spread of their centres, which a 3 x 2 grid cannot settle.+Run: python3 -m unittest -v test_reading_axis (from tools/laser-etch)"""+import os, sys, unittest++sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))+import mark_geom as MG+++def glyph(cx, cy, w, h):+ return dict(bbox=[cx - w / 2, cy - h / 2, 0, cx + w / 2, cy + h / 2, 0.01], c=[cx, cy, 0.005])+++def bbox(faces):+ return [min(f["bbox"][0] for f in faces), min(f["bbox"][1] for f in faces), 0, max(f["bbox"][3] for f in faces), max(f["bbox"][4] for f in faces), 0.01]+++def grid(along):+ """Two lines of three glyphs, 0.5 wide and 0.77 tall, 0.6 pitch, lines 1.0 apart (the spread across the lines just beats the spread along them), reading along x or y."""+ out = []+ for row, off in ((0, 0.5), (1, -0.5)):+ for i in range(3):+ u = -0.6 + 0.6 * i+ out.append(glyph(u, off, 0.5, 0.77) if along == "x" else glyph(off, u, 0.77, 0.5))+ return out+++class ReadingAxis(unittest.TestCase):+ def test_a_near_square_two_line_mark_reads_along_its_lines(self):+ for along in ("x", "y"):+ f = grid(along)+ self.assertEqual(MG.reading_axis(f, bbox(f)), along)++ def test_a_long_single_line_keeps_the_spread(self):+ f = [glyph(-1.2 + 0.6 * i, 0, 0.5, 0.77) for i in range(5)]+ self.assertEqual(MG.reading_axis(f, bbox(f)), "x")+++if __name__ == "__main__":+ unittest.main()
tools/test-kicad-cli-remote.py+13−3@@ -58,12 +58,17 @@ if verb == "shell_execute": if m: shutil.rmtree(p(m.group(1)), ignore_errors=True); out({"stdout": ""}) m = re.match(r'mkdir "(.*?)" 2>nul & (.*)$', c) if m: os.makedirs(p(m.group(1)), exist_ok=True); c = m.group(2)+ env = dict(os.environ)+ while True: # set "NAME=value" && ... (the remote environment, #177)+ m = re.match(r'set "([^=]+)=([^"]*)" && (.*)$', c)+ if not m: break+ v = p(m.group(2)); env[m.group(1)] = v[2:] if v.startswith("C:") else v; c = m.group(3) toks = re.findall(r'"([^"]*)"|(\S+)', c) argv = [p(q or r) for q, r in toks] assert argv[0].startswith("C:"), argv argv[0] = argv[0][2:] argv = [x[2:] if x.startswith("C:") else x for x in argv]- r = subprocess.run(argv, capture_output=True, text=True)+ r = subprocess.run(argv, capture_output=True, text=True, env=env) out({"stdout": r.stdout, "stderr": r.stderr, "exitCode": r.returncode}) out({"success": False, "error": "unknown verb " + verb}) '''@@ -83,7 +88,10 @@ case "$1 $2 $3" in "pcb export gerbers") libs "$last"; for l in F_Cu B_Cu Edge_Cuts; do echo "G04 $l*" > "${out%/}/demo-$l.gbr"; done; echo '{}' > "${out%/}/demo-job.gbrjob";; "pcb export drill") echo "M48" > "${out%/}/demo-PTH.drl";; "pcb export pos") echo "Ref,Val" > "$out";;-"pcb export step") [ -f "$(dirname "$last")/models/part.step" ] || exit 4; echo "Wire self-interference check failed"; echo "z: 1.585; bounding box: x0: 1; y0: 2; x1: 3; y1: 4" >&2; echo "ISO-10303-21;" > "$out";;+"pcb export step") [ -f "$(dirname "$last")/models/part.step" ] || exit 4+ # a ${KICAD10_3RD_PARTY} model travelled into the job folder and the variable points there (#177)+ case "$KICAD10_3RD_PARTY" in "$(dirname "$last")"/*) ;; *) echo "3rd-party folder not in the job: $KICAD10_3RD_PARTY" >&2; exit 7;; esac+ [ -f "$KICAD10_3RD_PARTY/3dmodels/x.step" ] || { echo "3rd-party model missing" >&2; exit 8; }; echo "Wire self-interference check failed"; echo "z: 1.585; bounding box: x0: 1; y0: 2; x1: 3; y1: 4" >&2; echo "ISO-10303-21;" > "$out";; *) echo "unknown $*" >&2; exit 2;; esac '''@@ -97,8 +105,9 @@ def run(osname): for name, body in (("adom-bridge", MOCK), ("kicad-cli", FAKE_CLI)): fp = os.path.join(bindir, name) open(fp, "w").write(body); os.chmod(fp, os.stat(fp).st_mode | stat.S_IEXEC)- open(os.path.join(work, "demo.kicad_pcb"), "w").write('(kicad_pcb (footprint "X" (model "${KIPRJMOD}/models/part.step")))')+ open(os.path.join(work, "demo.kicad_pcb"), "w").write('(kicad_pcb (footprint "X" (model "${KIPRJMOD}/models/part.step")) (footprint "Y" (model "${KICAD10_3RD_PARTY}/3dmodels/x.step")))') os.makedirs(os.path.join(work, "models")); open(os.path.join(work, "models", "part.step"), "w").write("ISO-10303-21;")+ os.makedirs(os.path.join(work, "kicad", "3rdparty", "3dmodels")); open(os.path.join(work, "kicad", "3rdparty", "3dmodels", "x.step"), "w").write("ISO-10303-21;") open(os.path.join(work, "demo.kicad_pro"), "w").write("{}") open(os.path.join(work, "demo.kicad_sch"), "w").write('(kicad_sch (sheet (property "Sheetfile" "sub/child.kicad_sch")))') open(os.path.join(work, "sub", "child.kicad_sch"), "w").write("(kicad_sch)")@@ -138,6 +147,7 @@ def run(osname): kc("pcb", "export", "drill", "--output", "run/mfg/gerbers/", "--format", "excellon", "demo.kicad_pcb") kc("pcb", "export", "pos", "--output", "run/mfg/demo-cpl.csv", "--format", "csv", "demo.kicad_pcb") # the board twin's STEP: the ${KIPRJMOD} model travels, the native output passes through+ env["KICAD10_3RD_PARTY"] = os.path.join(work, "kicad", "3rdparty") # what step-export sets (#177) st = kc("pcb", "export", "step", "--subst-models", "--include-tracks", "--include-silkscreen", "--output", "twin/demo.step", "demo.kicad_pcb", full=True) assert "Wire self-interference check failed" in st.stdout and "bounding box" in st.stderr, (st.stdout, st.stderr) assert open(os.path.join(work, "twin/demo.step")).read().startswith("ISO-10303-21"), "the STEP did not come back"