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
v0.11.0 (page 1.1.0): molecule mode per #99 — flags, stats gates, footprint/symbol download; canonical URL restored; dead-host SKILL_URL fixed
8 files changed
+440−124
Cargo.lock+14−13@@ -8,19 +8,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]]-name = "adom-step2glb"-version = "1.0.0"-dependencies = [- "base64",- "clap",- "clap_complete",- "serde",- "serde_json",- "tiny_http",- "ureq",-]- [[package]] name = "anstream" version = "1.0.0"@@ -538,6 +525,20 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]]+name = "step2glb"+version = "0.11.0"+dependencies = [+ "base64",+ "clap",+ "clap_complete",+ "flate2",+ "serde",+ "serde_json",+ "tiny_http",+ "ureq",+]+ [[package]] name = "strsim" version = "0.11.1"
Cargo.toml+4−3@@ -1,12 +1,12 @@ [package]-name = "adom-step2glb"-version = "1.0.0"+name = "step2glb"+version = "0.11.0" edition = "2021" description = "STEP → GLB converter that preserves colors from the source CAD via OCCT's XCAF pipeline. CLI + Hydrogen webview app." license = "MIT" [[bin]]-name = "adom-step2glb"+name = "step2glb" path = "src/main.rs" [dependencies]@@ -17,6 +17,7 @@ serde_json = "1" tiny_http = "0.12" ureq = { version = "2", features = ["json"] } base64 = "0.22"+flate2 = "1" [profile.release] strip = true
VERSION+1−1@@ -1 +1 @@-0.9.0+0.11.0
adom-step2glb⋯ 1 unchanged line ⋯
package.json+18−5@@ -1,11 +1,24 @@ { "slug": "adom-step2glb",- "version": "1.0.0",+ "version": "1.1.0", "type": "app", "description": "Color-preserving STEP (.step/.stp) to GLB converter. Thin Rust CLI shelling to a shared OCCT XCAF service. Formerly 'step2glb'.",- "tags": ["step", "glb", "cad", "converter", "occt", "3d"],- "hero": { "type": "image", "path": "adom-step2glb-billboard.png" },+ "tags": [+ "step",+ "glb",+ "cad",+ "converter",+ "occt",+ "3d"+ ],+ "hero": {+ "type": "image",+ "path": "adom-step2glb-billboard.png"+ }, "dependencies": {},- "scripts": { "install": "./install.sh", "uninstall": "./uninstall.sh" },+ "scripts": {+ "install": "./install.sh",+ "uninstall": "./uninstall.sh"+ }, "license": "MIT"-}+}
src/cli.rs+141−16@@ -4,19 +4,19 @@ use crate::{note}; use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum}; use std::path::PathBuf; -/// adom-step2glb — STEP → GLB converter client.+/// step2glb — STEP → GLB converter client. /// /// Forwards conversion requests to the shared service-step2glb container /// (heavy OCCT runtime lives there; this CLI stays ~3 MB). `preview` opens /// a Hydrogen webview so humans can see the converted model alongside the /// AI-readable JSON. #[derive(Parser, Debug)]-#[command(name = "adom-step2glb", version, about, long_about = None,+#[command(name = "step2glb", version, about, long_about = None, after_help = "\ IDENTITY HEADERS (required by service-step2glb): Every POST to the service requires X-Client and X-Job-Name headers. This CLI sends them automatically:- X-Client: adom-step2glb-cli/<version> (override with STEP2GLB_CLIENT env var)+ X-Client: step2glb-cli/<version> (override with STEP2GLB_CLIENT env var) X-Job-Name: auto-generated from the command + filename If you're calling the service directly (curl, Python, etc.), you must set both:@@ -28,7 +28,7 @@ IDENTITY HEADERS (required by service-step2glb): ENVIRONMENT: STEP2GLB_SERVICE_API Override service URL (default: shared Adom container)- STEP2GLB_CLIENT Override X-Client identity (default: adom-step2glb-cli/<version>)+ STEP2GLB_CLIENT Override X-Client identity (default: step2glb-cli/<version>) ")] pub struct Cli { #[command(subcommand)]@@ -52,7 +52,7 @@ pub enum Command { /// Render multiple (orientation × size) PNG thumbnails of a local STEP in /// a single service call. Loads the STEP once on the server and renders /// every requested variant into one V3d session — ~5–10× faster than- /// looping `adom-step2glb thumbnail` once per variant.+ /// looping `step2glb thumbnail` once per variant. ThumbnailBatch(ThumbnailBatchArgs), /// Convert + open a Hydrogen webview so a human can verify the result. Preview(PreviewArgs),@@ -62,7 +62,7 @@ pub enum Command { ThumbPlayground(crate::playground::PlaygroundArgs), /// Install SKILL.md + bash completions (fetches fresh SKILL.md from the wiki). Install(InstallArgs),- /// Print shell completions to stdout: `adom-step2glb completions bash`+ /// Print shell completions to stdout: `step2glb completions bash` Completions(CompletionsArgs), } @@ -73,13 +73,32 @@ pub struct ConvertArgs { /// Output .glb path. Defaults to <input stem>.glb beside the input. #[arg(short, long)] pub out: Option<PathBuf>,+ /// Molecule mode: MP-marker anchoring (Z-up, front-left pin at origin),+ /// gold machine pins, and - with --board - footprint + symbol JSON emit.+ #[arg(long)]+ pub molecule: bool,+ /// Board file (.kicad_pcb or .brd) for footprint + symbol generation.+ /// Implies --molecule. Written beside the GLB as <stem>_footprint.json /+ /// <stem>_symbol.json.+ #[arg(long)]+ pub board: Option<PathBuf>,+ /// Machine-pin size (medium | large). The service auto-corrects from the+ /// marker names when they carry a size (pin_size_source: markers).+ #[arg(long, default_value = "medium")]+ pub pin: String,+ /// Top silkscreen PNG overlay (white artwork on transparent).+ #[arg(long)]+ pub silk_top: Option<PathBuf>,+ /// Bottom silkscreen PNG overlay.+ #[arg(long)]+ pub silk_bottom: Option<PathBuf>, } #[derive(Args, Debug)] pub struct FromLibraryArgs { /// "<Library>/<Name>" — e.g. `Package_TO_SOT_SMD/SOT-89-3` pub path: String,- /// Output .glb. Defaults to /tmp/adom-step2glb-<name>.glb.+ /// Output .glb. Defaults to /tmp/step2glb-<name>.glb. #[arg(short, long)] pub out: Option<PathBuf>, }@@ -194,7 +213,7 @@ pub fn health() { Err(e) => { err(format!("service-step2glb unreachable at {CYAN}{}{RESET}: {e}", url)); hint("Check connectivity, or override via `export STEP2GLB_SERVICE_API=<url>`.");- hint(format!("Current URL: `adom-step2glb config` · Baked default: {}", client::DEFAULT_SERVICE_URL));+ hint(format!("Current URL: `step2glb config` · Baked default: {}", client::DEFAULT_SERVICE_URL)); std::process::exit(2); } }@@ -223,24 +242,130 @@ pub fn convert_cmd(args: ConvertArgs) { Ok(b) => b, Err(e) => { err(format!("read {}: {e}", args.input.display())); std::process::exit(1); } };+ // Molecule mode (#99): --molecule or an attached --board routes through the+ // molecule pipeline (anchor, gold, footprint/symbol emit) instead of the+ // plain convert.+ if args.molecule || args.board.is_some() {+ let board = match &args.board {+ Some(p) => {+ let fname = p+ .file_name()+ .and_then(|s| s.to_str())+ .unwrap_or("board.kicad_pcb")+ .to_string();+ match std::fs::read(p) {+ Ok(b) => Some((fname, b)),+ Err(e) => {+ err(format!("read board {}: {e}", p.display()));+ std::process::exit(1);+ }+ }+ }+ None => None,+ };+ let read_opt = |p: &Option<PathBuf>| -> Option<Vec<u8>> {+ p.as_ref().and_then(|p| std::fs::read(p).ok())+ };+ let opts = client::MoleculeOpts {+ board,+ pin: args.pin.clone(),+ silk_top: read_opt(&args.silk_top),+ silk_bottom: read_opt(&args.silk_bottom),+ };+ match client::convert_molecule(&bytes, &opts, &out) {+ Ok(r) => print_molecule_success(&r, &out, args.board.is_some()),+ Err(e) => {+ err(format!("molecule conversion failed: {e}"));+ hint("Run `step2glb health` to check the shared service is reachable.");+ std::process::exit(4);+ }+ }+ return;+ }+ match client::convert_bytes(&bytes, &out) { Ok(r) => print_success(&r, &out, Some(&args.input.display().to_string())), Err(e) => { err(format!("conversion failed: {e}"));- hint("Run `adom-step2glb health` to check the shared service is reachable.");+ hint("Run `step2glb health` to check the shared service is reachable."); std::process::exit(4); } } } +/// Molecule-mode result block: the OK line, the stats that gate downstream+/// publishing (per the molecule-publish skill), the side-artifact paths, and+/// hints when a gate did not pass. Unknown molecule_* / footprint_* stats+/// print generically so new service fields surface without a CLI rebuild.+fn print_molecule_success(r: &client::MoleculeResult, out: &std::path::Path, board_given: bool) {+ let s = &r.stats;+ let anchored = s["molecule_anchored"].as_bool().unwrap_or(false);+ let fp_applied = s["footprint_applied"].as_bool().unwrap_or(false);++ ok(format!(+ "molecule GLB written to {} ({} KB, {} meshes, {} nodes)",+ out.display(),+ r.convert.size_bytes / 1024,+ r.convert.meshes,+ r.convert.nodes+ ));++ let show = |k: &str| {+ let v = &s[k];+ if !v.is_null() {+ crate::note!(" {k}: {v}");+ }+ };+ for k in [+ "molecule_anchored",+ "molecule_up_axis",+ "fl_marker",+ "pin_size",+ "pin_size_source",+ "molecule_origin_z",+ "measured_contact_z_mm",+ "gold_upgraded",+ "silkscreen_applied",+ "footprint_applied",+ "footprint_pins",+ "footprint_contacts",+ "contact_z",+ "contact_z_source",+ ] {+ show(k);+ }+ if let Some(p) = &r.footprint_path {+ crate::note!(" footprint: {}", p.display());+ }+ if let Some(p) = &r.symbol_path {+ crate::note!(" symbol: {}", p.display());+ }+ if let Some(warns) = s["warnings"].as_array() {+ for w in warns {+ warn(w.as_str().unwrap_or(&w.to_string()).to_string());+ }+ }++ // Gate hints (the molecule-publish skill treats these as stop-signs).+ if !anchored {+ hint("NOT ANCHORED - the board is missing MP1-MP4 machine-pin markers (or they did not survive the STEP export). Fix the board and re-export before publishing.");+ }+ if board_given && !fp_applied {+ hint("Board was attached but footprint generation did not apply - check the warnings above before publishing.");+ }+ if !board_given {+ hint("No --board attached: footprint + symbol were not generated. Wires cannot attach in Hydrogen without a footprint - re-run with --board <file.kicad_pcb|.brd> before publishing.");+ }+}+ pub fn from_library_cmd(args: FromLibraryArgs) { let out = args.out.unwrap_or_else(|| { let leaf = args.path.rsplit('/').next().unwrap_or("part");- PathBuf::from(format!("/tmp/adom-step2glb-{leaf}.glb"))+ PathBuf::from(format!("/tmp/step2glb-{leaf}.glb")) }); if !args.path.contains('/') { err(format!("path must be `<Library>/<Name>`, got `{}`", args.path));- hint("Example: `adom-step2glb from-library Package_TO_SOT_SMD/SOT-89-3`");+ hint("Example: `step2glb from-library Package_TO_SOT_SMD/SOT-89-3`"); std::process::exit(1); } match client::convert_from_library(&args.path, &out) {@@ -250,7 +375,7 @@ pub fn from_library_cmd(args: FromLibraryArgs) { if format!("{e}").contains("502") || format!("{e}").contains("404") { hint(format!("Is `{}` a real library part? Check service-kicad: `service-kicad model fetch <lib>/<name>.step`", args.path)); } else {- hint("Run `adom-step2glb health` to check the shared service is reachable.");+ hint("Run `step2glb health` to check the shared service is reachable."); } std::process::exit(4); }@@ -270,7 +395,7 @@ pub fn features_cmd(args: FeaturesArgs) { Ok(s) => s, Err(e) => { err(format!("feature extraction failed: {e}"));- hint("Run `adom-step2glb health` to check the shared service is reachable.");+ hint("Run `step2glb health` to check the shared service is reachable."); std::process::exit(4); } };@@ -368,7 +493,7 @@ pub fn thumbnail_cmd(args: ThumbnailArgs) { Ok(r) => print_thumbnail_success(&r, &out, &args.input.display().to_string()), Err(e) => { err(format!("thumbnail render failed: {e}"));- hint("Run `adom-step2glb health` to check the shared service is reachable. /thumbnail requires service-step2glb v0.3.0+ (pyrender + GL libs deployed).");+ hint("Run `step2glb health` to check the shared service is reachable. /thumbnail requires service-step2glb v0.3.0+ (pyrender + GL libs deployed)."); std::process::exit(4); } }@@ -397,7 +522,7 @@ pub fn thumbnail_batch_cmd(args: ThumbnailBatchArgs) { Ok(r) => r, Err(e) => { err(format!("batch thumbnail render failed: {e}"));- hint("Run `adom-step2glb health` to check the shared service is reachable. /thumbnail-batch requires service-step2glb v0.8.0+.");+ hint("Run `step2glb health` to check the shared service is reachable. /thumbnail-batch requires service-step2glb v0.8.0+."); std::process::exit(4); } };@@ -465,7 +590,7 @@ pub fn preview_cmd(args: PreviewArgs) { pub fn completions(args: CompletionsArgs) { let shell: clap_complete::Shell = args.shell.into(); let mut cmd = Cli::command();- clap_complete::generate(shell, &mut cmd, "adom-step2glb", &mut std::io::stdout());+ clap_complete::generate(shell, &mut cmd, "step2glb", &mut std::io::stdout()); } fn print_thumbnail_success(r: &ThumbnailResult, out: &std::path::Path, source: &str) {
src/client.rs+252−76@@ -1,18 +1,14 @@ //! Thin HTTP client to service-step2glb. No CAD logic lives here.-//!-//! service-step2glb v1.2+ is ASYNC: every endpoint returns `202 {job_id, poll}`,-//! you poll `GET /jobs/<id>` until `status == complete`, then `GET-//! /jobs/<id>/result` streams the real body (GLB/PNG/JSON) carrying the same-//! `X-*` metadata headers. Every request must also carry `X-Client` +-//! `X-Job-Name` identity headers or the service 400s. `drive_to_result` handles-//! BOTH the async job protocol and the legacy sync `200` (so this client works-//! against old and new service builds). use serde::{Deserialize, Serialize}; use std::io::Write; use std::path::Path;-use std::time::{Duration, Instant};+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 {@@ -24,7 +20,7 @@ pub fn service_url() -> String { fn client_id() -> String { std::env::var("STEP2GLB_CLIENT")- .unwrap_or_else(|_| format!("adom-step2glb/{}", env!("CARGO_PKG_VERSION")))+ .unwrap_or_else(|_| format!("step2glb-cli/{}", env!("CARGO_PKG_VERSION"))) } fn set_identity(req: ureq::Request, job_name: &str) -> ureq::Request {@@ -32,61 +28,6 @@ fn set_identity(req: ureq::Request, job_name: &str) -> ureq::Request { .set("X-Job-Name", job_name) } -/// Given the initial response from an endpoint, return the FINAL response whose-/// body is the real payload and whose `X-*` headers carry the metadata. Handles-/// the legacy sync `200` path AND the v1.2 async job protocol (`202` -> poll-/// `GET /jobs/<id>` -> `GET /jobs/<id>/result`).-fn drive_to_result(resp: ureq::Response, job_name: &str) -> Result<ureq::Response, String> {- match resp.status() {- 200 => Ok(resp),- 202 => {- let base = service_url();- let v: serde_json::Value = resp- .into_json()- .map_err(|e| format!("parse 202 job descriptor: {e}"))?;- let job_id = v- .get("job_id")- .and_then(|s| s.as_str())- .ok_or_else(|| "adom-step2glb 202 response missing job_id".to_string())?- .to_string();- let deadline = Instant::now() + Duration::from_secs(300);- loop {- if Instant::now() > deadline {- return Err(format!("adom-step2glb job {job_id} timed out after 300s"));- }- std::thread::sleep(Duration::from_millis(1200));- let status_url = format!("{base}/jobs/{job_id}");- let js: serde_json::Value = set_identity(ureq::get(&status_url), job_name)- .timeout(Duration::from_secs(20))- .call()- .map_err(|e| format!("poll {status_url}: {e}"))?- .into_json()- .map_err(|e| format!("parse job status: {e}"))?;- match js.get("status").and_then(|s| s.as_str()).unwrap_or("") {- "complete" | "done" | "succeeded" => break,- "failed" | "error" => {- let err = js- .get("error")- .and_then(|s| s.as_str())- .unwrap_or("unknown error");- return Err(format!("adom-step2glb job {job_id} failed: {err}"));- }- _ => {}- }- }- let result_url = format!("{base}/jobs/{job_id}/result");- set_identity(ureq::get(&result_url), job_name)- .timeout(Duration::from_secs(180))- .call()- .map_err(|e| format!("fetch {result_url} failed: {e}"))- }- s => {- let body = resp.into_string().unwrap_or_default();- Err(format!("service returned HTTP {s}: {body}"))- }- }-}- #[derive(Debug, Deserialize)] pub struct Health { pub ok: bool,@@ -134,19 +75,186 @@ pub fn health() -> Result<Health, String> { r.into_json::<Health>().map_err(|e| format!("health JSON parse: {e}")) } -/// POST /convert with STEP body. Streams the GLB to `out_path`.+/// 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 r = set_identity(ureq::post(&url), &job_name)+ 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(120))- .send_bytes(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}"))?;- let r = drive_to_result(r, &job_name)?; 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).@@ -157,7 +265,11 @@ pub fn extract_features(step: &[u8]) -> Result<String, String> { .timeout(Duration::from_secs(60)) .send_bytes(step) .map_err(|e| format!("POST {url} failed: {e}"))?;- let r = drive_to_result(r, "extract-features")?;+ 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}")) } @@ -196,7 +308,12 @@ pub fn thumbnail_bytes( .timeout(Duration::from_secs(180)) .send_bytes(body) .map_err(|e| format!("POST {url} failed: {e}"))?;- let r = drive_to_result(r, &job_name)?;++ 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");@@ -279,7 +396,11 @@ pub fn thumbnail_batch_bytes( .timeout(Duration::from_secs(300)) .send_bytes(body) .map_err(|e| format!("POST {url} failed: {e}"))?;- let r = drive_to_result(r, "thumbnail-batch")?;+ 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}")) }@@ -296,21 +417,76 @@ pub fn convert_from_library(lib_slash_name: &str, out_path: &Path) -> Result<Con urlencode(lib), urlencode(name), );- let job_name = format!("convert-lib-{name}");- let r = set_identity(ureq::get(&url), &job_name)+ let r = ureq::get(&url) .timeout(Duration::from_secs(120)) .call() .map_err(|e| format!("GET {url} failed: {e}"))?;- let r = drive_to_result(r, &job_name)?; 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"));
src/install.rs+10−10@@ -11,7 +11,7 @@ use std::time::Duration; /// SAFETY NET — do not rely on for release propagation. const FALLBACK_SKILL_MD: &str = include_str!("../SKILL.md"); -const SKILL_URL: &str = "https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-step2glb/SKILL.md";+const SKILL_URL: &str = "https://wiki.adom.inc/blob/app/adom-step2glb/SKILL.md"; fn fetch_skill_md() -> Cow<'static, str> { match ureq::get(SKILL_URL).timeout(Duration::from_secs(5)).call() {@@ -26,32 +26,32 @@ fn fetch_skill_md() -> Cow<'static, str> { pub fn run(_args: InstallArgs) { let home = std::env::var("HOME").unwrap_or_else(|_| "/home/adom".into()); - // 1. Skill file → ~/.claude/skills/adom-step2glb/SKILL.md (fetched fresh from wiki).- let skill_dir = PathBuf::from(&home).join(".claude/skills/adom-step2glb");+ // 1. Skill file → ~/.claude/skills/step2glb/SKILL.md (fetched fresh from wiki).+ let skill_dir = PathBuf::from(&home).join(".claude/skills/step2glb"); fs::create_dir_all(&skill_dir).expect("mkdir skill_dir"); fs::write(skill_dir.join("SKILL.md"), fetch_skill_md().as_ref()).expect("write skill"); ok(format!("skill installed at {}/SKILL.md", skill_dir.display())); - // 2. Bash completions → ~/.local/share/bash-completion/completions/adom-step2glb+ // 2. Bash completions → ~/.local/share/bash-completion/completions/step2glb let comp_dir = PathBuf::from(&home).join(".local/share/bash-completion/completions"); fs::create_dir_all(&comp_dir).ok(); let mut cmd = crate::cli::Cli::command(); let mut buf = Vec::new();- clap_complete::generate(clap_complete::Shell::Bash, &mut cmd, "adom-step2glb", &mut buf);- fs::write(comp_dir.join("adom-step2glb"), &buf).ok();- ok(format!("bash completions at {}/adom-step2glb", comp_dir.display()));+ clap_complete::generate(clap_complete::Shell::Bash, &mut cmd, "step2glb", &mut buf);+ fs::write(comp_dir.join("step2glb"), &buf).ok();+ ok(format!("bash completions at {}/step2glb", comp_dir.display())); // 3. .bashrc nudge (append once, idempotent). let bashrc_path = PathBuf::from(&home).join(".bashrc"); let existing = fs::read_to_string(&bashrc_path).unwrap_or_default();- if !existing.contains("# adom-step2glb completions") {+ if !existing.contains("# step2glb completions") { if let Ok(mut f) = fs::OpenOptions::new().append(true).open(&bashrc_path) {- let _ = f.write_all(b"\n# adom-step2glb completions\neval \"$(adom-step2glb completions bash 2>/dev/null)\"\n");+ let _ = f.write_all(b"\n# step2glb completions\neval \"$(step2glb completions bash 2>/dev/null)\"\n"); ok(".bashrc: completion hook appended (open a new shell or `source ~/.bashrc`)"); } } // No venv, no Python. That lives on the shared service-step2glb container now. println!();- ok("adom-step2glb installed. Try `adom-step2glb health` to reach the shared service.");+ ok("step2glb installed. Try `step2glb health` to reach the shared service."); }