Offer the lens profile as a tick box, since applying it silently reads as absent
The develop panel's Optics group is three manual sliders: distortion, chromatic aberration and lens vignetting. The automatic correction was already there — the file's EXIF lens is matched against the bundled Lensfun database on open and the coefficients are fanned out to all three — but nothing in the interface said so except a line of grey text under the camera reading "· corrected", and there was no way to decline it. From the outside that is indistinguishable from the feature not existing, which is how it was read. `dr-lens` states the rule this breaks: an automatic correction that silently does nothing is worse than one the user can see is unavailable. The caption satisfied the letter of it and not the point — a photographer looking for "apply the lens profile" found three sliders and no switch. So the profile is now a control. It is a capability rather than a flag on the session, because everything a photographer sets travels one road: the capability list feeds the generated panel, `Preset` captures it, the sidecar stores it and the undo stack replays it. A bool on the side would have needed adding to each of those four by hand and would have been forgotten in at least one — which is exactly how the mask stack came to be missing from the history. It is on by default, which is what `switch_on` is for: the coefficients are a measurement of the lens that took the photograph, so accepting them is neutral and declining them is the edit. The sidecar therefore stores nothing for the ordinary case and the correction still happens. The switch appears only where a profile was matched. A tick box on a photograph whose lens the database has never heard of would be a control that looks available and does nothing, which is the failure the rule above names rather than an instance of following it — those photographs are told "· no profile" in words instead, and one whose box is unticked now says "· profile off", which is a third fact and not either of the other two. Two things had to be built underneath. `ParamKind::Bool` was in the core's closed enum and mapped to a row kind here, and had no control behind it in `adjust.slint`: a parameter declaring itself a switch was flattened into a row that drew nothing at all. Nothing shipped had one until now, so the gap cost nothing and was invisible. And `Check` self-toggled, which is right for a settings page that owns its value and wrong for a panel row that is a view of the edit graph — the click would have answered by replacing the binding with a literal, and the next undo or pasted preset would have moved the value with the tick left where the finger put it. It now takes `controlled`, and the generated row uses it. The manual sliders are unchanged and still trim whatever the profile leaves, so switching it off is "correct this by hand" rather than "stop correcting".
This commit is contained in:
@@ -148,6 +148,21 @@ pub struct EditGraph {
|
||||
/// derived from the file's EXIF and a database, exactly as the film's
|
||||
/// baked tables are derived from a stock's id.
|
||||
lens_profile: Option<LensProfile>,
|
||||
/// Whether the profile above is being used.
|
||||
///
|
||||
/// **The one part of automatic lens correction that is an edit.** The
|
||||
/// coefficients are a measurement of the lens and belong to the file;
|
||||
/// whether to accept them is the photographer's, and it is the answer a
|
||||
/// tick box in the panel gives. So this rides with the parameters rather
|
||||
/// than with the profile: it is published as
|
||||
/// [`crate::lens::profile_switch`], captured by [`Preset`], stored in the
|
||||
/// sidecar and replayed by the undo stack, all by the one road every other
|
||||
/// setting travels (FR-DEV-3c).
|
||||
///
|
||||
/// True on a fresh graph, because a profile that was found is a
|
||||
/// correction the photograph asked for — see
|
||||
/// [`crate::descriptor::ParamDescriptor::switch_on`].
|
||||
lens_profile_applied: bool,
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3f
|
||||
@@ -203,6 +218,7 @@ impl EditGraph {
|
||||
Box::new(crate::ops::Aberration::new()),
|
||||
],
|
||||
lens_profile: None,
|
||||
lens_profile_applied: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,6 +325,51 @@ impl EditGraph {
|
||||
/// [`crate::Operation::set_lens_profile`].
|
||||
pub fn set_lens_profile(&mut self, profile: Option<LensProfile>) {
|
||||
self.lens_profile = profile;
|
||||
self.fan_out_lens_profile();
|
||||
}
|
||||
|
||||
/// The profile this photograph was matched to, if any.
|
||||
///
|
||||
/// Reports what was *found*, not what is in effect: a profile the
|
||||
/// photographer has switched off is still the profile for this lens, and
|
||||
/// the switch is what says whether it is being used. A caller wanting the
|
||||
/// coefficients that are actually in the shader asks
|
||||
/// [`Self::lens_profile_applied`] as well — which is what the interface's
|
||||
/// lens line does, because "corrected" and "correction available, off" are
|
||||
/// two different things to tell a photographer.
|
||||
pub fn lens_profile(&self) -> Option<&LensProfile> {
|
||||
self.lens_profile.as_ref()
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// Whether the matched profile is being applied.
|
||||
pub fn lens_profile_applied(&self) -> bool {
|
||||
self.lens_profile_applied
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// Use the matched profile, or decline it.
|
||||
///
|
||||
/// Reached through `set_param` by the panel, like every other setting;
|
||||
/// this is the named door for a caller that has a `bool` rather than a
|
||||
/// parameter id.
|
||||
///
|
||||
/// Setting this with no profile matched is meaningful and harmless: a
|
||||
/// preset carrying the switch may land on a photograph whose lens the
|
||||
/// database has never heard of, and remembering the answer costs nothing.
|
||||
pub fn set_lens_profile_applied(&mut self, applied: bool) {
|
||||
self.lens_profile_applied = applied;
|
||||
self.fan_out_lens_profile();
|
||||
}
|
||||
|
||||
/// Hand every correction the coefficients it should be using.
|
||||
///
|
||||
/// `None` where the switch is off, which is the same call opening an
|
||||
/// unrecognised lens makes — the corrections cannot tell the difference
|
||||
/// between "no profile" and "not this one, thank you", and have no reason
|
||||
/// to.
|
||||
fn fan_out_lens_profile(&mut self) {
|
||||
let profile = self.lens_profile.filter(|_| self.lens_profile_applied);
|
||||
for warp in &mut self.warps {
|
||||
warp.set_profile(profile.as_ref());
|
||||
}
|
||||
@@ -317,11 +378,6 @@ impl EditGraph {
|
||||
}
|
||||
}
|
||||
|
||||
/// The profile currently applied, if any.
|
||||
pub fn lens_profile(&self) -> Option<&LensProfile> {
|
||||
self.lens_profile.as_ref()
|
||||
}
|
||||
|
||||
/// Descriptors for the coordinate-domain lens corrections, in order.
|
||||
///
|
||||
/// The counterpart to [`Self::descriptors`] and split from it for the same
|
||||
@@ -433,7 +489,46 @@ impl EditGraph {
|
||||
attributes: desc.attributes.clone(),
|
||||
};
|
||||
|
||||
warps.chain(ops).chain(std::iter::once(framing)).collect()
|
||||
// The lens profile switch, ahead of the corrections it drives — and
|
||||
// **only when a profile was matched**. A tick box on a photograph
|
||||
// whose lens the database has never heard of would be a control that
|
||||
// does nothing, which is the failure `dr_lens` names: an automatic
|
||||
// correction is allowed to be unavailable and is not allowed to look
|
||||
// available and be inert. Where there is no profile the interface says
|
||||
// so in words instead.
|
||||
let switch = self.lens_profile.map(|_| {
|
||||
let desc = crate::lens::profile_switch::descriptor();
|
||||
OpCapability {
|
||||
id: desc.id,
|
||||
label: desc.label,
|
||||
// A profile that is on is doing something to this photograph,
|
||||
// which is what the panel's modified marker is for. Off is the
|
||||
// departure from default, and reads as one either way.
|
||||
active: self.lens_profile_applied,
|
||||
params: desc
|
||||
.params
|
||||
.iter()
|
||||
.map(|p| ParamCapability {
|
||||
id: p.id,
|
||||
label: p.label,
|
||||
kind: p.kind.clone(),
|
||||
default: p.default,
|
||||
value: if self.lens_profile_applied { 1.0 } else { 0.0 },
|
||||
facet: p.facet,
|
||||
})
|
||||
.collect(),
|
||||
// An ordinary switch. Nothing about it wants the canvas.
|
||||
presentation: None,
|
||||
attributes: desc.attributes.clone(),
|
||||
}
|
||||
});
|
||||
|
||||
switch
|
||||
.into_iter()
|
||||
.chain(warps)
|
||||
.chain(ops)
|
||||
.chain(std::iter::once(framing))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3a
|
||||
@@ -517,6 +612,11 @@ impl EditGraph {
|
||||
// rather than restored — the same reason the film's tables travel
|
||||
// as an id and not as numbers.
|
||||
lens_profile: _,
|
||||
// Whether that profile is *used* is an edit, and it is in the
|
||||
// state below: it reaches `Preset::capture` through
|
||||
// `capabilities`, with the operations and the warps and for the
|
||||
// same reason (FR-DEV-3c).
|
||||
lens_profile_applied: _,
|
||||
masks,
|
||||
film,
|
||||
spots,
|
||||
@@ -582,6 +682,18 @@ impl EditGraph {
|
||||
}
|
||||
|
||||
pub fn set_param(&mut self, op: OpId, param: ParamId, value: f32) {
|
||||
if op == crate::lens::profile_switch::ID {
|
||||
if param != crate::lens::profile_switch::APPLY {
|
||||
log::warn!("unknown parameter {param} on {op}; ignoring");
|
||||
return;
|
||||
}
|
||||
// Accepted whether or not a profile was matched, for the reason
|
||||
// `set_lens_profile_applied` gives: a preset may carry the answer
|
||||
// to a photograph that has nothing to apply it to.
|
||||
self.set_lens_profile_applied(value != 0.0);
|
||||
return;
|
||||
}
|
||||
|
||||
if op == crate::framing::ID {
|
||||
// Bound rather than chained: `descriptor()` hands back an owned
|
||||
// `Arc` now, so a `param()` borrowed straight out of the call
|
||||
@@ -627,6 +739,10 @@ impl EditGraph {
|
||||
|
||||
/// Read a parameter back.
|
||||
pub fn param(&self, op: OpId, param: ParamId) -> Option<f32> {
|
||||
if op == crate::lens::profile_switch::ID {
|
||||
return (param == crate::lens::profile_switch::APPLY)
|
||||
.then_some(if self.lens_profile_applied { 1.0 } else { 0.0 });
|
||||
}
|
||||
if op == crate::framing::ID {
|
||||
return self
|
||||
.framing
|
||||
@@ -665,6 +781,10 @@ impl EditGraph {
|
||||
// stock, and turning a name into tables needs the profile database,
|
||||
// which this crate deliberately does not link (ARCH §6.5a).
|
||||
self.set_film(None);
|
||||
// The *matched profile* stays — it is the file's, not the edit's, and
|
||||
// a reset does not change which lens took the photograph. What returns
|
||||
// to default is the answer to whether to use it, which is on.
|
||||
self.set_lens_profile_applied(true);
|
||||
}
|
||||
|
||||
/// Set the crop rectangle. Clamped to keep it inside the frame.
|
||||
@@ -1114,6 +1234,171 @@ mod tests {
|
||||
assert!(g.lens_profile().is_none());
|
||||
}
|
||||
|
||||
/// A measured profile, for the switch's tests. Coefficients are one real
|
||||
/// wide-angle's, rounded — what matters is that each of the three
|
||||
/// corrections gets something to do.
|
||||
fn measured() -> crate::lens::LensProfile {
|
||||
use crate::lens::{LensProfile, Tca};
|
||||
use crate::ops::{distortion, vignetting};
|
||||
|
||||
LensProfile {
|
||||
distortion: Some(distortion::PtLens {
|
||||
a: 0.0,
|
||||
b: -0.012,
|
||||
c: 0.0,
|
||||
}),
|
||||
tca: Some(Tca {
|
||||
red_scale: 1.000_32,
|
||||
blue_scale: 0.999_93,
|
||||
}),
|
||||
vignetting: Some(vignetting::Pa {
|
||||
k1: -0.42,
|
||||
k2: 0.05,
|
||||
k3: 0.0,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// The switch takes the whole profile out of the shader, and puts it back.
|
||||
///
|
||||
/// The control the panel draws is this call, and what it has to mean is
|
||||
/// "develop this photograph as if the database had never heard of the
|
||||
/// lens" — all three corrections, not the geometry alone.
|
||||
#[test]
|
||||
fn declining_the_profile_removes_every_correction_it_was_driving() {
|
||||
use crate::lens::profile_switch;
|
||||
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_lens_profile(Some(measured()));
|
||||
assert!(g.compose().source.contains("---- warp: distortion ----"));
|
||||
|
||||
g.set_param(profile_switch::ID, profile_switch::APPLY, 0.0);
|
||||
let declined = g.compose().source;
|
||||
assert!(!declined.contains("---- warp: "), "{declined}");
|
||||
assert!(!declined.contains("---- vignetting ----"));
|
||||
|
||||
// The profile itself is untouched. It is a fact about the file, and
|
||||
// the interface still has to be able to say which lens this was.
|
||||
assert!(
|
||||
g.lens_profile().is_some(),
|
||||
"declining a profile must not forget it, or the switch could \
|
||||
never be turned back on"
|
||||
);
|
||||
|
||||
g.set_param(profile_switch::ID, profile_switch::APPLY, 1.0);
|
||||
assert!(g.compose().source.contains("---- warp: distortion ----"));
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// The manual trims keep working with the profile declined.
|
||||
///
|
||||
/// The two are independent by construction — each correction composes the
|
||||
/// profile with its own slider — and that is what makes the switch safe to
|
||||
/// offer: turning it off is "correct this by hand", not "stop correcting".
|
||||
#[test]
|
||||
fn declining_the_profile_leaves_the_manual_corrections_alone() {
|
||||
use crate::lens::profile_switch;
|
||||
use crate::ops::distortion;
|
||||
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_lens_profile(Some(measured()));
|
||||
g.set_param(distortion::ID, distortion::AMOUNT, 40.0);
|
||||
g.set_param(profile_switch::ID, profile_switch::APPLY, 0.0);
|
||||
|
||||
assert_eq!(g.param(distortion::ID, distortion::AMOUNT), Some(40.0));
|
||||
assert!(
|
||||
g.compose().source.contains("---- warp: distortion ----"),
|
||||
"the slider still bends the frame with the profile switched off"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// The switch is only offered where there is a profile to switch.
|
||||
///
|
||||
/// `dr_lens`'s rule, as a property of the capability list: a tick box on a
|
||||
/// photograph whose lens the database has never heard of would be a
|
||||
/// control that looks available and does nothing, which is the failure the
|
||||
/// automatic correction is supposed to avoid rather than an instance of
|
||||
/// it.
|
||||
#[test]
|
||||
fn the_switch_is_absent_until_a_profile_is_matched() {
|
||||
use crate::lens::profile_switch;
|
||||
|
||||
let mut g = EditGraph::default_chain();
|
||||
assert!(
|
||||
!g.capabilities().iter().any(|c| c.id == profile_switch::ID),
|
||||
"an unmatched lens must not grow a tick box"
|
||||
);
|
||||
|
||||
g.set_lens_profile(Some(measured()));
|
||||
let cap = g
|
||||
.capabilities()
|
||||
.into_iter()
|
||||
.find(|c| c.id == profile_switch::ID)
|
||||
.expect("a matched profile is offered as a control");
|
||||
assert_eq!(cap.params.len(), 1);
|
||||
assert_eq!(cap.params[0].kind, ParamKind::Bool);
|
||||
// On, and on is the default — so a photograph nobody has touched
|
||||
// carries nothing in its sidecar and is still corrected.
|
||||
assert_eq!(cap.params[0].value, 1.0);
|
||||
assert_eq!(cap.params[0].default, 1.0);
|
||||
assert!(!cap.params[0].is_modified());
|
||||
|
||||
// Ahead of the corrections it drives, so the panel reads top down:
|
||||
// the profile, then what to add to it by hand.
|
||||
let ids: Vec<&str> = g.capabilities().iter().map(|c| c.id.0).collect();
|
||||
assert_eq!(ids.first(), Some(&profile_switch::ID.0));
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3 | FR-DEV-5
|
||||
/// Declining the profile is an edit, so it travels like one.
|
||||
///
|
||||
/// The reason it is a parameter at all: capture and apply are the road the
|
||||
/// sidecar, the clipboard and the undo stack all take, and a `bool` on the
|
||||
/// side would have had to be added to each of them by hand.
|
||||
#[test]
|
||||
fn the_switch_survives_a_state_round_trip() {
|
||||
use crate::lens::profile_switch;
|
||||
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_lens_profile(Some(measured()));
|
||||
g.set_param(profile_switch::ID, profile_switch::APPLY, 0.0);
|
||||
|
||||
let state = g.state();
|
||||
|
||||
// Reopened: the profile is looked up again from the file, and the
|
||||
// stored edit says what to do with it.
|
||||
let mut reopened = EditGraph::default_chain();
|
||||
reopened.set_lens_profile(Some(measured()));
|
||||
assert_eq!(reopened.set_state(&state), FilmRebake::NotNeeded);
|
||||
|
||||
assert_eq!(
|
||||
reopened.param(profile_switch::ID, profile_switch::APPLY),
|
||||
Some(0.0),
|
||||
"a declined profile came back applied, so reopening the \
|
||||
photograph would silently correct it again"
|
||||
);
|
||||
assert!(!reopened.compose().source.contains("---- warp: "));
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// A reset accepts the profile again, and keeps it.
|
||||
#[test]
|
||||
fn resetting_returns_to_the_measured_profile() {
|
||||
use crate::lens::profile_switch;
|
||||
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_lens_profile(Some(measured()));
|
||||
g.set_param(profile_switch::ID, profile_switch::APPLY, 0.0);
|
||||
|
||||
g.reset();
|
||||
|
||||
assert!(g.lens_profile().is_some(), "the file still names a lens");
|
||||
assert!(g.lens_profile_applied());
|
||||
assert!(g.compose().source.contains("---- warp: distortion ----"));
|
||||
}
|
||||
|
||||
/// A warp is an edit, so `reset` has to reach it. It did not until the
|
||||
/// loop was added: a reset that left the lens corrections standing would
|
||||
/// mean "back to the file as it is" quietly did not mean that.
|
||||
|
||||
Reference in New Issue
Block a user