Add clone stamp tool
This commit is contained in:
parent
2c9d8c1589
commit
de24622f02
|
|
@ -299,9 +299,10 @@ impl BrushEngine {
|
||||||
other => other,
|
other => other,
|
||||||
};
|
};
|
||||||
let blend_mode_u = match base_blend {
|
let blend_mode_u = match base_blend {
|
||||||
RasterBlendMode::Normal => 0u32,
|
RasterBlendMode::Normal => 0u32,
|
||||||
RasterBlendMode::Erase => 1u32,
|
RasterBlendMode::Erase => 1u32,
|
||||||
RasterBlendMode::Smudge => 2u32,
|
RasterBlendMode::Smudge => 2u32,
|
||||||
|
RasterBlendMode::CloneStamp => 3u32,
|
||||||
};
|
};
|
||||||
|
|
||||||
let push_dab = |dabs: &mut Vec<GpuDab>,
|
let push_dab = |dabs: &mut Vec<GpuDab>,
|
||||||
|
|
@ -322,7 +323,9 @@ impl BrushEngine {
|
||||||
color_r: cr,
|
color_r: cr,
|
||||||
color_g: cg,
|
color_g: cg,
|
||||||
color_b: cb,
|
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,
|
ndx, ndy, smudge_dist,
|
||||||
blend_mode: blend_mode_u,
|
blend_mode: blend_mode_u,
|
||||||
elliptical_dab_ratio: bs.elliptical_dab_ratio.max(1.0),
|
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,
|
state, bs, pt.x, pt.y, base_r, pt.pressure, stroke.color,
|
||||||
);
|
);
|
||||||
if !matches!(base_blend, RasterBlendMode::Smudge) {
|
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);
|
0.0, 0.0, 0.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -468,6 +478,13 @@ impl BrushEngine {
|
||||||
push_dab(&mut dabs, &mut bbox,
|
push_dab(&mut dabs, &mut bbox,
|
||||||
ex, ey, radius2, opacity2, cr, cg, cb,
|
ex, ey, radius2, opacity2, cr, cg, cb,
|
||||||
ndx, ndy, smudge_dist);
|
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 {
|
} else {
|
||||||
push_dab(&mut dabs, &mut bbox,
|
push_dab(&mut dabs, &mut bbox,
|
||||||
ex, ey, radius2, opacity2, cr, cg, cb,
|
ex, ey, radius2, opacity2, cr, cg, cb,
|
||||||
|
|
@ -500,7 +517,14 @@ impl BrushEngine {
|
||||||
last_smooth_x, last_smooth_y,
|
last_smooth_x, last_smooth_y,
|
||||||
base_r, last_pressure, stroke.color,
|
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);
|
0.0, 0.0, 0.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ pub enum RasterBlendMode {
|
||||||
Erase,
|
Erase,
|
||||||
/// Smudge / blend surrounding pixels
|
/// Smudge / blend surrounding pixels
|
||||||
Smudge,
|
Smudge,
|
||||||
|
/// Clone stamp: copy pixels from a source region
|
||||||
|
CloneStamp,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for RasterBlendMode {
|
impl Default for RasterBlendMode {
|
||||||
|
|
@ -48,6 +50,11 @@ pub struct StrokeRecord {
|
||||||
/// RGBA linear color [r, g, b, a]
|
/// RGBA linear color [r, g, b, a]
|
||||||
pub color: [f32; 4],
|
pub color: [f32; 4],
|
||||||
pub blend_mode: RasterBlendMode,
|
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>,
|
pub points: Vec<StrokePoint>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -171,7 +171,8 @@ impl InfopanelPane {
|
||||||
|
|
||||||
let is_raster_paint_tool = active_is_raster && matches!(
|
let is_raster_paint_tool = active_is_raster && matches!(
|
||||||
tool,
|
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
|
// Only show tool options for tools that have options
|
||||||
|
|
@ -193,6 +194,7 @@ impl InfopanelPane {
|
||||||
match tool {
|
match tool {
|
||||||
Tool::Erase => "Eraser",
|
Tool::Erase => "Eraser",
|
||||||
Tool::Smudge => "Smudge",
|
Tool::Smudge => "Smudge",
|
||||||
|
Tool::CloneStamp => "Clone Stamp",
|
||||||
_ => "Brush",
|
_ => "Brush",
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -322,7 +324,8 @@ impl InfopanelPane {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Raster paint tools
|
// 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));
|
self.render_raster_tool_options(ui, shared, matches!(tool, Tool::Erase));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 new_a = current.a * (1.0 - dab_a);
|
||||||
let scale = select(0.0, new_a / current.a, current.a > 1e-6);
|
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);
|
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
|
// Smudge: directional warp — sample from position behind the stroke direction
|
||||||
let alpha = opa_weight * dab.opacity;
|
let alpha = opa_weight * dab.opacity;
|
||||||
if alpha <= 0.0 { return current; }
|
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.b + da * current.b,
|
||||||
alpha * src.a + da * current.a,
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -614,6 +614,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
||||||
brush_settings: scaled,
|
brush_settings: scaled,
|
||||||
color: [0.85f32, 0.88, 1.0, 1.0],
|
color: [0.85f32, 0.88, 1.0, 1.0],
|
||||||
blend_mode: RasterBlendMode::Normal,
|
blend_mode: RasterBlendMode::Normal,
|
||||||
|
clone_src_offset: None,
|
||||||
points: vec![
|
points: vec![
|
||||||
StrokePoint { x: x0, y: y_lo, pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0 },
|
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 },
|
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.
|
/// 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.
|
/// Used to compute `dt` for the unified distance+time dab accumulator.
|
||||||
raster_last_compute_time: f64,
|
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)
|
/// Synthetic drag/click override for test mode replay (debug builds only)
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
replay_override: Option<ReplayDragState>,
|
replay_override: Option<ReplayDragState>,
|
||||||
|
|
@ -2606,6 +2612,8 @@ impl StagePane {
|
||||||
stroke_clip_selection: None,
|
stroke_clip_selection: None,
|
||||||
painting_float: false,
|
painting_float: false,
|
||||||
raster_last_compute_time: 0.0,
|
raster_last_compute_time: 0.0,
|
||||||
|
clone_source: None,
|
||||||
|
clone_stroke_offset: None,
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
replay_override: None,
|
replay_override: None,
|
||||||
}
|
}
|
||||||
|
|
@ -4926,6 +4934,16 @@ impl StagePane {
|
||||||
&& self.raster_stroke_state.is_none())
|
&& self.raster_stroke_state.is_none())
|
||||||
|| (self.rsp_clicked(response) && self.raster_stroke_state.is_none());
|
|| (self.rsp_clicked(response) && self.raster_stroke_state.is_none());
|
||||||
if stroke_start {
|
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).
|
// Determine if we are painting into the float (B) or the layer (A).
|
||||||
let painting_float = shared.selection.raster_floating.is_some();
|
let painting_float = shared.selection.raster_floating.is_some();
|
||||||
self.painting_float = painting_float;
|
self.painting_float = painting_float;
|
||||||
|
|
@ -4954,6 +4972,7 @@ impl StagePane {
|
||||||
brush_settings: brush.clone(),
|
brush_settings: brush.clone(),
|
||||||
color,
|
color,
|
||||||
blend_mode,
|
blend_mode,
|
||||||
|
clone_src_offset: self.clone_stroke_offset,
|
||||||
points: vec![first_pt.clone()],
|
points: vec![first_pt.clone()],
|
||||||
};
|
};
|
||||||
let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, &mut stroke_state, 0.0);
|
let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, &mut stroke_state, 0.0);
|
||||||
|
|
@ -5042,6 +5061,7 @@ impl StagePane {
|
||||||
brush_settings: brush.clone(),
|
brush_settings: brush.clone(),
|
||||||
color,
|
color,
|
||||||
blend_mode,
|
blend_mode,
|
||||||
|
clone_src_offset: self.clone_stroke_offset,
|
||||||
points: vec![first_pt.clone()],
|
points: vec![first_pt.clone()],
|
||||||
};
|
};
|
||||||
let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, &mut stroke_state, 0.0);
|
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 {
|
if dx * dx + dy * dy >= MIN_DIST_SQ {
|
||||||
|
let clone_src_offset = self.clone_stroke_offset;
|
||||||
let seg = StrokeRecord {
|
let seg = StrokeRecord {
|
||||||
brush_settings: brush.clone(),
|
brush_settings: brush.clone(),
|
||||||
color,
|
color,
|
||||||
blend_mode,
|
blend_mode,
|
||||||
|
clone_src_offset,
|
||||||
points: vec![prev_pt, curr_local],
|
points: vec![prev_pt, curr_local],
|
||||||
};
|
};
|
||||||
let current_time = ui.input(|i| i.time);
|
let current_time = ui.input(|i| i.time);
|
||||||
|
|
@ -5186,6 +5208,7 @@ impl StagePane {
|
||||||
brush_settings: brush.clone(),
|
brush_settings: brush.clone(),
|
||||||
color,
|
color,
|
||||||
blend_mode,
|
blend_mode,
|
||||||
|
clone_src_offset: self.clone_stroke_offset,
|
||||||
points: vec![pt],
|
points: vec![pt],
|
||||||
};
|
};
|
||||||
let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, stroke_state, dt);
|
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)
|
// Handle tool input (only if not using Alt modifier for panning)
|
||||||
if !alt_held {
|
if !alt_held {
|
||||||
use lightningbeam_core::tool::Tool;
|
use lightningbeam_core::tool::Tool;
|
||||||
|
|
@ -7527,6 +7563,11 @@ impl StagePane {
|
||||||
Tool::Smudge => {
|
Tool::Smudge => {
|
||||||
self.handle_raster_stroke_tool(ui, &response, world_pos, lightningbeam_core::raster_layer::RasterBlendMode::Smudge, shared);
|
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 => {
|
Tool::SelectLasso => {
|
||||||
self.handle_raster_lasso_tool(ui, &response, world_pos, shared);
|
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.
|
// 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.
|
// 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()) {
|
if let Some(pos) = ui.input(|i| i.pointer.hover_pos()) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue