diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index d8244b4fec..b1d0b79a17 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -88,6 +88,7 @@ The other built-in ICC profiles and the generated LUT looks are produced by code | `docs/brand/` (all files) | ArtCraft name, wordmark and mark | ArtCraft Team | getartcraft.com | Not open source; trademarks of the ArtCraft Team, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) | | `docs/images/photocraft-*.jpg` | PhotoCraft screenshots | PhotoCraft contributors (UI) | Rendered offscreen with the `snapshot` example | MIT OR Apache-2.0 (UI); the artwork in each is public domain, listed below | | `docs/images/preferences-apply-*.png` | Preferences before and after adding Apply (no artwork) | PhotoCraft contributors | PhotoCraft control-channel capture and offscreen `snapshot` example | MIT OR Apache-2.0 | +| `docs/images/gradient-overlay-method-*.png` | Gradient Overlay Method selector, before and after (synthetic gradient) | @oRE-o | Rendered offscreen with the `snapshot` example; no external artwork | MIT OR Apache-2.0 | Artwork shown in the screenshots (all public domain, via Wikimedia Commons; details in [`docs/images/SOURCES.md`](docs/images/SOURCES.md)): diff --git a/crates/compose/src/cmyk_tests.rs b/crates/compose/src/cmyk_tests.rs index 58973db9a5..a0de5be31a 100644 --- a/crates/compose/src/cmyk_tests.rs +++ b/crates/compose/src/cmyk_tests.rs @@ -1265,6 +1265,7 @@ fn make_effect(f: &FxSpec) -> Effect { common: FxCommon::new(*blend, *opacity), gradient: Gradient { stops: vec![(0.0, ink_color(*from)), (1.0, ink_color(*to))], ..Default::default() }, dither: false, + interpolation: None, }, FxSpec::Satin { color, blend, opacity, invert } => Effect::Satin(Satin { common: FxCommon::new(*blend, *opacity), diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 7f33c4d493..88e1dd0acc 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1512,7 +1512,12 @@ fn glow_gradient_follows_the_shape() { // new pixels (#4053). Effects that don't depend on the frame repaint only what changed. #[test] fn relayout_damage_covers_a_layer_whose_gradient_frame_moved() { - let overlay = Effect::GradientOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), gradient: Gradient::default(), dither: false }; + let overlay = Effect::GradientOverlay { + common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), + gradient: Gradient::default(), + dither: false, + interpolation: None, + }; let grown = |a: &Document, rect: Rect, rgba: [f32; 4]| { let mut b = a.clone(); let old = b.layers.last().unwrap().clone(); @@ -1539,13 +1544,19 @@ fn relayout_damage_covers_a_layer_whose_gradient_frame_moved() { #[test] fn gradient_overlay_follows_angle_and_reverse() { let g = Gradient { stops: vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], angle: 0.0, ..Gradient::default() }; - let d = fx_doc(vec![Effect::GradientOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), gradient: g.clone(), dither: false }]); + let d = fx_doc(vec![Effect::GradientOverlay { + common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), + gradient: g.clone(), + dither: false, + interpolation: None, + }]); let (l, r) = (px(&d, 10, 20), px(&d, 29, 20)); assert!(l[0] < 0.1 && r[0] > 0.9, "{l:?} {r:?}"); let d = fx_doc(vec![Effect::GradientOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), gradient: Gradient { reverse: true, style: GradientStyle::Linear, ..g }, dither: false, + interpolation: None, }]); assert!(px(&d, 10, 20)[0] > 0.9); } diff --git a/crates/doc/src/effects.rs b/crates/doc/src/effects.rs index 7f0e34e9b0..4e3f32a97f 100644 --- a/crates/doc/src/effects.rs +++ b/crates/doc/src/effects.rs @@ -37,6 +37,16 @@ pub struct Gradient { pub offset: (f32, f32), } +/// Editable colour stops behind a Gradient Overlay's baked render stops. +/// Older documents have no interpolation record and keep their existing appearance. +/// Writers must rebake `gradient.stops` from this record, or clear it when replacing the curve. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GradientInterpolation { + pub method: String, + pub smoothness: f32, + pub stops: Vec<(f32, Color)>, +} + impl Default for Gradient { fn default() -> Self { Gradient { @@ -278,6 +288,8 @@ pub enum Effect { common: FxCommon, gradient: Gradient, dither: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + interpolation: Option, }, PatternOverlay { common: FxCommon, @@ -313,7 +325,12 @@ impl Effect { Effect::OuterGlow(g) | Effect::InnerGlow(g) => paint(&mut g.paint), Effect::Stroke(s) => paint(&mut s.paint), Effect::ColorOverlay { color, .. } => cc(color), - Effect::GradientOverlay { gradient, .. } => gradient.stops.iter_mut().for_each(|s| cc(&mut s.1)), + Effect::GradientOverlay { gradient, interpolation, .. } => { + gradient.stops.iter_mut().for_each(|s| cc(&mut s.1)); + if let Some(source) = interpolation { + source.stops.iter_mut().for_each(|s| cc(&mut s.1)); + } + } Effect::Satin(s) => cc(&mut s.color), Effect::BevelEmboss(b) => { cc(&mut b.highlight_color); diff --git a/crates/engine/src/color_cmds.rs b/crates/engine/src/color_cmds.rs index 4f3435cc7a..4c8e4df434 100644 --- a/crates/engine/src/color_cmds.rs +++ b/crates/engine/src/color_cmds.rs @@ -715,7 +715,12 @@ fn convert_layer_colors(l: &mut Layer, cc: &mut dyn FnMut(&mut Color)) { Effect::OuterGlow(g) | Effect::InnerGlow(g) => paint_colors(&mut g.paint, cc), Effect::Stroke(s) => paint_colors(&mut s.paint, cc), Effect::ColorOverlay { color, .. } => cc(color), - Effect::GradientOverlay { gradient, .. } => gradient.stops.iter_mut().for_each(|s| cc(&mut s.1)), + Effect::GradientOverlay { gradient, interpolation, .. } => { + gradient.stops.iter_mut().for_each(|s| cc(&mut s.1)); + if let Some(source) = interpolation { + source.stops.iter_mut().for_each(|s| cc(&mut s.1)); + } + } Effect::Satin(s) => cc(&mut s.color), Effect::BevelEmboss(b) => { cc(&mut b.highlight_color); diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs index 68e9344e59..2527f402f3 100644 --- a/crates/engine/src/layer_style.rs +++ b/crates/engine/src/layer_style.rs @@ -12,6 +12,8 @@ use crate::commands::{CommandSpec, blend_from_str}; use crate::presets::{always, bad}; use crate::{EngineError, Result, Session}; +mod gradient_overlay; + /// Parses a gradient style name (`linear`, `radial`, `angle`, `reflected`, `diamond`). pub fn gradient_style(s: &str) -> GradientStyle { match s.to_ascii_lowercase().as_str() { @@ -94,7 +96,7 @@ pub fn effect_defaults(kind: &str) -> Value { "stroke" => json!({"size": 1, "position": "inside", "blend": "Normal", "opacity": 100, "overprint": false, "color": "#000000"}), "colorOverlay" => json!({"blend": "Normal", "color": "#ff0000", "opacity": 100}), "gradientOverlay" => { - json!({"blend": "Normal", "opacity": 100, "from": "#000000", "to": "#ffffff", "reverse": false, "style": "linear", "angle": 90, "scale": 100}) + json!({"blend": "Normal", "opacity": 100, "from": "#000000", "to": "#ffffff", "reverse": false, "style": "linear", "angle": 90, "scale": 100, "method": "custom"}) } "patternOverlay" => json!({"blend": "Normal", "opacity": 100, "pattern": "", "angle": 0, "scale": 100, "link": true}), "bevelEmboss" => { @@ -524,7 +526,9 @@ fn fresh_effect(kind: &str) -> Option { overprint: false, }), "colorOverlay" => Effect::ColorOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), color: Color::rgb(1.0, 0.0, 0.0) }, - "gradientOverlay" => Effect::GradientOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), gradient: Gradient::default(), dither: false }, + "gradientOverlay" => { + Effect::GradientOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), gradient: Gradient::default(), dither: false, interpolation: None } + } "patternOverlay" => Effect::PatternOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), name: String::new(), @@ -717,9 +721,9 @@ pub fn overlay_effect(fx: &mut Effect, p: &Value) { common(c, p); set_color(p, "color", color); } - Effect::GradientOverlay { common: c, gradient: g, dither } => { + Effect::GradientOverlay { common: c, gradient: g, dither, interpolation } => { common(c, p); - overlay_gradient(g, p); + gradient_overlay::overlay(g, interpolation, p); if let Some(x) = b_of(p, "dither") { *dither = x; } @@ -1052,7 +1056,7 @@ pub fn specs() -> Vec { style_cmd!( "gradientOverlay", "Gradient Overlay…", - r##"{"from":"#rrggbb","to":"#rrggbb","method":"classic|perceptual|linear|smooth"? (Photoshop's interpolation; omitted: straight stops),"smoothness":0..100=100,"style":"linear|radial|angle|reflected|diamond","angle":deg=90,"scale":10..150=100,"reverse":bool,"opacity":0..100,"blend":str,"add":bool}"## + r##"{"from":"#rrggbb","to":"#rrggbb","method":"classic|perceptual|linear|smooth"? (Photoshop's interpolation; omitted on new effects: straight stops; edits retain the recorded method),"smoothness":0..100=100,"style":"linear|radial|angle|reflected|diamond","angle":deg=90,"scale":10..150=100,"reverse":bool,"opacity":0..100,"blend":str,"add":bool}"## ), style_cmd!( "patternOverlay", diff --git a/crates/engine/src/layer_style/gradient_overlay.rs b/crates/engine/src/layer_style/gradient_overlay.rs new file mode 100644 index 0000000000..9caf82f714 --- /dev/null +++ b/crates/engine/src/layer_style/gradient_overlay.rs @@ -0,0 +1,228 @@ +//! Keep editable stops separate from the dense stops used by the compositors. + +use photocraft_doc::effects::GradientInterpolation; +use photocraft_doc::{Color, Gradient}; +use serde_json::Value; + +use super::{color_of, f_of, overlay_gradient}; + +fn edit_endpoint(stop: Option<&mut (f32, Color)>, params: &Value, key: &str) { + if let (Some(stop), Some(color)) = (stop, color_of(params, key)) { + // The dialog displays hex colours. Opening it must not quantize the saved source. + let [r, g, b, _] = color.to_rgba8(); + let [sr, sg, sb, _] = stop.1.to_rgba8(); + if !params.get(key).is_some_and(Value::is_string) || [r, g, b] != [sr, sg, sb] { + let mut edited = color.in_mode(stop.1.mode); + edited.alpha = stop.1.alpha; + stop.1 = edited; + } + } +} + +/// Edits recorded source stops, initializing legacy curves from their end colours. +pub(super) fn overlay(gradient: &mut Gradient, interpolation: &mut Option, params: &Value) { + // Reset to Default restores the factory's unbaked interpolation too. + if params.get("method").and_then(Value::as_str) == Some("custom") { + if let Some(source) = interpolation.take() { + gradient.stops = source.stops; + } + overlay_gradient(gradient, params); + return; + } + let requested = params.get("method").and_then(Value::as_str).filter(|name| photocraft_io::GradientMethod::from_name(name).is_some()); + if interpolation.is_none() && requested.is_none() { + overlay_gradient(gradient, params); + return; + } + let previous = interpolation.clone(); + let source = interpolation.get_or_insert_with(|| GradientInterpolation { + method: requested.unwrap_or("classic").to_ascii_lowercase(), + smoothness: 100.0, + // Without a source record, intermediate stops may be baked samples (PSD import or + // older method commands). Keep the legacy rebuild-from-ends behaviour on first choice. + stops: vec![(0.0, gradient.stops.first().map_or(Color::BLACK, |s| s.1)), (1.0, gradient.stops.last().map_or(Color::WHITE, |s| s.1))], + }); + if let Some(method) = requested { + source.method = method.to_ascii_lowercase(); + } + if let Some(smoothness) = f_of(params, "smoothness").filter(|n| n.is_finite()) { + source.smoothness = smoothness.clamp(0.0, 100.0); + } + edit_endpoint(source.stops.first_mut(), params, "from"); + edit_endpoint(source.stops.last_mut(), params, "to"); + if previous.as_ref() != Some(source) + && let Some(method) = photocraft_io::GradientMethod::from_name(&source.method) + { + // Missing midpoint entries mean 50 %, including for multi-stop source gradients. + gradient.stops = photocraft_io::bake_gradient(source.stops.clone(), &[], source.smoothness / 100.0, method); + } + // Geometry edits must not replace just the ends of the baked curve. + let mut geometry = params.clone(); + if let Some(object) = geometry.as_object_mut() { + for key in ["method", "smoothness", "from", "to"] { + object.remove(key); + } + } + overlay_gradient(gradient, &geometry); +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_color::BlendMode; + use photocraft_doc::{Effect, FxCommon}; + use photocraft_io::{GradientMethod, bake_gradient}; + use serde_json::json; + + #[test] + fn choosing_a_method_on_a_baked_overlay_rebuilds_from_its_end_colours() { + let ends = vec![(0.0, Color::BLACK), (1.0, Color::WHITE)]; + for imported_method in [GradientMethod::Perceptual, GradientMethod::Smooth] { + let gradient = Gradient { + stops: bake_gradient(ends.clone(), &[0.5], 1.0, imported_method), + opacity_stops: vec![(0.0, 0.25), (0.6, 0.8), (1.0, 0.5)], + align: false, + offset: (0.2, -0.1), + ..Gradient::default() + }; + assert!(gradient.stops.len() > 2); + let source_ends = vec![(0.0, gradient.stops.first().unwrap().1), (1.0, gradient.stops.last().unwrap().1)]; + let mut fx = + Effect::GradientOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), gradient: gradient.clone(), dither: true, interpolation: None }; + super::super::overlay_effect(&mut fx, &json!({"method": "linear"})); + let mut expected = gradient; + expected.stops = bake_gradient(source_ends.clone(), &[0.5], 1.0, GradientMethod::Linear); + assert_eq!( + fx, + Effect::GradientOverlay { + common: FxCommon::new(BlendMode::Normal, 1.0), + gradient: expected, + dither: true, + interpolation: Some(GradientInterpolation { method: "linear".into(), smoothness: 100.0, stops: source_ends }), + } + ); + } + } + + #[test] + fn endpoint_edits_after_selecting_a_method_recolour_the_whole_baked_overlay() { + for same_edit in [false, true] { + let mut fx = Effect::GradientOverlay { + common: FxCommon::new(BlendMode::Normal, 1.0), + gradient: Gradient { + stops: bake_gradient(vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], &[0.5], 1.0, GradientMethod::Smooth), + ..Gradient::default() + }, + dither: false, + interpolation: None, + }; + if same_edit { + super::super::overlay_effect(&mut fx, &json!({"method": "classic", "from": "#ff0000", "to": "#0000ff"})); + } else { + super::super::overlay_effect(&mut fx, &json!({"method": "classic"})); + super::super::overlay_effect(&mut fx, &json!({"from": "#ff0000", "to": "#0000ff"})); + } + let ends = vec![(0.0, Color::rgb(1.0, 0.0, 0.0)), (1.0, Color::rgb(0.0, 0.0, 1.0))]; + let Effect::GradientOverlay { gradient, interpolation, .. } = fx else { panic!("expected overlay") }; + assert_eq!(gradient.stops, bake_gradient(ends.clone(), &[0.5], 1.0, GradientMethod::Classic)); + assert_eq!(interpolation.unwrap().stops, ends); + } + } + + #[test] + fn changing_method_keeps_all_source_stops_and_transparency() { + let mut gradient = Gradient { + stops: vec![(0.0, Color::rgb(1.0, 0.0, 0.0)), (0.3, Color::rgb(0.0, 1.0, 0.0)), (1.0, Color::rgb(0.0, 0.0, 1.0))], + opacity_stops: vec![(0.0, 0.25), (1.0, 0.8)], + align: false, + offset: (0.2, -0.1), + ..Gradient::default() + }; + let original = gradient.clone(); + let mut source = Some(GradientInterpolation { method: "classic".into(), smoothness: 100.0, stops: original.stops.clone() }); + gradient.stops = bake_gradient(original.stops.clone(), &[0.5, 0.5], 1.0, GradientMethod::Classic); + for method in ["classic", "smooth", "perceptual", "linear", "classic"] { + overlay(&mut gradient, &mut source, &json!({"method": method})); + let source = source.as_ref().unwrap(); + assert_eq!(source.method, method); + assert_eq!(source.stops, original.stops); + let expected = photocraft_io::bake_gradient(original.stops.clone(), &[0.5, 0.5], 1.0, photocraft_io::GradientMethod::from_name(method).unwrap()); + assert_eq!(gradient.stops, expected); + assert_eq!(gradient.opacity_stops, original.opacity_stops); + assert_eq!(gradient.align, original.align); + assert_eq!(gradient.offset, original.offset); + } + overlay(&mut gradient, &mut source, &json!({"from": "#112233", "to": "#445566"})); + let source = source.unwrap(); + assert_eq!(source.stops.len(), 3); + assert_eq!(source.stops[1], original.stops[1], "endpoint edits preserve the middle source stop"); + assert_eq!(gradient.stops, bake_gradient(source.stops, &[0.5, 0.5], 1.0, GradientMethod::Classic)); + } + + #[test] + fn colour_edit_rebakes_the_curve_and_geometry_edits_leave_it_exact() { + let mut gradient = Gradient::default(); + let mut source = None; + overlay(&mut gradient, &mut source, &json!({"method": "smooth", "from": "#ff0000", "to": "#0000ff"})); + let before = gradient.stops.clone(); + overlay(&mut gradient, &mut source, &json!({"from": "#00ff00", "angle": 45})); + assert_ne!(gradient.stops, before); + let expected = photocraft_io::bake_gradient( + vec![(0.0, Color::rgb(0.0, 1.0, 0.0)), (1.0, Color::rgb(0.0, 0.0, 1.0))], + &[0.5], + 1.0, + photocraft_io::GradientMethod::Smooth, + ); + assert_eq!(gradient.stops, expected); + overlay(&mut gradient, &mut source, &json!({"angle": 90, "reverse": true, "scale": 75})); + assert_eq!(gradient.stops, expected); + assert_eq!(gradient.angle, 90.0); + assert!(gradient.reverse); + assert_eq!(gradient.scale, 0.75); + } + + #[test] + fn reopening_does_not_quantize_source_colours_or_rebake() { + let mut gradient = Gradient::default(); + gradient.stops[0].1.alpha = 0.37; + let mut source = None; + overlay(&mut gradient, &mut source, &json!({"from": [0.123456, 0.234567, 0.345678], "method": "perceptual"})); + let before = (gradient.clone(), source.clone()); + assert_eq!(source.as_ref().unwrap().stops[0].1.alpha, 0.37); + let [r, g, b, _] = source.as_ref().unwrap().stops[0].1.to_rgba8(); + overlay(&mut gradient, &mut source, &json!({"from": format!("#{r:02x}{g:02x}{b:02x}"), "to": "#ffffff", "method": "perceptual", "smoothness": 100})); + assert_eq!((gradient, source), before); + } + + #[test] + fn old_gradients_are_unchanged_until_a_method_is_chosen() { + let mut gradient = Gradient::default(); + gradient.stops.insert(1, (0.4, Color::rgb(0.1, 0.9, 0.2))); + let original = gradient.clone(); + let mut source = None; + overlay(&mut gradient, &mut source, &json!({"method": "custom", "opacity": 50})); + assert_eq!(gradient, original); + assert!(source.is_none()); + } + + #[test] + fn endpoint_edits_keep_the_source_colour_model() { + for mode in [photocraft_doc::ColorMode::Grayscale, photocraft_doc::ColorMode::Cmyk, photocraft_doc::ColorMode::Lab] { + let mut gradient = Gradient { stops: vec![(0.0, Color::BLACK.in_mode(mode)), (1.0, Color::WHITE.in_mode(mode))], ..Gradient::default() }; + let mut source = None; + overlay(&mut gradient, &mut source, &json!({"method": "classic"})); + overlay(&mut gradient, &mut source, &json!({"from": "#ff0000"})); + assert!(source.as_ref().unwrap().stops.iter().all(|s| s.1.mode == mode)); + } + } + + #[test] + fn reset_to_factory_defaults_removes_the_recorded_method() { + let mut gradient = Gradient::default(); + let mut source = None; + overlay(&mut gradient, &mut source, &json!({"method": "smooth", "from": "#ff0000"})); + overlay(&mut gradient, &mut source, &super::super::effect_defaults("gradientOverlay")); + assert!(source.is_none()); + assert_eq!(gradient, Gradient::default()); + } +} diff --git a/crates/format/tests/it/common/mod.rs b/crates/format/tests/it/common/mod.rs index 0c91e33e32..8cf2a401f7 100644 --- a/crates/format/tests/it/common/mod.rs +++ b/crates/format/tests/it/common/mod.rs @@ -66,7 +66,12 @@ pub fn rich_doc(mode: ColorMode, depth: SampleType) -> Document { mask.linked = false; paint.mask = Some(mask); paint.effects.items.push(Effect::ColorOverlay { common: FxCommon::new(BlendMode::Normal, 0.8), color: Color::rgb(1.0, 0.2, 0.1) }); - paint.effects.items.push(Effect::GradientOverlay { common: FxCommon::new(BlendMode::Normal, 0.8), gradient: Gradient::default(), dither: true }); + paint.effects.items.push(Effect::GradientOverlay { + common: FxCommon::new(BlendMode::Normal, 0.8), + gradient: Gradient::default(), + dither: true, + interpolation: None, + }); paint.effects.psd_raw = Some(blob(3, 64)); paint.psd_blocks.push((*b"vmsk", blob(4, 40))); paint.psd_blocks.push((*b"lnsr", blob(5, 4))); diff --git a/crates/format/tests/it/roundtrip.rs b/crates/format/tests/it/roundtrip.rs index a199e15f13..06d3928fd1 100644 --- a/crates/format/tests/it/roundtrip.rs +++ b/crates/format/tests/it/roundtrip.rs @@ -11,6 +11,46 @@ use photocraft_raster::Rgba8Image; const MODES: [ColorMode; 4] = [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab]; +#[test] +fn gradient_overlay_interpolation_roundtrips_in_native_documents() { + use photocraft_doc::effects::GradientInterpolation; + use photocraft_doc::{Color, Effect, FxCommon, Gradient}; + + for mode in MODES { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut doc = rich_doc(mode, depth); + let source = GradientInterpolation { + method: "smooth".into(), + smoothness: 73.0, + stops: vec![(0.0, Color::rgb(0.123456, 0.4, 0.9)), (0.4, Color::WHITE), (1.0, Color::BLACK)], + }; + let effect = Effect::GradientOverlay { + common: FxCommon::new(photocraft_doc::BlendMode::Normal, 1.0), + gradient: Gradient::default(), + dither: true, + interpolation: Some(source), + } + .in_mode(mode); + doc.layers.first_mut().unwrap().effects.items.push(effect); + let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap(); + assert_eq!(load_from_bytes(&bytes).unwrap(), doc); + } + } +} + +#[test] +fn old_effect_snapshots_need_no_interpolation_record() { + let effect = photocraft_doc::Effect::GradientOverlay { + common: photocraft_doc::FxCommon::new(photocraft_doc::BlendMode::Normal, 1.0), + gradient: photocraft_doc::Gradient::default(), + dither: false, + interpolation: None, + }; + let json = serde_json::to_value(&effect).unwrap(); + assert!(json["GradientOverlay"].get("interpolation").is_none()); + assert_eq!(serde_json::from_value::(json).unwrap(), effect); +} + #[test] fn custom_shape_stroke_roundtrip_keeps_every_style_field() { for mode in MODES { diff --git a/crates/gpu/tests/it/parity.rs b/crates/gpu/tests/it/parity.rs index 45c03f18d0..44260c63d5 100644 --- a/crates/gpu/tests/it/parity.rs +++ b/crates/gpu/tests/it/parity.rs @@ -955,7 +955,7 @@ fn hard_stop_effects_and_gradient_maps_request_cpu() { } let paint = FxPaint::Gradient(gradient.clone()); let effects = [ - Effect::GradientOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), gradient, dither: false }, + Effect::GradientOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), gradient, dither: false, interpolation: None }, Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, 1.0), size: 1.0, @@ -1686,6 +1686,7 @@ fn effect_cases() -> Vec<(&'static str, Vec)> { common: FxCommon::new(BlendMode::Normal, 0.8), gradient: Gradient { style: GradientStyle::Radial, ..gradient() }, dither: false, + interpolation: None, }], ), ( @@ -1757,7 +1758,7 @@ fn effect_cases() -> Vec<(&'static str, Vec)> { invert: false, }), Effect::ColorOverlay { common: FxCommon::new(BlendMode::SoftLight, 0.5), color: Color::rgb(0.9, 0.3, 0.2) }, - Effect::GradientOverlay { common: FxCommon::new(BlendMode::Overlay, 0.4), gradient: gradient(), dither: false }, + Effect::GradientOverlay { common: FxCommon::new(BlendMode::Overlay, 0.4), gradient: gradient(), dither: false, interpolation: None }, Effect::Stroke(stroke(3.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(0.0, 0.0, 0.0)))), ], ), diff --git a/crates/io/src/effects_map.rs b/crates/io/src/effects_map.rs index b76fe9e368..416aa56b56 100644 --- a/crates/io/src/effects_map.rs +++ b/crates/io/src/effects_map.rs @@ -265,7 +265,9 @@ fn parse_one(key: &str, d: &Descriptor) -> Option { "IrGl" => Effect::InnerGlow(parse_glow(d, true)), "FrFX" => Effect::Stroke(parse_stroke(d)), "SoFi" => Effect::ColorOverlay { common: common(d, BlendMode::Normal, 1.0), color: color(d, "Clr ") }, - "GrFl" => Effect::GradientOverlay { common: common(d, BlendMode::Normal, 1.0), gradient: gradient_from(d), dither: bool_of(d, "Dthr") }, + "GrFl" => { + Effect::GradientOverlay { common: common(d, BlendMode::Normal, 1.0), gradient: gradient_from(d), dither: bool_of(d, "Dthr"), interpolation: None } + } "patternFill" => { let (name, id) = pattern_ref(d); let (angle, link, phase) = pattern_placement(d); @@ -440,7 +442,7 @@ fn write_one(e: &Effect) -> (&'static str, Descriptor) { ("FrFX", d) } Effect::ColorOverlay { common: c, color } => ("SoFi", with_common(Descriptor::new("SoFi"), c).with("Clr ", Value::Descriptor(color_to_desc(color)))), - Effect::GradientOverlay { common: c, gradient, dither } => { + Effect::GradientOverlay { common: c, gradient, dither, .. } => { ("GrFl", gradient_keys(with_common(Descriptor::new("GrFl"), c), gradient).with("Dthr", Value::Boolean(*dither))) } Effect::PatternOverlay { common: c, name, id, scale, angle, link, phase } => ( @@ -711,7 +713,7 @@ mod tests { overprint: true, }), Effect::ColorOverlay { common: FxCommon::new(BlendMode::Overlay, 0.5), color: Color::rgb(0.0, 0.0, 1.0) }, - Effect::GradientOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), gradient: g, dither: true }, + Effect::GradientOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), gradient: g, dither: true, interpolation: None }, Effect::PatternOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), name: "Bubbles".into(), diff --git a/crates/ui-egui/src/layer_style.rs b/crates/ui-egui/src/layer_style.rs index f38afe5745..084a48ffa4 100644 --- a/crates/ui-egui/src/layer_style.rs +++ b/crates/ui-egui/src/layer_style.rs @@ -23,6 +23,8 @@ use crate::{PhotocraftApp, widgets}; pub(crate) mod color_picker; mod contour; +#[cfg(test)] +mod gradient_method_tests; #[derive(Clone, Copy)] enum P { @@ -30,6 +32,7 @@ enum P { Color, Blend, Choice(&'static [(&'static str, &'static str)]), + GradientMethod, Check, Angle, /// A pattern from the dialog's `patternList` field (`[[id, name], …]`). @@ -159,6 +162,7 @@ fn spec(kind: &str) -> &'static [(&'static str, &'static str, P)] { ("style", "Style", P::Choice(GSTYLE)), ("angle", "Angle", P::Angle), ("scale", "Scale", P::Slider(10.0, 150.0, "%")), + ("method", "Method", P::GradientMethod), ], "patternOverlay" => &[ ("blend", "Blend Mode", P::Blend), @@ -293,13 +297,19 @@ fn values_of(e: &Effect, light: f32) -> Value { set(&mut v, "opacity", json!((common.opacity * 100.0).round())); set(&mut v, "color", json!(hex(color))); } - Effect::GradientOverlay { common, gradient: g, .. } => { + Effect::GradientOverlay { common, gradient: g, interpolation, .. } => { set(&mut v, "blend", json!(common.blend.label())); set(&mut v, "opacity", json!((common.opacity * 100.0).round())); - // Endpoint colours and geometry always load into the dialog. A baked curve - // (classic interpolation, PSD import) keeps its interior stops in `fx`; - // `overlay_gradient` only retints the ends unless `method` is present. - if let (Some(first), Some(last)) = (g.stops.first(), g.stops.last()) { + let stops = interpolation.as_ref().map_or(&g.stops, |source| &source.stops); + if let Some(source) = interpolation { + set(&mut v, "method", json!(source.method)); + set(&mut v, "smoothness", json!(source.smoothness)); + } + // Endpoint colours and geometry always load into the dialog. Recorded + // interpolation exposes the original source ends; a baked curve without a + // record (PSD import) keeps its interior stops in `fx`, and an edit only + // retints its ends until a method is chosen. + if let (Some(first), Some(last)) = (stops.first(), stops.last()) { set(&mut v, "from", json!(hex(&first.1))); set(&mut v, "to", json!(hex(&last.1))); } @@ -1057,6 +1067,44 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map { + ui.horizontal(|ui| { + ui.label(RichText::new(tl!("Method")).color(t.text_dim)); + let mut current = disp.get(key).and_then(Value::as_str).unwrap_or("custom").to_string(); + let mut options = vec![ + ("smooth".to_string(), "Smooth"), + ("perceptual".to_string(), "Perceptual"), + ("linear".to_string(), "Linear"), + ("classic".to_string(), "Classic"), + ]; + // Old/imported curves have no method record. Preserve them until + // the user explicitly chooses an interpolation method. + if photocraft_io::GradientMethod::from_name(¤t).is_none() { + current = "custom".into(); + options.insert(0, ("custom".into(), "Custom")); + } + if widgets::dropdown(ui, "fx-gradientOverlay-method", &mut current, &options, 150.0) { + // The older dialog deliberately omits From/To for rich imported + // curves. Once a method is chosen, show the real source ends. + let snapshot = entry(f, &selected).and_then(|e| e.get("fx")).cloned(); + if let Some(Effect::GradientOverlay { gradient, interpolation, .. }) = + snapshot.and_then(|v| serde_json::from_value::(v).ok()) + { + let stops = interpolation.as_ref().map_or(&gradient.stops, |source| &source.stops); + for (key, stop) in [("from", stops.first()), ("to", stops.last())] { + if entry(f, &selected).and_then(|e| e.get("params")).and_then(|p| p.get(key)).is_none() + && let Some(stop) = stop + { + set_param(f, &selected, key, json!(hex(&stop.1))); + } + } + } + let value = json!(current); + disp[key] = value.clone(); + set_param(f, &selected, key, value); + } + }); + } P::Pattern => { let list: Vec<(String, String)> = f .get("patternList") diff --git a/crates/ui-egui/src/layer_style/gradient_method_tests.rs b/crates/ui-egui/src/layer_style/gradient_method_tests.rs new file mode 100644 index 0000000000..2578c1ab51 --- /dev/null +++ b/crates/ui-egui/src/layer_style/gradient_method_tests.rs @@ -0,0 +1,116 @@ +use egui_kittest::kittest::Queryable; + +use super::*; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 32, "height": 32})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app +} + +fn effect(app: &PhotocraftApp) -> &Effect { + let state = app.session.active().unwrap(); + &state.doc.layer(state.active_layer.unwrap()).unwrap().effects.items[0] +} + +fn fields(app: &PhotocraftApp) -> Map { + let state = app.session.active().unwrap(); + initial_fields(state.doc.layer(state.active_layer.unwrap()).unwrap(), Some("gradientOverlay"), 120.0) +} + +#[test] +fn method_picker_is_pending_until_confirm_and_survives_reopening() { + let mut app = app(); + app.run("layer.layerStyle.gradientOverlay", json!({"from": "#ff0000", "to": "#0000ff"})).unwrap(); + let original = effect(&app).clone(); + let pending_fields = fields(&app); + let mut harness = egui_kittest::Harness::builder().with_size(vec2(1000.0, 1000.0)).build_ui_state( + |ui, state: &mut (PhotocraftApp, Map)| { + // The harness draws once before setup_context installs the app fonts. + if ui.ctx().fonts(|fonts| fonts.families().contains(&egui::FontFamily::Name("semibold".into()))) { + body(&mut state.0, ui, &mut state.1); + } + }, + (app, pending_fields), + ); + PhotocraftApp::setup_context(&harness.ctx, crate::theme::ThemeKind::ALL[0]); + harness.run_steps(3); + harness.get_by_value("Custom").click(); + harness.run_steps(2); + harness.get_by_label("Smooth").click(); + harness.run_steps(2); + assert_eq!(effect(&harness.state().0), &original, "cancel can still discard all pending edits"); + assert_eq!(harness.state().1["effects"][0]["params"]["method"], "smooth"); + let (app, pending) = harness.state_mut(); + confirm(app, pending).unwrap(); + let saved = effect(app).clone(); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(effect(app), &original); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(effect(app), &saved); + let reopened = fields(app); + assert_eq!(reopened["effects"][0]["params"]["method"], "smooth"); + assert_eq!(reopened["effects"][0]["params"]["from"], "#ff0000"); + confirm(app, &reopened).unwrap(); + assert_eq!(effect(app), &saved, "OK without edits leaves the rendered curve exact"); +} + +#[test] +fn all_four_methods_roundtrip_through_the_dialog() { + for method in ["classic", "linear", "perceptual", "smooth"] { + let mut app = app(); + app.run("layer.layerStyle.gradientOverlay", json!({"from": [0.123456, 0.234567, 0.345678], "to": "#abcdef", "method": method})).unwrap(); + let before = effect(&app).clone(); + let fields = fields(&app); + assert_eq!(fields["effects"][0]["params"]["method"], method); + confirm(&mut app, &fields).unwrap(); + assert_eq!(effect(&app), &before); + } +} + +#[test] +fn baked_overlay_is_unchanged_until_method_selection_then_edits_the_whole_curve() { + use photocraft_color::Color; + use photocraft_io::{GradientMethod, bake_gradient}; + + let mut app = app(); + app.run("layer.layerStyle.gradientOverlay", json!({})).unwrap(); + // PSD import and older method commands leave a baked curve without a source record. + let state = app.session.active_mut().unwrap(); + let fx = &mut std::sync::Arc::make_mut(&mut state.doc).layer_mut(state.active_layer.unwrap()).unwrap().effects.items[0]; + let Effect::GradientOverlay { gradient, interpolation, .. } = fx else { panic!("expected overlay") }; + gradient.stops = bake_gradient(vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], &[0.5], 1.0, GradientMethod::Smooth); + *interpolation = None; + let source_ends = vec![(0.0, gradient.stops.first().unwrap().1), (1.0, gradient.stops.last().unwrap().1)]; + + let original = effect(&app).clone(); + let mut pending = fields(&app); + let id = pending["effects"][0]["id"].as_str().unwrap().to_string(); + assert_eq!(pending["effects"][0]["params"]["method"], "custom"); + confirm(&mut app, &pending).unwrap(); + assert_eq!(effect(&app), &original); + + set_param(&mut pending, &id, "method", json!("linear")); + confirm(&mut app, &pending).unwrap(); + let selected = effect(&app).clone(); + let Effect::GradientOverlay { gradient, interpolation, .. } = &selected else { panic!("expected overlay") }; + assert_eq!(gradient.stops, bake_gradient(source_ends, &[0.5], 1.0, GradientMethod::Linear)); + assert_eq!(interpolation.as_ref().unwrap().stops.len(), 2); + + let mut reopened = fields(&app); + assert_eq!(reopened["effects"][0]["params"]["method"], "linear"); + set_param(&mut reopened, &id, "from", json!("#ff0000")); + set_param(&mut reopened, &id, "to", json!("#0000ff")); + confirm(&mut app, &reopened).unwrap(); + let edited = effect(&app).clone(); + let Effect::GradientOverlay { gradient, .. } = &edited else { panic!("expected overlay") }; + assert_eq!(gradient.stops, bake_gradient(vec![(0.0, Color::rgb(1.0, 0.0, 0.0)), (1.0, Color::rgb(0.0, 0.0, 1.0))], &[0.5], 1.0, GradientMethod::Linear)); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(effect(&app), &selected); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(effect(&app), &edited); + let reopened = fields(&app); + confirm(&mut app, &reopened).unwrap(); + assert_eq!(effect(&app), &edited); +} diff --git a/crates/ui-egui/tests/support/layout_doc.rs b/crates/ui-egui/tests/support/layout_doc.rs index f79523d505..a36acfc604 100644 --- a/crates/ui-egui/tests/support/layout_doc.rs +++ b/crates/ui-egui/tests/support/layout_doc.rs @@ -97,6 +97,7 @@ fn gradient_overlay(a: [f32; 3], b: [f32; 3]) -> Effect { common: FxCommon::new(BlendMode::Normal, 0.6), gradient: Gradient { stops: vec![(0.0, Color::rgb(a[0], a[1], a[2])), (1.0, Color::rgb(b[0], b[1], b[2]))], angle: 90.0, ..Default::default() }, dither: false, + interpolation: None, } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9e60af2136..361bb3527c 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -69,6 +69,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - The unsaved-changes prompt (closing a document, Revert, quitting with unsaved work) is listed in `ui.inspect`'s `dialogs` with the id `"discard"` while it is up: `fields` gives the waiting `command` (`file.close`, `file.exit`…), the `document` it asks about, how many are `remaining`, and its `choices`. `ui.dialog.confirm` takes `{"dialog": "discard", "choice": "save" | "dontSave" | "cancel"}` (default `save`; Revert takes `dontSave` or `cancel`) and answers it as the window's buttons do, one document at a time; `ui.dialog.cancel` with `{"dialog": "discard"}` cancels it. Save of an untitled document opens the save dialog first, so the reply's `fileDialog` is true and the prompt stays open until that is answered. - A save dialog's answer that PhotoCraft changes, by giving a name without an extension the suggested file's or by lowercasing the extension (Preferences › File Handling › Lowercase Extension), opens a replace prompt when a file exists under the changed name: a dialog with `__replaceSaved`, `message`, `detail` and `path` (the file the save would replace). `ui.dialog.confirm` writes the file; `ui.dialog.cancel` ends the save without writing, as Cancel in the save dialog does. The file dialog counts as open until the prompt is answered. A name that differs only in letter case and reaches the same file (Windows and macOS by default) does not open it. - Layer Style (`layerStyle {effect?}` opens on that effect kind): the fields hold the dialog's whole state, so agents read and drive it like a user. `effects` lists every effect instance in the layer's order as `{id, kind, on, params, fx}` (`fx` is the effect snapshot the dialog loaded; OK edits that snapshot with `params`, so anything the dialog doesn't model survives). `selected` picks the page (`blendingOptions` or an instance id), `preview` (default on) gates the live canvas preview, `p:blendingOptions` edits blend mode/opacity/fill opacity, and `patternList` names the usable patterns. OK replaces the layer's effects in one step (`layer.layerStyle.replace`); Cancel discards. "New Style…" saves the pending state via `style.presets.new {effects}`, a swatch click applies a style preset, and Make/Reset Default use `layer.layerStyle.makeDefault` / `layer.layerStyle.defaultFor` + - Gradient Overlay exposes `params.method` (`smooth`, `perceptual`, `linear`, `classic`). `custom` denotes an existing curve without a recorded method and preserves it. Choosing a method retains all recorded source colour stops. If no source record exists (including baked PSD imports), choosing a method rebuilds from the two end colours, matching legacy command behaviour; the old curve stays unchanged until that choice. Later endpoint edits rebuild the colour curve using the recorded method. The source stops, smoothness and method survive dialog reopening, undo/redo and native `.pcraft` saves. Saving a style preset through "New Style…" may lose the multi-stop source curve: it passes only params to `style.presets.new`, and `effect_from_params` rebuilds the effect from the default gradient. PSD export continues to write the baked appearance; reopening that PSD shows Custom because editable interpolation metadata is not exported yet. Opacity interpolation and the multi-stop editor are separate work. - Preferences (`ui.menu.invoke {id: "edit.preferences.interface"}`) exposes the sections in its `values` field. Apply (`ui.dialog.apply`) and OK (`ui.dialog.confirm`) on Preferences are refused over control because automation denies whole-preferences updates and filesystem-bearing preference sections. A refused `ui.dialog.apply` returns an error and keeps the dialog open; a refused `ui.dialog.confirm` returns an error but closes and removes the dialog. `ui.dialog.cancel` closes the dialog without saving. Individual safe preferences can instead be changed with `engine.execute` using `prefs.set` (e.g. `{"command": "prefs.set", "params": {"path": "interface.showTooltips", "value": false}}`). Settings marked for the next launch still require a restart. - Edit › Fill… (`ui.menu.invoke {id: "edit.fill"}`, Shift+F5, Shift+Backspace) opens the Fill dialog; its fields are `edit.fill`'s params (`contents`, `color`, `pattern`, `colorAdaptation`, `mode`, `opacity`, `preserveTransparency`), and OK remembers them in the preferences (`dialogs["edit.fill"]`). - Save for Web (`ui.menu.invoke {id: "file.export.saveForWebLegacy"}`) is resizable with independently scrolling settings. Dialog fields `__view` (`original|optimized|2up|4up`), `__zoom` (`null` = Fit, otherwise a factor; 1 = 100% in physical display pixels), and `__center: [u,v]` control the shared preview navigation. `__previewRects` reports pane rectangles in screen points. Drag a preview to pan, scroll/pinch to zoom at the pointer, or double-click to fit. GIF/PNG-8 expose `colorTable: ["#rrggbb", …]` (opaque source entries) and `colorTableEdits: {"0":"#rrggbb", …}` (zero-based replacements after quantization). A swatch opens a color picker; edits update the preview and saved file without remapping pixel indices or changing the document. Reset clears replacements. Changing palette/size/matte/transparency settings regenerates the table and clears index-based edits. Previews and generated tables use a bounded 2048-pixel proxy; full-size export uses that explicit table unchanged. `__paletteApproximate` reports proxy-derived palette generation, and enlarged proxy previews are labelled. The engine export command accepts the same table/edit parameters; its no-path estimate returns the source `colorTable` and `transparentIndex` as well as size statistics. Opening a new dialog does not carry a previous image's table across. Image Size has `width` and `height` (px), `percent` and `resample` (`nearest|bilinear|bicubic|bicubicSmoother|bicubicSharper`, default `bicubic`); `__constrain` (default true at each opening) links the two sides in proportion. A side edit sets both sides and removes `percent`, which the export command otherwise applies first; a Percent edit sets both sides from it. Each opening starts at the document's full size, while `resample` is remembered with the encoding choices. The document is never resized. diff --git a/docs/images/gradient-overlay-method-after.png b/docs/images/gradient-overlay-method-after.png new file mode 100644 index 0000000000..8d6107e7ab Binary files /dev/null and b/docs/images/gradient-overlay-method-after.png differ diff --git a/docs/images/gradient-overlay-method-before.png b/docs/images/gradient-overlay-method-before.png new file mode 100644 index 0000000000..75a63d97b3 Binary files /dev/null and b/docs/images/gradient-overlay-method-before.png differ diff --git a/docs/images/gradient-overlay-method-options.png b/docs/images/gradient-overlay-method-options.png new file mode 100644 index 0000000000..1c7aaed68c Binary files /dev/null and b/docs/images/gradient-overlay-method-options.png differ