From ded7c7ad04989b2b5ff76339ad6845d4ae462c8a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 00:54:31 -0400 Subject: [PATCH] =?UTF-8?q?M9:=20damage-region=20repaint=20=E2=80=94=20re-?= =?UTF-8?q?render=20only=20changed=20frames=20and=20patch=20the=20canvas?= =?UTF-8?q?=20texture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - render: damage()/damage_with() — changed items and only the frames whose composition changed (cache keeps the composition each edit replaced; Cache::composed_for matches a document version exactly); conservative full repaint for styles, swatches, layers, parents, wrap, effects and cross-story text (+ tests incl. pixel-identical patch vs full render) - UI: canvas patches its texture after edits with the view unchanged (0.6 ms vs 22 ms on the sample when typing); perf harness row for the damage repaint Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + apps/designcraft-cli/src/perf.rs | 32 +++- crates/compose/src/cache.rs | 68 +++++++- crates/render/src/damage.rs | 285 +++++++++++++++++++++++++++++++ crates/render/src/lib.rs | 1 + crates/ui-egui/src/canvas.rs | 70 ++++++++ crates/ui-egui/src/control.rs | 2 +- crates/ui-egui/src/lib.rs | 12 ++ 8 files changed, 468 insertions(+), 3 deletions(-) create mode 100644 crates/render/src/damage.rs diff --git a/ROADMAP.md b/ROADMAP.md index 4205ec896..a927a0dee 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -25,6 +25,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Ruler guides: drag out of the rulers (page guides, or spread guides on the pasteboard), drag to move, drop on a ruler to delete; Layout › Create Guides (rows/columns with gutters, fit to margins or page); Delete All Guides on Spread; `guide.*` commands. - Links panel: every placed graphic with status (OK / Modified / Missing / Embedded, checked against the file on disk), page and effective PPI (low resolution flagged); Relink…, Go To, Update, Embed; `links.*` commands. - Missing fonts: highlighted pink on screen (never in output), listed by Preflight; Type › Find/Replace Font (fonts in the document, missing first; replace in text, cells, footnotes and styles); `font.list` / `font.replace`. +- Damage-region repaint: after an edit the canvas re-renders only the frames whose composed text (or the items that) changed and patches its texture — pixel-identical to a full render; typing in a frame repaints in under a millisecond instead of a full-viewport render (22 ms measured on the sample). - Menus expose the engine's features (TOC, numbering & sections, text variables, hyperlinks, cross-references, footnotes, step and repeat, spelling, fitting/content, effects, Data Merge, Preflight, EPUB); any menu command with parameters gets a dialog generated from its parameter documentation. - Table of contents (generate/update, dot leaders) and text variables (running headers, last page number, chapter number, file name, dates, custom) resolved per page. - Soft effects: blurred drop shadow, inner shadow, outer glow, basic feather. diff --git a/apps/designcraft-cli/src/perf.rs b/apps/designcraft-cli/src/perf.rs index 7a3939344..083cd6dc9 100644 --- a/apps/designcraft-cli/src/perf.rs +++ b/apps/designcraft-cli/src/perf.rs @@ -421,7 +421,37 @@ fn measure(doc: &Document, runs: usize) -> Vec { } drop(r.render(&d, &cache, &all, W, H, v, &greek(HIDPI))); }); - row(&mut rows, "keystroke: recompose story + repaint at 100%", ms, Some(8.0)); + row(&mut rows, "keystroke: recompose story + full repaint at 100%", ms, None); + // What the canvas does: repaint only the damaged region (designcraft_render::damage). + let ms = median_ms(runs, || { + let old = d.clone(); + if let Some(st) = d.stories.get_mut(&sid) { + let st = Arc::make_mut(st); + let at = (st.text.len() / 2 + k).min(st.text.len()); + let at = (0..=at).rev().find(|i| st.text.is_char_boundary(*i)).unwrap_or(0); + st.text.insert(at, 'x'); + if let Some(run) = st.chars.iter_mut().find(|r| r.len > 0) { + run.len += 1; + } + st.rev += 1; + k += 1; + } + let screen = designcraft_geom::Rect::new(0.0, 0.0, W as f64, H as f64); + let mut px: Option = None; + for (sr, rect) in designcraft_render::damage::damage_with(&old, &d, Some(&cache)).unwrap_or_default() { + let off = all.iter().find(|p| p.spread == sr).map_or(Vec2::ZERO, |p| p.offset); + let r = v.transform_rect_bbox(rect + off).intersect(screen); + if r.width() > 0.0 && r.height() > 0.0 { + px = Some(px.map_or(r, |p| p.union(r))); + } + } + if let Some(p) = px { + let (x0, y0) = (p.x0.floor(), p.y0.floor()); + let (w, h) = ((p.x1.ceil() - x0) as u32, (p.y1.ceil() - y0) as u32); + drop(r.render(&d, &cache, &all, w.max(1), h.max(1), Affine::translate((-x0, -y0)) * v, &greek(HIDPI))); + } + }); + row(&mut rows, "keystroke: recompose story + damage repaint at 100%", ms, Some(8.0)); } let opts = RenderOptions { printing_only: true, ..Default::default() }; diff --git a/crates/compose/src/cache.rs b/crates/compose/src/cache.rs index 408724913..547bf8a7c 100644 --- a/crates/compose/src/cache.rs +++ b/crates/compose/src/cache.rs @@ -29,6 +29,9 @@ pub struct Cache { running: Mutex)>>, /// Cross-reference anchor pages with the document signature they were built for. xrefs: Mutex)>>, + /// The composition each story (page-independent key) had before its latest recomposition, + /// with the story revision it was composed from (repaint only the frames an edit changed). + previous: Mutex, Arc)>>, } /// Identity of everything a running header can depend on (stories, spreads, styles, variables). @@ -112,7 +115,70 @@ impl Cache { if g.0.len() > 4096 { g.0.retain(|_, e| stamp - e.stamp < 64); } - g.0.insert(key, Entry { sig, _keep: (story.clone(), doc.styles.clone(), keep_items), out: out.clone(), stamp }); + let replaced = g.0.insert(key.clone(), Entry { sig, _keep: (story.clone(), doc.styles.clone(), keep_items), out: out.clone(), stamp }); + drop(g); + if key.1.is_none() + && let Some(old) = replaced + { + self.previous.lock().unwrap_or_else(|e| e.into_inner()).insert(sid, (old.sig, old.out)); + } + out + } + + /// The cached composition of `sid` made from exactly this version of `doc` (the current + /// entry or the one it replaced), without composing anything. + pub fn composed_for(&self, doc: &Document, sid: StoryId) -> Option> { + let story = doc.stories.get(&sid)?; + let (sig, _) = signature(doc, story); + // Only the geometry/story part of the signature (variables etc. add to it). + let matches = |s: &[usize]| s.len() >= sig.len() && s[..sig.len()] == sig[..]; + { + let g = self.map.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(e) = g.0.get(&(sid, None)) + && matches(&e.sig) + { + return Some(e.out.clone()); + } + } + let g = self.previous.lock().unwrap_or_else(|e| e.into_inner()); + g.get(&sid).filter(|(s, _)| matches(s)).map(|(_, c)| c.clone()) + } + + /// Frames whose composed content differs between two compositions of a story. + pub fn changed_frames(a: &ComposedStory, b: &ComposedStory) -> Vec { + let same_line = |x: &crate::Line, y: &crate::Line| { + x.range == y.range + && (x.baseline - y.baseline).abs() < 1e-9 + && (x.x0 - y.x0).abs() < 1e-9 + && (x.end_x - y.end_x).abs() < 1e-9 + && x.glyphs.len() == y.glyphs.len() + && x.glyphs + .iter() + .zip(&y.glyphs) + .all(|(g, h)| g.gid == h.gid && g.style == h.style && (g.x - h.x).abs() < 1e-9 && (g.y - h.y).abs() < 1e-9) + }; + let mut out = Vec::new(); + for fb in &b.frames { + let same = a.frame(fb.frame).is_some_and(|fa| { + fa.lines.len() == fb.lines.len() + && fa.lines.iter().zip(&fb.lines).all(|(x, y)| same_line(x, y)) + && fa.decos == fb.decos + && fa.tables.is_empty() + && fb.tables.is_empty() + && fa.notes.is_empty() + && fb.notes.is_empty() + && fa.objects.is_empty() + && fb.objects.is_empty() + }) && a.styles == b.styles; + if !same { + out.push(fb.frame); + } + } + for fa in &a.frames { + if b.frame(fa.frame).is_none() { + out.push(fa.frame); + } + } out } diff --git a/crates/render/src/damage.rs b/crates/render/src/damage.rs new file mode 100644 index 000000000..ccb674ae5 --- /dev/null +++ b/crates/render/src/damage.rs @@ -0,0 +1,285 @@ +//! Damage: which parts of the spreads changed between two versions of a document, so a view can +//! repaint just those regions (typing in a frame repaints the frame, not the window). +//! +//! Conservative: anything whose effect can reach beyond the changed items' own bounds (styles, +//! swatches, layers, parent pages, text wrap, effects, text that other stories show through +//! cross-references, variables or footnote numbers) reports "everything" (`None`). + +use std::collections::HashMap; +use std::sync::Arc; + +use designcraft_doc::{Document, Item, ItemId, SpreadRef, Story, StoryId}; +use designcraft_geom::Rect; + +/// Changed regions in spread coordinates, or `None` when the whole view must be repainted. +pub fn damage(old: &Document, new: &Document) -> Option> { + damage_with(old, new, None) +} + +/// [`damage`] using the composition cache: of a changed story, only the frames whose composed +/// text actually changed are damaged (typing on page 40 of a chapter repaints page 40). +pub fn damage_with(old: &Document, new: &Document, cache: Option<&designcraft_compose::Cache>) -> Option> { + if std::ptr::eq(old, new) { + return Some(vec![]); + } + if !Arc::ptr_eq(&old.styles, &new.styles) + || old.swatches != new.swatches + || old.layers != new.layers + || old.settings != new.settings + || old.sections != new.sections + || old.text_variables != new.text_variables + || old.footnote_options != new.footnote_options + || old.xref_formats != new.xref_formats + || old.spreads.len() != new.spreads.len() + || old.parents.len() != new.parents.len() + || old.parents.iter().zip(&new.parents).any(|(a, b)| !Arc::ptr_eq(a, b)) + { + return None; + } + let mut out: Vec<(SpreadRef, Rect)> = Vec::new(); + // Stories whose text or frames changed (a resized frame reflows the rest of its thread). + let mut stories: std::collections::BTreeSet = std::collections::BTreeSet::new(); + // Items. + for (si, (a, b)) in old.spreads.iter().zip(&new.spreads).enumerate() { + if Arc::ptr_eq(a, b) { + continue; + } + if a.pages != b.pages { + return None; + } + let olds: HashMap> = a.items.iter().map(|i| (i.id, i)).collect(); + let news: HashMap> = b.items.iter().map(|i| (i.id, i)).collect(); + // Stacking order changes affect overlaps: repaint the moved items' areas too. + for (id, it) in &news { + match olds.get(id) { + Some(o) if Arc::ptr_eq(o, it) => {} + Some(o) => { + out.push((SpreadRef::Doc(si), item_area(o)?)); + out.push((SpreadRef::Doc(si), item_area(it)?)); + stories.extend(text_stories(o).into_iter().chain(text_stories(it))); + } + None => { + out.push((SpreadRef::Doc(si), item_area(it)?)); + stories.extend(text_stories(it)); + } + } + } + for (id, o) in &olds { + if !news.contains_key(id) { + out.push((SpreadRef::Doc(si), item_area(o)?)); + stories.extend(text_stories(o)); + } + } + let order = |v: &[Arc]| v.iter().map(|i| i.id).filter(|id| olds.contains_key(id) && news.contains_key(id)).collect::>(); + if order(&a.items) != order(&b.items) { + // Reordered: repaint every item whose rank changed (cheap to over-approximate). + for it in b.items.iter() { + out.push((SpreadRef::Doc(si), item_area(it)?)); + } + } + } + // Stories. + let ids: std::collections::BTreeSet = old.stories.keys().chain(new.stories.keys()).copied().collect(); + let mut changed_text = false; + for sid in ids { + let (a, b) = (old.stories.get(&sid), new.stories.get(&sid)); + let edited = !matches!((a, b), (Some(a), Some(b)) if Arc::ptr_eq(a, b)); + if !edited && !stories.contains(&sid) { + continue; + } + changed_text |= edited; + // Footnote numbers continue across stories. + if a.map_or(0, |s| s.notes.len()) != b.map_or(0, |s| s.notes.len()) { + return None; + } + // Only the frames whose composition changed, when the previous composition is known. + if let (Some(c), Some(_), Some(_)) = (cache, a, b) + && let Some(prev) = c.composed_for(old, sid) + { + let cur = c.get(new, sid, None); + let changed = designcraft_compose::Cache::changed_frames(&prev, &cur); + for f in changed { + for d in [old, new] { + if d.find(f).is_some() { + out.push(frame_area(d, f)?); + } + } + } + continue; + } + for (d, st) in [(old, a), (new, b)] { + let Some(st) = st else { continue }; + for f in &st.frames { + let (r, area) = frame_area(d, *f)?; + out.push((r, area)); + } + } + } + // Other stories can show this text (cross-references, running headers) or its pages. + if changed_text && new.stories.values().any(|s| shows_other_text(new, s)) { + return None; + } + // Parent-page frames aren't in `out` (parents are compared above); anything on a parent + // spread already returned None. + if out.iter().any(|(r, _)| !matches!(r, SpreadRef::Doc(_))) { + return None; + } + Some(out) +} + +/// Stories of the text frames in an item (groups included). +fn text_stories(it: &Item) -> Vec { + let mut v = Vec::new(); + it.walk(&mut |i| { + if let Some(t) = i.text_frame() { + v.push(t.story); + } + }); + v +} + +/// Does the story show text generated from other stories or pages? +fn shows_other_text(d: &Document, s: &Story) -> bool { + !s.xrefs.is_empty() || s.text.chars().any(|c| designcraft_doc::vars::var_index(c).is_some()) || d.toc.as_ref().is_some_and(|t| t.story == s.id) +} + +/// Area an item paints (spread coordinates), or None when it can't be bounded cheaply. +fn item_area(it: &Item) -> Option { + let mut ok = true; + it.walk(&mut |i| { + // Effects spill outside; text wrap reflows other frames. + if i.effects.any() || i.wrap.mode != designcraft_doc::WrapMode::None { + ok = false; + } + }); + if !ok { + return None; + } + let pad = it.stroke.weight.max(0.0) + 2.0 + if it.is_text_frame() { 24.0 } else { 0.0 }; + Some(it.bounds().inflate(pad, pad)) +} + +/// A text frame's area: its spread and bounds with room for glyph overhang. +fn frame_area(d: &Document, f: ItemId) -> Option<(SpreadRef, Rect)> { + let loc = d.find(f)?; + let it = d.item_at(&loc)?; + let b = d.parent_xf(&loc).transform_rect_bbox(it.bounds()); + // Overhang of italics, swashes and big initials, and anchored objects pinned to lines. + Some((loc.spread, b.inflate(24.0 + 0.1 * b.width().max(b.height()), 24.0))) +} + +#[cfg(test)] +mod tests { + use super::*; + use designcraft_doc::ParaFormat; + use designcraft_doc::build::NewDocument; + + #[test] + fn typing_damages_only_the_frame() { + let mut d = Document::new(&NewDocument { pages: 2, ..Default::default() }); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(72.0, 72.0, 300.0, 200.0), lid, "Hello", ParaFormat::default()).unwrap(); + let (_, _) = d.add_text_frame(SpreadRef::Doc(1), Rect::new(72.0, 72.0, 300.0, 200.0), lid, "Other", ParaFormat::default()).unwrap(); + let old = d.clone(); + d.story_mut(sid).unwrap().insert(5, " world"); + let dmg = damage(&old, &d).unwrap(); + assert!(!dmg.is_empty()); + assert!(dmg.iter().all(|(r, rect)| *r == SpreadRef::Doc(0) && rect.contains(designcraft_geom::Point::new(100.0, 100.0)))); + // Nothing changed. + assert_eq!(damage(&d, &d.clone()).unwrap().len(), 0); + // A style change repaints everything. + let old = d.clone(); + d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle { + name: "X".into(), + based_on: None, + next_style: None, + para: Default::default(), + chars: Default::default(), + shortcut: String::new(), + }); + assert!(damage(&old, &d).is_none()); + // Moving an item damages where it was and where it is. + let old = d.clone(); + let id = d.spreads[0].items[0].id; + d.item_mut(id).unwrap().xf = designcraft_geom::Affine::translate((100.0, 0.0)); + let dmg = damage(&old, &d).unwrap(); + assert!(dmg.iter().any(|(_, r)| r.contains(designcraft_geom::Point::new(100.0, 100.0)))); + assert!(dmg.iter().any(|(_, r)| r.contains(designcraft_geom::Point::new(350.0, 100.0)))); + } + + #[test] + fn patched_render_equals_full_render() { + use crate::{Placed, RenderOptions, Renderer}; + use designcraft_geom::{Affine, Vec2}; + let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() }); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(72.0, 72.0, 300.0, 200.0), lid, "Hello there", ParaFormat::default()).unwrap(); + let cache = designcraft_compose::Cache::new(); + let placed = [Placed { spread: SpreadRef::Doc(0), offset: Vec2::ZERO }]; + let (w, h) = (400u32, 300u32); + let view = Affine::scale(1.3); + let mut r = Renderer::new(); + r.threads = 0; + let opts = RenderOptions::default(); + let old = d.clone(); + let mut img = r.render(&old, &cache, &placed, w, h, view, &opts); + d.story_mut(sid).unwrap().insert(5, ", typed"); + let full = r.render(&d, &cache, &placed, w, h, view, &opts); + // Patch the damaged pixels. + let regions = damage(&old, &d).unwrap(); + let u = regions.iter().map(|(_, r)| *r).reduce(|a, b| a.union(b)).unwrap(); + let x0 = ((u.x0 * 1.3).floor().max(0.0)) as u32; + let y0 = ((u.y0 * 1.3).floor().max(0.0)) as u32; + let x1 = ((u.x1 * 1.3).ceil() as u32).min(w); + let y1 = ((u.y1 * 1.3).ceil() as u32).min(h); + let patch = r.render(&d, &cache, &placed, x1 - x0, y1 - y0, Affine::translate((-(x0 as f64), -(y0 as f64))) * view, &opts); + for y in y0..y1 { + for x in x0..x1 { + let pi = (((y - y0) * (x1 - x0) + (x - x0)) * 4) as usize; + let ii = ((y * w + x) * 4) as usize; + img.pixels[ii..ii + 4].copy_from_slice(&patch.pixels[pi..pi + 4]); + } + } + let diff = img.pixels.iter().zip(&full.pixels).filter(|(a, b)| a.abs_diff(**b) > 2).count(); + assert_eq!(diff, 0, "patched image differs from the full render in {diff} channels"); + } + + #[test] + fn only_changed_frames_of_a_thread_are_damaged() { + let mut d = Document::new(&NewDocument { pages: 3, facing_pages: false, ..Default::default() }); + let lid = d.default_layer(); + let text = "Lorem ipsum dolor sit amet. ".repeat(40); + let (f1, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(72.0, 72.0, 300.0, 300.0), lid, &text, ParaFormat::default()).unwrap(); + let (f2, _) = d.add_text_frame(SpreadRef::Doc(1), Rect::new(72.0, 72.0, 300.0, 300.0), lid, "", ParaFormat::default()).unwrap(); + let (f3, _) = d.add_text_frame(SpreadRef::Doc(2), Rect::new(72.0, 72.0, 300.0, 300.0), lid, "", ParaFormat::default()).unwrap(); + d.thread(f1, f2).unwrap(); + d.thread(f2, f3).unwrap(); + let cache = designcraft_compose::Cache::new(); + let _ = cache.get(&d, sid, None); + // Change a word in the last frame's text (same length): only that frame changes. + let old = d.clone(); + let st = d.story_mut(sid).unwrap(); + let at = st.text.len() - 10; + st.replace(at..at + 5, "XXXXX"); + let dmg = damage_with(&old, &d, Some(&cache)).unwrap(); + let spreads: Vec = dmg.iter().map(|(r, _)| *r).collect(); + assert!(!spreads.contains(&SpreadRef::Doc(0)), "first frame untouched: {dmg:?}"); + assert!(!dmg.is_empty()); + // Without the cache every frame of the thread is damaged. + let mut all: Vec = Vec::new(); + for (r, _) in damage(&old, &d).unwrap() { + if !all.contains(&r) { + all.push(r); + } + } + assert_eq!(all.len(), 3); + // Resizing the first frame reflows the thread: later frames are damaged too. + let old = d.clone(); + let _ = cache.get(&d, sid, None); + let it = d.item_mut(f1).unwrap(); + it.path = designcraft_geom::shapes::rectangle(Rect::new(72.0, 72.0, 300.0, 150.0)); + let dmg = damage_with(&old, &d, Some(&cache)).unwrap(); + let spreads: Vec = dmg.iter().map(|(r, _)| *r).collect(); + assert!(spreads.contains(&SpreadRef::Doc(1)), "{dmg:?}"); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 8d2b738b1..8ae7dcf7e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -22,6 +22,7 @@ use vello_cpu::{Pixmap, RenderContext, Resources}; pub use vello_cpu; +pub mod damage; mod fx; pub mod images; mod text; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d0e0c8881..ba147d8ff 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -291,6 +291,7 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, { upload(app, ctx, done.image); app.canvas.shown = Some(shown); + app.canvas.shown_doc = app.canvas.pending_doc.take(); app.canvas.pending = None; app.perf.render_ms = done.ms; } @@ -324,12 +325,17 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, let has_worker = app.canvas.worker.is_some(); #[cfg(target_arch = "wasm32")] let has_worker = false; + // An edit with the view unchanged: repaint just the regions it changed. + if doc_changed && zoom_same && patch_texture(app, layout, ppp, doc_key, preview) { + return; + } if !has_worker || app.canvas.shown.is_none() || (doc_changed && zoom_same) { let t0 = crate::now_ms(); let (doc, placed, w, h, view, opts) = job(app); let img = app.canvas.renderer.render(&doc, &app.session.cache, &placed, w, h, view, &opts); upload(app, ctx, img); app.canvas.shown = Some(target); + app.canvas.shown_doc = Some(doc); app.canvas.pending = None; app.perf.render_ms = crate::now_ms() - t0; return; @@ -347,6 +353,7 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, return; } let (doc, placed, w, h, view, opts) = job(app); + app.canvas.pending_doc = Some(doc.clone()); app.canvas.token += 1; let token = app.canvas.token; if let Some(wk) = app.canvas.worker.as_ref() { @@ -356,6 +363,69 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, } } +/// Repaint only the damaged regions of the current texture after an edit (typing, nudging). +/// Returns false when a full render is needed (view moved, too much changed, or damage unknown). +fn patch_texture(app: &mut DesignApp, layout: &CanvasLayout, ppp: f64, doc_key: (u64, u64, u64, bool), preview: bool) -> bool { + let (Some(sh), Some(old), Some(st)) = (app.canvas.shown, app.canvas.shown_doc.clone(), app.session.active()) else { return false }; + if sh.doc.0 != doc_key.0 || sh.doc.3 != doc_key.3 || app.canvas.texture.is_none() { + return false; + } + let new = st.doc.clone(); + let Some(regions) = designcraft_render::damage::damage_with(&old, &new, Some(&app.session.cache)) else { return false }; + let t0 = crate::now_ms(); + let (tw, th) = ((sh.size.0 as f64 * ppp).round() as i64, (sh.size.1 as f64 * ppp).round() as i64); + let k = sh.zoom * ppp; + let mut px: Option<(i64, i64, i64, i64)> = None; + for (r, rect) in ®ions { + let c = *rect + layout.offset(*r); + let x0 = ((c.x0 - sh.origin.x) * k).floor() as i64 - 1; + let y0 = ((c.y0 - sh.origin.y) * k).floor() as i64 - 1; + let x1 = ((c.x1 - sh.origin.x) * k).ceil() as i64 + 1; + let y1 = ((c.y1 - sh.origin.y) * k).ceil() as i64 + 1; + px = Some(px.map_or((x0, y0, x1, y1), |p| (p.0.min(x0), p.1.min(y0), p.2.max(x1), p.3.max(y1)))); + } + let done = |app: &mut DesignApp| { + app.canvas.shown = Some(crate::Shown { doc: doc_key, ..sh }); + app.canvas.shown_doc = Some(new.clone()); + // A background render of the old document must not overwrite this. + app.canvas.pending = None; + app.canvas.pending_doc = None; + }; + let Some((x0, y0, x1, y1)) = px.map(|p| (p.0.max(0), p.1.max(0), p.2.min(tw), p.3.min(th))) else { + done(app); + return true; + }; + if x1 <= x0 || y1 <= y0 { + done(app); + return true; + } + // Big changes: a full render costs about the same. + if ((x1 - x0) * (y1 - y0)) as f64 > 0.5 * (tw * th) as f64 { + return false; + } + let placed: Vec = + layout.slots.iter().map(|s| designcraft_render::Placed { spread: s.spread, offset: s.offset }).collect(); + let view = Affine::translate((-(x0 as f64), -(y0 as f64))) + * Affine::scale(ppp) + * Affine::scale(sh.zoom) + * Affine::translate((-sh.origin.x, -sh.origin.y)); + let opts = designcraft_render::RenderOptions { + printing_only: preview, + greek_below_px: 3.0 * ppp, + highlight_missing_fonts: !preview, + ..Default::default() + }; + let img = app.canvas.patcher.render(&new, &app.session.cache, &placed, (x1 - x0) as u32, (y1 - y0) as u32, view, &opts); + let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); + if let Some(tex) = app.canvas.texture.as_mut() { + tex.set_partial([x0 as usize, y0 as usize], ci, egui::TextureOptions::LINEAR); + } + done(app); + app.canvas.patches += 1; + app.perf.render_ms = crate::now_ms() - t0; + true +} + #[cfg(not(target_arch = "wasm32"))] fn render_worker_spawn(ctx: &egui::Context) -> Option { if std::env::var_os("DESIGNCRAFT_SYNC_RENDER").is_some() { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 09f9441b5..73ff5e7a5 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -79,7 +79,7 @@ pub fn inspect(app: &DesignApp, ctx: &egui::Context) -> Value { "currentPage": crate::canvas::current_page(app), "dialog": app.ui.dialog.as_ref().map(|d| serde_json::to_value(d).unwrap_or_default()), "activeDocument": app.session.active_index(), - "perf": {"frameMs": app.perf.frame_ms, "renderMs": app.perf.render_ms, "fps": app.perf.fps}, + "perf": {"frameMs": app.perf.frame_ms, "renderMs": app.perf.render_ms, "fps": app.perf.fps, "patches": app.canvas.patches}, }) } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0f923bb4d..1b470524f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -188,6 +188,14 @@ pub struct CanvasCache { pub worker: Option, pub worker_started: bool, pub last_ms: f64, + /// The document the texture shows (to repaint only what an edit changed). + pub shown_doc: Option>, + /// Document of the request in flight. + pub pending_doc: Option>, + /// Renders damaged regions (its own context: region sizes vary). + pub patcher: designcraft_render::Renderer, + /// Partial repaints / full renders since start (perf readout). + pub patches: u64, } #[derive(Default, Clone, Copy, Debug, Serialize)] @@ -242,6 +250,10 @@ impl DesignApp { worker: None, worker_started: false, last_ms: 0.0, + shown_doc: None, + pending_doc: None, + patcher: designcraft_render::Renderer::new(), + patches: 0, }, canvas_rect: None, perf: Perf::default(),