app
Board to Molecule
Public Made by Adomby adom
Turn any KiCad, Fusion/EAGLE, or Altium board into an Adom molecule: wings plus MP1-MP4 machine pins, written back in the board's own format.
main
Colby Knox
board-to-molecule 1.0.0: Rust source, six before/after examples (KiCad, Fusion, Altium), Adom 3D viewer readme
eb9ea91
4d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137
//! `serve`: the gallery viewer, a dependency-free HTTP server.
//!
//! Serves the embedded viewer page, `api/examples` (every showcase bundle under the
//! gallery directory), and the bundle files. All URLs are relative so the page works
//! behind the Hydrogen webview path proxy.
use anyhow::{Context, Result};
use serde_json::{json, Value};
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
const INDEX: &str = include_str!("../../viewer/index.html");
const VIEWER3D: &str = include_str!("../../viewer/viewer3d.html");
fn examples(gallery: &Path) -> Value {
let mut list = vec![];
let order = ["kicad", "eagle", "altium"];
if let Ok(rd) = std::fs::read_dir(gallery) {
for e in rd.flatten() {
let dir = e.path();
let Ok(text) = std::fs::read_to_string(dir.join("manifest.json")) else { continue };
let Ok(manifest) = serde_json::from_str::<Value>(&text) else { continue };
let id = e.file_name().to_string_lossy().to_string();
let meta: Value = std::fs::read_to_string(dir.join("meta.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(json!({}));
let title = meta["title"].as_str().map(String::from).unwrap_or_else(|| id.clone());
list.push(json!({"id": id, "title": title, "credit": meta["credit"], "order": meta["order"], "manifest": manifest}));
}
}
list.sort_by_key(|v| {
let eda = v["manifest"]["eda"].as_str().unwrap_or("");
(order.iter().position(|o| *o == eda).unwrap_or(9), v["order"].as_i64().unwrap_or(99), v["id"].as_str().unwrap_or("").to_string())
});
json!({"version": env!("CARGO_PKG_VERSION"), "examples": list})
}
fn content_type(p: &Path) -> &'static str {
match p.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase().as_str() {
"svg" => "image/svg+xml",
"png" => "image/png",
"glb" => "model/gltf-binary",
"json" => "application/json",
"html" => "text/html; charset=utf-8",
"kicad_pcb" | "brd" => "text/plain; charset=utf-8",
_ => "application/octet-stream",
}
}
fn respond(s: &mut TcpStream, code: &str, ctype: &str, body: &[u8], head_only: bool) -> std::io::Result<()> {
write!(s, "HTTP/1.1 {code}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n", body.len())?;
if !head_only {
s.write_all(body)?;
}
s.flush()
}
fn handle(mut s: TcpStream, gallery: PathBuf) -> std::io::Result<()> {
let mut reader = BufReader::new(s.try_clone()?);
let mut line = String::new();
reader.read_line(&mut line)?;
let mut len = 0usize;
loop {
let mut h = String::new();
if reader.read_line(&mut h)? == 0 || h == "\r\n" || h == "\n" {
break;
}
if let Some(v) = h.to_ascii_lowercase().strip_prefix("content-length:") {
len = v.trim().parse().unwrap_or(0);
}
}
let mut parts = line.split_whitespace();
let method = parts.next().unwrap_or("GET").to_string();
let raw = parts.next().unwrap_or("/");
let path = raw.split('?').next().unwrap_or("/");
let head = method == "HEAD";
let decoded = percent_decode(path);
if std::env::var_os("B2M_LOG").is_some() {
eprintln!("[http] {method} {raw}");
}
match decoded.as_str() {
"/" | "/index.html" => respond(&mut s, "200 OK", "text/html; charset=utf-8", INDEX.as_bytes(), head),
"/viewer3d.html" => respond(&mut s, "200 OK", "text/html; charset=utf-8", VIEWER3D.as_bytes(), head),
"/api/examples" => respond(&mut s, "200 OK", "application/json", examples(&gallery).to_string().as_bytes(), head),
"/api/console" => {
// Browser console relay: the page posts its errors and logs here, so an
// agent can read what the webview saw without a devtools window.
let mut body = vec![0u8; len.min(1 << 20)];
std::io::Read::read_exact(&mut reader, &mut body)?;
eprintln!("[console] {}", String::from_utf8_lossy(&body));
respond(&mut s, "204 No Content", "text/plain", b"", true)
}
"/health" => respond(&mut s, "200 OK", "application/json", json!({"ok": true, "version": env!("CARGO_PKG_VERSION")}).to_string().as_bytes(), head),
p if p.starts_with("/examples/") => {
let rel = &p["/examples/".len()..];
if rel.split('/').any(|c| c == ".." || c.is_empty()) {
return respond(&mut s, "400 Bad Request", "text/plain", b"bad path", head);
}
let f = gallery.join(rel);
match std::fs::read(&f) {
Ok(b) => respond(&mut s, "200 OK", content_type(&f), &b, head),
Err(_) => respond(&mut s, "404 Not Found", "text/plain", b"not found", head),
}
}
_ => respond(&mut s, "404 Not Found", "text/plain", b"not found", head),
}
}
fn percent_decode(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
if b[i] == b'%' && i + 2 < b.len() {
if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
out.push(v);
i += 3;
continue;
}
}
out.push(b[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
pub fn serve(gallery: &Path, port: u16) -> Result<()> {
let l = TcpListener::bind(("0.0.0.0", port)).with_context(|| format!("port {port} is in use; pass --port"))?;
println!("OK: serving {} on http://localhost:{port}/", gallery.display());
println!("Hint: open it in a Hydrogen webview; examples come from {}/*/manifest.json", gallery.display());
for conn in l.incoming().flatten() {
let g = gallery.to_path_buf();
std::thread::spawn(move || {
let _ = handle(conn, g);
});
}
Ok(())
}