Refactor tools

This commit is contained in:
Skyler Lehmkuhl 2026-03-06 10:07:24 -05:00
parent 901aa04246
commit 922e8f78b6
16 changed files with 617 additions and 475 deletions

View File

@ -329,7 +329,7 @@ impl BrushEngine {
color_b: cb,
// 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 || blend_mode_u == 4 || blend_mode_u == 6 || blend_mode_u == 7 { 1.0 } else { stroke.color[3] },
color_a: if base_blend.uses_brush_color() { stroke.color[3] } else { 1.0 },
ndx, ndy, smudge_dist,
blend_mode: blend_mode_u,
elliptical_dab_ratio: bs.elliptical_dab_ratio.max(1.0),
@ -363,21 +363,15 @@ impl BrushEngine {
state, bs, pt.x, pt.y, base_r, pt.pressure, stroke.color,
);
if !matches!(base_blend, RasterBlendMode::Smudge) {
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, 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 if matches!(base_blend, RasterBlendMode::DodgeBurn) {
// color_r = mode (0=dodge, 1=burn); strength comes from opacity
(stroke.dodge_burn_mode as f32, 0.0, 0.0, 0.0, 0.0)
} else if matches!(base_blend, RasterBlendMode::Sponge) {
// color_r = mode (0=saturate, 1=desaturate); strength comes from opacity
(stroke.sponge_mode as f32, 0.0, 0.0, 0.0, 0.0)
} else {
(cr, cg, cb, 0.0, 0.0)
let tp = &stroke.tool_params;
let (cr2, cg2, cb2, ndx2, ndy2) = match base_blend {
RasterBlendMode::CloneStamp | RasterBlendMode::Healing =>
(tp[0], tp[1], 0.0, 0.0, 0.0),
RasterBlendMode::PatternStamp =>
(cr, cg, cb, tp[0], tp[1]),
RasterBlendMode::DodgeBurn | RasterBlendMode::Sponge =>
(tp[0], 0.0, 0.0, 0.0, 0.0),
_ => (cr, cg, cb, 0.0, 0.0),
};
push_dab(&mut dabs, &mut bbox, ex, ey, r, o, cr2, cg2, cb2,
ndx2, ndy2, 0.0);
@ -491,34 +485,20 @@ 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 | RasterBlendMode::Healing) {
// 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 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 if matches!(base_blend, RasterBlendMode::DodgeBurn) {
// color_r = mode (0=dodge, 1=burn)
push_dab(&mut dabs, &mut bbox,
ex, ey, radius2, opacity2,
stroke.dodge_burn_mode as f32, 0.0, 0.0,
0.0, 0.0, 0.0);
} else if matches!(base_blend, RasterBlendMode::Sponge) {
// color_r = mode (0=saturate, 1=desaturate)
push_dab(&mut dabs, &mut bbox,
ex, ey, radius2, opacity2,
stroke.sponge_mode as f32, 0.0, 0.0,
0.0, 0.0, 0.0);
} else {
let tp = &stroke.tool_params;
let (cr2, cg2, cb2, ndx2, ndy2) = match base_blend {
RasterBlendMode::CloneStamp | RasterBlendMode::Healing =>
(tp[0], tp[1], 0.0, 0.0, 0.0),
RasterBlendMode::PatternStamp =>
(cr, cg, cb, tp[0], tp[1]),
RasterBlendMode::DodgeBurn | RasterBlendMode::Sponge =>
(tp[0], 0.0, 0.0, 0.0, 0.0),
_ => (cr, cg, cb, 0.0, 0.0),
};
push_dab(&mut dabs, &mut bbox,
ex, ey, radius2, opacity2, cr, cg, cb,
0.0, 0.0, 0.0);
ex, ey, radius2, opacity2, cr2, cg2, cb2,
ndx2, ndy2, 0.0);
}
state.partial_dabs = 0.0;
@ -547,21 +527,15 @@ impl BrushEngine {
last_smooth_x, last_smooth_y,
base_r, last_pressure, stroke.color,
);
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, 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 if matches!(base_blend, RasterBlendMode::DodgeBurn) {
// color_r = mode (0=dodge, 1=burn)
(stroke.dodge_burn_mode as f32, 0.0, 0.0, 0.0, 0.0)
} else if matches!(base_blend, RasterBlendMode::Sponge) {
// color_r = mode (0=saturate, 1=desaturate)
(stroke.sponge_mode as f32, 0.0, 0.0, 0.0, 0.0)
} else {
(cr, cg, cb, 0.0, 0.0)
let tp = &stroke.tool_params;
let (cr2, cg2, cb2, ndx2, ndy2) = match base_blend {
RasterBlendMode::CloneStamp | RasterBlendMode::Healing =>
(tp[0], tp[1], 0.0, 0.0, 0.0),
RasterBlendMode::PatternStamp =>
(cr, cg, cb, tp[0], tp[1]),
RasterBlendMode::DodgeBurn | RasterBlendMode::Sponge =>
(tp[0], 0.0, 0.0, 0.0, 0.0),
_ => (cr, cg, cb, 0.0, 0.0),
};
push_dab(&mut dabs, &mut bbox, ex, ey, r, o, cr2, cg2, cb2,
ndx2, ndy2, 0.0);

View File

@ -36,6 +36,15 @@ impl Default for RasterBlendMode {
}
}
impl RasterBlendMode {
/// Returns false for blend modes that operate on existing pixels and don't
/// use the brush color at all (clone, heal, dodge/burn, sponge).
/// Used by brush_engine.rs to decide whether color_a should be 1.0 or stroke.color[3].
pub fn uses_brush_color(self) -> bool {
!matches!(self, Self::CloneStamp | Self::Healing | Self::DodgeBurn | Self::Sponge)
}
}
/// A single point along a stroke
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct StrokePoint {
@ -58,28 +67,16 @@ 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.
/// Generic tool parameters — encoding depends on blend_mode:
/// - CloneStamp / Healing: [offset_x, offset_y, 0, 0]
/// - PatternStamp: [pattern_type, pattern_scale, 0, 0]
/// - DodgeBurn / Sponge: [mode, 0, 0, 0]
/// - all others: [0, 0, 0, 0]
#[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,
/// Dodge/Burn mode: 0 = dodge (lighten), 1 = burn (darken)
#[serde(default)]
pub dodge_burn_mode: u32,
/// Sponge mode: 0 = saturate, 1 = desaturate
#[serde(default)]
pub sponge_mode: u32,
pub tool_params: [f32; 4],
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

@ -13,6 +13,8 @@ use uuid::Uuid;
mod panes;
use panes::{PaneInstance, PaneRenderer};
mod tools;
mod widgets;
mod menu;
@ -784,40 +786,8 @@ struct EditorApp {
draw_simplify_mode: lightningbeam_core::tool::SimplifyMode, // Current simplification mode for draw tool
rdp_tolerance: f64, // RDP simplification tolerance (default: 10.0)
schneider_max_error: f64, // Schneider curve fitting max error (default: 30.0)
// Raster brush settings
brush_radius: f32, // brush radius in pixels
brush_opacity: f32, // brush opacity 0.01.0
brush_hardness: f32, // brush hardness 0.01.0
brush_spacing: f32, // dabs_per_radius (fraction of radius per dab)
brush_use_fg: bool, // true = paint with FG (stroke) color, false = BG (fill) color
/// Full brush settings for the currently active paint preset (carries elliptical, jitter, etc.)
active_brush_settings: lightningbeam_core::brush_settings::BrushSettings,
/// Eraser tool brush settings (separate from paint brush, defaults to "Brush" preset)
eraser_radius: f32,
eraser_opacity: f32,
eraser_hardness: f32,
eraser_spacing: f32,
active_eraser_settings: lightningbeam_core::brush_settings::BrushSettings,
/// Smudge tool settings (no preset picker)
smudge_radius: f32,
smudge_hardness: f32,
smudge_spacing: f32,
smudge_strength: f32,
/// Pattern stamp settings
pattern_type: u32,
pattern_scale: f32,
/// Dodge/Burn tool settings
dodge_burn_radius: f32,
dodge_burn_hardness: f32,
dodge_burn_spacing: f32,
dodge_burn_exposure: f32,
dodge_burn_mode: u32,
/// Sponge tool settings
sponge_radius: f32,
sponge_hardness: f32,
sponge_spacing: f32,
sponge_flow: f32,
sponge_mode: u32,
/// All per-tool raster paint settings (brush, eraser, smudge, clone, pattern, dodge/burn, sponge).
raster_settings: tools::RasterToolSettings,
/// 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
@ -1098,37 +1068,7 @@ impl EditorApp {
draw_simplify_mode: lightningbeam_core::tool::SimplifyMode::Smooth, // Default to smooth curves
rdp_tolerance: 10.0, // Default RDP tolerance
schneider_max_error: 30.0, // Default Schneider max error
brush_radius: 10.0,
brush_opacity: 1.0,
brush_hardness: 0.5,
brush_spacing: 0.1,
brush_use_fg: true,
active_brush_settings: lightningbeam_core::brush_settings::BrushSettings::default(),
eraser_radius: 10.0,
eraser_opacity: 1.0,
eraser_hardness: 0.5,
eraser_spacing: 0.1,
active_eraser_settings: lightningbeam_core::brush_settings::bundled_brushes()
.iter()
.find(|p| p.name == "Brush")
.map(|p| p.settings.clone())
.unwrap_or_default(),
smudge_radius: 15.0,
smudge_hardness: 0.8,
smudge_spacing: 8.0,
smudge_strength: 1.0,
pattern_type: 0,
pattern_scale: 32.0,
dodge_burn_radius: 30.0,
dodge_burn_hardness: 0.5,
dodge_burn_spacing: 3.0,
dodge_burn_exposure: 0.5,
dodge_burn_mode: 0,
sponge_radius: 30.0,
sponge_hardness: 0.5,
sponge_spacing: 3.0,
sponge_flow: 0.5,
sponge_mode: 0,
raster_settings: tools::RasterToolSettings::default(),
brush_preview_pixels: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
audio_stream,
audio_controller,
@ -5582,33 +5522,7 @@ impl eframe::App for EditorApp {
draw_simplify_mode: &mut self.draw_simplify_mode,
rdp_tolerance: &mut self.rdp_tolerance,
schneider_max_error: &mut self.schneider_max_error,
brush_radius: &mut self.brush_radius,
brush_opacity: &mut self.brush_opacity,
brush_hardness: &mut self.brush_hardness,
brush_spacing: &mut self.brush_spacing,
brush_use_fg: &mut self.brush_use_fg,
active_brush_settings: &mut self.active_brush_settings,
eraser_radius: &mut self.eraser_radius,
eraser_opacity: &mut self.eraser_opacity,
eraser_hardness: &mut self.eraser_hardness,
eraser_spacing: &mut self.eraser_spacing,
active_eraser_settings: &mut self.active_eraser_settings,
smudge_radius: &mut self.smudge_radius,
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,
dodge_burn_radius: &mut self.dodge_burn_radius,
dodge_burn_hardness: &mut self.dodge_burn_hardness,
dodge_burn_spacing: &mut self.dodge_burn_spacing,
dodge_burn_exposure: &mut self.dodge_burn_exposure,
dodge_burn_mode: &mut self.dodge_burn_mode,
sponge_radius: &mut self.sponge_radius,
sponge_hardness: &mut self.sponge_hardness,
sponge_spacing: &mut self.sponge_spacing,
sponge_flow: &mut self.sponge_flow,
sponge_mode: &mut self.sponge_mode,
raster_settings: &mut self.raster_settings,
audio_controller: self.audio_controller.as_ref(),
video_manager: &self.video_manager,
playback_time: &mut self.playback_time,

View File

@ -169,12 +169,8 @@ impl InfopanelPane {
.and_then(|id| shared.action_executor.document().get_layer(&id))
.map_or(false, |l| matches!(l, AnyLayer::Raster(_)));
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::PatternStamp
| Tool::DodgeBurn | Tool::Sponge
);
let raster_tool_def = active_is_raster.then(|| crate::tools::raster_tool_def(&tool)).flatten();
let is_raster_paint_tool = raster_tool_def.is_some();
// Only show tool options for tools that have options
let is_vector_tool = !active_is_raster && matches!(
@ -191,20 +187,9 @@ impl InfopanelPane {
return;
}
let header_label = if is_raster_paint_tool {
match tool {
Tool::Erase => "Eraser",
Tool::Smudge => "Smudge",
Tool::CloneStamp => "Clone Stamp",
Tool::HealingBrush => "Healing Brush",
Tool::PatternStamp => "Pattern Stamp",
Tool::DodgeBurn => "Dodge / Burn",
Tool::Sponge => "Sponge",
_ => "Brush",
}
} else {
"Tool Options"
};
let header_label = raster_tool_def
.map(|d| d.header_label())
.unwrap_or("Tool Options");
egui::CollapsingHeader::new(header_label)
.id_salt(("tool_options", path))
@ -218,6 +203,14 @@ impl InfopanelPane {
ui.add_space(2.0);
}
// Raster paint tool: delegate to per-tool impl.
if let Some(def) = raster_tool_def {
def.render_ui(ui, shared.raster_settings);
if def.show_brush_preset_picker() {
self.render_raster_tool_options(ui, shared, def.is_eraser());
}
}
match tool {
Tool::Draw if !is_raster_paint_tool => {
// Stroke width
@ -328,124 +321,6 @@ impl InfopanelPane {
});
}
// Raster paint tools
Tool::Draw | Tool::Pencil | Tool::Pen | Tool::Airbrush
| Tool::Erase | Tool::CloneStamp | Tool::HealingBrush if is_raster_paint_tool => {
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::DodgeBurn if is_raster_paint_tool => {
ui.horizontal(|ui| {
if ui.selectable_label(*shared.dodge_burn_mode == 0, "Dodge").clicked() {
*shared.dodge_burn_mode = 0;
}
if ui.selectable_label(*shared.dodge_burn_mode == 1, "Burn").clicked() {
*shared.dodge_burn_mode = 1;
}
});
ui.horizontal(|ui| {
ui.label("Size:");
ui.add(egui::Slider::new(shared.dodge_burn_radius, 1.0_f32..=500.0).logarithmic(true).suffix(" px"));
});
ui.horizontal(|ui| {
ui.label("Exposure:");
ui.add(egui::Slider::new(shared.dodge_burn_exposure, 0.0_f32..=1.0)
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
});
ui.horizontal(|ui| {
ui.label("Hardness:");
ui.add(egui::Slider::new(shared.dodge_burn_hardness, 0.0_f32..=1.0)
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
});
ui.horizontal(|ui| {
ui.label("Spacing:");
ui.add(egui::Slider::new(shared.dodge_burn_spacing, 0.5_f32..=20.0)
.logarithmic(true)
.custom_formatter(|v, _| format!("{:.1}", v)));
});
}
Tool::Sponge if is_raster_paint_tool => {
ui.horizontal(|ui| {
if ui.selectable_label(*shared.sponge_mode == 0, "Saturate").clicked() {
*shared.sponge_mode = 0;
}
if ui.selectable_label(*shared.sponge_mode == 1, "Desaturate").clicked() {
*shared.sponge_mode = 1;
}
});
ui.horizontal(|ui| {
ui.label("Size:");
ui.add(egui::Slider::new(shared.sponge_radius, 1.0_f32..=500.0).logarithmic(true).suffix(" px"));
});
ui.horizontal(|ui| {
ui.label("Flow:");
ui.add(egui::Slider::new(shared.sponge_flow, 0.0_f32..=1.0)
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
});
ui.horizontal(|ui| {
ui.label("Hardness:");
ui.add(egui::Slider::new(shared.sponge_hardness, 0.0_f32..=1.0)
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
});
ui.horizontal(|ui| {
ui.label("Spacing:");
ui.add(egui::Slider::new(shared.sponge_spacing, 0.5_f32..=20.0)
.logarithmic(true)
.custom_formatter(|v, _| format!("{:.1}", v)));
});
}
Tool::Smudge if is_raster_paint_tool => {
ui.horizontal(|ui| {
ui.label("Size:");
ui.add(egui::Slider::new(shared.smudge_radius, 1.0_f32..=200.0).logarithmic(true).suffix(" px"));
});
ui.horizontal(|ui| {
ui.label("Strength:");
ui.add(egui::Slider::new(shared.smudge_strength, 0.0_f32..=1.0)
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
});
ui.horizontal(|ui| {
ui.label("Hardness:");
ui.add(egui::Slider::new(shared.smudge_hardness, 0.0_f32..=1.0)
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
});
ui.horizontal(|ui| {
ui.label("Spacing:");
ui.add(egui::Slider::new(shared.smudge_spacing, 0.5_f32..=20.0)
.logarithmic(true)
.custom_formatter(|v, _| format!("{:.1}", v)));
});
}
_ => {}
}
@ -464,17 +339,19 @@ impl InfopanelPane {
self.render_brush_preset_grid(ui, shared, is_eraser);
ui.add_space(2.0);
let rs = &mut shared.raster_settings;
if !is_eraser {
ui.horizontal(|ui| {
ui.label("Color:");
ui.selectable_value(shared.brush_use_fg, true, "FG");
ui.selectable_value(shared.brush_use_fg, false, "BG");
ui.selectable_value(&mut rs.brush_use_fg, true, "FG");
ui.selectable_value(&mut rs.brush_use_fg, false, "BG");
});
}
macro_rules! field {
($eraser:ident, $brush:ident) => {
if is_eraser { &mut *shared.$eraser } else { &mut *shared.$brush }
if is_eraser { &mut rs.$eraser } else { &mut rs.$brush }
}
}
@ -603,16 +480,17 @@ impl InfopanelPane {
selected = Some(idx);
expanded = false;
let s = &preset.settings;
let rs = &mut shared.raster_settings;
if is_eraser {
*shared.eraser_opacity = s.opaque.clamp(0.0, 1.0);
*shared.eraser_hardness = s.hardness.clamp(0.0, 1.0);
*shared.eraser_spacing = s.dabs_per_radius;
*shared.active_eraser_settings = s.clone();
rs.eraser_opacity = s.opaque.clamp(0.0, 1.0);
rs.eraser_hardness = s.hardness.clamp(0.0, 1.0);
rs.eraser_spacing = s.dabs_per_radius;
rs.active_eraser_settings = s.clone();
} else {
*shared.brush_opacity = s.opaque.clamp(0.0, 1.0);
*shared.brush_hardness = s.hardness.clamp(0.0, 1.0);
*shared.brush_spacing = s.dabs_per_radius;
*shared.active_brush_settings = s.clone();
rs.brush_opacity = s.opaque.clamp(0.0, 1.0);
rs.brush_hardness = s.hardness.clamp(0.0, 1.0);
rs.brush_spacing = s.dabs_per_radius;
rs.active_brush_settings = s.clone();
// If the user was on a preset-backed tool (Pencil/Pen/Airbrush)
// and manually picked a different brush, revert to the generic tool.
if matches!(*shared.selected_tool, Tool::Pencil | Tool::Pen | Tool::Airbrush) {

View File

@ -187,45 +187,8 @@ pub struct SharedPaneState<'a> {
pub draw_simplify_mode: &'a mut lightningbeam_core::tool::SimplifyMode,
pub rdp_tolerance: &'a mut f64,
pub schneider_max_error: &'a mut f64,
/// Raster paint brush settings
pub brush_radius: &'a mut f32,
pub brush_opacity: &'a mut f32,
pub brush_hardness: &'a mut f32,
pub brush_spacing: &'a mut f32,
/// Whether the brush paints with the foreground (fill) color (true) or background (stroke) color (false)
pub brush_use_fg: &'a mut bool,
/// Full brush settings for the active paint preset (carries elliptical, jitter, slow_tracking, etc.)
pub active_brush_settings: &'a mut lightningbeam_core::brush_settings::BrushSettings,
/// Raster eraser brush settings (separate from paint brush)
pub eraser_radius: &'a mut f32,
pub eraser_opacity: &'a mut f32,
pub eraser_hardness: &'a mut f32,
pub eraser_spacing: &'a mut f32,
/// Full brush settings for the active eraser preset
pub active_eraser_settings: &'a mut lightningbeam_core::brush_settings::BrushSettings,
/// Raster smudge tool settings (no preset picker)
pub smudge_radius: &'a mut f32,
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,
/// Dodge/Burn tool settings
pub dodge_burn_radius: &'a mut f32,
pub dodge_burn_hardness: &'a mut f32,
pub dodge_burn_spacing: &'a mut f32,
pub dodge_burn_exposure: &'a mut f32,
/// 0 = dodge (lighten), 1 = burn (darken)
pub dodge_burn_mode: &'a mut u32,
/// Sponge tool settings
pub sponge_radius: &'a mut f32,
pub sponge_hardness: &'a mut f32,
pub sponge_spacing: &'a mut f32,
pub sponge_flow: &'a mut f32,
/// 0 = saturate, 1 = desaturate
pub sponge_mode: &'a mut u32,
/// All per-tool raster paint settings (replaces 20+ individual fields).
pub raster_settings: &'a mut crate::tools::RasterToolSettings,
/// 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

@ -614,11 +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,
pattern_type: 0,
pattern_scale: 32.0,
dodge_burn_mode: 0,
sponge_mode: 0,
tool_params: [0.0; 4],
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 },
@ -2487,8 +2483,6 @@ 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)>,
@ -2616,7 +2610,6 @@ 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,
@ -4841,6 +4834,27 @@ impl StagePane {
/// `self.pending_raster_dabs` for dispatch by `VelloCallback::prepare()`.
///
/// The actual pixel rendering happens on the GPU (compute shader). The CPU
/// Build the `tool_params: [f32; 4]` for a StrokeRecord.
/// For clone/healing: [offset_x, offset_y, 0, 0] (computed from clone_stroke_offset).
/// For all other tools: delegates to def.tool_params().
fn make_tool_params(
&self,
def: &dyn crate::tools::RasterToolDef,
shared: &SharedPaneState,
) -> [f32; 4] {
use lightningbeam_core::raster_layer::RasterBlendMode;
match def.blend_mode() {
RasterBlendMode::CloneStamp | RasterBlendMode::Healing => {
if let Some((ox, oy)) = self.clone_stroke_offset {
[ox, oy, 0.0, 0.0]
} else {
[0.0; 4]
}
}
_ => def.tool_params(shared.raster_settings),
}
}
/// only does dab placement arithmetic (cheap). On stroke end a readback is
/// requested so the undo system can capture the final pixel state.
fn handle_raster_stroke_tool(
@ -4848,9 +4862,10 @@ impl StagePane {
ui: &mut egui::Ui,
response: &egui::Response,
world_pos: egui::Vec2,
blend_mode: lightningbeam_core::raster_layer::RasterBlendMode,
def: &'static dyn crate::tools::RasterToolDef,
shared: &mut SharedPaneState,
) {
let blend_mode = def.blend_mode();
use lightningbeam_core::tool::ToolState;
use lightningbeam_core::layer::AnyLayer;
use lightningbeam_core::raster_layer::StrokePoint;
@ -4869,46 +4884,10 @@ impl StagePane {
if !is_raster { return; }
let brush = {
// Start from the active preset for this tool, then override the
// user-controlled slider values.
use lightningbeam_core::raster_layer::RasterBlendMode;
let (base_settings, radius, opacity, hardness, spacing) = match blend_mode {
RasterBlendMode::Erase => (
shared.active_eraser_settings.clone(),
*shared.eraser_radius,
*shared.eraser_opacity,
*shared.eraser_hardness,
*shared.eraser_spacing,
),
RasterBlendMode::Smudge => (
lightningbeam_core::brush_settings::BrushSettings::default(),
*shared.smudge_radius,
1.0, // opacity fixed at 1.0; strength is a separate smudge_dist multiplier
*shared.smudge_hardness,
*shared.smudge_spacing,
),
RasterBlendMode::DodgeBurn => (
lightningbeam_core::brush_settings::BrushSettings::default(),
*shared.dodge_burn_radius,
*shared.dodge_burn_exposure,
*shared.dodge_burn_hardness,
*shared.dodge_burn_spacing,
),
RasterBlendMode::Sponge => (
lightningbeam_core::brush_settings::BrushSettings::default(),
*shared.sponge_radius,
*shared.sponge_flow,
*shared.sponge_hardness,
*shared.sponge_spacing,
),
_ => (
shared.active_brush_settings.clone(),
*shared.brush_radius,
*shared.brush_opacity,
*shared.brush_hardness,
*shared.brush_spacing,
),
};
// Delegate brush parameter extraction to the tool definition.
let bp = def.brush_params(shared.raster_settings);
let (base_settings, radius, opacity, hardness, spacing) =
(bp.base_settings, bp.radius, bp.opacity, bp.hardness, bp.spacing);
let mut b = base_settings;
// Compensate for pressure_radius_gain so that the UI-chosen radius is the
// actual rendered radius at our fixed mouse pressure of 1.0.
@ -4918,12 +4897,12 @@ impl StagePane {
b.hardness = hardness;
b.opaque = opacity;
b.dabs_per_radius = spacing;
if matches!(blend_mode, RasterBlendMode::Smudge) {
if matches!(blend_mode, lightningbeam_core::raster_layer::RasterBlendMode::Smudge) {
// Zero dabs_per_actual_radius so the spacing slider is the sole density control.
b.dabs_per_actual_radius = 0.0;
// strength controls how far behind the stroke to sample (smudge_dist multiplier).
// smudge_dist = radius * exp(smudge_radius_log), so log(strength) gives the ratio.
b.smudge_radius_log = *shared.smudge_strength; // linear [0,1] strength
b.smudge_radius_log = shared.raster_settings.smudge_strength; // linear [0,1] strength
}
b
};
@ -4931,7 +4910,7 @@ impl StagePane {
let color = if matches!(blend_mode, lightningbeam_core::raster_layer::RasterBlendMode::Erase) {
[1.0f32, 1.0, 1.0, 1.0]
} else {
let c = if *shared.brush_use_fg { *shared.stroke_color } else { *shared.fill_color };
let c = if shared.raster_settings.brush_use_fg { *shared.stroke_color } else { *shared.fill_color };
let s2l = |v: u8| -> f32 {
let f = v as f32 / 255.0;
if f <= 0.04045 { f / 12.92 } else { ((f + 0.055) / 1.055).powf(2.4) }
@ -4956,7 +4935,7 @@ impl StagePane {
// This is constant for the entire stroke and used in every StrokeRecord below.
if matches!(blend_mode, lightningbeam_core::raster_layer::RasterBlendMode::CloneStamp
| lightningbeam_core::raster_layer::RasterBlendMode::Healing) {
self.clone_stroke_offset = self.clone_source.map(|s| (
self.clone_stroke_offset = shared.raster_settings.clone_source.map(|s| (
s.x - world_pos.x, s.y - world_pos.y,
));
} else {
@ -4991,11 +4970,7 @@ impl StagePane {
brush_settings: brush.clone(),
color,
blend_mode,
clone_src_offset: self.clone_stroke_offset,
pattern_type: *shared.pattern_type,
pattern_scale: *shared.pattern_scale,
dodge_burn_mode: *shared.dodge_burn_mode,
sponge_mode: *shared.sponge_mode,
tool_params: self.make_tool_params(def, shared),
points: vec![first_pt.clone()],
};
let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, &mut stroke_state, 0.0);
@ -5084,11 +5059,7 @@ impl StagePane {
brush_settings: brush.clone(),
color,
blend_mode,
clone_src_offset: self.clone_stroke_offset,
pattern_type: *shared.pattern_type,
pattern_scale: *shared.pattern_scale,
dodge_burn_mode: *shared.dodge_burn_mode,
sponge_mode: *shared.sponge_mode,
tool_params: self.make_tool_params(def, shared),
points: vec![first_pt.clone()],
};
let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, &mut stroke_state, 0.0);
@ -5130,6 +5101,7 @@ impl StagePane {
// Mouse drag: compute dabs for this segment
// ----------------------------------------------------------------
if self.rsp_dragged(response) {
let tool_params = self.make_tool_params(def, shared);
if let Some((layer_id, time, ref mut stroke_state, _)) = self.raster_stroke_state {
if let Some(prev_pt) = self.raster_last_point.take() {
// Get canvas info and float offset now (used for both distance check
@ -5168,16 +5140,11 @@ 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,
pattern_type: *shared.pattern_type,
pattern_scale: *shared.pattern_scale,
dodge_burn_mode: *shared.dodge_burn_mode,
sponge_mode: *shared.sponge_mode,
tool_params,
points: vec![prev_pt, curr_local],
};
let current_time = ui.input(|i| i.time);
@ -5214,6 +5181,7 @@ impl StagePane {
if self.raster_last_compute_time > 0.0 {
let dt = (current_time - self.raster_last_compute_time).clamp(0.0, 0.1) as f32;
self.raster_last_compute_time = current_time;
let tool_params = self.make_tool_params(def, shared);
if let Some((layer_id, time, ref mut stroke_state, _)) = self.raster_stroke_state {
let canvas_info = if self.painting_float {
@ -5239,11 +5207,7 @@ impl StagePane {
brush_settings: brush.clone(),
color,
blend_mode,
clone_src_offset: self.clone_stroke_offset,
pattern_type: *shared.pattern_type,
pattern_scale: *shared.pattern_scale,
dodge_burn_mode: *shared.dodge_burn_mode,
sponge_mode: *shared.sponge_mode,
tool_params,
points: vec![pt],
};
let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, stroke_state, dt);
@ -7541,16 +7505,18 @@ impl StagePane {
});
}
// Clone stamp / healing brush: Alt+click sets the source point regardless of the alt-pan guard below.
// Alt+click: set source point for clone/healing tools.
{
use lightningbeam_core::tool::Tool;
if matches!(*shared.selected_tool, Tool::CloneStamp | Tool::HealingBrush)
let tool_uses_alt = crate::tools::raster_tool_def(shared.selected_tool)
.map_or(false, |d| d.uses_alt_click());
if tool_uses_alt
&& alt_held
&& self.rsp_primary_pressed(ui)
&& response.hovered()
{
eprintln!("[clone/healing] set clone source to ({:.1}, {:.1})", world_pos.x, world_pos.y);
self.clone_source = Some(world_pos);
shared.raster_settings.clone_source = Some(world_pos);
}
}
@ -7584,37 +7550,14 @@ impl StagePane {
shared.action_executor.document().get_layer(&id)
}).map_or(false, |l| matches!(l, lightningbeam_core::layer::AnyLayer::Raster(_)));
if is_raster {
self.handle_raster_stroke_tool(ui, &response, world_pos, lightningbeam_core::raster_layer::RasterBlendMode::Normal, shared);
self.handle_raster_stroke_tool(ui, &response, world_pos, &crate::tools::paint::PAINT, shared);
} else {
self.handle_draw_tool(ui, &response, world_pos, shared);
}
}
Tool::Pencil | Tool::Pen | Tool::Airbrush => {
self.handle_raster_stroke_tool(ui, &response, world_pos, lightningbeam_core::raster_layer::RasterBlendMode::Normal, shared);
}
Tool::Erase => {
self.handle_raster_stroke_tool(ui, &response, world_pos, lightningbeam_core::raster_layer::RasterBlendMode::Erase, shared);
}
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::HealingBrush => {
// 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::DodgeBurn => {
self.handle_raster_stroke_tool(ui, &response, world_pos, lightningbeam_core::raster_layer::RasterBlendMode::DodgeBurn, shared);
}
Tool::Sponge => {
self.handle_raster_stroke_tool(ui, &response, world_pos, lightningbeam_core::raster_layer::RasterBlendMode::Sponge, shared);
tool if crate::tools::raster_tool_def(&tool).is_some() => {
let def = crate::tools::raster_tool_def(&tool).unwrap();
self.handle_raster_stroke_tool(ui, &response, world_pos, def, shared);
}
Tool::SelectLasso => {
self.handle_raster_lasso_tool(ui, &response, world_pos, shared);
@ -8092,20 +8035,25 @@ impl StagePane {
use lightningbeam_core::tool::Tool;
// Compute semi-axes (world pixels) and dab rotation angle.
let (a_world, b_world, dab_angle_rad) = match *shared.selected_tool {
Tool::Erase => (*shared.eraser_radius, *shared.eraser_radius, 0.0_f32),
Tool::Smudge
| Tool::BlurSharpen => (*shared.smudge_radius, *shared.smudge_radius, 0.0_f32),
Tool::DodgeBurn => (*shared.dodge_burn_radius, *shared.dodge_burn_radius, 0.0_f32),
Tool::Sponge => (*shared.sponge_radius, *shared.sponge_radius, 0.0_f32),
_ => {
let bs = &shared.active_brush_settings;
let r = *shared.brush_radius;
let (a_world, b_world, dab_angle_rad) = if let Some(def) = crate::tools::raster_tool_def(shared.selected_tool) {
let r = def.cursor_radius(shared.raster_settings);
// For the standard paint brush, also account for elliptical shape.
if matches!(*shared.selected_tool,
Tool::Draw | Tool::Pencil | Tool::Pen | Tool::Airbrush)
{
let bs = &shared.raster_settings.active_brush_settings;
let ratio = bs.elliptical_dab_ratio.max(1.0);
// Expand radius to cover the full jitter extent.
let expand = 1.0 + bs.offset_by_random;
(r * expand, r * expand / ratio, bs.elliptical_dab_angle.to_radians())
} else {
(r, r, 0.0_f32)
}
} else {
let bs = &shared.raster_settings.active_brush_settings;
let r = shared.raster_settings.brush_radius;
let ratio = bs.elliptical_dab_ratio.max(1.0);
let expand = 1.0 + bs.offset_by_random;
(r * expand, r * expand / ratio, bs.elliptical_dab_angle.to_radians())
};
let a = a_world * self.zoom; // major semi-axis in screen pixels
@ -8726,8 +8674,10 @@ impl PaneRenderer for StagePane {
}
// Draw clone source indicator when clone stamp or healing brush tool is selected.
if matches!(*shared.selected_tool, lightningbeam_core::tool::Tool::CloneStamp | lightningbeam_core::tool::Tool::HealingBrush) {
if let Some(src_world) = self.clone_source {
let tool_uses_alt = crate::tools::raster_tool_def(shared.selected_tool)
.map_or(false, |d| d.uses_alt_click());
if tool_uses_alt {
if let Some(src_world) = shared.raster_settings.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,

View File

@ -136,10 +136,10 @@ impl PaneRenderer for ToolbarPane {
if let Some(name) = preset_name {
if let Some(preset) = bundled_brushes().iter().find(|p| p.name == name) {
let s = &preset.settings;
*shared.brush_opacity = s.opaque.clamp(0.0, 1.0);
*shared.brush_hardness = s.hardness.clamp(0.0, 1.0);
*shared.brush_spacing = s.dabs_per_radius;
*shared.active_brush_settings = s.clone();
shared.raster_settings.brush_opacity = s.opaque.clamp(0.0, 1.0);
shared.raster_settings.brush_hardness = s.hardness.clamp(0.0, 1.0);
shared.raster_settings.brush_spacing = s.dabs_per_radius;
shared.raster_settings.active_brush_settings = s.clone();
}
}
}

View File

@ -0,0 +1,29 @@
use super::{BrushParams, RasterToolDef, RasterToolSettings};
use eframe::egui;
use lightningbeam_core::raster_layer::RasterBlendMode;
pub struct CloneStampTool;
pub static CLONE_STAMP: CloneStampTool = CloneStampTool;
impl RasterToolDef for CloneStampTool {
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::CloneStamp }
fn header_label(&self) -> &'static str { "Clone Stamp" }
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams {
BrushParams {
base_settings: s.active_brush_settings.clone(),
radius: s.brush_radius,
opacity: s.brush_opacity,
hardness: s.brush_hardness,
spacing: s.brush_spacing,
}
}
/// For Clone Stamp, tool_params are filled by stage.rs at stroke-start time
/// (offset = clone_source - stroke_start), not from settings directly.
fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] }
fn uses_alt_click(&self) -> bool { true }
fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) {
if s.clone_source.is_none() {
ui.label("Alt+click to set source point.");
}
}
}

View File

@ -0,0 +1,54 @@
use super::{BrushParams, RasterToolDef, RasterToolSettings};
use eframe::egui;
use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode};
pub struct DodgeBurnTool;
pub static DODGE_BURN: DodgeBurnTool = DodgeBurnTool;
impl RasterToolDef for DodgeBurnTool {
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::DodgeBurn }
fn header_label(&self) -> &'static str { "Dodge / Burn" }
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams {
BrushParams {
base_settings: BrushSettings::default(),
radius: s.dodge_burn_radius,
opacity: s.dodge_burn_exposure,
hardness: s.dodge_burn_hardness,
spacing: s.dodge_burn_spacing,
}
}
fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] {
[s.dodge_burn_mode as f32, 0.0, 0.0, 0.0]
}
fn show_brush_preset_picker(&self) -> bool { false }
fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) {
ui.horizontal(|ui| {
if ui.selectable_label(s.dodge_burn_mode == 0, "Dodge").clicked() {
s.dodge_burn_mode = 0;
}
if ui.selectable_label(s.dodge_burn_mode == 1, "Burn").clicked() {
s.dodge_burn_mode = 1;
}
});
ui.horizontal(|ui| {
ui.label("Size:");
ui.add(egui::Slider::new(&mut s.dodge_burn_radius, 1.0_f32..=500.0).logarithmic(true).suffix(" px"));
});
ui.horizontal(|ui| {
ui.label("Exposure:");
ui.add(egui::Slider::new(&mut s.dodge_burn_exposure, 0.0_f32..=1.0)
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
});
ui.horizontal(|ui| {
ui.label("Hardness:");
ui.add(egui::Slider::new(&mut s.dodge_burn_hardness, 0.0_f32..=1.0)
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
});
ui.horizontal(|ui| {
ui.label("Spacing:");
ui.add(egui::Slider::new(&mut s.dodge_burn_spacing, 0.5_f32..=20.0)
.logarithmic(true)
.custom_formatter(|v, _| format!("{:.1}", v)));
});
}
}

View File

@ -0,0 +1,23 @@
use super::{BrushParams, RasterToolDef, RasterToolSettings};
use eframe::egui;
use lightningbeam_core::raster_layer::RasterBlendMode;
pub struct EraseTool;
pub static ERASE: EraseTool = EraseTool;
impl RasterToolDef for EraseTool {
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Erase }
fn header_label(&self) -> &'static str { "Eraser" }
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams {
BrushParams {
base_settings: s.active_eraser_settings.clone(),
radius: s.eraser_radius,
opacity: s.eraser_opacity,
hardness: s.eraser_hardness,
spacing: s.eraser_spacing,
}
}
fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] }
fn is_eraser(&self) -> bool { true }
fn render_ui(&self, _ui: &mut egui::Ui, _s: &mut RasterToolSettings) {}
}

View File

@ -0,0 +1,28 @@
use super::{BrushParams, RasterToolDef, RasterToolSettings};
use eframe::egui;
use lightningbeam_core::raster_layer::RasterBlendMode;
pub struct HealingBrushTool;
pub static HEALING_BRUSH: HealingBrushTool = HealingBrushTool;
impl RasterToolDef for HealingBrushTool {
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Healing }
fn header_label(&self) -> &'static str { "Healing Brush" }
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams {
BrushParams {
base_settings: s.active_brush_settings.clone(),
radius: s.brush_radius,
opacity: s.brush_opacity,
hardness: s.brush_hardness,
spacing: s.brush_spacing,
}
}
/// tool_params are filled by stage.rs at stroke-start time (clone offset).
fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] }
fn uses_alt_click(&self) -> bool { true }
fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) {
if s.clone_source.is_none() {
ui.label("Alt+click to set source point.");
}
}
}

View File

@ -0,0 +1,163 @@
/// Per-tool module for raster painting tools.
///
/// Each tool implements `RasterToolDef`. Adding a new tool requires:
/// 1. A new file in this directory implementing `RasterToolDef`.
/// 2. One entry in `raster_tool_def()` below.
/// 3. Core changes: `RasterBlendMode` variant, `brush_engine.rs` constant, WGSL branch.
use eframe::egui;
use lightningbeam_core::{
brush_settings::BrushSettings,
raster_layer::RasterBlendMode,
tool::Tool,
};
pub mod paint;
pub mod erase;
pub mod smudge;
pub mod clone_stamp;
pub mod healing_brush;
pub mod pattern_stamp;
pub mod dodge_burn;
pub mod sponge;
// ---------------------------------------------------------------------------
// Shared settings struct (replaces 20+ individual SharedPaneState / EditorApp fields)
// ---------------------------------------------------------------------------
/// All per-tool settings for raster painting. Owned by `EditorApp`; borrowed
/// by `SharedPaneState` as a single `&'a mut RasterToolSettings`.
pub struct RasterToolSettings {
// --- Paint brush ---
pub brush_radius: f32,
pub brush_opacity: f32,
pub brush_hardness: f32,
pub brush_spacing: f32,
/// true = paint with FG (stroke) color, false = BG (fill) color
pub brush_use_fg: bool,
pub active_brush_settings: BrushSettings,
// --- Eraser ---
pub eraser_radius: f32,
pub eraser_opacity: f32,
pub eraser_hardness: f32,
pub eraser_spacing: f32,
pub active_eraser_settings: BrushSettings,
// --- Smudge ---
pub smudge_radius: f32,
pub smudge_hardness: f32,
pub smudge_spacing: f32,
pub smudge_strength: f32,
// --- Clone / Healing ---
/// World-space source point set by Alt+click.
pub clone_source: Option<egui::Vec2>,
// --- Pattern stamp ---
pub pattern_type: u32,
pub pattern_scale: f32,
// --- Dodge / Burn ---
pub dodge_burn_radius: f32,
pub dodge_burn_hardness: f32,
pub dodge_burn_spacing: f32,
pub dodge_burn_exposure: f32,
/// 0 = dodge (lighten), 1 = burn (darken)
pub dodge_burn_mode: u32,
// --- Sponge ---
pub sponge_radius: f32,
pub sponge_hardness: f32,
pub sponge_spacing: f32,
pub sponge_flow: f32,
/// 0 = saturate, 1 = desaturate
pub sponge_mode: u32,
}
impl Default for RasterToolSettings {
fn default() -> Self {
Self {
brush_radius: 10.0,
brush_opacity: 1.0,
brush_hardness: 0.5,
brush_spacing: 0.1,
brush_use_fg: true,
active_brush_settings: BrushSettings::default(),
eraser_radius: 10.0,
eraser_opacity: 1.0,
eraser_hardness: 0.5,
eraser_spacing: 0.1,
active_eraser_settings: lightningbeam_core::brush_settings::bundled_brushes()
.iter()
.find(|p| p.name == "Brush")
.map(|p| p.settings.clone())
.unwrap_or_default(),
smudge_radius: 15.0,
smudge_hardness: 0.8,
smudge_spacing: 8.0,
smudge_strength: 1.0,
clone_source: None,
pattern_type: 0,
pattern_scale: 32.0,
dodge_burn_radius: 30.0,
dodge_burn_hardness: 0.5,
dodge_burn_spacing: 3.0,
dodge_burn_exposure: 0.5,
dodge_burn_mode: 0,
sponge_radius: 30.0,
sponge_hardness: 0.5,
sponge_spacing: 3.0,
sponge_flow: 0.5,
sponge_mode: 0,
}
}
}
// ---------------------------------------------------------------------------
// Brush parameters extracted per-tool
// ---------------------------------------------------------------------------
pub struct BrushParams {
pub base_settings: BrushSettings,
pub radius: f32,
pub opacity: f32,
pub hardness: f32,
pub spacing: f32,
}
// ---------------------------------------------------------------------------
// RasterToolDef trait
// ---------------------------------------------------------------------------
pub trait RasterToolDef: Send + Sync {
fn blend_mode(&self) -> RasterBlendMode;
fn header_label(&self) -> &'static str;
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams;
/// Encode tool-specific state into the 4-float `StrokeRecord::tool_params`.
fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4];
/// Cursor display radius (world pixels).
fn cursor_radius(&self, s: &RasterToolSettings) -> f32 {
self.brush_params(s).radius
}
/// Render tool-specific controls in the infopanel (called before preset picker if any).
fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings);
/// Whether to show the brush preset picker after `render_ui`.
fn show_brush_preset_picker(&self) -> bool { true }
/// Whether this tool is the eraser (drives preset picker + color UI visibility).
fn is_eraser(&self) -> bool { false }
/// Whether Alt+click sets a source point for this tool.
fn uses_alt_click(&self) -> bool { false }
}
// ---------------------------------------------------------------------------
// Lookup: Tool → &'static dyn RasterToolDef
// ---------------------------------------------------------------------------
pub fn raster_tool_def(tool: &Tool) -> Option<&'static dyn RasterToolDef> {
match tool {
Tool::Draw | Tool::Pencil | Tool::Pen | Tool::Airbrush => Some(&paint::PAINT),
Tool::Erase => Some(&erase::ERASE),
Tool::Smudge => Some(&smudge::SMUDGE),
Tool::CloneStamp => Some(&clone_stamp::CLONE_STAMP),
Tool::HealingBrush => Some(&healing_brush::HEALING_BRUSH),
Tool::PatternStamp => Some(&pattern_stamp::PATTERN_STAMP),
Tool::DodgeBurn => Some(&dodge_burn::DODGE_BURN),
Tool::Sponge => Some(&sponge::SPONGE),
_ => None,
}
}

View File

@ -0,0 +1,22 @@
use super::{BrushParams, RasterToolDef, RasterToolSettings};
use eframe::egui;
use lightningbeam_core::raster_layer::RasterBlendMode;
pub struct PaintTool;
pub static PAINT: PaintTool = PaintTool;
impl RasterToolDef for PaintTool {
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Normal }
fn header_label(&self) -> &'static str { "Brush" }
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams {
BrushParams {
base_settings: s.active_brush_settings.clone(),
radius: s.brush_radius,
opacity: s.brush_opacity,
hardness: s.brush_hardness,
spacing: s.brush_spacing,
}
}
fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] }
fn render_ui(&self, _ui: &mut egui::Ui, _s: &mut RasterToolSettings) {}
}

View File

@ -0,0 +1,49 @@
use super::{BrushParams, RasterToolDef, RasterToolSettings};
use eframe::egui;
use lightningbeam_core::raster_layer::RasterBlendMode;
pub struct PatternStampTool;
pub static PATTERN_STAMP: PatternStampTool = PatternStampTool;
const PATTERN_NAMES: &[&str] = &[
"Checkerboard", "Dots", "H-Lines", "V-Lines", "Diagonal \\", "Diagonal /", "Crosshatch",
];
impl RasterToolDef for PatternStampTool {
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::PatternStamp }
fn header_label(&self) -> &'static str { "Pattern Stamp" }
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams {
BrushParams {
base_settings: s.active_brush_settings.clone(),
radius: s.brush_radius,
opacity: s.brush_opacity,
hardness: s.brush_hardness,
spacing: s.brush_spacing,
}
}
fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] {
[s.pattern_type as f32, s.pattern_scale, 0.0, 0.0]
}
fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) {
let selected_name = PATTERN_NAMES
.get(s.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(&mut s.pattern_type, i as u32, *name);
}
});
});
ui.horizontal(|ui| {
ui.label("Scale:");
ui.add(egui::Slider::new(&mut s.pattern_scale, 4.0_f32..=256.0)
.logarithmic(true).suffix(" px"));
});
ui.add_space(4.0);
}
}

View File

@ -0,0 +1,44 @@
use super::{BrushParams, RasterToolDef, RasterToolSettings};
use eframe::egui;
use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode};
pub struct SmudgeTool;
pub static SMUDGE: SmudgeTool = SmudgeTool;
impl RasterToolDef for SmudgeTool {
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Smudge }
fn header_label(&self) -> &'static str { "Smudge" }
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams {
BrushParams {
base_settings: BrushSettings::default(),
radius: s.smudge_radius,
opacity: 1.0, // strength is a separate smudge_dist multiplier
hardness: s.smudge_hardness,
spacing: s.smudge_spacing,
}
}
fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] }
fn show_brush_preset_picker(&self) -> bool { false }
fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) {
ui.horizontal(|ui| {
ui.label("Size:");
ui.add(egui::Slider::new(&mut s.smudge_radius, 1.0_f32..=200.0).logarithmic(true).suffix(" px"));
});
ui.horizontal(|ui| {
ui.label("Strength:");
ui.add(egui::Slider::new(&mut s.smudge_strength, 0.0_f32..=1.0)
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
});
ui.horizontal(|ui| {
ui.label("Hardness:");
ui.add(egui::Slider::new(&mut s.smudge_hardness, 0.0_f32..=1.0)
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
});
ui.horizontal(|ui| {
ui.label("Spacing:");
ui.add(egui::Slider::new(&mut s.smudge_spacing, 0.5_f32..=20.0)
.logarithmic(true)
.custom_formatter(|v, _| format!("{:.1}", v)));
});
}
}

View File

@ -0,0 +1,54 @@
use super::{BrushParams, RasterToolDef, RasterToolSettings};
use eframe::egui;
use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode};
pub struct SpongeTool;
pub static SPONGE: SpongeTool = SpongeTool;
impl RasterToolDef for SpongeTool {
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Sponge }
fn header_label(&self) -> &'static str { "Sponge" }
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams {
BrushParams {
base_settings: BrushSettings::default(),
radius: s.sponge_radius,
opacity: s.sponge_flow,
hardness: s.sponge_hardness,
spacing: s.sponge_spacing,
}
}
fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] {
[s.sponge_mode as f32, 0.0, 0.0, 0.0]
}
fn show_brush_preset_picker(&self) -> bool { false }
fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) {
ui.horizontal(|ui| {
if ui.selectable_label(s.sponge_mode == 0, "Saturate").clicked() {
s.sponge_mode = 0;
}
if ui.selectable_label(s.sponge_mode == 1, "Desaturate").clicked() {
s.sponge_mode = 1;
}
});
ui.horizontal(|ui| {
ui.label("Size:");
ui.add(egui::Slider::new(&mut s.sponge_radius, 1.0_f32..=500.0).logarithmic(true).suffix(" px"));
});
ui.horizontal(|ui| {
ui.label("Flow:");
ui.add(egui::Slider::new(&mut s.sponge_flow, 0.0_f32..=1.0)
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
});
ui.horizontal(|ui| {
ui.label("Hardness:");
ui.add(egui::Slider::new(&mut s.sponge_hardness, 0.0_f32..=1.0)
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
});
ui.horizontal(|ui| {
ui.label("Spacing:");
ui.add(egui::Slider::new(&mut s.sponge_spacing, 0.5_f32..=20.0)
.logarithmic(true)
.custom_formatter(|v, _| format!("{:.1}", v)));
});
}
}