use crate::{
AngleItem, DiagramDetailData, FileItem, LibraryModel, MainWindow, TiltModel, ViewportModel,
bridge::{
library::source::{self as library_source, LibrarySource},
render_thread::{PlanesOwner, RenderContext},
},
gui::{
editor::material_lookup::{MATERIAL_MATCH_TOLERANCE, material_for_refractive_index},
library::{
diagram_list::sync_range_bounds_to_ui_preserving_filters,
search::refresh_diagram_list_via_source,
},
render::camera_lighting::resubmit_live_solid,
show_toast,
solid_preview::preview_state::SolidPreviewState,
},
settings::WorkerSettings,
};
use indicatrix::{
geometry::{
cuts::{FacetSpec, StandardGemCuts},
plane::GpuFacetPlane,
},
optics::materials::GemMaterial,
};
use indicatrix_cut_core::material::{BuiltinMaterials, MaterialLookup};
use indicatrix_net::library::{DesignRecord, LibraryRequest, LibraryResponse};
use indicatrix_vault::{
db::sqlite::Database,
model::{entry::FullDiagramRecord, metadata_update::MetadataUpdate},
};
use slint::{ComponentHandle, Model, ModelRc, SharedString, VecModel};
use std::sync::{Arc, Mutex};
use tracing::{error, info};
const GIRDLE_ANGLE_THRESHOLD_DEG: f64 = 89.5;
fn parse_catalogue_angle_deg(angle: &str) -> Option<f64> {
angle.trim().trim_end_matches('\u{b0}').trim().parse().ok()
}
fn sides_from_angle_sequence<'a>(angles: impl Iterator<Item = &'a str>) -> Vec<i32> {
let degrees: Vec<Option<f64>> = angles.map(parse_catalogue_angle_deg).collect();
let girdle_idx = degrees
.iter()
.position(|deg| deg.is_some_and(|d| d >= GIRDLE_ANGLE_THRESHOLD_DEG));
degrees
.iter()
.enumerate()
.map(|(i, deg)| match deg {
None => 0,
Some(d) if *d >= GIRDLE_ANGLE_THRESHOLD_DEG => 0,
Some(_) => {
if girdle_idx.is_some_and(|g| i > g) {
-1
} else {
1
}
}
})
.collect()
}
#[must_use]
fn count_angles_for_mode(angles: &ModelRc<AngleItem>, mode: i32) -> i32 {
let count = angles
.iter()
.filter(|a| match mode {
1 => a.side < 0,
2 => a.side > 0,
_ => true,
})
.count();
i32::try_from(count).unwrap_or(i32::MAX)
}
pub fn setup_filtered_row_count_callback(ui: &MainWindow) {
let ui_weak = ui.as_weak();
ui.global::<LibraryModel>()
.on_filtered_row_count(move |mode: i32| {
let Some(ui) = ui_weak.upgrade() else {
return 0;
};
let angles = ui.global::<LibraryModel>().get_current_angles();
count_angles_for_mode(&angles, mode)
});
}
pub fn load_diagram_detail_via_source(
ui: &MainWindow,
db_mutex: &Arc<Mutex<Database>>,
source: &Arc<Mutex<LibrarySource>>,
render_ctx: &Arc<Mutex<RenderContext>>,
entry_id: i64,
preview_state: &Arc<SolidPreviewState>,
) {
let current = source
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
match current {
LibrarySource::Local => load_diagram_detail(ui, db_mutex, render_ctx, entry_id),
LibrarySource::Remote(worker) => {
load_diagram_detail_remote(ui, worker, render_ctx, entry_id, preview_state);
}
}
}
fn note_derived_from_if_any(ui: &MainWindow, db: &Database, entry_id: i64) {
if let Some((_, title)) = db.get_derived_from_title(entry_id).ok().flatten() {
ui.global::<LibraryModel>()
.set_status_message(format!("Derived from: {title}").into());
}
}
pub fn clear_current_detail_display(ui: &MainWindow) {
ui.global::<LibraryModel>()
.set_current_detail(DiagramDetailData {
id: -1,
title: SharedString::default(),
url: SharedString::default(),
designer: SharedString::default(),
shape: SharedString::default(),
gear: SharedString::default(),
facets: SharedString::default(),
lw_ratio: SharedString::default(),
ri: SharedString::default(),
material_guess: SharedString::default(),
volume: SharedString::default(),
competition: SharedString::default(),
image_name: SharedString::default(),
has_image: false,
is_local: false,
hw_ratio: SharedString::default(),
cw_ratio: SharedString::default(),
pw_ratio: SharedString::default(),
symmetry_order: SharedString::default(),
mirror_symmetry: false,
});
ui.global::<LibraryModel>().set_selected_entry_id(-1);
ui.global::<LibraryModel>()
.set_current_angles(ModelRc::new(VecModel::from(Vec::new())));
ui.global::<LibraryModel>()
.set_current_files(ModelRc::new(VecModel::from(Vec::new())));
}
fn push_shape_picker_and_cached_material(
ui: &MainWindow,
db: &Database,
entry_id: i64,
shape: Option<&str>,
) -> Option<String> {
let (shape_options, shape_index) =
crate::gui::library::local::build_shape_picker_options(db, shape);
ui.global::<LibraryModel>()
.set_shape_picker_options(ModelRc::new(VecModel::from(shape_options)));
ui.global::<LibraryModel>()
.set_shape_picker_current_index(shape_index);
let cached_curve_material = db
.get_preview_images(entry_id)
.ok()
.and_then(|p| p.material);
ui.global::<TiltModel>()
.set_cached_curve_material(cached_curve_material.clone().unwrap_or_default().into());
note_derived_from_if_any(ui, db, entry_id);
cached_curve_material
}
pub fn load_diagram_detail(
ui: &MainWindow,
db_mutex: &Arc<Mutex<Database>>,
render_ctx: &Arc<Mutex<RenderContext>>,
entry_id: i64,
) {
let db = match db_mutex.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
match db.get_diagram_full(entry_id) {
Ok(Some(full)) => {
let is_local = full.url.starts_with("local://");
let ri_text = full.refractive_index.clone();
let catalogue_planes = resolve_catalogue_planes_for_entry(&full);
let detail_data = DiagramDetailData {
id: full.entry_id as i32,
title: full.title.into(),
url: full.url.into(),
designer: full.designer_info.clone().unwrap_or_default().into(),
shape: full.shape.clone().unwrap_or_default().into(),
gear: full.index_gear.clone().unwrap_or_default().into(),
facets: full.facets_count.unwrap_or_default().into(),
lw_ratio: format_optional_proportion(full.lw_ratio.as_deref()).into(),
material_guess: catalogue_material_guess(full.refractive_index.as_deref()).into(),
ri: full.refractive_index.unwrap_or_default().into(),
volume: format_optional_proportion(full.volume.as_deref()).into(),
competition: full.competition_diagram.unwrap_or_default().into(),
image_name: full.diagram_image_name.unwrap_or_default().into(),
has_image: full.diagram_image_data.is_some(),
is_local,
hw_ratio: format_optional_proportion(full.hw_ratio.as_deref()).into(),
cw_ratio: format_optional_proportion(full.cw_ratio.as_deref()).into(),
pw_ratio: format_optional_proportion(full.pw_ratio.as_deref()).into(),
symmetry_order: full.symmetry_order.unwrap_or_default().into(),
mirror_symmetry: full.mirror_symmetry.unwrap_or(false),
};
ui.global::<LibraryModel>().set_current_detail(detail_data);
let sides =
sides_from_angle_sequence(full.angle_settings.iter().map(|a| a.angle.as_str()));
let angle_items: Vec<AngleItem> = full
.angle_settings
.into_iter()
.zip(sides)
.map(|(a, side)| AngleItem {
order_idx: a.order_index as i32,
side,
facet: a.facet.into(),
angle: a.angle.into(),
index_val: a.index.into(),
notes: a.notes.into(),
})
.collect();
ui.global::<LibraryModel>()
.set_current_angles(ModelRc::new(VecModel::from(angle_items.clone())));
let file_items: Vec<FileItem> = full
.attached_files
.into_iter()
.map(|f| {
let size_kb = f.content.len() as f64 / 1024.0;
FileItem {
name: f.name.into(),
url: f.url.into(),
size_str: format!("{size_kb:.1} KB").into(),
}
})
.collect();
ui.global::<LibraryModel>()
.set_current_files(ModelRc::new(VecModel::from(file_items)));
ui.global::<LibraryModel>()
.set_selected_entry_id(entry_id as i32);
let cached_curve_material =
push_shape_picker_and_cached_material(ui, &db, entry_id, full.shape.as_deref());
drop(db);
apply_reconstructed_planes(
ui,
render_ctx,
entry_id,
ReconstructedPlanesInput {
shape: full.shape.as_deref(),
index_gear: full.index_gear.as_deref(),
angle_items: &angle_items,
refractive_index: ri_text.as_deref(),
preview_material: cached_curve_material.as_deref(),
real_design: catalogue_planes,
},
);
}
Ok(None) => {
ui.global::<LibraryModel>()
.set_status_message("Diagram detail not found.".into());
}
Err(e) => {
error!("Failed to fetch diagram full detail: {:?}", e);
ui.global::<LibraryModel>()
.set_status_message(format!("Error loading detail: {e}").into());
}
}
}
fn load_diagram_detail_remote(
ui: &MainWindow,
worker: WorkerSettings,
render_ctx: &Arc<Mutex<RenderContext>>,
entry_id: i64,
preview_state: &Arc<SolidPreviewState>,
) {
let render_ctx = render_ctx.clone();
let preview_state = Arc::clone(preview_state);
library_source::spawn_library_request(
ui.as_weak(),
worker,
LibraryRequest::FetchDesign { entry_id },
move |ui, result| match result {
Ok(LibraryResponse::Design(record)) => {
apply_design_record_to_ui(ui, &render_ctx, &record, &preview_state);
}
Ok(LibraryResponse::NotFound) => {
ui.global::<LibraryModel>()
.set_status_message("Diagram detail not found on the remote library.".into());
}
Ok(_) => {
ui.global::<LibraryModel>()
.set_status_message("Unexpected reply fetching remote diagram detail.".into());
}
Err(e) => {
error!("Remote FetchDesign failed: {e}");
ui.global::<LibraryModel>()
.set_status_message(format!("Error loading remote detail: {e}").into());
}
},
);
}
fn catalogue_material_guess(ri_text: Option<&str>) -> String {
let Some(n_d) = ri_text.and_then(|s| s.trim().parse::<f64>().ok()) else {
return String::new();
};
material_for_refractive_index(n_d).map_or_else(String::new, |(name, _)| {
format!("{name}? (from RI {n_d:.2})")
})
}
fn apply_design_record_to_ui(
ui: &MainWindow,
render_ctx: &Arc<Mutex<RenderContext>>,
record: &DesignRecord,
preview_state: &Arc<SolidPreviewState>,
) {
let is_local = record.url.starts_with("local://");
let detail_data = DiagramDetailData {
id: record.entry_id as i32,
title: record.title.clone().into(),
url: record.url.clone().into(),
designer: record.designer_info.clone().unwrap_or_default().into(),
shape: record.shape.clone().unwrap_or_default().into(),
gear: record.index_gear.clone().unwrap_or_default().into(),
facets: record.facets_count.clone().unwrap_or_default().into(),
lw_ratio: format_optional_proportion(record.lw_ratio.as_deref()).into(),
material_guess: catalogue_material_guess(record.refractive_index.as_deref()).into(),
ri: record.refractive_index.clone().unwrap_or_default().into(),
volume: format_optional_proportion(record.volume.as_deref()).into(),
competition: record
.competition_diagram
.clone()
.unwrap_or_default()
.into(),
image_name: record.diagram_image_name.clone().unwrap_or_default().into(),
has_image: record.diagram_image_data.is_some(),
is_local,
hw_ratio: format_optional_proportion(record.hw_ratio.as_deref()).into(),
cw_ratio: format_optional_proportion(record.cw_ratio.as_deref()).into(),
pw_ratio: format_optional_proportion(record.pw_ratio.as_deref()).into(),
symmetry_order: record.symmetry_order.clone().unwrap_or_default().into(),
mirror_symmetry: record.mirror_symmetry.unwrap_or(false),
};
ui.global::<LibraryModel>().set_current_detail(detail_data);
ui.global::<TiltModel>()
.set_cached_curve_material(record.preview_material.clone().unwrap_or_default().into());
let sides = sides_from_angle_sequence(record.angle_settings.iter().map(|a| a.angle.as_str()));
let angle_items: Vec<AngleItem> = record
.angle_settings
.iter()
.zip(sides)
.map(|(a, side)| AngleItem {
order_idx: a.order_index as i32,
side,
facet: a.facet.clone().into(),
angle: a.angle.clone().into(),
index_val: a.index.clone().into(),
notes: a.notes.clone().into(),
})
.collect();
ui.global::<LibraryModel>()
.set_current_angles(ModelRc::new(VecModel::from(angle_items.clone())));
let file_items: Vec<FileItem> = record
.attachments
.iter()
.map(|f| {
let size_kb = f.size as f64 / 1024.0;
FileItem {
name: f.name.clone().into(),
url: f.url.clone().into(),
size_str: format!("{size_kb:.1} KB").into(),
}
})
.collect();
ui.global::<LibraryModel>()
.set_current_files(ModelRc::new(VecModel::from(file_items)));
ui.global::<LibraryModel>()
.set_selected_entry_id(record.entry_id as i32);
apply_reconstructed_planes(
ui,
render_ctx,
record.entry_id,
ReconstructedPlanesInput {
shape: record.shape.as_deref(),
index_gear: record.index_gear.as_deref(),
angle_items: &angle_items,
refractive_index: record.refractive_index.as_deref(),
preview_material: record.preview_material.as_deref(),
real_design: None,
},
);
if ui.get_render_view_tab() == 0 && ui.global::<ViewportModel>().get_live_view_mode() == 0 {
let ctx = render_ctx
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
resubmit_live_solid(ui, &ctx, preview_state);
}
}
fn format_proportion(raw: &str) -> String {
match raw.trim().parse::<f64>() {
Ok(n) if n.is_finite() => format!("{n:.3}"),
_ => raw.to_string(),
}
}
fn format_optional_proportion(raw: Option<&str>) -> String {
raw.map(format_proportion).unwrap_or_default()
}
fn non_empty(text: &str) -> Option<String> {
let trimmed = text.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
pub fn setup_save_metadata_callback(
ui: &MainWindow,
db_mutex: &Arc<Mutex<Database>>,
source: &Arc<Mutex<LibrarySource>>,
render_ctx: &Arc<Mutex<RenderContext>>,
) {
let db_meta = Arc::clone(db_mutex);
let source_meta = Arc::clone(source);
let render_ctx_meta = Arc::clone(render_ctx);
let ui_weak = ui.as_weak();
ui.global::<LibraryModel>().on_save_metadata(
move |title: SharedString,
designer: SharedString,
shape: SharedString,
refractive_index: SharedString,
index_gear: SharedString,
facets_count: SharedString,
symmetry_order: SharedString,
mirror_symmetry: bool,
lw_ratio: SharedString,
hw_ratio: SharedString,
cw_ratio: SharedString,
pw_ratio: SharedString,
volume: SharedString| {
let Some(ui) = ui_weak.upgrade() else {
return;
};
if source_meta
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_remote()
{
show_toast(
&ui,
"Switch to the local library to edit a design's metadata.",
"error",
);
return;
}
let entry_id = ui.global::<LibraryModel>().get_selected_entry_id();
if entry_id < 0 {
return;
}
let entry_id = i64::from(entry_id);
let result = {
let db = db_meta
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
db.rename_diagram_entry(entry_id, &title).and_then(|()| {
let update = MetadataUpdate {
designer_info: non_empty(&designer),
shape: non_empty(&shape),
refractive_index: non_empty(&refractive_index),
index_gear: non_empty(&index_gear),
facets_count: non_empty(&facets_count),
symmetry_order: non_empty(&symmetry_order),
mirror_symmetry: Some(mirror_symmetry),
lw_ratio: non_empty(&lw_ratio),
hw_ratio: non_empty(&hw_ratio),
cw_ratio: non_empty(&cw_ratio),
pw_ratio: non_empty(&pw_ratio),
volume: non_empty(&volume),
};
db.update_diagram_metadata(entry_id, &update)
})
};
match result {
Ok(()) => {
show_toast(&ui, "Metadata updated.", "success");
load_diagram_detail(&ui, &db_meta, &render_ctx_meta, entry_id);
sync_range_bounds_to_ui_preserving_filters(&ui, &db_meta);
let search = ui.global::<LibraryModel>().get_search_text();
let shape_idx = ui.global::<LibraryModel>().get_selected_shape_index() as usize;
let shape_filter = ui
.global::<LibraryModel>()
.get_shape_options()
.row_data(shape_idx)
.unwrap_or_default();
let gear_idx = ui.global::<LibraryModel>().get_selected_gear_index() as usize;
let gear_filter = ui
.global::<LibraryModel>()
.get_gear_options()
.row_data(gear_idx)
.unwrap_or_default();
refresh_diagram_list_via_source(
&ui,
&db_meta,
&source_meta,
&search,
&shape_filter,
&gear_filter,
);
}
Err(e) => show_toast(&ui, &format!("Metadata update failed: {e}"), "error"),
}
},
);
}
fn resolve_catalogue_planes_for_entry(
full: &FullDiagramRecord,
) -> Option<(Vec<GpuFacetPlane>, u32, f32)> {
crate::gui::editor::resolve_catalogue_planes(full)
.ok()
.flatten()
}
fn planes_gear_and_reference_angle(
real_design: Option<(Vec<GpuFacetPlane>, u32, f32)>,
shape: Option<&str>,
index_gear: Option<&str>,
angle_items: &[AngleItem],
) -> (Vec<GpuFacetPlane>, u32, f32) {
if let Some(resolved) = real_design {
return resolved;
}
let facet_specs: Vec<FacetSpec> = angle_items
.iter()
.map(|a| FacetSpec {
facet: a.facet.to_string(),
angle: a.angle.to_string(),
index: a.index_val.to_string(),
notes: a.notes.to_string(),
})
.collect();
let planes = reconstruct_planes(shape, index_gear, &facet_specs);
let gear_teeth: u32 = index_gear
.and_then(|g| g.trim().parse().ok())
.filter(|&g| g > 0)
.unwrap_or(96);
(planes, gear_teeth, 0.0)
}
struct ReconstructedPlanesInput<'a> {
shape: Option<&'a str>,
index_gear: Option<&'a str>,
angle_items: &'a [AngleItem],
refractive_index: Option<&'a str>,
preview_material: Option<&'a str>,
real_design: Option<(Vec<GpuFacetPlane>, u32, f32)>,
}
fn apply_reconstructed_planes(
ui: &MainWindow,
render_ctx: &Arc<Mutex<RenderContext>>,
entry_id: i64,
input: ReconstructedPlanesInput<'_>,
) {
let ReconstructedPlanesInput {
shape,
index_gear,
angle_items,
refractive_index,
preview_material,
real_design,
} = input;
let (planes, gear_teeth, gear_reference_angle) =
planes_gear_and_reference_angle(real_design, shape, index_gear, angle_items);
info!(
"Reconstructed {} 3D facet planes for diagram #{}",
planes.len(),
entry_id
);
let mut ctx = RenderContext::lock(render_ctx);
let claimed = ctx.claim_active_planes(
std::sync::Arc::new(planes),
Some((gear_teeth, gear_reference_angle)),
PlanesOwner::Catalogue { entry_id },
);
let resolved_material = if claimed {
let resolved = apply_catalogue_material(&mut ctx, refractive_index, preview_material);
ctx.dirty = true;
resolved
} else {
None
};
drop(ctx);
if let Some(name) = resolved_material {
let options = ui.global::<ViewportModel>().get_material_options();
if let Some(index) = crate::gui::startup_settings::find_option_index(&options, &name) {
ui.global::<ViewportModel>()
.set_selected_material_index(index);
}
}
if !claimed {
show_toast(
ui,
"The 3D view is showing the design you are editing -- it was left alone. \
Switch to the Library tab's own view to preview this row.",
"info",
);
return;
}
if ui.global::<TiltModel>().get_dialog_open() {
ui.global::<TiltModel>().invoke_request_tilt_profile_axes();
}
}
fn lookup_named_material(ctx: &RenderContext, name: &str) -> Option<GemMaterial> {
ctx.custom_materials
.iter()
.find(|m| m.name.eq_ignore_ascii_case(name))
.cloned()
.or_else(|| BuiltinMaterials.lookup(name))
}
fn apply_catalogue_material(
ctx: &mut RenderContext,
refractive_index: Option<&str>,
preview_material: Option<&str>,
) -> Option<String> {
if let Some(name) = preview_material.map(str::trim).filter(|s| !s.is_empty())
&& let Some(gem) = lookup_named_material(ctx, name)
{
ctx.material_unresolved = None;
ctx.material_name = name.to_string();
ctx.material_override = Some(gem);
return Some(name.to_string());
}
let n_d = refractive_index.and_then(|text| text.trim().parse::<f64>().ok());
let Some(n_d) = n_d.filter(|v| v.is_finite() && *v > 1.0) else {
ctx.material_unresolved = Some(
"This design records no usable refractive index, so there is no honest \
material to render it in. Pick one in the Render Material dropdown above."
.to_string(),
);
return None;
};
let Some((name, gem)) = material_for_refractive_index(n_d) else {
ctx.material_unresolved = Some(format!(
"This design's refractive index ({n_d:.4}) matches no built-in preset within \
{MATERIAL_MATCH_TOLERANCE:.2}. Pick a material in the Render Material dropdown \
above -- rendering it as something else would give you the optics of a \
different stone."
));
return None;
};
ctx.material_unresolved = None;
ctx.material_name.clone_from(&name);
ctx.material_override = Some(gem);
Some(name)
}
pub fn reconstruct_planes(
shape: Option<&str>,
index_gear: Option<&str>,
facet_specs: &[FacetSpec],
) -> Vec<GpuFacetPlane> {
let shape_str = shape.unwrap_or_default().to_lowercase();
let gear_num: u32 = index_gear.unwrap_or_default().parse().unwrap_or(96);
if shape_str.contains("emerald") || shape_str.contains("baguette") || shape_str.contains("rect")
{
StandardGemCuts::emerald_cut()
} else if !facet_specs.is_empty() {
StandardGemCuts::from_database_angles(facet_specs, gear_num)
} else {
StandardGemCuts::standard_round_brilliant()
}
}
pub fn export_diagram_file(
db_mutex: &Arc<Mutex<Database>>,
entry_id: i64,
file_name: &str,
dest_path: &std::path::Path,
) -> Result<String, String> {
let full_result = {
let db = match db_mutex.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
db.get_diagram_full(entry_id)
};
if let Ok(Some(full)) = full_result
&& let Some(f) = full.attached_files.iter().find(|af| af.name == file_name)
{
if let Some(parent) = dest_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if std::fs::write(dest_path, &f.content).is_ok() {
return Ok(format!("Saved '{}' to {}", f.name, dest_path.display()));
}
}
Err(format!("Failed to export file '{file_name}'."))
}
pub fn export_diagram_file_via_source(
ui: &MainWindow,
db_mutex: &Arc<Mutex<Database>>,
source: &Arc<Mutex<LibrarySource>>,
entry_id: i64,
file_name: &str,
) {
let default_dir = std::path::Path::new("exports");
let extension_filter = std::path::Path::new(file_name)
.extension()
.and_then(|e| e.to_str())
.map(str::to_string);
let db_mutex = Arc::clone(db_mutex);
let source = Arc::clone(source);
let entry_id_owned = entry_id;
let file_name_owned = file_name.to_string();
crate::gui::pickers::pick(
ui,
crate::gui::pickers::PickerRequest {
kind: crate::gui::pickers::PickerKind::SaveFile,
title: None,
filters: extension_filter
.map(|ext| vec![crate::gui::pickers::PickerFilter::single(ext)])
.unwrap_or_default(),
default_file_name: Some(file_name_owned.clone()),
starting_dir: default_dir.is_dir().then(|| default_dir.to_path_buf()),
},
move |ui, dest_path| {
let Some(dest_path) = dest_path else {
let msg = "Export cancelled.".to_string();
ui.global::<LibraryModel>()
.set_status_message(msg.clone().into());
crate::gui::show_toast(ui, &msg, "info");
return;
};
let current = source
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
match current {
LibrarySource::Local => {
match export_diagram_file(
&db_mutex,
entry_id_owned,
&file_name_owned,
&dest_path,
) {
Ok(msg) => {
ui.global::<LibraryModel>()
.set_status_message(msg.clone().into());
crate::gui::show_toast(ui, &msg, "success");
}
Err(err) => {
ui.global::<LibraryModel>()
.set_status_message(err.clone().into());
crate::gui::show_toast(ui, &err, "error");
}
}
}
LibrarySource::Remote(worker) => {
export_diagram_file_remote(
ui,
worker,
entry_id_owned,
&file_name_owned,
&dest_path,
);
}
}
},
);
}
fn export_diagram_file_remote(
ui: &MainWindow,
worker: WorkerSettings,
entry_id: i64,
file_name: &str,
dest_path: &std::path::Path,
) {
let file_name = file_name.to_string();
let dest_path = dest_path.to_path_buf();
let worker_for_attachment = worker.clone();
library_source::spawn_library_request(
ui.as_weak(),
worker,
LibraryRequest::FetchDesign { entry_id },
move |ui, result| {
let attachment_id = match result {
Ok(LibraryResponse::Design(record)) => record
.attachments
.iter()
.find(|f| f.name == file_name)
.map(|f| f.id),
_ => None,
};
let Some(attachment_id) = attachment_id else {
report_export_failure(ui, &file_name);
return;
};
let file_name_for_failure = file_name.clone();
let dest_path_for_attachment = dest_path.clone();
library_source::spawn_library_request(
ui.as_weak(),
worker_for_attachment,
LibraryRequest::FetchAttachment { attachment_id },
move |ui, result| match result {
Ok(LibraryResponse::Attachment { name, content }) => {
if let Some(parent) = dest_path_for_attachment.parent() {
let _ = std::fs::create_dir_all(parent);
}
if std::fs::write(&dest_path_for_attachment, &content).is_ok() {
let msg =
format!("Saved '{name}' to {}", dest_path_for_attachment.display());
ui.global::<LibraryModel>()
.set_status_message(msg.clone().into());
crate::gui::show_toast(ui, &msg, "success");
} else {
report_export_failure(ui, &file_name_for_failure);
}
}
_ => report_export_failure(ui, &file_name_for_failure),
},
);
},
);
}
fn report_export_failure(ui: &MainWindow, file_name: &str) {
let msg = format!("Failed to export file '{file_name}'.");
ui.global::<LibraryModel>()
.set_status_message(msg.clone().into());
crate::gui::show_toast(ui, &msg, "error");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_catalogue_angle_deg_strips_the_degree_sign() {
assert_eq!(parse_catalogue_angle_deg("42.70\u{b0}"), Some(42.70));
}
#[test]
fn parse_catalogue_angle_deg_also_accepts_plain_text_with_no_degree_sign() {
assert_eq!(parse_catalogue_angle_deg("42.70"), Some(42.70));
}
#[test]
fn parse_catalogue_angle_deg_is_none_for_unparsable_text() {
assert_eq!(parse_catalogue_angle_deg("not a number"), None);
}
#[test]
fn sides_from_angle_sequence_classifies_a_girdle_ordered_schedule() {
let angles = [
"34.50\u{b0}",
"42.70\u{b0}",
"90.00\u{b0}",
"41.00\u{b0}",
"43.10\u{b0}",
];
let sides = sides_from_angle_sequence(angles.iter().copied());
assert_eq!(sides, vec![1, 1, 0, -1, -1]);
}
#[test]
fn sides_from_angle_sequence_reports_zero_for_every_girdle_row_not_only_the_first() {
let angles = ["40.00\u{b0}", "89.80\u{b0}", "90.00\u{b0}", "41.00\u{b0}"];
let sides = sides_from_angle_sequence(angles.iter().copied());
assert_eq!(sides, vec![1, 0, 0, -1]);
}
#[test]
fn sides_from_angle_sequence_reports_zero_for_unparsable_text() {
let angles = ["not a number", "41.00\u{b0}"];
let sides = sides_from_angle_sequence(angles.iter().copied());
assert_eq!(sides[0], 0);
}
#[test]
fn sides_from_angle_sequence_treats_every_row_as_crown_with_no_girdle_marker() {
let angles = ["34.50\u{b0}", "42.70\u{b0}"];
let sides = sides_from_angle_sequence(angles.iter().copied());
assert_eq!(sides, vec![1, 1]);
}
#[test]
fn falls_back_to_the_placeholder_reconstruction_with_no_real_design() {
let angle_items = [AngleItem {
order_idx: 0,
side: -1,
facet: "P1".into(),
angle: "-41.0".into(),
index_val: "0, 24, 48, 72".into(),
notes: "".into(),
}];
let (planes, gear_teeth, reference_angle) =
planes_gear_and_reference_angle(None, Some("Round"), Some("96"), &angle_items);
assert_ne!(planes.len(), 0);
assert_eq!(gear_teeth, 96);
assert_eq!(reference_angle, 0.0);
}
#[test]
fn prefers_the_real_designs_own_planes_and_reference_angle_when_resolved() {
let resolved_planes = vec![GpuFacetPlane::new(glam::Vec3::Y, -1.0)];
let (planes, gear_teeth, reference_angle) = planes_gear_and_reference_angle(
Some((resolved_planes.clone(), 12, 2.5)),
Some("Round"),
Some("999"),
&[],
);
assert_eq!(planes.len(), resolved_planes.len());
assert_eq!(gear_teeth, 12);
assert_eq!(reference_angle, 2.5);
}
fn angle_item(side: i32) -> AngleItem {
AngleItem {
order_idx: 0,
side,
facet: "P1".into(),
angle: "0.0".into(),
index_val: "".into(),
notes: "".into(),
}
}
#[test]
fn mode_all_counts_every_row() {
let angles = ModelRc::new(VecModel::from(vec![
angle_item(-1),
angle_item(1),
angle_item(0),
]));
assert_eq!(count_angles_for_mode(&angles, 0), 3);
}
#[test]
fn mode_pavilion_counts_only_negative_side_rows() {
let angles = ModelRc::new(VecModel::from(vec![
angle_item(-1),
angle_item(-1),
angle_item(1),
angle_item(0),
]));
assert_eq!(count_angles_for_mode(&angles, 1), 2);
}
#[test]
fn mode_crown_counts_only_positive_side_rows() {
let angles = ModelRc::new(VecModel::from(vec![
angle_item(-1),
angle_item(1),
angle_item(1),
angle_item(0),
]));
assert_eq!(count_angles_for_mode(&angles, 2), 2);
}
#[test]
fn empty_angle_list_counts_to_zero_under_every_mode() {
let angles: ModelRc<AngleItem> = ModelRc::new(VecModel::from(Vec::<AngleItem>::new()));
for mode in [0, 1, 2] {
assert_eq!(count_angles_for_mode(&angles, mode), 0);
}
}
}