From 553cc383d52522a8e82ae5c2e8d99b9a6204ed67 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Fri, 6 Mar 2026 05:24:26 -0500 Subject: [PATCH] separate brush and eraser in infopanel --- .../lightningbeam-editor/src/main.rs | 35 ++- .../src/panes/infopanel.rs | 283 +++++++++++------- .../lightningbeam-editor/src/panes/mod.rs | 16 +- .../lightningbeam-editor/src/panes/stage.rs | 47 ++- 4 files changed, 261 insertions(+), 120 deletions(-) diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 057baa0..73c96c7 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -766,8 +766,19 @@ struct EditorApp { brush_hardness: f32, // brush hardness 0.0–1.0 brush_spacing: f32, // dabs_per_radius (fraction of radius per dab) brush_use_fg: bool, // true = paint with FG (stroke) color, false = BG (fill) color - /// Full brush settings for the currently active preset (carries elliptical, jitter, etc.) + /// Full brush settings for the currently active paint preset (carries elliptical, jitter, etc.) active_brush_settings: lightningbeam_core::brush_settings::BrushSettings, + /// Eraser tool brush settings (separate from paint brush, defaults to "Brush" preset) + eraser_radius: f32, + eraser_opacity: f32, + eraser_hardness: f32, + eraser_spacing: f32, + active_eraser_settings: lightningbeam_core::brush_settings::BrushSettings, + /// Smudge tool settings (no preset picker) + smudge_radius: f32, + smudge_hardness: f32, + smudge_spacing: f32, + smudge_strength: f32, /// GPU-rendered brush preview pixel buffers, shared with VelloCallback::prepare(). brush_preview_pixels: std::sync::Arc)>>>, // Audio engine integration @@ -1052,6 +1063,19 @@ impl EditorApp { brush_spacing: 0.1, brush_use_fg: true, active_brush_settings: lightningbeam_core::brush_settings::BrushSettings::default(), + eraser_radius: 10.0, + eraser_opacity: 1.0, + eraser_hardness: 0.5, + eraser_spacing: 0.1, + active_eraser_settings: lightningbeam_core::brush_settings::bundled_brushes() + .iter() + .find(|p| p.name == "Brush") + .map(|p| p.settings.clone()) + .unwrap_or_default(), + smudge_radius: 15.0, + smudge_hardness: 0.8, + smudge_spacing: 8.0, + smudge_strength: 1.0, brush_preview_pixels: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())), audio_stream, audio_controller, @@ -5510,6 +5534,15 @@ impl eframe::App for EditorApp { brush_spacing: &mut self.brush_spacing, brush_use_fg: &mut self.brush_use_fg, active_brush_settings: &mut self.active_brush_settings, + eraser_radius: &mut self.eraser_radius, + eraser_opacity: &mut self.eraser_opacity, + eraser_hardness: &mut self.eraser_hardness, + eraser_spacing: &mut self.eraser_spacing, + active_eraser_settings: &mut self.active_eraser_settings, + smudge_radius: &mut self.smudge_radius, + smudge_hardness: &mut self.smudge_hardness, + smudge_spacing: &mut self.smudge_spacing, + smudge_strength: &mut self.smudge_strength, 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 8e4bc7b..8923733 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -26,18 +26,29 @@ pub struct InfopanelPane { tool_section_open: bool, /// Whether the shape properties section is expanded shape_section_open: bool, - /// Index of the selected brush preset (None = custom / unset) + /// Index of the selected paint brush preset (None = custom / unset) selected_brush_preset: Option, + /// Whether the paint brush picker is expanded + brush_picker_expanded: bool, + /// Index of the selected eraser brush preset + selected_eraser_preset: Option, + /// Whether the eraser brush picker is expanded + eraser_picker_expanded: bool, /// Cached preview textures, one per preset (populated lazily). brush_preview_textures: Vec, } impl InfopanelPane { pub fn new() -> Self { + let presets = bundled_brushes(); + let default_eraser_idx = presets.iter().position(|p| p.name == "Brush"); Self { tool_section_open: true, shape_section_open: true, selected_brush_preset: None, + brush_picker_expanded: false, + selected_eraser_preset: default_eraser_idx, + eraser_picker_expanded: false, brush_preview_textures: Vec::new(), } } @@ -308,52 +319,31 @@ impl InfopanelPane { } // Raster paint tools - Tool::Draw | Tool::Erase | Tool::Smudge if is_raster_paint_tool => { - // Brush preset picker (Draw tool only) - if matches!(tool, Tool::Draw) { - self.render_brush_preset_grid(ui, shared); - ui.add_space(2.0); - } + Tool::Draw | Tool::Erase if is_raster_paint_tool => { + self.render_raster_tool_options(ui, shared, matches!(tool, Tool::Erase)); + } - // Color source toggle (Draw tool only) - if matches!(tool, Tool::Draw) { - ui.horizontal(|ui| { - ui.label("Color:"); - ui.selectable_value(shared.brush_use_fg, true, "FG"); - ui.selectable_value(shared.brush_use_fg, false, "BG"); - }); - } + 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"), - ); + ui.add(egui::Slider::new(shared.smudge_radius, 1.0_f32..=200.0).logarithmic(true).suffix(" px")); + }); + ui.horizontal(|ui| { + ui.label("Reach:"); + ui.add(egui::Slider::new(shared.smudge_strength, 0.1_f32..=5.0) + .logarithmic(true) + .custom_formatter(|v, _| format!("{:.2}x", v))); }); - 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.add(egui::Slider::new(shared.smudge_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)), - ); + ui.add(egui::Slider::new(shared.smudge_spacing, 0.5_f32..=20.0) + .logarithmic(true) + .custom_formatter(|v, _| format!("{:.1}", v))); }); } @@ -364,8 +354,56 @@ impl InfopanelPane { }); } - /// Render the brush preset thumbnail grid for the Draw raster tool. - fn render_brush_preset_grid(&mut self, ui: &mut Ui, shared: &mut SharedPaneState) { + /// Render all options for a raster paint tool (brush picker + sliders). + /// `is_eraser` drives which shared state is read/written. + fn render_raster_tool_options( + &mut self, + ui: &mut Ui, + shared: &mut SharedPaneState, + is_eraser: bool, + ) { + self.render_brush_preset_grid(ui, shared, is_eraser); + ui.add_space(2.0); + + if !is_eraser { + ui.horizontal(|ui| { + ui.label("Color:"); + ui.selectable_value(shared.brush_use_fg, true, "FG"); + ui.selectable_value(shared.brush_use_fg, false, "BG"); + }); + } + + macro_rules! field { + ($eraser:ident, $brush:ident) => { + if is_eraser { &mut *shared.$eraser } else { &mut *shared.$brush } + } + } + + ui.horizontal(|ui| { + ui.label("Size:"); + ui.add(egui::Slider::new(field!(eraser_radius, brush_radius), 1.0_f32..=200.0).logarithmic(true).suffix(" px")); + }); + ui.horizontal(|ui| { + ui.label("Opacity:"); + ui.add(egui::Slider::new(field!(eraser_opacity, 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(field!(eraser_hardness, 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(field!(eraser_spacing, brush_spacing), 0.01_f32..=1.0) + .logarithmic(true) + .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); + }); + } + + /// Render the brush preset thumbnail grid (collapsible). + /// `is_eraser` drives which picker state and which shared settings are updated. + fn render_brush_preset_grid(&mut self, ui: &mut Ui, shared: &mut SharedPaneState, is_eraser: bool) { let presets = bundled_brushes(); if presets.is_empty() { return; } @@ -390,80 +428,107 @@ impl InfopanelPane { } } + // Read picker state into locals to avoid multiple &mut self borrows. + let mut expanded = if is_eraser { self.eraser_picker_expanded } else { self.brush_picker_expanded }; + let mut selected = if is_eraser { self.selected_eraser_preset } else { self.selected_brush_preset }; + let gap = 3.0; let cols = 2usize; - let cell_w = ((ui.available_width() - gap * (cols as f32 - 1.0)) / cols as f32).max(50.0); + let avail_w = ui.available_width(); + let cell_w = ((avail_w - gap * (cols as f32 - 1.0)) / cols as f32).max(50.0); let cell_h = 80.0; - for (row_idx, chunk) in presets.chunks(cols).enumerate() { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = gap; - for (col_idx, preset) in chunk.iter().enumerate() { - let idx = row_idx * cols + col_idx; - let is_selected = self.selected_brush_preset == Some(idx); - - let (rect, resp) = ui.allocate_exact_size( - egui::vec2(cell_w, cell_h), - egui::Sense::click(), - ); - - let painter = ui.painter(); - - let bg = if is_selected { - egui::Color32::from_rgb(45, 65, 95) - } else if resp.hovered() { - egui::Color32::from_rgb(45, 50, 62) - } else { - egui::Color32::from_rgb(32, 36, 44) - }; - painter.rect_filled(rect, 4.0, bg); - if is_selected { - painter.rect_stroke( - rect, 4.0, - egui::Stroke::new(1.5, egui::Color32::from_rgb(80, 140, 220)), - egui::StrokeKind::Middle, - ); - } - - // Dab preview (upper portion, leaving 18 px for the name) - let preview_rect = egui::Rect::from_min_size( - rect.min + egui::vec2(4.0, 4.0), - egui::vec2(cell_w - 8.0, cell_h - 22.0), - ); - if let Some(tex) = self.brush_preview_textures.get(idx) { - painter.image( - tex.id(), preview_rect, - egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), - egui::Color32::WHITE, - ); - } - - // Name - painter.text( - egui::pos2(rect.center().x, rect.max.y - 9.0), - egui::Align2::CENTER_CENTER, - preset.name, - egui::FontId::proportional(9.5), - if is_selected { - egui::Color32::from_rgb(140, 190, 255) - } else { - egui::Color32::from_gray(160) - }, - ); - - if resp.clicked() { - self.selected_brush_preset = Some(idx); - let s = &preset.settings; - // Size is intentionally NOT reset — it is global and persists - // across preset switches. All other parameters load from preset. - *shared.brush_opacity = s.opaque.clamp(0.0, 1.0); - *shared.brush_hardness = s.hardness.clamp(0.0, 1.0); - *shared.brush_spacing = s.dabs_per_radius; - *shared.active_brush_settings = s.clone(); - } + if !expanded { + // Collapsed: show just the currently selected preset as a single wide cell. + let show_idx = selected.unwrap_or(0); + if let Some(preset) = presets.get(show_idx) { + let full_w = avail_w.max(50.0); + let (rect, resp) = ui.allocate_exact_size(egui::vec2(full_w, cell_h), egui::Sense::click()); + let painter = ui.painter(); + let bg = if resp.hovered() { + egui::Color32::from_rgb(50, 56, 70) + } else { + egui::Color32::from_rgb(45, 65, 95) + }; + painter.rect_filled(rect, 4.0, bg); + painter.rect_stroke(rect, 4.0, egui::Stroke::new(1.5, egui::Color32::from_rgb(80, 140, 220)), egui::StrokeKind::Middle); + let preview_rect = egui::Rect::from_min_size( + rect.min + egui::vec2(4.0, 4.0), + egui::vec2(rect.width() - 8.0, cell_h - 22.0), + ); + if let Some(tex) = self.brush_preview_textures.get(show_idx) { + painter.image(tex.id(), preview_rect, + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + egui::Color32::WHITE); } - }); - ui.add_space(gap); + painter.text(egui::pos2(rect.center().x, rect.max.y - 9.0), + egui::Align2::CENTER_CENTER, preset.name, + egui::FontId::proportional(9.5), egui::Color32::from_rgb(140, 190, 255)); + if resp.clicked() { expanded = true; } + } + } else { + // Expanded: full grid; clicking a preset selects it and collapses. + for (row_idx, chunk) in presets.chunks(cols).enumerate() { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = gap; + for (col_idx, preset) in chunk.iter().enumerate() { + let idx = row_idx * cols + col_idx; + let is_sel = selected == Some(idx); + let (rect, resp) = ui.allocate_exact_size(egui::vec2(cell_w, cell_h), egui::Sense::click()); + let painter = ui.painter(); + let bg = if is_sel { + egui::Color32::from_rgb(45, 65, 95) + } else if resp.hovered() { + egui::Color32::from_rgb(45, 50, 62) + } else { + egui::Color32::from_rgb(32, 36, 44) + }; + painter.rect_filled(rect, 4.0, bg); + if is_sel { + painter.rect_stroke(rect, 4.0, egui::Stroke::new(1.5, egui::Color32::from_rgb(80, 140, 220)), egui::StrokeKind::Middle); + } + let preview_rect = egui::Rect::from_min_size( + rect.min + egui::vec2(4.0, 4.0), + egui::vec2(cell_w - 8.0, cell_h - 22.0), + ); + if let Some(tex) = self.brush_preview_textures.get(idx) { + painter.image(tex.id(), preview_rect, + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + egui::Color32::WHITE); + } + painter.text(egui::pos2(rect.center().x, rect.max.y - 9.0), + egui::Align2::CENTER_CENTER, preset.name, + egui::FontId::proportional(9.5), + if is_sel { egui::Color32::from_rgb(140, 190, 255) } else { egui::Color32::from_gray(160) }); + if resp.clicked() { + selected = Some(idx); + expanded = false; + let s = &preset.settings; + if is_eraser { + *shared.eraser_opacity = s.opaque.clamp(0.0, 1.0); + *shared.eraser_hardness = s.hardness.clamp(0.0, 1.0); + *shared.eraser_spacing = s.dabs_per_radius; + *shared.active_eraser_settings = s.clone(); + } else { + *shared.brush_opacity = s.opaque.clamp(0.0, 1.0); + *shared.brush_hardness = s.hardness.clamp(0.0, 1.0); + *shared.brush_spacing = s.dabs_per_radius; + *shared.active_brush_settings = s.clone(); + } + } + } + }); + ui.add_space(gap); + } + } + + // Write back picker state. + if is_eraser { + self.eraser_picker_expanded = expanded; + self.selected_eraser_preset = selected; + } else { + self.brush_picker_expanded = expanded; + self.selected_brush_preset = selected; } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index 1ad44b4..e1b9616 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -187,15 +187,27 @@ 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 + /// Raster paint 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, /// Whether the brush paints with the foreground (fill) color (true) or background (stroke) color (false) pub brush_use_fg: &'a mut bool, - /// Full brush settings for the active preset (carries elliptical, jitter, slow_tracking, etc.) + /// Full brush settings for the active paint preset (carries elliptical, jitter, slow_tracking, etc.) pub active_brush_settings: &'a mut lightningbeam_core::brush_settings::BrushSettings, + /// Raster eraser brush settings (separate from paint brush) + pub eraser_radius: &'a mut f32, + pub eraser_opacity: &'a mut f32, + pub eraser_hardness: &'a mut f32, + pub eraser_spacing: &'a mut f32, + /// Full brush settings for the active eraser preset + pub active_eraser_settings: &'a mut lightningbeam_core::brush_settings::BrushSettings, + /// Raster smudge tool settings (no preset picker) + pub smudge_radius: &'a mut f32, + pub smudge_hardness: &'a mut f32, + pub smudge_spacing: &'a mut f32, + pub smudge_strength: &'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 66179fa..9402ad4 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -4772,17 +4772,48 @@ impl StagePane { if !is_raster { return; } let brush = { - // Start from the active preset (carries elliptical ratio/angle, jitter, etc.) - // then override the four parameters the user controls via UI sliders. - let mut b = shared.active_brush_settings.clone(); + // Start from the active preset for this tool, then override the + // user-controlled slider values. + use lightningbeam_core::raster_layer::RasterBlendMode; + let (base_settings, radius, opacity, hardness, spacing) = match blend_mode { + RasterBlendMode::Erase => ( + shared.active_eraser_settings.clone(), + *shared.eraser_radius, + *shared.eraser_opacity, + *shared.eraser_hardness, + *shared.eraser_spacing, + ), + RasterBlendMode::Smudge => ( + lightningbeam_core::brush_settings::BrushSettings::default(), + *shared.smudge_radius, + 1.0, // opacity fixed at 1.0; strength is a separate smudge_dist multiplier + *shared.smudge_hardness, + *shared.smudge_spacing, + ), + _ => ( + shared.active_brush_settings.clone(), + *shared.brush_radius, + *shared.brush_opacity, + *shared.brush_hardness, + *shared.brush_spacing, + ), + }; + let mut b = base_settings; // Compensate for pressure_radius_gain so that the UI-chosen radius is the // actual rendered radius at our fixed mouse pressure of 1.0. // radius_at_pressure(1.0) = exp(radius_log + gain × 0.5) - // → radius_log = ln(brush_radius) - gain × 0.5 - b.radius_log = shared.brush_radius.ln() - b.pressure_radius_gain * 0.5; - b.hardness = *shared.brush_hardness; - b.opaque = *shared.brush_opacity; - b.dabs_per_radius = *shared.brush_spacing; + // → radius_log = ln(radius) - gain × 0.5 + b.radius_log = radius.ln() - b.pressure_radius_gain * 0.5; + b.hardness = hardness; + b.opaque = opacity; + b.dabs_per_radius = spacing; + if matches!(blend_mode, RasterBlendMode::Smudge) { + // Zero dabs_per_actual_radius so the spacing slider is the sole density control. + b.dabs_per_actual_radius = 0.0; + // strength controls how far behind the stroke to sample (smudge_dist multiplier). + // smudge_dist = radius * exp(smudge_radius_log), so log(strength) gives the ratio. + b.smudge_radius_log = shared.smudge_strength.ln(); + } b };