diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs index 34bac39..352bae3 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs @@ -32,6 +32,7 @@ pub mod region_split; pub mod toggle_group_expansion; pub mod group_layers; pub mod raster_stroke; +pub mod raster_fill; pub mod move_layer; pub use add_clip_instance::AddClipInstanceAction; @@ -63,4 +64,5 @@ pub use region_split::RegionSplitAction; pub use toggle_group_expansion::ToggleGroupExpansionAction; pub use group_layers::GroupLayersAction; pub use raster_stroke::RasterStrokeAction; +pub use raster_fill::RasterFillAction; pub use move_layer::MoveLayerAction; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/raster_fill.rs b/lightningbeam-ui/lightningbeam-core/src/actions/raster_fill.rs new file mode 100644 index 0000000..38a4358 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/raster_fill.rs @@ -0,0 +1,58 @@ +//! Raster flood-fill action — records and undoes a paint bucket fill on a RasterLayer. + +use crate::action::Action; +use crate::document::Document; +use crate::layer::AnyLayer; +use uuid::Uuid; + +pub struct RasterFillAction { + layer_id: Uuid, + time: f64, + buffer_before: Vec, + buffer_after: Vec, + width: u32, + height: u32, +} + +impl RasterFillAction { + pub fn new( + layer_id: Uuid, + time: f64, + buffer_before: Vec, + buffer_after: Vec, + width: u32, + height: u32, + ) -> Self { + Self { layer_id, time, buffer_before, buffer_after, width, height } + } +} + +impl Action for RasterFillAction { + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + let layer = document.get_layer_mut(&self.layer_id) + .ok_or_else(|| format!("Layer {} not found", self.layer_id))?; + let raster = match layer { + AnyLayer::Raster(rl) => rl, + _ => return Err("Not a raster layer".to_string()), + }; + let kf = raster.ensure_keyframe_at(self.time, self.width, self.height); + kf.raw_pixels = self.buffer_after.clone(); + Ok(()) + } + + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + let layer = document.get_layer_mut(&self.layer_id) + .ok_or_else(|| format!("Layer {} not found", self.layer_id))?; + let raster = match layer { + AnyLayer::Raster(rl) => rl, + _ => return Err("Not a raster layer".to_string()), + }; + let kf = raster.ensure_keyframe_at(self.time, self.width, self.height); + kf.raw_pixels = self.buffer_before.clone(); + Ok(()) + } + + fn description(&self) -> String { + "Flood fill".to_string() + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/flood_fill.rs b/lightningbeam-ui/lightningbeam-core/src/flood_fill.rs index 023cea2..99ba88a 100644 --- a/lightningbeam-ui/lightningbeam-core/src/flood_fill.rs +++ b/lightningbeam-ui/lightningbeam-core/src/flood_fill.rs @@ -1,8 +1,172 @@ -//! Flood fill algorithm for paint bucket tool +//! Flood fill algorithms for paint bucket tool //! -//! This module implements a flood fill that tracks which curves each point -//! touches. Instead of filling with pixels, it returns boundary points that -//! can be used to construct a filled shape from exact curve geometry. +//! This module contains two fill implementations: +//! - `flood_fill` — vector curve-boundary fill (used by vector paint bucket) +//! - `raster_flood_fill` — pixel BFS fill with configurable threshold, soft +//! edge, and optional selection clipping (used by raster paint bucket) + +// ── Raster flood fill ───────────────────────────────────────────────────────── + +/// Which pixel to compare against when deciding if a neighbor should be filled. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FillThresholdMode { + /// Compare each candidate pixel to the original seed pixel (Photoshop default). + Absolute, + /// Compare each candidate pixel to the pixel it was reached from (spreads + /// through gradients without a global seed-color reference). + Relative, +} + +/// Pixel flood fill for the raster paint bucket tool. +/// +/// 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. +/// +/// # Parameters +/// - `pixels` – raw RGBA buffer, modified in place +/// - `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 +pub fn raster_flood_fill( + pixels: &mut Vec, + width: u32, + height: u32, + seed_x: i32, + seed_y: i32, + fill_color: [u8; 4], + threshold: f32, + softness: f32, + mode: FillThresholdMode, + selection: Option<&crate::selection::RasterSelection>, +) { + use std::collections::VecDeque; + + let w = width as i32; + let h = height as i32; + 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 + } else { + 1.0 - softness / 100.0 + }; + + for i in 0..n { + if let Some(d) = dist_map[i] { + let alpha = if threshold <= 0.0 { + fa_base + } else { + let t = d / threshold; // 0.0 at seed, 1.0 at boundary + if t <= falloff_start { + fa_base + } else { + let frac = (t - falloff_start) / (1.0 - falloff_start).max(1e-6); + 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; + } + } + } +} + +fn color_distance(a: [u8; 4], b: [u8; 4]) -> f32 { + let dr = a[0] as f32 - b[0] as f32; + let dg = a[1] as f32 - b[1] as f32; + let db = a[2] as f32 - b[2] as f32; + let da = a[3] as f32 - b[3] as f32; + (dr * dr + dg * dg + db * db + da * da).sqrt() +} + +// ── Vector (curve-boundary) flood fill ─────────────────────────────────────── +// The following is the original vector-layer flood fill, kept for the vector +// paint bucket tool. use crate::curve_segment::CurveSegment; use crate::quadtree::{BoundingBox, Quadtree}; diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index c1bbc3b..b8809b3 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -278,15 +278,52 @@ impl InfopanelPane { } Tool::PaintBucket => { - // Gap tolerance - ui.horizontal(|ui| { - ui.label("Gap Tolerance:"); - ui.add( - DragValue::new(shared.paint_bucket_gap_tolerance) - .speed(0.1) - .range(0.0..=50.0), - ); - }); + if active_is_raster { + use crate::tools::FillThresholdMode; + ui.horizontal(|ui| { + ui.label("Threshold:"); + ui.add( + egui::Slider::new( + &mut shared.raster_settings.fill_threshold, + 0.0_f32..=255.0, + ) + .step_by(1.0), + ); + }); + ui.horizontal(|ui| { + ui.label("Softness:"); + ui.add( + egui::Slider::new( + &mut shared.raster_settings.fill_softness, + 0.0_f32..=100.0, + ) + .custom_formatter(|v, _| format!("{:.0}%", v)), + ); + }); + ui.horizontal(|ui| { + ui.label("Mode:"); + ui.selectable_value( + &mut shared.raster_settings.fill_threshold_mode, + FillThresholdMode::Absolute, + "Absolute", + ); + ui.selectable_value( + &mut shared.raster_settings.fill_threshold_mode, + FillThresholdMode::Relative, + "Relative", + ); + }); + } else { + // Vector: gap tolerance + ui.horizontal(|ui| { + ui.label("Gap Tolerance:"); + ui.add( + DragValue::new(shared.paint_bucket_gap_tolerance) + .speed(0.1) + .range(0.0..=50.0), + ); + }); + } } Tool::Polygon => { diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index ba5ff9c..be91fbc 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -5669,31 +5669,28 @@ impl StagePane { shared: &mut SharedPaneState, ) { use lightningbeam_core::layer::AnyLayer; - use lightningbeam_core::shape::ShapeColor; - use lightningbeam_core::actions::PaintBucketAction; - use vello::kurbo::Point; - // Check if we have an active vector layer let active_layer_id = match shared.active_layer_id { - Some(id) => id, + Some(id) => *id, None => return, }; - let active_layer = match shared.action_executor.document().get_layer(&active_layer_id) { - Some(layer) => layer, - None => return, - }; + if !self.rsp_clicked(response) { return; } - if !matches!(active_layer, AnyLayer::Vector(_)) { - return; - } + let is_raster = shared.action_executor.document() + .get_layer(&active_layer_id) + .map_or(false, |l| matches!(l, AnyLayer::Raster(_))); - if self.rsp_clicked(response) { + if is_raster { + self.handle_raster_paint_bucket(world_pos, active_layer_id, shared); + } else { + use lightningbeam_core::shape::ShapeColor; + use lightningbeam_core::actions::PaintBucketAction; + use vello::kurbo::Point; let click_point = Point::new(world_pos.x as f64, world_pos.y as f64); let fill_color = ShapeColor::from_egui(*shared.fill_color); - let action = PaintBucketAction::new( - *active_layer_id, + active_layer_id, *shared.playback_time, click_point, fill_color, @@ -5702,6 +5699,77 @@ impl StagePane { } } + fn handle_raster_paint_bucket( + &mut self, + world_pos: egui::Vec2, + layer_id: uuid::Uuid, + shared: &mut SharedPaneState, + ) { + use lightningbeam_core::layer::AnyLayer; + use lightningbeam_core::actions::RasterFillAction; + use lightningbeam_core::flood_fill::{raster_flood_fill, FillThresholdMode}; + use crate::tools::FillThresholdMode as EditorMode; + + let time = *shared.playback_time; + + // Ensure a keyframe exists at the current time. + 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 current pixels. + let (buffer_before, 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 fill_egui = *shared.fill_color; + let fill_color = [fill_egui.r(), fill_egui.g(), fill_egui.b(), fill_egui.a()]; + let threshold = shared.raster_settings.fill_threshold; + let softness = shared.raster_settings.fill_softness; + let core_mode = match shared.raster_settings.fill_threshold_mode { + EditorMode::Absolute => FillThresholdMode::Absolute, + EditorMode::Relative => FillThresholdMode::Relative, + }; + + let mut buffer_after = buffer_before.clone(); + raster_flood_fill( + &mut buffer_after, + width, height, + seed_x, seed_y, + fill_color, + threshold, softness, + core_mode, + shared.selection.raster_selection.as_ref(), + ); + + let action = RasterFillAction::new(layer_id, time, buffer_before, buffer_after, width, height); + let _ = shared.action_executor.execute(Box::new(action)); + } + /// Apply transform preview to objects based on current mouse position fn apply_transform_preview( vector_layer: &mut lightningbeam_core::layer::VectorLayer, diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs index 55039db..71ccae3 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs @@ -77,6 +77,25 @@ pub struct RasterToolSettings { pub blur_sharpen_kernel: f32, /// 0 = blur, 1 = sharpen pub blur_sharpen_mode: u32, + // --- 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. + pub fill_threshold: f32, + /// Soft-edge width as a percentage of the threshold (0 = hard, 100 = full fade). + pub fill_softness: f32, + /// Whether to compare each pixel to the seed pixel (Absolute) or to its BFS + /// parent pixel (Relative, spreads across gradients). + pub fill_threshold_mode: FillThresholdMode, +} + +/// Threshold comparison mode for the raster flood fill. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum FillThresholdMode { + /// Compare each candidate pixel to the original seed pixel (default). + #[default] + Absolute, + /// Compare each candidate pixel to the pixel it was reached from. + Relative, } impl Default for RasterToolSettings { @@ -120,6 +139,9 @@ impl Default for RasterToolSettings { blur_sharpen_strength: 0.5, blur_sharpen_kernel: 5.0, blur_sharpen_mode: 0, + fill_threshold: 15.0, + fill_softness: 0.0, + fill_threshold_mode: FillThresholdMode::Absolute, } } }