From 7d55443b2a5dfef31a35f2811737fbf35be3f36e Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Fri, 6 Mar 2026 08:40:17 -0500 Subject: [PATCH] Add pattern stamp tool --- .../lightningbeam-core/src/brush_engine.rs | 30 +++++++++---- .../lightningbeam-core/src/raster_layer.rs | 10 +++++ .../lightningbeam-editor/src/main.rs | 7 +++ .../src/panes/infopanel.rs | 30 ++++++++++++- .../lightningbeam-editor/src/panes/mod.rs | 4 ++ .../src/panes/shaders/brush_dab.wgsl | 43 +++++++++++++++++++ .../lightningbeam-editor/src/panes/stage.rs | 13 ++++++ 7 files changed, 127 insertions(+), 10 deletions(-) diff --git a/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs b/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs index 693e355..5f7b585 100644 --- a/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs +++ b/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs @@ -303,7 +303,8 @@ impl BrushEngine { RasterBlendMode::Erase => 1u32, RasterBlendMode::Smudge => 2u32, RasterBlendMode::CloneStamp => 3u32, - RasterBlendMode::Healing => 4u32, + RasterBlendMode::Healing => 4u32, + RasterBlendMode::PatternStamp => 5u32, }; let push_dab = |dabs: &mut Vec, @@ -360,15 +361,18 @@ 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 | RasterBlendMode::Healing) { + let (cr2, cg2, cb2, ndx2, ndy2) = 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) + (ox, oy, 0.0, 0.0, 0.0) + } else if matches!(base_blend, RasterBlendMode::PatternStamp) { + // ndx = pattern_type, ndy = pattern_scale + (cr, cg, cb, stroke.pattern_type as f32, stroke.pattern_scale) } else { - (cr, cg, cb) + (cr, cg, cb, 0.0, 0.0) }; push_dab(&mut dabs, &mut bbox, ex, ey, r, o, cr2, cg2, cb2, - 0.0, 0.0, 0.0); + ndx2, ndy2, 0.0); } } } @@ -486,6 +490,11 @@ impl BrushEngine { push_dab(&mut dabs, &mut bbox, ex, ey, radius2, opacity2, ox, oy, 0.0, 0.0, 0.0, 0.0); + } else if matches!(base_blend, RasterBlendMode::PatternStamp) { + // ndx = pattern_type, ndy = pattern_scale + push_dab(&mut dabs, &mut bbox, + ex, ey, radius2, opacity2, cr, cg, cb, + stroke.pattern_type as f32, stroke.pattern_scale, 0.0); } else { push_dab(&mut dabs, &mut bbox, ex, ey, radius2, opacity2, cr, cg, cb, @@ -518,15 +527,18 @@ impl BrushEngine { last_smooth_x, last_smooth_y, base_r, last_pressure, stroke.color, ); - let (cr2, cg2, cb2) = if matches!(base_blend, RasterBlendMode::CloneStamp | RasterBlendMode::Healing) { + let (cr2, cg2, cb2, ndx2, ndy2) = 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) + (ox, oy, 0.0, 0.0, 0.0) + } else if matches!(base_blend, RasterBlendMode::PatternStamp) { + // ndx = pattern_type, ndy = pattern_scale + (cr, cg, cb, stroke.pattern_type as f32, stroke.pattern_scale) } else { - (cr, cg, cb) + (cr, cg, cb, 0.0, 0.0) }; push_dab(&mut dabs, &mut bbox, ex, ey, r, o, cr2, cg2, cb2, - 0.0, 0.0, 0.0); + ndx2, ndy2, 0.0); } } diff --git a/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs b/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs index e66dfa4..d144a40 100644 --- a/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs @@ -22,6 +22,8 @@ pub enum RasterBlendMode { CloneStamp, /// Healing brush: color-corrected clone stamp (preserves source texture, shifts color to match destination) Healing, + /// Pattern stamp: paint with a repeating procedural tile pattern + PatternStamp, } impl Default for RasterBlendMode { @@ -57,9 +59,17 @@ pub struct StrokeRecord { /// None for all non-clone-stamp blend modes. #[serde(default)] pub clone_src_offset: Option<(f32, f32)>, + /// Pattern stamp: procedural pattern type (0=Checkerboard, 1=Dots, 2=H-Lines, 3=V-Lines, 4=Diagonal, 5=Crosshatch) + #[serde(default)] + pub pattern_type: u32, + /// Pattern stamp: tile size in pixels + #[serde(default = "default_pattern_scale")] + pub pattern_scale: f32, pub points: Vec, } +fn default_pattern_scale() -> f32 { 32.0 } + /// Specifies how the raster content transitions to the next keyframe #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum TweenType { diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 966505e..ce363a9 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -803,6 +803,9 @@ struct EditorApp { smudge_hardness: f32, smudge_spacing: f32, smudge_strength: f32, + /// Pattern stamp settings + pattern_type: u32, + pattern_scale: f32, /// GPU-rendered brush preview pixel buffers, shared with VelloCallback::prepare(). brush_preview_pixels: std::sync::Arc)>>>, // Audio engine integration @@ -1102,6 +1105,8 @@ impl EditorApp { smudge_hardness: 0.8, smudge_spacing: 8.0, smudge_strength: 1.0, + pattern_type: 0, + pattern_scale: 32.0, brush_preview_pixels: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())), audio_stream, audio_controller, @@ -5570,6 +5575,8 @@ impl eframe::App for EditorApp { smudge_hardness: &mut self.smudge_hardness, smudge_spacing: &mut self.smudge_spacing, smudge_strength: &mut self.smudge_strength, + pattern_type: &mut self.pattern_type, + pattern_scale: &mut self.pattern_scale, audio_controller: self.audio_controller.as_ref(), video_manager: &self.video_manager, playback_time: &mut self.playback_time, diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index d9c9ab5..1ab9283 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::HealingBrush + | Tool::Erase | Tool::Smudge | Tool::CloneStamp | Tool::HealingBrush | Tool::PatternStamp ); // Only show tool options for tools that have options @@ -196,6 +196,7 @@ impl InfopanelPane { Tool::Smudge => "Smudge", Tool::CloneStamp => "Clone Stamp", Tool::HealingBrush => "Healing Brush", + Tool::PatternStamp => "Pattern Stamp", _ => "Brush", } } else { @@ -330,6 +331,33 @@ impl InfopanelPane { self.render_raster_tool_options(ui, shared, matches!(tool, Tool::Erase)); } + Tool::PatternStamp if is_raster_paint_tool => { + const PATTERN_NAMES: &[&str] = &[ + "Checkerboard", "Dots", "H-Lines", "V-Lines", "Diagonal \\", "Diagonal /", "Crosshatch", + ]; + let selected_name = PATTERN_NAMES + .get(*shared.pattern_type as usize) + .copied() + .unwrap_or("Checkerboard"); + ui.horizontal(|ui| { + ui.label("Pattern:"); + egui::ComboBox::from_id_salt("pattern_type") + .selected_text(selected_name) + .show_ui(ui, |ui| { + for (i, name) in PATTERN_NAMES.iter().enumerate() { + ui.selectable_value(shared.pattern_type, i as u32, *name); + } + }); + }); + ui.horizontal(|ui| { + ui.label("Scale:"); + ui.add(egui::Slider::new(shared.pattern_scale, 4.0_f32..=256.0) + .logarithmic(true).suffix(" px")); + }); + ui.add_space(4.0); + self.render_raster_tool_options(ui, shared, false); + } + Tool::Smudge if is_raster_paint_tool => { ui.horizontal(|ui| { ui.label("Size:"); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index b248c96..1f58e71 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -208,6 +208,10 @@ pub struct SharedPaneState<'a> { pub smudge_hardness: &'a mut f32, pub smudge_spacing: &'a mut f32, pub smudge_strength: &'a mut f32, + /// Pattern stamp: selected procedural pattern type (0=Checkerboard..5=Crosshatch) + pub pattern_type: &'a mut u32, + /// Pattern stamp: tile size in pixels + pub pattern_scale: &'a mut f32, /// Audio engine controller for playback control (wrapped in Arc> for thread safety) pub audio_controller: Option<&'a std::sync::Arc>>, /// Video manager for video decoding and frame caching 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 7cb6710..9beb0bc 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,49 @@ 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 == 5u { + // Pattern stamp: procedural tiling pattern using brush color. + // ndx = pattern_type (0=Checker, 1=Dots, 2=H-Lines, 3=V-Lines, 4=Diagonal, 5=Crosshatch) + // ndy = pattern_scale (tile size in pixels, >= 1.0) + let scale = max(dab.ndy, 1.0); + let pt = u32(dab.ndx); + + // Fractional position within the tile [0.0, 1.0) + let tx = fract(f32(px) / scale); + let ty = fract(f32(py) / scale); + + var on: bool; + if pt == 0u { // Checkerboard + let cx = u32(floor(f32(px) / scale)); + let cy = u32(floor(f32(py) / scale)); + on = (cx + cy) % 2u == 0u; + } else if pt == 1u { // Polka dots (r ≈ 0.35 of cell radius) + let ddx = tx - 0.5; let ddy = ty - 0.5; + on = ddx * ddx + ddy * ddy < 0.1225; + } else if pt == 2u { // Horizontal lines (50% duty) + on = ty < 0.5; + } else if pt == 3u { // Vertical lines (50% duty) + on = tx < 0.5; + } else if pt == 4u { // Diagonal \ (top-left → bottom-right) + on = fract((f32(px) + f32(py)) / scale) < 0.5; + } else if pt == 5u { // Diagonal / (top-right → bottom-left) + on = fract((f32(px) - f32(py)) / scale) < 0.5; + } else { // Crosshatch (type 6+) + on = tx < 0.4 || ty < 0.4; + } + + if !on { return current; } + + // Paint with brush color — same compositing as Normal blend + let dab_a = opa_weight * dab.opacity * dab.color_a; + if dab_a <= 0.0 { return current; } + let ba = 1.0 - dab_a; + return vec4( + dab_a * dab.color_r + ba * current.r, + dab_a * dab.color_g + ba * current.g, + dab_a * dab.color_b + ba * current.b, + dab_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. diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 200f0de..7b0afa6 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -615,6 +615,8 @@ impl egui_wgpu::CallbackTrait for VelloCallback { color: [0.85f32, 0.88, 1.0, 1.0], blend_mode: RasterBlendMode::Normal, clone_src_offset: None, + pattern_type: 0, + pattern_scale: 32.0, 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 }, @@ -4974,6 +4976,8 @@ impl StagePane { color, blend_mode, clone_src_offset: self.clone_stroke_offset, + pattern_type: *shared.pattern_type, + pattern_scale: *shared.pattern_scale, points: vec![first_pt.clone()], }; let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, &mut stroke_state, 0.0); @@ -5063,6 +5067,8 @@ impl StagePane { color, blend_mode, clone_src_offset: self.clone_stroke_offset, + pattern_type: *shared.pattern_type, + pattern_scale: *shared.pattern_scale, points: vec![first_pt.clone()], }; let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, &mut stroke_state, 0.0); @@ -5148,6 +5154,8 @@ impl StagePane { color, blend_mode, clone_src_offset, + pattern_type: *shared.pattern_type, + pattern_scale: *shared.pattern_scale, points: vec![prev_pt, curr_local], }; let current_time = ui.input(|i| i.time); @@ -5210,6 +5218,8 @@ impl StagePane { color, blend_mode, clone_src_offset: self.clone_stroke_offset, + pattern_type: *shared.pattern_type, + pattern_scale: *shared.pattern_scale, points: vec![pt], }; let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, stroke_state, dt); @@ -7573,6 +7583,9 @@ impl StagePane { // 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::PatternStamp => { + self.handle_raster_stroke_tool(ui, &response, world_pos, lightningbeam_core::raster_layer::RasterBlendMode::PatternStamp, shared); + } Tool::SelectLasso => { self.handle_raster_lasso_tool(ui, &response, world_pos, shared); }