Files
DarkRoom/core/dr-film/src/lib.rs
T
dtourolleandClaude Opus 5 3b5952769b Emit floats an f32 can hold, and drop the format! that formats nothing
CI runs cargo fmt --check and clippy -D warnings, and this branch had
never been through either. Both would have failed it.

The bulk was the generated colour tables: eight significant figures where
an f32 carries about 7.2, so the eighth is noise that rounds away at
compile time and clippy's excessive_precision says so 109 times over.
Fixed in the generator rather than only in the file, so it stays fixed --
and the file is trimmed in place rather than re-derived, because
regenerating it needs a colour-science stack that has nothing to do with
the defect.

The format! in the composer is mine too, from extracting the rendering
tail: the braces in it were escaped because the text used to live inside a
larger template, and once extracted the escapes are noise and the call
formats nothing.

Also here, and clearly not mine: an unused import and a shadowed binding
in dr-gpu, and an unused import in a test. They are pre-existing --
clippy has been failing on master before this branch existed, on lints
like is_multiple_of that arrived with a toolchain rather than with
anyone's code. Fixed because CI cannot go green around them, and called
out because a merge commit is a bad place to quietly edit someone else's
crate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 22:28:14 +02:00

148 lines
5.5 KiB
Rust

//! TRACES: FR-DEV-3f
//! Spectral film simulation — what a stock would have done with this light.
//!
//! # What this is, and what it is not
//!
//! Not a look-up table someone graded by eye. Each stock here is its
//! manufacturer's own measurements — spectral sensitivity, characteristic
//! curves, dye densities — run through the physics: light exposes three
//! emulsion layers, the layers develop to densities, the densities are dyes
//! that absorb, and what is left is what reaches the eye. A colour negative
//! comes out orange and upside down because that is what a colour negative is;
//! it becomes a photograph when [`bake::Recipe`] prints it on paper.
//!
//! What that buys over a LUT is that the *parameters are physical*. Exposing a
//! stop over moves the picture along the film's real characteristic curve,
//! shoulder and all, rather than scaling a number someone baked at one
//! exposure. And the data cost is the other way round from a LUT collection: a
//! stock is about 17 kB of published measurements, where one HaldCLUT is 800 kB
//! of one person's grade.
//!
//! # The shape of the crate
//!
//! - [`profile`] — a stock, as measured. Data, contributable as a file.
//! - [`spectrum`] — the fixed colour science: observer, illuminants, basis.
//! - [`bake`] — the reduction to three tables a shader can run.
//!
//! No wgpu dependency, deliberately: what comes out is plain `f32` with a
//! documented layout, and every property worth asserting about the model is
//! asserted on the CPU.
//!
//! # Provenance
//!
//! The shipped profiles are converted from **spektrafilm** by Andrea Volpato
//! (<https://github.com/andreavolpato/spektrafilm>), licensed CC BY-SA 4.0 and
//! modified for DarkRoom — see `profiles/LICENSE-PROFILES.txt` and
//! `profiles/CHANGELOG.txt`. The conversion is reproducible from
//! `tools/film-profiles/convert.py` rather than pasted, so what changed is
//! auditable. The sRGB reflectance basis is Mallett & Yuksel (2019).
pub mod bake;
mod built_in;
pub mod profile;
pub mod spectrum;
pub mod tables;
pub use bake::{bake, Baked, Recipe};
pub use profile::{Kind, Profile, Stage, Support};
use built_in::BUILT_IN;
/// Every stock that is compiled in, parsed on first use.
pub fn built_in() -> &'static [Profile] {
use std::sync::OnceLock;
static PARSED: OnceLock<Vec<Profile>> = OnceLock::new();
PARSED.get_or_init(|| {
BUILT_IN
.iter()
.filter_map(|(stock, yaml)| match Profile::parse(yaml) {
Ok(p) => Some(p),
Err(e) => {
// A compiled-in profile that does not parse is a build
// mistake, but refusing to start over one would take the
// whole application down for a stock nobody asked for.
log::error!("built-in film profile {stock} is malformed: {e}");
None
}
})
.collect()
})
}
/// The stocks a photographer can put in a camera.
///
/// Filtered on [`Stage`], not on [`Support`], and the difference is not
/// pedantry: Kodak 2383 is a *film* that a negative is printed onto, so a
/// picker built on `Support` would offer a projection print stock as something
/// to shoot on.
pub fn camera_stocks() -> impl Iterator<Item = &'static Profile> {
built_in().iter().filter(|p| p.stage == Stage::Filming)
}
/// Find a compiled-in stock by its identifier.
pub fn find(stock: &str) -> Option<&'static Profile> {
built_in().iter().find(|p| p.stock == stock)
}
/// The paper a stock should be printed on, if it names one and we have it.
///
/// A reversal stock names none, and needs none: it is the picture already.
pub fn default_print(film: &Profile) -> Option<&'static Profile> {
film.target_print.as_deref().and_then(find)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_built_in_profile_parses() {
assert_eq!(
built_in().len(),
BUILT_IN.len(),
"a built-in profile failed to parse"
);
}
#[test]
fn a_negative_finds_the_paper_it_names() {
let portra = find("kodak_portra_400").unwrap();
assert_eq!(default_print(portra).unwrap().stock, "kodak_portra_endura");
}
#[test]
fn every_shipped_stock_bakes() {
// Cheap and worth it: a profile can parse and still be unusable — a
// curve that never reaches the density its paper needs, a sensitivity
// table that is all sentinel. Baking every one is the only check that
// says the whole database renders.
for film in camera_stocks() {
let baked = bake::bake(&bake::Recipe::new(film, default_print(film)));
let mid = baked.apply([bake::MID_GREY; 3]);
assert!(
mid.iter().all(|c| c.is_finite()),
"{} rendered mid-grey as {mid:?}",
film.stock
);
}
}
#[test]
fn a_projection_print_stock_is_not_offered_as_a_camera_film() {
// Kodak 2383 is `support: film` and is nevertheless the Vision3
// stocks' paper. Filtering on support alone would put it in the picker.
assert!(find("kodak_2383").is_some(), "the profile is shipped");
assert!(
!camera_stocks().any(|p| p.stock == "kodak_2383"),
"a projection print film is being offered as a camera stock"
);
}
#[test]
fn a_reversal_stock_names_no_paper() {
let k64 = find("kodak_kodachrome_64").unwrap();
assert_eq!(k64.kind, Kind::Positive);
assert!(default_print(k64).is_none());
}
}