From 1900792fa96d77fa5f1da9a80079ca0df9b09dd7 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Sat, 7 Mar 2026 03:39:09 -0500 Subject: [PATCH] Magic wand selection --- .../lightningbeam-core/src/flood_fill.rs | 196 ++++++++++-------- .../lightningbeam-core/src/selection.rs | 35 ++++ .../lightningbeam-editor/src/main.rs | 3 +- .../src/panes/infopanel.rs | 30 ++- .../lightningbeam-editor/src/panes/stage.rs | 158 ++++++++++++++ .../lightningbeam-editor/src/tools/mod.rs | 10 + 6 files changed, 345 insertions(+), 87 deletions(-) diff --git a/lightningbeam-ui/lightningbeam-core/src/flood_fill.rs b/lightningbeam-ui/lightningbeam-core/src/flood_fill.rs index 99ba88a..1b439b8 100644 --- a/lightningbeam-ui/lightningbeam-core/src/flood_fill.rs +++ b/lightningbeam-ui/lightningbeam-core/src/flood_fill.rs @@ -17,22 +17,112 @@ pub enum FillThresholdMode { Relative, } -/// Pixel flood fill for the raster paint bucket tool. +/// BFS / global scan flood fill mask. /// -/// Operates on a flat RGBA `Vec` (4 bytes per pixel, row-major). -/// Pixels are alpha-composited with `fill_color` — the existing canvas content -/// shows through wherever `fill_color` has partial transparency or softness -/// reduces its effective alpha. +/// Returns a `Vec>` of length `width × height`: +/// - `Some(d)` — pixel is within the fill region; `d` is the color distance +/// from its comparison color (0.0 at seed, up to `threshold` at the edge). +/// - `None` — pixel is outside the fill region. /// /// # Parameters -/// - `pixels` – raw RGBA buffer, modified in place +/// - `pixels` – raw RGBA buffer (read-only) /// - `width/height` – canvas dimensions -/// - `seed_x/y` – click coordinates (canvas pixel indices, 0-based) -/// - `fill_color` – RGBA fill \[r, g, b, a\], 0–255 each -/// - `threshold` – max color distance (Euclidean RGBA) to include in fill (0–255·√4 ≈ 510) -/// - `softness` – how much of the threshold is a soft fade (0 = hard edge, 100 = full fade) -/// - `mode` – compare candidates to seed (Absolute) or to their BFS parent (Relative) -/// - `selection` – optional clip mask; pixels outside are never filled +/// - `seed_x/y` – click coordinates (canvas pixel indices, 0-based) +/// - `threshold` – max color distance to include +/// - `mode` – Absolute = compare to seed; Relative = compare to BFS parent +/// - `contiguous` – true = BFS from seed (connected region only); +/// false = scan every pixel against seed color globally +/// - `selection` – optional clip mask; pixels outside are never included +pub fn raster_fill_mask( + pixels: &[u8], + width: u32, + height: u32, + seed_x: i32, + seed_y: i32, + threshold: f32, + mode: FillThresholdMode, + contiguous: bool, + selection: Option<&crate::selection::RasterSelection>, +) -> Vec> { + use std::collections::VecDeque; + + let w = width as i32; + let h = height as i32; + let n = (width * height) as usize; + + let mut dist_map: Vec> = vec![None; n]; + + if seed_x < 0 || seed_y < 0 || seed_x >= w || seed_y >= h { + return dist_map; + } + + let seed_idx = (seed_y * w + seed_x) as usize; + let seed_color = [ + pixels[seed_idx * 4], + pixels[seed_idx * 4 + 1], + pixels[seed_idx * 4 + 2], + pixels[seed_idx * 4 + 3], + ]; + + if contiguous { + // BFS: only connected pixels within threshold. + let mut parent_color: Vec<[u8; 4]> = vec![[0; 4]; n]; + let mut queue: VecDeque<(i32, i32)> = VecDeque::new(); + + dist_map[seed_idx] = Some(0.0); + parent_color[seed_idx] = seed_color; + queue.push_back((seed_x, seed_y)); + + let dirs: [(i32, i32); 4] = [(0, -1), (0, 1), (-1, 0), (1, 0)]; + + while let Some((cx, cy)) = queue.pop_front() { + let ci = (cy * w + cx) as usize; + let compare_color = match mode { + FillThresholdMode::Absolute => seed_color, + FillThresholdMode::Relative => parent_color[ci], + }; + for (dx, dy) in dirs { + let nx = cx + dx; + let ny = cy + dy; + if nx < 0 || ny < 0 || nx >= w || ny >= h { continue; } + let ni = (ny * w + nx) as usize; + if dist_map[ni].is_some() { continue; } + if let Some(sel) = selection { + if !sel.contains_pixel(nx, ny) { continue; } + } + let npx = [pixels[ni*4], pixels[ni*4+1], pixels[ni*4+2], pixels[ni*4+3]]; + let d = color_distance(npx, compare_color); + if d <= threshold { + dist_map[ni] = Some(d); + parent_color[ni] = npx; + queue.push_back((nx, ny)); + } + } + } + } else { + // Global scan: every pixel compared against seed color (Absolute mode). + for row in 0..h { + for col in 0..w { + if let Some(sel) = selection { + if !sel.contains_pixel(col, row) { continue; } + } + let ni = (row * w + col) as usize; + let npx = [pixels[ni*4], pixels[ni*4+1], pixels[ni*4+2], pixels[ni*4+3]]; + let d = color_distance(npx, seed_color); + if d <= threshold { + dist_map[ni] = Some(d); + } + } + } + } + + dist_map +} + +/// Pixel flood fill for the raster paint bucket tool. +/// +/// Calls [`raster_fill_mask`] then alpha-composites `fill_color` over each +/// matched pixel. `softness` controls a fade zone near the fill boundary. pub fn raster_flood_fill( pixels: &mut Vec, width: u32, @@ -43,81 +133,20 @@ pub fn raster_flood_fill( threshold: f32, softness: f32, mode: FillThresholdMode, + contiguous: bool, selection: Option<&crate::selection::RasterSelection>, ) { - use std::collections::VecDeque; - - let w = width as i32; - let h = height as i32; + let dist_map = raster_fill_mask(pixels, width, height, seed_x, seed_y, + threshold, mode, contiguous, selection); let n = (width * height) as usize; - if seed_x < 0 || seed_y < 0 || seed_x >= w || seed_y >= h { return; } - - let seed_idx = (seed_y * w + seed_x) as usize; - let seed_color = [ - pixels[seed_idx * 4], - pixels[seed_idx * 4 + 1], - pixels[seed_idx * 4 + 2], - pixels[seed_idx * 4 + 3], - ]; - - // dist_map[i] = Some(d) when pixel i is within the fill region with - // color-distance `d` from its comparison color. - let mut dist_map: Vec> = vec![None; n]; - let mut queue: VecDeque<(i32, i32)> = VecDeque::new(); - - // Track the pixel that each BFS node was reached from (for Relative mode). - // In Absolute mode this is ignored (we always compare to seed_color). - let mut parent_color: Vec<[u8; 4]> = vec![[0; 4]; n]; - - dist_map[seed_idx] = Some(0.0); - parent_color[seed_idx] = seed_color; - queue.push_back((seed_x, seed_y)); - - let dirs: [(i32, i32); 4] = [(0, -1), (0, 1), (-1, 0), (1, 0)]; - - while let Some((cx, cy)) = queue.pop_front() { - let ci = (cy * w + cx) as usize; - - let compare_color = match mode { - FillThresholdMode::Absolute => seed_color, - FillThresholdMode::Relative => parent_color[ci], - }; - - for (dx, dy) in dirs { - let nx = cx + dx; - let ny = cy + dy; - if nx < 0 || ny < 0 || nx >= w || ny >= h { continue; } - let ni = (ny * w + nx) as usize; - if dist_map[ni].is_some() { continue; } - if let Some(sel) = selection { - if !sel.contains_pixel(nx, ny) { continue; } - } - let npx = [ - pixels[ni * 4], - pixels[ni * 4 + 1], - pixels[ni * 4 + 2], - pixels[ni * 4 + 3], - ]; - let d = color_distance(npx, compare_color); - if d <= threshold { - dist_map[ni] = Some(d); - parent_color[ni] = npx; - queue.push_back((nx, ny)); - } - } - } - - // Write pass: alpha-composite fill_color over existing pixels. let fr = fill_color[0] as f32 / 255.0; let fg = fill_color[1] as f32 / 255.0; let fb = fill_color[2] as f32 / 255.0; let fa_base = fill_color[3] as f32 / 255.0; - // Softness defines what fraction of [0..threshold] uses full alpha vs fade. - // falloff_start_ratio: distance ratio at which the fade begins (0→full fade, 1→hard). let falloff_start = if softness <= 0.0 || threshold <= 0.0 { - 1.0_f32 // hard edge: never start fading + 1.0_f32 } else { 1.0 - softness / 100.0 }; @@ -127,7 +156,7 @@ pub fn raster_flood_fill( let alpha = if threshold <= 0.0 { fa_base } else { - let t = d / threshold; // 0.0 at seed, 1.0 at boundary + let t = d / threshold; if t <= falloff_start { fa_base } else { @@ -135,22 +164,19 @@ pub fn raster_flood_fill( fa_base * (1.0 - frac) } }; - if alpha <= 0.0 { continue; } - // Porter-Duff "src over dst" with straight alpha. let dst_r = pixels[i * 4 ] as f32 / 255.0; let dst_g = pixels[i * 4 + 1] as f32 / 255.0; let dst_b = pixels[i * 4 + 2] as f32 / 255.0; let dst_a = pixels[i * 4 + 3] as f32 / 255.0; - let inv_a = 1.0 - alpha; let out_a = alpha + dst_a * inv_a; if out_a > 0.0 { - pixels[i * 4 ] = ((fr * alpha + dst_r * dst_a * inv_a) / out_a * 255.0).round() as u8; - pixels[i * 4 + 1] = ((fg * alpha + dst_g * dst_a * inv_a) / out_a * 255.0).round() as u8; - pixels[i * 4 + 2] = ((fb * alpha + dst_b * dst_a * inv_a) / out_a * 255.0).round() as u8; - pixels[i * 4 + 3] = (out_a * 255.0).round() as u8; + pixels[i*4 ] = ((fr * alpha + dst_r * dst_a * inv_a) / out_a * 255.0).round() as u8; + pixels[i*4+1] = ((fg * alpha + dst_g * dst_a * inv_a) / out_a * 255.0).round() as u8; + pixels[i*4+2] = ((fb * alpha + dst_b * dst_a * inv_a) / out_a * 255.0).round() as u8; + pixels[i*4+3] = (out_a * 255.0).round() as u8; } } } diff --git a/lightningbeam-ui/lightningbeam-core/src/selection.rs b/lightningbeam-ui/lightningbeam-core/src/selection.rs index 08247a7..ddd24ba 100644 --- a/lightningbeam-ui/lightningbeam-core/src/selection.rs +++ b/lightningbeam-ui/lightningbeam-core/src/selection.rs @@ -15,6 +15,16 @@ pub enum RasterSelection { Rect(i32, i32, i32, i32), /// Closed freehand lasso polygon. Lasso(Vec<(i32, i32)>), + /// Per-pixel boolean mask (e.g. from magic wand flood fill). + /// `data` is row-major, length = width × height. + Mask { + data: Vec, + width: u32, + height: u32, + /// Top-left canvas pixel of the mask's bounding canvas region. + origin_x: i32, + origin_y: i32, + }, } impl RasterSelection { @@ -29,6 +39,23 @@ impl RasterSelection { let y1 = pts.iter().map(|p| p.1).max().unwrap_or(0); (x0, y0, x1, y1) } + Self::Mask { data, width, height, origin_x, origin_y } => { + let w = *width as i32; + let mut bx0 = i32::MAX; let mut by0 = i32::MAX; + let mut bx1 = i32::MIN; let mut by1 = i32::MIN; + for row in 0..*height as i32 { + for col in 0..w { + if data[(row * w + col) as usize] { + bx0 = bx0.min(origin_x + col); + by0 = by0.min(origin_y + row); + bx1 = bx1.max(origin_x + col + 1); + by1 = by1.max(origin_y + row + 1); + } + } + } + if bx0 == i32::MAX { (*origin_x, *origin_y, *origin_x, *origin_y) } + else { (bx0, by0, bx1, by1) } + } } } @@ -37,6 +64,14 @@ impl RasterSelection { match self { Self::Rect(x0, y0, x1, y1) => px >= *x0 && px < *x1 && py >= *y0 && py < *y1, Self::Lasso(pts) => point_in_polygon(px, py, pts), + Self::Mask { data, width, height, origin_x, origin_y } => { + let lx = px - origin_x; + let ly = py - origin_y; + if lx < 0 || ly < 0 || lx >= *width as i32 || ly >= *height as i32 { + return false; + } + data[(ly * *width as i32 + lx) as usize] + } } } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 071a3a8..05155b4 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -1869,7 +1869,8 @@ impl EditorApp { let cy = y0 + row; let inside = match sel { RasterSelection::Rect(..) => true, - RasterSelection::Lasso(_) => sel.contains_pixel(cx as i32, cy as i32), + RasterSelection::Lasso(_) | RasterSelection::Mask { .. } => + sel.contains_pixel(cx as i32, cy as i32), }; if inside { let src = ((cy * canvas_w + cx) * 4) as usize; diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index b8809b3..ff56a61 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -184,7 +184,7 @@ impl InfopanelPane { let has_options = is_vector_tool || is_raster_paint_tool || is_raster_transform || matches!( tool, - Tool::PaintBucket | Tool::RegionSelect + Tool::PaintBucket | Tool::RegionSelect | Tool::MagicWand ); if !has_options { @@ -326,6 +326,34 @@ impl InfopanelPane { } } + Tool::MagicWand => { + use crate::tools::FillThresholdMode; + ui.horizontal(|ui| { + ui.label("Threshold:"); + ui.add( + egui::Slider::new( + &mut shared.raster_settings.wand_threshold, + 0.0_f32..=255.0, + ) + .step_by(1.0), + ); + }); + ui.horizontal(|ui| { + ui.label("Mode:"); + ui.selectable_value( + &mut shared.raster_settings.wand_mode, + FillThresholdMode::Absolute, + "Absolute", + ); + ui.selectable_value( + &mut shared.raster_settings.wand_mode, + FillThresholdMode::Relative, + "Relative", + ); + }); + ui.checkbox(&mut shared.raster_settings.wand_contiguous, "Contiguous"); + } + Tool::Polygon => { // Number of sides ui.horizontal(|ui| { diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index be91fbc..3b76c49 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -5501,6 +5501,13 @@ impl StagePane { RasterSelection::Rect(*x0 + dx, *y0 + dy, *x1 + dx, *y1 + dy), RasterSelection::Lasso(pts) => RasterSelection::Lasso(pts.iter().map(|(x, y)| (x + dx, y + dy)).collect()), + RasterSelection::Mask { data, width, height, origin_x, origin_y } => + RasterSelection::Mask { + data: std::mem::take(data), + width: *width, height: *height, + origin_x: *origin_x + dx, + origin_y: *origin_y + dy, + }, }; } // Shift floating pixels if any. @@ -5763,6 +5770,7 @@ impl StagePane { fill_color, threshold, softness, core_mode, + true, // paint bucket always fills contiguous region shared.selection.raster_selection.as_ref(), ); @@ -5770,6 +5778,146 @@ impl StagePane { let _ = shared.action_executor.execute(Box::new(action)); } + fn handle_magic_wand_tool( + &mut self, + response: &egui::Response, + world_pos: egui::Vec2, + shared: &mut SharedPaneState, + ) { + use lightningbeam_core::layer::AnyLayer; + use lightningbeam_core::flood_fill::{raster_fill_mask, FillThresholdMode}; + use lightningbeam_core::selection::RasterSelection; + use crate::tools::FillThresholdMode as EditorMode; + + if !self.rsp_clicked(response) { return; } + + let Some(layer_id) = *shared.active_layer_id else { return }; + + let is_raster = shared.action_executor.document() + .get_layer(&layer_id) + .map_or(false, |l| matches!(l, AnyLayer::Raster(_))); + if !is_raster { return; } + + let time = *shared.playback_time; + + // Ensure keyframe exists. + let (doc_w, doc_h) = { + let doc = shared.action_executor.document(); + (doc.width as u32, doc.height as u32) + }; + { + let doc = shared.action_executor.document_mut(); + if let Some(AnyLayer::Raster(rl)) = doc.get_layer_mut(&layer_id) { + rl.ensure_keyframe_at(time, doc_w, doc_h); + } + } + + let (pixels, width, height) = { + let doc = shared.action_executor.document(); + if let Some(AnyLayer::Raster(rl)) = doc.get_layer(&layer_id) { + if let Some(kf) = rl.keyframe_at(time) { + let expected = (kf.width * kf.height * 4) as usize; + let buf = if kf.raw_pixels.len() == expected { + kf.raw_pixels.clone() + } else { + vec![0u8; expected] + }; + (buf, kf.width, kf.height) + } else { return; } + } else { return; } + }; + + let seed_x = world_pos.x as i32; + let seed_y = world_pos.y as i32; + if seed_x < 0 || seed_y < 0 || seed_x >= width as i32 || seed_y >= height as i32 { + return; + } + + let threshold = shared.raster_settings.wand_threshold; + let contiguous = shared.raster_settings.wand_contiguous; + let core_mode = match shared.raster_settings.wand_mode { + EditorMode::Absolute => FillThresholdMode::Absolute, + EditorMode::Relative => FillThresholdMode::Relative, + }; + + // Use existing raster_selection as clip if present (so the wand only + // selects inside the current selection — Shift/Intersect not yet supported). + let dist_map = raster_fill_mask( + &pixels, width, height, + seed_x, seed_y, + threshold, core_mode, contiguous, + None, // ignore existing selection for wand — it defines a new one + ); + + let data: Vec = dist_map.iter().map(|d| d.is_some()).collect(); + + shared.selection.raster_selection = Some(RasterSelection::Mask { + data, + width, + height, + origin_x: 0, + origin_y: 0, + }); + Self::lift_selection_to_float(shared); + } + + /// Draw marching ants for a pixel mask selection. + /// + /// Animates horizontal edges leftward and vertical edges downward (position-based), + /// producing a coherent clockwise-like marching effect without contour tracing. + fn draw_marching_ants_mask( + painter: &egui::Painter, + rect_min: egui::Pos2, + data: &[bool], + width: u32, height: u32, + origin_x: i32, origin_y: i32, + zoom: f32, pan: egui::Vec2, + phase: f32, + ) { + let w = width as i32; + let h = height as i32; + let phase_i = phase as i32; + + let to_screen = |cx: i32, cy: i32| egui::pos2( + rect_min.x + pan.x + cx as f32 * zoom, + rect_min.y + pan.y + cy as f32 * zoom, + ); + + // Horizontal edges: between (row-1) and (row). Animate along x axis. + for row in 0..=h { + for col in 0..w { + let above = row > 0 && data[((row-1) * w + col) as usize]; + let below = row < h && data[(row * w + col) as usize]; + if above == below { continue; } + let cx = origin_x + col; + let cy = origin_y + row; + let on = (cx - phase_i).rem_euclid(8) < 4; + let color = if on { egui::Color32::WHITE } else { egui::Color32::BLACK }; + painter.line_segment( + [to_screen(cx, cy), to_screen(cx + 1, cy)], + egui::Stroke::new(1.0, color), + ); + } + } + + // Vertical edges: between (col-1) and (col). Animate along y axis. + for col in 0..=w { + for row in 0..h { + let left = col > 0 && data[(row * w + col - 1) as usize]; + let right = col < w && data[(row * w + col ) as usize]; + if left == right { continue; } + let cx = origin_x + col; + let cy = origin_y + row; + let on = (cy - phase_i).rem_euclid(8) < 4; + let color = if on { egui::Color32::WHITE } else { egui::Color32::BLACK }; + painter.line_segment( + [to_screen(cx, cy), to_screen(cx, cy + 1)], + egui::Stroke::new(1.0, color), + ); + } + } + } + /// Apply transform preview to objects based on current mouse position fn apply_transform_preview( vector_layer: &mut lightningbeam_core::layer::VectorLayer, @@ -8344,6 +8492,9 @@ impl StagePane { Tool::SelectLasso => { self.handle_raster_lasso_tool(ui, &response, world_pos, shared); } + Tool::MagicWand => { + self.handle_magic_wand_tool(&response, world_pos, shared); + } Tool::Transform => { self.handle_transform_tool(ui, &response, world_pos, shared); } @@ -8700,6 +8851,13 @@ impl StagePane { RasterSelection::Lasso(pts) => { Self::draw_marching_ants_lasso(&painter, rect.min, pts, zoom, pan, phase); } + RasterSelection::Mask { data, width, height, origin_x, origin_y } => { + Self::draw_marching_ants_mask( + &painter, rect.min, + data, *width, *height, *origin_x, *origin_y, + zoom, pan, phase, + ); + } } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs index 71ccae3..7b9fe57 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs @@ -77,6 +77,13 @@ pub struct RasterToolSettings { pub blur_sharpen_kernel: f32, /// 0 = blur, 1 = sharpen pub blur_sharpen_mode: u32, + // --- Magic wand (raster) --- + /// Color-distance threshold for magic wand selection (same scale as fill_threshold). + pub wand_threshold: f32, + /// Absolute = compare to seed pixel; Relative = compare to BFS parent. + pub wand_mode: FillThresholdMode, + /// true = BFS from click (contiguous region only); false = global color scan. + pub wand_contiguous: bool, // --- Flood fill (Paint Bucket, raster) --- /// Color-distance threshold (Euclidean RGBA, 0–510). Pixels within this /// distance of the comparison color are included in the fill. @@ -139,6 +146,9 @@ impl Default for RasterToolSettings { blur_sharpen_strength: 0.5, blur_sharpen_kernel: 5.0, blur_sharpen_mode: 0, + wand_threshold: 15.0, + wand_mode: FillThresholdMode::Absolute, + wand_contiguous: true, fill_threshold: 15.0, fill_softness: 0.0, fill_threshold_mode: FillThresholdMode::Absolute,