app
Adom Step2GLB - STEP to GLB converter
Public Made by Adomby adom
Color-preserving STEP (.step/.stp) to GLB converter. Thin Rust CLI shelling to a shared OCCT XCAF service. Formerly 'step2glb'.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517
//! Thin HTTP client to service-step2glb. No CAD logic lives here.
use serde::{Deserialize, Serialize};
use std::io::Write;
use std::path::Path;
use std::time::Duration;
/// Baked-in default. Override at runtime with `STEP2GLB_SERVICE_API`.
/// Canonical shared instance (john/service-step2glb container). The service-occt
/// repo rename is repo-only; the occt-fbnsqrdkehdj container is an untargeted hot
/// spare — do not repoint here without a fleet-wide migration (wiki server +
/// docs + this CLI together).
pub const DEFAULT_SERVICE_URL: &str = "https://step2glb-gmdoncpxdwx0.adom.cloud";
pub fn service_url() -> String {
std::env::var("STEP2GLB_SERVICE_API")
.unwrap_or_else(|_| DEFAULT_SERVICE_URL.to_string())
.trim_end_matches('/')
.to_string()
}
fn client_id() -> String {
std::env::var("STEP2GLB_CLIENT")
.unwrap_or_else(|_| format!("step2glb-cli/{}", env!("CARGO_PKG_VERSION")))
}
fn set_identity(req: ureq::Request, job_name: &str) -> ureq::Request {
req.set("X-Client", &client_id())
.set("X-Job-Name", job_name)
}
#[derive(Debug, Deserialize)]
pub struct Health {
pub ok: bool,
pub service: String,
pub version: String,
pub uptime_seconds: u64,
pub converts: u64,
pub kicad_base: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Material {
pub name: Option<String>,
#[serde(rename = "baseColor")]
pub base_color: Option<Vec<f64>>,
pub metal: Option<f64>,
pub roughness: Option<f64>,
}
#[derive(Debug, Clone)]
pub struct ConvertResult {
pub size_bytes: u64,
pub meshes: u32,
pub nodes: u32,
pub materials: Vec<Material>,
pub duration_ms: u64,
}
#[derive(Debug, Clone)]
pub struct ThumbnailResult {
pub size_bytes: u64,
pub width: u32,
pub height: u32,
pub pose: String,
pub src_kind: String,
pub up_axis: String,
pub bbox_mm: Option<serde_json::Value>,
pub duration_ms: u64,
}
pub fn health() -> Result<Health, String> {
let url = format!("{}/health", service_url());
let r = ureq::get(&url).timeout(Duration::from_secs(5)).call()
.map_err(|e| format!("GET {url} failed: {e}"))?;
r.into_json::<Health>().map_err(|e| format!("health JSON parse: {e}"))
}
/// POST /convert with STEP body. Gzips large files to speed upload.
pub fn convert_bytes(step: &[u8], out_path: &Path) -> Result<ConvertResult, String> {
let job_name = format!("convert-{}", out_path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown"));
let url = format!("{}/convert", service_url());
let size_mb = step.len() as f64 / (1024.0 * 1024.0);
// Gzip large STEP files — they're text and compress 5x, dramatically
// reducing upload time and avoiding proxy timeouts.
let (body, encoding): (Vec<u8>, Option<&str>) = if size_mb > 10.0 {
use std::io::Write;
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
encoder.write_all(step).map_err(|e| format!("gzip: {e}"))?;
let compressed = encoder.finish().map_err(|e| format!("gzip finish: {e}"))?;
eprintln!("Compressing {:.1}MB → {:.1}MB for upload", size_mb, compressed.len() as f64 / (1024.0 * 1024.0));
(compressed, Some("gzip"))
} else {
(step.to_vec(), None)
};
let mut req = set_identity(ureq::post(&url), &job_name)
.set("Content-Type", "application/step")
.timeout(Duration::from_secs(300));
if let Some(enc) = encoding {
req = req.set("Content-Encoding", enc);
}
let r = req.send_bytes(&body)
.map_err(|e| format!("POST {url} failed: {e}"))?;
write_response(r, out_path)
}
/// Molecule-mode conversion options (#99). `board` is (filename, bytes) — the
/// server sniffs .kicad_pcb vs .brd from the content; the filename just aids
/// logs. Silk PNGs are white-on-transparent overlays.
pub struct MoleculeOpts {
pub board: Option<(String, Vec<u8>)>,
pub pin: String,
pub silk_top: Option<Vec<u8>>,
pub silk_bottom: Option<Vec<u8>>,
}
/// Molecule job outcome: the GLB stats, the FULL job-status JSON (anchor/gold/
/// footprint stats — printed generically so new service fields surface without
/// a CLI rebuild), and which side artifacts were written beside the GLB.
pub struct MoleculeResult {
pub convert: ConvertResult,
pub stats: serde_json::Value,
pub footprint_path: Option<std::path::PathBuf>,
pub symbol_path: Option<std::path::PathBuf>,
}
/// POST /convert?molecule=true&pin=… as multipart (step + board + silks), poll
/// the job, download the GLB and — when the job produced them — the footprint
/// and symbol JSON beside it (`<stem>_footprint.json` / `<stem>_symbol.json`).
pub fn convert_molecule(step: &[u8], opts: &MoleculeOpts, out_path: &Path) -> Result<MoleculeResult, String> {
let job_name = format!(
"molecule-{}",
out_path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown")
);
let url = format!(
"{}/convert?molecule=true&pin={}",
service_url(),
urlencode(&opts.pin)
);
let boundary = "----step2glb-molecule-7f9c2ad41b";
let mut body: Vec<u8> = Vec::new();
let mut add_part = |name: &str, filename: &str, bytes: &[u8], body: &mut Vec<u8>| {
body.extend_from_slice(
format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"; filename=\"{filename}\"\r\nContent-Type: application/octet-stream\r\n\r\n"
)
.as_bytes(),
);
body.extend_from_slice(bytes);
body.extend_from_slice(b"\r\n");
};
add_part("step", "in.step", step, &mut body);
if let Some((fname, b)) = &opts.board {
add_part("board", fname, b, &mut body);
}
if let Some(b) = &opts.silk_top {
add_part("silk_top", "silk_top.png", b, &mut body);
}
if let Some(b) = &opts.silk_bottom {
add_part("silk_bottom", "silk_bottom.png", b, &mut body);
}
body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
let r = set_identity(ureq::post(&url), &job_name)
.set(
"Content-Type",
&format!("multipart/form-data; boundary={boundary}"),
)
.timeout(Duration::from_secs(300))
.send_bytes(&body)
.map_err(|e| format!("POST {url} failed: {e}"))?;
let status = r.status();
let body_s = r.into_string().unwrap_or_default();
if status != 202 && status != 200 {
return Err(format!("service returned HTTP {status}: {body_s}"));
}
let job: serde_json::Value =
serde_json::from_str(&body_s).map_err(|_| format!("unexpected response: {body_s}"))?;
let job_id = job["job_id"]
.as_str()
.ok_or_else(|| format!("no job_id in response: {body_s}"))?;
eprintln!("Molecule conversion queued (job {job_id}). Polling...");
poll_molecule_job(job_id, out_path)
}
fn poll_molecule_job(job_id: &str, out_path: &Path) -> Result<MoleculeResult, String> {
let base = service_url();
let poll_url = format!("{base}/jobs/{job_id}");
for round in 1..=240 {
// 240 × 5s = 20 minutes; molecule boards convert in well under that.
std::thread::sleep(Duration::from_secs(5));
let r = ureq::get(&poll_url)
.timeout(Duration::from_secs(15))
.call()
.map_err(|e| format!("poll {poll_url} failed: {e}"))?;
let status: serde_json::Value =
serde_json::from_str(&r.into_string().unwrap_or_default()).unwrap_or_default();
match status["status"].as_str().unwrap_or("unknown") {
"complete" => {
let result_url = format!("{base}/jobs/{job_id}/result");
let r = ureq::get(&result_url)
.timeout(Duration::from_secs(120))
.call()
.map_err(|e| format!("GET {result_url} failed: {e}"))?;
if r.status() != 200 {
let b = r.into_string().unwrap_or_default();
return Err(format!("result download HTTP error: {b}"));
}
let convert = read_glb_response(r, out_path)?;
let footprint_path = fetch_side_artifact(&base, job_id, "footprint", out_path);
let symbol_path = fetch_side_artifact(&base, job_id, "symbol", out_path);
return Ok(MoleculeResult {
convert,
stats: status,
footprint_path,
symbol_path,
});
}
"failed" => {
let e = status["error"].as_str().unwrap_or("unknown error");
return Err(format!("molecule job {job_id} failed: {e}"));
}
st => {
if round % 6 == 0 {
eprintln!(" [{round}] still {st}...");
}
}
}
}
Err(format!("molecule job {job_id} timed out after 20 minutes"))
}
/// GET /jobs/{id}/footprint|symbol → write `<glb stem>_<kind>.json` beside the
/// GLB. 404 (job produced none — e.g. no board attached) → None, not an error.
fn fetch_side_artifact(
base: &str,
job_id: &str,
kind: &str,
out_glb: &Path,
) -> Option<std::path::PathBuf> {
let url = format!("{base}/jobs/{job_id}/{kind}");
let r = ureq::get(&url).timeout(Duration::from_secs(30)).call().ok()?;
if r.status() != 200 {
return None;
}
let stem = out_glb.file_stem()?.to_str()?.to_string();
let path = out_glb.with_file_name(format!("{stem}_{kind}.json"));
let body = r.into_string().ok()?;
std::fs::write(&path, body).ok()?;
Some(path)
}
/// POST /features with STEP body. Returns the features JSON as a string
/// (the service emits the canonical format directly; we don't re-shape it
/// here so future schema additions land without a CLI rebuild).
pub fn extract_features(step: &[u8]) -> Result<String, String> {
let url = format!("{}/features", service_url());
let r = set_identity(ureq::post(&url), "extract-features")
.set("Content-Type", "application/step")
.timeout(Duration::from_secs(60))
.send_bytes(step)
.map_err(|e| format!("POST {url} failed: {e}"))?;
let status = r.status();
if status != 200 {
let body = r.into_string().unwrap_or_default();
return Err(format!("service returned HTTP {status}: {body}"));
}
r.into_string().map_err(|e| format!("read body: {e}"))
}
/// POST /thumbnail with STEP or GLB body. Streams the PNG to `out_path` and
/// parses the X-* response headers into a `ThumbnailResult`. The service
/// magic-byte sniffs by default; pass `kind` to force ("step", "glb", or
/// "auto").
pub fn thumbnail_bytes(
body: &[u8],
out_path: &Path,
width: u32,
height: u32,
pose: &str,
kind: &str,
up_axis: &str,
) -> Result<ThumbnailResult, String> {
// Pick the Content-Type from the kind hint so HTTP middleware /
// proxies have a real type to log. The body content always wins on
// the server side via magic-byte sniff.
let content_type = match kind {
"glb" => "model/gltf-binary",
_ => "application/step",
};
let url = format!(
"{}/thumbnail?width={}&height={}&pose={}&kind={}&upAxis={}",
service_url(),
width,
height,
urlencode(pose),
urlencode(kind),
urlencode(up_axis),
);
let job_name = format!("thumbnail-{}-{}", pose, out_path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown"));
let r = set_identity(ureq::post(&url), &job_name)
.set("Content-Type", content_type)
.timeout(Duration::from_secs(180))
.send_bytes(body)
.map_err(|e| format!("POST {url} failed: {e}"))?;
let status = r.status();
if status != 200 {
let resp_body = r.into_string().unwrap_or_default();
return Err(format!("service returned HTTP {status}: {resp_body}"));
}
let width_h = header_u32(&r, "X-Width");
let height_h = header_u32(&r, "X-Height");
let duration_ms = u64::from(header_u32(&r, "X-Duration-Ms"));
let pose_h = r.header("X-Pose").unwrap_or(pose).to_string();
let src_kind_h = r.header("X-Src-Kind").unwrap_or(kind).to_string();
let up_axis_h = r.header("X-Up-Axis").unwrap_or(up_axis).to_string();
let bbox_mm = r
.header("X-Bbox-Mm")
.and_then(|s| serde_json::from_str(s).ok());
let mut file = std::fs::File::create(out_path)
.map_err(|e| format!("create {}: {e}", out_path.display()))?;
let mut reader = r.into_reader();
let size_bytes = std::io::copy(&mut reader, &mut file)
.map_err(|e| format!("write {}: {e}", out_path.display()))?;
file.flush().ok();
Ok(ThumbnailResult {
size_bytes,
width: width_h,
height: height_h,
pose: pose_h,
src_kind: src_kind_h,
up_axis: up_axis_h,
bbox_mm,
duration_ms,
})
}
#[derive(Debug, Clone, Deserialize)]
pub struct BatchThumbnailEntry {
#[serde(rename = "upAxis")]
pub up_axis: String,
pub size: String,
pub width: u32,
pub height: u32,
#[serde(rename = "sizeBytes")]
pub size_bytes: u64,
#[serde(rename = "dataB64")]
pub data_b64: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct BatchThumbnailResult {
pub ok: bool,
#[serde(rename = "srcKind")]
pub src_kind: String,
pub pose: String,
#[serde(rename = "bboxMm")]
pub bbox_mm: Option<serde_json::Value>,
#[serde(rename = "loadDurationMs")]
pub load_duration_ms: u64,
#[serde(rename = "totalDurationMs")]
pub total_duration_ms: u64,
#[serde(rename = "thumbnailCount")]
pub thumbnail_count: u32,
pub thumbnails: Vec<BatchThumbnailEntry>,
#[serde(default, rename = "renderWarnings")]
pub render_warnings: Vec<String>,
}
/// POST /thumbnail-batch with STEP body — load STEP once, render N×M variants
/// in one V3d session. ~5–10× faster than per-variant /thumbnail calls.
pub fn thumbnail_batch_bytes(
body: &[u8],
orientations: &str,
sizes: &str,
pose: &str,
) -> Result<BatchThumbnailResult, String> {
let url = format!(
"{}/thumbnail-batch?orientations={}&sizes={}&pose={}&kind=step",
service_url(),
urlencode(orientations),
urlencode(sizes),
urlencode(pose),
);
let r = set_identity(ureq::post(&url), "thumbnail-batch")
.set("Content-Type", "application/step")
.timeout(Duration::from_secs(300))
.send_bytes(body)
.map_err(|e| format!("POST {url} failed: {e}"))?;
let status = r.status();
if status != 200 {
let body = r.into_string().unwrap_or_default();
return Err(format!("service returned HTTP {status}: {body}"));
}
r.into_json::<BatchThumbnailResult>()
.map_err(|e| format!("batch JSON parse: {e}"))
}
/// GET /convert?library=<L>&name=<N> — service fetches STEP from service-kicad
/// and converts in one round-trip.
pub fn convert_from_library(lib_slash_name: &str, out_path: &Path) -> Result<ConvertResult, String> {
let (lib, name) = lib_slash_name.rsplit_once('/').ok_or_else(|| {
format!("bad path {lib_slash_name:?}; expected <Library>/<Name>")
})?;
let url = format!(
"{}/convert?library={}&name={}",
service_url(),
urlencode(lib),
urlencode(name),
);
let r = ureq::get(&url)
.timeout(Duration::from_secs(120))
.call()
.map_err(|e| format!("GET {url} failed: {e}"))?;
write_response(r, out_path)
}
fn write_response(r: ureq::Response, out_path: &Path) -> Result<ConvertResult, String> {
let status = r.status();
// Async job queue: server returns 202 with {job_id, poll} for large files.
// Poll until complete, then download the result.
if status == 202 {
let body = r.into_string().unwrap_or_default();
let job: serde_json::Value = serde_json::from_str(&body)
.map_err(|_| format!("async 202 but invalid JSON: {body}"))?;
let job_id = job["job_id"].as_str()
.ok_or_else(|| format!("async 202 but no job_id: {body}"))?;
eprintln!("Large file queued for async conversion (job {job_id}). Polling...");
return poll_job(job_id, out_path);
}
if status != 200 {
let body = r.into_string().unwrap_or_default();
return Err(format!("service returned HTTP {status}: {body}"));
}
read_glb_response(r, out_path)
}
fn poll_job(job_id: &str, out_path: &Path) -> Result<ConvertResult, String> {
let base = service_url();
let poll_url = format!("{base}/jobs/{job_id}");
let result_url = format!("{base}/jobs/{job_id}/result");
for round in 1..=120 { // up to 60 minutes (120 × 30s)
std::thread::sleep(Duration::from_secs(30));
let r = ureq::get(&poll_url).timeout(Duration::from_secs(15)).call()
.map_err(|e| format!("poll {poll_url} failed: {e}"))?;
let body = r.into_string().unwrap_or_default();
let status: serde_json::Value = serde_json::from_str(&body).unwrap_or_default();
let st = status["status"].as_str().unwrap_or("unknown");
match st {
"complete" => {
let dur = status["duration_seconds"].as_f64().unwrap_or(0.0);
eprintln!("Job {job_id} complete ({dur:.0}s). Downloading GLB...");
let r = ureq::get(&result_url).timeout(Duration::from_secs(120)).call()
.map_err(|e| format!("GET {result_url} failed: {e}"))?;
let st = r.status();
if st != 200 {
let body = r.into_string().unwrap_or_default();
return Err(format!("result download HTTP {st}: {body}"));
}
return read_glb_response(r, out_path);
}
"failed" => {
let err = status["error"].as_str().unwrap_or("unknown error");
return Err(format!("async job {job_id} failed: {err}"));
}
_ => {
if round % 4 == 0 {
eprintln!(" [{round}] still {st}...");
}
}
}
}
Err(format!("async job {job_id} timed out after 60 minutes of polling"))
}
fn read_glb_response(r: ureq::Response, out_path: &Path) -> Result<ConvertResult, String> {
let meshes = header_u32(&r, "X-Meshes");
let nodes = header_u32(&r, "X-Nodes");
let duration_ms = u64::from(header_u32(&r, "X-Duration-Ms"));
let mats_json = r.header("X-Materials-Json").unwrap_or("[]").to_string();
let materials: Vec<Material> = serde_json::from_str(&mats_json).unwrap_or_default();
// Stream body to disk.
let mut file = std::fs::File::create(out_path).map_err(|e| format!("create {}: {e}", out_path.display()))?;
let mut reader = r.into_reader();
let size_bytes = std::io::copy(&mut reader, &mut file).map_err(|e| format!("write {}: {e}", out_path.display()))?;
file.flush().ok();
Ok(ConvertResult { size_bytes, meshes, nodes, materials, duration_ms })
}
fn header_u32(r: &ureq::Response, name: &str) -> u32 {
r.header(name).and_then(|s| s.parse().ok()).unwrap_or(0)
}
fn urlencode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
if matches!(b, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~') {
out.push(b as char);
} else {
out.push_str(&format!("%{:02X}", b));
}
}
out
}