Paint bucket
This commit is contained in:
parent
4386917fc2
commit
1e80b1bc77
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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\], 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<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};
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue