use crate::{EditorTierItem, GearRemapRow};
use indicatrix::{
geometry::{
GpuFacetPlane,
meet_solver::{MeetConstraint, SolveStrategy},
stone_metrics::SolidStatus,
},
optics::materials::GemMaterial,
};
use indicatrix_cut_core::{
Design, Edit, EditError, FreshDesignSpec, History, MaterialSelection, OptimizeOutcome,
OrbitUnit, RemapRounding, Risk, tier_margin_deg, windowing_risk,
};
use std::{
collections::BTreeSet,
sync::{
Arc, Mutex,
atomic::{AtomicU64, Ordering as AtomicOrdering},
},
};
pub(super) const GEAR_PRESETS: [i32; 6] = [96, 80, 77, 72, 64, 120];
pub(super) struct PendingGearRemap {
pub(super) from_gear: i32,
pub(super) to_gear: i32,
pub(super) symmetry_order: u32,
pub(super) mirror: bool,
pub(super) rounding: RemapRounding,
}
pub(super) struct EditorState {
pub(super) design: Design,
pub(super) history: History,
pub(super) printed_proportions:
Option<indicatrix::geometry::stone_metrics::ExternalProportions>,
pub(super) generation: Arc<AtomicU64>,
pub(super) deep_solve: Option<super::deep_solve::DeepSolveHandle>,
pub(super) optimize: Option<super::optimize_solve::OptimizeSolveHandle>,
pub(super) pending_optimize: Arc<Mutex<Option<(OptimizeOutcome, u64)>>>,
pub(super) asc_filename: Option<String>,
pub(super) original_asc_text: Option<String>,
pub(super) pending_gear_remap: Option<PendingGearRemap>,
pub(super) pending_retarget: Option<(super::retarget::RetargetProposal, u64)>,
pub(super) multi_selected: BTreeSet<usize>,
}
impl EditorState {
pub(super) fn fresh() -> Self {
let preform = indicatrix_cut_core::PreformSpec::cylinder(96, 1.5, 1.0, 1.5);
Self {
design: Design::fresh(preform, 96, 8, 1.54),
history: History::new(),
printed_proportions: None,
generation: Arc::new(AtomicU64::new(0)),
deep_solve: None,
optimize: None,
pending_optimize: Arc::new(Mutex::new(None)),
asc_filename: None,
original_asc_text: None,
pending_gear_remap: None,
pending_retarget: None,
multi_selected: BTreeSet::new(),
}
}
pub(super) fn fresh_from_spec(spec: FreshDesignSpec) -> Self {
Self {
design: Design::fresh_from_spec(spec),
history: History::new(),
printed_proportions: None,
generation: Arc::new(AtomicU64::new(0)),
deep_solve: None,
optimize: None,
pending_optimize: Arc::new(Mutex::new(None)),
asc_filename: None,
original_asc_text: None,
pending_gear_remap: None,
pending_retarget: None,
multi_selected: BTreeSet::new(),
}
}
pub(super) fn apply(&mut self, edit: Edit) -> Result<(), EditError> {
let Self {
design, history, ..
} = self;
let outcome = history.apply(design, edit);
if outcome.is_ok() {
self.generation.fetch_add(1, AtomicOrdering::Relaxed);
self.prune_multi_selected();
}
outcome
}
pub(super) fn apply_coalescing(&mut self, edit: Edit, key: u64) -> Result<(), EditError> {
let Self {
design, history, ..
} = self;
let outcome = history.apply_coalescing(design, edit, key, std::time::Instant::now());
if outcome.is_ok() {
self.generation.fetch_add(1, AtomicOrdering::Relaxed);
self.prune_multi_selected();
}
outcome
}
pub(super) fn undo(&mut self) -> Result<bool, EditError> {
let Self {
design, history, ..
} = self;
let undone = history.undo(design)?;
if undone {
self.generation.fetch_add(1, AtomicOrdering::Relaxed);
self.prune_multi_selected();
}
Ok(undone)
}
pub(super) fn redo(&mut self) -> Result<bool, EditError> {
let Self {
design, history, ..
} = self;
let redone = history.redo(design)?;
if redone {
self.generation.fetch_add(1, AtomicOrdering::Relaxed);
self.prune_multi_selected();
}
Ok(redone)
}
fn prune_multi_selected(&mut self) {
let tier_count = self.design.tiers.len();
self.multi_selected.retain(|&index| index < tier_count);
}
pub(super) fn apply_optimize_outcome(
&mut self,
outcome: &OptimizeOutcome,
) -> Result<usize, EditError> {
let Self {
design, history, ..
} = self;
let result = indicatrix_cut_core::apply_optimize_outcome(history, design, outcome);
match &result {
Ok(0) => {}
Ok(_) | Err(_) => {
self.generation.fetch_add(1, AtomicOrdering::Relaxed);
}
}
result
}
}
fn constraint_kind_and_text(constraint: &MeetConstraint) -> (i32, String) {
match constraint {
MeetConstraint::MeetExisting => (0, String::new()),
MeetConstraint::MeetNamed(names) => (1, names.join(", ")),
MeetConstraint::ScaleReference(value) => (2, value.to_string()),
}
}
fn imported_meet_text(imported_meet: Option<&MeetConstraint>) -> String {
match imported_meet {
Some(MeetConstraint::MeetExisting) => "meets an unspecified vertex".to_string(),
Some(MeetConstraint::MeetNamed(names)) => format!("meets {}", names.join(", ")),
None | Some(MeetConstraint::ScaleReference(_)) => String::new(),
}
}
fn orbit_status_text(units: &[OrbitUnit]) -> (String, bool) {
if units.iter().all(|u| u.members.len() <= 1) {
return (String::new(), false);
}
match units {
[one] => {
if one.is_complete() {
(format!("orbit x{}", one.members.len()), false)
} else {
(
format!("{}/{} orbit", one.members.len(), one.expected_len),
true,
)
}
}
many => {
let incomplete = many.iter().any(|u| !u.is_complete());
(format!("{} orbits", many.len()), incomplete)
}
}
}
pub(super) fn apply_multi_selection(rows: &mut [EditorTierItem], multi_selected: &BTreeSet<usize>) {
for row in rows {
row.multi_selected =
usize::try_from(row.index).is_ok_and(|index| multi_selected.contains(&index));
}
}
fn tier_margin_and_risk(tier_angle_deg: f64, n_d: f64) -> (String, i32) {
if tier_angle_deg >= 0.0 {
return (String::new(), -1);
}
let margin = tier_margin_deg(tier_angle_deg, n_d);
let risk_level = match windowing_risk(tier_angle_deg, n_d) {
Risk::Safe => 0,
Risk::Marginal => 1,
Risk::Windows => 2,
};
(format!("{margin:+.1}\u{b0}"), risk_level)
}
const fn strategy_label(strategy: SolveStrategy) -> (&'static str, bool) {
match strategy {
SolveStrategy::ScaleReference => ("Scale reference", false),
SolveStrategy::DependencyOrder => ("Dependency order", false),
SolveStrategy::JointGroup => ("Joint group", false),
SolveStrategy::LeastSquaresFallback => ("Least-squares est.", true),
SolveStrategy::Failed => ("FAILED (untrusted)", true),
}
}
pub(super) fn tier_items_stale(design: &Design, n_d: f64) -> Vec<EditorTierItem> {
design
.tiers
.iter()
.enumerate()
.map(|(index, tier)| {
let (constraint_kind, constraint_text) = constraint_kind_and_text(&tier.constraint);
let units = indicatrix_cut_core::orbit_units(&tier.indices, &design.meta);
let (orbit_status, orbit_incomplete) = orbit_status_text(&units);
let (margin_text, risk_level) = tier_margin_and_risk(tier.angle_deg, n_d);
EditorTierItem {
index: index as i32,
angle_deg: tier.angle_deg.to_string().into(),
name: tier.name.clone().into(),
indices: tier
.indices
.iter()
.map(f64::to_string)
.collect::<Vec<_>>()
.join(", ")
.into(),
constraint_kind,
constraint_text: constraint_text.into(),
mast: "-".into(),
strategy: "not solved".into(),
strategy_is_uncertain: true,
imported_meet_text: imported_meet_text(tier.imported_meet.as_ref()).into(),
orbit_status: orbit_status.into(),
orbit_incomplete,
is_detached: !tier.detached.is_empty(),
margin_text: margin_text.into(),
risk_level,
multi_selected: false,
}
})
.collect()
}
pub(super) fn tier_items(design: &Design, n_d: f64) -> Vec<EditorTierItem> {
let solved = design.solve();
design
.tiers
.iter()
.enumerate()
.map(|(index, tier)| {
let (constraint_kind, constraint_text) = constraint_kind_and_text(&tier.constraint);
let (mast, strategy, strategy_is_uncertain) = solved.as_ref().map_or_else(
|_| ("?".to_string(), "no anchor yet".to_string(), true),
|rows| {
let (label, uncertain) = strategy_label(rows[index].strategy);
(rows[index].mast.to_string(), label.to_string(), uncertain)
},
);
let units = indicatrix_cut_core::orbit_units(&tier.indices, &design.meta);
let (orbit_status, orbit_incomplete) = orbit_status_text(&units);
let (margin_text, risk_level) = tier_margin_and_risk(tier.angle_deg, n_d);
EditorTierItem {
index: index as i32,
angle_deg: tier.angle_deg.to_string().into(),
name: tier.name.clone().into(),
indices: tier
.indices
.iter()
.map(f64::to_string)
.collect::<Vec<_>>()
.join(", ")
.into(),
constraint_kind,
constraint_text: constraint_text.into(),
mast: mast.into(),
strategy: strategy.into(),
strategy_is_uncertain,
imported_meet_text: imported_meet_text(tier.imported_meet.as_ref()).into(),
orbit_status: orbit_status.into(),
orbit_incomplete,
is_detached: !tier.detached.is_empty(),
margin_text: margin_text.into(),
risk_level,
multi_selected: false,
}
})
.collect()
}
pub(super) fn manufacturability_warning_lines(design: &Design) -> Vec<String> {
let Ok(solved) = design.solve() else {
return Vec::new();
};
indicatrix_cut_core::manufacturability::check_manufacturability(
design,
&solved,
indicatrix_cut_core::manufacturability::DEFAULT_MIN_FACET_AREA_FRACTION_OF_W2,
)
.iter()
.map(std::string::ToString::to_string)
.collect()
}
pub(super) const MATERIAL_PRESET_NAMES: [&str; 14] = [
"(none)",
"Diamond",
"Sapphire",
"Ruby",
"Emerald",
"Zircon",
"Alexandrite",
"Topaz",
"Spinel",
"Quartz",
"Tourmaline",
"Tanzanite",
"Synthetic Moissanite",
"Cubic Zirconia",
];
pub(super) fn material_index_from_name(name: Option<&str>) -> i32 {
name.and_then(|n| MATERIAL_PRESET_NAMES.iter().position(|&p| p == n))
.map_or(0, |i| i as i32)
}
pub(super) fn material_name_from_index(index: i32) -> Option<String> {
usize::try_from(index)
.ok()
.and_then(|i| MATERIAL_PRESET_NAMES.get(i))
.filter(|&&name| name != "(none)")
.map(|&name| name.to_string())
}
pub(super) fn parse_yield_form(
girdle_diameter_mm: &str,
material_index: i32,
specific_gravity_override: &str,
) -> Result<(Option<f64>, MaterialSelection), String> {
let girdle_diameter_mm = if girdle_diameter_mm.trim().is_empty() {
None
} else {
let value: f64 = girdle_diameter_mm.trim().parse().map_err(|_| {
format!(
"Girdle diameter '{}' is not a number.",
girdle_diameter_mm.trim()
)
})?;
if !value.is_finite() || value <= 0.0 {
return Err("Girdle diameter must be a positive, finite number.".to_string());
}
Some(value)
};
let specific_gravity_override = if specific_gravity_override.trim().is_empty() {
None
} else {
let value: f64 = specific_gravity_override.trim().parse().map_err(|_| {
format!(
"Specific gravity '{}' is not a number.",
specific_gravity_override.trim()
)
})?;
if !value.is_finite() || value <= 0.0 {
return Err("Specific gravity override must be a positive, finite number.".to_string());
}
Some(value)
};
Ok((
girdle_diameter_mm,
MaterialSelection {
name: material_name_from_index(material_index),
specific_gravity_override,
refractive_index_override: None,
},
))
}
pub(super) fn yield_report_texts(design: &Design) -> (String, String, String, String) {
let Ok(solved) = design.solve() else {
return (String::new(), String::new(), String::new(), String::new());
};
let report = design.yield_report(&solved);
let volumetric_yield_text = report
.volumetric_yield
.map(|y| format!("{:.2}%", y * 100.0))
.unwrap_or_default();
let carat_weight_text = report
.carat_weight
.map(|c| format!("{c:.4} ct (est.)"))
.unwrap_or_default();
let specific_gravity_used_text = report.specific_gravity_used.map_or_else(String::new, |sg| {
if design.material.specific_gravity_override.is_some() {
format!("{sg:.3} (override)")
} else {
format!("{sg:.3}")
}
});
let preform_fit_warning_text = report
.preform_fit
.map(|fit| fit.to_string())
.unwrap_or_default();
(
volumetric_yield_text,
carat_weight_text,
specific_gravity_used_text,
preform_fit_warning_text,
)
}
pub(super) fn status_text_and_is_problem(design: &Design) -> (String, bool) {
match design.status() {
Ok(SolidStatus::Closed(_)) => {
let volume_note = design
.measure()
.ok()
.flatten()
.map(|m| format!(" -- volume {:.4}", m.volume))
.unwrap_or_default();
(format!("Closed solid.{volume_note}"), false)
}
Ok(SolidStatus::Degenerate {
vertex_count,
volume,
}) => {
let volume_text =
volume.map_or_else(|| "non-finite".to_string(), |v| format!("{v:.4}"));
(
format!(
"Degenerate: only {vertex_count} distinct vertex(es), volume {volume_text}."
),
true,
)
}
Ok(SolidStatus::Unbounded { escaping }) => (
format!("Unbounded: plane(s) {escaping:?} never close the solid."),
true,
),
Err(missing) => (format!("Cannot solve: {missing}."), true),
}
}
pub(super) fn design_to_gpu_planes(design: &Design) -> Vec<GpuFacetPlane> {
design
.planes()
.unwrap_or_default()
.into_iter()
.map(|(normal, offset)| GpuFacetPlane::new(normal.as_vec3(), -offset as f32))
.collect()
}
pub(super) fn design_material_options(custom: &[GemMaterial]) -> Vec<String> {
let mut options: Vec<String> = MATERIAL_PRESET_NAMES
.iter()
.map(|&s| s.to_string())
.collect();
for material in custom {
if !options
.iter()
.any(|name| name.eq_ignore_ascii_case(&material.name))
{
options.push(material.name.clone());
}
}
options.push("Custom RI\u{2026}".to_string());
options
}
pub(super) fn design_material_index_from_name(name: Option<&str>, options: &[String]) -> i32 {
name.and_then(|n| options.iter().position(|o| o.eq_ignore_ascii_case(n)))
.map_or(0, |i| i as i32)
}
pub(super) fn design_material_name_from_index(index: i32, options: &[String]) -> Option<String> {
usize::try_from(index)
.ok()
.and_then(|i| options.get(i))
.filter(|&name| name != "(none)" && name != "Custom RI\u{2026}")
.cloned()
}
pub(super) fn parse_design_material_form(
combo_index: i32,
ri_override_text: &str,
options: &[String],
current: &MaterialSelection,
) -> Result<MaterialSelection, String> {
let refractive_index_override = if ri_override_text.trim().is_empty() {
None
} else {
let value: f64 = ri_override_text.trim().parse().map_err(|_| {
format!(
"Refractive index '{}' is not a number.",
ri_override_text.trim()
)
})?;
if !value.is_finite() || value <= 1.0 {
return Err(
"Refractive index override must be a finite number greater than 1.0.".to_string(),
);
}
Some(value)
};
Ok(MaterialSelection {
name: design_material_name_from_index(combo_index, options),
specific_gravity_override: current.specific_gravity_override,
refractive_index_override,
})
}
pub(super) fn gear_index_from_teeth(gear_teeth: i32) -> i32 {
GEAR_PRESETS
.iter()
.position(|&g| g == gear_teeth)
.map_or(GEAR_PRESETS.len() as i32, |i| i as i32)
}
pub(super) fn gear_choice_to_teeth(preset_index: i32, custom_text: &str) -> Result<i32, String> {
if let Ok(i) = usize::try_from(preset_index)
&& let Some(&teeth) = GEAR_PRESETS.get(i)
{
return Ok(teeth);
}
let teeth: i32 = custom_text.trim().parse().map_err(|_| {
format!(
"Gear tooth count '{}' is not a whole number.",
custom_text.trim()
)
})?;
if teeth <= 0 {
return Err("Gear tooth count must be a positive whole number.".to_string());
}
Ok(teeth)
}
pub(super) fn gear_remap_preview(
design: &Design,
from_gear: i32,
to_gear: i32,
rounding: RemapRounding,
) -> Vec<GearRemapRow> {
let format_indices = |v: &[f64]| v.iter().map(f64::to_string).collect::<Vec<_>>().join(", ");
let original: Vec<String> = design
.tiers
.iter()
.map(|t| format_indices(&t.indices))
.collect();
let non_integral: Vec<bool> = design
.tiers
.iter()
.map(|t| {
t.indices.iter().any(|&i| {
let ratio = if from_gear == 0 {
1.0
} else {
f64::from(to_gear) / f64::from(from_gear)
};
(i * ratio).fract() != 0.0
})
})
.collect();
let mut remapped = design.clone();
if remapped
.apply_edit(Edit::RemapIndices {
from_gear,
to_gear,
rounding,
})
.is_err()
{
return Vec::new();
}
design
.tiers
.iter()
.zip(&remapped.tiers)
.enumerate()
.map(|(i, (before, after))| GearRemapRow {
name: before.name.clone().into(),
old_indices: original[i].clone().into(),
new_indices: format_indices(&after.indices).into(),
non_integral: non_integral[i],
})
.collect()
}
pub(super) fn angle_nudge_coalesce_key(targets: &[usize]) -> u64 {
use std::hash::{Hash, Hasher};
let mut sorted = targets.to_vec();
sorted.sort_unstable();
sorted.dedup();
let mut hasher = std::collections::hash_map::DefaultHasher::new();
sorted.len().hash(&mut hasher);
for value in &sorted {
value.hash(&mut hasher);
}
hasher.finish()
}
#[cfg(test)]
mod tests;