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
parent 808cd490cd
commit ba2fa1bddf
11 changed files with 2384 additions and 205 deletions

23
Cargo.lock generated
View File

@ -13,6 +13,9 @@ name = "arrayvec"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
dependencies = [
"serde",
]
[[package]]
name = "autocfg"
@ -140,6 +143,7 @@ dependencies = [
"effectcraft-keyframe",
"effectcraft-raster",
"kurbo",
"linesweeper",
"tiny-skia",
]
@ -309,6 +313,19 @@ version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "linesweeper"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c19728333c060c6569a53c9a0e56c4be0df52cb4e6e07a8fbe16084cecce769"
dependencies = [
"arrayvec",
"kurbo",
"polycool",
"rustc-hash",
"smallvec",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@ -490,6 +507,12 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rustc-hash"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rustix"
version = "1.1.5"

View File

@ -15,3 +15,4 @@ effectcraft-keyframe = { workspace = true }
effectcraft-raster = { workspace = true }
kurbo = { workspace = true }
tiny-skia = { workspace = true }
linesweeper = "0.4"

385
crates/path/src/boolean.rs Normal file
View File

@ -0,0 +1,385 @@
//! Curve-preserving boolean operations on filled [`BezPath`]s, built on `linesweeper`'s robust
//! sweep-line topology (MIT OR Apache-2.0).
//!
//! Adapted from DrawCraft's `vectorcraft-pathops` (`crates/pathops/src/boolean.rs`; our own code,
//! MIT OR Apache-2.0), reworked to take and return kurbo paths directly.
//!
//! Inputs are closed implicitly (a filled open path is painted as if closed). The sweep splits
//! curves at intersections and y-extrema; [`tidy_segments`] re-joins pieces that came from one
//! input curve (exactly) or one smooth run (by least-squares refit), so results carry roughly as
//! few vertices as the inputs. Output contours are simple and consistently oriented (holes wind
//! opposite to their outer contour), so they fill the same under either fill rule.
use kurbo::{BezPath, CubicBez, PathEl, PathSeg, Point, Shape as _};
use linesweeper::topology::{Contours, Topology, WindingNumber};
use crate::FillRule;
use crate::fit::{end_tangent, fit_single, is_straight, sample, start_tangent};
/// Refit tolerance (pixels) used to merge split curve pieces back together.
pub const DEFAULT_PRECISION: f64 = 0.01;
pub(crate) fn inside(rule: FillRule, w: i32) -> bool {
match rule {
FillRule::NonZero => w != 0,
FillRule::EvenOdd => w % 2 != 0,
}
}
/// Winding numbers for an arbitrary number of input paths (tag = input index).
#[derive(Clone, Debug, Default)]
pub(crate) struct Multi(Vec<i32>);
impl Multi {
fn get(&self, i: usize) -> i32 {
self.0.get(i).copied().unwrap_or(0)
}
}
impl PartialEq for Multi {
fn eq(&self, other: &Self) -> bool {
let n = self.0.len().max(other.0.len());
(0..n).all(|i| self.get(i) == other.get(i))
}
}
impl Eq for Multi {}
impl std::ops::Add for Multi {
type Output = Multi;
fn add(mut self, rhs: Self) -> Multi {
self += rhs;
self
}
}
impl std::ops::AddAssign for Multi {
fn add_assign(&mut self, rhs: Self) {
if self.0.len() < rhs.0.len() {
self.0.resize(rhs.0.len(), 0);
}
for (a, b) in self.0.iter_mut().zip(rhs.0) {
*a += b;
}
}
}
impl WindingNumber for Multi {
type Tag = usize;
fn single(tag: usize, positive: bool) -> Self {
let mut v = vec![0; tag + 1];
v[tag] = if positive { 1 } else { -1 };
Multi(v)
}
fn of_tag(&self, tag: usize) -> Self {
let mut v = vec![0; tag + 1];
v[tag] = self.get(tag);
Multi(v)
}
}
/// Close every subpath with at least two points; drop single points and non-finite input.
pub fn close_subpaths(p: &BezPath) -> BezPath {
let mut out = BezPath::new();
for sp in p.subpaths() {
let drawing = sp.iter().filter(|e| matches!(e, PathEl::LineTo(_) | PathEl::QuadTo(..) | PathEl::CurveTo(..))).count();
if drawing == 0 {
continue;
}
for el in sp {
if !matches!(el, PathEl::ClosePath) {
out.push(*el);
}
}
out.close_path();
}
out
}
fn finite(p: &BezPath) -> bool {
p.elements().iter().all(|el| match *el {
PathEl::MoveTo(a) | PathEl::LineTo(a) => a.is_finite(),
PathEl::QuadTo(a, b) => a.is_finite() && b.is_finite(),
PathEl::CurveTo(a, b, c) => a.is_finite() && b.is_finite() && c.is_finite(),
PathEl::ClosePath => true,
})
}
/// Sweep tolerance for a set of paths (scaled with the coordinate magnitude).
fn eps_for<'a>(paths: impl IntoIterator<Item = &'a BezPath>) -> f64 {
let mut m: f64 = 0.0;
for p in paths {
let b = p.bounding_box();
m = m.max(b.x0.abs()).max(b.x1.abs()).max(b.y0.abs()).max(b.y1.abs());
}
(m * f64::EPSILON * 64.0).max(1e-6)
}
/// Output cleanup settings: refit `precision`, anchor positions that must survive (the inputs' own
/// vertices — only joints the sweep introduced are merged away) and the input curves, used to
/// rebuild split pieces exactly.
#[derive(Clone, Debug, Default)]
pub(crate) struct Tidy {
pub(crate) precision: f64,
keep: Vec<Point>,
curves: Vec<(kurbo::Rect, CubicBez)>,
}
impl Tidy {
/// Merge any smooth joint.
pub(crate) fn free(precision: f64) -> Self {
Self { precision, keep: Vec::new(), curves: Vec::new() }
}
/// Keep every on-curve point of `paths`.
pub(crate) fn keeping<'a>(precision: f64, paths: impl IntoIterator<Item = &'a BezPath>) -> Self {
let mut keep = Vec::new();
let mut curves = Vec::new();
for p in paths {
for s in p.segments() {
let seg = to_seg(s);
keep.push(seg.c.p0);
if !seg.line {
curves.push((seg.c.bounding_box().inflate(1e-6, 1e-6), seg.c));
}
}
}
keep.sort_by(|a, b| a.x.total_cmp(&b.x));
Self { precision, keep, curves }
}
fn is_kept(&self, p: Point) -> bool {
let tol = 1e-6 * (1.0 + p.x.abs().max(p.y.abs()));
let lo = self.keep.partition_point(|q| q.x < p.x - tol);
self.keep[lo..].iter().take_while(|q| q.x <= p.x + tol).any(|q| (q.y - p.y).abs() <= tol)
}
}
/// An arrangement (planar subdivision) of N filled paths.
pub(crate) struct Arrangement {
top: Topology<Multi>,
rules: Vec<FillRule>,
pub(crate) tidy: Tidy,
}
impl Arrangement {
pub(crate) fn new(paths: &[(BezPath, FillRule)]) -> Option<Self> {
let closed: Vec<BezPath> = paths.iter().map(|(p, _)| close_subpaths(p)).collect();
if !closed.iter().all(finite) {
return None;
}
let eps = eps_for(&closed);
let top = Topology::<Multi>::from_paths(closed.iter().enumerate().map(|(i, p)| (p, i)), eps).ok()?;
let tidy = Tidy::keeping(DEFAULT_PRECISION, &closed);
Some(Self { top, rules: paths.iter().map(|p| p.1).collect(), tidy })
}
fn mask(&self, w: &Multi) -> Vec<bool> {
self.rules.iter().enumerate().map(|(i, &r)| inside(r, w.get(i))).collect()
}
pub(crate) fn contours(&self, pred: impl Fn(&[bool]) -> bool) -> Contours {
self.top.contours(|w| pred(&self.mask(w)))
}
}
/// Contours of a single path under its fill rule (self-overlaps resolved).
pub(crate) fn normalize_contours(p: &BezPath, rule: FillRule) -> Option<Contours> {
let bp = close_subpaths(p);
if !finite(&bp) {
return None;
}
let top = Topology::<i32>::from_path(&bp, eps_for([&bp])).ok()?;
Some(top.contours(|w| inside(rule, *w)))
}
/// Contours whose mean width (2·area / perimeter) is below `precision` are numerical debris.
fn is_sliver(bp: &BezPath, precision: f64) -> bool {
let a = bp.area().abs();
let per = bp.perimeter(1e-6);
per <= 0.0 || 2.0 * a / per < precision
}
/// Convert selected contours into one tidy compound path.
pub(crate) fn contours_to_path<'a>(contours: impl IntoIterator<Item = &'a BezPath>, tidy: &Tidy) -> BezPath {
let mut out = BezPath::new();
for c in contours {
if is_sliver(c, tidy.precision) {
continue;
}
let segs: Vec<Seg> = c.segments().map(to_seg).collect();
let segs = tidy_segments(segs, tidy);
if segs.len() < 2 && segs.iter().all(|s| s.line) {
continue;
}
out.move_to(segs[0].c.p0);
for s in &segs {
if s.line {
out.line_to(s.c.p3);
} else {
out.curve_to(s.c.p1, s.c.p2, s.c.p3);
}
}
out.close_path();
}
out
}
pub(crate) fn all_contours(c: &Contours, tidy: &Tidy) -> BezPath {
contours_to_path(c.contours().map(|k| &k.path), tidy)
}
/// One segment of a path: a cubic plus whether it is a straight line.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Seg {
pub(crate) c: CubicBez,
pub(crate) line: bool,
}
impl Seg {
fn line(a: Point, b: Point) -> Self {
Self { c: CubicBez::new(a, a, b, b), line: true }
}
}
pub(crate) fn to_seg(s: PathSeg) -> Seg {
match s {
PathSeg::Line(l) => Seg::line(l.p0, l.p1),
PathSeg::Quad(q) => {
let c = q.raise();
Seg { c, line: is_straight(&c, 1e-9) }
}
PathSeg::Cubic(c) => Seg { c, line: is_straight(&c, 1e-9 * (1.0 + c.p0.distance(c.p3))) },
}
}
/// Merge segments of a closed loop that were split from one curve (or collinear lines) back
/// together, dropping degenerate pieces.
fn tidy_segments(segs: Vec<Seg>, tidy: &Tidy) -> Vec<Seg> {
let scale = segs.iter().map(|s| s.c.p0.distance(s.c.p3)).fold(0.0, f64::max);
let tiny = (scale * 1e-9).max(1e-9);
let mut segs: Vec<Seg> = segs
.into_iter()
.filter(|s| {
let r = kurbo::Rect::from_points(s.c.p0, s.c.p3).union_pt(s.c.p1).union_pt(s.c.p2);
r.width() + r.height() > tiny
})
.collect();
let n = segs.len();
if n < 2 {
return segs;
}
// Joint i sits at the start of segment i.
let mergeable = |a: &Seg, b: &Seg| -> bool {
if a.line != b.line || tidy.is_kept(b.c.p0) {
return false;
}
let ta = end_tangent(&a.c);
let tb = start_tangent(&b.c);
ta.dot(tb) > 0.9998 && ta.cross(tb).abs() < 0.02
};
// Rotate so we start at a real corner (if there is one).
if let Some(k) = (0..n).find(|&i| !mergeable(&segs[(i + n - 1) % n], &segs[i])) {
segs.rotate_left(k);
}
let mut out: Vec<Seg> = Vec::with_capacity(n);
let mut i = 0;
while i < n {
let mut cur = segs[i];
let mut j = i + 1;
while j < n && mergeable(&segs[j - 1], &segs[j]) {
match try_merge(&segs[i..=j], tidy) {
Some(m) => {
cur = m;
j += 1;
}
None => break,
}
}
out.push(cur);
i = j;
}
out
}
fn try_merge(run: &[Seg], tidy: &Tidy) -> Option<Seg> {
let precision = tidy.precision;
let first = run[0].c;
let last = run[run.len() - 1].c;
if run[0].line {
let chord = last.p3 - first.p0;
let len = chord.hypot();
if len < 1e-12 {
return None;
}
let ok = run.iter().all(|s| ((s.c.p3 - first.p0).cross(chord) / len).abs() <= precision * 0.5);
return ok.then(|| Seg::line(first.p0, last.p3));
}
if let Some(c) = exact_merge(run, tidy) {
return Some(Seg { c, line: false });
}
let mut pts = Vec::with_capacity(run.len() * 12 + 1);
for (k, s) in run.iter().enumerate() {
sample(&s.c, 12, &mut pts, k == 0);
}
let (c, err, _) = fit_single(&pts, start_tangent(&first), end_tangent(&last));
if err > precision {
return None;
}
Some(Seg { c, line: false })
}
/// If every piece of `run` lies on one input cubic, return the exact sub-curve spanning the run.
fn exact_merge(run: &[Seg], tidy: &Tidy) -> Option<CubicBez> {
use kurbo::{ParamCurve, ParamCurveNearest};
let a = run[0].c.p0;
let b = run[run.len() - 1].c.p3;
let scale = 1.0 + a.x.abs().max(a.y.abs()).max(b.x.abs()).max(b.y.abs());
let tol = 1e-6 * scale;
for (r, c) in &tidy.curves {
if !r.contains(a) || !r.contains(b) {
continue;
}
let na = c.nearest(a, 1e-12);
let nb = c.nearest(b, 1e-12);
if na.distance_sq.sqrt() > tol || nb.distance_sq.sqrt() > tol {
continue;
}
let on_curve = run.iter().all(|s| c.nearest(s.c.eval(0.5), 1e-12).distance_sq.sqrt() <= tol * 10.0);
if !on_curve || (na.t - nb.t).abs() < 1e-12 {
continue;
}
let sub = if na.t < nb.t {
c.subsegment(na.t..nb.t)
} else {
let r = c.subsegment(nb.t..na.t);
CubicBez::new(r.p3, r.p2, r.p1, r.p0)
};
return Some(CubicBez::new(a, sub.p1, sub.p2, b));
}
None
}
/// N-ary boolean: the region where `pred(inside_flags)` holds, `inside_flags[i]` telling whether a
/// point is inside `paths[i]` under its fill rule. Returns one compound path.
pub fn boolean_n(paths: &[(BezPath, FillRule)], pred: impl Fn(&[bool]) -> bool) -> BezPath {
match Arrangement::new(paths) {
Some(arr) => all_contours(&arr.contours(pred), &arr.tidy),
None => BezPath::new(),
}
}
/// Resolve self-intersections / overlaps of one filled path into simple, consistently oriented
/// contours.
pub fn normalize(path: &BezPath, rule: FillRule) -> BezPath {
match normalize_contours(path, rule) {
Some(c) => all_contours(&c, &Tidy::keeping(DEFAULT_PRECISION, [path])),
None => BezPath::new(),
}
}
/// Unsigned filled area of a path under a fill rule (normalised first, so it is exact for
/// self-overlapping input).
pub fn filled_area(path: &BezPath, rule: FillRule) -> f64 {
match normalize_contours(path, rule) {
Some(c) => c.contours().map(|ct| ct.path.area()).sum::<f64>().abs(),
None => 0.0,
}
}

