Magic wand selection

This commit is contained in:
Skyler Lehmkuhl 2026-03-07 03:39:09 -05:00
parent 1e80b1bc77
commit 1900792fa9
6 changed files with 345 additions and 87 deletions

View File

@ -17,22 +17,112 @@ pub enum FillThresholdMode {
Relative, Relative,
} }
/// Pixel flood fill for the raster paint bucket tool. /// BFS / global scan flood fill mask.
/// ///
/// Operates on a flat RGBA `Vec<u8>` (4 bytes per pixel, row-major). /// Returns a `Vec<Option<f32>>` of length `width × height`:
/// Pixels are alpha-composited with `fill_color` — the existing canvas content /// - `Some(d)` — pixel is within the fill region; `d` is the color distance
/// shows through wherever `fill_color` has partial transparency or softness /// from its comparison color (0.0 at seed, up to `threshold` at the edge).
/// reduces its effective alpha. /// - `None` — pixel is outside the fill region.
/// ///
/// # Parameters /// # Parameters
/// - `pixels` raw RGBA buffer, modified in place /// - `pixels` raw RGBA buffer (read-only)
/// - `width/height` canvas dimensions /// - `width/height` canvas dimensions
/// - `seed_x/y` click coordinates (canvas pixel indices, 0-based) /// - `seed_x/y` click coordinates (canvas pixel indices, 0-based)
/// - `fill_color` RGBA fill \[r, g, b, a\], 0255 each /// - `threshold` max color distance to include
/// - `threshold` max color distance (Euclidean RGBA) to include in fill (0255·√4 ≈ 510) /// - `mode` Absolute = compare to seed; Relative = compare to BFS parent
/// - `softness` how much of the threshold is a soft fade (0 = hard edge, 100 = full fade) /// - `contiguous` true = BFS from seed (connected region only);
/// - `mode` compare candidates to seed (Absolute) or to their BFS parent (Relative) /// false = scan every pixel against seed color globally
/// - `selection` optional clip mask; pixels outside are never filled /// - `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<Option<f32>> {
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<Option<f32>> = 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( pub fn raster_flood_fill(
pixels: &mut Vec<u8>, pixels: &mut Vec<u8>,
width: u32, width: u32,
@ -43,81 +133,20 @@ pub fn raster_flood_fill(
threshold: f32, threshold: f32,
softness: f32, softness: f32,
mode: FillThresholdMode, mode: FillThresholdMode,
contiguous: bool,
selection: Option<&crate::selection::RasterSelection>, selection: Option<&crate::selection::RasterSelection>,
) { ) {
use std::collections::VecDeque; let dist_map = raster_fill_mask(pixels, width, height, seed_x, seed_y,
threshold, mode, contiguous, selection);
let w = width as i32;
let h = height as i32;
let n = (width * height) as usize; 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<Option<f32>> = 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 fr = fill_color[0] as f32 / 255.0;
let fg = fill_color[1] as f32 / 255.0; let fg = fill_color[1] as f32 / 255.0;
let fb = fill_color[2] as f32 / 255.0; let fb = fill_color[2] as f32 / 255.0;
let fa_base = fill_color[3] 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 { let falloff_start = if softness <= 0.0 || threshold <= 0.0 {
1.0_f32 // hard edge: never start fading 1.0_f32
} else { } else {
1.0 - softness / 100.0 1.0 - softness / 100.0
}; };
@ -127,7 +156,7 @@ pub fn raster_flood_fill(
let alpha = if threshold <= 0.0 { let alpha = if threshold <= 0.0 {
fa_base fa_base
} else { } else {
let t = d / threshold; // 0.0 at seed, 1.0 at boundary let t = d / threshold;
if t <= falloff_start { if t <= falloff_start {
fa_base fa_base
} else { } else {
@ -135,22 +164,19 @@ pub fn raster_flood_fill(
fa_base * (1.0 - frac) fa_base * (1.0 - frac)
} }
}; };
if alpha <= 0.0 { continue; } 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_r = pixels[i * 4 ] as f32 / 255.0;
let dst_g = pixels[i * 4 + 1] 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_b = pixels[i * 4 + 2] as f32 / 255.0;
let dst_a = pixels[i * 4 + 3] as f32 / 255.0; let dst_a = pixels[i * 4 + 3] as f32 / 255.0;
let inv_a = 1.0 - alpha; let inv_a = 1.0 - alpha;
let out_a = alpha + dst_a * inv_a; let out_a = alpha + dst_a * inv_a;
if out_a > 0.0 { 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 ] = ((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+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+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+3] = (out_a * 255.0).round() as u8;
} }
} }
} }

View File

