M5: opaque knockout fills draw once in hard-edge and supersampled raster export

A knockout element with one opaque Normal solid fill (a path or
compound, no mask, effects or isolation) is drawn once under
AntiAlias::None, including the internal hard-edge frame of Art
Optimized supersampled exports, instead of being erased and added back
through per-element backdrop layers. Analytical antialiasing keeps the
existing compositor and rounding.

Exact-pixel tests compare against the general knockout path for paths
and compound holes across precision, supersampling, translucent parents
and copied backdrops, and check that the backdrop rasters are skipped;
translucent fills keep their knockout compositing.

# Conflicts:
#	docs/roadmap.md
#	docs/target-app-parity.md
This commit is contained in:
Don Omondi
2026-10-31 06:28:42 +07:00
committed by bflatastic
parent de72fc3998
commit de703fe722
5 changed files with 124 additions and 3 deletions
+94
View File
@@ -184,3 +184,97 @@ fn page_knockout_looks_the_same_when_another_object_blends() {
assert!(close(a, b), "the group's middle over {below:?}: {a:?} alone, {b:?} with the Multiply square");
}
}
#[test]
fn opaque_knockout_fills_keep_pixels_without_per_element_backdrop_rasters() {
// Fractional curved edges and holes exercise coverage, not just opaque interiors. A disabled
// mask is visually neutral and keeps the general knockout path as an oracle, without adding
// another compositing layer or rounding step to each fill.
for precise in [false, true] {
for (anti_alias, supersample) in [(AntiAlias::Art, false), (AntiAlias::Art, true), (AntiAlias::None, false)] {
for copy_backdrop in [false, true] {
let make = |reference: bool| {
let mut d = Document::new(80.0, 60.0);
let mut members = Vec::new();
for i in 0..12 {
let outer = shapes::ellipse(Rect::new(2.3 + f64::from(i), 3.7, 65.4, 53.2));
let inner = shapes::ellipse(Rect::new(20.2, 20.8, 40.6, 38.1));
let child = Node::path(d.alloc_id(), inner, Appearance::default());
let first = Node::path(d.alloc_id(), outer, Appearance::default());
let mut n = if i % 2 == 0 {
Node::new(d.alloc_id(), NodeKind::Compound { children: vec![Arc::new(first), Arc::new(child)], rule: FillRule::EvenOdd })
} else {
first
};
n.appearance = Appearance::basic(Paint::solid(Color::rgb(i as f32 / 12.0, 0.3, 0.7)), Paint::None, 0.0).into();
if reference {
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 80.0, 60.0)), Appearance::default());
let mut mask = vectorcraft_doc::OpacityMask::new(art, false);
mask.disabled = true;
n.extras_mut().mask = Some(Box::new(mask));
}
members.push(Arc::new(n));
}
if copy_backdrop {
let mut blend = half(&mut d, 70.0, (0.5, 0.5, 0.5));
blend.blend = Blend::Multiply;
members.push(Arc::new(blend));
}
let mut g = Node::group(d.alloc_id(), members);
g.knockout = Knockout::On;
g.opacity = 0.5;
d.insert(Some(d.layers[0].id), 0, g).unwrap();
d
};
let opts = RenderOptions { background: Some([190, 210, 230, 255]), precise, anti_alias, supersample, ..Default::default() };
let draw = |reference| {
crate::group::PIXELS_DRAWN.with(|n| n.set(0));
let mut r = Renderer::new();
r.threads = 0;
let img = r.render(&make(reference), 80, 60, Affine::IDENTITY, &opts);
let pixels = crate::group::PIXELS_DRAWN.with(|n| n.get());
(img, pixels, r.stats.drawn)
};
let (fast, fast_pixels, fast_draws) = draw(false);
let (reference, reference_pixels, reference_draws) = draw(true);
assert!(fast.pixels == reference.pixels, "precise={precise}, aa={anti_alias:?}, supersample={supersample}, backdrop={copy_backdrop}");
if anti_alias == AntiAlias::None || supersample {
assert!(fast_draws < reference_draws, "opaque elements should draw once");
if copy_backdrop {
assert!(fast_pixels < reference_pixels, "opaque elements should not rasterize a backdrop per element");
}
} else {
assert_eq!(fast_draws, reference_draws, "analytical AA must keep its rounding");
assert_eq!(fast_pixels, reference_pixels);
}
}
}
}
}
#[test]
fn translucent_knockout_objects_and_fills_keep_their_compositing() {
for fill_opacity in [false, true] {
let d = doc(Knockout::On, |_, g| {
if fill_opacity {
for n in g.children_mut().unwrap() {
let n = Arc::make_mut(n);
n.opacity = 1.0;
for item in &mut n.appearance.items {
if let vectorcraft_doc::AppearanceItem::Fill(fill) = item {
fill.opacity = 0.5;
}
}
}
}
});
let opts = RenderOptions { anti_alias: AntiAlias::None, background: Some([255; 4]), ..Default::default() };
let mut renderer = Renderer::new();
let r = renderer.render(&d, 100, 100, Affine::IDENTITY, &opts);
// Object opacity is ignored by its knockout shape; a fill's own opacity is retained.
let want = if fill_opacity { BLUE_OVER_RED } else { BLUE_OVER_WHITE };
assert!(close(r.pixel(50, 50), want), "fill opacity={fill_opacity}: {:?}", r.pixel(50, 50));
let nodes = d.layers.iter().map(|n| n.count()).sum::<usize>();
assert!(renderer.stats.drawn > nodes, "translucent elements keep the separate knockout passes");
}
}