mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Render: the canvas no longer draws its picture back for each non-isolated group that blends
A transparency group whose members blend with the art below it, and a masked object with a blend mode, are drawn over a copy of that art. For each such group in each frame, the renderer rendered everything drawn so far into a new pixmap the size of its context (on the canvas, the whole view), started that context again and drew that pixmap back into it as an image, which the next copy and the frame rendered again. At 2880x1800, a document with 50 translucent groups, each holding a Multiply rectangle, took 299 ms a frame on 8 threads. Every render that composites in 8 bits, the canvas included, now keeps that picture and renders what the context draws next over it. The backdrop is still cut from the picture. The 50 groups take 65 ms a frame, and the frames have the same pixels. Renders that composite in floating point (raster exports, Save for Web, Object › Rasterize and Crop Image) still draw the picture back. Docs: docs/target-app-parity.md.
This commit is contained in:
1 parent
819e53fe92
commit
78980c8190
5 files changed
+204
-31
No files matched your search
@@ -135,9 +135,7 @@ impl Renderer {
|
||||
/// Render what `ctx` holds so far, and start it again from that picture (so later drawing sees
|
||||
/// it unchanged). Returns the pixels of `r`.
|
||||
fn flatten(&mut self, ctx: &mut RenderContext, r: Region) -> Arc<Pixmap> {
|
||||
ctx.flush();
|
||||
let mut all = Pixmap::new(ctx.width(), ctx.height());
|
||||
ctx.render_with(&mut all, &mut self.resources, self.raster);
|
||||
let all = self.finish(ctx, self.raster);
|
||||
ctx.reset();
|
||||
let crop = if (r.x, r.y, r.w, r.h) == (0, 0, all.width(), all.height()) {
|
||||
None
|
||||
@@ -151,7 +149,14 @@ impl Renderer {
|
||||
Some(Arc::new(out))
|
||||
};
|
||||
let all = Arc::new(all);
|
||||
draw_pixmap(ctx, all.clone(), (0.0, 0.0));
|
||||
// With 8-bit compositing, rendering over the picture gives the pixels that drawing it back
|
||||
// as an image gives: the context starts again empty, and `finish` renders what it draws
|
||||
// next over the picture. With floating-point compositing the picture is drawn back.
|
||||
if self.raster.render_mode == vello_cpu::RenderMode::OptimizeSpeed {
|
||||
self.picture = Some(all.clone());
|
||||
} else {
|
||||
draw_pixmap(ctx, all.clone(), (0.0, 0.0));
|
||||
}
|
||||
for (path, rule, xf) in &self.clip_paths {
|
||||
ctx.set_transform(*xf);
|
||||
ctx.set_fill_rule(fill_rule(*rule));
|
||||
@@ -160,18 +165,36 @@ impl Renderer {
|
||||
crop.unwrap_or(all)
|
||||
}
|
||||
|
||||
/// Render what `ctx` holds into a pixmap of its size: over the picture [`Self::flatten`]
|
||||
/// started it again from, if any, else over transparency.
|
||||
pub(crate) fn finish(&mut self, ctx: &mut RenderContext, settings: vello_cpu::RasterizerSettings) -> Pixmap {
|
||||
ctx.flush();
|
||||
let Some(below) = self.picture.take() else {
|
||||
let mut pm = Pixmap::new(ctx.width(), ctx.height());
|
||||
ctx.render_with(&mut pm, &mut self.resources, settings);
|
||||
return pm;
|
||||
};
|
||||
let mut pm = Arc::unwrap_or_clone(below);
|
||||
let over = vello_cpu::RasterizerSettings { composite_mode: vello_cpu::CompositeMode::SrcOver, ..settings };
|
||||
ctx.render_with(&mut pm, &mut self.resources, over);
|
||||
pm
|
||||
}
|
||||
|
||||
/// `draw` rendered into a new context of region `r` (frame `sub`), over `backdrop` if any.
|
||||
fn offscreen(&mut self, r: Region, sub: &Frame, backdrop: Option<Arc<Pixmap>>, draw: &mut Content) -> Pixmap {
|
||||
let mut ctx = sub.offscreen_context(r.w, r.h);
|
||||
if let Some(b) = &backdrop {
|
||||
draw_pixmap(&mut ctx, b.clone(), (0.0, 0.0));
|
||||
}
|
||||
let outer = (std::mem::replace(&mut self.nested, 0), std::mem::replace(&mut self.backdrop, backdrop), std::mem::take(&mut self.clip_paths));
|
||||
let outer = (
|
||||
std::mem::replace(&mut self.nested, 0),
|
||||
std::mem::replace(&mut self.backdrop, backdrop),
|
||||
std::mem::take(&mut self.clip_paths),
|
||||
self.picture.take(),
|
||||
);
|
||||
draw(self, &mut ctx, sub);
|
||||
(self.nested, self.backdrop, self.clip_paths) = outer;
|
||||
ctx.flush();
|
||||
let mut pm = Pixmap::new(r.w, r.h);
|
||||
ctx.render_with(&mut pm, &mut self.resources, self.raster);
|
||||
let pm = self.finish(&mut ctx, self.raster);
|
||||
(self.nested, self.backdrop, self.clip_paths, self.picture) = outer;
|
||||
pm
|
||||
}
|
||||
|
||||
@@ -244,12 +267,20 @@ impl Renderer {
|
||||
/// Draw `pm` with its top-left corner at pixel `at` (whole pixels: copied exactly).
|
||||
pub(crate) fn draw_pixmap(ctx: &mut RenderContext, pm: Arc<Pixmap>, at: (f64, f64)) {
|
||||
let (w, h) = (pm.width() as f64, pm.height() as f64);
|
||||
#[cfg(test)]
|
||||
PIXELS_DRAWN.with(|n| n.set(n.get() + u64::from(pm.width()) * u64::from(pm.height())));
|
||||
ctx.set_transform(Affine::translate(at));
|
||||
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler: peniko::ImageSampler::default() });
|
||||
ctx.fill_rect(&Rect::new(0.0, 0.0, w, h));
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// The pixels [`draw_pixmap`] has drawn on this thread.
|
||||
pub(crate) static PIXELS_DRAWN: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// The pixel region of `ctx` that `bounds` (document space) covers, with a margin for
|
||||
/// antialiasing; `None` when it is off the context.
|
||||
fn region(ctx: &RenderContext, f: &Frame, bounds: Option<Rect>) -> Option<Region> {
|
||||
|
||||
+19
-18
@@ -447,6 +447,9 @@ pub struct Renderer {
|
||||
/// Clip paths pushed on the current context (path, rule, transform): starting the context
|
||||
/// again from a picture of it pushes them again (see [`Self::group`]).
|
||||
clip_paths: Vec<(BezPath, FillRule, Affine)>,
|
||||
/// The picture [`Self::flatten`] started the current context again from, with 8-bit
|
||||
/// compositing: [`Self::finish`] renders the context over it.
|
||||
picture: Option<Arc<Pixmap>>,
|
||||
/// What clipping paths paint, per clipping path (see [`Self::clip_paint_of`]).
|
||||
clip_paints: FrameCache<usize, ClipPaintEntry>,
|
||||
/// Gradients along or across strokes (see [`Self::fill_path_gradient`]), keyed by
|
||||
@@ -555,6 +558,7 @@ impl Renderer {
|
||||
backdrop: None,
|
||||
blends: PtrMap::default(),
|
||||
clip_paths: vec![],
|
||||
picture: None,
|
||||
clip_paints: FrameCache::new(lru::CLIP_BUDGET, 256),
|
||||
stroke_slices: PtrMap::default(),
|
||||
adjusts: HashMap::new(),
|
||||
@@ -687,7 +691,7 @@ impl Renderer {
|
||||
ctx.fill_rect(&ab.rect);
|
||||
}
|
||||
}
|
||||
(self.nested, self.backdrop) = (0, None);
|
||||
(self.nested, self.backdrop, self.picture) = (0, None, None);
|
||||
self.clip_paths.clear();
|
||||
if opts.trim && !f.doc.artboards.is_empty() {
|
||||
let mut clip = BezPath::new();
|
||||
@@ -700,9 +704,7 @@ impl Renderer {
|
||||
} else {
|
||||
self.draw_page(&mut ctx, f);
|
||||
}
|
||||
ctx.flush();
|
||||
let mut pm = Pixmap::new(w, h);
|
||||
ctx.render_with(&mut pm, &mut self.resources, self.raster);
|
||||
let pm = self.finish(&mut ctx, self.raster);
|
||||
if threads == 0 {
|
||||
self.ctx_st = Some(ctx);
|
||||
} else {
|
||||
@@ -775,12 +777,10 @@ impl Renderer {
|
||||
let opts = RenderOptions::default();
|
||||
let frame = Frame { mt: false, doc, view, visible, px, opts: &opts, ink: Ink::Display };
|
||||
self.begin_frame();
|
||||
(self.knockout, self.nested, self.backdrop) = (false, 0, None);
|
||||
(self.knockout, self.nested, self.backdrop, self.picture) = (false, 0, None, None);
|
||||
self.clip_paths.clear();
|
||||
self.draw_arc(&mut ctx, &frame, n);
|
||||
ctx.flush();
|
||||
let mut pm = Pixmap::new(w, h);
|
||||
ctx.render(&mut pm, &mut self.resources);
|
||||
let pm = self.finish(&mut ctx, vello_cpu::RasterizerSettings::default());
|
||||
self.end_frame();
|
||||
Some(Rendered { width: w as u32, height: h as u32, pixels: pm.data_as_u8_slice().to_vec() })
|
||||
}
|
||||
@@ -816,12 +816,10 @@ impl Renderer {
|
||||
}
|
||||
let frame = Frame { mt: false, doc, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts, ink: Ink::Display };
|
||||
self.begin_frame();
|
||||
(self.knockout, self.nested, self.backdrop) = (false, 0, None);
|
||||
(self.knockout, self.nested, self.backdrop, self.picture) = (false, 0, None, None);
|
||||
self.clip_paths.clear();
|
||||
self.draw_node(&mut ctx, &frame, n, true);
|
||||
ctx.flush();
|
||||
let mut pm = Pixmap::new(w, w);
|
||||
ctx.render(&mut pm, &mut self.resources);
|
||||
let pm = self.finish(&mut ctx, vello_cpu::RasterizerSettings::default());
|
||||
self.end_frame();
|
||||
Some(Rendered { width: size, height: size, pixels: pm.data_as_u8_slice().to_vec() })
|
||||
}
|
||||
@@ -948,13 +946,16 @@ impl Renderer {
|
||||
let mut mctx = f.offscreen_context(w, h);
|
||||
// Mask art is a picture of its own: it takes no part in a knockout group around the object,
|
||||
// and its luminance is that of its screen colours.
|
||||
let outer =
|
||||
(std::mem::take(&mut self.knockout), std::mem::take(&mut self.nested), self.backdrop.take(), std::mem::take(&mut self.clip_paths));
|
||||
let outer = (
|
||||
std::mem::take(&mut self.knockout),
|
||||
std::mem::take(&mut self.nested),
|
||||
self.backdrop.take(),
|
||||
std::mem::take(&mut self.clip_paths),
|
||||
self.picture.take(),
|
||||
);
|
||||
self.draw_node(&mut mctx, &Frame { mt: false, ink: Ink::Display, ..*f }, &m.art, true);
|
||||
(self.knockout, self.nested, self.backdrop, self.clip_paths) = outer;
|
||||
mctx.flush();
|
||||
let mut pm = Pixmap::new(w, h);
|
||||
mctx.render_with(&mut pm, &mut self.resources, self.raster);
|
||||
let pm = self.finish(&mut mctx, self.raster);
|
||||
(self.knockout, self.nested, self.backdrop, self.clip_paths, self.picture) = outer;
|
||||
pm.data().iter().map(|p| mask_value(p.r, p.g, p.b, p.a, m.clip, m.invert)).collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -108,10 +108,7 @@ fn rasterize(r: &mut Renderer, doc: &Document, def: &PatternDef, region: Rect, w
|
||||
r.draw_arc(&mut ctx, &frame, a);
|
||||
}
|
||||
}
|
||||
ctx.flush();
|
||||
let mut pm = Pixmap::new(w, h);
|
||||
ctx.render_with(&mut pm, &mut r.resources, r.raster);
|
||||
pm
|
||||
r.finish(&mut ctx, r.raster)
|
||||
}
|
||||
|
||||
/// Run `f` with the shared off-screen renderer (None when nested too deep: a pattern whose art
|
||||
|
||||
@@ -27,6 +27,13 @@ fn multiply(d: &mut Document) -> Node {
|
||||
n
|
||||
}
|
||||
|
||||
/// A Multiply rectangle of color `c` at `r`.
|
||||
fn multiply_in(d: &mut Document, r: Rect, c: [f32; 3]) -> Node {
|
||||
let mut n = rect(d, r, c);
|
||||
n.blend = Blend::Multiply;
|
||||
n
|
||||
}
|
||||
|
||||
/// The backdrop, then `top` (built by `make`), in a 100 × 100 document.
|
||||
fn doc(make: impl FnOnce(&mut Document) -> Node) -> Document {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
@@ -219,6 +226,142 @@ fn copying_the_backdrop_changes_nothing_without_blending() {
|
||||
assert!(worst <= 1, "differs by {worst}/255");
|
||||
}
|
||||
|
||||
/// A 300 × 300 page: the backdrop with a 60% stripe over its right half, then nine objects that
|
||||
/// do not overlap. Each object copies its backdrop or is drawn between objects that do. Object `i`
|
||||
/// is on the page when `with(i)` is true.
|
||||
fn objects_apart(with: impl Fn(usize) -> bool) -> Document {
|
||||
let mut d = Document::new(300.0, 300.0);
|
||||
let below = rect(&mut d, Rect::new(0.0, 0.0, 300.0, 300.0), B);
|
||||
let mut stripe = rect(&mut d, Rect::new(150.0, 0.0, 300.0, 300.0), [0.1, 0.3, 0.9]);
|
||||
stripe.opacity = 0.6;
|
||||
let cell = |x: f64, y: f64| Rect::new(x, y, x + 50.0, y + 50.0);
|
||||
let mut objects = vec![];
|
||||
// Objects 0 and 1 are a 50% group and a 70% Screen group, each holding a Multiply child.
|
||||
let m = multiply_in(&mut d, cell(10.0, 10.0), S);
|
||||
objects.push(group(&mut d, vec![m], 0.5, Blend::Normal, false));
|
||||
let m = multiply_in(&mut d, cell(110.0, 10.0), S);
|
||||
objects.push(group(&mut d, vec![m], 0.7, Blend::Screen, false));
|
||||
// Object 2 is a masked group holding a Multiply child. Its mask art holds a 50% group with a
|
||||
// Multiply child.
|
||||
let m = multiply_in(&mut d, cell(210.0, 10.0), S);
|
||||
let mut masked = group(&mut d, vec![m], 1.0, Blend::Normal, false);
|
||||
let white = rect(&mut d, cell(205.0, 5.0), [1.0; 3]);
|
||||
let gray = multiply_in(&mut d, Rect::new(220.0, 20.0, 250.0, 50.0), [0.5; 3]);
|
||||
let half = group(&mut d, vec![gray], 0.5, Blend::Normal, false);
|
||||
let art = group(&mut d, vec![white, half], 1.0, Blend::Normal, false);
|
||||
masked.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, true)));
|
||||
objects.push(masked);
|
||||
// Object 3 is a knockout group of two Multiply rectangles that overlap.
|
||||
let lower = multiply_in(&mut d, Rect::new(10.0, 110.0, 45.0, 160.0), S);
|
||||
let upper = multiply_in(&mut d, Rect::new(25.0, 110.0, 60.0, 160.0), [1.0, 0.4, 0.6]);
|
||||
let mut knockout = group(&mut d, vec![lower, upper], 1.0, Blend::Normal, false);
|
||||
knockout.knockout = Knockout::On;
|
||||
objects.push(knockout);
|
||||
// Object 4 is an isolated group that holds a 50% group with a Multiply child.
|
||||
let green = rect(&mut d, cell(110.0, 110.0), [0.2, 0.9, 0.4]);
|
||||
let m = multiply_in(&mut d, Rect::new(120.0, 120.0, 150.0, 150.0), S);
|
||||
let half = group(&mut d, vec![m], 0.5, Blend::Normal, false);
|
||||
objects.push(group(&mut d, vec![green, half], 1.0, Blend::Normal, true));
|
||||
// Object 5 is a clip group that holds a 50% group with a Multiply child and a rectangle after
|
||||
// the group.
|
||||
let clip = clip_path(&mut d, Rect::new(215.0, 115.0, 255.0, 155.0));
|
||||
let m = multiply_in(&mut d, cell(210.0, 110.0), S);
|
||||
let half = group(&mut d, vec![m], 0.5, Blend::Normal, false);
|
||||
let after = rect(&mut d, Rect::new(210.0, 140.0, 260.0, 160.0), [0.9, 0.9, 0.1]);
|
||||
let children = [clip, half, after].into_iter().map(Arc::new).collect();
|
||||
objects.push(Node::new(d.alloc_id(), NodeKind::Group { children, clip: true }));
|
||||
// Object 6 is a 50% rectangle that is not in a group.
|
||||
let mut plain = rect(&mut d, cell(10.0, 210.0), [0.3, 0.2, 0.8]);
|
||||
plain.opacity = 0.5;
|
||||
objects.push(plain);
|
||||
// Object 7 is a Screen group at 100% holding a Multiply child.
|
||||
let m = multiply_in(&mut d, cell(110.0, 210.0), S);
|
||||
objects.push(group(&mut d, vec![m], 1.0, Blend::Screen, false));
|
||||
// Object 8 is a 50% group whose Multiply child reaches past the page's corner.
|
||||
let m = multiply_in(&mut d, Rect::new(260.0, 260.0, 330.0, 330.0), S);
|
||||
objects.push(group(&mut d, vec![m], 0.5, Blend::Normal, false));
|
||||
let l = d.layers[0].id;
|
||||
let kept = objects.into_iter().enumerate().filter(|(i, _)| with(*i)).map(|(_, n)| n);
|
||||
for n in [below, stripe].into_iter().chain(kept) {
|
||||
d.insert(Some(l), usize::MAX, n).unwrap();
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// The renderer copies the backdrop of several objects in one frame, each from the picture of what
|
||||
/// it drew before that object. The frame equals the page drawn with each object alone, pixel for
|
||||
/// pixel, when it is drawn on one thread and on two, turned, composited in floating point, inside
|
||||
/// an isolated page group, zoomed in until the first group fills the frame, and as the renderer's
|
||||
/// next frame.
|
||||
#[test]
|
||||
fn objects_that_copy_their_backdrop_draw_as_they_do_alone() {
|
||||
let (all, none) = (objects_apart(|_| true), objects_apart(|_| false));
|
||||
let alone: Vec<Document> = (0..9).map(|i| objects_apart(|j| j == i)).collect();
|
||||
let (plain, precise) = (RenderOptions::default(), RenderOptions { precise: true, ..Default::default() });
|
||||
let turned = Affine::rotate_about(0.35, vectorcraft_geom::Point::new(150.0, 150.0));
|
||||
let zoomed = Affine::translate((150.0, 150.0)) * Affine::scale(6.0) * Affine::translate((-35.0, -35.0));
|
||||
for (what, threads, view, opts, isolate) in [
|
||||
("one thread", 0, Affine::IDENTITY, &plain, false),
|
||||
("two threads", 2, Affine::IDENTITY, &plain, false),
|
||||
("turned", 0, turned, &plain, false),
|
||||
("floating point", 0, Affine::IDENTITY, &precise, false),
|
||||
("isolated page", 0, Affine::IDENTITY, &plain, true),
|
||||
("zoomed", 0, zoomed, &plain, false),
|
||||
] {
|
||||
let mut r = Renderer::new();
|
||||
r.threads = threads;
|
||||
let mut draw = |d: &Document| {
|
||||
let mut d = d.clone();
|
||||
d.page_isolate = isolate;
|
||||
r.render(&d, 300, 300, view, opts).pixels
|
||||
};
|
||||
let below = draw(&none);
|
||||
let mut want = below.clone();
|
||||
for d in &alone {
|
||||
let one = draw(d);
|
||||
for ((w, o), b) in want.as_chunks_mut::<4>().0.iter_mut().zip(one.as_chunks::<4>().0).zip(below.as_chunks::<4>().0) {
|
||||
if o != b {
|
||||
assert_eq!(*w, *b, "{what}: two objects draw on one pixel");
|
||||
*w = *o;
|
||||
}
|
||||
}
|
||||
}
|
||||
for frame in 1..=2 {
|
||||
let got = draw(&all);
|
||||
let differ: Vec<usize> = (0..300 * 300).filter(|i| got[i * 4..i * 4 + 4] != want[i * 4..i * 4 + 4]).collect();
|
||||
let at = differ.first().map(|i| (i % 300, i / 300));
|
||||
assert!(differ.is_empty(), "{what}, frame {frame}: {} pixels differ, the first at {at:?}", differ.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The renderer copies the backdrop of twenty 50% groups with Multiply children on a 400 × 400
|
||||
/// page. The images it draws in that frame add up to fewer pixels than the page: it draws no
|
||||
/// picture of the page back after a copy.
|
||||
#[test]
|
||||
fn backdrop_copies_draw_no_picture_of_the_page_back() {
|
||||
let mut d = Document::new(400.0, 400.0);
|
||||
let l = d.layers[0].id;
|
||||
let below = rect(&mut d, Rect::new(0.0, 0.0, 400.0, 400.0), B);
|
||||
d.insert(Some(l), usize::MAX, below).unwrap();
|
||||
for i in 0..20u32 {
|
||||
let (x, y) = (10.0 + f64::from(i % 5) * 80.0, 10.0 + f64::from(i / 5) * 80.0);
|
||||
let m = multiply_in(&mut d, Rect::new(x, y, x + 20.0, y + 20.0), S);
|
||||
let g = group(&mut d, vec![m], 0.5, Blend::Normal, false);
|
||||
d.insert(Some(l), usize::MAX, g).unwrap();
|
||||
}
|
||||
let drawn = || crate::group::PIXELS_DRAWN.with(std::cell::Cell::get);
|
||||
for threads in [0, 2] {
|
||||
let mut r = Renderer::new();
|
||||
r.threads = threads;
|
||||
let before = drawn();
|
||||
let frame = r.render(&d, 400, 400, Affine::IDENTITY, &RenderOptions::default());
|
||||
let images = drawn() - before;
|
||||
assert!(images < 400 * 400, "threads {threads}: {images} pixels drawn as images");
|
||||
assert_near(frame.pixel(20, 20), lerp(B, blend_rgb(Blend::Multiply, B, S), 0.5), "inside a group");
|
||||
}
|
||||
}
|
||||
|
||||
/// Isolation mode (#833): the art around the isolated group draws at half opacity, above it as
|
||||
/// well as below; the group's own art as usual.
|
||||
#[test]
|
||||
|
||||
@@ -485,6 +485,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
Fill and stroke paint (Fill, Stroke, None, Apply Last Color and Apply Last Gradient, also on characters selected with the Type tool), the Stroke panel's options, gradient and freeform point edits, Swap Fill and Stroke, Default Fill and Stroke, the Eyedropper, and the Appearance panel's item edits, Clear Appearance, Reduce to Basic Appearance, Remove Item, item reordering and Move Appearance record no undo step when they change nothing.
|
||||
- **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document state; a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks.
|
||||
- **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. SVG export writes a knockout group of opaque art plainly, without masks, so shapes meeting edge to edge leave no seam; translucent, blended, masked or brushed art knocks out through masks. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel.
|
||||
On the canvas, the renderer no longer draws the picture of the art below back as an image each time it copies the backdrop of a non-isolated group that blends: it keeps that picture and draws the art that follows over it. At 2880 × 1800, 50 translucent groups with Multiply members take 65 ms a frame instead of 299 ms on 8 threads (`vectorcraft-cli bench`), and the frames have the same pixels.
|
||||
- **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`.
|
||||
- **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names.
|
||||
- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize.
|
||||
|
||||
Reference in new issue
Block a user