diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index cfefd02d..053dd07e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -2404,6 +2404,8 @@ mod tests_frame_blend; #[cfg(test)] mod tests_mask_blur; #[cfg(test)] +mod tests_mask_expansion; +#[cfg(test)] mod tests_nested; #[cfg(test)] mod tests_paint; diff --git a/crates/render/src/masks.rs b/crates/render/src/masks.rs index 3f8d7542..bb91ef71 100644 --- a/crates/render/src/masks.rs +++ b/crates/render/src/masks.rs @@ -3,7 +3,7 @@ use effectcraft_effects::Buf; use effectcraft_geom::{Mat3, vec2}; use effectcraft_keyframe::Value; -use effectcraft_path::{FillRule, StrokeStyle}; +use effectcraft_path::{FillRule, Join}; use effectcraft_project::{FeatherFalloff, GroupKind, Layer, MaskMode, MaskMotionBlur}; use effectcraft_raster::{Mask, box_blur, gaussian_blur}; use rayon::prelude::*; @@ -72,21 +72,15 @@ fn coverage_at(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, let variable = !sp.feather.is_empty(); let mut cov = if variable { effectcraft_path::feather::variable_feather_coverage(&sp, &m, w, h, exp, linear) + } else if exp.abs() > 0.01 { + // Expansion offsets the outline itself (round joins). Offset Paths' geometry stays right + // when the expansion exceeds a curvature radius, where a fill + stroke blend leaves a + // ring around an uncovered middle (growing) or islands (shrinking). + let grown = effectcraft_path::offset::offset_path(&path, exp, Join::Round, 4.0); + effectcraft_path::fill_coverage(std::slice::from_ref(&grown), &m, w, h, FillRule::NonZero) } else { effectcraft_path::fill_coverage(std::slice::from_ref(&path), &m, w, h, FillRule::NonZero) }; - if exp.abs() > 0.01 && !variable { - let ring = effectcraft_path::stroke_coverage( - std::slice::from_ref(&path), - &StrokeStyle { width: exp.abs() * 2.0, join: effectcraft_path::Join::Round, ..Default::default() }, - &m, - w, - h, - ); - cov.data.par_iter_mut().zip(ring.data.par_iter()).for_each(|(c, r)| { - *c = if exp > 0.0 { (*c + r - *c * r).min(1.0) } else { (*c * (1.0 - r)).max(0.0) }; - }); - } let feather = ctx.v2(layer, g, "feather", [0.0; 2]); if feather[0] > 0.0 || feather[1] > 0.0 { let img = cov.to_image(); diff --git a/crates/render/src/tests_mask_expansion.rs b/crates/render/src/tests_mask_expansion.rs new file mode 100644 index 00000000..700c779a --- /dev/null +++ b/crates/render/src/tests_mask_expansion.rs @@ -0,0 +1,82 @@ +//! Mask Expansion grows or shrinks the mask outline by its distance, however large: past the +//! shape's curvature radius it must still cover everything within the distance of the path, +//! never leave a ring around an uncovered middle. + +use effectcraft_color::Label; +use effectcraft_keyframe::{ShapePath, Value}; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, MaskMode, Node, Project, Solid}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::{NoFootage, RenderOpts, Renderer}; + +const W: u32 = 400; +const H: u32 = 300; + +/// A white 400×300 solid with one mask of `shape` in `mode` expanded by `expansion` pixels. +fn project(shape: ShapePath, mode: MaskMode, expansion: f64) -> (Project, ItemId) { + let mut p = Project::default(); + let comp = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0; 3], width: W, height: H, pixel_aspect: 1.0 })); + let mut l = build::layer(&mut p, &comp, "S", LayerSource::Solid { item: sid }, (W, H), None); + let mut next = p.next_id; + let mut m = build::mask(&mut Ids(&mut next), "Mask 1", shape, mode, [255, 0, 0]); + p.next_id = next; + m.get_mut("expansion").unwrap().value = Value::Scalar(expansion); + l.props.sub_mut("masks").unwrap().children.push(Node::Group(m)); + let cid = p.add_item("C", Label::Sandstone, None, ItemKind::Comp(comp.into())); + p.comp_mut(cid).unwrap().layers.push(l); + (p, cid) +} + +/// Alpha at each of `points` for a Subtract mask on a 40 px circle at the centre (the hole). +fn hole(expansion: f64, points: &[(i64, i64)]) -> Vec { + let (p, cid) = project(ShapePath::ellipse([200.0, 150.0], 40.0, 40.0), MaskMode::Subtract, expansion); + let img = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO); + points.iter().map(|&(x, y)| img.get(x, y)[3]).collect() +} + +#[test] +fn no_expansion_cuts_the_circle() { + let a = hole(0.0, &[(200, 150), (215, 150), (225, 150)]); + assert!(a[0] < 0.01 && a[1] < 0.01, "inside the 20 px radius is cut: {a:?}"); + assert!(a[2] > 0.99, "outside it is kept: {a:?}"); +} + +#[test] +fn expansion_past_the_radius_grows_a_solid_hole() { + // +60 on a 20 px radius: a solid hole of radius 80. The fill + stroke blend left a ring + // from 40 to 80 px with the layer showing at 21–39 px. + let a = hole(60.0, &[(200, 150), (230, 150), (200, 120), (275, 150), (285, 150)]); + assert!(a[..4].iter().all(|&v| v < 0.01), "everything within 80 px is cut: {a:?}"); + assert!(a[4] > 0.99, "beyond 80 px is kept: {a:?}"); +} + +#[test] +fn huge_expansion_cuts_the_whole_frame() { + // +600 reaches every pixel; the blend left only the 20 px circle cut. + let a = hole(600.0, &[(200, 150), (230, 150), (0, 0), (399, 299), (399, 0)]); + assert!(a.iter().all(|&v| v < 0.01), "{a:?}"); +} + +#[test] +fn negative_expansion_shrinks_then_removes_the_hole() { + let a = hole(-15.0, &[(200, 150), (210, 150)]); + assert!(a[0] < 0.01, "a 5 px hole remains: {a:?}"); + assert!(a[1] > 0.99, "shrunk away from 10 px out: {a:?}"); + let a = hole(-25.0, &[(200, 150), (190, 150)]); + assert!(a.iter().all(|&v| v > 0.99), "shrunk past its radius, nothing is cut: {a:?}"); +} + +#[test] +fn expanded_rect_add_mask_has_round_corners() { + // A 100 px square mask (Add) grown by 30: the sides move out 30 px, the corners round off. + let (p, cid) = project(ShapePath::rect([200.0, 150.0], 100.0, 100.0), MaskMode::Add, 30.0); + let img = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO); + assert!(img.get(275, 150)[3] > 0.99, "25 px past the right side is inside"); + assert!(img.get(285, 150)[3] < 0.01, "35 px past it is outside"); + // 25 px diagonally out from the corner (250,100) is within 30 px: inside. + assert!(img.get(267, 82)[3] > 0.99); + // The square's bounding corner (280,70) is 42 px from the corner: a miter would cover it. + assert!(img.get(277, 73)[3] < 0.01); +}