use super::{auto_solve, material_lookup::EditorMaterialLookup};
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, DesignSolveError, Edit, EditError, FreshDesignSpec, History,
MaterialSelection, MissingAnchor, OptimizeOutcome, OrbitUnit, PreformSpec, RemapRounding, Risk,
TierTarget, 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];
const ANCHOR_EXPLAINER_SUPPRESS_KEY: &str = "anchor_explainer";
thread_local! {
static ANCHOR_EXPLAINER_DECIDED_THIS_SESSION: std::cell::Cell<bool> =
const { std::cell::Cell::new(false) };
}
fn anchor_explainer_is_suppressed() -> bool {
let settings_path = crate::settings::store::default_settings_path();
crate::settings::store::load_or_default(&settings_path)
.settings
.is_confirm_suppressed(ANCHOR_EXPLAINER_SUPPRESS_KEY)
}
pub(super) fn anchor_explainer_suppress_permanently() {
let settings_path = crate::settings::store::default_settings_path();
let mut file = crate::settings::store::load_or_default(&settings_path);
file.settings
.suppress_confirm(ANCHOR_EXPLAINER_SUPPRESS_KEY);
let _ = crate::settings::store::save(&settings_path, &file);
}
#[must_use]
pub(super) fn should_open_anchor_explainer(design_has_missing_anchor: bool) -> bool {
if !design_has_missing_anchor {
return false;
}
if ANCHOR_EXPLAINER_DECIDED_THIS_SESSION.with(std::cell::Cell::get) {
return false;
}
ANCHOR_EXPLAINER_DECIDED_THIS_SESSION.with(|decided| decided.set(true));
!anchor_explainer_is_suppressed()
}
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) deep_solve_result_generation: Option<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>,
pub(super) material_combo_cache: RefCell<MaterialComboCache>,
}
#[derive(Default)]
pub(super) struct PushedScratch {
material: Option<MaterialSelection>,
gear_teeth: Option<i32>,
symmetry: Option<(u32, bool)>,
preform: Option<PreformSpec>,
girdle_diameter_mm: GirdleDiameterPush,
meta: Option<(Vec<String>, Vec<String>, f64)>,
}
#[derive(Default)]
pub(super) struct MaterialComboCache {
signature: Option<Vec<String>>,
options: Vec<String>,
}
#[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,
pub(super) meta: 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)),
deep_solve_result_generation: 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()),
material_combo_cache: RefCell::new(MaterialComboCache::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)),
deep_solve_result_generation: 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()),
material_combo_cache: RefCell::new(MaterialComboCache::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 meta_now = (
design.meta.headers.clone(),
design.meta.footnotes.clone(),
design.meta.gear_reference_angle,
);
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),
meta: cache.meta.as_ref() != Some(&meta_now),
};
*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),
meta: Some(meta_now),
};
delta
}
pub(super) fn material_combo_options(&self, custom: &[GemMaterial]) -> Vec<String> {
let signature: Vec<String> = custom.iter().map(|m| m.name.clone()).collect();
let mut cache = self.material_combo_cache.borrow_mut();
if cache.signature.as_ref() != Some(&signature) {
cache.options = design_material_options(custom);
cache.signature = Some(signature);
}
cache.options.clone()
}
}
fn constraint_kind_and_text(
constraint: &MeetConstraint,
target: Option<TierTarget>,
) -> (i32, String) {
if let Some(target) = target {
return match target {
TierTarget::DepthMm(mm) => (3, mm.to_string()),
TierTarget::GirdleThicknessMm(mm) => (4, mm.to_string()),
TierTarget::TableWidthMm(mm) => (5, mm.to_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()
}
#[must_use]
pub(super) const fn result_is_stale(
result_generation: Option<u64>,
current_generation: u64,
) -> bool {
match result_generation {
Some(g) => g != current_generation,
None => false,
}
}
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>) {
push_rows(&ui.global::<EditorModel>().get_tiers(), rows, |model| {
ui.global::<EditorModel>().set_tiers(model);
});
ui.global::<EditorModel>().invoke_recompute_dirty();
}
pub(super) fn push_rows<T: Clone + 'static>(
current: &ModelRc<T>,
rows: Vec<T>,
set: impl FnOnce(ModelRc<T>),
) {
if current.row_count() == rows.len() {
for (index, row) in rows.into_iter().enumerate() {
current.set_row_data(index, row);
}
} else {
set(ModelRc::new(VecModel::from(rows)));
}
}
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,
pavilion_partner_deg: Option<f64>,
) -> (String, i32) {
let risk_level_of = |risk: Risk| match risk {
Risk::Safe => 0,
Risk::Marginal => 1,
Risk::Windows => 2,
};
if tier_angle_deg < 0.0 {
let margin = tier_margin_deg(tier_angle_deg, n_d);
let risk_level = risk_level_of(windowing_risk(tier_angle_deg, n_d));
(format!("{margin:+.1}\u{b0}"), risk_level)
} else if tier_angle_deg > 0.0 {
let Some(pavilion_deg) = pavilion_partner_deg else {
return (String::new(), -1);
};
let margin =
indicatrix_cut_core::crown_window_margin_deg(pavilion_deg, tier_angle_deg, n_d);
let risk_level = risk_level_of(indicatrix_cut_core::crown_windowing_risk(
pavilion_deg,
tier_angle_deg,
n_d,
));
(format!("{margin:+.1}\u{b0} (est.)"), risk_level)
} else {
(String::new(), -1)
}
}
pub(super) fn representative_crown_and_pavilion_angles_deg(
design: &Design,
) -> (Option<f64>, Option<f64>) {
let mut crown_main: Option<f64> = None;
let mut crown_largest: Option<f64> = None;
let mut pavilion_main: Option<f64> = None;
let mut pavilion_largest: Option<f64> = None;
for tier in &design.tiers {
let angle = tier.angle_deg;
let is_main = tier.name.to_lowercase().contains("main");
if angle > 0.0 {
crown_largest = Some(crown_largest.map_or(angle, |m| angle.max(m)));
if is_main {
crown_main = Some(crown_main.map_or(angle, |m| angle.max(m)));
}
} else if angle < 0.0 {
let magnitude = angle.abs();
pavilion_largest = Some(pavilion_largest.map_or(magnitude, |m| magnitude.max(m)));
if is_main {
pavilion_main = Some(pavilion_main.map_or(magnitude, |m| magnitude.max(m)));
}
}
}
(
crown_main.or(crown_largest),
pavilion_main.or(pavilion_largest),
)
}
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,
}
struct RowContext<'a> {
tier_blocks: &'a [Block],
warnings: &'a BTreeMap<usize, String>,
pavilion_partner_deg: Option<f64>,
}
fn build_tier_row(
design: &Design,
index: usize,
tier: &ConstraintTier,
n_d: f64,
info: TierSolveInfo,
row_context: &RowContext<'_>,
) -> EditorTierItem {
let tier_blocks = row_context.tier_blocks;
let warnings = row_context.warnings;
let pavilion_partner_deg = row_context.pavilion_partner_deg;
let (constraint_kind, constraint_text) =
constraint_kind_and_text(&tier.constraint, design.tier_target(index));
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, pavilion_partner_deg);
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,
proposed_angle: String::new().into(),
}
}
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));
let (_, pavilion_partner_deg) = representative_crown_and_pavilion_angles_deg(design);
let row_context = RowContext {
tier_blocks: &tier_blocks,
warnings: &warnings,
pavilion_partner_deg,
};
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, &row_context)
})
.collect()
}
#[must_use]
pub(super) fn tier_items_stale_with_last_solved(
design: &Design,
n_d: f64,
last_solved: Option<&[SolvedTier]>,
dirty: &BTreeSet<usize>,
) -> Vec<EditorTierItem> {
let last_solved = last_solved.filter(|solved| solved.len() == design.tiers.len());
let Some(last_solved) = last_solved else {
return tier_items_stale(design, n_d);
};
let tier_blocks = classify_blocks(&design.meet_tier_inputs());
let warnings = warning_text_by_tier(&manufacturability_warnings_tagged(design, None));
let mm_per_unit = design.yield_report(last_solved).mm_per_unit;
let (_, pavilion_partner_deg) = representative_crown_and_pavilion_angles_deg(design);
let row_context = RowContext {
tier_blocks: &tier_blocks,
warnings: &warnings,
pavilion_partner_deg,
};
design
.tiers
.iter()
.enumerate()
.map(|(index, tier)| {
let info = if dirty.contains(&index) {
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,
}
} else {
let (label, _) = strategy_label(last_solved[index].strategy);
TierSolveInfo {
mast: format!("{:.4}", last_solved[index].mast),
mast_mm: mm_per_unit.map_or_else(String::new, |mm| {
format!("{:.3} mm", last_solved[index].mast * mm)
}),
mast_full: format!("{}", last_solved[index].mast),
strategy: format!("stale ({label})"),
strategy_is_uncertain: true,
strategy_detail: last_solved[index].detail.clone(),
needs_anchor: false,
}
};
build_tier_row(design, index, tier, n_d, info, &row_context)
})
.collect()
}
pub(super) fn apply_proposed_angles(
tiers: &mut [EditorTierItem],
changes: &[indicatrix_cut_core::AngleChange],
) {
for change in changes {
if let Some(row) = tiers.get_mut(change.index) {
row.proposed_angle = format_angle_cell(change.to_deg).into();
}
}
}
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(err) => {
let missing_tier_blocks = match err {
DesignSolveError::MissingAnchor(missing) => {
missing_anchor_tier_blocks(missing, design)
}
DesignSolveError::Mismatch(_)
| DesignSolveError::Solve(_)
| DesignSolveError::Target(_) => BTreeMap::new(),
};
let blocked_detail = if matches!(err, DesignSolveError::MissingAnchor(_)) {
"Another block in this design has no anchor -- see the validation banner above."
.to_string()
} else {
err.to_string()
};
let tier_blocks = classify_blocks(&design.meet_tier_inputs());
let warnings = warning_text_by_tier(&manufacturability_warnings_tagged(design, None));
let (_, pavilion_partner_deg) = representative_crown_and_pavilion_angles_deg(design);
let row_context = RowContext {
tier_blocks: &tier_blocks,
warnings: &warnings,
pavilion_partner_deg,
};
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: blocked_detail.clone(),
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, &row_context)
})
.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;
let (_, pavilion_partner_deg) = representative_crown_and_pavilion_angles_deg(design);
let row_context = RowContext {
tier_blocks: &tier_blocks,
warnings: &warnings,
pavilion_partner_deg,
};
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, &row_context)
})
.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 builtin_preset_names() -> Vec<String> {
std::iter::once("(none)".to_string())
.chain(indicatrix_cut_core::MaterialCatalogue::build(&[]).names())
.collect()
}
pub(super) fn material_index_from_name(name: Option<&str>) -> i32 {
let names = builtin_preset_names();
name.and_then(|n| names.iter().position(|p| p == n))
.map_or(0, |i| i as i32)
}
pub(super) fn material_name_from_index(index: i32) -> Option<String> {
let names = builtin_preset_names();
usize::try_from(index)
.ok()
.and_then(|i| names.get(i).cloned())
.filter(|name| name != "(none)")
}
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,
custom_sg: &[(String, f64)],
) -> (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, custom_sg)
}
pub(super) fn yield_report_texts_from_solved(
design: &Design,
solved: &[SolvedTier],
custom_sg: &[(String, f64)],
) -> (String, String, String, String) {
let catalogue = EditorMaterialLookup::new(&[]).with_specific_gravity(custom_sg);
let report = design.yield_report_with(solved, &catalogue);
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();
if design.tiers.is_empty() {
return (dash(), dash(), dash(), dash(), dash());
}
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 girdle_and_ratio_texts(design: &Design) -> (String, String, String, String) {
let dash = || "-".to_string();
if design.tiers.is_empty() {
return (dash(), dash(), dash(), dash());
}
let Ok(solved) = design.solve() else {
return (dash(), dash(), dash(), dash());
};
let Some(proportions) = design.stone_proportions(&solved) else {
return (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 girdle_thickness_text = proportions
.girdle_thickness
.map_or_else(dash, |v| format!("{v:.3}{unit}"));
let crown_to_width_percent_text = proportions
.crown_to_width_percent
.map_or_else(dash, |v| format!("{v:.1}%"));
let pavilion_to_width_percent_text = proportions
.pavilion_to_width_percent
.map_or_else(dash, |v| format!("{v:.1}%"));
let girdle_to_width_percent_text = proportions
.girdle_to_width_percent
.map_or_else(dash, |v| format!("{v:.1}%"));
(
girdle_thickness_text,
crown_to_width_percent_text,
pavilion_to_width_percent_text,
girdle_to_width_percent_text,
)
}
pub(super) struct ProportionVerdict {
pub(super) level: i32,
pub(super) reason: String,
}
pub(super) struct ProportionVerdicts {
pub(super) table_pct: ProportionVerdict,
pub(super) crown_angle: ProportionVerdict,
pub(super) pavilion_angle: ProportionVerdict,
pub(super) total_depth_pct: ProportionVerdict,
pub(super) girdle_pct: ProportionVerdict,
}
const fn verdict_none() -> ProportionVerdict {
ProportionVerdict {
level: -1,
reason: String::new(),
}
}
fn verdict_from(
value: Option<f64>,
shape: indicatrix_cut_core::ShapeClass,
material: indicatrix_cut_core::MaterialClass,
metric: indicatrix_cut_core::ProportionMetric,
) -> ProportionVerdict {
let Some(v) = value else {
return verdict_none();
};
let Some((verdict, window)) =
indicatrix_cut_core::proportions_windows::verdict_for(shape, material, metric, v)
else {
return verdict_none();
};
let level = match verdict {
indicatrix_cut_core::Verdict::Within => 0,
indicatrix_cut_core::Verdict::Near => 1,
indicatrix_cut_core::Verdict::Outside => 2,
};
ProportionVerdict {
level,
reason: window.reason.to_string(),
}
}
const fn shape_class_for(
meta: &indicatrix_cut_core::ScheduleMeta,
) -> indicatrix_cut_core::ShapeClass {
if meta.symmetry_order >= 6 && meta.mirror {
indicatrix_cut_core::ShapeClass::Round
} else {
indicatrix_cut_core::ShapeClass::Other
}
}
#[must_use]
pub(super) fn proportion_verdicts(
design: &Design,
proportions: &indicatrix::geometry::stone_metrics::StoneProportions,
n_d: f64,
) -> ProportionVerdicts {
let shape = shape_class_for(&design.meta);
let material = indicatrix_cut_core::MaterialClass::from_ri(n_d);
let (crown_angle_deg, pavilion_angle_deg) =
representative_crown_and_pavilion_angles_deg(design);
let total_depth_pct = match (
proportions.crown_to_width_percent,
proportions.pavilion_to_width_percent,
proportions.girdle_to_width_percent,
) {
(Some(c), Some(p), Some(g)) => Some(c + p + g),
_ => None,
};
ProportionVerdicts {
table_pct: verdict_from(
proportions.table_percent,
shape,
material,
indicatrix_cut_core::ProportionMetric::TablePercent,
),
crown_angle: verdict_from(
crown_angle_deg,
shape,
material,
indicatrix_cut_core::ProportionMetric::CrownAngle,
),
pavilion_angle: verdict_from(
pavilion_angle_deg,
shape,
material,
indicatrix_cut_core::ProportionMetric::PavilionAngle,
),
total_depth_pct: verdict_from(
total_depth_pct,
shape,
material,
indicatrix_cut_core::ProportionMetric::TotalDepthPercent,
),
girdle_pct: verdict_from(
proportions.girdle_to_width_percent,
shape,
material,
indicatrix_cut_core::ProportionMetric::GirdleThicknessPercent,
),
}
}
#[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),
)
}
#[must_use]
pub(super) fn preform_y_offset_mm_text(preform_y_offset: f64, mm_per_unit: Option<f64>) -> String {
mm_per_unit.map_or_else(String::new, |mm_per_unit| {
format!("{:.2}", preform_y_offset * 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());
let schedule = match design.try_to_asc_schedule_from_solved(solved) {
Ok(schedule) => schedule,
Err(mismatch) => {
tracing::warn!(%mismatch, "cutting_schedule_rows: solved masts do not match this design's tier count");
return Vec::new();
}
};
schedule
.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 solved = design.solve().ok();
if solved
.as_deref()
.is_some_and(auto_solve::likely_hit_plane_cap)
&& let Some(message) = auto_solve::too_many_planes_message(design)
{
return (message, true);
}
let suspects_note = solved.as_ref().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 solved = design.solve().ok();
if solved
.as_deref()
.is_some_and(auto_solve::likely_hit_plane_cap)
&& let Some(message) = auto_solve::too_many_planes_message(design)
{
return (message, true);
}
let tier_text = solved.as_ref().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,
} => {
if auto_solve::likely_hit_plane_cap(solved)
&& let Some(message) = auto_solve::too_many_planes_message(design)
{
return (message, true);
}
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 } => {
if auto_solve::likely_hit_plane_cap(solved)
&& let Some(message) = auto_solve::too_many_planes_message(design)
{
return (message, true);
}
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()
}
const CUSTOM_BUILTIN_COLLISION_SUFFIX: &str = " (custom)";
pub(super) fn design_material_options(custom: &[GemMaterial]) -> Vec<String> {
let mut options: Vec<String> = builtin_preset_names();
for material in custom {
if options
.iter()
.any(|name| name.eq_ignore_ascii_case(&material.name))
{
options.push(format!(
"{}{CUSTOM_BUILTIN_COLLISION_SUFFIX}",
material.name
));
} else {
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.strip_suffix(CUSTOM_BUILTIN_COLLISION_SUFFIX)
.unwrap_or(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}")
.map(|name| {
name.strip_suffix(CUSTOM_BUILTIN_COLLISION_SUFFIX)
.map_or_else(|| name.clone(), str::to_string)
})
}
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::{
BTreeSet, EditorState, OrbitUnit, apply_proposed_angles, design_material_index_from_name,
design_material_name_from_index, design_material_options, external_proportions_note,
girdle_and_ratio_texts, orbit_status_text, preform_y_offset_mm_text, tier_items_stale,
tier_items_stale_with_last_solved,
};
use indicatrix::{geometry::stone_metrics::SolidMetrics, optics::materials::GemMaterial};
use indicatrix_cut_core::{Design, PreformSpec};
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"));
}
fn fixture_design() -> Design {
Design::fresh(PreformSpec::cylinder(96, 1.5, 1.0, 1.5), 96, 8, 1.54)
}
#[test]
fn girdle_and_ratio_texts_dashes_out_a_design_with_no_tiers() {
let design = fixture_design();
assert_eq!(
girdle_and_ratio_texts(&design),
(
"-".to_string(),
"-".to_string(),
"-".to_string(),
"-".to_string()
)
);
}
#[test]
fn design_material_options_lists_a_builtin_colliding_custom_material_under_a_suffixed_label() {
let mut custom = GemMaterial::diamond();
custom.name = "Diamond".to_string();
let options = design_material_options(std::slice::from_ref(&custom));
assert!(
options.iter().any(|o| o == "Diamond (custom)"),
"options was: {options:?}"
);
assert!(options.iter().any(|o| o == "Diamond"));
}
#[test]
fn material_combo_options_with_no_custom_materials_lists_the_builtins_on_first_call() {
let state = EditorState::fresh();
let options = state.material_combo_options(&[]);
assert_eq!(options, design_material_options(&[]));
assert_eq!(options.first().map(String::as_str), Some("(none)"));
assert!(
options.iter().any(|o| o == "Diamond"),
"options was: {options:?}"
);
assert_eq!(state.material_combo_options(&[]), options);
}
#[test]
fn design_material_options_lists_a_non_colliding_custom_material_plainly() {
let mut custom = GemMaterial::diamond();
custom.name = "My Garnet".to_string();
let options = design_material_options(std::slice::from_ref(&custom));
assert!(options.iter().any(|o| o == "My Garnet"));
assert!(!options.iter().any(|o| o.contains("(custom)")));
}
#[test]
fn design_material_name_from_index_strips_the_collision_suffix() {
let mut custom = GemMaterial::diamond();
custom.name = "Diamond".to_string();
let options = design_material_options(std::slice::from_ref(&custom));
let index = options
.iter()
.position(|o| o == "Diamond (custom)")
.expect("labeled entry must exist");
assert_eq!(
design_material_name_from_index(index as i32, &options),
Some("Diamond".to_string()),
"the parsed name must be the real material name, not the display label, \
so it still resolves through EditorMaterialLookup's custom-over-built-in \
precedence"
);
}
#[test]
fn design_material_index_from_name_still_finds_the_plain_builtin_entry() {
let mut custom = GemMaterial::diamond();
custom.name = "Diamond".to_string();
let options = design_material_options(std::slice::from_ref(&custom));
assert_eq!(
design_material_index_from_name(Some("Diamond"), &options),
1
);
}
#[test]
fn tier_items_stale_with_last_solved_falls_back_to_dashes_with_no_cached_solve() {
let design = fixture_design();
let rows = tier_items_stale_with_last_solved(&design, 1.54, None, &BTreeSet::new());
assert!(rows.is_empty(), "a fresh design has no tiers to show");
}
fn scale_reference_tier(name: &str) -> indicatrix_cut_core::ConstraintTier {
indicatrix_cut_core::ConstraintTier {
angle_deg: 40.0,
name: name.to_string(),
indices: Vec::new(),
constraint: indicatrix::geometry::meet_solver::MeetConstraint::ScaleReference(1.0),
imported_meet: None,
original_notes: None,
detached: Vec::new(),
}
}
fn solved_tier(mast: f64) -> super::SolvedTier {
super::SolvedTier {
mast,
strategy: indicatrix::geometry::meet_solver::SolveStrategy::ScaleReference,
detail: "given (scale reference)".to_string(),
}
}
#[test]
fn tier_items_stale_with_last_solved_blanks_only_the_dirty_rows() {
let mut design = fixture_design();
design.tiers = vec![scale_reference_tier("Table"), scale_reference_tier("P1")];
let last_solved = vec![solved_tier(1.0), solved_tier(0.75)];
let dirty = BTreeSet::from([0]);
let rows = tier_items_stale_with_last_solved(&design, 1.54, Some(&last_solved), &dirty);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].mast.as_str(), "-");
assert_eq!(rows[0].strategy.as_str(), "not solved");
assert_eq!(rows[1].mast.as_str(), "0.7500");
assert_eq!(rows[1].strategy.as_str(), "stale (Scale reference)");
}
#[test]
fn tier_items_stale_with_last_solved_ignores_a_mismatched_tier_count() {
let mut design = fixture_design();
design.tiers = vec![scale_reference_tier("Table"), scale_reference_tier("P1")];
let stale_last_solved = vec![solved_tier(1.0)];
let rows = tier_items_stale_with_last_solved(
&design,
1.54,
Some(&stale_last_solved),
&BTreeSet::new(),
);
assert_eq!(rows.len(), 2);
assert!(rows.iter().all(|r| r.mast.as_str() == "-"));
}
#[test]
fn preform_y_offset_mm_text_is_empty_with_no_scale() {
assert_eq!(preform_y_offset_mm_text(0.3, None), "");
}
#[test]
fn preform_y_offset_mm_text_converts_through_mm_per_unit() {
assert_eq!(preform_y_offset_mm_text(0.3, Some(4.0)), "1.20");
}
#[test]
fn apply_proposed_angles_patches_only_the_named_rows() {
let mut design = fixture_design();
design.tiers = vec![scale_reference_tier("Table"), scale_reference_tier("P1")];
let mut rows = tier_items_stale(&design, 1.54);
let changes = vec![indicatrix_cut_core::AngleChange {
index: 1,
from_deg: 40.0,
to_deg: 41.25,
}];
apply_proposed_angles(&mut rows, &changes);
assert_eq!(rows[0].proposed_angle.as_str(), "");
assert_eq!(rows[1].proposed_angle.as_str(), "41.25");
}
#[test]
fn apply_proposed_angles_ignores_an_out_of_range_index() {
let design = fixture_design();
let mut rows = tier_items_stale(&design, 1.54);
let changes = vec![indicatrix_cut_core::AngleChange {
index: 5,
from_deg: 0.0,
to_deg: 12.0,
}];
apply_proposed_angles(&mut rows, &changes);
assert_eq!(rows.len(), 0);
}
}
#[cfg(test)]
mod tests;