//! Dirty-rect diff for raster undo/redo. //! //! Brush strokes and fills used to store the *entire* before/after RGBA frame in the //! undo stack (~8 MB each at 1080p → up to ~1.6 GB at the 100-action cap). A //! [`RasterDiff`] instead stores only the changed bounding box's pixels before and //! after, which for a typical brush dab is a few tens of KB. //! //! Applying a diff overwrites just the bbox of the keyframe's `raw_pixels`, so the //! buffer **must be resident** (full length `w*h*4`) when `apply_*` runs. The editor //! guarantees this by faulting the target frame in before undo/redo (a clean evicted //! frame's container bytes equal its current logical state, so the restored base is //! correct). If the base is somehow not resident we skip rather than corrupt. /// Normalize a buffer to full length `n`; an empty/short buffer becomes transparent. fn normalize(buf: &[u8], n: usize) -> std::borrow::Cow<'_, [u8]> { if buf.len() == n { std::borrow::Cow::Borrowed(buf) } else { std::borrow::Cow::Owned(vec![0u8; n]) } } /// A minimal before/after record of the region a raster edit changed. #[derive(Clone, Debug)] pub struct RasterDiff { full_width: u32, full_height: u32, /// Changed bounding box `(x, y, w, h)`; `None` when before == after (no-op). bbox: Option<(u32, u32, u32, u32)>, /// bbox-sized RGBA (`w*h*4`) of the region before the edit. before_region: Vec, /// bbox-sized RGBA (`w*h*4`) of the region after the edit. after_region: Vec, /// The pre-edit buffer was blank (empty/unallocated) — i.e. this was the first /// edit on a fresh keyframe. Lets `apply_after` build from a transparent base /// (the commit/redo path often starts with empty `raw_pixels`) and `apply_before` /// restore to blank, instead of requiring a resident base. before_blank: bool, } impl RasterDiff { /// Build a diff from full before/after buffers. `after` is expected to be the /// resident post-edit buffer (`width*height*4`); `before` may be empty (a blank /// keyframe's first stroke), treated as fully transparent. pub fn compute(before: &[u8], after: &[u8], width: u32, height: u32) -> Self { let n = width as usize * height as usize * 4; let before_blank = before.len() != n; // Normalize both sides to full length; empty/short ⇒ transparent. let before_full = normalize(before, n); let after_full = normalize(after, n); // Find the tight bbox of differing pixels (compare 4-byte texels). let (w, h) = (width as usize, height as usize); let (mut min_x, mut min_y, mut max_x, mut max_y) = (usize::MAX, usize::MAX, 0usize, 0usize); let mut any = false; for y in 0..h { let row = y * w * 4; for x in 0..w { let i = row + x * 4; if before_full[i..i + 4] != after_full[i..i + 4] { any = true; if x < min_x { min_x = x; } if x > max_x { max_x = x; } if y < min_y { min_y = y; } if y > max_y { max_y = y; } } } } if !any { return Self { full_width: width, full_height: height, bbox: None, before_region: Vec::new(), after_region: Vec::new(), before_blank }; } let bw = max_x - min_x + 1; let bh = max_y - min_y + 1; let crop = |full: &[u8]| -> Vec { let mut out = Vec::with_capacity(bw * bh * 4); for row in 0..bh { let src = ((min_y + row) * w + min_x) * 4; out.extend_from_slice(&full[src..src + bw * 4]); } out }; Self { full_width: width, full_height: height, bbox: Some((min_x as u32, min_y as u32, bw as u32, bh as u32)), before_region: crop(&before_full), after_region: crop(&after_full), before_blank, } } /// Approximate retained size in bytes (the two cropped regions). pub fn byte_size(&self) -> usize { self.before_region.len() + self.after_region.len() } /// Restore the pre-edit pixels into `raw` (undo / first-execute rollback). `cur_w`/`cur_h` /// are the keyframe's current dimensions; if they differ from the diff's captured size the /// diff predates a resize and is skipped rather than applied at mismatched dims. pub fn apply_before(&self, raw: &mut Vec, cur_w: u32, cur_h: u32) { if self.bbox.is_none() { return; // no change } if cur_w != self.full_width || cur_h != self.full_height { return; // diff predates a keyframe resize } if self.before_blank { // The frame was blank before this edit (it was the first stroke); undoing // it returns to blank regardless of the current buffer. raw.clear(); return; } self.stamp_resident(&self.before_region, raw); } /// Apply the post-edit pixels into `raw` (commit / redo). `cur_w`/`cur_h` are the keyframe's /// current dimensions; a mismatch with the captured size means the diff predates a resize, so /// it's skipped rather than rebuilding the buffer at stale dimensions. pub fn apply_after(&self, raw: &mut Vec, cur_w: u32, cur_h: u32) { if self.bbox.is_none() { return; // no change } if cur_w != self.full_width || cur_h != self.full_height { return; // diff predates a keyframe resize } if self.before_blank { // Base was blank: build a full transparent buffer then stamp the bbox. The // commit/redo path frequently starts from empty `raw_pixels` here. let n = self.full_width as usize * self.full_height as usize * 4; raw.clear(); raw.resize(n, 0); } self.stamp_resident(&self.after_region, raw); } /// Stamp a bbox-sized region into `raw`, which must already be full-size. If it /// isn't (a non-blank base that the editor failed to fault in), skip rather than /// resize-and-corrupt — the frame will re-page to its container state. fn stamp_resident(&self, region: &[u8], raw: &mut [u8]) { let n = self.full_width as usize * self.full_height as usize * 4; let (x, y, bw, bh) = match self.bbox { Some(b) => b, None => return, }; if raw.len() != n { eprintln!( "⚠️ [RASTER_DIFF] base not resident ({} != {}); skipping undo/redo apply", raw.len(), n ); return; } let (x, y, bw, bh) = (x as usize, y as usize, bw as usize, bh as usize); let fw = self.full_width as usize; for row in 0..bh { let dst = ((y + row) * fw + x) * 4; let src = row * bw * 4; raw[dst..dst + bw * 4].copy_from_slice(®ion[src..src + bw * 4]); } } } #[cfg(test)] mod tests { use super::*; fn solid(w: u32, h: u32, px: [u8; 4]) -> Vec { px.iter().copied().cycle().take((w * h * 4) as usize).collect() } #[test] fn roundtrip_reproduces_buffers_exactly() { let (w, h) = (8, 6); let before = solid(w, h, [10, 20, 30, 255]); let mut after = before.clone(); // Change a 2x2 region at (3,2). for (dy, dx) in [(0, 0), (0, 1), (1, 0), (1, 1)] { let i = (((2 + dy) * w + (3 + dx)) * 4) as usize; after[i..i + 4].copy_from_slice(&[200, 100, 50, 255]); } let diff = RasterDiff::compute(&before, &after, w, h); assert_eq!(diff.bbox, Some((3, 2, 2, 2))); let mut buf = after.clone(); diff.apply_before(&mut buf, w, h); assert_eq!(buf, before, "undo must reproduce the pre-edit buffer exactly"); diff.apply_after(&mut buf, w, h); assert_eq!(buf, after, "redo must reproduce the post-edit buffer exactly"); } #[test] fn blank_before_first_stroke() { let (w, h) = (4, 4); let n = (w * h * 4) as usize; let before: Vec = Vec::new(); // blank keyframe let mut after = vec![0u8; n]; let i = ((1 * w + 1) * 4) as usize; after[i..i + 4].copy_from_slice(&[255, 0, 0, 255]); let diff = RasterDiff::compute(&before, &after, w, h); assert_eq!(diff.bbox, Some((1, 1, 1, 1))); // First execute / redo from EMPTY raw_pixels (the real commit path): builds // the full buffer from transparent + the stroke. let mut buf: Vec = Vec::new(); diff.apply_after(&mut buf, w, h); assert_eq!(buf, after, "commit/redo must build the frame from a blank base"); // Undo the first stroke → back to blank (empty). diff.apply_before(&mut buf, w, h); assert!(buf.is_empty(), "undoing the first stroke restores the blank keyframe"); // Redo again from the now-empty buffer. diff.apply_after(&mut buf, w, h); assert_eq!(buf, after); } #[test] fn no_change_is_noop() { let (w, h) = (4, 4); let buf = solid(w, h, [1, 2, 3, 4]); let diff = RasterDiff::compute(&buf, &buf, w, h); assert_eq!(diff.bbox, None); assert_eq!(diff.byte_size(), 0); let mut b = buf.clone(); diff.apply_before(&mut b, w, h); assert_eq!(b, buf); } #[test] fn not_resident_base_is_skipped_not_corrupted() { let (w, h) = (4, 4); let before = solid(w, h, [9, 9, 9, 255]); let after = solid(w, h, [1, 2, 3, 255]); let diff = RasterDiff::compute(&before, &after, w, h); let mut empty: Vec = Vec::new(); diff.apply_before(&mut empty, w, h); // base not resident assert!(empty.is_empty(), "must not resize/corrupt a non-resident base"); } }