use super::helpers::refresh_after_library_change;
use crate::{
LibraryModel, MainWindow,
bridge::library::source::LibrarySource,
gui::{library::detail::reconstruct_planes, show_toast},
settings::SettingsPersister,
};
use glam::DVec3;
use indicatrix::geometry::{GpuFacetPlane, cuts::FacetSpec, girdle, stone_metrics};
use indicatrix_vault::{
db::sqlite::{DEFAULT_SHAPES, Database},
local,
model::{detail::FacetDiagramDetail, entry::FullDiagramRecord},
};
use slint::{ComponentHandle, ModelRc, VecModel, Weak};
use std::{
collections::HashSet,
path::{Path, PathBuf},
sync::{Arc, Mutex},
thread,
};
use tracing::warn;
pub fn apply_measured_metadata(detail: &mut FacetDiagramDetail) {
if detail.angle_settings_table.is_empty() {
return;
}
let facet_specs: Vec<FacetSpec> = detail
.angle_settings_table
.iter()
.map(|a| FacetSpec {
facet: a.facet.clone(),
angle: a.angle.clone(),
index: a.index.clone(),
notes: a.notes.clone(),
})
.collect();
let planes = reconstruct_planes(None, detail.index_gear.as_deref(), &facet_specs);
let dvec_planes: Vec<(DVec3, f64)> = planes
.iter()
.map(|p| {
(
DVec3::new(
f64::from(p.normal[0]),
f64::from(p.normal[1]),
f64::from(p.normal[2]),
),
-f64::from(p.d),
)
})
.collect();
let Some(m) = stone_metrics::measure_solid(&dvec_planes) else {
return;
};
let w = m.width_axis;
if w < 1e-9 {
return;
}
detail.lw_ratio = Some((m.length_axis / w).to_string());
detail.hw_ratio = Some((m.total_height / w).to_string());
detail.cw_ratio = m.crown_height.map(|c| (c / w).to_string());
detail.pw_ratio = m.pavilion_depth.map(|p| (p / w).to_string());
detail.volume = Some((m.volume / (w * w * w)).to_string());
detail.shape = classify_shape(&planes, m.length_axis / w);
}
pub fn merge_reimport_metadata(fresh: &mut FacetDiagramDetail, existing: &FullDiagramRecord) {
if fresh.page_url.is_empty() {
fresh.page_url.clone_from(&existing.page_url);
}
fresh.diagram_image_name = fresh
.diagram_image_name
.take()
.or_else(|| existing.diagram_image_name.clone());
fresh.diagram_image_data = fresh
.diagram_image_data
.take()
.or_else(|| existing.diagram_image_data.clone());
fresh.competition_diagram = fresh
.competition_diagram
.take()
.or_else(|| existing.competition_diagram.clone());
fresh.lw_ratio = fresh.lw_ratio.take().or_else(|| existing.lw_ratio.clone());
fresh.refractive_index = fresh
.refractive_index
.take()
.or_else(|| existing.refractive_index.clone());
fresh.index_gear = fresh
.index_gear
.take()
.or_else(|| existing.index_gear.clone());
fresh.volume = fresh.volume.take().or_else(|| existing.volume.clone());
fresh.facets_count = fresh
.facets_count
.take()
.or_else(|| existing.facets_count.clone());
fresh.shape = fresh.shape.take().or_else(|| existing.shape.clone());
fresh.designer_info = fresh
.designer_info
.take()
.or_else(|| existing.designer_info.clone());
fresh.hw_ratio = fresh.hw_ratio.take().or_else(|| existing.hw_ratio.clone());
fresh.tw_ratio = fresh.tw_ratio.take().or_else(|| existing.tw_ratio.clone());
fresh.uw_ratio = fresh.uw_ratio.take().or_else(|| existing.uw_ratio.clone());
fresh.pw_ratio = fresh.pw_ratio.take().or_else(|| existing.pw_ratio.clone());
fresh.cw_ratio = fresh.cw_ratio.take().or_else(|| existing.cw_ratio.clone());
fresh.symmetry_order = fresh
.symmetry_order
.take()
.or_else(|| existing.symmetry_order.clone());
fresh.mirror_symmetry = fresh.mirror_symmetry.or(existing.mirror_symmetry);
fresh.designer = fresh.designer.take().or_else(|| existing.designer.clone());
fresh.source_citation = fresh
.source_citation
.take()
.or_else(|| existing.source_citation.clone());
fresh.pdf_file = fresh.pdf_file.take().or_else(|| existing.pdf_file.clone());
fresh.gem_file = fresh.gem_file.take().or_else(|| existing.gem_file.clone());
fresh.shape_category = fresh
.shape_category
.take()
.or_else(|| existing.shape_category.clone());
}
fn classify_shape(planes: &[GpuFacetPlane], lw: f64) -> Option<String> {
const ROUND_LW_MAX: f64 = 1.03;
const POLYGON_LW_MAX: f64 = 1.20;
const ROUND_MIN_SIDES: usize = 12;
let sides = girdle::classify_girdle_plane_indices(planes).len();
let name = match sides {
3 if lw <= POLYGON_LW_MAX => "Triangle",
4 if lw <= ROUND_LW_MAX => "Square",
6 if lw <= POLYGON_LW_MAX => "Hexagon",
8 if lw <= ROUND_LW_MAX => "Octagon",
n if n >= ROUND_MIN_SIDES && lw <= ROUND_LW_MAX => "Round",
_ => return None,
};
DEFAULT_SHAPES
.iter()
.find(|s| **s == name)
.map(|s| (*s).to_string())
}
fn catch_file_panic<T>(f: impl FnOnce() -> T + std::panic::UnwindSafe) -> Result<T, String> {
std::panic::catch_unwind(f).map_err(|payload| {
payload
.downcast_ref::<&str>()
.map(|s| (*s).to_string())
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "unknown panic".to_string())
})
}
const MAX_RECURSE_DEPTH: usize = 32;
fn collect_asc_files_recursive(
dir: &Path,
recurse: bool,
depth: usize,
max_depth: usize,
visited: &mut HashSet<PathBuf>,
out: &mut Vec<PathBuf>,
gem_gcs_skipped: &mut usize,
) {
if depth > max_depth {
warn!(
"Import: not descending into '{}' -- exceeded max recursion depth ({max_depth})",
dir.display()
);
return;
}
let canon = std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
if !visited.insert(canon) {
warn!(
"Import: skipping '{}' -- already visited (symlink loop?)",
dir.display()
);
return;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let p = entry.path();
if p.is_file() && p.extension().is_some_and(|e| e.eq_ignore_ascii_case("asc")) {
out.push(p);
} else if p.is_file() && is_gem_or_gcs(&p) {
*gem_gcs_skipped += 1;
} else if recurse && p.is_dir() {
collect_asc_files_recursive(
&p,
recurse,
depth + 1,
max_depth,
visited,
out,
gem_gcs_skipped,
);
}
}
}
#[must_use]
fn is_gem_or_gcs(path: &Path) -> bool {
path.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("gem") || e.eq_ignore_ascii_case("gcs"))
}
const GEM_GCS_EXPLANATION: &str = ".gem is GemCAD's own binary format -- this app reads only \
partial data from it (no facet geometry) elsewhere, never through Import; .gcs is read-only \
wherever it is read. Import a .asc export of the same design instead.";
fn collect_import_candidates(path: &Path, recurse: bool) -> Result<(Vec<PathBuf>, usize), String> {
let mut candidates: Vec<PathBuf> = Vec::new();
let mut gem_gcs_skipped = 0usize;
if path.is_dir() {
match std::fs::read_dir(path) {
Ok(entries) => {
let mut visited: HashSet<PathBuf> = HashSet::new();
for entry in entries.flatten() {
let p = entry.path();
if p.is_file() && p.extension().is_some_and(|e| e.eq_ignore_ascii_case("asc")) {
candidates.push(p);
} else if p.is_file() && is_gem_or_gcs(&p) {
gem_gcs_skipped += 1;
} else if recurse && p.is_dir() {
collect_asc_files_recursive(
&p,
recurse,
1,
MAX_RECURSE_DEPTH,
&mut visited,
&mut candidates,
&mut gem_gcs_skipped,
);
}
}
}
Err(e) => return Err(format!("Could not read folder '{}': {e}", path.display())),
}
} else if path.is_file() {
if indicatrix_cut_core::native::asc_path_for_native(path).is_some() {
return Err(format!(
"'{}' is an Indicatrix native design file, not a .asc -- open it with \"Open \
Native\" in the Edit tab instead of Import.",
path.display()
));
}
if is_gem_or_gcs(path) {
return Err(format!(
"'{}' is not a .asc file. {GEM_GCS_EXPLANATION}",
path.display()
));
}
candidates.push(path.to_path_buf());
} else {
return Err(format!("'{}' is not a file or folder.", path.display()));
}
if candidates.is_empty() {
return Err(if gem_gcs_skipped > 0 {
format!(
"No .asc files found at '{}' ({gem_gcs_skipped} .gem/.gcs file(s) found but not \
imported -- {GEM_GCS_EXPLANATION})",
path.display()
)
} else {
format!("No .asc files found at '{}'.", path.display())
});
}
Ok((candidates, gem_gcs_skipped))
}
fn find_native_sidecar(asc_path: &Path) -> Option<(String, Vec<u8>)> {
let mut current = asc_path.to_path_buf();
current.set_extension(indicatrix_cut_core::native::NATIVE_EXTENSION_SUFFIX);
let mut legacy = asc_path.to_path_buf();
legacy.set_extension(indicatrix_cut_core::native::LEGACY_NATIVE_EXTENSION_SUFFIX);
[current, legacy].into_iter().find_map(|candidate| {
let bytes = std::fs::read(&candidate).ok()?;
let name = candidate.file_name()?.to_string_lossy().into_owned();
Some((name, bytes))
})
}
struct ImportOutcome {
summary: String,
imported_ids: Vec<i64>,
had_failures: bool,
had_collision: bool,
}
fn save_imported_design(
db: &Arc<Mutex<Database>>,
url: &str,
seen_before_in_batch: bool,
parsed: local::ImportedAsc,
file_name: &str,
) -> anyhow::Result<(i64, bool)> {
let db = db.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let is_collision = seen_before_in_batch || db.has_detail_for_entry_url(url).unwrap_or(false);
let local::ImportedAsc {
entry,
mut detail,
derived_from_entry_id,
} = parsed;
db.save_diagram_entry(&entry, local::LOCAL_SOURCE_ID)
.and_then(|id| {
if is_collision && let Ok(Some(existing)) = db.get_diagram_full(id) {
merge_reimport_metadata(&mut detail, &existing);
}
db.save_diagram_detail(&detail, id).map(|()| id)
})
.map(|id| {
if is_collision {
if let Err(e) = db.delete_preview_images(id) {
warn!(
"Import: failed to invalidate stale preview cache for entry #{id} \
('{file_name}'): {e}"
);
}
if let Err(e) = db.delete_tilt_curves(id) {
warn!(
"Import: failed to invalidate stale tilt-curve cache for entry #{id} \
('{file_name}'): {e}"
);
}
} else if let Some(source_id) = derived_from_entry_id {
match db.get_diagram_full(source_id) {
Ok(Some(_)) => {
if let Err(e) = db.set_derived_from_entry_id(id, Some(source_id)) {
warn!(
"Import: failed to record entry #{id} ('{file_name}') as \
derived from #{source_id}: {e}"
);
}
}
Ok(None) => {
}
Err(e) => warn!(
"Import: could not verify source entry #{source_id} for '{file_name}' \
before recording provenance: {e}"
),
}
}
(id, is_collision)
})
}
fn parse_one_import(
file_name: &str,
content: &str,
sidecar: Option<&(String, Vec<u8>)>,
) -> Result<local::ImportedAsc, String> {
let parse_result = catch_file_panic(std::panic::AssertUnwindSafe(|| {
local::import_asc(
file_name,
content,
sidecar.map(|(name, bytes)| (name.as_str(), bytes.as_slice())),
)
.map(|mut parsed| {
apply_measured_metadata(&mut parsed.detail);
parsed
})
}));
match parse_result {
Ok(Ok(parsed)) => Ok(parsed),
Ok(Err(e)) => Err(format!("{file_name} (parse error: {e})")),
Err(panic_msg) => {
warn!("Import panicked while processing '{file_name}': {panic_msg}");
Err(format!("{file_name} (internal error: {panic_msg})"))
}
}
}
fn import_path(
db: &Arc<Mutex<Database>>,
path: &Path,
recurse: bool,
mut on_progress: impl FnMut(usize, usize),
) -> ImportOutcome {
let (candidates, gem_gcs_skipped) = match collect_import_candidates(path, recurse) {
Ok(c) => c,
Err(message) => {
return ImportOutcome {
summary: message,
imported_ids: Vec::new(),
had_failures: true,
had_collision: false,
};
}
};
let total = candidates.len();
let mut imported = 0usize;
let mut imported_ids: Vec<i64> = Vec::new();
let mut failed: Vec<String> = Vec::new();
let mut seen_in_batch: HashSet<String> = HashSet::new();
let mut replaced: Vec<String> = Vec::new();
for (i, file_path) in candidates.into_iter().enumerate() {
let file_name = file_path.file_name().map_or_else(
|| "unknown.asc".to_string(),
|n| n.to_string_lossy().into_owned(),
);
on_progress(i + 1, total);
let content = match std::fs::read_to_string(&file_path) {
Ok(c) => c,
Err(e) => {
failed.push(format!("{file_name} (read error: {e})"));
continue;
}
};
let url = format!("local://{file_name}");
let seen_before_in_batch = !seen_in_batch.insert(file_name.clone());
let sidecar = find_native_sidecar(&file_path);
let parsed = match parse_one_import(&file_name, &content, sidecar.as_ref()) {
Ok(parsed) => parsed,
Err(message) => {
failed.push(message);
continue;
}
};
let save_result = save_imported_design(db, &url, seen_before_in_batch, parsed, &file_name);
match save_result {
Ok((id, is_collision)) => {
imported += 1;
imported_ids.push(id);
if is_collision {
replaced.push(file_name);
}
}
Err(e) => failed.push(format!("{file_name} (save error: {e})")),
}
}
let mut summary = if failed.is_empty() {
format!("Imported {imported} .asc file(s).")
} else {
warn!(
"Import from {}: {} failed: {:?}",
path.display(),
failed.len(),
failed
);
format!(
"Imported {imported} .asc file(s); {} skipped ({}).",
failed.len(),
failed.join("; ")
)
};
if !replaced.is_empty() {
use std::fmt::Write as _;
let _ = write!(
summary,
" {} design(s) replaced an existing entry with the same file name ({}) -- \
filename-only matching, not a content comparison.",
replaced.len(),
replaced.join(", ")
);
}
if gem_gcs_skipped > 0 {
use std::fmt::Write as _;
let _ = write!(
summary,
" {gem_gcs_skipped} .gem/.gcs file(s) were not imported -- {GEM_GCS_EXPLANATION}"
);
}
ImportOutcome {
summary,
imported_ids,
had_failures: !failed.is_empty(),
had_collision: !replaced.is_empty(),
}
}
fn spawn_import(
ui_weak: Weak<MainWindow>,
db: Arc<Mutex<Database>>,
source: Arc<Mutex<LibrarySource>>,
path: PathBuf,
recurse: bool,
) {
thread::spawn(move || {
struct BusyGuard(Weak<MainWindow>);
impl Drop for BusyGuard {
fn drop(&mut self) {
let ui_weak = self.0.clone();
let _ = ui_weak.upgrade_in_event_loop(move |ui| {
ui.global::<LibraryModel>().set_is_busy(false);
});
}
}
let _busy_guard = BusyGuard(ui_weak.clone());
let progress_ui_weak = ui_weak.clone();
let ImportOutcome {
summary: message,
imported_ids,
had_failures,
had_collision,
} = import_path(&db, &path, recurse, move |done, total| {
let _ = progress_ui_weak.upgrade_in_event_loop(move |ui| {
ui.global::<LibraryModel>().set_import_done(done as i32);
ui.global::<LibraryModel>().set_import_total(total as i32);
ui.global::<LibraryModel>()
.set_import_progress(done as f32 / total as f32);
ui.global::<LibraryModel>()
.set_status_message(format!("Importing {done} / {total}...").into());
});
});
let _ = ui_weak.upgrade_in_event_loop(move |ui| {
ui.global::<LibraryModel>()
.set_import_result_text(message.clone().into());
ui.global::<LibraryModel>()
.set_status_message(message.clone().into());
let toast_kind = if had_failures { "error" } else { "success" };
show_toast(&ui, &message, toast_kind);
ui.global::<LibraryModel>()
.set_import_should_stay_open(had_failures || had_collision);
let imported_id_items: Vec<i32> = imported_ids
.iter()
.filter_map(|&id| i32::try_from(id).ok())
.collect();
if imported_id_items.len() > 1 {
ui.global::<LibraryModel>()
.set_recent_import_filter(ModelRc::new(VecModel::from(imported_id_items)));
} else {
ui.global::<LibraryModel>()
.set_recent_import_filter(ModelRc::new(VecModel::from(Vec::<i32>::new())));
}
refresh_after_library_change(&ui, &db, &source);
if let [only_id] = imported_ids.as_slice()
&& let Ok(id) = i32::try_from(*only_id)
{
ui.global::<LibraryModel>().invoke_select_diagram(id);
}
crate::gui::batch::preview::offer_batch_confirmation(&ui, &imported_ids);
});
});
}
pub fn setup_import_callback(
ui: &MainWindow,
db: &Arc<Mutex<Database>>,
source: &Arc<Mutex<LibrarySource>>,
settings_store: &Arc<SettingsPersister>,
) {
let db_file = Arc::clone(db);
let source_file = Arc::clone(source);
let settings_file = Arc::clone(settings_store);
let ui_weak_file = ui.as_weak();
ui.global::<LibraryModel>().on_pick_asc_file(move || {
let Some(ui) = ui_weak_file.upgrade() else {
return;
};
let db_file = Arc::clone(&db_file);
let source_file = Arc::clone(&source_file);
let settings_file = Arc::clone(&settings_file);
let ui_weak_file = ui_weak_file.clone();
crate::gui::pickers::pick(
&ui,
crate::gui::pickers::PickerRequest {
kind: crate::gui::pickers::PickerKind::OpenFile,
title: None,
filters: vec![crate::gui::pickers::PickerFilter {
label: ".asc design".to_string(),
extensions: vec!["asc".to_string()],
}],
default_file_name: None,
starting_dir: last_import_directory(&settings_file),
},
move |ui, path| {
let Some(path) = path else {
return;
};
remember_import_directory(&settings_file, path.parent());
let (collisions, total) = count_pending_collisions(&db_file, &path, false);
let db_file = Arc::clone(&db_file);
let source_file = Arc::clone(&source_file);
confirm_collisions_then(ui, collisions, total, move |ui| {
begin_import(ui, &db_file, &source_file, ui_weak_file, path, false);
});
},
);
});
let db_folder = Arc::clone(db);
let source_folder = Arc::clone(source);
let settings_folder = Arc::clone(settings_store);
let ui_weak_folder = ui.as_weak();
ui.global::<LibraryModel>()
.on_pick_asc_folder(move |recurse: bool| {
let Some(ui) = ui_weak_folder.upgrade() else {
return;
};
let db_folder = Arc::clone(&db_folder);
let source_folder = Arc::clone(&source_folder);
let settings_folder = Arc::clone(&settings_folder);
let ui_weak_folder = ui_weak_folder.clone();
crate::gui::pickers::pick(
&ui,
crate::gui::pickers::PickerRequest {
kind: crate::gui::pickers::PickerKind::PickFolder,
title: None,
filters: Vec::new(),
default_file_name: None,
starting_dir: last_import_directory(&settings_folder),
},
move |ui, path| {
let Some(path) = path else {
return;
};
remember_import_directory(&settings_folder, Some(path.as_path()));
let (collisions, total) = count_pending_collisions(&db_folder, &path, recurse);
let db_folder = Arc::clone(&db_folder);
let source_folder = Arc::clone(&source_folder);
confirm_collisions_then(ui, collisions, total, move |ui| {
begin_import(
ui,
&db_folder,
&source_folder,
ui_weak_folder,
path,
recurse,
);
});
},
);
});
spawn_save_folder_rescan(ui.as_weak(), Arc::clone(db), Arc::clone(settings_store));
}
fn count_pending_collisions(
db: &Arc<Mutex<Database>>,
path: &Path,
recurse: bool,
) -> (usize, usize) {
let (candidates, _gem_gcs_skipped) =
collect_import_candidates(path, recurse).unwrap_or_default();
let db = db.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let collisions = candidates
.iter()
.filter(|p| {
let file_name = p.file_name().map_or_else(
|| "unknown.asc".to_string(),
|n| n.to_string_lossy().into_owned(),
);
db.has_detail_for_entry_url(&format!("local://{file_name}"))
.unwrap_or(false)
})
.count();
(collisions, candidates.len())
}
fn confirm_collisions_then(
ui: &MainWindow,
collisions: usize,
total: usize,
proceed: impl FnOnce(&MainWindow) + 'static,
) {
if !collisions_need_confirmation(collisions) {
proceed(ui);
return;
}
crate::gui::editor::native_io::ask_write_confirm(
ui,
"Replace existing design(s)?",
format!(
"{collisions} of {total} file(s) have the same name as a design already in \
the catalogue and will REPLACE it (filename-only match, not a content \
comparison). Continue?"
),
"Continue",
None,
proceed,
);
}
#[must_use]
const fn collisions_need_confirmation(collisions: usize) -> bool {
collisions > 0
}
fn begin_import(
ui: &MainWindow,
db: &Arc<Mutex<Database>>,
source: &Arc<Mutex<LibrarySource>>,
ui_weak: Weak<MainWindow>,
path: PathBuf,
recurse: bool,
) {
ui.global::<LibraryModel>().set_is_busy(true);
ui.global::<LibraryModel>()
.set_status_message("Importing...".into());
reset_import_progress(ui);
spawn_import(ui_weak, Arc::clone(db), Arc::clone(source), path, recurse);
}
fn last_save_folder(settings_store: &Arc<SettingsPersister>) -> Option<PathBuf> {
settings_store
.snapshot()
.settings
.recent_native_files
.first()
.map(PathBuf::from)
.and_then(|p| p.parent().map(Path::to_path_buf))
}
fn spawn_save_folder_rescan(
ui_weak: Weak<MainWindow>,
db: Arc<Mutex<Database>>,
settings_store: Arc<SettingsPersister>,
) {
thread::spawn(move || {
let Some(folder) = last_save_folder(&settings_store) else {
return;
};
let (collisions, total) = count_pending_collisions(&db, &folder, false);
let new_files = total - collisions;
if new_files == 0 {
return;
}
let _ = ui_weak.upgrade_in_event_loop(move |ui| {
show_toast(
&ui,
&format!(
"{new_files} new .asc file(s) found in your last save folder -- open \
Import > Choose folder to bring {} in.",
if new_files == 1 { "it" } else { "them" }
),
"info",
);
});
});
}
fn last_import_directory(settings_store: &Arc<SettingsPersister>) -> Option<PathBuf> {
let remembered = settings_store.snapshot().settings.last_import_directory;
if remembered.is_empty() {
return None;
}
let path = PathBuf::from(remembered);
path.is_dir().then_some(path)
}
fn remember_import_directory(settings_store: &Arc<SettingsPersister>, directory: Option<&Path>) {
let Some(directory) = directory else {
return;
};
let as_string = directory.to_string_lossy().into_owned();
settings_store.update(|s| s.settings.last_import_directory.clone_from(&as_string));
}
fn reset_import_progress(ui: &MainWindow) {
ui.global::<LibraryModel>().set_import_done(0);
ui.global::<LibraryModel>().set_import_total(0);
ui.global::<LibraryModel>().set_import_progress(0.0);
}
#[cfg(test)]
mod tests;