Files
photocraft/crates/vector/tests/shape_patterns.rs
T
Skyfuller1303 3ab072088c feat(vector): pattern paints for shape fill and stroke
Pattern fills on shape layers rendered transparent because the vector
renderer had no access to the document's pattern table. Add
Paint::Pattern, Paint::from_fill_with_patterns,
CompiledShape::with_patterns and render_shape_with_patterns, which
resolve a pattern by id or name and sample it in the document's colour
mode, for the fill and the stroke independently.

The compositor's fill/stroke split (used for clipped layers and interior
effects) now renders with the same patterns and colour mode on CPU and
GPU, and its cache key covers the referenced pattern tiles.

Refs #2496
2026-10-13 19:53:02 +02:00

106 lines
5.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use photocraft_doc::{ColorMode, Fill, Pattern, PixelFormat, SampleType, ShapeLayer, ShapeStroke};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use photocraft_vector::{Paint, pattern::Tile, render_shape_with_patterns};
fn pattern(sample: SampleType, mode: ColorMode) -> Pattern {
let format = PixelFormat::new(mode, sample, true);
let mut s = Surface::new(format);
for (x, rgba) in [(0, [0.9, 0.2, 0.1, 1.0]), (1, [0.1, 0.3, 0.9, 0.5])] {
let mut values = [0.0; 5];
let n = photocraft_raster::from_rgba_into(&format, rgba, &mut values);
s.fill_rect(Rect::new(x, 0, x + 1, 2), &values[..n]);
}
Pattern::new("$$$/Patterns/Test=Tile", s, 2, 2)
}
fn fill(p: &Pattern) -> Fill {
Fill::Pattern { id: p.id.clone(), name: p.name.clone(), scale: 1.0, angle: 0.0, link: true, phase: (0.0, 0.0) }
}
fn close(a: [f32; 4], b: [f32; 4], tolerance: f32) {
for c in 0..4 {
assert!((a[c] - b[c]).abs() < tolerance, "{a:?} != {b:?}");
}
}
#[test]
fn pattern_shape_fill_and_stroke_render_at_every_depth_and_model() {
let canvas = Rect::new(0, 0, 32, 32);
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] {
let p = pattern(sample, mode);
let fmt = PixelFormat::new(mode, sample, true);
let check = |actual: [f32; 4], expected: [f32; 4], where_: &str| {
for c in 0..4 {
assert!((actual[c] - expected[c]).abs() < 1e-4, "{sample:?}/{mode:?} {where_}: {actual:?} != {expected:?}");
}
};
// The cache stores the document's format. CMYK's decode/encode/decode
// roundtrip need not preserve RGB, even when the source tile is also CMYK.
let encoded = |x| {
let values = photocraft_raster::from_rgba(&fmt, p.surface.rgba(x, 0));
Surface::with_default(fmt, &values).rgba(0, 0)
};
let sh = ShapeLayer { path: photocraft_vector::shapes::rounded_rect(8.0, 8.0, 16.0, 16.0, [0.0; 4]), fill: Some(fill(&p)), ..Default::default() };
let sampler =
Paint::from_fill_with_patterns(sh.fill.as_ref().unwrap(), (8.0, 8.0, 24.0, 24.0), (0.0, 0.0, 32.0, 32.0), std::slice::from_ref(&p), mode);
check(sampler.at(10.5, 10.5), p.surface.rgba(0, 0), "opaque tile sampler");
check(sampler.at(11.5, 10.5), p.surface.rgba(1, 0), "half-alpha tile sampler");
let surface = render_shape_with_patterns(&sh, fmt, canvas, std::slice::from_ref(&p));
check(surface.rgba(10, 10), encoded(0), "opaque fill cache");
check(surface.rgba(11, 10), encoded(1), "half-alpha fill cache");
assert_eq!(surface.rgba(2, 2)[3], 0.0);
let stroke = ShapeLayer { fill: None, stroke: Some(ShapeStroke { width: 4.0, paint: fill(&p), ..Default::default() }), ..sh };
let surface = render_shape_with_patterns(&stroke, fmt, canvas, std::slice::from_ref(&p));
check(surface.rgba(8, 12), encoded(0), "stroke cache");
assert_eq!(surface.rgba(16, 16)[3], 0.0);
}
}
}
#[test]
fn pattern_lookup_placement_and_premultiplied_sampling() {
let p = pattern(SampleType::F32, ColorMode::Rgb);
let mut f = fill(&p);
if let Fill::Pattern { id, name, link, phase, scale, angle } = &mut f {
*id = "missing-id".into();
*name = "tile".into();
*link = false;
*phase = (4.0, 6.0);
*scale = 2.0;
*angle = 90.0;
}
let paint = Paint::from_fill_with_patterns(&f, (8.0, 8.0, 24.0, 24.0), (0.0, 0.0, 32.0, 32.0), std::slice::from_ref(&p), ColorMode::Rgb);
// Undo 90° and 2× scale: (5,5) relative to phase maps to tile centre (.5,.5).
close(paint.at(5.0, 5.0), p.surface.rgba(0, 0), 0.001);
assert!(matches!(Paint::from_fill(&f, (0.0, 0.0, 1.0, 1.0), (0.0, 0.0, 32.0, 32.0)), Paint::None));
let tile = Tile::new(&p).unwrap();
let a = p.surface.rgba(0, 0);
let b = p.surface.rgba(1, 0);
let alpha = (a[3] + b[3]) / 2.0;
close(
tile.sample(1.0, 0.5),
[(a[0] * a[3] + b[0] * b[3]) / (2.0 * alpha), (a[1] * a[3] + b[1] * b[3]) / (2.0 * alpha), (a[2] * a[3] + b[2] * b[3]) / (2.0 * alpha), alpha],
0.001,
);
close(tile.sample(-1.5, 0.5), a, 0.001);
assert_eq!(tile.sample(f64::NAN, 0.0), [0.0; 4]);
assert!(tile.sample(f64::MAX, -f64::MAX).iter().all(|v| v.is_finite()));
}
#[test]
fn oversized_sparse_pattern_samples_without_a_second_full_surface() {
let p = Pattern {
id: "large".into(),
name: "large".into(),
width: 4096,
height: 4096,
surface: Surface::with_default(PixelFormat::RGBA8, &[0.2, 0.6, 0.9, 1.0]),
};
let tile = Tile::new(&p).unwrap();
close(tile.sample(4000.5, 2000.5), p.surface.rgba(4000, 2000), 0.001);
assert!(Tile::new(&Pattern { width: u32::MAX, ..p.clone() }).is_none());
assert!(Tile::new(&Pattern { height: 0, ..p }).is_none());
}