//! Partial / dirty-region compositing for the HCIE engine. //! //! ## Purpose //! Contains the functions that turn layer pixel data into a flat composite //! image: `render_composite_region()` for GUI partial updates, //! `get_composite_pixels()` for full-canvas exports, and the incremental //! tile synchroniser `sync_dirty_tiles()`. These are the most performance- //! sensitive composite paths. //! //! ## Logic & Workflow //! 1. Render dirty vector layers into their pixel buffers. //! 2. Apply layer effects/styles when `effects_dirty` is set, caching the //! result in `layer.effects_cache`. //! 3. Sync the sparse tile cache for any dirty layers (`sync_dirty_tiles`). //! 4. Composite either the dirty sub-region or the full canvas using the //! tiled compositor, optionally reusing the `below_cache` snapshot of //! layers below the active layer. //! //! ## Side Effects / Dependencies //! Mutates layer pixels, `tile_layers`, `composite_scratch`, `raw_pixel_backup`, //! and dirty flags. Depends on `hcie_tile::TiledLayer`, `hcie_fx`, and the //! dynamic `tiled::composite_tiled_into` / `composite_layers` compositors. use crate::dynamic_loader::vector::render_vector_shapes; use crate::dynamic_loader::{composite_layers, tiled}; use crate::Engine; use hcie_tile::TiledLayer; use rayon::prelude::*; impl Engine { /// **Purpose:** /// Computes the flat composite RGBA pixel buffer of all layers in the document. /// /// **Logic & Workflow:** /// 1. Renders vector shapes for dirty vector layers. /// 2. Applies layer effects/styles if they are dirty, caching the result. /// 3. Synchronizes `self.tile_layers` for any dirty layers. /// 4. Clears dirty flags. /// 5. Blends all layers into a flat caller-allocated buffer. pub fn get_composite_pixels(&mut self) -> Vec { log::trace!("[get_composite_pixels] ===== START ====="); for (i, l) in self.document.layers.iter().enumerate() { log::trace!( "[get_composite_pixels] layer[{}] id={} name='{}' visible={} dirty={} opacity={} blend={:?}", i, l.id, l.name, l.visible, l.dirty, l.opacity, l.blend_mode ); } self.apply_effects_and_sync_tiles(); self.document.clear_dirty(); let output = composite_layers( &self.document.layers, self.document.canvas_width, self.document.canvas_height, ); let non_zero_alpha = output.iter().skip(3).step_by(4).filter(|&&a| a > 0).count(); let total_pixels = output.len() / 4; log::trace!( "[get_composite_pixels] completed full composite: size={} bytes, non_zero_alpha={}/{} ({}%), first_pixel={:?}", output.len(), non_zero_alpha, total_pixels, if total_pixels > 0 { non_zero_alpha * 100 / total_pixels } else { 0 }, if output.len() >= 4 { [output[0], output[1], output[2], output[3]] } else { [0; 4] } ); for (i, l) in self.document.layers.iter().enumerate() { let opaque_count = l .pixels .iter() .skip(3) .step_by(4) .filter(|&&a| a > 0) .count(); let total = l.pixels.len() / 4; log::trace!( "[get_composite_pixels] LAYER CONTENT: layer[{}] id={} name='{}' visible={} pixels_total={} opaque_pixels={}/{} ({}%)", i, l.id, l.name, l.visible, l.pixels.len(), opaque_count, total, if total > 0 { opaque_count * 100 / total } else { 0 } ); } output } pub fn render_composite(&mut self) -> Vec { self.get_composite_pixels() } /// Composite only the dirty region into a pooled internal scratch buffer. /// Returns the dirty bounds `[x0, y0, x1, y1]` that were updated, /// or `None` if nothing was dirty (full composite needed). pub fn render_composite_region(&mut self) -> (Option<[u32; 4]>, *const u8, usize) { let has_dirty = self.document.composite_dirty; let dirty = self.document.dirty_bounds; let w = self.document.canvas_width; let h = self.document.canvas_height; if !has_dirty && dirty.is_none() { log::trace!("[render_composite_region] nothing dirty — returning None"); return (None, std::ptr::null(), 0); } if has_dirty && dirty.is_none() { // composite_dirty was set (e.g., after loading/importing) but no layer-level // dirty_bounds exist. Force a full-canvas composite so the caller sees content // instead of an empty/null region. self.document.dirty_bounds = Some([0, 0, w, h]); } log::trace!( "[render_composite_region] ===== START composite_dirty={}, dirty_bounds={:?} =====", has_dirty, dirty ); let w = self.document.canvas_width; let h = self.document.canvas_height; let buf_size = (w * h * 4) as usize; self.apply_effects_and_sync_tiles(); let (x0, y0, x1, y1) = match dirty { Some([x0, y0, x1, y1]) => (x0.min(w), y0.min(h), x1.min(w), y1.min(h)), None => (0, 0, w, h), }; // Now safe to borrow composite_scratch — no more mutable calls to self // that could touch this buffer until we return. if self .composite_scratch .as_ref() .map_or(true, |b| b.len() != buf_size) { self.composite_scratch = Some(vec![0u8; buf_size]); } let buf = self.composite_scratch.as_mut().unwrap(); if x1 > x0 && y1 > y0 { let wu = w as usize; let x0u = x0 as usize; let active_idx = self.document.active_layer; let cache_valid = !self.below_cache_dirty && self.below_cache.is_some() && self.below_cache_active_idx == Some(active_idx) && active_idx > 0; log::trace!( "[render_composite_region] BEFORE composite: active_idx={}, cache_valid={}, below_cache={}, below_cache_active_idx={:?}, tile_layers_len={}, dirty_rect=[{},{},{},{}]", active_idx, cache_valid, self.below_cache.is_some(), self.below_cache_active_idx, self.tile_layers.len(), x0, y0, x1, y1 ); if log::log_enabled!(log::Level::Trace) { for (i, l) in self.document.layers.iter().enumerate() { let tc = self .tile_layers .get(i) .and_then(|t| t.as_ref().map(|tl| tl.tile_count())) .unwrap_or(0); log::trace!( "[render_composite_region] layer[{}] id={} visible={} dirty={} opacity={} blend={:?} tile_count={}", i, l.id, l.visible, l.dirty, l.opacity, l.blend_mode, tc ); } } if cache_valid { let cache = self.below_cache.as_ref().unwrap(); if log::log_enabled!(log::Level::Trace) { let below_non_zero = cache.iter().filter(|&&b| b != 0).count(); log::trace!( "[render_composite_region] CACHE HIT: using below_cache ({} non-zero bytes), compositing layers[{}..{}] on top", below_non_zero, active_idx, self.document.layers.len() ); } for y in y0..y1 { let start = (y as usize * wu + x0u) * 4; let end = start + ((x1 - x0) as usize) * 4; if end <= cache.len() && end <= buf.len() { buf[start..end].copy_from_slice(&cache[start..end]); } } let tile_start = active_idx.min(self.tile_layers.len()); tiled::composite_tiled_into( &self.document.layers[active_idx..], &self.tile_layers[tile_start..], w, h, x0, y0, x1, y1, buf, ); log::trace!( "[render_composite_region] cache hit DONE: {} above layers composited, dirty_rect=[{},{},{},{}]", self.document.layers.len() - active_idx, x0, y0, x1, y1 ); } else { log::trace!( "[render_composite_region] CACHE MISS: full composite of all {} layers, dirty_rect=[{},{},{},{}]", self.document.layers.len(), x0, y0, x1, y1 ); let is_full_canvas = x0 == 0 && y0 == 0 && x1 == w && y1 == h; if is_full_canvas { buf.par_chunks_mut(65536).for_each(|chunk| chunk.fill(0)); } else { for y in y0..y1 { let start = (y as usize * wu + x0u) * 4; let end = start + ((x1 - x0) as usize) * 4; buf[start..end].fill(0); } } tiled::composite_tiled_into( &self.document.layers, &self.tile_layers, w, h, x0, y0, x1, y1, buf, ); log::trace!( "[render_composite_region] full composite DONE, dirty_rect=[{},{},{},{}]", x0, y0, x1, y1 ); } } self.document.clear_dirty(); let ptr = buf.as_ptr(); (Some([x0, y0, x1, y1]), ptr, buf_size) } /// Shared preprocessing for full and partial compositing: /// - render dirty vector shapes, /// - apply dirty layer effects/styles, /// - sync the sparse tile cache. fn apply_effects_and_sync_tiles(&mut self) { // Render vector shapes before compositing — only dirty vector layers for layer in &mut self.document.layers { if let hcie_protocol::LayerData::Vector { shapes: _ } = &layer.data { if layer.dirty { layer.pixels.fill(0); render_vector_shapes(layer); } } } // ── Effects pipeline (two-pass) ───────────────────────────────────────────── // Pass 1: Snapshot raw pixels for any effect-bearing layer that does NOT yet // have a backup. { let ids_needing_backup: Vec<(u64, Vec)> = self .document .layers .iter() .filter(|l| { (!l.effects.is_empty() || !l.styles.is_empty()) && !l.pixels.is_empty() && !self.raw_pixel_backup.contains_key(&l.id) }) .map(|l| (l.id, l.pixels.clone())) .collect(); for (id, pixels) in ids_needing_backup { self.raw_pixel_backup.insert(id, pixels); } } // Pass 2: Apply layer effects. Restores from backup first so that each // slider edit always applies on top of the original untouched pixels. // CRITICAL: Do NOT modify layer.pixels with effects output. layer.pixels // must always contain the raw drawing data so that new strokes are drawn // on top of clean pixels (not on effects-applied pixels). The composite // uses effects_cache for layers with effects, not layer.pixels. for layer in &mut self.document.layers { if layer.effects.is_empty() && layer.styles.is_empty() { continue; } // Check if there are any active/enabled effects or styles let has_enabled = layer.effects.iter().any(|e| match e { hcie_protocol::effects::LayerEffect::DropShadow { enabled, .. } | hcie_protocol::effects::LayerEffect::InnerShadow { enabled, .. } | hcie_protocol::effects::LayerEffect::OuterGlow { enabled, .. } | hcie_protocol::effects::LayerEffect::InnerGlow { enabled, .. } | hcie_protocol::effects::LayerEffect::BevelEmboss { enabled, .. } | hcie_protocol::effects::LayerEffect::Satin { enabled, .. } | hcie_protocol::effects::LayerEffect::ColorOverlay { enabled, .. } | hcie_protocol::effects::LayerEffect::GradientOverlay { enabled, .. } | hcie_protocol::effects::LayerEffect::PatternOverlay { enabled, .. } | hcie_protocol::effects::LayerEffect::Stroke { enabled, .. } => *enabled, }) || layer.styles.iter().any(|s| match s { hcie_protocol::LayerStyle::DropShadow { enabled, .. } | hcie_protocol::LayerStyle::InnerShadow { enabled, .. } | hcie_protocol::LayerStyle::OuterGlow { enabled, .. } | hcie_protocol::LayerStyle::InnerGlow { enabled, .. } | hcie_protocol::LayerStyle::BevelEmboss { enabled, .. } | hcie_protocol::LayerStyle::Satin { enabled, .. } | hcie_protocol::LayerStyle::ColorOverlay { enabled, .. } | hcie_protocol::LayerStyle::GradientOverlay { enabled, .. } | hcie_protocol::LayerStyle::PatternOverlay { enabled, .. } | hcie_protocol::LayerStyle::Stroke { enabled, .. } => *enabled, }); if !has_enabled { // No active effects: clean up cached rendering and backup to restore raw performance. *layer.effects_cache.lock().unwrap() = None; self.raw_pixel_backup.remove(&layer.id); layer .effects_dirty .store(false, std::sync::atomic::Ordering::Release); layer.dirty = true; continue; } if layer .effects_dirty .load(std::sync::atomic::Ordering::Acquire) { log::trace!( "Applying active effects/styles for layer ID {} because effects are dirty", layer.id ); // DON'T restore raw pixels from backup! Restoring raw pixels from backup overwrites // active stroke drawing and wipes out new strokes. Since we never overwrite layer.pixels // with the effects output, layer.pixels already contains the clean, raw pixels. let mut effects: Vec = layer.effects.iter() .filter(|e| !matches!(e, hcie_protocol::effects::LayerEffect::DropShadow { noise, .. } if *noise == -999.0)) .map(|e| hcie_fx::protocol_to_hcie_fx_effect(e)) .collect(); effects.extend( layer .styles .iter() .filter_map(|s| hcie_fx::layer_style_to_effect(s)), ); if effects.is_empty() { continue; } let processed = hcie_fx::apply_layer_effects( &layer.pixels, layer.width, layer.height, &effects, layer.fill_opacity, ); *layer.effects_cache.lock().unwrap() = Some(hcie_protocol::LayerEffects { rendered: processed.clone(), width: layer.width, height: layer.height, }); layer .effects_dirty .store(false, std::sync::atomic::Ordering::Release); layer.dirty = true; } } self.sync_dirty_tiles(); } /// Incremental tile cache update. /// For dirty layers, only re-tile the tiles overlapping the global /// dirty_bounds region instead of scanning the entire 33MB dense buffer. /// Falls back to full from_dense() when dirty_bounds is None. fn sync_dirty_tiles(&mut self) { let count = self.document.layers.len(); if self.tile_layers.len() < count { self.tile_layers.resize_with(count, || None); } let db = self.document.dirty_bounds; let mut synced_count = 0usize; for (i, layer) in self.document.layers.iter().enumerate() { if !layer.dirty || layer.pixels.is_empty() { continue; } if let Some([dx0, dy0, dx1, dy1]) = db { if let Some(ref mut tl) = self.tile_layers[i] { if tl.width() == layer.width && tl.height() == layer.height { tl.update_tiles_in_region(&layer.pixels, layer.width, dx0, dy0, dx1, dy1); log::trace!( "[sync_dirty_tiles] incremental update layer[{}] id={} visible={} dirty_bounds=[{},{},{},{}]", i, layer.id, layer.visible, dx0, dy0, dx1, dy1 ); } else { *tl = TiledLayer::from_dense(&layer.pixels, layer.width, layer.height); log::trace!( "[sync_dirty_tiles] size mismatch - full rebuild TiledLayer for layer[{}] id={} visible={} bounds=[{},{}]", i, layer.id, layer.visible, layer.width, layer.height ); } } else { let mut tl = TiledLayer::new(layer.width, layer.height); tl.update_tiles_in_region(&layer.pixels, layer.width, dx0, dy0, dx1, dy1); self.tile_layers[i] = Some(tl); log::trace!( "[sync_dirty_tiles] created new TiledLayer for layer[{}] id={} visible={} dirty_bounds=[{},{},{},{}]", i, layer.id, layer.visible, dx0, dy0, dx1, dy1 ); } } else { self.tile_layers[i] = Some(TiledLayer::from_dense( &layer.pixels, layer.width, layer.height, )); log::trace!( "[sync_dirty_tiles] full rebuild TiledLayer for layer[{}] id={} visible={} (no dirty_bounds)", i, layer.id, layer.visible ); } synced_count += 1; } self.tile_layers.truncate(count); if synced_count > 0 { log::trace!( "[sync_dirty_tiles] synced {} layers, db={:?}, tile_layers_len={}", synced_count, db, self.tile_layers.len() ); } } } /// Snapshot of layer data needed for background compositing. /// /// # Purpose /// Captures all state required to composite the canvas without borrowing /// `&mut Engine`. The background thread uses this snapshot to compute the /// composite independently of the UI thread. /// /// # Logic & Workflow /// 1. `Engine::create_composite_snapshot()` clones layer pixels, properties, /// and tile data into this struct. /// 2. `composite_from_snapshot()` reads the snapshot and produces the flat /// RGBA composite buffer on a background thread. pub struct CompositeSnapshot { pub layers: Vec, pub tile_layers: Vec>, pub canvas_width: u32, pub canvas_height: u32, pub active_layer: usize, } impl Engine { /// Create a snapshot of the current layer state for background compositing. /// /// # Purpose /// Clones all layer pixels, properties, and tile data so a background /// thread can compute the composite without borrowing the engine. /// /// # Side Effects /// Calls `apply_effects_and_sync_tiles()` to ensure dirty layers are /// up-to-date before cloning. This mutates engine state but is safe /// because it's called on the UI thread before spawning the background. pub fn create_composite_snapshot(&mut self) -> CompositeSnapshot { // Ensure all dirty layers are up-to-date before cloning self.apply_effects_and_sync_tiles(); let layers = self.document.layers.clone(); let tile_layers = self.tile_layers.clone(); let canvas_width = self.document.canvas_width; let canvas_height = self.document.canvas_height; let active_layer = self.document.active_layer; CompositeSnapshot { layers, tile_layers, canvas_width, canvas_height, active_layer, } } } /// Compute the flat composite RGBA buffer from a snapshot. /// /// # Purpose /// Performs the full canvas composite on a background thread using the /// cloned layer data from `CompositeSnapshot`. This function is standalone /// (not a method on `Engine`) so it can be called from a background thread /// without borrowing the engine. /// /// # Arguments /// * `snapshot` — Cloned layer data from `create_composite_snapshot()`. /// /// # Returns /// A `Vec` of size `width * height * 4` containing the flat RGBA composite. pub fn composite_from_snapshot(snapshot: CompositeSnapshot) -> Vec { let w = snapshot.canvas_width; let h = snapshot.canvas_height; let buf_size = (w * h * 4) as usize; // Build tile slices for the compositor let tile_slice_len = snapshot.active_layer.min(snapshot.tile_layers.len()); let _visible_below: Vec<(usize, bool)> = snapshot.layers[..snapshot.active_layer] .iter() .enumerate() .map(|(i, l)| (i, l.visible)) .collect(); let mut buf = vec![0u8; buf_size]; // Composite below layers first (if any) if snapshot.active_layer > 0 { tiled::composite_tiled_into( &snapshot.layers[..snapshot.active_layer], &snapshot.tile_layers[..tile_slice_len], w, h, 0, 0, w, h, &mut buf, ); } // Composite active layer and above let tile_start = snapshot.active_layer.min(snapshot.tile_layers.len()); tiled::composite_tiled_into( &snapshot.layers[snapshot.active_layer..], &snapshot.tile_layers[tile_start..], w, h, 0, 0, w, h, &mut buf, ); buf }