From eaaf1155a858a119dbd8d9a18b0359401a55f866 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 21:35:47 -0800 Subject: [PATCH] Render: node renders and thumbnails follow the supersample and precise options render_node_with and render_node_thumbnail_with ignored RenderOptions::supersample and RenderOptions::precise, so the Asset Export tiles, which ask for both, were drawn with the canvas's shape-by-shape edges and the fast render mode instead of the export's look. A node render now supersamples Art Optimized edges as a page render does, and picks the quality render mode when precise is set, for the art's offscreen groups too. --- crates/render/src/renderer.rs | 83 ++++++++++++++++++++++------------- crates/render/src/tests.rs | 37 ++++++++++++++++ 2 files changed, 90 insertions(+), 30 deletions(-) diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index a5bfcbf26..7e67cc7b2 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -258,8 +258,7 @@ impl Renderer { }; // `reset` keeps the threshold of the previous render: set it every time. ctx.set_aliasing_threshold(opts.anti_alias.threshold()); - let render_mode = if opts.precise { vello_cpu::RenderMode::OptimizeQuality } else { vello_cpu::RenderMode::OptimizeSpeed }; - self.raster = vello_cpu::RasterizerSettings { render_mode, ..Default::default() }; + self.raster = raster_settings(opts); if opts.artboards && !opts.outline { // Paper: white on screen, no ink on the ink planes. ctx.set_transform(f.view); @@ -358,20 +357,59 @@ impl Renderer { pub fn render_node_with(&mut self, doc: &Document, n: &Arc, w: u16, h: u16, view: Affine, opts: &RenderOptions) -> Option { self.check_plugins(doc); self.check_symbols(doc); - let inv = view.inverse(); - let visible = inv.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64)); + let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64)); + let pixels = self.node_pixels(doc, n, (w, h), view, visible, opts, false, 1); + Some(Rendered { width: w as u32, height: h as u32, pixels }) + } + + /// Node `n` drawn into `w`×`h` transparent pixels through `view`, culled to `visible` (document + /// space); `forced` draws it whatever its visibility and bounds. With [`RenderOptions::supersample`] + /// and Art Optimized edges the art is drawn with hard edges at several times the size and + /// averaged down, and [`RenderOptions::precise`] picks the quality render mode, as in a page + /// render ([`Self::render_as`], [`Self::draw_frame`]). `cell` is as in [`Self::render_as`]. + #[allow(clippy::too_many_arguments)] + fn node_pixels( + &mut self, + doc: &Document, + n: &Arc, + (w, h): (u16, u16), + view: Affine, + visible: Rect, + opts: &RenderOptions, + forced: bool, + cell: u32, + ) -> Vec { + if opts.supersample + && opts.anti_alias == AntiAlias::Art + && let Some(k) = supersample_factor(u32::from(w), u32::from(h)) + { + let hard = RenderOptions { anti_alias: AntiAlias::None, supersample: false, ..opts.clone() }; + // `supersample_factor` keeps each side within MAX_CONTEXT_SIDE, so it fits in a u16. + let (bw, bh) = (u32::from(w) * k, u32::from(h) * k); + let hairline = std::mem::replace(&mut self.hairline, f64::from(k)); + let big = self.node_pixels(doc, n, (bw as u16, bh as u16), Affine::scale(f64::from(k)) * view, visible, &hard, forced, k); + self.hairline = hairline; + return downsample(&big, bw as usize, bh as usize, k as usize); + } let px = 1.0 / view.determinant().abs().sqrt().max(1e-12); let mut ctx = single_threaded_context(w, h); ctx.set_aliasing_threshold(opts.anti_alias.threshold()); let symbols = doc.symbol_boxes().quick(); - let frame = Frame { mt: false, doc, symbols: &symbols, view, visible, px, opts, ink: Ink::Display, output: view, plates: None }; + let output = Affine::scale(1.0 / f64::from(cell.max(1))) * view; + let frame = Frame { mt: false, doc, symbols: &symbols, view, visible, px, opts, ink: Ink::Display, output, plates: None }; + let raster = std::mem::replace(&mut self.raster, raster_settings(opts)); self.begin_frame(); (self.knockout, self.nested, self.backdrop, self.picture) = (false, 0, None, None); self.clip_paths.clear(); - self.draw_arc(&mut ctx, &frame, n); - let pm = self.finish(&mut ctx, vello_cpu::RasterizerSettings::default()); + if forced { + self.draw_forced_arc(&mut ctx, &frame, n); + } else { + self.draw_arc(&mut ctx, &frame, n); + } + let pm = self.finish(&mut ctx, self.raster); self.end_frame(); - Some(Rendered { width: w as u32, height: h as u32, pixels: pm.data_as_u8_slice().to_vec() }) + self.raster = raster; + pm.data_as_u8_slice().to_vec() } /// Render any node (also one outside the tree, e.g. opacity-mask art) fitted into @@ -402,28 +440,7 @@ impl Renderer { // A 1-pixel margin on each side, unless the thumbnail is too small to keep one. let s = (w as f64 - 2.0).max(1.0) / b.width().max(b.height()).max(1e-6); let view = Affine::translate((w as f64 / 2.0, w as f64 / 2.0)) * Affine::scale(s) * Affine::translate(-b.center().to_vec2()); - let mut ctx = single_threaded_context(w, w); - ctx.set_aliasing_threshold(opts.anti_alias.threshold()); - let symbols = doc.symbol_boxes().quick(); - let frame = Frame { - mt: false, - doc, - symbols: &symbols, - view, - visible: b.inflate(1.0, 1.0), - px: 1.0 / s, - opts, - ink: Ink::Display, - output: view, - plates: None, - }; - self.begin_frame(); - (self.knockout, self.nested, self.backdrop, self.picture) = (false, 0, None, None); - self.clip_paths.clear(); - self.draw_forced_arc(&mut ctx, &frame, &Arc::new(n.clone())); - let pm = self.finish(&mut ctx, vello_cpu::RasterizerSettings::default()); - self.end_frame(); - let mut pixels = pm.data_as_u8_slice().to_vec(); + let mut pixels = self.node_pixels(doc, &Arc::new(n.clone()), (w, w), view, b.inflate(1.0, 1.0), opts, true, 1); if let Some(bg) = background { over_background(&mut pixels, bg); } @@ -431,6 +448,12 @@ impl Renderer { } } +/// The rasterizer settings for `opts`: the quality render mode when [`RenderOptions::precise`] is set. +fn raster_settings(opts: &RenderOptions) -> vello_cpu::RasterizerSettings { + let render_mode = if opts.precise { vello_cpu::RenderMode::OptimizeQuality } else { vello_cpu::RenderMode::OptimizeSpeed }; + vello_cpu::RasterizerSettings { render_mode, ..Default::default() } +} + /// Premultiplied RGBA `pixels` drawn over the solid color `background` (`[r, g, b, a]`, straight). pub(crate) fn over_background(pixels: &mut [u8], [r, g, b, a]: [u8; 4]) { let alpha = u32::from(a); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index a12e79e86..dd7198d53 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -952,6 +952,43 @@ fn node_renders_follow_the_anti_alias_option() { assert!(drawn(&thumb) && !partial(&thumb), "render_node_thumbnail_with: every alpha is 0 or 255"); } +/// Node renders follow `supersample` and `precise` as a page render does: two rectangles that meet +/// at x = 50.37 leave no seam when supersampled, and a node render gives the pixels of a page +/// render of the same art with the same options. +#[test] +fn node_renders_follow_the_supersample_and_precise_options() { + let mut d = Document::new(100.0, 100.0); + let left = rect_node(&mut d, Rect::new(10.0, 10.0, 50.37, 90.0), Paint::solid(Color::BLACK), Paint::None, 0.0); + let right = rect_node(&mut d, Rect::new(50.37, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(0.2, 0.6, 0.9)), Paint::None, 0.0); + // Blended at half opacity over both, so the group composites offscreen. + let mut over = rect_node(&mut d, Rect::new(30.0, 60.0, 70.0, 80.0), Paint::solid(Color::rgb(0.9, 0.7, 0.1)), Paint::None, 0.0); + over.opacity = 0.5; + over.blend = vectorcraft_color::BlendMode::Multiply; + let group = Node::group(d.alloc_id(), vec![Arc::new(left), Arc::new(right), Arc::new(over)]); + let l = d.layers[0].id; + d.insert(Some(l), 0, group.clone()).unwrap(); + let node = Arc::new(group.clone()); + let page = |opts: &RenderOptions| RenderOptions { background: None, artboards: false, ..opts.clone() }; + let mut r = Renderer::new(); + for (supersample, precise) in [(false, false), (true, false), (false, true), (true, true)] { + let opts = RenderOptions { supersample, precise, ..Default::default() }; + let case = format!("supersample {supersample}, precise {precise}"); + let img = r.render_node_with(&d, &node, 100, 100, Affine::IDENTITY, &opts).unwrap(); + let whole = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &page(&opts)); + assert!(img.pixels == whole.pixels, "{case}: render_node_with draws as a page render does"); + // Pixel column 50 at row 50: the black rectangle covers 37% of it and the blue one the rest. + let seam = img.pixels[(50 * 100 + 50) * 4 + 3]; + assert_eq!(seam == 255, supersample, "{case}: render_node_with seam alpha {seam}"); + // A 37-pixel thumbnail draws 0.4375 pixels a point, the seam at x = 18.66. + let thumb = r.render_node_thumbnail_with(&d, &group, 37, None, &opts).unwrap(); + let view = Affine::translate((18.5, 18.5)) * Affine::scale(0.4375) * Affine::translate((-50.0, -50.0)); + let whole = Renderer::new().render(&d, 37, 37, view, &page(&opts)); + assert!(thumb.pixels == whole.pixels, "{case}: the thumbnail draws as a page render does"); + let seam = thumb.pixels[(18 * 37 + 18) * 4 + 3]; + assert_eq!(seam == 255, supersample, "{case}: thumbnail seam alpha {seam}"); + } +} + #[test] fn opacity_mask_with_undecodable_image_leaves_out_object() { use vectorcraft_doc::{Effect, ImageBlob, ImageObject, NodeKind, OpacityMask};