feat: Enhance canvas texture management and selection encoding

- Introduced `SharedCompositePixels` for stable full-canvas pixel storage, enabling efficient pipeline creation and recovery.
- Added `TextureUpdate` struct for tightly packed dirty rectangle uploads, reducing unnecessary data copying.
- Refactored `upload_dirty_region` to utilize `TextureUpdate`, improving performance by eliminating full buffer scans.
- Implemented `encode_selection_texture` to generate an encoded R8 selection mask, optimizing selection rendering.
- Updated shader to sample selection texture only once, reducing GPU workload.
- Added comprehensive tests for texture updates, selection encoding, and performance diagnostics.
- Documented design decisions and validation sequences to ensure future performance stability.
This commit is contained in:
2026-07-22 02:49:22 +03:00
parent 7b4073e55b
commit 2d5b7a37e8
11 changed files with 994 additions and 399 deletions
+190 -159
View File
@@ -404,13 +404,11 @@ enum PendingFileOperation {
/// Per-document state wrapping an engine instance.
pub struct IcedDocument {
pub engine: Engine,
/// Composite RGBA pixel data shared with the shader pipeline via Arc.
/// The shader's `prepare()` reads this and uploads only the dirty region
/// via `queue.write_texture()`. Updated in `refresh_composite_if_needed()`.
pub composite_pixels: Arc<Vec<u8>>,
/// Mutable composite buffer for partial-copy from engine.
/// After updating, this is wrapped in a new Arc for the shader.
composite_raw: Vec<u8>,
/// Stable full RGBA recovery image shared with the persistent shader pipeline.
/// Normal frames use packed dirty payloads; this is read only for full texture recovery.
pub composite_pixels: crate::canvas::texture_update::SharedCompositePixels,
/// Mutable composite buffer for partial copies and packed dirty-update construction.
pub(crate) composite_raw: Vec<u8>,
/// Document display name.
pub name: String,
/// Original file path, if loaded from disk.
@@ -466,9 +464,13 @@ pub struct IcedDocument {
/// When true, the pending_paste pixels should be placed as a new layer
/// instead of entering transform mode (set by "Paste as New Layer").
pub pending_paste_as_new_layer: bool,
/// Selection mask (alpha channel) for the current selection
pub selection_mask: Option<Vec<u8>>,
/// Whether the selection mask has changed since last GPU upload
/// Raw selection mask retained for transform and selection-history workflows.
pub selection_mask: Option<Arc<Vec<u8>>>,
/// Encoded R8 selection texture: zero unselected, 128 interior, 255 border.
pub selection_texture: Option<Arc<Vec<u8>>>,
/// Whether a selection engine/local mutation still needs cache synchronization.
pub selection_model_dirty: bool,
/// Whether the encoded selection texture has changed since last GPU upload.
pub selection_mask_dirty: std::cell::Cell<bool>,
/// Selection bounds (x, y, width, height)
pub selection_bounds: Option<(u32, u32, u32, u32)>,
@@ -488,12 +490,6 @@ pub struct IcedDocument {
pub vision_rect: Option<(f32, f32, f32, f32)>,
/// Draft text state for the on-canvas text tool.
pub text_draft: Option<TextDraft>,
/// Cache for extracted selection edges (marching ants).
pub selection_edge_cache: crate::canvas::edge_cache::SelectionEdgeCache,
/// Extracted selection edges for overlay rendering.
pub marching_ants_edges: Option<Arc<Vec<(u32, u32, u32, u32)>>>,
/// Horizontal runs of selected pixels used to fill irregular masks exactly.
pub selection_fill_spans: Option<Arc<Vec<(u32, u32, u32)>>>,
/// How newly generated selection masks modify the existing mask.
pub selection_mode: selection::SelectionMode,
/// In-memory saved selection for the document's Load/Save Selection commands.
@@ -1563,8 +1559,7 @@ mod transform_transaction_tests {
///
/// **Logic & Workflow:** Restores only the previous transformed AABB from the stable base, invokes
/// the shared inverse-affine compositor for the current AABB, unions both with any pending dirty
/// region, and updates the shader Arc in place when uniquely owned. A full-buffer Arc clone is used
/// only when Iced still owns the prior frame's immutable Arc.
/// region, and copies only that union into the stable shader recovery image.
///
/// **Arguments:** `doc` contains transform/composite state and `previous_bounds` identifies stale
/// preview pixels. **Returns:** Nothing. **Side Effects / Dependencies:** Mutates preview buffers,
@@ -1593,15 +1588,13 @@ fn render_transform_preview(doc: &mut IcedDocument, previous_bounds: Option<[u32
let pending = doc.dirty_region.replace(None);
let upload_bounds = union_regions(pending, bounds);
doc.dirty_region.replace(Some(upload_bounds));
if let Some(shader_pixels) = Arc::get_mut(&mut doc.composite_pixels) {
restore_preview_region(
shader_pixels,
&doc.composite_raw,
canvas_width,
upload_bounds,
);
} else {
doc.composite_pixels = Arc::new(doc.composite_raw.clone());
if let Err(error) =
doc.composite_pixels
.copy_region_from(&doc.composite_raw, canvas_width, upload_bounds)
{
log::error!("failed to stage transform preview: {error}");
doc.composite_pixels.replace(&doc.composite_raw);
doc.full_upload.replace(true);
}
doc.render_generation = doc.render_generation.wrapping_add(1);
log::debug!(
@@ -1611,6 +1604,60 @@ fn render_transform_preview(doc: &mut IcedDocument, previous_bounds: Option<[u32
}
}
/// Rebuilds all GUI selection-rendering state from one raw mask.
///
/// **Purpose:** Makes selection texture work proportional to selection mutations rather than brush
/// refreshes. **Logic & Workflow:** Valid masks are encoded once into interior/border values and
/// cached behind `Arc`; empty or invalid masks clear all overlay state. **Arguments:** `doc` is the
/// target document and `mask` is an optional locally owned alpha mask. **Returns:** Nothing.
/// **Side Effects / Dependencies:** Updates selection caches and schedules exactly one GPU upload.
fn set_document_selection_mask(doc: &mut IcedDocument, mask: Option<Vec<u8>>) {
let width = doc.engine.canvas_width();
let height = doc.engine.canvas_height();
let valid_mask = mask.filter(|mask| mask.len() == (width * height) as usize);
if let Some(mask) = valid_mask {
let encoded = selection::state::encode_selection_texture(&mask, width, height);
if encoded.bounds.is_some() {
doc.selection_mask = Some(Arc::new(mask));
doc.selection_texture = Some(Arc::new(encoded.pixels));
doc.selection_bounds = encoded.bounds;
} else {
doc.selection_mask = None;
doc.selection_texture = None;
doc.selection_bounds = None;
}
} else {
doc.selection_mask = None;
doc.selection_texture = None;
doc.selection_bounds = None;
}
doc.selection_model_dirty = false;
doc.selection_mask_dirty.set(true);
}
/// Consumes one pending selection-cache synchronization request.
///
/// **Arguments:** `requested` is the document's mutation marker. **Returns:** Whether a rebuild must
/// run now. **Side Effects / Dependencies:** Resets the marker so ordinary composite refreshes are
/// constant-time until another selection operation explicitly marks it.
fn consume_selection_sync_request(requested: &mut bool) -> bool {
std::mem::take(requested)
}
/// Synchronizes cached selection rendering state after an engine selection mutation.
///
/// **Arguments:** `doc` is the document whose engine owns the source mask. **Returns:** Nothing.
/// **Logic & Workflow:** A clean marker is a constant-time no-op; a dirty marker clones and encodes
/// the engine mask once. **Side Effects / Dependencies:** Reads `hcie-engine-api` selection state and
/// may schedule a selection texture upload.
fn sync_document_selection_if_needed(doc: &mut IcedDocument) {
if !consume_selection_sync_request(&mut doc.selection_model_dirty) {
return;
}
let mask = doc.engine.get_selection_mask().map(ToOwned::to_owned);
set_document_selection_mask(doc, mask);
}
impl HcieIcedApp {
/// Combines the engine's newly generated selection with the previous mask.
///
@@ -1628,11 +1675,13 @@ impl HcieIcedApp {
doc.selection_history.push(previous.clone());
let Some(new_mask) = doc.engine.get_selection_mask().map(ToOwned::to_owned) else {
set_document_selection_mask(doc, None);
return;
};
let combined =
selection::state::combine_masks(previous.as_deref(), &new_mask, doc.selection_mode);
doc.engine.set_selection_mask(combined);
doc.selection_model_dirty = true;
}
/// Create the initial application state.
@@ -1656,7 +1705,9 @@ impl HcieIcedApp {
let doc = IcedDocument {
engine,
composite_pixels: Arc::new(composite_raw.clone()),
composite_pixels: crate::canvas::texture_update::SharedCompositePixels::new(
composite_raw.clone(),
),
composite_raw,
name: "Untitled".to_string(),
source_path: None,
@@ -1684,6 +1735,8 @@ impl HcieIcedApp {
pending_paste: None,
pending_paste_as_new_layer: false,
selection_mask: None,
selection_texture: None,
selection_model_dirty: false,
selection_mask_dirty: std::cell::Cell::new(false),
selection_bounds: None,
selection_history: Vec::new(),
@@ -1694,9 +1747,6 @@ impl HcieIcedApp {
gradient_drag: None,
vision_rect: None,
text_draft: None,
selection_edge_cache: Default::default(),
marching_ants_edges: None,
selection_fill_spans: None,
selection_mode: selection::SelectionMode::Replace,
saved_selection_mask: None,
quick_mask: false,
@@ -1955,8 +2005,14 @@ impl HcieIcedApp {
app.documents[0].engine.canvas_height()
);
app.documents[0].composite_raw = composite;
app.documents[0].composite_pixels =
std::sync::Arc::new(app.documents[0].composite_raw.clone());
app.documents[0]
.composite_pixels
.replace(&app.documents[0].composite_raw);
let selection = app.documents[0]
.engine
.get_selection_mask()
.map(ToOwned::to_owned);
set_document_selection_mask(&mut app.documents[0], selection);
app.documents[0].full_upload.replace(true);
app.documents[0].render_generation =
app.documents[0].render_generation.wrapping_add(1);
@@ -2183,6 +2239,8 @@ impl HcieIcedApp {
}
if let Some(next) = self.documents.iter().position(|document| document.modified) {
self.active_doc = next;
self.documents[next].full_upload.replace(true);
self.documents[next].selection_mask_dirty.set(true);
self.active_dialog = ActiveDialog::CloseConfirm;
Task::none()
} else {
@@ -2214,6 +2272,8 @@ impl HcieIcedApp {
self.vector_drag_last_angle = None;
if let Some(doc) = self.documents.get_mut(self.active_doc) {
doc.vector_drag_preview = None;
doc.full_upload.replace(true);
doc.selection_mask_dirty.set(true);
}
return;
}
@@ -2228,6 +2288,8 @@ impl HcieIcedApp {
self.vector_drag_last = None;
if let Some(doc) = self.documents.get_mut(self.active_doc) {
doc.vector_drag_preview = None;
doc.full_upload.replace(true);
doc.selection_mask_dirty.set(true);
}
}
@@ -2270,7 +2332,6 @@ impl HcieIcedApp {
// structure is no longer valid, and a partial copy would leave zeros in the new buffer.
if let Some(rect) = dirty_rect.filter(|_| !is_full_copy) {
let w = doc.engine.canvas_width() as usize;
let _h = doc.engine.canvas_height() as usize;
let [x0, y0, x1, y1] = rect;
let x0 = x0 as usize;
let y0 = y0 as usize;
@@ -2280,42 +2341,25 @@ impl HcieIcedApp {
let row_bytes = (x1 - x0) * 4;
if row_bytes > 0 {
unsafe {
let src_ptr = ptr;
let dst_raw_ptr = doc.composite_raw.as_mut_ptr();
let mut pixels_ptr = std::ptr::null_mut();
let mut need_new_arc = false;
if let Some(pixels) = Arc::get_mut(&mut doc.composite_pixels) {
// Important: resize arc if needed before taking mutable pointer
if pixels.len() != len {
pixels.resize(len, 0);
}
pixels_ptr = pixels.as_mut_ptr();
} else {
need_new_arc = true;
}
for y in y0..y1 {
let offset = (y * w + x0) * 4;
std::ptr::copy_nonoverlapping(
src_ptr.add(offset),
ptr.add(offset),
dst_raw_ptr.add(offset),
row_bytes,
);
if !pixels_ptr.is_null() {
std::ptr::copy_nonoverlapping(
src_ptr.add(offset),
pixels_ptr.add(offset),
row_bytes,
);
}
}
if need_new_arc {
doc.composite_pixels = Arc::new(doc.composite_raw.clone());
}
}
if let Err(error) = doc.composite_pixels.copy_region_from(
&doc.composite_raw,
w as u32,
rect,
) {
log::error!("failed to stage dirty composite region: {error}");
doc.composite_pixels.replace(&doc.composite_raw);
doc.full_upload.replace(true);
}
}
let current = doc.dirty_region.replace(None);
@@ -2325,16 +2369,7 @@ impl HcieIcedApp {
unsafe {
std::ptr::copy_nonoverlapping(ptr, doc.composite_raw.as_mut_ptr(), len);
}
if let Some(pixels) = Arc::get_mut(&mut doc.composite_pixels) {
if pixels.len() != len {
pixels.resize(len, 0);
}
unsafe {
std::ptr::copy_nonoverlapping(ptr, pixels.as_mut_ptr(), len);
}
} else {
doc.composite_pixels = Arc::new(doc.composite_raw.clone());
}
doc.composite_pixels.replace(&doc.composite_raw);
doc.full_upload.replace(true);
doc.dirty_region.replace(None);
}
@@ -2345,37 +2380,7 @@ impl HcieIcedApp {
doc.engine.clear_dirty_flags();
}
// Update selection edge cache if mask changed
if let Some(mask) = doc.engine.get_selection_mask() {
doc.selection_mask = Some(mask.to_vec());
doc.selection_mask_dirty.set(true);
doc.selection_bounds = selection::state::mask_bounds(
mask,
doc.engine.canvas_width(),
doc.engine.canvas_height(),
);
doc.selection_fill_spans = Some(Arc::new(selection::state::selected_spans(
mask,
doc.engine.canvas_width(),
doc.engine.canvas_height(),
)));
let edges = doc.selection_edge_cache.get(mask).unwrap_or_else(|| {
doc.selection_edge_cache.rebuild(
mask,
doc.engine.canvas_width(),
doc.engine.canvas_height(),
)
});
doc.marching_ants_edges = Some(edges);
} else {
doc.selection_mask = None;
doc.selection_mask_dirty.set(true);
doc.selection_bounds = None;
doc.selection_fill_spans = None;
doc.marching_ants_edges = None;
// Clear cache if selection is dropped so next time it won't mistakenly hit
doc.selection_edge_cache = Default::default();
}
sync_document_selection_if_needed(doc);
// Always refresh cached panel data (cheap operation)
doc.cached_layers = doc.engine.layer_infos();
@@ -2896,11 +2901,7 @@ impl HcieIcedApp {
self.documents[self.active_doc]
.engine
.create_selection_magic_wand(cx, cy, tol);
self.documents[self.active_doc].selection_bounds = {
let w = self.documents[self.active_doc].engine.canvas_width();
let h = self.documents[self.active_doc].engine.canvas_height();
Some((0, 0, w, h))
};
self.documents[self.active_doc].selection_model_dirty = true;
self.refresh_composite_if_needed();
}
Tool::Lasso => {
@@ -2937,6 +2938,7 @@ impl HcieIcedApp {
self.documents[self.active_doc]
.engine
.create_selection_polygon(&pts_ref);
self.documents[self.active_doc].selection_model_dirty = true;
self.refresh_composite_if_needed();
}
} else {
@@ -2984,8 +2986,7 @@ impl HcieIcedApp {
);
doc.engine.set_active_layer_pixels(cut_layer);
doc.engine.selection_clear();
doc.selection_mask = Some(mask_data.clone());
doc.selection_mask_dirty.set(true);
set_document_selection_mask(doc, Some(mask_data.clone()));
doc.transform_source = Some(tr.clone());
doc.transform_layer_baseline = Some(original_layer);
doc.transform_selection_baseline = Some(mask_data);
@@ -3307,8 +3308,6 @@ impl HcieIcedApp {
.engine
.create_selection_rect(sx, sy, ex, ey);
self.combine_current_selection(None);
self.documents[self.active_doc].selection_bounds =
Some((sx, sy, ex - sx, ey - sy));
self.refresh_composite_if_needed();
}
}
@@ -3455,7 +3454,7 @@ impl HcieIcedApp {
} else {
doc.engine.selection_clear();
}
doc.selection_mask_dirty.set(true);
doc.selection_model_dirty = true;
}
} else {
doc.engine.undo();
@@ -5211,6 +5210,8 @@ impl HcieIcedApp {
let doc = &mut self.documents[self.active_doc];
let as_new_layer = doc.pending_paste_as_new_layer;
doc.engine.resize_canvas(paste_w, paste_h);
doc.engine.selection_clear();
set_document_selection_mask(doc, None);
let canvas_w = doc.engine.canvas_width();
let canvas_h = doc.engine.canvas_height();
if let Some((pixels, w, h)) = doc.pending_paste.take() {
@@ -5328,7 +5329,9 @@ impl HcieIcedApp {
let history_current = engine.history_current();
let doc = IcedDocument {
engine,
composite_pixels: Arc::new(composite_raw.clone()),
composite_pixels: crate::canvas::texture_update::SharedCompositePixels::new(
composite_raw.clone(),
),
composite_raw,
name,
source_path: None,
@@ -5356,6 +5359,8 @@ impl HcieIcedApp {
pending_paste: None,
pending_paste_as_new_layer: false,
selection_mask: None,
selection_texture: None,
selection_model_dirty: false,
selection_mask_dirty: std::cell::Cell::new(false),
selection_bounds: None,
selection_history: Vec::new(),
@@ -5366,9 +5371,6 @@ impl HcieIcedApp {
gradient_drag: None,
vision_rect: None,
text_draft: None,
selection_edge_cache: Default::default(),
marching_ants_edges: None,
selection_fill_spans: None,
selection_mode: selection::SelectionMode::Replace,
saved_selection_mask: None,
quick_mask: false,
@@ -5505,6 +5507,7 @@ impl HcieIcedApp {
}
// Selection operations mutate the existing mask in place, so force overlay cache
// synchronization before the next marching-ants frame.
self.documents[self.active_doc].selection_model_dirty = true;
self.refresh_composite_if_needed();
self.active_dialog = ActiveDialog::None;
return Task::perform(async {}, |_| Message::CompositeRefresh);
@@ -5532,6 +5535,8 @@ impl HcieIcedApp {
let w = self.dialog_image_size_width.max(1);
let h = self.dialog_image_size_height.max(1);
self.documents[self.active_doc].engine.resize_canvas(w, h);
self.documents[self.active_doc].engine.selection_clear();
set_document_selection_mask(&mut self.documents[self.active_doc], None);
self.documents[self.active_doc].engine.pre_tile_all_layers();
self.refresh_composite_if_needed();
self.documents[self.active_doc].full_upload.replace(true);
@@ -5555,6 +5560,8 @@ impl HcieIcedApp {
self.documents[self.active_doc]
.engine
.resize_canvas(new_w, new_h);
self.documents[self.active_doc].engine.selection_clear();
set_document_selection_mask(&mut self.documents[self.active_doc], None);
self.documents[self.active_doc].engine.pre_tile_all_layers();
self.refresh_composite_if_needed();
self.documents[self.active_doc].full_upload.replace(true);
@@ -5586,9 +5593,6 @@ impl HcieIcedApp {
.engine
.create_selection_rect(sx, sy, ex, ey);
self.combine_current_selection(previous);
// Store selection bounds for marching ants display
self.documents[self.active_doc].selection_bounds =
Some((sx, sy, ex - sx, ey - sy));
// Clear the drag-preview rect so the blue rectangle
// disappears and the marching ants overlay takes over.
self.documents[self.active_doc].selection_rect = None;
@@ -5613,10 +5617,9 @@ impl HcieIcedApp {
.selection_history
.push(previous);
let w = self.documents[self.active_doc].engine.canvas_width();
let h = self.documents[self.active_doc].engine.canvas_height();
self.documents[self.active_doc].engine.selection_all();
self.documents[self.active_doc].selection_bounds = Some((0, 0, w, h));
self.documents[self.active_doc].selection_model_dirty = true;
self.refresh_composite_if_needed();
}
Message::Deselect => {
let doc = &mut self.documents[self.active_doc];
@@ -5633,14 +5636,12 @@ impl HcieIcedApp {
doc.selection_transform = None;
doc.transform_drag_handle = TransformHandle::None;
doc.engine.selection_clear();
doc.selection_mask = None;
doc.selection_mask_dirty.set(true);
doc.selection_fill_spans = None;
doc.marching_ants_edges = None;
set_document_selection_mask(doc, None);
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::SelectInverse => {
self.documents[self.active_doc].engine.selection_invert();
self.documents[self.active_doc].selection_model_dirty = true;
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
@@ -5659,6 +5660,7 @@ impl HcieIcedApp {
self.documents[self.active_doc]
.engine
.set_selection_mask(mask);
self.documents[self.active_doc].selection_model_dirty = true;
self.refresh_composite_if_needed();
}
}
@@ -5762,9 +5764,7 @@ impl HcieIcedApp {
},
);
doc.engine.selection_clear();
doc.selection_bounds = None;
doc.selection_mask = None;
doc.selection_mask_dirty.set(true);
set_document_selection_mask(doc, None);
doc.transform_drag_handle = TransformHandle::None;
doc.transform_drag_start = None;
doc.transform_original = None;
@@ -5791,8 +5791,7 @@ impl HcieIcedApp {
}
if let Some(mask) = doc.transform_selection_baseline.take() {
doc.engine.set_selection_mask(mask.clone());
doc.selection_mask = Some(mask);
doc.selection_mask_dirty.set(true);
set_document_selection_mask(doc, Some(mask));
}
doc.selection_transform = None;
doc.transform_source = None;
@@ -7060,11 +7059,6 @@ impl HcieIcedApp {
.engine
.create_selection_lasso(&pts_ref);
self.combine_current_selection(previous);
self.documents[self.active_doc].selection_bounds = {
let w = self.documents[self.active_doc].engine.canvas_width();
let h = self.documents[self.active_doc].engine.canvas_height();
Some((0, 0, w, h))
};
self.refresh_composite_if_needed();
}
}
@@ -7087,11 +7081,6 @@ impl HcieIcedApp {
.engine
.create_selection_polygon(&pts_ref);
self.combine_current_selection(previous);
self.documents[self.active_doc].selection_bounds = {
let w = self.documents[self.active_doc].engine.canvas_width();
let h = self.documents[self.active_doc].engine.canvas_height();
Some((0, 0, w, h))
};
self.refresh_composite_if_needed();
}
}
@@ -7157,6 +7146,8 @@ impl HcieIcedApp {
Message::CropConfirm => {
if let Some((x, y, w, h)) = self.documents[self.active_doc].crop_state.confirm() {
self.documents[self.active_doc].engine.crop(x, y, w, h);
self.documents[self.active_doc].engine.selection_clear();
set_document_selection_mask(&mut self.documents[self.active_doc], None);
self.documents[self.active_doc].crop_state.cancel();
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
@@ -7589,8 +7580,7 @@ impl HcieIcedApp {
}
Message::PasteImage => {
// Check internal clipboard first
if let Some(transform) = self.documents[self.active_doc].internal_clipboard.clone()
{
if let Some(transform) = self.documents[self.active_doc].internal_clipboard.clone() {
// Enter transform mode with the clipboard content
let mut placed = transform;
placed.pos.x += 10.0;
@@ -7615,7 +7605,8 @@ impl HcieIcedApp {
}
Message::PasteAsNewLayer => {
// Check internal clipboard first
if let Some(transform) = self.documents[self.active_doc].internal_clipboard.clone() {
if let Some(transform) = self.documents[self.active_doc].internal_clipboard.clone()
{
let doc = &mut self.documents[self.active_doc];
let canvas_w = doc.engine.canvas_width();
let canvas_h = doc.engine.canvas_height();
@@ -7828,6 +7819,7 @@ impl HcieIcedApp {
Ok(()) => {
self.show_welcome = false;
self.documents[self.active_doc].engine.pre_tile_all_layers();
self.documents[self.active_doc].selection_model_dirty = true;
self.documents[self.active_doc].name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
@@ -8203,7 +8195,9 @@ impl HcieIcedApp {
let history_current = engine.history_current();
self.documents.push(IcedDocument {
engine,
composite_pixels: Arc::new(composite_raw.clone()),
composite_pixels: crate::canvas::texture_update::SharedCompositePixels::new(
composite_raw.clone(),
),
composite_raw,
name: "Untitled".to_string(),
source_path: None,
@@ -8231,6 +8225,8 @@ impl HcieIcedApp {
pending_paste: None,
pending_paste_as_new_layer: false,
selection_mask: None,
selection_texture: None,
selection_model_dirty: false,
selection_mask_dirty: std::cell::Cell::new(false),
selection_bounds: None,
selection_history: Vec::new(),
@@ -8241,9 +8237,6 @@ impl HcieIcedApp {
gradient_drag: None,
vision_rect: None,
text_draft: None,
selection_edge_cache: Default::default(),
marching_ants_edges: None,
selection_fill_spans: None,
selection_mode: selection::SelectionMode::Replace,
saved_selection_mask: None,
quick_mask: false,
@@ -8268,6 +8261,8 @@ impl HcieIcedApp {
self.filter_params = serde_json::json!({});
}
self.active_doc = idx;
self.documents[idx].full_upload.replace(true);
self.documents[idx].selection_mask_dirty.set(true);
}
}
@@ -8289,6 +8284,8 @@ impl HcieIcedApp {
}
if modified {
self.active_doc = idx;
self.documents[idx].full_upload.replace(true);
self.documents[idx].selection_mask_dirty.set(true);
self.pending_document_close = Some(idx);
self.active_dialog = ActiveDialog::CloseConfirm;
} else {
@@ -8384,8 +8381,16 @@ impl HcieIcedApp {
.engine
.get_composite_pixels();
self.documents[self.active_doc].composite_raw = composite;
self.documents[self.active_doc].composite_pixels = std::sync::Arc::new(
self.documents[self.active_doc].composite_raw.clone(),
self.documents[self.active_doc]
.composite_pixels
.replace(&self.documents[self.active_doc].composite_raw);
let selection = self.documents[self.active_doc]
.engine
.get_selection_mask()
.map(ToOwned::to_owned);
set_document_selection_mask(
&mut self.documents[self.active_doc],
selection,
);
self.documents[self.active_doc].full_upload.replace(true);
self.documents[self.active_doc].render_generation = self.documents
@@ -8459,15 +8464,14 @@ impl HcieIcedApp {
return Task::perform(async {}, |_| Message::LayerFlatten)
}
MenuCommand::SelectAll => {
let w = self.documents[self.active_doc].engine.canvas_width() as f32;
let h = self.documents[self.active_doc].engine.canvas_height() as f32;
self.documents[self.active_doc].selection_rect = Some((0.0, 0.0, w, h));
return self.update(Message::SelectAll);
}
MenuCommand::Deselect => {
self.documents[self.active_doc].selection_rect = None;
return self.update(Message::Deselect);
}
MenuCommand::InvertSelection => {
self.documents[self.active_doc].engine.selection_invert();
self.documents[self.active_doc].selection_model_dirty = true;
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
@@ -8669,6 +8673,7 @@ impl HcieIcedApp {
doc.engine.set_active_layer_pixels(layer_pixels);
}
doc.engine.selection_clear();
set_document_selection_mask(doc, Some(mask_data));
doc.selection_transform = Some(tr);
self.refresh_composite_if_needed();
}
@@ -8976,9 +8981,14 @@ impl HcieIcedApp {
let composite =
self.documents[document_index].engine.get_composite_pixels();
self.documents[document_index].composite_raw = composite;
self.documents[document_index].composite_pixels = std::sync::Arc::new(
self.documents[document_index].composite_raw.clone(),
);
self.documents[document_index]
.composite_pixels
.replace(&self.documents[document_index].composite_raw);
let selection = self.documents[document_index]
.engine
.get_selection_mask()
.map(ToOwned::to_owned);
set_document_selection_mask(&mut self.documents[document_index], selection);
self.documents[document_index].full_upload.replace(true);
self.documents[document_index].render_generation = self.documents
[document_index]
@@ -10014,8 +10024,9 @@ fn active_index_after_document_close(
#[cfg(test)]
mod cycle_one_ux_tests {
use super::{
active_index_after_document_close, document_close_disposition, overlay_escape_target,
DocumentCloseDisposition, OverlayEscapeTarget,
active_index_after_document_close, consume_selection_sync_request,
document_close_disposition, overlay_escape_target, union_regions, DocumentCloseDisposition,
OverlayEscapeTarget,
};
/// Confirms Escape dismisses subtools before menus and leaves editor cancellation untouched.
@@ -10062,6 +10073,26 @@ mod cycle_one_ux_tests {
assert_eq!(active_index_after_document_close(2, 2, 2), 1);
assert_eq!(active_index_after_document_close(0, 2, 2), 0);
}
/// Ensures an unchanged selection cannot trigger work on every drawing refresh.
#[test]
fn selection_sync_request_is_consumed_once() {
let mut requested = true;
assert!(consume_selection_sync_request(&mut requested));
for _ in 0..120 {
assert!(!consume_selection_sync_request(&mut requested));
}
requested = true;
assert!(consume_selection_sync_request(&mut requested));
}
/// Protects the multi-event dirty accumulator from dropping an earlier update.
#[test]
fn dirty_region_union_retains_all_updates_before_view() {
let first = union_regions(None, [10, 20, 30, 40]);
let union = union_regions(Some(first), [25, 5, 50, 35]);
assert_eq!(union, [10, 5, 50, 40]);
}
}
/// Find the topmost text layer whose rasterized pixels contain the point
@@ -109,28 +109,10 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
if uniforms.has_selection > 0.5 {
let sel_val = textureSample(selection_texture, selection_sampler, in.uv).r;
if sel_val > 0.5 {
// This pixel is selected — compute distance to nearest border
// by sampling 8 neighbors at 1-pixel offset in canvas space
let texel_w = 1.0 / uniforms.canvas_w;
let texel_h = 1.0 / uniforms.canvas_h;
// Sample 8 neighbors to detect border (unrolled — WGSL forbids variable-indexed textureSample)
var is_border = false;
let n0 = textureSample(selection_texture, selection_sampler, in.uv + vec2<f32>(-texel_w, 0.0)).r;
let n1 = textureSample(selection_texture, selection_sampler, in.uv + vec2<f32>( texel_w, 0.0)).r;
let n2 = textureSample(selection_texture, selection_sampler, in.uv + vec2<f32>(0.0, -texel_h)).r;
let n3 = textureSample(selection_texture, selection_sampler, in.uv + vec2<f32>(0.0, texel_h)).r;
let n4 = textureSample(selection_texture, selection_sampler, in.uv + vec2<f32>(-texel_w, -texel_h)).r;
let n5 = textureSample(selection_texture, selection_sampler, in.uv + vec2<f32>( texel_w, -texel_h)).r;
let n6 = textureSample(selection_texture, selection_sampler, in.uv + vec2<f32>(-texel_w, texel_h)).r;
let n7 = textureSample(selection_texture, selection_sampler, in.uv + vec2<f32>( texel_w, texel_h)).r;
if n0 <= 0.5 || n1 <= 0.5 || n2 <= 0.5 || n3 <= 0.5 || n4 <= 0.5 || n5 <= 0.5 || n6 <= 0.5 || n7 <= 0.5 {
is_border = true;
}
if is_border {
if sel_val > 0.25 {
// Border membership is pre-encoded on selection changes so ordinary
// animation frames need one mask sample instead of nine.
if sel_val > 0.75 {
// ── Marching ants border ──────────────────────────────────
// Use canvas-space position for the dash pattern
let canvas_x = in.uv.x * uniforms.canvas_w;
@@ -1,78 +0,0 @@
//! Cached extraction of exact selection-mask edges.
//!
//! **Purpose:** Avoids rescanning a full selection mask every animation frame. **Logic & Workflow:**
//! A content fingerprint, dimensions, and length identify the current mask; changed content is
//! rescanned into pixel-boundary line segments. **Side Effects / Dependencies:** Allocates only
//! when the mask changes and shares the result through `Arc`.
use std::sync::Arc;
#[derive(Clone, Default)]
pub struct SelectionEdgeCache {
fingerprint: u64,
mask_len: usize,
edges: Arc<Vec<(u32, u32, u32, u32)>>,
}
impl SelectionEdgeCache {
/// Returns cached edges only when mask content and dimensions still match.
pub fn get(&self, mask: &[u8]) -> Option<Arc<Vec<(u32, u32, u32, u32)>>> {
if mask_fingerprint(mask) == self.fingerprint && mask.len() == self.mask_len {
Some(Arc::clone(&self.edges))
} else {
None
}
}
/// Rebuilds and stores exact pixel-boundary edges for `mask`.
pub fn rebuild(&mut self, mask: &[u8], w: u32, h: u32) -> Arc<Vec<(u32, u32, u32, u32)>> {
let edges = Arc::new(extract_selection_edges(mask, w, h));
self.fingerprint = mask_fingerprint(mask);
self.mask_len = mask.len();
self.edges = Arc::clone(&edges);
edges
}
}
/// Computes a fast deterministic fingerprint that detects in-place mask mutations.
fn mask_fingerprint(mask: &[u8]) -> u64 {
mask.iter().fold(0xcbf2_9ce4_8422_2325, |hash, byte| {
(hash ^ u64::from(*byte)).wrapping_mul(0x1000_0000_01b3)
})
}
pub fn extract_selection_edges(
selection_mask: &[u8],
canvas_width: u32,
canvas_height: u32,
) -> Vec<(u32, u32, u32, u32)> {
let w = canvas_width as usize;
let h = canvas_height as usize;
if selection_mask.len() != w * h {
return Vec::new();
}
let threshold: u8 = 128;
let mut edges = Vec::with_capacity(1024);
for y in 0..h {
for x in 0..w {
let idx = y * w + x;
if selection_mask[idx] <= threshold {
continue;
}
if x == 0 || selection_mask[idx - 1] <= threshold {
edges.push((x as u32, y as u32, x as u32, (y + 1) as u32));
}
if x == w - 1 || selection_mask[idx + 1] <= threshold {
edges.push(((x + 1) as u32, y as u32, (x + 1) as u32, (y + 1) as u32));
}
if y == 0 || selection_mask[idx - w] <= threshold {
edges.push((x as u32, y as u32, (x + 1) as u32, y as u32));
}
if y == h - 1 || selection_mask[idx + w] <= threshold {
edges.push((x as u32, (y + 1) as u32, (x + 1) as u32, (y + 1) as u32));
}
}
}
edges
}
@@ -19,8 +19,8 @@
//! - Only dirty sub-regions are uploaded — never the full 33MB buffer
//! - The checkerboard is procedural (in the fragment shader) — no CPU geometry
pub mod edge_cache;
pub mod shader_canvas;
pub mod texture_update;
// pub mod viewport;
// pub mod render;
@@ -34,6 +34,7 @@ use iced::{Element, Length, Point, Rectangle, Size, Vector};
use hcie_engine_api::{ZOOM_MAX, ZOOM_MIN};
use shader_canvas::CanvasShaderProgram;
use texture_update::TextureUpdate;
// ─── Overlay (selection rect, vector preview, crosshair) ─────────────────────
@@ -81,10 +82,6 @@ struct OverlayProgram {
lasso_points: Vec<(u32, u32)>,
/// Polygon selection points accumulated during click-by-click (canvas-space).
polygon_points: Vec<(u32, u32)>,
/// Extracted edges for marching ants rendering.
_marching_ants_edges: Option<std::sync::Arc<Vec<(u32, u32, u32, u32)>>>,
/// Exact horizontal selected-pixel spans for irregular selection fill.
_selection_fill_spans: Option<std::sync::Arc<Vec<(u32, u32, u32)>>>,
/// Whether selected pixels use quick-mask red instead of the normal blue tint.
_quick_mask: bool,
/// Active brush diameter in canvas pixels for the footprint cursor.
@@ -716,17 +713,11 @@ impl OverlayProgram {
draw_ellipse(frame, top_cy);
draw_ellipse(frame, bot_cy);
frame.stroke(
&Path::line(
Point::new(left_scr, top_cy),
Point::new(left_scr, bot_cy),
),
&Path::line(Point::new(left_scr, top_cy), Point::new(left_scr, bot_cy)),
stroke_style.clone(),
);
frame.stroke(
&Path::line(
Point::new(right_scr, top_cy),
Point::new(right_scr, bot_cy),
),
&Path::line(Point::new(right_scr, top_cy), Point::new(right_scr, bot_cy)),
stroke_style.clone(),
);
}
@@ -872,8 +863,8 @@ impl OverlayProgram {
let end_angle = std::f32::consts::PI * 1.2;
let crescent_path = Path::new(|b| {
for i in 0..=segments {
let a = start_angle
+ (end_angle - start_angle) * i as f32 / segments as f32;
let a =
start_angle + (end_angle - start_angle) * i as f32 / segments as f32;
let hx = cx + rx * a.cos();
let hy = cy + ry * a.sin();
let (rpx, rpy) = rotate(hx, hy);
@@ -1787,9 +1778,9 @@ impl canvas::Program<Message> for OverlayProgram {
// clearly visible during fast pointer movements.
if let Some(preview) = self.vector_drag_preview.as_ref() {
let stroke_style = Stroke {
style: canvas::stroke::Style::Solid(
iced::Color::from_rgba(0.2, 0.9, 0.4, 0.95),
),
style: canvas::stroke::Style::Solid(iced::Color::from_rgba(
0.2, 0.9, 0.4, 0.95,
)),
width: (2.5 / self.zoom.max(1.0)).max(1.0),
..Default::default()
};
@@ -1874,10 +1865,7 @@ impl canvas::Program<Message> for OverlayProgram {
let end_y = cy + indicator_radius * snap_angle.sin();
let radial_line = Path::line(
Point::new(origin_x + cx * self.zoom, origin_y + cy * self.zoom),
Point::new(
origin_x + end_x * self.zoom,
origin_y + end_y * self.zoom,
),
Point::new(origin_x + end_x * self.zoom, origin_y + end_y * self.zoom),
);
frame.stroke(
&radial_line,
@@ -1895,10 +1883,7 @@ impl canvas::Program<Message> for OverlayProgram {
// Small dot at arc end
frame.fill(
&Path::circle(
Point::new(
origin_x + end_x * self.zoom,
origin_y + end_y * self.zoom,
),
Point::new(origin_x + end_x * self.zoom, origin_y + end_y * self.zoom),
3.0,
),
snap_color,
@@ -2226,16 +2211,32 @@ pub fn view<'a>(
let pan_offset = doc.pan_offset;
// ── Shader canvas (main rendering) ───────────────────────────────────
let dirty_region = doc.dirty_region.take();
let full_upload = doc.full_upload.replace(false);
let texture_update = if full_upload {
None
} else {
dirty_region.and_then(|region| {
match TextureUpdate::pack(&doc.composite_raw, engine_w, region) {
Ok(update) => Some(update),
Err(error) => {
log::error!("failed to pack canvas texture update: {error}");
doc.full_upload.replace(true);
None
}
}
})
};
let shader_program = CanvasShaderProgram {
engine_w,
engine_h,
zoom,
pan_offset,
composite_pixels: doc.composite_pixels.clone(),
dirty_region: doc.dirty_region.take(),
full_upload: doc.full_upload.replace(false),
texture_update,
full_upload,
space_pan: tool_state.space_pan,
selection_mask: doc.selection_mask.clone(),
selection_mask: doc.selection_texture.clone(),
selection_dirty: doc.selection_mask_dirty.get(),
anim_time: marching_ants_offset * 2.0, // Convert 0..1 to seconds (2s cycle)
quick_mask: doc.quick_mask,
@@ -2301,8 +2302,6 @@ pub fn view<'a>(
gradient_drag: doc.gradient_drag,
lasso_points: doc.lasso_points.clone(),
polygon_points: doc.polygon_points.clone(),
_marching_ants_edges: doc.marching_ants_edges.clone(),
_selection_fill_spans: doc.selection_fill_spans.clone(),
_quick_mask: doc.quick_mask,
brush_size: tool_state.brush_size,
selected_vector_bounds: sel_vec_bounds,
@@ -16,6 +16,7 @@
//! All automated agents must preserve these structures and keep the wgpu/advanced feature dependencies.
//!
use super::texture_update::{SharedCompositePixels, TextureUpdate};
use crate::app::Message;
use iced::advanced::Shell;
use iced::mouse;
@@ -573,37 +574,9 @@ impl CanvasShaderPipeline {
///
/// ## Arguments
/// * `queue` — wgpu queue for the upload
/// * `region` — dirty bounds [x0, y0, x1, y1] in canvas pixels
/// * `full_buffer` — the complete composite_raw buffer (row-major RGBA)
/// * `canvas_w` — engine canvas width
fn upload_dirty_region(
&self,
queue: &wgpu::Queue,
region: [u32; 4],
full_buffer: &[u8],
canvas_w: u32,
) {
let [x0, y0, x1, y1] = region;
let rw = x1.saturating_sub(x0);
let rh = y1.saturating_sub(y0);
if rw == 0 || rh == 0 {
return;
}
// Extract the sub-region rows from the full buffer into a contiguous block
let row_bytes = (rw * 4) as usize;
let mut region_data = vec![0u8; (rw * rh * 4) as usize];
for row in 0..rh {
let src_offset = ((y0 + row) as usize * canvas_w as usize + x0 as usize) * 4;
let dst_offset = (row as usize) * row_bytes;
let src_end = src_offset + row_bytes;
if src_end <= full_buffer.len() && dst_offset + row_bytes <= region_data.len() {
region_data[dst_offset..dst_offset + row_bytes]
.copy_from_slice(&full_buffer[src_offset..src_end]);
}
}
/// * `update` — tightly packed immutable dirty-region pixels
fn upload_dirty_region(&self, queue: &wgpu::Queue, update: &TextureUpdate) {
let [x0, y0, _, _] = update.region;
let t0 = std::time::Instant::now();
queue.write_texture(
wgpu::ImageCopyTexture {
@@ -612,22 +585,22 @@ impl CanvasShaderPipeline {
origin: wgpu::Origin3d { x: x0, y: y0, z: 0 },
aspect: wgpu::TextureAspect::All,
},
&region_data,
&update.pixels,
wgpu::ImageDataLayout {
offset: 0,
bytes_per_row: Some(rw * 4),
rows_per_image: Some(rh),
bytes_per_row: Some(update.bytes_per_row),
rows_per_image: Some(update.height()),
},
wgpu::Extent3d {
width: rw,
height: rh,
width: update.width(),
height: update.height(),
depth_or_array_layers: 1,
},
);
let elapsed = t0.elapsed();
log::debug!(
"[CanvasShaderPipeline::upload_dirty_region] {}×{} region at ({},{}) → {:.2}ms, {} bytes",
rw, rh, x0, y0, elapsed.as_secs_f64() * 1000.0, region_data.len()
update.width(), update.height(), x0, y0, elapsed.as_secs_f64() * 1000.0, update.pixels.len()
);
}
@@ -701,17 +674,16 @@ pub struct CanvasShaderPrimitive {
pub zoom: f32,
/// Pan offset in screen pixels
pub pan_offset: Vector,
/// Dirty region from the engine [x0, y0, x1, y1], if any
pub dirty_region: Option<[u32; 4]>,
/// Full composite RGBA pixel data (shared reference via Arc)
/// Only the dirty sub-region is actually uploaded
pub composite_pixels: std::sync::Arc<Vec<u8>>,
/// Tightly packed dirty-region pixels for a normal partial upload.
pub texture_update: Option<TextureUpdate>,
/// Stable complete RGBA recovery image for pipeline creation and resize.
pub composite_pixels: SharedCompositePixels,
/// Whether this is a full texture upload (resize or first frame)
pub full_upload: bool,
/// Absolute widget bounds in the window
pub bounds: Rectangle,
/// Selection mask data (1 byte per pixel, >128 = selected), if any
pub selection_mask: Option<Vec<u8>>,
/// Encoded selection data (0 unselected, 128 interior, 255 border), if any.
pub selection_mask: Option<std::sync::Arc<Vec<u8>>>,
/// Whether the selection mask has changed since last upload
pub selection_dirty: bool,
/// Animation time in seconds for marching ants
@@ -740,15 +712,17 @@ impl shader::Primitive for CanvasShaderPrimitive {
let t0 = std::time::Instant::now();
// ── Create or retrieve pipeline ──────────────────────────────────
if !storage.has::<CanvasShaderPipeline>() {
let created_pipeline = !storage.has::<CanvasShaderPipeline>();
if created_pipeline {
log::info!("[CanvasShaderPrimitive::prepare] First frame — creating pipeline");
let pixels = self.composite_pixels.read();
let pipeline = CanvasShaderPipeline::new(
device,
queue,
format,
self.canvas_w,
self.canvas_h,
&self.composite_pixels,
&pixels,
);
storage.store(pipeline);
}
@@ -756,26 +730,20 @@ impl shader::Primitive for CanvasShaderPrimitive {
let pipeline = storage.get_mut::<CanvasShaderPipeline>().unwrap();
// ── Resize texture if canvas dimensions changed ──────────────────
if pipeline.texture_w != self.canvas_w || pipeline.texture_h != self.canvas_h {
pipeline.resize_texture(
device,
queue,
self.canvas_w,
self.canvas_h,
&self.composite_pixels,
);
} else if self.full_upload {
if !created_pipeline
&& (pipeline.texture_w != self.canvas_w || pipeline.texture_h != self.canvas_h)
{
let pixels = self.composite_pixels.read();
pipeline.resize_texture(device, queue, self.canvas_w, self.canvas_h, &pixels);
} else if !created_pipeline && self.full_upload {
// Full upload requested (e.g., after loading a file)
pipeline.resize_texture(
device,
queue,
self.canvas_w,
self.canvas_h,
&self.composite_pixels,
);
} else if let Some(region) = self.dirty_region {
let pixels = self.composite_pixels.read();
pipeline.resize_texture(device, queue, self.canvas_w, self.canvas_h, &pixels);
} else if !created_pipeline {
// ── Partial upload: only the dirty sub-region ────────────────
pipeline.upload_dirty_region(queue, region, &self.composite_pixels, self.canvas_w);
if let Some(update) = self.texture_update.as_ref() {
pipeline.upload_dirty_region(queue, update);
}
}
// ── Upload selection mask if changed ──────────────────────────────
@@ -939,16 +907,16 @@ pub struct CanvasShaderProgram {
pub zoom: f32,
/// Pan offset in screen pixels (relative to centered position).
pub pan_offset: Vector,
/// Full composite RGBA pixel buffer (shared via Arc to avoid copies).
pub composite_pixels: std::sync::Arc<Vec<u8>>,
/// Dirty region from the last engine composite [x0, y0, x1, y1].
pub dirty_region: Option<[u32; 4]>,
/// Stable complete RGBA recovery image used only for full uploads.
pub composite_pixels: SharedCompositePixels,
/// Tightly packed dirty-region pixels for the next partial upload.
pub texture_update: Option<TextureUpdate>,
/// Whether a full texture upload is needed (first frame, resize, file load).
pub full_upload: bool,
/// Whether Space temporarily changes left-drag into panning.
pub space_pan: bool,
/// Selection mask data (1 byte per pixel, >128 = selected), if any.
pub selection_mask: Option<Vec<u8>>,
/// Encoded selection data (0 unselected, 128 interior, 255 border), if any.
pub selection_mask: Option<std::sync::Arc<Vec<u8>>>,
/// Whether the selection mask has changed since last upload.
pub selection_dirty: bool,
/// Animation time in seconds for marching ants.
@@ -1279,7 +1247,7 @@ impl shader::Program<Message> for CanvasShaderProgram {
canvas_h: self.engine_h,
zoom: self.zoom,
pan_offset: self.pan_offset,
dirty_region: self.dirty_region,
texture_update: self.texture_update.clone(),
composite_pixels: self.composite_pixels.clone(),
full_upload: self.full_upload,
bounds,
@@ -1304,3 +1272,13 @@ impl shader::Program<Message> for CanvasShaderProgram {
}
}
}
#[cfg(test)]
mod shader_regression_tests {
/// Prevents restoration of the nine-sample per-fragment selection border detector.
#[test]
fn selection_overlay_uses_one_texture_sample() {
let shader = include_str!("canvas.wgsl");
assert_eq!(shader.matches("textureSample(selection_texture").count(), 1);
}
}
@@ -0,0 +1,290 @@
//! CPU-side staging for persistent canvas texture updates.
//!
//! **Purpose:** Keeps the shader's recovery image in stable shared storage while carrying ordinary
//! frame updates as tightly packed dirty rectangles. **Logic & Workflow:** Engine pixels are copied
//! into the shared full image by row, then the final per-frame dirty union is packed once for wgpu.
//! **Side Effects / Dependencies:** Uses a standard-library read/write lock because iced may retain
//! shader primitives across application updates; no lock is held during normal partial GPU uploads.
use std::sync::{Arc, RwLock, RwLockReadGuard};
/// Stable full-canvas pixels used only for pipeline creation, resize, and renderer recovery.
#[derive(Clone, Debug)]
pub struct SharedCompositePixels {
pixels: Arc<RwLock<Vec<u8>>>,
}
impl SharedCompositePixels {
/// Creates shared storage from one complete RGBA image.
///
/// **Arguments:** `pixels` is a row-major RGBA image. **Returns:** Shared stable storage.
/// **Side Effects / Dependencies:** Allocates once and initializes an `RwLock`.
pub fn new(pixels: Vec<u8>) -> Self {
Self {
pixels: Arc::new(RwLock::new(pixels)),
}
}
/// Replaces the complete recovery image after a document or canvas-size change.
///
/// **Arguments:** `pixels` is the new complete RGBA image. **Returns:** Nothing.
/// **Side Effects / Dependencies:** Takes the write lock and may resize the backing allocation.
pub fn replace(&self, pixels: &[u8]) {
let mut target = self
.pixels
.write()
.unwrap_or_else(|error| error.into_inner());
target.clear();
target.extend_from_slice(pixels);
}
/// Copies one RGBA rectangle from a complete source image into the recovery image.
///
/// **Arguments:** `source` is a full row-major RGBA image, `canvas_w` is its pixel width, and
/// `region` is an exclusive `[x0, y0, x1, y1]` rectangle. **Returns:** Copied byte count, or an
/// error for malformed dimensions/bounds. **Side Effects / Dependencies:** Takes the write lock
/// only while copying the selected rows.
pub fn copy_region_from(
&self,
source: &[u8],
canvas_w: u32,
region: [u32; 4],
) -> Result<usize, &'static str> {
let validated = ValidatedRegion::new(source.len(), canvas_w, region, 4)?;
let mut target = self
.pixels
.write()
.unwrap_or_else(|error| error.into_inner());
if target.len() != source.len() {
return Err("shared composite length does not match source");
}
for row in 0..validated.height {
let offset = validated.source_offset(row);
let end = offset + validated.row_bytes;
target[offset..end].copy_from_slice(&source[offset..end]);
}
Ok(validated.row_bytes * validated.height)
}
/// Borrows the complete image for a rare full GPU upload.
///
/// **Arguments:** None. **Returns:** A read guard exposing the full RGBA bytes.
/// **Side Effects / Dependencies:** Takes a read lock; callers must keep its scope short.
pub fn read(&self) -> RwLockReadGuard<'_, Vec<u8>> {
self.pixels
.read()
.unwrap_or_else(|error| error.into_inner())
}
#[cfg(test)]
fn allocation_identity(&self) -> *const RwLock<Vec<u8>> {
Arc::as_ptr(&self.pixels)
}
}
/// One tightly packed RGBA rectangle ready for `wgpu::Queue::write_texture`.
#[derive(Clone, Debug)]
pub struct TextureUpdate {
/// Exclusive canvas-space bounds `[x0, y0, x1, y1]`.
pub region: [u32; 4],
/// Number of bytes in one packed row.
pub bytes_per_row: u32,
/// Immutable packed RGBA rows.
pub pixels: Arc<[u8]>,
}
impl TextureUpdate {
/// Packs one dirty RGBA rectangle from a complete image.
///
/// **Arguments:** `source` is a full row-major RGBA image, `canvas_w` its width, and `region`
/// exclusive canvas bounds. **Returns:** A tightly packed upload payload or a validation error.
/// **Side Effects / Dependencies:** Allocates exactly `dirty_width * dirty_height * 4` bytes.
pub fn pack(source: &[u8], canvas_w: u32, region: [u32; 4]) -> Result<Self, &'static str> {
let validated = ValidatedRegion::new(source.len(), canvas_w, region, 4)?;
let mut pixels = vec![0; validated.row_bytes * validated.height];
for row in 0..validated.height {
let source_offset = validated.source_offset(row);
let target_offset = row * validated.row_bytes;
pixels[target_offset..target_offset + validated.row_bytes]
.copy_from_slice(&source[source_offset..source_offset + validated.row_bytes]);
}
Ok(Self {
region,
bytes_per_row: validated.row_bytes as u32,
pixels: pixels.into(),
})
}
/// Returns the packed rectangle width.
///
/// **Arguments:** None. **Returns:** Width in pixels. **Side Effects / Dependencies:** None.
pub fn width(&self) -> u32 {
self.region[2] - self.region[0]
}
/// Returns the packed rectangle height.
///
/// **Arguments:** None. **Returns:** Height in pixels. **Side Effects / Dependencies:** None.
pub fn height(&self) -> u32 {
self.region[3] - self.region[1]
}
}
/// Validated row-copy geometry shared by recovery and upload staging.
struct ValidatedRegion {
canvas_w: usize,
x0: usize,
y0: usize,
height: usize,
row_bytes: usize,
bytes_per_pixel: usize,
}
impl ValidatedRegion {
/// Validates a rectangle against a complete tightly packed image.
///
/// **Arguments:** Image length, width, exclusive region, and channel byte count. **Returns:**
/// Safe row-copy geometry or an error. **Side Effects / Dependencies:** None.
fn new(
source_len: usize,
canvas_w: u32,
region: [u32; 4],
bytes_per_pixel: usize,
) -> Result<Self, &'static str> {
let [x0, y0, x1, y1] = region;
if canvas_w == 0 || x0 >= x1 || y0 >= y1 || x1 > canvas_w {
return Err("invalid dirty rectangle");
}
let row_stride = canvas_w as usize * bytes_per_pixel;
if row_stride == 0 || !source_len.is_multiple_of(row_stride) {
return Err("source length does not match canvas width");
}
let canvas_h = source_len / row_stride;
if y1 as usize > canvas_h {
return Err("dirty rectangle exceeds canvas height");
}
Ok(Self {
canvas_w: canvas_w as usize,
x0: x0 as usize,
y0: y0 as usize,
height: (y1 - y0) as usize,
row_bytes: (x1 - x0) as usize * bytes_per_pixel,
bytes_per_pixel,
})
}
/// Computes one source row's byte offset.
///
/// **Arguments:** `row` is relative to the validated rectangle. **Returns:** Byte offset into
/// the complete image. **Side Effects / Dependencies:** None.
fn source_offset(&self, row: usize) -> usize {
((self.y0 + row) * self.canvas_w + self.x0) * self.bytes_per_pixel
}
}
#[cfg(test)]
mod tests {
use super::{SharedCompositePixels, TextureUpdate};
use std::sync::Arc;
const WIDTH: u32 = 3840;
const HEIGHT: u32 = 2160;
const FULL_BYTES: usize = WIDTH as usize * HEIGHT as usize * 4;
/// Verifies retained shader references cannot trigger a full-canvas clone in the new path.
#[test]
fn retained_shader_reference_keeps_stable_allocation_and_dirty_budget() {
let mut source = vec![0; FULL_BYTES];
let shared = SharedCompositePixels::new(source.clone());
let shader_reference = shared.clone();
let identity = shared.allocation_identity();
let mut copied = 0usize;
let mut uploaded = 0usize;
for sample in 0..120u32 {
let x0 = 100 + sample;
let region = [x0, 200, x0 + 48, 248];
let marker = sample as u8;
for y in region[1]..region[3] {
let start = ((y * WIDTH + region[0]) * 4) as usize;
source[start..start + 48 * 4].fill(marker);
}
copied += shared.copy_region_from(&source, WIDTH, region).unwrap();
let update = TextureUpdate::pack(&source, WIDTH, region).unwrap();
copied += update.pixels.len();
uploaded += update.pixels.len();
assert_eq!(update.pixels.len(), 48 * 48 * 4);
}
assert_eq!(identity, shader_reference.allocation_identity());
assert_eq!(copied, 120 * 48 * 48 * 4 * 2);
assert_eq!(uploaded, 120 * 48 * 48 * 4);
assert_eq!(&*shared.read(), &source);
}
/// Reproduces why the removed copy-on-write fallback cloned 33 MB per ordinary 4K update.
#[test]
fn legacy_arc_copy_on_write_reproduction_is_full_canvas() {
let mut pixels = Arc::new(vec![0u8; FULL_BYTES]);
let _shader_reference = pixels.clone();
assert!(Arc::get_mut(&mut pixels).is_none());
let fallback = Arc::new(pixels.as_ref().clone());
assert_eq!(fallback.len(), 33_177_600);
}
/// Confirms packed uploads preserve exact rows and reject malformed rectangles.
#[test]
fn packed_update_is_exact_and_bounds_checked() {
let source: Vec<u8> = (0..6 * 4 * 4).map(|value| value as u8).collect();
let update = TextureUpdate::pack(&source, 6, [2, 1, 5, 3]).unwrap();
assert_eq!(update.width(), 3);
assert_eq!(update.height(), 2);
assert_eq!(update.bytes_per_row, 12);
assert_eq!(&update.pixels[..12], &source[32..44]);
assert_eq!(&update.pixels[12..], &source[56..68]);
assert!(TextureUpdate::pack(&source, 6, [5, 1, 2, 3]).is_err());
assert!(TextureUpdate::pack(&source, 6, [0, 0, 7, 1]).is_err());
assert!(TextureUpdate::pack(&source, 6, [0, 0, 1, 5]).is_err());
}
/// Reports repeatable GUI staging latency and byte volume for a 4K stroke workload.
#[test]
#[ignore = "diagnostic benchmark; run with --ignored --nocapture"]
fn diagnostic_4k_dirty_staging_latency() {
let mut source = vec![0u8; FULL_BYTES];
let shared = SharedCompositePixels::new(source.clone());
let mut samples = Vec::with_capacity(120);
let mut dirty_bytes = 0usize;
for sample in 0..130u32 {
let x0 = 200 + sample * 8;
let region = [x0, 700, x0 + 48, 748];
let start = std::time::Instant::now();
for y in region[1]..region[3] {
let offset = ((y * WIDTH + region[0]) * 4) as usize;
source[offset..offset + 48 * 4].fill(sample as u8);
}
shared.copy_region_from(&source, WIDTH, region).unwrap();
let update = TextureUpdate::pack(&source, WIDTH, region).unwrap();
if sample >= 10 {
samples.push(start.elapsed().as_secs_f64() * 1000.0);
dirty_bytes += update.pixels.len();
}
}
samples.sort_by(f64::total_cmp);
let percentile = |fraction: f64| {
let index = ((samples.len() - 1) as f64 * fraction).round() as usize;
samples[index]
};
let legacy_bytes = samples.len() * FULL_BYTES;
println!(
"4K iced staging: p50={:.3}ms p95={:.3}ms max={:.3}ms dirty={}B legacy_cow={}B reduction={:.0}x",
percentile(0.5),
percentile(0.95),
samples[samples.len() - 1],
dirty_bytes,
legacy_bytes,
legacy_bytes as f64 / dirty_bytes as f64,
);
assert_eq!(dirty_bytes, 120 * 48 * 48 * 4);
}
}
@@ -73,11 +73,89 @@ pub fn mask_bounds(mask: &[u8], width: u32, height: u32) -> Option<(u32, u32, u3
))
}
/// Compresses selected pixels into horizontal spans for efficient irregular overlay filling.
/// Encoded R8 selection texture and exact non-zero source bounds.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EncodedSelection {
/// Per-pixel values: zero is unselected, 128 is interior, and 255 is border.
pub pixels: Vec<u8>,
/// Exact `(x, y, width, height)` bounds of the source mask.
pub bounds: Option<(u32, u32, u32, u32)>,
}
/// Encodes selection membership and its eight-neighbor border into one GPU texture.
///
/// **Arguments:** `mask` is one alpha byte per pixel and `width`/`height` are its dimensions.
/// **Returns:** One R8 byte per pixel plus exact selected bounds; invalid dimensions return an empty
/// texture. **Logic & Workflow:** Selected pixels become interior unless an in-bounds neighboring
/// pixel is unselected. Out-of-bounds neighbors are ignored to match the shader's clamp-to-edge
/// sampling behavior. **Side Effects / Dependencies:** None.
pub fn encode_selection_texture(mask: &[u8], width: u32, height: u32) -> EncodedSelection {
if width == 0 || height == 0 || mask.len() != (width * height) as usize {
return EncodedSelection {
pixels: Vec::new(),
bounds: None,
};
}
let mut pixels = vec![0; mask.len()];
let mut min_x = width;
let mut min_y = height;
let mut max_x = 0;
let mut max_y = 0;
let mut found = false;
for y in 0..height {
for x in 0..width {
let index = (y * width + x) as usize;
if mask[index] <= 127 {
continue;
}
found = true;
min_x = min_x.min(x);
min_y = min_y.min(y);
max_x = max_x.max(x);
max_y = max_y.max(y);
let mut border = false;
for dy in -1i32..=1 {
for dx in -1i32..=1 {
if dx == 0 && dy == 0 {
continue;
}
let nx = x as i32 + dx;
let ny = y as i32 + dy;
if nx >= 0
&& ny >= 0
&& nx < width as i32
&& ny < height as i32
&& mask[(ny as u32 * width + nx as u32) as usize] <= 127
{
border = true;
break;
}
}
if border {
break;
}
}
pixels[index] = if border { 255 } else { 128 };
}
}
EncodedSelection {
pixels,
bounds: found.then_some((
min_x,
min_y,
max_x.saturating_sub(min_x).saturating_add(1),
max_y.saturating_sub(min_y).saturating_add(1),
)),
}
}
/// Compresses selected pixels into horizontal spans for non-GPU callers.
///
/// **Arguments:** `mask` is one alpha byte per pixel and `width`/`height` are its dimensions.
/// **Returns:** Inclusive-exclusive `(x_start, y, x_end)` spans for alpha values above 127.
/// **Side Effects / Dependencies:** None.
/// **Side Effects / Dependencies:** None. The iced canvas no longer invokes this during frames;
/// the function remains part of the testable selection-state API.
pub fn selected_spans(mask: &[u8], width: u32, height: u32) -> Vec<(u32, u32, u32)> {
if width == 0 || height == 0 || mask.len() != (width * height) as usize {
return Vec::new();
@@ -100,3 +178,40 @@ pub fn selected_spans(mask: &[u8], width: u32, height: u32) -> Vec<(u32, u32, u3
}
spans
}
#[cfg(test)]
mod encoded_selection_tests {
use super::encode_selection_texture;
/// Verifies interior, border, empty, and clamp-to-edge classifications.
#[test]
fn selection_texture_encodes_membership_and_border_once() {
let mut mask = vec![0; 25];
for y in 1..4 {
for x in 1..4 {
mask[y * 5 + x] = 255;
}
}
let encoded = encode_selection_texture(&mask, 5, 5);
assert_eq!(encoded.bounds, Some((1, 1, 3, 3)));
assert_eq!(encoded.pixels[2 * 5 + 2], 128);
assert_eq!(encoded.pixels[1 * 5 + 1], 255);
assert_eq!(encoded.pixels[0], 0);
let full = encode_selection_texture(&vec![255; 25], 5, 5);
assert!(full.pixels.iter().all(|value| *value == 128));
assert_eq!(full.bounds, Some((0, 0, 5, 5)));
let empty = encode_selection_texture(&vec![0; 25], 5, 5);
assert!(empty.pixels.iter().all(|value| *value == 0));
assert_eq!(empty.bounds, None);
}
/// Confirms feathered coverage follows the shader's greater-than-127 membership threshold.
#[test]
fn feathered_mask_uses_existing_threshold() {
let encoded = encode_selection_texture(&[127, 128, 255], 3, 1);
assert_eq!(encoded.pixels, vec![0, 255, 128]);
assert_eq!(encoded.bounds, Some((1, 0, 2, 1)));
}
}