fix(liquify): include cubic overshoot in source reads

This commit is contained in:
yongjip
2026-10-21 08:18:18 +09:00
parent 872748fb5b
commit aaf3492b81
2 changed files with 104 additions and 1 deletions
+3 -1
View File
@@ -653,7 +653,9 @@ pub fn apply_liquify(src: &Surface, field: &LiquifyField) -> Surface {
if field.is_identity() || b.is_empty() {
return out;
}
let maxd = f64::from(field.max_displacement()).ceil() as i32 + 3;
// A 1D Catmull-Rom stencil's absolute weights sum to at most 5/4, so the 2D
// interpolated displacement can reach 25/16 of the largest nodal displacement.
let maxd = ((f64::from(field.max_displacement()) * (25.0 / 16.0)).ceil() as i32).saturating_add(3);
let tiles: Vec<Rect> = b.tiles().map(|t| t.rect().intersect(&b)).filter(|r| !r.is_empty() && field.touches(*r)).collect();
let work = |t: &Rect| -> Option<(Rect, Vec<f32>)> {
let w = t.width() as usize;
@@ -0,0 +1,101 @@
//! Liquify source reads must depend on the mapping, not the output tile phase.
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(shift: i32) -> Rect {
Rect::new(shift, 0, shift + 1024, 512)
}
fn source(sample: SampleType, shift: i32) -> Surface {
let mut surface = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, false));
let mut row = vec![0.0; 1024 * 3];
for y in 0..512 {
for (x, pixel) in row.chunks_exact_mut(3).enumerate() {
pixel.copy_from_slice(&[x as f32 / 1024.0, (x % 2) as f32, y as f32 / 512.0]);
}
surface.write_region(Rect::new(shift, y, shift + 1024, y + 1), &row);
}
surface
}
fn strokes(shift: i32, masked: bool) -> Vec<LiquifyStroke> {
let offset = f64::from(shift);
let mut strokes = Vec::new();
if masked {
let mut freeze = LiquifyStroke::new(LiquifyTool::LassoMask, 1.0);
freeze.amount = Some(1.0);
freeze.points = vec![vec![offset, 0.0], vec![offset + 508.0, 0.0], vec![offset + 508.0, 512.0], vec![offset, 512.0]];
strokes.push(freeze);
}
let mut warp = LiquifyStroke::new(LiquifyTool::ForwardWarp, 512.0);
warp.density = 100.0;
warp.points = vec![vec![offset + 512.0, 256.5], vec![offset + 499.2, 256.5]];
strokes.extend(std::iter::repeat_n(warp, 8));
for stroke in &strokes {
stroke.validate().expect("the fixture uses supported brush parameters");
}
strokes
}
fn check_tile_translation(masked: bool) {
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
let original = source(sample, 0);
let translated = source(sample, 128);
for cell in [2.0, 4.0] {
let field = LiquifyField::from_strokes(bounds(0), cell, &strokes(0, masked));
let shifted_field = LiquifyField::from_strokes(bounds(128), cell, &strokes(128, masked));
if masked {
assert!(field.d == shifted_field.d, "translation must preserve the nodal displacement values");
}
// The reported pixels lie entirely on the editable side of the mask.
assert_eq!(field.freeze_at(509.5, 220.5), 0.0);
assert_eq!(shifted_field.freeze_at(637.5, 220.5), 0.0);
let output = apply_liquify(&original, &field);
let shifted_output = apply_liquify(&translated, &shifted_field);
assert_ne!(output.pixel(509, 220), original.pixel(509, 220), "the fixture must actually warp the inspected strip");
let mut worst = 0.0f32;
// Translation changes which output tile contains this strip, while
// preserving the relative source image, brush path and document edges.
for y in 220..290 {
for x in 509..512 {
let a = output.pixel(x, y);
let b = shifted_output.pixel(x + 128, y);
for (a, b) in a.iter().zip(&b) {
worst = worst.max((a - b).abs());
}
}
}
// Coordinate translation can perturb the last few F32 arithmetic bits.
// This is far smaller than the clipped source-stencil error.
let tolerance = if sample == SampleType::F32 { 2e-6 } else { 0.0 };
assert!(worst <= tolerance, "{sample:?}, meshSize {cell}, masked={masked}: moving the document by half a tile changed output by {worst}");
}
}
}
#[test]
fn cubic_source_footprint_is_independent_of_output_tile_phase() {
check_tile_translation(true);
}
#[test]
fn ordinary_unmasked_warp_is_independent_of_output_tile_phase() {
check_tile_translation(false);
}
#[test]
fn source_reads_still_clamp_at_the_document_edge() {
let mut warp = LiquifyStroke::new(LiquifyTool::ForwardWarp, 256.0);
warp.density = 100.0;
warp.points = vec![vec![1008.0, 256.5], vec![944.0, 256.5]];
warp.validate().expect("the edge fixture uses supported brush parameters");
let field = LiquifyField::from_strokes(bounds(0), 4.0, &[warp]);
assert!(field.sample(1023.5, 256.5)[0] > 4.0, "the fixture must sample beyond the right document edge");
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
let original = source(sample, 0);
let output = apply_liquify(&original, &field);
assert_eq!(output.pixel(1023, 256), original.pixel(1023, 256), "{sample:?}: an out-of-document source read must repeat the true document edge");
}
}