1use std::path::{Path, PathBuf};
4
5use kallo_core::{CameraProfile, DevelopmentParams, FilmStock, PaperStock, RenderParams};
6use kallo_core::{film_stock, paper_stock};
7use kallo_spectral::Sensor;
8
9use crate::error::Error;
10
11#[derive(Debug, Clone)]
18pub struct StockChoice {
19 pub film: String,
21 pub paper: String,
23 pub filters: String,
25 pub heat_glass: Option<String>,
27 pub dev_time: Option<f64>,
29 pub temp: Option<f64>,
31 pub spectral: bool,
33 pub lut_size: usize,
35 pub dictionary: Vec<PathBuf>,
37 pub lut: Option<PathBuf>,
39 pub allow_model_mismatch: bool,
41 pub camera: Option<String>,
43 pub spectral_print: bool,
45 pub spectral_scan: bool,
47}
48
49impl Default for StockChoice {
50 fn default() -> Self {
51 Self {
52 film: "vision3_500t".to_owned(),
53 paper: "portra_endura".to_owned(),
54 filters: String::new(),
55 heat_glass: None,
56 dev_time: None,
57 temp: None,
58 spectral: false,
59 lut_size: 0,
60 dictionary: Vec::new(),
61 lut: None,
62 allow_model_mismatch: false,
63 camera: None,
64 spectral_print: false,
65 spectral_scan: false,
66 }
67 }
68}
69
70#[derive(Clone)]
72pub struct Selection {
73 pub development: DevelopmentParams,
75 pub film: &'static FilmStock,
77 pub paper: &'static PaperStock,
79 pub spectral: bool,
81 pub camera: CameraProfile,
83 pub lut_size: usize,
85 pub dictionary: Vec<PathBuf>,
87 pub lut: Option<PathBuf>,
89 pub allow_model_mismatch: bool,
91 pub spectral_print: bool,
93 pub filters: kallo_core::filters::FilterPack,
95 pub heat_glass: Option<kallo_core::filters::HeatGlass>,
97 pub spectral_scan: bool,
99}
100
101impl Selection {
102 #[must_use]
104 pub fn render_params(
105 &self,
106 exposure_ev: f64,
107 print_ev: f64,
108 interimage: bool,
109 film: &'static FilmStock,
110 ) -> RenderParams {
111 RenderParams {
112 film,
113 paper: self.paper,
114 development: self.development,
115 exposure_ev,
116 print_exposure_ev: print_ev,
117 spectral: self.spectral || self.lut.is_some(),
118 camera: self.camera.clone(),
119 lut_size: self.lut_size,
120 spectral_print: self.spectral_print,
121 spectral_scan: self.spectral_scan,
122 interimage,
125 filters: self.filters,
126 heat_glass: self.heat_glass,
127 }
128 }
129
130 pub fn prepare(
132 &self,
133 exposure_ev: f64,
134 interimage: bool,
135 ) -> Result<kallo_core::PreparedRender, Error> {
136 self.prepare_with_film(exposure_ev, 0.0, interimage, self.film)
137 }
138
139 pub fn prepare_with_film(
142 &self,
143 exposure_ev: f64,
144 print_ev: f64,
145 interimage: bool,
146 film: &'static FilmStock,
147 ) -> Result<kallo_core::PreparedRender, Error> {
148 let params = self.render_params(exposure_ev, print_ev, interimage, film);
149
150 if let Some(path) = &self.lut {
151 let table = kallo_core::ExposureLut::read(path).map_err(|error| Error::Lut {
152 path: path.clone(),
153 message: error.to_string(),
154 })?;
155
156 let expected = kallo_core::SpectralContext::for_stock(film).identity();
157 if let Err(message) = check_model(table.identity(), &expected) {
158 if !self.allow_model_mismatch {
159 return Err(Error::Lut {
160 path: path.clone(),
161 message,
162 });
163 }
164 eprintln!("warning: {message}");
165 }
166
167 return Ok(params.prepare_with_lut(table));
168 }
169
170 let atoms = if self.dictionary.is_empty() {
171 None
172 } else {
173 let mut atoms = Vec::new();
174 for path in &self.dictionary {
175 let set = kallo_spectral::load_csv(path).map_err(|error| Error::Dictionary {
176 path: path.clone(),
177 message: error.to_string(),
178 })?;
179 atoms.extend(set.samples.iter().map(|sample| sample.reflectance.clone()));
180 }
181 Some(atoms)
182 };
183 Ok(params.prepare_with(atoms.as_deref()))
184 }
185}
186
187pub fn check_model(table: &str, build: &str) -> Result<(), String> {
191 if table == build {
192 Ok(())
193 } else {
194 Err(format!(
195 "baked for a different model: table says `{table}`, this build is `{build}`. \
196 Pass --allow-model-mismatch to use it anyway."
197 ))
198 }
199}
200
201pub fn resolve(stock: &StockChoice) -> Result<Selection, Error> {
203 let film = film_stock(&stock.film).ok_or_else(|| Error::UnknownStock {
204 kind: "film",
205 id: stock.film.clone(),
206 })?;
207 let dev_time = stock.dev_time.unwrap_or(film.reference_time_min);
208 let temp = stock.temp.unwrap_or(film.reference_temp_c);
209 if !(dev_time.is_finite() && dev_time > 0.0) {
210 return Err(Error::InvalidNumber {
211 flag: "--dev",
212 value: dev_time,
213 expected: "a positive number of minutes",
214 });
215 }
216 if !temp.is_finite() {
217 return Err(Error::InvalidNumber {
218 flag: "--temp",
219 value: temp,
220 expected: "a finite temperature in degrees Celsius",
221 });
222 }
223
224 let paper = paper_stock(&stock.paper).ok_or_else(|| Error::UnknownStock {
225 kind: "paper",
226 id: stock.paper.clone(),
227 })?;
228
229 let development = DevelopmentParams {
232 time_min: dev_time,
233 temp_c: temp,
234 reference_time_min: film.reference_time_min,
235 reference_temp_c: film.reference_temp_c,
236 ..DevelopmentParams::default()
237 };
238
239 Ok(Selection {
240 development,
241 film,
242 paper,
243 spectral: stock.spectral,
244 camera: camera_profile(stock.camera.as_deref())?,
245 lut_size: stock.lut_size,
246 dictionary: stock.dictionary.clone(),
247 lut: stock.lut.clone(),
248 allow_model_mismatch: stock.allow_model_mismatch,
249 spectral_print: stock.spectral_print,
250 spectral_scan: stock.spectral_scan,
251 filters: parse_filters(&stock.filters)?,
252 heat_glass: parse_heat_glass(stock.heat_glass.as_deref())?,
253 })
254}
255
256fn parse_filters(text: &str) -> Result<kallo_core::filters::FilterPack, Error> {
261 let mut pack = kallo_core::filters::FilterPack::none();
262 for part in text
263 .split('+')
264 .map(str::trim)
265 .filter(|part| !part.is_empty())
266 {
267 let split = part
268 .find(|c: char| c.is_ascii_alphabetic())
269 .unwrap_or(part.len());
270 let (number, colour) = part.split_at(split);
271 let value: f64 = number.trim().parse().map_err(|_| Error::InvalidNumber {
272 flag: "--filters",
273 value: f64::NAN,
274 expected: "a CC setting such as 30M",
275 })?;
276 match colour.trim().to_ascii_uppercase().as_str() {
277 "Y" => pack.yellow = value,
278 "M" | "" => pack.magenta = value,
279 "C" => pack.cyan = value,
280 _ => {
281 return Err(Error::InvalidNumber {
282 flag: "--filters",
283 value: f64::NAN,
284 expected: "one of Y, M or C",
285 });
286 }
287 }
288 }
289 Ok(pack)
290}
291
292fn parse_heat_glass(text: Option<&str>) -> Result<Option<kallo_core::filters::HeatGlass>, Error> {
294 use kallo_core::filters::HeatGlass;
295 let Some(text) = text else {
296 return Ok(None);
297 };
298 match text.trim().to_ascii_lowercase().as_str() {
299 "kg1" => Ok(Some(HeatGlass::Kg1)),
300 "kg2" => Ok(Some(HeatGlass::Kg2)),
301 "kg3" => Ok(Some(HeatGlass::Kg3)),
302 "kg5" => Ok(Some(HeatGlass::Kg5)),
303 _ => Err(Error::InvalidNumber {
304 flag: "--heat-glass",
305 value: f64::NAN,
306 expected: "one of kg1, kg2, kg3, kg5",
307 }),
308 }
309}
310
311pub fn camera_profile(value: Option<&str>) -> Result<CameraProfile, Error> {
316 let Some(value) = value else {
317 return Ok(CameraProfile::default());
318 };
319 match value {
320 "srgb" => Ok(CameraProfile::Srgb),
321 "generic" => Ok(CameraProfile::Generic),
322 "observer" => Ok(CameraProfile::Observer),
323 path => {
324 let set = kallo_spectral::load_csv(path).map_err(|error| Error::Camera {
325 path: path.into(),
326 message: error.to_string(),
327 })?;
328 let channel = |name: &str| {
329 set.sample(name)
330 .map(|sample| sample.reflectance.clone())
331 .ok_or_else(|| Error::Camera {
332 path: path.into(),
333 message: format!("no column named {name:?}"),
334 })
335 };
336 let channels = [channel("red")?, channel("green")?, channel("blue")?];
337 let name = Path::new(path)
339 .file_stem()
340 .map(|stem| stem.to_string_lossy().into_owned())
341 .unwrap_or_else(|| path.to_owned());
342 let sensor = Sensor::new(name, channels)
343 .map_err(|error| Error::Camera {
344 path: path.into(),
345 message: error.to_string(),
346 })?
347 .area_normalized();
348 Ok(CameraProfile::Measured(sensor))
349 }
350 }
351}
352
353#[cfg(test)]
354mod tests {
355 use super::{camera_profile, check_model};
356 use kallo_core::CameraProfile;
357
358 #[test]
359 fn the_built_in_camera_names_resolve() {
360 assert!(matches!(camera_profile(None).unwrap(), CameraProfile::Srgb));
363 assert!(matches!(
364 camera_profile(Some("srgb")).unwrap(),
365 CameraProfile::Srgb
366 ));
367 assert!(matches!(
368 camera_profile(Some("generic")).unwrap(),
369 CameraProfile::Generic
370 ));
371 assert!(matches!(
372 camera_profile(Some("observer")).unwrap(),
373 CameraProfile::Observer
374 ));
375 }
376
377 #[test]
378 fn a_missing_camera_file_names_the_path() {
379 let message = camera_profile(Some("/nonexistent/camera.csv")).unwrap_err();
380 assert!(
381 message.to_string().contains("/nonexistent/camera.csv"),
382 "{message}"
383 );
384 }
385
386 #[test]
387 fn a_matching_identity_passes() {
388 assert!(check_model("camera=a;film=b", "camera=a;film=b").is_ok());
389 }
390
391 #[test]
392 fn a_foreign_identity_names_both_sides() {
393 let message = check_model("camera=old;film=b", "camera=new;film=b").unwrap_err();
394 assert!(message.contains("camera=old"), "{message}");
395 assert!(message.contains("camera=new"), "{message}");
396 assert!(message.contains("--allow-model-mismatch"), "{message}");
397 }
398}