Files
effectcraft/crates/render/src/tests_mask_expansion.rs
Ian-ChegeandClaude Opus 5.5 5270eab095 Masks: Mask Expansion past a curve's radius grows a solid mask instead of a ring
Expansion was a fill plus a stroke 2·|expansion| wide, blended per pixel. Once the
expansion passed the shape's curvature radius the stroke's inner edge inverted: a
20 px circle expanded by 60 left a ring with the layer showing through the middle,
+600 cut only the original dot, and negative values left islands. The outline is
now offset with Offset Paths' geometry (round joins) and filled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:15:30 +03:00

83 lines
4.0 KiB
Rust
Raw Permalink 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.
//! 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);
}