← Commit history

adom-fields 0.7.20: IR-drop probe + per-net maxDropMv budget (issue 14), pour fragments with the move that joins them (issue 15)

John Lauer ·93b45ac473 · ·parent 24001b2
12 files changed +227−37
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-fields"-version = "0.7.19"+version = "0.7.20" 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."
SKILL.md+4−3
@@ -14,7 +14,7 @@ One binary, three commands, one app. The AI does the analysis; the human explore adom-fields analyze --board <board.kicad_pcb | board.brd from Fusion> --spec <spec.json> --out fields ``` -The board must be saved with its zones filled (the filled polygons are the copper). The spec is the AI Flow spec: `loads` (net: amps, maxRiseC), `hot` (ref: watts, tabNet), `copperUm`, `hotMaxRiseC`, `thermalVias` (pad, count), optional `stackupMm` (three dielectric thicknesses). About two minutes for a 65 x 75 mm four-layer board at the default 0.2 mm cell; `--pitch 0.1` is four times longer and sharper.+The board must be saved with its zones filled (the filled polygons are the copper). The spec is the AI Flow spec: `loads` (net: amps, maxRiseC, maxDropMv), `hot` (ref: watts, tabNet), `copperUm`, `hotMaxRiseC`, `thermalVias` (pad, count), optional `stackupMm` (three dielectric thicknesses). About two minutes for a 65 x 75 mm four-layer board at the default 0.2 mm cell; `--pitch 0.1` is four times longer and sharper.  What comes out, in `fields/fields.json` and `fields/tex/`: - `nets`, ordered by amps then by peak density: peak A/mm² and where, the layers, the copper mm², the Joule loss, the vias.@@ -128,10 +128,11 @@ A screen, not a chamber: differential mode only; cables (common mode), which oft ## 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`, `inductor-ripple`, `output-ripple`, `decoupling-far`, `decoupling-missing` (each chip power pin's nearest small capacitor: over 3 mm moves it, none adds a 100 nF), `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: over 3 mm moves it, none adds a 100 nF), `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` `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).-- Budgets in the spec: `groundOffsetMv` (10), `dropPct` (1 % of the rail), `emcFmaxHz` (1 GHz, for the stitching pitch), `switching.<net>.riseNs` (8).+- **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.+- Budgets in the spec: `groundOffsetMv` (10), `dropPct` (1 % of the rail), `loads.<net>.maxDropMv` (none; `review.drops` lists every loaded net's drop and its two ends), `emcFmaxHz` (1 GHz, for the stitching pitch), `switching.<net>.riseNs` (8).  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). 
docs/release-0.7.20.mdadded+5
@@ -0,0 +1,5 @@+# adom-fields 0.7.20++- **IR-drop probe (issue 14).** `adom-fields probe --fields <dir> --from J1.1 --to U1.3` reads the DC drop between two pads of a solved net from the voltage map, without solving again. `--to <net>` measures to the net's farthest point, and `--json` prints the record. `adom-fields ui probe` shows the same number in the running app: it switches to the river view on the net and zooms to the pad. On the TPS54202 buck, VIN reads 3.0 mV from J1.1 to U1.3 at 0.6 A.+- **Per-net drop budget (issue 14).** `loads.<net>.maxDropMv` grades a net's IR drop. If the drop is over the budget, it becomes an `ir-drop` issue: medium, or high when it is over twice the budget. The issue names its two ends and carries a `widen` action at the net's densest copper, with the parallel-path alternative. `review.drops` lists every loaded net's drop, its ends and its budget.+- **Pour fragments (issue 15).** For each poured net and layer, `fields.json` `fragments` gives the number of pieces the fill broke into and their areas. It also gives how many pieces the net's vias join through other layers and how many are isolated, and the nearest gap from the main piece. A loaded or ground net with a split pour gets a `pour-fragmented` issue. It is low when the net's vias join the pieces and medium when a piece is isolated. If another net's pad sits in the gap, the action is `move` (rotate 90° or move by about the gap); otherwise it is `reroute` for the copper crossing the gap. On the ESC, +VBAT's F.Cu pour is in 4 pieces, and the nearest gap (0.85 mm) is blocked by R14 pad 2.
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-fields",   "type": "app",-  "version": "0.7.19",+  "version": "0.7.20",   "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.7.19",+  "version": "0.7.20",   "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]"   }-}\ No newline at end of file+}
skills/adom-fields/SKILL.md+4−3
@@ -14,7 +14,7 @@ One binary, three commands, one app. The AI does the analysis; the human explore adom-fields analyze --board <board.kicad_pcb> --spec <spec.json> --out fields ``` -The board must be saved with its zones filled (the filled polygons are the copper). The spec is the AI Flow spec: `loads` (net: amps, maxRiseC), `hot` (ref: watts, tabNet), `copperUm`, `hotMaxRiseC`, `thermalVias` (pad, count), optional `stackupMm` (three dielectric thicknesses). About two minutes for a 65 x 75 mm four-layer board at the default 0.2 mm cell; `--pitch 0.1` is four times longer and sharper.+The board must be saved with its zones filled (the filled polygons are the copper). The spec is the AI Flow spec: `loads` (net: amps, maxRiseC, maxDropMv), `hot` (ref: watts, tabNet), `copperUm`, `hotMaxRiseC`, `thermalVias` (pad, count), optional `stackupMm` (three dielectric thicknesses). About two minutes for a 65 x 75 mm four-layer board at the default 0.2 mm cell; `--pitch 0.1` is four times longer and sharper.  What comes out, in `fields/fields.json` and `fields/tex/`: - `nets`, ordered by amps then by peak density: peak A/mm² and where, the layers, the copper mm², the Joule loss, the vias.@@ -128,10 +128,11 @@ A screen, not a chamber: differential mode only; cables (common mode), which oft ## 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`, `inductor-ripple`, `output-ripple`, `decoupling-far`, `decoupling-missing` (each chip power pin's nearest small capacitor: over 3 mm moves it, none adds a 100 nF), `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: over 3 mm moves it, none adds a 100 nF), `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` `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).-- Budgets in the spec: `groundOffsetMv` (10), `dropPct` (1 % of the rail), `emcFmaxHz` (1 GHz, for the stitching pitch), `switching.<net>.riseNs` (8).+- **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.+- Budgets in the spec: `groundOffsetMv` (10), `dropPct` (1 % of the rail), `loads.<net>.maxDropMv` (none; `review.drops` lists every loaded net's drop and its two ends), `emcFmaxHz` (1 GHz, for the stitching pitch), `switching.<net>.riseNs` (8).  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). 
src/emc.rs+22−1
@@ -297,9 +297,30 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, issues: &mut Vec<Issue>) -> Va         let (mut lo, mut hi) = (f64::MAX, f64::MIN); for k in 0..LAYERS.len() { for i in 0..n { if nf.on[k].v[i] != 0.0 { let v = nf.v[k].v[i] as f64; lo = lo.min(v); hi = hi.max(v); } } }         let drop = (hi - lo).max(0.0); let budget = spec.dropPct.unwrap_or(1.0);         rails.push(json!({"net": name, "railV": rv, "dropMv": (drop * 1e5).round() / 100.0, "dropPct": (drop / rv * 1e4).round() / 100.0}));-        if drop / rv * 100.0 > budget { let nr = f.nets.iter().find(|x| x.net == *name); issues.push(issue("medium", "ir-drop", format!("{}: {:.0} mV of IR drop, {:.1} % of {} V (budget {:.1} %)", name, drop * 1e3, drop / rv * 100.0, rv, budget), nr.map(|x| x.peakAt), Some(name), None, format!("Widen {} where it narrows (its current-density peak marks the spot) or add a parallel path on another layer.", name), json!({"type": "widen", "net": name, "at": nr.map(|x| x.peakAt), "why": "IR drop"}))); }+        if drop / rv * 100.0 > budget && spec.loads.get(name).and_then(|l| l.maxDropMv).is_none() { let nr = f.nets.iter().find(|x| x.net == *name); issues.push(issue("medium", "ir-drop", format!("{}: {:.0} mV of IR drop, {:.1} % of {} V (budget {:.1} %)", name, drop * 1e3, drop / rv * 100.0, rv, budget), nr.map(|x| x.peakAt), Some(name), None, format!("Widen {} where it narrows (its current-density peak marks the spot) or add a parallel path on another layer.", name), json!({"type": "widen", "net": name, "at": nr.map(|x| x.peakAt), "why": "IR drop"}))); }     }     out["rails"] = json!(rails);+    // ---- each loaded net against its own drop budget, mV (issue 14): source terminal to the farthest sink -------+    let mut drops = Vec::new();+    for (name, load) in &spec.loads {+        let Some(nf) = f.flow.get(name) else { continue };+        let (mut lo, mut hi, mut ilo, mut ihi) = (f64::MAX, f64::MIN, 0usize, 0usize); for k in 0..LAYERS.len() { for i in 0..n { if nf.on[k].v[i] != 0.0 { let v = nf.v[k].v[i] as f64; if v < lo { lo = v; ilo = i; } if v > hi { hi = v; ihi = i; } } } }+        if lo > hi { continue; }+        let drop_mv = (hi - lo) * 1e3;+        // the two ends, named by the terminal nearest each voltage extreme (a resistive net's extremes sit at its terminals)+        let name_at = |i: usize| -> Option<String> { let (x, y) = (f.x0 + ((i % f.w) as f64 + 0.5) * f.d, f.y0 + ((i / f.w) as f64 + 0.5) * f.d); nf.terminals.iter().min_by(|a, z| (a.x - x).hypot(a.y - y).partial_cmp(&(z.x - x).hypot(z.y - y)).unwrap()).map(|t| format!("{}.{}", t.reference, t.pad)) };+        let (hi_t, lo_t) = (name_at(ihi), name_at(ilo));+        drops.push(json!({"net": name, "dropMv": (drop_mv * 1000.0).round() / 1000.0, "budgetMv": load.maxDropMv, "highEnd": hi_t, "lowEnd": lo_t}));+        if let Some(bmv) = load.maxDropMv { if drop_mv > bmv {+            let nr = f.nets.iter().find(|x| x.net == *name);+            let mut is = issue(if drop_mv > 2.0 * bmv { "high" } else { "medium" }, "ir-drop", format!("{}: {:.2} mV of IR drop from {} to {} against a {:.2} mV budget", name, drop_mv, hi_t.clone().unwrap_or_default(), lo_t.clone().unwrap_or_default(), bmv), nr.map(|x| x.peakAt), Some(name), None,+                format!("Widen {} at its densest point ({}) or add a parallel path on another layer with vias at both ends; the drop has to fall by {:.0} %.", name, nr.map(|x| format!("({:.1}, {:.1}) on {}", x.peakAt[0], x.peakAt[1], x.peakLayer)).unwrap_or_default(), (1.0 - bmv / drop_mv) * 100.0),+                json!({"type": "widen", "net": name, "at": nr.map(|x| x.peakAt), "layer": nr.map(|x| x.peakLayer.clone()), "dropMv": (drop_mv * 1000.0).round() / 1000.0, "budgetMv": bmv, "between": [hi_t, lo_t], "orAddVias": "a parallel path on another layer, stitched at both ends", "why": "IR drop over budget"}));+            is.layer = nr.map(|x| x.peakLayer.clone());+            issues.push(is);+        } }+    }+    out["drops"] = json!(drops);     out } 
src/fields.rs+1−1
@@ -60,7 +60,7 @@ fn d35() -> f64 { 35.0 } fn d60() -> f64 { 60.0 } /// a loaded net: its current, and optionally its pins' signed currents (PR 6: positive enters the copper, negative leaves; /// each side sums to amps), instead of the connector-to-parts heuristic-#[derive(Deserialize, Clone)] pub struct Load { pub amps: f64, #[serde(default)] pub maxRiseC: Option<f64>, #[serde(default)] pub terminals: Option<Vec<crate::terminals::Terminal>> }+#[derive(Deserialize, Clone)] pub struct Load { pub amps: f64, #[serde(default)] pub maxRiseC: Option<f64>, /** the IR drop this net may have from its source to its farthest sink, mV (issue 14) */ #[serde(default)] pub maxDropMv: Option<f64>, #[serde(default)] pub terminals: Option<Vec<crate::terminals::Terminal>> } /// a part's dissipation: through its exposed tab's net (tabNet), or, for a leaded part with no tab, through all its leads, /// shared by pad area (issue 8) #[derive(Deserialize, Clone)] pub struct Hot { pub watts: f64, #[serde(default)] pub tabNet: String }
src/main.rs+14−3
@@ -1,6 +1,6 @@ //! adom-fields: where the current and the heat go on your board. use sha2::{Digest, Sha256};-mod caps; 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 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;@@ -33,6 +33,9 @@ enum Cmd {     /// A scripted walkthrough over the live channel: the nets by relevance, each field, the issues; for the AI's video step     Tour { #[arg(long, default_value_t = 8874)] port: u16, /// milliseconds to hold each beat while the board drifts (camera moves take up to 18 s since 0.5); gentle on purpose, the flow speeds the clip up ten times     #[arg(long, default_value_t = 20000)] pause_ms: u64 },+    /// 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 },     /// Is the server up     Health { #[arg(long, default_value_t = 8874)] port: u16 },     /// Install the skill from the wiki page@@ -54,6 +57,8 @@ enum UiCmd {     Explain { #[arg(long)] quantity: Option<String>, #[arg(long)] stop: bool },     Select { #[arg(long)] net: Option<String>, #[arg(long)] chip: Option<String> },     Issue { #[arg(long)] index: usize },+    /// The drop between two pads (or a pad and its net) on the served board, shown in the river view with a toast+    Probe { #[arg(long)] from: String, #[arg(long)] to: String },     State,     Console, }@@ -92,13 +97,13 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck         if spec.loads.contains_key(&sw) || spec.loads.is_empty() { continue; }         let across = b.pads.iter().filter(|p| p.net == sw && p.reference.starts_with('L')).find_map(|p| b.pads.iter().find(|q| q.reference == p.reference && q.net != sw).map(|q| q.net.clone()));         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, terminals: None }); auto_switch.push((sw, amps)); }+        if amps > 0.0 { spec.loads.insert(sw.clone(), fields::Load { amps, maxRiseC: None, maxDropMv: None, terminals: None }); auto_switch.push((sw, amps)); }     }     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)) {         if !spec.loads.contains_key(&gname) && !spec.loads.is_empty() {             let amps = spec.loads.values().map(|l| l.amps).fold(0.0, f64::max);-            if amps > 0.0 { spec.loads.insert(gname.clone(), fields::Load { amps, maxRiseC: None, terminals: None }); auto_ground = Some((gname, amps)); }+            if amps > 0.0 { spec.loads.insert(gname.clone(), fields::Load { amps, maxRiseC: None, maxDropMv: None, terminals: None }); auto_ground = Some((gname, amps)); }         }     }     if !pitch.is_finite() || pitch <= 0.0 || pitch > b.width().min(b.height())/2.0 { return Err("pitch must be positive and resolve at least two cells across the board".into()); }@@ -145,6 +150,7 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck     let mut iss = std::mem::take(&mut f.issues);     iss.extend(unsolved);     let mut review = emc::review(&f, &b, &spec, &mut iss);+    let fragments = pours::review(&f, &b, &spec, &mut iss);     let mut nz = noise::review(&f, &b, &spec, &mut iss);     let pd = pdn::review(&f, &b, &spec, &review["converter"], &mut iss, Some(out.as_path()));     // a current solve that did not converge is an issue, not a result (issue 9)@@ -186,6 +192,7 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck     if let Some(cav) = nz["cavities"].as_array_mut() { let shapes: Vec<Value> = cav.iter_mut().map(|c| json!({"net": c["net"].clone(), "grid": c["grid"].clone(), "shapes": c["shapes"].take()})).collect(); for c in cav.iter_mut() { c.as_object_mut().map(|o| o.remove("shapes")); } std::fs::write(out.join("noise.json"), serde_json::to_string(&json!({"cavities": shapes})).unwrap()).map_err(|e| e.to_string())?; }     review["noise"] = nz;     review["pdn"] = pd;+    summary["fragments"] = fragments;     summary["review"] = review;     summary["fixPlan"] = emc::fix_plan(&f.issues);     summary["flow"] = flow::write(out, &f, &b, &spec)?;@@ -254,6 +261,10 @@ fn main() {             ok(&msg, &["A running `serve` picks them up on the next page load; `adom-fields ui view --parts ghost|solid|off` switches them.".into()]);         }         Cmd::Ui { what, port } => serve::ui(what, port),+        Cmd::Probe { fields, from, to, json } => match probe::compute(&fields, &from, &to) {+            Ok(v) => if json { println!("{}", serde_json::to_string_pretty(&v).unwrap()); } else { ok(&format!("{}: {} mV from {} to {} at {} A", v["net"].as_str().unwrap_or(""), v["dropMv"], v["from"].as_str().unwrap_or(""), v["to"].as_str().unwrap_or(""), v["amps"]), &[format!("The whole net spans {} mV; its densest copper is at {} on {} ({:.1} A/mm²), where widening buys the most.", v["netDropMv"], v["worstCell"]["at"], v["worstCell"]["layer"].as_str().unwrap_or("?"), v["worstCell"]["peakJ"].as_f64().unwrap_or(0.0)), "Budget it: spec loads.<net>.maxDropMv makes a drop over the budget an ir-drop issue with an action in fixPlan.".into()]); },+            Err(e) => err(&e, &["Pads are REF.PAD as in fields.json `pads`; --to may be the net's name for its farthest point.".into()]),+        },         Cmd::Tour { port, pause_ms } => serve::tour(port, pause_ms),         Cmd::Health { port } => match serve::get(port, "/state") { Ok(v) => ok(&format!("adom-fields serving on {port}: {}", v["board"]["name"].as_str().unwrap_or("no board")), &[]), Err(e) => err(&format!("not serving on {port}: {e}"), &[format!("Start it: `adom-fields serve --fields <dir> --port {port} &`")]) },         Cmd::Install => {
src/pours.rsadded+98
@@ -0,0 +1,98 @@+//! Pour continuity (issue 15): for each poured net and layer, how many separate pieces the fill broke into, whether the+//! net's vias join them through the other layers, and for a split pour the narrowest gap between its main piece and+//! the next one with the part (or the other net's copper) sitting in that gap, as a move the caller can make.+use crate::board::{Board, LAYERS};+use crate::fields::{Fields, Issue, Spec};+use crate::flow::is_ground;+use serde_json::{json, Value};++fn find(p: &mut [usize], mut a: usize) -> usize { while p[a] != a { p[a] = p[p[a]]; a = p[a]; } a }+fn r2(x: f64) -> f64 { (x * 100.0).round() / 100.0 }++pub fn review(f: &Fields, b: &Board, spec: &Spec, issues: &mut Vec<Issue>) -> Value {+    let (w, h, d) = (f.w, f.h, f.d); let n = w * h; let nl = LAYERS.len();+    let cell = |x: f64, y: f64| -> Option<usize> { let (c, r) = (((x - f.x0) / d).floor(), ((y - f.y0) / d).floor()); if c < 0.0 || r < 0.0 || c as usize >= w || r as usize >= h { None } else { Some(r as usize * w + c as usize) } };+    let at = |i: usize| [f.x0 + ((i % w) as f64 + 0.5) * d, f.y0 + ((i / w) as f64 + 0.5) * d];+    // a piece smaller than this is a sliver of fill, not a pour fragment worth a move+    let min_cells = ((0.5 / (d * d)).ceil() as usize).max(2);+    let mut nets: Vec<String> = b.polys.iter().map(|p| p.net.clone()).filter(|x| !x.is_empty()).collect(); nets.sort(); nets.dedup();+    let mut out = Vec::new();+    for net in &nets {+        let Some(id) = f.net_names.iter().position(|x| x == net) else { continue }; let id = id as u16;+        // the net's copper per layer, 4-connected pieces+        let mut lab = vec![u32::MAX; nl * n]; let mut size: Vec<usize> = Vec::new(); let mut layer_of: Vec<usize> = Vec::new();+        for k in 0..nl { for s in 0..n {+            if f.net_ids[k][s] != id || lab[k * n + s] != u32::MAX { continue; }+            let c = size.len() as u32; lab[k * n + s] = c; let mut st = vec![s]; let mut cnt = 0;+            while let Some(i) = st.pop() { cnt += 1; let (r, cc) = (i / w, i % w);+                for (ok, j) in [(cc > 0, i.wrapping_sub(1)), (cc + 1 < w, i + 1), (r > 0, i.wrapping_sub(w)), (r + 1 < h, i + w)] { if ok && f.net_ids[k][j] == id && lab[k * n + j] == u32::MAX { lab[k * n + j] = c; st.push(j); } } }+            size.push(cnt); layer_of.push(k);+        } }+        // the pieces across the layers: a via or plated hole of the net joins every layer it lands copper on (the drill+        // void is no copper, so its ring of cells is searched)+        let mut par: Vec<usize> = (0..size.len()).collect();+        for v in b.vias.iter().chain(b.plated_holes.iter()).filter(|v| v.net == *net) {+            let Some(i) = cell(v.x, v.y) else { continue }; let rad = ((v.size / 2.0 / d).ceil() as i64).max(1); let (r0, c0) = ((i / w) as i64, (i % w) as i64);+            let mut hit: Vec<usize> = Vec::new();+            for k in 0..nl { 'k: for dr in -rad..=rad { for dc in -rad..=rad { let (r, c) = (r0 + dr, c0 + dc); if r < 0 || c < 0 || r >= h as i64 || c >= w as i64 { continue; } let l = lab[k * n + r as usize * w + c as usize]; if l != u32::MAX { hit.push(l as usize); break 'k; } } } }+            for p in hit.windows(2) { let (a, z) = (find(&mut par, p[0]), find(&mut par, p[1])); if a != z { par[a] = z; } }+        }+        let important = spec.loads.contains_key(net) || is_ground(net);+        for k in 0..nl {+            // the pieces this layer's pour polygons landed in+            let mut pieces: Vec<usize> = Vec::new();+            for p in b.polys.iter().filter(|p| p.net == *net && p.layer == LAYERS[k]) {+                for &(x, y) in p.pts.iter().step_by((p.pts.len() / 16).max(1)) { let Some(i) = cell(x, y) else { continue }; let (r0, c0) = ((i / w) as i64, (i % w) as i64);+                    'v: for dr in -1..=1i64 { for dc in -1..=1i64 { let (r, c) = (r0 + dr, c0 + dc); if r < 0 || c < 0 || r >= h as i64 || c >= w as i64 { continue; } let l = lab[k * n + r as usize * w + c as usize]; if l != u32::MAX { pieces.push(l as usize); break 'v; } } } }+            }+            pieces.sort(); pieces.dedup(); pieces.retain(|&p| size[p] >= min_cells);+            if pieces.is_empty() { continue; }+            pieces.sort_by(|a, z| size[*z].cmp(&size[*a]));+            let main = pieces[0];+            let mut rec = json!({"net": net, "layer": LAYERS[k], "pieces": pieces.len(), "areasMm2": pieces.iter().map(|&p| r2(size[p] as f64 * d * d)).collect::<Vec<_>>()});+            if pieces.len() > 1 {+                // the gap from the main piece to each other piece: one breadth-first sweep from the main piece over the+                // layer, carrying where each cell was reached from+                let mut src = vec![usize::MAX; n]; let mut q = std::collections::VecDeque::new();+                for i in 0..n { if lab[k * n + i] == main as u32 { src[i] = i; q.push_back(i); } }+                while let Some(i) = q.pop_front() { let (r, c) = ((i / w) as i64, (i % w) as i64);+                    for dr in -1..=1i64 { for dc in -1..=1i64 { let (rr, cc) = (r + dr, c + dc); if rr < 0 || cc < 0 || rr >= h as i64 || cc >= w as i64 { continue; } let j = rr as usize * w + cc as usize; if src[j] == usize::MAX { src[j] = src[i]; q.push_back(j); } } } }+                let main_root = find(&mut par, main);+                let mut others = Vec::new();+                for &p in &pieces[1..] {+                    let (mut best, mut pair) = (f64::MAX, (0, 0));+                    for i in 0..n { if lab[k * n + i] == p as u32 && src[i] != usize::MAX { let (a, z) = (at(src[i]), at(i)); let g = (a[0] - z[0]).hypot(a[1] - z[1]); if g < best { best = g; pair = (src[i], i); } } }+                    let joined = find(&mut par, p) == main_root;+                    others.push((p, best, pair, joined));+                }+                others.sort_by(|a, z| a.1.partial_cmp(&z.1).unwrap());+                let isolated = others.iter().filter(|o| !o.3).count();+                rec["joinedThroughVias"] = json!(others.len() - isolated); rec["isolated"] = json!(isolated);+                let (p, gap, (ia, ib), joined) = others[0];+                let (a, z) = (at(ia), at(ib)); let m = [(a[0] + z[0]) / 2.0, (a[1] + z[1]) / 2.0];+                // what sits in the gap: another net's pad on this layer near its middle, else the other net's copper across it+                let on_layer = |pl: &[String]| pl.iter().any(|l| l == LAYERS[k] || l == "*.Cu");+                let pad = b.pads.iter().filter(|q| q.net != *net && on_layer(&q.layers)).map(|q| (q, (q.x - m[0]).hypot(q.y - m[1]))).filter(|(_, dd)| *dd <= gap / 2.0 + 1.5).min_by(|x, y| x.1.partial_cmp(&y.1).unwrap()).map(|(q, _)| q.clone());+                let mut across: std::collections::BTreeMap<u16, usize> = Default::default();+                let steps = ((gap / d).ceil() as usize).max(1) * 2;+                for s in 0..=steps { let t = s as f64 / steps as f64; if let Some(i) = cell(a[0] + (z[0] - a[0]) * t, a[1] + (z[1] - a[1]) * t) { let o = f.net_ids[k][i]; if o != id && o != u16::MAX { *across.entry(o).or_default() += 1; } } }+                let blocking_net = across.iter().max_by_key(|x| x.1).map(|(o, _)| f.net_names[*o as usize].clone());+                let gap_info = json!({"from": [r2(a[0]), r2(a[1])], "to": [r2(z[0]), r2(z[1])], "gapMm": r2(gap), "pieceAreaMm2": r2(size[p] as f64 * d * d), "joinedThroughVias": joined, "blockingPad": pad.as_ref().map(|q| format!("{}.{}", q.reference, q.pad)), "blockingNet": pad.as_ref().map(|q| q.net.clone()).or(blocking_net.clone())});+                rec["nearestGap"] = gap_info.clone();+                if important {+                    let sev = if isolated > 0 { "medium" } else { "low" };+                    let how = if joined { "joined only through the net's vias on other layers" } else { "not joined to the main piece on any layer" };+                    let (fix, action) = match &pad {+                        Some(q) => (format!("Rotate {} 90° or move it about {:.1} mm so its {} pad ({}) leaves the gap and {}'s pour on {} closes; re-pour and re-run.", q.reference, gap + 0.3, q.pad, q.net, net, LAYERS[k]),+                            json!({"type": "move", "reference": q.reference, "pad": q.pad, "orRotateDeg": 90, "byMm": r2(gap + 0.3), "net": net, "layer": LAYERS[k], "gap": gap_info, "why": "join a split pour"})),+                        None => (format!("Reroute {} out of the {:.1} mm gap at ({:.1}, {:.1}) on {} so {}'s pour closes; re-pour and re-run.", blocking_net.clone().unwrap_or_else(|| "the copper".into()), gap, m[0], m[1], LAYERS[k], net),+                            json!({"type": "reroute", "net": blocking_net, "at": [r2(m[0]), r2(m[1])], "layer": LAYERS[k], "gap": gap_info, "why": "join a split pour"})),+                    };+                    issues.push(Issue { severity: sev.into(), kind: "pour-fragmented".into(), what: format!("{net}: its pour on {} is in {} pieces ({} joined through vias, {} isolated); the nearest one ({:.1} mm², {how}) is {:.1} mm from the main piece", LAYERS[k], pieces.len(), others.len() - isolated, isolated, size[p] as f64 * d * d, gap), at: Some([r2(m[0]), r2(m[1])]), layer: Some(LAYERS[k].into()), net: Some(net.clone()), reference: pad.as_ref().map(|q| q.reference.clone()), fix, action: Some(action) });+                }+            }+            out.push(rec);+        }+    }+    json!(out)+}
src/probe.rsadded+56
@@ -0,0 +1,56 @@+//! The IR-drop probe (issue 14): the DC voltage difference between two pads of a solved net, or from a pad to the far end+//! of its net, read from the solve's voltage map (flow.bin) without re-solving.+use serde_json::{json, Value};+use std::path::Path;++fn r(x: f64, k: f64) -> f64 { (x * k).round() / k }++/// `from` and `to` are REF.PAD (J1.1); `to` may instead be a net name, meaning the point of that net farthest in voltage+/// from `from`.+pub fn compute(dir: &Path, from: &str, to: &str) -> Result<Value, String> {+    let f: Value = serde_json::from_str(&std::fs::read_to_string(dir.join("fields.json")).map_err(|e| format!("{}: {e}", dir.join("fields.json").display()))?).map_err(|e| e.to_string())?;+    let bin = std::fs::read(dir.join("flow.bin")).map_err(|_| "no flow.bin next to fields.json: re-run `adom-fields analyze` with 0.7.20 or later".to_string())?;+    let fl = &f["flow"];+    let (w, h) = (fl["cols"].as_u64().unwrap_or(0) as usize, fl["rows"].as_u64().unwrap_or(0) as usize);+    let (d, x0, y0) = (fl["cellMm"].as_f64().unwrap_or(0.2), fl["x0"].as_f64().unwrap_or(0.0), fl["y0"].as_f64().unwrap_or(0.0));+    let layers: Vec<String> = fl["layers"].as_array().cloned().unwrap_or_default().iter().filter_map(|l| l.as_str().map(String::from)).collect();+    let names: Vec<String> = fl["nets"].as_array().cloned().unwrap_or_default().iter().map(|m| m["name"].as_str().unwrap_or("").to_string()).collect();+    let n = w * h;+    if n == 0 || bin.len() < layers.len() * 4 * n * 4 { return Err("flow.bin does not match fields.json's grid".into()); }+    let at = |k: usize, arr: usize, i: usize| -> f32 { let o = ((k * 4 + arr) * n + i) * 4; f32::from_le_bytes([bin[o], bin[o + 1], bin[o + 2], bin[o + 3]]) };+    let volt = |k: usize, i: usize| at(k, 2, i); let owner = |k: usize, i: usize| at(k, 3, i);+    let pads = f["pads"].as_array().cloned().unwrap_or_default();+    let pad = |s: &str| -> Result<Value, String> {+        let (rf, pn) = s.rsplit_once('.').ok_or_else(|| format!("{s}: give a pad as REF.PAD, e.g. J1.1"))?;+        pads.iter().find(|p| p["ref"] == rf && p["pad"] == pn).cloned().ok_or_else(|| format!("{s}: no such pad on the board"))+    };+    // the pad's voltage: the mean over its net's solved copper within 0.6 mm, on every layer+    let pad_v = |p: &Value| -> Result<(f64, String), String> {+        let net = p["net"].as_str().unwrap_or("").to_string();+        let id = names.iter().position(|x| *x == net).ok_or_else(|| format!("{}.{}: its net {net} is not on the grid", p["ref"], p["pad"]))? as f32;+        let (x, y) = (p["x"].as_f64().unwrap_or(0.0), p["y"].as_f64().unwrap_or(0.0));+        for rad in [0.6, 1.2] {+            let (mut s, mut c) = (0.0, 0);+            let (c0, r0) = (((x - x0) / d) as i64, ((y - y0) / d) as i64); let k_r = (rad / d).ceil() as i64;+            for k in 0..layers.len() { for dr in -k_r..=k_r { for dc in -k_r..=k_r { let (rr, cc) = (r0 + dr, c0 + dc); if rr < 0 || cc < 0 || rr >= h as i64 || cc >= w as i64 { continue; } if ((dr as f64) * d).hypot(dc as f64 * d) > rad { continue; } let i = rr as usize * w + cc as usize; let v = volt(k, i); if owner(k, i) == id && v.is_finite() { s += v as f64; c += 1; } } } }+            if c > 0 { return Ok((s / c as f64, net)); }+        }+        Err(format!("{}.{}: no copper of {net} under it on the grid", p["ref"], p["pad"]))+    };+    let pa = pad(from)?; let (va, net) = pad_v(&pa)?;+    let meta = fl["nets"].as_array().and_then(|a| a.iter().find(|m| m["name"] == net.as_str())).cloned().unwrap_or(Value::Null);+    if meta["loaded"] != true { return Err(format!("{net} carries no solved current: its drop is zero by construction; add it to the spec's loads")); }+    let (vb, to_at, to_name) = if to.contains('.') && pads.iter().any(|p| format!("{}.{}", p["ref"].as_str().unwrap_or(""), p["pad"].as_str().unwrap_or("")) == to) {+        let pb = pad(to)?; let (vb, nb) = pad_v(&pb)?;+        if nb != net { return Err(format!("{from} is on {net} and {to} is on {nb}: the probe reads the drop along one net")); }+        (vb, [pb["x"].as_f64().unwrap_or(0.0), pb["y"].as_f64().unwrap_or(0.0)], to.to_string())+    } else {+        if to != net { return Err(format!("{to}: give a pad (REF.PAD) on {net}, or the net name {net} for its farthest point")); }+        let id = names.iter().position(|x| *x == net).unwrap() as f32;+        let (mut best, mut bi) = (va, (0usize, 0usize));+        for k in 0..layers.len() { for i in 0..n { let v = volt(k, i); if owner(k, i) == id && v.is_finite() && (v as f64 - va).abs() > (best - va).abs() { best = v as f64; bi = (k, i); } } }+        (best, [x0 + ((bi.1 % w) as f64 + 0.5) * d, y0 + ((bi.1 / w) as f64 + 0.5) * d], format!("{net} (farthest point, {})", layers[bi.0]))+    };+    let worst = f["nets"].as_array().and_then(|a| a.iter().find(|x| x["net"] == net.as_str())).map(|x| json!({"at": x["peakAt"], "layer": x["peakLayer"], "peakJ": x["peakJ"], "note": "the net's highest current density: where its copper narrows most and the drop builds fastest"})).unwrap_or(Value::Null);+    Ok(json!({"net": net, "from": from, "to": to_name, "fromAt": [pa["x"], pa["y"]], "toAt": [r(to_at[0], 100.0), r(to_at[1], 100.0)], "fromV": r(va, 1e6), "toV": r(vb, 1e6), "dropMv": r((va - vb) * 1e3, 1e3), "netDropMv": r(meta["dropV"].as_f64().unwrap_or(0.0) * 1e3, 1e3), "amps": meta["amps"], "worstCell": worst}))+}
src/serve.rs+13−1
@@ -71,6 +71,11 @@ fn handle(mut req: tiny_http::Request, app: Arc<Mutex<App>>) {             let file = app.lock().unwrap().dir.join("flow.bin");             match std::fs::read(&file) { Ok(bytes) => { let _ = req.respond(tiny_http::Response::from_data(bytes).with_header(tiny_http::Header::from_bytes("Content-Type", "application/octet-stream").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); } Err(_) => reply(req, 404, json!({"status": "error", "error": "no flow.bin: this solve predates the flow views", "hint": "Run `adom-fields analyze` again with this version."})) }         }+        ("POST", "/probe") => {+            let q: Value = serde_json::from_str(&body).unwrap_or(Value::Null);+            let dir = app.lock().unwrap().dir.clone();+            match crate::probe::compute(&dir, q["from"].as_str().unwrap_or(""), q["to"].as_str().unwrap_or("")) { Ok(v) => reply(req, 200, v), Err(e) => reply(req, 200, json!({"status": "error", "error": e})) }+        }         ("GET", "/health") => reply(req, 200, json!({"status": "ok", "version": env!("CARGO_PKG_VERSION")})),         ("GET", "/state") => { let a = app.lock().unwrap(); reply(req, 200, json!({"status": "ok", "view": a.view, "board": a.fields["board"], "nets": a.fields["nets"].as_array().map(|x| x.len()).unwrap_or(0), "issues": a.fields["issues"].as_array().map(|x| x.len()).unwrap_or(0), "clients": a.clients.len(), "started": a.started, "_hint": "POST /ui/view {quantity (current|heat|temperature|signals|flow|river|energy|light|cycle|charge|magnetic|emc|ground|noise|pdn), probe mm (the magnetic sheet's height), explode 0..1, speed 0.1..4, net, layer, camera (top|iso|bottom|fit|zoom), air, parts: auto|ghost|solid|off, drift, focus: [x, y] mm for camera zoom}; /ui/select {net|chip}; /ui/issue {index}; /ui/toast {text, kind}; GET /events is the SSE channel; GET /fields.json is everything solved."})); }         ("GET", "/fields.json") => {@@ -104,7 +109,7 @@ fn handle(mut req: tiny_http::Request, app: Arc<Mutex<App>>) {                 let Some(issue) = a.fields["issues"].get(i).cloned() else { drop(a); reply(req, 404, json!({"status": "error", "error": "no such issue", "hint": "GET /fields.json .issues lists them, 0-based"})); return; };                 a.view["issue"] = json!(i); a.view["net"] = issue["net"].clone(); if let Some(l) = issue["layer"].as_str() { a.view["layer"] = json!(l); }                 // each finding opens the view that shows it-                a.view["quantity"] = json!(match issue["kind"].as_str() { Some("current-neck") => "current", Some("hot-loop") | Some("emissions") | Some("no-reference") => "emc", Some("ground-offset") | Some("ground-island") | Some("stitching-gap") | Some("ground-loop-risk") => "ground", Some("cavity-resonance") | Some("crosstalk") => "noise", Some("pdn-impedance") | Some("input-filter-stability") | Some("hot-plug-overshoot") => "pdn", Some("decoupling-far") | Some("decoupling-missing") | Some("esd-unprotected") => "emc", Some("ir-drop") => "river", Some("output-ripple") | Some("inductor-ripple") => "cycle", _ => "heat" });+                a.view["quantity"] = json!(match issue["kind"].as_str() { Some("current-neck") => "current", Some("hot-loop") | Some("emissions") | Some("no-reference") => "emc", Some("pour-fragmented") | Some("ground-offset") | Some("ground-island") | Some("stitching-gap") | Some("ground-loop-risk") => "ground", Some("cavity-resonance") | Some("crosstalk") => "noise", Some("pdn-impedance") | Some("input-filter-stability") | Some("hot-plug-overshoot") => "pdn", Some("decoupling-far") | Some("decoupling-missing") | Some("esd-unprotected") => "emc", Some("ir-drop") => "river", Some("output-ripple") | Some("inductor-ripple") => "cycle", _ => "heat" });                 a.view["camera"] = json!("zoom"); a.view["focus"] = issue["at"].clone();             } else {                 for k in ["quantity", "net", "layer", "camera", "air", "chip", "drift", "parts", "focus", "explode", "speed", "ghost", "silk", "probe"] { if !v[k].is_null() { a.view[k] = v[k].clone(); } }@@ -190,6 +195,13 @@ pub fn ui(what: UiCmd, port: u16) {         UiCmd::Toast { text, kind } => { up("/ui/toast", &json!({"text": text, "kind": kind})); crate::ok("toast shown", &[]); }         UiCmd::Select { net, chip } => { let r = up("/ui/select", &json!({"net": net, "chip": chip})); crate::ok(&format!("selected: {}", r["view"]), &[]); }         UiCmd::Issue { index } => { let r = up("/ui/issue", &json!({"index": index})); crate::ok(&format!("issue {index} in view: {}", r["view"]), &[]); }+        UiCmd::Probe { from, to } => {+            let v = up("/probe", &json!({"from": from, "to": to}));+            if v["status"] == "error" { crate::err(v["error"].as_str().unwrap_or("probe failed"), &[]); }+            let _ = post(port, "/ui/view", &json!({"quantity": "river", "net": v["net"], "focus": v["fromAt"], "camera": "zoom"}));+            let _ = post(port, "/ui/toast", &json!({"text": format!("{}: {} mV from {} to {}", v["net"].as_str().unwrap_or(""), v["dropMv"], v["from"].as_str().unwrap_or(""), v["to"].as_str().unwrap_or("")), "kind": "info"}));+            crate::ok(&format!("{}: {} mV from {} to {} at {} A", v["net"].as_str().unwrap_or(""), v["dropMv"], v["from"].as_str().unwrap_or(""), v["to"].as_str().unwrap_or(""), v["amps"]), &[format!("Shown in the river view on {}; its densest copper is at {} on {}.", v["net"].as_str().unwrap_or(""), v["worstCell"]["at"], v["worstCell"]["layer"].as_str().unwrap_or("?"))]);+        }         UiCmd::State => { let v = get(port, "/state").unwrap_or_else(|e| crate::err(&e, &[])); println!("{}", serde_json::to_string_pretty(&v).unwrap()); }         UiCmd::Console => { let v = get(port, "/console").unwrap_or_else(|e| crate::err(&e, &[])); for l in v["lines"].as_array().cloned().unwrap_or_default() { println!("{}", l.as_str().unwrap_or("")); } }     }