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
+52 -2
View File
@@ -760,6 +760,41 @@ fn no_choices() -> slint::ModelRc<slint::SharedString> {
EMPTY.with(Clone::clone)
}
/// The choices model for one enum parameter, built once per variant list.
///
/// Memoised for exactly the reason [`no_choices`] is shared: `ModelRc` compares
/// by *identity*, so building a fresh one each call makes the row differ from
/// itself on every parameter event. `sync_rows` would then replace the row —
/// destroying the elements built from it, including whichever `TouchArea` is
/// holding the current gesture — and the enum's own control would fight every
/// slider drag elsewhere in the panel.
///
/// Curve rows solve the same problem the other way, by writing new values
/// through the existing model. That is not available here: a variant list is
/// fixed at compile time, so the model never needs updating and can simply be
/// the same one every time.
///
/// Keyed on the labels rather than the slice's address, because they are
/// resolved through the UI's catalogue and two operations offering the same
/// choices should share one model.
fn choices_model(labels: &[slint::SharedString]) -> slint::ModelRc<slint::SharedString> {
use std::cell::RefCell;
use std::collections::HashMap;
thread_local! {
static CACHE: RefCell<HashMap<String, slint::ModelRc<slint::SharedString>>> =
RefCell::new(HashMap::new());
}
let key = labels.join("\u{1f}");
CACHE.with(|cache| {
cache
.borrow_mut()
.entry(key)
.or_insert_with(|| slint::ModelRc::new(slint::VecModel::from(labels.to_vec())))
.clone()
})
}
/// Whether this frontend has an implementation of `widget` **anywhere**.
///
/// "Anywhere" is doing real work: a widget may be drawn in the panel, as the
@@ -979,7 +1014,7 @@ pub(crate) fn rows_filtered(
choices: if choices.is_empty() {
no_choices()
} else {
slint::ModelRc::new(slint::VecModel::from(choices))
choices_model(&choices)
},
});
}
@@ -2394,8 +2429,23 @@ impl DevelopSession {
// stock looks comes from. The sensor's width in pixels then says how
// much film one pixel covers, and the grain model needs nothing else
// to be correct at any zoom.
// TRACES: FR-DEV-3f
// The frame this is being simulated on, against the pixels it is being
// rendered to: together they are the enlargement, and the enlargement
// is what decides how grainy the result looks. A crystal is a fixed
// size in micrometres — the same emulsion on a sheet averages far more
// of them into each pixel than it does on 35 mm.
let format = dr_film::Format::from_index(
self.graph
.param(
dr_pipeline::ops::film_sim::ID,
dr_pipeline::ops::film_sim::FORMAT,
)
.unwrap_or(0.0)
.max(0.0) as usize,
);
let (source_width, _) = self.demosaiced.size();
let pixel_size_um = dr_film::grain::FRAME_WIDTH_UM / source_width.max(1) as f32;
let pixel_size_um = format.width_um() / source_width.max(1) as f32;
let grain = dr_film::Grain::for_pixel_size(profile, pixel_size_um);
let baked = dr_film::bake(&dr_film::Recipe {