Magic wand selection
This commit is contained in:
parent
1e80b1bc77
commit
1900792fa9
|
|
@ -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\], 0–255 each
|
/// - `threshold` – max color distance to include
|
||||||
/// - `threshold` – max color distance (Euclidean RGBA) to include in fill (0–255·√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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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| {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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, 0–510). Pixels within this
|
/// Color-distance threshold (Euclidean RGBA, 0–510). 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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue