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.23
22 files changed
+1104−87
Cargo.lock+10−10Cargo.toml+1−1SKILL.md+8−1bin/adom-aiflowcrates/adom-aiflow/src/copper_carry.rs+233−8crates/adom-aiflow/src/edge_overhang.rs+14crates/adom-aiflow/src/main.rs+117−8crates/adom-aiflow/src/placement_verify.rs+57−14crates/adom-aiflow/src/pourverify.rs+19−5crates/adom-aiflow/src/preboard.rs+142−16crates/adom-aiflow/src/switchnode.rs+151−4crates/adom-aiflow/src/thermalvias.rs+59−5crates/adom-aiflow/src/zone_ownership.rs+173docs/edge-connectors.md+5−1docs/existing-pours.md+14−1package.json+1−1page.json+1−1skills/adom-aiflow/SKILL.md+9−2skills/aiflow-power-layout/SKILL.md+1−1skills/aiflow-sourcing/SKILL.md+15−7tests/sourcing-suggestions.py+73tools/altium-project.py+1−1Cargo.lock+10−10@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow"-version = "0.2.22"+version = "0.2.23" dependencies = [ "aiflow-analyze", "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [ [[package]] name = "aiflow-analyze"-version = "0.2.22"+version = "0.2.23" dependencies = [ "aiflow-board", "serde",@@ -35,7 +35,7 @@ dependencies = [ [[package]] name = "aiflow-board"-version = "0.2.22"+version = "0.2.23" dependencies = [ "roxmltree", "serde",@@ -44,7 +44,7 @@ dependencies = [ [[package]] name = "aiflow-bridge"-version = "0.2.22"+version = "0.2.23" dependencies = [ "aiflow-board", "serde",@@ -53,7 +53,7 @@ dependencies = [ [[package]] name = "aiflow-copper"-version = "0.2.22"+version = "0.2.23" dependencies = [ "aiflow-board", "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [ [[package]] name = "aiflow-grid"-version = "0.2.22"+version = "0.2.23" dependencies = [ "aiflow-board", "serde",@@ -72,7 +72,7 @@ dependencies = [ [[package]] name = "aiflow-place"-version = "0.2.22"+version = "0.2.23" dependencies = [ "aiflow-board", "serde",@@ -81,7 +81,7 @@ dependencies = [ [[package]] name = "aiflow-pours"-version = "0.2.22"+version = "0.2.23" dependencies = [ "aiflow-board", "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [ [[package]] name = "aiflow-router"-version = "0.2.22"+version = "0.2.23" dependencies = [ "aiflow-board", "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [ [[package]] name = "aiflow-run"-version = "0.2.22"+version = "0.2.23" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.2.22"+version = "0.2.23" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
SKILL.md+8−1@@ -200,7 +200,7 @@ wrong part at its component page, not just on this board. The clock starts at the prompt even when there is no board yet. - **`intake --prompt-time <UTC of the prompt> [--brief <file>] [--requirements requirements.json] [--target <box>] [--page <owner/slug> [--page-dir <folder>]]`** opens the run with the `intake` step and no board. Pre-board steps are steps like any other: `step sourcing`, `step design`, `step simulation`, `step schematic`; every command is a turn charged to the step, and with `--target` each films the tool's own window when the step works in it, else Hydrogen with the run's dashboard, else the AI's terminal window, or records a skip when nothing meaningful is on screen. `start --board ... --spec ...` on the same `--run` attaches the board: same clock, same ledger, the steps before the board stay on the page.-- **`sourcing check --bom design/bom.csv [--profile fab|jlcpcb] [--qty N] [--requirements requirements.json]`** gates the BOM. The profile comes from the spec or the requirements (`sourcing.profile` or `fab.target`), `fab` by default. Every electrical reference on the board needs a row with an MPN (contacts and pads the footprint does not exclude from the BOM take a `no part` row; the diff lists missing and extra references). On `fab` every row is from Mouser or Adom stock and no row is an LCSC/JLCPCB part; on `jlcpcb` every row carries its LCSC number. The stock column carries its check date (`stock_checked_2026-09-29`, or a `stock_date` column); under twice the build quantity is a thin-stock warning. Before the board it checks the BOM alone; run it again after `start --board`, because `finish` wants the diff.+- **`sourcing check --bom design/bom.csv [--profile fab|jlcpcb] [--qty N] [--requirements requirements.json]`** gates the BOM. The profile comes from the spec or the requirements (`sourcing.profile` or `fab.target`), `fab` by default. Every electrical reference on the board needs a row with an MPN (contacts and pads the footprint does not exclude from the BOM take a `no part` row; the diff lists missing and extra references). A profile states preferences, never a gate: any distributor is fine on any profile (DigiKey, Arrow, LCSC, ...), because every fab takes consigned (customer-supplied) parts. `fab` (the 3rd party fab) prefers Mouser or Adom stock; `jlcpcb` prefers JLCPCB/LCSC parts with their LCSC number. Rows from anywhere else pass with one warning that lists them as consigned parts, and under it one suggestion per row: the check looks the exact MPN up at the preferred source with `adom-parts-search search` (one search per distinct MPN, at most 25 per check, cached in the run folder as sourcing-search-cache.json) and says to switch the source when it is in stock there, or how to look for an equivalent. `AIFLOW_NO_PART_SEARCH=1` skips the searches. The outcome records `consignedRows` and `suggestions`. The stock column carries its check date (`stock_checked_2026-09-29`, or a `stock_date` column); under twice the build quantity is a thin-stock warning. Before the board it checks the BOM alone; run it again after `start --board`, because `finish` wants the diff. - **`evidence add --kind calcs|simulation|vendor-model --file <json|md> [--note ...]`** records the design record in the run and the ledger with its sha256 (a copy lands in `<run>/evidence/`). A simulation result that says it fails (a top-level `"pass": false`, or a `"checks"` row `{"name","value","limit","pass"}` that fails) is refused. Vendor PSpice/LTspice runs belong to the separately published adom/adom-spice-skillpack; `--kind vendor-model` records their result. - **`finish`** also refuses without a passed sourcing check against the board when the spec or the requirements name a sourcing profile, and without the evidence kinds the spec lists in `"requireEvidence": ["calcs", "simulation"]`. @@ -528,6 +528,13 @@ A drilled pad with no copper layer (an NPTH that lists only `*.Mask`, such as a - 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. - 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).**+- `placement.edgeConnectors` (what `place verify` accepts for a part whose courtyard crosses the edge) takes any footprint the board uses for that reference, a project-library `<lib>:<name>` as well as `Connector_*`; the declared footprint must equal the live and saved one. One `edgeConnectors` entry also allows the part's moves to land (as `placement.edgeOverhang` does), and both check pads and footprint copper with the same inside-the-outline test. `edgeOverhang` alone lets moves land but is not enough for `place verify`: the refusal names the `edgeConnectors` fields to add (docs/edge-connectors.md).+- `thermal-vias land` counts the landed vias on the live board: on KiCad through `kicad_routing_state` (live vias, live pad centres when reported), on Fusion through its live snapshot. When the live read fails it counts the saved snapshot and says so; the outcome records `countedOn`.+- `land pours` drops an owned zone ID that exists neither live nor in the saved board (the run's snapshot and the desktop file, pulled fresh), as after a KiCad crash between the zone commit and a save, and records it in `zoneOwnershipDropped` (ids, time, reason "missing live and saved after commit"). For a manual case: `zones forget --missing --why "..."` or `zones forget --id <id> --why "..."`. An ID that still exists live or saved is never dropped; without a live read and the desktop file nothing is dropped.+- Switch nodes without `spec.switchNodes`: the nets of `spec.switching`, else structure, a net shared by an inductor pad (L<n> or an inductor footprint) and a SW, LX or PH pin of another part. Only when structure finds none are names (SW, *_SW, SW_*, */SW) used, never a named net with no inductor on a board whose structure is known (it has inductors or pin functions), and the gate prints which nets it took with the `"switchNodes": [...]` override first. `"switchNodes": []` still means none.+- A zones-only adoption (docs/existing-pours.md): when `adopt-board` finds that only zones changed, it voids the evidence as before and records a pending carry. `verify-pours --plan <the plan pour wrote> --adopt --why ...` that matches every new zone confirms it: place verify, `land-route` and `land-vias` are carried over (recorded in `zonesCarry`); gate, measure, the analyses and Fields are done again.+ ### Artifact evidence is copied without overwriting `artifact --file` stores a content-addressed snapshot, including files already inside the run. Two different files named `review.json` remain separate; later source edits do not rewrite prior evidence. Copy/readback failures refuse registration. The ledger records the SHA-256 and original source path. Use the registered path printed by the command with `--replaces`; no historical event is rewritten. A hash establishes identity, not engineering validity. Existing ledgers remain unchanged.
bin/adom-aiflow⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/copper_carry.rs+233−8@@ -19,6 +19,14 @@ use sha2::{Digest, Sha256}; use std::path::Path; pub const SCHEME: &str = "copper-fp-v1";+/// The fingerprint without zones (issue #105, the zones-only carry).+pub const SCHEME_WITHOUT_ZONES: &str = "copper-fp-v1-without-zones";++/// Evidence a zones-only change carries once `verify-pours --adopt` matches the zones to a plan `pour`+/// wrote: placement (place verify, which is not removed by an adoption but bound to the board hash) and+/// the live route and via landings. The gate, measure, analyses and Fields are re-done (zones change DRC,+/// current and heat).+pub const ZONES_ONLY_CARRIED: [&str; 2] = ["land-route", "land-vias"]; /// Outcomes bound to the board's silkscreen: re-done after any adoption that changes the file. pub const SILK_OUTCOMES: [&str; 2] = ["silkscreen", "export"];@@ -104,7 +112,10 @@ fn rules_part(board: &Path) -> Result<Value, String> { } /// The canonical text the fingerprint hashes (exposed for tests and review).-pub fn canonical_text(text: &str) -> Result<String, String> {+pub fn canonical_text(text: &str) -> Result<String, String> { canonical_text_with(text, true) }++/// `canonical_text`, leaving out the board's own zones when `zones` is false (issue #105).+pub fn canonical_text_with(text: &str, zones: bool) -> Result<String, String> { if aiflow_board::eagle::is_eagle(text) { return Err("an EAGLE source has no copper fingerprint".into()); } let root = sx::parse(text)?; if root.tag != "kicad_pcb" { return Err("not a kicad_pcb".into()); }@@ -115,7 +126,7 @@ pub fn canonical_text(text: &str) -> Result<String, String> { Item::Node(n) => { let t = n.tag.as_str(); // everything not named here stays in (the format version, layers, nets, tracks, zones)- if NEVER_COPPER.contains(&t) { continue; }+ if NEVER_COPPER.contains(&t) || (!zones && t == "zone") { continue; } match t { "footprint" => items.push(footprint(n).to_string()), "setup" => items.push(canon_without(n, &["pcbplotparams"]).to_string()),@@ -132,12 +143,17 @@ pub fn canonical_text(text: &str) -> Result<String, String> { } /// The copper fingerprint of a saved board and its project rule files.-pub fn fingerprint(board: &Path) -> Result<String, String> {+pub fn fingerprint(board: &Path) -> Result<String, String> { fingerprint_with(board, true) }++/// Issue #105: the copper fingerprint without the board's zones (footprint keepouts stay in).+pub fn fingerprint_without_zones(board: &Path) -> Result<String, String> { fingerprint_with(board, false) }++fn fingerprint_with(board: &Path, zones: bool) -> Result<String, String> { let text = std::fs::read_to_string(board).map_err(|e| format!("{}: {e}", board.display()))?;- let body = canonical_text(&text)?;+ let body = if zones { canonical_text(&text)? } else { canonical_text_with(&text, false)? }; let rules = rules_part(board)?; let mut h = Sha256::new();- h.update(SCHEME.as_bytes());+ h.update(if zones { SCHEME } else { SCHEME_WITHOUT_ZONES }.as_bytes()); h.update(b"\0"); h.update(body.as_bytes()); h.update(b"\0");@@ -169,8 +185,17 @@ pub fn adopt(r: &mut Run, dest: &str) -> (bool, String) { } Err(why) => { r.log("copper-carry-refused", json!({"newBoard": dest, "why": why}));+ // issue #105: a zones-only change carries placement and route evidence once verify-pours --adopt confirms it+ let pending = if old.is_empty() { None } else { zones_only_pending(&r.data, Path::new(&old), Path::new(dest)).ok() }; r.add_board(dest);- (false, format!("copper evidence invalidated ({why})"))+ r.data["zonesCarryPending"] = pending.clone().unwrap_or(Value::Null);+ match pending {+ Some(_) => {+ r.log("zones-only-carry-pending", json!({"newBoard": dest}));+ (false, format!("copper evidence invalidated ({why}); only zones changed: `verify-pours --plan <the plan pour wrote> --adopt --why ...` that matches these zones carries placement (place verify) and the route and via landings over; gate, measure and the analyses are re-done"))+ }+ None => (false, format!("copper evidence invalidated ({why})")),+ } } }; let mut dropped = Vec::new();@@ -189,6 +214,125 @@ pub fn adopt(r: &mut Run, dest: &str) -> (bool, String) { (carried, note) } +/// Issue #105: a pending zones-only carry from `old` to `new`, when the two boards differ only in their+/// zones (the fingerprints without zones are equal). The evidence `ZONES_ONLY_CARRIED` is stashed here+/// (the adoption removes it) until `confirm_zones_carry` re-validates it.+pub fn zones_only_pending(data: &Value, old: &Path, new: &Path) -> Result<Value, String> {+ let a = fingerprint_without_zones(old)?;+ let b = fingerprint_without_zones(new)?;+ if a != b { return Err("more than zones changed".into()); }+ let mut stash = json!({});+ let mut stash_at = json!({});+ for k in ZONES_ONLY_CARRIED {+ if let Some(o) = data["outcomes"].get(k).filter(|o| !o.is_null()) { stash[k] = o.clone(); stash_at[k] = data["outcomeAt"][k].clone(); }+ }+ Ok(json!({"scheme": SCHEME_WITHOUT_ZONES, "fromBoard": old.display().to_string(), "fromSha256": sha256_file(old)?,+ "toBoard": new.display().to_string(), "toSha256": sha256_file(new)?, "fingerprintWithoutZones": a, "at": aiflow_run::now(),+ "stash": stash, "stashAt": stash_at}))+}++/// Issue #105: called by `verify-pours --adopt` after a passing match. Confirms the pending zones-only+/// carry when (1) the plan's provenance is `pour` (its hash recorded in the run), (2) the pending record+/// leads to the current board (directly, or through copper-unchanged adoptions), (3) both snapshot files+/// are unchanged and still differ only in zones, (4) every zone that is new in the adopted board is one+/// the verification matched to the plan, and every zone that disappeared was owned by the run. Then the+/// stashed route and via landings are restored (unless re-recorded since) and a `zonesCarry` record lets+/// `placement_bound` accept the place verify proof. None when nothing is pending.+pub fn confirm_zones_carry(r: &mut Run, proof: &Value) -> Option<Result<String, String>> {+ let pending = r.data["zonesCarryPending"].clone();+ if !pending.is_object() { return None; }+ Some(confirm_with(r, proof, &pending))+}++fn confirm_with(r: &mut Run, proof: &Value, pending: &Value) -> Result<String, String> {+ if proof["pass"] != true { return Err("the verification did not pass".into()); }+ if proof["planProvenance"]["kind"] != "aiflow-pour" { return Err(format!("the plan was not written by `pour` in this run (provenance {})", proof["planProvenance"]["kind"])); }+ let current = r.current_board();+ let cur = Path::new(¤t);+ let to_sha = pending["toSha256"].as_str().ok_or("pending record without a board hash")?;+ if sha256_file(cur)? != to_sha { carried_from(&r.data, to_sha, cur).map_err(|e| format!("the board changed after the zones-only adoption: {e}"))?; }+ let boards: Vec<String> = r.data["boards"].as_array().map(|a| a.iter().filter_map(|b| b.as_str().map(str::to_string)).collect()).unwrap_or_default();+ let fp = pending["fingerprintWithoutZones"].as_str().ok_or("pending record without a fingerprint")?;+ let mut files = Vec::new();+ for (f, h) in [("fromBoard", "fromSha256"), ("toBoard", "toSha256")] {+ let file = pending[f].as_str().ok_or("pending record without a board file")?;+ if !boards.iter().any(|b| b == file) { return Err(format!("{file} is not one of the run's board snapshots")); }+ let p = Path::new(file);+ if pending[h].as_str() != Some(sha256_file(p)?.as_str()) { return Err(format!("{file} changed since it was adopted")); }+ if fingerprint_without_zones(p)? != fp { return Err(format!("{file}: more than zones differ")); }+ files.push(aiflow_board::Board::load(file)?);+ }+ let (old, new) = (&files[0], &files[1]);+ let old_defs = crate::pourverify::zone_definitions(old);+ let new_defs = crate::pourverify::zone_definitions(new);+ let matched: Vec<&str> = proof["groups"].as_array().into_iter().flatten().filter_map(|g| g["boardZoneUuid"].as_str()).collect();+ let owned: Vec<&str> = r.data["ownedZoneIds"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();+ let mut added = Vec::new();+ for (d, id) in &new_defs {+ if old_defs.iter().any(|(o, _)| o == d) { continue; }+ let id = id.as_deref().ok_or("a changed zone has no saved ID")?;+ if !matched.contains(&id) { return Err(format!("zone {id} changed but the plan does not match it")); }+ added.push(id.to_string());+ }+ let mut removed = Vec::new();+ for (d, id) in &old_defs {+ if new_defs.iter().any(|(n, _)| n == d) { continue; }+ let id = id.as_deref().unwrap_or("");+ if !owned.contains(&id) { return Err(format!("zone {} disappeared and was not owned by this run", if id.is_empty() { "without an ID" } else { id })); }+ removed.push(id.to_string());+ }+ let mut restored = Vec::new();+ for k in ZONES_ONLY_CARRIED {+ let Some(o) = pending["stash"].get(k).filter(|o| !o.is_null()) else { continue };+ if r.data["outcomes"].get(k).is_some_and(|x| !x.is_null()) { continue; }+ r.data["outcomes"][k] = o.clone();+ r.data["outcomeAt"][k] = pending["stashAt"][k].clone();+ restored.push(k);+ }+ let rec = json!({"scheme": SCHEME_WITHOUT_ZONES, "fromBoard": pending["fromBoard"], "fromSha256": pending["fromSha256"], "toBoard": pending["toBoard"], "toSha256": pending["toSha256"],+ "fingerprintWithoutZones": fp, "plan": proof["plan"], "planSha256": proof["planSha256"], "zonesAdded": added, "zonesRemoved": removed, "restored": restored, "at": aiflow_run::now()});+ r.log("board-evidence-carried-zones-only", rec.clone());+ if !r.data["zonesCarry"].is_array() { r.data["zonesCarry"] = json!([]); }+ r.data["zonesCarry"].as_array_mut().unwrap().push(rec);+ r.data["zonesCarryPending"] = Value::Null;+ Ok(format!("only zones changed and they match the plan `pour` wrote: place verify{} carried over from board {}; gate, measure and the analyses are still required",+ if restored.is_empty() { String::new() } else { format!(", {}", restored.join(" and ")) }, pending["fromBoard"].as_str().unwrap_or("?")))+}++/// Like `carried_from`, through copper-unchanged adoptions (`copperCarry`) and confirmed zones-only+/// adoptions (`zonesCarry`), comparing every snapshot's fingerprint without zones. Only for evidence+/// zones cannot change (placement).+pub fn carried_without_zones(data: &Value, recorded: &str, current: &Path) -> Result<Vec<Value>, String> {+ let cur = sha256_file(current)?;+ if cur == recorded { return Ok(Vec::new()); }+ let mut records: Vec<Value> = data["copperCarry"].as_array().cloned().unwrap_or_default().into_iter().filter(|r| r["scheme"] == SCHEME).collect();+ records.extend(data["zonesCarry"].as_array().cloned().unwrap_or_default().into_iter().filter(|r| r["scheme"] == SCHEME_WITHOUT_ZONES));+ if records.is_empty() { return Err("no carry-over is recorded".into()); }+ let fp = fingerprint_without_zones(current)?;+ let boards: Vec<String> = data["boards"].as_array().map(|a| a.iter().filter_map(|b| b.as_str().map(str::to_string)).collect()).unwrap_or_default();+ let check = |file: &Value, sha: &Value| -> Result<(), String> {+ let f = file.as_str().ok_or("carry record without a board file")?;+ if !boards.iter().any(|b| b == f) { return Err(format!("{f} is not one of the run's board snapshots")); }+ let p = Path::new(f);+ if sha.as_str() != Some(sha256_file(p)?.as_str()) { return Err(format!("{f} changed since it was adopted")); }+ if fingerprint_without_zones(p)? != fp { return Err(format!("{f}: more than zones differ")); }+ Ok(())+ };+ let mut target = cur;+ let mut chain = Vec::new();+ for _ in 0..records.len() {+ let rec = records.iter().rev().find(|r| r["toSha256"].as_str() == Some(target.as_str()))+ .ok_or_else(|| "no carry record reaches the recorded board".to_string())?;+ check(&rec["toBoard"], &rec["toSha256"])?;+ check(&rec["fromBoard"], &rec["fromSha256"])?;+ chain.push(rec.clone());+ let from = rec["fromSha256"].as_str().unwrap_or("").to_string();+ if from == recorded { return Ok(chain); }+ target = from;+ }+ Err("the carry records do not reach the recorded board".into())+}+ /// Accept evidence recorded on board hash `recorded` for the board file `current` through the /// run's carry records: a chain of adoptions, each re-verified from its snapshot files, from the /// recorded board to the current one with one unchanged copper fingerprint. Ok(chain) or why not.@@ -244,10 +388,12 @@ pub fn pours_bound(data: &Value, outcome: &Value, board: &Path, spec: &Path) -> pub fn placement_bound(data: &Value, proof: &Value, board: &Path, spec: &Path) -> Result<Option<String>, String> { if crate::placement_verify::bound(proof, board, spec) { return Ok(None); } let recorded = proof["boardSha256"].as_str().ok_or("place verify proof has no board hash")?;- let chain = carried_from(data, recorded, board)?;+ // issue #105: footprint placement is outside the zones, so a confirmed zones-only carry counts too+ let (chain, zones_only) = match carried_from(data, recorded, board) { Ok(c) => (c, false), Err(e) => (carried_without_zones(data, recorded, board).map_err(|_| e)?, true) }; let mut p = proof.clone(); p["boardSha256"] = json!(sha256_file(board)?);- if crate::placement_verify::bound(&p, board, spec) { Ok(Some(chain_note("place verify", &chain))) } else { Err("spec changed or the proof did not pass".into()) }+ if !crate::placement_verify::bound(&p, board, spec) { return Err("spec changed or the proof did not pass".into()); }+ if zones_only { Ok(Some(format!("place verify: carried over from board {} (only zones changed, matched to the plan `pour` wrote, across {} adoption(s))", chain.last().and_then(|c| c["fromBoard"].as_str()).unwrap_or("?"), chain.len()))) } else { Ok(Some(chain_note("place verify", &chain))) } } /// `aiflow_analyze::field_screen` on the board file `board`, also accepting a Fields report solved@@ -413,6 +559,85 @@ mod tests { let _ = std::fs::remove_dir_all(d); } + const NEW_ZONE: &str = " (zone (net 2) (net_name \"VIN\") (layer \"F.Cu\") (uuid \"z2\") (name \"VIN pour\") (priority 1) (connect_pads (clearance 0.2)) (min_thickness 0.2) (fill yes (thermal_gap 0.5) (thermal_bridge_width 0.5)) (polygon (pts (xy 12 12) (xy 18 12) (xy 18 18) (xy 12 18))) (filled_polygon (layer \"F.Cu\") (pts (xy 12 12) (xy 18 12) (xy 18 18))))\n";++ /// board-0 with a verified-existing place proof; board-1 adds the zone z2 (a zones-only change).+ fn zones_only_run(name: &str) -> (std::path::PathBuf, Run, std::path::PathBuf, std::path::PathBuf, Value) {+ let d = tmp(name);+ let (mut r, b0, spec, _, _) = run_with_evidence(&d);+ let placed = json!({"ok": true, "mode": "verified-existing", "boardSha256": sha256_file(&b0).unwrap(), "specSha256": sha256_file(&spec).unwrap()});+ r.data["outcomes"]["place-land"] = placed.clone();+ r.data["outcomeAt"]["land-route"] = json!("2026-10-20T00:00:00Z");+ r.data["outcomeAt"]["land-vias"] = json!("2026-10-20T00:00:01Z");+ let b1 = d.join("board-1.kicad_pcb");+ std::fs::write(&b1, board("", "0 -1.5", "a.step", &format!("{COPPER}{NEW_ZONE}"))).unwrap();+ (d, r, b1, spec, placed)+ }++ fn pour_proof(ids: &[&str], kind: &str) -> Value {+ json!({"pass": true, "planProvenance": {"kind": kind}, "plan": "/run/plan-pours.json", "planSha256": "ab",+ "groups": ids.iter().map(|i| json!({"name": "VIN pour", "boardZoneUuid": i})).collect::<Vec<_>>()})+ }++ #[test]+ fn zones_only_adoption_carries_placement_and_route_after_verify_pours_matches_a_pour_plan() {+ let (d, mut r, b1, spec, placed) = zones_only_run("zones-only");+ assert!(fingerprint(&b1).unwrap() != fingerprint(Path::new(&r.current_board())).unwrap());+ assert_eq!(fingerprint_without_zones(&b1).unwrap(), fingerprint_without_zones(Path::new(&r.current_board())).unwrap());+ let (carried, note) = adopt(&mut r, &b1.display().to_string());+ assert!(!carried && note.contains("only zones changed"), "{note}");+ // until verify-pours confirms: everything is voided, placement is not bound+ for k in Run::COPPER_OUTCOMES { assert!(r.data["outcomes"].get(k).is_none(), "{k} voided at adoption"); }+ assert!(placement_bound(&r.data, &placed, &b1, &spec).is_err());+ assert!(r.data["zonesCarryPending"]["stash"]["land-route"].is_object());+ // refused: a plan not written by pour, a changed zone the plan does not match+ let mut r2 = Run { dir: r.dir.clone(), data: r.data.clone() };+ assert!(confirm_zones_carry(&mut r2, &pour_proof(&["z2"], "accepted-with-why")).unwrap().unwrap_err().contains("not written by `pour`"));+ assert!(confirm_zones_carry(&mut r2, &pour_proof(&["z1"], "aiflow-pour")).unwrap().unwrap_err().contains("zone z2 changed"));+ assert!(r2.data["outcomes"].get("land-route").is_none() && r2.data["zonesCarryPending"].is_object(), "a refusal changes nothing");+ // confirmed: route and via landings restored with their times, placement bound, gate and analyses still voided+ let note = confirm_zones_carry(&mut r, &pour_proof(&["z2"], "aiflow-pour")).unwrap().unwrap();+ assert!(note.contains("land-route and land-vias carried over"), "{note}");+ assert_eq!(r.data["outcomes"]["land-route"], json!({"pass": true}));+ assert_eq!(r.data["outcomeAt"]["land-vias"], "2026-10-20T00:00:01Z");+ for k in ["gate", "measure", "analyze-current", "analyze-thermal", "route"] { assert!(r.data["outcomes"].get(k).is_none(), "{k} stays voided"); }+ assert!(placement_bound(&r.data, &placed, &b1, &spec).unwrap().unwrap().contains("only zones changed"));+ assert!(r.data["zonesCarryPending"].is_null() && r.data["zonesCarry"][0]["zonesAdded"] == json!(["z2"]));+ // the zones-only carry never stands in for copper evidence (pours, Fields)+ assert!(carried_from(&r.data, &sha256_file(Path::new(r.data["boards"][0].as_str().unwrap())).unwrap(), &b1).is_err());+ // a later silk-only adoption still reaches the placement proof+ let b2 = d.join("board-2.kicad_pcb");+ std::fs::write(&b2, board(" (gr_text \"REV B\" (at 3 3) (layer \"F.SilkS\"))\n", "0 -1.5", "a.step", &format!("{COPPER}{NEW_ZONE}"))).unwrap();+ assert!(adopt(&mut r, &b2.display().to_string()).0);+ assert!(placement_bound(&r.data, &placed, &b2, &spec).unwrap().is_some());+ // an edited snapshot breaks the chain+ std::fs::write(&b1, board("", "0 -1.5", "a.step", &format!("{COPPER}{NEW_ZONE} "))).unwrap();+ assert!(placement_bound(&r.data, &placed, &b2, &spec).is_err());+ let _ = std::fs::remove_dir_all(d);+ }++ #[test]+ fn any_other_copper_change_or_an_unowned_removed_zone_carries_nothing() {+ // zones and a moved via: no pending carry+ let (d, mut r, b1, _, _) = zones_only_run("zones-and-via");+ std::fs::write(&b1, board("", "0 -1.5", "a.step", &format!("{COPPER}{NEW_ZONE}").replace("(at 5 1) (size 0.6)", "(at 5.2 1) (size 0.6)"))).unwrap();+ let (carried, note) = adopt(&mut r, &b1.display().to_string());+ assert!(!carried && !note.contains("only zones"), "{note}");+ assert!(r.data["zonesCarryPending"].is_null());+ assert!(confirm_zones_carry(&mut r, &pour_proof(&["z2"], "aiflow-pour")).is_none());+ let _ = std::fs::remove_dir_all(d);+ // the old zone z1 replaced by z2: allowed only when the run owned z1+ let (d, mut r, b1, _, _) = zones_only_run("zone-replaced");+ let only_new = COPPER.lines().filter(|l| !l.contains("(zone ")).map(|l| format!("{l}\n")).collect::<String>();+ std::fs::write(&b1, board("", "0 -1.5", "a.step", &format!("{only_new}{NEW_ZONE}"))).unwrap();+ assert!(adopt(&mut r, &b1.display().to_string()).1.contains("only zones changed"));+ let mut unowned = Run { dir: r.dir.clone(), data: r.data.clone() };+ assert!(confirm_zones_carry(&mut unowned, &pour_proof(&["z2"], "aiflow-pour")).unwrap().unwrap_err().contains("zone z1 disappeared"));+ r.data["ownedZoneIds"] = json!(["z1"]);+ assert!(confirm_zones_carry(&mut r, &pour_proof(&["z2"], "aiflow-pour")).unwrap().is_ok());+ let _ = std::fs::remove_dir_all(d);+ }+ #[test] fn copper_changes_invalidate_and_tampering_is_refused() { for (i, (from, to)) in [("(at 5 1) (size 0.6)", "(at 5.2 1) (size 0.6)"), ("(end 5 1) (width 0.25)", "(end 5 1.5) (width 0.25)"), ("(xy 20 20) (xy 0 20)", "(xy 18 20) (xy 0 20)"), ("(at 0.8 0 90) (size", "(at 0.9 0 90) (size")].iter().enumerate() {
crates/adom-aiflow/src/edge_overhang.rs+14@@ -3,13 +3,23 @@ //! outline with their courtyard and body; `place land` and `land moves` forward `allowOutside: true` to //! kicad_move_footprint for those parts only. Their pads and copper must still lie inside the outline: //! that is checked here on the moved board before anything lands. Edge clearance stays a DRC matter.+//! A `placement.edgeConnectors` entry (issue #100, what `place verify` accepts) allows its part here too;+//! both use `aiflow_place::copper_outside` for the pads and copper. use aiflow_board::Board; use serde_json::{json, Value}; /// The allowed parts, (ref, why): the spec's entries, then the command line's (why "--allow-outside").+/// A `placement.edgeConnectors` entry (the reviewed, bounded declaration `place verify` accepts) also+/// allows its part, with its reason as the why, so one declaration covers landing and verification. pub fn allowed(spec: &Value, cli: &[String]) -> Result<Vec<(String, String)>, String> { let mut out = aiflow_place::edge_overhang(spec)?;+ if let Some(o) = spec["placement"]["edgeConnectors"].as_object() {+ for (r, c) in o {+ let why = c["reason"].as_str().map(str::trim).filter(|w| !w.is_empty()).ok_or_else(|| format!("placement.edgeConnectors.{r} needs a reason"))?;+ if !out.iter().any(|(x, _)| x == r) { out.push((r.clone(), format!("placement.edgeConnectors: {why}"))); }+ }+ } for r in cli.iter().map(|r| r.trim()).filter(|r| !r.is_empty()) { if !out.iter().any(|(x, _)| x == r) { out.push((r.to_string(), "--allow-outside on the command line".into())); } }@@ -68,6 +78,10 @@ mod tests { assert_eq!(a[0].1, "USB-C mouth proud of the edge"); assert!(super::allowed(&json!({"placement": {"edgeOverhang": [{"ref": "J13"}]}}), &[]).unwrap_err().contains("needs a why")); assert!(super::allowed(&json!({}), &[]).unwrap().is_empty());+ // an edgeConnectors declaration (what place verify accepts) also allows the landing+ let both = super::allowed(&json!({"placement": {"edgeConnectors": {"J1": {"footprint": "lib:USB_C", "edge": "maxX", "maxCourtyardOverhangMm": 0.5, "reason": "USB-C mouth"}}}}), &[]).unwrap();+ assert_eq!(both, vec![("J1".to_string(), "placement.edgeConnectors: USB-C mouth".to_string())]);+ assert!(super::allowed(&json!({"placement": {"edgeConnectors": {"J1": {"footprint": "lib:USB_C"}}}}), &[]).unwrap_err().contains("needs a reason")); } #[test]
crates/adom-aiflow/src/main.rs+117−8@@ -49,6 +49,7 @@ mod rules_card; mod demo; mod boardkind; mod pagedir;+mod zone_ownership; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -153,7 +154,7 @@ enum Cmd { #[arg(long)] page_dir: Option<String>, /// the EDA tool the board is built in (default: the brief's own words, else KiCad); fixes the run's step sequence #[arg(long, value_parser = ["kicad", "fusion", "altium"])] eda: Option<String>, /// `pattern`: a passive copper-pattern KiCad board (no parts, BOM, schematic or electrical load) takes the short pattern sequence; finish reports the electrical steps as not applicable. Refused later for a board with pads on nets that need routing. #[arg(long, value_parser = ["pattern"])] kind: Option<String> },- /// `sourcing check --bom <csv>`: every electrical board reference in the BOM with an MPN, sources that fit the profile (fab: Mouser or Adom stock, no LCSC/JLCPCB parts; jlcpcb: LCSC numbers), a dated stock check, thin stock warned. finish requires a pass when the spec or requirements name a profile.+ /// `sourcing check --bom <csv>`: every electrical board reference in the BOM with an MPN, any source on any profile (a profile states preferences: fab prefers Mouser or Adom stock, jlcpcb prefers LCSC numbers; other rows are consigned parts you send the fab, warned once with a suggestion per row from adom-parts-search), a dated stock check, thin stock warned. finish requires a pass when the spec or requirements name a profile. Sourcing { what: String, #[arg(long)] bom: String, #[arg(long, value_parser = ["fab", "jlcpcb"])] profile: Option<String>, /// boards to build (default: requirements build.quantity, else 10) #[arg(long)] qty: Option<u64>, /// the board to diff against (default: the run's current board) #[arg(long)] board: Option<String>, #[arg(long)] requirements: Option<String> },@@ -230,6 +231,11 @@ enum Cmd { /// only record this reviewed plan's hash now, before the zones it describes change #[arg(long)] record_plan: bool, },+ /// Zone ownership: `zones forget --id <id>|--missing --why "..."` drops owned zone IDs that exist neither live nor in the saved board (recorded in zoneOwnershipDropped)+ Zones { #[arg(value_parser = ["forget"])] action: String, /// an owned zone item ID to forget (repeatable)+ #[arg(long)] id: Vec<String>, /// every owned ID missing from the live board and the saved boards+ #[arg(long)] missing: bool, /// why (recorded)+ #[arg(long)] why: Option<String> }, /// Filled copper per layer from the live board Measure, /// current or thermal. `analyze current --ack [email protected]|NET@x,y --why "..."` records a reviewed part-limited peak over the Fields density screen (repeat --ack for more); it covers only that place (the pad or 0.5 mm), keeps the number reported and expires when the copper fingerprint or the spec changes. spec.boardSink declares a board-face heat sink for named parts (thermal).@@ -382,6 +388,82 @@ fn board_step_hints(r: &Run, step: &str) -> Vec<String> { out } +/// Suggestions for the consigned rows of a sourcing check: one `adom-parts-search search <MPN>`+/// per distinct MPN (at most 25 per run, no retries), the exact MPN looked up at the profile's+/// preferred source. Results are cached in the run folder (sourcing-search-cache.json), so a rerun+/// searches nothing it has already searched. Skipped with one note when adom-parts-search is not+/// installed or AIFLOW_NO_PART_SEARCH=1. Returns (suggestions, note).+fn part_suggestions(dir: &Path, prof: &str, consigned: &Value) -> (Vec<Value>, Option<String>) {+ const LIMIT: usize = 25;+ let rows = consigned.as_array().cloned().unwrap_or_default();+ if rows.is_empty() { return (Vec::new(), None); }+ if std::env::var("AIFLOW_NO_PART_SEARCH").map(|v| v == "1").unwrap_or(false) {+ return (Vec::new(), Some("part suggestions skipped (AIFLOW_NO_PART_SEARCH=1)".into()));+ }+ let installed = std::env::var_os("PATH").map(|p| std::env::split_paths(&p).any(|d| d.join("adom-parts-search").is_file())).unwrap_or(false);+ if !installed {+ return (Vec::new(), Some("part suggestions skipped: adom-parts-search is not installed (`adom-wiki discover find \"adom-parts-search\"` installs it), so the consigned rows were not looked up at the preferred source".into()));+ }+ let cache_path = dir.join("sourcing-search-cache.json");+ let mut cache: Value = std::fs::read_to_string(&cache_path).ok().and_then(|t| serde_json::from_str(&t).ok()).filter(Value::is_object).unwrap_or_else(|| json!({}));+ let (mut searched, mut over_limit, mut changed) = (0usize, Vec::new(), false);+ let mut out = Vec::new();+ for row in &rows {+ let mpn = row["mpn"].as_str().unwrap_or("").trim().to_string();+ if mpn.is_empty() { continue; }+ let key = mpn.to_ascii_uppercase();+ let (result, from) = if let Some(c) = cache.get(&key).filter(|c| c["result"].is_object()) {+ (c["result"].clone(), "cache")+ } else if searched >= LIMIT {+ if !over_limit.contains(&mpn) { over_limit.push(mpn.clone()); }+ continue;+ } else {+ searched += 1;+ match parts_search_once(&mpn) {+ Ok(v) => {+ // a vendor that failed is not cached, so the next check can look again+ if v["mouser"]["ok"] != false && v["jlcpcb"]["ok"] != false {+ cache[&key] = json!({"checkedAt": now(), "result": {"query": v["query"], "mouser": v["mouser"], "jlcpcb": v["jlcpcb"]}});+ changed = true;+ }+ (v, "search")+ }+ Err(e) => {+ let failed = json!({"ok": false, "error": e});+ (json!({"mouser": failed, "jlcpcb": failed}), "search")+ }+ }+ };+ out.push(json!({"refs": row["refs"], "mpn": mpn, "preferred": preboard::preferred_source(prof), "from": from, "suggestion": preboard::part_suggestion(prof, row, &result)}));+ }+ if changed { let _ = std::fs::write(&cache_path, serde_json::to_string_pretty(&cache).unwrap_or_default()); }+ let note = (!over_limit.is_empty()).then(|| format!("{} more MPN(s) not looked up ({LIMIT} searches per check, shared quota): {}; check again to look them up, or `adom-parts-search show <MPN>` one at a time", over_limit.len(), over_limit.join(", ")));+ (out, note)+}++/// One `adom-parts-search search <MPN>`, 60 s at most, no retry. Its stdout is the unified JSON.+fn parts_search_once(mpn: &str) -> Result<Value, String> {+ use std::process::{Command, Stdio};+ let tmp = std::env::temp_dir().join(format!("aiflow-parts-search-{}-{}.json", std::process::id(), mpn.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>()));+ let f = std::fs::File::create(&tmp).map_err(|e| e.to_string())?;+ let mut child = Command::new("adom-parts-search").args(["search", mpn]).stdin(Stdio::null()).stdout(f).stderr(Stdio::null()).spawn().map_err(|e| e.to_string())?;+ let start = std::time::Instant::now();+ let status = loop {+ if let Some(s) = child.try_wait().map_err(|e| e.to_string())? { break s; }+ if start.elapsed() >= std::time::Duration::from_secs(60) {+ let _ = child.kill();+ let _ = child.wait();+ let _ = std::fs::remove_file(&tmp);+ return Err("adom-parts-search timed out after 60 s".into());+ }+ std::thread::sleep(std::time::Duration::from_millis(50));+ };+ let body = std::fs::read_to_string(&tmp).unwrap_or_default();+ let _ = std::fs::remove_file(&tmp);+ let v: Value = serde_json::from_str(&body).map_err(|_| format!("adom-parts-search exited {status} without JSON"))?;+ Ok(v)+}+ fn thread(cli: &Cli) -> String { cli.ai_thread.clone().unwrap_or_else(|| err("this command changes state and needs --ai-thread <name> (the name of the AI conversation calling)", &["Re-run with --ai-thread \"<your thread name>\".".into()])) }@@ -2897,10 +2979,21 @@ fn main() { out["checkedAt"] = json!(now()); out["profileNamedBy"] = json!(if profile.is_some() { "--profile" } else { named.as_ref().map(|n| n.1).unwrap_or("default") }); out["qtySource"] = json!(qty_src);+ let (suggestions, suggestion_note) = part_suggestions(&r.dir, &prof, &out["consignedRows"]["list"]);+ out["suggestions"] = json!(suggestions);+ if let Some(n) = &suggestion_note { out["suggestionsNote"] = json!(n); } r.outcome("sourcing", out.clone()); r.save().unwrap();- let warnings: Vec<String> = out["warnings"].as_array().cloned().unwrap_or_default().iter().filter_map(|w| w.as_str().map(|s| format!("warning: {s}"))).collect();- let rule = if prof == "jlcpcb" { "jlcpcb: every row carries its LCSC number (JLCPCB/LCSC basic parts first)" } else { "fab: every row from Mouser or Adom stock, no LCSC/JLCPCB parts (the 3rd party fab)" };+ let consigned_warning = out["consignedRows"]["warning"].as_str().unwrap_or("").to_string();+ let mut warnings: Vec<String> = Vec::new();+ for w in out["warnings"].as_array().cloned().unwrap_or_default().iter().filter_map(|w| w.as_str()) {+ warnings.push(format!("warning: {w}"));+ if !consigned_warning.is_empty() && w == consigned_warning {+ for sg in &suggestions { if let Some(t) = sg["suggestion"].as_str() { warnings.push(format!(" suggestion: {t}")); } }+ if let Some(n) = &suggestion_note { warnings.push(format!(" {n}")); }+ }+ }+ let rule = if prof == "jlcpcb" { "jlcpcb prefers JLCPCB/LCSC parts with their LCSC number (basic parts first); any other source is fine as consigned parts you send JLCPCB" } else { "fab (the 3rd party fab) prefers Mouser or Adom stock; any other distributor (DigiKey, Arrow, ...) is fine as consigned parts you send the fab" }; if out["pass"] != true { let errs: Vec<String> = out["errors"].as_array().cloned().unwrap_or_default().iter().filter_map(|w| w.as_str().map(|s| format!(" {s}"))).collect(); let mut hints = vec![format!("Profile {rule}. Fix the BOM (aiflow-sourcing: search by spec and in stock, record stock and the date), then check again."), "Every electrical reference needs a row with an MPN; contacts and test pads the footprint does not exclude from the BOM take a `no part` row.".into(), override_note];@@ -3629,7 +3722,7 @@ fn main() { }; s["planeUnderIssues"] = json!(pu_issues); // the switch node: short, and never under another part (switchnode.rs)- let sw_issues = { let rb = Board::from_text(&routed.display().to_string(), text.clone()).unwrap_or_else(|e| err(&e, &[])); switchnode::findings(&spec_g, &rb) };+ let sw_issues = { let rb = Board::from_text(&routed.display().to_string(), text.clone()).unwrap_or_else(|e| err(&e, &[])); if let Some(n) = switchnode::note(&spec_g, &rb) { eprintln!("{n}"); } switchnode::findings(&spec_g, &rb) }; s["switchNodeIssues"] = json!(sw_issues); let dec = decoupling_check::review_text("gate", &spec_g, &routed.display().to_string(), &text, !adds_copper, if adds_copper && !aiflow_copper::native_drc_available() { "an approximate raster fill by the gate, not a native refill" } else { "the zone fills saved in the board file" }, &plan_v, None, r.outcomes().get("pour")); if let Some(d) = &dec { s["decoupling"] = d.value.clone(); }@@ -3859,6 +3952,9 @@ fn main() { r.stage_start("land-pours", Some("binary"), None); let before = br.zone_state(&rb, true); if !Bridge::ok(&before) { err("cannot read settled zones before landing", &[before.to_string()]); }+ // issue #102: owned IDs gone from the live board and both saved boards (KiCad died after the commit)+ let run_board = r.current_board();+ for l in zone_ownership::reconcile_before_landing(&mut r.data, &before, &run_board, &br, &rb, &dir) { eprintln!("{l}"); } let mut owned: Vec<String> = r.data["ownedZoneIds"].as_array().into_iter().flatten().filter_map(|v|v.as_str().map(str::to_string)).collect(); // Legacy successful operation replies are evidence of creation, not an inference. if let Some(groups)=r.outcomes().get("land-pours").and_then(|o|o["groups"].as_array()).filter(|_| r.data.get("ownedZoneIds").is_none()) {@@ -3904,6 +4000,12 @@ fn main() { let mut r = load_run(&cli); match pourverify::command(&mut r, &dir, plan, *adopt, why.as_deref(), *record_plan) { Ok((m, h)) => ok(&m, &h), Err((m, h)) => err(&m, &h) } }+ Cmd::Zones { action: _, id, missing, why } => {+ thread(&cli);+ let mut r = load_run(&cli);+ let br = bridge_of(&r, &cli);+ match zone_ownership::forget_command(&mut r, &br, &dir, id, *missing, why.as_deref()) { Ok((m, h)) => ok(&m, &h), Err((m, h)) => err(&m, &h) }+ } Cmd::Measure => { let mut r = load_run(&cli); let br = bridge_of(&r, &cli);@@ -4249,6 +4351,7 @@ fn main() { let sharp = outline::sharp_corners(&edges); if !sharp.is_empty() && r.data["sequence"].is_array() { missing.push(format!("outline: {} sharp corner(s) (outline round)", sharp.len())); } }+ if let Some(n) = switchnode::note(&spec_f, &board_now()) { eprintln!("{n}"); } for f in switchnode::findings(&spec_f, &board_now()) { missing.push(format!("switch node: {f} (back to placement)")); } // a recorded silkscreen proof is re-checked against the board's own pads and vias (the audit's geometry is supplied) if let Some(inp) = o.get("silkscreen").and_then(|p| p["input"].as_str()) {@@ -4691,12 +4794,18 @@ fn main() { if !st.success() { err("landing the heatsink vias stopped (see above)", &["Fix what it says, then `thermal-vias plan` again (the plan counts what landed) and land.".into()]); } } let r2 = load_run(&cli);- let live_board = live_board(&r2, &cli, &dir).map(|l| Board::load(&l.1).unwrap_or_else(|e| err(&e, &[]))).unwrap_or_else(|| board_of(&r2));- let short = thermalvias::short(&spec, &live_board, &acked);+ // issue #101: count on the live board (Fusion: its live snapshot; KiCad: kicad_routing_state)+ let (short, counted_on) = match live_board(&r2, &cli, &dir) {+ Some(l) => (thermalvias::short(&spec, &Board::load(&l.1).unwrap_or_else(|e| err(&e, &[])), &acked), "live board"),+ None => match r2.data["remoteBoard"].as_str().filter(|s| !s.is_empty()).and_then(|rb| bridge_of(&r2, &cli).live_copper(rb)) {+ Some(live) => (thermalvias::short_live(&spec, &board_of(&r2), &live, &acked), "live board (kicad_routing_state)"),+ None => { eprintln!("NOTE: the live board could not be read (kicad_routing_state); the heatsink vias are counted on the saved snapshot, which lacks vias landed since the last adopt-board"); (thermalvias::short(&spec, &board_of(&r2), &acked), "saved snapshot (live read unavailable)") }+ },+ }; let mut r = r2;- r.outcome("thermal-vias", json!({"landed": n, "short": short.iter().map(|(a, h, t)| json!({"ref": a, "have": h, "target": t})).collect::<Vec<_>>(), "acked": acked, "leftOutAcked": left_out}));+ r.outcome("thermal-vias", json!({"landed": n, "short": short.iter().map(|(a, h, t)| json!({"ref": a, "have": h, "target": t})).collect::<Vec<_>>(), "acked": acked, "leftOutAcked": left_out, "countedOn": counted_on})); r.save().unwrap();- if !short.is_empty() { err(&format!("{n} heatsink vias landed, but parts are still short: {}", short.iter().map(|(a, h, t)| format!("{a} {h}/{t}")).collect::<Vec<_>>().join(", ")), &["`thermal-vias plan` again (more room after a move or a pour), or `--ack REF --why`.".into()]); }+ if !short.is_empty() { err(&format!("{n} heatsink vias landed, but parts are still short (counted on the {counted_on}): {}", short.iter().map(|(a, h, t)| format!("{a} {h}/{t}")).collect::<Vec<_>>().join(", ")), &["`thermal-vias plan` again (more room after a move or a pour), or `--ack REF --why`.".into()]); } ok(&format!("{n} heatsink vias landed; every hot part at its target"), &["Next: `step pours` (the pours connect the vias), then the analyses read the heat path through them.".into()]); } }
crates/adom-aiflow/src/placement_verify.rs+57−14@@ -45,23 +45,37 @@ fn pair_text(o: &Value) -> String { // A connector's mechanical courtyard may intentionally cross an edge. Its copper // stays on the board and native clearance/placement DRC remains mandatory.-fn edge_connector(board: &Board, p: &Value, spec: &Value) -> bool {- let Some(reference)=p["ref"].as_str() else {return false};+fn edge_connector(board: &Board, p: &Value, spec: &Value) -> bool { edge_connector_why(board,p,spec).is_ok() }++/// Why a part outside the outline is not an accepted `placement.edgeConnectors` entry, or Ok.+/// Issue #100: the declared footprint is any footprint the board really uses for that reference (a+/// project-library `<lib>:<name>` as well as KiCad's `Connector_*`); the comparison with the live and+/// saved footprint is what binds the declaration. Pads and footprint copper are checked with+/// `aiflow_place::copper_outside`, the same check `placement.edgeOverhang` (#95) applies before landing.+pub fn edge_connector_why(board: &Board, p: &Value, spec: &Value) -> Result<(),String> {+ let reference=p["ref"].as_str().ok_or("missing reference")?; let c=&spec["placement"]["edgeConnectors"][reference];- let Some(lib)=c["footprint"].as_str().filter(|s|s.starts_with("Connector_")) else{return false};- if p["footprint"]!=lib || c["reason"].as_str().is_none_or(|s|s.trim().is_empty()) {return false}- let Some(limit)=c["maxCourtyardOverhangMm"].as_f64().filter(|x|x.is_finite()&&*x>0.0) else{return false};- let Some(edge)=c["edge"].as_str() else{return false};+ if c.is_null() {+ let overhang=aiflow_place::edge_overhang(spec).unwrap_or_default();+ return Err(if overhang.iter().any(|(r,_)|r==reference) {+ format!("{reference} is in placement.edgeOverhang, which lets its moves land; place verify also needs placement.edgeConnectors.{reference} with footprint, edge, maxCourtyardOverhangMm and reason (docs/edge-connectors.md)")+ } else { format!("{reference} is outside the outline and not declared in placement.edgeConnectors") });+ }+ let lib=c["footprint"].as_str().map(str::trim).filter(|s|!s.is_empty()).ok_or_else(||format!("placement.edgeConnectors.{reference} needs the footprint the board uses"))?;+ if p["footprint"]!=lib { return Err(format!("placement.edgeConnectors.{reference} names footprint {lib}; the board uses {}",p["footprint"])) }+ if c["reason"].as_str().is_none_or(|s|s.trim().is_empty()) { return Err(format!("placement.edgeConnectors.{reference} needs a reason")) }+ let limit=c["maxCourtyardOverhangMm"].as_f64().filter(|x|x.is_finite()&&*x>0.0).ok_or_else(||format!("placement.edgeConnectors.{reference} needs a positive maxCourtyardOverhangMm"))?;+ let edge=c["edge"].as_str().ok_or_else(||format!("placement.edgeConnectors.{reference} needs edge (minX, minY, maxX or maxY)"))?; let (x0,y0,x1,y1)=board.bbox; let keys=["minX","minY","maxX","maxY"]; let mut bounds=[0.0;4]; for (i,key) in keys.iter().enumerate(){- let Some(v)=p["courtyard"][key].as_f64().filter(|x|x.is_finite()) else{return false};bounds[i]=v;+ bounds[i]=p["courtyard"][key].as_f64().filter(|x|x.is_finite()).ok_or_else(||format!("{reference}: the live state has no courtyard {key}"))?; }- if bounds[0]>=bounds[2]||bounds[1]>=bounds[3]||p["courtyardSource"]!="courtyard" {return false}+ if bounds[0]>=bounds[2]||bounds[1]>=bounds[3]||p["courtyardSource"]!="courtyard" {return Err(format!("{reference}: no usable courtyard in the live state"))} let mut allowed=[x0,y0,x1,y1];- match edge {"minX"=>allowed[0]-=limit,"minY"=>allowed[1]-=limit,"maxX"=>allowed[2]+=limit,"maxY"=>allowed[3]+=limit,_=>return false}- if bounds[0]<allowed[0]-1e-6||bounds[1]<allowed[1]-1e-6||bounds[2]>allowed[2]+1e-6||bounds[3]>allowed[3]+1e-6{return false}+ match edge {"minX"=>allowed[0]-=limit,"minY"=>allowed[1]-=limit,"maxX"=>allowed[2]+=limit,"maxY"=>allowed[3]+=limit,_=>return Err(format!("placement.edgeConnectors.{reference}: edge {edge} is not minX, minY, maxX or maxY"))}+ if bounds[0]<allowed[0]-1e-6||bounds[1]<allowed[1]-1e-6||bounds[2]>allowed[2]+1e-6||bounds[3]>allowed[3]+1e-6{return Err(format!("{reference}: the courtyard crosses the outline beyond {limit} mm at {edge}, or at another edge"))} // The entire protruding span must lie on one straight native outline edge. // Rounded corners and concave outlines are never authorized by a bounding box. let span=match edge {"minX"|"maxX"=>(bounds[1],bounds[3]),_=>(bounds[0],bounds[2])};@@ -71,10 +85,13 @@ fn edge_connector(board: &Board, p: &Value, spec: &Value) -> bool { let (an,at,bn,bt)=if vertical{(ax,ay,bx,by)}else{(ay,ax,by,bx)}; (an-face).abs()<1e-6 && (bn-face).abs()<1e-6 && at.min(bt)<=span.0 && at.max(bt)>=span.1 });- if !face_exists || p["insideOutline"]!=false{return false}+ if !face_exists {return Err(format!("{reference}: the overhang does not sit along one straight outline edge at {edge}"))}+ if p["insideOutline"]!=false {return Err(format!("{reference}: the live state does not report it outside the outline"))} let pads:Vec<_>=board.pads.iter().filter(|q|q.reference==reference).collect();- !pads.is_empty() && pads.iter().all(|q| !q.polys.is_empty() && q.polys.iter().all(|poly|- !poly.is_empty() && poly.iter().all(|&(x,y)| aiflow_board::geom::point_in_poly(x,y,&board.outline))))+ if pads.is_empty() || pads.iter().any(|q| q.polys.is_empty() || q.polys.iter().any(|poly|poly.is_empty())) {return Err(format!("{reference}: no pad geometry to check"))}+ let outside=aiflow_place::copper_outside(board,&[reference.to_string()]);+ if !outside.is_empty() {return Err(format!("{reference}: an edge connector's copper stays inside the outline: {}",outside.join("; ")))}+ Ok(()) } pub fn check_with_spec(board: &Board, before: &Value, validation: &Value, after: &Value, remote: &str, spec: &Value) -> Result<Verified,String> {@@ -96,7 +113,8 @@ pub fn check_with_spec(board: &Board, before: &Value, validation: &Value, after: let delta=if key=="rotation" { ((got-want+180.0).rem_euclid(360.0)-180.0).abs() } else { (got-want).abs() }; if delta>0.001 { return Err(format!("{reference}: live/saved {key} differs")); } }- if p["side"]!=f.layer || p["footprint"]!=f.lib || (p["insideOutline"]!=true && !edge_connector(board,p,spec)) { return Err(format!("{reference}: side, library or outline mismatch")); }+ if p["side"]!=f.layer || p["footprint"]!=f.lib { return Err(format!("{reference}: side or library mismatch")); }+ if p["insideOutline"]!=true { edge_connector_why(board,p,spec).map_err(|e|format!("{reference}: outline mismatch: {e}"))?; } } } let drc=&validation["drc"];@@ -220,6 +238,31 @@ mod tests { let copper_outside=Board::from_text("edge",board.text.replace("(at 0 0) (size 1 1)","(at 5 0) (size 1 1)")).unwrap(); assert!(check_with_spec(&copper_outside,&state,&native,&state,"board",&spec).is_err()); }+ #[test]+ fn a_project_library_edge_connector_is_accepted_when_it_names_the_board_footprint() {+ // issue #100: a USB-C receptacle from the project library (`<lib>:<name>`), not Connector_*+ let (_,mut state,mut native)=fixture();+ let lib="smart_laser_target:USB_C_Receptacle_HRO_TYPE-C-31-M-12";+ let board=Board::from_text("edge", format!("(kicad_pcb (gr_rect (start 0 0) (end 10 10) (layer \"Edge.Cuts\")) (footprint \"{lib}\" (layer \"F.Cu\") (at 5 5 0) (property \"Reference\" \"J1\") (pad \"A1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\" \"F.Mask\"))))")).unwrap();+ state["footprints"][0]=json!({"ref":"J1","x":5,"y":5,"rotation":0,"side":"F.Cu","footprint":lib,"insideOutline":false,"courtyardSource":"courtyard","courtyard":{"minX":4,"minY":4,"maxX":10.4,"maxY":6}});+ native["outsideOutline"]=json!(["J1"]);native["placed"]=json!(false);+ let entry=json!({"footprint":lib,"edge":"maxX","maxCourtyardOverhangMm":0.5,"reason":"USB-C mating face at the right edge"});+ let spec=json!({"placement":{"edgeConnectors":{"J1":entry}}});+ assert_eq!(check_with_spec(&board,&state,&native,&state,"board",&spec).unwrap().accepted,1);+ // the declaration must still name the footprint the board uses+ let mut other=spec.clone();other["placement"]["edgeConnectors"]["J1"]["footprint"]=json!("smart_laser_target:USB_C_OTHER");+ let e=check_with_spec(&board,&state,&native,&state,"board",&other).unwrap_err();+ assert!(e.contains("names footprint smart_laser_target:USB_C_OTHER"),"{e}");+ let mut empty=spec.clone();empty["placement"]["edgeConnectors"]["J1"]["footprint"]=json!(" ");+ assert!(check_with_spec(&board,&state,&native,&state,"board",&empty).is_err());+ // a part only in edgeOverhang (#95) is told what place verify needs+ let e=check_with_spec(&board,&state,&native,&state,"board",&json!({"placement":{"edgeOverhang":[{"ref":"J1","why":"USB-C"}]}})).unwrap_err();+ assert!(e.contains("placement.edgeOverhang") && e.contains("placement.edgeConnectors.J1"),"{e}");+ // copper past the edge stays refused (the check edgeOverhang uses)+ let copper_outside=Board::from_text("edge",board.text.replace("(at 0 0) (size 1 1)","(at 5 0) (size 1 1)")).unwrap();+ let e=check_with_spec(&copper_outside,&state,&native,&state,"board",&spec).unwrap_err();+ assert!(e.contains("pad J1.A1 reaches outside the outline"),"{e}");+ } /// Two concentric copper-only rings (ring courtyards, no pads) and a resistor R1 inside the inner ring, /// as the live state and the bridge's box verdict report them. fn rings() -> (Board,Value,Value) {
crates/adom-aiflow/src/pourverify.rs+19−5@@ -394,6 +394,13 @@ pub fn landable(zones: &[Value]) -> Result<(), String> { /// A hash of the board's zone definitions (outline, net, layers, settings), leaving out fills and /// item IDs, so a refill or a re-save does not count as a zone change. pub fn zone_fingerprint(board: &Board) -> String {+ let mut zones: Vec<String> = zone_definitions(board).into_iter().map(|(d, _)| d).collect();+ zones.sort();+ format!("{:x}", Sha256::digest(zones.join("\n").as_bytes()))+}++/// Each board zone's definition (as `zone_fingerprint` sees it: no fills, no item IDs) with its saved ID.+pub fn zone_definitions(board: &Board) -> Vec<(String, Option<String>)> { fn strip(n: &Node) -> Value { let mut children: Vec<Value> = n .nodes()@@ -406,9 +413,7 @@ pub fn zone_fingerprint(board: &Board) -> String { let atoms: Vec<&str> = (1..).map_while(|i| n.atom(i)).collect(); json!([n.tag, atoms, children]) }- let mut zones: Vec<String> = board.root.find_all("zone").map(|z| strip(z).to_string()).collect();- zones.sort();- format!("{:x}", Sha256::digest(zones.join("\n").as_bytes()))+ board.root.find_all("zone").map(|z| (strip(z).to_string(), zone_id(z))).collect() } fn mtime(p: &Path) -> Option<u64> {@@ -642,6 +647,12 @@ pub fn command(r: &mut Run, dir: &Path, plan: &str, adopt_zones: bool, why: Opti proof["ownershipGranted"] = json!(true); proof["adoptedZoneIds"] = json!(ids); proof["why"] = json!(why);+ // issue #105: a pending zones-only adoption is confirmed (or refused) now+ match crate::copper_carry::confirm_zones_carry(r, &proof) {+ Some(Ok(note)) => proof["zonesOnlyCarry"] = json!({"carried": true, "note": note}),+ Some(Err(why)) => { r.log("zones-only-carry-refused", json!({"why": why})); proof["zonesOnlyCarry"] = json!({"carried": false, "why": why}); }+ None => {}+ } } let out = dir.join("verify-pours.json"); std::fs::write(&out, serde_json::to_string_pretty(&proof).unwrap()).map_err(|x| e(format!("{}: {x}", out.display())))?;@@ -655,8 +666,11 @@ pub fn command(r: &mut Run, dir: &Path, plan: &str, adopt_zones: bool, why: Opti } let kind = proof["planProvenance"]["kind"].as_str().unwrap_or("").to_string(); if adopt_zones {- return Ok((format!("existing saved pours match the reviewed plan ({kind}); {} zone(s) adopted: owned by this run, recorded as adopted, not landed", proof["adoptedZoneIds"].as_array().map(|a| a.len()).unwrap_or(0)),- vec!["A later `land pours` may now replace these zones through the native bridge. Native DRC, live measurement, current and thermal checks remain required. Changed board, spec or plan invalidates this proof.".into()]));+ let mut hints = vec!["A later `land pours` may now replace these zones through the native bridge. Native DRC, live measurement, current and thermal checks remain required. Changed board, spec or plan invalidates this proof.".to_string()];+ let carry = &proof["zonesOnlyCarry"];+ if carry["carried"] == true { hints.insert(0, carry["note"].as_str().unwrap_or("").to_string()); }+ if carry["carried"] == false { hints.insert(0, format!("the zones-only adoption did not carry placement and route evidence: {}; verify placement and land route and vias again", carry["why"].as_str().unwrap_or("?"))); }+ return Ok((format!("existing saved pours match the reviewed plan ({kind}); {} zone(s) adopted: owned by this run, recorded as adopted, not landed", proof["adoptedZoneIds"].as_array().map(|a| a.len()).unwrap_or(0)), hints)); } Ok((format!("existing saved pours match the reviewed plan ({kind}); no copper or ownership changed"), vec!["Native DRC, live measurement, current and thermal checks remain required. Changed board, spec or plan invalidates this proof. Add --adopt --why <reason> to take ownership of these zones.".into()]))
crates/adom-aiflow/src/preboard.rs+142−16@@ -103,6 +103,8 @@ pub fn sourcing_check(bom: &str, board: Option<&(BTreeSet<String>, BTreeSet<Stri let c_src = col(header, &["source", "supplier", "distributor", "vendor"]); let c_vpn = col(header, &["vendor_pn", "supplier_pn", "distributor_pn", "mouser_pn", "vendor_part_number", "supplier_part_number"]); let c_lcsc = col(header, &["lcsc", "lcsc_pn", "lcsc_part", "lcsc_part_number", "jlcpcb_part", "jlc_pn"]);+ let c_value = col(header, &["value", "val"]);+ let c_pkg = col(header, &["package", "footprint", "case", "case_package"]); // the dated stock column: a header carrying the date (stock_checked_2026-09-29), or a stock // column beside a stock_date / checked column that carries the date per row let c_stock_dated = header.iter().position(|h| h.to_ascii_lowercase().contains("stock") && date_in(h).is_some());@@ -121,6 +123,10 @@ pub fn sourcing_check(bom: &str, board: Option<&(BTreeSet<String>, BTreeSet<Stri let (mut no_part_rows, mut adom_rows, mut distributor_rows, mut lcsc_rows) = (0usize, 0usize, 0usize, 0usize); let mut dates: BTreeSet<String> = header_date.iter().cloned().collect(); let mut adom_no_qty: Vec<String> = Vec::new();+ // Where a part is bought is the user's choice on every profile: a profile only says which sources+ // that fab prefers. Anything else is a consigned (customer-supplied) part the user sends the fab,+ // which every fab accepts. Never an error.+ let mut consigned: Vec<Value> = Vec::new(); for (i, row) in rows.iter().enumerate().skip(1) { let line = i + 1; let refs: Vec<String> = get(row, c_ref).split(|c: char| c == ' ' || c == ',' || c == ';').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect();@@ -140,20 +146,18 @@ pub fn sourcing_check(bom: &str, board: Option<&(BTreeSet<String>, BTreeSet<Stri if carries_lcsc { lcsc_rows += 1; } let adom = src_l.contains("adom"); if adom { adom_rows += 1; } else { distributor_rows += 1; }- match profile {- "jlcpcb" => {- if lcsc.is_empty() && !is_lcsc_number(&vpn) { errors.push(format!("{label} ({mpn}): no LCSC number on the jlcpcb profile (add an lcsc column, e.g. C12345)")); }- }- _ => {- if carries_lcsc { errors.push(format!("{label} ({mpn}): an LCSC/JLCPCB part on the fab profile; source it from Mouser or Adom stock")); }- else if !(src_l.contains("mouser") || adom) { errors.push(format!("{label} ({mpn}): source `{src}` is not Mouser or Adom stock on the fab profile")); }- }- }+ let customer = src_l.contains("customer") || src_l.contains("consign") || src_l.contains("own stock") || src_l.contains("supplied");+ let preferred = match profile {+ "jlcpcb" => !lcsc.is_empty() || is_lcsc_number(&vpn),+ _ => src_l.contains("mouser") || adom,+ };+ if !preferred { consigned.push(json!({"refs": label, "mpn": mpn, "source": src, "value": get(row, c_value), "package": get(row, c_pkg)})); } let stock_text = get(row, c_stock); if let Some(d) = c_stock_date.map(|c| get(row, Some(c))).filter(|d| !d.is_empty()) { if let Some(d) = date_in(&d) { dates.insert(d); } } let stock = leading_count(&stock_text); if stock_text.is_empty() { if adom { adom_no_qty.push(label.clone()); }+ else if customer { warnings.push(format!("{label} ({mpn}): customer-supplied, no stock count recorded")); } else { errors.push(format!("{label} ({mpn}): no stock check recorded")); } } else if stock.is_none() { warnings.push(format!("{label} ({mpn}): stock `{stock_text}` is not a count"));@@ -165,6 +169,16 @@ pub fn sourcing_check(bom: &str, board: Option<&(BTreeSet<String>, BTreeSet<Stri e.0 += refs.len() as u64; if e.1.is_none() { e.1 = stock; } }+ let mut consigned_warning = String::new();+ if !consigned.is_empty() {+ let n = consigned.len();+ let list = consigned.iter().map(|c| format!("{} ({}, {})", c["refs"].as_str().unwrap_or(""), c["mpn"].as_str().unwrap_or(""), c["source"].as_str().filter(|s| !s.is_empty()).unwrap_or("no source named"))).collect::<Vec<_>>().join("; ");+ consigned_warning = match profile {+ "jlcpcb" => format!("{n} row(s) are not JLCPCB/LCSC parts ({list}). That is fine: buy them anywhere (DigiKey, Mouser, ...) and send them to JLCPCB as consigned parts. Picking an LCSC part instead is usually cheaper and faster for JLCPCB assembly."),+ _ => format!("{n} row(s) come from a source the 3rd party fab does not stock ({list}). That is fine: buy them anywhere (DigiKey, Arrow, ...) and send them to the fab as consigned parts. Mouser or Adom stock is the fab's preferred source and saves a shipment."),+ };+ warnings.push(consigned_warning.clone());+ } if !adom_no_qty.is_empty() { warnings.push(format!("{} Adom stock row(s) with no quantity recorded ({}): `pnp-inventory bulk-check --bom <csv>` fills it", adom_no_qty.len(), adom_no_qty.join(", "))); } for (mpn, (n, stock, label)) in &per_mpn { if let Some(s) = stock {@@ -185,9 +199,50 @@ pub fn sourcing_check(bom: &str, board: Option<&(BTreeSet<String>, BTreeSet<Stri "stockCheckDates": dates, "boardChecked": board.is_some(), "boardRefs": board.map(|(n, _)| n.len()), "excludedByBoard": board.map(|(_, x)| x.iter().cloned().collect::<Vec<_>>()), "missing": missing, "extra": extra, "errors": errors, "warnings": warnings,+ "consignedRows": {"count": consigned.len(), "list": consigned, "warning": consigned_warning}, }) } +/// The source a profile prefers, by name: where the part suggestions look.+pub fn preferred_source(profile: &str) -> &'static str {+ if profile == "jlcpcb" { "LCSC" } else { "Mouser" }+}++/// What to search for an equivalent when the exact MPN is not at the preferred source: the+/// row's value and package (its key spec), else the MPN itself.+fn equivalent_query(row: &Value) -> String {+ let parts: Vec<&str> = ["value", "package"].iter().filter_map(|k| row[*k].as_str().map(str::trim).filter(|s| !s.is_empty())).collect();+ if parts.is_empty() { row["mpn"].as_str().unwrap_or("").to_string() } else { parts.join(" ") }+}++/// One consigned row's suggestion from an `adom-parts-search search <MPN>` result (its unified+/// JSON): the exact MPN, in stock, at the profile's preferred source, or a pointer to an+/// equivalent. Pure, so the wording is tested offline.+pub fn part_suggestion(profile: &str, row: &Value, search: &Value) -> String {+ let mpn = row["mpn"].as_str().unwrap_or("").trim();+ let who = format!("{} ({mpn})", row["refs"].as_str().unwrap_or(""));+ let pref = preferred_source(profile);+ let vendor = if profile == "jlcpcb" { "jlcpcb" } else { "mouser" };+ let v = &search[vendor];+ let fallback = |why: String| format!("{who}: {why}; `adom-parts-search show \"{}\"` to look for an equivalent, or send this part as consigned", equivalent_query(row));+ if v["ok"] == false {+ return fallback(format!("the {pref} search failed ({})", v["error"].as_str().unwrap_or("no reason given")));+ }+ let best = v["hits"].as_array().into_iter().flatten()+ .filter(|h| h["mpn"].as_str().map(|m| m.trim().eq_ignore_ascii_case(mpn)).unwrap_or(false))+ .filter(|h| h["stock"].as_u64().unwrap_or(0) > 0)+ .max_by_key(|h| h["stock"].as_u64().unwrap_or(0));+ let Some(h) = best else { return fallback(format!("no exact match at {pref}")) };+ let (pn, qty) = (h["vendor_pn"].as_str().unwrap_or("?"), h["stock"].as_u64().unwrap_or(0));+ if profile == "jlcpcb" {+ let flags: Vec<String> = h["flags"].as_array().into_iter().flatten().filter_map(|f| f.as_str().map(|s| s.to_ascii_lowercase())).collect();+ let tier = if flags.iter().any(|f| f == "basic") { "basic, " } else if flags.iter().any(|f| f == "extended") { "extended, " } else { "" };+ format!("{who}: LCSC has {mpn} ({pn}, {tier}{qty} in stock): add it to the lcsc column")+ } else {+ format!("{who}: Mouser has {mpn} ({pn}, {qty} in stock): set the source to Mouser to skip a consignment shipment")+ }+}+ /// Pass/fail fields of an evidence file: a top-level `pass`, and `checks` rows with /// `name`, `value`, `limit`, `pass` (a row without `pass` is judged by value against limit: /// a number is a maximum, {"min","max"} a range). Markdown: a line `pass: true|false`.@@ -400,22 +455,93 @@ mod tests { } #[test]- fn fab_profile_refuses_lcsc_and_other_sources_and_missing_mpn() {+ fn fab_profile_takes_any_source_as_consigned_but_needs_an_mpn() { let bom = format!("{HEAD}U1,x,TPS,TI,JLCPCB,C123456,1000,\nU2,x,ABC,TI,DigiKey,1-2,1000,\nU3,x,,TI,Mouser,1-2,1000,\n"); let out = sourcing_check(&bom, None, "fab", 1);- let e = out["errors"].to_string();- assert!(e.contains("U1") && e.contains("LCSC"), "{e}");- assert!(e.contains("U2") && e.contains("DigiKey"), "{e}");- assert!(e.contains("U3: no MPN"), "{e}");+ let e = out["errors"].as_array().unwrap();+ assert_eq!(e.len(), 1, "{out}");+ assert!(e[0].as_str().unwrap().contains("U3: no MPN"), "{out}");+ let w = out["warnings"].to_string();+ assert!(w.contains("2 row(s) come from a source the 3rd party fab does not stock") && w.contains("U1 (TPS, JLCPCB)") && w.contains("U2 (ABC, DigiKey)") && w.contains("consigned"), "{w}");+ assert_eq!(out["consignedRows"]["count"], 2);+ assert_eq!(out["consignedRows"]["list"][1]["source"], "DigiKey"); assert_eq!(out["boardChecked"], false); } #[test]- fn jlcpcb_profile_needs_lcsc_numbers() {+ fn fab_profile_passes_digikey_and_arrow_with_one_warning() {+ let bom = format!("{HEAD}U1,x,TPS54202DDCR,TI,DigiKey,296-TPS54202DDCRTR-ND,900,\nU2,x,LM358,TI,Arrow,LM358,800,\nU3,x,OPA2134,TI,Mouser,595-OPA2134,700,\n");+ let out = sourcing_check(&bom, Some(&refs(&["U1", "U2", "U3"], &[])), "fab", 10);+ assert_eq!(out["pass"], true, "{out}");+ let w: Vec<&str> = out["warnings"].as_array().unwrap().iter().filter_map(|x| x.as_str()).filter(|x| x.contains("consigned")).collect();+ assert_eq!(w.len(), 1, "{out}");+ assert!(w[0].starts_with("2 row(s)") && w[0].contains("DigiKey") && w[0].contains("Arrow") && w[0].contains("Mouser or Adom stock is the fab's preferred source"), "{}", w[0]);+ assert_eq!(out["consignedRows"]["count"], 2);+ assert_eq!(out["consignedRows"]["list"][0]["mpn"], "TPS54202DDCR");+ }++ #[test]+ fn jlcpcb_profile_passes_a_mouser_bom_as_consigned() {+ let bom = "ref,mpn,source,vendor_pn,stock_checked_2026-09-29\nU1,TPS54202DDCR,Mouser,595-TPS54202DDCR,5000\nR1,CR0402,Mouser,652-CR0402,9000\n";+ let out = sourcing_check(bom, None, "jlcpcb", 1);+ assert_eq!(out["pass"], true, "{out}");+ let w = out["warnings"].as_array().unwrap();+ assert_eq!(w.len(), 1, "{out}");+ assert!(w[0].as_str().unwrap().starts_with("2 row(s) are not JLCPCB/LCSC parts") && w[0].as_str().unwrap().contains("consigned parts"), "{out}");+ assert_eq!(out["consignedRows"]["count"], 2);+ }++ #[test]+ fn jlcpcb_profile_row_without_an_lcsc_number_is_consigned() { let bom = "ref,mpn,source,lcsc,stock_checked_2026-09-29\nU1,TPS,JLCPCB,C123456,5000\nU2,ABC,JLCPCB,,5000\n"; let out = sourcing_check(bom, None, "jlcpcb", 1);+ assert_eq!(out["pass"], true, "{out}");+ assert!(out["warnings"].to_string().contains("1 row(s) are not JLCPCB/LCSC parts (U2 (ABC, JLCPCB))"), "{out}");+ }++ #[test]+ fn customer_supplied_without_stock_warns() {+ let bom = format!("{HEAD}U1,x,XYZ123,Acme,customer supplied,,,\n");+ let out = sourcing_check(&bom, None, "fab", 1);+ assert_eq!(out["pass"], true, "{out}");+ assert!(out["warnings"].to_string().contains("U1 (XYZ123): customer-supplied, no stock count recorded"), "{out}");+ assert_eq!(out["consignedRows"]["count"], 1);+ }++ #[test]+ fn real_problems_still_error() {+ // no MPN and a reference listed twice+ let out = sourcing_check(&format!("{HEAD}U1,x,,TI,DigiKey,1,100,\nR1,10k,RC0402,Yageo,DigiKey,1,100,\nR1,10k,RC0402,Yageo,DigiKey,1,100,\n"), None, "fab", 1);+ assert_eq!(out["pass"], false);+ let e = out["errors"].to_string();+ assert!(e.contains("U1: no MPN") && e.contains("R1: listed twice"), "{e}");+ // BOM rows against the board+ let out = sourcing_check(&format!("{HEAD}U1,x,ABC,TI,DigiKey,1,100,\n"), Some(&refs(&["U1", "C9"], &[])), "jlcpcb", 1); assert_eq!(out["pass"], false);- assert!(out["errors"].to_string().contains("U2 (ABC): no LCSC number"));+ assert_eq!(out["missing"], json!(["C9"]));+ // a distributor row with no stock check, and one with a stock count but no date+ let out = sourcing_check(&format!("{HEAD}U1,x,ABC,TI,DigiKey,1,,\n"), None, "fab", 1);+ assert!(out["errors"].to_string().contains("U1 (ABC): no stock check recorded"), "{out}");+ let out = sourcing_check("ref,mpn,source,stock,stock_date\nU1,ABC,Arrow,100,\n", None, "fab", 1);+ assert!(out["errors"].to_string().contains("U1 (ABC): the stock check has no date"), "{out}");+ assert_eq!(out["pass"], false);+ }++ #[test]+ fn suggestions_name_the_exact_part_or_an_equivalent_search() {+ let search = json!({+ "mouser": {"ok": true, "hits": [+ {"mpn": "TPS54202DDCT", "vendor_pn": "595-TPS54202DDCT", "stock": 9000},+ {"mpn": "TPS54202DDCR", "vendor_pn": "595-TPS54202DDCR", "stock": 1200}]},+ "jlcpcb": {"ok": true, "hits": [{"mpn": "TPS54202DDCR", "vendor_pn": "C191884", "stock": 299988, "flags": ["extended"]}]}});+ let row = json!({"refs": "U1", "mpn": "TPS54202DDCR", "source": "DigiKey", "value": "", "package": ""});+ assert_eq!(part_suggestion("fab", &row, &search), "U1 (TPS54202DDCR): Mouser has TPS54202DDCR (595-TPS54202DDCR, 1200 in stock): set the source to Mouser to skip a consignment shipment");+ assert_eq!(part_suggestion("jlcpcb", &row, &search), "U1 (TPS54202DDCR): LCSC has TPS54202DDCR (C191884, extended, 299988 in stock): add it to the lcsc column");+ let out_of_stock = json!({"mouser": {"ok": true, "hits": [{"mpn": "TPS54202DDCR", "vendor_pn": "595-TPS54202DDCR", "stock": 0}]}});+ let row = json!({"refs": "U1", "mpn": "TPS54202DDCR", "source": "DigiKey", "value": "buck 2A", "package": "SOT-23-6"});+ assert_eq!(part_suggestion("fab", &row, &out_of_stock), "U1 (TPS54202DDCR): no exact match at Mouser; `adom-parts-search show \"buck 2A SOT-23-6\"` to look for an equivalent, or send this part as consigned");+ let failed = json!({"jlcpcb": {"ok": false, "error": "timeout"}});+ assert!(part_suggestion("jlcpcb", &row, &failed).contains("the LCSC search failed (timeout)")); } #[test]
crates/adom-aiflow/src/switchnode.rs+151−4@@ -7,10 +7,96 @@ use aiflow_board::Board; use serde_json::Value; -/// The switch nets: spec.switchNodes, else any net named SW / *_SW / SW_*.-pub fn nets(spec: &Value, b: &Board) -> Vec<String> {- if let Some(a) = spec["switchNodes"].as_array() { return a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect(); }- b.nets.keys().filter(|n| { let u = n.to_ascii_uppercase(); u == "SW" || u.ends_with("_SW") || u.starts_with("SW_") || u.ends_with("/SW") }).cloned().collect()+/// The switch nets (see `find`).+pub fn nets(spec: &Value, b: &Board) -> Vec<String> { find(spec, b).nets }++/// How the switch nets were found.+#[derive(Debug, Clone, PartialEq)]+pub enum Source { Spec, Structure, Name }++#[derive(Debug, Clone, PartialEq)]+pub struct Found {+ pub nets: Vec<String>,+ pub source: Source,+ /// nets named like a switch node that the board's structure says are not one+ pub not_taken: Vec<String>,+}++fn named_like(n: &str) -> bool { let u = n.to_ascii_uppercase(); u == "SW" || u.ends_with("_SW") || u.starts_with("SW_") || u.ends_with("/SW") }++/// A pin function that is a converter's switching pin: SW, LX or PH (with a number: SW1, LX2), as a+/// whole token of the KiCad pin function ("SW", "LX/SW", "PH").+fn switching_pin(function: &str) -> bool {+ function.to_ascii_uppercase().split(|c: char| !c.is_ascii_alphanumeric()).any(|t| {+ let head = t.trim_end_matches(|c: char| c.is_ascii_digit());+ matches!(head, "SW" | "LX" | "PH")+ })+}++/// An inductor: a reference L<digit> (L1, L12), or a footprint from an inductor library or named L_*.+fn inductor(reference: &str, lib: &str) -> bool {+ let r = reference.as_bytes();+ let by_ref = r.len() >= 2 && r[0] == b'L' && r[1].is_ascii_digit();+ let l = lib.to_ascii_lowercase();+ let name = l.rsplit(':').next().unwrap_or("");+ by_ref || l.split(':').next().unwrap_or("").contains("inductor") || name.starts_with("l_")+}++/// Issue #104: the switch nets.+/// 1. spec.switchNodes, when given (an empty list means none);+/// 2. spec.switching's nets that are on the board (the converter block Fields models);+/// 3. structure: a net that joins an inductor pad and a switching pin (pin function SW, LX or PH) of+/// another part;+/// 4. only when the structure finds none: nets named SW, *_SW, SW_* or */SW, except one with no inductor+/// pad on a board whose structure is known (it has inductors, or its pads carry pin functions). The+/// caller prints `note` so the human sees what was taken and the override.+pub fn find(spec: &Value, b: &Board) -> Found {+ if let Some(a) = spec["switchNodes"].as_array() { return Found { nets: a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect(), source: Source::Spec, not_taken: vec![] }; }+ let declared: Vec<String> = spec["switching"].as_object().map(|o| o.keys().filter(|k| b.nets.contains_key(*k)).cloned().collect()).unwrap_or_default();+ if !declared.is_empty() { return Found { nets: declared, source: Source::Spec, not_taken: vec![] }; }+ // pin functions by (reference, pad number), and each footprint's library+ let mut function: std::collections::BTreeMap<(String, String), String> = Default::default();+ let mut lib: std::collections::BTreeMap<String, String> = Default::default();+ for fp in b.root.find_all("footprint") {+ let Some(r) = aiflow_board::reference_of(fp) else { continue };+ lib.insert(r.clone(), fp.atom(1).unwrap_or("").to_string());+ for pad in fp.find_all("pad") {+ if let Some(f) = pad.find("pinfunction").and_then(|n| n.value()).filter(|f| !f.is_empty()) {+ function.insert((r.clone(), pad.atom(1).unwrap_or("").to_string()), f.to_string());+ }+ }+ }+ let is_l = |r: &str| inductor(r, lib.get(r).map(String::as_str).unwrap_or(""));+ let any_inductor = lib.keys().any(|r| is_l(r));+ let mut structural: Vec<String> = Vec::new();+ let mut ruled_out: Vec<String> = Vec::new();+ for net in b.nets.keys().filter(|n| !n.is_empty()) {+ let pads: Vec<_> = b.pads.iter().filter(|p| &p.net == net).collect();+ let has_l = pads.iter().any(|p| is_l(&p.reference));+ let has_sw_pin = pads.iter().filter(|p| !is_l(&p.reference)).any(|p| function.get(&(p.reference.clone(), p.name.clone())).is_some_and(|f| switching_pin(f)));+ if has_l && has_sw_pin { structural.push(net.clone()); continue; }+ if named_like(net) && !has_l && (any_inductor || !function.is_empty()) { ruled_out.push(net.clone()); }+ }+ if !structural.is_empty() {+ let not_taken = b.nets.keys().filter(|n| named_like(n) && !structural.contains(n)).cloned().collect();+ return Found { nets: structural, source: Source::Structure, not_taken };+ }+ let named: Vec<String> = b.nets.keys().filter(|n| named_like(n) && !ruled_out.contains(n)).cloned().collect();+ Found { nets: named, source: Source::Name, not_taken: ruled_out }+}++/// The line to print before a gate judges the switch nets: which nets the name heuristic took (and the+/// override), or which switch-like names were left out. None when the spec or the structure decided+/// and nothing named like a switch node was left out.+pub fn note(spec: &Value, b: &Board) -> Option<String> {+ let f = find(spec, b);+ let list = |v: &[String]| v.join(", ");+ let override_ = |v: &[String]| format!("\"switchNodes\": [{}]", v.iter().map(|n| format!("\"{n}\"")).collect::<Vec<_>>().join(", "));+ match f.source {+ Source::Name if !f.nets.is_empty() => Some(format!("switch nodes by name only (no inductor pad shares a net with a SW/LX/PH pin): {}. If any is not a converter's switch node, set {} in the spec (spec-amend); `\"switchNodes\": []` for none.{}", list(&f.nets), override_(&f.nets), if f.not_taken.is_empty() { String::new() } else { format!(" Not taken (no inductor or no switching pin on them): {}.", list(&f.not_taken)) })),+ _ if !f.not_taken.is_empty() => Some(format!("not judged as switch nodes (named like one, but no inductor pad and switching pin share them): {}{}. To judge one anyway, list it in spec switchNodes.", list(&f.not_taken), if f.nets.is_empty() { String::new() } else { format!("; switch nodes: {}", list(&f.nets)) })),+ _ => None,+ } } fn in_box(p: (f64, f64), bb: &(f64, f64, f64, f64), grow: f64) -> bool { p.0 > bb.0 - grow && p.0 < bb.2 + grow && p.1 > bb.1 - grow && p.1 < bb.3 + grow }@@ -61,6 +147,67 @@ pub const HINT: &str = "Fix it in placement, not with copper: put the inductor r #[cfg(test)] mod tests {+ use super::*;+ use serde_json::json;++ /// U2: an analogue switch whose control pins sit on SW_DAY1/SW_DAY2; with `buck`, U1 (a regulator+ /// with pin functions) and L1 on the net SW, and L1's other pad on VOUT.+ fn board(buck: bool, functions: bool) -> Board {+ let f = |name: &str| if functions { format!(" (pinfunction \"{name}\")") } else { String::new() };+ let mut nets = String::from("(net 0 \"\") (net 1 \"SW_DAY1\") (net 2 \"SW_DAY2\") (net 3 \"GND\")");+ let mut parts = format!("(footprint \"Package_SO:SOIC-8\" (layer \"F.Cu\") (at 10 10) (property \"Reference\" \"U2\")+ (pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\") (net 1 \"SW_DAY1\"){})+ (pad \"2\" smd rect (at 1 0) (size 1 1) (layers \"F.Cu\") (net 2 \"SW_DAY2\"){})+ (pad \"3\" smd rect (at 2 0) (size 1 1) (layers \"F.Cu\") (net 3 \"GND\"){}))+ (footprint \"Resistor_SMD:R_0603\" (layer \"F.Cu\") (at 20 10) (property \"Reference\" \"R1\")+ (pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\") (net 1 \"SW_DAY1\")))", f("IN1"), f("IN2"), f("GND"));+ if buck {+ nets.push_str(" (net 4 \"SW\") (net 5 \"VOUT\")");+ parts.push_str(&format!(" (footprint \"Package_TO_SOT_SMD:SOT-23-6\" (layer \"F.Cu\") (at 30 10) (property \"Reference\" \"U1\")+ (pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\") (net 4 \"SW\"){})+ (pad \"2\" smd rect (at 1 0) (size 1 1) (layers \"F.Cu\") (net 3 \"GND\"){})+ (pad \"3\" smd rect (at 2 0) (size 1 1) (layers \"F.Cu\") (net 5 \"VOUT\"){}))+ (footprint \"Inductor_SMD:L_1210\" (layer \"F.Cu\") (at 35 10) (property \"Reference\" \"L1\")+ (pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\") (net 4 \"SW\"))+ (pad \"2\" smd rect (at 2 0) (size 1 1) (layers \"F.Cu\") (net 5 \"VOUT\")))", f("SW"), f("GND"), f("FB")));+ }+ Board::from_text("t.kicad_pcb", format!("(kicad_pcb {nets} (gr_rect (start 0 0) (end 40 20) (layer \"Edge.Cuts\")) {parts})")).unwrap()+ }++ #[test]+ fn analogue_switch_control_nets_are_not_switch_nodes() {+ // no inductor anywhere, pins with functions: none of SW_DAY* is a switch node+ let f = find(&json!({}), &board(false, true));+ assert!(f.nets.is_empty(), "{f:?}");+ assert_eq!(f.not_taken, vec!["SW_DAY1", "SW_DAY2"]);+ assert!(note(&json!({}), &board(false, true)).unwrap().contains("not judged as switch nodes"));+ // with a buck on the board: only its SW net, by structure+ let f = find(&json!({}), &board(true, true));+ assert_eq!((f.nets.clone(), f.source.clone()), (vec!["SW".to_string()], Source::Structure));+ assert_eq!(f.not_taken, vec!["SW_DAY1", "SW_DAY2"]);+ // no pin functions: the inductor still rules SW_DAY* out, and SW is taken by name (said aloud)+ let f = find(&json!({}), &board(true, false));+ assert_eq!((f.nets.clone(), f.source.clone()), (vec!["SW".to_string()], Source::Name));+ let n = note(&json!({}), &board(true, false)).unwrap();+ assert!(n.contains("switch nodes by name only") && n.contains("\"switchNodes\": [\"SW\"]"), "{n}");+ // nothing to tell structure from: the name heuristic, printed with the override+ let f = find(&json!({}), &board(false, false));+ assert_eq!(f.nets, vec!["SW_DAY1", "SW_DAY2"]);+ assert!(note(&json!({}), &board(false, false)).unwrap().contains("\"switchNodes\": [\"SW_DAY1\", \"SW_DAY2\"]"));+ // the spec decides when it speaks+ assert!(nets(&json!({"switchNodes": []}), &board(true, true)).is_empty());+ assert_eq!(nets(&json!({"switchNodes": ["SW_DAY1"]}), &board(true, true)), vec!["SW_DAY1"]);+ assert_eq!(nets(&json!({"switching": {"SW": {"freqHz": 5e5}, "ABSENT": {}}}), &board(true, false)), vec!["SW"]);+ assert!(note(&json!({"switchNodes": ["SW"]}), &board(true, true)).is_none());+ }++ #[test]+ fn switching_pins_and_inductors() {+ for f in ["SW", "LX", "PH", "SW1", "LX2", "LX/SW", "sw"] { assert!(switching_pin(f), "{f}"); }+ for f in ["SWEN", "IN1", "SWITCH", "VIN", "PHASE_EN", "BST"] { assert!(!switching_pin(f), "{f}"); }+ assert!(inductor("L1", "") && inductor("PL3", "Inductor_SMD:L_1210") && inductor("X1", "mylib:L_XAL4020"));+ assert!(!inductor("LED1", "LED_SMD:LED_0603") && !inductor("R1", "Resistor_SMD:R_0603"));+ } #[test] fn real_board_if_present() { let Ok(p) = std::env::var("AIFLOW_SW_BOARD") else { return };
crates/adom-aiflow/src/thermalvias.rs+59−5@@ -233,20 +233,38 @@ pub fn pour_freshness(spec: &Value, b: &Board, landed: bool) -> Vec<String> { out } -/// After landing: (reference, have, target) for every hot part short of its target.+/// After landing: (reference, have, target) for every hot part short of its target, counted on a saved board. pub fn short(spec: &Value, b: &Board, acked: &[String]) -> Vec<(String, usize, usize)> {+ let vias: Vec<(String, f64, f64, f64)> = b.vias().into_iter().map(|v| (v.net, v.x, v.y, v.size)).collect();+ short_with(spec, b, &vias, &|_, _| None, acked)+}++/// Issue #101: `short` counted on the live board (a KiCad run: the vias `land vias` just placed are live+/// and not yet in the saved snapshot). Vias come from the live kicad_routing_state; a part's heat pads+/// from the live state too when it reports them (`REF.pad` with net and centre), else from the saved+/// board (landing vias moves no part). The outline is the saved board's.+pub fn short_live(spec: &Value, b: &Board, live: &aiflow_bridge::landing::LiveCopper, acked: &[String]) -> Vec<(String, usize, usize)> {+ let vias: Vec<(String, f64, f64, f64)> = live.vias.iter().map(|v| (v.net.clone(), v.at.0, v.at.1, v.size)).collect();+ let pads = |reference: &str, net: &str| -> Option<Vec<(f64, f64)>> {+ let prefix = format!("{reference}.");+ let p: Vec<(f64, f64)> = live.pads.iter().filter(|(k, (n, _))| k.starts_with(&prefix) && n == net).map(|(_, (_, at))| *at).collect();+ if p.is_empty() { None } else { Some(p) }+ };+ short_with(spec, b, &vias, &pads, acked)+}++fn short_with(spec: &Value, b: &Board, vias: &[(String, f64, f64, f64)], live_pads: &dyn Fn(&str, &str) -> Option<Vec<(f64, f64)>>, acked: &[String]) -> Vec<(String, usize, usize)> { let max_rise = spec["hotMaxRiseC"].as_f64().unwrap_or(10.0);- let vias = b.vias(); let pours = spec["pours"].as_array().cloned().unwrap_or_default(); let mut out = Vec::new(); for (reference, h) in spec["hot"].as_object().cloned().unwrap_or_default() { if acked.contains(&reference) { continue; } let net = h["tabNet"].as_str().unwrap_or("GND"); let tgt = target(h["watts"].as_f64().unwrap_or(0.0), h["maxRiseC"].as_f64().unwrap_or(max_rise));- let pads: Vec<_> = b.pads.iter().filter(|p| p.reference == reference && p.net == net).collect();+ let pads: Vec<(f64, f64)> = live_pads(&reference, net).unwrap_or_else(|| b.pads.iter().filter(|p| p.reference == reference && p.net == net).map(|p| (p.x, p.y)).collect()); if pads.is_empty() { continue; }- let have = vias.iter().filter(|v| v.net == net && pads.iter().any(|p| (p.x - v.x).hypot(p.y - v.y) < 3.0)- && local_pours(&pours, net, (v.x, v.y), v.size / 2.0, &b.outline)).count();+ let have = vias.iter().filter(|(vn, x, y, size)| vn == net && pads.iter().any(|p| (p.0 - x).hypot(p.1 - y) < 3.0)+ && local_pours(&pours, net, (*x, *y), size / 2.0, &b.outline)).count(); if have < tgt { out.push((reference, have, tgt)); } } out@@ -311,6 +329,42 @@ mod tests { assert_eq!(super::short(&spec, &saved, &[])[0].1, 0); } + #[test]+ fn short_live_counts_the_vias_on_the_live_board() {+ // issue #101: the saved snapshot has no vias yet; the live routing state has the landed ones+ let text = r#"(kicad_pcb (net 0 "") (net 1 "GND") (layers (0 "F.Cu" signal)(31 "B.Cu" signal))+ (gr_rect (start 0 0)(end 30 30)(layer "Edge.Cuts"))+ (footprint "x" (layer "F.Cu") (at 10 10) (property "Reference" "U1")+ (pad "1" smd rect (at 0 0)(size 1 1)(layers "F.Cu")(net 1 "GND"))))"#;+ let saved = aiflow_board::Board::from_text("fixture", text.into()).unwrap();+ let spec = serde_json::json!({"hot":{"U1":{"watts":0.3,"tabNet":"GND"}},+ "pours":[{"net":"GND","layers":["F.Cu","B.Cu"],"polygon":[[5,5],[15,5],[15,15],[5,15]]}]});+ let target = super::target(0.3, 10.0);+ assert_eq!(super::short(&spec, &saved, &[]), vec![("U1".to_string(), 0, target)]);+ let via = |x: f64, y: f64, net: &str| serde_json::json!({"position": {"x": x, "y": y}, "size": 0.6, "drill": 0.3, "net_name": net, "uuid": format!("v{x}{y}")});+ let mut vias: Vec<serde_json::Value> = (0..target).map(|i| via(9.0 + 0.9 * (i % 3) as f64, 11.5 + 0.9 * (i / 3) as f64, "GND")).collect();+ // another net's via and a far one do not count+ vias.push(via(10.0, 8.5, "VIN"));+ vias.push(via(14.5, 14.5, "GND"));+ let state = serde_json::json!({"success": true, "vias": vias, "segments": [], "pads": [{"name": "U1.1", "netName": "GND", "x": 10.0, "y": 10.0}]});+ let live = aiflow_bridge::landing::LiveCopper::from_state(&state);+ assert!(super::short_live(&spec, &saved, &live, &[]).is_empty(), "every landed via counts live");+ // one fewer live via: short by one, counted live+ let mut fewer = state.clone();+ fewer["vias"].as_array_mut().unwrap().remove(0);+ let live = aiflow_bridge::landing::LiveCopper::from_state(&fewer);+ assert_eq!(super::short_live(&spec, &saved, &live, &[]), vec![("U1".to_string(), target - 1, target)]);+ // live pads win over the saved ones when the state reports them (here: U1 elsewhere live)+ let mut moved = state.clone();+ moved["pads"][0]["x"] = serde_json::json!(25.0);+ let live = aiflow_bridge::landing::LiveCopper::from_state(&moved);+ assert_eq!(super::short_live(&spec, &saved, &live, &[])[0].1, 0);+ // no live pads: the saved ones+ let mut no_pads = state.clone();+ no_pads["pads"] = serde_json::json!([]);+ assert!(super::short_live(&spec, &saved, &aiflow_bridge::landing::LiveCopper::from_state(&no_pads), &[]).is_empty());+ }+ fn zone_board(zone_poly: &str) -> aiflow_board::Board { aiflow_board::Board::from_text("test.kicad_pcb", format!(r#"(kicad_pcb (layers (0 "F.Cu" signal) (31 "B.Cu" signal))
crates/adom-aiflow/src/zone_ownership.rsadded+173@@ -0,0 +1,173 @@+//! Issue #102: zone IDs the run owns (`ownedZoneIds`) that no longer exist anywhere. `land pours`+//! commits zones through `kicad_apply_zone_plan` and keeps the returned item IDs; when KiCad dies after+//! the commit and before a save, those zones exist neither live nor in the saved file, and every later+//! landing was refused ("owned ID ... is not a live zone"). An owned ID missing from BOTH the live board+//! and every saved board read is dropped from `ownedZoneIds`, and the drop is recorded in+//! `zoneOwnershipDropped` with the IDs, the time, the reason and what was read. An ID that still exists+//! live or in a saved file is never dropped.++use serde_json::{json, Value};+use std::collections::BTreeSet;++pub const REASON_AFTER_COMMIT: &str = "missing live and saved after commit";++/// The item IDs of the live zones in a kicad_zone_state reply; None when the read failed.+pub fn live_ids(live: &Value) -> Option<BTreeSet<String>> {+ if live["success"] == false || live.get("errorCode").is_some_and(|c| !c.is_null()) { return None; }+ let rows = live["zones"].as_array()?;+ Some(rows.iter().filter_map(|r| r["id"].as_str().map(str::to_string)).collect())+}++/// The saved IDs (uuid, else tstamp) of every zone in a board file's text.+pub fn saved_ids(text: &str) -> Result<BTreeSet<String>, String> {+ // the s-expression alone: a board without an outline still has zones to read+ let root = aiflow_board::sx::parse(text)?;+ Ok(root.find_all("zone").filter_map(|z| z.find("uuid").or_else(|| z.find("tstamp")).and_then(|n| n.value()).filter(|s| !s.is_empty()).map(str::to_string)).collect())+}++pub fn owned(data: &Value) -> Vec<String> {+ data["ownedZoneIds"].as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect()+}++/// The owned IDs found neither live nor in any of the saved boards read.+pub fn missing_everywhere(owned: &[String], live: &BTreeSet<String>, saved: &[BTreeSet<String>]) -> Vec<String> {+ owned.iter().filter(|id| !live.contains(*id) && !saved.iter().any(|s| s.contains(*id))).cloned().collect()+}++/// Drop `ids` from `ownedZoneIds` and record it. Refuses an ID that is still live or saved (`still`),+/// or that the run does not own.+pub fn drop_ids(data: &mut Value, ids: &[String], still: &dyn Fn(&str) -> Option<String>, reason: &str, why: Option<&str>, evidence: Value) -> Result<Vec<String>, String> {+ if ids.is_empty() { return Ok(Vec::new()); }+ let owned_now = owned(data);+ for id in ids {+ if !owned_now.contains(id) { return Err(format!("zone ID {id} is not owned by this run (ownedZoneIds)")); }+ if let Some(w) = still(id) { return Err(format!("zone ID {id} still exists ({w}); ownership is kept")); }+ }+ data["ownedZoneIds"] = json!(owned_now.iter().filter(|x| !ids.contains(x)).collect::<Vec<_>>());+ if !data["zoneOwnershipDropped"].is_array() { data["zoneOwnershipDropped"] = json!([]); }+ data["zoneOwnershipDropped"].as_array_mut().unwrap().push(json!({"ids": ids, "at": aiflow_run::now(), "reason": reason, "why": why, "evidence": evidence}));+ Ok(ids.to_vec())+}++/// Which IDs to drop for `zones forget`: the named ones, or (`missing`) every owned ID found neither+/// live nor saved. Named IDs are checked the same way by `drop_ids`.+pub fn forget_targets(owned: &[String], ids: &[String], missing: bool, live: &BTreeSet<String>, saved: &[BTreeSet<String>]) -> Result<Vec<String>, String> {+ if missing == !ids.is_empty() { return Err("zones forget needs --id <id> (repeatable) or --missing, not both".into()); }+ if missing { return Ok(missing_everywhere(owned, live, saved)); }+ Ok(ids.to_vec())+}++/// Where an ID still exists, for `drop_ids`.+pub fn still_exists<'a>(live: &'a BTreeSet<String>, saved: &'a [(String, BTreeSet<String>)]) -> impl Fn(&str) -> Option<String> + 'a {+ move |id: &str| {+ if live.contains(id) { return Some("a live zone".into()); }+ saved.iter().find(|(_, s)| s.contains(id)).map(|(what, _)| format!("a zone in {what}"))+ }+}++/// The saved boards to read: the run's current snapshot and the desktop file `rb` pulled fresh.+/// Err when the desktop file could not be read (then nothing may be dropped).+pub fn saved_boards(run_board: &str, br: &aiflow_bridge::Bridge, rb: &str, dir: &std::path::Path) -> Result<Vec<(String, BTreeSet<String>)>, String> {+ let mut out = Vec::new();+ let text = std::fs::read_to_string(run_board).map_err(|e| format!("{run_board}: {e}"))?;+ out.push((format!("the run's snapshot {run_board}"), saved_ids(&text)?));+ let pull = dir.join(format!("zone-ownership-{}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0)));+ std::fs::create_dir_all(&pull).map_err(|e| e.to_string())?;+ let save_to = std::fs::canonicalize(&pull).map_err(|e| e.to_string())?;+ let reply = br.call("pull_file", &json!({"filePaths": [rb], "saveTo": save_to}));+ let name = rb.rsplit(['/', '\\']).next().unwrap_or("board.kicad_pcb");+ let local = save_to.join(name);+ if !aiflow_bridge::Bridge::ok(&reply) || !local.is_file() { return Err(format!("the saved desktop board {rb} could not be read: {}", reply["error"].as_str().or(reply["message"].as_str()).unwrap_or("pull_file failed"))); }+ let text = std::fs::read_to_string(&local).map_err(|e| format!("{}: {e}", local.display()))?;+ out.push((format!("the saved desktop board {rb}"), saved_ids(&text)?));+ Ok(out)+}++/// Before `land pours`: drop the owned IDs that are missing from the live readback (`live`, a+/// kicad_zone_state reply) and from both saved boards. Returns lines to print. Reads the desktop file+/// only when some owned ID is missing live.+pub fn reconcile_before_landing(data: &mut Value, live: &Value, run_board: &str, br: &aiflow_bridge::Bridge, rb: &str, dir: &std::path::Path) -> Vec<String> {+ let owned_ids = owned(data);+ let Some(live_set) = live_ids(live) else { return Vec::new() };+ if owned_ids.iter().all(|id| live_set.contains(id)) { return Vec::new(); }+ let saved = match saved_boards(run_board, br, rb, dir) {+ Ok(s) => s,+ Err(e) => return vec![format!("owned zone ID(s) missing from the live board were kept: {e}. If they are gone, `zones forget --missing --why \"...\"` once the saved board can be read.")],+ };+ let sets: Vec<BTreeSet<String>> = saved.iter().map(|s| s.1.clone()).collect();+ let gone = missing_everywhere(&owned_ids, &live_set, &sets);+ if gone.is_empty() { return Vec::new(); }+ let evidence = json!({"liveRevision": live["revision"], "liveZoneCount": live_set.len(), "savedRead": saved.iter().map(|s| s.0.clone()).collect::<Vec<_>>()});+ let dropped = drop_ids(data, &gone, &still_exists(&live_set, &saved), REASON_AFTER_COMMIT, None, evidence);+ match dropped {+ Ok(ids) => vec![format!("ownership dropped for {} owned zone ID(s) that exist neither live nor in the saved board (recorded in zoneOwnershipDropped): {}", ids.len(), ids.join(", "))],+ Err(e) => vec![format!("owned zone IDs kept: {e}")],+ }+}++type Reply = Result<(String, Vec<String>), (String, Vec<String>)>;++/// `zones forget --id <id>|--missing --why "..."`: drop owned zone IDs that exist neither live nor in+/// the saved boards (the run's snapshot and the desktop file). Needs the live read and the desktop file.+pub fn forget_command(r: &mut aiflow_run::Run, br: &aiflow_bridge::Bridge, dir: &std::path::Path, ids: &[String], missing: bool, why: Option<&str>) -> Reply {+ let e = |m: String| (m, Vec::<String>::new());+ let why = why.map(str::trim).filter(|w| !w.is_empty()).ok_or_else(|| e("zones forget needs --why \"<why these zones are gone>\" (recorded)".into()))?;+ let rb = r.data["remoteBoard"].as_str().filter(|s| !s.is_empty()).map(str::to_string).ok_or_else(|| e("zones forget needs the run's desktop board (bind-remote-board)".into()))?;+ let live = br.zone_state(&rb, false);+ let live_set = live_ids(&live).ok_or_else(|| (format!("the live zones could not be read: {}", live["errorCode"].as_str().or(live["error"].as_str()).unwrap_or("no zones in the reply")), vec!["Open the board in KiCad (the bridge reads its zones), then forget again. Nothing was changed.".into()]))?;+ let saved = saved_boards(&r.current_board(), br, &rb, dir).map_err(|m| (m, vec!["Ownership is dropped only when the saved board shows the zones are gone. Nothing was changed.".into()]))?;+ let sets: Vec<BTreeSet<String>> = saved.iter().map(|s| s.1.clone()).collect();+ let owned_ids = owned(&r.data);+ let targets = forget_targets(&owned_ids, ids, missing, &live_set, &sets).map_err(e)?;+ if targets.is_empty() { return Ok(("no owned zone ID is missing from both the live board and the saved boards; nothing to forget".into(), vec![])); }+ let reason = if missing { "missing live and saved (zones forget --missing)" } else { "zones forget --id" };+ let evidence = json!({"liveRevision": live["revision"], "liveZoneCount": live_set.len(), "savedRead": saved.iter().map(|s| s.0.clone()).collect::<Vec<_>>()});+ let dropped = drop_ids(&mut r.data, &targets, &still_exists(&live_set, &saved), reason, Some(why), evidence).map_err(|m| (m, vec!["Nothing was changed. A zone that still exists keeps its ownership; replace it with `land pours`.".into()]))?;+ r.log("zone-ownership-dropped", json!({"ids": dropped, "reason": reason, "why": why}));+ r.save().map_err(e)?;+ Ok((format!("ownership dropped for {} zone ID(s) found neither live nor saved: {}", dropped.len(), dropped.join(", ")), vec!["Recorded in run.json zoneOwnershipDropped. Next: `land pours` lands the plan's zones again.".into()]))+}++#[cfg(test)]+mod tests {+ use super::*;++ fn set(a: &[&str]) -> BTreeSet<String> { a.iter().map(|s| s.to_string()).collect() }++ #[test]+ fn only_ids_missing_live_and_saved_are_dropped_and_the_drop_is_recorded() {+ let mut data = json!({"ownedZoneIds": ["gone-1", "gone-2", "live-1", "saved-1"]});+ let live = live_ids(&json!({"success": true, "zones": [{"id": "live-1"}, {"id": "other"}]})).unwrap();+ let text = r#"(kicad_pcb (zone (net 1) (net_name "GND") (layer "F.Cu") (uuid "saved-1") (polygon (pts (xy 0 0) (xy 1 0) (xy 1 1)))))"#;+ let saved = vec![("the run's snapshot".to_string(), saved_ids(text).unwrap())];+ let sets: Vec<BTreeSet<String>> = saved.iter().map(|s| s.1.clone()).collect();+ let gone = missing_everywhere(&owned(&data), &live, &sets);+ assert_eq!(gone, vec!["gone-1", "gone-2"]);+ let dropped = drop_ids(&mut data, &gone, &still_exists(&live, &saved), REASON_AFTER_COMMIT, None, json!({"live": "rev"})).unwrap();+ assert_eq!(dropped.len(), 2);+ assert_eq!(data["ownedZoneIds"], json!(["live-1", "saved-1"]));+ let rec = &data["zoneOwnershipDropped"][0];+ assert_eq!(rec["ids"], json!(["gone-1", "gone-2"]));+ assert_eq!(rec["reason"], REASON_AFTER_COMMIT);+ assert!(rec["at"].is_string());+ // never an ID that exists live or saved, or one the run does not own+ let e = drop_ids(&mut data, &["live-1".into()], &still_exists(&live, &saved), "manual", Some("x"), Value::Null).unwrap_err();+ assert!(e.contains("still exists (a live zone)"), "{e}");+ let e = drop_ids(&mut data, &["saved-1".into()], &still_exists(&live, &saved), "manual", Some("x"), Value::Null).unwrap_err();+ assert!(e.contains("a zone in the run's snapshot"), "{e}");+ assert!(drop_ids(&mut data, &["nobody".into()], &still_exists(&live, &saved), "manual", Some("x"), Value::Null).unwrap_err().contains("not owned"));+ assert_eq!(data["ownedZoneIds"], json!(["live-1", "saved-1"]), "a refused drop changes nothing");+ }++ #[test]+ fn a_failed_live_read_gives_no_ids_and_forget_needs_one_selector() {+ assert!(live_ids(&json!({"success": false, "errorCode": "api_server_off"})).is_none());+ assert!(live_ids(&json!({"success": true})).is_none());+ let owned = vec!["a".to_string(), "b".to_string()];+ let live = set(&["a"]);+ assert_eq!(forget_targets(&owned, &[], true, &live, &[]).unwrap(), vec!["b"]);+ assert_eq!(forget_targets(&owned, &["b".into()], false, &live, &[]).unwrap(), vec!["b"]);+ assert!(forget_targets(&owned, &[], false, &live, &[]).is_err());+ assert!(forget_targets(&owned, &["b".into()], true, &live, &[]).is_err());+ }+}
docs/edge-connectors.md+5−1@@ -1,6 +1,6 @@ # Intentional board-edge connectors -`place verify` distinguishes a reviewed mechanical courtyard overhang from electrical copper outside a board. Use the actual footprint and a small bound derived from the mating-face drawing and intended board edge:+`place verify` distinguishes a reviewed mechanical courtyard overhang from electrical copper outside a board. Use the footprint the board actually uses for that reference (a KiCad `Connector_*` footprint or a project-library `<lib>:<name>` one) and a small bound derived from the mating-face drawing and intended board edge: ```json {"placement":{"edgeConnectors":{"J8":{"footprint":"Connector_USB:USB_C_Receptacle_GCT_USB4105-xx-A_16P_TopMnt_Horizontal","edge":"maxX","maxCourtyardOverhangMm":0.55,"reason":"Reviewed USB mating face at the right board edge; courtyard projects 0.505 mm."}}}}@@ -9,3 +9,7 @@ This example is specific to the electronic-load Board34 placement; do not copy its bound to another board without reviewing that connector. The allowance covers only its mechanical courtyard. All saved pad polygons remain strictly within the native outline, and the overhang must sit along one straight native edge for its full span. A rounded corner is not authorized by the enclosing rectangle. Exact live/saved position, rotation, side, library and stable revision checks remain mandatory. Native placement DRC must be available, untruncated, and clean. Missing or inconsistent native outside-outline reports fail. The bridge may report `placed: false` solely because of the reviewed courtyard. AIFlow independently verifies that exact condition; other unplaced parts, overlaps and native errors still fail. Full-board DRC, electrical and thermal checks are separate. Check the connector body, cable envelope, mounting pads and enclosure/carrier clearance in CAD before release.++## edgeConnectors and edgeOverhang++`placement.edgeOverhang: [{"ref", "why"}]` (or `--allow-outside REF`) lets `place land` and `land moves` move a part whose body crosses the outline. `placement.edgeConnectors` is the reviewed, bounded form: it also allows the part's moves, and it is the one `place verify` accepts. Both refuse any pad or footprint copper outside the outline with the same check. Declare an edge connector in `edgeConnectors`; a part only in `edgeOverhang` lands but `place verify` refuses it and names the fields to add.
docs/existing-pours.md+14−1@@ -28,8 +28,21 @@ The zone change time is when the run added the oldest board snapshot in the late `verify-pours --plan <plan> --adopt --why "<reason>"` runs the same verification and, only on a full pass, takes ownership of the matched zones. Each matched zone must have a saved `uuid` (or `tstamp`); nothing is inferred from names or geometry. The IDs join `ownedZoneIds` (legacy creation receipts are kept), the grant is appended to `zoneAdoptions` with the why, plan, plan hash, board hash and provenance, and the outcome records `mode: adopted-existing`, `ownershipGranted: true`. A later `land pours` may replace these zones through the native bridge, which still compares definitions, runs candidate DRC and refuses conflicts with unowned zones. If the live KiCad item IDs differ from the saved IDs, the bridge treats the zones as unowned and refuses: the safe outcome. +## Zones written offline: what an adoption keeps++When the live landing cannot land the zones (KiCad crashed after `kicad_apply_zone_plan`, for instance), write the planned zones from the plan `pour` wrote into the board file, fill them and run DRC with kicad-cli, save, then:++1. `adopt-board --board <file> --evidence "..."`. The copper fingerprint includes zones, so the copper evidence is voided as for any copper change. When the boards differ only in their zones (the fingerprint computed without the board's zones is unchanged), the adoption also records a pending zones-only carry (`zonesCarryPending`) holding the `land-route` and `land-vias` outcomes it voided.+2. `verify-pours --plan <the plan pour wrote> --adopt --why "..."`. On a full pass the carry decision is made here, because only now is it known that the changed zones are the plan's. It is confirmed when the plan's provenance is `pour` (its hash recorded by the run), both snapshot files are unchanged and still differ only in zones, every zone that is new or changed in the adopted board is one the verification matched, and every zone that disappeared was owned by the run. Then `land-route` and `land-vias` are restored (unless recorded again since) and a `zonesCarry` record lets `place verify` evidence through finish and finish-guard. The output says what was carried, or why not.++The gate is still required (DRC sees the zones), and measure, the current and thermal analyses and the Fields report are done again (zones change current and heat). Any other copper change (a track, a via, a pad, a footprint, the outline, rules) carries nothing, as before. A plan accepted only `--why`, or a reviewed plan, carries nothing either.++## Owned zones that are gone++`land pours` keeps the item IDs `kicad_apply_zone_plan` returns in `ownedZoneIds`. If KiCad dies after the commit and before a save, those zones exist neither live nor in the saved file. Before landing, `land pours` drops any owned ID missing from the live readback and from both saved boards (the run's snapshot and the desktop file, pulled fresh), and records it in `zoneOwnershipDropped` with the IDs, the time and the reason `missing live and saved after commit`. `zones forget --missing --why "..."` (or `--id <id>`) does the same by hand, with the why recorded. An ID that still exists live or in a saved file is never dropped, and without a live read and the desktop file nothing is dropped.+ ## Evidence -The landing-evidence slot records `mode: verified-existing` or `mode: adopted-existing`; neither claims the run landed the copper, and `finish`, `status` and the summary name the mode. Finish checks SHA-256 bindings to the board, spec and plan (a silk-only adoption is accepted only through its recorded copper carry). Native DRC, live copper measurement, current and thermal acceptance remain separate requirements. Saved fill presence alone is not proof of native fill freshness, current capacity or thermal performance.+The landing-evidence slot records `mode: verified-existing` or `mode: adopted-existing`; neither claims the run landed the copper, and `finish`, `status` and the summary name the mode. Finish checks SHA-256 bindings to the board, spec and plan (a silk-only adoption is accepted only through its recorded copper carry; a zones-only carry never stands in for pours, gate, measurement or Fields evidence). Native DRC, live copper measurement, current and thermal acceptance remain separate requirements. Saved fill presence alone is not proof of native fill freshness, current capacity or thermal performance. Do not replay `land pours` merely to restore dashboard evidence. That command is a mutation with native ownership rules. Preserve successful creation receipts and never infer ownership from names or geometry.
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.22",+ "version": "0.2.23", "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.22",+ "version": "0.2.23", "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+9−2@@ -238,7 +238,7 @@ everything drawn on the design. The lane's steps are the Fusion lane's, with `al The clock starts at the prompt even when there is no board yet. - **`intake --prompt-time <UTC of the prompt> [--brief <file>] [--requirements requirements.json] [--target <box>] [--page <owner/slug> [--page-dir <folder>]] [--eda kicad|fusion|altium]`** opens the run with the `intake` step and no board. Pre-board steps are steps like any other: `step sourcing`, `step design`, `step simulation`, `step schematic`; every command is a turn charged to the step, and with `--target` each films the tool's own window when the step works in it, else Hydrogen with the run's dashboard, else the AI's terminal window, or records a skip when nothing meaningful is on screen. `start --board ... --spec ...` on the same `--run` attaches the board: same clock, same ledger, the steps before the board stay on the page.-- **`sourcing check --bom design/bom.csv [--profile fab|jlcpcb] [--qty N] [--requirements requirements.json]`** gates the BOM. The profile comes from the spec or the requirements (`sourcing.profile` or `fab.target`), `fab` by default. Every electrical reference on the board needs a row with an MPN (contacts and pads the footprint does not exclude from the BOM take a `no part` row; the diff lists missing and extra references). On `fab` every row is from Mouser or Adom stock and no row is an LCSC/JLCPCB part; on `jlcpcb` every row carries its LCSC number. The stock column carries its check date (`stock_checked_2026-09-29`, or a `stock_date` column); under twice the build quantity is a thin-stock warning. Before the board it checks the BOM alone; run it again after `start --board`, because `finish` wants the diff.+- **`sourcing check --bom design/bom.csv [--profile fab|jlcpcb] [--qty N] [--requirements requirements.json]`** gates the BOM. The profile comes from the spec or the requirements (`sourcing.profile` or `fab.target`), `fab` by default. Every electrical reference on the board needs a row with an MPN (contacts and pads the footprint does not exclude from the BOM take a `no part` row; the diff lists missing and extra references). A profile states preferences, never a gate: any distributor is fine on any profile (DigiKey, Arrow, LCSC, ...), because every fab takes consigned (customer-supplied) parts. `fab` (the 3rd party fab) prefers Mouser or Adom stock; `jlcpcb` prefers JLCPCB/LCSC parts with their LCSC number. Rows from anywhere else pass with one warning that lists them as consigned parts, and under it one suggestion per row: the check looks the exact MPN up at the preferred source with `adom-parts-search search` (one search per distinct MPN, at most 25 per check, cached in the run folder as sourcing-search-cache.json) and says to switch the source when it is in stock there, or how to look for an equivalent. `AIFLOW_NO_PART_SEARCH=1` skips the searches. The outcome records `consignedRows` and `suggestions`. The stock column carries its check date (`stock_checked_2026-09-29`, or a `stock_date` column); under twice the build quantity is a thin-stock warning. Before the board it checks the BOM alone; run it again after `start --board`, because `finish` wants the diff. - **`evidence add --kind calcs|simulation|vendor-model --file <json|md> [--note ...]`** records the design record in the run and the ledger with its sha256 (a copy lands in `<run>/evidence/`). A simulation result that says it fails (a top-level `"pass": false`, or a `"checks"` row `{"name","value","limit","pass"}` that fails) is refused. Vendor PSpice/LTspice runs belong to the separately published adom/adom-spice-skillpack; `--kind vendor-model` records their result. - **`finish`** also refuses without a passed sourcing check against the board when the spec or the requirements name a sourcing profile, and without the evidence kinds the spec lists in `"requireEvidence": ["calcs", "simulation"]`. @@ -571,7 +571,7 @@ Read `skills/aiflow-project-page/SKILL.md` and run `adom-aiflow project-page-che ### Board-edge connectors -A USB or other edge connector may intentionally project its mechanical courtyard past a straight board edge. Do not move its mating face inward merely to satisfy a bounding-box check. `place verify` supports a reviewed `placement.edgeConnectors` entry keyed by reference, with exact `footprint`, `edge` (`minX`, `maxX`, `minY`, `maxY`), positive `maxCourtyardOverhangMm`, and a concrete `reason`. Read `docs/edge-connectors.md`. The complete protruding span must lie on a straight native outline segment, every pad polygon must stay inside the PCB, saved/live poses must match, and native placement DRC must be complete and clean. This does not waive copper, hole, silk, courtyard-overlap, mechanical mating or full-board DRC requirements. Unsupported outline geometry and unlisted protrusions remain failures.+A USB or other edge connector may intentionally project its mechanical courtyard past a straight board edge. Do not move its mating face inward merely to satisfy a bounding-box check. `place verify` supports a reviewed `placement.edgeConnectors` entry keyed by reference, with the exact `footprint` the board uses (a project-library footprint too), `edge` (`minX`, `maxX`, `minY`, `maxY`), positive `maxCourtyardOverhangMm`, and a concrete `reason`. Read `docs/edge-connectors.md`. The complete protruding span must lie on a straight native outline segment, every pad polygon must stay inside the PCB, saved/live poses must match, and native placement DRC must be complete and clean. This does not waive copper, hole, silk, courtyard-overlap, mechanical mating or full-board DRC requirements. Unsupported outline geometry and unlisted protrusions remain failures. ### Reviewed via counts do not add optional holes@@ -601,6 +601,13 @@ A drilled pad with no copper layer (an NPTH that lists only `*.Mask`, such as a - 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. - 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).**+- `placement.edgeConnectors` (what `place verify` accepts for a part whose courtyard crosses the edge) takes any footprint the board uses for that reference, a project-library `<lib>:<name>` as well as `Connector_*`; the declared footprint must equal the live and saved one. One `edgeConnectors` entry also allows the part's moves to land (as `placement.edgeOverhang` does), and both check pads and footprint copper with the same inside-the-outline test. `edgeOverhang` alone lets moves land but is not enough for `place verify`: the refusal names the `edgeConnectors` fields to add (docs/edge-connectors.md).+- `thermal-vias land` counts the landed vias on the live board: on KiCad through `kicad_routing_state` (live vias, live pad centres when reported), on Fusion through its live snapshot. When the live read fails it counts the saved snapshot and says so; the outcome records `countedOn`.+- `land pours` drops an owned zone ID that exists neither live nor in the saved board (the run's snapshot and the desktop file, pulled fresh), as after a KiCad crash between the zone commit and a save, and records it in `zoneOwnershipDropped` (ids, time, reason "missing live and saved after commit"). For a manual case: `zones forget --missing --why "..."` or `zones forget --id <id> --why "..."`. An ID that still exists live or saved is never dropped; without a live read and the desktop file nothing is dropped.+- Switch nodes without `spec.switchNodes`: the nets of `spec.switching`, else structure, a net shared by an inductor pad (L<n> or an inductor footprint) and a SW, LX or PH pin of another part. Only when structure finds none are names (SW, *_SW, SW_*, */SW) used, never a named net with no inductor on a board whose structure is known (it has inductors or pin functions), and the gate prints which nets it took with the `"switchNodes": [...]` override first. `"switchNodes": []` still means none.+- A zones-only adoption (docs/existing-pours.md): when `adopt-board` finds that only zones changed, it voids the evidence as before and records a pending carry. `verify-pours --plan <the plan pour wrote> --adopt --why ...` that matches every new zone confirms it: place verify, `land-route` and `land-vias` are carried over (recorded in `zonesCarry`); gate, measure, the analyses and Fields are done again.+ ### Reviewed current peaks and board-face sinks `analyze current --ack [email protected]|NET@x,y --why "..."` records a reviewed part-limited density peak (net, place, peak, reason, ai thread). It covers only that pad or 0.5 mm around the point, keeps the number reported, shows in `finish`, and expires when the copper fingerprint (the one copper carry-over uses) or the spec changes. A whole-net acknowledgement is refused. Read `docs/current-peak-acknowledgements.md`.
skills/aiflow-power-layout/SKILL.md+1−1@@ -29,7 +29,7 @@ Write `aiflow/spec.json` from the schematic before `start`, with a note on every "switching": {"SW": {"highV": 12, "lowV": 0, "freqHz": 500000, "riseNs": 8}} ``` - `highV` is the input rail for a buck (the output for a boost), `freqHz` the switching frequency from calcs.json, `riseNs` the switch's edge time from the regulator datasheet (8 ns is adom-fields' default, an estimate to replace). A run is a converter when the spec declares `switchNodes` or `switching`, or the calcs evidence carries a switching frequency (`fsw`, `fsw_kHz`, ...) or a `topology` (buck, boost, ...). Then `start` and `evidence add --kind calcs` warn when the block is missing, and `tour fields` and `finish` refuse it, printing the block filled from calcs.json and the board.+ `highV` is the input rail for a buck (the output for a boost), `freqHz` the switching frequency from calcs.json, `riseNs` the switch's edge time from the regulator datasheet (8 ns is adom-fields' default, an estimate to replace). Without `switchNodes`, the switch nodes are the nets of `switching`, else a net shared by an inductor pad and a SW, LX or PH pin; a net only named like one (SW_DAY1, an analogue switch's control line) is not taken when the board's structure is known, and when names alone decide, the gate prints the nets and the `"switchNodes": [...]` override first. A run is a converter when the spec declares `switchNodes` or `switching`, or the calcs evidence carries a switching frequency (`fsw`, `fsw_kHz`, ...) or a `topology` (buck, boost, ...). Then `start` and `evidence add --kind calcs` warn when the block is missing, and `tour fields` and `finish` refuse it, printing the block filled from calcs.json and the board. ## 2. Placement: the hot loop first, chains in one rotation
skills/aiflow-sourcing/SKILL.md+15−7@@ -1,23 +1,23 @@ --- name: aiflow-sourcing description: >-- Parts and CAD sourcing for an adom-aiflow board, before the schematic is frozen: the fab profile picks where parts come from (default `fab` = the 3rd party fab, Mouser plus Adom stocked basic parts, no JLCPCB parts; user-selectable "jlcpcb" = JLCPCB/LCSC basic parts), search by spec and in stock before naming an MPN, constrain passives to the stocked set, record stock and lead time with a date, then get each part's symbol, footprint and STEP in a fixed order (manufacturer site, distributor CAD links, Adom wiki component page, KiCad's library model or KiCad's package generator at the datasheet values, draw your own labelled AI-generated) and check every model on the step2glb service. Never adom-chipsmith. Trigger words: aiflow sourcing, source the BOM, pick parts, in stock, lead time, sourcing profile, fab profile, Mouser or JLCPCB, Adom basic parts, stocked values, find the STEP, 3D model for this part, manufacturer CAD, SamacSys, Ultra Librarian, footprint source, symbol source.+ Parts and CAD sourcing for an adom-aiflow board, before the schematic is frozen: the fab profile states where parts are preferred from (default `fab` = the 3rd party fab, prefers Mouser plus Adom stocked basic parts; user-selectable "jlcpcb" prefers JLCPCB/LCSC basic parts), and any distributor (DigiKey, Arrow, ...) is fine on any profile as consigned parts the fab is sent, search by spec and in stock before naming an MPN, constrain passives to the stocked set, record stock and lead time with a date, then get each part's symbol, footprint and STEP in a fixed order (manufacturer site, distributor CAD links, Adom wiki component page, KiCad's library model or KiCad's package generator at the datasheet values, draw your own labelled AI-generated) and check every model on the step2glb service. Never adom-chipsmith. Trigger words: aiflow sourcing, source the BOM, pick parts, in stock, lead time, sourcing profile, fab profile, Mouser or JLCPCB, Adom basic parts, stocked values, find the STEP, 3D model for this part, manufacturer CAD, SamacSys, Ultra Librarian, footprint source, symbol source. --- # aiflow-sourcing: parts that exist, CAD that is true A part that is not in stock is not a design choice, and a model with the wrong size or axis is not evidence. This skill covers which parts, and where their CAD comes from. -## 1. The fab profile picks the parts+## 1. The fab profile states preferences Read `fab.target` from requirements.json (aiflow-intake) and set `sourcing.profile`. Do not wait for the human to say it. -| Profile | When | Parts come from | Never |+| Profile | When | Preferred source | Anything else | |---|---|---|---|-| `fab` (default) | The 3rd party fab | Adom stocked basic parts (the pick-and-place reels) first, then Mouser | JLCPCB/LCSC-only parts |-| `jlcpcb` (user choice) | The human picks a JLCPCB build | JLCPCB/LCSC basic parts first, extended only with a reason | parts JLCPCB cannot place |+| `fab` (default) | The 3rd party fab | Adom stocked basic parts (the pick-and-place reels) first, then Mouser | consigned: buy it anywhere (DigiKey, Arrow, LCSC, ...) and send it to the fab |+| `jlcpcb` (user choice) | The human picks a JLCPCB build | JLCPCB/LCSC basic parts first, extended only with a reason | consigned: buy it anywhere and send it to JLCPCB (LCSC is usually cheaper and faster) | -Switching profile redoes this whole pass; never mix profiles in one BOM. A thin-stock IC on the 3rd party fab profile is a note and an early order, not a reason to switch to JLCPCB parts.+A profile is a preference, never a gate: the person building the board may buy any part from DigiKey or any other distributor for any fab, because fabs accept consigned (customer-supplied) parts. Pick the preferred source when it has the part in stock, since it saves a consignment shipment, and say so when it does not. A thin-stock IC on the 3rd party fab profile is a note and an early order, not a reason to switch to JLCPCB parts. ## 2. Passives: the stocked set first @@ -60,7 +60,15 @@ The binary gates this: adom-aiflow --ai-thread "<thread>" sourcing check --bom design/bom.csv [--profile fab|jlcpcb] [--qty <boards>] ``` -It reads the profile from the spec or requirements.json (`fab` by default), refuses a row without an MPN, a row from anywhere but Mouser or Adom stock on `fab` (and any LCSC/JLCPCB part there), a row without an LCSC number on `jlcpcb`, and a stock column without its check date; it warns on stock under twice the build quantity. With a board it diffs the BOM against every electrical reference (missing and extra); run it again after `start --board`, because `finish` requires the diffed pass when a profile is named. On the TPS54202 molecule the check found the contacts and machine pins (J1 to J6, MP1 to MP4) missing from the BOM.+It reads the profile from the spec or requirements.json (`fab` by default), refuses a row without an MPN, a reference listed twice, a distributor row without a stock check, and a stock column without its check date; it warns on stock under twice the build quantity. A source is never an error: rows not from the profile's preferred source (Mouser or Adom stock on `fab`, a row with an LCSC number on `jlcpcb`) pass with one warning that lists them as consigned parts, recorded as `consignedRows` in the outcome. A customer-supplied row (`customer supplied`, `consigned`) with no stock count is a warning too.++Under that warning it makes one suggestion per consigned row. It runs `adom-parts-search search <MPN>` once per distinct MPN (at most 25 per check, no retries, shared quota) and looks for that exact MPN in stock at the preferred source:++- `fab`: `Mouser has <MPN> (<Mouser PN>, <qty> in stock): set the source to Mouser to skip a consignment shipment`+- `jlcpcb`: `LCSC has <MPN> (<C-number>, basic|extended, <qty> in stock): add it to the lcsc column`+- otherwise: `no exact match at <Mouser|LCSC>; adom-parts-search show "<value package>" to look for an equivalent, or send this part as consigned`++The results are cached in the run folder (sourcing-search-cache.json), so checking again searches only new MPNs. The suggestions are stored as `suggestions` in the outcome. With adom-parts-search not installed, or `AIFLOW_NO_PART_SEARCH=1`, the searches are skipped with one note. With a board it diffs the BOM against every electrical reference (missing and extra); run it again after `start --board`, because `finish` requires the diffed pass when a profile is named. On the TPS54202 molecule the check found the contacts and machine pins (J1 to J6, MP1 to MP4) missing from the BOM. ## 4. Never write a package dimension without its source
tests/sourcing-suggestions.pyadded+73@@ -0,0 +1,73 @@+"""`sourcing check` through the CLI: any distributor passes on any profile as consigned parts, with one+warning and a suggestion per row from adom-parts-search. A fake adom-parts-search on PATH returns fixed+JSON and logs each call, so the test never spends the shared vendor quota; it proves one search per+distinct MPN, the cache in the run folder (a rerun searches nothing), and the AIFLOW_NO_PART_SEARCH=1+skip. No CAD, desktop or dashboard."""+import json, os, pathlib, subprocess, sys, tempfile+binary = str(pathlib.Path(sys.argv[1]).resolve())++FAKE = r'''#!/usr/bin/env python3+import json, os, sys+open(os.environ["FAKE_LOG"], "a").write(" ".join(sys.argv[1:]) + "\n")+mpn = sys.argv[2]+mouser = {"TPS54202DDCR": [{"mpn": "TPS54202DDCR", "vendor_pn": "595-TPS54202DDCR", "stock": 1200}]}.get(mpn, [])+jlc = {"TPS54202DDCR": [{"mpn": "TPS54202DDCR", "vendor_pn": "C191884", "stock": 299988, "flags": ["extended"]}]}.get(mpn, [])+print(json.dumps({"query": mpn, "mouser": {"ok": True, "hits": mouser}, "digikey": {"ok": True, "hits": []}, "jlcpcb": {"ok": True, "hits": jlc}}))+'''++with tempfile.TemporaryDirectory(prefix='aiflow-sourcing-') as td:+ p = pathlib.Path(td)+ fakebin = p / 'bin'; fakebin.mkdir()+ fake = fakebin / 'adom-parts-search'; fake.write_text(FAKE); fake.chmod(0o755)+ log = p / 'searches.log'+ env = dict(os.environ, AIFLOW_DASHBOARD='off', PATH=f"{fakebin}{os.pathsep}{os.environ.get('PATH', '')}", FAKE_LOG=str(log))+ env.pop('AIFLOW_NO_PART_SEARCH', None)+ def call(run, *args, extra=None):+ return subprocess.run([binary, '--run', str(run), '--ai-thread', 'sourcing-test', *args], env=dict(env, **(extra or {})), capture_output=True, text=True, timeout=120)+ def outcome(run): return json.loads((run / 'run.json').read_text())['outcomes']['sourcing']+ def searches(): return log.read_text().splitlines() if log.exists() else []++ bom = p / 'bom.csv'+ bom.write_text('ref,value,mpn,manufacturer,source,vendor_pn,package,stock_checked_2026-10-27\n'+ 'U1,buck,TPS54202DDCR,TI,DigiKey,296-TPS54202DDCRTR-ND,SOT-23-6,900\n'+ 'U2,buck,TPS54202DDCR,TI,DigiKey,296-TPS54202DDCRTR-ND,SOT-23-6,900\n'+ 'U3,op amp,ABC123,Acme,Arrow,ABC123,SOIC-8,800\n'+ 'R1,10k,CR0402-FX-1002GLF,Bourns,Adom stock,,0402,5000\n')+ run = p / 'run'+ r = call(run, 'intake', '--prompt-time', '2026-10-27T00:00:00Z', '--engine', 'test'); assert r.returncode == 0, r.stdout + r.stderr++ # the opt-out: no search, one note, still a pass+ r = call(run, 'sourcing', 'check', '--bom', str(bom), '--qty', '1', extra={'AIFLOW_NO_PART_SEARCH': '1'})+ assert r.returncode == 0, r.stdout + r.stderr+ assert 'part suggestions skipped (AIFLOW_NO_PART_SEARCH=1)' in r.stdout and not searches(), r.stdout+ o = outcome(run); assert o['consignedRows']['count'] == 3 and o['suggestions'] == [], o++ # fab: DigiKey and Arrow pass with one consigned warning; one search per distinct MPN+ r = call(run, 'sourcing', 'check', '--bom', str(bom), '--qty', '1')+ assert r.returncode == 0, r.stdout + r.stderr+ assert sum('warning:' in l and 'consigned parts' in l for l in r.stdout.splitlines()) == 1, r.stdout+ assert searches() == ['search TPS54202DDCR', 'search ABC123'], searches()+ assert 'suggestion: U1 (TPS54202DDCR): Mouser has TPS54202DDCR (595-TPS54202DDCR, 1200 in stock): set the source to Mouser to skip a consignment shipment' in r.stdout, r.stdout+ assert 'suggestion: U3 (ABC123): no exact match at Mouser; `adom-parts-search show "op amp SOIC-8"` to look for an equivalent, or send this part as consigned' in r.stdout, r.stdout+ lines = r.stdout.splitlines()+ w = next(i for i, l in enumerate(lines) if 'warning:' in l and 'consigned parts' in l)+ assert 'suggestion:' in lines[w + 1], lines[w:w + 3]+ o = outcome(run)+ assert o['pass'] and o['consignedRows']['count'] == 3 and len(o['suggestions']) == 3, o+ assert (run / 'sourcing-search-cache.json').is_file()++ # a rerun reads the cache: no new searches; jlcpcb from the same cache+ r = call(run, 'sourcing', 'check', '--bom', str(bom), '--qty', '1')+ assert r.returncode == 0 and len(searches()) == 2, searches()+ r = call(run, 'sourcing', 'check', '--bom', str(bom), '--qty', '1', '--profile', 'jlcpcb')+ assert r.returncode == 0, r.stdout + r.stderr+ assert searches()[2:] == ['search CR0402-FX-1002GLF'], searches() # only the MPN not in the cache yet+ assert 'are not JLCPCB/LCSC parts' in r.stdout, r.stdout+ assert 'LCSC has TPS54202DDCR (C191884, extended, 299988 in stock): add it to the lcsc column' in r.stdout, r.stdout+ assert outcome(run)['consignedRows']['count'] == 4 # the Adom stock row is consigned on jlcpcb++ # a real problem still fails: no MPN+ bad = p / 'bad.csv'; bad.write_text('ref,mpn,source,stock_checked_2026-10-27\nU1,,DigiKey,10\n')+ r = call(run, 'sourcing', 'check', '--bom', str(bad), '--qty', '1')+ assert r.returncode != 0 and 'U1: no MPN' in r.stdout, r.stdout+print('sourcing-suggestions: ok')
tools/altium-project.py+1−1@@ -662,7 +662,7 @@ def cmd_export(a): tps = sorted(r_ for r_, k in roles.items() if k == 'tp'); dnp = sorted(r_ for r_, k in roles.items() if k == 'machine') ok('manufacturing files in %s, checked against the board: Gerbers (%s), NC drill (%d hits = vias + drilled pads), pick and place (%d parts), BOM (%d lines), the JLCPCB BOM and CPL' % ( out, zp.name, hits, len(refs_in(cpl)), len(table(bom)[1])),- ['No LCSC number on: ' + ', '.join(missing_numbers) + ' (JLCPCB assembly needs one per line).' if missing_numbers else '',+ ['No LCSC number on: ' + ', '.join(missing_numbers) + '. That is fine: for a JLCPCB build, buy these anywhere (DigiKey, Mouser, ...) and send them as consigned parts, or add an LCSC number to the part to have JLCPCB source it (usually cheaper and faster).' if missing_numbers else '', 'Test points and board-only pads are bare copper, out of the BOM and the pick and place: ' + ', '.join(tps) if tps else '', 'Machine contacts and pins are fitted after assembly: DNP in the BOM, out of the pick and place: ' + ', '.join(dnp) if dnp else ''])