use indicatrix::geometry::stone_metrics::ExternalProportions;
use indicatrix_cut_core::{
Design, PreformSpec, load_paired,
native::{LEGACY_NATIVE_EXTENSION_SUFFIX, NATIVE_EXTENSION_SUFFIX},
};
use indicatrix_vault::model::file::AttachedFile;
use tracing::warn;
fn default_preform_for_schedule(
schedule: &indicatrix_formats::asc::AscSchedule,
lw_ratio: Option<&str>,
) -> PreformSpec {
let length_over_width = lw_ratio
.and_then(|s| s.trim().parse::<f64>().ok())
.filter(|v| v.is_finite() && *v > 0.0)
.unwrap_or(1.0);
PreformSpec::cylinder_for_schedule(schedule, 3.0, length_over_width, 3.0)
}
pub(in crate::gui::editor) struct LoadedDesign {
pub(in crate::gui::editor) design: Design,
pub(in crate::gui::editor) used_placeholder: bool,
pub(in crate::gui::editor) asc_filename: Option<String>,
pub(in crate::gui::editor) original_asc_text: Option<String>,
}
pub(in crate::gui::editor) fn design_from_asc_text(
file_name: &str,
text: &str,
lw_ratio: Option<&str>,
) -> Result<LoadedDesign, String> {
let schedule = indicatrix_formats::asc::parse_asc(text).map_err(|e| e.to_string())?;
let preform = default_preform_for_schedule(&schedule, lw_ratio);
Ok(LoadedDesign {
design: Design::from_asc_schedule(preform, &schedule),
used_placeholder: false,
asc_filename: Some(file_name.to_string()),
original_asc_text: Some(text.to_string()),
})
}
fn native_sidecar_attachment(files: &[AttachedFile]) -> Option<&AttachedFile> {
files.iter().find(|f| {
let lower = f.name.to_lowercase();
lower.ends_with(NATIVE_EXTENSION_SUFFIX) || lower.ends_with(LEGACY_NATIVE_EXTENSION_SUFFIX)
})
}
fn design_from_asc_and_native(
asc_name: &str,
asc_text: &str,
native_text: &str,
lw_ratio: Option<&str>,
entry_id: i64,
) -> Result<LoadedDesign, String> {
match load_paired(asc_text, native_text, false) {
Ok(result) => Ok(LoadedDesign {
design: result.design,
used_placeholder: false,
asc_filename: Some(asc_name.to_string()),
original_asc_text: Some(asc_text.to_string()),
}),
Err(e) => {
warn!(
"Native sidecar paired with '{asc_name}' on diagram #{entry_id} failed to \
load ({e}); falling back to the plain .asc schedule."
);
design_from_asc_text(asc_name, asc_text, lw_ratio)
}
}
}
pub(in crate::gui::editor) fn design_from_full_record(
full: &indicatrix_vault::model::entry::FullDiagramRecord,
) -> Result<LoadedDesign, String> {
if let Some(attached) = full
.attached_files
.iter()
.find(|f| f.name.to_lowercase().ends_with(".asc"))
{
let text = String::from_utf8_lossy(&attached.content);
let loaded = native_sidecar_attachment(&full.attached_files).map_or_else(
|| design_from_asc_text(&attached.name, &text, full.lw_ratio.as_deref()),
|sidecar| {
let native_text = String::from_utf8_lossy(&sidecar.content);
design_from_asc_and_native(
&attached.name,
&text,
&native_text,
full.lw_ratio.as_deref(),
full.entry_id,
)
},
);
match loaded {
Ok(loaded) => return Ok(loaded),
Err(e) => warn!(
"Attached .asc '{}' on diagram #{} failed to parse ({e}); falling back to the \
angle-table reconstruction.",
attached.name, full.entry_id
),
}
}
let schedule = indicatrix_vault::local::reconstruct_asc_schedule(
&full.title,
full.refractive_index.as_deref(),
full.index_gear.as_deref(),
&full.angle_settings,
)
.ok_or_else(|| "This diagram has no cutting-schedule data to load.".to_string())?;
let preform = default_preform_for_schedule(&schedule, full.lw_ratio.as_deref());
Ok(LoadedDesign {
design: Design::from_asc_schedule(preform, &schedule),
used_placeholder: true,
asc_filename: None,
original_asc_text: None,
})
}
pub(in crate::gui::editor) fn external_proportions_from_full_record(
full: &indicatrix_vault::model::entry::FullDiagramRecord,
) -> Option<ExternalProportions> {
fn parse(value: Option<&String>) -> Option<f64> {
value
.map(String::as_str)?
.trim()
.parse::<f64>()
.ok()
.filter(|v: &f64| v.is_finite() && *v > 0.0)
}
let props = ExternalProportions {
vol_w3: parse(full.volume.as_ref()),
lw: parse(full.lw_ratio.as_ref()),
cw: parse(full.cw_ratio.as_ref()),
pw: parse(full.pw_ratio.as_ref()),
hw: parse(full.hw_ratio.as_ref()),
};
(props.vol_w3.is_some()
|| props.lw.is_some()
|| props.cw.is_some()
|| props.pw.is_some()
|| props.hw.is_some())
.then_some(props)
}
#[cfg(test)]
mod tests {
use super::*;
use indicatrix::geometry::meet_solver::MeetConstraint;
use indicatrix_vault::model::entry::FullDiagramRecord;
fn empty_full_record() -> 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: Vec::new(),
}
}
#[test]
fn design_from_asc_text_parses_a_real_schedule() {
let text = "GemCad 5.0\ng 96 0.0\ny 4 y\nI 1.54\na -41.000000 0.64991234 92 n 1 84\n";
let loaded = design_from_asc_text("design.asc", text, None).unwrap();
assert!(!loaded.used_placeholder);
assert_eq!(loaded.asc_filename.as_deref(), Some("design.asc"));
assert_eq!(loaded.original_asc_text.as_deref(), Some(text));
assert_eq!(loaded.design.tiers.len(), 1);
}
#[test]
fn design_from_asc_text_rejects_unparseable_text() {
assert!(design_from_asc_text("bad.asc", "not a real .asc schedule", None).is_err());
}
#[test]
fn design_from_full_record_prefers_a_real_attached_asc_file() {
let mut full = empty_full_record();
full.attached_files
.push(indicatrix_vault::model::file::AttachedFile {
name: "design.asc".to_string(),
url: String::new(),
content:
b"GemCad 5.0\ng 96 0.0\ny 4 y\nI 1.54\na -41.000000 0.64991234 92 n 1 84\n"
.to_vec(),
});
let loaded = design_from_full_record(&full).unwrap();
assert!(!loaded.used_placeholder);
assert_eq!(loaded.asc_filename.as_deref(), Some("design.asc"));
assert!(loaded.original_asc_text.is_some());
assert_eq!(loaded.design.tiers.len(), 1);
match loaded.design.tiers[0].constraint {
MeetConstraint::ScaleReference(v) => assert!((v - 0.649_912_34).abs() < 1e-9),
ref other => panic!("expected a borrowed ScaleReference anchor, got {other:?}"),
}
}
#[test]
fn native_sidecar_attachment_finds_the_indicatrix_toml_sibling() {
let files = vec![
AttachedFile {
name: "design.asc".to_string(),
url: String::new(),
content: Vec::new(),
},
AttachedFile {
name: "design.indicatrix.toml".to_string(),
url: String::new(),
content: b"native".to_vec(),
},
];
let sidecar = native_sidecar_attachment(&files).expect("sidecar must be found");
assert_eq!(sidecar.name, "design.indicatrix.toml");
}
#[test]
fn native_sidecar_attachment_also_finds_the_legacy_gemcut_toml_sibling() {
let files = vec![AttachedFile {
name: "design.gemcut.toml".to_string(),
url: String::new(),
content: b"native".to_vec(),
}];
assert!(native_sidecar_attachment(&files).is_some());
}
#[test]
fn native_sidecar_attachment_is_none_without_a_sidecar() {
let files = vec![AttachedFile {
name: "design.asc".to_string(),
url: String::new(),
content: Vec::new(),
}];
assert!(native_sidecar_attachment(&files).is_none());
}
#[test]
fn design_from_asc_and_native_falls_back_to_the_plain_asc_when_the_sidecar_does_not_parse() {
let asc_text = "GemCad 5.0\ng 96 0.0\ny 4 y\nI 1.54\na -41.000000 0.64991234 92 n 1 84\n";
let loaded =
design_from_asc_and_native("design.asc", asc_text, "not valid toml [[[", None, 1)
.expect("must fall back to the plain .asc path rather than erroring");
assert!(!loaded.used_placeholder);
assert_eq!(loaded.asc_filename.as_deref(), Some("design.asc"));
assert_eq!(loaded.design.tiers.len(), 1);
}
#[test]
fn design_from_full_record_falls_back_to_the_angle_table_when_no_asc_is_attached() {
let mut full = empty_full_record();
full.angle_settings
.push(indicatrix_vault::model::angle::AngleSetting {
order_index: 0,
facet: "T".to_string(),
angle: "0".to_string(),
index: String::new(),
notes: String::new(),
});
let loaded = design_from_full_record(&full).unwrap();
assert!(loaded.used_placeholder);
assert_eq!(loaded.asc_filename, None);
assert_eq!(loaded.original_asc_text, None);
assert_eq!(loaded.design.tiers.len(), 1);
assert_eq!(
loaded.design.tiers[0].constraint,
MeetConstraint::ScaleReference(0.0)
);
}
#[test]
fn design_from_full_record_errors_with_no_schedule_data_at_all() {
let full = empty_full_record();
assert!(design_from_full_record(&full).is_err());
}
}