Files Branches

Comparing main ← fix-physical-two-layer-stack

Changes on fix-physical-two-layer-stack that are not yet on main (three-dot, from the merge base).

11 files changed, 311 insertions(+), 68 deletions(-)
README.md+14
@@ -98,3 +98,17 @@ 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.
SKILL.md+14
@@ -90,3 +90,17 @@ 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.
src/board.rs+82−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> } #[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 });         }         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,18 @@ 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()); }+                    b.plated_holes.push(Via { net: net.clone(), x, y, size: sw.min(sh), drill, drill_long });+                }                 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 +141,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 +162,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};+        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 });                 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 });         }         // 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+120−38
@@ -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 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 } #[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();+        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,15 @@ 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 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 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 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();+                sys.gz[a][r * w + c] += sigma_area / distance;+            }         }         // 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@@ -126,7 +133,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; }@@ -146,9 +153,9 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu             if !pinned { let (a, r, c) = sp[0]; sys.leak[a][r * w + c] = 1e3; pinned = true; }         }         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 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() {@@ -171,26 +178,25 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu             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(); } }+                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(); } }             }             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 * 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, residual: res, platedPads: plated_pads });         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,9 +207,9 @@ 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 {+        for v in b.vias.iter().chain(&b.plated_holes) {             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));+            let area = v.barrel_area_mm2();             for k in 0..nl - 1 { sys.gz[k][r * w + c] += K_CU * area / t_diel[k]; }         }         let meshes:Vec<_> = spec.heatsinks.iter().map(|block| crate::heatsink::attach(&mut sys,block,b.x0,b.y0,d,h_air)).collect();@@ -262,13 +268,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 i = r*w+c;+                let flow = K_CU * v.barrel_area_mm2() / t_diel[0] * (tv[i]-tv[n+i]);+                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();@@ -295,6 +307,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 +319,23 @@ 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},+        "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."+        "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 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 +348,72 @@ 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 });+        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);+    }+}
src/main.rs+10−6
@@ -57,15 +57,18 @@ 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());} }+    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 +84,14 @@ 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 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"));
src/raster.rs+2−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,7 +60,7 @@ 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);     }
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/ui/app.js+8−3
@@ -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);};
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>