154
crates/path/src/fit.rs Normal file
View File

@ -0,0 +1,154 @@
//! Adapted from DrawCraft's `vectorcraft-pathops` (`crates/pathops/src/fit.rs`; our own code,
//! MIT OR Apache-2.0).
//!
//! Least-squares cubic Bézier fitting of point sequences (the classic "fit a cubic with fixed end
//! tangents, then reparameterise with Newton steps, then split at the worst point" approach from
//! Graphics Gems, written from the published description).
use kurbo::{CubicBez, ParamCurve, ParamCurveDeriv, Point, Vec2};
/// Maximum Newton reparameterisation rounds per fit.
const MAX_REPARAM: usize = 6;
/// Unit vector or `None` if (nearly) zero.
pub(crate) fn unit(v: Vec2) -> Option<Vec2> {
let l = v.hypot();
if l > 1e-12 && l.is_finite() { Some(v / l) } else { None }
}
/// Direction the curve leaves `p0` (robust to retracted handles).
pub(crate) fn start_tangent(c: &CubicBez) -> Vec2 {
unit(c.p1 - c.p0).or_else(|| unit(c.p2 - c.p0)).or_else(|| unit(c.p3 - c.p0)).unwrap_or(Vec2::new(1.0, 0.0))
}
/// Direction the curve arrives at `p3`, i.e. pointing *forward* along the travel direction.
pub(crate) fn end_tangent(c: &CubicBez) -> Vec2 {
unit(c.p3 - c.p2).or_else(|| unit(c.p3 - c.p1)).or_else(|| unit(c.p3 - c.p0)).unwrap_or(Vec2::new(1.0, 0.0))
}
/// Best single cubic for `pts` with the given end tangents. Returns (curve, max error, index of
/// the worst point).
pub(crate) fn fit_single(pts: &[Point], t0: Vec2, t1: Vec2) -> (CubicBez, f64, usize) {
let mut u = chord_params(pts);
let mut best = generate(pts, &u, t0, t1);
let (mut best_err, mut best_split) = max_error(pts, &best, &u);
for _ in 0..MAX_REPARAM {
for (i, ui) in u.iter_mut().enumerate() {
*ui = newton(&best, pts[i], *ui);
}
let c = generate(pts, &u, t0, t1);
let (e, s) = max_error(pts, &c, &u);
if e < best_err {
best = c;
best_err = e;
best_split = s;
} else {
break;
}
}
(best, best_err, best_split)
}
fn chord_params(pts: &[Point]) -> Vec<f64> {
let mut u = Vec::with_capacity(pts.len());
let mut acc = 0.0;
u.push(0.0);
for w in pts.windows(2) {
acc += w[0].distance(w[1]);
u.push(acc);
}
if acc > 0.0 {
for x in &mut u {
*x /= acc;
}
}
u
}
fn bern(t: f64) -> [f64; 4] {
let s = 1.0 - t;
[s * s * s, 3.0 * s * s * t, 3.0 * s * t * t, t * t * t]
}
/// Solve the 2x2 normal equations for the handle lengths along the fixed tangents.
fn generate(pts: &[Point], u: &[f64], t0: Vec2, t1: Vec2) -> CubicBez {
let p0 = pts[0];
let p3 = *pts.last().unwrap();
let dir_in = -t1; // handle at the end points backwards
let (mut c00, mut c01, mut c11, mut x0, mut x1) = (0.0, 0.0, 0.0, 0.0, 0.0);
for (p, &t) in pts.iter().zip(u) {
let b = bern(t);
let a0 = t0 * b[1];
let a1 = dir_in * b[2];
c00 += a0.dot(a0);
c01 += a0.dot(a1);
c11 += a1.dot(a1);
let tmp = *p - (p0.to_vec2() * (b[0] + b[1]) + p3.to_vec2() * (b[2] + b[3])).to_point();
x0 += a0.dot(tmp);
x1 += a1.dot(tmp);
}
let det = c00 * c11 - c01 * c01;
let seg = p0.distance(p3);
let eps = 1e-6 * seg;
let (mut al, mut ar) = if det.abs() > 1e-12 { ((x0 * c11 - x1 * c01) / det, (c00 * x1 - c01 * x0) / det) } else { (seg / 3.0, seg / 3.0) };
if !al.is_finite() || !ar.is_finite() || al < eps || ar < eps {
al = seg / 3.0;
ar = seg / 3.0;
}
// Guard against wild handles on nearly-degenerate data.
let cap = seg * 4.0 + 1e-9;
al = al.min(cap);
ar = ar.min(cap);
CubicBez::new(p0, p0 + t0 * al, p3 + dir_in * ar, p3)
}
fn max_error(pts: &[Point], c: &CubicBez, u: &[f64]) -> (f64, usize) {
let mut e = 0.0;
let mut idx = pts.len() / 2;
for (i, (p, &t)) in pts.iter().zip(u).enumerate() {
let d = c.eval(t).distance(*p);
if d > e {
e = d;
idx = i;
}
}
(e, idx)
}
fn newton(c: &CubicBez, p: Point, t: f64) -> f64 {
let d1 = c.deriv();
let d2 = d1.deriv();
let q = c.eval(t) - p;
let q1 = d1.eval(t).to_vec2();
let q2 = d2.eval(t).to_vec2();
let num = q.dot(q1);
let den = q1.dot(q1) + q.dot(q2);
if den.abs() < 1e-12 {
return t;
}
let nt = t - num / den;
if nt.is_finite() { nt.clamp(0.0, 1.0) } else { t }
}
/// Is the cubic (within `tol`) a straight line from p0 to p3 with handles inside the chord?
pub(crate) fn is_straight(c: &CubicBez, tol: f64) -> bool {
let chord = c.p3 - c.p0;
let len = chord.hypot();
if len < 1e-12 {
return c.p1.distance(c.p0) <= tol && c.p2.distance(c.p0) <= tol;
}
let dir = chord / len;
[c.p1, c.p2].iter().all(|&h| {
let v = h - c.p0;
let along = v.dot(dir);
v.cross(dir).abs() <= tol && along >= -tol && along <= len + tol
})
}
/// Sample a cubic at `n+1` evenly spaced parameters (including both ends).
pub(crate) fn sample(c: &CubicBez, n: usize, out: &mut Vec<Point>, include_start: bool) {
let start = if include_start { 0 } else { 1 };
for i in start..=n {
out.push(c.eval(i as f64 / n as f64));
}
}

View File

@ -2,8 +2,12 @@
//!
//! Geometry is kept in [`kurbo::BezPath`]; anti-aliased coverage comes from tiny-skia's scanline
//! rasteriser (BSD-3) and is converted to our `f32` [`Mask`]. Path operations (trim, round corners,
//! pucker & bloat, zig zag, twist, wiggle, offset, merge) live in [`ops`].
//! pucker & bloat, zig zag, twist, wiggle, offset, merge) live in [`ops`]; curve-preserving booleans
//! (for Merge Paths) in [`boolean`] and curve offsetting in [`offset`].
pub mod boolean;
mod fit;
pub mod offset;
pub mod ops;
use effectcraft_geom::Mat3;

138
crates/path/src/offset.rs Normal file
View File

