Merge PR #8903 into integration batch

This commit is contained in:
Federico Olivares
2026-10-24 00:51:30 -03:00
4 changed files with 106 additions and 21 deletions
+2 -4
View File
@@ -1257,8 +1257,7 @@ fn paint_pattern(
m,
|i| {
let (x, y) = (f64::from(big.x0 + (i % w) as i32) + 0.5, f64::from(big.y0 + (i / w) as i32) + 0.5);
let (u, v) = place.map(x, y);
tile.sample(u, v)
tile.sample_area(&place, x, y)
},
blend,
opacity,
@@ -1609,8 +1608,7 @@ fn bevel_height(shape: &Map, b: &Bevel, g: &BevelGeom, tex: &TextureCtx, pattern
|| (),
|_, y, row, _| {
for (x, o) in row.iter_mut().enumerate() {
let (u, v) = place.map(f64::from(x0 + x as i32) + 0.5, f64::from(y0 + y as i32) + 0.5);
let p = tile.sample(u, v);
let p = tile.sample_area(&place, f64::from(x0 + x as i32) + 0.5, f64::from(y0 + y as i32) + 0.5);
let l = (0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]) * p[3];
*o += k * if t.invert { 1.0 - l } else { l };
}
+77 -7
View File
@@ -3,7 +3,9 @@
//! A pattern tiles the plane from an origin (the layer's frame when linked, else the canvas
//! origin, plus the phase), scaled and rotated about that origin. Samples are bilinear with
//! wrap-around on premultiplied colour, so integer placements at 100 % reproduce the tile
//! exactly and scaled ones stay seamless.
//! exactly and scaled ones stay seamless. Below 100 % each document pixel averages an
//! `n × n` grid of bilinear taps over its footprint in the tile (see [`pixel_taps`]), so a
//! minified tile is box-filtered instead of aliased; at 100 % and above it is a single tap.
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
@@ -157,6 +159,33 @@ impl Tile {
/// Bilinear, wrapping sample at continuous tile coordinates (pixel centres at `i + 0.5`).
/// Returns straight alpha.
pub fn sample(&self, u: f64, v: f64) -> [f32; 4] {
unpremultiply(self.sample_premul(u, v))
}
/// The pattern at the centre `(x, y)` of a document pixel, filtered over the pixel's
/// footprint in the tile when the placement shrinks it. Returns straight alpha.
pub fn sample_area(&self, place: &Placement, x: f64, y: f64) -> [f32; 4] {
let n = pixel_taps(place.inv_scale);
if n == 1 {
let (u, v) = place.map(x, y);
return self.sample(u, v);
}
let mut acc = [0.0f32; 4];
for j in 0..n {
for i in 0..n {
let (u, v) = place.map(x + tap_offset(i, n), y + tap_offset(j, n));
let q = self.sample_premul(u, v);
for c in 0..4 {
acc[c] += q[c];
}
}
}
let k = 1.0 / (n * n) as f32;
unpremultiply([acc[0] * k, acc[1] * k, acc[2] * k, acc[3] * k])
}
/// Bilinear, wrapping sample, premultiplied.
fn sample_premul(&self, u: f64, v: f64) -> [f32; 4] {
let (u, v) = (u - 0.5, v - 0.5);
let (x0, y0) = (u.floor(), v.floor());
let (fx, fy) = ((u - x0) as f32, (v - y0) as f32);
@@ -177,13 +206,32 @@ impl Tile {
}
}
}
if acc[3] <= 0.0 {
return [0.0; 4];
}
[acc[0] / acc[3], acc[1] / acc[3], acc[2] / acc[3], acc[3].min(1.0)]
acc
}
}
/// Most taps per axis of the minification filter (the WGSL mirror uses the same cap).
const MAX_TAPS: usize = 8;
/// Taps per axis for a placement whose inverse scale is `inv_scale`: one pixel covers
/// `inv_scale` tile pixels, so that many taps (capped) box-filter it. 1 at 100 % and above.
fn pixel_taps(inv_scale: f64) -> usize {
let s = inv_scale as f32;
if s.is_finite() && s > 1.0 { (s.ceil() as usize).min(MAX_TAPS) } else { 1 }
}
/// Offset in document pixels of tap `i` of `n`, centred on the pixel.
fn tap_offset(i: usize, n: usize) -> f64 {
(i as f64 + 0.5) / n as f64 - 0.5
}
fn unpremultiply(acc: [f32; 4]) -> [f32; 4] {
if acc[3] <= 0.0 {
return [0.0; 4];
}
[acc[0] / acc[3], acc[1] / acc[3], acc[2] / acc[3], acc[3].min(1.0)]
}
/// Where a pattern sits: document point → tile coordinates.
#[derive(Clone, Copy, Debug)]
pub struct Placement {
@@ -234,8 +282,7 @@ pub fn render(tile: &Tile, place: &Placement, rect: Rect) -> Vec<[f32; 4]> {
let mut out = Vec::with_capacity(rect.width() as usize * rect.height() as usize);
for y in rect.y0..rect.y1 {
for x in rect.x0..rect.x1 {
let (u, v) = place.map(f64::from(x) + 0.5, f64::from(y) + 0.5);
out.push(tile.sample(u, v));
out.push(tile.sample_area(place, f64::from(x) + 0.5, f64::from(y) + 0.5));
}
}
out
@@ -299,6 +346,29 @@ mod tests {
assert!((u - -3.0).abs() < 1e-9 && (v - 1.0).abs() < 1e-9, "{u},{v}");
}
#[test]
fn minified_pattern_is_filtered_not_aliased() {
// 1-px dark / light stripes (mean 0.5), shown at 25 %: every pixel covers 4 tile pixels.
// The 0.125 px phase puts each pixel centre on a light tile pixel's centre, so a single
// bilinear tap would return pure white.
let mut s = Surface::new(PixelFormat::RGBA8);
s.fill_rect(Rect::new(0, 0, 8, 8), &[1.0, 1.0, 1.0, 1.0]);
for x in (0..8).step_by(2) {
s.fill_rect(Rect::new(x, 0, x + 1, 8), &[0.0, 0.0, 0.0, 1.0]);
}
let t = Tile::new(&Pattern::new("stripes", s, 8, 8)).unwrap();
let place = Placement::new(Rect::EMPTY, false, (0.125, 0.0), 0.25, 0.0);
let (u, _) = place.map(0.5, 0.5);
assert_eq!(t.sample(u, 0.5)[0], 1.0, "the single tap lands on a light stripe");
for p in render(&t, &place, Rect::new(0, 0, 8, 1)) {
assert!((p[0] - 0.5).abs() < 0.05, "{p:?}");
}
// 100 % stays exact.
let one = Placement::new(Rect::EMPTY, false, (0.0, 0.0), 1.0, 0.0);
let row = render(&t, &one, Rect::new(0, 0, 2, 1));
assert_eq!((row[0][0], row[1][0]), (0.0, 1.0));
}
#[test]
fn sampling_at_hostile_phases_does_not_overflow() {
let patterns = [checker()];
+26 -9
View File
@@ -1116,15 +1116,10 @@ fn wrap(i: i32, n: i32) -> i32 {
return ((i % n) + n) % n;
}
// compose::pattern::Tile::sample through Placement::map. p3 = (origin, cos, sin),
// p4 = (1 / scale, tile w, tile h); `pat_tex` holds premultiplied RGBA.
fn pattern_sample(d: vec2<i32>) -> vec4<f32> {
let dx = f32(d.x) + 0.5 - op.p3.x;
let dy = f32(d.y) + 0.5 - op.p3.y;
let c = op.p3.z;
let s = op.p3.w;
let u = (dx * c - dy * s) * op.p4.x - 0.5;
let v = (dx * s + dy * c) * op.p4.x - 0.5;
// One bilinear, wrapping, premultiplied tap at tile coordinates (u, v) (Tile::sample_premul).
fn pattern_tap(u0: f32, v0: f32) -> vec4<f32> {
let u = u0 - 0.5;
let v = v0 - 0.5;
let x0 = floor(u);
let y0 = floor(v);
let fx = u - x0;
@@ -1143,6 +1138,28 @@ fn pattern_sample(d: vec2<i32>) -> vec4<f32> {
acc += textureLoad(pat_tex, vec2(wrap(ix + i, tw), wrap(iy + j, th)), 0) * (wx[i] * wy[j]);
}
}
return acc;
}
// compose::pattern::Tile::sample_area through Placement::map. p3 = (origin, cos, sin),
// p4 = (1 / scale, tile w, tile h); `pat_tex` holds premultiplied RGBA. Below 100 % a pixel
// averages n x n taps over its footprint (n = ceil(1 / scale), at most 8), like the CPU.
fn pattern_sample(d: vec2<i32>) -> vec4<f32> {
var n = 1;
if (op.p4.x > 1.0) { n = i32(min(ceil(op.p4.x), 8.0)); }
let c = op.p3.z;
let s = op.p3.w;
var acc = vec4(0.0);
for (var j = 0; j < n; j++) {
for (var i = 0; i < n; i++) {
let ox = (f32(i) + 0.5) / f32(n) - 0.5;
let oy = (f32(j) + 0.5) / f32(n) - 0.5;
let dx = f32(d.x) + 0.5 + ox - op.p3.x;
let dy = f32(d.y) + 0.5 + oy - op.p3.y;
acc += pattern_tap((dx * c - dy * s) * op.p4.x, (dx * s + dy * c) * op.p4.x);
}
}
acc = acc / f32(n * n);
if (acc.a <= 0.0) { return vec4(0.0); }
return vec4(acc.rgb / acc.a, min(acc.a, 1.0));
}
+1 -1
View File
@@ -2658,7 +2658,7 @@ fn pattern_cache_tracks_source_format_and_cow_tiles() {
fn pattern_fill_layers() {
let Some(mut g) = gpu() else { return };
let pat = checker_pattern();
for (link, scale, angle, masked) in [(true, 1.0, 0.0, false), (false, 0.7, 25.0, true), (true, 1.6, -40.0, false)] {
for (link, scale, angle, masked) in [(true, 1.0, 0.0, false), (false, 0.7, 25.0, true), (true, 1.6, -40.0, false), (true, 0.3, 10.0, false)] {
let mut d = base_doc(70, 50);
d.patterns.push(pat.clone());
let fill = Fill::Pattern { name: pat.name.clone(), id: pat.id.clone(), scale, angle, link, phase: (3.0, -2.0) };