app
Adom Fields
Public Made by Adomby adom
Adom Fields: where the current and the heat go on your board.
← Commit history
adom-fields 0.7.6: the design review and fix plan an AI acts on, every issue with an action; the Ground view
18 files changed
+478−29
Cargo.lock+1−1Cargo.toml+1−1README.md+12SKILL.md+15−1bin/adom-fieldsdocs/release-0.7.6.md+5docs/videos/ground-poster.jpgdocs/videos/ground.mp4package.json+7−2page.json+13−10skills/adom-fields/SKILL.md+15−1src/emc.rs+203src/fields.rs+8−4src/main.rs+11−2src/serve.rs+6−3src/ui/app.js+10−3src/ui/ground.js+169src/ui/index.html+2−1Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-fields"-version = "0.7.5"+version = "0.7.6" dependencies = [ "clap", "png",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-fields"-version = "0.7.5"+version = "0.7.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."
README.md+12@@ -113,6 +113,18 @@ Where the board could radiate: the switching hot loop and the smallest it could [Download the EMC video](docs/videos/emc.mp4) +### Ground++The return path: ground's own voltage drop, the possible loops through cables, floating islands, and the stitching-via fence along the board edge.++<video controls preload="metadata" width="1080" poster="docs/videos/ground-poster.jpg" src="docs/videos/ground.mp4"></video>++[Download the ground video](docs/videos/ground.mp4)++## What to fix: the review for the AI++Every solve now ends with a design review an AI flow can act on without looking at a screen: each finding (a current neck, a hot tab, the switching hot loop, copper with no ground under it, ground offset, islands, gaps in the stitching fence, IR drop, converter ripple) comes with a plain fix and an action to carry out (move this capacitor to these pins, add ground vias at these points, widen this net here), and `fixPlan` orders them. On the buck: the hot loop is fine (2.7 mm² against a best of 1.5), but only a third of the board edge is stitched, so the plan adds eight ground vias at the exact points it lists.+ ## The app `adom-fields serve` puts the board in 3D on the canonical Adom 3D viewer (adom/adom-3d-viewer, vendored and served by the binary: its camera, ViewCube, studio lighting, framing and Z-up frame, as 3d-viewer-design asks): the picked field on the picked layer, the other layers faded, the vias as dots, the hot parts labelled, the legend, the number under the cursor. On the left the nets by what they carry and the hot parts hottest first; on the right the issues and the method. Every click is a command: the state lives on the server and goes out over a live channel, so `adom-fields ui view --quantity heat --layer B.Cu` does exactly what the button does, and `adom-fields tour` walks the whole board for the AI Flow video.
SKILL.md+15−1@@ -54,7 +54,7 @@ Every camera move and the idle sway run at half the 0.4 speed (the sway period i ## Drive it (the AI's wheel) ```-adom-fields ui view --quantity current|heat|temperature|signals|flow|river|energy|light|cycle|charge|magnetic|emc [--explode 0..1] [--probe mm] [--speed 1] [--layer F.Cu|In1.Cu|In2.Cu|B.Cu] [--camera top|iso|bottom|fit|zoom --at x,y] [--air still|moving] [--parts auto|ghost|solid|off]+adom-fields ui view --quantity current|heat|temperature|signals|flow|river|energy|light|cycle|charge|magnetic|emc|ground [--explode 0..1] [--probe mm] [--speed 1] [--layer F.Cu|In1.Cu|In2.Cu|B.Cu] [--camera top|iso|bottom|fit|zoom --at x,y] [--air still|moving] [--parts auto|ghost|solid|off] adom-fields ui select --net +VBAT # one net lit alone (current) adom-fields ui select --chip Q3 # fly to a hot part, heat view adom-fields ui issue --index 0 # fly to an issue@@ -125,6 +125,20 @@ The model is the ideal continuous-conduction buck solved exactly: D = Vout/Vin, - **Radiated emissions**: the hot loop as a small loop antenna (Ott: E = 1.316·10⁻¹⁴ f² A I / r), its current's harmonics from the trapezoid (duty from the cycle, edge time from the spec's `switching.<net>.riseNs`, 8 ns by default), 30 MHz to 1 GHz at 3 m against the FCC Part 15 class B limit, with the worst margin and what the smallest loop would gain. A screen, not a chamber: differential mode only; cables (common mode), which often dominate, are not modelled. +## 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`, `inductor-ripple`, `output-ripple`.+- **`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).+- Budgets in the spec: `groundOffsetMv` (10), `dropPct` (1 % of the rail), `emcFmaxHz` (1 GHz, for the stitching pitch), `switching.<net>.riseNs` (8).++After applying a step, run `analyze` again: the issue goes away or says how far it still is. In the app, clicking an issue opens the view that shows it (EMC, Ground, River, Cycle).++## The ground view (0.7.6)++`ui view --quantity ground`: the ground copper lifted by its solved millivolts (rings every step), each part's ground pin tagged with its offset, dashed arcs for the possible loops through the ground connectors' cables, floating ground islands in red, and the stitching fence: a ring per ground via (λ/40 reach) with the uncovered stretches of the edge in red.+ ## Honest limits (0.1) DC only (no skin effect, no switching loss on the copper); per-layer thickness when copperUmByLayer is supplied; the 1.6 mm stack is 0.2 / 1.065 / 0.2 mm unless `stackupMm` says otherwise; convection is a flat coefficient on both faces (10 or 25 W/m²K), optional specified rectangular conducting blocks; no fins, enclosure or radiation; the tab watts come from the spec, not from a switching model. It is a screen that finds the necks and the starved tabs, and shows them; it is not a datasheet number.
bin/adom-fields⋯ 1 unchanged line ⋯
docs/release-0.7.6.mdadded+5@@ -0,0 +1,5 @@+# adom-fields 0.7.6++- **The review and the fix plan, for the AI that called the solve.** `analyze` now reviews the design: the switching hot loop per input capacitor against the best the switch allows, the emissions margin (FCC B, loop only), copper with no ground under it, ground offset, floating ground islands, the stitching fence along the edge, possible ground loops through the cables, IR drop per rail, the inductor ripple ratio and the output ripple. Every issue (the solve's own included) carries an `action` an AI can carry out (move, add-vias with the points, widen, pour, add-copper, add-part, review); `fixPlan` orders them; `analyze` prints a Review and a Fix plan hint. `fields.json` `review` holds the numbers. Budgets: `groundOffsetMv`, `dropPct`, `emcFmaxHz`, `switching.<net>.riseNs`.+- **Ground.** `ui view --quantity ground`: the ground copper lifted by its millivolts, each ground pin's offset, the possible loops through the ground connectors, floating islands, the stitching fence and its gaps. A narrated video on the page.+- Clicking an issue in the app opens the view that shows it.
docs/videos/ground-poster.jpgadded⋯ 1 unchanged line ⋯
docs/videos/ground.mp4added⋯ 1 unchanged line ⋯
package.json+7−2@@ -1,7 +1,7 @@ { "slug": "adom-fields", "type": "app",- "version": "0.7.5",+ "version": "0.7.6", "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.",@@ -64,7 +64,12 @@ "radiated emissions estimate", "fcc class b", "return path",- "ground under trace"+ "ground under trace",+ "ground loop",+ "ground offset",+ "stitching vias",+ "design review of a pcb",+ "fix plan for a board layout" ], "sample_prompts": [ {
page.json+13−10@@ -1,7 +1,7 @@ { "slug": "adom-fields", "type": "app",- "version": "0.7.5",+ "version": "0.7.6", "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",@@ -64,7 +62,12 @@ "radiated emissions estimate", "fcc class b", "return path",- "ground under trace"+ "ground under trace",+ "ground loop",+ "ground offset",+ "stitching vias",+ "design review of a pcb",+ "fix plan for a board layout" ], "sample_prompts": [ {@@ -105,4 +108,4 @@ "name": "John Lauer", "email": "[email protected]" }-}+}
skills/adom-fields/SKILL.md+15−1@@ -54,7 +54,7 @@ Every camera move and the idle sway run at half the 0.4 speed (the sway period i ## Drive it (the AI's wheel) ```-adom-fields ui view --quantity current|heat|temperature|signals|flow|river|energy|light|cycle|charge|magnetic|emc [--explode 0..1] [--probe mm] [--speed 1] [--layer F.Cu|In1.Cu|In2.Cu|B.Cu] [--camera top|iso|bottom|fit|zoom --at x,y] [--air still|moving] [--parts auto|ghost|solid|off]+adom-fields ui view --quantity current|heat|temperature|signals|flow|river|energy|light|cycle|charge|magnetic|emc|ground [--explode 0..1] [--probe mm] [--speed 1] [--layer F.Cu|In1.Cu|In2.Cu|B.Cu] [--camera top|iso|bottom|fit|zoom --at x,y] [--air still|moving] [--parts auto|ghost|solid|off] adom-fields ui select --net +VBAT # one net lit alone (current) adom-fields ui select --chip Q3 # fly to a hot part, heat view adom-fields ui issue --index 0 # fly to an issue@@ -125,6 +125,20 @@ The model is the ideal continuous-conduction buck solved exactly: D = Vout/Vin, - **Radiated emissions**: the hot loop as a small loop antenna (Ott: E = 1.316·10⁻¹⁴ f² A I / r), its current's harmonics from the trapezoid (duty from the cycle, edge time from the spec's `switching.<net>.riseNs`, 8 ns by default), 30 MHz to 1 GHz at 3 m against the FCC Part 15 class B limit, with the worst margin and what the smallest loop would gain. A screen, not a chamber: differential mode only; cables (common mode), which often dominate, are not modelled. +## 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`, `inductor-ripple`, `output-ripple`.+- **`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).+- Budgets in the spec: `groundOffsetMv` (10), `dropPct` (1 % of the rail), `emcFmaxHz` (1 GHz, for the stitching pitch), `switching.<net>.riseNs` (8).++After applying a step, run `analyze` again: the issue goes away or says how far it still is. In the app, clicking an issue opens the view that shows it (EMC, Ground, River, Cycle).++## The ground view (0.7.6)++`ui view --quantity ground`: the ground copper lifted by its solved millivolts (rings every step), each part's ground pin tagged with its offset, dashed arcs for the possible loops through the ground connectors' cables, floating ground islands in red, and the stitching fence: a ring per ground via (λ/40 reach) with the uncovered stretches of the edge in red.+ ## Honest limits (0.1) DC only (no skin effect, no switching loss on the copper); per-layer thickness when copperUmByLayer is supplied; the 1.6 mm stack is 0.2 / 1.065 / 0.2 mm unless `stackupMm` says otherwise; convection is a flat coefficient on both faces (10 or 25 W/m²K), optional specified rectangular conducting blocks; no fins, enclosure or radiation; the tab watts come from the spec, not from a switching model. It is a screen that finds the necks and the starved tabs, and shows them; it is not a datasheet number.
src/emc.rsadded+203@@ -0,0 +1,203 @@+//! The design review the caller acts on: EMC, ground, rails and the converter, each finding an issue with+//! a plain fix and an `action` an AI can carry out (move a part, add vias, widen, pour, review), plus the+//! ordered fix plan. The app draws the same findings (the EMC, Ground, River and Cycle views); this is the+//! part an AI flow reads without looking at a screen.+use crate::board::{Board, Pad, LAYERS};+use crate::fields::{Fields, Issue, Spec};+use crate::flow::{is_ground, is_switch_name, rail_from_name};+use serde_json::{json, Value};++const MU: f64 = 1.0;+fn r2(v: f64) -> f64 { (v * 100.0).round() / 100.0 }+fn parse_value(s: &str, unit: char) -> Option<f64> {+ for tok in s.replace(',', ".").split_whitespace() {+ let t: Vec<char> = tok.chars().collect();+ let mut i = 0; while i < t.len() && (t[i].is_ascii_digit() || t[i] == '.') { i += 1; }+ if i == 0 || i >= t.len() { continue; }+ let whole: String = t[..i].iter().collect();+ let mul = match t[i] { 'p' => 1e-12, 'n' => 1e-9, 'u' | 'µ' => 1e-6, 'm' => 1e-3, _ => continue };+ let mut j = i + 1; while j < t.len() && t[j].is_ascii_digit() { j += 1; }+ let frac: String = t[i + 1..j].iter().collect();+ let rest: String = t[j..].iter().collect();+ if !rest.is_empty() && !rest.eq_ignore_ascii_case(&unit.to_string()) { continue; }+ let v: f64 = if frac.is_empty() { whole.parse().ok()? } else { format!("{whole}.{frac}").parse().ok()? };+ return Some(v * mul * MU);+ }+ None+}+fn issue(sev: &str, kind: &str, what: String, at: Option<[f64; 2]>, net: Option<&str>, reference: Option<&str>, fix: String, action: Value) -> Issue {+ Issue { severity: sev.into(), kind: kind.into(), what, at: at.map(|a| [r2(a[0]), r2(a[1])]), layer: Some("F.Cu".into()), net: net.map(str::to_string), reference: reference.map(str::to_string), fix, action: Some(action) }+}+fn shoelace(p: &[(f64, f64)]) -> f64 { let mut a = 0.0; for i in 0..p.len() { let j = (i + 1) % p.len(); a += p[i].0 * p[j].1 - p[j].0 * p[i].1; } (a / 2.0).abs() }++/// The review: adds issues to `f.issues`-style list and returns the `review` block for fields.json.+pub fn review(f: &Fields, b: &Board, spec: &Spec, issues: &mut Vec<Issue>) -> Value {+ let rail = |n: &str| spec.voltages.get(n).copied().or_else(|| rail_from_name(n));+ let gnd = f.net_names.iter().find(|n| is_ground(n)).cloned();+ let pads_of = |r: &str, n: &str| -> Vec<&Pad> { b.pads.iter().filter(|p| p.reference == r && p.net == n).collect() };+ let nets_of = |r: &str| -> Vec<String> { let mut v: Vec<String> = b.pads.iter().filter(|p| p.reference == r).map(|p| p.net.clone()).collect(); v.sort(); v.dedup(); v };+ let refs: Vec<String> = { let mut v: Vec<String> = b.pads.iter().map(|p| p.reference.clone()).collect(); v.sort(); v.dedup(); v };+ let near = |list: &[&Pad], x: f64, y: f64| list.iter().min_by(|a, c| ((a.x - x).hypot(a.y - y)).partial_cmp(&(c.x - x).hypot(c.y - y)).unwrap()).map(|p| (*p).clone());+ let mut out = json!({});+ // ---- the converter: switching node, inductor, switch, input, output ------------------------------+ let sw = spec.switching.keys().next().cloned().or_else(|| f.net_names.iter().find(|n| is_switch_name(n)).cloned());+ let mut conv: Option<Value> = None;+ if let (Some(sw), Some(gnd)) = (sw.clone(), gnd.clone()) {+ let lref = refs.iter().find(|r| r.starts_with('L') && nets_of(r).contains(&sw)).cloned();+ let out_net = lref.as_ref().and_then(|l| nets_of(l).into_iter().find(|n| *n != sw));+ let swref = spec.sources.get(&sw).and_then(|v| v.first().cloned()).or_else(|| refs.iter().find(|r| r.starts_with('U') && nets_of(r).contains(&sw)).cloned());+ let in_net = swref.as_ref().and_then(|u| { let mut c: Vec<String> = nets_of(u).into_iter().filter(|n| *n != sw && *n != gnd && rail(n).is_some()).collect(); c.sort_by(|a, z| rail(z).partial_cmp(&rail(a)).unwrap()); c.into_iter().next() });+ if let (Some(lref), Some(out_net), Some(swref), Some(in_net)) = (lref, out_net, swref, in_net) {+ let caps = |net: &str| -> Vec<(String, f64)> { refs.iter().filter(|r| r.starts_with('C') && nets_of(r).contains(&net.to_string()) && nets_of(r).contains(&gnd)).filter_map(|r| b.values.get(r).and_then(|v| parse_value(v, 'F')).map(|c| (r.clone(), c))).collect() };+ let (cin, cout) = (caps(&in_net), caps(&out_net));+ let l = b.values.get(&lref).and_then(|v| parse_value(v, 'H'));+ let (vin, vout) = (rail(&in_net), rail(&out_net));+ let iout = spec.loads.get(&out_net).map(|l| l.amps);+ let swcfg = spec.switching.get(&sw);+ let fsw = swcfg.and_then(|s| s.freqHz).unwrap_or(500e3);+ let tr = swcfg.and_then(|s| s.riseNs).unwrap_or(8.0) * 1e-9;+ if let (Some(l), Some(vin), Some(vout), Some(iout)) = (l, vin, vout, iout) {+ if vout < vin && vout > 0.0 {+ let t = 1.0 / fsw; let dcy = vout / vin; let di = (vin - vout) * dcy * t / l;+ let ccin: f64 = cin.iter().map(|c| c.1).sum(); let ccout: f64 = cout.iter().map(|c| c.1).sum();+ let rip_out = if ccout > 0.0 { di / (8.0 * fsw * ccout) } else { f64::NAN };+ let rip_in = if ccin > 0.0 { iout * dcy * (1.0 - dcy) / (fsw * ccin) } else { f64::NAN };+ let ratio = di / iout;+ // the hot loop of each input capacitor: its rail pad -> the switch's input pin -> its ground pin -> its ground pad+ let (vin_pins, g_pins) = (pads_of(&swref, &in_net), pads_of(&swref, &gnd));+ let mut loops: Vec<(String, f64, f64, [Pad; 4])> = Vec::new();+ for (r, c) in &cin { let (cv, cg) = (pads_of(r, &in_net), pads_of(r, &gnd)); if cv.is_empty() || cg.is_empty() || vin_pins.is_empty() || g_pins.is_empty() { continue; } let uv = near(&vin_pins, cv[0].x, cv[0].y).unwrap(); let ug = near(&g_pins, cg[0].x, cg[0].y).unwrap(); let a = shoelace(&[(cv[0].x, cv[0].y), (uv.x, uv.y), (ug.x, ug.y), (cg[0].x, cg[0].y)]); loops.push((r.clone(), *c, a, [cv[0].clone(), uv, ug, cg[0].clone()])); }+ let hf = loops.iter().min_by(|a, z| a.1.partial_cmp(&z.1).unwrap()).cloned();+ let ideal = if let (Some(uv), Some(_)) = (vin_pins.first(), g_pins.first()) { let ug = near(&g_pins, uv.x, uv.y).unwrap(); Some(((uv.x - ug.x).hypot(uv.y - ug.y) * 0.8, (*uv).clone(), ug)) } else { None };+ // the hot loop as a loop antenna (Ott), trapezoid harmonics, FCC class B at 3 m+ let mut margin = None;+ if let Some(h) = &hf {+ let (a, ipk) = (h.2 * 1e-6, iout + di / 2.0); let sinc = |x: f64| if x == 0.0 { 1.0 } else { x.sin() / x };+ let lim = |fr: f64| if fr < 88e6 { 40.0 } else if fr < 216e6 { 43.5 } else if fr < 960e6 { 46.0 } else { 54.0 };+ let (mut worst, mut wf) = (f64::MAX, 0.0);+ let mut n = (30e6 / fsw).ceil() as u64; let stepn = ((1e9 - 30e6) / fsw / 4000.0).max(1.0) as u64;+ while (n as f64) * fsw <= 1e9 { let fr = n as f64 * fsw; let inn = 2.0 * ipk * dcy * (std::f64::consts::PI * n as f64 * dcy).pipe(sinc).abs() * (std::f64::consts::PI * n as f64 * tr / t).pipe(sinc).abs(); let e = 1.316e-14 * fr * fr * a * inn / 3.0; let db = 20.0 * (e * 1e6 + 1e-30).log10(); let m = lim(fr) - db; if m < worst { worst = m; wf = fr; } n += stepn; }+ margin = Some((worst, wf));+ if let Some((ia, uv, ug)) = &ideal {+ if h.2 > (ia * 2.0).max(ia + 3.0) {+ let gain = 20.0 * (h.2 / ia).log10();+ issues.push(issue(if h.2 > 4.0 * ia { "medium" } else { "low" }, "hot-loop", format!("{}: the hot loop {}→{}→{} is {:.1} mm²; the switch allows {:.1} mm²", h.0, h.0, swref, gnd, h.2, ia), Some([(h.3[0].x + h.3[3].x) / 2.0, (h.3[0].y + h.3[3].y) / 2.0]), Some(&in_net), Some(&h.0),+ format!("Move {} so its pads sit straight across {} pin {} ({}) and pin {} ({}), on the same layer with no vias in the loop: about {:.0} dB less radiated noise.", h.0, swref, uv.pad, in_net, ug.pad, gnd, gain),+ json!({"type": "move", "ref": h.0, "toPins": [format!("{}.{}", swref, uv.pad), format!("{}.{}", swref, ug.pad)], "targetMm2": (ia * 10.0).round() / 10.0, "currentMm2": (h.2 * 10.0).round() / 10.0, "gainDb": (gain * 10.0).round() / 10.0})));+ }+ }+ if worst < 6.0 { issues.push(issue(if worst < 0.0 { "high" } else { "medium" }, "emissions", format!("The hot loop's estimated emissions come within {:.1} dB of FCC class B at {:.0} MHz (differential mode, 3 m)", worst, wf / 1e6), Some([h.3[1].x, h.3[1].y]), Some(&sw), Some(&swref), "Shrink the hot loop (the input capacitor across the switch's pins), slow the edge if the part allows, and keep the switch node's copper small.".into(), json!({"type": "move", "ref": h.0, "toPins": ideal.as_ref().map(|(_, uv, ug)| vec![format!("{}.{}", swref, uv.pad), format!("{}.{}", swref, ug.pad)]), "why": "emissions margin"}))); }+ }+ // the converter itself: the inductor ripple ratio and the output ripple+ if !(0.15..=0.5).contains(&ratio) { issues.push(issue("low", "inductor-ripple", format!("{}: the ripple is {:.0} % of the load current (typical design: 20 to 40 %)", lref, ratio * 100.0), b.pads.iter().find(|p| p.reference == lref).map(|p| [p.x, p.y]), Some(&sw), Some(&lref), if ratio > 0.5 { format!("A larger inductance (about {:.0} µH for 30 %) or a higher switching frequency.", (vin - vout) * dcy * t / (0.3 * iout) * 1e6) } else { format!("A smaller inductance (about {:.0} µH for 30 %) responds faster and is smaller.", (vin - vout) * dcy * t / (0.3 * iout) * 1e6) }, json!({"type": "review", "what": "inductor value", "ref": lref, "suggestUh": ((vin - vout) * dcy * t / (0.3 * iout) * 1e7).round() / 10.0}))); }+ if rip_out.is_finite() && rip_out > 0.01 * vout { issues.push(issue("medium", "output-ripple", format!("{}: output ripple about {:.1} mV, over 1 % of {} V", out_net, rip_out * 1e3, vout), None, Some(&out_net), None, format!("Add output capacitance (about {:.0} µF more for 1 %) or low-ESR ceramics close to {}.", (di / (8.0 * fsw * 0.01 * vout) - ccout) * 1e6, lref), json!({"type": "add-part", "what": "output capacitance", "net": out_net, "uF": ((di / (8.0 * fsw * 0.01 * vout) - ccout) * 1e6).ceil()}))); }+ conv = Some(json!({"switchNet": sw, "switch": swref, "inductor": lref, "inNet": in_net, "outNet": out_net, "vin": vin, "vout": vout, "iout": iout, "fswHz": fsw, "riseNs": tr * 1e9, "duty": dcy, "rippleA": di, "rippleRatio": ratio, "lUh": l * 1e6, "cinUf": ccin * 1e6, "coutUf": ccout * 1e6, "rippleInMv": rip_in * 1e3, "rippleOutMv": rip_out * 1e3,+ "hotLoops": loops.iter().map(|(r, c, a, _)| json!({"cap": r, "uF": c * 1e6, "mm2": (a * 100.0).round() / 100.0})).collect::<Vec<_>>(), "hotLoop": hf.as_ref().map(|h| json!({"cap": h.0, "mm2": (h.2 * 100.0).round() / 100.0})), "idealMm2": ideal.as_ref().map(|i| (i.0 * 100.0).round() / 100.0),+ "emissions": margin.map(|(m, fr)| json!({"worstMarginDb": (m * 10.0).round() / 10.0, "atMHz": (fr / 1e6).round(), "limit": "FCC 15 class B, 3 m", "model": "Ott differential-mode loop; no cables"}))}));+ }+ }+ }+ }+ out["converter"] = conv.unwrap_or(Value::Null);+ // ---- the reference plane under power and switching copper ----------------------------------------+ let (w, h, n) = (f.w, f.h, f.w * f.h);+ let real: Vec<usize> = (0..LAYERS.len()).filter(|k| f.net_ids[*k].iter().filter(|x| **x != u16::MAX).count() > n * 15 / 100).collect();+ let gid = gnd.as_ref().and_then(|g| f.net_names.iter().position(|x| x == g)).map(|i| i as u16);+ let mut refs_out = Vec::new();+ if let (Some(g), true) = (gid, real.len() >= 2) {+ let (k0, k1) = (real[0], real[1]);+ let mut per: std::collections::BTreeMap<u16, (usize, usize, f64, f64)> = Default::default();+ for i in 0..n { let id = f.net_ids[k0][i]; if id == u16::MAX || id == g { continue; } let name = &f.net_names[id as usize]; if !(f.flow.contains_key(name) || spec.switching.contains_key(name)) { continue; } let e = per.entry(id).or_default(); if f.net_ids[k1][i] == g { e.0 += 1; } else { e.1 += 1; e.2 += f.x0 + ((i % w) as f64 + 0.5) * f.d; e.3 += f.y0 + ((i / w) as f64 + 0.5) * f.d; } }+ for (id, (on, off, sx, sy)) in per {+ let name = f.net_names[id as usize].clone(); let pct = off as f64 / (on + off).max(1) as f64 * 100.0; let mm2 = off as f64 * f.d * f.d;+ let switching = spec.switching.contains_key(&name) || is_switch_name(&name);+ refs_out.push(json!({"net": name, "unreferencedPct": (pct * 10.0).round() / 10.0, "unreferencedMm2": (mm2 * 10.0).round() / 10.0}));+ if (switching && pct > 5.0) || pct > 20.0 { let at = [sx / off.max(1) as f64, sy / off.max(1) as f64]; issues.push(issue(if switching { "medium" } else { "low" }, "no-reference", format!("{}: {:.0} % of its top copper ({:.1} mm²) has no {} under it", name, pct, mm2, gnd.clone().unwrap_or_default()), Some(at), Some(&name), None, format!("Pour {} on {} under {} around ({:.1}, {:.1}), or route it over solid ground, so its return current does not detour.", gnd.clone().unwrap_or_default(), LAYERS[k1], name, at[0], at[1]), json!({"type": "pour", "net": gnd, "layer": LAYERS[k1], "under": name, "at": at}))); }+ }+ }+ out["reference"] = json!(refs_out);+ // ---- ground: offset, islands, stitching, connectors to the outside -----------------------------------+ if let (Some(gname), Some(g)) = (gnd.clone(), gid) {+ let mut gout = json!({"net": gname});+ if let Some(nf) = f.flow.get(&gname) {+ let (mut lo, mut hi, mut at_hi) = (f64::MAX, f64::MIN, [0.0, 0.0]);+ for k in 0..LAYERS.len() { for i in 0..n { if nf.on[k].v[i] == 0.0 { continue; } let v = (nf.v_ext - nf.v[k].v[i] as f64).max(f64::MIN); if v < lo { lo = v; } if v > hi { hi = v; at_hi = [f.x0 + ((i % w) as f64 + 0.5) * f.d, f.y0 + ((i / w) as f64 + 0.5) * f.d]; } } }+ let span = (hi - lo).max(0.0);+ gout["offsetMv"] = json!((span * 1e5).round() / 100.0);+ let budget = spec.groundOffsetMv.unwrap_or(10.0);+ if span * 1e3 > budget { issues.push(issue("medium", "ground-offset", format!("{}: {:.1} mV across the ground copper (budget {:.0} mV)", gname, span * 1e3, budget), Some(at_hi), Some(&gname), None, format!("Widen the ground path near ({:.1}, {:.1}) or add ground vias to the other layer there, so the return current has a lower-resistance way home.", at_hi[0], at_hi[1]), json!({"type": "add-vias", "net": gname, "at": at_hi, "why": "ground offset"}))); }+ let ext: Vec<&crate::flow::Terminal> = { let mut v: Vec<&crate::flow::Terminal> = nf.terminals.iter().filter(|t| t.side == "ext").collect(); v.dedup_by(|a, z| a.reference == z.reference); v };+ gout["toOutside"] = json!(ext.iter().map(|t| t.reference.clone()).collect::<Vec<_>>());+ if ext.len() > 1 { let (a, z) = (ext[0], ext[ext.len() - 1]); issues.push(issue("low", "ground-loop-risk", format!("{} connectors carry {} off the board ({}): cables that meet off the board close a loop", ext.len(), gname, ext.iter().map(|t| t.reference.clone()).collect::<Vec<_>>().join(", ")), Some([(a.x + z.x) / 2.0, (a.y + z.y) / 2.0]), Some(&gname), None, "Return each cable's ground on its own connector and route paired wires together (twisted or adjacent), or tie the grounds at one point.".into(), json!({"type": "review", "what": "ground cabling", "connectors": ext.iter().map(|t| t.reference.clone()).collect::<Vec<_>>()}))); }+ }+ // islands: ground copper with no via or pin to the main body+ if real.len() >= 1 {+ let mut link = vec![false; n];+ for v in b.vias.iter().filter(|v| v.net == gname) { let c = ((v.x - f.x0) / f.d) as isize; let r = ((v.y - f.y0) / f.d) as isize; if c >= 0 && r >= 0 && (c as usize) < w && (r as usize) < h { link[r as usize * w + c as usize] = true; } }+ for p in b.pads.iter().filter(|p| p.net == gname && p.layers.len() > 1) { let c = ((p.x - f.x0) / f.d) as isize; let r = ((p.y - f.y0) / f.d) as isize; if c >= 0 && r >= 0 && (c as usize) < w && (r as usize) < h { link[r as usize * w + c as usize] = true; } }+ let nl = real.len(); let mut comp = vec![-1i32; nl * n]; let mut sizes: Vec<(usize, f64, f64)> = Vec::new();+ for li in 0..nl { for i in 0..n { if f.net_ids[real[li]][i] != g || comp[li * n + i] >= 0 { continue; } let id = sizes.len() as i32; let mut st = vec![li * n + i]; comp[li * n + i] = id; let (mut sz, mut sx, mut sy) = (0usize, 0.0, 0.0);+ while let Some(v) = st.pop() { let (l, j) = (v / n, v % n); let (r, c) = (j / w, j % w); sz += 1; sx += c as f64; sy += r as f64; let mut nb = Vec::new(); if c > 0 { nb.push(v - 1); } if c + 1 < w { nb.push(v + 1); } if r > 0 { nb.push(v - w); } if r + 1 < h { nb.push(v + w); } if link[j] { for l2 in 0..nl { if l2 != l { nb.push(l2 * n + j); } } } for u in nb { if comp[u] < 0 && f.net_ids[real[u / n]][u % n] == g { comp[u] = id; st.push(u); } } }+ sizes.push((sz, sx / sz as f64, sy / sz as f64)); } }+ let main = sizes.iter().enumerate().max_by_key(|(_, s)| s.0).map(|(i, _)| i);+ let isl: Vec<(f64, [f64; 2])> = sizes.iter().enumerate().filter(|(i, s)| Some(*i) != main && s.0 as f64 * f.d * f.d > 0.5).map(|(_, s)| (s.0 as f64 * f.d * f.d, [f.x0 + (s.1 + 0.5) * f.d, f.y0 + (s.2 + 0.5) * f.d])).collect();+ gout["islands"] = json!(isl.iter().map(|(a, at)| json!({"mm2": (a * 10.0).round() / 10.0, "at": at})).collect::<Vec<_>>());+ for (a, at) in &isl { issues.push(issue("low", "ground-island", format!("{}: a {:.1} mm² island of ground copper with no via or pin to the rest", gname, a), Some(*at), Some(&gname), None, format!("Add a ground via in the island at ({:.1}, {:.1}) to tie it to the plane, or remove it: floating copper acts as an antenna.", at[0], at[1]), json!({"type": "add-vias", "net": gname, "at": at, "count": 1, "why": "tie a floating island"}))); }+ }+ // the stitching fence along the board edge: lambda/20 at the highest frequency of interest+ let fmax = spec.emcFmaxHz.unwrap_or(1e9); let er = 4.3; let lambda = 299.792458 / (fmax / 1e9) / ((er + 1.0) / 2.0f64).sqrt(); let pitch = lambda / 20.0; let reach = pitch / 2.0;+ let gv: Vec<(f64, f64)> = b.vias.iter().filter(|v| v.net == gname).map(|v| (v.x, v.y)).collect();+ let (bw, bh) = (b.width(), b.height()); let per = 2.0 * (bw + bh); let step = 0.4; let mut s = 0.0; let mut gaps: Vec<Vec<(f64, f64)>> = Vec::new(); let mut run: Vec<(f64, f64)> = Vec::new(); let (mut cov, mut tot) = (0usize, 0usize);+ while s < per { let (x, y) = if s < bw { (b.x0 + s, b.y0) } else if s < bw + bh { (b.x0 + bw, b.y0 + s - bw) } else if s < 2.0 * bw + bh { (b.x0 + bw - (s - bw - bh), b.y0 + bh) } else { (b.x0, b.y0 + bh - (s - 2.0 * bw - bh)) };+ let ok = gv.iter().any(|v| (v.0 - x).hypot(v.1 - y) <= reach + 1.0); tot += 1; if ok { cov += 1; if run.len() > 1 { gaps.push(std::mem::take(&mut run)); } run.clear(); } else { run.push((x, y)); } s += step; }+ if run.len() > 1 { gaps.push(run); }+ gaps.sort_by(|a, z| z.len().cmp(&a.len()));+ gout["stitching"] = json!({"fmaxHz": fmax, "pitchMm": (pitch * 10.0).round() / 10.0, "edgeCoveredPct": (cov as f64 / tot.max(1) as f64 * 1000.0).round() / 10.0, "gaps": gaps.iter().take(6).map(|g| json!({"from": [g[0].0, g[0].1], "to": [g[g.len() - 1].0, g[g.len() - 1].1], "mm": g.len() as f64 * step})).collect::<Vec<_>>()});+ for gp in gaps.iter().filter(|g| g.len() as f64 * step > pitch.min(4.0)) { let mm = gp.len() as f64 * step; let cnt = (mm / pitch).ceil().max(1.0) as usize; issues.push(issue("low", "stitching-gap", format!("{}: {:.1} mm of board edge with no ground via within {:.1} mm", gname, mm, reach), Some([gp[gp.len() / 2].0, gp[gp.len() / 2].1]), Some(&gname), None, format!("Add {} ground via(s) along the edge (the action lists each point) from ({:.1}, {:.1}) to ({:.1}, {:.1}), one every {:.1} mm, about 0.6 mm in from the edge.", cnt, gp[0].0, gp[0].1, gp[gp.len() - 1].0, gp[gp.len() - 1].1, pitch), { // the via points themselves, along the gap, set in from whichever edge each sits on+ let every = ((pitch / step).round() as usize).max(1); let first = (gp.len() % every) / 2;+ let pts: Vec<[f64; 2]> = gp.iter().enumerate().filter(|(k, _)| *k >= first && (k - first) % every == 0).map(|(_, p)| { let (mut x, mut y) = *p; if (y - b.y0).abs() < 1e-6 { y += 0.6; } else if (y - (b.y0 + bh)).abs() < 1e-6 { y -= 0.6; } if (x - b.x0).abs() < 1e-6 { x += 0.6; } else if (x - (b.x0 + bw)).abs() < 1e-6 { x -= 0.6; } [r2(x), r2(y)] }).collect();+ json!({"type": "add-vias", "net": gname, "points": pts, "from": [r2(gp[0].0), r2(gp[0].1)], "to": [r2(gp[gp.len() - 1].0), r2(gp[gp.len() - 1].1)], "pitchMm": (pitch * 10.0).round() / 10.0, "count": pts.len().max(cnt), "insetMm": 0.6}) })); }+ out["ground"] = gout;+ }+ // ---- the rails: IR drop against a budget ------------------------------------------------------------+ let mut rails = Vec::new();+ for (name, nf) in &f.flow {+ let Some(rv) = rail(name) else { continue }; if rv <= 0.0 || spec.switching.contains_key(name) { continue; }+ let (mut lo, mut hi) = (f64::MAX, f64::MIN); for k in 0..LAYERS.len() { for i in 0..n { if nf.on[k].v[i] != 0.0 { let v = nf.v[k].v[i] as f64; lo = lo.min(v); hi = hi.max(v); } } }+ let drop = (hi - lo).max(0.0); let budget = spec.dropPct.unwrap_or(1.0);+ rails.push(json!({"net": name, "railV": rv, "dropMv": (drop * 1e5).round() / 100.0, "dropPct": (drop / rv * 1e4).round() / 100.0}));+ if drop / rv * 100.0 > budget { let nr = f.nets.iter().find(|x| x.net == *name); issues.push(issue("medium", "ir-drop", format!("{}: {:.0} mV of IR drop, {:.1} % of {} V (budget {:.1} %)", name, drop * 1e3, drop / rv * 100.0, rv, budget), nr.map(|x| x.peakAt), Some(name), None, format!("Widen {} where it narrows (its current-density peak marks the spot) or add a parallel path on another layer.", name), json!({"type": "widen", "net": name, "at": nr.map(|x| x.peakAt), "why": "IR drop"}))); }+ }+ out["rails"] = json!(rails);+ out+}++trait Pipe: Sized { fn pipe<R>(self, f: impl FnOnce(Self) -> R) -> R { f(self) } }+impl Pipe for f64 {}++/// Actions for the solver's own issues (current necks, tab rise, thermal vias), so every finding carries one.+pub fn act_on_core(issues: &mut [Issue], f: &Fields) {+ for i in issues.iter_mut() {+ if i.action.is_some() { continue; }+ i.action = Some(match i.kind.as_str() {+ "current-neck" => { let nr = i.net.as_ref().and_then(|n| f.nets.iter().find(|x| x.net == *n)); json!({"type": "widen", "net": i.net, "at": i.at, "layer": i.layer, "amps": nr.map(|x| x.amps), "why": "current density over the neck threshold"}) }+ "tab-rise" => json!({"type": "add-copper", "ref": i.reference, "net": i.net, "at": i.at, "also": ["add thermal vias under the tab", "heatsink or airflow"]}),+ "thermal-vias" => json!({"type": "add-vias", "ref": i.reference, "net": i.net, "at": i.at, "why": "thermal vias under the tab"}),+ _ => json!({"type": "review"}),+ });+ }+}++/// The fix plan: every action, worst first, numbered, for the caller to work down.+pub fn fix_plan(issues: &[Issue]) -> Value {+ let rank = |s: &str| match s { "high" => 0, "medium" => 1, _ => 2 };+ let review = |i: &Issue| i.action.as_ref().and_then(|a| a["type"].as_str()).map_or(1, |t| if t == "review" { 1 } else { 0 });+ let mut v: Vec<&Issue> = issues.iter().filter(|i| i.action.is_some()).collect();+ v.sort_by_key(|i| (rank(&i.severity), review(i)));+ json!(v.iter().enumerate().map(|(k, i)| json!({"step": k + 1, "severity": i.severity, "kind": i.kind, "do": i.fix, "action": i.action, "at": i.at})).collect::<Vec<_>>())+}++#[cfg(test)] mod tests {+ use super::*;+ #[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+8−4@@ -31,6 +31,8 @@ pub struct Spec { #[serde(default)] pub sources: BTreeMap<String, Vec<String>>, /// switching nodes: the copper that swings between two rails ("SW": {"highV": 12, "lowV": 0, "freqHz": 500000}) #[serde(default)] pub switching: BTreeMap<String, crate::flow::Switching>,+ /// the design review's budgets: ground offset (mV, 10), IR drop per rail (% of the rail, 1), the highest EMC frequency (Hz, 1 GHz)+ #[serde(default)] pub groundOffsetMv: Option<f64>, #[serde(default)] pub dropPct: Option<f64>, #[serde(default)] pub emcFmaxHz: Option<f64>, } fn d35() -> f64 { 35.0 } fn d60() -> f64 { 60.0 }@@ -42,7 +44,9 @@ pub struct NetResult { pub net: String, pub amps: f64, pub layers: Vec<String>, #[derive(Serialize, Clone)] pub struct ChipResult { pub reference: String, pub watts: f64, pub tabNet: String, pub x: f64, pub y: f64, pub riseC: f64, pub riseMovingC: f64, pub planeW: f64, pub viaW: f64, pub vias: usize, pub otherViaW: f64, pub otherVias: usize, pub dielW: f64, pub airW: f64, pub viasWanted: usize } #[derive(Serialize, Clone)]-pub struct Issue { pub severity: String, pub kind: String, pub what: String, pub at: Option<[f64; 2]>, pub layer: Option<String>, pub net: Option<String>, pub reference: Option<String>, pub fix: String }+pub struct Issue { pub severity: String, pub kind: String, pub what: String, pub at: Option<[f64; 2]>, pub layer: Option<String>, pub net: Option<String>, pub reference: Option<String>, pub fix: String,+ /// what to change, in a form an AI can carry out (EMC and ground findings): {type: move|add-vias|pour|tie|review, ...}+ #[serde(skip_serializing_if = "Option::is_none")] pub action: Option<Value> } pub struct Fields { pub d: f64, pub h: usize, pub w: usize, pub x0: f64, pub y0: f64,@@ -330,15 +334,15 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu // amp through a thin track is a warm trace and twenty amps through a neck is a fuse if x.peakJ > neck { let severity = if x.amps < 1.0 { "low" } else if x.peakJ > 2.5 * neck { "high" } else { "medium" };- issues.push(Issue { severity: severity.into(), kind: "current-neck".into(), what: format!("{}: {:.0} A/mm² at its worst point on {} ({} A through a neck about {:.2} mm wide at {:.0} µm; the threshold is {neck:.0} A/mm²)", x.net, x.peakJ, x.peakLayer, x.amps, x.amps / (x.peakJ * spec.copperUmByLayer.get(&x.peakLayer).copied().unwrap_or(spec.copperUm)/1000.0), spec.copperUmByLayer.get(&x.peakLayer).copied().unwrap_or(spec.copperUm)), at: Some(x.peakAt), layer: Some(x.peakLayer.clone()), net: Some(x.net.clone()), reference: None, fix: if x.amps < 1.0 { "A small current: widen the track if it is long, otherwise note it and move on.".into() } else { "Widen the neck or add a parallel path on the other layer with vias; the density map shows where the current squeezes.".into() } });+ issues.push(Issue { severity: severity.into(), kind: "current-neck".into(), what: format!("{}: {:.0} A/mm² at its worst point on {} ({} A through a neck about {:.2} mm wide at {:.0} µm; the threshold is {neck:.0} A/mm²)", x.net, x.peakJ, x.peakLayer, x.amps, x.amps / (x.peakJ * spec.copperUmByLayer.get(&x.peakLayer).copied().unwrap_or(spec.copperUm)/1000.0), spec.copperUmByLayer.get(&x.peakLayer).copied().unwrap_or(spec.copperUm)), at: Some(x.peakAt), layer: Some(x.peakLayer.clone()), net: Some(x.net.clone()), reference: None, fix: if x.amps < 1.0 { "A small current: widen the track if it is long, otherwise note it and move on.".into() } else { "Widen the neck or add a parallel path on the other layer with vias; the density map shows where the current squeezes.".into() } , action: None }); } } for c in &chips { if c.riseC > spec.hotMaxRiseC {- issues.push(Issue { severity: if c.riseMovingC > spec.hotMaxRiseC { "high".into() } else { "medium".into() }, kind: "tab-rise".into(), what: format!("{}: +{:.0} °C at its tab in still air (+{:.0} °C in moving air) against a {:.0} °C budget", c.reference, c.riseC, c.riseMovingC, spec.hotMaxRiseC), at: Some([c.x, c.y]), layer: Some("F.Cu".into()), net: Some(c.tabNet.clone()), reference: Some(c.reference.clone()), fix: if c.riseMovingC > spec.hotMaxRiseC { "More copper under the tab and more vias to the other layers, or a heatsink; moving air alone does not bring it in.".into() } else { "Moving air brings it in; in still air it needs more copper under the tab, more vias, or a heatsink.".into() } });+ issues.push(Issue { severity: if c.riseMovingC > spec.hotMaxRiseC { "high".into() } else { "medium".into() }, kind: "tab-rise".into(), what: format!("{}: +{:.0} °C at its tab in still air (+{:.0} °C in moving air) against a {:.0} °C budget", c.reference, c.riseC, c.riseMovingC, spec.hotMaxRiseC), at: Some([c.x, c.y]), layer: Some("F.Cu".into()), net: Some(c.tabNet.clone()), reference: Some(c.reference.clone()), fix: if c.riseMovingC > spec.hotMaxRiseC { "More copper under the tab and more vias to the other layers, or a heatsink; moving air alone does not bring it in.".into() } else { "Moving air brings it in; in still air it needs more copper under the tab, more vias, or a heatsink.".into() } , action: None }); } if c.vias < c.viasWanted {- issues.push(Issue { severity: "medium".into(), kind: "thermal-vias".into(), what: format!("{}: {} via(s) within {r_chip:.0} mm of its tab, the spec asks for {}", c.reference, c.vias, c.viasWanted), at: Some([c.x, c.y]), layer: Some("F.Cu".into()), net: Some(c.tabNet.clone()), reference: Some(c.reference.clone()), fix: "Land the thermal vias under the tab pad; without them the tab's heat has only the top copper to spread on.".into() });+ issues.push(Issue { severity: "medium".into(), kind: "thermal-vias".into(), what: format!("{}: {} via(s) within {r_chip:.0} mm of its tab, the spec asks for {}", c.reference, c.vias, c.viasWanted), at: Some([c.x, c.y]), layer: Some("F.Cu".into()), net: Some(c.tabNet.clone()), reference: Some(c.reference.clone()), fix: "Land the thermal vias under the tab pad; without them the tab's heat has only the top copper to spread on.".into() , action: None }); } } let _ = (tab_total, joule_total);
src/main.rs+11−2@@ -1,6 +1,6 @@ //! adom-fields: where the current and the heat go on your board. use sha2::{Digest, Sha256};-mod eagle; mod flow; 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 eagle; mod emc; mod flow; 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 std::path::PathBuf; @@ -90,7 +90,12 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck std::fs::create_dir_all(out.join("tex")).map_err(|e| e.to_string())?; let t0 = std::time::Instant::now(); let mut lines: Vec<String> = Vec::new();- let f = fields::analyze(&b, &spec, pitch, neck, &mut |s| { eprintln!("{s}"); lines.push(s.to_string()); });+ let mut f = fields::analyze(&b, &spec, pitch, neck, &mut |s| { eprintln!("{s}"); lines.push(s.to_string()); });+ // the design review the caller acts on: EMC, ground, rails, the converter; every issue gets an action+ let mut iss = std::mem::take(&mut f.issues);+ let review = emc::review(&f, &b, &spec, &mut iss);+ emc::act_on_core(&mut iss, &f);+ f.issues = iss; if !f.heat_residual.is_finite() || f.heat_residual>1e-6 || !f.heat_moving_residual.is_finite() || f.heat_moving_residual>1e-6 {return Err(format!("thermal solve did not converge: still {}, moving {}; refine the model or increase the solve budget",f.heat_residual,f.heat_moving_residual));} let tex = out.join("tex"); let mut textures: Vec<String> = Vec::new();@@ -121,6 +126,8 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck } } let mut summary = fields::summary(&f, &b, &spec, &textures);+ summary["review"] = review;+ summary["fixPlan"] = emc::fix_plan(&f.issues); summary["flow"] = flow::write(out, &f, &b, &spec)?; summary["board"]["name"] = serde_json::json!(spec.board.split('(').next().unwrap_or(&spec.board).trim()); summary["board"]["description"] = serde_json::json!(spec.board);@@ -149,6 +156,8 @@ fn main() { ok(&format!("{} nets solved, {} hot parts, {} issue(s), in {} s: {}", nets, s["chips"].as_array().map(|a| a.len()).unwrap_or(0), issues.len(), s["seconds"], out.join("fields.json").display()), &[ format!("Peak current density {:.0} A/mm²; peak rise {:.0} °C still air, {:.0} °C moving air, budget {:.0} °C; Joule loss {} W on top of {} W from the tabs.", s["scales"]["jMax"].as_f64().unwrap_or(0.0), s["heat"]["peakRiseC"].as_f64().unwrap_or(0.0), s["heat"]["peakRiseMovingC"].as_f64().unwrap_or(0.0), s["heat"]["budgetC"].as_f64().unwrap_or(0.0), s["heat"]["jouleW"], s["heat"]["tabW"]), issues.iter().take(3).map(|i| format!("{} {}: {}", i["severity"].as_str().unwrap_or(""), i["kind"].as_str().unwrap_or(""), i["what"].as_str().unwrap_or(""))).collect::<Vec<_>>().join(" | "),+ { let r = &s["review"]; let c = &r["converter"]; let g = &r["ground"]; format!("Review: {}{}{}.", if c.is_null() { "no switching converter modelled".to_string() } else { format!("hot loop {} {} mm² (best {} mm²), emissions margin {} dB at {} MHz (FCC B, loop only), output ripple {:.1} mV", c["hotLoop"]["cap"].as_str().unwrap_or("?"), c["hotLoop"]["mm2"], c["idealMm2"], c["emissions"]["worstMarginDb"], c["emissions"]["atMHz"], c["rippleOutMv"].as_f64().unwrap_or(0.0)) }, if g.is_null() { String::new() } else { format!("; ground offset {} mV, {} island(s), edge stitched {} %", g["offsetMv"], g["islands"].as_array().map_or(0, |x| x.len()), g["stitching"]["edgeCoveredPct"]) }, r["reference"].as_array().map(|a| a.iter().filter(|x| x["unreferencedPct"].as_f64().unwrap_or(0.0) > 5.0).map(|x| format!("; {} {}% without ground under it", x["net"].as_str().unwrap_or(""), x["unreferencedPct"])).collect::<String>()).unwrap_or_default()) },+ { let p = s["fixPlan"].as_array().cloned().unwrap_or_default(); if p.is_empty() { "Fix plan: nothing to change.".to_string() } else { format!("Fix plan ({} step(s), fields.json fixPlan has the actions): {}", p.len(), p.iter().take(5).map(|x| format!("{}) [{}] {}", x["step"], x["kind"].as_str().unwrap_or(""), x["do"].as_str().unwrap_or(""))).collect::<Vec<_>>().join(" ")) } }, if s["parts"].is_null() { "Parts: skipped (--no-parts).".to_string() } else { format!("Parts: {} with 3D models, {} as boxes, {} bare{}.", s["parts"]["modelled"], s["parts"]["boxed"], s["parts"]["bare"], if s["parts"]["failed"].as_array().map_or(false, |a| !a.is_empty()) { "; some models did not convert (see the log above; --models-dir adds a folder to search)" } else { "" }) }, format!("Heatsinks: {}; signal capacitance checks: {}; differential pairs: {}. Signals are screening estimates; unsupported geometry is reported, never certified.", s["heatsinks"].as_array().map_or(0,|x|x.len()),s["signals"]["capacitance"].as_array().map_or(0,|x|x.len()),s["signals"]["differentialPairs"].as_array().map_or(0,|x|x.len())), format!("Next: `adom-fields serve --fields {}` and open it (pup or the browser); `adom-fields ui view --quantity current --net <net>` drives it; `adom-fields tour` walks it for a video.", out.display()),
src/serve.rs+6−3@@ -58,6 +58,7 @@ fn handle(mut req: tiny_http::Request, app: Arc<Mutex<App>>) { ("GET", "/") | ("GET", "/index.html") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("index.html", include_str!("ui/index.html"))).with_header(tiny_http::Header::from_bytes("Content-Type", "text/html; charset=utf-8").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); } ("GET", "/app.js") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("app.js", include_str!("ui/app.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", "/flow.js") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("flow.js", include_str!("ui/flow.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", "/ground.js") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("ground.js", include_str!("ui/ground.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", "/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())); }@@ -67,7 +68,7 @@ fn handle(mut req: tiny_http::Request, app: Arc<Mutex<App>>) { match std::fs::read(&file) { Ok(bytes) => { let _ = req.respond(tiny_http::Response::from_data(bytes).with_header(tiny_http::Header::from_bytes("Content-Type", "application/octet-stream").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); } Err(_) => reply(req, 404, json!({"status": "error", "error": "no flow.bin: this solve predates the flow views", "hint": "Run `adom-fields analyze` again with this version."})) } } ("GET", "/health") => reply(req, 200, json!({"status": "ok", "version": env!("CARGO_PKG_VERSION")})),- ("GET", "/state") => { let a = app.lock().unwrap(); reply(req, 200, json!({"status": "ok", "view": a.view, "board": a.fields["board"], "nets": a.fields["nets"].as_array().map(|x| x.len()).unwrap_or(0), "issues": a.fields["issues"].as_array().map(|x| x.len()).unwrap_or(0), "clients": a.clients.len(), "started": a.started, "_hint": "POST /ui/view {quantity (current|heat|temperature|signals|flow|river|energy|light|cycle|charge|magnetic|emc), probe mm (the magnetic sheet's height), explode 0..1, speed 0.1..4, net, layer, camera (top|iso|bottom|fit|zoom), air, parts: auto|ghost|solid|off, drift, focus: [x, y] mm for camera zoom}; /ui/select {net|chip}; /ui/issue {index}; /ui/toast {text, kind}; GET /events is the SSE channel; GET /fields.json is everything solved."})); }+ ("GET", "/state") => { let a = app.lock().unwrap(); reply(req, 200, json!({"status": "ok", "view": a.view, "board": a.fields["board"], "nets": a.fields["nets"].as_array().map(|x| x.len()).unwrap_or(0), "issues": a.fields["issues"].as_array().map(|x| x.len()).unwrap_or(0), "clients": a.clients.len(), "started": a.started, "_hint": "POST /ui/view {quantity (current|heat|temperature|signals|flow|river|energy|light|cycle|charge|magnetic|emc|ground), probe mm (the magnetic sheet's height), explode 0..1, speed 0.1..4, net, layer, camera (top|iso|bottom|fit|zoom), air, parts: auto|ghost|solid|off, drift, focus: [x, y] mm for camera zoom}; /ui/select {net|chip}; /ui/issue {index}; /ui/toast {text, kind}; GET /events is the SSE channel; GET /fields.json is everything solved."})); } ("GET", "/fields.json") => { // re-read: `adom-fields parts` may have added the 3D models since the server started let mut a = app.lock().unwrap();@@ -98,7 +99,8 @@ fn handle(mut req: tiny_http::Request, app: Arc<Mutex<App>>) { let i = v["index"].as_u64().unwrap_or(0) as usize; let Some(issue) = a.fields["issues"].get(i).cloned() else { drop(a); reply(req, 404, json!({"status": "error", "error": "no such issue", "hint": "GET /fields.json .issues lists them, 0-based"})); return; }; a.view["issue"] = json!(i); a.view["net"] = issue["net"].clone(); if let Some(l) = issue["layer"].as_str() { a.view["layer"] = json!(l); }- a.view["quantity"] = json!(match issue["kind"].as_str() { Some("current-neck") => "current", _ => "heat" });+ // each finding opens the view that shows it+ a.view["quantity"] = json!(match issue["kind"].as_str() { Some("current-neck") => "current", Some("hot-loop") | Some("emissions") | Some("no-reference") => "emc", Some("ground-offset") | Some("ground-island") | Some("stitching-gap") | Some("ground-loop-risk") => "ground", Some("ir-drop") => "river", Some("output-ripple") | Some("inductor-ripple") => "cycle", _ => "heat" }); a.view["camera"] = json!("zoom"); a.view["focus"] = issue["at"].clone(); } else { for k in ["quantity", "net", "layer", "camera", "air", "chip", "drift", "parts", "focus", "explode", "speed", "ghost", "silk", "probe"] { if !v[k].is_null() { a.view[k] = v[k].clone(); } }@@ -179,7 +181,7 @@ fn handle(mut req: tiny_http::Request, app: Arc<Mutex<App>>) { pub fn ui(what: UiCmd, port: u16) { let up = |p: &str, v: &Value| -> Value { post(port, p, v).unwrap_or_else(|e| crate::err(&format!("the app did not answer on {port}: {e}"), &[format!("Start it: `adom-fields serve --fields <dir> --port {port} &`")])) }; match what {- UiCmd::View { quantity, net, layer, camera, air, parts, at, explode, speed, ghost, silk, probe } => { let focus = at.as_deref().map(|t| { let v: Vec<f64> = t.split(',').filter_map(|x| x.trim().parse().ok()).collect(); if v.len() != 2 { crate::err("--at takes x,y in board mm, e.g. --at 113.9,90.5", &[]); } json!(v) }); if let Some(p) = &parts { if !["auto", "ghost", "solid", "off"].contains(&p.as_str()) { crate::err(&format!("--parts {p}: use auto, ghost, solid or off"), &[]); } } if let Some(q) = &quantity { if !["current", "heat", "temperature", "signals", "flow", "river", "energy", "light", "cycle", "charge", "magnetic", "emc"].contains(&q.as_str()) { crate::err(&format!("--quantity {q}: use current, heat, temperature, signals, flow, river, energy, light, cycle, charge, magnetic or emc"), &[]); } } let r = up("/ui/view", &json!({"quantity": quantity, "net": net, "layer": layer, "camera": camera, "air": air, "parts": parts, "focus": focus, "explode": explode, "speed": speed, "ghost": ghost, "silk": silk.as_deref().map(|s| s != "off" && s != "false"), "probe": probe})); crate::ok(&format!("view: {}", r["view"]), &["The app repainted; a screenshot of the window shows it. `ui select --net <net>` lights one net; `ui issue --index n` flies to an issue.".into()]); }+ UiCmd::View { quantity, net, layer, camera, air, parts, at, explode, speed, ghost, silk, probe } => { let focus = at.as_deref().map(|t| { let v: Vec<f64> = t.split(',').filter_map(|x| x.trim().parse().ok()).collect(); if v.len() != 2 { crate::err("--at takes x,y in board mm, e.g. --at 113.9,90.5", &[]); } json!(v) }); if let Some(p) = &parts { if !["auto", "ghost", "solid", "off"].contains(&p.as_str()) { crate::err(&format!("--parts {p}: use auto, ghost, solid or off"), &[]); } } if let Some(q) = &quantity { if !["current", "heat", "temperature", "signals", "flow", "river", "energy", "light", "cycle", "charge", "magnetic", "emc", "ground"].contains(&q.as_str()) { crate::err(&format!("--quantity {q}: use current, heat, temperature, signals, flow, river, energy, light, cycle, charge, magnetic, emc or ground"), &[]); } } let r = up("/ui/view", &json!({"quantity": quantity, "net": net, "layer": layer, "camera": camera, "air": air, "parts": parts, "focus": focus, "explode": explode, "speed": speed, "ghost": ghost, "silk": silk.as_deref().map(|s| s != "off" && s != "false"), "probe": probe})); crate::ok(&format!("view: {}", r["view"]), &["The app repainted; a screenshot of the window shows it. `ui select --net <net>` lights one net; `ui issue --index n` flies to an issue.".into()]); } UiCmd::Explain { quantity, stop } => { let r = up("/ui/explain", &json!({"quantity": quantity, "stop": stop})); crate::ok(&format!("{} to {} page(s)", if stop { "stop sent" } else { "explain sent" }, r["clients"]), &["The page composes the explanation from this board's numbers and plays it with a one-line subtitle; a browser may hold the first audio until the viewer has clicked the page once.".into()]); } UiCmd::Toast { text, kind } => { up("/ui/toast", &json!({"text": text, "kind": kind})); crate::ok("toast shown", &[]); } UiCmd::Select { net, chip } => { let r = up("/ui/select", &json!({"net": net, "chip": chip})); crate::ok(&format!("selected: {}", r["view"]), &[]); }@@ -220,6 +222,7 @@ pub fn tour(port: u16, hold_ms: u64) { toast("Charge: the board as capacitors"); step("/ui/view", json!({"quantity": "charge", "explode": 0.6, "camera": "iso"}), settle); steps += 1; toast("Magnetic: the field every current makes"); step("/ui/view", json!({"quantity": "magnetic", "explode": 0, "camera": "iso"}), settle); steps += 1; toast("EMC: the hot loop, the return path and the estimated emissions"); step("/ui/view", json!({"quantity": "emc", "camera": "top"}), settle); steps += 1;+ toast("Ground: its own voltage drop, the loops, the stitching fence"); step("/ui/view", json!({"quantity": "ground", "camera": "iso"}), settle); steps += 1; step("/ui/view", json!({"explode": 0}), 0); } toast("Back to the current density"); step("/ui/view", json!({"quantity": "current", "air": "still", "camera": "iso"}), settle); steps += 1;
src/ui/app.js+10−3@@ -190,6 +190,7 @@ if (q === 'cycle') return window.fieldsCycle ? window.fieldsCycle.narration() : ''; if (q === 'magnetic') return window.fieldsMagnetic ? window.fieldsMagnetic.narration() : ''; if (q === 'emc') return window.fieldsEmc ? window.fieldsEmc.narration() : '';+ if (q === 'ground') return window.fieldsGround ? window.fieldsGround.narration() : ''; if (window.fieldsFlow && ['flow', 'river', 'energy', 'light', 'charge'].includes(q)) return window.fieldsFlow.narration(q); const n0 = (f.nets || [])[0], c0 = (f.chips || [])[0]; if (q === 'current') return `This is current density: how many amps flow through each square millimetre of copper. Dark is quiet copper, yellow is where the current crowds.${n0 ? ` The busiest spot is on ${n0.net}, at about ${Math.round(n0.peakJ)} amps per square millimetre, on the ${n0.peakLayer === 'F.Cu' ? 'top' : n0.peakLayer === 'B.Cu' ? 'bottom' : 'inner'} copper.` : ''} Above about sixty amps per square millimetre, a narrow neck starts to heat up, and the issues list calls it out. Click a net on the left to see it alone.`;@@ -328,7 +329,7 @@ function texName(L) { const q = state.view.quantity, ls = slug(L); if (q === 'signals') return `copper-${ls}.png`;- if (q === 'river' || q === 'energy' || q === 'light' || q === 'cycle' || q === 'charge' || q === 'magnetic' || q === 'emc') return `copper-${ls}.png`;+ if (q === 'river' || q === 'energy' || q === 'light' || q === 'cycle' || q === 'charge' || q === 'magnetic' || q === 'emc' || q === 'ground') 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`; } return (state.view.air === 'moving' ? 'Tm-' : 'T-') + ls + '.png';@@ -343,7 +344,7 @@ if (L === 'In1.Cu' || L === 'In2.Cu') m.alpha = (L === selected) ? 1.0 : 0.0; } if (viaRoot) viaRoot.setEnabled(!['temperature', 'river'].includes(state.view.quantity));- for (const { el, c } of labels) { el.hidden = ['current','signals','flow','river','energy','light','cycle','charge','magnetic','emc'].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'].includes(state.view.quantity); el.textContent = c.reference + ' +' + (state.view.air === 'moving' ? c.riseMovingC : c.riseC).toFixed(0) + ' °C'; } $('#signalsPanel').hidden = state.view.quantity !== 'signals'; $('#legend').hidden = state.view.quantity === 'signals'; $('#readout').hidden = state.view.quantity === 'signals';@@ -356,6 +357,10 @@ 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.fieldsFlow) window.fieldsFlow.apply(state.view);+ if (window.fieldsGround) {+ window.fieldsGround.apply(state.view);+ if (state.view.quantity === 'ground') { const bd = $('#flowBadge'), kb = $('#btnKey'); let on = false; try { on = localStorage.getItem('fields.keyOn') === '1'; } catch (e) { void e; } const [h, ...rows] = window.fieldsGround.describe(); if (bd) { bd.innerHTML = `<div class="kh">${h}</div><ul>${rows.map(r => `<li>${r}</li>`).join('')}</ul>`; bd.hidden = !on; } if (kb) { kb.hidden = false; kb.classList.toggle('is-on', on); } }+ } if (window.fieldsEmc) { window.fieldsEmc.apply(state.view); if (state.view.quantity === 'emc') { const bd = $('#flowBadge'), kb = $('#btnKey'); let on = false; try { on = localStorage.getItem('fields.keyOn') === '1'; } catch (e) { void e; } const [h, ...rows] = window.fieldsEmc.describe(); if (bd) { bd.innerHTML = `<div class="kh">${h}</div><ul>${rows.map(r => `<li>${r}</li>`).join('')}</ul>`; bd.hidden = !on; } if (kb) { kb.hidden = false; kb.classList.toggle('is-on', on); } }@@ -388,6 +393,7 @@ const x = p.x + board.x0 + board.widthMm / 2, y = board.y0 + board.heightMm / 2 - p.y; const raw = state.raw[rawName(L)]; let val = ''; if (window.fieldsMagnetic && state.view.quantity === 'magnetic') val = window.fieldsMagnetic.readout(L, x, y);+ else if (window.fieldsGround && state.view.quantity === 'ground') val = window.fieldsGround.readout(L, x, y); else if (window.fieldsFlow && ['flow', 'river', 'energy', 'light', 'charge', 'cycle'].includes(state.view.quantity)) val = window.fieldsFlow.readout(L, x, y); else if (raw && raw.d) { const c = Math.min(raw.w - 1, Math.max(0, Math.floor((x - board.x0) / board.cellMm))), r = Math.min(raw.h - 1, Math.max(0, Math.floor((y - board.y0) / board.cellMm))); const i = (r * raw.w + c) * 4; const v = raw.d[i] / 255; const sc = state.fields.scales; const hi = state.view.quantity === 'current' ? sc.jHi : state.view.quantity === 'heat' ? sc.qHi : sc.tHi; const num = v * hi; val = state.view.quantity === 'current' ? num.toFixed(1) + ' A/mm²' : state.view.quantity === 'heat' ? num.toFixed(4) + ' W/mm' : '+' + num.toFixed(1) + ' °C'; } ro.innerHTML = `<span class="k">${L}</span> ${val}<br><span class="k">x</span> ${x.toFixed(2)} <span class="k">y</span> ${y.toFixed(2)} mm`;@@ -476,7 +482,8 @@ // the energy cycle of a switching converter, on the flow view's shaders return import('./cycle.js').then(m => m.attach({ BAB, scene, V, camera, state, Z, LAYERS, frameDt, root, layerMeshes: state.layerMeshes })).then(c => { window.fieldsCycle = c; applyTextures(); }).catch(e => console.error('cycle: ' + (e && e.stack || e))) .then(() => import('./magnetic.js')).then(m => m.attach({ BAB, scene, V, camera, state, LAYERS, frameDt, root, layerMeshes: state.layerMeshes, flow: api })).then(mg => { window.fieldsMagnetic = mg; applyTextures(); }).catch(e => console.error('magnetic: ' + (e && e.stack || e)))- .then(() => import('./emc.js')).then(m => m.attach({ BAB, scene, V, camera, state, LAYERS, frameDt, root, layerMeshes: state.layerMeshes, flow: api, cycle: window.fieldsCycle })).then(em => { window.fieldsEmc = em; applyTextures(); }).catch(e => console.error('emc: ' + (e && e.stack || e))); })+ .then(() => import('./emc.js')).then(m => m.attach({ BAB, scene, V, camera, state, LAYERS, frameDt, root, layerMeshes: state.layerMeshes, flow: api, cycle: window.fieldsCycle })).then(em => { window.fieldsEmc = em; applyTextures(); }).catch(e => console.error('emc: ' + (e && e.stack || e)))+ .then(() => import('./ground.js')).then(m => m.attach({ BAB, scene, V, camera, state, LAYERS, frameDt, root, layerMeshes: state.layerMeshes, flow: api })).then(gr => { window.fieldsGround = gr; applyTextures(); }).catch(e => console.error('ground: ' + (e && e.stack || e))); }) .catch(e => { flowNote = 'The flow view did not load: ' + (e && e.message || e); console.error('flow: ' + (e && e.stack || e)); applyTextures(); }); const st = await fetch('state').then(r => r.json()).catch(() => null); applyView(st && st.view ? st.view : {}, true);
src/ui/ground.jsadded+169@@ -0,0 +1,169 @@+// Adom Fields: the ground. Three checks on the board's return path, drawn on it:+// 1. Ground offset: the GND copper as a rubber sheet, height = the solved millivolts (exaggerated, stated),+// contour rings; each part's ground pin tagged with its offset from the connectors' ground.+// 2. Ground loops and islands: every connector that carries ground to the outside is a cable that may meet+// another off the board and close a loop (dashed arcs, the loop's area estimated); ground copper with no+// via or pin tying it to the rest floats (an antenna), flagged red.+// 3. The stitching fence: each ground via covers lambda/40 around it (lambda/20 spacing at the highest+// frequency of interest, 1 GHz unless the spec says); stretches of the board edge no via covers glow red.+export async function attach(ctx) {+ const { BAB, scene, V, camera, state, LAYERS, frameDt, root, flow } = ctx;+ if (!flow || !flow.data) return null;+ const { lay, real, W, H, d, meta, nets, zOf, cellOf, bx, by, ER } = flow.data;+ const board = state.fields.board;+ const gid = nets.findIndex(n => n.ground);+ if (gid < 0) return { apply() {}, describe: () => ['<b>Ground</b>', 'No ground net in this solve'], narration: () => 'This board has no ground net in the solve.' };+ const G = nets[gid], N = W * H;+ // ---- 1. offset: the GND cells' volts, in mV ----------------------------------------------------------+ let vmin = Infinity, vmax = -Infinity;+ for (const k of real) for (let i = 0; i < N; i++) if (lay[k].net[i] === gid && Number.isFinite(lay[k].volt[i])) { vmin = Math.min(vmin, lay[k].volt[i]); vmax = Math.max(vmax, lay[k].volt[i]); }+ if (!Number.isFinite(vmin)) { vmin = 0; vmax = 1e-6; }+ const span = Math.max(1e-7, vmax - vmin), H_MM = Math.min(4, 0.15 * Math.min(board.widthMm, board.heightMm)), kz = H_MM / span; // mm per volt+ BAB.Effect.ShadersStore.fieldsGroundVertexShader = `precision highp float;+attribute vec3 position; attribute vec2 uv; attribute float mv;+uniform mat4 world; uniform mat4 viewProjection; uniform float lift; varying vec2 vUV; varying float vMv; varying vec3 vW;+void main(void) { vUV = uv; vMv = mv; vec3 p = position + vec3(0.0, 0.0, lift * mv); vW = (world * vec4(p, 1.0)).xyz; gl_Position = viewProjection * vec4(vW, 1.0); }`;+ BAB.Effect.ShadersStore.fieldsGroundFragmentShader = `precision highp float;+varying vec2 vUV; varying float vMv; varying vec3 vW; uniform sampler2D mask; uniform float top; uniform float step; uniform float alpha;+vec3 magma(float t) { t = clamp(t, 0.0, 1.0); vec3 a = vec3(0.05, 0.03, 0.18), b = vec3(0.55, 0.12, 0.5), c = vec3(0.98, 0.45, 0.35), e = vec3(0.99, 0.92, 0.7);+ return t < 0.33 ? mix(a, b, t / 0.33) : t < 0.66 ? mix(b, c, (t - 0.33) / 0.33) : mix(c, e, (t - 0.66) / 0.34); }+void main(void) {+ if (texture2D(mask, vUV).a < 0.5) discard;+ float t = vMv / top; vec3 col = magma(t);+ float f = fract(vMv / step); float ring = smoothstep(0.06, 0.0, min(f, 1.0 - f)); // a ring every step of the scale+ vec3 n = normalize(cross(dFdx(vW), dFdy(vW))); float sh = 0.75 + 0.25 * abs(n.z);+ gl_FragColor = vec4(col * sh + ring * 0.6, alpha);+}`;+ const terrains = [];+ const stride = Math.max(1, Math.ceil(Math.sqrt(N / 60000)));+ for (const k of real) {+ const L = lay[k]; let cells = 0; for (let i = 0; i < N; i++) if (L.net[i] === gid) cells++; if (cells < 0.02 * N) continue;+ const cw = Math.ceil(W / stride) + 1, ch = Math.ceil(H / stride) + 1, pos = new Float32Array(cw * ch * 3), uvs = new Float32Array(cw * ch * 2), mv = new Float32Array(cw * ch), ind = [];+ for (let rr = 0; rr < ch; rr++) for (let cc = 0; cc < cw; cc++) {+ const c = Math.min(W - 1, cc * stride), r = Math.min(H - 1, rr * stride), v = rr * cw + cc;+ pos[v * 3] = bx(meta.x0 + Math.min(W, cc * stride) * d); pos[v * 3 + 1] = by(meta.y0 + Math.min(H, rr * stride) * d);+ uvs[v * 2] = Math.min(W, cc * stride) / W; uvs[v * 2 + 1] = Math.min(H, rr * stride) / H;+ let i = r * W + c; if (L.net[i] !== gid) { let best = -1; for (let rad = 1; rad <= 3 && best < 0; rad++) for (let dr = -rad; dr <= rad && best < 0; dr++) for (let dc = -rad; dc <= rad; dc++) { const r2 = r + dr, c2 = c + dc; if (r2 < 0 || c2 < 0 || r2 >= H || c2 >= W) continue; if (L.net[r2 * W + c2] === gid) { best = r2 * W + c2; break; } } if (best >= 0) i = best; }+ mv[v] = L.net[i] === gid && Number.isFinite(L.volt[i]) ? (L.volt[i] - vmin) * 1000 : 0;+ }+ for (let rr = 0; rr < ch - 1; rr++) for (let cc = 0; cc < cw - 1; cc++) { const a = rr * cw + cc; ind.push(a, a + cw, a + 1, a + 1, a + cw, a + cw + 1); }+ const mesh = new BAB.Mesh('G-' + L.name, scene); const vd = new BAB.VertexData(); vd.positions = pos; vd.uvs = uvs; vd.indices = ind; vd.applyToMesh(mesh, false); mesh.setVerticesData('mv', mv, false, 1);+ const tex = new Uint8Array(N * 4); for (let i = 0; i < N; i++) tex[i * 4 + 3] = L.net[i] === gid ? 255 : 0;+ const mtex = new BAB.RawTexture(tex, W, H, BAB.Constants.TEXTUREFORMAT_RGBA, scene, false, false, BAB.Texture.BILINEAR_SAMPLINGMODE, BAB.Constants.TEXTURETYPE_UNSIGNED_BYTE);+ const mat = new BAB.ShaderMaterial('fields-ground-' + L.name, scene, { vertex: 'fieldsGround', fragment: 'fieldsGround' }, { attributes: ['position', 'uv', 'mv'], uniforms: ['world', 'viewProjection', 'lift', 'top', 'step', 'alpha'], samplers: ['mask'], needAlphaBlending: true });+ const topMv = span * 1000, step = Math.pow(10, Math.floor(Math.log10(topMv / 4))) * (topMv / Math.pow(10, Math.floor(Math.log10(topMv / 4))) > 20 ? 5 : 1);+ mat.setTexture('mask', mtex); mat.setFloat('top', topMv); mat.setFloat('step', step); mat.setFloat('lift', kz / 1000); mat.backFaceCulling = false;+ mesh.material = mat; mesh.parent = root; mesh.isPickable = false; mesh.renderingGroupId = 1; mesh.setEnabled(false);+ terrains.push({ k, mesh, mat, step });+ }+ // the parts' ground pins: their offset from the connectors+ const term = (G.terminals || []);+ const mvAt = (x, y) => { const i = cellOf(x, y); if (i < 0) return null; for (const k of real) if (lay[k].net[i] === gid && Number.isFinite(lay[k].volt[i])) return (lay[k].volt[i] - vmin) * 1000; return null; };+ // ---- 2. ground loops through the outside, and floating ground islands --------------------------------+ const ext = term.filter(t => /^(J|P|MC|MP)\d/i.test(t.ref));+ const outside = [...new Map(ext.map(t => [t.ref, t])).values()];+ const loops = [];+ for (let a = 0; a < outside.length; a++) for (let b = a + 1; b < outside.length; b++) { const p = outside[a], q = outside[b], dist = Math.hypot(p.x - q.x, p.y - q.y); loops.push({ a: p.ref, b: q.ref, p, q, dist, mm2: dist * 10 }); } // a cable pair 10 mm off the board, an estimate+ loops.sort((x, y) => y.mm2 - x.mm2);+ // islands: ground copper components, joined across layers only by ground vias and pins+ const comp = new Int32Array(real.length * N).fill(-1); const sizes = [];+ const link = new Set(); for (const v of (state.fields.vias || [])) if (v.net === G.name) { const i = cellOf(v.x, v.y); if (i >= 0) link.add(i); }+ for (const p of (state.fields.pads || [])) if (p.net === G.name) { const i = cellOf(p.x, p.y); if (i >= 0) link.add(i); }+ let nc = 0;+ for (let li = 0; li < real.length; li++) for (let i = 0; i < N; i++) {+ if (lay[real[li]].net[i] !== gid || comp[li * N + i] >= 0) continue;+ const stack = [li * N + i]; comp[li * N + i] = nc; let sz = 0;+ while (stack.length) {+ const v = stack.pop(), l = Math.floor(v / N), j = v % N, r = Math.floor(j / W), c = j % W; sz++;+ const nb = []; if (c > 0) nb.push(v - 1); if (c < W - 1) nb.push(v + 1); if (r > 0) nb.push(v - W); if (r < H - 1) nb.push(v + W);+ if (link.has(j)) for (let l2 = 0; l2 < real.length; l2++) if (l2 !== l) nb.push(l2 * N + j);+ for (const u of nb) { const l2 = Math.floor(u / N), j2 = u % N; if (comp[u] < 0 && lay[real[l2]].net[j2] === gid) { comp[u] = nc; stack.push(u); } }+ }+ sizes.push(sz); nc++;+ }+ const mainComp = sizes.indexOf(Math.max(...sizes, 0));+ const islands = []; for (let c = 0; c < nc; c++) if (c !== mainComp && sizes[c] * d * d > 0.5) islands.push({ c, mm2: sizes[c] * d * d });+ const isl = new Uint8Array(N * 4);+ for (let li = 0; li < real.length; li++) for (let i = 0; i < N; i++) { const c = comp[li * N + i]; if (c >= 0 && c !== mainComp && sizes[c] * d * d > 0.5) { isl[i * 4] = 255; isl[i * 4 + 1] = 60; isl[i * 4 + 2] = 50; isl[i * 4 + 3] = 230; } }+ // ---- 3. the stitching fence ---------------------------------------------------------------------------+ const FMAX = 1e9, lambda = 299.792458 / (FMAX / 1e9) / Math.sqrt((ER + 1) / 2), reach = lambda / 40; // mm, in the board's half-air field+ const gvias = (state.fields.vias || []).filter(v => v.net === G.name);+ const edge = []; const per = 2 * (board.widthMm + board.heightMm), stepE = 0.4;+ for (let s = 0; s < per; s += stepE) {+ let x, y; const w = board.widthMm, h = board.heightMm;+ if (s < w) { x = board.x0 + s; y = board.y0; } else if (s < w + h) { x = board.x0 + w; y = board.y0 + (s - w); } else if (s < 2 * w + h) { x = board.x0 + w - (s - w - h); y = board.y0 + h; } else { x = board.x0; y = board.y0 + h - (s - 2 * w - h); }+ const near = gvias.some(v => Math.hypot(v.x - x, v.y - y) <= reach + 1.0); // 1 mm: the via's own ring and the pour's edge+ edge.push({ x, y, ok: near });+ }+ const gaps = []; { let run = null; for (const e of edge) { if (!e.ok) { if (!run) run = { pts: [] }; run.pts.push(e); } else if (run) { gaps.push(run); run = null; } } if (run) gaps.push(run); }+ for (const g of gaps) g.mm = g.pts.length * stepE;+ gaps.sort((a, b) => b.mm - a.mm);+ const covered = edge.filter(e => e.ok).length / edge.length * 100;+ // ---- drawing ----------------------------------------------------------------------------------------------+ const group = new BAB.TransformNode('fields-ground', scene); group.parent = root; group.setEnabled(false);+ const lines = [];+ const gl = (pts3, rgb, width, dash, name) => { try { const m = BAB.CreateGreasedLine(name, { points: pts3 }, { color: new BAB.Color3(...rgb), width, useDash: !!dash, dashCount: 30, dashRatio: 0.5, sizeAttenuation: true }, scene); m.parent = group; m.renderingGroupId = 2; m.isPickable = false; lines.push(m); return m; } catch (e) { return null; } };+ const zt = () => zOf(real[0]) + 0.1;+ // the fence: a ring per ground via, the gaps along the edge in red+ for (const v of gvias) { const pts = []; for (let a = 0; a <= 32; a++) { const t = a / 32 * Math.PI * 2; pts.push(bx(v.x + Math.cos(t) * reach), by(v.y + Math.sin(t) * reach), 0); } gl(pts, [0.3, 0.85, 1.0], 0.05, false, 'gnd-fence'); }+ for (const g of gaps) { if (g.pts.length < 2) continue; gl(g.pts.flatMap(p => [bx(p.x), by(p.y), 0.15]), [1, 0.3, 0.25], 0.35, false, 'gnd-gap'); }+ // the possible loops through the outside: arcs above the board between ground connectors+ for (const l of loops.slice(0, 3)) { const pts = []; for (let a = 0; a <= 24; a++) { const t = a / 24, x = l.p.x + (l.q.x - l.p.x) * t, y = l.p.y + (l.q.y - l.p.y) * t; pts.push(bx(x), by(y), 2 + 8 * Math.sin(Math.PI * t)); } gl(pts, [1, 0.65, 0.2], 0.12, true, 'gnd-loop'); }+ // islands: a red sheet over floating ground copper+ const it = new BAB.RawTexture(isl, W, H, BAB.Constants.TEXTUREFORMAT_RGBA, scene, false, false, BAB.Texture.NEAREST_SAMPLINGMODE, BAB.Constants.TEXTURETYPE_UNSIGNED_BYTE);+ const ip = BAB.MeshBuilder.CreatePlane('gnd-islands', { width: W * d, height: H * d, sideOrientation: BAB.Mesh.DOUBLESIDE }, scene);+ { const uvs = ip.getVerticesData(BAB.VertexBuffer.UVKind); for (let i = 1; i < uvs.length; i += 2) uvs[i] = 1 - uvs[i]; ip.setVerticesData(BAB.VertexBuffer.UVKind, uvs); }+ ip.position.x = bx(meta.x0 + W * d / 2); ip.position.y = by(meta.y0 + H * d / 2); ip.position.z = 0.2;+ const im = new BAB.StandardMaterial('gnd-islands-m', scene); im.diffuseTexture = it; it.hasAlpha = true; im.useAlphaFromDiffuseTexture = true; im.emissiveColor = new BAB.Color3(1, 1, 1); im.disableLighting = true; im.backFaceCulling = false;+ ip.material = im; ip.parent = group; ip.renderingGroupId = 2; ip.isPickable = false;+ // tags: each part's ground offset, the connectors+ const host = document.getElementById('labels'), tags = [];+ const tag = (html, x, y, z) => { const el = document.createElement('div'); el.className = 'lbl ground'; el.innerHTML = html; el.hidden = true; host.appendChild(el); tags.push({ el, x, y, z }); };+ const seen = new Set();+ for (const t of term) { if (seen.has(t.ref)) continue; seen.add(t.ref); const m = mvAt(t.x, t.y); if (m == null) continue; const isExt = outside.some(o => o.ref === t.ref); tag(`<b>${t.ref}</b> ${isExt ? 'to the outside' : 'ground pin'} <span class="v">${m >= 0.01 ? '+' + m.toFixed(2) : m.toFixed(2)} mV</span>`, t.x, t.y, 1.5); }+ for (const l of loops.slice(0, 1)) tag(`<b>possible ground loop</b> ${l.a}–${l.b} <span class="v">~${l.mm2.toFixed(0)} mm²</span>`, (l.p.x + l.q.x) / 2, (l.p.y + l.q.y) / 2, 10.5);+ if (gaps[0] && gaps[0].mm > 1) { const g = gaps[0], m = g.pts[Math.floor(g.pts.length / 2)]; tag(`<b>stitching gap</b> <span class="v">${g.mm.toFixed(1)} mm</span>`, m.x, m.y, 1.2); }+ // ---- the panel -------------------------------------------------------------------------------------------+ const panel = document.createElement('div'); panel.className = 'cyclemeter groundpanel'; panel.hidden = true; document.querySelector('.stage').appendChild(panel);+ panel.innerHTML = `<div class="ch" title="Click to fold">Ground</div><div class="body">+ <div class="row"><span class="dot" style="background:#fca678"></span>Offset across ${G.name}<i>${(span * 1000).toFixed(2)} mV</i></div>+ <div class="row"><span class="dot" style="background:#ffa63d"></span>Ground to the outside<i>${outside.length} (${outside.map(o => o.ref).join(', ')})</i></div>+ ${loops[0] ? `<div class="row"><span class="dot" style="background:#ffa63d"></span>Largest possible loop<i>${loops[0].a}–${loops[0].b} ~${loops[0].mm2.toFixed(0)} mm²</i></div>` : ''}+ <div class="row"><span class="dot" style="background:${islands.length ? '#ff5a4a' : '#5cf08f'}"></span>Floating ground islands<i>${islands.length ? islands.length + ' · ' + islands.reduce((s, x) => s + x.mm2, 0).toFixed(1) + ' mm²' : 'none'}</i></div>+ <div class="row"><span class="dot" style="background:${covered > 95 ? '#5cf08f' : '#ff5a4a'}"></span>Edge stitched (λ/20 at 1 GHz)<i>${covered.toFixed(0)} %${gaps[0] && gaps[0].mm > 1 ? ' · gap ' + gaps[0].mm.toFixed(1) + ' mm' : ''}</i></div>+ <div class="row" style="color:var(--text-2)">Height ×${(kz).toFixed(0)} mm per volt; a ring every ${terrains[0] ? terrains[0].step : '?'} mV</div></div>`;+ panel.addEventListener('click', (e) => { if (e.target.closest('.ch')) panel.classList.toggle('folded'); });+ // ---- frame and view -----------------------------------------------------------------------------------+ const knob = { on: 0, to: 0 };+ scene.onBeforeRenderObservable.add(() => {+ const dt = frameDt();+ knob.on += (knob.to - knob.on) * (1 - Math.exp(-dt * 2.5)); if (Math.abs(knob.to - knob.on) < 1e-3) knob.on = knob.to;+ const live = knob.on > 0.01; group.setEnabled(live); group.position.z = zt();+ for (const t of terrains) { t.mesh.setEnabled(live); if (!live) continue; t.mesh.position.z = zOf(t.k) + 0.05; t.mat.setFloat('alpha', knob.on * (t.k === real[0] || knob.on < 0.5 ? 0.95 : 0.8)); }+ im.alpha = knob.on;+ if (!live) { for (const t of tags) t.el.hidden = true; return; }+ const e = V.getEngine(), w = e.getRenderWidth(), h = e.getRenderHeight(), hostEl = document.getElementById('host'), wm = group.getWorldMatrix();+ for (const t of tags) { const p = BAB.Vector3.Project(BAB.Vector3.TransformCoordinates(new BAB.Vector3(bx(t.x), by(t.y), t.z), wm), BAB.Matrix.Identity(), scene.getTransformMatrix(), camera.viewport.toGlobal(w, h)); t.el.hidden = p.z > 1 || p.z < 0; t.el.style.left = (p.x / (w / hostEl.clientWidth)) + 'px'; t.el.style.top = (p.y / (h / hostEl.clientHeight)) + 'px'; }+ });+ function apply(view) {+ knob.to = view.quantity === 'ground' ? 1 : 0; panel.hidden = view.quantity !== 'ground';+ if (view.quantity !== 'ground') { const leg = document.getElementById('legend'); if (leg) leg.classList.remove('ground'); return; }+ for (const Lname of LAYERS) { const m = ctx.layerMeshes[Lname]; if (m && m.material) m.material.alpha = 0.25; }+ const leg = document.getElementById('legend'); if (leg) { leg.hidden = false; leg.classList.add('magma'); document.getElementById('legendTitle').textContent = `${G.name} above its lowest point, mV (height and colour)`; document.getElementById('legLo').textContent = '0'; document.getElementById('legHi').textContent = (span * 1000).toFixed(2); }+ }+ function readout(Lname, x, y) { const m = mvAt(x, y); return m == null ? 'not ground copper' : `${G.name} +${m.toFixed(3)} mV above its lowest point`; }+ function describe() {+ return ['<b>Ground</b>: the return path',+ '<b>The sheet</b> = the ground copper, lifted by its voltage: ground is not one voltage, the return current drops a little across it (rings every ' + (terrains[0] ? terrains[0].step : '?') + ' mV)',+ '<b>Dashed orange arcs</b> = cables from ground connectors that may meet off the board and close a loop',+ '<b>Red copper</b> = ground islands with no via or pin to the rest: they float and act as antennas',+ '<b>Blue rings</b> = each ground via\'s reach for stitching (λ/20 at 1 GHz); <b>red edge</b> = a gap in the fence',+ 'DC offset from the solve; the loop areas assume cables 10 mm off the board'];+ }+ function narration() {+ const u = term.filter(t => !outside.some(o => o.ref === t.ref)).map(t => ({ ref: t.ref, m: mvAt(t.x, t.y) })).filter(t => t.m != null).sort((a, b) => b.m - a.m)[0];+ return `This is the Ground view. Ground is supposed to be zero volts everywhere, but it is copper too, and the current returning through it makes small voltage drops. The sheet is the ground copper lifted by its voltage, greatly exaggerated: across this board it spans ${(span * 1000).toFixed(1)} millivolts${u ? `, highest under ${u.ref}, ${u.m.toFixed(1)} millivolts above the connectors` : ''}. That difference is noise that every chip sees on its own ground. ${outside.length > 1 ? `${outside.length} connectors carry ground to the outside: ${outside.map(o => o.ref).join(', ')}. If their cables meet somewhere off the board, they close a ground loop, drawn as the dashed orange arcs, and that loop picks up magnetic noise. ` : ''}${islands.length ? `There ${islands.length === 1 ? 'is one floating island' : 'are ' + islands.length + ' floating islands'} of ground copper, in red, with no via tying ${islands.length === 1 ? 'it' : 'them'} to the rest; floating copper acts as an antenna. ` : 'Every piece of ground copper is tied to the rest, with no floating islands. '}The blue rings show how far each ground via stitches the layers together for frequencies up to one gigahertz; ${covered > 95 ? 'the board edge is fully covered.' : `about ${covered.toFixed(0)} percent of the board edge is covered, and the red stretches are gaps where noise can leak out.`}`;+ }+ console.log(`ground: ${G.name} spans ${(span * 1000).toFixed(3)} mV; ${outside.length} ground connectors (${outside.map(o => o.ref).join(' ')}); ${islands.length} floating island(s) ${islands.map(i => i.mm2.toFixed(1) + ' mm²').join(' ')}; edge stitched ${covered.toFixed(0)} % with ${gvias.length} ground vias, reach ${reach.toFixed(1)} mm, largest gap ${gaps[0] ? gaps[0].mm.toFixed(1) : 0} mm`);+ return { apply, describe, narration, readout };+}
src/ui/index.html+2−1@@ -53,6 +53,7 @@ body{margin:0;background:var(--bg);color:var(--text-body);font-family:"Satoshi", .labels{position:absolute;inset:0;pointer-events:none;overflow:hidden} .lbl{position:absolute;transform:translate(-50%,-140%);font-family:"JetBrains Mono",monospace;font-size:12px;color:var(--text);background:rgba(13,17,23,.75);border:1px solid var(--border);border-radius:6px;padding:2px 7px;white-space:nowrap} .lbl.chip{font-size:11px;padding:2px 6px;border-color:rgba(255,255,255,.18);background:rgba(13,17,23,.82)}.lbl.chip b{color:#fff;margin-right:2px}.lbl.chip .in{color:#ffb86b;margin-left:4px}.lbl.chip .out{color:#7ee0ff;margin-left:4px}+.lbl.ground{border-color:rgba(252,166,120,.5)}.lbl.ground .v{color:#fca678;font-weight:600} .lbl.emc{border-color:rgba(255,120,90,.5)}.lbl.emc .v{color:#ff8a6e;font-weight:600}.lbl.emc .g{color:#5cf08f;font-weight:600}.cyclemeter.emcpanel canvas{height:auto;aspect-ratio:460/190} .lbl.river{border-color:rgba(89,217,250,.45);color:#c8f3ff} .legend{position:absolute;right:16px;bottom:16px;background:rgba(22,27,34,.85);border:1px solid var(--border);border-radius:8px;padding:8px 10px;font-size:12px;color:var(--text-body);backdrop-filter:blur(4px);min-width:170px}@@ -111,7 +112,7 @@ body{margin:0;background:var(--bg);color:var(--text-body);font-family:"Satoshi", </header> <div class="app-body"> <aside class="side" id="left">- <div class="sec"><h3>Field</h3><div class="seg" id="qty"><button data-q="current" data-tip="Current density on the copper, amps per square millimetre, every loaded net at its spec current">Current</button><button data-q="heat" data-tip="Heat flow in the copper, watts per millimetre of width, from the hot tabs and the Joule loss">Heat</button><button data-q="signals" data-tip="Copper overlap capacitance and differential-pair screening">Signals</button><button data-q="temperature" data-tip="Temperature rise over the board, the FR4 spreading">Temperature</button></div><div class="seg" id="qty2" style="margin-top:4px"><button data-q="flow" data-tip="The solved current as charge streaming through the copper: speed and heat follow the current density, the vias take their share">Flow</button><button data-q="river" data-tip="Every net lifted to its voltage, the current running downhill as water: the loads fall to ground, the sources lift it back">River</button><button data-q="energy" data-tip="Where the energy really travels: through the fields between the copper layers, while the electrons crawl at millimetres per second">Energy</button><button data-q="light" data-tip="How fast a change travels: the local speed of light in the copper's field, and when a switch-on reaches each part">Light</button><button data-q="cycle" data-tip="One switching period: energy from the input capacitors into the inductor's magnetic field, then out to the output; the stores and the heat as they change">Cycle</button><button data-q="charge" data-tip="The board as capacitors: charge on facing copper, the electric field between, the switch node charging every cycle, and the capacitor parts">Charge</button><button data-q="magnetic" data-tip="The magnetic field of every current: field lines wrapping the copper, and the field strength at a height like a near-field probe">Magnetic</button><button data-q="emc" data-tip="Where the board could radiate: the hot loop and the smallest it could be, copper with no ground under it, and the estimated emissions against the FCC limit">EMC</button></div></div>+ <div class="sec"><h3>Field</h3><div class="seg" id="qty"><button data-q="current" data-tip="Current density on the copper, amps per square millimetre, every loaded net at its spec current">Current</button><button data-q="heat" data-tip="Heat flow in the copper, watts per millimetre of width, from the hot tabs and the Joule loss">Heat</button><button data-q="signals" data-tip="Copper overlap capacitance and differential-pair screening">Signals</button><button data-q="temperature" data-tip="Temperature rise over the board, the FR4 spreading">Temperature</button></div><div class="seg" id="qty2" style="margin-top:4px"><button data-q="flow" data-tip="The solved current as charge streaming through the copper: speed and heat follow the current density, the vias take their share">Flow</button><button data-q="river" data-tip="Every net lifted to its voltage, the current running downhill as water: the loads fall to ground, the sources lift it back">River</button><button data-q="energy" data-tip="Where the energy really travels: through the fields between the copper layers, while the electrons crawl at millimetres per second">Energy</button><button data-q="light" data-tip="How fast a change travels: the local speed of light in the copper's field, and when a switch-on reaches each part">Light</button><button data-q="cycle" data-tip="One switching period: energy from the input capacitors into the inductor's magnetic field, then out to the output; the stores and the heat as they change">Cycle</button><button data-q="charge" data-tip="The board as capacitors: charge on facing copper, the electric field between, the switch node charging every cycle, and the capacitor parts">Charge</button><button data-q="magnetic" data-tip="The magnetic field of every current: field lines wrapping the copper, and the field strength at a height like a near-field probe">Magnetic</button><button data-q="emc" data-tip="Where the board could radiate: the hot loop and the smallest it could be, copper with no ground under it, and the estimated emissions against the FCC limit">EMC</button><button data-q="ground" data-tip="The return path: ground's own voltage drop, possible ground loops through the cables, floating ground islands, and the stitching-via fence along the edge">Ground</button></div></div> <div class="sec"><h3>Layer</h3><div class="seg" id="layer"><button data-l="F.Cu">F.Cu</button><button data-l="In1.Cu">In1</button><button data-l="In2.Cu">In2</button><button data-l="B.Cu">B.Cu</button></div></div> <div class="sec" id="airSec"><h3>Air</h3><div class="seg" id="air"><button data-a="still" data-tip="Still air, 10 W/m²K on both faces">Still</button><button data-a="moving" data-tip="Moving air, 25 W/m²K: a fan or a moving vehicle">Moving</button></div></div> <div class="sec"><h3>Explain each view out loud</h3><div class="seg" id="narrMode"><button data-n="first" data-tip="Each kind of view explains itself the first time you open it">First time</button><button data-n="always" data-tip="Every view explains itself whenever you open it">Always</button><button data-n="ask" data-tip="Only when you click the speaker in the header">On ask</button></div></div>