@ -15,6 +15,16 @@ pub enum RasterSelection {
Rect(i32, i32, i32, i32), Rect(i32, i32, i32, i32),
/// Closed freehand lasso polygon. /// Closed freehand lasso polygon.
Lasso(Vec<(i32, i32)>), 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<bool>,
width: u32,
height: u32,
/// Top-left canvas pixel of the mask's bounding canvas region.
origin_x: i32,
origin_y: i32,
},
} }
impl RasterSelection { impl RasterSelection {
@ -29,6 +39,23 @@ impl RasterSelection {
let y1 = pts.iter().map(|p| p.1).max().unwrap_or(0); let y1 = pts.iter().map(|p| p.1).max().unwrap_or(0);
(x0, y0, x1, y1) (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 { match self {
Self::Rect(x0, y0, x1, y1) => px >= *x0 && px < *x1 && py >= *y0 && py < *y1, Self::Rect(x0, y0, x1, y1) => px >= *x0 && px < *x1 && py >= *y0 && py < *y1,
Self::Lasso(pts) => point_in_polygon(px, py, pts), 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]
}
} }
} }
} }

View File

@ -1869,7 +1869,8 @@ impl EditorApp {
let cy = y0 + row; let cy = y0 + row;
let inside = match sel { let inside = match sel {
RasterSelection::Rect(..) => true, 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 { if inside {
let src = ((cy * canvas_w + cx) * 4) as usize; let src = ((cy * canvas_w + cx) * 4) as usize;

View File

@ -184,7 +184,7 @@ impl InfopanelPane {
let has_options = is_vector_tool || is_raster_paint_tool || is_raster_transform || matches!( let has_options = is_vector_tool || is_raster_paint_tool || is_raster_transform || matches!(
tool, tool,
Tool::PaintBucket | Tool::RegionSelect Tool::PaintBucket | Tool::RegionSelect | Tool::MagicWand
); );
if !has_options { 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 => { Tool::Polygon => {
// Number of sides // Number of sides
ui.horizontal(|ui| { ui.horizontal(|ui| {

View File

@ -5501,6 +5501,13 @@ impl StagePane {
RasterSelection::Rect(*x0 + dx, *y0 + dy, *x1 + dx, *y1 + dy), RasterSelection::Rect(*x0 + dx, *y0 + dy, *x1 + dx, *y1 + dy),
RasterSelection::Lasso(pts) => RasterSelection::Lasso(pts) =>
RasterSelection::Lasso(pts.iter().map(|(x, y)| (x + dx, y + dy)).collect()), 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. // Shift floating pixels if any.
@ -5763,6 +5770,7 @@ impl StagePane {
fill_color, fill_color,
threshold, softness, threshold, softness,
core_mode, core_mode,
true, // paint bucket always fills contiguous region
shared.selection.raster_selection.as_ref(), shared.selection.raster_selection.as_ref(),
); );
@ -5770,6 +5778,146 @@ impl StagePane {
let _ = shared.action_executor.execute(Box::new(action)); 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<bool> = 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 /// Apply transform preview to objects based on current mouse position
fn apply_transform_preview( fn apply_transform_preview(
vector_layer: &mut lightningbeam_core::layer::VectorLayer, vector_layer: &mut lightningbeam_core::layer::VectorLayer,
@ -8344,6 +8492,9 @@ impl StagePane {
Tool::SelectLasso => { Tool::SelectLasso => {
self.handle_raster_lasso_tool(ui, &response, world_pos, shared); self.handle_raster_lasso_tool(ui, &response, world_pos, shared);
} }
Tool::MagicWand => {
self.handle_magic_wand_tool(&response, world_pos, shared);
}
Tool::Transform => { Tool::Transform => {
self.handle_transform_tool(ui, &response, world_pos, shared); self.handle_transform_tool(ui, &response, world_pos, shared);
} }
@ -8700,6 +8851,13 @@ impl StagePane {
RasterSelection::Lasso(pts) => { RasterSelection::Lasso(pts) => {
Self::draw_marching_ants_lasso(&painter, rect.min, pts, zoom, pan, phase); 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,
);
}
} }
} }

View File

@ -77,6 +77,13 @@ pub struct RasterToolSettings {
pub blur_sharpen_kernel: f32, pub blur_sharpen_kernel: f32,
/// 0 = blur, 1 = sharpen /// 0 = blur, 1 = sharpen
pub blur_sharpen_mode: u32, 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) --- // --- Flood fill (Paint Bucket, raster) ---
/// Color-distance threshold (Euclidean RGBA, 0510). Pixels within this /// Color-distance threshold (Euclidean RGBA, 0510). Pixels within this
/// distance of the comparison color are included in the fill. /// 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_strength: 0.5,
blur_sharpen_kernel: 5.0, blur_sharpen_kernel: 5.0,
blur_sharpen_mode: 0, blur_sharpen_mode: 0,
wand_threshold: 15.0,
wand_mode: FillThresholdMode::Absolute,
wand_contiguous: true,
fill_threshold: 15.0, fill_threshold: 15.0,
fill_softness: 0.0, fill_softness: 0.0,
fill_threshold_mode: FillThresholdMode::Absolute, fill_threshold_mode: FillThresholdMode::Absolute,