main
John Lauer 0.10.0: lean by default; optional modules with board-specific hints; grouped findings; lean fields.json + detail.json;… 4bbb86d
//! The server: the app, the fields, the state, the console, the live channel, the drivers.
use crate::UiCmd;
use serde_json::{json, Value};
use std::io::Write;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};

pub fn get(port: u16, path: &str) -> Result<Value, String> { http(port, "GET", path, None) }
fn post(port: u16, path: &str, body: &Value) -> Result<Value, String> { http(port, "POST", path, Some(body)) }
fn http(port: u16, method: &str, path: &str, body: Option<&Value>) -> Result<Value, String> {
    use std::io::Read;
    let mut s = std::net::TcpStream::connect(("127.0.0.1", port)).map_err(|e| e.to_string())?;
    s.set_read_timeout(Some(std::time::Duration::from_secs(10))).ok();
    let b = body.map(|v| v.to_string()).unwrap_or_default();
    // HTTP/1.0 so the server answers with a Content-Length instead of chunks (a 40 KB fields.json comes chunked over 1.1)
    write!(s, "{method} {path} HTTP/1.0\r\nHost: 127.0.0.1\r\nUser-Agent: adom-fields-cli/{}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", env!("CARGO_PKG_VERSION"), b.len(), b).map_err(|e| e.to_string())?;
    let mut buf = String::new(); s.read_to_string(&mut buf).map_err(|e| e.to_string())?;
    let body = buf.split("\r\n\r\n").nth(1).unwrap_or("");
    serde_json::from_str(body).map_err(|e| format!("{e}: {}", body.chars().take(200).collect::<String>()))
}

struct App { dir: PathBuf, fields: Value, view: Value, console: Vec<String>, clients: Vec<std::sync::mpsc::Sender<String>>, started: String, seq: u64 }

/// The UI's own files, built into the binary; ADOM_FIELDS_UI_DIR=<src/ui> serves them from disk instead, so a
/// change to the page shows on reload without a rebuild (development only)
fn ui_file(name: &str, built_in: &'static str) -> String {
    match std::env::var("ADOM_FIELDS_UI_DIR") { Ok(dir) if !dir.is_empty() => std::fs::read_to_string(std::path::Path::new(&dir).join(name)).unwrap_or_else(|_| built_in.to_string()), _ => built_in.to_string() }
}

fn now() -> String { let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); format!("{t}") }

pub fn serve(dir: &PathBuf, port: u16) {
    let fields: Value = crate::slim::load(dir).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, "parts": "auto", "explode": 0, "speed": 1, "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() {
        let app = app.clone();
        std::thread::spawn(move || handle(req, app));
    }
}

fn broadcast(app: &mut App, event: &str, data: &Value) {
    let msg = format!("event: {event}\ndata: {}\n\n", data);
    app.clients.retain(|c| c.send(msg.clone()).is_ok());
}

