mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 13:47:21 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user