Swatch libraries: Patterns › Grit Textures adds seamless grain, speckle and stipple patterns

Window › Swatch Libraries › Patterns and the Swatches panel's library
menu gain a second built-in pattern library, Grit Textures, with eight
black textures on square grid tiles of 18 to 36 pt: Fine Grain, Coarse
Grit, Sand, Speckle, Stipple, Scratches, Sparse Flecks and Dense Flecks.
Each is drawn in code from a fixed seed, so it is the same every time.
Marks that reach past a tile edge are drawn on the next tile by the
tiling, so the tiles join without a seam.

Docs: docs/mcp.md, docs/target-app-parity.md, ROADMAP.
This commit is contained in:
dotCooCoo
2026-11-11 21:30:00 -08:00
parent fd6b8f6c97
commit 9fc4e3c5cf
33 changed files with 227 additions and 6 deletions
+160 -2
View File
@@ -23,8 +23,10 @@ pub struct BuiltinPatternLibrary {
}
/// The built-in pattern libraries (Window → Swatch Libraries → Patterns), in menu order.
pub const PATTERN_LIBRARIES: &[BuiltinPatternLibrary] =
&[BuiltinPatternLibrary { id: "geometric-patterns", name: "Geometric Patterns", make: geometric_patterns }];
pub const PATTERN_LIBRARIES: &[BuiltinPatternLibrary] = &[
BuiltinPatternLibrary { id: "geometric-patterns", name: "Geometric Patterns", make: geometric_patterns },
BuiltinPatternLibrary { id: "grit-textures", name: "Grit Textures", make: grit_textures },
];
/// The library category of the built-in pattern libraries.
pub const CATEGORY: &str = "patterns";
@@ -154,6 +156,106 @@ fn geometric_patterns(d: &mut Document) {
add(d, "Zigzag", 10.0, 8.0, TileType::Grid, vec![polygon(&[(0.0, 5.0), (5.0, 1.0), (10.0, 5.0), (10.0, 7.0), (5.0, 3.0), (0.0, 7.0)])]);
}
/// SplitMix64, a small generator with a fixed seed per pattern, so a grit pattern draws the same
/// marks every time it is made.
struct SplitMix64(u64);
impl SplitMix64 {
fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// A number from `lo` up to, but not including, `hi`.
fn range(&mut self, lo: f64, hi: f64) -> f64 {
lo + (self.next() >> 11) as f64 / (1u64 << 53) as f64 * (hi - lo)
}
}
/// A round dot of radius `r` at (`x`, `y`).
fn dot(x: f64, y: f64, r: f64) -> PathData {
shapes::ellipse(Rect::new(x - r, y - r, x + r, y + r))
}
/// An irregular fleck of four to seven corners about (`x`, `y`), at most `r` from it.
fn fleck(rng: &mut SplitMix64, x: f64, y: f64, r: f64) -> PathData {
use std::f64::consts::TAU;
let n = 4 + (rng.next() % 4) as usize;
let turn = rng.range(0.0, TAU);
let points: Vec<(f64, f64)> = (0..n)
.map(|k| {
let a = turn + TAU * (k as f64 + rng.range(-0.3, 0.3)) / n as f64;
let reach = r * rng.range(0.55, 1.0);
(x + reach * a.cos(), y + reach * a.sin())
})
.collect();
polygon(&points)
}
/// A thin straight scratch `len` long and `width` wide, centered on (`x`, `y`) at a random angle.
fn scratch(rng: &mut SplitMix64, x: f64, y: f64, len: f64, width: f64) -> PathData {
let (s, c) = rng.range(0.0, std::f64::consts::PI).sin_cos();
let (hx, hy, wx, wy) = (c * len / 2.0, s * len / 2.0, -s * width / 2.0, c * width / 2.0);
polygon(&[(x - hx - wx, y - hy - wy), (x + hx - wx, y + hy - wy), (x + hx + wx, y + hy + wy), (x - hx + wx, y - hy + wy)])
}
/// A grit pattern of `count` marks scattered over a square grid tile of side `size`, drawn from
/// `seed` (`mark` draws one at a point). A mark that reaches past an edge is kept whole: the tiling
/// draws the part outside the tile on the next tile, so the tiles join without a seam.
fn grit(d: &mut Document, name: &str, size: f64, seed: u64, count: usize, mark: impl Fn(&mut SplitMix64, f64, f64) -> PathData) {
let mut rng = SplitMix64(seed);
let marks = (0..count)
.map(|_| {
let (x, y) = (rng.range(0.0, size), rng.range(0.0, size));
mark(&mut rng, x, y)
})
.collect();
add(d, name, size, size, TileType::Grid, marks);
}
/// Grit Textures: seamless black grain, grit, sand, speckle, stipple, scratch and fleck textures on
/// a clear background, each drawn from its own fixed seed.
fn grit_textures(d: &mut Document) {
grit(d, "Fine Grain", 24.0, 1, 320, |rng, x, y| dot(x, y, rng.range(0.15, 0.35)));
grit(d, "Coarse Grit", 30.0, 2, 150, |rng, x, y| {
let r = rng.range(0.45, 1.1);
fleck(rng, x, y, r)
});
grit(d, "Sand", 24.0, 3, 280, |rng, x, y| {
let r = rng.range(0.2, 0.45);
fleck(rng, x, y, r)
});
grit(d, "Speckle", 30.0, 4, 90, |rng, x, y| dot(x, y, rng.range(0.3, 0.9)));
// Stipple: one dot in each cell of a 12 × 12 grid, moved a little off the cell's center.
let mut rng = SplitMix64(5);
let (size, cells) = (24.0, 12);
let step = size / f64::from(cells);
let dots = (0..cells)
.flat_map(|row| (0..cells).map(move |col| (col, row)))
.map(|(col, row)| {
let x = (f64::from(col) + 0.5) * step + rng.range(-0.3, 0.3) * step;
let y = (f64::from(row) + 0.5) * step + rng.range(-0.3, 0.3) * step;
dot(x, y, rng.range(0.3, 0.45))
})
.collect();
add(d, "Stipple", size, size, TileType::Grid, dots);
grit(d, "Scratches", 36.0, 6, 45, |rng, x, y| {
let (len, width) = (rng.range(2.0, 7.0), rng.range(0.15, 0.35));
scratch(rng, x, y, len, width)
});
grit(d, "Sparse Flecks", 36.0, 7, 32, |rng, x, y| {
let r = rng.range(0.35, 0.8);
fleck(rng, x, y, r)
});
grit(d, "Dense Flecks", 18.0, 8, 220, |rng, x, y| {
let r = rng.range(0.2, 0.5);
fleck(rng, x, y, r)
});
}
#[cfg(test)]
mod tests {
use super::*;
@@ -168,4 +270,60 @@ mod tests {
assert_eq!(existing_pattern(&d, &lib, "Dots").as_deref(), expected, "{other}");
}
}
fn grit_doc() -> Document {
let mut d = Document::new(100.0, 100.0);
grit_textures(&mut d);
d
}
#[test]
fn grit_textures_draw_the_same_marks_every_time() {
let (a, b) = (grit_doc(), grit_doc());
assert_eq!(a.patterns.len(), 8);
assert_eq!(a.patterns, b.patterns);
}
/// Every grit tile is 18 to 36 pt with at most 600 marks, each with an area, touching the tile
/// and near it. Marks reach past the edges, and the tiling draws a tile's art on the next tile
/// across each edge a mark crosses. No mark is another mark moved by a whole tile, which the tiling would draw
/// twice in one place.
#[test]
fn grit_tiles_join_without_a_seam() {
let mut crossing = 0;
for p in &grit_doc().patterns {
let (w, h) = (p.width(), p.height());
assert!((18.0..=36.0).contains(&w) && w == h && p.tile.x0 == 0.0 && p.tile.y0 == 0.0, "{}", p.name);
assert!(!p.art.is_empty() && p.art.len() <= 600, "{}: {} marks", p.name, p.art.len());
let bounds: Vec<Rect> = p
.art
.iter()
.map(|n| match &n.kind {
vectorcraft_doc::NodeKind::Path { path, .. } => path.bounds().unwrap_or_else(|| panic!("{}: an empty mark", p.name)),
_ => panic!("{}: a mark that is not a path", p.name),
})
.collect();
let near = |a: &Rect, b: Rect| {
(a.x0 - b.x0).abs() < 1e-9 && (a.y0 - b.y0).abs() < 1e-9 && (a.x1 - b.x1).abs() < 1e-9 && (a.y1 - b.y1).abs() < 1e-9
};
let band = p.tile.inflate(w / 4.0, h / 4.0);
for b in &bounds {
assert!(b.width() > 0.0 && b.height() > 0.0, "{}: {b:?}", p.name);
assert!(b.x1 > 0.0 && b.y1 > 0.0 && b.x0 < w && b.y0 < h, "{}: {b:?} does not touch the tile", p.name);
assert!(b.x0 >= band.x0 && b.y0 >= band.y0 && b.x1 <= band.x1 && b.y1 <= band.y1, "{}: {b:?} is too far out", p.name);
// The tile past each edge the mark crosses draws this tile's art.
let (dx, dy) = (f64::from(i8::from(b.x1 > w) - i8::from(b.x0 < 0.0)), f64::from(i8::from(b.y1 > h) - i8::from(b.y0 < 0.0)));
for (x, y) in [(dx, 0.0), (0.0, dy), (dx, dy)] {
let next = p.tile + kurbo::Vec2::new(x * w, y * h);
assert!(p.offsets_covering(next).contains(&kurbo::Vec2::ZERO), "{}: {b:?} is cut off at {next:?}", p.name);
}
crossing += usize::from(dx != 0.0 || dy != 0.0);
for (dx, dy) in [(w, 0.0), (-w, 0.0), (0.0, h), (0.0, -h), (w, h), (w, -h), (-w, h), (-w, -h)] {
let moved = *b + kurbo::Vec2::new(dx, dy);
assert!(!bounds.iter().any(|o| near(o, moved)), "{}: {b:?} is drawn twice by the tiling", p.name);
}
}
}
assert!(crossing > 0, "no mark reaches past an edge");
}
}