Fix smudge tool

This commit is contained in:
Skyler Lehmkuhl 2026-03-01 14:00:39 -05:00
parent 1c7256a12e
commit e85efe7405
7 changed files with 260 additions and 105 deletions

View File

@ -42,13 +42,11 @@ use crate::raster_layer::{RasterBlendMode, StrokeRecord};
pub struct StrokeState {
/// Distance along the path already "consumed" toward the next dab (in pixels)
pub distance_since_last_dab: f32,
/// Accumulated canvas color for smudge mode (RGBA linear, updated each dab)
pub smudge_color: [f32; 4],
}
impl StrokeState {
pub fn new() -> Self {
Self { distance_since_last_dab: 0.0, smudge_color: [0.0; 4] }
Self { distance_since_last_dab: 0.0 }
}
}
@ -85,12 +83,8 @@ impl BrushEngine {
if let Some(pt) = stroke.points.first() {
let r = stroke.brush_settings.radius_at_pressure(pt.pressure);
let o = stroke.brush_settings.opacity_at_pressure(pt.pressure);
if matches!(stroke.blend_mode, RasterBlendMode::Smudge) {
// Seed smudge color from canvas at the tap position
state.smudge_color = Self::sample_average(buffer, pt.x, pt.y, r);
Self::render_dab(buffer, pt.x, pt.y, r, stroke.brush_settings.hardness,
o, state.smudge_color, RasterBlendMode::Normal);
} else {
// Smudge has no drag direction on a single tap — skip painting
if !matches!(stroke.blend_mode, RasterBlendMode::Smudge) {
Self::render_dab(buffer, pt.x, pt.y, r, stroke.brush_settings.hardness,
o, stroke.color, stroke.blend_mode);
}
@ -137,16 +131,15 @@ impl BrushEngine {
let opacity2 = stroke.brush_settings.opacity_at_pressure(pressure2);
if matches!(stroke.blend_mode, RasterBlendMode::Smudge) {
// Sample canvas under dab, blend into running smudge color
let sampled = Self::sample_average(buffer, x2, y2, radius2);
const PICK_UP: f32 = 0.15;
for i in 0..4 {
state.smudge_color[i] = state.smudge_color[i] * (1.0 - PICK_UP)
+ sampled[i] * PICK_UP;
}
Self::render_dab(buffer, x2, y2, radius2,
stroke.brush_settings.hardness,
opacity2, state.smudge_color, RasterBlendMode::Normal);
// Directional warp smudge: each pixel in the dab footprint
// samples from a position offset backwards along the stroke,
// preserving lateral color structure.
let ndx = dx / seg_len;
let ndy = dy / seg_len;
let smudge_dist = (radius2 * stroke.brush_settings.dabs_per_radius).max(1.0);
Self::render_smudge_dab(buffer, x2, y2, radius2,
stroke.brush_settings.hardness,
opacity2, ndx, ndy, smudge_dist);
} else {
Self::render_dab(buffer, x2, y2, radius2,
stroke.brush_settings.hardness,
@ -235,8 +228,11 @@ impl BrushEngine {
(out_r, out_g, out_b, out_a)
}
RasterBlendMode::Erase => {
// Reduce destination alpha by dab_alpha
let new_a = (dst[3] - dab_alpha).max(0.0);
// Multiplicative erase: each dab removes dab_alpha *fraction* of remaining
// alpha. This prevents dense overlapping dabs from summing past 1.0 and
// fully erasing at low opacity — opacity now controls the per-dab fraction
// removed rather than an absolute amount.
let new_a = dst[3] * (1.0 - dab_alpha);
let scale = if dst[3] > 1e-6 { new_a / dst[3] } else { 0.0 };
(dst[0] * scale, dst[1] * scale, dst[2] * scale, new_a)
}
@ -250,35 +246,121 @@ impl BrushEngine {
}
}
/// Sample the average RGBA color in a circular region of `radius` around (x, y).
/// Render a smudge dab using directional per-pixel warp.
///
/// Used by smudge to pick up canvas color before painting each dab.
fn sample_average(buffer: &RgbaImage, x: f32, y: f32, radius: f32) -> [f32; 4] {
let sample_r = (radius * 0.5).max(1.0);
let x0 = ((x - sample_r).floor() as i32).max(0) as u32;
let y0 = ((y - sample_r).floor() as i32).max(0) as u32;
let x1 = ((x + sample_r).ceil() as i32).min(buffer.width() as i32 - 1).max(0) as u32;
let y1 = ((y + sample_r).ceil() as i32).min(buffer.height() as i32 - 1).max(0) as u32;
/// Each pixel in the dab footprint samples from the canvas at a position offset
/// backwards along `(ndx, ndy)` by `smudge_dist` pixels, then blends that
/// sampled color over the current pixel weighted by the dab opacity.
///
/// Because each pixel samples its own source position, lateral color structure
/// is preserved: dragging over a 1-pixel dot with a 20-pixel brush produces a
/// narrow streak rather than a uniform smear.
///
/// Updates are collected before any writes to avoid read/write aliasing.
fn render_smudge_dab(
buffer: &mut RgbaImage,
x: f32,
y: f32,
radius: f32,
hardness: f32,
opacity: f32,
ndx: f32, // normalized stroke direction x
ndy: f32, // normalized stroke direction y
smudge_dist: f32,
) {
if radius < 0.5 || opacity <= 0.0 {
return;
}
let hardness = hardness.clamp(1e-3, 1.0);
let seg1_offset = 1.0f32;
let seg1_slope = -(1.0 / hardness - 1.0);
let seg2_offset = hardness / (1.0 - hardness);
let seg2_slope = -hardness / (1.0 - hardness);
let r_fringe = radius + 1.0;
let x0 = ((x - r_fringe).floor() as i32).max(0) as u32;
let y0 = ((y - r_fringe).floor() as i32).max(0) as u32;
let x1 = ((x + r_fringe).ceil() as i32).min(buffer.width() as i32 - 1).max(0) as u32;
let y1 = ((y + r_fringe).ceil() as i32).min(buffer.height() as i32 - 1).max(0) as u32;
let one_over_r2 = 1.0 / (radius * radius);
// Collect updates before writing to avoid aliasing between source and dest reads
let mut updates: Vec<(u32, u32, [u8; 4])> = Vec::new();
let mut sum = [0.0f32; 4];
let mut count = 0u32;
for py in y0..=y1 {
for px in x0..=x1 {
let p = buffer.get_pixel(px, py);
sum[0] += p[0] as f32 / 255.0;
sum[1] += p[1] as f32 / 255.0;
sum[2] += p[2] as f32 / 255.0;
sum[3] += p[3] as f32 / 255.0;
count += 1;
let fdx = px as f32 + 0.5 - x;
let fdy = py as f32 + 0.5 - y;
let rr = (fdx * fdx + fdy * fdy) * one_over_r2;
if rr > 1.0 {
continue;
}
let opa_weight = if rr <= hardness {
seg1_offset + rr * seg1_slope
} else {
seg2_offset + rr * seg2_slope
}
.clamp(0.0, 1.0);
let alpha = opa_weight * opacity;
if alpha <= 0.0 {
continue;
}
// Sample from one dab-spacing behind the current position along stroke
let src_x = px as f32 + 0.5 - ndx * smudge_dist;
let src_y = py as f32 + 0.5 - ndy * smudge_dist;
let src = Self::sample_bilinear(buffer, src_x, src_y);
let dst = buffer.get_pixel(px, py);
let da = 1.0 - alpha;
let out = [
((alpha * src[0] + da * dst[0] as f32 / 255.0).clamp(0.0, 1.0) * 255.0) as u8,
((alpha * src[1] + da * dst[1] as f32 / 255.0).clamp(0.0, 1.0) * 255.0) as u8,
((alpha * src[2] + da * dst[2] as f32 / 255.0).clamp(0.0, 1.0) * 255.0) as u8,
((alpha * src[3] + da * dst[3] as f32 / 255.0).clamp(0.0, 1.0) * 255.0) as u8,
];
updates.push((px, py, out));
}
}
if count > 0 {
let n = count as f32;
[sum[0] / n, sum[1] / n, sum[2] / n, sum[3] / n]
} else {
[0.0; 4]
for (px, py, rgba) in updates {
let p = buffer.get_pixel_mut(px, py);
p[0] = rgba[0];
p[1] = rgba[1];
p[2] = rgba[2];
p[3] = rgba[3];
}
}
/// Bilinearly sample a floating-point position from the buffer, clamped to bounds.
fn sample_bilinear(buffer: &RgbaImage, x: f32, y: f32) -> [f32; 4] {
let w = buffer.width() as i32;
let h = buffer.height() as i32;
let x0 = (x.floor() as i32).clamp(0, w - 1);
let y0 = (y.floor() as i32).clamp(0, h - 1);
let x1 = (x0 + 1).min(w - 1);
let y1 = (y0 + 1).min(h - 1);
let fx = (x - x0 as f32).clamp(0.0, 1.0);
let fy = (y - y0 as f32).clamp(0.0, 1.0);
let p00 = buffer.get_pixel(x0 as u32, y0 as u32);
let p10 = buffer.get_pixel(x1 as u32, y0 as u32);
let p01 = buffer.get_pixel(x0 as u32, y1 as u32);
let p11 = buffer.get_pixel(x1 as u32, y1 as u32);
let mut out = [0.0f32; 4];
for i in 0..4 {
let top = p00[i] as f32 * (1.0 - fx) + p10[i] as f32 * fx;
let bot = p01[i] as f32 * (1.0 - fx) + p11[i] as f32 * fx;
out[i] = (top * (1.0 - fy) + bot * fy) / 255.0;
}
out
}
}
/// Create an `RgbaImage` from a raw RGBA pixel buffer.

View File

@ -119,25 +119,18 @@
"name": "Drawing/Painting",
"description": "Minimal UI - just canvas and drawing tools",
"layout": {
"type": "vertical-grid",
"percent": 8,
"type": "horizontal-grid",
"percent": 15,
"children": [
{ "type": "pane", "name": "toolbar" },
{
"type": "horizontal-grid",
"percent": 85,
"type": "vertical-grid",
"percent": 30,
"children": [
{ "type": "pane", "name": "stage" },
{
"type": "vertical-grid",
"percent": 70,
"children": [
{ "type": "pane", "name": "infopanel" },
{ "type": "pane", "name": "timelineV2" }
]
}
{ "type": "pane", "name": "toolbar" },
{ "type": "pane", "name": "infopanel" }
]
}
},
{ "type": "pane", "name": "stage" }
]
}
}

View File

@ -755,6 +755,11 @@ struct EditorApp {
draw_simplify_mode: lightningbeam_core::tool::SimplifyMode, // Current simplification mode for draw tool
rdp_tolerance: f64, // RDP simplification tolerance (default: 10.0)
schneider_max_error: f64, // Schneider curve fitting max error (default: 30.0)
// Raster brush settings
brush_radius: f32, // brush radius in pixels
brush_opacity: f32, // brush opacity 0.01.0
brush_hardness: f32, // brush hardness 0.01.0
brush_spacing: f32, // dabs_per_radius (fraction of radius per dab)
// Audio engine integration
#[allow(dead_code)] // Must be kept alive to maintain audio output
audio_stream: Option<cpal::Stream>,
@ -1026,6 +1031,10 @@ impl EditorApp {
draw_simplify_mode: lightningbeam_core::tool::SimplifyMode::Smooth, // Default to smooth curves
rdp_tolerance: 10.0, // Default RDP tolerance
schneider_max_error: 30.0, // Default Schneider max error
brush_radius: 10.0,
brush_opacity: 1.0,
brush_hardness: 0.5,
brush_spacing: 0.1,
audio_stream,
audio_controller,
audio_event_rx,
@ -5069,6 +5078,10 @@ impl eframe::App for EditorApp {
draw_simplify_mode: &mut self.draw_simplify_mode,
rdp_tolerance: &mut self.rdp_tolerance,
schneider_max_error: &mut self.schneider_max_error,
brush_radius: &mut self.brush_radius,
brush_opacity: &mut self.brush_opacity,
brush_hardness: &mut self.brush_hardness,
brush_spacing: &mut self.brush_spacing,
audio_controller: self.audio_controller.as_ref(),
video_manager: &self.video_manager,
playback_time: &mut self.playback_time,

View File

@ -147,13 +147,19 @@ impl InfopanelPane {
fn render_tool_section(&mut self, ui: &mut Ui, path: &NodePath, shared: &mut SharedPaneState) {
let tool = *shared.selected_tool;
let active_is_raster = shared.active_layer_id
.and_then(|id| shared.action_executor.document().get_layer(&id))
.map_or(false, |l| matches!(l, AnyLayer::Raster(_)));
let is_raster_paint_tool = active_is_raster && matches!(tool, Tool::Draw | Tool::Erase | Tool::Smudge);
// Only show tool options for tools that have options
let is_vector_tool = matches!(
let is_vector_tool = !active_is_raster && matches!(
tool,
Tool::Select | Tool::BezierEdit | Tool::Draw | Tool::Rectangle
| Tool::Ellipse | Tool::Line | Tool::Polygon
);
let has_options = is_vector_tool || matches!(
let has_options = is_vector_tool || is_raster_paint_tool || matches!(
tool,
Tool::PaintBucket | Tool::RegionSelect
);
@ -162,7 +168,17 @@ impl InfopanelPane {
return;
}
egui::CollapsingHeader::new("Tool Options")
let header_label = if is_raster_paint_tool {
match tool {
Tool::Erase => "Eraser",
Tool::Smudge => "Smudge",
_ => "Brush",
}
} else {
"Tool Options"
};
egui::CollapsingHeader::new(header_label)
.id_salt(("tool_options", path))
.default_open(self.tool_section_open)
.show(ui, |ui| {
@ -175,7 +191,7 @@ impl InfopanelPane {
}
match tool {
Tool::Draw => {
Tool::Draw if !is_raster_paint_tool => {
// Stroke width
ui.horizontal(|ui| {
ui.label("Stroke Width:");
@ -284,6 +300,42 @@ impl InfopanelPane {
});
}
// Raster paint tools
Tool::Draw | Tool::Erase | Tool::Smudge if is_raster_paint_tool => {
ui.horizontal(|ui| {
ui.label("Size:");
ui.add(
egui::Slider::new(shared.brush_radius, 1.0_f32..=200.0)
.logarithmic(true)
.suffix(" px"),
);
});
if !matches!(tool, Tool::Smudge) {
ui.horizontal(|ui| {
ui.label("Opacity:");
ui.add(
egui::Slider::new(shared.brush_opacity, 0.0_f32..=1.0)
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)),
);
});
}
ui.horizontal(|ui| {
ui.label("Hardness:");
ui.add(
egui::Slider::new(shared.brush_hardness, 0.0_f32..=1.0)
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)),
);
});
ui.horizontal(|ui| {
ui.label("Spacing:");
ui.add(
egui::Slider::new(shared.brush_spacing, 0.01_f32..=1.0)
.logarithmic(true)
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)),
);
});
}
_ => {}
}

View File

@ -185,6 +185,11 @@ pub struct SharedPaneState<'a> {
pub draw_simplify_mode: &'a mut lightningbeam_core::tool::SimplifyMode,
pub rdp_tolerance: &'a mut f64,
pub schneider_max_error: &'a mut f64,
/// Raster brush settings
pub brush_radius: &'a mut f32,
pub brush_opacity: &'a mut f32,
pub brush_hardness: &'a mut f32,
pub brush_spacing: &'a mut f32,
/// Audio engine controller for playback control (wrapped in Arc<Mutex<>> for thread safety)
pub audio_controller: Option<&'a std::sync::Arc<std::sync::Mutex<daw_backend::EngineController>>>,
/// Video manager for video decoding and frame caching

View File

@ -4213,7 +4213,20 @@ impl StagePane {
return;
}
let brush = lightningbeam_core::brush_settings::BrushSettings::default_round_soft();
let brush = {
use lightningbeam_core::brush_settings::BrushSettings;
BrushSettings {
radius_log: shared.brush_radius.ln(),
hardness: *shared.brush_hardness,
opaque: *shared.brush_opacity,
dabs_per_radius: *shared.brush_spacing,
color_h: 0.0,
color_s: 0.0,
color_v: 0.0,
pressure_radius_gain: 0.3,
pressure_opacity_gain: 0.8,
}
};
let color = if matches!(blend_mode, lightningbeam_core::raster_layer::RasterBlendMode::Erase) {
[1.0f32, 1.0, 1.0, 1.0]

View File

@ -193,53 +193,55 @@ impl PaneRenderer for ToolbarPane {
y += button_size + button_spacing;
}
// Add color pickers below the tool buttons (vector layers only)
if matches!(active_layer_type, None | Some(LayerType::Vector)) {
let is_raster = matches!(active_layer_type, Some(LayerType::Raster));
let show_colors = matches!(active_layer_type, None | Some(LayerType::Vector) | Some(LayerType::Raster));
// Add color pickers below the tool buttons
if show_colors {
y += button_spacing * 2.0; // Extra spacing
// Fill Color
let fill_label_width = 40.0;
let color_button_size = 50.0;
let color_row_width = fill_label_width + color_button_size + button_spacing;
let color_x = rect.left() + (rect.width() - color_row_width) / 2.0;
// Fill color label
// For raster layers show a single "Color" swatch (brush paint color = stroke_color).
// For vector layers show Fill + Stroke.
if !is_raster {
// Fill color label
ui.painter().text(
egui::pos2(color_x + fill_label_width / 2.0, y + color_button_size / 2.0),
egui::Align2::CENTER_CENTER,
"Fill",
egui::FontId::proportional(14.0),
egui::Color32::from_gray(200),
);
// Fill color button
let fill_button_rect = egui::Rect::from_min_size(
egui::pos2(color_x + fill_label_width + button_spacing, y),
egui::vec2(color_button_size, color_button_size),
);
let fill_button_id = ui.id().with(("fill_color_button", path));
let fill_response = ui.interact(fill_button_rect, fill_button_id, egui::Sense::click());
draw_color_button(ui, fill_button_rect, *shared.fill_color);
egui::containers::Popup::from_toggle_button_response(&fill_response)
.show(|ui| {
let changed = egui::color_picker::color_picker_color32(ui, shared.fill_color, egui::color_picker::Alpha::OnlyBlend);
if changed {
*shared.active_color_mode = super::ColorMode::Fill;
}
});
y += color_button_size + button_spacing;
}
// Stroke/brush color label
let stroke_label = if is_raster { "Color" } else { "Stroke" };
ui.painter().text(
egui::pos2(color_x + fill_label_width / 2.0, y + color_button_size / 2.0),
egui::Align2::CENTER_CENTER,
"Fill",
egui::FontId::proportional(14.0),
egui::Color32::from_gray(200),
);
// Fill color button
let fill_button_rect = egui::Rect::from_min_size(
egui::pos2(color_x + fill_label_width + button_spacing, y),
egui::vec2(color_button_size, color_button_size),
);
let fill_button_id = ui.id().with(("fill_color_button", path));
let fill_response = ui.interact(fill_button_rect, fill_button_id, egui::Sense::click());
// Draw fill color button with checkerboard for alpha
draw_color_button(ui, fill_button_rect, *shared.fill_color);
// Show fill color picker popup using new Popup API
egui::containers::Popup::from_toggle_button_response(&fill_response)
.show(|ui| {
let changed = egui::color_picker::color_picker_color32(ui, shared.fill_color, egui::color_picker::Alpha::OnlyBlend);
// Track that the user interacted with the fill color
if changed {
*shared.active_color_mode = super::ColorMode::Fill;
}
});
y += color_button_size + button_spacing;
// Stroke color label
ui.painter().text(
egui::pos2(color_x + fill_label_width / 2.0, y + color_button_size / 2.0),
egui::Align2::CENTER_CENTER,
"Stroke",
stroke_label,
egui::FontId::proportional(14.0),
egui::Color32::from_gray(200),
);
@ -251,20 +253,15 @@ impl PaneRenderer for ToolbarPane {
);
let stroke_button_id = ui.id().with(("stroke_color_button", path));
let stroke_response = ui.interact(stroke_button_rect, stroke_button_id, egui::Sense::click());
// Draw stroke color button with checkerboard for alpha
draw_color_button(ui, stroke_button_rect, *shared.stroke_color);
// Show stroke color picker popup using new Popup API
egui::containers::Popup::from_toggle_button_response(&stroke_response)
.show(|ui| {
let changed = egui::color_picker::color_picker_color32(ui, shared.stroke_color, egui::color_picker::Alpha::OnlyBlend);
// Track that the user interacted with the stroke color
if changed {
*shared.active_color_mode = super::ColorMode::Stroke;
}
});
} // end color pickers (vector only)
} // end color pickers
}
fn name(&self) -> &str {