← Commit history
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-fields"-version = "0.4.0"+version = "0.5.0" dependencies = [  "clap",  "png",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-fields"-version = "0.4.0"+version = "0.5.0" edition = "2021" license = "MIT" description = "Adom Fields: where the current and the heat go on your board. Current density, copper heat flow and board temperature from the board file itself, in 3D, for the AI to drive and the human to explore."
README.md+6
@@ -65,3 +65,9 @@ Recorded through AI Flow on AdomLapper, 129.8 seconds raw and 13.1 seconds at 10 ## Conducting heatsinks and signals in 0.4.0  Specify finite-conductivity rectangular blocks with contact resistance, inspect heat entering them, and compare still/moving air. The Signals view reports copper-overlap capacitance, differential-pair length skew, sampled reference coverage and conservative microstrip impedance estimates. Unsupported geometry is explicit. [Inputs, limitations, sources and reproducible fixture](docs/heatsinks-and-signals.md).++## The chips over the heat in 0.5.0++A hot zone alone does not say what is heating it. Fields now stands each part's own 3D model on the board, where KiCad places it, see-through by default so the field glows through the body: the regulator sits in its hot spot, the inductor in its own. The models come from the board's footprints, converted once on the Adom STEP service and cached; a footprint with no model gets a box from its courtyard. The header's cube button (or `ui view --parts ghost|solid|off`) switches them. Every camera move and the idle sway now run at half speed, so a clip the flow plays at ten times speed stays calm.++![The first buck board's heat flow with the parts see-through: U1 and L1 standing in their hot zones](docs/screenshots/parts-heat-iso.png)
SKILL.md+19−2
@@ -32,16 +32,33 @@ adom-fields serve --fields fields --port 8874 &  Open `https://<slug>.adom.cloud/proxy/8874/` in pup, the browser, or a Hydrogen webview (never localhost from outside the container). The app: a 44 px header, the board in 3D on the canonical Adom 3D viewer (adom/adom-3d-viewer, embedded; ViewCube, studio lighting, zoom to the cursor), the field painted on the picked layer with the other layers faded, the vias as dots, the hot parts labelled with their rise, the legend, the readout under the cursor, the nets and hot parts on the left, the issues and the method on the right. Phone: the two side panels open from the header buttons. +## The parts over the fields (0.5.0)++`analyze` now also places every footprint's own 3D model on the board, so the chip stands in its hot zone and the cause of a hot spot is plain. The models come from the board file: each footprint's `(model ...)` entries, resolved as KiCad does (`${KIPRJMOD}`, `${KICAD*_3DMODEL_DIR}`, then `--models-dir <folder>` by file name, then a stock KiCad library model fetched by name), converted once to GLB on the Adom STEP service (`step2glb`) and cached by content in `~/.cache/adom-fields/models/`. They land in `<out>/parts/` and the `parts` block of fields.json. A footprint with no model gets a 1 mm box from its courtyard; test points and board-only footprints stay bare copper. A failed conversion never fails the solve: it is logged and the part becomes a box.++```+adom-fields analyze --board run/board-2.kicad_pcb --spec spec.json --out fields --models-dir kicad/3d   # a snapshot outside the project folder+adom-fields parts --board board.kicad_pcb --fields fields      # add or refresh the parts of an existing solve+adom-fields ui view --parts ghost|solid|off|auto              # auto: see-through on every field but signals+adom-fields analyze ... --no-parts                            # skip them (no STEP service needed)+```++`ghost` draws the bodies see-through so the field glows through them; `solid` is the plain assembly; the header's cube button cycles them. The hot-part labels ride on top of their part.++## Calmer motion (0.5.0)++Every camera move and the idle sway run at half the 0.4 speed (the sway period is about 40 s), because the flow plays the clip ten times faster. `tour --pause-ms` defaults to 20000 so a beat settles before the next. For frame-exact video, the page exposes `window.fieldsCapture.begin(1/30)` and `.step()` (one frame of page time per call; the live loop stops and the orbit pivot marker hides); `ui view --camera zoom --at x,y` flies to any board point.+ ## Drive it (the AI's wheel)  ```-adom-fields ui view --quantity current|heat|temperature [--layer F.Cu|In1.Cu|In2.Cu|B.Cu] [--camera top|iso|bottom|fit] [--air still|moving]+adom-fields ui view --quantity current|heat|temperature [--layer F.Cu|In1.Cu|In2.Cu|B.Cu] [--camera top|iso|bottom|fit|zoom --at x,y] [--air still|moving] [--parts auto|ghost|solid|off] adom-fields ui 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 adom-fields ui toast "20 A through a 1.5 mm neck" adom-fields ui state | console-adom-fields tour --pause-ms 2500         # the scripted walkthrough: nets by relevance, each field, the hot parts, the issues+adom-fields tour --pause-ms 20000        # the scripted walkthrough: nets by relevance, each field, the hot parts, the issues ```  The server: `GET /state`, `GET /console` (the UI's log), `GET /events` (SSE), `GET /fields.json`, `POST /ui/view|select|issue|toast`. State lives on the server; the UI, the CLI and the AI see the same view.
bin/adom-fields
⋯ 1 unchanged line ⋯
docs/screenshots/parts-heat-iso.pngadded
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-fields",   "type": "app",-  "version": "0.4.0",+  "version": "0.5.0",   "title": "Adom Fields",   "brief": "Adom Fields: where the current and the heat go on your board.",   "summary": "Current density on the copper, heat flow through the pours and the vias, and the temperature over the board, solved from the board file itself and shown on the board in 3D. The AI does the analysis and drives the view; the human orbits, zooms, hovers for the number under the cursor, and reads the nets by what they carry and the issues by what to fix. A step in Adom's AI Flow: the walkthrough goes into the video.",
page.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-fields",   "type": "app",-  "version": "0.4.0",+  "version": "0.5.0",   "title": "Adom Fields",   "brief": "Adom Fields: where the current and the heat go on your board.",   "summary": "Current density on the copper, heat flow through the pours and the vias, and the temperature over the board, solved from the board file itself and shown on the board in 3D. The AI does the analysis and drives the view; the human orbits, zooms, hovers for the number under the cursor, and reads the nets by what they carry and the issues by what to fix. A step in Adom's AI Flow: the walkthrough goes into the video.",
skills/adom-fields/SKILL.md+19−2
@@ -32,16 +32,33 @@ adom-fields serve --fields fields --port 8874 &  Open `https://<slug>.adom.cloud/proxy/8874/` in pup, the browser, or a Hydrogen webview (never localhost from outside the container). The app: a 44 px header, the board in 3D on the canonical Adom 3D viewer (adom/adom-3d-viewer, embedded; ViewCube, studio lighting, zoom to the cursor), the field painted on the picked layer with the other layers faded, the vias as dots, the hot parts labelled with their rise, the legend, the readout under the cursor, the nets and hot parts on the left, the issues and the method on the right. Phone: the two side panels open from the header buttons. +## The parts over the fields (0.5.0)++`analyze` now also places every footprint's own 3D model on the board, so the chip stands in its hot zone and the cause of a hot spot is plain. The models come from the board file: each footprint's `(model ...)` entries, resolved as KiCad does (`${KIPRJMOD}`, `${KICAD*_3DMODEL_DIR}`, then `--models-dir <folder>` by file name, then a stock KiCad library model fetched by name), converted once to GLB on the Adom STEP service (`step2glb`) and cached by content in `~/.cache/adom-fields/models/`. They land in `<out>/parts/` and the `parts` block of fields.json. A footprint with no model gets a 1 mm box from its courtyard; test points and board-only footprints stay bare copper. A failed conversion never fails the solve: it is logged and the part becomes a box.++```+adom-fields analyze --board run/board-2.kicad_pcb --spec spec.json --out fields --models-dir kicad/3d   # a snapshot outside the project folder+adom-fields parts --board board.kicad_pcb --fields fields      # add or refresh the parts of an existing solve+adom-fields ui view --parts ghost|solid|off|auto              # auto: see-through on every field but signals+adom-fields analyze ... --no-parts                            # skip them (no STEP service needed)+```++`ghost` draws the bodies see-through so the field glows through them; `solid` is the plain assembly; the header's cube button cycles them. The hot-part labels ride on top of their part.++## Calmer motion (0.5.0)++Every camera move and the idle sway run at half the 0.4 speed (the sway period is about 40 s), because the flow plays the clip ten times faster. `tour --pause-ms` defaults to 20000 so a beat settles before the next. For frame-exact video, the page exposes `window.fieldsCapture.begin(1/30)` and `.step()` (one frame of page time per call; the live loop stops and the orbit pivot marker hides); `ui view --camera zoom --at x,y` flies to any board point.+ ## Drive it (the AI's wheel)  ```-adom-fields ui view --quantity current|heat|temperature [--layer F.Cu|In1.Cu|In2.Cu|B.Cu] [--camera top|iso|bottom|fit] [--air still|moving]+adom-fields ui view --quantity current|heat|temperature [--layer F.Cu|In1.Cu|In2.Cu|B.Cu] [--camera top|iso|bottom|fit|zoom --at x,y] [--air still|moving] [--parts auto|ghost|solid|off] adom-fields ui 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 adom-fields ui toast "20 A through a 1.5 mm neck" adom-fields ui state | console-adom-fields tour --pause-ms 2500         # the scripted walkthrough: nets by relevance, each field, the hot parts, the issues+adom-fields tour --pause-ms 20000        # the scripted walkthrough: nets by relevance, each field, the hot parts, the issues ```  The server: `GET /state`, `GET /console` (the UI's log), `GET /events` (SSE), `GET /fields.json`, `POST /ui/view|select|issue|toast`. State lives on the server; the UI, the CLI and the AI see the same view.
src/main.rs+29−10
@@ -1,6 +1,6 @@ //! adom-fields: where the current and the heat go on your board. use sha2::{Digest, Sha256};-mod signals; mod heatsink; mod board; mod fields; mod raster; mod sexp; mod solve; mod tex; mod serve; mod vendor;+mod parts; mod signals; mod heatsink; mod board; mod fields; mod raster; mod sexp; mod solve; mod tex; mod serve; mod vendor; use clap::{Parser, Subcommand}; use std::path::PathBuf; @@ -13,14 +13,20 @@ enum Cmd {     /// Solve the fields of a board: current density per loaded net, heat flow in the copper, temperature over the board; writes fields.json and the textures to --out     Analyze { #[arg(long)] board: String, #[arg(long)] spec: String, #[arg(long, default_value = "fields")] out: PathBuf, /// solve cell in mm (0.2 is a minute on a 65 x 75 mm board; 0.1 is four times longer)     #[arg(long, default_value_t = 0.2)] pitch: f64, /// the current density a loaded net may not exceed before it is an issue, A/mm² (60 is about IPC-2221 external at 35 µm for 10 °C)-    #[arg(long, default_value_t = 60.0)] neck: f64 },+    #[arg(long, default_value_t = 60.0)] neck: f64,+    /// 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> },+    /// (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     Serve { #[arg(long, default_value = "fields")] fields: PathBuf, #[arg(long, default_value_t = 8874)] port: u16, #[arg(long)] board: Option<String>, #[arg(long)] spec: Option<String> },-    /// Drive the running app: `ui view --quantity current|heat|temperature|signals [--net X] [--layer F.Cu] [--camera top|iso|bottom|fit]`, `ui toast "..."`, `ui select --net X`, `ui issue --index 0`+    /// Drive the running app: `ui view --quantity current|heat|temperature|signals [--net X] [--layer F.Cu] [--camera top|iso|bottom|fit|zoom --at x,y] [--parts auto|ghost|solid|off]`, `ui toast "..."`, `ui select --net X`, `ui issue --index 0`     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; gentle on purpose, the flow speeds the clip up ten times-    #[arg(long, default_value_t = 14000)] pause_ms: u64 },+    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+    #[arg(long, default_value_t = 20000)] pause_ms: u64 },     /// Is the server up     Health { #[arg(long, default_value_t = 8874)] port: u16 },     /// Install the skill from the wiki page@@ -29,7 +35,9 @@ enum Cmd {  #[derive(Subcommand)] enum UiCmd {-    View { #[arg(long)] quantity: Option<String>, #[arg(long)] net: Option<String>, #[arg(long)] layer: Option<String>, #[arg(long)] camera: Option<String>, #[arg(long)] air: Option<String> },+    View { #[arg(long)] quantity: Option<String>, #[arg(long)] net: Option<String>, #[arg(long)] layer: Option<String>, #[arg(long)] camera: Option<String>, #[arg(long)] air: Option<String>, /// the parts' 3D bodies over the fields: ghost (see-through), solid, off, or auto (ghost on every field but signals)+    #[arg(long)] parts: Option<String>, /// with --camera zoom: fly to this board point, "x,y" in mm (KiCad coordinates)+    #[arg(long)] at: Option<String> },     Toast { text: String, #[arg(long, default_value = "info")] kind: String },     Select { #[arg(long)] net: Option<String>, #[arg(long)] chip: Option<String> },     Issue { #[arg(long)] index: usize },@@ -42,7 +50,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) -> Result<serde_json::Value, String> {+pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck: f64, parts: Option<&[PathBuf]>) -> 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)?;@@ -112,6 +120,7 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck         return Err("board or spec changed during the solve; run analyze again on stable inputs".into());     }     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}")); }     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)@@ -120,14 +129,15 @@ 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 } => {-            match run_analyze(&board, &spec, &out, pitch, neck) {+        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) }) {                 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();                     ok(&format!("{} nets solved, {} hot parts, {} issue(s), in {} s: {}", nets, s["chips"].as_array().map(|a| a.len()).unwrap_or(0), issues.len(), s["seconds"], out.join("fields.json").display()), &[                         format!("Peak current density {:.0} A/mm²; peak rise {:.0} °C still air, {:.0} °C moving air, budget {:.0} °C; Joule loss {} W on top of {} W from the tabs.", s["scales"]["jMax"].as_f64().unwrap_or(0.0), s["heat"]["peakRiseC"].as_f64().unwrap_or(0.0), s["heat"]["peakRiseMovingC"].as_f64().unwrap_or(0.0), s["heat"]["budgetC"].as_f64().unwrap_or(0.0), s["heat"]["jouleW"], s["heat"]["tabW"]),                         issues.iter().take(3).map(|i| format!("{} {}: {}", i["severity"].as_str().unwrap_or(""), i["kind"].as_str().unwrap_or(""), i["what"].as_str().unwrap_or(""))).collect::<Vec<_>>().join(" | "),+                        if s["parts"].is_null() { "Parts: skipped (--no-parts).".to_string() } else { format!("Parts: {} with 3D models, {} as boxes, {} bare{}.", s["parts"]["modelled"], s["parts"]["boxed"], s["parts"]["bare"], if s["parts"]["failed"].as_array().map_or(false, |a| !a.is_empty()) { "; some models did not convert (see the log above; --models-dir adds a folder to search)" } else { "" }) },                         format!("Heatsinks: {}; signal capacitance checks: {}; differential pairs: {}. Signals are screening estimates; unsupported geometry is reported, never certified.", s["heatsinks"].as_array().map_or(0,|x|x.len()),s["signals"]["capacitance"].as_array().map_or(0,|x|x.len()),s["signals"]["differentialPairs"].as_array().map_or(0,|x|x.len())),                         format!("Next: `adom-fields serve --fields {}` and open it (pup or the browser); `adom-fields ui view --quantity current --net <net>` drives it; `adom-fields tour` walks it for a video.", out.display()),                     ]);@@ -136,9 +146,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) { err(&e, &[]); } }+            if let (Some(bd), Some(sp)) = (&board, &spec) { if let Err(e) = run_analyze(bd, sp, &fields, 0.2, 60.0, Some(&[])) { err(&e, &[]); } }             serve::serve(&fields, port);         }+        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()]));+            let p = parts::build(&board, &fields, &models_dir, &mut |s| eprintln!("{s}"));+            let msg = format!("{} parts with 3D models, {} as boxes, {} bare: {}", p["modelled"], p["boxed"], p["bare"], path.display());+            f["parts"] = p;+            std::fs::write(&path, serde_json::to_string_pretty(&f).unwrap()).unwrap_or_else(|e| err(&e.to_string(), &[]));+            ok(&msg, &["A running `serve` picks them up on the next page load; `adom-fields ui view --parts ghost|solid|off` switches them.".into()]);+        }         Cmd::Ui { what, port } => serve::ui(what, port),         Cmd::Tour { port, pause_ms } => serve::tour(port, pause_ms),         Cmd::Health { port } => match serve::get(port, "/state") { Ok(v) => ok(&format!("adom-fields serving on {port}: {}", v["board"]["name"].as_str().unwrap_or("no board")), &[]), Err(e) => err(&format!("not serving on {port}: {e}"), &[format!("Start it: `adom-fields serve --fields <dir> --port {port} &`")]) },
src/parts.rsadded+169
@@ -0,0 +1,169 @@+//! The parts on the board: every footprint's placement and its 3D model, so the viewer can sit the+//! real chip bodies on top of the fields (a hot zone reads at a glance once U1 is standing in it).+//! Models come from the board itself: each footprint's `(model ...)` entries, resolved the way KiCad+//! does (${KIPRJMOD}, ${KICAD*_3DMODEL_DIR}, extra --models-dir folders), converted once to GLB on the+//! shared Adom STEP service (`step2glb`) and cached by content. A footprint with no model gets a+//! low box from its courtyard, marked as such; parts excluded from the BOM (test points, fiducials)+//! are left as bare copper.+use crate::sexp::{parse, Sx};+use serde_json::{json, Value};+use sha2::{Digest, Sha256};+use std::path::{Path, PathBuf};++#[derive(Debug, Clone)]+struct Model { path: String, offset: [f64; 3], scale: [f64; 3], rotate: [f64; 3] }++#[derive(Debug, Clone)]+struct Part { reference: String, value: String, x: f64, y: f64, rot: f64, bottom: bool, models: Vec<Model>, bbox: Option<[f64; 4]>, skip: bool }++fn xyz(s: Option<&Sx>, d: [f64; 3]) -> [f64; 3] { s.and_then(|k| k.kid("xyz")).map(|x| [x.num(1).unwrap_or(d[0]), x.num(2).unwrap_or(d[1]), x.num(3).unwrap_or(d[2])]).unwrap_or(d) }++fn read_parts(board: &str) -> Result<Vec<Part>, String> {+    let text = std::fs::read_to_string(board).map_err(|e| format!("{board}: {e}"))?;+    let root = parse(&text);+    let mut out = Vec::new();+    for fp in root.kids("footprint").into_iter().chain(root.kids("module")) {+        let prop = |name: &str| fp.kids("property").iter().find(|p| p.atom(1) == Some(name)).and_then(|p| p.atom(2)).map(str::to_string)+            .or_else(|| fp.kids("fp_text").iter().find(|t| t.atom(1) == Some(&name.to_lowercase())).and_then(|t| t.atom(2)).map(str::to_string));+        let reference = prop("Reference").unwrap_or_else(|| "?".into());+        let at = fp.kid("at");+        let attrs: Vec<String> = fp.kid("attr").map(|a| a.list().iter().skip(1).filter_map(|x| if let Sx::Atom(s) = x { Some(s.clone()) } else { None }).collect()).unwrap_or_default();+        let models = fp.kids("model").iter().filter(|m| !m.list().iter().any(|k| matches!(k, Sx::List(_)) && k.head() == "hide") && m.atom(2) != Some("hide")).filter_map(|m| {+            let path = m.atom(1)?.to_string();+            // KiCad 6+: (offset (xyz mm)); KiCad 5: (at (xyz inches))+            let offset = if m.kid("offset").is_some() { xyz(m.kid("offset"), [0.0; 3]) } else { let a = xyz(m.kid("at"), [0.0; 3]); [a[0] * 25.4, a[1] * 25.4, a[2] * 25.4] };+            Some(Model { path, offset, scale: xyz(m.kid("scale"), [1.0; 3]), rotate: xyz(m.kid("rotate"), [0.0; 3]) })+        }).collect();+        // the courtyard (else the fab outline) in footprint coordinates, for the box when there is no model+        let mut xs = Vec::new(); let mut ys = Vec::new();+        for want in ["CrtYd", "Fab"] {+            for key in ["fp_line", "fp_rect", "fp_poly", "fp_circle", "fp_arc"] {+                for g in fp.kids(key) {+                    if !g.kid("layer").and_then(|l| l.atom(1)).map(|l| l.ends_with(want)).unwrap_or(false) { continue; }+                    for k in ["start", "end", "mid"] { if let Some(p) = g.kid(k) { if let (Some(x), Some(y)) = (p.num(1), p.num(2)) { xs.push(x); ys.push(y); } } }+                    if let Some(pts) = g.kid("pts") { for q in pts.kids("xy") { if let (Some(x), Some(y)) = (q.num(1), q.num(2)) { xs.push(x); ys.push(y); } } }+                }+            }+            if !xs.is_empty() { break; }+        }+        let bbox = if xs.len() >= 2 { Some([xs.iter().cloned().fold(f64::MAX, f64::min), ys.iter().cloned().fold(f64::MAX, f64::min), xs.iter().cloned().fold(f64::MIN, f64::max), ys.iter().cloned().fold(f64::MIN, f64::max)]) } else { None };+        out.push(Part {+            value: prop("Value").unwrap_or_default(), x: at.and_then(|a| a.num(1)).unwrap_or(0.0), y: at.and_then(|a| a.num(2)).unwrap_or(0.0), rot: at.and_then(|a| a.num(3)).unwrap_or(0.0),+            bottom: fp.kid("layer").and_then(|l| l.atom(1)) == Some("B.Cu"), models, bbox,+            skip: attrs.iter().any(|a| a == "exclude_from_bom" || a == "board_only"), reference,+        });+    }+    Ok(out)+}++/// ${VAR} and $(VAR) from the environment, KIPRJMOD = the board's folder+fn expand(p: &str, board_dir: &Path) -> (String, Option<String>) {+    let mut s = p.replace('\\', "/"); let mut lib_var = None;+    loop {+        let (open, close) = if let Some(i) = s.find("${") { (i, s[i..].find('}').map(|j| i + j)) } else if let Some(i) = s.find("$(") { (i, s[i..].find(')').map(|j| i + j)) } else { break };+        let Some(close) = close else { break };+        let var = s[open + 2..close].to_string();+        let val = if var == "KIPRJMOD" { board_dir.display().to_string() } else { if var.contains("3DMODEL_DIR") { lib_var = Some(var.clone()); } std::env::var(&var).unwrap_or_else(|_| format!("<{var}>")) };+        s = format!("{}{}{}", &s[..open], val, &s[close + 1..]);+    }+    (s, lib_var)+}++fn is_step(p: &Path) -> bool { matches!(p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref(), Some("step" | "stp")) }++/// Where the model's STEP is on this machine, or the KiCad library name the service can fetch.+fn resolve(path: &str, board_dir: &Path, dirs: &[PathBuf]) -> Result<PathBuf, Option<String>> {+    let (full, lib_var) = expand(path, board_dir);+    let full = PathBuf::from(&full);+    let full = if full.is_relative() { board_dir.join(full) } else { full };+    // KiCad libraries ship each model as .wrl and .step: the STEP carries the real geometry and colour+    let mut cands = vec![];+    for ext in ["step", "stp", "STEP", "STP"] { cands.push(full.with_extension(ext)); }+    let name = full.file_name().map(|n| n.to_owned());+    let stem = full.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();+    let mut search: Vec<PathBuf> = dirs.to_vec(); search.push(board_dir.join("3d")); search.push(board_dir.join("3dmodels")); search.push(board_dir.to_path_buf());+    // the stock library on a Linux KiCad install, when the variable is not set in this shell+    if let Some(rest) = lib_var.as_ref().and_then(|_| path.split_once('}').map(|x| x.1.trim_start_matches('/').to_string())) {+        for ext in ["step", "stp"] { cands.push(Path::new("/usr/share/kicad/3dmodels").join(&rest).with_extension(ext)); }+    }+    for d in &search { if let Some(n) = &name { cands.push(d.join(n)); } for ext in ["step", "stp"] { cands.push(d.join(format!("{stem}.{ext}"))); } }+    if let Some(f) = cands.into_iter().find(|c| c.is_file() && is_step(c)) { return Ok(f); }+    // a stock KiCad library model the machine does not have: the service fetches it by name+    let lib = lib_var.and_then(|_| { let parts: Vec<&str> = path.rsplitn(3, '/').collect(); if parts.len() >= 2 { Some(format!("{}/{}", parts[1].trim_end_matches(".3dshapes"), stem)) } else { None } });+    Err(lib)+}++fn cache_dir() -> PathBuf { std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from("/tmp")).join(".cache/adom-fields/models") }++/// one model to a GLB in the content cache: Ok(key) or Err(why)+fn convert(src: &Result<PathBuf, Option<String>>) -> Result<String, String> {+    let cache = cache_dir(); std::fs::create_dir_all(&cache).map_err(|e| e.to_string())?;+    let (key, args): (String, Vec<String>) = match src {+        Ok(p) => { let bytes = std::fs::read(p).map_err(|e| format!("{}: {e}", p.display()))?; (format!("{:x}", Sha256::digest(&bytes))[..24].to_string(), vec!["convert".into(), p.display().to_string()]) }+        Err(Some(lib)) => (format!("lib-{}", lib.replace(['/', ' '], "_")), vec!["from-library".into(), lib.clone()]),+        Err(None) => return Err("model file not found".into()),+    };+    let glb = cache.join(format!("{key}.glb"));+    if glb.is_file() && std::fs::metadata(&glb).map(|m| m.len() > 0).unwrap_or(false) { return Ok(key); }+    let tmp = cache.join(format!("{key}.part.glb"));+    let mut cmd = std::process::Command::new("step2glb"); cmd.args(&args).arg("-o").arg(&tmp);+    let out = cmd.output().map_err(|e| format!("step2glb not runnable ({e}); install it: adom-wiki pkg install adom/adom-step2glb"))?;+    if !out.status.success() || !tmp.is_file() { let _ = std::fs::remove_file(&tmp); return Err(format!("step2glb {}: {}", args[0], String::from_utf8_lossy(&out.stdout).lines().chain(String::from_utf8_lossy(&out.stderr).lines()).filter(|l| l.contains("ERROR") || l.contains("rror")).take(2).collect::<Vec<_>>().join(" "))); }+    std::fs::rename(&tmp, &glb).map_err(|e| e.to_string())?;+    Ok(key)+}++/// The parts of a board with their models converted into `<out>/parts/`: the `parts` block of fields.json.+pub fn build(board: &str, out: &Path, dirs: &[PathBuf], log: &mut dyn FnMut(&str)) -> Value {+    let parts = match read_parts(board) { Ok(p) => p, Err(e) => return json!({"status": "error", "error": e, "items": []}) };+    let board_dir = std::fs::canonicalize(board).ok().and_then(|p| p.parent().map(Path::to_path_buf)).unwrap_or_else(|| PathBuf::from("."));+    // every distinct model once, a few at a time on the service+    let mut srcs: Vec<(String, Result<PathBuf, Option<String>>)> = Vec::new();+    for p in parts.iter().filter(|p| !p.skip) { for m in &p.models { if !srcs.iter().any(|(k, _)| *k == m.path) { srcs.push((m.path.clone(), resolve(&m.path, &board_dir, dirs))); } } }+    let mut results: Vec<(String, Result<String, String>)> = Vec::new();+    for chunk in srcs.chunks(4) {+        let hs: Vec<_> = chunk.iter().cloned().map(|(k, s)| std::thread::spawn(move || (k, convert(&s)))).collect();+        for h in hs { if let Ok(r) = h.join() { results.push(r); } }+    }+    let _ = std::fs::create_dir_all(out.join("parts"));+    let mut failed = Vec::new();+    for (k, r) in &results {+        match r {+            Ok(key) => { let dst = out.join("parts").join(format!("{key}.glb")); if !dst.is_file() { let _ = std::fs::copy(cache_dir().join(format!("{key}.glb")), &dst); } }+            Err(e) => { log(&format!("parts: no model for {k}: {e}")); failed.push(json!({"model": k, "why": e})); }+        }+    }+    let key_of = |path: &str| results.iter().find(|(k, _)| k == path).and_then(|(_, r)| r.as_ref().ok().cloned());+    let mut items = Vec::new(); let (mut modelled, mut boxed, mut bare) = (0, 0, 0);+    for p in &parts {+        let models: Vec<Value> = if p.skip { vec![] } else { p.models.iter().filter_map(|m| key_of(&m.path).map(|k| json!({"glb": format!("parts/{k}.glb"), "source": m.path, "offset": m.offset, "scale": m.scale, "rotate": m.rotate}))).collect() };+        let kind = if p.skip { bare += 1; "bare" } else if !models.is_empty() { modelled += 1; "model" } else if p.bbox.is_some() { boxed += 1; "box" } else { bare += 1; "bare" };+        items.push(json!({"ref": p.reference, "value": p.value, "x": p.x, "y": p.y, "rotation": p.rot, "side": if p.bottom { "bottom" } else { "top" }, "kind": kind, "models": models, "bbox": p.bbox}));+    }+    log(&format!("parts: {modelled} with 3D models, {boxed} as courtyard boxes (no model), {bare} bare (test points and board-only)"));+    json!({"status": "ok", "items": items, "modelled": modelled, "boxed": boxed, "bare": bare, "failed": failed,+           "method": "Each footprint's own 3D model from the board file, placed as KiCad places it (footprint position and rotation, the model's offset, rotation and scale), converted to GLB on the Adom STEP service and cached by content. Boxes stand in where a footprint has no model."})+}++#[cfg(test)] mod tests {+    use super::*;+    #[test] fn expands_project_and_library_variables() {+        let (s, v) = expand("${KIPRJMOD}/3d/a.step", Path::new("/p")); assert_eq!(s, "/p/3d/a.step"); assert!(v.is_none());+        let (_, v) = expand("${KICAD10_3DMODEL_DIR}/Capacitor_SMD.3dshapes/C_1206_3216Metric.wrl", Path::new("/p")); assert_eq!(v.as_deref(), Some("KICAD10_3DMODEL_DIR"));+    }+    #[test] fn missing_library_model_falls_back_to_service_name() {+        std::env::remove_var("KICAD10_3DMODEL_DIR");+        let r = resolve("${KICAD10_3DMODEL_DIR}/Capacitor_SMD.3dshapes/C_1206_3216Metric.wrl", Path::new("/nonexistent"), &[]);+        assert_eq!(r.err().flatten().as_deref(), Some("Capacitor_SMD/C_1206_3216Metric"));+    }+    #[test] fn reads_footprint_models_and_courtyard() {+        let dir = std::env::temp_dir().join(format!("af-parts-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap();+        let pcb = dir.join("b.kicad_pcb");+        std::fs::write(&pcb, r#"(kicad_pcb (footprint "L" (layer "F.Cu") (at 10 20 90) (property "Reference" "L1") (attr smd)+          (fp_rect (start -1 -2) (end 1 2) (layer "F.CrtYd")) (model "${KIPRJMOD}/3d/l.step" (offset (xyz 0 0 0.05)) (scale (xyz 1 1 1)) (rotate (xyz -90 0 0))))+          (footprint "TP" (layer "B.Cu") (at 1 2) (property "Reference" "TP1") (attr exclude_from_bom)))"#).unwrap();+        let p = read_parts(pcb.to_str().unwrap()).unwrap();+        assert_eq!(p[0].reference, "L1"); assert_eq!(p[0].rot, 90.0); assert_eq!(p[0].models[0].rotate, [-90.0, 0.0, 0.0]); assert_eq!(p[0].models[0].offset[2], 0.05);+        assert_eq!(p[0].bbox, Some([-1.0, -2.0, 1.0, 2.0])); assert!(p[1].bottom && p[1].skip);+    }+}
src/serve.rs+15−5
@@ -25,7 +25,7 @@ fn now() -> String { let t = std::time::SystemTime::now().duration_since(std::ti  pub fn serve(dir: &PathBuf, port: u16) {     let fields: Value = std::fs::read_to_string(dir.join("fields.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_else(|| crate::err(&format!("no fields.json in {}", dir.display()), &["Run `adom-fields analyze --board <pcb> --spec <json> --out <dir>` first, or pass --board and --spec to serve.".into()]));-    let app = Arc::new(Mutex::new(App { dir: dir.clone(), fields, view: json!({"quantity": "current", "net": null, "layer": null, "camera": "iso", "air": "still", "issue": null, "chip": null, "drift": true, "seq": 0, "changed": []}), console: Vec::new(), clients: Vec::new(), started: now(), seq: 0 }));+    let app = Arc::new(Mutex::new(App { dir: dir.clone(), fields, view: json!({"quantity": "current", "net": null, "layer": null, "camera": "iso", "air": "still", "issue": null, "chip": null, "drift": true, "parts": "auto", "seq": 0, "changed": []}), console: Vec::new(), clients: Vec::new(), started: now(), seq: 0 }));     let server = tiny_http::Server::http(("0.0.0.0", port)).unwrap_or_else(|e| crate::err(&format!("cannot listen on {port}: {e}"), &[format!("Another adom-fields may be up: `adom-fields health --port {port}`; or pick --port.")]));     crate::ok(&format!("adom-fields serving on http://127.0.0.1:{port}/ ({})", app.lock().unwrap().fields["board"]["name"].as_str().unwrap_or("")), &["Open it from outside the container through the proxy: https://<slug>.adom.cloud/proxy/".to_string() + &port.to_string() + "/ (pup, the browser, or a Hydrogen webview).", "Drive it: `adom-fields ui view --quantity heat --layer F.Cu`, `ui select --net +VBAT`, `ui issue --index 0`, `ui toast \"...\"`; `adom-fields tour` walks the whole thing.".into(), "GET /state, GET /console, GET /events (SSE) for the AI; POST /ui/view, /ui/toast, /ui/select, /ui/issue.".into()]);     for req in server.incoming_requests() {@@ -52,8 +52,13 @@ fn handle(mut req: tiny_http::Request, app: Arc<Mutex<App>>) {         ("GET", "/") | ("GET", "/index.html") => { let _ = req.respond(tiny_http::Response::from_string(include_str!("ui/index.html")).with_header(tiny_http::Header::from_bytes("Content-Type", "text/html; charset=utf-8").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); }         ("GET", "/app.js") => { let _ = req.respond(tiny_http::Response::from_string(include_str!("ui/app.js")).with_header(tiny_http::Header::from_bytes("Content-Type", "application/javascript").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); }         ("GET", "/health") => reply(req, 200, json!({"status": "ok", "version": env!("CARGO_PKG_VERSION")})),-        ("GET", "/state") => { let a = app.lock().unwrap(); reply(req, 200, json!({"status": "ok", "view": a.view, "board": a.fields["board"], "nets": a.fields["nets"].as_array().map(|x| x.len()).unwrap_or(0), "issues": a.fields["issues"].as_array().map(|x| x.len()).unwrap_or(0), "clients": a.clients.len(), "started": a.started, "_hint": "POST /ui/view {quantity, net, layer, camera, air}; /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") => { let a = app.lock().unwrap(); reply(req, 200, a.fields.clone()); }+        ("GET", "/state") => { let a = app.lock().unwrap(); reply(req, 200, json!({"status": "ok", "view": a.view, "board": a.fields["board"], "nets": a.fields["nets"].as_array().map(|x| x.len()).unwrap_or(0), "issues": a.fields["issues"].as_array().map(|x| x.len()).unwrap_or(0), "clients": a.clients.len(), "started": a.started, "_hint": "POST /ui/view {quantity, net, layer, camera (top|iso|bottom|fit|zoom), air, parts: auto|ghost|solid|off, drift, focus: [x, y] mm for camera zoom}; /ui/select {net|chip}; /ui/issue {index}; /ui/toast {text, kind}; GET /events is the SSE channel; GET /fields.json is everything solved."})); }+        ("GET", "/fields.json") => {+            // re-read: `adom-fields parts` may have added the 3D models since the server started+            let mut a = app.lock().unwrap();+            if let Some(f) = std::fs::read_to_string(a.dir.join("fields.json")).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()) { a.fields = f; }+            reply(req, 200, a.fields.clone());+        }         ("GET", "/console") => { let a = app.lock().unwrap(); reply(req, 200, json!({"status": "ok", "lines": a.console})); }         ("POST", "/console") => { let v: Value = serde_json::from_str(&body).unwrap_or(json!({})); let mut a = app.lock().unwrap(); a.console.push(format!("{} {}", v["level"].as_str().unwrap_or("log"), v["text"].as_str().unwrap_or(""))); if a.console.len() > 500 { a.console.remove(0); } reply(req, 200, json!({"status": "ok"})); }         ("GET", "/events") => {@@ -81,7 +86,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"] { if !v[k].is_null() { a.view[k] = v[k].clone(); } }+                for k in ["quantity", "net", "layer", "camera", "air", "chip", "drift", "parts", "focus"] { 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"); } } }             }@@ -107,6 +112,11 @@ fn handle(mut req: tiny_http::Request, app: Arc<Mutex<App>>) {                 None => reply(req, 404, json!({"status": "error", "error": format!("no vendor file {name}")})),             }         }+        ("GET", p) if p.starts_with("/parts/") && p.ends_with(".glb") => {+            let name = p.trim_start_matches("/parts/").replace("..", "").replace('/', "");+            let file = app.lock().unwrap().dir.join("parts").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", "model/gltf-binary").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 part model"})) }+        }         ("GET", p) if p.starts_with("/tex/") => {             let name = p.trim_start_matches("/tex/").replace("..", "");             let file = app.lock().unwrap().dir.join("tex").join(&name);@@ -119,7 +129,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 } => { let r = up("/ui/view", &json!({"quantity": quantity, "net": net, "layer": layer, "camera": camera, "air": air})); 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 } => { let focus = at.as_deref().map(|t| { let v: Vec<f64> = t.split(',').filter_map(|x| x.trim().parse().ok()).collect(); if v.len() != 2 { crate::err("--at takes x,y in board mm, e.g. --at 113.9,90.5", &[]); } json!(v) }); if let Some(p) = &parts { if !["auto", "ghost", "solid", "off"].contains(&p.as_str()) { crate::err(&format!("--parts {p}: use auto, ghost, solid or off"), &[]); } } let r = up("/ui/view", &json!({"quantity": quantity, "net": net, "layer": layer, "camera": camera, "air": air, "parts": parts, "focus": focus})); crate::ok(&format!("view: {}", r["view"]), &["The app repainted; a screenshot of the window shows it. `ui select --net <net>` lights one net; `ui issue --index n` flies to an issue.".into()]); }         UiCmd::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+119−16
@@ -1,7 +1,10 @@ // Adom Fields: the board in 3D with the fields on its copper, driven by the AI over the live channel. (async () => {   const $ = (s) => document.querySelector(s);-  const state = { view: { quantity: 'current', net: null, layer: 'F.Cu', camera: 'iso', air: 'still', issue: null, chip: null, drift: true, focus: null }, fields: null, raw: {}, tex: {}, layerMeshes: {}, light: false };+  const state = { view: { quantity: 'current', net: null, layer: 'F.Cu', camera: 'iso', air: 'still', issue: null, chip: null, drift: true, focus: null, parts: 'auto' }, fields: null, raw: {}, tex: {}, layerMeshes: {}, light: false };+  // every rotation runs at half the speed it did in 0.4: the flow plays these clips ten times faster,+  // and a sway that reads as calm live turned frenetic on the sped-up video+  const SLOW = 2;   const LAYERS = ['F.Cu', 'In1.Cu', 'In2.Cu', 'B.Cu'];   const Z = { 'F.Cu': 0.8, 'In1.Cu': 0.6, 'In2.Cu': -0.6, 'B.Cu': -0.8 };   const slug = (s) => s.replace(/[^A-Za-z0-9]/g, '_');@@ -75,35 +78,121 @@     });     V.addContentRoot(root);     V.frameModel(0.85);+    loadParts(f).catch(e => console.warn('parts: ' + (e && e.message || e)));+  }+  // ---- 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 };+  const deg = (d) => d * Math.PI / 180;+  function kicadPlacement(p, m) {+    // KiCad: fp = T(x, -y, surface) Rz(rot) [Ry(pi) Rz(pi) on the bottom]; model = T(offset) Rz(-rz) Ry(-ry) Rx(-rx) S(scale)+    // Babylon matrices compose left to right in the order they apply+    const T = board.thicknessMm, M = BAB.Matrix;+    let mx = M.Identity();+    if (m) {+      mx = M.Scaling(1000, 1000, 1000)   // the GLB is in metres+        .multiply(M.Scaling(m.scale[0], m.scale[1], m.scale[2]))+        .multiply(M.RotationX(deg(-m.rotate[0]))).multiply(M.RotationY(deg(-m.rotate[1]))).multiply(M.RotationZ(deg(-m.rotate[2])))+        .multiply(M.Translation(m.offset[0], m.offset[1], m.offset[2]));+    }+    if (p.side === 'bottom') mx = mx.multiply(M.RotationY(Math.PI)).multiply(M.RotationZ(Math.PI));+    const at = boardSpace(p.x, p.y, p.side === 'bottom' ? -T / 2 : T / 2);+    return mx.multiply(M.RotationZ(deg(p.rotation || 0))).multiply(M.Translation(at.x, at.y, at.z));+  }+  function place(node, mx) { const s = new BAB.Vector3(), q = new BAB.Quaternion(), t = new BAB.Vector3(); mx.decompose(s, q, t); node.scaling = s; node.rotationQuaternion = q; node.position = t; }+  async function loadParts(f) {+    const items = (f.parts && f.parts.items) || [];+    if (!items.length) { console.log('parts: none in fields.json (run `adom-fields parts --board <pcb> --fields <dir>`)'); return; }+    parts.root = new BAB.TransformNode('fields-parts', scene); parts.root.parent = root;+    const containers = {};+    const load = (url) => containers[url] || (containers[url] = BAB.LoadAssetContainerAsync(url, scene, { pluginExtension: '.glb' }).catch(e => { console.warn('parts: ' + url + ' did not load: ' + (e && e.message || e)); return null; }));+    let n = 0;+    for (const p of items) {+      if (p.kind === 'model') {+        for (const m of p.models) {+          const c = await load(m.glb); if (!c) continue;+          const inst = c.instantiateModelsToScene((name) => `${p.ref}:${name}`, false, { doNotInstantiate: true });+          const holder = new BAB.TransformNode('part-' + p.ref, scene); holder.parent = parts.root; place(holder, kicadPlacement(p, m));+          for (const r of inst.rootNodes) {+            // the model's own frame is KiCad's (Z up, mm after the scale): undo the loader's handedness flip+            r.parent = holder; r.position = BAB.Vector3.Zero(); r.rotationQuaternion = BAB.Quaternion.Identity(); r.scaling = new BAB.Vector3(1, 1, 1);+            for (const mesh of r.getChildMeshes(false)) { mesh.isPickable = false; parts.meshes.push(mesh); (parts.byRef[p.ref] = parts.byRef[p.ref] || []).push(mesh); if (mesh.material) parts.mats.add(mesh.material); }+          }+          n++;+        }+      } else if (p.kind === 'box' && p.bbox) {+        // no model on the footprint: a low box from its courtyard, so the part is still there+        const [x0, y0, x1, y1] = p.bbox, h = 1.0;+        const b = BAB.MeshBuilder.CreateBox('partbox-' + p.ref, { width: x1 - x0, height: y1 - y0, depth: h }, scene);+        const holder = new BAB.TransformNode('part-' + p.ref, scene); holder.parent = parts.root; place(holder, kicadPlacement(p, null));+        b.parent = holder; b.position.set((x0 + x1) / 2, -(y0 + y1) / 2, h / 2); b.isPickable = false;+        const bm2 = new BAB.PBRMaterial('partbox-m-' + p.ref, scene); bm2.albedoColor = new BAB.Color3(0.45, 0.47, 0.5); bm2.metallic = 0; bm2.roughness = 0.8; b.material = bm2;+        parts.mats.add(bm2); parts.meshes.push(b); parts.boxes.push(p.ref); (parts.byRef[p.ref] = parts.byRef[p.ref] || []).push(b);+      }+    }+    for (const mat of parts.mats) { mat.backFaceCulling = false; mat._partAlpha0 = mat.alpha; }+    scene.setRenderingAutoClearDepthStencil(1, false, false, false);+    // hot-part labels ride on top of their part, not inside it+    for (const l of labels) { const ms = parts.byRef[l.c.reference]; if (!ms || !ms.length) continue; let top = -1e9; for (const m of ms) { m.computeWorldMatrix(true); top = Math.max(top, m.getBoundingInfo().boundingBox.maximumWorld.z); } l.at.z = Math.max(l.at.z, top + 0.4); }+    parts.ready = true;+    console.log(`parts: ${n} models placed, ${parts.boxes.length} courtyard boxes${parts.boxes.length ? ' (' + parts.boxes.join(', ') + ')' : ''}`);+    applyParts();+  }+  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();+    const b = $('#btnParts'); if (b) { b.classList.toggle('is-on', mode !== 'off'); b.setAttribute('aria-pressed', mode !== 'off'); b.dataset.mode = mode; }+    if (!parts.ready) return;+    parts.root.setEnabled(mode !== 'off');+    for (const mat of parts.mats) {+      // ghost: the bodies stay readable while the field glows through them+      mat.alpha = mode === 'ghost' ? 0.42 : (mat._partAlpha0 ?? 1);+      mat.transparencyMode = mode === 'ghost' ? BAB.Material.MATERIAL_ALPHABLEND : (mat._partAlpha0 < 1 ? BAB.Material.MATERIAL_ALPHABLEND : BAB.Material.MATERIAL_OPAQUE);+    }+    // the parts draw after the board and its field (group 1, the depth kept), so a see-through body+    // is never painted over by the field plane under it+    for (const m of parts.meshes) { m.renderingGroupId = 1; }   }   // views: the viewer's ViewCube does this by hand; the drivers do it by command. One tween, held.-  const HOLD = BAB.Animation.ANIMATIONLOOPMODE_CONSTANT;-  const EASE = new BAB.CubicEase(); EASE.setEasingMode(BAB.EasingFunction.EASINGMODE_EASEINOUT);+  // every camera move is a slow eased tween on the page's own clock: a snap looks fine live and turns+  // frenetic when the flow plays the clip at ten times speed. The clock is the engine's, or the+  // capture's fixed step (window.fieldsCapture) for frame-exact video.+  const clock = { capture: false, dt: 1 / 30 };+  const frameDt = () => clock.capture ? clock.dt : Math.min(0.1, (V.getEngine().getDeltaTime() || 16) / 1000);+  let tweens = [];   let tweening = 0;-  // every camera move is a slow eased tween: a snap looks fine live and turns frenetic when the-  // flow plays the clip at ten times speed+  const ease = (k) => k < 0.5 ? 4 * k * k * k : 1 - Math.pow(-2 * k + 2, 3) / 2;   function tween(prop, to, seconds) {-    const frames = Math.max(2, Math.round(60 * seconds));     const from = camera[prop] instanceof BAB.Vector3 ? camera[prop].clone() : camera[prop];-    tweening++;-    BAB.Animation.CreateAndStartAnimation('cam-' + prop, camera, prop, 60, frames, from, to, HOLD, EASE, () => { tweening = Math.max(0, tweening - 1); drift.base = null; });+    tweens.push({ prop, from, to: to instanceof BAB.Vector3 ? to.clone() : to, t: 0, s: Math.max(0.05, seconds) });+    tweening = tweens.length;   }   function goto(alpha, beta, radius, target, seconds) {-    const s = seconds || 9.0;   // slow on purpose: the flow plays this clip at ten times speed-    scene.stopAnimation(camera); tweening = 0; drift.base = null;+    const s = (seconds || 9.0) * SLOW;   // slow on purpose: the flow plays this clip at ten times speed+    scene.stopAnimation(camera); tweens = []; tweening = 0; drift.base = null;     if (target) tween('target', target, s);     tween('alpha', alpha, s); tween('beta', beta, s); tween('radius', radius, s);   }-  // the drift: about six degrees of sway and a breath of tilt, over twenty seconds, so a held view-  // is never a frozen frame and never a spin+  scene.onBeforeRenderObservable.add(() => {+    if (!tweens.length) return;+    const dt = frameDt();+    for (const tw of tweens) {+      tw.t = Math.min(tw.s, tw.t + dt); const k = ease(tw.t / tw.s);+      if (tw.to instanceof BAB.Vector3) camera[tw.prop] = BAB.Vector3.Lerp(tw.from, tw.to, k); else camera[tw.prop] = tw.from + (tw.to - tw.from) * k;+    }+    tweens = tweens.filter(tw => tw.t < tw.s); tweening = tweens.length;+    if (!tweening) drift.base = null;+  });+  // the drift: about six degrees of sway and a breath of tilt, over forty seconds (twenty before 0.5),+  // so a held view is never a frozen frame and never a spin   const drift = { t: 0, base: null, last: null };   scene.onBeforeRenderObservable.add(() => {     if (!state.view.drift || tweening > 0 || !board) { drift.base = null; return; }-    const dt = Math.min(0.1, (V.getEngine().getDeltaTime() || 16) / 1000);+    const dt = frameDt();     if (!drift.base || (drift.last !== null && Math.abs(camera.alpha - drift.last) > 1e-4)) { drift.base = { a: camera.alpha, b: camera.beta }; drift.t = 0; }     drift.t += dt;-    const a = drift.base.a + 0.10 * Math.sin(drift.t / 3.2);-    const b = drift.base.b + 0.030 * Math.sin(drift.t / 4.7);+    const a = drift.base.a + 0.10 * Math.sin(drift.t / (3.2 * SLOW));+    const b = drift.base.b + 0.030 * Math.sin(drift.t / (4.7 * SLOW));     camera.alpha = a; camera.beta = Math.max(0.02, Math.min(Math.PI - 0.02, b));     drift.last = camera.alpha;   });@@ -119,7 +208,7 @@     if (which === 'top') goto(NORTH, 0.02, R * 1.05, home, s);     else if (which === 'bottom') goto(NORTH, Math.PI - 0.02, R * 1.05, home, s);     else if (which === 'iso') goto(NORTH - 0.55, 0.95, R * 1.25, home, s);-    else { scene.stopAnimation(camera); V.goHome(); }+    else { tweens = []; tweening = 0; V.goHome(); }   }   function zoomTo(x, y, fromBelow) {     // gentle: about half the board in frame, from near where the camera already is, over four seconds@@ -155,6 +244,7 @@     else if (state.view.quantity === 'heat') { $('#legendTitle').textContent = 'Heat flow in the copper, W/mm (log)'; $('#legLo').textContent = (sc.qHi / 300).toExponential(1); $('#legHi').textContent = sc.qHi.toFixed(3); }     else { $('#legendTitle').textContent = 'Rise above ambient, °C (' + state.view.air + ' air)'; $('#legLo').textContent = '0'; $('#legHi').textContent = sc.tHi.toFixed(0); }     $('#airSec').style.display = state.view.quantity === 'temperature' ? '' : 'none';+    applyParts();     loadRaw();   }   function rawName(L) { const q = state.view.quantity, ls = slug(L); return (q === 'current' ? 'J-' : q === 'heat' ? 'q-' : (state.view.air === 'moving' ? 'Tm-' : 'T-')) + ls + '.raw.png'; }@@ -214,6 +304,7 @@     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('#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'); }   });   function applyView(v, first) {@@ -252,5 +343,17 @@     // a poll as the fallback for proxies that buffer SSE     setInterval(async () => { const s = await fetch('state').then(r => r.json()).catch(() => null); if (s && s.view && s.view.seq !== state.view.seq) applyView(s.view, false); }, 1500);   }+  // frame-exact capture for video: stop the live loop, then step(dt) renders one frame dt seconds on+  window.fieldsCapture = {+    begin(dt) {+      clock.capture = true; clock.dt = dt || 1 / 30; V.getEngine().stopRenderLoop();+      // the orbit pivot marker is for the hand on the mouse, not for the video+      try { const ax = V.getAxesState && V.getAxesState(); if (ax === true || (ax && ax.visible)) V.toggleAxes(); } catch (e) { void e; }+      for (const n of scene.meshes.concat(scene.transformNodes)) if (n.name === 'arcRotateIndicatorSphere' || n.name === 'arrow' || (n.parent && n.parent.name === 'arrow')) n.setEnabled(false);+      return true;+    },+    step(dt) { if (dt) clock.dt = dt; scene.render(); return { seq: state.view.seq, tweening, partsReady: parts.ready }; },+    state: () => ({ view: state.view, tweening, partsReady: parts.ready, parts: parts.meshes.length, texturesReady: Object.values(state.tex).every(t => t.isReady()) }),+  };   boot(); })();
src/ui/index.html+1
@@ -88,6 +88,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 and layers (phone)" 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 (phone)" 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="btnParts" data-tip="The parts' 3D bodies over the field: see-through, solid, or off" aria-pressed="false"><svg viewBox="0 0 24 24"><path d="M12 3l8 4.5v9L12 21l-8-4.5v-9z"/><path d="M12 12l8-4.5M12 12v9M12 12L4 7.5"/></svg></button>       <button class="ibtn" id="btnLight" data-tip="Light the underside (dark parts)" aria-pressed="false"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg></button>     </div>   </header>