use crate::automation::hit_keys::HitKeyDoc;
use crate::document::{Document, Documents, EMPTY_DOCUMENT};
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, PartSink, StepAssemblyImport, StepAssemblyProbe,
StepAssemblyReport, StepProbeOutcome,
};
use eframe::egui;
use std::collections::HashMap;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FileAction {
New,
Open,
Save,
SaveAs,
Import,
Export,
ExportFlatPattern,
InsertComponent,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Mode {
Open,
SaveAs,
ConfirmClose,
Export,
Import,
FlatPattern,
InsertComponent,
StepAssembly,
}
struct PendingStepImport {
name: String,
text: String,
probe: StepAssemblyProbe,
flatten: bool,
}
struct PendingStepProbe {
id: u64,
name: String,
text: String,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum StepChoice {
Assembly,
Bodies,
Cancel,
}
fn plural(count: usize) -> &'static str {
if count == 1 {
""
} else {
"s"
}
}
fn step_document_name(file_name: &str) -> String {
let base = file_name.rsplit(['/', '\\']).next().unwrap_or(file_name);
let cut = base
.rfind('.')
.filter(|dot| is_step_name(&base[*dot..]))
.unwrap_or(base.len());
let stem = &base[..cut];
if stem.is_empty() {
base.to_string()
} else {
stem.to_string()
}
}
struct StorePartSink<'a> {
store: &'a dyn ModelStore,
prefix: String,
taken: std::collections::HashSet<String>,
written: usize,
failures: Vec<String>,
}
impl<'a> StorePartSink<'a> {
fn new(store: &'a dyn ModelStore, prefix: &str) -> Self {
Self {
store,
prefix: sanitize_file_stem(prefix),
taken: std::collections::HashSet::new(),
written: 0,
failures: Vec::new(),
}
}
}
impl PartSink for StorePartSink<'_> {
fn store_part(&mut self, part_name: &str, document_json: &str) -> Option<String> {
let base = format!("{}-{}", self.prefix, sanitize_file_stem(part_name));
let mut name = base.clone();
let mut suffix = 2;
while !self.taken.insert(name.clone()) {
name = format!("{base}-{suffix}");
suffix += 1;
}
match self.store.browser_write(&name, document_json) {
Ok(identity) => {
self.written += 1;
Some(identity)
}
Err(error) => {
self.failures.push(format!("{name}: {error}"));
None
}
}
}
}
fn sanitize_file_stem(name: &str) -> String {
let mut out = String::with_capacity(name.len());
for ch in name.chars() {
if ch.is_ascii_alphanumeric() || ch == '.' || ch == '-' {
out.push(ch);
} else if !out.ends_with('_') {
out.push('_');
}
}
let trimmed = out.trim_matches('_');
if trimmed.is_empty() {
"part".to_string()
} else {
trimmed.to_string()
}
}
fn assembly_import_message(name: &str, report: &StepAssemblyReport) -> String {
let mut message = format!(
"imported {name} \u{2014} {} part{}, {} component{}",
report.parts,
plural(report.parts),
report.instances,
plural(report.instances)
);
if report.baked_nonrigid > 0 {
message.push_str(&format!(
" ({} mirrored instance{} baked)",
report.baked_nonrigid,
plural(report.baked_nonrigid)
));
}
if report.failed_products > 0 {
message.push_str(&format!(
"; {} product{} could not be built",
report.failed_products,
plural(report.failed_products)
));
}
if report.flat_fallback {
message.push_str("; the structure was NOT used \u{2014} the bodies came in flat");
}
if let Some(error) = &report.first_error {
message.push_str(&format!(" [{error}]"));
}
message
}
fn is_step_name(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
lower.ends_with(".step") || lower.ends_with(".stp")
}
fn is_iges_name(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
lower.ends_with(".iges") || lower.ends_with(".igs")
}
fn is_stl_name(name: &str) -> bool {
name.to_ascii_lowercase().ends_with(".stl")
}
fn is_obj_name(name: &str) -> bool {
name.to_ascii_lowercase().ends_with(".obj")
}
pub struct FileDialog {
explorer: FileExplorer,
name_buf: String,
status: String,
open: bool,
mode: Mode,
want_focus: bool,
pending_component_import: bool,
pending_close: Option<usize>,
pending_step_import: Option<PendingStepImport>,
pending_step_probe: Option<PendingStepProbe>,
pending_stl_import: Option<(String, Vec<u8>)>,
save_generation: u64,
hits: HashMap<String, egui::Rect>,
}
impl FileDialog {
pub fn new() -> Self {
Self {
explorer: FileExplorer::new(),
name_buf: String::new(),
status: String::new(),
open: false,
mode: Mode::Open,
want_focus: false,
pending_component_import: false,
pending_close: None,
pending_step_import: None,
pending_step_probe: None,
pending_stl_import: None,
save_generation: 0,
hits: HashMap::new(),
}
}
pub fn save_generation(&self) -> u64 {
self.save_generation
}
pub fn dispatch(&mut self, action: FileAction, docs: &mut Documents, store: &dyn ModelStore) {
match action {
FileAction::New => self.new_document(docs),
FileAction::Open => self.open_modal(Mode::Open),
FileAction::Save => match docs.active().name().map(str::to_string) {
Some(name) => {
let _ = self.save_to(docs, store, name);
}
None => self.dispatch(FileAction::SaveAs, docs, store),
},
FileAction::SaveAs => {
self.name_buf = self.effective_name(docs);
self.open_modal(Mode::SaveAs);
}
FileAction::Import => {
self.open_modal(Mode::Import);
}
FileAction::Export => {
self.name_buf = self.effective_name(docs);
self.open_modal(Mode::Export);
}
FileAction::ExportFlatPattern => {
self.name_buf = self.effective_name(docs);
self.open_modal(Mode::FlatPattern);
}
FileAction::InsertComponent => self.open_modal(Mode::InsertComponent),
}
}
fn open_modal(&mut self, mode: Mode) {
self.mode = mode;
self.open = true;
self.want_focus = true;
}
pub fn show(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
self.hits.clear();
self.poll_step_probe(docs.engine_mut());
if let Some(imported) = store.take_import() {
self.cancel_pending_step_import(docs.engine_mut());
let name = imported.name;
let bytes = imported.bytes;
if is_step_name(&name) {
self.import_text(docs.engine_mut(), &name, &bytes, Self::import_step);
} else if is_iges_name(&name) {
self.import_text(docs.engine_mut(), &name, &bytes, Self::import_iges);
} else if is_stl_name(&name) {
self.stage_stl_preview(name, bytes);
} else if is_obj_name(&name) {
self.import_obj(docs.engine_mut(), &name, &bytes);
} else if std::mem::take(&mut self.pending_component_import) {
match String::from_utf8(bytes) {
Ok(contents) => {
self.insert_component_document(docs.engine_mut(), &name, &contents)
}
Err(_) => self.status = format!("open failed: {name} is not UTF-8 text"),
}
} else {
match String::from_utf8(bytes) {
Ok(contents) => self.load_document(docs, &name, &contents),
Err(_) => self.status = format!("open failed: {name} is not UTF-8 text"),
}
}
self.close_after_import();
}
if !self.open {
return;
}
match self.mode {
Mode::ConfirmClose => self.show_confirm_close(ctx, docs),
Mode::SaveAs => self.show_save_as(ctx, docs, store),
Mode::Open => self.show_open(ctx, docs, store),
Mode::Import => self.show_import(ctx, docs.engine_mut(), store),
Mode::Export => self.show_export(ctx, docs, store),
Mode::FlatPattern => self.show_flat_pattern(ctx, docs, store),
Mode::InsertComponent => self.show_insert_component(ctx, docs.engine_mut(), store),
Mode::StepAssembly => self.show_step_assembly(ctx, docs.engine_mut(), store),
}
}
fn close_after_import(&mut self) {
if self.pending_step_import.is_some() {
self.open_modal(Mode::StepAssembly);
} else {
self.open = false;
}
}
fn cancel_pending_step_import(&mut self, state: &mut EngineState) {
if self.pending_step_import.take().is_some() {
state.discard_probed_step_assembly();
}
}
fn show_import(
&mut self,
ctx: &egui::Context,
state: &mut EngineState,
store: &dyn ModelStore,
) {
const EXTENSIONS: &[&str] = &["step", "stp", "iges", "igs", "stl", "obj"];
let modal = egui::Modal::new(egui::Id::new("brep-file-import")).show(ctx, |ui| {
file_explorer::dialog_body(ui, |ui| {
ui.heading("Import CAD file");
ui.add_space(4.0);
file_explorer::dialog_footer(ui, "import", |ui| {
if !self.status.is_empty() {
ui.add_space(4.0);
ui.weak(&self.status);
}
});
let options = FileExplorerOptions {
hit_prefix: "import",
empty_label: "(no STEP, IGES, STL, or OBJ files)",
row_icon: "\u{1F5CE}",
current: None,
allow_delete: false,
allow_import: store.supports_file_interchange(),
import_label: "Upload\u{2026}",
import_hit: "import:upload",
show_cancel: true,
confirm_label: Some("Import"),
extensions: EXTENSIONS,
};
let output = self.explorer.show_store(ui, store, options);
self.record_explorer_hits(&output.hits);
output
})
});
let should_close = modal.should_close();
let output = modal.inner;
if let Some(name) = output.activated {
match store.read_external_file(&name) {
Some(bytes) if is_step_name(&name) => {
self.import_text(state, &name, &bytes, Self::import_step)
}
Some(bytes) if is_iges_name(&name) => {
self.import_text(state, &name, &bytes, Self::import_iges)
}
Some(bytes) if is_stl_name(&name) => self.stage_stl_preview(name, bytes),
Some(bytes) if is_obj_name(&name) => self.import_obj(state, &name, &bytes),
Some(_) => self.status = format!("unsupported import file: {name}"),
None => self.status = format!("import failed: '{name}' not found"),
}
self.close_after_import();
} else if output.import {
match store.begin_import_filtered(("CAD / mesh", EXTENSIONS)) {
Ok(()) => {
self.status = "choose a STEP, IGES, STL, or OBJ file\u{2026}".into();
self.open = false;
}
Err(e) => self.status = format!("import failed: {e}"),
}
} else if output.cancel || should_close {
self.open = false;
}
}
pub fn request_close(&mut self, docs: &mut Documents, index: usize) {
let dirty = docs.get(index).is_some_and(Document::is_dirty);
if dirty {
self.pending_close = Some(index);
self.open_modal(Mode::ConfirmClose);
} else {
docs.close(index);
}
}
fn show_confirm_close(&mut self, ctx: &egui::Context, docs: &mut Documents) {
let Some(title) = self
.pending_close
.and_then(|index| docs.get(index))
.map(Document::title)
else {
self.pending_close = None;
self.open = false;
return;
};
let mut discard = false;
let mut cancel = false;
let modal = egui::Modal::new(egui::Id::new("brep-file-confirm-close")).show(ctx, |ui| {
ui.set_width(340.0);
ui.heading("Discard unsaved changes?");
ui.add_space(4.0);
ui.label(format!("\"{title}\" has unsaved changes. Close it?"));
ui.add_space(6.0);
ui.horizontal(|ui| {
let d = ui.button("Discard and close");
self.hit("confirm:discard", &d);
if d.clicked() {
discard = true;
}
let c = ui.button("Cancel");
self.hit("confirm:cancel", &c);
if c.clicked() {
cancel = true;
}
});
});
if discard {
if let Some(index) = self.pending_close.take() {
docs.close(index);
}
self.open = false;
} else if cancel || modal.should_close() {
self.pending_close = None;
self.open = false;
}
}
fn show_step_assembly(
&mut self,
ctx: &egui::Context,
state: &mut EngineState,
store: &dyn ModelStore,
) {
let Some(pending) = self.pending_step_import.as_ref() else {
self.open = false;
return;
};
let name = pending.name.clone();
let probe = pending.probe;
let mut flatten = pending.flatten;
let mut choice: Option<StepChoice> = None;
let modal = egui::Modal::new(egui::Id::new("brep-file-step-assembly")).show(ctx, |ui| {
ui.set_width(360.0);
ui.heading(format!("\"{name}\" contains an assembly"));
ui.add_space(4.0);
ui.label(format!(
"{} part{}, {} instance{}{}.",
probe.parts,
plural(probe.parts),
probe.instances,
plural(probe.instances),
match probe.nested_depth {
0 | 1 => String::new(),
depth => format!(", {depth} levels deep"),
}
));
if probe.nested_depth > 1 {
ui.add_space(4.0);
let check = ui.checkbox(&mut flatten, "Flatten sub-assemblies");
self.hit("stepassembly:flatten", &check);
check.on_hover_text(
"Off: each sub-assembly becomes one rigid component you can \
expand in the structure tree.\nOn: every part is placed \
directly in this document at its world position.",
);
}
ui.add_space(6.0);
ui.horizontal(|ui| {
let a = ui.button("Import as assembly");
self.hit("stepassembly:assembly", &a);
if a.clicked() {
choice = Some(StepChoice::Assembly);
}
let b = ui.button("Import as bodies");
self.hit("stepassembly:bodies", &b);
if b.clicked() {
choice = Some(StepChoice::Bodies);
}
let c = ui.button("Cancel");
self.hit("stepassembly:cancel", &c);
if c.clicked() {
choice = Some(StepChoice::Cancel);
}
});
});
if let Some(pending) = self.pending_step_import.as_mut() {
pending.flatten = flatten;
}
let choice = choice.or_else(|| modal.should_close().then_some(StepChoice::Cancel));
let Some(choice) = choice else { return };
match choice {
StepChoice::Assembly => self.step_assembly_import(state, store),
StepChoice::Bodies => self.step_assembly_bodies(state),
StepChoice::Cancel => self.step_assembly_cancel(state),
}
self.open = false;
}
fn show_save_as(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
let current = docs.active().name().map(str::to_string);
let mut do_save = false;
let mut cancel = false;
let modal = egui::Modal::new(egui::Id::new("brep-file-saveas")).show(ctx, |ui| {
file_explorer::dialog_body(ui, |ui| {
ui.heading("Save model as");
ui.add_space(4.0);
file_explorer::dialog_footer(ui, "saveas", |ui| {
ui.label("File name");
let field = ui.add(
egui::TextEdit::singleline(&mut self.name_buf)
.hint_text("model name")
.desired_width(f32::INFINITY),
);
self.hit("field:name", &field);
if self.want_focus {
field.request_focus();
self.want_focus = false;
}
let enter =
field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
ui.add_space(6.0);
ui.horizontal(|ui| {
let save = ui.button("Save");
self.hit("save", &save);
if save.clicked() || enter {
do_save = true;
}
let c = ui.button("Cancel");
self.hit("cancel", &c);
if c.clicked() {
cancel = true;
}
});
if !self.status.is_empty() {
ui.add_space(4.0);
ui.weak(&self.status);
}
});
let options = FileExplorerOptions {
hit_prefix: "saveas:file",
empty_label: "(no saved models)",
row_icon: "\u{1F5CE}",
current: current.as_deref(),
allow_delete: false,
allow_import: false,
import_label: "",
import_hit: "saveas:upload",
show_cancel: false,
confirm_label: None,
extensions: &["BREP.json", "json"],
};
let output = self.explorer.show_store(ui, store, options);
self.record_explorer_hits(&output.hits);
if let Some(name) = output.picked.or(output.activated) {
self.name_buf = model_display_name(&name);
}
})
});
if do_save {
let name = self.name_buf.clone();
if self.save_to_browser(docs, store, name) {
self.open = false;
}
} else if cancel || modal.should_close() {
self.open = false;
}
}
fn show_export(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
let has_components = !docs.engine().component_ids().is_empty();
let mut chosen: Option<&'static str> = None;
let mut bom_chosen: Option<&'static str> = None;
let mut cancel = false;
let modal = egui::Modal::new(egui::Id::new("brep-file-export")).show(ctx, |ui| {
ui.set_width(320.0);
ui.heading("Export model");
ui.add_space(4.0);
let field = ui.add(
egui::TextEdit::singleline(&mut self.name_buf)
.hint_text("file name")
.desired_width(f32::INFINITY),
);
self.hit("field:name", &field);
if self.want_focus {
field.request_focus();
self.want_focus = false;
}
ui.add_space(6.0);
ui.horizontal(|ui| {
let step = ui.button("STEP (.step)");
self.hit("export:step", &step);
if step.clicked() {
chosen = Some("step");
}
let iges = ui.button("IGES (.igs)");
self.hit("export:iges", &iges);
if iges.clicked() {
chosen = Some("iges");
}
let stl = ui.button("STL (.stl)");
self.hit("export:stl", &stl);
if stl.clicked() {
chosen = Some("stl");
}
let json = ui.button("JSON (.BREP.json)");
self.hit("export:json", &json);
if json.clicked() {
chosen = Some("json");
}
let c = ui.button("Cancel");
self.hit("cancel", &c);
if c.clicked() {
cancel = true;
}
});
ui.add_space(4.0);
ui.horizontal(|ui| {
let csv = ui.add_enabled(has_components, egui::Button::new("BOM (CSV)"));
self.hit("export:bomcsv", &csv);
if csv.clicked() {
bom_chosen = Some("csv");
}
let json = ui.add_enabled(has_components, egui::Button::new("BOM (JSON)"));
self.hit("export:bomjson", &json);
if json.clicked() {
bom_chosen = Some("json");
}
});
if !self.status.is_empty() {
ui.add_space(4.0);
ui.weak(&self.status);
}
});
if let Some(format) = chosen {
if self.export_as(docs, store, format) {
self.open = false;
}
} else if let Some(format) = bom_chosen {
if self.export_bom_as(docs, store, format) {
self.open = false;
}
} else if cancel || modal.should_close() {
self.open = false;
}
}
fn show_flat_pattern(
&mut self,
ctx: &egui::Context,
docs: &mut Documents,
store: &dyn ModelStore,
) {
let mut chosen: Option<&'static str> = None;
let mut cancel = false;
let modal = egui::Modal::new(egui::Id::new("brep-file-flatpattern")).show(ctx, |ui| {
ui.set_width(340.0);
ui.heading("Export flat pattern");
ui.add_space(2.0);
ui.weak("Unfolds the sheet-metal body to a 2D vector file.");
ui.add_space(4.0);
let field = ui.add(
egui::TextEdit::singleline(&mut self.name_buf)
.hint_text("file name")
.desired_width(f32::INFINITY),
);
self.hit("field:name", &field);
if self.want_focus {
field.request_focus();
self.want_focus = false;
}
ui.add_space(6.0);
ui.horizontal(|ui| {
let dxf = ui.button("DXF (.dxf)");
self.hit("flat:dxf", &dxf);
if dxf.clicked() {
chosen = Some("dxf");
}
let svg = ui.button("SVG (.svg)");
self.hit("flat:svg", &svg);
if svg.clicked() {
chosen = Some("svg");
}
let c = ui.button("Cancel");
self.hit("cancel", &c);
if c.clicked() {
cancel = true;
}
});
if !self.status.is_empty() {
ui.add_space(4.0);
ui.weak(&self.status);
}
});
if let Some(format) = chosen {
if self.export_flat_pattern_as(docs, store, format) {
self.open = false;
}
} else if cancel || modal.should_close() {
self.open = false;
}
}
fn show_open(&mut self, ctx: &egui::Context, docs: &mut Documents, store: &dyn ModelStore) {
let current = docs.active().name().map(str::to_string);
let modal = egui::Modal::new(egui::Id::new("brep-file-open")).show(ctx, |ui| {
file_explorer::dialog_body(ui, |ui| {
ui.heading("Open model");
ui.add_space(4.0);
file_explorer::dialog_footer(ui, "open", |ui| {
if !self.status.is_empty() {
ui.add_space(4.0);
ui.weak(&self.status);
}
});
let mut options = FileExplorerOptions::open(current.as_deref());
options.allow_import = store.supports_file_interchange();
let output = self.explorer.show_store(ui, store, options);
self.record_explorer_hits(&output.hits);
output
})
});
let should_close = modal.should_close();
let output = modal.inner;
if let Some(name) = output.activated {
self.open_document(docs, store, &name);
self.open = false;
} else if let Some(name) = output.remove {
let _ = store.remove(&name);
self.status = format!("removed {name}");
if let Some(index) = docs.index_of(&name) {
if let Some(doc) = docs.get_mut(index) {
doc.set_name(None);
}
}
} else if output.import {
match store.begin_import() {
Ok(()) => {
self.status = "choose a file…".into();
self.open = false;
}
Err(e) => self.status = format!("import failed: {e}"),
}
} else if output.cancel || should_close {
self.open = false;
}
}
fn show_insert_component(
&mut self,
ctx: &egui::Context,
state: &mut EngineState,
store: &dyn ModelStore,
) {
let library = state.parts_library_names();
let modal = egui::Modal::new(egui::Id::new("brep-file-insert-component")).show(ctx, |ui| {
file_explorer::dialog_body(ui, |ui| {
ui.heading("Insert component");
ui.add_space(4.0);
file_explorer::dialog_footer(ui, "insert", |ui| {
if !self.status.is_empty() {
ui.add_space(4.0);
ui.weak(&self.status);
}
});
let mut chosen_library = None;
if !library.is_empty() {
ui.weak("In this document (parts library)");
for name in &library {
let row = ui.add_sized(
egui::vec2(ui.available_width(), 18.0),
crate::icon_text::icon_button(ui, &format!("\u{25A3} {name}"))
.frame(false),
);
self.hit(&format!("insert:lib:{name}"), &row);
if row.clicked() {
chosen_library = Some(name.clone());
}
}
ui.add_space(4.0);
}
let options = FileExplorerOptions {
hit_prefix: "insert:model",
empty_label: "(no saved models)",
row_icon: "\u{1F5CE}",
current: None,
allow_delete: false,
allow_import: store.supports_file_interchange(),
import_label: "Upload\u{2026}",
import_hit: "insert:upload",
show_cancel: true,
confirm_label: Some("Insert"),
extensions: &["BREP.json", "json"],
};
let output = self.explorer.show_store(ui, store, options);
self.record_explorer_hits(&output.hits);
(chosen_library, output)
})
});
let should_close = modal.should_close();
let (chosen_library, output) = modal.inner;
if let Some(part_name) = chosen_library {
match state.insert_component(ComponentInsert::Existing { part_name: &part_name }) {
Ok(id) => {
self.status = format!("inserted {part_name} ({id})");
self.open = false;
}
Err(e) => self.status = format!("insert failed: {e}"),
}
} else if let Some(name) = output.activated {
match store.read(&name) {
Some(contents) => {
self.insert_component_document(state, &name, &contents);
self.open = false;
}
None => self.status = format!("insert failed: '{name}' not found"),
}
} else if output.import {
match store.begin_import() {
Ok(()) => {
self.pending_component_import = true;
self.status = "choose a part file…".into();
self.open = false;
}
Err(e) => self.status = format!("insert failed: {e}"),
}
} else if output.cancel || should_close {
self.open = false;
}
}
fn insert_component_document(&mut self, state: &mut EngineState, name: &str, contents: &str) {
let display = model_display_name(name);
match state.insert_component(ComponentInsert::New {
name: &display,
source_key: name,
source_signature: &document_signature(contents),
document_json: contents,
}) {
Ok(id) => self.status = format!("inserted {display} ({id})"),
Err(e) => self.status = format!("insert failed: {e}"),
}
}
fn effective_name(&self, docs: &Documents) -> String {
let field = self.name_buf.trim();
if !field.is_empty() {
field.to_string()
} else {
docs.active()
.name()
.map(str::to_string)
.unwrap_or_else(|| "untitled".into())
}
}
fn new_document(&mut self, docs: &mut Documents) {
let mut engine = docs.spawn_engine();
let _ = engine.set_history_json(EMPTY_DOCUMENT);
docs.open_document(Document::new(engine));
self.name_buf.clear();
self.status = "new (empty) model".into();
}
fn name_is_free(&mut self, docs: &Documents, name: &str) -> bool {
let display = model_display_name(name);
let taken = docs.iter().enumerate().any(|(index, doc)| {
index != docs.active_index()
&& doc
.name()
.is_some_and(|open| model_display_name(open) == display)
});
if taken {
self.status = format!("'{display}' is already open in another tab");
}
!taken
}
fn save_to(&mut self, docs: &mut Documents, store: &dyn ModelStore, name: String) -> bool {
let name = name.trim().to_string();
if name.is_empty() {
self.status = "enter a name to save".into();
return false;
}
if !self.name_is_free(docs, &name) {
return false;
}
match store.write(&name, &docs.engine().history_request_json()) {
Ok(()) => {
self.name_buf = model_display_name(&name);
let doc = docs.active_mut();
doc.set_name(Some(name.clone()));
doc.mark_clean();
self.save_generation += 1;
self.status = format!("saved {name}");
true
}
Err(e) => {
self.status = format!("save failed: {e}");
false
}
}
}
fn save_to_browser(
&mut self,
docs: &mut Documents,
store: &dyn ModelStore,
name: String,
) -> bool {
let name = name.trim().to_string();
if name.is_empty() {
self.status = "enter a name to save".into();
return false;
}
if !self.name_is_free(docs, &name) {
return false;
}
match store.browser_write(&name, &docs.engine().history_request_json()) {
Ok(identity) => {
self.name_buf = model_display_name(&identity);
let doc = docs.active_mut();
doc.set_name(Some(identity.clone()));
doc.mark_clean();
self.save_generation += 1;
self.status = format!("saved {identity}");
true
}
Err(e) => {
self.status = format!("save failed: {e}");
false
}
}
}
pub fn open_document(&mut self, docs: &mut Documents, store: &dyn ModelStore, name: &str) {
if docs.focus_named(name) {
self.status = format!("{name} is already open");
return;
}
match store.read(name) {
Some(contents) => self.load_document(docs, name, &contents),
None => self.status = format!("open failed: '{name}' not found"),
}
}
fn export_as(&mut self, docs: &mut Documents, store: &dyn ModelStore, format: &str) -> bool {
let name = self.effective_name(docs);
let state = docs.engine_mut();
let (text, ext) = match format {
"stl" => (state.export_stl_text(), "stl"),
"iges" => (state.export_iges_text(), "igs"),
"json" => (Ok(state.history_request_json()), "BREP.json"),
_ => (state.export_step_text_named(&name), "step"),
};
match text {
Ok(contents) => match store.export_file_named(&format!("{name}.{ext}"), &contents) {
Ok(()) => {
self.status = format!("exported {name}.{ext}");
true
}
Err(e) => {
self.status = format!("export failed: {e}");
false
}
},
Err(e) => {
self.status = format!("export failed: {e}");
false
}
}
}
fn export_flat_pattern_as(
&mut self,
docs: &mut Documents,
store: &dyn ModelStore,
format: &str,
) -> bool {
let name = self.effective_name(docs);
let state = docs.engine_mut();
let (text, ext) = match format {
"svg" => (state.export_flat_pattern_svg(), "svg"),
_ => (state.export_flat_pattern_dxf(), "dxf"),
};
match text {
Ok(contents) => match store.export_file_named(&format!("{name}.{ext}"), &contents) {
Ok(()) => {
self.status = format!("exported {name}.{ext}");
true
}
Err(e) => {
self.status = format!("flat-pattern export failed: {e}");
false
}
},
Err(e) => {
self.status = format!("flat-pattern export failed: {e}");
state.push_notice(format!("Flat pattern: {e}"));
false
}
}
}
fn export_bom_as(
&mut self,
docs: &mut Documents,
store: &dyn ModelStore,
format: &str,
) -> bool {
let name = self.effective_name(docs);
let state = docs.engine_mut();
let (text, ext) = match format {
"json" => (state.export_bom_json(), "bom.json"),
_ => (state.export_bom_csv(), "bom.csv"),
};
match text {
Ok(contents) => match store.export_file_named(&format!("{name}.{ext}"), &contents) {
Ok(()) => {
self.status = format!("exported {name}.{ext}");
true
}
Err(e) => {
self.status = format!("BOM export failed: {e}");
false
}
},
Err(e) => {
self.status = format!("BOM export failed: {e}");
state.push_notice(format!("BOM export: {e}"));
false
}
}
}
fn import_step(&mut self, state: &mut EngineState, name: &str, contents: &str) {
self.pending_step_import = None;
let id = state.submit_step_probe(contents);
self.pending_step_probe = Some(PendingStepProbe {
id,
name: name.to_string(),
text: contents.to_string(),
});
self.status = format!("reading \"{name}\"\u{2026}");
self.poll_step_probe(state);
}
fn poll_step_probe(&mut self, state: &mut EngineState) {
while let Some((id, outcome)) = state.take_step_probe() {
let Some(pending) = self.pending_step_probe.as_ref() else {
continue;
};
if pending.id != id {
continue; }
let PendingStepProbe { name, text, .. } = self.pending_step_probe.take().unwrap();
match outcome {
StepProbeOutcome::Structure(probe) => {
self.status = format!(
"\"{name}\" contains an assembly — {} part{}, {} instance{}",
probe.parts,
plural(probe.parts),
probe.instances,
plural(probe.instances)
);
self.pending_step_import = Some(PendingStepImport {
name,
text,
probe,
flatten: false,
});
self.open_modal(Mode::StepAssembly);
}
StepProbeOutcome::Flat | StepProbeOutcome::Failed(_) => {
self.import_step_flat(state, &name, &text);
}
}
}
if self.pending_step_probe.is_some() && !state.step_probes_pending() {
let pending = self.pending_step_probe.take().unwrap();
self.status = format!("import of \"{}\" was cancelled", pending.name);
}
}
fn import_step_flat(&mut self, state: &mut EngineState, name: &str, contents: &str) {
match state.import_step_feature(contents) {
Ok(_) => self.status = format!("imported {name}"),
Err(e) => self.status = format!("import failed: {e}"),
}
}
fn step_assembly_import(&mut self, state: &mut EngineState, store: &dyn ModelStore) {
let Some(pending) = self.pending_step_import.take() else {
return;
};
let was_assembly = state.history_has_assembly();
let doc_name = step_document_name(&pending.name);
let opts = StepAssemblyImport {
nested: !pending.flatten,
};
let mut sink = StorePartSink::new(store, &doc_name);
let message = match state.import_probed_step_assembly(&doc_name, opts, &mut sink) {
Ok(report) => {
if !was_assembly {
self.enter_assembly_workbench(state);
}
for failure in &sink.failures {
state.push_notice(format!("part not saved — {failure}"));
}
let saved = sink.written;
let base = assembly_import_message(&pending.name, &report);
match (saved, sink.failures.len()) {
(0, _) => base,
(saved, 0) => format!("{base}; saved {saved} part file(s)"),
(saved, failed) => {
format!("{base}; saved {saved} part file(s), {failed} could not be saved")
}
}
}
Err(error) => match state.import_step_feature(&pending.text) {
Ok(_) => format!(
"imported {} as bodies — the assembly structure could not be built ({error})",
pending.name
),
Err(flat) => format!("import failed: {flat}"),
},
};
self.status = message.clone();
state.push_notice(message);
}
fn step_assembly_bodies(&mut self, state: &mut EngineState) {
let Some(pending) = self.pending_step_import.take() else {
return;
};
state.discard_probed_step_assembly();
self.import_step_flat(state, &pending.name, &pending.text);
}
fn step_assembly_cancel(&mut self, state: &mut EngineState) {
let Some(pending) = self.pending_step_import.take() else {
return;
};
state.discard_probed_step_assembly();
self.status = format!("import cancelled: {}", pending.name);
}
fn enter_assembly_workbench(&mut self, state: &mut EngineState) {
if crate::workbench::panel_visible(
&state.settings.workbench,
crate::workbench::assembly::BOM_PANEL_ID,
) {
return;
}
let _ = state.apply_settings_json(
&serde_json::json!({ "workbench": crate::workbench::assembly::ASSEMBLY.id })
.to_string(),
);
}
fn import_iges(&mut self, state: &mut EngineState, name: &str, contents: &str) {
match state.import_iges_feature(contents) {
Ok(_) => self.status = format!("imported {name}"),
Err(e) => self.status = format!("import failed: {e}"),
}
}
fn stage_stl_preview(&mut self, name: String, contents: Vec<u8>) {
self.pending_stl_import = Some((name, contents));
self.status.clear();
}
pub fn take_stl_import(&mut self) -> Option<(String, Vec<u8>)> {
self.pending_stl_import.take()
}
fn import_obj(&mut self, state: &mut EngineState, name: &str, contents: &[u8]) {
match state.import_obj_bytes_feature(contents) {
Ok(_) => self.status = format!("reconstructing {name} in background…"),
Err(e) => self.status = format!("import failed: {e}"),
}
}
fn import_text(
&mut self,
state: &mut EngineState,
name: &str,
bytes: &[u8],
importer: fn(&mut Self, &mut EngineState, &str, &str),
) {
match std::str::from_utf8(bytes) {
Ok(contents) => importer(self, state, name, contents),
Err(_) => self.status = format!("import failed: {name} is not UTF-8 text"),
}
}
fn load_document(&mut self, docs: &mut Documents, name: &str, contents: &str) {
let mut engine = docs.spawn_engine();
match engine.load_model_and_fit(contents) {
Ok(_) => {
let mut doc = Document::new(engine);
doc.set_name(Some(name.to_string()));
docs.open_document(doc);
self.name_buf = model_display_name(name);
self.status = format!("opened {name}");
}
Err(e) => self.status = format!("open failed: {e}"),
}
}
pub fn file_state_json(&self, docs: &Documents, store: &dyn ModelStore) -> String {
serde_json::json!({
"name": docs.active().name(),
"nameBuf": self.name_buf,
"selected": self.explorer.selected(),
"location": store.browser_location(),
"dirty": docs.active().is_dirty(),
"backend": store.backend_label(),
"interchange": store.supports_file_interchange(),
"list": store.list(),
"status": self.status,
"open": self.open,
"mode": match self.mode {
Mode::Open => "open",
Mode::SaveAs => "saveas",
Mode::ConfirmClose => "confirmclose",
Mode::Export => "export",
Mode::Import => "import",
Mode::FlatPattern => "flatpattern",
Mode::InsertComponent => "insertcomponent",
Mode::StepAssembly => "stepassembly",
},
"probing": self.pending_step_probe.as_ref().map(|pending| pending.name.clone()),
"stepAssembly": self.pending_step_import.as_ref().map(|pending| {
serde_json::json!({
"name": pending.name,
"parts": pending.probe.parts,
"instances": pending.probe.instances,
"nestedDepth": pending.probe.nested_depth,
"flatten": pending.flatten,
})
}),
})
.to_string()
}
pub fn hits_json(&self) -> String {
crate::automation::hit_rects::hits_json(&self.hits)
}
fn hit(&mut self, key: &str, resp: &egui::Response) {
self.hits.insert(key.to_string(), resp.rect);
}
fn record_explorer_hits(&mut self, hits: &[(String, egui::Rect)]) {
self.hits.extend(hits.iter().cloned());
}
}
pub static HIT_KEYS: &[HitKeyDoc] = &[
HitKeyDoc { panel: "file", prefix: "field:name", meaning: "the document name field", command: None },
HitKeyDoc { panel: "file", prefix: "save", meaning: "confirm save / save-as", command: None },
HitKeyDoc { panel: "file", prefix: "cancel", meaning: "close the modal", command: None },
HitKeyDoc { panel: "file", prefix: "confirm:cancel", meaning: "keep the dirty document open", command: None },
HitKeyDoc { panel: "file", prefix: "confirm:discard", meaning: "discard the dirty document", command: None },
HitKeyDoc { panel: "file", prefix: "export:", meaning: "export in a format (export:step, export:stl, export:iges, export:json, export:bomcsv, export:bomjson)", command: None },
HitKeyDoc { panel: "file", prefix: "flat:", meaning: "export the flat pattern (flat:dxf, flat:svg)", command: None },
HitKeyDoc { panel: "file", prefix: "stepassembly:", meaning: "a STEP-with-structure import choice (assembly, bodies, flatten, cancel)", command: None },
HitKeyDoc { panel: "file", prefix: "insert:lib:", meaning: "insert a parts-library entry (insert:lib:name)", command: None },
HitKeyDoc { panel: "file", prefix: "filesystem:", meaning: "an entry of the file explorer", command: None },
];