Quick select tool

This commit is contained in:
Skyler Lehmkuhl 2026-03-07 05:30:51 -05:00
parent 1900792fa9
commit 354b96f142
3 changed files with 280 additions and 19 deletions

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::MagicWand Tool::PaintBucket | Tool::RegionSelect | Tool::MagicWand | Tool::QuickSelect
); );
if !has_options { if !has_options {
@ -354,6 +354,43 @@ impl InfopanelPane {
ui.checkbox(&mut shared.raster_settings.wand_contiguous, "Contiguous"); ui.checkbox(&mut shared.raster_settings.wand_contiguous, "Contiguous");
} }
Tool::QuickSelect => {
use crate::tools::FillThresholdMode;
ui.horizontal(|ui| {
ui.label("Radius:");
ui.add(
egui::Slider::new(
&mut shared.raster_settings.quick_select_radius,
1.0_f32..=200.0,
)
.step_by(1.0),
);
});
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",
);
});
}
Tool::Polygon => { Tool::Polygon => {
// Number of sides // Number of sides
ui.horizontal(|ui| { ui.horizontal(|ui| {

View File

@ -2543,6 +2543,8 @@ pub struct StagePane {
raster_transform_state: Option<RasterTransformState>, raster_transform_state: Option<RasterTransformState>,
/// GPU transform work to dispatch in prepare(). /// GPU transform work to dispatch in prepare().
pending_transform_dispatch: Option<PendingTransformDispatch>, pending_transform_dispatch: Option<PendingTransformDispatch>,
/// Accumulated state for the quick-select brush tool.
quick_select_state: Option<QuickSelectState>,
/// Synthetic drag/click override for test mode replay (debug builds only) /// Synthetic drag/click override for test mode replay (debug builds only)
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
replay_override: Option<ReplayDragState>, replay_override: Option<ReplayDragState>,
@ -2557,6 +2559,18 @@ pub struct ReplayDragState {
pub drag_stopped: bool, pub drag_stopped: bool,
} }
/// Accumulated state for the Quick Select brush-based selection tool.
struct QuickSelectState {
/// Per-pixel OR'd selection mask (width × height).
mask: Vec<bool>,
/// RGBA snapshot of the canvas at drag start (read-only for all fills).
pixels: Vec<u8>,
width: u32,
height: u32,
/// Last canvas-pixel position where a fill was run (for debouncing).
last_pos: (i32, i32),
}
/// Cached DCEL snapshot for undo when editing vertices, curves, or control points /// Cached DCEL snapshot for undo when editing vertices, curves, or control points
#[derive(Clone)] #[derive(Clone)]
struct DcelEditingCache { struct DcelEditingCache {
@ -2758,6 +2772,7 @@ impl StagePane {
clone_stroke_offset: None, clone_stroke_offset: None,
raster_transform_state: None, raster_transform_state: None,
pending_transform_dispatch: None, pending_transform_dispatch: None,
quick_select_state: None,
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
replay_override: None, replay_override: None,
} }
@ -5861,6 +5876,169 @@ impl StagePane {
Self::lift_selection_to_float(shared); Self::lift_selection_to_float(shared);
} }
fn handle_quick_select_tool(
&mut self,
ui: &mut egui::Ui,
response: &egui::Response,
world_pos: egui::Vec2,
shared: &mut SharedPaneState,
) {
use lightningbeam_core::layer::AnyLayer;
use lightningbeam_core::selection::RasterSelection;
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;
let radius = shared.raster_settings.quick_select_radius;
let threshold = shared.raster_settings.wand_threshold;
if self.rsp_drag_started(response) {
// Commit any existing float selection before starting a new one.
Self::commit_raster_floating_now(shared);
// Ensure the 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);
}
}
// Snapshot canvas pixels.
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;
let mask = vec![false; (width * height) as usize];
let mut qs = QuickSelectState {
mask,
pixels,
width,
height,
last_pos: (seed_x - (radius as i32 * 2), seed_y), // force first fill
};
// Run the initial fill at the starting position.
let mode = match shared.raster_settings.wand_mode {
crate::tools::FillThresholdMode::Absolute =>
lightningbeam_core::flood_fill::FillThresholdMode::Absolute,
crate::tools::FillThresholdMode::Relative =>
lightningbeam_core::flood_fill::FillThresholdMode::Relative,
};
Self::quick_select_fill_point(&mut qs, seed_x, seed_y, threshold, mode, radius);
shared.selection.raster_selection = Some(RasterSelection::Mask {
data: qs.mask.clone(),
width: qs.width,
height: qs.height,
origin_x: 0,
origin_y: 0,
});
self.quick_select_state = Some(qs);
}
if self.rsp_dragged(response) {
let mode = match shared.raster_settings.wand_mode {
crate::tools::FillThresholdMode::Absolute =>
lightningbeam_core::flood_fill::FillThresholdMode::Absolute,
crate::tools::FillThresholdMode::Relative =>
lightningbeam_core::flood_fill::FillThresholdMode::Relative,
};
if let Some(ref mut qs) = self.quick_select_state {
let sx = world_pos.x as i32;
let sy = world_pos.y as i32;
let dx = sx - qs.last_pos.0;
let dy = sy - qs.last_pos.1;
let min_move = (radius / 2.0).max(1.0) as i32;
if dx * dx + dy * dy >= min_move * min_move {
Self::quick_select_fill_point(qs, sx, sy, threshold, mode, radius);
}
// Always sync raster_selection from the current mask so the
// marching ants update every frame (same pattern as marquee select).
shared.selection.raster_selection = Some(RasterSelection::Mask {
data: qs.mask.clone(),
width: qs.width,
height: qs.height,
origin_x: 0,
origin_y: 0,
});
}
}
if self.rsp_drag_stopped(response) {
if self.quick_select_state.is_some() {
Self::lift_selection_to_float(shared);
self.quick_select_state = None;
}
}
}
/// Run a single flood-fill from `(seed_x, seed_y)` clipped to a local region
/// and OR the result into `qs.mask`.
fn quick_select_fill_point(
qs: &mut QuickSelectState,
seed_x: i32, seed_y: i32,
threshold: f32,
mode: lightningbeam_core::flood_fill::FillThresholdMode,
radius: f32,
) {
use lightningbeam_core::flood_fill::raster_fill_mask;
use lightningbeam_core::selection::RasterSelection;
if seed_x < 0 || seed_y < 0
|| seed_x >= qs.width as i32
|| seed_y >= qs.height as i32
{
return;
}
let expand = (radius * 3.0) as i32;
let clip_x0 = (seed_x - expand).max(0);
let clip_y0 = (seed_y - expand).max(0);
let clip_x1 = (seed_x + expand).min(qs.width as i32);
let clip_y1 = (seed_y + expand).min(qs.height as i32);
let clip = RasterSelection::Rect(clip_x0, clip_y0, clip_x1, clip_y1);
let dist_map = raster_fill_mask(
&qs.pixels, qs.width, qs.height,
seed_x, seed_y,
threshold, mode, true, // contiguous = true
Some(&clip),
);
for (i, d) in dist_map.iter().enumerate() {
if d.is_some() {
qs.mask[i] = true;
}
}
qs.last_pos = (seed_x, seed_y);
}
/// Draw marching ants for a pixel mask selection. /// Draw marching ants for a pixel mask selection.
/// ///
/// Animates horizontal edges leftward and vertical edges downward (position-based), /// Animates horizontal edges leftward and vertical edges downward (position-based),
@ -5876,44 +6054,79 @@ impl StagePane {
) { ) {
let w = width as i32; let w = width as i32;
let h = height as i32; let h = height as i32;
let phase_i = phase as i32;
// Phase in screen pixels: 4px on, 4px off cycling every 8 screen pixels.
// One canvas pixel = zoom screen pixels; scale phase accordingly.
let screen_phase = phase; // already in screen pixels (matches draw_marching_ants)
let cycle_canvas = 8.0 / zoom.max(0.01); // canvas-pixel length of a full 8-screen-px cycle
let half_cycle_canvas = cycle_canvas / 2.0;
let to_screen = |cx: i32, cy: i32| egui::pos2( let to_screen = |cx: i32, cy: i32| egui::pos2(
rect_min.x + pan.x + cx as f32 * zoom, rect_min.x + pan.x + cx as f32 * zoom,
rect_min.y + pan.y + cy as f32 * zoom, rect_min.y + pan.y + cy as f32 * zoom,
); );
// Horizontal edges: between (row-1) and (row). Animate along x axis. // Pre-scan: compute tight bounding box of set pixels so we don't iterate
for row in 0..=h { // the full canvas every frame (critical perf for large canvases with small masks).
let mut min_row = h;
let mut max_row = -1i32;
let mut min_col = w;
let mut max_col = -1i32;
for row in 0..h {
for col in 0..w { for col in 0..w {
if data[(row * w + col) as usize] {
if row < min_row { min_row = row; }
if row > max_row { max_row = row; }
if col < min_col { min_col = col; }
if col > max_col { max_col = col; }
}
}
}
if max_row < 0 { return; } // Empty mask — nothing to draw.
let r0 = (min_row - 1).max(0);
let r1 = (max_row + 1).min(h - 1);
let c0 = (min_col - 1).max(0);
let c1 = (max_col + 1).min(w - 1);
// Horizontal edges: between (row-1) and (row). Animate along x axis.
// Use screen-space phase so the dash pattern looks correct at any zoom.
for row in r0..=(r1 + 1) {
for col in c0..=c1 {
let above = row > 0 && data[((row-1) * w + col) as usize]; let above = row > 0 && data[((row-1) * w + col) as usize];
let below = row < h && data[(row * w + col) as usize]; let below = row < h && data[(row * w + col) as usize];
if above == below { continue; } if above == below { continue; }
let cx = origin_x + col; let cx = origin_x + col;
let cy = origin_y + row; let cy = origin_y + row;
let on = (cx - phase_i).rem_euclid(8) < 4; // canvas-pixel position along the edge, converted to screen pixels for phase
let color = if on { egui::Color32::WHITE } else { egui::Color32::BLACK }; let cx_screen = cx as f32 * zoom;
painter.line_segment( let on = (cx_screen - screen_phase).rem_euclid(8.0) < 4.0;
[to_screen(cx, cy), to_screen(cx + 1, cy)], // Also check next pixel to handle partial overlap of the 4-px window
egui::Stroke::new(1.0, color), let _ = half_cycle_canvas; // suppress unused warning
); if on {
let p0 = to_screen(cx, cy);
let p1 = to_screen(cx + 1, cy);
painter.line_segment([p0, p1], egui::Stroke::new(2.5, egui::Color32::WHITE));
painter.line_segment([p0, p1], egui::Stroke::new(1.5, egui::Color32::BLACK));
}
} }
} }
// Vertical edges: between (col-1) and (col). Animate along y axis. // Vertical edges: between (col-1) and (col). Animate along y axis.
for col in 0..=w { for col in c0..=(c1 + 1) {
for row in 0..h { for row in r0..=r1 {
let left = col > 0 && data[(row * w + col - 1) as usize]; let left = col > 0 && data[(row * w + col - 1) as usize];
let right = col < w && data[(row * w + col ) as usize]; let right = col < w && data[(row * w + col ) as usize];
if left == right { continue; } if left == right { continue; }
let cx = origin_x + col; let cx = origin_x + col;
let cy = origin_y + row; let cy = origin_y + row;
let on = (cy - phase_i).rem_euclid(8) < 4; let cy_screen = cy as f32 * zoom;
let color = if on { egui::Color32::WHITE } else { egui::Color32::BLACK }; let on = (cy_screen - screen_phase).rem_euclid(8.0) < 4.0;
painter.line_segment( if on {
[to_screen(cx, cy), to_screen(cx, cy + 1)], let p0 = to_screen(cx, cy);
egui::Stroke::new(1.0, color), let p1 = to_screen(cx, cy + 1);
); painter.line_segment([p0, p1], egui::Stroke::new(2.5, egui::Color32::WHITE));
painter.line_segment([p0, p1], egui::Stroke::new(1.5, egui::Color32::BLACK));
}
} }
} }
} }
@ -8495,6 +8708,9 @@ impl StagePane {
Tool::MagicWand => { Tool::MagicWand => {
self.handle_magic_wand_tool(&response, world_pos, shared); self.handle_magic_wand_tool(&response, world_pos, shared);
} }
Tool::QuickSelect => {
self.handle_quick_select_tool(ui, &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);
} }
@ -8978,7 +9194,10 @@ impl StagePane {
use lightningbeam_core::tool::Tool; use lightningbeam_core::tool::Tool;
// Compute semi-axes (world pixels) and dab rotation angle. // Compute semi-axes (world pixels) and dab rotation angle.
let (a_world, b_world, dab_angle_rad) = if let Some(def) = crate::tools::raster_tool_def(shared.selected_tool) { let (a_world, b_world, dab_angle_rad) = if matches!(*shared.selected_tool, Tool::QuickSelect) {
let r = shared.raster_settings.quick_select_radius;
(r, r, 0.0_f32)
} else if let Some(def) = crate::tools::raster_tool_def(shared.selected_tool) {
let r = def.cursor_radius(shared.raster_settings); let r = def.cursor_radius(shared.raster_settings);
// For the standard paint brush, also account for elliptical shape. // For the standard paint brush, also account for elliptical shape.
if matches!(*shared.selected_tool, if matches!(*shared.selected_tool,
@ -9744,6 +9963,7 @@ impl PaneRenderer for StagePane {
| Tool::Erase | Tool::Smudge | Tool::Erase | Tool::Smudge
| Tool::CloneStamp | Tool::HealingBrush | Tool::PatternStamp | Tool::CloneStamp | Tool::HealingBrush | Tool::PatternStamp
| Tool::DodgeBurn | Tool::Sponge | Tool::BlurSharpen | Tool::DodgeBurn | Tool::Sponge | Tool::BlurSharpen
| Tool::QuickSelect
) && shared.active_layer_id.and_then(|id| { ) && shared.active_layer_id.and_then(|id| {
shared.action_executor.document().get_layer(&id) shared.action_executor.document().get_layer(&id)
}).map_or(false, |l| matches!(l, lightningbeam_core::layer::AnyLayer::Raster(_))); }).map_or(false, |l| matches!(l, lightningbeam_core::layer::AnyLayer::Raster(_)));

View File

@ -84,6 +84,9 @@ pub struct RasterToolSettings {
pub wand_mode: FillThresholdMode, pub wand_mode: FillThresholdMode,
/// true = BFS from click (contiguous region only); false = global color scan. /// true = BFS from click (contiguous region only); false = global color scan.
pub wand_contiguous: bool, pub wand_contiguous: bool,
// --- Quick Select ---
/// Brush radius in canvas pixels for the quick-select tool.
pub quick_select_radius: f32,
// --- 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.
@ -152,6 +155,7 @@ impl Default for RasterToolSettings {
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,
quick_select_radius: 20.0,
} }
} }
} }