From 1d9d702a59503918e589e98a895983ae0a67aa4e Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Fri, 6 Mar 2026 08:25:12 -0500 Subject: [PATCH] Add healing brush --- .../lightningbeam-core/src/brush_engine.rs | 9 ++-- .../lightningbeam-core/src/raster_layer.rs | 2 + .../src/panes/infopanel.rs | 5 ++- .../src/panes/shaders/brush_dab.wgsl | 41 +++++++++++++++++++ .../lightningbeam-editor/src/panes/stage.rs | 19 +++++---- 5 files changed, 63 insertions(+), 13 deletions(-) diff --git a/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs b/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs index 2aa0702..693e355 100644 --- a/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs +++ b/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs @@ -303,6 +303,7 @@ impl BrushEngine { RasterBlendMode::Erase => 1u32, RasterBlendMode::Smudge => 2u32, RasterBlendMode::CloneStamp => 3u32, + RasterBlendMode::Healing => 4u32, }; let push_dab = |dabs: &mut Vec, @@ -325,7 +326,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 { 1.0 } else { stroke.color[3] }, + color_a: if blend_mode_u == 3 || blend_mode_u == 4 { 1.0 } else { stroke.color[3] }, ndx, ndy, smudge_dist, blend_mode: blend_mode_u, elliptical_dab_ratio: bs.elliptical_dab_ratio.max(1.0), @@ -359,7 +360,7 @@ impl BrushEngine { state, bs, pt.x, pt.y, base_r, pt.pressure, stroke.color, ); if !matches!(base_blend, RasterBlendMode::Smudge) { - let (cr2, cg2, cb2) = if matches!(base_blend, RasterBlendMode::CloneStamp) { + let (cr2, cg2, cb2) = 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) @@ -478,7 +479,7 @@ 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) { + } 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)); @@ -517,7 +518,7 @@ impl BrushEngine { last_smooth_x, last_smooth_y, base_r, last_pressure, stroke.color, ); - let (cr2, cg2, cb2) = if matches!(base_blend, RasterBlendMode::CloneStamp) { + let (cr2, cg2, cb2) = 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) diff --git a/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs b/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs index 4247dbe..e66dfa4 100644 --- a/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs @@ -20,6 +20,8 @@ pub enum RasterBlendMode { Smudge, /// Clone stamp: copy pixels from a source region CloneStamp, + /// Healing brush: color-corrected clone stamp (preserves source texture, shifts color to match destination) + Healing, } impl Default for RasterBlendMode { diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index e0b9ad2..d9c9ab5 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -172,7 +172,7 @@ impl InfopanelPane { 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::Erase | Tool::Smudge | Tool::CloneStamp | Tool::HealingBrush ); // Only show tool options for tools that have options @@ -195,6 +195,7 @@ impl InfopanelPane { Tool::Erase => "Eraser", Tool::Smudge => "Smudge", Tool::CloneStamp => "Clone Stamp", + Tool::HealingBrush => "Healing Brush", _ => "Brush", } } else { @@ -325,7 +326,7 @@ impl InfopanelPane { // Raster paint tools Tool::Draw | Tool::Pencil | Tool::Pen | Tool::Airbrush - | Tool::Erase | Tool::CloneStamp if is_raster_paint_tool => { + | Tool::Erase | Tool::CloneStamp | Tool::HealingBrush if is_raster_paint_tool => { self.render_raster_tool_options(ui, shared, matches!(tool, Tool::Erase)); } 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 0d40565..7cb6710 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/brush_dab.wgsl +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/brush_dab.wgsl @@ -156,6 +156,47 @@ fn apply_dab(current: vec4, dab: GpuDab, px: i32, py: i32) -> vec4 { alpha * src.b + ba * current.b, alpha * src.a + ba * current.a, ); + } else if dab.blend_mode == 4u { + // Healing brush: per-pixel color-corrected clone stamp. + // color_r/color_g = source offset (ox, oy), same as clone stamp. + // For each pixel: result = src_pixel + (local_dest_mean - local_src_mean) + // Means are computed from 4 cardinal neighbors at ±half-radius — per-pixel, no banding. + let alpha = opa_weight * dab.opacity; + if alpha <= 0.0 { return current; } + + let cw = i32(params.canvas_w); + let ch = i32(params.canvas_h); + let ox = dab.color_r; + let oy = dab.color_g; + let hr = max(dab.radius * 0.5, 1.0); + let ihr = i32(hr); + + // Per-pixel DESTINATION mean: 4 cardinal neighbors from canvas_src (pre-batch state) + let d_n = textureLoad(canvas_src, vec2(px, clamp(py - ihr, 0, ch - 1)), 0); + let d_s = textureLoad(canvas_src, vec2(px, clamp(py + ihr, 0, ch - 1)), 0); + let d_w = textureLoad(canvas_src, vec2(clamp(px - ihr, 0, cw - 1), py ), 0); + let d_e = textureLoad(canvas_src, vec2(clamp(px + ihr, 0, cw - 1), py ), 0); + let d_mean = (d_n + d_s + d_w + d_e) * 0.25; + + // Per-pixel SOURCE mean: 4 cardinal neighbors at offset position (bilinear for sub-pixel offsets) + let spx = f32(px) + 0.5 + ox; + let spy = f32(py) + 0.5 + oy; + let s_mean = (bilinear_sample(spx, spy - hr) + + bilinear_sample(spx, spy + hr) + + bilinear_sample(spx - hr, spy ) + + bilinear_sample(spx + hr, spy )) * 0.25; + + // Source pixel + color correction + let s_pixel = bilinear_sample(spx, spy); + let corrected = clamp(s_pixel + (d_mean - s_mean), vec4(0.0), vec4(1.0)); + + let ba = 1.0 - alpha; + return vec4( + alpha * corrected.r + ba * current.r, + alpha * corrected.g + ba * current.g, + alpha * corrected.b + ba * current.b, + alpha * corrected.a + ba * current.a, + ); } else { return current; } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 471bc5b..200f0de 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -4934,9 +4934,10 @@ impl StagePane { && self.raster_stroke_state.is_none()) || (self.rsp_clicked(response) && self.raster_stroke_state.is_none()); if stroke_start { - // Clone stamp: compute and store the source offset (source - drag_start). + // Clone stamp / healing brush: compute and store the source offset (source - drag_start). // This is constant for the entire stroke and used in every StrokeRecord below. - if matches!(blend_mode, lightningbeam_core::raster_layer::RasterBlendMode::CloneStamp) { + 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| ( s.x - world_pos.x, s.y - world_pos.y, )); @@ -7506,15 +7507,15 @@ impl StagePane { }); } - // Clone stamp: Alt+click sets the source point regardless of the alt-pan guard below. + // Clone stamp / healing brush: Alt+click sets the source point regardless of the alt-pan guard below. { use lightningbeam_core::tool::Tool; - if matches!(*shared.selected_tool, Tool::CloneStamp) + if matches!(*shared.selected_tool, Tool::CloneStamp | Tool::HealingBrush) && alt_held && self.rsp_primary_pressed(ui) && response.hovered() { - eprintln!("[clone stamp] set clone source to ({:.1}, {:.1})", world_pos.x, world_pos.y); + eprintln!("[clone/healing] set clone source to ({:.1}, {:.1})", world_pos.x, world_pos.y); self.clone_source = Some(world_pos); } } @@ -7568,6 +7569,10 @@ impl StagePane { // 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::SelectLasso => { self.handle_raster_lasso_tool(ui, &response, world_pos, shared); } @@ -8677,8 +8682,8 @@ impl PaneRenderer for StagePane { ); } - // Draw clone source indicator when clone stamp tool is selected. - if matches!(*shared.selected_tool, lightningbeam_core::tool::Tool::CloneStamp) { + // 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 src_canvas = egui::vec2( src_world.x * self.zoom + self.pan_offset.x,