diff --git a/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs b/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs index 6794391..2aa0702 100644 --- a/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs +++ b/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs @@ -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, @@ -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); } } diff --git a/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs b/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs index 9f6f293..4247dbe 100644 --- a/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs @@ -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, } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index e3c1ee7..e0b9ad2 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -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)); } 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 4c7f71c..0d40565 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/brush_dab.wgsl +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/brush_dab.wgsl @@ -126,7 +126,7 @@ fn apply_dab(current: vec4, dab: GpuDab, px: i32, py: i32) -> vec4 { let new_a = current.a * (1.0 - dab_a); let scale = select(0.0, new_a / current.a, current.a > 1e-6); return vec4(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, dab: GpuDab, px: i32, py: i32) -> vec4 { 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( + 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; } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index e3b7cfc..471bc5b 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -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, + /// 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, @@ -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()) {