Files
effectcraft/crates/render/src/shapes.rs
T
Brandon ThomasandClaude Opus 5.5 468dc13cfe M0.13: viewer snapping, rulers, channels, snapshots, ROI
Composition viewer interaction parity with After Effects:
- Snapping (View > Snapping, the Tools bar checkbox, Cmd/Ctrl inverts while
  dragging): layer edges, centres and anchor points, mask and shape-path
  vertices, comp edges/centre, guides (Snap to Guides) and grid (Snap to
  Grid), with highlighted target box / line / cross feedback. Pure maths in
  engine::viewer (nearest target within tolerance, priority ties).
- Rulers (Cmd+R) with live pointer markers and a draggable zero point;
  guides dragged out of the rulers, moved in the viewer, dragged off to
  remove (view.moveGuide / view.removeGuide), lock respected; proportional
  grid.
- Show Channel (RGB/Red/Green/Blue/Alpha/RGB Straight, Colorize), exposure
  (+/- stops in linear light, reset), Take Snapshot (Shift+F5) with a stored
  render and Show Snapshot (F5 held / button held), Fast Previews (Off,
  Adaptive Resolution, Draft, Fast Draft, Wireframe), Resolution > Custom.
- Region of interest: drawn in the viewer; RenderOpts.roi renders only that
  rectangle (crop of the full render, tested), Crop Comp to ROI uses it.
- Bottom bar in AE 2026 order ("136.1 %", "(Full)").
- Shape Pen (SHP-1): Pen on a shape layer / nothing selected draws Shape n
  groups (Path 1, Stroke 1, Fill 1) via shape.newPath; the G slot cycles
  Add / Delete / Convert Vertex and Mask Feather; mask.* vertex commands
  work on shape paths; mask.insertVertex / mask.convertVertex; marquee
  vertex selection, Alt-drag/click converts, double-click free transform
  box (path.freeTransform on masks and shape paths).
- Motion paths (ANM-3): keyframe dots and spatial tangent handles, dragging
  keys (keys.set) and tangents (keys.setSpatialTangents), parented layers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:16:05 -04:00

