use crate::http::query_component;
use crate::http::{fetch_bytes, fetch_text};
use crate::automation::hit_keys::HitKeyDoc;
use crate::panels::parts_library::document_signature;
use crate::panels::file_explorer::{self, 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 SEARCH: &str = "STEP parts search";
const DOWNLOAD: &str = "STEP part download";
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 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(&query_component(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 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;
}
pub fn busy_activities(&self) -> Vec<super::busy::Activity> {
let mut out = Vec::new();
if self.step_rx.is_some() {
out.push(super::busy::Activity::new(
"stepParts:download",
"download",
format!("Downloading {}", self.dest_name),
));
}
if self.search_rx.is_some() {
out.push(super::busy::Activity::new("stepParts:search", "search", "Searching STEP parts"));
}
out
}
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();
let url = search_url(API_BASE, &self.query, self.page);
if let Some(sentence) = crate::offsite::refusal(SEARCH, &url) {
self.searching = false;
self.status = sentence;
return;
}
self.search_rx = Some(fetch_text(ctx, url));
}
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() && crate::offsite::refusal(SEARCH, &part.png_url).is_some() {
self.thumbs.insert(part.id.clone(), ThumbState::Failed);
} else 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) => {
let url = search_url(API_BASE, &self.query, self.page);
self.status = crate::offsite::explain(SEARCH, &url, 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) => {
let url = self.pending.as_ref().map(|p| p.step_url.clone()).unwrap_or_default();
self.status = crate::offsite::explain(DOWNLOAD, &url, format!("STEP download failed: {err}"));
}
}
}
}
}
fn begin_import(&mut self, ctx: &egui::Context, part: PartItem) {
if let Some(sentence) = crate::offsite::refusal(DOWNLOAD, &part.step_url) {
self.status = sentence;
return;
}
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 (index, part) in results.iter().enumerate() {
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:{index}"), &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:{index}"), &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 step_ready = self.pending_step.is_some();
let mut do_import = false;
let mut go_back = false;
file_explorer::dialog_footer(ui, "steplib:dest", |ui| {
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 name_ok = !self.dest_name.trim().is_empty();
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…");
}
});
});
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: &["nbrep"],
};
let output = self.explorer.show_store(ui, store, options);
for (key, rect) in output.hits {
self.hits.insert(key, rect);
}
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 {
crate::automation::hit_rects::hits_json(&self.hits)
}
}
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
}
}
pub static HIT_KEYS: &[HitKeyDoc] = &[
HitKeyDoc { panel: "stepparts", prefix: "steplib:", meaning: "the STEP parts library controls (search, query, result:i and add:i by the row's index on the current page, prev, next, back, save, dest-name)", command: None },
];