app
Adom Chip Thumbnailer
Public Made by Adomby adom
The chip-icon factory: from a STEP file it renders the complete family of chip icons, shaded 3D orientations, transparent icons, name-lasered variants, and 11 vector outline styles, in a live web app
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972
//! chip-thumbnailer web app: a Hydrogen-webview dashboard that takes a STEP
//! file and renders every icon (symbol, footprint, and all six 3D face-up
//! orientations at sm/md/lg) in real-time. The UI lays out a dashed-box
//! placeholder for each upcoming icon and pops the rendered thumbnail into
//! its box the moment it lands, with a per-icon caption: file name, format,
//! pixel dimensions, KB, and whether it carries real alpha transparency.
//!
//! Follows the Adom app-creator conventions: server is the single source of
//! truth (the UI only polls `GET /api/state` and POSTs commands), every
//! action is curl-drivable, relative URLs throughout, brand-compliant UI,
//! monochrome favicon, console forwarding, graceful `/shutdown`.
use crate::library::{self, ChipPaths, ThreeDOrientation, ThreeDSize};
use crate::render_3d;
use anyhow::{anyhow, Context, Result};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use tiny_http::{Header, Method, Response, Server};
const INDEX_HTML: &str = include_str!("ui/app.html");
const FAVICON_SVG: &str = include_str!("ui/icon.svg");
// Monotonic generation counter. Each `generate` / `regenerate` bumps it; the
// background render thread checks its captured generation before every state
// write and bails the instant a newer run supersedes it (so a Regenerate
// click never lets a stale thread clobber the fresh grid).
static GEN: AtomicU64 = AtomicU64::new(0);
static STATE: Mutex<Option<Session>> = Mutex::new(None);
// Bounded ring buffer of forwarded browser console lines (app-creator §8).
static CONSOLE: Mutex<Vec<String>> = Mutex::new(Vec::new());
#[derive(Clone)]
struct Session {
generation: u64,
name: String,
source: &'static str, // "upload" | "library"
dir: PathBuf,
started: u64,
running: bool,
slots: Vec<Slot>,
up_axis: String, // json_key of the orientation driving the extended icons
up_axis_source: String, // "chip-fetcher" | "override" | "unset"
}
#[derive(Clone)]
struct Slot {
id: String,
kind: &'static str, // "symbol" | "footprint" | "3d"
label: String, // short caption shown under the box
group: String, // group header the box sits under
status: String, // pending | rendering | done | failed | skipped
file: Option<String>,
format: Option<String>,
width: Option<u32>,
height: Option<u32>,
size_bytes: Option<u64>,
transparent: Option<bool>,
light_tile: Option<bool>,
error: Option<String>,
}
/// What the render thread actually does for one slot. chip-thumbnailer renders
/// the chip body (3D orientations); the transparent icon is keyed as a side
/// effect of the Md render, so it has no Job of its own.
#[derive(Clone)]
enum Job {
ThreeD(ThreeDOrientation, ThreeDSize),
}
struct Plan {
id: String,
job: Job,
out: PathBuf,
}
fn now_epoch() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
// ────────────────────────────────────────────────────────────────────────
// HTTP server
// ────────────────────────────────────────────────────────────────────────
pub fn run(port: u16) -> Result<()> {
let addr = format!("0.0.0.0:{port}");
let server = Server::http(&addr).map_err(|e| anyhow!("bind {addr}: {e}"))?;
println!("adom-chip-thumbnailer web app listening on http://{addr}");
println!("open it in Hydrogen: adom-chip-thumbnailer app --port {port}");
println!("or visit: {}", proxy_url_for_port(port));
for mut request in server.incoming_requests() {
// Strip a Coder proxy prefix (e.g. /proxy/8794/) so routes match
// whether we're hit directly or through the wiki proxy.
let raw_url = request.url().to_string();
let url = strip_proxy_prefix(&raw_url);
let path = url.split('?').next().unwrap_or("").to_string();
let method = request.method().clone();
let response = match (&method, path.as_str()) {
(Method::Get, "/") | (Method::Get, "/index.html") => html_response(INDEX_HTML),
(Method::Get, "/favicon.svg") | (Method::Get, "/favicon.ico") => Response::from_string(
FAVICON_SVG.to_string(),
)
.with_header(hdr("Content-Type", "image/svg+xml")),
(Method::Get, "/api/state") => json_response(state_json()),
(Method::Get, "/api/library") => json_response(library_json()),
(Method::Post, "/api/generate") => {
let filename = header_value(&request, "X-Filename");
let ctype = header_value(&request, "Content-Type").unwrap_or_default();
let mut body: Vec<u8> = Vec::new();
let _ = request.as_reader().read_to_end(&mut body);
handle_generate(ctype, filename, body)
}
(Method::Post, "/api/regenerate") => handle_regenerate(),
(Method::Post, "/api/up-axis") => {
let mut body = String::new();
let _ = request.as_reader().read_to_string(&mut body);
handle_set_up_axis(body)
}
(Method::Get, p) if p.starts_with("/thumb/") => serve_thumb(&p["/thumb/".len()..]),
(Method::Post, "/console") => {
let mut body = String::new();
let _ = request.as_reader().read_to_string(&mut body);
push_console(&body);
json_response("{\"ok\":true}".into())
}
(Method::Get, "/console") => json_response(console_json()),
(Method::Post, "/shutdown") => {
let _ = json_response("{\"ok\":true}".into());
println!("shutdown requested — exiting");
let _ = request.respond(json_response("{\"ok\":true}".into()));
std::process::exit(0);
}
_ => not_found(),
};
let _ = request.respond(response);
}
Ok(())
}
// ────────────────────────────────────────────────────────────────────────
// command handlers
// ────────────────────────────────────────────────────────────────────────
fn handle_generate(
ctype: String,
filename: Option<String>,
body: Vec<u8>,
) -> Response<std::io::Cursor<Vec<u8>>> {
// JSON body => library pick: {"mpn":"..."}.
if ctype.contains("application/json") {
let v: serde_json::Value = match serde_json::from_slice(&body) {
Ok(v) => v,
Err(e) => return error_response(400, format!("{{\"error\":\"bad json: {e}\"}}")),
};
let mpn = v.get("mpn").and_then(|s| s.as_str()).unwrap_or("");
if mpn.is_empty() {
return error_response(400, "{\"error\":\"missing mpn\"}".into());
}
return match ChipPaths::for_mpn(mpn) {
Ok(chip) => {
let dir = chip.dir.clone();
start_generation("library", mpn.to_string(), dir, chip);
json_response(state_json())
}
Err(e) => error_response(404, format!("{{\"error\":\"{e}\"}}")),
};
}
// Otherwise: a raw STEP upload.
if body.is_empty() {
return error_response(400, "{\"error\":\"empty body — POST a STEP file\"}".into());
}
if !looks_like_step(&body) {
return error_response(
400,
"{\"error\":\"that does not look like a STEP file (no ISO-10303-21 header)\"}".into(),
);
}
let stem = sanitize_stem(filename.as_deref().unwrap_or("upload"));
let dir = sessions_root().join(&stem);
if let Err(e) = std::fs::create_dir_all(&dir) {
return error_response(500, format!("{{\"error\":\"mkdir: {e}\"}}"));
}
// Clear any stale thumbnails from a previous run of the same file.
if let Ok(entries) = std::fs::read_dir(&dir) {
for e in entries.flatten() {
let p = e.path();
let keep = p
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.ends_with(".step"))
.unwrap_or(false);
if !keep {
let _ = std::fs::remove_file(&p);
}
}
}
let step_path = dir.join(format!("{stem}.step"));
if let Err(e) = std::fs::write(&step_path, &body) {
return error_response(500, format!("{{\"error\":\"write step: {e}\"}}"));
}
let chip = ChipPaths::for_dir(dir.clone(), &stem);
start_generation("upload", stem, dir, chip);
json_response(state_json())
}
fn handle_regenerate() -> Response<std::io::Cursor<Vec<u8>>> {
let (name, dir) = {
let g = STATE.lock().unwrap();
match g.as_ref() {
Some(s) => (s.name.clone(), s.dir.clone()),
None => return error_response(409, "{\"error\":\"nothing to regenerate yet\"}".into()),
}
};
// ChipPaths::for_dir works for both upload + library dirs (same layout).
let chip = ChipPaths::for_dir(dir.clone(), &name);
let source = if dir.starts_with(library::root()) {
"library"
} else {
"upload"
};
start_generation(source, name, dir, chip);
json_response(state_json())
}
/// Override the up orientation that drives the extended icons. Persists the
/// choice to info.json (so adom-symbol and everything downstream read it too),
/// then re-renders ONLY the "Chip icons" tab (outlines + name-laser) from the
/// new axis — the six shaded poses don't depend on the choice, so they stay.
fn handle_set_up_axis(body: String) -> Response<std::io::Cursor<Vec<u8>>> {
let v: serde_json::Value = serde_json::from_str(&body).unwrap_or(serde_json::json!({}));
let axis = v.get("axis").and_then(|a| a.as_str()).unwrap_or("");
if axis.is_empty() {
return error_response(400, "{\"error\":\"missing axis\"}".into());
}
let orient = render_3d::orient_from_token(axis);
let (name, dir, gen) = {
let g = STATE.lock().unwrap();
match g.as_ref() {
Some(s) => (s.name.clone(), s.dir.clone(), s.generation),
None => return error_response(409, "{\"error\":\"no chip loaded\"}".into()),
}
};
let chip = ChipPaths::for_dir(dir, &name);
if let Err(e) = render_3d::set_up_axis(&chip, orient) {
return error_response(500, format!("{{\"error\":\"write info.json: {e}\"}}"));
}
// Drop the stale derived slots, flip to running, and bust the image cache
// (bump `started`) so the regenerated icons re-fetch instead of showing the
// old axis from cache.
{
let mut g = STATE.lock().unwrap();
if let Some(s) = g.as_mut() {
if s.generation == gen {
s.up_axis = orient.json_key().to_string();
s.up_axis_source = "override".to_string();
s.slots.retain(|x| x.kind != "named" && x.kind != "outline");
s.started = now_epoch();
s.running = true;
}
}
}
let chip2 = chip.clone();
std::thread::spawn(move || {
if let Some(step) = chip2.step.as_ref() {
let _ = render_3d::render_family_extras(&chip2, step);
}
if is_current(gen) {
append_family_slots(gen, &chip2);
}
let mut g = STATE.lock().unwrap();
if let Some(s) = g.as_mut() {
if s.generation == gen {
s.running = false;
}
}
});
json_response(state_json())
}
// ────────────────────────────────────────────────────────────────────────
// generation: build slots, spawn the render thread
// ────────────────────────────────────────────────────────────────────────
fn start_generation(source: &'static str, name: String, dir: PathBuf, chip: ChipPaths) {
let generation = GEN.fetch_add(1, Ordering::SeqCst) + 1;
let (slots, plans) = build_slots_and_plans(&chip);
let (up_orient, up_source) = render_3d::up_axis_decision(&chip);
{
let mut g = STATE.lock().unwrap();
*g = Some(Session {
generation,
name,
source,
dir,
started: now_epoch(),
running: !plans.is_empty(),
slots,
up_axis: up_orient.json_key().to_string(),
up_axis_source: up_source.to_string(),
});
}
if plans.is_empty() {
return;
}
std::thread::spawn(move || render_loop(generation, chip, plans));
}
/// Build the display slots (the whole chip-icon family) and the render plan.
/// chip-thumbnailer renders the chip body, so every slot here is a CHIP icon:
/// the shaded 3D orientations and their transparent alpha-keyed icons. The
/// name-lasered + vector-outline variants (produced by service-step2glb) are
/// shown when they already exist in the chip dir.
fn build_slots_and_plans(chip: &ChipPaths) -> (Vec<Slot>, Vec<Plan>) {
let mut slots = Vec::new();
let mut plans = Vec::new();
let have_step = chip.step.is_some();
// 3D: six face-up orientations. Under each orientation: the transparent
// icon (the headline asset adom-symbol consumes), then the shaded md / sm /
// lg renders. Rendered md-first so the icon + canonical view fill first.
for orient in ThreeDOrientation::all() {
let group = format!("Chip {}", short_orient(orient));
// Transparent icon (keyed from the Md shaded render; no own job).
let icon_id = format!("icon-{}", orient.json_key());
if have_step {
slots.push(Slot {
group: group.clone(),
..pending_slot(&icon_id, "icon", &group, "transparent icon")
});
} else {
slots.push(skipped_slot(&icon_id, "icon", &group, "transparent icon", "no .step in the source"));
}
for size in [ThreeDSize::Md, ThreeDSize::Sm, ThreeDSize::Lg] {
let id = format!("3d-{}-{}", orient.json_key(), size.label());
let label = format!("shaded · {}", size.label());
let out = chip.three_d_thumb_path(orient, size);
if have_step {
slots.push(Slot {
group: group.clone(),
..pending_slot(&id, "3d", &group, &label)
});
plans.push(Plan { id, job: Job::ThreeD(orient, size), out });
} else {
slots.push(skipped_slot(&id, "3d", &group, &label, "no .step in the source"));
}
}
}
// Display any name-lasered + vector-outline icons already produced for this
// chip by service-step2glb (read-only tiles, with provenance).
push_existing_assets(chip, &mut slots);
(slots, plans)
}
/// Add read-only display slots for name-lasered + vector-outline icons that
/// exist in the chip dir (produced by service-step2glb's emboss/HLR pass).
fn push_existing_assets(chip: &ChipPaths, slots: &mut Vec<Slot>) {
// Placement-debug tile: explains how the part-number placement was derived
// (full bbox in red, top-slice bbox in cyan). Sits first in the icons tab.
let dbg_file = format!("{}-3d-iso-named-debug.png", chip.mpn);
if chip.dir.join(&dbg_file).is_file() {
let mut s = pending_slot("named-debug", "named", "Placement", "full bbox (red) + top-slice bbox (cyan)");
s.status = "done".into();
s.file = Some(dbg_file.clone());
if let Some(m) = inspect(&chip.dir.join(&dbg_file)) {
s.format = Some(m.format); s.width = Some(m.width); s.height = Some(m.height);
s.size_bytes = Some(m.size_bytes); s.transparent = Some(m.transparent); s.light_tile = Some(m.light_tile);
}
slots.push(s);
}
let named = [
("named-icon", format!("{}-3d-iso-named-icon.png", chip.mpn), "name-lasered icon (facing)"),
("named", format!("{}-3d-iso-named.png", chip.mpn), "name-lasered (facing, opaque)"),
("named90-icon", format!("{}-3d-iso-named90-icon.png", chip.mpn), "name-lasered icon (right-angle)"),
("named90", format!("{}-3d-iso-named90.png", chip.mpn), "name-lasered (right-angle, opaque)"),
];
for (id, file, label) in named {
if chip.dir.join(&file).is_file() {
let mut s = pending_slot(id, "named", "Name-lasered", label);
s.status = "done".into();
s.file = Some(file.clone());
if let Some(m) = inspect(&chip.dir.join(&file)) {
s.format = Some(m.format); s.width = Some(m.width); s.height = Some(m.height);
s.size_bytes = Some(m.size_bytes); s.transparent = Some(m.transparent); s.light_tile = Some(m.light_tile);
}
slots.push(s);
}
}
// Vector outlines: <mpn>-3d-outline-<style>[-named].svg
let mut outlines: Vec<String> = Vec::new();
if let Ok(rd) = std::fs::read_dir(&chip.dir) {
let prefix = format!("{}-3d-outline-", chip.mpn);
for e in rd.flatten() {
let name = e.file_name().to_string_lossy().to_string();
if name.starts_with(&prefix) && name.ends_with(".svg") {
outlines.push(name);
}
}
}
outlines.sort();
for file in outlines {
let style = file
.trim_start_matches(&format!("{}-3d-outline-", chip.mpn))
.trim_end_matches(".svg")
.to_string();
let mut s = pending_slot(&format!("outline-{style}"), "outline", "Vector outlines", &style);
s.status = "done".into();
s.file = Some(file.clone());
if let Some(m) = inspect(&chip.dir.join(&file)) {
s.format = Some(m.format); s.width = Some(m.width); s.height = Some(m.height);
s.size_bytes = Some(m.size_bytes); s.transparent = Some(m.transparent); s.light_tile = Some(m.light_tile);
}
slots.push(s);
}
}
fn render_loop(generation: u64, chip: ChipPaths, plans: Vec<Plan>) {
for plan in plans {
// Bail if a newer generation superseded us.
if !is_current(generation) {
return;
}
if !set_status(generation, &plan.id, "rendering") {
return;
}
let Job::ThreeD(o, s) = plan.job;
let result = render_3d::render_orient_size(&chip, o, s);
if !is_current(generation) {
return;
}
match result {
Ok(_) => {
let meta = inspect(&plan.out);
finish_slot(generation, &plan.id, &plan.out, meta);
// The Md render also keys the transparent icon — finish its slot.
if s == ThreeDSize::Md {
// The as-is tile shows the stable true-as-is icon, not the
// no-suffix file (which gets clobbered with the chosen pose
// for adom-symbol — that's what made as-is look like Y-up).
let icon_out = if o == ThreeDOrientation::AsIs {
chip.dir.join(format!("{}-3d-iso-asis-icon.png", chip.mpn))
} else {
chip.three_d_icon_path(o)
};
let icon_meta = inspect(&icon_out);
finish_slot(generation, &format!("icon-{}", o.json_key()), &icon_out, icon_meta);
}
}
Err(e) => {
fail_slot(generation, &plan.id, format!("{e}"));
if s == ThreeDSize::Md {
fail_slot(generation, &format!("icon-{}", o.json_key()), "shaded render failed".into());
}
}
}
}
// Generate the vector-outline + name-lasered family (service OCCT), then
// surface the produced files as new slots so they appear in the grid.
if is_current(generation) {
if let Some(step) = chip.step.as_ref() {
let _ = render_3d::render_family_extras(&chip, step);
if is_current(generation) {
append_family_slots(generation, &chip);
}
}
}
// Mark the session done.
let mut g = STATE.lock().unwrap();
if let Some(s) = g.as_mut() {
if s.generation == generation {
s.running = false;
}
}
}
/// After the family extras are generated, append display slots for the new
/// outline / name-lasered files (skipping ids already present).
fn append_family_slots(generation: u64, chip: &ChipPaths) {
let mut extra: Vec<Slot> = Vec::new();
push_existing_assets(chip, &mut extra);
let mut g = STATE.lock().unwrap();
let Some(s) = g.as_mut() else { return };
if s.generation != generation {
return;
}
let have: std::collections::HashSet<String> = s.slots.iter().map(|x| x.id.clone()).collect();
for slot in extra {
if !have.contains(&slot.id) {
s.slots.push(slot);
}
}
}
// ── slot helpers ────────────────────────────────────────────────────────
fn pending_slot(id: &str, kind: &'static str, group: &str, label: &str) -> Slot {
Slot {
id: id.into(),
kind,
label: label.into(),
group: group.into(),
status: "pending".into(),
file: None,
format: None,
width: None,
height: None,
size_bytes: None,
transparent: None,
light_tile: None,
error: None,
}
}
fn skipped_slot(id: &str, kind: &'static str, group: &str, label: &str, why: &str) -> Slot {
Slot {
status: "skipped".into(),
error: Some(why.into()),
..pending_slot(id, kind, group, label)
}
}
fn is_current(generation: u64) -> bool {
STATE
.lock()
.unwrap()
.as_ref()
.map(|s| s.generation == generation)
.unwrap_or(false)
}
fn set_status(generation: u64, id: &str, status: &str) -> bool {
let mut g = STATE.lock().unwrap();
let Some(s) = g.as_mut() else { return false };
if s.generation != generation {
return false;
}
if let Some(slot) = s.slots.iter_mut().find(|x| x.id == id) {
slot.status = status.into();
}
true
}
fn finish_slot(generation: u64, id: &str, out: &Path, meta: Option<ImgMeta>) {
let mut g = STATE.lock().unwrap();
let Some(s) = g.as_mut() else { return };
if s.generation != generation {
return;
}
if let Some(slot) = s.slots.iter_mut().find(|x| x.id == id) {
slot.status = "done".into();
slot.file = out
.file_name()
.map(|n| n.to_string_lossy().to_string());
if let Some(m) = meta {
slot.format = Some(m.format);
slot.width = Some(m.width);
slot.height = Some(m.height);
slot.size_bytes = Some(m.size_bytes);
slot.transparent = Some(m.transparent);
slot.light_tile = Some(m.light_tile);
}
}
}
fn fail_slot(generation: u64, id: &str, err: String) {
let mut g = STATE.lock().unwrap();
let Some(s) = g.as_mut() else { return };
if s.generation != generation {
return;
}
if let Some(slot) = s.slots.iter_mut().find(|x| x.id == id) {
slot.status = "failed".into();
slot.error = Some(err);
}
}
// ────────────────────────────────────────────────────────────────────────
// image introspection — real dims + true alpha transparency
// ────────────────────────────────────────────────────────────────────────
struct ImgMeta {
format: String,
width: u32,
height: u32,
size_bytes: u64,
transparent: bool,
// Vector outlines drawn in the dark theme color (#0d1117 lines, the
// "ink on paper" styles thin/bold) are invisible on a dark tile, so the
// UI puts them on a light tile instead.
light_tile: bool,
}
fn inspect(path: &Path) -> Option<ImgMeta> {
let size_bytes = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
if ext == "svg" {
let txt = std::fs::read_to_string(path).ok()?;
let (width, height) = svg_dims(&txt);
// An SVG is opaque only if it paints a full-canvas background rect; the
// vector outlines paint no background, so they are transparent. (The
// old heuristic wrongly keyed on the presence of #0d1117 anywhere,
// which mislabeled the dark-LINE styles as opaque.)
let transparent = !has_full_canvas_rect(&txt, width, height);
// Dark-line styles (thin/bold draw strokes in #0d1117 / #191919) are
// invisible on a dark tile — flag them so the UI shows a light tile.
let light_tile = transparent
&& (txt.contains("stroke=\"#0d1117\"") || txt.contains("stroke=\"#191919\""));
return Some(ImgMeta {
format: "SVG".into(),
width,
height,
size_bytes,
transparent,
light_tile,
});
}
// PNG: decode the header for true dimensions, then scan the alpha
// channel for any sub-255 pixel so "transparent" reflects reality, not
// just "has an alpha channel".
let file = std::fs::File::open(path).ok()?;
let decoder = png::Decoder::new(file);
let mut reader = decoder.read_info().ok()?;
let (width, height, has_trns) = {
let info = reader.info();
(info.width, info.height, info.trns.is_some())
};
let mut buf = vec![0u8; reader.output_buffer_size()];
let frame = reader.next_frame(&mut buf).ok()?;
let bytes = &buf[..frame.buffer_size()];
let (out_color, _bd) = reader.output_color_type();
let transparent = match out_color {
png::ColorType::Rgba => bytes.chunks_exact(4).any(|p| p[3] < 255),
png::ColorType::GrayscaleAlpha => bytes.chunks_exact(2).any(|p| p[1] < 255),
_ => has_trns,
};
Some(ImgMeta {
format: "PNG".into(),
width,
height,
size_bytes,
transparent,
light_tile: false,
})
}
/// True if the SVG paints a rectangle covering the whole canvas (an opaque
/// background). Used to tell a baked-background SVG from a bare line drawing.
fn has_full_canvas_rect(txt: &str, w: u32, h: u32) -> bool {
// Look for a <rect> sized to 100% or the exact viewBox dims with a fill.
let wh = format!("width=\"{w}\"");
let hh = format!("height=\"{h}\"");
txt.contains("<rect")
&& (txt.contains("width=\"100%\"")
|| (txt.contains(&wh) && txt.contains(&hh)))
&& txt.contains("fill=\"#")
}
fn svg_dims(txt: &str) -> (u32, u32) {
// Prefer viewBox; fall back to width/height attributes.
if let Some(c) = regex_once(r#"viewBox\s*=\s*["']\s*[-\d.]+\s+[-\d.]+\s+([\d.]+)\s+([\d.]+)"#, txt)
{
return (c.0.round() as u32, c.1.round() as u32);
}
let w = regex_one(r#"width\s*=\s*["']([\d.]+)"#, txt).unwrap_or(0.0);
let h = regex_one(r#"height\s*=\s*["']([\d.]+)"#, txt).unwrap_or(0.0);
(w.round() as u32, h.round() as u32)
}
fn regex_once(pat: &str, txt: &str) -> Option<(f64, f64)> {
let re = regex::Regex::new(pat).ok()?;
let c = re.captures(txt)?;
Some((c.get(1)?.as_str().parse().ok()?, c.get(2)?.as_str().parse().ok()?))
}
fn regex_one(pat: &str, txt: &str) -> Option<f64> {
let re = regex::Regex::new(pat).ok()?;
let c = re.captures(txt)?;
c.get(1)?.as_str().parse().ok()
}
// ────────────────────────────────────────────────────────────────────────
// JSON serialization (hand-rolled so we don't have to derive Serialize on
// the state types, which carry non-serde enums)
// ────────────────────────────────────────────────────────────────────────
fn state_json() -> String {
let g = STATE.lock().unwrap();
match g.as_ref() {
None => "{\"hasSession\":false}".into(),
Some(s) => {
let slots: Vec<serde_json::Value> = s
.slots
.iter()
.map(|x| {
serde_json::json!({
"id": x.id,
"kind": x.kind,
"label": x.label,
"group": x.group,
"status": x.status,
"file": x.file,
"format": x.format,
"width": x.width,
"height": x.height,
"sizeBytes": x.size_bytes,
"transparent": x.transparent,
"lightTile": x.light_tile,
"error": x.error,
// Which tab the box lives under, and (for orientation
// tiles) which axis it represents so the UI can drive
// the up-orientation picker.
"tab": slot_tab(x.kind),
"axis": slot_axis(&x.id, x.kind),
})
})
.collect();
let up_orient = render_3d::orient_from_token(&s.up_axis);
serde_json::json!({
"hasSession": true,
"name": s.name,
"source": s.source,
"running": s.running,
"started": s.started,
"total": s.slots.len(),
"done": s.slots.iter().filter(|x| x.status == "done").count(),
"failed": s.slots.iter().filter(|x| x.status == "failed").count(),
// The orientation decision that drives the "Chip icons" tab.
"upAxis": s.up_axis,
"upAxisLabel": short_orient(up_orient),
"upAxisSource": s.up_axis_source,
"slots": slots,
})
.to_string()
}
}
}
fn library_json() -> String {
let mut entries = Vec::new();
if let Ok(mpns) = library::list_mpns() {
for mpn in mpns {
if let Ok(chip) = ChipPaths::for_mpn(&mpn) {
if chip.step.is_some() {
entries.push(serde_json::json!({
"mpn": mpn,
"hasSym": chip.kicad_sym.is_some(),
"hasMod": chip.kicad_mod.is_some(),
}));
}
}
}
}
serde_json::json!({ "chips": entries }).to_string()
}
// ────────────────────────────────────────────────────────────────────────
// thumb serving + small helpers
// ────────────────────────────────────────────────────────────────────────
fn serve_thumb(name: &str) -> Response<std::io::Cursor<Vec<u8>>> {
// Defend against path traversal — basenames only.
if name.is_empty() || name.contains('/') || name.contains("..") {
return not_found();
}
let dir = {
let g = STATE.lock().unwrap();
match g.as_ref() {
Some(s) => s.dir.clone(),
None => return not_found(),
}
};
let path = dir.join(name);
let ct = match path.extension().and_then(|e| e.to_str()) {
Some("svg") => "image/svg+xml",
Some("png") => "image/png",
_ => "application/octet-stream",
};
match std::fs::read(&path) {
Ok(bytes) => Response::from_data(bytes)
.with_header(hdr("Content-Type", ct))
.with_header(hdr("Cache-Control", "no-store")),
Err(_) => not_found(),
}
}
fn looks_like_step(body: &[u8]) -> bool {
let head_len = body.len().min(512);
let head = String::from_utf8_lossy(&body[..head_len]);
head.contains("ISO-10303-21") || head.contains("ISO-10303")
}
fn sanitize_stem(name: &str) -> String {
let stem = Path::new(name)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("upload");
let cleaned: String = stem
.chars()
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' })
.collect();
let cleaned = cleaned.trim_matches('_').to_string();
if cleaned.is_empty() {
"upload".into()
} else {
cleaned
}
}
/// Which tab a slot's box lives under: the shaded poses + their transparent
/// icons are the orientation *picker* (Tab 1); the name-laser + vector outlines
/// are the icons *derived* from the chosen orientation (Tab 2).
fn slot_tab(kind: &str) -> &'static str {
match kind {
"3d" | "icon" => "orientation",
_ => "icons",
}
}
/// The orientation axis (json_key) an orientation-tile represents, parsed from
/// its slot id ("3d-yUp-md" -> "yUp", "icon-yUp" -> "yUp"). None for the
/// derived (icons-tab) slots, which aren't tied to a single pickable axis.
fn slot_axis(id: &str, kind: &str) -> Option<String> {
match kind {
"3d" => id.splitn(3, '-').nth(1).map(String::from),
"icon" => id.strip_prefix("icon-").map(String::from),
_ => None,
}
}
fn short_orient(o: ThreeDOrientation) -> &'static str {
match o {
ThreeDOrientation::AsIs => "as-is (Z-up)",
ThreeDOrientation::ZDown => "Z-down",
ThreeDOrientation::YUp => "Y-up",
ThreeDOrientation::YDown => "Y-down",
ThreeDOrientation::XUp => "X-up",
ThreeDOrientation::XDown => "X-down",
}
}
fn sessions_root() -> PathBuf {
if let Ok(p) = std::env::var("CHIP_THUMBNAILER_SESSIONS") {
return PathBuf::from(p);
}
std::env::temp_dir().join("chip-thumbnailer-sessions")
}
fn push_console(line: &str) {
let mut c = CONSOLE.lock().unwrap();
c.push(line.to_string());
let len = c.len();
if len > 500 {
c.drain(0..len - 500);
}
}
fn console_json() -> String {
let c = CONSOLE.lock().unwrap();
serde_json::json!({ "lines": *c }).to_string()
}
fn header_value(req: &tiny_http::Request, field: &str) -> Option<String> {
req.headers()
.iter()
.find(|h| h.field.as_str().as_str().eq_ignore_ascii_case(field))
.map(|h| h.value.as_str().to_string())
}
fn strip_proxy_prefix(url: &str) -> String {
// Turn "/proxy/8794/api/state" into "/api/state".
if let Some(rest) = url.strip_prefix("/proxy/") {
if let Some(slash) = rest.find('/') {
return rest[slash..].to_string();
}
}
url.to_string()
}
fn hdr(name: &str, value: &str) -> Header {
Header::from_bytes(name.as_bytes(), value.as_bytes()).unwrap()
}
fn html_response(body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
Response::from_string(body.to_string())
.with_header(hdr("Content-Type", "text/html; charset=utf-8"))
}
fn json_response(body: String) -> Response<std::io::Cursor<Vec<u8>>> {
Response::from_string(body).with_header(hdr("Content-Type", "application/json"))
}
fn error_response(code: u16, body: String) -> Response<std::io::Cursor<Vec<u8>>> {
Response::from_string(body)
.with_status_code(code)
.with_header(hdr("Content-Type", "application/json"))
}
fn not_found() -> Response<std::io::Cursor<Vec<u8>>> {
Response::from_string("not found")
.with_status_code(404)
.with_header(hdr("Content-Type", "text/plain"))
}
// ────────────────────────────────────────────────────────────────────────
// proxy URL + Hydrogen tab open
// ────────────────────────────────────────────────────────────────────────
fn proxy_url_for_port(port: u16) -> String {
if let Ok(host) = std::env::var("ADOM_PROXY_HOST") {
return format!("https://{host}/proxy/{port}/");
}
if let Ok(slug) = std::env::var("ADOM_CONTAINER_SLUG") {
return format!("https://{slug}.adom.cloud/proxy/{port}/");
}
if let Ok(tpl) = std::env::var("VSCODE_PROXY_URI") {
if tpl.contains("{{port}}") {
return tpl.replace("{{port}}", &port.to_string());
}
}
format!("http://127.0.0.1:{port}/")
}
pub fn open_in_hydrogen(port: u16) -> Result<()> {
let url = proxy_url_for_port(port);
println!("opening {url} in Hydrogen webview");
let status = std::process::Command::new("adom-cli")
.args([
"hydrogen",
"webview",
"open-or-refresh",
"--url",
&url,
"--name",
"Chip Thumbnailer",
])
.status()
.context("running adom-cli hydrogen webview open-or-refresh")?;
if !status.success() {
anyhow::bail!("adom-cli webview open returned non-zero exit");
}
Ok(())
}