Merge approved PhotoCraft PR #4711: Treat Dissolve as a per-pixel choice

Port behavior and regression coverage into current modules; preserve current upstream behavior.
Current modular compositors already implement spatial Dissolve for adjustments and finished effects. Preserve equivalent implementations and newer Fill/HDR behavior, append approved missing CPU/GPU regression coverage.
This commit is contained in:
FredApps
2026-10-29 06:11:42 +01:00
2 changed files with 76 additions and 0 deletions
+49
View File
@@ -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.
+27
View File
@@ -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.