Merge pull request #1356

This commit is contained in:
Brandon Thomas
2026-10-18 09:35:33 -04:00
3 changed files with 91 additions and 13 deletions
+2
View File
@@ -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;
+7 -13
View File
@@ -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();
+82
View File
@@ -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<f32> {
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);
}