From b2c88e5471dbde3a5925cff92c882a026b651847 Mon Sep 17 00:00:00 2001 From: Halit Can Date: Fri, 17 Jul 2026 23:15:27 +0300 Subject: [PATCH] feat(canvas): enhance composite shader with selection overlay and marching ants - Updated the canvas composite shader to include a selection mask fill and animated marching ants border. - Added new uniforms for animation time, selection mask presence, and quick mask mode. - Modified the fragment shader to handle selection rendering, including color tinting for selected areas and a dash pattern for the marching ants effect. - Refactored the OverlayProgram to remove the old marching ants rendering logic, now handled by the GPU shader. - Introduced new data structures in the shader pipeline for managing selection mask textures and samplers. - Implemented functionality to upload selection mask data to the GPU. - Updated selection state handling to ensure correct bounds calculations. - Enhanced sidebar tool button rendering with fallback for missing icons. --- hcie-engine-api/src/lib.rs | 55 +++++ hcie-iced-app/crates/hcie-iced-gui/src/app.rs | 229 +++++++++++++++--- .../src/canvas/.mimocode/.cron-lock | 1 + .../hcie-iced-gui/src/canvas/canvas.wgsl | 122 ++++++++-- .../crates/hcie-iced-gui/src/canvas/mod.rs | 214 ++++++---------- .../hcie-iced-gui/src/canvas/shader_canvas.rs | 181 +++++++++++++- .../hcie-iced-gui/src/selection/state.rs | 7 +- .../crates/hcie-iced-gui/src/sidebar/mod.rs | 34 ++- 8 files changed, 634 insertions(+), 209 deletions(-) create mode 100644 hcie-iced-app/crates/hcie-iced-gui/src/canvas/.mimocode/.cron-lock diff --git a/hcie-engine-api/src/lib.rs b/hcie-engine-api/src/lib.rs index 96bd490..15a9fb2 100644 --- a/hcie-engine-api/src/lib.rs +++ b/hcie-engine-api/src/lib.rs @@ -751,6 +751,9 @@ impl Engine { // ── Filter Ops ─────────────────────────────────────────────────────── pub fn apply_filter(&mut self, filter_id: &str, params: serde_json::Value) { + // Clone the selection mask before borrowing the layer mutably, to avoid + // borrow-checker conflict between `active_layer_mut()` and `selection_mask`. + let mask = self.document.selection_mask.clone(); if let Some(layer) = self.document.active_layer_mut() { let before_pixels = layer.pixels.clone(); let _ = apply_filter( @@ -760,6 +763,30 @@ impl Engine { layer.width, layer.height, ); + // When a selection is active, restore original pixels for unselected + // areas so the filter is confined to the selected region. + if let Some(ref mask) = mask { + let w = layer.width as usize; + let h = layer.height as usize; + if mask.len() == w * h { + for y in 0..h { + for x in 0..w { + let idx = y * w + x; + if mask[idx] == 0 { + let pidx = idx * 4; + if pidx + 3 < layer.pixels.len() + && pidx + 3 < before_pixels.len() + { + layer.pixels[pidx] = before_pixels[pidx]; + layer.pixels[pidx + 1] = before_pixels[pidx + 1]; + layer.pixels[pidx + 2] = before_pixels[pidx + 2]; + layer.pixels[pidx + 3] = before_pixels[pidx + 3]; + } + } + } + } + } + } layer.dirty = true; self.document.push_draw_snapshot( self.document.active_layer, @@ -773,7 +800,11 @@ impl Engine { } pub fn apply_filter_preview(&mut self, filter_id: &str, params: serde_json::Value) { + let mask = self.document.selection_mask.clone(); if let Some(layer) = self.document.active_layer_mut() { + // Save the current pixels before applying the filter, so we can + // restore unselected areas afterwards. + let saved_before = layer.pixels.clone(); let _ = apply_filter( filter_id, ¶ms, @@ -781,6 +812,30 @@ impl Engine { layer.width, layer.height, ); + // When a selection is active, restore original pixels for unselected + // areas so the filter preview is confined to the selected region. + if let Some(ref mask) = mask { + let w = layer.width as usize; + let h = layer.height as usize; + if mask.len() == w * h { + for y in 0..h { + for x in 0..w { + let idx = y * w + x; + if mask[idx] == 0 { + let pidx = idx * 4; + if pidx + 3 < layer.pixels.len() + && pidx + 3 < saved_before.len() + { + layer.pixels[pidx] = saved_before[pidx]; + layer.pixels[pidx + 1] = saved_before[pidx + 1]; + layer.pixels[pidx + 2] = saved_before[pidx + 2]; + layer.pixels[pidx + 3] = saved_before[pidx + 3]; + } + } + } + } + } + } layer.dirty = true; self.document.composite_dirty = true; } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs index 0ce8509..187254a 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs @@ -386,8 +386,14 @@ pub struct IcedDocument { pub internal_clipboard: Option, /// Selection mask (alpha channel) for the current selection pub selection_mask: Option>, + /// Whether the selection mask has changed since last GPU upload + pub selection_mask_dirty: std::cell::Cell, /// Selection bounds (x, y, width, height) pub selection_bounds: Option<(u32, u32, u32, u32)>, + /// History of selection masks for undo support. + pub selection_history: Vec>>, + /// Engine history index when the selection was last modified. + pub selection_history_marker: i32, /// Crop tool state pub crop_state: CropState, /// Accumulated lasso points (freehand selection) in canvas coordinates. @@ -1142,6 +1148,14 @@ impl HcieIcedApp { /// through `hcie-engine-api` when add/subtract/intersect mode is active. fn combine_current_selection(&mut self, previous: Option>) { let doc = &mut self.documents[self.active_doc]; + + let current_marker = doc.engine.history_current(); + if current_marker != doc.selection_history_marker { + doc.selection_history.clear(); + doc.selection_history_marker = current_marker; + } + doc.selection_history.push(previous.clone()); + let Some(new_mask) = doc.engine.get_selection_mask().map(ToOwned::to_owned) else { return; }; @@ -1194,7 +1208,10 @@ impl HcieIcedApp { transform_source: None, internal_clipboard: None, selection_mask: None, + selection_mask_dirty: std::cell::Cell::new(false), selection_bounds: None, + selection_history: Vec::new(), + selection_history_marker: 0, crop_state: CropState::default(), lasso_points: Vec::new(), polygon_points: Vec::new(), @@ -1673,22 +1690,89 @@ impl HcieIcedApp { let doc = &mut self.documents[self.active_doc]; if doc.engine.is_composite_dirty() { - // Use get_composite_pixels() instead of render_composite_region() - // to avoid segfaults when canvas dimensions change (e.g. after PSD import). - // get_composite_pixels() returns a safe Vec via composite_layers(). - let composite = doc.engine.get_composite_pixels(); - let size = composite.len(); - if size > 0 { - doc.composite_raw = composite; - // Update the shared Arc — if no other reference exists, mutate in-place - if let Some(pixels) = Arc::get_mut(&mut doc.composite_pixels) { - pixels.resize(doc.composite_raw.len(), 0); - pixels.copy_from_slice(&doc.composite_raw); - } else { - doc.composite_pixels = Arc::new(doc.composite_raw.clone()); + let (dirty_rect, ptr, len) = doc.engine.render_composite_region(); + if len > 0 && !ptr.is_null() { + let mut is_full_copy = false; + // Resize composite_raw if canvas dimensions changed (e.g. after PSD import) + if doc.composite_raw.len() != len { + doc.composite_raw.resize(len, 0); + doc.full_upload.replace(true); + is_full_copy = true; } - doc.full_upload.replace(true); - doc.dirty_region.replace(None); + + // If the canvas changed size, we MUST force a full upload because the old memory + // 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; + let x1 = x1 as usize; + let y1 = y1 as usize; + + 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), + 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()); + } + } + } + + let current = doc.dirty_region.replace(None); + doc.dirty_region.replace(Some(union_regions(current, rect))); + } else { + // Full update + 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.full_upload.replace(true); + doc.dirty_region.replace(None); + } + doc.render_generation = doc.render_generation.wrapping_add(1); } @@ -1698,6 +1782,7 @@ impl HcieIcedApp { // 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(), @@ -1718,6 +1803,7 @@ impl HcieIcedApp { 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; @@ -2202,6 +2288,7 @@ impl HcieIcedApp { } doc.engine.selection_clear(); doc.selection_mask = Some(mask_data); + doc.selection_mask_dirty.set(true); doc.transform_source = Some(tr.clone()); doc.selection_transform = Some(tr); } @@ -2288,6 +2375,20 @@ impl HcieIcedApp { } if self.tool_state.is_drawing { + // Selection tools (Lasso, Polygon, Vision, Rect) use is_drawing + // to track drag state but must not enter the brush code path. + let is_selection_tool = matches!( + self.tool_state.active_tool, + Tool::Lasso + | Tool::PolygonSelect + | Tool::Select + | Tool::VisionSelect + | Tool::MagicWand + | Tool::SmartSelect + ); + if is_selection_tool { + // Fall through to the dedicated selection drag handlers below. + } else { let is_ctrl = self.modifiers.control() || self.modifiers.logo(); if is_ctrl { // Pick color @@ -2388,6 +2489,7 @@ impl HcieIcedApp { self.tool_state.last_stroke_pos = Some((x, y)); } } + } // end else (non-selection tool) } // Handle selection drag @@ -2503,6 +2605,19 @@ impl HcieIcedApp { } if self.tool_state.is_drawing { + // Selection tools use is_drawing to track drag state but never + // start a brush stroke — skip end_stroke / commit_pending_history + // so they don't push a bogus undo entry that empties the canvas. + let is_selection_tool = matches!( + self.tool_state.active_tool, + Tool::Lasso + | Tool::PolygonSelect + | Tool::Select + | Tool::VisionSelect + | Tool::MagicWand + | Tool::SmartSelect + ); + if !is_selection_tool { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); self.documents[self.active_doc].engine.end_stroke(layer_id); self.documents[self.active_doc] @@ -2514,6 +2629,7 @@ impl HcieIcedApp { self.documents[self.active_doc].modified = true; self.refresh_composite_if_needed(); + } // end if !is_selection_tool } // End selection drag @@ -2599,10 +2715,26 @@ impl HcieIcedApp { Message::CompositeRefresh | Message::Undo | Message::Redo => { match message { Message::Undo => { - self.documents[self.active_doc].engine.undo(); + let doc = &mut self.documents[self.active_doc]; + let current_marker = doc.engine.history_current(); + if current_marker == doc.selection_history_marker && !doc.selection_history.is_empty() { + // Undo selection + if let Some(prev_mask) = doc.selection_history.pop() { + if let Some(mask) = prev_mask { + doc.engine.set_selection_mask(mask); + } else { + doc.engine.selection_clear(); + } + doc.selection_mask_dirty.set(true); + } + } else { + doc.engine.undo(); + doc.selection_history.clear(); + } } Message::Redo => { self.documents[self.active_doc].engine.redo(); + self.documents[self.active_doc].selection_history.clear(); } _ => {} } @@ -4254,7 +4386,10 @@ impl HcieIcedApp { transform_source: None, internal_clipboard: None, selection_mask: None, + selection_mask_dirty: std::cell::Cell::new(false), selection_bounds: None, + selection_history: Vec::new(), + selection_history_marker: 0, crop_state: CropState::default(), lasso_points: Vec::new(), polygon_points: Vec::new(), @@ -4465,6 +4600,9 @@ impl HcieIcedApp { // 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; self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } @@ -4473,19 +4611,39 @@ impl HcieIcedApp { self.documents[self.active_doc].selection_rect = None; } Message::SelectAll => { + let previous = self.documents[self.active_doc].engine.get_selection_mask().map(ToOwned::to_owned); + let current_marker = self.documents[self.active_doc].engine.history_current(); + if current_marker != self.documents[self.active_doc].selection_history_marker { + self.documents[self.active_doc].selection_history.clear(); + self.documents[self.active_doc].selection_history_marker = current_marker; + } + self.documents[self.active_doc].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)); } Message::Deselect => { - self.documents[self.active_doc].selection_rect = None; - self.documents[self.active_doc].selection_bounds = None; - self.documents[self.active_doc].selection_transform = None; - self.documents[self.active_doc].transform_drag_handle = TransformHandle::None; - self.documents[self.active_doc].engine.selection_clear(); - self.documents[self.active_doc].selection_fill_spans = None; - self.documents[self.active_doc].marching_ants_edges = None; + let doc = &mut self.documents[self.active_doc]; + let previous = doc.engine.get_selection_mask().map(ToOwned::to_owned); + let current_marker = doc.engine.history_current(); + if current_marker != doc.selection_history_marker { + doc.selection_history.clear(); + doc.selection_history_marker = current_marker; + } + doc.selection_history.push(previous); + + doc.selection_rect = None; + doc.selection_bounds = None; + 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; + return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::SelectInverse => { self.documents[self.active_doc].engine.selection_invert(); @@ -4632,6 +4790,7 @@ impl HcieIcedApp { self.documents[self.active_doc].engine.selection_clear(); self.documents[self.active_doc].selection_bounds = None; self.documents[self.active_doc].selection_mask = None; + self.documents[self.active_doc].selection_mask_dirty.set(true); self.documents[self.active_doc].transform_drag_handle = TransformHandle::None; self.documents[self.active_doc].transform_drag_start = None; self.documents[self.active_doc].transform_original = None; @@ -6604,7 +6763,10 @@ impl HcieIcedApp { transform_source: None, internal_clipboard: None, selection_mask: None, + selection_mask_dirty: std::cell::Cell::new(false), selection_bounds: None, + selection_history: Vec::new(), + selection_history_marker: 0, crop_state: CropState::default(), lasso_points: Vec::new(), polygon_points: Vec::new(), @@ -7544,7 +7706,9 @@ impl HcieIcedApp { Some(Message::SelectAll) } // Ctrl+D = Deselect - iced::keyboard::Key::Character(ref c) if c.as_str() == "d" && ctrl && !shift => { + iced::keyboard::Key::Character(ref c) + if c.as_str().eq_ignore_ascii_case("d") && ctrl && !shift => + { Some(Message::Deselect) } // Ctrl+Shift+I = Inverse Selection @@ -7749,21 +7913,18 @@ impl HcieIcedApp { } }); - // Timer for marching ants animation - fires every 50ms when selection is active + // The marching ants are rendered in the persistent GPU canvas shader. + // Use a real timer rather than an immediately-ready async loop: the old + // loop generated an unbounded stream of CompositeRefresh messages and + // starved pointer events while drawing. let marching_ants = if self .documents .iter() .any(|doc| doc.selection_bounds.is_some()) + && !self.tool_state.is_drawing { - iced::Subscription::run_with_id( - std::time::Instant::now(), - iced::futures::stream::unfold((), |_| async { - // Wait 50ms between animation frames (~20fps for smooth marching ants) - iced::futures::future::ready(()).await; - // Use a simple yield to create animation frames - Some((Message::CompositeRefresh, ())) - }), - ) + iced::time::every(std::time::Duration::from_millis(50)) + .map(|_| Message::CompositeRefresh) } else { iced::Subscription::none() }; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/.mimocode/.cron-lock b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/.mimocode/.cron-lock new file mode 100644 index 0000000..64ded12 --- /dev/null +++ b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/.mimocode/.cron-lock @@ -0,0 +1 @@ +{"pid":293615,"startedAt":1784244897004} \ No newline at end of file diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/canvas.wgsl b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/canvas.wgsl index b954e39..8c563e0 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/canvas.wgsl +++ b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/canvas.wgsl @@ -1,22 +1,32 @@ -// Canvas composite shader — draws the composite texture with zoom/pan/checkerboard. +// Canvas composite shader — draws composite texture with zoom/pan/checkerboard +// and GPU-accelerated marching ants selection overlay. // // ## Purpose // Single-pass fragment shader that renders: // 1. Procedural checkerboard background (no CPU geometry) // 2. Composite texture from the engine (partial-updated via queue.write_texture) +// 3. Selection mask fill (purple/red tint over selected pixels) +// 4. Marching ants border (animated black+white dashes along selection edge) // // ## Uniforms -// - transform: 2D affine transform (zoom + pan → NDC) +// - transform: 2D affine transform (zoom + pan -> NDC) // - canvas_size: engine canvas dimensions in pixels // - viewport_size: widget viewport dimensions in pixels // - checker_size: checkerboard square size in pixels +// - anim_time: marching ants animation time in seconds +// - has_selection: 1.0 if selection mask is bound, 0.0 otherwise +// - quick_mask: 1.0 for red quick-mask, 0.0 for normal blue tint +// - _pad: padding for 16-byte alignment // -// ## Vertex Layout -// Two triangles forming a fullscreen quad. Positions are in NDC [-1,1]. -// UV coordinates map to the composite texture. +// ## Bindings +// - @binding(0): Uniforms +// - @binding(1): Composite texture (RGBA) +// - @binding(2): Sampler +// - @binding(3): Selection mask (R8: >0.5 = selected) +// - @binding(4): Selection mask sampler struct Uniforms { - // Column-major 2×2 scale/rotation (we only use scale) + // Column-major 2x2 scale/rotation (we only use scale) scale_x: f32, scale_y: f32, // Translation in NDC @@ -30,10 +40,12 @@ struct Uniforms { viewport_h: f32, // Checkerboard square size (pixels) checker_size: f32, - // Padding for 16-byte alignment - _pad1: f32, - _pad2: f32, - _pad3: f32, + // Animation time (seconds) for marching ants + anim_time: f32, + // 1.0 if selection mask is bound, 0.0 otherwise + has_selection: f32, + // 1.0 for red quick-mask tint, 0.0 for normal blue tint + quick_mask: f32, } @group(0) @binding(0) @@ -45,6 +57,12 @@ var composite_texture: texture_2d; @group(0) @binding(2) var composite_sampler: sampler; +@group(0) @binding(3) +var selection_texture: texture_2d; + +@group(0) @binding(4) +var selection_sampler: sampler; + struct VertexInput { @location(0) position: vec2, @location(1) uv: vec2, @@ -75,21 +93,87 @@ fn vs_main(in: VertexInput) -> VertexOutput { @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4 { - // Checkerboard pattern (screen-space, fixed pixel size) + // ── 1. Checkerboard pattern (screen-space, fixed pixel size) ────────── let checker_col = floor(in.screen_pos.x / uniforms.checker_size); let checker_row = floor(in.screen_pos.y / uniforms.checker_size); let is_light = ((i32(checker_col) + i32(checker_row)) % 2) == 0; let light_color = vec4(0.70, 0.70, 0.70, 1.0); let dark_color = vec4(0.50, 0.50, 0.50, 1.0); - let checker = select(dark_color, light_color, is_light); + var color = select(dark_color, light_color, is_light); - // Sample composite texture + // ── 2. Composite texture ───────────────────────────────────────────── let tex_color = textureSample(composite_texture, composite_sampler, in.uv); + color = vec4(mix(color.rgb, tex_color.rgb, tex_color.a), 1.0); - // Alpha-blend composite over checkerboard - let blended = vec4( - mix(checker.rgb, tex_color.rgb, tex_color.a), - 1.0, - ); - return blended; + // ── 3. Selection overlay (if selection mask is bound) ───────────────── + 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(-texel_w, 0.0)).r; + let n1 = textureSample(selection_texture, selection_sampler, in.uv + vec2( texel_w, 0.0)).r; + let n2 = textureSample(selection_texture, selection_sampler, in.uv + vec2(0.0, -texel_h)).r; + let n3 = textureSample(selection_texture, selection_sampler, in.uv + vec2(0.0, texel_h)).r; + let n4 = textureSample(selection_texture, selection_sampler, in.uv + vec2(-texel_w, -texel_h)).r; + let n5 = textureSample(selection_texture, selection_sampler, in.uv + vec2( texel_w, -texel_h)).r; + let n6 = textureSample(selection_texture, selection_sampler, in.uv + vec2(-texel_w, texel_h)).r; + let n7 = textureSample(selection_texture, selection_sampler, in.uv + vec2( 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 { + // ── Marching ants border ────────────────────────────────── + // Use canvas-space position for the dash pattern + let canvas_x = in.uv.x * uniforms.canvas_w; + let canvas_y = in.uv.y * uniforms.canvas_h; + + // Diagonal dash pattern (combines X and Y for diagonal movement) + let dash_length = 8.0; + let gap_length = 4.0; + let cycle = dash_length + gap_length; + let speed = 22.0; // pixels per second + + // Diagonal offset: moves dashes diagonally for the marching effect + let pattern_pos = (canvas_x + canvas_y + uniforms.anim_time * speed) % cycle; + + if pattern_pos < dash_length { + // White dash + color = vec4(1.0, 1.0, 1.0, 0.9); + } else { + // Black gap + color = vec4(0.0, 0.0, 0.0, 0.8); + } + } else { + // ── Selected area fill ──────────────────────────────────── + if uniforms.quick_mask > 0.5 { + // Quick mask: red tint + color = vec4( + mix(color.r, 1.0, 0.35), + mix(color.g, 0.1, 0.35), + mix(color.b, 0.1, 0.35), + 1.0 + ); + } else { + // Normal selection: blue/purple tint + color = vec4( + mix(color.r, 0.4, 0.12), + mix(color.g, 0.5, 0.12), + mix(color.b, 1.0, 0.12), + 1.0 + ); + } + } + } + } + + return color; } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs index 5637a55..0c1e9fa 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs @@ -77,6 +77,10 @@ struct OverlayProgram { rect_radius: f32, /// Gradient drag preview endpoints in canvas-space. gradient_drag: Option<((f32, f32), (f32, f32))>, + /// Lasso freehand path points accumulated during drag (canvas-space). + 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>>, /// Exact horizontal selected-pixel spans for irregular selection fill. @@ -578,7 +582,9 @@ impl canvas::Program for OverlayProgram { || self.vector_draw.is_some() || self.selected_vector_bounds.is_some() || self.selection_transform.is_some() - || self.crop_state.as_ref().map_or(false, |c| c.active); + || self.crop_state.as_ref().map_or(false, |c| c.active) + || !self.lasso_points.is_empty() + || !self.polygon_points.is_empty(); if has_overlays { let mut frame = Frame::new(renderer, bounds.size()); @@ -605,142 +611,75 @@ impl canvas::Program for OverlayProgram { } } - // Draw marching ants for active selection (after selection is created) - if let Some((sx, sy, sw, sh)) = self.selection_bounds { - if self.selection_transform.is_none() && self.selection_rect.is_none() { - let sel_x = origin_x + sx as f32 * self.zoom; - let sel_y = origin_y + sy as f32 * self.zoom; - let sel_w = sw as f32 * self.zoom; - let sel_h = sh as f32 * self.zoom; + // Selection overlay (fill + marching ants) is now rendered by the GPU shader. + // Fill exact mask runs are handled in the fragment shader via selection mask texture. + // The shader computes: + // - Purple/red fill for selected pixels + // - Animated black+white dashes along the selection border - // Fill exact mask runs rather than the irregular selection's bounding box. - let fill_color = if self.quick_mask { - iced::Color::from_rgba(1.0, 0.1, 0.1, 0.35) - } else { - iced::Color::from_rgba(0.4, 0.5, 1.0, 0.12) - }; - if let Some(spans) = &self.selection_fill_spans { - for &(start_x, y, end_x) in spans.iter() { - frame.fill( - &Path::rectangle( - Point::new( - origin_x + start_x as f32 * self.zoom, - origin_y + y as f32 * self.zoom, - ), - Size::new( - (end_x - start_x) as f32 * self.zoom, - self.zoom.max(1.0), - ), - ), - fill_color, - ); - } - } - - // Calculate dash offset for marching ants animation - // The offset moves the dash pattern to create the marching effect - let dash_length = 8.0; - let gap_length = 4.0; - let total_cycle = dash_length + gap_length; - let animated_offset = (self.marching_ants_offset * total_cycle) as usize; - - // Draw white dashes (foreground) - offset by half cycle for marching effect - let white_offset = - (animated_offset + (total_cycle / 2.0) as usize) % (total_cycle as usize); - - if let Some(edges) = &self.marching_ants_edges { - // Irregular selection bounds (marching ants along extracted edges) - for &(x1, y1, x2, y2) in edges.iter() { - let s1 = Point::new( - origin_x + x1 as f32 * self.zoom, - origin_y + y1 as f32 * self.zoom, - ); - let s2 = Point::new( - origin_x + x2 as f32 * self.zoom, - origin_y + y2 as f32 * self.zoom, - ); - - // Optimization: we could clip, but `Path::line` handles it generally. - let dist = ((s1.x - s2.x).powi(2) + (s1.y - s2.y).powi(2)).sqrt(); - if dist < 1e-6 { - continue; - } - - let line_path = Path::line(s1, s2); - frame.stroke( - &line_path, - Stroke { - style: canvas::stroke::Style::Solid(iced::Color::from_rgb( - 0.0, 0.0, 0.0, - )), - width: 1.5, - ..Default::default() - }, - ); - - let dir_x = (s2.x - s1.x) / dist; - let dir_y = (s2.y - s1.y) / dist; - - let mut curr = -(self.marching_ants_offset * total_cycle); - let mut iter = 0u32; - while curr < dist && iter < 1000 { - iter += 1; - let start_d = curr.max(0.0); - let end_d = (curr + dash_length).min(dist); - if end_d > start_d { - let p_start = - Point::new(s1.x + dir_x * start_d, s1.y + dir_y * start_d); - let p_end = - Point::new(s1.x + dir_x * end_d, s1.y + dir_y * end_d); - let dash_path = Path::line(p_start, p_end); - frame.stroke( - &dash_path, - Stroke { - style: canvas::stroke::Style::Solid( - iced::Color::from_rgb(1.0, 1.0, 1.0), - ), - width: 1.5, - ..Default::default() - }, - ); - } - curr += total_cycle; - } - } - } else { - // Fallback to bounding box marching ants - let sel_path = - Path::rectangle(Point::new(sel_x, sel_y), Size::new(sel_w, sel_h)); - frame.stroke( - &sel_path, - Stroke { - style: canvas::stroke::Style::Solid(iced::Color::from_rgb( - 0.0, 0.0, 0.0, - )), - width: 2.0 / self.zoom.max(1.0), - line_dash: canvas::LineDash { - segments: &[dash_length, gap_length], - offset: animated_offset, - }, - ..Default::default() - }, - ); - - frame.stroke( - &sel_path, - Stroke { - style: canvas::stroke::Style::Solid(iced::Color::from_rgb( - 1.0, 1.0, 1.0, - )), - width: 2.0 / self.zoom.max(1.0), - line_dash: canvas::LineDash { - segments: &[dash_length, gap_length], - offset: white_offset, - }, - ..Default::default() - }, + // Draw lasso freehand path while dragging + if self.lasso_points.len() > 1 { + let lasso_path = Path::new(|b| { + let first = &self.lasso_points[0]; + let start = Point::new( + origin_x + first.0 as f32 * self.zoom, + origin_y + first.1 as f32 * self.zoom, + ); + b.move_to(start); + for pt in &self.lasso_points[1..] { + let p = Point::new( + origin_x + pt.0 as f32 * self.zoom, + origin_y + pt.1 as f32 * self.zoom, ); + b.line_to(p); } + b.close(); + }); + frame.stroke( + &lasso_path, + Stroke { + style: canvas::stroke::Style::Solid(iced::Color::from_rgb(0.0, 0.6, 1.0)), + width: 1.5 / self.zoom.max(1.0), + ..Default::default() + }, + ); + } + + // Draw polygon selection points while placing vertices + if self.polygon_points.len() > 1 { + let poly_path = Path::new(|b| { + let first = &self.polygon_points[0]; + let start = Point::new( + origin_x + first.0 as f32 * self.zoom, + origin_y + first.1 as f32 * self.zoom, + ); + b.move_to(start); + for pt in &self.polygon_points[1..] { + let p = Point::new( + origin_x + pt.0 as f32 * self.zoom, + origin_y + pt.1 as f32 * self.zoom, + ); + b.line_to(p); + } + }); + frame.stroke( + &poly_path, + Stroke { + style: canvas::stroke::Style::Solid(iced::Color::from_rgb(0.0, 0.6, 1.0)), + width: 1.5 / self.zoom.max(1.0), + ..Default::default() + }, + ); + // Draw vertex dots + for pt in &self.polygon_points { + let p = Point::new( + origin_x + pt.0 as f32 * self.zoom, + origin_y + pt.1 as f32 * self.zoom, + ); + frame.fill( + &Path::circle(p, 3.0 / self.zoom.max(1.0)), + iced::Color::from_rgb(0.0, 0.6, 1.0), + ); } } @@ -1465,7 +1404,12 @@ pub fn view<'a>( dirty_region: doc.dirty_region.take(), full_upload: doc.full_upload.replace(false), space_pan: tool_state.space_pan, + selection_mask: doc.selection_mask.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, }; + doc.selection_mask_dirty.set(false); let shader_canvas = Shader::new(shader_program) .width(Length::Fill) @@ -1508,6 +1452,8 @@ pub fn view<'a>( polygon_sides, rect_radius, 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, diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/shader_canvas.rs b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/shader_canvas.rs index 4a3bb63..b0afe83 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/shader_canvas.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/shader_canvas.rs @@ -56,10 +56,12 @@ struct CanvasUniforms { viewport_h: f32, /// Checkerboard square size in pixels checker_size: f32, - /// Padding for 16-byte alignment - _pad1: f32, - _pad2: f32, - _pad3: f32, + /// Marching ants animation time in seconds + anim_time: f32, + /// 1.0 if selection mask is bound, 0.0 otherwise + has_selection: f32, + /// 1.0 for red quick-mask tint, 0.0 for normal blue tint + quick_mask: f32, } // ─── Vertex struct ─────────────────────────────────────────────────────────── @@ -149,6 +151,12 @@ struct CanvasShaderPipeline { /// Current texture dimensions (to detect resize) texture_w: u32, texture_h: u32, + /// Selection mask texture (R8, canvas_w × canvas_h) + sel_texture: wgpu::Texture, + /// Selection mask texture view + sel_texture_view: wgpu::TextureView, + /// Selection mask sampler + sel_sampler: wgpu::Sampler, } impl CanvasShaderPipeline { @@ -236,6 +244,33 @@ impl CanvasShaderPipeline { ..Default::default() }); + // ── Selection mask texture (R8, same size as canvas) ───────────── + let sel_texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("hcie-selection-texture"), + size: wgpu::Extent3d { + width: canvas_w.max(1), + height: canvas_h.max(1), + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::R8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + let sel_texture_view = sel_texture.create_view(&wgpu::TextureViewDescriptor::default()); + let sel_sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("hcie-selection-sampler"), + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Nearest, + min_filter: wgpu::FilterMode::Nearest, + mipmap_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }); + // ── Uniform buffer ─────────────────────────────────────────────── let uniform_data = CanvasUniforms { scale_x: 1.0, @@ -247,9 +282,9 @@ impl CanvasShaderPipeline { viewport_w: 800.0, viewport_h: 600.0, checker_size: CHECKER_SQUARE, - _pad1: 0.0, - _pad2: 0.0, - _pad3: 0.0, + anim_time: 0.0, + has_selection: 0.0, + quick_mask: 0.0, }; let uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { @@ -284,13 +319,31 @@ impl CanvasShaderPipeline { }, count: None, }, - // @binding(2): Sampler + // @binding(2): Composite sampler wgpu::BindGroupLayoutEntry { binding: 2, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, + // @binding(3): Selection mask texture (R8) + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + // @binding(4): Selection mask sampler + wgpu::BindGroupLayoutEntry { + binding: 4, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, ], }); @@ -311,6 +364,14 @@ impl CanvasShaderPipeline { binding: 2, resource: wgpu::BindingResource::Sampler(&sampler), }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView(&sel_texture_view), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::Sampler(&sel_sampler), + }, ], }); @@ -385,6 +446,9 @@ impl CanvasShaderPipeline { sampler, texture_w: canvas_w, texture_h: canvas_h, + sel_texture, + sel_texture_view, + sel_sampler, } } @@ -454,7 +518,26 @@ impl CanvasShaderPipeline { ); } - // Recreate bind group with new texture view + // Recreate selection mask texture with new dimensions + self.sel_texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("hcie-selection-texture"), + size: wgpu::Extent3d { + width: canvas_w.max(1), + height: canvas_h.max(1), + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::R8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + self.sel_texture_view = self + .sel_texture + .create_view(&wgpu::TextureViewDescriptor::default()); + + // Recreate bind group with new texture views self.bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("hcie-canvas-bind-group"), layout: &self.bind_group_layout, @@ -471,6 +554,14 @@ impl CanvasShaderPipeline { binding: 2, resource: wgpu::BindingResource::Sampler(&self.sampler), }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView(&self.sel_texture_view), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::Sampler(&self.sel_sampler), + }, ], }); @@ -540,6 +631,45 @@ impl CanvasShaderPipeline { ); } + /// Upload the full selection mask to the GPU texture. + /// + /// Called when the selection mask changes (new selection, selection modified). + /// The mask is a single-channel (R8) texture matching the canvas dimensions. + /// + /// ## Arguments + /// * `queue` — wgpu queue for the upload + /// * `mask` — selection mask bytes (1 byte per pixel, >128 = selected) + /// * `canvas_w` — mask width in pixels + /// * `canvas_h` — mask height in pixels + fn upload_selection_mask(&self, queue: &wgpu::Queue, mask: &[u8], canvas_w: u32, canvas_h: u32) { + if mask.len() != (canvas_w * canvas_h) as usize { + log::warn!( + "[CanvasShaderPipeline::upload_selection_mask] Size mismatch: mask={} but canvas={}×{}={}", + mask.len(), canvas_w, canvas_h, canvas_w * canvas_h + ); + return; + } + queue.write_texture( + wgpu::ImageCopyTexture { + texture: &self.sel_texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + mask, + wgpu::ImageDataLayout { + offset: 0, + bytes_per_row: Some(canvas_w), + rows_per_image: Some(canvas_h), + }, + wgpu::Extent3d { + width: canvas_w, + height: canvas_h, + depth_or_array_layers: 1, + }, + ); + } + /// Update the uniform buffer with current zoom/pan/viewport values. /// /// ## Arguments @@ -574,6 +704,14 @@ pub struct CanvasShaderPrimitive { 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>, + /// Whether the selection mask has changed since last upload + pub selection_dirty: bool, + /// Animation time in seconds for marching ants + pub anim_time: f32, + /// Whether quick-mask mode is active (red tint vs blue) + pub quick_mask: bool, } impl shader::Primitive for CanvasShaderPrimitive { @@ -634,6 +772,13 @@ impl shader::Primitive for CanvasShaderPrimitive { pipeline.upload_dirty_region(queue, region, &self.composite_pixels, self.canvas_w); } + // ── Upload selection mask if changed ────────────────────────────── + if self.selection_dirty { + if let Some(ref mask) = self.selection_mask { + pipeline.upload_selection_mask(queue, mask, self.canvas_w, self.canvas_h); + } + } + // ── Compute uniforms ───────────────────────────────────────────── let viewport_w = bounds.width; let viewport_h = bounds.height; @@ -675,9 +820,9 @@ impl shader::Primitive for CanvasShaderPrimitive { viewport_w, viewport_h, checker_size: CHECKER_SQUARE, - _pad1: 0.0, - _pad2: 0.0, - _pad3: 0.0, + anim_time: self.anim_time, + has_selection: if self.selection_mask.is_some() { 1.0 } else { 0.0 }, + quick_mask: if self.quick_mask { 1.0 } else { 0.0 }, }; pipeline.update_uniforms(queue, &uniforms); @@ -792,6 +937,14 @@ pub struct CanvasShaderProgram { 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>, + /// Whether the selection mask has changed since last upload. + pub selection_dirty: bool, + /// Animation time in seconds for marching ants. + pub anim_time: f32, + /// Whether quick-mask mode is active (red tint vs blue). + pub quick_mask: bool, } /// Convert a viewport-local point to canvas-space coordinates. @@ -1120,6 +1273,10 @@ impl shader::Program for CanvasShaderProgram { composite_pixels: self.composite_pixels.clone(), full_upload: self.full_upload, bounds, + selection_mask: self.selection_mask.clone(), + selection_dirty: self.selection_dirty, + anim_time: self.anim_time, + quick_mask: self.quick_mask, } } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/selection/state.rs b/hcie-iced-app/crates/hcie-iced-gui/src/selection/state.rs index ddb5e1c..9be9230 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/selection/state.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/selection/state.rs @@ -65,7 +65,12 @@ pub fn mask_bounds(mask: &[u8], width: u32, height: u32) -> Option<(u32, u32, u3 } } } - found.then_some((min_x, min_y, max_x - min_x + 1, max_y - min_y + 1)) + 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 efficient irregular overlay filling. diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/sidebar/mod.rs b/hcie-iced-app/crates/hcie-iced-gui/src/sidebar/mod.rs index 9957f3a..59721c6 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/sidebar/mod.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/sidebar/mod.rs @@ -582,18 +582,34 @@ fn tool_button<'a>( let is_tool_active = tool == tool_state.active_tool; let c = colors; + let tool_icon_path = tool_icon(tool); + let resolved_path = resolve_icon_path(tool_icon_path); + let popup_icon: Element<'_, Message> = - svg(svg::Handle::from_path(resolve_icon_path(tool_icon(tool)))) - .width(18) - .height(18) - .style(move |_theme, _status| svg::Style { - color: Some(if is_tool_active { + if resolved_path.exists() { + let handle = svg::Handle::from_path(&resolved_path); + svg(handle) + .width(18) + .height(18) + .style(move |_theme, _status| svg::Style { + color: Some(if is_tool_active { + iced::Color::WHITE + } else { + c.text_primary + }), + }) + .into() + } else { + // Fallback: show first letter of tool name when icon is missing + text(tool_name.chars().next().unwrap_or('?').to_string()) + .size(14) + .color(if is_tool_active { iced::Color::WHITE } else { c.text_primary - }), - }) - .into(); + }) + .into() + }; let item = button(container(popup_icon).width(32).height(28).center(18)) .on_press(Message::ToolSelected(tool)) @@ -658,7 +674,7 @@ fn tool_button<'a>( .padding(1) .width(68) .style(move |_theme| iced::widget::container::Style { - background: Some(iced::Background::Color(colors.bg_active)), + background: Some(iced::Background::Color(colors.bg_panel)), border: iced::Border::default().color(colors.border_high).width(1), shadow: iced::Shadow { color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.4),