@ -0,0 +1,138 @@
//! Offset Paths: grow or shrink the filled region of a path by a distance, with true curve
//! offsetting (kurbo's stroker offsets curves analytically and refits them) and robust overlap
//! removal (the boolean sweep).
//!
//! Adapted from DrawCraft's `vectorcraft-pathops` (`crates/pathops/src/offset.rs`; our own code,
//! MIT OR Apache-2.0).
use kurbo::{BezPath, PathEl, Stroke, StrokeOpts};
use linesweeper::topology::{ContourIdx, Contours};
use crate::boolean::{Arrangement, DEFAULT_PRECISION, Tidy, contours_to_path};
use crate::{FillRule, Join};
/// Flattening/fit tolerance handed to kurbo's stroker.
const STROKE_TOL: f64 = 1e-3;
fn kjoin(j: Join) -> kurbo::Join {
match j {
Join::Miter => kurbo::Join::Miter,
Join::Round => kurbo::Join::Round,
Join::Bevel => kurbo::Join::Bevel,
}
}
fn stroke_bez(bp: &BezPath, width: f64, join: Join, miter_limit: f64) -> BezPath {
let style = Stroke::new(width).with_join(kjoin(join)).with_caps(kurbo::Cap::Butt).with_miter_limit(miter_limit.max(1.0));
crate::boolean::close_subpaths(&kurbo::stroke(bp.iter(), &style, &StrokeOpts::default(), STROKE_TOL))
}
/// Split a path into its closed and open subpaths.
fn split_closed(p: &BezPath) -> (BezPath, BezPath) {
let (mut closed, mut open) = (BezPath::new(), BezPath::new());
for sp in p.subpaths() {
let n = sp.iter().filter(|e| !matches!(e, PathEl::MoveTo(_) | PathEl::ClosePath)).count();
if n == 0 {
continue;
}
let dst = if matches!(sp.last(), Some(PathEl::ClosePath)) { &mut closed } else { &mut open };
dst.extend(sp.iter().copied());
}
(closed, open)
}
/// Offset a path by `delta`: positive grows the (non-zero) filled region, negative insets it.
/// Closed subpaths are treated as a filled region; open subpaths are outlined with a stroke of
/// width `2|delta|`. The result is a clean compound path of closed contours.
pub fn offset_path(path: &BezPath, delta: f64, join: Join, miter_limit: f64) -> BezPath {
if !delta.is_finite() || path.elements().is_empty() {
return BezPath::new();
}
if delta == 0.0 {
return path.clone();
}
let (closed, open) = split_closed(path);
let d = delta.abs();
let ring = if closed.elements().is_empty() { BezPath::new() } else { stroke_bez(&closed, 2.0 * d, join, miter_limit) };
let open_bp = if open.elements().is_empty() { BezPath::new() } else { stroke_bez(&open, 2.0 * d, join, miter_limit) };
let Some(arr) = Arrangement::new(&[(closed, FillRule::NonZero), (ring, FillRule::NonZero), (open_bp, FillRule::NonZero)]) else {
return BezPath::new();
};
let c = if delta >= 0.0 { arr.contours(|m| m[0] || m[1] || m[2]) } else { arr.contours(|m| m[0] && !m[1]) };
let tidy = Tidy::free(DEFAULT_PRECISION);
// When `d` exceeds a curvature radius the stroker's inner offset inverts and its loops cancel
// winding, leaving faces uncovered that are really within `d` of the path. A contour is
// spurious iff a point deep inside it lies closer than `d`: holes when growing, islands when
// insetting.
let drop_outer = delta < 0.0;
let spurious: Vec<bool> =
c.contours().map(|k| k.outer == drop_outer && deep_point(&k.path).is_some_and(|p| dist_to(path, p) < d * (1.0 - 1e-4) - 1e-6)).collect();
let keep: Vec<&BezPath> = (0..spurious.len()).filter(|&i| !has_marked_ancestor(&c, i, &spurious)).map(|i| &c[ContourIdx(i)].path).collect();
contours_to_path(keep, &tidy)
}
fn has_marked_ancestor(c: &Contours, mut i: usize, marked: &[bool]) -> bool {
loop {
if marked[i] {
return true;
}
match c[ContourIdx(i)].parent {
Some(ContourIdx(p)) => i = p,
None => return false,
}
}
}
/// A point well inside a closed contour: the midpoint of the widest span on a few horizontal
/// lines through it.
fn deep_point(bp: &BezPath) -> Option<kurbo::Point> {
use kurbo::Shape;
let r = bp.bounding_box();
if !(r.width() > 0.0 && r.height() > 0.0) {
return None;
}
let mut best: Option<(f64, kurbo::Point)> = None;
for f in [0.5, 0.3, 0.7] {
let y = r.y0 + r.height() * f;
let mut xs = vec![];
let mut prev: Option<kurbo::Point> = None;
let mut start = kurbo::Point::ZERO;
let edge = |a: kurbo::Point, b: kurbo::Point, xs: &mut Vec<f64>| {
if (a.y <= y) != (b.y <= y) {
xs.push(a.x + (y - a.y) / (b.y - a.y) * (b.x - a.x));
}
};
kurbo::flatten(bp.iter(), 0.01 * r.width().min(r.height()).max(1e-6), |el| match el {
PathEl::MoveTo(p) => {
start = p;
prev = Some(p);
}
PathEl::LineTo(p) => {
if let Some(a) = prev {
edge(a, p, &mut xs);
}
prev = Some(p);
}
PathEl::ClosePath => {
if let Some(a) = prev {
edge(a, start, &mut xs);
}
prev = Some(start);
}
_ => {}
});
xs.sort_by(f64::total_cmp);
for w in xs.chunks_exact(2) {
let span = w[1] - w[0];
if best.is_none_or(|(b, _)| span > b) {
best = Some((span, kurbo::Point::new((w[0] + w[1]) / 2.0, y)));
}
}
}
best.map(|(_, p)| p)
}
fn dist_to(bp: &BezPath, p: kurbo::Point) -> f64 {
use kurbo::ParamCurveNearest;
bp.segments().map(|s| s.nearest(p, 1e-6).distance_sq).fold(f64::INFINITY, f64::min).sqrt()
}

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;

View File

