← Commit history

0.10.3: KiCad 8 numbered nets (issue 21); same-net copper pieces kept apart (issue 22)

John Lauer ·5ed6b7b59a · ·parent 029b30f
11 files changed +188−14
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-fields"-version = "0.10.2"+version = "0.10.3" dependencies = [  "clap",  "png",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-fields"-version = "0.10.2"+version = "0.10.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."
docs/release-0.10.3.mdadded+11
@@ -0,0 +1,11 @@+# adom-fields 0.10.3++- **KiCad 8 boards (issue 21).**+  - **The bug:** a track or via whose net is a number (`(net 41)`, as KiCad 8 and earlier write it, with the name only in the board's net table) was read with no net. It dropped out of every solve, and nothing said so.+  - **The fix:** numbered nets resolve through the net table, as KiCad does, and a board whose tracks or vias name a number the table does not list is refused with the count.+  - **The check:** the same board in KiCad 8 and KiCad 10 format now solves identically, with the same vias, copper and peaks on every net.+- **Same-net copper closer than a cell (issue 22).**+  - **The bug:** two pieces of one net's copper that the board keeps apart (two fills 0.14 mm apart, joined only through other layers) were joined by the 0.2 mm grid. That invented a neck in the gap and could set the net's peak.+  - **The fix:** each net's copper on each layer is now split into its pieces from the board's geometry (the fills, tracks, pads and via rings that touch or overlap). A solve cell belongs to the piece with most copper in it, and cells of different pieces do not conduct.+  - **Reporting:** every spot where two pieces came closer than a cell is listed in `nets[].piecesKeptApart` and logged; `--pitch` half the default resolves the copper there exactly.+  - **The check:** on a test board with two fills 0.14 mm apart joined only through the bottom layer, 0.10.2 put the peak in the gap. 0.10.3 sends the current through the vias and the bottom track, where the peak really is.
package.json+2−2
@@ -1,8 +1,8 @@ {   "slug": "adom-fields",   "type": "app",-  "version": "0.10.2",-  "changelog": "0.10.2: in --with pdn, each decoupling capacitor's loop inductance is solved from the copper (PEEC) where the stackup allows, with the estimate and the reason kept beside it.",+  "version": "0.10.3",+  "changelog": "0.10.3: KiCad 8 boards: numbered track and via nets resolve through the net table (issue 21); same-net copper closer than a solve cell is no longer joined by the grid, and the spots are reported (issue 22).",   "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+2−2
@@ -1,8 +1,8 @@ {   "slug": "adom-fields",   "type": "app",-  "version": "0.10.2",-  "changelog": "0.10.2: in --with pdn, each decoupling capacitor's loop inductance is solved from the copper (PEEC) where the stackup allows, with the estimate and the reason kept beside it.",+  "version": "0.10.3",+  "changelog": "0.10.3: KiCad 8 boards: numbered track and via nets resolve through the net table (issue 21); same-net copper closer than a solve cell is no longer joined by the grid, and the spots are reported (issue 22).",   "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.",
skills/adom-fields-review/SKILL.md+5
@@ -10,6 +10,11 @@ Parent skill: **adom-fields**  Every finding kind, which module it comes from (`core` always runs; the rest need `--with <module>`), and the review blocks behind them. +## Copper pieces and KiCad 8 boards (0.10.3)++- **Separate pieces stay separate.** Same-net copper the board keeps apart (two fills closer than a solve cell, joined only through other layers) is no longer joined by the grid. Each net's copper is split into the pieces that really touch, and every spot where two pieces came closer than a cell is in `nets[].piecesKeptApart` (layer and position). A peak near such a spot is real; for the exact copper there, re-run at `--pitch 0.1`.+- **KiCad 8 files work.** Tracks and vias written `(net 41)` resolve through the board's net table. A number the table does not list stops the run with the count; open and save the board in KiCad to fix it.+ ## Finding kinds by module  `core` always runs; the others need `--with <module>` (or the spec keys listed in SKILL.md). Kinds not listed are core: `current-neck`, `current-barrel`, `ampacity`, `ir-drop`, `pour-fragmented`, `tab-rise`, `thermal-vias`, `junction-temperature`, `capacitor-temperature`, `electro-thermal`, `ground-offset`, `ground-island`, `decoupling-far`, `decoupling-missing`, `hot-loop`, `inductor-ripple`, `output-ripple`, `solve-unconverged`, `load-unsolved`.
src/board.rs+8−2
@@ -86,8 +86,11 @@ impl Board {                 if d > 0.2 { b.stack_mm = Some(match ncu { 2 => vec![d], 4 => vec![0.2, (d - 0.4).max(0.1), 0.2], _ => { let c = ((d - 0.4) / 3.0).max(0.05); vec![0.2, c, c, c, 0.2] } }); }             }         }-        let net_names: std::collections::BTreeMap<String,String> = root.kids("net").iter().filter_map(|n|Some((n.atom(1)?.to_string(),n.atom(2)?.to_string()))).collect();-        let net_name = |n: &Sx| { let value=n.last_atom().unwrap_or(""); net_names.get(value).cloned().unwrap_or_else(||value.to_string()) };+        // the net table: `(net 41 "GND")` (KiCad 8 and earlier number every net; KiCad 10 names them on each item)+        let net_names: std::collections::BTreeMap<String,String> = root.kids("net").iter().filter_map(|n|Some((n.tok(1)?,n.atom(2)?.to_string()))).collect();+        // an item's net: its name when it carries one (`(net 41 "GND")`, `(net "GND")`), else its number through the table+        // (a KiCad 8 track or via is `(net 41)`, issue 21)+        let net_name = |n: &Sx| -> String { if let Some(name) = n.atom(2) { return name.to_string(); } let id = n.tok(1).unwrap_or_default(); net_names.get(&id).cloned().unwrap_or(id) };         // outline: the Edge.Cuts graphics' bounding box         let (mut xs, mut ys) = (Vec::new(), Vec::new());         for key in ["gr_line", "gr_rect", "gr_arc", "gr_poly", "gr_circle"] {@@ -176,6 +179,9 @@ impl Board {                 b.pads.push(Pad { reference: reference.clone(), pad: p.atom(1).map(str::to_string).or_else(|| p.num(1).map(|n| format!("{}", n as i64))).unwrap_or_default(), net, x, y, layers: on, area, func: p.kid("pinfunction").and_then(|k| k.atom(1)).map(str::to_string).unwrap_or_default() });             }         }+        // a numbered net with no table entry would silently drop the item from every solve: refuse instead (issue 21)+        let unresolved = b.tracks.iter().map(|t| &t.net).chain(b.vias.iter().map(|v| &v.net)).filter(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit())).count();+        if unresolved > 0 { return Err(format!("{path}: {unresolved} track(s) or via(s) name their net by a number the board's net table does not list; open and save the board in KiCad, then run again")); }         Ok(b)     }     pub fn width(&self) -> f64 { self.x1 - self.x0 }
src/fields.rs+17−5
@@ -102,7 +102,7 @@ fn d60() -> f64 { 60.0 } #[derive(Deserialize, Clone)] pub struct Hot { pub watts: f64, #[serde(default)] pub tabNet: String, /** this part's own rise limit, °C; spec hotMaxRiseC when not given (issue 16) */ #[serde(default)] pub maxRiseC: Option<f64>, /** absolute maximum junction temperature, °C (125 when not given) */ #[serde(default)] pub tjMaxC: Option<f64>, /** junction-to-case (tab), K/W, added on top of the solved tab rise */ #[serde(default)] pub thetaJcKPerW: Option<f64>, /** the pad the part is read at (its exposed pad's number, e.g. "29"); the largest tab-net pad when not given */ #[serde(default)] pub tabPad: Option<String> }  #[derive(Serialize, Clone)]-pub struct NetResult { /** the current on every terminal pin the solve used: + into the copper, - out (issue 17) */ pub terminalCurrents: Vec<Value>, /** heuristic or explicit, and how the sources and sinks were chosen */ pub terminalBasis: String, pub residual: f64, pub converged: bool, pub platedPads: usize, pub platedConnections: Vec<Value>, pub net: String, pub amps: f64, pub layers: Vec<String>, pub peakJ: f64, pub peakAt: [f64; 2], pub peakLayer: String, pub copperMm2: f64, pub jouleW: f64, pub vias: usize, pub iterations: usize }+pub struct NetResult { /** the current on every terminal pin the solve used: + into the copper, - out (issue 17) */ pub terminalCurrents: Vec<Value>, /** heuristic or explicit, and how the sources and sinks were chosen */ pub terminalBasis: String, pub residual: f64, pub converged: bool, pub platedPads: usize, pub platedConnections: Vec<Value>, pub net: String, pub amps: f64, pub layers: Vec<String>, pub peakJ: f64, pub peakAt: [f64; 2], pub peakLayer: String, pub copperMm2: f64, pub jouleW: f64, pub vias: usize, pub iterations: usize, /** cells where two separate pieces of this net's copper came closer than a cell and were kept apart (issue 22) */ #[serde(skip_serializing_if = "Vec::is_empty")] pub piecesKeptApart: Vec<Value> } #[derive(Serialize, Clone)] pub struct ChipResult { pub reference: String, pub watts: f64, pub tabNet: String, pub x: f64, pub y: f64, pub riseC: f64, pub riseMovingC: f64, pub planeW: f64, pub viaW: f64, pub vias: usize, pub otherViaW: f64, pub otherVias: usize, pub dielW: f64, pub airW: f64, pub viasWanted: usize } #[derive(Serialize, Clone)]@@ -159,7 +159,18 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu     let mut nets: Vec<NetResult> = Vec::new();     let mut flow: BTreeMap<String, crate::flow::NetFlow> = BTreeMap::new();     for (net, load) in &spec.loads {-        let fr: Vec<Grid> = LAYERS.iter().map(|l| ras.fraction(net, l, pool)).collect();+        // each layer's copper as pieces (crate::pieces, issue 22): a cell belongs to the piece with most copper in it, and+        // cells of different pieces do not conduct, so same-net copper closer than a cell is not joined where the board+        // does not join it+        let mut met: Vec<Value> = Vec::new();+        let (fr, piece): (Vec<Grid>, Vec<Vec<u32>>) = LAYERS.iter().map(|l| {+            match ras.masks.get(&(net.clone(), l.to_string())) {+                None => (Grid::new(w, h), vec![u32::MAX; w * h]),+                Some(m) => { let (f, c, mt) = crate::pieces::cells(b, net, l, m, ras.w, ras.h, ras.res, ras.x0, ras.y0, pool);+                    for (r, col) in mt.iter().take(20) { met.push(json!({"layer": l, "at": [((b.x0 + (*col as f64 + 0.5) * d) * 100.0).round() / 100.0, ((b.y0 + (*r as f64 + 0.5) * d) * 100.0).round() / 100.0]})); }+                    if !mt.is_empty() { log(&format!("{net} on {l}: {} cell(s) where two separate pieces of its copper came closer than one cell; kept apart (--pitch {:.2} resolves both)", mt.len(), d / 2.0)); }+                    let mut g = Grid::new(w, h); g.v.copy_from_slice(&f); (g, c) }+            } }).unzip();         let live: Vec<usize> = (0..LAYERS.len()).filter(|k| fr[*k].sum() > 0.0).collect();         if live.is_empty() { log(&format!("{net}: no copper on the board, skipped")); continue; }         let nl = live.len();@@ -169,8 +180,9 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu             // copper's conductivity at its solved temperature on the electro-thermal pass (20 °C reference, 0.393 %/°C)             let amb = spec.ambientC.unwrap_or(25.0);             let sg = |i: usize| -> f64 { match &spec.cu_rise { Some(t) => SIGMA / (1.0 + 0.00393 * (amb + t[*k].v[i] as f64 - 20.0)), None => SIGMA } };-            for r in 0..h { for c in 0..w - 1 { let i = r * w + c; sys.gx[a][r * (w - 1) + c] = 0.5 * (sg(i) + sg(i + 1)) * t_cu[*k] * harm(f.at(r, c) as f64, f.at(r, c + 1) as f64); } }-            for r in 0..h - 1 { for c in 0..w { let i = r * w + c; sys.gy[a][r * w + c] = 0.5 * (sg(i) + sg(i + w)) * t_cu[*k] * harm(f.at(r, c) as f64, f.at(r + 1, c) as f64); } }+            let pc = &piece[*k]; let same = |i: usize, j: usize| if pc[i] == pc[j] { 1.0 } else { 0.0 };+            for r in 0..h { for c in 0..w - 1 { let i = r * w + c; sys.gx[a][r * (w - 1) + c] = 0.5 * (sg(i) + sg(i + 1)) * t_cu[*k] * harm(f.at(r, c) as f64, f.at(r, c + 1) as f64) * same(i, i + 1); } }+            for r in 0..h - 1 { for c in 0..w { let i = r * w + c; sys.gy[a][r * w + c] = 0.5 * (sg(i) + sg(i + w)) * t_cu[*k] * harm(f.at(r, c) as f64, f.at(r + 1, c) as f64) * same(i, i + w); } }             for i in 0..n { sys.leak[a][i] = if f.v[i] > 0.0 { 1e-9 } else { 1.0 }; }         }         // every via and plated through-hole pad barrel on this net joins its live layers, each span by its real length@@ -384,7 +396,7 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu         nf.v_ext = mean(&ext_spots); nf.v_chip = mean(&chip_spots); nf.terminals = terminals; nf.amps = load.amps;         flow.insert(net.clone(), nf);         let term_currents: Vec<Value> = assignments.iter().map(|(p, a)| json!({"pin": format!("{}.{}", p.reference, p.pad), "amps": (a * 1e6).round() / 1e6})).collect();-        nets.push(NetResult { terminalCurrents: term_currents, terminalBasis: basis.clone(), residual: res, converged: res <= 1e-4, platedConnections: connections, platedPads: b.plated_holes.iter().filter(|v| v.net == *net).count(), net: net.clone(), amps: load.amps, layers: live.iter().map(|k| LAYERS[*k].to_string()).collect(), peakJ: peak, peakAt: peak_at, peakLayer: peak_layer, copperMm2: (copper * 10.0).round() / 10.0, jouleW: (joule * 1000.0).round() / 1000.0, vias: nvias, iterations: it });+        nets.push(NetResult { terminalCurrents: term_currents, terminalBasis: basis.clone(), residual: res, converged: res <= 1e-4, platedConnections: connections, platedPads: b.plated_holes.iter().filter(|v| v.net == *net).count(), net: net.clone(), amps: load.amps, layers: live.iter().map(|k| LAYERS[*k].to_string()).collect(), peakJ: peak, peakAt: peak_at, peakLayer: peak_layer, copperMm2: (copper * 10.0).round() / 10.0, jouleW: (joule * 1000.0).round() / 1000.0, vias: nvias, iterations: it, piecesKeptApart: met });         j_net.insert(net.clone(), grids);     }     let joule_total: f64 = nets.iter().map(|x| x.jouleW).sum();
src/main.rs+1−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 verify; mod compliance; mod report; mod inputs; mod tdr; mod modules; mod slim; mod peec; 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 compliance; mod report; mod inputs; mod tdr; mod modules; mod slim; mod peec; mod pieces; 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;
src/pieces.rsadded+138
@@ -0,0 +1,138 @@+//! Separate pieces of one net's copper (0.10.3, issue 22). Two same-net fills KiCad keeps 0.14 mm apart are two+//! conductors, joined only through other layers; on a 0.2 mm grid both put copper into the cells along the gap, and the+//! solve used to join them, inventing a neck. Here the pieces come from the board's geometry, not from pixels: per net+//! and layer, the fills, tracks, pads and via rings that touch or overlap are united into pieces (exact distance tests,+//! with a bucket grid so a board of thousands of items stays fast). Each solve cell then belongs to the piece with most+//! copper in it, and neighbouring cells conduct only within one piece. A cell where two pieces met is reported, since+//! one of them lost that cell's copper there (finer --pitch resolves both).+use crate::board::Board;+use std::collections::HashMap;++const EPS: f64 = 1e-4; // mm: copper this close is touching++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 cr = |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) = (cr(c, d, a), cr(c, d, b), cr(a, b, c), cr(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+}++/// An item of copper: a polygon, or a stroke (a track; a via ring is a stroke of zero length)+enum Item<'a> { Poly(&'a [(f64, f64)]), Stroke((f64, f64), (f64, f64), f64) }+impl Item<'_> {+    fn bbox(&self) -> [f64; 4] { match self {+        Item::Poly(p) => [p.iter().map(|q| q.0).fold(f64::MAX, f64::min), p.iter().map(|q| q.1).fold(f64::MAX, f64::min), p.iter().map(|q| q.0).fold(f64::MIN, f64::max), p.iter().map(|q| q.1).fold(f64::MIN, f64::max)],+        Item::Stroke(a, b, w) => [a.0.min(b.0) - w / 2.0, a.1.min(b.1) - w / 2.0, a.0.max(b.0) + w / 2.0, a.1.max(b.1) + w / 2.0] } }+}+fn touch(x: &Item, y: &Item) -> bool {+    match (x, y) {+        (Item::Stroke(a, b, w), Item::Stroke(c, d, v)) => seg_seg(*a, *b, *c, *d) <= (w + v) / 2.0 + EPS,+        (Item::Stroke(a, b, w), Item::Poly(p)) | (Item::Poly(p), Item::Stroke(a, b, w)) => { if inside(*a, p) || inside(*b, p) { return true; } let n = p.len(); (0..n).any(|i| seg_seg(*a, *b, p[i], p[(i + 1) % n]) <= w / 2.0 + EPS) }+        (Item::Poly(p), Item::Poly(q)) => { if p.iter().any(|v| inside(*v, q)) || q.iter().any(|v| inside(*v, p)) { return true; } let (n, m) = (p.len(), q.len());+            // edges of q bucketed by x so a long fill outline is not tested edge against edge everywhere+            let qe: Vec<((f64, f64), (f64, f64))> = (0..m).map(|j| (q[j], q[(j + 1) % m])).collect();+            (0..n).any(|i| { let (a, b) = (p[i], p[(i + 1) % n]); let (lo, hi) = (a.0.min(b.0) - EPS, a.0.max(b.0) + EPS); let (ylo, yhi) = (a.1.min(b.1) - EPS, a.1.max(b.1) + EPS);+                qe.iter().any(|(c, d)| c.0.max(d.0) >= lo && c.0.min(d.0) <= hi && c.1.max(d.1) >= ylo && c.1.min(d.1) <= yhi && seg_seg(a, b, *c, *d) <= EPS) }) }+    }+}++fn find(p: &mut Vec<usize>, x: usize) -> usize { let mut r = x; while p[r] != r { r = p[r]; } let mut y = x; while p[y] != r { let n = p[y]; p[y] = r; y = n; } r }++/// The pieces of one net on one layer: (items, piece of each item). Items: fills, pad polygons, tracks, via rings.+fn pieces_of<'a>(b: &'a Board, net: &str, layer: &str) -> (Vec<Item<'a>>, Vec<usize>) {+    let mut items: Vec<Item> = Vec::new();+    for p in b.polys.iter().chain(b.pad_polys.iter()).filter(|p| p.net == net && p.layer == layer && p.pts.len() > 2) { items.push(Item::Poly(&p.pts)); }+    for f in b.fills.iter().filter(|f| f.net == net && f.layer == layer && f.outer.len() > 2) { items.push(Item::Poly(&f.outer)); }+    for t in b.tracks.iter().filter(|t| t.net == net && t.layer == layer) { items.push(Item::Stroke(t.a, t.b, t.w)); }+    let real = b.copper.is_empty() || b.copper.iter().any(|c| c == layer);+    if real { for v in b.vias.iter().filter(|v| v.net == net) { items.push(Item::Stroke((v.x, v.y), (v.x, v.y), v.size)); } }+    let n = items.len(); let mut par: Vec<usize> = (0..n).collect();+    // candidate pairs from a 1 mm bucket grid over the items' boxes+    let bb: Vec<[f64; 4]> = items.iter().map(|i| i.bbox()).collect();+    let mut buckets: HashMap<(i64, i64), Vec<usize>> = HashMap::new();+    for (i, x) in bb.iter().enumerate() { for gx in (x[0] - EPS).floor() as i64..=(x[2] + EPS).floor() as i64 { for gy in (x[1] - EPS).floor() as i64..=(x[3] + EPS).floor() as i64 { buckets.entry((gx, gy)).or_default().push(i); } } }+    let mut tested: std::collections::HashSet<(usize, usize)> = Default::default();+    for v in buckets.values() { for (k, &i) in v.iter().enumerate() { for &j in &v[k + 1..] {+        let (a, z) = (i.min(j), i.max(j)); if find(&mut par, a) == find(&mut par, z) || !tested.insert((a, z)) { continue; }+        if bb[a][0] > bb[z][2] + EPS || bb[z][0] > bb[a][2] + EPS || bb[a][1] > bb[z][3] + EPS || bb[z][1] > bb[a][3] + EPS { continue; }+        if touch(&items[a], &items[z]) { let (ra, rz) = (find(&mut par, a), find(&mut par, z)); par[ra] = rz; }+    } } }+    let piece: Vec<usize> = (0..n).map(|i| find(&mut par, i)).collect();+    (items, piece)+}++/// Per solve cell of one net on one layer: the dominant piece's copper fraction, its piece (u32::MAX: no copper), and the+/// cells where two pieces met. `mask` is the net's fine copper mask (the raster's, so drills and pours are as solved).+pub fn cells(b: &Board, net: &str, layer: &str, mask: &[u8], fw: usize, fh: usize, res: f64, x0: f64, y0: f64, pool: usize) -> (Vec<f32>, Vec<u32>, Vec<(usize, usize)>) {+    let (gw, gh) = (fw / pool, fh / pool);+    let (items, piece) = pieces_of(b, net, layer);+    let distinct: std::collections::BTreeSet<usize> = piece.iter().copied().collect();+    // one piece (the usual case): plain fractions, nothing to keep apart+    if distinct.len() <= 1 {+        let mut f = vec![0f32; gw * gh]; let mut c = vec![u32::MAX; gw * gh];+        for r in 0..gh { for col in 0..gw { let mut s = 0u32; for i in 0..pool { for j in 0..pool { s += mask[(r * pool + i) * fw + col * pool + j] as u32; } } if s > 0 { f[r * gw + col] = s as f32 / (pool * pool) as f32; c[r * gw + col] = 0; } } }+        return (f, c, vec![]);+    }+    // label each copper pixel with the piece of the item covering its centre (the nearest item when none does, e.g. a+    // pour the raster filled itself)+    let mut lab = vec![u32::MAX; fw * fh];+    for (k, it) in items.iter().enumerate() {+        let x = it.bbox(); let (c0, c1) = ((((x[0] - x0) / res).floor().max(0.0)) as usize, (((x[2] - x0) / res).ceil().max(0.0) as usize).min(fw));+        let (r0, r1) = ((((x[1] - y0) / res).floor().max(0.0)) as usize, (((x[3] - y0) / res).ceil().max(0.0) as usize).min(fh));+        for r in r0..r1 { for c in c0..c1 { let i = r * fw + c; if mask[i] == 0 || lab[i] != u32::MAX { continue; }+            let p = (x0 + (c as f64 + 0.5) * res, y0 + (r as f64 + 0.5) * res);+            let hit = match it { Item::Poly(q) => inside(p, q), Item::Stroke(a, z, w) => seg_dist(p, *a, *z) <= w / 2.0 + res * 0.71 };+            if hit { lab[i] = piece[k] as u32; } } }+    }+    // copper no item claims (a raster-filled pour, a rounding edge): take a labelled neighbour's piece, spreading outward+    let mut frontier: Vec<usize> = (0..fw * fh).filter(|i| lab[*i] != u32::MAX).collect();+    while !frontier.is_empty() { let mut next = Vec::new(); for i in frontier { let (r, c) = (i / fw, i % fw); for j in [if r > 0 { Some(i - fw) } else { None }, if r + 1 < fh { Some(i + fw) } else { None }, if c > 0 { Some(i - 1) } else { None }, if c + 1 < fw { Some(i + 1) } else { None }].into_iter().flatten() { if mask[j] != 0 && lab[j] == u32::MAX { lab[j] = lab[i]; next.push(j); } } } frontier = next; }+    let mut f = vec![0f32; gw * gh]; let mut cp = vec![u32::MAX; gw * gh]; let mut met = Vec::new();+    for r in 0..gh { for col in 0..gw {+        let mut count: Vec<(u32, u32)> = Vec::new();+        for i in 0..pool { for j in 0..pool { let l = lab[(r * pool + i) * fw + col * pool + j]; if l == u32::MAX { continue; } match count.iter_mut().find(|x| x.0 == l) { Some(x) => x.1 += 1, None => count.push((l, 1)) } } }+        if let Some(best) = count.iter().max_by_key(|x| (x.1, std::cmp::Reverse(x.0))) { f[r * gw + col] = best.1 as f32 / (pool * pool) as f32; cp[r * gw + col] = best.0; if count.len() > 1 { met.push((r, col)); } }+    } }+    // and where two pieces sit in neighbouring cells: the edge the grid used to bridge+    for r in 0..gh { for col in 0..gw { let i = r * gw + col; if cp[i] == u32::MAX { continue; }+        let right = col + 1 < gw && cp[i + 1] != u32::MAX && cp[i + 1] != cp[i]; let down = r + 1 < gh && cp[i + gw] != u32::MAX && cp[i + gw] != cp[i];+        if (right || down) && !met.contains(&(r, col)) { met.push((r, col)); } } }+    (f, cp, met)+}++#[cfg(test)] mod tests {+    use super::*;+    use crate::board::Poly;+    fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> Vec<(f64, f64)> { vec![(x0, y0), (x1, y0), (x1, y1), (x0, y1)] }+    #[test] fn two_fills_apart_stay_apart_and_touching_ones_join() {+        let mut b = Board::default();+        b.polys = vec![Poly { net: "V".into(), layer: "F.Cu".into(), pts: rect(0.0, 0.0, 5.0, 2.0) }, Poly { net: "V".into(), layer: "F.Cu".into(), pts: rect(5.14, 0.0, 10.0, 2.0) }];+        let (_, p) = pieces_of(&b, "V", "F.Cu"); assert_ne!(p[0], p[1]);+        b.polys[1].pts = rect(5.0, 0.0, 10.0, 2.0); let (_, p) = pieces_of(&b, "V", "F.Cu"); assert_eq!(p[0], p[1]);+        // a track across the gap joins them+        b.polys[1].pts = rect(5.14, 0.0, 10.0, 2.0); b.tracks.push(crate::board::Track { net: "V".into(), layer: "F.Cu".into(), a: (4.0, 1.0), b: (6.0, 1.0), w: 0.25 });+        let (_, p) = pieces_of(&b, "V", "F.Cu"); assert_eq!(p[0], p[1]);+    }+    #[test] fn cells_keep_the_gap() {+        let mut b = Board::default();+        b.polys = vec![Poly { net: "V".into(), layer: "F.Cu".into(), pts: rect(0.0, 0.0, 1.0, 1.0) }, Poly { net: "V".into(), layer: "F.Cu".into(), pts: rect(1.14, 0.0, 2.2, 1.0) }];+        // a 0.1 mm mask of the two fills with the gap closed (as a coarse raster would see it), pooled by 2 to 0.2 mm cells+        let (fw, fh, res) = (22, 10, 0.1); let mask = vec![1u8; fw * fh];+        let (f, c, met) = cells(&b, "V", "F.Cu", &mask, fw, fh, res, 0.0, 0.0, 2);+        let (gw, _) = (fw / 2, fh / 2);+        assert!(f.iter().all(|x| *x > 0.0));+        assert_ne!(c[0], c[gw - 1]); // the two ends are different pieces+        let changes = (0..gw - 1).filter(|k| c[*k] != c[k + 1]).count(); assert_eq!(changes, 1); // one boundary between them+        assert!(!met.is_empty());+    }+}
src/sexp.rs+2
@@ -9,6 +9,8 @@ impl Sx {     pub fn kids(&self, key: &str) -> Vec<&Sx> { self.list().iter().filter(|k| matches!(k, Sx::List(_)) && k.head() == key).collect() }     pub fn kid(&self, key: &str) -> Option<&Sx> { self.list().iter().find(|k| matches!(k, Sx::List(_)) && k.head() == key) }     pub fn num(&self, i: usize) -> Option<f64> { match self.list().get(i) { Some(Sx::Num(n)) => Some(*n), Some(Sx::Atom(a)) => a.parse().ok(), _ => None } }+    /// the i-th element as text, a number or a word alike (a KiCad 8 net is `(net 41)`, its table entry `(net 41 "GND")`)+    pub fn tok(&self, i: usize) -> Option<String> { match self.list().get(i) { Some(Sx::Atom(a)) => Some(a.clone()), Some(Sx::Num(n)) => Some(if n.fract() == 0.0 { format!("{}", *n as i64) } else { format!("{n}") }), _ => None } }     pub fn atom(&self, i: usize) -> Option<&str> { match self.list().get(i) { Some(Sx::Atom(a)) => Some(a), _ => None } }     /// the last atom of the list (a net entry is `(net 3 "+VBAT")` or `(net "+VBAT")`)     pub fn last_atom(&self) -> Option<&str> { self.list().iter().rev().find_map(|k| if let Sx::Atom(a) = k { Some(a.as_str()) } else { None }) }