separate brush and eraser in infopanel

This commit is contained in:
Skyler Lehmkuhl 2026-03-06 05:24:26 -05:00
parent f2c15d7f0d
commit 553cc383d5
4 changed files with 261 additions and 120 deletions

View File

@ -766,8 +766,19 @@ struct EditorApp {
brush_hardness: f32, // brush hardness 0.01.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<std::sync::Mutex<Vec<(u32, u32, Vec<u8>)>>>,
// 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,

View File

@ -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<usize>,
/// Whether the paint brush picker is expanded
brush_picker_expanded: bool,
/// Index of the selected eraser brush preset
selected_eraser_preset: Option<usize>,
/// Whether the eraser brush picker is expanded
eraser_picker_expanded: bool,
/// Cached preview textures, one per preset (populated lazily).
brush_preview_textures: Vec<egui::TextureHandle>,
}
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;
}
}

View File

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

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