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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 14:26:28 -04:00
co-authored by Claude Opus 5.5
parent 808cd490cd
commit ba2fa1bddf
11 changed files with 2384 additions and 205 deletions
+315 -112
View File
@@ -1,11 +1,18 @@
//! Shape-layer contents: groups, parametric paths, fills, strokes, gradients and path operators,
//! with the After Effects stacking semantics (operators and paint items act on the paths above
//! them in the same group; items higher in the list draw on top).
//! with the After Effects stacking semantics:
//!
//! - path operators (Trim Paths, Round Corners, Offset Paths, Pucker & Bloat, Twist, Zig Zag,
//! Wiggle Paths, Merge Paths, Repeater) act on all paths above them in the same group, including
//! the paths of nested groups;
//! - paint items (fills, strokes, gradient fills/strokes) paint the paths above them, *as modified
//! by every operator in the stack* — a Trim Paths below a Stroke still trims it;
//! - items higher in the list draw on top.
use effectcraft_color::{BlendMode, blend_pixel};
use effectcraft_effects::Buf;
use effectcraft_geom::{Mat3, vec2};
use effectcraft_keyframe::{Gradient, Value};
use effectcraft_path::{BezPath, Cap, FillRule, Join, StrokeStyle};
use effectcraft_path::{BezPath, Cap, FillRule, Join, StrokeStyle, ops};
use effectcraft_project::{Layer, Node, PropGroup};
use effectcraft_raster::{Image, Mask};
use rayon::prelude::*;
@@ -14,22 +21,40 @@ use crate::eval::EvalCtx;
#[derive(Clone, Debug)]
enum Paint {
Fill { color: [f32; 4], rule: FillRule },
Stroke { color: [f32; 4], style: StrokeStyle },
Gradient { g: Gradient, radial: bool, start: [f64; 2], end: [f64; 2], rule: FillRule },
Fill {
color: [f32; 4],
rule: FillRule,
},
Stroke {
color: [f32; 4],
style: StrokeStyle,
},
/// Gradient fill, or gradient stroke when `stroke` is set.
Gradient {
g: Gradient,
radial: bool,
start: [f64; 2],
end: [f64; 2],
rule: FillRule,
stroke: Option<StrokeStyle>,
},
}
/// A paint item. It references path *slots* rather than copies of the geometry: path operators
/// further down the stack rewrite the slots in place, so (as in After Effects) a Trim Paths or
/// Round Corners placed below a fill or stroke still changes what that paint draws.
#[derive(Clone, Debug)]
struct Draw {
paint: Paint,
paths: Vec<BezPath>,
/// Group-space → layer-space for gradients and stroke scaling.
slots: Vec<usize>,
/// Paint-group space → layer space, for gradients and stroke scaling.
xf: Mat3,
opacity: f32,
blend: BlendMode,
}
fn mat_paths(paths: &[BezPath], m: &Mat3) -> Vec<BezPath> {
effectcraft_path::transform(paths, m)
fn mat_path(path: &BezPath, m: &Mat3) -> BezPath {
effectcraft_path::transform(std::slice::from_ref(path), m).pop().unwrap_or_default()
}
fn group_matrix(ctx: &EvalCtx, layer: &Layer, tr: &PropGroup) -> (Mat3, f32) {
@@ -51,10 +76,55 @@ fn rule(ctx: &EvalCtx, layer: &Layer, g: &PropGroup) -> FillRule {
if ctx.e(layer, g, "rule") == 1 { FillRule::EvenOdd } else { FillRule::NonZero }
}
/// Collect draws and accumulated paths of a contents group (in group space).
fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> (Vec<Draw>, Vec<BezPath>) {
fn stroke_style(ctx: &EvalCtx, layer: &Layer, g: &PropGroup) -> StrokeStyle {
let dash = g.sub("dashes").and_then(|d| {
let dash = ctx.f(layer, d, "dash", 0.0);
let gap = ctx.f(layer, d, "gap", 0.0);
(dash > 0.0).then(|| (vec![dash, if gap > 0.0 { gap } else { dash }], ctx.f(layer, d, "offset", 0.0)))
});
StrokeStyle {
width: ctx.f(layer, g, "width", 2.0),
cap: [Cap::Butt, Cap::Round, Cap::Square][ctx.e(layer, g, "cap").min(2) as usize],
join: [Join::Miter, Join::Round, Join::Bevel][ctx.e(layer, g, "join").min(2) as usize],
miter: ctx.f(layer, g, "miter", 4.0),
dash,
}
}
/// Blend mode of a paint item (`blend` holds an index into the Blending Mode menu).
fn blend_mode(ctx: &EvalCtx, layer: &Layer, g: &PropGroup) -> BlendMode {
BlendMode::ALL.get(ctx.e(layer, g, "blend") as usize).copied().unwrap_or_default()
}
/// Apply a path operator to the live slots in place.
fn apply(arena: &mut [BezPath], live: &[usize], f: impl FnOnce(&[BezPath]) -> Vec<BezPath>) {
let input: Vec<BezPath> = live.iter().map(|&i| arena[i].clone()).collect();
for (&i, p) in live.iter().zip(f(&input)) {
arena[i] = p;
}
}
/// Push a shape's path as a new slot, honouring its Path Direction toggle (1 = reversed).
fn push_shape(ctx: &EvalCtx, layer: &Layer, g: &PropGroup, p: BezPath, arena: &mut Vec<BezPath>, live: &mut Vec<usize>) {
let p = if ctx.e(layer, g, "direction") == 1 { p.reverse_subpaths() } else { p };
arena.push(p);
live.push(arena.len() - 1);
}
/// Every slot used by `live` or by `draws`, sorted and unique.
fn used_slots(live: &[usize], draws: &[Draw]) -> Vec<usize> {
let mut v: Vec<usize> = live.iter().copied().chain(draws.iter().flat_map(|d| d.slots.iter().copied())).collect();
v.sort_unstable();
v.dedup();
v
}
/// Collect the draws of a contents group and the path slots live at its end (in group space).
/// `arena` holds every path slot; operators rewrite slots in place.
fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<BezPath>) -> (Vec<Draw>, Vec<usize>) {
let mut draws: Vec<Draw> = Vec::new();
let mut acc: Vec<BezPath> = Vec::new();
let mut live: Vec<usize> = Vec::new();
let opacity = |g: &PropGroup| (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32;
for node in &contents.children {
let Node::Group(g) = node else { continue };
if !g.enabled {
@@ -64,16 +134,17 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> (Vec<Draw>, Ve
"rect" => {
let size = ctx.v2(layer, g, "size", [100.0; 2]);
let pos = ctx.v2(layer, g, "position", [0.0; 2]);
acc.push(effectcraft_path::rect(size, pos, ctx.f(layer, g, "roundness", 0.0)));
let p = effectcraft_path::rect(size, pos, ctx.f(layer, g, "roundness", 0.0));
push_shape(ctx, layer, g, p, arena, &mut live);
}
"ellipse" => {
let size = ctx.v2(layer, g, "size", [100.0; 2]);
let pos = ctx.v2(layer, g, "position", [0.0; 2]);
acc.push(effectcraft_path::ellipse(size, pos));
push_shape(ctx, layer, g, effectcraft_path::ellipse(size, pos), arena, &mut live);
}
"star" => {
let star = ctx.e(layer, g, "type") == 0;
acc.push(effectcraft_path::polystar(
let p = effectcraft_path::polystar(
star,
ctx.f(layer, g, "points", 5.0),
ctx.v2(layer, g, "position", [0.0; 2]),
@@ -82,40 +153,43 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> (Vec<Draw>, Ve
ctx.f(layer, g, "outerRadius", 100.0),
ctx.f(layer, g, "innerRoundness", 0.0),
ctx.f(layer, g, "outerRoundness", 0.0),
));
);
push_shape(ctx, layer, g, p, arena, &mut live);
}
"path" => {
if let Some(Value::Path(p)) = ctx.group_value(layer, g, "path") {
acc.push(effectcraft_path::to_kurbo(&p));
push_shape(ctx, layer, g, effectcraft_path::to_kurbo(&p), arena, &mut live);
}
}
"fill" => {
let mut c = ctx.color(layer, g, "color");
c[3] = 1.0;
draws.push(Draw { paint: Paint::Fill { color: c, rule: rule(ctx, layer, g) }, paths: acc.clone(), xf: Mat3::IDENTITY, opacity: (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32 });
draws.push(Draw {
paint: Paint::Fill { color: c, rule: rule(ctx, layer, g) },
slots: live.clone(),
xf: Mat3::IDENTITY,
opacity: opacity(g),
blend: blend_mode(ctx, layer, g),
});
}
"stroke" => {
let mut c = ctx.color(layer, g, "color");
c[3] = 1.0;
let dash = g.sub("dashes").and_then(|d| {
let dash = ctx.f(layer, d, "dash", 0.0);
let gap = ctx.f(layer, d, "gap", 0.0);
(dash > 0.0).then(|| (vec![dash, if gap > 0.0 { gap } else { dash }], ctx.f(layer, d, "offset", 0.0)))
let style = stroke_style(ctx, layer, g);
draws.push(Draw {
paint: Paint::Stroke { color: c, style },
slots: live.clone(),
xf: Mat3::IDENTITY,
opacity: opacity(g),
blend: blend_mode(ctx, layer, g),
});
let style = StrokeStyle {
width: ctx.f(layer, g, "width", 2.0),
cap: [Cap::Butt, Cap::Round, Cap::Square][ctx.e(layer, g, "cap").min(2) as usize],
join: [Join::Miter, Join::Round, Join::Bevel][ctx.e(layer, g, "join").min(2) as usize],
miter: ctx.f(layer, g, "miter", 4.0),
dash,
};
draws.push(Draw { paint: Paint::Stroke { color: c, style }, paths: acc.clone(), xf: Mat3::IDENTITY, opacity: (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32 });
}
"gfill" => {
"gfill" | "gstroke" => {
let gr = match ctx.group_value(layer, g, "colors") {
Some(Value::Gradient(gr)) => gr,
_ => Gradient::default(),
};
let stroke = (g.match_id == "gstroke").then(|| stroke_style(ctx, layer, g));
draws.push(Draw {
paint: Paint::Gradient {
g: gr,
@@ -123,34 +197,86 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> (Vec<Draw>, Ve
start: ctx.v2(layer, g, "start", [0.0; 2]),
end: ctx.v2(layer, g, "end", [100.0, 0.0]),
rule: rule(ctx, layer, g),
stroke,
},
paths: acc.clone(),
slots: live.clone(),
xf: Mat3::IDENTITY,
opacity: (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32,
opacity: opacity(g),
blend: blend_mode(ctx, layer, g),
});
}
"trim" => {
let (s, e, o) = (ctx.f(layer, g, "start", 0.0), ctx.f(layer, g, "end", 100.0), ctx.f(layer, g, "offset", 0.0));
acc = effectcraft_path::ops::trim(&acc, s, e, o);
for d in &mut draws {
// Paint items above the trim are not affected (AE semantics).
let _ = d;
// Trim Multiple Shapes: 0 = Simultaneously (each path by the same fractions),
// 1 = Individually (one after another, as one continuous length).
if ctx.e(layer, g, "mode") == 1 {
apply(arena, &live, |p| ops::trim_individually(p, s, e, o));
} else {
apply(arena, &live, |p| ops::trim(p, s, e, o));
}
}
"pucker" => {
acc = effectcraft_path::ops::pucker_bloat(&acc, ctx.f(layer, g, "amount", 0.0));
let a = ctx.f(layer, g, "amount", 0.0);
apply(arena, &live, |p| ops::pucker_bloat(p, a));
}
"round" => {
let r = ctx.f(layer, g, "radius", 10.0);
apply(arena, &live, |p| ops::round_corners(p, r));
}
"offset" => {
let amount = ctx.f(layer, g, "amount", 10.0);
let join = [Join::Miter, Join::Round, Join::Bevel][ctx.e(layer, g, "join").min(2) as usize];
let miter = ctx.f(layer, g, "miter", 4.0);
let copies = ctx.f(layer, g, "copies", 1.0);
let copy_offset = ctx.f(layer, g, "copyOffset", 0.0);
apply(arena, &live, |p| ops::offset(p, amount, join, miter, copies, copy_offset));
}
"twist" => {
let angle = ctx.f(layer, g, "angle", 0.0);
let center = ctx.v2(layer, g, "center", [0.0; 2]);
apply(arena, &live, |p| ops::twist(p, angle, center));
}
"zigzag" => {
let size = ctx.f(layer, g, "size", 10.0);
let ridges = ctx.f(layer, g, "ridges", 5.0);
let smooth = ctx.e(layer, g, "points") == 1;
apply(arena, &live, |p| ops::zigzag(p, size, ridges, smooth));
}
"wiggle" => {
let w = ops::WiggleParams {
size: ctx.f(layer, g, "size", 10.0),
detail: ctx.f(layer, g, "detail", 10.0),
smooth: ctx.e(layer, g, "points") == 1,
speed: ctx.f(layer, g, "speed", 2.0),
correlation: ctx.f(layer, g, "correlation", 50.0),
phase_deg: ctx.f(layer, g, "phase", 0.0),
seed: ctx.f(layer, g, "seed", 0.0),
};
let t = layer.layer_time(ctx.time).seconds();
apply(arena, &live, |p| ops::wiggle(p, &w, t));
}
"merge" => {
let mode = ops::MergeMode::from_index(ctx.e(layer, g, "mode"));
let input: Vec<BezPath> = live.iter().map(|&i| arena[i].clone()).collect();
arena.push(ops::merge(&input, mode));
live = vec![arena.len() - 1];
// As in After Effects, fills and strokes above a Merge Paths in its group are not
// rendered: the merged path is painted by the paint items below it.
draws.clear();
}
"group" => {
let Some(inner) = g.sub("contents") else { continue };
let (cd, cp) = collect(ctx, layer, inner);
let (cd, cl) = collect(ctx, layer, inner, arena);
let (m, op) = g.sub("transform").map(|t| group_matrix(ctx, layer, t)).unwrap_or((Mat3::IDENTITY, 1.0));
for i in used_slots(&cl, &cd) {
arena[i] = mat_path(&arena[i], &m);
}
for mut d in cd {
d.paths = mat_paths(&d.paths, &m);
d.xf = m * d.xf;
d.opacity *= op;
draws.push(d);
}
acc.extend(mat_paths(&cp, &m));
live.extend(cl);
}
"repeater" => {
let copies = ctx.f(layer, g, "copies", 3.0).max(0.0);
@@ -171,114 +297,191 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> (Vec<Draw>, Ve
* Mat3::translate(vec2(-anchor[0], -anchor[1]))
};
let base_draws = std::mem::take(&mut draws);
let base_acc = std::mem::take(&mut acc);
let base_live = std::mem::take(&mut live);
let base_slots = used_slots(&base_live, &base_draws);
// Each copy gets its own transformed slots, so operators below the repeater act on
// the repeated geometry (copy order: original first).
let mut copy_maps: Vec<std::collections::HashMap<usize, usize>> = Vec::with_capacity(n);
for i in 0..n {
let m = step(i as f64 + offset);
let mut map = std::collections::HashMap::new();
for &s in &base_slots {
arena.push(mat_path(&arena[s], &m));
map.insert(s, arena.len() - 1);
}
live.extend(base_live.iter().map(|s| map[s]));
copy_maps.push(map);
}
let above = ctx.e(layer, g, "composite") == 1;
let mut order: Vec<usize> = (0..n).collect();
if !above {
order.reverse();
}
for i in order {
let k = i as f64 + offset;
let m = step(k);
let m = step(i as f64 + offset);
let t = if n > 1 { i as f32 / (n - 1) as f32 } else { 0.0 };
let opk = so + (eo - so) * t;
let partial = if i + 1 == n && copies.fract() > 0.0 { copies.fract() as f32 } else { 1.0 };
for d in &base_draws {
let mut d = d.clone();
d.paths = mat_paths(&d.paths, &m);
d.slots = d.slots.iter().map(|s| copy_maps[i][s]).collect();
d.xf = m * d.xf;
d.opacity *= opk * partial;
draws.push(d);
}
acc.extend(mat_paths(&base_acc, &m));
}
}
_ => {}
}
}
(draws, acc)
(draws, live)
}
fn composite_coverage(img: &mut Image, cov: &Mask, color: [f32; 4], opacity: f32) {
img.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(px, &c)| {
let a = c * opacity * color[3];
if a <= 0.0 {
return;
/// Pixel rectangle of the layer buffer (`x0, y0, width, height`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct PxRect {
x0: u32,
y0: u32,
w: u32,
h: u32,
}
/// Composite `cov`-weighted colour (`color_at(x, y)` in buffer pixels, straight alpha) into the
/// `r` sub-rectangle of a premultiplied image (`cov` is `r.w × r.h`).
fn paint(img: &mut Image, r: PxRect, cov: &Mask, opacity: f32, blend: BlendMode, color_at: impl Fn(usize, usize) -> [f32; 4] + Sync) {
let iw = img.width as usize;
let (x0, y0, rw) = (r.x0 as usize, r.y0 as usize, r.w as usize);
let row = |y: usize, out: &mut [[f32; 4]], crow: &[f32]| {
for (dx, (px, &c)) in out[x0..x0 + rw].iter_mut().zip(crow).enumerate() {
if c <= 0.0 {
continue;
}
let x = x0 + dx;
let col = color_at(x, y);
let a = c * opacity * col[3];
if a <= 0.0 {
continue;
}
let src = [col[0] * a, col[1] * a, col[2] * a, a];
if blend == BlendMode::Normal {
let k = 1.0 - a;
*px = [src[0] + px[0] * k, src[1] + px[1] * k, src[2] + px[2] * k, a + px[3] * k];
} else {
// Deterministic per-pixel noise for the dissolve modes.
let h = ((x as u32).wrapping_mul(73_856_093) ^ (y as u32).wrapping_mul(19_349_663)).wrapping_mul(2_654_435_761);
*px = blend_pixel(blend, *px, src, (h >> 8) as f32 / (1u32 << 24) as f32);
}
}
let k = 1.0 - a;
px[0] = color[0] * a + px[0] * k;
px[1] = color[1] * a + px[1] * k;
px[2] = color[2] * a + px[2] * k;
px[3] = a + px[3] * k;
});
}
/// Render a shape layer's contents into a layer-space buffer at scale `s`.
pub fn render(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, s: f64) -> Buf {
let (draws, _) = collect(ctx, layer, contents);
let mut bounds: Option<kurbo::Rect> = None;
for d in &draws {
if let Some(b) = effectcraft_path::bounds(&d.paths) {
let grow = match &d.paint {
Paint::Stroke { style, .. } => style.width * style.miter.max(1.0) * 0.5 * d.xf.mean_scale().max(1.0) + 1.0,
_ => 1.0,
};
let b = b.inflate(grow, grow);
bounds = Some(bounds.map_or(b, |a| a.union(b)));
};
let rows = img.data.chunks_mut(iw).skip(y0).take(r.h as usize);
if r.w as usize * r.h as usize >= 64 * 1024 {
let rows: Vec<&mut [[f32; 4]]> = rows.collect();
rows.into_par_iter().zip(cov.data.par_chunks(rw)).enumerate().for_each(|(dy, (out, crow))| row(y0 + dy, out, crow));
} else {
for (dy, (out, crow)) in rows.zip(cov.data.chunks(rw)).enumerate() {
row(y0 + dy, out, crow);
}
}
let Some(b) = bounds else { return Buf { img: Image::new(1, 1), offset: [0.0, 0.0], scale: s } };
}
/// Coverage of one draw inside its pixel rectangle.
fn draw_coverage(d: &Draw, paths: &[BezPath], m: &Mat3, r: PxRect) -> Mask {
// Rasterise into a small mask: shift the buffer matrix so the rectangle starts at 0,0.
let sub = Mat3::translate(vec2(-(r.x0 as f64), -(r.y0 as f64))) * *m;
let stroke = |style: &StrokeStyle| {
// Strokes scale with their group transform.
let mut st = style.clone();
st.width *= d.xf.mean_scale();
effectcraft_path::stroke_coverage(paths, &st, &sub, r.w, r.h)
};
match &d.paint {
Paint::Fill { rule, .. } => effectcraft_path::fill_coverage(paths, &sub, r.w, r.h, *rule),
Paint::Stroke { style, .. } => stroke(style),
Paint::Gradient { stroke: Some(style), .. } => stroke(style),
Paint::Gradient { rule, .. } => effectcraft_path::fill_coverage(paths, &sub, r.w, r.h, *rule),
}
}
/// Local-space bounds of what a draw can touch (stroke width, miters and anti-aliasing included).
fn draw_bounds(d: &Draw, paths: &[BezPath]) -> Option<kurbo::Rect> {
let b = effectcraft_path::bounds(paths)?;
let stroke = match &d.paint {
Paint::Stroke { style, .. } => Some(style),
Paint::Gradient { stroke: Some(style), .. } => Some(style),
_ => None,
};
let grow = stroke.map_or(1.0, |style| style.width * style.miter.max(1.0) * 0.5 * d.xf.mean_scale().max(1.0) + 1.0);
Some(b.inflate(grow, grow))
}
/// Maximum number of coverage pixels rasterised concurrently (bounds peak memory).
const BATCH_PIXELS: usize = 8 << 20;
/// Render a shape layer's contents into a layer-space buffer at scale `s`.
///
/// Each draw rasterises and composites only its own pixel rectangle; coverage masks of
/// consecutive draws are rasterised in parallel, then composited in stacking order.
pub fn render(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, s: f64) -> Buf {
let mut arena = Vec::new();
let (draws, _) = collect(ctx, layer, contents, &mut arena);
// Bottom-most draw first.
let resolved: Vec<(&Draw, Vec<BezPath>, kurbo::Rect)> = draws
.iter()
.rev()
.filter(|d| d.opacity > 0.0)
.filter_map(|d| {
let paths: Vec<BezPath> = d.slots.iter().map(|&i| arena[i].clone()).filter(|p| !p.elements().is_empty()).collect();
let b = draw_bounds(d, &paths)?;
Some((d, paths, b))
})
.collect();
let Some(b) = resolved.iter().map(|r| r.2).reduce(|a, b| a.union(b)) else { return Buf { img: Image::new(1, 1), offset: [0.0, 0.0], scale: s } };
// Keep buffers sane for runaway geometry.
let b = b.intersect(kurbo::Rect::new(-20000.0, -20000.0, 20000.0, 20000.0));
let w = ((b.width() * s).ceil() as u32 + 4).clamp(1, 16384);
let h = ((b.height() * s).ceil() as u32 + 4).clamp(1, 16384);
let offset = [-b.x0 * s + 2.0, -b.y0 * s + 2.0];
let m = Mat3::translate(vec2(offset[0], offset[1])) * Mat3::scale(vec2(s, s));
let px_rect = |r: kurbo::Rect| -> Option<PxRect> {
let x0 = (r.x0 * s + offset[0]).floor().max(0.0);
let y0 = (r.y0 * s + offset[1]).floor().max(0.0);
let x1 = (r.x1 * s + offset[0]).ceil().min(w as f64);
let y1 = (r.y1 * s + offset[1]).ceil().min(h as f64);
(x1 > x0 && y1 > y0).then_some(PxRect { x0: x0 as u32, y0: y0 as u32, w: (x1 - x0) as u32, h: (y1 - y0) as u32 })
};
let jobs: Vec<(&Draw, &[BezPath], PxRect)> = resolved.iter().filter_map(|(d, p, b)| Some((*d, p.as_slice(), px_rect(*b)?))).collect();
let mut img = Image::new(w, h);
for d in draws.iter().rev() {
if d.opacity <= 0.0 || d.paths.is_empty() {
continue;
let mut i = 0;
while i < jobs.len() {
// Next batch: as many draws as fit the pixel budget (at least one).
let mut j = i;
let mut px = 0usize;
while j < jobs.len() && (j == i || px + jobs[j].2.w as usize * jobs[j].2.h as usize <= BATCH_PIXELS) {
px += jobs[j].2.w as usize * jobs[j].2.h as usize;
j += 1;
}
match &d.paint {
Paint::Fill { color, rule } => {
let cov = effectcraft_path::fill_coverage(&d.paths, &m, w, h, *rule);
composite_coverage(&mut img, &cov, *color, d.opacity);
}
Paint::Stroke { color, style } => {
// Strokes scale with their group transform.
let mut st = style.clone();
st.width *= d.xf.mean_scale();
let cov = effectcraft_path::stroke_coverage(&d.paths, &st, &m, w, h);
composite_coverage(&mut img, &cov, *color, d.opacity);
}
Paint::Gradient { g, radial, start, end, rule } => {
let cov = effectcraft_path::fill_coverage(&d.paths, &m, w, h, *rule);
let to_local = (m * d.xf).inverse().unwrap_or(Mat3::IDENTITY);
let (sx, sy) = (start[0], start[1]);
let (dx, dy) = (end[0] - sx, end[1] - sy);
let len2 = (dx * dx + dy * dy).max(1e-9);
let op = d.opacity;
img.data.par_chunks_mut(w as usize).zip(cov.data.par_chunks(w as usize)).enumerate().for_each(|(y, (row, crow))| {
for x in 0..row.len() {
let c = crow[x];
if c <= 0.0 {
continue;
}
let covs: Vec<Mask> = jobs[i..j].par_iter().map(|(d, paths, r)| draw_coverage(d, paths, &m, *r)).collect();
for ((d, _, r), cov) in jobs[i..j].iter().zip(&covs) {
match &d.paint {
Paint::Fill { color, .. } | Paint::Stroke { color, .. } => paint(&mut img, *r, cov, d.opacity, d.blend, |_, _| *color),
Paint::Gradient { g, radial, start, end, .. } => {
let to_local = (m * d.xf).inverse().unwrap_or(Mat3::IDENTITY);
let (sx, sy) = (start[0], start[1]);
let (dx, dy) = (end[0] - sx, end[1] - sy);
let len2 = (dx * dx + dy * dy).max(1e-9);
paint(&mut img, *r, cov, d.opacity, d.blend, |x, y| {
let p = to_local.apply(vec2(x as f64 + 0.5, y as f64 + 0.5));
let (vx, vy) = (p.x - sx, p.y - sy);
let t = if *radial { ((vx * vx + vy * vy) / len2).sqrt() } else { (vx * dx + vy * dy) / len2 };
let col = g.sample(t);
let a = c * op * col[3];
let k = 1.0 - a;
let px = &mut row[x];
px[0] = col[0] * a + px[0] * k;
px[1] = col[1] * a + px[1] * k;
px[2] = col[2] * a + px[2] * k;
px[3] = a + px[3] * k;
}
});
g.sample(t)
});
}
}
}
i = j;
}
Buf { img, offset, scale: s }
}
#[cfg(test)]
mod tests;
+342
View File
@@ -0,0 +1,342 @@
//! Render-level tests for shape-layer path operators and paint items.
use effectcraft_color::Label;
use effectcraft_keyframe::{Gradient, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, PropGroup};
use effectcraft_raster::Image;
use effectcraft_time::{FrameRate, Tick};
use crate::render_frame;
/// A 200×100 comp with one shape layer (origin at the comp centre, 100,50) holding `items` at the
/// top level of its contents.
fn shape_comp(items: impl FnOnce(&mut Ids) -> Vec<PropGroup>) -> (Project, ItemId) {
shape_comp_sized(200, 100, items)
}
fn shape_comp_sized(w: u32, h: u32, items: impl FnOnce(&mut Ids) -> Vec<PropGroup>) -> (Project, ItemId) {
let mut p = Project::default();
let comp = Comp::new(w, h, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
let mut l = build::layer(&mut p, &comp, "Shape Layer 1", LayerSource::Shape, (w, h), None);
let mut next = p.next_id;
let items = items(&mut Ids(&mut next));
p.next_id = next;
let contents = l.props.sub_mut("contents").unwrap();
for i in items {
contents.children.push(i.into());
}
p.comp_mut(cid).unwrap().layers.push(l);
(p, cid)
}
fn frame(p: &Project, cid: ItemId) -> Image {
render_frame(p, cid, Tick::ZERO, 1.0)
}
fn alpha(img: &Image, x: i64, y: i64) -> f32 {
img.get(x, y)[3]
}
fn coverage(img: &Image) -> f32 {
img.data.iter().map(|px| px[3]).sum()
}
fn set(g: &mut PropGroup, path: &str, v: Value) {
g.prop_mut(path).unwrap_or_else(|| panic!("no {path}")).value = v;
}
fn op(ids: &mut Ids, kind: &str, params: &[(&str, Value)]) -> PropGroup {
let mut g = build::shape_simple_op(ids, kind).unwrap();
for (k, v) in params {
set(&mut g, k, v.clone());
}
g
}
const RED: [f64; 4] = [1.0, 0.0, 0.0, 1.0];
const GREEN: [f64; 4] = [0.0, 1.0, 0.0, 1.0];
#[test]
fn merge_add_feeds_fill_below_and_drops_paint_above() {
let (p, cid) = shape_comp(|ids| {
vec![
build::shape_rect(ids, [40.0, 40.0], [-20.0, 0.0], 0.0),
build::shape_rect(ids, [40.0, 40.0], [20.0, 0.0], 0.0),
build::shape_fill(ids, RED),
op(ids, "merge", &[("mode", Value::Enum(1))]),
build::shape_fill(ids, GREEN),
]
});
let img = frame(&p, cid);
let c = img.get(100, 50);
assert!(c[1] > 0.99 && c[0] < 0.01, "merged path painted by the fill below only: {c:?}");
assert!(img.get(65, 50)[1] > 0.99 && img.get(135, 50)[1] > 0.99);
assert!((coverage(&img) - 80.0 * 40.0).abs() < 20.0, "{}", coverage(&img));
}
#[test]
fn merge_subtract_and_intersect() {
let sub = |mode: u32| {
let (p, cid) = shape_comp(|ids| {
vec![
build::shape_rect(ids, [80.0, 60.0], [0.0, 0.0], 0.0),
build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0),
op(ids, "merge", &[("mode", Value::Enum(mode))]),
build::shape_fill(ids, RED),
]
});
frame(&p, cid)
};
let s = sub(2);
assert!(alpha(&s, 100, 50) < 0.01, "hole");
assert!(alpha(&s, 70, 50) > 0.99);
assert!((coverage(&s) - (80.0 * 60.0 - 400.0)).abs() < 20.0);
let i = sub(3);
assert!(alpha(&i, 100, 50) > 0.99);
assert!(alpha(&i, 70, 50) < 0.01);
assert!((coverage(&i) - 400.0).abs() < 10.0);
let x = sub(4);
assert!((coverage(&x) - (80.0 * 60.0 - 400.0)).abs() < 20.0);
}
#[test]
fn offset_paths_grow_and_shrink_fill() {
let run = |amount: f64| {
let (p, cid) = shape_comp(|ids| {
vec![build::shape_rect(ids, [40.0, 40.0], [0.0, 0.0], 0.0), op(ids, "offset", &[("amount", Value::Scalar(amount))]), build::shape_fill(ids, RED)]
});
frame(&p, cid)
};
let g = run(10.0);
assert!((coverage(&g) - 60.0 * 60.0).abs() < 20.0, "{}", coverage(&g));
assert!(alpha(&g, 100 + 25, 50) > 0.99);
let s = run(-10.0);
assert!((coverage(&s) - 20.0 * 20.0).abs() < 10.0, "{}", coverage(&s));
}
#[test]
fn operators_below_repeater_act_on_copies() {
let (p, cid) = shape_comp(|ids| {
let mut rep = build::shape_repeater(ids, 3.0, [30.0, 0.0]);
set(&mut rep, "transform/position", Value::Vec2([30.0, 0.0]));
vec![build::shape_rect(ids, [20.0, 20.0], [-30.0, 0.0], 0.0), rep, op(ids, "offset", &[("amount", Value::Scalar(2.0))]), build::shape_fill(ids, RED)]
});
let img = frame(&p, cid);
// Three 24×24 squares (offset by 2) at x = -30, 0, 30.
for cx in [70, 100, 130] {
assert!(alpha(&img, cx, 50) > 0.99, "copy at {cx}");
assert!(alpha(&img, cx + 11, 50) > 0.99, "offset applied to copy at {cx}");
}
assert!((coverage(&img) - 3.0 * 24.0 * 24.0).abs() < 30.0, "{}", coverage(&img));
}
#[test]
fn repeater_then_merge_unites_copies() {
let (p, cid) = shape_comp(|ids| {
vec![
build::shape_rect(ids, [20.0, 20.0], [-10.0, 0.0], 0.0),
build::shape_repeater(ids, 2.0, [10.0, 0.0]),
op(ids, "merge", &[("mode", Value::Enum(4))]),
build::shape_fill(ids, RED),
]
});
let img = frame(&p, cid);
// Two squares overlapping by 10 px: exclude leaves the two 10×20 ends.
assert!(alpha(&img, 95, 50) < 0.01);
assert!(alpha(&img, 85, 50) > 0.99 && alpha(&img, 105, 50) > 0.99);
assert!((coverage(&img) - 400.0).abs() < 10.0, "{}", coverage(&img));
}
#[test]
fn trim_below_stroke_trims_it() {
let run = |end: f64| {
let (p, cid) = shape_comp(|ids| {
let mut t = build::shape_trim(ids, 0.0, end, 0.0);
set(&mut t, "end", Value::Scalar(end));
vec![build::shape_rect(ids, [60.0, 60.0], [0.0, 0.0], 0.0), build::shape_stroke(ids, [1.0; 4], 4.0), t]
});
coverage(&frame(&p, cid))
};
let full = run(100.0);
let half = run(50.0);
assert!(full > 900.0);
assert!((half / full - 0.5).abs() < 0.05, "{half} / {full}");
}
#[test]
fn trim_individually_vs_simultaneously() {
let run = |mode: u32| {
let (p, cid) = shape_comp(|ids| {
let items = vec![build::shape_rect(ids, [40.0, 40.0], [-40.0, 0.0], 0.0), build::shape_stroke(ids, [1.0; 4], 2.0)];
let a = build::shape_group(ids, "A", items);
let items = vec![build::shape_rect(ids, [40.0, 40.0], [40.0, 0.0], 0.0), build::shape_stroke(ids, [1.0; 4], 2.0)];
let b = build::shape_group(ids, "B", items);
let mut t = build::shape_trim(ids, 0.0, 50.0, 0.0);
set(&mut t, "mode", Value::Enum(mode));
vec![a, b, t]
});
let img = frame(&p, cid);
let left: f32 = (0..100).flat_map(|x| (0..100).map(move |y| (x, y))).map(|(x, y)| alpha(&img, x, y)).sum();
let right: f32 = (100..200).flat_map(|x| (0..100).map(move |y| (x, y))).map(|(x, y)| alpha(&img, x, y)).sum();
(left, right)
};
let (l, r) = run(0);
assert!((l - r).abs() < 0.1 * l, "simultaneously: both half drawn {l} {r}");
let (l, r) = run(1);
assert!(l > 250.0 && r < 1.0, "individually: first path complete, second not started {l} {r}");
}
#[test]
fn gradient_stroke_paints_gradient_along_stroke() {
let (p, cid) = shape_comp(|ids| {
let g = Gradient { colors: vec![(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] };
vec![build::shape_rect(ids, [60.0, 60.0], [0.0, 0.0], 0.0), build::shape_gradient_stroke(ids, false, [-30.0, 0.0], [30.0, 0.0], g, 6.0)]
});
let img = frame(&p, cid);
let l = img.get(70, 50);
let r = img.get(130, 50);
assert!(l[3] > 0.99 && r[3] > 0.99, "{l:?} {r:?}");
assert!(l[0] < 0.1 && r[0] > 0.9, "{l:?} {r:?}");
assert!(alpha(&img, 100, 50) < 0.01, "stroke only");
}
#[test]
fn fill_blend_mode_multiply() {
let (p, cid) = shape_comp(|ids| {
let mut top = build::shape_fill(ids, [0.5, 0.5, 0.5, 1.0]);
set(
&mut top,
"blend",
Value::Enum(effectcraft_color::BlendMode::ALL.iter().position(|m| *m == effectcraft_color::BlendMode::Multiply).unwrap() as u32),
);
vec![build::shape_rect(ids, [40.0, 40.0], [0.0, 0.0], 0.0), top, build::shape_fill(ids, [1.0, 0.0, 0.0, 1.0])]
});
let c = frame(&p, cid).get(100, 50);
assert!((c[0] - 0.5).abs() < 0.01 && c[1] < 0.01, "{c:?}");
}
#[test]
fn reversed_path_direction_cuts_hole_under_non_zero() {
let (p, cid) = shape_comp(|ids| {
let mut inner = build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0);
set(&mut inner, "direction", Value::Enum(1));
vec![build::shape_rect(ids, [60.0, 60.0], [0.0, 0.0], 0.0), inner, op(ids, "merge", &[]), build::shape_fill(ids, RED)]
});
let img = frame(&p, cid);
assert!(alpha(&img, 100, 50) < 0.01);
assert!(alpha(&img, 75, 50) > 0.99);
}
#[test]
fn round_corners_clip_corner_pixels() {
let (p, cid) = shape_comp(|ids| {
vec![build::shape_rect(ids, [40.0, 40.0], [0.0, 0.0], 0.0), op(ids, "round", &[("radius", Value::Scalar(20.0))]), build::shape_fill(ids, RED)]
});
let img = frame(&p, cid);
assert!(alpha(&img, 81, 31) < 0.01, "corner rounded off");
assert!(alpha(&img, 100, 50) > 0.99);
assert!((coverage(&img) - std::f32::consts::PI * 400.0).abs() < 15.0, "{}", coverage(&img));
}
#[test]
fn wiggle_paths_vary_over_time_deterministically() {
let (p, cid) = shape_comp(|ids| {
vec![build::shape_ellipse(ids, [60.0, 60.0], [0.0, 0.0]), op(ids, "wiggle", &[("size", Value::Scalar(8.0))]), build::shape_fill(ids, RED)]
});
let a = render_frame(&p, cid, Tick::from_seconds_f64(0.5), 1.0);
let b = render_frame(&p, cid, Tick::from_seconds_f64(0.5), 1.0);
let c = render_frame(&p, cid, Tick::from_seconds_f64(1.0), 1.0);
assert_eq!(a.data, b.data);
assert_ne!(a.data, c.data);
}
#[test]
fn zigzag_and_twist_render() {
let (p, cid) = shape_comp(|ids| {
vec![
build::shape_rect(ids, [60.0, 60.0], [0.0, 0.0], 0.0),
op(ids, "zigzag", &[("size", Value::Scalar(5.0)), ("ridges", Value::Scalar(4.0))]),
op(ids, "twist", &[("angle", Value::Scalar(45.0))]),
build::shape_fill(ids, RED),
]
});
let img = frame(&p, cid);
assert!(alpha(&img, 100, 50) > 0.99);
let cov = coverage(&img);
assert!(cov > 2000.0 && cov < 4600.0, "{cov}");
}
#[test]
fn repeater_draws_match_per_copy_bounds() {
// Many small copies spread over a large buffer: each must rasterise correctly in its own
// sub-rectangle.
let (p, cid) = shape_comp_sized(400, 400, |ids| {
let mut rep = build::shape_repeater(ids, 12.0, [0.0, 0.0]);
set(&mut rep, "transform/rotation", Value::Scalar(30.0));
let items = vec![build::shape_rect(ids, [10.0, 30.0], [0.0, -150.0], 3.0), build::shape_fill(ids, RED)];
let g = build::shape_group(ids, "Tick", items);
vec![g, rep]
});
let img = frame(&p, cid);
for k in 0..12 {
let a = (k as f64 * 30.0).to_radians();
let (x, y) = (200.0 + 150.0 * a.sin(), 200.0 - 150.0 * a.cos());
assert!(alpha(&img, x.round() as i64, y.round() as i64) > 0.99, "copy {k}");
}
assert!(alpha(&img, 200, 200) < 0.01);
let expect = 12.0 * (300.0 - (4.0 - std::f32::consts::PI) * 9.0);
assert!((coverage(&img) - expect).abs() < 40.0, "{}", coverage(&img));
}
/// Performance probe: 60-copy repeater of a small rounded rect at 1920×1080.
/// Run with `cargo test -p effectcraft-render --release -- --ignored --nocapture shape_perf`.
#[test]
#[ignore]
fn shape_perf_repeater_60_copies_1080p() {
let (p, cid) = shape_comp_sized(1920, 1080, |ids| {
let mut rep = build::shape_repeater(ids, 60.0, [0.0, 0.0]);
set(&mut rep, "transform/rotation", Value::Scalar(6.0));
let items = vec![
build::shape_rect(ids, [12.0, 60.0], [0.0, -420.0], 4.0),
build::shape_fill(ids, [1.0, 0.8, 0.2, 1.0]),
build::shape_stroke(ids, [1.0; 4], 2.0),
];
let g = build::shape_group(ids, "Tick", items);
vec![g, rep]
});
let _ = render_frame(&p, cid, Tick::ZERO, 1.0);
let n = 10;
let t0 = std::time::Instant::now();
for _ in 0..n {
let _ = render_frame(&p, cid, Tick::ZERO, 1.0);
}
let ms = t0.elapsed().as_secs_f64() * 1000.0 / n as f64;
println!("shape_perf: 60-copy repeater at 1920x1080: {ms:.2} ms/frame");
let ItemKind::Comp(comp) = &p.item(cid).unwrap().kind else { unreachable!() };
let layer = &comp.layers[0];
let ctx = crate::eval::EvalCtx::new(&p, cid, comp, Tick::ZERO);
let contents = layer.props.sub("contents").unwrap();
let t0 = std::time::Instant::now();
for _ in 0..n {
let _ = super::render(&ctx, layer, contents, 1.0);
}
let ms = t0.elapsed().as_secs_f64() * 1000.0 / n as f64;
println!("shape_perf: shapes::render alone: {ms:.2} ms");
let mut arena = Vec::new();
let t0 = std::time::Instant::now();
for _ in 0..n {
arena.clear();
let _ = super::collect(&ctx, layer, contents, &mut arena);
}
let ms = t0.elapsed().as_secs_f64() * 1000.0 / n as f64;
println!("shape_perf: collect alone: {ms:.2} ms");
let (empty, ecid) = shape_comp_sized(1920, 1080, |_| vec![]);
let t0 = std::time::Instant::now();
for _ in 0..n {
let _ = render_frame(&empty, ecid, Tick::ZERO, 1.0);
}
let ms = t0.elapsed().as_secs_f64() * 1000.0 / n as f64;
println!("shape_perf: empty shape layer comp (compositor baseline): {ms:.2} ms");
}