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.2.3
10 files changed
+145−28
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."+}
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 {