use crate::{AngleItem, EditorModel, EditorTierItem, GearRemapRow, IndexChipItem, MainWindow};
use indicatrix::{
geometry::{
GpuFacetPlane,
meet_solver::{
Block, MeetConstraint, MeetNameResolver, SolveStrategy, SolvedTier, TokenResolution,
classify_blocks,
},
stone_metrics::{SolidMetrics, SolidStatus, build_solid_mesh, measure_solid},
},
optics::materials::GemMaterial,
};
use indicatrix_cut_core::{
ConstraintTier, Design, Edit, EditError, FreshDesignSpec, History, MaterialSelection,
MissingAnchor, OptimizeOutcome, OrbitUnit, PreformSpec, RemapRounding, Risk,
degenerate_suspects, remap_ratio, tier_margin_deg, windowing_risk,
};
use slint::{ComponentHandle, Model, ModelRc, VecModel};
use std::{
cell::RefCell,
collections::{BTreeMap, BTreeSet},
sync::{
Arc, Mutex,
atomic::{AtomicU64, Ordering as AtomicOrdering},
},
};
pub(super) const GEAR_PRESETS: [i32; 6] = [96, 80, 77, 72, 64, 120];
pub(super) const ANGLE_NUDGE_COALESCE_WINDOW: std::time::Duration =
std::time::Duration::from_millis(1500);
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) enum PendingUnsavedAction {
New {
spec: FreshDesignSpec,
template_index: i32,
},
LoadSelected,
OpenNative,
}
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) design_epoch: Arc<AtomicU64>,
pub(super) saved_generation: u64,
pub(super) pending_unsaved_action: Option<PendingUnsavedAction>,
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) source_entry_id: Option<i64>,
pub(super) used_placeholder: bool,
pub(super) pending_gear_remap: Option<PendingGearRemap>,
pub(super) pending_retarget: Option<(super::retarget::RetargetProposal, u64)>,
pub(super) multi_selected: BTreeSet<usize>,
pub(super) last_pushed_scratch: RefCell<PushedScratch>,
}
#[derive(Default)]
pub(super) struct PushedScratch {
material: Option<MaterialSelection>,
gear_teeth: Option<i32>,
symmetry: Option<(u32, bool)>,
preform: Option<PreformSpec>,
girdle_diameter_mm: GirdleDiameterPush,
}
#[derive(Clone, Copy, Default, PartialEq)]
pub(super) enum GirdleDiameterPush {
#[default]
Unobserved,
Observed(Option<f64>),
}
pub(super) struct ScratchDelta {
pub(super) material: bool,
pub(super) gear: bool,
pub(super) symmetry: bool,
pub(super) preform: bool,
pub(super) girdle: bool,
}
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::with_coalesce_window(ANGLE_NUDGE_COALESCE_WINDOW),
printed_proportions: None,
generation: Arc::new(AtomicU64::new(0)),
design_epoch: Arc::new(AtomicU64::new(0)),
saved_generation: 0,
pending_unsaved_action: None,
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(),
source_entry_id: None,
used_placeholder: false,
last_pushed_scratch: RefCell::new(PushedScratch::default()),
}
}
pub(super) fn fresh_from_spec(spec: FreshDesignSpec) -> Self {
Self {
design: Design::fresh_from_spec(spec),
history: History::with_coalesce_window(ANGLE_NUDGE_COALESCE_WINDOW),
printed_proportions: None,
generation: Arc::new(AtomicU64::new(0)),
design_epoch: Arc::new(AtomicU64::new(0)),
saved_generation: 0,
pending_unsaved_action: None,
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(),
source_entry_id: None,
used_placeholder: false,
last_pushed_scratch: RefCell::new(PushedScratch::default()),
}
}
pub(super) fn replace_wholesale(&mut self, mut replacement: Self) {
replacement.generation = Arc::clone(&self.generation);
let now = replacement.generation.fetch_add(1, AtomicOrdering::Relaxed) + 1;
replacement.saved_generation = now;
replacement.design_epoch = Arc::clone(&self.design_epoch);
replacement
.design_epoch
.fetch_add(1, AtomicOrdering::Relaxed);
*self = replacement;
}
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
}
#[must_use]
pub(super) fn is_dirty(&self) -> bool {
self.generation.load(AtomicOrdering::Relaxed) != self.saved_generation
}
pub(super) fn record_scratch_push(&self) -> ScratchDelta {
let design = &self.design;
let mut cache = self.last_pushed_scratch.borrow_mut();
let delta = ScratchDelta {
material: cache.material.as_ref() != Some(&design.material),
gear: cache.gear_teeth != Some(design.meta.gear_teeth),
symmetry: cache.symmetry != Some((design.meta.symmetry_order, design.meta.mirror)),
preform: cache.preform != Some(design.preform),
girdle: cache.girdle_diameter_mm
!= GirdleDiameterPush::Observed(design.girdle_diameter_mm),
};
*cache = PushedScratch {
material: Some(design.material.clone()),
gear_teeth: Some(design.meta.gear_teeth),
symmetry: Some((design.meta.symmetry_order, design.meta.mirror)),
preform: Some(design.preform),
girdle_diameter_mm: GirdleDiameterPush::Observed(design.girdle_diameter_mm),
};
delta
}
}
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 format_angle_cell(angle_deg: f64) -> String {
format!("{angle_deg:.2}")
}
fn format_index_value(value: f64) -> String {
if value.fract().abs() < 1e-9 {
format!("{value:.0}")
} else {
format!("{value:.2}")
}
}
#[must_use]
pub(super) fn index_chip_items(indices: &[f64], detached: &[f64]) -> Vec<IndexChipItem> {
indices
.iter()
.map(|&position| IndexChipItem {
position: position as f32,
label: format_index_value(position).into(),
detached: detached.iter().any(|&d| (d - position).abs() < 1e-9),
})
.collect()
}
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().filter(|u| !u.is_complete()).count();
if incomplete == 0 {
(format!("{} orbits", many.len()), false)
} else if incomplete == many.len() {
("not symmetric".to_string(), true)
} else {
(
format!("{} orbits ({incomplete} incomplete)", many.len()),
true,
)
}
}
}
}
#[must_use]
pub(super) fn tiers_incomplete_under_proposed_symmetry(
design: &Design,
symmetry_order: u32,
mirror: bool,
) -> usize {
let mut proposed_meta = design.meta.clone();
proposed_meta.symmetry_order = symmetry_order;
proposed_meta.mirror = mirror;
design
.tiers
.iter()
.filter(|tier| {
let units = indicatrix_cut_core::orbit_units(&tier.indices, &proposed_meta);
orbit_status_text(&units).1
})
.count()
}
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));
}
}
pub(super) fn push_multi_selected_count(ui: &MainWindow, count: usize) {
ui.global::<EditorModel>()
.set_multi_selected_count(i32::try_from(count).unwrap_or(i32::MAX));
}
pub(super) fn push_tiers(ui: &MainWindow, rows: Vec<EditorTierItem>) {
let model = ui.global::<EditorModel>().get_tiers();
if model.row_count() == rows.len() {
for (index, row) in rows.into_iter().enumerate() {
model.set_row_data(index, row);
}
} else {
ui.global::<EditorModel>()
.set_tiers(ModelRc::new(VecModel::from(rows)));
}
ui.global::<EditorModel>().invoke_recompute_dirty();
}
pub(super) fn first_unresolved_meet_name(design: &Design, names: &[String]) -> Option<String> {
let inputs = design.meet_tier_inputs();
let resolver = MeetNameResolver::new(&inputs);
names
.iter()
.find(|name| matches!(resolver.resolve_token(name), TokenResolution::Unresolved))
.cloned()
}
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),
}
}
fn missing_anchor_tier_blocks(missing: &MissingAnchor, design: &Design) -> BTreeMap<usize, Block> {
missing
.block_details(design)
.into_iter()
.flat_map(|(block, indices)| indices.into_iter().map(move |index| (index, block)))
.collect()
}
fn tier_label(design: &Design, tier_index: usize) -> String {
design.tiers.get(tier_index).map_or_else(
|| format!("tier {}", tier_index + 1),
|tier| {
if tier.name.is_empty() {
format!("tier {}", tier_index + 1)
} else {
format!("tier {} ({})", tier_index + 1, tier.name)
}
},
)
}
fn meet_partners_text(design: &Design, index: usize) -> String {
let Ok(targets) = design.facet_meets(index) else {
return String::new();
};
if targets.is_empty() {
return String::new();
}
format!(
"meets {}",
targets
.into_iter()
.map(|target| tier_label(design, target))
.collect::<Vec<_>>()
.join(", ")
)
}
fn escaping_tier_text(design: &Design, solved: &[SolvedTier], escaping: &[usize]) -> String {
escaping
.iter()
.map(|&plane_index| {
design
.tier_for_plane_index(solved, plane_index)
.map_or_else(
|| format!("plane {plane_index}"),
|tier_index| tier_label(design, tier_index),
)
})
.collect::<Vec<_>>()
.join(", ")
}
fn degenerate_suspects_note(design: &Design, solved: &[SolvedTier]) -> String {
let suspects = degenerate_suspects(solved);
if suspects.is_empty() {
return String::new();
}
let names: Vec<String> = suspects
.into_iter()
.map(|index| tier_label(design, index))
.collect();
format!(" -- check {}", names.join(", "))
}
struct TierSolveInfo {
mast: String,
mast_mm: String,
mast_full: String,
strategy: String,
strategy_is_uncertain: bool,
strategy_detail: String,
needs_anchor: bool,
}
fn build_tier_row(
design: &Design,
index: usize,
tier: &ConstraintTier,
n_d: f64,
info: TierSolveInfo,
tier_blocks: &[Block],
warnings: &BTreeMap<usize, String>,
) -> EditorTierItem {
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: format_angle_cell(tier.angle_deg).into(),
name: tier.name.clone().into(),
indices: tier
.indices
.iter()
.copied()
.map(format_index_value)
.collect::<Vec<_>>()
.join(", ")
.into(),
constraint_kind,
constraint_text: constraint_text.into(),
mast: info.mast.into(),
mast_mm: info.mast_mm.into(),
mast_full: info.mast_full.into(),
strategy: info.strategy.into(),
strategy_is_uncertain: info.strategy_is_uncertain,
strategy_detail: info.strategy_detail.into(),
needs_anchor: info.needs_anchor,
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(),
block: block_label(tier_blocks[index]).into(),
margin_text: margin_text.into(),
risk_level,
meet_partners_text: meet_partners_text(design, index).into(),
warning_text: warnings.get(&index).cloned().unwrap_or_default().into(),
multi_selected: false,
}
}
pub(super) fn tier_items_stale(design: &Design, n_d: f64) -> Vec<EditorTierItem> {
let tier_blocks = classify_blocks(&design.meet_tier_inputs());
let warnings = warning_text_by_tier(&manufacturability_warnings_tagged(design, None));
design
.tiers
.iter()
.enumerate()
.map(|(index, tier)| {
let info = TierSolveInfo {
mast: "-".to_string(),
mast_mm: String::new(),
mast_full: String::new(),
strategy: "not solved".to_string(),
strategy_is_uncertain: true,
strategy_detail: String::new(),
needs_anchor: false,
};
build_tier_row(design, index, tier, n_d, info, &tier_blocks, &warnings)
})
.collect()
}
pub(super) fn tier_items(design: &Design, n_d: f64) -> Vec<EditorTierItem> {
let solved = design.solve();
match &solved {
Ok(rows) => tier_items_from_solved(design, rows, n_d),
Err(missing) => {
let missing_tier_blocks = missing_anchor_tier_blocks(missing, design);
let tier_blocks = classify_blocks(&design.meet_tier_inputs());
let warnings = warning_text_by_tier(&manufacturability_warnings_tagged(design, None));
design
.tiers
.iter()
.enumerate()
.map(|(index, tier)| {
let info = missing_tier_blocks.get(&index).map_or_else(
|| TierSolveInfo {
mast: "-".to_string(),
mast_mm: String::new(),
mast_full: String::new(),
strategy: "blocked".to_string(),
strategy_is_uncertain: true,
strategy_detail: "Another block in this design has no anchor -- \
see the validation banner above."
.to_string(),
needs_anchor: false,
},
|&block| TierSolveInfo {
mast: "?".to_string(),
mast_mm: String::new(),
mast_full: String::new(),
strategy: "no anchor yet".to_string(),
strategy_is_uncertain: true,
strategy_detail: MissingAnchor::block_sentence(block),
needs_anchor: true,
},
);
build_tier_row(design, index, tier, n_d, info, &tier_blocks, &warnings)
})
.collect()
}
}
}
pub(super) fn tier_items_from_solved(
design: &Design,
solved: &[SolvedTier],
n_d: f64,
) -> Vec<EditorTierItem> {
let tier_blocks = classify_blocks(&design.meet_tier_inputs());
let warnings = warning_text_by_tier(&manufacturability_warnings_tagged(design, Some(solved)));
let mm_per_unit = design.yield_report(solved).mm_per_unit;
design
.tiers
.iter()
.enumerate()
.map(|(index, tier)| {
let (label, uncertain) = strategy_label(solved[index].strategy);
let info = TierSolveInfo {
mast: format!("{:.4}", solved[index].mast),
mast_mm: mm_per_unit.map_or_else(String::new, |mm| {
format!("{:.3} mm", solved[index].mast * mm)
}),
mast_full: format!("{}", solved[index].mast),
strategy: label.to_string(),
strategy_is_uncertain: uncertain,
strategy_detail: solved[index].detail.clone(),
needs_anchor: false,
};
build_tier_row(design, index, tier, n_d, info, &tier_blocks, &warnings)
})
.collect()
}
const fn block_label(block: Block) -> &'static str {
match block {
Block::Crown => "Crown",
Block::Pavilion => "Pavilion",
Block::Girdle => "Girdle",
}
}
pub(super) fn manufacturability_warnings_by_tier(
design: &Design,
solved: Option<&[SolvedTier]>,
) -> Vec<(usize, String)> {
indicatrix_cut_core::manufacturability::check_manufacturability_available(
design,
solved,
indicatrix_cut_core::manufacturability::DEFAULT_MIN_FACET_AREA_FRACTION_OF_W2,
)
.iter()
.map(|warning| (warning.tier_index(), warning.to_string()))
.collect()
}
pub(super) fn manufacturability_warnings_tagged(
design: &Design,
solved: Option<&[SolvedTier]>,
) -> Vec<(usize, String)> {
let pairs = manufacturability_warnings_by_tier(design, solved);
if solved.is_some() {
return pairs;
}
pairs
.into_iter()
.map(|(index, text)| (index, format!("(pre-solve) {text}")))
.collect()
}
fn warning_text_by_tier(pairs: &[(usize, String)]) -> BTreeMap<usize, String> {
let mut grouped: BTreeMap<usize, Vec<String>> = BTreeMap::new();
for (tier_index, text) in pairs {
grouped.entry(*tier_index).or_default().push(text.clone());
}
grouped
.into_iter()
.map(|(index, texts)| (index, texts.join("; ")))
.collect()
}
pub(super) fn manufacturability_warning_lines(design: &Design) -> Vec<String> {
let solved = design.solve().ok();
manufacturability_warnings_tagged(design, solved.as_deref())
.into_iter()
.map(|(_, text)| text)
.collect()
}
pub(super) fn manufacturability_warning_lines_from_solved(
design: &Design,
solved: &[SolvedTier],
) -> Vec<String> {
manufacturability_warnings_tagged(design, Some(solved))
.into_iter()
.map(|(_, text)| text)
.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,
current: &MaterialSelection,
) -> 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,
current.with_specific_gravity_override(specific_gravity_override),
))
}
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());
};
yield_report_texts_from_solved(design, &solved)
}
pub(super) fn yield_report_texts_from_solved(
design: &Design,
solved: &[SolvedTier],
) -> (String, String, String, String) {
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 proportions_texts(design: &Design) -> (String, String, String, String, String) {
let dash = || "-".to_string();
let Ok(solved) = design.solve() else {
return (dash(), dash(), dash(), dash(), dash());
};
let Some(proportions) = design.stone_proportions(&solved) else {
return (dash(), dash(), dash(), dash(), dash());
};
let mm_per_unit = design.yield_report(&solved).mm_per_unit;
let proportions = mm_per_unit.map_or(proportions, |mm| proportions.to_mm(mm));
let unit = if mm_per_unit.is_some() { " mm" } else { "" };
let table_percent_text = proportions
.table_percent
.map_or_else(dash, |v| format!("{v:.1}%"));
let crown_height_text = proportions
.crown_height
.map_or_else(dash, |v| format!("{v:.3}{unit}"));
let pavilion_depth_text = proportions
.pavilion_depth
.map_or_else(dash, |v| format!("{v:.3}{unit}"));
let total_depth_text = format!("{:.3}{unit}", proportions.total_depth);
let length_to_width_text = proportions
.length_to_width
.map_or_else(dash, |v| format!("{v:.3}"));
(
table_percent_text,
crown_height_text,
pavilion_depth_text,
total_depth_text,
length_to_width_text,
)
}
#[must_use]
pub(super) fn preform_mm_texts(design: &Design) -> (String, String) {
let Ok(solved) = design.solve() else {
return (String::new(), String::new());
};
let Some(mm_per_unit) = design.yield_report(&solved).mm_per_unit else {
return (String::new(), String::new());
};
let preform = &design.preform;
(
format!("\u{2248} {:.3} mm", preform.half_width * mm_per_unit),
format!("\u{2248} {:.3} mm", preform.depth * mm_per_unit),
)
}
pub(super) fn design_label_text(asc_filename: Option<&str>) -> String {
asc_filename.map_or_else(|| "Untitled design".to_string(), str::to_string)
}
pub(super) fn cutting_schedule_rows(design: &Design, solved: &[SolvedTier]) -> Vec<AngleItem> {
let tier_blocks = classify_blocks(&design.meet_tier_inputs());
design
.to_asc_schedule_from_solved(solved)
.tiers
.into_iter()
.enumerate()
.map(|(order_idx, tier)| AngleItem {
order_idx: order_idx as i32,
side: match tier_blocks.get(order_idx) {
Some(Block::Crown) => 1,
Some(Block::Pavilion) => -1,
_ => 0,
},
facet: if tier.name.is_empty() {
format!("#{}", order_idx + 1)
} else {
tier.name
}
.into(),
angle: format_angle_cell(tier.angle_deg).into(),
index_val: tier
.indices
.into_iter()
.map(format_index_value)
.collect::<Vec<_>>()
.join(", ")
.into(),
notes: tier.notes.into(),
})
.collect()
}
fn external_proportions_note(m: &SolidMetrics, mm_per_unit: Option<f64>) -> String {
if m.width_axis <= 0.0 {
return String::new();
}
let mut parts = vec![
format!("L/W {:.3}", m.length_axis / m.width_axis),
format!("H/W {:.3}", m.total_height / m.width_axis),
];
if let Some(c) = m.crown_height {
parts.push(format!("C/W {:.3}", c / m.width_axis));
}
if let Some(p) = m.pavilion_depth {
parts.push(format!("P/W {:.3}", p / m.width_axis));
}
if let Some(mm) = mm_per_unit {
parts.push(format!(
"{:.2} x {:.2} mm",
m.width_axis * mm,
m.total_height * mm
));
}
format!(", {}", parts.join(", "))
}
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| {
let mm_per_unit = design
.solve()
.ok()
.and_then(|solved| design.yield_report(&solved).mm_per_unit);
format!(
" -- volume {:.4} (model units^3){}",
m.volume,
external_proportions_note(&m, mm_per_unit)
)
})
.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} (model units^3)"),
);
let suspects_note = design.solve().map_or(String::new(), |solved| {
degenerate_suspects_note(design, &solved)
});
(
format!(
"Degenerate: only {vertex_count} distinct vertex(es), volume \
{volume_text}{suspects_note}."
),
true,
)
}
Ok(SolidStatus::Unbounded { escaping }) => {
let tier_text = design.solve().map_or_else(
|_| format!("plane(s) {escaping:?}"),
|solved| escaping_tier_text(design, &solved, &escaping),
);
(
format!("Unbounded: {tier_text} never close the solid."),
true,
)
}
Err(missing) => (missing.to_string(), true),
}
}
pub(super) fn status_text_and_is_problem_from_solved(
design: &Design,
solved: &[SolvedTier],
) -> (String, bool) {
let planes = design.planes_from_solved(solved);
match build_solid_mesh(&planes) {
SolidStatus::Closed(_) => {
let mm_per_unit = design.yield_report(solved).mm_per_unit;
let volume_note = measure_solid(&planes)
.map(|m| {
format!(
" -- volume {:.4} (model units^3){}",
m.volume,
external_proportions_note(&m, mm_per_unit)
)
})
.unwrap_or_default();
(format!("Closed solid{volume_note}."), false)
}
SolidStatus::Degenerate {
vertex_count,
volume,
} => {
let volume_text = volume.map_or_else(
|| "non-finite".to_string(),
|v| format!("{v:.4} (model units^3)"),
);
let suspects_note = degenerate_suspects_note(design, solved);
(
format!(
"Degenerate: only {vertex_count} distinct vertex(es), volume \
{volume_text}{suspects_note}."
),
true,
)
}
SolidStatus::Unbounded { escaping } => {
let tier_text = escaping_tier_text(design, solved, &escaping);
(
format!("Unbounded: {tier_text} never close the solid."),
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()
}
#[must_use]
pub(super) fn tier_matches_filter(haystack: &str, filter: &str) -> bool {
let filter = filter.trim();
if filter.is_empty() {
return true;
}
haystack.to_lowercase().contains(&filter.to_lowercase())
}
#[must_use]
pub(super) fn ri_source_text(material: &MaterialSelection, custom: &[GemMaterial]) -> String {
if let Some(value) = material.refractive_index_override {
return format!("typed override ({value:.4})");
}
if let Some(name) = material.name.as_deref() {
if custom.iter().any(|gem| gem.name.eq_ignore_ascii_case(name)) {
return format!("custom catalogue material '{name}'");
}
if indicatrix_cut_core::built_in_refractive_index(name).is_some() {
return format!("built-in material '{name}'");
}
return format!(
"'{name}' is not a recognized material -- falling back to this design's legacy imported value"
);
}
"no material selected -- using this design's legacy imported value".to_string()
}
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 ratio = remap_ratio(from_gear, to_gear);
let non_integral: Vec<bool> = design
.tiers
.iter()
.map(|t| t.indices.iter().any(|&i| (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 inline_tests {
use super::{OrbitUnit, external_proportions_note, orbit_status_text};
use indicatrix::geometry::stone_metrics::SolidMetrics;
fn unit(members: usize, expected_len: usize) -> OrbitUnit {
OrbitUnit {
members: (0..members).map(|i| i as f64).collect(),
expected_len,
}
}
#[test]
fn orbit_status_text_reports_a_clean_multi_unit_fold_as_complete() {
let (text, incomplete) = orbit_status_text(&[unit(4, 4), unit(4, 4)]);
assert_eq!(text, "2 orbits");
assert!(!incomplete);
}
#[test]
fn orbit_status_text_counts_how_many_units_are_incomplete() {
let (text, incomplete) = orbit_status_text(&[unit(4, 4), unit(2, 4), unit(4, 4)]);
assert_eq!(text, "3 orbits (1 incomplete)");
assert!(incomplete);
}
#[test]
fn orbit_status_text_reports_a_mixed_fold_with_no_complete_unit_as_not_symmetric() {
let (text, incomplete) = orbit_status_text(&[unit(2, 4), unit(4, 8)]);
assert_eq!(text, "not symmetric");
assert!(incomplete);
}
fn metrics(
width_axis: f64,
length_axis: f64,
total_height: f64,
crown_height: Option<f64>,
pavilion_depth: Option<f64>,
) -> SolidMetrics {
SolidMetrics {
volume: 1.0,
width_axis,
length_axis,
width_caliper: width_axis,
length_caliper: length_axis,
total_height,
crown_height,
pavilion_depth,
girdle_thickness: None,
vertex_count: 8,
}
}
#[test]
fn external_proportions_note_reports_lw_hw_cw_pw_when_a_girdle_is_present() {
let m = metrics(2.0, 2.0, 1.2, Some(0.4), Some(0.6));
let note = external_proportions_note(&m, None);
assert!(note.contains("L/W 1.000"));
assert!(note.contains("H/W 0.600"));
assert!(note.contains("C/W 0.200"));
assert!(note.contains("P/W 0.300"));
}
#[test]
fn external_proportions_note_omits_cw_pw_without_a_live_girdle_facet() {
let m = metrics(2.0, 2.0, 1.2, None, None);
let note = external_proportions_note(&m, None);
assert!(note.contains("L/W"));
assert!(note.contains("H/W"));
assert!(!note.contains("C/W"));
assert!(!note.contains("P/W"));
}
#[test]
fn external_proportions_note_is_empty_for_a_zero_width_solid() {
let m = metrics(0.0, 2.0, 1.2, None, None);
assert_eq!(external_proportions_note(&m, None).len(), 0);
}
#[test]
fn external_proportions_note_appends_absolute_mm_when_a_scale_is_known() {
let m = metrics(2.0, 2.0, 1.2, None, None);
let note = external_proportions_note(&m, Some(2.5));
assert!(note.contains("5.00 x 3.00 mm"), "note was: {note}");
}
#[test]
fn external_proportions_note_omits_mm_clause_without_a_known_scale() {
let m = metrics(2.0, 2.0, 1.2, None, None);
let note = external_proportions_note(&m, None);
assert!(!note.contains("mm"));
}
}
#[cfg(test)]
mod tests;