diff --git a/crates/engine/src/crs_masks.rs b/crates/engine/src/crs_masks.rs
index 848251c65..a54bd620d 100644
--- a/crates/engine/src/crs_masks.rs
+++ b/crates/engine/src/crs_masks.rs
@@ -446,337 +446,4 @@ pub fn refit_radials(partial: &mut Value, from: f64, to: f64) {
}
#[cfg(test)]
-mod tests {
- use super::*;
- use lightcraft_develop::{DevelopSettings, MaskOp, MaskShape};
-
- /// A packet in the shape local corrections take (values written for this test).
- const PACKET: &str = r#"
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- d 0.1 0.2d 0.15 0.22
- d 0.5 0.5
-
-
-"#;
-
- fn settings() -> (DevelopSettings, Vec) {
- let d = lightcraft_meta::parse_xmp(PACKET).unwrap();
- let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
- (DevelopSettings::default().merged(&partial).expect("valid settings"), unmapped)
- }
-
- #[test]
- fn corrections_become_masks() {
- let (s, unmapped) = settings();
- assert_eq!(s.light.exposure, 0.2, "global settings still map");
- assert_eq!(s.masks.len(), 3, "inactive corrections are skipped: {:?}", s.masks);
- let sky = &s.masks[0];
- assert_eq!(sky.name, "Darker sky");
- assert_eq!((sky.adjust.exposure, sky.adjust.saturation, sky.adjust.temp, sky.adjust.amount), (-1.0, 30.0, -10.0, 100.0));
- assert_eq!(sky.components[0].shape, MaskShape::Sky);
- assert_eq!(sky.components[1].op, MaskOp::Intersect);
- let MaskShape::Linear { start, end } = &sky.components[1].shape else { panic!() };
- assert_eq!((start.y, end.y), (0.0, 0.6), "full effect at the Full point");
- let rad = &s.masks[1];
- assert_eq!((rad.name.as_str(), rad.adjust.contrast, rad.adjust.amount), ("Mask 2", 20.0, 50.0));
- let MaskShape::Radial { center, rx, ry, angle, feather, invert } = &rad.components[0].shape else { panic!() };
- assert!((center.x - 0.5).abs() < 1e-9 && (center.y - 0.5).abs() < 1e-9);
- assert!((rx - 0.3).abs() < 1e-9 && (ry - 0.2 / 1.5).abs() < 1e-9, "radii in long-edge units: {rx} {ry}");
- assert_eq!((*angle, *feather, *invert), (10.0, 40.0, false), "Flipped=\"true\" is Lightroom's ordinary radial: effect inside");
- assert!(!rad.components[0].invert);
- let MaskShape::LuminanceRange { lo, hi, lo_feather, .. } = &rad.components[1].shape else { panic!() };
- assert!(lo < hi && *lo_feather > 0.0);
- let brush = &s.masks[2];
- assert_eq!(brush.adjust.clarity, 40.0);
- assert_eq!(brush.components.len(), 1, "the brush's dab runs are one component");
- let MaskShape::Brush { strokes } = &brush.components[0].shape else { panic!() };
- assert_eq!((strokes.len(), strokes[0].points.len(), strokes[0].flow, strokes[0].feather), (2, 2, 80.0, 30.0));
- assert_eq!(unmapped, vec!["Mask: AI mask (9)".to_string()]);
- }
-
- #[test]
- fn masks_render() {
- let (s, _) = settings();
- let img = lightcraft_scenes::demo_library()[0].render(96, 64);
- let render = |d: &DevelopSettings| {
- lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(96, 64)).image.data
- };
- assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the masks change the picture");
- }
-
- #[test]
- fn range_to_white_stays_open_and_unmapped_local_sliders_are_reported() {
- // written for this test: a highlights range (full from 0.85 up to white) carrying a local slider we lack
- let x = r#"
-
-
-
-
-
-
-
-
-"#;
- let d = lightcraft_meta::parse_xmp(x).unwrap();
- let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
- let s = DevelopSettings::default().merged(&partial).expect("valid settings");
- let MaskShape::LuminanceRange { hi, hi_feather, .. } = &s.masks[0].components[0].shape else { panic!() };
- assert_eq!((*hi, *hi_feather), (1.0, 0.0), "brighter than scene white is still in the range");
- assert_eq!(s.masks[0].adjust.clarity, -30.0);
- assert_eq!(unmapped, ["Mask: LocalGrain"], "a refine saturation of 100 is neutral, not reported");
- }
-
- #[test]
- fn lrtemplate_corrections() {
- let t = r#"s = { title = "Grad", value = { settings = { Exposure2012 = 0.1, GradientBasedCorrections = {
- { What = "Correction", CorrectionAmount = 1, LocalExposure2012 = -0.5, CorrectionMasks = {
- { What = "Mask/Gradient", ZeroX = 0.5, ZeroY = 0.5, FullX = 0.5, FullY = 0, MaskValue = 1 } } } } } } }"#;
- let v = crate::preset_import::read_presets("Grad.lrtemplate", t.as_bytes(), None).unwrap();
- assert!(v[0].unmapped.is_empty(), "{:?}", v[0].unmapped);
- let s = DevelopSettings::default().merged(&v[0].preset.settings).unwrap();
- assert_eq!(s.masks.len(), 1);
- assert_eq!(s.masks[0].adjust.exposure, -2.0);
- }
-
- #[test]
- fn presets_add_masks_once() {
- let (s, _) = settings();
- let partial = json!({"masks": serde_json::to_value(&s.masks).unwrap(), "light": {"exposure": 0.5}});
- let p =
- lightcraft_develop::Preset { id: "t".into(), name: "t".into(), group: "g".into(), settings: partial, favorite: false, builtin: false };
- let mut mine = DevelopSettings::default();
- mine.masks.push(lightcraft_develop::Mask { id: 7, name: "Mine".into(), ..Default::default() });
- let once = p.apply(&mine, 1.0);
- assert_eq!(once.masks.len(), 4, "added to the photo's own mask");
- assert_eq!(once.masks[1].id, 8, "fresh ids");
- assert_eq!(p.apply(&once, 1.0).masks.len(), 4, "applying again adds nothing");
- let half = p.apply(&mine, 0.5);
- assert_eq!(half.masks[1].adjust.amount, 50.0, "amount scales the masks");
- }
-
- #[test]
- fn sidecar_radials_fit_the_photo() {
- let mut v = json!({"masks": [{"components": [{"shape": {"kind": "radial", "rx": 0.3, "ry": 0.2}}]}]});
- refit_radials(&mut v, 1.5, 1.0);
- let s = &v["masks"][0]["components"][0]["shape"];
- assert!((s["rx"].as_f64().unwrap() - 0.3).abs() < 1e-9);
- assert!((s["ry"].as_f64().unwrap() - 0.3).abs() < 1e-9, "square photo: y radius = its box half-height");
- }
-
- /// One radial correction in `container`, with `attrs` on its mask component (values written
- /// for this test; the component attributes follow what Lightroom Classic writes).
- fn radial_packet(container: &str, attrs: &str) -> String {
- format!(
- r#"
-
-
-
-
-
-
-
-
-"#
- )
- }
-
- fn radial_settings(container: &str, attrs: &str) -> DevelopSettings {
- let d = lightcraft_meta::parse_xmp(&radial_packet(container, attrs)).unwrap();
- let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
- assert!(unmapped.is_empty(), "{unmapped:?}");
- let s = DevelopSettings::default().merged(&partial).unwrap();
- assert_eq!(s.masks.len(), 1, "{:?}", s.masks);
- s
- }
-
- /// (the radial's `invert`, its feather, the component's `invert`).
- fn radial_of(s: &DevelopSettings) -> (bool, f64, bool) {
- let c = &s.masks[0].components[0];
- let MaskShape::Radial { invert, feather, .. } = &c.shape else { panic!("{:?}", c.shape) };
- (*invert, *feather, c.invert)
- }
-
- /// How much brighter the mask makes the centre and a corner of a flat grey frame (8-bit green).
- fn lift_inside_outside(s: &DevelopSettings) -> (i32, i32) {
- let img = lightcraft_raster::Image { width: 90, height: 60, data: vec![[0.18f32; 3]; 90 * 60] };
- let render =
- |d: &DevelopSettings| lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(90, 60)).image;
- let (with, without) = (render(s), render(&DevelopSettings { light: s.light, ..Default::default() }));
- let g = |im: &lightcraft_raster::Rgba8, x: usize, y: usize| i32::from(im.data[y * im.width + x][1]);
- (g(&with, 45, 30) - g(&without, 45, 30), g(&with, 3, 3) - g(&without, 3, 3))
- }
-
- #[test]
- fn lightroom_radial_flipped_is_inside() {
- // as Lightroom Classic writes an ordinary radial mask today (#1815)
- let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="false" crs:Feather="25" crs:Flipped="true" crs:Version="2""#);
- assert_eq!(radial_of(&s), (false, 25.0, false));
- let (inside, outside) = lift_inside_outside(&s);
- assert!(inside > 20 && outside == 0, "the effect is inside the circle: {inside} {outside}");
- // no MaskInverted at all reads the same
- let s = radial_settings("MaskGroupBasedCorrections", r#"crs:Feather="25" crs:Flipped="true" crs:Version="2""#);
- assert_eq!(radial_of(&s), (false, 25.0, false));
- }
-
- #[test]
- fn lightroom_radial_mask_inverted_is_outside() {
- let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="true" crs:Feather="25" crs:Flipped="true" crs:Version="2""#);
- assert_eq!(radial_of(&s), (false, 25.0, true), "the inversion is the component's");
- let (inside, outside) = lift_inside_outside(&s);
- assert!(inside == 0 && outside > 20, "the effect is outside the circle: {inside} {outside}");
- // version 2 with Flipped="false": outside, the legacy sense of the flag
- let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="false" crs:Feather="25" crs:Flipped="false" crs:Version="2""#);
- assert_eq!(radial_of(&s), (true, 25.0, false));
- }
-
- #[test]
- fn versionless_group_radial_is_inside_with_half_the_feather() {
- // Lightroom renders both inside and rewrites them Flipped="true", Feather halved, Version="2"
- for flipped in ["true", "false"] {
- let s = radial_settings("MaskGroupBasedCorrections", &format!(r#"crs:MaskInverted="false" crs:Feather="50" crs:Flipped="{flipped}""#));
- assert_eq!(radial_of(&s), (false, 25.0, false), "Flipped={flipped}");
- let (inside, outside) = lift_inside_outside(&s);
- assert!(inside > 20 && outside == 0, "Flipped={flipped}: {inside} {outside}");
- }
- }
-
- #[test]
- fn legacy_radial_filter_is_outside_unless_flipped() {
- // the process-2012 Radial Filter: effect outside by default, "Invert Mask" = Flipped="true"
- let s = radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50" crs:Flipped="false""#);
- assert_eq!(radial_of(&s), (true, 50.0, false));
- let (inside, outside) = lift_inside_outside(&s);
- assert!(inside == 0 && outside > 20, "default: outside: {inside} {outside}");
- assert_eq!(radial_of(&radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50""#)), (true, 50.0, false));
- let s = radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50" crs:Flipped="true""#);
- assert_eq!(radial_of(&s), (false, 50.0, false));
- let (inside, outside) = lift_inside_outside(&s);
- assert!(inside > 20 && outside == 0, "inverted: inside: {inside} {outside}");
- }
-
- #[test]
- fn lrtemplate_radial_filter_reads_flipped() {
- // .lrtemplate presets and catalog develop settings are the same Lua tables
- let t = |flipped: &str| {
- format!(
- r#"s = {{ title = "Rad", value = {{ settings = {{ CircularGradientBasedCorrections = {{
- {{ What = "Correction", CorrectionAmount = 1, LocalExposure2012 = 0.25, CorrectionMasks = {{
- {{ What = "Mask/CircularGradient", Top = 0.25, Left = 0.25, Bottom = 0.75, Right = 0.75, Angle = 0, Feather = 50, Flipped = {flipped}, MaskValue = 1 }} }} }} }} }} }} }}"#
- )
- };
- for (flipped, outside) in [("true", false), ("false", true)] {
- let v = crate::preset_import::read_presets("Rad.lrtemplate", t(flipped).as_bytes(), None).unwrap();
- let s = DevelopSettings::default().merged(&v[0].preset.settings).unwrap();
- assert_eq!(radial_of(&s).0, outside, "Flipped = {flipped}");
- }
- }
-
- /// Spot removal in the shape Lightroom Classic writes it (values written for this test):
- /// a healed circle with an explicit source, a cloned circle with an automatic source, a
- /// brushed removal, a circular-gradient spot, one without a mask, and the legacy list.
- const SPOTS: &str = r#"
-
-
- centerX = 0.4, centerY = 0.3, radius = 0.01, sourceState = sourceSetExplicitly, sourceX = 0.45, sourceY = 0.3, spotType = heal
-
-
-
-
-
-
-
-
-
-
- d 0.1 0.8d 0.12 0.82d 0.14 0.84
-
-
-
-
-
-
-
-
-"#;
-
- #[test]
- fn retouch_areas_become_spots() {
- use lightcraft_develop::SpotMode;
- let d = lightcraft_meta::parse_xmp(SPOTS).unwrap();
- let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
- let s = DevelopSettings::default().merged(&partial).expect("valid settings");
- assert_eq!(s.light.exposure, 0.1, "global settings still map");
- assert_eq!(s.spots.len(), 4, "the gradient 'spot' is skipped: {:?}", s.spots);
- let heal = &s.spots[0];
- assert_eq!((heal.mode, heal.points.len(), heal.opacity, heal.feather), (SpotMode::Heal, 1, 100.0, 0.0));
- assert!((heal.points[0].x - 0.4).abs() < 1e-9 && (heal.points[0].y - 0.3).abs() < 1e-9);
- assert!((heal.size - 0.01).abs() < 1e-9, "a landscape target: width fractions are long-edge fractions");
- let src = heal.source_offset.expect("explicit source");
- assert!((src.x - 0.05).abs() < 1e-9 && src.y.abs() < 1e-9, "source stored absolute, ours an offset: {src:?}");
- let clone = &s.spots[1];
- assert_eq!((clone.mode, clone.opacity, clone.feather, clone.source_offset), (SpotMode::Clone, 50.0, 25.0, None));
- let brushed = &s.spots[2];
- assert_eq!((brushed.points.len(), brushed.size), (3, 0.03));
- let src = brushed.source_offset.expect("explicit source");
- assert!((src.x - 0.1).abs() < 1e-9 && (src.y - 0.1).abs() < 1e-9);
- let circ = &s.spots[3];
- assert!((circ.points[0].x - 0.5).abs() < 1e-9 && (circ.points[0].y - 0.5).abs() < 1e-9 && (circ.size - 0.01).abs() < 1e-9);
- assert_eq!(unmapped, vec!["Spot: Gradient".to_string()], "{unmapped:?}");
- }
-
- #[test]
- fn portrait_target_scales_spot_sizes() {
- let d = lightcraft_meta::parse_xmp(SPOTS).unwrap();
- let (partial, _) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 2.0 / 3.0);
- let s = DevelopSettings::default().merged(&partial).unwrap();
- assert!((s.spots[0].size - 0.01 * 2.0 / 3.0).abs() < 1e-9, "a width fraction on a portrait target: {}", s.spots[0].size);
- }
-
- #[test]
- fn legacy_retouch_info_is_read_on_its_own() {
- let packet = SPOTS.replace("crs:RetouchAreas", "crs:Ignored");
- let d = lightcraft_meta::parse_xmp(&packet).unwrap();
- let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
- let s = DevelopSettings::default().merged(&partial).unwrap();
- assert_eq!(s.spots.len(), 1, "{:?}", s.spots);
- assert_eq!(s.spots[0].mode, lightcraft_develop::SpotMode::Heal);
- assert!((s.spots[0].points[0].x - 0.4).abs() < 1e-9 && (s.spots[0].size - 0.01).abs() < 1e-9);
- assert!(s.spots[0].source_offset.is_some_and(|o| (o.x - 0.05).abs() < 1e-9));
- assert!(unmapped.iter().any(|u| u == "Ignored"), "{unmapped:?}");
- assert!(!unmapped.iter().any(|u| u == "RetouchInfo"), "{unmapped:?}");
- }
-
- #[test]
- fn spots_render() {
- let d = lightcraft_meta::parse_xmp(SPOTS).unwrap();
- let (partial, _) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
- let s = DevelopSettings::default().merged(&partial).unwrap();
- let img = lightcraft_scenes::demo_library()[0].render(96, 64);
- let render = |d: &DevelopSettings| {
- lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(96, 64)).image.data
- };
- assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the spots change the picture");
- }
-}
+mod tests;
diff --git a/crates/engine/src/crs_masks/tests.rs b/crates/engine/src/crs_masks/tests.rs
new file mode 100644
index 000000000..4e507df3e
--- /dev/null
+++ b/crates/engine/src/crs_masks/tests.rs
@@ -0,0 +1,332 @@
+use super::*;
+use lightcraft_develop::{DevelopSettings, MaskOp, MaskShape};
+
+/// A packet in the shape local corrections take (values written for this test).
+const PACKET: &str = r#"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ d 0.1 0.2d 0.15 0.22
+ d 0.5 0.5
+
+
+"#;
+
+fn settings() -> (DevelopSettings, Vec) {
+ let d = lightcraft_meta::parse_xmp(PACKET).unwrap();
+ let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
+ (DevelopSettings::default().merged(&partial).expect("valid settings"), unmapped)
+}
+
+#[test]
+fn corrections_become_masks() {
+ let (s, unmapped) = settings();
+ assert_eq!(s.light.exposure, 0.2, "global settings still map");
+ assert_eq!(s.masks.len(), 3, "inactive corrections are skipped: {:?}", s.masks);
+ let sky = &s.masks[0];
+ assert_eq!(sky.name, "Darker sky");
+ assert_eq!((sky.adjust.exposure, sky.adjust.saturation, sky.adjust.temp, sky.adjust.amount), (-1.0, 30.0, -10.0, 100.0));
+ assert_eq!(sky.components[0].shape, MaskShape::Sky);
+ assert_eq!(sky.components[1].op, MaskOp::Intersect);
+ let MaskShape::Linear { start, end } = &sky.components[1].shape else { panic!() };
+ assert_eq!((start.y, end.y), (0.0, 0.6), "full effect at the Full point");
+ let rad = &s.masks[1];
+ assert_eq!((rad.name.as_str(), rad.adjust.contrast, rad.adjust.amount), ("Mask 2", 20.0, 50.0));
+ let MaskShape::Radial { center, rx, ry, angle, feather, invert } = &rad.components[0].shape else { panic!() };
+ assert!((center.x - 0.5).abs() < 1e-9 && (center.y - 0.5).abs() < 1e-9);
+ assert!((rx - 0.3).abs() < 1e-9 && (ry - 0.2 / 1.5).abs() < 1e-9, "radii in long-edge units: {rx} {ry}");
+ assert_eq!((*angle, *feather, *invert), (10.0, 40.0, false), "Flipped=\"true\" is Lightroom's ordinary radial: effect inside");
+ assert!(!rad.components[0].invert);
+ let MaskShape::LuminanceRange { lo, hi, lo_feather, .. } = &rad.components[1].shape else { panic!() };
+ assert!(lo < hi && *lo_feather > 0.0);
+ let brush = &s.masks[2];
+ assert_eq!(brush.adjust.clarity, 40.0);
+ assert_eq!(brush.components.len(), 1, "the brush's dab runs are one component");
+ let MaskShape::Brush { strokes } = &brush.components[0].shape else { panic!() };
+ assert_eq!((strokes.len(), strokes[0].points.len(), strokes[0].flow, strokes[0].feather), (2, 2, 80.0, 30.0));
+ assert_eq!(unmapped, vec!["Mask: AI mask (9)".to_string()]);
+}
+
+#[test]
+fn masks_render() {
+ let (s, _) = settings();
+ let img = lightcraft_scenes::demo_library()[0].render(96, 64);
+ let render = |d: &DevelopSettings| {
+ lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(96, 64)).image.data
+ };
+ assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the masks change the picture");
+}
+
+#[test]
+fn range_to_white_stays_open_and_unmapped_local_sliders_are_reported() {
+ // written for this test: a highlights range (full from 0.85 up to white) carrying a local slider we lack
+ let x = r#"
+
+
+
+
+
+
+
+
+"#;
+ let d = lightcraft_meta::parse_xmp(x).unwrap();
+ let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
+ let s = DevelopSettings::default().merged(&partial).expect("valid settings");
+ let MaskShape::LuminanceRange { hi, hi_feather, .. } = &s.masks[0].components[0].shape else { panic!() };
+ assert_eq!((*hi, *hi_feather), (1.0, 0.0), "brighter than scene white is still in the range");
+ assert_eq!(s.masks[0].adjust.clarity, -30.0);
+ assert_eq!(unmapped, ["Mask: LocalGrain"], "a refine saturation of 100 is neutral, not reported");
+}
+
+#[test]
+fn lrtemplate_corrections() {
+ let t = r#"s = { title = "Grad", value = { settings = { Exposure2012 = 0.1, GradientBasedCorrections = {
+ { What = "Correction", CorrectionAmount = 1, LocalExposure2012 = -0.5, CorrectionMasks = {
+ { What = "Mask/Gradient", ZeroX = 0.5, ZeroY = 0.5, FullX = 0.5, FullY = 0, MaskValue = 1 } } } } } } }"#;
+ let v = crate::preset_import::read_presets("Grad.lrtemplate", t.as_bytes(), None).unwrap();
+ assert!(v[0].unmapped.is_empty(), "{:?}", v[0].unmapped);
+ let s = DevelopSettings::default().merged(&v[0].preset.settings).unwrap();
+ assert_eq!(s.masks.len(), 1);
+ assert_eq!(s.masks[0].adjust.exposure, -2.0);
+}
+
+#[test]
+fn presets_add_masks_once() {
+ let (s, _) = settings();
+ let partial = json!({"masks": serde_json::to_value(&s.masks).unwrap(), "light": {"exposure": 0.5}});
+ let p =
+ lightcraft_develop::Preset { id: "t".into(), name: "t".into(), group: "g".into(), settings: partial, favorite: false, builtin: false };
+ let mut mine = DevelopSettings::default();
+ mine.masks.push(lightcraft_develop::Mask { id: 7, name: "Mine".into(), ..Default::default() });
+ let once = p.apply(&mine, 1.0);
+ assert_eq!(once.masks.len(), 4, "added to the photo's own mask");
+ assert_eq!(once.masks[1].id, 8, "fresh ids");
+ assert_eq!(p.apply(&once, 1.0).masks.len(), 4, "applying again adds nothing");
+ let half = p.apply(&mine, 0.5);
+ assert_eq!(half.masks[1].adjust.amount, 50.0, "amount scales the masks");
+}
+
+#[test]
+fn sidecar_radials_fit_the_photo() {
+ let mut v = json!({"masks": [{"components": [{"shape": {"kind": "radial", "rx": 0.3, "ry": 0.2}}]}]});
+ refit_radials(&mut v, 1.5, 1.0);
+ let s = &v["masks"][0]["components"][0]["shape"];
+ assert!((s["rx"].as_f64().unwrap() - 0.3).abs() < 1e-9);
+ assert!((s["ry"].as_f64().unwrap() - 0.3).abs() < 1e-9, "square photo: y radius = its box half-height");
+}
+
+/// One radial correction in `container`, with `attrs` on its mask component (values written
+/// for this test; the component attributes follow what Lightroom Classic writes).
+fn radial_packet(container: &str, attrs: &str) -> String {
+ format!(
+ r#"
+
+
+
+
+
+
+
+
+"#
+ )
+}
+
+fn radial_settings(container: &str, attrs: &str) -> DevelopSettings {
+ let d = lightcraft_meta::parse_xmp(&radial_packet(container, attrs)).unwrap();
+ let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
+ assert!(unmapped.is_empty(), "{unmapped:?}");
+ let s = DevelopSettings::default().merged(&partial).unwrap();
+ assert_eq!(s.masks.len(), 1, "{:?}", s.masks);
+ s
+}
+
+/// (the radial's `invert`, its feather, the component's `invert`).
+fn radial_of(s: &DevelopSettings) -> (bool, f64, bool) {
+ let c = &s.masks[0].components[0];
+ let MaskShape::Radial { invert, feather, .. } = &c.shape else { panic!("{:?}", c.shape) };
+ (*invert, *feather, c.invert)
+}
+
+/// How much brighter the mask makes the centre and a corner of a flat grey frame (8-bit green).
+fn lift_inside_outside(s: &DevelopSettings) -> (i32, i32) {
+ let img = lightcraft_raster::Image { width: 90, height: 60, data: vec![[0.18f32; 3]; 90 * 60] };
+ let render =
+ |d: &DevelopSettings| lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(90, 60)).image;
+ let (with, without) = (render(s), render(&DevelopSettings { light: s.light, ..Default::default() }));
+ let g = |im: &lightcraft_raster::Rgba8, x: usize, y: usize| i32::from(im.data[y * im.width + x][1]);
+ (g(&with, 45, 30) - g(&without, 45, 30), g(&with, 3, 3) - g(&without, 3, 3))
+}
+
+#[test]
+fn lightroom_radial_flipped_is_inside() {
+ // as Lightroom Classic writes an ordinary radial mask today (#1815)
+ let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="false" crs:Feather="25" crs:Flipped="true" crs:Version="2""#);
+ assert_eq!(radial_of(&s), (false, 25.0, false));
+ let (inside, outside) = lift_inside_outside(&s);
+ assert!(inside > 20 && outside == 0, "the effect is inside the circle: {inside} {outside}");
+ // no MaskInverted at all reads the same
+ let s = radial_settings("MaskGroupBasedCorrections", r#"crs:Feather="25" crs:Flipped="true" crs:Version="2""#);
+ assert_eq!(radial_of(&s), (false, 25.0, false));
+}
+
+#[test]
+fn lightroom_radial_mask_inverted_is_outside() {
+ let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="true" crs:Feather="25" crs:Flipped="true" crs:Version="2""#);
+ assert_eq!(radial_of(&s), (false, 25.0, true), "the inversion is the component's");
+ let (inside, outside) = lift_inside_outside(&s);
+ assert!(inside == 0 && outside > 20, "the effect is outside the circle: {inside} {outside}");
+ // version 2 with Flipped="false": outside, the legacy sense of the flag
+ let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="false" crs:Feather="25" crs:Flipped="false" crs:Version="2""#);
+ assert_eq!(radial_of(&s), (true, 25.0, false));
+}
+
+#[test]
+fn versionless_group_radial_is_inside_with_half_the_feather() {
+ // Lightroom renders both inside and rewrites them Flipped="true", Feather halved, Version="2"
+ for flipped in ["true", "false"] {
+ let s = radial_settings("MaskGroupBasedCorrections", &format!(r#"crs:MaskInverted="false" crs:Feather="50" crs:Flipped="{flipped}""#));
+ assert_eq!(radial_of(&s), (false, 25.0, false), "Flipped={flipped}");
+ let (inside, outside) = lift_inside_outside(&s);
+ assert!(inside > 20 && outside == 0, "Flipped={flipped}: {inside} {outside}");
+ }
+}
+
+#[test]
+fn legacy_radial_filter_is_outside_unless_flipped() {
+ // the process-2012 Radial Filter: effect outside by default, "Invert Mask" = Flipped="true"
+ let s = radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50" crs:Flipped="false""#);
+ assert_eq!(radial_of(&s), (true, 50.0, false));
+ let (inside, outside) = lift_inside_outside(&s);
+ assert!(inside == 0 && outside > 20, "default: outside: {inside} {outside}");
+ assert_eq!(radial_of(&radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50""#)), (true, 50.0, false));
+ let s = radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50" crs:Flipped="true""#);
+ assert_eq!(radial_of(&s), (false, 50.0, false));
+ let (inside, outside) = lift_inside_outside(&s);
+ assert!(inside > 20 && outside == 0, "inverted: inside: {inside} {outside}");
+}
+
+#[test]
+fn lrtemplate_radial_filter_reads_flipped() {
+ // .lrtemplate presets and catalog develop settings are the same Lua tables
+ let t = |flipped: &str| {
+ format!(
+ r#"s = {{ title = "Rad", value = {{ settings = {{ CircularGradientBasedCorrections = {{
+ {{ What = "Correction", CorrectionAmount = 1, LocalExposure2012 = 0.25, CorrectionMasks = {{
+ {{ What = "Mask/CircularGradient", Top = 0.25, Left = 0.25, Bottom = 0.75, Right = 0.75, Angle = 0, Feather = 50, Flipped = {flipped}, MaskValue = 1 }} }} }} }} }} }} }}"#
+ )
+ };
+ for (flipped, outside) in [("true", false), ("false", true)] {
+ let v = crate::preset_import::read_presets("Rad.lrtemplate", t(flipped).as_bytes(), None).unwrap();
+ let s = DevelopSettings::default().merged(&v[0].preset.settings).unwrap();
+ assert_eq!(radial_of(&s).0, outside, "Flipped = {flipped}");
+ }
+}
+
+/// Spot removal in the shape Lightroom Classic writes it (values written for this test):
+/// a healed circle with an explicit source, a cloned circle with an automatic source, a
+/// brushed removal, a circular-gradient spot, one without a mask, and the legacy list.
+const SPOTS: &str = r#"
+
+
+ centerX = 0.4, centerY = 0.3, radius = 0.01, sourceState = sourceSetExplicitly, sourceX = 0.45, sourceY = 0.3, spotType = heal
+
+
+
+
+
+
+
+
+
+
+ d 0.1 0.8d 0.12 0.82d 0.14 0.84
+
+
+
+
+
+
+
+
+"#;
+
+#[test]
+fn retouch_areas_become_spots() {
+ use lightcraft_develop::SpotMode;
+ let d = lightcraft_meta::parse_xmp(SPOTS).unwrap();
+ let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
+ let s = DevelopSettings::default().merged(&partial).expect("valid settings");
+ assert_eq!(s.light.exposure, 0.1, "global settings still map");
+ assert_eq!(s.spots.len(), 4, "the gradient 'spot' is skipped: {:?}", s.spots);
+ let heal = &s.spots[0];
+ assert_eq!((heal.mode, heal.points.len(), heal.opacity, heal.feather), (SpotMode::Heal, 1, 100.0, 0.0));
+ assert!((heal.points[0].x - 0.4).abs() < 1e-9 && (heal.points[0].y - 0.3).abs() < 1e-9);
+ assert!((heal.size - 0.01).abs() < 1e-9, "a landscape target: width fractions are long-edge fractions");
+ let src = heal.source_offset.expect("explicit source");
+ assert!((src.x - 0.05).abs() < 1e-9 && src.y.abs() < 1e-9, "source stored absolute, ours an offset: {src:?}");
+ let clone = &s.spots[1];
+ assert_eq!((clone.mode, clone.opacity, clone.feather, clone.source_offset), (SpotMode::Clone, 50.0, 25.0, None));
+ let brushed = &s.spots[2];
+ assert_eq!((brushed.points.len(), brushed.size), (3, 0.03));
+ let src = brushed.source_offset.expect("explicit source");
+ assert!((src.x - 0.1).abs() < 1e-9 && (src.y - 0.1).abs() < 1e-9);
+ let circ = &s.spots[3];
+ assert!((circ.points[0].x - 0.5).abs() < 1e-9 && (circ.points[0].y - 0.5).abs() < 1e-9 && (circ.size - 0.01).abs() < 1e-9);
+ assert_eq!(unmapped, vec!["Spot: Gradient".to_string()], "{unmapped:?}");
+}
+
+#[test]
+fn portrait_target_scales_spot_sizes() {
+ let d = lightcraft_meta::parse_xmp(SPOTS).unwrap();
+ let (partial, _) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 2.0 / 3.0);
+ let s = DevelopSettings::default().merged(&partial).unwrap();
+ assert!((s.spots[0].size - 0.01 * 2.0 / 3.0).abs() < 1e-9, "a width fraction on a portrait target: {}", s.spots[0].size);
+}
+
+#[test]
+fn legacy_retouch_info_is_read_on_its_own() {
+ let packet = SPOTS.replace("crs:RetouchAreas", "crs:Ignored");
+ let d = lightcraft_meta::parse_xmp(&packet).unwrap();
+ let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
+ let s = DevelopSettings::default().merged(&partial).unwrap();
+ assert_eq!(s.spots.len(), 1, "{:?}", s.spots);
+ assert_eq!(s.spots[0].mode, lightcraft_develop::SpotMode::Heal);
+ assert!((s.spots[0].points[0].x - 0.4).abs() < 1e-9 && (s.spots[0].size - 0.01).abs() < 1e-9);
+ assert!(s.spots[0].source_offset.is_some_and(|o| (o.x - 0.05).abs() < 1e-9));
+ assert!(unmapped.iter().any(|u| u == "Ignored"), "{unmapped:?}");
+ assert!(!unmapped.iter().any(|u| u == "RetouchInfo"), "{unmapped:?}");
+}
+
+#[test]
+fn spots_render() {
+ let d = lightcraft_meta::parse_xmp(SPOTS).unwrap();
+ let (partial, _) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
+ let s = DevelopSettings::default().merged(&partial).unwrap();
+ let img = lightcraft_scenes::demo_library()[0].render(96, 64);
+ let render = |d: &DevelopSettings| {
+ lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(96, 64)).image.data
+ };
+ assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the spots change the picture");
+}