app
Adom Fields
Public Made by Adomby adom
Adom Fields: where the current and the heat go on your board.
← Commit history
adom-fields 0.7.2: the energy cycle of a switching converter, the board's silkscreen over every view, part values, subtitles at the bottom edge
19 files changed
+396−34
Cargo.lock+1−1Cargo.toml+1−1README.md+8SKILL.md+11−1bin/adom-fieldsdocs/release-0.7.2.md+7docs/screenshots/cycle-top.pngpackage.json+6−2page.json+12−10skills/adom-fields/SKILL.md+11−1src/board.rs+3src/eagle.rs+1src/fields.rs+1src/main.rs+21−7src/serve.rs+10−3src/silk.rs+28src/ui/app.js+55−6src/ui/cycle.js+216src/ui/index.html+4−2Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-fields"-version = "0.7.1"+version = "0.7.2" dependencies = [ "clap", "png",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-fields"-version = "0.7.1"+version = "0.7.2" edition = "2021" license = "MIT" description = "Adom Fields: where the current and the heat go on your board. Current density, copper heat flow and board temperature from the board file itself, in 3D, for the AI to drive and the human to explore."
README.md+8@@ -102,6 +102,14 @@ Two more views from the same solve. **Energy** shows that the energy does not tr *Each piece of copper coloured by how fast a change travels around it; the tags give when a switch-on reaches each part.* +## The energy cycle in 0.7.2++One switching period of the buck, slowed 1.6 million times: energy leaves the input capacitors (red) while the switch is on and flows through U1 into L1, whose magnetic field (blue) grows with its current; when the switch turns off the field collapses and keeps pushing current into the output capacitors and the load. A meter tracks each store, the heat lost and the 10 µJ delivered every cycle. L and C are the parts' own values from the board.++++The board's own silkscreen now lies over every view (the T button toggles it), so VIN, VOUT, the connectors and every part's reference and value read right on the field.+ ## Explained out loud in 0.7.1 Each view can explain itself in plain words, by voice, with this board's own numbers: the speaker button in the header, or automatically the first time a viewer opens each kind of view (the left panel sets First time, Always or On ask). The explanation is written by the app from the solve, so it costs no AI tokens, and voiced once by adom-tts.
SKILL.md+11−1@@ -54,7 +54,7 @@ Every camera move and the idle sway run at half the 0.4 speed (the sway period i ## Drive it (the AI's wheel) ```-adom-fields ui view --quantity current|heat|temperature|signals|flow|river|energy|light [--explode 0..1] [--speed 1] [--layer F.Cu|In1.Cu|In2.Cu|B.Cu] [--camera top|iso|bottom|fit|zoom --at x,y] [--air still|moving] [--parts auto|ghost|solid|off]+adom-fields ui view --quantity current|heat|temperature|signals|flow|river|energy|light|cycle [--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@@ -99,6 +99,16 @@ Developing the page: `ADOM_FIELDS_UI_DIR=<repo>/src/ui adom-fields serve ...` se Every view can explain itself by voice: the speaker button in the header, `adom-fields ui explain [--quantity q]` (`--stop` silences it), or automatically. The left panel's "Explain each view out loud" sets the rule per viewer: **First time** (the default: each kind of view speaks once, the first time this viewer opens it, so a newcomer hears what they are looking at), **Always**, or **On ask**. The page writes each explanation from a template with this board's own numbers (the peak density and its net, the hottest part, the rails, the electrons' speed, the arrival times), so no AI tokens are spent; the server voices it with adom-tts and keeps the mp3 under `<fields>/narration/`, named by the text's hash, so it is voiced once. A one-line subtitle follows the voice, sentence by sentence, sitting above the legend and readout so neither is covered. Without adom-tts, a toast says so. +## The energy cycle (0.7.2)++`ui view --quantity cycle`: one switching period of a buck converter, slowed about a million times (the meter states the factor). Pink energy packets run from the input capacitors through the switch into the inductor while the switch is on, and from the inductor to the output capacitors and the load all the time; blue particles ride the inductor's magnetic field lines, brightening and swelling as its current and stored energy grow, collapsing on the off-time; red arches over each capacitor brighten as its stored energy rises; orange heat leaves the switch and the inductor. A meter (bottom left, click its title to fold) gives the switch state, the inductor current waveform with a cursor, each store's voltage or current and energy, the heat and the energy delivered per cycle.++The model is the ideal continuous-conduction buck solved exactly: D = Vout/Vin, inductor ripple ΔI = (Vin − Vout)·D·T/L, the capacitor charge integrated over the period. It finds the converter from the solve: the switching node, the inductor on it (L*), the switch (the node's source), the input rail (the switch's highest rail), the output (the inductor's other net), the input and output capacitors (C* between each rail and ground). L and C come from the parts' values on the board (KiCad Value, Fusion element value; `fields.json` `partValues`), fsw from the spec's `switching.freqHz`, the load from the output net's amps, the heat from the hot parts' watts. A board it cannot model says why.++## The silkscreen (0.7.2)++`analyze` exports the board's F.SilkS and B.SilkS with kicad-cli as SVG (`silk-front.svg`, `silk-back.svg`, fields.json `silk`); `adom-fields silk --board <pcb> --fields <dir>` adds them to an existing solve. The page draws each once into a texture on one plane per side (two quads, no geometry), riding its copper layer and the exploded stack, each side shown only from its own side of the board, hidden in the river. The header's T button or `ui view --silk on|off` toggles it. `--no-silk` skips it; a Fusion board has none yet.+ ## 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.2.mdadded+7@@ -0,0 +1,7 @@+# adom-fields 0.7.2++- **The energy cycle.** `ui view --quantity cycle`: one switching period of a buck converter, slowed about a million times: energy from the input capacitors through the switch into the inductor's magnetic field, the field collapsing to push the current on into the output, the capacitors' stored energy and the heat, with a meter (switch state, inductor current waveform, each store, heat and delivered energy per cycle). Ideal continuous-conduction model from the parts' own L and C values and the spec's switching frequency; a board it cannot model says why.+- **The silkscreen.** The board's own F.SilkS and B.SilkS (KiCad's SVG export) on one textured plane per side over every view, so VIN, the connectors and every reference read on the field; the T button or `ui view --silk on|off`; `adom-fields silk` adds it to an existing solve.+- **Part values.** `fields.json` `partValues`: each part's value from the board (KiCad Value property, Fusion element value).+- **Subtitles** sit at the bottom edge between the readout and the legend; a sentence break no longer splits a decimal ("1.6 million").+- The tour adds the energy cycle.
docs/screenshots/cycle-top.pngadded⋯ 1 unchanged line ⋯
package.json+6−2@@ -1,7 +1,7 @@ { "slug": "adom-fields", "type": "app",- "version": "0.7.1",+ "version": "0.7.2", "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.",@@ -45,7 +45,11 @@ "exploded layer stack", "current through vias", "explain the view out loud",- "narrate the fields"+ "narrate the fields",+ "energy cycle",+ "buck converter energy",+ "inductor magnetic field",+ "capacitor energy storage" ], "sample_prompts": [ {
page.json+12−10@@ -1,7 +1,7 @@ { "slug": "adom-fields", "type": "app",- "version": "0.7.1",+ "version": "0.7.2", "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,12 +37,17 @@ "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"+ "narrate the fields",+ "energy cycle",+ "buck converter energy",+ "inductor magnetic field",+ "capacitor energy storage" ], "sample_prompts": [ {@@ -86,4 +88,4 @@ "name": "John Lauer", "email": "[email protected]" }-}+}
skills/adom-fields/SKILL.md+11−1@@ -54,7 +54,7 @@ Every camera move and the idle sway run at half the 0.4 speed (the sway period i ## Drive it (the AI's wheel) ```-adom-fields ui view --quantity current|heat|temperature|signals|flow|river|energy|light [--explode 0..1] [--speed 1] [--layer F.Cu|In1.Cu|In2.Cu|B.Cu] [--camera top|iso|bottom|fit|zoom --at x,y] [--air still|moving] [--parts auto|ghost|solid|off]+adom-fields ui view --quantity current|heat|temperature|signals|flow|river|energy|light|cycle [--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@@ -99,6 +99,16 @@ Developing the page: `ADOM_FIELDS_UI_DIR=<repo>/src/ui adom-fields serve ...` se Every view can explain itself by voice: the speaker button in the header, `adom-fields ui explain [--quantity q]` (`--stop` silences it), or automatically. The left panel's "Explain each view out loud" sets the rule per viewer: **First time** (the default: each kind of view speaks once, the first time this viewer opens it, so a newcomer hears what they are looking at), **Always**, or **On ask**. The page writes each explanation from a template with this board's own numbers (the peak density and its net, the hottest part, the rails, the electrons' speed, the arrival times), so no AI tokens are spent; the server voices it with adom-tts and keeps the mp3 under `<fields>/narration/`, named by the text's hash, so it is voiced once. A one-line subtitle follows the voice, sentence by sentence, sitting above the legend and readout so neither is covered. Without adom-tts, a toast says so. +## The energy cycle (0.7.2)++`ui view --quantity cycle`: one switching period of a buck converter, slowed about a million times (the meter states the factor). Pink energy packets run from the input capacitors through the switch into the inductor while the switch is on, and from the inductor to the output capacitors and the load all the time; blue particles ride the inductor's magnetic field lines, brightening and swelling as its current and stored energy grow, collapsing on the off-time; red arches over each capacitor brighten as its stored energy rises; orange heat leaves the switch and the inductor. A meter (bottom left, click its title to fold) gives the switch state, the inductor current waveform with a cursor, each store's voltage or current and energy, the heat and the energy delivered per cycle.++The model is the ideal continuous-conduction buck solved exactly: D = Vout/Vin, inductor ripple ΔI = (Vin − Vout)·D·T/L, the capacitor charge integrated over the period. It finds the converter from the solve: the switching node, the inductor on it (L*), the switch (the node's source), the input rail (the switch's highest rail), the output (the inductor's other net), the input and output capacitors (C* between each rail and ground). L and C come from the parts' values on the board (KiCad Value, Fusion element value; `fields.json` `partValues`), fsw from the spec's `switching.freqHz`, the load from the output net's amps, the heat from the hot parts' watts. A board it cannot model says why.++## The silkscreen (0.7.2)++`analyze` exports the board's F.SilkS and B.SilkS with kicad-cli as SVG (`silk-front.svg`, `silk-back.svg`, fields.json `silk`); `adom-fields silk --board <pcb> --fields <dir>` adds them to an existing solve. The page draws each once into a texture on one plane per side (two quads, no geometry), riding its copper layer and the exploded stack, each side shown only from its own side of the board, hidden in the river. The header's T button or `ui view --silk on|off` toggles it. `--no-silk` skips it; a Fusion board has none yet.+ ## 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/board.rs+3@@ -35,6 +35,8 @@ pub struct Board { pub fills: Vec<FillShape>, /// "kicad" or "fusion" pub source: String,+ /// each part's value as the board carries it ("15uH", "22uF 10V"): the energy cycle reads L and C from it+ pub values: std::collections::BTreeMap<String, String>, } fn rot(x: f64, y: f64, deg: f64) -> (f64, f64) { let a = deg.to_radians(); (x * a.cos() - y * a.sin(), x * a.sin() + y * a.cos()) }@@ -79,6 +81,7 @@ impl Board { } for fp in root.kids("footprint") { let reference = fp.kids("property").iter().find(|p| p.atom(1) == Some("Reference")).and_then(|p| p.atom(2)).unwrap_or("?").to_string();+ if let Some(v) = fp.kids("property").iter().find(|p| p.atom(1) == Some("Value")).and_then(|p| p.atom(2)) { b.values.insert(reference.clone(), v.to_string()); } let at = fp.kid("at"); let (fx, fy, fa) = (at.and_then(|a| a.num(1)).unwrap_or(0.0), at.and_then(|a| a.num(2)).unwrap_or(0.0), at.and_then(|a| a.num(3)).unwrap_or(0.0)); for p in fp.kids("pad") {
src/eagle.rs+1@@ -93,6 +93,7 @@ pub fn load(text: &str) -> Result<Board, String> { } for e in board.descendants().filter(|n| n.has_tag_name("element")) { let reference = e.attribute("name").unwrap_or("?").to_string();+ if let Some(v) = e.attribute("value").filter(|v| !v.is_empty()) { b.values.insert(reference.clone(), v.to_string()); } let key = (e.attribute("library").unwrap_or("").to_string(), e.attribute("package").unwrap_or("").to_string()); let Some(pkg) = packages.get(&key) else { continue }; let (ex, ey) = (f(&e, "x"), f(&e, "y"));
src/fields.rs+1@@ -366,6 +366,7 @@ pub fn summary(f: &Fields, b: &Board, spec: &Spec, textures: &[String]) -> Value "scales": {"jMax": f.j_all.iter().map(|g| g.max()).fold(0.0, f32::max), "qMax": f.q.iter().map(|g| g.max()).fold(0.0, f32::max), "tMax": tmax, "tMovingMax": f.t_moving.iter().map(|g| g.max()).fold(0.0, f32::max)}, "heat": {"tabW": f.tab_total, "jouleW": f.joule_total, "peakRiseC": tmax, "peakRiseMovingC": f.t_moving.iter().map(|g| g.max()).fold(0.0, f32::max), "budgetC": spec.hotMaxRiseC, "stillAirWPerM2K": H_STILL * 1e6, "movingAirWPerM2K": H_MOVING * 1e6}, "textures": textures,+ "partValues": b.values, "pourNets": pour_nets, "method": "Copper fraction per cell from the board's filled polygons, tracks, pads and vias; DC conduction per loaded net between its terminals (σ 5.8e4 S/mm × thickness); heat: four layers coupled through the dielectric and every via barrel, copper 0.40 W/mm·K, FR4 0.3/0.6 W/m·K, 10 (still) or 25 (moving) W/m²K to the air on both faces; one steady-state solve each. Optional rectangular heatsinks use a 3D finite-volume conducting block with specified conductivity, contact resistance and air loss on exposed faces. Copper thickness may differ by layer; Joule heat is deposited on its source layer. This remains a screening model: rectangular board outline, prescribed chip watts and convection; no fins, radiation, airflow solve or junction-temperature qualification." })
src/main.rs+21−7@@ -1,6 +1,6 @@ //! adom-fields: where the current and the heat go on your board. use sha2::{Digest, Sha256};-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;+mod eagle; mod flow; mod parts; mod silk; 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; @@ -17,7 +17,10 @@ enum Cmd { /// skip the parts: no 3D models of the components over the fields (they need the Adom STEP service, step2glb) #[arg(long)] no_parts: bool, /// extra folders to look in for the footprints' 3D models (by file name), besides the board's ${KIPRJMOD} and ./3d- #[arg(long = "models-dir")] models_dir: Vec<PathBuf> },+ #[arg(long = "models-dir")] models_dir: Vec<PathBuf>, /// skip the silkscreen (it needs kicad-cli)+ #[arg(long)] no_silk: bool },+ /// (Re)build only the silkscreen of a solved board: KiCad's F.SilkS and B.SilkS as SVG, into <fields>/ and fields.json `silk`+ Silk { #[arg(long)] board: String, #[arg(long, default_value = "fields")] fields: PathBuf }, /// (Re)build only the parts of a solved board: each footprint's 3D model, converted and placed, into <fields>/fields.json and <fields>/parts/ 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@@ -40,7 +43,8 @@ enum UiCmd { #[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> },+ #[arg(long)] ghost: Option<f64>, /// the silkscreen over the fields: on or off+ #[arg(long)] silk: Option<String> }, Toast { text: String, #[arg(long, default_value = "info")] kind: String }, /// Explain the view out loud in the page (adom-tts, with a caption): the current view, or --quantity; --stop silences it Explain { #[arg(long)] quantity: Option<String>, #[arg(long)] stop: bool },@@ -55,7 +59,7 @@ pub fn err(msg: &str, hints: &[String]) -> ! { println!("ERROR: {msg}"); for h i fn slug(s: &str) -> String { s.chars().map(|c| if c.is_alphanumeric() { c } else { '_' }).collect() } -pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck: f64, parts: Option<&[PathBuf]>) -> Result<serde_json::Value, String> {+pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck: f64, parts: Option<&[PathBuf]>, no_silk: bool) -> Result<serde_json::Value, String> { let board_bytes = std::fs::read(board).map_err(|e|e.to_string())?; let spec_bytes = std::fs::read(spec_path).map_err(|e|e.to_string())?; let b = board::Board::load(board)?;@@ -127,6 +131,7 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck } summary["inputIdentity"] = serde_json::json!({"algorithm":"sha256", "board":format!("{:x}",Sha256::digest(&board_bytes)), "spec":format!("{:x}",Sha256::digest(&spec_bytes)), "solverVersion":env!("CARGO_PKG_VERSION")}); if let Some(dirs) = parts { summary["parts"] = parts::build(board, out, dirs, &mut |s| eprintln!("{s}")); }+ if !no_silk { summary["silk"] = silk::build(board, out, &mut |s| eprintln!("{s}")); } summary["source"] = serde_json::json!({"board": std::fs::canonicalize(board).map(|p| p.display().to_string()).unwrap_or(board.to_string()), "spec": spec_path}); std::fs::write(out.join("fields.json"), serde_json::to_string_pretty(&summary).unwrap()).map_err(|e| e.to_string())?; Ok(summary)@@ -135,8 +140,8 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck fn main() { let cli = Cli::parse(); match cli.cmd {- Cmd::Analyze { board, spec, out, pitch, neck, no_parts, models_dir } => {- match run_analyze(&board, &spec, &out, pitch, neck, if no_parts { None } else { Some(&models_dir) }) {+ Cmd::Analyze { board, spec, out, pitch, neck, no_parts, models_dir, no_silk } => {+ match run_analyze(&board, &spec, &out, pitch, neck, if no_parts { None } else { Some(&models_dir) }, no_silk) { Ok(s) => { let nets = s["nets"].as_array().map(|a| a.len()).unwrap_or(0); let issues = s["issues"].as_array().cloned().unwrap_or_default();@@ -152,9 +157,18 @@ fn main() { } } Cmd::Serve { fields, port, board, spec } => {- if let (Some(bd), Some(sp)) = (&board, &spec) { if let Err(e) = run_analyze(bd, sp, &fields, 0.2, 60.0, Some(&[])) { err(&e, &[]); } }+ if let (Some(bd), Some(sp)) = (&board, &spec) { if let Err(e) = run_analyze(bd, sp, &fields, 0.2, 60.0, Some(&[]), false) { err(&e, &[]); } } serve::serve(&fields, port); }+ Cmd::Silk { board, fields } => {+ let path = fields.join("fields.json");+ let mut f: serde_json::Value = std::fs::read_to_string(&path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_else(|| err(&format!("no fields.json in {}", fields.display()), &["Run `adom-fields analyze` first.".into()]));+ let s = silk::build(&board, &fields, &mut |s| eprintln!("{s}"));+ let none = s.is_null(); f["silk"] = s;+ std::fs::write(&path, serde_json::to_string_pretty(&f).unwrap()).unwrap_or_else(|e| err(&e.to_string(), &[]));+ if none { err("no silkscreen exported", &["It needs a .kicad_pcb and kicad-cli (service-kicad, or KiCad through Adom Bridge).".into()]); }+ ok(&format!("silkscreen added: {}", path.display()), &["A running `serve` shows it on the next page load; `adom-fields ui view --silk off` hides it.".into()]);+ } Cmd::Parts { board, fields, models_dir } => { let path = fields.join("fields.json"); let mut f: serde_json::Value = std::fs::read_to_string(&path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_else(|| err(&format!("no fields.json in {}", fields.display()), &["Run `adom-fields analyze` first.".into()]));
src/serve.rs+10−3@@ -58,13 +58,14 @@ fn handle(mut req: tiny_http::Request, app: Arc<Mutex<App>>) { ("GET", "/") | ("GET", "/index.html") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("index.html", include_str!("ui/index.html"))).with_header(tiny_http::Header::from_bytes("Content-Type", "text/html; charset=utf-8").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); } ("GET", "/app.js") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("app.js", include_str!("ui/app.js"))).with_header(tiny_http::Header::from_bytes("Content-Type", "application/javascript").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); } ("GET", "/flow.js") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("flow.js", include_str!("ui/flow.js"))).with_header(tiny_http::Header::from_bytes("Content-Type", "application/javascript").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); }+ ("GET", "/cycle.js") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("cycle.js", include_str!("ui/cycle.js"))).with_header(tiny_http::Header::from_bytes("Content-Type", "application/javascript").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); } ("GET", "/flow.bin") => { // the signed current, the voltage and the owning net of every cell, for the flow and river views 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 (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", "/state") => { let a = app.lock().unwrap(); reply(req, 200, json!({"status": "ok", "view": a.view, "board": a.fields["board"], "nets": a.fields["nets"].as_array().map(|x| x.len()).unwrap_or(0), "issues": a.fields["issues"].as_array().map(|x| x.len()).unwrap_or(0), "clients": a.clients.len(), "started": a.started, "_hint": "POST /ui/view {quantity (current|heat|temperature|signals|flow|river|energy|light|cycle), explode 0..1, speed 0.1..4, net, layer, camera (top|iso|bottom|fit|zoom), air, parts: auto|ghost|solid|off, drift, focus: [x, y] mm for camera zoom}; /ui/select {net|chip}; /ui/issue {index}; /ui/toast {text, kind}; GET /events is the SSE channel; GET /fields.json is everything solved."})); } ("GET", "/fields.json") => { // re-read: `adom-fields parts` may have added the 3D models since the server started let mut a = app.lock().unwrap();@@ -98,7 +99,7 @@ fn handle(mut req: tiny_http::Request, app: Arc<Mutex<App>>) { a.view["quantity"] = json!(match issue["kind"].as_str() { Some("current-neck") => "current", _ => "heat" }); a.view["camera"] = json!("zoom"); a.view["focus"] = issue["at"].clone(); } else {- for k in ["quantity", "net", "layer", "camera", "air", "chip", "drift", "parts", "focus", "explode", "speed", "ghost"] { if !v[k].is_null() { a.view[k] = v[k].clone(); } }+ for k in ["quantity", "net", "layer", "camera", "air", "chip", "drift", "parts", "focus", "explode", "speed", "ghost", "silk"] { 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"); } } } }@@ -139,6 +140,11 @@ fn handle(mut req: tiny_http::Request, app: Arc<Mutex<App>>) { } reply(req, 200, json!({"status": "ok", "url": format!("narration/{name}"), "cache": if cached { "cached" } else { "voiced" }})); }+ ("GET", p) if p.starts_with("/silk-") && p.ends_with(".svg") => {+ let name = p.trim_start_matches('/').replace("..", "").replace('/', "");+ let file = app.lock().unwrap().dir.join(&name);+ 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", "image/svg+xml").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); } Err(_) => reply(req, 404, json!({"status": "error", "error": "no silkscreen", "hint": "adom-fields silk --board <pcb> --fields <dir>"})) }+ } ("GET", p) if p.starts_with("/narration/") && p.ends_with(".mp3") => { let name = p.trim_start_matches("/narration/").replace("..", "").replace('/', ""); let file = app.lock().unwrap().dir.join("narration").join(&name);@@ -171,7 +177,7 @@ fn handle(mut req: tiny_http::Request, app: Arc<Mutex<App>>) { pub fn ui(what: UiCmd, port: u16) { let up = |p: &str, v: &Value| -> Value { post(port, p, v).unwrap_or_else(|e| crate::err(&format!("the app did not answer on {port}: {e}"), &[format!("Start it: `adom-fields serve --fields <dir> --port {port} &`")])) }; match what {- UiCmd::View { quantity, net, layer, camera, air, parts, at, explode, speed, ghost } => { 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::View { quantity, net, layer, camera, air, parts, at, explode, speed, ghost, silk } => { let focus = at.as_deref().map(|t| { let v: Vec<f64> = t.split(',').filter_map(|x| x.trim().parse().ok()).collect(); if v.len() != 2 { crate::err("--at takes x,y in board mm, e.g. --at 113.9,90.5", &[]); } json!(v) }); if let Some(p) = &parts { if !["auto", "ghost", "solid", "off"].contains(&p.as_str()) { crate::err(&format!("--parts {p}: use auto, ghost, solid or off"), &[]); } } if let Some(q) = &quantity { if !["current", "heat", "temperature", "signals", "flow", "river", "energy", "light", "cycle"].contains(&q.as_str()) { crate::err(&format!("--quantity {q}: use current, heat, temperature, signals, flow, river, energy, light or cycle"), &[]); } } let r = up("/ui/view", &json!({"quantity": quantity, "net": net, "layer": layer, "camera": camera, "air": air, "parts": parts, "focus": focus, "explode": explode, "speed": speed, "ghost": ghost, "silk": silk.as_deref().map(|s| s != "off" && s != "false")})); crate::ok(&format!("view: {}", r["view"]), &["The app repainted; a screenshot of the window shows it. `ui select --net <net>` lights one net; `ui issue --index n` flies to an issue.".into()]); } UiCmd::Explain { quantity, stop } => { let r = up("/ui/explain", &json!({"quantity": quantity, "stop": stop})); crate::ok(&format!("{} to {} page(s)", if stop { "stop sent" } else { "explain sent" }, r["clients"]), &["The page composes the explanation from this board's numbers and plays it with a one-line subtitle; a browser may hold the first audio until the viewer has clicked the page once.".into()]); } UiCmd::Toast { text, kind } => { up("/ui/toast", &json!({"text": text, "kind": kind})); crate::ok("toast shown", &[]); } UiCmd::Select { net, chip } => { let r = up("/ui/select", &json!({"net": net, "chip": chip})); crate::ok(&format!("selected: {}", r["view"]), &[]); }@@ -208,6 +214,7 @@ pub fn tour(port: u16, hold_ms: u64) { 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;+ toast("The energy cycle: input caps, the inductor's magnetic field, the output, one switching period"); step("/ui/view", json!({"quantity": "cycle", "camera": "iso"}), settle); steps += 1; step("/ui/view", json!({"explode": 0}), 0); } toast("Back to the current density"); step("/ui/view", json!({"quantity": "current", "air": "still", "camera": "iso"}), settle); steps += 1;
src/silk.rsadded+28@@ -0,0 +1,28 @@+//! The silkscreen, so the human can read what is printed on the board (J1, VIN, the logo) over the fields:+//! KiCad exports each silk layer as an SVG of the board area, exactly as it prints it; the app draws it on+//! one textured plane per side. A board KiCad cannot export (a Fusion .brd, no kicad-cli) simply has none.+use serde_json::{json, Value};+use std::path::Path;++fn export(board: &Path, out: &Path, layer: &str, name: &str) -> Result<(f64, f64), String> {+ let file = out.join(name);+ let o = std::process::Command::new("kicad-cli").args(["pcb", "export", "svg", "--layers", &format!("{layer},Edge.Cuts"), "--mode-single", "--page-size-mode", "2", "--exclude-drawing-sheet", "--black-and-white", "-o", &file.display().to_string(), &board.display().to_string()]).output().map_err(|e| format!("kicad-cli: {e}"))?;+ if !o.status.success() || !file.exists() { return Err(format!("kicad-cli could not export {layer}: {}", String::from_utf8_lossy(&o.stderr).lines().last().unwrap_or(""))); }+ let svg = std::fs::read_to_string(&file).map_err(|e| e.to_string())?;+ // the SVG frame is the board area in mm: viewBox="0 0 w h"+ let vb = svg.split("viewBox=\"").nth(1).and_then(|r| r.split('"').next()).ok_or("no viewBox in the silkscreen SVG")?;+ let n: Vec<f64> = vb.split_whitespace().filter_map(|x| x.parse().ok()).collect();+ if n.len() != 4 { return Err("unreadable viewBox".into()); }+ Ok((n[2], n[3]))+}++/// Export both silk layers into `out`; returns the fields.json `silk` block (null when there is none).+pub fn build(board: &str, out: &Path, log: &mut dyn FnMut(&str)) -> Value {+ if !board.to_lowercase().ends_with(".kicad_pcb") { log("silkscreen: only KiCad boards export it so far"); return Value::Null; }+ let b = std::fs::canonicalize(board).unwrap_or_else(|_| Path::new(board).to_path_buf());+ let mut res = json!({});+ for (layer, name, key) in [("F.SilkS", "silk-front.svg", "front"), ("B.SilkS", "silk-back.svg", "back")] {+ match export(&b, out, layer, name) { Ok((w, h)) => { res[key] = json!({"file": name, "widthMm": w, "heightMm": h}); } Err(e) => { log(&format!("silkscreen: {e}")); } }+ }+ if res.as_object().map_or(true, |o| o.is_empty()) { Value::Null } else { log("silkscreen: front and back exported"); res }+}
src/ui/app.js+55−6@@ -79,7 +79,41 @@ V.addContentRoot(root); V.frameModel(0.85); loadParts(f).catch(e => console.warn('parts: ' + (e && e.message || e)));+ loadSilk(f).catch(e => console.warn('silk: ' + (e && e.message || e))); }+ // ---- the silkscreen: KiCad's own SVG of each silk layer, drawn once into a texture on one plane per side,+ // so the board's printing (J1, VIN, the logo) reads over the fields for the cost of two quads+ const silk = { meshes: [] };+ async function loadSilk(f) {+ const s = f.silk; if (!s) { console.log('silk: none in this solve (`adom-fields silk --board <pcb> --fields <dir>` adds it)'); return; }+ for (const [side, info] of Object.entries(s)) {+ if (!info || !info.file) continue;+ const img = new Image(); img.src = info.file;+ try { await img.decode(); } catch (e) { console.warn('silk: ' + info.file + ' did not load'); continue; }+ const pxmm = Math.min(48, 4096 / Math.max(info.widthMm, info.heightMm)), cw = Math.round(info.widthMm * pxmm), ch = Math.round(info.heightMm * pxmm);+ const dt = new BAB.DynamicTexture('silk-' + side, { width: cw, height: ch }, scene, true);+ const g = dt.getContext(); g.clearRect(0, 0, cw, ch); g.drawImage(img, 0, 0, cw, ch);+ // KiCad plots it black: keep the shapes, paint them silkscreen white+ g.globalCompositeOperation = 'source-in'; g.fillStyle = '#f4f6f7'; g.fillRect(0, 0, cw, ch); g.globalCompositeOperation = 'source-over';+ dt.hasAlpha = true; dt.update(true);+ const plane = BAB.MeshBuilder.CreatePlane('silk-' + side, { width: info.widthMm, height: info.heightMm, sideOrientation: BAB.Mesh.DOUBLESIDE }, scene);+ const m = new BAB.StandardMaterial('silk-m-' + side, scene); m.diffuseTexture = dt; m.emissiveColor = new BAB.Color3(1, 1, 1); m.disableLighting = true; m.useAlphaFromDiffuseTexture = true; m.backFaceCulling = false; m.alpha = 0.92;+ plane.material = m; plane.parent = root; plane.isPickable = false; plane.renderingGroupId = 1;+ silk.meshes.push({ side, plane });+ }+ console.log(`silk: ${silk.meshes.map(x => x.side).join(' and ')} silkscreen on ${silk.meshes.length} plane(s)`);+ }+ scene.onBeforeRenderObservable.add(() => {+ if (!silk.meshes.length || !root) return;+ const on = state.view.silk !== false && state.view.quantity !== 'river';+ // each side shows only from its own side of the board, riding its copper layer (the exploded stack too)+ const inv = root.getWorldMatrix().clone().invert(), cam = BAB.Vector3.TransformCoordinates(camera.globalPosition || camera.position, inv);+ for (const { side, plane } of silk.meshes) {+ const L = state.layerMeshes[side === 'front' ? 'F.Cu' : 'B.Cu']; const z = (L ? L.position.z : 0) + (side === 'front' ? 0.06 : -0.06);+ plane.position.z = z; plane.setEnabled(on && (side === 'front' ? cam.z > z : cam.z < z));+ }+ const b = $('#btnSilk'); if (b) { b.hidden = !silk.meshes.length; b.classList.toggle('is-on', state.view.silk !== false); }+ }); // ---- the parts: each footprint's own 3D model, placed the way KiCad places it ---------------- // the hot zones say where the heat is; the chip standing in them says why const parts = { root: null, mats: new Set(), meshes: [], boxes: [], byRef: {}, ready: false };@@ -151,6 +185,7 @@ try { narr.mode = localStorage.getItem('fields.narrate') || 'first'; narr.heard = JSON.parse(localStorage.getItem('fields.narrated') || '{}'); } catch (e) { void e; } function narrationText(q) { const f = state.fields; if (!f) return '';+ if (q === 'cycle') return window.fieldsCycle ? window.fieldsCycle.narration() : ''; if (window.fieldsFlow && ['flow', 'river', 'energy', 'light'].includes(q)) return window.fieldsFlow.narration(q); const n0 = (f.nets || [])[0], c0 = (f.chips || [])[0]; if (q === 'current') return `This is current density: how many amps flow through each square millimetre of copper. Dark is quiet copper, yellow is where the current crowds.${n0 ? ` The busiest spot is on ${n0.net}, at about ${Math.round(n0.peakJ)} amps per square millimetre, on the ${n0.peakLayer === 'F.Cu' ? 'top' : n0.peakLayer === 'B.Cu' ? 'bottom' : 'inner'} copper.` : ''} Above about sixty amps per square millimetre, a narrow neck starts to heat up, and the issues list calls it out. Click a net on the left to see it alone.`;@@ -164,10 +199,17 @@ // one line over the legend hid the legend) function caption(text) { const c = $('#caption'); if (!c) return; c.textContent = text || ''; c.hidden = !text; if (!text) return;- const leg = $('#legend'), ro = $('#readout'); const hh = Math.max(leg && !leg.hidden ? leg.offsetHeight : 0, ro && !ro.hidden ? ro.offsetHeight : 0);- c.style.bottom = (16 + hh + 10) + 'px';+ // the bottom edge, in the gap between the readout and the legend, so it covers neither them nor the+ // field; only a pane too narrow for that gap lifts it above that row+ const stage = c.parentElement.getBoundingClientRect(), leg = $('#legend'), ro = $('#readout');+ const left = ro && !ro.hidden ? ro.getBoundingClientRect().right - stage.left + 12 : 16;+ const right = leg && !leg.hidden ? stage.right - leg.getBoundingClientRect().left + 12 : 16;+ const room = stage.width - left - right;+ if (room >= 280) { c.style.left = left + 'px'; c.style.right = right + 'px'; c.style.transform = 'none'; c.style.width = 'auto'; c.style.maxWidth = 'none'; c.style.bottom = '16px'; }+ else { const hh = Math.max(leg && !leg.hidden ? leg.offsetHeight : 0, ro && !ro.hidden ? ro.offsetHeight : 0); c.style.left = '50%'; c.style.right = 'auto'; c.style.transform = 'translateX(-50%)'; c.style.width = 'max-content'; c.style.maxWidth = 'min(640px, calc(100% - 32px))'; c.style.bottom = (16 + hh + 10) + 'px'; } }- const sentences = (t) => (t.match(/[^.!?]+[.!?]+(\s|$)/g) || [t]).map(x => x.trim()).filter(Boolean);+ // a sentence ends at . ! or ? followed by a space: '1.6 million' stays whole+ const sentences = (t) => t.split(/(?<=[.!?])\s+/).map(x => x.trim()).filter(Boolean); function subtitles(a, text) { const ss = sentences(text), total = ss.reduce((n, x) => n + x.length, 0) || 1; let shown = -1; a.ontimeupdate = () => { if (!a.duration) return; const at = a.currentTime / a.duration * total; let acc = 0, k = 0; for (; k < ss.length - 1; k++) { acc += ss[k].length; if (acc > at) break; } if (k !== shown) { shown = k; caption(ss[k]); } };@@ -282,7 +324,7 @@ function texName(L) { const q = state.view.quantity, ls = slug(L); if (q === 'signals') return `copper-${ls}.png`;- if (q === 'river' || q === 'energy' || q === 'light') return `copper-${ls}.png`;+ if (q === 'river' || q === 'energy' || q === 'light' || q === 'cycle') 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';@@ -297,7 +339,7 @@ if (L === 'In1.Cu' || L === 'In2.Cu') m.alpha = (L === selected) ? 1.0 : 0.0; } if (viaRoot) viaRoot.setEnabled(!['temperature', 'river'].includes(state.view.quantity));- for (const { el, c } of labels) { el.hidden = ['current','signals','flow','river','energy','light'].includes(state.view.quantity); el.textContent = c.reference + ' +' + (state.view.air === 'moving' ? c.riseMovingC : c.riseC).toFixed(0) + ' °C'; }+ for (const { el, c } of labels) { el.hidden = ['current','signals','flow','river','energy','light','cycle'].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';@@ -310,6 +352,10 @@ loadRaw(); for (const b of document.querySelectorAll('#btnExplode')) { const on = (+state.view.explode || 0) > 0.3; b.classList.toggle('is-on', on); b.setAttribute('aria-pressed', on); } if (window.fieldsFlow) window.fieldsFlow.apply(state.view);+ if (window.fieldsCycle) {+ window.fieldsCycle.apply(state.view);+ if (state.view.quantity === 'cycle') { const bd = $('#flowBadge'), kb = $('#btnKey'); let on = false; try { on = localStorage.getItem('fields.keyOn') === '1'; } catch (e) { void e; } const [h, ...rows] = window.fieldsCycle.describe(); if (bd) { bd.innerHTML = `<div class="kh">${h}</div><ul>${rows.map(r => `<li>${r}</li>`).join('')}</ul>`; bd.hidden = !on; } if (kb) { kb.hidden = false; kb.classList.toggle('is-on', on); } }+ } else { const bd = $('#flowBadge'); if (bd) { bd.hidden = !['flow', 'river', 'energy', 'light'].includes(state.view.quantity); bd.textContent = flowNote; } } } let flowNote = 'Loading the flow data';@@ -373,6 +419,7 @@ if (e.target.closest('#btnIssues')) { $('#right').classList.toggle('is-open'); $('#left').classList.remove('is-open'); } if (e.target.closest('#btnExplain')) { if (narr.audio || narr.busy) return stopNarration(); return explain(state.view.quantity, false); } const nm = e.target.closest('#narrMode button'); if (nm) { narr.mode = nm.dataset.n; try { localStorage.setItem('fields.narrate', narr.mode); } catch (err) { void err; } syncNarrSeg(); if (narr.mode !== 'ask') toast(narr.mode === 'first' ? 'Each view explains itself the first time you open it' : 'Every view explains itself when you open it'); return; }+ if (e.target.closest('#btnSilk')) return setView({ silk: state.view.silk === false }); 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'); }@@ -412,7 +459,9 @@ 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(); })+ .then(api => { window.fieldsFlow = api; if (!api) flowNote = 'This solve has no flow data: run adom-fields analyze again with 0.7 or later.'; applyTextures();+ // the energy cycle of a switching converter, on the flow view's shaders+ return import('./cycle.js').then(m => m.attach({ BAB, scene, V, camera, state, Z, LAYERS, frameDt, root, layerMeshes: state.layerMeshes })).then(c => { window.fieldsCycle = c; applyTextures(); }).catch(e => console.error('cycle: ' + (e && e.stack || e))); }) .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/cycle.jsadded+216@@ -0,0 +1,216 @@+// Adom Fields: the energy cycle of a switching converter.+// One switching period, slowed about a million times: the input capacitors' electric field giving energy+// while the switch is on, the inductor's magnetic field growing on the on-time and collapsing on the+// off-time to push the current on, the output capacitors smoothing it, the heat drained every pass.+// The model is the ideal continuous-conduction buck solved exactly (no AI, no SPICE): duty D = Vout/Vin,+// inductor ripple dI = (Vin - Vout) D T / L, the capacitor currents and their charge integrated over the+// period. L and C come from the parts' values on the board; the parts from the nets they join.+export async function attach(ctx) {+ const { BAB, scene, V, camera, state, Z, LAYERS, frameDt, root } = ctx;+ const f = state.fields, meta = f.flow, board = f.board;+ if (!meta) return null;+ const nets = meta.nets, vals = f.partValues || {}, pads = f.pads || [];+ const bx = (x) => x - board.x0 - board.widthMm / 2, by = (y) => board.y0 + board.heightMm / 2 - y;+ const PREFIX = { p: 1e-12, n: 1e-9, u: 1e-6, 'µ': 1e-6, m: 1e-3, k: 1e3, '': 1 };+ // "15uH", "22uF 10V", "4u7", "100nF", "0.1uF" -> SI, or null+ function parseValue(s, unit) {+ for (const tok of String(s || '').replace(',', '.').split(/\s+/)) {+ const m = tok.match(/^(\d+(?:\.\d+)?)([pnuµmk]?)(\d*)([FH]?)$/i); if (!m) continue;+ const u = (m[4] || '').toUpperCase(); if (u && u !== unit) continue; if (!u && !m[2]) continue;+ const num = parseFloat(m[1] + (m[3] ? '.' + m[3] : '')); const mul = PREFIX[m[2]] ?? PREFIX[m[2].toLowerCase()] ?? 1;+ return num * mul;+ }+ return null;+ }+ const padsOf = (ref) => pads.filter(p => p.ref === ref);+ const netsOf = (ref) => [...new Set(padsOf(ref).map(p => p.net))];+ const centre = (ref) => { const it = ((f.parts || {}).items || []).find(p => p.ref === ref); if (it) return { x: it.x, y: it.y }; const ps = padsOf(ref); if (!ps.length) return null; return { x: ps.reduce((s, p) => s + p.x, 0) / ps.length, y: ps.reduce((s, p) => s + p.y, 0) / ps.length }; };+ // ---- find the converter: the switching node, its inductor, its switch, its input and output -------+ const sw = nets.find(n => n.switching && n.loaded) || nets.find(n => n.switching);+ const why = [];+ let model = null;+ if (!sw) why.push('no switching node in this solve (the spec\'s switching, or a net named SW, LX or PH)');+ else {+ const refs = [...new Set(pads.map(p => p.ref))];+ const Lref = refs.find(r => /^L\d/i.test(r) && netsOf(r).includes(sw.name));+ const outNet = Lref ? netsOf(Lref).find(n => n !== sw.name) : null;+ const swRef = ((sw.terminals || []).find(t => t.role === 'source') || {}).ref || refs.find(r => /^U\d/i.test(r) && netsOf(r).includes(sw.name));+ const gnd = nets.find(n => n.ground);+ const rail = (name) => (nets.find(n => n.name === name) || {}).railV;+ const inNet = swRef ? netsOf(swRef).filter(n => n !== sw.name && !(gnd && n === gnd.name)).sort((a, b) => (rail(b) ?? -1) - (rail(a) ?? -1))[0] : null;+ const capsOn = (net) => refs.filter(r => /^C\d/i.test(r) && netsOf(r).includes(net) && gnd && netsOf(r).includes(gnd.name)).map(r => ({ ref: r, C: parseValue(vals[r], 'F') })).filter(c => c.C);+ const L = Lref ? parseValue(vals[Lref], 'H') : null;+ const Vin = inNet ? rail(inNet) : null, Vout = outNet ? rail(outNet) : null;+ const loadOut = outNet ? (nets.find(n => n.name === outNet) || {}).amps : null;+ const fsw = sw.switching.freqHz || 500e3;+ if (!Lref || !L) why.push('no inductor with a value on the switching node');+ if (!Vin || !Vout) why.push('the input and output rails need voltages');+ if (!loadOut) why.push('the output net needs a load current');+ if (!why.length && Vout < Vin) {+ const cin = capsOn(inNet), cout = capsOn(outNet);+ const Cin = cin.reduce((s, c) => s + c.C, 0) || 1e-6, Cout = cout.reduce((s, c) => s + c.C, 0) || 1e-6;+ const T = 1 / fsw, D = Vout / Vin, Iout = loadOut, dI = (Vin - Vout) * D * T / L, Iin = D * Iout;+ const heatW = (f.chips || []).filter(c => c.reference === swRef || c.reference === Lref).reduce((s, c) => s + c.watts, 0);+ const S = 240, iL = new Float64Array(S), iSw = new Float64Array(S), vCin = new Float64Array(S), vCout = new Float64Array(S);+ for (let k = 0; k < S; k++) { const ph = k / S; iL[k] = ph < D ? Iout - dI / 2 + dI * ph / D : Iout + dI / 2 - dI * (ph - D) / (1 - D); iSw[k] = ph < D ? iL[k] : 0; }+ // the capacitor charge over the period, zero mean: the input caps give while the switch is on, refill while it is off+ let qi = 0, qo = 0; const qin = new Float64Array(S), qout = new Float64Array(S);+ for (let k = 0; k < S; k++) { qi += (Iin - iSw[k]) * T / S; qo += (iL[k] - Iout) * T / S; qin[k] = qi; qout[k] = qo; }+ const mi = qin.reduce((s, x) => s + x, 0) / S, mo = qout.reduce((s, x) => s + x, 0) / S;+ for (let k = 0; k < S; k++) { vCin[k] = Vin + (qin[k] - mi) / Cin; vCout[k] = Vout + (qout[k] - mo) / Cout; }+ const EL = Array.from(iL, i => 0.5 * L * i * i), ECin = Array.from(vCin, v => 0.5 * Cin * v * v), ECout = Array.from(vCout, v => 0.5 * Cout * v * v);+ model = { Lref, swRef, inNet, outNet, gnd: gnd && gnd.name, cin, cout, Cin, Cout, L, Vin, Vout, Iout, Iin, D, T, fsw, dI, heatW, S, iL, iSw, vCin, vCout, EL, ECin, ECout,+ perCycle: Vout * Iout * T, heatPerCycle: heatW * T, rippleIn: Math.max(...vCin) - Math.min(...vCin), rippleOut: Math.max(...vCout) - Math.min(...vCout) };+ } else if (!why.length) why.push('only a step-down (buck) converter is modelled so far');+ }+ const at = (arr, ph) => { const x = ((ph % 1) + 1) % 1 * model.S, k = Math.floor(x), t = x - k; return arr[k] * (1 - t) + arr[(k + 1) % model.S] * t; };+ const span = (arr) => { let lo = Infinity, hi = -Infinity; for (const v of arr) { lo = Math.min(lo, v); hi = Math.max(hi, v); } return [lo, hi]; };++ // ---- the picture: instanced streaks (the flow view's own shader) ---------------------------------+ const CAP = 2400;+ const IP = new Float32Array(CAP * 4), IV = new Float32Array(CAP * 4), IC = new Float32Array(CAP * 4);+ const mesh = new BAB.Mesh('fields-cycle-streaks', scene);+ { const vd = new BAB.VertexData(); vd.positions = [-1, -1, 0, 1, -1, 0, 1, 1, 0, -1, 1, 0]; vd.indices = [0, 1, 2, 0, 2, 3]; vd.applyToMesh(mesh, false); }+ const engine = V.getEngine();+ const vbP = new BAB.VertexBuffer(engine, IP, 'ip', true, false, 4, true), vbV = new BAB.VertexBuffer(engine, IV, 'iv', true, false, 4, true), vbC = new BAB.VertexBuffer(engine, IC, 'ic', true, false, 4, true);+ mesh.setVerticesBuffer(vbP); mesh.setVerticesBuffer(vbV); mesh.setVerticesBuffer(vbC);+ mesh.forcedInstanceCount = CAP; mesh.alwaysSelectAsActiveMesh = true; mesh.isPickable = false; mesh.parent = root; mesh.renderingGroupId = 1; mesh.setEnabled(false);+ const mat = new BAB.ShaderMaterial('fields-cycle-streak', scene, { vertex: 'fieldsStreak', fragment: 'fieldsStreak' }, { attributes: ['position', 'ip', 'iv', 'ic'], uniforms: ['world', 'viewProjection', 'camPos', 'trail', 'width'], needAlphaBlending: true });+ mat.alphaMode = BAB.Constants.ALPHA_ADD; mat.disableDepthWrite = true; mat.backFaceCulling = false; mat.setFloat('trail', 0.25); mat.setFloat('width', 1.0);+ mesh.material = mat;+ try { const g = scene.effectLayers && scene.effectLayers.find(l => l.name === 'fields-flow-glow'); if (g) { g.addIncludedOnlyMesh(mesh); g.referenceMeshToUseItsOwnMaterial(mesh); } } catch (e) { void e; }++ const diag = Math.hypot(board.widthMm, board.heightMm), size = Math.max(0.06, diag * 0.004);+ const top = () => Z['F.Cu'] + 0.05;+ // the particles: kind 0 energy packet on a route, 1 magnetic loop around the inductor, 2 field between a capacitor's pads, 3 heat+ const P = [];+ let seed = 7; const rnd = () => { seed = (seed * 16807) % 2147483647; return seed / 2147483647; };+ function route(refs) { const pts = refs.map(centre).filter(Boolean); const len = []; let acc = 0; for (let i = 1; i < pts.length; i++) { acc += Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y); len.push(acc); } return { pts, len, total: acc || 1 }; }+ function along(r, u) { const d = u * r.total; let i = 0; while (i < r.len.length - 1 && r.len[i] < d) i++; const d0 = i ? r.len[i - 1] : 0, seg = (r.len[i] - d0) || 1, t = Math.min(1, Math.max(0, (d - d0) / seg)); const a = r.pts[i], b = r.pts[i + 1] || a; return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; }+ let routes = null, magC = null, caps = [];+ function build() {+ if (!model) return;+ const src = ((nets.find(n => n.name === model.inNet) || {}).terminals || []).find(t => t.role === 'source');+ const sink = ((nets.find(n => n.name === model.outNet) || {}).terminals || []).find(t => t.role === 'sink');+ const cinMain = model.cin.slice().sort((a, b) => b.C - a.C)[0], coutMain = model.cout.slice().sort((a, b) => b.C - a.C)[0];+ routes = {+ feed: route([src && src.ref, cinMain && cinMain.ref].filter(Boolean)), // the source refills the input caps+ on: route([cinMain && cinMain.ref, model.swRef, model.Lref].filter(Boolean)), // on: the input caps through the switch into the inductor+ out: route([model.Lref, coutMain && coutMain.ref, sink && sink.ref].filter(Boolean)), // always: the inductor into the output caps and the load+ };+ magC = centre(model.Lref);+ caps = model.cin.map(c => ({ ...c, side: 'in' })).concat(model.cout.map(c => ({ ...c, side: 'out' }))).map(c => { const ps = padsOf(c.ref); const hot = ps.find(p => p.net !== model.gnd), cold = ps.find(p => p.net === model.gnd); return hot && cold ? { ...c, a: hot, b: cold } : null; }).filter(Boolean);+ P.length = 0;+ for (let i = 0; i < 260; i++) P.push({ kind: 0, r: i < 45 ? 'feed' : i < 150 ? 'on' : 'out', u: rnd(), s: 0.75 + 0.5 * rnd(), j: (rnd() - 0.5) * 0.5 });+ for (let a = 0; a < 6; a++) for (const R of [0.9, 1.5, 2.3]) for (let k = 0; k < 14; k++) P.push({ kind: 1, a: a * Math.PI / 6 + (R > 1 ? 0.25 : 0), R, u: k / 14 + rnd() * 0.05 });+ for (const c of caps) for (let k = 0; k < 26; k++) P.push({ kind: 2, cap: c, u: rnd(), j: (rnd() - 0.5) });+ for (let k = 0; k < 140; k++) P.push({ kind: 3, ref: k % 3 ? model.swRef : model.Lref, u: rnd(), dx: (rnd() - 0.5) * 1.6, dy: (rnd() - 0.5) * 1.6 });+ }+ build();++ // ---- the clock: one period over CYCLE_S seconds on screen ------------------------------------------+ const CYCLE_S = 3.2;+ const knob = { on: 0, to: 0, ph: 0 };+ const [eLlo, eLhi] = model ? span(model.EL) : [0, 1], [ciLo, ciHi] = model ? span(model.ECin) : [0, 1], [coLo, coHi] = model ? span(model.ECout) : [0, 1];+ const norm = (v, lo, hi) => hi > lo ? (v - lo) / (hi - lo) : 0.5;+ function write(i, x, y, z, vx, vy, vz, a, c, s) { const o = i * 4; IP[o] = bx(x); IP[o + 1] = by(y); IP[o + 2] = z; IP[o + 3] = size * (s || 1); IV[o] = vx; IV[o + 1] = -vy; IV[o + 2] = vz; IV[o + 3] = a; IC[o] = c[0]; IC[o + 1] = c[1]; IC[o + 2] = c[2]; IC[o + 3] = 1; }+ function frame(dt) {+ knob.ph = (knob.ph + dt / CYCLE_S) % 1;+ const ph = knob.ph, isOn = ph < model.D;+ const iL = at(model.iL, ph), iSw = at(model.iSw, ph);+ const eL = norm(at(model.EL, ph), eLlo, eLhi), eIn = norm(at(model.ECin, ph), ciLo, ciHi), eOut = norm(at(model.ECout, ph), coLo, coHi);+ const z0 = top(), rates = { feed: model.Iin * model.Vin, on: iSw * model.Vin, out: iL * model.Vout }, maxRate = Math.max(model.Vin * (model.Iout + model.dI / 2), 1e-9);+ const lTop = (() => { const n = scene.getTransformNodeByName('part-' + model.Lref); let h = 1.5; if (n) for (const m of n.getChildMeshes(false)) { const bb = m.getBoundingInfo().boundingBox; h = Math.max(h, bb.maximumWorld.z - z0); } return Math.min(h, 6); })();+ for (let i = 0; i < CAP; i++) {+ const p = P[i]; if (!p) { IV[i * 4 + 3] = 0; continue; }+ if (p.kind === 0) {+ const r = routes[p.r]; if (!r || r.pts.length < 2) { IV[i * 4 + 3] = 0; continue; }+ const rate = rates[p.r] / maxRate;+ p.u = (p.u + dt * 0.45 * p.s) % 1;+ const q = along(r, p.u), q2 = along(r, Math.min(1, p.u + 0.02)), lift = 0.35 + 0.25 * Math.sin(p.u * Math.PI);+ write(i, q.x + p.j * 0.3, q.y + p.j * 0.3, z0 + lift, (q2.x - q.x) * 40, (q2.y - q.y) * 40, 0, knob.on * Math.min(1, rate) * 0.32, [1.0, 0.35, 0.85], 0.8);+ } else if (p.kind === 1) {+ // a field line of a dipole: r = R sin² θ in a vertical plane through the inductor; the particles run+ // along it as fast as the current, as bright as the field's stored energy+ p.u = (p.u + dt * (0.15 + 0.5 * iL / (model.Iout + model.dI / 2))) % 1;+ const th = 0.12 + p.u * (Math.PI - 0.24), sc = (0.75 + 0.45 * eL) * p.R * 1.6, rr = sc * Math.sin(th) ** 2;+ const xx = rr * Math.sin(th), zz = rr * Math.cos(th);+ const th2 = th + 0.05, rr2 = sc * Math.sin(th2) ** 2, xx2 = rr2 * Math.sin(th2), zz2 = rr2 * Math.cos(th2);+ const ca = Math.cos(p.a), sa = Math.sin(p.a);+ write(i, magC.x + xx * ca, magC.y + xx * sa, z0 + lTop * 0.5 + zz, (xx2 - xx) * ca * 30, (xx2 - xx) * sa * 30, (zz2 - zz) * 30, knob.on * (0.3 + 0.7 * eL), [0.4, 0.68, 1.0], 1.25);+ } else if (p.kind === 2) {+ // the field between a capacitor's two pads: an arch from the rail pad to the ground pad+ const c = p.cap, e = c.side === 'in' ? eIn : eOut;+ p.u = (p.u + dt * 0.6) % 1;+ const x = c.a.x + (c.b.x - c.a.x) * p.u, y = c.a.y + (c.b.y - c.a.y) * p.u, hz = 0.25 + 0.6 * Math.sin(p.u * Math.PI) * (0.6 + p.j * 0.4);+ write(i, x + p.j * 0.25, y + p.j * 0.25, z0 + hz, (c.b.x - c.a.x) * 2, (c.b.y - c.a.y) * 2, Math.cos(p.u * Math.PI) * 2, knob.on * (0.15 + 0.85 * e), [1.0, 0.3, 0.25], 1.0);+ } else {+ // heat leaving the switch and the inductor every pass+ const cx = centre(p.ref); if (!cx) { IV[i * 4 + 3] = 0; continue; }+ p.u = (p.u + dt * 0.35) % 1;+ write(i, cx.x + p.dx, cx.y + p.dy, z0 + 0.4 + p.u * 4, 0, 0, 1.5, knob.on * 0.35 * (1 - p.u) * Math.min(1, model.heatW / 0.3 + 0.3), [1.0, 0.55, 0.15], 0.8);+ }+ }+ vbP.update(IP); vbV.update(IV); vbC.update(IC);+ mat.setVector3('camPos', camera.globalPosition || camera.position);+ meter(ph, isOn, iL, eL, eIn, eOut);+ }++ // ---- the meter: where the energy is, this instant of the period --------------------------------------+ const panel = document.createElement('div'); panel.className = 'cyclemeter'; panel.hidden = true;+ document.querySelector('.stage').appendChild(panel);+ const uJ = (j) => (j * 1e6).toFixed(j * 1e6 < 10 ? 2 : 1) + ' µJ';+ if (model) panel.innerHTML = `<div class="ch" title="Click to fold">Energy cycle · ${(model.fsw / 1000).toFixed(0)} kHz, shown ${Math.round(CYCLE_S * model.fsw).toLocaleString()}× slower</div>+ <div class="body"><div class="row sw"><span>${model.swRef} switch</span><b id="cySw"></b></div>+ <canvas id="cyWave" width="440" height="64"></canvas>+ <div class="row"><span class="dot in"></span>Input caps (${(model.Cin * 1e6).toFixed(0)} µF) <i id="cyIn"></i></div><div class="bar"><div id="cyInB" class="in"></div></div>+ <div class="row"><span class="dot l"></span>${model.Lref} magnetic field (${(model.L * 1e6).toFixed(0)} µH) <i id="cyL"></i></div><div class="bar"><div id="cyLB" class="l"></div></div>+ <div class="row"><span class="dot out"></span>Output caps (${(model.Cout * 1e6).toFixed(0)} µF) <i id="cyOut"></i></div><div class="bar"><div id="cyOutB" class="out"></div></div>+ <div class="row"><span class="dot heat"></span>Heat, each cycle <i>${uJ(model.heatPerCycle)}</i></div>+ <div class="row"><span class="dot pk"></span>To the load, each cycle <i>${uJ(model.perCycle)}</i></div></div>`;+ panel.addEventListener('click', (e) => { if (e.target.closest('.ch')) panel.classList.toggle('folded'); });+ function meter(ph, isOn, iL, eL, eIn, eOut) {+ const q = (id) => panel.querySelector('#' + id); if (!q('cySw')) return;+ q('cySw').textContent = isOn ? 'ON: input → inductor' : 'OFF: inductor → output'; q('cySw').className = isOn ? 'on' : 'off';+ q('cyIn').textContent = `${(at(model.vCin, ph)).toFixed(3)} V`; q('cyInB').style.width = (10 + 90 * eIn) + '%';+ q('cyL').textContent = `${iL.toFixed(2)} A · ${uJ(at(model.EL, ph))}`; q('cyLB').style.width = (10 + 90 * eL) + '%';+ q('cyOut').textContent = `${(at(model.vCout, ph)).toFixed(4)} V`; q('cyOutB').style.width = (10 + 90 * eOut) + '%';+ const cv = q('cyWave'), g = cv.getContext('2d'), W = cv.width, H = cv.height, [lo, hi] = span(model.iL);+ g.clearRect(0, 0, W, H); g.fillStyle = 'rgba(255,90,210,.10)'; g.fillRect(0, 0, W * model.D, H);+ g.strokeStyle = '#64abff'; g.lineWidth = 3; g.beginPath();+ for (let k = 0; k <= 120; k++) { const t = k / 120, v = at(model.iL, t), yy = H - 8 - (v - lo) / (hi - lo || 1) * (H - 16); k ? g.lineTo(t * W, yy) : g.moveTo(t * W, yy); } g.stroke();+ g.strokeStyle = '#ffffff'; g.lineWidth = 2; g.beginPath(); g.moveTo(ph * W, 0); g.lineTo(ph * W, H); g.stroke();+ g.fillStyle = '#8b949e'; g.font = '20px JetBrains Mono, monospace'; g.fillText(`${model.Lref} current ${lo.toFixed(2)}–${hi.toFixed(2)} A`, 8, 22);+ }++ // ---- the frame, the view, the words ---------------------------------------------------------------+ 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 && model; mesh.setEnabled(!!live); panel.hidden = !(knob.to > 0.5);+ if (live) frame(dt * (+state.view.speed || 1));+ });+ function apply(view) {+ knob.to = view.quantity === 'cycle' ? 1 : 0;+ if (view.quantity !== 'cycle') return;+ if (!model) { panel.innerHTML = `<div class="ch">Energy cycle</div><div class="body"><div class="row">Not modelled: ${why.join('; ')}.</div></div>`; }+ for (const Lname of LAYERS) { const m = ctx.layerMeshes[Lname]; if (m && m.material) m.material.alpha = Lname === 'F.Cu' ? 0.35 : 0; }+ const leg = document.getElementById('legend'); if (leg) leg.hidden = true;+ }+ function describe() {+ if (!model) return ['<b>Energy cycle</b>', 'Not modelled: ' + why.join('; ')];+ return ['<b>Energy cycle</b>: where the energy is during one switching period',+ '<b>Pink</b> = energy moving: from the input caps through ' + model.swRef + ' into ' + model.Lref + ' while the switch is on; from ' + model.Lref + ' to the output caps and the load all the time',+ '<b>Blue</b> = ' + model.Lref + '\'s magnetic field, growing while the switch is on and collapsing while it is off: that collapse keeps the current flowing',+ '<b>Red</b> = the electric field in each capacitor: the input caps give while the switch is on and refill while it is off',+ '<b>Orange</b> = heat lost every cycle',+ 'Ideal buck model, D = Vout/Vin, from the parts\' values; brightness shows each store\'s swing, not its total'];+ }+ function narration() {+ if (!model) return 'The energy cycle needs a switching converter: ' + why.join('; ') + '.';+ const pct = Math.round(model.D * 100), lo = model.Iout - model.dI / 2, hi = model.Iout + model.dI / 2;+ return `This is the energy cycle, one switching period of the buck converter, slowed down about ${Math.round(CYCLE_S * model.fsw / 1e6 * 10) / 10} million times. For ${pct} percent of each cycle, the switch in ${model.swRef} is on. Energy, in pink, leaves the input capacitors, which you see dim in red as they give it up, and flows through ${model.swRef} into the inductor ${model.Lref}. The inductor stores it in its magnetic field, in blue, which grows as its current climbs to ${hi.toFixed(2)} amps. Then the switch turns off. The magnetic field collapses, and that collapse keeps pushing current into the output capacitors and the load, even with the input cut off, until the current falls back to ${lo.toFixed(2)} amps. Meanwhile the input capacitors refill from the supply. Every cycle, about ${(model.perCycle * 1e6).toFixed(0)} microjoules reach the load, and a little is lost as heat, in orange. Electric field, magnetic field, and back: that is how a switching converter moves energy.`;+ }+ console.log(model ? `cycle: ${model.swRef} switching ${model.inNet} ${model.Vin} V to ${model.outNet} ${model.Vout} V at ${(model.fsw / 1000).toFixed(0)} kHz, D ${(model.D * 100).toFixed(1)} %, ${model.Lref} ${(model.L * 1e6).toFixed(1)} µH ripple ${model.dI.toFixed(3)} A, Cin ${(model.Cin * 1e6).toFixed(1)} µF (${model.cin.map(c => c.ref).join(' ')}), Cout ${(model.Cout * 1e6).toFixed(1)} µF (${model.cout.map(c => c.ref).join(' ')}), ripple in ${(model.rippleIn * 1000).toFixed(1)} mV out ${(model.rippleOut * 1000).toFixed(2)} mV` : 'cycle: not modelled: ' + why.join('; '));+ return { apply, describe, narration, model: () => model };+}
src/ui/index.html+4−2@@ -61,7 +61,8 @@ body{margin:0;background:var(--bg);color:var(--text-body);font-family:"Satoshi", .lbl.light{border-color:rgba(126,230,176,.45)}.lbl.light .ps{color:#d9ffbf;font-weight:600} .legend.energy .ramp{background:linear-gradient(90deg,#3a0a3a,#8c1a8c,#e040c8,#ff7ae0,#ffd9f4,#ffffff)} .legend.volts .ramp{background:linear-gradient(90deg,#041238,#0a3a6a,#006b8c,#2fa9c9,#59d9fa,#c8f3ff)}-.caption{position:absolute;left:50%;bottom:120px;transform:translateX(-50%);width:max-content;max-width:min(640px,calc(100% - 32px));background:rgba(13,17,23,.84);border:1px solid var(--border);border-radius:10px;padding:6px 14px;font-size:15px;line-height:1.45;text-align:center;color:var(--text);pointer-events:none;z-index:5;backdrop-filter:blur(6px)}.caption[hidden]{display:none}+.caption{position:absolute;left:50%;bottom:16px;transform:translateX(-50%);width:max-content;max-width:min(640px,calc(100% - 32px));background:rgba(13,17,23,.84);border:1px solid var(--border);border-radius:10px;padding:6px 14px;font-size:14px;line-height:1.45;text-align:center;color:var(--text);pointer-events:none;z-index:5;backdrop-filter:blur(6px)}.caption[hidden]{display:none}+.cyclemeter{position:absolute;left:16px;bottom:72px;width:250px;background:rgba(22,27,34,.88);border:1px solid var(--border);border-radius:8px;padding:8px 10px;font-size:12px;color:var(--text-body);backdrop-filter:blur(4px);z-index:4}.cyclemeter[hidden]{display:none}.cyclemeter .ch{font-family:"Familjen Grotesk",sans-serif;font-size:12.5px;color:var(--text);cursor:pointer;margin-bottom:4px}.cyclemeter.folded .body{display:none}.cyclemeter .row{display:flex;align-items:center;gap:6px;margin-top:5px}.cyclemeter .row i{margin-left:auto;font-style:normal;font-family:"JetBrains Mono";font-size:11px;color:var(--text)}.cyclemeter .sw b{margin-left:auto;font-weight:600;font-size:11.5px}.cyclemeter .sw b.on{color:#ff7ae0}.cyclemeter .sw b.off{color:#64abff}.cyclemeter canvas{width:100%;height:32px;display:block;margin-top:4px;background:rgba(13,17,23,.6);border-radius:4px}.cyclemeter .bar{height:4px;background:rgba(255,255,255,.08);border-radius:2px;margin-top:3px}.cyclemeter .bar div{height:100%;border-radius:2px}.cyclemeter .dot{width:8px;height:8px;border-radius:50%;flex:0 0 8px}.cyclemeter .in{background:#ff5a4a}.cyclemeter .l{background:#5a9aff}.cyclemeter .out{background:#ff8a5a}.cyclemeter .heat{background:#ff9a2a}.cyclemeter .pk{background:#ff5ad8} .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)}@@ -99,6 +100,7 @@ body{margin:0;background:var(--bg);color:var(--text-body);font-family:"Satoshi", <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="btnExplain" data-tip="Explain this view out loud (click again to stop)" aria-pressed="false"><svg viewBox="0 0 24 24"><path d="M11 5 6 9H3v6h3l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13"/></svg></button>+ <button class="ibtn" id="btnSilk" hidden data-tip="The silkscreen: what is printed on the board (J1, VIN, the logo), on or off" aria-pressed="false"><svg viewBox="0 0 24 24"><path d="M5 6h14M12 6v13M8.5 19h7"/></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>@@ -106,7 +108,7 @@ body{margin:0;background:var(--bg);color:var(--text-body);font-family:"Satoshi", </header> <div class="app-body"> <aside class="side" id="left">- <div class="sec"><h3>Field</h3><div class="seg" id="qty"><button data-q="current" data-tip="Current density on the copper, amps per square millimetre, every loaded net at its spec current">Current</button><button data-q="heat" data-tip="Heat flow in the copper, watts per millimetre of width, from the hot tabs and the Joule loss">Heat</button><button data-q="signals" data-tip="Copper overlap capacitance and differential-pair screening">Signals</button><button data-q="temperature" data-tip="Temperature rise over the board, the FR4 spreading">Temperature</button></div><div class="seg" id="qty2" style="margin-top:4px"><button data-q="flow" data-tip="The solved current as charge streaming through the copper: speed and heat follow the current density, the vias take their share">Flow</button><button data-q="river" data-tip="Every net lifted to its voltage, the current running downhill as water: the loads fall to ground, the sources lift it back">River</button><button data-q="energy" data-tip="Where the energy really travels: through the fields between the copper layers, while the electrons crawl at millimetres per second">Energy</button><button data-q="light" data-tip="How fast a change travels: the local speed of light in the copper's field, and when a switch-on reaches each part">Light</button></div></div>+ <div class="sec"><h3>Field</h3><div class="seg" id="qty"><button data-q="current" data-tip="Current density on the copper, amps per square millimetre, every loaded net at its spec current">Current</button><button data-q="heat" data-tip="Heat flow in the copper, watts per millimetre of width, from the hot tabs and the Joule loss">Heat</button><button data-q="signals" data-tip="Copper overlap capacitance and differential-pair screening">Signals</button><button data-q="temperature" data-tip="Temperature rise over the board, the FR4 spreading">Temperature</button></div><div class="seg" id="qty2" style="margin-top:4px"><button data-q="flow" data-tip="The solved current as charge streaming through the copper: speed and heat follow the current density, the vias take their share">Flow</button><button data-q="river" data-tip="Every net lifted to its voltage, the current running downhill as water: the loads fall to ground, the sources lift it back">River</button><button data-q="energy" data-tip="Where the energy really travels: through the fields between the copper layers, while the electrons crawl at millimetres per second">Energy</button><button data-q="light" data-tip="How fast a change travels: the local speed of light in the copper's field, and when a switch-on reaches each part">Light</button><button data-q="cycle" data-tip="One switching period: energy from the input capacitors into the inductor's magnetic field, then out to the output; the stores and the heat as they change">Cycle</button></div></div> <div class="sec"><h3>Layer</h3><div class="seg" id="layer"><button data-l="F.Cu">F.Cu</button><button data-l="In1.Cu">In1</button><button data-l="In2.Cu">In2</button><button data-l="B.Cu">B.Cu</button></div></div> <div class="sec" id="airSec"><h3>Air</h3><div class="seg" id="air"><button data-a="still" data-tip="Still air, 10 W/m²K on both faces">Still</button><button data-a="moving" data-tip="Moving air, 25 W/m²K: a fan or a moving vehicle">Moving</button></div></div> <div class="sec"><h3>Explain each view out loud</h3><div class="seg" id="narrMode"><button data-n="first" data-tip="Each kind of view explains itself the first time you open it">First time</button><button data-n="always" data-tip="Every view explains itself whenever you open it">Always</button><button data-n="ask" data-tip="Only when you click the speaker in the header">On ask</button></div></div>