fn handle(mut req: tiny_http::Request, app: Arc<Mutex<App>>) {
    let url = req.url().to_string();
    let path = url.split('?').next().unwrap_or("/").to_string();
    let method = req.method().as_str().to_string();
    let mut body = String::new();
    let _ = req.as_reader().read_to_string(&mut body);
    let jsonh = tiny_http::Header::from_bytes("Content-Type", "application/json").unwrap();
    let cors = tiny_http::Header::from_bytes("Access-Control-Allow-Origin", "*").unwrap();
    let reply = |req: tiny_http::Request, code: u16, v: Value| { let _ = req.respond(tiny_http::Response::from_string(v.to_string()).with_status_code(code).with_header(jsonh.clone()).with_header(cors.clone())); };
    match (method.as_str(), path.as_str()) {
        ("GET", "/") | ("GET", "/index.html") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("index.html", include_str!("ui/index.html"))).with_header(tiny_http::Header::from_bytes("Content-Type", "text/html; charset=utf-8").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); }
        ("GET", "/app.js") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("app.js", include_str!("ui/app.js"))).with_header(tiny_http::Header::from_bytes("Content-Type", "application/javascript").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); }
        ("GET", "/flow.js") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("flow.js", include_str!("ui/flow.js"))).with_header(tiny_http::Header::from_bytes("Content-Type", "application/javascript").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); }
        ("GET", "/noise.js") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("noise.js", include_str!("ui/noise.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", "/pdn.js") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("pdn.js", include_str!("ui/pdn.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", "/signals.js") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("signals.js", include_str!("ui/signals.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", "/ground.js") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("ground.js", include_str!("ui/ground.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", "/emc.js") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("emc.js", include_str!("ui/emc.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", "/magnetic.js") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("magnetic.js", include_str!("ui/magnetic.js"))).with_header(tiny_http::Header::from_bytes("Content-Type", "application/javascript").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); }
        ("GET", "/cycle.js") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("cycle.js", include_str!("ui/cycle.js"))).with_header(tiny_http::Header::from_bytes("Content-Type", "application/javascript").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); }
        ("GET", "/solids.js") => { let _ = req.respond(tiny_http::Response::from_string(ui_file("solids.js", include_str!("ui/solids.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", "/solids.json") => { let file = app.lock().unwrap().dir.join("solids.json"); match std::fs::read(&file) { Ok(bytes) => { let _ = req.respond(tiny_http::Response::from_data(bytes).with_header(jsonh.clone()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); } Err(_) => reply(req, 404, json!({"status": "error", "error": "no solids.json: this solve has no spec solids"})) } }
        ("GET", "/noise.json") => { let file = app.lock().unwrap().dir.join("noise.json"); match std::fs::read(&file) { Ok(bytes) => { let _ = req.respond(tiny_http::Response::from_data(bytes).with_header(jsonh.clone()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); } Err(_) => reply(req, 404, json!({"status": "error", "error": "no noise.json: run analyze with 0.7.8 or later"})) } }
        ("GET", "/flow.bin") => {
            // the signed current, the voltage and the owning net of every cell, for the flow and river views
            let file = app.lock().unwrap().dir.join("flow.bin");
            match std::fs::read(&file) { Ok(bytes) => { let _ = req.respond(tiny_http::Response::from_data(bytes).with_header(tiny_http::Header::from_bytes("Content-Type", "application/octet-stream").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); } Err(_) => reply(req, 404, json!({"status": "error", "error": "no flow.bin: this solve predates the flow views", "hint": "Run `adom-fields analyze` again with this version."})) }
        }
        ("POST", "/probe") => {
            let q: Value = serde_json::from_str(&body).unwrap_or(Value::Null);
            let dir = app.lock().unwrap().dir.clone();
            match crate::probe::compute(&dir, q["from"].as_str().unwrap_or(""), q["to"].as_str().unwrap_or("")) { Ok(v) => reply(req, 200, v), Err(e) => reply(req, 200, json!({"status": "error", "error": e})) }
        }
        ("GET", "/health") => reply(req, 200, json!({"status": "ok", "version": env!("CARGO_PKG_VERSION")})),
        ("GET", "/state") => { let a = app.lock().unwrap(); reply(req, 200, json!({"status": "ok", "view": a.view, "board": a.fields["board"], "nets": a.fields["nets"].as_array().map(|x| x.len()).unwrap_or(0), "issues": a.fields["issues"].as_array().map(|x| x.len()).unwrap_or(0), "clients": a.clients.len(), "started": a.started, "_hint": "POST /ui/view {quantity (current|heat|temperature|signals|flow|river|energy|light|cycle|charge|magnetic|emc|ground|noise|pdn), probe mm (the magnetic sheet's height), explode 0..1, speed 0.1..4, net, layer, camera (top|iso|bottom|fit|zoom), air, parts: auto|ghost|solid|off, drift, focus: [x, y] mm for camera zoom}; /ui/select {net|chip}; /ui/issue {index}; /ui/toast {text, kind}; GET /events is the SSE channel; GET /fields.json is everything solved."})); }
        ("GET", "/fields.json") => {
            // re-read: `adom-fields parts` may have added the 3D models since the server started
            let mut a = app.lock().unwrap();
            if let Some(f) = crate::slim::load(&a.dir) { 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") => {
            let (tx, rx) = std::sync::mpsc::channel::<String>();
            let first = { let mut a = app.lock().unwrap(); a.clients.push(tx); format!("event: state\ndata: {}\n\n", json!({"view": a.view})) };
            let mut w = req.into_writer();
            let _ = write!(w, "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache\r\nAccess-Control-Allow-Origin: *\r\nConnection: keep-alive\r\n\r\n{first}");
            let _ = w.flush();
            loop {
                match rx.recv_timeout(std::time::Duration::from_secs(15)) {
                    Ok(m) => { if write!(w, "{m}").is_err() || w.flush().is_err() { break; } }
                    Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { if write!(w, ": ping\n\n").is_err() || w.flush().is_err() { break; } }
                    Err(_) => break,
                }
            }
        }
        ("POST", "/ui/view") | ("POST", "/ui/select") | ("POST", "/ui/issue") => {
            let v: Value = serde_json::from_str(&body).unwrap_or(json!({}));
            let mut a = app.lock().unwrap();
            // the Power and Noise views draw an optional module's results: say how to get them rather than show nothing
            if path == "/ui/view" { if let Some(q) = v["quantity"].as_str() { let m = match q { "pdn" => Some(("pdn", "pdn")), "noise" => Some(("noise", "noise")), _ => None };
                if let Some((key, module)) = m { if a.fields["review"][key]["ran"] == false { drop(a); reply(req, 409, json!({"status": "error", "error": format!("the {q} view needs the {module} module, which this solve did not run"), "hint": format!("Re-run `adom-fields analyze ... --with {module}` (or --with all) on this board, then reload the app.")})); return; } } } }
            let before = a.view.clone();
            if path == "/ui/issue" {
                let i = v["index"].as_u64().unwrap_or(0) as usize;
                let Some(issue) = a.fields["issues"].get(i).cloned() else { drop(a); reply(req, 404, json!({"status": "error", "error": "no such issue", "hint": "GET /fields.json .issues lists them, 0-based"})); return; };
                a.view["issue"] = json!(i); a.view["net"] = issue["net"].clone(); if let Some(l) = issue["layer"].as_str() { a.view["layer"] = json!(l); }
                // each finding opens the view that shows it
                a.view["quantity"] = json!(match issue["kind"].as_str() { Some("current-neck") | Some("ampacity") => "current", Some("junction-temperature") | Some("capacitor-temperature") => "temperature", Some("cable-emissions") | Some("conducted-emissions") | Some("clock-outer-layer") | Some("clock-near-connector") | Some("edge-signal") | Some("io-unfiltered") | Some("input-filter-cutoff") => "emc", Some("impedance") | Some("diff-impedance") | Some("via-stub") | Some("length-skew") | Some("stackup-far-reference") | Some("stackup-adjacent-signals") => "signals", Some("lo-leak") | Some("stage-isolation") | Some("lo-emission") => "noise", Some("hot-loop") | Some("emissions") | Some("no-reference") => "emc", Some("pour-fragmented") | Some("ground-offset") | Some("ground-island") | Some("stitching-gap") | Some("ground-loop-risk") => "ground", Some("cavity-resonance") | Some("crosstalk") => "noise", Some("pdn-impedance") | Some("input-filter-stability") | Some("hot-plug-overshoot") => "pdn", Some("decoupling-far") | Some("decoupling-missing") | Some("esd-unprotected") => "emc", Some("ir-drop") => "river", Some("output-ripple") | Some("inductor-ripple") => "cycle", _ => "heat" });
                a.view["camera"] = json!("zoom"); a.view["focus"] = issue["at"].clone();
            } else {
                for k in ["quantity", "net", "layer", "camera", "air", "chip", "drift", "parts", "focus", "explode", "speed", "ghost", "silk", "probe"] { if !v[k].is_null() { a.view[k] = v[k].clone(); } }
                if v["net"].as_str() == Some("") { a.view["net"] = Value::Null; }
                // the views whose field lives between or just above the copper (issue 18): on entering one, open the stack,
                // put the parts out of the way and look from low on the side, unless the command set those itself or
                // asked to keep its camera (keepCamera)
                let entered = v["quantity"].as_str().filter(|q| before["quantity"].as_str() != Some(*q));
                if let (Some(q), false) = (entered, v["keepCamera"].as_bool() == Some(true)) {
                    let pick: Option<(f64, &str, &str)> = match q { "energy" => Some((0.8, "off", "front")), "flow" => Some((0.6, "ghost", "low")), "magnetic" => Some((0.0, "off", "low")), _ => None };
                    if let Some((ex, parts, cam)) = pick {
                        // remember the view as it was before the first of these views, to put it back on leaving them
                        if a.view["restore"].is_null() { a.view["restore"] = json!({"explode": before["explode"], "parts": before["parts"], "camera": before["camera"], "focus": before["focus"]}); }
                        if v["explode"].is_null() { a.view["explode"] = json!(ex); }
                        if v["parts"].is_null() { a.view["parts"] = json!(parts); }
                        if v["camera"].is_null() { a.view["camera"] = json!(cam); a.view["focus"] = Value::Null; }
                    }
                }
                if let Some(q) = entered { if !["energy", "flow", "magnetic"].contains(&q) && !a.view["restore"].is_null() {
                    let r = a.view["restore"].clone();
                    for k in ["explode", "parts", "camera", "focus"] { if v[k].is_null() { a.view[k] = r[k].clone(); } }
                    a.view["restore"] = Value::Null;
                } }
                if path == "/ui/select" { a.view["issue"] = Value::Null; if let Some(c) = v["chip"].as_str() { if let Some(ch) = a.fields["chips"].as_array().and_then(|x| x.iter().find(|x| x["reference"].as_str() == Some(c))).cloned() { a.view["focus"] = json!([ch["x"], ch["y"]]); a.view["camera"] = json!("zoom"); a.view["quantity"] = json!("heat"); } } }
            }
            // every command gets a sequence number and the keys it changed, so the page animates the
            // camera only when a command moved it, and never on a poll that merely re-read the view
            a.seq += 1;
            let seq = a.seq;
            let mut changed: Vec<String> = Vec::new();
            if let (Some(bo), Some(no)) = (before.as_object(), a.view.as_object()) { for (k, val) in no { if k == "seq" || k == "changed" { continue; } if bo.get(k) != Some(val) { changed.push(k.clone()); } } }
            if path == "/ui/view" && !v["camera"].is_null() && !changed.iter().any(|k| k == "camera") { changed.push("camera".into()); }
            if path == "/ui/issue" || (path == "/ui/select" && !v["chip"].is_null()) { if !changed.iter().any(|k| k == "focus") { changed.push("focus".into()); } }
            a.view["seq"] = json!(seq); a.view["changed"] = json!(changed);
            let view = a.view.clone();
            broadcast(&mut a, "view", &view);
            reply(req, 200, json!({"status": "ok", "view": view}));
        }
        ("POST", "/narrate") => {
            // the page sends the explanation it composed; adom-tts voices it once per text (the file name is
            // the text's hash), so a second listen, or another viewer, costs nothing
            let v: Value = serde_json::from_str(&body).unwrap_or(json!({}));
            let text: String = v["text"].as_str().unwrap_or("").chars().take(3000).collect();
            if text.trim().is_empty() { reply(req, 400, json!({"status": "error", "error": "no text to speak"})); return; }
            let dir = app.lock().unwrap().dir.join("narration");
            let _ = std::fs::create_dir_all(&dir);
            use sha2::{Digest, Sha256};
            let name = format!("{}-{}.mp3", v["view"].as_str().unwrap_or("view").chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>(), &format!("{:x}", Sha256::digest(text.as_bytes()))[..12]);
            let file = dir.join(&name);
            let cached = file.exists();
            if !cached {
                let src = dir.join(format!("{name}.txt"));
                let _ = std::fs::write(&src, &text);
                let out = std::process::Command::new("adom-tts").args(["say", &format!("@{}", src.display()), "--out", &file.display().to_string()]).env("ADOM_TTS_NO_HINTS", "1").output();
                match out {
                    Ok(o) if o.status.success() && file.exists() => {}
                    Ok(o) => { reply(req, 502, json!({"status": "error", "error": format!("adom-tts could not voice it: {}", String::from_utf8_lossy(&o.stderr).lines().last().unwrap_or("")), "hint": "adom-tts health"})); return; }
                    Err(_) => { reply(req, 503, json!({"status": "error", "error": "adom-tts is not installed", "hint": "adom-wiki pkg install adom/adom-tts"})); return; }
                }
            }
            reply(req, 200, json!({"status": "ok", "url": format!("narration/{name}"), "cache": if cached { "cached" } else { "voiced" }}));
        }
        ("GET", p) if p.starts_with("/silk-") && p.ends_with(".svg") => {
            let name = p.trim_start_matches('/').replace("..", "").replace('/', "");
            let file = app.lock().unwrap().dir.join(&name);
            match std::fs::read(&file) { Ok(bytes) => { let _ = req.respond(tiny_http::Response::from_data(bytes).with_header(tiny_http::Header::from_bytes("Content-Type", "image/svg+xml").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); } Err(_) => reply(req, 404, json!({"status": "error", "error": "no silkscreen", "hint": "adom-fields silk --board <pcb> --fields <dir>"})) }
        }
        ("GET", p) if p.starts_with("/narration/") && p.ends_with(".mp3") => {
            let name = p.trim_start_matches("/narration/").replace("..", "").replace('/', "");
            let file = app.lock().unwrap().dir.join("narration").join(&name);
            match std::fs::read(&file) { Ok(bytes) => { let _ = req.respond(tiny_http::Response::from_data(bytes).with_header(tiny_http::Header::from_bytes("Content-Type", "audio/mpeg").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "public, max-age=86400").unwrap())); } Err(_) => reply(req, 404, json!({"status": "error", "error": "no such narration"})) }
        }
        ("POST", "/ui/explain") => { let v: Value = serde_json::from_str(&body).unwrap_or(json!({})); let mut a = app.lock().unwrap(); broadcast(&mut a, "explain", &json!({"quantity": v["quantity"], "stop": v["stop"].as_bool().unwrap_or(false)})); reply(req, 200, json!({"status": "ok", "clients": a.clients.len()})); }
        ("POST", "/ui/toast") => { let v: Value = serde_json::from_str(&body).unwrap_or(json!({})); let mut a = app.lock().unwrap(); broadcast(&mut a, "toast", &json!({"text": v["text"], "kind": v["kind"].as_str().unwrap_or("info"), "sticky": v["kind"].as_str() == Some("error")})); reply(req, 200, json!({"status": "ok"})); }
        ("GET", p) if p.starts_with("/vendor/3d-viewer/") => {
            // the canonical Adom 3D viewer (adom/adom-3d-viewer), embedded in the binary
            let name = p.trim_start_matches("/vendor/3d-viewer/").split('?').next().unwrap_or("");
            match crate::vendor::vendor_file(name) {
                Some((bytes, ct)) => { let _ = req.respond(tiny_http::Response::from_data(bytes.to_vec()).with_header(tiny_http::Header::from_bytes("Content-Type", ct).unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "public, max-age=86400").unwrap()).with_header(cors.clone())); }
                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);
            match std::fs::read(&file) { Ok(bytes) => { let _ = req.respond(tiny_http::Response::from_data(bytes).with_header(tiny_http::Header::from_bytes("Content-Type", "image/png").unwrap()).with_header(tiny_http::Header::from_bytes("Cache-Control", "no-cache").unwrap())); } Err(_) => reply(req, 404, json!({"status": "error", "error": "no such texture"})) }
        }
        _ => reply(req, 404, json!({"status": "error", "error": format!("no route {method} {path}"), "hint": "GET / (the app), /state, /console, /events, /fields.json, /tex/<name>; POST /ui/view, /ui/select, /ui/issue, /ui/toast, /console"})),
    }
}

pub fn ui(what: UiCmd, port: u16) {
    let up = |p: &str, v: &Value| -> Value { post(port, p, v).unwrap_or_else(|e| crate::err(&format!("the app did not answer on {port}: {e}"), &[format!("Start it: `adom-fields serve --fields <dir> --port {port} &`")])) };
    match what {
        UiCmd::View { quantity, net, layer, camera, air, parts, at, explode, speed, ghost, silk, probe, keep_camera } => { let focus = at.as_deref().map(|t| { let v: Vec<f64> = t.split(',').filter_map(|x| x.trim().parse().ok()).collect(); if v.len() != 2 { crate::err("--at takes x,y in board mm, e.g. --at 113.9,90.5", &[]); } json!(v) }); if let Some(p) = &parts { if !["auto", "ghost", "solid", "off"].contains(&p.as_str()) { crate::err(&format!("--parts {p}: use auto, ghost, solid or off"), &[]); } } if let Some(q) = &quantity { if !["current", "heat", "temperature", "signals", "flow", "river", "energy", "light", "cycle", "charge", "magnetic", "emc", "ground", "noise", "pdn"].contains(&q.as_str()) { crate::err(&format!("--quantity {q}: use current, heat, temperature, signals, flow, river, energy, light, cycle, charge, magnetic, emc, ground, noise or pdn"), &[]); } } let r = up("/ui/view", &json!({"quantity": quantity, "net": net, "layer": layer, "camera": camera, "air": air, "parts": parts, "focus": focus, "explode": explode, "speed": speed, "ghost": ghost, "silk": silk.as_deref().map(|s| s != "off" && s != "false"), "probe": probe, "keepCamera": if keep_camera { Some(true) } else { None }})); if r["status"] == "error" { crate::err(r["error"].as_str().unwrap_or("the app refused the view"), &[r["hint"].as_str().unwrap_or("").to_string()]); } crate::ok(&format!("view: {}", r["view"]), &["The app repainted; a screenshot of the window shows it. `ui select --net <net>` lights one net; `ui issue --index n` flies to an issue.".into()]); }
        UiCmd::Explain { quantity, stop } => { let r = up("/ui/explain", &json!({"quantity": quantity, "stop": stop})); crate::ok(&format!("{} to {} page(s)", if stop { "stop sent" } else { "explain sent" }, r["clients"]), &["The page composes the explanation from this board's numbers and plays it with a one-line subtitle; a browser may hold the first audio until the viewer has clicked the page once.".into()]); }
        UiCmd::Toast { text, kind } => { up("/ui/toast", &json!({"text": text, "kind": kind})); crate::ok("toast shown", &[]); }
        UiCmd::Select { net, chip } => { let r = up("/ui/select", &json!({"net": net, "chip": chip})); crate::ok(&format!("selected: {}", r["view"]), &[]); }
        UiCmd::Issue { index } => { let r = up("/ui/issue", &json!({"index": index})); crate::ok(&format!("issue {index} in view: {}", r["view"]), &[]); }
        UiCmd::Probe { from, to } => {
            let v = up("/probe", &json!({"from": from, "to": to}));
            if v["status"] == "error" { crate::err(v["error"].as_str().unwrap_or("probe failed"), &[]); }
            let _ = post(port, "/ui/view", &json!({"quantity": "river", "net": v["net"], "focus": v["fromAt"], "camera": "zoom"}));
            let _ = post(port, "/ui/toast", &json!({"text": format!("{}: {} mV from {} to {}", v["net"].as_str().unwrap_or(""), v["dropMv"], v["from"].as_str().unwrap_or(""), v["to"].as_str().unwrap_or("")), "kind": "info"}));
            crate::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!("Shown in the river view on {}; its densest copper is at {} on {}.", v["net"].as_str().unwrap_or(""), v["worstCell"]["at"], v["worstCell"]["layer"].as_str().unwrap_or("?"))]);
        }
        UiCmd::State => { let v = get(port, "/state").unwrap_or_else(|e| crate::err(&e, &[])); println!("{}", serde_json::to_string_pretty(&v).unwrap()); }
        UiCmd::Console => { let v = get(port, "/console").unwrap_or_else(|e| crate::err(&e, &[])); for l in v["lines"].as_array().cloned().unwrap_or_default() { println!("{}", l.as_str().unwrap_or("")); } }
    }
}

/// The walkthrough: overview, each field on each outer layer, the nets by relevance, the hot chips, the issues.
pub fn tour(port: u16, hold_ms: u64) {
    let f = get(port, "/fields.json").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} &`")]));
    // a calm walkthrough: few beats, each held while the board drifts a few degrees, so the clip
    // still reads as motion when the flow speeds it up ten times
    let hold = |ms: u64| std::thread::sleep(std::time::Duration::from_millis(ms));
    let step = |p: &str, v: Value, ms: u64| { let _ = post(port, p, &v); hold(ms); };
    let toast = |t: &str| { let _ = post(port, "/ui/toast", &json!({"text": t, "kind": "info"})); };
    let pours: Vec<String> = f["pourNets"].as_array().cloned().unwrap_or_default().iter().filter_map(|p| p["net"].as_str().map(str::to_string)).take(4).collect();
    let mut steps = 0;
    let settle = hold_ms.max(4000);
    let _ = post(port, "/ui/view", &json!({"drift": true}));
    toast("Heat flow in the copper, the top layer"); step("/ui/view", json!({"quantity": "heat", "net": "", "layer": "F.Cu", "camera": "top", "issue": null, "chip": null}), settle); steps += 1;
    toast("The same board, tilted"); step("/ui/view", json!({"camera": "iso"}), settle); steps += 1;
    for net in &pours { toast(&format!("The {net} pour on its own")); step("/ui/select", json!({"net": net}), settle); steps += 1; }
    toast("Turning the board over to the bottom copper"); step("/ui/view", json!({"net": "", "layer": "B.Cu", "camera": "bottom"}), settle); steps += 1;
    for net in pours.iter().take(3) { toast(&format!("{net} on the bottom")); step("/ui/select", json!({"net": net}), settle); steps += 1; }
    toast("Temperature over the whole board, still air"); step("/ui/view", json!({"quantity": "temperature", "air": "still", "net": "", "layer": "F.Cu", "camera": "top"}), settle); steps += 1;
    toast("The same board in moving air"); step("/ui/view", json!({"air": "moving"}), settle); steps += 1;
    if !f["flow"].is_null() {
        // the flow and the river (0.7): the same solve as moving charge, then as water over the voltages
        toast("The current as charge: speed and glow follow the density"); step("/ui/view", json!({"quantity": "flow", "air": "still", "net": "", "layer": "F.Cu", "camera": "iso", "explode": 0}), settle); steps += 1;
        toast("The layers pulled apart: the current drops through the vias"); step("/ui/view", json!({"explode": 1}), settle); steps += 1;
        toast("The river: every net at its voltage, the current running downhill"); step("/ui/view", json!({"quantity": "river", "explode": 0, "camera": "iso"}), settle); steps += 1;
        toast("The loads fall to ground and the sources lift it back up"); step("/ui/view", json!({"explode": 1}), settle); steps += 1;
        toast("Energy: it travels in the fields between the layers, while the electrons crawl"); step("/ui/view", json!({"quantity": "energy", "explode": 0.6, "camera": "front"}), settle * 2); steps += 1;
        toast("Light: how fast a change crosses the board, and when it reaches each part"); step("/ui/view", json!({"quantity": "light", "explode": 0, "camera": "top"}), settle); steps += 1;
        toast("The energy cycle: input caps, the inductor's magnetic field, the output, one switching period"); step("/ui/view", json!({"quantity": "cycle", "camera": "iso"}), settle); steps += 1;
        toast("Charge: the board as capacitors"); step("/ui/view", json!({"quantity": "charge", "explode": 0.6, "camera": "iso"}), settle); steps += 1;
        toast("Magnetic: the field every current makes"); step("/ui/view", json!({"quantity": "magnetic", "explode": 0, "camera": "iso"}), settle); steps += 1;
        toast("EMC: the hot loop, the return path and the estimated emissions"); step("/ui/view", json!({"quantity": "emc", "camera": "top"}), settle); steps += 1;
        toast("Ground: its own voltage drop, the loops, the stitching fence"); step("/ui/view", json!({"quantity": "ground", "camera": "iso"}), settle); steps += 1;
        toast("Noise: the planes ringing, the switching edges reaching quiet nets"); step("/ui/view", json!({"quantity": "noise", "camera": "iso"}), settle); steps += 1;
        toast("Power: each rail's impedance across frequency, and which capacitor carries it"); step("/ui/view", json!({"quantity": "pdn", "camera": "iso"}), settle); steps += 1;
        step("/ui/view", json!({"explode": 0}), 0);
    }
    toast("Back to the current density"); step("/ui/view", json!({"quantity": "current", "air": "still", "camera": "iso"}), settle); steps += 1;
    for (i, is) in f["issues"].as_array().cloned().unwrap_or_default().iter().enumerate().take(2) { toast(&format!("Issue {}: {}", i + 1, is["what"].as_str().unwrap_or(""))); step("/ui/issue", json!({"index": i}), settle); steps += 1; }
    step("/ui/view", json!({"camera": "iso", "issue": null, "net": ""}), settle);
    crate::ok(&format!("tour done: {steps} beats, {} ms each, the board drifting gently through every one", settle), &["Record the window while this runs (the flow's `tour fields` does it) to make it a clip.".into()]);
}