Merge branch 'worktree-agent-a9f473e3a56abd824'
This commit is contained in:
commit
3feab9db97
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -15,3 +15,4 @@ effectcraft-keyframe = { workspace = true }
|
|||
effectcraft-raster = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
tiny-skia = { workspace = true }
|
||||
linesweeper = "0.4"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,385 @@
|
|||
//! Curve-preserving boolean operations on filled [`BezPath`]s, built on `linesweeper`'s robust
|
||||
//! sweep-line topology (MIT OR Apache-2.0).
|
||||
//!
|
||||
//! Adapted from DrawCraft's `vectorcraft-pathops` (`crates/pathops/src/boolean.rs`; our own code,
|
||||
//! MIT OR Apache-2.0), reworked to take and return kurbo paths directly.
|
||||
//!
|
||||
//! Inputs are closed implicitly (a filled open path is painted as if closed). The sweep splits
|
||||
//! curves at intersections and y-extrema; [`tidy_segments`] re-joins pieces that came from one
|
||||
//! input curve (exactly) or one smooth run (by least-squares refit), so results carry roughly as
|
||||
//! few vertices as the inputs. Output contours are simple and consistently oriented (holes wind
|
||||
//! opposite to their outer contour), so they fill the same under either fill rule.
|
||||
|
||||
use kurbo::{BezPath, CubicBez, PathEl, PathSeg, Point, Shape as _};
|
||||
use linesweeper::topology::{Contours, Topology, WindingNumber};
|
||||
|
||||
use crate::FillRule;
|
||||
use crate::fit::{end_tangent, fit_single, is_straight, sample, start_tangent};
|
||||
|
||||
/// Refit tolerance (pixels) used to merge split curve pieces back together.
|
||||
pub const DEFAULT_PRECISION: f64 = 0.01;
|
||||
|
||||
pub(crate) fn inside(rule: FillRule, w: i32) -> bool {
|
||||
match rule {
|
||||
FillRule::NonZero => w != 0,
|
||||
FillRule::EvenOdd => w % 2 != 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Winding numbers for an arbitrary number of input paths (tag = input index).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct Multi(Vec<i32>);
|
||||
|
||||
impl Multi {
|
||||
fn get(&self, i: usize) -> i32 {
|
||||
self.0.get(i).copied().unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Multi {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
let n = self.0.len().max(other.0.len());
|
||||
(0..n).all(|i| self.get(i) == other.get(i))
|
||||
}
|
||||
}
|
||||
impl Eq for Multi {}
|
||||
|
||||
impl std::ops::Add for Multi {
|
||||
type Output = Multi;
|
||||
fn add(mut self, rhs: Self) -> Multi {
|
||||
self += rhs;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::AddAssign for Multi {
|
||||
fn add_assign(&mut self, rhs: Self) {
|
||||
if self.0.len() < rhs.0.len() {
|
||||
self.0.resize(rhs.0.len(), 0);
|
||||
}
|
||||
for (a, b) in self.0.iter_mut().zip(rhs.0) {
|
||||
*a += b;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WindingNumber for Multi {
|
||||
type Tag = usize;
|
||||
fn single(tag: usize, positive: bool) -> Self {
|
||||
let mut v = vec![0; tag + 1];
|
||||
v[tag] = if positive { 1 } else { -1 };
|
||||
Multi(v)
|
||||
}
|
||||
fn of_tag(&self, tag: usize) -> Self {
|
||||
let mut v = vec![0; tag + 1];
|
||||
v[tag] = self.get(tag);
|
||||
Multi(v)
|
||||
}
|
||||
}
|
||||
|
||||
/// Close every subpath with at least two points; drop single points and non-finite input.
|
||||
pub fn close_subpaths(p: &BezPath) -> BezPath {
|
||||
let mut out = BezPath::new();
|
||||
for sp in p.subpaths() {
|
||||
let drawing = sp.iter().filter(|e| matches!(e, PathEl::LineTo(_) | PathEl::QuadTo(..) | PathEl::CurveTo(..))).count();
|
||||
if drawing == 0 {
|
||||
continue;
|
||||
}
|
||||
for el in sp {
|
||||
if !matches!(el, PathEl::ClosePath) {
|
||||
out.push(*el);
|
||||
}
|
||||
}
|
||||
out.close_path();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn finite(p: &BezPath) -> bool {
|
||||
p.elements().iter().all(|el| match *el {
|
||||
PathEl::MoveTo(a) | PathEl::LineTo(a) => a.is_finite(),
|
||||
PathEl::QuadTo(a, b) => a.is_finite() && b.is_finite(),
|
||||
PathEl::CurveTo(a, b, c) => a.is_finite() && b.is_finite() && c.is_finite(),
|
||||
PathEl::ClosePath => true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Sweep tolerance for a set of paths (scaled with the coordinate magnitude).
|
||||
fn eps_for<'a>(paths: impl IntoIterator<Item = &'a BezPath>) -> f64 {
|
||||
let mut m: f64 = 0.0;
|
||||
for p in paths {
|
||||
let b = p.bounding_box();
|
||||
m = m.max(b.x0.abs()).max(b.x1.abs()).max(b.y0.abs()).max(b.y1.abs());
|
||||
}
|
||||
(m * f64::EPSILON * 64.0).max(1e-6)
|
||||
}
|
||||
|
||||
/// Output cleanup settings: refit `precision`, anchor positions that must survive (the inputs' own
|
||||
/// vertices — only joints the sweep introduced are merged away) and the input curves, used to
|
||||
/// rebuild split pieces exactly.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct Tidy {
|
||||
pub(crate) precision: f64,
|
||||
keep: Vec<Point>,
|
||||
curves: Vec<(kurbo::Rect, CubicBez)>,
|
||||
}
|
||||
|
||||
impl Tidy {
|
||||
/// Merge any smooth joint.
|
||||
pub(crate) fn free(precision: f64) -> Self {
|
||||
Self { precision, keep: Vec::new(), curves: Vec::new() }
|
||||
}
|
||||
/// Keep every on-curve point of `paths`.
|
||||
pub(crate) fn keeping<'a>(precision: f64, paths: impl IntoIterator<Item = &'a BezPath>) -> Self {
|
||||
let mut keep = Vec::new();
|
||||
let mut curves = Vec::new();
|
||||
for p in paths {
|
||||
for s in p.segments() {
|
||||
let seg = to_seg(s);
|
||||
keep.push(seg.c.p0);
|
||||
if !seg.line {
|
||||
curves.push((seg.c.bounding_box().inflate(1e-6, 1e-6), seg.c));
|
||||
}
|
||||
}
|
||||
}
|
||||
keep.sort_by(|a, b| a.x.total_cmp(&b.x));
|
||||
Self { precision, keep, curves }
|
||||
}
|
||||
fn is_kept(&self, p: Point) -> bool {
|
||||
let tol = 1e-6 * (1.0 + p.x.abs().max(p.y.abs()));
|
||||
let lo = self.keep.partition_point(|q| q.x < p.x - tol);
|
||||
self.keep[lo..].iter().take_while(|q| q.x <= p.x + tol).any(|q| (q.y - p.y).abs() <= tol)
|
||||
}
|
||||
}
|
||||
|
||||
/// An arrangement (planar subdivision) of N filled paths.
|
||||
pub(crate) struct Arrangement {
|
||||
top: Topology<Multi>,
|
||||
rules: Vec<FillRule>,
|
||||
pub(crate) tidy: Tidy,
|
||||
}
|
||||
|
||||
impl Arrangement {
|
||||
pub(crate) fn new(paths: &[(BezPath, FillRule)]) -> Option<Self> {
|
||||
let closed: Vec<BezPath> = paths.iter().map(|(p, _)| close_subpaths(p)).collect();
|
||||
if !closed.iter().all(finite) {
|
||||
return None;
|
||||
}
|
||||
let eps = eps_for(&closed);
|
||||
let top = Topology::<Multi>::from_paths(closed.iter().enumerate().map(|(i, p)| (p, i)), eps).ok()?;
|
||||
let tidy = Tidy::keeping(DEFAULT_PRECISION, &closed);
|
||||
Some(Self { top, rules: paths.iter().map(|p| p.1).collect(), tidy })
|
||||
}
|
||||
|
||||
fn mask(&self, w: &Multi) -> Vec<bool> {
|
||||
self.rules.iter().enumerate().map(|(i, &r)| inside(r, w.get(i))).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn contours(&self, pred: impl Fn(&[bool]) -> bool) -> Contours {
|
||||
self.top.contours(|w| pred(&self.mask(w)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Contours of a single path under its fill rule (self-overlaps resolved).
|
||||
pub(crate) fn normalize_contours(p: &BezPath, rule: FillRule) -> Option<Contours> {
|
||||
let bp = close_subpaths(p);
|
||||
if !finite(&bp) {
|
||||
return None;
|
||||
}
|
||||
let top = Topology::<i32>::from_path(&bp, eps_for([&bp])).ok()?;
|
||||
Some(top.contours(|w| inside(rule, *w)))
|
||||
}
|
||||
|
||||
/// Contours whose mean width (2·area / perimeter) is below `precision` are numerical debris.
|
||||
fn is_sliver(bp: &BezPath, precision: f64) -> bool {
|
||||
let a = bp.area().abs();
|
||||
let per = bp.perimeter(1e-6);
|
||||
per <= 0.0 || 2.0 * a / per < precision
|
||||
}
|
||||
|
||||
/// Convert selected contours into one tidy compound path.
|
||||
pub(crate) fn contours_to_path<'a>(contours: impl IntoIterator<Item = &'a BezPath>, tidy: &Tidy) -> BezPath {
|
||||
let mut out = BezPath::new();
|
||||
for c in contours {
|
||||
if is_sliver(c, tidy.precision) {
|
||||
continue;
|
||||
}
|
||||
let segs: Vec<Seg> = c.segments().map(to_seg).collect();
|
||||
let segs = tidy_segments(segs, tidy);
|
||||
if segs.len() < 2 && segs.iter().all(|s| s.line) {
|
||||
continue;
|
||||
}
|
||||
out.move_to(segs[0].c.p0);
|
||||
for s in &segs {
|
||||
if s.line {
|
||||
out.line_to(s.c.p3);
|
||||
} else {
|
||||
out.curve_to(s.c.p1, s.c.p2, s.c.p3);
|
||||
}
|
||||
}
|
||||
out.close_path();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn all_contours(c: &Contours, tidy: &Tidy) -> BezPath {
|
||||
contours_to_path(c.contours().map(|k| &k.path), tidy)
|
||||
}
|
||||
|
||||
/// One segment of a path: a cubic plus whether it is a straight line.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Seg {
|
||||
pub(crate) c: CubicBez,
|
||||
pub(crate) line: bool,
|
||||
}
|
||||
|
||||
impl Seg {
|
||||
fn line(a: Point, b: Point) -> Self {
|
||||
Self { c: CubicBez::new(a, a, b, b), line: true }
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn to_seg(s: PathSeg) -> Seg {
|
||||
match s {
|
||||
PathSeg::Line(l) => Seg::line(l.p0, l.p1),
|
||||
PathSeg::Quad(q) => {
|
||||
let c = q.raise();
|
||||
Seg { c, line: is_straight(&c, 1e-9) }
|
||||
}
|
||||
PathSeg::Cubic(c) => Seg { c, line: is_straight(&c, 1e-9 * (1.0 + c.p0.distance(c.p3))) },
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge segments of a closed loop that were split from one curve (or collinear lines) back
|
||||
/// together, dropping degenerate pieces.
|
||||
fn tidy_segments(segs: Vec<Seg>, tidy: &Tidy) -> Vec<Seg> {
|
||||
let scale = segs.iter().map(|s| s.c.p0.distance(s.c.p3)).fold(0.0, f64::max);
|
||||
let tiny = (scale * 1e-9).max(1e-9);
|
||||
let mut segs: Vec<Seg> = segs
|
||||
.into_iter()
|
||||
.filter(|s| {
|
||||
let r = kurbo::Rect::from_points(s.c.p0, s.c.p3).union_pt(s.c.p1).union_pt(s.c.p2);
|
||||
r.width() + r.height() > tiny
|
||||
})
|
||||
.collect();
|
||||
let n = segs.len();
|
||||
if n < 2 {
|
||||
return segs;
|
||||
}
|
||||
// Joint i sits at the start of segment i.
|
||||
let mergeable = |a: &Seg, b: &Seg| -> bool {
|
||||
if a.line != b.line || tidy.is_kept(b.c.p0) {
|
||||
return false;
|
||||
}
|
||||
let ta = end_tangent(&a.c);
|
||||
let tb = start_tangent(&b.c);
|
||||
ta.dot(tb) > 0.9998 && ta.cross(tb).abs() < 0.02
|
||||
};
|
||||
// Rotate so we start at a real corner (if there is one).
|
||||
if let Some(k) = (0..n).find(|&i| !mergeable(&segs[(i + n - 1) % n], &segs[i])) {
|
||||
segs.rotate_left(k);
|
||||
}
|
||||
let mut out: Vec<Seg> = Vec::with_capacity(n);
|
||||
let mut i = 0;
|
||||
while i < n {
|
||||
let mut cur = segs[i];
|
||||
let mut j = i + 1;
|
||||
while j < n && mergeable(&segs[j - 1], &segs[j]) {
|
||||
match try_merge(&segs[i..=j], tidy) {
|
||||
Some(m) => {
|
||||
cur = m;
|
||||
j += 1;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
out.push(cur);
|
||||
i = j;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn try_merge(run: &[Seg], tidy: &Tidy) -> Option<Seg> {
|
||||
let precision = tidy.precision;
|
||||
let first = run[0].c;
|
||||
let last = run[run.len() - 1].c;
|
||||
if run[0].line {
|
||||
let chord = last.p3 - first.p0;
|
||||
let len = chord.hypot();
|
||||
if len < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let ok = run.iter().all(|s| ((s.c.p3 - first.p0).cross(chord) / len).abs() <= precision * 0.5);
|
||||
return ok.then(|| Seg::line(first.p0, last.p3));
|
||||
}
|
||||
if let Some(c) = exact_merge(run, tidy) {
|
||||
return Some(Seg { c, line: false });
|
||||
}
|
||||
let mut pts = Vec::with_capacity(run.len() * 12 + 1);
|
||||
for (k, s) in run.iter().enumerate() {
|
||||
sample(&s.c, 12, &mut pts, k == 0);
|
||||
}
|
||||
let (c, err, _) = fit_single(&pts, start_tangent(&first), end_tangent(&last));
|
||||
if err > precision {
|
||||
return None;
|
||||
}
|
||||
Some(Seg { c, line: false })
|
||||
}
|
||||
|
||||
/// If every piece of `run` lies on one input cubic, return the exact sub-curve spanning the run.
|
||||
fn exact_merge(run: &[Seg], tidy: &Tidy) -> Option<CubicBez> {
|
||||
use kurbo::{ParamCurve, ParamCurveNearest};
|
||||
let a = run[0].c.p0;
|
||||
let b = run[run.len() - 1].c.p3;
|
||||
let scale = 1.0 + a.x.abs().max(a.y.abs()).max(b.x.abs()).max(b.y.abs());
|
||||
let tol = 1e-6 * scale;
|
||||
for (r, c) in &tidy.curves {
|
||||
if !r.contains(a) || !r.contains(b) {
|
||||
continue;
|
||||
}
|
||||
let na = c.nearest(a, 1e-12);
|
||||
let nb = c.nearest(b, 1e-12);
|
||||
if na.distance_sq.sqrt() > tol || nb.distance_sq.sqrt() > tol {
|
||||
continue;
|
||||
}
|
||||
let on_curve = run.iter().all(|s| c.nearest(s.c.eval(0.5), 1e-12).distance_sq.sqrt() <= tol * 10.0);
|
||||
if !on_curve || (na.t - nb.t).abs() < 1e-12 {
|
||||
continue;
|
||||
}
|
||||
let sub = if na.t < nb.t {
|
||||
c.subsegment(na.t..nb.t)
|
||||
} else {
|
||||
let r = c.subsegment(nb.t..na.t);
|
||||
CubicBez::new(r.p3, r.p2, r.p1, r.p0)
|
||||
};
|
||||
return Some(CubicBez::new(a, sub.p1, sub.p2, b));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// N-ary boolean: the region where `pred(inside_flags)` holds, `inside_flags[i]` telling whether a
|
||||
/// point is inside `paths[i]` under its fill rule. Returns one compound path.
|
||||
pub fn boolean_n(paths: &[(BezPath, FillRule)], pred: impl Fn(&[bool]) -> bool) -> BezPath {
|
||||
match Arrangement::new(paths) {
|
||||
Some(arr) => all_contours(&arr.contours(pred), &arr.tidy),
|
||||
None => BezPath::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve self-intersections / overlaps of one filled path into simple, consistently oriented
|
||||
/// contours.
|
||||
pub fn normalize(path: &BezPath, rule: FillRule) -> BezPath {
|
||||
match normalize_contours(path, rule) {
|
||||
Some(c) => all_contours(&c, &Tidy::keeping(DEFAULT_PRECISION, [path])),
|
||||
None => BezPath::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Unsigned filled area of a path under a fill rule (normalised first, so it is exact for
|
||||
/// self-overlapping input).
|
||||
pub fn filled_area(path: &BezPath, rule: FillRule) -> f64 {
|
||||
match normalize_contours(path, rule) {
|
||||
Some(c) => c.contours().map(|ct| ct.path.area()).sum::<f64>().abs(),
|
||||
None => 0.0,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
//! Adapted from DrawCraft's `vectorcraft-pathops` (`crates/pathops/src/fit.rs`; our own code,
|
||||
//! MIT OR Apache-2.0).
|
||||
//!
|
||||
//! Least-squares cubic Bézier fitting of point sequences (the classic "fit a cubic with fixed end
|
||||
//! tangents, then reparameterise with Newton steps, then split at the worst point" approach from
|
||||
//! Graphics Gems, written from the published description).
|
||||
|
||||
use kurbo::{CubicBez, ParamCurve, ParamCurveDeriv, Point, Vec2};
|
||||
|
||||
/// Maximum Newton reparameterisation rounds per fit.
|
||||
const MAX_REPARAM: usize = 6;
|
||||
|
||||
/// Unit vector or `None` if (nearly) zero.
|
||||
pub(crate) fn unit(v: Vec2) -> Option<Vec2> {
|
||||
let l = v.hypot();
|
||||
if l > 1e-12 && l.is_finite() { Some(v / l) } else { None }
|
||||
}
|
||||
|
||||
/// Direction the curve leaves `p0` (robust to retracted handles).
|
||||
pub(crate) fn start_tangent(c: &CubicBez) -> Vec2 {
|
||||
unit(c.p1 - c.p0).or_else(|| unit(c.p2 - c.p0)).or_else(|| unit(c.p3 - c.p0)).unwrap_or(Vec2::new(1.0, 0.0))
|
||||
}
|
||||
|
||||
/// Direction the curve arrives at `p3`, i.e. pointing *forward* along the travel direction.
|
||||
pub(crate) fn end_tangent(c: &CubicBez) -> Vec2 {
|
||||
unit(c.p3 - c.p2).or_else(|| unit(c.p3 - c.p1)).or_else(|| unit(c.p3 - c.p0)).unwrap_or(Vec2::new(1.0, 0.0))
|
||||
}
|
||||
|
||||
/// Best single cubic for `pts` with the given end tangents. Returns (curve, max error, index of
|
||||
/// the worst point).
|
||||
pub(crate) fn fit_single(pts: &[Point], t0: Vec2, t1: Vec2) -> (CubicBez, f64, usize) {
|
||||
let mut u = chord_params(pts);
|
||||
let mut best = generate(pts, &u, t0, t1);
|
||||
let (mut best_err, mut best_split) = max_error(pts, &best, &u);
|
||||
for _ in 0..MAX_REPARAM {
|
||||
for (i, ui) in u.iter_mut().enumerate() {
|
||||
*ui = newton(&best, pts[i], *ui);
|
||||
}
|
||||
let c = generate(pts, &u, t0, t1);
|
||||
let (e, s) = max_error(pts, &c, &u);
|
||||
if e < best_err {
|
||||
best = c;
|
||||
best_err = e;
|
||||
best_split = s;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
(best, best_err, best_split)
|
||||
}
|
||||
|
||||
fn chord_params(pts: &[Point]) -> Vec<f64> {
|
||||
let mut u = Vec::with_capacity(pts.len());
|
||||
let mut acc = 0.0;
|
||||
u.push(0.0);
|
||||
for w in pts.windows(2) {
|
||||
acc += w[0].distance(w[1]);
|
||||
u.push(acc);
|
||||
}
|
||||
if acc > 0.0 {
|
||||
for x in &mut u {
|
||||
*x /= acc;
|
||||
}
|
||||
}
|
||||
u
|
||||
}
|
||||
|
||||
fn bern(t: f64) -> [f64; 4] {
|
||||
let s = 1.0 - t;
|
||||
[s * s * s, 3.0 * s * s * t, 3.0 * s * t * t, t * t * t]
|
||||
}
|
||||
|
||||
/// Solve the 2x2 normal equations for the handle lengths along the fixed tangents.
|
||||
fn generate(pts: &[Point], u: &[f64], t0: Vec2, t1: Vec2) -> CubicBez {
|
||||
let p0 = pts[0];
|
||||
let p3 = *pts.last().unwrap();
|
||||
let dir_in = -t1; // handle at the end points backwards
|
||||
let (mut c00, mut c01, mut c11, mut x0, mut x1) = (0.0, 0.0, 0.0, 0.0, 0.0);
|
||||
for (p, &t) in pts.iter().zip(u) {
|
||||
let b = bern(t);
|
||||
let a0 = t0 * b[1];
|
||||
let a1 = dir_in * b[2];
|
||||
c00 += a0.dot(a0);
|
||||
c01 += a0.dot(a1);
|
||||
c11 += a1.dot(a1);
|
||||
let tmp = *p - (p0.to_vec2() * (b[0] + b[1]) + p3.to_vec2() * (b[2] + b[3])).to_point();
|
||||
x0 += a0.dot(tmp);
|
||||
x1 += a1.dot(tmp);
|
||||
}
|
||||
let det = c00 * c11 - c01 * c01;
|
||||
let seg = p0.distance(p3);
|
||||
let eps = 1e-6 * seg;
|
||||
let (mut al, mut ar) = if det.abs() > 1e-12 { ((x0 * c11 - x1 * c01) / det, (c00 * x1 - c01 * x0) / det) } else { (seg / 3.0, seg / 3.0) };
|
||||
if !al.is_finite() || !ar.is_finite() || al < eps || ar < eps {
|
||||
al = seg / 3.0;
|
||||
ar = seg / 3.0;
|
||||
}
|
||||
// Guard against wild handles on nearly-degenerate data.
|
||||
let cap = seg * 4.0 + 1e-9;
|
||||
al = al.min(cap);
|
||||
ar = ar.min(cap);
|
||||
CubicBez::new(p0, p0 + t0 * al, p3 + dir_in * ar, p3)
|
||||
}
|
||||
|
||||
fn max_error(pts: &[Point], c: &CubicBez, u: &[f64]) -> (f64, usize) {
|
||||
let mut e = 0.0;
|
||||
let mut idx = pts.len() / 2;
|
||||
for (i, (p, &t)) in pts.iter().zip(u).enumerate() {
|
||||
let d = c.eval(t).distance(*p);
|
||||
if d > e {
|
||||
e = d;
|
||||
idx = i;
|
||||
}
|
||||
}
|
||||
(e, idx)
|
||||
}
|
||||
|
||||
fn newton(c: &CubicBez, p: Point, t: f64) -> f64 {
|
||||
let d1 = c.deriv();
|
||||
let d2 = d1.deriv();
|
||||
let q = c.eval(t) - p;
|
||||
let q1 = d1.eval(t).to_vec2();
|
||||
let q2 = d2.eval(t).to_vec2();
|
||||
let num = q.dot(q1);
|
||||
let den = q1.dot(q1) + q.dot(q2);
|
||||
if den.abs() < 1e-12 {
|
||||
return t;
|
||||
}
|
||||
let nt = t - num / den;
|
||||
if nt.is_finite() { nt.clamp(0.0, 1.0) } else { t }
|
||||
}
|
||||
|
||||
/// Is the cubic (within `tol`) a straight line from p0 to p3 with handles inside the chord?
|
||||
pub(crate) fn is_straight(c: &CubicBez, tol: f64) -> bool {
|
||||
let chord = c.p3 - c.p0;
|
||||
let len = chord.hypot();
|
||||
if len < 1e-12 {
|
||||
return c.p1.distance(c.p0) <= tol && c.p2.distance(c.p0) <= tol;
|
||||
}
|
||||
let dir = chord / len;
|
||||
[c.p1, c.p2].iter().all(|&h| {
|
||||
let v = h - c.p0;
|
||||
let along = v.dot(dir);
|
||||
v.cross(dir).abs() <= tol && along >= -tol && along <= len + tol
|
||||
})
|
||||
}
|
||||
|
||||
/// Sample a cubic at `n+1` evenly spaced parameters (including both ends).
|
||||
pub(crate) fn sample(c: &CubicBez, n: usize, out: &mut Vec<Point>, include_start: bool) {
|
||||
let start = if include_start { 0 } else { 1 };
|
||||
for i in start..=n {
|
||||
out.push(c.eval(i as f64 / n as f64));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,138 @@
|
|||
//! Offset Paths: grow or shrink the filled region of a path by a distance, with true curve
|
||||
//! offsetting (kurbo's stroker offsets curves analytically and refits them) and robust overlap
|
||||
//! removal (the boolean sweep).
|
||||
//!
|
||||
//! Adapted from DrawCraft's `vectorcraft-pathops` (`crates/pathops/src/offset.rs`; our own code,
|
||||
//! MIT OR Apache-2.0).
|
||||
|
||||
use kurbo::{BezPath, PathEl, Stroke, StrokeOpts};
|
||||
use linesweeper::topology::{ContourIdx, Contours};
|
||||
|
||||
use crate::boolean::{Arrangement, DEFAULT_PRECISION, Tidy, contours_to_path};
|
||||
use crate::{FillRule, Join};
|
||||
|
||||
/// Flattening/fit tolerance handed to kurbo's stroker.
|
||||
const STROKE_TOL: f64 = 1e-3;
|
||||
|
||||
fn kjoin(j: Join) -> kurbo::Join {
|
||||
match j {
|
||||
Join::Miter => kurbo::Join::Miter,
|
||||
Join::Round => kurbo::Join::Round,
|
||||
Join::Bevel => kurbo::Join::Bevel,
|
||||
}
|
||||
}
|
||||
|
||||
fn stroke_bez(bp: &BezPath, width: f64, join: Join, miter_limit: f64) -> BezPath {
|
||||
let style = Stroke::new(width).with_join(kjoin(join)).with_caps(kurbo::Cap::Butt).with_miter_limit(miter_limit.max(1.0));
|
||||
crate::boolean::close_subpaths(&kurbo::stroke(bp.iter(), &style, &StrokeOpts::default(), STROKE_TOL))
|
||||
}
|
||||
|
||||
/// Split a path into its closed and open subpaths.
|
||||
fn split_closed(p: &BezPath) -> (BezPath, BezPath) {
|
||||
let (mut closed, mut open) = (BezPath::new(), BezPath::new());
|
||||
for sp in p.subpaths() {
|
||||
let n = sp.iter().filter(|e| !matches!(e, PathEl::MoveTo(_) | PathEl::ClosePath)).count();
|
||||
if n == 0 {
|
||||
continue;
|
||||
}
|
||||
let dst = if matches!(sp.last(), Some(PathEl::ClosePath)) { &mut closed } else { &mut open };
|
||||
dst.extend(sp.iter().copied());
|
||||
}
|
||||
(closed, open)
|
||||
}
|
||||
|
||||
/// Offset a path by `delta`: positive grows the (non-zero) filled region, negative insets it.
|
||||
/// Closed subpaths are treated as a filled region; open subpaths are outlined with a stroke of
|
||||
/// width `2|delta|`. The result is a clean compound path of closed contours.
|
||||
pub fn offset_path(path: &BezPath, delta: f64, join: Join, miter_limit: f64) -> BezPath {
|
||||
if !delta.is_finite() || path.elements().is_empty() {
|
||||
return BezPath::new();
|
||||
}
|
||||
if delta == 0.0 {
|
||||
return path.clone();
|
||||
}
|
||||
let (closed, open) = split_closed(path);
|
||||
let d = delta.abs();
|
||||
let ring = if closed.elements().is_empty() { BezPath::new() } else { stroke_bez(&closed, 2.0 * d, join, miter_limit) };
|
||||
let open_bp = if open.elements().is_empty() { BezPath::new() } else { stroke_bez(&open, 2.0 * d, join, miter_limit) };
|
||||
let Some(arr) = Arrangement::new(&[(closed, FillRule::NonZero), (ring, FillRule::NonZero), (open_bp, FillRule::NonZero)]) else {
|
||||
return BezPath::new();
|
||||
};
|
||||
let c = if delta >= 0.0 { arr.contours(|m| m[0] || m[1] || m[2]) } else { arr.contours(|m| m[0] && !m[1]) };
|
||||
let tidy = Tidy::free(DEFAULT_PRECISION);
|
||||
// When `d` exceeds a curvature radius the stroker's inner offset inverts and its loops cancel
|
||||
// winding, leaving faces uncovered that are really within `d` of the path. A contour is
|
||||
// spurious iff a point deep inside it lies closer than `d`: holes when growing, islands when
|
||||
// insetting.
|
||||
let drop_outer = delta < 0.0;
|
||||
let spurious: Vec<bool> =
|
||||
c.contours().map(|k| k.outer == drop_outer && deep_point(&k.path).is_some_and(|p| dist_to(path, p) < d * (1.0 - 1e-4) - 1e-6)).collect();
|
||||
let keep: Vec<&BezPath> = (0..spurious.len()).filter(|&i| !has_marked_ancestor(&c, i, &spurious)).map(|i| &c[ContourIdx(i)].path).collect();
|
||||
contours_to_path(keep, &tidy)
|
||||
}
|
||||
|
||||
fn has_marked_ancestor(c: &Contours, mut i: usize, marked: &[bool]) -> bool {
|
||||
loop {
|
||||
if marked[i] {
|
||||
return true;
|
||||
}
|
||||
match c[ContourIdx(i)].parent {
|
||||
Some(ContourIdx(p)) => i = p,
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A point well inside a closed contour: the midpoint of the widest span on a few horizontal
|
||||
/// lines through it.
|
||||
fn deep_point(bp: &BezPath) -> Option<kurbo::Point> {
|
||||
use kurbo::Shape;
|
||||
let r = bp.bounding_box();
|
||||
if !(r.width() > 0.0 && r.height() > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let mut best: Option<(f64, kurbo::Point)> = None;
|
||||
for f in [0.5, 0.3, 0.7] {
|
||||
let y = r.y0 + r.height() * f;
|
||||
let mut xs = vec![];
|
||||
let mut prev: Option<kurbo::Point> = None;
|
||||
let mut start = kurbo::Point::ZERO;
|
||||
let edge = |a: kurbo::Point, b: kurbo::Point, xs: &mut Vec<f64>| {
|
||||
if (a.y <= y) != (b.y <= y) {
|
||||
xs.push(a.x + (y - a.y) / (b.y - a.y) * (b.x - a.x));
|
||||
}
|
||||
};
|
||||
kurbo::flatten(bp.iter(), 0.01 * r.width().min(r.height()).max(1e-6), |el| match el {
|
||||
PathEl::MoveTo(p) => {
|
||||
start = p;
|
||||
prev = Some(p);
|
||||
}
|
||||
PathEl::LineTo(p) => {
|
||||
if let Some(a) = prev {
|
||||
edge(a, p, &mut xs);
|
||||
}
|
||||
prev = Some(p);
|
||||
}
|
||||
PathEl::ClosePath => {
|
||||
if let Some(a) = prev {
|
||||
edge(a, start, &mut xs);
|
||||
}
|
||||
prev = Some(start);
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
xs.sort_by(f64::total_cmp);
|
||||
for w in xs.chunks_exact(2) {
|
||||
let span = w[1] - w[0];
|
||||
if best.is_none_or(|(b, _)| span > b) {
|
||||
best = Some((span, kurbo::Point::new((w[0] + w[1]) / 2.0, y)));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, p)| p)
|
||||
}
|
||||
|
||||
fn dist_to(bp: &BezPath, p: kurbo::Point) -> f64 {
|
||||
use kurbo::ParamCurveNearest;
|
||||
bp.segments().map(|s| s.nearest(p, 1e-6).distance_sq).fold(f64::INFINITY, f64::min).sqrt()
|
||||
}
|
||||
|
|
@ -1,17 +1,46 @@
|
|||
//! Shape-layer path operations.
|
||||
//! Shape-layer path operators (After Effects "Add ▸" path operations).
|
||||
//!
|
||||
//! Every operator maps a list of paths to a list of paths of the same length (one entry per shape
|
||||
//! above the operator), except [`merge`], which combines them into one. Paths are in the space of
|
||||
//! the shape group the operator lives in.
|
||||
|
||||
use kurbo::{BezPath, ParamCurve, ParamCurveArclen, PathSeg};
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use kurbo::{BezPath, ParamCurve, ParamCurveArclen, ParamCurveDeriv, PathEl, PathSeg, Point, Vec2};
|
||||
|
||||
use crate::fit::{end_tangent, start_tangent, unit};
|
||||
use crate::{FillRule, Join};
|
||||
|
||||
const ACC: f64 = 1e-3;
|
||||
|
||||
fn segments(p: &BezPath) -> Vec<PathSeg> {
|
||||
p.segments().collect()
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
// Subpath helpers
|
||||
|
||||
/// One subpath as a list of segments.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Sub {
|
||||
segs: Vec<PathSeg>,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
fn append(out: &mut BezPath, seg: PathSeg, start: bool) {
|
||||
if start {
|
||||
out.move_to(seg.start());
|
||||
}
|
||||
fn degenerate(s: &PathSeg) -> bool {
|
||||
let c = s.to_cubic();
|
||||
let d = |a: Point, b: Point| a.distance(b) < 1e-9;
|
||||
d(c.p0, c.p1) && d(c.p0, c.p2) && d(c.p0, c.p3)
|
||||
}
|
||||
|
||||
fn subs(p: &BezPath) -> Vec<Sub> {
|
||||
p.subpaths()
|
||||
.filter_map(|els| {
|
||||
let closed = matches!(els.last(), Some(PathEl::ClosePath));
|
||||
let bp = BezPath::from_vec(els.to_vec());
|
||||
let segs: Vec<PathSeg> = bp.segments().filter(|s| !degenerate(s)).collect();
|
||||
(!segs.is_empty()).then_some(Sub { segs, closed })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn push_seg(out: &mut BezPath, seg: PathSeg) {
|
||||
match seg {
|
||||
PathSeg::Line(l) => out.line_to(l.p1),
|
||||
PathSeg::Quad(q) => out.quad_to(q.p1, q.p2),
|
||||
|
|
@ -19,90 +48,128 @@ fn append(out: &mut BezPath, seg: PathSeg, start: bool) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Portion of a path between arc-length fractions `a..b` (0..1, a ≤ b).
|
||||
fn sub_path(segs: &[PathSeg], lens: &[f64], total: f64, a: f64, b: f64, out: &mut BezPath) {
|
||||
let (sa, sb) = (a * total, b * total);
|
||||
let mut acc = 0.0;
|
||||
let mut started = false;
|
||||
for (seg, &len) in segs.iter().zip(lens) {
|
||||
let (s0, s1) = (acc, acc + len);
|
||||
acc = s1;
|
||||
if s1 <= sa || s0 >= sb || len <= 0.0 {
|
||||
continue;
|
||||
fn seg_start_tan(s: &PathSeg) -> Vec2 {
|
||||
start_tangent(&s.to_cubic())
|
||||
}
|
||||
|
||||
fn seg_end_tan(s: &PathSeg) -> Vec2 {
|
||||
end_tangent(&s.to_cubic())
|
||||
}
|
||||
|
||||
/// Unit tangent of a segment at parameter `t` (robust at retracted handles).
|
||||
fn seg_tan_at(s: &PathSeg, t: f64) -> Vec2 {
|
||||
let c = s.to_cubic();
|
||||
if t <= 1e-9 {
|
||||
return start_tangent(&c);
|
||||
}
|
||||
if t >= 1.0 - 1e-9 {
|
||||
return end_tangent(&c);
|
||||
}
|
||||
unit(c.deriv().eval(t).to_vec2()).unwrap_or_else(|| start_tangent(&c))
|
||||
}
|
||||
|
||||
fn perp(v: Vec2) -> Vec2 {
|
||||
Vec2::new(-v.y, v.x)
|
||||
}
|
||||
|
||||
fn last_point(p: &BezPath) -> Option<Point> {
|
||||
p.elements().last().and_then(|e| e.end_point())
|
||||
}
|
||||
|
||||
/// Reverse the direction of every subpath (the Path Direction toggle).
|
||||
pub fn reverse(paths: &[BezPath]) -> Vec<BezPath> {
|
||||
paths.iter().map(|p| p.reverse_subpaths()).collect()
|
||||
}
|
||||
|
||||
/// Total arc length of a path.
|
||||
pub fn length(p: &BezPath) -> f64 {
|
||||
p.segments().map(|s| s.arclen(ACC)).sum()
|
||||
}
|
||||
|
||||
/// Number of on-curve vertices of a path (closing duplicates excluded).
|
||||
pub fn vertex_count(p: &BezPath) -> usize {
|
||||
subs(p).iter().map(|s| s.segs.len() + usize::from(!s.closed)).sum()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
// Trim Paths
|
||||
|
||||
/// A path measured for trimming.
|
||||
struct Measured {
|
||||
segs: Vec<PathSeg>,
|
||||
lens: Vec<f64>,
|
||||
total: f64,
|
||||
}
|
||||
|
||||
impl Measured {
|
||||
fn new(p: &BezPath) -> Self {
|
||||
let segs: Vec<PathSeg> = p.segments().filter(|s| !degenerate(s)).collect();
|
||||
let lens: Vec<f64> = segs.iter().map(|s| s.arclen(ACC)).collect();
|
||||
let total = lens.iter().sum();
|
||||
Measured { segs, lens, total }
|
||||
}
|
||||
|
||||
/// Append the portion between absolute lengths `sa..sb`, continuing the current subpath when
|
||||
/// the piece starts where `out` ends (e.g. across the start point of a closed path).
|
||||
fn append(&self, sa: f64, sb: f64, out: &mut BezPath) {
|
||||
let mut acc = 0.0;
|
||||
for (seg, &len) in self.segs.iter().zip(&self.lens) {
|
||||
let (s0, s1) = (acc, acc + len);
|
||||
acc = s1;
|
||||
if s1 <= sa || s0 >= sb || len <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let t0 = if sa > s0 { seg.inv_arclen(sa - s0, ACC) } else { 0.0 };
|
||||
let t1 = if sb < s1 { seg.inv_arclen(sb - s0, ACC) } else { 1.0 };
|
||||
if t1 <= t0 {
|
||||
continue;
|
||||
}
|
||||
let piece = seg.subsegment(t0..t1);
|
||||
if last_point(out).is_none_or(|q| q.distance(piece.start()) > 1e-6) {
|
||||
out.move_to(piece.start());
|
||||
}
|
||||
push_seg(out, piece);
|
||||
}
|
||||
let t0 = if sa > s0 { seg.inv_arclen(sa - s0, ACC) } else { 0.0 };
|
||||
let t1 = if sb < s1 { seg.inv_arclen(sb - s0, ACC) } else { 1.0 };
|
||||
let piece = seg.subsegment(t0..t1);
|
||||
append(out, piece, !started);
|
||||
started = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Trim Paths on each path individually: `start`/`end` in percent, `offset` in degrees.
|
||||
pub fn trim(paths: &[BezPath], start: f64, end: f64, offset_deg: f64) -> Vec<BezPath> {
|
||||
/// Fractional intervals kept by a trim, or `None` when nothing is trimmed.
|
||||
fn trim_intervals(start: f64, end: f64, offset_deg: f64) -> Option<Vec<(f64, f64)>> {
|
||||
let (mut s, mut e) = (start / 100.0, end / 100.0);
|
||||
if s > e {
|
||||
std::mem::swap(&mut s, &mut e);
|
||||
}
|
||||
if e - s >= 1.0 - 1e-9 && offset_deg == 0.0 {
|
||||
return paths.to_vec();
|
||||
let (s, e) = (s.clamp(0.0, 1.0), e.clamp(0.0, 1.0));
|
||||
if e - s >= 1.0 - 1e-9 {
|
||||
return None;
|
||||
}
|
||||
if e - s <= 1e-9 {
|
||||
return vec![];
|
||||
return Some(vec![]);
|
||||
}
|
||||
let off = (offset_deg / 360.0).rem_euclid(1.0);
|
||||
let (s, e) = (s + off, e + off);
|
||||
paths
|
||||
.iter()
|
||||
.filter_map(|p| {
|
||||
let segs = segments(p);
|
||||
let lens: Vec<f64> = segs.iter().map(|s| s.arclen(ACC)).collect();
|
||||
let total: f64 = lens.iter().sum();
|
||||
if total <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let mut out = BezPath::new();
|
||||
if e <= 1.0 {
|
||||
sub_path(&segs, &lens, total, s, e, &mut out);
|
||||
} else if s >= 1.0 {
|
||||
sub_path(&segs, &lens, total, s - 1.0, e - 1.0, &mut out);
|
||||
} else {
|
||||
sub_path(&segs, &lens, total, s, 1.0, &mut out);
|
||||
sub_path(&segs, &lens, total, 0.0, e - 1.0, &mut out);
|
||||
}
|
||||
Some(out)
|
||||
})
|
||||
.collect()
|
||||
Some(if e <= 1.0 {
|
||||
vec![(s, e)]
|
||||
} else if s >= 1.0 {
|
||||
vec![(s - 1.0, e - 1.0)]
|
||||
} else {
|
||||
vec![(s, 1.0), (0.0, e - 1.0)]
|
||||
})
|
||||
}
|
||||
|
||||
/// Trim Paths "Simultaneously" across several paths: treated as one continuous length.
|
||||
pub fn trim_simultaneous(paths: &[BezPath], start: f64, end: f64, offset_deg: f64) -> Vec<BezPath> {
|
||||
// AE's "Simultaneously" trims each path by the same fractions.
|
||||
trim(paths, start, end, offset_deg)
|
||||
}
|
||||
|
||||
/// Pucker & Bloat: move vertices towards (negative) / away from (positive) the centre while
|
||||
/// pulling control points the other way.
|
||||
pub fn pucker_bloat(paths: &[BezPath], amount: f64) -> Vec<BezPath> {
|
||||
let a = amount / 100.0;
|
||||
/// Trim Paths, "Trim Multiple Shapes: Simultaneously": every path is trimmed by the same
|
||||
/// fractions of its own length at the same time. `start`/`end` in percent, `offset` in degrees.
|
||||
/// Paths trimmed away entirely come back empty (the list keeps its length).
|
||||
pub fn trim(paths: &[BezPath], start: f64, end: f64, offset_deg: f64) -> Vec<BezPath> {
|
||||
let Some(iv) = trim_intervals(start, end, offset_deg) else { return paths.to_vec() };
|
||||
paths
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let Some(b) = crate::bounds(std::slice::from_ref(p)) else { return p.clone() };
|
||||
let c = b.center();
|
||||
let m = Measured::new(p);
|
||||
let mut out = BezPath::new();
|
||||
for el in p.elements() {
|
||||
let mv = |q: kurbo::Point| q + (q - c) * a;
|
||||
let mvc = |q: kurbo::Point| q - (q - c) * a;
|
||||
match *el {
|
||||
kurbo::PathEl::MoveTo(q) => out.move_to(mv(q)),
|
||||
kurbo::PathEl::LineTo(q) => {
|
||||
let prev = out.elements().last().and_then(|e| e.end_point()).unwrap_or(q);
|
||||
out.curve_to(mvc(prev.lerp(q, 1.0 / 3.0)), mvc(prev.lerp(q, 2.0 / 3.0)), mv(q))
|
||||
}
|
||||
kurbo::PathEl::QuadTo(x, q) => out.quad_to(mvc(x), mv(q)),
|
||||
kurbo::PathEl::CurveTo(x, y, q) => out.curve_to(mvc(x), mvc(y), mv(q)),
|
||||
kurbo::PathEl::ClosePath => out.close_path(),
|
||||
if m.total > 0.0 {
|
||||
for &(a, b) in &iv {
|
||||
m.append(a * m.total, b * m.total, &mut out);
|
||||
}
|
||||
}
|
||||
out
|
||||
|
|
@ -110,29 +177,492 @@ pub fn pucker_bloat(paths: &[BezPath], amount: f64) -> Vec<BezPath> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use kurbo::Shape;
|
||||
/// Trim Paths, "Trim Multiple Shapes: Individually": the paths are laid end to end in stacking
|
||||
/// order and trimmed as one continuous length, so they draw on one after another.
|
||||
pub fn trim_individually(paths: &[BezPath], start: f64, end: f64, offset_deg: f64) -> Vec<BezPath> {
|
||||
let Some(iv) = trim_intervals(start, end, offset_deg) else { return paths.to_vec() };
|
||||
let ms: Vec<Measured> = paths.iter().map(Measured::new).collect();
|
||||
let total: f64 = ms.iter().map(|m| m.total).sum();
|
||||
let mut base = 0.0;
|
||||
ms.iter()
|
||||
.map(|m| {
|
||||
let mut out = BezPath::new();
|
||||
for &(a, b) in &iv {
|
||||
let (ga, gb) = (a * total - base, b * total - base);
|
||||
let (la, lb) = (ga.max(0.0), gb.min(m.total));
|
||||
if lb > la {
|
||||
m.append(la, lb, &mut out);
|
||||
}
|
||||
}
|
||||
base += m.total;
|
||||
out
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn len(p: &[BezPath]) -> f64 {
|
||||
p.iter().map(|x| x.segments().map(|s| s.arclen(1e-4)).sum::<f64>()).sum()
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
// Pucker & Bloat
|
||||
|
||||
/// Pucker & Bloat (`amount` in percent, −100..100). Positive (bloat) moves the vertices towards
|
||||
/// the path's centre (the mean of its vertices) and pushes the tangent handles away from it, so
|
||||
/// segments bulge outwards; negative (pucker) does the opposite, giving spiky shapes.
|
||||
pub fn pucker_bloat(paths: &[BezPath], amount: f64) -> Vec<BezPath> {
|
||||
let a = amount / 100.0;
|
||||
if a == 0.0 {
|
||||
return paths.to_vec();
|
||||
}
|
||||
paths
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let verts: Vec<Point> = p.elements().iter().filter_map(|e| if matches!(e, PathEl::ClosePath) { None } else { e.end_point() }).collect();
|
||||
if verts.is_empty() {
|
||||
return p.clone();
|
||||
}
|
||||
let sum = verts.iter().fold(Vec2::ZERO, |s, q| s + q.to_vec2());
|
||||
let c = (sum / verts.len() as f64).to_point();
|
||||
let mv = |q: Point| q + (c - q) * a;
|
||||
let mvh = |q: Point| q + (q - c) * a;
|
||||
let mut out = BezPath::new();
|
||||
let mut prev = Point::ZERO;
|
||||
let mut start = Point::ZERO;
|
||||
for el in p.elements() {
|
||||
match *el {
|
||||
PathEl::MoveTo(q) => {
|
||||
out.move_to(mv(q));
|
||||
prev = q;
|
||||
start = q;
|
||||
}
|
||||
PathEl::LineTo(q) => {
|
||||
out.curve_to(mvh(prev), mvh(q), mv(q));
|
||||
prev = q;
|
||||
}
|
||||
PathEl::QuadTo(x, q) => {
|
||||
let c1 = prev + (x - prev) * (2.0 / 3.0);
|
||||
let c2 = q + (x - q) * (2.0 / 3.0);
|
||||
out.curve_to(mvh(c1), mvh(c2), mv(q));
|
||||
prev = q;
|
||||
}
|
||||
PathEl::CurveTo(x, y, q) => {
|
||||
out.curve_to(mvh(x), mvh(y), mv(q));
|
||||
prev = q;
|
||||
}
|
||||
PathEl::ClosePath => {
|
||||
// The implicit closing line becomes a curve too.
|
||||
if prev.distance(start) > 1e-9 {
|
||||
out.curve_to(mvh(prev), mvh(start), mv(start));
|
||||
}
|
||||
out.close_path();
|
||||
prev = start;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_half_square() {
|
||||
let sq = kurbo::Rect::new(0.0, 0.0, 100.0, 100.0).to_path(0.1);
|
||||
let t = trim(std::slice::from_ref(&sq), 0.0, 50.0, 0.0);
|
||||
assert!((len(&t) - 200.0).abs() < 0.5, "{}", len(&t));
|
||||
let w = trim(&[sq], 75.0, 125.0, 0.0);
|
||||
let _ = w;
|
||||
let t2 = trim(&[kurbo::Rect::new(0.0, 0.0, 100.0, 100.0).to_path(0.1)], 0.0, 50.0, 270.0);
|
||||
assert!((len(&t2) - 200.0).abs() < 0.5);
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
// Round Corners
|
||||
|
||||
/// Round Corners: every corner vertex (a tangent discontinuity, e.g. between two lines) is
|
||||
/// replaced by a circular arc that starts and ends `radius` along the adjacent segments (clamped
|
||||
/// to half of each segment). Smooth vertices and the ends of open paths are kept.
|
||||
pub fn round_corners(paths: &[BezPath], radius: f64) -> Vec<BezPath> {
|
||||
if radius <= 0.0 || !radius.is_finite() {
|
||||
return paths.to_vec();
|
||||
}
|
||||
paths
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let mut out = BezPath::new();
|
||||
for sub in subs(p) {
|
||||
round_sub(&sub, radius, &mut out);
|
||||
}
|
||||
out
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_empty() {
|
||||
let sq = kurbo::Rect::new(0.0, 0.0, 10.0, 10.0).to_path(0.1);
|
||||
assert!(trim(&[sq], 30.0, 30.0, 0.0).is_empty());
|
||||
fn round_sub(sub: &Sub, r: f64, out: &mut BezPath) {
|
||||
let segs = &sub.segs;
|
||||
let n = segs.len();
|
||||
let lens: Vec<f64> = segs.iter().map(|s| s.arclen(ACC)).collect();
|
||||
// cut[i]: distance cut away on both sides of vertex i (the start of segment i).
|
||||
let mut cut = vec![0.0; n];
|
||||
let mut phi = vec![0.0; n];
|
||||
for i in 0..n {
|
||||
if !sub.closed && i == 0 {
|
||||
continue;
|
||||
}
|
||||
let prev = (i + n - 1) % n;
|
||||
let (tin, tout) = (seg_end_tan(&segs[prev]), seg_start_tan(&segs[i]));
|
||||
let turn = tin.cross(tout).atan2(tin.dot(tout)).abs();
|
||||
if !(1e-3..=PI - 1e-3).contains(&turn) {
|
||||
continue;
|
||||
}
|
||||
cut[i] = r.min(lens[prev] / 2.0).min(lens[i] / 2.0);
|
||||
phi[i] = turn;
|
||||
}
|
||||
// Trimmed segments with the original tangents at their cut ends.
|
||||
let trimmed: Vec<(PathSeg, Vec2, Vec2)> = (0..n)
|
||||
.map(|j| {
|
||||
let a = cut[j];
|
||||
let b = if sub.closed || j + 1 < n { cut[(j + 1) % n] } else { 0.0 };
|
||||
if a <= 0.0 && b <= 0.0 {
|
||||
return (segs[j], seg_start_tan(&segs[j]), seg_end_tan(&segs[j]));
|
||||
}
|
||||
let t0 = if a > 0.0 { segs[j].inv_arclen(a, ACC) } else { 0.0 };
|
||||
let t1 = if b > 0.0 { segs[j].inv_arclen((lens[j] - b).max(0.0), ACC) } else { 1.0 }.max(t0);
|
||||
(segs[j].subsegment(t0..t1), seg_tan_at(&segs[j], t0), seg_tan_at(&segs[j], t1))
|
||||
})
|
||||
.collect();
|
||||
let arc = |out: &mut BezPath, from: &(PathSeg, Vec2, Vec2), to: &(PathSeg, Vec2, Vec2), corner: Point, phi: f64| {
|
||||
let (p, q) = (from.0.end(), to.0.start());
|
||||
// Circular arc through the two tangent points: radius = d / tan(φ/2), handle = 4/3·tan(φ/4)·R.
|
||||
let k = 4.0 / 3.0 * (phi / 4.0).tan() / (phi / 2.0).tan();
|
||||
let (hp, hq) = (p.distance(corner) * k, q.distance(corner) * k);
|
||||
out.curve_to(p + from.2 * hp, q - to.1 * hq, q);
|
||||
};
|
||||
let piece = |out: &mut BezPath, t: &(PathSeg, Vec2, Vec2)| {
|
||||
if !degenerate(&t.0) {
|
||||
push_seg(out, t.0);
|
||||
}
|
||||
};
|
||||
out.move_to(trimmed[0].0.start());
|
||||
piece(out, &trimmed[0]);
|
||||
for j in 1..n {
|
||||
if cut[j] > 0.0 {
|
||||
arc(out, &trimmed[j - 1], &trimmed[j], segs[j].start(), phi[j]);
|
||||
}
|
||||
piece(out, &trimmed[j]);
|
||||
}
|
||||
if sub.closed {
|
||||
if cut[0] > 0.0 {
|
||||
arc(out, &trimmed[n - 1], &trimmed[0], segs[0].start(), phi[0]);
|
||||
}
|
||||
out.close_path();
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
// Offset Paths
|
||||
|
||||
/// Offset Paths: grows (positive `amount`) or shrinks (negative) every path with the given line
|
||||
/// join. `copies` copies are made, copy *i* (0-based) offset by `amount · (i + 1 + copy_offset)`,
|
||||
/// all returned in the same compound path.
|
||||
pub fn offset(paths: &[BezPath], amount: f64, join: Join, miter_limit: f64, copies: f64, copy_offset: f64) -> Vec<BezPath> {
|
||||
let n = copies.round().clamp(1.0, 1000.0) as usize;
|
||||
paths
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let mut out = BezPath::new();
|
||||
for i in 0..n {
|
||||
let d = amount * (i as f64 + 1.0 + copy_offset);
|
||||
out.extend(crate::offset::offset_path(p, d, join, miter_limit).elements().iter().copied());
|
||||
}
|
||||
out
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
// Twist
|
||||
|
||||
/// Twist: rotates the paths about `center` by an angle that grows linearly with the distance
|
||||
/// from the centre, from 0 at the centre to `angle_deg` at the farthest point, so the middle twists
|
||||
/// most sharply. Segments are subdivided and their handles mapped through the exact derivative of
|
||||
/// the warp, which keeps the result smooth.
|
||||
pub fn twist(paths: &[BezPath], angle_deg: f64, center: [f64; 2]) -> Vec<BezPath> {
|
||||
let c = Point::new(center[0], center[1]);
|
||||
let mut rmax: f64 = 0.0;
|
||||
for p in paths {
|
||||
for el in p.elements() {
|
||||
if let Some(q) = el.end_point() {
|
||||
rmax = rmax.max(q.distance(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
let a = angle_deg.to_radians();
|
||||
if a == 0.0 || rmax <= 1e-9 || !a.is_finite() {
|
||||
return paths.to_vec();
|
||||
}
|
||||
let k = a / rmax;
|
||||
let rot = |v: Vec2, th: f64| {
|
||||
let (s, co) = th.sin_cos();
|
||||
Vec2::new(v.x * co - v.y * s, v.x * s + v.y * co)
|
||||
};
|
||||
let map = |p: Point| {
|
||||
let v = p - c;
|
||||
c + rot(v, k * v.hypot())
|
||||
};
|
||||
// Derivative of the warp at `p` applied to `dv`.
|
||||
let jac = |p: Point, dv: Vec2| {
|
||||
let v = p - c;
|
||||
let r = v.hypot();
|
||||
let mut w = dv;
|
||||
if r > 1e-12 {
|
||||
w += perp(v) * (k * v.dot(dv) / r);
|
||||
}
|
||||
rot(w, k * r)
|
||||
};
|
||||
paths
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let mut out = BezPath::new();
|
||||
for sub in subs(p) {
|
||||
out.move_to(map(sub.segs[0].start()));
|
||||
for seg in &sub.segs {
|
||||
let cb = seg.to_cubic();
|
||||
let len = seg.arclen(ACC);
|
||||
let m = ((a.abs() * len / rmax) / 0.15 + len / 50.0).ceil().clamp(1.0, 64.0) as usize;
|
||||
for i in 0..m {
|
||||
let q = cb.subsegment(i as f64 / m as f64..(i + 1) as f64 / m as f64);
|
||||
let (p0, p3) = (map(q.p0), map(q.p3));
|
||||
out.curve_to(p0 + jac(q.p0, q.p1 - q.p0), p3 + jac(q.p3, q.p2 - q.p3), p3);
|
||||
}
|
||||
}
|
||||
if sub.closed {
|
||||
out.close_path();
|
||||
}
|
||||
}
|
||||
out
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
// Resampling (Zig Zag, Wiggle Paths)
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Sample {
|
||||
p: Point,
|
||||
tan: Vec2,
|
||||
/// Length of the piece before / after this sample.
|
||||
len_prev: f64,
|
||||
len_next: f64,
|
||||
}
|
||||
|
||||
/// Split every segment into `m` pieces of equal arc length. Original vertices get the average of
|
||||
/// their incoming and outgoing tangents (so a corner's normal is its bisector).
|
||||
fn resample(sub: &Sub, m: usize) -> Vec<Sample> {
|
||||
let segs = &sub.segs;
|
||||
let n = segs.len();
|
||||
let lens: Vec<f64> = segs.iter().map(|s| s.arclen(ACC)).collect();
|
||||
let mut out = Vec::with_capacity(n * m + 1);
|
||||
for j in 0..n {
|
||||
let piece = lens[j] / m as f64;
|
||||
for k in 0..m {
|
||||
if k == 0 {
|
||||
let tout = seg_start_tan(&segs[j]);
|
||||
let prev = if sub.closed || j > 0 { Some((j + n - 1) % n) } else { None };
|
||||
let tan = match prev {
|
||||
Some(pj) => unit(seg_end_tan(&segs[pj]) + tout).unwrap_or(tout),
|
||||
None => tout,
|
||||
};
|
||||
let len_prev = prev.map_or(0.0, |pj| lens[pj] / m as f64);
|
||||
out.push(Sample { p: segs[j].start(), tan, len_prev, len_next: piece });
|
||||
} else {
|
||||
let t = segs[j].inv_arclen(piece * k as f64, ACC);
|
||||
out.push(Sample { p: segs[j].eval(t), tan: seg_tan_at(&segs[j], t), len_prev: piece, len_next: piece });
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sub.closed {
|
||||
let last = &segs[n - 1];
|
||||
out.push(Sample { p: last.end(), tan: seg_end_tan(last), len_prev: lens[n - 1] / m as f64, len_next: 0.0 });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Emit displaced samples as corner (polyline) or smooth (tangent-aligned cubic) points.
|
||||
/// `handle` is the handle length as a fraction of the adjacent piece length.
|
||||
fn emit(samples: &[Sample], disp: &[Vec2], closed: bool, smooth: bool, handle: f64, out: &mut BezPath) {
|
||||
let n = samples.len();
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
let pt = |i: usize| samples[i].p + disp[i];
|
||||
out.move_to(pt(0));
|
||||
let count = if closed { n } else { n - 1 };
|
||||
for i in 0..count {
|
||||
let j = (i + 1) % n;
|
||||
if smooth {
|
||||
let (a, b) = (&samples[i], &samples[j]);
|
||||
out.curve_to(pt(i) + a.tan * (a.len_next * handle), pt(j) - b.tan * (b.len_prev * handle), pt(j));
|
||||
} else {
|
||||
out.line_to(pt(j));
|
||||
}
|
||||
}
|
||||
if closed {
|
||||
out.close_path();
|
||||
}
|
||||
}
|
||||
|
||||
/// Zig Zag: each segment gets `ridges` ridges — it is split into `ridges + 1` equal pieces and the
|
||||
/// points (original vertices included) are pushed alternately `size` to either side of the path.
|
||||
/// `smooth` makes rounded waves instead of sharp zig zags. A closed path with `n` segments ends up
|
||||
/// with `n · (ridges + 1)` vertices (open: one more).
|
||||
pub fn zigzag(paths: &[BezPath], size: f64, ridges: f64, smooth: bool) -> Vec<BezPath> {
|
||||
let m = ridges.round().clamp(0.0, 1000.0) as usize + 1;
|
||||
paths
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let mut out = BezPath::new();
|
||||
for sub in subs(p) {
|
||||
let s = resample(&sub, m);
|
||||
let disp: Vec<Vec2> = s.iter().enumerate().map(|(i, x)| perp(x.tan) * if i % 2 == 0 { size } else { -size }).collect();
|
||||
emit(&s, &disp, sub.closed, smooth, 0.36, &mut out);
|
||||
}
|
||||
out
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Wiggle Paths parameters.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct WiggleParams {
|
||||
/// Maximum displacement (px).
|
||||
pub size: f64,
|
||||
/// Subdivisions per segment.
|
||||
pub detail: f64,
|
||||
/// Smooth (true) or corner points.
|
||||
pub smooth: bool,
|
||||
/// Wiggles per second.
|
||||
pub speed: f64,
|
||||
/// 0..100 %: how much neighbouring points move together.
|
||||
pub correlation: f64,
|
||||
/// Temporal phase in degrees (360° = one wiggle period).
|
||||
pub phase_deg: f64,
|
||||
pub seed: f64,
|
||||
}
|
||||
|
||||
impl Default for WiggleParams {
|
||||
fn default() -> Self {
|
||||
WiggleParams { size: 10.0, detail: 10.0, smooth: true, speed: 2.0, correlation: 50.0, phase_deg: 0.0, seed: 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
fn splitmix(mut x: u64) -> u64 {
|
||||
x = x.wrapping_add(0x9e37_79b9_7f4a_7c15);
|
||||
let mut z = x;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
|
||||
/// Deterministic hash → [−1, 1].
|
||||
fn hash(seed: u64, idx: u64, t: i64) -> f64 {
|
||||
let h = splitmix(splitmix(splitmix(seed) ^ idx) ^ t as u64);
|
||||
(h >> 11) as f64 / (1u64 << 53) as f64 * 2.0 - 1.0
|
||||
}
|
||||
|
||||
/// Smooth 1D value noise in [−1, 1] (cubic Hermite through hashed lattice values).
|
||||
fn noise(seed: u64, idx: u64, t: f64) -> f64 {
|
||||
let i = t.floor();
|
||||
let f = t - i;
|
||||
let i = i as i64;
|
||||
let p = |k: i64| hash(seed, idx, i + k);
|
||||
// Catmull-Rom through four lattice values keeps the motion C1-continuous.
|
||||
let (p0, p1, p2, p3) = (p(-1), p(0), p(1), p(2));
|
||||
let v = 0.5 * (2.0 * p1 + (-p0 + p2) * f + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * f * f + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * f * f * f);
|
||||
v.clamp(-1.0, 1.0)
|
||||
}
|
||||
|
||||
/// Wiggle Paths: subdivides the paths (`detail` points per segment) and displaces every point by
|
||||
/// smooth random noise of up to `size` pixels that evolves over `time_s` seconds at `speed`
|
||||
/// wiggles per second. Deterministic for a given seed and time.
|
||||
pub fn wiggle(paths: &[BezPath], w: &WiggleParams, time_s: f64) -> Vec<BezPath> {
|
||||
if w.size == 0.0 {
|
||||
return paths.to_vec();
|
||||
}
|
||||
let m = w.detail.round().clamp(0.0, 1000.0) as usize + 1;
|
||||
let seed = w.seed.round() as i64 as u64;
|
||||
let t = time_s * w.speed + w.phase_deg / 360.0;
|
||||
let corr = (w.correlation / 100.0).clamp(0.0, 1.0);
|
||||
let shared = Vec2::new(noise(seed, u64::MAX, t), noise(seed, u64::MAX - 1, t));
|
||||
let mut idx: u64 = 0;
|
||||
paths
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let mut out = BezPath::new();
|
||||
for sub in subs(p) {
|
||||
let s = resample(&sub, m);
|
||||
let disp: Vec<Vec2> = s
|
||||
.iter()
|
||||
.map(|_| {
|
||||
let own = Vec2::new(noise(seed, idx * 2, t + idx as f64 * 0.37), noise(seed, idx * 2 + 1, t + idx as f64 * 0.61));
|
||||
idx += 1;
|
||||
(shared * corr + own * (1.0 - corr)) * w.size
|
||||
})
|
||||
.collect();
|
||||
emit(&s, &disp, sub.closed, w.smooth, 1.0 / 3.0, &mut out);
|
||||
}
|
||||
out
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
// Merge Paths
|
||||
|
||||
/// Merge Paths modes.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum MergeMode {
|
||||
/// Combine into one compound path (no boolean).
|
||||
#[default]
|
||||
Merge,
|
||||
/// Union.
|
||||
Add,
|
||||
/// The first path minus all the others.
|
||||
Subtract,
|
||||
/// Area covered by every path.
|
||||
Intersect,
|
||||
/// Area covered by an odd number of paths.
|
||||
Exclude,
|
||||
}
|
||||
|
||||
impl MergeMode {
|
||||
pub fn from_index(i: u32) -> MergeMode {
|
||||
match i {
|
||||
1 => MergeMode::Add,
|
||||
2 => MergeMode::Subtract,
|
||||
3 => MergeMode::Intersect,
|
||||
4 => MergeMode::Exclude,
|
||||
_ => MergeMode::Merge,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge Paths: combine the paths (in stacking order, each filled non-zero) into one path.
|
||||
pub fn merge(paths: &[BezPath], mode: MergeMode) -> BezPath {
|
||||
if mode == MergeMode::Merge {
|
||||
let mut out = BezPath::new();
|
||||
for p in paths {
|
||||
out.extend(p.elements().iter().copied());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
let inputs: Vec<(BezPath, FillRule)> = paths.iter().filter(|p| !p.elements().is_empty()).map(|p| (p.clone(), FillRule::NonZero)).collect();
|
||||
if inputs.is_empty() {
|
||||
return BezPath::new();
|
||||
}
|
||||
match mode {
|
||||
MergeMode::Add => crate::boolean::boolean_n(&inputs, |m| m.iter().any(|&b| b)),
|
||||
MergeMode::Subtract => crate::boolean::boolean_n(&inputs, |m| m[0] && !m[1..].iter().any(|&b| b)),
|
||||
MergeMode::Intersect => crate::boolean::boolean_n(&inputs, |m| m.iter().all(|&b| b)),
|
||||
MergeMode::Exclude => crate::boolean::boolean_n(&inputs, |m| m.iter().filter(|&&b| b).count() % 2 == 1),
|
||||
MergeMode::Merge => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Signed area helper used by tests and callers: unsigned non-zero filled area of a path list.
|
||||
pub fn area(paths: &[BezPath]) -> f64 {
|
||||
let mut all = BezPath::new();
|
||||
for p in paths {
|
||||
all.extend(p.elements().iter().copied());
|
||||
}
|
||||
crate::boolean::filled_area(&all, FillRule::NonZero)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,375 @@
|
|||
use super::*;
|
||||
use kurbo::{Circle, Rect, Shape};
|
||||
|
||||
fn len(p: &[BezPath]) -> f64 {
|
||||
p.iter().map(length).sum()
|
||||
}
|
||||
|
||||
fn sq(x: f64, y: f64, s: f64) -> BezPath {
|
||||
Rect::new(x, y, x + s, y + s).to_path(0.1)
|
||||
}
|
||||
|
||||
fn circle(x: f64, y: f64, r: f64) -> BezPath {
|
||||
Circle::new((x, y), r).to_path(1e-3)
|
||||
}
|
||||
|
||||
fn close(a: f64, b: f64, tol: f64) -> bool {
|
||||
(a - b).abs() <= tol
|
||||
}
|
||||
|
||||
fn on_curve(p: &BezPath) -> Vec<Point> {
|
||||
p.elements().iter().filter_map(|e| e.end_point()).collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- trim
|
||||
|
||||
#[test]
|
||||
fn trim_half_square() {
|
||||
let s = sq(0.0, 0.0, 100.0);
|
||||
let t = trim(std::slice::from_ref(&s), 0.0, 50.0, 0.0);
|
||||
assert!(close(len(&t), 200.0, 0.5), "{}", len(&t));
|
||||
let t2 = trim(&[s], 0.0, 50.0, 270.0);
|
||||
assert!(close(len(&t2), 200.0, 0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_empty_keeps_slot() {
|
||||
let t = trim(&[sq(0.0, 0.0, 10.0)], 30.0, 30.0, 0.0);
|
||||
assert_eq!(t.len(), 1);
|
||||
assert!(t[0].elements().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_across_start_point_is_one_piece() {
|
||||
// 75 %..125 % (via the offset) crosses the start of the closed path: one continuous subpath.
|
||||
let t = trim(&[sq(0.0, 0.0, 100.0)], 0.0, 50.0, 270.0);
|
||||
let moves = t[0].elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count();
|
||||
assert_eq!(moves, 1, "{:?}", t[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_simultaneously_trims_each_path() {
|
||||
let paths = [sq(0.0, 0.0, 100.0), sq(200.0, 0.0, 50.0)];
|
||||
let t = trim(&paths, 0.0, 50.0, 0.0);
|
||||
assert!(close(length(&t[0]), 200.0, 0.5));
|
||||
assert!(close(length(&t[1]), 100.0, 0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_individually_is_sequential() {
|
||||
let paths = [sq(0.0, 0.0, 100.0), sq(200.0, 0.0, 100.0)];
|
||||
let t = trim_individually(&paths, 0.0, 50.0, 0.0);
|
||||
assert!(close(length(&t[0]), 400.0, 0.5), "{}", length(&t[0]));
|
||||
assert!(length(&t[1]) < 1e-6);
|
||||
let t = trim_individually(&paths, 25.0, 75.0, 0.0);
|
||||
assert!(close(length(&t[0]), 200.0, 0.5));
|
||||
assert!(close(length(&t[1]), 200.0, 0.5));
|
||||
// Unequal lengths: the kept length is the fraction of the total.
|
||||
let paths = [sq(0.0, 0.0, 100.0), sq(200.0, 0.0, 25.0)];
|
||||
let t = trim_individually(&paths, 10.0, 90.0, 45.0);
|
||||
assert!(close(len(&t), 0.8 * 500.0, 0.5), "{}", len(&t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_flips_orientation() {
|
||||
let s = sq(0.0, 0.0, 10.0);
|
||||
let r = reverse(std::slice::from_ref(&s));
|
||||
assert!(close(r[0].area(), -s.area(), 1e-9));
|
||||
assert_eq!(vertex_count(&r[0]), 4);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- round corners
|
||||
|
||||
#[test]
|
||||
fn round_corners_square() {
|
||||
let r = round_corners(&[sq(0.0, 0.0, 100.0)], 10.0);
|
||||
assert_eq!(vertex_count(&r[0]), 8);
|
||||
let expect = 10000.0 - (4.0 - PI) * 100.0;
|
||||
assert!(close(r[0].area().abs(), expect, 1.0), "{}", r[0].area());
|
||||
// Matches the rectangle's own roundness.
|
||||
let rr = crate::rect([100.0, 100.0], [50.0, 50.0], 10.0);
|
||||
assert!(close(rr.area().abs(), r[0].area().abs(), 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_corners_clamps_to_half_segment() {
|
||||
let r = round_corners(&[sq(0.0, 0.0, 20.0)], 100.0);
|
||||
assert!(close(r[0].area().abs(), PI * 100.0, 2.0), "{}", r[0].area());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_corners_keeps_smooth_and_open_ends() {
|
||||
let c = circle(0.0, 0.0, 50.0);
|
||||
let r = round_corners(std::slice::from_ref(&c), 10.0);
|
||||
assert_eq!(vertex_count(&r[0]), vertex_count(&c));
|
||||
let mut open = BezPath::new();
|
||||
open.move_to((0.0, 0.0));
|
||||
open.line_to((100.0, 0.0));
|
||||
open.line_to((100.0, 100.0));
|
||||
let r = round_corners(&[open], 20.0);
|
||||
assert_eq!(vertex_count(&r[0]), 4);
|
||||
let pts = on_curve(&r[0]);
|
||||
assert_eq!(pts[0], Point::new(0.0, 0.0));
|
||||
assert!(pts.last().unwrap().distance(Point::new(100.0, 100.0)) < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_corners_triangle_arc_is_tangent() {
|
||||
let mut t = BezPath::new();
|
||||
t.move_to((0.0, 0.0));
|
||||
t.line_to((100.0, 0.0));
|
||||
t.line_to((50.0, 80.0));
|
||||
t.close_path();
|
||||
let r = round_corners(std::slice::from_ref(&t), 15.0);
|
||||
assert_eq!(vertex_count(&r[0]), 6);
|
||||
assert!(r[0].area().abs() < t.area().abs());
|
||||
assert!(r[0].area().abs() > t.area().abs() - 3.0 * 15.0 * 15.0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- offset
|
||||
|
||||
#[test]
|
||||
fn offset_grows_square_with_miter() {
|
||||
let o = offset(&[sq(0.0, 0.0, 100.0)], 10.0, Join::Miter, 4.0, 1.0, 0.0);
|
||||
assert!(close(area(&o), 120.0 * 120.0, 1.0), "{}", area(&o));
|
||||
let b = o[0].bounding_box();
|
||||
assert!(close(b.x0, -10.0, 1e-3) && close(b.x1, 110.0, 1e-3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_round_join_adds_perimeter_times_d() {
|
||||
let o = offset(&[sq(0.0, 0.0, 100.0)], 10.0, Join::Round, 4.0, 1.0, 0.0);
|
||||
let expect = 10000.0 + 400.0 * 10.0 + PI * 100.0;
|
||||
assert!(close(area(&o), expect, 2.0), "{} vs {expect}", area(&o));
|
||||
let o = offset(&[sq(0.0, 0.0, 100.0)], 10.0, Join::Bevel, 4.0, 1.0, 0.0);
|
||||
let expect = 10000.0 + 400.0 * 10.0 + 4.0 * 50.0;
|
||||
assert!(close(area(&o), expect, 2.0), "{} vs {expect}", area(&o));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_negative_shrinks() {
|
||||
let o = offset(&[sq(0.0, 0.0, 100.0)], -10.0, Join::Miter, 4.0, 1.0, 0.0);
|
||||
assert!(close(area(&o), 80.0 * 80.0, 1.0), "{}", area(&o));
|
||||
let gone = offset(&[sq(0.0, 0.0, 100.0)], -60.0, Join::Miter, 4.0, 1.0, 0.0);
|
||||
assert!(area(&gone) < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_circle_is_a_circle() {
|
||||
let o = offset(&[circle(0.0, 0.0, 50.0)], 10.0, Join::Round, 4.0, 1.0, 0.0);
|
||||
assert!(close(area(&o), PI * 3600.0, 3600.0 * PI * 1e-3), "{}", area(&o));
|
||||
let o = offset(&[circle(0.0, 0.0, 50.0)], -20.0, Join::Round, 4.0, 1.0, 0.0);
|
||||
assert!(close(area(&o), PI * 900.0, 900.0 * PI * 1e-3), "{}", area(&o));
|
||||
// Curves stay curves (no polygon flattening blow-up).
|
||||
assert!(vertex_count(&o[0]) < 40, "{}", vertex_count(&o[0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_is_independent_of_orientation() {
|
||||
let s = sq(0.0, 0.0, 100.0);
|
||||
let a = offset(std::slice::from_ref(&s), 5.0, Join::Miter, 4.0, 1.0, 0.0);
|
||||
let b = offset(&reverse(&[s]), 5.0, Join::Miter, 4.0, 1.0, 0.0);
|
||||
assert!(close(area(&a), area(&b), 1e-3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_copies() {
|
||||
let o = offset(&[sq(0.0, 0.0, 100.0)], 10.0, Join::Miter, 4.0, 3.0, 0.0);
|
||||
let contours = o[0].elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count();
|
||||
assert_eq!(contours, 3);
|
||||
assert!(close(o[0].bounding_box().width(), 160.0, 1e-3));
|
||||
let o = offset(&[sq(0.0, 0.0, 100.0)], 10.0, Join::Miter, 4.0, 1.0, 1.0);
|
||||
assert!(close(o[0].bounding_box().width(), 140.0, 1e-3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_open_path_outlines() {
|
||||
let mut l = BezPath::new();
|
||||
l.move_to((0.0, 0.0));
|
||||
l.line_to((100.0, 0.0));
|
||||
let o = offset(&[l], 5.0, Join::Miter, 4.0, 1.0, 0.0);
|
||||
assert!(close(area(&o), 1000.0, 1.0), "{}", area(&o));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- pucker & bloat
|
||||
|
||||
#[test]
|
||||
fn bloat_moves_vertices_inwards_and_bulges() {
|
||||
let s = sq(-50.0, -50.0, 100.0);
|
||||
let b = pucker_bloat(std::slice::from_ref(&s), 50.0);
|
||||
for p in on_curve(&b[0]) {
|
||||
assert!(close(p.x.abs(), 25.0, 1e-9) && close(p.y.abs(), 25.0, 1e-9), "{p:?}");
|
||||
}
|
||||
// Handles pushed outwards: the shape reaches beyond its vertices.
|
||||
let bb = b[0].bounding_box();
|
||||
assert!(bb.width() > 50.0 + 1.0, "{bb:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pucker_moves_vertices_outwards() {
|
||||
let s = sq(-50.0, -50.0, 100.0);
|
||||
let p = pucker_bloat(std::slice::from_ref(&s), -50.0);
|
||||
for q in on_curve(&p[0]) {
|
||||
assert!(close(q.x.abs(), 75.0, 1e-9) && close(q.y.abs(), 75.0, 1e-9));
|
||||
}
|
||||
assert!(area(&p) < area(&[s]) * 2.25, "segments pulled in");
|
||||
assert_eq!(pucker_bloat(&[sq(0.0, 0.0, 1.0)], 0.0)[0], sq(0.0, 0.0, 1.0));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- twist
|
||||
|
||||
#[test]
|
||||
fn twist_rotates_by_distance() {
|
||||
let mut l = BezPath::new();
|
||||
l.move_to((0.0, 0.0));
|
||||
l.line_to((100.0, 0.0));
|
||||
let t = twist(&[l], 90.0, [0.0, 0.0]);
|
||||
let pts = on_curve(&t[0]);
|
||||
assert!(pts[0].distance(Point::ZERO) < 1e-9, "centre is fixed");
|
||||
let end = *pts.last().unwrap();
|
||||
assert!(end.distance(Point::new(0.0, 100.0)) < 1e-9, "{end:?}");
|
||||
// Every on-curve point keeps its distance from the centre (it is a rotation per radius).
|
||||
for p in &pts {
|
||||
let r = p.distance(Point::ZERO);
|
||||
let ang = p.y.atan2(p.x);
|
||||
assert!(close(ang, PI / 2.0 * r / 100.0, 1e-9), "{p:?}");
|
||||
}
|
||||
assert!(pts.len() > 4, "subdivided");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twist_of_centred_circle_keeps_it_round() {
|
||||
let c = circle(0.0, 0.0, 50.0);
|
||||
let t = twist(std::slice::from_ref(&c), 120.0, [0.0, 0.0]);
|
||||
assert!(close(area(&t), area(&[c]), 1.0));
|
||||
let id = twist(&[sq(0.0, 0.0, 5.0)], 0.0, [0.0, 0.0]);
|
||||
assert_eq!(id[0], sq(0.0, 0.0, 5.0));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- zig zag
|
||||
|
||||
#[test]
|
||||
fn zigzag_vertex_counts() {
|
||||
let z = zigzag(&[sq(0.0, 0.0, 100.0)], 5.0, 5.0, false);
|
||||
assert_eq!(vertex_count(&z[0]), 24);
|
||||
let mut l = BezPath::new();
|
||||
l.move_to((0.0, 0.0));
|
||||
l.line_to((100.0, 0.0));
|
||||
let z = zigzag(&[l], 5.0, 3.0, false);
|
||||
assert_eq!(vertex_count(&z[0]), 5);
|
||||
let pts = on_curve(&z[0]);
|
||||
for (i, p) in pts.iter().enumerate() {
|
||||
assert!(close(p.x, 25.0 * i as f64, 1e-6));
|
||||
assert!(close(p.y.abs(), 5.0, 1e-9));
|
||||
if i > 0 {
|
||||
assert!(p.y * pts[i - 1].y < 0.0, "alternates");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zigzag_smooth_uses_curves() {
|
||||
let z = zigzag(&[sq(0.0, 0.0, 100.0)], 5.0, 2.0, true);
|
||||
assert!(z[0].elements().iter().any(|e| matches!(e, PathEl::CurveTo(..))));
|
||||
assert_eq!(vertex_count(&z[0]), 12);
|
||||
let corner = zigzag(&[sq(0.0, 0.0, 100.0)], 5.0, 2.0, false);
|
||||
assert!(!corner[0].elements().iter().any(|e| matches!(e, PathEl::CurveTo(..))));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- wiggle
|
||||
|
||||
#[test]
|
||||
fn wiggle_is_deterministic_and_time_varying() {
|
||||
let s = [sq(0.0, 0.0, 100.0)];
|
||||
let w = WiggleParams::default();
|
||||
let a = wiggle(&s, &w, 0.5);
|
||||
assert_eq!(a, wiggle(&s, &w, 0.5));
|
||||
assert_ne!(a, wiggle(&s, &w, 0.75));
|
||||
assert_ne!(a, wiggle(&s, &WiggleParams { seed: 3.0, ..w }, 0.5));
|
||||
// Phase of 360° = one wiggle period at speed 1: same as advancing time by one second.
|
||||
let w1 = WiggleParams { speed: 1.0, ..w };
|
||||
assert_eq!(wiggle(&s, &WiggleParams { phase_deg: 360.0, ..w1 }, 0.25), wiggle(&s, &w1, 1.25));
|
||||
assert_eq!(vertex_count(&a[0]), 4 * 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wiggle_displacement_is_bounded() {
|
||||
let mut l = BezPath::new();
|
||||
l.move_to((0.0, 0.0));
|
||||
l.line_to((1000.0, 0.0));
|
||||
let w = WiggleParams { size: 7.0, detail: 50.0, smooth: false, ..Default::default() };
|
||||
for t in [0.0, 0.3, 1.7, 9.1] {
|
||||
let r = wiggle(std::slice::from_ref(&l), &w, t);
|
||||
let pts = on_curve(&r[0]);
|
||||
assert_eq!(pts.len(), 52);
|
||||
assert!(pts.iter().all(|p| p.y.abs() <= 7.0 * 1.0001));
|
||||
assert!(pts.iter().any(|p| p.y.abs() > 0.1));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wiggle_full_correlation_translates() {
|
||||
let s = [sq(0.0, 0.0, 100.0)];
|
||||
let w = WiggleParams { correlation: 100.0, smooth: false, ..Default::default() };
|
||||
let r = wiggle(&s, &w, 0.4);
|
||||
assert!(close(area(&r), 10000.0, 1e-6));
|
||||
let zero = wiggle(&s, &WiggleParams { size: 0.0, ..w }, 1.0);
|
||||
assert_eq!(zero[0], s[0]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- merge / booleans
|
||||
|
||||
fn inclusion_exclusion(a: BezPath, b: BezPath) {
|
||||
let aa = area(std::slice::from_ref(&a));
|
||||
let ab = area(std::slice::from_ref(&b));
|
||||
let pair = [a, b];
|
||||
let u = area(&[merge(&pair, MergeMode::Add)]);
|
||||
let i = area(&[merge(&pair, MergeMode::Intersect)]);
|
||||
let s = area(&[merge(&pair, MergeMode::Subtract)]);
|
||||
let x = area(&[merge(&pair, MergeMode::Exclude)]);
|
||||
let tol = 1e-3 * (aa + ab);
|
||||
assert!(i > 1.0, "overlapping");
|
||||
assert!(close(u, aa + ab - i, tol), "union {u} vs {aa}+{ab}-{i}");
|
||||
assert!(close(s, aa - i, tol), "subtract {s}");
|
||||
assert!(close(x, u - i, tol), "exclude {x}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_rects_inclusion_exclusion() {
|
||||
inclusion_exclusion(sq(0.0, 0.0, 100.0), sq(50.0, 30.0, 100.0));
|
||||
let i = merge(&[sq(0.0, 0.0, 100.0), sq(50.0, 30.0, 100.0)], MergeMode::Intersect);
|
||||
assert!(close(area(&[i]), 50.0 * 70.0, 1e-6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_circles_inclusion_exclusion() {
|
||||
inclusion_exclusion(circle(0.0, 0.0, 50.0), circle(40.0, 10.0, 35.0));
|
||||
// Curves are preserved: the union of two circles has few vertices.
|
||||
let u = merge(&[circle(0.0, 0.0, 50.0), circle(40.0, 10.0, 35.0)], MergeMode::Add);
|
||||
assert!(vertex_count(&u) < 20, "{}", vertex_count(&u));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_modes_edge_cases() {
|
||||
let a = sq(0.0, 0.0, 10.0);
|
||||
let b = sq(100.0, 0.0, 10.0);
|
||||
assert!(area(&[merge(&[a.clone(), b.clone()], MergeMode::Intersect)]) < 1e-9);
|
||||
assert!(close(area(&[merge(&[a.clone(), b.clone()], MergeMode::Add)]), 200.0, 1e-6));
|
||||
let m = merge(&[a.clone(), b.clone()], MergeMode::Merge);
|
||||
assert_eq!(m.elements().len(), a.elements().len() + b.elements().len());
|
||||
// Subtract with three operands: first minus the rest.
|
||||
let s = merge(&[sq(0.0, 0.0, 100.0), sq(0.0, 0.0, 50.0), sq(50.0, 50.0, 50.0)], MergeMode::Subtract);
|
||||
assert!(close(area(&[s]), 5000.0, 1e-6));
|
||||
assert!(merge(&[], MergeMode::Add).elements().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_and_filled_area() {
|
||||
// Two same-direction overlapping squares in one path: non-zero fills the union.
|
||||
let mut p = sq(0.0, 0.0, 100.0);
|
||||
p.extend(sq(50.0, 0.0, 100.0).elements().iter().copied());
|
||||
assert!(close(crate::boolean::filled_area(&p, FillRule::NonZero), 15000.0, 1e-6));
|
||||
assert!(close(crate::boolean::filled_area(&p, FillRule::EvenOdd), 10000.0, 1e-6));
|
||||
let n = crate::boolean::normalize(&p, FillRule::EvenOdd);
|
||||
assert!(close(n.area().abs(), 10000.0, 1e-6));
|
||||
}
|
||||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -1,11 +1,18 @@
|
|||
//! Shape-layer contents: groups, parametric paths, fills, strokes, gradients and path operators,
|
||||
//! with the After Effects stacking semantics (operators and paint items act on the paths above
|
||||
//! them in the same group; items higher in the list draw on top).
|
||||
//! with the After Effects stacking semantics:
|
||||
//!
|
||||
//! - path operators (Trim Paths, Round Corners, Offset Paths, Pucker & Bloat, Twist, Zig Zag,
|
||||
//! Wiggle Paths, Merge Paths, Repeater) act on all paths above them in the same group, including
|
||||
//! the paths of nested groups;
|
||||
//! - paint items (fills, strokes, gradient fills/strokes) paint the paths above them, *as modified
|
||||
//! by every operator in the stack* — a Trim Paths below a Stroke still trims it;
|
||||
//! - items higher in the list draw on top.
|
||||
|
||||
use effectcraft_color::{BlendMode, blend_pixel};
|
||||
use effectcraft_effects::Buf;
|
||||
use effectcraft_geom::{Mat3, vec2};
|
||||
use effectcraft_keyframe::{Gradient, Value};
|
||||
use effectcraft_path::{BezPath, Cap, FillRule, Join, StrokeStyle};
|
||||
use effectcraft_path::{BezPath, Cap, FillRule, Join, StrokeStyle, ops};
|
||||
use effectcraft_project::{Layer, Node, PropGroup};
|
||||
use effectcraft_raster::{Image, Mask};
|
||||
use rayon::prelude::*;
|
||||
|
|
@ -14,22 +21,40 @@ use crate::eval::EvalCtx;
|
|||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Paint {
|
||||
Fill { color: [f32; 4], rule: FillRule },
|
||||
Stroke { color: [f32; 4], style: StrokeStyle },
|
||||
Gradient { g: Gradient, radial: bool, start: [f64; 2], end: [f64; 2], rule: FillRule },
|
||||
Fill {
|
||||
color: [f32; 4],
|
||||
rule: FillRule,
|
||||
},
|
||||
Stroke {
|
||||
color: [f32; 4],
|
||||
style: StrokeStyle,
|
||||
},
|
||||
/// Gradient fill, or gradient stroke when `stroke` is set.
|
||||
Gradient {
|
||||
g: Gradient,
|
||||
radial: bool,
|
||||
start: [f64; 2],
|
||||
end: [f64; 2],
|
||||
rule: FillRule,
|
||||
stroke: Option<StrokeStyle>,
|
||||
},
|
||||
}
|
||||
|
||||
/// A paint item. It references path *slots* rather than copies of the geometry: path operators
|
||||
/// further down the stack rewrite the slots in place, so (as in After Effects) a Trim Paths or
|
||||
/// Round Corners placed below a fill or stroke still changes what that paint draws.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Draw {
|
||||
paint: Paint,
|
||||
paths: Vec<BezPath>,
|
||||
/// Group-space → layer-space for gradients and stroke scaling.
|
||||
slots: Vec<usize>,
|
||||
/// Paint-group space → layer space, for gradients and stroke scaling.
|
||||
xf: Mat3,
|
||||
opacity: f32,
|
||||
blend: BlendMode,
|
||||
}
|
||||
|
||||
fn mat_paths(paths: &[BezPath], m: &Mat3) -> Vec<BezPath> {
|
||||
effectcraft_path::transform(paths, m)
|
||||
fn mat_path(path: &BezPath, m: &Mat3) -> BezPath {
|
||||
effectcraft_path::transform(std::slice::from_ref(path), m).pop().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn group_matrix(ctx: &EvalCtx, layer: &Layer, tr: &PropGroup) -> (Mat3, f32) {
|
||||
|
|
@ -51,10 +76,55 @@ fn rule(ctx: &EvalCtx, layer: &Layer, g: &PropGroup) -> FillRule {
|
|||
if ctx.e(layer, g, "rule") == 1 { FillRule::EvenOdd } else { FillRule::NonZero }
|
||||
}
|
||||
|
||||
/// Collect draws and accumulated paths of a contents group (in group space).
|
||||
fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> (Vec<Draw>, Vec<BezPath>) {
|
||||
fn stroke_style(ctx: &EvalCtx, layer: &Layer, g: &PropGroup) -> StrokeStyle {
|
||||
let dash = g.sub("dashes").and_then(|d| {
|
||||
let dash = ctx.f(layer, d, "dash", 0.0);
|
||||
let gap = ctx.f(layer, d, "gap", 0.0);
|
||||
(dash > 0.0).then(|| (vec![dash, if gap > 0.0 { gap } else { dash }], ctx.f(layer, d, "offset", 0.0)))
|
||||
});
|
||||
StrokeStyle {
|
||||
width: ctx.f(layer, g, "width", 2.0),
|
||||
cap: [Cap::Butt, Cap::Round, Cap::Square][ctx.e(layer, g, "cap").min(2) as usize],
|
||||
join: [Join::Miter, Join::Round, Join::Bevel][ctx.e(layer, g, "join").min(2) as usize],
|
||||
miter: ctx.f(layer, g, "miter", 4.0),
|
||||
dash,
|
||||
}
|
||||
}
|
||||
|
||||
/// Blend mode of a paint item (`blend` holds an index into the Blending Mode menu).
|
||||
fn blend_mode(ctx: &EvalCtx, layer: &Layer, g: &PropGroup) -> BlendMode {
|
||||
BlendMode::ALL.get(ctx.e(layer, g, "blend") as usize).copied().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Apply a path operator to the live slots in place.
|
||||
fn apply(arena: &mut [BezPath], live: &[usize], f: impl FnOnce(&[BezPath]) -> Vec<BezPath>) {
|
||||
let input: Vec<BezPath> = live.iter().map(|&i| arena[i].clone()).collect();
|
||||
for (&i, p) in live.iter().zip(f(&input)) {
|
||||
arena[i] = p;
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a shape's path as a new slot, honouring its Path Direction toggle (1 = reversed).
|
||||
fn push_shape(ctx: &EvalCtx, layer: &Layer, g: &PropGroup, p: BezPath, arena: &mut Vec<BezPath>, live: &mut Vec<usize>) {
|
||||
let p = if ctx.e(layer, g, "direction") == 1 { p.reverse_subpaths() } else { p };
|
||||
arena.push(p);
|
||||
live.push(arena.len() - 1);
|
||||
}
|
||||
|
||||
/// Every slot used by `live` or by `draws`, sorted and unique.
|
||||
fn used_slots(live: &[usize], draws: &[Draw]) -> Vec<usize> {
|
||||
let mut v: Vec<usize> = live.iter().copied().chain(draws.iter().flat_map(|d| d.slots.iter().copied())).collect();
|
||||
v.sort_unstable();
|
||||
v.dedup();
|
||||
v
|
||||
}
|
||||
|
||||
/// Collect the draws of a contents group and the path slots live at its end (in group space).
|
||||
/// `arena` holds every path slot; operators rewrite slots in place.
|
||||
fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<BezPath>) -> (Vec<Draw>, Vec<usize>) {
|
||||
let mut draws: Vec<Draw> = Vec::new();
|
||||
let mut acc: Vec<BezPath> = Vec::new();
|
||||
let mut live: Vec<usize> = Vec::new();
|
||||
let opacity = |g: &PropGroup| (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32;
|
||||
for node in &contents.children {
|
||||
let Node::Group(g) = node else { continue };
|
||||
if !g.enabled {
|
||||
|
|
@ -64,16 +134,17 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> (Vec<Draw>, Ve
|
|||
"rect" => {
|
||||
let size = ctx.v2(layer, g, "size", [100.0; 2]);
|
||||
let pos = ctx.v2(layer, g, "position", [0.0; 2]);
|
||||
acc.push(effectcraft_path::rect(size, pos, ctx.f(layer, g, "roundness", 0.0)));
|
||||
let p = effectcraft_path::rect(size, pos, ctx.f(layer, g, "roundness", 0.0));
|
||||
push_shape(ctx, layer, g, p, arena, &mut live);
|
||||
}
|
||||
"ellipse" => {
|
||||
let size = ctx.v2(layer, g, "size", [100.0; 2]);
|
||||
let pos = ctx.v2(layer, g, "position", [0.0; 2]);
|
||||
acc.push(effectcraft_path::ellipse(size, pos));
|
||||
push_shape(ctx, layer, g, effectcraft_path::ellipse(size, pos), arena, &mut live);
|
||||
}
|
||||
"star" => {
|
||||
let star = ctx.e(layer, g, "type") == 0;
|
||||
acc.push(effectcraft_path::polystar(
|
||||
let p = effectcraft_path::polystar(
|
||||
star,
|
||||
ctx.f(layer, g, "points", 5.0),
|
||||
ctx.v2(layer, g, "position", [0.0; 2]),
|
||||
|
|
@ -82,40 +153,43 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> (Vec<Draw>, Ve
|
|||
ctx.f(layer, g, "outerRadius", 100.0),
|
||||
ctx.f(layer, g, "innerRoundness", 0.0),
|
||||
ctx.f(layer, g, "outerRoundness", 0.0),
|
||||
));
|
||||
);
|
||||
push_shape(ctx, layer, g, p, arena, &mut live);
|
||||
}
|
||||
"path" => {
|
||||
if let Some(Value::Path(p)) = ctx.group_value(layer, g, "path") {
|
||||
acc.push(effectcraft_path::to_kurbo(&p));
|
||||
push_shape(ctx, layer, g, effectcraft_path::to_kurbo(&p), arena, &mut live);
|
||||
}
|
||||
}
|
||||
"fill" => {
|
||||
let mut c = ctx.color(layer, g, "color");
|
||||
c[3] = 1.0;
|
||||
draws.push(Draw { paint: Paint::Fill { color: c, rule: rule(ctx, layer, g) }, paths: acc.clone(), xf: Mat3::IDENTITY, opacity: (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32 });
|
||||
draws.push(Draw {
|
||||
paint: Paint::Fill { color: c, rule: rule(ctx, layer, g) },
|
||||
slots: live.clone(),
|
||||
xf: Mat3::IDENTITY,
|
||||
opacity: opacity(g),
|
||||
blend: blend_mode(ctx, layer, g),
|
||||
});
|
||||
}
|
||||
"stroke" => {
|
||||
let mut c = ctx.color(layer, g, "color");
|
||||
c[3] = 1.0;
|
||||
let dash = g.sub("dashes").and_then(|d| {
|
||||
let dash = ctx.f(layer, d, "dash", 0.0);
|
||||
let gap = ctx.f(layer, d, "gap", 0.0);
|
||||
(dash > 0.0).then(|| (vec![dash, if gap > 0.0 { gap } else { dash }], ctx.f(layer, d, "offset", 0.0)))
|
||||
let style = stroke_style(ctx, layer, g);
|
||||
draws.push(Draw {
|
||||
paint: Paint::Stroke { color: c, style },
|
||||
slots: live.clone(),
|
||||
xf: Mat3::IDENTITY,
|
||||
opacity: opacity(g),
|
||||
blend: blend_mode(ctx, layer, g),
|
||||
});
|
||||
let style = StrokeStyle {
|
||||
width: ctx.f(layer, g, "width", 2.0),
|
||||
cap: [Cap::Butt, Cap::Round, Cap::Square][ctx.e(layer, g, "cap").min(2) as usize],
|
||||
join: [Join::Miter, Join::Round, Join::Bevel][ctx.e(layer, g, "join").min(2) as usize],
|
||||
miter: ctx.f(layer, g, "miter", 4.0),
|
||||
dash,
|
||||
};
|
||||
draws.push(Draw { paint: Paint::Stroke { color: c, style }, paths: acc.clone(), xf: Mat3::IDENTITY, opacity: (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32 });
|
||||
}
|
||||
"gfill" => {
|
||||
"gfill" | "gstroke" => {
|
||||
let gr = match ctx.group_value(layer, g, "colors") {
|
||||
Some(Value::Gradient(gr)) => gr,
|
||||
_ => Gradient::default(),
|
||||
};
|
||||
let stroke = (g.match_id == "gstroke").then(|| stroke_style(ctx, layer, g));
|
||||
draws.push(Draw {
|
||||
paint: Paint::Gradient {
|
||||
g: gr,
|
||||
|
|
@ -123,34 +197,86 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> (Vec<Draw>, Ve
|
|||
start: ctx.v2(layer, g, "start", [0.0; 2]),
|
||||
end: ctx.v2(layer, g, "end", [100.0, 0.0]),
|
||||
rule: rule(ctx, layer, g),
|
||||
stroke,
|
||||
},
|
||||
paths: acc.clone(),
|
||||
slots: live.clone(),
|
||||
xf: Mat3::IDENTITY,
|
||||
opacity: (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32,
|
||||
opacity: opacity(g),
|
||||
blend: blend_mode(ctx, layer, g),
|
||||
});
|
||||
}
|
||||
"trim" => {
|
||||
let (s, e, o) = (ctx.f(layer, g, "start", 0.0), ctx.f(layer, g, "end", 100.0), ctx.f(layer, g, "offset", 0.0));
|
||||
acc = effectcraft_path::ops::trim(&acc, s, e, o);
|
||||
for d in &mut draws {
|
||||
// Paint items above the trim are not affected (AE semantics).
|
||||
let _ = d;
|
||||
// Trim Multiple Shapes: 0 = Simultaneously (each path by the same fractions),
|
||||
// 1 = Individually (one after another, as one continuous length).
|
||||
if ctx.e(layer, g, "mode") == 1 {
|
||||
apply(arena, &live, |p| ops::trim_individually(p, s, e, o));
|
||||
} else {
|
||||
apply(arena, &live, |p| ops::trim(p, s, e, o));
|
||||
}
|
||||
}
|
||||
"pucker" => {
|
||||
acc = effectcraft_path::ops::pucker_bloat(&acc, ctx.f(layer, g, "amount", 0.0));
|
||||
let a = ctx.f(layer, g, "amount", 0.0);
|
||||
apply(arena, &live, |p| ops::pucker_bloat(p, a));
|
||||
}
|
||||
"round" => {
|
||||
let r = ctx.f(layer, g, "radius", 10.0);
|
||||
apply(arena, &live, |p| ops::round_corners(p, r));
|
||||
}
|
||||
"offset" => {
|
||||
let amount = ctx.f(layer, g, "amount", 10.0);
|
||||
let join = [Join::Miter, Join::Round, Join::Bevel][ctx.e(layer, g, "join").min(2) as usize];
|
||||
let miter = ctx.f(layer, g, "miter", 4.0);
|
||||
let copies = ctx.f(layer, g, "copies", 1.0);
|
||||
let copy_offset = ctx.f(layer, g, "copyOffset", 0.0);
|
||||
apply(arena, &live, |p| ops::offset(p, amount, join, miter, copies, copy_offset));
|
||||
}
|
||||
"twist" => {
|
||||
let angle = ctx.f(layer, g, "angle", 0.0);
|
||||
let center = ctx.v2(layer, g, "center", [0.0; 2]);
|
||||
apply(arena, &live, |p| ops::twist(p, angle, center));
|
||||
}
|
||||
"zigzag" => {
|
||||
let size = ctx.f(layer, g, "size", 10.0);
|
||||
let ridges = ctx.f(layer, g, "ridges", 5.0);
|
||||
let smooth = ctx.e(layer, g, "points") == 1;
|
||||
apply(arena, &live, |p| ops::zigzag(p, size, ridges, smooth));
|
||||
}
|
||||
"wiggle" => {
|
||||
let w = ops::WiggleParams {
|
||||
size: ctx.f(layer, g, "size", 10.0),
|
||||
detail: ctx.f(layer, g, "detail", 10.0),
|
||||
smooth: ctx.e(layer, g, "points") == 1,
|
||||
speed: ctx.f(layer, g, "speed", 2.0),
|
||||
correlation: ctx.f(layer, g, "correlation", 50.0),
|
||||
phase_deg: ctx.f(layer, g, "phase", 0.0),
|
||||
seed: ctx.f(layer, g, "seed", 0.0),
|
||||
};
|
||||
let t = layer.layer_time(ctx.time).seconds();
|
||||
apply(arena, &live, |p| ops::wiggle(p, &w, t));
|
||||
}
|
||||
"merge" => {
|
||||
let mode = ops::MergeMode::from_index(ctx.e(layer, g, "mode"));
|
||||
let input: Vec<BezPath> = live.iter().map(|&i| arena[i].clone()).collect();
|
||||
arena.push(ops::merge(&input, mode));
|
||||
live = vec![arena.len() - 1];
|
||||
// As in After Effects, fills and strokes above a Merge Paths in its group are not
|
||||
// rendered: the merged path is painted by the paint items below it.
|
||||
draws.clear();
|
||||
}
|
||||
"group" => {
|
||||
let Some(inner) = g.sub("contents") else { continue };
|
||||
let (cd, cp) = collect(ctx, layer, inner);
|
||||
let (cd, cl) = collect(ctx, layer, inner, arena);
|
||||
let (m, op) = g.sub("transform").map(|t| group_matrix(ctx, layer, t)).unwrap_or((Mat3::IDENTITY, 1.0));
|
||||
for i in used_slots(&cl, &cd) {
|
||||
arena[i] = mat_path(&arena[i], &m);
|
||||
}
|
||||
for mut d in cd {
|
||||
d.paths = mat_paths(&d.paths, &m);
|
||||
d.xf = m * d.xf;
|
||||
d.opacity *= op;
|
||||
draws.push(d);
|
||||
}
|
||||
acc.extend(mat_paths(&cp, &m));
|
||||
live.extend(cl);
|
||||
}
|
||||
"repeater" => {
|
||||
let copies = ctx.f(layer, g, "copies", 3.0).max(0.0);
|
||||
|
|
@ -171,114 +297,191 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> (Vec<Draw>, Ve
|
|||
* Mat3::translate(vec2(-anchor[0], -anchor[1]))
|
||||
};
|
||||
let base_draws = std::mem::take(&mut draws);
|
||||
let base_acc = std::mem::take(&mut acc);
|
||||
let base_live = std::mem::take(&mut live);
|
||||
let base_slots = used_slots(&base_live, &base_draws);
|
||||
// Each copy gets its own transformed slots, so operators below the repeater act on
|
||||
// the repeated geometry (copy order: original first).
|
||||
let mut copy_maps: Vec<std::collections::HashMap<usize, usize>> = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let m = step(i as f64 + offset);
|
||||
let mut map = std::collections::HashMap::new();
|
||||
for &s in &base_slots {
|
||||
arena.push(mat_path(&arena[s], &m));
|
||||
map.insert(s, arena.len() - 1);
|
||||
}
|
||||
live.extend(base_live.iter().map(|s| map[s]));
|
||||
copy_maps.push(map);
|
||||
}
|
||||
let above = ctx.e(layer, g, "composite") == 1;
|
||||
let mut order: Vec<usize> = (0..n).collect();
|
||||
if !above {
|
||||
order.reverse();
|
||||
}
|
||||
for i in order {
|
||||
let k = i as f64 + offset;
|
||||
let m = step(k);
|
||||
let m = step(i as f64 + offset);
|
||||
let t = if n > 1 { i as f32 / (n - 1) as f32 } else { 0.0 };
|
||||
let opk = so + (eo - so) * t;
|
||||
let partial = if i + 1 == n && copies.fract() > 0.0 { copies.fract() as f32 } else { 1.0 };
|
||||
for d in &base_draws {
|
||||
let mut d = d.clone();
|
||||
d.paths = mat_paths(&d.paths, &m);
|
||||
d.slots = d.slots.iter().map(|s| copy_maps[i][s]).collect();
|
||||
d.xf = m * d.xf;
|
||||
d.opacity *= opk * partial;
|
||||
draws.push(d);
|
||||
}
|
||||
acc.extend(mat_paths(&base_acc, &m));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(draws, acc)
|
||||
(draws, live)
|
||||
}
|
||||
|
||||
fn composite_coverage(img: &mut Image, cov: &Mask, color: [f32; 4], opacity: f32) {
|
||||
img.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(px, &c)| {
|
||||
let a = c * opacity * color[3];
|
||||
if a <= 0.0 {
|
||||
return;
|
||||
/// Pixel rectangle of the layer buffer (`x0, y0, width, height`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct PxRect {
|
||||
x0: u32,
|
||||
y0: u32,
|
||||
w: u32,
|
||||
h: u32,
|
||||
}
|
||||
|
||||
/// Composite `cov`-weighted colour (`color_at(x, y)` in buffer pixels, straight alpha) into the
|
||||
/// `r` sub-rectangle of a premultiplied image (`cov` is `r.w × r.h`).
|
||||
fn paint(img: &mut Image, r: PxRect, cov: &Mask, opacity: f32, blend: BlendMode, color_at: impl Fn(usize, usize) -> [f32; 4] + Sync) {
|
||||
let iw = img.width as usize;
|
||||
let (x0, y0, rw) = (r.x0 as usize, r.y0 as usize, r.w as usize);
|
||||
let row = |y: usize, out: &mut [[f32; 4]], crow: &[f32]| {
|
||||
for (dx, (px, &c)) in out[x0..x0 + rw].iter_mut().zip(crow).enumerate() {
|
||||
if c <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let x = x0 + dx;
|
||||
let col = color_at(x, y);
|
||||
let a = c * opacity * col[3];
|
||||
if a <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let src = [col[0] * a, col[1] * a, col[2] * a, a];
|
||||
if blend == BlendMode::Normal {
|
||||
let k = 1.0 - a;
|
||||
*px = [src[0] + px[0] * k, src[1] + px[1] * k, src[2] + px[2] * k, a + px[3] * k];
|
||||
} else {
|
||||
// Deterministic per-pixel noise for the dissolve modes.
|
||||
let h = ((x as u32).wrapping_mul(73_856_093) ^ (y as u32).wrapping_mul(19_349_663)).wrapping_mul(2_654_435_761);
|
||||
*px = blend_pixel(blend, *px, src, (h >> 8) as f32 / (1u32 << 24) as f32);
|
||||
}
|
||||
}
|
||||
let k = 1.0 - a;
|
||||
px[0] = color[0] * a + px[0] * k;
|
||||
px[1] = color[1] * a + px[1] * k;
|
||||
px[2] = color[2] * a + px[2] * k;
|
||||
px[3] = a + px[3] * k;
|
||||
});
|
||||
}
|
||||
|
||||
/// Render a shape layer's contents into a layer-space buffer at scale `s`.
|
||||
pub fn render(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, s: f64) -> Buf {
|
||||
let (draws, _) = collect(ctx, layer, contents);
|
||||
let mut bounds: Option<kurbo::Rect> = None;
|
||||
for d in &draws {
|
||||
if let Some(b) = effectcraft_path::bounds(&d.paths) {
|
||||
let grow = match &d.paint {
|
||||
Paint::Stroke { style, .. } => style.width * style.miter.max(1.0) * 0.5 * d.xf.mean_scale().max(1.0) + 1.0,
|
||||
_ => 1.0,
|
||||
};
|
||||
let b = b.inflate(grow, grow);
|
||||
bounds = Some(bounds.map_or(b, |a| a.union(b)));
|
||||
};
|
||||
let rows = img.data.chunks_mut(iw).skip(y0).take(r.h as usize);
|
||||
if r.w as usize * r.h as usize >= 64 * 1024 {
|
||||
let rows: Vec<&mut [[f32; 4]]> = rows.collect();
|
||||
rows.into_par_iter().zip(cov.data.par_chunks(rw)).enumerate().for_each(|(dy, (out, crow))| row(y0 + dy, out, crow));
|
||||
} else {
|
||||
for (dy, (out, crow)) in rows.zip(cov.data.chunks(rw)).enumerate() {
|
||||
row(y0 + dy, out, crow);
|
||||
}
|
||||
}
|
||||
let Some(b) = bounds else { return Buf { img: Image::new(1, 1), offset: [0.0, 0.0], scale: s } };
|
||||
}
|
||||
|
||||
/// Coverage of one draw inside its pixel rectangle.
|
||||
fn draw_coverage(d: &Draw, paths: &[BezPath], m: &Mat3, r: PxRect) -> Mask {
|
||||
// Rasterise into a small mask: shift the buffer matrix so the rectangle starts at 0,0.
|
||||
let sub = Mat3::translate(vec2(-(r.x0 as f64), -(r.y0 as f64))) * *m;
|
||||
let stroke = |style: &StrokeStyle| {
|
||||
// Strokes scale with their group transform.
|
||||
let mut st = style.clone();
|
||||
st.width *= d.xf.mean_scale();
|
||||
effectcraft_path::stroke_coverage(paths, &st, &sub, r.w, r.h)
|
||||
};
|
||||
match &d.paint {
|
||||
Paint::Fill { rule, .. } => effectcraft_path::fill_coverage(paths, &sub, r.w, r.h, *rule),
|
||||
Paint::Stroke { style, .. } => stroke(style),
|
||||
Paint::Gradient { stroke: Some(style), .. } => stroke(style),
|
||||
Paint::Gradient { rule, .. } => effectcraft_path::fill_coverage(paths, &sub, r.w, r.h, *rule),
|
||||
}
|
||||
}
|
||||
|
||||
/// Local-space bounds of what a draw can touch (stroke width, miters and anti-aliasing included).
|
||||
fn draw_bounds(d: &Draw, paths: &[BezPath]) -> Option<kurbo::Rect> {
|
||||
let b = effectcraft_path::bounds(paths)?;
|
||||
let stroke = match &d.paint {
|
||||
Paint::Stroke { style, .. } => Some(style),
|
||||
Paint::Gradient { stroke: Some(style), .. } => Some(style),
|
||||
_ => None,
|
||||
};
|
||||
let grow = stroke.map_or(1.0, |style| style.width * style.miter.max(1.0) * 0.5 * d.xf.mean_scale().max(1.0) + 1.0);
|
||||
Some(b.inflate(grow, grow))
|
||||
}
|
||||
|
||||
/// Maximum number of coverage pixels rasterised concurrently (bounds peak memory).
|
||||
const BATCH_PIXELS: usize = 8 << 20;
|
||||
|
||||
/// Render a shape layer's contents into a layer-space buffer at scale `s`.
|
||||
///
|
||||
/// Each draw rasterises and composites only its own pixel rectangle; coverage masks of
|
||||
/// consecutive draws are rasterised in parallel, then composited in stacking order.
|
||||
pub fn render(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, s: f64) -> Buf {
|
||||
let mut arena = Vec::new();
|
||||
let (draws, _) = collect(ctx, layer, contents, &mut arena);
|
||||
// Bottom-most draw first.
|
||||
let resolved: Vec<(&Draw, Vec<BezPath>, kurbo::Rect)> = draws
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|d| d.opacity > 0.0)
|
||||
.filter_map(|d| {
|
||||
let paths: Vec<BezPath> = d.slots.iter().map(|&i| arena[i].clone()).filter(|p| !p.elements().is_empty()).collect();
|
||||
let b = draw_bounds(d, &paths)?;
|
||||
Some((d, paths, b))
|
||||
})
|
||||
.collect();
|
||||
let Some(b) = resolved.iter().map(|r| r.2).reduce(|a, b| a.union(b)) else { return Buf { img: Image::new(1, 1), offset: [0.0, 0.0], scale: s } };
|
||||
// Keep buffers sane for runaway geometry.
|
||||
let b = b.intersect(kurbo::Rect::new(-20000.0, -20000.0, 20000.0, 20000.0));
|
||||
let w = ((b.width() * s).ceil() as u32 + 4).clamp(1, 16384);
|
||||
let h = ((b.height() * s).ceil() as u32 + 4).clamp(1, 16384);
|
||||
let offset = [-b.x0 * s + 2.0, -b.y0 * s + 2.0];
|
||||
let m = Mat3::translate(vec2(offset[0], offset[1])) * Mat3::scale(vec2(s, s));
|
||||
let px_rect = |r: kurbo::Rect| -> Option<PxRect> {
|
||||
let x0 = (r.x0 * s + offset[0]).floor().max(0.0);
|
||||
let y0 = (r.y0 * s + offset[1]).floor().max(0.0);
|
||||
let x1 = (r.x1 * s + offset[0]).ceil().min(w as f64);
|
||||
let y1 = (r.y1 * s + offset[1]).ceil().min(h as f64);
|
||||
(x1 > x0 && y1 > y0).then_some(PxRect { x0: x0 as u32, y0: y0 as u32, w: (x1 - x0) as u32, h: (y1 - y0) as u32 })
|
||||
};
|
||||
let jobs: Vec<(&Draw, &[BezPath], PxRect)> = resolved.iter().filter_map(|(d, p, b)| Some((*d, p.as_slice(), px_rect(*b)?))).collect();
|
||||
let mut img = Image::new(w, h);
|
||||
for d in draws.iter().rev() {
|
||||
if d.opacity <= 0.0 || d.paths.is_empty() {
|
||||
continue;
|
||||
let mut i = 0;
|
||||
while i < jobs.len() {
|
||||
// Next batch: as many draws as fit the pixel budget (at least one).
|
||||
let mut j = i;
|
||||
let mut px = 0usize;
|
||||
while j < jobs.len() && (j == i || px + jobs[j].2.w as usize * jobs[j].2.h as usize <= BATCH_PIXELS) {
|
||||
px += jobs[j].2.w as usize * jobs[j].2.h as usize;
|
||||
j += 1;
|
||||
}
|
||||
match &d.paint {
|
||||
Paint::Fill { color, rule } => {
|
||||
let cov = effectcraft_path::fill_coverage(&d.paths, &m, w, h, *rule);
|
||||
composite_coverage(&mut img, &cov, *color, d.opacity);
|
||||
}
|
||||
Paint::Stroke { color, style } => {
|
||||
// Strokes scale with their group transform.
|
||||
let mut st = style.clone();
|
||||
st.width *= d.xf.mean_scale();
|
||||
let cov = effectcraft_path::stroke_coverage(&d.paths, &st, &m, w, h);
|
||||
composite_coverage(&mut img, &cov, *color, d.opacity);
|
||||
}
|
||||
Paint::Gradient { g, radial, start, end, rule } => {
|
||||
let cov = effectcraft_path::fill_coverage(&d.paths, &m, w, h, *rule);
|
||||
let to_local = (m * d.xf).inverse().unwrap_or(Mat3::IDENTITY);
|
||||
let (sx, sy) = (start[0], start[1]);
|
||||
let (dx, dy) = (end[0] - sx, end[1] - sy);
|
||||
let len2 = (dx * dx + dy * dy).max(1e-9);
|
||||
let op = d.opacity;
|
||||
img.data.par_chunks_mut(w as usize).zip(cov.data.par_chunks(w as usize)).enumerate().for_each(|(y, (row, crow))| {
|
||||
for x in 0..row.len() {
|
||||
let c = crow[x];
|
||||
if c <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let covs: Vec<Mask> = jobs[i..j].par_iter().map(|(d, paths, r)| draw_coverage(d, paths, &m, *r)).collect();
|
||||
for ((d, _, r), cov) in jobs[i..j].iter().zip(&covs) {
|
||||
match &d.paint {
|
||||
Paint::Fill { color, .. } | Paint::Stroke { color, .. } => paint(&mut img, *r, cov, d.opacity, d.blend, |_, _| *color),
|
||||
Paint::Gradient { g, radial, start, end, .. } => {
|
||||
let to_local = (m * d.xf).inverse().unwrap_or(Mat3::IDENTITY);
|
||||
let (sx, sy) = (start[0], start[1]);
|
||||
let (dx, dy) = (end[0] - sx, end[1] - sy);
|
||||
let len2 = (dx * dx + dy * dy).max(1e-9);
|
||||
paint(&mut img, *r, cov, d.opacity, d.blend, |x, y| {
|
||||
let p = to_local.apply(vec2(x as f64 + 0.5, y as f64 + 0.5));
|
||||
let (vx, vy) = (p.x - sx, p.y - sy);
|
||||
let t = if *radial { ((vx * vx + vy * vy) / len2).sqrt() } else { (vx * dx + vy * dy) / len2 };
|
||||
let col = g.sample(t);
|
||||
let a = c * op * col[3];
|
||||
let k = 1.0 - a;
|
||||
let px = &mut row[x];
|
||||
px[0] = col[0] * a + px[0] * k;
|
||||
px[1] = col[1] * a + px[1] * k;
|
||||
px[2] = col[2] * a + px[2] * k;
|
||||
px[3] = a + px[3] * k;
|
||||
}
|
||||
});
|
||||
g.sample(t)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
Buf { img, offset, scale: s }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,342 @@
|
|||
//! Render-level tests for shape-layer path operators and paint items.
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_keyframe::{Gradient, Value};
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, PropGroup};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
use crate::render_frame;
|
||||
|
||||
/// A 200×100 comp with one shape layer (origin at the comp centre, 100,50) holding `items` at the
|
||||
/// top level of its contents.
|
||||
fn shape_comp(items: impl FnOnce(&mut Ids) -> Vec<PropGroup>) -> (Project, ItemId) {
|
||||
shape_comp_sized(200, 100, items)
|
||||
}
|
||||
|
||||
fn shape_comp_sized(w: u32, h: u32, items: impl FnOnce(&mut Ids) -> Vec<PropGroup>) -> (Project, ItemId) {
|
||||
let mut p = Project::default();
|
||||
let comp = Comp::new(w, h, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||||
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
|
||||
let mut l = build::layer(&mut p, &comp, "Shape Layer 1", LayerSource::Shape, (w, h), None);
|
||||
let mut next = p.next_id;
|
||||
let items = items(&mut Ids(&mut next));
|
||||
p.next_id = next;
|
||||
let contents = l.props.sub_mut("contents").unwrap();
|
||||
for i in items {
|
||||
contents.children.push(i.into());
|
||||
}
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
fn frame(p: &Project, cid: ItemId) -> Image {
|
||||
render_frame(p, cid, Tick::ZERO, 1.0)
|
||||
}
|
||||
|
||||
fn alpha(img: &Image, x: i64, y: i64) -> f32 {
|
||||
img.get(x, y)[3]
|
||||
}
|
||||
|
||||
fn coverage(img: &Image) -> f32 {
|
||||
img.data.iter().map(|px| px[3]).sum()
|
||||
}
|
||||
|
||||
fn set(g: &mut PropGroup, path: &str, v: Value) {
|
||||
g.prop_mut(path).unwrap_or_else(|| panic!("no {path}")).value = v;
|
||||
}
|
||||
|
||||
fn op(ids: &mut Ids, kind: &str, params: &[(&str, Value)]) -> PropGroup {
|
||||
let mut g = build::shape_simple_op(ids, kind).unwrap();
|
||||
for (k, v) in params {
|
||||
set(&mut g, k, v.clone());
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
const RED: [f64; 4] = [1.0, 0.0, 0.0, 1.0];
|
||||
const GREEN: [f64; 4] = [0.0, 1.0, 0.0, 1.0];
|
||||
|
||||
#[test]
|
||||
fn merge_add_feeds_fill_below_and_drops_paint_above() {
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
vec![
|
||||
build::shape_rect(ids, [40.0, 40.0], [-20.0, 0.0], 0.0),
|
||||
build::shape_rect(ids, [40.0, 40.0], [20.0, 0.0], 0.0),
|
||||
build::shape_fill(ids, RED),
|
||||
op(ids, "merge", &[("mode", Value::Enum(1))]),
|
||||
build::shape_fill(ids, GREEN),
|
||||
]
|
||||
});
|
||||
let img = frame(&p, cid);
|
||||
let c = img.get(100, 50);
|
||||
assert!(c[1] > 0.99 && c[0] < 0.01, "merged path painted by the fill below only: {c:?}");
|
||||
assert!(img.get(65, 50)[1] > 0.99 && img.get(135, 50)[1] > 0.99);
|
||||
assert!((coverage(&img) - 80.0 * 40.0).abs() < 20.0, "{}", coverage(&img));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_subtract_and_intersect() {
|
||||
let sub = |mode: u32| {
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
vec![
|
||||
build::shape_rect(ids, [80.0, 60.0], [0.0, 0.0], 0.0),
|
||||
build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0),
|
||||
op(ids, "merge", &[("mode", Value::Enum(mode))]),
|
||||
build::shape_fill(ids, RED),
|
||||
]
|
||||
});
|
||||
frame(&p, cid)
|
||||
};
|
||||
let s = sub(2);
|
||||
assert!(alpha(&s, 100, 50) < 0.01, "hole");
|
||||
assert!(alpha(&s, 70, 50) > 0.99);
|
||||
assert!((coverage(&s) - (80.0 * 60.0 - 400.0)).abs() < 20.0);
|
||||
let i = sub(3);
|
||||
assert!(alpha(&i, 100, 50) > 0.99);
|
||||
assert!(alpha(&i, 70, 50) < 0.01);
|
||||
assert!((coverage(&i) - 400.0).abs() < 10.0);
|
||||
let x = sub(4);
|
||||
assert!((coverage(&x) - (80.0 * 60.0 - 400.0)).abs() < 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_paths_grow_and_shrink_fill() {
|
||||
let run = |amount: f64| {
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
vec![build::shape_rect(ids, [40.0, 40.0], [0.0, 0.0], 0.0), op(ids, "offset", &[("amount", Value::Scalar(amount))]), build::shape_fill(ids, RED)]
|
||||
});
|
||||
frame(&p, cid)
|
||||
};
|
||||
let g = run(10.0);
|
||||
assert!((coverage(&g) - 60.0 * 60.0).abs() < 20.0, "{}", coverage(&g));
|
||||
assert!(alpha(&g, 100 + 25, 50) > 0.99);
|
||||
let s = run(-10.0);
|
||||
assert!((coverage(&s) - 20.0 * 20.0).abs() < 10.0, "{}", coverage(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operators_below_repeater_act_on_copies() {
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
let mut rep = build::shape_repeater(ids, 3.0, [30.0, 0.0]);
|
||||
set(&mut rep, "transform/position", Value::Vec2([30.0, 0.0]));
|
||||
vec![build::shape_rect(ids, [20.0, 20.0], [-30.0, 0.0], 0.0), rep, op(ids, "offset", &[("amount", Value::Scalar(2.0))]), build::shape_fill(ids, RED)]
|
||||
});
|
||||
let img = frame(&p, cid);
|
||||
// Three 24×24 squares (offset by 2) at x = -30, 0, 30.
|
||||
for cx in [70, 100, 130] {
|
||||
assert!(alpha(&img, cx, 50) > 0.99, "copy at {cx}");
|
||||
assert!(alpha(&img, cx + 11, 50) > 0.99, "offset applied to copy at {cx}");
|
||||
}
|
||||
assert!((coverage(&img) - 3.0 * 24.0 * 24.0).abs() < 30.0, "{}", coverage(&img));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeater_then_merge_unites_copies() {
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
vec![
|
||||
build::shape_rect(ids, [20.0, 20.0], [-10.0, 0.0], 0.0),
|
||||
build::shape_repeater(ids, 2.0, [10.0, 0.0]),
|
||||
op(ids, "merge", &[("mode", Value::Enum(4))]),
|
||||
build::shape_fill(ids, RED),
|
||||
]
|
||||
});
|
||||
let img = frame(&p, cid);
|
||||
// Two squares overlapping by 10 px: exclude leaves the two 10×20 ends.
|
||||
assert!(alpha(&img, 95, 50) < 0.01);
|
||||
assert!(alpha(&img, 85, 50) > 0.99 && alpha(&img, 105, 50) > 0.99);
|
||||
assert!((coverage(&img) - 400.0).abs() < 10.0, "{}", coverage(&img));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_below_stroke_trims_it() {
|
||||
let run = |end: f64| {
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
let mut t = build::shape_trim(ids, 0.0, end, 0.0);
|
||||
set(&mut t, "end", Value::Scalar(end));
|
||||
vec![build::shape_rect(ids, [60.0, 60.0], [0.0, 0.0], 0.0), build::shape_stroke(ids, [1.0; 4], 4.0), t]
|
||||
});
|
||||
coverage(&frame(&p, cid))
|
||||
};
|
||||
let full = run(100.0);
|
||||
let half = run(50.0);
|
||||
assert!(full > 900.0);
|
||||
assert!((half / full - 0.5).abs() < 0.05, "{half} / {full}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_individually_vs_simultaneously() {
|
||||
let run = |mode: u32| {
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
let items = vec![build::shape_rect(ids, [40.0, 40.0], [-40.0, 0.0], 0.0), build::shape_stroke(ids, [1.0; 4], 2.0)];
|
||||
let a = build::shape_group(ids, "A", items);
|
||||
let items = vec![build::shape_rect(ids, [40.0, 40.0], [40.0, 0.0], 0.0), build::shape_stroke(ids, [1.0; 4], 2.0)];
|
||||
let b = build::shape_group(ids, "B", items);
|
||||
let mut t = build::shape_trim(ids, 0.0, 50.0, 0.0);
|
||||
set(&mut t, "mode", Value::Enum(mode));
|
||||
vec![a, b, t]
|
||||
});
|
||||
let img = frame(&p, cid);
|
||||
let left: f32 = (0..100).flat_map(|x| (0..100).map(move |y| (x, y))).map(|(x, y)| alpha(&img, x, y)).sum();
|
||||
let right: f32 = (100..200).flat_map(|x| (0..100).map(move |y| (x, y))).map(|(x, y)| alpha(&img, x, y)).sum();
|
||||
(left, right)
|
||||
};
|
||||
let (l, r) = run(0);
|
||||
assert!((l - r).abs() < 0.1 * l, "simultaneously: both half drawn {l} {r}");
|
||||
let (l, r) = run(1);
|
||||
assert!(l > 250.0 && r < 1.0, "individually: first path complete, second not started {l} {r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_stroke_paints_gradient_along_stroke() {
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
let g = Gradient { colors: vec![(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] };
|
||||
vec![build::shape_rect(ids, [60.0, 60.0], [0.0, 0.0], 0.0), build::shape_gradient_stroke(ids, false, [-30.0, 0.0], [30.0, 0.0], g, 6.0)]
|
||||
});
|
||||
let img = frame(&p, cid);
|
||||
let l = img.get(70, 50);
|
||||
let r = img.get(130, 50);
|
||||
assert!(l[3] > 0.99 && r[3] > 0.99, "{l:?} {r:?}");
|
||||
assert!(l[0] < 0.1 && r[0] > 0.9, "{l:?} {r:?}");
|
||||
assert!(alpha(&img, 100, 50) < 0.01, "stroke only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_blend_mode_multiply() {
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
let mut top = build::shape_fill(ids, [0.5, 0.5, 0.5, 1.0]);
|
||||
set(
|
||||
&mut top,
|
||||
"blend",
|
||||
Value::Enum(effectcraft_color::BlendMode::ALL.iter().position(|m| *m == effectcraft_color::BlendMode::Multiply).unwrap() as u32),
|
||||
);
|
||||
vec![build::shape_rect(ids, [40.0, 40.0], [0.0, 0.0], 0.0), top, build::shape_fill(ids, [1.0, 0.0, 0.0, 1.0])]
|
||||
});
|
||||
let c = frame(&p, cid).get(100, 50);
|
||||
assert!((c[0] - 0.5).abs() < 0.01 && c[1] < 0.01, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reversed_path_direction_cuts_hole_under_non_zero() {
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
let mut inner = build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0);
|
||||
set(&mut inner, "direction", Value::Enum(1));
|
||||
vec![build::shape_rect(ids, [60.0, 60.0], [0.0, 0.0], 0.0), inner, op(ids, "merge", &[]), build::shape_fill(ids, RED)]
|
||||
});
|
||||
let img = frame(&p, cid);
|
||||
assert!(alpha(&img, 100, 50) < 0.01);
|
||||
assert!(alpha(&img, 75, 50) > 0.99);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_corners_clip_corner_pixels() {
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
vec![build::shape_rect(ids, [40.0, 40.0], [0.0, 0.0], 0.0), op(ids, "round", &[("radius", Value::Scalar(20.0))]), build::shape_fill(ids, RED)]
|
||||
});
|
||||
let img = frame(&p, cid);
|
||||
assert!(alpha(&img, 81, 31) < 0.01, "corner rounded off");
|
||||
assert!(alpha(&img, 100, 50) > 0.99);
|
||||
assert!((coverage(&img) - std::f32::consts::PI * 400.0).abs() < 15.0, "{}", coverage(&img));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wiggle_paths_vary_over_time_deterministically() {
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
vec![build::shape_ellipse(ids, [60.0, 60.0], [0.0, 0.0]), op(ids, "wiggle", &[("size", Value::Scalar(8.0))]), build::shape_fill(ids, RED)]
|
||||
});
|
||||
let a = render_frame(&p, cid, Tick::from_seconds_f64(0.5), 1.0);
|
||||
let b = render_frame(&p, cid, Tick::from_seconds_f64(0.5), 1.0);
|
||||
let c = render_frame(&p, cid, Tick::from_seconds_f64(1.0), 1.0);
|
||||
assert_eq!(a.data, b.data);
|
||||
assert_ne!(a.data, c.data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zigzag_and_twist_render() {
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
vec![
|
||||
build::shape_rect(ids, [60.0, 60.0], [0.0, 0.0], 0.0),
|
||||
op(ids, "zigzag", &[("size", Value::Scalar(5.0)), ("ridges", Value::Scalar(4.0))]),
|
||||
op(ids, "twist", &[("angle", Value::Scalar(45.0))]),
|
||||
build::shape_fill(ids, RED),
|
||||
]
|
||||
});
|
||||
let img = frame(&p, cid);
|
||||
assert!(alpha(&img, 100, 50) > 0.99);
|
||||
let cov = coverage(&img);
|
||||
assert!(cov > 2000.0 && cov < 4600.0, "{cov}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeater_draws_match_per_copy_bounds() {
|
||||
// Many small copies spread over a large buffer: each must rasterise correctly in its own
|
||||
// sub-rectangle.
|
||||
let (p, cid) = shape_comp_sized(400, 400, |ids| {
|
||||
let mut rep = build::shape_repeater(ids, 12.0, [0.0, 0.0]);
|
||||
set(&mut rep, "transform/rotation", Value::Scalar(30.0));
|
||||
let items = vec![build::shape_rect(ids, [10.0, 30.0], [0.0, -150.0], 3.0), build::shape_fill(ids, RED)];
|
||||
let g = build::shape_group(ids, "Tick", items);
|
||||
vec![g, rep]
|
||||
});
|
||||
let img = frame(&p, cid);
|
||||
for k in 0..12 {
|
||||
let a = (k as f64 * 30.0).to_radians();
|
||||
let (x, y) = (200.0 + 150.0 * a.sin(), 200.0 - 150.0 * a.cos());
|
||||
assert!(alpha(&img, x.round() as i64, y.round() as i64) > 0.99, "copy {k}");
|
||||
}
|
||||
assert!(alpha(&img, 200, 200) < 0.01);
|
||||
let expect = 12.0 * (300.0 - (4.0 - std::f32::consts::PI) * 9.0);
|
||||
assert!((coverage(&img) - expect).abs() < 40.0, "{}", coverage(&img));
|
||||
}
|
||||
|
||||
/// Performance probe: 60-copy repeater of a small rounded rect at 1920×1080.
|
||||
/// Run with `cargo test -p effectcraft-render --release -- --ignored --nocapture shape_perf`.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn shape_perf_repeater_60_copies_1080p() {
|
||||
let (p, cid) = shape_comp_sized(1920, 1080, |ids| {
|
||||
let mut rep = build::shape_repeater(ids, 60.0, [0.0, 0.0]);
|
||||
set(&mut rep, "transform/rotation", Value::Scalar(6.0));
|
||||
let items = vec![
|
||||
build::shape_rect(ids, [12.0, 60.0], [0.0, -420.0], 4.0),
|
||||
build::shape_fill(ids, [1.0, 0.8, 0.2, 1.0]),
|
||||
build::shape_stroke(ids, [1.0; 4], 2.0),
|
||||
];
|
||||
let g = build::shape_group(ids, "Tick", items);
|
||||
vec![g, rep]
|
||||
});
|
||||
let _ = render_frame(&p, cid, Tick::ZERO, 1.0);
|
||||
let n = 10;
|
||||
let t0 = std::time::Instant::now();
|
||||
for _ in 0..n {
|
||||
let _ = render_frame(&p, cid, Tick::ZERO, 1.0);
|
||||
}
|
||||
let ms = t0.elapsed().as_secs_f64() * 1000.0 / n as f64;
|
||||
println!("shape_perf: 60-copy repeater at 1920x1080: {ms:.2} ms/frame");
|
||||
let ItemKind::Comp(comp) = &p.item(cid).unwrap().kind else { unreachable!() };
|
||||
let layer = &comp.layers[0];
|
||||
let ctx = crate::eval::EvalCtx::new(&p, cid, comp, Tick::ZERO);
|
||||
let contents = layer.props.sub("contents").unwrap();
|
||||
let t0 = std::time::Instant::now();
|
||||
for _ in 0..n {
|
||||
let _ = super::render(&ctx, layer, contents, 1.0);
|
||||
}
|
||||
let ms = t0.elapsed().as_secs_f64() * 1000.0 / n as f64;
|
||||
println!("shape_perf: shapes::render alone: {ms:.2} ms");
|
||||
let mut arena = Vec::new();
|
||||
let t0 = std::time::Instant::now();
|
||||
for _ in 0..n {
|
||||
arena.clear();
|
||||
let _ = super::collect(&ctx, layer, contents, &mut arena);
|
||||
}
|
||||
let ms = t0.elapsed().as_secs_f64() * 1000.0 / n as f64;
|
||||
println!("shape_perf: collect alone: {ms:.2} ms");
|
||||
let (empty, ecid) = shape_comp_sized(1920, 1080, |_| vec![]);
|
||||
let t0 = std::time::Instant::now();
|
||||
for _ in 0..n {
|
||||
let _ = render_frame(&empty, ecid, Tick::ZERO, 1.0);
|
||||
}
|
||||
let ms = t0.elapsed().as_secs_f64() * 1000.0 / n as f64;
|
||||
println!("shape_perf: empty shape layer comp (compositor baseline): {ms:.2} ms");
|
||||
}
|
||||
Loading…
Reference in New Issue