mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 11:55:23 +00:00
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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user