app
Adom Chip Thumbnailer
Public Made by Adomby adom
The chip-icon factory: from a STEP file it renders the complete family of chip icons, shaded 3D orientations, transparent icons, name-lasered variants, and 11 vector outline styles, in a live web app
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613
//! 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();
match service_thumbnail(&step, &out, orient.step2glb_arg(), w, h, &chip.mpn, "") {
Ok(_) => {
// The transparent icon is rendered on a green chroma-key and
// alpha-keyed (a gray-bg key ate part of the gray/black chip).
if let Err(e) = render_icon_green(chip, &step, orient) {
if first_err.is_none() {
first_err = Some(anyhow!(
"transparent icon (green key) failed for {} ({}): {e}",
chip.mpn,
orient.json_key()
));
}
}
}
// Keep rendering the other orientations; report the first failure.
Err(e) => {
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);
// Full chip-icon family: vector outlines + name-lasered icons via the
// service's OCCT endpoints (the service owns OCCT; we stay a lean client).
// Best-effort: a service hiccup here must not fail the core orientation
// renders that already landed.
if let Err(e) = render_family_extras(chip, &step) {
eprintln!("warn: chip-icon family extras failed for {}: {e:#}", chip.mpn);
}
match first_err {
Some(e) => Err(e),
None => Ok(()),
}
}
/// The vector outlines + name-laser are a single-pose set. chip-fetcher records
/// the picked up-axis in `info.json.chosen_up_axis`; we honor it so Y-up chips
/// (Bosch / Espressif / Nordic WLCSP) pose correctly. Default = Z-up canonical.
/// Parse a chip-fetcher / UI up-axis token into an orientation. Accepts the
/// upstream forms ("y", "-z") and our json_key forms ("yUp", "zDown").
pub fn orient_from_token(tok: &str) -> ThreeDOrientation {
match tok.to_lowercase().as_str() {
"y" | "yup" | "+y" => ThreeDOrientation::YUp,
"-y" | "ydown" => ThreeDOrientation::YDown,
"x" | "xup" | "+x" => ThreeDOrientation::XUp,
"-x" | "xdown" => ThreeDOrientation::XDown,
"-z" | "zdown" => ThreeDOrientation::ZDown,
_ => ThreeDOrientation::AsIs,
}
}
/// The up-axis decision that drives the extended icons (outlines + name-laser),
/// plus where it came from:
/// "override" — set by the user here in chip-thumbnailer
/// "chip-fetcher" — from the upstream `chosen_up_axis` metadata
/// "unset" — no metadata at all; defaulted to AsIs (z-up)
pub fn up_axis_decision(chip: &ChipPaths) -> (ThreeDOrientation, &'static str) {
let info = std::fs::read_to_string(chip.dir.join("info.json"))
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok());
let axis = info
.as_ref()
.and_then(|v| v.get("chosen_up_axis"))
.and_then(|a| a.as_str())
.filter(|s| !s.is_empty());
match axis {
None => (ThreeDOrientation::AsIs, "unset"),
Some(tok) => {
let src = info
.as_ref()
.and_then(|v| v.get("chosen_up_axis_source"))
.and_then(|a| a.as_str());
let source = if src == Some("chip-thumbnailer") {
"override"
} else {
"chip-fetcher"
};
(orient_from_token(tok), source)
}
}
}
/// Persist the user's up-axis override into info.json, MERGING so we never
/// clobber the other fields chip-fetcher wrote. Tags the source so the UI can
/// show that this axis was chosen here, and so downstream tools (adom-symbol)
/// read the corrected orientation.
pub fn set_up_axis(chip: &ChipPaths, orient: ThreeDOrientation) -> Result<()> {
let path = chip.dir.join("info.json");
let mut v: serde_json::Value = std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_else(|| serde_json::json!({}));
if !v.is_object() {
v = serde_json::json!({});
}
let obj = v.as_object_mut().unwrap();
obj.insert(
"chosen_up_axis".into(),
serde_json::Value::String(orient.json_key().to_string()),
);
obj.insert(
"chosen_up_axis_source".into(),
serde_json::Value::String("chip-thumbnailer".into()),
);
let pretty = serde_json::to_string_pretty(&v).context("serialize info.json")?;
std::fs::write(&path, pretty).with_context(|| format!("write {}", path.display()))?;
Ok(())
}
fn chosen_orientation(chip: &ChipPaths) -> ThreeDOrientation {
up_axis_decision(chip).0
}
/// The in-plane text rotation that makes the lasered / outline part number read
/// FACING the viewer at the iso camera, per up-axis. A fixed 180 only faces the
/// viewer on z-up chips; y-up chips need 0 (the text otherwise points away —
/// verified on BME688). Returns (primary, alternate); the alternate is a right
/// angle (facing − 90) for long names that would spill off the body.
fn facing_rotations(orient: ThreeDOrientation) -> (u32, u32) {
let primary = match orient {
// y-up family: text faces the viewer at 0 (verified), alternate 270.
ThreeDOrientation::YUp | ThreeDOrientation::YDown => 0,
// z-up family (asIs/zDown) verified at 180; x-* default to the same.
_ => 180,
};
(primary, (primary + 270) % 360)
}
/// Generate the vector-outline SVGs + name-lasered icons for the chip by
/// calling service-step2glb's /outline and /name-emboss. Writes:
/// <MPN>-3d-outline-<style>.svg (5 plain styles)
/// <MPN>-named.step (embossed STEP, for KiCad too)
/// <MPN>-3d-iso-named.png (opaque name-lasered iso)
/// <MPN>-3d-iso-named-icon.png (transparent name-lasered icon)
/// <MPN>-3d-outline-<style>-named.svg (5 name-marked styles)
pub fn render_family_extras(chip: &ChipPaths, step: &Path) -> Result<()> {
let step_bytes = std::fs::read(step).with_context(|| format!("read {}", step.display()))?;
let orient = chosen_orientation(chip);
let axis = orient.step2glb_arg(); // "asIs" / "yUp" / ...
// The text rotation that faces the viewer depends on the up-axis (the fixed
// 180 pointed away on y-up chips). `primary` = the default `-named`;
// `alt` = the right-angle `-named90` for long names that would spill.
let (primary, alt) = facing_rotations(orient);
// Plain vector outlines (no text, so rotation is irrelevant).
write_outlines(chip, &step_bytes, axis, None, "", primary)?;
if write_named(chip, &step_bytes, axis, primary, "named").is_ok() {
let _ = write_outlines(chip, &step_bytes, axis, Some(&chip.mpn), "-named", primary);
// A debug tile showing the full + top-slice bboxes used for placement.
let _ = write_named_debug(chip, &step_bytes, axis, primary);
}
if write_named(chip, &step_bytes, axis, alt, "named90").is_ok() {
let _ = write_outlines(chip, &step_bytes, axis, Some(&chip.mpn), "-named90", alt);
}
// The canonical `<MPN>-3d-iso-icon.png` (no axis suffix) follows the chosen
// axis, matching chip-fetcher's convention (adom-symbol reads that name).
// The TRUE as-is icon is preserved separately by render_icon_green
// (`-asis-icon.png`), so the dashboard's as-is tile stays honest.
if orient != ThreeDOrientation::AsIs {
let src = chip.three_d_icon_path(orient);
if src.is_file() {
let _ = std::fs::copy(&src, chip.three_d_icon_path(ThreeDOrientation::AsIs));
}
}
Ok(())
}
/// Render a placement-debug tile: the chip on the dark canvas with the full
/// bbox (red) + top-slice bbox (cyan) overlaid, so the dashboard can explain how
/// the part-number placement was derived. Writes <MPN>-3d-iso-named-debug.png.
fn write_named_debug(chip: &ChipPaths, step: &[u8], axis: &str, rot: u32) -> Result<()> {
let tmp_step = chip.dir.join(format!(".dbg-src-{}.step", chip.mpn));
std::fs::write(&tmp_step, step)?;
let out = chip.dir.join(format!("{}-3d-iso-named-debug.png", chip.mpn));
let res = service_thumbnail_named_ext(
&tmp_step, &out, axis, 460, 350, &chip.mpn, "", &chip.mpn, rot, true,
);
let _ = std::fs::remove_file(&tmp_step);
res
}
/// Vector outline SVGs (5 styles) via service /outline. `suffix` e.g. "-named".
fn write_outlines(
chip: &ChipPaths,
step: &[u8],
axis: &str,
name: Option<&str>,
suffix: &str,
rot: u32,
) -> Result<()> {
let mut q = format!("/outline?upAxis={axis}");
if let Some(n) = name {
q.push_str(&format!("&name={n}&suffix={suffix}&rot={rot}"));
}
let v = service_job_json(&q, step, &format!("outline-{}{}", chip.mpn, suffix))?;
let outlines = v
.get("outlines")
.and_then(|o| o.as_object())
.ok_or_else(|| anyhow!("/outline result had no outlines"))?;
// The service already bakes `suffix` into the returned style key
// (e.g. "blueprint-named"), so the filename must NOT re-append it.
for (style, svg) in outlines {
if let Some(s) = svg.as_str() {
let path = chip
.dir
.join(format!("{}-3d-outline-{style}.svg", chip.mpn));
std::fs::write(&path, s).with_context(|| format!("write {}", path.display()))?;
}
}
Ok(())
}
/// Name-lasered iso + transparent named-icon, the part number drawn onto the
/// chip top in the clean single-stroke Hershey OUTLINE font (the same line-font
/// the vector outlines use) rather than a noisy 3D-extruded emboss. `tag` names
/// the output set ("named" = 180 upright default, "named90" = the -90 right
/// angle). Renders on a green chroma-key so the transparent icon keys cleanly
/// (chip + light text are far from green), then composites onto the dark canvas.
/// Writes: <MPN>-3d-iso-<tag>.png <MPN>-3d-iso-<tag>-icon.png
fn write_named(chip: &ChipPaths, step: &[u8], axis: &str, rot: u32, tag: &str) -> Result<()> {
// service_thumbnail_named_ext reads the STEP from a path, so stage the bytes.
let tmp_step = chip.dir.join(format!(".named-src-{}.step", chip.mpn));
std::fs::write(&tmp_step, step).with_context(|| format!("write {}", tmp_step.display()))?;
let green = chip.dir.join(format!(".green-{tag}-{}.png", chip.mpn));
let res = service_thumbnail_named_ext(
&tmp_step, &green, axis, 320, 240, &chip.mpn, "00ff00", &chip.mpn, rot, false,
);
let _ = std::fs::remove_file(&tmp_step);
res?;
if let Ok((w, h, rgba)) = key_green_rgba(&green) {
let icon = chip.dir.join(format!("{}-3d-iso-{tag}-icon.png", chip.mpn));
let _ = write_rgba_png(&icon, w, h, &rgba);
let disp = chip.dir.join(format!("{}-3d-iso-{tag}.png", chip.mpn));
let _ = write_rgba_on_dark(&disp, w, h, &rgba);
}
let _ = std::fs::remove_file(&green);
Ok(())
}
/// POST a STEP to a service JSON endpoint (sync 200 or async 202 + job) and
/// return the parsed result JSON.
fn service_job_json(path_and_query: &str, step: &[u8], job_name: &str) -> Result<serde_json::Value> {
let svc = service_url();
let url = format!("{svc}{path_and_query}");
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(step)
.map_err(|e| anyhow!("POST {url}: {e}"))?;
match resp.status() {
200 => {
let t = resp.into_string().context("read 200 body")?;
Ok(serde_json::from_str(&t).context("parse result json")?)
}
202 => {
let t = resp.into_string().context("read 202 body")?;
let v: serde_json::Value = serde_json::from_str(&t).context("parse 202 json")?;
let jid = v
.get("job_id")
.and_then(|s| s.as_str())
.ok_or_else(|| anyhow!("202 response had no job_id"))?
.to_string();
poll_job_json(&svc, &jid)
}
code => Err(anyhow!("service returned HTTP {code}")),
}
}
fn poll_job_json(svc: &str, jid: &str) -> Result<serde_json::Value> {
for _ in 0..45 {
if let Ok(r) = ureq::get(&format!("{svc}/jobs/{jid}"))
.set("X-Client", "chip-thumbnailer/chip-fetcher")
.set("X-Job-Name", "poll")
.timeout(Duration::from_secs(20))
.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") | Some("done") | Some("succeeded") => {
let res = ureq::get(&format!("{svc}/jobs/{jid}/result"))
.set("X-Client", "chip-thumbnailer/chip-fetcher")
.set("X-Job-Name", "result")
.timeout(Duration::from_secs(60))
.call()
.map_err(|e| anyhow!("GET job result: {e}"))?;
let t = res.into_string().context("read job result")?;
return Ok(serde_json::from_str(&t).context("parse job result json")?);
}
Some("failed") | Some("error") => {
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 {jid} timed out"))
}
/// 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()))?;
// The transparent icon (Md) is rendered on green + alpha-keyed.
if size == ThreeDSize::Md {
render_icon_green(chip, &step, orient)
.with_context(|| format!("transparent icon {} ({})", chip.mpn, orient.json_key()))?;
}
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("adom-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`.
#[allow(clippy::too_many_arguments)]
fn service_thumbnail(
step: &Path,
out: &Path,
up_axis: &str,
w: u32,
h: u32,
mpn: &str,
bg: &str,
) -> Result<()> {
service_thumbnail_named_ext(step, out, up_axis, w, h, mpn, bg, "", 180, false)
}
/// Like `service_thumbnail`, but optionally lasers `name` onto the chip top in
/// the clean single-stroke Hershey outline font (the same line-font the vector
/// outlines use) at in-plane rotation `name_rot`. Empty `name` = plain render.
/// `name_debug` overlays the full + top-slice bboxes used for placement.
#[allow(clippy::too_many_arguments)]
fn service_thumbnail_named_ext(
step: &Path,
out: &Path,
up_axis: &str,
w: u32,
h: u32,
mpn: &str,
bg: &str,
name: &str,
name_rot: u32,
name_debug: bool,
) -> 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 mut url = format!("{svc}/thumbnail?pose=iso&kind=step&upAxis={up_axis}&width={w}&height={h}");
if !bg.is_empty() {
url.push_str(&format!("&bg={bg}"));
}
if !name.is_empty() {
url.push_str(&format!("&name={name}&rot={name_rot}"));
}
if name_debug {
url.push_str("&nameDebug=1");
}
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") | Some("done") | Some("succeeded") => {
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") | Some("error") => {
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"))
}
/// Render one orientation on a pure-green chroma-key background and alpha-key
/// the green out, writing the transparent icon. The chip is gray/black, far from
/// green, so this keeps the whole chip, unlike keying a gray background (which
/// ate chip faces that happened to match the bg shade).
fn render_icon_green(chip: &ChipPaths, step: &Path, orient: ThreeDOrientation) -> Result<()> {
let green = chip.dir.join(format!(".green-{}-{}.png", chip.mpn, orient.json_key()));
service_thumbnail(step, &green, orient.step2glb_arg(), 320, 240, &chip.mpn, "00ff00")?;
let (w, h, rgba) = key_green_rgba(&green)?;
let _ = std::fs::remove_file(&green);
write_rgba_png(&chip.three_d_icon_path(orient), w, h, &rgba)?;
// Keep a stable TRUE as-is icon for the dashboard's as-is tile: the
// no-suffix `-icon.png` later gets clobbered with the chosen pose for
// adom-symbol, so the as-is group would otherwise show the chosen pose.
if orient == ThreeDOrientation::AsIs {
let asis_true = chip.dir.join(format!("{}-3d-iso-asis-icon.png", chip.mpn));
let _ = write_rgba_png(&asis_true, w, h, &rgba);
}
Ok(())
}
/// Alpha-key a pure-green-background PNG. greenness = G - max(R,B): ~0 on the
/// gray/black chip (opaque), high on the green background (transparent). Spill
/// suppression pulls G down to max(R,B) so anti-aliased edges have no green
/// fringe. Returns (width, height, RGBA bytes).
fn key_green_rgba(green_png: &Path) -> Result<(usize, usize, Vec<u8>)> {
let decoder = png::Decoder::new(
std::fs::File::open(green_png).with_context(|| format!("open {}", green_png.display()))?,
);
let mut reader = decoder.read_info().context("read png header")?;
let (width, height) = {
let info = reader.info();
(info.width as usize, info.height as usize)
};
let mut buf = vec![0u8; reader.output_buffer_size()];
let frame = reader.next_frame(&mut buf).context("decode png frame")?;
let bytes = &buf[..frame.buffer_size()];
let ch = match reader.output_color_type().0 {
png::ColorType::Rgb => 3usize,
png::ColorType::Rgba => 4usize,
other => return Err(anyhow!("unexpected iso PNG color type {other:?}")),
};
const LOW: f64 = 30.0; // greenness below this -> fully opaque (chip)
const HIGH: f64 = 95.0; // greenness above this -> fully transparent (bg)
let mut rgba = vec![0u8; width * height * 4];
for row in 0..height {
for col in 0..width {
let i = (row * width + col) * ch;
let (r, g, b) = (bytes[i] as f64, bytes[i + 1] as f64, bytes[i + 2] as f64);
let maxrb = r.max(b);
let greenness = g - maxrb;
let alpha = ((1.0 - ((greenness - LOW) / (HIGH - LOW)).clamp(0.0, 1.0)) * 255.0)
.round() as u8;
let g2 = g.min(maxrb); // spill suppression
let o = (row * width + col) * 4;
rgba[o] = r as u8;
rgba[o + 1] = g2 as u8;
rgba[o + 2] = b as u8;
rgba[o + 3] = alpha;
}
}
Ok((width, height, rgba))
}
fn write_rgba_png(out: &Path, w: usize, h: usize, rgba: &[u8]) -> Result<()> {
use std::io::BufWriter;
let file = std::fs::File::create(out).with_context(|| format!("create {}", out.display()))?;
let mut enc = png::Encoder::new(BufWriter::new(file), w as u32, h as u32);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut writer = enc.write_header().context("write png header")?;
writer.write_image_data(rgba).context("write png data")?;
Ok(())
}
/// Composite an RGBA buffer over the Adom dark canvas (#0d1117) -> opaque RGB
/// PNG, for the shaded display variants (named.png etc.).
fn write_rgba_on_dark(out: &Path, w: usize, h: usize, rgba: &[u8]) -> Result<()> {
use std::io::BufWriter;
const BG: [f64; 3] = [13.0, 17.0, 23.0]; // #0d1117
let mut rgb = vec![0u8; w * h * 3];
for px in 0..(w * h) {
let a = rgba[px * 4 + 3] as f64 / 255.0;
for c in 0..3 {
rgb[px * 3 + c] =
(rgba[px * 4 + c] as f64 * a + BG[c] * (1.0 - a)).round() as u8;
}
}
let file = std::fs::File::create(out).with_context(|| format!("create {}", out.display()))?;
let mut enc = png::Encoder::new(BufWriter::new(file), w as u32, h as u32);
enc.set_color(png::ColorType::Rgb);
enc.set_depth(png::BitDepth::Eight);
let mut writer = enc.write_header().context("write png header")?;
writer.write_image_data(&rgb).context("write png data")?;
Ok(())
}
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(())
}