//! TRACES: FR-DEV-6 //! An edit lifted off one photograph and dropped onto another. //! //! # This is the same data a sidecar already stores //! //! A [`Preset`] is a map of `(op, param) -> value` holding only what differs //! from default — which is precisely [`Version::params`](crate::Version). That //! is not a coincidence to be tidied away later: copying settings between two //! images and persisting one image's settings are the same operation seen from //! two ends, so they share one representation and one capture routine //! ([`Preset::capture`], which `sidecar` calls). A second, parallel notion of //! "a bundle of parameter values" would be a second thing to keep in step with //! the descriptors. //! //! It follows that this module names no operation either, with the single //! exception of framing — for the reason below, which is a statement about //! photographs rather than about code. //! //! # Why framing is its own scope //! //! A crop is a decision about *this* photograph's composition. Copying colour //! from one frame to the next is what a photographer means by "make these //! match"; copying the crop as well re-frames every one of them to a rectangle //! chosen while looking at a different picture, and on a batch of forty that is //! forty compositions destroyed by one action. //! //! So the default [`Scope::Adjustments`] leaves the target's framing where it //! is, and [`Scope::Everything`] is available for the case the exclusion exists //! to protect against being impossible otherwise — applying one aspect ratio //! across a shoot. The choice is the caller's; neither is hardcoded here. //! //! # Why applying replaces rather than overlays //! //! Within its scope, [`Preset::apply`] resets first. A parameter absent from //! the preset means *default*, exactly as absence means default in a sidecar //! — so pasting a neutral edit clears the target rather than leaving the //! target's own exposure standing underneath. Overlaying would make the result //! depend on what the target happened to hold, and "these two images now match" //! is the whole claim the action makes. use std::collections::BTreeMap; use crate::descriptor::{OpId, ParamId}; use crate::graph::EditGraph; /// Which part of an edit a copy carries. /// /// Two variants rather than a per-operation mask because the distinction being /// drawn is not "which operations" but "is this about the picture's colour or /// about its shape". Everything in the chain but framing answers the first. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Scope { /// Colour and tone. The target keeps its own crop, straightening, /// rotation and flips. #[default] Adjustments, /// The whole edit, framing included. Everything, } impl Scope { /// Whether this scope reaches the named operation. /// /// Takes a `&str` rather than an [`OpId`] because the caller may be /// holding a name read from a file, which has no `'static` lifetime to /// offer — the sidecar path amends a parameter map without ever building /// a graph. pub fn covers(self, op: &str) -> bool { match self { Self::Everything => true, Self::Adjustments => op != crate::framing::ID.0, } } } /// A set of non-default parameter values, ready to apply elsewhere. /// /// Ordered, so two captures of the same edit compare equal and a caller can /// tell whether the clipboard actually changed. #[derive(Debug, Clone, PartialEq, Default)] pub struct Preset { params: BTreeMap<(String, String), f32>, } impl Preset { /// Every non-default parameter in `graph`. /// /// Walks [`EditGraph::capabilities`] — the same list the panel builds /// controls from and the sidecar persists — so an operation is copyable by /// virtue of being in the chain, with nothing to register (FR-DEV-3c). /// /// Captured at full [`Scope::Everything`]: filtering happens when the /// preset is *applied*, not when it is taken. Otherwise the clipboard would /// have to be re-copied to change one's mind about framing, and a preset /// that had already discarded the crop could never grow it back. pub fn capture(graph: &EditGraph) -> Self { let mut params = BTreeMap::new(); for cap in graph.capabilities() { for p in &cap.params { if p.is_modified() { params.insert((cap.id.0.to_string(), p.id.0.to_string()), p.value); } } } Self { params } } /// Build from an already-captured parameter map — a sidecar's, typically. pub fn from_params(params: BTreeMap<(String, String), f32>) -> Self { Self { params } } /// The parameters, for a caller that stores them. pub fn params(&self) -> &BTreeMap<(String, String), f32> { &self.params } /// Consume into the parameter map. pub fn into_params(self) -> BTreeMap<(String, String), f32> { self.params } /// Whether this preset carries anything at all. /// /// An empty preset is a *neutral* edit rather than a missing one, and /// applying it is meaningful: it returns the target to default. What this /// answers is whether there is a clipboard to offer, which is why the UI /// asks it before enabling a paste. pub fn is_empty(&self) -> bool { self.params.is_empty() } /// How many parameters were captured. pub fn len(&self) -> usize { self.params.len() } /// Whether this preset carries any framing — a crop, a rotation, a flip or /// a straightening angle. /// /// What the interface asks to decide whether offering "include crop and /// rotation" would change anything for *this* clipboard. Offering it on a /// copy that has no framing in it promises an effect that cannot happen. pub fn touches_framing(&self) -> bool { self.params .keys() .any(|(op, _)| !Scope::Adjustments.covers(op)) } /// How many operations this preset touches, in scope. /// /// For the interface's "3 adjustments" readout. Counted over operations /// rather than parameters because thirty-six mixer sliders is a number /// about the mixer's shape, not about how much was copied. pub fn op_count(&self, scope: Scope) -> usize { let mut ops: Vec<&str> = self .params .keys() .map(|(op, _)| op.as_str()) .filter(|op| scope.covers(op)) .collect(); ops.sort_unstable(); ops.dedup(); ops.len() } /// Apply to a graph, replacing whatever it held within `scope`. /// /// Parameters this build does not recognise are skipped with a warning by /// the same route a sidecar's are — a preset may have been captured by a /// newer build, and an unknown name must cost its own line rather than the /// paste. /// /// The **view is preserved**. Zoom and pan say where the user is looking, /// not what the photograph is; resetting the framing would throw them back /// to a fitted view mid-comparison, which reads as the paste having /// navigated somewhere. This mirrors `DevelopSession::reset_framing`, and /// lives here so every caller inherits it rather than each remembering. pub fn apply(&self, graph: &mut EditGraph, scope: Scope) { let view = graph.framing().view(); // Clear the scope first, so absence means default (see the module // note). Collected before writing because `capabilities` borrows the // graph and `set_param` needs it mutably. let clears: Vec<(OpId, ParamId, f32)> = graph .capabilities() .iter() .filter(|cap| scope.covers(cap.id.0)) .flat_map(|cap| cap.params.iter().map(|p| (cap.id, p.id, p.default))) .collect(); for (op, param, default) in clears { graph.set_param(op, param, default); } for ((op, param), value) in &self.params { if !scope.covers(op) { continue; } let Some((op, param)) = resolve(graph, op, param) else { log::warn!("preset: unknown parameter {op}.{param}; ignoring"); continue; }; graph.set_param(op, param, *value); } graph.framing_mut().set_view(view); } /// Apply to a parameter map — the sidecar of an image that is not open. /// /// The batch path. Applying to forty images by loading forty edit graphs /// would mean instantiating the whole chain forty times to move some /// numbers between two maps; the graph adds nothing here because there is /// no rendering to do and clamping happens when the file is next read into /// one ([`EditGraph::set_param`] clamps, and `Version::apply` goes through /// it). /// /// Same replacement rule as [`Self::apply`]: the target's in-scope keys go, /// the preset's arrive, and out-of-scope keys — the target's own crop, on /// the default scope — are left exactly as they were. pub fn amend(&self, target: &mut BTreeMap<(String, String), f32>, scope: Scope) { target.retain(|(op, _), _| !scope.covers(op)); for ((op, param), value) in &self.params { if scope.covers(op) { target.insert((op.clone(), param.clone()), *value); } } } } /// Find the `'static` ids matching these names, or `None` if this build has no /// such parameter. /// /// Looking them up in the descriptors rather than leaking the caller's strings /// is what bounds memory: a name read from a file or carried on a clipboard /// never becomes a `'static`. pub(crate) fn resolve(graph: &EditGraph, op: &str, param: &str) -> Option<(OpId, ParamId)> { let cap = graph.capabilities().into_iter().find(|c| c.id.0 == op)?; let p = cap.params.iter().find(|p| p.id.0 == param)?; Some((cap.id, p.id)) } #[cfg(test)] mod tests { use super::*; use crate::framing; use crate::ops::{exposure, saturation, white_balance}; use crate::CropRect; /// A graph with colour *and* framing moved off default, which is what makes /// the scope distinction observable. fn edited() -> EditGraph { let mut g = EditGraph::default_chain(); g.set_param(exposure::ID, exposure::EXPOSURE, 0.75); g.set_param(white_balance::ID, white_balance::TEMPERATURE, 30.0); g.set_crop(CropRect { x: 0.1, y: 0.1, width: 0.5, height: 0.5, }); g.set_param(framing::ID, framing::ANGLE, -2.0); g } #[test] fn a_copy_carries_only_what_was_changed() { // The property the whole format rests on, restated for the clipboard: // a neutral operation contributes nothing, so pasting cannot carry a // value the source never set. let preset = Preset::capture(&edited()); assert!(preset .params() .contains_key(&("exposure".into(), "exposure".into()))); assert!( !preset.params().keys().any(|(op, _)| op == saturation::ID.0), "an untouched operation must not be copied" ); } #[test] fn a_neutral_graph_copies_nothing() { assert!(Preset::capture(&EditGraph::default_chain()).is_empty()); } #[test] fn a_copy_captures_framing_even_though_the_default_scope_drops_it() { // Capture is deliberately unfiltered: the decision about framing is // made at paste time, so a user who ticks "include crop" after copying // must not have to copy again. let preset = Preset::capture(&edited()); assert!(preset.touches_framing()); } #[test] fn pasting_adjustments_leaves_the_targets_composition_alone() { // The case the default scope exists for: two photographs framed // differently, made to match in colour without either being re-cropped. let source = edited(); let preset = Preset::capture(&source); let mut target = EditGraph::default_chain(); let target_crop = CropRect { x: 0.0, y: 0.25, width: 1.0, height: 0.5, }; target.set_crop(target_crop); preset.apply(&mut target, Scope::Adjustments); assert_eq!( target.param(exposure::ID, exposure::EXPOSURE), Some(0.75), "the colour must arrive" ); assert!( (target.crop().width - target_crop.width).abs() < 1e-5 && (target.crop().y - target_crop.y).abs() < 1e-5, "the target's own crop must survive: {:?}", target.crop() ); assert_eq!( target.param(framing::ID, framing::ANGLE), Some(0.0), "the source's straightening must not travel on this scope" ); } #[test] fn pasting_everything_carries_the_composition_too() { let preset = Preset::capture(&edited()); let mut target = EditGraph::default_chain(); preset.apply(&mut target, Scope::Everything); assert_eq!(target.param(framing::ID, framing::ANGLE), Some(-2.0)); assert!( (target.crop().width - 0.5).abs() < 1e-5, "{:?}", target.crop() ); } #[test] fn pasting_replaces_rather_than_overlaying() { // Pasting a neutral copy must *clear* the target. Were this an // overlay, "make these match" would leave whatever the target already // had underneath, and the two images would not in fact match. let neutral = Preset::capture(&EditGraph::default_chain()); let mut target = EditGraph::default_chain(); target.set_param(exposure::ID, exposure::EXPOSURE, 2.0); target.set_param(saturation::ID, saturation::SATURATION, -50.0); neutral.apply(&mut target, Scope::Adjustments); assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(0.0)); assert_eq!( target.param(saturation::ID, saturation::SATURATION), Some(0.0) ); } #[test] fn a_paste_that_excludes_framing_does_not_clear_the_targets_framing() { // The other half of the replacement rule: "replace" is scoped. A // neutral paste must not straighten the target back to zero, or // excluding framing would still destroy it — just to a different value. let neutral = Preset::capture(&EditGraph::default_chain()); let mut target = EditGraph::default_chain(); target.set_param(framing::ID, framing::ANGLE, 3.5); neutral.apply(&mut target, Scope::Adjustments); assert_eq!(target.param(framing::ID, framing::ANGLE), Some(3.5)); } #[test] fn pasting_leaves_the_view_where_the_user_was_looking() { // Zoom is navigation, not an edit. A paste that reset it would throw // the user out of a 4x inspection they were making the comparison at. let preset = Preset::capture(&edited()); let mut target = EditGraph::default_chain(); target.framing_mut().set_view(CropRect { x: 0.25, y: 0.25, width: 0.25, height: 0.25, }); preset.apply(&mut target, Scope::Everything); assert!( target.framing().is_zoomed(), "the paste threw away the viewport: {:?}", target.framing().view() ); } #[test] fn a_paste_does_not_disturb_how_the_file_stored_its_pixels() { // Orientation is a fact about the target's own file. A preset copied // from a landscape frame must not lay that frame's sensor scan over a // portrait one — the image would open on its side. let preset = Preset::capture(&edited()); let mut sideways = EditGraph::default_chain(); sideways.set_orientation(dr_types::Orientation::from_exif(6)); preset.apply(&mut sideways, Scope::Everything); assert_eq!( sideways.framing().baseline(), dr_types::Orientation::from_exif(6) ); } #[test] fn an_unknown_parameter_costs_its_own_line_and_not_the_paste() { // A clipboard captured by a newer build. The rest must still apply, or // one unfamiliar operation would silently discard the whole copy. let mut params = BTreeMap::new(); params.insert(("time_machine".to_string(), "year".to_string()), 1994.0); params.insert(("exposure".to_string(), "exposure".to_string()), 1.25); let mut target = EditGraph::default_chain(); Preset::from_params(params).apply(&mut target, Scope::Adjustments); assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(1.25)); } #[test] fn an_out_of_range_value_is_clamped_rather_than_trusted() { let mut params = BTreeMap::new(); params.insert(("exposure".to_string(), "exposure".to_string()), 99.0); let mut target = EditGraph::default_chain(); Preset::from_params(params).apply(&mut target, Scope::Adjustments); assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(5.0)); } // --- the batch path, which has no graph --------------------------------- #[test] fn amending_a_map_replaces_the_scope_and_spares_the_rest() { let preset = Preset::capture(&edited()); // A target that has its own crop and its own, different, exposure. let mut target = BTreeMap::new(); target.insert(("exposure".to_string(), "exposure".to_string()), -1.0); target.insert(("saturation".to_string(), "saturation".to_string()), 40.0); target.insert(("framing".to_string(), "crop_w".to_string()), 0.3); preset.amend(&mut target, Scope::Adjustments); assert_eq!( target.get(&("exposure".to_string(), "exposure".to_string())), Some(&0.75), "the copied value must land" ); assert!( !target.contains_key(&("saturation".to_string(), "saturation".to_string())), "an in-scope key the preset does not set must be cleared, not kept" ); assert_eq!( target.get(&("framing".to_string(), "crop_w".to_string())), Some(&0.3), "the target's own crop must survive an adjustments-only paste" ); } #[test] fn amending_at_full_scope_replaces_the_targets_crop_too() { let preset = Preset::capture(&edited()); let mut target = BTreeMap::new(); target.insert(("framing".to_string(), "crop_w".to_string()), 0.3); preset.amend(&mut target, Scope::Everything); assert_eq!( target.get(&("framing".to_string(), "crop_w".to_string())), Some(&0.5) ); } #[test] fn the_map_path_and_the_graph_path_agree() { // Two routes to the same result — one through a graph, one through a // bare map — and a batch apply must not produce a different edit from // pasting onto the image with it open. Asserted by round-tripping the // amended map back through a graph and comparing every parameter. let preset = Preset::capture(&edited()); for scope in [Scope::Adjustments, Scope::Everything] { let mut target_graph = EditGraph::default_chain(); target_graph.set_param(saturation::ID, saturation::SATURATION, 20.0); target_graph.set_param(framing::ID, framing::ANGLE, 4.0); let mut target_map = Preset::capture(&target_graph).into_params(); preset.apply(&mut target_graph, scope); preset.amend(&mut target_map, scope); let mut rebuilt = EditGraph::default_chain(); Preset::from_params(target_map).apply(&mut rebuilt, Scope::Everything); for cap in target_graph.capabilities() { for p in &cap.params { assert_eq!( rebuilt.param(cap.id, p.id), Some(p.value), "{scope:?}: {}.{} differs between the graph and map paths", cap.id, p.id ); } } } } #[test] fn a_copy_round_trips_through_a_paste() { // The end-to-end claim the feature makes: after pasting, the target // holds the source's edit. Checked over the whole chain rather than a // sample, so an operation that needed special handling would fail here. let source = edited(); let preset = Preset::capture(&source); let mut target = EditGraph::default_chain(); preset.apply(&mut target, Scope::Everything); for cap in source.capabilities() { for p in &cap.params { assert_eq!( target.param(cap.id, p.id), Some(p.value), "{}.{} did not survive the copy", cap.id, p.id ); } } } #[test] fn operations_are_counted_in_scope() { let preset = Preset::capture(&edited()); // exposure, white_balance and framing were touched. assert_eq!(preset.op_count(Scope::Everything), 3); assert_eq!(preset.op_count(Scope::Adjustments), 2); } #[test] fn scope_names_framing_and_nothing_else() { // The one operation this module knows by name. If a second ever // appears here, it should be because someone decided it is about the // picture's shape rather than because it was convenient. let g = EditGraph::default_chain(); let excluded: Vec<&str> = g .capabilities() .iter() .map(|c| c.id.0) .filter(|id| !Scope::Adjustments.covers(id)) .collect(); assert_eq!(excluded, vec![framing::ID.0]); } }