use super::helpers::refresh_after_library_change;
use crate::{
LibraryModel, MainWindow,
bridge::library::source::LibrarySource,
gui::{library::detail::reconstruct_planes, show_toast},
};
use glam::DVec3;
use indicatrix::geometry::{GpuFacetPlane, cuts::FacetSpec, girdle, stone_metrics};
use indicatrix_vault::{
db::sqlite::{DEFAULT_SHAPES, Database},
local,
model::detail::FacetDiagramDetail,
};
use slint::{ComponentHandle, Weak};
use std::{
collections::HashSet,
path::{Path, PathBuf},
sync::{Arc, Mutex},
thread,
};
use tracing::warn;
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);
}
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() {
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)
}
struct ImportOutcome {
summary: String,
imported_ids: Vec<i64>,
}
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(),
};
}
};
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 parse_result = catch_file_panic(std::panic::AssertUnwindSafe(|| {
local::import_asc(&file_name, &content).map(|mut parsed| {
apply_measured_metadata(&mut parsed.detail);
parsed
})
}));
let parsed = match parse_result {
Ok(Ok(parsed)) => parsed,
Ok(Err(e)) => {
failed.push(format!("{file_name} (parse error: {e})"));
continue;
}
Err(panic_msg) => {
warn!("Import panicked while processing '{file_name}': {panic_msg}");
failed.push(format!("{file_name} (internal error: {panic_msg})"));
continue;
}
};
let save_result = {
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);
db.save_diagram_entry(&parsed.entry, local::LOCAL_SOURCE_ID)
.and_then(|id| db.save_diagram_detail(&parsed.detail, id).map(|()| id))
.map(|id| (id, is_collision))
};
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 of the same name ({}) -- \
filename-only dedup, see this app's import report.",
replaced.len(),
replaced.join(", ")
);
}
ImportOutcome {
summary,
imported_ids,
}
}
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,
} = 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 message.starts_with("Imported 0") || !message.contains("Imported") {
"error"
} else {
"success"
};
show_toast(&ui, &message, toast_kind);
refresh_after_library_change(&ui, &db, &source);
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>>,
) {
let db_file = Arc::clone(db);
let source_file = Arc::clone(source);
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 = rfd::FileDialog::new().add_filter(".asc design", &["asc"]);
let Some(path) = dialog.pick_file() else {
return;
};
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 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 = rfd::FileDialog::new();
let Some(path) = dialog.pick_folder() else {
return;
};
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 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;