mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-14 19:27:07 -04:00
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:
1 parent
69563933be
commit
ded7c7ad04
8 files changed
+468
-3
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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() };
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ use vello_cpu::{Pixmap, RenderContext, Resources};
|
||||
|
||||
pub use vello_cpu;
|
||||
|
||||
pub mod damage;
|
||||
mod fx;
|
||||
pub mod images;
|
||||
mod text;
|
||||
|
||||
@@ -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<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() {
|
||||
|
||||
@@ -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},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user