Add healing brush

This commit is contained in:
Skyler Lehmkuhl 2026-03-06 08:25:12 -05:00
parent de24622f02
commit 1d9d702a59
5 changed files with 63 additions and 13 deletions

View File

@ -303,6 +303,7 @@ impl BrushEngine {
RasterBlendMode::Erase => 1u32,
RasterBlendMode::Smudge => 2u32,
RasterBlendMode::CloneStamp => 3u32,
RasterBlendMode::Healing => 4u32,
};
let push_dab = |dabs: &mut Vec<GpuDab>,
@ -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)

View File

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

View File

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

View File

@ -156,6 +156,47 @@ fn apply_dab(current: vec4<f32>, dab: GpuDab, px: i32, py: i32) -> vec4<f32> {
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<i32>(px, clamp(py - ihr, 0, ch - 1)), 0);
let d_s = textureLoad(canvas_src, vec2<i32>(px, clamp(py + ihr, 0, ch - 1)), 0);
let d_w = textureLoad(canvas_src, vec2<i32>(clamp(px - ihr, 0, cw - 1), py ), 0);
let d_e = textureLoad(canvas_src, vec2<i32>(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<f32>(0.0), vec4<f32>(1.0));
let ba = 1.0 - alpha;
return vec4<f32>(
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;
}

View File

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