app
Adom Chipsmith
Public Made by Adomby adom
STEP-native chip validator — 5-source cross-validation, OCCT-generated copyright-free 3D models with embedded signal annotations, datasheet-to-STEP pipeline.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314
pub mod cache;
pub mod convert;
use anyhow::Result;
use rust_embed::RustEmbed;
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use tiny_http::{Header, Method, Request, Response, StatusCode};
// Inherited from adom-step via git submodule at vendor/adom-step/.
// adom-step itself inherits the Babylon9 viewer bundle from the
// canonical adom-3d-viewer-babylon9 repo (its own submodule), so this
// chipsmith binary picks up the entire stack transitively. Workflow:
// git submodule update --remote --recursive
// cargo build --release
// is the full upstream-inheritance loop.
//
// chipsmith-specific UI (8 tabs, detection pipeline, sign-off, etc.)
// will live in src/assets/chipsmith-overlay.{js,css} (separate files)
// served via dedicated routes — never touches the inherited shell.html.
#[derive(RustEmbed)]
#[folder = "vendor/adom-step/vendor/3d-viewer-dist/"]
struct ViewerAssets;
const SHELL_HTML: &[u8] = include_bytes!("../../vendor/adom-step/src/assets/shell.html");
const ADOM_CSS: &[u8] = include_bytes!("../../vendor/adom-step/src/assets/adom.css");
const ICON_SVG: &[u8] = include_bytes!("../assets/icon.svg");
// Chipsmith-specific overlay (branding + 8 tabs + detection + sign-off).
// Auto-injected into the inherited shell.html via <script> tag before
// </body>. Source-of-truth lives in src/assets/chipsmith-overlay.js;
// shell.html itself is read-only (it's the adom-step submodule).
const CHIPSMITH_OVERLAY_JS: &[u8] = include_bytes!("../assets/chipsmith-overlay.js");
// Vendored earcut.min.js (Mapbox earcut v2.2.4, MIT) — exposes
// window.earcut. Required by Babylon's MeshBuilder.CreatePolygon /
// PolygonMeshBuilder for the ds2sf SVG-to-vector-polygon renderer.
// Loaded BEFORE chipsmith-overlay.js so it's globally available when
// renderDs2sfOverlay() runs.
const EARCUT_MIN_JS: &[u8] = include_bytes!("../assets/earcut.min.js");
pub struct ServerConfig {
pub port: u16,
pub initial_glb_path: Option<PathBuf>,
pub initial_source_label: Option<String>,
/// Provenance: "kicad-library" | "local-file" | "upload" | "unknown".
/// Drives the UI's "From KiCad library / Local file / Upload" badge so
/// the user always knows where the geometry came from.
pub initial_source_type: Option<String>,
}
#[derive(Clone, Debug)]
pub struct ConsoleMessage {
pub seq: u64,
pub ts_ms: u64,
pub level: String,
pub text: String,
}
#[derive(Clone, Debug)]
pub struct EvalJob {
pub id: String,
pub code: String,
pub result: Option<serde_json::Value>,
pub done: bool,
pub taken: bool,
}
pub struct ServerState {
pub config: ServerConfig,
pub current_glb_path: Mutex<Option<PathBuf>>,
pub current_source_label: Mutex<Option<String>>,
pub current_source_type: Mutex<Option<String>>,
pub current_materials: Mutex<serde_json::Value>,
/// Last components tree the UI posted to /api/components.
pub components: Mutex<serde_json::Value>,
/// Per-node visibility map {nodeName: bool}.
pub component_visibility: Mutex<serde_json::Value>,
/// Active section planes — list of {axis, offset, enabled, flipped}.
pub sections: Mutex<serde_json::Value>,
/// Last selection posted by the UI (for `adom-chipsmith inspect`-like queries).
pub selection: Mutex<serde_json::Value>,
/// Pending command bus — UI polls /api/poll-command and applies.
pub pending_command: Mutex<Option<serde_json::Value>>,
/// chipsmith-spec.json contents (manifest with pin_count, structural_type, body_dims_mm, etc.)
pub spec: Mutex<serde_json::Value>,
/// Parsed footprint (.kicad_mod) JSON — pads, layers, drill, etc.
pub footprint: Mutex<serde_json::Value>,
/// Multi-source footprints — keyed by source name (datasheet | kicad | fusion | altium).
/// Each value has the same shape as `footprint` (pad list).
pub footprint_sources: Mutex<serde_json::Value>,
/// Where to write <mpn>.chipsmith.json on sign-off (chip-fetcher library dir).
pub signoff_dir: Mutex<Option<PathBuf>>,
/// Path to the source STEP file (so /api/step-meta + /bake can re-read it).
pub source_step_path: Mutex<Option<PathBuf>>,
/// Sign-off state ({signed, sidecar_path, ...}).
pub signoff_state: Mutex<serde_json::Value>,
/// JS eval pipeline.
pub eval_jobs: Mutex<Vec<EvalJob>>,
/// Console ring buffer (UI -> server -> CLI tail).
pub console: Mutex<VecDeque<ConsoleMessage>>,
pub console_seq: AtomicU64,
}
const CONSOLE_RING_CAP: usize = 2000;
impl ServerState {
pub fn new(config: ServerConfig) -> Arc<Self> {
let initial_path = config.initial_glb_path.clone();
let initial_label = config.initial_source_label.clone();
let initial_type = config.initial_source_type.clone();
Arc::new(Self {
config,
current_glb_path: Mutex::new(initial_path),
current_source_label: Mutex::new(initial_label),
current_source_type: Mutex::new(initial_type),
current_materials: Mutex::new(serde_json::json!([])),
components: Mutex::new(serde_json::json!([])),
component_visibility: Mutex::new(serde_json::json!({})),
sections: Mutex::new(serde_json::json!([])),
selection: Mutex::new(serde_json::json!(null)),
pending_command: Mutex::new(None),
spec: Mutex::new(serde_json::json!(null)),
footprint: Mutex::new(serde_json::json!(null)),
footprint_sources: Mutex::new(serde_json::json!({})),
signoff_dir: Mutex::new(None),
source_step_path: Mutex::new(None),
signoff_state: Mutex::new(serde_json::json!({"signed": false})),
eval_jobs: Mutex::new(Vec::new()),
console: Mutex::new(VecDeque::with_capacity(CONSOLE_RING_CAP)),
console_seq: AtomicU64::new(0),
})
}
pub fn push_console(&self, level: &str, text: &str) {
let ts_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let seq = self.console_seq.fetch_add(1, Ordering::SeqCst);
let mut buf = self.console.lock().unwrap();
if buf.len() >= CONSOLE_RING_CAP {
buf.pop_front();
}
buf.push_back(ConsoleMessage {
seq,
ts_ms,
level: level.to_string(),
text: text.to_string(),
});
}
}
pub fn run(state: Arc<ServerState>) -> Result<()> {
// Bind 0.0.0.0 so the cloudflared tunnel (which routes through the
// container's bridge IP, not host loopback) can reach us. Adom's
// public proxy at <prefix>-<slug>.adom.cloud → port 8872 fails on a
// 127.0.0.1-only bind; gallery-style services bind 0.0.0.0 for the
// same reason. Local CLI calls hit 127.0.0.1 transparently because
// the OS routes loopback to any-interface listeners.
let addr = format!("0.0.0.0:{}", state.config.port);
let server = tiny_http::Server::http(&addr)
.map_err(|e| anyhow::anyhow!("failed to bind {}: {}", addr, e))?;
eprintln!("adom-chipsmith listening on http://{}", addr);
for request in server.incoming_requests() {
let state = state.clone();
std::thread::spawn(move || {
if let Err(e) = route(&state, request) {
eprintln!("request error: {e:#}");
}
});
}
Ok(())
}
fn url_decode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'+' { out.push(' '); i += 1; }
else if bytes[i] == b'%' && i + 2 < bytes.len() {
let hh = std::str::from_utf8(&bytes[i+1..i+3]).unwrap_or("00");
let v = u8::from_str_radix(hh, 16).unwrap_or(0);
out.push(v as char); i += 3;
}
else { out.push(bytes[i] as char); i += 1; }
}
out
}
fn chrono_iso8601_now() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64).unwrap_or(0);
// Manual ISO-8601 — avoid pulling chrono crate just for this.
let (y, m, d, hh, mm, ss) = epoch_to_ymdhms(secs);
format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, m, d, hh, mm, ss)
}
fn epoch_to_ymdhms(epoch: i64) -> (i32, u32, u32, u32, u32, u32) {
let days_since_epoch = epoch / 86400;
let secs = epoch.rem_euclid(86400) as u32;
let hh = secs / 3600;
let mm = (secs / 60) % 60;
let ss = secs % 60;
// Days since 1970-01-01
let mut y = 1970;
let mut d = days_since_epoch as i32;
loop {
let leap = (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0);
let days_in_year = if leap { 366 } else { 365 };
if d < days_in_year { break; }
d -= days_in_year;
y += 1;
}
let leap = (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0);
let mlens = if leap {
[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
} else {
[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
};
let mut month = 0u32;
let mut day = d as u32;
for (i, &n) in mlens.iter().enumerate() {
if day < n { month = i as u32 + 1; break; }
day -= n;
}
(y, month, day + 1, hh, mm, ss)
}
// Cloudflare-compliant anti-cache headers (per gallia/skills/adom guides).
const CACHE_CONTROL_VAL: &str = "no-store, no-cache, must-revalidate, max-age=0";
const PRAGMA_VAL: &str = "no-cache";
const SURROGATE_VAL: &str = "no-store";
fn no_cache<R: std::io::Read>(resp: Response<R>) -> Response<R> {
resp.with_header(Header::from_bytes(&b"Cache-Control"[..], CACHE_CONTROL_VAL.as_bytes()).unwrap())
.with_header(Header::from_bytes(&b"Pragma"[..], PRAGMA_VAL.as_bytes()).unwrap())
.with_header(Header::from_bytes(&b"Surrogate-Control"[..], SURROGATE_VAL.as_bytes()).unwrap())
}
fn json_response(status: u16, body: &serde_json::Value) -> Response<std::io::Cursor<Vec<u8>>> {
let bytes = serde_json::to_vec(body).unwrap_or_default();
let resp = Response::from_data(bytes)
.with_status_code(StatusCode(status))
.with_header(Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap());
no_cache(resp)
}
fn text_response(status: u16, mime: &str, body: Vec<u8>) -> Response<std::io::Cursor<Vec<u8>>> {
let resp = Response::from_data(body)
.with_status_code(StatusCode(status))
.with_header(Header::from_bytes(&b"Content-Type"[..], mime.as_bytes()).unwrap());
no_cache(resp)
}
fn read_body(req: &mut Request) -> Vec<u8> {
let mut buf = Vec::new();
let _ = req.as_reader().read_to_end(&mut buf);
buf
}
fn parse_json(req: &mut Request) -> Option<serde_json::Value> {
let bytes = read_body(req);
serde_json::from_slice(&bytes).ok()
}
fn next_eval_id(state: &ServerState) -> String {
let seq = state.console_seq.fetch_add(1, Ordering::SeqCst);
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
format!("e_{ts}_{seq}")
}
fn route(state: &Arc<ServerState>, mut request: Request) -> Result<()> {
let url = request.url().to_string();
// Strip query string for routing — handlers re-parse from request.url() if needed.
let path = url.split('?').next().unwrap_or(&url).to_string();
let method = request.method().clone();
// ---- Health & state ---------------------------------------------------
if path == "/health" {
let body = serde_json::json!({
"ok": true,
"service": "adom-chipsmith",
"port": state.config.port,
});
return Ok(request.respond(json_response(200, &body))?);
}
if path == "/state" {
let body = serde_json::json!({
"source": *state.current_source_label.lock().unwrap(),
"source_type": *state.current_source_type.lock().unwrap(),
"has_model": state.current_glb_path.lock().unwrap().is_some(),
"materials": *state.current_materials.lock().unwrap(),
"components": *state.components.lock().unwrap(),
"visibility": *state.component_visibility.lock().unwrap(),
"sections": *state.sections.lock().unwrap(),
});
return Ok(request.respond(json_response(200, &body))?);
}
if path == "/shutdown" && method == Method::Post {
let _ = request.respond(json_response(200, &serde_json::json!({"ok": true})));
std::process::exit(0);
}
// ---- Static shell -----------------------------------------------------
if path == "/" || path == "/index.html" {
// Inject the chipsmith overlay <script> tag before </body> of the
// inherited shell.html. The shell.html itself is read-only (it
// ships from the adom-step submodule); overlay code lives in
// chipsmith-overlay.js where chipsmith-specific behaviors stack.
let html = String::from_utf8_lossy(SHELL_HTML);
// Relative path (NOT leading-slash) so the script resolves
// correctly under the Hydrogen proxy URL (e.g.
// /proxy/8872/chipsmith-overlay.js, not /chipsmith-overlay.js
// at the domain root).
let injected = html.replace(
"</body>",
"<script src=\"earcut.min.js\"></script>\n<script src=\"chipsmith-overlay.js\" defer></script>\n</body>",
);
return Ok(request.respond(text_response(200, "text/html; charset=utf-8", injected.into_bytes()))?);
}
if path == "/chipsmith-overlay.js" {
return Ok(request.respond(text_response(200, "application/javascript; charset=utf-8", CHIPSMITH_OVERLAY_JS.to_vec()))?);
}
if path == "/earcut.min.js" {
return Ok(request.respond(text_response(200, "application/javascript; charset=utf-8", EARCUT_MIN_JS.to_vec()))?);
}
if path == "/adom.css" {
return Ok(request.respond(text_response(200, "text/css; charset=utf-8", ADOM_CSS.to_vec()))?);
}
if path == "/favicon.svg" || path == "/favicon.ico" {
return Ok(request.respond(text_response(200, "image/svg+xml", ICON_SVG.to_vec()))?);
}
// Vendored Babylon9 ESM bundle and chunks: /viewer/<filename>
if let Some(asset) = path.strip_prefix("/viewer/") {
if let Some(file) = ViewerAssets::get(asset) {
let mime = if asset.ends_with(".js") {
"application/javascript; charset=utf-8"
} else if asset.ends_with(".css") {
"text/css; charset=utf-8"
} else if asset.ends_with(".env") {
"application/octet-stream"
} else {
"application/octet-stream"
};
return Ok(request.respond(text_response(200, mime, file.data.into_owned()))?);
}
return Ok(request.respond(json_response(404, &serde_json::json!({"error": "not found"})))?);
}
// ---- Model serving ----------------------------------------------------
if path == "/model.glb" {
let glb_path = state.current_glb_path.lock().unwrap().clone();
match glb_path {
Some(p) => match std::fs::read(&p) {
Ok(bytes) => {
let resp = Response::from_data(bytes)
.with_status_code(StatusCode(200))
.with_header(Header::from_bytes(&b"Content-Type"[..], &b"model/gltf-binary"[..]).unwrap());
return Ok(request.respond(no_cache(resp))?);
}
Err(e) => {
return Ok(request.respond(json_response(
500,
&serde_json::json!({"error": format!("read {}: {e}", p.display())}),
))?);
}
},
None => {
return Ok(request.respond(json_response(
404,
&serde_json::json!({"error": "no model loaded — POST a STEP file to /api/load or drop one onto the canvas"}),
))?);
}
}
}
// ---- STEP upload (drop zone + AI) ------------------------------------
if path == "/api/load" && method == Method::Post {
let bytes = read_body(&mut request);
if bytes.is_empty() {
return Ok(request.respond(json_response(
400,
&serde_json::json!({"error": "empty body"}),
))?);
}
// Filename hint via X-Filename header (for the source label in HUD).
let filename = request
.headers()
.iter()
.find(|h| h.field.as_str().as_str().eq_ignore_ascii_case("X-Filename"))
.map(|h| h.value.as_str().to_string())
.unwrap_or_else(|| "uploaded.step".to_string());
match convert::convert_step_bytes(&bytes) {
Ok(result) => {
*state.current_glb_path.lock().unwrap() = Some(result.glb_path.clone());
*state.current_source_label.lock().unwrap() = Some(filename.clone());
*state.current_source_type.lock().unwrap() = Some("upload".to_string());
*state.current_materials.lock().unwrap() = result.materials.clone();
let body = serde_json::json!({
"ok": true,
"source": filename,
"glb_size_bytes": result.size_bytes,
"meshes": result.meshes,
"nodes": result.nodes,
"duration_ms": result.duration_ms,
"materials": result.materials,
"cache_hit": result.cache_hit,
});
return Ok(request.respond(json_response(200, &body))?);
}
Err(e) => {
return Ok(request.respond(json_response(
500,
&serde_json::json!({"error": e.to_string()}),
))?);
}
}
}
// ---- Components tree (UI <-> server) ---------------------------------
if path == "/api/components" && method == Method::Post {
if let Some(v) = parse_json(&mut request) {
*state.components.lock().unwrap() = v;
}
return Ok(request.respond(json_response(200, &serde_json::json!({"ok": true})))?);
}
if path == "/api/components" && method == Method::Get {
let v = state.components.lock().unwrap().clone();
return Ok(request.respond(json_response(200, &v))?);
}
// ---- chipsmith-spec.json (manifest) ---------------------------------
if path == "/api/spec" && method == Method::Get {
let v = state.spec.lock().unwrap().clone();
return Ok(request.respond(json_response(200, &v))?);
}
// ---- ds2sf v0.5.1 authoritative footprint extraction ----------------
// Returns the contents of <MPN>-extraction.result.json so the UI can
// surface the canonical 2D ground-truth SVG (pixel-trustworthy when
// sourced from service-kicad-stdlib, layout-only when synthesized).
if path == "/api/ds2sf" && method == Method::Get {
let dir = state.signoff_dir.lock().unwrap().clone();
let mpn = state.current_source_label.lock().unwrap().clone()
.map(|s| s.trim_end_matches(".step").trim_end_matches(".STEP").trim_end_matches(".stp").to_string())
.unwrap_or_default();
let mut out = serde_json::json!({"available": false});
if let Some(d) = &dir {
let result_path = d.join(format!("{mpn}-extraction.result.json"));
if result_path.exists() {
if let Ok(s) = std::fs::read_to_string(&result_path) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&s) {
out = v;
out["available"] = serde_json::Value::Bool(true);
}
}
}
}
return Ok(request.respond(json_response(200, &out))?);
}
// ds2sf v0.6.0 per-pad position data. Returns the pads[] array from
// <MPN>-footprint.extracted.json in the same schema chipsmith uses
// for KiCad/Fusion/Altium: [{number, type, shape, at, size, layers, drill}].
// This is the 5th independent cross-validation source.
if path == "/api/ds2sf-pads" && method == Method::Get {
let dir = state.signoff_dir.lock().unwrap().clone();
let mpn = state.current_source_label.lock().unwrap().clone()
.map(|s| s.trim_end_matches(".step").trim_end_matches(".STEP").trim_end_matches(".stp").to_string())
.unwrap_or_default();
if let Some(d) = &dir {
let fp_path = d.join(format!("{mpn}-footprint.extracted.json"));
if fp_path.exists() {
if let Ok(s) = std::fs::read_to_string(&fp_path) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&s) {
if let Some(pads) = v.get("pads") {
let pad_count = pads.as_array().map(|a| a.len()).unwrap_or(0);
let source = v.get("package_outline_source")
.or_else(|| v.get("kicad_baseline").and_then(|b| b.get("source")))
.and_then(|v| v.as_str())
.unwrap_or("unknown");
return Ok(request.respond(json_response(200, &serde_json::json!({
"pads": pads,
"pad_count": pad_count,
"source": source,
"mpn": mpn,
})))?);
}
}
}
}
}
return Ok(request.respond(json_response(404, &serde_json::json!({"error": "no ds2sf pads for this chip (needs ds2sf v0.6.0+)"})))?);
}
// ---- Generate Adom 3D chip / footprint via local OCCT ----------------
// Calls create_chip.py / create_footprint.py locally (via the step2glb
// venv), converts the resulting STEP to GLB via service-step2glb /convert,
// and serves the GLB so the frontend can load it as a new outline branch.
// Cached at /tmp/adom-chipsmith-cache/<mpn>-generated-{chip,fp}.glb.
if (path == "/api/generate-chip-glb" || path == "/api/generate-footprint-glb") && method == Method::Get {
let is_chip = path.contains("chip");
let dir = state.signoff_dir.lock().unwrap().clone();
let mpn = state.current_source_label.lock().unwrap().clone()
.map(|s| s.trim_end_matches(".step").trim_end_matches(".STEP").trim_end_matches(".stp").to_string())
.unwrap_or_default();
let cache_dir = PathBuf::from("/tmp/adom-chipsmith-cache");
let tag = if is_chip { "chip" } else { "fp" };
let glb_cache = cache_dir.join(format!("{mpn}-generated-{tag}.glb"));
if glb_cache.exists() {
let bytes = std::fs::read(&glb_cache).unwrap_or_default();
let response = tiny_http::Response::from_data(bytes)
.with_header("Content-Type: model/gltf-binary".parse::<tiny_http::Header>().unwrap())
.with_header("Cache-Control: no-cache".parse::<tiny_http::Header>().unwrap());
return Ok(request.respond(response)?);
}
// Need to generate. Read the ds2sf data.
let fp_path = dir.as_ref().map(|d| d.join(format!("{mpn}-footprint.extracted.json")));
let fp_json: Option<serde_json::Value> = fp_path.and_then(|p| {
std::fs::read_to_string(&p).ok().and_then(|s| serde_json::from_str(&s).ok())
});
// Read info.json for document_metadata (mpn, manufacturer, etc.)
let info_path = dir.as_ref().map(|d| d.join("info.json"));
let info_json: Option<serde_json::Value> = info_path.and_then(|p| {
std::fs::read_to_string(&p).ok().and_then(|s| serde_json::from_str(&s).ok())
});
let doc_meta = serde_json::json!({
"mpn": info_json.as_ref().and_then(|v| v.get("mpn")).cloned().unwrap_or(serde_json::json!(mpn)),
"manufacturer": info_json.as_ref().and_then(|v| v.get("manufacturer")).cloned().unwrap_or_default(),
"package": info_json.as_ref().and_then(|v| v.get("package")).cloned().unwrap_or_default(),
"datasheet_url": info_json.as_ref().and_then(|v| v.get("datasheet_url")).cloned().unwrap_or_default(),
});
let config_json = if is_chip {
fp_json.as_ref().and_then(|fj| fj.get("chip_3d")).cloned().map(|mut c| {
if let Some(pads) = fp_json.as_ref().and_then(|fj| fj.get("pads")).and_then(|p| p.as_array()) {
let annots: Vec<serde_json::Value> = pads.iter().map(|p| {
let x = p.get("at").and_then(|a| a.get(0)).and_then(|v| v.as_f64()).unwrap_or(0.0);
serde_json::json!({
"number": p.get("number").cloned().unwrap_or_default(),
"name": p.get("name").cloned().unwrap_or_default(),
"function": p.get("function").cloned().unwrap_or_default(),
"side": if x > 0.0 { "right" } else { "left" },
})
}).collect();
c["lead_annotations"] = serde_json::json!(annots);
}
c["document_metadata"] = doc_meta.clone();
c
})
} else {
fp_json.as_ref().map(|v| {
serde_json::json!({
"pads": v.get("pads").cloned().unwrap_or(serde_json::json!([])),
"copper_thickness_mm": 0.035,
"board_thickness_mm": 1.6,
"document_metadata": doc_meta.clone(),
})
})
};
if config_json.is_none() {
return Ok(request.respond(json_response(404, &serde_json::json!({
"error": format!("no {} data in ds2sf extraction (needs ds2sf v0.8.0+)", if is_chip { "chip_3d" } else { "pads" })
})))?);
}
// Call the live service endpoints (v0.9.0) instead of local OCCT.
// Generate STEP via /create-chip or /create-footprint, then convert
// to GLB via /convert. Both go through the same service URL.
let service_url = crate::cli::step2glb_service_url();
let endpoint = if is_chip { "create-chip" } else { "create-footprint" };
let config_str = serde_json::to_string(&config_json.unwrap()).unwrap_or_default();
// Step 1: generate STEP
let step_result = ureq::post(&format!("{service_url}/{endpoint}"))
.set("Content-Type", "application/json")
.send_string(&config_str);
let step_bytes = match step_result {
Ok(resp) => {
let mut buf = Vec::new();
let _ = resp.into_reader().read_to_end(&mut buf);
buf
}
Err(e) => {
return Ok(request.respond(json_response(502, &serde_json::json!({
"error": format!("/{endpoint} failed: {e}")
})))?);
}
};
if step_bytes.is_empty() {
return Ok(request.respond(json_response(502, &serde_json::json!({
"error": format!("/{endpoint} returned empty response")
})))?);
}
// Save STEP for sign-off compositing + config JSON for annotation readback
// (the chipsmith_metadata product doesn't survive STEP→GLB conversion).
let step_path = cache_dir.join(format!("{mpn}-generated-{tag}.step"));
let annot_path = cache_dir.join(format!("{mpn}-generated-{tag}-annotations.json"));
let _ = std::fs::create_dir_all(&cache_dir);
let _ = std::fs::write(&step_path, &step_bytes);
let _ = std::fs::write(&annot_path, &config_str);
// Step 2: convert STEP → GLB
match ureq::post(&format!("{service_url}/convert"))
.set("Content-Type", "application/step")
.send_bytes(&step_bytes)
{
Ok(resp) => {
let mut glb_bytes = Vec::new();
let _ = resp.into_reader().read_to_end(&mut glb_bytes);
let _ = std::fs::write(&glb_cache, &glb_bytes);
let response = tiny_http::Response::from_data(glb_bytes)
.with_header("Content-Type: model/gltf-binary".parse::<tiny_http::Header>().unwrap());
return Ok(request.respond(response)?);
}
Err(e) => {
return Ok(request.respond(json_response(502, &serde_json::json!({
"error": format!("GLB conversion failed: {e}")
})))?);
}
}
}
// Serve cached annotation JSON for the generated chip / footprint.
// The chipsmith_metadata product doesn't survive STEP→GLB, so the
// frontend reads annotations from this endpoint instead.
if (path == "/api/generated-chip-annotations" || path == "/api/generated-footprint-annotations") && method == Method::Get {
let tag = if path.contains("chip") { "chip" } else { "fp" };
let mpn = state.current_source_label.lock().unwrap().clone()
.map(|s| s.trim_end_matches(".step").trim_end_matches(".STEP").trim_end_matches(".stp").to_string())
.unwrap_or_default();
let annot_path = PathBuf::from("/tmp/adom-chipsmith-cache").join(format!("{mpn}-generated-{tag}-annotations.json"));
if annot_path.exists() {
if let Ok(bytes) = std::fs::read(&annot_path) {
return Ok(request.respond(text_response(200, "application/json; charset=utf-8", bytes))?);
}
}
return Ok(request.respond(json_response(404, &serde_json::json!({"error": "no cached annotations"})))?);
}
// ds2sf v0.8.0 chip_3d + mechanical — the 3D generation recipe and
// JEDEC dimension provenance. Used by the "Adom 3D chip" outline
// branch and the Measurements tab provenance links.
if path == "/api/ds2sf-chip3d" && method == Method::Get {
let dir = state.signoff_dir.lock().unwrap().clone();
let mpn = state.current_source_label.lock().unwrap().clone()
.map(|s| s.trim_end_matches(".step").trim_end_matches(".STEP").trim_end_matches(".stp").to_string())
.unwrap_or_default();
if let Some(d) = &dir {
let fp_path = d.join(format!("{mpn}-footprint.extracted.json"));
if fp_path.exists() {
if let Ok(s) = std::fs::read_to_string(&fp_path) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&s) {
let chip_3d = v.get("chip_3d").cloned().unwrap_or(serde_json::json!(null));
let mechanical = v.get("mechanical").cloned().unwrap_or(serde_json::json!(null));
if !chip_3d.is_null() {
return Ok(request.respond(json_response(200, &serde_json::json!({
"chip_3d": chip_3d,
"mechanical": mechanical,
"mpn": mpn,
})))?);
}
}
}
}
}
return Ok(request.respond(json_response(404, &serde_json::json!({"error": "no chip_3d data (needs ds2sf v0.8.0+)"})))?);
}
// Static-serve ds2sf SVGs. v0.8.0 produces two:
// /api/ds2sf-svg → ds2sf's OWN datasheet-derived SVG (primary)
// /api/ds2sf-svg?which=pcbnew → pcbnew stdlib render (comparison)
// Falls back through filename variants for compat with older ds2sf.
if path == "/api/ds2sf-svg" && method == Method::Get {
let dir = state.signoff_dir.lock().unwrap().clone();
let mpn = state.current_source_label.lock().unwrap().clone()
.map(|s| s.trim_end_matches(".step").trim_end_matches(".STEP").trim_end_matches(".stp").to_string())
.unwrap_or_default();
let qs = request.url().split('?').nth(1).unwrap_or("");
let want_pcbnew = qs.contains("which=pcbnew");
if let Some(d) = &dir {
// Try the v0.8.0 filenames first, fall back to the old name.
let candidates = if want_pcbnew {
vec![
d.join(format!("{mpn}-footprint.pcbnew.svg")),
]
} else {
vec![
d.join(format!("{mpn}-footprint.ds2sf.svg")),
d.join(format!("{mpn}-footprint.authoritative.svg")),
]
};
for svg_path in candidates {
if svg_path.exists() {
if let Ok(bytes) = std::fs::read(&svg_path) {
let response = tiny_http::Response::from_data(bytes)
.with_header("Content-Type: image/svg+xml".parse::<tiny_http::Header>().unwrap())
.with_header("Cache-Control: no-cache".parse::<tiny_http::Header>().unwrap());
return Ok(request.respond(response)?);
}
}
}
}
return Ok(request.respond(json_response(404, &serde_json::json!({"error": "no ds2sf SVG for this chip"})))?);
}
// ---- Local file paths (for "📂 reveal" buttons in the UI) ----------
// Returns absolute paths to every artifact chipsmith knows about,
// so the UI can render reveal-in-explorer buttons next to each.
if path == "/api/paths" && method == Method::Get {
let dir = state.signoff_dir.lock().unwrap().clone();
let step_path = state.source_step_path.lock().unwrap().clone();
let mpn = state.current_source_label.lock().unwrap().clone()
.map(|s| s.trim_end_matches(".step").trim_end_matches(".STEP").trim_end_matches(".stp").to_string())
.unwrap_or_default();
let mut out = serde_json::json!({});
if let Some(d) = &dir {
let try_paths: Vec<(&str, std::path::PathBuf)> = vec![
("step", d.join(format!("{mpn}.step"))),
("step_alt", d.join(format!("{mpn}.STEP"))),
("kicad_mod", d.join(format!("{mpn}.kicad_mod"))),
("kicad_sym", d.join(format!("{mpn}.kicad_sym"))),
("eagle_lbr", d.join(format!("{mpn}.lbr"))),
("altium_pcblib", d.join(format!("{mpn}.PcbLib"))),
("altium_intlib", d.join(format!("{mpn}.IntLib"))),
("datasheet", d.join(format!("{mpn}.pdf"))),
("info_json", d.join("info.json")),
("chipsmith_step", d.join(format!("{mpn}.chipsmith.step"))),
("chipsmith_glb", d.join(format!("{mpn}.chipsmith.glb"))),
("chipsmith_json", d.join(format!("{mpn}.chipsmith.json"))),
// ds2sf v0.5.1 — authoritative top-down footprint SVG and
// its extraction result.json (the latter carries the
// outputs.footprint_svg_source flag: "service-kicad-stdlib"
// = pixel-trustworthy, "ds2sf-synthesized" = layout-only).
("ds2sf_result", d.join(format!("{mpn}-extraction.result.json"))),
("ds2sf_svg", d.join(format!("{mpn}-footprint.authoritative.svg"))),
];
for (key, path) in try_paths {
if path.exists() {
out[key] = serde_json::Value::String(path.to_string_lossy().into_owned());
}
}
out["dir"] = serde_json::Value::String(d.to_string_lossy().into_owned());
}
if let Some(p) = &step_path {
// Override with the actual loaded STEP path (may differ from dir/mpn.step
// when the user opened a file directly via `view`).
out["step"] = serde_json::Value::String(p.to_string_lossy().into_owned());
}
return Ok(request.respond(json_response(200, &out))?);
}
// ---- Reveal a path in VS Code Explorer (delegates to adom-vscode) --
if path == "/api/reveal" && method == Method::Post {
let body = parse_json(&mut request).unwrap_or_default();
let path_arg = body.get("path").and_then(|v| v.as_str()).unwrap_or("");
if path_arg.is_empty() {
return Ok(request.respond(json_response(400, &serde_json::json!({"error": "path required"})))?);
}
// Hard-fail any non-existent path to avoid leaking accidental injection.
if !std::path::Path::new(path_arg).exists() {
return Ok(request.respond(json_response(404, &serde_json::json!({"error": format!("path not found: {path_arg}")})))?);
}
let result = std::process::Command::new("adom-vscode")
.arg("reveal")
.arg(path_arg)
.output();
match result {
Ok(o) if o.status.success() => {
return Ok(request.respond(json_response(200, &serde_json::json!({"ok": true, "path": path_arg})))?);
}
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr).to_string();
return Ok(request.respond(json_response(500, &serde_json::json!({"error": format!("adom-vscode reveal failed: {stderr}")})))?);
}
Err(e) => {
return Ok(request.respond(json_response(500, &serde_json::json!({"error": format!("adom-vscode not on PATH: {e}")})))?);
}
}
}
// ---- Open a file in VS Code's editor pane. Pairs with /api/reveal
// — reveal pings the Explorer sidebar, open actually opens the
// file in a tab. Used by chipsmith for source files like .kicad_mod
// / .step / info.json (PDFs go to adom-pdf-viewer instead — see the
// datasheet section in the right-pane Source tab). -----------
if path == "/api/open" && method == Method::Post {
let body = parse_json(&mut request).unwrap_or_default();
let path_arg = body.get("path").and_then(|v| v.as_str()).unwrap_or("");
if path_arg.is_empty() {
return Ok(request.respond(json_response(400, &serde_json::json!({"error": "path required"})))?);
}
if !std::path::Path::new(path_arg).exists() {
return Ok(request.respond(json_response(404, &serde_json::json!({"error": format!("path not found: {path_arg}")})))?);
}
let result = std::process::Command::new("adom-vscode")
.arg("open")
.arg(path_arg)
.output();
match result {
Ok(o) if o.status.success() => {
return Ok(request.respond(json_response(200, &serde_json::json!({"ok": true, "path": path_arg})))?);
}
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr).to_string();
return Ok(request.respond(json_response(500, &serde_json::json!({"error": format!("adom-vscode open failed: {stderr}")})))?);
}
Err(e) => {
return Ok(request.respond(json_response(500, &serde_json::json!({"error": format!("adom-vscode not on PATH: {e}")})))?);
}
}
}
// ---- Datasheet PDF (served from the chip-fetcher dir) --------------
if path == "/api/datasheet" && method == Method::Get {
let dir = state.signoff_dir.lock().unwrap().clone();
if let Some(d) = dir {
// Prefer <MPN>.pdf, then any single .pdf in the dir as fallback.
let mpn = state.current_source_label.lock().unwrap().clone()
.map(|s| s.trim_end_matches(".step").trim_end_matches(".STEP").trim_end_matches(".stp").to_string())
.unwrap_or_default();
let canonical = d.join(format!("{mpn}.pdf"));
if canonical.exists() {
if let Ok(bytes) = std::fs::read(&canonical) {
let resp = Response::from_data(bytes)
.with_status_code(StatusCode(200))
.with_header(Header::from_bytes(&b"Content-Type"[..], &b"application/pdf"[..]).unwrap())
.with_header(Header::from_bytes(&b"Content-Disposition"[..], format!("inline; filename=\"{mpn}.pdf\"").as_bytes()).unwrap());
return Ok(request.respond(resp)?);
}
}
}
return Ok(request.respond(json_response(404, &serde_json::json!({"error": "no datasheet PDF"})))?);
}
if path == "/api/spec" && method == Method::Post {
if let Some(v) = parse_json(&mut request) {
// Update in-memory spec.
*state.spec.lock().unwrap() = v.clone();
// ALSO persist to info.json next to the source STEP, so the
// user's pin_count / structural_type / body_dims_mm edits
// survive reloads. We merge into the existing info.json
// rather than overwrite — non-chipsmith fields stay intact.
let dir = state.signoff_dir.lock().unwrap().clone();
let mut wrote = false;
if let Some(d) = dir {
let info_path = d.join("info.json");
let existing: serde_json::Value = std::fs::read_to_string(&info_path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_else(|| serde_json::json!({}));
let mut merged = existing.as_object().cloned().unwrap_or_default();
if let Some(obj) = v.as_object() {
for (k, v) in obj { merged.insert(k.clone(), v.clone()); }
}
if let Ok(s) = serde_json::to_string_pretty(&serde_json::Value::Object(merged)) {
if std::fs::write(&info_path, s).is_ok() {
wrote = true;
}
}
}
return Ok(request.respond(json_response(200, &serde_json::json!({"ok": true, "persisted": wrote})))?);
}
return Ok(request.respond(json_response(400, &serde_json::json!({"error": "no body"})))?);
}
// ---- footprint (.kicad_mod) -----------------------------------------
if path == "/api/footprint" && method == Method::Get {
let v = state.footprint.lock().unwrap().clone();
return Ok(request.respond(json_response(200, &v))?);
}
if path == "/api/footprint" && method == Method::Post {
if let Some(v) = parse_json(&mut request) {
*state.footprint.lock().unwrap() = v;
}
return Ok(request.respond(json_response(200, &serde_json::json!({"ok": true})))?);
}
// ---- Multi-source footprints (datasheet / kicad / fusion / altium) ----
if path == "/api/footprint-sources" && method == Method::Get {
let v = state.footprint_sources.lock().unwrap().clone();
return Ok(request.respond(json_response(200, &v))?);
}
// ---- KiCad-stock reference fetch (service-kicad proxy) -------------
// Given a footprint_name like "Package_SOIC/SOIC-8", fetch:
// - The .kicad_mod from service-kicad
// - The reference 3D STEP (when available) and convert via service-step2glb
// Returns JSON { kicad_mod_text, glb_url } so the JS can render both
// as a "KiCad reference" overlay branch in the scene-graph.
if path == "/api/kicad-stock" && method == Method::Get {
let qs = url.split('?').nth(1).unwrap_or("");
let mut footprint_name = String::new();
let mut chip_3d_name = String::new();
let mut client_candidates: Vec<String> = Vec::new();
for kv in qs.split('&') {
let mut it = kv.splitn(2, '=');
let k = it.next().unwrap_or("");
let v = it.next().unwrap_or("").to_string();
let v = url_decode(&v);
if k == "footprint_name" { footprint_name = v; }
else if k == "kicad_3d_name" { chip_3d_name = v; }
else if k == "candidates" {
// pipe-delimited list of "library/leaf" entries (no .pretty,
// no .kicad_mod) — chipsmith JS pre-resolves them from the
// package_kind + pin_count + body_dims_mm.
client_candidates.extend(v.split('|').filter(|s| !s.is_empty()).map(|s| s.to_string()));
}
}
if footprint_name.is_empty() && client_candidates.is_empty() {
return Ok(request.respond(json_response(400, &serde_json::json!({"error": "footprint_name or candidates required"})))?);
}
// Build URLs to the shared service-kicad container.
let kicad_base = std::env::var("KICAD_SERVICE_API")
.unwrap_or_else(|_| "https://kicad-rk5ue5pcfemi.adom.cloud".to_string());
let kicad_base = kicad_base.trim_end_matches('/');
// Build the URL list. service-kicad serves the static library as
// /footprints/<library>.pretty/<leaf>.kicad_mod (note the .pretty on
// the directory). Try every permutation of "library/leaf" the
// client gave us, plus legacy "library/leaf.kicad_mod" forms.
let mut leaf_candidates: Vec<String> = Vec::new();
if !footprint_name.is_empty() { leaf_candidates.push(footprint_name.clone()); }
for c in &client_candidates {
if !leaf_candidates.contains(c) { leaf_candidates.push(c.clone()); }
}
let mut url_candidates: Vec<String> = Vec::new();
for c in &leaf_candidates {
// c is "<library>/<leaf>" — split into library + leaf.
let mut parts = c.splitn(2, '/');
let library = parts.next().unwrap_or("");
let leaf = parts.next().unwrap_or("");
if library.is_empty() || leaf.is_empty() { continue; }
// Canonical: Package_X.pretty/<leaf>.kicad_mod
url_candidates.push(format!("{kicad_base}/footprints/{library}.pretty/{leaf}.kicad_mod"));
// Legacy fallback (some early service-kicad versions stripped .pretty)
url_candidates.push(format!("{kicad_base}/footprints/{library}/{leaf}.kicad_mod"));
}
let mut out = serde_json::json!({"footprint_name": footprint_name, "kicad_3d_name": chip_3d_name});
let mut got = false;
let mut last_err = String::new();
let mut tried: Vec<serde_json::Value> = Vec::new();
for fp_url in &url_candidates {
let res = ureq::get(fp_url).timeout(std::time::Duration::from_secs(8)).call();
match res {
Ok(r) if r.status() == 200 => {
let txt = r.into_string().unwrap_or_default();
// Parse the .kicad_mod text into a pads + silk_markers
// structure that matches the four user-supplied
// footprint sources, so cross-validation in Tab 4
// can include the stock library as a fifth pair.
if let Ok(parsed) = crate::cli::view_library::parse_kicad_mod_text(&txt) {
out["parsed"] = parsed;
}
out["kicad_mod_text"] = serde_json::Value::String(txt);
out["kicad_mod_url"] = serde_json::Value::String(fp_url.clone());
// Surface the matched library / leaf so the UI can show
// exactly which one chipsmith found.
if let Some(after) = fp_url.split("/footprints/").nth(1) {
let matched = after.trim_end_matches(".kicad_mod").replace(".pretty/", "/");
out["matched_footprint_name"] = serde_json::Value::String(matched);
}
tried.push(serde_json::json!({ "url": fp_url, "status": 200 }));
got = true;
break;
}
Ok(r) => {
let s = r.status();
last_err = format!("HTTP {s} on {fp_url}");
tried.push(serde_json::json!({ "url": fp_url, "status": s }));
}
Err(e) => {
last_err = format!("{e} on {fp_url}");
tried.push(serde_json::json!({ "url": fp_url, "error": format!("{e}") }));
}
}
}
if !got {
out["error_kicad_mod"] = serde_json::json!({
"error": last_err,
"tried_count": tried.len(),
"tried": tried,
});
}
// Try the matching 3D model too. KiCad layout: Package_X.3dshapes/<leaf>.step
if !chip_3d_name.is_empty() {
let m_url = format!("{kicad_base}/models/{}.step", chip_3d_name);
out["kicad_3d_url"] = serde_json::Value::String(m_url);
} else if got {
// Derive 3D name from the matched footprint when possible.
if let Some(matched) = out.get("matched_footprint_name").and_then(|v| v.as_str()) {
let parts: Vec<&str> = matched.splitn(2, '/').collect();
if parts.len() == 2 {
let model_path = format!("{}.3dshapes/{}.step", parts[0], parts[1]);
out["kicad_3d_name"] = serde_json::Value::String(model_path.clone());
out["kicad_3d_url"] = serde_json::Value::String(format!("{kicad_base}/models/{model_path}"));
}
}
}
return Ok(request.respond(json_response(200, &out))?);
}
if path == "/api/footprint-sources" && method == Method::Post {
if let Some(v) = parse_json(&mut request) {
*state.footprint_sources.lock().unwrap() = v;
}
return Ok(request.respond(json_response(200, &serde_json::json!({"ok": true})))?);
}
// ---- step-meta (B-rep proxy from service-step2glb) ------------------
if path == "/api/step-meta" && method == Method::Get {
// Proxy to service-step2glb /step-meta, caching the resulting JSON
// by source-STEP SHA-256 next to the GLB.
let step_path = state.source_step_path.lock().unwrap().clone();
let step_path = match step_path {
Some(p) => p,
None => return Ok(request.respond(json_response(404, &serde_json::json!({
"error": "no source STEP path on this server (started without view-library?)"
})))?),
};
let step_bytes = match std::fs::read(&step_path) {
Ok(b) => b,
Err(e) => return Ok(request.respond(json_response(500, &serde_json::json!({
"error": format!("read step: {e}")
})))?),
};
match convert::fetch_step_meta(&step_bytes) {
Ok(Some(v)) => return Ok(request.respond(json_response(200, &v))?),
Ok(None) => return Ok(request.respond(json_response(404, &serde_json::json!({
"error": "service-step2glb /step-meta endpoint not yet deployed (returned 404). Falling back to GLB-walk detection."
})))?),
Err(e) => return Ok(request.respond(json_response(502, &serde_json::json!({
"error": format!("upstream /step-meta failed: {e}")
})))?),
}
}
// ---- chipsmith sign-off bake ----------------------------------------
if path == "/api/signoff" && method == Method::Post {
let body = parse_json(&mut request).unwrap_or_default();
let source_label_owned = state.current_source_label.lock().unwrap().clone();
let source_label = source_label_owned.clone().unwrap_or_else(|| "unknown".to_string());
let mpn = source_label.trim_end_matches(".step").trim_end_matches(".STEP").trim_end_matches(".stp").to_string();
let sidecar_dir = state.signoff_dir.lock().unwrap().clone();
let sidecar_path = match &sidecar_dir {
Some(d) => d.join(format!("{mpn}.chipsmith.json")),
None => std::env::temp_dir().join(format!("{mpn}.chipsmith.json")),
};
let baked_step_path = match &sidecar_dir {
Some(d) => d.join(format!("{mpn}.chipsmith.step")),
None => std::env::temp_dir().join(format!("{mpn}.chipsmith.step")),
};
let mut sidecar = body.clone();
if sidecar.is_object() {
sidecar["mpn"] = serde_json::Value::String(mpn.clone());
sidecar["source"] = serde_json::Value::String(source_label.clone());
sidecar["signed_at"] = serde_json::Value::String(chrono_iso8601_now());
sidecar["chipsmith_version"] = serde_json::Value::String(env!("CARGO_PKG_VERSION").to_string());
}
// Try to bake the canonical .chipsmith.step via service-step2glb /bake.
// Requires the source STEP bytes; we read them from the chip-fetcher
// library dir if available.
let mut step_baked = false;
let mut bake_status = String::new();
if let Some(d) = &sidecar_dir {
let mut step_path = d.join(format!("{mpn}.step"));
if !step_path.exists() { step_path = d.join(format!("{mpn}.STEP")); }
if step_path.exists() {
if let Ok(step_bytes) = std::fs::read(&step_path) {
let bake_meta = serde_json::json!({
"applied_scale": sidecar.get("applied_unit_scale").cloned().unwrap_or(serde_json::json!(1.0)),
"applied_translation_mm": sidecar.get("applied_translation_mm").cloned().unwrap_or(serde_json::json!([0.0, 0.0, 0.0])),
"applied_rotation": sidecar.get("applied_rotation").cloned().unwrap_or(serde_json::json!({})),
"role_tags": sidecar.get("role_tags").cloned().unwrap_or(serde_json::json!([])),
"pin1_marker": sidecar.get("pin1_chip").cloned().unwrap_or(serde_json::json!(null)),
// Names of GLB-product entries the user (or chipsmith
// auto-flag) marked as decorative — vendor logos, lens
// cosmetics, embossed marketing geometry. service-step2glb
// /bake reparents these under a `chipsmith_decorative`
// assembly node so downstream Adom tools can default
// them OFF. NEVER deleted, only moved.
"decorative_product_names": sidecar.get("decorative_product_names").cloned().unwrap_or(serde_json::json!([])),
"signoff_metadata": {
"chipsmith_version": env!("CARGO_PKG_VERSION"),
"signed_at": chrono_iso8601_now(),
"spec": sidecar.get("spec").cloned().unwrap_or(serde_json::json!(null)),
},
});
match convert::bake_step(&step_bytes, &bake_meta) {
Ok(Some(out)) => {
if let Err(e) = std::fs::write(&baked_step_path, &out) {
bake_status = format!("bake produced bytes but write failed: {e}");
} else {
step_baked = true;
bake_status = format!("baked to {}", baked_step_path.display());
}
}
Ok(None) => {
bake_status = "service-step2glb /bake not deployed yet (404) — JSON sidecar still written".to_string();
}
Err(e) => {
bake_status = format!("bake error: {e} — JSON sidecar still written");
}
}
}
}
}
if let Err(e) = std::fs::write(&sidecar_path, serde_json::to_string_pretty(&sidecar).unwrap_or_default()) {
return Ok(request.respond(json_response(500, &serde_json::json!({"error": format!("write sidecar: {e}")})))?);
}
*state.signoff_state.lock().unwrap() = serde_json::json!({
"signed": true,
"sidecar_path": sidecar_path.to_string_lossy(),
"step_baked": step_baked,
"step_path": if step_baked { Some(baked_step_path.to_string_lossy().to_string()) } else { None },
"bake_status": bake_status,
});
return Ok(request.respond(json_response(200, &serde_json::json!({
"ok": true,
"sidecar_path": sidecar_path.to_string_lossy(),
"step_baked": step_baked,
"step_path": if step_baked { Some(baked_step_path.to_string_lossy().to_string()) } else { None },
"bake_status": bake_status,
})))?);
}
if path == "/api/signoff" && method == Method::Get {
let v = state.signoff_state.lock().unwrap().clone();
return Ok(request.respond(json_response(200, &v))?);
}
// ---- Visibility ------------------------------------------------------
if path == "/api/visibility" && method == Method::Post {
if let Some(v) = parse_json(&mut request) {
*state.component_visibility.lock().unwrap() = v;
}
return Ok(request.respond(json_response(200, &serde_json::json!({"ok": true})))?);
}
// ---- Sections --------------------------------------------------------
if path == "/api/sections" && method == Method::Post {
if let Some(v) = parse_json(&mut request) {
*state.sections.lock().unwrap() = v;
}
return Ok(request.respond(json_response(200, &serde_json::json!({"ok": true})))?);
}
if path == "/api/sections" && method == Method::Get {
let v = state.sections.lock().unwrap().clone();
return Ok(request.respond(json_response(200, &v))?);
}
// ---- Selection (UI -> server, drives `inspect` CLI) ------------------
if path == "/api/selection" && method == Method::Post {
if let Some(v) = parse_json(&mut request) {
*state.selection.lock().unwrap() = v;
}
return Ok(request.respond(json_response(200, &serde_json::json!({"ok": true})))?);
}
if path == "/api/selection" && method == Method::Get {
let v = state.selection.lock().unwrap().clone();
return Ok(request.respond(json_response(200, &v))?);
}
// ---- Command bus (CLI -> server -> UI poll) --------------------------
if path == "/api/command" && method == Method::Post {
if let Some(v) = parse_json(&mut request) {
*state.pending_command.lock().unwrap() = Some(v);
}
return Ok(request.respond(json_response(200, &serde_json::json!({"ok": true})))?);
}
if path == "/api/poll-command" && method == Method::Get {
let v = state.pending_command.lock().unwrap().take();
return Ok(request.respond(json_response(200, &serde_json::json!({"command": v})))?);
}
// ---- JS eval pipeline ------------------------------------------------
if path == "/eval" && method == Method::Post {
let body: serde_json::Value = parse_json(&mut request).unwrap_or(serde_json::json!({}));
let code = body
.get("code")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if code.is_empty() {
return Ok(request.respond(json_response(
400,
&serde_json::json!({"error": "missing 'code'"}),
))?);
}
let id = next_eval_id(state);
state.eval_jobs.lock().unwrap().push(EvalJob {
id: id.clone(),
code,
result: None,
done: false,
taken: false,
});
return Ok(request.respond(json_response(200, &serde_json::json!({"id": id})))?);
}
if path == "/eval/pending" && method == Method::Get {
let mut jobs = state.eval_jobs.lock().unwrap();
let next = jobs
.iter_mut()
.find(|j| !j.done && !j.taken)
.map(|j| {
j.taken = true;
serde_json::json!({"id": j.id, "code": j.code})
});
return Ok(request.respond(json_response(200, &serde_json::json!({"job": next})))?);
}
if path.starts_with("/eval/") && path.ends_with("/result") && method == Method::Post {
let id = path
.trim_start_matches("/eval/")
.trim_end_matches("/result")
.to_string();
let body = parse_json(&mut request).unwrap_or(serde_json::json!(null));
let mut jobs = state.eval_jobs.lock().unwrap();
if let Some(job) = jobs.iter_mut().find(|j| j.id == id) {
job.result = Some(body);
job.done = true;
}
return Ok(request.respond(json_response(200, &serde_json::json!({"ok": true})))?);
}
if path.starts_with("/eval/")
&& !path.ends_with("/result")
&& path != "/eval/pending"
&& method == Method::Get
{
let id = path.trim_start_matches("/eval/").to_string();
let jobs = state.eval_jobs.lock().unwrap();
let body = match jobs.iter().find(|j| j.id == id) {
Some(j) => serde_json::json!({
"id": j.id,
"done": j.done,
"result": j.result.clone().unwrap_or(serde_json::Value::Null),
}),
None => serde_json::json!({"error": "no such id"}),
};
return Ok(request.respond(json_response(200, &body))?);
}
// ---- Console forwarder ----------------------------------------------
if path == "/console" && method == Method::Post {
if let Some(v) = parse_json(&mut request) {
let level = v.get("level").and_then(|x| x.as_str()).unwrap_or("log");
let text = v.get("text").and_then(|x| x.as_str()).unwrap_or("");
state.push_console(level, text);
}
return Ok(request.respond(json_response(200, &serde_json::json!({"ok": true})))?);
}
if path == "/console" && method == Method::Get {
let qs = url.split('?').nth(1).unwrap_or("");
let since: u64 = qs
.split('&')
.find_map(|p| p.strip_prefix("since=").and_then(|v| v.parse().ok()))
.unwrap_or(0);
let buf = state.console.lock().unwrap();
let messages: Vec<_> = buf
.iter()
.filter(|m| m.seq >= since)
.map(|m| {
serde_json::json!({
"seq": m.seq,
"ts_ms": m.ts_ms,
"level": m.level,
"text": m.text,
})
})
.collect();
let next_since = buf.iter().last().map(|m| m.seq + 1).unwrap_or(since);
let body = serde_json::json!({"messages": messages, "next_since": next_since});
return Ok(request.respond(json_response(200, &body))?);
}
// ---- 404 -------------------------------------------------------------
Ok(request.respond(json_response(
404,
&serde_json::json!({"error": format!("no route for {} {}", method, path)}),
))?)
}