From cd2b4aca0c62a440a21329e83a1482dea631d9e3 Mon Sep 17 00:00:00 2001 From: Yongjip Kim <13984989+yongjip@users.noreply.github.com> Date: Sat, 24 Oct 2026 06:40:45 +0900 Subject: [PATCH] test(liquify): expand freeze-boundary regression coverage (#8082) --- .../algo/tests/it/liquify_freeze_formats.rs | 107 ++++++++++++++++++ crates/algo/tests/it/main.rs | 1 + 2 files changed, 108 insertions(+) create mode 100644 crates/algo/tests/it/liquify_freeze_formats.rs diff --git a/crates/algo/tests/it/liquify_freeze_formats.rs b/crates/algo/tests/it/liquify_freeze_formats.rs new file mode 100644 index 0000000000..e5def7106f --- /dev/null +++ b/crates/algo/tests/it/liquify_freeze_formats.rs @@ -0,0 +1,107 @@ +//! Final-render coverage for #7989, extending the boundary fix in #8012. +use photocraft_algo::liquify::{LiquifyField, LiquifyStroke, LiquifyTool, apply_liquify}; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_geom::Rect; +use photocraft_raster::Surface; + +fn bounds() -> Rect { + // Cross the tile boundary at x=256, with a nonzero origin on both axes. + Rect::new(192, -32, 320, 96) +} + +fn source(mode: ColorMode, sample: SampleType) -> Surface { + let format = PixelFormat::new(mode, sample, true); + let mut pixels = Vec::with_capacity(128 * 128 * format.channels()); + for y in 0..128 { + for x in 0..128 { + let alpha = if (48..52).contains(&x) { 0.0 } else { ((x + 3 * y) % 5 + 1) as f32 / 5.0 }; + for channel in 0..mode.color_channels() { + pixels.push(if alpha == 0.0 { 0.0 } else { ((x * 19 + y * 7 + channel * 43) % 251 + 2) as f32 / 255.0 }); + } + pixels.push(alpha); + } + } + let mut source = Surface::new(format); + source.write_region(bounds(), &pixels); + source +} + +fn warp(points: &[[f64; 2]], size: f64, pressure: f64) -> LiquifyStroke { + let mut stroke = LiquifyStroke::new(LiquifyTool::ForwardWarp, size); + stroke.pressure = pressure; + stroke.points = points.iter().map(|point| point.to_vec()).collect(); + stroke +} + +fn freeze_left() -> LiquifyStroke { + let mut stroke = LiquifyStroke::new(LiquifyTool::LassoMask, 1.0); + stroke.amount = Some(1.0); + stroke.points = vec![vec![192.0, -32.0], vec![256.0, -32.0], vec![256.0, 96.0], vec![192.0, 96.0]]; + stroke +} + +fn check_frozen_output(prior_warp: bool) { + for mode in [ColorMode::Grayscale, ColorMode::Rgb, ColorMode::Cmyk] { + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + let source = source(mode, sample); + for cell in [2.5, 4.0] { + let mut strokes = Vec::new(); + if prior_warp { + strokes.push(warp(&[[238.0, 20.0], [261.0, 28.0]], 64.0, 55.0)); + } + strokes.push(freeze_left()); + let mut field = LiquifyField::from_strokes(bounds(), cell, &strokes); + // #8012 establishes the bilinear mapping when the mask is applied. + // This test checks later edits, not exact pre-mask cubic preservation. + let before = apply_liquify(&source, &field); + assert_eq!(before.format(), source.format()); + assert_eq!(before == source, !prior_warp, "the fixture must retain an earlier warp and keep a fresh mask-only operation unchanged"); + strokes.push(warp(&[[268.0, 32.0], [252.0, 32.0]], 48.0, 80.0)); + field.apply_stroke(&strokes[strokes.len() - 1]); + let after = apply_liquify(&source, &field); + assert_eq!(after.format(), source.format()); + + let (mut protected, mut fractional_alpha, mut unfrozen_changes) = (0, 0, 0); + let mut frozen_warped = 0; + for y in bounds().y0..bounds().y1 { + for x in bounds().x0..bounds().x1 { + let pixel = before.pixel(x, y); + if field.freeze_at(f64::from(x) + 0.5, f64::from(y) + 0.5) == 1.0 { + protected += 1; + frozen_warped += usize::from(pixel != source.pixel(x, y)); + let alpha = pixel[mode.color_channels()]; + fractional_alpha += usize::from(alpha > 0.0 && alpha < 1.0); + // An unchanged F32 pixel can round on premultiply/unpremultiply. + // Integer-depth pixels must remain exactly equal. + let tolerance = if sample == SampleType::F32 { 4.0 * f32::EPSILON } else { 0.0 }; + for (channel, (actual, expected)) in after.pixel(x, y).iter().zip(&pixel).enumerate() { + assert!( + (actual - expected).abs() <= tolerance, + "{mode:?}/{sample:?}, mesh {cell}, prior warp {prior_warp}: frozen channel {channel} changed at {x},{y}: {expected} -> {actual}" + ); + } + } else if field.freeze_at(f64::from(x) + 0.5, f64::from(y) + 0.5) == 0.0 { + unfrozen_changes += usize::from(after.pixel(x, y) != pixel); + } + } + } + assert!(protected > 7_000 && fractional_alpha > 3_000, "the fixture must cover frozen pixels with fractional alpha"); + assert_eq!(frozen_warped > 0, prior_warp, "the fixture must retain the earlier warp inside the fully frozen region"); + assert!(unfrozen_changes > 0, "the adjacent unfrozen region must still move"); + let replay = LiquifyField::from_strokes(bounds(), cell, &strokes); + assert!(field == replay, "stroke replay must reproduce the field exactly"); + assert!(apply_liquify(&source, &replay) == after, "stroke replay must reproduce the final pixels exactly"); + } + } + } +} + +#[test] +fn fresh_frozen_output_preserves_color_and_alpha_across_formats() { + check_frozen_output(false); +} + +#[test] +fn existing_frozen_mapping_survives_adjacent_warps_across_formats() { + check_frozen_output(true); +} diff --git a/crates/algo/tests/it/main.rs b/crates/algo/tests/it/main.rs index 107626cfc2..e611f93cb9 100644 --- a/crates/algo/tests/it/main.rs +++ b/crates/algo/tests/it/main.rs @@ -1,5 +1,6 @@ //! The crate's integration tests, linked into one test binary. +mod liquify_freeze_formats; mod liquify_source_footprint; mod retouch_blur_exact; mod warp_clipped;