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
+17
View File
@@ -25,6 +25,23 @@ pub(crate) fn single_plain_fill(n: &Node) -> bool {
matches!((painted.next(), painted.next()), (Some(AppearanceItem::Fill(f)), None) if f.blend == vectorcraft_color::BlendMode::Normal && f.opacity >= 1.0 && !matches!(f.paint, vectorcraft_color::Paint::Pattern { .. }))
}
/// A knockout element whose opaque paint covers exactly its knockout shape. The caller limits
/// source-over substitution to binary coverage, retaining analytical AA's intermediate rounding.
pub(crate) fn opaque_knockout_fill(n: &Node) -> bool {
matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. })
&& n.opacity >= 1.0
&& n.blend == vectorcraft_color::BlendMode::Normal
&& !n.isolate
&& n.extras().mask.is_none()
&& !crate::fx::has_fx(n)
&& !crate::fx::has_object_fx(n)
&& single_plain_fill(n)
&& n.appearance
.items
.iter()
.any(|i| matches!(i, AppearanceItem::Fill(f) if f.visible && matches!(f.paint, vectorcraft_color::Paint::Solid { .. })))
}
/// Number of visible painted fill/stroke items (opacity folding is exact only for one). A dashed
/// stroke is one paint: its dashes are outlined into one path and filled once, so overlapping
/// dashes don't darken where they overlap. A dashed brush stroke draws its pieces one by one, so
+7
View File
@@ -593,6 +593,13 @@ impl Renderer {
if self.skipped(f, c) {
continue;
}
// Hard-edge coverage is binary, so an opaque fill replaces its knockout shape exactly.
// This also serves supersampled exports. Analytical AA keeps the erase/add passes:
// their intermediate rounding can differ from source-over at partially covered edges.
if f.opts.anti_alias == crate::AntiAlias::None && crate::node_checks::opaque_knockout_fill(c) {
self.draw_arc(ctx, f, c);
continue;
}
self.knockout_pass(ctx, f, c, Compose::DestOut);
match &backdrop {
Some(b) => self.knockout_over(ctx, f, c, b),
+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");
}
}