← 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

Drew Owens ·e50413e396 ·3mo ago ·parent ef8bb59
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"-}+}\ No newline at end of file
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."); }