Paint bucket

This commit is contained in:
Skyler Lehmkuhl 2026-03-07 02:53:47 -05:00
parent 4386917fc2
commit 1e80b1bc77
6 changed files with 379 additions and 28 deletions

View File

@ -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;

View File

@ -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<u8>,
buffer_after: Vec<u8>,
width: u32,
height: u32,
}
impl RasterFillAction {
pub fn new(
layer_id: Uuid,
time: f64,
buffer_before: Vec<u8>,
buffer_after: Vec<u8>,
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()
}
}

View File

@ -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<u8>` (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\], 0255 each
/// - `threshold` max color distance (Euclidean RGBA) to include in fill (0255·√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<u8>,
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<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 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};

View File

@ -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 => {

View File

@ -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,

View File

@ -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, 0510). 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,
}
}
}