← Commit history
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-fields"-version = "0.7.20"+version = "0.8.0" edition = "2021" license = "MIT" description = "Adom Fields: where the current and the heat go on your board. Current density, copper heat flow and board temperature from the board file itself, in 3D, for the AI to drive and the human to explore."
SKILL.md+2
@@ -177,6 +177,8 @@ After applying a step, run `analyze` again: the issue goes away or says how far - **Explicit pin currents** (PR 6, 10): `loads.<net>.terminals: [{reference, pad, amps, at?}]`, positive into the copper, negative out, each side summing to `amps`; `at: [x, y]` picks one physical land where a pin number repeats. Wrong-net, missing, duplicate and unbalanced pins are refused; a loaded net the spec names that cannot be solved fails the run instead of publishing a partial report. `terminalAssignments` says explicit or heuristic per net. - **Peak current density is the modeled maximum** (PR 8), before the display texture's 99.5th-percentile clip and unrounded (`peakJDefinition`). - **`dcMaxIterations`** (PR 5, default 20000, at most 100000) bounds each net's solve; the target is unchanged.+- **Solid bodies off the board** (0.8.0): `solids: {pitchMm, bodies: [{name, boxesMm: [[x0,y0,z0,x1,y1,z1]], conductivityWMk, watts | heatOf: "<hot ref>", hStillWM2k, hMovingWM2k, boardContactM2kW?, provenance}], contacts: [{bodies: [a, b], resistanceM2kW}], boardLinks: [{body, at, layer, resistanceKPerW, pad}], assumptions}`. Boxes are in board mm (x and y as KiCad, z up from the top copper). The bodies are voxelized and solved in the same heat system as the board, so the heat runs from the die through the tab, the pad and the heatsink fins to the air, and down the leads into the copper, in one energy balance. Bodies may touch only through a declared contact (0 means ideal), and they may not overlap. `fields.json` `solids` gives each body's peak rise, heat to air and to board, and peak flux. `solids.json` has every cell's temperature and flux vector, and the viewer paints them in the Heat and Temperature views. Calibrate a sink's h to its vendor rating rather than trusting a textbook h. See docs/solids.md.+- **Fan levels** (0.8.0): `fanLevels: [0, 0.25, 0.5, 0.75, 1]` solves each level fully. h on every exposed face (board, bodies, a packageCooling sink) follows (h_still³ + (h_forced·sⁿ)³)^⅓, where s is the fraction of full speed, s = 1 gives the moving-air h, and n is `fanExponent` (0.5). `fields.json` `airStates` gives each level's h, chip rises and bodies. The viewer's Fan control (Off, 25, 50, 75, 100 %) switches the board and the bodies together, on one colour scale, so the levels compare by eye. `ui view --air fan50` drives it. - **Package cooling** (PR 11): `packageCooling: [{reference, junctionCaseKPerW, caseSinkKPerW, sinkAmbientStillKPerW, sinkAmbientMovingKPerW, boardLinks, provenance}]` puts a hot part's watts at its junction and lets them split through the supplied case, sink and board links; junction, case and sink rises per air state and an energy balance in `packageCooling`. See docs/package-cooling.md. - **`adom-fields thermal3d --spec --out`** (PR 12): axis-aligned 3D solids with explicit materials, contacts and convection for an off-board package and finned sink, with a GLB of the surface temperatures. Not CFD. See docs/thermal3d.md. 
docs/release-0.8.0.mdadded+6
@@ -0,0 +1,6 @@+# adom-fields 0.8.0++- **Heat through the parts and the heatsink.** The spec's new `solids` block adds 3D bodies: a package's die, tab and moulding, an interface pad, a finned heatsink. They are boxes in board coordinates, voxelized and solved in the same heat system as the copper, with one energy balance. Heat runs from the die through the tab and pad, up the fins into the air, and down the leads into the board. The Heat and Temperature views paint every body's cells, purple to yellow, under a see-through heatsink. `fields.json` `solids` gives each body's peak rise, heat to air and board, and peak flux, and `solids.json` holds every cell. See docs/solids.md.+- **Fan levels.** `fanLevels: [0, 0.25, 0.5, 0.75, 1]` solves each fan speed fully. Convection on every exposed face follows a mixed natural/forced blend that matches still air at 0 and the spec's moving air at 1. The viewer's Fan control (Off, 25, 50, 75, 100 %) switches the board and the bodies together on one colour scale; `ui view --air fan50` drives it. `fields.json` `airStates` gives each level's chip rises and bodies.+- **The default view frames the whole scene.** The iso view and Fit fit everything in the scene (board, bodies, parts, an exploded stack) inside the drift's sway, so a 40 mm heatsink or a fan is never cut off.+- The temperature scale includes the bodies, since a die runs hotter than any copper.
docs/solids.mdadded+44
@@ -0,0 +1,44 @@+# Heat through the parts and the heatsink (`solids`)++Since 0.8.0 the heat solve does not stop at the board. A spec can add **solid bodies**, such as a power package's die, tab and moulding, an isolating pad, and a finned heatsink. They are solved in the same steady system as the copper, so the viewer shows where the heat goes: from the die into the tab, across the pad into the sink base, up the fins into the air, and down the leads into the board.++## The model++- **Geometry.** Each body is a union of axis-aligned boxes in board millimetres: x and y as in KiCad, z up from the top copper face. A body under the board uses z below minus the board thickness. The grid keeps every box edge and subdivides to `pitchMm` (1 mm by default), so a 0.6 mm pad or a 1.2 mm fin keeps its true thickness.+- **Conduction** inside a body uses its `conductivityWMk`. Between two bodies there must be a declared `contacts` entry, with area resistance in m²K/W (0 means ideal). Two touching bodies with no contact are refused, and so are overlapping bodies.+- **Air.** Every exposed face loses heat through `hStillWM2k` / `hMovingWM2k`, in series with half a cell of conduction. This is prescribed convection, not CFD, and there's no radiation.+- **The board.** A face lying on the outer copper (z = 0) couples to the copper under it through `boardContactM2kW`. `boardLinks` join a body to one board point with a resistance in K/W, for example a lead to its pad.+- **Heat.** `heatOf: "Q1"` takes the spec's `hot.Q1.watts` and spreads them over the body's volume. The board then gets none of Q1's heat directly. `watts` sets a body's own heat instead. Don't use both, and don't use `heatOf` together with a `packageCooling` entry for the same part.++## Calibrate, don't guess++A finned sink's real h depends on its fin spacing and the airflow, so calibrate it. Solve once, take the sink's mean base rise over its heat to air, and scale h until that matches the vendor's K/W. The R7 electronic-load example below converged in one step: 9.04 K/W against the vendor's 9.0 still, and 5.84 against 5.8 moving. Calibrate the package's junction-to-tab contact to the datasheet's RthJC, and the interface films to their K/W.++## Fan levels++`fanLevels: [0, 0.25, 0.5, 0.75, 1]` solves each level as a full steady state. h on every exposed face (board, bodies, a packageCooling sink) follows Churchill's mixed-convection blend:++    h(s) = (h_still³ + (h_forced · sⁿ)³)^⅓,  h_forced chosen so h(1) = h_moving++Here s is the fraction of full fan speed. The exponent n is `fanExponent`: 0.5 for laminar flow over plates and through fin channels, about 0.8 when turbulent. The fan is assumed to reach every face evenly; there's no duct or shadowing model. Each level writes its own board textures (`T-fan50-*`, `q-fan50-*`) and its bodies' cells (`solids.json` `levels`). `fields.json` `airStates` gives each level's h, chip rises and per-body results.++## Example: the R7 electronic load++Q1 (IXTP15N50L2, TO-220, tab up) is pressed through an AlN pad into a 53 × 53 mm base with 20 fins. The model takes the boxes from the assembly GLB. At 5.5 W:++| Fan | Q1 die | Heatsink peak |+|---|---|---|+| Off | +56.9 °C | +48.9 °C |+| 25 % | +52.0 °C | +44.0 °C |+| 50 % | +46.6 °C | +38.7 °C |+| 75 % | +42.5 °C | +34.5 °C |+| 100 % | +39.4 °C | +31.4 °C |++These come from the 0.2 mm board solve; the 1 mm body grid is the same in both runs. Every number depends on the supplied interface, convection and lead resistances, so read them as screening estimates, not qualified ratings.++## In the viewer++- **Heat view:** each body's cells are coloured by heat flux in W/mm² (log, purple to yellow).+- **Temperature view:** colour shows the rise.+- **Range:** one colour range covers all the bodies and all fan levels, so the colours compare.+- **Display:** the heatsink is see-through so the package under it shows, the bodies move with an exploded stack, and the default iso view frames everything in the scene.
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-fields",   "type": "app",-  "version": "0.7.20",+  "version": "0.8.0",   "title": "Adom Fields",   "brief": "Adom Fields: where the current and the heat go on your board.",   "summary": "Current density on the copper, heat flow through the pours and the vias, and the temperature over the board, solved from the board file itself and shown on the board in 3D. The AI does the analysis and drives the view; the human orbits, zooms, hovers for the number under the cursor, and reads the nets by what they carry and the issues by what to fix. A step in Adom's AI Flow: the walkthrough goes into the video.",
page.json+8−23
@@ -1,7 +1,7 @@ {   "slug": "adom-fields",   "type": "app",-  "version": "0.7.20",+  "version": "0.8.0",   "title": "Adom Fields",   "brief": "Adom Fields: where the current and the heat go on your board.",   "summary": "Current density on the copper, heat flow through the pours and the vias, and the temperature over the board, solved from the board file itself and shown on the board in 3D. The AI does the analysis and drives the view; the human orbits, zooms, hovers for the number under the cursor, and reads the nets by what they carry and the issues by what to fix. A step in Adom's AI Flow: the walkthrough goes into the video.",@@ -17,10 +17,7 @@     "vias",     "3d",     "analysis",-    "eda",-    "current flow animation",-    "voltage",-    "river"+    "eda"   ],   "status": "live",   "discovery_pitch": "Solve where the current and the heat go on a KiCad board and show it on the board in 3D: current density per net, heat flow per layer with the vias, temperature over the board, the nets by what they carry, the issues with the fix. One command to solve, one to serve, one to drive it on camera.",@@ -40,10 +37,11 @@     "fields",     "flow of current",     "river view",-    "voltage drop in 3d",-    "ground bounce",+    "energy flow on a board",+    "poynting vector",+    "speed of light on a pcb",+    "electron drift speed",     "exploded layer stack",-    "current through vias",     "explain the view out loud",     "narrate the fields",     "energy cycle",@@ -85,20 +83,7 @@     "anti-resonance",     "input filter stability",     "middlebrook",-    "ngspice pdn",-    "signals view",-    "copper capacitance",-    "switch node capacitance",-    "plane capacitance",-    "differential pair skew",-    "six layer board",-    "receiver frequency plan",-    "lo leakage",-    "tone in band",-    "rf crosstalk",-    "return path over supply plane",-    "antenna ground loop",-    "stage feed bypass"+    "ngspice pdn"   ],   "sample_prompts": [     {@@ -139,4 +124,4 @@     "name": "John Lauer",     "email": "[email protected]"   }-}\ No newline at end of file+}
skills/adom-fields/SKILL.md+2
@@ -177,6 +177,8 @@ After applying a step, run `analyze` again: the issue goes away or says how far - **Explicit pin currents** (PR 6, 10): `loads.<net>.terminals: [{reference, pad, amps, at?}]`, positive into the copper, negative out, each side summing to `amps`; `at: [x, y]` picks one physical land where a pin number repeats. Wrong-net, missing, duplicate and unbalanced pins are refused; a loaded net the spec names that cannot be solved fails the run instead of publishing a partial report. `terminalAssignments` says explicit or heuristic per net. - **Peak current density is the modeled maximum** (PR 8), before the display texture's 99.5th-percentile clip and unrounded (`peakJDefinition`). - **`dcMaxIterations`** (PR 5, default 20000, at most 100000) bounds each net's solve; the target is unchanged.+- **Solid bodies off the board** (0.8.0): `solids: {pitchMm, bodies: [{name, boxesMm: [[x0,y0,z0,x1,y1,z1]], conductivityWMk, watts | heatOf: "<hot ref>", hStillWM2k, hMovingWM2k, boardContactM2kW?, provenance}], contacts: [{bodies: [a, b], resistanceM2kW}], boardLinks: [{body, at, layer, resistanceKPerW, pad}], assumptions}`. Boxes are in board mm (x and y as KiCad, z up from the top copper). The bodies are voxelized and solved in the same heat system as the board, so the heat runs from the die through the tab, the pad and the heatsink fins to the air, and down the leads into the copper, in one energy balance. Bodies may touch only through a declared contact (0 means ideal), and they may not overlap. `fields.json` `solids` gives each body's peak rise, heat to air and to board, and peak flux. `solids.json` has every cell's temperature and flux vector, and the viewer paints them in the Heat and Temperature views. Calibrate a sink's h to its vendor rating rather than trusting a textbook h. See docs/solids.md.+- **Fan levels** (0.8.0): `fanLevels: [0, 0.25, 0.5, 0.75, 1]` solves each level fully. h on every exposed face (board, bodies, a packageCooling sink) follows (h_still³ + (h_forced·sⁿ)³)^⅓, where s is the fraction of full speed, s = 1 gives the moving-air h, and n is `fanExponent` (0.5). `fields.json` `airStates` gives each level's h, chip rises and bodies. The viewer's Fan control (Off, 25, 50, 75, 100 %) switches the board and the bodies together, on one colour scale, so the levels compare by eye. `ui view --air fan50` drives it. - **Package cooling** (PR 11): `packageCooling: [{reference, junctionCaseKPerW, caseSinkKPerW, sinkAmbientStillKPerW, sinkAmbientMovingKPerW, boardLinks, provenance}]` puts a hot part's watts at its junction and lets them split through the supplied case, sink and board links; junction, case and sink rises per air state and an energy balance in `packageCooling`. See docs/package-cooling.md. - **`adom-fields thermal3d --spec --out`** (PR 12): axis-aligned 3D solids with explicit materials, contacts and convection for an off-board package and finned sink, with a GLB of the surface temperatures. Not CFD. See docs/thermal3d.md. 
src/fields.rs+67−8
@@ -12,6 +12,10 @@ pub const K_FR4_XY: f64 = 0.0006;      // W/(mm K), in plane pub const K_FR4_Z: f64 = 0.0003;       // W/(mm K), through pub const H_STILL: f64 = 1.0e-5;       // W/(mm2 K): about 10 W/m2K, still air pub const H_MOVING: f64 = 2.5e-5;      // moving air+/// h at fan level s (0 off, 1 full): natural and forced convection combined as (h0³ + (hf·sⁿ)³)^⅓, with hf chosen so+/// s = 1 gives h1 (Churchill's mixed-convection blend; n = 0.5 for laminar flow over plates and fins)+pub fn fan_mix(h0: f64, h1: f64, s: f64, n: f64) -> f64 { let hf = (h1.powi(3) - h0.powi(3)).max(0.0).cbrt(); (h0.powi(3) + (hf * s.clamp(0.0, 1.0).powf(n)).powi(3)).cbrt() }+pub struct FanLevel { pub level: f64, pub t: Vec<Grid>, pub q: Vec<Grid>, pub chips: Vec<(String, f64)>, pub solids: Value, pub solids_cells: Value, pub residual: f64, pub h_board_w_m2k: f64 }  #[derive(Deserialize, Default, Clone)] pub struct Spec {@@ -26,6 +30,12 @@ pub struct Spec {     #[serde(default)] pub heatsinks: Vec<crate::heatsink::Block>,     /// a power device cooled off the board: junction, case and sink nodes with supplied resistances (PR 11)     #[serde(default)] pub packageCooling: Vec<crate::package_cooling::Package>,+    /// solid bodies off the board (package, pad, finned sink) solved with the board's heat (crate::solids)+    #[serde(default)] pub solids: Option<crate::solids::Solids>,+    /// fan levels to solve besides still air (0) and full moving air (1), as fractions of full speed, e.g. [0.25, 0.5, 0.75]+    #[serde(default)] pub fanLevels: Vec<f64>,+    /// forced-convection h grows as speed^n (0.5 laminar, 0.8 turbulent); 0.5 when not given+    #[serde(default)] pub fanExponent: Option<f64>,     #[serde(default)] pub stackupMm: Vec<f64>,     /// each net's rail voltage for the river view; nets not named here are read from their names (GND 0, 3V3 3.3, +12V 12)     #[serde(default)] pub voltages: BTreeMap<String, f64>,@@ -87,6 +97,10 @@ pub struct Fields {     pub joule_total: f64, pub tab_total: f64,     pub heatsinks: Vec<Value>,     pub package_cooling: Vec<Value>,+    /// the solid bodies' summary (fields.json `solids`) and their cells for the viewer (solids.json)+    pub solids: Value, pub solids_cells: Value,+    /// the extra fan levels (spec fanLevels), each a full solve+    pub fan: Vec<FanLevel>,     pub signals: Value,     pub heat_residual: f64, pub heat_moving_residual: f64,     /// the signed current, the solved potential and the terminals of each loaded net, for the flow and river views@@ -319,7 +333,12 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu     let nl = LAYERS.len();     let t_diel: Vec<f64> = slot_gaps(spec);     let share: Vec<f64> = (0..LAYERS.len()).map(|k| (if k > 0 { t_diel[k - 1] / 2.0 } else { 0.0 }) + (if k + 1 < LAYERS.len() { t_diel[k] / 2.0 } else { 0.0 })).collect();-    let build = |h_air: f64| {+    let board_thick: f64 = { let gaps = if spec.real_gaps.is_empty() { slot_gaps(spec) } else { spec.real_gaps.clone() }; gaps.iter().sum::<f64>() + 2.0 * spec.copperUm / 1000.0 };+    let solid_grid = spec.solids.as_ref().map(|sd| sd.validate([b.x0, b.y0, b.x1, b.y1], &|_| true, &|r| spec.hot.contains_key(r)).expect("solids validated before the solve"));+    let solid_heat = |r: &str| spec.solids.as_ref().is_some_and(|sd| sd.bodies.iter().any(|x| x.heat_of.as_deref() == Some(r)));+    let fan_n = spec.fanExponent.unwrap_or(0.5);+    let build = |level: f64| {+        let h_air = fan_mix(H_STILL, H_MOVING, level, fan_n);         let mut sys = System::new(nl, h, w);         for k in 0..nl {             let kt: Vec<f64> = frac[k].v.iter().map(|f| *f as f64 * K_CU * t_cu[k] + K_FR4_XY * share[k]).collect();@@ -337,9 +356,10 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu         let meshes:Vec<_> = spec.heatsinks.iter().map(|block| crate::heatsink::attach(&mut sys,block,b.x0,b.y0,d,h_air)).collect();         let packages: Vec<_> = spec.packageCooling.iter().map(|p| {             let nodes: Vec<_> = p.board_links.iter().filter_map(|link| { let k = LAYERS.iter().position(|l| *l == link.layer)?; let (r, c) = cell(link.at[0], link.at[1])?; Some(k * n + r * w + c) }).collect();-            p.attach(&mut sys, &nodes, h_air == H_MOVING)+            p.attach(&mut sys, &nodes, level, fan_n)         }).collect();-        (sys, meshes, packages)+        let solid = match (&spec.solids, &solid_grid) { (Some(sd), Some(g)) => Some(crate::solids::attach(&mut sys, sd, g, level, fan_n, b.x0, b.y0, d, h_air, board_thick, &|r: &str| spec.hot.get(r).map_or(0.0, |x| x.watts)).expect("solids validated before the solve")), _ => None };+        (sys, meshes, packages, solid)     };     let mut q_rhs = vec![0.0; nl * n];     for k in 0..nl { for i in 0..n {q_rhs[k*n+i] += p_joule[k].v[i] as f64;} }@@ -352,7 +372,7 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu         let area_sum: f64 = pads.iter().map(|p| p.area.max(1e-6)).sum();         for (pi, (x, y)) in cells.iter().enumerate() {             // a package cooled off the board takes its watts at its junction node, not into the copper (PR 11)-            let per = if spec.packageCooling.iter().any(|p| p.reference == *reference) { 0.0 } else { hot.watts * pads[pi].area.max(1e-6) / area_sum };+            let per = if spec.packageCooling.iter().any(|p| p.reference == *reference) || solid_heat(reference) { 0.0 } else { hot.watts * pads[pi].area.max(1e-6) / area_sum };             let Some((r0, c0)) = cell(*x, *y) else { continue };             let rad = (1.2 / d) as isize; let mut spots = Vec::new();             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 { spots.push((r as usize, c as usize)); } } }@@ -361,18 +381,28 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu         chip_pos.push((reference.clone(), cells[0].0, cells[0].1, hot.watts, if leads { "leads".to_string() } else { hot.tabNet.clone() }));     }     let tab_total: f64 = spec.hot.values().map(|h| h.watts).sum();-    let (sys_still, sink_meshes, packages_still) = build(H_STILL);+    let (sys_still, sink_meshes, packages_still, solid_still) = build(0.0);     q_rhs.resize(sys_still.n(),0.0);+    if let Some(a) = &solid_still { for (nd, wt) in &a.sources { q_rhs[*nd] += wt; } }     for (p, nodes) in spec.packageCooling.iter().zip(&packages_still) { q_rhs[nodes.junction] = spec.hot.get(&p.reference).map_or(0.0, |h| h.watts); }     let (tv, it, res) = sys_still.solve(&q_rhs, 1e-7, 8000);     log(&format!("heat, still air: {it} iterations, residual {res:.1e}"));-    let (sys_mov, _, packages_moving) = build(H_MOVING);+    let (sys_mov, _, packages_moving, solid_moving) = build(1.0);     let (tm, it2, res2) = sys_mov.solve(&q_rhs, 1e-7, 8000);     log(&format!("heat, moving air: {it2} iterations"));     let package_cooling: Vec<Value> = spec.packageCooling.iter().zip(&packages_still).zip(&packages_moving).map(|((p, a), bm)| {         let watts = spec.hot.get(&p.reference).map_or(0.0, |h| h.watts);         json!({"definition": p, "still": a.result(&tv, watts), "moving": bm.result(&tm, watts), "status": "supplied-parameter-steady-state-screen", "transientQualified": false, "chipRisePolicy": "maximum of board terminal region, case and junction; never hide a hot external node"})     }).collect();+    let (solids, solids_cells) = match (&spec.solids, &solid_still, &solid_moving) {+        (Some(sd), Some(a), Some(bm)) => {+            let ((bs, cs), (bmv, cmv)) = (a.result(sd, &tv), bm.result(sd, &tm));+            let cells: Vec<Value> = a.cells.iter().map(|(_, bi, x)| json!([x[0], x[1], x[2], x[3], x[4], x[5], bi])).collect();+            (json!({"model": "voxel conduction in the bodies, solved with the board's heat; prescribed h on exposed faces (not CFD, no radiation)", "file": "solids.json", "cells": cells.len(), "pitchMm": sd.pitch_mm, "bodies": sd.bodies.iter().map(|x| json!({"name": x.name, "conductivityWMk": x.conductivity_w_mk, "hStillWM2k": x.h_still_w_m2k, "hMovingWM2k": x.h_moving_w_m2k, "heatOf": x.heat_of, "provenance": x.provenance})).collect::<Vec<_>>(), "contacts": sd.contacts, "boardLinks": sd.board_links, "assumptions": sd.assumptions, "still": bs, "moving": bmv}),+             json!({"bodies": sd.bodies.iter().map(|x| x.name.clone()).collect::<Vec<_>>(), "cells": cells, "still": cs, "moving": cmv}))+        }+        _ => (Value::Null, Value::Null),+    };     let heatsinks: Vec<Value> = spec.heatsinks.iter().zip(&sink_meshes).map(|(block,mesh)| {         let peak=|t:&[f64]|mesh.nodes.iter().map(|i|t[*i]).fold(0.0,f64::max);         let power=|t:&[f64]|mesh.contacts.iter().map(|(a,b,g)|g*(t[*a]-t[*b])).sum::<f64>();@@ -391,6 +421,31 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu         for i in 0..n { if frac[k].v[i] >= 0.25 { qg.v[i] = ((cx[i] * cx[i] + cy[i] * cy[i]).sqrt() / d) as f32; } }         q.push(qg); fx_all.push(fx); fy_all.push(fy);     }+    // the extra fan levels: each its own solve, its temperatures, its heat flow in the copper, its bodies+    let qgrids = |sys: &crate::solve::System, tt: &[f64]| -> Vec<Grid> { (0..nl).map(|k| {+        let mut cx = vec![0.0f64; n]; let mut cy = vec![0.0f64; n];+        for r in 0..h { for c in 0..w - 1 { let f = sys.gx[k][r * (w - 1) + c] * (tt[k * n + r * w + c] - tt[k * n + r * w + c + 1]); cx[r * w + c] += f / 2.0; cx[r * w + c + 1] += f / 2.0; } }+        for r in 0..h - 1 { for c in 0..w { let f = sys.gy[k][r * w + c] * (tt[k * n + r * w + c] - tt[k * n + (r + 1) * w + c]); cy[r * w + c] += f / 2.0; cy[(r + 1) * w + c] += f / 2.0; } }+        let mut qg = Grid::new(w, h); for i in 0..n { if frac[k].v[i] >= 0.25 { qg.v[i] = ((cx[i] * cx[i] + cy[i] * cy[i]).sqrt() / d) as f32; } } qg }).collect() };+    let grids_of = |tt: &[f64]| -> Vec<Grid> { (0..nl).map(|k| { let mut g = Grid::new(w, h); g.v.copy_from_slice(&tt[k * n..(k + 1) * n].iter().map(|x| *x as f32).collect::<Vec<_>>()); g }).collect() };+    let mut levels: Vec<f64> = spec.fanLevels.iter().cloned().filter(|l| l.is_finite() && *l >= 0.0 && *l <= 1.0).collect();+    if !levels.is_empty() { for x in [0.0, 1.0] { if !levels.iter().any(|l| (l - x).abs() < 1e-9) { levels.push(x); } } }+    levels.sort_by(|a, z| a.partial_cmp(z).unwrap()); levels.dedup_by(|a, z| (*a - *z).abs() < 1e-9);+    let mut fan: Vec<FanLevel> = Vec::new();+    for &lv in &levels {+        let (sys_l, _, pk_l, sol_l, tl, rl) = if lv == 0.0 { (None, None, None, None, tv.clone(), res) } else if lv == 1.0 { (None, None, None, None, tm.clone(), res2) } else {+            let (sy, _, pk, so) = build(lv); let (tt, itl, rr) = sy.solve(&q_rhs, 1e-7, 8000); log(&format!("heat, fan {:.0} %: {itl} iterations", lv * 100.0)); (Some(sy), Some(()), Some(pk), so, tt, rr) };+        let _ = &sys_l; let _ = pk_l.as_ref(); let _ = sol_l.as_ref();+        let q_l = match &sys_l { Some(sy) => qgrids(sy, &tl), None => if lv == 0.0 { qgrids(&sys_still, &tl) } else { qgrids(&sys_mov, &tl) } };+        let sol_ref = match (&sol_l, lv) { (Some(a), _) => Some(a), (None, x) if x == 0.0 => solid_still.as_ref(), (None, _) => solid_moving.as_ref() };+        let (sv, sc) = match (&spec.solids, sol_ref) { (Some(sd), Some(a)) => a.result(sd, &tl), _ => (Value::Null, Value::Null) };+        let pk_ref = match &pk_l { Some(p) => p, None => if lv == 0.0 { &packages_still } else { &packages_moving } };+        let chips_l: Vec<(String, f64)> = chip_pos.iter().filter_map(|(rf, x, y, _, _)| { let (r0, c0) = cell(*x, *y)?; let mut v = tl[r0 * w + c0];+            for (p, nd) in spec.packageCooling.iter().zip(pk_ref.iter()) { if p.reference == *rf { v = v.max(tl[nd.junction]).max(tl[nd.case]); } }+            if let (Some(sd), Some(a)) = (&spec.solids, sol_ref) { for c in &a.cells { if sd.bodies[c.1].heat_of.as_deref() == Some(rf.as_str()) { v = v.max(tl[c.0]); } } }+            Some((rf.clone(), (v * 10.0).round() / 10.0)) }).collect();+        fan.push(FanLevel { level: lv, t: grids_of(&tl), q: q_l, chips: chips_l, solids: sv, solids_cells: sc, residual: rl, h_board_w_m2k: fan_mix(H_STILL, H_MOVING, lv, fan_n) * 1e6 });+    }     // per chip: where its watts go within 3 mm of the tab     let r_chip = 3.0;     let via_wanted = |reference: &str| -> usize { spec.thermalVias.iter().filter(|v| v["pad"].as_str().map(|p| p.split('.').next() == Some(reference)).unwrap_or(false)).map(|v| v["count"].as_u64().unwrap_or(0) as usize).sum() };@@ -414,6 +469,10 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu     // ---- issues -------------------------------------------------------------------------------     // a package's rise is the hottest of its board region, case and junction (PR 11)     for (p, (a, bm)) in spec.packageCooling.iter().zip(packages_still.iter().zip(&packages_moving)) { if let Some(chip) = chips.iter_mut().find(|c| c.reference == p.reference) { chip.riseC = chip.riseC.max(tv[a.junction]).max(tv[a.case]); chip.riseMovingC = chip.riseMovingC.max(tm[bm.junction]).max(tm[bm.case]); } }+    if let (Some(sd), Some(a), Some(bm)) = (&spec.solids, &solid_still, &solid_moving) { for chip in chips.iter_mut() {+        let hot_cells = |at: &crate::solids::Attached, t: &[f64]| at.cells.iter().filter(|c| sd.bodies[c.1].heat_of.as_deref() == Some(chip.reference.as_str())).map(|c| t[c.0]).fold(f64::MIN, f64::max);+        let (sv, mv) = (hot_cells(a, &tv), hot_cells(bm, &tm)); if sv > f64::MIN { chip.riseC = chip.riseC.max((sv * 10.0).round() / 10.0); chip.riseMovingC = chip.riseMovingC.max((mv * 10.0).round() / 10.0); }+    } }     let mut issues: Vec<Issue> = Vec::new();     for x in &nets {         // IPC-2221 external, 10 C rise: about 60 A/mm2 at 35 um; a pinch well past that is a hot neck@@ -441,7 +500,7 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu         }     }     let _ = (tab_total, joule_total);-    Fields { package_cooling, flow, net_ids, net_names, signals:crate::signals::analyze(b,&ras,&spec.signals,&spec.copperUmByLayer,spec.copperUm), heatsinks, heat_residual:res, heat_moving_residual:res2, d, h, w, x0: b.x0, y0: b.y0, frac, j_net, cu_net, j_all, q, t, t_moving, nets, chips, issues, joule_total: (joule_total * 1000.0).round() / 1000.0, tab_total }+    Fields { package_cooling, solids, solids_cells, fan, flow, net_ids, net_names, signals:crate::signals::analyze(b,&ras,&spec.signals,&spec.copperUmByLayer,spec.copperUm), heatsinks, heat_residual:res, heat_moving_residual:res2, d, h, w, x0: b.x0, y0: b.y0, frac, j_net, cu_net, j_all, q, t, t_moving, nets, chips, issues, joule_total: (joule_total * 1000.0).round() / 1000.0, tab_total } }  /// The summary the app and the page read.@@ -462,7 +521,7 @@ pub fn summary(f: &Fields, b: &Board, spec: &Spec, textures: &[String]) -> Value         "barrelCoupling": {"model": "distributed-plated-wall", "platingUm": 25, "drillVoidsRemoved": true, "quadrature": "uniform-perimeter-32-samples-per-cell-minimum-128", "unresolvedWall": "nearest cells with copper on both faces (fallback: true on the connection)"},         "peakJDefinition": {"statistic": "unclipped-cell-maximum", "minimumCopperFraction": 0.25, "displayPercentile": 0.995, "rounded": false},         "terminalAssignments": spec.loads.iter().map(|(net, load)| (net.clone(), json!({"mode": if load.terminals.is_some() {"explicit"} else {"heuristic"}, "pins": load.terminals}))).collect::<BTreeMap<_,_>>(),-        "signals": f.signals, "heatsinks": f.heatsinks, "packageCooling": f.package_cooling, "thermalConvergence":{"stillResidual":f.heat_residual,"movingResidual":f.heat_moving_residual},+        "signals": f.signals, "heatsinks": f.heatsinks, "packageCooling": f.package_cooling, "solids": f.solids, "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)},
src/main.rs+20−3
@@ -1,6 +1,6 @@ //! adom-fields: where the current and the heat go on your board. use sha2::{Digest, Sha256};-mod caps; mod pours; mod probe; mod terminals; mod barrel; mod package_cooling; mod thermal3d; mod eagle; mod emc; mod flow; mod noise; mod pdn; mod parts; mod silk; mod signals; mod heatsink; mod board; mod fields; mod raster; mod sexp; mod solve; mod tex; mod serve; mod vendor;+mod caps; mod solids; mod pours; mod probe; mod terminals; mod barrel; mod package_cooling; mod thermal3d; mod eagle; mod emc; mod flow; mod noise; mod pdn; mod parts; mod silk; mod signals; mod heatsink; mod board; mod fields; mod raster; mod sexp; mod solve; mod tex; mod serve; mod vendor; use clap::{Parser, Subcommand}; use serde_json::{json, Value}; use std::path::PathBuf;@@ -114,6 +114,11 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck     spec.signals.validate(&spec.real_copper)?;     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());}}     // a package cooled off the board (PR 11): one entry per hot part, its links on that part's own pads+    if let Some(sd) = &spec.solids {+        let copper: Vec<String> = b.layer_names.clone();+        sd.validate([b.x0, b.y0, b.x1, b.y1], &|l| copper.iter().any(|c| c == l) || l == "F.Cu" || l == "B.Cu", &|r| spec.hot.contains_key(r))?;+        for x in &sd.bodies { if let Some(r) = &x.heat_of { if spec.packageCooling.iter().any(|p| p.reference == *r) { return Err(format!("solids: {} takes {r}'s heat, which packageCooling also models; use one or the other", x.name)); } } }+    }     for (i, package) in spec.packageCooling.iter().enumerate() {         let cu: Vec<&str> = spec.real_copper.iter().map(String::as_str).collect();         package.validate_board(&b, &cu, pitch)?;@@ -162,8 +167,9 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck     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);+    let qhi = f.q.iter().map(|g| g.max()).fold(0.01, f32::max).max(f.fan.iter().flat_map(|l| l.q.iter().map(|g| g.max())).fold(0.0, f32::max));+    // the bodies off the board share the temperature scale (the die runs hotter than any copper)+    let thi = f.t.iter().map(|g| g.max()).fold(1.0, f32::max).max(f.solids["still"].as_array().map_or(0.0, |b| b.iter().filter_map(|x| x["maxRiseC"].as_f64()).fold(0.0, f64::max)) as f32);     for (k, l) in board::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"));@@ -187,14 +193,25 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck             tex::write(&tex.join(format!("J-{ns}-{ls}.png")).display().to_string(), &grids[k], Some(&f.frac[k]), &tex::Scale::Log { lo: jhi / 300.0, hi: jhi }, false)?; textures.push(format!("J-{ns}-{ls}.png"));         }     }+    // each fan level: its temperature and its heat flow, on the same scales as still air, so the levels compare by eye+    let fan_name = |lv: f64| format!("fan{:.0}", lv * 100.0);+    for lv in &f.fan { let nm = fan_name(lv.level); for (k, l) in board::LAYERS.iter().enumerate() { let ls = slug(l);+        tex::write(&tex.join(format!("T-{nm}-{ls}.png")).display().to_string(), &lv.t[k], None, &tex::Scale::Linear { hi: thi }, true)?; textures.push(format!("T-{nm}-{ls}.png"));+        tex::write_raw(&tex.join(format!("T-{nm}-{ls}.raw.png")).display().to_string(), &lv.t[k], thi)?;+        tex::write(&tex.join(format!("q-{nm}-{ls}.png")).display().to_string(), &lv.q[k], Some(&f.frac[k]), &tex::Scale::Log { lo: qhi / 300.0, hi: qhi }, false)?; textures.push(format!("q-{nm}-{ls}.png"));+        tex::write_raw(&tex.join(format!("q-{nm}-{ls}.raw.png")).display().to_string(), &lv.q[k], qhi)?;+    } }+    if !f.fan.is_empty() && !f.solids_cells.is_null() { for lv in &f.fan { f.solids_cells["levels"][fan_name(lv.level)] = lv.solids_cells.clone(); } }     let mut summary = fields::summary(&f, &b, &spec, &textures);     // the cavities' mode shapes are for the app: their own file, so fields.json stays readable for an AI caller+    if !f.solids_cells.is_null() { std::fs::write(out.join("solids.json"), serde_json::to_string(&f.solids_cells).unwrap()).map_err(|e| e.to_string())?; }     if let Some(cav) = nz["cavities"].as_array_mut() { let shapes: Vec<Value> = cav.iter_mut().map(|c| json!({"net": c["net"].clone(), "grid": c["grid"].clone(), "shapes": c["shapes"].take()})).collect(); for c in cav.iter_mut() { c.as_object_mut().map(|o| o.remove("shapes")); } std::fs::write(out.join("noise.json"), serde_json::to_string(&json!({"cavities": shapes})).unwrap()).map_err(|e| e.to_string())?; }     review["noise"] = nz;     review["pdn"] = pd;     summary["fragments"] = fragments;     summary["review"] = review;     summary["fixPlan"] = emc::fix_plan(&f.issues);+    if !f.fan.is_empty() { summary["airStates"] = json!({"model": "fan level s: h = (h_still³ + (h_forced·sⁿ)³)^⅓ on every exposed face (board, bodies, a package sink), s = 0 is still air and s = 1 the spec's moving air; prescribed, not CFD", "exponent": spec.fanExponent.unwrap_or(0.5), "levels": f.fan.iter().map(|l| json!({"name": fan_name(l.level), "level": l.level, "hBoardWM2k": (l.h_board_w_m2k * 100.0).round() / 100.0, "residual": l.residual, "chips": l.chips.iter().map(|(r, t)| json!({"reference": r, "riseC": t})).collect::<Vec<_>>(), "solids": l.solids})).collect::<Vec<_>>()}); }     summary["flow"] = flow::write(out, &f, &b, &spec)?;     summary["board"]["stackupFrom"] = serde_json::json!(stack_from); if !auto_switch.is_empty() { summary["switchingLoads"] = serde_json::json!(auto_switch.iter().map(|(n, a)| serde_json::json!({"net": n, "amps": a, "note": "a switching node with no load in the spec: solved with the current of the net across its inductor (else the largest load); set loads.<net> to change it"})).collect::<Vec<_>>()); } if let Some((g, a)) = &auto_ground { summary["groundReturn"] = serde_json::json!({"net": g, "amps": a, "note": "not in the spec: solved with the largest supply load as the return current (set loads.<ground> to change it)"}); }     summary["board"]["name"] = serde_json::json!(spec.board.split('(').next().unwrap_or(&spec.board).trim());
src/package_cooling.rs+6−10
@@ -124,16 +124,12 @@ impl Package {     }      /// Caller resolves and validates physical pads and layer indices first.-    pub fn attach(&self, sys: &mut System, board_nodes: &[usize], moving: bool) -> Nodes {+    pub fn attach(&self, sys: &mut System, board_nodes: &[usize], level: f64, fan_n: f64) -> Nodes {         assert_eq!(board_nodes.len(), self.board_links.len());         let board_count = sys.nl * sys.h * sys.w;         assert!(board_nodes.iter().all(|i| *i < board_count));-        let sink_conductance = 1.0-            / if moving {-                self.sink_ambient_moving_k_per_w-            } else {-                self.sink_ambient_still_k_per_w-            };+        // the sink's conductance to the air follows the fan level like h does (crate::fields::fan_mix)+        let sink_conductance = crate::fields::fan_mix(1.0 / self.sink_ambient_still_k_per_w, 1.0 / self.sink_ambient_moving_k_per_w, level, fan_n);         let junction = sys.add_node(0.0);         let case = sys.add_node(0.0);         let sink = sys.add_node(sink_conductance);@@ -202,7 +198,7 @@ mod tests {         let p = package();         let mut s = System::new(1, 1, 1);         s.leak[0][0] = 1.0;-        let n = p.attach(&mut s, &[], false);+        let n = p.attach(&mut s, &[], 0.0, 0.5);         let mut q = vec![0.0; s.n()];         q[n.junction] = 2.0;         let (t, _, res) = s.solve(&q, 1e-12, 100);@@ -223,7 +219,7 @@ mod tests {         });         let mut s = System::new(1, 1, 1);         s.leak[0][0] = 0.5;-        let n = p.attach(&mut s, &[0], false);+        let n = p.attach(&mut s, &[0], 0.0, 0.5);         let mut q = vec![0.0; s.n()];         q[n.junction] = 2.0;         let (t, _, _) = s.solve(&q, 1e-12, 100);@@ -245,7 +241,7 @@ mod tests {         });         let mut s = System::new(1, 1, 1);         s.leak[0][0] = 0.5;-        let n = p.attach(&mut s, &[0], false);+        let n = p.attach(&mut s, &[0], 0.0, 0.5);         let mut q = vec![0.0; s.n()];         q[0] = 10.0;         q[n.junction] = 1.0;
src/serve.rs+2
@@ -65,6 +65,8 @@ fn handle(mut req: tiny_http::Request, app: Arc<Mutex<App>>) {         ("GET", "/emc.js") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("emc.js", include_str!("ui/emc.js"))).with_header(tiny_http::Header::from_bytes("Content-Type", "application/javascript").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); }         ("GET", "/magnetic.js") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("magnetic.js", include_str!("ui/magnetic.js"))).with_header(tiny_http::Header::from_bytes("Content-Type", "application/javascript").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); }         ("GET", "/cycle.js") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("cycle.js", include_str!("ui/cycle.js"))).with_header(tiny_http::Header::from_bytes("Content-Type", "application/javascript").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); }+        ("GET", "/solids.js") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("solids.js", include_str!("ui/solids.js"))).with_header(tiny_http::Header::from_bytes("Content-Type", "application/javascript").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); }+        ("GET", "/solids.json") => { let file = app.lock().unwrap().dir.join("solids.json"); match std::fs::read(&file) { Ok(bytes) => { let _ = req.respond(tiny_http::Response::from_data(bytes).with_header(jsonh.clone()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); } Err(_) => reply(req, 404, json!({"status": "error", "error": "no solids.json: this solve has no spec solids"})) } }         ("GET", "/noise.json") => { let file = app.lock().unwrap().dir.join("noise.json"); match std::fs::read(&file) { Ok(bytes) => { let _ = req.respond(tiny_http::Response::from_data(bytes).with_header(jsonh.clone()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); } Err(_) => reply(req, 404, json!({"status": "error", "error": "no noise.json: run analyze with 0.7.8 or later"})) } }         ("GET", "/flow.bin") => {             // the signed current, the voltage and the owning net of every cell, for the flow and river views
src/solids.rsadded+249
@@ -0,0 +1,249 @@+//! Solid bodies off the board (a package, an interface pad, a finned heatsink) solved in the same steady heat system as+//! the board copper, so the heat runs from the die through the tab, the pad and the sink to the air, and down the leads+//! into the board, in one solve with one energy balance. Each body is a union of axis-aligned boxes in board millimetres+//! (x and y as KiCad, z up from the top copper face; a body under the board has z below minus the board thickness). The+//! grid keeps every box edge and subdivides to the pitch, so a 0.6 mm pad or a 1.2 mm fin is never rounded away.+//! Bodies touch through declared contacts only; exposed faces lose heat to the air through a prescribed h (not CFD, no+//! radiation); a face lying on the board's outer copper couples to the copper under it; point links (a lead to its pad)+//! join a body to one board cell.+use crate::solve::System;+use serde::{Deserialize, Serialize};+use serde_json::{json, Value};++#[derive(Clone, Deserialize, Serialize)]+#[serde(rename_all = "camelCase", deny_unknown_fields)]+pub struct Body {+    pub name: String,+    /// [x0, y0, z0, x1, y1, z1] in board mm, z up from the top copper face+    pub boxes_mm: Vec<[f64; 6]>,+    pub conductivity_w_mk: f64,+    /// heat generated in the body, spread over its volume+    #[serde(default)] pub watts: Option<f64>,+    /// the hot part (spec `hot`) whose watts this body takes; the board then gets none of that part's heat directly+    #[serde(default)] pub heat_of: Option<String>,+    /// convection on exposed faces, W/m²K, still and moving air (prescribed, e.g. calibrated to a vendor's sink rating)+    pub h_still_w_m2k: f64,+    pub h_moving_w_m2k: f64,+    /// a face lying on the board's outer copper (z = 0 on top, z = -thickness under it) couples to it through this, m²K/W+    #[serde(default)] pub board_contact_m2k_w: Option<f64>,+    #[serde(default)] pub provenance: String,+}+#[derive(Clone, Deserialize, Serialize)]+#[serde(rename_all = "camelCase", deny_unknown_fields)]+pub struct Contact { pub bodies: [String; 2], pub resistance_m2k_w: f64, #[serde(default)] pub provenance: String }+#[derive(Clone, Deserialize, Serialize)]+#[serde(rename_all = "camelCase", deny_unknown_fields)]+pub struct BoardLink { pub body: String, pub at: [f64; 2], pub layer: String, pub resistance_k_per_w: f64, #[serde(default)] pub pad: String }+#[derive(Clone, Deserialize, Serialize)]+#[serde(rename_all = "camelCase", deny_unknown_fields)]+pub struct Solids {+    #[serde(default = "pitch")] pub pitch_mm: f64,+    pub bodies: Vec<Body>,+    #[serde(default)] pub contacts: Vec<Contact>,+    #[serde(default)] pub board_links: Vec<BoardLink>,+    #[serde(default)] pub assumptions: Vec<String>,+}+fn pitch() -> f64 { 1.0 }++/// the voxel grid: breakpoints per axis and the owning body of each cell+pub struct Grid { pub xs: Vec<f64>, pub ys: Vec<f64>, pub zs: Vec<f64>, pub owner: Vec<Option<usize>> }+impl Grid {+    pub fn dims(&self) -> (usize, usize, usize) { (self.xs.len() - 1, self.ys.len() - 1, self.zs.len() - 1) }+    pub fn idx(&self, i: usize, j: usize, k: usize) -> usize { let (nx, ny, _) = self.dims(); (k * ny + j) * nx + i }+}+fn axis(mut cuts: Vec<f64>, pitch: f64) -> Vec<f64> {+    cuts.sort_by(|a, b| a.partial_cmp(b).unwrap()); cuts.dedup_by(|a, b| (*a - *b).abs() < 1e-6);+    let mut out = vec![cuts[0]];+    for w in cuts.windows(2) { let n = ((w[1] - w[0]) / pitch).ceil().max(1.0) as usize; for s in 1..=n { out.push(w[0] + (w[1] - w[0]) * s as f64 / n as f64); } }+    out+}++impl Solids {+    pub fn validate(&self, board: [f64; 4], layers_ok: &dyn Fn(&str) -> bool, hot: &dyn Fn(&str) -> bool) -> Result<Grid, String> {+        if self.bodies.is_empty() { return Err("solids: no bodies".into()); }+        if !(self.pitch_mm.is_finite() && self.pitch_mm >= 0.1) { return Err("solids.pitchMm must be at least 0.1 mm".into()); }+        let _ = board;+        for (i, b) in self.bodies.iter().enumerate() {+            if b.name.trim().is_empty() || self.bodies[..i].iter().any(|o| o.name == b.name) { return Err(format!("solids: body {i} needs a unique name")); }+            if b.boxes_mm.is_empty() || b.boxes_mm.iter().any(|x| x.iter().any(|v| !v.is_finite()) || x[3] <= x[0] || x[4] <= x[1] || x[5] <= x[2]) { return Err(format!("solids: {}: every box is [x0, y0, z0, x1, y1, z1] with x1 > x0, y1 > y0, z1 > z0", b.name)); }+            if !(b.conductivity_w_mk.is_finite() && b.conductivity_w_mk > 0.0) || !(b.h_still_w_m2k.is_finite() && b.h_still_w_m2k >= 0.0) || !(b.h_moving_w_m2k.is_finite() && b.h_moving_w_m2k >= 0.0) { return Err(format!("solids: {}: conductivityWMk must be positive and hStillWM2k / hMovingWM2k not negative", b.name)); }+            if b.watts.is_some_and(|w| !w.is_finite() || w < 0.0) { return Err(format!("solids: {}: watts must be finite and not negative", b.name)); }+            if b.board_contact_m2k_w.is_some_and(|r| !r.is_finite() || r < 0.0) { return Err(format!("solids: {}: boardContactM2kW must be finite and not negative", b.name)); }+            if let Some(h) = &b.heat_of { if !hot(h) { return Err(format!("solids: {}: heatOf {h} is not in the spec's hot parts", b.name)); } if b.watts.is_some() { return Err(format!("solids: {}: give watts or heatOf, not both (the heat would count twice)", b.name)); } }+        }+        for c in &self.contacts {+            for n in &c.bodies { if !self.bodies.iter().any(|b| b.name == *n) { return Err(format!("solids: contact names {n}, which is not a body")); } }+            if c.bodies[0] == c.bodies[1] || !(c.resistance_m2k_w.is_finite() && c.resistance_m2k_w >= 0.0) { return Err(format!("solids: contact {:?} needs two different bodies and a resistance of zero or more", c.bodies)); }+        }+        for l in &self.board_links {+            if !self.bodies.iter().any(|b| b.name == l.body) { return Err(format!("solids: board link names {}, which is not a body", l.body)); }+            if !layers_ok(&l.layer) || !(l.resistance_k_per_w.is_finite() && l.resistance_k_per_w > 0.0) || l.at[0] < board[0] || l.at[0] > board[2] || l.at[1] < board[1] || l.at[1] > board[3] { return Err(format!("solids: board link from {} needs a copper layer of the board, a point on the board and a positive resistanceKPerW", l.body)); }+        }+        let all = || self.bodies.iter().flat_map(|b| b.boxes_mm.iter());+        let xs = axis(all().flat_map(|x| [x[0], x[3]]).collect(), self.pitch_mm);+        let ys = axis(all().flat_map(|x| [x[1], x[4]]).collect(), self.pitch_mm);+        let zs = axis(all().flat_map(|x| [x[2], x[5]]).collect(), self.pitch_mm);+        let n = (xs.len() - 1) * (ys.len() - 1) * (zs.len() - 1);+        if n > 3_000_000 { return Err(format!("solids: the grid would have {n} cells; raise pitchMm")); }+        let mut g = Grid { xs, ys, zs, owner: vec![None; n] };+        let (nx, ny, nz) = g.dims();+        for k in 0..nz { for j in 0..ny { for i in 0..nx {+            let c = [(g.xs[i] + g.xs[i + 1]) / 2.0, (g.ys[j] + g.ys[j + 1]) / 2.0, (g.zs[k] + g.zs[k + 1]) / 2.0];+            let mut who: Option<usize> = None;+            for (bi, b) in self.bodies.iter().enumerate() { if b.boxes_mm.iter().any(|x| c[0] > x[0] && c[0] < x[3] && c[1] > x[1] && c[1] < x[4] && c[2] > x[2] && c[2] < x[5]) {+                if let Some(o) = who { if o != bi { return Err(format!("solids: {} and {} overlap at ({:.2}, {:.2}, {:.2}); bodies may touch but not overlap", self.bodies[o].name, b.name, c[0], c[1], c[2])); } } who = Some(bi);+            } }+            let id = g.idx(i, j, k); g.owner[id] = who;+        } } }+        for (bi, b) in self.bodies.iter().enumerate() { if !g.owner.iter().any(|o| *o == Some(bi)) { return Err(format!("solids: {} has no cell on the grid", b.name)); } }+        Ok(g)+    }+    fn contact(&self, a: usize, b: usize) -> Option<f64> { let (na, nb) = (&self.bodies[a].name, &self.bodies[b].name); self.contacts.iter().find(|c| (c.bodies[0] == *na && c.bodies[1] == *nb) || (c.bodies[0] == *nb && c.bodies[1] == *na)).map(|c| c.resistance_m2k_w * 1e6) }+}++/// what the solve needs to read the bodies back: the node of each owned cell and every conductance by kind+pub struct Attached {+    pub cells: Vec<(usize, usize, [f64; 6])>, // (node, body, box)+    pub faces: Vec<(usize, usize, f64, usize, f64)>, // (cell a, cell b, conductance W/K, axis, face area mm²) between owned cells+    pub air: Vec<(usize, f64)>,                     // (cell, conductance to ambient)+    pub board: Vec<(usize, usize, f64)>,            // (board node, cell, conductance)+    pub sources: Vec<(usize, f64)>,                 // (node, watts)+}++#[allow(clippy::too_many_arguments)]+pub fn attach(sys: &mut System, s: &Solids, g: &Grid, level: f64, fan_n: f64, x0: f64, y0: f64, d: f64, h_air: f64, thickness: f64, hot_watts: &dyn Fn(&str) -> f64) -> Result<Attached, String> {+    let (nx, ny, nz) = g.dims(); let (w, h) = (sys.w, sys.h); let nb = sys.nl * w * h;+    let mut cell_of = vec![usize::MAX; g.owner.len()]; let mut a = Attached { cells: Vec::new(), faces: Vec::new(), air: Vec::new(), board: Vec::new(), sources: Vec::new() };+    let kmm = |b: usize| s.bodies[b].conductivity_w_mk / 1000.0;+    let hmm = |b: usize| crate::fields::fan_mix(s.bodies[b].h_still_w_m2k, s.bodies[b].h_moving_w_m2k, level, fan_n) * 1e-6;+    let span = |v: &Vec<f64>, i: usize| v[i + 1] - v[i];+    for k in 0..nz { for j in 0..ny { for i in 0..nx { let id = g.idx(i, j, k); if let Some(b) = g.owner[id] {+        let node = sys.add_node(0.0); cell_of[id] = a.cells.len(); a.cells.push((node, b, [g.xs[i], g.ys[j], g.zs[k], g.xs[i + 1], g.ys[j + 1], g.zs[k + 1]]));+    } } } }+    let mut leak = vec![0.0f64; a.cells.len()];+    let board_cells = |bx: [f64; 4]| -> Vec<(usize, f64)> {+        let mut v = Vec::new();+        let (c0, c1) = (((bx[0] - x0) / d).floor().max(0.0) as usize, (((bx[2] - x0) / d).ceil().max(0.0) as usize).min(w));+        let (r0, r1) = (((bx[1] - y0) / d).floor().max(0.0) as usize, (((bx[3] - y0) / d).ceil().max(0.0) as usize).min(h));+        for r in r0..r1 { for c in c0..c1 { let (xa, xb) = ((x0 + c as f64 * d).max(bx[0]), (x0 + (c + 1) as f64 * d).min(bx[2])); let (ya, yb) = ((y0 + r as f64 * d).max(bx[1]), (y0 + (r + 1) as f64 * d).min(bx[3])); if xb > xa && yb > ya { v.push((r * w + c, (xb - xa) * (yb - ya))); } } }+        v+    };+    for k in 0..nz { for j in 0..ny { for i in 0..nx {+        let id = g.idx(i, j, k); let Some(b) = g.owner[id] else { continue }; let ci = cell_of[id];+        let (dx, dy, dz) = (span(&g.xs, i), span(&g.ys, j), span(&g.zs, k));+        // the three positive-side neighbours: conduction or a declared contact; the exposed faces lose heat to the air+        for (ax, nb_ijk, area, half, half_n) in [+            (0usize, (i + 1 < nx).then(|| g.idx(i + 1, j, k)), dy * dz, dx / 2.0, if i + 1 < nx { span(&g.xs, i + 1) / 2.0 } else { 0.0 }),+            (1, (j + 1 < ny).then(|| g.idx(i, j + 1, k)), dx * dz, dy / 2.0, if j + 1 < ny { span(&g.ys, j + 1) / 2.0 } else { 0.0 }),+            (2, (k + 1 < nz).then(|| g.idx(i, j, k + 1)), dx * dy, dz / 2.0, if k + 1 < nz { span(&g.zs, k + 1) / 2.0 } else { 0.0 }),+        ] {+            match nb_ijk.and_then(|q| g.owner[q].map(|o| (q, o))) {+                Some((q, o)) => {+                    let rc = if o == b { 0.0 } else { s.contact(b, o).ok_or_else(|| format!("solids: {} and {} touch but have no contact; declare one (resistanceM2kW 0 means ideal)", s.bodies[b].name, s.bodies[o].name))? / area };+                    let gc = 1.0 / (half / (kmm(b) * area) + half_n / (kmm(o) * area) + rc);+                    let cj = cell_of[q]; sys.edges.push((a.cells[ci].0, a.cells[cj].0, gc)); a.faces.push((ci, cj, gc, ax, area));+                }+                None => { let hh = hmm(b); if hh > 0.0 { leak[ci] += area / (1.0 / hh + half / kmm(b)); } }+            }+        }+        for (ax, prev, area, half) in [(0usize, (i > 0).then(|| g.idx(i - 1, j, k)), dy * dz, dx / 2.0), (1, (j > 0).then(|| g.idx(i, j - 1, k)), dx * dz, dy / 2.0), (2, (k > 0).then(|| g.idx(i, j, k - 1)), dx * dy, dz / 2.0)] {+            if prev.and_then(|q| g.owner[q]).is_some() { continue; }+            let z_lo = g.zs[k]; let z_hi = g.zs[k + 1];+            // a face on the board's outer copper: the top face of a body under the board, or the bottom face of one on it+            let on_board = ax == 2 && s.bodies[b].board_contact_m2k_w.is_some() && (z_lo.abs() < 1e-6);+            if on_board {+                let r = s.bodies[b].board_contact_m2k_w.unwrap() * 1e6;+                for (bc, ar) in board_cells([g.xs[i], g.ys[j], g.xs[i + 1], g.ys[j + 1]]) { let gc = ar / (r + half / kmm(b)); sys.edges.push((bc, a.cells[ci].0, gc)); a.board.push((bc, ci, gc)); sys.leak[0][bc] = (sys.leak[0][bc] - h_air * ar).max(1e-12); }+                continue;+            }+            let _ = z_hi;+            let hh = hmm(b); if hh > 0.0 { leak[ci] += area / (1.0 / hh + half / kmm(b)); }+        }+        // the top face of a body lying under the board touches B.Cu+        if s.bodies[b].board_contact_m2k_w.is_some() && (g.zs[k + 1] + thickness).abs() < 1e-6 && (k + 1 >= nz || g.owner[g.idx(i, j, k + 1)].is_none()) {+            let r = s.bodies[b].board_contact_m2k_w.unwrap() * 1e6; let bl = sys.nl - 1;+            // that face was counted as exposed above; take its air loss back+            let hh = hmm(b); if hh > 0.0 { leak[ci] -= dx * dy / (1.0 / hh + dz / 2.0 / kmm(b)); }+            for (bc, ar) in board_cells([g.xs[i], g.ys[j], g.xs[i + 1], g.ys[j + 1]]) { let node = bl * w * h + bc; let gc = ar / (r + dz / 2.0 / kmm(b)); sys.edges.push((node, a.cells[ci].0, gc)); a.board.push((node, ci, gc)); sys.leak[bl][bc] = (sys.leak[bl][bc] - h_air * ar).max(1e-12); }+        }+    } } }+    for (ci, l) in leak.iter().enumerate() { if *l > 0.0 { let node = a.cells[ci].0; sys.extra_leak[node - nb] += l; a.air.push((ci, *l)); } }+    // point links: from the body's cell nearest the board point to that board cell+    for l in &s.board_links {+        let bi = s.bodies.iter().position(|b| b.name == l.body).unwrap();+        let li = if l.layer == "F.Cu" { 0 } else { sys.nl - 1 };+        let zt = if li == 0 { 0.0 } else { -thickness };+        let ci = a.cells.iter().enumerate().filter(|(_, c)| c.1 == bi).min_by(|p, q| { let dd = |c: &[f64; 6]| ((c[0] + c[3]) / 2.0 - l.at[0]).hypot((c[1] + c[4]) / 2.0 - l.at[1]).hypot((c[2] + c[5]) / 2.0 - zt); dd(&p.1 .2).partial_cmp(&dd(&q.1 .2)).unwrap() }).map(|(i, _)| i).unwrap();+        let (c, r) = (((l.at[0] - x0) / d) as usize, ((l.at[1] - y0) / d) as usize);+        let bn = li * w * h + r.min(h - 1) * w + c.min(w - 1); let gc = 1.0 / l.resistance_k_per_w;+        sys.edges.push((bn, a.cells[ci].0, gc)); a.board.push((bn, ci, gc));+    }+    // each body's heat, spread over its volume+    for (bi, b) in s.bodies.iter().enumerate() {+        let watts = b.watts.unwrap_or(0.0) + b.heat_of.as_deref().map_or(0.0, hot_watts);+        if watts <= 0.0 { continue; }+        let vol: f64 = a.cells.iter().filter(|c| c.1 == bi).map(|c| (c.2[3] - c.2[0]) * (c.2[4] - c.2[1]) * (c.2[5] - c.2[2])).sum();+        for c in a.cells.iter().filter(|c| c.1 == bi) { a.sources.push((c.0, watts * (c.2[3] - c.2[0]) * (c.2[4] - c.2[1]) * (c.2[5] - c.2[2]) / vol)); }+    }+    Ok(a)+}++fn r3(x: f64) -> f64 { (x * 1000.0).round() / 1000.0 }++impl Attached {+    /// per cell: temperature and the heat flux vector (W/mm², from the face flows), and per body: the extremes and where+    /// its heat went+    pub fn result(&self, s: &Solids, t: &[f64]) -> (Value, Value) {+        let n = self.cells.len();+        let mut q = vec![[0.0f64; 3]; n];+        for &(a, b, gc, ax, area) in &self.faces { let f = gc * (t[self.cells[a].0] - t[self.cells[b].0]) / area; q[a][ax] += f / 2.0; q[b][ax] += f / 2.0; }+        let mut bodies = Vec::new();+        for (bi, b) in s.bodies.iter().enumerate() {+            let cells: Vec<usize> = (0..n).filter(|i| self.cells[*i].1 == bi).collect();+            let tt: Vec<f64> = cells.iter().map(|i| t[self.cells[*i].0]).collect();+            let air: f64 = self.air.iter().filter(|(c, _)| self.cells[*c].1 == bi).map(|(c, g)| g * t[self.cells[*c].0]).sum();+            let board: f64 = self.board.iter().filter(|(_, c, _)| self.cells[*c].1 == bi).map(|(bn, c, g)| g * (t[self.cells[*c].0] - t[*bn])).sum();+            let src: f64 = self.sources.iter().filter(|(nd, _)| cells.iter().any(|c| self.cells[*c].0 == *nd)).map(|(_, w)| w).sum();+            let qmax = cells.iter().map(|i| (q[*i][0].powi(2) + q[*i][1].powi(2) + q[*i][2].powi(2)).sqrt()).fold(0.0, f64::max);+            bodies.push(json!({"name": b.name, "cells": cells.len(), "maxRiseC": r3(tt.iter().cloned().fold(f64::MIN, f64::max)), "minRiseC": r3(tt.iter().cloned().fold(f64::MAX, f64::min)), "sourceW": r3(src), "toAirW": r3(air), "toBoardW": r3(board), "peakFluxWPerMm2": r3(qmax)}));+        }+        let per_cell = json!({"t": (0..n).map(|i| (t[self.cells[i].0] * 100.0).round() / 100.0).collect::<Vec<_>>(), "q": q.iter().map(|v| [ (v[0] * 1e5).round() / 1e5, (v[1] * 1e5).round() / 1e5, (v[2] * 1e5).round() / 1e5 ]).collect::<Vec<_>>()});+        (json!(bodies), per_cell)+    }+}++#[cfg(test)]+mod tests {+    use super::*;+    fn body(name: &str, bx: [f64; 6], k: f64, h: f64) -> Body { Body { name: name.into(), boxes_mm: vec![bx], conductivity_w_mk: k, watts: None, heat_of: None, h_still_w_m2k: h, h_moving_w_m2k: h, board_contact_m2k_w: None, provenance: String::new() } }+    #[test]+    fn slab_over_its_top_face_matches_the_series_network() {+        // a 10 x 10 x 10 mm block, 1 W in it, only its top face convecting: T_mean - T_top = q L / 2k-ish; check the energy+        let mut b = body("slab", [0., 0., 0., 10., 10., 10.], 10.0, 0.0); b.watts = Some(1.0);+        let mut top = body("lid", [0., 0., 10., 10., 10., 11.], 200.0, 100.0); top.provenance = "test".into();+        let s = Solids { pitch_mm: 1.0, bodies: vec![b, top], contacts: vec![Contact { bodies: ["slab".into(), "lid".into()], resistance_m2k_w: 0.0, provenance: String::new() }], board_links: vec![], assumptions: vec![] };+        let g = s.validate([0., 0., 1., 1.], &|_| true, &|_| false).unwrap();+        let mut sys = System::new(1, 2, 2); sys.leak[0] = vec![1e-6; 4];+        let a = attach(&mut sys, &s, &g, 0.0, 0.5, 100.0, 100.0, 0.5, 1e-5, 1.6, &|_| 0.0).unwrap();+        let mut rhs = vec![0.0; sys.n()]; for (nd, w) in &a.sources { rhs[*nd] += w; }+        let (t, _, r) = sys.solve(&rhs, 1e-10, 20000); assert!(r < 1e-8);+        let (bodies, _) = a.result(&s, &t);+        let air: f64 = bodies.as_array().unwrap().iter().map(|b| b["toAirW"].as_f64().unwrap()).sum();+        assert!((air - 1.0).abs() < 2e-3, "{air}");+        // the lid convects on its top and its four 1 mm sides: 1 W over 140 mm² at 100 W/m²K is 71.4 K, plus a little conduction+        let lid = bodies[1]["maxRiseC"].as_f64().unwrap(); assert!(lid > 71.0 && lid < 73.0, "{lid}");+        assert!(bodies[0]["maxRiseC"].as_f64().unwrap() > lid);+    }+    #[test]+    fn touching_bodies_need_a_contact() {+        let s = Solids { pitch_mm: 1.0, bodies: vec![body("a", [0., 0., 0., 1., 1., 1.], 1.0, 1.0), body("b", [1., 0., 0., 2., 1., 1.], 1.0, 1.0)], contacts: vec![], board_links: vec![], assumptions: vec![] };+        let g = s.validate([0., 0., 1., 1.], &|_| true, &|_| false).unwrap();+        let mut sys = System::new(1, 2, 2);+        assert!(attach(&mut sys, &s, &g, 0.0, 0.5, 0.0, 0.0, 0.5, 1e-5, 1.6, &|_| 0.0).is_err());+    }+    #[test]+    fn overlapping_bodies_are_refused() {+        let s = Solids { pitch_mm: 1.0, bodies: vec![body("a", [0., 0., 0., 2., 1., 1.], 1.0, 1.0), body("b", [1., 0., 0., 3., 1., 1.], 1.0, 1.0)], contacts: vec![], board_links: vec![], assumptions: vec![] };+        assert!(s.validate([0., 0., 1., 1.], &|_| true, &|_| false).is_err());+    }+}
src/ui/app.js+32−9
@@ -37,6 +37,10 @@    let board = null, root = null, viaRoot = null, labels = [];   // z up: x to the right, y up the board (KiCad's y runs down), z out of the top copper+  // fan levels (spec fanLevels): the air control becomes Fan off / 25 / 50 / 75 / 100 %, each its own solve+  function fanKey(air) { const fs = state.fields && state.fields.airStates; if (!fs) return null; if (air === 'still') return 'fan0'; if (air === 'moving') return 'fan100'; return fs.levels.some(l => l.name === air) ? air : null; }+  function riseOf(c) { const k = fanKey(state.view.air); if (k) { const l = state.fields.airStates.levels.find(x => x.name === k); const r = l && l.chips.find(x => x.reference === c.reference); if (r) return r.riseC; } return state.view.air === 'moving' ? c.riseMovingC : c.riseC; }+  function airLabel() { const k = fanKey(state.view.air); return k ? (k === 'fan0' ? 'fan off' : 'fan ' + k.slice(3) + ' %') : state.view.air + ' air'; }   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;@@ -51,6 +55,8 @@     // the layer buttons: the board's own copper     const seg = document.getElementById('layer'); if (seg) seg.innerHTML = realCu.map(L => `<button data-l="${L}">${L === 'F.Cu' || L === 'B.Cu' ? L : L.replace('.Cu', '')}</button>`).join(''); +    if (f.airStates) { const seg = document.getElementById('air'); const h3 = document.querySelector('#airSec h3'); if (h3) h3.textContent = 'Fan';+      if (seg) seg.innerHTML = f.airStates.levels.map(l => `<button data-a="${l.name}" data-tip="${l.level === 0 ? 'Fan off: still air' : 'Fan at ' + Math.round(l.level * 100) + ' %'}, h ${l.hBoardWM2k} W/m²K on the board">${l.level === 0 ? 'Off' : Math.round(l.level * 100) + '%'}</button>`).join(''); }     const W = board.widthMm, H = board.heightMm, T = board.thicknessMm;     root = new BAB.TransformNode('fields-board', scene);     const box = BAB.MeshBuilder.CreateBox('fr4_board', { width: W, height: H, depth: T }, scene);@@ -176,6 +182,8 @@     // hot-part labels ride on top of their part, not inside it     for (const l of labels) { const ms = parts.byRef[l.c.reference]; if (!ms || !ms.length) continue; let top = -1e9; for (const m of ms) { m.computeWorldMatrix(true); top = Math.max(top, m.getBoundingInfo().boundingBox.maximumWorld.z); } l.at.z = Math.max(l.at.z, top + 0.4); }     parts.ready = true;+    // the parts change what the scene holds: re-frame the default view around them+    if (state.view.camera === 'iso' && !tweening) setTimeout(() => fitCamera('iso'), 0);     console.log(`parts: ${n} models placed, ${parts.boxes.length} courtyard boxes${parts.boxes.length ? ' (' + parts.boxes.join(', ') + ')' : ''}`);     applyParts();   }@@ -328,11 +336,22 @@     // an exploded stack is taller than the board is wide: back off so the whole stack stays in frame     const R = Math.max((board.heightMm / 2) / Math.tan(vf / 2), (board.widthMm / 2) / Math.tan(hf / 2)) * 1.06 * (1 + 0.55 * Math.max(0, Math.min(1, +state.view.explode || 0)));     const home = BAB.Vector3.Zero();+    // everything in the scene, not just the board: the solved bodies (a heatsink stands 40 mm tall), the parts, an+    // exploded stack. The iso view and Fit frame its bounding sphere, so the drift's sway never turns any of it out of frame+    const lo = new BAB.Vector3(Infinity, Infinity, Infinity), hi = new BAB.Vector3(-Infinity, -Infinity, -Infinity);+    if (root) for (const m of root.getChildMeshes(false)) {+      if (!m.isEnabled() || !m.isVisible || !m.getTotalVertices || m.getTotalVertices() === 0) continue;+      m.computeWorldMatrix(true); const bb = m.getBoundingInfo().boundingBox;+      lo.minimizeInPlace(bb.minimumWorld); hi.maximizeInPlace(bb.maximumWorld);+    }+    const sphere = isFinite(lo.x) ? { c: lo.add(hi).scale(0.5), r: hi.subtract(lo).length() / 2 } : null;+    const Rs = sphere ? sphere.r / Math.sin(Math.min(vf, hf) / 2) * 1.04 : 0;     const flipping = (which === 'bottom' && camera.beta < Math.PI / 2) || (which === 'top' && camera.beta > Math.PI / 2);     const s = flipping ? 2.6 : 9.0;   // the turn-over is quick, everything else is slow     if (which === 'top') goto(NORTH, 0.02, R, home, s);     else if (which === 'bottom') goto(NORTH, Math.PI - 0.02, R, home, s);-    else if (which === 'iso') goto(NORTH - 0.55, 0.95, R * 1.08, home, s);+    else if (which === 'iso') { if (sphere && sphere.r > Math.hypot(board.widthMm, board.heightMm, board.thicknessMm) / 2 * 1.02) goto(NORTH - 0.55, 0.95, Rs, sphere.c, s); else goto(NORTH - 0.55, 0.95, R * 1.08, home, s); }+    else if (sphere) goto(camera.alpha, camera.beta, Rs, sphere.c, s);     else { tweens = []; tweening = 0; V.goHome(); }   }   function zoomTo(x, y, fromBelow) {@@ -348,7 +367,9 @@     if (q === 'signals') return `copper-${ls}.png`;     if (q === 'river' || q === 'energy' || q === 'light' || q === 'cycle' || q === 'charge' || q === 'magnetic' || q === 'emc' || q === 'ground' || q === 'noise' || q === 'pdn') return `copper-${ls}.png`;     if (q === 'current' || q === 'flow') return state.view.net ? `J-${slug(state.view.net)}-${ls}.png` : `J-${ls}.png`;-    if (q === 'heat') { const iso = state.view.net && (state.fields.pourNets || []).some(p => p.net === state.view.net); return iso ? `q-${slug(state.view.net)}-${ls}.png` : `q-${ls}.png`; }+    const fk = fanKey(state.view.air);+    if (q === 'heat') { const iso = state.view.net && (state.fields.pourNets || []).some(p => p.net === state.view.net); return iso ? `q-${slug(state.view.net)}-${ls}.png` : fk ? `q-${fk}-${ls}.png` : `q-${ls}.png`; }+    if (fk) return `T-${fk}-${ls}.png`;     return (state.view.air === 'moving' ? 'Tm-' : 'T-') + ls + '.png';   }   function applyTextures() {@@ -361,7 +382,7 @@       if (L !== 'F.Cu' && L !== 'B.Cu') m.alpha = (L === selected) ? 1.0 : 0.0;     }     if (viaRoot) viaRoot.setEnabled(!['temperature', 'river', 'signals'].includes(state.view.quantity));-    for (const { el, c } of labels) { el.hidden = ['current','signals','flow','river','energy','light','cycle','charge','magnetic','emc','ground','noise','pdn'].includes(state.view.quantity); el.textContent = c.reference + '  +' + (state.view.air === 'moving' ? c.riseMovingC : c.riseC).toFixed(0) + ' °C'; }+    for (const { el, c } of labels) { el.hidden = ['current','signals','flow','river','energy','light','cycle','charge','magnetic','emc','ground','noise','pdn'].includes(state.view.quantity); el.textContent = c.reference + '  +' + riseOf(c).toFixed(0) + ' °C'; }     // each view's tags and panel show only while it is the view, even if its animation loop is paused (a background tab)     document.body.dataset.view = state.view.quantity;     $('#signalsPanel').hidden = state.view.quantity !== 'signals';@@ -369,12 +390,13 @@     $('#readout').hidden = state.view.quantity === 'signals';     const sc = state.fields.scales, leg = $('#legend'); leg.classList.toggle('magma', state.view.quantity === 'temperature');     if (state.view.quantity === 'current' || state.view.quantity === 'flow') { $('#legendTitle').textContent = 'Current density, A/mm² (log)'; $('#legLo').textContent = (sc.jHi / 300).toFixed(1); $('#legHi').textContent = sc.jHi.toFixed(0); }-    else if (state.view.quantity === 'heat') { $('#legendTitle').textContent = 'Heat flow in the copper, W/mm (log)'; $('#legLo').textContent = (sc.qHi / 300).toExponential(1); $('#legHi').textContent = sc.qHi.toFixed(3); }-    else { $('#legendTitle').textContent = 'Rise above ambient, °C (' + state.view.air + ' air)'; $('#legLo').textContent = '0'; $('#legHi').textContent = sc.tHi.toFixed(0); }-    $('#airSec').style.display = state.view.quantity === 'temperature' ? '' : 'none';+    else if (state.view.quantity === 'heat') { $('#legendTitle').textContent = 'Heat flow in the copper, W/mm (log' + (fanKey(state.view.air) ? ', ' + airLabel() : '') + ')'; $('#legLo').textContent = (sc.qHi / 300).toExponential(1); $('#legHi').textContent = sc.qHi.toFixed(3); }+    else { $('#legendTitle').textContent = 'Rise above ambient, °C (' + airLabel() + ')'; $('#legLo').textContent = '0'; $('#legHi').textContent = sc.tHi.toFixed(0); }+    $('#airSec').style.display = state.view.quantity === 'temperature' || (state.view.quantity === 'heat' && state.fields.airStates) ? '' : 'none';     applyParts();     loadRaw();     for (const b of document.querySelectorAll('#btnExplode')) { const on = (+state.view.explode || 0) > 0.3; b.classList.toggle('is-on', on); b.setAttribute('aria-pressed', on); }+    if (window.fieldsSolids) window.fieldsSolids.apply(state.view);     if (window.fieldsFlow) window.fieldsFlow.apply(state.view);     if (window.fieldsNoise) {       window.fieldsNoise.apply(state.view);@@ -407,7 +429,7 @@     else { const bd = $('#flowBadge'); if (bd) { bd.hidden = !['flow', 'river', 'energy', 'light'].includes(state.view.quantity); bd.textContent = flowNote; } }   }   let flowNote = 'Loading the flow data';-  function rawName(L) { const q = state.view.quantity, ls = slug(L); return (q === 'current' || q === 'flow' || q === 'river' ? 'J-' : q === 'heat' ? 'q-' : (state.view.air === 'moving' ? 'Tm-' : 'T-')) + ls + '.raw.png'; }+  function rawName(L) { const q = state.view.quantity, ls = slug(L), fk = fanKey(state.view.air); if (fk && (q === 'heat' || q === 'temperature') && !(q === 'heat' && state.view.net)) return (q === 'heat' ? 'q-' : 'T-') + fk + '-' + ls + '.raw.png'; return (q === 'current' || q === 'flow' || q === 'river' ? 'J-' : q === 'heat' ? 'q-' : (state.view.air === 'moving' ? 'Tm-' : 'T-')) + ls + '.raw.png'; }   function loadRaw() {     for (const L of ['F.Cu', 'B.Cu']) {       const name = rawName(L); if (state.raw[name]) continue; state.raw[name] = { pending: true };@@ -435,11 +457,11 @@     const f = state.fields, v = state.view;     for (const b of document.querySelectorAll('#qty button, #qty2 button')) { b.classList.toggle('is-on', b.dataset.q === v.quantity); b.setAttribute('aria-pressed', b.dataset.q === v.quantity); }     for (const b of document.querySelectorAll('#layer button')) { const on = b.dataset.l === (v.layer || 'F.Cu'); b.classList.toggle('is-on', on); b.setAttribute('aria-pressed', on); }-    for (const b of document.querySelectorAll('#air button')) { b.classList.toggle('is-on', b.dataset.a === v.air); b.setAttribute('aria-pressed', b.dataset.a === v.air); }+    for (const b of document.querySelectorAll('#air button')) { const on = b.dataset.a === v.air || (fanKey(v.air) && b.dataset.a === fanKey(v.air)); b.classList.toggle('is-on', !!on); b.setAttribute('aria-pressed', !!on); }     for (const b of document.querySelectorAll('.views .chip[data-cam]')) 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>';-    const chipRise = c => v.air === 'moving' ? c.riseMovingC : c.riseC;+    const chipRise = c => riseOf(c);     $('#chips').innerHTML = [...f.chips].sort((a,b) => chipRise(b)-chipRise(a)).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>';     $('#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();@@ -516,6 +538,7 @@     $('#ver').textContent = '';     buildBoard(f);     // the flow and the river: charge streaming through the copper, every net lifted to its voltage+    import('./solids.js').then(m => m.attach({ BAB, scene, state, Z, root })).then(sd => { window.fieldsSolids = sd; applyTextures(); if (sd && state.view.camera && state.view.camera !== 'zoom') fitCamera(state.view.camera); }).catch(e => console.error('solids: ' + (e && e.stack || e)));     import('./flow.js').then(m => m.attach({ BAB, scene, V, camera, state, Z, LAYERS, frameDt, root, layerMeshes: state.layerMeshes, clock }))       .then(api => { window.fieldsFlow = api; if (!api) flowNote = 'This solve has no flow data: run adom-fields analyze again with 0.7 or later.'; applyTextures();         // the energy cycle of a switching converter, on the flow view's shaders
src/ui/solids.jsadded+110
@@ -0,0 +1,110 @@+// Adom Fields: the solid bodies off the board (a package, its pad, a finned heatsink) solved with the board's heat+// (spec `solids`). Each body is drawn from its solve cells: every face that is not shared with a cell of the same body+// (the outside, and the faces where it meets another body), coloured by that cell's heat flux in the heat view and by+// its temperature in the temperature view, on the same palettes as the copper. The heatsink is drawn see-through so+// the package and pad under it show; hiding a body reveals the interface faces of the one it sits on.+const INFERNO = [[0.001, 0.0, 0.014], [0.16, 0.04, 0.33], [0.38, 0.09, 0.44], [0.59, 0.19, 0.38], [0.79, 0.30, 0.26], [0.93, 0.47, 0.11], [0.98, 0.68, 0.06], [0.97, 0.87, 0.30], [0.99, 1.0, 0.64]];+const MAGMA = [[0.001, 0.0, 0.014], [0.11, 0.06, 0.28], [0.29, 0.06, 0.48], [0.47, 0.11, 0.51], [0.66, 0.18, 0.47], [0.83, 0.28, 0.40], [0.96, 0.44, 0.36], [0.99, 0.65, 0.47], [0.99, 0.99, 0.75]];+function ramp(t, cm) { t = Math.min(1, Math.max(0, t)) * 8; const i = Math.min(7, Math.floor(t)), f = t - i, a = cm[i], b = cm[i + 1]; return [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f]; }++export async function attach(ctx) {+  const { BAB, scene, state, Z, root } = ctx;+  const meta = state.fields.solids;+  if (!meta || !meta.file) return null;+  const S = await fetch(meta.file).then(r => { if (!r.ok) throw new Error(meta.file + ': HTTP ' + r.status); return r.json(); });+  const board = state.fields.board, T = board.thicknessMm;+  const cells = S.cells, n = cells.length;+  const key = (a, b, c) => Math.round(a * 1000) + ',' + Math.round(b * 1000) + ',' + Math.round(c * 1000);+  const lo = new Map(); cells.forEach((c, i) => lo.set(key(c[0], c[1], c[2]), i));+  const bx = (x) => x - board.x0 - board.widthMm / 2, by = (y) => board.y0 + board.heightMm / 2 - y, bz = (z) => T / 2 + z;+  // the faces of one body: [x0,y0,z0,x1,y1,z1] corners per axis and side, with a shade so the shape reads in 3D+  const faceDefs = [+    { nb: (c) => key(c[3], c[1], c[2]), quad: (c) => [[c[3], c[1], c[2]], [c[3], c[4], c[2]], [c[3], c[4], c[5]], [c[3], c[1], c[5]]], shade: 0.78 },+    { back: 0, quad: (c) => [[c[0], c[1], c[2]], [c[0], c[1], c[5]], [c[0], c[4], c[5]], [c[0], c[4], c[2]]], shade: 0.70 },+    { nb: (c) => key(c[0], c[4], c[2]), quad: (c) => [[c[0], c[4], c[2]], [c[0], c[4], c[5]], [c[3], c[4], c[5]], [c[3], c[4], c[2]]], shade: 0.86 },+    { back: 1, quad: (c) => [[c[0], c[1], c[2]], [c[3], c[1], c[2]], [c[3], c[1], c[5]], [c[0], c[1], c[5]]], shade: 0.66 },+    { nb: (c) => key(c[0], c[1], c[5]), quad: (c) => [[c[0], c[1], c[5]], [c[3], c[1], c[5]], [c[3], c[4], c[5]], [c[0], c[4], c[5]]], shade: 1.0 },+    { back: 2, quad: (c) => [[c[0], c[1], c[2]], [c[0], c[4], c[2]], [c[3], c[4], c[2]], [c[3], c[1], c[2]]], shade: 0.55 },+  ];+  // the negative-side neighbour: the cell whose far corner on that axis is this cell's near one+  const hi = [new Map(), new Map(), new Map()];+  cells.forEach((c, i) => { hi[0].set(key(c[3], c[1], c[2]), i); hi[1].set(key(c[0], c[4], c[2]), i); hi[2].set(key(c[0], c[1], c[5]), i); });+  const neighbour = (c, f) => {+    if (f.back === undefined) { const j = lo.get(f.nb(c)); return j === undefined ? -1 : j; }+    const j = hi[f.back].get(key(c[0], c[1], c[2])); return j === undefined ? -1 : j;+  };+  const bodies = S.bodies.map((name, bi) => {+    const pos = [], idx = [], cellOf = [], shade = [];+    for (let i = 0; i < n; i++) {+      const c = cells[i]; if (c[6] !== bi) continue;+      for (const f of faceDefs) {+        const j = neighbour(c, f); if (j >= 0 && cells[j][6] === bi) continue;+        const v0 = pos.length / 3;+        for (const p of f.quad(c)) pos.push(bx(p[0]), by(p[1]), bz(p[2]));+        idx.push(v0, v0 + 1, v0 + 2, v0, v0 + 2, v0 + 3);+        cellOf.push(i); shade.push(f.shade);+      }+    }+    const mesh = new BAB.Mesh('solid-' + name, scene);+    const vd = new BAB.VertexData(); vd.positions = pos; vd.indices = idx;+    const norms = []; BAB.VertexData.ComputeNormals(pos, idx, norms); vd.normals = norms;+    vd.colors = new Array(pos.length / 3 * 4).fill(1);+    vd.applyToMesh(mesh, true);+    const mat = new BAB.StandardMaterial('solid-mat-' + name, scene);+    mat.disableLighting = true; mat.emissiveColor = new BAB.Color3(1, 1, 1); mat.diffuseColor = new BAB.Color3(0, 0, 0); mat.backFaceCulling = false;+    const sink = /heat ?sink/i.test(name);+    if (sink) { mat.alpha = 0.62; mat.needDepthPrePass = true; }+    mesh.material = mat; mesh.useVertexColors = true; mesh.hasVertexAlpha = false; mesh.isPickable = false; mesh.parent = root; mesh.renderingGroupId = 1;+    return { name, mesh, cellOf, shade, sink };+  });+  // the regimes: still and moving air, and each fan level (spec fanLevels) when the solve has them+  const R = { still: S.still, moving: S.moving, ...(S.levels || {}) };+  const fluxes = Object.fromEntries(Object.entries(R).map(([k, v]) => [k, v.q.map(q => Math.hypot(q[0], q[1], q[2]))]));+  // the bodies' own ranges, so purple to yellow spans what happens inside them: flux from its 2nd percentile to its peak+  // (log), temperature from the coolest to the hottest body cell+  const pct = (arr, p) => { const v = arr.filter(x => x > 0).sort((a, b) => a - b); return v.length ? v[Math.min(v.length - 1, Math.floor(p * v.length))] : 1e-9; };+  // with fan levels, one range over all of them, so the levels compare by colour; without, each regime its own+  const keys = Object.keys(R), fanned = !!S.levels;+  // loops, not spreads: five levels of 40k cells overflow the call stack as Math.max(...arguments)+  const all = (pick) => keys.flatMap(k => Array.from(pick(k)));+  const mx = (a) => { let m = -Infinity; for (const x of a) if (x > m) m = x; return m; }, mn = (a) => { let m = Infinity; for (const x of a) if (x < m) m = x; return m; };+  const shared = { qLo: Math.max(1e-9, pct(all(k => fluxes[k]), 0.02)), qHi: Math.max(1e-9, mx(all(k => fluxes[k]))), tLo: mn(all(k => R[k].t)), tHi: mx(all(k => R[k].t)) };+  const range = Object.fromEntries(keys.map(r => [r, fanned ? shared : { qLo: Math.max(1e-9, pct(fluxes[r], 0.02)), qHi: Math.max(1e-9, mx(fluxes[r])), tLo: mn(R[r].t), tHi: mx(R[r].t) }]));+  const qHi = range[keys[0]].qHi;+  // the legend line under the copper's: what the bodies show and the hottest point of each+  const legend = document.getElementById('legend');+  let info = document.getElementById('solidsInfo');+  if (!info && legend) { info = document.createElement('div'); info.id = 'solidsInfo'; info.style.cssText = 'margin-top:6px;font-size:11px;line-height:1.35;opacity:.9;max-width:340px'; legend.appendChild(info); }+  function apply(view) {+    const q = view.quantity, show = q === 'heat' || q === 'temperature';+    const fk = S.levels ? (view.air === 'still' ? 'fan0' : view.air === 'moving' ? 'fan100' : view.air) : null;+    const reg = fk && R[fk] ? fk : (view.air === 'moving' ? 'moving' : 'still');+    const tHi = (state.fields.scales && state.fields.scales.tHi) || 1;+    const t = R[reg].t, fq = fluxes[reg];+    for (const b of bodies) {+      b.mesh.setEnabled(show);+      if (!show) continue;+      const col = new Float32Array(b.cellOf.length * 16);+      for (let f = 0; f < b.cellOf.length; f++) {+        const i = b.cellOf[f];+        const rg = range[reg];+        const c = q === 'heat' ? ramp(fq[i] <= rg.qLo ? 0 : Math.log(fq[i] / rg.qLo) / Math.log(rg.qHi / rg.qLo), INFERNO) : ramp((t[i] - rg.tLo) / Math.max(1e-6, rg.tHi - rg.tLo), MAGMA);+        const s = b.shade[f];+        for (let v = 0; v < 4; v++) { const o = (f * 4 + v) * 4; col[o] = c[0] * s; col[o + 1] = c[1] * s; col[o + 2] = c[2] * s; col[o + 3] = 1; }+      }+      b.mesh.setVerticesData(BAB.VertexBuffer.ColorKind, col, true);+      // pulled apart with the stack: the bodies ride on the top copper+      const L = state.layerMeshes['F.Cu']; b.mesh.position.z = L ? L.position.z - Z['F.Cu'] : 0;+    }+    if (info) {+      info.hidden = !show;+      if (show) {+        const lvl = state.fields.airStates && state.fields.airStates.levels.find(l => l.name === reg);+        const stats = ((lvl && lvl.solids) || state.fields.solids[reg] || []).map(x => `${x.name} +${x.maxRiseC.toFixed(1)} °C`).join(' · ');+        const rg = range[reg];+        info.textContent = (q === 'heat' ? `Q1 and the heatsink: heat flux, W/mm² (log, ${rg.qLo.toExponential(1)} purple to ${rg.qHi.toFixed(3)} yellow). ` : `Q1 and the heatsink: +${rg.tLo.toFixed(1)} °C purple to +${rg.tHi.toFixed(1)} °C yellow (${reg.startsWith('fan') ? (reg === 'fan0' ? 'fan off' : 'fan ' + reg.slice(3) + ' %') : reg + ' air'}). `) + `Hottest: ${stats}`;+      }+    }+  }+  return { apply, bodies };+}