use crate::panels::assembly_edit::document_signature;
use crate::panels::file_explorer::{FileExplorer, FileExplorerOptions};
use crate::store::{model_display_name, ModelStore};
use brep_render::engine_state::{ComponentInsert, EngineState};
use eframe::egui;
use serde_json::Value;
use std::collections::HashMap;
use std::sync::mpsc::Receiver;
const API_BASE: &str = "https://api.step.parts/v1";
const PAGE_SIZE: u32 = 24;
#[derive(Clone, Debug, Default, PartialEq)]
pub struct PartItem {
pub id: String,
pub name: String,
pub description: String,
pub category: String,
pub step_url: String,
pub png_url: String,
}
fn encode_query(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
fn search_url(base: &str, query: &str, page: u32) -> String {
let mut url = format!("{base}/parts?pageSize={PAGE_SIZE}&page={page}");
let q = query.trim();
if !q.is_empty() {
url.push_str("&q=");
url.push_str(&encode_query(q));
}
url
}
fn str_field(obj: &Value, key: &str) -> String {
obj.get(key).and_then(Value::as_str).unwrap_or("").to_string()
}
fn response_has_next(json: &Value) -> bool {
json.get("hasNextPage").and_then(Value::as_bool).unwrap_or(false)
}
fn parse_search_response(json: &str) -> Result<(Vec<PartItem>, bool), String> {
let v: Value = serde_json::from_str(json).map_err(|e| format!("bad response: {e}"))?;
if let Some(err) = v.get("error").and_then(Value::as_str) {
return Err(err.to_string());
}
let items = v
.get("items")
.and_then(Value::as_array)
.ok_or("response missing `items` array")?;
let parts = items
.iter()
.map(|it| PartItem {
id: str_field(it, "id"),
name: str_field(it, "name"),
description: str_field(it, "description"),
category: str_field(it, "category"),
step_url: str_field(it, "stepUrl"),
png_url: str_field(it, "pngUrl"),
})
.collect();
Ok((parts, response_has_next(&v)))
}
fn step_filename_stem(step_url: &str) -> String {
let last = step_url
.rsplit('/')
.next()
.unwrap_or(step_url);
let last = last.split(['?', '#']).next().unwrap_or(last);
let stem = last
.strip_suffix(".step")
.or_else(|| last.strip_suffix(".STEP"))
.or_else(|| last.strip_suffix(".stp"))
.or_else(|| last.strip_suffix(".STP"))
.unwrap_or(last);
if stem.is_empty() {
"imported-part".to_string()
} else {
stem.to_string()
}
}
fn build_part_document(step_text: &str) -> Result<String, String> {
if !step_text.contains("ISO-10303-21") {
return Err("not a STEP file (missing the ISO-10303-21 header)".into());
}
Ok(serde_json::json!({
"expressions": "",
"configurator": {},
"features": [{
"type": "IMPORT3D",
"inputParams": { "id": "IMPORT3D1", "stepText": step_text },
"persistentData": {}
}]
})
.to_string())
}
fn fetch_text(ctx: &egui::Context, url: String) -> Receiver<Result<String, String>> {
let (tx, rx) = std::sync::mpsc::channel();
let ctx = ctx.clone();
ehttp::fetch(ehttp::Request::get(url), move |result| {
let out = match result {
Ok(resp) if resp.ok => Ok(resp
.text()
.map(str::to_owned)
.unwrap_or_else(|| String::from_utf8_lossy(&resp.bytes).into_owned())),
Ok(resp) => Err(format!("HTTP {} {}", resp.status, resp.status_text)),
Err(err) => Err(err),
};
let _ = tx.send(out);
ctx.request_repaint();
});
rx
}
fn fetch_bytes(ctx: &egui::Context, url: String) -> Receiver<Result<Vec<u8>, String>> {
let (tx, rx) = std::sync::mpsc::channel();
let ctx = ctx.clone();
ehttp::fetch(ehttp::Request::get(url), move |result| {
let out = match result {
Ok(resp) if resp.ok => Ok(resp.bytes),
Ok(resp) => Err(format!("HTTP {} {}", resp.status, resp.status_text)),
Err(err) => Err(err),
};
let _ = tx.send(out);
ctx.request_repaint();
});
rx
}
fn decode_thumbnail(ctx: &egui::Context, id: &str, bytes: &[u8]) -> Option<egui::TextureHandle> {
let image = image::load_from_memory(bytes).ok()?.to_rgba8();
let (w, h) = image.dimensions();
let color = egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], image.as_raw());
Some(ctx.load_texture(format!("steplib-thumb:{id}"), color, egui::TextureOptions::LINEAR))
}
enum ThumbState {
Loading,
Failed,
Ready(egui::TextureHandle),
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum View {
Search,
Destination,
}
pub struct StepPartsPanel {
open: bool,
view: View,
query: String,
page: u32,
has_next: bool,
searching: bool,
status: String,
results: Vec<PartItem>,
search_rx: Option<Receiver<Result<String, String>>>,
thumbs: HashMap<String, ThumbState>,
thumb_rx: HashMap<String, Receiver<Result<Vec<u8>, String>>>,
pending: Option<PartItem>,
dest_name: String,
pending_step: Option<String>,
step_rx: Option<Receiver<Result<String, String>>>,
explorer: FileExplorer,
seeded: bool,
hits: HashMap<String, egui::Rect>,
}
impl Default for StepPartsPanel {
fn default() -> Self {
Self {
open: false,
view: View::Search,
query: String::new(),
page: 1,
has_next: false,
searching: false,
status: String::new(),
results: Vec::new(),
search_rx: None,
thumbs: HashMap::new(),
thumb_rx: HashMap::new(),
pending: None,
dest_name: String::new(),
pending_step: None,
step_rx: None,
explorer: FileExplorer::new(),
seeded: false,
hits: HashMap::new(),
}
}
}
impl StepPartsPanel {
pub fn new() -> Self {
Self::default()
}
pub fn open(&mut self) {
self.open = true;
self.view = View::Search;
self.seeded = false;
}
fn hit(&mut self, key: &str, resp: &egui::Response) {
self.hits.insert(key.to_string(), resp.rect);
}
fn start_search(&mut self, ctx: &egui::Context, page: u32) {
self.page = page.max(1);
self.searching = true;
self.status = "searching…".into();
self.results.clear();
self.thumbs.clear();
self.thumb_rx.clear();
self.search_rx = Some(fetch_text(ctx, search_url(API_BASE, &self.query, self.page)));
}
fn apply_search_response(&mut self, ctx: &egui::Context, body: &str) {
match parse_search_response(body) {
Ok((parts, has_next)) => {
self.has_next = has_next;
self.status = if parts.is_empty() {
"no matches".into()
} else {
format!("{} result{}", parts.len(), if parts.len() == 1 { "" } else { "s" })
};
for part in &parts {
if !part.png_url.is_empty() {
self.thumbs.insert(part.id.clone(), ThumbState::Loading);
self.thumb_rx
.insert(part.id.clone(), fetch_bytes(ctx, part.png_url.clone()));
}
}
self.results = parts;
}
Err(err) => {
self.status = format!("search failed: {err}");
self.results.clear();
self.has_next = false;
}
}
}
fn poll(&mut self, ctx: &egui::Context) {
if let Some(rx) = &self.search_rx {
if let Ok(reply) = rx.try_recv() {
self.search_rx = None;
self.searching = false;
match reply {
Ok(body) => self.apply_search_response(ctx, &body),
Err(err) => self.status = format!("search failed: {err}"),
}
}
}
let ready: Vec<(String, Result<Vec<u8>, String>)> = self
.thumb_rx
.iter()
.filter_map(|(id, rx)| rx.try_recv().ok().map(|r| (id.clone(), r)))
.collect();
for (id, reply) in ready {
self.thumb_rx.remove(&id);
let state = match reply {
Ok(bytes) => decode_thumbnail(ctx, &id, &bytes)
.map(ThumbState::Ready)
.unwrap_or(ThumbState::Failed),
Err(_) => ThumbState::Failed,
};
self.thumbs.insert(id, state);
}
if let Some(rx) = &self.step_rx {
if let Ok(reply) = rx.try_recv() {
self.step_rx = None;
match reply {
Ok(text) => {
if text.contains("ISO-10303-21") {
self.pending_step = Some(text);
self.status = "STEP downloaded — choose a location and save".into();
} else {
self.status = "download is not a STEP file".into();
}
}
Err(err) => self.status = format!("STEP download failed: {err}"),
}
}
}
}
fn begin_import(&mut self, ctx: &egui::Context, part: PartItem) {
self.dest_name = step_filename_stem(&part.step_url);
self.pending_step = None;
self.status = format!("downloading {}…", self.dest_name);
self.step_rx = Some(fetch_text(ctx, part.step_url.clone()));
self.pending = Some(part);
self.view = View::Destination;
}
fn import_step_text(
state: &mut EngineState,
store: &dyn ModelStore,
dest_name: &str,
step_text: &str,
) -> Result<String, String> {
let document = build_part_document(step_text)?;
let identity = store.browser_write(dest_name, &document)?;
let display = model_display_name(&identity);
let id = state
.insert_component(ComponentInsert::New {
name: &display,
source_key: &identity,
source_signature: &document_signature(&document),
document_json: &document,
})
.map_err(|e| format!("add component failed: {e}"))?;
Ok(id)
}
pub fn show(&mut self, ctx: &egui::Context, state: &mut EngineState, store: &dyn ModelStore) {
self.hits.clear();
if !self.open {
return;
}
self.poll(ctx);
if !self.seeded && !self.searching && self.results.is_empty() {
self.seeded = true;
self.start_search(ctx, 1);
}
let mut open = true;
egui::Window::new("step.parts library")
.id(egui::Id::new("brep-step-parts-window"))
.open(&mut open)
.movable(true)
.resizable(true)
.default_size([460.0, 520.0])
.default_pos([820.0, 70.0])
.show(ctx, |ui| match self.view {
View::Search => self.search_view(ui, ctx),
View::Destination => self.destination_view(ui, state, store),
});
self.open = open;
}
fn search_view(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) {
ui.horizontal(|ui| {
let field = ui.add(
egui::TextEdit::singleline(&mut self.query)
.hint_text("Search parts (e.g. M3 screw, ISO 4762)…")
.desired_width(260.0),
);
self.hit("steplib:query", &field);
let enter = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
let search = ui.button("Search");
self.hit("steplib:search", &search);
if search.clicked() || enter {
self.start_search(ctx, 1);
}
});
ui.horizontal(|ui| {
ui.weak(&self.status);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let next = ui.add_enabled(self.has_next && !self.searching, egui::Button::new("Next ›"));
self.hit("steplib:next", &next);
if next.clicked() {
self.start_search(ctx, self.page + 1);
}
let prev = ui.add_enabled(self.page > 1 && !self.searching, egui::Button::new("‹ Prev"));
self.hit("steplib:prev", &prev);
if prev.clicked() {
self.start_search(ctx, self.page - 1);
}
ui.weak(format!("page {}", self.page));
});
});
ui.separator();
let mut chosen: Option<PartItem> = None;
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
if self.results.is_empty() && !self.searching {
ui.weak("No results. Try a different search.");
}
let results = self.results.clone();
for part in &results {
ui.horizontal(|ui| {
let size = egui::vec2(56.0, 56.0);
match self.thumbs.get(&part.id) {
Some(ThumbState::Ready(tex)) => {
ui.add(egui::Image::new(tex).fit_to_exact_size(size));
}
Some(ThumbState::Loading) => {
ui.add_sized(size, egui::Spinner::new());
}
_ => {
let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover());
ui.painter().rect_filled(
rect,
3.0,
ui.visuals().extreme_bg_color,
);
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
"STEP",
egui::TextStyle::Small.resolve(ui.style()),
ui.visuals().weak_text_color(),
);
}
}
ui.vertical(|ui| {
let row = ui.selectable_label(
false,
egui::RichText::new(&part.name).strong(),
);
self.hit(&format!("steplib:result:{}", part.id), &row);
if !part.category.is_empty() {
ui.weak(&part.category);
}
if !part.description.is_empty() {
ui.small(truncate(&part.description, 90));
}
let add = ui.button("Add to assembly");
self.hit(&format!("steplib:add:{}", part.id), &add);
if add.clicked() || row.double_clicked() {
chosen = Some(part.clone());
}
});
});
ui.separator();
}
});
if let Some(part) = chosen {
self.begin_import(ctx, part);
}
}
fn destination_view(
&mut self,
ui: &mut egui::Ui,
state: &mut EngineState,
store: &dyn ModelStore,
) {
let part_name = self
.pending
.as_ref()
.map(|p| p.name.clone())
.unwrap_or_default();
ui.heading("Save part & add component");
ui.weak(&part_name);
ui.add_space(4.0);
let options = FileExplorerOptions {
hit_prefix: "steplib:dest",
empty_label: "(no saved models here)",
row_icon: "\u{1F5CE}",
current: None,
allow_delete: false,
allow_import: false,
import_label: "",
import_hit: "steplib:dest:upload",
show_cancel: false,
confirm_label: None,
extensions: &["BREP.json", "json"],
};
let output = self.explorer.show_store(ui, store, options);
for (key, rect) in output.hits {
self.hits.insert(key, rect);
}
ui.add_space(4.0);
ui.label("File name (from the STEP file)");
let field = ui.add(
egui::TextEdit::singleline(&mut self.dest_name)
.hint_text("part name")
.desired_width(f32::INFINITY),
);
self.hit("steplib:dest-name", &field);
if !self.status.is_empty() {
ui.add_space(2.0);
ui.weak(&self.status);
}
ui.add_space(6.0);
let step_ready = self.pending_step.is_some();
let name_ok = !self.dest_name.trim().is_empty();
let mut do_import = false;
let mut go_back = false;
ui.horizontal(|ui| {
let save = ui.add_enabled(
step_ready && name_ok,
egui::Button::new("Save & Add"),
);
self.hit("steplib:save", &save);
if save.clicked() {
do_import = true;
}
let back = ui.button("Back");
self.hit("steplib:back", &back);
if back.clicked() {
go_back = true;
}
if !step_ready {
ui.add(egui::Spinner::new());
ui.weak("downloading…");
}
});
if do_import {
let step_text = self.pending_step.clone().unwrap_or_default();
let dest = self.dest_name.trim().to_string();
match Self::import_step_text(state, store, &dest, &step_text) {
Ok(id) => {
self.status = format!("added {dest} ({id})");
self.pending = None;
self.pending_step = None;
self.view = View::Search;
}
Err(err) => self.status = err,
}
} else if go_back {
self.pending = None;
self.pending_step = None;
self.step_rx = None;
self.status.clear();
self.view = View::Search;
}
}
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
pub fn state_json(&self) -> String {
let results: Vec<Value> = self
.results
.iter()
.map(|p| {
serde_json::json!({
"id": p.id,
"name": p.name,
"category": p.category,
"hasThumb": matches!(self.thumbs.get(&p.id), Some(ThumbState::Ready(_))),
})
})
.collect();
let pending = self.pending.as_ref().map(|p| {
serde_json::json!({
"id": p.id,
"name": p.name,
"destName": self.dest_name,
"stepReady": self.pending_step.is_some(),
})
});
serde_json::json!({
"open": self.open,
"view": match self.view { View::Search => "search", View::Destination => "destination" },
"query": self.query,
"status": self.status,
"searching": self.searching,
"page": self.page,
"hasNext": self.has_next,
"resultCount": self.results.len(),
"results": results,
"pending": pending.unwrap_or(Value::Null),
})
.to_string()
}
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
pub fn hits_json(&self) -> String {
let map: serde_json::Map<String, Value> = self
.hits
.iter()
.map(|(k, r)| {
(
k.clone(),
serde_json::json!([r.min.x, r.min.y, r.width(), r.height()]),
)
})
.collect();
Value::Object(map).to_string()
}
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let mut out: String = s.chars().take(max).collect();
out.push('…');
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::MemModelStore;
#[test]
fn search_url_encodes_query_and_page() {
assert_eq!(
search_url("https://api.step.parts/v1", "", 1),
"https://api.step.parts/v1/parts?pageSize=24&page=1"
);
assert_eq!(
search_url("https://api.step.parts/v1", "M3 screw", 2),
"https://api.step.parts/v1/parts?pageSize=24&page=2&q=M3%20screw"
);
assert!(search_url("b", "ISO 4762", 1).ends_with("&q=ISO%204762"));
}
#[test]
fn parse_search_response_reads_documented_fields() {
let body = serde_json::json!({
"items": [
{
"id": "din913_set_screw_m3x3",
"name": "M3×3 Set Screw",
"description": "DIN 913 set screw.",
"category": "fastener",
"stepUrl": "https://media.example/catalog/step/din913_set_screw_m3x3.step",
"pngUrl": "https://blob.example/preview/png/din913-abc.png"
}
],
"hasNextPage": true
})
.to_string();
let (parts, has_next) = parse_search_response(&body).unwrap();
assert!(has_next);
assert_eq!(parts.len(), 1);
let p = &parts[0];
assert_eq!(p.id, "din913_set_screw_m3x3");
assert_eq!(p.name, "M3×3 Set Screw");
assert_eq!(p.category, "fastener");
assert!(p.step_url.ends_with("din913_set_screw_m3x3.step"));
assert!(p.png_url.ends_with(".png"));
}
#[test]
fn parse_search_response_surfaces_api_error_and_bad_body() {
let err = parse_search_response(r#"{"error":"bad query"}"#).unwrap_err();
assert!(err.contains("bad query"), "{err}");
assert!(parse_search_response("not json").is_err());
assert!(parse_search_response(r#"{"total":0}"#).is_err(), "missing items");
}
#[test]
fn step_filename_stem_keeps_the_original_name() {
assert_eq!(
step_filename_stem(
"https://media.githubusercontent.com/media/x/y/catalog/step/adafruit_5128_macropad.step"
),
"adafruit_5128_macropad"
);
assert_eq!(step_filename_stem("a/b/PART_01.STP?token=zzz"), "PART_01");
assert_eq!(step_filename_stem(""), "imported-part");
}
#[test]
fn build_part_document_gates_non_step_and_embeds_step_text() {
assert!(build_part_document("garbage").is_err(), "ISO gate refuses non-STEP");
let step = "ISO-10303-21;\nHEADER;\nENDSEC;\nDATA;\nENDSEC;\nEND-ISO-10303-21;\n";
let doc = build_part_document(step).unwrap();
let v: Value = serde_json::from_str(&doc).unwrap();
assert_eq!(v["features"][0]["type"], "IMPORT3D");
assert_eq!(v["features"][0]["inputParams"]["stepText"], step);
}
#[test]
fn import_step_text_writes_part_and_adds_acomp() {
brep_render::brep_kernel::clear_history_cache();
let solid = brep_render::brep_kernel::make_box_brep(
brep_render::brep_kernel::Vec3::new(0.0, 0.0, 0.0),
4.0,
3.0,
2.0,
)
.expect("box");
let step_text = brep_render::brep_kernel::export_step(
&[solid],
"part",
"MM",
"2026-01-01T00:00:00Z",
)
.expect("export step");
let mut state = EngineState::new();
let store = MemModelStore::new();
let id =
StepPartsPanel::import_step_text(&mut state, &store, "bracket", &step_text).unwrap();
assert!(id.starts_with("ACOMP"), "returns the new ACOMP id: {id}");
let saved = store.read("bracket").expect("part document written");
let v: Value = serde_json::from_str(&saved).unwrap();
assert_eq!(v["features"][0]["type"], "IMPORT3D");
assert!(v["features"][0]["inputParams"]["stepText"]
.as_str()
.unwrap()
.contains("ISO-10303-21"));
let doc: Value = serde_json::from_str(&state.history_request_json()).unwrap();
let acomps: Vec<&Value> = doc["features"]
.as_array()
.unwrap()
.iter()
.filter(|f| f["type"] == "ACOMP")
.collect();
assert_eq!(acomps.len(), 1, "one component added");
assert!(doc["partsLibrary"].as_object().unwrap().len() >= 1, "library entry seeded");
}
#[test]
fn state_json_reports_view_and_pending() {
let mut panel = StepPartsPanel::new();
panel.open();
let s: Value = serde_json::from_str(&panel.state_json()).unwrap();
assert_eq!(s["open"], true);
assert_eq!(s["view"], "search");
assert_eq!(s["resultCount"], 0);
assert_eq!(s["pending"], Value::Null);
}
}