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.
This commit is contained in:
@@ -386,8 +386,14 @@ pub struct IcedDocument {
|
||||
pub internal_clipboard: Option<SelectionTransform>,
|
||||
/// Selection mask (alpha channel) for the current selection
|
||||
pub selection_mask: Option<Vec<u8>>,
|
||||
/// Whether the selection mask 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)>,
|
||||
/// History of selection masks for undo support.
|
||||
pub selection_history: Vec<Option<Vec<u8>>>,
|
||||
/// 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<Vec<u8>>) {
|
||||
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<u8> 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()
|
||||
};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"pid":293615,"startedAt":1784244897004}
|
||||
@@ -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<f32>;
|
||||
@group(0) @binding(2)
|
||||
var composite_sampler: sampler;
|
||||
|
||||
@group(0) @binding(3)
|
||||
var selection_texture: texture_2d<f32>;
|
||||
|
||||
@group(0) @binding(4)
|
||||
var selection_sampler: sampler;
|
||||
|
||||
struct VertexInput {
|
||||
@location(0) position: vec2<f32>,
|
||||
@location(1) uv: vec2<f32>,
|
||||
@@ -75,21 +93,87 @@ fn vs_main(in: VertexInput) -> VertexOutput {
|
||||
|
||||
@fragment
|
||||
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
// 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<f32>(0.70, 0.70, 0.70, 1.0);
|
||||
let dark_color = vec4<f32>(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<f32>(mix(color.rgb, tex_color.rgb, tex_color.a), 1.0);
|
||||
|
||||
// Alpha-blend composite over checkerboard
|
||||
let blended = vec4<f32>(
|
||||
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<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 {
|
||||
// ── 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<f32>(1.0, 1.0, 1.0, 0.9);
|
||||
} else {
|
||||
// Black gap
|
||||
color = vec4<f32>(0.0, 0.0, 0.0, 0.8);
|
||||
}
|
||||
} else {
|
||||
// ── Selected area fill ────────────────────────────────────
|
||||
if uniforms.quick_mask > 0.5 {
|
||||
// Quick mask: red tint
|
||||
color = vec4<f32>(
|
||||
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<f32>(
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<std::sync::Arc<Vec<(u32, u32, u32, u32)>>>,
|
||||
/// Exact horizontal selected-pixel spans for irregular selection fill.
|
||||
@@ -578,7 +582,9 @@ impl canvas::Program<Message> 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<Message> 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,
|
||||
|
||||
@@ -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<Vec<u8>>,
|
||||
/// 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<Vec<u8>>,
|
||||
/// 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<Message> 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user