@ -0,0 +1,375 @@
use super::*;
use kurbo::{Circle, Rect, Shape};
fn len(p: &[BezPath]) -> f64 {
p.iter().map(length).sum()
}
fn sq(x: f64, y: f64, s: f64) -> BezPath {
Rect::new(x, y, x + s, y + s).to_path(0.1)
}
fn circle(x: f64, y: f64, r: f64) -> BezPath {
Circle::new((x, y), r).to_path(1e-3)
}
fn close(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() <= tol
}
fn on_curve(p: &BezPath) -> Vec<Point> {
p.elements().iter().filter_map(|e| e.end_point()).collect()
}
// ---------------------------------------------------------------- trim
#[test]
fn trim_half_square() {
let s = sq(0.0, 0.0, 100.0);
let t = trim(std::slice::from_ref(&s), 0.0, 50.0, 0.0);
assert!(close(len(&t), 200.0, 0.5), "{}", len(&t));
let t2 = trim(&[s], 0.0, 50.0, 270.0);
assert!(close(len(&t2), 200.0, 0.5));
}
#[test]
fn trim_empty_keeps_slot() {
let t = trim(&[sq(0.0, 0.0, 10.0)], 30.0, 30.0, 0.0);
assert_eq!(t.len(), 1);
assert!(t[0].elements().is_empty());
}
#[test]
fn trim_across_start_point_is_one_piece() {
// 75 %..125 % (via the offset) crosses the start of the closed path: one continuous subpath.
let t = trim(&[sq(0.0, 0.0, 100.0)], 0.0, 50.0, 270.0);
let moves = t[0].elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count();
assert_eq!(moves, 1, "{:?}", t[0]);
}
#[test]
fn trim_simultaneously_trims_each_path() {
let paths = [sq(0.0, 0.0, 100.0), sq(200.0, 0.0, 50.0)];
let t = trim(&paths, 0.0, 50.0, 0.0);
assert!(close(length(&t[0]), 200.0, 0.5));
assert!(close(length(&t[1]), 100.0, 0.5));
}
#[test]
fn trim_individually_is_sequential() {
let paths = [sq(0.0, 0.0, 100.0), sq(200.0, 0.0, 100.0)];
let t = trim_individually(&paths, 0.0, 50.0, 0.0);
assert!(close(length(&t[0]), 400.0, 0.5), "{}", length(&t[0]));
assert!(length(&t[1]) < 1e-6);
let t = trim_individually(&paths, 25.0, 75.0, 0.0);
assert!(close(length(&t[0]), 200.0, 0.5));
assert!(close(length(&t[1]), 200.0, 0.5));
// Unequal lengths: the kept length is the fraction of the total.
let paths = [sq(0.0, 0.0, 100.0), sq(200.0, 0.0, 25.0)];
let t = trim_individually(&paths, 10.0, 90.0, 45.0);
assert!(close(len(&t), 0.8 * 500.0, 0.5), "{}", len(&t));
}
#[test]
fn reverse_flips_orientation() {
let s = sq(0.0, 0.0, 10.0);
let r = reverse(std::slice::from_ref(&s));
assert!(close(r[0].area(), -s.area(), 1e-9));
assert_eq!(vertex_count(&r[0]), 4);
}
// ---------------------------------------------------------------- round corners
#[test]
fn round_corners_square() {
let r = round_corners(&[sq(0.0, 0.0, 100.0)], 10.0);
assert_eq!(vertex_count(&r[0]), 8);
let expect = 10000.0 - (4.0 - PI) * 100.0;
assert!(close(r[0].area().abs(), expect, 1.0), "{}", r[0].area());
// Matches the rectangle's own roundness.
let rr = crate::rect([100.0, 100.0], [50.0, 50.0], 10.0);
assert!(close(rr.area().abs(), r[0].area().abs(), 1.0));
}
#[test]
fn round_corners_clamps_to_half_segment() {
let r = round_corners(&[sq(0.0, 0.0, 20.0)], 100.0);
assert!(close(r[0].area().abs(), PI * 100.0, 2.0), "{}", r[0].area());
}
#[test]
fn round_corners_keeps_smooth_and_open_ends() {
let c = circle(0.0, 0.0, 50.0);
let r = round_corners(std::slice::from_ref(&c), 10.0);
assert_eq!(vertex_count(&r[0]), vertex_count(&c));
let mut open = BezPath::new();
open.move_to((0.0, 0.0));
open.line_to((100.0, 0.0));
open.line_to((100.0, 100.0));
let r = round_corners(&[open], 20.0);
assert_eq!(vertex_count(&r[0]), 4);
let pts = on_curve(&r[0]);
assert_eq!(pts[0], Point::new(0.0, 0.0));
assert!(pts.last().unwrap().distance(Point::new(100.0, 100.0)) < 1e-9);
}
#[test]
fn round_corners_triangle_arc_is_tangent() {
let mut t = BezPath::new();
t.move_to((0.0, 0.0));
t.line_to((100.0, 0.0));
t.line_to((50.0, 80.0));
t.close_path();
let r = round_corners(std::slice::from_ref(&t), 15.0);
assert_eq!(vertex_count(&r[0]), 6);
assert!(r[0].area().abs() < t.area().abs());
assert!(r[0].area().abs() > t.area().abs() - 3.0 * 15.0 * 15.0);
}
// ---------------------------------------------------------------- offset
#[test]
fn offset_grows_square_with_miter() {
let o = offset(&[sq(0.0, 0.0, 100.0)], 10.0, Join::Miter, 4.0, 1.0, 0.0);
assert!(close(area(&o), 120.0 * 120.0, 1.0), "{}", area(&o));
let b = o[0].bounding_box();
assert!(close(b.x0, -10.0, 1e-3) && close(b.x1, 110.0, 1e-3));
}
#[test]
fn offset_round_join_adds_perimeter_times_d() {
let o = offset(&[sq(0.0, 0.0, 100.0)], 10.0, Join::Round, 4.0, 1.0, 0.0);
let expect = 10000.0 + 400.0 * 10.0 + PI * 100.0;
assert!(close(area(&o), expect, 2.0), "{} vs {expect}", area(&o));
let o = offset(&[sq(0.0, 0.0, 100.0)], 10.0, Join::Bevel, 4.0, 1.0, 0.0);
let expect = 10000.0 + 400.0 * 10.0 + 4.0 * 50.0;
assert!(close(area(&o), expect, 2.0), "{} vs {expect}", area(&o));
}
#[test]
fn offset_negative_shrinks() {
let o = offset(&[sq(0.0, 0.0, 100.0)], -10.0, Join::Miter, 4.0, 1.0, 0.0);
assert!(close(area(&o), 80.0 * 80.0, 1.0), "{}", area(&o));
let gone = offset(&[sq(0.0, 0.0, 100.0)], -60.0, Join::Miter, 4.0, 1.0, 0.0);
assert!(area(&gone) < 1e-6);
}
#[test]
fn offset_circle_is_a_circle() {
let o = offset(&[circle(0.0, 0.0, 50.0)], 10.0, Join::Round, 4.0, 1.0, 0.0);
assert!(close(area(&o), PI * 3600.0, 3600.0 * PI * 1e-3), "{}", area(&o));
let o = offset(&[circle(0.0, 0.0, 50.0)], -20.0, Join::Round, 4.0, 1.0, 0.0);
assert!(close(area(&o), PI * 900.0, 900.0 * PI * 1e-3), "{}", area(&o));
// Curves stay curves (no polygon flattening blow-up).
assert!(vertex_count(&o[0]) < 40, "{}", vertex_count(&o[0]));
}
#[test]
fn offset_is_independent_of_orientation() {
let s = sq(0.0, 0.0, 100.0);
let a = offset(std::slice::from_ref(&s), 5.0, Join::Miter, 4.0, 1.0, 0.0);
let b = offset(&reverse(&[s]), 5.0, Join::Miter, 4.0, 1.0, 0.0);
assert!(close(area(&a), area(&b), 1e-3));
}
#[test]
fn offset_copies() {
let o = offset(&[sq(0.0, 0.0, 100.0)], 10.0, Join::Miter, 4.0, 3.0, 0.0);
let contours = o[0].elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count();
assert_eq!(contours, 3);
assert!(close(o[0].bounding_box().width(), 160.0, 1e-3));
let o = offset(&[sq(0.0, 0.0, 100.0)], 10.0, Join::Miter, 4.0, 1.0, 1.0);
assert!(close(o[0].bounding_box().width(), 140.0, 1e-3));
}
#[test]
fn offset_open_path_outlines() {
let mut l = BezPath::new();
l.move_to((0.0, 0.0));
l.line_to((100.0, 0.0));
let o = offset(&[l], 5.0, Join::Miter, 4.0, 1.0, 0.0);
assert!(close(area(&o), 1000.0, 1.0), "{}", area(&o));
}
// ---------------------------------------------------------------- pucker & bloat
#[test]
fn bloat_moves_vertices_inwards_and_bulges() {
let s = sq(-50.0, -50.0, 100.0);
let b = pucker_bloat(std::slice::from_ref(&s), 50.0);
for p in on_curve(&b[0]) {
assert!(close(p.x.abs(), 25.0, 1e-9) && close(p.y.abs(), 25.0, 1e-9), "{p:?}");
}
// Handles pushed outwards: the shape reaches beyond its vertices.
let bb = b[0].bounding_box();
assert!(bb.width() > 50.0 + 1.0, "{bb:?}");
}
#[test]
fn pucker_moves_vertices_outwards() {
let s = sq(-50.0, -50.0, 100.0);
let p = pucker_bloat(std::slice::from_ref(&s), -50.0);
for q in on_curve(&p[0]) {
assert!(close(q.x.abs(), 75.0, 1e-9) && close(q.y.abs(), 75.0, 1e-9));
}
assert!(area(&p) < area(&[s]) * 2.25, "segments pulled in");
assert_eq!(pucker_bloat(&[sq(0.0, 0.0, 1.0)], 0.0)[0], sq(0.0, 0.0, 1.0));
}
// ---------------------------------------------------------------- twist
#[test]
fn twist_rotates_by_distance() {
let mut l = BezPath::new();
l.move_to((0.0, 0.0));
l.line_to((100.0, 0.0));
let t = twist(&[l], 90.0, [0.0, 0.0]);
let pts = on_curve(&t[0]);
assert!(pts[0].distance(Point::ZERO) < 1e-9, "centre is fixed");
let end = *pts.last().unwrap();
assert!(end.distance(Point::new(0.0, 100.0)) < 1e-9, "{end:?}");
// Every on-curve point keeps its distance from the centre (it is a rotation per radius).
for p in &pts {
let r = p.distance(Point::ZERO);
let ang = p.y.atan2(p.x);
assert!(close(ang, PI / 2.0 * r / 100.0, 1e-9), "{p:?}");
}
assert!(pts.len() > 4, "subdivided");
}
#[test]
fn twist_of_centred_circle_keeps_it_round() {
let c = circle(0.0, 0.0, 50.0);
let t = twist(std::slice::from_ref(&c), 120.0, [0.0, 0.0]);
assert!(close(area(&t), area(&[c]), 1.0));
let id = twist(&[sq(0.0, 0.0, 5.0)], 0.0, [0.0, 0.0]);
assert_eq!(id[0], sq(0.0, 0.0, 5.0));
}
// ---------------------------------------------------------------- zig zag
#[test]
fn zigzag_vertex_counts() {
let z = zigzag(&[sq(0.0, 0.0, 100.0)], 5.0, 5.0, false);
assert_eq!(vertex_count(&z[0]), 24);
let mut l = BezPath::new();
l.move_to((0.0, 0.0));
l.line_to((100.0, 0.0));
let z = zigzag(&[l], 5.0, 3.0, false);
assert_eq!(vertex_count(&z[0]), 5);
let pts = on_curve(&z[0]);
for (i, p) in pts.iter().enumerate() {
assert!(close(p.x, 25.0 * i as f64, 1e-6));
assert!(close(p.y.abs(), 5.0, 1e-9));
if i > 0 {
assert!(p.y * pts[i - 1].y < 0.0, "alternates");
}
}
}
#[test]
fn zigzag_smooth_uses_curves() {
let z = zigzag(&[sq(0.0, 0.0, 100.0)], 5.0, 2.0, true);
assert!(z[0].elements().iter().any(|e| matches!(e, PathEl::CurveTo(..))));
assert_eq!(vertex_count(&z[0]), 12);
let corner = zigzag(&[sq(0.0, 0.0, 100.0)], 5.0, 2.0, false);
assert!(!corner[0].elements().iter().any(|e| matches!(e, PathEl::CurveTo(..))));
}
// ---------------------------------------------------------------- wiggle
#[test]
fn wiggle_is_deterministic_and_time_varying() {
let s = [sq(0.0, 0.0, 100.0)];
let w = WiggleParams::default();
let a = wiggle(&s, &w, 0.5);
assert_eq!(a, wiggle(&s, &w, 0.5));
assert_ne!(a, wiggle(&s, &w, 0.75));
assert_ne!(a, wiggle(&s, &WiggleParams { seed: 3.0, ..w }, 0.5));
// Phase of 360° = one wiggle period at speed 1: same as advancing time by one second.
let w1 = WiggleParams { speed: 1.0, ..w };
assert_eq!(wiggle(&s, &WiggleParams { phase_deg: 360.0, ..w1 }, 0.25), wiggle(&s, &w1, 1.25));
assert_eq!(vertex_count(&a[0]), 4 * 11);
}
#[test]
fn wiggle_displacement_is_bounded() {
let mut l = BezPath::new();
l.move_to((0.0, 0.0));
l.line_to((1000.0, 0.0));
let w = WiggleParams { size: 7.0, detail: 50.0, smooth: false, ..Default::default() };
for t in [0.0, 0.3, 1.7, 9.1] {
let r = wiggle(std::slice::from_ref(&l), &w, t);
let pts = on_curve(&r[0]);
assert_eq!(pts.len(), 52);
assert!(pts.iter().all(|p| p.y.abs() <= 7.0 * 1.0001));
assert!(pts.iter().any(|p| p.y.abs() > 0.1));
}
}
#[test]
fn wiggle_full_correlation_translates() {
let s = [sq(0.0, 0.0, 100.0)];
let w = WiggleParams { correlation: 100.0, smooth: false, ..Default::default() };
let r = wiggle(&s, &w, 0.4);
assert!(close(area(&r), 10000.0, 1e-6));
let zero = wiggle(&s, &WiggleParams { size: 0.0, ..w }, 1.0);
assert_eq!(zero[0], s[0]);
}
// ---------------------------------------------------------------- merge / booleans
fn inclusion_exclusion(a: BezPath, b: BezPath) {
let aa = area(std::slice::from_ref(&a));
let ab = area(std::slice::from_ref(&b));
let pair = [a, b];
let u = area(&[merge(&pair, MergeMode::Add)]);
let i = area(&[merge(&pair, MergeMode::Intersect)]);
let s = area(&[merge(&pair, MergeMode::Subtract)]);
let x = area(&[merge(&pair, MergeMode::Exclude)]);
let tol = 1e-3 * (aa + ab);
assert!(i > 1.0, "overlapping");
assert!(close(u, aa + ab - i, tol), "union {u} vs {aa}+{ab}-{i}");
assert!(close(s, aa - i, tol), "subtract {s}");
assert!(close(x, u - i, tol), "exclude {x}");
}
#[test]
fn merge_rects_inclusion_exclusion() {
inclusion_exclusion(sq(0.0, 0.0, 100.0), sq(50.0, 30.0, 100.0));
let i = merge(&[sq(0.0, 0.0, 100.0), sq(50.0, 30.0, 100.0)], MergeMode::Intersect);
assert!(close(area(&[i]), 50.0 * 70.0, 1e-6));
}
#[test]
fn merge_circles_inclusion_exclusion() {
inclusion_exclusion(circle(0.0, 0.0, 50.0), circle(40.0, 10.0, 35.0));
// Curves are preserved: the union of two circles has few vertices.
let u = merge(&[circle(0.0, 0.0, 50.0), circle(40.0, 10.0, 35.0)], MergeMode::Add);
assert!(vertex_count(&u) < 20, "{}", vertex_count(&u));
}
#[test]
fn merge_modes_edge_cases() {
let a = sq(0.0, 0.0, 10.0);
let b = sq(100.0, 0.0, 10.0);
assert!(area(&[merge(&[a.clone(), b.clone()], MergeMode::Intersect)]) < 1e-9);
assert!(close(area(&[merge(&[a.clone(), b.clone()], MergeMode::Add)]), 200.0, 1e-6));
let m = merge(&[a.clone(), b.clone()], MergeMode::Merge);
assert_eq!(m.elements().len(), a.elements().len() + b.elements().len());
// Subtract with three operands: first minus the rest.
let s = merge(&[sq(0.0, 0.0, 100.0), sq(0.0, 0.0, 50.0), sq(50.0, 50.0, 50.0)], MergeMode::Subtract);
assert!(close(area(&[s]), 5000.0, 1e-6));
assert!(merge(&[], MergeMode::Add).elements().is_empty());
}
#[test]
fn normalize_and_filled_area() {
// Two same-direction overlapping squares in one path: non-zero fills the union.
let mut p = sq(0.0, 0.0, 100.0);
p.extend(sq(50.0, 0.0, 100.0).elements().iter().copied());
assert!(close(crate::boolean::filled_area(&p, FillRule::NonZero), 15000.0, 1e-6));
assert!(close(crate::boolean::filled_area(&p, FillRule::EvenOdd), 10000.0, 1e-6));
let n = crate::boolean::normalize(&p, FillRule::EvenOdd);
assert!(close(n.area().abs(), 10000.0, 1e-6));
}

