init
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
//! Stroke-level cache management for the HCIE engine.
|
||||
//!
|
||||
//! ## Purpose
|
||||
//! Holds the functions that set up and tear down a brush stroke, together with
|
||||
//! the `below_cache` snapshot of all layers below the active drawing layer.
|
||||
//! Keeping these in one module makes the stroke-side performance logic
|
||||
//! (mask pooling, below-layer cache reuse, sub-rect undo bounds) easier to
|
||||
//! protect from accidental regression.
|
||||
//!
|
||||
//! ## Logic & Workflow
|
||||
//! - `begin_stroke()` pools `active_stroke_mask`, snapshots the layer for undo,
|
||||
//! and either reuses or rebuilds the `below_cache` composite.
|
||||
//! - `end_stroke()` commits a sub-rect undo snapshot and retains the below-layer
|
||||
//! cache so the next stroke on the same active layer can reuse it.
|
||||
//! - `expand_stroke_bounds()` tracks the union of all brush stamps for the
|
||||
//! sub-rect snapshot.
|
||||
//!
|
||||
//! ## Side Effects / Dependencies
|
||||
//! All functions mutate `Engine` state (`document`, `tile_layers`, pooled
|
||||
//! buffers, dirty flags). They depend on `hcie_tile::TiledLayer` and the
|
||||
//! dynamic `tiled::composite_tiled_into` compositor.
|
||||
|
||||
use hcie_protocol::LayerData;
|
||||
use hcie_tile::TiledLayer;
|
||||
use crate::Engine;
|
||||
use crate::dynamic_loader::tiled;
|
||||
|
||||
impl Engine {
|
||||
/// Begin a new brush stroke on the given layer.
|
||||
///
|
||||
/// # Allocation Behaviour
|
||||
///
|
||||
/// This function performs two allocations per call:
|
||||
///
|
||||
/// 1. `active_stroke_mask` — **Pooled**: if the existing buffer matches
|
||||
/// `layer_size`, it is zeroed in-place with `fill(0)`. Only if the
|
||||
/// canvas dimensions changed does a fresh `vec![0u8; layer_size]`
|
||||
/// allocation occur (~8MB on 4K). The buffer is retained across strokes.
|
||||
///
|
||||
/// 2. `stroke_before` — **NOT pooled**: `layer.pixels.clone()` allocates a
|
||||
/// fresh ~33MB buffer on every call. This buffer is consumed by
|
||||
/// `push_draw_snapshot()` during `end_stroke()` and becomes owned by the
|
||||
/// undo history. Pooling would require the history crate to
|
||||
/// participate in a buffer return mechanism. See struct-level doc.
|
||||
///
|
||||
/// # Risk
|
||||
///
|
||||
/// If `active_stroke_mask` is accidentally set to `None` at any point
|
||||
/// after `end_stroke()` (e.g. a partial merge revert), the pooling
|
||||
/// optimization is silently negated and ~8MB will be allocated per stroke.
|
||||
/// The merge artifact cleanup of 2026-05-28 removed exactly this regression.
|
||||
pub fn begin_stroke(&mut self, layer_id: u64, x: f32, y: f32) {
|
||||
self.last_stroke_pos = Some((x, y, 1.0));
|
||||
self.sketch_history.clear();
|
||||
if let Some(layer) = self.document.get_layer_by_id(layer_id) {
|
||||
let layer_size = (layer.width * layer.height) as usize;
|
||||
|
||||
// Pool active_stroke_mask: reuse buffer if size matches, otherwise allocate.
|
||||
// This avoids a ~16MB allocation per stroke on a 4K canvas.
|
||||
match &mut self.active_stroke_mask {
|
||||
Some(mask) if mask.len() == layer_size => {
|
||||
// Reuse existing buffer — just zero it
|
||||
mask.fill(0);
|
||||
}
|
||||
_ => {
|
||||
// Size changed (different document/layer) — allocate fresh
|
||||
self.active_stroke_mask = Some(vec![0u8; layer_size]);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize last_stroke_bounds with brush radius around first point
|
||||
// Used for sub-rect snapshot in end_stroke (avoid 33MB clone on 4K)
|
||||
let tip = &self.current_tip;
|
||||
let dr = ((tip.size.max(2.0) * 1.3) as i32).max(1);
|
||||
let ix = x as i32;
|
||||
let iy = y as i32;
|
||||
let x0 = (ix - dr).max(0) as u32;
|
||||
let y0 = (iy - dr).max(0) as u32;
|
||||
let x1 = ((ix + dr + 1).min(layer.width as i32 - 1)).max(0) as u32;
|
||||
let y1 = ((iy + dr + 1).min(layer.height as i32 - 1)).max(0) as u32;
|
||||
self.last_stroke_bounds = Some([x0, y0, x1, y1]);
|
||||
|
||||
let before = layer.pixels.clone();
|
||||
let before_shapes = if let LayerData::Vector { shapes } = &layer.data {
|
||||
Some(shapes.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.stroke_before = Some((layer_id, before, before_shapes));
|
||||
|
||||
// Cache selection mask once at stroke start to avoid ~8MB clone per stroke_to().
|
||||
self.cached_selection_mask = self.document.selection_mask.clone();
|
||||
|
||||
// ── Below-layer composite cache ──────────────────────────────
|
||||
self.rebuild_below_cache_if_needed();
|
||||
}
|
||||
}
|
||||
|
||||
/// End the current brush stroke and commit a sub-rect undo snapshot.
|
||||
///
|
||||
/// Uses `last_stroke_bounds` to extract only the changed region (~100KB on a
|
||||
/// 4K canvas with a typical brush) instead of cloning the entire 33MB layer.
|
||||
/// The full `stroke_before` buffer is still used for draw_brush_stroke
|
||||
/// reference, but the history snapshot is sub-rect.
|
||||
pub fn end_stroke(&mut self, layer_id: u64) {
|
||||
log::trace!(
|
||||
"[end_stroke] layer_id={}, stroke_before={}, below_cache={}, below_cache_active_idx={:?}, last_stroke_bounds={:?}",
|
||||
layer_id,
|
||||
self.stroke_before.is_some(),
|
||||
self.below_cache.is_some(),
|
||||
self.below_cache_active_idx,
|
||||
self.last_stroke_bounds
|
||||
);
|
||||
if let Some((id, before, before_shapes)) = self.stroke_before.take() {
|
||||
if id == layer_id {
|
||||
if let Some(idx) = self.document.layer_index_by_id(layer_id) {
|
||||
if let Some([sx0, sy0, sx1, sy1]) = self.last_stroke_bounds {
|
||||
if sx0 < sx1 && sy0 < sy1 {
|
||||
let layer = &self.document.layers[idx];
|
||||
let lw = layer.width;
|
||||
let rw = sx1 - sx0;
|
||||
let rh = sy1 - sy0;
|
||||
let rect_size = (rw * rh * 4) as usize;
|
||||
let mut before_rect = vec![0u8; rect_size];
|
||||
let mut after_rect = vec![0u8; rect_size];
|
||||
for row in 0..rh {
|
||||
let src_start = (((sy0 + row) * lw + sx0) * 4) as usize;
|
||||
let dst_start = (row * rw * 4) as usize;
|
||||
let len = (rw * 4) as usize;
|
||||
before_rect[dst_start..dst_start + len]
|
||||
.copy_from_slice(&before[src_start..src_start + len]);
|
||||
after_rect[dst_start..dst_start + len]
|
||||
.copy_from_slice(&layer.pixels[src_start..src_start + len]);
|
||||
}
|
||||
self.document.push_draw_snapshot_subrect(
|
||||
idx, before_rect, after_rect,
|
||||
(sx0, sy0, sx1, sy1),
|
||||
"Brush Stroke".to_string(),
|
||||
);
|
||||
// Stroke changed layer.pixels — invalidate effects backup so
|
||||
// the next style edit captures the post-stroke raw pixels.
|
||||
self.raw_pixel_backup.remove(&layer_id);
|
||||
self.last_stroke_bounds = None;
|
||||
self.last_stroke_pos = None;
|
||||
// The active layer's pixels changed during the stroke, but
|
||||
// the layers *below* the active layer did not. Retain the
|
||||
// below_cache snapshot and mark it valid so the next
|
||||
// begin_stroke on the same active layer can reuse it.
|
||||
self.below_cache_dirty = false;
|
||||
log::trace!("[end_stroke] sub-rect snapshot done, below_cache retained for reuse");
|
||||
if let Some(mask) = &mut self.active_stroke_mask {
|
||||
mask.fill(0);
|
||||
}
|
||||
self.cached_selection_mask = None;
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.document.push_draw_snapshot(
|
||||
idx, before, before_shapes,
|
||||
"Brush Stroke".to_string(),
|
||||
);
|
||||
// Stroke changed layer.pixels — invalidate effects backup.
|
||||
self.raw_pixel_backup.remove(&layer_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.last_stroke_pos = None;
|
||||
self.last_stroke_bounds = None;
|
||||
// Retain the below_cache snapshot across strokes: the layers below the
|
||||
// active layer did not change, so the cache is still valid for the
|
||||
// same active layer index.
|
||||
self.below_cache_dirty = false;
|
||||
log::trace!("[end_stroke] fallback cleanup done, below_cache retained for reuse");
|
||||
if let Some(mask) = &mut self.active_stroke_mask {
|
||||
mask.fill(0);
|
||||
}
|
||||
self.cached_selection_mask = None;
|
||||
}
|
||||
|
||||
/// Expand the stroke bounds to include the given point with radius.
|
||||
pub(crate) fn expand_stroke_bounds(&mut self, x: u32, y: u32, radius: f32) {
|
||||
let r = (radius as i32).max(1);
|
||||
let x0 = (x as i32 - r).max(0) as u32;
|
||||
let y0 = (y as i32 - r).max(0) as u32;
|
||||
let x1 = ((x as i32 + r + 1).min(self.document.canvas_width as i32 - 1)).max(0) as u32;
|
||||
let y1 = ((y as i32 + r + 1).min(self.document.canvas_height as i32 - 1)).max(0) as u32;
|
||||
match &mut self.last_stroke_bounds {
|
||||
Some(b) => {
|
||||
b[0] = b[0].min(x0);
|
||||
b[1] = b[1].min(y0);
|
||||
b[2] = b[2].max(x1);
|
||||
b[3] = b[3].max(y1);
|
||||
}
|
||||
None => {
|
||||
self.last_stroke_bounds = Some([x0, y0, x1, y1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the `below_cache` snapshot of layers below the active layer
|
||||
/// only when necessary. Reuses the existing buffer when the active layer
|
||||
/// index matches and no layer property that affects the below-layer
|
||||
/// composite has changed since the last stroke.
|
||||
fn rebuild_below_cache_if_needed(&mut self) {
|
||||
let active_idx = self.document.active_layer;
|
||||
let cw = self.document.canvas_width;
|
||||
let ch = self.document.canvas_height;
|
||||
let buf_size = (cw * ch * 4) as usize;
|
||||
|
||||
let can_reuse = !self.below_cache_dirty
|
||||
&& self.below_cache_active_idx == Some(active_idx)
|
||||
&& active_idx > 0
|
||||
&& self.below_cache.as_ref().map_or(false, |b| b.len() == buf_size);
|
||||
|
||||
log::trace!(
|
||||
"[begin_stroke] below_cache state: active_idx={}, reuse={}, dirty={}, existing_cache={}, existing_active_idx={:?}",
|
||||
active_idx, can_reuse, self.below_cache_dirty, self.below_cache.is_some(), self.below_cache_active_idx
|
||||
);
|
||||
|
||||
if can_reuse {
|
||||
log::trace!("[begin_stroke] REUSING below_cache for active_idx={}", active_idx);
|
||||
self.below_cache_dirty = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if active_idx > 0 {
|
||||
let lcount = self.document.layers.len();
|
||||
if self.tile_layers.len() < lcount {
|
||||
self.tile_layers.resize_with(lcount, || None);
|
||||
}
|
||||
let mut tiles_built = 0usize;
|
||||
for (ti, tl) in self.document.layers.iter().enumerate() {
|
||||
if ti >= active_idx { break; }
|
||||
if !tl.pixels.is_empty() && self.tile_layers[ti].is_none() {
|
||||
self.tile_layers[ti] = Some(TiledLayer::from_dense(
|
||||
&tl.pixels, tl.width, tl.height,
|
||||
));
|
||||
tiles_built += 1;
|
||||
log::trace!("[begin_stroke] built tile for below layer[{}] id={}", ti, tl.id);
|
||||
}
|
||||
}
|
||||
let mut cache = match self.below_cache.take() {
|
||||
Some(mut b) if b.len() == buf_size => {
|
||||
b.fill(0);
|
||||
b
|
||||
}
|
||||
_ => vec![0u8; buf_size],
|
||||
};
|
||||
let tile_slice_len = active_idx.min(self.tile_layers.len());
|
||||
let visible_below: Vec<(usize, bool)> = self.document.layers[..active_idx]
|
||||
.iter().enumerate().map(|(i, l)| (i, l.visible)).collect();
|
||||
log::trace!(
|
||||
"[begin_stroke] compositing below_cache: {} below_layers, tile_slice_len={}, visible_below={:?}, tiles_built={}",
|
||||
active_idx, tile_slice_len, visible_below, tiles_built
|
||||
);
|
||||
tiled::composite_tiled_into(
|
||||
&self.document.layers[..active_idx],
|
||||
&self.tile_layers[..tile_slice_len],
|
||||
cw, ch, 0, 0, cw, ch,
|
||||
&mut cache,
|
||||
);
|
||||
let non_zero = cache.iter().filter(|&&b| b != 0).count();
|
||||
log::trace!(
|
||||
"[begin_stroke] below_cache built: {} bytes, non-zero bytes={}, active_idx={}",
|
||||
cache.len(), non_zero, active_idx
|
||||
);
|
||||
self.below_cache = Some(cache);
|
||||
self.below_cache_active_idx = Some(active_idx);
|
||||
self.below_cache_dirty = false;
|
||||
} else {
|
||||
log::trace!("[begin_stroke] active_idx=0 (bottom layer), no below_cache");
|
||||
self.below_cache = None;
|
||||
self.below_cache_active_idx = None;
|
||||
self.below_cache_dirty = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user