← Commit history
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-fields"-version = "0.6.2"+version = "0.7.0" dependencies = [  "clap",  "png",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-fields"-version = "0.6.2"+version = "0.7.0" 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+30
@@ -72,6 +72,36 @@ A hot zone alone does not say what is heating it. Fields now stands each part's  ![The first buck board's heat flow with the parts see-through: U1 and L1 standing in their hot zones](docs/screenshots/parts-heat-iso.png) +## The flow and the river in 0.7.0++Two new views, both from the same DC solve, both animated on the GPU so the eye reads the field as motion.++**Flow** turns the solved current into charge streaming through the copper: thousands of glowing streaks whose speed and heat follow the current density (slow and dim on a quiet pour, white-hot through a neck), dropping through each via barrel in proportion to the current the solve says that barrel carries. The layer button in the header pulls the stack apart, so the inner copper shows and the current falling between the layers is plain.++![The buck's layers pulled apart, the current streaming over both copper layers and down the via barrels](docs/screenshots/flow-exploded.png)++*The 12 V to 5 V buck, layers pulled apart: charge on the top and bottom copper, and the current pouring down the barrels between them.*++**River** lifts every net's copper to its voltage: the rail where the net is fed, plus the IR drop the solve found (exaggerated, and the badge says by how much). The current runs over it as water. A ground is the sea, a supply a terrace above it, a switching node a tide between its rails (slowed to a rate the eye can follow, and labelled). The loads are waterfalls down to ground; the sources are fountains back up. Each net carries a sign with its voltage and its drop, and the cursor reads the volts under it.++![The river: VIN at 12 V, VOUT at 5 V, SW swinging between them, GND at sea level](docs/screenshots/river-iso.png)++*VIN's 12 V plateau, VOUT's 5 V terrace, SW at the top of its tide and the GND sea, with the waterfalls where U1 and the loads take their current. VIN loses 1.4 mV from J1 to U1; the ground under U1 sits 1.6 mV above the connectors.*++The rails come from the net names (GND 0, 3V3 3.3, +12V 12) or the spec's `voltages`; `sources` names the part a net is fed from when that is not the connector (a regulator's output); `switching` marks a node that swings between rails. A rail the spec does not give stays at its drop alone, and its sign says so. Drive both like everything else: `adom-fields ui view --quantity flow --explode 1`, `ui view --quantity river`.++## Energy and light in 0.7.0++Two more views from the same solve. **Energy** shows that the energy does not travel inside the copper: pink particles stream through the board material between the layers (the Poynting vector, E × H), rising into each part that takes its power, while blue dots show the electrons themselves crawling through the copper at their real speed, a few millimetres per second, against the current. **Light** colours the copper by how fast a change travels around it, as a percentage of the speed of light, and sweeps a switch-on front out from each source with the arrival time at each part: on the buck, VIN reaches U1 103 ps after J1.++![Energy: pink energy flowing between the copper layers into U1 and L1, blue electrons crawling on the copper](docs/screenshots/energy-iso.png)++*The buck's layers apart: the energy runs through the board between the copper layers into the regulator and the inductor, while the electrons on the copper barely move.*++![Light: the copper coloured by the local speed of a change, with each part's arrival time](docs/screenshots/light-top.png)++*Each piece of copper coloured by how fast a change travels around it; the tags give when a switch-on reaches each part.*+ ## Fusion boards in 0.6.0  `--board` now also takes a Fusion Electronics board saved as EAGLE source (`.brd`: the Fusion
SKILL.md+28−2
@@ -1,7 +1,7 @@ --- name: adom-fields description: >--  Solve where the current and the heat go on a KiCad board and show it on the board in 3D (Adom Fields): current density per loaded net, heat flow in each copper layer with the vias, temperature over the board in still and moving air, the nets by what they carry, the hot parts by their rise, the issues with the fix; a server the AI drives over a live channel (view, select, issue, toast, tour) for the human to explore and for the AI Flow video. Trigger words: adom-fields, fields, current density, heat map, thermal analysis, copper pour analysis, thermal vias, where does the current go, hot tab, temperature rise, IR drop, current neck, heat flow.+  Solve where the current and the heat go on a KiCad board and show it on the board in 3D (Adom Fields): current density per loaded net, heat flow in each copper layer with the vias, temperature over the board in still and moving air, the nets by what they carry, the hot parts by their rise, the issues with the fix; a server the AI drives over a live channel (view, select, issue, toast, tour) for the human to explore and for the AI Flow video. Trigger words: adom-fields, fields, current density, heat map, thermal analysis, copper pour analysis, thermal vias, where does the current go, hot tab, temperature rise, IR drop, current neck, heat flow, current flow animation, river view, voltage drop in 3D, ground bounce, exploded layer stack, current through vias. ---  # Adom Fields@@ -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 [--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 [--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@@ -69,6 +69,32 @@ The server: `GET /state`, `GET /console` (the UI's log), `GET /events` (SSE), `G  Record the pup window (Adom Bridge `desktop_record_window_start` on it) while `adom-fields tour` runs: the clip is the fields step, the toasts are the captions, the AI's own analysis on the board in 3D. +## Flow, river and the exploded stack (0.7.0)++`analyze` also writes `flow.bin` (the signed current, the voltage and the owning net of every cell) and `fields.json` `flow` (each net's rail, sense, drop, terminals as source or sink, and the current down each barrel). Two views use it:++- `ui view --quantity flow`: the current as charge, streaks whose speed and glow follow |J|, dropping through the barrels by their solved share. `--explode 1` pulls the layers apart (0 puts them back); the camera backs off to keep the stack in frame.+- `ui view --quantity river`: each net lifted to its voltage, water over it, loads as waterfalls, sources as fountains, a switching node as a slowed tide. Signs give each net's voltage and drop; the readout gives the volts under the cursor.+- `--speed 0.1..4` slows or quickens both. The badge states the scale and the exaggeration; say it when you narrate.++Tell the solve the voltages it cannot read from names, in the spec:++```json+"voltages": {"VIN": 12, "VOUT": 5},+"sources": {"VOUT": ["L1"], "SW": ["U1"]},+"switching": {"SW": {"highV": 12, "lowV": 0, "freqHz": 500000}}+```++Names give GND/PGND/AGND/VSS 0, 3V3 3.3, +12V 12, 5V_AUX 5; SW, LX and PH are switching nodes between the highest rail and 0 unless the spec says otherwise. Default sense: a supply flows from its connector to its parts, a ground from its parts back to its connector; `sources` flips a net whose feed is a part (a regulator or inductor output). A net with no rail stays at its drop and says "rail not given". The voltages are DC: the rail plus the solved IR drop. Copper the pinned terminal cannot reach (a floating island) sits still at the rail.++Developing the page: `ADOM_FIELDS_UI_DIR=<repo>/src/ui adom-fields serve ...` serves index.html, app.js and flow.js from disk, so a reload shows a change without a rebuild.++## Energy and light++**Energy** (`ui view --quantity energy --explode 0.6`) shows where the energy really travels: pink particles in the board material between the copper layers, along the Poynting vector S = E × H. Between two layers a gap h apart, E is (V_top − V_bottom)/h and the two current sheets give H, so S = ½ (V_top − V_bottom)/h · (K_top − K_bottom) in W/mm²; over a supply and its return that integrates to V × I. Pink rising into a part is that part taking its energy. The blue dots are the electrons in the copper at their real drift speed (J / n e, 0.073 mm/s per A/mm²), moving against the current. DC, inside the board only: part of the energy travels in the air around it.++**Light** (`ui view --quantity light`) colours each piece of copper by how fast a change travels in its field, as a % of the speed of light: 1/√εr_eff, all FR4 between planes (about 48 %), Hammerstad's εr_eff for a trace over a plane (wider is slower), mostly air with no plane under it. A switch-on front sweeps out from each fed net's source, and each load is tagged with its arrival time in picoseconds (the fastest path, the sum of length over speed along the way). εr comes from the spec's signals epsilonR, else 4.3. A switching node's DC average (`switching.SW.meanV`) sets its level in the energy view.+ ## 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.0.mdadded+13
@@ -0,0 +1,13 @@+# adom-fields 0.7.0++- **Flow.** `ui view --quantity flow`: the solved DC current as charge streaming through the copper on the GPU (instanced streaks with glow, 60 fps on a laptop's integrated graphics), speed and glow by current density, each via barrel taking its solved share of the current between layers. The particle count follows the current-carrying copper area, and a tier check halves it once if the page cannot hold 38 fps.+- **The exploded stack.** `--explode 0..1` (the layers button in the header) pulls the layers apart with an eased animation; the barrels turn to glass and stretch, the parts ride their side, the camera backs off to keep the stack in frame.+- **River.** `ui view --quantity river`: every net's copper lifted to its voltage (rail plus the solved IR drop, exaggerated and labelled on the badge), water running over it with a flow-mapped shader, terrace sides as rock, loads falling to ground and sources lifting back up, a switching node as a tide between its rails at a slowed, stated rate. Each net gets a sign with its voltage and drop; the cursor reads the volts.+- **flow.bin and fields.json `flow`.** Per cell: the signed current density in the physical sense, the voltage, the owning net. Per net: rail, sense ("from J1 to U1"), drop, terminals as source or sink, the current down each barrel. The spec gains `voltages`, `sources` and `switching`; rails are otherwise read from net names.+- Copper of a net that the pinned terminal cannot reach floats in the solve; the flow and river views now ignore it rather than show its arbitrary potential.+- The tour adds four beats when the solve carries flow data: flow, the stack apart, the river, the river apart.+- **Energy.** `ui view --quantity energy`: the Poynting vector between copper layers as pink particles, rising into the parts that take the power; the electrons at their real drift speed, against the current.+- **Light.** `ui view --quantity light`: the local speed of a change as a % of c (1/√εr_eff, Hammerstad for traces over a plane), a looping switch-on front, and each load's arrival time in ps.+- **Parts.** Ghosted at 33 % by default with a slider beside the view buttons (`ui view --ghost`); each part tagged with the current it takes in and puts out; the parts ride the river's terraces.+- **The key.** A plain-words key for each animated view, off by default behind the Key button (the legend names the scale).+- `ADOM_FIELDS_UI_DIR` serves the page from disk for development.
docs/screenshots/energy-iso.pngadded
⋯ 1 unchanged line ⋯
docs/screenshots/flow-exploded.pngadded
⋯ 1 unchanged line ⋯
docs/screenshots/flow-top.pngadded
⋯ 1 unchanged line ⋯
docs/screenshots/light-top.pngadded
⋯ 1 unchanged line ⋯
docs/screenshots/river-exploded.pngadded
⋯ 1 unchanged line ⋯
docs/screenshots/river-iso.pngadded
⋯ 1 unchanged line ⋯
package.json+12−3
@@ -1,7 +1,7 @@ {   "slug": "adom-fields",   "type": "app",-  "version": "0.6.2",+  "version": "0.7.0",   "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,7 +17,10 @@     "vias",     "3d",     "analysis",-    "eda"+    "eda",+    "current flow animation",+    "voltage",+    "river"   ],   "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.",@@ -34,7 +37,13 @@     "IR drop",     "current neck",     "heat flow in copper",-    "fields"+    "fields",+    "flow of current",+    "river view",+    "voltage drop in 3d",+    "ground bounce",+    "exploded layer stack",+    "current through vias"   ],   "sample_prompts": [     {
skills/adom-fields/SKILL.md+28−2
@@ -1,7 +1,7 @@ --- name: adom-fields description: >--  Solve where the current and the heat go on a KiCad board and show it on the board in 3D (Adom Fields): current density per loaded net, heat flow in each copper layer with the vias, temperature over the board in still and moving air, the nets by what they carry, the hot parts by their rise, the issues with the fix; a server the AI drives over a live channel (view, select, issue, toast, tour) for the human to explore and for the AI Flow video. Trigger words: adom-fields, fields, current density, heat map, thermal analysis, copper pour analysis, thermal vias, where does the current go, hot tab, temperature rise, IR drop, current neck, heat flow.+  Solve where the current and the heat go on a KiCad board and show it on the board in 3D (Adom Fields): current density per loaded net, heat flow in each copper layer with the vias, temperature over the board in still and moving air, the nets by what they carry, the hot parts by their rise, the issues with the fix; a server the AI drives over a live channel (view, select, issue, toast, tour) for the human to explore and for the AI Flow video. Trigger words: adom-fields, fields, current density, heat map, thermal analysis, copper pour analysis, thermal vias, where does the current go, hot tab, temperature rise, IR drop, current neck, heat flow, current flow animation, river view, voltage drop in 3D, ground bounce, exploded layer stack, current through vias. ---  # Adom Fields@@ -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 [--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 [--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@@ -69,6 +69,32 @@ The server: `GET /state`, `GET /console` (the UI's log), `GET /events` (SSE), `G  Record the pup window (Adom Bridge `desktop_record_window_start` on it) while `adom-fields tour` runs: the clip is the fields step, the toasts are the captions, the AI's own analysis on the board in 3D. +## Flow, river and the exploded stack (0.7.0)++`analyze` also writes `flow.bin` (the signed current, the voltage and the owning net of every cell) and `fields.json` `flow` (each net's rail, sense, drop, terminals as source or sink, and the current down each barrel). Two views use it:++- `ui view --quantity flow`: the current as charge, streaks whose speed and glow follow |J|, dropping through the barrels by their solved share. `--explode 1` pulls the layers apart (0 puts them back); the camera backs off to keep the stack in frame.+- `ui view --quantity river`: each net lifted to its voltage, water over it, loads as waterfalls, sources as fountains, a switching node as a slowed tide. Signs give each net's voltage and drop; the readout gives the volts under the cursor.+- `--speed 0.1..4` slows or quickens both. The badge states the scale and the exaggeration; say it when you narrate.++Tell the solve the voltages it cannot read from names, in the spec:++```json+"voltages": {"VIN": 12, "VOUT": 5},+"sources": {"VOUT": ["L1"], "SW": ["U1"]},+"switching": {"SW": {"highV": 12, "lowV": 0, "freqHz": 500000}}+```++Names give GND/PGND/AGND/VSS 0, 3V3 3.3, +12V 12, 5V_AUX 5; SW, LX and PH are switching nodes between the highest rail and 0 unless the spec says otherwise. Default sense: a supply flows from its connector to its parts, a ground from its parts back to its connector; `sources` flips a net whose feed is a part (a regulator or inductor output). A net with no rail stays at its drop and says "rail not given". The voltages are DC: the rail plus the solved IR drop. Copper the pinned terminal cannot reach (a floating island) sits still at the rail.++Developing the page: `ADOM_FIELDS_UI_DIR=<repo>/src/ui adom-fields serve ...` serves index.html, app.js and flow.js from disk, so a reload shows a change without a rebuild.++## Energy and light++**Energy** (`ui view --quantity energy --explode 0.6`) shows where the energy really travels: pink particles in the board material between the copper layers, along the Poynting vector S = E × H. Between two layers a gap h apart, E is (V_top − V_bottom)/h and the two current sheets give H, so S = ½ (V_top − V_bottom)/h · (K_top − K_bottom) in W/mm²; over a supply and its return that integrates to V × I. Pink rising into a part is that part taking its energy. The blue dots are the electrons in the copper at their real drift speed (J / n e, 0.073 mm/s per A/mm²), moving against the current. DC, inside the board only: part of the energy travels in the air around it.++**Light** (`ui view --quantity light`) colours each piece of copper by how fast a change travels in its field, as a % of the speed of light: 1/√εr_eff, all FR4 between planes (about 48 %), Hammerstad's εr_eff for a trace over a plane (wider is slower), mostly air with no plane under it. A switch-on front sweeps out from each fed net's source, and each load is tagged with its arrival time in picoseconds (the fastest path, the sum of length over speed along the way). εr comes from the spec's signals epsilonR, else 4.3. A switching node's DC average (`switching.SW.meanV`) sets its level in the energy view.+ ## Honest limits (0.1)  DC only (no skin effect, no switching loss on the copper); per-layer thickness when copperUmByLayer is supplied; the 1.6 mm stack is 0.2 / 1.065 / 0.2 mm unless `stackupMm` says otherwise; convection is a flat coefficient on both faces (10 or 25 W/m²K), optional specified rectangular conducting blocks; no fins, enclosure or radiation; the tab watts come from the spec, not from a switching model. It is a screen that finds the necks and the starved tabs, and shows them; it is not a datasheet number.
src/fields.rs+57−5
@@ -25,6 +25,12 @@ pub struct Spec {     #[serde(default)] pub signals: crate::signals::Config,     #[serde(default)] pub heatsinks: Vec<crate::heatsink::Block>,     #[serde(default)] pub stackupMm: Vec<f64>,+    /// each net's rail voltage for the river view; nets not named here are read from their names (GND 0, 3V3 3.3, +12V 12)+    #[serde(default)] pub voltages: BTreeMap<String, f64>,+    /// the parts a net's current flows OUT of, when that is not the connector side (a regulator output: "VOUT": ["L1"])+    #[serde(default)] pub sources: BTreeMap<String, Vec<String>>,+    /// switching nodes: the copper that swings between two rails ("SW": {"highV": 12, "lowV": 0, "freqHz": 500000})+    #[serde(default)] pub switching: BTreeMap<String, crate::flow::Switching>, } fn d35() -> f64 { 35.0 } fn d60() -> f64 { 60.0 }@@ -52,6 +58,10 @@ pub struct Fields {     pub heatsinks: Vec<Value>,     pub signals: Value,     pub heat_residual: f64, pub heat_moving_residual: f64,+    /// the signed current, the solved potential and the terminals of each loaded net, for the flow and river views+    pub flow: BTreeMap<String, crate::flow::NetFlow>,+    /// which net owns each cell, per layer (index into `net_names`, u16::MAX where there is no copper)+    pub net_ids: Vec<Vec<u16>>, pub net_names: Vec<String>, }  pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMut(&str)) -> Fields {@@ -72,6 +82,7 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu     let mut j_all: Vec<Grid> = LAYERS.iter().map(|_| Grid::new(w, h)).collect();     let mut p_joule:Vec<Grid> = LAYERS.iter().map(|_|Grid::new(w,h)).collect();     let mut nets: Vec<NetResult> = Vec::new();+    let mut flow: BTreeMap<String, crate::flow::NetFlow> = BTreeMap::new();     for (net, load) in &spec.loads {         let fr: Vec<Grid> = LAYERS.iter().map(|l| ras.fraction(net, l, pool)).collect();         let live: Vec<usize> = (0..LAYERS.len()).filter(|k| fr[*k].sum() > 0.0).collect();@@ -137,13 +148,15 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu         };         let (mut hs, mut hk) = (0usize, 0usize);         let per_ext = load.amps / ext.len() as f64;-        for p in &ext { let sp = spots_of(p); if sp.is_empty() { continue; } hs += 1; for (a, r, c) in &sp { rhs[a * n + r * w + c] += per_ext / sp.len() as f64; } }-        let mut pinned = false;+        let (mut ext_spots, mut chip_spots, mut terminals): (Vec<usize>, Vec<usize>, Vec<crate::flow::Terminal>) = (Vec::new(), Vec::new(), Vec::new());+        for p in &ext { let sp = spots_of(p); if sp.is_empty() { continue; } hs += 1; for (a, r, c) in &sp { rhs[a * n + r * w + c] += per_ext / sp.len() as f64; ext_spots.push(a * n + r * w + c); } terminals.push(crate::flow::Terminal { reference: p.reference.clone(), pad: p.pad.clone(), x: p.x, y: p.y, amps: per_ext, side: "ext".into() }); }+        let mut pinned = false; let mut pinned_at: Option<usize> = None;         for p in &chips {             let share = load.amps * area_by_ref[&p.reference] / area_total / chips.iter().filter(|q| q.reference == p.reference).count() as f64;             let sp = spots_of(p); if sp.is_empty() { continue; } hk += 1;-            for (a, r, c) in &sp { rhs[a * n + r * w + c] -= share / sp.len() as f64; }-            if !pinned { let (a, r, c) = sp[0]; sys.leak[a][r * w + c] = 1e3; pinned = true; }+            for (a, r, c) in &sp { rhs[a * n + r * w + c] -= share / sp.len() as f64; chip_spots.push(a * n + r * w + c); }+            terminals.push(crate::flow::Terminal { reference: p.reference.clone(), pad: p.pad.clone(), x: p.x, y: p.y, amps: share, side: "chip".into() });+            if !pinned { let (a, r, c) = sp[0]; sys.leak[a][r * w + c] = 1e3; pinned = true; pinned_at = Some(a * n + r * w + c); }         }         if hs == 0 || hk == 0 { log(&format!("{net}: terminals not on copper (sources {hs}, sinks {hk}), skipped")); continue; }         let (v, it, res) = sys.solve(&rhs, 1e-7, 6000);@@ -151,11 +164,31 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu         let mut grids: Vec<Grid> = LAYERS.iter().map(|_| Grid::new(w, h)).collect();         let (mut peak, mut peak_at, mut peak_layer) = (0.0f64, [0.0, 0.0], String::new());         let mut joule = 0.0;+        let mut nf = crate::flow::NetFlow::new(w, h, LAYERS.len());+        // the copper the terminals actually reach: an island of the net with no path to them floats in the+        // solve (its potential is whatever the solver left), so the flow and river views keep only this+        let mut reach = vec![false; nl * n];+        let mut stack: Vec<usize> = pinned_at.into_iter().collect();+        for i in &stack { reach[*i] = true; }+        while let Some(i) = stack.pop() {+            let (a, cell) = (i / n, i % n); let (r, c) = (cell / w, cell % w);+            let mut nb: Vec<(usize, f64)> = Vec::with_capacity(6);+            if c + 1 < w { nb.push((i + 1, sys.gx[a][r * (w - 1) + c])); }+            if c > 0 { nb.push((i - 1, sys.gx[a][r * (w - 1) + c - 1])); }+            if r + 1 < h { nb.push((i + w, sys.gy[a][r * w + c])); }+            if r > 0 { nb.push((i - w, sys.gy[a][(r - 1) * w + c])); }+            if a + 1 < nl { nb.push((i + n, sys.gz[a][cell])); }+            if a > 0 { nb.push((i - n, sys.gz[a - 1][cell])); }+            for (j, g) in nb { if g > 0.0 && !reach[j] { reach[j] = true; stack.push(j); } }+        }         for (a, k) in live.iter().enumerate() {             let f = &fr[*k];             let mut cx = vec![0.0f64; n]; let mut cy = vec![0.0f64; n];             for r in 0..h { for c in 0..w - 1 { let g = sys.gx[a][r * (w - 1) + c]; let i = g * (v[a * n + r * w + c] - v[a * n + r * w + c + 1]); cx[r * w + c] += i / 2.0; cx[r * w + c + 1] += i / 2.0; } }             for r in 0..h - 1 { for c in 0..w { let g = sys.gy[a][r * w + c]; let i = g * (v[a * n + r * w + c] - v[a * n + (r + 1) * w + c]); cy[r * w + c] += i / 2.0; cy[(r + 1) * w + c] += i / 2.0; } }+            // the signed current density and the potential on this net's copper, in the solve's own sense+            // (from the connector side to the parts); flow.rs turns it into the physical sense and volts+            for i in 0..n { let fi = f.v[i] as f64; if fi > 0.05 && reach[a * n + i] { let s = d * fi.max(0.25) * t_cu[*k]; nf.jx[*k].v[i] = (cx[i] / s) as f32; nf.jy[*k].v[i] = (cy[i] / s) as f32; nf.v[*k].v[i] = v[a * n + i] as f32; nf.on[*k].v[i] = 1.0; } }             let mut vals: Vec<f32> = Vec::new();             for i in 0..n {                 let fi = f.v[i] as f64;@@ -176,10 +209,29 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu             j_all[*k].max_with(&grids[*k]);         }         let copper: f64 = live.iter().map(|k| fr[*k].sum() * d * d).sum();+        // the current down each via barrel, between the live layers it joins (A, from the upper layer down)+        for a in 0..nl.saturating_sub(1) { for i in 0..n { let g = sys.gz[a][i]; if g > 0.0 && reach[a * n + i] { let amps = g * (v[a * n + i] - v[(a + 1) * n + i]); if amps.abs() > 1e-4 * load.amps { nf.vias.push((live[a], live[a + 1], i, amps)); } } } }+        // a terminal's spots on copper the pinned terminal cannot reach (a through-hole pad's ring on an inner+        // layer, before barrels are modelled) float: only the reached ones say the terminal's potential+        let mean = |s: &[usize]| { let r: Vec<f64> = s.iter().filter(|i| reach[**i]).map(|i| v[*i]).collect(); if r.is_empty() { 0.0 } else { r.iter().sum::<f64>() / r.len() as f64 } };+        nf.v_ext = mean(&ext_spots); nf.v_chip = mean(&chip_spots); nf.terminals = terminals; nf.amps = load.amps;+        flow.insert(net.clone(), nf);         nets.push(NetResult { net: net.clone(), amps: load.amps, layers: live.iter().map(|k| LAYERS[*k].to_string()).collect(), peakJ: (peak * 10.0).round() / 10.0, peakAt: peak_at, peakLayer: peak_layer, copperMm2: (copper * 10.0).round() / 10.0, jouleW: (joule * 1000.0).round() / 1000.0, vias: nvias, iterations: it });         j_net.insert(net.clone(), grids);     }     let joule_total: f64 = nets.iter().map(|x| x.jouleW).sum();+    // which net owns each cell: the river lifts every net's copper to its own voltage+    let mut net_names: Vec<String> = Vec::new();+    let mut net_ids: Vec<Vec<u16>> = LAYERS.iter().map(|_| vec![u16::MAX; n]).collect();+    for (k, l) in LAYERS.iter().enumerate() {+        let mut best = vec![0.05f32; n];+        for net in ras.nets_on(l) {+            if net.is_empty() { continue; }+            let g = ras.fraction(&net, l, pool);+            let id = match net_names.iter().position(|x| *x == net) { Some(i) => i, None => { net_names.push(net.clone()); net_names.len() - 1 } } as u16;+            for i in 0..n { if g.v[i] > best[i] { best[i] = g.v[i]; net_ids[k][i] = id; } }+        }+    }     // every net with real copper on the board, so the walkthrough can show one pour at a time     let mut cu_net: BTreeMap<String, Vec<Grid>> = BTreeMap::new();     for l in LAYERS { for net in ras.nets_on(l) { if cu_net.contains_key(&net) { continue; }@@ -290,7 +342,7 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu         }     }     let _ = (tab_total, joule_total);-    Fields { signals:crate::signals::analyze(b,&ras,&spec.signals,&spec.copperUmByLayer,spec.copperUm), heatsinks, heat_residual:res, heat_moving_residual:res2, d, h, w, x0: b.x0, y0: b.y0, frac, j_net, cu_net, j_all, q, t, t_moving, nets, chips, issues, joule_total: (joule_total * 1000.0).round() / 1000.0, tab_total }+    Fields { flow, net_ids, net_names, signals:crate::signals::analyze(b,&ras,&spec.signals,&spec.copperUmByLayer,spec.copperUm), heatsinks, heat_residual:res, heat_moving_residual:res2, d, h, w, x0: b.x0, y0: b.y0, frac, j_net, cu_net, j_all, q, t, t_moving, nets, chips, issues, joule_total: (joule_total * 1000.0).round() / 1000.0, tab_total } }  /// The summary the app and the page read.
src/flow.rsadded+174
@@ -0,0 +1,174 @@+//! The flow and river data: the current as a signed vector on every cell, in the physical sense (from+//! where it enters the net to where it leaves), the voltage on every cell of copper (the net's rail plus+//! the IR drop the solve found), the current down each via barrel, and each net's terminals. One binary+//! file the app streams straight into its particle and terrain buffers, and the metadata that reads it.+use crate::board::{Board, LAYERS};+use crate::fields::{Fields, Spec};+use crate::raster::Grid;+use serde::{Deserialize, Serialize};+use serde_json::{json, Value};+use std::io::Write;+use std::path::Path;++/// A switching node: copper that swings between two rails (a buck's SW). The river shows it as a tide.+#[derive(Deserialize, Serialize, Clone, Debug)]+pub struct Switching { pub highV: f64, #[serde(default)] pub lowV: f64, #[serde(default)] pub freqHz: Option<f64>, /** the node's DC average (a buck's SW averages its output voltage); the energy view uses it, the midpoint otherwise */ #[serde(default)] pub meanV: Option<f64> }++#[derive(Serialize, Clone, Debug)]+pub struct Terminal { pub reference: String, pub pad: String, pub x: f64, pub y: f64, pub amps: f64, pub side: String }++/// One loaded net as solved: current density (A/mm², the solve's sense), potential (V, arbitrary zero),+/// the cells it covers, the via currents (from layer, to layer, cell, amps), the mean potential at each+/// terminal side and the terminals.+pub struct NetFlow { pub jx: Vec<Grid>, pub jy: Vec<Grid>, pub v: Vec<Grid>, pub on: Vec<Grid>, pub vias: Vec<(usize, usize, usize, f64)>, pub v_ext: f64, pub v_chip: f64, pub terminals: Vec<Terminal>, pub amps: f64 }++impl NetFlow {+    pub fn new(w: usize, h: usize, nl: usize) -> NetFlow {+        let g = || (0..nl).map(|_| Grid::new(w, h)).collect::<Vec<_>>();+        NetFlow { jx: g(), jy: g(), v: g(), on: g(), vias: Vec::new(), v_ext: 0.0, v_chip: 0.0, terminals: Vec::new(), amps: 0.0 }+    }+}++pub fn is_ground(name: &str) -> bool {+    let u = name.trim_start_matches(['+', '-', '/']).to_uppercase();+    ["GND", "PGND", "AGND", "DGND", "SGND", "GNDA", "GNDD", "VSS", "0V", "EARTH", "CHASSIS"].contains(&u.as_str()) || u.starts_with("GND_") || u.ends_with("_GND") || u.ends_with("GND")+}++pub fn is_switch_name(name: &str) -> bool {+    let u = name.trim_start_matches(['+', '-', '/']).to_uppercase();+    ["SW", "LX", "PH", "SW_NODE", "SWNODE", "SW1", "SW2", "LX1", "LX2"].contains(&u.as_str())+}++/// The rail a net's name says: GND 0, 3V3 3.3, +12V 12, 5V_AUX 5, VCC_1V8 1.8. None when the name does not say.+pub fn rail_from_name(name: &str) -> Option<f64> {+    if is_ground(name) { return Some(0.0); }+    let u: Vec<char> = name.to_uppercase().chars().collect();+    for i in 0..u.len() {+        if u[i] != 'V' || i == 0 || !u[i - 1].is_ascii_digit() { continue; }+        let mut s = i; while s > 0 && u[s - 1].is_ascii_digit() { s -= 1; }+        // a digit run glued to letters on its left (VCC2V5 is fine, R12V is not a rail)+        if s > 0 && u[s - 1].is_ascii_alphabetic() && u[s - 1] != 'C' && u[s - 1] != 'D' && u[s - 1] != 'A' { continue; }+        let whole: String = u[s..i].iter().collect();+        let mut e = i + 1; while e < u.len() && u[e].is_ascii_digit() { e += 1; }+        let frac: String = u[i + 1..e].iter().collect();+        let v: f64 = if frac.is_empty() { whole.parse().ok()? } else { format!("{whole}.{frac}").parse().ok()? };+        if v > 0.0 && v <= 1000.0 { return Some(v); }+    }+    None+}++/// Write `<out>/flow.bin` and return the metadata that reads it (also stored as fields.json `flow`).+pub fn write(out: &Path, f: &Fields, b: &Board, spec: &Spec) -> Result<Value, String> {+    let (w, h, n) = (f.w, f.h, f.w * f.h);+    let nl = LAYERS.len();+    let known_rails: Vec<f64> = f.net_names.iter().filter_map(|nm| spec.voltages.get(nm).copied().or_else(|| rail_from_name(nm))).collect();+    let top_rail = known_rails.iter().cloned().fold(0.0, f64::max);+    let mut jx: Vec<Vec<f32>> = (0..nl).map(|_| vec![0.0; n]).collect();+    let mut jy = jx.clone();+    let mut volt: Vec<Vec<f32>> = (0..nl).map(|_| vec![f32::NAN; n]).collect();+    let mut netf: Vec<Vec<f32>> = (0..nl).map(|_| vec![-1.0; n]).collect();+    let mut metas: Vec<Value> = Vec::new();+    let (mut jmax, mut vmin, mut vmax) = (0.0f64, f64::MAX, f64::MIN);+    for (id, name) in f.net_names.iter().enumerate() {+        let switching = spec.switching.get(name).cloned().or_else(|| if is_switch_name(name) && !spec.voltages.contains_key(name) && top_rail > 0.0 { Some(Switching { highV: top_rail, lowV: 0.0, freqHz: None, meanV: None }) } else { None });+        let rail = spec.voltages.get(name).copied().or_else(|| rail_from_name(name));+        let ground = rail == Some(0.0);+        for k in 0..nl { for i in 0..n { if f.net_ids[k][i] as usize == id { netf[k][i] = id as f32; } } }+        let Some(nf) = f.flow.get(name) else {+            // copper that carries no solved current: a still pond at its rail+            if let (Some(r), None) = (rail, &switching) { for k in 0..nl { for i in 0..n { if f.net_ids[k][i] as usize == id { volt[k][i] = r as f32; } } } vmin = vmin.min(r); vmax = vmax.max(r); }+            metas.push(json!({"name": name, "railV": rail, "loaded": false, "switching": switching}));+            continue;+        };+        // the physical sense: the solve drives current from the connector side (ext) to the parts (chip);+        // a ground returns the other way, and the spec's `sources` names a part side that feeds the net+        let refs_on = |side: &str, list: &[String]| nf.terminals.iter().any(|t| t.side == side && list.contains(&t.reference));+        let mut note: Option<String> = None;+        let sign: f64 = match spec.sources.get(name) {+            Some(list) if refs_on("chip", list) => -1.0,+            Some(list) if refs_on("ext", list) => 1.0,+            Some(list) => { note = Some(format!("sources {:?} are not terminals of {name}; kept the default sense", list)); if ground { -1.0 } else { 1.0 } }+            None => if ground { -1.0 } else { 1.0 },+        };+        // the voltage is the rail where the net is anchored: at its source for a supply, at the connector for a ground+        let anchor_ext = if ground { true } else { sign > 0.0 };+        let v_anchor = if anchor_ext { nf.v_ext } else { nf.v_chip };+        let base = if switching.is_some() { 0.0 } else { rail.unwrap_or(0.0) };+        let (mut lo, mut hi) = (f64::MAX, f64::MIN);+        // the net's copper the terminals do not reach carries nothing: it sits still at the anchor's level+        for k in 0..nl { for i in 0..n { if f.net_ids[k][i] as usize == id { volt[k][i] = base as f32; } } }+        for k in 0..nl { for i in 0..n {+            if nf.on[k].v[i] == 0.0 { continue; }+            let vx = base + sign * (nf.v[k].v[i] as f64 - v_anchor);+            if f.net_ids[k][i] as usize == id { volt[k][i] = vx as f32; }+            jx[k][i] += (sign * nf.jx[k].v[i] as f64) as f32; jy[k][i] += (sign * nf.jy[k].v[i] as f64) as f32;+            jmax = jmax.max((nf.jx[k].v[i] as f64).hypot(nf.jy[k].v[i] as f64));+            lo = lo.min(vx); hi = hi.max(vx);+        } }+        if switching.is_none() && rail.is_some() { vmin = vmin.min(lo); vmax = vmax.max(hi); }+        let source_side = if sign > 0.0 { "ext" } else { "chip" };+        let terminals: Vec<Value> = nf.terminals.iter().map(|t| json!({"ref": t.reference, "pad": t.pad, "x": t.x, "y": t.y, "amps": t.amps, "role": if t.side == source_side { "source" } else { "sink" }})).collect();+        let refs = |src: bool| { let mut v: Vec<String> = nf.terminals.iter().filter(|t| (t.side == source_side) == src).map(|t| t.reference.clone()).collect(); v.sort(); v.dedup(); v.join(", ") };+        let sense = format!("from {} to {}", refs(true), refs(false));+        let vias: Vec<Value> = nf.vias.iter().map(|(a, bb, i, amps)| json!({"x": f.x0 + ((i % w) as f64 + 0.5) * f.d, "y": f.y0 + ((i / w) as f64 + 0.5) * f.d, "from": LAYERS[*a], "to": LAYERS[*bb], "amps": sign * amps})).collect();+        metas.push(json!({"name": name, "railV": rail, "loaded": true, "amps": nf.amps, "sense": sense, "ground": ground, "switching": switching,+            "dropV": if lo <= hi { hi - lo } else { 0.0 }, "voltRange": [lo, hi], "voltKnown": rail.is_some() || switching.is_some(), "terminals": terminals, "vias": vias, "note": note}));+    }+    if vmin > vmax { vmin = 0.0; vmax = 1.0; }+    let path = out.join("flow.bin");+    let file = std::fs::File::create(&path).map_err(|e| format!("{}: {e}", path.display()))?;+    let mut wr = std::io::BufWriter::new(file);+    for k in 0..nl { for arr in [&jx[k], &jy[k], &volt[k], &netf[k]] { for x in arr.iter() { wr.write_all(&x.to_le_bytes()).map_err(|e| e.to_string())?; } } }+    wr.flush().map_err(|e| e.to_string())?;+    Ok(json!({+        "file": "flow.bin", "cols": w, "rows": h, "cellMm": f.d, "x0": b.x0, "y0": b.y0, "layers": LAYERS,+        "layout": "for each layer in order: jx, jy (A/mm², physical sense, +x right, +y down the board as KiCad), volt (V; NaN off copper or where the rail is unknown), net (index into nets, -1 off copper); each float32 little-endian, rows*cols, row-major from (x0, y0)",+        "nets": metas, "jMax": jmax, "voltMin": vmin, "voltMax": vmax, "topRailV": top_rail,+        "method": "The DC solve per loaded net, signed: current enters at the source terminals and leaves at the sinks (a ground returns to its connector; the spec's `sources` names the part a net is fed from). Voltage = the net's rail at its anchor plus the solved IR drop; the rail comes from the spec's `voltages` or the net's name. A switching node is drawn as a tide between its rails at a slowed, stated rate."+    }))+}++#[cfg(test)] mod tests {+    use super::*;+    #[test] fn rails_from_names() {+        assert_eq!(rail_from_name("GND"), Some(0.0)); assert_eq!(rail_from_name("PGND"), Some(0.0));+        assert_eq!(rail_from_name("+12V"), Some(12.0)); assert_eq!(rail_from_name("3V3"), Some(3.3));+        assert_eq!(rail_from_name("5V_AUX"), Some(5.0)); assert_eq!(rail_from_name("VCC_1V8"), Some(1.8));+        assert_eq!(rail_from_name("VIN"), None); assert_eq!(rail_from_name("V_GATE"), None); assert_eq!(rail_from_name("SW"), None);+        assert!(is_switch_name("SW")); assert!(!is_switch_name("SWDIO"));+    }++    /// Two strips from a connector J1 (left) to a chip U1 (right): +5V is fed from J1, GND returns to J1.+    #[test] fn supply_flows_from_its_source_and_ground_returns_to_its_connector() {+        use crate::board::{Board, Pad, Poly};+        let mut b = Board { x0: 0., y0: 0., x1: 20., y1: 6., ..Board::default() };+        let strip = |y0: f64, y1: f64| vec![(1., y0), (19., y0), (19., y1), (1., y1)];+        b.polys.push(Poly { net: "+5V".into(), layer: "F.Cu".into(), pts: strip(0.5, 2.5) });+        b.polys.push(Poly { net: "GND".into(), layer: "F.Cu".into(), pts: strip(3.5, 5.5) });+        for (r, x) in [("J1", 1.5), ("U1", 18.5)] { for (net, y) in [("+5V", 1.5), ("GND", 4.5)] {+            b.pads.push(Pad { reference: r.into(), pad: "1".into(), net: net.into(), x, y, layers: vec!["F.Cu".into()], area: 1. });+        } }+        let spec: Spec = serde_json::from_value(json!({"copperUm": 35, "loads": {"+5V": {"amps": 2}, "GND": {"amps": 2}}})).unwrap();+        let f = crate::fields::analyze(&b, &spec, 0.25, 60., &mut |_| {});+        let dir = std::env::temp_dir().join(format!("adom-fields-flow-test-{}", std::process::id()));+        std::fs::create_dir_all(&dir).unwrap();+        let meta = write(&dir, &f, &b, &spec).unwrap();+        let bytes = std::fs::read(dir.join("flow.bin")).unwrap();+        let vals: Vec<f32> = bytes.chunks(4).map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect();+        let (w, n) = (f.w, f.w * f.h);+        let at = |arr: usize, x: f64, y: f64| vals[arr * n + ((y / f.d) as usize) * w + (x / f.d) as usize];+        let (jx, volt) = (0, 2);   // layer F.Cu: jx, jy, volt, net+        // +5V: current runs left to right, the voltage is 5 V at J1 and falls toward U1+        assert!(at(jx, 10., 1.5) > 0.0);+        let (v_src, v_mid, v_load) = (at(volt, 1.6, 1.5) as f64, at(volt, 10., 1.5) as f64, at(volt, 18.4, 1.5) as f64);+        assert!((v_src - 5.0).abs() < 1e-3 && v_src > v_mid && v_mid > v_load && v_load > 4.9, "{v_src} {v_mid} {v_load}");+        // GND: the same current comes back right to left, 0 V at the connector and above it under the chip+        assert!(at(jx, 10., 4.5) < 0.0);+        let (g_conn, g_chip) = (at(volt, 1.6, 4.5) as f64, at(volt, 18.4, 4.5) as f64);+        assert!(g_conn.abs() < 1e-3 && g_chip > g_conn, "{g_conn} {g_chip}");+        let net = |name: &str| meta["nets"].as_array().unwrap().iter().find(|x| x["name"] == name).cloned().unwrap();+        assert_eq!(net("+5V")["sense"], "from J1 to U1"); assert_eq!(net("GND")["sense"], "from U1 to J1");+        assert!((net("+5V")["dropV"].as_f64().unwrap() - (v_src - v_load)).abs() < 2e-3);+        std::fs::remove_dir_all(&dir).ok();+    }+}
src/main.rs+7−3
@@ -1,6 +1,6 @@ //! adom-fields: where the current and the heat go on your board. use sha2::{Digest, Sha256};-mod eagle; mod parts; mod signals; mod heatsink; mod board; mod fields; mod raster; mod sexp; mod solve; mod tex; mod serve; mod vendor;+mod eagle; mod flow; mod parts; mod signals; mod heatsink; mod board; mod fields; mod raster; mod sexp; mod solve; mod tex; mod serve; mod vendor; use clap::{Parser, Subcommand}; use std::path::PathBuf; @@ -22,7 +22,7 @@ enum Cmd {     Parts { #[arg(long)] board: String, #[arg(long, default_value = "fields")] fields: PathBuf, #[arg(long = "models-dir")] models_dir: Vec<PathBuf> },     /// Serve the app for a solved board (or for a board and spec, solving first): the 3D board with the fields on it, the nets by relevance, the issues, and the live channel the AI drives     Serve { #[arg(long, default_value = "fields")] fields: PathBuf, #[arg(long, default_value_t = 8874)] port: u16, #[arg(long)] board: Option<String>, #[arg(long)] spec: Option<String> },-    /// Drive the running app: `ui view --quantity current|heat|temperature|signals [--net X] [--layer F.Cu] [--camera top|iso|bottom|fit|zoom --at x,y] [--parts auto|ghost|solid|off]`, `ui toast "..."`, `ui select --net X`, `ui issue --index 0`+    /// Drive the running app: `ui view --quantity current|heat|temperature|signals|flow|river [--explode 0..1] [--speed 1] [--net X] [--layer F.Cu] [--camera top|iso|bottom|fit|zoom --at x,y] [--parts auto|ghost|solid|off]`, `ui toast "..."`, `ui select --net X`, `ui issue --index 0`     Ui { #[command(subcommand)] what: UiCmd, #[arg(long, default_value_t = 8874)] port: u16 },     /// A scripted walkthrough over the live channel: the nets by relevance, each field, the issues; for the AI's video step     Tour { #[arg(long, default_value_t = 8874)] port: u16, /// milliseconds to hold each beat while the board drifts (camera moves take up to 18 s since 0.5); gentle on purpose, the flow speeds the clip up ten times@@ -37,7 +37,10 @@ enum Cmd { enum UiCmd {     View { #[arg(long)] quantity: Option<String>, #[arg(long)] net: Option<String>, #[arg(long)] layer: Option<String>, #[arg(long)] camera: Option<String>, #[arg(long)] air: Option<String>, /// the parts' 3D bodies over the fields: ghost (see-through), solid, off, or auto (ghost on every field but signals)     #[arg(long)] parts: Option<String>, /// with --camera zoom: fly to this board point, "x,y" in mm (KiCad coordinates)-    #[arg(long)] at: Option<String> },+    #[arg(long)] at: Option<String>, /// pull the layer stack apart, 0 (together) to 1 (fully apart): the inner copper and the current down the vias show+    #[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> },     Toast { text: String, #[arg(long, default_value = "info")] kind: String },     Select { #[arg(long)] net: Option<String>, #[arg(long)] chip: Option<String> },     Issue { #[arg(long)] index: usize },@@ -111,6 +114,7 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck         }     }     let mut summary = fields::summary(&f, &b, &spec, &textures);+    summary["flow"] = flow::write(out, &f, &b, &spec)?;     summary["board"]["name"] = serde_json::json!(spec.board.split('(').next().unwrap_or(&spec.board).trim());     summary["board"]["description"] = serde_json::json!(spec.board);     summary["log"] = serde_json::json!(lines);
src/serve.rs+28−6
@@ -21,11 +21,17 @@ fn http(port: u16, method: &str, path: &str, body: Option<&Value>) -> Result<Val  struct App { dir: PathBuf, fields: Value, view: Value, console: Vec<String>, clients: Vec<std::sync::mpsc::Sender<String>>, started: String, seq: u64 } +/// The UI's own files, built into the binary; ADOM_FIELDS_UI_DIR=<src/ui> serves them from disk instead, so a+/// change to the page shows on reload without a rebuild (development only)+fn ui_file(name: &str, built_in: &'static str) -> String {+    match std::env::var("ADOM_FIELDS_UI_DIR") { Ok(dir) if !dir.is_empty() => std::fs::read_to_string(std::path::Path::new(&dir).join(name)).unwrap_or_else(|_| built_in.to_string()), _ => built_in.to_string() }+}+ fn now() -> String { let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); format!("{t}") }  pub fn serve(dir: &PathBuf, port: u16) {     let fields: Value = std::fs::read_to_string(dir.join("fields.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_else(|| crate::err(&format!("no fields.json in {}", dir.display()), &["Run `adom-fields analyze --board <pcb> --spec <json> --out <dir>` first, or pass --board and --spec to serve.".into()]));-    let app = Arc::new(Mutex::new(App { dir: dir.clone(), fields, view: json!({"quantity": "current", "net": null, "layer": null, "camera": "iso", "air": "still", "issue": null, "chip": null, "drift": true, "parts": "auto", "seq": 0, "changed": []}), console: Vec::new(), clients: Vec::new(), started: now(), seq: 0 }));+    let app = Arc::new(Mutex::new(App { dir: dir.clone(), fields, view: json!({"quantity": "current", "net": null, "layer": null, "camera": "iso", "air": "still", "issue": null, "chip": null, "drift": true, "parts": "auto", "explode": 0, "speed": 1, "seq": 0, "changed": []}), console: Vec::new(), clients: Vec::new(), started: now(), seq: 0 }));     let server = tiny_http::Server::http(("0.0.0.0", port)).unwrap_or_else(|e| crate::err(&format!("cannot listen on {port}: {e}"), &[format!("Another adom-fields may be up: `adom-fields health --port {port}`; or pick --port.")]));     crate::ok(&format!("adom-fields serving on http://127.0.0.1:{port}/ ({})", app.lock().unwrap().fields["board"]["name"].as_str().unwrap_or("")), &["Open it from outside the container through the proxy: https://<slug>.adom.cloud/proxy/".to_string() + &port.to_string() + "/ (pup, the browser, or a Hydrogen webview).", "Drive it: `adom-fields ui view --quantity heat --layer F.Cu`, `ui select --net +VBAT`, `ui issue --index 0`, `ui toast \"...\"`; `adom-fields tour` walks the whole thing.".into(), "GET /state, GET /console, GET /events (SSE) for the AI; POST /ui/view, /ui/toast, /ui/select, /ui/issue.".into()]);     for req in server.incoming_requests() {@@ -49,10 +55,16 @@ fn handle(mut req: tiny_http::Request, app: Arc<Mutex<App>>) {     let cors = tiny_http::Header::from_bytes("Access-Control-Allow-Origin", "*").unwrap();     let reply = |req: tiny_http::Request, code: u16, v: Value| { let _ = req.respond(tiny_http::Response::from_string(v.to_string()).with_status_code(code).with_header(jsonh.clone()).with_header(cors.clone())); };     match (method.as_str(), path.as_str()) {-        ("GET", "/") | ("GET", "/index.html") => { let _ = req.respond(tiny_http::Response::from_string(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(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", "/") | ("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", "/flow.bin") => {+            // the signed current, the voltage and the owning net of every cell, for the flow and river views+            let file = app.lock().unwrap().dir.join("flow.bin");+            match std::fs::read(&file) { Ok(bytes) => { let _ = req.respond(tiny_http::Response::from_data(bytes).with_header(tiny_http::Header::from_bytes("Content-Type", "application/octet-stream").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); } Err(_) => reply(req, 404, json!({"status": "error", "error": "no flow.bin: this solve predates the flow views", "hint": "Run `adom-fields analyze` again with this version."})) }+        }         ("GET", "/health") => reply(req, 200, json!({"status": "ok", "version": env!("CARGO_PKG_VERSION")})),-        ("GET", "/state") => { let a = app.lock().unwrap(); reply(req, 200, json!({"status": "ok", "view": a.view, "board": a.fields["board"], "nets": a.fields["nets"].as_array().map(|x| x.len()).unwrap_or(0), "issues": a.fields["issues"].as_array().map(|x| x.len()).unwrap_or(0), "clients": a.clients.len(), "started": a.started, "_hint": "POST /ui/view {quantity, 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), 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();@@ -86,7 +98,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"] { 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"] { 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"); } } }             }@@ -129,7 +141,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 } => { 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"), &[]); } } let r = up("/ui/view", &json!({"quantity": quantity, "net": net, "layer": layer, "camera": camera, "air": air, "parts": parts, "focus": focus})); 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 } => { 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"].contains(&q.as_str()) { crate::err(&format!("--quantity {q}: use current, heat, temperature, signals, flow, river, energy or light"), &[]); } } 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})); 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::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"]), &[]); }         UiCmd::Issue { index } => { let r = up("/ui/issue", &json!({"index": index})); crate::ok(&format!("issue {index} in view: {}", r["view"]), &[]); }@@ -157,6 +169,16 @@ pub fn tour(port: u16, hold_ms: u64) {     for net in pours.iter().take(3) { toast(&format!("{net} on the bottom")); step("/ui/select", json!({"net": net}), settle); steps += 1; }     toast("Temperature over the whole board, still air"); step("/ui/view", json!({"quantity": "temperature", "air": "still", "net": "", "layer": "F.Cu", "camera": "top"}), settle); steps += 1;     toast("The same board in moving air"); step("/ui/view", json!({"air": "moving"}), settle); steps += 1;+    if !f["flow"].is_null() {+        // the flow and the river (0.7): the same solve as moving charge, then as water over the voltages+        toast("The current as charge: speed and glow follow the density"); step("/ui/view", json!({"quantity": "flow", "air": "still", "net": "", "layer": "F.Cu", "camera": "iso", "explode": 0}), settle); steps += 1;+        toast("The layers pulled apart: the current drops through the vias"); step("/ui/view", json!({"explode": 1}), settle); steps += 1;+        toast("The river: every net at its voltage, the current running downhill"); step("/ui/view", json!({"quantity": "river", "explode": 0, "camera": "iso"}), settle); steps += 1;+        toast("The loads fall to ground and the sources lift it back up"); step("/ui/view", json!({"explode": 1}), settle); steps += 1;+        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;+        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;     for (i, is) in f["issues"].as_array().cloned().unwrap_or_default().iter().enumerate().take(2) { toast(&format!("Issue {}: {}", i + 1, is["what"].as_str().unwrap_or(""))); step("/ui/issue", json!({"index": i}), settle); steps += 1; }     step("/ui/view", json!({"camera": "iso", "issue": null, "net": ""}), settle);
src/ui/app.js+34−16
@@ -1,7 +1,7 @@ // Adom Fields: the board in 3D with the fields on its copper, driven by the AI over the live channel. (async () => {   const $ = (s) => document.querySelector(s);-  const state = { view: { quantity: 'current', net: null, layer: 'F.Cu', camera: 'iso', air: 'still', issue: null, chip: null, drift: true, focus: null, parts: 'auto' }, fields: null, raw: {}, tex: {}, layerMeshes: {}, light: false };+  const state = { view: { quantity: 'current', net: null, layer: 'F.Cu', camera: 'iso', air: 'still', issue: null, chip: null, drift: true, focus: null, parts: 'auto', explode: 0, speed: 1 }, fields: null, raw: {}, tex: {}, layerMeshes: {}, light: false };   // every rotation runs at half the speed it did in 0.4: the flow plays these clips ten times faster,   // and a sway that reads as calm live turned frenetic on the sped-up video   const SLOW = 2;@@ -138,15 +138,21 @@     console.log(`parts: ${n} models placed, ${parts.boxes.length} courtyard boxes${parts.boxes.length ? ' (' + parts.boxes.join(', ') + ')' : ''}`);     applyParts();   }+  // how see-through a ghosted part is: the view's own value (the AI sets it), else this viewer's slider, else 0.33+  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 === 'ghostAlpha') console.log('parts: ghost opacity set to ' + Math.round(e.target.value * 100) + '% by the viewer'); });   function partsMode() { const m = state.view.parts || 'auto'; if (m !== 'auto') return m; return state.view.quantity === 'signals' ? 'off' : 'ghost'; }   function applyParts() {     const mode = partsMode();+    syncGhostCtl();     const b = $('#btnParts'); if (b) { b.classList.toggle('is-on', mode !== 'off'); b.setAttribute('aria-pressed', mode !== 'off'); b.dataset.mode = mode; }     if (!parts.ready) return;     parts.root.setEnabled(mode !== 'off');     for (const mat of parts.mats) {       // ghost: the bodies stay readable while the field glows through them-      mat.alpha = mode === 'ghost' ? 0.42 : (mat._partAlpha0 ?? 1);+      mat.alpha = mode === 'ghost' ? ghostAlpha() : (mat._partAlpha0 ?? 1);       mat.transparencyMode = mode === 'ghost' ? BAB.Material.MATERIAL_ALPHABLEND : (mat._partAlpha0 < 1 ? BAB.Material.MATERIAL_ALPHABLEND : BAB.Material.MATERIAL_OPAQUE);     }     // the parts draw after the board and its field (group 1, the depth kept), so a see-through body@@ -204,7 +210,8 @@     // fit the board to the pane's own shape: a tall narrow pane is limited by its width, a wide one by its height     const vf = camera.fov || 0.8, aspect = scene.getEngine().getAspectRatio(camera) || 1;     const hf = 2 * Math.atan(Math.tan(vf / 2) * aspect);-    const R = Math.max((board.heightMm / 2) / Math.tan(vf / 2), (board.widthMm / 2) / Math.tan(hf / 2)) * 1.06;+    // an exploded stack is taller than the board is wide: back off so the whole stack stays in frame+    const R = Math.max((board.heightMm / 2) / Math.tan(vf / 2), (board.widthMm / 2) / Math.tan(hf / 2)) * 1.06 * (1 + 0.55 * Math.max(0, Math.min(1, +state.view.explode || 0)));     const home = BAB.Vector3.Zero();     const flipping = (which === 'bottom' && camera.beta < Math.PI / 2) || (which === 'top' && camera.beta > Math.PI / 2);     const s = flipping ? 2.6 : 9.0;   // the turn-over is quick, everything else is slow@@ -224,7 +231,8 @@   function texName(L) {     const q = state.view.quantity, ls = slug(L);     if (q === 'signals') return `copper-${ls}.png`;-    if (q === 'current') return state.view.net ? `J-${slug(state.view.net)}-${ls}.png` : `J-${ls}.png`;+    if (q === 'river' || q === 'energy' || q === 'light') 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';   }@@ -237,20 +245,24 @@       m.alpha = (L === selected) ? 1.0 : (state.view.quantity === 'temperature' ? 0.0 : 0.35);       if (L === 'In1.Cu' || L === 'In2.Cu') m.alpha = (L === selected) ? 1.0 : 0.0;     }-    if (viaRoot) viaRoot.setEnabled(state.view.quantity !== 'temperature');-    for (const { el, c } of labels) { el.hidden = ['current','signals'].includes(state.view.quantity); el.textContent = c.reference + '  +' + (state.view.air === 'moving' ? c.riseMovingC : c.riseC).toFixed(0) + ' °C'; }+    if (viaRoot) viaRoot.setEnabled(!['temperature', 'river'].includes(state.view.quantity));+    for (const { el, c } of labels) { el.hidden = ['current','signals','flow','river','energy','light'].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';     const sc = state.fields.scales, leg = $('#legend'); leg.classList.toggle('magma', state.view.quantity === 'temperature');-    if (state.view.quantity === 'current') { $('#legendTitle').textContent = 'Current density, A/mm² (log)'; $('#legLo').textContent = (sc.jHi / 300).toFixed(1); $('#legHi').textContent = sc.jHi.toFixed(0); }+    if (state.view.quantity === 'current' || state.view.quantity === 'flow') { $('#legendTitle').textContent = 'Current density, A/mm² (log)'; $('#legLo').textContent = (sc.jHi / 300).toFixed(1); $('#legHi').textContent = sc.jHi.toFixed(0); }     else if (state.view.quantity === 'heat') { $('#legendTitle').textContent = 'Heat flow in the copper, W/mm (log)'; $('#legLo').textContent = (sc.qHi / 300).toExponential(1); $('#legHi').textContent = sc.qHi.toFixed(3); }     else { $('#legendTitle').textContent = 'Rise above ambient, °C (' + state.view.air + ' air)'; $('#legLo').textContent = '0'; $('#legHi').textContent = sc.tHi.toFixed(0); }     $('#airSec').style.display = state.view.quantity === 'temperature' ? '' : 'none';     applyParts();     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);+    else { const bd = $('#flowBadge'); if (bd) { bd.hidden = !['flow', 'river', 'energy', 'light'].includes(state.view.quantity); bd.textContent = flowNote; } }   }-  function rawName(L) { const q = state.view.quantity, ls = slug(L); return (q === 'current' ? 'J-' : q === 'heat' ? 'q-' : (state.view.air === 'moving' ? 'Tm-' : 'T-')) + ls + '.raw.png'; }+  let flowNote = 'Loading the flow data';+  function rawName(L) { const q = state.view.quantity, ls = slug(L); return (q === 'current' || q === 'flow' || q === 'river' ? 'J-' : q === 'heat' ? 'q-' : (state.view.air === 'moving' ? 'Tm-' : 'T-')) + ls + '.raw.png'; }   function loadRaw() {     for (const L of ['F.Cu', 'B.Cu']) {       const name = rawName(L); if (state.raw[name]) continue; state.raw[name] = { pending: true };@@ -260,23 +272,24 @@   }   scene.onPointerObservable.add((pi) => {     if (pi.type !== BAB.PointerEventTypes.POINTERMOVE || !board) return;-    const pick = scene.pick(scene.pointerX, scene.pointerY, (m) => m.name.startsWith('L-'));+    const pick = scene.pick(scene.pointerX, scene.pointerY, (m) => state.view.quantity === 'river' ? m.name.startsWith('R-') : m.name.startsWith('L-'));     const ro = $('#readout');     if (!pick || !pick.hit) { ro.innerHTML = '<span class="k">hover the board</span>'; return; }     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 (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'; }+    if (window.fieldsFlow && ['flow', 'river', 'energy', 'light'].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`;   });    // ---- panels ----------------------------------------------------------------------------   function renderPanels() {     const f = state.fields, v = state.view;-    for (const b of document.querySelectorAll('#qty button')) { b.classList.toggle('is-on', b.dataset.q === v.quantity); b.setAttribute('aria-pressed', b.dataset.q === v.quantity); }+    for (const b of document.querySelectorAll('#qty button, #qty2 button')) { b.classList.toggle('is-on', b.dataset.q === v.quantity); b.setAttribute('aria-pressed', b.dataset.q === v.quantity); }     for (const b of document.querySelectorAll('#layer button')) { const on = b.dataset.l === (v.layer || 'F.Cu'); b.classList.toggle('is-on', on); b.setAttribute('aria-pressed', on); }     for (const b of document.querySelectorAll('#air button')) { b.classList.toggle('is-on', b.dataset.a === v.air); b.setAttribute('aria-pressed', b.dataset.a === v.air); }-    for (const b of document.querySelectorAll('.views .chip')) b.classList.toggle('is-on', b.dataset.cam === v.camera);+    for (const b of document.querySelectorAll('.views .chip[data-cam]')) b.classList.toggle('is-on', b.dataset.cam === v.camera);     const jmax = Math.max(1, ...f.nets.map(n => n.peakJ));     $('#nets').innerHTML = f.nets.map(n => `<div class="row ${v.net === n.net ? 'is-on' : ''}" data-net="${n.net}" data-tip="${n.amps} A on ${n.layers.join(', ')}; ${n.copperMm2} mm² of copper, ${n.vias} vias, ${n.jouleW} W of Joule loss. Click to light this net alone."><div style="flex:1;min-width:0"><div class="name">${n.net}</div><div class="bar" style="width:${(n.peakJ / jmax * 100).toFixed(0)}%"></div></div><div class="num">${n.amps} A · ${n.peakJ.toFixed(0)} A/mm²</div></div>`).join('') || '<div class="empty">no loaded nets in the spec</div>';     $('#chips').innerHTML = f.chips.map(c => `<div class="row ${v.chip === c.reference ? 'is-on' : ''}" data-chip="${c.reference}" data-tip="${c.watts} W on ${c.tabNet}: ${(c.planeW / c.watts * 100).toFixed(0)}% spreads along F.Cu, ${(c.viaW / c.watts * 100).toFixed(0)}% goes down its ${c.vias} own via(s) on ${c.tabNet} (spec asks ${c.viasWanted}), ${(c.otherViaW / c.watts * 100).toFixed(0)}% through ${c.otherVias} other nets' barrel(s) nearby, ${(c.dielW / c.watts * 100).toFixed(0)}% through the bare dielectric. +${c.riseMovingC} °C in moving air."><div class="name">${c.reference} <span class="num">${c.watts} W</span></div><div class="num" style="color:${c.riseC > f.heat.budgetC ? 'var(--red)' : 'var(--text)'}">+${c.riseC.toFixed(0)} °C</div></div>`).join('') || '<div class="empty">no hot parts in the spec</div>';@@ -298,15 +311,16 @@   }   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'); if (q) return setView({ quantity: q.dataset.q, issue: null });+    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 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'); if (cam) return setView({ camera: cam.dataset.cam });+    const cam = e.target.closest('.views .chip[data-cam]'); if (cam) return setView({ camera: cam.dataset.cam });     const n = e.target.closest('[data-net]'); if (n) return post('ui/select', { net: state.view.net === n.dataset.net ? '' : n.dataset.net });     const c = e.target.closest('[data-chip]'); if (c) return post('ui/select', { chip: c.dataset.chip });     const is = e.target.closest('[data-issue]'); if (is) return post('ui/issue', { index: +is.dataset.issue });     if (e.target.closest('#btnNets')) { $('#left').classList.toggle('is-open'); $('#right').classList.remove('is-open'); }     if (e.target.closest('#btnIssues')) { $('#right').classList.toggle('is-open'); $('#left').classList.remove('is-open'); }+    if (e.target.closest('#btnExplode')) return setView({ explode: (+state.view.explode || 0) > 0.3 ? 0 : 1 });     if (e.target.closest('#btnParts')) { const order = ['ghost', 'solid', 'off']; const cur = partsMode(); return setView({ parts: order[(order.indexOf(cur) + 1) % order.length] }); }     if (e.target.closest('#btnLight')) { state.light = !state.light; V.setBottomLight(state.light); const b = $('#btnLight'); b.classList.toggle('is-on', state.light); b.setAttribute('aria-pressed', state.light); toast(state.light ? 'Underside light on' : 'Underside light off'); }   });@@ -317,7 +331,7 @@     state.view = Object.assign({}, state.view, v);     renderPanels(); applyTextures();     const changed = (v.changed || []);-    const moveCamera = first || changed.includes('camera') || changed.includes('focus');+    const moveCamera = first || changed.includes('camera') || changed.includes('focus') || (changed.includes('explode') && state.view.camera !== 'zoom');     if (!moveCamera) return;     if (state.view.camera === 'zoom' && state.view.focus) { zoomTo(state.view.focus[0], state.view.focus[1], (state.view.layer || 'F.Cu') === 'B.Cu'); }     else if (state.view.camera) fitCamera(state.view.camera);@@ -340,6 +354,10 @@     $('#solveInfo').textContent = `peak ${f.scales.jMax.toFixed(0)} A/mm² · +${f.heat.peakRiseC.toFixed(0)} °C still, +${f.heat.peakRiseMovingC.toFixed(0)} °C moving · budget ${f.heat.budgetC} °C`;     $('#ver').textContent = '';     buildBoard(f);+    // the flow and the river: charge streaming through the copper, every net lifted to its voltage+    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(); })+      .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);     connect();@@ -356,7 +374,7 @@       return true;     },     step(dt) { if (dt) clock.dt = dt; scene.render(); return { seq: state.view.seq, tweening, partsReady: parts.ready }; },-    state: () => ({ view: state.view, tweening, partsReady: parts.ready, parts: parts.meshes.length, texturesReady: Object.values(state.tex).every(t => t.isReady()) }),+    state: () => ({ view: state.view, tweening, partsReady: parts.ready, parts: parts.meshes.length, flow: window.fieldsFlow ? window.fieldsFlow.stats() : null, texturesReady: Object.values(state.tex).every(t => t.isReady()) }),   };   boot(); })();
src/ui/flow.jsadded+837
@@ -0,0 +1,837 @@+// Adom Fields: the flow and the river.+// Flow: the solved current as charge streaming through the copper, thousands of glowing streaks whose+// speed and heat follow the current density, dropping through the via barrels in proportion to the+// current each barrel carries, with the layer stack pulled apart so the inner copper shows.+// River: every net's copper lifted to its voltage (the rail where the net is fed plus the IR drop the+// solve found, exaggerated and labelled), the current running over it as water, a switching node as a+// tide between its rails, the loads as waterfalls down to ground and the sources as fountains back up.+// Everything moves on the page's own clock (ctx.frameDt), so a frame-exact capture is the same picture.+export async function attach(ctx) {+  const { BAB, scene, V, camera, state, Z, LAYERS, frameDt, root } = ctx;+  const meta = state.fields.flow;+  if (!meta) { console.warn('flow: this solve carries no flow data; run `adom-fields analyze` again with 0.7 or later'); return null; }+  const buf = await fetch('flow.bin').then(r => { if (!r.ok) throw new Error('flow.bin: HTTP ' + r.status); return r.arrayBuffer(); });+  const W = meta.cols, H = meta.rows, N = W * H, d = meta.cellMm;+  const all = new Float32Array(buf);+  if (all.length !== LAYERS.length * 4 * N) throw new Error(`flow.bin holds ${all.length} values, expected ${LAYERS.length * 4 * N}`);+  const board = state.fields.board;+  const cuMm = LAYERS.map(l => ((board.copperUmByLayer || {})[l] || board.copperUm) / 1000);+  const lay = LAYERS.map((name, k) => {+    const o = k * 4 * N;+    const L = { name, k, jx: all.subarray(o, o + N), jy: all.subarray(o + N, o + 2 * N), volt: all.subarray(o + 2 * N, o + 3 * N), net: all.subarray(o + 3 * N, o + 4 * N) };+    let cu = 0, cur = 0; for (let i = 0; i < N; i++) { if (L.net[i] >= 0) cu++; if (L.jx[i] !== 0 || L.jy[i] !== 0) cur++; }+    L.cuCells = cu; L.curCells = cur;+    return L;+  });+  const nets = meta.nets;+  const netIndex = Object.fromEntries(nets.map((n, i) => [n.name, i]));+  // world frame: x right, y up the board (KiCad's y runs down), z out of the top copper+  const bx = (x) => x - board.x0 - board.widthMm / 2, by = (y) => board.y0 + board.heightMm / 2 - y;+  const diag = Math.hypot(board.widthMm, board.heightMm), minDim = Math.min(board.widthMm, board.heightMm);+  // the density scale: 99.5th percentile of the solved |J| (a single hot cell must not wash out the rest)+  const mags = []; for (const L of lay) for (let i = 0; i < N; i += 3) { const m = Math.hypot(L.jx[i], L.jy[i]); if (m > 0) mags.push(m); }+  mags.sort((a, b) => a - b);+  const jHi = mags.length ? mags[Math.min(mags.length - 1, Math.floor(mags.length * 0.995))] : 1, jLo = jHi / 300;+  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 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];+  // the river: the top rail rises a quarter of the board's short side; drops within a net are exaggerated+  // so a millivolt slope can be seen, and the factor is printed on the badge+  const topRail = Math.max(1e-6, meta.topRailV || meta.voltMax || 1);+  const kR = 0.25 * minDim / topRail;+  const maxDrop = Math.max(1e-6, ...nets.filter(n => n.loaded && !n.switching).map(n => n.dropV || 0));+  const kD = 0.05 * minDim / maxDrop;+  const exaggeration = kD / kR;+  const swNets = nets.map(n => n.switching || null);+  const railPart = (id) => { const n = nets[id]; if (!n) return 0; if (n.switching) return n.switching.lowV || 0; return n.railV ?? 0; };+  // the tide: a switching node slowed to 0.4 Hz, a softened square wave between its rails+  const TIDE_HZ = 0.4;+  const tideOf = (t) => 0.5 + 0.5 * Math.tanh(4 * Math.sin(2 * Math.PI * TIDE_HZ * t));+  function heightAt(k, i) {+    const id = lay[k].net[i]; if (id < 0) return 0;+    const n = nets[id], v = lay[k].volt[i];+    const rail = railPart(id), drop = Number.isFinite(v) ? (n.switching ? v : v - (n.railV ?? 0)) : 0;+    const sw = n.switching ? (n.switching.highV - (n.switching.lowV || 0)) * knob.tide : 0;+    return knob.river * ((rail + sw) * kR + drop * kD);+  }++  // ---- the vias: the current each barrel carries between adjacent layers ---------------------------+  // per cell: the current crossing each layer gap, positive downward (F.Cu toward B.Cu)+  const viaGaps = new Map();+  for (const n of nets) for (const v of (n.vias || [])) {+    const c = Math.floor((v.x - meta.x0) / d), r = Math.floor((v.y - meta.y0) / d); if (c < 0 || r < 0 || c >= W || r >= H) continue;+    const i = r * W + c, a = LAYERS.indexOf(v.from), b = LAYERS.indexOf(v.to); if (a < 0 || b < 0) continue;+    const g = viaGaps.get(i) || new Float32Array(LAYERS.length - 1);+    const lo = Math.min(a, b), hi = Math.max(a, b), sgn = a < b ? 1 : -1;+    for (let k = lo; k < hi; k++) g[k] += sgn * v.amps;+    viaGaps.set(i, g);+  }++  // ---- the energy flow (the Poynting vector S = E x H) in the board material between copper layers -------+  // Between two copper layers a gap h apart: E is vertical, (Va - Vb)/h; two current sheets K = J t give+  // H = 1/2 (Kb - Ka) x z between them; so S = E x H = 1/2 (Va - Vb)/h (Ka - Kb), in W/mm², along the+  // supply's current. Integrated over a supply over its return, that is V x I: the power delivered.+  // Only the field inside the board is drawn; part of the energy travels in the air outside it.+  const voltsAt = (k, i) => {+    const id = lay[k].net[i]; if (id < 0) return NaN; const n = nets[id], v = lay[k].volt[i];+    if (n.switching) { const sw = n.switching; const mean = sw.meanV ?? (sw.highV + (sw.lowV || 0)) / 2; return mean + (Number.isFinite(v) ? v : 0); }+    if (n.railV == null) return NaN; return Number.isFinite(v) ? v : n.railV;+  };+  // real copper layers only: an inner layer of a two-layer board holds nothing but via and pad rings+  let real = lay.filter(L => L.cuCells > 0.15 * N).map(L => L.k);+  if (real.length < 2) real = lay.filter(L => L.cuCells > 0).map(L => L.k);+  const gaps = [];+  for (let g = 0; g + 1 < real.length; g++) {+    const a = real[g], b = real[g + 1], h = Math.max(0.05, Z[LAYERS[a]] - Z[LAYERS[b]]);+    const Sx = new Float32Array(N), Sy = new Float32Array(N), info = new Int16Array(N * 2).fill(-1);+    const A = lay[a], B = lay[b];+    for (let i = 0; i < N; i++) {+      const va = voltsAt(a, i), vb = voltsAt(b, i); if (!Number.isFinite(va) || !Number.isFinite(vb)) continue;+      const f = 0.5 * (va - vb) / h;+      Sx[i] = f * (A.jx[i] * cuMm[a] - B.jx[i] * cuMm[b]); Sy[i] = f * (A.jy[i] * cuMm[a] - B.jy[i] * cuMm[b]);+      info[i * 2] = A.net[i]; info[i * 2 + 1] = B.net[i];+    }+    gaps.push({ a, b, h, Sx, Sy, info });+  }+  const smags = []; for (const G of gaps) for (let i = 0; i < N; i += 2) { const m = Math.hypot(G.Sx[i], G.Sy[i]); if (m > 0) smags.push(m); }+  smags.sort((x, y) => x - y);+  const sHi = smags.length ? smags[Math.min(smags.length - 1, Math.floor(smags.length * 0.995))] : 1, sLo = sHi / 300;+  const sT = (m) => m <= sLo ? 0 : Math.min(1, Math.log(m / sLo) / Math.log(sHi / sLo));+  // electrons drift at v = J / (n e): copper has n = 8.5e28 /m³, so 1 A/mm² is 0.0734 mm/s+  const DRIFT = 1e6 / (8.5e28 * 1.602e-19) * 1e3;++  // ---- seeded randomness, so a capture replays the same frames --------------------------------------+  let seed = 0x9e3779b9;+  const rnd = () => { seed |= 0; seed = seed + 0x6D2B79F5 | 0; let t = Math.imul(seed ^ seed >>> 15, 1 | seed); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; };++  // ---- where charge is born: weighted by the square root of |J|, so thin, quiet copper still shows -+  let cdf = null, cdfKeys = null, cdfNet = undefined;+  function buildSpawn(netName) {+    const only = netName ? netIndex[netName] : -1;+    const keys = [], w = [];+    for (const L of lay) {+      if (!L.curCells) continue;+      for (let i = 0; i < N; i++) {+        const m = Math.hypot(L.jx[i], L.jy[i]); if (m <= jLo) continue;+        if (only >= 0 && L.net[i] !== only) continue;+        keys.push(L.k * N + i); w.push(Math.pow(m, 0.8));+      }+    }+    cdfKeys = Int32Array.from(keys); cdf = new Float64Array(w.length); let s = 0; for (let i = 0; i < w.length; i++) { s += w[i]; cdf[i] = s; }+    cdfNet = netName || null;+  }+  function sampleKey() { if (!cdf || !cdf.length) return -1; const t = rnd() * cdf[cdf.length - 1]; let lo = 0, hi = cdf.length - 1; while (lo < hi) { const m = (lo + hi) >> 1; if (cdf[m] < t) lo = m + 1; else hi = m; } return cdfKeys[lo]; }++  // ---- the particle pool --------------------------------------------------------------------------+  const tier = (() => { const e = V.getEngine(); const r = e.getRenderer ? '' : ''; void r; const big = (e.getCaps().maxTextureSize || 4096) >= 16384; return big ? 'high' : 'mid'; })();+  const COUNTS = { low: 6000, mid: 16000, high: 32000 };+  // the count follows the copper that carries current (about four charges per square millimetre), so a+  // small board is not a white wall and a big one is not sparse; the tier caps it+  const curArea = lay.reduce((s, L) => s + L.curCells, 0) * d * d;+  let P = Math.max(2500, Math.min(COUNTS[tier], Math.round(curArea * 4)));+  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 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);+  const vx = new Float32Array(MAX), vy = new Float32Array(MAX), vz = new Float32Array(MAX), heat = new Float32Array(MAX);+  // instance buffers: position + size, velocity + alpha, colour + intensity+  const IP = new Float32Array(TOTAL * 4), IV = new Float32Array(TOTAL * 4), IC = new Float32Array(TOTAL * 4);++  function sampleJ(L, x, y) {+    // bilinear over the four nearest cells, only cells of the particle's own net count+    const fx = (x - meta.x0) / d - 0.5, fy = (y - meta.y0) / d - 0.5;+    const c0 = Math.floor(fx), r0 = Math.floor(fy), tx = fx - c0, ty = fy - r0;+    let sx = 0, sy = 0, sw = 0;+    for (let dr = 0; dr < 2; dr++) for (let dc = 0; dc < 2; dc++) {+      const c = c0 + dc, r = r0 + dr; if (c < 0 || r < 0 || c >= W || r >= H) continue;+      const i = r * W + c; if (L.net[i] < 0) continue;+      const wgt = (dc ? tx : 1 - tx) * (dr ? ty : 1 - ty); sx += L.jx[i] * wgt; sy += L.jy[i] * wgt; sw += wgt;+    }+    return sw > 1e-6 ? [sx / sw, sy / sw] : [0, 0];+  }+  const cellOf = (x, y) => { const c = Math.floor((x - meta.x0) / d), r = Math.floor((y - meta.y0) / d); return (c < 0 || r < 0 || c >= W || r >= H) ? -1 : r * W + c; };++  function spawn(p) {+    const key = sampleKey(); if (key < 0) { life[p] = 0; return; }+    const k = Math.floor(key / N), i = key % N;+    px[p] = meta.x0 + ((i % W) + rnd()) * d; py[p] = meta.y0 + (Math.floor(i / W) + rnd()) * d; pk[p] = k;+    pnet[p] = lay[k].net[i]; age[p] = 0; life[p] = knob.electrons ? 6 + rnd() * 6 : 1.5 + rnd() * 3.5; mode[p] = 0; lastCell[p] = i;+  }+  // the falls and fountains: a load drops its net's level to ground, a source lifts it back up+  const terminals = [];+  for (const [id, n] of nets.entries()) {+    if (!n.loaded || n.ground) continue;+    for (const t of n.terminals || []) terminals.push({ id, x: t.x, y: t.y, amps: t.amps, up: t.role === 'source', ref: t.ref });+  }+  const termAmps = terminals.reduce((s, t) => s + t.amps, 0) || 1;+  const termCdf = []; { let s = 0; for (const t of terminals) { s += t.amps; termCdf.push(s); } }+  function spawnFall(p) {+    if (!terminals.length) { life[p] = 0; return; }+    const r = rnd() * termAmps; let j = termCdf.findIndex(c => c >= r); if (j < 0) j = terminals.length - 1;+    const t = terminals[j]; const rad = 0.22 * Math.sqrt(rnd());+    const a = rnd() * Math.PI * 2;+    px[p] = t.x + rad * Math.cos(a); py[p] = t.y + rad * Math.sin(a); pk[p] = 0; pnet[p] = t.id; mode[p] = t.up ? 3 : 2;+    age[p] = 0; life[p] = 1.2 + rnd() * 0.8; dT[p] = rnd() * 0.15;+  }+  function levelOf(id, x, y) {+    // the height of a net's own copper at this point (its rail, its drop, the tide)+    const i = cellOf(x, y); if (i >= 0 && lay[0].net[i] === id) return heightAt(0, i);+    const n = nets[id]; const sw = n && n.switching ? (n.switching.highV - (n.switching.lowV || 0)) * knob.tide : 0;+    return knob.river * (railPart(id) + sw) * kR;+  }++  function step(dt) {+    knob.time += dt * knob.speed;+    knob.tide = tideOf(knob.time);+    const base = diag * 0.11 * knob.speed;+    const sel = state.view.layer || 'F.Cu', selK = LAYERS.indexOf(sel);+    const exploded = knob.explode > 0.3;+    const showFlow = knob.flowOn > 0.01;+    for (let p = 0; p < P; p++) {+      if (!showFlow) break;+      if (age[p] >= life[p]) spawn(p);+      if (!(life[p] > 0)) continue;+      age[p] += dt;+      const k = pk[p], L = lay[k];+      if (mode[p] === 1) {+        // dropping through a barrel+        dT[p] += dt * 3.2 * knob.speed;+        const za = zOf(k), zb = zOf(dTo[p]);+        if (dT[p] >= 1) {+          const from = k; pk[p] = dTo[p];+          // keep falling if this barrel carries the current on to the next layer as well+          const g = viaGaps.get(lastCell[p]); const down = dTo[p] > from;+          const nk = pk[p] + (down ? 1 : -1);+          const inflow = g ? Math.abs(g[Math.min(from, pk[p])]) : 0;+          const onward = g && nk >= 0 && nk < LAYERS.length ? (down ? g[pk[p]] : -g[pk[p] - 1]) : 0;+          if (onward > 0 && rnd() < Math.min(1, onward / Math.max(inflow, 1e-9))) { dTo[p] = nk; dT[p] = 0; }+          else { mode[p] = 0; }+        }+        pz[p] = za + (zb - za) * Math.min(1, dT[p]);+        vx[p] = 0; vy[p] = 0; vz[p] = (zb - za) * 3.2 * knob.speed; heat[p] = Math.max(heat[p], 0.8);+        continue;+      }+      // in the copper: midpoint step along the current+      const [ax, ay] = sampleJ(L, px[p], py[p]);+      let m = Math.hypot(ax, ay);+      if (m <= jLo * 0.5) { age[p] = life[p]; continue; }+      const t = logT(m), sp = knob.electrons ? DRIFT * m * knob.speed : base * (0.12 + 0.88 * t);+      const hx = px[p] + ax / m * sp * dt * 0.5, hy = py[p] + ay / m * sp * dt * 0.5;+      const [bx2, by2] = sampleJ(L, hx, hy); const m2 = Math.hypot(bx2, by2) || m;+      const dir = knob.electrons ? -1 : 1;   // electrons move against the conventional current+      const ux = dir * (m2 > 0 ? bx2 / m2 : ax / m), uy = dir * (m2 > 0 ? by2 / m2 : ay / m);+      px[p] += ux * sp * dt; py[p] += uy * sp * dt;+      const i = cellOf(px[p], py[p]);+      if (i < 0 || L.net[i] !== pnet[p]) { age[p] = life[p]; continue; }+      vx[p] = ux * sp; vy[p] = uy * sp; vz[p] = 0; heat[p] = t;+      // a new cell with a barrel in it: the barrel takes its share of the current, down or up+      if (i !== lastCell[p] && !knob.electrons) {+        lastCell[p] = i;+        const g = viaGaps.get(i);+        if (g) {+          const down = k < LAYERS.length - 1 ? Math.max(0, g[k]) : 0, up = k > 0 ? Math.max(0, -g[k - 1]) : 0;+          const out = down + up;+          if (out > 0) {+            const plane = m * d * cuMm[k];+            if (rnd() < out / (out + plane)) {+              mode[p] = 1; dT[p] = 0; dTo[p] = (rnd() * out < down) ? k + 1 : k - 1;+              px[p] = meta.x0 + ((i % W) + 0.5) * d; py[p] = meta.y0 + (Math.floor(i / W) + 0.5) * d;+            }+          }+        }+      }+      pz[p] = zOf(pk[p]) + (pk[p] === 0 ? 1 : pk[p] === LAYERS.length - 1 ? -1 : 0) * 0.03 + heightAt(pk[p], i >= 0 ? i : 0);+      void selK; void exploded;+    }+    // falls and fountains (river only)+    for (let p = COUNTS.high; p < COUNTS.high + FALLS; p++) {+      if (knob.river < 0.02) { life[p] = 0; continue; }+      if (age[p] >= life[p] || !(life[p] > 0)) spawnFall(p);+      if (!(life[p] > 0)) continue;+      age[p] += dt * knob.speed;+      const top = levelOf(pnet[p], px[p], py[p]), z0 = zOf(0) + 0.03;+      const u = Math.min(1, Math.max(0, (age[p] - dT[p]) / (life[p] - dT[p])));+      // a fall accelerates, a fountain decelerates as it rises+      const f = mode[p] === 2 ? u * u : 1 - (1 - u) * (1 - u);+      const zTop = z0 + top, zBot = z0;+      pz[p] = mode[p] === 2 ? zTop + (zBot - zTop) * f : zBot + (zTop - zBot) * f;+      vx[p] = 0; vy[p] = 0; vz[p] = (mode[p] === 2 ? -1 : 1) * Math.max(0.5, (zTop - zBot)) * 2.2 * knob.speed; heat[p] = 0.6;+    }+  }++  // ---- energy particles: born where S is strong, carried along S, rising into the part that takes the+  // energy where the flow ends at a load+  const ex = new Float32Array(EMAX), ey = new Float32Array(EMAX), ez = new Float32Array(EMAX), eg = new Int8Array(EMAX);+  const eage = new Float32Array(EMAX), elife = new Float32Array(EMAX), emode = new Int8Array(EMAX), eh = new Float32Array(EMAX);+  const evx = new Float32Array(EMAX), evy = new Float32Array(EMAX), evz = new Float32Array(EMAX), ez0 = new Float32Array(EMAX);+  let ecdf = null, ekeys = null; const EP = Math.max(1500, Math.min(EMAX, Math.round(curArea * 3)));+  function buildEnergySpawn() {+    const keys = [], w = [];+    for (const [g, G] of gaps.entries()) for (let i = 0; i < N; i++) { const m = Math.hypot(G.Sx[i], G.Sy[i]); if (m > sLo) { keys.push(g * N + i); w.push(Math.pow(m, 0.75)); } }+    ekeys = Int32Array.from(keys); ecdf = new Float64Array(w.length); let acc = 0; for (let i = 0; i < w.length; i++) { acc += w[i]; ecdf[i] = acc; }+  }+  const sinkNear = (x, y) => terminals.find(t => !t.up && Math.hypot(t.x - x, t.y - y) < 1.4);+  const gapZ = (g) => (zOf(gaps[g].a) + zOf(gaps[g].b)) / 2;+  function sampleS(G, x, y) {+    const fx = (x - meta.x0) / d - 0.5, fy = (y - meta.y0) / d - 0.5, c0 = Math.floor(fx), r0 = Math.floor(fy), tx = fx - c0, ty = fy - r0;+    let sx = 0, sy = 0;+    for (let dr = 0; dr < 2; dr++) for (let dc = 0; dc < 2; dc++) { const c = c0 + dc, r = r0 + dr; if (c < 0 || r < 0 || c >= W || r >= H) continue; const i = r * W + c, wgt = (dc ? tx : 1 - tx) * (dr ? ty : 1 - ty); sx += G.Sx[i] * wgt; sy += G.Sy[i] * wgt; }+    return [sx, sy];+  }+  function spawnEnergy(p) {+    if (!ecdf || !ecdf.length) { elife[p] = 0; return; }+    const r = rnd() * ecdf[ecdf.length - 1]; let lo = 0, hi = ecdf.length - 1; while (lo < hi) { const m = (lo + hi) >> 1; if (ecdf[m] < r) lo = m + 1; else hi = m; }+    const key = ekeys[lo], g = Math.floor(key / N), i = key % N;+    ex[p] = meta.x0 + ((i % W) + rnd()) * d; ey[p] = meta.y0 + (Math.floor(i / W) + rnd()) * d; eg[p] = g;+    eage[p] = 0; elife[p] = 2 + rnd() * 3; emode[p] = 0; ez[p] = gapZ(g);+  }+  function stepEnergy(dt) {+    const base = diag * 0.16 * knob.speed;+    for (let p = 0; p < EP; p++) {+      if (eage[p] >= elife[p]) spawnEnergy(p);+      if (!(elife[p] > 0)) continue;+      eage[p] += dt;+      if (emode[p] === 1) {+        // into the part: the flow ends here, so the energy leaves the board material up into the load+        const u = Math.min(1, (eage[p] - ez0[p]) / 0.7), top = zOf(0) + 1.4;+        ez[p] = gapZ(eg[p]) + (top - gapZ(eg[p])) * u * u; evx[p] = 0; evy[p] = 0; evz[p] = 4; eh[p] = 0.8;+        if (u >= 1) eage[p] = elife[p];+        continue;+      }+      const G = gaps[eg[p]]; const [sx, sy] = sampleS(G, ex[p], ey[p]); const m = Math.hypot(sx, sy);+      if (m <= sLo * 0.6) { if (sinkNear(ex[p], ey[p])) { emode[p] = 1; ez0[p] = eage[p]; elife[p] = eage[p] + 0.75; } else eage[p] = elife[p]; continue; }+      const t = sT(m), sp = base * (0.2 + 0.8 * t);+      ex[p] += sx / m * sp * dt; ey[p] += sy / m * sp * dt; ez[p] = gapZ(eg[p]);+      evx[p] = sx / m * sp; evy[p] = sy / m * sp; evz[p] = 0; eh[p] = t;+      if (ex[p] < meta.x0 || ey[p] < meta.y0 || ex[p] > meta.x0 + W * d || ey[p] > meta.y0 + H * d) eage[p] = elife[p];+    }+  }++  // ---- the streak renderer: one instanced quad, stretched along each particle's velocity --------+  BAB.Effect.ShadersStore.fieldsStreakVertexShader = `precision highp float;+attribute vec3 position; attribute vec4 ip; attribute vec4 iv; attribute vec4 ic;+uniform mat4 world; uniform mat4 viewProjection; uniform vec3 camPos; uniform float trail; uniform float width;+varying vec2 vQ; varying vec4 vC;+void main(void) {+  vec3 pw = (world * vec4(ip.xyz, 1.0)).xyz;+  vec3 vel = mat3(world) * iv.xyz; float sp = length(vel);+  vec3 dir = sp > 1e-6 ? vel / sp : vec3(1.0, 0.0, 0.0);+  vec3 toCam = normalize(camPos - pw);+  vec3 side = cross(dir, toCam); float sl = length(side); side = sl > 1e-4 ? side / sl : vec3(0.0, 1.0, 0.0);+  float along = position.x * 0.5 + 0.5;+  float len = ip.w + sp * trail;+  vec3 p = pw - dir * len * along + side * ip.w * width * position.y;+  vQ = vec2(along, position.y); vC = vec4(ic.rgb, ic.a * iv.w);+  gl_Position = viewProjection * vec4(p, 1.0);+}`;+  BAB.Effect.ShadersStore.fieldsStreakFragmentShader = `precision highp float;+varying vec2 vQ; varying vec4 vC;+void main(void) {+  float across = max(0.0, 1.0 - vQ.y * vQ.y);+  float tail = pow(1.0 - vQ.x, 1.7);+  float head = 1.0 + 1.6 * exp(-vQ.x * 18.0);+  float a = vC.a * across * tail * head;+  if (a < 0.004) discard;+  gl_FragColor = vec4(vC.rgb, min(1.0, a));+}`;+  const streak = new BAB.Mesh('fields-flow-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(streak, false); }+  const engine = V.getEngine();+  const vbP = new BAB.VertexBuffer(engine, IP, 'ip', true, false, 4, true);+  const vbV = new BAB.VertexBuffer(engine, IV, 'iv', true, false, 4, true);+  const vbC = new BAB.VertexBuffer(engine, IC, 'ic', true, false, 4, true);+  streak.setVerticesBuffer(vbP); streak.setVerticesBuffer(vbV); streak.setVerticesBuffer(vbC);+  streak.forcedInstanceCount = TOTAL;+  streak.alwaysSelectAsActiveMesh = true; streak.isPickable = false; streak.parent = root;+  const smat = new BAB.ShaderMaterial('fields-flow-streak', scene, { vertex: 'fieldsStreak', fragment: 'fieldsStreak' }, { attributes: ['position', 'ip', 'iv', 'ic'], uniforms: ['world', 'viewProjection', 'camPos', 'trail', 'width'], needAlphaBlending: true });+  smat.alphaMode = BAB.Constants.ALPHA_ADD; smat.disableDepthWrite = true; smat.backFaceCulling = false;+  smat.setFloat('trail', 0.22); smat.setFloat('width', 1.0);+  streak.material = smat; streak.renderingGroupId = 1;+  scene.setRenderingAutoClearDepthStencil(1, false, false, false);++  // the electric palette: deep teal where the current idles, cyan, then white-hot in a neck+  const ramp = [[0.0, 0.28, 0.42], [0.0, 0.62, 0.80], [0.30, 0.88, 1.0], [0.95, 0.95, 0.70], [1.0, 0.72, 0.25]];+  const rampAt = (t) => { const s = Math.max(0, Math.min(1, t)) * (ramp.length - 1); const i = Math.min(ramp.length - 2, Math.floor(s)), f = s - i; return [0, 1, 2].map(c => ramp[i][c] + (ramp[i + 1][c] - ramp[i][c]) * f); };+  const size = Math.max(0.06, diag * 0.0042);+  function upload() {+    const sel = state.view.layer || 'F.Cu', selK = LAYERS.indexOf(sel);+    const exploded = knob.explode > 0.3;+    const sx = bx(0) - 0, sy = by(0);+    for (let p = 0; p < MAX; p++) {+      const o = p * 4;+      const live = life[p] > 0 && (p < P || p >= COUNTS.high);+      let alpha = 0;+      if (live) {+        const fade = Math.min(1, age[p] / 0.25, (life[p] - age[p]) / 0.35);+        const isFall = p >= COUNTS.high;+        const layerA = isFall ? knob.river : (exploded || knob.river > 0.5 || pk[p] === selK || mode[p] === 1) ? 1 : 0.1;+        alpha = knob.electrons && !isFall ? Math.max(0, fade) * 0.7 * knob.flowOn : Math.max(0, fade) * layerA * (isFall ? 0.28 : (0.12 + 0.7 * heat[p] * heat[p]) * (1 - 0.55 * knob.river)) * (isFall ? 1 : knob.flowOn);+      }+      IP[o] = px[p] + sx; IP[o + 1] = sy - py[p]; IP[o + 2] = pz[p]; IP[o + 3] = size * (p >= COUNTS.high ? 0.8 : knob.electrons ? 0.9 : 1);+      IV[o] = vx[p]; IV[o + 1] = -vy[p]; IV[o + 2] = vz[p]; IV[o + 3] = alpha;+      let c;+      if (p >= COUNTS.high) c = mode[p] === 3 ? [0.55, 1.0, 0.7] : [0.75, 0.93, 1.0];+      else if (knob.river > 0.5) c = [0.70, 0.92, 1.0];+      else if (knob.electrons) c = [0.40, 0.62, 1.0];+      else c = rampAt(heat[p]);+      IC[o] = c[0]; IC[o + 1] = c[1]; IC[o + 2] = c[2]; IC[o + 3] = 1.0;+    }+    // the energy: magenta to white, in the gap between the layers+    for (let q = 0; q < EMAX; q++) {+      const o = (MAX + q) * 4, live = q < EP && elife[q] > 0 && knob.energyOn > 0.01;+      const fade = live ? Math.max(0, Math.min(1, eage[q] / 0.3, (elife[q] - eage[q]) / 0.4)) : 0;+      IP[o] = bx(ex[q]); IP[o + 1] = by(ey[q]); IP[o + 2] = ez[q]; IP[o + 3] = size * 1.15;+      IV[o] = evx[q]; IV[o + 1] = -evy[q]; IV[o + 2] = evz[q]; IV[o + 3] = fade * knob.energyOn * (0.10 + 0.42 * eh[q] * eh[q]);+      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;+    }+    vbP.update(IP); vbV.update(IV); vbC.update(IC);+    smat.setVector3('camPos', camera.globalPosition || camera.position);+  }++  // ---- the river terrain: a height-field per layer, coloured by volts, water running over it -------+  BAB.Effect.ShadersStore.fieldsRiverVertexShader = `precision highp float;+attribute vec3 position; attribute vec2 uv; attribute vec4 rv;+uniform mat4 world; uniform mat4 viewProjection; uniform float kR; uniform float kD; uniform float tide; uniform float lift;+varying vec2 vUV; varying float vV; varying vec3 vW; varying vec3 vL;+void main(void) {+  float v = rv.x + rv.z * tide;+  vec3 p = position + vec3(0.0, 0.0, lift * (v * kR + rv.y * kD));+  vUV = uv; vV = v + rv.y; vW = (world * vec4(p, 1.0)).xyz; vL = p;+  gl_Position = viewProjection * vec4(vW, 1.0);+}`;+  BAB.Effect.ShadersStore.fieldsRiverFragmentShader = `precision highp float;+varying vec2 vUV; varying float vV; varying vec3 vW; varying vec3 vL;+uniform sampler2D flowTex; uniform float time; uniform float vTop; uniform vec3 camPos; uniform vec2 cells; uniform float alpha;+float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }+float noise(vec2 p) { vec2 i = floor(p), f = fract(p); f = f * f * (3.0 - 2.0 * f);+  return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x), mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), f.x), f.y); }+float ripple(vec2 p, vec2 dir, float ph) {+  vec2 n = vec2(-dir.y, dir.x);+  vec2 q = vec2(dot(p, dir), dot(p, n));+  q.x -= ph * 7.0;+  return noise(vec2(q.x * 0.55, q.y * 2.6)) * 0.65 + noise(vec2(q.x * 1.3, q.y * 5.0) + 17.0) * 0.35;+}+void main(void) {+  vec4 ft = texture2D(flowTex, vUV);+  if (ft.a < 0.5) discard;+  vec2 fl = ft.gb * 2.0 - 1.0; float fm = length(fl); vec2 dir = fm > 0.05 ? fl / fm : vec2(1.0, 0.0); float mag = ft.r;+  vec3 nrm = normalize(cross(dFdx(vW), dFdy(vW)));+  vec3 vd = normalize(camPos - vW); if (dot(nrm, vd) < 0.0) nrm = -nrm;+  float t = clamp(vV / vTop, 0.0, 1.0);+  vec3 c = mix(vec3(0.015, 0.07, 0.22), vec3(0.0, 0.42, 0.55), smoothstep(0.0, 0.4, t));+  c = mix(c, vec3(0.35, 0.85, 0.98), smoothstep(0.35, 1.0, t));+  vec2 p = vUV * cells * 0.35;+  float ph = time * (0.25 + 1.6 * mag);+  float p0 = fract(ph), p1 = fract(ph + 0.5);+  float r = mix(ripple(p, dir, p0), ripple(p + 3.7, dir, p1), abs(1.0 - 2.0 * p0));+  float foam = smoothstep(0.66, 0.95, r) * pow(mag, 1.4) * step(0.05, fm);+  vec3 Ld = normalize(vec3(0.35, 0.55, 0.75));+  float diff = 0.5 + 0.5 * max(dot(nrm, Ld), 0.0);+  vec3 h = normalize(Ld + vd); float spec = pow(max(dot(nrm, h), 0.0), 60.0) * 0.6;+  float fres = pow(1.0 - abs(dot(nrm, vd)), 3.0);+  vec3 col = c * diff + foam * vec3(0.85, 0.97, 1.0) * 0.9 + spec + fres * vec3(0.25, 0.45, 0.55);+  // a terrace's side is rock, not water: no current crosses the gap between two nets+  vec3 nl = normalize(cross(dFdx(vL), dFdy(vL)));+  float cliff = smoothstep(0.55, 0.85, 1.0 - abs(nl.z));+  vec3 rock = vec3(0.10, 0.13, 0.17) * (0.6 + 0.4 * diff) + vec3(0.0, 0.10, 0.14) * t;+  col = mix(col, rock, cliff);+  gl_FragColor = vec4(col, alpha * mix(1.0, 0.8, cliff));+}`;+  const terrains = [];+  const stride = Math.max(1, Math.ceil(Math.sqrt(N / 70000)));+  for (const L of lay) {+    if (L.cuCells < 0.02 * N) continue;+    const cw = Math.ceil(W / stride) + 1, ch = Math.ceil(H / stride) + 1;+    const pos = new Float32Array(cw * ch * 3), uvs = new Float32Array(cw * ch * 2), rv = new Float32Array(cw * ch * 4), ind = [];+    for (let rr = 0; rr < ch; rr++) for (let cc = 0; cc < cw; cc++) {+      const c = Math.min(W - 1, cc * stride), r = Math.min(H - 1, rr * stride), v = rr * cw + cc;+      const x = meta.x0 + Math.min(W, cc * stride) * d, y = meta.y0 + Math.min(H, rr * stride) * d;+      pos[v * 3] = bx(x); pos[v * 3 + 1] = by(y); pos[v * 3 + 2] = 0;+      uvs[v * 2] = Math.min(W, cc * stride) / W; uvs[v * 2 + 1] = Math.min(H, rr * stride) / H;+      // off the copper, borrow the nearest copper's level so a plateau keeps its edge (the mask cuts it)+      let i = r * W + c;+      if (L.net[i] < 0) { let best = -1; for (let rad = 1; rad <= 3 && best < 0; rad++) for (let dr = -rad; dr <= rad && best < 0; dr++) for (let dc = -rad; dc <= rad; dc++) { const r2 = r + dr, c2 = c + dc; if (r2 < 0 || c2 < 0 || r2 >= H || c2 >= W) continue; const j = r2 * W + c2; if (L.net[j] >= 0) { best = j; break; } } if (best >= 0) i = best; }+      const id = L.net[i];+      if (id >= 0) {+        const n = nets[id], vv = L.volt[i];+        rv[v * 4] = railPart(id); rv[v * 4 + 1] = Number.isFinite(vv) ? (n.switching ? vv : vv - (n.railV ?? 0)) : 0;+        rv[v * 4 + 2] = n.switching ? n.switching.highV - (n.switching.lowV || 0) : 0; rv[v * 4 + 3] = 1;+      }+    }+    for (let rr = 0; rr < ch - 1; rr++) for (let cc = 0; cc < cw - 1; cc++) { const a = rr * cw + cc; ind.push(a, a + cw, a + 1, a + 1, a + cw, a + cw + 1); }+    const mesh = new BAB.Mesh('R-' + L.name, scene);+    const vd = new BAB.VertexData(); vd.positions = pos; vd.uvs = uvs; vd.indices = ind; vd.applyToMesh(mesh, false);+    mesh.setVerticesData('rv', rv, false, 4);+    // the flow texture: |J| (log) in red, direction in green and blue, copper in alpha+    const tex = new Uint8Array(N * 4);+    for (let i = 0; i < N; i++) {+      const m = Math.hypot(L.jx[i], L.jy[i]);+      tex[i * 4] = Math.round(logT(m) * 255);+      tex[i * 4 + 1] = Math.round((m > 0 ? L.jx[i] / m * 0.5 + 0.5 : 0.5) * 255);+      tex[i * 4 + 2] = Math.round((m > 0 ? L.jy[i] / m * 0.5 + 0.5 : 0.5) * 255);+      tex[i * 4 + 3] = L.net[i] >= 0 ? 255 : 0;+    }+    const ftex = new BAB.RawTexture(tex, W, H, BAB.Constants.TEXTUREFORMAT_RGBA, scene, false, false, BAB.Texture.BILINEAR_SAMPLINGMODE, BAB.Constants.TEXTURETYPE_UNSIGNED_BYTE);+    ftex.wrapU = ftex.wrapV = BAB.Texture.CLAMP_ADDRESSMODE;+    const mat = new BAB.ShaderMaterial('fields-river-' + L.name, scene, { vertex: 'fieldsRiver', fragment: 'fieldsRiver' }, { attributes: ['position', 'uv', 'rv'], uniforms: ['world', 'viewProjection', 'kR', 'kD', 'tide', 'lift', 'time', 'vTop', 'camPos', 'cells', 'alpha'], samplers: ['flowTex'], needAlphaBlending: true });+    mat.setTexture('flowTex', ftex); mat.setFloat('kR', kR); mat.setFloat('kD', kD); mat.setFloat('vTop', topRail); mat.setVector2('cells', new BAB.Vector2(W * d, H * d)); mat.backFaceCulling = false;+    mesh.material = mat; mesh.parent = root; mesh.isPickable = true; mesh.setEnabled(false);+    terrains.push({ L, mesh, mat });+  }++  // ---- light: how fast a change travels, cell by cell ------------------------------------------------+  // A change (switch-on, every switching edge) travels as a wave in the field around the copper, at+  // v = c / sqrt(er_eff). Between two planes the field is all FR4 (er_eff = er); a trace over a plane is a+  // microstrip, half in air: er_eff = (er+1)/2 + (er-1)/2 (1 + 12 h/w)^-1/2 (Hammerstad), so a wide pour is+  // slower than a thin trace; copper with no reference plane under it is mostly air. The delay to a part+  // is the sum of length / speed along the copper from the source, the fastest path (Dijkstra).+  const ER = +(state.fields.signals && state.fields.signals.epsilonR) || 4.3;+  const C_MM_PS = 0.299792458;+  const pctC = new Float32Array(LAYERS.length * N).fill(NaN);+  {+    const realSet = new Set(real);+    for (const k of real) {+      const L = lay[k], idx = real.indexOf(k);+      const above = idx > 0 ? real[idx - 1] : -1, below = idx + 1 < real.length ? real[idx + 1] : -1;+      // the distance (cells) from each copper cell to the edge of its own net's copper: half the local width+      const dist = new Float32Array(N).fill(1e9);+      const q = [];+      for (let i = 0; i < N; i++) { const n = L.net[i]; if (n < 0) continue; const r = Math.floor(i / W), c = i % W;+        const edge = c === 0 || r === 0 || c === W - 1 || r === H - 1 || L.net[i - 1] !== n || L.net[i + 1] !== n || L.net[i - W] !== n || L.net[i + W] !== n;+        if (edge) { dist[i] = 0.5; q.push(i); } }+      for (let h0 = 0; h0 < q.length; h0++) { const i = q[h0], n = L.net[i]; for (const j of [i - 1, i + 1, i - W, i + W]) { if (j < 0 || j >= N || L.net[j] !== n) continue; if (dist[j] > dist[i] + 1) { dist[j] = dist[i] + 1; q.push(j); } } }+      for (let i = 0; i < N; i++) {+        if (L.net[i] < 0) continue;+        const refUp = above >= 0 && lay[above].net[i] >= 0, refDn = below >= 0 && lay[below].net[i] >= 0;+        let eff;+        if (refUp && refDn) eff = ER;                                         // stripline: all FR4+        else if (refUp || refDn) {                                            // microstrip over a plane+          const h = Math.abs(Z[LAYERS[k]] - Z[LAYERS[refUp ? above : below]]);+          let wl = 0; const r = Math.floor(i / W), c = i % W;                 // local width: twice the widest half-width nearby+          for (let dr = -2; dr <= 2; dr++) for (let dc = -2; dc <= 2; dc++) { const rr = r + dr, cc = c + dc; if (rr < 0 || cc < 0 || rr >= H || cc >= W) continue; const j = rr * W + cc; if (L.net[j] === L.net[i]) wl = Math.max(wl, dist[j]); }+          const w = Math.max(d, 2 * wl * d);+          eff = (ER + 1) / 2 + (ER - 1) / 2 / Math.sqrt(1 + 12 * h / w);+        } else eff = 1 + (ER - 1) * 0.15;                                     // no reference: mostly air+        pctC[k * N + i] = 1 / Math.sqrt(eff);+      }+      void realSet;+    }+  }+  // arrival time (ps) from each fed net's source terminals over its own copper, vias joining the layers+  const arrival = new Float64Array(LAYERS.length * N).fill(Infinity);   // 64-bit: the queue compares exact times+  const arrivals = [];+  {+    const heap = []; const push = (t, v) => { heap.push([t, v]); let i = heap.length - 1; while (i > 0) { const p = (i - 1) >> 1; if (heap[p][0] <= heap[i][0]) break; [heap[p], heap[i]] = [heap[i], heap[p]]; i = p; } };+    const pop = () => { const top = heap[0], last = heap.pop(); if (heap.length) { heap[0] = last; let i = 0; for (;;) { const l = 2 * i + 1, r = l + 1; let m = i; if (l < heap.length && heap[l][0] < heap[m][0]) m = l; if (r < heap.length && heap[r][0] < heap[m][0]) m = r; if (m === i) break; [heap[m], heap[i]] = [heap[i], heap[m]]; i = m; } } return top; };+    const viaCells = new Set(state.fields.vias.map(v => cellOf(v.x, v.y)).filter(i => i >= 0));+    for (const [id, n] of nets.entries()) {+      if (!n.loaded || n.ground) continue;+      for (const t of (n.terminals || []).filter(t => t.role === 'source')) {+        const i0 = cellOf(t.x, t.y); if (i0 < 0) continue;+        for (const k of real) { const R = Math.ceil(0.6 / d); const r0 = Math.floor(i0 / W), c0 = i0 % W;+          for (let dr = -R; dr <= R; dr++) for (let dc = -R; dc <= R; dc++) { const r = r0 + dr, c = c0 + dc; if (r < 0 || c < 0 || r >= H || c >= W) continue; const i = r * W + c; if (lay[k].net[i] === id && arrival[k * N + i] > 0) { arrival[k * N + i] = 0; push(0, k * N + i); } } }+      }+      while (heap.length) {+        const [t, v] = pop(); if (t > arrival[v]) continue;+        const k = Math.floor(v / N), i = v % N, r = Math.floor(i / W), c = i % W;+        const sp = (pctC[v] || 0.5) * C_MM_PS;+        const nb = [];+        if (c > 0) nb.push(v - 1); if (c < W - 1) nb.push(v + 1); if (r > 0) nb.push(v - W); if (r < H - 1) nb.push(v + W);+        if (viaCells.has(i)) for (const k2 of real) if (k2 !== k) nb.push(k2 * N + i);+        for (const u of nb) {+          const k2 = Math.floor(u / N), j = u % N; if (lay[k2].net[j] !== id) continue;+          const len = k2 === k ? d : Math.abs(Z[LAYERS[k]] - Z[LAYERS[k2]]);+          const nt = t + len / (0.5 * (sp + (pctC[u] || 0.5) * C_MM_PS));+          if (nt < arrival[u]) { arrival[u] = nt; push(nt, u); }+        }+      }+      { let cnt = 0, mx = 0; for (const k of real) for (let i = 0; i < N; i++) if (lay[k].net[i] === id && Number.isFinite(arrival[k * N + i])) { cnt++; mx = Math.max(mx, arrival[k * N + i]); } console.log(`light: ${n.name} wave reaches ${cnt} cells, the last at ${mx.toFixed(0)} ps`); }+      for (const t of (n.terminals || []).filter(t => t.role === 'sink')) {+        const i = cellOf(t.x, t.y); let best = Infinity;+        for (const k of real) { const R = Math.ceil(0.6 / d), r0 = Math.floor(i / W), c0 = i % W; for (let dr = -R; dr <= R; dr++) for (let dc = -R; dc <= R; dc++) { const r = r0 + dr, c = c0 + dc; if (r < 0 || c < 0 || r >= H || c >= W) continue; const j = r * W + c; if (lay[k].net[j] === id) best = Math.min(best, arrival[k * N + j]); } }+        if (Number.isFinite(best)) arrivals.push({ net: n.name, ref: t.ref, x: t.x, y: t.y, ps: best, from: (n.terminals.find(s => s.role === 'source') || {}).ref });+      }+    }+  }+  let tMax = 1; for (let v = 0; v < arrival.length; v++) if (Number.isFinite(arrival[v])) tMax = Math.max(tMax, arrival[v]);+  // the light layers: flat sheets on the copper, coloured by % of c, the wave front sweeping by arrival time+  BAB.Effect.ShadersStore.fieldsLightVertexShader = `precision highp float;+attribute vec3 position; attribute vec2 uv; uniform mat4 world; uniform mat4 viewProjection; varying vec2 vUV;+void main(void) { vUV = uv; gl_Position = viewProjection * world * vec4(position, 1.0); }`;+  BAB.Effect.ShadersStore.fieldsLightFragmentShader = `precision highp float;+varying vec2 vUV; uniform sampler2D lightTex; uniform float phase; uniform float alpha;+void main(void) {+  vec4 t = texture2D(lightTex, vUV);+  if (t.a < 0.5) discard;+  float pc = t.g;                                    // 0..1 maps 40 %..100 % of c+  vec3 slow = vec3(0.10, 0.20, 0.75), mid = vec3(0.0, 0.75, 0.80), fast = vec3(0.85, 1.0, 0.75);+  vec3 base = pc < 0.3 ? mix(slow, mid, pc / 0.3) : mix(mid, fast, (pc - 0.3) / 0.7);+  float arrived = t.b > 0.5 ? 1.0 : 0.0;+  float dt = phase - t.r;                            // how long ago the front passed this cell (0..1 of the loop)+  float front = arrived * exp(-pow(dt * 40.0, 2.0)) ;+  float wake = arrived * step(0.0, dt) * exp(-dt * 6.0);+  vec3 col = base * (0.35 + 0.65 * wake) + vec3(1.0) * front * 1.2;+  gl_FragColor = vec4(col, alpha * (0.55 + 0.45 * max(front, wake)));+}`;+  const lightMeshes = [];+  for (const k of real) {+    const L = lay[k], tex = new Uint8Array(N * 4);+    for (let i = 0; i < N; i++) {+      const v = k * N + i, pc = pctC[v], at = arrival[v];+      tex[i * 4] = Number.isFinite(at) ? Math.round(Math.min(1, at / tMax) * 255) : 0;+      tex[i * 4 + 1] = Number.isFinite(pc) ? Math.round(Math.max(0, Math.min(1, (pc - 0.4) / 0.6)) * 255) : 0;+      tex[i * 4 + 2] = Number.isFinite(at) ? 255 : 0;+      tex[i * 4 + 3] = L.net[i] >= 0 ? 255 : 0;+    }+    const ltex = new BAB.RawTexture(tex, W, H, BAB.Constants.TEXTUREFORMAT_RGBA, scene, false, false, BAB.Texture.BILINEAR_SAMPLINGMODE, BAB.Constants.TEXTURETYPE_UNSIGNED_BYTE);+    const mesh = BAB.MeshBuilder.CreatePlane('LT-' + L.name, { width: W * d, height: H * d, sideOrientation: BAB.Mesh.DOUBLESIDE }, scene);+    // the plane's uv runs bottom-up; the texture rows run down the board (KiCad y): flip v+    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 mat = new BAB.ShaderMaterial('fields-light-' + L.name, scene, { vertex: 'fieldsLight', fragment: 'fieldsLight' }, { attributes: ['position', 'uv'], uniforms: ['world', 'viewProjection', 'phase', 'alpha'], samplers: ['lightTex'], needAlphaBlending: true });+    mat.setTexture('lightTex', ltex); mat.backFaceCulling = false; mat.alphaMode = BAB.Constants.ALPHA_COMBINE;+    mesh.material = mat; mesh.parent = root; mesh.isPickable = false; mesh.setEnabled(false);+    lightMeshes.push({ k, mesh, mat });+  }+  const LIGHT_LOOP = 3.2;   // seconds per sweep on screen; the real sweep takes tMax picoseconds+  const lightTags = [];+  { const host = document.getElementById('labels'); const seen = new Set();+    for (const a of arrivals.sort((x, y) => x.ps - y.ps)) { const key = a.ref + a.net; if (seen.has(key)) continue; seen.add(key);+      const el = document.createElement('div'); el.className = 'lbl light'; el.hidden = true; host.appendChild(el);+      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 }); } }++  // ---- 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); }+  catch (e) { console.warn('flow: no glow layer: ' + (e && e.message || e)); }++  // ---- the stack: layer planes, vias, board and parts follow the explode ------------------------------+  const boardBox = scene.getMeshByName('fr4_board');+  const partsRoot = scene.getTransformNodeByName('fields-parts');+  const partSide = {}, partAt = {}; for (const it of ((state.fields.parts || {}).items || [])) { partSide[it.ref] = it.side; const i = cellOf(it.x, it.y); if (i >= 0) partAt[it.ref] = { i, x: it.x, y: it.y }; }+  const viaMesh = scene.getMeshByName('barrel');+  function applyStack() {+    for (const [k, Lname] of LAYERS.entries()) { const m = ctx.layerMeshes[Lname]; if (m) m.position.z = zOf(k); }+    for (const t of terrains) t.mesh.position.z = zOf(t.L.k);+    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);+    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; }+  }++  // ---- the river's signposts: each net's name and voltage over the middle of its own copper ------------+  const signs = [];+  {+    const host = document.getElementById('labels');+    for (const [id, n] of nets.entries()) {+      if (!n.loaded && n.railV == null) continue;+      // the copper cell of this net nearest its centroid, on the top copper (else any layer)+      let best = -1, bk = 0;+      for (const L of lay) {+        let sx = 0, sy = 0, cnt = 0; for (let i = 0; i < N; i++) if (L.net[i] === id) { sx += i % W; sy += Math.floor(i / W); cnt++; }+        if (cnt < 30) continue;+        const cx = sx / cnt, cy = sy / cnt; let bd = Infinity;+        for (let i = 0; i < N; i++) if (L.net[i] === id) { const dd = (i % W - cx) ** 2 + (Math.floor(i / W) - cy) ** 2; if (dd < bd) { bd = dd; best = i; } }+        bk = L.k; break;+      }+      if (best < 0) continue;+      const el = document.createElement('div'); el.className = 'lbl river'; el.hidden = true; host.appendChild(el);+      const mv = (v) => v >= 1 ? v.toFixed(2) + ' V' : (v * 1000).toFixed(v < 0.01 ? 1 : 0) + ' mV';+      el.textContent = n.switching ? `${n.name} ${n.switching.lowV || 0} ↔ ${n.switching.highV} V` : n.railV == null ? `${n.name} · rail not given` : n.loaded && n.dropV > 1e-6 ? `${n.name} ${n.railV} V · ${n.ground ? 'rises ' : ''}${mv(n.dropV)}${n.ground ? '' : ' drop'}` : `${n.name} ${n.railV} V`;+      signs.push({ el, k: bk, i: best, x: meta.x0 + ((best % W) + 0.5) * d, y: meta.y0 + (Math.floor(best / W) + 0.5) * d });+    }+  }+  // the parts' own currents: what each part takes from a net and gives to one, from the solve's terminals+  const chipTags = [];+  {+    const host = document.getElementById('labels'), byRef = {};+    for (const n of nets) for (const t of (n.terminals || [])) { const r = byRef[t.ref] = byRef[t.ref] || { x: 0, y: 0, k: 0, ins: {}, outs: {} }; r.x += t.x; r.y += t.y; r.k++; const side = t.role === 'sink' ? r.ins : r.outs; side[n.name] = (side[n.name] || 0) + t.amps; }+    const amp = (a) => a >= 1 ? a.toFixed(1) + ' A' : (a * 1000).toFixed(0) + ' mA';+    const list = (m) => Object.entries(m).sort((a, b) => b[1] - a[1]).map(([net, a]) => `${net} ${amp(a)}`).join(', ');+    for (const [ref, r] of Object.entries(byRef)) {+      const el = document.createElement('div'); el.className = 'lbl chip'; el.hidden = true; host.appendChild(el);+      const ins = list(r.ins), outs = list(r.outs);+      el.innerHTML = `<b>${ref}</b>${ins ? ` <span class="in">in ${ins}</span>` : ''}${outs ? ` <span class="out">out ${outs}</span>` : ''}`;+      const at = partAt[ref] || { x: r.x / r.k, y: r.y / r.k, i: cellOf(r.x / r.k, r.y / r.k) };+      chipTags.push({ el, ref, at, connector: /^(J|P|MC|MP|TP)\d/i.test(ref) });+    }+  }+  scene.onAfterRenderObservable.add(() => {+    const show = (knob.flowOn > 0.5 || knob.river > 0.5) && knob.lightOn < 0.5 && state.view.chipTags !== false;+    const e = engine, w = e.getRenderWidth(), h = e.getRenderHeight(), hostEl = document.getElementById('host'), wm = root.getWorldMatrix();+    for (const t of chipTags) {+      // in the river the nets carry their own signs: the connectors' tags would only crowd them+      if (!show || (knob.river > 0.5 && t.connector)) { t.el.hidden = true; continue; }+      const side = partSide[t.ref] === 'bottom' ? -1 : 1;+      let top = 0; const ms = scene.getTransformNodeByName('part-' + t.ref);+      if (ms) { for (const m of ms.getChildMeshes(false)) { const bb = m.getBoundingInfo().boundingBox; top = Math.max(top, (side > 0 ? bb.maximumWorld : bb.minimumWorld).z); } }+      const z0 = side > 0 ? zOf(0) + (t.at.i >= 0 ? heightAt(0, t.at.i) : 0) : zOf(LAYERS.length - 1);+      const local = new BAB.Vector3(bx(t.at.x), by(t.at.y), z0 + side * 2.2);+      const p = BAB.Vector3.Project(BAB.Vector3.TransformCoordinates(local, wm), BAB.Matrix.Identity(), scene.getTransformMatrix(), camera.viewport.toGlobal(w, h));+      t.el.hidden = p.z > 1 || p.z < 0 || (side < 0 && knob.explode < 0.3 && (state.view.layer || 'F.Cu') !== 'B.Cu');+      t.el.style.left = (p.x / (w / hostEl.clientWidth)) + 'px'; t.el.style.top = (p.y / (h / hostEl.clientHeight)) + 'px';+      void top;+    }+    for (const t of lightTags) {+      if (knob.lightOn < 0.5) { t.el.hidden = true; continue; }+      const local = new BAB.Vector3(bx(t.a.x), by(t.a.y), zOf(0) + 2.2);+      const p = BAB.Vector3.Project(BAB.Vector3.TransformCoordinates(local, wm), BAB.Matrix.Identity(), scene.getTransformMatrix(), camera.viewport.toGlobal(w, h));+      t.el.hidden = p.z > 1 || p.z < 0; t.el.style.left = (p.x / (w / hostEl.clientWidth)) + 'px'; t.el.style.top = (p.y / (h / hostEl.clientHeight)) + 'px';+    }+    // tags that land on each other step down until they clear (top to bottom, one pass)+    const vis = chipTags.concat(lightTags).filter(t => !t.el.hidden).map(t => ({ t, r: t.el.getBoundingClientRect() })).sort((a, b) => a.r.top - b.r.top);+    const placed = [];+    for (const v of vis) {+      let dy = 0, r = v.r;+      for (let guard = 0; guard < 8; guard++) {+        const hit = placed.find(q => r.left < q.right && r.right > q.left && r.top + dy < q.bottom && r.bottom + dy > q.top);+        if (!hit) break; dy = hit.bottom - r.top + 2;+      }+      if (dy) v.t.el.style.top = (parseFloat(v.t.el.style.top) + dy) + 'px';+      placed.push({ left: r.left, right: r.right, top: r.top + dy, bottom: r.bottom + dy });+    }+  });+  scene.onAfterRenderObservable.add(() => {+    const on = knob.river > 0.5;+    if (!on) { for (const s of signs) s.el.hidden = true; return; }+    const e = engine, w = e.getRenderWidth(), h = e.getRenderHeight(), hostEl = document.getElementById('host');+    const wm = root.getWorldMatrix();+    for (const s of signs) {+      const show = knob.explode > 0.3 || s.k === LAYERS.indexOf(state.view.layer || 'F.Cu');+      const local = new BAB.Vector3(bx(s.x), by(s.y), zOf(s.k) + heightAt(s.k, s.i) + 0.6);+      const p = BAB.Vector3.Project(BAB.Vector3.TransformCoordinates(local, wm), BAB.Matrix.Identity(), scene.getTransformMatrix(), camera.viewport.toGlobal(w, h));+      s.el.hidden = !show || p.z > 1 || p.z < 0;+      s.el.style.left = (p.x / (w / hostEl.clientWidth)) + 'px'; s.el.style.top = (p.y / (h / hostEl.clientHeight)) + 'px';+    }+  });++  // ---- the frame ------------------------------------------------------------------------------------+  const ease = (cur, to, dt, rate) => { const k = 1 - Math.exp(-dt * rate); return Math.abs(to - cur) < 1e-3 ? to : cur + (to - cur) * k; };+  let fpsAcc = 0, fpsN = 0, tierChecked = false, sinceStart = 0;+  scene.onBeforeRenderObservable.add(() => {+    const dt = frameDt();+    const was = [knob.explode, knob.river];+    knob.explode = ease(knob.explode, knob.explodeTo, dt, 1.4);+    knob.river = ease(knob.river, knob.riverTo, dt, 1.1);+    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);+    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) {+      const on = knob.river > 0.01; t.mesh.setEnabled(on);+      if (!on) continue;+      t.mat.setFloat('tide', knob.tide); t.mat.setFloat('lift', knob.river); t.mat.setFloat('time', knob.time);+      t.mat.setVector3('camPos', camera.globalPosition || camera.position);+      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;+    streak.setEnabled(active);+    if (!active) return;+    if (cdfNet === undefined || cdfNet !== (state.view.net || null)) buildSpawn(state.view.net || null);+    step(dt);+    if (knob.energyOn > 0.01) { if (!ecdf) buildEnergySpawn(); stepEnergy(dt); }+    upload();+    // the quality tier: a laptop that cannot hold 40 fps drops to fewer particles, once+    if (!ctx.clock.capture) { sinceStart += dt; fpsAcc += engine.getFps(); fpsN++; if (!tierChecked && sinceStart > 4) { tierChecked = true; const fps = fpsAcc / fpsN; if (fps < 38 && P > COUNTS.low) { P = Math.max(COUNTS.low, Math.floor(P * 0.5)); console.log(`flow: ${fps.toFixed(0)} fps, particles down to ${P}`); } else console.log(`flow: ${fps.toFixed(0)} fps with ${P} particles (${tier} tier)`); } }+  });++  // ---- what the badge and the legend say -------------------------------------------------------------+  // the key: what every thing on screen is, in plain words, so nobody has to guess+  const xf = exaggeration >= 100 ? Math.round(exaggeration).toLocaleString() : exaggeration.toFixed(0);+  function describe(q) {+    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',+      '<b>Part tags</b> = when the front reaches that part',+      'Each stretch of copper has its own speed; the delay to a part is the sum along the way, not the slowest stretch',+      'er = ' + ER + ' (FR4); er_eff by Hammerstad for traces over a plane'];+    if (q === 'energy') return ['<b>Energy</b>: where the energy really travels',+      '<b>Pink particles</b> = energy, flowing through the board material between the copper layers (E × H)',+      '<b>Blue dots</b> = the electrons in the copper, at their real speed: ' + (DRIFT * jHi).toFixed(1) + ' mm/s at ' + jHi.toFixed(0) + ' A/mm², moving against the current',+      'The copper guides the energy; the energy itself travels in the fields around it, at about half the speed of light',+      '<b>Pink rising</b> into a part = that part taking its energy',+      'DC, drawn inside the board only: part of the energy travels in the air around it'];+    if (q === 'river') return ['<b>River</b>: the circuit as water',+      '<b>Height and colour</b> = voltage (1 V = ' + kR.toFixed(2) + ' mm)',+      '<b>Water speed and foam</b> = current density',+      '<b>Waterfall</b> = current dropping through a part to ground; <b>fountain</b> = a source lifting it back up',+      '<b>SW</b> rises and falls between its rails, slowed to ' + TIDE_HZ + ' Hz',+      'Slopes within a net exaggerated ×' + xf + ' so millivolts show'];+    return ['<b>Flow</b>: where the current goes',+      '<b>Each particle</b> = current, moving the way it flows (+ to −)',+      '<b>Faster and brighter</b> = more current per mm² of copper',+      '<b>Colour under them</b> = the same current density, A/mm²',+      '<b>Through a via</b>: the share of particles = the share of current',+      'Chip tags: the current each part takes in and puts out'];+  }+  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.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 === '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 === '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 %';+    } else if (q === 'energy') {+      leg.hidden = false; leg.classList.remove('magma'); leg.classList.add('energy');+      document.getElementById('legendTitle').textContent = 'Energy flow, W/mm² (log): the pink particles\' glow'; document.getElementById('legLo').textContent = sLo.toFixed(3); document.getElementById('legHi').textContent = sHi.toFixed(2);+    } else if (q === 'flow') {+      document.getElementById('legendTitle').textContent = 'Current density, A/mm² (log): the map, and the particles\' speed and glow';+    }+  }+  // the key is off by default (the legend says what the colours are); the Key button shows it and remembers+  let keyShown = false; try { keyShown = localStorage.getItem('fields.keyOn') === '1'; } catch (e) { void e; }+  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 === '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]];+      if (!Number.isFinite(pc)) return 'no copper';+      return `${n ? n.name + ' · ' : ''}${(pc * 100).toFixed(1)} % of c (${(pc * C_MM_PS * 1000).toFixed(0)} mm/ns)${Number.isFinite(at) ? ` · reached ${at.toFixed(0)} ps after its source` : ''}`;+    }+    if (state.view.quantity === 'energy') {+      const i = cellOf(x, y); if (i < 0) return ''; const k = LAYERS.indexOf(Lname), L = lay[k] || lay[0];+      const G = gaps[0]; let txt = '';+      if (G) { const m = Math.hypot(G.Sx[i], G.Sy[i]); const top = nets[G.info[i * 2]], bot = nets[G.info[i * 2 + 1]]; txt = m > 0 ? `energy ${m.toFixed(2)} W/mm²` + (top && bot ? ` (${top.name} over ${bot.name}, ${(voltsAt(G.a, i) - voltsAt(G.b, i)).toFixed(1)} V across ${G.h.toFixed(2)} mm)` : '') : 'no energy flow here'; }+      const j = L ? Math.hypot(L.jx[i], L.jy[i]) : 0; if (j > 0) txt += ` · electrons ${(DRIFT * j).toFixed(3)} mm/s`;+      return txt;+    }+    const k = LAYERS.indexOf(Lname); if (k < 0) return '';+    const i = cellOf(x, y); if (i < 0) return '';+    const L = lay[k], id = L.net[i]; if (id < 0) return 'no copper';+    const n = nets[id], v = L.volt[i], m = Math.hypot(L.jx[i], L.jy[i]);+    const vtxt = n.switching ? `switching ${n.switching.lowV || 0} to ${n.switching.highV} V${n.switching.freqHz ? ' at ' + (n.switching.freqHz / 1000).toFixed(0) + ' kHz' : ''}` : Number.isFinite(v) ? (n.railV == null ? `${(v * 1000).toFixed(2)} mV of drop (rail unknown)` : `${v.toFixed(4)} V`) : 'rail unknown';+    return `${n.name} · ${vtxt}${m > 0 ? ` · ${m.toFixed(1)} A/mm²` : ''}`;+  }+  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, 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+13−2
@@ -52,14 +52,23 @@ body{margin:0;background:var(--bg);color:var(--text-body);font-family:"Satoshi", .host{position:absolute;inset:0} .labels{position:absolute;inset:0;pointer-events:none;overflow:hidden} .lbl{position:absolute;transform:translate(-50%,-140%);font-family:"JetBrains Mono",monospace;font-size:12px;color:var(--text);background:rgba(13,17,23,.75);border:1px solid var(--border);border-radius:6px;padding:2px 7px;white-space:nowrap}+.lbl.chip{font-size:11px;padding:2px 6px;border-color:rgba(255,255,255,.18);background:rgba(13,17,23,.82)}.lbl.chip b{color:#fff;margin-right:2px}.lbl.chip .in{color:#ffb86b;margin-left:4px}.lbl.chip .out{color:#7ee0ff;margin-left:4px}+.lbl.river{border-color:rgba(89,217,250,.45);color:#c8f3ff} .legend{position:absolute;right:16px;bottom:16px;background:rgba(22,27,34,.85);border:1px solid var(--border);border-radius:8px;padding:8px 10px;font-size:12px;color:var(--text-body);backdrop-filter:blur(4px);min-width:170px} .legend .ramp{height:10px;border-radius:5px;margin:6px 0;background:linear-gradient(90deg,#000004,#2a0a4a,#621a5f,#97305f,#c94d3f,#ee7a1c,#f9ad10,#f7de4d,#fcffa4)}+.legend.amps .ramp{background:linear-gradient(90deg,#05387f,#0073e6,#26b8ff,#9ee6ff,#ffffff)}+.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)}+.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)} .readout{position:absolute;left:16px;bottom:16px;background:rgba(22,27,34,.85);border:1px solid var(--border);border-radius:8px;padding:8px 10px;font-family:"JetBrains Mono";font-size:12px;color:var(--text);min-width:160px;backdrop-filter:blur(4px)} .readout .k{color:var(--text-2)} .views{position:absolute;left:16px;top:12px;display:flex;gap:4px} .chip{background:rgba(22,27,34,.85);border:1px solid var(--border);border-radius:8px;padding:5px 9px;font-size:12px;color:var(--text);cursor:pointer;transition:background .15s ease}+.ghostctl{display:inline-flex;align-items:center;gap:6px;cursor:default}.ghostctl[hidden]{display:none}.ghostctl svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:1.8}.ghostctl input{width:90px;accent-color:var(--accent)}.ghostctl span{font-family:"JetBrains Mono";font-size:11px;color:var(--text-2);min-width:28px} .chip:hover{background:var(--elevated)}.chip.is-on{box-shadow:inset 0 0 0 1px var(--accent);color:var(--accent-hover)} .toasts{position:fixed;left:50%;bottom:20px;transform:translateX(-50%);display:flex;flex-direction:column-reverse;gap:8px;z-index:100;pointer-events:none} .toast{pointer-events:auto;background:var(--overlay);border:1px solid var(--border);border-left:3px solid var(--accent);color:var(--text);padding:9px 14px;border-radius:8px;font-size:13px;box-shadow:0 8px 24px rgba(0,0,0,.4);animation:in .18s ease;max-width:560px}@@ -88,13 +97,14 @@ body{margin:0;background:var(--bg);color:var(--text-body);font-family:"Satoshi",     <div class="actions">       <button class="ibtn" id="btnNets" data-tip="Nets, layers and hot parts" aria-pressed="false"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></svg></button>       <button class="ibtn" id="btnIssues" data-tip="Issues and how it was solved" aria-pressed="false"><svg viewBox="0 0 24 24"><path d="M12 9v4M12 17h.01M10.3 3.9 2.5 17a2 2 0 0 0 1.7 3h15.6a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/></svg></button>+      <button class="ibtn" id="btnExplode" data-tip="Pull the layers apart to see the inner copper and the current in the vias" aria-pressed="false"><svg viewBox="0 0 24 24"><path d="M4 6l8-3 8 3-8 3z"/><path d="M4 12l8 3 8-3"/><path d="M4 18l8 3 8-3"/></svg></button>       <button class="ibtn" id="btnParts" data-tip="The parts' 3D bodies over the field: see-through, solid, or off" aria-pressed="false"><svg viewBox="0 0 24 24"><path d="M12 3l8 4.5v9L12 21l-8-4.5v-9z"/><path d="M12 12l8-4.5M12 12v9M12 12L4 7.5"/></svg></button>       <button class="ibtn" id="btnLight" data-tip="Light the underside (dark parts)" aria-pressed="false"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg></button>     </div>   </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>+      <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></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>Nets, by what they carry</h3><div id="nets"></div></div>@@ -103,7 +113,8 @@ 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></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="flowbadge" id="flowBadge" hidden></div>       <div class="readout" id="readout"><span class="k">hover the board</span></div>       <div class="legend" id="legend"><div id="legendTitle">Current, A/mm²</div><div class="ramp"></div><div class="ends"><span id="legLo">0</span><span id="legHi">0</span></div></div>     </main>