app
adom-layout-viewer
Public Made by Adomby adom
Interactive PCB layout viewer: your EDA's own render plus live net/trace highlighting and per-pad stock, price and wiki lookup
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
//! adom-layout-viewer live app server — the AI-drivable surface + the embeddable live PCB
//! view (same app-creator contract as adom-footprint/adom-symbol). Routes:
//! GET / (UI), /viewer.js, /favicon.svg, /state (poller), POST /load (push a
//! .kicad_pcb), GET /enrich (component sidecar), /eval + /console, /shutdown.
use anyhow::Result;
use serde_json::{json, Value};
use std::collections::{HashMap, VecDeque};
use std::io::Read;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use tiny_http::{Header, Method, Response, Server};
#[derive(Default, Clone)]
pub struct AppState {
pub name: String,
pub svg: String,
pub meta_json: String, // serialized RenderMeta
pub kicad_pcb: String, // retained for re-render / delivery
}
static STATE: Mutex<Option<AppState>> = Mutex::new(None);
static EVAL_PENDING: Mutex<VecDeque<(u64, String)>> = Mutex::new(VecDeque::new());
static EVAL_RESULTS: Mutex<Option<HashMap<u64, Value>>> = Mutex::new(None);
static CONSOLE: Mutex<Option<Vec<String>>> = Mutex::new(None);
static ENRICH_CACHE: Mutex<Option<HashMap<String, String>>> = Mutex::new(None);
static ENRICH_PENDING: Mutex<Option<std::collections::HashSet<String>>> = Mutex::new(None);
static EVAL_ID: AtomicU64 = AtomicU64::new(1);
// AI channel: narrate (POST /ui/toast) + drive (POST /ui/cmd); the UI drains both
// via GET /ui/pull. (Complements the JS /eval channel with a user-visible one.)
static TOASTS: Mutex<Vec<Value>> = Mutex::new(Vec::new());
static CMDS: Mutex<Vec<Value>> = Mutex::new(Vec::new());
const APP_HTML: &str = include_str!("app.html");
const VIEWER_JS: &str = include_str!("viewer.js");
const FAVICON: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#00e6dc" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8" cy="8" r="1.4"/><circle cx="16" cy="16" r="1.4"/><path d="M8 9.5v3l4 3.5"/></svg>"##;
pub fn set_state(s: AppState) { *STATE.lock().unwrap() = Some(s); }
pub fn load_pcb(name: &str, kicad_pcb: &str) -> Result<()> {
let (svg, meta) = crate::pcb_render::render_board(kicad_pcb);
// pre-warm enrichment for every component that carries an MPN, so hover is
// instant. Sequential in ONE background thread — vendor APIs are slow and we
// don't want to fan out N live requests at once.
let mpns: Vec<(String, String)> = meta.components.iter()
.filter(|c| !c.mpn.is_empty())
.map(|c| (c.mpn.clone(), c.lcsc.clone())).collect();
set_state(AppState {
name: name.to_string(), svg,
meta_json: serde_json::to_string(&meta)?,
kicad_pcb: kicad_pcb.to_string(),
});
if !mpns.is_empty() {
std::thread::spawn(move || {
for (mpn, lcsc) in mpns { let _ = enrich_fill(&mpn, &lcsc); }
});
}
Ok(())
}
/// Compute (blocking) and cache one enrichment record. Returns the JSON.
fn enrich_fill(mpn: &str, lcsc: &str) -> String {
let key = format!("{mpn}|{lcsc}");
let out = crate::enrich::lookup(mpn, lcsc);
if let Some(m) = ENRICH_CACHE.lock().unwrap().as_mut() { m.insert(key.clone(), out.clone()); }
if let Some(p) = ENRICH_PENDING.lock().unwrap().as_mut() { p.remove(&key); }
out
}
fn state_json() -> String {
match &*STATE.lock().unwrap() {
Some(s) => format!(r#"{{"name":{},"svg":{},"meta":{}}}"#,
serde_json::to_string(&s.name).unwrap(),
serde_json::to_string(&s.svg).unwrap(),
if s.meta_json.is_empty() { "{}" } else { &s.meta_json }),
None => "{}".to_string(),
}
}
pub fn run(port: u16) -> Result<()> {
*EVAL_RESULTS.lock().unwrap() = Some(HashMap::new());
*CONSOLE.lock().unwrap() = Some(Vec::new());
*ENRICH_CACHE.lock().unwrap() = Some(HashMap::new());
*ENRICH_PENDING.lock().unwrap() = Some(std::collections::HashSet::new());
let server = Server::http(format!("0.0.0.0:{port}")).map_err(|e| anyhow::anyhow!("bind {port}: {e}"))?;
eprintln!("[adom-layout-viewer] serving on http://127.0.0.1:{port}");
register_port(port, "adom-layout-viewer", "Interactive PCB layout view (AI-drivable)");
for mut req in server.incoming_requests() {
let raw = req.url().to_string();
let url = raw.split('?').next().unwrap_or("/").to_string();
let query = raw.splitn(2, '?').nth(1).unwrap_or("").to_string();
let method = req.method().clone();
let mut body = String::new();
if matches!(method, Method::Post) { let _ = req.as_reader().read_to_string(&mut body); }
let resp = route(&method, &url, &query, &body);
let _ = req.respond(resp);
if url == "/shutdown" && matches!(method, Method::Post) { break; }
}
unregister_port(port);
Ok(())
}
fn route(method: &Method, url: &str, query: &str, body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
match (method, url) {
(Method::Get, "/") | (Method::Get, "/index.html") => resp(APP_HTML, "text/html; charset=utf-8"),
(Method::Get, "/viewer.js") => resp(VIEWER_JS, "application/javascript"),
(Method::Get, "/favicon.svg") => resp(FAVICON, "image/svg+xml"),
(Method::Get, "/state") => json_resp(&state_json()),
(Method::Get, "/version") => json_resp(&json!({ "version": env!("CARGO_PKG_VERSION"), "name": "adom-layout-viewer" }).to_string()),
(Method::Post, "/ui/toast") => {
if let Ok(v) = serde_json::from_str::<Value>(body) {
let msg = v.get("message").and_then(|x| x.as_str()).unwrap_or("");
let ty = v.get("type").and_then(|x| x.as_str()).unwrap_or("info");
if !msg.is_empty() { TOASTS.lock().unwrap().push(json!({ "message": msg, "type": ty })); }
}
json_resp("{\"ok\":true}")
}
(Method::Post, "/ui/cmd") => {
if let Ok(v) = serde_json::from_str::<Value>(body) {
if v.get("action").and_then(|x| x.as_str()).map(|s| !s.is_empty()).unwrap_or(false) { CMDS.lock().unwrap().push(v); }
}
json_resp("{\"ok\":true}")
}
(Method::Get, "/ui/pull") => {
let toasts: Vec<Value> = std::mem::take(&mut *TOASTS.lock().unwrap());
let cmds: Vec<Value> = std::mem::take(&mut *CMDS.lock().unwrap());
json_resp(&json!({ "toasts": toasts, "cmds": cmds }).to_string())
}
(Method::Get, "/enrich") => json_resp(&enrich(query)),
(Method::Post, "/load") => {
if let Ok(v) = serde_json::from_str::<Value>(body) {
if let Some(pcb) = v.get("kicadPcb").and_then(|x| x.as_str()) {
let name = v.get("name").and_then(|x| x.as_str()).unwrap_or("board").to_string();
match load_pcb(&name, pcb) {
Ok(()) => return json_resp("{\"ok\":true}"),
Err(e) => return json_err(422, &e.to_string()),
}
}
set_state(AppState {
name: v.get("name").and_then(|x| x.as_str()).unwrap_or("").to_string(),
svg: v.get("svg").and_then(|x| x.as_str()).unwrap_or("").to_string(),
meta_json: v.get("meta").map(|m| m.to_string()).unwrap_or_else(|| "{}".into()),
kicad_pcb: String::new(),
});
}
json_resp("{\"ok\":true}")
}
(Method::Post, "/eval") => {
let code = serde_json::from_str::<Value>(body).ok()
.and_then(|v| v.get("code").and_then(|c| c.as_str()).map(String::from)).unwrap_or_default();
let id = EVAL_ID.fetch_add(1, Ordering::SeqCst);
EVAL_PENDING.lock().unwrap().push_back((id, code));
json_resp(&json!({ "id": id.to_string() }).to_string())
}
(Method::Get, "/eval/pending") => match EVAL_PENDING.lock().unwrap().pop_front() {
Some((id, code)) => json_resp(&json!({ "id": id.to_string(), "code": code }).to_string()),
None => json_resp("{}"),
},
(Method::Get, "/console") => {
let log = CONSOLE.lock().unwrap().clone().unwrap_or_default();
json_resp(&json!({ "lines": log }).to_string())
}
(Method::Post, "/console") => {
if let Ok(v) = serde_json::from_str::<Value>(body) {
let line = format!("[{}] {}", v.get("level").and_then(|l| l.as_str()).unwrap_or("log"), v.get("msg").and_then(|m| m.as_str()).unwrap_or(""));
if let Some(buf) = CONSOLE.lock().unwrap().as_mut() { buf.push(line); let n = buf.len(); if n > 500 { buf.drain(0..n - 500); } }
}
json_resp("{\"ok\":true}")
}
(Method::Post, "/shutdown") => json_resp("{\"ok\":true}"),
_ if url.starts_with("/eval/") => {
let rest = &url["/eval/".len()..];
if let Some(id_str) = rest.strip_suffix("/result") {
if matches!(method, Method::Post) {
if let (Ok(id), Ok(v)) = (id_str.parse::<u64>(), serde_json::from_str::<Value>(body)) {
if let Some(map) = EVAL_RESULTS.lock().unwrap().as_mut() { map.insert(id, v); }
}
return json_resp("{\"ok\":true}");
}
}
if let Ok(id) = rest.parse::<u64>() {
let map = EVAL_RESULTS.lock().unwrap();
if let Some(r) = map.as_ref().and_then(|m| m.get(&id)) {
return json_resp(&json!({ "done": true, "result": r }).to_string());
}
return json_resp("{\"done\":false}");
}
not_found()
}
_ => not_found(),
}
}
/// Component enrichment: mpn (+optional lcsc) → price/stock/wiki/popularity.
/// Delegates to sibling Adom CLIs (adom-parts-search, adom-wiki-cli); cached
/// in-process. Best-effort — never blocks the UI, returns {} on any failure.
fn enrich(query: &str) -> String {
let params = parse_query(query);
let mpn = match params.get("mpn") { Some(m) if !m.is_empty() => m.clone(), _ => return "{}".into() };
let lcsc = params.get("lcsc").cloned().unwrap_or_default();
let key = format!("{mpn}|{lcsc}");
if let Some(hit) = ENRICH_CACHE.lock().unwrap().as_ref().and_then(|m| m.get(&key).cloned()) { return hit; }
// not cached — kick a one-off background fill (deduped) and tell the client
// to retry. NEVER block the single-threaded server loop on a vendor call.
let already = { let mut g = ENRICH_PENDING.lock().unwrap(); let set = g.get_or_insert_with(Default::default); !set.insert(key.clone()) };
if !already {
std::thread::spawn(move || { let _ = enrich_fill(&mpn, &lcsc); });
}
"{\"pending\":true}".into()
}
fn parse_query(q: &str) -> HashMap<String, String> {
q.split('&').filter_map(|kv| {
let mut it = kv.splitn(2, '=');
Some((it.next()?.to_string(), urldecode(it.next().unwrap_or(""))))
}).collect()
}
fn urldecode(s: &str) -> String {
let b = s.as_bytes(); let mut out = String::new(); let mut i = 0;
while i < b.len() {
match b[i] {
b'%' if i + 2 < b.len() => { if let Ok(c) = u8::from_str_radix(&s[i + 1..i + 3], 16) { out.push(c as char); i += 3; } else { out.push('%'); i += 1; } }
b'+' => { out.push(' '); i += 1; }
c => { out.push(c as char); i += 1; }
}
}
out
}
// ── HD port registration (best-effort; no-op outside Hydrogen Desktop) ──
fn register_port(port: u16, name: &str, reason: &str) {
let body = format!("{{\"port\":{port},\"action\":\"register\",\"name\":\"{name}\",\"reason\":\"{reason}\",\"expose\":\"internal\"}}");
std::thread::spawn(move || hd_post(&body));
}
fn unregister_port(port: u16) { hd_post(&format!("{{\"port\":{port},\"action\":\"unregister\"}}")); }
fn hd_post(body: &str) {
use std::io::Write;
use std::net::{TcpStream, ToSocketAddrs};
use std::time::Duration;
let addr = match ("host.docker.internal", 47084u16).to_socket_addrs().ok().and_then(|mut a| a.next()) { Some(a) => a, None => return };
if let Ok(mut s) = TcpStream::connect_timeout(&addr, Duration::from_millis(800)) {
let _ = s.set_write_timeout(Some(Duration::from_millis(800)));
let req = format!("POST /port-forward HTTP/1.1\r\nHost: host.docker.internal\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body);
let _ = s.write_all(req.as_bytes());
}
}
fn hdr(ct: &str) -> Header { Header::from_bytes(&b"Content-Type"[..], ct.as_bytes()).unwrap() }
fn resp(s: &str, ct: &str) -> Response<std::io::Cursor<Vec<u8>>> { Response::from_string(s).with_header(hdr(ct)) }
fn json_resp(s: &str) -> Response<std::io::Cursor<Vec<u8>>> { Response::from_string(s).with_header(hdr("application/json")) }
fn not_found() -> Response<std::io::Cursor<Vec<u8>>> { Response::from_string("{\"error\":\"not found\"}").with_status_code(404).with_header(hdr("application/json")) }
fn json_err(code: u16, msg: &str) -> Response<std::io::Cursor<Vec<u8>>> {
Response::from_string(json!({ "ok": false, "error": msg }).to_string()).with_status_code(code).with_header(hdr("application/json"))
}