use crate::{LibraryModel, MainWindow, bridge::library::source::LibrarySource, gui::show_toast};
use indicatrix_vault::{db::sqlite::Database, local};
use slint::ComponentHandle;
use std::sync::{Arc, Mutex};
fn concave_row_count(full: &indicatrix_vault::model::entry::FullDiagramRecord) -> usize {
full.angle_settings
.iter()
.filter(|row| row.tool.is_some())
.count()
}
fn concave_export_notice(
full: &indicatrix_vault::model::entry::FullDiagramRecord,
) -> Option<String> {
let count = concave_row_count(full);
(count > 0).then(|| {
indicatrix_cut_core::ManufacturabilityWarning::ConcaveTiersOmittedFromExport { count }
.to_string()
})
}
fn split_concave_rows(
full: &indicatrix_vault::model::entry::FullDiagramRecord,
) -> (
Vec<indicatrix_vault::model::angle::AngleSetting>,
Vec<String>,
) {
use indicatrix_cut_core::design::{concave_facet_footnote, concave_tool_footnote};
let mut flat = Vec::with_capacity(full.angle_settings.len());
let mut footnotes = Vec::new();
for row in &full.angle_settings {
if row.tool.is_none() {
flat.push(row.clone());
continue;
}
footnotes.push(concave_facet_footnote(
&row.facet, &row.angle, &row.index, &row.notes,
));
if let Some(line) = &row.tool_line {
footnotes.push(concave_tool_footnote(line));
}
}
(flat, footnotes)
}
fn confirm_concave_loss_then(
ui: &MainWindow,
notice: Option<String>,
proceed: impl FnOnce(&MainWindow) + 'static,
) {
match notice {
None => proceed(ui),
Some(notice) => crate::gui::editor::native_io::ask_write_confirm(
ui,
"Concave tiers are not part of this format",
format!("{notice}\n\nExport anyway?"),
"Export Anyway",
None,
proceed,
),
}
}
pub fn setup_export_asc_callback(
ui: &MainWindow,
db: &Arc<Mutex<Database>>,
source: &Arc<Mutex<LibrarySource>>,
) {
let db_export = Arc::clone(db);
let source_export = Arc::clone(source);
let ui_weak = ui.as_weak();
ui.global::<LibraryModel>().on_export_asc(move || {
let Some(ui) = ui_weak.upgrade() else {
return;
};
if source_export
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_remote()
{
show_toast(
&ui,
"Switch to the local library to export a reconstructed .asc (use the Attachments tab to download a remote design's own files).",
"error",
);
return;
}
let entry_id = ui.global::<LibraryModel>().get_selected_entry_id();
if entry_id < 0 {
show_toast(&ui, "No diagram selected for export.", "error");
return;
}
let full = {
let db = db_export
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
db.get_diagram_full(i64::from(entry_id))
};
let Ok(Some(full)) = full else {
show_toast(&ui, "Diagram detail not found.", "error");
return;
};
let existing_name = full
.attached_files
.iter()
.find(|f| f.name.to_lowercase().ends_with(".asc"))
.map(|f| f.name.clone());
let default_file_name = existing_name.clone().unwrap_or_else(|| {
local::design_attachment_position(full.attached_files.iter().map(|f| f.name.as_str()))
.map_or_else(
|| format!("{}.asc", sanitize_filename(&full.title)),
|(position, _)| {
local::converted_asc_file_name(&full.attached_files[position].name)
},
)
});
let default_dir = std::path::Path::new("exports");
let db_for_pick = Arc::clone(&db_export);
let notice = (existing_name.is_none()
&& local::design_attachment_position(
full.attached_files.iter().map(|f| f.name.as_str()),
)
.is_none())
.then(|| concave_export_notice(&full))
.flatten();
confirm_concave_loss_then(&ui, notice, move |ui| {
crate::gui::pickers::pick(
ui,
crate::gui::pickers::PickerRequest {
kind: crate::gui::pickers::PickerKind::SaveFile,
title: None,
filters: vec![crate::gui::pickers::PickerFilter {
label: ".asc design".to_string(),
extensions: vec!["asc".to_string()],
}],
default_file_name: Some(default_file_name),
starting_dir: default_dir.is_dir().then(|| default_dir.to_path_buf()),
},
move |ui, dest_path| {
finish_export_asc(
ui,
&db_for_pick,
entry_id,
&full,
existing_name,
dest_path,
);
},
);
});
});
setup_export_gcs_callback(ui, db, source);
}
fn setup_export_gcs_callback(
ui: &MainWindow,
db: &Arc<Mutex<Database>>,
source: &Arc<Mutex<LibrarySource>>,
) {
let db_export = Arc::clone(db);
let source_export = Arc::clone(source);
let ui_weak = ui.as_weak();
ui.global::<LibraryModel>().on_export_gcs(move || {
let Some(ui) = ui_weak.upgrade() else {
return;
};
if source_export
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_remote()
{
show_toast(
&ui,
"Switch to the local library to export a .gcs file.",
"error",
);
return;
}
let entry_id = ui.global::<LibraryModel>().get_selected_entry_id();
if entry_id < 0 {
show_toast(&ui, "No diagram selected for export.", "error");
return;
}
let full = db_export
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get_diagram_full(i64::from(entry_id));
let Ok(Some(full)) = full else {
show_toast(&ui, "Diagram detail not found.", "error");
return;
};
let export = match gcs_export_for_record(&full) {
Ok(export) => export,
Err(message) => {
show_toast(&ui, &message, "error");
return;
}
};
let default_dir = std::path::Path::new("exports");
let notice = (!export.is_original)
.then(|| concave_export_notice(&full))
.flatten();
confirm_concave_loss_then(&ui, notice, move |ui| {
crate::gui::pickers::pick(
ui,
crate::gui::pickers::PickerRequest {
kind: crate::gui::pickers::PickerKind::SaveFile,
title: None,
filters: vec![crate::gui::pickers::PickerFilter {
label: "Gem Cut Studio design (.gcs)".to_string(),
extensions: vec!["gcs".to_string()],
}],
default_file_name: Some(export.file_name.clone()),
starting_dir: default_dir.is_dir().then(|| default_dir.to_path_buf()),
},
move |ui, dest_path| finish_export_gcs(ui, &export, dest_path),
);
});
});
}
#[derive(Debug)]
struct GcsExport {
file_name: String,
bytes: Vec<u8>,
is_original: bool,
}
fn gcs_export_for_record(
full: &indicatrix_vault::model::entry::FullDiagramRecord,
) -> Result<GcsExport, String> {
if let Some(original) = full.attached_files.iter().find(|f| {
local::DesignFileKind::from_file_name(&f.name) == Some(local::DesignFileKind::Gcs)
}) {
return Ok(GcsExport {
file_name: original.name.clone(),
bytes: original.content.clone(),
is_original: true,
});
}
let file = design_attachment_as_asc(full).unwrap_or_else(|| {
Err(
"This design has no attached .asc or .gem file, so its mast distances are unknown \
and no .gcs can be built from it. Load it in the editor, solve it, and use Export \
as Gem Cut Studio (.gcs) there."
.to_string(),
)
})?;
let schedule = indicatrix_formats::asc::parse_asc(&file.asc_text).map_err(|e| {
format!(
"The attached '{}' could not be read: {e}",
file.asc_file_name
)
})?;
let gcs = indicatrix_formats::gcs::to_gcs_string(&schedule)
.map_err(|e| format!("Cannot write this design as .gcs: {e}"))?;
Ok(GcsExport {
file_name: std::path::Path::new(&file.asc_file_name)
.with_extension("gcs")
.to_string_lossy()
.into_owned(),
bytes: gcs.into_bytes(),
is_original: false,
})
}
fn design_attachment_as_asc(
full: &indicatrix_vault::model::entry::FullDiagramRecord,
) -> Option<Result<local::DesignFileText, String>> {
let (position, kind) =
local::design_attachment_position(full.attached_files.iter().map(|f| f.name.as_str()))?;
let attached = &full.attached_files[position];
let read = super::catch_file_panic(std::panic::AssertUnwindSafe(|| {
local::design_file_to_asc_text(&attached.name, kind, &attached.content)
}));
Some(match read {
Ok(result) => result.map_err(|e| format!("'{}': {e}", attached.name)),
Err(panic_msg) => Err(format!("'{}': internal error: {panic_msg}", attached.name)),
})
}
fn finish_export_gcs(ui: &MainWindow, export: &GcsExport, dest_path: Option<std::path::PathBuf>) {
let Some(dest_path) = dest_path else {
let msg = "Export cancelled.".to_string();
ui.global::<LibraryModel>()
.set_status_message(msg.clone().into());
show_toast(ui, &msg, "info");
return;
};
if let Some(parent) = dest_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
match std::fs::write(&dest_path, &export.bytes) {
Ok(()) => {
let msg = if export.is_original {
format!(
"Exported the original .gcs file to {}.",
dest_path.display()
)
} else {
format!(
"Exported {} (experimental Gem Cut Studio file -- check it in Gem Cut \
Studio before cutting from it).",
dest_path.display()
)
};
ui.global::<LibraryModel>()
.set_status_message(msg.clone().into());
show_toast(ui, &msg, "success");
}
Err(e) => show_toast(
ui,
&format!("Failed to write {}: {e}", dest_path.display()),
"error",
),
}
}
fn write_converted_asc(
ui: &MainWindow,
converted: Result<local::DesignFileText, String>,
dest_path: &std::path::Path,
) {
let file = match converted {
Ok(file) => file,
Err(e) => {
show_toast(
ui,
&format!("This diagram's design file could not be read: {e}"),
"error",
);
return;
}
};
if let Some(parent) = dest_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
match std::fs::write(dest_path, file.asc_text) {
Ok(()) => {
let msg = format!(
"Exported the cutting instructions converted from this design's .gem/.gcs \
file to {}.",
dest_path.display()
);
ui.global::<LibraryModel>()
.set_status_message(msg.clone().into());
show_toast(ui, &msg, "success");
}
Err(e) => show_toast(
ui,
&format!("Failed to write {}: {e}", dest_path.display()),
"error",
),
}
}
fn finish_export_asc(
ui: &MainWindow,
db: &Arc<Mutex<Database>>,
entry_id: i32,
full: &indicatrix_vault::model::entry::FullDiagramRecord,
existing_name: Option<String>,
dest_path: Option<std::path::PathBuf>,
) {
let Some(dest_path) = dest_path else {
let msg = "Export cancelled.".to_string();
ui.global::<LibraryModel>()
.set_status_message(msg.clone().into());
show_toast(ui, &msg, "info");
return;
};
if let Some(name) = existing_name {
match crate::gui::library::detail::export_diagram_file(
db,
i64::from(entry_id),
&name,
&dest_path,
) {
Ok(msg) => {
ui.global::<LibraryModel>()
.set_status_message(msg.clone().into());
show_toast(ui, &msg, "success");
}
Err(e) => show_toast(ui, &e, "error"),
}
return;
}
if let Some(converted) = design_attachment_as_asc(full) {
write_converted_asc(ui, converted, &dest_path);
return;
}
let (flat_rows, concave_footnotes) = split_concave_rows(full);
let schedule = match local::reconstruct_asc_schedule(
&full.title,
full.refractive_index.as_deref(),
full.index_gear.as_deref(),
&flat_rows,
) {
Ok(Some(mut schedule)) => {
schedule.footnotes.extend(concave_footnotes);
schedule
}
Ok(None) => {
show_toast(
ui,
"No cutting-instructions data to export for this diagram.",
"error",
);
return;
}
Err(e) => {
show_toast(
ui,
&format!("This diagram's cutting-instructions data could not be read: {e}"),
"error",
);
return;
}
};
let text = match indicatrix_formats::asc::to_asc_string(&schedule) {
Ok(text) => text,
Err(e) => {
show_toast(
ui,
&format!("Cannot write this schedule as .asc: {e}"),
"error",
);
return;
}
};
if let Some(parent) = dest_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
match std::fs::write(&dest_path, text) {
Ok(()) => {
let msg = format!(
"Exported reconstructed schedule to {} \
(mast distances are placeholders -- see the file's RECONSTRUCTED header).",
dest_path.display()
);
ui.global::<LibraryModel>()
.set_status_message(msg.clone().into());
show_toast(ui, &msg, "success");
}
Err(e) => show_toast(
ui,
&format!("Failed to write {}: {e}", dest_path.display()),
"error",
),
}
}
pub fn sanitize_filename(title: &str) -> String {
let cleaned: String = title
.chars()
.map(|c| {
if matches!(c, '\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|') {
'_'
} else {
c
}
})
.collect();
let trimmed = cleaned.trim();
if trimmed.is_empty() {
"design".to_string()
} else {
trimmed.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use indicatrix_vault::model::{
angle::AngleSetting, entry::FullDiagramRecord, file::AttachedFile,
};
fn schedule_row(
order_index: u32,
facet: &str,
angle: &str,
index: &str,
notes: &str,
tool: Option<(&str, Option<&str>)>,
) -> AngleSetting {
AngleSetting {
order_index,
facet: facet.to_string(),
angle: angle.to_string(),
index: index.to_string(),
notes: notes.to_string(),
tool: tool.map(|(name, _)| name.to_string()),
tool_line: tool.and_then(|(_, line)| line).map(str::to_string),
}
}
#[test]
fn concave_rows_become_the_footnote_pair_the_editor_export_writes() {
let mut full = record_with(Vec::new());
full.angle_settings = vec![
schedule_row(0, "P1", "41.00", "3", "", None),
schedule_row(
1,
"Groove",
"-62.00",
"3 11.50 19",
"cut to depth",
Some((
"Cylinder",
Some(
"CYL +90.00° X = 0.000, Y = 0.120, Z = 0.050 D/W = 0.250, reciprocating",
),
)),
),
schedule_row(2, "Pit", "40.00", "0", "", Some(("Cone", None))),
schedule_row(3, "Notch", "12.00", "5", "by\r\nhand", Some(("Ball", None))),
];
let (flat, footnotes) = split_concave_rows(&full);
assert_eq!(flat.len(), 1);
assert_eq!(flat[0].facet, "P1");
assert_eq!(
footnotes,
vec![
"Groove -62.00 3 11.50 19 cut to depth [concave tier]".to_string(),
"CYL +90.00 deg X = 0.000, Y = 0.120, Z = 0.050 D/W = 0.250, reciprocating \
[concave tier]"
.to_string(),
"Pit 40.00 0 [concave tier]".to_string(),
"Notch 12.00 5 by hand [concave tier]".to_string(),
]
);
assert!(footnotes.iter().all(|line| line.is_ascii()));
}
#[test]
fn sanitize_filename_replaces_reserved_characters() {
assert_eq!(
sanitize_filename("Round: Trichecker/12 \"Special\""),
"Round_ Trichecker_12 _Special_"
);
}
#[test]
fn sanitize_filename_falls_back_for_empty_or_blank_titles() {
assert_eq!(sanitize_filename(""), "design");
assert_eq!(sanitize_filename(" "), "design");
}
fn record_with(attached_files: Vec<AttachedFile>) -> FullDiagramRecord {
FullDiagramRecord {
entry_id: 1,
title: "Test Design".to_string(),
url: "local://test.asc".to_string(),
design_id: None,
page_url: String::new(),
diagram_image_name: None,
diagram_image_data: None,
competition_diagram: None,
lw_ratio: None,
refractive_index: None,
index_gear: None,
volume: None,
facets_count: None,
shape: None,
designer_info: None,
hw_ratio: None,
tw_ratio: None,
uw_ratio: None,
pw_ratio: None,
cw_ratio: None,
symmetry_order: None,
mirror_symmetry: None,
designer: None,
source_citation: None,
pdf_file: None,
gem_file: None,
shape_category: None,
angle_settings: Vec::new(),
attached_files,
}
}
fn standard_round_brilliant_asc() -> (String, usize) {
let spec = indicatrix_cut_core::templates::TEMPLATES
.first()
.expect("TEMPLATES is non-empty");
assert_eq!(spec.name, "Standard Round Brilliant");
let design = indicatrix_cut_core::design::Design::new(
indicatrix_cut_core::preform::PreformSpec::cylinder(
spec.gear_teeth.unsigned_abs() as usize,
1.5,
1.0,
1.5,
),
spec.schedule_meta(),
spec.tiers(),
);
let schedule = design
.to_asc_schedule()
.expect("the standard round brilliant solves");
let text = indicatrix_formats::asc::to_asc_string(&schedule).expect("writes as .asc");
(text, schedule.tiers.len())
}
#[test]
fn a_windows_1252_asc_attachment_reads_as_text() {
let raw = b"GemCad 5.0\ng 96 0.0\ny 6 y\nI 1.72\nH Caf\xE9 \xB0\na 41.000000 0.5 92 n T\n"
.to_vec();
assert!(std::str::from_utf8(&raw).is_err());
let full = record_with(vec![AttachedFile {
name: "cafe.asc".to_string(),
url: String::new(),
content: raw,
}]);
let file = design_attachment_as_asc(&full)
.expect("the record has a design file")
.expect("a .asc always reads as text");
assert_eq!(file.asc_file_name, "cafe.asc");
assert!(
file.asc_text.contains("Caf\u{e9} \u{b0}"),
"{:?}",
file.asc_text
);
}
#[test]
fn gcs_export_of_the_standard_round_brilliant_record_parses_back() {
let (asc_text, tier_count) = standard_round_brilliant_asc();
let full = record_with(vec![AttachedFile {
name: "round.asc".to_string(),
url: String::new(),
content: asc_text.into_bytes(),
}]);
let export = gcs_export_for_record(&full).expect("a solved design exports");
assert_eq!(export.file_name, "round.gcs");
assert!(!export.is_original);
let parsed =
indicatrix_formats::gcs::parse_gcs_bytes(&export.bytes).expect("the .gcs parses back");
assert_eq!(parsed.tiers.len(), tier_count);
}
#[test]
fn gcs_export_prefers_the_original_gcs_attachment() {
let full = record_with(vec![
AttachedFile {
name: "octabar.asc".to_string(),
url: String::new(),
content: b"GemCad 5.0\n".to_vec(),
},
AttachedFile {
name: "octabar.gcs".to_string(),
url: String::new(),
content: b"<GemCutStudio version=\"1000\"/>".to_vec(),
},
]);
let export = gcs_export_for_record(&full).expect("the original is exported");
assert!(export.is_original);
assert_eq!(export.file_name, "octabar.gcs");
assert_eq!(export.bytes, b"<GemCutStudio version=\"1000\"/>");
}
#[test]
fn gcs_export_refuses_a_design_with_no_attached_asc() {
let err = gcs_export_for_record(&record_with(Vec::new()))
.expect_err("no attached .asc means no masts");
assert!(err.contains("no attached .asc"), "{err}");
}
}