From ede08590a0394138afe05ea5e2105001fc7e960d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ju=CC=88rg=20Lehni?= Date: Wed, 21 Oct 2026 20:52:53 +0200 Subject: [PATCH] Canvas: an edit redraws only the area it changed Wide effect rasters cropped to the frame also serve parts of it. --- crates/doc/src/lib.rs | 130 +++++++++++++++++ crates/doc/src/node.rs | 6 +- crates/doc/src/text.rs | 6 + crates/render/src/changed.rs | 120 ++++++++++++++++ crates/render/src/fx.rs | 57 ++++++-- crates/render/src/lib.rs | 31 ++-- crates/render/src/tests_changed.rs | 215 ++++++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 133 ++++++++++++++++- crates/ui-egui/src/lib.rs | 5 + crates/ui-egui/src/render_worker.rs | 6 +- 10 files changed, 675 insertions(+), 34 deletions(-) create mode 100644 crates/render/src/changed.rs create mode 100644 crates/render/src/tests_changed.rs diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 0817b1cd6..1ddae592c 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -935,6 +935,136 @@ type Places = BTreeMap, Vec<(usize, usize)>>; pub const FORMAT_VERSION: u32 = 1; impl Document { + /// Is everything but the layers the same in `other`, as the next version of the document after + /// an edit of its art? The id counter and the id index are left out, and art outside the layers + /// (symbols, patterns, graph designs) compares by identity, which an edit keeps for what it + /// doesn't change: quick for every frame of an animation. + pub fn same_outside_layers(&self, other: &Self) -> bool { + let same_art = |a: &[Arc], b: &[Arc]| a.len() == b.len() && a.iter().zip(b).all(|(a, b)| Arc::ptr_eq(a, b)); + let same_pattern = |a: &PatternDef, b: &PatternDef| { + same_art(&a.art, &b.art) && PatternDef { art: Vec::new(), ..a.clone() } == PatternDef { art: Vec::new(), ..b.clone() } + }; + let Self { + version, + title, + template, + units, + ruler_origin, + rulers, + artboard_ruler_origins, + color_mode, + artboards, + layers: _, + swatches, + swatch_groups, + graphic_styles, + char_styles, + para_styles, + text_threads, + symbols, + graph_designs, + guides, + views, + saved_selections, + variables, + grid, + raster_effects_ppi, + images, + patterns, + pattern_edit, + mask_edit, + next_id: _, + unknown, + page_isolate, + page_knockout, + spot_use_lab, + setup, + paste_remembers_layers, + metadata, + raster_effects, + last_view, + transparency_grid, + layers_open, + color_profiles, + export_settings, + extra, + slices, + slices_clip_to_artboard, + print_setup, + assets, + svg_scripts, + puppet, + new_art, + dimensions, + index: _, + } = self; + *version == other.version + && *title == other.title + && *template == other.template + && *units == other.units + && *ruler_origin == other.ruler_origin + && *rulers == other.rulers + && *artboard_ruler_origins == other.artboard_ruler_origins + && *color_mode == other.color_mode + && *artboards == other.artboards + && *swatches == other.swatches + && *swatch_groups == other.swatch_groups + && *graphic_styles == other.graphic_styles + && *char_styles == other.char_styles + && *para_styles == other.para_styles + && *text_threads == other.text_threads + && symbols.len() == other.symbols.len() + && symbols.iter().zip(&other.symbols).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art)) + && graph_designs.len() == other.graph_designs.len() + && graph_designs.iter().zip(&other.graph_designs).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art)) + && *guides == other.guides + && *views == other.views + && *saved_selections == other.saved_selections + && *variables == other.variables + && *grid == other.grid + && *raster_effects_ppi == other.raster_effects_ppi + && *images == other.images + && patterns.len() == other.patterns.len() + && patterns.iter().zip(&other.patterns).all(|(a, b)| same_pattern(a, b)) + && match (pattern_edit, &other.pattern_edit) { + (Some(a), Some(b)) => { + a.pattern == b.pattern + && a.layer == b.layer + && match (&a.original, &b.original) { + (Some(a), Some(b)) => same_pattern(a, b), + (a, b) => a.is_none() && b.is_none(), + } + } + (a, b) => a.is_none() && b.is_none(), + } + && *mask_edit == other.mask_edit + && *unknown == other.unknown + && *page_isolate == other.page_isolate + && *page_knockout == other.page_knockout + && *spot_use_lab == other.spot_use_lab + && *setup == other.setup + && *paste_remembers_layers == other.paste_remembers_layers + && *metadata == other.metadata + && *raster_effects == other.raster_effects + && *last_view == other.last_view + && *transparency_grid == other.transparency_grid + && *layers_open == other.layers_open + && *color_profiles == other.color_profiles + && *export_settings == other.export_settings + && *extra == other.extra + && *slices == other.slices + && *slices_clip_to_artboard == other.slices_clip_to_artboard + && *print_setup == other.print_setup + && *assets == other.assets + && *svg_scripts == other.svg_scripts + && match (puppet, &other.puppet) { + (Some(a), Some(b)) => Arc::ptr_eq(a, b), + (a, b) => a.is_none() && b.is_none(), + } + && *new_art == other.new_art + && *dimensions == other.dimensions + } + /// Document Raster Effects Settings › Resolution in ppi, 1 to 2400 (72 when the stored value /// isn't a number). pub fn raster_ppi(&self) -> f64 { diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index ad2d76b19..eef469061 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -1301,10 +1301,8 @@ impl Node { self.projected(Some(b)).map(|b| b.inflate(o, o)) } NodeKind::Text(t) => { - // The characters' strokes paint around the glyphs, scaled with the type. - let k = Scaling::factor(t.xf).unwrap_or(1.0); - let chars = t.runs.iter().map(|r| r.style.stroke_outset()).fold(0.0, f64::max) * k; - let o = self.appearance.outset().max(chars); + // The characters' strokes paint around the glyphs. + let o = self.appearance.outset().max(t.char_stroke_outset()); self.geometric(symbols).map(|b| b.inflate(o, o)) } // An image is measured by its frame, without its own fills and strokes. diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 9a1f8ae43..1dc202553 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -1710,6 +1710,12 @@ impl TextObject { TextKind::Point => Some(self.xf.transform_rect_bbox(self.cached_bounds.unwrap_or_else(|| self.estimate_bounds()))), } } + /// How far the characters' strokes paint past their glyphs, in document space: they scale + /// with the type. + pub fn char_stroke_outset(&self) -> f64 { + let k = crate::node::Scaling::factor(self.xf).unwrap_or(1.0); + self.runs.iter().map(|r| r.style.stroke_outset()).fold(0.0, f64::max) * k + } pub fn transform(&mut self, a: Affine) { self.xf = a * self.xf; } diff --git a/crates/render/src/changed.rs b/crates/render/src/changed.rs new file mode 100644 index 000000000..8af4c07d9 --- /dev/null +++ b/crates/render/src/changed.rs @@ -0,0 +1,120 @@ +//! What draws differently after an edit, so a view can draw again only there. + +use std::sync::Arc; + +use vectorcraft_doc::{Document, Node, NodeKind, SymbolBoxes}; +use vectorcraft_geom::{Affine, Rect}; + +/// What may draw differently in a document than in its version before an edit ([`changed`]). +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Changed { + Nothing, + /// This area of the document, outside of which every pixel draws as before. + Area(Rect), + Everything, +} + +/// What may draw differently in `new` than in `old`, the version before an edit: the bounds of +/// everything the objects that changed paint, before and after the edit ([`crate::painted_bounds`]), +/// or everything when more than the art in the layers changed, or while a pattern is edited (its +/// art also draws in the copies round it). Objects that an edit doesn't touch +/// keep their identity, so this costs what changed, not the size of the document. +pub fn changed(old: &Document, new: &Document) -> Changed { + if new.pattern_edit.is_some() || !old.same_outside_layers(new) { + return Changed::Everything; + } + let mut diff = Diff { old: old.symbol_boxes(), new: new.symbol_boxes(), area: None }; + diff.children(&old.layers, &new.layers); + diff.area.map_or(Changed::Nothing, Changed::Area) +} + +/// The pixels of a `w`×`h` render through `view` that drawing `area` again covers, as x0, y0, x1, +/// y1: its box in the view, grown by a pixel for the anti-aliased edges. None when it covers none. +pub fn view_pixels(area: Rect, view: Affine, w: u32, h: u32) -> Option<[u32; 4]> { + let r = view.transform_rect_bbox(area).inflate(1.0, 1.0); + if ![r.x0, r.y0, r.x1, r.y1].iter().all(|c| c.is_finite()) { + return None; + } + let (x0, y0) = (r.x0.floor().max(0.0), r.y0.floor().max(0.0)); + let (x1, y1) = (r.x1.ceil().min(f64::from(w)), r.y1.ceil().min(f64::from(h))); + (x1 > x0 && y1 > y0).then_some([x0 as u32, y0 as u32, x1 as u32, y1 as u32]) +} + +/// [`changed`] in progress: the symbols' boxes of both versions, and the area so far. +struct Diff<'a> { + old: SymbolBoxes<'a>, + new: SymbolBoxes<'a>, + area: Option, +} + +impl Diff<'_> { + /// Members in the same order compare one by one. Members added, removed or restacked count + /// before and after, except those that kept their place unchanged. + fn children(&mut self, old: &[Arc], new: &[Arc]) { + if old.len() == new.len() && old.iter().zip(new).all(|(a, b)| a.id == b.id) { + for (a, b) in old.iter().zip(new) { + self.node(a, b); + } + return; + } + for i in 0..old.len().max(new.len()) { + match (old.get(i), new.get(i)) { + (Some(a), Some(b)) if Arc::ptr_eq(a, b) => {} + (a, b) => { + self.add_old(a.map(|a| &**a)); + self.add_new(b.map(|b| &**b)); + } + } + } + } + + /// A group or layer that only its members changed in, without a clipping mask or effects of + /// its own that draw from all of them, changed where they did. Any other object that changed + /// counts whole, before and after. + fn node(&mut self, old: &Arc, new: &Arc) { + if Arc::ptr_eq(old, new) { + return; + } + if let (Some(a), Some(b)) = (members(old), members(new)) + && same_but_members(old, new) + { + self.children(a, b); + } else { + self.add_old(Some(old)); + self.add_new(Some(new)); + } + } + + fn add_old(&mut self, n: Option<&Node>) { + self.add(n.and_then(|n| crate::brush_fx::cull_bounds(&self.old, n))); + } + + fn add_new(&mut self, n: Option<&Node>) { + self.add(n.and_then(|n| crate::brush_fx::cull_bounds(&self.new, n))); + } + + fn add(&mut self, r: Option) { + self.area = vectorcraft_geom::union_opt(self.area, r); + } +} + +/// The members of a group or layer whose members draw each on their own: no clipping mask, and no +/// effects of its own. +fn members(n: &Node) -> Option<&[Arc]> { + match &n.kind { + NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } if !crate::fx::has_object_fx(n) => Some(children), + _ => None, + } +} + +/// Are `a` and `b` the same but for their members? +fn same_but_members(a: &Node, b: &Node) -> bool { + let strip = |n: &Node| { + let mut n = n.clone(); + if let Some(children) = n.children_mut() { + children.clear(); + } + n + }; + strip(a) == strip(b) +} diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index f078d235c..98a1d6baa 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -478,12 +478,25 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Optio } _ => { let b = n.visual_bounds_with(symbols)?; + let b = match &n.kind { + NodeKind::Text(t) => vectorcraft_geom::union_opt(Some(b), glyphs_box(t, n.appearance.outset())).unwrap_or(b), + _ => b, + }; let a = live_content_reach(symbols, n); Some(b.inflate(a, a)) } } } +/// The box of type `t`'s glyphs as laid out (its layout bounds, else laid out now), grown by its +/// characters' strokes and by `outset`: on a path they stand above it and run past its ends, in an +/// area they can hang past its frame, where its own box ([`Node::visual_bounds_with`]) doesn't. +fn glyphs_box(t: &vectorcraft_doc::TextObject, outset: f64) -> Option { + let b = t.cached_bounds.unwrap_or_else(|| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).bounds); + let o = outset.max(t.char_stroke_outset()); + (b.width() > 0.0 || b.height() > 0.0).then(|| t.xf.transform_rect_bbox(b).inflate(o, o)) +} + /// How far the content of live object `n` (a blend, envelope or repeat) can paint past its visual /// bounds: for each object in it, how far the effects in a symbol instance's art paint past that /// art ([`art_effects_reach`]) and how far brush art can paint ([`crate::brush_fx::pad`]), on the @@ -643,7 +656,7 @@ pub(crate) struct PixelEntry { /// For one cropped to the view, or made at a lower resolution, the view and the rect it was /// made for: it draws only there, so dragging other art over the object keeps it (a wide blur, /// or any blur zoomed in, reaches past the pixel budget). - view: Option, + view: Option, made: Made, /// What the content read when the raster was made, with the channels the effects ran on and, /// under De-Interlace, the raster resolution. @@ -672,13 +685,29 @@ fn linear_key(view: Affine) -> [u64; 4] { [c[0].to_bits(), c[1].to_bits(), c[2].to_bits(), c[3].to_bits()] } -/// A view's six coefficients (its pan included) and the device-pixel rect a frame draws in, as a -/// cache key: a raster cropped to that rect, or made at a lower resolution than the view's, draws -/// only in that view of that rect ([`PixelEntry::view`], [`ShadowEntry::view`]). -type ViewKey = ([u64; 6], [u64; 4]); +/// The pan of the view a raster was cropped for, or made at a lower resolution than the view's, and +/// the device-pixel rect of the frame it was made in ([`PixelEntry::view`], [`ShadowEntry::view`]). +#[derive(Clone, Copy, Debug)] +struct ViewCrop { + pan: Vec2, + screen: Rect, +} -fn view_key(view: Affine, screen: Rect) -> ViewKey { - (view.as_coeffs().map(f64::to_bits), [screen.x0, screen.y0, screen.x1, screen.y1].map(f64::to_bits)) +impl ViewCrop { + fn of(view: Affine, screen: Rect) -> Self { + Self { pan: view.translation(), screen } + } + + /// Does the raster draw in a frame through `view` (of the same zoom and rotation) onto + /// `screen`? When the view is panned by whole pixels and the frame's rect, panned back, lies + /// within the one the raster was made in: the same pixels in the same places, as when a frame + /// is drawn again only in a part of it. + fn serves(&self, view: Affine, screen: Rect) -> bool { + let d = view.translation() - self.pan; + let whole = Vec2::new(d.x.round(), d.y.round()); + let s = screen - whole; + (d - whole).hypot() < 1e-6 && s.x0 >= self.screen.x0 && s.y0 >= self.screen.y0 && s.x1 <= self.screen.x1 && s.y1 <= self.screen.y1 + } } /// Most pixels a raster of [`Renderer::pixel_content`] or [`Renderer::with_filters`] holds while @@ -768,7 +797,7 @@ pub(crate) struct ShadowEntry { /// `None` for a raster of the whole silhouette's reach: it draws after a pan too. For one /// cropped to the view, or made over such a raster, the view and the rect it was made for: it /// draws only there ([`PixelEntry::view`]). - view: Option, + view: Option, made: Made, /// What the silhouette read when the raster was made. inputs: Inputs, @@ -1084,9 +1113,9 @@ impl Renderer { // What the silhouette reads: as the raster kept for `n` found it, else found now. let reads = self.shadows.get(&key).filter(|e| e.node.is(n)).map_or_else(|| Reads::of(n), |e| e.inputs.reads); let inputs = self.raster_inputs(f, n, reads); - let in_view = view_key(f.view, screen); + let in_view = ViewCrop::of(f.view, screen); let exact = |e: &ShadowEntry| { - e.linear == linear && e.node.is(n) && e.inputs == inputs && (interacting || !e.moved) && e.view.is_none_or(|v| v == in_view) + e.linear == linear && e.node.is(n) && e.inputs == inputs && (interacting || !e.moved) && e.view.is_none_or(|v| v.serves(f.view, screen)) }; if interacting && !self.shadows.get(&key).is_some_and(exact) { self.move_shadow(f, n, key, fx, reach + Vec2::new(dx, dy), owner, &inputs, paint); @@ -1784,7 +1813,7 @@ impl Renderer { // The cached raster: of the whole content at full size at this zoom, else of this view. let linear = linear_key(f.view); let whole = fits && k >= 1.0; - let in_view = (!whole).then(|| view_key(f.view, screen)); + let in_view = (!whole).then(|| ViewCrop::of(f.view, screen)); let key = content.cache.map(|slot| (content.node as *const Node as usize, slot, f.ink)); let stamp = self.stamp; let channels = match f.ink { @@ -1803,8 +1832,12 @@ impl Renderer { self.raster_inputs(f, content.node, reads).with((std::mem::discriminant(&channels), interlaced.then_some(ppi.to_bits()))) }); let interacting = f.opts.interacting; + let in_place = |e: &PixelEntry| match e.view { + None => whole, + Some(v) => !whole && v.serves(f.view, screen), + }; let exact = |e: &PixelEntry| { - e.linear == linear && e.view == in_view && e.node.is(content.node) && Some(&e.inputs) == inputs.as_ref() && (interacting || !e.moved) + e.linear == linear && in_place(e) && e.node.is(content.node) && Some(&e.inputs) == inputs.as_ref() && (interacting || !e.moved) }; if interacting && whole diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index f71957999..fa38b9372 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -7,6 +7,7 @@ #![forbid(unsafe_code)] mod brush_fx; +mod changed; pub mod encode; mod freeform; mod fx; @@ -35,6 +36,7 @@ use ink::Ink; use lru::FrameCache; pub use brush_fx::instance_art; +pub use changed::{Changed, changed, view_pixels}; pub use effects::stroke::width_outline; pub use images::{IMAGE_CACHE_MAX, image_cache_bytes, set_image_cache_budget}; pub use live::expand_live; @@ -101,7 +103,7 @@ pub fn raster_size(region: Rect, scale: f64) -> Result<(u32, u32), String> { } /// Rendering options. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct RenderOptions { /// Outline (wireframe) view: 1 px black paths, no paint. pub outline: bool, @@ -444,8 +446,6 @@ pub struct Renderer { text_fonts: u64, /// Clip regions per clipping path (see [`Self::clip_of`]). clips: FrameCache, - /// Context reused when rendering single-threaded (`threads == 0`). - ctx_st: Option, /// Worker threads for the multithreaded rasterizer (0 = single-threaded). pub threads: u16, geom: PtrMap, @@ -476,7 +476,10 @@ pub struct Renderer { stamp: u64, /// Opacity folded into paint alpha for the leaf being drawn (avoids a compositing layer). alpha: f32, - ctx: Option, + /// The contexts kept for the next frames, with the threads they draw on, the last used last: a + /// frame of the size of one reuses it instead of making a context and its threads. Two are + /// kept, so a view's frames and the parts of them drawn again ([`changed`]) each keep theirs. + contexts: Vec<(u16, RenderContext)>, resources: Resources, /// Statistics of the last frame. pub stats: FrameStats, @@ -613,7 +616,6 @@ impl Renderer { texts: FrameCache::new(lru::TEXT_BUDGET, 1024), text_fonts: 0, clips: FrameCache::new(lru::CLIP_BUDGET, 256), - ctx_st: None, threads: default_threads(), geom: PtrMap::default(), symbols: vec![], @@ -625,7 +627,7 @@ impl Renderer { fx_geom: PtrMap::default(), stamp: 0, alpha: 1.0, - ctx: None, + contexts: Vec::new(), resources: Resources::new(), stats: FrameStats::default(), brushes: Default::default(), @@ -761,13 +763,13 @@ impl Renderer { /// Draw frame `f` (`w`×`h` pixels): the background, the artboards and the page's art. fn draw_frame(&mut self, f: &Frame, w: u16, h: u16) -> Vec { let (threads, opts) = (self.threads, f.opts); - let slot = if threads == 0 { self.ctx_st.take() } else { self.ctx.take() }; - let mut ctx = match slot { - Some(mut c) if c.width() == w && c.height() == h => { + let kept = self.contexts.iter().position(|(t, c)| *t == threads && c.width() == w && c.height() == h); + let mut ctx = match kept.map(|i| self.contexts.remove(i)) { + Some((_, mut c)) => { c.reset(); c } - _ => RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: threads, ..Default::default() }), + None => RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: threads, ..Default::default() }), }; // `reset` keeps the threshold of the previous render: set it every time. ctx.set_aliasing_threshold(opts.anti_alias.threshold()); @@ -800,10 +802,9 @@ impl Renderer { self.draw_page(&mut ctx, f); } let pm = self.finish(&mut ctx, self.raster); - if threads == 0 { - self.ctx_st = Some(ctx); - } else { - self.ctx = Some(ctx); + self.contexts.push((threads, ctx)); + if self.contexts.len() > 2 { + self.contexts.remove(0); } pm.data_as_u8_slice().to_vec() } @@ -2560,6 +2561,8 @@ mod tests_alternate; #[cfg(test)] mod tests_blend; #[cfg(test)] +mod tests_changed; +#[cfg(test)] mod tests_charstroke; #[cfg(test)] mod tests_clip; diff --git a/crates/render/src/tests_changed.rs b/crates/render/src/tests_changed.rs new file mode 100644 index 000000000..b5e78138f --- /dev/null +++ b/crates/render/src/tests_changed.rs @@ -0,0 +1,215 @@ +//! Drawing again only what an edit changed gives the pixels of drawing it all again. + +use super::*; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::Appearance; +use vectorcraft_geom::{Point, shapes}; + +const W: u32 = 160; +const H: u32 = 120; + +/// A view that doesn't line the art up with the pixels, so the edges are anti-aliased. +fn view() -> Affine { + Affine::translate((3.3, 2.7)) * Affine::scale(1.37) +} + +fn rect(d: &mut Document, r: Rect, fill: Color) -> Node { + let id = d.alloc_id(); + Node::path(id, shapes::rectangle(r), Appearance::basic(Paint::solid(fill), Paint::solid(Color::BLACK), 2.0)) +} + +fn shadow() -> vectorcraft_doc::Effect { + vectorcraft_doc::Effect { + id: "stylize.dropShadow".into(), + params: serde_json::json!({"x": 6.0, "y": 6.0, "blur": 4.0, "opacity": 75.0}), + visible: true, + } +} + +/// A layer with a rectangle, a group at half opacity holding two, a clip group, and one with a +/// drop shadow; the ids of the loose rectangle, a member of each group, and the shadowed one. +fn document() -> (Document, [NodeId; 5]) { + let mut d = Document::new(110.0, 80.0); + let l = d.layers[0].id; + let loose = rect(&mut d, Rect::new(5.0, 5.0, 25.0, 20.0), Color::rgb(1.0, 0.0, 0.0)); + let loose = d.insert(Some(l), 0, loose).unwrap(); + let (a, b) = ( + rect(&mut d, Rect::new(30.0, 5.0, 50.0, 25.0), Color::rgb(0.0, 0.6, 0.0)), + rect(&mut d, Rect::new(40.0, 15.0, 60.0, 35.0), Color::rgb(0.0, 0.0, 1.0)), + ); + let mut group = Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(a), Arc::new(b)], clip: false }); + group.opacity = 0.5; + let member = group.children().unwrap()[1].id; + d.insert(Some(l), 1, group).unwrap(); + let (mask, art) = + (rect(&mut d, Rect::new(5.0, 40.0, 30.0, 60.0), Color::WHITE), rect(&mut d, Rect::new(0.0, 45.0, 40.0, 55.0), Color::rgb(0.8, 0.4, 0.0))); + let clipped = art.id; + let clip = Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(mask), Arc::new(art)], clip: true }); + d.insert(Some(l), 2, clip).unwrap(); + let mut shadowed = rect(&mut d, Rect::new(60.0, 45.0, 80.0, 60.0), Color::rgb(0.9, 0.9, 0.2)); + shadowed.appearance.effects.push(shadow()); + let shadowed = d.insert(Some(l), 3, shadowed).unwrap(); + let other = rect(&mut d, Rect::new(85.0, 10.0, 105.0, 30.0), Color::rgb(0.5, 0.0, 0.5)); + let other = d.insert(Some(l), 4, other).unwrap(); + (d, [loose, member, clipped, shadowed, other]) +} + +/// Draws `new` again only where it changed from `old`, over `old` drawn whole, and checks that +/// this is `new` drawn whole; returns what changed. +fn redraw(old: &Document, new: &Document) -> Changed { + let mut r = Renderer::new(); + let opts = RenderOptions::default(); + let before = r.render(old, W, H, view(), &opts); + let after = r.render(new, W, H, view(), &opts); + let change = changed(old, new); + let mut patched = Rendered { width: W, height: H, pixels: before.pixels.clone() }; + match change { + Changed::Everything => patched.pixels = after.pixels.clone(), + Changed::Nothing => {} + Changed::Area(area) => { + if let Some([x0, y0, x1, y1]) = view_pixels(area, view(), W, H) { + let part = r.render(new, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view(), &opts); + for y in 0..y1 - y0 { + let (from, to) = ((y * (x1 - x0) * 4) as usize, (((y0 + y) * W + x0) * 4) as usize); + let n = ((x1 - x0) * 4) as usize; + patched.pixels[to..to + n].copy_from_slice(&part.pixels[from..from + n]); + } + } + } + } + assert!(patched.pixels == after.pixels, "{change:?}: drawn again only there, it differs from drawing it all"); + change +} + +fn area(c: Changed) -> Rect { + match c { + Changed::Area(a) => a, + other => panic!("an area: {other:?}"), + } +} + +#[test] +fn moving_an_object_changes_where_it_was_and_is() { + let (old, [loose, ..]) = document(); + let mut new = old.clone(); + new.node_mut(loose).unwrap().transform(Affine::translate((7.0, 3.0)), false); + let a = area(redraw(&old, &new)); + assert!(a.contains(Point::new(6.0, 6.0)) && a.contains(Point::new(31.0, 22.0)), "before and after: {a:?}"); + assert!(a.x1 < 35.0, "not the objects beside it: {a:?}"); +} + +#[test] +fn recolouring_a_member_of_a_translucent_group_changes_only_the_member() { + let (old, [_, member, ..]) = document(); + let mut new = old.clone(); + new.node_mut(member).unwrap().appearance.fill_mut().unwrap().paint = Paint::solid(Color::rgb(1.0, 0.5, 0.0)); + let a = area(redraw(&old, &new)); + assert!(a.x0 > 35.0, "not the group's other member: {a:?}"); +} + +#[test] +fn moving_clipped_art_changes_its_whole_clip_group() { + let (old, [_, _, clipped, ..]) = document(); + let mut new = old.clone(); + new.node_mut(clipped).unwrap().transform(Affine::translate((0.0, 4.0)), false); + redraw(&old, &new); +} + +#[test] +fn moving_a_shadowed_object_changes_where_its_shadow_falls() { + let (old, [.., shadowed, _]) = document(); + let mut new = old.clone(); + new.node_mut(shadowed).unwrap().transform(Affine::translate((-5.0, 5.0)), false); + let a = area(redraw(&old, &new)); + assert!(a.x1 > 86.0 && a.y1 > 70.0, "the shadow, before and after: {a:?}"); +} + +#[test] +fn adding_removing_and_restacking_change_those_objects() { + let (old, [loose, _, _, _, other]) = document(); + let mut added = old.clone(); + let n = rect(&mut added, Rect::new(70.0, 5.0, 90.0, 15.0), Color::BLACK); + let l = added.layers[0].id; + added.insert(Some(l), 5, n).unwrap(); + redraw(&old, &added); + let mut removed = old.clone(); + removed.remove(loose).unwrap(); + redraw(&old, &removed); + let mut restacked = old.clone(); + let n = restacked.remove(other).unwrap(); + restacked.insert(Some(l), 0, (*n).clone()).unwrap(); + redraw(&old, &restacked); +} + +/// A blur too wide for a raster of all of it is cropped to the frame. A part of the frame drawn +/// again, during a drag too, draws it from that raster: the same pixels, and no preview at the +/// drag's lower resolution. +#[test] +fn a_part_of_a_frame_draws_a_cropped_blur_from_the_frames_raster() { + let (mut d, _) = document(); + let mut back = rect(&mut d, Rect::new(0.0, 0.0, 110.0, 80.0), Color::rgb(0.1, 0.3, 0.9)); + back.appearance.effects.push(vectorcraft_doc::Effect { id: "blur.gaussian".into(), params: serde_json::json!({"radius": 500.0}), visible: true }); + let l = d.layers[0].id; + d.insert(Some(l), 0, back).unwrap(); + let view = Affine::scale(2.0) * Affine::translate((-20.0, -15.0)); + let mut r = Renderer::new(); + let whole = r.render(&d, W, H, view, &RenderOptions::default()); + let [x0, y0, x1, y1] = [40, 30, 100, 80]; + let dragging = RenderOptions { interacting: true, dragging: true, ..Default::default() }; + let part = r.render(&d, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view, &dragging); + assert_eq!(r.stats.previewed, 0, "drawn from the frame's raster"); + for y in 0..y1 - y0 { + for x in 0..x1 - x0 { + assert_eq!(part.pixel(x, y), whole.pixel(x0 + x, y0 + y), "at {x}, {y}"); + } + } +} + +/// Type on a path whose path a script reshapes: its letters stand above the path and run past +/// where it ends, and they change where they were and where they are, outside the path's box. +#[test] +fn reshaping_the_path_of_type_on_a_path_changes_where_its_letters_were_and_are() { + let mut old = Document::new(110.0, 80.0); + let l = old.layers[0].id; + let on = |y: f64| { + let mut t = vectorcraft_doc::text::TextObject::point( + Point::ZERO, + "Text along a path", + vectorcraft_doc::text::CharStyle { size: 14.0, ..Default::default() }, + ); + let line = kurbo::Line::new((10.0, 60.0), (90.0, y)).to_path(0.1); + t.kind = vectorcraft_doc::TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&line), start: 0.0, end: None }; + t.xf = Affine::IDENTITY; + // The engine lays out type that changed ([`vectorcraft_doc::TextObject::cached_bounds`]). + t.cached_bounds = Some(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).bounds); + t + }; + let text = old.insert(Some(l), 0, Node::new(NodeId(0), NodeKind::Text(Box::new(on(55.0))))).unwrap(); + let mut new = old.clone(); + if let Some(NodeKind::Text(t)) = new.node_mut(text).map(|n| &mut n.kind) { + **t = on(25.0); + } + redraw(&old, &new); +} + +#[test] +fn an_unchanged_document_changes_nothing_and_a_new_symbol_everything() { + let (old, _) = document(); + assert_eq!(redraw(&old, &old.clone()), Changed::Nothing); + let mut new = old.clone(); + let art = Arc::new(rect(&mut new, Rect::new(0.0, 0.0, 5.0, 5.0), Color::BLACK)); + new.symbols.push(vectorcraft_doc::Symbol { name: "Dot".into(), art }); + assert_eq!(redraw(&old, &new), Changed::Everything); +} + +/// While a pattern is edited its art also draws in the copies round it: an edit of it changes +/// everything. +#[test] +fn an_edit_while_a_pattern_is_edited_changes_everything() { + let (mut old, [loose, ..]) = document(); + let l = old.layers[0].id; + old.pattern_edit = Some(vectorcraft_doc::PatternEdit { pattern: "Tile".into(), layer: l, original: None }); + let mut new = old.clone(); + new.node_mut(loose).unwrap().transform(Affine::translate((7.0, 3.0)), false); + assert_eq!(changed(&old, &new), Changed::Everything); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index c32645b16..bcfe660cd 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -379,6 +379,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { { upload(app, ui.ctx(), &done.img, done.key.pixel.is_some()); app.canvas.key = Some(done.key); + app.canvas.shown = Some((done.doc, done.opts)); app.perf.render_ms = done.ms; app.canvas.last_ms = done.ms; app.canvas.moved = done.moved > 0; @@ -453,13 +454,30 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let settling = app.canvas.moved && !key.interacting; let staying = key.interacting && app.canvas.from_worker; let heavy = (app.canvas.last_ms > 8.0 || settling || staying) && app.canvas.texture.is_some() && same_doc; - match (&mut app.canvas.worker, heavy) { - (Some(worker), true) => worker.submit(crate::render_worker::Job { key: key.clone(), doc: doc.clone(), w, h, view, opts }), + let changed = changed_pixels(app, &key, &doc, &opts, view, w, h); + match (&mut app.canvas.worker, heavy, changed) { + // Only the document changed since the frame shown, in a part of it: that part is + // rendered again, here, into the texture. `last_ms` stays the whole frame's. + (_, _, Some(region)) => { + let t0 = now_ms(); + if let (Some([x0, y0, x1, y1]), Some(tex)) = (region, &mut app.canvas.texture) { + let part = app.canvas.renderer.render(&doc, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view, &opts); + let color = color_image(&part); + let options = if pixel.is_some() { egui::TextureOptions::NEAREST } else { egui::TextureOptions::LINEAR }; + tex.set_partial([x0 as usize, y0 as usize], color, options); + app.canvas.moved |= app.canvas.renderer.stats.moved > 0; + } + app.canvas.key = Some(key.clone()); + app.canvas.shown = Some((doc.clone(), opts.clone())); + app.perf.render_ms = now_ms() - t0; + } + (Some(worker), true, None) => worker.submit(crate::render_worker::Job { key: key.clone(), doc: doc.clone(), w, h, view, opts }), _ => { let t0 = now_ms(); let img = app.canvas.renderer.render(&doc, w, h, view, &opts); upload(app, ui.ctx(), &img, pixel.is_some()); app.canvas.key = Some(key.clone()); + app.canvas.shown = Some((doc.clone(), opts.clone())); app.perf.render_ms = now_ms() - t0; app.canvas.last_ms = app.perf.render_ms; app.canvas.moved = app.canvas.renderer.stats.moved > 0; @@ -3127,6 +3145,52 @@ pub(crate) fn color_image(img: &vectorcraft_render::Rendered) -> egui::ColorImag egui::ColorImage::new(size, pixels) } +/// The pixels of the `w`×`h` frame through `view` to render again for `doc`, when only the document +/// changed since the frame shown (its key `key` but for the revision, and its render options +/// `opts`), which this thread rendered, and no frame is on its way from the worker: those it +/// changed in (`Some(None)`: none), grown to whole eighths of the frame, as long as they are at +/// most half of it. None: render the whole frame. Frames the worker renders stay +/// whole: its renderer keeps the rasters of the art's effects, this thread's doesn't. +fn changed_pixels( + app: &VectorcraftApp, + key: &CacheKey, + doc: &vectorcraft_doc::Document, + opts: &vectorcraft_render::RenderOptions, + view: Affine, + w: u32, + h: u32, +) -> Option> { + let (shown, _) = app.canvas.shown.as_ref().filter(|(_, o)| o == opts)?; + // A viewed opacity mask shows its coverage wherever its art reaches, past its object's bounds. + if opts.mask_view.is_some() { + return None; + } + let same_view = app.canvas.key.as_ref().is_some_and(|k| *k == CacheKey { revision: k.revision, ..key.clone() }); + if !same_view || app.canvas.texture.is_none() || app.canvas.from_worker || app.canvas.worker.as_ref().is_some_and(|w| w.pending.is_some()) { + return None; + } + match vectorcraft_render::changed(shown, doc) { + vectorcraft_render::Changed::Nothing => Some(None), + vectorcraft_render::Changed::Area(area) => match vectorcraft_render::view_pixels(area, view, w, h) { + None => Some(None), + Some([x0, y0, x1, y1]) => { + let ((x0, x1), (y0, y1)) = (in_steps(x0, x1, w), in_steps(y0, y1, h)); + (u64::from(x1 - x0) * u64::from(y1 - y0) * 2 <= u64::from(w) * u64::from(h)).then_some(Some([x0, y0, x1, y1])) + } + }, + vectorcraft_render::Changed::Everything => None, + } +} + +/// `a..b` of `0..n`, grown to whole eighths of `n` and moved back into `0..n`: the parts of a +/// frame rendered again come in few sizes, which keep the contexts the renderer keeps. +fn in_steps(a: u32, b: u32, n: u32) -> (u32, u32) { + let step = n.div_ceil(8).max(1); + let len = (b - a).max(1).div_ceil(step).saturating_mul(step).min(n); + let a = a.min(n - len); + (a, a + len) +} + /// Upload the art's raster; Pixel Preview's document pixels keep hard edges when magnified. fn upload(app: &mut VectorcraftApp, ctx: &egui::Context, img: &vectorcraft_render::Rendered, pixel: bool) { let color = color_image(img); @@ -4691,6 +4755,69 @@ mod tests { (v, out.textures_delta) } + /// An edit of one object renders the canvas again only where it changed, into its texture; a + /// change of the view renders all of it. + #[test] + fn an_edit_renders_only_its_part_of_the_canvas() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width":400,"height":300})).unwrap(); + app.ui.task_bar = false; + app.ui.view.rulers = false; + app.canvas.worker_started = true; // every frame on this thread, however long it takes + let id = app.run("shape.rectangle", json!({"x":50,"y":50,"width":40,"height":30})).unwrap()["id"].as_u64().unwrap(); + app.run("shape.rectangle", json!({"x":250,"y":200,"width":40,"height":30})).unwrap(); + app.run("select.none", json!({})).unwrap(); + let ctx = egui::Context::default(); + // Where the canvas texture changed in a frame, and how much of it. + let canvas = |app: &VectorcraftApp, mut delta: egui::TexturesDelta| { + let tex = app.canvas.texture.as_ref().map(|t| t.id()); + let set = delta.set.iter().find(|(t, _)| tex == Some(**t)).and_then(|(_, d)| d.last()).map(|d| (d.pos, d.image.size())); + delta.clear(); + set + }; + let (_, delta) = frame_output(&mut app, &ctx); + assert!(canvas(&app, delta).is_some_and(|(pos, _)| pos.is_none()), "the first frame renders all of it"); + app.run("paint.setFill", json!({"color": "#ff0000", "ids": [id]})).unwrap(); + let (_, delta) = frame_output(&mut app, &ctx); + let (pos, size) = canvas(&app, delta).expect("the edit shows"); + let whole = app.canvas.key.as_ref().map(|k| (k.w as usize, k.h as usize)).unwrap(); + assert!(pos.is_some() && size[0] * size[1] * 10 < whole.0 * whole.1, "a small part of it: {pos:?} {size:?} of {whole:?}"); + // An edit with other render options than the frame shown's renders all of it. + app.session.prefs.pattern_tile_edge_color = "#ff00ff".into(); + app.run("paint.setFill", json!({"color": "#00ff00", "ids": [id]})).unwrap(); + let (_, delta) = frame_output(&mut app, &ctx); + assert!(canvas(&app, delta).is_some_and(|(pos, _)| pos.is_none()), "other options render all of it"); + app.run("view.setZoom", json!({"zoom": 2})).unwrap(); + let (_, delta) = frame_output(&mut app, &ctx); + assert!(canvas(&app, delta).is_some_and(|(pos, _)| pos.is_none()), "a new view renders all of it"); + } + + /// While an opacity mask is viewed, an edit renders all of the canvas: the mask view shows the + /// mask's coverage wherever its art reaches, past its object's bounds. + #[test] + fn an_edit_while_a_mask_is_viewed_renders_all_of_the_canvas() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width":400,"height":300})).unwrap(); + app.ui.task_bar = false; + app.ui.view.rulers = false; + app.canvas.worker_started = true; // every frame on this thread, however long it takes + let rect = + |app: &mut VectorcraftApp, x: f64| app.run("shape.rectangle", json!({"x": x, "y": 50, "width": 60, "height": 40})).unwrap()["id"].clone(); + let (obj, mask) = (rect(&mut app, 50.0), rect(&mut app, 70.0)); + app.run("select.set", json!({"ids": [obj, mask]})).unwrap(); + app.run("transparency.makeOpacityMask", json!({})).unwrap(); + app.run("transparency.viewOpacityMask", json!({})).unwrap(); + let ctx = egui::Context::default(); + let (_, mut delta) = frame_output(&mut app, &ctx); + delta.clear(); + app.run("paint.setFill", json!({"color": "#ff0000", "ids": [obj]})).unwrap(); + let (_, mut delta) = frame_output(&mut app, &ctx); + let tex = app.canvas.texture.as_ref().map(|t| t.id()); + let pos = delta.set.iter().find(|(t, _)| tex == Some(**t)).and_then(|(_, d)| d.last()).map(|d| d.pos); + delta.clear(); + assert_eq!(pos, Some(None), "all of it"); + } + /// #1278: a segment click marks the edge, not its endpoint anchors or neighboring handles. #[test] fn a_segment_pick_has_hollow_anchors_and_only_its_own_handles() { @@ -5298,7 +5425,7 @@ mod tests { // Deliver the actual replacement and verify that it replaces the cached key. let img = app.canvas.renderer.render(&job.doc, job.w, job.h, job.view, &job.opts); let expected = job.key.clone(); - completed.send(crate::render_worker::Done { key: job.key, img, ms: 1.0, moved: 0 }).unwrap(); + completed.send(crate::render_worker::Done { key: job.key, doc: job.doc, opts: job.opts, img, ms: 1.0, moved: 0 }).unwrap(); frame(&mut app, size, new_ppp); assert_eq!(app.canvas.key.as_ref(), Some(&expected), "the worker's frame arrived"); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c4a376e75..4f2cd7ecb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -321,6 +321,10 @@ pub struct CanvasCache { pub renderer: vectorcraft_render::Renderer, pub texture: Option, pub key: Option, + /// The document the frame shown was rendered from, and the options it was rendered with, with + /// [`Self::key`]: when only the document changes, the canvas renders again only where it + /// changed ([`vectorcraft_render::changed`]). + pub shown: Option<(std::sync::Arc, vectorcraft_render::RenderOptions)>, pub last_ms: f64, /// The frame shown drew raster effects from rasters made before their objects moved, or at a /// drag's lower resolution ([`vectorcraft_render::FrameStats::moved`]): the next frame outside @@ -565,6 +569,7 @@ impl VectorcraftApp { renderer: vectorcraft_render::Renderer::new(), texture: None, key: None, + shown: None, last_ms: 0.0, moved: false, from_worker: false, diff --git a/crates/ui-egui/src/render_worker.rs b/crates/ui-egui/src/render_worker.rs index 4dcf879b8..704c9058a 100644 --- a/crates/ui-egui/src/render_worker.rs +++ b/crates/ui-egui/src/render_worker.rs @@ -22,6 +22,9 @@ pub struct Job { pub struct Done { pub key: CacheKey, + /// The document rendered, and the options it was rendered with. + pub doc: Arc, + pub opts: RenderOptions, pub img: Rendered, pub ms: f64, /// [`vectorcraft_render::FrameStats::moved`] of the frame. @@ -53,7 +56,8 @@ impl Worker { } let t0 = crate::now_ms(); let img = r.render(&job.doc, job.w, job.h, job.view, &job.opts); - if done_tx.send(Done { key: job.key, img, ms: crate::now_ms() - t0, moved: r.stats.moved }).is_err() { + if done_tx.send(Done { key: job.key, doc: job.doc, opts: job.opts, img, ms: crate::now_ms() - t0, moved: r.stats.moved }).is_err() + { break; } ctx.request_repaint();