mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 10:09:32 -06:00
Merge pull request #1356
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user