524 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Shape-layer contents: groups, parametric paths, fills, strokes, gradients and path operators,
//! 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, ops};
use effectcraft_project::{Layer, Node, PropGroup};
use effectcraft_raster::{Image, Mask};
use rayon::prelude::*;
use crate::eval::EvalCtx;
#[derive(Clone, Debug)]
enum Paint {
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,
slots: Vec<usize>,
/// Paint-group space → layer space, for gradients and stroke scaling.
xf: Mat3,
opacity: f32,
blend: BlendMode,
}
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) {
let anchor = ctx.v2(layer, tr, "anchor", [0.0; 2]);
let pos = ctx.v2(layer, tr, "position", [0.0; 2]);
let scale = ctx.v2(layer, tr, "scale", [100.0; 2]);
let rot = ctx.f(layer, tr, "rotation", 0.0);
let skew = ctx.f(layer, tr, "skew", 0.0);
let skew_axis = ctx.f(layer, tr, "skewAxis", 0.0);
let m = Mat3::translate(vec2(pos[0], pos[1]))
* Mat3::rotate_deg(rot)
* Mat3::skew_deg(-skew, skew_axis)
* Mat3::scale(vec2(scale[0] / 100.0, scale[1] / 100.0))
* Mat3::translate(vec2(-anchor[0], -anchor[1]));
(m, (ctx.f(layer, tr, "opacity", 100.0) / 100.0) as f32)
}
/// Shape item (a Path, Rectangle… inside nested Shape groups) → layer space: the product of the
/// enclosing groups' transforms. `None` when `uid` is not in the layer's contents.
pub fn item_matrix(ctx: &EvalCtx, layer: &Layer, uid: u64) -> Option<Mat3> {
fn walk(ctx: &EvalCtx, layer: &Layer, g: &PropGroup, uid: u64, m: Mat3) -> Option<Mat3> {
for sub in g.groups() {
if sub.uid == uid {
return Some(m);
}
if sub.match_id == "group"
&& let Some(inner) = sub.sub("contents")
{
let gm = sub.sub("transform").map(|t| group_matrix(ctx, layer, t).0).unwrap_or(Mat3::IDENTITY);
if let Some(r) = walk(ctx, layer, inner, uid, m * gm) {
return Some(r);
}
}
}
None
}
walk(ctx, layer, layer.props.sub("contents")?, uid, Mat3::IDENTITY)
}
fn rule(ctx: &EvalCtx, layer: &Layer, g: &PropGroup) -> FillRule {
if ctx.e(layer, g, "rule") == 1 { FillRule::EvenOdd } else { FillRule::NonZero }
}
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 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 {
continue;
}
match g.match_id.as_str() {
"rect" => {
let size = ctx.v2(layer, g, "size", [100.0; 2]);
let pos = ctx.v2(layer, g, "position", [0.0; 2]);
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]);
push_shape(ctx, layer, g, effectcraft_path::ellipse(size, pos), arena, &mut live);
}
"star" => {
let star = ctx.e(layer, g, "type") == 0;
let p = effectcraft_path::polystar(
star,
ctx.f(layer, g, "points", 5.0),
ctx.v2(layer, g, "position", [0.0; 2]),
ctx.f(layer, g, "rotation", 0.0),
ctx.f(layer, g, "innerRadius", 50.0),
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") {
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) },
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 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),
});
}
"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,
radial: ctx.e(layer, g, "type") == 1,
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,
},
slots: live.clone(),
xf: Mat3::IDENTITY,
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));
// 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" => {
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, 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.xf = m * d.xf;
d.opacity *= op;
draws.push(d);
}
live.extend(cl);
}
"repeater" => {
let copies = ctx.f(layer, g, "copies", 3.0).max(0.0);
let offset = ctx.f(layer, g, "offset", 0.0);
let n = copies.ceil() as usize;
let Some(tr) = g.sub("transform") else { continue };
let anchor = ctx.v2(layer, tr, "anchor", [0.0; 2]);
let pos = ctx.v2(layer, tr, "position", [100.0, 0.0]);
let scale = ctx.v2(layer, tr, "scale", [100.0; 2]);
let rot = ctx.f(layer, tr, "rotation", 0.0);
let so = (ctx.f(layer, tr, "startOpacity", 100.0) / 100.0) as f32;
let eo = (ctx.f(layer, tr, "endOpacity", 100.0) / 100.0) as f32;
let step = |k: f64| {
Mat3::translate(vec2(pos[0] * k, pos[1] * k))
* Mat3::translate(vec2(anchor[0], anchor[1]))
* Mat3::rotate_deg(rot * k)
* Mat3::scale(vec2((scale[0] / 100.0).powf(k), (scale[1] / 100.0).powf(k)))
* Mat3::translate(vec2(-anchor[0], -anchor[1]))
};
let base_draws = std::mem::take(&mut draws);
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 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.slots = d.slots.iter().map(|s| copy_maps[i][s]).collect();
d.xf = m * d.xf;
d.opacity *= opk * partial;
draws.push(d);
}
}
}
_ => {}
}
}
(draws, live)
}
/// 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 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);
}
}
}
/// 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);
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;
}
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 };
g.sample(t)
});
}
}
}
i = j;
}
Buf { img, offset, scale: s }
}
#[cfg(test)]
mod tests;
/// Layer-space bounds of a shape layer's painted contents (strokes included), without
/// rasterising.
pub fn content_bounds(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> Option<kurbo::Rect> {
let mut arena = Vec::new();
let (draws, _) = collect(ctx, layer, contents, &mut arena);
draws
.iter()
.filter_map(|d| {
let paths: Vec<BezPath> = d.slots.iter().map(|&i| arena[i].clone()).filter(|p| !p.elements().is_empty()).collect();
draw_bounds(d, &paths)
})
.reduce(|a, b| a.union(b))
}