From e85efe740524e5b1e684087641f1cf29e3d50e4f Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Sun, 1 Mar 2026 14:00:39 -0500 Subject: [PATCH] Fix smudge tool --- .../lightningbeam-core/src/brush_engine.rs | 166 +++++++++++++----- .../lightningbeam-editor/assets/layouts.json | 23 +-- .../lightningbeam-editor/src/main.rs | 13 ++ .../src/panes/infopanel.rs | 60 ++++++- .../lightningbeam-editor/src/panes/mod.rs | 5 + .../lightningbeam-editor/src/panes/stage.rs | 15 +- .../lightningbeam-editor/src/panes/toolbar.rs | 83 +++++---- 7 files changed, 260 insertions(+), 105 deletions(-) diff --git a/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs b/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs index 22241f2..ecdad50 100644 --- a/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs +++ b/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs @@ -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. diff --git a/lightningbeam-ui/lightningbeam-editor/assets/layouts.json b/lightningbeam-ui/lightningbeam-editor/assets/layouts.json index f5f5202..366cbf5 100644 --- a/lightningbeam-ui/lightningbeam-editor/assets/layouts.json +++ b/lightningbeam-ui/lightningbeam-editor/assets/layouts.json @@ -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" } ] } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 482b3e6..97c1c12 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -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.0–1.0 + brush_hardness: f32, // brush hardness 0.0–1.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, @@ -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, diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index a54f9f6..638bfdb 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -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)), + ); + }); + } + _ => {} } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index 3faa6d5..fde2d8c 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -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> for thread safety) pub audio_controller: Option<&'a std::sync::Arc>>, /// Video manager for video decoding and frame caching diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index af1f8d0..33eb262 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -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] diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs index becc4c3..d82b70f 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs @@ -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 {