Add clone stamp tool

This commit is contained in:
Skyler Lehmkuhl 2026-03-06 08:05:45 -05:00
parent 2c9d8c1589
commit de24622f02
5 changed files with 128 additions and 9 deletions

View File

@ -299,9 +299,10 @@ impl BrushEngine {
other => other,
};
let blend_mode_u = match base_blend {
RasterBlendMode::Normal => 0u32,
RasterBlendMode::Erase => 1u32,
RasterBlendMode::Smudge => 2u32,
RasterBlendMode::Normal => 0u32,
RasterBlendMode::Erase => 1u32,
RasterBlendMode::Smudge => 2u32,
RasterBlendMode::CloneStamp => 3u32,
};
let push_dab = |dabs: &mut Vec<GpuDab>,
@ -322,7 +323,9 @@ impl BrushEngine {
color_r: cr,
color_g: cg,
color_b: cb,
color_a: stroke.color[3],
// 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] },
ndx, ndy, smudge_dist,
blend_mode: blend_mode_u,
elliptical_dab_ratio: bs.elliptical_dab_ratio.max(1.0),
@ -356,7 +359,14 @@ impl BrushEngine {
state, bs, pt.x, pt.y, base_r, pt.pressure, stroke.color,
);
if !matches!(base_blend, RasterBlendMode::Smudge) {
push_dab(&mut dabs, &mut bbox, ex, ey, r, o, cr, cg, cb,
let (cr2, cg2, cb2) = if matches!(base_blend, RasterBlendMode::CloneStamp) {
// 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)
} else {
(cr, cg, cb)
};
push_dab(&mut dabs, &mut bbox, ex, ey, r, o, cr2, cg2, cb2,
0.0, 0.0, 0.0);
}
}
@ -468,6 +478,13 @@ 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) {
// 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 {
push_dab(&mut dabs, &mut bbox,
ex, ey, radius2, opacity2, cr, cg, cb,
@ -500,7 +517,14 @@ impl BrushEngine {
last_smooth_x, last_smooth_y,
base_r, last_pressure, stroke.color,
);
push_dab(&mut dabs, &mut bbox, ex, ey, r, o, cr, cg, cb,
let (cr2, cg2, cb2) = if matches!(base_blend, RasterBlendMode::CloneStamp) {
// 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)
} else {
(cr, cg, cb)
};
push_dab(&mut dabs, &mut bbox, ex, ey, r, o, cr2, cg2, cb2,
0.0, 0.0, 0.0);
}
}

View File

@ -18,6 +18,8 @@ pub enum RasterBlendMode {
Erase,
/// Smudge / blend surrounding pixels
Smudge,
/// Clone stamp: copy pixels from a source region
CloneStamp,
}
impl Default for RasterBlendMode {
@ -48,6 +50,11 @@ 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.
#[serde(default)]
pub clone_src_offset: Option<(f32, f32)>,
pub points: Vec<StrokePoint>,
}

View File

@ -171,7 +171,8 @@ 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::Draw | Tool::Pencil | Tool::Pen | Tool::Airbrush
| Tool::Erase | Tool::Smudge | Tool::CloneStamp
);
// Only show tool options for tools that have options
@ -193,6 +194,7 @@ impl InfopanelPane {
match tool {
Tool::Erase => "Eraser",
Tool::Smudge => "Smudge",
Tool::CloneStamp => "Clone Stamp",
_ => "Brush",
}
} else {
@ -322,7 +324,8 @@ impl InfopanelPane {
}
// Raster paint tools
Tool::Draw | Tool::Pencil | Tool::Pen | Tool::Airbrush | Tool::Erase if is_raster_paint_tool => {
Tool::Draw | Tool::Pencil | Tool::Pen | Tool::Airbrush
| Tool::Erase | Tool::CloneStamp if is_raster_paint_tool => {
self.render_raster_tool_options(ui, shared, matches!(tool, Tool::Erase));
}

View File

@ -126,7 +126,7 @@ fn apply_dab(current: vec4<f32>, dab: GpuDab, px: i32, py: i32) -> vec4<f32> {
let new_a = current.a * (1.0 - dab_a);
let scale = select(0.0, new_a / current.a, current.a > 1e-6);
return vec4<f32>(current.r * scale, current.g * scale, current.b * scale, new_a);
} else {
} else if dab.blend_mode == 2u {
// Smudge: directional warp sample from position behind the stroke direction
let alpha = opa_weight * dab.opacity;
if alpha <= 0.0 { return current; }
@ -140,6 +140,24 @@ fn apply_dab(current: vec4<f32>, dab: GpuDab, px: i32, py: i32) -> vec4<f32> {
alpha * src.b + da * current.b,
alpha * src.a + da * current.a,
);
} else if dab.blend_mode == 3u {
// Clone stamp: sample from (this_pixel + offset) in the source canvas.
// color_r/color_g store the world-space offset (source_world - drag_start_world)
// computed once when the stroke begins. Each pixel samples its own source texel.
let alpha = opa_weight * dab.opacity;
if alpha <= 0.0 { return current; }
let src_x = f32(px) + 0.5 + dab.color_r;
let src_y = f32(py) + 0.5 + dab.color_g;
let src = bilinear_sample(src_x, src_y);
let ba = 1.0 - alpha;
return vec4<f32>(
alpha * src.r + ba * current.r,
alpha * src.g + ba * current.g,
alpha * src.b + ba * current.b,
alpha * src.a + ba * current.a,
);
} else {
return current;
}
}

View File

@ -614,6 +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,
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 },
@ -2482,6 +2483,11 @@ 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<egui::Vec2>,
/// 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)>,
/// Synthetic drag/click override for test mode replay (debug builds only)
#[cfg(debug_assertions)]
replay_override: Option<ReplayDragState>,
@ -2606,6 +2612,8 @@ 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,
}
@ -4926,6 +4934,16 @@ 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).
// This is constant for the entire stroke and used in every StrokeRecord below.
if matches!(blend_mode, lightningbeam_core::raster_layer::RasterBlendMode::CloneStamp) {
self.clone_stroke_offset = self.clone_source.map(|s| (
s.x - world_pos.x, s.y - world_pos.y,
));
} else {
self.clone_stroke_offset = None;
}
// Determine if we are painting into the float (B) or the layer (A).
let painting_float = shared.selection.raster_floating.is_some();
self.painting_float = painting_float;
@ -4954,6 +4972,7 @@ impl StagePane {
brush_settings: brush.clone(),
color,
blend_mode,
clone_src_offset: self.clone_stroke_offset,
points: vec![first_pt.clone()],
};
let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, &mut stroke_state, 0.0);
@ -5042,6 +5061,7 @@ impl StagePane {
brush_settings: brush.clone(),
color,
blend_mode,
clone_src_offset: self.clone_stroke_offset,
points: vec![first_pt.clone()],
};
let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, &mut stroke_state, 0.0);
@ -5121,10 +5141,12 @@ 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,
points: vec![prev_pt, curr_local],
};
let current_time = ui.input(|i| i.time);
@ -5186,6 +5208,7 @@ impl StagePane {
brush_settings: brush.clone(),
color,
blend_mode,
clone_src_offset: self.clone_stroke_offset,
points: vec![pt],
};
let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, stroke_state, dt);
@ -7483,6 +7506,19 @@ impl StagePane {
});
}
// Clone stamp: 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)
&& alt_held
&& self.rsp_primary_pressed(ui)
&& response.hovered()
{
eprintln!("[clone stamp] set clone source to ({:.1}, {:.1})", world_pos.x, world_pos.y);
self.clone_source = Some(world_pos);
}
}
// Handle tool input (only if not using Alt modifier for panning)
if !alt_held {
use lightningbeam_core::tool::Tool;
@ -7527,6 +7563,11 @@ impl StagePane {
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::SelectLasso => {
self.handle_raster_lasso_tool(ui, &response, world_pos, shared);
}
@ -8636,6 +8677,32 @@ impl PaneRenderer for StagePane {
);
}
// Draw clone source indicator when clone stamp tool is selected.
if matches!(*shared.selected_tool, lightningbeam_core::tool::Tool::CloneStamp) {
if let Some(src_world) = self.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,
);
let src_screen = rect.min + src_canvas;
let painter = ui.painter_at(rect);
let r = 8.0_f32; // circle radius
let arm = 14.0_f32; // arm half-length (extends past the circle)
let gap = r + 2.0; // gap between circle edge and arm start
for (width, color) in [
(3.0_f32, egui::Color32::BLACK),
(1.5_f32, egui::Color32::WHITE),
] {
let s = egui::Stroke::new(width, color);
painter.circle_stroke(src_screen, r, s);
painter.line_segment([src_screen - egui::vec2(arm, 0.0), src_screen - egui::vec2(gap, 0.0)], s);
painter.line_segment([src_screen + egui::vec2(gap, 0.0), src_screen + egui::vec2(arm, 0.0)], s);
painter.line_segment([src_screen - egui::vec2(0.0, arm), src_screen - egui::vec2(0.0, gap)], s);
painter.line_segment([src_screen + egui::vec2(0.0, gap), src_screen + egui::vec2(0.0, arm)], s);
}
}
}
// Set custom tool cursor when pointer is over the stage canvas.
// Raster paint tools get a brush-size outline; everything else uses the SVG cursor.
if let Some(pos) = ui.input(|i| i.pointer.hover_pos()) {