main
Colby Knox adom-fields 0.10.7: the board's ground (issue 25); coincident lands as one terminal (issue 26); verify keeps via copper… 554eeec
//! adom-fields: where the current and the heat go on your board.
use sha2::{Digest, Sha256};
mod caps; mod checks; mod verify; mod compliance; mod report; mod inputs; mod tdr; mod modules; mod slim; mod peec; mod pieces; mod converter; mod cm; mod conducted; mod xsec; mod limits; mod rf; mod solids; mod pours; mod probe; mod terminals; mod barrel; mod package_cooling; mod thermal3d; mod eagle; mod emc; mod flow; mod noise; mod pdn; mod parts; mod silk; mod signals; mod heatsink; mod board; mod fields; mod raster; mod sexp; mod solve; mod tex; mod serve; mod vendor;
use clap::{Parser, Subcommand};
use serde_json::{json, Value};
use std::path::PathBuf;

#[derive(Parser)]
#[command(name = "adom-fields", version, about = "Adom Fields: current density, copper heat flow and board temperature from the board file, in 3D")]
struct Cli { #[command(subcommand)] cmd: Cmd }

#[derive(Subcommand)]
enum Cmd {
    /// Solve axis-aligned 3D solids (a package, an interface, a finned sink) with explicit contacts and prescribed convection (not CFD); see docs/thermal3d.md
    Thermal3d { #[arg(long)] spec: String, #[arg(long)] out: PathBuf },
    /// 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,
    /// 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>, /// skip the silkscreen (it needs kicad-cli)
    #[arg(long)] no_silk: bool, /// optional modules to run besides the core, comma-separated: emc, esd, pdn, si, noise, rf, or all (the output says which would matter on this board and why)
    #[arg(long, value_delimiter = ',')] with: Vec<String> },
    /// (Re)build only the silkscreen of a solved board: KiCad's F.SilkS and B.SilkS as SVG, into <fields>/ and fields.json `silk`
    Silk { #[arg(long)] board: String, #[arg(long, default_value = "fields")] fields: PathBuf },
    /// (Re)build only the parts of a solved board: each footprint's 3D model, converted and placed, into <fields>/fields.json and <fields>/parts/
    Parts { #[arg(long)] board: String, #[arg(long, default_value = "fields")] fields: PathBuf, #[arg(long = "models-dir")] models_dir: Vec<PathBuf> },
    /// Serve the app for a solved board (or for a board and spec, solving first): the 3D board with the fields on it, the nets by relevance, the issues, and the live channel the AI drives
    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|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
    #[arg(long, default_value_t = 20000)] pause_ms: u64 },
    /// The DC voltage drop between two pads of a solved net, or from a pad to its net's farthest point: `probe --fields <dir> --from J1.1 --to U1.3` (or `--to VIN`); reads the solve, no re-solve
    Probe { #[arg(long, default_value = "fields")] fields: PathBuf, #[arg(long)] from: String, #[arg(long)] to: String, /// print the JSON instead of the OK line
    #[arg(long)] json: bool },
    /// Apply fix-plan steps to a copy of the board (vias, wider segments: copper edits only), solve the copy and report what changed: `verify --board b.kicad_pcb --spec s.json --step 1,3 --out verify`
    Verify { #[arg(long)] board: String, #[arg(long)] spec: String, #[arg(long, default_value = "verify")] out: PathBuf, /// fix-plan step numbers, comma-separated (all of them when left out)
    #[arg(long, value_delimiter = ',')] step: Vec<u64>, #[arg(long, default_value_t = 0.2)] pitch: f64, /// an existing solve of the same board to use as "before" (skips solving it again)
    #[arg(long)] fields: Option<PathBuf>, /// refill the zones and run DRC with KiCad (kicad-cli: service-kicad or the desktop) on the original and the fixed copy, so other nets' pours make room for the fix and new DRC errors are reported
    #[arg(long)] refill: bool },
    /// Certification readiness: one report over the fields solve, Adom Safety's safety.json and Adom Mechanics' mechanics.json (each optional): every area ready, needs work, blocked or not checked, with its numbers and findings; writes <out> (Markdown) and the same as .json
    Report { #[arg(long, default_value = "fields")] fields: PathBuf, /// adom-safety's output folder or safety.json
    #[arg(long)] safety: Option<PathBuf>, /// adom-mechanics' output folder or mechanics.json
    #[arg(long)] mechanics: Option<PathBuf>, #[arg(long, default_value = "readiness.md")] out: PathBuf },
    /// Is the server up
    Health { #[arg(long, default_value_t = 8874)] port: u16 },
    /// Install the skill from the wiki page
    Install,
}

#[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>, /// 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>, /// pull the layer stack apart, 0 (together) to 1 (fully apart): the inner copper and the current down the vias show
    #[arg(long)] explode: Option<f64>, /// animation speed of the flow and river views, 0.1 to 4 (1 is the default)
    #[arg(long)] speed: Option<f64>, /// how see-through the ghosted parts are, 0.05 to 1 (the viewer's own slider otherwise; 0.33 by default)
    #[arg(long)] ghost: Option<f64>, /// 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>, /// 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 },
    Select { #[arg(long)] net: Option<String>, #[arg(long)] chip: Option<String> },
    Issue { #[arg(long)] index: usize },
    /// The drop between two pads (or a pad and its net) on the served board, shown in the river view with a toast
    Probe { #[arg(long)] from: String, #[arg(long)] to: String },
    State,
    Console,
}

pub fn ok(msg: &str, hints: &[String]) { println!("OK: {msg}"); for h in hints { if !h.is_empty() { println!("Hint: {h}"); } } }
pub fn err(msg: &str, hints: &[String]) -> ! { println!("ERROR: {msg}"); for h in hints { if !h.is_empty() { println!("Hint: {h}"); } } std::process::exit(1) }

fn slug(s: &str) -> String { s.chars().map(|c| if c.is_alphanumeric() { c } else { '_' }).collect() }

pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck: f64, parts: Option<&[PathBuf]>, no_silk: bool, with: &[String]) -> 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)?;
    let mods = modules::Mods::choose(with, &serde_json::from_slice::<Value>(&spec_bytes).unwrap_or(Value::Null))?;
    let mut spec: fields::Spec = serde_json::from_str(&std::fs::read_to_string(spec_path).map_err(|e| format!("{spec_path}: {e}"))?).map_err(|e| format!("{spec_path}: {e}"))?;
    // the copper stack: the board's real layers (two, four or six; issue 10, 11) and the dielectric between each pair,
    // from the spec (one value per real gap; three on a two-layer board is the old form and is summed), else the board's
    // own stackup or thickness, else a default; spread over the six copper slots for the solve
    board::check_copper(&b.copper, &b.layer_names)?;
    let cu: Vec<String> = if b.copper.is_empty() { ["F.Cu", "In1.Cu", "In2.Cu", "B.Cu"].iter().map(|s| s.to_string()).collect() } else { b.copper.clone() };
    let ngap = cu.len() - 1;
    if spec.stackupMm.iter().any(|v| !v.is_finite() || *v <= 0.0) { return Err("stackupMm requires positive dielectric thicknesses".into()); }
    let (gaps, stack_from): (Vec<f64>, &str) = if !spec.stackupMm.is_empty() {
        if spec.stackupMm.len() == ngap { (spec.stackupMm.clone(), "spec") }
        else if ngap == 1 && spec.stackupMm.len() == 3 { (vec![spec.stackupMm.iter().sum()], "spec (three values on a two-layer board, summed)") }
        else { return Err(format!("stackupMm has {} value(s) but this board has {} copper layers ({}): give one dielectric thickness per gap, {} value(s)", spec.stackupMm.len(), cu.len(), cu.join(", "), ngap)); }
    } else if let Some(g) = b.stack_mm.clone().filter(|g| g.len() == ngap) { (g, "board file (its stackup, else its thickness)") }
    else { (match ngap { 1 => vec![1.465], 3 => vec![0.2, 1.065, 0.2], _ => vec![0.2, 0.3, 0.33, 0.3, 0.2] }, "default") };
    spec.er_slots = b.stack_er.clone().filter(|e| e.len() == ngap).map(|e| { let idx: Vec<usize> = cu.iter().filter_map(|c| board::LAYERS.iter().position(|l| *l == c)).collect(); let mut out = vec![spec.epsilonR.unwrap_or(4.3); board::LAYERS.len() - 1]; for (er, w) in e.iter().zip(idx.windows(2)) { for k in w[0]..w[1] { out[k] = *er; } } out });
    spec.stackupMm = fields::spread(&gaps, &cu); spec.real_gaps = gaps; spec.real_copper = cu.clone();
    // ground carries the return current even when the spec lists only the supplies (issue 9): solve it with the
    // largest supply load as its current, and say so
    // a switching node with no load still carries the inductor's whole current (issue 13): solve it with the load of the net
    // across its inductor (the converter's output), else the largest load, and say so
    let mut auto_switch: Vec<(String, f64)> = Vec::new();
    for sw in spec.switching.keys().cloned().collect::<Vec<_>>() {
        if spec.loads.contains_key(&sw) || spec.loads.is_empty() { continue; }
        let across = b.pads.iter().filter(|p| p.net == sw && p.reference.starts_with('L')).find_map(|p| b.pads.iter().find(|q| q.reference == p.reference && q.net != sw).map(|q| q.net.clone()));
        let amps = across.as_ref().and_then(|n| spec.loads.get(n)).map(|l| l.amps).unwrap_or_else(|| spec.loads.values().map(|l| l.amps).fold(0.0, f64::max));
        if amps > 0.0 { spec.loads.insert(sw.clone(), fields::Load { amps, maxRiseC: None, maxDropMv: None, draws: Default::default(), terminals: None }); auto_switch.push((sw, amps)); }
    }
    if let Some(g) = &spec.ground { if !b.pads.iter().any(|p| p.net == *g) && !b.polys.iter().any(|p| p.net == *g) { return Err(format!("spec ground {g} is not a net on this board")); } }
    let mut auto_ground: Option<(String, f64)> = None;
    let pad_nets: Vec<String> = b.pads.iter().map(|p| p.net.clone()).collect();
    if let Some((gname, _)) = fields::board_ground(&spec, &b, &pad_nets) {
        if !spec.loads.contains_key(&gname) && !spec.loads.is_empty() {
            let amps = spec.loads.values().map(|l| l.amps).fold(0.0, f64::max);
            // under 0.2 A the return current's drop is microvolts and its heat nothing: not worth a solve (name the ground
            // in spec loads to have it solved anyway)
            if amps >= 0.2 { spec.loads.insert(gname.clone(), fields::Load { amps, maxRiseC: None, maxDropMv: None, draws: Default::default(), terminals: None }); auto_ground = Some((gname, amps)); }
            else if amps > 0.0 { eprintln!("{gname}: return current not solved: the largest load is {amps} A (under 0.2 A, its drop is negligible); name {gname} in spec loads to solve it"); }
        }
    }
    if !pitch.is_finite() || pitch <= 0.0 || pitch > b.width().min(b.height())/2.0 { return Err("pitch must be positive and resolve at least two cells across the board".into()); }
    if !spec.copperUm.is_finite() || spec.copperUm<=0.0 {return Err("copperUm must be positive".into());}
    for (net, load) in &spec.loads { if let Some(rows) = &load.terminals { terminals::resolve(&b, net, load.amps, rows)?; } }
    if !(1..=100000).contains(&spec.dcMaxIterations) { return Err("dcMaxIterations must be between 1 and 100000; the convergence target is unchanged".into()); }
    for (layer,t) in &spec.copperUmByLayer { if !board::LAYERS.contains(&layer.as_str()) || !t.is_finite() || *t<=0.0 {return Err("copperUmByLayer requires known copper layers and positive thicknesses".into());} }
    spec.signals.validate(&spec.real_copper)?;
    for hot in spec.hot.values() {if !hot.watts.is_finite() || hot.watts<0.0 {return Err("hot watts must be finite and nonnegative".into());}}
    // a package cooled off the board (PR 11): one entry per hot part, its links on that part's own pads
    for (r, h) in &spec.hot { if let Some(l) = h.maxRiseC { if !l.is_finite() || l <= 0.0 { return Err(format!("hot.{r}.maxRiseC must be a positive number of °C, got {l}")); } } }
    if let Some(sd) = &spec.solids {
        let copper: Vec<String> = b.layer_names.clone();
        sd.validate([b.x0, b.y0, b.x1, b.y1], &|l| copper.iter().any(|c| c == l) || l == "F.Cu" || l == "B.Cu", &|r| spec.hot.contains_key(r))?;
        for x in &sd.bodies { if let Some(r) = &x.heat_of { if spec.packageCooling.iter().any(|p| p.reference == *r) { return Err(format!("solids: {} takes {r}'s heat, which packageCooling also models; use one or the other", x.name)); } } }
    }
    for (i, package) in spec.packageCooling.iter().enumerate() {
        let cu: Vec<&str> = spec.real_copper.iter().map(String::as_str).collect();
        package.validate_board(&b, &cu, pitch)?;
        if !spec.hot.contains_key(&package.reference) || spec.packageCooling[..i].iter().any(|p| p.reference == package.reference) { return Err("packageCooling requires one entry per existing hot reference".into()); }
    }
    for load in spec.loads.values() {if !load.amps.is_finite() || load.amps<0.0 {return Err("load amps must be finite and nonnegative".into());}}
    if !spec.signals.capacitance.is_empty() || !spec.signals.differential_pairs.is_empty() {
        for (layer_a,layer_b,distance) in spec.signals.capacitance.iter().map(|c|(&c.layer_a,&c.layer_b,c.dielectric_mm)).chain(spec.signals.differential_pairs.iter().map(|p|(&p.layer,&p.reference_layer,p.dielectric_mm))) {
            let a=board::LAYERS.iter().position(|l|*l==layer_a).unwrap();let bb=board::LAYERS.iter().position(|l|*l==layer_b).unwrap();
            let h = fields::gap_between(&spec, &b, a, bb).0;
            if (h-distance).abs()>1e-3 {return Err(format!("signal dielectricMm {distance} disagrees with the stackup's {h:.4} mm between {layer_a} and {layer_b} ({stack_from})"));}
        }
    }
    for (i,block) in spec.heatsinks.iter().enumerate() {
        block.validate([b.x0,b.y0,b.x0+b.width(),b.y0+b.height()],pitch)?;
        for other in &spec.heatsinks[..i] {
            let a=block.bounds_mm; let c=other.bounds_mm;
            if block.name==other.name || (block.layer==other.layer && a[0]<c[2] && a[2]>c[0] && a[1]<c[3] && a[3]>c[1]) {return Err("heatsinks require unique names and non-overlapping contact rectangles on each face".into());}
        }
    }
    if spec.loads.is_empty() && spec.hot.is_empty() && spec.signals.capacitance.is_empty() && spec.signals.differential_pairs.is_empty() { return Err("the spec has no loads, hot parts or signal checks: nothing to solve".into()); }
    std::fs::create_dir_all(out.join("tex")).map_err(|e| e.to_string())?;
    let t0 = std::time::Instant::now();
    let mut lines: Vec<String> = Vec::new();
    let mut f = fields::analyze(&b, &spec, pitch, neck, &mut |s| { eprintln!("{s}"); lines.push(s.to_string()); });
    // electro-thermal: copper's resistance rises 0.393 %/°C, so a hot neck dissipates more and runs hotter still. Always a
    // first-order estimate of how much more Joule heat the solved temperatures mean; with spec electroThermal, a second
    // full pass with each copper cell at its solved temperature (the answer the report then carries)
    let et_est = { let amb = spec.ambientC.unwrap_or(25.0); let (mut p0, mut p1) = (0.0f64, 0.0f64);
        for (k, g) in f.joule_map.iter().enumerate() { for (i, p) in g.v.iter().enumerate() { let p = *p as f64; p0 += p; p1 += p * (1.0 + 0.00393 * (amb + f.t[k].v[i] as f64 - 20.0)) / (1.0 + 0.00393 * (20.0 - 20.0)); } }
        if p0 > 0.0 { p1 / p0 - 1.0 } else { 0.0 } };
    let mut et_report = serde_json::json!({"jouleIncreaseEstimate": (et_est * 1000.0).round() / 10.0, "ran": false, "note": "first-order: the Joule loss at the solved copper temperatures over the loss at 20 °C; set spec electroThermal: true for the full second pass"});
    if spec.electroThermal != Some(true) && et_est > 0.10 { f.issues.push(fields::Issue { severity: "low".into(), kind: "electro-thermal".into(), what: format!("Copper at its solved temperatures dissipates about {:.0} % more than at 20 °C: the reported rises are low by roughly that share of the Joule heat", et_est * 100.0), at: None, layer: None, net: None, reference: None, fix: "Run again with spec electroThermal: true for the self-consistent temperatures (a second full pass).".into(), action: Some(serde_json::json!({"type": "review", "why": "electro-thermal coupling", "jouleIncreasePct": (et_est * 1000.0).round() / 10.0})) }); }
    if spec.electroThermal == Some(true) {
        let (peak0, joule0) = (f.t.iter().map(|g| g.max()).fold(0.0f32, f32::max), f.joule_total);
        let mut spec2 = spec.clone(); spec2.cu_rise = Some(f.t.clone());
        f = fields::analyze(&b, &spec2, pitch, neck, &mut |s| { eprintln!("electro-thermal pass: {s}"); lines.push(format!("electro-thermal pass: {s}")); });
        let (peak1, joule1) = (f.t.iter().map(|g| g.max()).fold(0.0f32, f32::max), f.joule_total);
        et_report = serde_json::json!({"ran": true, "passes": 2, "peakRiseC": [((peak0 * 10.0).round() / 10.0), ((peak1 * 10.0).round() / 10.0)], "jouleW": [joule0, joule1], "jouleIncreasePct": if joule0 > 0.0 { ((joule1 / joule0 - 1.0) * 1000.0).round() / 10.0 } else { 0.0 }, "jouleIncreaseEstimate": (et_est * 1000.0).round() / 10.0});
    }
    // the design review the caller acts on: EMC, ground, rails, the converter; every issue gets an action
    // a loaded net that was not solved: with the spec's own pins, an error, never a partial report (PR 6); with guessed pins, a
    // high issue telling the caller to name them (AI Flow refuses a solve missing a loaded net, so nothing passes on it)
    let mut unsolved: Vec<fields::Issue> = Vec::new();
    for (net, load) in &spec.loads { if load.amps > 0.0 && auto_ground.as_ref().map_or(true, |g| g.0 != *net) && !f.nets.iter().any(|r| r.net == *net) {
        if load.terminals.is_some() { return Err(format!("{net}: loaded net with explicit terminals was not solved (see the log above); no partial report is published")); }
        unsolved.push(fields::Issue { severity: "high".into(), kind: "load-unsolved".into(), what: format!("{net}: its {} A load was not solved: no terminals could be found for it (see the log)", load.amps), at: None, layer: None, net: Some(net.clone()), reference: None, fix: format!("Name {net}'s pins and their currents in spec loads.{net}.terminals (positive into the copper, negative out); a rail made inside a chip has no connector to feed it."), action: Some(serde_json::json!({"type": "review", "net": net, "why": "loaded net not solved: give spec terminals"})) });
    } }
    let mut iss = std::mem::take(&mut f.issues);
    iss.extend(unsolved);
    let mut review = emc::review(&f, &b, &spec, &mut iss);
    let fragments = pours::review(&f, &b, &spec, &mut iss);
    // the optional modules run only when asked (crate::modules); a skipped one leaves a note, never a guess
    let off = |m: &str| json!({"ran": false, "module": m, "turnOn": format!("--with {m}")});
    let mut nz = if mods.has("noise") || mods.has("rf") { noise::review(&f, &b, &spec, &mut iss) } else { off("noise") };
    let pd = if mods.has("pdn") { pdn::review(&f, &b, &spec, &review["converter"], &mut iss, Some(out.as_path())) } else { off("pdn") };
    let rfv = if mods.has("rf") { rf::review(&b, &spec, &json!({"noise": nz.clone(), "stageFeeds": review["stageFeeds"].clone()}), out.as_path(), &mut iss) } else { off("rf") };
    let chk = checks::review(&f, &b, &spec, &review, &mut iss);
    let cmv = if mods.has("emc") { cm::review(&f, &b, &spec, &review, &mut iss) } else { off("emc") };
    let cev = if mods.has("emc") { conducted::review(&b, &spec, &review["converter"], &mut iss) } else { off("emc") };
    let xsv = if mods.has("si") { xsec::review(&b, &spec, &chk["stackup"], &mut iss) } else { off("si") };
    let tdv = if mods.has("si") { tdr::review(&b, &spec, &xsv, out.as_path(), &mut iss) } else { off("si") };
    // a current solve that did not converge is an issue, not a result (issue 9)
    for nr in f.nets.iter().filter(|x| !x.converged) { iss.push(fields::Issue { severity: "high".into(), kind: "solve-unconverged".into(), what: format!("{}: the current solve stopped at residual {:.1e} (target 1e-7); its current density and voltage drop are not reliable", nr.net, nr.residual), at: None, layer: None, net: Some(nr.net.clone()), reference: None, fix: "Re-run at a coarser --pitch, or name this net's terminals in the spec; copper islands with terminals are referenced on their own.".into(), action: Some(json!({"type": "review", "net": nr.net, "why": "solver did not converge"})) }); }
    emc::act_on_core(&mut iss, &f);
    // keep the findings of the modules that ran, then group the repetitive ones
    iss.retain(|i| mods.has(modules::module_of(&i.kind)));
    let iss = modules::group(iss, &spec.external);
    let module_why = modules::detect(&b, &spec, &review);
    f.issues = iss;
    for package in &f.package_cooling { for state in ["still", "moving"] { let r = &package[state]; let residual = r["energyResidualW"].as_f64().unwrap_or(f64::INFINITY); let scale = 1.0 + r["sourceW"].as_f64().unwrap_or(0.0).abs() + r["boardCouplingW"].as_f64().unwrap_or(0.0).abs(); if !residual.is_finite() || residual.abs() > 1e-5 * scale { return Err(format!("package cooling energy balance failed for {} ({state})", package["definition"]["reference"])); } } }
    if !f.heat_residual.is_finite() || f.heat_residual>1e-6 || !f.heat_moving_residual.is_finite() || f.heat_moving_residual>1e-6 {return Err(format!("thermal solve did not converge: still {}, moving {}; refine the model or increase the solve budget",f.heat_residual,f.heat_moving_residual));}
    let tex = out.join("tex");
    let mut textures: Vec<String> = Vec::new();
    let jhi = f.j_all.iter().map(|g| g.max()).fold(1.0, f32::max);
    let qhi = f.q.iter().map(|g| g.max()).fold(0.01, f32::max).max(f.fan.iter().flat_map(|l| l.q.iter().map(|g| g.max())).fold(0.0, f32::max));
    // the bodies off the board share the temperature scale (the die runs hotter than any copper)
    let thi = f.t.iter().map(|g| g.max()).fold(1.0, f32::max).max(f.solids["still"].as_array().map_or(0.0, |b| b.iter().filter_map(|x| x["maxRiseC"].as_f64()).fold(0.0, f64::max)) as f32);
    for (k, l) in board::LAYERS.iter().enumerate() {
        let ls = slug(l);
        tex::write_mask(&tex.join(format!("copper-{ls}.png")).display().to_string(), &f.frac[k], [214, 148, 90])?; textures.push(format!("copper-{ls}.png"));
        tex::write(&tex.join(format!("J-{ls}.png")).display().to_string(), &f.j_all[k], Some(&f.frac[k]), &tex::Scale::Log { lo: jhi / 300.0, hi: jhi }, false)?; textures.push(format!("J-{ls}.png"));
        tex::write_raw(&tex.join(format!("J-{ls}.raw.png")).display().to_string(), &f.j_all[k], jhi)?;
        tex::write(&tex.join(format!("q-{ls}.png")).display().to_string(), &f.q[k], Some(&f.frac[k]), &tex::Scale::Log { lo: qhi / 300.0, hi: qhi }, false)?; textures.push(format!("q-{ls}.png"));
        tex::write_raw(&tex.join(format!("q-{ls}.raw.png")).display().to_string(), &f.q[k], qhi)?;
        tex::write(&tex.join(format!("T-{ls}.png")).display().to_string(), &f.t[k], None, &tex::Scale::Linear { hi: thi }, true)?; textures.push(format!("T-{ls}.png"));
        tex::write_raw(&tex.join(format!("T-{ls}.raw.png")).display().to_string(), &f.t[k], thi)?;
        tex::write(&tex.join(format!("Tm-{ls}.png")).display().to_string(), &f.t_moving[k], None, &tex::Scale::Linear { hi: thi }, true)?; textures.push(format!("Tm-{ls}.png"));
        tex::write_raw(&tex.join(format!("Tm-{ls}.raw.png")).display().to_string(), &f.t_moving[k], thi)?;
        // one pour at a time: the heat flow masked to a single net's copper
        for (net, cu) in &f.cu_net {
            let ns = slug(net);
            let mut only = crate::raster::Grid::new(f.q[k].w, f.q[k].h);
            for i in 0..only.v.len() { if cu[k].v[i] > 0.05 { only.v[i] = f.q[k].v[i]; } }
            tex::write(&tex.join(format!("q-{ns}-{ls}.png")).display().to_string(), &only, Some(&cu[k]), &tex::Scale::Log { lo: qhi / 300.0, hi: qhi }, false)?; textures.push(format!("q-{ns}-{ls}.png"));
        }
        for (net, grids) in &f.j_net {
            let ns = slug(net);
            tex::write(&tex.join(format!("J-{ns}-{ls}.png")).display().to_string(), &grids[k], Some(&f.frac[k]), &tex::Scale::Log { lo: jhi / 300.0, hi: jhi }, false)?; textures.push(format!("J-{ns}-{ls}.png"));
        }
    }
    // each fan level: its temperature and its heat flow, on the same scales as still air, so the levels compare by eye
    let fan_name = |lv: f64| format!("fan{:.0}", lv * 100.0);
    for lv in &f.fan { let nm = fan_name(lv.level); for (k, l) in board::LAYERS.iter().enumerate() { let ls = slug(l);
        tex::write(&tex.join(format!("T-{nm}-{ls}.png")).display().to_string(), &lv.t[k], None, &tex::Scale::Linear { hi: thi }, true)?; textures.push(format!("T-{nm}-{ls}.png"));
        tex::write_raw(&tex.join(format!("T-{nm}-{ls}.raw.png")).display().to_string(), &lv.t[k], thi)?;
        tex::write(&tex.join(format!("q-{nm}-{ls}.png")).display().to_string(), &lv.q[k], Some(&f.frac[k]), &tex::Scale::Log { lo: qhi / 300.0, hi: qhi }, false)?; textures.push(format!("q-{nm}-{ls}.png"));
        tex::write_raw(&tex.join(format!("q-{nm}-{ls}.raw.png")).display().to_string(), &lv.q[k], qhi)?;
    } }
    if !f.fan.is_empty() && !f.solids_cells.is_null() { for lv in &f.fan { f.solids_cells["levels"][fan_name(lv.level)] = lv.solids_cells.clone(); } }
    let mut summary = fields::summary(&f, &b, &spec, &textures);
    // the cavities' mode shapes are for the app: their own file, so fields.json stays readable for an AI caller
    if !f.solids_cells.is_null() { std::fs::write(out.join("solids.json"), serde_json::to_string(&f.solids_cells).unwrap()).map_err(|e| e.to_string())?; }
    if let Some(cav) = nz["cavities"].as_array_mut() { let shapes: Vec<Value> = cav.iter_mut().map(|c| json!({"net": c["net"].clone(), "grid": c["grid"].clone(), "shapes": c["shapes"].take()})).collect(); for c in cav.iter_mut() { c.as_object_mut().map(|o| o.remove("shapes")); } std::fs::write(out.join("noise.json"), serde_json::to_string(&json!({"cavities": shapes})).unwrap()).map_err(|e| e.to_string())?; }
    review["noise"] = nz;
    review["pdn"] = pd;
    review["rf"] = rfv;
    review["checks"] = chk;
    review["electroThermal"] = et_report.clone();
    review["cableEmissions"] = cmv;
    review["conductedEmissions"] = cev;
    review["impedance"] = xsv;
    review["reflections"] = tdv;
    review["compliance"] = if mods.has("emc") || mods.has("rf") { compliance::review(&b, &spec, &review) } else { off("emc") };
    let (module_lines, module_rec) = modules::report(&mods, &module_why);
    summary["modules"] = module_rec; summary["modules"]["lines"] = json!(module_lines);
    summary["fragments"] = fragments;
    summary["review"] = review;
    summary["fixPlan"] = emc::fix_plan(&f.issues);
    if !f.fan.is_empty() { summary["airStates"] = json!({"model": "fan level s: h = (h_still³ + (h_forced·sⁿ)³)^⅓ on every exposed face (board, bodies, a package sink), s = 0 is still air and s = 1 the spec's moving air; prescribed, not CFD", "exponent": spec.fanExponent.unwrap_or(0.5), "levels": f.fan.iter().map(|l| json!({"name": fan_name(l.level), "level": l.level, "hBoardWM2k": (l.h_board_w_m2k * 100.0).round() / 100.0, "residual": l.residual, "chips": l.chips.iter().map(|(r, t)| json!({"reference": r, "riseC": t})).collect::<Vec<_>>(), "solids": l.solids})).collect::<Vec<_>>()}); }
    summary["flow"] = flow::write(out, &f, &b, &spec)?;
    summary["board"]["stackupFrom"] = serde_json::json!(stack_from); if !auto_switch.is_empty() { summary["switchingLoads"] = serde_json::json!(auto_switch.iter().map(|(n, a)| serde_json::json!({"net": n, "amps": a, "note": "a switching node with no load in the spec: solved with the current of the net across its inductor (else the largest load); set loads.<net> to change it"})).collect::<Vec<_>>()); } if let Some((g, a)) = &auto_ground { summary["groundReturn"] = serde_json::json!({"net": g, "amps": a, "note": "not in the spec: solved with the largest supply load as the return current (set loads.<ground> to change it)"}); }
    { let raw: Value = serde_json::from_slice(&spec_bytes).unwrap_or(Value::Null); let mut rv = summary["review"].clone(); rv["stackupFrom"] = json!(if stack_from == "default" { "default" } else { stack_from });
      summary["review"]["inputs"] = inputs::review(&b, &spec, &raw, &rv, &summary["chips"]); }
    summary["board"]["name"] = serde_json::json!(spec.board.split('(').next().unwrap_or(&spec.board).trim());
    summary["board"]["description"] = serde_json::json!(spec.board);
    summary["log"] = serde_json::json!(lines);
    summary["seconds"] = serde_json::json!((t0.elapsed().as_secs_f64() * 10.0).round() / 10.0);
    summary["scales"]["jHi"] = serde_json::json!(jhi); summary["scales"]["qHi"] = serde_json::json!(qhi); summary["scales"]["tHi"] = serde_json::json!(thi);
    if std::fs::read(board).map_err(|e|e.to_string())? != board_bytes || std::fs::read(spec_path).map_err(|e|e.to_string())? != spec_bytes {
        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}")); }
    if !no_silk { summary["silk"] = silk::build(board, out, &mut |s| eprintln!("{s}")); }
    summary["source"] = serde_json::json!({"board": std::fs::canonicalize(board).map(|p| p.display().to_string()).unwrap_or(board.to_string()), "spec": spec_path});
    // the AI's fields.json stays lean; the bulk the app draws goes to detail.json (crate::slim), merged back on serve
    let (lean, detail) = slim::split(summary.clone());
    std::fs::write(out.join(slim::DETAIL), serde_json::to_string(&detail).unwrap()).map_err(|e| e.to_string())?;
    std::fs::write(out.join("fields.json"), slim::to_text(&lean)).map_err(|e| e.to_string())?;
    Ok(summary)
}

fn main() {
    let cli = Cli::parse();
    match cli.cmd {
        Cmd::Thermal3d { spec, out } => match thermal3d::run(&spec, &out) { Ok(()) => ok("3D solid thermal solve written", &[]), Err(e) => err(&e, &[]) },
        Cmd::Analyze { board, spec, out, pitch, neck, no_parts, models_dir, no_silk, with } => {
            match run_analyze(&board, &spec, &out, pitch, neck, if no_parts { None } else { Some(&models_dir) }, no_silk, &with) {
                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["nets"].as_array().into_iter().flatten().filter_map(|n| n["peakJ"].as_f64()).fold(0.0, f64::max), s["heat"]["peakRiseC"].as_f64().unwrap_or(0.0), s["heat"]["peakRiseMovingC"].as_f64().unwrap_or(0.0), s["heat"]["budgetC"].as_f64().unwrap_or(0.0), s["heat"]["jouleW"], s["heat"]["tabW"]),
                        issues.iter().take(3).map(|i| format!("{} {}: {}", i["severity"].as_str().unwrap_or(""), i["kind"].as_str().unwrap_or(""), i["what"].as_str().unwrap_or(""))).collect::<Vec<_>>().join(" | "),
                        { let r = &s["review"]; let c = &r["converter"]; let g = &r["ground"]; format!("Review: {}{}{}.", if c["fswHz"].is_null() { match c["unsupported"].as_str() { Some(w) => format!("switching converter not modelled: {w}"), None => "no switching converter modelled".to_string() } } else { format!("hot loop {} {} mm² (best {} mm²), emissions margin {} dB at {} MHz (FCC B, loop only), output ripple {:.1} mV", c["hotLoop"]["cap"].as_str().unwrap_or("?"), c["hotLoop"]["mm2"], c["idealMm2"], c["emissions"]["worstMarginDb"], c["emissions"]["atMHz"], c["rippleOutMv"].as_f64().unwrap_or(0.0)) }, if g.is_null() { String::new() } else { format!("; {}{} island(s), edge stitched {} %", if g["offsetMv"].is_number() { format!("ground offset {} mV, ", g["offsetMv"]) } else { "ground offset not solved (ground is not a loaded net), ".to_string() }, g["islands"].as_array().map_or(0, |x| x.len()), g["stitching"]["edgeCoveredPct"]) }, r["reference"].as_array().map(|a| a.iter().filter(|x| x["unreferencedPct"].as_f64().unwrap_or(0.0) > 5.0).take(5).map(|x| format!("; {} {}% without ground under it", x["net"].as_str().unwrap_or(""), x["unreferencedPct"])).collect::<String>()).unwrap_or_default()) },
                        if s["review"]["pdn"]["ran"] == false { String::new() } else { let all = s["review"]["pdn"]["rails"].as_array().cloned().unwrap_or_default(); let rs: Vec<Value> = all.iter().filter(|r| r["skipped"].is_null()).cloned().collect(); let sk: String = all.iter().filter(|r| !r["skipped"].is_null()).map(|r| format!("; {} not checked: {}", r["net"].as_str().unwrap_or(""), r["skipped"].as_str().unwrap_or(""))).collect(); if rs.is_empty() { format!("PDN: no rail checked{}.", sk) } else { format!("PDN (|Z| seen from each load pin against V·tol/step): {}{}.", rs.iter().map(|r| format!("{} {}× target at {} Hz (from {})", r["net"].as_str().unwrap_or(""), r["worst"]["ratio"], r["worst"]["hz"], r["load"].as_str().unwrap_or(""))).collect::<Vec<_>>().join(", "), if rs.iter().all(|r| r["spice"]["ran"] == true) { format!("; ngspice agrees within {} %{}", rs.iter().map(|r| r["spice"]["maxErrPct"].as_f64().unwrap_or(0.0)).fold(0.0, f64::max), sk) } else { sk }) } },
                        if s["review"]["compliance"]["ran"] == false { String::new() } else { let c = &s["review"]["compliance"]; let v = |t: &str| { let x = &c[t.split(' ').next().unwrap_or(t)]; if x["verdict"] == "not modelled" { format!("{t} not modelled") } else { format!("{t} {} (worst {} {}…{} dB)", x["verdict"].as_str().unwrap_or(""), x["worst"].as_str().unwrap_or(""), x["worstBand"][0], x["worstBand"][1]) } }; let f: Vec<String> = c["toSixDb"][0]["fixes"].as_array().into_iter().flatten().map(|x| x["id"].as_str().unwrap_or("").to_string()).collect(); format!("Compliance ({}): {}; {}{}.{} review.compliance has every source, the fixes by dB and a bench plan.", c["limit"].as_str().unwrap_or(""), v("radiated"), v("conducted"), if c["antenna"]["verdict"] == "not modelled" { String::new() } else { format!("; {}", v("antenna port")) }, if f.is_empty() { String::new() } else { format!(" Cheapest set to {}: {}.", if c["toSixDb"][0]["reaches6Db"] == true { "6 dB everywhere" } else { "get closest to 6 dB" }, f.join(", ")) }) },
                        { let i = &s["review"]["inputs"]; let top: Vec<String> = i["items"].as_array().into_iter().flatten().filter(|x| x["matters"] == "high").take(4).map(|x| format!("{}{}", x["what"].as_str().unwrap_or(""), x["part"]["mpn"].as_str().filter(|m| !m.is_empty()).map(|m| format!(" ({m})")).unwrap_or_default())).collect(); format!("Inputs assumed: {} that matter most, {} medium, {} low{}. review.inputs names each datasheet field and spec key; specPatch is the fragment to fill.", i["counts"]["high"], i["counts"]["medium"], i["counts"]["low"], if top.is_empty() { String::new() } else { format!(": {}", top.join("; ")) }) },
                        { let p = s["fixPlan"].as_array().cloned().unwrap_or_default(); if p.is_empty() { "Fix plan: nothing to change.".to_string() } else { format!("Fix plan ({} step(s), fields.json fixPlan has the actions): {}", p.len(), p.iter().take(5).map(|x| { let who = x["action"]["net"].as_str().or(x["action"]["ref"].as_str()).or(x["action"]["reference"].as_str()).map(|w| format!(" {w}")).unwrap_or_default(); format!("{}) [{}{}] {}", x["step"], x["kind"].as_str().unwrap_or(""), who, x["do"].as_str().unwrap_or("")) }).collect::<Vec<_>>().join(" ")) } },
                        if s["parts"].is_null() { "Parts: skipped (--no-parts).".to_string() } else { format!("Parts: {} with 3D models, {} as boxes, {} bare{}.", s["parts"]["modelled"], s["parts"]["boxed"], s["parts"]["bare"], if s["parts"]["failed"].as_array().map_or(false, |a| !a.is_empty()) { "; some models did not convert (see the log above; --models-dir adds a folder to search)" } else { "" }) },
                        { let mut v: Vec<String> = s["switchingLoads"].as_array().into_iter().flatten().map(|x| format!("{} (switching, {} A)", x["net"].as_str().unwrap_or(""), x["amps"])).collect(); if let Some(g) = s["groundReturn"].as_object() { v.push(format!("{} (return, {} A)", g["net"].as_str().unwrap_or(""), g["amps"])); } if v.is_empty() { "Loads: all from the spec.".to_string() } else { format!("Loads filled in (not in the spec): {}; set loads.<net> to change them.", v.join(", ")) } },
                        if s["heat"]["sourcesModelled"] == 0 { "Thermal: no heat sources modelled, so the temperature is only the copper's own I²R loss: this is not a thermal pass for any part. Add each dissipating part to spec hot (watts; tabNet if it has an exposed tab, else the watts go through all its leads by pad area).".to_string() } else { format!("Thermal: {} part(s) modelled as heat sources.", s["heat"]["sourcesModelled"]) },
                        { let (h, c, d) = (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())); if h + c + d == 0 { String::new() } else { format!("Heatsinks: {h}; signal capacitance checks: {c}; differential pairs: {d}. Signals are screening estimates; unsupported geometry is reported, never certified.") } },
                        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()),
                    ].into_iter().chain(s["modules"]["lines"].as_array().into_iter().flatten().filter_map(|l| l.as_str().map(String::from))).collect::<Vec<_>>());
                }
                Err(e) => err(&e, &["The board must be a .kicad_pcb saved with its zones filled; the spec is the aiflow spec (loads, hot, copperUm, hotMaxRiseC, thermalVias).".into()]),
            }
        }
        Cmd::Verify { board, spec, out, step, pitch, fields, refill } => match verify::run(&board, &spec, &out, &step, pitch, fields.as_deref(), refill) {
            Ok(v) => {
                let r = &v["report"];
                let mut steps: Vec<String> = r["steps"].as_array().into_iter().flatten().filter(|s| s["refused"].is_null()).map(|s| format!("step {} ({}): {} edit(s), issue {}", s["step"], s["kind"].as_str().unwrap_or(""), s["applied"].as_array().map_or(0, |a| a.iter().filter(|x| x["skipped"].is_null()).count()), if s["issueCleared"] == true { "cleared" } else { "still there" })).collect();
                let mut why: Vec<(String, Vec<String>)> = Vec::new();
                for s in r["steps"].as_array().into_iter().flatten().filter(|s| !s["refused"].is_null()) { let w = s["refused"].as_str().unwrap_or("").to_string(); let tag = format!("{} ({})", s["step"], s["kind"].as_str().unwrap_or("")); match why.iter_mut().find(|x| x.0 == w) { Some(x) => x.1.push(tag), None => why.push((w, vec![tag])) } }
                for (w, t) in why { steps.push(format!("not applied, step{} {}: {}", if t.len() > 1 { "s" } else { "" }, t.join(", "), w)); }
                let met: Vec<String> = r["metrics"].as_array().into_iter().flatten().take(12).map(|m| format!("{} {} → {} {}{}", m["metric"].as_str().unwrap_or(""), m["before"], m["after"], m["unit"].as_str().unwrap_or(""), if m["better"] == true { "" } else { " (worse)" })).collect();
                if v["applied"] == 0 { err("no step could be applied as a copper edit", &steps); }
                ok(&format!("{} step(s) applied; issues {} → {}; fixed board {}", v["applied"], r["issuesBefore"], r["issuesAfter"], v["board"].as_str().unwrap_or("")), &[
                    steps.join(" | "),
                    if met.is_empty() { "No solved number moved.".into() } else { format!("Changed ({} better, {} worse): {}. A peak density is one cell and can move to a new corner; Joule loss and temperatures are the robust measures.", r["better"], r["worse"], met.join("; ")) },
                    format!("Cleared: {}. New: {}.", r["cleared"].as_array().map_or(0, |a| a.len()), r["new"].as_array().map(|a| if a.is_empty() { "none".to_string() } else { a.iter().map(|i| format!("{} {}", i["severity"].as_str().unwrap_or(""), i["kind"].as_str().unwrap_or(""))).collect::<Vec<_>>().join(", ") }).unwrap_or_default()),
                    if v["refilled"] == true { let d = &v["drc"]; let nn = d["new"].as_array().map_or(0, |a| a.len()); if nn == 0 { format!("KiCad refilled both boards; DRC errors {} → {}, none new: the fixed board is clean to adopt.", d["errorsBefore"], d["errorsAfter"]) } else { format!("KiCad refilled both boards; {} new DRC error(s): {}. Fix those (or drop the step) before adopting.", nn, d["new"].as_array().unwrap().iter().take(4).map(|x| format!("{} at {}", x["type"].as_str().unwrap_or(""), x["items"][0]["at"])).collect::<Vec<_>>().join("; ")) } } else { "The zone fills are as saved: add --refill to have KiCad refill and DRC both boards, or refill and run DRC in KiCad before adopting.".into() },
                    format!("Details in {}.", out.join("verify.json").display()),
                ]);
            }
            Err(e) => err(&e, &["verify takes the same board and spec as analyze; `--step` picks fix-plan steps by number.".into()]),
        },
        Cmd::Report { fields, safety, mechanics, out } => match report::build(&fields, safety.as_deref(), mechanics.as_deref()) {
            Ok(r) => {
                let md = report::markdown(&r);
                if let Err(e) = std::fs::write(&out, &md).and_then(|_| std::fs::write(out.with_extension("json"), serde_json::to_string_pretty(&r).unwrap())) { err(&e.to_string(), &[]); }
                let mut hints: Vec<String> = r["areas"].as_array().into_iter().flatten().map(|a| format!("{}: {} ({})", a["title"].as_str().unwrap_or(""), a["status"].as_str().unwrap_or(""), a["why"].as_str().unwrap_or(""))).collect();
                hints.push(format!("{} and {} have every area's numbers and first findings; the fields solve's fixPlan, safety.json's and mechanics.json's fix plans have the actions.", out.display(), out.with_extension("json").display()));
                ok(&format!("{}: overall {}", r["board"].as_str().unwrap_or("board"), r["overall"].as_str().unwrap_or("")), &hints);
            }
            Err(e) => err(&e, &["--fields is an analyze output folder; --safety and --mechanics take those tools' output folders or json files.".into()]),
        },
        Cmd::Serve { fields, port, board, spec } => {
            if let (Some(bd), Some(sp)) = (&board, &spec) { if let Err(e) = run_analyze(bd, sp, &fields, 0.2, 60.0, Some(&[]), false, &[]) { err(&e, &[]); } }
            serve::serve(&fields, port);
        }
        Cmd::Silk { board, fields } => {
            let path = fields.join("fields.json");
            let mut f: serde_json::Value = std::fs::read_to_string(&path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_else(|| err(&format!("no fields.json in {}", fields.display()), &["Run `adom-fields analyze` first.".into()]));
            let s = silk::build(&board, &fields, &mut |s| eprintln!("{s}"));
            let none = s.is_null(); f["silk"] = s;
            std::fs::write(&path, slim::to_text(&f)).unwrap_or_else(|e| err(&e.to_string(), &[]));
            if none { err("no silkscreen exported", &["It needs a .kicad_pcb and kicad-cli (service-kicad, or KiCad through Adom Bridge).".into()]); }
            ok(&format!("silkscreen added: {}", path.display()), &["A running `serve` shows it on the next page load; `adom-fields ui view --silk off` hides it.".into()]);
        }
        Cmd::Parts { board, fields, models_dir } => {
            let path = fields.join("fields.json");
            let mut f: serde_json::Value = std::fs::read_to_string(&path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_else(|| err(&format!("no fields.json in {}", fields.display()), &["Run `adom-fields analyze` first.".into()]));
            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());
            // the parts' placements live in detail.json since 0.10.0 (a solve from before keeps them in fields.json)
            let dpath = fields.join(slim::DETAIL);
            match std::fs::read_to_string(&dpath).ok().and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok()) {
                Some(mut d) => { d["parts"] = p; std::fs::write(&dpath, serde_json::to_string(&d).unwrap()).unwrap_or_else(|e| err(&e.to_string(), &[])); }
                None => { f["parts"] = p; std::fs::write(&path, slim::to_text(&f)).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::Probe { fields, from, to, json } => match probe::compute(&fields, &from, &to) {
            Ok(v) => if json { println!("{}", serde_json::to_string_pretty(&v).unwrap()); } else { ok(&format!("{}: {} mV from {} to {} at {} A", v["net"].as_str().unwrap_or(""), v["dropMv"], v["from"].as_str().unwrap_or(""), v["to"].as_str().unwrap_or(""), v["amps"]), &[format!("The whole net spans {} mV; its densest copper is at {} on {} ({:.1} A/mm²), where widening buys the most.", v["netDropMv"], v["worstCell"]["at"], v["worstCell"]["layer"].as_str().unwrap_or("?"), v["worstCell"]["peakJ"].as_f64().unwrap_or(0.0)), "Budget it: spec loads.<net>.maxDropMv makes a drop over the budget an ir-drop issue with an action in fixPlan.".into()]); },
            Err(e) => err(&e, &["Pads are REF.PAD as in fields.json `pads`; --to may be the net's name for its farthest point.".into()]),
        },
        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} &`")]) },
        Cmd::Install => {
            let st = std::process::Command::new("adom-wiki").args(["skills", "install", "adom/adom-fields"]).status();
            match st { Ok(s) if s.success() => ok("skill installed from adom/adom-fields", &[]), _ => err("adom-wiki skills install adom/adom-fields failed", &["Is adom-wiki on the PATH and signed in?".into()]) }
        }
    }
}