M6.2: shape path operators (round corners, offset, twist, zig zag, wiggle, merge paths, gradient stroke)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 14:26:28 -04:00
co-authored by Claude Opus 5.5
parent 808cd490cd
commit ba2fa1bddf
11 changed files with 2384 additions and 205 deletions
+622 -92
View File
@@ -1,17 +1,46 @@
//! Shape-layer path operations.
//! Shape-layer path operators (After Effects "Add ▸" path operations).
//!
//! Every operator maps a list of paths to a list of paths of the same length (one entry per shape
//! above the operator), except [`merge`], which combines them into one. Paths are in the space of
//! the shape group the operator lives in.
use kurbo::{BezPath, ParamCurve, ParamCurveArclen, PathSeg};
use std::f64::consts::PI;
use kurbo::{BezPath, ParamCurve, ParamCurveArclen, ParamCurveDeriv, PathEl, PathSeg, Point, Vec2};
use crate::fit::{end_tangent, start_tangent, unit};
use crate::{FillRule, Join};
const ACC: f64 = 1e-3;
fn segments(p: &BezPath) -> Vec<PathSeg> {
p.segments().collect()
// ------------------------------------------------------------------------------------------------
// Subpath helpers
/// One subpath as a list of segments.
#[derive(Clone, Debug)]
struct Sub {
segs: Vec<PathSeg>,
closed: bool,
}
fn append(out: &mut BezPath, seg: PathSeg, start: bool) {
if start {
out.move_to(seg.start());
}
fn degenerate(s: &PathSeg) -> bool {
let c = s.to_cubic();
let d = |a: Point, b: Point| a.distance(b) < 1e-9;
d(c.p0, c.p1) && d(c.p0, c.p2) && d(c.p0, c.p3)
}
fn subs(p: &BezPath) -> Vec<Sub> {
p.subpaths()
.filter_map(|els| {
let closed = matches!(els.last(), Some(PathEl::ClosePath));
let bp = BezPath::from_vec(els.to_vec());
let segs: Vec<PathSeg> = bp.segments().filter(|s| !degenerate(s)).collect();
(!segs.is_empty()).then_some(Sub { segs, closed })
})
.collect()
}
fn push_seg(out: &mut BezPath, seg: PathSeg) {
match seg {
PathSeg::Line(l) => out.line_to(l.p1),
PathSeg::Quad(q) => out.quad_to(q.p1, q.p2),
@@ -19,90 +48,128 @@ fn append(out: &mut BezPath, seg: PathSeg, start: bool) {
}
}
/// Portion of a path between arc-length fractions `a..b` (0..1, a ≤ b).
fn sub_path(segs: &[PathSeg], lens: &[f64], total: f64, a: f64, b: f64, out: &mut BezPath) {
let (sa, sb) = (a * total, b * total);
let mut acc = 0.0;
let mut started = false;
for (seg, &len) in segs.iter().zip(lens) {
let (s0, s1) = (acc, acc + len);
acc = s1;
if s1 <= sa || s0 >= sb || len <= 0.0 {
continue;
fn seg_start_tan(s: &PathSeg) -> Vec2 {
start_tangent(&s.to_cubic())
}
fn seg_end_tan(s: &PathSeg) -> Vec2 {
end_tangent(&s.to_cubic())
}
/// Unit tangent of a segment at parameter `t` (robust at retracted handles).
fn seg_tan_at(s: &PathSeg, t: f64) -> Vec2 {
let c = s.to_cubic();
if t <= 1e-9 {
return start_tangent(&c);
}
if t >= 1.0 - 1e-9 {
return end_tangent(&c);
}
unit(c.deriv().eval(t).to_vec2()).unwrap_or_else(|| start_tangent(&c))
}
fn perp(v: Vec2) -> Vec2 {
Vec2::new(-v.y, v.x)
}
fn last_point(p: &BezPath) -> Option<Point> {
p.elements().last().and_then(|e| e.end_point())
}
/// Reverse the direction of every subpath (the Path Direction toggle).
pub fn reverse(paths: &[BezPath]) -> Vec<BezPath> {
paths.iter().map(|p| p.reverse_subpaths()).collect()
}
/// Total arc length of a path.
pub fn length(p: &BezPath) -> f64 {
p.segments().map(|s| s.arclen(ACC)).sum()
}
/// Number of on-curve vertices of a path (closing duplicates excluded).
pub fn vertex_count(p: &BezPath) -> usize {
subs(p).iter().map(|s| s.segs.len() + usize::from(!s.closed)).sum()
}
// ------------------------------------------------------------------------------------------------
// Trim Paths
/// A path measured for trimming.
struct Measured {
segs: Vec<PathSeg>,
lens: Vec<f64>,
total: f64,
}
impl Measured {
fn new(p: &BezPath) -> Self {
let segs: Vec<PathSeg> = p.segments().filter(|s| !degenerate(s)).collect();
let lens: Vec<f64> = segs.iter().map(|s| s.arclen(ACC)).collect();
let total = lens.iter().sum();
Measured { segs, lens, total }
}
/// Append the portion between absolute lengths `sa..sb`, continuing the current subpath when
/// the piece starts where `out` ends (e.g. across the start point of a closed path).
fn append(&self, sa: f64, sb: f64, out: &mut BezPath) {
let mut acc = 0.0;
for (seg, &len) in self.segs.iter().zip(&self.lens) {
let (s0, s1) = (acc, acc + len);
acc = s1;
if s1 <= sa || s0 >= sb || len <= 0.0 {
continue;
}
let t0 = if sa > s0 { seg.inv_arclen(sa - s0, ACC) } else { 0.0 };
let t1 = if sb < s1 { seg.inv_arclen(sb - s0, ACC) } else { 1.0 };
if t1 <= t0 {
continue;
}
let piece = seg.subsegment(t0..t1);
if last_point(out).is_none_or(|q| q.distance(piece.start()) > 1e-6) {
out.move_to(piece.start());
}
push_seg(out, piece);
}
let t0 = if sa > s0 { seg.inv_arclen(sa - s0, ACC) } else { 0.0 };
let t1 = if sb < s1 { seg.inv_arclen(sb - s0, ACC) } else { 1.0 };
let piece = seg.subsegment(t0..t1);
append(out, piece, !started);
started = true;
}
}
/// Trim Paths on each path individually: `start`/`end` in percent, `offset` in degrees.
pub fn trim(paths: &[BezPath], start: f64, end: f64, offset_deg: f64) -> Vec<BezPath> {
/// Fractional intervals kept by a trim, or `None` when nothing is trimmed.
fn trim_intervals(start: f64, end: f64, offset_deg: f64) -> Option<Vec<(f64, f64)>> {
let (mut s, mut e) = (start / 100.0, end / 100.0);
if s > e {
std::mem::swap(&mut s, &mut e);
}
if e - s >= 1.0 - 1e-9 && offset_deg == 0.0 {
return paths.to_vec();
let (s, e) = (s.clamp(0.0, 1.0), e.clamp(0.0, 1.0));
if e - s >= 1.0 - 1e-9 {
return None;
}
if e - s <= 1e-9 {
return vec![];
return Some(vec![]);
}
let off = (offset_deg / 360.0).rem_euclid(1.0);
let (s, e) = (s + off, e + off);
paths
.iter()
.filter_map(|p| {
let segs = segments(p);
let lens: Vec<f64> = segs.iter().map(|s| s.arclen(ACC)).collect();
let total: f64 = lens.iter().sum();
if total <= 0.0 {
return None;
}
let mut out = BezPath::new();
if e <= 1.0 {
sub_path(&segs, &lens, total, s, e, &mut out);
} else if s >= 1.0 {
sub_path(&segs, &lens, total, s - 1.0, e - 1.0, &mut out);
} else {
sub_path(&segs, &lens, total, s, 1.0, &mut out);
sub_path(&segs, &lens, total, 0.0, e - 1.0, &mut out);
}
Some(out)
})
.collect()
Some(if e <= 1.0 {
vec![(s, e)]
} else if s >= 1.0 {
vec![(s - 1.0, e - 1.0)]
} else {
vec![(s, 1.0), (0.0, e - 1.0)]
})
}
/// Trim Paths "Simultaneously" across several paths: treated as one continuous length.
pub fn trim_simultaneous(paths: &[BezPath], start: f64, end: f64, offset_deg: f64) -> Vec<BezPath> {
// AE's "Simultaneously" trims each path by the same fractions.
trim(paths, start, end, offset_deg)
}
/// Pucker & Bloat: move vertices towards (negative) / away from (positive) the centre while
/// pulling control points the other way.
pub fn pucker_bloat(paths: &[BezPath], amount: f64) -> Vec<BezPath> {
let a = amount / 100.0;
/// Trim Paths, "Trim Multiple Shapes: Simultaneously": every path is trimmed by the same
/// fractions of its own length at the same time. `start`/`end` in percent, `offset` in degrees.
/// Paths trimmed away entirely come back empty (the list keeps its length).
pub fn trim(paths: &[BezPath], start: f64, end: f64, offset_deg: f64) -> Vec<BezPath> {
let Some(iv) = trim_intervals(start, end, offset_deg) else { return paths.to_vec() };
paths
.iter()
.map(|p| {
let Some(b) = crate::bounds(std::slice::from_ref(p)) else { return p.clone() };
let c = b.center();
let m = Measured::new(p);
let mut out = BezPath::new();
for el in p.elements() {
let mv = |q: kurbo::Point| q + (q - c) * a;
let mvc = |q: kurbo::Point| q - (q - c) * a;
match *el {
kurbo::PathEl::MoveTo(q) => out.move_to(mv(q)),
kurbo::PathEl::LineTo(q) => {
let prev = out.elements().last().and_then(|e| e.end_point()).unwrap_or(q);
out.curve_to(mvc(prev.lerp(q, 1.0 / 3.0)), mvc(prev.lerp(q, 2.0 / 3.0)), mv(q))
}
kurbo::PathEl::QuadTo(x, q) => out.quad_to(mvc(x), mv(q)),
kurbo::PathEl::CurveTo(x, y, q) => out.curve_to(mvc(x), mvc(y), mv(q)),
kurbo::PathEl::ClosePath => out.close_path(),
if m.total > 0.0 {
for &(a, b) in &iv {
m.append(a * m.total, b * m.total, &mut out);
}
}
out
@@ -110,29 +177,492 @@ pub fn pucker_bloat(paths: &[BezPath], amount: f64) -> Vec<BezPath> {
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use kurbo::Shape;
/// Trim Paths, "Trim Multiple Shapes: Individually": the paths are laid end to end in stacking
/// order and trimmed as one continuous length, so they draw on one after another.
pub fn trim_individually(paths: &[BezPath], start: f64, end: f64, offset_deg: f64) -> Vec<BezPath> {
let Some(iv) = trim_intervals(start, end, offset_deg) else { return paths.to_vec() };
let ms: Vec<Measured> = paths.iter().map(Measured::new).collect();
let total: f64 = ms.iter().map(|m| m.total).sum();
let mut base = 0.0;
ms.iter()
.map(|m| {
let mut out = BezPath::new();
for &(a, b) in &iv {
let (ga, gb) = (a * total - base, b * total - base);
let (la, lb) = (ga.max(0.0), gb.min(m.total));
if lb > la {
m.append(la, lb, &mut out);
}
}
base += m.total;
out
})
.collect()
}
fn len(p: &[BezPath]) -> f64 {
p.iter().map(|x| x.segments().map(|s| s.arclen(1e-4)).sum::<f64>()).sum()
// ------------------------------------------------------------------------------------------------
// Pucker & Bloat
/// Pucker & Bloat (`amount` in percent, −100..100). Positive (bloat) moves the vertices towards
/// the path's centre (the mean of its vertices) and pushes the tangent handles away from it, so
/// segments bulge outwards; negative (pucker) does the opposite, giving spiky shapes.
pub fn pucker_bloat(paths: &[BezPath], amount: f64) -> Vec<BezPath> {
let a = amount / 100.0;
if a == 0.0 {
return paths.to_vec();
}
paths
.iter()
.map(|p| {
let verts: Vec<Point> = p.elements().iter().filter_map(|e| if matches!(e, PathEl::ClosePath) { None } else { e.end_point() }).collect();
if verts.is_empty() {
return p.clone();
}
let sum = verts.iter().fold(Vec2::ZERO, |s, q| s + q.to_vec2());
let c = (sum / verts.len() as f64).to_point();
let mv = |q: Point| q + (c - q) * a;
let mvh = |q: Point| q + (q - c) * a;
let mut out = BezPath::new();
let mut prev = Point::ZERO;
let mut start = Point::ZERO;
for el in p.elements() {
match *el {
PathEl::MoveTo(q) => {
out.move_to(mv(q));
prev = q;
start = q;
}
PathEl::LineTo(q) => {
out.curve_to(mvh(prev), mvh(q), mv(q));
prev = q;
}
PathEl::QuadTo(x, q) => {
let c1 = prev + (x - prev) * (2.0 / 3.0);
let c2 = q + (x - q) * (2.0 / 3.0);
out.curve_to(mvh(c1), mvh(c2), mv(q));
prev = q;
}
PathEl::CurveTo(x, y, q) => {
out.curve_to(mvh(x), mvh(y), mv(q));
prev = q;
}
PathEl::ClosePath => {
// The implicit closing line becomes a curve too.
if prev.distance(start) > 1e-9 {
out.curve_to(mvh(prev), mvh(start), mv(start));
}
out.close_path();
prev = start;
}
}
}
out
})
.collect()
}
#[test]
fn trim_half_square() {
let sq = kurbo::Rect::new(0.0, 0.0, 100.0, 100.0).to_path(0.1);
let t = trim(std::slice::from_ref(&sq), 0.0, 50.0, 0.0);
assert!((len(&t) - 200.0).abs() < 0.5, "{}", len(&t));
let w = trim(&[sq], 75.0, 125.0, 0.0);
let _ = w;
let t2 = trim(&[kurbo::Rect::new(0.0, 0.0, 100.0, 100.0).to_path(0.1)], 0.0, 50.0, 270.0);
assert!((len(&t2) - 200.0).abs() < 0.5);
// ------------------------------------------------------------------------------------------------
// Round Corners
/// Round Corners: every corner vertex (a tangent discontinuity, e.g. between two lines) is
/// replaced by a circular arc that starts and ends `radius` along the adjacent segments (clamped
/// to half of each segment). Smooth vertices and the ends of open paths are kept.
pub fn round_corners(paths: &[BezPath], radius: f64) -> Vec<BezPath> {
if radius <= 0.0 || !radius.is_finite() {
return paths.to_vec();
}
paths
.iter()
.map(|p| {
let mut out = BezPath::new();
for sub in subs(p) {
round_sub(&sub, radius, &mut out);
}
out
})
.collect()
}
#[test]
fn trim_empty() {
let sq = kurbo::Rect::new(0.0, 0.0, 10.0, 10.0).to_path(0.1);
assert!(trim(&[sq], 30.0, 30.0, 0.0).is_empty());
fn round_sub(sub: &Sub, r: f64, out: &mut BezPath) {
let segs = &sub.segs;
let n = segs.len();
let lens: Vec<f64> = segs.iter().map(|s| s.arclen(ACC)).collect();
// cut[i]: distance cut away on both sides of vertex i (the start of segment i).
let mut cut = vec![0.0; n];
let mut phi = vec![0.0; n];
for i in 0..n {
if !sub.closed && i == 0 {
continue;
}
let prev = (i + n - 1) % n;
let (tin, tout) = (seg_end_tan(&segs[prev]), seg_start_tan(&segs[i]));
let turn = tin.cross(tout).atan2(tin.dot(tout)).abs();
if !(1e-3..=PI - 1e-3).contains(&turn) {
continue;
}
cut[i] = r.min(lens[prev] / 2.0).min(lens[i] / 2.0);
phi[i] = turn;
}
// Trimmed segments with the original tangents at their cut ends.
let trimmed: Vec<(PathSeg, Vec2, Vec2)> = (0..n)
.map(|j| {
let a = cut[j];
let b = if sub.closed || j + 1 < n { cut[(j + 1) % n] } else { 0.0 };
if a <= 0.0 && b <= 0.0 {
return (segs[j], seg_start_tan(&segs[j]), seg_end_tan(&segs[j]));
}
let t0 = if a > 0.0 { segs[j].inv_arclen(a, ACC) } else { 0.0 };
let t1 = if b > 0.0 { segs[j].inv_arclen((lens[j] - b).max(0.0), ACC) } else { 1.0 }.max(t0);
(segs[j].subsegment(t0..t1), seg_tan_at(&segs[j], t0), seg_tan_at(&segs[j], t1))
})
.collect();
let arc = |out: &mut BezPath, from: &(PathSeg, Vec2, Vec2), to: &(PathSeg, Vec2, Vec2), corner: Point, phi: f64| {
let (p, q) = (from.0.end(), to.0.start());
// Circular arc through the two tangent points: radius = d / tan(φ/2), handle = 4/3·tan(φ/4)·R.
let k = 4.0 / 3.0 * (phi / 4.0).tan() / (phi / 2.0).tan();
let (hp, hq) = (p.distance(corner) * k, q.distance(corner) * k);
out.curve_to(p + from.2 * hp, q - to.1 * hq, q);
};
let piece = |out: &mut BezPath, t: &(PathSeg, Vec2, Vec2)| {
if !degenerate(&t.0) {
push_seg(out, t.0);
}
};
out.move_to(trimmed[0].0.start());
piece(out, &trimmed[0]);
for j in 1..n {
if cut[j] > 0.0 {
arc(out, &trimmed[j - 1], &trimmed[j], segs[j].start(), phi[j]);
}
piece(out, &trimmed[j]);
}
if sub.closed {
if cut[0] > 0.0 {
arc(out, &trimmed[n - 1], &trimmed[0], segs[0].start(), phi[0]);
}
out.close_path();
}
}
// ------------------------------------------------------------------------------------------------
// Offset Paths
/// Offset Paths: grows (positive `amount`) or shrinks (negative) every path with the given line
/// join. `copies` copies are made, copy *i* (0-based) offset by `amount · (i + 1 + copy_offset)`,
/// all returned in the same compound path.
pub fn offset(paths: &[BezPath], amount: f64, join: Join, miter_limit: f64, copies: f64, copy_offset: f64) -> Vec<BezPath> {
let n = copies.round().clamp(1.0, 1000.0) as usize;
paths
.iter()
.map(|p| {
let mut out = BezPath::new();
for i in 0..n {
let d = amount * (i as f64 + 1.0 + copy_offset);
out.extend(crate::offset::offset_path(p, d, join, miter_limit).elements().iter().copied());
}
out
})
.collect()
}
// ------------------------------------------------------------------------------------------------
// Twist
/// Twist: rotates the paths about `center` by an angle that grows linearly with the distance
/// from the centre, from 0 at the centre to `angle_deg` at the farthest point, so the middle twists
/// most sharply. Segments are subdivided and their handles mapped through the exact derivative of
/// the warp, which keeps the result smooth.
pub fn twist(paths: &[BezPath], angle_deg: f64, center: [f64; 2]) -> Vec<BezPath> {
let c = Point::new(center[0], center[1]);
let mut rmax: f64 = 0.0;
for p in paths {
for el in p.elements() {
if let Some(q) = el.end_point() {
rmax = rmax.max(q.distance(c));
}
}
}
let a = angle_deg.to_radians();
if a == 0.0 || rmax <= 1e-9 || !a.is_finite() {
return paths.to_vec();
}
let k = a / rmax;
let rot = |v: Vec2, th: f64| {
let (s, co) = th.sin_cos();
Vec2::new(v.x * co - v.y * s, v.x * s + v.y * co)
};
let map = |p: Point| {
let v = p - c;
c + rot(v, k * v.hypot())
};
// Derivative of the warp at `p` applied to `dv`.
let jac = |p: Point, dv: Vec2| {
let v = p - c;
let r = v.hypot();
let mut w = dv;
if r > 1e-12 {
w += perp(v) * (k * v.dot(dv) / r);
}
rot(w, k * r)
};
paths
.iter()
.map(|p| {
let mut out = BezPath::new();
for sub in subs(p) {
out.move_to(map(sub.segs[0].start()));
for seg in &sub.segs {
let cb = seg.to_cubic();
let len = seg.arclen(ACC);
let m = ((a.abs() * len / rmax) / 0.15 + len / 50.0).ceil().clamp(1.0, 64.0) as usize;
for i in 0..m {
let q = cb.subsegment(i as f64 / m as f64..(i + 1) as f64 / m as f64);
let (p0, p3) = (map(q.p0), map(q.p3));
out.curve_to(p0 + jac(q.p0, q.p1 - q.p0), p3 + jac(q.p3, q.p2 - q.p3), p3);
}
}
if sub.closed {
out.close_path();
}
}
out
})
.collect()
}
// ------------------------------------------------------------------------------------------------
// Resampling (Zig Zag, Wiggle Paths)
#[derive(Clone, Copy, Debug)]
struct Sample {
p: Point,
tan: Vec2,
/// Length of the piece before / after this sample.
len_prev: f64,
len_next: f64,
}
/// Split every segment into `m` pieces of equal arc length. Original vertices get the average of
/// their incoming and outgoing tangents (so a corner's normal is its bisector).
fn resample(sub: &Sub, m: usize) -> Vec<Sample> {
let segs = &sub.segs;
let n = segs.len();
let lens: Vec<f64> = segs.iter().map(|s| s.arclen(ACC)).collect();
let mut out = Vec::with_capacity(n * m + 1);
for j in 0..n {
let piece = lens[j] / m as f64;
for k in 0..m {
if k == 0 {
let tout = seg_start_tan(&segs[j]);
let prev = if sub.closed || j > 0 { Some((j + n - 1) % n) } else { None };
let tan = match prev {
Some(pj) => unit(seg_end_tan(&segs[pj]) + tout).unwrap_or(tout),
None => tout,
};
let len_prev = prev.map_or(0.0, |pj| lens[pj] / m as f64);
out.push(Sample { p: segs[j].start(), tan, len_prev, len_next: piece });
} else {
let t = segs[j].inv_arclen(piece * k as f64, ACC);
out.push(Sample { p: segs[j].eval(t), tan: seg_tan_at(&segs[j], t), len_prev: piece, len_next: piece });
}
}
}
if !sub.closed {
let last = &segs[n - 1];
out.push(Sample { p: last.end(), tan: seg_end_tan(last), len_prev: lens[n - 1] / m as f64, len_next: 0.0 });
}
out
}
/// Emit displaced samples as corner (polyline) or smooth (tangent-aligned cubic) points.
/// `handle` is the handle length as a fraction of the adjacent piece length.
fn emit(samples: &[Sample], disp: &[Vec2], closed: bool, smooth: bool, handle: f64, out: &mut BezPath) {
let n = samples.len();
if n == 0 {
return;
}
let pt = |i: usize| samples[i].p + disp[i];
out.move_to(pt(0));
let count = if closed { n } else { n - 1 };
for i in 0..count {
let j = (i + 1) % n;
if smooth {
let (a, b) = (&samples[i], &samples[j]);
out.curve_to(pt(i) + a.tan * (a.len_next * handle), pt(j) - b.tan * (b.len_prev * handle), pt(j));
} else {
out.line_to(pt(j));
}
}
if closed {
out.close_path();
}
}
/// Zig Zag: each segment gets `ridges` ridges — it is split into `ridges + 1` equal pieces and the
/// points (original vertices included) are pushed alternately `size` to either side of the path.
/// `smooth` makes rounded waves instead of sharp zig zags. A closed path with `n` segments ends up
/// with `n · (ridges + 1)` vertices (open: one more).
pub fn zigzag(paths: &[BezPath], size: f64, ridges: f64, smooth: bool) -> Vec<BezPath> {
let m = ridges.round().clamp(0.0, 1000.0) as usize + 1;
paths
.iter()
.map(|p| {
let mut out = BezPath::new();
for sub in subs(p) {
let s = resample(&sub, m);
let disp: Vec<Vec2> = s.iter().enumerate().map(|(i, x)| perp(x.tan) * if i % 2 == 0 { size } else { -size }).collect();
emit(&s, &disp, sub.closed, smooth, 0.36, &mut out);
}
out
})
.collect()
}
/// Wiggle Paths parameters.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct WiggleParams {
/// Maximum displacement (px).
pub size: f64,
/// Subdivisions per segment.
pub detail: f64,
/// Smooth (true) or corner points.
pub smooth: bool,
/// Wiggles per second.
pub speed: f64,
/// 0..100 %: how much neighbouring points move together.
pub correlation: f64,
/// Temporal phase in degrees (360° = one wiggle period).
pub phase_deg: f64,
pub seed: f64,
}
impl Default for WiggleParams {
fn default() -> Self {
WiggleParams { size: 10.0, detail: 10.0, smooth: true, speed: 2.0, correlation: 50.0, phase_deg: 0.0, seed: 0.0 }
}
}
fn splitmix(mut x: u64) -> u64 {
x = x.wrapping_add(0x9e37_79b9_7f4a_7c15);
let mut z = x;
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
z ^ (z >> 31)
}
/// Deterministic hash → [−1, 1].
fn hash(seed: u64, idx: u64, t: i64) -> f64 {
let h = splitmix(splitmix(splitmix(seed) ^ idx) ^ t as u64);
(h >> 11) as f64 / (1u64 << 53) as f64 * 2.0 - 1.0
}
/// Smooth 1D value noise in [−1, 1] (cubic Hermite through hashed lattice values).
fn noise(seed: u64, idx: u64, t: f64) -> f64 {
let i = t.floor();
let f = t - i;
let i = i as i64;
let p = |k: i64| hash(seed, idx, i + k);
// Catmull-Rom through four lattice values keeps the motion C1-continuous.
let (p0, p1, p2, p3) = (p(-1), p(0), p(1), p(2));
let v = 0.5 * (2.0 * p1 + (-p0 + p2) * f + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * f * f + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * f * f * f);
v.clamp(-1.0, 1.0)
}
/// Wiggle Paths: subdivides the paths (`detail` points per segment) and displaces every point by
/// smooth random noise of up to `size` pixels that evolves over `time_s` seconds at `speed`
/// wiggles per second. Deterministic for a given seed and time.
pub fn wiggle(paths: &[BezPath], w: &WiggleParams, time_s: f64) -> Vec<BezPath> {
if w.size == 0.0 {
return paths.to_vec();
}
let m = w.detail.round().clamp(0.0, 1000.0) as usize + 1;
let seed = w.seed.round() as i64 as u64;
let t = time_s * w.speed + w.phase_deg / 360.0;
let corr = (w.correlation / 100.0).clamp(0.0, 1.0);
let shared = Vec2::new(noise(seed, u64::MAX, t), noise(seed, u64::MAX - 1, t));
let mut idx: u64 = 0;
paths
.iter()
.map(|p| {
let mut out = BezPath::new();
for sub in subs(p) {
let s = resample(&sub, m);
let disp: Vec<Vec2> = s
.iter()
.map(|_| {
let own = Vec2::new(noise(seed, idx * 2, t + idx as f64 * 0.37), noise(seed, idx * 2 + 1, t + idx as f64 * 0.61));
idx += 1;
(shared * corr + own * (1.0 - corr)) * w.size
})
.collect();
emit(&s, &disp, sub.closed, w.smooth, 1.0 / 3.0, &mut out);
}
out
})
.collect()
}
// ------------------------------------------------------------------------------------------------
// Merge Paths
/// Merge Paths modes.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum MergeMode {
/// Combine into one compound path (no boolean).
#[default]
Merge,
/// Union.
Add,
/// The first path minus all the others.
Subtract,
/// Area covered by every path.
Intersect,
/// Area covered by an odd number of paths.
Exclude,
}
impl MergeMode {
pub fn from_index(i: u32) -> MergeMode {
match i {
1 => MergeMode::Add,
2 => MergeMode::Subtract,
3 => MergeMode::Intersect,
4 => MergeMode::Exclude,
_ => MergeMode::Merge,
}
}
}
/// Merge Paths: combine the paths (in stacking order, each filled non-zero) into one path.
pub fn merge(paths: &[BezPath], mode: MergeMode) -> BezPath {
if mode == MergeMode::Merge {
let mut out = BezPath::new();
for p in paths {
out.extend(p.elements().iter().copied());
}
return out;
}
let inputs: Vec<(BezPath, FillRule)> = paths.iter().filter(|p| !p.elements().is_empty()).map(|p| (p.clone(), FillRule::NonZero)).collect();
if inputs.is_empty() {
return BezPath::new();
}
match mode {
MergeMode::Add => crate::boolean::boolean_n(&inputs, |m| m.iter().any(|&b| b)),
MergeMode::Subtract => crate::boolean::boolean_n(&inputs, |m| m[0] && !m[1..].iter().any(|&b| b)),
MergeMode::Intersect => crate::boolean::boolean_n(&inputs, |m| m.iter().all(|&b| b)),
MergeMode::Exclude => crate::boolean::boolean_n(&inputs, |m| m.iter().filter(|&&b| b).count() % 2 == 1),
MergeMode::Merge => unreachable!(),
}
}
/// Signed area helper used by tests and callers: unsigned non-zero filled area of a path list.
pub fn area(paths: &[BezPath]) -> f64 {
let mut all = BezPath::new();
for p in paths {
all.extend(p.elements().iter().copied());
}
crate::boolean::filled_area(&all, FillRule::NonZero)
}
#[cfg(test)]
mod tests;