← Commit history
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-fields"-version = "0.7.3"+version = "0.7.4" dependencies = [  "clap",  "png",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-fields"-version = "0.7.3"+version = "0.7.4" 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
@@ -97,6 +97,14 @@ The board as capacitors: charge on facing copper, the field between, and the cap  [Download the charge video](docs/videos/charge.mp4) +### Magnetic++The field every current makes: field lines looping around the copper, and a probe sheet that shows the strength at a height above the board.++<video controls preload="metadata" width="1080" poster="docs/videos/magnetic-poster.jpg" src="docs/videos/magnetic.mp4"></video>++[Download the magnetic video](docs/videos/magnetic.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+5−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 [--explode 0..1] [--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 [--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@@ -113,6 +113,10 @@ The model is the ideal continuous-conduction buck solved exactly: D = Vout/Vin,  `ui view --quantity charge` (the Charge button pulls the layers apart): wherever copper at different voltages faces copper across the board material, it is a capacitor. Each face holds σ = ε0·εr·ΔV/h, red on the higher voltage, blue on the lower, brighter for more charge; particles cross the gap from the + face to the − face along the field; a switching node's copper charges and empties with its tide every cycle (the stray capacitance switching noise couples through). A panel compares the copper's own capacitance between net pairs (pF, pC) with the capacitor parts (µF, µC, the energy ½CV²); the readout gives the charge per mm² and the field under the cursor. Parallel plates only: fringing and the air above are not counted. +## Magnetic: the field of every current (0.7.4)++`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.+ ## 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.4.mdadded+4
@@ -0,0 +1,4 @@+# adom-fields 0.7.4++- **Magnetic.** `ui view --quantity magnetic`: the Biot–Savart field of the solved currents: glowing field lines looping around the copper (dashes running with the field), and a probe sheet at a chosen height (`--probe`, the slider beside the view buttons) glowing where the field is strong, with a ring at every tenfold step; µT, the share of Earth's field and A/m under the cursor. DC; the switching field comes with the EMC views.+- The tour adds Charge and Magnetic. A narrated video of the Magnetic view on the page.
docs/videos/magnetic-poster.jpgadded
⋯ 1 unchanged line ⋯
docs/videos/magnetic.mp4added
⋯ 1 unchanged line ⋯
package.json+6−2
@@ -1,7 +1,7 @@ {   "slug": "adom-fields",   "type": "app",-  "version": "0.7.3",+  "version": "0.7.4",   "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.",@@ -53,7 +53,11 @@     "capacitance visualization",     "parasitic capacitance",     "charge on copper",-    "electric field between layers"+    "electric field between layers",+    "magnetic field of a pcb",+    "near-field probe",+    "biot-savart",+    "magnetic field lines"   ],   "sample_prompts": [     {
page.json+12−10
@@ -1,7 +1,7 @@ {   "slug": "adom-fields",   "type": "app",-  "version": "0.7.3",+  "version": "0.7.4",   "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",@@ -53,7 +51,11 @@     "capacitance visualization",     "parasitic capacitance",     "charge on copper",-    "electric field between layers"+    "electric field between layers",+    "magnetic field of a pcb",+    "near-field probe",+    "biot-savart",+    "magnetic field lines"   ],   "sample_prompts": [     {@@ -94,4 +96,4 @@     "name": "John Lauer",     "email": "[email protected]"   }-}\ No newline at end of file+}
skills/adom-fields/SKILL.md+5−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 [--explode 0..1] [--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 [--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@@ -113,6 +113,10 @@ The model is the ideal continuous-conduction buck solved exactly: D = Vout/Vin,  `ui view --quantity charge` (the Charge button pulls the layers apart): wherever copper at different voltages faces copper across the board material, it is a capacitor. Each face holds σ = ε0·εr·ΔV/h, red on the higher voltage, blue on the lower, brighter for more charge; particles cross the gap from the + face to the − face along the field; a switching node's copper charges and empties with its tide every cycle (the stray capacitance switching noise couples through). A panel compares the copper's own capacitance between net pairs (pF, pC) with the capacitor parts (µF, µC, the energy ½CV²); the readout gives the charge per mm² and the field under the cursor. Parallel plates only: fringing and the air above are not counted. +## Magnetic: the field of every current (0.7.4)++`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.+ ## 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/main.rs+2−1
@@ -44,7 +44,8 @@ enum UiCmd {     #[arg(long)] explode: Option<f64>, /// animation speed of the flow and river views, 0.1 to 4 (1 is the default)     #[arg(long)] speed: Option<f64>, /// how see-through the ghosted parts are, 0.05 to 1 (the viewer's own slider otherwise; 0.33 by default)     #[arg(long)] ghost: Option<f64>, /// the silkscreen over the fields: on or off-    #[arg(long)] silk: Option<String> },+    #[arg(long)] silk: Option<String>, /// the magnetic view's probe sheet height above the top copper, mm (0.5 to 15)+    #[arg(long)] probe: Option<f64> },     Toast { text: String, #[arg(long, default_value = "info")] kind: String },     /// Explain the view out loud in the page (adom-tts, with a caption): the current view, or --quantity; --stop silences it     Explain { #[arg(long)] quantity: Option<String>, #[arg(long)] stop: bool },
src/serve.rs+6−3
@@ -58,6 +58,7 @@ fn handle(mut req: tiny_http::Request, app: Arc<Mutex<App>>) {         ("GET", "/") | ("GET", "/index.html") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("index.html", include_str!("ui/index.html"))).with_header(tiny_http::Header::from_bytes("Content-Type", "text/html; charset=utf-8").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); }         ("GET", "/app.js") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("app.js", include_str!("ui/app.js"))).with_header(tiny_http::Header::from_bytes("Content-Type", "application/javascript").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); }         ("GET", "/flow.js") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("flow.js", include_str!("ui/flow.js"))).with_header(tiny_http::Header::from_bytes("Content-Type", "application/javascript").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); }+        ("GET", "/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") => {             // the signed current, the voltage and the owning net of every cell, for the flow and river views@@ -65,7 +66,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), 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), 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();@@ -99,7 +100,7 @@ fn handle(mut req: tiny_http::Request, app: Arc<Mutex<App>>) {                 a.view["quantity"] = json!(match issue["kind"].as_str() { Some("current-neck") => "current", _ => "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"] { if !v[k].is_null() { a.view[k] = v[k].clone(); } }+                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(); } }                 if v["net"].as_str() == Some("") { a.view["net"] = Value::Null; }                 if path == "/ui/select" { a.view["issue"] = Value::Null; if let Some(c) = v["chip"].as_str() { if let Some(ch) = a.fields["chips"].as_array().and_then(|x| x.iter().find(|x| x["reference"].as_str() == Some(c))).cloned() { a.view["focus"] = json!([ch["x"], ch["y"]]); a.view["camera"] = json!("zoom"); a.view["quantity"] = json!("heat"); } } }             }@@ -177,7 +178,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 } => { 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"].contains(&q.as_str()) { crate::err(&format!("--quantity {q}: use current, heat, temperature, signals, flow, river, energy, light, cycle or charge"), &[]); } } 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")})); 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"].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::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"]), &[]); }@@ -215,6 +216,8 @@ pub fn tour(port: u16, hold_ms: u64) {         toast("Energy: it travels in the fields between the layers, while the electrons crawl"); step("/ui/view", json!({"quantity": "energy", "explode": 0.6, "camera": "iso"}), settle); steps += 1;         toast("Light: how fast a change crosses the board, and when it reaches each part"); step("/ui/view", json!({"quantity": "light", "explode": 0, "camera": "top"}), settle); steps += 1;         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;         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+13−4
@@ -176,6 +176,8 @@   function ghostAlpha() { const v = +state.view.ghost; if (v > 0) return Math.min(1, v); let s = NaN; try { s = parseFloat(localStorage.getItem('fields.ghostAlpha')); } catch (e) { void e; } return s > 0 ? s : 0.33; }   function syncGhostCtl() { const c = $('#ghostCtl'); if (!c) return; c.hidden = partsMode() !== 'ghost'; const a = ghostAlpha(); $('#ghostAlpha').value = a; $('#ghostVal').textContent = Math.round(a * 100) + '%'; }   document.addEventListener('input', (e) => { if (e.target.id !== 'ghostAlpha') return; const a = +e.target.value; try { localStorage.setItem('fields.ghostAlpha', String(a)); } catch (err) { void err; } state.view.ghost = null; applyParts(); });+  document.addEventListener('change', (e) => { if (e.target.id === 'probeH') setView({ probe: +e.target.value }); });+  document.addEventListener('input', (e) => { if (e.target.id === 'probeH') { const l = $('#probeVal'); if (l) l.textContent = (+e.target.value).toFixed(1) + ' mm'; } });   document.addEventListener('change', (e) => { if (e.target.id === 'ghostAlpha') console.log('parts: ghost opacity set to ' + Math.round(e.target.value * 100) + '% by the viewer'); });   // ---- narration: each view explained out loud ------------------------------------------------------   // The text is the page's own template with this board's numbers (no AI writes it at view time); the@@ -186,6 +188,7 @@   function narrationText(q) {     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 (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.`;@@ -324,7 +327,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') return `copper-${ls}.png`;+    if (q === 'river' || q === 'energy' || q === 'light' || q === 'cycle' || q === 'charge' || q === 'magnetic') 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';@@ -339,7 +342,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'].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'].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';@@ -352,6 +355,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.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); } }+    }     if (window.fieldsCycle) {       window.fieldsCycle.apply(state.view);       if (state.view.quantity === 'cycle') { const bd = $('#flowBadge'), kb = $('#btnKey'); let on = false; try { on = localStorage.getItem('fields.keyOn') === '1'; } catch (e) { void e; } const [h, ...rows] = window.fieldsCycle.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); } }@@ -375,7 +382,8 @@     const L = pick.pickedMesh.name.slice(2); const p = pick.pickedPoint;     const x = p.x + board.x0 + board.widthMm / 2, y = board.y0 + board.heightMm / 2 - p.y;     const raw = state.raw[rawName(L)]; let val = '';-    if (window.fieldsFlow && ['flow', 'river', 'energy', 'light', 'charge', 'cycle'].includes(state.view.quantity)) val = window.fieldsFlow.readout(L, x, y);+    if (window.fieldsMagnetic && state.view.quantity === 'magnetic') val = window.fieldsMagnetic.readout(L, x, y);+    else if (window.fieldsFlow && ['flow', 'river', 'energy', 'light', 'charge', 'cycle'].includes(state.view.quantity)) val = window.fieldsFlow.readout(L, x, y);     else if (raw && raw.d) { const c = Math.min(raw.w - 1, Math.max(0, Math.floor((x - board.x0) / board.cellMm))), r = Math.min(raw.h - 1, Math.max(0, Math.floor((y - board.y0) / board.cellMm))); const i = (r * raw.w + c) * 4; const v = raw.d[i] / 255; const sc = state.fields.scales; const hi = state.view.quantity === 'current' ? sc.jHi : state.view.quantity === 'heat' ? sc.qHi : sc.tHi; const num = v * hi; val = state.view.quantity === 'current' ? num.toFixed(1) + ' A/mm²' : state.view.quantity === 'heat' ? num.toFixed(4) + ' W/mm' : '+' + num.toFixed(1) + ' °C'; }     ro.innerHTML = `<span class="k">${L}</span> ${val}<br><span class="k">x</span> ${x.toFixed(2)} <span class="k">y</span> ${y.toFixed(2)} mm`;   });@@ -461,7 +469,8 @@     import('./flow.js').then(m => m.attach({ BAB, scene, V, camera, state, Z, LAYERS, frameDt, root, layerMeshes: state.layerMeshes, clock }))       .then(api => { window.fieldsFlow = api; if (!api) flowNote = 'This solve has no flow data: run adom-fields analyze again with 0.7 or later.'; applyTextures();         // the energy cycle of a switching converter, on the flow view's shaders-        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))); })+        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))); })       .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/flow.js+1−1
@@ -969,6 +969,6 @@ void main(void) {   console.log(`flow: ${nets.filter(n => n.loaded).length} loaded nets, ${viaGaps.size} barrels carrying current, ${terrains.length} terrain layer(s) at stride ${stride}, ${P} particles (${tier}); river 1 V = ${kR.toFixed(3)} mm, slopes x${exaggeration.toFixed(0)}`);   console.log(`light: er ${ER}, ${arrivals.length} part arrivals, the slowest ${tMax.toFixed(0)} ps; ` + arrivals.slice(0, 6).map(a => `${a.ref}/${a.net} ${a.ps.toFixed(0)} ps`).join(', '));   console.log(`energy: ${gaps.length} gap(s) between copper layers ${real.map(k => LAYERS[k]).join(', ')}; S up to ${sHi.toFixed(2)} W/mm² (99.5th pct); electrons drift ${DRIFT.toFixed(4)} mm/s per A/mm²`);-  const api = { apply, readout, describe, narration, knob, get particles() { return P; }, set particles(n) { P = Math.max(500, Math.min(COUNTS.high, n | 0)); }, stats: () => ({ energyParticles: EP, gaps: gaps.length, sHi, particles: P, tier, jLo, jHi, kR, kD, exaggeration, terrains: terrains.length, barrels: viaGaps.size, explode: knob.explode, river: knob.river }) };+  const api = { data: { lay, real, W, H, d, meta, cuMm, nets, zOf, cellOf, bx, by, ER }, apply, readout, describe, narration, knob, get particles() { return P; }, set particles(n) { P = Math.max(500, Math.min(COUNTS.high, n | 0)); }, stats: () => ({ energyParticles: EP, gaps: gaps.length, sHi, particles: P, tier, jLo, jHi, kR, kD, exaggeration, terrains: terrains.length, barrels: viaGaps.size, explode: knob.explode, river: knob.river }) };   return api; }
src/ui/index.html+3−2
@@ -57,6 +57,7 @@ body{margin:0;background:var(--bg);color:var(--text-body);font-family:"Satoshi", .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)} .legend.amps .ramp{background:linear-gradient(90deg,#05387f,#0073e6,#26b8ff,#9ee6ff,#ffffff)}+.legend.magnetic .ramp{background:linear-gradient(90deg,#0a0828,#3c1e8c,#286ee6,#46d2ff,#ebfaff)} .legend.charge .ramp{background:linear-gradient(90deg,#3c8cff,#9cc4ff,#2a2f3a,#ffa08c,#ff4636)} .legend.light .ramp{background:linear-gradient(90deg,#1a33bf,#0aa0c8,#00bfcc,#7ee6b0,#d9ffbf)} .lbl.light{border-color:rgba(126,230,176,.45)}.lbl.light .ps{color:#d9ffbf;font-weight:600}@@ -109,7 +110,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></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></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>@@ -119,7 +120,7 @@ body{margin:0;background:var(--bg);color:var(--text-body);font-family:"Satoshi",     <main class="stage">       <div id="host" class="host"></div>       <div id="labels" class="labels"></div>-      <div class="views"><span class="chip" data-cam="top" data-tip="Straight down at the top copper">Top</span><span class="chip" data-cam="iso" data-tip="Three-quarter view">Iso</span><span class="chip" data-cam="bottom" data-tip="The bottom copper, from below">Bottom</span><span class="chip" data-cam="fit" data-tip="Fit the board">Fit</span><label class="chip ghostctl" id="ghostCtl" hidden data-tip="How see-through the ghosted parts are"><svg viewBox="0 0 24 24"><path d="M12 3l8 4.5v9L12 21l-8-4.5v-9z"/></svg><input type="range" id="ghostAlpha" min="0.05" max="0.8" step="0.01" aria-label="Part opacity"><span id="ghostVal"></span></label><span class="chip" id="btnKey" hidden data-tip="What the particles, the heights and the waterfalls mean">Key</span></div>+      <div class="views"><span class="chip" data-cam="top" data-tip="Straight down at the top copper">Top</span><span class="chip" data-cam="iso" data-tip="Three-quarter view">Iso</span><span class="chip" data-cam="bottom" data-tip="The bottom copper, from below">Bottom</span><span class="chip" data-cam="fit" data-tip="Fit the board">Fit</span><label class="chip ghostctl" id="ghostCtl" hidden data-tip="How see-through the ghosted parts are"><svg viewBox="0 0 24 24"><path d="M12 3l8 4.5v9L12 21l-8-4.5v-9z"/></svg><input type="range" id="ghostAlpha" min="0.05" max="0.8" step="0.01" aria-label="Part opacity"><span id="ghostVal"></span></label><label class="chip ghostctl" id="probeCtl" hidden data-tip="Height of the magnetic probe sheet above the top copper"><svg viewBox="0 0 24 24"><path d="M12 19V5M7 10l5-5 5 5"/></svg><input type="range" id="probeH" min="0.5" max="15" step="0.5" aria-label="Probe height"><span id="probeVal"></span></label><span class="chip" id="btnKey" hidden data-tip="What the particles, the heights and the waterfalls mean">Key</span></div>       <div class="flowbadge" id="flowBadge" hidden></div>       <div class="caption" id="caption" hidden></div>       <div class="readout" id="readout"><span class="k">hover the board</span></div>
src/ui/magnetic.jsadded+190
@@ -0,0 +1,190 @@+// Adom Fields: the magnetic field of the board's currents.+// Every current makes a magnetic field that wraps around it (Biot-Savart: dB = mu0/4pi I dl x r / r^3).+// The solved DC current on every cell of copper, and the current down every via, are summed onto a+// coarse grid of current elements; the field is then evaluated anywhere above or around the board:+// field lines traced in 3D for blue particles to ride, and a probe plane at a chosen height coloured by+// |B| the way a near-field probe would see it. DC: the switching field comes with the switching currents.+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, cuMm, nets, zOf, bx, by } = flow.data;+  const board = state.fields.board;+  // ---- the current elements: I·dl (A·mm) summed onto a grid of about half a millimetre --------------+  const G = Math.max(1, Math.round(0.5 / d)), gw = Math.ceil(W / G), gh = Math.ceil(H / G);+  const src = [];   // {x, y, z, ix, iy, iz} in board mm (KiCad x right, y down), z up+  for (const k of real) {+    const L = lay[k], ax = new Float64Array(gw * gh), ay = new Float64Array(gw * gh);+    for (let i = 0; i < W * H; i++) { if (L.jx[i] === 0 && L.jy[i] === 0) continue; const c = Math.floor((i % W) / G), r = Math.floor(Math.floor(i / W) / G), g = r * gw + c; const a = cuMm[k] * d * d; ax[g] += L.jx[i] * a; ay[g] += L.jy[i] * a; }+    const z = zOf(k);+    for (let g = 0; g < gw * gh; g++) { if (Math.hypot(ax[g], ay[g]) < 1e-6) continue; src.push({ x: meta.x0 + ((g % gw) + 0.5) * G * d, y: meta.y0 + (Math.floor(g / gw) + 0.5) * G * d, z, ix: ax[g], iy: ay[g], iz: 0 }); }+  }+  // the vias: the current down each barrel between the layers it joins (z up, so downward current is -z)+  for (const n of nets) for (const v of (n.vias || [])) { const za = zOf(LAYERS.indexOf(v.from)), zb = zOf(LAYERS.indexOf(v.to)); src.push({ x: v.x, y: v.y, z: (za + zb) / 2, ix: 0, iy: 0, iz: v.amps * (zb - za) }); }+  const S = src.length, SX = new Float64Array(S), SY = new Float64Array(S), SZ = new Float64Array(S), IX = new Float64Array(S), IY = new Float64Array(S), IZ = new Float64Array(S);+  // board frame for the maths: x right, y UP (flip KiCad's y), z up; then B comes out in the same frame+  src.forEach((s, i) => { SX[i] = s.x; SY[i] = -s.y; SZ[i] = s.z; IX[i] = s.ix; IY[i] = -s.iy; IZ[i] = s.iz; });+  const SOFT = (G * d * 0.5) ** 2;   // a soft core the size of a grid cell, so the field stays finite on the copper+  // B in µT at a point (board mm, y up): 1e-7 T·m/A × 1000 (mm) → µT ×100 per A/mm+  function B(x, y, z) {+    let bx_ = 0, by_ = 0, bz_ = 0;+    for (let i = 0; i < S; i++) {+      const rx = x - SX[i], ry = y - SY[i], rz = z - SZ[i], r2 = rx * rx + ry * ry + rz * rz + SOFT, inv = 1 / (r2 * Math.sqrt(r2));+      bx_ += (IY[i] * rz - IZ[i] * ry) * inv; by_ += (IZ[i] * rx - IX[i] * rz) * inv; bz_ += (IX[i] * ry - IY[i] * rx) * inv;+    }+    return [bx_ * 100, by_ * 100, bz_ * 100];+  }+  const EARTH = 50;   // µT+  // ---- the probe plane: |B| at a height, the board plus a margin, coloured by strength ----------------+  const margin = 4, PW = board.widthMm + 2 * margin, PH = board.heightMm + 2 * margin, res = Math.max(0.25, Math.max(PW, PH) / 200);+  const pw = Math.ceil(PW / res), ph = Math.ceil(PH / res);+  const ptex = new Uint8Array(pw * ph * 4), pval = new Float32Array(pw * ph);+  const tex = new BAB.RawTexture(ptex, pw, ph, BAB.Constants.TEXTUREFORMAT_RGBA, scene, false, false, BAB.Texture.BILINEAR_SAMPLINGMODE, BAB.Constants.TEXTURETYPE_UNSIGNED_BYTE);+  const plane = BAB.MeshBuilder.CreatePlane('fields-probe', { width: PW, height: PH, sideOrientation: BAB.Mesh.DOUBLESIDE }, scene);+  { const uvs = plane.getVerticesData(BAB.VertexBuffer.UVKind); for (let i = 1; i < uvs.length; i += 2) uvs[i] = 1 - uvs[i]; plane.setVerticesData(BAB.VertexBuffer.UVKind, uvs); }+  const pm = new BAB.StandardMaterial('fields-probe-m', scene); pm.diffuseTexture = tex; tex.hasAlpha = true; pm.useAlphaFromDiffuseTexture = true; pm.emissiveColor = new BAB.Color3(1, 1, 1); pm.disableLighting = true; pm.backFaceCulling = false;+  plane.material = pm; plane.parent = root; plane.isPickable = true; plane.renderingGroupId = 1; plane.setEnabled(false);+  let probeAt = null, bMax = 1, bMin = 0.01, computing = false;+  const ramp = [[10, 8, 40], [60, 30, 140], [40, 110, 230], [70, 210, 255], [235, 250, 255]];+  async function paintProbe(h) {+    if (computing) return; computing = true;+    const zTop = zOf(real[0]), z = zTop + h; let mx = 0;+    for (let r = 0; r < ph; r++) {+      for (let c = 0; c < pw; c++) { const x = meta.x0 - margin + (c + 0.5) * res, y = meta.y0 - margin + (r + 0.5) * res; const b = B(x, -y, z); const m = Math.hypot(b[0], b[1], b[2]); pval[r * pw + c] = m; if (m > mx) mx = m; }+      if (r % 20 === 19) await new Promise(res2 => setTimeout(res2, 0));   // keep the page breathing+    }+    bMax = mx || 1; bMin = bMax / 300;+    for (let i = 0; i < pw * ph; i++) {+      const t = pval[i] <= bMin ? 0 : Math.min(1, Math.log(pval[i] / bMin) / Math.log(bMax / bMin)), s = t * (ramp.length - 1), k = Math.min(ramp.length - 2, Math.floor(s)), f = s - k;+      for (let ch = 0; ch < 3; ch++) ptex[i * 4 + ch] = Math.round(ramp[k][ch] + (ramp[k + 1][ch] - ramp[k][ch]) * f);+      // thin contour lines every power of ten of µT, so the fall-off with height reads at a glance+      const lg = Math.log10(Math.max(1e-6, pval[i])), contour = t > 0.15 && Math.abs(lg - Math.round(lg)) < 0.006 ? 1 : 0;+      ptex[i * 4 + 3] = Math.round(Math.min(255, (t < 0.3 ? 0 : 230 * ((t - 0.3) / 0.7) ** 1.3) + 110 * contour));+      if (contour) { ptex[i * 4] = 255; ptex[i * 4 + 1] = 255; ptex[i * 4 + 2] = 255; }+    }+    tex.update(ptex); probeAt = h; computing = false;+    legend();+  }+  // ---- field lines: traced once, followed by particles ----------------------------------------------+  // traced only when the view is first opened, a line at a time, so the page never stalls+  const lines = []; let traced = false, tracing = false, sMax = 1e-9;+  async function traceLines() {+    tracing = true;+    const order = src.map((s, i) => [Math.hypot(s.ix, s.iy, s.iz), i]).sort((a, b) => b[0] - a[0]);+    const pick = []; for (const [m, i] of order) { if (pick.length >= 48) break; if (pick.every(j => Math.hypot(SX[j] - SX[i], SY[j] - SY[i]) > 1.4)) pick.push(i); }+    for (const i of pick) for (const off of [0.35, 0.9]) {+      let p = [SX[i], SY[i], SZ[i] + off];+      const pts = [p.slice()]; const step = 0.12;+      for (let n = 0; n < 360; n++) {+        const b1 = B(p[0], p[1], p[2]), m1 = Math.hypot(...b1) || 1e-9;+        const mid = [p[0] + b1[0] / m1 * step / 2, p[1] + b1[1] / m1 * step / 2, p[2] + b1[2] / m1 * step / 2];+        const b2 = B(mid[0], mid[1], mid[2]), m2 = Math.hypot(...b2) || 1e-9;+        p = [p[0] + b2[0] / m2 * step, p[1] + b2[1] / m2 * step, p[2] + b2[2] / m2 * step];+        pts.push(p.slice());+        if (n > 20 && Math.hypot(p[0] - pts[0][0], p[1] - pts[0][1], p[2] - pts[0][2]) < step * 1.5) break;   // closed the loop+        if (Math.abs(p[2] - SZ[i]) > 6 || Math.abs(p[0] - SX[i]) > 8 || Math.abs(p[1] - SY[i]) > 8) break;+      }+      if (pts.length > 12) { const strength = Math.hypot(...B(pts[0][0], pts[0][1], pts[0][2])); lines.push({ pts, strength }); sMax = Math.max(sMax, strength); }+      await new Promise(r => setTimeout(r, 0));+    }+    makeLines();+    traced = true; tracing = false;+    console.log(`magnetic: ${lines.length} field lines traced`);+  }++  // ---- the lines themselves: GreasedLine, glowing, dashes running along the field's direction ---------+  let lineMeshes = [];+  function makeLines() {+    try {+      // three strength bands, brightest for the strongest field, so the eye finds the hot loop+      const bands = [[], [], []];+      for (const l of lines) { const t = Math.sqrt(l.strength / sMax); bands[t > 0.6 ? 2 : t > 0.3 ? 1 : 0].push(l.pts.flatMap(p => [wx(p[0]), wy(p[1]), p[2]])); }+      const cols = [new BAB.Color3(0.22, 0.35, 0.85), new BAB.Color3(0.35, 0.62, 1.0), new BAB.Color3(0.75, 0.9, 1.0)];+      bands.forEach((pts, b) => {+        if (!pts.length) return;+        const m = BAB.CreateGreasedLine('fields-mag-lines-' + b, { points: pts }, { color: cols[b], width: 0.05 + 0.03 * b, useDash: true, dashCount: 40, dashRatio: 0.55, sizeAttenuation: true }, scene);+        m.parent = root; m.isPickable = false; m.renderingGroupId = 1; m.setEnabled(false);+        try { const g = scene.effectLayers && scene.effectLayers.find(l => l.name === 'fields-flow-glow'); if (g) g.addIncludedOnlyMesh(m); } catch (e) { void e; }+        lineMeshes.push(m);+      });+      console.log(`magnetic: ${lines.length} field lines drawn in ${lineMeshes.length} bands`);+    } catch (e) { console.warn('magnetic: no GreasedLine, particles only: ' + (e && e.message || e)); }+  }+  // ---- the particles riding the lines (the flow view's shader) ------------------------------------+  const PER = 18, CAP = 96 * PER;+  const IP = new Float32Array(CAP * 4), IV = new Float32Array(CAP * 4), IC = new Float32Array(CAP * 4);+  const mesh = new BAB.Mesh('fields-magnetic-streaks', scene);+  { const vd = new BAB.VertexData(); vd.positions = [-1, -1, 0, 1, -1, 0, 1, 1, 0, -1, 1, 0]; vd.indices = [0, 1, 2, 0, 2, 3]; vd.applyToMesh(mesh, false); }+  const engine = V.getEngine();+  const vbP = new BAB.VertexBuffer(engine, IP, 'ip', true, false, 4, true), vbV = new BAB.VertexBuffer(engine, IV, 'iv', true, false, 4, true), vbC = new BAB.VertexBuffer(engine, IC, 'ic', true, false, 4, true);+  mesh.setVerticesBuffer(vbP); mesh.setVerticesBuffer(vbV); mesh.setVerticesBuffer(vbC);+  mesh.forcedInstanceCount = CAP; mesh.alwaysSelectAsActiveMesh = true; mesh.isPickable = false; mesh.parent = root; mesh.renderingGroupId = 1; mesh.setEnabled(false);+  const mat = new BAB.ShaderMaterial('fields-magnetic-streak', scene, { vertex: 'fieldsStreak', fragment: 'fieldsStreak' }, { attributes: ['position', 'ip', 'iv', 'ic'], uniforms: ['world', 'viewProjection', 'camPos', 'trail', 'width'], needAlphaBlending: true });+  mat.alphaMode = BAB.Constants.ALPHA_ADD; mat.disableDepthWrite = true; mat.backFaceCulling = false; mat.setFloat('trail', 0.3); mat.setFloat('width', 1.0);+  mesh.material = mat;+  try { const g = scene.effectLayers && scene.effectLayers.find(l => l.name === 'fields-flow-glow'); if (g) { g.addIncludedOnlyMesh(mesh); g.referenceMeshToUseItsOwnMaterial(mesh); } } catch (e) { void e; }+  const size = Math.max(0.05, Math.hypot(board.widthMm, board.heightMm) * 0.0035);+  const phase = new Float32Array(CAP); for (let i = 0; i < CAP; i++) phase[i] = (i % PER) / PER + Math.random() * 0.05;+  // world: board mm (y up in the maths) to the scene's board frame+  const wx = (x) => bx(x), wy = (y) => by(-y);+  function frame(dt) {+    for (let i = lines.length * PER; i < CAP; i++) IV[i * 4 + 3] = 0;+    for (let li = 0; li < Math.min(lines.length, 96); li++) {+      const l = lines[li], n = l.pts.length, t = Math.sqrt(l.strength / sMax);+      for (let k = 0; k < PER; k++) {+        const i = li * PER + k, o = i * 4;+        phase[i] = (phase[i] + dt * (0.04 + 0.12 * t) * (+state.view.speed || 1)) % 1;+        const u = phase[i] * (n - 1), a = Math.floor(u), f = u - a, p = l.pts[a], q = l.pts[Math.min(n - 1, a + 1)];+        const x = p[0] + (q[0] - p[0]) * f, y = p[1] + (q[1] - p[1]) * f, z = p[2] + (q[2] - p[2]) * f;+        IP[o] = wx(x); IP[o + 1] = wy(y); IP[o + 2] = z; IP[o + 3] = size;+        IV[o] = (q[0] - p[0]) * 20; IV[o + 1] = (q[1] - p[1]) * 20; IV[o + 2] = (q[2] - p[2]) * 20; IV[o + 3] = knob.on * (lineMeshes.length ? 0.1 + 0.35 * t : 0.18 + 0.7 * t);+        IC[o] = 0.35 + 0.4 * t; IC[o + 1] = 0.6 + 0.3 * t; IC[o + 2] = 1; IC[o + 3] = 1;+      }+    }+    vbP.update(IP); vbV.update(IV); vbC.update(IC);+    mat.setVector3('camPos', camera.globalPosition || camera.position);+  }+  // ---- the view ---------------------------------------------------------------------------------------+  const knob = { on: 0, to: 0 };+  const probeH = () => Math.max(0.5, Math.min(15, +state.view.probe || 2));+  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; mesh.setEnabled(live); plane.setEnabled(live && probeAt !== null);+    for (const m of lineMeshes) { m.setEnabled(live); if (live && m.greasedLineMaterial) { m.greasedLineMaterial.dashOffset = (m.greasedLineMaterial.dashOffset || 0) - dt * 0.12 * (+state.view.speed || 1); m.visibility = knob.on; } }+    if (!live) return;+    if (probeAt !== probeH() && !computing) paintProbe(probeH());+    if (!traced && !tracing && probeAt !== null) traceLines();+    plane.position.z = zOf(real[0]) + (probeAt ?? probeH()); pm.alpha = knob.on * 0.75;+    frame(dt);+  });+  function legend() {+    if (state.view.quantity !== 'magnetic') return;+    const leg = document.getElementById('legend'); if (!leg) return; leg.hidden = false; leg.classList.remove('magma'); leg.classList.add('magnetic');+    document.getElementById('legendTitle').textContent = `Magnetic field at ${probeH().toFixed(1)} mm, µT (log)`; document.getElementById('legLo').textContent = bMin.toFixed(2); document.getElementById('legHi').textContent = bMax.toFixed(bMax < 10 ? 1 : 0);+  }+  function apply(view) {+    knob.to = view.quantity === 'magnetic' ? 1 : 0;+    const ctl = document.getElementById('probeCtl'); if (ctl) { ctl.hidden = view.quantity !== 'magnetic'; const inp = document.getElementById('probeH'); if (inp && document.activeElement !== inp) inp.value = probeH(); const lab = document.getElementById('probeVal'); if (lab) lab.textContent = probeH().toFixed(1) + ' mm'; }+    if (view.quantity !== 'magnetic') { const leg = document.getElementById('legend'); if (leg) leg.classList.remove('magnetic'); return; }+    for (const Lname of LAYERS) { const m = ctx.layerMeshes[Lname]; if (m && m.material) m.material.alpha = Lname === 'F.Cu' ? 0.35 : 0; }+    legend();+  }+  function readout(Lname, x, y) {+    const h = probeH(), b = B(x, -y, zOf(real[0]) + h), m = Math.hypot(...b);+    return `${h.toFixed(1)} mm above: ${m.toFixed(m < 10 ? 2 : 1)} µT (${(m / EARTH * 100).toFixed(0)} % of Earth's field), H ${(m / 1.2566).toFixed(1)} A/m`;+  }+  function describe() {+    return ['<b>Magnetic</b>: the field every current makes',+      '<b>Blue lines</b> = magnetic field lines, looping around the currents; the dashes run the way the field points, brighter where it is stronger',+      '<b>The coloured sheet</b> = the field strength at a height above the board, like a near-field probe; the slider moves it (white rings mark each tenfold step)',+      'Earth\'s own field is about 50 µT, for scale',+      'DC: the steady currents. The switching field comes with the EMC loop views'];+  }+  function narration() {+    const h = probeH(), top = (() => { let best = 0, bi = 0; for (let i = 0; i < pval.length; i++) if (pval[i] > best) { best = pval[i]; bi = i; } return { b: best, x: meta.x0 - margin + ((bi % pw) + 0.5) * res, y: meta.y0 - margin + (Math.floor(bi / pw) + 0.5) * res }; })();+    const at = (() => { let best = null, bd = 1e9; for (const n of nets) for (const t of (n.terminals || [])) { const dd = Math.hypot(t.x - top.x, t.y - top.y); if (dd < bd) { bd = dd; best = t.ref; } } return best; })();+    return `This is the Magnetic view. Every current makes a magnetic field that wraps around it. The glowing blue lines are those field lines, looping around the copper, and their moving dashes show which way the field points. The coloured sheet floating above the board is the field strength at ${h.toFixed(0)} millimetres up, the way a near-field probe would see it. The strongest field here is about ${top.b.toFixed(0)} microtesla${at ? `, near ${at}` : ''}, which is ${top.b > EARTH ? `about ${(top.b / EARTH).toFixed(1)} times` : `about ${Math.round(top.b / EARTH * 100)} percent of`} the Earth's own magnetic field. Move the sheet higher with the slider and watch the field fade: it drops quickly with distance. This is the steady, DC field; the field from the switching currents is what radiates noise, and it comes next with the EMC views.`;+  }+  console.log(`magnetic: ${S} current elements on a ${(G * d).toFixed(2)} mm grid; the field lines trace when the view opens`);+  return { apply, describe, narration, readout, B };+}