M9: damage-region repaint — re-render only changed frames and patch the canvas texture

- 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 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-02 00:54:31 -04:00
1 parent 69563933be
commit ded7c7ad04
8 files changed
+468 -3

No files matched your search

+1
View File
@@ -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.
+31 -1
View File
@@ -421,7 +421,37 @@ fn measure(doc: &Document, runs: usize) -> Vec<Row> {
}
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<designcraft_geom::Rect> = 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() };
+67 -1
View File
@@ -29,6 +29,9 @@ pub struct Cache {
running: Mutex<Option<(u64, Arc<RunningIndex>)>>,
/// Cross-reference anchor pages with the document signature they were built for.
xrefs: Mutex<Option<(u64, Arc<crate::xref::XrefIndex>)>>,
/// 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<HashMap<StoryId, (Vec<usize>, Arc<ComposedStory>)>>,
}
/// 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<Arc<ComposedStory>> {
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<designcraft_doc::ItemId> {
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
}
+285
View File
@@ -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<Vec<(SpreadRef, Rect)>> {
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<Vec<(SpreadRef, Rect)>> {
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<StoryId> = 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<ItemId, &Arc<Item>> = a.items.iter().map(|i| (i.id, i)).collect();
let news: HashMap<ItemId, &Arc<Item>> = 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<Item>]| v.iter().map(|i| i.id).filter(|id| olds.contains_key(id) && news.contains_key(id)).collect::<Vec<_>>();
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<StoryId> = 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<StoryId> {
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<Rect> {
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<SpreadRef> = 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<SpreadRef> = 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<SpreadRef> = dmg.iter().map(|(r, _)| *r).collect();
assert!(spreads.contains(&SpreadRef::Doc(1)), "{dmg:?}");
}
}
+1
View File
@@ -22,6 +22,7 @@ use vello_cpu::{Pixmap, RenderContext, Resources};
pub use vello_cpu;
pub mod damage;
mod fx;
pub mod images;
mod text;
+70
View File
@@ -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 &regions {
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<designcraft_render::Placed> =
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<crate::render_worker::Worker> {
if std::env::var_os("DESIGNCRAFT_SYNC_RENDER").is_some() {
+1 -1
View File
@@ -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},
})
}
+12
View File
@@ -188,6 +188,14 @@ pub struct CanvasCache {
pub worker: Option<render_worker::Worker>,
pub worker_started: bool,
pub last_ms: f64,
/// The document the texture shows (to repaint only what an edit changed).
pub shown_doc: Option<std::sync::Arc<designcraft_doc::Document>>,
/// Document of the request in flight.
pub pending_doc: Option<std::sync::Arc<designcraft_doc::Document>>,
/// 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(),