Adom Fields
Public Made by Adomby adom
Adom Fields: where the current and the heat go on your board.
Use actual copper stacks and reject unconverged DC screens #4
fix-physical-two-layer-stack → mainA two-layer KiCad board was analyzed as four layers: vias and plated pads created fictitious inner copper, and the solver, thermal stack and viewer followed that hardcoded stack. This change uses the physical two- or four-layer stack throughout. Two-layer inputs require one explicit dielectric interval; unsupported stacks and non-through vias fail explicitly. Fusion layerSetup/active declarations preserve empty and hidden physical layers instead of dropping them.
A real-board reproduction also exposed a separate acceptance hole: GND DC residual 3.2e2 after 6000 iterations generated 599.856 W of invalid Joule heat, yet thermal convergence allowed success. DC residuals are now retained and checked before a summary is accepted. The invalid result is not a temperature prediction.
Base: b8aecfc5de87ca3a8b09ebbd4a82a8c9b416988f (0.6.2), including the merged Fusion reader. No runtime replacement, package publication or self-merge. Related issue: #8; package-to-heatsink heat modeling remains unresolved.
Validation: cargo check --workspace --bins; cargo test --workspace (18 tests); cargo build --release; node --check src/ui/app.js; review-safe preflight with immutable base and live HEAD checks. Viewer dependencies fetched using the existing pinned 0.7.1 script; generated vendor.rs is unchanged. Existing Fusion fill, mirror, pad-rule and thermal-block tests are preserved. Tests cover two-layer pad/via raster, thermal grid count and total thickness, signal adjacency, declared hidden/empty Fusion layers, unsupported stacks/vias and DC-convergence rejection.
Native evidence: the source board was independently saved/refilled and has zero native DRC errors and zero opens, with 15 inherited library lookup warnings. This does not establish field accuracy. The real 0.2 mm field mesh remains invalid due to GND DC nonconvergence. No physical thermal/electrical measurements or live viewer acceptance are claimed. The model still assumes terminal allocation, via plating, prescribed package watts and convection; it does not qualify cooling or junction temperature.
Diff Skip to comments (1)
README.md+10@@ -98,3 +98,13 @@ 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.
SKILL.md+10@@ -90,3 +90,13 @@ 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.
src/board.rs+47−2@@ -45,7 +45,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,6 +75,14 @@ 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) }); }@@ -93,7 +102,7 @@ impl Board { let kind = p.atom(2).unwrap_or("smd"); let shape = p.atom(3).unwrap_or("rect"); 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 +129,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 +150,32 @@ 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);+ 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());+ }+}
src/eagle.rs+26−8@@ -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@@ -133,6 +139,7 @@ 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 });@@ -216,6 +223,17 @@ 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 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+78−24@@ -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};@@ -32,7 +32,7 @@ fn d60() -> f64 { 60.0 } #[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 } #[derive(Serialize, Clone)] pub struct ChipResult { pub reference: String, pub watts: f64, pub tabNet: String, pub x: f64, pub y: f64, pub riseC: f64, pub riseMovingC: f64, pub planeW: f64, pub viaW: f64, pub vias: usize, pub otherViaW: f64, pub otherVias: usize, pub dielW: f64, pub airW: f64, pub viasWanted: usize } #[derive(Serialize, Clone)]@@ -55,26 +55,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);@@ -88,8 +90,11 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu 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 sigma_area = SIGMA * std::f64::consts::PI * ((v.drill / 2.0 + 0.025).powi(2) - (v.drill / 2.0).powi(2));+ 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 +131,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; }@@ -148,7 +153,7 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu 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); 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 +176,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 }); 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 {@@ -295,6 +299,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,7 +311,7 @@ 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}, "vias": b.vias.iter().map(|v| json!({"net": v.net, "x": v.x, "y": v.y, "size": v.size, "drill": v.drill})).collect::<Vec<_>>(),@@ -315,14 +320,14 @@ pub fn summary(f: &Fields, b: &Board, spec: &Spec, textures: &[String]) -> Value "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 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 +340,52 @@ 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());+ }+}
src/main.rs+9−6@@ -57,15 +57,17 @@ 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()?;+ 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 +83,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/ui/app.js+7−2@@ -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;
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>
Comments
Log in to comment.