diff --git a/Cargo.lock b/Cargo.lock index 7b552594..df0a8d0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,6 +19,9 @@ name = "arrayvec" version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +dependencies = [ + "serde", +] [[package]] name = "autocfg" @@ -192,6 +195,7 @@ dependencies = [ "effectcraft-keyframe", "effectcraft-raster", "kurbo", + "linesweeper", "tiny-skia", ] @@ -396,6 +400,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" @@ -626,6 +643,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" diff --git a/crates/path/Cargo.toml b/crates/path/Cargo.toml index f2c8bd16..46cf0a83 100644 --- a/crates/path/Cargo.toml +++ b/crates/path/Cargo.toml @@ -15,3 +15,4 @@ effectcraft-keyframe = { workspace = true } effectcraft-raster = { workspace = true } kurbo = { workspace = true } tiny-skia = { workspace = true } +linesweeper = "0.4" diff --git a/crates/path/src/boolean.rs b/crates/path/src/boolean.rs new file mode 100644 index 00000000..4cdaeb29 --- /dev/null +++ b/crates/path/src/boolean.rs @@ -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); + +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) -> 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, + 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) -> 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, + rules: Vec, + pub(crate) tidy: Tidy, +} + +impl Arrangement { + pub(crate) fn new(paths: &[(BezPath, FillRule)]) -> Option { + let closed: Vec = paths.iter().map(|(p, _)| close_subpaths(p)).collect(); + if !closed.iter().all(finite) { + return None; + } + let eps = eps_for(&closed); + let top = Topology::::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 { + 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 { + let bp = close_subpaths(p); + if !finite(&bp) { + return None; + } + let top = Topology::::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, tidy: &Tidy) -> BezPath { + let mut out = BezPath::new(); + for c in contours { + if is_sliver(c, tidy.precision) { + continue; + } + let segs: Vec = 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, tidy: &Tidy) -> Vec { + 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 = 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 = 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 { + 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 { + 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::().abs(), + None => 0.0, + } +} diff --git a/crates/path/src/fit.rs b/crates/path/src/fit.rs new file mode 100644 index 00000000..b9dbced1 --- /dev/null +++ b/crates/path/src/fit.rs @@ -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 { + 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 { + 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, 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)); + } +} diff --git a/crates/path/src/lib.rs b/crates/path/src/lib.rs index dd5b03fa..1a1ae2ad 100644 --- a/crates/path/src/lib.rs +++ b/crates/path/src/lib.rs @@ -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; diff --git a/crates/path/src/offset.rs b/crates/path/src/offset.rs new file mode 100644 index 00000000..0bca8ca1 --- /dev/null +++ b/crates/path/src/offset.rs @@ -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 = + 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 { + 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 = None; + let mut start = kurbo::Point::ZERO; + let edge = |a: kurbo::Point, b: kurbo::Point, xs: &mut Vec| { + 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() +} diff --git a/crates/path/src/ops.rs b/crates/path/src/ops.rs index 65be3b61..33537ba0 100644 --- a/crates/path/src/ops.rs +++ b/crates/path/src/ops.rs @@ -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 { - p.segments().collect() +// ------------------------------------------------------------------------------------------------ +// Subpath helpers + +/// One subpath as a list of segments. +#[derive(Clone, Debug)] +struct Sub { + segs: Vec, + 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 { + p.subpaths() + .filter_map(|els| { + let closed = matches!(els.last(), Some(PathEl::ClosePath)); + let bp = BezPath::from_vec(els.to_vec()); + let segs: Vec = 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 { + 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 { + 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, + lens: Vec, + total: f64, +} + +impl Measured { + fn new(p: &BezPath) -> Self { + let segs: Vec = p.segments().filter(|s| !degenerate(s)).collect(); + let lens: Vec = 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 { +/// Fractional intervals kept by a trim, or `None` when nothing is trimmed. +fn trim_intervals(start: f64, end: f64, offset_deg: f64) -> Option> { 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 = 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 { - // 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 { - 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 { + 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 { .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 { + let Some(iv) = trim_intervals(start, end, offset_deg) else { return paths.to_vec() }; + let ms: Vec = 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::()).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 { + let a = amount / 100.0; + if a == 0.0 { + return paths.to_vec(); } + paths + .iter() + .map(|p| { + let verts: Vec = 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 { + 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 = 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 { + 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 { + 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 { + let segs = &sub.segs; + let n = segs.len(); + let lens: Vec = 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 { + 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 = 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 { + 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 = 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; diff --git a/crates/path/src/ops/tests.rs b/crates/path/src/ops/tests.rs new file mode 100644 index 00000000..dbdf213b --- /dev/null +++ b/crates/path/src/ops/tests.rs @@ -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 { + 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)); +} diff --git a/crates/project/src/build.rs b/crates/project/src/build.rs index 5d686e71..629e600d 100644 --- a/crates/project/src/build.rs +++ b/crates/project/src/build.rs @@ -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))) diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index 36753e87..f322fe81 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -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, + }, } +/// 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, - /// Group-space → layer-space for gradients and stroke scaling. + slots: Vec, + /// Paint-group space → layer space, for gradients and stroke scaling. xf: Mat3, opacity: f32, + blend: BlendMode, } -fn mat_paths(paths: &[BezPath], m: &Mat3) -> Vec { - 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, Vec) { +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) { + let input: Vec = 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, live: &mut Vec) { + 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 { + let mut v: Vec = 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) -> (Vec, Vec) { let mut draws: Vec = Vec::new(); - let mut acc: Vec = Vec::new(); + let mut live: Vec = 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, 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, 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, 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 = 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, 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> = 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 = (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 = 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 { + 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, kurbo::Rect)> = draws + .iter() + .rev() + .filter(|d| d.opacity > 0.0) + .filter_map(|d| { + let paths: Vec = 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 { + 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 = 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; diff --git a/crates/render/src/shapes/tests.rs b/crates/render/src/shapes/tests.rs new file mode 100644 index 00000000..7f96a32e --- /dev/null +++ b/crates/render/src/shapes/tests.rs @@ -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) -> (Project, ItemId) { + shape_comp_sized(200, 100, items) +} + +fn shape_comp_sized(w: u32, h: u32, items: impl FnOnce(&mut Ids) -> Vec) -> (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"); +}