View File

@ -249,6 +249,30 @@ pub fn shape_gradient_fill(ids: &mut Ids, radial: bool, start: [f64; 2], end: [f
.with(ids.prop("opacity", "Opacity", Value::Scalar(100.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 0)))
}
/// Gradient Stroke: a stroke painted with a linear or radial gradient.
pub fn shape_gradient_stroke(ids: &mut Ids, radial: bool, start: [f64; 2], end: [f64; 2], g: Gradient, width: f64) -> PropGroup {
indexed(ids.group("gstroke", "Gradient Stroke 1"))
.with(ids.prop("blend", "Blend Mode", Value::Enum(0)).with_ui(ParamUi::Hidden))
.with(ids.prop("composite", "Composite", Value::Enum(0)).with_ui(popup(&["Below Previous in Same Group", "Above Previous in Same Group"])))
.with(ids.prop("type", "Type", Value::Enum(radial as u32)).with_ui(popup(&["Linear", "Radial"])))
.with(ids.prop("start", "Start Point", Value::Vec2(start)).with_ui(ParamUi::Point))
.with(ids.prop("end", "End Point", Value::Vec2(end)).with_ui(ParamUi::Point))
.with(ids.prop("highlightLength", "Highlight Length", Value::Scalar(0.0)).with_ui(ParamUi::Percent))
.with(ids.prop("highlightAngle", "Highlight Angle", Value::Scalar(0.0)).with_ui(ParamUi::Angle))
.with(ids.prop("colors", "Colors", Value::Gradient(g)).with_ui(ParamUi::Gradient))
.with(ids.prop("opacity", "Opacity", Value::Scalar(100.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 0)))
.with(ids.prop("width", "Stroke Width", Value::Scalar(width)).with_ui(ParamUi::Pixels))
.with(ids.prop("cap", "Line Cap", Value::Enum(0)).with_ui(popup(&["Butt Cap", "Round Cap", "Projecting Cap"])))
.with(ids.prop("join", "Line Join", Value::Enum(0)).with_ui(popup(&["Miter Join", "Round Join", "Bevel Join"])))
.with(ids.prop("miter", "Miter Limit", Value::Scalar(4.0)))
.with(
ids.group("dashes", "Dashes")
.with(ids.prop("dash", "Dash", Value::Scalar(0.0)).with_ui(ParamUi::Pixels))
.with(ids.prop("gap", "Gap", Value::Scalar(0.0)).with_ui(ParamUi::Pixels))
.with(ids.prop("offset", "Offset", Value::Scalar(0.0)).with_ui(ParamUi::Pixels)),
)
}
pub fn shape_trim(ids: &mut Ids, start: f64, end: f64, offset: f64) -> PropGroup {
indexed(ids.group("trim", "Trim Paths 1"))
.with(ids.prop("start", "Start", Value::Scalar(start)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 1)))

View File

@ -1,11 +1,18 @@
//! Shape-layer contents: groups, parametric paths, fills, strokes, gradients and path operators,
//! with the After Effects stacking semantics (operators and paint items act on the paths above
//! them in the same group; items higher in the list draw on top).
//! with the After Effects stacking semantics:
//!
//! - path operators (Trim Paths, Round Corners, Offset Paths, Pucker & Bloat, Twist, Zig Zag,
//! Wiggle Paths, Merge Paths, Repeater) act on all paths above them in the same group, including
//! the paths of nested groups;
//! - paint items (fills, strokes, gradient fills/strokes) paint the paths above them, *as modified
//! by every operator in the stack* — a Trim Paths below a Stroke still trims it;
//! - items higher in the list draw on top.
use effectcraft_color::{BlendMode, blend_pixel};
use effectcraft_effects::Buf;
use effectcraft_geom::{Mat3, vec2};
use effectcraft_keyframe::{Gradient, Value};
use effectcraft_path::{BezPath, Cap, FillRule, Join, StrokeStyle};
use effectcraft_path::{BezPath, Cap, FillRule, Join, StrokeStyle, ops};
use effectcraft_project::{Layer, Node, PropGroup};
use effectcraft_raster::{Image, Mask};
use rayon::prelude::*;
@ -14,22 +21,40 @@ use crate::eval::EvalCtx;
#[derive(Clone, Debug)]
enum Paint {
Fill { color: [f32; 4], rule: FillRule },
Stroke { color: [f32; 4], style: StrokeStyle },
Gradient { g: Gradient, radial: bool, start: [f64; 2], end: [f64; 2], rule: FillRule },
Fill {
color: [f32; 4],
rule: FillRule,
},
Stroke {
color: [f32; 4],
style: StrokeStyle,
},
/// Gradient fill, or gradient stroke when `stroke` is set.
Gradient {
g: Gradient,
radial: bool,
start: [f64; 2],
end: [f64; 2],
rule: FillRule,
stroke: Option<StrokeStyle>,
},
}
/// A paint item. It references path *slots* rather than copies of the geometry: path operators
/// further down the stack rewrite the slots in place, so (as in After Effects) a Trim Paths or
/// Round Corners placed below a fill or stroke still changes what that paint draws.
#[derive(Clone, Debug)]
struct Draw {
paint: Paint,
paths: Vec<BezPath>,
/// Group-space → layer-space for gradients and stroke scaling.
slots: Vec<usize>,
/// Paint-group space → layer space, for gradients and stroke scaling.
xf: Mat3,
opacity: f32,
blend: BlendMode,
}
fn mat_paths(paths: &[BezPath], m: &Mat3) -> Vec<BezPath> {
effectcraft_path::transform(paths, m)
fn mat_path(path: &BezPath, m: &Mat3) -> BezPath {
effectcraft_path::transform(std::slice::from_ref(path), m).pop().unwrap_or_default()
}
fn group_matrix(ctx: &EvalCtx, layer: &Layer, tr: &PropGroup) -> (Mat3, f32) {
@ -51,10 +76,55 @@ fn rule(ctx: &EvalCtx, layer: &Layer, g: &PropGroup) -> FillRule {
if ctx.e(layer, g, "rule") == 1 { FillRule::EvenOdd } else { FillRule::NonZero }
}
/// Collect draws and accumulated paths of a contents group (in group space).
fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> (Vec<Draw>, Vec<BezPath>) {
fn stroke_style(ctx: &EvalCtx, layer: &Layer, g: &PropGroup) -> StrokeStyle {
let dash = g.sub("dashes").and_then(|d| {
let dash = ctx.f(layer, d, "dash", 0.0);
let gap = ctx.f(layer, d, "gap", 0.0);
(dash > 0.0).then(|| (vec![dash, if gap > 0.0 { gap } else { dash }], ctx.f(layer, d, "offset", 0.0)))
});
StrokeStyle {
width: ctx.f(layer, g, "width", 2.0),
cap: [Cap::Butt, Cap::Round, Cap::Square][ctx.e(layer, g, "cap").min(2) as usize],
join: [Join::Miter, Join::Round, Join::Bevel][ctx.e(layer, g, "join").min(2) as usize],
miter: ctx.f(layer, g, "miter", 4.0),
dash,
}
}
/// Blend mode of a paint item (`blend` holds an index into the Blending Mode menu).
fn blend_mode(ctx: &EvalCtx, layer: &Layer, g: &PropGroup) -> BlendMode {
BlendMode::ALL.get(ctx.e(layer, g, "blend") as usize).copied().unwrap_or_default()
}
/// Apply a path operator to the live slots in place.
fn apply(arena: &mut [BezPath], live: &[usize], f: impl FnOnce(&[BezPath]) -> Vec<BezPath>) {
let input: Vec<BezPath> = live.iter().map(|&i| arena[i].clone()).collect();
for (&i, p) in live.iter().zip(f(&input)) {
arena[i] = p;
}
}
/// Push a shape's path as a new slot, honouring its Path Direction toggle (1 = reversed).
fn push_shape(ctx: &EvalCtx, layer: &Layer, g: &PropGroup, p: BezPath, arena: &mut Vec<BezPath>, live: &mut Vec<usize>) {
let p = if ctx.e(layer, g, "direction") == 1 { p.reverse_subpaths() } else { p };
arena.push(p);
live.push(arena.len() - 1);
}
/// Every slot used by `live` or by `draws`, sorted and unique.
fn used_slots(live: &[usize], draws: &[Draw]) -> Vec<usize> {
let mut v: Vec<usize> = live.iter().copied().chain(draws.iter().flat_map(|d| d.slots.iter().copied())).collect();
v.sort_unstable();
v.dedup();
v
}
/// Collect the draws of a contents group and the path slots live at its end (in group space).
/// `arena` holds every path slot; operators rewrite slots in place.
fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<BezPath>) -> (Vec<Draw>, Vec<usize>) {
let mut draws: Vec<Draw> = Vec::new();
let mut acc: Vec<BezPath> = Vec::new();
let mut live: Vec<usize> = Vec::new();
let opacity = |g: &PropGroup| (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32;
for node in &contents.children {
let Node::Group(g) = node else { continue };
if !g.enabled {
@ -64,16 +134,17 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> (Vec<Draw>, Ve
"rect" => {
let size = ctx.v2(layer, g, "size", [100.0; 2]);
let pos = ctx.v2(layer, g, "position", [0.0; 2]);
acc.push(effectcraft_path::rect(size, pos, ctx.f(layer, g, "roundness", 0.0)));
let p = effectcraft_path::rect(size, pos, ctx.f(layer, g, "roundness", 0.0));
push_shape(ctx, layer, g, p, arena, &mut live);
}
"ellipse" => {
let size = ctx.v2(layer, g, "size", [100.0; 2]);
let pos = ctx.v2(layer, g, "position", [0.0; 2]);
acc.push(effectcraft_path::ellipse(size, pos));
push_shape(ctx, layer, g, effectcraft_path::ellipse(size, pos), arena, &mut live);
}
"star" => {
let star = ctx.e(layer, g, "type") == 0;
acc.push(effectcraft_path::polystar(
let p = effectcraft_path::polystar(
star,
ctx.f(layer, g, "points", 5.0),
ctx.v2(layer, g, "position", [0.0; 2]),
@ -82,40 +153,43 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> (Vec<Draw>, Ve
ctx.f(layer, g, "outerRadius", 100.0),
ctx.f(layer, g, "innerRoundness", 0.0),
ctx.f(layer, g, "outerRoundness", 0.0),
));
);
push_shape(ctx, layer, g, p, arena, &mut live);
}
"path" => {
if let Some(Value::Path(p)) = ctx.group_value(layer, g, "path") {
acc.push(effectcraft_path::to_kurbo(&p));
push_shape(ctx, layer, g, effectcraft_path::to_kurbo(&p), arena, &mut live);
}
}
"fill" => {
let mut c = ctx.color(layer, g, "color");
c[3] = 1.0;
draws.push(Draw { paint: Paint::Fill { color: c, rule: rule(ctx, layer, g) }, paths: acc.clone(), xf: Mat3::IDENTITY, opacity: (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32 });
draws.push(Draw {
paint: Paint::Fill { color: c, rule: rule(ctx, layer, g) },
slots: live.clone(),
xf: Mat3::IDENTITY,
opacity: opacity(g),
blend: blend_mode(ctx, layer, g),
});
}
"stroke" => {
let mut c = ctx.color(layer, g, "color");
c[3] = 1.0;
let dash = g.sub("dashes").and_then(|d| {
let dash = ctx.f(layer, d, "dash", 0.0);
let gap = ctx.f(layer, d, "gap", 0.0);
(dash > 0.0).then(|| (vec![dash, if gap > 0.0 { gap } else { dash }], ctx.f(layer, d, "offset", 0.0)))
let style = stroke_style(ctx, layer, g);
draws.push(Draw {
paint: Paint::Stroke { color: c, style },
slots: live.clone(),
xf: Mat3::IDENTITY,
opacity: opacity(g),
blend: blend_mode(ctx, layer, g),
});
let style = StrokeStyle {
width: ctx.f(layer, g, "width", 2.0),
cap: [Cap::Butt, Cap::Round, Cap::Square][ctx.e(layer, g, "cap").min(2) as usize],
join: [Join::Miter, Join::Round, Join::Bevel][ctx.e(layer, g, "join").min(2) as usize],
miter: ctx.f(layer, g, "miter", 4.0),
dash,
};
draws.push(Draw { paint: Paint::Stroke { color: c, style }, paths: acc.clone(), xf: Mat3::IDENTITY, opacity: (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32 });
}
"gfill" => {
"gfill" | "gstroke" => {
let gr = match ctx.group_value(layer, g, "colors") {
Some(Value::Gradient(gr)) => gr,
_ => Gradient::default(),
};
let stroke = (g.match_id == "gstroke").then(|| stroke_style(ctx, layer, g));
draws.push(Draw {
paint: Paint::Gradient {
g: gr,
@ -123,34 +197,86 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> (Vec<Draw>, Ve
start: ctx.v2(layer, g, "start", [0.0; 2]),
end: ctx.v2(layer, g, "end", [100.0, 0.0]),
rule: rule(ctx, layer, g),
stroke,
},
paths: acc.clone(),
slots: live.clone(),
xf: Mat3::IDENTITY,
opacity: (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32,
opacity: opacity(g),
blend: blend_mode(ctx, layer, g),
});
}
"trim" => {
let (s, e, o) = (ctx.f(layer, g, "start", 0.0), ctx.f(layer, g, "end", 100.0), ctx.f(layer, g, "offset", 0.0));
acc = effectcraft_path::ops::trim(&acc, s, e, o);
for d in &mut draws {
// Paint items above the trim are not affected (AE semantics).
let _ = d;
// Trim Multiple Shapes: 0 = Simultaneously (each path by the same fractions),
// 1 = Individually (one after another, as one continuous length).
if ctx.e(layer, g, "mode") == 1 {
apply(arena, &live, |p| ops::trim_individually(p, s, e, o));
} else {
apply(arena, &live, |p| ops::trim(p, s, e, o));
}
}
"pucker" => {
acc = effectcraft_path::ops::pucker_bloat(&acc, ctx.f(layer, g, "amount", 0.0));
let a = ctx.f(layer, g, "amount", 0.0);
apply(arena, &live, |p| ops::pucker_bloat(p, a));
}
"round" => {
let r = ctx.f(layer, g, "radius", 10.0);
apply(arena, &live, |p| ops::round_corners(p, r));
}
"offset" => {
let amount = ctx.f(layer, g, "amount", 10.0);
let join = [Join::Miter, Join::Round, Join::Bevel][ctx.e(layer, g, "join").min(2) as usize];
let miter = ctx.f(layer, g, "miter", 4.0);
let copies = ctx.f(layer, g, "copies", 1.0);
let copy_offset = ctx.f(layer, g, "copyOffset", 0.0);
apply(arena, &live, |p| ops::offset(p, amount, join, miter, copies, copy_offset));
}
"twist" => {
let angle = ctx.f(layer, g, "angle", 0.0);
let center = ctx.v2(layer, g, "center", [0.0; 2]);
apply(arena, &live, |p| ops::twist(p, angle, center));
}
"zigzag" => {
let size = ctx.f(layer, g, "size", 10.0);
let ridges = ctx.f(layer, g, "ridges", 5.0);
let smooth = ctx.e(layer, g, "points") == 1;
apply(arena, &live, |p| ops::zigzag(p, size, ridges, smooth));
}
"wiggle" => {
let w = ops::WiggleParams {
size: ctx.f(layer, g, "size", 10.0),
detail: ctx.f(layer, g, "detail", 10.0),
smooth: ctx.e(layer, g, "points") == 1,
speed: ctx.f(layer, g, "speed", 2.0),
correlation: ctx.f(layer, g, "correlation", 50.0),
phase_deg: ctx.f(layer, g, "phase", 0.0),
seed: ctx.f(layer, g, "seed", 0.0),
};
let t = layer.layer_time(ctx.time).seconds();
apply(arena, &live, |p| ops::wiggle(p, &w, t));
}
"merge" => {
let mode = ops::MergeMode::from_index(ctx.e(layer, g, "mode"));
let input: Vec<BezPath> = live.iter().map(|&i| arena[i].clone()).collect();
arena.push(ops::merge(&input, mode));
live = vec![arena.len() - 1];
// As in After Effects, fills and strokes above a Merge Paths in its group are not
// rendered: the merged path is painted by the paint items below it.
draws.clear();
}
"group" => {
let Some(inner) = g.sub("contents") else { continue };
let (cd, cp) = collect(ctx, layer, inner);
let (cd, cl) = collect(ctx, layer, inner, arena);
let (m, op) = g.sub("transform").map(|t| group_matrix(ctx, layer, t)).unwrap_or((Mat3::IDENTITY, 1.0));
for i in used_slots(&cl, &cd) {
arena[i] = mat_path(&arena[i], &m);
}
for mut d in cd {
d.paths = mat_paths(&d.paths, &m);
d.xf = m * d.xf;
d.opacity *= op;
draws.push(d);
}
acc.extend(mat_paths(&cp, &m));
live.extend(cl);
}
"repeater" => {
let copies = ctx.f(layer, g, "copies", 3.0).max(0.0);
@ -171,114 +297,191 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> (Vec<Draw>, Ve
* Mat3::translate(vec2(-anchor[0], -anchor[1]))
};
let base_draws = std::mem::take(&mut draws);
let base_acc = std::mem::take(&mut acc);
let base_live = std::mem::take(&mut live);
let base_slots = used_slots(&base_live, &base_draws);
// Each copy gets its own transformed slots, so operators below the repeater act on
// the repeated geometry (copy order: original first).
let mut copy_maps: Vec<std::collections::HashMap<usize, usize>> = Vec::with_capacity(n);
for i in 0..n {
let m = step(i as f64 + offset);
let mut map = std::collections::HashMap::new();
for &s in &base_slots {
arena.push(mat_path(&arena[s], &m));
map.insert(s, arena.len() - 1);
}
live.extend(base_live.iter().map(|s| map[s]));
copy_maps.push(map);
}
let above = ctx.e(layer, g, "composite") == 1;
let mut order: Vec<usize> = (0..n).collect();
if !above {
order.reverse();
}
for i in order {
let k = i as f64 + offset;
let m = step(k);
let m = step(i as f64 + offset);
let t = if n > 1 { i as f32 / (n - 1) as f32 } else { 0.0 };
let opk = so + (eo - so) * t;
let partial = if i + 1 == n && copies.fract() > 0.0 { copies.fract() as f32 } else { 1.0 };
for d in &base_draws {
let mut d = d.clone();
d.paths = mat_paths(&d.paths, &m);
d.slots = d.slots.iter().map(|s| copy_maps[i][s]).collect();
d.xf = m * d.xf;
d.opacity *= opk * partial;
draws.push(d);
}
acc.extend(mat_paths(&base_acc, &m));
}
}
_ => {}
}
}
(draws, acc)
(draws, live)
}
fn composite_coverage(img: &mut Image, cov: &Mask, color: [f32; 4], opacity: f32) {
img.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(px, &c)| {
let a = c * opacity * color[3];
if a <= 0.0 {
return;
/// Pixel rectangle of the layer buffer (`x0, y0, width, height`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct PxRect {
x0: u32,
y0: u32,
w: u32,
h: u32,
}
/// Composite `cov`-weighted colour (`color_at(x, y)` in buffer pixels, straight alpha) into the
/// `r` sub-rectangle of a premultiplied image (`cov` is `r.w × r.h`).
fn paint(img: &mut Image, r: PxRect, cov: &Mask, opacity: f32, blend: BlendMode, color_at: impl Fn(usize, usize) -> [f32; 4] + Sync) {
let iw = img.width as usize;
let (x0, y0, rw) = (r.x0 as usize, r.y0 as usize, r.w as usize);
let row = |y: usize, out: &mut [[f32; 4]], crow: &[f32]| {
for (dx, (px, &c)) in out[x0..x0 + rw].iter_mut().zip(crow).enumerate() {
if c <= 0.0 {
continue;
}
let x = x0 + dx;
let col = color_at(x, y);
let a = c * opacity * col[3];
if a <= 0.0 {
continue;
}
let src = [col[0] * a, col[1] * a, col[2] * a, a];
if blend == BlendMode::Normal {
let k = 1.0 - a;
*px = [src[0] + px[0] * k, src[1] + px[1] * k, src[2] + px[2] * k, a + px[3] * k];
} else {
// Deterministic per-pixel noise for the dissolve modes.
let h = ((x as u32).wrapping_mul(73_856_093) ^ (y as u32).wrapping_mul(19_349_663)).wrapping_mul(2_654_435_761);
*px = blend_pixel(blend, *px, src, (h >> 8) as f32 / (1u32 << 24) as f32);
}
}
let k = 1.0 - a;
px[0] = color[0] * a + px[0] * k;
px[1] = color[1] * a + px[1] * k;
px[2] = color[2] * a + px[2] * k;
px[3] = a + px[3] * k;
});
}
/// Render a shape layer's contents into a layer-space buffer at scale `s`.
pub fn render(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, s: f64) -> Buf {
let (draws, _) = collect(ctx, layer, contents);
let mut bounds: Option<kurbo::Rect> = None;
for d in &draws {
if let Some(b) = effectcraft_path::bounds(&d.paths) {
let grow = match &d.paint {
Paint::Stroke { style, .. } => style.width * style.miter.max(1.0) * 0.5 * d.xf.mean_scale().max(1.0) + 1.0,
_ => 1.0,
};
let b = b.inflate(grow, grow);
bounds = Some(bounds.map_or(b, |a| a.union(b)));
};
let rows = img.data.chunks_mut(iw).skip(y0).take(r.h as usize);
if r.w as usize * r.h as usize >= 64 * 1024 {
let rows: Vec<&mut [[f32; 4]]> = rows.collect();
rows.into_par_iter().zip(cov.data.par_chunks(rw)).enumerate().for_each(|(dy, (out, crow))| row(y0 + dy, out, crow));
} else {
for (dy, (out, crow)) in rows.zip(cov.data.chunks(rw)).enumerate() {
row(y0 + dy, out, crow);
}
}
let Some(b) = bounds else { return Buf { img: Image::new(1, 1), offset: [0.0, 0.0], scale: s } };
}
/// Coverage of one draw inside its pixel rectangle.
fn draw_coverage(d: &Draw, paths: &[BezPath], m: &Mat3, r: PxRect) -> Mask {
// Rasterise into a small mask: shift the buffer matrix so the rectangle starts at 0,0.
let sub = Mat3::translate(vec2(-(r.x0 as f64), -(r.y0 as f64))) * *m;
let stroke = |style: &StrokeStyle| {
// Strokes scale with their group transform.
let mut st = style.clone();
st.width *= d.xf.mean_scale();
effectcraft_path::stroke_coverage(paths, &st, &sub, r.w, r.h)
};
match &d.paint {
Paint::Fill { rule, .. } => effectcraft_path::fill_coverage(paths, &sub, r.w, r.h, *rule),
Paint::Stroke { style, .. } => stroke(style),
Paint::Gradient { stroke: Some(style), .. } => stroke(style),
Paint::Gradient { rule, .. } => effectcraft_path::fill_coverage(paths, &sub, r.w, r.h, *rule),
}
}
/// Local-space bounds of what a draw can touch (stroke width, miters and anti-aliasing included).
fn draw_bounds(d: &Draw, paths: &[BezPath]) -> Option<kurbo::Rect> {
let b = effectcraft_path::bounds(paths)?;
let stroke = match &d.paint {
Paint::Stroke { style, .. } => Some(style),
Paint::Gradient { stroke: Some(style), .. } => Some(style),
_ => None,
};
let grow = stroke.map_or(1.0, |style| style.width * style.miter.max(1.0) * 0.5 * d.xf.mean_scale().max(1.0) + 1.0);
Some(b.inflate(grow, grow))
}
/// Maximum number of coverage pixels rasterised concurrently (bounds peak memory).
const BATCH_PIXELS: usize = 8 << 20;
/// Render a shape layer's contents into a layer-space buffer at scale `s`.
///
/// Each draw rasterises and composites only its own pixel rectangle; coverage masks of
/// consecutive draws are rasterised in parallel, then composited in stacking order.
pub fn render(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, s: f64) -> Buf {
let mut arena = Vec::new();
let (draws, _) = collect(ctx, layer, contents, &mut arena);
// Bottom-most draw first.
let resolved: Vec<(&Draw, Vec<BezPath>, kurbo::Rect)> = draws
.iter()
.rev()
.filter(|d| d.opacity > 0.0)
.filter_map(|d| {
let paths: Vec<BezPath> = d.slots.iter().map(|&i| arena[i].clone()).filter(|p| !p.elements().is_empty()).collect();
let b = draw_bounds(d, &paths)?;
Some((d, paths, b))
})
.collect();
let Some(b) = resolved.iter().map(|r| r.2).reduce(|a, b| a.union(b)) else { return Buf { img: Image::new(1, 1), offset: [0.0, 0.0], scale: s } };
// Keep buffers sane for runaway geometry.
let b = b.intersect(kurbo::Rect::new(-20000.0, -20000.0, 20000.0, 20000.0));
let w = ((b.width() * s).ceil() as u32 + 4).clamp(1, 16384);
let h = ((b.height() * s).ceil() as u32 + 4).clamp(1, 16384);
let offset = [-b.x0 * s + 2.0, -b.y0 * s + 2.0];
let m = Mat3::translate(vec2(offset[0], offset[1])) * Mat3::scale(vec2(s, s));
let px_rect = |r: kurbo::Rect| -> Option<PxRect> {
let x0 = (r.x0 * s + offset[0]).floor().max(0.0);
let y0 = (r.y0 * s + offset[1]).floor().max(0.0);
let x1 = (r.x1 * s + offset[0]).ceil().min(w as f64);
let y1 = (r.y1 * s + offset[1]).ceil().min(h as f64);
(x1 > x0 && y1 > y0).then_some(PxRect { x0: x0 as u32, y0: y0 as u32, w: (x1 - x0) as u32, h: (y1 - y0) as u32 })
};
let jobs: Vec<(&Draw, &[BezPath], PxRect)> = resolved.iter().filter_map(|(d, p, b)| Some((*d, p.as_slice(), px_rect(*b)?))).collect();
let mut img = Image::new(w, h);
for d in draws.iter().rev() {
if d.opacity <= 0.0 || d.paths.is_empty() {
continue;
let mut i = 0;
while i < jobs.len() {
// Next batch: as many draws as fit the pixel budget (at least one).
let mut j = i;
let mut px = 0usize;
while j < jobs.len() && (j == i || px + jobs[j].2.w as usize * jobs[j].2.h as usize <= BATCH_PIXELS) {
px += jobs[j].2.w as usize * jobs[j].2.h as usize;
j += 1;
}
match &d.paint {
Paint::Fill { color, rule } => {
let cov = effectcraft_path::fill_coverage(&d.paths, &m, w, h, *rule);
composite_coverage(&mut img, &cov, *color, d.opacity);
}
Paint::Stroke { color, style } => {
// Strokes scale with their group transform.
let mut st = style.clone();
st.width *= d.xf.mean_scale();
let cov = effectcraft_path::stroke_coverage(&d.paths, &st, &m, w, h);
composite_coverage(&mut img, &cov, *color, d.opacity);
}
Paint::Gradient { g, radial, start, end, rule } => {
let cov = effectcraft_path::fill_coverage(&d.paths, &m, w, h, *rule);
let to_local = (m * d.xf).inverse().unwrap_or(Mat3::IDENTITY);
let (sx, sy) = (start[0], start[1]);
let (dx, dy) = (end[0] - sx, end[1] - sy);
let len2 = (dx * dx + dy * dy).max(1e-9);
let op = d.opacity;
img.data.par_chunks_mut(w as usize).zip(cov.data.par_chunks(w as usize)).enumerate().for_each(|(y, (row, crow))| {
for x in 0..row.len() {
let c = crow[x];
if c <= 0.0 {
continue;
}
let covs: Vec<Mask> = jobs[i..j].par_iter().map(|(d, paths, r)| draw_coverage(d, paths, &m, *r)).collect();
for ((d, _, r), cov) in jobs[i..j].iter().zip(&covs) {
match &d.paint {
Paint::Fill { color, .. } | Paint::Stroke { color, .. } => paint(&mut img, *r, cov, d.opacity, d.blend, |_, _| *color),
Paint::Gradient { g, radial, start, end, .. } => {
let to_local = (m * d.xf).inverse().unwrap_or(Mat3::IDENTITY);
let (sx, sy) = (start[0], start[1]);
let (dx, dy) = (end[0] - sx, end[1] - sy);
let len2 = (dx * dx + dy * dy).max(1e-9);
paint(&mut img, *r, cov, d.opacity, d.blend, |x, y| {
let p = to_local.apply(vec2(x as f64 + 0.5, y as f64 + 0.5));
let (vx, vy) = (p.x - sx, p.y - sy);
let t = if *radial { ((vx * vx + vy * vy) / len2).sqrt() } else { (vx * dx + vy * dy) / len2 };
let col = g.sample(t);
let a = c * op * col[3];
let k = 1.0 - a;
let px = &mut row[x];
px[0] = col[0] * a + px[0] * k;
px[1] = col[1] * a + px[1] * k;
px[2] = col[2] * a + px[2] * k;
px[3] = a + px[3] * k;
}
});
g.sample(t)
});
}
}
}
i = j;
}
Buf { img, offset, scale: s }
}
#[cfg(test)]
mod tests;

View File

@ -0,0 +1,342 @@
//! Render-level tests for shape-layer path operators and paint items.
use effectcraft_color::Label;
use effectcraft_keyframe::{Gradient, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, PropGroup};
use effectcraft_raster::Image;
use effectcraft_time::{FrameRate, Tick};
use crate::render_frame;
/// A 200×100 comp with one shape layer (origin at the comp centre, 100,50) holding `items` at the
/// top level of its contents.
fn shape_comp(items: impl FnOnce(&mut Ids) -> Vec<PropGroup>) -> (Project, ItemId) {
shape_comp_sized(200, 100, items)
}
fn shape_comp_sized(w: u32, h: u32, items: impl FnOnce(&mut Ids) -> Vec<PropGroup>) -> (Project, ItemId) {
let mut p = Project::default();
let comp = Comp::new(w, h, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
let mut l = build::layer(&mut p, &comp, "Shape Layer 1", LayerSource::Shape, (w, h), None);
let mut next = p.next_id;
let items = items(&mut Ids(&mut next));
p.next_id = next;
let contents = l.props.sub_mut("contents").unwrap();
for i in items {
contents.children.push(i.into());
}
p.comp_mut(cid).unwrap().layers.push(l);
(p, cid)
}
fn frame(p: &Project, cid: ItemId) -> Image {
render_frame(p, cid, Tick::ZERO, 1.0)
}
fn alpha(img: &Image, x: i64, y: i64) -> f32 {
img.get(x, y)[3]
}
fn coverage(img: &Image) -> f32 {
img.data.iter().map(|px| px[3]).sum()
}
fn set(g: &mut PropGroup, path: &str, v: Value) {
g.prop_mut(path).unwrap_or_else(|| panic!("no {path}")).value = v;
}
fn op(ids: &mut Ids, kind: &str, params: &[(&str, Value)]) -> PropGroup {
let mut g = build::shape_simple_op(ids, kind).unwrap();
for (k, v) in params {
set(&mut g, k, v.clone());
}
g
}
const RED: [f64; 4] = [1.0, 0.0, 0.0, 1.0];
const GREEN: [f64; 4] = [0.0, 1.0, 0.0, 1.0];
#[test]
fn merge_add_feeds_fill_below_and_drops_paint_above() {
let (p, cid) = shape_comp(|ids| {
vec![
build::shape_rect(ids, [40.0, 40.0], [-20.0, 0.0], 0.0),
build::shape_rect(ids, [40.0, 40.0], [20.0, 0.0], 0.0),
build::shape_fill(ids, RED),
op(ids, "merge", &[("mode", Value::Enum(1))]),
build::shape_fill(ids, GREEN),
]
});
let img = frame(&p, cid);
let c = img.get(100, 50);
assert!(c[1] > 0.99 && c[0] < 0.01, "merged path painted by the fill below only: {c:?}");
assert!(img.get(65, 50)[1] > 0.99 && img.get(135, 50)[1] > 0.99);
assert!((coverage(&img) - 80.0 * 40.0).abs() < 20.0, "{}", coverage(&img));
}
#[test]
fn merge_subtract_and_intersect() {
let sub = |mode: u32| {
let (p, cid) = shape_comp(|ids| {
vec![
build::shape_rect(ids, [80.0, 60.0], [0.0, 0.0], 0.0),
build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0),
op(ids, "merge", &[("mode", Value::Enum(mode))]),
build::shape_fill(ids, RED),
]
});
frame(&p, cid)
};
let s = sub(2);
assert!(alpha(&s, 100, 50) < 0.01, "hole");
assert!(alpha(&s, 70, 50) > 0.99);
assert!((coverage(&s) - (80.0 * 60.0 - 400.0)).abs() < 20.0);
let i = sub(3);
assert!(alpha(&i, 100, 50) > 0.99);
assert!(alpha(&i, 70, 50) < 0.01);
assert!((coverage(&i) - 400.0).abs() < 10.0);
let x = sub(4);
assert!((coverage(&x) - (80.0 * 60.0 - 400.0)).abs() < 20.0);
}
#[test]
fn offset_paths_grow_and_shrink_fill() {
let run = |amount: f64| {
let (p, cid) = shape_comp(|ids| {
vec![build::shape_rect(ids, [40.0, 40.0], [0.0, 0.0], 0.0), op(ids, "offset", &[("amount", Value::Scalar(amount))]), build::shape_fill(ids, RED)]
});
frame(&p, cid)
};
let g = run(10.0);
assert!((coverage(&g) - 60.0 * 60.0).abs() < 20.0, "{}", coverage(&g));
assert!(alpha(&g, 100 + 25, 50) > 0.99);
let s = run(-10.0);
assert!((coverage(&s) - 20.0 * 20.0).abs() < 10.0, "{}", coverage(&s));
}
#[test]
fn operators_below_repeater_act_on_copies() {
let (p, cid) = shape_comp(|ids| {
let mut rep = build::shape_repeater(ids, 3.0, [30.0, 0.0]);
set(&mut rep, "transform/position", Value::Vec2([30.0, 0.0]));
vec![build::shape_rect(ids, [20.0, 20.0], [-30.0, 0.0], 0.0), rep, op(ids, "offset", &[("amount", Value::Scalar(2.0))]), build::shape_fill(ids, RED)]
});
let img = frame(&p, cid);
// Three 24×24 squares (offset by 2) at x = -30, 0, 30.
for cx in [70, 100, 130] {
assert!(alpha(&img, cx, 50) > 0.99, "copy at {cx}");
assert!(alpha(&img, cx + 11, 50) > 0.99, "offset applied to copy at {cx}");
}
assert!((coverage(&img) - 3.0 * 24.0 * 24.0).abs() < 30.0, "{}", coverage(&img));
}
#[test]
fn repeater_then_merge_unites_copies() {
let (p, cid) = shape_comp(|ids| {
vec![
build::shape_rect(ids, [20.0, 20.0], [-10.0, 0.0], 0.0),
build::shape_repeater(ids, 2.0, [10.0, 0.0]),
op(ids, "merge", &[("mode", Value::Enum(4))]),
build::shape_fill(ids, RED),
]
});
let img = frame(&p, cid);
// Two squares overlapping by 10 px: exclude leaves the two 10×20 ends.
assert!(alpha(&img, 95, 50) < 0.01);
assert!(alpha(&img, 85, 50) > 0.99 && alpha(&img, 105, 50) > 0.99);
assert!((coverage(&img) - 400.0).abs() < 10.0, "{}", coverage(&img));
}
#[test]
fn trim_below_stroke_trims_it() {
let run = |end: f64| {
let (p, cid) = shape_comp(|ids| {
let mut t = build::shape_trim(ids, 0.0, end, 0.0);
set(&mut t, "end", Value::Scalar(end));
vec![build::shape_rect(ids, [60.0, 60.0], [0.0, 0.0], 0.0), build::shape_stroke(ids, [1.0; 4], 4.0), t]
});
coverage(&frame(&p, cid))
};
let full = run(100.0);
let half = run(50.0);
assert!(full > 900.0);
assert!((half / full - 0.5).abs() < 0.05, "{half} / {full}");
}
#[test]
fn trim_individually_vs_simultaneously() {
let run = |mode: u32| {
let (p, cid) = shape_comp(|ids| {
let items = vec![build::shape_rect(ids, [40.0, 40.0], [-40.0, 0.0], 0.0), build::shape_stroke(ids, [1.0; 4], 2.0)];
let a = build::shape_group(ids, "A", items);
let items = vec![build::shape_rect(ids, [40.0, 40.0], [40.0, 0.0], 0.0), build::shape_stroke(ids, [1.0; 4], 2.0)];
let b = build::shape_group(ids, "B", items);
let mut t = build::shape_trim(ids, 0.0, 50.0, 0.0);
set(&mut t, "mode", Value::Enum(mode));
vec![a, b, t]
});
let img = frame(&p, cid);
let left: f32 = (0..100).flat_map(|x| (0..100).map(move |y| (x, y))).map(|(x, y)| alpha(&img, x, y)).sum();
let right: f32 = (100..200).flat_map(|x| (0..100).map(move |y| (x, y))).map(|(x, y)| alpha(&img, x, y)).sum();
(left, right)
};
let (l, r) = run(0);
assert!((l - r).abs() < 0.1 * l, "simultaneously: both half drawn {l} {r}");
let (l, r) = run(1);
assert!(l > 250.0 && r < 1.0, "individually: first path complete, second not started {l} {r}");
}
#[test]
fn gradient_stroke_paints_gradient_along_stroke() {
let (p, cid) = shape_comp(|ids| {
let g = Gradient { colors: vec![(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] };
vec![build::shape_rect(ids, [60.0, 60.0], [0.0, 0.0], 0.0), build::shape_gradient_stroke(ids, false, [-30.0, 0.0], [30.0, 0.0], g, 6.0)]
});
let img = frame(&p, cid);
let l = img.get(70, 50);
let r = img.get(130, 50);
assert!(l[3] > 0.99 && r[3] > 0.99, "{l:?} {r:?}");
assert!(l[0] < 0.1 && r[0] > 0.9, "{l:?} {r:?}");
assert!(alpha(&img, 100, 50) < 0.01, "stroke only");
}
#[test]
fn fill_blend_mode_multiply() {
let (p, cid) = shape_comp(|ids| {
let mut top = build::shape_fill(ids, [0.5, 0.5, 0.5, 1.0]);
set(
&mut top,
"blend",
Value::Enum(effectcraft_color::BlendMode::ALL.iter().position(|m| *m == effectcraft_color::BlendMode::Multiply).unwrap() as u32),
);
vec![build::shape_rect(ids, [40.0, 40.0], [0.0, 0.0], 0.0), top, build::shape_fill(ids, [1.0, 0.0, 0.0, 1.0])]
});
let c = frame(&p, cid).get(100, 50);
assert!((c[0] - 0.5).abs() < 0.01 && c[1] < 0.01, "{c:?}");
}
#[test]
fn reversed_path_direction_cuts_hole_under_non_zero() {
let (p, cid) = shape_comp(|ids| {
let mut inner = build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0);
set(&mut inner, "direction", Value::Enum(1));
vec![build::shape_rect(ids, [60.0, 60.0], [0.0, 0.0], 0.0), inner, op(ids, "merge", &[]), build::shape_fill(ids, RED)]
});
let img = frame(&p, cid);
assert!(alpha(&img, 100, 50) < 0.01);
assert!(alpha(&img, 75, 50) > 0.99);
}
#[test]
fn round_corners_clip_corner_pixels() {
let (p, cid) = shape_comp(|ids| {
vec![build::shape_rect(ids, [40.0, 40.0], [0.0, 0.0], 0.0), op(ids, "round", &[("radius", Value::Scalar(20.0))]), build::shape_fill(ids, RED)]
});
let img = frame(&p, cid);
assert!(alpha(&img, 81, 31) < 0.01, "corner rounded off");
assert!(alpha(&img, 100, 50) > 0.99);
assert!((coverage(&img) - std::f32::consts::PI * 400.0).abs() < 15.0, "{}", coverage(&img));
}
#[test]
fn wiggle_paths_vary_over_time_deterministically() {
let (p, cid) = shape_comp(|ids| {
vec![build::shape_ellipse(ids, [60.0, 60.0], [0.0, 0.0]), op(ids, "wiggle", &[("size", Value::Scalar(8.0))]), build::shape_fill(ids, RED)]
});
let a = render_frame(&p, cid, Tick::from_seconds_f64(0.5), 1.0);
let b = render_frame(&p, cid, Tick::from_seconds_f64(0.5), 1.0);
let c = render_frame(&p, cid, Tick::from_seconds_f64(1.0), 1.0);
assert_eq!(a.data, b.data);
assert_ne!(a.data, c.data);
}
#[test]
fn zigzag_and_twist_render() {
let (p, cid) = shape_comp(|ids| {
vec![
build::shape_rect(ids, [60.0, 60.0], [0.0, 0.0], 0.0),
op(ids, "zigzag", &[("size", Value::Scalar(5.0)), ("ridges", Value::Scalar(4.0))]),
op(ids, "twist", &[("angle", Value::Scalar(45.0))]),
build::shape_fill(ids, RED),
]
});
let img = frame(&p, cid);
assert!(alpha(&img, 100, 50) > 0.99);
let cov = coverage(&img);
assert!(cov > 2000.0 && cov < 4600.0, "{cov}");
}
#[test]
fn repeater_draws_match_per_copy_bounds() {
// Many small copies spread over a large buffer: each must rasterise correctly in its own
// sub-rectangle.
let (p, cid) = shape_comp_sized(400, 400, |ids| {
let mut rep = build::shape_repeater(ids, 12.0, [0.0, 0.0]);
set(&mut rep, "transform/rotation", Value::Scalar(30.0));
let items = vec![build::shape_rect(ids, [10.0, 30.0], [0.0, -150.0], 3.0), build::shape_fill(ids, RED)];
let g = build::shape_group(ids, "Tick", items);
vec![g, rep]
});
let img = frame(&p, cid);
for k in 0..12 {
let a = (k as f64 * 30.0).to_radians();
let (x, y) = (200.0 + 150.0 * a.sin(), 200.0 - 150.0 * a.cos());
assert!(alpha(&img, x.round() as i64, y.round() as i64) > 0.99, "copy {k}");
}
assert!(alpha(&img, 200, 200) < 0.01);
let expect = 12.0 * (300.0 - (4.0 - std::f32::consts::PI) * 9.0);
assert!((coverage(&img) - expect).abs() < 40.0, "{}", coverage(&img));
}
/// Performance probe: 60-copy repeater of a small rounded rect at 1920×1080.
/// Run with `cargo test -p effectcraft-render --release -- --ignored --nocapture shape_perf`.
#[test]
#[ignore]
fn shape_perf_repeater_60_copies_1080p() {
let (p, cid) = shape_comp_sized(1920, 1080, |ids| {
let mut rep = build::shape_repeater(ids, 60.0, [0.0, 0.0]);
set(&mut rep, "transform/rotation", Value::Scalar(6.0));
let items = vec![
build::shape_rect(ids, [12.0, 60.0], [0.0, -420.0], 4.0),
build::shape_fill(ids, [1.0, 0.8, 0.2, 1.0]),
build::shape_stroke(ids, [1.0; 4], 2.0),
];
let g = build::shape_group(ids, "Tick", items);
vec![g, rep]
});
let _ = render_frame(&p, cid, Tick::ZERO, 1.0);
let n = 10;
let t0 = std::time::Instant::now();
for _ in 0..n {
let _ = render_frame(&p, cid, Tick::ZERO, 1.0);
}
let ms = t0.elapsed().as_secs_f64() * 1000.0 / n as f64;
println!("shape_perf: 60-copy repeater at 1920x1080: {ms:.2} ms/frame");
let ItemKind::Comp(comp) = &p.item(cid).unwrap().kind else { unreachable!() };
let layer = &comp.layers[0];
let ctx = crate::eval::EvalCtx::new(&p, cid, comp, Tick::ZERO);
let contents = layer.props.sub("contents").unwrap();
let t0 = std::time::Instant::now();
for _ in 0..n {
let _ = super::render(&ctx, layer, contents, 1.0);
}
let ms = t0.elapsed().as_secs_f64() * 1000.0 / n as f64;
println!("shape_perf: shapes::render alone: {ms:.2} ms");
let mut arena = Vec::new();
let t0 = std::time::Instant::now();
for _ in 0..n {
arena.clear();
let _ = super::collect(&ctx, layer, contents, &mut arena);
}
let ms = t0.elapsed().as_secs_f64() * 1000.0 / n as f64;
println!("shape_perf: collect alone: {ms:.2} ms");
let (empty, ecid) = shape_comp_sized(1920, 1080, |_| vec![]);
let t0 = std::time::Instant::now();
for _ in 0..n {
let _ = render_frame(&empty, ecid, Tick::ZERO, 1.0);
}
let ms = t0.elapsed().as_secs_f64() * 1000.0 / n as f64;
println!("shape_perf: empty shape layer comp (compositor baseline): {ms:.2} ms");
}