app
[DEPRECATED] Chip Thumbnailer → adom-chip-thumbnailer
Public Made by Adomby adom
DEPRECATED: use adom/adom-chip-thumbnailer. No longer maintained.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
//! 3D thumbnail: call service-step2glb's POST /thumbnail directly (OCCT
//! V3d offscreen). We talk to the service over HTTP rather than shelling
//! to the `step2glb` CLI, because the deployed CLI's thumbnail path does
//! NOT poll the service's async (202 + job) response — it bails with
//! "service returned HTTP 202". Calling the service directly lets us poll
//! the job to completion, which is what actually works.
//!
//! Renders the as-is + 5 other face-up orientations at the medium size the
//! dashboard displays. Manufacturer chip STEPs ship in inconsistent up-axis
//! conventions; chip-thumbnailer is intentionally neutral and renders every
//! face-up variant — downstream tools pick the right one per chip.
use crate::library::{ChipPaths, ThreeDOrientation, ThreeDSize};
use anyhow::{anyhow, Context, Result};
use std::path::Path;
use std::time::Duration;
const SERVICE_URL_DEFAULT: &str = "https://step2glb-gmdoncpxdwx0.adom.cloud";
fn service_url() -> String {
std::env::var("STEP2GLB_SERVICE_API")
.unwrap_or_else(|_| SERVICE_URL_DEFAULT.to_string())
.trim_end_matches('/')
.to_string()
}
/// Render every face-up orientation at the displayed (Md) size. This covers
/// the dashboard's Z↑/Y↑ tiles plus the 6-orientation popover. (The sm/lg
/// size variants are rendered on demand via `render_orient_size`.)
pub fn render(chip: &ChipPaths) -> Result<()> {
let step = chip
.step
.as_ref()
.ok_or_else(|| anyhow!("no .step in {}", chip.dir.display()))?
.clone();
let mut first_err: Option<anyhow::Error> = None;
for orient in ThreeDOrientation::all() {
let out = chip.three_d_thumb_path(orient, ThreeDSize::Md);
let (w, h) = ThreeDSize::Md.dims();
if let Err(e) = service_thumbnail(&step, &out, orient.step2glb_arg(), w, h, &chip.mpn) {
// Keep rendering the other orientations; report the first failure.
if first_err.is_none() {
first_err = Some(anyhow!(
"step2glb service render failed for {} ({}): {e}",
chip.mpn,
orient.json_key()
));
}
}
}
// Lazily populate <MPN>-features.json (manifest pulls bbox from it).
ensure_features(&step, chip);
match first_err {
Some(e) => Err(e),
None => Ok(()),
}
}
/// Back-compat single-size render at the AsIs orientation.
pub fn render_size(chip: &ChipPaths, size: ThreeDSize) -> Result<()> {
render_orient_size(chip, ThreeDOrientation::AsIs, size)
}
/// Render a single (orientation, size) to disk via the service.
pub fn render_orient_size(
chip: &ChipPaths,
orient: ThreeDOrientation,
size: ThreeDSize,
) -> Result<()> {
let step = chip
.step
.as_ref()
.ok_or_else(|| anyhow!("no .step in {}", chip.dir.display()))?
.clone();
let out = chip.three_d_thumb_path(orient, size);
let (w, h) = size.dims();
service_thumbnail(&step, &out, orient.step2glb_arg(), w, h, &chip.mpn)
.with_context(|| format!("3D render {} ({}/{})", chip.mpn, orient.json_key(), size.label()))?;
ensure_features(&step, chip);
Ok(())
}
fn ensure_features(step: &Path, chip: &ChipPaths) {
let features_path = chip.dir.join(format!("{}-features.json", chip.mpn));
if features_path.is_file() {
return;
}
// features still goes through the CLI. Its render path can return the
// service's async 202 (which the CLI doesn't poll) — that's a non-fatal
// metadata miss, so capture output rather than letting a misleading
// "feature extraction failed" line leak to the user.
let _ = std::process::Command::new("step2glb")
.arg("features")
.arg(step)
.arg("--out")
.arg(&features_path)
.output();
}
/// POST a STEP to service-step2glb's /thumbnail, handling both the legacy
/// synchronous (200 → PNG) and the current async (202 → job → poll) shapes,
/// then write the PNG to `out`.
fn service_thumbnail(
step: &Path,
out: &Path,
up_axis: &str,
w: u32,
h: u32,
mpn: &str,
) -> Result<()> {
let svc = service_url();
let body = std::fs::read(step).with_context(|| format!("read {}", step.display()))?;
let job_name = format!("thumbnail-iso-{up_axis}-{mpn}-for-chip-thumbnailer");
let url = format!("{svc}/thumbnail?pose=iso&kind=step&upAxis={up_axis}&width={w}&height={h}");
let resp = ureq::post(&url)
.set("Content-Type", "application/step")
.set("X-Client", "chip-thumbnailer/chip-fetcher")
.set("X-Job-Name", &job_name)
.timeout(Duration::from_secs(180))
.send_bytes(&body)
.map_err(|e| anyhow!("POST {url}: {e}"))?;
match resp.status() {
200 => write_reader(resp, out),
202 => {
let txt = resp.into_string().context("read 202 job body")?;
let v: serde_json::Value =
serde_json::from_str(&txt).context("parse 202 job json")?;
let job_id = v
.get("job_id")
.and_then(|s| s.as_str())
.ok_or_else(|| anyhow!("202 response had no job_id"))?
.to_string();
poll_and_write(&svc, &job_id, out)
}
code => Err(anyhow!("service returned HTTP {code}")),
}
}
fn poll_and_write(svc: &str, job_id: &str, out: &Path) -> Result<()> {
for _ in 0..30 {
if let Ok(r) = ureq::get(&format!("{svc}/jobs/{job_id}"))
.set("X-Client", "chip-thumbnailer/chip-fetcher")
.set("X-Job-Name", "poll")
.timeout(Duration::from_secs(15))
.call()
{
let v: serde_json::Value = r
.into_string()
.ok()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or(serde_json::Value::Null);
match v.get("status").and_then(|s| s.as_str()) {
Some("complete") => {
let res = ureq::get(&format!("{svc}/jobs/{job_id}/result"))
.set("X-Client", "chip-thumbnailer/chip-fetcher")
.set("X-Job-Name", "result")
.timeout(Duration::from_secs(30))
.call()
.map_err(|e| anyhow!("GET job result: {e}"))?;
return write_reader(res, out);
}
Some("failed") => {
let msg = v.get("error").and_then(|s| s.as_str()).unwrap_or("unknown");
return Err(anyhow!("service job failed: {msg}"));
}
_ => {}
}
}
std::thread::sleep(Duration::from_secs(2));
}
Err(anyhow!("service job {job_id} timed out"))
}
fn write_reader(resp: ureq::Response, out: &Path) -> Result<()> {
let mut reader = resp.into_reader();
let mut f = std::fs::File::create(out).with_context(|| format!("create {}", out.display()))?;
std::io::copy(&mut reader, &mut f).with_context(|| format!("write {}", out.display()))?;
Ok(())
}