← Commit history
Cargo.lock+1−1
@@ -527,7 +527,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"  [[package]] name = "step2glb"-version = "0.12.0"+version = "0.12.1" dependencies = [  "base64",  "clap",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "step2glb"-version = "0.12.0"+version = "0.12.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"
VERSION+1−1
@@ -1 +1 @@-0.12.0+0.12.1
adom-step2glb
⋯ 1 unchanged line ⋯
adom-step2glb-release
⋯ 1 unchanged line ⋯
package.json+3−2
@@ -1,6 +1,6 @@ {   "slug": "adom-step2glb",-  "version": "1.2.2",+  "version": "1.2.3",   "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": [@@ -69,5 +69,6 @@       "prompt": "Convert this STEP and show me the result with materials"     }   ],-  "title": "Adom Step2GLB - The Adom STEP Engine"+  "title": "Adom Step2GLB - The Adom STEP Engine",+  "changelog": "1.2.3 (CLI 0.12.1): `thumbnail` no longer advertises GLB input; it refuses a GLB (by --kind, extension or glTF magic) before upload with a pointer to a glTF viewer (adom/adom-step2glb#11). `thumbnail` also polls the service job queue again, so the command works (0.12.0 printed the 202 job JSON as an error). Reconciles the wiki-only 0.12.0 (silk overlays in plain mode) with GitHub main 0.11.2-0.11.3 (--compress, web-grade LOD twin); source of truth is adom-inc/step2glb." }
page.json+4−3
@@ -1,6 +1,6 @@ {   "slug": "adom-step2glb",-  "version": "1.2.2",+  "version": "1.2.3",   "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": [@@ -78,5 +78,6 @@   ],   "install": {     "binary_name": "step2glb"-  }-}+  },+  "changelog": "1.2.3 (CLI 0.12.1): `thumbnail` no longer advertises GLB input; it refuses a GLB (by --kind, extension or glTF magic) before upload with a pointer to a glTF viewer (adom/adom-step2glb#11). `thumbnail` also polls the service job queue again, so the command works (0.12.0 printed the 202 job JSON as an error). Reconciles the wiki-only 0.12.0 (silk overlays in plain mode) with GitHub main 0.11.2-0.11.3 (--compress, web-grade LOD twin); source of truth is adom-inc/step2glb."+}\ No newline at end of file
src/cli.rs+30−5
@@ -86,6 +86,10 @@ pub struct ConvertArgs {     /// marker names when they carry a size (pin_size_source: markers).     #[arg(long, default_value = "medium")]     pub pin: String,+    /// Compression override (#4): quantize | draco | none. Omit for the+    /// mode-aware default (molecule -> draco optimize, plain -> quantize).+    #[arg(long)]+    pub compress: Option<String>,     /// Top silkscreen PNG overlay (white artwork on transparent). Works in     /// both plain and --molecule mode; the response reports silkscreen_applied.     #[arg(long)]@@ -115,7 +119,9 @@ pub struct FeaturesArgs {  #[derive(Args, Debug)] pub struct ThumbnailArgs {-    /// Input .step / .stp / .glb file. Kind auto-detected from extension or magic bytes.+    /// Input .step / .stp file. GLB is NOT accepted: the shared service renders from+    /// STEP only (its OCCT build's glTF reader is incomplete, service v0.4.0+). A+    /// textured GLB is already renderable as-is by a glTF viewer such as the wiki's.     pub input: PathBuf,     /// Output .png path. Defaults to <input stem>-thumb.png beside the input.     #[arg(short, long)]@@ -129,7 +135,7 @@ pub struct ThumbnailArgs {     /// Camera pose: iso, iso2, top, front, side.     #[arg(long, default_value = "iso")]     pub pose: String,-    /// Force input kind: auto (default), step, or glb.+    /// Force input kind: auto (default) or step. `glb` is refused before upload; see `input`.     #[arg(long, default_value = "auto")]     pub kind: String,     /// Pre-rotate the model before rendering. Six face-up variants:@@ -278,6 +284,7 @@ pub fn convert_cmd(args: ConvertArgs) {         let opts = client::MoleculeOpts {             board,             pin: args.pin.clone(),+            compress: args.compress.clone(),             silk_top: read_silk(&args.silk_top),             silk_bottom: read_silk(&args.silk_bottom),         };@@ -298,6 +305,7 @@ pub fn convert_cmd(args: ConvertArgs) {         let opts = client::MoleculeOpts {             board: None,             pin: args.pin.clone(),+            compress: args.compress.clone(),             silk_top: read_silk(&args.silk_top),             silk_bottom: read_silk(&args.silk_bottom),         };@@ -328,7 +336,7 @@ pub fn convert_cmd(args: ConvertArgs) {         return;     } -    match client::convert_bytes(&bytes, &out) {+    match client::convert_bytes(&bytes, &out, args.compress.as_deref()) {         Ok(r) => print_success(&r, &out, Some(&args.input.display().to_string())),         Err(e) => {             err(format!("conversion failed: {e}"));@@ -491,7 +499,17 @@ pub fn thumbnail_cmd(args: ThumbnailArgs) {         err(format!("invalid --pose `{}`; must be one of: {}", args.pose, valid_poses.join(", ")));         std::process::exit(1);     }-    let valid_kinds = ["auto", "step", "glb"];+    // GLB is refused HERE, not by the service after an upload (adom/adom-step2glb#11).+    // The help used to advertise `.glb` and `--kind glb`; the service has rejected GLB+    // bodies since v0.4.0 (cadquery-ocp's RWGltf_CafReader is incomplete), and the+    // 400 it returned told the caller to send STEP, which cannot carry the textures+    // that made the GLB worth rendering in the first place.+    if args.kind == "glb" {+        err("--kind glb: the shared service renders STEP only; it cannot read GLB.".to_string());+        hint("Render a GLB with a glTF viewer (the wiki's Babylon viewer shows it as-is), or pass the source .step to thumbnail it from CAD.");+        std::process::exit(2);+    }+    let valid_kinds = ["auto", "step"];     if !valid_kinds.contains(&args.kind.as_str()) {         err(format!("invalid --kind `{}`; must be one of: {}", args.kind, valid_kinds.join(", ")));         std::process::exit(1);@@ -518,7 +536,6 @@ pub fn thumbnail_cmd(args: ThumbnailArgs) {     // bytes say. No filesystem read required to pick the hint.     let kind_hint = if args.kind == "auto" {         match args.input.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()) {-            Some(ref e) if e == "glb"  => "glb",             Some(ref e) if e == "step" || e == "stp" => "step",             _ => "auto",         }@@ -534,6 +551,14 @@ pub fn thumbnail_cmd(args: ThumbnailArgs) {         Ok(b) => b,         Err(e) => { err(format!("read {}: {e}", args.input.display())); std::process::exit(1); }     };+    // Same refusal for a GLB that arrives under any name: the magic is "glTF".+    let looks_glb = bytes.starts_with(b"glTF")+        || args.input.extension().and_then(|e| e.to_str()).map(|e| e.eq_ignore_ascii_case("glb")).unwrap_or(false);+    if looks_glb {+        err(format!("{} is a GLB; the shared service renders STEP only and would refuse it after upload.", args.input.display()));+        hint("Render a GLB with a glTF viewer (the wiki's Babylon viewer shows it as-is), or pass the source .step to thumbnail it from CAD.");+        std::process::exit(2);+    }     match client::thumbnail_bytes(&bytes, &out, args.width, args.height, &args.pose, kind_hint, &up_norm) {         Ok(r) => print_thumbnail_success(&r, &out, &args.input.display().to_string()),         Err(e) => {
src/client.rs+104−14
@@ -76,9 +76,12 @@ pub fn health() -> Result<Health, String> { }  /// POST /convert with STEP body. Gzips large files to speed upload.-pub fn convert_bytes(step: &[u8], out_path: &Path) -> Result<ConvertResult, String> {+pub fn convert_bytes(step: &[u8], out_path: &Path, compress: Option<&str>) -> 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 mut url = format!("{}/convert", service_url());+    if let Some(c) = compress {+        url.push_str(&format!("?compress={}", urlencode(c)));+    }     let size_mb = step.len() as f64 / (1024.0 * 1024.0);      // Gzip large STEP files — they're text and compress 5x, dramatically@@ -112,6 +115,8 @@ pub fn convert_bytes(step: &[u8], out_path: &Path) -> Result<ConvertResult, Stri pub struct MoleculeOpts {     pub board: Option<(String, Vec<u8>)>,     pub pin: String,+    /// #4: quantize | draco | none; None = mode-aware service default.+    pub compress: Option<String>,     pub silk_top: Option<Vec<u8>>,     pub silk_bottom: Option<Vec<u8>>, }@@ -131,7 +136,8 @@ pub struct MoleculeResult { /// the GLB and — when the job produced them — the footprint and symbol JSON /// beside it (`<stem>_footprint.json` / `<stem>_symbol.json`). With /// `molecule=false` this is a plain convert that still carries silk overlays-/// (#5): the service applies them in either mode.+/// (#5): the service applies them in either mode. `compress` (#4) rides along+/// in both. pub fn convert_multipart(step: &[u8], opts: &MoleculeOpts, molecule: bool, out_path: &Path) -> Result<MoleculeResult, String> {     let stem = out_path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown");     let job_name = if molecule {@@ -139,15 +145,20 @@ pub fn convert_multipart(step: &[u8], opts: &MoleculeOpts, molecule: bool, out_p     } else {         format!("convert-silk-{stem}")     };-    let url = if molecule {+    let mut url = if molecule {         format!(             "{}/convert?molecule=true&pin={}",             service_url(),             urlencode(&opts.pin)         )     } else {-        format!("{}/convert", service_url())+        format!("{}/convert?", service_url())     };+    if let Some(c) = &opts.compress {+        if !url.ends_with('?') { url.push('&'); }+        url.push_str(&format!("compress={}", urlencode(c)));+    }+    if url.ends_with('?') { url.pop(); }      let boundary = "----step2glb-molecule-7f9c2ad41b";     let mut body: Vec<u8> = Vec::new();@@ -205,9 +216,11 @@ fn poll_molecule_job(job_id: &str, out_path: &Path) -> Result<MoleculeResult, St     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));+    for round in 1..=420 {+        // Adaptive poll: 1s for the first 20 rounds (typical molecule converts+        // 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()@@ -229,6 +242,9 @@ fn poll_molecule_job(job_id: &str, out_path: &Path) -> Result<MoleculeResult, St                 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);+                // Web-grade LOD twin (adom/wiki#82), present only when the full+                // model was over the inline budget. Written as <stem>.web.glb.+                fetch_weblod(&base, job_id, out_path);                 return Ok(MoleculeResult {                     convert,                     stats: status,@@ -270,6 +286,24 @@ fn fetch_side_artifact(     Some(path) } +/// GET /jobs/{id}/weblod → write `<glb stem>.web.glb` beside the GLB when the+/// service produced a decimated inline twin. 404 (model within budget) → None.+fn fetch_weblod(base: &str, job_id: &str, out_glb: &Path) -> Option<std::path::PathBuf> {+    let url = format!("{base}/jobs/{job_id}/weblod");+    let r = ureq::get(&url).timeout(Duration::from_secs(120)).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}.web.glb"));+    let mut reader = r.into_reader();+    let mut buf = Vec::new();+    std::io::Read::read_to_end(&mut reader, &mut buf).ok()?;+    std::fs::write(&path, &buf).ok()?;+    eprintln!("  web-grade LOD written: {} ({} KB)", path.display(), buf.len() / 1024);+    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).@@ -290,7 +324,7 @@ pub fn extract_features(step: &[u8]) -> Result<String, String> {  /// 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+/// magic-byte sniffs by default; pass `kind` to force ("step", or /// "auto"). pub fn thumbnail_bytes(     body: &[u8],@@ -325,10 +359,24 @@ pub fn thumbnail_bytes(         .map_err(|e| format!("POST {url} failed: {e}"))?;      let status = r.status();-    if status != 200 {+    // /thumbnail is a queued job on the service now (202 + {job_id, poll}), like every+    // other endpoint. The CLI treated anything but 200 as failure and printed the job+    // JSON as an error, which broke `step2glb thumbnail` outright. Poll, then fetch+    // the PNG from /jobs/{id}/result, which carries the same X-* headers.+    let r = if status == 202 {+        let body_s = r.into_string().unwrap_or_default();+        let job: serde_json::Value = serde_json::from_str(&body_s)+            .map_err(|_| format!("async 202 but invalid JSON: {body_s}"))?;+        let job_id = job["job_id"].as_str()+            .ok_or_else(|| format!("async 202 but no job_id: {body_s}"))?;+        eprintln!("Thumbnail queued (job {job_id}). Polling...");+        poll_binary_job(job_id, "thumbnail")?+    } else if status != 200 {         let resp_body = r.into_string().unwrap_or_default();         return Err(format!("service returned HTTP {status}: {resp_body}"));-    }+    } else {+        r+    };      let width_h = header_u32(&r, "X-Width");     let height_h = header_u32(&r, "X-Height");@@ -431,6 +479,43 @@ pub fn thumbnail_batch_bytes(         .map_err(|e| format!("batch JSON parse: {e}")) } +/// Poll /jobs/{id} until complete, then return the GET /jobs/{id}/result response so the+/// caller can read headers and stream the body. 1s x 30 then 3s x 180 = ~9.5 minutes.+fn poll_binary_job(job_id: &str, what: &str) -> Result<ureq::Response, 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..=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();+        match status["status"].as_str().unwrap_or("unknown") {+            "complete" => {+                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}"));+                }+                return Ok(r);+            }+            "failed" => {+                let e = status["error"].as_str().unwrap_or("unknown error");+                let hint = status["hint"].as_str().map(|h| format!(" ({h})")).unwrap_or_default();+                return Err(format!("{what} job {job_id} failed: {e}{hint}"));+            }+            st => {+                if round % 10 == 0 {+                    eprintln!("  [{round}] still {st}...");+                }+            }+        }+    }+    Err(format!("{what} job {job_id} did not finish within the polling window"))+}+ fn poll_thumbnail_batch_job(job_id: &str) -> Result<BatchThumbnailResult, String> {     let base = service_url();     let poll_url = format!("{base}/jobs/{job_id}");@@ -515,8 +600,11 @@ fn poll_job(job_id: &str, out_path: &Path) -> Result<ConvertResult, String> {     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));+    for round in 1..=730 {+        // Adaptive poll: small STEPs finish in <10s — a 30s tick made every+        // 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();@@ -534,7 +622,9 @@ fn poll_job(job_id: &str, out_path: &Path) -> Result<ConvertResult, String> {                     let body = r.into_string().unwrap_or_default();                     return Err(format!("result download HTTP {st}: {body}"));                 }-                return read_glb_response(r, out_path);+                let cr = read_glb_response(r, out_path);+                fetch_weblod(&base, job_id, out_path);+                return cr;             }             "failed" => {                 let err = status["error"].as_str().unwrap_or("unknown error");
src/preview.rs+1−1
@@ -58,7 +58,7 @@ fn resolve(source: &str) -> Result<(PathBuf, serde_json::Value, String), String>         let out = PathBuf::from(format!("/tmp/step2glb-preview-{}.glb",             as_path.file_stem().map(|s|s.to_string_lossy().to_string()).unwrap_or_else(||"in".into())));         let bytes = fs::read(&as_path).map_err(|e| format!("read {}: {e}", as_path.display()))?;-        let r = client::convert_bytes(&bytes, &out)?;+        let r = client::convert_bytes(&bytes, &out, None)?;         let label = as_path.file_name().map(|n|n.to_string_lossy().to_string()).unwrap_or_else(||source.to_string());         Ok((out.clone(), conversion_to_json(&r, &out), label))     } else {