Ask which frame this was taken on, because grain is enlargement
Build and test / Desktop (Linux) (push) Successful in 19m6s
Build and test / Layer separation (push) Successful in 28s
Traceability / Requirement traces (push) Failing after 26s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Failing after 33m7s

A crystal is a fixed size in micrometres. How grainy a photograph looks is
therefore not a property of the emulsion alone -- it is film size against
output size, and the frame is the half a digital file cannot supply.

This assumed 35 mm for everything. The same emulsion on 4x5 averages about
3,800 crystals into the pixel that holds 300 on 35 mm, so it renders roughly
3.5 times smoother at the same print; every large-format photograph was being
rendered as grainy as a half-frame.

`Format` now carries the real image widths -- the gate, not the nominal inches,
since a "4x5" exposes about 121 mm -- and the film node asks for it. It is a
genuinely fixed list, unlike the stocks, so it is a declared `enum` parameter
and gets its control, its sidecar entry and its undo step for nothing.

It is also the first enum in the develop chain, and it broke two tests by
being one. A row has to compare equal to itself across two builds or
`sync_rows` replaces it on every parameter event -- destroying the elements
built from it, including whichever TouchArea holds the current gesture, so the
format picker would have fought every slider drag in the panel. `ModelRc`
compares by identity and the row built a fresh choices model each call.

`no_choices` already shares one empty model for exactly this reason, and the
build site already said "see no_choices for why the identity matters". The fix
follows it: memoise the model per variant list. Curve rows solve the same
problem the other way, writing values through the existing model, which is not
needed here -- a variant list is fixed at compile time, so one model can serve
forever.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 21:47:19 +02:00
co-authored by Claude Opus 5
parent 871a0eac28
commit e14bc34a9e
5 changed files with 470 additions and 9 deletions
+93 -6
View File
@@ -76,13 +76,65 @@ const RMS_APERTURE_AREA_UM2: f32 = std::f32::consts::PI * 24.0 * 24.0;
/// The net density the granularity figure is quoted at.
const RMS_REFERENCE_NET_DENSITY: f32 = 1.0;
/// A 35 mm frame's width, in micrometres.
/// TRACES: FR-DEV-3f
/// The frame a photograph is being simulated on.
///
/// What turns a pixel count into a grain size. A photograph has no inherent
/// film format, so simulating one means choosing what the frame *would have
/// been*; 35 mm is the choice that makes the numbers mean what a photographer
/// expects, since published granularity and every intuition about how grainy a
/// stock looks come from 35 mm.
/// **Grain is a function of enlargement, and this is the half of it the
/// photograph cannot supply.** A crystal is a fixed size in micrometres, so
/// how grainy a picture looks depends entirely on how much the frame was
/// magnified to make it — and that is film size against output size.
///
/// The same emulsion on 4x5 packs about 3,800 crystals into the pixel that
/// holds 300 on 35 mm, so it renders roughly 3.5 times smoother at the same
/// output size. Treating everything as 35 mm, as this did, made every
/// photograph as grainy as the smallest common format.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Format {
Mm35,
Format645,
Format6x6,
Format6x7,
Sheet4x5,
Sheet8x10,
}
impl Format {
/// The formats, in the order the picker offers them.
///
/// Smallest first, so index zero is 35 mm — the commonest frame, and the
/// one whose grain every published figure and every photographer's
/// intuition is calibrated against.
pub const ALL: [Format; 6] = [
Format::Mm35,
Format::Format645,
Format::Format6x6,
Format::Format6x7,
Format::Sheet4x5,
Format::Sheet8x10,
];
/// The frame's width in micrometres — the *image* area, not the sheet.
pub fn width_um(self) -> f32 {
match self {
Format::Mm35 => 36_000.0,
// 6x4.5 and 6x6 share a 56 mm gate; only the other axis differs,
// and grain scales with the linear magnification of the axis being
// enlarged.
Format::Format645 | Format::Format6x6 => 56_000.0,
Format::Format6x7 => 70_000.0,
// The image area of a sheet, which is smaller than the nominal
// inches: a "4x5" exposes about 121 x 97 mm.
Format::Sheet4x5 => 121_000.0,
Format::Sheet8x10 => 248_000.0,
}
}
pub fn from_index(i: usize) -> Self {
Self::ALL.get(i).copied().unwrap_or(Format::Mm35)
}
}
/// A 35 mm frame's width, in micrometres. The default format.
pub const FRAME_WIDTH_UM: f32 = 36_000.0;
/// TRACES: FR-DEV-3f
@@ -245,6 +297,41 @@ mod tests {
);
}
#[test]
fn a_larger_format_is_less_grainy_at_the_same_output_size() {
// TRACES: FR-DEV-3f
// The point of the whole control, and a fact about photography rather
// than about this code: enlarge 35 mm and 4x5 to the same print and the
// sheet is visibly smoother, because each of its pixels averages far
// more crystals. Treating every frame as 35 mm made a large-format
// photograph as grainy as a small one.
let p = portra();
let out_px = 5472.0;
let small = Grain::for_pixel_size(&p, Format::Mm35.width_um() / out_px);
let large = Grain::for_pixel_size(&p, Format::Sheet4x5.width_um() / out_px);
let d = small.density_max[1] * 0.5;
let ratio = small.sigma(1, d) / large.sigma(1, d);
// Linear magnification is 121/36, so the crystal count per pixel goes
// as its square and sigma as its reciprocal: about 3.4x.
assert!(
(2.5..4.5).contains(&ratio),
"35mm is {ratio:.2}x grainier than 4x5, which is not the enlargement"
);
}
#[test]
fn the_formats_are_ordered_smallest_first() {
// Index zero has to be the neutral choice — 35 mm, which is what every
// published granularity figure is calibrated against.
let widths: Vec<f32> = Format::ALL.iter().map(|f| f.width_um()).collect();
assert_eq!(widths[0], FRAME_WIDTH_UM);
assert!(
widths.windows(2).all(|w| w[0] <= w[1]),
"formats are not ordered by size: {widths:?}"
);
}
#[test]
fn a_pixel_never_holds_less_than_one_grain() {
// Past this the model describes a pixel smaller than a crystal, where