app
Adom Step2GLB - STEP to GLB converter
Public Made by Adomby adom
Color-preserving STEP (.step/.stp) to GLB converter. Thin Rust CLI shelling to a shared OCCT XCAF service. Formerly 'step2glb'.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651
use crate::client::{self, ConvertResult, ThumbnailResult};
use crate::ui::{err, hint, ok, warn, CYAN, DIM, RESET};
use crate::{note};
use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
use std::path::PathBuf;
/// 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 = "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: 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:
X-Client: <tool-name>/<user-or-container> e.g. adom-tsci/john-dev
X-Job-Name: <endpoint>-<what>-<part>-<context>
e.g. convert-usbc-connector-type-c-31-m-12-to-glb
thumbnail-iso-vl53l8cx-for-library
features-extract-bme688-pin-detection
ENVIRONMENT:
STEP2GLB_SERVICE_API Override service URL (default: shared Adom container)
STEP2GLB_CLIENT Override X-Client identity (default: step2glb-cli/<version>)
")]
pub struct Cli {
#[command(subcommand)]
pub command: Command,
}
#[derive(Subcommand, Debug)]
pub enum Command {
/// Reachability + version check against the shared service.
Health,
/// Print the service URL this CLI will hit.
Config,
/// Convert a local STEP file → GLB.
Convert(ConvertArgs),
/// Fetch a STEP from service-kicad by "<Library>/<Name>" and convert.
FromLibrary(FromLibraryArgs),
/// Extract geometric features (bbox, floor_z, longest_axis, pin1) from a local STEP without converting.
Features(FeaturesArgs),
/// Render a PNG thumbnail of a local STEP (server-side via pyrender; iso pose by default).
Thumbnail(ThumbnailArgs),
/// 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 `step2glb thumbnail` once per variant.
ThumbnailBatch(ThumbnailBatchArgs),
/// Convert + open a Hydrogen webview so a human can verify the result.
Preview(PreviewArgs),
/// Open a Hydrogen webview thumb-playground — drag-drop a .step file,
/// see all 6 orientations × 3 sizes populate. Drag-drop UI for one-off
/// renders; tool integrations should hit /thumbnail-batch directly.
ThumbPlayground(crate::playground::PlaygroundArgs),
/// Install SKILL.md + bash completions (fetches fresh SKILL.md from the wiki).
Install(InstallArgs),
/// Print shell completions to stdout: `step2glb completions bash`
Completions(CompletionsArgs),
}
#[derive(Args, Debug)]
pub struct ConvertArgs {
/// Input .step / .stp file.
pub input: PathBuf,
/// 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/step2glb-<name>.glb.
#[arg(short, long)]
pub out: Option<PathBuf>,
}
#[derive(Args, Debug)]
pub struct FeaturesArgs {
/// Input .step / .stp file.
pub input: PathBuf,
/// Output .json path. Defaults to <input stem>-features.json beside the input. Use `-` to print to stdout.
#[arg(short, long)]
pub out: Option<PathBuf>,
}
#[derive(Args, Debug)]
pub struct ThumbnailArgs {
/// Input .step / .stp / .glb file. Kind auto-detected from extension or magic bytes.
pub input: PathBuf,
/// Output .png path. Defaults to <input stem>-thumb.png beside the input.
#[arg(short, long)]
pub out: Option<PathBuf>,
/// Image width in pixels (default 320 — thumbnail; pass 800+ for hero size).
#[arg(long, default_value_t = 320)]
pub width: u32,
/// Image height in pixels (default 240 — thumbnail; pass 600+ for hero size).
#[arg(long, default_value_t = 240)]
pub height: u32,
/// Camera pose: iso, iso2, top, front, side.
#[arg(long, default_value = "iso")]
pub pose: String,
/// Force input kind: auto (default), step, or glb.
#[arg(long, default_value = "auto")]
pub kind: String,
/// Pre-rotate the model before rendering. Six face-up variants:
/// `asIs` (=`as-is`/`z`, default — source +Z up, STEP canonical),
/// `zDown` (R_x(π) — chip flipped),
/// `yUp` (=`y`, R_x(+π/2) — source +Y up, glTF / three.js),
/// `yDown` (R_x(-π/2) — source -Y up, e.g. WAGO connectors),
/// `xUp` (R_y(-π/2)), `xDown` (R_y(+π/2)).
/// chip-thumbnailer renders all six per chip so a downstream picker
/// can compare side-by-side. See gallia/skills/up-axis-conventions.
#[arg(long, default_value = "asIs")]
pub up_axis: String,
}
#[derive(Args, Debug)]
pub struct ThumbnailBatchArgs {
/// Input .step / .stp file.
pub input: PathBuf,
/// Directory to write the rendered PNGs into. Defaults to `<input stem>-thumbs/`
/// beside the input. Files are named `<stem>-3d-iso<orient><size>.png`
/// to match chip-thumbnailer's canonical layout.
#[arg(short, long)]
pub out_dir: Option<PathBuf>,
/// Comma-separated orientations. `all` (default) = asIs,zDown,yUp,yDown,xUp,xDown.
#[arg(long, default_value = "all")]
pub orientations: String,
/// Comma-separated sizes. `all` (default) = sm,md,lg.
#[arg(long, default_value = "all")]
pub sizes: String,
/// Camera pose. Default `iso`.
#[arg(long, default_value = "iso")]
pub pose: String,
}
#[derive(Args, Debug)]
pub struct PreviewArgs {
/// Either a local .step path or "<Library>/<Name>" (same shape as from-library).
pub source: String,
/// Port for the local preview HTTP server. 0 = auto-pick a free port.
#[arg(long, default_value_t = 0)]
pub port: u16,
/// Skip opening the Hydrogen webview tab (still prints the proxy URL).
#[arg(long)]
pub no_tab: bool,
}
#[derive(Args, Debug)]
pub struct InstallArgs {}
#[derive(Args, Debug)]
pub struct CompletionsArgs {
#[arg(value_enum)]
pub shell: ShellArg,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum ShellArg { Bash, Zsh, Fish }
impl From<ShellArg> for clap_complete::Shell {
fn from(s: ShellArg) -> Self {
match s {
ShellArg::Bash => clap_complete::Shell::Bash,
ShellArg::Zsh => clap_complete::Shell::Zsh,
ShellArg::Fish => clap_complete::Shell::Fish,
}
}
}
// ────────────────────────────────────────────────────────────────────────
// command implementations
// ────────────────────────────────────────────────────────────────────────
pub fn health() {
let url = client::service_url();
match client::health() {
Ok(h) => {
ok(format!(
"service-step2glb v{} reachable at {CYAN}{}{RESET} (kicad_base={CYAN}{}{RESET}, converts={}, uptime={}s)",
h.version, url, h.kicad_base, h.converts, h.uptime_seconds
));
}
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: `step2glb config` · Baked default: {}", client::DEFAULT_SERVICE_URL));
std::process::exit(2);
}
}
}
pub fn config() {
note!("{DIM}service URL:{RESET} {CYAN}{}{RESET}", client::service_url());
note!("{DIM} baked default:{RESET} {}", client::DEFAULT_SERVICE_URL);
if std::env::var("STEP2GLB_SERVICE_API").is_ok() {
note!("{DIM} overridden by STEP2GLB_SERVICE_API env var{RESET}");
}
}
pub fn convert_cmd(args: ConvertArgs) {
if !args.input.exists() {
err(format!("input file not found: {}", args.input.display()));
hint(format!("Check the path exists: `ls {}`", args.input.parent().map(|p|p.display().to_string()).unwrap_or_else(||".".into())));
std::process::exit(1);
}
let out = args.out.unwrap_or_else(|| {
let mut p = args.input.clone();
p.set_extension("glb");
p
});
let bytes = match std::fs::read(&args.input) {
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 `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/step2glb-{leaf}.glb"))
});
if !args.path.contains('/') {
err(format!("path must be `<Library>/<Name>`, got `{}`", args.path));
hint("Example: `step2glb from-library Package_TO_SOT_SMD/SOT-89-3`");
std::process::exit(1);
}
match client::convert_from_library(&args.path, &out) {
Ok(r) => print_success(&r, &out, Some(&args.path)),
Err(e) => {
err(format!("conversion failed: {e}"));
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 `step2glb health` to check the shared service is reachable.");
}
std::process::exit(4);
}
}
}
pub fn features_cmd(args: FeaturesArgs) {
if !args.input.exists() {
err(format!("input file not found: {}", args.input.display()));
std::process::exit(1);
}
let bytes = match std::fs::read(&args.input) {
Ok(b) => b,
Err(e) => { err(format!("read {}: {e}", args.input.display())); std::process::exit(1); }
};
let json_str = match client::extract_features(&bytes) {
Ok(s) => s,
Err(e) => {
err(format!("feature extraction failed: {e}"));
hint("Run `step2glb health` to check the shared service is reachable.");
std::process::exit(4);
}
};
let pretty = serde_json::from_str::<serde_json::Value>(&json_str)
.ok()
.and_then(|v| serde_json::to_string_pretty(&v).ok())
.unwrap_or(json_str);
let out_path = match args.out {
Some(p) if p.as_os_str() == "-" => {
println!("{pretty}");
return;
}
Some(p) => p,
None => {
let stem = args.input.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "step".into());
args.input.with_file_name(format!("{stem}-features.json"))
}
};
if let Err(e) = std::fs::write(&out_path, &pretty) {
err(format!("write {}: {e}", out_path.display()));
std::process::exit(1);
}
ok(format!("features written → {CYAN}{}{RESET}", out_path.display()));
// Echo a one-line summary so AI callers see the highlights without re-parsing the file.
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&pretty) {
let bb = &v["bbox_mm"];
let pin1_src = v["pin1"]["source"].as_str().unwrap_or("?");
let extra_src = match pin1_src {
"step-feature" => format!(" {CYAN}pin1=step-feature{RESET}"),
_ => format!(" {DIM}pin1={pin1_src}{RESET}"),
};
note!(
"{DIM}bbox{RESET} x={}..{} y={}..{} z={}..{} {DIM}floor_z{RESET}={} {DIM}axis{RESET}={}{}",
bb["x"][0], bb["x"][1], bb["y"][0], bb["y"][1], bb["z"][0], bb["z"][1],
v["floor_z_mm"], v["longest_axis"].as_str().unwrap_or("?"), extra_src
);
}
}
pub fn thumbnail_cmd(args: ThumbnailArgs) {
if !args.input.exists() {
err(format!("input file not found: {}", args.input.display()));
std::process::exit(1);
}
let valid_poses = ["iso", "iso2", "top", "front", "side"];
if !valid_poses.contains(&args.pose.as_str()) {
err(format!("invalid --pose `{}`; must be one of: {}", args.pose, valid_poses.join(", ")));
std::process::exit(1);
}
let valid_kinds = ["auto", "step", "glb"];
if !valid_kinds.contains(&args.kind.as_str()) {
err(format!("invalid --kind `{}`; must be one of: {}", args.kind, valid_kinds.join(", ")));
std::process::exit(1);
}
// Six canonical face-up variants. Aliases: as-is/asis/z → asIs, y → yUp.
// The service does the same normalization, but reject early here so
// callers get a clear error instead of a successful-but-wrong render.
let valid_up = [
"asIs", "as-is", "asis", "z", // → asIs
"zDown", // R_x(π)
"yUp", "y", // → yUp
"yDown", // R_x(-π/2)
"xUp", "xDown", // R_y(±π/2)
];
if !valid_up.contains(&args.up_axis.as_str()) {
err(format!("invalid --up-axis `{}`; must be one of: {}", args.up_axis, valid_up.join(", ")));
std::process::exit(1);
}
// Pass through verbatim — the service normalizes synonyms server-side.
let up_norm = args.up_axis.clone();
// If --kind is auto, hint by file extension. The service still magic-byte
// sniffs, so an unknown extension just falls through to whatever the
// bytes say. No filesystem read required to pick the hint.
let kind_hint = if args.kind == "auto" {
match args.input.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()) {
Some(ref e) if e == "glb" => "glb",
Some(ref e) if e == "step" || e == "stp" => "step",
_ => "auto",
}
} else {
args.kind.as_str()
};
let out = args.out.unwrap_or_else(|| {
let stem = args.input.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "step".into());
args.input.with_file_name(format!("{stem}-thumb.png"))
});
let bytes = match std::fs::read(&args.input) {
Ok(b) => b,
Err(e) => { err(format!("read {}: {e}", args.input.display())); std::process::exit(1); }
};
match client::thumbnail_bytes(&bytes, &out, args.width, args.height, &args.pose, kind_hint, &up_norm) {
Ok(r) => print_thumbnail_success(&r, &out, &args.input.display().to_string()),
Err(e) => {
err(format!("thumbnail render failed: {e}"));
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);
}
}
}
pub fn thumbnail_batch_cmd(args: ThumbnailBatchArgs) {
use base64::Engine;
if !args.input.exists() {
err(format!("input file not found: {}", args.input.display()));
std::process::exit(1);
}
let bytes = match std::fs::read(&args.input) {
Ok(b) => b,
Err(e) => { err(format!("read {}: {e}", args.input.display())); std::process::exit(1); }
};
let stem = args.input.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "step".into());
let out_dir = args.out_dir.unwrap_or_else(|| {
args.input.with_file_name(format!("{stem}-thumbs"))
});
if let Err(e) = std::fs::create_dir_all(&out_dir) {
err(format!("create {}: {e}", out_dir.display()));
std::process::exit(1);
}
let result = match client::thumbnail_batch_bytes(&bytes, &args.orientations, &args.sizes, &args.pose) {
Ok(r) => r,
Err(e) => {
err(format!("batch thumbnail render failed: {e}"));
hint("Run `step2glb health` to check the shared service is reachable. /thumbnail-batch requires service-step2glb v0.8.0+.");
std::process::exit(4);
}
};
// Write each variant. Naming matches chip-thumbnailer's canonical scheme:
// <stem>-3d-iso.png (asIs / md — back-compat name)
// <stem>-3d-iso-yup.png (yUp / md)
// <stem>-3d-iso-yup-sm.png (yUp / sm)
// ... etc.
let b64 = base64::engine::general_purpose::STANDARD;
let mut written = 0u32;
for entry in &result.thumbnails {
let png = match b64.decode(entry.data_b64.as_bytes()) {
Ok(b) => b,
Err(e) => { err(format!("base64 decode {}/{}: {e}", entry.up_axis, entry.size)); continue; }
};
let orient_suffix = match entry.up_axis.as_str() {
"asIs" => "",
"zDown" => "-zdown",
"yUp" => "-yup",
"yDown" => "-ydown",
"xUp" => "-xup",
"xDown" => "-xdown",
other => other,
};
let size_suffix = match entry.size.as_str() {
"md" => "",
"sm" => "-sm",
"lg" => "-lg",
other => other,
};
let path = out_dir.join(format!("{stem}-3d-iso{orient_suffix}{size_suffix}.png"));
if let Err(e) = std::fs::write(&path, &png) {
err(format!("write {}: {e}", path.display()));
continue;
}
written += 1;
}
ok(format!(
"{written} PNGs written to {CYAN}{}{RESET} ({DIM}load {} ms · total {} ms · {} variants{RESET})",
out_dir.display(), result.load_duration_ms, result.total_duration_ms, result.thumbnail_count
));
for w in &result.render_warnings {
crate::ui::warn(w);
}
// Final JSON line for AI / scripted callers.
println!("{}", serde_json::to_string(&serde_json::json!({
"ok": true,
"outDir": out_dir.display().to_string(),
"written": written,
"thumbnailCount": result.thumbnail_count,
"loadDurationMs": result.load_duration_ms,
"totalDurationMs": result.total_duration_ms,
"bboxMm": result.bbox_mm,
"renderWarnings": result.render_warnings,
})).unwrap_or_default());
}
pub fn preview_cmd(args: PreviewArgs) {
crate::preview::run(args);
}
pub fn completions(args: CompletionsArgs) {
let shell: clap_complete::Shell = args.shell.into();
let mut cmd = Cli::command();
clap_complete::generate(shell, &mut cmd, "step2glb", &mut std::io::stdout());
}
fn print_thumbnail_success(r: &ThumbnailResult, out: &std::path::Path, source: &str) {
ok(format!(
"PNG written from {CYAN}{source}{RESET} ({DIM}{}{RESET}) → {CYAN}{}{RESET} ({DIM}{}×{} {} pose, {:.1} KB, {} ms{RESET})",
r.src_kind, out.display(), r.width, r.height, r.pose, r.size_bytes as f64 / 1024.0, r.duration_ms
));
// Echo the chip's bbox in mm so AI callers see the highlights without
// re-parsing the JSON. Mirrors the summary line in features_cmd.
if let Some(bb) = &r.bbox_mm {
if let (Some(xs), Some(ys), Some(zs)) = (bb.get(0), bb.get(1), bb.get(2)) {
let ext = |v: &serde_json::Value| -> Option<f64> {
let lo = v.get(0).and_then(|x| x.as_f64())?;
let hi = v.get(1).and_then(|x| x.as_f64())?;
Some(hi - lo)
};
if let (Some(dx), Some(dy), Some(dz)) = (ext(xs), ext(ys), ext(zs)) {
note!(
"{DIM}bbox{RESET} {:.2}×{:.2}×{:.2} mm {DIM}pose{RESET}={CYAN}{}{RESET} {DIM}src{RESET}={}",
dx, dy, dz, r.pose, r.src_kind
);
}
}
}
println!("{}", serde_json::to_string(&serde_json::json!({
"ok": true,
"output": out,
"sizeBytes": r.size_bytes,
"width": r.width,
"height": r.height,
"pose": r.pose,
"srcKind": r.src_kind,
"bboxMm": r.bbox_mm,
"durationMs": r.duration_ms,
})).unwrap());
}
fn print_success(r: &ConvertResult, out: &std::path::Path, source: Option<&str>) {
let mats_n = r.materials.len();
let src_label = source.map(|s| format!(" from {CYAN}{s}{RESET}")).unwrap_or_default();
ok(format!(
"GLB written{src_label} → {CYAN}{}{RESET} ({DIM}{} meshes, {} materials, {:.1} KB, {} ms{RESET})",
out.display(), r.meshes, mats_n, r.size_bytes as f64 / 1024.0, r.duration_ms
));
if mats_n == 0 {
warn("GLB has no materials — the source STEP may lack color assignments. Downstream viewers will render it white.");
}
// Also emit the structured JSON on a separate line so AI callers still get machine-readable output.
println!("{}", serde_json::to_string(&serde_json::json!({
"ok": true,
"output": out,
"sizeBytes": r.size_bytes,
"meshes": r.meshes,
"nodes": r.nodes,
"materials": r.materials,
"durationMs": r.duration_ms,
})).unwrap());
}