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:
@@ -421,6 +421,27 @@ impl ParamDescriptor {
|
||||
}
|
||||
}
|
||||
|
||||
/// A toggle that is **on** when nothing has been chosen.
|
||||
///
|
||||
/// The reset contract is unchanged — a default is still the neutral value
|
||||
/// and a sidecar still stores only departures from it. What differs is
|
||||
/// which state is neutral, and that is a fact about the setting rather
|
||||
/// than about switches: a correction the file itself asked for is on
|
||||
/// unless the photographer says otherwise, so "off" is the edit and
|
||||
/// storing it is right. A switch written the other way round would have to
|
||||
/// be labelled for its negation — "Ignore the lens profile" — and every
|
||||
/// photograph that simply wanted correcting would carry a stored
|
||||
/// parameter saying so.
|
||||
pub const fn switch_on(id: &'static str, label: &'static str) -> Self {
|
||||
Self {
|
||||
id: ParamId(id),
|
||||
label: LocalizedKey(label),
|
||||
kind: ParamKind::Bool,
|
||||
default: 1.0,
|
||||
facet: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// One of a fixed list of alternatives, defaulting to the first.
|
||||
///
|
||||
/// The first rather than a caller-chosen index, so the reset contract
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -59,9 +59,9 @@
|
||||
//! wearing the same lens, which defeats the point of a lens profile.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use crate::descriptor::{OpDescriptor, ParamId};
|
||||
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
|
||||
use crate::operation::{Helper, Uniform};
|
||||
|
||||
/// Lateral chromatic aberration, as a per-channel radial scale.
|
||||
@@ -90,6 +90,64 @@ pub struct LensProfile {
|
||||
pub vignetting: Option<crate::ops::vignetting::Pa>,
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// The lens profile itself, as one switch a photographer can reach.
|
||||
///
|
||||
/// **Not an operation and not a warp** — it corrects nothing on its own. It
|
||||
/// is the answer to "use the measured profile for this lens, or not", and the
|
||||
/// three corrections that read the coefficients ([`crate::ops::Distortion`],
|
||||
/// [`crate::ops::Aberration`] and the vignetting node) are where the
|
||||
/// correction actually happens. Framing is in the capability list on the same
|
||||
/// terms: something a photographer sets which is not an `Operation`.
|
||||
///
|
||||
/// # Why this exists at all
|
||||
///
|
||||
/// The profile arrives from the file's EXIF and a database, and applying it
|
||||
/// silently was the whole of the interface for it. That reads as the feature
|
||||
/// being absent: the corrections in the panel are manual sliders, the
|
||||
/// automatic part is a line of grey text under the camera, and nothing
|
||||
/// anywhere says "this is on and you may turn it off". `dr_lens`'s honesty
|
||||
/// rule — an automatic correction the user cannot see is worse than one they
|
||||
/// can see is unavailable — asks for a control here, not just a caption.
|
||||
///
|
||||
/// # Why it is a parameter rather than a flag on the session
|
||||
///
|
||||
/// Everything a photographer sets travels by one road: the capability list
|
||||
/// feeds the generated panel, [`crate::Preset`] captures it, the sidecar
|
||||
/// stores it and the undo stack replays it (FR-DEV-3c). A `bool` on the
|
||||
/// session would have needed its own place in each of those four, and would
|
||||
/// have been forgotten in at least one — which is exactly how the mask stack
|
||||
/// came to be missing from the history. As a parameter it is in all of them
|
||||
/// with nothing registered.
|
||||
pub mod profile_switch {
|
||||
use super::*;
|
||||
|
||||
pub const ID: OpId = OpId("lens_profile");
|
||||
pub const APPLY: ParamId = ParamId("apply");
|
||||
|
||||
/// On by default: the profile is a measurement of the lens that took the
|
||||
/// photograph, so applying it is the neutral state and declining it is
|
||||
/// the edit. See [`ParamDescriptor::switch_on`].
|
||||
pub(crate) static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
id: ID,
|
||||
label: LocalizedKey("op.lens_profile"),
|
||||
params: vec![ParamDescriptor::switch_on(
|
||||
"apply",
|
||||
"param.lens_profile.apply",
|
||||
)],
|
||||
// Optics, so it sits with the corrections it drives rather than in
|
||||
// a group of its own.
|
||||
attributes: vec![Attribute::Optics],
|
||||
})
|
||||
});
|
||||
|
||||
/// This switch's description, for a caller building a control from it.
|
||||
pub fn descriptor() -> Arc<OpDescriptor> {
|
||||
DESCRIPTOR.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// A coordinate-domain operation, applied before the source is sampled.
|
||||
///
|
||||
/// Object-safe for the same reason [`crate::operation::Operation`] is: the
|
||||
|
||||
@@ -198,7 +198,16 @@ impl Scope {
|
||||
fn attributes_of(op: &str) -> Option<&'static [Attribute]> {
|
||||
static TABLE: std::sync::LazyLock<BTreeMap<String, Vec<Attribute>>> =
|
||||
std::sync::LazyLock::new(|| {
|
||||
EditGraph::default_chain()
|
||||
let mut chain = EditGraph::default_chain();
|
||||
// With a profile in place, because one capability only exists once
|
||||
// a photograph has brought a lens profile with it — the switch
|
||||
// that accepts or declines it. Table entries are what
|
||||
// [`Scope::covers`] classifies by, and an id missing from here
|
||||
// falls through to `carries_unclassified`: the switch would then
|
||||
// travel with a scope that named only tone, which is precisely
|
||||
// what a scope is for refusing.
|
||||
chain.set_lens_profile(Some(crate::lens::LensProfile::default()));
|
||||
chain
|
||||
.capabilities()
|
||||
.into_iter()
|
||||
.map(|cap| (cap.id.0.to_string(), cap.attributes))
|
||||
@@ -739,6 +748,35 @@ mod tests {
|
||||
assert!(Preset::capture(&EditGraph::default_chain()).is_empty());
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3 | FR-DEV-3c
|
||||
/// The lens profile switch is classified, so a scope can refuse it.
|
||||
///
|
||||
/// It is the one capability that exists only once a photograph has brought
|
||||
/// a lens profile with it, and the attribute table is built from a chain
|
||||
/// — so it was missing from that table before the chain used to build it
|
||||
/// was given a profile. An unclassified id falls through to
|
||||
/// `carries_unclassified`, and a copy of somebody's tone edit would have
|
||||
/// arrived carrying "do not correct this lens".
|
||||
#[test]
|
||||
fn declining_a_lens_profile_is_an_optical_edit() {
|
||||
use crate::lens::profile_switch;
|
||||
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_lens_profile(Some(crate::lens::LensProfile::default()));
|
||||
g.set_param(profile_switch::ID, profile_switch::APPLY, 0.0);
|
||||
|
||||
let preset = Preset::capture(&g);
|
||||
assert!(
|
||||
preset
|
||||
.params()
|
||||
.contains_key(&(profile_switch::ID.0.into(), profile_switch::APPLY.0.into())),
|
||||
"declining the profile is an edit and must be copyable"
|
||||
);
|
||||
|
||||
assert!(Scope::of([Attribute::Optics]).covers(profile_switch::ID.0));
|
||||
assert!(!Scope::of([Attribute::Tone]).covers(profile_switch::ID.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_captures_framing_even_though_the_default_scope_drops_it() {
|
||||
// Capture is deliberately unfiltered: the decision about framing is
|
||||
|
||||
Reference in New Issue
Block a user