← Commit history
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-fields"-version = "0.7.7"+version = "0.7.8" dependencies = [  "clap",  "png",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-fields"-version = "0.7.7"+version = "0.7.8" 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+8
@@ -121,6 +121,14 @@ The return path: ground's own voltage drop, the possible loops through cables, f  [Download the ground video](docs/videos/ground.mp4) +### Noise++Where switching noise can ring and spread: each power pour over the ground plane ringing like a drum at its own frequencies, and the switching edges reaching quiet nets through stray capacitance.++<video controls preload="metadata" width="1080" poster="docs/videos/noise-poster.jpg" src="docs/videos/noise.mp4"></video>++[Download the noise video](docs/videos/noise.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.
SKILL.md+11−2
@@ -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|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 view --quantity current|heat|temperature|signals|flow|river|energy|light|cycle|charge|magnetic|emc|ground|noise [--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@@ -128,7 +128,7 @@ A screen, not a chamber: differential mode only; cables (common mode), which oft ## 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`, `decoupling-far`, `decoupling-missing` (each chip power pin's nearest small capacitor: over 3 mm moves it, none adds a 100 nF), `esd-unprotected` (a connector pin with no diode to ground: add a TVS within 3 mm if it faces the outside).+- **`fields.json` `issues`**: every finding with `severity`, `kind`, `what`, `at` (board mm), `net`, `reference`, a plain `fix`, and an **`action`** to carry out: `move` (`ref`, `toPins`, `targetMm2`), `add-vias` (`net`, `points` or `at`, `pitchMm`), `widen` (`net`, `at`), `pour` (`net`, `layer`, `under`, `at`), `add-copper`, `add-part`, `review`. Kinds: `current-neck`, `tab-rise`, `thermal-vias` (the solve's own), and from the review `hot-loop`, `emissions`, `no-reference`, `ground-offset`, `ground-island`, `stitching-gap`, `ground-loop-risk`, `ir-drop`, `inductor-ripple`, `output-ripple`, `decoupling-far`, `decoupling-missing` (each chip power pin's nearest small capacitor: over 3 mm moves it, none adds a 100 nF), `cavity-resonance`, `crosstalk`, `esd-unprotected` (a connector pin with no diode to ground: add a TVS within 3 mm if it faces the outside). - **`fields.json` `fixPlan`**: the actions, worst first and concrete edits before reviews, numbered for the caller to work down. `analyze` prints the first five as a `Hint: Fix plan` line and the review as a `Hint: Review` line. - **`fields.json` `review`**: the numbers behind them: `converter` (duty, ripple, hot loops per input capacitor and the best possible, the emissions margin), `reference` (each net's copper with no ground under it), `ground` (offset, connectors to the outside, islands, the stitching fence and its gaps), `rails` (IR drop per rail), `decoupling` (pin, cap, distance, loop), `esd` (each connector pin and what protects it). - Budgets in the spec: `groundOffsetMv` (10), `dropPct` (1 % of the rail), `emcFmaxHz` (1 GHz, for the stitching pitch), `switching.<net>.riseNs` (8).@@ -139,6 +139,15 @@ After applying a step, run `analyze` again: the issue goes away or says how far  `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. +## Noise: plane resonance and crosstalk (0.7.8)++`analyze` also screens noise couplings, each finding with an action:+- **Cavities**: every power or switching pour facing the ground plane is a parallel-plate cavity; its lowest modes are solved on the real overlap (the Neumann Laplacian's eigenpairs by Lanczos with full reorthogonalisation, then Jacobi; f = c·k/(2π√εr); checked against the rectangle's c/(2a√εr) in the tests). A mode within 5 % of a strong tone (the switching harmonics up to ten times the edge's knee, `clocks` from the spec) is `cavity-resonance`, with an `add-part` capacitor at the mode's loudest point.+- **Crosstalk**: each quiet net (not a rail, not loaded, not tied to the aggressor through a capacitor, like a bootstrap) next to a switching node or a clock: closest approach, run within 1 mm, side and overlap capacitance, and the coupled step ΔV·Cm/(Cm+Cg). Over `crosstalkMv` (50) is `crosstalk`, with a `reroute` action (spacing, or a ground guard).+- `review.noise` holds the cavities' modes, the tones and the crosstalk table; `noise.json` holds the mode shapes for the app.++`ui view --quantity noise`: the largest cavity rings in 3D (its real modes, red and blue phases, white nodal lines), the switching copper pulses, and quiet nets glow by the millivolts they pick up, with arcs at the closest approach.+ ## 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.8.mdadded+6
@@ -0,0 +1,6 @@+# adom-fields 0.7.8++- **Plane resonance.** Each power or switching pour over the ground plane: its lowest cavity modes solved on the real overlap; a mode near a strong switching harmonic or a named clock is `cavity-resonance`, with a damping capacitor at its loudest point.+- **Crosstalk.** Each quiet net near a switching node or clock: the coupled step per edge from side and overlap capacitance; over budget is `crosstalk`, with a reroute or ground-guard action. A bootstrap net tied to the switch node through its capacitor is not a victim.+- **The Noise view** draws both: the largest cavity ringing in 3D, the switching copper pulsing, the victims glowing. A narrated video on the page.+- Spec: `clocks` (net: Hz), `crosstalkMv` (50).
docs/videos/noise-poster.jpgadded
⋯ 1 unchanged line ⋯
docs/videos/noise.mp4added
⋯ 1 unchanged line ⋯
package.json+6−2
@@ -1,7 +1,7 @@ {   "slug": "adom-fields",   "type": "app",-  "version": "0.7.7",+  "version": "0.7.8",   "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.",@@ -72,7 +72,11 @@     "fix plan for a board layout",     "decoupling capacitor placement",     "esd protection check",-    "tvs diode"+    "tvs diode",+    "plane resonance",+    "cavity modes",+    "crosstalk",+    "switching noise coupling"   ],   "sample_prompts": [     {
page.json+12−10
@@ -1,7 +1,7 @@ {   "slug": "adom-fields",   "type": "app",-  "version": "0.7.7",+  "version": "0.7.8",   "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",@@ -72,7 +70,11 @@     "fix plan for a board layout",     "decoupling capacitor placement",     "esd protection check",-    "tvs diode"+    "tvs diode",+    "plane resonance",+    "cavity modes",+    "crosstalk",+    "switching noise coupling"   ],   "sample_prompts": [     {@@ -113,4 +115,4 @@     "name": "John Lauer",     "email": "[email protected]"   }-}\ No newline at end of file+}
skills/adom-fields/SKILL.md+11−2
@@ -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|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 view --quantity current|heat|temperature|signals|flow|river|energy|light|cycle|charge|magnetic|emc|ground|noise [--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@@ -128,7 +128,7 @@ A screen, not a chamber: differential mode only; cables (common mode), which oft ## 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`, `decoupling-far`, `decoupling-missing` (each chip power pin's nearest small capacitor: over 3 mm moves it, none adds a 100 nF), `esd-unprotected` (a connector pin with no diode to ground: add a TVS within 3 mm if it faces the outside).+- **`fields.json` `issues`**: every finding with `severity`, `kind`, `what`, `at` (board mm), `net`, `reference`, a plain `fix`, and an **`action`** to carry out: `move` (`ref`, `toPins`, `targetMm2`), `add-vias` (`net`, `points` or `at`, `pitchMm`), `widen` (`net`, `at`), `pour` (`net`, `layer`, `under`, `at`), `add-copper`, `add-part`, `review`. Kinds: `current-neck`, `tab-rise`, `thermal-vias` (the solve's own), and from the review `hot-loop`, `emissions`, `no-reference`, `ground-offset`, `ground-island`, `stitching-gap`, `ground-loop-risk`, `ir-drop`, `inductor-ripple`, `output-ripple`, `decoupling-far`, `decoupling-missing` (each chip power pin's nearest small capacitor: over 3 mm moves it, none adds a 100 nF), `cavity-resonance`, `crosstalk`, `esd-unprotected` (a connector pin with no diode to ground: add a TVS within 3 mm if it faces the outside). - **`fields.json` `fixPlan`**: the actions, worst first and concrete edits before reviews, numbered for the caller to work down. `analyze` prints the first five as a `Hint: Fix plan` line and the review as a `Hint: Review` line. - **`fields.json` `review`**: the numbers behind them: `converter` (duty, ripple, hot loops per input capacitor and the best possible, the emissions margin), `reference` (each net's copper with no ground under it), `ground` (offset, connectors to the outside, islands, the stitching fence and its gaps), `rails` (IR drop per rail), `decoupling` (pin, cap, distance, loop), `esd` (each connector pin and what protects it). - Budgets in the spec: `groundOffsetMv` (10), `dropPct` (1 % of the rail), `emcFmaxHz` (1 GHz, for the stitching pitch), `switching.<net>.riseNs` (8).@@ -139,6 +139,15 @@ After applying a step, run `analyze` again: the issue goes away or says how far  `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. +## Noise: plane resonance and crosstalk (0.7.8)++`analyze` also screens noise couplings, each finding with an action:+- **Cavities**: every power or switching pour facing the ground plane is a parallel-plate cavity; its lowest modes are solved on the real overlap (the Neumann Laplacian's eigenpairs by Lanczos with full reorthogonalisation, then Jacobi; f = c·k/(2π√εr); checked against the rectangle's c/(2a√εr) in the tests). A mode within 5 % of a strong tone (the switching harmonics up to ten times the edge's knee, `clocks` from the spec) is `cavity-resonance`, with an `add-part` capacitor at the mode's loudest point.+- **Crosstalk**: each quiet net (not a rail, not loaded, not tied to the aggressor through a capacitor, like a bootstrap) next to a switching node or a clock: closest approach, run within 1 mm, side and overlap capacitance, and the coupled step ΔV·Cm/(Cm+Cg). Over `crosstalkMv` (50) is `crosstalk`, with a `reroute` action (spacing, or a ground guard).+- `review.noise` holds the cavities' modes, the tones and the crosstalk table; `noise.json` holds the mode shapes for the app.++`ui view --quantity noise`: the largest cavity rings in 3D (its real modes, red and blue phases, white nodal lines), the switching copper pulses, and quiet nets glow by the millivolts they pick up, with arcs at the closest approach.+ ## 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/fields.rs+2
@@ -33,6 +33,8 @@ pub struct Spec {     #[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>,+    /// named clocks (net: Hz), for the resonance and crosstalk checks; the coupled-noise budget on a quiet net (mV, 50)+    #[serde(default)] pub clocks: BTreeMap<String, f64>, #[serde(default)] pub crosstalkMv: Option<f64>, } fn d35() -> f64 { 35.0 } fn d60() -> f64 { 60.0 }
src/main.rs+7−2
@@ -1,7 +1,8 @@ //! adom-fields: where the current and the heat go on your board. use sha2::{Digest, Sha256};-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;+mod eagle; mod emc; mod flow; mod noise; mod parts; mod silk; mod signals; mod heatsink; mod board; mod fields; mod raster; mod sexp; mod solve; mod tex; mod serve; mod vendor; use clap::{Parser, Subcommand};+use serde_json::{json, Value}; use std::path::PathBuf;  #[derive(Parser)]@@ -93,7 +94,8 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck     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);+    let mut review = emc::review(&f, &b, &spec, &mut iss);+    let mut nz = noise::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));}@@ -126,6 +128,9 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck         }     }     let mut summary = fields::summary(&f, &b, &spec, &textures);+    // the cavities' mode shapes are for the app: their own file, so fields.json stays readable for an AI caller+    if let Some(cav) = nz["cavities"].as_array_mut() { let shapes: Vec<Value> = cav.iter_mut().map(|c| json!({"net": c["net"].clone(), "grid": c["grid"].clone(), "shapes": c["shapes"].take()})).collect(); for c in cav.iter_mut() { c.as_object_mut().map(|o| o.remove("shapes")); } std::fs::write(out.join("noise.json"), serde_json::to_string(&json!({"cavities": shapes})).unwrap()).map_err(|e| e.to_string())?; }+    review["noise"] = nz;     summary["review"] = review;     summary["fixPlan"] = emc::fix_plan(&f.issues);     summary["flow"] = flow::write(out, &f, &b, &spec)?;
src/noise.rsadded+155
@@ -0,0 +1,155 @@+//! Noise couplings: plane cavity resonance and crosstalk, each finding an issue with an action.+//! Cavities: a power or switching pour facing the ground plane is a parallel-plate cavity; its modes solve+//! the Helmholtz equation on the real overlap with open (magnetic-wall) edges: the Neumann Laplacian's+//! lowest eigenpairs (Lanczos, fully reorthogonalised, then Jacobi on the small tridiagonal), k² = λ/h²,+//! f = c k / (2π √εr). A mode near a strong harmonic of the switching (or a named clock) rings.+//! Crosstalk: each quiet net next to a switching node or clock: closest approach, run within 1 mm, the side+//! coupling and the overlap on the next layer as capacitance, the coupled step ΔV Cm / (Cm + Cg).+use crate::board::{Board, LAYERS};+use crate::fields::{Fields, Issue, Spec};+use crate::flow::{is_ground, is_switch_name, rail_from_name};+use serde_json::{json, Value};++const C_MM: f64 = 2.99792458e11;   // mm/s+const E0: f64 = 8.854e-15;         // F/mm+fn r2(v: f64) -> f64 { (v * 100.0).round() / 100.0 }++/// The lowest `k` eigenpairs of a symmetric operator given as a matvec on n unknowns (Lanczos + Jacobi).+pub fn lowest_modes(n: usize, k: usize, apply: &dyn Fn(&[f64], &mut [f64])) -> Vec<(f64, Vec<f64>)> {+    if n == 0 { return Vec::new(); }+    let m = n.min(260);+    let mut vs: Vec<Vec<f64>> = Vec::with_capacity(m);+    let (mut alpha, mut beta) = (vec![0.0; m], vec![0.0; m]);+    let mut v: Vec<f64> = (0..n).map(|i| ((i as f64 * 12.9898).sin() * 43758.5453).fract() - 0.5).collect();+    let nv = v.iter().map(|x| x * x).sum::<f64>().sqrt(); for x in v.iter_mut() { *x /= nv; }+    let mut w = vec![0.0; n]; let mut steps = 0;+    for j in 0..m {+        vs.push(v.clone()); steps = j + 1;+        apply(&v, &mut w);+        alpha[j] = w.iter().zip(&v).map(|(a, b)| a * b).sum();+        // full reorthogonalisation keeps the small modes clean+        for _ in 0..2 { for q in &vs { let c: f64 = w.iter().zip(q).map(|(a, b)| a * b).sum(); for (wi, qi) in w.iter_mut().zip(q) { *wi -= c * qi; } } }+        let b = w.iter().map(|x| x * x).sum::<f64>().sqrt();+        if j + 1 < m { beta[j] = b; if b < 1e-10 { break; } v = w.iter().map(|x| x / b).collect(); }+    }+    let mm = steps;+    // the tridiagonal, then Jacobi+    let mut a = vec![vec![0.0; mm]; mm]; let mut z = vec![vec![0.0; mm]; mm];+    for i in 0..mm { a[i][i] = alpha[i]; z[i][i] = 1.0; if i + 1 < mm { a[i][i + 1] = beta[i]; a[i + 1][i] = beta[i]; } }+    for _sweep in 0..60 {+        let mut off = 0.0; for p in 0..mm { for q in p + 1..mm { off += a[p][q] * a[p][q]; } } if off < 1e-22 { break; }+        for p in 0..mm { for q in p + 1..mm {+            if a[p][q].abs() < 1e-14 { continue; }+            let th = (a[q][q] - a[p][p]) / (2.0 * a[p][q]); let t = th.signum() / (th.abs() + (th * th + 1.0).sqrt()); let t = if th == 0.0 { 1.0 } else { t };+            let c = 1.0 / (t * t + 1.0).sqrt(); let s = t * c;+            for r in 0..mm { let (arp, arq) = (a[r][p], a[r][q]); a[r][p] = c * arp - s * arq; a[r][q] = s * arp + c * arq; }+            for r in 0..mm { let (apr, aqr) = (a[p][r], a[q][r]); a[p][r] = c * apr - s * aqr; a[q][r] = s * apr + c * aqr; }+            for r in 0..mm { let (zrp, zrq) = (z[r][p], z[r][q]); z[r][p] = c * zrp - s * zrq; z[r][q] = s * zrp + c * zrq; }+        } }+    }+    let mut order: Vec<usize> = (0..mm).collect(); order.sort_by(|x, y| a[*x][*x].partial_cmp(&a[*y][*y]).unwrap());+    order.into_iter().take(k).map(|c| { let mut vec = vec![0.0; n]; for j in 0..mm { let y = z[j][c]; for i in 0..n { vec[i] += y * vs[j][i]; } } (a[c][c], vec) }).collect()+}++pub fn review(f: &Fields, b: &Board, spec: &Spec, issues: &mut Vec<Issue>) -> Value {+    let (w, h, n) = (f.w, f.h, f.w * f.h);+    let rail = |nm: &str| spec.voltages.get(nm).copied().or_else(|| rail_from_name(nm));+    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 = f.net_names.iter().position(|x| is_ground(x)).map(|i| i as u16);+    let er: f64 = 4.3;+    // the strong tones: switching harmonics (trapezoid envelope within 40 dB of the fundamental) and named clocks+    let mut tones: Vec<(f64, String)> = Vec::new();+    for (net, sw) in &spec.switching { let fs = sw.freqHz.unwrap_or(500e3); let tr = sw.riseNs.unwrap_or(8.0) * 1e-9; let knee = 1.0 / (std::f64::consts::PI * tr); let top = (knee * 10.0).min(spec.emcFmaxHz.unwrap_or(1e9)); let mut k = 1.0; while k * fs <= top { tones.push((k * fs, format!("{net} harmonic {}", k as u64))); k += if k * fs < 100e6 { 1.0 } else { (k * 0.01).max(1.0).floor() }; } }+    for (net, hz) in &spec.clocks { let mut k = 1.0; while k * hz <= spec.emcFmaxHz.unwrap_or(1e9) * 2.0 { tones.push((k * hz, format!("{net} clock harmonic {}", k as u64))); k += 1.0; } }+    let mut out = json!({});+    // ---- cavities --------------------------------------------------------------------------------------------+    let mut cavities = Vec::new();+    if let (Some(g), true) = (gid, real.len() >= 2) {+        let (k0, k1) = (real[0], real[1]);+        let hmm = { let t: f64 = if spec.stackupMm.len() == 3 { spec.stackupMm.iter().sum() } else { 1.465 }; if real.len() == 2 { t } else { spec.stackupMm.first().copied().unwrap_or(0.2) } };+        let mut pairs: std::collections::BTreeMap<u16, usize> = Default::default();+        for i in 0..n { let a = f.net_ids[k0][i]; if a != u16::MAX && a != g && f.net_ids[k1][i] == g { *pairs.entry(a).or_default() += 1; } }+        for (id, cnt) in pairs {+            let name = f.net_names[id as usize].clone();+            if !(rail(&name).is_some() || spec.switching.contains_key(&name)) || (cnt as f64 * f.d * f.d) < 20.0 { continue; }+            // coarsen to at most ~5000 cells+            let s = ((cnt as f64 / 5000.0).sqrt().ceil() as usize).max(1); let (cw, ch) = (w.div_ceil(s), h.div_ceil(s)); let hc = f.d * s as f64;+            let mut mask = vec![false; cw * ch];+            for i in 0..n { if f.net_ids[k0][i] == id && f.net_ids[k1][i] == g { mask[(i / w / s) * cw + (i % w) / s] = true; } }+            // the largest connected piece+            let mut comp = vec![usize::MAX; cw * ch]; let mut best = (0usize, 0usize);+            for st in 0..cw * ch { if !mask[st] || comp[st] != usize::MAX { continue; } let mut stack = vec![st]; comp[st] = st; let mut sz = 0; while let Some(v) = stack.pop() { sz += 1; let (r, c) = (v / cw, v % cw); for (dr, dc) in [(0i64, 1i64), (0, -1), (1, 0), (-1, 0)] { let (rr, cc) = (r as i64 + dr, c as i64 + dc); if rr < 0 || cc < 0 || rr >= ch as i64 || cc >= cw as i64 { continue; } let u = rr as usize * cw + cc as usize; if mask[u] && comp[u] == usize::MAX { comp[u] = st; stack.push(u); } } } if sz > best.1 { best = (st, sz); } }+            let cells: Vec<usize> = (0..cw * ch).filter(|i| comp[*i] == best.0).collect(); let nn = cells.len(); if nn < 12 { continue; }+            let mut idx = vec![usize::MAX; cw * ch]; for (j, c) in cells.iter().enumerate() { idx[*c] = j; }+            let nb: Vec<Vec<usize>> = cells.iter().map(|&c| { let (r, cc) = (c / cw, c % cw); let mut v = Vec::new(); if cc + 1 < cw && idx[c + 1] != usize::MAX { v.push(idx[c + 1]); } if cc > 0 && idx[c - 1] != usize::MAX { v.push(idx[c - 1]); } if r + 1 < ch && idx[c + cw] != usize::MAX { v.push(idx[c + cw]); } if r > 0 && idx[c - cw] != usize::MAX { v.push(idx[c - cw]); } v }).collect();+            let apply = |x: &[f64], y: &mut [f64]| { for i in 0..nn { let mut s_ = nb[i].len() as f64 * x[i]; for &j in &nb[i] { s_ -= x[j]; } y[i] = s_; } };+            let modes = lowest_modes(nn, 5, &apply);+            let mut ms = Vec::new();+            for (lam, vec) in modes.iter().skip(1) {   // the first is the constant: the plate's own static charge+                if *lam <= 1e-9 { continue; }+                let k = lam.sqrt() / hc; let fr = C_MM * k / (2.0 * std::f64::consts::PI * er.sqrt());+                let (mut amax, mut at) = (0.0, 0usize); for (j, v) in vec.iter().enumerate() { if v.abs() > amax { amax = v.abs(); at = j; } }+                let atxy = [f.x0 + ((cells[at] % cw) as f64 + 0.5) * hc, f.y0 + ((cells[at] / cw) as f64 + 0.5) * hc];+                let near = tones.iter().filter(|t| (t.0 - fr).abs() / fr < 0.05).min_by(|a, z| (a.0 - fr).abs().partial_cmp(&(z.0 - fr).abs()).unwrap());+                ms.push(json!({"fHz": fr.round(), "antinode": [r2(atxy[0]), r2(atxy[1])], "near": near.map(|t| t.1.clone())}));+                if let Some(t) = near { issues.push(Issue { severity: "medium".into(), kind: "cavity-resonance".into(), what: format!("{} over {}: a plane mode at {:.0} MHz sits within 5 % of the {} ({:.0} MHz)", name, f.net_names[g as usize], fr / 1e6, t.1, t.0 / 1e6), at: Some([r2(atxy[0]), r2(atxy[1])]), layer: Some(LAYERS[k0].into()), net: Some(name.clone()), reference: None, fix: format!("Damp it where it rings loudest, ({:.1}, {:.1}): a 1 to 10 nF capacitor from {} to ground there, or ground vias through the pour.", atxy[0], atxy[1], name), action: Some(json!({"type": "add-part", "what": "capacitor", "value": "4.7nF", "between": [name, f.net_names[g as usize]], "at": [r2(atxy[0]), r2(atxy[1])], "why": "damp a plane resonance"})) }); }+            }+            // the shapes over the cavity's own bounding box, three decimals: the app animates them+            let (r0, r1) = (cells.iter().map(|c| c / cw).min().unwrap(), cells.iter().map(|c| c / cw).max().unwrap()); let (c0, c1) = (cells.iter().map(|c| c % cw).min().unwrap(), cells.iter().map(|c| c % cw).max().unwrap()); let (bw, bh) = (c1 - c0 + 1, r1 - r0 + 1);+            let shapes: Vec<Vec<f64>> = modes.iter().skip(1).take(3).map(|(_, v)| { let mx = v.iter().fold(0.0f64, |a, b| a.max(b.abs())).max(1e-12); let mut sub = vec![0.0f64; bw * bh]; for (j, c) in cells.iter().enumerate() { sub[(c / cw - r0) * bw + (c % cw - c0)] = (v[j] / mx * 1000.0).round() / 1000.0; } sub }).collect();+            let mask_sub: Vec<u8> = (0..bw * bh).map(|i| { let c = (r0 + i / bw) * cw + c0 + i % bw; if comp[c] == best.0 { 1 } else { 0 } }).collect();+            cavities.push(json!({"net": name, "over": f.net_names[g as usize], "areaMm2": r2(cnt as f64 * f.d * f.d), "gapMm": hmm, "grid": {"cols": bw, "rows": bh, "cellMm": hc, "x0": f.x0 + c0 as f64 * hc, "y0": f.y0 + r0 as f64 * hc, "mask": mask_sub}, "modes": ms, "shapes": shapes}));+        }+    }+    out["cavities"] = json!(cavities);+    // ---- crosstalk ----------------------------------------------------------------------------------------------+    let aggressors: Vec<(String, f64)> = spec.switching.iter().map(|(k, s)| (k.clone(), s.highV - s.lowV)).chain(f.net_names.iter().filter(|nm| is_switch_name(nm) && !spec.switching.contains_key(*nm)).map(|nm| (nm.clone(), f.net_names.iter().filter_map(|x| rail(x)).fold(0.0, f64::max)))).chain(spec.clocks.keys().map(|k| (k.clone(), rail("VCC").unwrap_or(3.3)))).collect();+    let mut xt = Vec::new();+    let budget = spec.crosstalkMv.unwrap_or(50.0);+    let thick = |k: usize| spec.copperUmByLayer.get(LAYERS[k]).copied().unwrap_or(spec.copperUm) / 1000.0;+    for (agg, dv) in &aggressors {+        let Some(aid) = f.net_names.iter().position(|x| x == agg) else { continue }; let aid = aid as u16;+        for (vid, vname) in f.net_names.iter().enumerate() {+            let vid = vid as u16; if vid == aid || Some(vid) == gid || f.flow.contains_key(vname) || rail(vname).is_some() || aggressors.iter().any(|a| a.0 == *vname) { continue; }+            // a net joined to the aggressor through a capacitor rides on it on purpose (a bootstrap): not a victim+            let rides = { let mut parts: std::collections::BTreeMap<&str, (bool, bool)> = Default::default(); for p in &b.pads { if !p.reference.starts_with('C') { continue; } let e = parts.entry(p.reference.as_str()).or_default(); if p.net == *agg { e.0 = true; } if p.net == *vname { e.1 = true; } } parts.values().any(|(a, v)| *a && *v) };+            if rides { continue; }+            // side coupling on each layer: victim cells within 1 mm of the aggressor, summed as C per length at that spacing+            let (mut cm, mut closest, mut at, mut run) = (0.0f64, f64::MAX, [0.0, 0.0], 0usize); let mut varea = 0.0;+            let rad = (1.0 / f.d).ceil() as i64;+            for &k in &real { for i in 0..n {+                if f.net_ids[k][i] != vid { continue; } varea += f.d * f.d;+                let (r, c) = ((i / w) as i64, (i % w) as i64); let mut dmin = f64::MAX;+                for dr in -rad..=rad { for dc in -rad..=rad { let (rr, cc) = (r + dr, c + dc); if rr < 0 || cc < 0 || rr >= h as i64 || cc >= w as i64 { continue; } if f.net_ids[k][rr as usize * w + cc as usize] == aid { dmin = dmin.min(((dr * dr + dc * dc) as f64).sqrt() * f.d); } } }+                if dmin < f64::MAX { let s_ = dmin.max(0.05); let eff = (er + 1.0) / 2.0; cm += E0 * eff * (thick(k) / s_ + 0.6 * (1.0 + f.d / s_).ln()) * f.d; run += 1; if dmin < closest { closest = dmin; at = [f.x0 + (c as f64 + 0.5) * f.d, f.y0 + (r as f64 + 0.5) * f.d]; } }+            } }+            // overlap with the aggressor on the next layer+            let mut over = 0.0; if real.len() >= 2 { for i in 0..n { if (f.net_ids[real[0]][i] == vid && f.net_ids[real[1]][i] == aid) || (f.net_ids[real[1]][i] == vid && f.net_ids[real[0]][i] == aid) { over += f.d * f.d; } } }+            let hgap = if spec.stackupMm.len() == 3 { spec.stackupMm.iter().sum::<f64>() } else { 1.465 };+            cm += E0 * er * over / hgap;+            if cm <= 0.0 { continue; }+            let cg = E0 * er * varea / hgap + 3e-12;   // the victim to ground: its copper over the plane, plus about 3 pF of pins+            let mv = dv * cm / (cm + cg) * 1e3;+            xt.push(json!({"aggressor": agg, "victim": vname, "cmPf": r2(cm * 1e12), "closestMm": if closest < f64::MAX { r2(closest) } else { -1.0 }, "runMm": r2(run as f64 * f.d), "overlapMm2": r2(over), "coupledMv": r2(mv), "at": [r2(at[0]), r2(at[1])]}));+            if mv > budget { issues.push(Issue { severity: if mv > 4.0 * budget { "medium".into() } else { "low".into() }, kind: "crosstalk".into(), what: format!("{} picks up about {:.0} mV from each {} edge ({:.2} pF, closest {:.2} mm, {:.1} mm run)", vname, mv, agg, cm * 1e12, closest, run as f64 * f.d), at: Some([r2(at[0]), r2(at[1])]), layer: None, net: Some(vname.clone()), reference: None, fix: format!("Route {} at least {:.1} mm from {} (three times the spacing), or run a ground guard between them, and keep {} off the copper under {}.", vname, (closest * 3.0).max(1.0), agg, vname, agg), action: Some(json!({"type": "reroute", "net": vname, "awayFrom": agg, "minSpacingMm": r2((closest * 3.0).max(1.0)), "at": [r2(at[0]), r2(at[1])], "or": "ground guard between"})) }); }+        }+    }+    xt.sort_by(|a, z| z["coupledMv"].as_f64().partial_cmp(&a["coupledMv"].as_f64()).unwrap());+    out["crosstalk"] = json!(xt);+    out["tones"] = json!(tones.iter().take(40).map(|t| json!({"fHz": t.0, "what": t.1})).collect::<Vec<_>>());+    let _ = b; out+}++#[cfg(test)] mod tests {+    use super::*;+    /// a 20 x 10 mm rectangle: the first mode is (1,0), f = c / (2 a √εr)+    #[test] fn rectangle_modes() {+        let (cw, ch, hc) = (40usize, 20usize, 0.5); let n = cw * ch;+        let apply = |x: &[f64], y: &mut [f64]| { for i in 0..n { let (r, c) = (i / cw, i % cw); let mut s = 0.0; let mut k = 0.0; if c + 1 < cw { s += x[i + 1]; k += 1.0; } if c > 0 { s += x[i - 1]; k += 1.0; } if r + 1 < ch { s += x[i + cw]; k += 1.0; } if r > 0 { s += x[i - cw]; k += 1.0; } y[i] = k * x[i] - s; } };+        let m = lowest_modes(n, 3, &apply);+        assert!(m[0].0.abs() < 1e-6, "constant mode {}", m[0].0);+        let f1 = C_MM * (m[1].0.sqrt() / hc) / (2.0 * std::f64::consts::PI * 4.3f64.sqrt());+        let exact = C_MM / (2.0 * 20.0 * 4.3f64.sqrt());+        assert!((f1 - exact).abs() / exact < 0.01, "{f1} vs {exact}");+    }+}
src/serve.rs+6−3
@@ -58,17 +58,19 @@ 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", "/noise.js") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("noise.js", include_str!("ui/noise.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())); }+        ("GET", "/noise.json") => { let file = app.lock().unwrap().dir.join("noise.json"); match std::fs::read(&file) { Ok(bytes) => { let _ = req.respond(tiny_http::Response::from_data(bytes).with_header(jsonh.clone()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); } Err(_) => reply(req, 404, json!({"status": "error", "error": "no noise.json: run analyze with 0.7.8 or later"})) } }         ("GET", "/flow.bin") => {             // the signed current, the voltage and the owning net of every cell, for the flow and river views             let file = app.lock().unwrap().dir.join("flow.bin");             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|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", "/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|noise), 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();@@ -100,7 +102,7 @@ fn handle(mut req: tiny_http::Request, app: Arc<Mutex<App>>) {                 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); }                 // 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["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("cavity-resonance") | Some("crosstalk") => "noise", Some("decoupling-far") | Some("decoupling-missing") | Some("esd-unprotected") => "emc", 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(); } }@@ -181,7 +183,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", "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::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", "noise"].contains(&q.as_str()) { crate::err(&format!("--quantity {q}: use current, heat, temperature, signals, flow, river, energy, light, cycle, charge, magnetic, emc, ground or noise"), &[]); } } 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"]), &[]); }@@ -223,6 +225,7 @@ pub fn tour(port: u16, hold_ms: u64) {         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;+        toast("Noise: the planes ringing, the switching edges reaching quiet nets"); step("/ui/view", json!({"quantity": "noise", "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+9−3
@@ -191,6 +191,7 @@     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 (q === 'noise') return window.fieldsNoise ? window.fieldsNoise.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.`;@@ -329,7 +330,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' || q === 'ground') return `copper-${ls}.png`;+    if (q === 'river' || q === 'energy' || q === 'light' || q === 'cycle' || q === 'charge' || q === 'magnetic' || q === 'emc' || q === 'ground' || q === 'noise') 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';@@ -344,7 +345,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','ground'].includes(state.view.quantity); el.textContent = c.reference + '  +' + (state.view.air === 'moving' ? c.riseMovingC : c.riseC).toFixed(0) + ' °C'; }+    for (const { el, c } of labels) { el.hidden = ['current','signals','flow','river','energy','light','cycle','charge','magnetic','emc','ground','noise'].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';@@ -357,6 +358,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.fieldsNoise) {+      window.fieldsNoise.apply(state.view);+      if (state.view.quantity === 'noise') { const bd = $('#flowBadge'), kb = $('#btnKey'); let on = false; try { on = localStorage.getItem('fields.keyOn') === '1'; } catch (e) { void e; } const [h, ...rows] = window.fieldsNoise.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.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); } }@@ -483,7 +488,8 @@         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('./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))); })+          .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)))+          .then(() => import('./noise.js')).then(m => m.attach({ BAB, scene, V, camera, state, LAYERS, frameDt, root, layerMeshes: state.layerMeshes, flow: api })).then(nz => { window.fieldsNoise = nz; applyTextures(); }).catch(e => console.error('noise: ' + (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/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.noise{border-color:rgba(255,200,80,.5)}.lbl.noise .v{color:#ff8a6e;font-weight:600}.lbl.noise .g{color:#5cf08f;font-weight:600} .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}@@ -112,7 +113,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><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>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><button data-q="noise" data-tip="Where switching noise rings and spreads: the planes ringing like drums at their own frequencies, and the switching edges reaching quiet nets">Noise</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>
src/ui/noise.jsadded+112
@@ -0,0 +1,112 @@+// Adom Fields: noise couplings. Two findings from the solve's own review (fields.json review.noise and+// noise.json for the mode shapes), drawn on the board:+//  - the plane cavities: a power pour over the ground plane rings like a drum at its own frequencies; its+//    real lowest modes flex in 3D, each tagged with its frequency against the nearest strong tone;+//  - crosstalk: the switching copper pulses, arcs jump to each quiet net at its closest approach, the victim's+//    copper glows by the millivolts it picks up per edge against the budget.+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, bx, by } = flow.data;+  const rv = (state.fields.review || {}).noise || { cavities: [], crosstalk: [] };+  let shapes = { cavities: [] }; try { shapes = await fetch('noise.json').then(r => r.ok ? r.json() : { cavities: [] }); } catch (e) { void e; }+  const budget = 50;+  const group = new BAB.TransformNode('fields-noise', scene); group.parent = root; group.setEnabled(false);+  // ---- the drum: the largest cavity's modes as a flexing surface --------------------------------------+  const cav = (rv.cavities || []).slice().sort((a, b) => b.areaMm2 - a.areaMm2)[0];+  const cs = cav && shapes.cavities.find(c => c.net === cav.net);+  let drum = null;+  if (cav && cs && cs.shapes.length) {+    const g = cs.grid, cw = g.cols, ch = g.rows, hc = g.cellMm;+    const pos = new Float32Array(cw * ch * 3), col = new Float32Array(cw * ch * 4), ind = [];+    for (let r = 0; r < ch; r++) for (let c = 0; c < cw; c++) { const v = r * cw + c; pos[v * 3] = bx(g.x0 + (c + 0.5) * hc); pos[v * 3 + 1] = by(g.y0 + (r + 0.5) * hc); pos[v * 3 + 2] = 0; }+    for (let r = 0; r < ch - 1; r++) for (let c = 0; c < cw - 1; c++) { const a = r * cw + c; if (g.mask[a] && g.mask[a + 1] && g.mask[a + cw] && g.mask[a + cw + 1]) ind.push(a, a + cw, a + 1, a + 1, a + cw, a + cw + 1); }+    const mesh = new BAB.Mesh('noise-drum', scene); const vd = new BAB.VertexData(); vd.positions = pos; vd.indices = ind; vd.colors = col; vd.applyToMesh(mesh, true);+    const m = new BAB.StandardMaterial('noise-drum-m', scene); m.disableLighting = true; m.emissiveColor = new BAB.Color3(1, 1, 1); m.backFaceCulling = false; m.alpha = 0.85; mesh.useVertexColors = true; m.diffuseColor = new BAB.Color3(0, 0, 0);+    mesh.material = m; mesh.hasVertexAlpha = true; mesh.parent = group; mesh.renderingGroupId = 2; mesh.isPickable = false;+    drum = { mesh, mat: m, pos, col, g, amp: Math.min(3.5, 0.2 * Math.max(cw, ch) * hc) };+  }+  // ---- crosstalk: victim copper glow, arcs at the closest approach --------------------------------------+  const xt = (rv.crosstalk || []).filter(x => x.coupledMv > 1);+  const vtex = new Uint8Array(W * H * 4); const k0 = real[0];+  const idOf = (name) => nets.findIndex(n => n.name === name);+  const aggIds = new Set(xt.map(x => idOf(x.aggressor)));+  for (const x of xt) { const id = idOf(x.victim); const t = Math.min(1, x.coupledMv / budget); for (const k of real) for (let i = 0; i < W * H; i++) if (lay[k].net[i] === id) { const o = i * 4; vtex[o] = 255; vtex[o + 1] = Math.round(200 - 140 * t); vtex[o + 2] = 40; vtex[o + 3] = Math.round(90 + 165 * t); } }+  const atex = new Uint8Array(W * H * 4);+  for (const k of real) for (let i = 0; i < W * H; i++) if (aggIds.has(lay[k].net[i])) { const o = i * 4; atex[o] = 255; atex[o + 1] = 90; atex[o + 2] = 220; atex[o + 3] = 200; }+  const plane = (name, data) => { const t = new BAB.RawTexture(data, W, H, BAB.Constants.TEXTUREFORMAT_RGBA, scene, false, false, BAB.Texture.NEAREST_SAMPLINGMODE, BAB.Constants.TEXTURETYPE_UNSIGNED_BYTE); const p = BAB.MeshBuilder.CreatePlane(name, { width: W * d, height: H * d, sideOrientation: BAB.Mesh.DOUBLESIDE }, scene); const uvs = p.getVerticesData(BAB.VertexBuffer.UVKind); for (let i = 1; i < uvs.length; i += 2) uvs[i] = 1 - uvs[i]; p.setVerticesData(BAB.VertexBuffer.UVKind, uvs); p.position.x = bx(meta.x0 + W * d / 2); p.position.y = by(meta.y0 + H * d / 2); const m = new BAB.StandardMaterial(name + '-m', scene); m.diffuseTexture = t; t.hasAlpha = true; m.useAlphaFromDiffuseTexture = true; m.emissiveColor = new BAB.Color3(1, 1, 1); m.disableLighting = true; m.backFaceCulling = false; p.material = m; p.parent = group; p.renderingGroupId = 2; p.isPickable = false; return { p, m }; };+  const victims = plane('noise-victims', vtex), aggressors = plane('noise-aggressors', atex);+  const arcs = [];+  for (const x of xt) {+    // from the nearest aggressor copper to the victim's closest point: a spark arc+    const aid = idOf(x.aggressor); let best = null, bd = Infinity; const ci = Math.floor((x.at[1] - meta.y0) / d) * W + Math.floor((x.at[0] - meta.x0) / d);+    const r0 = Math.floor(ci / W), c0 = ci % W, R = Math.ceil(2 / d);+    for (let dr = -R; dr <= R; dr++) for (let dc = -R; dc <= R; dc++) { const r = r0 + dr, c = c0 + dc; if (r < 0 || c < 0 || r >= H || c >= W) continue; if (lay[k0].net[r * W + c] === aid) { const dd = dr * dr + dc * dc; if (dd < bd) { bd = dd; best = [meta.x0 + (c + 0.5) * d, meta.y0 + (r + 0.5) * d]; } } }+    if (!best) continue;+    const pts = []; for (let a = 0; a <= 12; a++) { const t = a / 12; pts.push(bx(best[0] + (x.at[0] - best[0]) * t), by(best[1] + (x.at[1] - best[1]) * t), 0.15 + 0.8 * Math.sin(Math.PI * t)); }+    try { const m = BAB.CreateGreasedLine('noise-arc-' + x.victim, { points: pts }, { color: new BAB.Color3(1, 0.85, 0.3), width: 0.14, useDash: true, dashCount: 8, dashRatio: 0.5, sizeAttenuation: true }, scene); m.parent = group; m.renderingGroupId = 2; m.isPickable = false; arcs.push(m); } catch (e) { void e; }+  }+  // tags+  const host = document.getElementById('labels'), tags = [];+  const tag = (html, x, y, z) => { const el = document.createElement('div'); el.className = 'lbl noise'; el.innerHTML = html; el.hidden = true; host.appendChild(el); tags.push({ el, x, y, z }); };+  for (const x of xt.slice(0, 5)) tag(`<b>${x.victim}</b> ← ${x.aggressor} <span class="${x.coupledMv > budget ? 'v' : 'g'}">${x.coupledMv.toFixed(0)} mV</span> · ${x.closestMm} mm`, x.at[0], x.at[1], 1.3);+  const modeTag = { el: null };+  if (cav) { const el = document.createElement('div'); el.className = 'lbl noise'; el.hidden = true; host.appendChild(el); modeTag.el = el; }+  // ---- the panel -------------------------------------------------------------------------------------------+  const ghz = (h) => h >= 1e9 ? (h / 1e9).toFixed(2) + ' GHz' : (h / 1e6).toFixed(0) + ' MHz';+  const tones = rv.tones || [], topTone = tones.length ? Math.max(...tones.map(t => t.fHz)) : 0;+  const panel = document.createElement('div'); panel.className = 'cyclemeter noisepanel'; panel.hidden = true; document.querySelector('.stage').appendChild(panel);+  panel.innerHTML = `<div class="ch" title="Click to fold">Noise couplings</div><div class="body">+    <div class="row" style="color:var(--text-2)">Plane cavities (lowest mode)</div>+    ${(rv.cavities || []).map(c => `<div class="row"><span class="dot" style="background:${c.modes.some(m => m.near) ? '#ff5a4a' : '#5cf08f'}"></span>${c.net} over ${c.over}<i>${c.modes[0] ? ghz(c.modes[0].fHz) : '?'}${c.modes.some(m => m.near) ? ' · rings' : ''}</i></div>`).join('') || '<div class="row">none</div>'}+    <div class="row" style="color:var(--text-2)">Strong tones reach ${topTone ? ghz(topTone) : '—'}</div>+    <div class="row" style="color:var(--text-2);margin-top:6px">Crosstalk per edge (budget ${budget} mV)</div>+    ${xt.slice(0, 4).map(x => `<div class="row"><span class="dot" style="background:${x.coupledMv > budget ? '#ff5a4a' : '#5cf08f'}"></span>${x.victim} ← ${x.aggressor}<i>${x.coupledMv.toFixed(0)} mV</i></div>`).join('') || '<div class="row">no quiet net near a switching node</div>'}+  </div>`;+  panel.addEventListener('click', (e) => { if (e.target.closest('.ch')) panel.classList.toggle('folded'); });+  // ---- frame ------------------------------------------------------------------------------------------------+  const knob = { on: 0, to: 0, t: 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 = zOf(k0) + 0.1;+    if (!live) { for (const t of tags) t.el.hidden = true; if (modeTag.el) modeTag.el.hidden = true; return; }+    knob.t += dt * (+state.view.speed || 1);+    // the aggressor pulses with the switching, the victims glow with it+    const pulse = 0.5 + 0.5 * Math.tanh(4 * Math.sin(2 * Math.PI * 0.4 * knob.t));+    aggressors.m.alpha = knob.on * (0.25 + 0.75 * pulse); victims.m.alpha = knob.on * (0.3 + 0.7 * pulse);+    for (const a of arcs) { a.setEnabled(pulse > 0.5); if (a.greasedLineMaterial) a.greasedLineMaterial.dashOffset = -knob.t * 0.6; }+    // the drum: each mode for six seconds, flexing+    if (drum) {+      const shp = cs.shapes, mi = Math.floor(knob.t / 6) % shp.length, s = shp[mi], ph = Math.sin(2 * Math.PI * 0.8 * knob.t), g = drum.g;+      for (let i = 0; i < s.length; i++) { const v = s[i] * ph; drum.pos[i * 3 + 2] = drum.amp * 0.6 + v * drum.amp * 0.5; const t = Math.abs(v); // the two phases red and blue, the nodal lines (where the plate stays still) white+        const sv = Math.abs(s[i]); if (sv < 0.06) { drum.col[i * 4] = 1; drum.col[i * 4 + 1] = 1; drum.col[i * 4 + 2] = 1; drum.col[i * 4 + 3] = g.mask[i] ? 0.95 : 0; continue; }+        if (v >= 0) { drum.col[i * 4] = 1; drum.col[i * 4 + 1] = 0.25 * (1 - t); drum.col[i * 4 + 2] = 0.3 * (1 - t); } else { drum.col[i * 4] = 0.15 * (1 - t); drum.col[i * 4 + 1] = 0.45 * (1 - t) + 0.3; drum.col[i * 4 + 2] = 1; } drum.col[i * 4 + 3] = g.mask[i] ? 0.55 + 0.45 * sv : 0; }+      drum.mesh.updateVerticesData(BAB.VertexBuffer.PositionKind, drum.pos); drum.mesh.updateVerticesData(BAB.VertexBuffer.ColorKind, drum.col); drum.mat.alpha = knob.on * 0.9;+      if (modeTag.el) { const md = cav.modes[mi]; modeTag.el.innerHTML = md ? `<b>${cav.net} over ${cav.over}</b> mode ${mi + 1}: <span class="${md.near ? 'v' : 'g'}">${ghz(md.fHz)}</span>${md.near ? ' near the ' + md.near : ''}` : ''; modeTag._p = md ? md.antinode : null; }+    }+    const e = V.getEngine(), w = e.getRenderWidth(), h = e.getRenderHeight(), hostEl = document.getElementById('host'), wm = group.getWorldMatrix();+    const place = (el, x, y, z) => { const p = BAB.Vector3.Project(BAB.Vector3.TransformCoordinates(new BAB.Vector3(bx(x), by(y), z), wm), BAB.Matrix.Identity(), scene.getTransformMatrix(), camera.viewport.toGlobal(w, h)); el.hidden = p.z > 1 || p.z < 0; el.style.left = (p.x / (w / hostEl.clientWidth)) + 'px'; el.style.top = (p.y / (h / hostEl.clientHeight)) + 'px'; };+    for (const t of tags) place(t.el, t.x, t.y, t.z);+    if (modeTag.el && modeTag._p) place(modeTag.el, modeTag._p[0], modeTag._p[1], (drum ? drum.amp : 1) + 0.8);+  });+  function apply(view) {+    knob.to = view.quantity === 'noise' ? 1 : 0; panel.hidden = view.quantity !== 'noise';+    if (view.quantity !== 'noise') return;+    for (const Lname of LAYERS) { const m = ctx.layerMeshes[Lname]; if (m && m.material) m.material.alpha = Lname === 'F.Cu' ? 0.35 : 0; }+    const leg = document.getElementById('legend'); if (leg) leg.hidden = true;+  }+  function describe() {+    return ['<b>Noise couplings</b>: where switching noise rings and spreads',+      '<b>The flexing sheet</b> = a power pour over the ground plane ringing like a drum: its real lowest modes, red and blue the two phases',+      '<b>Pink copper</b> = the switching node, pulsing; <b>yellow to red copper</b> = quiet nets picking up its edges, brighter for more millivolts',+      '<b>Arcs</b> = where the coupling is closest',+      'A mode near a strong harmonic, or a quiet net over ' + budget + ' mV, is in the fix plan; parallel-plate and side-coupling estimates'];+  }+  function narration() {+    const c0 = (rv.cavities || []).slice().sort((a, b) => b.areaMm2 - a.areaMm2)[0], x0 = xt[0];+    return `This is the Noise view: where switching noise can ring and spread. ${c0 ? `Any power pour sitting over the ground plane is a cavity, like a drum skin, and it rings at its own frequencies. The flexing sheet is the ${c0.net} pour, ringing in its real lowest modes. Its first mode is at ${(c0.modes[0].fHz / 1e9).toFixed(1)} gigahertz, ${c0.modes.some(m => m.near) ? 'and one of its modes lines up with a strong harmonic, so it is in the fix plan, with a small capacitor where it rings loudest.' : `well above where this converter's harmonics are still strong, so it will not ring.`} ` : ''}${x0 ? `The pink copper is the switch node, pulsing with every edge. The quiet nets near it pick up a little of each edge through stray capacitance: ${x0.victim} gets about ${x0.coupledMv.toFixed(0)} millivolts, ${x0.coupledMv > budget ? 'over the budget, so the fix plan reroutes it away or adds a ground guard.' : 'under the fifty millivolt budget.'}` : 'No quiet net runs close to the switch node.'} Both are estimates, a screen for what to look at, and every finding goes into the fix plan for the AI.`;+  }+  console.log(`noise: ${(rv.cavities || []).length} cavities${cav ? `, drum ${cav.net} ${cs ? cs.shapes.length : 0} modes` : ''}; crosstalk ${xt.map(x => x.victim + '←' + x.aggressor + ' ' + x.coupledMv + ' mV').join(', ')}`);+  return { apply, describe, narration };+}