app
Adom Step2GLB - The Adom STEP Engine
Public Made by Adomby adom
The Adom STEP engine: fully manage STEP files. Convert to color-preserving GLB, render thumbnails and vector outlines, laser-etch MPNs, create and edit STEP geometry, extract features, and run molecule mode. One name for the thin Rust CLI and the shared OCCT service. Formerly 'step2glb' + 'service-step2glb'.
← Commit history
Publish 1.3.1
9 files changed
+55−27
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "step2glb"-version = "0.13.0"+version = "0.13.1" edition = "2021" description = "STEP → GLB converter that preserves colors from the source CAD via OCCT's XCAF pipeline. CLI + Hydrogen webview app." license = "MIT"
README.md+5@@ -201,6 +201,11 @@ All POSTs return `202 {"job_id": …, "poll": "/jobs/<id>"}`. Poll, then downloa ## What's new +**Service 1.3.1 + CLI 0.13.1** (2026-09-17), all four filed by John Lauer with exact repros:++- **Fixed #13, #14, #15:** `/laser-etch` now fits the part number to the usable top face. Every upward planar face within a band below the highest one is a candidate (the body top between raised terminals counts, so CL21B105KBFNNNE no longer fails with "no top face found at zmax"); both in-plane orientations are compared and the larger font wins (TPSM365R6V5RDNR reads along its 4.5 mm side); the text is scaled to a sampled rectangle proven to lie on the face and not under any higher face, with no minimum font (a 0402 gets 0.52 mm of text on its 0.6 mm body instead of 1.66 mm hanging off both ends), and glyph thickness follows the font. The job status carries `fit` (face, axis, font size, extents, an overflow check, every candidate) so `complete` cannot hide a bad mark. A side-lying source frame is reported as `fit.frame_hint`, never rotated. After John's `fit_mpn_surface.py` (PR #1).+- **Fixed #16:** a job still queued or processing an hour after submission was deleted by the expiry sweep while alive, and the poller got a bare 404. In-flight jobs never expire now; a finished job past the hour answers `410` with `status: "expired"` and a hint instead of `404`, and `404` is reserved for ids the service never had. The CLI says which of the two happened.+ **Service 1.2.1 + CLI 0.12.0** (2026-08-06), both filed by barrett-land, thanks for the sharp repros: - **Fixed #6:** every queued `thumbnail-batch` job died server-side with `NameError: name 'job_type' is not defined` (a copy-pasted resident-worker block referencing out-of-scope variables). And the CLI now polls `202` async batch jobs instead of erroring out with an empty output directory.
VERSION+1−1@@ -1 +1 @@-0.13.0+0.13.1
adom-step2glb⋯ 1 unchanged line ⋯
adom-step2glb-release⋯ 1 unchanged line ⋯
build.sh+4−5@@ -8,12 +8,11 @@ echo "== cargo build --release ==" cargo build --release echo "== installing to /usr/local/bin =="-sudo install -m 0755 target/release/adom-step2glb /usr/local/bin/adom-step2glb-sudo ln -sf /usr/local/bin/adom-step2glb /usr/local/bin/step2glb # back-compat alias-/usr/local/bin/adom-step2glb --version+sudo install -m 0755 target/release/step2glb /usr/local/bin/step2glb+/usr/local/bin/step2glb --version echo "== running install (skill + completions) =="-adom-step2glb install+step2glb install echo ""-echo "done. Try: adom-step2glb health"+echo "done. Try: step2glb health"
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-step2glb",- "version": "1.3.0",+ "version": "1.3.1", "type": "app", "description": "The Adom STEP engine: fully manage STEP files. Convert to color-preserving GLB, render thumbnails and vector outlines, laser-etch MPNs, create and edit STEP geometry, extract features, and run molecule mode. One name for the thin Rust CLI and the shared OCCT service. Formerly 'step2glb' + 'service-step2glb'.", "tags": [
page.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-step2glb",- "version": "1.3.0",+ "version": "1.3.1", "type": "app", "description": "The Adom STEP engine: fully manage STEP files. Convert to color-preserving GLB, render thumbnails and vector outlines, laser-etch MPNs, create and edit STEP geometry, extract features, and run molecule mode. One name for the thin Rust CLI and the shared OCCT service. Formerly 'step2glb' + 'service-step2glb'.", "tags": [
src/client.rs+42−18@@ -212,6 +212,28 @@ pub fn convert_molecule(step: &[u8], opts: &MoleculeOpts, out_path: &Path) -> Re convert_multipart(step, opts, true, out_path) } +/// One GET /jobs/{id} for every poll loop. A 4xx from the service is an answer, not a+/// transport failure: 410 means the job finished but its files expired (results are kept an+/// hour, adom-step2glb #16), 404 means no such id. Both come back as a clear Err with the+/// service's own hint; anything else is the status JSON.+fn poll_status(poll_url: &str, job_id: &str) -> Result<serde_json::Value, String> {+ match ureq::get(poll_url).timeout(Duration::from_secs(15)).call() {+ Ok(r) => Ok(serde_json::from_str(&r.into_string().unwrap_or_default()).unwrap_or_default()),+ Err(ureq::Error::Status(code, r)) => {+ let body: serde_json::Value =+ serde_json::from_str(&r.into_string().unwrap_or_default()).unwrap_or_default();+ let what = body["error"].as_str().unwrap_or("").to_string();+ let hint = body["hint"].as_str().map(|h| format!(" ({h})")).unwrap_or_default();+ Err(match code {+ 410 => format!("job {job_id} expired: {what}{hint}"),+ 404 => format!("job {job_id} is unknown to the service: {what}{hint}"),+ _ => format!("poll {poll_url} returned HTTP {code}: {what}{hint}"),+ })+ }+ Err(e) => Err(format!("poll {poll_url} failed: {e}")),+ }+}+ 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}");@@ -221,12 +243,7 @@ fn poll_molecule_job(job_id: &str, out_path: &Path) -> Result<MoleculeResult, St // finish in 6-12s server-side; a 5s tick was adding up to 5s of pure // idle), then 3s. 20x1 + 400x3 = 20 minutes, same ceiling as before. std::thread::sleep(Duration::from_secs(if round <= 20 { 1 } else { 3 }));- 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();+ let status = poll_status(&poll_url, job_id)?; match status["status"].as_str().unwrap_or("unknown") { "complete" => {@@ -252,6 +269,10 @@ fn poll_molecule_job(job_id: &str, out_path: &Path) -> Result<MoleculeResult, St symbol_path, }); }+ "expired" => {+ let hint = status["hint"].as_str().unwrap_or("results are kept an hour; re-submit");+ return Err(format!("convert job {job_id} expired before its result was fetched: {hint}"));+ } "failed" => { let e = status["error"].as_str().unwrap_or("unknown error"); return Err(format!("convert job {job_id} failed: {e}"));@@ -499,10 +520,7 @@ fn poll_binary_job(job_id: &str, what: &str) -> Result<ureq::Response, String> { let result_url = format!("{base}/jobs/{job_id}/result"); for round in 1..=210 { std::thread::sleep(Duration::from_secs(if round <= 30 { 1 } else { 3 }));- 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();+ let status = poll_status(&poll_url, job_id)?; match status["status"].as_str().unwrap_or("unknown") { "complete" => { let r = ureq::get(&result_url).timeout(Duration::from_secs(120)).call()@@ -513,6 +531,10 @@ fn poll_binary_job(job_id: &str, what: &str) -> Result<ureq::Response, String> { } return Ok(r); }+ "expired" => {+ let hint = status["hint"].as_str().unwrap_or("results are kept an hour; re-submit");+ return Err(format!("{what} job {job_id} expired before its result was fetched: {hint}"));+ } "failed" => { let e = status["error"].as_str().unwrap_or("unknown error"); let hint = status["hint"].as_str().map(|h| format!(" ({h})")).unwrap_or_default();@@ -536,10 +558,7 @@ fn poll_thumbnail_batch_job(job_id: &str) -> Result<BatchThumbnailResult, String for round in 1..=240 { // 240 × 3s = 12 minutes; the service kills batch jobs at 600s. std::thread::sleep(Duration::from_secs(3));- 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();+ let status = poll_status(&poll_url, job_id)?; match status["status"].as_str().unwrap_or("unknown") { "complete" => {@@ -552,6 +571,10 @@ fn poll_thumbnail_batch_job(job_id: &str) -> Result<BatchThumbnailResult, String return r.into_json::<BatchThumbnailResult>() .map_err(|e| format!("batch JSON parse: {e}")); }+ "expired" => {+ let hint = status["hint"].as_str().unwrap_or("results are kept an hour; re-submit");+ return Err(format!("batch job {job_id} expired before its result was fetched: {hint}"));+ } "failed" => { let e = status["error"].as_str().unwrap_or("unknown error"); return Err(format!("batch job {job_id} failed: {e}"));@@ -617,10 +640,7 @@ fn poll_job(job_id: &str, out_path: &Path) -> Result<ConvertResult, String> { // plain convert feel glacial. 1s x 30, then 5s x 700 = ~59 minutes, // same ceiling as the old 120 x 30s. std::thread::sleep(Duration::from_secs(if round <= 30 { 1 } else { 5 }));- 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 status = poll_status(&poll_url, job_id)?; let st = status["status"].as_str().unwrap_or("unknown"); match st {@@ -638,6 +658,10 @@ fn poll_job(job_id: &str, out_path: &Path) -> Result<ConvertResult, String> { fetch_weblod(&base, job_id, out_path); return cr; }+ "expired" => {+ let hint = status["hint"].as_str().unwrap_or("results are kept an hour; re-submit");+ return Err(format!("async job {job_id} expired before its result was fetched: {hint}"));+ } "failed" => { let err = status["error"].as_str().unwrap_or("unknown error"); return Err(format!("async job {job_id} failed: {err}"));