Files Branches

Comparing main ← physical-pad-terminal-selectors

Changes on physical-pad-terminal-selectors that are not yet on main (three-dot, from the merge base).

15 files changed, 737 insertions(+), 90 deletions(-)
README.md+30
@@ -98,3 +98,33 @@ adom-fields analyze --board buck.brd --spec spec.json --out fields --models-dir   Fusion bridge binds it. - Two copper layers plus up to two inner layers, as for KiCad boards. ++## Physical copper stack++The solver and viewer use the board's physical copper layers. Supported stacks are two layers (F.Cu/B.Cu) and four layers (F.Cu/In1.Cu/In2.Cu/B.Cu). KiCad uses the copper layer table; Fusion uses `layerSetup`, or active copper declarations when that parameter is absent. Empty or hidden inner layers must not disappear from a physical stack. Other stacks and blind/buried/micro vias are rejected.++For two layers supply one positive `stackupMm` dielectric interval, for example `[1.53]` with 35 µm copper on both sides for a nominal 1.6 mm board. This is an explicit design assumption, not a fabrication measurement. Four-layer legacy defaults remain 0.2/1.065/0.2 mm and are labeled in `board.stackupSource`. Signal checks require explicit intervals and use actual physical adjacency. Check the summary's layer list, stack source and total thickness before interpreting a field plot.++These are board conduction screens. They do not establish MOSFET junction temperature, heatsink performance, fan airflow, thermal-contact resistance or an industrial power rating.++Every DC net must converge before a summary is published, even when the thermal solve converges. An unconverged net can inject nonsensical Joule heat; inspect its raster connectivity and mesh resolution instead of interpreting that temperature as a prediction.++Plated through-hole pads (including circular and oblong KiCad drills) connect copper layers in both electrical and thermal models. They are counted separately as `platedPads`; routing-via counts retain their original meaning. Barrel conduction assumes 25 µm plating, not a measured fabrication result. Omitting these pad barrels can falsely isolate a connector pad even when native DRC reports full connectivity.++`dcMaxIterations` defaults to 6000 and can be explicitly increased up to 100000 for diagnostic convergence work. This does not change the acceptance tolerance. Reported solver residuals are recomputed from A*x-b, rather than trusting only the internal CG recurrence. Retain failed runs and compare mesh sensitivity; extra iterations cannot repair missing geometry or justify a power rating.++## Explicit pin currents (source candidate)++Each `loads.<net>` may supply `terminals`, an array of `{ "reference": "J1", "pad": "1", "amps": 3.0 }` rows. Positive current enters the board copper; negative current leaves it. Positive and negative totals must each equal that net's `amps`. Include sense-branch current separately instead of assigning the entire load to every narrow track. Pin identifiers must resolve uniquely on the specified net; duplicate, missing, wrong-net and unbalanced assignments are rejected. Pads omitted from this explicit boundary condition draw zero modeled current, so this is an engineering assumption to review against the circuit, including fixture return paths and auxiliary loads. For repeated logical pin numbers, add `"at": [x_mm, y_mm]` using the physical pad center in board coordinates. The selector must match exactly one physical pad within 0.000001 mm, on the stated net. Coordinates must be finite; assigning the same physical pad twice is rejected even if one row omits `at`. Without `at`, ambiguous pin numbers still fail. Declare each physical branch current explicitly and justify its split from the package/circuit; this feature does not infer equal current sharing or an internal package model.++Reports retain the declared pins in `terminalAssignments` and label every net as `explicit` or `heuristic`. Legacy loads without the array retain the existing terminal heuristic. A loaded net that cannot be solved now prevents publishing a partial result. No partial current injection is accepted if any nonzero terminal has no resolved copper.++This change does not replace the current injection's existing 1 mm neighborhood approximation with an exact package contact model. Refine the mesh and review pad geometry and boundary conditions before accepting density values. It also does not model the external MOSFET heatsink or certify thermal performance. This candidate is stacked on physical-stack PR #5; neither change is a deployed shared release yet.++## Current maxima and display clipping (source candidate)++`nets[].peakJ` now reports the unclipped, unrounded modeled cell maximum among cells with at least 25% copper coverage. `peakAt` and `peakLayer` locate that same maximum. The viewer texture still caps its 99.5th percentile for legibility; that display cap must never be used as an acceptance maximum. `peakJDefinition` records these semantics. Earlier reports lack the marker and their reported peaks may be clipped; rerun them before making a maximum-density acceptance claim. The 25% coverage cutoff, finite mesh and terminal injection approximation remain screening limitations, so this is not an exact geometric singularity or physical qualification.++## Plated-wall current and thermal coupling++Read [plated-wall-coupling](docs/plated-wall-coupling.md) before accepting a via-dominated maximum. Drill voids are removed from copper and layer conductance is distributed around the plated wall. Inspect each net's `platedConnections` and `current-barrel` issues as well as the unclipped surface maximum. Do not substitute a passing coarse mesh for refinement, relabel old center-cell reports, or treat this as a package/heatsink or physical qualification model. Nonzero plated-hole offsets are refused.
SKILL.md+30
@@ -90,3 +90,33 @@ on the Fusion bridge). Run RATSNEST in Fusion first so the pours are filled: the Fusion's own fill. Put the parts' STEP files, named by MPN, in a folder and pass `--models-dir` so the chips sit over the fields. ++## Physical copper stack++The solver and viewer use the board's physical copper layers. Supported stacks are two layers (F.Cu/B.Cu) and four layers (F.Cu/In1.Cu/In2.Cu/B.Cu). KiCad uses the copper layer table; Fusion uses `layerSetup`, or active copper declarations when that parameter is absent. Empty or hidden inner layers must not disappear from a physical stack. Other stacks and blind/buried/micro vias are rejected.++For two layers supply one positive `stackupMm` dielectric interval, for example `[1.53]` with 35 µm copper on both sides for a nominal 1.6 mm board. This is an explicit design assumption, not a fabrication measurement. Four-layer legacy defaults remain 0.2/1.065/0.2 mm and are labeled in `board.stackupSource`. Signal checks require explicit intervals and use actual physical adjacency. Check the summary's layer list, stack source and total thickness before interpreting a field plot.++These are board conduction screens. They do not establish MOSFET junction temperature, heatsink performance, fan airflow, thermal-contact resistance or an industrial power rating.++Every DC net must converge before a summary is published, even when the thermal solve converges. An unconverged net can inject nonsensical Joule heat; inspect its raster connectivity and mesh resolution instead of interpreting that temperature as a prediction.++Plated through-hole pads (including circular and oblong KiCad drills) connect copper layers in both electrical and thermal models. They are counted separately as `platedPads`; routing-via counts retain their original meaning. Barrel conduction assumes 25 µm plating, not a measured fabrication result. Omitting these pad barrels can falsely isolate a connector pad even when native DRC reports full connectivity.++`dcMaxIterations` defaults to 6000 and can be explicitly increased up to 100000 for diagnostic convergence work. This does not change the acceptance tolerance. Reported solver residuals are recomputed from A*x-b, rather than trusting only the internal CG recurrence. Retain failed runs and compare mesh sensitivity; extra iterations cannot repair missing geometry or justify a power rating.++## Explicit pin currents (source candidate)++Each `loads.<net>` may supply `terminals`, an array of `{ "reference": "J1", "pad": "1", "amps": 3.0 }` rows. Positive current enters the board copper; negative current leaves it. Positive and negative totals must each equal that net's `amps`. Include sense-branch current separately instead of assigning the entire load to every narrow track. Pin identifiers must resolve uniquely on the specified net; duplicate, missing, wrong-net and unbalanced assignments are rejected. Pads omitted from this explicit boundary condition draw zero modeled current, so this is an engineering assumption to review against the circuit, including fixture return paths and auxiliary loads. For repeated logical pin numbers, add `"at": [x_mm, y_mm]` using the physical pad center in board coordinates. The selector must match exactly one physical pad within 0.000001 mm, on the stated net. Coordinates must be finite; assigning the same physical pad twice is rejected even if one row omits `at`. Without `at`, ambiguous pin numbers still fail. Declare each physical branch current explicitly and justify its split from the package/circuit; this feature does not infer equal current sharing or an internal package model.++Reports retain the declared pins in `terminalAssignments` and label every net as `explicit` or `heuristic`. Legacy loads without the array retain the existing terminal heuristic. A loaded net that cannot be solved now prevents publishing a partial result. No partial current injection is accepted if any nonzero terminal has no resolved copper.++This change does not replace the current injection's existing 1 mm neighborhood approximation with an exact package contact model. Refine the mesh and review pad geometry and boundary conditions before accepting density values. It also does not model the external MOSFET heatsink or certify thermal performance. This candidate is stacked on physical-stack PR #5; neither change is a deployed shared release yet.++## Current maxima and display clipping (source candidate)++`nets[].peakJ` now reports the unclipped, unrounded modeled cell maximum among cells with at least 25% copper coverage. `peakAt` and `peakLayer` locate that same maximum. The viewer texture still caps its 99.5th percentile for legibility; that display cap must never be used as an acceptance maximum. `peakJDefinition` records these semantics. Earlier reports lack the marker and their reported peaks may be clipped; rerun them before making a maximum-density acceptance claim. The 25% coverage cutoff, finite mesh and terminal injection approximation remain screening limitations, so this is not an exact geometric singularity or physical qualification.++## Plated-wall current and thermal coupling++Read [plated-wall-coupling](docs/plated-wall-coupling.md) before accepting a via-dominated maximum. Drill voids are removed from copper and layer conductance is distributed around the plated wall. Inspect each net's `platedConnections` and `current-barrel` issues as well as the unclipped surface maximum. Do not substitute a passing coarse mesh for refinement, relabel old center-cell reports, or treat this as a package/heatsink or physical qualification model. Nonzero plated-hole offsets are refused.
docs/plated-wall-coupling.mdadded+11
@@ -0,0 +1,11 @@+# Plated holes in the field screen++The previous model painted a solid copper disc across each via drill and attached its complete vertical conductance to the center solve cell. That produces a point-transfer singularity: refining the mesh can increase an apparent copper peak inside a physical hole. On the electronic-load v18 diagnostic at 0.1 mm, the complete worst GND and SHUNT_P cells lie inside 0.6 mm drills. Their reported 102.24 and 78.40 A/mm² values cannot describe surface copper in those cells. The drain peak cell intersects a drill boundary and must not be dismissed solely because its center lies in the hole.++This change subtracts the circular/rotated oblong plated drill after drawing tracks, pads and fills. It distributes vertical coupling around the plated wall, using uniform arc-length quadrature at the middle of the assumed 25 µm plating. Total barrel conductance remains conductivity × barrel area / layer distance. The DC projection uses cells with resolved copper on both faces and reports the retained perimeter fraction. A missing resolved wall or out-of-grid loaded connection refuses that net; the CLI's existing completeness check rejects the report. Nonzero drill offsets are explicitly unsupported.++The same perimeter weights distribute thermal barrel coupling and the per-part barrel heat-flow accounting. This does not add a package/case/heatsink model. Existing FR4 and chip heat-source approximations remain; a drilled hole is removed from copper but not meshed as a separate 3D air cavity.++Every net now includes `platedConnections`: position, adjacent modeled layers, signed current, assumed barrel area, mean barrel current density and resolved perimeter fraction. `current-barrel` issues use the same configured screening density threshold. Mean barrel density and the unclipped surface-cell maximum are different quantities and both require review. `barrelCoupling` identifies the model; old reports must be re-solved, not stamped with new metadata.++Tests check drill subtraction despite overlapping copper, rotated slots, conserved distributed conductance, terminal-current conservation through a lone plated connection, and a two-mesh fixture. Its surface maxima were 12.19 and 14.84 A/mm² at 0.2 and 0.1 mm; this is a bounded regression check, not universal mesh convergence. For real boards inspect finer meshes, individual connections, copper coverage, material assumptions and boundary currents. The model remains a screening approximation, not manufacturing or thermal qualification.
skills/adom-fields/SKILL.md+10
@@ -82,3 +82,13 @@ Each solve records SHA-256 identities of the board and spec in `fields.json.inpu Read `docs/heatsinks-and-signals.md` in the package before specifying these models. Use measured conductivity/contact resistance for `heatsinks`; contact is to the board face, not an automatically modeled chip lid. Never add a fictitious heatsink to make a gate pass. Inspect both air conditions and mesh sensitivity.  For signal screening, provide `signals.capacitance` or `signals.differentialPairs`, explicit consistent `stackupMm`, and justified epsilonR. Inspect Signals with `adom-fields ui --port 8874 view --quantity signals`. Unsupported geometry must stay unsupported; do not replace null estimates with guessed numbers. This is outer-layer microstrip and adjacent-layer overlap screening, not high-frequency signoff. The summary records assumptions and primary references.++## Source-candidate current checks++Before accepting a current screen, inspect `inputIdentity`, explicit signed `loads.<net>.terminals`, convergence for every loaded net, and `peakJDefinition`. The maximum must be the unclipped, unrounded cell maximum; the texture percentile is display-only. Use actual copper layers and an explicit dielectric stack. Missing or stale report metadata requires a fresh solve, not manual relabeling.++The plated-wall model removes plated drill voids from copper and distributes coupling around the wall. Inspect `barrelCoupling`, each net's `platedConnections` (signed amps, mean barrel density and resolved perimeter fraction), and `current-barrel` issues as well as surface maxima. Refine the mesh near holes; a cell center within a hole may still represent a partly copper-covered cell, but a wholly copper-free cell must not carry surface current. Nonzero drill offsets are unsupported. These remain screening approximations, not a package/heatsink model or physical qualification. See the package's `docs/plated-wall-coupling.md`.++## Physical pad selectors for repeated pin numbers++Explicit `loads.<net>.terminals` rows accept optional `"at": [x_mm, y_mm]` in board coordinates. Use it when a package has multiple physical pads with the same reference and logical pin number, such as two anode lands. The center must uniquely match within 0.000001 mm on the stated net; missing, ambiguous, nonfinite, wrong-net or duplicate physical assignments fail. Without `at`, the existing unique-pin behavior is unchanged. Positive and negative current totals must each equal the net load. State and justify the branch split; selecting physical pads does not infer equal sharing or model internal package resistance. Recheck coordinates after any component movement.
src/barrel.rsadded+90
@@ -0,0 +1,90 @@+//! Plated-hole geometry and distributed layer coupling. No conducting disc in the drill.+use crate::board::Via;+use std::collections::BTreeMap;+const PLATING_MM: f64 = 0.025;++pub fn transform(v: &Via, x: f64, y: f64) -> (f64, f64) {+    let (s, c) = v.drill_angle.to_radians().sin_cos();+    (v.x + x * c + y * s, v.y - x * s + y * c)+}+pub fn axis(v: &Via) -> ((f64, f64), (f64, f64)) {+    let a = (v.drill_long.unwrap_or(v.drill) - v.drill) / 2.0;+    (transform(v, -a, 0.0), transform(v, a, 0.0))+}+/// Uniform arc-length quadrature at the middle of the plated wall. The weights+/// sum to one; multiplying by sigma * barrel area / length preserves conductance.+pub fn weights(v: &Via, x0: f64, y0: f64, pitch: f64, w: usize, h: usize) -> Vec<(usize, f64)> {+    let a = (v.drill_long.unwrap_or(v.drill) - v.drill) / 2.0;+    let r = v.drill / 2.0 + PLATING_MM / 2.0;+    let arc = std::f64::consts::PI * r;+    let perimeter = 4.0 * a + 2.0 * arc;+    let samples = ((perimeter / pitch * 32.0).ceil() as usize).max(128);+    let mut bins = BTreeMap::<usize, usize>::new();+    for j in 0..samples {+        let t = (j as f64 + 0.5) * perimeter / samples as f64;+        let (x, y) = if t < 2.0 * a {+            (-a + t, -r)+        } else if t < 2.0 * a + arc {+            let q = (t - 2.0 * a) / r - std::f64::consts::FRAC_PI_2;+            (a + r * q.cos(), r * q.sin())+        } else if t < 4.0 * a + arc {+            (a - (t - 2.0 * a - arc), r)+        } else {+            let q = (t - 4.0 * a - arc) / r + std::f64::consts::FRAC_PI_2;+            (-a + r * q.cos(), r * q.sin())+        };+        let (x, y) = transform(v, x, y);+        let c = ((x - x0) / pitch).floor() as isize;+        let row = ((y - y0) / pitch).floor() as isize;+        if c >= 0 && row >= 0 && c < (w as isize) && row < (h as isize) {+            *bins.entry(row as usize * w + c as usize).or_default() += 1;+        }+    }+    bins.into_iter()+        .map(|(i, n)| (i, n as f64 / samples as f64))+        .collect()+}+#[cfg(test)]+mod tests {+    use super::*;+    fn via() -> Via {+        Via {+            net: "N".into(),+            x: 3.0,+            y: 3.0,+            size: 1.2,+            drill: 0.6,+            drill_long: None,+            drill_angle: 0.0,+        }+    }+    #[test]+    fn coupling_is_conserved_and_avoids_center_at_both_meshes() {+        for pitch in [0.2, 0.1, 0.05] {+            let n = (6.0 / pitch) as usize;+            let ws = weights(&via(), 0.0, 0.0, pitch, n, n);+            assert!((ws.iter().map(|x| x.1).sum::<f64>() - 1.0).abs() < 1e-12);+            let center = (3.0 / pitch) as usize * n + (3.0 / pitch) as usize;+            assert!(!ws.iter().any(|(i, _)| *i == center));+            assert!(ws.len() > 4);+        }+    }+    #[test]+    fn rotated_slot_preserves_conductance_and_changes_axis() {+        let mut v = via();+        v.drill_long = Some(1.6);+        v.drill_angle = 90.0;+        let (a, b) = axis(&v);+        assert!((a.0 - b.0).abs() < 1e-12);+        assert!((a.1 - b.1).abs() > 0.99);+        assert!(+            (weights(&v, 0.0, 0.0, 0.1, 60, 60)+                .iter()+                .map(|x| x.1)+                .sum::<f64>()+                - 1.0)+                .abs()+                < 1e-12+        );+    }+}
src/board.rs+84−4
@@ -8,7 +8,7 @@ pub struct Poly { pub net: String, pub layer: String, pub pts: Vec<(f64, f64)> } #[derive(Debug, Clone)] pub struct Track { pub net: String, pub layer: String, pub a: (f64, f64), pub b: (f64, f64), pub w: f64 } #[derive(Debug, Clone)]-pub struct Via { pub net: String, pub x: f64, pub y: f64, pub size: f64, pub drill: f64 }+pub struct Via { pub net: String, pub x: f64, pub y: f64, pub size: f64, pub drill: f64, pub drill_long: Option<f64>, pub drill_angle: f64 } #[derive(Debug, Clone)] pub struct Pad { pub reference: String, pub pad: String, pub net: String, pub x: f64, pub y: f64, pub layers: Vec<String>, pub area: f64 } @@ -29,6 +29,8 @@ pub struct Board {     pub pad_polys: Vec<Poly>,     pub arc_nets: Vec<String>,     pub layer_names: Vec<String>,+    /// Plated through-hole pad barrels, separate from routing vias.+    pub plated_holes: Vec<Via>,     /// pours to fill on the solve grid (Fusion/EAGLE boards; empty for KiCad, whose fill is in the file)     pub pours: Vec<Pour>,     /// pours whose fill the file DOES carry as outline-with-holes islands (Fusion's polygonfilldetails)@@ -45,7 +47,8 @@ impl Board {         if crate::eagle::is_eagle(&text) { let mut b = crate::eagle::load(&text)?; b.source = "fusion".into(); return Ok(b); }         let root = parse(&text);         let mut b = Board::default(); b.source = "kicad".into();-        b.layer_names = root.kid("layers").map(|l| l.list().iter().skip(1).filter_map(|x| x.atom(1).map(str::to_string)).collect()).unwrap_or_default();+        b.layer_names = root.kid("layers").map(|l| l.list().iter().skip(1).filter_map(|x| x.atom(1).filter(|n| n.ends_with(".Cu")).map(str::to_string)).collect()).unwrap_or_default();+        b.validate_layers()?;         let net_names: std::collections::BTreeMap<String,String> = root.kids("net").iter().filter_map(|n|Some((n.atom(1)?.to_string(),n.atom(2)?.to_string()))).collect();         let net_name = |n: &Sx| { let value=n.last_atom().unwrap_or(""); net_names.get(value).cloned().unwrap_or_else(||value.to_string()) };         // outline: the Edge.Cuts graphics' bounding box@@ -74,8 +77,16 @@ impl Board {             b.tracks.push(Track { net: s.kid("net").map(&net_name).unwrap_or_default(), layer: s.kid("layer").and_then(|l| l.atom(1)).unwrap_or("").to_string(), a: (st.num(1).unwrap_or(0.0), st.num(2).unwrap_or(0.0)), b: (en.num(1).unwrap_or(0.0), en.num(2).unwrap_or(0.0)), w: s.kid("width").and_then(|w| w.num(1)).unwrap_or(0.2) });         }         for v in root.kids("via") {+            if v.atom(1).is_some_and(|kind| kind == "blind" || kind == "micro") {+                return Err("blind/buried/micro vias are not supported by the through-via field model".into());+            }+            if let Some(span) = v.kid("layers") {+                if span.atom(1) != Some("F.Cu") || span.atom(2) != Some("B.Cu") {+                    return Err("only through vias spanning F.Cu to B.Cu are supported".into());+                }+            }             let Some(at) = v.kid("at") else { continue };-            b.vias.push(Via { net: v.kid("net").map(&net_name).unwrap_or_default(), x: at.num(1).unwrap_or(0.0), y: at.num(2).unwrap_or(0.0), size: v.kid("size").and_then(|s| s.num(1)).unwrap_or(0.6), drill: v.kid("drill").and_then(|d| d.num(1)).unwrap_or(0.3) });+            b.vias.push(Via { net: v.kid("net").map(&net_name).unwrap_or_default(), x: at.num(1).unwrap_or(0.0), y: at.num(2).unwrap_or(0.0), size: v.kid("size").and_then(|s| s.num(1)).unwrap_or(0.6), drill: v.kid("drill").and_then(|d| d.num(1)).unwrap_or(0.3), drill_long: None, drill_angle: 0.0 });         }         for fp in root.kids("footprint") {             let reference = fp.kids("property").iter().find(|p| p.atom(1) == Some("Reference")).and_then(|p| p.atom(2)).unwrap_or("?").to_string();@@ -92,8 +103,20 @@ impl Board {                 let (sw, sh) = (p.kid("size").and_then(|s| s.num(1)).unwrap_or(1.0), p.kid("size").and_then(|s| s.num(2)).unwrap_or(1.0));                 let kind = p.atom(2).unwrap_or("smd");                 let shape = p.atom(3).unwrap_or("rect");+                if kind == "thru_hole" {+                    let drill_node = p.kid("drill").ok_or("plated pad is missing its drill")?;+                    let (drill, drill_long) = if drill_node.atom(1) == Some("oval") {+                        let a = drill_node.num(2).ok_or("slot requires two drill dimensions")?;+                        let b = drill_node.num(3).ok_or("slot requires two drill dimensions")?;+                        (a.min(b), Some(a.max(b)))+                    } else { (drill_node.num(1).ok_or("plated pad requires drill dimensions")?, None) };+                    if drill <= 0.0 { return Err("plated pad drill must be positive".into()); }+                    let drill_angle = pa + if drill_node.atom(1) == Some("oval") && drill_node.num(3) > drill_node.num(2) { 90.0 } else { 0.0 };+                    if let Some(offset)=drill_node.kid("offset") { if offset.num(1).unwrap_or(0.0)!=0.0 || offset.num(2).unwrap_or(0.0)!=0.0 { return Err("offset plated holes are not supported by the annular field model".into()); } }+                    b.plated_holes.push(Via { net: net.clone(), x, y, size: sw.min(sh), drill, drill_long, drill_angle });+                }                 let lay: Vec<String> = p.kid("layers").map(|l| l.list().iter().skip(1).filter_map(|x| if let Sx::Atom(a) = x { Some(a.clone()) } else { None }).collect()).unwrap_or_default();-                let on: Vec<String> = if kind == "thru_hole" { LAYERS.iter().map(|s| s.to_string()).collect() } else { LAYERS.iter().filter(|l| lay.iter().any(|x| x == *l || x == "*.Cu")).map(|s| s.to_string()).collect() };+                let on: Vec<String> = if kind == "thru_hole" { b.layer_names.clone() } else { b.layer_names.iter().filter(|l| lay.iter().any(|x| x == *l || x == "*.Cu")).cloned().collect() };                 let mut area = sw * sh;                 for l in &on {                     let mut shapes: Vec<Vec<(f64, f64)>> = Vec::new();@@ -120,6 +143,13 @@ impl Board {         }         Ok(b)     }+    pub fn validate_layers(&self) -> Result<(), String> {+        let layers: Vec<&str> = self.layer_names.iter().map(String::as_str).collect();+        if layers != ["F.Cu", "B.Cu"] && layers != LAYERS {+            return Err(format!("supported copper stacks are F.Cu/B.Cu or F.Cu/In1.Cu/In2.Cu/B.Cu; found {:?}", layers));+        }+        Ok(())+    }     pub fn width(&self) -> f64 { self.x1 - self.x0 }     pub fn height(&self) -> f64 { self.y1 - self.y0 } }@@ -134,3 +164,53 @@ fn stadium_poly(x: f64, y: f64, w: f64, h: f64, deg: f64) -> Vec<(f64, f64)> {     let ang = if w >= h { deg } else { deg + 90.0 };     pts.into_iter().map(|(px, py)| { let (qx, qy) = rot(px, -py, ang); (x + qx, y - qy) }).collect() }++#[cfg(test)]+mod stack_tests {+    use super::*;+    #[test]+    fn two_layer_plated_pad_and_via_have_no_phantom_inner_copper() {+        let path = std::env::temp_dir().join(format!("fields-two-layer-{}.kicad_pcb", std::process::id()));+        std::fs::write(&path, r#"(kicad_pcb (layers (0 "F.Cu" signal) (31 "B.Cu" signal) (44 "Edge.Cuts" user))+          (net 1 "GND") (gr_rect (start 0 0) (end 10 10) (layer "Edge.Cuts"))+          (via (at 5 5) (size 1) (drill 0.3) (layers "F.Cu" "B.Cu") (net 1))+          (footprint "test" (property "Reference" "J1") (at 3 3)+            (pad "1" thru_hole circle (at 0 0) (size 2 2) (drill 1) (layers "*.Cu" "*.Mask") (net 1 "GND"))))"#).unwrap();+        let b = Board::load(path.to_str().unwrap()).unwrap();+        std::fs::remove_file(path).unwrap();+        assert_eq!(b.layer_names, ["F.Cu", "B.Cu"]);+        assert_eq!(b.pads[0].layers, b.layer_names);+        assert_eq!(b.plated_holes.len(), 1);+        assert_eq!(b.plated_holes[0].drill, 1.0);+        let mut raster = crate::raster::Raster::new(&b, 0.1);+        raster.draw_board(&b);+        assert_eq!(raster.masks.keys().map(|(_, l)| l).collect::<std::collections::BTreeSet<_>>().len(), 2);+        assert!(raster.masks.keys().all(|(_, l)| l == "F.Cu" || l == "B.Cu"));+    }+    #[test]+    fn unsupported_stack_is_rejected() {+        let mut b = Board::default();+        assert!(b.validate_layers().is_err());+        b.layer_names = vec!["F.Cu".into(), "In1.Cu".into(), "B.Cu".into()];+        assert!(b.validate_layers().is_err());+    }+}++impl Via {+    /// Circular or oblong plated barrel, assuming 25 µm plating as in the existing via model.+    pub fn barrel_area_mm2(&self) -> f64 {+        let plating = 0.025;+        let perimeter = std::f64::consts::PI * self.drill + 2.0 * (self.drill_long.unwrap_or(self.drill) - self.drill);+        perimeter * plating + std::f64::consts::PI * plating * plating+    }+}++#[cfg(test)] mod barrel_tests {+    use super::*;+    #[test] fn circular_and_slotted_plating_cross_sections() {+        let round=Via{net:"GND".into(),x:0.,y:0.,size:2.,drill:1.,drill_long:None,drill_angle:0.0};+        assert!((round.barrel_area_mm2()-0.08050331175).abs()<1e-9);+        let slot=Via{drill:0.6,drill_long:Some(1.7),..round};+        assert!((slot.barrel_area_mm2()-0.10408738521).abs()<1e-9);+    }+}
src/eagle.rs+38−9
@@ -38,16 +38,22 @@ pub fn load(text: &str) -> Result<Board, String> {     let doc = roxmltree::Document::parse_with_options(text, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).map_err(|e| format!("not a readable EAGLE board: {e}"))?;     let board = doc.descendants().find(|n| n.has_tag_name("board")).ok_or("no <board> in the file: export the BOARD (.brd) from Fusion, not the schematic")?;     let mut b = Board::default();-    // used inner copper layers, in stack order, become In1.Cu / In2.Cu-    let mut inner: Vec<String> = Vec::new();-    for l in doc.descendants().filter(|n| n.has_tag_name("layer")) {-        let num = l.attribute("number").unwrap_or("");-        if let Ok(k) = num.parse::<u32>() { if (2..=15).contains(&k) && l.attribute("active") != Some("no") && l.attribute("visible") != Some("no") { inner.push(num.to_string()); } }+    // Physical stack order must not depend on whether a layer is visible or contains copper.+    let declared = board.descendants().find(|n| n.has_tag_name("param") && n.attribute("name") == Some("layerSetup")).and_then(|n| n.attribute("value"));+    let mut numbers: Vec<u32> = if let Some(setup) = declared {+        setup.split(|c: char| !c.is_ascii_digit()).filter(|s| !s.is_empty()).map(|s| s.parse::<u32>().map_err(|_| "invalid layerSetup".to_string())).collect::<Result<_, _>>()?+    } else {+        doc.descendants().filter(|n| n.has_tag_name("layer") && n.attribute("active") != Some("no"))+            .filter_map(|n| n.attribute("number").and_then(|v| v.parse::<u32>().ok())).filter(|n| (1..=16).contains(n)).collect()+    };+    numbers.sort_unstable();+    numbers.dedup();+    if !matches!(numbers.len(), 2 | 4) || numbers.first() != Some(&1) || numbers.last() != Some(&16) {+        return Err(format!("Fusion copper stack must declare two or four layers including 1 and 16; found {:?}", numbers));     }-    // only inner layers that actually carry copper count-    let used: std::collections::BTreeSet<String> = board.descendants().filter_map(|n| n.attribute("layer").map(str::to_string)).collect();-    inner.retain(|l| used.contains(l)); inner.truncate(2);+    let inner: Vec<String> = numbers.iter().copied().filter(|n| *n != 1 && *n != 16).map(|n| n.to_string()).collect();     b.layer_names = std::iter::once("F.Cu".to_string()).chain((0..inner.len()).map(|i| LAYERS[1 + i].to_string())).chain(std::iter::once("B.Cu".to_string())).collect();+    b.validate_layers()?;     let y = |v: f64| -v;      // outline: the Dimension layer (20) of the plain section@@ -118,7 +124,9 @@ pub fn load(text: &str) -> Result<Board, String> {                 b.pads.push(Pad { reference: reference.clone(), pad: name, net, x: cx, y: cy, layers: vec![layer.into()], area: dx * dy });             } else {                 let drill = f(&p, "drill");+                if drill <= 0.0 { return Err("plated pad drill must be positive".into()); }                 let dia = f(&p, "diameter").max(drill + 2.0 * ring(drill, pad_rv, pad_min, pad_max));+                b.plated_holes.push(Via { net: net.clone(), x: cx, y: cy, size: dia, drill, drill_long: None, drill_angle: 0.0 });                 let ring = |l: &str| Poly { net: net.clone(), layer: l.into(), pts: (0..24).map(|k| { let a = k as f64 / 24.0 * std::f64::consts::TAU; (cx + dia / 2.0 * a.cos(), cy + dia / 2.0 * a.sin()) }).collect() };                 let on: Vec<String> = b.layer_names.clone();                 for l in &on { b.pad_polys.push(ring(l)); }@@ -133,9 +141,10 @@ pub fn load(text: &str) -> Result<Board, String> {             b.tracks.push(Track { net: net.clone(), layer, a: (f(&w, "x1"), y(f(&w, "y1"))), b: (f(&w, "x2"), y(f(&w, "y2"))), w: f(&w, "width").max(0.05) });         }         for v in s.children().filter(|n| n.has_tag_name("via")) {+            if v.attribute("extent") != Some("1-16") { return Err("only Fusion through vias with extent 1-16 are supported".into()); }             let drill = f(&v, "drill");             let size = f(&v, "diameter").max(drill + 2.0 * ring(drill, via_rv, via_min, via_max));-            b.vias.push(Via { net: net.clone(), x: f(&v, "x"), y: y(f(&v, "y")), size, drill });+            b.vias.push(Via { net: net.clone(), x: f(&v, "x"), y: y(f(&v, "y")), size, drill, drill_long: None, drill_angle: 0.0 });         }         // Fusion's own pours: <polygonpour> with the outline the user drew and, once Fusion has         // filled it (RATSNEST), <polygonfilldetails>: each island's outline and its holes. Use the@@ -216,6 +225,26 @@ pub fn elements(text: &str) -> Result<Vec<EagleElement>, String> {         let parts = super::elements(BRD).unwrap();         assert_eq!(parts[0].names[0], "RC0402"); assert!(parts[1].bottom);     }+    #[test] fn fusion_plated_pads_keep_barrels_separate_from_vias() {+        let text = BRD.replace("</package>", "<pad name=\"3\" x=\"0\" y=\"1\" drill=\"0.8\" diameter=\"1.6\"/></package>")+            .replace("<signal name=\"A\">", "<signal name=\"A\"><contactref element=\"R1\" pad=\"3\"/>");+        let b = super::load(&text).unwrap();+        assert_eq!(b.plated_holes.len(), 1);+        assert_eq!(b.plated_holes[0].net, "A");+        assert_eq!(b.plated_holes[0].drill, 0.8);+        assert_eq!(b.vias.len(), 1);+    }+    #[test] fn declared_empty_hidden_inner_layers_are_preserved() {+        let text = BRD.replace("<board>", "<board><designrules><param name=\"layerSetup\" value=\"(1*2*15*16)\"/></designrules>");+        let b = super::load(&text).unwrap();+        assert_eq!(b.layer_names, ["F.Cu", "In1.Cu", "In2.Cu", "B.Cu"]);+        let hidden = BRD.replace("</layers>", "<layer number=\"2\" active=\"yes\" visible=\"no\"/><layer number=\"15\" active=\"yes\" visible=\"no\"/></layers>");+        assert_eq!(super::load(&hidden).unwrap().layer_names, b.layer_names);+    }+    #[test] fn blind_vias_and_six_layers_are_rejected() {+        assert!(super::load(&BRD.replace("extent=\"1-16\"", "extent=\"1-2\"")).is_err());+        assert!(super::load(&BRD.replace("<board>", "<board><designrules><param name=\"layerSetup\" value=\"(1*2*3*14*15*16)\"/></designrules>")).is_err());+    }     #[test] fn unfilled_pour_falls_back_to_a_computed_fill() {         let t = BRD.replace(|_: char| false, "");         let i = t.find("<polygonfilldetails>").unwrap(); let j = t.find("</polygonfilldetails>").unwrap() + "</polygonfilldetails>".len();
src/fields.rs+263−57
@@ -1,5 +1,5 @@ //! The fields: current density per loaded net, heat flow in the copper, temperature over the board.-use crate::board::{Board, LAYERS};+use crate::board::Board; use crate::raster::{Grid, Raster}; use crate::solve::{harm, System}; use serde::{Deserialize, Serialize};@@ -25,16 +25,18 @@ pub struct Spec {     #[serde(default)] pub signals: crate::signals::Config,     #[serde(default)] pub heatsinks: Vec<crate::heatsink::Block>,     #[serde(default)] pub stackupMm: Vec<f64>,+    #[serde(default = "d6000")] pub dcMaxIterations: usize, }+fn d6000() -> usize { 6000 } fn d35() -> f64 { 35.0 } fn d60() -> f64 { 60.0 }-#[derive(Deserialize, Clone)] pub struct Load { pub amps: f64, #[serde(default)] pub maxRiseC: Option<f64> }+#[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 Hot { pub watts: f64, pub tabNet: String }  #[derive(Serialize, Clone)]-pub struct NetResult { pub net: String, pub amps: f64, pub layers: Vec<String>, pub peakJ: f64, pub peakAt: [f64; 2], pub peakLayer: String, pub copperMm2: f64, pub jouleW: f64, pub vias: usize, pub iterations: usize }+pub struct NetResult { pub net: String, pub amps: f64, pub layers: Vec<String>, pub peakJ: f64, pub peakAt: [f64; 2], pub peakLayer: String, pub copperMm2: f64, pub jouleW: f64, pub vias: usize, pub iterations: usize, pub residual: f64, pub platedPads: usize, pub platedConnections: Vec<Value> } #[derive(Serialize, Clone)]-pub struct ChipResult { pub reference: String, pub watts: f64, pub tabNet: String, pub x: f64, pub y: f64, pub riseC: f64, pub riseMovingC: f64, pub planeW: f64, pub viaW: f64, pub vias: usize, pub otherViaW: f64, pub otherVias: usize, pub dielW: f64, pub airW: f64, pub viasWanted: usize }+pub struct ChipResult { pub reference: String, pub watts: f64, pub tabNet: String, pub x: f64, pub y: f64, pub riseC: f64, pub riseMovingC: f64, pub planeW: f64, pub viaW: f64, pub vias: usize, pub otherViaW: f64, pub otherVias: usize, pub platedPadW: f64, pub platedPads: usize, pub dielW: f64, pub airW: f64, pub viasWanted: usize } #[derive(Serialize, Clone)] pub struct Issue { pub severity: String, pub kind: String, pub what: String, pub at: Option<[f64; 2]>, pub layer: Option<String>, pub net: Option<String>, pub reference: Option<String>, pub fix: String } @@ -55,26 +57,28 @@ pub struct Fields { }  pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMut(&str)) -> Fields {+    let layers: Vec<&str> = b.layer_names.iter().map(String::as_str).collect();     let fine = pitch / 2.0;     let mut ras = Raster::new(b, fine);     ras.draw_board(b);     let pool = 2;     let (w, h) = (ras.w / pool, ras.h / pool);     let d = pitch;-    let t_cu: Vec<f64> = LAYERS.iter().map(|l|spec.copperUmByLayer.get(*l).copied().unwrap_or(spec.copperUm)/1000.0).collect();+    let t_cu: Vec<f64> = layers.iter().map(|l|spec.copperUmByLayer.get(*l).copied().unwrap_or(spec.copperUm)/1000.0).collect();+    let t_diel = dielectric_mm(spec, layers.len()).expect("validated stackup");     let n = w * h;     let cell = |x: f64, y: f64| -> Option<(usize, usize)> { let (c, r) = (((x - b.x0) / d) as isize, ((y - b.y0) / d) as isize); if r >= 0 && c >= 0 && (r as usize) < h && (c as usize) < w { Some((r as usize, c as usize)) } else { None } };     // all copper per layer-    let mut frac: Vec<Grid> = LAYERS.iter().map(|_| Grid::new(w, h)).collect();-    for (k, l) in LAYERS.iter().enumerate() { for net in ras.nets_on(l) { let g = ras.fraction(&net, l, pool); frac[k].max_with(&g); } }+    let mut frac: Vec<Grid> = layers.iter().map(|_| Grid::new(w, h)).collect();+    for (k, l) in layers.iter().enumerate() { for net in ras.nets_on(l) { let g = ras.fraction(&net, l, pool); frac[k].max_with(&g); } }     // ---- current, one DC solve per loaded net --------------------------------------------------     let mut j_net: BTreeMap<String, Vec<Grid>> = BTreeMap::new();-    let mut j_all: Vec<Grid> = LAYERS.iter().map(|_| Grid::new(w, h)).collect();-    let mut p_joule:Vec<Grid> = LAYERS.iter().map(|_|Grid::new(w,h)).collect();+    let mut j_all: Vec<Grid> = layers.iter().map(|_| Grid::new(w, h)).collect();+    let mut p_joule:Vec<Grid> = layers.iter().map(|_|Grid::new(w,h)).collect();     let mut nets: Vec<NetResult> = Vec::new();-    for (net, load) in &spec.loads {-        let fr: Vec<Grid> = LAYERS.iter().map(|l| ras.fraction(net, l, pool)).collect();-        let live: Vec<usize> = (0..LAYERS.len()).filter(|k| fr[*k].sum() > 0.0).collect();+    'loaded_nets: for (net, load) in &spec.loads {+        let fr: Vec<Grid> = layers.iter().map(|l| ras.fraction(net, l, pool)).collect();+        let live: Vec<usize> = (0..layers.len()).filter(|k| fr[*k].sum() > 0.0).collect();         if live.is_empty() { log(&format!("{net}: no copper on the board, skipped")); continue; }         let nl = live.len();         let mut sys = System::new(nl, h, w);@@ -84,12 +88,20 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu             for r in 0..h - 1 { for c in 0..w { sys.gy[a][r * w + c] = SIGMA * t_cu[*k] * harm(f.at(r, c) as f64, f.at(r + 1, c) as f64); } }             for i in 0..n { sys.leak[a][i] = if f.v[i] > 0.0 { 1e-9 } else { 1.0 }; }         }-        let mut nvias = 0;-        for v in b.vias.iter().filter(|v| v.net == *net) {-            let Some((r, c)) = cell(v.x, v.y) else { continue };-            nvias += 1;-            let g = SIGMA * std::f64::consts::PI * ((v.drill / 2.0 + 0.025).powi(2) - (v.drill / 2.0).powi(2)) / 1.6;-            for a in 0..nl.saturating_sub(1) { sys.gz[a][r * w + c] += g; }+        let nvias = b.vias.iter().filter(|v| v.net == *net).count();+        let plated_pads = b.plated_holes.iter().filter(|v| v.net == *net).count();+        for v in b.vias.iter().chain(&b.plated_holes).filter(|v| v.net == *net) {+            let weights=crate::barrel::weights(v,b.x0,b.y0,d,w,h);+            if (weights.iter().map(|(_,q)|q).sum::<f64>()-1.0).abs()>1e-9 {log(&format!("{net}: plated wall crosses solver bounds"));continue 'loaded_nets;}+            let sigma_area = SIGMA * v.barrel_area_mm2();+            for a in 0..nl.saturating_sub(1) {+                let distance: f64 = (live[a]..live[a+1]).map(|k| t_diel[k] + (t_cu[k] + t_cu[k+1])/2.0).sum();+                // Project wall quadrature only onto cells with resolved copper on+                // both faces; retain total barrel conductance after projection.+                let norm:f64=weights.iter().filter(|(i,_)|fr[live[a]].v[*i]>0.0 && fr[live[a+1]].v[*i]>0.0).map(|(_,q)|q).sum();+                if norm<=0.0 {log(&format!("{net}: plated wall has no resolved copper at {},{}",v.x,v.y));continue 'loaded_nets;}+                for (i,q) in &weights {if fr[live[a]].v[*i]>0.0 && fr[live[a+1]].v[*i]>0.0 {sys.gz[a][*i]+=sigma_area/distance*q/norm;}}+            }         }         // terminals: the external pads (wire pads, connectors) on one side, the chips that draw the         // current on the other, each chip's share by its pad area on the net (a gate driver's sense pin@@ -116,7 +128,7 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu                 chips = if rest.is_empty() { cands.iter().cloned().filter(|p| p.reference != big).collect() } else { rest };             }         }-        if ext.is_empty() || chips.is_empty() { log(&format!("{net}: no terminals found (needs pads of two parts on the net), skipped")); continue; }+        if load.terminals.is_none() && (ext.is_empty() || chips.is_empty()) { log(&format!("{net}: no terminals found (needs pads of two parts on the net), skipped")); continue; }         let mut area_by_ref: BTreeMap<String, f64> = BTreeMap::new();         for p in &chips { *area_by_ref.entry(p.reference.clone()).or_default() += p.area.max(0.05); }         let area_total: f64 = area_by_ref.values().sum();@@ -126,7 +138,7 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu             let rad = (1.0 / d) as isize;             let mut spots: Vec<(usize, usize, usize)> = Vec::new();             for (a, k) in live.iter().enumerate() {-                if !p.layers.iter().any(|l| l == LAYERS[*k]) { continue; }+                if !p.layers.iter().any(|l| l == layers[*k]) { continue; }                 for dr in -rad..=rad { for dc in -rad..=rad {                     let (r, c) = (r0 as isize + dr, c0 as isize + dc);                     if r < 0 || c < 0 || r as usize >= h || c as usize >= w || dr * dr + dc * dc > rad * rad { continue; }@@ -136,19 +148,45 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu             spots         };         let (mut hs, mut hk) = (0usize, 0usize);-        let per_ext = load.amps / ext.len() as f64;-        for p in &ext { let sp = spots_of(p); if sp.is_empty() { continue; } hs += 1; for (a, r, c) in &sp { rhs[a * n + r * w + c] += per_ext / sp.len() as f64; } }+        let assignments: Vec<(&crate::board::Pad, f64)> = if let Some(rows) = &load.terminals {+            match crate::terminals::resolve(b, net, load.amps, rows) {+                Ok(rows) => rows,+                Err(error) => { log(&error); continue; }+            }+        } else {+            let mut rows: Vec<_> = ext.iter().map(|p| (*p, load.amps / ext.len() as f64)).collect();+            for p in &chips {+                let share = load.amps * area_by_ref[&p.reference] / area_total / chips.iter().filter(|q| q.reference == p.reference).count() as f64;+                rows.push((*p, -share));+            }+            rows+        };         let mut pinned = false;-        for p in &chips {-            let share = load.amps * area_by_ref[&p.reference] / area_total / chips.iter().filter(|q| q.reference == p.reference).count() as f64;-            let sp = spots_of(p); if sp.is_empty() { continue; } hk += 1;-            for (a, r, c) in &sp { rhs[a * n + r * w + c] -= share / sp.len() as f64; }-            if !pinned { let (a, r, c) = sp[0]; sys.leak[a][r * w + c] = 1e3; pinned = true; }+        let mut missing = false;+        for (p, amps) in &assignments {+            if *amps == 0.0 { continue; }+            let sp = spots_of(p);+            if sp.is_empty() { missing = true; break; }+            if *amps > 0.0 { hs += 1; } else { hk += 1; }+            for (a, r, c) in &sp { rhs[a * n + r * w + c] += amps / sp.len() as f64; }+            if *amps < 0.0 && !pinned { let (a, r, c) = sp[0]; sys.leak[a][r * w + c] = 1e3; pinned = true; }         }+        if missing { log(&format!("{net}: a terminal has no resolved copper; refusing partial current injection")); continue; }         if hs == 0 || hk == 0 { log(&format!("{net}: terminals not on copper (sources {hs}, sinks {hk}), skipped")); continue; }-        let (v, it, res) = sys.solve(&rhs, 1e-7, 6000);+        let (v, it, res) = sys.solve(&rhs, 1e-7, spec.dcMaxIterations);         log(&format!("{net}: {} A, {} layer(s), {it} iterations, residual {res:.1e}", load.amps, nl));-        let mut grids: Vec<Grid> = LAYERS.iter().map(|_| Grid::new(w, h)).collect();+        let mut connections=Vec::new();+        for hole in b.vias.iter().chain(&b.plated_holes).filter(|q|q.net==*net) {+            let weights=crate::barrel::weights(hole,b.x0,b.y0,d,w,h);+            for a in 0..nl.saturating_sub(1) {+                let distance:f64=(live[a]..live[a+1]).map(|k|t_diel[k]+(t_cu[k]+t_cu[k+1])/2.0).sum();+                let norm:f64=weights.iter().filter(|(i,_)|fr[live[a]].v[*i]>0.0 && fr[live[a+1]].v[*i]>0.0).map(|(_,q)|q).sum();+                if norm<=0.0 {continue;}+                let amps=SIGMA*hole.barrel_area_mm2()/distance*weights.iter().filter(|(i,_)|fr[live[a]].v[*i]>0.0 && fr[live[a+1]].v[*i]>0.0).map(|(i,q)|q/norm*(v[a*n+*i]-v[(a+1)*n+*i])).sum::<f64>();+                connections.push(json!({"at":[hole.x,hole.y],"from":layers[live[a]],"to":layers[live[a+1]],"amps":amps,"meanBarrelJ":amps.abs()/hole.barrel_area_mm2(),"barrelAreaMm2":hole.barrel_area_mm2(),"resolvedPerimeterFraction":norm}));+            }+        }+        let mut grids: Vec<Grid> = layers.iter().map(|_| Grid::new(w, h)).collect();         let (mut peak, mut peak_at, mut peak_layer) = (0.0f64, [0.0, 0.0], String::new());         let mut joule = 0.0;         for (a, k) in live.iter().enumerate() {@@ -156,7 +194,6 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu             let mut cx = vec![0.0f64; n]; let mut cy = vec![0.0f64; n];             for r in 0..h { for c in 0..w - 1 { let g = sys.gx[a][r * (w - 1) + c]; let i = g * (v[a * n + r * w + c] - v[a * n + r * w + c + 1]); cx[r * w + c] += i / 2.0; cx[r * w + c + 1] += i / 2.0; } }             for r in 0..h - 1 { for c in 0..w { let g = sys.gy[a][r * w + c]; let i = g * (v[a * n + r * w + c] - v[a * n + (r + 1) * w + c]); cy[r * w + c] += i / 2.0; cy[(r + 1) * w + c] += i / 2.0; } }-            let mut vals: Vec<f32> = Vec::new();             for i in 0..n {                 let fi = f.v[i] as f64;                 if fi > 0.05 {@@ -165,32 +202,32 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu                     grids[*k].v[i] = jj as f32;                     joule += jj * jj / SIGMA * fi * d * d * t_cu[*k];                     p_joule[*k].v[i] += (jj * jj / SIGMA * fi * d * d * t_cu[*k]) as f32;-                    if fi >= 0.25 { vals.push(jj as f32); }                 }             }-            if !vals.is_empty() {-                vals.sort_by(|a, b2| a.partial_cmp(b2).unwrap());-                let cap = vals[((vals.len() as f64 * 0.995) as usize).min(vals.len() - 1)] as f64;-                for i in 0..n { if grids[*k].v[i] as f64 > cap { grids[*k].v[i] = cap as f32; } if f.v[i] >= 0.25 && (grids[*k].v[i] as f64) > peak { peak = grids[*k].v[i] as f64; peak_at = [b.x0 + (i % w) as f64 * d + d / 2.0, b.y0 + (i / w) as f64 * d + d / 2.0]; peak_layer = LAYERS[*k].to_string(); } }+            if let Some((layer_peak, index)) = current_peak_and_clip(&mut grids[*k], f) {+                if layer_peak > peak {+                    peak = layer_peak;+                    peak_at = [b.x0 + (index % w) as f64 * d + d / 2.0, b.y0 + (index / w) as f64 * d + d / 2.0];+                    peak_layer = layers[*k].to_string();+                }             }             j_all[*k].max_with(&grids[*k]);         }         let copper: f64 = live.iter().map(|k| fr[*k].sum() * d * d).sum();-        nets.push(NetResult { net: net.clone(), amps: load.amps, layers: live.iter().map(|k| LAYERS[*k].to_string()).collect(), peakJ: (peak * 10.0).round() / 10.0, peakAt: peak_at, peakLayer: peak_layer, copperMm2: (copper * 10.0).round() / 10.0, jouleW: (joule * 1000.0).round() / 1000.0, vias: nvias, iterations: it });+        nets.push(NetResult { net: net.clone(), amps: load.amps, layers: live.iter().map(|k| layers[*k].to_string()).collect(), peakJ: peak, peakAt: peak_at, peakLayer: peak_layer, copperMm2: (copper * 10.0).round() / 10.0, jouleW: (joule * 1000.0).round() / 1000.0, vias: nvias, iterations: it, residual: res, platedPads: plated_pads, platedConnections: connections });         j_net.insert(net.clone(), grids);     }     let joule_total: f64 = nets.iter().map(|x| x.jouleW).sum();     // every net with real copper on the board, so the walkthrough can show one pour at a time     let mut cu_net: BTreeMap<String, Vec<Grid>> = BTreeMap::new();-    for l in LAYERS { for net in ras.nets_on(l) { if cu_net.contains_key(&net) { continue; }-        let per: Vec<Grid> = LAYERS.iter().map(|ll| ras.fraction(&net, ll, pool)).collect();+    for l in &layers { for net in ras.nets_on(l) { if cu_net.contains_key(&net) { continue; }+        let per: Vec<Grid> = layers.iter().map(|ll| ras.fraction(&net, ll, pool)).collect();         let area: f64 = per.iter().map(|g| g.sum()).sum::<f64>() * d * d;         if area > 5.0 { cu_net.insert(net.clone(), per); }     } }-    // ---- heat: four layers, the dielectric and the vias between them, the air on the faces ---------    let nl = LAYERS.len();-    let t_diel: Vec<f64> = if spec.stackupMm.len() == 3 { spec.stackupMm.clone() } else { vec![0.2, 1.065, 0.2] };-    let share = [t_diel[0] / 2.0, t_diel[0] / 2.0 + t_diel[1] / 2.0, t_diel[1] / 2.0 + t_diel[2] / 2.0, t_diel[2] / 2.0];+    // ---- heat: actual copper layers, the dielectric and the vias between them, the air on the faces --------+    let nl = layers.len();+    let share = dielectric_share(&t_diel);     let build = |h_air: f64| {         let mut sys = System::new(nl, h, w);         for k in 0..nl {@@ -201,10 +238,10 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu             for i in 0..n { sys.leak[k][i] = leak + 1e-12; }         }         for k in 0..nl - 1 { let g = K_FR4_Z * d * d / t_diel[k]; for i in 0..n { sys.gz[k][i] = g; } }-        for v in &b.vias {-            let Some((r, c)) = cell(v.x, v.y) else { continue };-            let area = std::f64::consts::PI * ((v.drill / 2.0 + 0.025).powi(2) - (v.drill / 2.0).powi(2));-            for k in 0..nl - 1 { sys.gz[k][r * w + c] += K_CU * area / t_diel[k]; }+        for v in b.vias.iter().chain(&b.plated_holes) {+            let weights=crate::barrel::weights(v,b.x0,b.y0,d,w,h);+            let area = v.barrel_area_mm2();+            for k in 0..nl - 1 {for (i,q) in &weights {sys.gz[k][*i] += K_CU * area / t_diel[k]*q;}}         }         let meshes:Vec<_> = spec.heatsinks.iter().map(|block| crate::heatsink::attach(&mut sys,block,b.x0,b.y0,d,h_air)).collect();         (sys, meshes)@@ -262,13 +299,19 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu         for r in 0..h - 1 { for c in 0..w { let (a, bb) = (inside(r, c), inside(r + 1, c)); if a && !bb { plane += fy_all[0][r * w + c]; } else if !a && bb { plane -= fy_all[0][r * w + c]; } } }         // the tab's own thermal vias are the ones on its net; other nets' barrels nearby still carry         // heat (through the dielectric first) and are counted apart, never as the tab's provision-        let (mut via_w, mut other_w, mut diel_w, mut air_w, mut nv, mut no) = (0.0, 0.0, 0.0, 0.0, 0usize, 0usize);+        let (mut via_w, mut other_w, mut plated_w, mut diel_w, mut air_w, mut nv, mut no, mut np) = (0.0, 0.0, 0.0, 0.0, 0.0, 0usize, 0usize, 0usize);         let g_diel = K_FR4_Z * d * d / t_diel[0];-        let mut own_cells: std::collections::HashSet<usize> = std::collections::HashSet::new();-        for v in b.vias.iter().filter(|v| v.net == *tab) { if let Some((r, c)) = cell(v.x, v.y) { if inside(r, c) { own_cells.insert(r * w + c); nv += 1; } } }-        for v in b.vias.iter().filter(|v| v.net != *tab) { if let Some((r, c)) = cell(v.x, v.y) { if inside(r, c) { no += 1; } } }-        for r in 0..h { for c in 0..w { if !inside(r, c) { continue; } let i = r * w + c; let dt = tv[i] - tv[n + i]; let gv = sys_still.gz[0][i] - g_diel; if gv > 0.0 { if own_cells.contains(&i) { via_w += gv * dt; } else { other_w += gv * dt; } } diel_w += g_diel * dt; air_w += sys_still.leak[0][i] * tv[i]; } }-        chips.push(ChipResult { reference: reference.clone(), watts: *watts, tabNet: tab.clone(), x: *x, y: *y, riseC: (tv[r0 * w + c0] * 10.0).round() / 10.0, riseMovingC: (tm[r0 * w + c0] * 10.0).round() / 10.0, planeW: (plane * 1000.0).round() / 1000.0, viaW: (via_w * 1000.0).round() / 1000.0, vias: nv, otherViaW: (other_w * 1000.0).round() / 1000.0, otherVias: no, dielW: (diel_w * 1000.0).round() / 1000.0, airW: (air_w * 1000.0).round() / 1000.0, viasWanted: via_wanted(reference) });+        for (plated, v) in b.vias.iter().map(|v| (false,v)).chain(b.plated_holes.iter().map(|v| (true,v))) {+            if let Some((r,c)) = cell(v.x,v.y) { if inside(r,c) {+                let weights=crate::barrel::weights(v,b.x0,b.y0,d,w,h);+                let flow = K_CU * v.barrel_area_mm2() / t_diel[0] * weights.iter().map(|(i,q)|q*(tv[*i]-tv[n+*i])).sum::<f64>();+                if plated { plated_w += flow; np += 1; }+                else if v.net == *tab { via_w += flow; nv += 1; }+                else { other_w += flow; no += 1; }+            } }+        }+        for r in 0..h { for c in 0..w { if !inside(r,c) { continue; } let i=r*w+c; diel_w += g_diel*(tv[i]-tv[n+i]); air_w += sys_still.leak[0][i]*tv[i]; } }+        chips.push(ChipResult { reference: reference.clone(), watts: *watts, tabNet: tab.clone(), x: *x, y: *y, riseC: (tv[r0 * w + c0] * 10.0).round() / 10.0, riseMovingC: (tm[r0 * w + c0] * 10.0).round() / 10.0, planeW: (plane * 1000.0).round() / 1000.0, viaW: (via_w * 1000.0).round() / 1000.0, vias: nv, otherViaW: (other_w * 1000.0).round() / 1000.0, otherVias: no, platedPadW: (plated_w*1000.0).round()/1000.0, platedPads: np, dielW: (diel_w * 1000.0).round() / 1000.0, airW: (air_w * 1000.0).round() / 1000.0, viasWanted: via_wanted(reference) });     }     // ---- issues -------------------------------------------------------------------------------     let mut issues: Vec<Issue> = Vec::new();@@ -278,7 +321,15 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu         // amp through a thin track is a warm trace and twenty amps through a neck is a fuse         if x.peakJ > neck {             let severity = if x.amps < 1.0 { "low" } else if x.peakJ > 2.5 * neck { "high" } else { "medium" };-            issues.push(Issue { severity: severity.into(), kind: "current-neck".into(), what: format!("{}: {:.0} A/mm² at its worst point on {} ({} A through a neck about {:.2} mm wide at {:.0} µm; the threshold is {neck:.0} A/mm²)", x.net, x.peakJ, x.peakLayer, x.amps, x.amps / (x.peakJ * spec.copperUmByLayer.get(&x.peakLayer).copied().unwrap_or(spec.copperUm)/1000.0), spec.copperUmByLayer.get(&x.peakLayer).copied().unwrap_or(spec.copperUm)), at: Some(x.peakAt), layer: Some(x.peakLayer.clone()), net: Some(x.net.clone()), reference: None, fix: if x.amps < 1.0 { "A small current: widen the track if it is long, otherwise note it and move on.".into() } else { "Widen the neck or add a parallel path on the other layer with vias; the density map shows where the current squeezes.".into() } });+            issues.push(Issue { severity: severity.into(), kind: "current-neck".into(), what: format!("{}: {:.0} A/mm² at its worst point on {} ({} A total; equivalent full-net width {:.2} mm at {:.0} µm; the threshold is {neck:.0} A/mm²)", x.net, x.peakJ, x.peakLayer, x.amps, x.amps / (x.peakJ * spec.copperUmByLayer.get(&x.peakLayer).copied().unwrap_or(spec.copperUm)/1000.0), spec.copperUmByLayer.get(&x.peakLayer).copied().unwrap_or(spec.copperUm)), at: Some(x.peakAt), layer: Some(x.peakLayer.clone()), net: Some(x.net.clone()), reference: None, fix: if x.amps < 1.0 { "A small current: widen the track if it is long, otherwise note it and move on.".into() } else { "Widen the neck or add a parallel path on the other layer with vias; the density map shows where the current squeezes.".into() } });+        }+    }+    for net in &nets {+        for connection in &net.platedConnections {+            let density=connection["meanBarrelJ"].as_f64().unwrap_or(f64::INFINITY);+            if density>neck {+                issues.push(Issue {severity:"medium".into(),kind:"current-barrel".into(),what:format!("{}: plated connection mean density {:.2} A/mm² exceeds {neck:.2} A/mm²",net.net,density),at:Some([connection["at"][0].as_f64().unwrap(),connection["at"][1].as_f64().unwrap()]),layer:None,net:Some(net.net.clone()),reference:None,fix:"Review barrel plating, drill and current sharing; add a parallel physical connection where needed.".into()});+            }         }     }     for c in &chips {@@ -295,6 +346,7 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu  /// The summary the app and the page read. pub fn summary(f: &Fields, b: &Board, spec: &Spec, textures: &[String]) -> Value {+    let layers: Vec<&str> = b.layer_names.iter().map(String::as_str).collect();     let mut nets = f.nets.clone();     // relevance: amps first, then the peak density     nets.sort_by(|a, bb| bb.amps.partial_cmp(&a.amps).unwrap().then(bb.peakJ.partial_cmp(&a.peakJ).unwrap()));@@ -306,23 +358,50 @@ pub fn summary(f: &Fields, b: &Board, spec: &Spec, textures: &[String]) -> Value     areas.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));     let pour_nets: Vec<Value> = areas.into_iter().map(|(n, a)| json!({"net": n, "copperMm2": (a * 10.0).round() / 10.0})).collect();     json!({-        "board": {"name": spec.board, "x0": b.x0, "y0": b.y0, "widthMm": b.width(), "heightMm": b.height(), "layers": LAYERS, "copperUm": spec.copperUm, "dielectricMm":if spec.stackupMm.len()==3 {spec.stackupMm.clone()} else {vec![0.2,1.065,0.2]}, "copperUmByLayer":LAYERS.iter().map(|l|((*l).to_string(),spec.copperUmByLayer.get(*l).copied().unwrap_or(spec.copperUm))).collect::<BTreeMap<_,_>>(), "thicknessMm": if spec.stackupMm.len()==3 {spec.stackupMm.iter().sum::<f64>()} else {1.465} + LAYERS.iter().map(|l|spec.copperUmByLayer.get(*l).copied().unwrap_or(spec.copperUm)/1000.0).sum::<f64>(), "cellMm": f.d, "cols": f.w, "rows": f.h},+        "board": {"name": spec.board, "x0": b.x0, "y0": b.y0, "widthMm": b.width(), "heightMm": b.height(), "layers": layers, "copperUm": spec.copperUm, "stackupSource":if spec.stackupMm.is_empty() {"legacy-four-layer-assumption"} else {"explicit-spec"}, "dielectricMm":dielectric_mm(spec, layers.len()).expect("validated stackup"), "copperUmByLayer":layers.iter().map(|l|((*l).to_string(),spec.copperUmByLayer.get(*l).copied().unwrap_or(spec.copperUm))).collect::<BTreeMap<_,_>>(), "thicknessMm": dielectric_mm(spec, layers.len()).expect("validated stackup").iter().sum::<f64>() + layers.iter().map(|l|spec.copperUmByLayer.get(*l).copied().unwrap_or(spec.copperUm)/1000.0).sum::<f64>(), "cellMm": f.d, "cols": f.w, "rows": f.h},         "nets": nets, "chips": chips, "issues": f.issues,-        "signals": f.signals, "heatsinks": f.heatsinks, "thermalConvergence":{"stillResidual":f.heat_residual,"movingResidual":f.heat_moving_residual},+        "peakJDefinition": {"statistic":"unclipped-cell-maximum","minimumCopperFraction":0.25,"displayPercentile":0.995,"rounded":false},+        "terminalAssignments": spec.loads.iter().map(|(net, load)| (net.clone(), json!({"mode": if load.terminals.is_some() {"explicit"} else {"heuristic"}, "pins": load.terminals}))).collect::<BTreeMap<_,_>>(),+        "signals": f.signals, "heatsinks": f.heatsinks, "dcMaxIterations":spec.dcMaxIterations, "thermalConvergence":{"stillResidual":f.heat_residual,"movingResidual":f.heat_moving_residual},         "vias": b.vias.iter().map(|v| json!({"net": v.net, "x": v.x, "y": v.y, "size": v.size, "drill": v.drill})).collect::<Vec<_>>(),         "pads": b.pads.iter().filter(|p| spec.hot.contains_key(&p.reference) || spec.loads.contains_key(&p.net)).map(|p| json!({"ref": p.reference, "pad": p.pad, "net": p.net, "x": p.x, "y": p.y})).collect::<Vec<_>>(),         "scales": {"jMax": f.j_all.iter().map(|g| g.max()).fold(0.0, f32::max), "qMax": f.q.iter().map(|g| g.max()).fold(0.0, f32::max), "tMax": tmax, "tMovingMax": f.t_moving.iter().map(|g| g.max()).fold(0.0, f32::max)},         "heat": {"tabW": f.tab_total, "jouleW": f.joule_total, "peakRiseC": tmax, "peakRiseMovingC": f.t_moving.iter().map(|g| g.max()).fold(0.0, f32::max), "budgetC": spec.hotMaxRiseC, "stillAirWPerM2K": H_STILL * 1e6, "movingAirWPerM2K": H_MOVING * 1e6},         "textures": textures,         "pourNets": pour_nets,-        "method": "Copper fraction per cell from the board's filled polygons, tracks, pads and vias; DC conduction per loaded net between its terminals (σ 5.8e4 S/mm × thickness); heat: four layers coupled through the dielectric and every via barrel, copper 0.40 W/mm·K, FR4 0.3/0.6 W/m·K, 10 (still) or 25 (moving) W/m²K to the air on both faces; one steady-state solve each. Optional rectangular heatsinks use a 3D finite-volume conducting block with specified conductivity, contact resistance and air loss on exposed faces. Copper thickness may differ by layer; Joule heat is deposited on its source layer. This remains a screening model: rectangular board outline, prescribed chip watts and convection; no fins, radiation, airflow solve or junction-temperature qualification."+        "barrelCoupling": {"model":"distributed-plated-wall", "platingUm":25, "drillVoidsRemoved":true, "quadrature":"uniform-perimeter-32-samples-per-cell-minimum-128"},+        "method": "Copper fraction per cell from the board's filled polygons, tracks, pads and vias; DC conduction per loaded net between its terminals (σ 5.8e4 S/mm × thickness); heat: actual copper layers coupled through the dielectric and every via and plated-pad barrel, copper 0.40 W/mm·K, FR4 0.3/0.6 W/m·K, 10 (still) or 25 (moving) W/m²K to the air on both faces; one steady-state solve each. Optional rectangular heatsinks use a 3D finite-volume conducting block with specified conductivity, contact resistance and air loss on exposed faces. Copper thickness may differ by layer; Joule heat is deposited on its source layer. This remains a screening model: rectangular board outline, prescribed chip watts and convection; no fins, radiation, airflow solve or junction-temperature qualification."     }) } +#[cfg(test)] mod terminal_tests {+    use super::*;+    #[test] fn explicit_fuse_diode_path_is_independent_of_designator_and_current_direction() {+        let mut b = Board { x1: 10., y1: 6., layer_names: vec!["F.Cu".into(), "B.Cu".into()], ..Board::default() };+        b.polys.push(crate::board::Poly { net:"V".into(), layer:"F.Cu".into(), pts:vec![(0.,0.),(10.,0.),(10.,6.),(0.,6.)] });+        for (reference,x) in [("F1",1.5),("D1",8.5),("R24",5.)] {+            b.pads.push(crate::board::Pad { reference:reference.into(), pad:"1".into(), net:"V".into(), x,y:3., layers:vec!["F.Cu".into()],area:1. });+        }+        let mut spec:Spec=serde_json::from_value(json!({"copperUm":35,"stackupMm":[1.53],"loads":{"V":{"amps":3,"terminals":[{"reference":"F1","pad":"1","amps":3},{"reference":"D1","pad":"1","amps":-2.9999},{"reference":"R24","pad":"1","amps":-0.0001}]}}})).unwrap();+        let f=analyze(&b,&spec,0.5,60.,&mut |_|{});+        assert_eq!(f.nets.len(),1);+        validate_current_residual("V",f.nets[0].residual).unwrap();+        assert!(f.nets[0].jouleW > 0.);+        for row in spec.loads.get_mut("V").unwrap().terminals.as_mut().unwrap() { row.amps = -row.amps; }+        let reversed=analyze(&b,&spec,0.5,60.,&mut |_|{});+        assert_eq!(reversed.nets.len(),1);+        assert!((f.nets[0].jouleW-reversed.nets[0].jouleW).abs()<1e-5);+        assert!((f.nets[0].peakJ-reversed.nets[0].peakJ).abs()<0.1);+        b.pads[1].x = 50.;+        let missing=analyze(&b,&spec,0.5,60.,&mut |_|{});+        assert!(missing.nets.is_empty(), "a missing sink must not be silently omitted");+    }+}+ #[cfg(test)] mod thermal_tests {     use super::*;     #[test] fn board_with_block_lowers_tab_rise_and_reports_heat_received() {-        let mut b=Board{ x0:0.,y0:0.,x1:12.,y1:12., ..Board::default() };+        let mut b=Board{ layer_names: crate::board::LAYERS.iter().map(|l|l.to_string()).collect(), x0:0.,y0:0.,x1:12.,y1:12., ..Board::default() };         b.pads.push(crate::board::Pad{reference:"U1".into(),pad:"1".into(),net:"GND".into(),x:6.,y:6.,layers:vec!["F.Cu".into()],area:4.});         let mut spec:Spec=serde_json::from_value(json!({"copperUm":35,"hot":{"U1":{"watts":0.2,"tabNet":"GND"}}})).unwrap();         let baseline=analyze(&b,&spec,1.,60.,&mut |_|{});@@ -335,3 +414,130 @@ pub fn summary(f: &Fields, b: &Board, spec: &Spec, textures: &[String]) -> Value         assert!(cooled.chips[0].riseMovingC<cooled.chips[0].riseC);     } }++/// Legacy four-layer defaults remain explicit; other stacks require measured/specification input.+pub fn dielectric_mm(spec: &Spec, copper_layers: usize) -> Result<Vec<f64>, String> {+    if spec.stackupMm.is_empty() && copper_layers == 4 { return Ok(vec![0.2, 1.065, 0.2]); }+    if copper_layers < 2 || spec.stackupMm.len() != copper_layers - 1 || spec.stackupMm.iter().any(|v| !v.is_finite() || *v <= 0.0) {+        return Err(format!("stackupMm requires {} positive dielectric thicknesses for {} copper layers", copper_layers.saturating_sub(1), copper_layers));+    }+    Ok(spec.stackupMm.clone())+}+fn dielectric_share(thickness: &[f64]) -> Vec<f64> {+    (0..=thickness.len()).map(|k| thickness.get(k).unwrap_or(&0.0)/2.0 + k.checked_sub(1).and_then(|i|thickness.get(i)).unwrap_or(&0.0)/2.0).collect()+}++#[cfg(test)]+mod stackup_tests {+    use super::*;+    #[test]+    fn two_layer_stack_requires_one_explicit_interval() {+        let mut spec: Spec = serde_json::from_value(json!({"copperUm":35})).unwrap();+        assert!(dielectric_mm(&spec, 2).is_err());+        assert_eq!(dielectric_mm(&spec, 4).unwrap(), [0.2, 1.065, 0.2]);+        spec.stackupMm = vec![1.53];+        assert_eq!(dielectric_mm(&spec, 2).unwrap(), [1.53]);+        assert!(dielectric_mm(&spec, 4).is_err());+        assert_eq!(dielectric_share(&spec.stackupMm), [0.765, 0.765]);+        let b = Board { x1: 8., y1: 8., layer_names: vec!["F.Cu".into(), "B.Cu".into()], ..Board::default() };+        let f = analyze(&b, &spec, 1., 60., &mut |_| {});+        assert_eq!(f.frac.len(), 2);+        assert_eq!(f.t.len(), 2);+        let report = summary(&f, &b, &spec, &[]);+        assert_eq!(report["board"]["layers"], json!(["F.Cu", "B.Cu"]));+        assert!((report["board"]["thicknessMm"].as_f64().unwrap() - 1.6).abs() < 1e-9);+    }+}++pub fn validate_current_residual(net: &str, residual: f64) -> Result<(), String> {+    if !residual.is_finite() || residual > 1e-6 || residual < 0.0 {+        return Err(format!("DC solve for {net} did not converge (residual {residual}); no current/thermal summary is accepted; inspect copper raster connectivity and mesh resolution"));+    }+    Ok(())+}+#[cfg(test)] mod convergence_tests {+    #[test] fn reject_divergent_or_nonfinite_current_before_accepting_heat() {+        for residual in [320.0, 1.1e-6, f64::NAN, f64::INFINITY, -1.0] {+            assert!(super::validate_current_residual("GND", residual).is_err());+        }+        assert!(super::validate_current_residual("GND", 9.4e-8).is_ok());+    }+}++#[cfg(test)] mod plated_connection_tests {+    use super::*;+    #[test] fn plated_pad_connects_top_source_to_bottom_sink_without_any_via() {+        let mut b = Board { x1: 6., y1: 6., layer_names: vec!["F.Cu".into(), "B.Cu".into()], ..Board::default() };+        for layer in ["F.Cu", "B.Cu"] { b.polys.push(crate::board::Poly { net:"GND".into(), layer:layer.into(), pts:vec![(0.,0.),(6.,0.),(6.,6.),(0.,6.)] }); }+        for (reference,layer,x) in [("J1","F.Cu",1.5),("Q1","B.Cu",4.5)] {+            b.pads.push(crate::board::Pad { reference:reference.into(), pad:"1".into(), net:"GND".into(), x,y:3., layers:vec![layer.into()],area:1. });+        }+        b.plated_holes.push(crate::board::Via { net:"GND".into(),x:3.,y:3.,size:2.,drill:1.,drill_long:None,drill_angle:0.0 });+        let spec:Spec=serde_json::from_value(json!({"copperUm":35,"stackupMm":[1.53],"loads":{"GND":{"amps":1}}})).unwrap();+        let f=analyze(&b,&spec,0.5,60.,&mut |_|{});+        assert_eq!(f.nets.len(),1);+        validate_current_residual("GND",f.nets[0].residual).unwrap();+        assert_eq!(f.nets[0].vias,0);+        assert_eq!(f.nets[0].platedPads,1);+        assert!(f.nets[0].jouleW<0.1);+        assert!(f.heat_residual<1e-6);+    }+    #[test] fn annular_coupling_preserves_terminal_current_across_meshes() {+        let mut b = Board { x1: 6., y1: 6., layer_names: vec!["F.Cu".into(), "B.Cu".into()], ..Board::default() };+        for layer in ["F.Cu", "B.Cu"] { b.polys.push(crate::board::Poly { net:"GND".into(), layer:layer.into(), pts:vec![(0.,0.),(6.,0.),(6.,6.),(0.,6.)] }); }+        for (reference,layer,x) in [("J1","F.Cu",1.5),("Q1","B.Cu",4.5)] {+            b.pads.push(crate::board::Pad { reference:reference.into(), pad:"1".into(), net:"GND".into(), x,y:3., layers:vec![layer.into()],area:1. });+        }+        b.plated_holes.push(crate::board::Via { net:"GND".into(),x:3.,y:3.,size:2.,drill:1.,drill_long:None,drill_angle:0.0 });+        let spec:Spec=serde_json::from_value(json!({"copperUm":35,"stackupMm":[1.53],"loads":{"GND":{"amps":1}}})).unwrap();+        let mut peaks=Vec::new();+        for pitch in [0.2,0.1] {+            let f=analyze(&b,&spec,pitch,60.,&mut |_|{});+            assert_eq!(f.nets.len(),1);let net=&f.nets[0];+            let connection=&net.platedConnections[0];+            assert!((connection["amps"].as_f64().unwrap().abs()-1.0).abs()<1e-5);+            let dx=net.peakAt[0]-3.0;let dy=net.peakAt[1]-3.0;+            assert!((dx*dx+dy*dy).sqrt()+pitch*std::f64::consts::SQRT_2/2.0>=0.5,"peak cell must intersect copper outside the drill");+            peaks.push(net.peakJ);+        }+        eprintln!("annular mesh maxima: {:?}",peaks);+        assert!((peaks[0]-peaks[1]).abs()/peaks[1]<0.25,"mesh changed peak by more than fixture tolerance");+    }++}++/// Preserve the modeled maximum before clipping only the displayed texture.+fn current_peak_and_clip(grid: &mut Grid, fraction: &Grid) -> Option<(f64, usize)> {+    let mut eligible: Vec<(usize, f32)> = grid.v.iter().copied().enumerate()+        .filter(|(i, _)| fraction.v[*i] >= 0.25).collect();+    if eligible.is_empty() { return None; }+    eligible.sort_by(|a, b| a.1.total_cmp(&b.1));+    let (index, maximum) = *eligible.last().unwrap();+    let cap = eligible[((eligible.len() as f64 * 0.995) as usize).min(eligible.len() - 1)].1;+    for value in &mut grid.v { if *value > cap { *value = cap; } }+    Some((maximum as f64, index))+}++#[cfg(test)]+mod peak_reporting_tests {+    use super::*;+    #[test]+    fn narrow_hotspot_is_not_hidden_by_display_percentile() {+        let mut density = Grid::new(1000, 1);+        let mut fraction = Grid::new(1000, 1);+        density.v.fill(20.0); fraction.v.fill(1.0);+        density.v[731] = 60.04;+        let (maximum, index) = current_peak_and_clip(&mut density, &fraction).unwrap();+        assert!(maximum > 60.0, "a real threshold violation must not round away");+        assert_eq!(index, 731);+        assert_eq!(density.v[731], 20.0, "display clipping remains independent");+    }+    #[test]+    fn copper_fraction_cutoff_is_explicit_and_preserved() {+        let mut density = Grid::new(2, 1); let mut fraction = Grid::new(2, 1);+        density.v = vec![900.0, 12.0]; fraction.v = vec![0.24, 0.25];+        assert_eq!(current_peak_and_clip(&mut density, &fraction), Some((12.0, 1)));+        fraction.v.fill(0.0);+        assert_eq!(current_peak_and_clip(&mut density, &fraction), None);+    }+}
src/main.rs+16−8
@@ -1,6 +1,8 @@ //! adom-fields: where the current and the heat go on your board. use sha2::{Digest, Sha256};-mod eagle; mod parts; mod signals; mod heatsink; mod board; mod fields; mod raster; mod sexp; mod solve; mod tex; mod serve; mod vendor;+mod terminals;+mod eagle; mod parts; mod signals; mod heatsink; mod board;+mod barrel; mod fields; mod raster; mod sexp; mod solve; mod tex; mod serve; mod vendor; use clap::{Parser, Subcommand}; use std::path::PathBuf; @@ -57,15 +59,19 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck     let spec: fields::Spec = serde_json::from_str(&std::fs::read_to_string(spec_path).map_err(|e| format!("{spec_path}: {e}"))?).map_err(|e| format!("{spec_path}: {e}"))?;     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()); }     if !spec.copperUm.is_finite() || spec.copperUm<=0.0 {return Err("copperUm must be positive".into());}-    if !spec.stackupMm.is_empty() && (spec.stackupMm.len()!=3 || spec.stackupMm.iter().any(|v|!v.is_finite() || *v<=0.0)) {return Err("stackupMm requires three positive dielectric thicknesses".into());}-    for (layer,t) in &spec.copperUmByLayer { if !board::LAYERS.contains(&layer.as_str()) || !t.is_finite() || *t<=0.0 {return Err("copperUmByLayer requires known copper layers and positive thicknesses".into());} }-    spec.signals.validate()?;+    if !(1..=100000).contains(&spec.dcMaxIterations) { return Err("dcMaxIterations must be between 1 and 100000; convergence tolerance is unchanged".into()); }+    b.validate_layers()?;+    fields::dielectric_mm(&spec, b.layer_names.len())?;+    for (layer,t) in &spec.copperUmByLayer { if !b.layer_names.contains(layer) || !t.is_finite() || *t<=0.0 {return Err("copperUmByLayer requires actual board copper layers and positive thicknesses".into());} }+    for (net, load) in &spec.loads { if let Some(rows) = &load.terminals { terminals::resolve(&b, net, load.amps, rows)?; } }+    let layers: Vec<&str> = b.layer_names.iter().map(String::as_str).collect();+    spec.signals.validate_layers(&layers)?;     for hot in spec.hot.values() {if !hot.watts.is_finite() || hot.watts<0.0 {return Err("hot watts must be finite and nonnegative".into());}}     for load in spec.loads.values() {if !load.amps.is_finite() || load.amps<0.0 {return Err("load amps must be finite and nonnegative".into());}}     if !spec.signals.capacitance.is_empty() || !spec.signals.differential_pairs.is_empty() {-        if spec.stackupMm.len()!=3 {return Err("signals requires explicit stackupMm: three dielectric thicknesses, consistent with each signal check".into());}+        if spec.stackupMm.len()!=layers.len()-1 {return Err("signals requires explicit stackupMm: one dielectric thickness per adjacent copper-layer interval, consistent with each signal check".into());}         for (layer_a,layer_b,distance) in spec.signals.capacitance.iter().map(|c|(&c.layer_a,&c.layer_b,c.dielectric_mm)).chain(spec.signals.differential_pairs.iter().map(|p|(&p.layer,&p.reference_layer,p.dielectric_mm))) {-            let a=board::LAYERS.iter().position(|l|*l==layer_a).unwrap();let b=board::LAYERS.iter().position(|l|*l==layer_b).unwrap();+            let a=layers.iter().position(|l|*l==layer_a).unwrap();let b=layers.iter().position(|l|*l==layer_b).unwrap();             if (spec.stackupMm[a.min(b)]-distance).abs()>1e-6 {return Err("signal dielectricMm disagrees with stackupMm for its adjacent layers".into());}         }     }@@ -81,13 +87,15 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck     let t0 = std::time::Instant::now();     let mut lines: Vec<String> = Vec::new();     let f = fields::analyze(&b, &spec, pitch, neck, &mut |s| { eprintln!("{s}"); lines.push(s.to_string()); });+    for (net, load) in &spec.loads { if load.amps > 0.0 && !f.nets.iter().any(|r| r.net == *net) { return Err(format!("{net}: loaded net was not solved; no partial report will be published")); } }+    for net in &f.nets { fields::validate_current_residual(&net.net, net.residual)?; }     if !f.heat_residual.is_finite() || f.heat_residual>1e-6 || !f.heat_moving_residual.is_finite() || f.heat_moving_residual>1e-6 {return Err(format!("thermal solve did not converge: still {}, moving {}; refine the model or increase the solve budget",f.heat_residual,f.heat_moving_residual));}     let tex = out.join("tex");     let mut textures: Vec<String> = Vec::new();     let jhi = f.j_all.iter().map(|g| g.max()).fold(1.0, f32::max);     let qhi = f.q.iter().map(|g| g.max()).fold(0.01, f32::max);     let thi = f.t.iter().map(|g| g.max()).fold(1.0, f32::max);-    for (k, l) in board::LAYERS.iter().enumerate() {+    for (k, l) in layers.iter().enumerate() {         let ls = slug(l);         tex::write_mask(&tex.join(format!("copper-{ls}.png")).display().to_string(), &f.frac[k], [214, 148, 90])?; textures.push(format!("copper-{ls}.png"));         tex::write(&tex.join(format!("J-{ls}.png")).display().to_string(), &f.j_all[k], Some(&f.frac[k]), &tex::Scale::Log { lo: jhi / 300.0, hi: jhi }, false)?; textures.push(format!("J-{ls}.png"));@@ -135,7 +143,7 @@ fn main() {                     let nets = s["nets"].as_array().map(|a| a.len()).unwrap_or(0);                     let issues = s["issues"].as_array().cloned().unwrap_or_default();                     ok(&format!("{} nets solved, {} hot parts, {} issue(s), in {} s: {}", nets, s["chips"].as_array().map(|a| a.len()).unwrap_or(0), issues.len(), s["seconds"], out.join("fields.json").display()), &[-                        format!("Peak current density {:.0} A/mm²; peak rise {:.0} °C still air, {:.0} °C moving air, budget {:.0} °C; Joule loss {} W on top of {} W from the tabs.", s["scales"]["jMax"].as_f64().unwrap_or(0.0), s["heat"]["peakRiseC"].as_f64().unwrap_or(0.0), s["heat"]["peakRiseMovingC"].as_f64().unwrap_or(0.0), s["heat"]["budgetC"].as_f64().unwrap_or(0.0), s["heat"]["jouleW"], s["heat"]["tabW"]),+                        format!("Peak current density {:.0} A/mm²; peak rise {:.0} °C still air, {:.0} °C moving air, budget {:.0} °C; Joule loss {} W on top of {} W from the tabs.", s["nets"].as_array().into_iter().flatten().filter_map(|n| n["peakJ"].as_f64()).fold(0.0, f64::max), s["heat"]["peakRiseC"].as_f64().unwrap_or(0.0), s["heat"]["peakRiseMovingC"].as_f64().unwrap_or(0.0), s["heat"]["budgetC"].as_f64().unwrap_or(0.0), s["heat"]["jouleW"], s["heat"]["tabW"]),                         issues.iter().take(3).map(|i| format!("{} {}: {}", i["severity"].as_str().unwrap_or(""), i["kind"].as_str().unwrap_or(""), i["what"].as_str().unwrap_or(""))).collect::<Vec<_>>().join(" | "),                         if s["parts"].is_null() { "Parts: skipped (--no-parts).".to_string() } else { format!("Parts: {} with 3D models, {} as boxes, {} bare{}.", s["parts"]["modelled"], s["parts"]["boxed"], s["parts"]["bare"], if s["parts"]["failed"].as_array().map_or(false, |a| !a.is_empty()) { "; some models did not convert (see the log above; --models-dir adds a folder to search)" } else { "" }) },                         format!("Heatsinks: {}; signal capacitance checks: {}; differential pairs: {}. Signals are screening estimates; unsupported geometry is reported, never certified.", s["heatsinks"].as_array().map_or(0,|x|x.len()),s["signals"]["capacitance"].as_array().map_or(0,|x|x.len()),s["signals"]["differentialPairs"].as_array().map_or(0,|x|x.len())),
src/raster.rs+35−2
@@ -1,5 +1,5 @@ //! Copper masks on a fine grid, pooled to the solve grid as copper fractions.-use crate::board::{Board, Poly, LAYERS};+use crate::board::{Board, Poly}; use std::collections::HashMap;  pub struct Raster { pub w: usize, pub h: usize, pub res: f64, pub x0: f64, pub y0: f64, pub masks: HashMap<(String, String), Vec<u8>> }@@ -60,9 +60,19 @@ impl Raster {         let polys: Vec<Poly> = b.polys.iter().chain(b.pad_polys.iter()).cloned().collect();         for p in &polys { self.fill_poly(&p.net, &p.layer, &p.pts); }         for t in &b.tracks { let (net, layer, a, bb, w) = (t.net.clone(), t.layer.clone(), t.a, t.b, t.w); self.thick_line(&net, &layer, a, bb, w); }-        for v in &b.vias { for l in LAYERS { let (net, x, y, s) = (v.net.clone(), v.x, v.y, v.size); self.disc(&net, l, x, y, s / 2.0); } }+        for v in &b.vias { for l in &b.layer_names { let (net, x, y, s) = (v.net.clone(), v.x, v.y, v.size); self.disc(&net, l, x, y, s / 2.0); } }         self.draw_fills(b);         self.fill_pours(b);+        // Drill voids remove copper even when tracks or pours cross the hole.+        // Use a temporary mask so every net/layer loses the same physical void.+        let key="\u{0}drills";+        for v in b.vias.iter().chain(&b.plated_holes) {+            let (a,z)=crate::barrel::axis(v);+            self.thick_line(key,key,a,z,v.drill);+        }+        if let Some(holes)=self.masks.remove(&(key.into(),key.into())) {+            for mask in self.masks.values_mut() { for (m,h) in mask.iter_mut().zip(&holes) {if *h!=0 {*m=0;}} }+        }     }     /// Pour islands the file carries already filled (Fusion): each outline minus its holes.     fn draw_fills(&mut self, b: &Board) {@@ -159,3 +169,26 @@ fn dilate(m: &[u8], w: usize, h: usize, r: i64) -> Vec<u8> {     }     out }++#[cfg(test)] mod drill_tests {+    use super::*;+    #[test] fn drill_removes_overlapping_track_and_pour_copper_on_every_layer() {+        let mut b=Board{x1:4.0,y1:4.0,layer_names:vec!["F.Cu".into(),"B.Cu".into()],..Board::default()};+        for layer in &b.layer_names {b.polys.push(Poly{net:"N".into(),layer:layer.clone(),pts:vec![(0.,0.),(4.,0.),(4.,4.),(0.,4.)]});}+        b.vias.push(crate::board::Via{net:"N".into(),x:2.0,y:2.0,size:1.4,drill:0.8,drill_long:None,drill_angle:0.0});+        let mut r=Raster::new(&b,0.025);r.draw_board(&b);+        for layer in &b.layer_names {+            let g=r.fraction("N",layer,2);+            assert_eq!(g.at(40,40),0.0);assert_eq!(g.at(40,50),1.0);+            let removed=16.0-g.sum()*0.05*0.05;+            assert!((removed-std::f64::consts::PI*0.4*0.4).abs()<0.025);+        }+    }+    #[test] fn rotated_slot_is_void_along_its_actual_axis() {+        let mut b=Board{x1:4.0,y1:4.0,layer_names:vec!["F.Cu".into()],..Board::default()};+        b.polys.push(Poly{net:"N".into(),layer:"F.Cu".into(),pts:vec![(0.,0.),(4.,0.),(4.,4.),(0.,4.)]});+        b.plated_holes.push(crate::board::Via{net:"N".into(),x:2.,y:2.,size:2.,drill:0.4,drill_long:Some(1.4),drill_angle:90.});+        let mut r=Raster::new(&b,0.025);r.draw_board(&b);let g=r.fraction("N","F.Cu",2);+        assert_eq!(g.at(30,40),0.0);assert_eq!(g.at(40,30),1.0);+    }+}
src/signals.rs+19−5
@@ -1,7 +1,8 @@ //! Explicit, limited signal screening. Not a frequency-domain field solver. use serde::{Deserialize,Serialize}; use serde_json::{json,Value};-use crate::board::{Board,Track,LAYERS};+use crate::board::{Board,Track};+#[cfg(test)] use crate::board::LAYERS; use crate::raster::Raster; use std::collections::{BTreeMap,BTreeSet}; #[derive(Clone,Default,Deserialize,Serialize)]@@ -16,12 +17,14 @@ pub struct Capacitance {pub name:String,pub net_a:String,pub layer_a:String,pub #[derive(Clone,Deserialize,Serialize)] #[serde(rename_all="camelCase",deny_unknown_fields)] pub struct Pair {pub name:String,pub positive:String,pub negative:String,pub layer:String,pub reference_net:String,pub reference_layer:String,pub dielectric_mm:f64,pub epsilon_r:f64,pub max_skew_mm:f64,pub target_ohms:f64,pub tolerance_percent:f64}-fn adjacent(a:&str,b:&str)->bool {match (LAYERS.iter().position(|l|*l==a),LAYERS.iter().position(|l|*l==b)) {(Some(a),Some(b))=>a.abs_diff(b)==1,_=>false}}+fn adjacent(layers:&[&str],a:&str,b:&str)->bool {match (layers.iter().position(|l|*l==a),layers.iter().position(|l|*l==b)) {(Some(a),Some(b))=>a.abs_diff(b)==1,_=>false}} fn material(h:f64,er:f64)->bool {h.is_finite()&&h>0.&&er.is_finite()&&er>=1.}-impl Config {pub fn validate(&self)->Result<(),String>{+impl Config {+ #[cfg(test)] pub fn validate(&self)->Result<(),String>{ self.validate_layers(&LAYERS) }+ pub fn validate_layers(&self,layers:&[&str])->Result<(),String>{  let mut names=BTreeSet::new();- for c in &self.capacitance {if c.name.trim().is_empty()||!names.insert(c.name.clone())||c.net_a==c.net_b||!adjacent(&c.layer_a,&c.layer_b)||!material(c.dielectric_mm,c.epsilon_r){return Err("capacitance needs unique names, different nets, adjacent known layers and positive dielectricMm/epsilonR >= 1".into());}}- for p in &self.differential_pairs {if p.name.trim().is_empty()||!names.insert(p.name.clone())||p.positive==p.negative||!matches!(p.layer.as_str(),"F.Cu"|"B.Cu")||!adjacent(&p.layer,&p.reference_layer)||!material(p.dielectric_mm,p.epsilon_r)||!p.max_skew_mm.is_finite()||p.max_skew_mm<0.||!p.target_ohms.is_finite()||p.target_ohms<=0.||!p.tolerance_percent.is_finite()||p.tolerance_percent<=0. {return Err("differentialPairs requires unique names, distinct nets, outer microstrip layer, adjacent reference, explicit dielectric and positive target/tolerance".into());}}+ for c in &self.capacitance {if c.name.trim().is_empty()||!names.insert(c.name.clone())||c.net_a==c.net_b||!adjacent(layers,&c.layer_a,&c.layer_b)||!material(c.dielectric_mm,c.epsilon_r){return Err("capacitance needs unique names, different nets, adjacent known layers and positive dielectricMm/epsilonR >= 1".into());}}+ for p in &self.differential_pairs {if p.name.trim().is_empty()||!names.insert(p.name.clone())||p.positive==p.negative||!matches!(p.layer.as_str(),"F.Cu"|"B.Cu")||!adjacent(layers,&p.layer,&p.reference_layer)||!material(p.dielectric_mm,p.epsilon_r)||!p.max_skew_mm.is_finite()||p.max_skew_mm<0.||!p.target_ohms.is_finite()||p.target_ohms<=0.||!p.tolerance_percent.is_finite()||p.tolerance_percent<=0. {return Err("differentialPairs requires unique names, distinct nets, outer microstrip layer, adjacent reference, explicit dielectric and positive target/tolerance".into());}}  Ok(()) }} pub fn microstrip(w:f64,h:f64,t:f64,er:f64)->Option<f64>{@@ -102,3 +105,14 @@ pub fn analyze(b:&Board,r:&Raster,c:&Config,copper:&BTreeMap<String,f64>,fallbac   assert_eq!(a["capacitance"][0]["status"],"missing-copper");  } }++#[cfg(test)]+mod layer_tests {+    use super::*;+    #[test]+    fn front_back_adjacency_depends_on_actual_stack() {+        let c: Config = serde_json::from_value(json!({"capacitance":[{"name":"planes","netA":"A","layerA":"F.Cu","netB":"B","layerB":"B.Cu","dielectricMm":1.53,"epsilonR":4}]})).unwrap();+        assert!(c.validate_layers(&["F.Cu", "B.Cu"]).is_ok());+        assert!(c.validate_layers(&LAYERS).is_err());+    }+}
src/solve.rs+3
@@ -67,6 +67,9 @@ impl System {             let beta = rz2 / rz; rz = rz2;             for i in 0..n { p[i] = z[i] + beta * p[i]; }         }+        // Report the actual equation residual, not just the recursively updated CG estimate.+        self.matvec(&x, &mut ap);+        res = b.iter().zip(&ap).map(|(rhs, ax)| (rhs - ax).powi(2)).sum::<f64>().sqrt() / bnorm;         (x, it, res)     } }
src/terminals.rsadded+98
@@ -0,0 +1,98 @@+//! Explicit signed pin currents. Positive enters copper; negative leaves it.+use crate::board::{Board, Pad};+use serde::{Deserialize, Serialize};+use std::collections::BTreeSet;++#[derive(Clone, Debug, Deserialize, Serialize)]+#[serde(deny_unknown_fields)]+pub struct Terminal {+    pub reference: String,+    pub pad: String,+    pub amps: f64,+    /// Optional board-space pad center in mm, for repeated logical pin numbers.+    #[serde(default, skip_serializing_if = "Option::is_none")]+    pub at: Option<[f64; 2]>,+}++pub fn resolve<'a>(board: &'a Board, net: &str, amps: f64, rows: &[Terminal])+    -> Result<Vec<(&'a Pad, f64)>, String>+{+    if rows.is_empty() { return Err(format!("{net}: explicit terminals must not be empty")); }+    let mut seen = BTreeSet::new();+    let mut result = Vec::new();+    let (mut positive, mut negative) = (0.0, 0.0);+    for t in rows {+        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)| {+            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();+        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));+        }+        if !seen.insert(pads[0].0) {+            return Err(format!("{net}: physical pad assigned more than once"));+        }+        positive += t.amps.max(0.0);+        negative += (-t.amps).max(0.0);+        result.push((pads[0].1, t.amps));+    }+    let tolerance = 1e-9_f64.max(amps.abs() * 1e-8);+    if !amps.is_finite() || amps < 0.0 || (positive - amps).abs() > tolerance || (negative - amps).abs() > tolerance {+        return Err(format!("{net}: sources ({positive} A) and sinks ({negative} A) must each equal load ({amps} A)"));+    }+    Ok(result)+}++#[cfg(test)]+mod tests {+    use super::*;+    fn board() -> Board {+        Board { pads: [("J1", "1", "V"), ("F1", "1", "V"), ("R1", "1", "V"), ("J2", "1", "GND")].into_iter().map(|(r,p,n)| Pad { reference:r.into(), pad:p.into(), net:n.into(), x:0., y:0., layers:vec!["F.Cu".into()], area:1. }).collect(), ..Board::default() }+    }+    fn row(reference: &str, amps: f64) -> Terminal { Terminal { reference:reference.into(), pad:"1".into(), amps, at: None } }+    #[test] fn main_and_sense_branches_preserve_assigned_currents() {+        let b=board();+        let r=resolve(&b,"V",3.,&[row("J1",3.),row("F1",-2.9999),row("R1",-0.0001)]).unwrap();+        assert_eq!(r[1].0.reference,"F1"); assert_eq!(r[2].1,-0.0001);+    }+    #[test] fn reject_wrong_net_missing_duplicate_and_unbalanced_terminals() {+        let b=board();+        for rows in [vec![],vec![row("J1",3.),row("J2",-3.)],vec![row("J1",3.),row("MISSING",-3.)],vec![row("J1",3.),row("F1",-2.)],vec![row("J1",3.),row("F1",-1.5),row("F1",-1.5)],vec![row("J1",f64::NAN),row("F1",-3.)]] {+            assert!(resolve(&b,"V",3.,&rows).is_err());+        }+    }+    #[test] fn reject_ambiguous_repeated_pad_number() {+        let mut b=board(); b.pads.push(b.pads[0].clone());+        assert!(resolve(&b,"V",3.,&[row("J1",3.),row("F1",-3.)]).is_err());+    }+    #[test] fn repeated_pin_physical_pads_require_explicit_centers() {+        let mut b=board();+        let mut second=b.pads[1].clone(); second.x=2.; b.pads.push(second);+        let mut first=row("F1",-1.5); first.at=Some([0.,0.]);+        let mut second=row("F1",-1.5); second.at=Some([2.,0.]);+        let resolved=resolve(&b,"V",3.,&[row("J1",3.),first.clone(),second.clone()]).unwrap();+        assert_eq!(resolved[1].0.x,0.); assert_eq!(resolved[2].0.x,2.);+        assert!(resolve(&b,"V",3.,&[row("J1",3.),first.clone(),first.clone()]).is_err());+        for at in [Some([1.,0.]),Some([f64::NAN,0.]),None] {+            second.at=at;+            assert!(resolve(&b,"V",3.,&[row("J1",3.),first.clone(),second.clone()]).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());+        let mut selected=row("F1",-1.5);selected.at=Some([0.,0.]);+        assert!(resolve(&b,"V",3.,&[row("J1",3.),row("F1",-1.5),selected]).is_err());+    }+    #[test] fn old_json_remains_valid_and_new_selector_roundtrips() {+        let old:Terminal=serde_json::from_str(r#"{"reference":"F1","pad":"1","amps":-3}"#).unwrap();+        assert!(old.at.is_none());+        let mut new=old;new.at=Some([111.138,90.08]);+        let restored:Terminal=serde_json::from_str(&serde_json::to_string(&new).unwrap()).unwrap();+        assert_eq!(restored.at,new.at);+    }++}
src/ui/app.js+9−4
@@ -5,8 +5,8 @@   // every rotation runs at half the speed it did in 0.4: the flow plays these clips ten times faster,   // and a sway that reads as calm live turned frenetic on the sped-up video   const SLOW = 2;-  const LAYERS = ['F.Cu', 'In1.Cu', 'In2.Cu', 'B.Cu'];-  const Z = { 'F.Cu': 0.8, 'In1.Cu': 0.6, 'In2.Cu': -0.6, 'B.Cu': -0.8 };+  let LAYERS = [];+  const Z = {};   const slug = (s) => s.replace(/[^A-Za-z0-9]/g, '_');   // console to the server, so the AI reads what the UI does   const post = (p, body) => fetch(p, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) }).then(r => r.json()).catch(() => null);@@ -40,6 +40,11 @@   function boardSpace(x, y, z = 0) { return new BAB.Vector3(x - board.x0 - board.widthMm / 2, board.y0 + board.heightMm / 2 - y, z); }   function buildBoard(f) {     board = f.board;+    LAYERS = [...board.layers];+    $('#layer').replaceChildren(...LAYERS.map(layer => {+      const button = document.createElement('button');+      button.dataset.l = layer; button.textContent = layer; return button;+    }));     const cu = board.copperUmByLayer || Object.fromEntries(LAYERS.map(l=>[l,board.copperUm]));     const dielectric = board.dielectricMm || [0.2,1.065,0.2];     Z['F.Cu']=board.thicknessMm/2-cu['F.Cu']/2000;@@ -279,7 +284,7 @@     for (const b of document.querySelectorAll('.views .chip')) b.classList.toggle('is-on', b.dataset.cam === v.camera);     const jmax = Math.max(1, ...f.nets.map(n => n.peakJ));     $('#nets').innerHTML = f.nets.map(n => `<div class="row ${v.net === n.net ? 'is-on' : ''}" data-net="${n.net}" data-tip="${n.amps} A on ${n.layers.join(', ')}; ${n.copperMm2} mm² of copper, ${n.vias} vias, ${n.jouleW} W of Joule loss. Click to light this net alone."><div style="flex:1;min-width:0"><div class="name">${n.net}</div><div class="bar" style="width:${(n.peakJ / jmax * 100).toFixed(0)}%"></div></div><div class="num">${n.amps} A · ${n.peakJ.toFixed(0)} A/mm²</div></div>`).join('') || '<div class="empty">no loaded nets in the spec</div>';-    $('#chips').innerHTML = f.chips.map(c => `<div class="row ${v.chip === c.reference ? 'is-on' : ''}" data-chip="${c.reference}" data-tip="${c.watts} W on ${c.tabNet}: ${(c.planeW / c.watts * 100).toFixed(0)}% spreads along F.Cu, ${(c.viaW / c.watts * 100).toFixed(0)}% goes down its ${c.vias} own via(s) on ${c.tabNet} (spec asks ${c.viasWanted}), ${(c.otherViaW / c.watts * 100).toFixed(0)}% through ${c.otherVias} other nets' barrel(s) nearby, ${(c.dielW / c.watts * 100).toFixed(0)}% through the bare dielectric. +${c.riseMovingC} °C in moving air."><div class="name">${c.reference} <span class="num">${c.watts} W</span></div><div class="num" style="color:${c.riseC > f.heat.budgetC ? 'var(--red)' : 'var(--text)'}">+${c.riseC.toFixed(0)} °C</div></div>`).join('') || '<div class="empty">no hot parts in the spec</div>';+    $('#chips').innerHTML = f.chips.map(c => `<div class="row ${v.chip === c.reference ? 'is-on' : ''}" data-chip="${c.reference}" data-tip="${c.watts} W on ${c.tabNet}: ${(c.planeW / c.watts * 100).toFixed(0)}% spreads along F.Cu, ${(c.viaW / c.watts * 100).toFixed(0)}% goes down its ${c.vias} own via(s) on ${c.tabNet} (spec asks ${c.viasWanted}), ${(c.otherViaW / c.watts * 100).toFixed(0)}% through ${c.otherVias} other nets' via barrel(s) nearby, ${((c.platedPadW || 0) / c.watts * 100).toFixed(0)}% through ${c.platedPads || 0} plated-pad barrel(s), ${(c.dielW / c.watts * 100).toFixed(0)}% through the bare dielectric. +${c.riseMovingC} °C in moving air."><div class="name">${c.reference} <span class="num">${c.watts} W</span></div><div class="num" style="color:${c.riseC > f.heat.budgetC ? 'var(--red)' : 'var(--text)'}">+${c.riseC.toFixed(0)} °C</div></div>`).join('') || '<div class="empty">no hot parts in the spec</div>';     $('#issues').innerHTML = f.issues.map((i, k) => `<div class="issue ${v.issue === k ? 'is-on' : ''}" data-issue="${k}"><span class="sev ${i.severity}">${i.severity}</span> <span class="num" style="font-family:JetBrains Mono;font-size:11px;color:var(--text-2)">${i.kind}</span><div class="what">${i.what}</div><div class="fix">${i.fix}</div></div>`).join('') || '<div class="empty">No current or thermal threshold findings in this solve. Signal checks are reported separately.</div>';     const signals = f.signals || {}; const signalPanel=$('#signals'); signalPanel.replaceChildren();     const addSignal=(text)=>{const row=document.createElement('div');row.className='empty';row.textContent=text;signalPanel.appendChild(row);};@@ -337,7 +342,7 @@     state.fields = f;     $('#boardName').textContent = f.board.name || 'board';     $('#boardMeta').textContent = `${f.board.widthMm.toFixed(0)} x ${f.board.heightMm.toFixed(0)} mm · ${f.board.copperUm} µm · ${f.nets.length} nets · ${f.chips.length} hot parts · ${f.issues.length} issues`;-    $('#solveInfo').textContent = `peak ${f.scales.jMax.toFixed(0)} A/mm² · +${f.heat.peakRiseC.toFixed(0)} °C still, +${f.heat.peakRiseMovingC.toFixed(0)} °C moving · budget ${f.heat.budgetC} °C`;+    $('#solveInfo').textContent = `peak ${Math.max(0, ...f.nets.map(n => n.peakJ)).toFixed(0)} A/mm² · +${f.heat.peakRiseC.toFixed(0)} °C still, +${f.heat.peakRiseMovingC.toFixed(0)} °C moving · budget ${f.heat.budgetC} °C`;     $('#ver').textContent = '';     buildBoard(f);     const st = await fetch('state').then(r => r.json()).catch(() => null);
src/ui/index.html+1−1
@@ -95,7 +95,7 @@ body{margin:0;background:var(--bg);color:var(--text-body);font-family:"Satoshi",   <div class="app-body">     <aside class="side" id="left">       <div class="sec"><h3>Field</h3><div class="seg" id="qty"><button data-q="current" data-tip="Current density on the copper, amps per square millimetre, every loaded net at its spec current">Current</button><button data-q="heat" data-tip="Heat flow in the copper, watts per millimetre of width, from the hot tabs and the Joule loss">Heat</button><button data-q="signals" data-tip="Copper overlap capacitance and differential-pair screening">Signals</button><button data-q="temperature" data-tip="Temperature rise over the board, the FR4 spreading">Temperature</button></div></div>-      <div class="sec"><h3>Layer</h3><div class="seg" id="layer"><button data-l="F.Cu">F.Cu</button><button data-l="In1.Cu">In1</button><button data-l="In2.Cu">In2</button><button data-l="B.Cu">B.Cu</button></div></div>+      <div class="sec"><h3>Layer</h3><div class="seg" id="layer"></div></div>       <div class="sec" id="airSec"><h3>Air</h3><div class="seg" id="air"><button data-a="still" data-tip="Still air, 10 W/m²K on both faces">Still</button><button data-a="moving" data-tip="Moving air, 25 W/m²K: a fan or a moving vehicle">Moving</button></div></div>       <div class="sec"><h3>Nets, by what they carry</h3><div id="nets"></div></div>       <div class="sec"><h3>Hot parts, hottest first</h3><div id="chips"></div></div>