← Commit history
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-fields"-version = "0.7.4"+version = "0.7.5" dependencies = [  "clap",  "png",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-fields"-version = "0.7.4"+version = "0.7.5" 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
@@ -105,6 +105,14 @@ The field every current makes: field lines looping around the copper, and a prob  [Download the magnetic video](docs/videos/magnetic.mp4) +### EMC screen++Where the board could radiate: the switching hot loop and the smallest it could be, copper with no ground under it, and the loop's estimated emissions against the FCC class B limit.++<video controls preload="metadata" width="1080" poster="docs/videos/emc-poster.jpg" src="docs/videos/emc.mp4"></video>++[Download the EMC video](docs/videos/emc.mp4)+ ## 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+9−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 [--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 [--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@@ -117,6 +117,14 @@ The model is the ideal continuous-conduction buck solved exactly: D = Vout/Vin,  `ui view --quantity magnetic [--probe 2]`: the Biot–Savart field of the solved currents (every copper cell's current summed onto a half-millimetre grid of current elements, plus the current down each via). Glowing blue field lines loop around the current paths, brightest where the field is strongest, their dashes running the way the field points; a sheet at `--probe` mm above the top copper (the slider beside the view buttons, 0.5 to 15 mm) glows where the field is strong, with a white ring at every tenfold step, the way a near-field probe would see it. The readout gives µT, the share of Earth's field (about 50 µT) and H in A/m. DC: the switching field comes with the EMC loop views. +## The EMC screen (0.7.5)++`ui view --quantity emc` on a board with a modelled switching converter (see the energy cycle): three checks drawn on the board.+- **The hot loop**: input capacitor → the switch's input pin → its ground pin → back to the capacitor, for every input capacitor; the smallest (fastest-edged) capacitor's loop is the red patch, pulsing with the switching, drawn over the parts; a green dashed outline is the smallest loop the switch allows (a capacitor straight across its pins). Areas in mm², in the board's plane between the pads.+- **The reference plane**: copper of the loaded and switching nets on the top layer, green where ground copper lies on the next layer, red where it does not (the return current has to detour); the share and mm² per net in the panel.+- **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.+ ## 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.5.mdadded+5
@@ -0,0 +1,5 @@+# adom-fields 0.7.5++- **The EMC screen.** `ui view --quantity emc`: the switching hot loop of each input capacitor drawn and measured (mm²) with the smallest loop the switch allows; power and switching copper coloured by whether ground lies under it; the hot loop's radiated emissions estimated 30 MHz to 1 GHz at 3 m against the FCC class B limit, with the worst margin. Differential mode only, labelled.+- Spec: `switching.<net>.riseNs`, the switch's edge time (8 ns when not given).+- A narrated video of the EMC screen on the page; the tour adds it.
docs/videos/emc-poster.jpgadded
⋯ 1 unchanged line ⋯
docs/videos/emc.mp4added
⋯ 1 unchanged line ⋯
package.json+9−2
@@ -1,7 +1,7 @@ {   "slug": "adom-fields",   "type": "app",-  "version": "0.7.4",+  "version": "0.7.5",   "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.",@@ -57,7 +57,14 @@     "magnetic field of a pcb",     "near-field probe",     "biot-savart",-    "magnetic field lines"+    "magnetic field lines",+    "emc",+    "emi",+    "hot loop area",+    "radiated emissions estimate",+    "fcc class b",+    "return path",+    "ground under trace"   ],   "sample_prompts": [     {
page.json+15−10
@@ -1,7 +1,7 @@ {   "slug": "adom-fields",   "type": "app",-  "version": "0.7.4",+  "version": "0.7.5",   "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",@@ -57,7 +55,14 @@     "magnetic field of a pcb",     "near-field probe",     "biot-savart",-    "magnetic field lines"+    "magnetic field lines",+    "emc",+    "emi",+    "hot loop area",+    "radiated emissions estimate",+    "fcc class b",+    "return path",+    "ground under trace"   ],   "sample_prompts": [     {@@ -98,4 +103,4 @@     "name": "John Lauer",     "email": "[email protected]"   }-}\ No newline at end of file+}
skills/adom-fields/SKILL.md+9−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 [--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 [--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@@ -117,6 +117,14 @@ The model is the ideal continuous-conduction buck solved exactly: D = Vout/Vin,  `ui view --quantity magnetic [--probe 2]`: the Biot–Savart field of the solved currents (every copper cell's current summed onto a half-millimetre grid of current elements, plus the current down each via). Glowing blue field lines loop around the current paths, brightest where the field is strongest, their dashes running the way the field points; a sheet at `--probe` mm above the top copper (the slider beside the view buttons, 0.5 to 15 mm) glows where the field is strong, with a white ring at every tenfold step, the way a near-field probe would see it. The readout gives µT, the share of Earth's field (about 50 µT) and H in A/m. DC: the switching field comes with the EMC loop views. +## The EMC screen (0.7.5)++`ui view --quantity emc` on a board with a modelled switching converter (see the energy cycle): three checks drawn on the board.+- **The hot loop**: input capacitor → the switch's input pin → its ground pin → back to the capacitor, for every input capacitor; the smallest (fastest-edged) capacitor's loop is the red patch, pulsing with the switching, drawn over the parts; a green dashed outline is the smallest loop the switch allows (a capacitor straight across its pins). Areas in mm², in the board's plane between the pads.+- **The reference plane**: copper of the loaded and switching nets on the top layer, green where ground copper lies on the next layer, red where it does not (the return current has to detour); the share and mm² per net in the panel.+- **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.+ ## 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/flow.rs+2−2
@@ -12,7 +12,7 @@ use std::path::Path;  /// A switching node: copper that swings between two rails (a buck's SW). The river shows it as a tide. #[derive(Deserialize, Serialize, Clone, Debug)]-pub struct Switching { pub highV: f64, #[serde(default)] pub lowV: f64, #[serde(default)] pub freqHz: Option<f64>, /** the node's DC average (a buck's SW averages its output voltage); the energy view uses it, the midpoint otherwise */ #[serde(default)] pub meanV: Option<f64> }+pub struct Switching { pub highV: f64, #[serde(default)] pub lowV: f64, #[serde(default)] pub freqHz: Option<f64>, /** the node's DC average (a buck's SW averages its output voltage); the energy view uses it, the midpoint otherwise */ #[serde(default)] pub meanV: Option<f64>, /** the switch's edge time, ns (the EMC estimate's harmonics roll off above 1/(pi tr)); 8 ns when not given */ #[serde(default)] pub riseNs: Option<f64> }  #[derive(Serialize, Clone, Debug)] pub struct Terminal { pub reference: String, pub pad: String, pub x: f64, pub y: f64, pub amps: f64, pub side: String }@@ -70,7 +70,7 @@ pub fn write(out: &Path, f: &Fields, b: &Board, spec: &Spec) -> Result<Value, St     let mut metas: Vec<Value> = Vec::new();     let (mut jmax, mut vmin, mut vmax) = (0.0f64, f64::MAX, f64::MIN);     for (id, name) in f.net_names.iter().enumerate() {-        let switching = spec.switching.get(name).cloned().or_else(|| if is_switch_name(name) && !spec.voltages.contains_key(name) && top_rail > 0.0 { Some(Switching { highV: top_rail, lowV: 0.0, freqHz: None, meanV: None }) } else { None });+        let switching = spec.switching.get(name).cloned().or_else(|| if is_switch_name(name) && !spec.voltages.contains_key(name) && top_rail > 0.0 { Some(Switching { highV: top_rail, lowV: 0.0, freqHz: None, meanV: None, riseNs: None }) } else { None });         let rail = spec.voltages.get(name).copied().or_else(|| rail_from_name(name));         let ground = rail == Some(0.0);         for k in 0..nl { for i in 0..n { if f.net_ids[k][i] as usize == id { netf[k][i] = id as f32; } } }
src/serve.rs+4−2
@@ -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", "/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", "/flow.bin") => {@@ -66,7 +67,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), 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), 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();@@ -178,7 +179,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"].contains(&q.as_str()) { crate::err(&format!("--quantity {q}: use current, heat, temperature, signals, flow, river, energy, light, cycle, charge or magnetic"), &[]); } } 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"].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::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"]), &[]); }@@ -218,6 +219,7 @@ pub fn tour(port: u16, hold_ms: u64) {         toast("The energy cycle: input caps, the inductor's magnetic field, the output, one switching period"); step("/ui/view", json!({"quantity": "cycle", "camera": "iso"}), settle); steps += 1;         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;         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
@@ -189,6 +189,7 @@     const f = state.fields; if (!f) return '';     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 (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.`;@@ -327,7 +328,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') 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 === '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';@@ -342,7 +343,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'].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'].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';@@ -355,6 +356,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.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); } }+    }     if (window.fieldsMagnetic) {       window.fieldsMagnetic.apply(state.view);       if (state.view.quantity === 'magnetic') { const bd = $('#flowBadge'), kb = $('#btnKey'); let on = false; try { on = localStorage.getItem('fields.keyOn') === '1'; } catch (e) { void e; } const [h, ...rows] = window.fieldsMagnetic.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); } }@@ -470,7 +475,8 @@       .then(api => { window.fieldsFlow = api; if (!api) flowNote = 'This solve has no flow data: run adom-fields analyze again with 0.7 or later.'; applyTextures();         // the energy cycle of a switching converter, on the flow view's shaders         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('./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))); })       .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/emc.jsadded+162
@@ -0,0 +1,162 @@+// Adom Fields: the EMC screen of a switching converter.+// Three checks, each drawn on the board:+//  1. The hot loop: input capacitor -> the switch's input pin -> its ground pin -> back to the capacitor.+//     Its area is what radiates; drawn as a pulsing patch, with the smallest loop this placement could reach.+//  2. The reference plane: power and switching copper over solid ground (green) or over a gap (red), where+//     the return current has to detour.+//  3. Radiated emissions: the hot loop as a small loop antenna (Ott: E = 1.316e-14 f² A I / r, V/m), its+//     current's harmonics from the trapezoid (duty, edge time), at 3 m against the FCC Part 15 class B limit.+// A screen, not a chamber: differential mode only, no cable (common mode), the loop in the board's plane.+export async function attach(ctx) {+  const { BAB, scene, V, camera, state, LAYERS, frameDt, root, flow, cycle } = ctx;+  if (!flow || !flow.data) return null;+  const { lay, real, W, H, d, meta, nets, zOf, bx, by } = flow.data;+  const model = cycle && cycle.model ? cycle.model() : null;+  const pads = state.fields.pads || [];+  const why = [];+  if (!model) why.push('no switching converter modelled (see the Cycle view)');+  // ---- 1. the hot loops -------------------------------------------------------------------------------+  const padOf = (ref, net) => pads.filter(p => p.ref === ref && p.net === net);+  const near = (list, to) => list.slice().sort((a, b) => Math.hypot(a.x - to.x, a.y - to.y) - Math.hypot(b.x - to.x, b.y - to.y))[0];+  const area = (pts) => { let a = 0; for (let i = 0; i < pts.length; i++) { const j = (i + 1) % pts.length; a += pts[i].x * pts[j].y - pts[j].x * pts[i].y; } return Math.abs(a) / 2; };+  let loops = [], ideal = null;+  if (model) {+    const vinPins = padOf(model.swRef, model.inNet), gndPins = padOf(model.swRef, model.gnd);+    if (!vinPins.length || !gndPins.length) why.push(`${model.swRef} has no pad on ${model.inNet} or ${model.gnd}`);+    else {+      for (const c of model.cin) {+        const cv = padOf(c.ref, model.inNet)[0], cg = padOf(c.ref, model.gnd)[0]; if (!cv || !cg) continue;+        const uv = near(vinPins, cv), ug = near(gndPins, cg);+        const pts = [cv, uv, ug, cg].map(p => ({ x: p.x, y: p.y }));+        loops.push({ ref: c.ref, C: c.C, pts, mm2: area(pts) });+      }+      loops.sort((a, b) => a.mm2 - b.mm2);+      // the best this switch allows: a capacitor straight across its own input and ground pins+      const uv = vinPins[0], ug = near(gndPins, uv), span = Math.hypot(uv.x - ug.x, uv.y - ug.y), w = 0.8;+      const nx = -(ug.y - uv.y) / (span || 1), ny = (ug.x - uv.x) / (span || 1);+      ideal = { pts: [{ x: uv.x, y: uv.y }, { x: ug.x, y: ug.y }, { x: ug.x + nx * w, y: ug.y + ny * w }, { x: uv.x + nx * w, y: uv.y + ny * w }], mm2: span * w };+    }+  }+  const hf = loops.length ? loops.slice().sort((a, b) => a.C - b.C)[0] : null;   // the smallest capacitor carries the fastest edges+  const main = hf || loops[0] || null;+  // ---- 2. the reference plane under power and switching copper ------------------------------------------+  const k0 = real[0], k1 = real.length > 1 ? real[1] : -1;+  const gid = nets.findIndex(n => n.ground);+  const refStats = {};+  const rtex = new Uint8Array(W * H * 4);+  if (k1 >= 0 && gid >= 0) {+    for (let i = 0; i < W * H; i++) {+      const id = lay[k0].net[i]; if (id < 0 || id === gid) continue; const n = nets[id]; if (!n || !(n.loaded || n.switching)) continue;+      const ok = lay[k1].net[i] === gid;+      const st = refStats[n.name] = refStats[n.name] || { on: 0, off: 0 }; ok ? st.on++ : st.off++;+      const o = i * 4; if (ok) { rtex[o] = 60; rtex[o + 1] = 200; rtex[o + 2] = 120; rtex[o + 3] = 70; } else { rtex[o] = 255; rtex[o + 1] = 70; rtex[o + 2] = 60; rtex[o + 3] = 200; }+    }+  }+  const refRows = Object.entries(refStats).map(([name, s]) => ({ name, pct: s.off / (s.on + s.off) * 100, mm2: s.off * d * d, switching: !!(nets.find(n => n.name === name) || {}).switching })).sort((a, b) => b.pct - a.pct);+  // ---- 3. radiated emissions of the hot loop ------------------------------------------------------------+  const R = 3, FCC = (f) => f < 88e6 ? 40 : f < 216e6 ? 43.5 : f < 960e6 ? 46 : 54;   // dBµV/m at 3 m, class B+  let spec = null;+  if (model && main) {+    const sw = nets.find(n => n.switching) || {}, tr = ((sw.switching && sw.switching.riseNs) || 8) * 1e-9, T = model.T, D = model.D;+    const I = model.Iout + model.dI / 2, A = main.mm2 * 1e-6;+    const sinc = (x) => x === 0 ? 1 : Math.sin(x) / x;+    const bins = []; const f0 = 30e6, f1 = 1e9, nb = 64;+    for (let b = 0; b < nb; b++) { const fa = f0 * Math.pow(f1 / f0, b / nb), fb = f0 * Math.pow(f1 / f0, (b + 1) / nb); let mx = -999; for (let n = Math.max(1, Math.ceil(fa * T)); n * (1 / T) < fb; n += Math.max(1, Math.floor((fb - fa) * T / 40))) { const f = n / T, In = 2 * I * D * Math.abs(sinc(Math.PI * n * D)) * Math.abs(sinc(Math.PI * n * tr / T)); const E = 1.316e-14 * f * f * A * In / R; mx = Math.max(mx, 20 * Math.log10(E * 1e6 + 1e-30)); } bins.push({ f: Math.sqrt(fa * fb), db: mx, lim: FCC(Math.sqrt(fa * fb)) }); }+    const worst = bins.reduce((w, b) => (b.lim - b.db) < (w.lim - w.db) ? b : w, bins[0]);+    // the same board with the ideal loop, for the gain a tighter layout buys+    const gain = ideal && main.mm2 > 0 ? 20 * Math.log10(main.mm2 / ideal.mm2) : 0;+    spec = { bins, worst, margin: worst.lim - worst.db, tr, I, gain };+  }+  // ---- drawing: the loops, the reference overlay -----------------------------------------------------+  const z0 = () => zOf(k0) + 0.08;+  const group = new BAB.TransformNode('fields-emc', scene); group.parent = root; group.setEnabled(false);+  const meshes = [];+  function patch(pts, rgb, alpha, name) {+    const m = new BAB.Mesh(name, scene); const vd = new BAB.VertexData();+    vd.positions = pts.flatMap(p => [bx(p.x), by(p.y), 0]); vd.indices = pts.length === 4 ? [0, 1, 2, 0, 2, 3] : [0, 1, 2]; vd.applyToMesh(m, false);+    const mt = new BAB.StandardMaterial(name + '-m', scene); mt.emissiveColor = new BAB.Color3(...rgb); mt.disableLighting = true; mt.alpha = alpha; mt.backFaceCulling = false;+    m.material = mt; m.parent = group; m.renderingGroupId = 2; m.isPickable = false; meshes.push({ m, mt, base: alpha }); return m;+  }+  function outline(pts, rgb, width, name, dash) {+    try {+      const flat = [...pts, pts[0]].flatMap(p => [bx(p.x), by(p.y), 0]);+      const m = BAB.CreateGreasedLine(name, { points: flat }, { color: new BAB.Color3(...rgb), width, useDash: !!dash, dashCount: 24, dashRatio: 0.55, sizeAttenuation: true }, scene);+      m.parent = group; m.renderingGroupId = 2; m.isPickable = false; return m;+    } catch (e) { return null; }+  }+  let mainPatch = null, mainLine = null;+  for (const l of loops) { const isMain = l === main; const p = patch(l.pts, isMain ? [1, 0.25, 0.2] : [1, 0.55, 0.3], isMain ? 0.5 : 0.1, 'emc-loop-' + l.ref); const o = outline(l.pts, isMain ? [1, 0.45, 0.3] : [1, 0.7, 0.45], isMain ? 0.24 : 0.08, 'emc-loop-line-' + l.ref, !isMain); if (isMain) { mainPatch = p; mainLine = o; } }+  if (ideal) { patch(ideal.pts, [0.3, 1, 0.55], 0.25, 'emc-ideal'); outline(ideal.pts, [0.4, 1, 0.6], 0.14, 'emc-ideal-line', true); }+  const rt = new BAB.RawTexture(rtex, W, H, BAB.Constants.TEXTUREFORMAT_RGBA, scene, false, false, BAB.Texture.NEAREST_SAMPLINGMODE, BAB.Constants.TEXTURETYPE_UNSIGNED_BYTE);+  const refPlane = BAB.MeshBuilder.CreatePlane('emc-ref', { width: W * d, height: H * d, sideOrientation: BAB.Mesh.DOUBLESIDE }, scene);+  { const uvs = refPlane.getVerticesData(BAB.VertexBuffer.UVKind); for (let i = 1; i < uvs.length; i += 2) uvs[i] = 1 - uvs[i]; refPlane.setVerticesData(BAB.VertexBuffer.UVKind, uvs); }+  refPlane.position.x = bx(meta.x0 + W * d / 2); refPlane.position.y = by(meta.y0 + H * d / 2);+  const rm = new BAB.StandardMaterial('emc-ref-m', scene); rm.diffuseTexture = rt; rt.hasAlpha = true; rm.useAlphaFromDiffuseTexture = true; rm.emissiveColor = new BAB.Color3(1, 1, 1); rm.disableLighting = true; rm.backFaceCulling = false;+  refPlane.material = rm; refPlane.parent = group; refPlane.renderingGroupId = 1; refPlane.isPickable = false;+  // tags: the loop's area where it is+  const host = document.getElementById('labels'), tags = [];+  const tag = (html, p) => { const el = document.createElement('div'); el.className = 'lbl emc'; el.innerHTML = html; el.hidden = true; host.appendChild(el); tags.push({ el, p }); };+  const cen = (pts) => ({ x: pts.reduce((s, p) => s + p.x, 0) / pts.length, y: pts.reduce((s, p) => s + p.y, 0) / pts.length });+  if (main) tag(`<b>hot loop</b> ${main.ref}→${model.swRef} <span class="v">${main.mm2.toFixed(1)} mm²</span>`, cen(main.pts));+  if (ideal) tag(`<b>best possible</b> <span class="g">${ideal.mm2.toFixed(1)} mm²</span>`, { x: cen(ideal.pts).x, y: cen(ideal.pts).y + 1.2 });+  // ---- the panel --------------------------------------------------------------------------------------+  const panel = document.createElement('div'); panel.className = 'cyclemeter emcpanel'; panel.hidden = true; document.querySelector('.stage').appendChild(panel);+  if (why.length && !main) panel.innerHTML = `<div class="ch">EMC screen</div><div class="body"><div class="row">Not screened: ${why.join('; ')}.</div></div>`;+  else {+    panel.innerHTML = `<div class="ch" title="Click to fold">EMC screen</div><div class="body">+      <div class="row"><span class="dot in"></span>Hot loop (${main.ref}, ${(main.C * 1e9 >= 1000 ? (main.C * 1e6).toFixed(1) + ' µF' : (main.C * 1e9).toFixed(0) + ' nF')})<i>${main.mm2.toFixed(1)} mm²</i></div>+      <div class="row"><span class="dot" style="background:#5cf08f"></span>Best this switch allows<i>${ideal ? ideal.mm2.toFixed(1) : '?'} mm²</i></div>+      ${spec ? `<div class="row"><span class="dot pk"></span>Worst margin to FCC B<i style="color:${spec.margin < 0 ? '#ff6b5e' : spec.margin < 6 ? '#ffc04d' : '#5cf08f'}">${spec.margin >= 0 ? '+' : ''}${spec.margin.toFixed(1)} dB at ${(spec.worst.f / 1e6).toFixed(0)} MHz</i></div><canvas id="emcChart" width="460" height="190"></canvas><div class="row" style="color:var(--text-2)">A ${ideal ? 'tighter loop' : 'smaller loop'} would gain about ${spec.gain.toFixed(0)} dB</div>` : ''}+      ${refRows.length ? `<div class="row" style="color:var(--text-2);margin-top:6px">Copper with no ground under it</div>` + refRows.slice(0, 4).map(r => `<div class="row"><span class="dot" style="background:${r.pct > 20 ? '#ff6b5e' : '#5cf08f'}"></span>${r.name}${r.switching ? ' ~' : ''}<i>${r.pct.toFixed(0)} % · ${r.mm2.toFixed(1)} mm²</i></div>`).join('') : ''}+    </div>`;+    panel.addEventListener('click', (e) => { if (e.target.closest('.ch')) panel.classList.toggle('folded'); });+  }+  function chart() {+    const cv = panel.querySelector('#emcChart'); if (!cv || !spec) return; const g = cv.getContext('2d'), Wc = cv.width, Hc = cv.height, hi = 70, lo = Math.min(-20, Math.floor(Math.min(...spec.bins.map(b => b.db)) / 20) * 20);+    g.clearRect(0, 0, Wc, Hc); g.fillStyle = 'rgba(13,17,23,.6)'; g.fillRect(0, 0, Wc, Hc);+    const X = (f) => 34 + (Math.log(f / 30e6) / Math.log(1e9 / 30e6)) * (Wc - 42), Y = (db) => Hc - 22 - (db - lo) / (hi - lo) * (Hc - 34);+    g.strokeStyle = 'rgba(255,255,255,.08)'; g.lineWidth = 1; const ticks = []; for (let db = lo; db <= hi; db += 20) ticks.push(db); for (const db of ticks) { g.beginPath(); g.moveTo(34, Y(db)); g.lineTo(Wc - 8, Y(db)); g.stroke(); }+    g.fillStyle = '#8b949e'; g.font = '17px JetBrains Mono, monospace'; for (const db of ticks) g.fillText(db, 2, Y(db) + 5); for (const f of [30e6, 100e6, 300e6, 1e9]) g.fillText(f >= 1e9 ? '1G' : (f / 1e6) + 'M', X(f) - 12, Hc - 4);+    g.strokeStyle = '#ffc04d'; g.lineWidth = 2.5; g.beginPath(); spec.bins.forEach((b, i) => { const x = X(b.f), y = Y(b.lim); i ? g.lineTo(x, y) : g.moveTo(x, y); }); g.stroke();+    g.fillStyle = 'rgba(255,90,200,.8)'; for (const b of spec.bins) { const x = X(Math.max(30e6, b.f)), y = Y(Math.max(lo, b.db)); g.fillRect(x - 2.5, y, 5, Y(lo) - y); }+    g.fillStyle = '#ffc04d'; g.fillText('FCC B, 3 m', Wc - 120, 18); g.fillStyle = '#ff7ae0'; g.fillText('hot loop, dBµV/m', 40, 18);+  }+  // ---- the frame and the view -------------------------------------------------------------------------+  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 = z0();+    if (!live) { for (const t of tags) t.el.hidden = true; return; }+    knob.t += dt * (+state.view.speed || 1);+    // the loop pulses with the switching: on for the duty, off for the rest, slowed to a beat the eye follows+    const ph = (knob.t / 1.6) % 1, on = model && ph < model.D;+    for (const s of meshes) s.mt.alpha = knob.on * s.base * (s.m === mainPatch ? (on ? 1.6 : 0.6) : 1);+    if (mainLine && mainLine.greasedLineMaterial) mainLine.greasedLineMaterial.color = on ? new BAB.Color3(1, 0.55, 0.35) : new BAB.Color3(0.6, 0.25, 0.2);+    rm.alpha = knob.on * 0.9;+    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.p.x), by(t.p.y), 1.2), 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 === 'emc' ? 1 : 0; panel.hidden = view.quantity !== 'emc';+    if (view.quantity !== 'emc') return;+    chart();+    for (const Lname of LAYERS) { const m = ctx.layerMeshes[Lname]; if (m && m.material) m.material.alpha = Lname === 'F.Cu' ? 0.45 : 0; }+    const leg = document.getElementById('legend'); if (leg) leg.hidden = true;+  }+  function describe() {+    return ['<b>EMC screen</b>: where this board could radiate',+      '<b>Red patch</b> = the hot loop: input capacitor → ' + (model ? model.swRef : 'switch') + ' → ground → back; its area is what radiates, pulsing with the switching',+      '<b>Green dashed</b> = the smallest loop this switch allows (a capacitor straight across its pins)',+      '<b>Green / red copper</b> = power and switching copper with ground under it / with no ground under it (the return current detours)',+      '<b>The chart</b> = the hot loop\'s radiated field at 3 m against the FCC class B limit',+      'A screen, not a chamber: differential mode only (Ott), no cables; the edge time is ' + (spec ? (spec.tr * 1e9).toFixed(0) : '8') + ' ns'];+  }+  function narration() {+    if (!main) return 'The EMC screen needs a switching converter it can model: ' + why.join('; ') + '.';+    const r0 = refRows.find(r => r.switching) || refRows[0];+    return `This is the EMC screen: where this board could radiate radio noise. The red patch is the hot loop: every switching cycle, a sharp pulse of current runs from the input capacitor ${main.ref} into ${model.swRef} and back through ground. The area of that loop acts like an antenna. Here it is about ${main.mm2.toFixed(0)} square millimetres. The green dashed outline is the smallest loop this switch allows, about ${ideal ? ideal.mm2.toFixed(1) : 'a few'} square millimetres, with a capacitor placed straight across its pins.${spec ? ` The chart estimates what the loop radiates at three metres, against the FCC class B limit. The closest it comes is ${Math.abs(spec.margin).toFixed(0)} decibels ${spec.margin >= 0 ? 'under' : 'over'} the limit, near ${(spec.worst.f / 1e6).toFixed(0)} megahertz, and a tighter loop would gain about ${spec.gain.toFixed(0)} decibels.` : ''}${r0 ? ` The copper colours show the return path: green has solid ground underneath, red does not. ${r0.name} has ${r0.pct.toFixed(0)} percent of its copper with no ground beneath it.` : ''} This is a screen, not a test chamber: it leaves out cables, which often dominate.`;+  }+  console.log(main ? `emc: hot loop ${main.ref}→${model.swRef} ${main.mm2.toFixed(1)} mm² (best ${ideal ? ideal.mm2.toFixed(1) : '?'} mm²), loops ${loops.map(l => l.ref + ' ' + l.mm2.toFixed(1)).join(', ')}; ${spec ? `worst margin ${spec.margin.toFixed(1)} dB at ${(spec.worst.f / 1e6).toFixed(0)} MHz, tr ${(spec.tr * 1e9).toFixed(0)} ns, tighter loop +${spec.gain.toFixed(0)} dB; ` : ''}reference: ${refRows.map(r => r.name + ' ' + r.pct.toFixed(0) + '%').join(', ')}` : 'emc: not screened: ' + why.join('; '));+  return { apply, describe, narration, stats: () => ({ loops, ideal, spec, refRows }) };+}
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.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} .legend .ramp{height:10px;border-radius:5px;margin:6px 0;background:linear-gradient(90deg,#000004,#2a0a4a,#621a5f,#97305f,#c94d3f,#ee7a1c,#f9ad10,#f7de4d,#fcffa4)}@@ -110,7 +111,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></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></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>