From 922e8f78b69f3f2a4ef620b9d2a0486a758660ea Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Fri, 6 Mar 2026 10:07:24 -0500 Subject: [PATCH] Refactor tools --- .../lightningbeam-core/src/brush_engine.rs | 88 +++------ .../lightningbeam-core/src/raster_layer.rs | 33 ++-- .../lightningbeam-editor/src/main.rs | 98 +--------- .../src/panes/infopanel.rs | 176 +++-------------- .../lightningbeam-editor/src/panes/mod.rs | 41 +--- .../lightningbeam-editor/src/panes/stage.rs | 182 +++++++----------- .../lightningbeam-editor/src/panes/toolbar.rs | 8 +- .../src/tools/clone_stamp.rs | 29 +++ .../src/tools/dodge_burn.rs | 54 ++++++ .../lightningbeam-editor/src/tools/erase.rs | 23 +++ .../src/tools/healing_brush.rs | 28 +++ .../lightningbeam-editor/src/tools/mod.rs | 163 ++++++++++++++++ .../lightningbeam-editor/src/tools/paint.rs | 22 +++ .../src/tools/pattern_stamp.rs | 49 +++++ .../lightningbeam-editor/src/tools/smudge.rs | 44 +++++ .../lightningbeam-editor/src/tools/sponge.rs | 54 ++++++ 16 files changed, 617 insertions(+), 475 deletions(-) create mode 100644 lightningbeam-ui/lightningbeam-editor/src/tools/clone_stamp.rs create mode 100644 lightningbeam-ui/lightningbeam-editor/src/tools/dodge_burn.rs create mode 100644 lightningbeam-ui/lightningbeam-editor/src/tools/erase.rs create mode 100644 lightningbeam-ui/lightningbeam-editor/src/tools/healing_brush.rs create mode 100644 lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs create mode 100644 lightningbeam-ui/lightningbeam-editor/src/tools/paint.rs create mode 100644 lightningbeam-ui/lightningbeam-editor/src/tools/pattern_stamp.rs create mode 100644 lightningbeam-ui/lightningbeam-editor/src/tools/smudge.rs create mode 100644 lightningbeam-ui/lightningbeam-editor/src/tools/sponge.rs diff --git a/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs b/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs index 9712657..b4b94bf 100644 --- a/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs +++ b/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs @@ -329,7 +329,7 @@ impl BrushEngine { color_b: cb, // Clone stamp: color_r/color_g hold source canvas X/Y, so color_a = 1.0 // (blend strength is opa_weight × opacity × 1.0 in the shader). - color_a: if blend_mode_u == 3 || blend_mode_u == 4 || blend_mode_u == 6 || blend_mode_u == 7 { 1.0 } else { stroke.color[3] }, + color_a: if base_blend.uses_brush_color() { stroke.color[3] } else { 1.0 }, ndx, ndy, smudge_dist, blend_mode: blend_mode_u, elliptical_dab_ratio: bs.elliptical_dab_ratio.max(1.0), @@ -363,21 +363,15 @@ impl BrushEngine { state, bs, pt.x, pt.y, base_r, pt.pressure, stroke.color, ); if !matches!(base_blend, RasterBlendMode::Smudge) { - let (cr2, cg2, cb2, ndx2, ndy2) = if matches!(base_blend, RasterBlendMode::CloneStamp | RasterBlendMode::Healing) { - // Store offset in color_r/color_g; shader adds it per-pixel. - let (ox, oy) = stroke.clone_src_offset.unwrap_or((0.0, 0.0)); - (ox, oy, 0.0, 0.0, 0.0) - } else if matches!(base_blend, RasterBlendMode::PatternStamp) { - // ndx = pattern_type, ndy = pattern_scale - (cr, cg, cb, stroke.pattern_type as f32, stroke.pattern_scale) - } else if matches!(base_blend, RasterBlendMode::DodgeBurn) { - // color_r = mode (0=dodge, 1=burn); strength comes from opacity - (stroke.dodge_burn_mode as f32, 0.0, 0.0, 0.0, 0.0) - } else if matches!(base_blend, RasterBlendMode::Sponge) { - // color_r = mode (0=saturate, 1=desaturate); strength comes from opacity - (stroke.sponge_mode as f32, 0.0, 0.0, 0.0, 0.0) - } else { - (cr, cg, cb, 0.0, 0.0) + let tp = &stroke.tool_params; + let (cr2, cg2, cb2, ndx2, ndy2) = match base_blend { + RasterBlendMode::CloneStamp | RasterBlendMode::Healing => + (tp[0], tp[1], 0.0, 0.0, 0.0), + RasterBlendMode::PatternStamp => + (cr, cg, cb, tp[0], tp[1]), + RasterBlendMode::DodgeBurn | RasterBlendMode::Sponge => + (tp[0], 0.0, 0.0, 0.0, 0.0), + _ => (cr, cg, cb, 0.0, 0.0), }; push_dab(&mut dabs, &mut bbox, ex, ey, r, o, cr2, cg2, cb2, ndx2, ndy2, 0.0); @@ -491,34 +485,20 @@ impl BrushEngine { push_dab(&mut dabs, &mut bbox, ex, ey, radius2, opacity2, cr, cg, cb, ndx, ndy, smudge_dist); - } else if matches!(base_blend, RasterBlendMode::CloneStamp | RasterBlendMode::Healing) { - // Store the offset (not absolute position) in color_r/color_g. - // The shader adds this to each pixel's own position for per-pixel sampling. - let (ox, oy) = stroke.clone_src_offset.unwrap_or((0.0, 0.0)); - push_dab(&mut dabs, &mut bbox, - ex, ey, radius2, opacity2, ox, oy, 0.0, - 0.0, 0.0, 0.0); - } else if matches!(base_blend, RasterBlendMode::PatternStamp) { - // ndx = pattern_type, ndy = pattern_scale - push_dab(&mut dabs, &mut bbox, - ex, ey, radius2, opacity2, cr, cg, cb, - stroke.pattern_type as f32, stroke.pattern_scale, 0.0); - } else if matches!(base_blend, RasterBlendMode::DodgeBurn) { - // color_r = mode (0=dodge, 1=burn) - push_dab(&mut dabs, &mut bbox, - ex, ey, radius2, opacity2, - stroke.dodge_burn_mode as f32, 0.0, 0.0, - 0.0, 0.0, 0.0); - } else if matches!(base_blend, RasterBlendMode::Sponge) { - // color_r = mode (0=saturate, 1=desaturate) - push_dab(&mut dabs, &mut bbox, - ex, ey, radius2, opacity2, - stroke.sponge_mode as f32, 0.0, 0.0, - 0.0, 0.0, 0.0); } else { + let tp = &stroke.tool_params; + let (cr2, cg2, cb2, ndx2, ndy2) = match base_blend { + RasterBlendMode::CloneStamp | RasterBlendMode::Healing => + (tp[0], tp[1], 0.0, 0.0, 0.0), + RasterBlendMode::PatternStamp => + (cr, cg, cb, tp[0], tp[1]), + RasterBlendMode::DodgeBurn | RasterBlendMode::Sponge => + (tp[0], 0.0, 0.0, 0.0, 0.0), + _ => (cr, cg, cb, 0.0, 0.0), + }; push_dab(&mut dabs, &mut bbox, - ex, ey, radius2, opacity2, cr, cg, cb, - 0.0, 0.0, 0.0); + ex, ey, radius2, opacity2, cr2, cg2, cb2, + ndx2, ndy2, 0.0); } state.partial_dabs = 0.0; @@ -547,21 +527,15 @@ impl BrushEngine { last_smooth_x, last_smooth_y, base_r, last_pressure, stroke.color, ); - let (cr2, cg2, cb2, ndx2, ndy2) = if matches!(base_blend, RasterBlendMode::CloneStamp | RasterBlendMode::Healing) { - // Store offset in color_r/color_g; shader adds it per-pixel. - let (ox, oy) = stroke.clone_src_offset.unwrap_or((0.0, 0.0)); - (ox, oy, 0.0, 0.0, 0.0) - } else if matches!(base_blend, RasterBlendMode::PatternStamp) { - // ndx = pattern_type, ndy = pattern_scale - (cr, cg, cb, stroke.pattern_type as f32, stroke.pattern_scale) - } else if matches!(base_blend, RasterBlendMode::DodgeBurn) { - // color_r = mode (0=dodge, 1=burn) - (stroke.dodge_burn_mode as f32, 0.0, 0.0, 0.0, 0.0) - } else if matches!(base_blend, RasterBlendMode::Sponge) { - // color_r = mode (0=saturate, 1=desaturate) - (stroke.sponge_mode as f32, 0.0, 0.0, 0.0, 0.0) - } else { - (cr, cg, cb, 0.0, 0.0) + let tp = &stroke.tool_params; + let (cr2, cg2, cb2, ndx2, ndy2) = match base_blend { + RasterBlendMode::CloneStamp | RasterBlendMode::Healing => + (tp[0], tp[1], 0.0, 0.0, 0.0), + RasterBlendMode::PatternStamp => + (cr, cg, cb, tp[0], tp[1]), + RasterBlendMode::DodgeBurn | RasterBlendMode::Sponge => + (tp[0], 0.0, 0.0, 0.0, 0.0), + _ => (cr, cg, cb, 0.0, 0.0), }; push_dab(&mut dabs, &mut bbox, ex, ey, r, o, cr2, cg2, cb2, ndx2, ndy2, 0.0); diff --git a/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs b/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs index 86546e8..cf0aee9 100644 --- a/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs @@ -36,6 +36,15 @@ impl Default for RasterBlendMode { } } +impl RasterBlendMode { + /// Returns false for blend modes that operate on existing pixels and don't + /// use the brush color at all (clone, heal, dodge/burn, sponge). + /// Used by brush_engine.rs to decide whether color_a should be 1.0 or stroke.color[3]. + pub fn uses_brush_color(self) -> bool { + !matches!(self, Self::CloneStamp | Self::Healing | Self::DodgeBurn | Self::Sponge) + } +} + /// A single point along a stroke #[derive(Clone, Debug, Serialize, Deserialize)] pub struct StrokePoint { @@ -58,28 +67,16 @@ pub struct StrokeRecord { /// RGBA linear color [r, g, b, a] pub color: [f32; 4], pub blend_mode: RasterBlendMode, - /// Clone stamp source offset: (source_x - drag_start_x, source_y - drag_start_y). - /// For each dab at canvas position D, the source pixel is sampled from D + offset. - /// None for all non-clone-stamp blend modes. + /// Generic tool parameters — encoding depends on blend_mode: + /// - CloneStamp / Healing: [offset_x, offset_y, 0, 0] + /// - PatternStamp: [pattern_type, pattern_scale, 0, 0] + /// - DodgeBurn / Sponge: [mode, 0, 0, 0] + /// - all others: [0, 0, 0, 0] #[serde(default)] - pub clone_src_offset: Option<(f32, f32)>, - /// Pattern stamp: procedural pattern type (0=Checkerboard, 1=Dots, 2=H-Lines, 3=V-Lines, 4=Diagonal, 5=Crosshatch) - #[serde(default)] - pub pattern_type: u32, - /// Pattern stamp: tile size in pixels - #[serde(default = "default_pattern_scale")] - pub pattern_scale: f32, - /// Dodge/Burn mode: 0 = dodge (lighten), 1 = burn (darken) - #[serde(default)] - pub dodge_burn_mode: u32, - /// Sponge mode: 0 = saturate, 1 = desaturate - #[serde(default)] - pub sponge_mode: u32, + pub tool_params: [f32; 4], pub points: Vec, } -fn default_pattern_scale() -> f32 { 32.0 } - /// Specifies how the raster content transitions to the next keyframe #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum TweenType { diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 446b244..071a3a8 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -13,6 +13,8 @@ use uuid::Uuid; mod panes; use panes::{PaneInstance, PaneRenderer}; +mod tools; + mod widgets; mod menu; @@ -784,40 +786,8 @@ 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) - brush_use_fg: bool, // true = paint with FG (stroke) color, false = BG (fill) color - /// 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, - /// Pattern stamp settings - pattern_type: u32, - pattern_scale: f32, - /// Dodge/Burn tool settings - dodge_burn_radius: f32, - dodge_burn_hardness: f32, - dodge_burn_spacing: f32, - dodge_burn_exposure: f32, - dodge_burn_mode: u32, - /// Sponge tool settings - sponge_radius: f32, - sponge_hardness: f32, - sponge_spacing: f32, - sponge_flow: f32, - sponge_mode: u32, + /// All per-tool raster paint settings (brush, eraser, smudge, clone, pattern, dodge/burn, sponge). + raster_settings: tools::RasterToolSettings, /// GPU-rendered brush preview pixel buffers, shared with VelloCallback::prepare(). brush_preview_pixels: std::sync::Arc)>>>, // Audio engine integration @@ -1098,37 +1068,7 @@ 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, - 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, - pattern_type: 0, - pattern_scale: 32.0, - dodge_burn_radius: 30.0, - dodge_burn_hardness: 0.5, - dodge_burn_spacing: 3.0, - dodge_burn_exposure: 0.5, - dodge_burn_mode: 0, - sponge_radius: 30.0, - sponge_hardness: 0.5, - sponge_spacing: 3.0, - sponge_flow: 0.5, - sponge_mode: 0, + raster_settings: tools::RasterToolSettings::default(), brush_preview_pixels: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())), audio_stream, audio_controller, @@ -5582,33 +5522,7 @@ 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, - 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, - pattern_type: &mut self.pattern_type, - pattern_scale: &mut self.pattern_scale, - dodge_burn_radius: &mut self.dodge_burn_radius, - dodge_burn_hardness: &mut self.dodge_burn_hardness, - dodge_burn_spacing: &mut self.dodge_burn_spacing, - dodge_burn_exposure: &mut self.dodge_burn_exposure, - dodge_burn_mode: &mut self.dodge_burn_mode, - sponge_radius: &mut self.sponge_radius, - sponge_hardness: &mut self.sponge_hardness, - sponge_spacing: &mut self.sponge_spacing, - sponge_flow: &mut self.sponge_flow, - sponge_mode: &mut self.sponge_mode, + raster_settings: &mut self.raster_settings, 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 6d63616..137cbdc 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -169,12 +169,8 @@ impl InfopanelPane { .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::Pencil | Tool::Pen | Tool::Airbrush - | Tool::Erase | Tool::Smudge | Tool::CloneStamp | Tool::HealingBrush | Tool::PatternStamp - | Tool::DodgeBurn | Tool::Sponge - ); + let raster_tool_def = active_is_raster.then(|| crate::tools::raster_tool_def(&tool)).flatten(); + let is_raster_paint_tool = raster_tool_def.is_some(); // Only show tool options for tools that have options let is_vector_tool = !active_is_raster && matches!( @@ -191,20 +187,9 @@ impl InfopanelPane { return; } - let header_label = if is_raster_paint_tool { - match tool { - Tool::Erase => "Eraser", - Tool::Smudge => "Smudge", - Tool::CloneStamp => "Clone Stamp", - Tool::HealingBrush => "Healing Brush", - Tool::PatternStamp => "Pattern Stamp", - Tool::DodgeBurn => "Dodge / Burn", - Tool::Sponge => "Sponge", - _ => "Brush", - } - } else { - "Tool Options" - }; + let header_label = raster_tool_def + .map(|d| d.header_label()) + .unwrap_or("Tool Options"); egui::CollapsingHeader::new(header_label) .id_salt(("tool_options", path)) @@ -218,6 +203,14 @@ impl InfopanelPane { ui.add_space(2.0); } + // Raster paint tool: delegate to per-tool impl. + if let Some(def) = raster_tool_def { + def.render_ui(ui, shared.raster_settings); + if def.show_brush_preset_picker() { + self.render_raster_tool_options(ui, shared, def.is_eraser()); + } + } + match tool { Tool::Draw if !is_raster_paint_tool => { // Stroke width @@ -328,124 +321,6 @@ impl InfopanelPane { }); } - // Raster paint tools - Tool::Draw | Tool::Pencil | Tool::Pen | Tool::Airbrush - | Tool::Erase | Tool::CloneStamp | Tool::HealingBrush if is_raster_paint_tool => { - self.render_raster_tool_options(ui, shared, matches!(tool, Tool::Erase)); - } - - Tool::PatternStamp if is_raster_paint_tool => { - const PATTERN_NAMES: &[&str] = &[ - "Checkerboard", "Dots", "H-Lines", "V-Lines", "Diagonal \\", "Diagonal /", "Crosshatch", - ]; - let selected_name = PATTERN_NAMES - .get(*shared.pattern_type as usize) - .copied() - .unwrap_or("Checkerboard"); - ui.horizontal(|ui| { - ui.label("Pattern:"); - egui::ComboBox::from_id_salt("pattern_type") - .selected_text(selected_name) - .show_ui(ui, |ui| { - for (i, name) in PATTERN_NAMES.iter().enumerate() { - ui.selectable_value(shared.pattern_type, i as u32, *name); - } - }); - }); - ui.horizontal(|ui| { - ui.label("Scale:"); - ui.add(egui::Slider::new(shared.pattern_scale, 4.0_f32..=256.0) - .logarithmic(true).suffix(" px")); - }); - ui.add_space(4.0); - self.render_raster_tool_options(ui, shared, false); - } - - Tool::DodgeBurn if is_raster_paint_tool => { - ui.horizontal(|ui| { - if ui.selectable_label(*shared.dodge_burn_mode == 0, "Dodge").clicked() { - *shared.dodge_burn_mode = 0; - } - if ui.selectable_label(*shared.dodge_burn_mode == 1, "Burn").clicked() { - *shared.dodge_burn_mode = 1; - } - }); - ui.horizontal(|ui| { - ui.label("Size:"); - ui.add(egui::Slider::new(shared.dodge_burn_radius, 1.0_f32..=500.0).logarithmic(true).suffix(" px")); - }); - ui.horizontal(|ui| { - ui.label("Exposure:"); - ui.add(egui::Slider::new(shared.dodge_burn_exposure, 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.dodge_burn_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.dodge_burn_spacing, 0.5_f32..=20.0) - .logarithmic(true) - .custom_formatter(|v, _| format!("{:.1}", v))); - }); - } - - Tool::Sponge if is_raster_paint_tool => { - ui.horizontal(|ui| { - if ui.selectable_label(*shared.sponge_mode == 0, "Saturate").clicked() { - *shared.sponge_mode = 0; - } - if ui.selectable_label(*shared.sponge_mode == 1, "Desaturate").clicked() { - *shared.sponge_mode = 1; - } - }); - ui.horizontal(|ui| { - ui.label("Size:"); - ui.add(egui::Slider::new(shared.sponge_radius, 1.0_f32..=500.0).logarithmic(true).suffix(" px")); - }); - ui.horizontal(|ui| { - ui.label("Flow:"); - ui.add(egui::Slider::new(shared.sponge_flow, 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.sponge_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.sponge_spacing, 0.5_f32..=20.0) - .logarithmic(true) - .custom_formatter(|v, _| format!("{:.1}", v))); - }); - } - - Tool::Smudge if is_raster_paint_tool => { - ui.horizontal(|ui| { - ui.label("Size:"); - ui.add(egui::Slider::new(shared.smudge_radius, 1.0_f32..=200.0).logarithmic(true).suffix(" px")); - }); - ui.horizontal(|ui| { - ui.label("Strength:"); - ui.add(egui::Slider::new(shared.smudge_strength, 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.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.smudge_spacing, 0.5_f32..=20.0) - .logarithmic(true) - .custom_formatter(|v, _| format!("{:.1}", v))); - }); - } - _ => {} } @@ -464,17 +339,19 @@ impl InfopanelPane { self.render_brush_preset_grid(ui, shared, is_eraser); ui.add_space(2.0); + let rs = &mut shared.raster_settings; + 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"); + ui.selectable_value(&mut rs.brush_use_fg, true, "FG"); + ui.selectable_value(&mut rs.brush_use_fg, false, "BG"); }); } macro_rules! field { ($eraser:ident, $brush:ident) => { - if is_eraser { &mut *shared.$eraser } else { &mut *shared.$brush } + if is_eraser { &mut rs.$eraser } else { &mut rs.$brush } } } @@ -603,16 +480,17 @@ impl InfopanelPane { selected = Some(idx); expanded = false; let s = &preset.settings; + let rs = &mut shared.raster_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(); + rs.eraser_opacity = s.opaque.clamp(0.0, 1.0); + rs.eraser_hardness = s.hardness.clamp(0.0, 1.0); + rs.eraser_spacing = s.dabs_per_radius; + rs.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(); + rs.brush_opacity = s.opaque.clamp(0.0, 1.0); + rs.brush_hardness = s.hardness.clamp(0.0, 1.0); + rs.brush_spacing = s.dabs_per_radius; + rs.active_brush_settings = s.clone(); // If the user was on a preset-backed tool (Pencil/Pen/Airbrush) // and manually picked a different brush, revert to the generic tool. if matches!(*shared.selected_tool, Tool::Pencil | Tool::Pen | Tool::Airbrush) { diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index 10b77ca..27e20b3 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -187,45 +187,8 @@ 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 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 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, - /// Pattern stamp: selected procedural pattern type (0=Checkerboard..5=Crosshatch) - pub pattern_type: &'a mut u32, - /// Pattern stamp: tile size in pixels - pub pattern_scale: &'a mut f32, - /// Dodge/Burn tool settings - pub dodge_burn_radius: &'a mut f32, - pub dodge_burn_hardness: &'a mut f32, - pub dodge_burn_spacing: &'a mut f32, - pub dodge_burn_exposure: &'a mut f32, - /// 0 = dodge (lighten), 1 = burn (darken) - pub dodge_burn_mode: &'a mut u32, - /// Sponge tool settings - pub sponge_radius: &'a mut f32, - pub sponge_hardness: &'a mut f32, - pub sponge_spacing: &'a mut f32, - pub sponge_flow: &'a mut f32, - /// 0 = saturate, 1 = desaturate - pub sponge_mode: &'a mut u32, + /// All per-tool raster paint settings (replaces 20+ individual fields). + pub raster_settings: &'a mut crate::tools::RasterToolSettings, /// 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 ee733c4..dd2f464 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -614,11 +614,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { brush_settings: scaled, color: [0.85f32, 0.88, 1.0, 1.0], blend_mode: RasterBlendMode::Normal, - clone_src_offset: None, - pattern_type: 0, - pattern_scale: 32.0, - dodge_burn_mode: 0, - sponge_mode: 0, + tool_params: [0.0; 4], points: vec![ StrokePoint { x: x0, y: y_lo, pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0 }, StrokePoint { x: mid_x, y: mid_y, pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0 }, @@ -2487,8 +2483,6 @@ pub struct StagePane { /// Timestamp (ui time in seconds) of the last `compute_dabs` call for this stroke. /// Used to compute `dt` for the unified distance+time dab accumulator. raster_last_compute_time: f64, - /// Clone stamp: world-space source point set by Alt+click. - clone_source: Option, /// Clone stamp: (source_world - drag_start_world) computed at stroke start. /// Constant for the entire stroke; cleared when the stroke ends. clone_stroke_offset: Option<(f32, f32)>, @@ -2616,7 +2610,6 @@ impl StagePane { stroke_clip_selection: None, painting_float: false, raster_last_compute_time: 0.0, - clone_source: None, clone_stroke_offset: None, #[cfg(debug_assertions)] replay_override: None, @@ -4841,6 +4834,27 @@ impl StagePane { /// `self.pending_raster_dabs` for dispatch by `VelloCallback::prepare()`. /// /// The actual pixel rendering happens on the GPU (compute shader). The CPU + /// Build the `tool_params: [f32; 4]` for a StrokeRecord. + /// For clone/healing: [offset_x, offset_y, 0, 0] (computed from clone_stroke_offset). + /// For all other tools: delegates to def.tool_params(). + fn make_tool_params( + &self, + def: &dyn crate::tools::RasterToolDef, + shared: &SharedPaneState, + ) -> [f32; 4] { + use lightningbeam_core::raster_layer::RasterBlendMode; + match def.blend_mode() { + RasterBlendMode::CloneStamp | RasterBlendMode::Healing => { + if let Some((ox, oy)) = self.clone_stroke_offset { + [ox, oy, 0.0, 0.0] + } else { + [0.0; 4] + } + } + _ => def.tool_params(shared.raster_settings), + } + } + /// only does dab placement arithmetic (cheap). On stroke end a readback is /// requested so the undo system can capture the final pixel state. fn handle_raster_stroke_tool( @@ -4848,9 +4862,10 @@ impl StagePane { ui: &mut egui::Ui, response: &egui::Response, world_pos: egui::Vec2, - blend_mode: lightningbeam_core::raster_layer::RasterBlendMode, + def: &'static dyn crate::tools::RasterToolDef, shared: &mut SharedPaneState, ) { + let blend_mode = def.blend_mode(); use lightningbeam_core::tool::ToolState; use lightningbeam_core::layer::AnyLayer; use lightningbeam_core::raster_layer::StrokePoint; @@ -4869,46 +4884,10 @@ impl StagePane { if !is_raster { return; } let brush = { - // 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, - ), - RasterBlendMode::DodgeBurn => ( - lightningbeam_core::brush_settings::BrushSettings::default(), - *shared.dodge_burn_radius, - *shared.dodge_burn_exposure, - *shared.dodge_burn_hardness, - *shared.dodge_burn_spacing, - ), - RasterBlendMode::Sponge => ( - lightningbeam_core::brush_settings::BrushSettings::default(), - *shared.sponge_radius, - *shared.sponge_flow, - *shared.sponge_hardness, - *shared.sponge_spacing, - ), - _ => ( - shared.active_brush_settings.clone(), - *shared.brush_radius, - *shared.brush_opacity, - *shared.brush_hardness, - *shared.brush_spacing, - ), - }; + // Delegate brush parameter extraction to the tool definition. + let bp = def.brush_params(shared.raster_settings); + let (base_settings, radius, opacity, hardness, spacing) = + (bp.base_settings, bp.radius, bp.opacity, bp.hardness, bp.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. @@ -4918,12 +4897,12 @@ impl StagePane { b.hardness = hardness; b.opaque = opacity; b.dabs_per_radius = spacing; - if matches!(blend_mode, RasterBlendMode::Smudge) { + if matches!(blend_mode, lightningbeam_core::raster_layer::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; // linear [0,1] strength + b.smudge_radius_log = shared.raster_settings.smudge_strength; // linear [0,1] strength } b }; @@ -4931,7 +4910,7 @@ impl StagePane { let color = if matches!(blend_mode, lightningbeam_core::raster_layer::RasterBlendMode::Erase) { [1.0f32, 1.0, 1.0, 1.0] } else { - let c = if *shared.brush_use_fg { *shared.stroke_color } else { *shared.fill_color }; + let c = if shared.raster_settings.brush_use_fg { *shared.stroke_color } else { *shared.fill_color }; let s2l = |v: u8| -> f32 { let f = v as f32 / 255.0; if f <= 0.04045 { f / 12.92 } else { ((f + 0.055) / 1.055).powf(2.4) } @@ -4956,7 +4935,7 @@ impl StagePane { // This is constant for the entire stroke and used in every StrokeRecord below. if matches!(blend_mode, lightningbeam_core::raster_layer::RasterBlendMode::CloneStamp | lightningbeam_core::raster_layer::RasterBlendMode::Healing) { - self.clone_stroke_offset = self.clone_source.map(|s| ( + self.clone_stroke_offset = shared.raster_settings.clone_source.map(|s| ( s.x - world_pos.x, s.y - world_pos.y, )); } else { @@ -4991,11 +4970,7 @@ impl StagePane { brush_settings: brush.clone(), color, blend_mode, - clone_src_offset: self.clone_stroke_offset, - pattern_type: *shared.pattern_type, - pattern_scale: *shared.pattern_scale, - dodge_burn_mode: *shared.dodge_burn_mode, - sponge_mode: *shared.sponge_mode, + tool_params: self.make_tool_params(def, shared), points: vec![first_pt.clone()], }; let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, &mut stroke_state, 0.0); @@ -5084,11 +5059,7 @@ impl StagePane { brush_settings: brush.clone(), color, blend_mode, - clone_src_offset: self.clone_stroke_offset, - pattern_type: *shared.pattern_type, - pattern_scale: *shared.pattern_scale, - dodge_burn_mode: *shared.dodge_burn_mode, - sponge_mode: *shared.sponge_mode, + tool_params: self.make_tool_params(def, shared), points: vec![first_pt.clone()], }; let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, &mut stroke_state, 0.0); @@ -5130,6 +5101,7 @@ impl StagePane { // Mouse drag: compute dabs for this segment // ---------------------------------------------------------------- if self.rsp_dragged(response) { + let tool_params = self.make_tool_params(def, shared); if let Some((layer_id, time, ref mut stroke_state, _)) = self.raster_stroke_state { if let Some(prev_pt) = self.raster_last_point.take() { // Get canvas info and float offset now (used for both distance check @@ -5168,16 +5140,11 @@ impl StagePane { }; if dx * dx + dy * dy >= MIN_DIST_SQ { - let clone_src_offset = self.clone_stroke_offset; let seg = StrokeRecord { brush_settings: brush.clone(), color, blend_mode, - clone_src_offset, - pattern_type: *shared.pattern_type, - pattern_scale: *shared.pattern_scale, - dodge_burn_mode: *shared.dodge_burn_mode, - sponge_mode: *shared.sponge_mode, + tool_params, points: vec![prev_pt, curr_local], }; let current_time = ui.input(|i| i.time); @@ -5214,6 +5181,7 @@ impl StagePane { if self.raster_last_compute_time > 0.0 { let dt = (current_time - self.raster_last_compute_time).clamp(0.0, 0.1) as f32; self.raster_last_compute_time = current_time; + let tool_params = self.make_tool_params(def, shared); if let Some((layer_id, time, ref mut stroke_state, _)) = self.raster_stroke_state { let canvas_info = if self.painting_float { @@ -5239,11 +5207,7 @@ impl StagePane { brush_settings: brush.clone(), color, blend_mode, - clone_src_offset: self.clone_stroke_offset, - pattern_type: *shared.pattern_type, - pattern_scale: *shared.pattern_scale, - dodge_burn_mode: *shared.dodge_burn_mode, - sponge_mode: *shared.sponge_mode, + tool_params, points: vec![pt], }; let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, stroke_state, dt); @@ -7541,16 +7505,18 @@ impl StagePane { }); } - // Clone stamp / healing brush: Alt+click sets the source point regardless of the alt-pan guard below. + // Alt+click: set source point for clone/healing tools. { use lightningbeam_core::tool::Tool; - if matches!(*shared.selected_tool, Tool::CloneStamp | Tool::HealingBrush) + let tool_uses_alt = crate::tools::raster_tool_def(shared.selected_tool) + .map_or(false, |d| d.uses_alt_click()); + if tool_uses_alt && alt_held && self.rsp_primary_pressed(ui) && response.hovered() { eprintln!("[clone/healing] set clone source to ({:.1}, {:.1})", world_pos.x, world_pos.y); - self.clone_source = Some(world_pos); + shared.raster_settings.clone_source = Some(world_pos); } } @@ -7584,37 +7550,14 @@ impl StagePane { shared.action_executor.document().get_layer(&id) }).map_or(false, |l| matches!(l, lightningbeam_core::layer::AnyLayer::Raster(_))); if is_raster { - self.handle_raster_stroke_tool(ui, &response, world_pos, lightningbeam_core::raster_layer::RasterBlendMode::Normal, shared); + self.handle_raster_stroke_tool(ui, &response, world_pos, &crate::tools::paint::PAINT, shared); } else { self.handle_draw_tool(ui, &response, world_pos, shared); } } - Tool::Pencil | Tool::Pen | Tool::Airbrush => { - self.handle_raster_stroke_tool(ui, &response, world_pos, lightningbeam_core::raster_layer::RasterBlendMode::Normal, shared); - } - Tool::Erase => { - self.handle_raster_stroke_tool(ui, &response, world_pos, lightningbeam_core::raster_layer::RasterBlendMode::Erase, shared); - } - Tool::Smudge => { - self.handle_raster_stroke_tool(ui, &response, world_pos, lightningbeam_core::raster_layer::RasterBlendMode::Smudge, shared); - } - Tool::CloneStamp => { - // Alt+click (source-setting) is handled before this block. - // Here alt_held is always false, so just paint. - self.handle_raster_stroke_tool(ui, &response, world_pos, lightningbeam_core::raster_layer::RasterBlendMode::CloneStamp, shared); - } - Tool::HealingBrush => { - // Alt+click (source-setting) is handled before this block. - self.handle_raster_stroke_tool(ui, &response, world_pos, lightningbeam_core::raster_layer::RasterBlendMode::Healing, shared); - } - Tool::PatternStamp => { - self.handle_raster_stroke_tool(ui, &response, world_pos, lightningbeam_core::raster_layer::RasterBlendMode::PatternStamp, shared); - } - Tool::DodgeBurn => { - self.handle_raster_stroke_tool(ui, &response, world_pos, lightningbeam_core::raster_layer::RasterBlendMode::DodgeBurn, shared); - } - Tool::Sponge => { - self.handle_raster_stroke_tool(ui, &response, world_pos, lightningbeam_core::raster_layer::RasterBlendMode::Sponge, shared); + tool if crate::tools::raster_tool_def(&tool).is_some() => { + let def = crate::tools::raster_tool_def(&tool).unwrap(); + self.handle_raster_stroke_tool(ui, &response, world_pos, def, shared); } Tool::SelectLasso => { self.handle_raster_lasso_tool(ui, &response, world_pos, shared); @@ -8092,20 +8035,25 @@ impl StagePane { use lightningbeam_core::tool::Tool; // Compute semi-axes (world pixels) and dab rotation angle. - let (a_world, b_world, dab_angle_rad) = match *shared.selected_tool { - Tool::Erase => (*shared.eraser_radius, *shared.eraser_radius, 0.0_f32), - Tool::Smudge - | Tool::BlurSharpen => (*shared.smudge_radius, *shared.smudge_radius, 0.0_f32), - Tool::DodgeBurn => (*shared.dodge_burn_radius, *shared.dodge_burn_radius, 0.0_f32), - Tool::Sponge => (*shared.sponge_radius, *shared.sponge_radius, 0.0_f32), - _ => { - let bs = &shared.active_brush_settings; - let r = *shared.brush_radius; + let (a_world, b_world, dab_angle_rad) = if let Some(def) = crate::tools::raster_tool_def(shared.selected_tool) { + let r = def.cursor_radius(shared.raster_settings); + // For the standard paint brush, also account for elliptical shape. + if matches!(*shared.selected_tool, + Tool::Draw | Tool::Pencil | Tool::Pen | Tool::Airbrush) + { + let bs = &shared.raster_settings.active_brush_settings; let ratio = bs.elliptical_dab_ratio.max(1.0); - // Expand radius to cover the full jitter extent. let expand = 1.0 + bs.offset_by_random; (r * expand, r * expand / ratio, bs.elliptical_dab_angle.to_radians()) + } else { + (r, r, 0.0_f32) } + } else { + let bs = &shared.raster_settings.active_brush_settings; + let r = shared.raster_settings.brush_radius; + let ratio = bs.elliptical_dab_ratio.max(1.0); + let expand = 1.0 + bs.offset_by_random; + (r * expand, r * expand / ratio, bs.elliptical_dab_angle.to_radians()) }; let a = a_world * self.zoom; // major semi-axis in screen pixels @@ -8726,8 +8674,10 @@ impl PaneRenderer for StagePane { } // Draw clone source indicator when clone stamp or healing brush tool is selected. - if matches!(*shared.selected_tool, lightningbeam_core::tool::Tool::CloneStamp | lightningbeam_core::tool::Tool::HealingBrush) { - if let Some(src_world) = self.clone_source { + let tool_uses_alt = crate::tools::raster_tool_def(shared.selected_tool) + .map_or(false, |d| d.uses_alt_click()); + if tool_uses_alt { + if let Some(src_world) = shared.raster_settings.clone_source { let src_canvas = egui::vec2( src_world.x * self.zoom + self.pan_offset.x, src_world.y * self.zoom + self.pan_offset.y, diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs index 9d3d42b..019df99 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs @@ -136,10 +136,10 @@ impl PaneRenderer for ToolbarPane { if let Some(name) = preset_name { if let Some(preset) = bundled_brushes().iter().find(|p| p.name == name) { let s = &preset.settings; - *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(); + shared.raster_settings.brush_opacity = s.opaque.clamp(0.0, 1.0); + shared.raster_settings.brush_hardness = s.hardness.clamp(0.0, 1.0); + shared.raster_settings.brush_spacing = s.dabs_per_radius; + shared.raster_settings.active_brush_settings = s.clone(); } } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/clone_stamp.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/clone_stamp.rs new file mode 100644 index 0000000..bbfb9f2 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/clone_stamp.rs @@ -0,0 +1,29 @@ +use super::{BrushParams, RasterToolDef, RasterToolSettings}; +use eframe::egui; +use lightningbeam_core::raster_layer::RasterBlendMode; + +pub struct CloneStampTool; +pub static CLONE_STAMP: CloneStampTool = CloneStampTool; + +impl RasterToolDef for CloneStampTool { + fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::CloneStamp } + fn header_label(&self) -> &'static str { "Clone Stamp" } + fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { + BrushParams { + base_settings: s.active_brush_settings.clone(), + radius: s.brush_radius, + opacity: s.brush_opacity, + hardness: s.brush_hardness, + spacing: s.brush_spacing, + } + } + /// For Clone Stamp, tool_params are filled by stage.rs at stroke-start time + /// (offset = clone_source - stroke_start), not from settings directly. + fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] } + fn uses_alt_click(&self) -> bool { true } + fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) { + if s.clone_source.is_none() { + ui.label("Alt+click to set source point."); + } + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/dodge_burn.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/dodge_burn.rs new file mode 100644 index 0000000..7ab1968 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/dodge_burn.rs @@ -0,0 +1,54 @@ +use super::{BrushParams, RasterToolDef, RasterToolSettings}; +use eframe::egui; +use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode}; + +pub struct DodgeBurnTool; +pub static DODGE_BURN: DodgeBurnTool = DodgeBurnTool; + +impl RasterToolDef for DodgeBurnTool { + fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::DodgeBurn } + fn header_label(&self) -> &'static str { "Dodge / Burn" } + fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { + BrushParams { + base_settings: BrushSettings::default(), + radius: s.dodge_burn_radius, + opacity: s.dodge_burn_exposure, + hardness: s.dodge_burn_hardness, + spacing: s.dodge_burn_spacing, + } + } + fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] { + [s.dodge_burn_mode as f32, 0.0, 0.0, 0.0] + } + fn show_brush_preset_picker(&self) -> bool { false } + fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) { + ui.horizontal(|ui| { + if ui.selectable_label(s.dodge_burn_mode == 0, "Dodge").clicked() { + s.dodge_burn_mode = 0; + } + if ui.selectable_label(s.dodge_burn_mode == 1, "Burn").clicked() { + s.dodge_burn_mode = 1; + } + }); + ui.horizontal(|ui| { + ui.label("Size:"); + ui.add(egui::Slider::new(&mut s.dodge_burn_radius, 1.0_f32..=500.0).logarithmic(true).suffix(" px")); + }); + ui.horizontal(|ui| { + ui.label("Exposure:"); + ui.add(egui::Slider::new(&mut s.dodge_burn_exposure, 0.0_f32..=1.0) + .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); + }); + ui.horizontal(|ui| { + ui.label("Hardness:"); + ui.add(egui::Slider::new(&mut s.dodge_burn_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(&mut s.dodge_burn_spacing, 0.5_f32..=20.0) + .logarithmic(true) + .custom_formatter(|v, _| format!("{:.1}", v))); + }); + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/erase.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/erase.rs new file mode 100644 index 0000000..361858a --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/erase.rs @@ -0,0 +1,23 @@ +use super::{BrushParams, RasterToolDef, RasterToolSettings}; +use eframe::egui; +use lightningbeam_core::raster_layer::RasterBlendMode; + +pub struct EraseTool; +pub static ERASE: EraseTool = EraseTool; + +impl RasterToolDef for EraseTool { + fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Erase } + fn header_label(&self) -> &'static str { "Eraser" } + fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { + BrushParams { + base_settings: s.active_eraser_settings.clone(), + radius: s.eraser_radius, + opacity: s.eraser_opacity, + hardness: s.eraser_hardness, + spacing: s.eraser_spacing, + } + } + fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] } + fn is_eraser(&self) -> bool { true } + fn render_ui(&self, _ui: &mut egui::Ui, _s: &mut RasterToolSettings) {} +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/healing_brush.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/healing_brush.rs new file mode 100644 index 0000000..c6ae87a --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/healing_brush.rs @@ -0,0 +1,28 @@ +use super::{BrushParams, RasterToolDef, RasterToolSettings}; +use eframe::egui; +use lightningbeam_core::raster_layer::RasterBlendMode; + +pub struct HealingBrushTool; +pub static HEALING_BRUSH: HealingBrushTool = HealingBrushTool; + +impl RasterToolDef for HealingBrushTool { + fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Healing } + fn header_label(&self) -> &'static str { "Healing Brush" } + fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { + BrushParams { + base_settings: s.active_brush_settings.clone(), + radius: s.brush_radius, + opacity: s.brush_opacity, + hardness: s.brush_hardness, + spacing: s.brush_spacing, + } + } + /// tool_params are filled by stage.rs at stroke-start time (clone offset). + fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] } + fn uses_alt_click(&self) -> bool { true } + fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) { + if s.clone_source.is_none() { + ui.label("Alt+click to set source point."); + } + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs new file mode 100644 index 0000000..53fff4c --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs @@ -0,0 +1,163 @@ +/// Per-tool module for raster painting tools. +/// +/// Each tool implements `RasterToolDef`. Adding a new tool requires: +/// 1. A new file in this directory implementing `RasterToolDef`. +/// 2. One entry in `raster_tool_def()` below. +/// 3. Core changes: `RasterBlendMode` variant, `brush_engine.rs` constant, WGSL branch. + +use eframe::egui; +use lightningbeam_core::{ + brush_settings::BrushSettings, + raster_layer::RasterBlendMode, + tool::Tool, +}; + +pub mod paint; +pub mod erase; +pub mod smudge; +pub mod clone_stamp; +pub mod healing_brush; +pub mod pattern_stamp; +pub mod dodge_burn; +pub mod sponge; + +// --------------------------------------------------------------------------- +// Shared settings struct (replaces 20+ individual SharedPaneState / EditorApp fields) +// --------------------------------------------------------------------------- + +/// All per-tool settings for raster painting. Owned by `EditorApp`; borrowed +/// by `SharedPaneState` as a single `&'a mut RasterToolSettings`. +pub struct RasterToolSettings { + // --- Paint brush --- + pub brush_radius: f32, + pub brush_opacity: f32, + pub brush_hardness: f32, + pub brush_spacing: f32, + /// true = paint with FG (stroke) color, false = BG (fill) color + pub brush_use_fg: bool, + pub active_brush_settings: BrushSettings, + // --- Eraser --- + pub eraser_radius: f32, + pub eraser_opacity: f32, + pub eraser_hardness: f32, + pub eraser_spacing: f32, + pub active_eraser_settings: BrushSettings, + // --- Smudge --- + pub smudge_radius: f32, + pub smudge_hardness: f32, + pub smudge_spacing: f32, + pub smudge_strength: f32, + // --- Clone / Healing --- + /// World-space source point set by Alt+click. + pub clone_source: Option, + // --- Pattern stamp --- + pub pattern_type: u32, + pub pattern_scale: f32, + // --- Dodge / Burn --- + pub dodge_burn_radius: f32, + pub dodge_burn_hardness: f32, + pub dodge_burn_spacing: f32, + pub dodge_burn_exposure: f32, + /// 0 = dodge (lighten), 1 = burn (darken) + pub dodge_burn_mode: u32, + // --- Sponge --- + pub sponge_radius: f32, + pub sponge_hardness: f32, + pub sponge_spacing: f32, + pub sponge_flow: f32, + /// 0 = saturate, 1 = desaturate + pub sponge_mode: u32, +} + +impl Default for RasterToolSettings { + fn default() -> Self { + Self { + brush_radius: 10.0, + brush_opacity: 1.0, + brush_hardness: 0.5, + brush_spacing: 0.1, + brush_use_fg: true, + active_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, + clone_source: None, + pattern_type: 0, + pattern_scale: 32.0, + dodge_burn_radius: 30.0, + dodge_burn_hardness: 0.5, + dodge_burn_spacing: 3.0, + dodge_burn_exposure: 0.5, + dodge_burn_mode: 0, + sponge_radius: 30.0, + sponge_hardness: 0.5, + sponge_spacing: 3.0, + sponge_flow: 0.5, + sponge_mode: 0, + } + } +} + +// --------------------------------------------------------------------------- +// Brush parameters extracted per-tool +// --------------------------------------------------------------------------- + +pub struct BrushParams { + pub base_settings: BrushSettings, + pub radius: f32, + pub opacity: f32, + pub hardness: f32, + pub spacing: f32, +} + +// --------------------------------------------------------------------------- +// RasterToolDef trait +// --------------------------------------------------------------------------- + +pub trait RasterToolDef: Send + Sync { + fn blend_mode(&self) -> RasterBlendMode; + fn header_label(&self) -> &'static str; + fn brush_params(&self, s: &RasterToolSettings) -> BrushParams; + /// Encode tool-specific state into the 4-float `StrokeRecord::tool_params`. + fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4]; + /// Cursor display radius (world pixels). + fn cursor_radius(&self, s: &RasterToolSettings) -> f32 { + self.brush_params(s).radius + } + /// Render tool-specific controls in the infopanel (called before preset picker if any). + fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings); + /// Whether to show the brush preset picker after `render_ui`. + fn show_brush_preset_picker(&self) -> bool { true } + /// Whether this tool is the eraser (drives preset picker + color UI visibility). + fn is_eraser(&self) -> bool { false } + /// Whether Alt+click sets a source point for this tool. + fn uses_alt_click(&self) -> bool { false } +} + +// --------------------------------------------------------------------------- +// Lookup: Tool → &'static dyn RasterToolDef +// --------------------------------------------------------------------------- + +pub fn raster_tool_def(tool: &Tool) -> Option<&'static dyn RasterToolDef> { + match tool { + Tool::Draw | Tool::Pencil | Tool::Pen | Tool::Airbrush => Some(&paint::PAINT), + Tool::Erase => Some(&erase::ERASE), + Tool::Smudge => Some(&smudge::SMUDGE), + Tool::CloneStamp => Some(&clone_stamp::CLONE_STAMP), + Tool::HealingBrush => Some(&healing_brush::HEALING_BRUSH), + Tool::PatternStamp => Some(&pattern_stamp::PATTERN_STAMP), + Tool::DodgeBurn => Some(&dodge_burn::DODGE_BURN), + Tool::Sponge => Some(&sponge::SPONGE), + _ => None, + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/paint.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/paint.rs new file mode 100644 index 0000000..75c134e --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/paint.rs @@ -0,0 +1,22 @@ +use super::{BrushParams, RasterToolDef, RasterToolSettings}; +use eframe::egui; +use lightningbeam_core::raster_layer::RasterBlendMode; + +pub struct PaintTool; +pub static PAINT: PaintTool = PaintTool; + +impl RasterToolDef for PaintTool { + fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Normal } + fn header_label(&self) -> &'static str { "Brush" } + fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { + BrushParams { + base_settings: s.active_brush_settings.clone(), + radius: s.brush_radius, + opacity: s.brush_opacity, + hardness: s.brush_hardness, + spacing: s.brush_spacing, + } + } + fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] } + fn render_ui(&self, _ui: &mut egui::Ui, _s: &mut RasterToolSettings) {} +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/pattern_stamp.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/pattern_stamp.rs new file mode 100644 index 0000000..acd83d3 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/pattern_stamp.rs @@ -0,0 +1,49 @@ +use super::{BrushParams, RasterToolDef, RasterToolSettings}; +use eframe::egui; +use lightningbeam_core::raster_layer::RasterBlendMode; + +pub struct PatternStampTool; +pub static PATTERN_STAMP: PatternStampTool = PatternStampTool; + +const PATTERN_NAMES: &[&str] = &[ + "Checkerboard", "Dots", "H-Lines", "V-Lines", "Diagonal \\", "Diagonal /", "Crosshatch", +]; + +impl RasterToolDef for PatternStampTool { + fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::PatternStamp } + fn header_label(&self) -> &'static str { "Pattern Stamp" } + fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { + BrushParams { + base_settings: s.active_brush_settings.clone(), + radius: s.brush_radius, + opacity: s.brush_opacity, + hardness: s.brush_hardness, + spacing: s.brush_spacing, + } + } + fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] { + [s.pattern_type as f32, s.pattern_scale, 0.0, 0.0] + } + fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) { + let selected_name = PATTERN_NAMES + .get(s.pattern_type as usize) + .copied() + .unwrap_or("Checkerboard"); + ui.horizontal(|ui| { + ui.label("Pattern:"); + egui::ComboBox::from_id_salt("pattern_type") + .selected_text(selected_name) + .show_ui(ui, |ui| { + for (i, name) in PATTERN_NAMES.iter().enumerate() { + ui.selectable_value(&mut s.pattern_type, i as u32, *name); + } + }); + }); + ui.horizontal(|ui| { + ui.label("Scale:"); + ui.add(egui::Slider::new(&mut s.pattern_scale, 4.0_f32..=256.0) + .logarithmic(true).suffix(" px")); + }); + ui.add_space(4.0); + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/smudge.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/smudge.rs new file mode 100644 index 0000000..b79eeb8 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/smudge.rs @@ -0,0 +1,44 @@ +use super::{BrushParams, RasterToolDef, RasterToolSettings}; +use eframe::egui; +use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode}; + +pub struct SmudgeTool; +pub static SMUDGE: SmudgeTool = SmudgeTool; + +impl RasterToolDef for SmudgeTool { + fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Smudge } + fn header_label(&self) -> &'static str { "Smudge" } + fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { + BrushParams { + base_settings: BrushSettings::default(), + radius: s.smudge_radius, + opacity: 1.0, // strength is a separate smudge_dist multiplier + hardness: s.smudge_hardness, + spacing: s.smudge_spacing, + } + } + fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] } + fn show_brush_preset_picker(&self) -> bool { false } + fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) { + ui.horizontal(|ui| { + ui.label("Size:"); + ui.add(egui::Slider::new(&mut s.smudge_radius, 1.0_f32..=200.0).logarithmic(true).suffix(" px")); + }); + ui.horizontal(|ui| { + ui.label("Strength:"); + ui.add(egui::Slider::new(&mut s.smudge_strength, 0.0_f32..=1.0) + .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); + }); + ui.horizontal(|ui| { + ui.label("Hardness:"); + ui.add(egui::Slider::new(&mut s.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(&mut s.smudge_spacing, 0.5_f32..=20.0) + .logarithmic(true) + .custom_formatter(|v, _| format!("{:.1}", v))); + }); + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/sponge.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/sponge.rs new file mode 100644 index 0000000..a410810 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/sponge.rs @@ -0,0 +1,54 @@ +use super::{BrushParams, RasterToolDef, RasterToolSettings}; +use eframe::egui; +use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode}; + +pub struct SpongeTool; +pub static SPONGE: SpongeTool = SpongeTool; + +impl RasterToolDef for SpongeTool { + fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Sponge } + fn header_label(&self) -> &'static str { "Sponge" } + fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { + BrushParams { + base_settings: BrushSettings::default(), + radius: s.sponge_radius, + opacity: s.sponge_flow, + hardness: s.sponge_hardness, + spacing: s.sponge_spacing, + } + } + fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] { + [s.sponge_mode as f32, 0.0, 0.0, 0.0] + } + fn show_brush_preset_picker(&self) -> bool { false } + fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) { + ui.horizontal(|ui| { + if ui.selectable_label(s.sponge_mode == 0, "Saturate").clicked() { + s.sponge_mode = 0; + } + if ui.selectable_label(s.sponge_mode == 1, "Desaturate").clicked() { + s.sponge_mode = 1; + } + }); + ui.horizontal(|ui| { + ui.label("Size:"); + ui.add(egui::Slider::new(&mut s.sponge_radius, 1.0_f32..=500.0).logarithmic(true).suffix(" px")); + }); + ui.horizontal(|ui| { + ui.label("Flow:"); + ui.add(egui::Slider::new(&mut s.sponge_flow, 0.0_f32..=1.0) + .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); + }); + ui.horizontal(|ui| { + ui.label("Hardness:"); + ui.add(egui::Slider::new(&mut s.sponge_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(&mut s.sponge_spacing, 0.5_f32..=20.0) + .logarithmic(true) + .custom_formatter(|v, _| format!("{:.1}", v))); + }); + } +}