use super::state::material_name_from_index;
use indicatrix::geometry::{meet_solver::MeetConstraint, stone_metrics::ExternalProportions};
use indicatrix_cut_core::{
ConstraintTier, Design, FreshDesignSpec, MaterialSelection, 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(super) struct LoadedDesign {
pub(super) design: Design,
pub(super) used_placeholder: bool,
pub(super) asc_filename: Option<String>,
pub(super) original_asc_text: Option<String>,
}
pub(super) 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(super) 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(super) 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)
}
pub(super) fn parse_angle_only(angle: &str) -> Result<f64, String> {
let angle_deg: f64 = angle
.trim()
.parse()
.map_err(|_| format!("Angle '{}' is not a number.", angle.trim()))?;
if !angle_deg.is_finite() {
return Err("Angle must be a finite number.".to_string());
}
reject_angle_over_90(angle_deg)?;
Ok(angle_deg)
}
fn parse_colon_sequence(token: &str, gear_teeth_abs_f64: f64) -> Option<Vec<f64>> {
let parts: Vec<&str> = token.split(':').collect();
let [start, step, stop] = parts.as_slice() else {
return None;
};
let start: f64 = start.trim().parse().ok()?;
let step: f64 = step.trim().parse().ok()?;
let stop: f64 = stop.trim().parse().ok()?;
if step == 0.0 || !(start.is_finite() && step.is_finite() && stop.is_finite()) {
return None;
}
let mut values = Vec::new();
let mut current = start;
for _ in 0..10_000 {
if (step > 0.0 && current >= stop) || (step < 0.0 && current <= stop) {
break;
}
values.push(if gear_teeth_abs_f64 > 0.0 {
current.rem_euclid(gear_teeth_abs_f64)
} else {
current
});
current += step;
}
Some(values)
}
fn parse_orbit_suffix(suffix: &str) -> Option<u32> {
let digits = suffix.strip_prefix(['x', 'X'])?;
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
digits.parse::<u32>().ok().filter(|&n| n > 0)
}
fn expand_orbit_shorthand(base: f64, fold_count: u32, gear_teeth_abs_f64: f64) -> Vec<f64> {
if gear_teeth_abs_f64 <= 0.0 {
return vec![base];
}
let step = gear_teeth_abs_f64 / f64::from(fold_count);
(0..fold_count)
.map(|k| f64::mul_add(f64::from(k), step, base).rem_euclid(gear_teeth_abs_f64))
.collect()
}
fn reject_angle_over_90(angle_deg: f64) -> Result<(), String> {
if angle_deg.abs() > 90.0 {
return Err(format!(
"Angle {angle_deg:.2}\u{b0} exceeds 90\u{b0} -- angles are measured from the girdle \
plane, so no crown or pavilion facet can be steeper than that."
));
}
Ok(())
}
pub(super) struct TierFormFields<'a> {
pub(super) angle: &'a str,
pub(super) constraint_kind: i32,
pub(super) constraint_text: &'a str,
pub(super) name: &'a str,
pub(super) indices: &'a str,
pub(super) gear_teeth_abs: u32,
pub(super) imported_meet: Option<MeetConstraint>,
pub(super) original_notes: Option<String>,
pub(super) other_tier_names: Vec<String>,
}
fn parse_index_list(indices: &str, gear_teeth_abs: u32) -> Result<Vec<f64>, String> {
let gear_teeth_abs_f64 = f64::from(gear_teeth_abs);
let mut parsed_indices: Vec<f64> = Vec::new();
let push_index =
|value: f64, label: &str, parsed_indices: &mut Vec<f64>| -> Result<(), String> {
if !value.is_finite() {
return Err(format!("Index '{label}' must be a finite number."));
}
if value < 0.0 || value >= gear_teeth_abs_f64 {
return Err(format!(
"Index '{label}' is outside this design's {gear_teeth_abs}-tooth gear."
));
}
if parsed_indices.contains(&value) {
return Err(format!("Index '{label}' is listed more than once."));
}
parsed_indices.push(value);
Ok(())
};
let raw_tokens: Vec<&str> = indices
.split([',', ' ', ';'])
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
let mut i = 0;
while i < raw_tokens.len() {
let token = raw_tokens[i];
if let Some(values) = parse_colon_sequence(token, gear_teeth_abs_f64) {
for value in values {
push_index(
value,
&format!("{value} (from '{token}')"),
&mut parsed_indices,
)?;
}
i += 1;
continue;
}
if let Ok(base) = token.parse::<f64>()
&& let Some(next) = raw_tokens.get(i + 1)
&& let Some(fold_count) = parse_orbit_suffix(next)
{
let shorthand = format!("{token} {next}");
for value in expand_orbit_shorthand(base, fold_count, gear_teeth_abs_f64) {
push_index(
value,
&format!("{value} (from '{shorthand}')"),
&mut parsed_indices,
)?;
}
i += 2;
continue;
}
let value: f64 = token
.parse()
.map_err(|_| format!("Index '{token}' is not a number."))?;
push_index(value, token, &mut parsed_indices)?;
i += 1;
}
Ok(parsed_indices)
}
pub(super) fn parse_tier_form(form: TierFormFields<'_>) -> Result<ConstraintTier, String> {
let TierFormFields {
angle,
constraint_kind,
constraint_text,
name,
indices,
gear_teeth_abs,
imported_meet,
original_notes,
other_tier_names,
} = form;
let angle_deg: f64 = angle
.trim()
.parse()
.map_err(|_| format!("Angle '{}' is not a number.", angle.trim()))?;
if !angle_deg.is_finite() {
return Err("Angle must be a finite number.".to_string());
}
reject_angle_over_90(angle_deg)?;
let name = name.trim();
for token in name.split('/').map(str::trim).filter(|s| !s.is_empty()) {
if let Some(existing) = other_tier_names
.iter()
.find(|other| other.eq_ignore_ascii_case(token))
{
return Err(format!(
"Another tier is already named '{existing}' -- facet names must be unique so \
meet constraints resolve to the right tier."
));
}
}
let parsed_indices = parse_index_list(indices, gear_teeth_abs)?;
let constraint = match constraint_kind {
0 => MeetConstraint::MeetExisting,
1 => {
let names: Vec<String> = constraint_text
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
if names.is_empty() {
return Err("\"Meet named\" needs at least one facet name.".to_string());
}
MeetConstraint::MeetNamed(names)
}
2 => {
let value: f64 = constraint_text.trim().parse().map_err(|_| {
format!(
"Scale reference '{}' is not a number.",
constraint_text.trim()
)
})?;
if !value.is_finite() {
return Err("Scale reference must be a finite number.".to_string());
}
MeetConstraint::ScaleReference(value)
}
other => return Err(format!("Unknown constraint kind {other}.")),
};
Ok(ConstraintTier {
angle_deg,
name: name.to_string(),
indices: parsed_indices,
constraint,
imported_meet,
original_notes,
detached: Vec::new(),
})
}
pub(super) fn parse_preform_form(
shape_index: i32,
half_width: &str,
length_over_width: &str,
depth: &str,
cylinder_sides: usize,
) -> Result<PreformSpec, String> {
let half_width: f64 = half_width
.trim()
.parse()
.map_err(|_| format!("Half-width '{}' is not a number.", half_width.trim()))?;
let length_over_width: f64 = length_over_width.trim().parse().map_err(|_| {
format!(
"Length/width '{}' is not a number.",
length_over_width.trim()
)
})?;
let depth: f64 = depth
.trim()
.parse()
.map_err(|_| format!("Depth '{}' is not a number.", depth.trim()))?;
if !(half_width.is_finite() && length_over_width.is_finite() && depth.is_finite())
|| half_width <= 0.0
|| length_over_width <= 0.0
|| depth <= 0.0
{
return Err("Preform dimensions must be positive, finite numbers.".to_string());
}
Ok(if shape_index == 0 {
PreformSpec::block(half_width, length_over_width, depth)
} else {
PreformSpec::cylinder(cylinder_sides, half_width, length_over_width, depth)
})
}
pub(super) fn parse_new_design_form(
gear_teeth: i32,
preform: PreformSpec,
symmetry_order_text: &str,
mirror: bool,
material_index: i32,
) -> Result<FreshDesignSpec, String> {
let symmetry_order: u32 = symmetry_order_text.trim().parse().map_err(|_| {
format!(
"Symmetry order '{}' is not a whole number.",
symmetry_order_text.trim()
)
})?;
if symmetry_order < 1 {
return Err("Symmetry order must be a positive whole number.".to_string());
}
Ok(FreshDesignSpec {
gear_teeth,
symmetry_order,
mirror,
material: MaterialSelection {
name: material_name_from_index(material_index),
specific_gravity_override: None,
refractive_index_override: None,
},
preform,
})
}
pub(super) fn material_selection_for_accepted_suggestion(
name: &str,
schedule_ri: f64,
current: &MaterialSelection,
) -> MaterialSelection {
let built_in_ri = indicatrix_cut_core::built_in_refractive_index(name);
let refractive_index_override = match built_in_ri {
Some(ri) if (ri - schedule_ri).abs() > 0.01 => Some(schedule_ri),
_ => None,
};
MaterialSelection {
name: Some(name.to_string()),
specific_gravity_override: current.specific_gravity_override,
refractive_index_override,
}
}
#[cfg(test)]
mod tests {
use super::*;
use indicatrix_cut_core::PreformShape;
use indicatrix_vault::model::entry::FullDiagramRecord;
fn tier_form<'a>(
angle: &'a str,
constraint_kind: i32,
constraint_text: &'a str,
name: &'a str,
indices: &'a str,
) -> TierFormFields<'a> {
TierFormFields {
angle,
constraint_kind,
constraint_text,
name,
indices,
gear_teeth_abs: 96,
imported_meet: None,
original_notes: None,
other_tier_names: Vec::new(),
}
}
#[test]
fn parse_angle_only_accepts_a_well_formed_number() {
assert_eq!(parse_angle_only(" -41.0 ").unwrap(), -41.0);
assert_eq!(parse_angle_only("0").unwrap(), 0.0);
}
#[test]
fn parse_angle_only_rejects_non_numeric_and_non_finite_text() {
assert!(parse_angle_only("not-a-number").is_err());
assert!(parse_angle_only("NaN").is_err());
assert!(parse_angle_only("inf").is_err());
assert!(parse_angle_only("").is_err());
}
#[test]
fn parse_angle_only_accepts_exactly_90_either_sign() {
assert_eq!(parse_angle_only("90").unwrap(), 90.0);
assert_eq!(parse_angle_only("-90").unwrap(), -90.0);
}
#[test]
fn parse_angle_only_rejects_a_magnitude_over_90() {
let err = parse_angle_only("90.01").unwrap_err();
assert!(err.contains("90"));
let err = parse_angle_only("-410").unwrap_err();
assert!(err.contains("90"));
}
#[test]
fn parse_tier_form_accepts_a_well_formed_scale_reference_row() {
let tier = parse_tier_form(TierFormFields {
angle: "-41.0",
constraint_kind: 2,
constraint_text: "0.65",
name: " P1 ",
indices: "0, 24, 48, 72",
gear_teeth_abs: 96,
imported_meet: None,
original_notes: None,
other_tier_names: Vec::new(),
})
.unwrap();
assert_eq!(tier.angle_deg, -41.0);
assert_eq!(tier.constraint, MeetConstraint::ScaleReference(0.65));
assert_eq!(tier.name, "P1");
assert_eq!(tier.indices, vec![0.0, 24.0, 48.0, 72.0]);
}
#[test]
fn parse_tier_form_carries_the_files_original_note_through_an_edit() {
let tier = parse_tier_form(TierFormFields {
angle: "-41.0",
constraint_kind: 2,
constraint_text: "0.65",
name: "P1",
indices: "0, 24",
gear_teeth_abs: 96,
imported_meet: None,
original_notes: Some("Cut to TCP".to_string()),
other_tier_names: Vec::new(),
})
.unwrap();
assert_eq!(tier.original_notes.as_deref(), Some("Cut to TCP"));
}
#[test]
fn parse_tier_form_accepts_an_empty_index_list() {
let tier = parse_tier_form(tier_form("0.0", 2, "0.32", "T", "")).unwrap();
assert_eq!(tier.indices, [] as [f64; 0]);
}
#[test]
fn parse_tier_form_splits_on_comma_space_and_semicolon() {
let tier = parse_tier_form(tier_form("10", 2, "0.5", "G", "1, 2 3;4")).unwrap();
assert_eq!(tier.indices, vec![1.0, 2.0, 3.0, 4.0]);
}
#[test]
fn parse_tier_form_expands_a_colon_sequence() {
let tier = parse_tier_form(tier_form("10", 2, "0.5", "G", "0:12:96")).unwrap();
assert_eq!(
tier.indices,
vec![0.0, 12.0, 24.0, 36.0, 48.0, 60.0, 72.0, 84.0]
);
}
#[test]
fn parse_tier_form_colon_sequence_can_be_combined_with_other_tokens() {
let tier = parse_tier_form(tier_form("10", 2, "0.5", "G", "0:24:96, 6")).unwrap();
assert_eq!(tier.indices, vec![0.0, 24.0, 48.0, 72.0, 6.0]);
}
#[test]
fn parse_tier_form_colon_sequence_out_of_range_is_still_reported() {
let tier = parse_tier_form(tier_form("10", 2, "0.5", "G", "0:200:500")).unwrap();
assert!(tier.indices.iter().all(|&v| (0.0..96.0).contains(&v)));
}
#[test]
fn parse_tier_form_expands_the_orbit_shorthand() {
let tier = parse_tier_form(tier_form("10", 2, "0.5", "G", "12 x8")).unwrap();
assert_eq!(
tier.indices,
vec![12.0, 24.0, 36.0, 48.0, 60.0, 72.0, 84.0, 0.0]
);
}
#[test]
fn parse_tier_form_orbit_shorthand_accepts_uppercase_x() {
let tier = parse_tier_form(tier_form("10", 2, "0.5", "G", "0 X4")).unwrap();
assert_eq!(tier.indices, vec![0.0, 24.0, 48.0, 72.0]);
}
#[test]
fn parse_tier_form_orbit_shorthand_can_be_combined_with_other_tokens() {
let tier = parse_tier_form(tier_form("10", 2, "0.5", "G", "6, 0 x4")).unwrap();
assert_eq!(tier.indices, vec![6.0, 0.0, 24.0, 48.0, 72.0]);
}
#[test]
fn parse_tier_form_orbit_shorthand_duplicate_with_a_prior_token_is_rejected() {
let err = parse_tier_form(tier_form("10", 2, "0.5", "G", "0, 0 x4")).unwrap_err();
assert!(err.contains("more than once"));
}
#[test]
fn parse_tier_form_a_lone_x_token_is_not_mistaken_for_shorthand() {
let err = parse_tier_form(tier_form("10", 2, "0.5", "G", "x8")).unwrap_err();
assert!(err.contains("Index 'x8' is not a number."));
}
#[test]
fn parse_tier_form_negative_index_is_not_mistaken_for_a_colon_sequence() {
let err = parse_tier_form(tier_form("0.0", 2, "0.5", "T", "-1")).unwrap_err();
assert!(err.contains("-1"));
}
#[test]
fn parse_tier_form_rejects_a_non_numeric_angle() {
let err = parse_tier_form(tier_form("not-a-number", 2, "0.5", "T", "")).unwrap_err();
assert!(err.contains("Angle"));
}
#[test]
fn parse_tier_form_rejects_a_magnitude_over_90() {
let err = parse_tier_form(tier_form("95.0", 2, "0.5", "T", "")).unwrap_err();
assert!(err.contains("exceeds 90"));
}
#[test]
fn parse_tier_form_accepts_exactly_90() {
assert!(parse_tier_form(tier_form("90.0", 2, "0.5", "T", "")).is_ok());
assert!(parse_tier_form(tier_form("-90.0", 2, "0.5", "T", "")).is_ok());
}
#[test]
fn parse_tier_form_rejects_a_name_another_tier_already_holds() {
let mut form = tier_form("30.0", 2, "0.5", "P1", "");
form.other_tier_names = vec!["P1".to_string()];
let err = parse_tier_form(form).unwrap_err();
assert!(err.contains("P1"));
}
#[test]
fn parse_tier_form_name_collision_check_is_case_insensitive() {
let mut form = tier_form("30.0", 2, "0.5", "p1", "");
form.other_tier_names = vec!["P1".to_string()];
assert!(parse_tier_form(form).is_err());
}
#[test]
fn parse_tier_form_checks_each_slash_joined_name_for_a_collision() {
let mut form = tier_form("30.0", 2, "0.5", "P1/P2", "");
form.other_tier_names = vec!["P2".to_string()];
let err = parse_tier_form(form).unwrap_err();
assert!(err.contains("P2"));
}
#[test]
fn parse_tier_form_allows_a_name_no_other_tier_holds() {
let mut form = tier_form("30.0", 2, "0.5", "P1", "");
form.other_tier_names = vec!["G1".to_string(), "C1".to_string()];
assert!(parse_tier_form(form).is_ok());
}
#[test]
fn parse_tier_form_rejects_a_non_numeric_index() {
let err = parse_tier_form(tier_form("0.0", 2, "0.5", "T", "1, x, 3")).unwrap_err();
assert!(err.contains("Index"));
}
#[test]
fn parse_tier_form_rejects_non_finite_values() {
assert!(parse_tier_form(tier_form("NaN", 2, "0.5", "T", "")).is_err());
assert!(parse_tier_form(tier_form("0.0", 2, "inf", "T", "")).is_err());
}
#[test]
fn parse_tier_form_rejects_a_non_finite_index() {
let err = parse_tier_form(tier_form("0.0", 2, "0.5", "T", "1, NaN, 3")).unwrap_err();
assert!(err.contains("Index"));
assert!(err.contains("finite"));
}
#[test]
fn parse_tier_form_rejects_an_index_outside_the_gears_tooth_count() {
let err = parse_tier_form(tier_form("0.0", 2, "0.5", "T", "1, 96")).unwrap_err();
assert!(err.contains("96"));
}
#[test]
fn parse_tier_form_rejects_a_negative_index() {
let err = parse_tier_form(tier_form("0.0", 2, "0.5", "T", "-1")).unwrap_err();
assert!(err.contains("-1"));
}
#[test]
fn parse_tier_form_rejects_a_duplicate_index() {
let err = parse_tier_form(tier_form("0.0", 2, "0.5", "T", "24, 48, 24")).unwrap_err();
assert!(err.contains("24"));
assert!(err.contains("more than once"));
}
#[test]
fn parse_tier_form_kind_zero_is_meet_existing_and_ignores_the_text_field() {
let tier = parse_tier_form(tier_form("30.0", 0, "this text is ignored", "C1", "")).unwrap();
assert_eq!(tier.constraint, MeetConstraint::MeetExisting);
}
#[test]
fn parse_tier_form_kind_one_splits_named_facets_on_comma() {
let tier = parse_tier_form(tier_form("30.0", 1, "P1, P2 , G1", "C1", "")).unwrap();
assert_eq!(
tier.constraint,
MeetConstraint::MeetNamed(vec!["P1".to_string(), "P2".to_string(), "G1".to_string()])
);
}
#[test]
fn parse_tier_form_kind_one_rejects_an_empty_name_list() {
let err = parse_tier_form(tier_form("30.0", 1, " , ", "C1", "")).unwrap_err();
assert!(err.contains("Meet named"));
}
#[test]
fn parse_tier_form_rejects_an_unknown_constraint_kind() {
let err = parse_tier_form(tier_form("30.0", 3, "", "C1", "")).unwrap_err();
assert!(err.contains('3'));
}
#[test]
fn parse_preform_form_builds_a_block_at_index_zero() {
let preform = parse_preform_form(0, "1.2", "1.5", "0.8", 96).unwrap();
assert_eq!(preform.shape, PreformShape::Block);
assert_eq!(preform.half_width, 1.2);
assert_eq!(preform.length_over_width, 1.5);
assert_eq!(preform.depth, 0.8);
}
#[test]
fn parse_preform_form_builds_a_cylinder_with_the_given_side_count() {
let preform = parse_preform_form(1, "1.0", "1.0", "0.8", 64).unwrap();
assert_eq!(preform.shape, PreformShape::Cylinder { sides: 64 });
}
#[test]
fn parse_preform_form_rejects_zero_or_negative_dimensions() {
assert!(parse_preform_form(0, "0.0", "1.0", "0.8", 96).is_err());
assert!(parse_preform_form(0, "1.0", "-1.0", "0.8", 96).is_err());
assert!(parse_preform_form(0, "1.0", "1.0", "0.0", 96).is_err());
}
#[test]
fn parse_preform_form_rejects_unparseable_text() {
assert!(parse_preform_form(0, "wide", "1.0", "0.8", 96).is_err());
}
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());
}
#[test]
fn parse_new_design_form_round_trips_gear_symmetry_mirror_and_material() {
let preform = PreformSpec::cylinder(80, 1.5, 1.0, 1.5);
let spec = parse_new_design_form(80, preform, "6", false, 9).unwrap();
assert_eq!(spec.gear_teeth, 80);
assert_eq!(spec.symmetry_order, 6);
assert!(!spec.mirror);
assert_eq!(spec.material.name.as_deref(), Some("Quartz")); assert_eq!(spec.preform.half_width, 1.5);
}
#[test]
fn parse_new_design_form_none_material_index_means_no_material_selected() {
let preform = PreformSpec::cylinder(50, 1.0, 1.0, 1.0);
let spec = parse_new_design_form(50, preform, "8", true, 0).unwrap();
assert_eq!(spec.gear_teeth, 50);
assert_eq!(spec.material.name, None);
}
#[test]
fn parse_new_design_form_rejects_a_non_positive_symmetry_order() {
let preform = PreformSpec::cylinder(96, 1.0, 1.0, 1.0);
assert!(parse_new_design_form(96, preform, "0", true, 0).is_err());
assert!(parse_new_design_form(96, preform, "wide", true, 0).is_err());
}
#[test]
fn accepted_suggestion_sets_no_override_when_the_built_in_ri_is_within_tolerance() {
let quartz_ri = indicatrix_cut_core::built_in_refractive_index("Quartz").unwrap();
let current = MaterialSelection::none();
let selection = material_selection_for_accepted_suggestion("Quartz", quartz_ri, ¤t);
assert_eq!(selection.name.as_deref(), Some("Quartz"));
assert_eq!(selection.refractive_index_override, None);
}
#[test]
fn accepted_suggestion_pins_the_schedule_ri_when_the_built_in_ri_differs_by_more_than_the_tolerance()
{
let quartz_ri = indicatrix_cut_core::built_in_refractive_index("Quartz").unwrap();
let schedule_ri = quartz_ri + 0.02;
let current = MaterialSelection::none();
let selection = material_selection_for_accepted_suggestion("Quartz", schedule_ri, ¤t);
assert_eq!(selection.refractive_index_override, Some(schedule_ri));
}
#[test]
fn accepted_suggestion_keeps_the_current_specific_gravity_override() {
let mut current = MaterialSelection::none();
current.specific_gravity_override = Some(3.9);
let selection = material_selection_for_accepted_suggestion("Diamond", 2.417, ¤t);
assert_eq!(selection.specific_gravity_override, Some(3.9));
}
}