use std::{
collections::BTreeSet,
fmt::Write as _,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
};
use indicatrix::{
color::body_color::{BodyColor, BodyColors, Illuminant, body_colors, delta_e_2000},
optics::{
chromophore::{
ChromophoreCatalogue, ChromophoreData, ColorRecipe, GlowStrength, HostData,
ResolvedBands, SolveResult, UV365_NM, UV395_NM, UvGlow, fluorescence_report,
garnet_optics, resolve, species_element,
},
materials::{CrystalSystem, GemMaterial, OpticalCharacter},
},
};
use indicatrix_cut_core::material::{
ActiveColor, ColorMode, RecipeHistory, built_in_specific_gravity,
};
use super::crystal_optics::{crystal_system_to_index, optical_character_to_index};
pub const DEFAULT_PATH_MM: f32 = 5.0;
pub const GIRDLE_PATH_FACTOR: f32 = 1.5;
pub const LOG_SPAN: f64 = 1e-3;
pub const STRENGTH_RANGE: (f64, f64) = (0.05, 10.0);
pub const REACHABLE_DELTA_E: f64 = 1.0;
pub const WARN_DELTA_E: f64 = 2.0;
pub const COLOR_CHANGE_DELTA_E: f64 = 5.0;
pub const PHYSICS_COLOR_UI: bool = cfg!(feature = "physical-color");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorSectionView {
Fantasy,
Physics,
FantasyOnly,
FantasyOverRecipe,
}
impl ColorSectionView {
#[must_use]
pub const fn shows_mode_toggle(self) -> bool {
matches!(self, Self::Fantasy | Self::Physics)
}
#[must_use]
pub const fn shows_physics_section(self) -> bool {
matches!(self, Self::Physics)
}
#[must_use]
pub const fn shows_fantasy_section(self) -> bool {
!matches!(self, Self::Physics)
}
#[must_use]
pub const fn shows_recipe_note(self) -> bool {
matches!(self, Self::FantasyOverRecipe)
}
}
#[must_use]
pub const fn color_section_view(feature_on: bool, mode_is_physics: bool) -> ColorSectionView {
match (feature_on, mode_is_physics) {
(true, false) => ColorSectionView::Fantasy,
(true, true) => ColorSectionView::Physics,
(false, false) => ColorSectionView::FantasyOnly,
(false, true) => ColorSectionView::FantasyOverRecipe,
}
}
#[must_use]
pub fn default_reference_path_mm(stone_width_mm: f32) -> f32 {
if stone_width_mm > 0.0 {
stone_width_mm * GIRDLE_PATH_FACTOR
} else {
DEFAULT_PATH_MM
}
}
#[must_use]
pub fn amount_to_pos(amount: f64, max: f64) -> f64 {
let c_min = LOG_SPAN * max;
if max <= 0.0 || amount <= c_min {
return 0.0;
}
(amount / c_min).log(max / c_min).clamp(0.0, 1.0)
}
#[must_use]
pub fn pos_to_amount(pos: f64, max: f64) -> f64 {
if max <= 0.0 || !pos.is_finite() || pos <= 0.0 {
return 0.0;
}
let c_min = LOG_SPAN * max;
(c_min * (max / c_min).powf(pos.min(1.0))).min(max)
}
#[must_use]
pub fn item_unit(host: &HostData, id: &str) -> String {
if host.end_members.iter().any(|m| m.id == id) {
"mol_fraction".to_string()
} else {
host.element_unit(id).unwrap_or_default()
}
}
#[must_use]
pub fn unit_label(unit: &str) -> String {
if let Some(oxide) = unit.strip_prefix("wt_pct_oxide:") {
return format!("wt% {oxide}");
}
match unit {
"ppm_site" => "ppm".to_string(),
"ppma_all" => "ppma".to_string(),
"mol_fraction" => "mol fraction".to_string(),
other => other.to_string(),
}
}
#[must_use]
pub fn format_amount(amount: f64, unit: &str) -> String {
let number = if unit.starts_with("wt_pct_oxide:") || unit == "mol_fraction" {
format!("{amount:.3}")
} else if amount >= 100.0 {
format!("{amount:.0}")
} else {
format!("{amount:.1}")
};
format!("{number} {}", unit_label(unit))
}
#[must_use]
pub fn drivers(c: &ChromophoreData) -> Vec<String> {
let mut ids = absorbers(c);
if c.kind == "ivct_pair"
&& let Some(comp) = &c.compensator
{
ids.push(comp.element.clone());
}
ids
}
fn absorbers(c: &ChromophoreData) -> Vec<String> {
match c.kind.as_str() {
"end_member" => c.end_member.iter().cloned().collect(),
"ivct_pair" => c
.partners
.iter()
.map(|p| species_element(p).to_string())
.collect(),
_ if c.conc_unit == "intensity" => Vec::new(),
_ => c.elements.clone(),
}
}
#[must_use]
pub fn typical_amount(host: &HostData, id: &str) -> f64 {
let max = host.element_conc_max(id);
if host.end_members.iter().any(|m| m.id == id) {
let typical = host
.chromophores
.iter()
.filter(|c| c.is_offered() && c.end_member.as_deref() == Some(id))
.filter_map(|c| c.conc_typical.map(|t| t[1]))
.fold(0.0_f64, f64::max);
return if typical > 0.0 {
typical.min(max)
} else {
0.25 * max
};
}
let unit = item_unit(host, id);
let n_in = host.n_site_for_unit(&unit);
let mut best = 0.0_f64;
if n_in > 0.0 {
for c in &host.chromophores {
if !c.is_offered() || !drivers(c).iter().any(|d| d == id) {
continue;
}
if let Some(t) = c.conc_typical {
best = best.max(t[1] * host.n_site_for_unit(&c.conc_unit) / n_in);
}
}
}
if best > 0.0 { best.min(max) } else { 0.1 * max }
}
#[must_use]
pub fn impossible_reason(host: &HostData, recipe: &ColorRecipe, id: &str) -> Option<String> {
let chromos: Vec<&ChromophoreData> = host
.chromophores
.iter()
.filter(|c| c.is_offered() && drivers(c).iter().any(|d| d == id))
.collect();
if chromos.is_empty() || host.element_conc_max(id) <= 0.0 {
return Some("no usable concentration range in the data".to_string());
}
let present: BTreeSet<&str> = recipe
.entries
.iter()
.filter(|e| e.amount > 0.0)
.map(|e| e.id.as_str())
.collect();
if host.end_members.iter().any(|m| m.id == id) {
let sum: f64 = host
.end_members
.iter()
.filter(|m| !m.colorless)
.map(|m| recipe.amount(&m.id))
.sum();
if sum >= 1.0 - 1e-9 {
return Some("end-member fractions already add up to 100 %".to_string());
}
}
let chromophore_present = |other: &ChromophoreData| -> bool {
absorbers(other)
.iter()
.any(|d| present.contains(d.as_str()))
};
let mut first_reason: Option<String> = None;
for c in &chromos {
let reason = chromophore_block(host, recipe, c, &chromophore_present)?;
first_reason.get_or_insert(reason);
}
first_reason
}
fn chromophore_block(
host: &HostData,
recipe: &ColorRecipe,
c: &ChromophoreData,
present: &dyn Fn(&ChromophoreData) -> bool,
) -> Option<String> {
for req in &c.requires {
if let Some(other) = host.chromophores.iter().find(|o| &o.id == req)
&& !present(other)
{
return Some(format!("needs {} first", other.id));
}
}
for exc in &c.excludes {
if let Some(other) = host.chromophores.iter().find(|o| &o.id == exc)
&& present(other)
{
return Some(format!("excluded by {}", other.id));
}
}
if host.is_treatment_created(&c.id) {
let creators: Vec<_> = host
.treatments
.iter()
.filter(|t| {
t.effects.iter().any(|e| {
e.effect_type == "create_centre" && e.centre_id.as_deref() == Some(&c.id)
})
})
.collect();
if !creators.iter().any(|t| recipe.treatments.contains(&t.id)) {
let name = creators.first().map_or("a treatment", |t| t.name.as_str());
return Some(format!("only exists after treatment: {name}"));
}
}
None
}
fn confidence_rank(confidence: &str) -> u8 {
match confidence {
"unknown" => 0,
"estimate" => 1,
"secondary" => 2,
_ => 3,
}
}
#[must_use]
pub fn item_sources(host: &HostData, id: &str) -> (String, String) {
let mut worst = "verified".to_string();
let mut text = String::new();
for c in &host.chromophores {
if !c.is_offered() || !drivers(c).iter().any(|d| d == id) {
continue;
}
let mut conf = c.confidence.clone();
for b in c.usable_bands() {
if let Some(bc) = &b.confidence
&& confidence_rank(bc) < confidence_rank(&conf)
{
conf.clone_from(bc);
}
}
if confidence_rank(&conf) < confidence_rank(&worst) {
worst.clone_from(&conf);
}
let _ = writeln!(text, "{} - confidence: {conf}", c.id);
let mut seen = BTreeSet::new();
for b in c.usable_bands() {
if let Some(src) = &b.source
&& seen.insert(src.clone())
&& seen.len() <= 6
{
let _ = writeln!(text, " \u{2022} {src}");
}
}
if seen.len() > 6 {
let _ = writeln!(text, " \u{2026} and {} more sources", seen.len() - 6);
}
let skipped = c.bands.len() - c.usable_bands().count();
if skipped > 0 {
let _ = writeln!(
text,
" {skipped} band(s) skipped (suspect or no coefficient)"
);
}
}
if text.is_empty() {
text.push_str("No sources recorded.");
}
(worst, text.trim_end().to_string())
}
#[must_use]
pub fn host_banner(host: &HostData) -> Option<String> {
let offered: Vec<&ChromophoreData> = host
.chromophores
.iter()
.filter(|c| c.is_offered())
.collect();
let weak = offered
.iter()
.filter(|c| !matches!(c.confidence.as_str(), "verified" | "measured"))
.count();
if weak == 0 {
return None;
}
let estimates = offered
.iter()
.filter(|c| matches!(c.confidence.as_str(), "estimate" | "unknown"))
.count();
let detail = if estimates > 0 {
format!(
"{weak} of {} chromophores are secondary-source or estimated ({estimates} estimated)",
offered.len()
)
} else {
format!(
"{weak} of {} chromophores are secondary-source",
offered.len()
)
};
Some(format!(
"Strengths uncalibrated \u{2014} approximate. {detail}."
))
}
#[derive(Debug, Clone, PartialEq)]
pub struct Prefill {
pub ri: f32,
pub dispersion: f32,
pub birefringence: f32,
pub specific_gravity: f32,
pub crystal_system_idx: i32,
pub optical_character_idx: i32,
pub biaxial_delta: f32,
}
fn builtin_prefill(host: &HostData, b: &GemMaterial) -> Prefill {
let sg = host
.materials
.iter()
.find_map(|n| built_in_specific_gravity(n))
.map_or(host.density_g_cm3 as f32, |s| s.representative as f32);
Prefill {
ri: b.dispersion.evaluate(589.3),
dispersion: b.dispersion.evaluate(486.1) - b.dispersion.evaluate(656.3),
birefringence: b.birefringence_delta,
specific_gravity: sg,
crystal_system_idx: crystal_system_to_index(b.crystal_system),
optical_character_idx: optical_character_to_index(b.optical_character),
biaxial_delta: b.biaxial_delta_beta_alpha.unwrap_or(0.0),
}
}
fn generic_prefill(host: &HostData) -> Prefill {
let oc = match host.optical.as_str() {
"isotropic" => OpticalCharacter::Isotropic,
"biaxial" => OpticalCharacter::BiaxialPositive,
_ => OpticalCharacter::UniaxialPositive,
};
let cs = match oc {
OpticalCharacter::Isotropic => CrystalSystem::Cubic,
OpticalCharacter::BiaxialPositive | OpticalCharacter::BiaxialNegative => {
CrystalSystem::Orthorhombic
}
_ => CrystalSystem::Hexagonal,
};
Prefill {
ri: 1.6,
dispersion: 0.015,
birefringence: 0.0,
specific_gravity: host.density_g_cm3 as f32,
crystal_system_idx: crystal_system_to_index(cs),
optical_character_idx: optical_character_to_index(oc),
biaxial_delta: 0.0,
}
}
#[must_use]
pub fn host_prefill(host: &HostData, recipe: Option<&ColorRecipe>) -> Prefill {
let builtin = host.materials.iter().find_map(|name| {
GemMaterial::all_materials()
.into_iter()
.find(|m| m.name.eq_ignore_ascii_case(name))
});
let mut out = builtin
.as_ref()
.map_or_else(|| generic_prefill(host), |b| builtin_prefill(host, b));
if !host.end_members.is_empty() {
let fractions = host.end_member_fractions(|id| recipe.map_or(0.0, |r| r.amount(id)));
if let Some(fractions) = fractions {
let refs: Vec<(&str, f64)> = fractions.iter().map(|(k, v)| (k.as_str(), *v)).collect();
let (ri, sg) = garnet_optics(&refs);
out.ri = ri as f32;
out.specific_gravity = sg as f32;
}
}
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SolveKind {
Pick,
InitialFromFantasy,
}
#[derive(Debug, Clone)]
pub struct SolveJob {
pub generation: u64,
pub host: String,
pub target_lab: [f64; 3],
pub reference_path_mm: f32,
pub locked: Vec<(String, f64)>,
pub cancel: Arc<AtomicBool>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Target {
pub lab: [f64; 3],
pub srgb: [u8; 3],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SolveSummary {
pub capped: bool,
}
#[derive(Debug)]
pub struct PhysicsState {
pub mode: ColorMode,
pub host_id: String,
pub view_fractions: bool,
pub locked: BTreeSet<String>,
pub target: Option<Target>,
pub summary: Option<SolveSummary>,
pub solving: bool,
pub history: RecipeHistory,
pub stone_width_mm: f32,
pub generation: u64,
cancel: Option<Arc<AtomicBool>>,
solving_kind: Option<SolveKind>,
fantasy_picked: bool,
drag_origin: Option<ColorRecipe>,
baseline: ColorMode,
opened_json: String,
pending_prefill: Option<Prefill>,
}
impl PhysicsState {
#[must_use]
pub fn open(
stored_json: &str,
fantasy_rgb: [f32; 3],
material_name: &str,
stone_width_mm: f32,
catalogue: &ChromophoreCatalogue,
previous_generation: u64,
) -> Self {
let mode =
ColorMode::from_json(stored_json).unwrap_or_else(|| ColorMode::fantasy(fantasy_rgb));
let host_id = mode
.last_recipe
.as_ref()
.map(|r| r.host.clone())
.filter(|h| catalogue.host(h).is_some())
.or_else(|| {
catalogue
.host_for_material(material_name)
.map(|h| h.id.clone())
})
.or_else(|| catalogue.hosts.first().map(|h| h.id.clone()))
.unwrap_or_default();
Self {
baseline: mode.clone(),
opened_json: stored_json.trim().to_string(),
mode,
host_id,
view_fractions: true,
locked: BTreeSet::new(),
target: None,
summary: None,
solving: false,
history: RecipeHistory::new(),
stone_width_mm,
generation: previous_generation + 1,
cancel: None,
solving_kind: None,
fantasy_picked: false,
drag_origin: None,
pending_prefill: None,
}
}
#[must_use]
pub const fn is_physics(&self) -> bool {
matches!(self.mode.active, ActiveColor::Physics)
}
#[must_use]
pub const fn recipe(&self) -> Option<&ColorRecipe> {
self.mode.last_recipe.as_ref()
}
#[must_use]
pub fn reference_path_mm(&self) -> f32 {
self.recipe().map_or_else(
|| default_reference_path_mm(self.stone_width_mm),
|r| r.reference_path_mm,
)
}
fn blank_recipe(&self, catalogue: &ChromophoreCatalogue) -> ColorRecipe {
let mut recipe = ColorRecipe::new(self.host_id.clone(), catalogue.data_version);
recipe.reference_path_mm = default_reference_path_mm(self.stone_width_mm);
refresh(&mut recipe, catalogue);
recipe
}
fn recipe_mut(&mut self, catalogue: &ChromophoreCatalogue) -> &mut ColorRecipe {
if self.mode.last_recipe.is_none() {
let blank = self.blank_recipe(catalogue);
self.mode.last_recipe = Some(blank);
}
self.mode
.last_recipe
.as_mut()
.unwrap_or_else(|| unreachable!("a recipe was just installed"))
}
fn push_history(&mut self) {
if let Some(r) = self.mode.last_recipe.clone() {
self.history.push(r);
}
}
pub fn set_mode(
&mut self,
physics: bool,
fantasy_rgb_from_dialog: Option<[f32; 3]>,
catalogue: &ChromophoreCatalogue,
) -> Option<SolveJob> {
self.cancel_solves();
if let Some(rgb) = fantasy_rgb_from_dialog {
self.mode.fantasy_rgb = rgb;
}
if !physics {
self.mode.switch_to_fantasy();
return None;
}
let needs_solve = self.mode.last_recipe.is_none();
self.mode.active = ActiveColor::Physics;
if needs_solve {
let blank = self.blank_recipe(catalogue);
self.mode.last_recipe = Some(blank);
self.pending_prefill = catalogue
.host(&self.host_id)
.map(|h| host_prefill(h, self.recipe()));
let lab = self.mode.fantasy_target_lab();
return Some(self.begin_solve(SolveKind::InitialFromFantasy, lab, None));
}
None
}
pub const fn set_fantasy_pick(&mut self, rgb: [f32; 3]) {
self.mode.fantasy_rgb = rgb;
self.mode.active = ActiveColor::Fantasy;
self.fantasy_picked = true;
}
pub fn choose_fixed_color(&mut self, rgb: Option<[f32; 3]>) {
self.cancel_solves();
match rgb {
Some(rgb) => {
self.mode.fantasy_rgb = rgb;
self.mode.active = ActiveColor::Fantasy;
}
None => self.mode.switch_to_fantasy(),
}
}
pub fn select_host(&mut self, host_id: &str, catalogue: &ChromophoreCatalogue) -> bool {
let Some(host) = catalogue.host(host_id) else {
return false;
};
self.cancel_solves();
self.locked.clear();
self.target = None;
self.summary = None;
if self.host_id != host_id || self.recipe().is_some_and(|r| r.host != host_id) {
self.push_history();
self.host_id = host_id.to_string();
let blank = self.blank_recipe(catalogue);
self.mode.last_recipe = Some(blank);
}
self.pending_prefill = Some(host_prefill(host, self.recipe()));
true
}
pub const fn take_prefill(&mut self) -> Option<Prefill> {
self.pending_prefill.take()
}
fn max_for(&self, id: &str, catalogue: &ChromophoreCatalogue) -> f64 {
catalogue
.host(&self.host_id)
.map_or(0.0, |h| h.element_conc_max(id))
}
fn end_member_room(&self, id: &str, catalogue: &ChromophoreCatalogue) -> Option<f64> {
let host = catalogue.host(&self.host_id)?;
if !host.end_members.iter().any(|m| m.id == id && !m.colorless) {
return None;
}
let recipe = self.recipe()?;
let others: f64 = host
.end_members
.iter()
.filter(|m| !m.colorless && m.id != id)
.map(|m| recipe.amount(&m.id))
.sum();
Some((1.0 - others).max(0.0))
}
pub fn set_amount_pos(&mut self, id: &str, pos: f64, catalogue: &ChromophoreCatalogue) {
let mut max = self.max_for(id, catalogue);
if let Some(room) = self.end_member_room(id, catalogue) {
max = max.min(room);
}
let amount = pos_to_amount(pos, self.max_for(id, catalogue)).min(max);
self.begin_drag();
let recipe = self.recipe_mut(catalogue);
if recipe.set_amount(id, amount) {
refresh(recipe, catalogue);
}
self.after_edit(id, catalogue);
}
pub fn set_fraction(&mut self, id: &str, fraction: f64, catalogue: &ChromophoreCatalogue) {
let Some(host) = catalogue.host(&self.host_id) else {
return;
};
let Some(recipe) = self.recipe() else {
return;
};
let units: Vec<(String, f64)> = recipe
.entries
.iter()
.map(|e| {
(
e.id.clone(),
e.amount / typical_amount(host, &e.id).max(1e-12),
)
})
.collect();
let total: f64 = units.iter().map(|(_, u)| u).sum();
if total <= 0.0 || units.len() < 2 {
return;
}
let f = fraction.clamp(0.0, 1.0);
let others: f64 = units.iter().filter(|(k, _)| k != id).map(|(_, u)| u).sum();
let new_units: Vec<(String, f64)> = units
.iter()
.map(|(k, u)| {
if k == id {
(k.clone(), f * total)
} else if others > 0.0 {
(k.clone(), u / others * (1.0 - f) * total)
} else {
(k.clone(), (1.0 - f) * total / (units.len() - 1) as f64)
}
})
.collect();
self.begin_drag();
let host_id = self.host_id.clone();
let Some(host) = catalogue.host(&host_id) else {
return;
};
let caps: Vec<(String, f64, f64)> = new_units
.iter()
.map(|(k, u)| {
(
k.clone(),
u * typical_amount(host, k),
host.element_conc_max(k),
)
})
.collect();
let recipe = self.recipe_mut(catalogue);
for (k, amount, max) in caps {
recipe.set_amount(&k, amount.min(max));
}
refresh(recipe, catalogue);
self.after_edit(id, catalogue);
}
fn after_edit(&mut self, id: &str, catalogue: &ChromophoreCatalogue) {
if let Some(host) = catalogue.host(&self.host_id)
&& host.end_members.iter().any(|m| m.id == id)
{
self.pending_prefill = Some(host_prefill(host, self.recipe()));
}
}
fn begin_drag(&mut self) {
if self.drag_origin.is_none() {
self.drag_origin = self.recipe().cloned();
}
}
pub fn release(&mut self) {
if let Some(origin) = self.drag_origin.take()
&& self.recipe().is_some_and(|r| *r != origin)
{
self.history.push(origin);
}
}
pub fn set_strength(&mut self, strength: f64, catalogue: &ChromophoreCatalogue) {
self.begin_drag();
let recipe = self.recipe_mut(catalogue);
if recipe.set_strength(strength.clamp(STRENGTH_RANGE.0, STRENGTH_RANGE.1)) {
refresh(recipe, catalogue);
}
}
pub fn set_path(&mut self, mm: f32, catalogue: &ChromophoreCatalogue) {
if !mm.is_finite() {
return;
}
self.begin_drag();
let recipe = self.recipe_mut(catalogue);
recipe.reference_path_mm = mm.clamp(0.1, 100.0);
refresh(recipe, catalogue);
}
pub fn add_item(&mut self, id: &str, catalogue: &ChromophoreCatalogue) -> bool {
let Some(host) = catalogue.host(&self.host_id) else {
return false;
};
if !catalogue
.selectable_elements(&self.host_id)
.iter()
.any(|e| e == id)
{
return false;
}
if self
.recipe()
.is_some_and(|r| impossible_reason(host, r, id).is_some())
{
return false;
}
self.push_history();
let mut amount = typical_amount(host, id);
if let Some(room) = self.end_member_room(id, catalogue) {
amount = amount.min(room);
}
let recipe = self.recipe_mut(catalogue);
recipe.set_amount(id, amount);
refresh(recipe, catalogue);
self.after_edit(id, catalogue);
true
}
pub fn remove_item(&mut self, id: &str, catalogue: &ChromophoreCatalogue) {
if self
.recipe()
.is_none_or(|r| !r.entries.iter().any(|e| e.id == id))
{
return;
}
self.push_history();
self.locked.remove(id);
let host_id = self.host_id.clone();
let recipe = self.recipe_mut(catalogue);
recipe.remove_entry(id);
let present: Vec<String> = recipe
.entries
.iter()
.filter(|e| e.amount > 0.0)
.map(|e| e.id.clone())
.collect();
let present_refs: Vec<&str> = present.iter().map(String::as_str).collect();
let valid: BTreeSet<String> = catalogue
.selectable_treatments(&host_id, &present_refs)
.iter()
.map(|t| t.id.clone())
.collect();
recipe.treatments.retain(|t| valid.contains(t));
refresh(recipe, catalogue);
self.after_edit(id, catalogue);
}
pub fn toggle_lock(&mut self, id: &str) {
if !self.locked.remove(id) {
self.locked.insert(id.to_string());
}
}
pub fn toggle_treatment(&mut self, id: &str, active: bool, catalogue: &ChromophoreCatalogue) {
if self
.recipe()
.is_some_and(|r| r.treatments.iter().any(|t| t == id) == active)
{
return;
}
self.push_history();
let recipe = self.recipe_mut(catalogue);
if active {
recipe.treatments.push(id.to_string());
} else {
recipe.treatments.retain(|t| t != id);
}
refresh(recipe, catalogue);
}
pub fn update_data(&mut self, catalogue: &ChromophoreCatalogue) {
self.push_history();
let recipe = self.recipe_mut(catalogue);
refresh(recipe, catalogue);
}
pub fn undo(&mut self) -> bool {
let Some(current) = self.mode.last_recipe.clone() else {
return false;
};
self.cancel_solves();
self.drag_origin = None;
let Some(prev) = self.history.undo(current) else {
return false;
};
self.host_id.clone_from(&prev.host);
self.mode.last_recipe = Some(prev);
true
}
pub fn redo(&mut self) -> bool {
let Some(current) = self.mode.last_recipe.clone() else {
return false;
};
self.cancel_solves();
self.drag_origin = None;
let Some(next) = self.history.redo(current) else {
return false;
};
self.host_id.clone_from(&next.host);
self.mode.last_recipe = Some(next);
true
}
pub fn cancel_solves(&mut self) {
if let Some(token) = self.cancel.take() {
token.store(true, Ordering::SeqCst);
}
self.generation += 1;
self.solving = false;
self.solving_kind = None;
}
fn begin_solve(&mut self, kind: SolveKind, lab: [f64; 3], srgb: Option<[u8; 3]>) -> SolveJob {
self.cancel_solves();
let cancel = Arc::new(AtomicBool::new(false));
self.cancel = Some(Arc::clone(&cancel));
self.solving = true;
self.solving_kind = Some(kind);
if let Some(srgb) = srgb {
self.target = Some(Target { lab, srgb });
}
let locked: Vec<(String, f64)> = self
.recipe()
.map(|r| {
r.entries
.iter()
.filter(|e| self.locked.contains(&e.id))
.map(|e| (e.id.clone(), e.amount))
.collect()
})
.unwrap_or_default();
SolveJob {
generation: self.generation,
host: self.host_id.clone(),
target_lab: lab,
reference_path_mm: self.reference_path_mm(),
locked,
cancel,
}
}
pub fn pick(&mut self, srgb: [u8; 3]) -> SolveJob {
let lab = indicatrix::color::body_color::srgb_to_lab(srgb.map(|c| f64::from(c) / 255.0));
self.begin_solve(SolveKind::Pick, lab, Some(srgb))
}
pub fn finish_solve(
&mut self,
generation: u64,
result: SolveResult,
catalogue: &ChromophoreCatalogue,
) -> bool {
if generation != self.generation || !self.solving {
return false;
}
self.solving = false;
self.solving_kind = None;
self.cancel = None;
let keep_path = self.reference_path_mm();
let mut recipe = result.recipe;
recipe.reference_path_mm = keep_path;
refresh(&mut recipe, catalogue);
self.push_history();
self.host_id.clone_from(&recipe.host);
self.pending_prefill = catalogue
.host(&recipe.host)
.filter(|h| !h.end_members.is_empty())
.map(|h| host_prefill(h, Some(&recipe)));
self.mode.last_recipe = Some(recipe);
self.summary = Some(SolveSummary {
capped: result.capped,
});
true
}
#[must_use]
pub fn is_dirty(&self) -> bool {
if self.fantasy_picked {
return true;
}
if self.mode.active != self.baseline.active {
return true;
}
match (&self.mode.last_recipe, &self.baseline.last_recipe) {
(Some(a), Some(b)) => a != b,
(None, None) => false,
(Some(a), None) => !(a.entries.is_empty() && a.treatments.is_empty()),
(None, Some(_)) => true,
}
}
#[must_use]
pub fn mode_json(&self) -> String {
if self.is_untouched_stored_recipe() {
return self.opened_json.clone();
}
let blank = |r: &ColorRecipe| r.entries.is_empty() && r.treatments.is_empty();
match (&self.mode.last_recipe, self.is_physics()) {
_ if self.fantasy_picked => self.mode.to_json(),
(None, false) => String::new(),
(Some(r), false) if self.baseline.last_recipe.is_none() && blank(r) => String::new(),
_ => self.mode.to_json(),
}
}
fn is_untouched_stored_recipe(&self) -> bool {
!self.fantasy_picked
&& !self.opened_json.is_empty()
&& self.mode.last_recipe.is_some()
&& self.mode == self.baseline
}
#[must_use]
pub fn view(&self, catalogue: &ChromophoreCatalogue) -> PhysicsView {
let host = catalogue.host(&self.host_id);
let recipe = self.recipe();
let mut view = PhysicsView {
active: self.is_physics(),
dirty: self.is_dirty(),
mode_json: self.mode_json(),
host_names: catalogue.hosts.iter().map(|h| h.name.clone()).collect(),
host_index: catalogue
.hosts
.iter()
.position(|h| h.id == self.host_id)
.unwrap_or(0),
banner: host.and_then(host_banner),
data_updated: self.mode.data_outdated(catalogue),
view_fractions: self.view_fractions,
strength: recipe.map_or(1.0, |r| r.strength),
reference_path_mm: self.reference_path_mm(),
path_hint: path_hint(self.stone_width_mm),
solving: self.solving,
can_undo: self.history.can_undo(),
can_redo: self.history.can_redo(),
..PhysicsView::default()
};
let (Some(host), Some(recipe)) = (host, recipe) else {
return view;
};
view.optics_locked = true;
self.fill_rows(&mut view, catalogue, host, recipe);
self.fill_colors(&mut view, recipe);
fill_glow(&mut view, catalogue, recipe);
if self.solving_kind == Some(SolveKind::InitialFromFantasy) {
view.note_text =
"Fitting your current fantasy color into this host\u{2026}".to_string();
} else if self.is_physics() && recipe.entries.is_empty() && !self.solving {
view.note_text = "Pure host: add elements or pick a color.".to_string();
}
view
}
}
impl PhysicsState {
fn fill_rows(
&self,
view: &mut PhysicsView,
catalogue: &ChromophoreCatalogue,
host: &HostData,
recipe: &ColorRecipe,
) {
let units: Vec<(String, f64)> = recipe
.entries
.iter()
.map(|e| {
(
e.id.clone(),
e.amount / typical_amount(host, &e.id).max(1e-12),
)
})
.collect();
let total: f64 = units.iter().map(|(_, u)| u).sum();
for e in &recipe.entries {
let unit = item_unit(host, &e.id);
let max = host.element_conc_max(&e.id);
let (confidence, sources) = item_sources(host, &e.id);
let share = units
.iter()
.find(|(k, _)| *k == e.id)
.map_or(0.0, |(_, u)| if total > 0.0 { u / total } else { 0.0 });
let shown = if self.view_fractions {
e.amount * recipe.strength
} else {
e.amount
};
view.rows.push(RowView {
id: e.id.clone(),
label: catalogue
.end_member(&e.id)
.map_or_else(|| e.id.clone(), |m| m.name.clone()),
amount_text: format_amount(shown, &unit),
unit: unit_label(&unit),
slider: amount_to_pos(e.amount, max),
fraction: share,
fraction_text: format!("{:.0} %", share * 100.0),
locked: self.locked.contains(&e.id),
estimated: matches!(confidence.as_str(), "estimate" | "unknown"),
confidence,
sources,
});
}
for id in catalogue.selectable_elements(&self.host_id) {
if recipe.entries.iter().any(|e| e.id == id) {
continue;
}
let reason = impossible_reason(host, recipe, &id);
view.add_options.push(AddOptionView {
label: catalogue
.end_member(&id)
.map_or_else(|| id.clone(), |m| m.name.clone()),
id,
enabled: reason.is_none(),
reason: reason.unwrap_or_default(),
});
}
let present: Vec<&str> = recipe
.entries
.iter()
.filter(|e| e.amount > 0.0)
.map(|e| e.id.as_str())
.collect();
for t in catalogue.selectable_treatments(&self.host_id, &present) {
view.treatments.push(TreatmentView {
id: t.id.clone(),
name: t.name.clone(),
conditions: t.conditions.clone(),
active: recipe.treatments.contains(&t.id),
});
}
}
fn fill_colors(&self, view: &mut PhysicsView, recipe: &ColorRecipe) {
let tensor = recipe.resolved_bands.to_tensor();
let path = f64::from(recipe.reference_path_mm);
let d65 = body_colors(&tensor, path, Illuminant::D65);
let a = body_colors(&tensor, path, Illuminant::Planckian(3200.0));
let delta_cc = delta_e_2000(d65.unpolarised.lab, a.unpolarised.lab);
view.color_change_text = if delta_cc > COLOR_CHANGE_DELTA_E {
format!("color change \u{394}E {delta_cc:.1}")
} else {
format!("\u{394}E {delta_cc:.1} between D65 and 3200 K (no color change)")
};
view.has_beta = d65.beta_ray.is_some();
view.d65 = Some(mode_swatches(&d65));
view.a = Some(mode_swatches(&a));
let stone_path = f64::from(default_reference_path_mm(self.stone_width_mm));
if self.stone_width_mm > 0.0 && (stone_path - path).abs() > 0.05 {
view.stone = Some((
body_colors(&tensor, stone_path, Illuminant::D65)
.unpolarised
.srgb,
body_colors(&tensor, stone_path, Illuminant::Planckian(3200.0))
.unpolarised
.srgb,
));
view.stone_text = format!("At this stone's size ({stone_path:.1} mm)");
}
if let Some(target) = self.target {
let de = delta_e_2000(target.lab, d65.unpolarised.lab);
view.target = Some(target.srgb);
view.match_warn = de > WARN_DELTA_E;
view.match_text = if de <= REACHABLE_DELTA_E {
format!("\u{394}E {de:.1} from your pick (D65)")
} else {
format!("\u{394}E {de:.1} from your pick (D65) \u{b7} closest reachable shown")
};
if self.summary.is_some_and(|s| s.capped) {
view.match_text.push_str(" \u{b7} search capped");
}
}
}
}
fn fill_glow(view: &mut PhysicsView, catalogue: &ChromophoreCatalogue, recipe: &ColorRecipe) {
let report = fluorescence_report(catalogue, recipe);
let path = recipe.reference_path_mm;
let (glow365, glow395) = (report.glow(UV365_NM, path), report.glow(UV395_NM, path));
let swatch = |glow: &UvGlow| GlowSwatch {
srgb: glow.srgb.map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8),
level: (glow.photon_yield / GLOW_LEVEL_FULL).clamp(0.0, 1.0),
text: glow_caption(glow),
};
let best = if glow395.photon_yield > glow365.photon_yield {
&glow395
} else {
&glow365
};
view.fluorescence_text = match (best.strength(), best.color_name()) {
(GlowStrength::None, _) | (_, None) => "Fluorescence: none".to_string(),
(GlowStrength::Weak, Some(c)) => format!("Fluorescence: weak {c}"),
(GlowStrength::Strong, Some(c)) => format!("Fluorescence: strong {c}"),
};
view.quench_text = report
.dominant_quencher()
.map(|by| format!("quenched by {by}"))
.unwrap_or_default();
view.glow = (!report.fluorescence.is_empty()).then(|| [swatch(&glow365), swatch(&glow395)]);
}
const GLOW_LEVEL_FULL: f32 = 0.5;
fn glow_caption(glow: &UvGlow) -> String {
format!(
"{:.0} %",
(glow.photon_yield / GLOW_LEVEL_FULL).clamp(0.0, 1.0) * 100.0
)
}
fn path_hint(stone_width_mm: f32) -> String {
if stone_width_mm > 0.0 {
format!(
"equivalent path (default {GIRDLE_PATH_FACTOR} \u{d7} girdle {stone_width_mm:.1} mm)"
)
} else {
"equivalent path (no design size: default 5 mm)".to_string()
}
}
pub fn refresh(recipe: &mut ColorRecipe, catalogue: &ChromophoreCatalogue) {
recipe.clamp_amounts(catalogue);
recipe.data_version = catalogue.data_version;
if let Ok((tensor, _)) = resolve(recipe, catalogue) {
recipe.resolved_bands = ResolvedBands::from_tensor(&tensor);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModeSwatches {
pub unpol: [u8; 3],
pub o: [u8; 3],
pub e: [u8; 3],
pub beta: Option<[u8; 3]>,
}
fn mode_swatches(c: &BodyColors) -> ModeSwatches {
let rgb = |b: &BodyColor| b.srgb;
ModeSwatches {
unpol: rgb(&c.unpolarised),
o: rgb(&c.o_ray),
e: rgb(&c.e_ray),
beta: c.beta_ray.as_ref().map(rgb),
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct GlowSwatch {
pub srgb: [u8; 3],
pub level: f32,
pub text: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RowView {
pub id: String,
pub label: String,
pub amount_text: String,
pub unit: String,
pub slider: f64,
pub fraction: f64,
pub fraction_text: String,
pub locked: bool,
pub estimated: bool,
pub confidence: String,
pub sources: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AddOptionView {
pub id: String,
pub label: String,
pub enabled: bool,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TreatmentView {
pub id: String,
pub name: String,
pub conditions: String,
pub active: bool,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct PhysicsView {
pub active: bool,
pub dirty: bool,
pub mode_json: String,
pub host_names: Vec<String>,
pub host_index: usize,
pub banner: Option<String>,
pub data_updated: bool,
pub rows: Vec<RowView>,
pub add_options: Vec<AddOptionView>,
pub treatments: Vec<TreatmentView>,
pub view_fractions: bool,
pub strength: f64,
pub reference_path_mm: f32,
pub path_hint: String,
pub d65: Option<ModeSwatches>,
pub a: Option<ModeSwatches>,
pub has_beta: bool,
pub stone: Option<([u8; 3], [u8; 3])>,
pub stone_text: String,
pub color_change_text: String,
pub glow: Option<[GlowSwatch; 2]>,
pub fluorescence_text: String,
pub quench_text: String,
pub target: Option<[u8; 3]>,
pub match_text: String,
pub match_warn: bool,
pub solving: bool,
pub note_text: String,
pub can_undo: bool,
pub can_redo: bool,
pub optics_locked: bool,
}
#[cfg(test)]
mod tests;