app
Adom Fields
Public Made by Adomby adom
Adom Fields: where the current and the heat go on your board.
← Commit history
Merge PR #434: 0.10.7: the board's ground, coincident lands, verify edge clearance (fields-25-26 -> main) merge
Showing changes introduced by the merge (against its first parent).
15 files changed
+182−26
Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-fields"-version = "0.10.6"+version = "0.10.7" dependencies = [ "clap", "png",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-fields"-version = "0.10.6"+version = "0.10.7" 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.7.mdadded+38@@ -0,0 +1,38 @@+# adom-fields 0.10.7++Issues 25 and 26, plus a `verify` fix found while testing them.++- **The board's ground (issue 25).** Any net whose name ends in GND counted as ground, and the first one in alphabetical order won. On a board with GND and BRIDGE_GND (a motor bridge's return, joined to GND only through a shunt), BRIDGE_GND became the ground, and the ground offset, stitching, decoupling and ESD checks were measured against it. On a board with AGND and PGND, the small AGND won.+ - The ground is now spec `ground` when given, else the net named GND, else the ground-named net the most pads are on.+ - `review.ground` records `groundFrom` and `groundNets`, so a wrong pick shows. A spec `ground` that isn't a net on the board is refused.+ - The same pick is used for the ground's own current solve and the noise review. The hot loop's cable emissions use the converter's power ground.+ - A protector diode or a stage-feed bypass capacitor to any ground now counts. Before, only one to the picked ground did, which gave false `esd-unprotected` and `stage-feed-unbypassed` findings.+ - `--with pdn` judged a rail only on capacitors to the picked ground, so a rail decoupled to PGND on a board whose ground was another net got no verdict or a wrong one. Each rail now uses the ground most of its capacitors go to, recorded as `return`. `capsToOtherGrounds` lists any it leaves out.+- **Coincident lands (issue 26).** TI's DGN0008B footprint has pad 9 eight times, two of them at the centre: a 2 x 3 mm land and a 1.6 x 2.94 mm one. No `at` could name one of them, so the terminal was refused.+ - Lands of one pad number at one centre on one net are now one piece of copper: `at` names it, and the largest land stands for it.+ - Naming it twice is still refused, and so is coincident copper on two nets.+ - With no `at` the pin is still ambiguous, and the error now says to give one.+- **`verify` kept a stitching via's centre, not its copper, 0.5 mm from the edge.** Applying the fix plan's stitching vias on the 12 V buck put one 0.1 mm from the edge, and KiCad's DRC failed it. The via's copper now keeps 0.5 mm. The fix plan's suggested points sit the via's radius plus 0.5 mm in, where they used to sit 0.6 mm in. Where no spot within reach keeps that and lands on the ground on both layers, `verify` skips the step and says so.++**What moves on your boards:** only boards with more than one ground net, or a ground not named GND. On single-ground boards (12 V buck, CM5 Minima, StickHub, tinytapeout demo), every finding with every module is unchanged.++**Checked:**++- Issue 25, on a test board built to the issue (GND, and BRIDGE_GND carrying 10 A through a shunt):+ - The ground is now GND, not BRIDGE_GND, and the false `esd-unprotected` on the protected USB line is gone.+ - A unit test covers the other side of it: a capacitor that only reaches BRIDGE_GND no longer counts as decoupling a chip that is only on GND.+ - With spec `ground: "BRIDGE_GND"` the old pick comes back. A spec ground that isn't on the board is refused.+- BQ25792 charger (Fusion `.brd`, AGND with 2 pads and PGND with 28):+ - The ground moves from AGND to PGND, and the stitching findings follow it.+ - PDN now lists all 6 rails, instead of silently skipping them. Each still needs spec `sources` for a verdict.+- PDN test board, ground GND, +3V3 decoupled to PGND: judged on PGND with C1 and C2. 0.10.6 judged it on the one capacitor to GND.+- Issue 26: the centre lands with `at` now solve, as does a mixed centre and via-pad split. Naming the centre twice, and leaving out `at`, are refused with a clear message.+- `verify --refill` against KiCad 10.0.8, with the buck project's own rules: 0.10.6 made one `copper_edge_clearance` DRC error. 0.10.7 makes none: it skips the step, because on that board no spot within 1 mm keeps the edge clearance on ground copper.+- Every command, on the new build:+ - `analyze --with all` on six real boards, compared finding by finding with 0.10.6.+ - `serve` and all 15 views in a headless browser, with no page errors from this release.+ - `tour`, `probe`, `report` and `parts`.+ - `thermal3d`, whose output is byte-identical to 0.10.6.+ - 0.10.6's fixes for issues 27, 28 and 29, re-run.++**Not checked:** the AM32 ESC board itself, whose page has the schematic but no board file; `silk`, which needs a kicad-cli SVG export not available here and is unchanged.
package.json+2−2@@ -1,8 +1,8 @@ { "slug": "adom-fields", "type": "app",- "version": "0.10.6",- "changelog": "0.10.6: the grid covers the whole board and a pad centred on its edge is modelled (issue 27); one board thickness, a body under the board couples at it, and a new solid-uncoupled issue says when a body is left out (issue 28); a decoupling capacitor counts when it returns to any ground the chip has a pin on, so VCC2 to PGND is no false finding (issue 29).",+ "version": "0.10.7",+ "changelog": "0.10.7: the board's ground is the net named GND (or spec ground, else the ground with the most pads), not the first ground name in the alphabet, and a rail decoupled to another ground gets a PDN verdict (issue 25); coincident lands of one pad number are one terminal named by at (issue 26); verify's stitching vias keep their copper 0.5 mm from the board edge.", "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.6",- "changelog": "0.10.6: the grid covers the whole board and a pad centred on its edge is modelled (issue 27); one board thickness, a body under the board couples at it, and a new solid-uncoupled issue says when a body is left out (issue 28); a decoupling capacitor counts when it returns to any ground the chip has a pin on, so VCC2 to PGND is no false finding (issue 29).",+ "version": "0.10.7",+ "changelog": "0.10.7: the board's ground is the net named GND (or spec ground, else the ground with the most pads), not the first ground name in the alphabet, and a rail decoupled to another ground gets a PDN verdict (issue 25); coincident lands of one pad number are one terminal named by at (issue 26); verify's stitching vias keep their copper 0.5 mm from the board edge.", "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+1−1@@ -33,7 +33,7 @@ Grouping: three or more low findings of one kind become one finding (`action.typ ## For the AI that called the solve: the review and the fix plan (0.7.6) `analyze` reviews the design as well as solving it, and says what to change, without anyone looking at the app:-- **`fields.json` `issues`**: every finding with `severity`, `kind`, `what`, `at` (board mm), `net`, `reference`, a plain `fix`, and an **`action`** to carry out: `move` (`ref`, `toPins`, `targetMm2`), `add-vias` (`net`, `points` or `at`, `pitchMm`), `widen` (`net`, `at`), `pour` (`net`, `layer`, `under`, `at`), `add-copper`, `add-part`, `review`. Kinds: `current-neck`, `tab-rise`, `thermal-vias` (the solve's own), and from the review `hot-loop`, `emissions`, `no-reference`, `ground-offset`, `ground-island`, `stitching-gap`, `ground-loop-risk`, `ir-drop` (over `dropPct` of the rail, or over the net's own `loads.<net>.maxDropMv`), `pour-fragmented` (a poured net's fill on one layer broke into pieces: the nearest gap, the pad in it, and a `move` (`reference`, `orRotateDeg`, `byMm`) or `reroute` action; `fields.json` `fragments` lists every pour's pieces per layer), `inductor-ripple`, `output-ripple`, `decoupling-far`, `decoupling-missing` (each chip power pin's nearest small capacitor to any ground the chip has a pin on, or to the review's ground, recorded as `return` in `review.decoupling`: over 3 mm moves it, none adds a 100 nF), `solid-uncoupled` (a solid body the solve left out: `boardContactM2kW` set but no face on the copper is high; no heat and no path to the board, so 0 W, is medium), `cavity-resonance`, `crosstalk`, `esd-unprotected` (a connector pin with no diode to ground: add a TVS within 3 mm if it faces the outside).+- **`fields.json` `issues`**: every finding with `severity`, `kind`, `what`, `at` (board mm), `net`, `reference`, a plain `fix`, and an **`action`** to carry out: `move` (`ref`, `toPins`, `targetMm2`), `add-vias` (`net`, `points` or `at`, `pitchMm`), `widen` (`net`, `at`), `pour` (`net`, `layer`, `under`, `at`), `add-copper`, `add-part`, `review`. Kinds: `current-neck`, `tab-rise`, `thermal-vias` (the solve's own), and from the review `hot-loop`, `emissions`, `no-reference`, `ground-offset`, `ground-island`, `stitching-gap`, `ground-loop-risk`, `ir-drop` (over `dropPct` of the rail, or over the net's own `loads.<net>.maxDropMv`), `pour-fragmented` (a poured net's fill on one layer broke into pieces: the nearest gap, the pad in it, and a `move` (`reference`, `orRotateDeg`, `byMm`) or `reroute` action; `fields.json` `fragments` lists every pour's pieces per layer), `inductor-ripple`, `output-ripple`, `decoupling-far`, `decoupling-missing` (each chip power pin's nearest small capacitor to any ground the chip has a pin on, or to the review's ground, recorded as `return` in `review.decoupling`: over 3 mm moves it, none adds a 100 nF), `solid-uncoupled` (a solid body the solve left out: `boardContactM2kW` set but no face on the copper is high; no heat and no path to the board, so 0 W, is medium), `cavity-resonance`, `crosstalk`, `esd-unprotected` (a connector pin with no diode to any ground: add a TVS within 3 mm if it faces the outside). - **`fields.json` `fixPlan`**: the actions, worst first and concrete edits before reviews, numbered for the caller to work down. `analyze` prints the first five as a `Hint: Fix plan` line and the review as a `Hint: Review` line. - **`fields.json` `review`**: the numbers behind them: `converter` (duty, ripple, hot loops per input capacitor and the best possible, the emissions margin), `reference` (each net's copper with no ground under it), `ground` (offset, connectors to the outside, islands, the stitching fence and its gaps), `rails` (IR drop per rail), `decoupling` (pin, cap, distance, loop), `esd` (each connector pin and what protects it). - **IR-drop probe**: `adom-fields probe --fields <dir> --from J1.1 --to U1.3` (or `--to <net>` for its farthest point, `--json` for the record) reads the drop between two pads from the solve, no re-solve; `adom-fields ui probe --from J1.1 --to U1.3` shows it in the running app's river view with a toast.
skills/adom-fields-spec/SKILL.md+5−1@@ -44,7 +44,7 @@ chips sit over the fields. ## The solver: holes, terminals, heat models, fans, transients - **Plated holes** (PR 5, 9): every via and plated through-hole pad barrel joins its copper layers in the electrical and thermal models (25 µm plating, circular or slotted, `platedPads` counted apart from vias). Drill holes are voids on every layer; each barrel's conductance is spread around its plated wall on cells with copper on both faces, and a wall the mesh does not resolve falls back to the nearest such cells, so a plated hole never disconnects a net. `nets[].platedConnections` gives each hole's current and mean barrel density. Blind, buried and micro vias are refused; Fusion boards read their declared `layerSetup`. See docs/plated-wall-coupling.md.-- **Explicit pin currents** (PR 6, 10): `loads.<net>.terminals: [{reference, pad, amps, at?}]`, positive into the copper, negative out, each side summing to `amps`; `at: [x, y]` picks one physical land where a pin number repeats. Wrong-net, missing, duplicate and unbalanced pins are refused; a loaded net the spec names that cannot be solved fails the run instead of publishing a partial report. `terminalAssignments` says explicit or heuristic per net.+- **Explicit pin currents** (PR 6, 10): `loads.<net>.terminals: [{reference, pad, amps, at?}]`, positive into the copper, negative out, each side summing to `amps`; `at: [x, y]` picks one physical land where a pin number repeats. Lands of one pad number at one centre on one net (a Texas DGN exposed pad's two coincident lands) are one piece of copper: `at` names it (0.10.7). Wrong-net, missing, duplicate and unbalanced pins are refused; a loaded net the spec names that cannot be solved fails the run instead of publishing a partial report. `terminalAssignments` says explicit or heuristic per net. - **Peak current density is the modeled maximum** (PR 8), before the display texture's 99.5th-percentile clip and unrounded (`peakJDefinition`). - **`dcMaxIterations`** (PR 5, default 20000, at most 100000) bounds each net's solve; the target is unchanged. - **Electro-thermal** (0.9.1, `review.electroThermal`). Copper's resistance rises 0.393 %/°C, so every run reports the first-order extra Joule heat at the solved temperatures, and an `electro-thermal` issue when it is over 10 %. With spec `electroThermal: true`, a second full pass solves the current and heat with each copper cell at its solved temperature. On the ESC: +33 % Joule, peak rise 109 → 119 °C.@@ -57,6 +57,10 @@ chips sit over the fields. - **Package cooling** (PR 11): `packageCooling: [{reference, junctionCaseKPerW, caseSinkKPerW, sinkAmbientStillKPerW, sinkAmbientMovingKPerW, boardLinks, provenance}]` puts a hot part's watts at its junction and lets them split through the supplied case, sink and board links; junction, case and sink rises per air state and an energy balance in `packageCooling`. See docs/package-cooling.md. - **`adom-fields thermal3d --spec --out`** (PR 12): axis-aligned 3D solids with explicit materials, contacts and convection for an off-board package and finned sink, with a GLB of the surface temperatures. Not CFD. See docs/thermal3d.md. +## The board's ground (0.10.7)++`ground: "GND"` names the net that is the board's ground. Without it the ground is the net named GND, else the ground-named net the most pads are on, so a separate return such as BRIDGE_GND, or a small AGND, never wins by its name's place in the alphabet. `review.ground` records `net`, `groundFrom` and `groundNets`. The ground offset, islands and stitching are measured on it. A protector or bypass capacitor to any ground counts, decoupling counts the chip's own grounds, and `--with pdn` judges each rail against the ground most of its capacitors go to (`return`, with `capsToOtherGrounds`). A spec `ground` that isn't a net on the board is refused. The converter's power ground is separate (`converter.ground`).+ ## The converter block (0.10.5) `converter: {inductor: "L1", vinMin: 21, ground: "PGND", series: ["R1"]}`, all optional:
src/cm.rs+2−2@@ -11,7 +11,6 @@ //! filtering on the cable. Compared with the chosen limit set (crate::limits) at 3 m. use crate::board::{Board, LAYERS}; use crate::fields::{Fields, Issue, Spec};-use crate::flow::is_ground; use serde_json::{json, Value}; use std::f64::consts::PI; @@ -20,7 +19,8 @@ fn r1(v: f64) -> f64 { (v * 10.0).round() / 10.0 } pub fn review(f: &Fields, b: &Board, spec: &Spec, rev: &Value, issues: &mut Vec<Issue>) -> Value { let conv = &rev["converter"]; let Some(fsw) = conv["fswHz"].as_f64() else { return json!({"ran": false, "why": "no switching converter found"}) };- let Some(gname) = f.net_names.iter().find(|n| is_ground(n)).cloned() else { return json!({"ran": false, "why": "no ground net"}) };+ // the hot loop returns on the converter's power ground; else the board's+ let Some(gname) = conv["ground"].as_str().map(str::to_string).or_else(|| crate::fields::board_ground(spec, b, &f.net_names).map(|g| g.0)) else { return json!({"ran": false, "why": "no ground net"}) }; let Some(nf) = f.flow.get(&gname) else { return json!({"ran": false, "why": "the ground current was not solved"}) }; let ig = nf.amps.max(1e-6); let (iout, ripple, duty, tr) = (conv["iout"].as_f64().unwrap_or(0.0), conv["rippleA"].as_f64().unwrap_or(0.0), conv["duty"].as_f64().unwrap_or(0.5), conv["riseNs"].as_f64().unwrap_or(8.0) * 1e-9);
src/emc.rs+37−8@@ -33,7 +33,10 @@ fn shoelace(p: &[(f64, f64)]) -> f64 { let mut a = 0.0; for i in 0..p.len() { le /// The review: adds issues to `f.issues`-style list and returns the `review` block for fields.json. pub fn review(f: &Fields, b: &Board, spec: &Spec, issues: &mut Vec<Issue>) -> Value { let rail = |n: &str| spec.voltages.get(n).copied().or_else(|| rail_from_name(n));- let gnd = f.net_names.iter().find(|n| is_ground(n)).cloned();+ let picked = crate::fields::board_ground(spec, b, &f.net_names);+ let gnd = picked.as_ref().map(|g| g.0.clone());+ // any net that is a ground: a protector or a bypass capacitor to one of them is doing its job (issue 25)+ let any_ground = |n: &str| is_ground(n) || spec.voltages.get(n) == Some(&0.0) || gnd.as_deref() == Some(n); let pads_of = |r: &str, n: &str| -> Vec<&Pad> { b.pads.iter().filter(|p| p.reference == r && p.net == n).collect() }; let nets_of = |r: &str| -> Vec<String> { let mut v: Vec<String> = b.pads.iter().filter(|p| p.reference == r).map(|p| p.net.clone()).collect(); v.sort(); v.dedup(); v }; let refs: Vec<String> = { let mut v: Vec<String> = b.pads.iter().map(|p| p.reference.clone()).collect(); v.sort(); v.dedup(); v };@@ -97,7 +100,7 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, issues: &mut Vec<Issue>) -> Va out["reference"] = json!(refs_out); // ---- ground: offset, islands, stitching, connectors to the outside ----------------------------------- if let (Some(gname), Some(g)) = (gnd.clone(), gid) {- let mut gout = json!({"net": gname});+ let mut gout = json!({"net": gname, "groundFrom": picked.as_ref().map(|g| g.1), "groundNets": f.net_names.iter().filter(|n| any_ground(n)).cloned().collect::<Vec<_>>()}); // a ground solve that did not converge has no offset worth reporting (issue 9) let g_ok = f.nets.iter().find(|x| x.net == gname).map_or(true, |x| x.converged); if !g_ok { gout["offsetNote"] = json!("the ground current solve did not converge: no offset reported"); }@@ -154,10 +157,13 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, issues: &mut Vec<Issue>) -> Va if run.len() > 1 { gaps.push(run); } gaps.sort_by(|a, z| z.len().cmp(&a.len())); gout["stitching"] = json!({"fmaxHz": fmax, "pitchMm": (pitch * 10.0).round() / 10.0, "edgeCoveredPct": (cov as f64 / tot.max(1) as f64 * 1000.0).round() / 10.0, "gaps": gaps.iter().take(6).map(|g| json!({"from": [g[0].0, g[0].1], "to": [g[g.len() - 1].0, g[g.len() - 1].1], "mm": g.len() as f64 * step})).collect::<Vec<_>>()});- for gp in gaps.iter().filter(|g| g.len() as f64 * step > pitch.min(4.0)) { let mm = gp.len() as f64 * step; let cnt = (mm / pitch).ceil().max(1.0) as usize; issues.push(issue("low", "stitching-gap", format!("{}: {:.1} mm of board edge with no ground via within {:.1} mm", gname, mm, reach), Some([gp[gp.len() / 2].0, gp[gp.len() / 2].1]), Some(&gname), None, format!("Add {} ground via(s) along the edge (the action lists each point) from ({:.1}, {:.1}) to ({:.1}, {:.1}), one every {:.1} mm, about 0.6 mm in from the edge.", cnt, gp[0].0, gp[0].1, gp[gp.len() - 1].0, gp[gp.len() - 1].1, pitch), { // the via points themselves, along the gap, set in from whichever edge each sits on+ // a stitching via's centre sits its radius plus KiCad's default 0.5 mm edge clearance in from the outline, at the+ // board's most common via size (0.6 mm when it has none)+ let inset = { let mut sizes: Vec<i64> = b.vias.iter().map(|v| (v.size * 1000.0).round() as i64).collect(); sizes.sort(); let mut best = (600i64, 0usize); let mut k = 0; while k < sizes.len() { let e = sizes[k..].iter().take_while(|x| **x == sizes[k]).count(); if e > best.1 { best = (sizes[k], e); } k += e; } 0.5 + best.0 as f64 / 2000.0 };+ for gp in gaps.iter().filter(|g| g.len() as f64 * step > pitch.min(4.0)) { let mm = gp.len() as f64 * step; let cnt = (mm / pitch).ceil().max(1.0) as usize; issues.push(issue("low", "stitching-gap", format!("{}: {:.1} mm of board edge with no ground via within {:.1} mm", gname, mm, reach), Some([gp[gp.len() / 2].0, gp[gp.len() / 2].1]), Some(&gname), None, format!("Add {} ground via(s) along the edge (the action lists each point) from ({:.1}, {:.1}) to ({:.1}, {:.1}), one every {:.1} mm, about {:.1} mm in from the edge (the via's copper 0.5 mm clear of it).", cnt, gp[0].0, gp[0].1, gp[gp.len() - 1].0, gp[gp.len() - 1].1, pitch, inset), { // the via points themselves, along the gap, set in from whichever edge each sits on let every = ((pitch / step).round() as usize).max(1); let first = (gp.len() % every) / 2;- let pts: Vec<[f64; 2]> = gp.iter().enumerate().filter(|(k, _)| *k >= first && (k - first) % every == 0).map(|(_, p)| { let (mut x, mut y) = *p; if (y - b.y0).abs() < 1e-6 { y += 0.6; } else if (y - (b.y0 + bh)).abs() < 1e-6 { y -= 0.6; } if (x - b.x0).abs() < 1e-6 { x += 0.6; } else if (x - (b.x0 + bw)).abs() < 1e-6 { x -= 0.6; } [r2(x), r2(y)] }).collect();- json!({"type": "add-vias", "net": gname, "points": pts, "from": [r2(gp[0].0), r2(gp[0].1)], "to": [r2(gp[gp.len() - 1].0), r2(gp[gp.len() - 1].1)], "pitchMm": (pitch * 10.0).round() / 10.0, "count": pts.len().max(cnt), "insetMm": 0.6}) })); }+ let pts: Vec<[f64; 2]> = gp.iter().enumerate().filter(|(k, _)| *k >= first && (k - first) % every == 0).map(|(_, p)| { let (mut x, mut y) = *p; if (y - b.y0).abs() < 1e-6 { y += inset; } else if (y - (b.y0 + bh)).abs() < 1e-6 { y -= inset; } if (x - b.x0).abs() < 1e-6 { x += inset; } else if (x - (b.x0 + bw)).abs() < 1e-6 { x -= inset; } [r2(x), r2(y)] }).collect();+ json!({"type": "add-vias", "net": gname, "points": pts, "from": [r2(gp[0].0), r2(gp[0].1)], "to": [r2(gp[gp.len() - 1].0), r2(gp[gp.len() - 1].1)], "pitchMm": (pitch * 10.0).round() / 10.0, "count": pts.len().max(cnt), "insetMm": r2(inset)}) })); } out["ground"] = gout; } // ---- decoupling: every chip power pin and its nearest capacitor ----------------------------------------@@ -206,10 +212,11 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, issues: &mut Vec<Issue>) -> Va // it feeds a stage: a transistor on this net, or an inductor from it to a transistor let stage = refs.iter().find(|q| q.starts_with('Q') && (nets_of(q).contains(&feed) || refs.iter().any(|l| l.starts_with('L') && nets_of(l).contains(&feed) && nets_of(l).iter().any(|n| nets_of(q).contains(n) && *n != feed)))).cloned(); let Some(stage) = stage else { continue };- let caps: Vec<(String, f64)> = refs.iter().filter(|c| c.starts_with('C') && nets_of(c).contains(&feed) && nets_of(c).contains(&gname)).filter_map(|c| b.values.get(c).and_then(|v| parse_value(v, 'F')).map(|v| (c.clone(), v))).collect();+ let cap_return = |c: &str| nets_of(c).into_iter().find(|n| *n != feed && any_ground(n));+ let caps: Vec<(String, f64)> = refs.iter().filter(|c| c.starts_with('C') && nets_of(c).contains(&feed) && cap_return(c).is_some()).filter_map(|c| b.values.get(c).and_then(|v| parse_value(v, 'F')).map(|v| (c.clone(), v))).collect(); let ctot: f64 = caps.iter().map(|c| c.1).sum(); let fc = if ctot > 0.0 && r.starts_with('R') { Some(1.0 / (2.0 * std::f64::consts::PI * ser * ctot)) } else { None };- let srf: Vec<Value> = caps.iter().map(|(c, v)| { let span = { let pv = pads_of(c, &feed); let pg = pads_of(c, &gname); match (pv.first(), pg.first()) { (Some(a), Some(z)) => (a.x - z.x).hypot(a.y - z.y), _ => 1.0 } }; let esl = (0.15 + 0.3 * span + 0.5) * 1e-9; json!({"cap": c, "uF": (v * 1e9).round() / 1e3, "srfMHz": ((1.0 / (2.0 * std::f64::consts::PI * (v * esl).sqrt())) / 1e4).round() / 100.0}) }).collect();+ let srf: Vec<Value> = caps.iter().map(|(c, v)| { let span = { let pv = pads_of(c, &feed); let pg = cap_return(c).map_or(Vec::new(), |g| pads_of(c, &g)); match (pv.first(), pg.first()) { (Some(a), Some(z)) => (a.x - z.x).hypot(a.y - z.y), _ => 1.0 } }; let esl = (0.15 + 0.3 * span + 0.5) * 1e-9; json!({"cap": c, "uF": (v * 1e9).round() / 1e3, "srfMHz": ((1.0 / (2.0 * std::f64::consts::PI * (v * esl).sqrt())) / 1e4).round() / 100.0}) }).collect(); let at = pads_of(r, &feed).first().map(|p| [r2(p.x), r2(p.y)]); feeds.push(json!({"feed": feed, "rail": rl, "series": r, "seriesValue": val, "stage": stage, "caps": srf, "cornerHz": fc.map(|x| x.round())})); if caps.is_empty() { issues.push(issue("medium", "stage-feed-unbypassed", format!("{} feeds {} from {} through {} ({}) with no capacitor to {} on it: the stage's signal rides the rail into every other stage on {}", feed, stage, rl, r, val, gname, rl), at, Some(&feed), Some(r), format!("Add a 100 nF plus a small high-frequency ceramic (1 nF for an IF stage, 100 pF for a VHF stage) from {} to {} at {}'s end, each with its own ground via.", feed, gname, stage), json!({"type": "add-part", "what": "capacitor", "value": "100nF", "also": "1nF or 100pF for the stage frequency", "between": [feed, gname], "near": stage, "maxDistMm": 2.0, "why": "stage feed bypass"}))); }@@ -223,7 +230,7 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, issues: &mut Vec<Issue>) -> Va // a protector: a diode or TVS by its designator, or an ESD array by its part number (USBLC6, TPD…, PESD…), on the line and ground const ESD: [&str; 12] = ["USBLC", "TPD", "PESD", "ESD", "TVS", "SP05", "SRV05", "PRTR", "SMF", "SMAJ", "SMBJ", "RCLAMP"]; let esd_part = |r: &str| [b.values.get(r), b.mpns.get(r)].iter().flatten().any(|v| { let u = v.to_ascii_uppercase(); ESD.iter().any(|k| u.contains(k)) });- let protector = |net: &str| refs.iter().find(|r| (r.starts_with('D') || r.starts_with("TVS") || r.starts_with("ESD") || esd_part(r)) && nets_of(r).contains(&net.to_string()) && nets_of(r).contains(&gname)).cloned();+ let protector = |net: &str| refs.iter().find(|r| (r.starts_with('D') || r.starts_with("TVS") || r.starts_with("ESD") || esd_part(r)) && nets_of(r).contains(&net.to_string()) && nets_of(r).iter().any(|n| n != net && any_ground(n))).cloned(); for jref in refs.iter().filter(|r| r.starts_with('J') || (r.starts_with('P') && !r.starts_with("PS"))) { for p in b.pads.iter().filter(|p| p.reference == *jref && !p.net.is_empty() && !is_ground(&p.net) && !p.net.starts_with("unconnected-")) { let prot = protector(&p.net);@@ -329,6 +336,28 @@ pub fn fix_plan(issues: &[Issue]) -> Value { // the suggested capacitor goes to the chip's nearest ground pin assert!(miss.iter().any(|m| m.starts_with("VBAD") && m.contains("[\"VBAD\",\"AGND\"]")), "{miss:?}"); }+ // issue 25: GND is the ground though BRIDGE_GND sorts first; the reviews measure against GND, and a protector or a+ // bypass capacitor to a ground counts+ #[test] fn the_review_uses_the_real_ground() {+ let mut b = Board { x1: 30., y1: 30., layer_names: vec!["F.Cu".into(), "B.Cu".into()], copper: vec!["F.Cu".into(), "B.Cu".into()], ..Board::default() };+ b.pads = vec![pad("U1", "1", "GND", 10., 10.), pad("U1", "2", "VCC", 10., 12.), pad("C1", "1", "VCC", 8., 12.), pad("C1", "2", "BRIDGE_GND", 8., 11.),+ pad("J1", "1", "GND", 25., 25.), pad("J1", "2", "DATA", 25., 27.), pad("J1", "3", "OTHER", 25., 29.), pad("D1", "1", "DATA", 22., 27.), pad("D1", "2", "GND", 22., 28.),+ pad("R5", "1", "+5V", 5., 20.), pad("R5", "2", "FEED", 5., 22.), pad("Q5", "1", "FEED", 5., 25.), pad("C5", "1", "FEED", 8., 22.), pad("C5", "2", "PGND", 8., 23.), pad("U9", "1", "PGND", 15., 25.)];+ b.values.insert("C1".into(), "100n".into()); b.values.insert("C5".into(), "100n".into()); b.values.insert("R5".into(), "10".into());+ for (i, g) in ["BRIDGE_GND", "GND", "PGND"].iter().enumerate() { let x = i as f64 * 10.0; b.polys.push(crate::board::Poly { net: (*g).into(), layer: "B.Cu".into(), pts: vec![(x, 0.), (x + 9., 0.), (x + 9., 30.), (x, 30.)] }); }+ let spec: crate::fields::Spec = serde_json::from_value(json!({"copperUm": 35, "voltages": {"VCC": 5}, "hot": {"U1": {"watts": 0.1, "tabNet": "GND"}}})).unwrap();+ let f = crate::fields::analyze(&b, &spec, 0.5, 60., &mut |_| {});+ let mut issues = Vec::new();+ let r = review(&f, &b, &spec, &mut issues);+ assert_eq!(r["ground"]["net"], "GND"); assert_eq!(r["ground"]["groundFrom"], "the net named GND");+ assert_eq!(r["ground"]["groundNets"], json!(["BRIDGE_GND", "GND", "PGND"].iter().filter(|n| f.net_names.iter().any(|x| x == *n)).collect::<Vec<_>>()));+ // U1 is on GND only: its capacitor to BRIDGE_GND doesn't decouple it, which 0.10.6 could not see while BRIDGE_GND was the ground+ assert!(issues.iter().any(|i| i.kind == "decoupling-missing" && i.net.as_deref() == Some("VCC") && i.what.contains("to GND anywhere")), "{:?}", issues.iter().map(|i| &i.what).collect::<Vec<_>>());+ let esd: Vec<&str> = issues.iter().filter(|i| i.kind == "esd-unprotected").filter_map(|i| i.net.as_deref()).collect();+ assert!(!esd.contains(&"DATA"), "D1 protects DATA to GND: {esd:?}");+ assert!(!issues.iter().any(|i| i.kind == "stage-feed-unbypassed"), "C5 bypasses FEED to PGND");+ assert_eq!(r["stageFeeds"][0]["feed"], "FEED"); assert_eq!(r["stageFeeds"][0]["caps"][0]["cap"], "C5");+ } // a board with one ground: the same finding as before, naming that ground #[test] fn one_ground_finding_is_unchanged() { let (miss, _) = missing(vec![pad("U1", "1", "GND", 10., 10.), pad("U1", "2", "VCC", 10., 12.), pad("U1", "3", "VDD", 12., 12.), pad("C1", "1", "VDD", 13., 12.), pad("C1", "2", "GND", 13., 13.), pad("J1", "1", "GND", 20., 20.)], json!({"VCC": 5, "VDD": 3.3}));
src/fields.rs+43@@ -25,6 +25,8 @@ pub struct Spec { #[serde(default = "d60")] pub hotMaxRiseC: f64, #[serde(default)] pub thermalVias: Vec<Value>, #[serde(default)] pub board: String,+ /// the net that is the board's ground, when the names leave it open (issue 25)+ #[serde(default)] pub ground: Option<String>, #[serde(default)] pub copperUmByLayer: BTreeMap<String,f64>, #[serde(default)] pub signals: crate::signals::Config, #[serde(default)] pub heatsinks: Vec<crate::heatsink::Block>,@@ -706,6 +708,19 @@ pub fn gap_between(spec: &Spec, _b: &crate::board::Board, ka: usize, kb: usize) (h, er) } /// The dielectric in each of the five slot gaps (between the six copper slots), as analyze normalised it.+/// The board's ground and how it was chosen (issue 25): spec `ground`; else the net named GND; else the ground-named net+/// most pads are on, so a separate return such as BRIDGE_GND or a small AGND never wins by its name's place in the+/// alphabet. `names` are the nets to choose from.+pub fn board_ground(spec: &Spec, b: &Board, names: &[String]) -> Option<(String, &'static str)> {+ if let Some(g) = &spec.ground { return Some((g.clone(), "spec ground")); }+ let mut grounds: Vec<&String> = names.iter().filter(|n| crate::flow::is_ground(n)).collect(); grounds.sort(); grounds.dedup();+ if let Some(g) = grounds.iter().find(|n| n.trim_start_matches(['+', '-', '/']).eq_ignore_ascii_case("GND")) { return Some(((*g).clone(), "the net named GND")); }+ let pads = |n: &String| b.pads.iter().filter(|p| p.net == *n).count();+ let mut best: Option<&String> = None;+ for g in grounds { if best.is_none_or(|x| pads(g) > pads(x)) { best = Some(g); } }+ best.map(|g| (g.clone(), "the ground net with the most pads"))+}+ /// The solve cell (row, column) under a point. A point on the board's far edge or within one cell past any edge snaps to /// the edge cell, so a pad centred on the outline has a cell on all four sides (issue 27). pub fn cell_at(x: f64, y: f64, x0: f64, y0: f64, d: f64, w: usize, h: usize) -> Option<(usize, usize)> {@@ -803,6 +818,34 @@ pub struct Band { pub minHz: f64, pub maxHz: f64, #[serde(default)] pub ifHz: Op } } +#[cfg(test)] mod ground_pick_tests {+ use super::*;+ fn pick(ground: Option<&str>, nets: &[(&str, usize)]) -> Option<(String, &'static str)> {+ let mut b = Board::default();+ for (n, count) in nets { for i in 0..*count { b.pads.push(crate::board::Pad { reference: format!("U{i}"), pad: "1".into(), net: (*n).into(), x: 0., y: 0., layers: vec!["F.Cu".into()], area: 1., func: String::new() }); } }+ let mut spec: Spec = serde_json::from_value(json!({"copperUm": 35})).unwrap(); spec.ground = ground.map(str::to_string);+ board_ground(&spec, &b, &nets.iter().map(|n| n.0.to_string()).collect::<Vec<_>>())+ }+ // issue 25+ #[test] fn the_board_ground_is_not_the_first_name_in_the_alphabet() {+ // the reported case: the separate return sorts first and has more pads, and GND is still the ground+ assert_eq!(pick(None, &[("BRIDGE_GND", 20), ("GND", 3), ("+3V3", 40)]).unwrap().0, "GND");+ assert_eq!(pick(None, &[("AGND", 30), ("gnd", 2)]).unwrap().0, "gnd");+ assert_eq!(pick(None, &[("AGND", 30), ("/GND", 2)]).unwrap().0, "/GND");+ // no net named GND: the one most parts are on+ assert_eq!(pick(None, &[("AGND", 2), ("PGND", 28)]).unwrap(), ("PGND".to_string(), "the ground net with the most pads"));+ assert_eq!(pick(None, &[("AGND", 28), ("PGND", 2)]).unwrap().0, "AGND");+ // a tie stays alphabetical, the same on every run+ assert_eq!(pick(None, &[("PGND", 4), ("AGND", 4)]).unwrap().0, "AGND");+ // the spec's word beats all of it, even for a net the names don't call a ground+ assert_eq!(pick(Some("BRIDGE_GND"), &[("BRIDGE_GND", 1), ("GND", 9)]).unwrap(), ("BRIDGE_GND".to_string(), "spec ground"));+ assert_eq!(pick(Some("RTN"), &[("RTN", 1), ("GND", 9)]).unwrap().0, "RTN");+ // one ground, and none+ assert_eq!(pick(None, &[("VSS", 1), ("+5V", 9)]).unwrap().0, "VSS");+ assert!(pick(None, &[("+5V", 9)]).is_none());+ }+}+ #[cfg(test)] mod solid_board_tests { use super::*; // issue 28: a 4-layer board, 17.5 µm copper and 1.5 mm of dielectric: 1.57 mm in all, 1.535 mm counting the outer copper only
src/main.rs+3−1@@ -111,8 +111,10 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck let amps = across.as_ref().and_then(|n| spec.loads.get(n)).map(|l| l.amps).unwrap_or_else(|| spec.loads.values().map(|l| l.amps).fold(0.0, f64::max)); if amps > 0.0 { spec.loads.insert(sw.clone(), fields::Load { amps, maxRiseC: None, maxDropMv: None, draws: Default::default(), terminals: None }); auto_switch.push((sw, amps)); } }+ if let Some(g) = &spec.ground { if !b.pads.iter().any(|p| p.net == *g) && !b.polys.iter().any(|p| p.net == *g) { return Err(format!("spec ground {g} is not a net on this board")); } } let mut auto_ground: Option<(String, f64)> = None;- if let Some(gname) = b.pads.iter().map(|p| p.net.clone()).find(|n| crate::flow::is_ground(n)) {+ let pad_nets: Vec<String> = b.pads.iter().map(|p| p.net.clone()).collect();+ if let Some((gname, _)) = fields::board_ground(&spec, &b, &pad_nets) { if !spec.loads.contains_key(&gname) && !spec.loads.is_empty() { let amps = spec.loads.values().map(|l| l.amps).fold(0.0, f64::max); // under 0.2 A the return current's drop is microvolts and its heat nothing: not worth a solve (name the ground
src/noise.rs+1−1@@ -55,7 +55,7 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, issues: &mut Vec<Issue>) -> Va let (w, h, n) = (f.w, f.h, f.w * f.h); let rail = |nm: &str| spec.voltages.get(nm).copied().or_else(|| rail_from_name(nm)); let real: Vec<usize> = (0..LAYERS.len()).filter(|k| f.net_ids[*k].iter().filter(|x| **x != u16::MAX).count() > n * 15 / 100).collect();- let gid = f.net_names.iter().position(|x| is_ground(x)).map(|i| i as u16);+ let gid = crate::fields::board_ground(spec, b, &f.net_names).and_then(|g| f.net_names.iter().position(|x| *x == g.0)).map(|i| i as u16); // the strong tones, each a frequency range (a tuned LO sweeps): switching harmonics (trapezoid envelope within 40 dB // of the fundamental) and the named clocks: a crystal's pins, or an LC oscillator no chip drives, swing nearly as a // sine, so only the first five harmonics count as strong; a logic clock's run on (or the spec's harmonics)
src/pdn.rs+8−3@@ -69,7 +69,7 @@ pub struct RailCfg { pub fn review(f: &Fields, b: &Board, spec: &Spec, conv: &Value, issues: &mut Vec<Issue>, out_dir: Option<&std::path::Path>) -> Value { let rail = |n: &str| spec.voltages.get(n).copied().or_else(|| rail_from_name(n));- let Some(gnd) = f.net_names.iter().find(|n| is_ground(n)).cloned() else { return json!({"rails": [], "note": "no ground net"}) };+ let Some((gnd, _)) = crate::fields::board_ground(spec, b, &f.net_names) else { return json!({"rails": [], "note": "no ground net"}) }; let refs: Vec<String> = { let mut v: Vec<String> = b.pads.iter().map(|p| p.reference.clone()).collect(); v.sort(); v.dedup(); v }; let nets_of = |r: &str| -> Vec<String> { let mut v: Vec<String> = b.pads.iter().filter(|p| p.reference == r).map(|p| p.net.clone()).collect(); v.sort(); v.dedup(); v }; let pads_of = |r: &str, n: &str| -> Vec<&Pad> { b.pads.iter().filter(|p| p.reference == r && p.net == n).collect() };@@ -77,7 +77,6 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, conv: &Value, issues: &mut Vec let real: Vec<usize> = (0..LAYERS.len()).filter(|k| f.net_ids[*k].iter().filter(|x| **x != u16::MAX).count() > n * 15 / 100).collect(); // the top copper's gap to the next real copper layer, and that dielectric's εr let (h, er_top) = if real.len() >= 2 { crate::fields::gap_between(spec, b, real[0], real[1]) } else { (1.465, spec.epsilonR.unwrap_or(4.3)) };- let gid = f.net_names.iter().position(|x| *x == gnd).map(|i| i as u16); let fs = freqs(); let lib = crate::caps::Library::load(spec.capCurves.as_deref()); // PEEC (crate::peec): each capacitor's loop to its load pin solved from the copper, every copper layer at its height@@ -90,6 +89,12 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, conv: &Value, issues: &mut Vec let cfg = spec.pdn.get(&name).cloned().unwrap_or_default(); let is_out = conv["outNet"].as_str() == Some(&name); let is_in = conv["inNet"].as_str() == Some(&name); let srcs: Vec<String> = spec.sources.get(&name).cloned().unwrap_or_default();+ // the rail's return: the ground most of its capacitors go to, the board's on a tie or with none, so a rail decoupled to+ // PGND is judged on a board whose ground is something else (issue 25)+ let count_on = |g: &String| refs.iter().filter(|r| r.starts_with('C') && { let ns = nets_of(r); ns.contains(&name) && ns.contains(g) }).count();+ let gnd = { let mut best = (gnd.clone(), count_on(&gnd)); for g in f.net_names.iter().filter(|x| is_ground(x)) { let c = count_on(g); if c > best.1 { best = (g.clone(), c); } } best.0 };+ let gid = f.net_names.iter().position(|x| *x == gnd).map(|i| i as u16);+ let other_ground_caps: Vec<String> = refs.iter().filter(|r| r.starts_with('C') && { let ns = nets_of(r); ns.contains(&name) && !ns.contains(&gnd) && ns.iter().any(|x| *x != name && is_ground(x)) }).cloned().collect(); let caps: Vec<String> = refs.iter().filter(|r| r.starts_with('C') && nets_of(r).contains(&name) && nets_of(r).contains(&gnd)).cloned().collect(); if caps.is_empty() && !is_out { continue; } // what feeds the rail: the spec's sources, the modelled converter, an on-board regulator (a part named@@ -413,7 +418,7 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, conv: &Value, issues: &mut Vec } let br: Vec<Value> = bs.iter().map(|x| json!({"name": x.name, "kind": x.kind, "at": x.at, "r": x.r, "l": x.l, "lp": x.lp, "rp": x.rp, "c": x.c, "info": x.extra})).collect(); let added: Vec<Value> = cur.iter().skip(bs.len()).map(|x| json!({"name": x.name, "kind": x.kind, "at": x.at, "r": x.r, "l": x.l, "lp": 0.0, "rp": 0.0, "c": x.c, "info": x.extra})).collect();- rails.push(json!({"net": name, "railV": v, "load": load_name, "loadAt": [r2(load.x), r2(load.y)], "targetOhm": sig(zt), "stepA": sig(step), "tolPct": tol, "bandHz": [flo, sig(fhi)],+ rails.push(json!({"net": name, "railV": v, "return": gnd, "capsToOtherGrounds": other_ground_caps, "load": load_name, "loadAt": [r2(load.x), r2(load.y)], "targetOhm": sig(zt), "stepA": sig(step), "tolPct": tol, "bandHz": [flo, sig(fhi)], "worst": {"hz": sig(fs[wi]), "ohm": sig(z0[wi]), "ratio": sig(wr), "inductive": bl, "capacitive": bc}, "traceWidthMm": wmm, "nHPerMm": sig(lpm * 1e9), "branches": br, "added": added, "fixes": fixes, "decapPlan": plan, "zOhm": z0.iter().map(|x| sig(*x)).collect::<Vec<_>>(), "zAfterOhm": if fixes.is_empty() { Value::Null } else { json!(zf.iter().map(|x| sig(*x)).collect::<Vec<_>>()) }, "middlebrook": mb, "hotPlug": hot, "spice": spice})); }
src/terminals.rs+33−2@@ -25,12 +25,19 @@ pub fn resolve<'a>(board: &'a Board, net: &str, amps: f64, rows: &[Terminal]) if !t.amps.is_finite() || t.at.is_some_and(|xy| xy.iter().any(|v| !v.is_finite())) { return Err(format!("{net}: terminal currents must be finite and pin assignments unique")); }- let pads: Vec<_> = board.pads.iter().enumerate().filter(|(_, p)| {+ let mut pads: Vec<_> = board.pads.iter().enumerate().filter(|(_, p)| { p.reference == t.reference && p.pad == t.pad && t.at.is_none_or(|[x, y]| (p.x - x).abs() <= 1e-6 && (p.y - y).abs() <= 1e-6) }).collect();+ // lands of one pad number at one centre on one net are one piece of copper, and KiCad unions them (issue 26):+ // `at` names the piece, and the largest land stands for it+ if t.at.is_some() && pads.len() > 1 && pads.iter().all(|(_, p)| p.net == net) {+ let largest = pads.iter().copied().max_by(|a, b| a.1.area.total_cmp(&b.1.area)).unwrap();+ pads = vec![largest];+ } if pads.len() != 1 || pads[0].1.net != net {- return Err(format!("{net}: {}.{} must identify exactly one pad on this net", t.reference, t.pad));+ let hint = if t.at.is_none() && pads.len() > 1 { format!("; pad {} repeats {} times, so give `at` with the centre of one land", t.pad, pads.len()) } else { String::new() };+ return Err(format!("{net}: {}.{} must identify exactly one pad on this net{hint}", t.reference, t.pad)); } if !seen.insert(pads[0].0) { return Err(format!("{net}: physical pad assigned more than once"));@@ -81,6 +88,30 @@ mod tests { assert!(resolve(&b,"V",3.,&[row("J1",3.),first.clone(),second.clone()]).is_err()); } }+ // issue 26: Texas DGN0008B has pad 9 eight times, two of them coincident at the centre with different sizes+ #[test] fn coincident_lands_are_one_land_named_by_at() {+ let mut b = board();+ let land = |x: f64, y: f64, area: f64, net: &str| Pad { reference: "U1".into(), pad: "9".into(), net: net.into(), x, y, layers: vec!["F.Cu".into()], area, func: String::new() };+ for p in [land(5., 5., 6.0, "V"), land(5., 5., 4.7, "V"), land(4.5, 4., 0.2, "V"), land(5.5, 4., 0.2, "V")] { b.pads.push(p); }+ let rows = |r: Vec<Terminal>| { let mut v = vec![row("J1", 3.)]; v.extend(r); v };+ let nine = |amps: f64, at: Option<[f64; 2]>| Terminal { reference: "U1".into(), pad: "9".into(), amps, at };+ let r = resolve(&b, "V", 3., &rows(vec![nine(-3., Some([5., 5.]))])).unwrap();+ assert_eq!(r[1].0.area, 6.0, "the largest land stands for the piece");+ // the order on the board doesn't matter+ let mut swapped = b.clone(); let n = swapped.pads.len(); swapped.pads.swap(n - 4, n - 3);+ assert_eq!(resolve(&swapped, "V", 3., &rows(vec![nine(-3., Some([5., 5.]))])).unwrap()[1].0.area, 6.0);+ // a via pad beside it is still its own land, and the piece can't be assigned twice+ let split = resolve(&b, "V", 3., &rows(vec![nine(-2., Some([5., 5.])), nine(-1., Some([4.5, 4.]))])).unwrap();+ assert_eq!(split[2].0.x, 4.5);+ let twice = resolve(&b, "V", 3., &rows(vec![nine(-1.5, Some([5., 5.])), nine(-1.5, Some([5., 5.]))])).unwrap_err();+ assert!(twice.contains("more than once"), "{twice}");+ // no centre given stays ambiguous, and says what to do+ let none = resolve(&b, "V", 3., &rows(vec![nine(-3., None)])).unwrap_err();+ assert!(none.contains("give `at`"), "{none}");+ // coincident lands on two nets are not one piece+ b.pads.push(land(8., 8., 1.0, "V")); b.pads.push(land(8., 8., 2.0, "GND"));+ assert!(resolve(&b, "V", 3., &rows(vec![nine(-3., Some([8., 8.]))])).is_err());+ } #[test] fn selector_cannot_bypass_wrong_net_or_duplicate_assignment() { let b=board(); let mut wrong=row("J2",-3.);wrong.at=Some([0.,0.]); assert!(resolve(&b,"V",3.,&[row("J1",3.),wrong]).is_err());
src/verify.rs+5−1@@ -12,6 +12,8 @@ use sha2::{Digest, Sha256}; use std::path::Path; const CLR: f64 = 0.2;+/// KiCad's default copper-to-board-edge clearance; a new via's copper keeps at least this from the outline+const EDGE_CLR: f64 = 0.5; fn r2(v: f64) -> f64 { (v * 100.0).round() / 100.0 } fn seg_dist(p: (f64, f64), a: (f64, f64), b: (f64, f64)) -> f64 {@@ -101,7 +103,9 @@ pub fn apply(text: &str, b0: &Board, plan: &[Value], soft: bool) -> (String, Vec 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+ // the via's copper, not its centre, keeps KiCad's default 0.5 mm board edge clearance+ let edge = EDGE_CLR + vs / 2.0;+ let fits = |p: (f64, f64), placed: &[(f64, f64)]| p.0 > b.x0 + edge && p.0 < b.x1 - edge && p.1 > b.y0 + edge && p.1 < b.y1 - edge && 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);