Files
effectcraft/crates/render/src/shapes.rs
T
Brandon ThomasandClaude Opus 5.5 bbaee1fa0f M13.5: stroke taper/wave/multi-dashes, radial gradient highlight, variable-width mask feather, adaptive motion-blur samples
Shapes (SHP-2): Stroke Taper (pixels/percent start/end length, width, ease) and
Wave (amount, pixels/cycles, wavelength, phase) rendered as a variable-width
outline; Dash 2/Gap 2/Dash 3/Gap 3 (shape.dashes.add/remove, timeline +/-
buttons); radial gradient Highlight Length/Angle as a focal gradient.
Masks (MSK-1): mask feather points on the Mask Path (mask.featherPoint.*,
Mask Feather tool add/drag), per-point signed radius and tension, rendered
from the signed distance to the path; carried through vertex edits.
Motion blur (LYR-10): Adaptive Sample Limit comp setting; 2D layers take one
sample per pixel of shutter travel between Samples Per Frame and the limit.

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

622 lines
28 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, varstroke};
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>,
/// Radial gradients: Highlight Length (−100…100 %) and Highlight Angle (degrees).
highlight: (f64, f64),
},
}
/// 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);
if dash <= 0.0 {
return None;
}
let mut pat = vec![dash, if gap > 0.0 { gap } else { dash }];
// Dash 2 / Gap 2, Dash 3 / Gap 3 (added with the Dashes "+" button).
for (dm, gm) in effectcraft_project::build::EXTRA_DASHES {
if d.get(dm).is_none() {
break;
}
let dv = ctx.f(layer, d, dm, 0.0);
pat.push(dv);
pat.push(if d.get(gm).is_some() { ctx.f(layer, d, gm, 0.0) } else { dv });
}
Some((pat, ctx.f(layer, d, "offset", 0.0)))
});
let taper = g.sub("taper").map(|t| varstroke::Taper {
percent: ctx.e(layer, t, "units") == 1,
start_len: ctx.f(layer, t, "startLength", 0.0) / if ctx.e(layer, t, "units") == 1 { 100.0 } else { 1.0 },
end_len: ctx.f(layer, t, "endLength", 0.0) / if ctx.e(layer, t, "units") == 1 { 100.0 } else { 1.0 },
start_width: ctx.f(layer, t, "startWidth", 100.0) / 100.0,
end_width: ctx.f(layer, t, "endWidth", 100.0) / 100.0,
start_ease: ctx.f(layer, t, "startEase", 0.0) / 100.0,
end_ease: ctx.f(layer, t, "endEase", 0.0) / 100.0,
});
let wave = g.sub("wave").map(|w| varstroke::Wave {
amount: ctx.f(layer, w, "amount", 0.0) / 100.0,
cycles: (ctx.e(layer, w, "units") == 1).then(|| ctx.f(layer, w, "cycles", 10.0)),
wavelength: ctx.f(layer, w, "wavelength", 20.0),
phase: ctx.f(layer, w, "phase", 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,
taper: taper.filter(|t| t.is_active()),
wave: wave.filter(|w| w.is_active()),
}
}
/// 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,
highlight: (ctx.f(layer, g, "highlightLength", 0.0), ctx.f(layer, g, "highlightAngle", 0.0)),
},
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)
}
/// Gradient position of a point: linear along start→end, radial by distance from the start over
/// the radius |end − start|. A radial Highlight shifts the focal point towards
/// `highlight_length` % of the radius at `highlight_angle` from the start→end axis (a focal
/// two-point gradient: t is measured from the focal point to the circle along the ray).
#[derive(Clone, Copy, Debug)]
pub(crate) struct GradientRamp {
radial: bool,
s: [f64; 2],
d: [f64; 2],
len2: f64,
focal: Option<[f64; 2]>,
}
impl GradientRamp {
pub(crate) fn new(radial: bool, start: [f64; 2], end: [f64; 2], highlight: (f64, f64)) -> Self {
let d = [end[0] - start[0], end[1] - start[1]];
let len2 = (d[0] * d[0] + d[1] * d[1]).max(1e-9);
let (hl, ha) = highlight;
let focal = (radial && hl.abs() > 1e-6).then(|| {
let r = len2.sqrt();
let a = d[1].atan2(d[0]) + ha.to_radians();
let dist = r * (hl / 100.0).clamp(-0.99, 0.99);
[start[0] + dist * a.cos(), start[1] + dist * a.sin()]
});
GradientRamp { radial, s: start, d, len2, focal }
}
pub(crate) fn t(&self, x: f64, y: f64) -> f64 {
let (vx, vy) = (x - self.s[0], y - self.s[1]);
if !self.radial {
return (vx * self.d[0] + vy * self.d[1]) / self.len2;
}
let Some(f) = self.focal else { return ((vx * vx + vy * vy) / self.len2).sqrt() };
// Ray from the focal point through p meets the circle (centre s, radius r) at λ.
let (px, py) = (x - f[0], y - f[1]);
let dist = (px * px + py * py).sqrt();
if dist < 1e-12 {
return 0.0;
}
let (ux, uy) = (px / dist, py / dist);
let (cx, cy) = (f[0] - self.s[0], f[1] - self.s[1]);
let b = ux * cx + uy * cy;
let c = cx * cx + cy * cy - self.len2;
let lambda = -b + (b * b - c).max(0.0).sqrt();
if lambda <= 1e-12 { 1.0 } else { dist / lambda }
}
}
/// 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, highlight, .. } => {
let to_local = (m * d.xf).inverse().unwrap_or(Mat3::IDENTITY);
let ramp = GradientRamp::new(*radial, *start, *end, *highlight);
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));
g.sample(ramp.t(p.x, p.y))
});
}
}
}
i = j;
}
Buf { img, offset, scale: s }
}
#[cfg(test)]
mod tests;
/// The filled outlines of a shape layer in layer space with their straight colours (gradient
/// fills: the colour half way along), for Advanced 3D extrusion. Strokes are not extruded.
pub fn fill_outlines(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> Vec<(Vec<BezPath>, [f32; 4])> {
let mut arena = Vec::new();
let (draws, _) = collect(ctx, layer, contents, &mut arena);
draws
.iter()
.rev()
.filter(|d| d.opacity > 0.0)
.filter_map(|d| {
let c = match &d.paint {
Paint::Fill { color, .. } => *color,
Paint::Gradient { g, stroke: None, .. } => g.sample(0.5),
_ => return None,
};
let paths: Vec<BezPath> = d.slots.iter().map(|&i| arena[i].clone()).filter(|p| !p.elements().is_empty()).collect();
(!paths.is_empty()).then_some((paths, [c[0], c[1], c[2], c[3] * d.opacity]))
})
.collect()
}
/// 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))
}