670 lines
24 KiB
Rust
670 lines
24 KiB
Rust
//! 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 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;
|
||
|
||
// ------------------------------------------------------------------------------------------------
|
||
// Subpath helpers
|
||
|
||
/// One subpath as a list of segments.
|
||
#[derive(Clone, Debug)]
|
||
struct Sub {
|
||
segs: Vec<PathSeg>,
|
||
closed: bool,
|
||
}
|
||
|
||
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),
|
||
PathSeg::Cubic(c) => out.curve_to(c.p1, c.p2, c.p3),
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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);
|
||
}
|
||
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 Some(vec![]);
|
||
}
|
||
let off = (offset_deg / 360.0).rem_euclid(1.0);
|
||
let (s, e) = (s + off, e + off);
|
||
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, "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 m = Measured::new(p);
|
||
let mut out = BezPath::new();
|
||
if m.total > 0.0 {
|
||
for &(a, b) in &iv {
|
||
m.append(a * m.total, b * m.total, &mut out);
|
||
}
|
||
}
|
||
out
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// 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()
|
||
}
|
||
|
||
// ------------------------------------------------------------------------------------------------
|
||
// 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()
|
||
}
|
||
|
||
// ------------------------------------------------------------------------------------------------
|
||
// 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()
|
||
}
|
||
|
||
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),
|
||
// Handled above (no boolean operation).
|
||
MergeMode::Merge => crate::boolean::boolean_n(&inputs, |m| m.iter().any(|&b| b)),
|
||
}
|
||
}
|
||
|
||
/// 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;
|