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.8.6: Energy/Flow/Magnetic frame themselves, --keep-camera, restore on leaving (issue 18)
9 files changed
+46−28
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-fields"-version = "0.8.5"+version = "0.8.6" 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."
SKILL.md+1@@ -177,6 +177,7 @@ After applying a step, run `analyze` again: the issue goes away or says how far - **Explicit pin currents** (PR 6, 10): `loads.<net>.terminals: [{reference, pad, amps, at?}]`, positive into the copper, negative out, each side summing to `amps`; `at: [x, y]` picks one physical land where a pin number repeats. Wrong-net, missing, duplicate and unbalanced pins are refused; a loaded net the spec names that cannot be solved fails the run instead of publishing a partial report. `terminalAssignments` says explicit or heuristic per net. - **Peak current density is the modeled maximum** (PR 8), before the display texture's 99.5th-percentile clip and unrounded (`peakJDefinition`). - **`dcMaxIterations`** (PR 5, default 20000, at most 100000) bounds each net's solve; the target is unchanged.+- **Field views frame themselves** (0.8.6): entering Energy (stack open 0.8, parts off, low front camera), Flow (0.6, parts ghosted, low side) or Magnetic (parts off, low side) sets the stack, parts and camera so the field shows; values the command sets win, `ui view --keep-camera` keeps everything as it is, and leaving for another view restores what was there. Camera presets: top, iso, front, low, bottom, fit, zoom. - **Terminals** (0.8.1): without explicit `loads.<net>.terminals`, the solve picks them. The named `sources` feed the net, and every other connector and part on it is a sink, so a board-to-board connector that a rail feeds counts as a load. Otherwise connectors feed and parts draw. A part listed in `loads.<net>.draws` carries its own amps, the rest share what is left (sinks by pad area, sources evenly), and each part's share splits evenly over its pins. A pin with no copper near it is left out and its share goes to the other pins on its side, so the injection always balances. Every net in fields.json lists `terminalCurrents` (pin, amps, + into the copper) and `terminalBasis`, so a reviewer can check them. - **Solid bodies off the board** (0.8.0): `solids: {pitchMm, bodies: [{name, boxesMm: [[x0,y0,z0,x1,y1,z1]], conductivityWMk, watts | heatOf: "<hot ref>", hStillWM2k, hMovingWM2k, boardContactM2kW?, provenance}], contacts: [{bodies: [a, b], resistanceM2kW}], boardLinks: [{body, at, layer, resistanceKPerW, pad}], assumptions}`. Boxes are in board mm (x and y as KiCad, z up from the top copper). The bodies are voxelized and solved in the same heat system as the board, so the heat runs from the die through the tab, the pad and the heatsink fins to the air, and down the leads into the copper, in one energy balance. Bodies may touch only through a declared contact (0 means ideal), and they may not overlap. `fields.json` `solids` gives each body's peak rise, heat to air and to board, and peak flux. `solids.json` has every cell's temperature and flux vector, and the viewer paints them in the Heat and Temperature views. Calibrate a sink's h to its vendor rating rather than trusting a textbook h. See docs/solids.md. - **Fan levels** (0.8.0): `fanLevels: [0, 0.25, 0.5, 0.75, 1]` solves each level fully. h on every exposed face (board, bodies, a packageCooling sink) follows (h_still³ + (h_forced·sⁿ)³)^⅓, where s is the fraction of full speed, s = 1 gives the moving-air h, and n is `fanExponent` (0.5). `fields.json` `airStates` gives each level's h, chip rises and bodies. The viewer's Fan control (Off, 25, 50, 75, 100 %) switches the board and the bodies together, on one colour scale, so the levels compare by eye. `ui view --air fan50` drives it.
docs/release-0.8.6.mdadded+9@@ -0,0 +1,9 @@+# adom-fields 0.8.6++- **Field views frame themselves (issue 18).** Entering the Energy, Flow or Magnetic view, from the app or with `ui view --quantity`, opens the stack, puts the parts out of the way and looks from low on the side, so the field between or just above the copper is what you see:+ - **Energy:** stack open 0.8, parts off, low front camera.+ - **Flow:** stack open 0.6, parts ghosted, low side camera.+ - **Magnetic:** parts off, low side camera, so the sheet above the board reads.+- **Your settings win.** Any value the command sets itself (explode, parts, camera) is kept. `ui view --keep-camera` keeps all three as they are.+- **Leaving restores.** Switching from one of these views to another view puts back the stack, parts and camera that were there before.+- **New camera preset:** `low` (`ui view --camera low`), low and to the side.
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-fields", "type": "app",- "version": "0.8.5",+ "version": "0.8.6", "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.",
page.json+8−23@@ -1,7 +1,7 @@ { "slug": "adom-fields", "type": "app",- "version": "0.8.5",+ "version": "0.8.6", "title": "Adom Fields", "brief": "Adom Fields: where the current and the heat go on your board.", "summary": "Current density on the copper, heat flow through the pours and the vias, and the temperature over the board, solved from the board file itself and shown on the board in 3D. The AI does the analysis and drives the view; the human orbits, zooms, hovers for the number under the cursor, and reads the nets by what they carry and the issues by what to fix. A step in Adom's AI Flow: the walkthrough goes into the video.",@@ -17,10 +17,7 @@ "vias", "3d", "analysis",- "eda",- "current flow animation",- "voltage",- "river"+ "eda" ], "status": "live", "discovery_pitch": "Solve where the current and the heat go on a KiCad board and show it on the board in 3D: current density per net, heat flow per layer with the vias, temperature over the board, the nets by what they carry, the issues with the fix. One command to solve, one to serve, one to drive it on camera.",@@ -40,10 +37,11 @@ "fields", "flow of current", "river view",- "voltage drop in 3d",- "ground bounce",+ "energy flow on a board",+ "poynting vector",+ "speed of light on a pcb",+ "electron drift speed", "exploded layer stack",- "current through vias", "explain the view out loud", "narrate the fields", "energy cycle",@@ -85,20 +83,7 @@ "anti-resonance", "input filter stability", "middlebrook",- "ngspice pdn",- "signals view",- "copper capacitance",- "switch node capacitance",- "plane capacitance",- "differential pair skew",- "six layer board",- "receiver frequency plan",- "lo leakage",- "tone in band",- "rf crosstalk",- "return path over supply plane",- "antenna ground loop",- "stage feed bypass"+ "ngspice pdn" ], "sample_prompts": [ {@@ -139,4 +124,4 @@ "name": "John Lauer", "email": "[email protected]" }-}+}
skills/adom-fields/SKILL.md+1@@ -177,6 +177,7 @@ After applying a step, run `analyze` again: the issue goes away or says how far - **Explicit pin currents** (PR 6, 10): `loads.<net>.terminals: [{reference, pad, amps, at?}]`, positive into the copper, negative out, each side summing to `amps`; `at: [x, y]` picks one physical land where a pin number repeats. Wrong-net, missing, duplicate and unbalanced pins are refused; a loaded net the spec names that cannot be solved fails the run instead of publishing a partial report. `terminalAssignments` says explicit or heuristic per net. - **Peak current density is the modeled maximum** (PR 8), before the display texture's 99.5th-percentile clip and unrounded (`peakJDefinition`). - **`dcMaxIterations`** (PR 5, default 20000, at most 100000) bounds each net's solve; the target is unchanged.+- **Field views frame themselves** (0.8.6): entering Energy (stack open 0.8, parts off, low front camera), Flow (0.6, parts ghosted, low side) or Magnetic (parts off, low side) sets the stack, parts and camera so the field shows; values the command sets win, `ui view --keep-camera` keeps everything as it is, and leaving for another view restores what was there. Camera presets: top, iso, front, low, bottom, fit, zoom. - **Terminals** (0.8.1): without explicit `loads.<net>.terminals`, the solve picks them. The named `sources` feed the net, and every other connector and part on it is a sink, so a board-to-board connector that a rail feeds counts as a load. Otherwise connectors feed and parts draw. A part listed in `loads.<net>.draws` carries its own amps, the rest share what is left (sinks by pad area, sources evenly), and each part's share splits evenly over its pins. A pin with no copper near it is left out and its share goes to the other pins on its side, so the injection always balances. Every net in fields.json lists `terminalCurrents` (pin, amps, + into the copper) and `terminalBasis`, so a reviewer can check them. - **Solid bodies off the board** (0.8.0): `solids: {pitchMm, bodies: [{name, boxesMm: [[x0,y0,z0,x1,y1,z1]], conductivityWMk, watts | heatOf: "<hot ref>", hStillWM2k, hMovingWM2k, boardContactM2kW?, provenance}], contacts: [{bodies: [a, b], resistanceM2kW}], boardLinks: [{body, at, layer, resistanceKPerW, pad}], assumptions}`. Boxes are in board mm (x and y as KiCad, z up from the top copper). The bodies are voxelized and solved in the same heat system as the board, so the heat runs from the die through the tab, the pad and the heatsink fins to the air, and down the leads into the copper, in one energy balance. Bodies may touch only through a declared contact (0 means ideal), and they may not overlap. `fields.json` `solids` gives each body's peak rise, heat to air and to board, and peak flux. `solids.json` has every cell's temperature and flux vector, and the viewer paints them in the Heat and Temperature views. Calibrate a sink's h to its vendor rating rather than trusting a textbook h. See docs/solids.md. - **Fan levels** (0.8.0): `fanLevels: [0, 0.25, 0.5, 0.75, 1]` solves each level fully. h on every exposed face (board, bodies, a packageCooling sink) follows (h_still³ + (h_forced·sⁿ)³)^⅓, where s is the fraction of full speed, s = 1 gives the moving-air h, and n is `fanExponent` (0.5). `fields.json` `airStates` gives each level's h, chip rises and bodies. The viewer's Fan control (Off, 25, 50, 75, 100 %) switches the board and the bodies together, on one colour scale, so the levels compare by eye. `ui view --air fan50` drives it.
src/main.rs+3−2@@ -28,7 +28,7 @@ enum Cmd { Parts { #[arg(long)] board: String, #[arg(long, default_value = "fields")] fields: PathBuf, #[arg(long = "models-dir")] models_dir: Vec<PathBuf> }, /// Serve the app for a solved board (or for a board and spec, solving first): the 3D board with the fields on it, the nets by relevance, the issues, and the live channel the AI drives Serve { #[arg(long, default_value = "fields")] fields: PathBuf, #[arg(long, default_value_t = 8874)] port: u16, #[arg(long)] board: Option<String>, #[arg(long)] spec: Option<String> },- /// Drive the running app: `ui view --quantity current|heat|temperature|signals|flow|river [--explode 0..1] [--speed 1] [--net X] [--layer F.Cu] [--camera top|iso|front|bottom|fit|zoom --at x,y] [--parts auto|ghost|solid|off]`, `ui toast "..."`, `ui select --net X`, `ui issue --index 0`+ /// Drive the running app: `ui view --quantity current|heat|temperature|signals|flow|river [--explode 0..1] [--speed 1] [--net X] [--layer F.Cu] [--camera top|iso|front|low|bottom|fit|zoom --at x,y] [--parts auto|ghost|solid|off]`, `ui toast "..."`, `ui select --net X`, `ui issue --index 0` Ui { #[command(subcommand)] what: UiCmd, #[arg(long, default_value_t = 8874)] port: u16 }, /// A scripted walkthrough over the live channel: the nets by relevance, each field, the issues; for the AI's video step Tour { #[arg(long, default_value_t = 8874)] port: u16, /// milliseconds to hold each beat while the board drifts (camera moves take up to 18 s since 0.5); gentle on purpose, the flow speeds the clip up ten times@@ -51,7 +51,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>, /// the silkscreen over the fields: on or off #[arg(long)] silk: Option<String>, /// the magnetic view's probe sheet height above the top copper, mm (0.5 to 15)- #[arg(long)] probe: Option<f64> },+ #[arg(long)] probe: Option<f64>, /// on entering the Energy, Flow or Magnetic view, keep the camera, stack and parts as they are (the view otherwise opens the stack, sets the parts aside and looks from low on the side)+ #[arg(long)] keep_camera: bool }, 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 },
src/serve.rs+20−1@@ -116,6 +116,25 @@ fn handle(mut req: tiny_http::Request, app: Arc<Mutex<App>>) { } else { for k in ["quantity", "net", "layer", "camera", "air", "chip", "drift", "parts", "focus", "explode", "speed", "ghost", "silk", "probe"] { if !v[k].is_null() { a.view[k] = v[k].clone(); } } if v["net"].as_str() == Some("") { a.view["net"] = Value::Null; }+ // the views whose field lives between or just above the copper (issue 18): on entering one, open the stack,+ // put the parts out of the way and look from low on the side, unless the command set those itself or+ // asked to keep its camera (keepCamera)+ let entered = v["quantity"].as_str().filter(|q| before["quantity"].as_str() != Some(*q));+ if let (Some(q), false) = (entered, v["keepCamera"].as_bool() == Some(true)) {+ let pick: Option<(f64, &str, &str)> = match q { "energy" => Some((0.8, "off", "front")), "flow" => Some((0.6, "ghost", "low")), "magnetic" => Some((0.0, "off", "low")), _ => None };+ if let Some((ex, parts, cam)) = pick {+ // remember the view as it was before the first of these views, to put it back on leaving them+ if a.view["restore"].is_null() { a.view["restore"] = json!({"explode": before["explode"], "parts": before["parts"], "camera": before["camera"], "focus": before["focus"]}); }+ if v["explode"].is_null() { a.view["explode"] = json!(ex); }+ if v["parts"].is_null() { a.view["parts"] = json!(parts); }+ if v["camera"].is_null() { a.view["camera"] = json!(cam); a.view["focus"] = Value::Null; }+ }+ }+ if let Some(q) = entered { if !["energy", "flow", "magnetic"].contains(&q) && !a.view["restore"].is_null() {+ let r = a.view["restore"].clone();+ for k in ["explode", "parts", "camera", "focus"] { if v[k].is_null() { a.view[k] = r[k].clone(); } }+ a.view["restore"] = 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"); } } } } // every command gets a sequence number and the keys it changed, so the page animates the@@ -192,7 +211,7 @@ fn handle(mut req: tiny_http::Request, app: Arc<Mutex<App>>) { pub fn ui(what: UiCmd, port: u16) { let up = |p: &str, v: &Value| -> Value { post(port, p, v).unwrap_or_else(|e| crate::err(&format!("the app did not answer on {port}: {e}"), &[format!("Start it: `adom-fields serve --fields <dir> --port {port} &`")])) }; match what {- UiCmd::View { quantity, net, layer, camera, air, parts, at, explode, speed, ghost, silk, probe } => { let focus = at.as_deref().map(|t| { let v: Vec<f64> = t.split(',').filter_map(|x| x.trim().parse().ok()).collect(); if v.len() != 2 { crate::err("--at takes x,y in board mm, e.g. --at 113.9,90.5", &[]); } json!(v) }); if let Some(p) = &parts { if !["auto", "ghost", "solid", "off"].contains(&p.as_str()) { crate::err(&format!("--parts {p}: use auto, ghost, solid or off"), &[]); } } if let Some(q) = &quantity { if !["current", "heat", "temperature", "signals", "flow", "river", "energy", "light", "cycle", "charge", "magnetic", "emc", "ground", "noise", "pdn"].contains(&q.as_str()) { crate::err(&format!("--quantity {q}: use current, heat, temperature, signals, flow, river, energy, light, cycle, charge, magnetic, emc, ground, noise or pdn"), &[]); } } 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"), "probe": probe})); 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, probe, keep_camera } => { let focus = at.as_deref().map(|t| { let v: Vec<f64> = t.split(',').filter_map(|x| x.trim().parse().ok()).collect(); if v.len() != 2 { crate::err("--at takes x,y in board mm, e.g. --at 113.9,90.5", &[]); } json!(v) }); if let Some(p) = &parts { if !["auto", "ghost", "solid", "off"].contains(&p.as_str()) { crate::err(&format!("--parts {p}: use auto, ghost, solid or off"), &[]); } } if let Some(q) = &quantity { if !["current", "heat", "temperature", "signals", "flow", "river", "energy", "light", "cycle", "charge", "magnetic", "emc", "ground", "noise", "pdn"].contains(&q.as_str()) { crate::err(&format!("--quantity {q}: use current, heat, temperature, signals, flow, river, energy, light, cycle, charge, magnetic, emc, ground, noise or pdn"), &[]); } } 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"), "probe": probe, "keepCamera": if keep_camera { Some(true) } else { None }})); crate::ok(&format!("view: {}", r["view"]), &["The app repainted; a screenshot of the window shows it. `ui select --net <net>` lights one net; `ui issue --index n` flies to an issue.".into()]); } UiCmd::Explain { quantity, stop } => { let r = up("/ui/explain", &json!({"quantity": quantity, "stop": stop})); crate::ok(&format!("{} to {} page(s)", if stop { "stop sent" } else { "explain sent" }, r["clients"]), &["The page composes the explanation from this board's numbers and plays it with a one-line subtitle; a browser may hold the first audio until the viewer has clicked the page once.".into()]); } UiCmd::Toast { text, kind } => { up("/ui/toast", &json!({"text": text, "kind": kind})); crate::ok("toast shown", &[]); } UiCmd::Select { net, chip } => { let r = up("/ui/select", &json!({"net": net, "chip": chip})); crate::ok(&format!("selected: {}", r["view"]), &[]); }
src/ui/app.js+2@@ -375,6 +375,8 @@ if (which === 'top') goto(NORTH, 0.02, R, home, s); else if (which === 'bottom') goto(NORTH, Math.PI - 0.02, R, home, s); // low and from the front: the layers edge-on, so what runs between them (the Energy view's fields) is in sight+ // low and to the side: what sits between the layers or just above the copper reads, the board still in view+ else if (which === 'low') goto(NORTH - 0.6, 1.18, (sphere ? Rs : R * 1.08) * 0.95, sphere ? sphere.c : home, s); else if (which === 'front') goto(NORTH, 1.32, sphere ? Rs : R * 1.08, sphere ? sphere.c : home, s); else if (which === 'iso') { if (sphere && sphere.r > Math.hypot(board.widthMm, board.heightMm, board.thicknessMm) / 2 * 1.02) goto(NORTH - 0.55, 0.95, Rs, sphere.c, s); else goto(NORTH - 0.55, 0.95, R * 1.08, home, s); } else if (sphere) goto(camera.alpha, camera.beta, Rs, sphere.c, s);