diff --git a/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs b/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs index b4b94bf..b8e83bf 100644 --- a/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs +++ b/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs @@ -307,6 +307,7 @@ impl BrushEngine { RasterBlendMode::PatternStamp => 5u32, RasterBlendMode::DodgeBurn => 6u32, RasterBlendMode::Sponge => 7u32, + RasterBlendMode::BlurSharpen => 8u32, }; let push_dab = |dabs: &mut Vec, @@ -371,6 +372,8 @@ impl BrushEngine { (cr, cg, cb, tp[0], tp[1]), RasterBlendMode::DodgeBurn | RasterBlendMode::Sponge => (tp[0], 0.0, 0.0, 0.0, 0.0), + RasterBlendMode::BlurSharpen => + (tp[0], 0.0, 0.0, tp[1], 0.0), _ => (cr, cg, cb, 0.0, 0.0), }; push_dab(&mut dabs, &mut bbox, ex, ey, r, o, cr2, cg2, cb2, @@ -494,6 +497,8 @@ impl BrushEngine { (cr, cg, cb, tp[0], tp[1]), RasterBlendMode::DodgeBurn | RasterBlendMode::Sponge => (tp[0], 0.0, 0.0, 0.0, 0.0), + RasterBlendMode::BlurSharpen => + (tp[0], 0.0, 0.0, tp[1], 0.0), _ => (cr, cg, cb, 0.0, 0.0), }; push_dab(&mut dabs, &mut bbox, @@ -535,6 +540,8 @@ impl BrushEngine { (cr, cg, cb, tp[0], tp[1]), RasterBlendMode::DodgeBurn | RasterBlendMode::Sponge => (tp[0], 0.0, 0.0, 0.0, 0.0), + RasterBlendMode::BlurSharpen => + (tp[0], 0.0, 0.0, tp[1], 0.0), _ => (cr, cg, cb, 0.0, 0.0), }; push_dab(&mut dabs, &mut bbox, ex, ey, r, o, cr2, cg2, cb2, diff --git a/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs b/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs index cf0aee9..4bac3e7 100644 --- a/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs @@ -28,6 +28,8 @@ pub enum RasterBlendMode { DodgeBurn, /// Sponge: saturate or desaturate existing pixels Sponge, + /// Blur / Sharpen: soften or crisp up existing pixels + BlurSharpen, } impl Default for RasterBlendMode { @@ -41,7 +43,7 @@ impl RasterBlendMode { /// 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) + !matches!(self, Self::CloneStamp | Self::Healing | Self::DodgeBurn | Self::Sponge | Self::BlurSharpen) } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/brush_dab.wgsl b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/brush_dab.wgsl index d324b3a..134b3dd 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/brush_dab.wgsl +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/brush_dab.wgsl @@ -278,6 +278,52 @@ fn apply_dab(current: vec4, dab: GpuDab, px: i32, py: i32) -> vec4 { adjusted = mix(current.rgb, luma_vec, s); } return vec4(adjusted, current.a); + } else if dab.blend_mode == 8u { + // Blur / Sharpen: 5×5 separable Gaussian kernel. + // color_r: 0.0 = blur, 1.0 = sharpen + // ndx: kernel radius in canvas pixels (> 0) + // + // Samples are placed on a grid at ±step and ±2*step per axis, where step = kr/2. + // Weights are exp(-x²/2σ²) with σ = step, factored as a separable product. + // This gives a true Gaussian falloff rather than a flat ring, so edges blend + // into a smooth gradient rather than a flat averaged zone. + let s = opa_weight * dab.opacity; + if s <= 0.0 { return current; } + + let kr = max(dab.ndx, 1.0); + let cx2 = f32(px) + 0.5; + let cy2 = f32(py) + 0.5; + let step = kr * 0.5; + + // 1-D Gaussian weights at distances 0, ±step, ±2*step (σ = step): + // exp(0) = 1.0, exp(-0.5) ≈ 0.6065, exp(-2.0) ≈ 0.1353 + var gauss = array(0.1353, 0.6065, 1.0, 0.6065, 0.1353); + + var blur_sum = vec4(0.0); + var blur_w = 0.0; + for (var iy = 0; iy < 5; iy++) { + for (var ix = 0; ix < 5; ix++) { + let w = gauss[ix] * gauss[iy]; + let spx = cx2 + (f32(ix) - 2.0) * step; + let spy = cy2 + (f32(iy) - 2.0) * step; + blur_sum += bilinear_sample(spx, spy) * w; + blur_w += w; + } + } + let blurred = blur_sum / blur_w; + + let c = textureLoad(canvas_src, vec2(px, py), 0); + var result: vec4; + if dab.color_r < 0.5 { + // Blur: blend current toward the Gaussian-weighted local average. + result = mix(current, blurred, s); + } else { + // Sharpen: unsharp mask — push pixel away from the local average. + // sharpened = 2*src - blurred → highlights diverge, shadows diverge. + let sharpened = clamp(c * 2.0 - blurred, vec4(0.0), vec4(1.0)); + result = mix(current, sharpened, s); + } + return result; } else { return current; } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index dd2f464..c057208 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -4904,6 +4904,10 @@ impl StagePane { // smudge_dist = radius * exp(smudge_radius_log), so log(strength) gives the ratio. b.smudge_radius_log = shared.raster_settings.smudge_strength; // linear [0,1] strength } + if matches!(blend_mode, lightningbeam_core::raster_layer::RasterBlendMode::BlurSharpen) { + // Zero dabs_per_actual_radius so the spacing slider is the sole density control. + b.dabs_per_actual_radius = 0.0; + } b }; diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/blur_sharpen.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/blur_sharpen.rs new file mode 100644 index 0000000..876b5d6 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/blur_sharpen.rs @@ -0,0 +1,60 @@ +use super::{BrushParams, RasterToolDef, RasterToolSettings}; +use eframe::egui; +use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode}; + +pub struct BlurSharpenTool; +pub static BLUR_SHARPEN: BlurSharpenTool = BlurSharpenTool; + +impl RasterToolDef for BlurSharpenTool { + fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::BlurSharpen } + fn header_label(&self) -> &'static str { "Blur / Sharpen" } + fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { + BrushParams { + base_settings: BrushSettings::default(), + radius: s.blur_sharpen_radius, + opacity: s.blur_sharpen_strength, + hardness: s.blur_sharpen_hardness, + spacing: s.blur_sharpen_spacing, + } + } + fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] { + [s.blur_sharpen_mode as f32, s.blur_sharpen_kernel, 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.blur_sharpen_mode == 0, "Blur").clicked() { + s.blur_sharpen_mode = 0; + } + if ui.selectable_label(s.blur_sharpen_mode == 1, "Sharpen").clicked() { + s.blur_sharpen_mode = 1; + } + }); + ui.horizontal(|ui| { + ui.label("Size:"); + ui.add(egui::Slider::new(&mut s.blur_sharpen_radius, 1.0_f32..=500.0).logarithmic(true).suffix(" px")); + }); + ui.horizontal(|ui| { + ui.label("Strength:"); + ui.add(egui::Slider::new(&mut s.blur_sharpen_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.blur_sharpen_hardness, 0.0_f32..=1.0) + .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); + }); + ui.horizontal(|ui| { + ui.label("Kernel:"); + ui.add(egui::Slider::new(&mut s.blur_sharpen_kernel, 1.0_f32..=20.0) + .logarithmic(true) + .custom_formatter(|v, _| format!("{:.1} px", v))); + }); + ui.horizontal(|ui| { + ui.label("Spacing:"); + ui.add(egui::Slider::new(&mut s.blur_sharpen_spacing, 0.5_f32..=20.0) + .logarithmic(true) + .custom_formatter(|v, _| format!("{:.1}", v))); + }); + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs index 53fff4c..55039db 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs @@ -20,6 +20,7 @@ pub mod healing_brush; pub mod pattern_stamp; pub mod dodge_burn; pub mod sponge; +pub mod blur_sharpen; // --------------------------------------------------------------------------- // Shared settings struct (replaces 20+ individual SharedPaneState / EditorApp fields) @@ -67,6 +68,15 @@ pub struct RasterToolSettings { pub sponge_flow: f32, /// 0 = saturate, 1 = desaturate pub sponge_mode: u32, + // --- Blur / Sharpen --- + pub blur_sharpen_radius: f32, + pub blur_sharpen_hardness: f32, + pub blur_sharpen_spacing: f32, + pub blur_sharpen_strength: f32, + /// Neighborhood kernel radius in canvas pixels (1–20) + pub blur_sharpen_kernel: f32, + /// 0 = blur, 1 = sharpen + pub blur_sharpen_mode: u32, } impl Default for RasterToolSettings { @@ -104,6 +114,12 @@ impl Default for RasterToolSettings { sponge_spacing: 3.0, sponge_flow: 0.5, sponge_mode: 0, + blur_sharpen_radius: 30.0, + blur_sharpen_hardness: 0.5, + blur_sharpen_spacing: 3.0, + blur_sharpen_strength: 0.5, + blur_sharpen_kernel: 5.0, + blur_sharpen_mode: 0, } } } @@ -158,6 +174,7 @@ pub fn raster_tool_def(tool: &Tool) -> Option<&'static dyn RasterToolDef> { Tool::PatternStamp => Some(&pattern_stamp::PATTERN_STAMP), Tool::DodgeBurn => Some(&dodge_burn::DODGE_BURN), Tool::Sponge => Some(&sponge::SPONGE), + Tool::BlurSharpen => Some(&blur_sharpen::BLUR_SHARPEN), _ => None, } }