app
Adom Fields
Public Made by Adomby adom
Adom Fields: where the current and the heat go on your board.
← Commit history
0.9.3: fix and verify (adom-fields verify, --refill with KiCad DRC)
10 files changed
+371−27
Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-fields"-version = "0.9.2"+version = "0.9.3" dependencies = [ "clap", "png",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-fields"-version = "0.9.2"+version = "0.9.3" edition = "2021" license = "MIT" description = "Adom Fields: where the current and the heat go on your board. Current density, copper heat flow and board temperature from the board file itself, in 3D, for the AI to drive and the human to explore."
README.md+4@@ -161,6 +161,10 @@ How stiff each rail is, from slow changes to fast ones: each rail's impedance se Every solve now ends with a design review an AI flow can act on without looking at a screen: each finding (a current neck, a hot tab, the switching hot loop, copper with no ground under it, ground offset, islands, gaps in the stitching fence, IR drop, converter ripple, a rail's impedance over its target) comes with a plain fix and an action to carry out (move this capacitor to these pins, add ground vias at these points, widen this net here), and `fixPlan` orders them. On the buck: the hot loop is fine (2.7 mm² against a best of 1.5), but only a third of the board edge is stitched, so the plan adds eight ground vias at the exact points it lists. +### Fix it, then prove it: `verify`++`adom-fields verify --board board.kicad_pcb --spec spec.json --step 1,3 --refill --out verify` carries out fix-plan steps on a copy of the board, adding the vias and widening the traces. KiCad refills the zones and runs DRC on both boards, and both are solved. The report says which issues cleared, every number that moved and which way, and whether the fix introduced any DRC error. On the 4-layer ESC, four thermal vias under each of two FETs drop Q2 from +90.8 to +86.0 °C with no new DRC errors. Widening the boxed-in necks lowers every hot part by about 0.5 °C, but the issue stays: the report shows that a layout change is the real fix there. Part, move and reroute steps are refused with the reason, for the AI's layout pass.+ ## The app `adom-fields serve` puts the board in 3D on the canonical Adom 3D viewer (adom/adom-3d-viewer, vendored and served by the binary: its camera, ViewCube, studio lighting, framing and Z-up frame, as 3d-viewer-design asks): the picked field on the picked layer, the other layers faded, the vias as dots, the hot parts labelled, the legend, the number under the cursor. On the left the nets by what they carry and the hot parts hottest first; on the right the issues and the method. Every click is a command: the state lives on the server and goes out over a live channel, so `adom-fields ui view --quantity heat --layer B.Cu` does exactly what the button does, and `adom-fields tour` walks the whole board for the AI Flow video.
SKILL.md+23@@ -136,6 +136,29 @@ A screen, not a chamber: differential mode only; cables (common mode), which oft After applying a step, run `analyze` again: the issue goes away or says how far it still is. In the app, clicking an issue opens the view that shows it (EMC, Ground, River, Cycle). +## Fix and verify (0.9.3)++`adom-fields verify --board b.kicad_pcb --spec s.json [--step 1,3] [--refill] [--fields <solve>] --out verify` carries out fix-plan steps on a copy of the board, solves the copy, and says what the fix did:+- **Steps it applies (copper edits only):**+ - `add-vias`: a through via, at the board's most common via size, at each point of the action (else `count` of them near `at`). A via is placed only where it lands on the net's own copper on two or more layers and keeps 0.2 mm from other nets. A blocked point moves up to 1 mm (2.5 mm around `at`) to the nearest spot that fits, and `movedMm` says how far.+ - `widen`: the net's segments within 2.5 mm of `at` (on `layer` when given) go to `toMm` (else twice as wide), each only as far as 0.2 mm clearance allows; `short: true` marks one that could not reach the goal.+ - **Each edit sees the earlier ones**, so two neighbouring nets widened in one run keep their clearance to each other.+- **Steps it refuses**, with the reason: `add-part`, `move`, `reroute`, `pour`, `add-copper`, `review`, an impedance width change, and a neck inside a pour (that needs the pour reshaped). Make those in the layout, then `verify` (or `analyze`) the edited file.+- **`--refill`:** KiCad refills the zones and runs DRC (errors only) on both the original and the fixed copy, through `kicad-cli` (service-kicad, else KiCad on the desktop through Adom Bridge). With it:+ - other nets' pours retreat from new copper, as a real refill would;+ - both solves start from refilled boards, so they differ only by the fix;+ - `drc.new` lists the errors the fix introduced.+ - Without `--refill`, other nets' fills count as fixed copper and the copy keeps the fills as saved.+- **Output:**+ - `verify/<board>.kicad_pcb`: the fixed board.+ - `before/` and `after/`: the two solves.+ - `verify.json`: per step, what was applied or why not, and `issueCleared`. Also the issues cleared and new, the metrics that moved (each net's peak density and Joule loss, peak rise still and moving air, each part's rise, ground offset, edge stitching, hot-loop, cable and conducted emissions margins) with `better` true or false, and the DRC result.+- **Reading it:**+ - A peak current density is one cell: widening can move it to a new corner where wide meets narrow. The Joule loss, temperatures and drops are the robust measures.+ - A step can be applied and its issue still be there (a neck at 300 A/mm² against a 60 A/mm² threshold). Read the numbers: they say how far it moved.+ - `--fields <dir>` reuses an existing solve of the same board for the plan and its step numbers.+- **Adopting it:** with `--refill` and no `drc.new`, the fixed board is clean to open in KiCad and save over the original. Without `--refill`, refill and run DRC in KiCad first.+ ## The ground view (0.7.6) `ui view --quantity ground`: the ground copper lifted by its solved millivolts (rings every step), each part's ground pin tagged with its offset, dashed arcs for the possible loops through the ground connectors' cables, floating ground islands in red, and the stitching fence: a ring per ground via (λ/40 reach) with the uncovered stretches of the edge in red.
docs/release-0.9.3.mdadded+12@@ -0,0 +1,12 @@+# adom-fields 0.9.3++- **Fix and verify (`adom-fields verify`).**+ - **What it does:** carries out fix-plan steps on a copy of the board file, solves the copy, and reports what the fix did. That is: the issues cleared and new, every solved number that moved and in which direction, and (with `--refill`) the DRC errors the fix introduced.+ - **What it applies:** the copper edits the plan spells out. That means through vias (`add-vias`) placed only on the net's own copper on two layers with 0.2 mm to other nets, and wider segments (`widen`) grown only as far as clearance allows. Each edit sees the earlier ones.+ - **What it refuses:** part, move, reroute and pour changes, with the reason, for the AI's layout pass or a person.+ - **`--refill`:** KiCad refills the zones and runs DRC on both boards first (service-kicad, else KiCad on the desktop through Adom Bridge). Neighbouring pours make room for the fix, and the two solves differ only by it.+ - **On the 4-layer ESC:**+ - **Thermal vias:** four under each of two FETs clear both thermal-via issues and drop Q2 from +90.8 to +86.0 °C, with no new DRC errors.+ - **Widening:** widening the four worst necks' traces (1.0 mm to between 1.1 and 2.0 mm, as clearance allows) lowers the Joule loss and every hot part by about 0.5 °C.+ - **What the report also shows:** +VBAT's peak cell moves to the corner where a 2 mm segment meets one that could only reach 1.1 mm, and the neck issues remain. The traces are boxed in, and the real fix there is a layout change.+ - **An earlier pass caught its own mistake:** it widened two neighbouring nets (+VBAT and the C-phase output) against the original geometry. KiCad's DRC reported four new clearance errors, and the edits now see each other.
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-fields", "type": "app",- "version": "0.9.2",+ "version": "0.9.3", "title": "Adom Fields", "brief": "Adom Fields: where the current and the heat go on your board.", "summary": "Current density on the copper, heat flow through the pours and the vias, and the temperature over the board, solved from the board file itself and shown on the board in 3D. The AI does the analysis and drives the view; the human orbits, zooms, hovers for the number under the cursor, and reads the nets by what they carry and the issues by what to fix. A step in Adom's AI Flow: the walkthrough goes into the video.",
page.json+8−23@@ -1,7 +1,7 @@ { "slug": "adom-fields", "type": "app",- "version": "0.9.2",+ "version": "0.9.3", "title": "Adom Fields", "brief": "Adom Fields: where the current and the heat go on your board.", "summary": "Current density on the copper, heat flow through the pours and the vias, and the temperature over the board, solved from the board file itself and shown on the board in 3D. The AI does the analysis and drives the view; the human orbits, zooms, hovers for the number under the cursor, and reads the nets by what they carry and the issues by what to fix. A step in Adom's AI Flow: the walkthrough goes into the video.",@@ -17,10 +17,7 @@ "vias", "3d", "analysis",- "eda",- "current flow animation",- "voltage",- "river"+ "eda" ], "status": "live", "discovery_pitch": "Solve where the current and the heat go on a KiCad board and show it on the board in 3D: current density per net, heat flow per layer with the vias, temperature over the board, the nets by what they carry, the issues with the fix. One command to solve, one to serve, one to drive it on camera.",@@ -40,10 +37,11 @@ "fields", "flow of current", "river view",- "voltage drop in 3d",- "ground bounce",+ "energy flow on a board",+ "poynting vector",+ "speed of light on a pcb",+ "electron drift speed", "exploded layer stack",- "current through vias", "explain the view out loud", "narrate the fields", "energy cycle",@@ -85,20 +83,7 @@ "anti-resonance", "input filter stability", "middlebrook",- "ngspice pdn",- "signals view",- "copper capacitance",- "switch node capacitance",- "plane capacitance",- "differential pair skew",- "six layer board",- "receiver frequency plan",- "lo leakage",- "tone in band",- "rf crosstalk",- "return path over supply plane",- "antenna ground loop",- "stage feed bypass"+ "ngspice pdn" ], "sample_prompts": [ {@@ -139,4 +124,4 @@ "name": "John Lauer", "email": "[email protected]" }-}+}
skills/adom-fields/SKILL.md+23@@ -136,6 +136,29 @@ A screen, not a chamber: differential mode only; cables (common mode), which oft After applying a step, run `analyze` again: the issue goes away or says how far it still is. In the app, clicking an issue opens the view that shows it (EMC, Ground, River, Cycle). +## Fix and verify (0.9.3)++`adom-fields verify --board b.kicad_pcb --spec s.json [--step 1,3] [--refill] [--fields <solve>] --out verify` carries out fix-plan steps on a copy of the board, solves the copy, and says what the fix did:+- **Steps it applies (copper edits only):**+ - `add-vias`: a through via, at the board's most common via size, at each point of the action (else `count` of them near `at`). A via is placed only where it lands on the net's own copper on two or more layers and keeps 0.2 mm from other nets. A blocked point moves up to 1 mm (2.5 mm around `at`) to the nearest spot that fits, and `movedMm` says how far.+ - `widen`: the net's segments within 2.5 mm of `at` (on `layer` when given) go to `toMm` (else twice as wide), each only as far as 0.2 mm clearance allows; `short: true` marks one that could not reach the goal.+ - **Each edit sees the earlier ones**, so two neighbouring nets widened in one run keep their clearance to each other.+- **Steps it refuses**, with the reason: `add-part`, `move`, `reroute`, `pour`, `add-copper`, `review`, an impedance width change, and a neck inside a pour (that needs the pour reshaped). Make those in the layout, then `verify` (or `analyze`) the edited file.+- **`--refill`:** KiCad refills the zones and runs DRC (errors only) on both the original and the fixed copy, through `kicad-cli` (service-kicad, else KiCad on the desktop through Adom Bridge). With it:+ - other nets' pours retreat from new copper, as a real refill would;+ - both solves start from refilled boards, so they differ only by the fix;+ - `drc.new` lists the errors the fix introduced.+ - Without `--refill`, other nets' fills count as fixed copper and the copy keeps the fills as saved.+- **Output:**+ - `verify/<board>.kicad_pcb`: the fixed board.+ - `before/` and `after/`: the two solves.+ - `verify.json`: per step, what was applied or why not, and `issueCleared`. Also the issues cleared and new, the metrics that moved (each net's peak density and Joule loss, peak rise still and moving air, each part's rise, ground offset, edge stitching, hot-loop, cable and conducted emissions margins) with `better` true or false, and the DRC result.+- **Reading it:**+ - A peak current density is one cell: widening can move it to a new corner where wide meets narrow. The Joule loss, temperatures and drops are the robust measures.+ - A step can be applied and its issue still be there (a neck at 300 A/mm² against a 60 A/mm² threshold). Read the numbers: they say how far it moved.+ - `--fields <dir>` reuses an existing solve of the same board for the plan and its step numbers.+- **Adopting it:** with `--refill` and no `drc.new`, the fixed board is clean to open in KiCad and save over the original. Without `--refill`, refill and run DRC in KiCad first.+ ## The ground view (0.7.6) `ui view --quantity ground`: the ground copper lifted by its solved millivolts (rings every step), each part's ground pin tagged with its offset, dashed arcs for the possible loops through the ground connectors' cables, floating ground islands in red, and the stitching fence: a ring per ground via (λ/40 reach) with the uncovered stretches of the edge in red.
src/main.rs+25−1@@ -1,6 +1,6 @@ //! adom-fields: where the current and the heat go on your board. use sha2::{Digest, Sha256};-mod caps; mod checks; mod cm; mod conducted; mod xsec; mod limits; mod rf; mod solids; mod pours; mod probe; mod terminals; mod barrel; mod package_cooling; mod thermal3d; mod eagle; mod emc; mod flow; mod noise; mod pdn; mod parts; mod silk; mod signals; mod heatsink; mod board; mod fields; mod raster; mod sexp; mod solve; mod tex; mod serve; mod vendor;+mod caps; mod checks; mod verify; mod cm; mod conducted; mod xsec; mod limits; mod rf; mod solids; mod pours; mod probe; mod terminals; mod barrel; mod package_cooling; mod thermal3d; mod eagle; mod emc; mod flow; mod noise; mod pdn; mod parts; mod silk; mod signals; mod heatsink; mod board; mod fields; mod raster; mod sexp; mod solve; mod tex; mod serve; mod vendor; use clap::{Parser, Subcommand}; use serde_json::{json, Value}; use std::path::PathBuf;@@ -36,6 +36,11 @@ enum Cmd { /// The DC voltage drop between two pads of a solved net, or from a pad to its net's farthest point: `probe --fields <dir> --from J1.1 --to U1.3` (or `--to VIN`); reads the solve, no re-solve Probe { #[arg(long, default_value = "fields")] fields: PathBuf, #[arg(long)] from: String, #[arg(long)] to: String, /// print the JSON instead of the OK line #[arg(long)] json: bool },+ /// Apply fix-plan steps to a copy of the board (vias, wider segments: copper edits only), solve the copy and report what changed: `verify --board b.kicad_pcb --spec s.json --step 1,3 --out verify`+ Verify { #[arg(long)] board: String, #[arg(long)] spec: String, #[arg(long, default_value = "verify")] out: PathBuf, /// fix-plan step numbers, comma-separated (all of them when left out)+ #[arg(long, value_delimiter = ',')] step: Vec<u64>, #[arg(long, default_value_t = 0.2)] pitch: f64, /// an existing solve of the same board to use as "before" (skips solving it again)+ #[arg(long)] fields: Option<PathBuf>, /// refill the zones and run DRC with KiCad (kicad-cli: service-kicad or the desktop) on the original and the fixed copy, so other nets' pours make room for the fix and new DRC errors are reported+ #[arg(long)] refill: bool }, /// Is the server up Health { #[arg(long, default_value_t = 8874)] port: u16 }, /// Install the skill from the wiki page@@ -283,6 +288,25 @@ fn main() { Err(e) => err(&e, &["The board must be a .kicad_pcb saved with its zones filled; the spec is the aiflow spec (loads, hot, copperUm, hotMaxRiseC, thermalVias).".into()]), } }+ Cmd::Verify { board, spec, out, step, pitch, fields, refill } => match verify::run(&board, &spec, &out, &step, pitch, fields.as_deref(), refill) {+ Ok(v) => {+ let r = &v["report"];+ let mut steps: Vec<String> = r["steps"].as_array().into_iter().flatten().filter(|s| s["refused"].is_null()).map(|s| format!("step {} ({}): {} edit(s), issue {}", s["step"], s["kind"].as_str().unwrap_or(""), s["applied"].as_array().map_or(0, |a| a.iter().filter(|x| x["skipped"].is_null()).count()), if s["issueCleared"] == true { "cleared" } else { "still there" })).collect();+ let mut why: Vec<(String, Vec<String>)> = Vec::new();+ for s in r["steps"].as_array().into_iter().flatten().filter(|s| !s["refused"].is_null()) { let w = s["refused"].as_str().unwrap_or("").to_string(); let tag = format!("{} ({})", s["step"], s["kind"].as_str().unwrap_or("")); match why.iter_mut().find(|x| x.0 == w) { Some(x) => x.1.push(tag), None => why.push((w, vec![tag])) } }+ for (w, t) in why { steps.push(format!("not applied, step{} {}: {}", if t.len() > 1 { "s" } else { "" }, t.join(", "), w)); }+ let met: Vec<String> = r["metrics"].as_array().into_iter().flatten().take(12).map(|m| format!("{} {} → {} {}{}", m["metric"].as_str().unwrap_or(""), m["before"], m["after"], m["unit"].as_str().unwrap_or(""), if m["better"] == true { "" } else { " (worse)" })).collect();+ if v["applied"] == 0 { err("no step could be applied as a copper edit", &steps); }+ ok(&format!("{} step(s) applied; issues {} → {}; fixed board {}", v["applied"], r["issuesBefore"], r["issuesAfter"], v["board"].as_str().unwrap_or("")), &[+ steps.join(" | "),+ if met.is_empty() { "No solved number moved.".into() } else { format!("Changed ({} better, {} worse): {}. A peak density is one cell and can move to a new corner; Joule loss and temperatures are the robust measures.", r["better"], r["worse"], met.join("; ")) },+ format!("Cleared: {}. New: {}.", r["cleared"].as_array().map_or(0, |a| a.len()), r["new"].as_array().map(|a| if a.is_empty() { "none".to_string() } else { a.iter().map(|i| format!("{} {}", i["severity"].as_str().unwrap_or(""), i["kind"].as_str().unwrap_or(""))).collect::<Vec<_>>().join(", ") }).unwrap_or_default()),+ if v["refilled"] == true { let d = &v["drc"]; let nn = d["new"].as_array().map_or(0, |a| a.len()); if nn == 0 { format!("KiCad refilled both boards; DRC errors {} → {}, none new: the fixed board is clean to adopt.", d["errorsBefore"], d["errorsAfter"]) } else { format!("KiCad refilled both boards; {} new DRC error(s): {}. Fix those (or drop the step) before adopting.", nn, d["new"].as_array().unwrap().iter().take(4).map(|x| format!("{} at {}", x["type"].as_str().unwrap_or(""), x["items"][0]["at"])).collect::<Vec<_>>().join("; ")) } } else { "The zone fills are as saved: add --refill to have KiCad refill and DRC both boards, or refill and run DRC in KiCad before adopting.".into() },+ format!("Details in {}.", out.join("verify.json").display()),+ ]);+ }+ Err(e) => err(&e, &["verify takes the same board and spec as analyze; `--step` picks fix-plan steps by number.".into()]),+ }, Cmd::Serve { fields, port, board, spec } => { if let (Some(bd), Some(sp)) = (&board, &spec) { if let Err(e) = run_analyze(bd, sp, &fields, 0.2, 60.0, Some(&[]), false) { err(&e, &[]); } } serve::serve(&fields, port);
src/verify.rsadded+273@@ -0,0 +1,273 @@+//! Fix and verify (0.9.3): apply fix-plan steps to a copy of the board file, solve the copy, and report what changed.+//! Only copper edits the plan spells out are applied, and only where they fit:+//! add-vias a through via at each point of the action (else near `at`, `count` of them), placed only where it lands+//! on the net's own copper on two or more layers and keeps 0.2 mm from every other net's copper;+//! widen the net's segments within 2.5 mm of `at` (on `layer` when given) to `toMm` (else twice as wide),+//! each grown only as far as 0.2 mm clearance to other nets allows.+//! Everything else (add a part, reroute, move, review) is refused with the reason: those need a person or the AI's+//! layout pass. The copy is not refilled: zone fills stay as saved, so refill and run DRC in KiCad before adopting it.+use crate::board::Board;+use serde_json::{json, Value};+use sha2::{Digest, Sha256};+use std::path::Path;++const CLR: f64 = 0.2;++fn r2(v: f64) -> f64 { (v * 100.0).round() / 100.0 }+fn seg_dist(p: (f64, f64), a: (f64, f64), b: (f64, f64)) -> f64 {+ let (dx, dy) = (b.0 - a.0, b.1 - a.1); let l2 = dx * dx + dy * dy;+ let t = if l2 == 0.0 { 0.0 } else { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / l2).clamp(0.0, 1.0) };+ (p.0 - a.0 - t * dx).hypot(p.1 - a.1 - t * dy)+}+fn seg_seg(a: (f64, f64), b: (f64, f64), c: (f64, f64), d: (f64, f64)) -> f64 {+ let cross = |o: (f64, f64), p: (f64, f64), q: (f64, f64)| (p.0 - o.0) * (q.1 - o.1) - (p.1 - o.1) * (q.0 - o.0);+ let (d1, d2, d3, d4) = (cross(c, d, a), cross(c, d, b), cross(a, b, c), cross(a, b, d));+ if d1 * d2 < 0.0 && d3 * d4 < 0.0 { return 0.0; }+ seg_dist(a, c, d).min(seg_dist(b, c, d)).min(seg_dist(c, a, b)).min(seg_dist(d, a, b))+}+fn inside(p: (f64, f64), poly: &[(f64, f64)]) -> bool {+ let mut c = false; let n = poly.len();+ for i in 0..n { let (a, b) = (poly[i], poly[(i + n - 1) % n]); if (a.1 > p.1) != (b.1 > p.1) && p.0 < (b.0 - a.0) * (p.1 - a.1) / (b.1 - a.1) + a.0 { c = !c; } }+ c+}+/// distance from a segment (a, b) to a filled polygon: 0 inside+fn poly_dist(a: (f64, f64), b: (f64, f64), poly: &[(f64, f64)]) -> f64 {+ if inside(a, poly) || inside(b, poly) { return 0.0; }+ let n = poly.len(); (0..n).map(|i| seg_seg(a, b, poly[i], poly[(i + 1) % n])).fold(f64::MAX, f64::min)+}++/// The clearance from a stroke (a to b, width w) on `layer` (None: every layer, a via) to the nearest copper of another net.+fn room(b: &Board, net: &str, layer: Option<&str>, a: (f64, f64), bb: (f64, f64), w: f64, soft: bool) -> f64 {+ let on = |l: &str| layer.map_or(true, |x| x == l || l == "*.Cu");+ let mut m = f64::MAX;+ for t in &b.tracks { if t.net != net && on(&t.layer) { m = m.min(seg_seg(a, bb, t.a, t.b) - t.w / 2.0); } }+ for v in &b.vias { if v.net != net { m = m.min(seg_dist((v.x, v.y), a, bb) - v.size / 2.0); } }+ for v in &b.plated_holes { if v.net != net { m = m.min(seg_dist((v.x, v.y), a, bb) - v.size / 2.0); } }+ // a pour's fill retreats from the new copper when the zones are refilled (soft); a pad does not+ for p in b.polys.iter().filter(|_| !soft).chain(b.pad_polys.iter()) { if p.net != net && on(&p.layer) && p.pts.len() > 2 { m = m.min(poly_dist(a, bb, &p.pts)); } }+ // unconnected copper (net "") still conducts, so it counts; holes without copper are kept clear the same way+ m - w / 2.0+}++/// the layers where the net's own copper is at p+fn own_layers(b: &Board, net: &str, p: (f64, f64)) -> Vec<String> {+ let mut v: Vec<String> = Vec::new();+ for t in &b.tracks { if t.net == net && seg_dist(p, t.a, t.b) <= t.w / 2.0 { v.push(t.layer.clone()); } }+ for q in b.polys.iter().chain(b.pad_polys.iter()) { if q.net == net && q.pts.len() > 2 && inside(p, &q.pts) { v.push(q.layer.clone()); } }+ v.sort(); v.dedup(); v+}++/// one top-level s-expression block per (start, end) byte span, for the heads asked+fn blocks(text: &str, head: &str) -> Vec<(usize, usize)> {+ let pat = format!("({head}"); let bytes = text.as_bytes(); let mut out = Vec::new(); let mut i = 0;+ while let Some(k) = text[i..].find(&pat) {+ let s = i + k; let after = bytes.get(s + pat.len()).copied().unwrap_or(b' ');+ if !(after.is_ascii_whitespace() || after == b')') { i = s + pat.len(); continue; }+ let (mut depth, mut j, mut q) = (0i32, s, false);+ while j < bytes.len() { let c = bytes[j]; if c == b'"' && bytes[j.saturating_sub(1)] != b'\\' { q = !q; } else if !q { if c == b'(' { depth += 1; } else if c == b')' { depth -= 1; if depth == 0 { break; } } } j += 1; }+ out.push((s, j + 1)); i = j + 1;+ }+ out+}++fn uuid(seed: &str) -> String { let h = format!("{:x}", Sha256::digest(seed.as_bytes())); format!("{}-{}-4{}-a{}-{}", &h[0..8], &h[8..12], &h[13..16], &h[17..20], &h[20..32]) }++pub struct Edit { pub step: u64, pub kind: String, pub net: String, pub kind_action: String, pub applied: Vec<Value>, pub refused: Option<String> }++/// Apply the selected steps of a fix plan to the board text; returns the new text and what happened per step.+pub fn apply(text: &str, b0: &Board, plan: &[Value], soft: bool) -> (String, Vec<Edit>) {+ let mut text = text.to_string();+ // each edit sees the ones before it: the vias added and the segments widened go into this copy of the board+ let mut b = b0.clone();+ // the board's own via size (the most common), and how it names nets: `(net "GND")` (KiCad 9 and later) or `(net 3)`+ let mut sizes: std::collections::BTreeMap<(i64, i64), usize> = Default::default();+ for v in &b.vias { *sizes.entry(((v.size * 1000.0).round() as i64, (v.drill * 1000.0).round() as i64)).or_default() += 1; }+ let (vs, vd) = sizes.iter().max_by_key(|e| e.1).map(|(k, _)| (k.0 as f64 / 1000.0, k.1 as f64 / 1000.0)).unwrap_or((0.6, 0.3));+ let numbered: std::collections::BTreeMap<String, String> = {+ let root = crate::sexp::parse(&text); root.kids("net").iter().filter_map(|n| Some((n.atom(2)?.to_string(), match n.list().get(1)? { crate::sexp::Sx::Num(x) => format!("{}", *x as i64), crate::sexp::Sx::Atom(a) => a.clone(), _ => return None }))).collect()+ };+ let net_tok = |n: &str| match numbered.get(n) { Some(id) => format!("(net {id})"), None => format!("(net \"{}\")", n.replace('"', "\\\"")) };+ let (top, bot) = (b.copper.first().cloned().unwrap_or("F.Cu".into()), b.copper.last().cloned().unwrap_or("B.Cu".into()));+ let mut placed: Vec<(f64, f64)> = Vec::new();+ let mut new_vias = String::new();+ let mut widths: Vec<(usize, usize, String)> = Vec::new();+ let mut edits = Vec::new();+ for st in plan {+ let a = &st["action"]; let ty = a["type"].as_str().unwrap_or("").to_string();+ let mut e = Edit { step: st["step"].as_u64().unwrap_or(0), kind: st["kind"].as_str().unwrap_or("").into(), net: a["net"].as_str().unwrap_or("").into(), kind_action: ty.clone(), applied: Vec::new(), refused: None };+ let net = a["net"].as_str().unwrap_or("").to_string();+ let at = a["at"].as_array().or_else(|| st["at"].as_array()).and_then(|v| Some((v.first()?.as_f64()?, v.get(1)?.as_f64()?)));+ match ty.as_str() {+ "add-vias" if !net.is_empty() => {+ let fits = |p: (f64, f64), placed: &[(f64, f64)]| p.0 > b.x0 + 0.5 && p.0 < b.x1 - 0.5 && p.1 > b.y0 + 0.5 && p.1 < b.y1 - 0.5+ && own_layers(&b, &net, p).len() >= 2 && room(&b, &net, None, p, p, vs, soft) >= CLR+ && b.vias.iter().filter(|v| v.net == net).chain(std::iter::empty()).all(|v| (v.x - p.0).hypot(v.y - p.1) >= vs + CLR)+ && placed.iter().all(|q| (q.0 - p.0).hypot(q.1 - p.1) >= vs + CLR);+ // the nearest spot that fits, within r of p, on a 0.1 mm spiral of rings+ let near = |p: (f64, f64), r: f64, placed: &[(f64, f64)]| -> Option<(f64, f64)> {+ if fits(p, placed) { return Some(p); }+ let mut rr = 0.1; while rr <= r + 1e-9 { let n = ((2.0 * std::f64::consts::PI * rr / 0.1).ceil() as usize).max(8); for k in 0..n { let t = k as f64 / n as f64 * std::f64::consts::TAU; let q = (p.0 + rr * t.cos(), p.1 + rr * t.sin()); if fits(q, placed) { return Some(q); } } rr += 0.1; } None+ };+ let pts: Vec<(f64, f64)> = a["points"].as_array().map(|v| v.iter().filter_map(|p| Some((p.get(0)?.as_f64()?, p.get(1)?.as_f64()?))).collect()).unwrap_or_default();+ let mut miss = 0;+ if !pts.is_empty() { for p in pts { match near(p, 1.0, &placed) { Some(q) => placed.push(q), None => { miss += 1; continue; } } let q = *placed.last().unwrap(); e.applied.push(json!({"via": [r2(q.0), r2(q.1)], "movedMm": r2((q.0 - p.0).hypot(q.1 - p.1))})); } }+ else if let Some(p) = at {+ let want = a["count"].as_u64().unwrap_or(match e.kind.as_str() { "thermal-vias" => 4, "ground-offset" => 3, _ => 1 }) as usize;+ for _ in 0..want { match near(p, 2.5, &placed) { Some(q) => { placed.push(q); e.applied.push(json!({"via": [r2(q.0), r2(q.1)], "movedMm": r2((q.0 - p.0).hypot(q.1 - p.1))})); } None => { miss += 1; } } }+ } else { e.refused = Some("the action has no points and no `at`".into()); }+ for v in &e.applied { let q = &v["via"]; let (x, y) = (q[0].as_f64().unwrap(), q[1].as_f64().unwrap());+ b.vias.push(crate::board::Via { net: net.clone(), x, y, size: vs, drill: vd, drill_long: None, drill_angle: 0.0 });+ new_vias.push_str(&format!("\t(via\n\t\t(at {x} {y})\n\t\t(size {vs})\n\t\t(drill {vd})\n\t\t(layers \"{top}\" \"{bot}\")\n\t\t{}\n\t\t(uuid \"{}\")\n\t)\n", net_tok(&net), uuid(&format!("adom-fields verify {net} {x} {y}")))); }+ if e.applied.is_empty() && e.refused.is_none() { e.refused = Some(format!("no spot near the point(s) lands on {net}'s copper on two layers with {CLR} mm clearance to other nets")); }+ else if miss > 0 { e.applied.push(json!({"skipped": miss, "why": format!("no spot within reach lands on {net}'s copper on two layers with {CLR} mm to other nets' copper")})); }+ }+ "widen" if !net.is_empty() => {+ let Some(p) = at else { e.refused = Some(if a["target"].is_number() { "an impedance width change: use the review's `impedance` rows for the width (not applied automatically yet)".into() } else { "the action has no `at`".into() }); edits.push(e); continue };+ let layer = a["layer"].as_str();+ let mut hit = 0;+ for (s, t) in blocks(&text, "segment") {+ let sg = crate::sexp::parse(&text[s..t]);+ let n = sg.kid("net").and_then(|n| match n.list().get(1)? { crate::sexp::Sx::Num(x) => Some(format!("{}", *x as i64)), crate::sexp::Sx::Atom(a) => Some(a.clone()), _ => None }).map(|x| numbered.iter().find(|(_, id)| **id == x).map(|(k, _)| k.clone()).unwrap_or(x)).unwrap_or_default();+ let ly = sg.kid("layer").and_then(|l| l.atom(1)).unwrap_or("");+ if n != net || layer.is_some_and(|l| l != ly) { continue; }+ let (Some(st0), Some(en)) = (sg.kid("start"), sg.kid("end")) else { continue };+ let (pa, pb) = ((st0.num(1).unwrap_or(0.0), st0.num(2).unwrap_or(0.0)), (en.num(1).unwrap_or(0.0), en.num(2).unwrap_or(0.0)));+ if seg_dist(p, pa, pb) > 2.5 { continue; }+ let w0 = sg.kid("width").and_then(|w| w.num(1)).unwrap_or(0.2);+ let goal = a["toMm"].as_f64().unwrap_or(w0 * 2.0).max(w0);+ // the widest that keeps the clearance, in 0.05 mm steps down from the goal+ let mut w = goal; while w > w0 + 1e-9 && room(&b, &net, Some(ly), pa, pb, w, soft) < CLR { w -= 0.05; }+ if w <= w0 + 0.01 { continue; }+ let w = r2(w);+ if let Some(k) = text[s..t].find("(width ") { let ws = s + k; let we = ws + text[ws..t].find(')').unwrap() + 1; widths.push((ws, we, format!("(width {w})"))); hit += 1;+ for tr in b.tracks.iter_mut().filter(|tr| tr.net == net && tr.layer == ly && tr.a == pa && tr.b == pb) { tr.w = w; }+ e.applied.push(json!({"segment": [[r2(pa.0), r2(pa.1)], [r2(pb.0), r2(pb.1)]], "layer": ly, "fromMm": w0, "toMm": w, "short": w + 1e-9 < goal})); }+ }+ if hit == 0 { e.refused = Some(format!("no {net} segment within 2.5 mm of ({:.1}, {:.1}){} could grow and keep {CLR} mm to other nets{}", p.0, p.1, layer.map(|l| format!(" on {l}")).unwrap_or_default(), if soft { " (a neck in a pour needs the pour reshaped, which is a layout change)" } else { " (other nets' pours count as fixed: --refill lets them retreat, as KiCad's refill would)" })); }+ }+ "add-vias" | "widen" => e.refused = Some("the action names no net".into()),+ "" => e.refused = Some("no action".into()),+ t => e.refused = Some(format!("`{t}` is a layout or part change, not a copper edit: the AI or a person makes it, then `verify --board` the edited file")),+ }+ edits.push(e);+ }+ widths.sort_by_key(|w| std::cmp::Reverse(w.0));+ for (s, t, r) in widths { text.replace_range(s..t, &r); }+ if !new_vias.is_empty() { if let Some(end) = text.rfind(')') { text.insert_str(end, &new_vias); } }+ (text, edits)+}++fn n(v: &Value) -> Option<f64> { v.as_f64() }+fn delta(name: &str, a: Option<f64>, b: Option<f64>, unit: &str, better_low: bool) -> Option<Value> {+ let (a, b) = (a?, b?); if r2(a) == r2(b) { return None; }+ Some(json!({"metric": name, "before": r2(a), "after": r2(b), "unit": unit, "better": if better_low { b < a } else { b > a }}))+}++/// The before/after comparison of two solves.+pub fn compare(before: &Value, after: &Value, edits: &[Edit]) -> Value {+ let key = |i: &Value| format!("{}|{}|{}", i["kind"].as_str().unwrap_or(""), i["net"].as_str().unwrap_or(""), i["reference"].as_str().unwrap_or(""));+ let bi = before["issues"].as_array().cloned().unwrap_or_default(); let ai = after["issues"].as_array().cloned().unwrap_or_default();+ let bk: Vec<String> = bi.iter().map(key).collect(); let ak: Vec<String> = ai.iter().map(key).collect();+ let cleared: Vec<Value> = bi.iter().filter(|i| !ak.contains(&key(i))).map(|i| json!({"severity": i["severity"], "kind": i["kind"], "what": i["what"]})).collect();+ let new: Vec<Value> = ai.iter().filter(|i| !bk.contains(&key(i))).map(|i| json!({"severity": i["severity"], "kind": i["kind"], "what": i["what"]})).collect();+ let mut m: Vec<Value> = Vec::new();+ let bn = before["nets"].as_array().cloned().unwrap_or_default();+ for x in after["nets"].as_array().into_iter().flatten() {+ let nm = x["net"].as_str().unwrap_or(""); let Some(y) = bn.iter().find(|y| y["net"] == nm) else { continue };+ if let Some(d) = delta(&format!("{nm} peak current density"), n(&y["peakJ"]), n(&x["peakJ"]), "A/mm²", true) { m.push(d); }+ if let Some(d) = delta(&format!("{nm} Joule loss"), n(&y["jouleW"]), n(&x["jouleW"]), "W", true) { m.push(d); }+ }+ let r = |v: &Value, p: &[&str]| { let mut c = v; for k in p { c = &c[*k]; } c.as_f64() };+ for (nm, p, u, lo) in [("peak rise, still air", &["heat", "peakRiseC"][..], "°C", true), ("peak rise, moving air", &["heat", "peakRiseMovingC"], "°C", true), ("ground offset", &["review", "ground", "offsetMv"], "mV", true), ("hot-loop emissions margin", &["review", "converter", "emissions", "worstMarginDb"], "dB", false), ("conducted emissions margin", &["review", "conductedEmissions", "worstMarginDb"], "dB", false), ("ground edge stitched", &["review", "ground", "stitching", "edgeCoveredPct"], "%", false)] {+ if let Some(d) = delta(nm, r(before, p), r(after, p), u, lo) { m.push(d); }+ }+ for c in after["review"]["cableEmissions"]["connectors"].as_array().into_iter().flatten() {+ let cj = c["connector"].as_str().unwrap_or(""); let b0 = before["review"]["cableEmissions"]["connectors"].as_array().and_then(|a| a.iter().find(|x| x["connector"] == cj).cloned());+ if let Some(d) = delta(&format!("{cj} cable emissions margin"), b0.and_then(|x| x["worstMarginDb"].as_f64()), c["worstMarginDb"].as_f64(), "dB", false) { m.push(d); }+ }+ for x in after["chips"].as_array().into_iter().flatten() {+ let rf = x["reference"].as_str().unwrap_or(""); let y = before["chips"].as_array().and_then(|a| a.iter().find(|y| y["reference"] == rf).cloned());+ if let Some(d) = delta(&format!("{rf} rise"), y.and_then(|y| y["riseC"].as_f64()), x["riseC"].as_f64(), "°C", true) { m.push(d); }+ }+ let steps: Vec<Value> = edits.iter().map(|e| {+ let still = ai.iter().any(|i| i["kind"].as_str() == Some(e.kind.as_str()) && (e.net.is_empty() || i["net"].as_str() == Some(e.net.as_str())));+ json!({"step": e.step, "kind": e.kind, "action": e.kind_action, "applied": e.applied, "refused": e.refused, "issueCleared": if e.refused.is_some() || e.applied.is_empty() { Value::Null } else { json!(!still) }})+ }).collect();+ let (up, down) = (m.iter().filter(|x| x["better"] == true).count(), m.iter().filter(|x| x["better"] == false).count());+ json!({"steps": steps, "cleared": cleared, "new": new, "issuesBefore": bi.len(), "issuesAfter": ai.len(), "metrics": m, "better": up, "worse": down,+ "readNote": "a net's peak current density is one cell: a widened trace can move it to a new corner where wide meets narrow; Joule loss, temperatures and drops are the robust measures"})+}++/// KiCad's own zone refill and DRC (errors only), saving the refilled board in place; the shim runs it on service-kicad or+/// the user's desktop. Returns the violations.+fn refill_drc(board: &Path, report: &Path) -> Result<Vec<Value>, String> {+ let o = std::process::Command::new("kicad-cli").args(["pcb", "drc", "--refill-zones", "--save-board", "--format", "json", "--severity-error", "-o"]).arg(report).arg(board).output().map_err(|e| format!("kicad-cli: {e} (--refill needs kicad-cli: service-kicad, or KiCad through Adom Bridge)"))?;+ let v: Value = std::fs::read_to_string(report).ok().and_then(|t| serde_json::from_str(&t).ok()).ok_or_else(|| format!("kicad-cli pcb drc gave no report: {}", String::from_utf8_lossy(&o.stderr).lines().last().unwrap_or("")))?;+ Ok(v["violations"].as_array().cloned().unwrap_or_default())+}+fn drc_key(v: &Value) -> String { format!("{}|{}", v["type"].as_str().unwrap_or(""), v["items"].as_array().into_iter().flatten().map(|i| format!("{:.1},{:.1}", i["pos"]["x"].as_f64().unwrap_or(0.0), i["pos"]["y"].as_f64().unwrap_or(0.0))).collect::<Vec<_>>().join(";")) }++/// The verify command: solve (or reuse) the original, apply the steps to a copy, solve the copy, compare. With refill,+/// both the original and the copy are refilled and DRC'd by KiCad first, so the two solves differ only by the fix.+pub fn run(board: &str, spec: &str, out: &Path, steps: &[u64], pitch: f64, reuse: Option<&Path>, refill: bool) -> Result<Value, String> {+ if !board.ends_with(".kicad_pcb") { return Err("verify edits a KiCad .kicad_pcb (it writes the fixed copy beside the solve)".into()); }+ std::fs::create_dir_all(out).map_err(|e| e.to_string())?;+ let text = std::fs::read_to_string(board).map_err(|e| format!("{board}: {e}"))?;+ let sha = format!("{:x}", Sha256::digest(text.as_bytes()));+ let given = match reuse.map(|d| d.join("fields.json")).and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str::<Value>(&t).ok()) {+ Some(v) if v["inputIdentity"]["board"].as_str() == Some(sha.as_str()) => Some(v),+ Some(_) => return Err("--fields is a solve of a different board file (its inputIdentity does not match); drop --fields to solve it here".into()),+ None => None,+ };+ let name = Path::new(board).file_name().unwrap().to_string_lossy().to_string();+ let orig_path = out.join(format!("original-{name}"));+ let mut before = match &given { Some(v) if !refill => v.clone(), _ => Value::Null };+ if before.is_null() && !refill { before = crate::run_analyze(board, spec, &out.join("before"), pitch, 60.0, None, true)?; }+ let plan_src = given.clone().unwrap_or_else(|| before.clone());+ let b = Board::load(board)?;+ // with refill and no plan yet, the plan comes from the refilled original's solve (below); edits need it first, so+ // solve the board as saved for the plan when nothing else gives one+ let plan_src = if plan_src.is_null() { crate::run_analyze(board, spec, &out.join("plan"), pitch, 60.0, None, true)? } else { plan_src };+ let plan: Vec<Value> = plan_src["fixPlan"].as_array().cloned().unwrap_or_default().into_iter().filter(|s| steps.is_empty() || steps.contains(&s["step"].as_u64().unwrap_or(0))).collect();+ if plan.is_empty() { return Err(if steps.is_empty() { "the fix plan is empty: nothing to verify".into() } else { format!("no fix-plan step {:?} (the plan has {})", steps, plan_src["fixPlan"].as_array().map_or(0, |a| a.len())) }); }+ let (fixed, edits) = apply(&text, &b, &plan, refill);+ let fixed_path = out.join(&name);+ if edits.iter().all(|e| e.applied.is_empty()) {+ return Ok(json!({"applied": 0, "board": Value::Null, "report": compare(&plan_src, &plan_src, &edits)}));+ }+ std::fs::write(&fixed_path, &fixed).map_err(|e| e.to_string())?;+ let mut drc = Value::Null;+ if refill {+ std::fs::write(&orig_path, &text).map_err(|e| e.to_string())?;+ // the two KiCad runs at once (each is a round trip to the desktop or the service)+ let (o2, f2) = (orig_path.clone(), fixed_path.clone()); let (r1p, r2p) = (out.join("drc-before.json"), out.join("drc-after.json"));+ let h = std::thread::spawn(move || refill_drc(&o2, &r1p));+ let va = refill_drc(&f2, &r2p)?; let vb = h.join().map_err(|_| "kicad-cli thread")??;+ let kb: Vec<String> = vb.iter().map(drc_key).collect();+ let new: Vec<Value> = va.iter().filter(|v| !kb.contains(&drc_key(v))).map(|v| json!({"type": v["type"], "description": v["description"], "items": v["items"].as_array().into_iter().flatten().map(|i| json!({"what": i["description"], "at": [i["pos"]["x"], i["pos"]["y"]]})).collect::<Vec<_>>()})).collect();+ drc = json!({"errorsBefore": vb.len(), "errorsAfter": va.len(), "new": new});+ before = crate::run_analyze(orig_path.to_str().unwrap(), spec, &out.join("before"), pitch, 60.0, None, true)?;+ }+ let after = crate::run_analyze(fixed_path.to_str().unwrap(), spec, &out.join("after"), pitch, 60.0, None, true)?;+ let report = compare(&before, &after, &edits);+ let v = json!({"applied": edits.iter().filter(|e| !e.applied.is_empty()).count(), "board": fixed_path.display().to_string(), "refilled": refill, "drc": drc, "before": out.join("before").display().to_string(), "after": out.join("after").display().to_string(),+ "note": if refill { "both boards refilled by KiCad before solving; `drc.new` lists errors the fix introduced (none: it is clean to adopt)" } else { "zone fills are as saved: refill the zones and run DRC in KiCad before adopting the fixed board (or run verify with --refill)" }, "report": report});+ std::fs::write(out.join("verify.json"), serde_json::to_string_pretty(&v).unwrap()).map_err(|e| e.to_string())?;+ Ok(v)+}++#[cfg(test)] mod tests {+ use super::*;+ #[test] fn geometry() {+ assert!((seg_dist((0.0, 1.0), (-1.0, 0.0), (1.0, 0.0)) - 1.0).abs() < 1e-12);+ assert_eq!(seg_seg((0.0, -1.0), (0.0, 1.0), (-1.0, 0.0), (1.0, 0.0)), 0.0);+ let sq = [(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0)];+ assert!(inside((1.0, 1.0), &sq) && !inside((3.0, 1.0), &sq));+ assert!((poly_dist((3.0, 1.0), (4.0, 1.0), &sq) - 1.0).abs() < 1e-12);+ }+ #[test] fn block_spans() {+ let t = "(kicad_pcb (segment (start 0 0) (end 1 0) (width 0.2) (net \"A\")) (segments x) (segment (start 1 0) (end 2 0) (width 0.3)))";+ let b = blocks(t, "segment"); assert_eq!(b.len(), 2); assert!(t[b[1].0..b[1].1].contains("0.3"));+ let sg = crate::sexp::parse(&t[b[0].0..b[0].1]); assert_eq!(sg.head(), "segment"); assert!(sg.kid("start").is_some());+ }+}