← Commit history

adom-fields 0.10.6: grid covers the board (issue 27); one board thickness and solid-uncoupled (issue 28); decoupling counts the chip's own grounds (issue 29)

Colby Knox ·cb236bf91c · ·parent b610da4
Co-Authored-By: Claude Opus 5.5 <[email protected]>
16 files changed +289−40
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-fields"-version = "0.10.5"+version = "0.10.6" dependencies = [  "clap",  "png",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-fields"-version = "0.10.5"+version = "0.10.6" 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."
docs/release-0.10.6.mdadded+34
@@ -0,0 +1,34 @@+# adom-fields 0.10.6++Three fixes from Glenn's 772 power module list (issues 27, 28 and 29). Thanks, Glenn.++- **The grid covers the whole board (issue 27).** The column and row counts were rounded to the nearest pair of fine pixels, so a 49.53 mm board got 247 columns of 0.2 mm, 49.4 mm in all, and a pad centred on the right edge had no cell. An explicit terminal on it failed with "a terminal has no copper under it".+  - The grid now rounds up to whole cells. A board that is already a whole number of cells keeps its grid.+  - A pad centred on any edge, or within 1 mm past it, now reaches the copper inside the grid. This also fixes the case rounding up can't: a pad on the far edge of a board that is an exact number of cells, such as 50.00 mm.+  - The last column and row can now reach past the outline by part of a cell. Only the part on the board loses heat to the air, and the viewer shows only that part, with each cell in its true place.+  - `at` on a terminal is unchanged: it still picks one physical pad by its centre.+- **One board thickness (issue 28).** A body under the board coupled to B.Cu only when its top face was at the sum of the dielectrics and the two outer copper layers, while `fields.json` reported every copper layer in `board.thicknessMm`. On a 4-layer board those differ, so a body placed at the reported thickness silently took 0 W.+  - `board.thicknessMm` is now the one number the report, the viewer and the bodies use.+  - A top face couples when it is within 0.05 mm of that thickness, which takes a nominal 1.6 mm on a 1.57 mm stack, or at the older outer-copper sum, so existing specs keep working.+  - A new `solid-uncoupled` issue says when a body is left out. It is `high` when `boardContactM2kW` is set but no face is on the copper, and `medium` when a body has no heat of its own and no path to the board, so it carries 0 W. A solve also logs it, and `solids.still` has `onCopper` per body.+  - A body larger than the board now keeps its top face in the air outside the outline. It used to lose that face altogether.+- **Decoupling counts the chip's own grounds (issue 29).** The review looked for a capacitor from each power pin to one ground, the first in alphabetical order. On a board with AGND and PGND, that gave a false `decoupling-missing` for every rail decoupled to PGND.+  - A capacitor now counts when it returns to any ground the chip has a pin on, or to the review's ground as before. `review.decoupling` records the `return` net of each pin.+  - A pin with no capacitor is still a finding, and so is a capacitor to a ground the chip has no pin on. The suggested capacitor goes to the ground of the chip's nearest ground pin.+  - The choice of the board's ground (issue 25) is unchanged, and so is `--with pdn`, which still looks for a rail's capacitors on that one ground only.++**What moves on your boards.** A board that isn't a whole number of cells gains a column or row, so its numbers shift slightly with the added copper and area. Peak rise moved under 1 % on the real boards below, and a value sitting right on a threshold can cross it: on StickHub, C22 at about 75 °C dropped out of a `capacitor-temperature` finding.++**Checked:**++- Each report reproduced on 0.10.5 and fixed here, on small test boards:+  - Terminals on the far edge at 49.53, 50.00 and 49.46 x 20.03 mm all failed on 0.10.5 and now solve, also with `--with all`. A 49.53 mm Fusion `.brd` does too.+  - A carrier at z = -1.57 took 0 W on 0.10.5. It now takes the same 0.537 W at -1.57, -1.535 and -1.6. At -2.0 it takes 0 W and raises `solid-uncoupled`. Fan levels and a transient ran on it.+  - On a chip with AGND and PGND, VCC2 to PGND is no longer a finding. A rail with no capacitor and a rail whose capacitor goes to a ground the chip isn't on still are.+- On real boards, 0.10.5 against 0.10.6:+  - The 12 V buck (28 x 20 mm): every finding and number unchanged.+  - BQ25792 charger (Fusion `.brd`, AGND and PGND, 50.4 x 26.4 mm): the grid and heat numbers unchanged, and two false `decoupling-missing` gone (LDO_3V3, on chips with only PGND pins, with C3 and C5 to PGND).+  - StickHub (16.5 x 40 mm): peak rise 61.7 to 61.3 °C, one column more. CM5 Minima (59.5 x 62.7 mm): 43.44 to 43.42 °C, one column and one row more.+- The viewer, in a headless browser on a coarse-grid test board: the fields line up with the board and stop at its outline.++**Not checked:** the LM251772 board itself, which wasn't available; `verify` and `report` on the new grid.
docs/solids.md+1−1
@@ -7,7 +7,7 @@ Since 0.8.0 the heat solve does not stop at the board. A spec can add **solid bo - **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.+- **The board.** A face lying on the outer copper couples to it through `boardContactM2kW`: the bottom face of a body on top at z = 0, or the top face of a body under the board at z = minus `board.thicknessMm` in `fields.json` (every dielectric and every copper layer). The underside has a tolerance of 0.05 mm, and also accepts the older sum of the dielectrics and the two outer copper layers. A body with `boardContactM2kW` and no face on the copper, or a body with no heat and no path to the board, raises a `solid-uncoupled` issue; `solids.still` gives `onCopper` per body. `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
package.json+2−2
@@ -1,8 +1,8 @@ {   "slug": "adom-fields",   "type": "app",-  "version": "0.10.5",-  "changelog": "0.10.5: converter ground is the switch legs' return (PGND, not AGND), with spec converter.ground and the pick recorded; resistor Values with a rating or size (3m 1W, 0R003 1%) read as their resistance, spec converter.series (issue 24).",+  "version": "0.10.6",+  "changelog": "0.10.6: the grid covers the whole board and a pad centred on its edge is modelled (issue 27); one board thickness, a body under the board couples at it, and a new solid-uncoupled issue says when a body is left out (issue 28); a decoupling capacitor counts when it returns to any ground the chip has a pin on, so VCC2 to PGND is no false finding (issue 29).",   "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+2−2
@@ -1,8 +1,8 @@ {   "slug": "adom-fields",   "type": "app",-  "version": "0.10.5",-  "changelog": "0.10.5: converter ground is the switch legs' return (PGND, not AGND), with spec converter.ground and the pick recorded; resistor Values with a rating or size (3m 1W, 0R003 1%) read as their resistance, spec converter.series (issue 24).",+  "version": "0.10.6",+  "changelog": "0.10.6: the grid covers the whole board and a pad centred on its edge is modelled (issue 27); one board thickness, a body under the board couples at it, and a new solid-uncoupled issue says when a body is left out (issue 28); a decoupling capacitor counts when it returns to any ground the chip has a pin on, so VCC2 to PGND is no false finding (issue 29).",   "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.",
skills/adom-fields-review/SKILL.md+2−2
@@ -17,7 +17,7 @@ Every finding kind, which module it comes from (`core` always runs; the rest nee  ## Finding kinds by module -`core` always runs; the others need `--with <module>` (or the spec keys listed in SKILL.md). Kinds not listed are core: `current-neck`, `current-barrel`, `ampacity`, `ir-drop`, `pour-fragmented`, `tab-rise`, `thermal-vias`, `junction-temperature`, `capacitor-temperature`, `electro-thermal`, `ground-offset`, `ground-island`, `decoupling-far`, `decoupling-missing`, `hot-loop`, `inductor-ripple`, `output-ripple`, `solve-unconverged`, `load-unsolved`.+`core` always runs; the others need `--with <module>` (or the spec keys listed in SKILL.md). Kinds not listed are core: `current-neck`, `current-barrel`, `ampacity`, `ir-drop`, `pour-fragmented`, `tab-rise`, `thermal-vias`, `junction-temperature`, `capacitor-temperature`, `electro-thermal`, `solid-uncoupled`, `ground-offset`, `ground-island`, `decoupling-far`, `decoupling-missing`, `hot-loop`, `inductor-ripple`, `output-ripple`, `solve-unconverged`, `load-unsolved`.  | Module | Kinds | |---|---|@@ -33,7 +33,7 @@ Grouping: three or more low findings of one kind become one finding (`action.typ ## For the AI that called the solve: the review and the fix plan (0.7.6)  `analyze` reviews the design as well as solving it, and says what to change, without anyone looking at the app:-- **`fields.json` `issues`**: every finding with `severity`, `kind`, `what`, `at` (board mm), `net`, `reference`, a plain `fix`, and an **`action`** to carry out: `move` (`ref`, `toPins`, `targetMm2`), `add-vias` (`net`, `points` or `at`, `pitchMm`), `widen` (`net`, `at`), `pour` (`net`, `layer`, `under`, `at`), `add-copper`, `add-part`, `review`. Kinds: `current-neck`, `tab-rise`, `thermal-vias` (the solve's own), and from the review `hot-loop`, `emissions`, `no-reference`, `ground-offset`, `ground-island`, `stitching-gap`, `ground-loop-risk`, `ir-drop` (over `dropPct` of the rail, or over the net's own `loads.<net>.maxDropMv`), `pour-fragmented` (a poured net's fill on one layer broke into pieces: the nearest gap, the pad in it, and a `move` (`reference`, `orRotateDeg`, `byMm`) or `reroute` action; `fields.json` `fragments` lists every pour's pieces per layer), `inductor-ripple`, `output-ripple`, `decoupling-far`, `decoupling-missing` (each chip power pin's nearest small capacitor: over 3 mm moves it, none adds a 100 nF), `cavity-resonance`, `crosstalk`, `esd-unprotected` (a connector pin with no diode to ground: add a TVS within 3 mm if it faces the outside).+- **`fields.json` `issues`**: every finding with `severity`, `kind`, `what`, `at` (board mm), `net`, `reference`, a plain `fix`, and an **`action`** to carry out: `move` (`ref`, `toPins`, `targetMm2`), `add-vias` (`net`, `points` or `at`, `pitchMm`), `widen` (`net`, `at`), `pour` (`net`, `layer`, `under`, `at`), `add-copper`, `add-part`, `review`. Kinds: `current-neck`, `tab-rise`, `thermal-vias` (the solve's own), and from the review `hot-loop`, `emissions`, `no-reference`, `ground-offset`, `ground-island`, `stitching-gap`, `ground-loop-risk`, `ir-drop` (over `dropPct` of the rail, or over the net's own `loads.<net>.maxDropMv`), `pour-fragmented` (a poured net's fill on one layer broke into pieces: the nearest gap, the pad in it, and a `move` (`reference`, `orRotateDeg`, `byMm`) or `reroute` action; `fields.json` `fragments` lists every pour's pieces per layer), `inductor-ripple`, `output-ripple`, `decoupling-far`, `decoupling-missing` (each chip power pin's nearest small capacitor to any ground the chip has a pin on, or to the review's ground, recorded as `return` in `review.decoupling`: over 3 mm moves it, none adds a 100 nF), `solid-uncoupled` (a solid body the solve left out: `boardContactM2kW` set but no face on the copper is high; no heat and no path to the board, so 0 W, is medium), `cavity-resonance`, `crosstalk`, `esd-unprotected` (a connector pin with no diode to ground: add a TVS within 3 mm if it faces the outside). - **`fields.json` `fixPlan`**: the actions, worst first and concrete edits before reviews, numbered for the caller to work down. `analyze` prints the first five as a `Hint: Fix plan` line and the review as a `Hint: Review` line. - **`fields.json` `review`**: the numbers behind them: `converter` (duty, ripple, hot loops per input capacitor and the best possible, the emissions margin), `reference` (each net's copper with no ground under it), `ground` (offset, connectors to the outside, islands, the stitching fence and its gaps), `rails` (IR drop per rail), `decoupling` (pin, cap, distance, loop), `esd` (each connector pin and what protects it). - **IR-drop probe**: `adom-fields probe --fields <dir> --from J1.1 --to U1.3` (or `--to <net>` for its farthest point, `--json` for the record) reads the drop between two pads from the solve, no re-solve; `adom-fields ui probe --from J1.1 --to U1.3` shows it in the running app's river view with a toast.
skills/adom-fields-spec/SKILL.md+1−1
@@ -52,7 +52,7 @@ chips sit over the fields.   - It gives the board peak and each hot part's rise over time, and each part's time constant (`t63S`, to 63 % of its steady rise).   - On the buck: U1 about 2 min, L1 about 3 min. - **Terminals** (0.8.1): without explicit `loads.<net>.terminals`, the solve picks them. The named `sources` feed the net, and every other connector and part on it is a sink, so a board-to-board connector that a rail feeds counts as a load. Otherwise connectors feed and parts draw. A part listed in `loads.<net>.draws` carries its own amps, the rest share what is left (sinks by pad area, sources evenly), and each part's share splits evenly over its pins. A pin with no copper near it is left out and its share goes to the other pins on its side, so the injection always balances. Every net in fields.json lists `terminalCurrents` (pin, amps, + into the copper) and `terminalBasis`, so a reviewer can check them.-- **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.+- **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. A body under the board couples to B.Cu through `boardContactM2kW` with its top face at z = minus `board.thicknessMm` (within 0.05 mm); a body missing the copper raises `solid-uncoupled` (0.10.6). `fields.json` `solids` gives each body's peak rise, heat to air and to board, peak flux and `onCopper`. `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/checks.rs+1−1
@@ -35,7 +35,7 @@ fn protocol_ps(p: &str) -> Option<f64> { pub fn review(f: &Fields, b: &Board, spec: &Spec, rev: &Value, issues: &mut Vec<Issue>) -> Value {     let mut out = json!({});     let n = f.w * f.h;-    let cell = |x: f64, y: f64| -> Option<usize> { let (c, r) = (((x - f.x0) / f.d).floor(), ((y - f.y0) / f.d).floor()); if c < 0.0 || r < 0.0 || c as usize >= f.w || r as usize >= f.h { None } else { Some(r as usize * f.w + c as usize) } };+    let cell = |x: f64, y: f64| -> Option<usize> { crate::fields::cell_at(x, y, f.x0, f.y0, f.d, f.w, f.h).map(|(r, c)| r * f.w + c) };     let lay = |name: &str| LAYERS.iter().position(|l| *l == name);     let real: Vec<String> = if spec.real_copper.is_empty() { b.copper.clone() } else { spec.real_copper.clone() };     let gaps = if spec.real_gaps.is_empty() { crate::fields::slot_gaps(spec) } else { spec.real_gaps.clone() };
src/emc.rs+51−5
@@ -164,18 +164,26 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, issues: &mut Vec<Issue>) -> Va     let mut dec = Vec::new();     if let Some(gname) = gnd.clone() {         for u in refs.iter().filter(|r| r.starts_with('U')) {-            let gpins = pads_of(u, &gname);+            // the returns that count (issue 29): every ground the chip has a pin on, so a gate-drive rail decoupled to PGND+            // is decoupled on a board whose review ground is AGND, and the review's ground as before. A capacitor to a+            // ground the chip is not on still does not count.+            let mut returns: Vec<String> = nets_of(u).into_iter().filter(|n| is_ground(n) || rail(n) == Some(0.0)).collect();+            if !returns.contains(&gname) { returns.push(gname.clone()); }             for net in nets_of(u) {                 let Some(rv) = rail(&net) else { continue }; if rv <= 0.0 || is_ground(&net) || spec.switching.contains_key(&net) || is_switch_name(&net) { continue; }                 for pin in pads_of(u, &net) {-                    // the capacitors bridging this rail to ground, nearest first-                    let mut cands: Vec<(String, f64, f64, f64)> = refs.iter().filter(|r| r.starts_with('C') && nets_of(r).contains(&net) && nets_of(r).contains(&gname)).filter_map(|r| { let cv = pads_of(r, &net); let cg = pads_of(r, &gname); let (cv, cg) = (cv.first()?, cg.first()?); let c = b.values.get(r).and_then(|v| parse_value(v, 'F')).unwrap_or(f64::NAN); let gp = near(&gpins, cg.x, cg.y); let area = gp.map(|g| shoelace(&[(pin.x, pin.y), (cv.x, cv.y), (cg.x, cg.y), (g.x, g.y)])).unwrap_or(f64::NAN); Some((r.clone(), (cv.x - pin.x).hypot(cv.y - pin.y), area, c)) }).collect();+                    // the capacitors bridging this rail to a return, nearest first+                    let mut cands: Vec<(String, f64, f64, f64, String)> = refs.iter().filter(|r| r.starts_with('C') && nets_of(r).contains(&net)).filter_map(|r| { let ret = returns.iter().find(|g| **g != net && nets_of(r).contains(*g))?.clone(); let gpins = pads_of(u, &ret); let cv = pads_of(r, &net); let cg = pads_of(r, &ret); let (cv, cg) = (cv.first()?, cg.first()?); let c = b.values.get(r).and_then(|v| parse_value(v, 'F')).unwrap_or(f64::NAN); let gp = near(&gpins, cg.x, cg.y); let area = gp.map(|g| shoelace(&[(pin.x, pin.y), (cv.x, cv.y), (cg.x, cg.y), (g.x, g.y)])).unwrap_or(f64::NAN); Some((r.clone(), (cv.x - pin.x).hypot(cv.y - pin.y), area, c, ret)) }).collect();                     cands.sort_by(|a, z| a.1.partial_cmp(&z.1).unwrap());                     let hf = cands.iter().filter(|c| c.3.is_finite() && c.3 <= 1.1e-6).min_by(|a, z| a.1.partial_cmp(&z.1).unwrap()).or(cands.first()).cloned();-                    let entry = json!({"chip": u, "pin": pin.pad, "net": net, "at": [r2(pin.x), r2(pin.y)], "cap": hf.as_ref().map(|c| c.0.clone()), "distMm": hf.as_ref().map(|c| r2(c.1)), "loopMm2": hf.as_ref().map(|c| r2(c.2)), "capAt": hf.as_ref().and_then(|c| pads_of(&c.0, &net).first().map(|p| [r2(p.x), r2(p.y)]))});+                    let entry = json!({"chip": u, "pin": pin.pad, "net": net, "at": [r2(pin.x), r2(pin.y)], "cap": hf.as_ref().map(|c| c.0.clone()), "distMm": hf.as_ref().map(|c| r2(c.1)), "loopMm2": hf.as_ref().map(|c| r2(c.2)), "return": hf.as_ref().map(|c| c.4.clone()), "capAt": hf.as_ref().and_then(|c| pads_of(&c.0, &net).first().map(|p| [r2(p.x), r2(p.y)]))});                     dec.push(entry);                     match &hf {-                        None => issues.push(issue("medium", "decoupling-missing", format!("{} pin {} ({}): no capacitor from {} to {} anywhere on the board", u, pin.pad, net, net, gname), Some([pin.x, pin.y]), Some(&net), Some(u), format!("Add a 100 nF ceramic from {} to {} right at {} pin {}, its ground pad straight to the chip's ground pin.", net, gname, u, pin.pad), json!({"type": "add-part", "what": "capacitor", "value": "100nF", "between": [net, gname], "nearPin": format!("{}.{}", u, pin.pad), "maxDistMm": 1.0}))),+                        None => {+                            // suggest the return of the chip's ground pin nearest this pin+                            let ret = returns.iter().flat_map(|g| pads_of(u, g).into_iter().map(move |p| (g, (p.x - pin.x).hypot(p.y - pin.y)))).min_by(|a, z| a.1.partial_cmp(&z.1).unwrap()).map_or(&gname, |x| x.0);+                            issues.push(issue("medium", "decoupling-missing", format!("{} pin {} ({}): no capacitor from {} to {} anywhere on the board", u, pin.pad, net, net, returns.join(" or ")), Some([pin.x, pin.y]), Some(&net), Some(u), format!("Add a 100 nF ceramic from {} to {} right at {} pin {}, its ground pad straight to the chip's ground pin.", net, ret, u, pin.pad), json!({"type": "add-part", "what": "capacitor", "value": "100nF", "between": [net, ret], "nearPin": format!("{}.{}", u, pin.pad), "maxDistMm": 1.0})))+                        }                         Some(c) if c.1 > 3.0 => issues.push(issue(if c.1 > 6.0 { "medium" } else { "low" }, "decoupling-far", format!("{} pin {} ({}): the nearest small capacitor, {}, is {:.1} mm away (loop {:.1} mm²)", u, pin.pad, net, c.0, c.1, c.2), Some([pin.x, pin.y]), Some(&net), Some(u), format!("Move {} to within 1 mm of {} pin {}, its ground pad straight to the chip's ground pin, or add a 100 nF there.", c.0, u, pin.pad), json!({"type": "move", "ref": c.0, "toPins": [format!("{}.{}", u, pin.pad)], "maxDistMm": 1.0, "currentDistMm": r2(c.1)}))),                         _ => {}                     }@@ -288,5 +296,43 @@ pub fn fix_plan(issues: &[Issue]) -> Value {  #[cfg(test)] mod tests {     use super::*;+    fn pad(reference: &str, name: &str, net: &str, x: f64, y: f64) -> Pad { Pad { reference: reference.into(), pad: name.into(), net: net.into(), x, y, layers: vec!["F.Cu".into()], area: 0.5, func: String::new() } }+    fn missing(pads: Vec<Pad>, voltages: Value) -> (Vec<String>, Value) {+        let mut b = Board { x1: 30., y1: 30., layer_names: vec!["F.Cu".into(), "B.Cu".into()], copper: vec!["F.Cu".into(), "B.Cu".into()], pads, ..Board::default() };+        for (c, v) in [("C16", "1u"), ("C1", "100n"), ("C2", "100n"), ("C3", "100n")] { b.values.insert(c.into(), v.into()); }+        // each ground has a pour of its own, side by side on the back+        let mut grounds: Vec<String> = b.pads.iter().map(|p| p.net.clone()).filter(|n| is_ground(n)).collect(); grounds.sort(); grounds.dedup();+        for (i, g) in grounds.iter().enumerate() { let x = i as f64 * 10.0; b.polys.push(crate::board::Poly { net: g.clone(), layer: "B.Cu".into(), pts: vec![(x, 0.), (x + 9., 0.), (x + 9., 30.), (x, 30.)] }); }+        let spec: crate::fields::Spec = serde_json::from_value(json!({"copperUm": 35, "voltages": voltages, "hot": {"U1": {"watts": 0.1, "tabNet": "PGND"}}})).unwrap();+        let f = crate::fields::analyze(&b, &spec, 0.5, 60., &mut |_| {});+        let mut issues = Vec::new();+        let r = review(&f, &b, &spec, &mut issues);+        (issues.iter().filter(|i| i.kind == "decoupling-missing").map(|i| format!("{} {}", i.net.clone().unwrap(), serde_json::to_string(&i.action).unwrap())).collect(), r["decoupling"].clone())+    }+    // issue 29: a rail decoupled to any ground the chip is on counts; no capacitor, or one to a ground the chip is not on, does not+    #[test] fn decoupling_counts_the_chips_own_grounds_and_only_those() {+        let (miss, dec) = missing(vec![+            pad("U1", "1", "AGND", 10., 10.), pad("U1", "2", "VCC1", 10., 12.), pad("U1", "3", "VBAD", 10., 14.), pad("U1", "4", "VCC2", 14., 10.), pad("U1", "5", "VCC2", 14., 11.), pad("U1", "6", "PGND", 14., 12.), pad("U1", "7", "VOTHER", 14., 14.),+            pad("C16", "1", "VCC2", 16., 10.), pad("C16", "2", "PGND", 16., 11.),   // the datasheet's way: gate drive to the power ground+            pad("C1", "1", "VCC1", 8., 12.), pad("C1", "2", "AGND", 8., 11.),       // to the review's ground, as before+            pad("C2", "1", "VOTHER", 16., 14.), pad("C2", "2", "CGND", 16., 15.),   // to a ground the chip has no pin on+            pad("J1", "1", "CGND", 20., 20.),+        ], json!({"VCC1": 5, "VCC2": 10, "VBAD": 3.3, "VOTHER": 3.3}));+        let nets: Vec<&str> = miss.iter().map(|m| m.split(' ').next().unwrap()).collect();+        assert!(nets.contains(&"VBAD"), "a rail with no capacitor must stay a finding: {miss:?}");+        assert!(nets.contains(&"VOTHER"), "a capacitor to a ground the chip is not on must stay a finding: {miss:?}");+        assert!(!nets.contains(&"VCC2"), "{miss:?}"); assert!(!nets.contains(&"VCC1"), "{miss:?}");+        let row = |pin: &str| dec.as_array().unwrap().iter().find(|e| e["pin"] == pin).unwrap().clone();+        assert_eq!(row("4")["cap"], "C16"); assert_eq!(row("4")["return"], "PGND"); assert!(row("4")["loopMm2"].as_f64().unwrap() > 0.0);+        assert_eq!(row("2")["return"], "AGND");+        assert!(row("3")["cap"].is_null());+        // the suggested capacitor goes to the chip's nearest ground pin+        assert!(miss.iter().any(|m| m.starts_with("VBAD") && m.contains("[\"VBAD\",\"AGND\"]")), "{miss:?}");+    }+    // a board with one ground: the same finding as before, naming that ground+    #[test] fn one_ground_finding_is_unchanged() {+        let (miss, _) = missing(vec![pad("U1", "1", "GND", 10., 10.), pad("U1", "2", "VCC", 10., 12.), pad("U1", "3", "VDD", 12., 12.), pad("C1", "1", "VDD", 13., 12.), pad("C1", "2", "GND", 13., 13.), pad("J1", "1", "GND", 20., 20.)], json!({"VCC": 5, "VDD": 3.3}));+        assert_eq!(miss.len(), 1); assert!(miss[0].starts_with("VCC") && miss[0].contains("[\"VCC\",\"GND\"]"), "{miss:?}");+    }     #[test] fn values() { assert!((parse_value("15uH", 'H').unwrap() - 15e-6).abs() < 1e-12); assert!((parse_value("22uF 10V", 'F').unwrap() - 22e-6).abs() < 1e-12); assert!((parse_value("4u7", 'F').unwrap() - 4.7e-6).abs() < 1e-12); assert_eq!(parse_value("10k", 'F'), None); assert!((parse_value("100nF", 'F').unwrap() - 1e-7).abs() < 1e-15); } }
src/fields.rs+121−9
@@ -144,14 +144,14 @@ pub struct Fields {  pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMut(&str)) -> Fields {     let fine = pitch / 2.0;-    let mut ras = Raster::new(b, fine);-    ras.draw_board(b);     let pool = 2;+    let mut ras = Raster::pooled(b, fine, pool);+    ras.draw_board(b);     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 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 } };+    let cell = |x: f64, y: f64| cell_at(x, y, b.x0, b.y0, d, w, h);     // 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); } }@@ -258,14 +258,15 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu         let area_total: f64 = area_by_ref.values().sum();         let mut rhs = vec![0.0; nl * n];         let mut spots_of = |p: &crate::board::Pad| -> Vec<(usize, usize, usize)> {-            let Some((r0, c0)) = cell(p.x, p.y) else { return Vec::new() };+            // a pad centred on the board edge or just past it still reaches the copper inside the grid (issue 27)+            let (r0, c0) = (((p.y - b.y0) / d).floor() as isize, ((p.x - b.x0) / d).floor() as isize);             // within 1 mm, or the pad's own ring when it is wider: a large plated pad's drill is a void (PR 9)             let rad = ((1.0f64).max((p.area / std::f64::consts::PI).sqrt() + d) / d).ceil() 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; }                 for dr in -rad..=rad { for dc in -rad..=rad {-                    let (r, c) = (r0 as isize + dr, c0 as isize + dc);+                    let (r, c) = (r0 + dr, c0 + dc);                     if r < 0 || c < 0 || r as usize >= h || c as usize >= w || dr * dr + dc * dc > rad * rad { continue; }                     if fr[*k].at(r as usize, c as usize) > 0.0 { spots.push((a, r as usize, c as usize)); }                 } }@@ -426,10 +427,16 @@ 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 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 board_thick = board_thickness(spec);+    // where a top face counts as lying on B.Cu (issue 28): the board's thickness within 0.05 mm, which takes a nominal 1.6 mm+    // on a 1.57 mm stack, and down to the outer-copper-only sum that specs written before 0.10.6 had to use on 4 layers+    let under_face: (f64, f64) = { let older = slot_gaps(spec).iter().sum::<f64>() + 2.0 * spec.copperUm / 1000.0; (board_thick.min(older) - 0.05, board_thick.max(older) + 0.05) };     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);+    // the last column and row reach past the outline by up to a cell (issue 27): only the board's part of them meets the air+    let (cover_x, cover_y) = (((b.width() - (w - 1) as f64 * d) / d).clamp(0.0, 1.0), ((b.height() - (h - 1) as f64 * d) / d).clamp(0.0, 1.0));+    let on_board = |i: usize| (if i % w == w - 1 { cover_x } else { 1.0 }) * (if i / w == h - 1 { cover_y } else { 1.0 });     let build = |level: f64| {         let h_air = fan_mix(H_STILL, H_MOVING, level, fan_n);         let mut sys = System::new(nl, h, w);@@ -438,7 +445,7 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu             for r in 0..h { for c in 0..w - 1 { sys.gx[k][r * (w - 1) + c] = harm(kt[r * w + c], kt[r * w + c + 1]); } }             for r in 0..h - 1 { for c in 0..w { sys.gy[k][r * w + c] = harm(kt[r * w + c], kt[(r + 1) * w + c]); } }             let leak = if k == 0 || k == nl - 1 { h_air * d * d } else { 0.0 };-            for i in 0..n { sys.leak[k][i] = leak + 1e-12; }+            for i in 0..n { sys.leak[k][i] = leak * on_board(i) + 1e-12; }         }         for k in 0..nl - 1 { let g = K_FR4_Z * d * d / t_diel[k]; for i in 0..n { sys.gz[k][i] = g; } }         for v in b.vias.iter().chain(b.plated_holes.iter()) {@@ -451,7 +458,7 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu             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, level, fan_n)         }).collect();-        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 };+        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, under_face, &|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];@@ -634,6 +641,11 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu         }     }     let _ = (tab_total, joule_total);+    if let (Some(sd), Some(a)) = (&spec.solids, &solid_still) { for (severity, body, what, fix) in a.unused(sd, board_thick) {+        log(&format!("solids: {what}"));+        let x = &sd.bodies[body];+        issues.push(Issue { severity: severity.into(), kind: "solid-uncoupled".into(), what, at: x.boxes_mm.first().map(|v| [(v[0] + v[3]) / 2.0, (v[1] + v[4]) / 2.0]), layer: None, net: None, reference: x.heat_of.clone(), fix, action: Some(json!({"type": "review", "what": "solid body placement", "body": x.name, "boardUndersideZMm": -((board_thick * 1000.0).round() / 1000.0)})) });+    } }     Fields { transient, joule_map: p_joule.clone(), 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 } } @@ -650,7 +662,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.real_gaps.is_empty() {slot_gaps(spec)} else {spec.real_gaps.clone()}, "slotDielectricMm": slot_gaps(spec), "copperLayers": if spec.real_copper.is_empty() { LAYERS.iter().map(|l| l.to_string()).collect::<Vec<_>>() } else { spec.real_copper.clone() }, "copperUmByLayer":LAYERS.iter().map(|l|((*l).to_string(),spec.copperUmByLayer.get(*l).copied().unwrap_or(spec.copperUm))).collect::<BTreeMap<_,_>>(), "thicknessMm": slot_gaps(spec).iter().sum::<f64>() + (if spec.real_copper.is_empty() { LAYERS.iter().map(|l| l.to_string()).collect::<Vec<_>>() } else { spec.real_copper.clone() }).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, "dielectricMm":if spec.real_gaps.is_empty() {slot_gaps(spec)} else {spec.real_gaps.clone()}, "slotDielectricMm": slot_gaps(spec), "copperLayers": if spec.real_copper.is_empty() { LAYERS.iter().map(|l| l.to_string()).collect::<Vec<_>>() } else { spec.real_copper.clone() }, "copperUmByLayer":LAYERS.iter().map(|l|((*l).to_string(),spec.copperUmByLayer.get(*l).copied().unwrap_or(spec.copperUm))).collect::<BTreeMap<_,_>>(), "thicknessMm": board_thickness(spec), "cellMm": f.d, "cols": f.w, "rows": f.h},         "nets": nets, "chips": chips, "issues": f.issues,         "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},@@ -694,6 +706,21 @@ pub fn gap_between(spec: &Spec, _b: &crate::board::Board, ka: usize, kb: usize)     (h, er) } /// The dielectric in each of the five slot gaps (between the six copper slots), as analyze normalised it.+/// The solve cell (row, column) under a point. A point on the board's far edge or within one cell past any edge snaps to+/// the edge cell, so a pad centred on the outline has a cell on all four sides (issue 27).+pub fn cell_at(x: f64, y: f64, x0: f64, y0: f64, d: f64, w: usize, h: usize) -> Option<(usize, usize)> {+    let (c, r) = (((x - x0) / d).floor(), ((y - y0) / d).floor());+    if c < -1.0 || r < -1.0 || c > w as f64 || r > h as f64 { return None; }+    Some((r.clamp(0.0, h as f64 - 1.0) as usize, c.clamp(0.0, w as f64 - 1.0) as usize))+}++/// The board's thickness, from the top copper face to the bottom one: every dielectric and every copper layer. The one+/// number for the report, the viewer and the solid bodies (issue 28).+pub fn board_thickness(spec: &Spec) -> f64 {+    let copper = if spec.real_copper.is_empty() { LAYERS.iter().map(|l| l.to_string()).collect::<Vec<_>>() } else { spec.real_copper.clone() };+    slot_gaps(spec).iter().sum::<f64>() + copper.iter().map(|l| spec.copperUmByLayer.get(l).copied().unwrap_or(spec.copperUm) / 1000.0).sum::<f64>()+}+ pub fn slot_gaps(spec: &Spec) -> Vec<f64> { if spec.stackupMm.len() == LAYERS.len() - 1 { spec.stackupMm.clone() } else { spread(&[0.2, 1.065, 0.2], &["F.Cu".into(), "In1.Cu".into(), "In2.Cu".into(), "B.Cu".into()]) } } /// Real gaps between consecutive real copper layers, spread over the slots between them (empty slots carry only dielectric). pub fn spread(gaps: &[f64], copper: &[String]) -> Vec<f64> {@@ -735,6 +762,91 @@ pub struct Band { pub minHz: f64, pub maxHz: f64, #[serde(default)] pub ifHz: Op         b.pads[1].x = 50.;         assert!(analyze(&b, &spec, 0.5, 60., &mut |_| {}).nets.is_empty(), "a sink off the copper must not be silently dropped");     }+    fn edge_board(w: f64, h: f64, pad: (f64, f64)) -> (Board, Spec) {+        let mut b = Board { x1: w, y1: h, layer_names: vec!["F.Cu".into(), "B.Cu".into()], copper: vec!["F.Cu".into(), "B.Cu".into()], ..Board::default() };+        b.polys.push(crate::board::Poly { net: "V".into(), layer: "F.Cu".into(), pts: vec![(0., 0.), (w, 0.), (w, h), (0., h)] });+        for (r, x, y) in [("J1", 1., h / 2.), ("J2", pad.0, pad.1)] { b.pads.push(crate::board::Pad { reference: r.into(), pad: "1".into(), net: "V".into(), x, y, layers: vec!["F.Cu".into()], area: 1., func: String::new() }); }+        let spec = serde_json::from_value(json!({"copperUm": 35, "loads": {"V": {"amps": 3, "terminals": [{"reference": "J1", "pad": "1", "amps": 3}, {"reference": "J2", "pad": "1", "amps": -3}]}}})).unwrap();+        (b, spec)+    }+    // issue 27: a pad centred on the far edge is modelled, whether or not the board is a whole number of cells+    #[test] fn terminal_on_the_far_edge_has_copper() {+        for (w, h, cols, rows) in [(9.53, 6.0, 48, 30), (10.0, 6.0, 50, 30), (9.41, 6.03, 48, 31)] {+            for pad in [(w, h / 2.), (w / 2., h), (w + 0.1, h / 2.)] {+                let (b, spec) = edge_board(w, h, pad);+                let f = analyze(&b, &spec, 0.2, 60., &mut |_| {});+                assert_eq!((f.w, f.h), (cols, rows), "the grid covers {w} x {h}");+                assert_eq!(f.nets.len(), 1, "{w} x {h}, pad at {pad:?}"); assert!(f.nets[0].converged);+                assert!(f.nets[0].terminalCurrents.iter().any(|t| t["pin"] == "J2.1"));+            }+        }+        // past the 1 mm reach it is still no copper, not a guess+        let (b, spec) = edge_board(10.0, 6.0, (12.5, 3.0));+        assert!(analyze(&b, &spec, 0.2, 60., &mut |_| {}).nets.is_empty());+    }+    #[test] fn a_point_on_or_just_past_the_outline_snaps_to_the_edge_cell() {+        let at = |x, y| cell_at(x, y, 100., 50., 0.2, 50, 30);+        assert_eq!(at(100.0, 50.0), Some((0, 0))); assert_eq!(at(110.0, 56.0), Some((29, 49))); assert_eq!(at(110.1, 56.1), Some((29, 49))); assert_eq!(at(99.9, 49.9), Some((0, 0)));+        assert_eq!(at(104.3, 53.1), Some((15, 21)));+        assert_eq!(at(110.5, 53.0), None); assert_eq!(at(99.5, 53.0), None); assert_eq!(at(105.0, 56.5), None);+    }+    // the extra column reaches past the outline, but the air only sees the board's part of it+    #[test] fn a_sliver_of_a_cell_adds_no_air() {+        let rise = |w: f64| {+            let mut b = Board { x1: w, y1: 6., layer_names: vec!["F.Cu".into(), "B.Cu".into()], copper: vec!["F.Cu".into(), "B.Cu".into()], ..Board::default() };+            b.pads.push(crate::board::Pad { reference: "U1".into(), pad: "1".into(), net: "GND".into(), x: 3., y: 3., layers: vec!["F.Cu".into()], area: 4., func: String::new() });+            let spec: Spec = serde_json::from_value(json!({"copperUm": 35, "hot": {"U1": {"watts": 0.2, "tabNet": "GND"}}})).unwrap();+            analyze(&b, &spec, 0.5, 60., &mut |_| {}).chips[0].riseC+        };+        let (fit, sliver) = (rise(10.0), rise(10.01));+        assert!((fit - sliver).abs() / fit < 0.005, "10.00 mm: {fit}, 10.01 mm: {sliver}");+    }+}++#[cfg(test)] mod solid_board_tests {+    use super::*;+    // issue 28: a 4-layer board, 17.5 µm copper and 1.5 mm of dielectric: 1.57 mm in all, 1.535 mm counting the outer copper only+    fn solve(bodies: Value) -> Fields {+        let mut b = Board { x1: 20., y1: 20., ..Board::default() };+        b.pads.push(crate::board::Pad { reference: "Q1".into(), pad: "1".into(), net: "GND".into(), x: 10., y: 10., layers: vec!["F.Cu".into()], area: 16., func: String::new() });+        for l in ["F.Cu", "B.Cu"] { b.polys.push(crate::board::Poly { net: "GND".into(), layer: l.into(), pts: vec![(1., 1.), (19., 1.), (19., 19.), (1., 19.)] }); }+        let spec: Spec = serde_json::from_value(json!({"copperUm": 17.5, "stackupMm": [0.2, 1.1, 0.2], "hot": {"Q1": {"watts": 1, "tabNet": "GND"}}, "solids": {"pitchMm": 4, "bodies": bodies}})).unwrap();+        assert!((board_thickness(&spec) - 1.57).abs() < 1e-9);+        analyze(&b, &spec, 1., 60., &mut |_| {})+    }+    fn carrier(top: f64) -> Value { json!({"name": "carrier", "boxesMm": [[2, 2, top - 2.0, 18, 18, top]], "conductivityWMk": 50, "hStillWM2k": 5, "hMovingWM2k": 20, "boardContactM2kW": 1e-4}) }+    fn warnings(f: &Fields) -> Vec<&Issue> { f.issues.iter().filter(|i| i.kind == "solid-uncoupled").collect() }+    #[test] fn a_body_under_the_board_takes_the_same_heat_at_either_thickness() {+        let base = solve(json!([carrier(-1.57)]));+        let w = -base.solids["still"][0]["toBoardW"].as_f64().unwrap(); assert!(w > 0.05, "{w}"); assert!(warnings(&base).is_empty());+        assert_eq!(base.solids["still"][0]["onCopper"], true);+        // the sum older specs had to use, and a nominal board thickness+        for top in [-1.535, -1.6, -1.5] {+            let f = solve(json!([carrier(top)]));+            assert!((-f.solids["still"][0]["toBoardW"].as_f64().unwrap() - w).abs() < 0.03 * w, "z = {top}"); assert!(warnings(&f).is_empty(), "z = {top}");+        }+    }+    #[test] fn a_declared_contact_that_misses_the_copper_is_reported() {+        // well under the board, and at the right height beside it+        let beside = json!({"name": "carrier", "boxesMm": [[30, 2, -3.57, 40, 18, -1.57]], "conductivityWMk": 50, "hStillWM2k": 5, "hMovingWM2k": 20, "boardContactM2kW": 1e-4});+        for body in [carrier(-2.0), beside, carrier(-1.4)] {+            let f = solve(json!([body]));+            assert_eq!(f.solids["still"][0]["toBoardW"].as_f64().unwrap().abs(), 0.0);+            let w = warnings(&f); assert_eq!(w.len(), 1); assert_eq!(w[0].severity, "high"); assert!(w[0].what.contains("-1.570"), "{}", w[0].what);+        }+        // a miss is reported for a body with heat of its own too: the heat stays out of the board+        let mut hot = carrier(-2.0); hot["watts"] = json!(1.0);+        assert_eq!(warnings(&solve(json!([hot])))[0].severity, "high");+    }+    #[test] fn a_body_carrying_nothing_is_reported_and_a_working_one_is_not() {+        let plain = |name: &str, bx: [f64; 6]| json!({"name": name, "boxesMm": [bx], "conductivityWMk": 50, "hStillWM2k": 5, "hMovingWM2k": 20});+        // no contact, no link, no heat: 0 W+        let w = solve(json!([plain("block", [2., 2., -3.57, 18., 18., -1.57])])); let w = warnings(&w);+        assert_eq!(w.len(), 1); assert_eq!(w[0].severity, "medium"); assert!(w[0].what.contains("0 W"));+        // a sink with its own heat and no board contact is fine+        let mut own = plain("sink", [2., 2., 5., 18., 18., 8.]); own["watts"] = json!(1.0);+        assert!(warnings(&solve(json!([own]))).is_empty());+    } }  /// The modeled maximum (cells at least a quarter copper) before clipping the displayed texture at its 99.5th percentile (PR 8).
src/pours.rs+1−1
@@ -11,7 +11,7 @@ fn r2(x: f64) -> f64 { (x * 100.0).round() / 100.0 }  pub fn review(f: &Fields, b: &Board, spec: &Spec, issues: &mut Vec<Issue>) -> Value {     let (w, h, d) = (f.w, f.h, f.d); let n = w * h; let nl = LAYERS.len();-    let cell = |x: f64, y: f64| -> Option<usize> { let (c, r) = (((x - f.x0) / d).floor(), ((y - f.y0) / d).floor()); if c < 0.0 || r < 0.0 || c as usize >= w || r as usize >= h { None } else { Some(r as usize * w + c as usize) } };+    let cell = |x: f64, y: f64| -> Option<usize> { crate::fields::cell_at(x, y, f.x0, f.y0, d, w, h).map(|(r, c)| r * w + c) };     let at = |i: usize| [f.x0 + ((i % w) as f64 + 0.5) * d, f.y0 + ((i / w) as f64 + 0.5) * d];     // a piece smaller than this is a sliver of fill, not a pour fragment worth a move     let min_cells = ((0.5 / (d * d)).ceil() as usize).max(2);
src/raster.rs+14−2
@@ -5,8 +5,13 @@ 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>> }  impl Raster {-    pub fn new(b: &Board, res: f64) -> Raster {-        Raster { w: ((b.width() / res).round() as usize).max(1), h: ((b.height() / res).round() as usize).max(1), res, x0: b.x0, y0: b.y0, masks: HashMap::new() }+    pub fn new(b: &Board, res: f64) -> Raster { Raster::pooled(b, res, 1) }+    /// A fine grid that pools `pool` pixels into each solve cell: whole cells, rounded up so the grid covers the whole+    /// outline and a pad on the far edge has copper in it (issue 27). The 1e-6 keeps a board that is an exact number of+    /// cells from gaining a column to floating-point noise.+    pub fn pooled(b: &Board, res: f64, pool: usize) -> Raster {+        let px = |mm: f64| ((mm / (res * pool as f64) - 1e-6).ceil() as usize).max(1) * pool;+        Raster { w: px(b.width()), h: px(b.height()), res, x0: b.x0, y0: b.y0, masks: HashMap::new() }     }     fn mask(&mut self, net: &str, layer: &str) -> &mut Vec<u8> {         let n = self.w * self.h;@@ -168,6 +173,13 @@ fn dilate(m: &[u8], w: usize, h: usize, r: i64) -> Vec<u8> {  #[cfg(test)] mod drill_tests {     use super::*;+    // issue 27: whole solve cells, rounded up so they cover the outline; an exact fit gains nothing+    #[test] fn pooled_grid_covers_the_outline() {+        let g = |x1: f64, y1: f64| { let r = Raster::pooled(&Board { x1, y1, ..Board::default() }, 0.1, 2); (r.w, r.h) };+        assert_eq!(g(49.53, 20.0), (496, 200)); assert_eq!(g(49.41, 20.03), (496, 202)); assert_eq!(g(50.0, 28.0), (500, 280)); assert_eq!(g(0.01, 0.01), (2, 2));+        // the same when a rounding error leaves the width a hair over a whole cell+        assert_eq!(g(16.200000000000003, 60.1 - 40.1), (162, 200));+    }     // PR 9: a drill is a void on every layer even where a pour crosses it; a slot is void along its own axis     #[test] fn drill_voids() {         let mut b = Board { x1: 4.0, y1: 4.0, layer_names: vec!["F.Cu".into(), "B.Cu".into()], copper: vec!["F.Cu".into(), "B.Cu".into()], ..Board::default() };
src/report.rs+1−1
@@ -7,7 +7,7 @@ use serde_json::{json, Value}; use std::path::Path;  const AREAS: &[(&str, &str, &[&str])] = &[-    ("thermal", "Thermal", &["tab-rise", "thermal-vias", "junction-temperature", "capacitor-temperature", "electro-thermal", "capacitor"]),+    ("thermal", "Thermal", &["tab-rise", "thermal-vias", "junction-temperature", "capacitor-temperature", "electro-thermal", "capacitor", "solid-uncoupled"]),     ("copper", "Current and copper", &["current-neck", "ampacity", "ir-drop", "current-barrel", "pour-fragmented", "load-unsolved", "solve-unconverged"]),     ("power", "Power delivery", &["pdn-impedance", "decoupling-far", "decoupling-missing", "output-ripple", "inductor-ripple", "input-filter-stability", "hot-plug-overshoot", "regulator", "stage-feed-unbypassed"]),     ("signals", "Signal integrity", &["impedance", "diff-impedance", "length-skew", "crosstalk", "no-reference", "via-stub", "stackup-adjacent-signals", "stackup-far-reference", "plane", "cavity-resonance", "tone-in-band", "stage-isolation", "circuit-path"]),
src/solids.rs+52−10
@@ -24,7 +24,7 @@ pub struct Body {     /// 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+    /// a face lying on the board's outer copper (z = 0 on top, z = -thickness under it, within 0.05 mm) couples to it through this, m²K/W     #[serde(default)] pub board_contact_m2k_w: Option<f64>,     #[serde(default)] pub provenance: String, }@@ -108,12 +108,13 @@ pub struct Attached {     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)+    pub on_copper: Vec<bool>,                       // per body: a face of it couples to the board's outer copper }  #[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> {+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, under_face: (f64, 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 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(), on_copper: vec![false; s.bodies.len()] };     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];@@ -153,18 +154,21 @@ pub fn attach(sys: &mut System, s: &Solids, g: &Grid, level: f64, fan_n: f64, x0             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); }+                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)); a.on_copper[b] = true; 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 top_under = -g.zs[k + 1];+        let cover = if s.bodies[b].board_contact_m2k_w.is_some() && top_under >= under_face.0 && top_under <= under_face.1 && (k + 1 >= nz || g.owner[g.idx(i, j, k + 1)].is_none()) { board_cells([g.xs[i], g.ys[j], g.xs[i + 1], g.ys[j + 1]]) } else { Vec::new() };+        if !cover.is_empty() {             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+            // that face was counted as exposed above; take its air loss back (a cell past the board's edge stays in the air)             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); }+            a.on_copper[b] = true;+            for (bc, ar) in cover { 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)); } }@@ -191,6 +195,29 @@ pub fn attach(sys: &mut System, s: &Solids, g: &Grid, level: f64, fan_n: f64, x0 fn r3(x: f64) -> f64 { (x * 1000.0).round() / 1000.0 }  impl Attached {+    /// The bodies the solve left out of the board's heat without saying so (issue 28): a declared boardContactM2kW that no+    /// face met, and a body carrying nothing, with no heat of its own and no path to the board or to a body that has one.+    /// Returns (severity, body, what, fix).+    pub fn unused(&self, s: &Solids, thickness: f64) -> Vec<(&'static str, usize, String, String)> {+        let nb = s.bodies.len();+        let mut par: Vec<usize> = (0..nb).collect();+        fn find(p: &mut [usize], mut x: usize) -> usize { while p[x] != x { p[x] = p[p[x]]; x = p[x]; } x }+        for &(ca, cb, ..) in &self.faces { let (ra, rb) = (find(&mut par, self.cells[ca].1), find(&mut par, self.cells[cb].1)); par[ra] = rb; }+        let mut live = vec![false; nb]; // a group of touching bodies with heat in it or a path to the board+        for &(_, c, _) in &self.board { let r = find(&mut par, self.cells[c].1); live[r] = true; }+        let body_of: std::collections::HashMap<usize, usize> = self.cells.iter().map(|c| (c.0, c.1)).collect();+        for (nd, _) in &self.sources { if let Some(bi) = body_of.get(nd) { let r = find(&mut par, *bi); live[r] = true; } }+        let mut out = Vec::new();+        for (bi, b) in s.bodies.iter().enumerate() {+            let (lo, hi) = (b.boxes_mm.iter().map(|x| x[2]).fold(f64::MAX, f64::min), b.boxes_mm.iter().map(|x| x[5]).fold(f64::MIN, f64::max));+            if b.board_contact_m2k_w.is_some() && !self.on_copper[bi] {+                out.push(("high", bi, format!("{}: boardContactM2kW is set but none of its faces is on the board's copper, so no heat crosses that contact: the body spans z = {:.3} to {:.3} mm, and the copper faces are z = 0 and z = {:.3} mm, within the outline", b.name, lo, hi, -thickness), format!("Put a face of {} on the copper: the top face at z = {:.3} mm for a body under the board, the bottom face at z = 0 for one on top, overlapping the outline. If the gap is real, model it as a body with its own conductivity and remove boardContactM2kW.", b.name, -thickness)));+            } else if !live[find(&mut par, bi)] {+                out.push(("medium", bi, format!("{}: touches no copper and has no heat of its own, so it carries 0 W and changes nothing: it spans z = {:.3} to {:.3} mm", b.name, lo, hi), format!("To take heat from the board, set boardContactM2kW on {} with a face at z = {:.3} mm (under) or z = 0 (on top), or add a boardLink; otherwise remove it.", b.name, -thickness)));+            }+        }+        out+    }     /// 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) {@@ -205,7 +232,7 @@ impl Attached {             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)}));+            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), "onCopper": self.on_copper[bi], "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)@@ -224,7 +251,7 @@ mod tests {         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 a = attach(&mut sys, &s, &g, 0.0, 0.5, 100.0, 100.0, 0.5, 1e-5, 1.6, (1.55, 1.65), &|_| 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);@@ -234,12 +261,27 @@ mod tests {         let lid = bodies[1]["maxRiseC"].as_f64().unwrap(); assert!(lid > 71.0 && lid < 73.0, "{lid}");         assert!(bodies[0]["maxRiseC"].as_f64().unwrap() > lid);     }+    // issue 28: a top face inside the window couples to B.Cu; a cell of the same face past the board's edge stays in the air+    #[test]+    fn under_board_face_couples_inside_the_window_and_only_over_the_board() {+        let mut bd = body("carrier", [0., 0., -3.57, 4., 2., -1.57], 50.0, 10.0); bd.board_contact_m2k_w = Some(1e-4);+        let s = Solids { pitch_mm: 2.0, bodies: vec![bd], contacts: vec![], board_links: vec![], assumptions: vec![] };+        let g = s.validate([0., 0., 2., 2.], &|_| 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, 0.0, 0.0, 1.0, 1e-5, 1.57, (1.485, 1.62), &|_| 0.0).unwrap();+        assert!(a.on_copper[0]); assert_eq!(a.board.iter().map(|x| x.1).max(), Some(0), "only the cell over the board couples");+        let top = 2.0 * 2.0 / (1.0 / 1e-5 + 1.0 / 0.05);+        assert!((a.air[1].1 - a.air[0].1 - top).abs() < 1e-9, "the off-board cell keeps its top face in the air: {:?}", a.air);+        let mut miss = System::new(1, 2, 2); miss.leak[0] = vec![1e-6; 4];+        let m = attach(&mut miss, &s, &g, 0.0, 0.5, 0.0, 0.0, 1.0, 1e-5, 1.57, (1.0, 1.5), &|_| 0.0).unwrap();+        assert!(!m.on_copper[0] && m.board.is_empty()); assert_eq!(m.unused(&s, 1.57).len(), 1);+    }     #[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());+        assert!(attach(&mut sys, &s, &g, 0.0, 0.5, 0.0, 0.0, 0.5, 1e-5, 1.6, (1.55, 1.65), &|_| 0.0).is_err());     }     #[test]     fn overlapping_bodies_are_refused() {
src/ui/app.js+4−1
@@ -410,7 +410,10 @@   function applyTextures() {     for (const L of LAYERS) {       const name = texName(L); const key = name;-      if (!state.tex[key]) { const t = new BAB.Texture('tex/' + name, scene, false, true, BAB.Texture.NEAREST_SAMPLINGMODE); t.hasAlpha = true; state.tex[key] = t; }+      if (!state.tex[key]) { const t = new BAB.Texture('tex/' + name, scene, false, true, BAB.Texture.NEAREST_SAMPLINGMODE); t.hasAlpha = true;+        // the grid can reach past the outline by up to a cell: show the part on the board, cells where they are (row 0 is the board's y0, the top of the image)+        t.uScale = board.widthMm / (board.cols * board.cellMm); t.vScale = board.heightMm / (board.rows * board.cellMm); t.vOffset = 1 - t.vScale;+        state.tex[key] = t; }       const m = state.layerMeshes[L].material; m.diffuseTexture = state.tex[key];       const selected = state.view.layer || 'F.Cu';       m.alpha = (L === selected) ? 1.0 : (state.view.quantity === 'temperature' ? 0.0 : 0.35);