Add pattern stamp tool

This commit is contained in:
Skyler Lehmkuhl 2026-03-06 08:40:17 -05:00
parent 1d9d702a59
commit 7d55443b2a
7 changed files with 127 additions and 10 deletions

View File

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

View File

@ -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<StrokePoint>,
}
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 {

View File

@ -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<std::sync::Mutex<Vec<(u32, u32, Vec<u8>)>>>,
// 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,

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::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:");

View File

@ -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<Mutex<>> for thread safety)
pub audio_controller: Option<&'a std::sync::Arc<std::sync::Mutex<daw_backend::EngineController>>>,
/// Video manager for video decoding and frame caching

View File

@ -156,6 +156,49 @@ 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 == 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<f32>(
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.

View File

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