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>,
) {
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 recurse && p.is_dir() {
collect_asc_files_recursive(&p, recurse, depth + 1, max_depth, visited, out);
}
}
}
fn collect_import_candidates(path: &Path, recurse: bool) -> Result<Vec<PathBuf>, String> {
let mut candidates: Vec<PathBuf> = Vec::new();
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 recurse && p.is_dir() {
collect_asc_files_recursive(
&p,
recurse,
1,
MAX_RECURSE_DEPTH,
&mut visited,
&mut candidates,
);
}
}
}
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()
));
}
candidates.push(path.to_path_buf());
} else {
return Err(format!("'{}' is not a file or folder.", path.display()));
}
if candidates.is_empty() {
return Err(format!("No .asc files found at '{}'.", path.display()));
}
Ok(candidates)
}
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 = 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(", ")
);
}
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 dialog = seed_last_import_directory(
rfd::FileDialog::new().add_filter(".asc design", &["asc"]),
&settings_file,
);
let Some(path) = dialog.pick_file() else {
return;
};
remember_import_directory(&settings_file, path.parent());
ui.global::<LibraryModel>().set_is_busy(true);
ui.global::<LibraryModel>()
.set_status_message("Importing...".into());
reset_import_progress(&ui);
spawn_import(
ui_weak_file.clone(),
Arc::clone(&db_file),
Arc::clone(&source_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 dialog = seed_last_import_directory(rfd::FileDialog::new(), &settings_folder);
let Some(path) = dialog.pick_folder() else {
return;
};
remember_import_directory(&settings_folder, Some(path.as_path()));
ui.global::<LibraryModel>().set_is_busy(true);
ui.global::<LibraryModel>()
.set_status_message("Importing...".into());
reset_import_progress(&ui);
spawn_import(
ui_weak_folder.clone(),
Arc::clone(&db_folder),
Arc::clone(&source_folder),
path,
recurse,
);
});
}
fn seed_last_import_directory(
dialog: rfd::FileDialog,
settings_store: &Arc<SettingsPersister>,
) -> rfd::FileDialog {
let remembered = settings_store.snapshot().settings.last_import_directory;
if remembered.is_empty() {
return dialog;
}
let path = PathBuf::from(remembered);
if path.is_dir() {
dialog.set_directory(path)
} else {
dialog
}
}
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;