1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
//! color recipe and resolved band budget definitions.
use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Deserializer, Serialize};
use crate::optics::absorption::{AbsorptionBand, AbsorptionTensor};
/// A physical chromophore recipe specifying host, concentration entries, treatments and scale.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ColorRecipe {
/// Host crystal identifier (e.g. `"corundum"`).
pub host: String,
/// Catalogue data version this recipe was built with.
pub data_version: u32,
/// Element and chromophore amounts, sorted by ID.
pub entries: Vec<RecipeEntry>,
/// Active treatments applied in catalogue order.
pub treatments: Vec<String>,
/// Global absorption multiplier on every item's resulting alpha (default 1.0); must be finite.
#[serde(deserialize_with = "finite_f64")]
pub strength: f64,
/// Reference stone path thickness in mm for preview swatches and solver (default 5.0); must be finite.
#[serde(deserialize_with = "finite_f32")]
pub reference_path_mm: f32,
/// The budgeted, rendered absorption tensor bands (single source of truth for rendering).
pub resolved_bands: ResolvedBands,
}
impl ColorRecipe {
/// Builds a new recipe with defaults (strength = 1.0, `reference_path_mm` = 5.0).
#[must_use]
pub fn new(host: impl Into<String>, data_version: u32) -> Self {
Self {
host: host.into(),
data_version,
entries: Vec::new(),
treatments: Vec::new(),
strength: 1.0,
reference_path_mm: 5.0,
resolved_bands: ResolvedBands::default(),
}
}
/// Sets an element or end-member amount, maintaining sorted order.
///
/// Non-finite amounts are rejected (the recipe is left unchanged and `false` is returned);
/// negative amounts are stored as 0. The upper bound `conc_max` is applied by
/// [`Self::clamp_amounts`] and by `resolve`, which know the host.
pub fn set_amount(&mut self, id: &str, amount: f64) -> bool {
if !amount.is_finite() {
return false;
}
let amount = amount.max(0.0);
if let Some(entry) = self.entries.iter_mut().find(|e| e.id == id) {
entry.amount = amount;
} else {
self.entries.push(RecipeEntry {
id: id.to_string(),
amount,
});
self.entries.sort_by(|a, b| a.id.cmp(&b.id));
}
true
}
/// Sets the global strength; non-finite values are rejected and negative ones stored as 0.
pub const fn set_strength(&mut self, strength: f64) -> bool {
if !strength.is_finite() {
return false;
}
self.strength = strength.max(0.0);
true
}
/// Clamps every entry to `[0, conc_max]` of its host under the recipe's treatments
/// (`HostData::element_conc_max_split` with the treated valence split), scales down together
/// the elements of a multi-element chromophore whose amounts add up to more than its
/// `conc_max` (`HostData::cap_summed_amounts`), and drops entries whose id is not selectable
/// in the host. Returns the number of changed or dropped entries. A recipe for an unknown host
/// is left untouched.
pub fn clamp_amounts(&mut self, catalogue: &super::catalogue::ChromophoreCatalogue) -> usize {
let Some(host) = catalogue.host(&self.host) else {
return 0;
};
let selectable = catalogue.selectable_elements(&self.host);
let before = self.entries.clone();
// Which elements are present gates the treatments' `requires`; read it before editing.
let present: BTreeSet<String> = self
.entries
.iter()
.filter(|e| e.amount > 0.0)
.map(|e| e.id.clone())
.collect();
let split = host.treated_split(&self.treatments, &|id| present.contains(id));
self.entries.retain(|e| selectable.contains(&e.id));
for e in &mut self.entries {
let max = host.element_conc_max_split(&e.id, &split);
e.amount = if e.amount.is_finite() {
e.amount.clamp(0.0, max)
} else {
0.0
};
}
let mut amounts: BTreeMap<String, f64> = self
.entries
.iter()
.map(|e| (e.id.clone(), e.amount))
.collect();
host.cap_summed_amounts(&mut amounts);
for e in &mut self.entries {
if let Some(&amount) = amounts.get(&e.id) {
e.amount = amount;
}
}
before.iter().filter(|b| !self.entries.contains(b)).count()
}
/// Gets an entry amount, or 0.0 if absent.
#[must_use]
pub fn amount(&self, id: &str) -> f64 {
self.entries
.iter()
.find(|e| e.id == id)
.map_or(0.0, |e| e.amount)
}
/// Removes an entry by ID.
pub fn remove_entry(&mut self, id: &str) {
self.entries.retain(|e| e.id != id);
}
}
/// A single entry in a recipe: element/chromophore identifier and its numerical amount.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RecipeEntry {
/// Selectable identifier (e.g. `"Cr"`, `"Fe"`, or chromophore ID).
pub id: String,
/// Amount in the item's input unit; must be finite (deserialisation rejects NaN/inf).
#[serde(deserialize_with = "finite_f64")]
pub amount: f64,
}
fn finite_f64<'de, D: Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
let v = f64::deserialize(d)?;
if v.is_finite() {
Ok(v)
} else {
Err(serde::de::Error::custom(
"non-finite number in color recipe",
))
}
}
fn finite_f32<'de, D: Deserializer<'de>>(d: D) -> Result<f32, D::Error> {
let v = f32::deserialize(d)?;
if v.is_finite() {
Ok(v)
} else {
Err(serde::de::Error::custom(
"non-finite number in color recipe",
))
}
}
/// A serialized snapshot of the budgeted [`AbsorptionTensor`].
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ResolvedBands {
/// Ordinary ray / alpha bands.
pub o_ray: Vec<AbsorptionBand>,
/// Extraordinary ray / gamma bands.
pub e_ray: Vec<AbsorptionBand>,
/// Optional beta ray bands for trichroic biaxial materials.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub beta_ray: Option<Vec<AbsorptionBand>>,
/// Whether the material has pleochroic absorption.
pub is_pleochroic: bool,
}
impl ResolvedBands {
/// Converts these budgeted bands into a live [`AbsorptionTensor`].
#[must_use]
pub fn to_tensor(&self) -> AbsorptionTensor {
self.beta_ray.as_ref().map_or_else(
|| {
if self.is_pleochroic {
AbsorptionTensor::uniaxial(self.o_ray.clone(), self.e_ray.clone())
} else {
AbsorptionTensor::isotropic(self.o_ray.clone())
}
},
|beta| AbsorptionTensor::biaxial(self.o_ray.clone(), beta.clone(), self.e_ray.clone()),
)
}
/// Captures bands from an [`AbsorptionTensor`].
#[must_use]
pub fn from_tensor(tensor: &AbsorptionTensor) -> Self {
Self {
o_ray: tensor.o_ray.clone(),
e_ray: tensor.e_ray.clone(),
beta_ray: tensor.beta_ray.clone(),
is_pleochroic: tensor.is_pleochroic,
}
}
}
/// Warnings produced during forward model band budgeting.
#[derive(Debug, Clone, PartialEq)]
pub enum ResolveWarning {
/// Bands exceeded the GPU budget of 8 per eigenmode and the weakest were dropped.
BandsDropped {
/// Mode name (`"o_ray"`, `"e_ray"`, or `"beta_ray"`).
mode: String,
/// Number of dropped bands.
count: usize,
/// color difference Delta E 2000 caused by dropping them.
delta_e: f64,
},
/// Bands exceeded the GPU budget of 8 per eigenmode and the cheapest pairs were merged into
/// single energy Gaussians (moment matching, least color error first).
BandsMerged {
/// Mode name (`"o_ray"`, `"e_ray"`, or `"beta_ray"`).
mode: String,
/// Number of merges performed (each removes one band).
count: usize,
/// color difference Delta E 2000 caused by the merges.
delta_e: f64,
},
/// A treatment was ignored because a required element is absent or the id is unknown.
TreatmentSkipped {
/// Treatment id.
id: String,
/// Why it was skipped.
reason: String,
},
/// A recipe entry that is not selectable in the host was ignored.
UnknownEntry {
/// Entry id.
id: String,
},
/// A catalogue `requires` / `excludes` relation is violated: an active chromophore `id` whose
/// `relation` partner `other` is active (`"excludes"`) or absent (`"requires"`). Advisory.
Relation {
/// The active chromophore carrying the relation.
id: String,
/// `"requires"` or `"excludes"`.
relation: &'static str,
/// The chromophore the relation names.
other: String,
},
/// An amount above the host's bound under the recipe's treatments was clamped, or scaled down
/// with its partner elements to the summed `conc_max` of a multi-element chromophore.
AmountClamped {
/// Entry id.
id: String,
/// Requested amount.
requested: f64,
/// Amount used.
used: f64,
},
}
/// Errors during forward model resolution.
#[derive(Debug, Clone, PartialEq)]
pub enum ResolveError {
/// The specified host crystal was not found in the catalogue.
HostNotFound(String),
/// An amount, the strength or the reference path is NaN or infinite.
NonFinite(String),
/// Garnet-style end-member fractions sum to more than 1.
EndMemberSumExceeded(f64),
}
impl std::fmt::Display for ResolveError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::HostNotFound(h) => write!(f, "Host crystal '{h}' not found in catalogue"),
Self::NonFinite(what) => write!(f, "Non-finite value in color recipe: {what}"),
Self::EndMemberSumExceeded(sum) => write!(f, "End-member fractions sum to {sum} > 1"),
}
}
}
impl std::error::Error for ResolveError {}