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:
@@ -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,
|
||||
},
|
||||
®ion_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)));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user