← Commit history

adom-fields 0.7.12: the Signals view in 3D (facing copper as glowing plates with the field between, one check at a time) and its narrated video on the ESC G431; PDN finds regulators by their VOUT pin

John Lauer ·9b1e1da152 · ·parent 4038ff7
21 files changed +187−29
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-fields"-version = "0.7.11"+version = "0.7.12" dependencies = [  "clap",  "png",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-fields"-version = "0.7.11"+version = "0.7.12" 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+9−1
@@ -23,7 +23,7 @@  ## Watch each view -Each clip is the app on the 12 V to 5 V buck, explaining the view in its own voice (the narration the app writes from the solve, voiced by adom-tts).+Each clip is the app on the 12 V to 5 V buck (Signals on the ESC G431), explaining the view in its own voice (the narration the app writes from the solve, voiced by adom-tts).  ### Current density @@ -129,6 +129,14 @@ Where switching noise can ring and spread: each power pour over the ground plane  [Download the noise video](docs/videos/noise.mp4) +### Signals++Copper as capacitors, on the ESC G431 (the buck has no signal checks): each checked pair of facing copper glows as two plates with the electric field crossing between them, one at a time, with its picofarads. The three motor phases' switching copper has 25 to 36 pF to the ground plane under it, charged and emptied on every PWM edge; the two inner planes make 134 pF.++<video controls preload="metadata" width="1080" poster="docs/videos/signals-poster.jpg" src="docs/videos/signals.mp4"></video>++[Download the signals video](docs/videos/signals.mp4)+ ### Power  How stiff each rail is, from slow changes to fast ones: each rail's impedance seen from its load pin, 1 kHz to 1 GHz, against its target, while the frequency sweeps. Each part's column is its share of the load's current at that frequency (the regulator or the supply leads at the low end, the bulk capacitors, then the small ones nearest the pin); the glass sleeve at the load pin is the target. ngspice runs the same circuit as a cross-check.
SKILL.md+3−1
@@ -148,9 +148,11 @@ After applying a step, run `analyze` again: the issue goes away or says how far  `ui view --quantity noise`: the largest cavity rings in 3D (its real modes, red and blue phases, white nodal lines), the switching copper pulses, and quiet nets glow by the millivolts they pick up, with arcs at the closest approach. +`ui view --quantity signals` (the Signals button pulls the layers apart): each `signals.capacitance` check lights its real overlap on both layers as two glowing plates with the electric field crossing between them as sparks, one check at a time (seven seconds each, or the one picked in the panel), tagged with its picofarads, overlap and dielectric; differential pairs glow red and blue, tagged with skew and impedance. The via barrels hide so the plates read.+ ## Power delivery: rail impedance (0.7.9) -`analyze` also checks each rail's impedance seen from its load pin (a pin of the chip with the most pins on the rail, else an outgoing connector; `pdn.<net>.load` names another), 1 kHz to 1 GHz. What feeds the rail comes from the spec's `sources`, the modelled converter, an on-board regulator (named like one: TLV7…, LDO, AMS1117…, bridging the rail and a higher one), or the connector the solve found the current entering by; a rail with none of these is listed `skipped` with the reason, never guessed:+`analyze` also checks each rail's impedance seen from its load pin (a pin of the chip with the most pins on the rail, else an outgoing connector; `pdn.<net>.load` names another), 1 kHz to 1 GHz. What feeds the rail comes from the spec's `sources`, the modelled converter, an on-board regulator (named like one: TLV7…, LDO, AMS1117…, bridging the rail and a higher one), a chip whose pin on the rail is named as an output (VOUT, OUT: KiCad's pin function), or the connector the solve found the current entering by; a rail with none of these is listed `skipped` with the reason, never guessed: - **The model**: branches in parallel at the load pin. The source: a modelled converter's output is flat (5 mΩ) below its loop crossover (fsw/10, `crossoverHz`), then an inductance that resonates with the output capacitors there, damped to Q 1 (about a 50° phase margin), fading out a decade above; a linear regulator the same with a 100 kHz crossover; a connector-fed rail is about a metre of supply leads (30 mΩ, 0.7 µH; `sourceOhm`, `sourceNh`). Each capacitor: its board value (nominal, no DC-bias derating), ESR by type, body inductance from its pad span, and the mounting inductance of a microstrip over the plane from it to the load pin. The rail's copper over ground as a plate capacitor. - **The target**: V·tolPct/100 / stepA (5 %; the step is half the rail's spec load, or the converter's output current on its input rail), over a band: up to the switching edge's knee on a converter input, 100 MHz at a chip pin, 1 MHz at a connector (`fminHz`, `fmaxHz`, or `targetOhm` outright). - **The fix**: a peak over the target is `pdn-impedance`, with an `add-part` capacitor (value, package, pin) chosen by re-solving with each candidate, up to three, each with the ratio it reaches; still over after three is a `pour` action (a plane pair). A converter's input rail also gets Middlebrook's check: under 10 dB below the converter's negative input resistance V²/P is `input-filter-stability`, with a damping capacitor and its ESR.
bin/adom-fields
⋯ 1 unchanged line ⋯
docs/release-0.7.12.mdadded+5
@@ -0,0 +1,5 @@+# adom-fields 0.7.12++- **The Signals view in 3D.** Each capacitance check lights its real overlap on both layers as two plates with the electric field crossing between them, one at a time, with its picofarads; differential pairs glow in two colours with their skew and impedance. The layers pull apart and the via barrels hide. A narrated video on the page, on the ESC G431.+- **PDN: regulators by pin function.** A chip whose pin on a rail is named VOUT or OUT (KiCad's pin function) is that rail's regulator, whatever its part number: on the ESC, the TPSM365 makes +5V and the ZXTR2012 makes +12V.+- The review hint says when ground offset was not solved (ground not a loaded net) instead of printing null.
docs/videos/signals-poster.jpgadded
⋯ 1 unchanged line ⋯
docs/videos/signals.mp4added
⋯ 1 unchanged line ⋯
package.json+7−2
@@ -1,7 +1,7 @@ {   "slug": "adom-fields",   "type": "app",-  "version": "0.7.11",+  "version": "0.7.12",   "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.",@@ -85,7 +85,12 @@     "anti-resonance",     "input filter stability",     "middlebrook",-    "ngspice pdn"+    "ngspice pdn",+    "signals view",+    "copper capacitance",+    "switch node capacitance",+    "plane capacitance",+    "differential pair skew"   ],   "sample_prompts": [     {
page.json+7−9
@@ -1,7 +1,7 @@ {   "slug": "adom-fields",   "type": "app",-  "version": "0.7.11",+  "version": "0.7.12",   "title": "Adom Fields",   "brief": "Adom Fields: where the current and the heat go on your board.",   "summary": "Current density on the copper, heat flow through the pours and the vias, and the temperature over the board, solved from the board file itself and shown on the board in 3D. The AI does the analysis and drives the view; the human orbits, zooms, hovers for the number under the cursor, and reads the nets by what they carry and the issues by what to fix. A step in Adom's AI Flow: the walkthrough goes into the video.",@@ -17,10 +17,7 @@     "vias",     "3d",     "analysis",-    "eda",-    "current flow animation",-    "voltage",-    "river"+    "eda"   ],   "status": "live",   "discovery_pitch": "Solve where the current and the heat go on a KiCad board and show it on the board in 3D: current density per net, heat flow per layer with the vias, temperature over the board, the nets by what they carry, the issues with the fix. One command to solve, one to serve, one to drive it on camera.",@@ -40,10 +37,11 @@     "fields",     "flow of current",     "river view",-    "voltage drop in 3d",-    "ground bounce",+    "energy flow on a board",+    "poynting vector",+    "speed of light on a pcb",+    "electron drift speed",     "exploded layer stack",-    "current through vias",     "explain the view out loud",     "narrate the fields",     "energy cycle",@@ -126,4 +124,4 @@     "name": "John Lauer",     "email": "[email protected]"   }-}\ No newline at end of file+}
skills/adom-fields/SKILL.md+3−1
@@ -148,9 +148,11 @@ After applying a step, run `analyze` again: the issue goes away or says how far  `ui view --quantity noise`: the largest cavity rings in 3D (its real modes, red and blue phases, white nodal lines), the switching copper pulses, and quiet nets glow by the millivolts they pick up, with arcs at the closest approach. +`ui view --quantity signals` (the Signals button pulls the layers apart): each `signals.capacitance` check lights its real overlap on both layers as two glowing plates with the electric field crossing between them as sparks, one check at a time (seven seconds each, or the one picked in the panel), tagged with its picofarads, overlap and dielectric; differential pairs glow red and blue, tagged with skew and impedance. The via barrels hide so the plates read.+ ## Power delivery: rail impedance (0.7.9) -`analyze` also checks each rail's impedance seen from its load pin (a pin of the chip with the most pins on the rail, else an outgoing connector; `pdn.<net>.load` names another), 1 kHz to 1 GHz. What feeds the rail comes from the spec's `sources`, the modelled converter, an on-board regulator (named like one: TLV7…, LDO, AMS1117…, bridging the rail and a higher one), or the connector the solve found the current entering by; a rail with none of these is listed `skipped` with the reason, never guessed:+`analyze` also checks each rail's impedance seen from its load pin (a pin of the chip with the most pins on the rail, else an outgoing connector; `pdn.<net>.load` names another), 1 kHz to 1 GHz. What feeds the rail comes from the spec's `sources`, the modelled converter, an on-board regulator (named like one: TLV7…, LDO, AMS1117…, bridging the rail and a higher one), a chip whose pin on the rail is named as an output (VOUT, OUT: KiCad's pin function), or the connector the solve found the current entering by; a rail with none of these is listed `skipped` with the reason, never guessed: - **The model**: branches in parallel at the load pin. The source: a modelled converter's output is flat (5 mΩ) below its loop crossover (fsw/10, `crossoverHz`), then an inductance that resonates with the output capacitors there, damped to Q 1 (about a 50° phase margin), fading out a decade above; a linear regulator the same with a 100 kHz crossover; a connector-fed rail is about a metre of supply leads (30 mΩ, 0.7 µH; `sourceOhm`, `sourceNh`). Each capacitor: its board value (nominal, no DC-bias derating), ESR by type, body inductance from its pad span, and the mounting inductance of a microstrip over the plane from it to the load pin. The rail's copper over ground as a plate capacitor. - **The target**: V·tolPct/100 / stepA (5 %; the step is half the rail's spec load, or the converter's output current on its input rail), over a band: up to the switching edge's knee on a converter input, 100 MHz at a chip pin, 1 MHz at a connector (`fminHz`, `fmaxHz`, or `targetOhm` outright). - **The fix**: a peak over the target is `pdn-impedance`, with an `add-part` capacitor (value, package, pin) chosen by re-solving with each candidate, up to three, each with the ratio it reaches; still over after three is a `pour` action (a plane pair). A converter's input rail also gets Middlebrook's check: under 10 dB below the converter's negative input resistance V²/P is `input-filter-stability`, with a damping capacitor and its ESR.
src/board.rs+2−2
@@ -10,7 +10,7 @@ pub struct Track { pub net: String, pub layer: String, pub a: (f64, f64), pub b: #[derive(Debug, Clone)] pub struct Via { pub net: String, pub x: f64, pub y: f64, pub size: f64, pub drill: f64 } #[derive(Debug, Clone)]-pub struct Pad { pub reference: String, pub pad: String, pub net: String, pub x: f64, pub y: f64, pub layers: Vec<String>, pub area: f64 }+pub struct Pad { pub reference: String, pub pad: String, pub net: String, pub x: f64, pub y: f64, pub layers: Vec<String>, pub area: f64, /** the symbol pin's name when the file has it (KiCad pinfunction: VIN, VOUT_3, GND) */ pub func: String }  /// A copper pour whose fill the file does not carry (a Fusion/EAGLE polygon): the outline, its net and /// layer, and the isolate distance; the raster fills it (see `Raster::draw_board`).@@ -118,7 +118,7 @@ impl Board {                     }                     for pts in shapes { b.pad_polys.push(Poly { net: net.clone(), layer: l.clone(), pts }); }                 }-                b.pads.push(Pad { reference: reference.clone(), pad: p.atom(1).map(str::to_string).or_else(|| p.num(1).map(|n| format!("{}", n as i64))).unwrap_or_default(), net, x, y, layers: on, area });+                b.pads.push(Pad { reference: reference.clone(), pad: p.atom(1).map(str::to_string).or_else(|| p.num(1).map(|n| format!("{}", n as i64))).unwrap_or_default(), net, x, y, layers: on, area, func: p.kid("pinfunction").and_then(|k| k.atom(1)).map(str::to_string).unwrap_or_default() });             }         }         Ok(b)
src/eagle.rs+2−2
@@ -116,14 +116,14 @@ pub fn load(text: &str) -> Result<Board, String> {                 let on_top = p.attribute("layer").unwrap_or("1") == "1";                 let layer = if on_top != mirror { "F.Cu" } else { "B.Cu" };                 b.pad_polys.push(Poly { net: net.clone(), layer: layer.into(), pts: corners(dx, dy) });-                b.pads.push(Pad { reference: reference.clone(), pad: name, net, x: cx, y: cy, layers: vec![layer.into()], area: dx * dy });+                b.pads.push(Pad { reference: reference.clone(), pad: name, net, x: cx, y: cy, layers: vec![layer.into()], area: dx * dy, func: String::new() });             } else {                 let drill = f(&p, "drill");                 let dia = f(&p, "diameter").max(drill + 2.0 * ring(drill, pad_rv, pad_min, pad_max));                 let ring = |l: &str| Poly { net: net.clone(), layer: l.into(), pts: (0..24).map(|k| { let a = k as f64 / 24.0 * std::f64::consts::TAU; (cx + dia / 2.0 * a.cos(), cy + dia / 2.0 * a.sin()) }).collect() };                 let on: Vec<String> = b.layer_names.clone();                 for l in &on { b.pad_polys.push(ring(l)); }-                b.pads.push(Pad { reference: reference.clone(), pad: name, net, x: cx, y: cy, layers: on, area: std::f64::consts::PI * dia * dia / 4.0 });+                b.pads.push(Pad { reference: reference.clone(), pad: name, net, x: cx, y: cy, layers: on, area: std::f64::consts::PI * dia * dia / 4.0, func: String::new() });             }         }     }
src/fields.rs+1−1
@@ -384,7 +384,7 @@ pub fn summary(f: &Fields, b: &Board, spec: &Spec, textures: &[String]) -> Value     use super::*;     #[test] fn board_with_block_lowers_tab_rise_and_reports_heat_received() {         let mut b=Board{ x0:0.,y0:0.,x1:12.,y1:12., ..Board::default() };-        b.pads.push(crate::board::Pad{reference:"U1".into(),pad:"1".into(),net:"GND".into(),x:6.,y:6.,layers:vec!["F.Cu".into()],area:4.});+        b.pads.push(crate::board::Pad{reference:"U1".into(),pad:"1".into(),net:"GND".into(),x:6.,y:6.,layers:vec!["F.Cu".into()],area:4.,func:String::new()});         let mut spec:Spec=serde_json::from_value(json!({"copperUm":35,"hot":{"U1":{"watts":0.2,"tabNet":"GND"}}})).unwrap();         let baseline=analyze(&b,&spec,1.,60.,&mut |_|{});         spec.heatsinks.push(serde_json::from_value(json!({"name":"block","layer":"F.Cu","boundsMm":[2,2,10,10],"thicknessMm":3,"conductivityWPerMK":200,"contactResistanceMm2KPerW":100,"zCells":3})).unwrap());
src/flow.rs+1−1
@@ -146,7 +146,7 @@ pub fn write(out: &Path, f: &Fields, b: &Board, spec: &Spec) -> Result<Value, St         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. });+            b.pads.push(Pad { reference: r.into(), pad: "1".into(), net: net.into(), x, y, layers: vec!["F.Cu".into()], area: 1., func: String::new() });         } }         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 |_| {});
src/main.rs+1−1
@@ -163,7 +163,7 @@ fn main() {                     ok(&format!("{} nets solved, {} hot parts, {} issue(s), in {} s: {}", nets, s["chips"].as_array().map(|a| a.len()).unwrap_or(0), issues.len(), s["seconds"], out.join("fields.json").display()), &[                         format!("Peak current density {:.0} A/mm²; peak rise {:.0} °C still air, {:.0} °C moving air, budget {:.0} °C; Joule loss {} W on top of {} W from the tabs.", s["scales"]["jMax"].as_f64().unwrap_or(0.0), s["heat"]["peakRiseC"].as_f64().unwrap_or(0.0), s["heat"]["peakRiseMovingC"].as_f64().unwrap_or(0.0), s["heat"]["budgetC"].as_f64().unwrap_or(0.0), s["heat"]["jouleW"], s["heat"]["tabW"]),                         issues.iter().take(3).map(|i| format!("{} {}: {}", i["severity"].as_str().unwrap_or(""), i["kind"].as_str().unwrap_or(""), i["what"].as_str().unwrap_or(""))).collect::<Vec<_>>().join(" | "),-                        { let r = &s["review"]; let c = &r["converter"]; let g = &r["ground"]; format!("Review: {}{}{}.", if c.is_null() { "no switching converter modelled".to_string() } else { format!("hot loop {} {} mm² (best {} mm²), emissions margin {} dB at {} MHz (FCC B, loop only), output ripple {:.1} mV", c["hotLoop"]["cap"].as_str().unwrap_or("?"), c["hotLoop"]["mm2"], c["idealMm2"], c["emissions"]["worstMarginDb"], c["emissions"]["atMHz"], c["rippleOutMv"].as_f64().unwrap_or(0.0)) }, if g.is_null() { String::new() } else { format!("; ground offset {} mV, {} island(s), edge stitched {} %", g["offsetMv"], g["islands"].as_array().map_or(0, |x| x.len()), g["stitching"]["edgeCoveredPct"]) }, r["reference"].as_array().map(|a| a.iter().filter(|x| x["unreferencedPct"].as_f64().unwrap_or(0.0) > 5.0).map(|x| format!("; {} {}% without ground under it", x["net"].as_str().unwrap_or(""), x["unreferencedPct"])).collect::<String>()).unwrap_or_default()) },+                        { let r = &s["review"]; let c = &r["converter"]; let g = &r["ground"]; format!("Review: {}{}{}.", if c.is_null() { "no switching converter modelled".to_string() } else { format!("hot loop {} {} mm² (best {} mm²), emissions margin {} dB at {} MHz (FCC B, loop only), output ripple {:.1} mV", c["hotLoop"]["cap"].as_str().unwrap_or("?"), c["hotLoop"]["mm2"], c["idealMm2"], c["emissions"]["worstMarginDb"], c["emissions"]["atMHz"], c["rippleOutMv"].as_f64().unwrap_or(0.0)) }, if g.is_null() { String::new() } else { format!("; {}{} island(s), edge stitched {} %", if g["offsetMv"].is_number() { format!("ground offset {} mV, ", g["offsetMv"]) } else { "ground offset not solved (ground is not a loaded net), ".to_string() }, g["islands"].as_array().map_or(0, |x| x.len()), g["stitching"]["edgeCoveredPct"]) }, r["reference"].as_array().map(|a| a.iter().filter(|x| x["unreferencedPct"].as_f64().unwrap_or(0.0) > 5.0).map(|x| format!("; {} {}% without ground under it", x["net"].as_str().unwrap_or(""), x["unreferencedPct"])).collect::<String>()).unwrap_or_default()) },                         { let all = s["review"]["pdn"]["rails"].as_array().cloned().unwrap_or_default(); let rs: Vec<Value> = all.iter().filter(|r| r["skipped"].is_null()).cloned().collect(); let sk: String = all.iter().filter(|r| !r["skipped"].is_null()).map(|r| format!("; {} not checked: {}", r["net"].as_str().unwrap_or(""), r["skipped"].as_str().unwrap_or(""))).collect(); if rs.is_empty() { format!("PDN: no rail checked{}.", sk) } else { format!("PDN (|Z| seen from each load pin against V·tol/step): {}{}.", rs.iter().map(|r| format!("{} {}× target at {} Hz (from {})", r["net"].as_str().unwrap_or(""), r["worst"]["ratio"], r["worst"]["hz"], r["load"].as_str().unwrap_or(""))).collect::<Vec<_>>().join(", "), if rs.iter().all(|r| r["spice"]["ran"] == true) { format!("; ngspice agrees within {} %{}", rs.iter().map(|r| r["spice"]["maxErrPct"].as_f64().unwrap_or(0.0)).fold(0.0, f64::max), sk) } else { sk }) } },                         { let p = s["fixPlan"].as_array().cloned().unwrap_or_default(); if p.is_empty() { "Fix plan: nothing to change.".to_string() } else { format!("Fix plan ({} step(s), fields.json fixPlan has the actions): {}", p.len(), p.iter().take(5).map(|x| format!("{}) [{}] {}", x["step"], x["kind"].as_str().unwrap_or(""), x["do"].as_str().unwrap_or(""))).collect::<Vec<_>>().join(" ")) } },                         if s["parts"].is_null() { "Parts: skipped (--no-parts).".to_string() } else { format!("Parts: {} with 3D models, {} as boxes, {} bare{}.", s["parts"]["modelled"], s["parts"]["boxed"], s["parts"]["bare"], if s["parts"]["failed"].as_array().map_or(false, |a| !a.is_empty()) { "; some models did not convert (see the log above; --models-dir adds a folder to search)" } else { "" }) },
src/pdn.rs+5−1
@@ -100,7 +100,11 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, conv: &Value, issues: &mut Vec         let bridges = |r: &str| { let ns = nets_of(r); ns.contains(&name) && ns.contains(&gnd) && ns.iter().any(|x| *x != name && rail(x).map_or(false, |vx| vx > v)) };         const REG: [&str; 26] = ["ldo", "reg", "tlv7", "tps7", "lp59", "lp29", "lp38", "ap21", "ap7", "mcp17", "xc62", "ams1117", "lm1117", "ld11", "ld39", "ldl", "lm317", "lm78", "78l", "ncp1", "ht7", "rt9", "sgm2", "me62", "adp1", "max88"];         let looks_reg = |r: &str| b.values.get(r).map_or(false, |v| { let l = v.to_ascii_lowercase(); REG.iter().any(|k| l.contains(k)) });-        let reg: Option<String> = if is_out { None } else { srcs.iter().find(|r| r.starts_with('U')).cloned().or_else(|| refs.iter().find(|r| r.starts_with('U') && bridges(r) && looks_reg(r)).cloned()) };+        // a chip whose pin on this rail is named as an output (VOUT, OUT, VO) is its regulator, whatever it is called+        let out_pin = |r: &str| b.pads.iter().any(|p| p.reference == r && p.net == name && { let f = p.func.to_ascii_uppercase(); let f = f.split('_').next().unwrap_or(""); f == "VOUT" || f == "OUT" || f == "VO" || f == "OUTPUT" });+        let reg: Option<String> = if is_out { None } else { srcs.iter().find(|r| r.starts_with('U')).cloned()+            .or_else(|| refs.iter().find(|r| r.starts_with('U') && out_pin(r) && nets_of(r).contains(&gnd)).cloned())+            .or_else(|| refs.iter().find(|r| r.starts_with('U') && bridges(r) && looks_reg(r)).cloned()) };         let entry: Vec<String> = if !srcs.is_empty() { srcs.iter().filter(|r| r.starts_with('J') || r.starts_with('P')).cloned().collect() } else { f.flow.get(&name).map(|nf| { let mut v: Vec<String> = nf.terminals.iter().filter(|t| t.side == "ext" && (t.reference.starts_with('J') || t.reference.starts_with('P'))).map(|t| t.reference.clone()).collect(); v.dedup(); v }).unwrap_or_default() };         if !is_out && reg.is_none() && entry.is_empty() && cfg.sourceOhm.is_none() {             let maybe: Vec<String> = refs.iter().filter(|r| r.starts_with('U') && bridges(r) && b.pads.iter().filter(|p| p.reference == **r).count() <= 8).cloned().collect();
src/serve.rs+1
@@ -60,6 +60,7 @@ fn handle(mut req: tiny_http::Request, app: Arc<Mutex<App>>) {         ("GET", "/flow.js") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("flow.js", include_str!("ui/flow.js"))).with_header(tiny_http::Header::from_bytes("Content-Type", "application/javascript").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); }         ("GET", "/noise.js") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("noise.js", include_str!("ui/noise.js"))).with_header(tiny_http::Header::from_bytes("Content-Type", "application/javascript").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); }         ("GET", "/pdn.js") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("pdn.js", include_str!("ui/pdn.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", "/signals.js") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("signals.js", include_str!("ui/signals.js"))).with_header(tiny_http::Header::from_bytes("Content-Type", "application/javascript").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); }         ("GET", "/ground.js") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("ground.js", include_str!("ui/ground.js"))).with_header(tiny_http::Header::from_bytes("Content-Type", "application/javascript").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); }         ("GET", "/emc.js") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("emc.js", include_str!("ui/emc.js"))).with_header(tiny_http::Header::from_bytes("Content-Type", "application/javascript").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); }         ("GET", "/magnetic.js") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("magnetic.js", include_str!("ui/magnetic.js"))).with_header(tiny_http::Header::from_bytes("Content-Type", "application/javascript").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); }
src/ui/app.js+10−4
@@ -198,6 +198,7 @@     if (q === 'current') return `This is current density: how many amps flow through each square millimetre of copper. Dark is quiet copper, yellow is where the current crowds.${n0 ? ` The busiest spot is on ${n0.net}, at about ${Math.round(n0.peakJ)} amps per square millimetre, on the ${n0.peakLayer === 'F.Cu' ? 'top' : n0.peakLayer === 'B.Cu' ? 'bottom' : 'inner'} copper.` : ''} Above about sixty amps per square millimetre, a narrow neck starts to heat up, and the issues list calls it out. Click a net on the left to see it alone.`;     if (q === 'heat') return `This is heat flow in the copper: where the watts travel away from the hot parts. The brighter the copper, the more heat is moving through it.${c0 ? ` ${c0.reference} puts out ${c0.watts} watts: about ${Math.round(c0.planeW / c0.watts * 100)} percent spreads along its copper, and ${Math.round(c0.viaW / c0.watts * 100)} percent goes down its ${c0.vias} vias to the other side.` : ''} More copper and more vias under a hot part give its heat more ways out.`;     if (q === 'temperature') return `This is the temperature rise over the whole board, in ${state.view.air === 'moving' ? 'moving' : 'still'} air: how many degrees above the room each spot sits.${c0 ? ` The hottest part is ${c0.reference}, at ${Math.round(state.view.air === 'moving' ? c0.riseMovingC : c0.riseC)} degrees above ambient, against a budget of ${f.heat.budgetC}.` : ''} Switch between still and moving air on the left to see what a fan does.`;+    if (q === 'signals' && window.fieldsSignals) return window.fieldsSignals.narration();     if (q === 'signals') return `This is the signals view: the capacitance where copper overlaps between layers, and the checks on differential pairs, their skew and their impedance. These are screening estimates; the panel on the right lists each result and what it assumed.`;     return '';   }@@ -245,7 +246,7 @@     if (narr.mode === 'first' && narr.heard[q]) return;     explain(q, true);   }-  window.fieldsNarrate = { explain, stop: stopNarration, text: narrationText, mode: () => narr.mode };+  window.fieldsNarrate = { explain, stop: stopNarration, text: narrationText, mode: () => narr.mode, caption, sentences };   // the sidebar buttons light while their panel is on screen (a wide window shows both panels all the time)   function syncSideBtns() { for (const [btn, side] of [['#btnNets', '#left'], ['#btnIssues', '#right']]) { const b = $(btn), p = $(side); if (!b || !p) continue; const on = getComputedStyle(p).display !== 'none'; b.classList.toggle('is-on', on); b.setAttribute('aria-pressed', on); } }   window.addEventListener('resize', syncSideBtns); setTimeout(syncSideBtns, 0);@@ -352,7 +353,7 @@       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(!['temperature', 'river'].includes(state.view.quantity));+    if (viaRoot) viaRoot.setEnabled(!['temperature', 'river', 'signals'].includes(state.view.quantity));     for (const { el, c } of labels) { el.hidden = ['current','signals','flow','river','energy','light','cycle','charge','magnetic','emc','ground','noise','pdn'].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';@@ -370,6 +371,10 @@       window.fieldsNoise.apply(state.view);       if (state.view.quantity === 'noise') { const bd = $('#flowBadge'), kb = $('#btnKey'); let on = false; try { on = localStorage.getItem('fields.keyOn') === '1'; } catch (e) { void e; } const [h, ...rows] = window.fieldsNoise.describe(); if (bd) { bd.innerHTML = `<div class="kh">${h}</div><ul>${rows.map(r => `<li>${r}</li>`).join('')}</ul>`; bd.hidden = !on; } if (kb) { kb.hidden = false; kb.classList.toggle('is-on', on); } }     }+    if (window.fieldsSignals) {+      window.fieldsSignals.apply(state.view);+      if (state.view.quantity === 'signals') { const bd = $('#flowBadge'), kb = $('#btnKey'); let on = false; try { on = localStorage.getItem('fields.keyOn') === '1'; } catch (e) { void e; } const [h, ...rows] = window.fieldsSignals.describe(); if (bd) { bd.innerHTML = `<div class="kh">${h}</div><ul>${rows.map(r => `<li>${r}</li>`).join('')}</ul>`; bd.hidden = !on; } if (kb) { kb.hidden = false; kb.classList.toggle('is-on', on); } }+    }     if (window.fieldsPdn) {       window.fieldsPdn.apply(state.view);       if (state.view.quantity === 'pdn') { const bd = $('#flowBadge'), kb = $('#btnKey'); let on = false; try { on = localStorage.getItem('fields.keyOn') === '1'; } catch (e) { void e; } const [h, ...rows] = window.fieldsPdn.describe(); if (bd) { bd.innerHTML = `<div class="kh">${h}</div><ul>${rows.map(r => `<li>${r}</li>`).join('')}</ul>`; bd.hidden = !on; } if (kb) { kb.hidden = false; kb.classList.toggle('is-on', on); } }@@ -444,7 +449,7 @@   }   async function setView(patch) { const r = await post('ui/view', patch); if (!r) toast('The server did not answer', 'error', true); }   document.addEventListener('click', e => {-    const q = e.target.closest('#qty button, #qty2 button'); if (q) return setView(q.dataset.q === 'energy' || q.dataset.q === 'charge' ? { quantity: q.dataset.q, issue: null, explode: Math.max(+state.view.explode || 0, 0.6) } : { quantity: q.dataset.q, issue: null });+    const q = e.target.closest('#qty button, #qty2 button'); if (q) return setView(q.dataset.q === 'energy' || q.dataset.q === 'charge' || q.dataset.q === 'signals' ? { quantity: q.dataset.q, issue: null, explode: Math.max(+state.view.explode || 0, 0.6) } : { quantity: q.dataset.q, issue: null });     const l = e.target.closest('#layer button'); if (l) return setView({ layer: l.dataset.l });     const a = e.target.closest('#air button'); if (a) return setView({ air: a.dataset.a });     const cam = e.target.closest('.views .chip[data-cam]'); if (cam) return setView({ camera: cam.dataset.cam });@@ -502,7 +507,8 @@           .then(() => import('./emc.js')).then(m => m.attach({ BAB, scene, V, camera, state, LAYERS, frameDt, root, layerMeshes: state.layerMeshes, flow: api, cycle: window.fieldsCycle })).then(em => { window.fieldsEmc = em; applyTextures(); }).catch(e => console.error('emc: ' + (e && e.stack || e)))           .then(() => import('./ground.js')).then(m => m.attach({ BAB, scene, V, camera, state, LAYERS, frameDt, root, layerMeshes: state.layerMeshes, flow: api })).then(gr => { window.fieldsGround = gr; applyTextures(); }).catch(e => console.error('ground: ' + (e && e.stack || e)))           .then(() => import('./noise.js')).then(m => m.attach({ BAB, scene, V, camera, state, LAYERS, frameDt, root, layerMeshes: state.layerMeshes, flow: api })).then(nz => { window.fieldsNoise = nz; applyTextures(); }).catch(e => console.error('noise: ' + (e && e.stack || e)))-          .then(() => import('./pdn.js')).then(m => m.attach({ BAB, scene, V, camera, state, LAYERS, frameDt, root, layerMeshes: state.layerMeshes, flow: api })).then(pd => { window.fieldsPdn = pd; applyTextures(); }).catch(e => console.error('pdn: ' + (e && e.stack || e))); })+          .then(() => import('./pdn.js')).then(m => m.attach({ BAB, scene, V, camera, state, LAYERS, frameDt, root, layerMeshes: state.layerMeshes, flow: api })).then(pd => { window.fieldsPdn = pd; applyTextures(); }).catch(e => console.error('pdn: ' + (e && e.stack || e)))+          .then(() => import('./signals.js')).then(m => m.attach({ BAB, scene, V, camera, state, LAYERS, frameDt, root, layerMeshes: state.layerMeshes, flow: api })).then(sg => { window.fieldsSignals = sg; applyTextures(); }).catch(e => console.error('signals: ' + (e && e.stack || e))); })       .catch(e => { flowNote = 'The flow view did not load: ' + (e && e.message || e); console.error('flow: ' + (e && e.stack || e)); applyTextures(); });     const st = await fetch('state').then(r => r.json()).catch(() => null);     applyView(st && st.view ? st.view : {}, true);
src/ui/index.html+1−1
@@ -53,7 +53,7 @@ body{margin:0;background:var(--bg);color:var(--text-body);font-family:"Satoshi", .labels{position:absolute;inset:0;pointer-events:none;overflow:hidden} .lbl{position:absolute;transform:translate(-50%,-140%);font-family:"JetBrains Mono",monospace;font-size:12px;color:var(--text);background:rgba(13,17,23,.75);border:1px solid var(--border);border-radius:6px;padding:2px 7px;white-space:nowrap} .lbl.chip{font-size:11px;padding:2px 6px;border-color:rgba(255,255,255,.18);background:rgba(13,17,23,.82)}.lbl.chip b{color:#fff;margin-right:2px}.lbl.chip .in{color:#ffb86b;margin-left:4px}.lbl.chip .out{color:#7ee0ff;margin-left:4px}-.lbl.pdn{border-color:rgba(92,240,143,.45)}.lbl.pdn .v{color:#ff8a6e;font-weight:600}.lbl.pdn .g{color:#5cf08f;font-weight:600}.lbl.pdn i{color:#5cf08f;font-style:normal}.lbl.pdn.big{font-size:12.5px}.cyclemeter.pdnpanel{width:280px}.cyclemeter.pdnpanel canvas{height:auto;aspect-ratio:460/210}.cyclemeter .pdnbtn{background:rgba(255,255,255,.08);border:1px solid var(--border);color:var(--text);border-radius:4px;font-size:11px;padding:1px 7px;cursor:pointer}.lbl.noise{border-color:rgba(255,200,80,.5)}.lbl.noise .v{color:#ff8a6e;font-weight:600}.lbl.noise .g{color:#5cf08f;font-weight:600}+.cyclemeter.signalspanel{width:290px}.lbl.signals{border-color:rgba(90,200,255,.5)}.lbl.signals .v{color:#7fd4ff;font-weight:600}.lbl.pdn{border-color:rgba(92,240,143,.45)}.lbl.pdn .v{color:#ff8a6e;font-weight:600}.lbl.pdn .g{color:#5cf08f;font-weight:600}.lbl.pdn i{color:#5cf08f;font-style:normal}.lbl.pdn.big{font-size:12.5px}.cyclemeter.pdnpanel{width:280px}.cyclemeter.pdnpanel canvas{height:auto;aspect-ratio:460/210}.cyclemeter .pdnbtn{background:rgba(255,255,255,.08);border:1px solid var(--border);color:var(--text);border-radius:4px;font-size:11px;padding:1px 7px;cursor:pointer}.lbl.noise{border-color:rgba(255,200,80,.5)}.lbl.noise .v{color:#ff8a6e;font-weight:600}.lbl.noise .g{color:#5cf08f;font-weight:600} .lbl.ground{border-color:rgba(252,166,120,.5)}.lbl.ground .v{color:#fca678;font-weight:600} .lbl.emc{border-color:rgba(255,120,90,.5)}.lbl.emc .v{color:#ff8a6e;font-weight:600}.lbl.emc .g{color:#5cf08f;font-weight:600}.cyclemeter.emcpanel canvas{height:auto;aspect-ratio:460/190} .lbl.river{border-color:rgba(89,217,250,.45);color:#c8f3ff}
src/ui/signals.jsadded+127
@@ -0,0 +1,127 @@+// Adom Fields: the Signals view in 3D. The solve's own signal checks (fields.json signals), drawn where they are:+//  - capacitance: each checked pair of facing copper (net A on one layer, net B on the next) lights its real+//    overlap on both layers as two glowing plates, and the electric field crosses between them as sparks from+//    plate to plate; one check at a time, the others dim, its picofarads and overlap tagged at its centre;+//  - differential pairs: the two traces glow in two colours, tagged with their skew and impedance.+// The layers pull apart (the Signals button opens the stack) so the inner planes show.+export async function attach(ctx) {+  const { BAB, scene, V, camera, state, LAYERS, frameDt, root, flow } = ctx;+  if (!flow || !flow.data) return null;+  const { lay, W, H, d, meta, nets, zOf, bx, by } = flow.data;+  const sg = state.fields.signals || {};+  const caps = sg.capacitance || [], pairs = sg.differentialPairs || [];+  const idOf = (name) => nets.findIndex(n => n.name === name);+  const hues = [[0.35, 0.8, 1], [1, 0.55, 0.3], [0.45, 1, 0.55], [1, 0.4, 0.85], [1, 0.85, 0.3], [0.65, 0.5, 1], [0.3, 1, 0.9]];+  const hex = (c) => '#' + c.map(v => Math.round(v * 255).toString(16).padStart(2, '0')).join('');+  const group = new BAB.TransformNode('fields-signals', scene); group.parent = root; group.setEnabled(false);+  const host = document.getElementById('labels');+  const plate = (name, cells, rgb) => {+    const tex = new Uint8Array(W * H * 4);+    for (const i of cells) { const o = i * 4; tex[o] = 255; tex[o + 1] = 255; tex[o + 2] = 255; tex[o + 3] = 255; }+    const t = new BAB.RawTexture(tex, W, H, BAB.Constants.TEXTUREFORMAT_RGBA, scene, false, false, BAB.Texture.NEAREST_SAMPLINGMODE, BAB.Constants.TEXTURETYPE_UNSIGNED_BYTE);+    const p = BAB.MeshBuilder.CreatePlane(name, { width: W * d, height: H * d, sideOrientation: BAB.Mesh.DOUBLESIDE }, scene);+    const uvs = p.getVerticesData(BAB.VertexBuffer.UVKind); for (let i = 1; i < uvs.length; i += 2) uvs[i] = 1 - uvs[i]; p.setVerticesData(BAB.VertexBuffer.UVKind, uvs);+    p.position.x = bx(meta.x0 + W * d / 2); p.position.y = by(meta.y0 + H * d / 2);+    const m = new BAB.StandardMaterial(name + '-m', scene); m.diffuseTexture = t; t.hasAlpha = true; m.useAlphaFromDiffuseTexture = true; m.diffuseColor = new BAB.Color3(0, 0, 0); m.emissiveColor = new BAB.Color3(...rgb); m.disableLighting = true; m.backFaceCulling = false;+    p.material = m; p.parent = group; p.renderingGroupId = 2; p.isPickable = false; return { p, m };+  };+  // ---- the capacitance checks: two plates and the field between ------------------------------------------+  const checks = caps.map((c, ci) => {+    const df = c.definition || {}, ka = LAYERS.indexOf(df.layerA), kb = LAYERS.indexOf(df.layerB), ia = idOf(df.netA), ib = idOf(df.netB), rgb = hues[ci % hues.length];+    const cells = [];+    if (ka >= 0 && kb >= 0 && ia >= 0 && ib >= 0 && lay[ka] && lay[kb]) for (let i = 0; i < W * H; i++) if (lay[ka].net[i] === ia && lay[kb].net[i] === ib) cells.push(i);+    const a = plate('sig-a-' + ci, cells, rgb), b = plate('sig-b-' + ci, cells, rgb.map(v => v * 0.75));+    // the sparks: points spread over the overlap, each crossing from plate A to plate B on its own phase+    const n = Math.min(700, Math.max(cells.length ? 40 : 0, Math.round(cells.length / 6)));+    const sx = new Float32Array(n), sy = new Float32Array(n), ph = new Float32Array(n);+    for (let k = 0; k < n; k++) { const i = cells[Math.floor(((Math.sin(k * 12.9898 + ci) * 43758.5453) % 1 + 1) % 1 * cells.length)]; sx[k] = meta.x0 + ((i % W) + 0.5) * d; sy[k] = meta.y0 + (Math.floor(i / W) + 0.5) * d; ph[k] = ((Math.sin(k * 78.233 + ci * 3.1) * 12543.17) % 1 + 1) % 1; }+    let dots = null, buf = null;+    if (n) {+      dots = BAB.MeshBuilder.CreateSphere('sig-dots-' + ci, { diameter: 1, segments: 4 }, scene);+      const dm = new BAB.StandardMaterial('sig-dots-m' + ci, scene); dm.emissiveColor = new BAB.Color3(...rgb.map(v => Math.min(1, v * 0.4 + 0.6))); dm.disableLighting = true; dots.material = dm;+      buf = new Float32Array(n * 16); dots.thinInstanceSetBuffer('matrix', buf, 16, false); dots.parent = group; dots.renderingGroupId = 2; dots.isPickable = false; dots.alwaysSelectAsActiveMesh = true;+    }+    let cxs = 0, cys = 0; for (const i of cells) { cxs += (i % W) + 0.5; cys += Math.floor(i / W) + 0.5; }+    const cen = cells.length ? [meta.x0 + cxs / cells.length * d, meta.y0 + cys / cells.length * d] : [meta.x0 + W * d / 2, meta.y0 + H * d / 2];+    const tag = document.createElement('div'); tag.className = 'lbl signals'; tag.hidden = true; host.appendChild(tag);+    return { c, df, ka, kb, rgb, cells, a, b, dots, buf, n, sx, sy, ph, cen, tag, on: 0 };+  });+  // ---- differential pairs: the two traces in two colours ---------------------------------------------------+  const prs = pairs.map((p, pi) => {+    const k = LAYERS.indexOf(p.definition ? p.definition.layer : p.layer), ip = idOf(p.definition ? p.definition.positive : p.positive), inn = idOf(p.definition ? p.definition.negative : p.negative);+    const cp = [], cn = []; if (k >= 0 && lay[k]) for (let i = 0; i < W * H; i++) { if (lay[k].net[i] === ip) cp.push(i); else if (lay[k].net[i] === inn) cn.push(i); }+    const P = plate('sig-pp-' + pi, cp, [1, 0.35, 0.35]), N = plate('sig-pn-' + pi, cn, [0.35, 0.6, 1]);+    const all = cp.concat(cn); const cen = all.length ? [meta.x0 + all.reduce((s, i) => s + (i % W) + 0.5, 0) / all.length * d, meta.y0 + all.reduce((s, i) => s + Math.floor(i / W) + 0.5, 0) / all.length * d] : [0, 0];+    const tag = document.createElement('div'); tag.className = 'lbl signals'; tag.hidden = true; host.appendChild(tag);+    return { p, k, P, N, cen, tag };+  });+  // ---- the panel ---------------------------------------------------------------------------------------+  const pf = (v) => v >= 1000 ? (v / 1000).toFixed(2) + ' nF' : v >= 10 ? v.toFixed(0) + ' pF' : v.toFixed(1) + ' pF';+  const panel = document.createElement('div'); panel.className = 'cyclemeter signalspanel'; panel.hidden = true; document.querySelector('.stage').appendChild(panel);+  const sel = { i: 0, picked: false, t: 0 };+  panel.innerHTML = !checks.length && !prs.length ? `<div class="ch">Signals</div><div class="body"><div class="row">No signal checks in this solve: add signals.capacitance or signals.differentialPairs to the spec.</div></div>`+    : `<div class="ch" title="Click to fold">Signals</div><div class="body">+      ${checks.length ? `<div class="row" style="color:var(--text-2)">Capacitance between facing copper</div>` : ''}+      ${checks.map((x, i) => `<div class="row sigrow" data-i="${i}" style="cursor:pointer"><span class="dot" style="background:${hex(x.rgb)}"></span>${x.c.name}<i>${pf(x.c.capacitancePf)}</i></div>`).join('')}+      ${prs.map(x => `<div class="row"><span class="dot" style="background:#ff6b6b"></span>${x.p.name}<i>skew ${x.p.skewMm != null ? x.p.skewMm.toFixed(2) + ' mm' : '?'}${x.p.differentialOhms ? ' · ' + x.p.differentialOhms.toFixed(0) + ' Ω' : ''}</i></div>`).join('')}+      <div class="row" style="color:var(--text-2);margin-top:6px">Parallel-plate estimates: no fringe fields, no frequency dependence</div>+    </div>`;+  panel.addEventListener('click', (e) => { if (e.target.closest('.ch')) panel.classList.toggle('folded'); const r = e.target.closest('.sigrow'); if (r) { sel.i = +r.dataset.i; sel.picked = true; sel.t = 0; } });+  // ---- frame -------------------------------------------------------------------------------------------+  const knob = { on: 0, to: 0, t: 0 };+  scene.onBeforeRenderObservable.add(() => {+    const dt = frameDt();+    knob.on += (knob.to - knob.on) * (1 - Math.exp(-dt * 2.5)); if (Math.abs(knob.to - knob.on) < 1e-3) knob.on = knob.to;+    const live = knob.on > 0.01; group.setEnabled(live);+    if (!live) { for (const x of checks) x.tag.hidden = true; for (const x of prs) x.tag.hidden = true; return; }+    const sp = +state.view.speed || 1; knob.t += dt * sp; sel.t += dt * sp;+    // one check at a time, seven seconds each, unless one was picked+    if (!sel.picked && checks.length && sel.t > 7) { sel.t = 0; sel.i = (sel.i + 1) % checks.length; }+    const e = V.getEngine(), w = e.getRenderWidth(), h = e.getRenderHeight(), hostEl = document.getElementById('host'), wm = group.getWorldMatrix();+    const place = (el, x, y, z) => { const p = BAB.Vector3.Project(BAB.Vector3.TransformCoordinates(new BAB.Vector3(bx(x), by(y), z), wm), BAB.Matrix.Identity(), scene.getTransformMatrix(), camera.viewport.toGlobal(w, h)); el.hidden = p.z > 1 || p.z < 0; el.style.left = (p.x / (w / hostEl.clientWidth)) + 'px'; el.style.top = (p.y / (h / hostEl.clientHeight)) + 'px'; };+    checks.forEach((x, i) => {+      const act = i === sel.i; x.on += ((act ? 1 : 0) - x.on) * (1 - Math.exp(-dt * 4));+      const za = zOf(x.ka) + (x.ka < x.kb ? -0.02 : 0.02), zb = zOf(x.kb) + (x.kb < x.ka ? -0.02 : 0.02);+      x.a.p.position.z = za; x.b.p.position.z = zb;+      const glow = 0.75 + 0.25 * Math.sin(knob.t * 3);+      x.a.m.alpha = knob.on * (0.08 + 0.8 * x.on * glow); x.b.m.alpha = knob.on * (0.06 + 0.7 * x.on * glow);+      if (x.dots) {+        x.dots.setEnabled(x.on > 0.05);+        const s = 0.16 + 0.12 * x.on;+        for (let k = 0; k < x.n; k++) { const t = (x.ph[k] + knob.t * 0.45) % 1, o = k * 16; x.buf[o] = s; x.buf[o + 5] = s; x.buf[o + 10] = s; x.buf[o + 15] = 1; x.buf[o + 12] = bx(x.sx[k]); x.buf[o + 13] = by(x.sy[k]); x.buf[o + 14] = za + (zb - za) * t; }+        x.dots.thinInstanceBufferUpdated('matrix'); x.dots.visibility = x.on * knob.on;+      }+      x.tag.innerHTML = `<b>${x.c.name}</b> <span class="v">${pf(x.c.capacitancePf)}</span> · ${x.c.overlapMm2.toFixed(0)} mm² · ${x.df.dielectricMm} mm`;+      if (act) place(x.tag, x.cen[0], x.cen[1], Math.max(za, zb) + 1.2); else x.tag.hidden = true;+    });+    for (const x of prs) { const z = zOf(x.k) + 0.03; x.P.p.position.z = z; x.N.p.position.z = z; x.P.m.alpha = x.N.m.alpha = knob.on * (0.6 + 0.3 * Math.sin(knob.t * 3)); x.tag.innerHTML = `<b>${x.p.name}</b> skew ${x.p.skewMm != null ? x.p.skewMm.toFixed(2) + ' mm' : '?'}${x.p.differentialOhms ? ' · ' + x.p.differentialOhms.toFixed(0) + ' Ω' : ''}`; place(x.tag, x.cen[0], x.cen[1], z + 1); }+  });+  function apply(view) {+    knob.to = view.quantity === 'signals' ? 1 : 0; panel.hidden = view.quantity !== 'signals';+    if (view.quantity !== 'signals') return;+    if (view.net) { const i = checks.findIndex(x => x.df.netA === view.net || x.df.netB === view.net); if (i >= 0) { sel.i = i; sel.picked = true; } }+    for (const Lname of LAYERS) { const m = ctx.layerMeshes[Lname]; if (m && m.material) m.material.alpha = 0.12; }+  }+  function describe() {+    return ['<b>Signals</b>: copper facing copper across the board as capacitors, and the differential pairs',+      '<b>Two glowing plates</b> = one checked overlap: net A on its layer, net B on the next, the same shape on both',+      '<b>Sparks crossing between them</b> = the electric field through the board material; one check at a time, the panel picks one',+      'Picofarads from the overlap area, the dielectric thickness and εr (parallel plate, no fringe fields)'];+  }+  function narration() {+    if (!checks.length && !prs.length) return 'This is the Signals view, but this solve has no signal checks: add capacitance or differential pair checks to the spec.';+    const say = (v) => v >= 1000 ? `${(v / 1000).toFixed(1)} nanofarads` : `${Math.round(v)} picofarads`;+    const big = checks.slice().sort((a, b) => b.c.capacitancePf - a.c.capacitancePf)[0];+    const sw = checks.filter(x => /(^|\/)(SW|SH[A-C]?|DRV_SH|PHASE)/i.test(x.df.netA + ' ' + x.df.netB) || nets.some(n => n.switching && (n.name === x.df.netA || n.name === x.df.netB)));+    const plane = checks.find(x => /In\d/.test(x.df.layerA) && /In\d/.test(x.df.layerB));+    let t = `This is the Signals view: copper as capacitors. Wherever copper on one layer faces copper on the next, the two pieces are the plates of a capacitor, and the electric field crosses the board material between them. Each pair of glowing plates is one check, the sparks are that field, and the layers are pulled apart so the inner copper shows.`;+    if (big) t += ` The biggest here is ${big.c.name.replace(/:.*$/, '')}: about ${say(big.c.capacitancePf)} over ${Math.round(big.c.overlapMm2)} square millimetres, through ${Math.round(big.df.dielectricMm * 100) / 100} millimetres of board.`;+    if (plane && plane !== big) t += ` The two inner planes make ${say(plane.c.capacitancePf)} between them, a little free decoupling at very high frequencies, small because the core between them is thick.`;+    else if (plane) t += ` That is the two inner planes: a little free decoupling at very high frequencies, small because the core between them is thick.`;+    if (sw.length) t += ` The switching copper matters most: ${sw.map(x => `${x.c.name.replace(/ switch node.*$/i, '').replace(/:.*$/, '')} has ${say(x.c.capacitancePf)}`).join(', ')} to the ground plane under it. Every switching edge has to charge and empty that capacitance, so it costs a little energy each cycle and pushes a spike of current into ground; a smaller switch-node area keeps it down.`;+    if (prs.length) t += ` The differential pairs glow red and blue, tagged with their length mismatch and their impedance.`;+    return t + ' These are parallel-plate estimates, a screen that shows where the coupling is.';+  }+  console.log(`signals: ${checks.map(x => `${x.c.name} ${x.cells.length} cells`).join(', ')}`);+  return { apply, describe, narration };+}