Swatch libraries: Patterns › Optical Effects adds wave, moiré and op-art patterns

Window › Swatch Libraries › Patterns and the Swatches panel's library
menu gain Optical Effects (`optical-effects`), a built-in pattern library
of eight black tiles drawn in code: Sine Bands, Ripple Lines,
Interference Rings, Offset Rings, Warped Checks, Pinched Stripes,
Chevron Pulse and Radiating Lenses. The names and designs are
VectorCraft's own. Each tile is 32 to 48 pt, one period of its design,
with fixed sample counts (at most 600 paths and 20,000 segments), so a
band that leaves one edge comes back at the same place on the opposite
edge, and rings and lenses that cross an edge are kept whole for the
tiling to draw on the next tile. Clicking a swatch adds its pattern and
swatch once and applies it, as with Geometric Patterns
(`swatch.library.add`).

The menu label has a row in every catalog; docs/mcp.md, the parity doc
and ROADMAP name the library.
This commit is contained in:
dotCooCoo
2026-11-12 00:48:41 -08:00
parent eb608bb8b5
commit 2bb8cd0b92
33 changed files with 333 additions and 6 deletions
+254
View File
@@ -26,6 +26,7 @@ pub struct BuiltinPatternLibrary {
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 },
BuiltinPatternLibrary { id: "optical-effects", name: "Optical Effects", make: optical_effects },
];
/// The library category of the built-in pattern libraries.
@@ -256,6 +257,162 @@ fn grit_textures(d: &mut Document) {
});
}
/// `n` + 1 points of the curve `(t, f(t))` for `t` from `t0` to `t1`, evenly spaced.
fn curve(t0: f64, t1: f64, n: u32, f: impl Fn(f64) -> f64) -> Vec<(f64, f64)> {
(0..=n)
.map(|i| {
let t = t0 + (t1 - t0) * f64::from(i) / f64::from(n);
(t, f(t))
})
.collect()
}
/// The closed band between two curves drawn from the same side to the same side: `upper` forward,
/// then `lower` back.
fn band(upper: &[(f64, f64)], lower: &[(f64, f64)]) -> PathData {
let points: Vec<(f64, f64)> = upper.iter().chain(lower.iter().rev()).copied().collect();
polygon(&points)
}
/// 72 corners of the circle of radius `r` about (`cx`, `cy`), turning the other way with `reverse`.
fn circle(cx: f64, cy: f64, r: f64, reverse: bool) -> SubPath {
const CORNERS: u32 = 72;
let turn = if reverse { -std::f64::consts::TAU } else { std::f64::consts::TAU };
let points: Vec<Point> = (0..CORNERS)
.map(|i| {
let a = turn * f64::from(i) / f64::from(CORNERS);
Point::new(cx + r * a.cos(), cy + r * a.sin())
})
.collect();
SubPath::polyline(&points, true)
}
/// The ring from radius `inner` to `outer` about (`cx`, `cy`): the outer circle and the inner one
/// turning the other way, so the middle stays clear under either fill rule. An `inner` of 0 gives
/// a disk.
fn ring(cx: f64, cy: f64, inner: f64, outer: f64) -> PathData {
let mut subpaths = vec![circle(cx, cy, outer, false)];
if inner > 0.0 {
subpaths.push(circle(cx, cy, inner, true));
}
PathData::new(subpaths)
}
/// A lens `2 × l` long and `2 × h` high about (`cx`, `cy`), lying along x: two half sine waves that
/// meet at its tips, turning the other way with `reverse`.
fn lens(cx: f64, cy: f64, l: f64, h: f64, reverse: bool) -> SubPath {
const STEPS: u32 = 32;
let at = |i: u32, side: f64| {
let t = f64::from(i) / f64::from(STEPS);
Point::new(cx - l + 2.0 * l * t, cy + side * h * (std::f64::consts::PI * t).sin())
};
let mut points: Vec<Point> = (0..=STEPS).map(|i| at(i, -1.0)).chain((1..STEPS).rev().map(|i| at(i, 1.0))).collect();
if reverse {
points.reverse();
}
SubPath::polyline(&points, true)
}
/// Optical Effects: black waves, rings, warped checks, pinched stripes, chevrons and lenses on a
/// clear background. Each tile is one period of its design, so a band that leaves one edge comes
/// back at the same place on the opposite edge. Art that reaches past an edge is kept whole: the
/// tiling draws the part outside the tile on the next tile.
fn optical_effects(d: &mut Document) {
use std::f64::consts::TAU;
// Three bands 5 pt wide, 12 pt apart, each one sine wave across the tile.
let bands = (0..3u32)
.map(|k| {
let mid = move |x: f64| 6.0 + 12.0 * f64::from(k) + 3.0 * (TAU * x / 36.0).sin();
band(&curve(0.0, 36.0, 48, |x| mid(x) - 2.5), &curve(0.0, 36.0, 48, |x| mid(x) + 2.5))
})
.collect();
add(d, "Sine Bands", 36.0, 36.0, TileType::Grid, bands);
// Nine thin waves 4 pt apart, two waves across the tile, each a ninth of a wave on from the one
// above it, so the ninth leads into the first wave of the next tile down.
let lines = (0..9u32)
.map(|k| {
let (y, phase) = (2.0 + 4.0 * f64::from(k), TAU * f64::from(k) / 9.0);
let mid = move |x: f64| y + 2.5 * (TAU * x / 18.0 + phase).sin();
band(&curve(0.0, 36.0, 72, |x| mid(x) - 0.6), &curve(0.0, 36.0, 72, |x| mid(x) + 0.6))
})
.collect();
add(d, "Ripple Lines", 36.0, 36.0, TileType::Grid, lines);
// Thin rings 3 pt apart about the center and rings 3.3 pt apart about a point 4 pt to its
// right. The two sets cross, and the outer rings reach into the next tile across.
let rings = (1..=7u32)
.map(|k| (24.0, 3.0 * f64::from(k)))
.chain((1..=7u32).map(|k| (28.0, 3.3 * f64::from(k))))
.map(|(cx, r)| ring(cx, 24.0, r - 0.6, r + 0.6))
.collect();
add(d, "Interference Rings", 48.0, 48.0, TileType::Grid, rings);
// Rings 2 pt and 1 pt wide by turns about the tile's corner and about its center, the center's
// starting with the narrower one. The corner's rings are the same rings at all four corners.
let mut rings = vec![];
for (c, first) in [(0.0, 0), (20.0, 1)] {
let mut r = 0.0;
for k in 0..5 {
let width = if (k + first) % 2 == 0 { 2.0 } else { 1.0 };
rings.push(ring(c, c, r, r + width));
r += width + 1.2;
}
}
add(d, "Offset Rings", 40.0, 40.0, TileType::Grid, rings);
// An 8 × 8 checkerboard of 4 pt cells whose columns are bent by a sine wave down the tile and
// whose rows by one across it, each edge drawn through five points.
let warp = |x: f64, y: f64| (x + 2.0 * (TAU * y / 32.0).sin(), y + 2.0 * (TAU * x / 32.0).sin());
let cells = (0..8u32)
.flat_map(|row| (0..8u32).map(move |col| (col, row)))
.filter(|(col, row)| (col + row) % 2 == 0)
.map(|(col, row)| {
let (x, y) = (4.0 * f64::from(col), 4.0 * f64::from(row));
let corners = [(x, y), (x + 4.0, y), (x + 4.0, y + 4.0), (x, y + 4.0), (x, y)];
let points: Vec<(f64, f64)> = corners
.windows(2)
.flat_map(|e| {
let (a, b) = (e[0], e[1]);
(0..4u32).map(move |i| {
let t = f64::from(i) / 4.0;
warp(a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t)
})
})
.collect();
polygon(&points)
})
.collect();
add(d, "Warped Checks", 32.0, 32.0, TileType::Grid, cells);
// Six upright stripes 6 pt apart that widen from 0.8 pt to 5.6 pt and narrow again down the tile,
// each a sixth of a wave on from the one to its left.
let stripes = (0..6u32)
.map(|k| {
let (x, phase) = (3.0 + 6.0 * f64::from(k), TAU * f64::from(k) / 6.0);
let half = move |y: f64| 1.6 + 1.2 * (TAU * y / 36.0 + phase).cos();
let side = |s: f64| -> Vec<(f64, f64)> { curve(0.0, 36.0, 48, |y| x + s * half(y)).into_iter().map(|(y, at)| (at, y)).collect() };
band(&side(-1.0), &side(1.0))
})
.collect();
add(d, "Pinched Stripes", 36.0, 36.0, TileType::Grid, stripes);
// Four zigzag bands 8 pt apart, two zigzags across the tile; the bend and the width of the bands
// rise and fall from band to band.
let chevrons = [(0.0, 3.0, 4.0), (8.0, 4.5, 2.5), (16.0, 3.0, 1.0), (24.0, 1.5, 2.5)]
.into_iter()
.map(|(y, bend, width)| {
let upper: Vec<(f64, f64)> = (0..=4u32).map(|i| (8.0 * f64::from(i), if i % 2 == 1 { y + bend } else { y })).collect();
let lower: Vec<(f64, f64)> = upper.iter().map(|&(x, y)| (x, y + width)).collect();
band(&upper, &lower)
})
.collect();
add(d, "Chevron Pulse", 32.0, 32.0, TileType::Grid, chevrons);
// Five nested lens outlines 1.5 pt wide about the center, 14 pt to 78 pt long. The longer ones
// reach across the side edges and cross the next tiles' lenses.
let lenses = (0..5u32)
.map(|k| {
let (l, h) = (7.0 + 8.0 * f64::from(k), 4.0 + 4.0 * f64::from(k));
PathData::new(vec![lens(24.0, 24.0, l, h, false), lens(24.0, 24.0, l - 1.5, h - 1.5, true)])
})
.collect();
add(d, "Radiating Lenses", 48.0, 48.0, TileType::Grid, lenses);
}
#[cfg(test)]
mod tests {
use super::*;
@@ -326,4 +483,101 @@ mod tests {
}
assert!(crossing > 0, "no mark reaches past an edge");
}
const OPTICAL: [&str; 8] =
["Sine Bands", "Ripple Lines", "Interference Rings", "Offset Rings", "Warped Checks", "Pinched Stripes", "Chevron Pulse", "Radiating Lenses"];
fn optical_doc() -> Document {
let mut d = Document::new(100.0, 100.0);
optical_effects(&mut d);
d
}
/// The paths of pattern `p`'s art, each with its fill rule.
fn paths(p: &PatternDef) -> Vec<(&PathData, vectorcraft_geom::FillRule)> {
p.art
.iter()
.map(|n| match &n.kind {
vectorcraft_doc::NodeKind::Path { path, rule, .. } => (path, *rule),
_ => panic!("{}: art that is not a path", p.name),
})
.collect()
}
#[test]
fn optical_effects_is_a_pattern_library_of_eight_bounded_tiles() {
let b = PATTERN_LIBRARIES.iter().find(|b| b.id == "optical-effects").expect("Optical Effects is listed");
assert_eq!(b.name, "Optical Effects");
let names: Vec<String> = library_doc(b.id).unwrap().patterns.iter().map(|p| p.name.clone()).collect();
assert_eq!(names, OPTICAL);
// Made twice, the art is the same.
let (a, again) = (optical_doc(), optical_doc());
assert_eq!(a.patterns, again.patterns);
for p in &a.patterns {
let (w, h) = (p.width(), p.height());
assert!((32.0..=48.0).contains(&w) && w == h && p.tile.x0 == 0.0 && p.tile.y0 == 0.0, "{}", p.name);
assert_eq!(p.tile_type, TileType::Grid, "{}", p.name);
let paths = paths(p);
let segments: usize = paths.iter().flat_map(|(path, _)| &path.subpaths).map(SubPath::segment_count).sum();
assert!(!paths.is_empty() && paths.len() <= 600 && segments <= 20_000, "{}: {} paths, {segments} segments", p.name, paths.len());
for (path, _) in &paths {
let b = path.bounds().unwrap_or_else(|| panic!("{}: an empty path", p.name));
assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) && 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);
}
}
}
/// Just either side of a tile's right and bottom edges, the tiling paints the same points: a band
/// cut off at an edge, or one that comes back at another height or width, paints one side only.
/// No path is another moved by a whole tile, which the tiling would draw twice in one place.
#[test]
fn optical_tiles_join_without_a_seam() {
const SAMPLES: u32 = 400;
const STEP: f64 = 1e-4;
for p in &optical_doc().patterns {
let (w, h) = (p.width(), p.height());
let shapes: Vec<(BezPathFill, Rect)> =
paths(p).into_iter().map(|(path, rule)| ((path.to_bezpath(), rule), path.bounds().unwrap_or(Rect::ZERO))).collect();
let paints = |q: Point| {
let near = Rect::new(q.x - STEP, q.y - STEP, q.x + STEP, q.y + STEP);
p.offsets_covering(near).into_iter().any(|o| {
let at = q - o;
shapes.iter().any(|((bp, rule), b)| {
let inside = b.x0 <= at.x && at.x <= b.x1 && b.y0 <= at.y && at.y <= b.y1;
let turns = if inside { kurbo::Shape::winding(bp, at) } else { 0 };
match rule {
vectorcraft_geom::FillRule::EvenOdd => turns % 2 != 0,
vectorcraft_geom::FillRule::NonZero => turns != 0,
}
})
})
};
let (mut differ, mut painted) = (0, 0);
for i in 0..SAMPLES {
let t = (f64::from(i) + 0.37) / f64::from(SAMPLES);
let (right, bottom) = (Point::new(w, t * h), Point::new(t * w, h));
let (dx, dy) = (kurbo::Vec2::new(STEP, 0.0), kurbo::Vec2::new(0.0, STEP));
for (a, b) in [(right - dx, right + dx), (bottom - dy, bottom + dy)] {
let (pa, pb) = (paints(a), paints(b));
differ += usize::from(pa != pb);
painted += usize::from(pa);
}
}
assert!(differ <= 2, "{}: {differ} of {} points across the edges differ", p.name, 2 * SAMPLES);
assert!(painted > 0, "{}: nothing crosses an edge", p.name);
let bounds: Vec<Rect> = shapes.iter().map(|(_, b)| *b).collect();
let same = |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
};
for b in &bounds {
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| same(o, moved)), "{}: {b:?} is drawn twice by the tiling", p.name);
}
}
}
}
type BezPathFill = (vectorcraft_geom::BezPath, vectorcraft_geom::FillRule);
}