← Commit history
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-fields"-version = "0.7.2"+version = "0.7.3" dependencies = [  "clap",  "png",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-fields"-version = "0.7.2"+version = "0.7.3" 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+76
@@ -21,6 +21,82 @@  *The temperature over the board, the FR4 spreading, still air.* +## Watch each view++Each clip is the app on the 12 V to 5 V buck, explaining the view in its own voice (the narration the app writes from the solve, voiced by adom-tts).++### Current density++How many amps flow through each square millimetre of copper, every loaded net at its spec current.++<video controls preload="metadata" width="1080" poster="docs/videos/current-poster.jpg" src="docs/videos/current.mp4"></video>++[Download the current density video](docs/videos/current.mp4)++### Heat flow++Where the watts travel from the hot parts, along the copper and down the vias.++<video controls preload="metadata" width="1080" poster="docs/videos/heat-poster.jpg" src="docs/videos/heat.mp4"></video>++[Download the heat flow video](docs/videos/heat.mp4)++### Temperature++The rise above the room over the whole board, in still or moving air.++<video controls preload="metadata" width="1080" poster="docs/videos/temperature-poster.jpg" src="docs/videos/temperature.mp4"></video>++[Download the temperature video](docs/videos/temperature.mp4)++### Flow++The solved current as moving charge: speed and glow follow the density, each via takes its share.++<video controls preload="metadata" width="1080" poster="docs/videos/flow-poster.jpg" src="docs/videos/flow.mp4"></video>++[Download the flow video](docs/videos/flow.mp4)++### River++Every net lifted to its voltage, the current running over it as water, loads as waterfalls.++<video controls preload="metadata" width="1080" poster="docs/videos/river-poster.jpg" src="docs/videos/river.mp4"></video>++[Download the river video](docs/videos/river.mp4)++### Energy++The energy travels in the fields between the copper layers, while the electrons crawl at millimetres per second.++<video controls preload="metadata" width="1080" poster="docs/videos/energy-poster.jpg" src="docs/videos/energy.mp4"></video>++[Download the energy video](docs/videos/energy.mp4)++### Light++How fast a change crosses the board as a share of the speed of light, and when a switch-on reaches each part.++<video controls preload="metadata" width="1080" poster="docs/videos/light-poster.jpg" src="docs/videos/light.mp4"></video>++[Download the light video](docs/videos/light.mp4)++### Energy cycle++One switching period: the input capacitors, the inductor's magnetic field, the output, and the heat.++<video controls preload="metadata" width="1080" poster="docs/videos/cycle-poster.jpg" src="docs/videos/cycle.mp4"></video>++[Download the energy cycle video](docs/videos/cycle.mp4)++### Charge++The board as capacitors: charge on facing copper, the field between, and the capacitor parts beside it.++<video controls preload="metadata" width="1080" poster="docs/videos/charge-poster.jpg" src="docs/videos/charge.mp4"></video>++[Download the charge video](docs/videos/charge.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 [--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 [--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 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@@ -109,6 +109,10 @@ The model is the ideal continuous-conduction buck solved exactly: D = Vout/Vin,  `analyze` exports the board's F.SilkS and B.SilkS with kicad-cli as SVG (`silk-front.svg`, `silk-back.svg`, fields.json `silk`); `adom-fields silk --board <pcb> --fields <dir>` adds them to an existing solve. The page draws each once into a texture on one plane per side (two quads, no geometry), riding its copper layer and the exploded stack, each side shown only from its own side of the board, hidden in the river. The header's T button or `ui view --silk on|off` toggles it. `--no-silk` skips it; a Fusion board has none yet. +## Charge: the board as capacitors (0.7.3)++`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.+ ## 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.3.mdadded+5
@@ -0,0 +1,5 @@+# adom-fields 0.7.3++- **Charge.** `ui view --quantity charge`: the board as capacitors: + and − charge on facing copper, the electric field crossing the gap, the switching node charging and emptying every cycle, and a panel comparing the copper's own capacitance with the capacitor parts (on the buck, C1 holds 18 million times the copper's VIN capacitance).+- **Videos of every view** on the page: each one recorded in the app explaining itself.+- The readout under the cursor speaks each view's own quantity (charge and field in Charge; nothing misleading in Cycle).
docs/videos/charge-poster.jpgadded
⋯ 1 unchanged line ⋯
docs/videos/charge.mp4added
⋯ 1 unchanged line ⋯
docs/videos/current-poster.jpgadded
⋯ 1 unchanged line ⋯
docs/videos/current.mp4added
⋯ 1 unchanged line ⋯
docs/videos/cycle-poster.jpgadded
⋯ 1 unchanged line ⋯
docs/videos/cycle.mp4added
⋯ 1 unchanged line ⋯
docs/videos/energy-poster.jpgadded
⋯ 1 unchanged line ⋯
docs/videos/energy.mp4added
⋯ 1 unchanged line ⋯
docs/videos/flow-poster.jpgadded
⋯ 1 unchanged line ⋯
docs/videos/flow.mp4added
⋯ 1 unchanged line ⋯
docs/videos/heat-poster.jpgadded
⋯ 1 unchanged line ⋯
docs/videos/heat.mp4added
⋯ 1 unchanged line ⋯
docs/videos/light-poster.jpgadded
⋯ 1 unchanged line ⋯
docs/videos/light.mp4added
⋯ 1 unchanged line ⋯
docs/videos/river-poster.jpgadded
⋯ 1 unchanged line ⋯
docs/videos/river.mp4added
⋯ 1 unchanged line ⋯
docs/videos/temperature-poster.jpgadded
⋯ 1 unchanged line ⋯
docs/videos/temperature.mp4added
⋯ 1 unchanged line ⋯
package.json+6−2
@@ -1,7 +1,7 @@ {   "slug": "adom-fields",   "type": "app",-  "version": "0.7.2",+  "version": "0.7.3",   "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.",@@ -49,7 +49,11 @@     "energy cycle",     "buck converter energy",     "inductor magnetic field",-    "capacitor energy storage"+    "capacitor energy storage",+    "capacitance visualization",+    "parasitic capacitance",+    "charge on copper",+    "electric field between layers"   ],   "sample_prompts": [     {
page.json+12−10
@@ -1,7 +1,7 @@ {   "slug": "adom-fields",   "type": "app",-  "version": "0.7.2",+  "version": "0.7.3",   "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,16 +37,21 @@     "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",     "buck converter energy",     "inductor magnetic field",-    "capacitor energy storage"+    "capacitor energy storage",+    "capacitance visualization",+    "parasitic capacitance",+    "charge on copper",+    "electric field between layers"   ],   "sample_prompts": [     {@@ -90,4 +92,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 [--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 [--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 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@@ -109,6 +109,10 @@ The model is the ideal continuous-conduction buck solved exactly: D = Vout/Vin,  `analyze` exports the board's F.SilkS and B.SilkS with kicad-cli as SVG (`silk-front.svg`, `silk-back.svg`, fields.json `silk`); `adom-fields silk --board <pcb> --fields <dir>` adds them to an existing solve. The page draws each once into a texture on one plane per side (two quads, no geometry), riding its copper layer and the exploded stack, each side shown only from its own side of the board, hidden in the river. The header's T button or `ui view --silk on|off` toggles it. `--no-silk` skips it; a Fusion board has none yet. +## Charge: the board as capacitors (0.7.3)++`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.+ ## 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/serve.rs+2−2
@@ -65,7 +65,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), 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), 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();@@ -177,7 +177,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"].contains(&q.as_str()) { crate::err(&format!("--quantity {q}: use current, heat, temperature, signals, flow, river, energy, light or cycle"), &[]); } } 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 } => { 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::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"]), &[]); }
src/ui/app.js+5−5
@@ -186,7 +186,7 @@   function narrationText(q) {     const f = state.fields; if (!f) return '';     if (q === 'cycle') return window.fieldsCycle ? window.fieldsCycle.narration() : '';-    if (window.fieldsFlow && ['flow', 'river', 'energy', 'light'].includes(q)) return window.fieldsFlow.narration(q);+    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.`;     if (q === 'heat') return `This is heat flow in the copper: where the watts travel away from the hot parts. The brighter the copper, the more heat is moving through it.${c0 ? ` ${c0.reference} puts out ${c0.watts} watts: about ${Math.round(c0.planeW / c0.watts * 100)} percent spreads along its copper, and ${Math.round(c0.viaW / c0.watts * 100)} percent goes down its ${c0.vias} vias to the other side.` : ''} More copper and more vias under a hot part give its heat more ways out.`;@@ -324,7 +324,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') return `copper-${ls}.png`;+    if (q === 'river' || q === 'energy' || q === 'light' || q === 'cycle' || q === 'charge') 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 +339,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'].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'].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';@@ -375,7 +375,7 @@     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'].includes(state.view.quantity)) val = window.fieldsFlow.readout(L, x, y);+    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`;   });@@ -408,7 +408,7 @@   }   async function setView(patch) { const r = await post('ui/view', patch); if (!r) toast('The server did not answer', 'error', true); }   document.addEventListener('click', e => {-    const q = e.target.closest('#qty button, #qty2 button'); if (q) return setView(q.dataset.q === 'energy' ? { quantity: 'energy', issue: null, explode: Math.max(+state.view.explode || 0, 0.6) } : { quantity: q.dataset.q, issue: null });+    const q = e.target.closest('#qty button, #qty2 button'); if (q) return setView(q.dataset.q === 'energy' || q.dataset.q === 'charge' ? { quantity: q.dataset.q, issue: null, explode: Math.max(+state.view.explode || 0, 0.6) } : { quantity: q.dataset.q, issue: null });     const l = e.target.closest('#layer button'); if (l) return setView({ layer: l.dataset.l });     const a = e.target.closest('#air button'); if (a) return setView({ air: a.dataset.a });     const cam = e.target.closest('.views .chip[data-cam]'); if (cam) return setView({ camera: cam.dataset.cam });
src/ui/flow.js+128−9
@@ -35,7 +35,7 @@ export async function attach(ctx) {   const logT = (m) => m <= jLo ? 0 : Math.min(1, Math.log(m / jLo) / Math.log(jHi / jLo));    // ---- the view's knobs, eased so nothing snaps -----------------------------------------------------  const knob = { explode: 0, explodeTo: 0, river: 0, riverTo: 0, flowOn: 0, flowOnTo: 0, energyOn: 0, energyTo: 0, lightOn: 0, lightTo: 0, electrons: false, speed: 1, time: 0, tide: 0 };+  const knob = { explode: 0, explodeTo: 0, river: 0, riverTo: 0, flowOn: 0, flowOnTo: 0, energyOn: 0, energyTo: 0, lightOn: 0, lightTo: 0, chargeOn: 0, chargeTo: 0, electrons: false, speed: 1, time: 0, tide: 0 };   const gap = Math.max(3, 0.16 * Math.max(board.widthMm, board.heightMm));   const spread = [1.5, 0.5, -0.5, -1.5];   const zOf = (k) => Z[LAYERS[k]] + knob.explode * gap * spread[k];@@ -136,7 +136,7 @@ export async function attach(ctx) {   const FALLS = 2400;   const MAX = COUNTS.high + FALLS;   const EMAX = 9000;            // the energy view's own particles, after the charge and the falls-  const TOTAL = MAX + EMAX;+  const TOTAL = MAX + EMAX + 7000;   // + the charge view's field particles   const px = new Float32Array(MAX), py = new Float32Array(MAX), pz = new Float32Array(MAX), pk = new Int8Array(MAX);   const age = new Float32Array(MAX), life = new Float32Array(MAX), mode = new Int8Array(MAX), pnet = new Int16Array(MAX);   const dTo = new Int8Array(MAX), dT = new Float32Array(MAX), lastCell = new Int32Array(MAX).fill(-1);@@ -392,6 +392,19 @@ void main(void) {       const t = eh[q], c = t < 0.5 ? [0.55 + 0.9 * t, 0.10 + 0.3 * t, 0.55 + 0.6 * t] : [1.0, 0.25 + 1.3 * (t - 0.5), 0.85 + 0.3 * (t - 0.5)];       IC[o] = Math.min(1, c[0]); IC[o + 1] = Math.min(1, c[1]); IC[o + 2] = Math.min(1, c[2]); IC[o + 3] = 1;     }+    // the charge view's field: + face to - face across each gap, red to blue+    for (let q = 0; q < 7000; q++) {+      const o = (MAX + EMAX + q) * 4, live = q < CP && knob.chargeOn > 0.01 && cs[q] > 0;+      if (!live) { IV[o + 3] = 0; continue; }+      const G = gaps[cg[q]], i = cellOf(cx[q], cy[q]); const a = lay[G.a].net[i], b = lay[G.b].net[i];+      const va = railOf(a, knob.tide), vb = railOf(b, knob.tide), dv = va - vb;+      cu[q] += lastDt * 0.9 * cs[q] * knob.speed; if (cu[q] >= 1) { cu[q] -= 1; spawnCharge(q); }+      const za = zOf(G.a), zb = zOf(G.b), from = dv >= 0 ? za : zb, to = dv >= 0 ? zb : za, z = from + (to - from) * cu[q];+      const t = Math.min(1, Math.abs(dv) / Math.max(1e-9, topRail));+      IP[o] = bx(cx[q]); IP[o + 1] = by(cy[q]); IP[o + 2] = z; IP[o + 3] = size * 0.9;+      IV[o] = 0; IV[o + 1] = 0; IV[o + 2] = (to - from) * 1.2; IV[o + 3] = knob.chargeOn * (0.1 + 0.6 * t) * Math.sin(Math.PI * cu[q]);+      IC[o] = 1 - 0.65 * cu[q]; IC[o + 1] = 0.35 + 0.25 * cu[q]; IC[o + 2] = 0.3 + 0.7 * cu[q]; IC[o + 3] = 1;+    }     vbP.update(IP); vbV.update(IV); vbC.update(IC);     smat.setVector3('camPos', camera.globalPosition || camera.position);   }@@ -609,6 +622,85 @@ void main(void) {       el.innerHTML = `<b>${a.ref}</b> ${a.net} arrives <span class="ps">${a.ps.toFixed(0)} ps</span> after ${a.from || 'the source'}`;       lightTags.push({ el, a }); } } +  // ---- charge: the board as capacitors --------------------------------------------------------------+  // Copper at different voltages facing each other across the board material is a capacitor: each face+  // holds sigma = e0 er dV / h (C/mm², + on the higher voltage), the field between them is dV / h. A switching+  // node's copper charges and empties with its tide every cycle. The capacitor parts hold Q = C V.+  const E0 = 8.854e-15;   // F/mm+  let lastDt = 1 / 60;+  const railOf = (id, tide) => { const n = nets[id]; if (!n) return NaN; if (n.switching) return (n.switching.lowV || 0) + (n.switching.highV - (n.switching.lowV || 0)) * tide; return n.railV == null ? NaN : n.railV; };+  const chargeTex = [];+  const pairs = new Map();   // "top|bottom" -> { area mm², h, top, bot }+  for (const G of gaps) {+    const A = lay[G.a], B = lay[G.b];+    for (let i = 0; i < N; i++) { const ta = A.net[i], tb = B.net[i]; if (ta < 0 || tb < 0 || ta === tb) continue; const key = ta + '|' + tb; const p = pairs.get(key) || { area: 0, h: G.h, top: ta, bot: tb }; p.area += d * d; pairs.set(key, p); }+  }+  const pairList = [...pairs.values()].map(p => ({ ...p, C: E0 * ER * p.area / p.h })).sort((x, y) => y.C - x.C);+  let sigMax = 1e-30;+  const sigmaAt = (k, i, tide) => { // the charge per mm² on layer k's copper at cell i+    const id = lay[k].net[i]; if (id < 0) return 0; const vk = railOf(id, tide); if (!Number.isFinite(vk)) return 0; let s = 0;+    for (const G of gaps) { if (G.a !== k && G.b !== k) continue; const o = G.a === k ? G.b : G.a; const oid = lay[o].net[i]; if (oid < 0 || oid === id) continue; const vo = railOf(oid, tide); if (Number.isFinite(vo)) s += E0 * ER * (vk - vo) / G.h; }+    return s;+  };+  for (const k of real) for (let i = 0; i < N; i += 3) sigMax = Math.max(sigMax, Math.abs(sigmaAt(k, i, 1)), Math.abs(sigmaAt(k, i, 0)));+  for (const k of real) {+    const L = lay[k], data = new Uint8Array(N * 4);+    const tex = new BAB.RawTexture(data, W, H, BAB.Constants.TEXTUREFORMAT_RGBA, scene, false, false, BAB.Texture.BILINEAR_SAMPLINGMODE, BAB.Constants.TEXTURETYPE_UNSIGNED_BYTE);+    const mesh = BAB.MeshBuilder.CreatePlane('CH-' + L.name, { width: W * d, height: H * d, sideOrientation: BAB.Mesh.DOUBLESIDE }, scene);+    const uvs = mesh.getVerticesData(BAB.VertexBuffer.UVKind); for (let i = 1; i < uvs.length; i += 2) uvs[i] = 1 - uvs[i]; mesh.setVerticesData(BAB.VertexBuffer.UVKind, uvs);+    mesh.position.x = bx(meta.x0 + W * d / 2); mesh.position.y = by(meta.y0 + H * d / 2);+    const m = new BAB.StandardMaterial('CH-m-' + L.name, scene); m.diffuseTexture = tex; tex.hasAlpha = true; m.useAlphaFromDiffuseTexture = true; m.emissiveColor = new BAB.Color3(1, 1, 1); m.disableLighting = true; m.backFaceCulling = false;+    mesh.material = m; mesh.parent = root; mesh.isPickable = false; mesh.setEnabled(false);+    // the switching cells, so a frame only rewrites what the tide moves+    const sw = []; for (let i = 0; i < N; i++) { const id = L.net[i]; if (id < 0) continue; let moving = !!(nets[id] && nets[id].switching); if (!moving) for (const G of gaps) { if (G.a !== k && G.b !== k) continue; const o = G.a === k ? G.b : G.a, oid = lay[o].net[i]; if (oid >= 0 && nets[oid] && nets[oid].switching) moving = true; } if (moving) sw.push(i); }+    chargeTex.push({ k, L, data, tex, mesh, mat: m, sw, painted: false });+  }+  function paintCharge(ct, tide, all) {+    const { k, L, data } = ct; const cells = all ? null : ct.sw;+    const n = all ? N : cells.length;+    for (let q = 0; q < n; q++) {+      const i = all ? q : cells[q], o = i * 4;+      if (L.net[i] < 0) { data[o + 3] = 0; continue; }+      const s = sigmaAt(k, i, tide), t = Math.sqrt(Math.min(1, Math.abs(s) / sigMax));+      if (s >= 0) { data[o] = 255; data[o + 1] = Math.round(70 + 60 * (1 - t)); data[o + 2] = Math.round(60 + 60 * (1 - t)); }+      else { data[o] = Math.round(60 + 60 * (1 - t)); data[o + 1] = Math.round(120 + 40 * (1 - t)); data[o + 2] = 255; }+      data[o + 3] = Math.round(40 + 215 * t);+    }+    ct.tex.update(data);+  }+  // the field: particles crossing each gap from the + face to the - face+  const CMAX = 7000, cx = new Float32Array(CMAX), cy = new Float32Array(CMAX), cu = new Float32Array(CMAX), cg = new Int8Array(CMAX), cdir = new Int8Array(CMAX), cs = new Float32Array(CMAX);+  let cCdf = null, cKeys = null; const CP = Math.max(1500, Math.min(CMAX, Math.round(lay[0].cuCells * d * d * 4)));+  function buildChargeSpawn() {+    const keys = [], w = [];+    for (const [g, G] of gaps.entries()) for (let i = 0; i < N; i++) { const a = lay[G.a].net[i], b = lay[G.b].net[i]; if (a < 0 || b < 0 || a === b) continue; const dv = Math.max(Math.abs(railOf(a, 1) - railOf(b, 1)), Math.abs(railOf(a, 0) - railOf(b, 0))); if (Number.isFinite(dv) && dv > 0.05) { keys.push(g * N + i); w.push(dv); } }+    cKeys = Int32Array.from(keys); cCdf = new Float64Array(w.length); let acc = 0; for (let i = 0; i < w.length; i++) { acc += w[i]; cCdf[i] = acc; }+    for (let p = 0; p < CMAX; p++) cu[p] = rnd();+    for (let p = 0; p < CP; p++) spawnCharge(p);+  }+  function spawnCharge(p) {+    if (!cCdf || !cCdf.length) { cs[p] = 0; return; }+    const r = rnd() * cCdf[cCdf.length - 1]; let lo = 0, hi = cCdf.length - 1; while (lo < hi) { const m = (lo + hi) >> 1; if (cCdf[m] < r) lo = m + 1; else hi = m; }+    const key = cKeys[lo], g = Math.floor(key / N), i = key % N; cg[p] = g; cx[p] = meta.x0 + ((i % W) + rnd()) * d; cy[p] = meta.y0 + (Math.floor(i / W) + rnd()) * d; cs[p] = 0.7 + 0.6 * rnd();+  }+  const capParts = [];+  { const vals = state.fields.partValues || {}, pads = state.fields.pads || [];+    const parseC = (s) => { for (const tok of String(s || '').replace(',', '.').split(/\s+/)) { const m = tok.match(/^(\d+(?:\.\d+)?)([pnuµm]?)(\d*)F?$/i); if (!m || !m[2]) continue; const mul = { p: 1e-12, n: 1e-9, u: 1e-6, 'µ': 1e-6, m: 1e-3 }[m[2].toLowerCase()] || 1; return parseFloat(m[1] + (m[3] ? '.' + m[3] : '')) * mul; } return null; };+    const byRef = {}; for (const p of pads) if (/^C\d/i.test(p.ref)) (byRef[p.ref] = byRef[p.ref] || []).push(p);+    for (const [ref, ps] of Object.entries(byRef)) { const ids = [...new Set(ps.map(p => netIndex[p.net]))]; if (ids.length !== 2) continue; const C = parseC(vals[ref]); if (!C) continue; const v = Math.abs(railOf(ids[0], 1) - railOf(ids[1], 1)); if (!Number.isFinite(v)) continue; capParts.push({ ref, C, V: v, Q: C * v, E: 0.5 * C * v * v, x: ps.reduce((s, p) => s + p.x, 0) / ps.length, y: ps.reduce((s, p) => s + p.y, 0) / ps.length }); }+    capParts.sort((a, b) => b.Q - a.Q); }+  const fmtC = (c) => c >= 1e-6 ? (c * 1e6).toFixed(c >= 1e-5 ? 0 : 1) + ' µF' : c >= 1e-9 ? (c * 1e9).toFixed(1) + ' nF' : (c * 1e12).toFixed(1) + ' pF';+  const fmtQ = (q) => q >= 1e-6 ? (q * 1e6).toFixed(0) + ' µC' : q >= 1e-9 ? (q * 1e9).toFixed(1) + ' nC' : (q * 1e12).toFixed(1) + ' pC';+  const fmtE = (e) => e >= 1e-3 ? (e * 1e3).toFixed(2) + ' mJ' : e >= 1e-6 ? (e * 1e6).toFixed(2) + ' µJ' : (e * 1e9).toFixed(3) + ' nJ';+  const chargePanel = document.createElement('div'); chargePanel.className = 'cyclemeter chargepanel'; chargePanel.hidden = true; document.querySelector('.stage').appendChild(chargePanel);+  {+    const rows = pairList.filter(p => Number.isFinite(railOf(p.top, 1) - railOf(p.bot, 1))).slice(0, 3).map(p => { const a = nets[p.top], b = nets[p.bot], v = Math.abs(railOf(p.top, 1) - railOf(p.bot, 1)); return `<div class="row"><span class="dot in"></span>${a.name}/${b.name}${a.switching || b.switching ? ' ~' : ''}<i>${fmtC(p.C)} · ${fmtQ(p.C * v)}</i></div>`; }).join('');+    const parts = capParts.slice(0, 3).map(c => `<div class="row"><span class="dot out"></span>${c.ref} ${fmtC(c.C)}<i>${fmtQ(c.Q)} · ${fmtE(c.E)}</i></div>`).join('');+    const ratio = capParts[0] && pairList[0] ? capParts[0].C / pairList[0].C : 0;+    chargePanel.innerHTML = `<div class="ch" title="Click to fold">Capacitance on this board</div><div class="body"><div class="row" style="color:var(--text-2)">The copper itself (~ switching)</div>${rows}<div class="row" style="color:var(--text-2);margin-top:8px">The capacitor parts</div>${parts}${ratio ? `<div class="row" style="color:var(--text-2);margin-top:8px">${capParts[0].ref} alone holds ${ratio >= 1e6 ? (ratio / 1e6).toFixed(0) + ' million' : Math.round(ratio).toLocaleString()} times the copper's ${nets[pairList[0].top].name} capacitance</div>` : ''}</div>`;+    chargePanel.addEventListener('click', (e) => { if (e.target.closest('.ch')) chargePanel.classList.toggle('folded'); });+  }+   // ---- glow: the streaks bloom, nothing else does --------------------------------------------------   let glow = null;   try { glow = new BAB.GlowLayer('fields-flow-glow', scene, { mainTextureSamples: 2, blurKernelSize: 24 }); glow.intensity = 0.3; glow.addIncludedOnlyMesh(streak); glow.referenceMeshToUseItsOwnMaterial(streak); }@@ -625,7 +717,7 @@ void main(void) {     if (viaMesh) viaMesh.scaling.z = 1 + knob.explode * gap * 3 / Math.max(0.2, board.thicknessMm);     // pulled apart, the barrels turn to glass so the current falling inside them shows     if (viaMesh && viaMesh.material) { const m = viaMesh.material; m.alpha = 1 - 0.8 * knob.explode; m.transparencyMode = knob.explode > 0.02 ? BAB.Material.MATERIAL_ALPHABLEND : BAB.Material.MATERIAL_OPAQUE; }-    if (boardBox && boardBox.material) boardBox.material.alpha = 0.94 * (1 - 0.82 * knob.explode) * (1 - 0.6 * knob.river) * (1 - 0.7 * knob.energyOn);+    if (boardBox && boardBox.material) boardBox.material.alpha = 0.94 * (1 - 0.82 * knob.explode) * (1 - 0.6 * knob.river) * (1 - 0.7 * knob.energyOn) * (1 - 0.7 * knob.chargeOn);     const pr = partsRoot || scene.getTransformNodeByName('fields-parts');     if (pr) for (const h of pr.getChildren()) { const ref = h.name.replace(/^part-/, ''); const side = partSide[ref] === 'bottom' ? -1 : 1; if (h._z0 === undefined) h._z0 = h.position.z; const at = partAt[ref]; const lift = side > 0 && at ? heightAt(0, at.i) : 0; h.position.z = h._z0 + knob.explode * gap * 1.5 * side + lift; }   }@@ -727,6 +819,11 @@ void main(void) {     knob.flowOn = ease(knob.flowOn, knob.flowOnTo, dt, 2.5);     const wasE = knob.energyOn; knob.energyOn = ease(knob.energyOn, knob.energyTo, dt, 2.0);     knob.lightOn = ease(knob.lightOn, knob.lightTo, dt, 2.0);+    const wasC = knob.chargeOn; knob.chargeOn = ease(knob.chargeOn, knob.chargeTo, dt, 2.0); if (knob.chargeOn !== wasC) applyStack();+    if (knob.chargeOn > 0.01) {+      if (!cCdf) buildChargeSpawn();+      for (const ct of chargeTex) { ct.mesh.setEnabled(true); ct.mesh.position.z = zOf(ct.k) + (ct.k === real[0] ? -0.03 : 0.03); ct.mat.alpha = knob.chargeOn * 0.9; if (!ct.painted) { paintCharge(ct, knob.tide, true); ct.painted = true; } else if (ct.sw.length) paintCharge(ct, knob.tide, false); }+    } else for (const ct of chargeTex) ct.mesh.setEnabled(false);     for (const lm of lightMeshes) { const on = knob.lightOn > 0.01; lm.mesh.setEnabled(on); if (!on) continue; lm.mesh.position.z = zOf(lm.k) + (lm.k === 0 ? 0.02 : -0.02); lm.mat.setFloat('alpha', knob.lightOn * ((knob.explode > 0.3 || LAYERS[lm.k] === (state.view.layer || 'F.Cu')) ? 1 : 0.25)); lm.mat.setFloat('phase', ((knob.time % LIGHT_LOOP) / LIGHT_LOOP) * 1.25); }     if (knob.explode !== was[0] || knob.river !== was[1] || knob.river > 0 || knob.energyOn !== wasE) applyStack();     for (const t of terrains) {@@ -737,8 +834,9 @@ void main(void) {       const sel = state.view.layer || 'F.Cu';       t.mat.setFloat('alpha', (knob.explode > 0.3 || t.L.name === sel) ? 0.94 * Math.min(1, knob.river * 1.4) : 0.0);     }-    if (knob.lightOn > 0.01) knob.time += dt * knob.speed;   // the light view's own clock (no particles to step it)-    const active = knob.flowOn > 0.01 || knob.river > 0.01 || knob.energyOn > 0.01;+    if (knob.lightOn > 0.01) knob.time += dt * knob.speed;+    lastDt = dt;   // the light view's own clock (no particles to step it)+    const active = knob.flowOn > 0.01 || knob.river > 0.01 || knob.energyOn > 0.01 || knob.chargeOn > 0.01;     streak.setEnabled(active);     if (!active) return;     if (cdfNet === undefined || cdfNet !== (state.view.net || null)) buildSpawn(state.view.net || null);@@ -756,6 +854,10 @@ void main(void) {   // writes anything at view time and adom-tts caches the audio by its text   const say = (x) => String(x).replace(/\bmm\b/g, 'millimetres');   function narration(q) {+    if (q === 'charge') {+      const p0 = pairList[0], c0 = capParts[0], swp = pairList.find(p => nets[p.top].switching || nets[p.bot].switching);+      return `This is the Charge view: the board as capacitors. Wherever two pieces of copper at different voltages face each other across the board, they store charge, like the two plates of a capacitor. Red is positive charge on the higher-voltage copper, blue is the negative charge on the copper facing it, and the particles are the electric field crossing between them.${p0 ? ` The biggest one here is ${nets[p0.top].name} over ${nets[p0.bot].name}, about ${(p0.C * 1e12).toFixed(0)} picofarads.` : ''}${c0 ? ` That is tiny next to the capacitor parts: ${c0.ref} alone holds ${fmtC(c0.C).replace('µF', 'microfarads')}, millions of times more, which is why the circuit needs real capacitors.` : ''}${swp ? ` Watch ${nets[swp.top].switching ? nets[swp.top].name : nets[swp.bot].name}: its copper blinks, because it charges and empties every switching cycle, ${Math.round((nets.find(n => n.switching).switching.freqHz || 500000) / 1000)} thousand times a second. That stray capacitance is one of the ways switching noise couples into the rest of the board.` : ''}`;+    }     const amps = nets.filter(n => n.loaded && !n.ground);     if (q === 'flow') return `This is the Flow view. Every particle is current, moving the way the current flows, from plus to minus. The faster and brighter a particle, the more current is packed into each square millimetre of copper: up to ${Math.round((state.fields.scales && state.fields.scales.jMax) || jHi)} amps per square millimetre at the busiest spot on this board. The colour underneath is the same current density, so a bright yellow region is where the copper is working hardest. At a via, the share of particles that drop through it is the share of the current it carries. Each part's tag says the current it takes in and puts out. Use the layers button to pull the layers apart and watch the current move between them.`;     if (q === 'river') {@@ -771,6 +873,12 @@ void main(void) {     void amps; return '';   }   function describe(q) {+    if (q === 'charge') return ['<b>Charge</b>: the board as capacitors',+      '<b>Red</b> = positive charge on the higher-voltage copper, <b>blue</b> = negative on the copper facing it; brighter = more charge per mm²',+      '<b>Particles</b> = the electric field, crossing the board from the + face to the − face',+      'A switching node blinks: its copper charges and empties every cycle (slowed to ' + TIDE_HZ + ' Hz)',+      'The panel: the copper\'s own capacitance, tiny, next to the capacitor parts',+      'Parallel plates, er = ' + ER + '; fringing at the edges and the air above are not counted'];     if (q === 'light') return ['<b>Light</b>: how fast a change crosses the board',       '<b>Colour</b> = local speed, % of the speed of light: deep blue inside the FR4 (about ' + Math.round(100 / Math.sqrt(ER)) + ' %), cyan to green on thin traces with air above them',       '<b>The white front</b> = a switch-on wave leaving each source; it really crosses this board in about ' + tMax.toFixed(0) + ' ps, slowed here to ' + LIGHT_LOOP + ' s',@@ -799,19 +907,23 @@ void main(void) {   function apply(view) {     const q = view.quantity;     knob.flowOnTo = q === 'flow' || q === 'river' || q === 'energy' ? 1 : 0;-    knob.energyTo = q === 'energy' ? 1 : 0; knob.electrons = q === 'energy'; knob.lightTo = q === 'light' ? 1 : 0;+    knob.energyTo = q === 'energy' ? 1 : 0; knob.electrons = q === 'energy'; knob.lightTo = q === 'light' ? 1 : 0; knob.chargeTo = q === 'charge' ? 1 : 0;+    chargePanel.hidden = q !== 'charge';     knob.riverTo = q === 'river' ? 1 : 0;     knob.explodeTo = Math.max(0, Math.min(1, +view.explode || 0));     knob.speed = Math.max(0.1, Math.min(4, +view.speed || 1));     if (q === 'flow') { for (const Lname of LAYERS) { const m = ctx.layerMeshes[Lname]; if (m) m.material.alpha = knob.explodeTo > 0.3 ? (lay[LAYERS.indexOf(Lname)].cuCells > 0.02 * N ? 0.3 : 0) : m.material.alpha * 0.3; } }     if (q === 'river') { for (const Lname of LAYERS) { const m = ctx.layerMeshes[Lname]; if (m) m.material.alpha = 0; } }     if (q === 'energy') { for (const Lname of LAYERS) { const m = ctx.layerMeshes[Lname]; if (m) m.material.alpha = real.includes(LAYERS.indexOf(Lname)) ? 0.16 : 0; } }-    if (q === 'light') { for (const Lname of LAYERS) { const m = ctx.layerMeshes[Lname]; if (m) m.material.alpha = 0; } }-    const badge = document.getElementById('flowBadge'); if (badge) { badge.hidden = !(q === 'flow' || q === 'river' || q === 'energy' || q === 'light'); const [head, ...rows] = describe(q); badge.innerHTML = `<div class="kh">${head}</div><ul>${rows.map(r => `<li>${r}</li>`).join('')}</ul>`;  badge.hidden = badge.hidden || !keyOn(); const kb = document.getElementById('btnKey'); if (kb) { kb.hidden = !(q === 'flow' || q === 'river' || q === 'energy' || q === 'light'); kb.classList.toggle('is-on', keyOn()); } }-    const leg = document.getElementById('legend'); leg.classList.remove('volts', 'amps', 'energy', 'light');+    if (q === 'light' || q === 'charge') { for (const Lname of LAYERS) { const m = ctx.layerMeshes[Lname]; if (m) m.material.alpha = 0; } }+    const badge = document.getElementById('flowBadge'); if (badge) { badge.hidden = !(q === 'flow' || q === 'river' || q === 'energy' || q === 'light' || q === 'charge'); const [head, ...rows] = describe(q); badge.innerHTML = `<div class="kh">${head}</div><ul>${rows.map(r => `<li>${r}</li>`).join('')}</ul>`;  badge.hidden = badge.hidden || !keyOn(); const kb = document.getElementById('btnKey'); if (kb) { kb.hidden = !(q === 'flow' || q === 'river' || q === 'energy' || q === 'light' || q === 'charge'); kb.classList.toggle('is-on', keyOn()); } }+    const leg = document.getElementById('legend'); leg.classList.remove('volts', 'amps', 'energy', 'light', 'charge');     if (q === 'river') {       leg.hidden = false; leg.classList.remove('magma'); leg.classList.add('volts');       document.getElementById('legendTitle').textContent = 'Voltage, V: height and colour'; document.getElementById('legLo').textContent = '0'; document.getElementById('legHi').textContent = topRail.toFixed(topRail < 10 ? 1 : 0);+    } else if (q === 'charge') {+      leg.hidden = false; leg.classList.remove('magma'); leg.classList.add('charge');+      document.getElementById('legendTitle').textContent = 'Charge on the copper, C/mm²: + red, − blue'; document.getElementById('legLo').textContent = '−' + sigMax.toExponential(1); document.getElementById('legHi').textContent = '+' + sigMax.toExponential(1);     } else if (q === 'light') {       leg.hidden = false; leg.classList.remove('magma'); leg.classList.add('light');       document.getElementById('legendTitle').textContent = 'Speed of a change, % of the speed of light'; document.getElementById('legLo').textContent = '40 %'; document.getElementById('legHi').textContent = '100 %';@@ -827,6 +939,13 @@ void main(void) {   const keyOn = () => keyShown;   document.addEventListener('click', (e) => { if (!e.target.closest('#btnKey') && !e.target.closest('#flowBadge .kh')) return; keyShown = !keyShown; try { localStorage.setItem('fields.keyOn', keyShown ? '1' : '0'); } catch (err) { void err; } apply(state.view); });   function readout(Lname, x, y) {+    if (state.view.quantity === 'cycle') return '';+    if (state.view.quantity === 'charge') {+      const i = cellOf(x, y), k = LAYERS.indexOf(Lname); if (i < 0 || k < 0) return '';+      const id = lay[k].net[i]; if (id < 0) return 'no copper'; const s = sigmaAt(k, i, knob.tide);+      const G = gaps.find(g => g.a === k || g.b === k); const o = G ? (G.a === k ? G.b : G.a) : -1, oid = o >= 0 ? lay[o].net[i] : -1;+      return `${nets[id].name}${oid >= 0 && oid !== id ? ' facing ' + nets[oid].name : ''} · ${s >= 0 ? '+' : '−'}${Math.abs(s * 1e12).toFixed(3)} pC/mm²` + (oid >= 0 && oid !== id && G ? ` · field ${(Math.abs(railOf(id, knob.tide) - railOf(oid, knob.tide)) / G.h).toFixed(1)} V/mm` : '');+    }     if (state.view.quantity === 'light') {       const i = cellOf(x, y), k = LAYERS.indexOf(Lname); if (i < 0 || k < 0) return '';       const pc = pctC[k * N + i], at = arrival[k * N + i], n = nets[lay[k].net[i]];
src/ui/index.html+3−2
@@ -57,12 +57,13 @@ 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.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} .legend.energy .ramp{background:linear-gradient(90deg,#3a0a3a,#8c1a8c,#e040c8,#ff7ae0,#ffd9f4,#ffffff)} .legend.volts .ramp{background:linear-gradient(90deg,#041238,#0a3a6a,#006b8c,#2fa9c9,#59d9fa,#c8f3ff)} .caption{position:absolute;left:50%;bottom:16px;transform:translateX(-50%);width:max-content;max-width:min(640px,calc(100% - 32px));background:rgba(13,17,23,.84);border:1px solid var(--border);border-radius:10px;padding:6px 14px;font-size:14px;line-height:1.45;text-align:center;color:var(--text);pointer-events:none;z-index:5;backdrop-filter:blur(6px)}.caption[hidden]{display:none}-.cyclemeter{position:absolute;left:16px;bottom:72px;width:250px;background:rgba(22,27,34,.88);border:1px solid var(--border);border-radius:8px;padding:8px 10px;font-size:12px;color:var(--text-body);backdrop-filter:blur(4px);z-index:4}.cyclemeter[hidden]{display:none}.cyclemeter .ch{font-family:"Familjen Grotesk",sans-serif;font-size:12.5px;color:var(--text);cursor:pointer;margin-bottom:4px}.cyclemeter.folded .body{display:none}.cyclemeter .row{display:flex;align-items:center;gap:6px;margin-top:5px}.cyclemeter .row i{margin-left:auto;font-style:normal;font-family:"JetBrains Mono";font-size:11px;color:var(--text)}.cyclemeter .sw b{margin-left:auto;font-weight:600;font-size:11.5px}.cyclemeter .sw b.on{color:#ff7ae0}.cyclemeter .sw b.off{color:#64abff}.cyclemeter canvas{width:100%;height:32px;display:block;margin-top:4px;background:rgba(13,17,23,.6);border-radius:4px}.cyclemeter .bar{height:4px;background:rgba(255,255,255,.08);border-radius:2px;margin-top:3px}.cyclemeter .bar div{height:100%;border-radius:2px}.cyclemeter .dot{width:8px;height:8px;border-radius:50%;flex:0 0 8px}.cyclemeter .in{background:#ff5a4a}.cyclemeter .l{background:#5a9aff}.cyclemeter .out{background:#ff8a5a}.cyclemeter .heat{background:#ff9a2a}.cyclemeter .pk{background:#ff5ad8}+.cyclemeter{position:absolute;left:16px;bottom:72px;width:250px;max-height:calc(100% - 150px);overflow:auto;background:rgba(22,27,34,.88);border:1px solid var(--border);border-radius:8px;padding:8px 10px;font-size:12px;color:var(--text-body);backdrop-filter:blur(4px);z-index:4}.cyclemeter[hidden]{display:none}.cyclemeter .ch{font-family:"Familjen Grotesk",sans-serif;font-size:12.5px;color:var(--text);cursor:pointer;margin-bottom:4px}.cyclemeter.folded .body{display:none}.cyclemeter .row{display:flex;align-items:center;gap:6px;margin-top:5px}.cyclemeter .row i{margin-left:auto;padding-left:6px;white-space:nowrap;font-style:normal;font-family:"JetBrains Mono";font-size:11px;color:var(--text)}.cyclemeter .sw b{margin-left:auto;font-weight:600;font-size:11.5px}.cyclemeter .sw b.on{color:#ff7ae0}.cyclemeter .sw b.off{color:#64abff}.cyclemeter canvas{width:100%;height:32px;display:block;margin-top:4px;background:rgba(13,17,23,.6);border-radius:4px}.cyclemeter .bar{height:4px;background:rgba(255,255,255,.08);border-radius:2px;margin-top:3px}.cyclemeter .bar div{height:100%;border-radius:2px}.cyclemeter .dot{width:8px;height:8px;border-radius:50%;flex:0 0 8px}.cyclemeter .in{background:#ff5a4a}.cyclemeter .l{background:#5a9aff}.cyclemeter .out{background:#ff8a5a}.cyclemeter .heat{background:#ff9a2a}.cyclemeter .pk{background:#ff5ad8} .flowbadge{position:absolute;left:16px;top:56px;max-width:min(380px,55%);background:rgba(22,27,34,.88);border:1px solid var(--border);border-radius:8px;padding:8px 12px;font-size:12px;line-height:1.5;color:var(--text-body);backdrop-filter:blur(4px)}.flowbadge .kh{font-family:"Familjen Grotesk",sans-serif;font-size:13px;color:var(--text);margin-bottom:3px;cursor:pointer;user-select:none}.flowbadge .kh::after{content:" ▾";color:var(--text-2)}.flowbadge.folded .kh::after{content:" ▸"}.flowbadge.folded ul{display:none}.flowbadge.folded .kh{margin:0}.flowbadge b{color:var(--text);font-weight:600}.flowbadge ul{margin:0;padding-left:16px}.flowbadge li{margin:1px 0} .legend.magma .ramp{background:linear-gradient(90deg,#000004,#1c1047,#4a0c7a,#781c81,#a82e78,#d44866,#f5705c,#fca678,#fcfdbf)} .legend .ends{display:flex;justify-content:space-between;font-family:"JetBrains Mono";font-size:11px;color:var(--text-2)}@@ -108,7 +109,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></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></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>