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.1: every view explains itself out loud (first time by default), one-line subtitles
14 files changed
+139−8
Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-fields"-version = "0.7.0"+version = "0.7.1" dependencies = [ "clap", "png",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-fields"-version = "0.7.0"+version = "0.7.1" 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+4@@ -102,6 +102,10 @@ 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.* +## 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.+ ## Fusion boards in 0.6.0 `--board` now also takes a Fusion Electronics board saved as EAGLE source (`.brd`: the Fusion
SKILL.md+4@@ -95,6 +95,10 @@ Developing the page: `ADOM_FIELDS_UI_DIR=<repo>/src/ui adom-fields serve ...` se **Light** (`ui view --quantity light`) colours each piece of copper by how fast a change travels in its field, as a % of the speed of light: 1/√εr_eff, all FR4 between planes (about 48 %), Hammerstad's εr_eff for a trace over a plane (wider is slower), mostly air with no plane under it. A switch-on front sweeps out from each fed net's source, and each load is tagged with its arrival time in picoseconds (the fastest path, the sum of length over speed along the way). εr comes from the spec's signals epsilonR, else 4.3. A switching node's DC average (`switching.SW.meanV`) sets its level in the energy view. +## Explained out loud (0.7.1)++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.+ ## 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.1.mdadded+4@@ -0,0 +1,4 @@+# adom-fields 0.7.1++- **Explained out loud.** A speaker button speaks the view in plain words with this board's numbers; `ui explain` does it from the CLI. By default each kind of view explains itself the first time a viewer opens it (First time / Always / On ask in the left panel, saved per viewer). The text is the app's own template, so no AI tokens; adom-tts voices it once and the mp3 is cached under `<fields>/narration/` by the text's hash. A one-line subtitle follows the voice, above the legend and readout.+- The Flow explanation quotes the true peak density, not the display scale.
package.json+4−2@@ -1,7 +1,7 @@ { "slug": "adom-fields", "type": "app",- "version": "0.7.0",+ "version": "0.7.1", "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.",@@ -43,7 +43,9 @@ "voltage drop in 3d", "ground bounce", "exploded layer stack",- "current through vias"+ "current through vias",+ "explain the view out loud",+ "narrate the fields" ], "sample_prompts": [ {
page.json+4−2@@ -1,7 +1,7 @@ { "slug": "adom-fields", "type": "app",- "version": "0.7.0",+ "version": "0.7.1", "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.",@@ -41,7 +41,9 @@ "poynting vector", "speed of light on a pcb", "electron drift speed",- "exploded layer stack"+ "exploded layer stack",+ "explain the view out loud",+ "narrate the fields" ], "sample_prompts": [ {
skills/adom-fields/SKILL.md+4@@ -95,6 +95,10 @@ Developing the page: `ADOM_FIELDS_UI_DIR=<repo>/src/ui adom-fields serve ...` se **Light** (`ui view --quantity light`) colours each piece of copper by how fast a change travels in its field, as a % of the speed of light: 1/√εr_eff, all FR4 between planes (about 48 %), Hammerstad's εr_eff for a trace over a plane (wider is slower), mostly air with no plane under it. A switch-on front sweeps out from each fed net's source, and each load is tagged with its arrival time in picoseconds (the fastest path, the sum of length over speed along the way). εr comes from the spec's signals epsilonR, else 4.3. A switching node's DC average (`switching.SW.meanV`) sets its level in the energy view. +## Explained out loud (0.7.1)++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.+ ## Honest limits (0.1) DC only (no skin effect, no switching loss on the copper); per-layer thickness when copperUmByLayer is supplied; the 1.6 mm stack is 0.2 / 1.065 / 0.2 mm unless `stackupMm` says otherwise; convection is a flat coefficient on both faces (10 or 25 W/m²K), optional specified rectangular conducting blocks; no fins, enclosure or radiation; the tab watts come from the spec, not from a switching model. It is a screen that finds the necks and the starved tabs, and shows them; it is not a datasheet number.
src/main.rs+2@@ -42,6 +42,8 @@ enum UiCmd { #[arg(long)] speed: Option<f64>, /// how see-through the ghosted parts are, 0.05 to 1 (the viewer's own slider otherwise; 0.33 by default) #[arg(long)] ghost: Option<f64> }, Toast { text: String, #[arg(long, default_value = "info")] kind: String },+ /// 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 }, Select { #[arg(long)] net: Option<String>, #[arg(long)] chip: Option<String> }, Issue { #[arg(long)] index: usize }, State,
src/serve.rs+31@@ -115,6 +115,36 @@ fn handle(mut req: tiny_http::Request, app: Arc<Mutex<App>>) { broadcast(&mut a, "view", &view); reply(req, 200, json!({"status": "ok", "view": view})); }+ ("POST", "/narrate") => {+ // the page sends the explanation it composed; adom-tts voices it once per text (the file name is+ // the text's hash), so a second listen, or another viewer, costs nothing+ let v: Value = serde_json::from_str(&body).unwrap_or(json!({}));+ let text: String = v["text"].as_str().unwrap_or("").chars().take(3000).collect();+ if text.trim().is_empty() { reply(req, 400, json!({"status": "error", "error": "no text to speak"})); return; }+ let dir = app.lock().unwrap().dir.join("narration");+ let _ = std::fs::create_dir_all(&dir);+ use sha2::{Digest, Sha256};+ let name = format!("{}-{}.mp3", v["view"].as_str().unwrap_or("view").chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>(), &format!("{:x}", Sha256::digest(text.as_bytes()))[..12]);+ let file = dir.join(&name);+ let cached = file.exists();+ if !cached {+ let src = dir.join(format!("{name}.txt"));+ let _ = std::fs::write(&src, &text);+ let out = std::process::Command::new("adom-tts").args(["say", &format!("@{}", src.display()), "--out", &file.display().to_string()]).env("ADOM_TTS_NO_HINTS", "1").output();+ match out {+ Ok(o) if o.status.success() && file.exists() => {}+ Ok(o) => { reply(req, 502, json!({"status": "error", "error": format!("adom-tts could not voice it: {}", String::from_utf8_lossy(&o.stderr).lines().last().unwrap_or("")), "hint": "adom-tts health"})); return; }+ Err(_) => { reply(req, 503, json!({"status": "error", "error": "adom-tts is not installed", "hint": "adom-wiki pkg install adom/adom-tts"})); return; }+ }+ }+ reply(req, 200, json!({"status": "ok", "url": format!("narration/{name}"), "cache": if cached { "cached" } else { "voiced" }}));+ }+ ("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);+ 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", "audio/mpeg").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "public, max-age=86400").unwrap())); } Err(_) => reply(req, 404, json!({"status": "error", "error": "no such narration"})) }+ }+ ("POST", "/ui/explain") => { let v: Value = serde_json::from_str(&body).unwrap_or(json!({})); let mut a = app.lock().unwrap(); broadcast(&mut a, "explain", &json!({"quantity": v["quantity"], "stop": v["stop"].as_bool().unwrap_or(false)})); reply(req, 200, json!({"status": "ok", "clients": a.clients.len()})); } ("POST", "/ui/toast") => { let v: Value = serde_json::from_str(&body).unwrap_or(json!({})); let mut a = app.lock().unwrap(); broadcast(&mut a, "toast", &json!({"text": v["text"], "kind": v["kind"].as_str().unwrap_or("info"), "sticky": v["kind"].as_str() == Some("error")})); reply(req, 200, json!({"status": "ok"})); } ("GET", p) if p.starts_with("/vendor/3d-viewer/") => { // the canonical Adom 3D viewer (adom/adom-3d-viewer), embedded in the binary@@ -142,6 +172,7 @@ 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::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"]), &[]); } UiCmd::Issue { index } => { let r = up("/ui/issue", &json!({"index": index})); crate::ok(&format!("issue {index} in view: {}", r["view"]), &[]); }
src/ui/app.js+57−1@@ -143,6 +143,57 @@ function syncGhostCtl() { const c = $('#ghostCtl'); if (!c) return; c.hidden = partsMode() !== 'ghost'; const a = ghostAlpha(); $('#ghostAlpha').value = a; $('#ghostVal').textContent = Math.round(a * 100) + '%'; } document.addEventListener('input', (e) => { if (e.target.id !== 'ghostAlpha') return; const a = +e.target.value; try { localStorage.setItem('fields.ghostAlpha', String(a)); } catch (err) { void err; } state.view.ghost = null; applyParts(); }); document.addEventListener('change', (e) => { if (e.target.id === 'ghostAlpha') console.log('parts: ghost opacity set to ' + Math.round(e.target.value * 100) + '% by the viewer'); });+ // ---- narration: each view explained out loud ------------------------------------------------------+ // The text is the page's own template with this board's numbers (no AI writes it at view time); the+ // server voices it with adom-tts, cached by its text. "first" (the default) speaks each kind of view the+ // first time this viewer opens it; "always" every time; "ask" only on the Explain button.+ const narr = { audio: null, mode: 'first', heard: {}, busy: false, view: null };+ 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 (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.`;+ 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') 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 '';+ }+ // subtitles: one sentence at a time, stepped by how far the voice is through the clip, sitting just above+ // the legend and readout row so neither is covered (John, 2026-10-10: a paragraph overlay hid the board,+ // 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';+ }+ const sentences = (t) => (t.match(/[^.!?]+[.!?]+(\s|$)/g) || [t]).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]); } };+ caption(ss[0]);+ }+ function stopNarration() { if (narr.audio) { narr.audio.pause(); narr.audio = null; } caption(''); const b = $('#btnExplain'); if (b) { b.classList.remove('is-on'); b.setAttribute('aria-pressed', false); } }+ async function explain(q, auto) {+ q = q || state.view.quantity; const text = narrationText(q); if (!text) return;+ stopNarration(); narr.busy = true; const b = $('#btnExplain'); if (b) { b.classList.add('is-on'); b.setAttribute('aria-pressed', true); }+ caption(sentences(text)[0]);+ const r = await post('narrate', { view: q, text });+ narr.busy = false;+ narr.heard[q] = true; try { localStorage.setItem('fields.narrated', JSON.stringify(narr.heard)); } catch (e) { void e; }+ if (!r || r.status !== 'ok') { caption(''); toast(((r && r.error) || 'No voice right now') + ' (the Key button has the written version)', 'info'); if (b) b.classList.remove('is-on'); return; }+ const a = new Audio(r.url); narr.audio = a; subtitles(a, text);+ a.onended = () => { if (narr.audio === a) stopNarration(); };+ try { await a.play(); console.log(`narration: ${q} (${auto ? 'first time' : 'asked'}), ${r.cache}`); }+ catch (e) { stopNarration(); if (auto) toast('Click the speaker to hear what this view shows', 'info'); else toast('The browser blocked the audio: click the speaker again', 'info'); }+ }+ function maybeExplain(q) {+ if (narr.mode === 'ask' || !narrationText(q)) return;+ if (narr.mode === 'first' && narr.heard[q]) return;+ explain(q, true);+ }+ window.fieldsNarrate = { explain, stop: stopNarration, text: narrationText, mode: () => narr.mode };+ function syncNarrSeg() { for (const b of document.querySelectorAll('#narrMode button')) { const on = b.dataset.n === narr.mode; b.classList.toggle('is-on', on); b.setAttribute('aria-pressed', on); } } function partsMode() { const m = state.view.parts || 'auto'; if (m !== 'auto') return m; return state.view.quantity === 'signals' ? 'off' : 'ghost'; } function applyParts() { const mode = partsMode();@@ -320,6 +371,8 @@ const is = e.target.closest('[data-issue]'); if (is) return post('ui/issue', { index: +is.dataset.issue }); if (e.target.closest('#btnNets')) { $('#left').classList.toggle('is-open'); $('#right').classList.remove('is-open'); } if (e.target.closest('#btnIssues')) { $('#right').classList.toggle('is-open'); $('#left').classList.remove('is-open'); }+ if (e.target.closest('#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('#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'); }@@ -329,7 +382,9 @@ if (!first && v.seq !== undefined && v.seq === state.view.seq) return; const prevFocus = JSON.stringify(state.view.focus); state.view = Object.assign({}, state.view, v);- renderPanels(); applyTextures();+ renderPanels(); applyTextures(); syncNarrSeg();+ // a new kind of view: stop the last explanation, and explain this one if the narration rule says so+ if (state.view.quantity !== narr.view) { const had = narr.view !== null; narr.view = state.view.quantity; stopNarration(); const q = narr.view; setTimeout(() => { if (state.view.quantity === q) maybeExplain(q); }, had ? 900 : 2500); } const changed = (v.changed || []); const moveCamera = first || changed.includes('camera') || changed.includes('focus') || (changed.includes('explode') && state.view.camera !== 'zoom'); if (!moveCamera) return;@@ -343,6 +398,7 @@ const es = new EventSource('events'); es.addEventListener('view', e => applyView(JSON.parse(e.data))); es.addEventListener('toast', e => { const d = JSON.parse(e.data); toast(d.text, d.kind, d.sticky); });+ es.addEventListener('explain', e => { const d = JSON.parse(e.data); if (d.stop) stopNarration(); else explain(d.quantity || state.view.quantity, false); }); es.addEventListener('state', e => { const d = JSON.parse(e.data); if (d.view) applyView(d.view, false); $('#liveDot').classList.remove('off'); $('#liveText').textContent = 'live channel'; }); es.onerror = () => { $('#liveDot').classList.add('off'); $('#liveText').textContent = 'reconnecting'; }; }
src/ui/flow.js+19−1@@ -752,6 +752,24 @@ void main(void) { // ---- what the badge and the legend say ------------------------------------------------------------- // the key: what every thing on screen is, in plain words, so nobody has to guess const xf = exaggeration >= 100 ? Math.round(exaggeration).toLocaleString() : exaggeration.toFixed(0);+ // what each animated view says out loud: a template filled with this board's own numbers, so no AI+ // writes anything at view time and adom-tts caches the audio by its text+ const say = (x) => String(x).replace(/\bmm\b/g, 'millimetres');+ function narration(q) {+ const amps = nets.filter(n => n.loaded && !n.ground);+ if (q === 'flow') return `This is the Flow view. Every particle is current, moving the way the current flows, from plus to minus. The faster and brighter a particle, the more current is packed into each square millimetre of copper: up to ${Math.round((state.fields.scales && state.fields.scales.jMax) || jHi)} amps per square millimetre at the busiest spot on this board. The colour underneath is the same current density, so a bright yellow region is where the copper is working hardest. At a via, the share of particles that drop through it is the share of the current it carries. Each part's tag says the current it takes in and puts out. Use the layers button to pull the layers apart and watch the current move between them.`;+ if (q === 'river') {+ const rails = nets.filter(n => n.loaded && n.railV != null && !n.switching).sort((a, b) => b.railV - a.railV).map(n => `${n.name} at ${n.railV} volts`);+ const sw = nets.find(n => n.switching);+ return `This is the River view: the circuit as water. Height and colour are voltage, so every net sits at its own level${rails.length ? ': ' + rails.join(', ') : ''}. Ground is the sea. The water runs over each net, faster where the current is denser. Where current passes through a part down to ground, you see a waterfall, and where a source lifts it back up, a fountain.${sw ? ` ${sw.name} is a switching node: it rises and falls like a tide between ${sw.switching.lowV || 0} and ${sw.switching.highV} volts, slowed down so your eye can follow it.` : ''} Each net's sign gives its voltage and how much it drops across the board. The slopes are exaggerated so millivolts can be seen.`;+ }+ if (q === 'energy') return `This is the Energy view, and it shows something surprising. The pink particles are energy, and they are not in the copper. They travel through the board material between the copper layers, in the electric and magnetic fields around the conductors. The copper only guides them. Where the pink rises up into a part, that part is taking in its energy. The blue dots on the copper are the electrons themselves, at their real speed: only about ${(DRIFT * jHi).toFixed(1)} millimetres per second at the busiest spot, and moving backwards, against the direction of the current. So the electrons barely crawl, while the energy arrives at about half the speed of light.`;+ if (q === 'light') {+ const a = arrivals.slice().sort((x, y) => y.ps - x.ps)[0];+ return `This is the Light view. The colour is how fast a change travels around each piece of copper, as a percentage of the speed of light: about ${Math.round(100 / Math.sqrt(ER))} percent inside the board material, and a little faster on thin traces with air above them. The white front is a switch-on wave leaving each source. In real life it crosses this board in about ${Math.round(tMax)} picoseconds; here it is slowed down billions of times.${a ? ` The tags say when the wave reaches each part: ${a.net} reaches ${a.ref} ${Math.round(a.ps)} picoseconds after ${a.from || 'its source'}.` : ''} Each stretch of copper has its own speed, and the delay to a part is the sum of every stretch along the way.`;+ }+ void amps; return '';+ } function describe(q) { if (q === 'light') return ['<b>Light</b>: how fast a change crosses the board', '<b>Colour</b> = local speed, % of the speed of light: deep blue inside the FR4 (about ' + Math.round(100 / Math.sqrt(ER)) + ' %), cyan to green on thin traces with air above them',@@ -832,6 +850,6 @@ void main(void) { console.log(`flow: ${nets.filter(n => n.loaded).length} loaded nets, ${viaGaps.size} barrels carrying current, ${terrains.length} terrain layer(s) at stride ${stride}, ${P} particles (${tier}); river 1 V = ${kR.toFixed(3)} mm, slopes x${exaggeration.toFixed(0)}`); console.log(`light: er ${ER}, ${arrivals.length} part arrivals, the slowest ${tMax.toFixed(0)} ps; ` + arrivals.slice(0, 6).map(a => `${a.ref}/${a.net} ${a.ps.toFixed(0)} ps`).join(', ')); console.log(`energy: ${gaps.length} gap(s) between copper layers ${real.map(k => LAYERS[k]).join(', ')}; S up to ${sHi.toFixed(2)} W/mm² (99.5th pct); electrons drift ${DRIFT.toFixed(4)} mm/s per A/mm²`);- const api = { apply, readout, describe, knob, get particles() { return P; }, set particles(n) { P = Math.max(500, Math.min(COUNTS.high, n | 0)); }, stats: () => ({ energyParticles: EP, gaps: gaps.length, sHi, particles: P, tier, jLo, jHi, kR, kD, exaggeration, terrains: terrains.length, barrels: viaGaps.size, explode: knob.explode, river: knob.river }) };+ const api = { apply, readout, describe, narration, knob, get particles() { return P; }, set particles(n) { P = Math.max(500, Math.min(COUNTS.high, n | 0)); }, stats: () => ({ energyParticles: EP, gaps: gaps.length, sHi, particles: P, tier, jLo, jHi, kR, kD, exaggeration, terrains: terrains.length, barrels: viaGaps.size, explode: knob.explode, river: knob.river }) }; return api; }
src/ui/index.html+4@@ -61,6 +61,7 @@ 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} .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)}@@ -97,6 +98,7 @@ body{margin:0;background:var(--bg);color:var(--text-body);font-family:"Satoshi", <div class="actions"> <button class="ibtn" id="btnNets" data-tip="Nets, layers and hot parts" aria-pressed="false"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></svg></button> <button class="ibtn" id="btnIssues" data-tip="Issues and how it was solved" aria-pressed="false"><svg viewBox="0 0 24 24"><path d="M12 9v4M12 17h.01M10.3 3.9 2.5 17a2 2 0 0 0 1.7 3h15.6a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/></svg></button>+ <button class="ibtn" id="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="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>@@ -107,6 +109,7 @@ body{margin:0;background:var(--bg);color:var(--text-body);font-family:"Satoshi", <div class="sec"><h3>Field</h3><div class="seg" id="qty"><button data-q="current" data-tip="Current density on the copper, amps per square millimetre, every loaded net at its spec current">Current</button><button data-q="heat" data-tip="Heat flow in the copper, watts per millimetre of width, from the hot tabs and the Joule loss">Heat</button><button data-q="signals" data-tip="Copper overlap capacitance and differential-pair screening">Signals</button><button data-q="temperature" data-tip="Temperature rise over the board, the FR4 spreading">Temperature</button></div><div class="seg" id="qty2" style="margin-top:4px"><button data-q="flow" data-tip="The solved current as charge streaming through the copper: speed and heat follow the current density, the vias take their share">Flow</button><button data-q="river" data-tip="Every net lifted to its voltage, the current running downhill as water: the loads fall to ground, the sources lift it back">River</button><button data-q="energy" data-tip="Where the energy really travels: through the fields between the copper layers, while the electrons crawl at millimetres per second">Energy</button><button data-q="light" data-tip="How fast a change travels: the local speed of light in the copper's field, and when a switch-on reaches each part">Light</button></div></div> <div class="sec"><h3>Layer</h3><div class="seg" id="layer"><button data-l="F.Cu">F.Cu</button><button data-l="In1.Cu">In1</button><button data-l="In2.Cu">In2</button><button data-l="B.Cu">B.Cu</button></div></div> <div class="sec" id="airSec"><h3>Air</h3><div class="seg" id="air"><button data-a="still" data-tip="Still air, 10 W/m²K on both faces">Still</button><button data-a="moving" data-tip="Moving air, 25 W/m²K: a fan or a moving vehicle">Moving</button></div></div>+ <div class="sec"><h3>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> <div class="sec"><h3>Nets, by what they carry</h3><div id="nets"></div></div> <div class="sec"><h3>Hot parts, hottest first</h3><div id="chips"></div></div> </aside>@@ -115,6 +118,7 @@ body{margin:0;background:var(--bg);color:var(--text-body);font-family:"Satoshi", <div id="labels" class="labels"></div> <div class="views"><span class="chip" data-cam="top" data-tip="Straight down at the top copper">Top</span><span class="chip" data-cam="iso" data-tip="Three-quarter view">Iso</span><span class="chip" data-cam="bottom" data-tip="The bottom copper, from below">Bottom</span><span class="chip" data-cam="fit" data-tip="Fit the board">Fit</span><label class="chip ghostctl" id="ghostCtl" hidden data-tip="How see-through the ghosted parts are"><svg viewBox="0 0 24 24"><path d="M12 3l8 4.5v9L12 21l-8-4.5v-9z"/></svg><input type="range" id="ghostAlpha" min="0.05" max="0.8" step="0.01" aria-label="Part opacity"><span id="ghostVal"></span></label><span class="chip" id="btnKey" hidden data-tip="What the particles, the heights and the waterfalls mean">Key</span></div> <div class="flowbadge" id="flowBadge" hidden></div>+ <div class="caption" id="caption" hidden></div> <div class="readout" id="readout"><span class="k">hover the board</span></div> <div class="legend" id="legend"><div id="legendTitle">Current, A/mm²</div><div class="ramp"></div><div class="ends"><span id="legLo">0</span><span id="legHi">0</span></div></div> </main>