diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index fd195bf453..fa8a6c5b89 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1017,6 +1017,55 @@ fn dissolve_coverage_matches_opacity() { assert_eq!(flatten(&d), b); } +/// Invert at 50% Dissolve is black or white, not the gray of a Normal mix (#3050). +#[test] +fn dissolve_adjustment_is_a_per_pixel_choice() { + let mut d = doc_white(16, 16); + let mut inv = Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)); + inv.blend = photocraft_color::BlendMode::Dissolve; + inv.opacity = 0.5; + d.layers.push(inv); + let b = flatten(&d); + let (black, white, other) = b.px.iter().fold((0, 0, 0), |(k, w, o), p| { + if close4(*p, [0.0, 0.0, 0.0, 1.0]) { + (k + 1, w, o) + } else if close4(*p, [1.0, 1.0, 1.0, 1.0]) { + (k, w + 1, o) + } else { + (k, w, o + 1) + } + }); + assert_eq!(other, 0, "a partial mix would be gray"); + assert!(black > 40 && white > 40, "black {black} white {white}"); +} + +/// A clipped black fill at 50% Dissolve stays black or white on an opaque base (#3052). +#[test] +fn clipped_dissolve_fill_is_a_per_pixel_choice() { + let mut d = doc_white(16, 16); + let mut l = solid_layer("k", Rect::new(0, 0, 16, 16), [0.0, 0.0, 0.0, 1.0]); + l.clipped = true; + l.blend = photocraft_color::BlendMode::Dissolve; + l.opacity = 0.5; + d.layers.push(l); + let b = flatten(&d); + assert!(b.px.iter().all(|p| close4(*p, [0.0, 0.0, 0.0, 1.0]) || close4(*p, [1.0; 4]))); +} + +/// A Color Overlay must not turn Dissolve coverage into a Normal mix (#3051). +#[test] +fn color_overlay_keeps_dissolve_coverage() { + use photocraft_doc::effects::{Effect, FxCommon}; + let mut d = doc_white(16, 16); + let mut l = Layer::new("ink", LayerContent::Fill(Fill::Solid(photocraft_color::Color::BLACK))); + l.effects.items.push(Effect::ColorOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), color: photocraft_color::Color::BLACK }); + l.blend = photocraft_color::BlendMode::Dissolve; + l.opacity = 0.5; + d.layers.push(l); + let b = flatten(&d); + assert!(b.px.iter().all(|p| close4(*p, [0.0, 0.0, 0.0, 1.0]) || close4(*p, [1.0; 4]))); +} + #[test] fn render_is_tile_independent() { // Rendering a sub-rect must equal the same region of a full render. diff --git a/crates/gpu/tests/it/parity.rs b/crates/gpu/tests/it/parity.rs index 0b19410f19..fdfff2be61 100644 --- a/crates/gpu/tests/it/parity.rs +++ b/crates/gpu/tests/it/parity.rs @@ -3196,6 +3196,33 @@ fn pass_through_clipping_base_with_an_adjustment() { check(&mut g, &d, "pass-through clipping base with an adjustment"); } +/// Dissolve's adjustment/effect decisions use document coordinates, even in offset regions +/// and in the paged compositor. The clipped path comes from #4992. +#[test] +fn dissolve_adjustments_and_effects_match_cpu_in_offset_regions() { + let Some(mut g) = gpu() else { return }; + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for effects in [false, true] { + let mut d = Document::new("Dissolve", Size::new(300, 280), ColorMode::Rgb, depth); + d.layers.push(noise_layer("base", d.pixel_format(), Rect::new(0, 0, 300, 280), 19, 0.4)); + let mut layer = if effects { + let mut l = noise_layer("effect", d.pixel_format(), Rect::new(-8, 13, 290, 270), 27, 0.6); + l.effects.items = vec![Effect::ColorOverlay { common: FxCommon::new(BlendMode::Normal, 0.7), color: Color::rgb(0.2, 0.8, 0.4) }]; + l + } else { + Layer::new("adjustment", LayerContent::Adjustment(Adjustment::Invert)) + }; + layer.blend = BlendMode::Dissolve; + layer.opacity = 0.37; + layer.mask = Some(mask(Rect::new(11, 7, 287, 265), 43, 0.0)); + d.layers.push(layer); + let label = format!("Dissolve effects={effects} {depth:?}"); + fx_check(&mut g, &d, &label); + diff_rect(&mut g, &d, Rect::new(17, 23, 281, 269), &label).unwrap(); + } + } +} + /// Transparency Shapes Layer off changes the special eight's blend (the layer's alpha blends as /// Fill, #5692); the GPU has no pass for it and leaves such documents to the CPU. Other modes look /// the same either way and stay on the GPU.