7b4073e55b
- Added brush color variant and variant amount settings to the brush panel. - Introduced a checkbox for color variant and a slider for variant amount in the brush settings. - Implemented "Paste as New Layer" functionality, allowing users to paste clipboard images directly onto a new layer. - Updated menus to include the new paste option with a shortcut. - Improved layer panel iconography by replacing text buttons with SVG icons for visibility and lock toggles. - Created new SVG assets for closed eye and watercolor icons. - Enhanced history panel to provide richer descriptions for brush strokes and vector shape modifications. - Fixed the issue where the first brush stroke was missing from the history panel.
522 lines
20 KiB
Rust
522 lines
20 KiB
Rust
//! Brush stroke drawing functions for the HCIE engine.
|
||
//!
|
||
//! ## Purpose
|
||
//! Implements `stroke_to`, `draw_pen_segment`, and `draw_stroke`, plus the
|
||
//! `effects_dirty` conditional flag logic. These are the hot paths called for
|
||
//! every pointer event during painting.
|
||
//!
|
||
//! ## Logic & Workflow
|
||
//! - `stroke_to()` draws a single brush stamp or specialized stroke segment.
|
||
//! - `draw_pen_segment()` draws an anti-aliased line segment for the pen tool.
|
||
//! - `draw_stroke()` draws a complete multi-point stroke in one call.
|
||
//! - All paths expand `document.dirty_bounds` only by the brush footprint and
|
||
//! set `effects_dirty` only when the target layer actually has effects/styles.
|
||
//!
|
||
//! ## Side Effects / Dependencies
|
||
//! Mutates the active layer's pixel buffer, the document dirty flags, and the
|
||
//! engine's stroke state. Calls into `hcie-draw` and `hcie-brush-engine` via
|
||
//! the dynamic loader.
|
||
|
||
use crate::dynamic_loader::{
|
||
draw_brush_stroke, draw_filled_ellipse, draw_filled_rect, draw_line, draw_specialized_stroke,
|
||
};
|
||
use crate::Engine;
|
||
use hcie_protocol::{BrushStyle, BrushTip};
|
||
|
||
impl Engine {
|
||
/// Interpolate a brush property along the stroke using accumulated distance.
|
||
///
|
||
/// `t` is the normalized stroke progress (0.0 at start, 1.0 at end). If the
|
||
/// `time_enabled` flag is off or the start/end values are equal, the base
|
||
/// value is returned unchanged. Otherwise the value is lerped from start
|
||
/// to end and multiplied with the base.
|
||
fn apply_time_interpolation(
|
||
base: f32,
|
||
start: f32,
|
||
end: f32,
|
||
time_enabled: bool,
|
||
t: f32,
|
||
) -> f32 {
|
||
if !time_enabled {
|
||
return base;
|
||
}
|
||
let lerp = start + (end - start) * t.clamp(0.0, 1.0);
|
||
base * lerp
|
||
}
|
||
|
||
/// Apply time-based dynamics to the brush tip for the current stroke segment.
|
||
///
|
||
/// Returns a **copy** of `self.current_tip` with `size`, `opacity`, and
|
||
/// `angle` interpolated based on stroke progress. The original
|
||
/// `self.current_tip` is **not** modified, so each `stroke_to` call starts
|
||
/// from the same base values — preventing cumulative drift that would
|
||
/// shrink the brush to a thin line over a long stroke.
|
||
fn brush_tip_with_time_dynamics(&self, x: f32, y: f32) -> BrushTip {
|
||
let cfg = self.current_tip.clone();
|
||
if !cfg.time_enabled {
|
||
return cfg;
|
||
}
|
||
// Approximate stroke progress from segment length vs a nominal total.
|
||
let segment_len = if let Some((lx, ly, _)) = self.last_stroke_pos {
|
||
let dx = x - lx;
|
||
let dy = y - ly;
|
||
(dx * dx + dy * dy).sqrt()
|
||
} else {
|
||
0.0
|
||
};
|
||
let nominal_total = 250.0_f32.max(segment_len);
|
||
let t = (segment_len / nominal_total).clamp(0.0, 1.0);
|
||
let mut tip = cfg;
|
||
tip.size = Self::apply_time_interpolation(
|
||
tip.size,
|
||
tip.time_size_start,
|
||
tip.time_size_end,
|
||
tip.time_enabled,
|
||
t,
|
||
);
|
||
tip.opacity = Self::apply_time_interpolation(
|
||
tip.opacity,
|
||
tip.time_opacity_start,
|
||
tip.time_opacity_end,
|
||
tip.time_enabled,
|
||
t,
|
||
);
|
||
tip.angle = Self::apply_time_interpolation(
|
||
tip.angle,
|
||
tip.time_angle_start,
|
||
tip.time_angle_end,
|
||
tip.time_enabled,
|
||
t,
|
||
);
|
||
tip
|
||
}
|
||
|
||
pub fn stroke_to(&mut self, layer_id: u64, x: f32, y: f32, pressure: f32) {
|
||
let w = self.document.canvas_width as f32;
|
||
let h = self.document.canvas_height as f32;
|
||
let inside = x >= 0.0 && y >= 0.0 && x < w && y < h;
|
||
if !inside {
|
||
self.last_stroke_pos = None;
|
||
return;
|
||
}
|
||
let tip = self.brush_tip_with_time_dynamics(x, y);
|
||
let color = self.apply_cyclic_color(self.current_color);
|
||
let mask_ref = self.cached_selection_mask.as_deref();
|
||
|
||
if Self::is_specialized_style(tip.style) {
|
||
if let Some((lx, ly, lp)) = self.last_stroke_pos {
|
||
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
||
// If click without movement, draw a single dab at current position
|
||
let points_vec = if (lx - x).abs() < 0.001 && (ly - y).abs() < 0.001 {
|
||
vec![(x, y, pressure)]
|
||
} else {
|
||
vec![(lx, ly, lp), (x, y, pressure)]
|
||
};
|
||
draw_specialized_stroke(
|
||
&mut layer.pixels,
|
||
layer.width,
|
||
layer.height,
|
||
&points_vec,
|
||
tip.style,
|
||
tip.size,
|
||
tip.hardness,
|
||
color,
|
||
tip.opacity,
|
||
tip.spacing,
|
||
self.is_eraser,
|
||
if tip.style == BrushStyle::Sketch {
|
||
Some(&mut self.sketch_history)
|
||
} else {
|
||
None
|
||
},
|
||
tip.spray_particle_size,
|
||
tip.spray_density,
|
||
mask_ref,
|
||
self.active_stroke_mask.as_deref_mut(),
|
||
self.stroke_before_buf.as_deref(),
|
||
tip.color_variant,
|
||
tip.variant_amount,
|
||
tip.density,
|
||
);
|
||
layer.dirty = true;
|
||
if !layer.effects.is_empty() || !layer.styles.is_empty() {
|
||
*layer.effects_cache.lock().unwrap() = None;
|
||
}
|
||
let dirty_r = match tip.style {
|
||
// Star/Spray: spread was reduced to 0.65× so 1.8× covers the footprint.
|
||
BrushStyle::Star | BrushStyle::Spray => tip.size * 1.8,
|
||
// Meadow/Leaf draw blades/leaves upward from center — asymmetric extent.
|
||
BrushStyle::Meadow | BrushStyle::Leaf => tip.size * 3.0,
|
||
// Clouds/Tree scatter particles in a wide area.
|
||
BrushStyle::Clouds | BrushStyle::Tree => tip.size * 2.5,
|
||
_ => tip.size.max(2.0),
|
||
};
|
||
self.document.expand_dirty(lx as u32, ly as u32, dirty_r);
|
||
self.document.expand_dirty(x as u32, y as u32, dirty_r);
|
||
self.expand_stroke_bounds(lx as u32, ly as u32, dirty_r);
|
||
self.expand_stroke_bounds(x as u32, y as u32, dirty_r);
|
||
}
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
} else {
|
||
self.last_stroke_pos = Some((x, y, pressure));
|
||
return;
|
||
}
|
||
} else {
|
||
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
||
draw_brush_stroke(
|
||
layer,
|
||
&[(x, y, pressure)],
|
||
color,
|
||
&tip,
|
||
self.is_eraser,
|
||
mask_ref,
|
||
self.active_stroke_mask.as_deref_mut(),
|
||
self.stroke_before_buf.as_deref(),
|
||
);
|
||
layer.dirty = true;
|
||
if !layer.effects.is_empty() || !layer.styles.is_empty() {
|
||
layer
|
||
.effects_dirty
|
||
.store(true, std::sync::atomic::Ordering::Release);
|
||
}
|
||
let dirty_r = tip.size.max(2.0);
|
||
if let Some((lx, ly, _)) = self.last_stroke_pos {
|
||
self.document.expand_dirty(lx as u32, ly as u32, dirty_r);
|
||
}
|
||
self.document.expand_dirty(x as u32, y as u32, dirty_r);
|
||
if let Some((lx, ly, _)) = self.last_stroke_pos {
|
||
self.expand_stroke_bounds(lx as u32, ly as u32, dirty_r);
|
||
}
|
||
self.expand_stroke_bounds(x as u32, y as u32, dirty_r);
|
||
}
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
}
|
||
self.last_stroke_pos = Some((x, y, pressure));
|
||
}
|
||
|
||
/// Pen tool stroke — draws a true anti-aliased line segment.
|
||
/// Uses begin_stroke/end_stroke for undo.
|
||
pub fn draw_pen_segment(
|
||
&mut self,
|
||
layer_id: u64,
|
||
x0: f32,
|
||
y0: f32,
|
||
x1: f32,
|
||
y1: f32,
|
||
pressure: f32,
|
||
) {
|
||
let tip = self.current_tip.clone();
|
||
let color = self.apply_cyclic_color(self.current_color);
|
||
let mask_ref = self.cached_selection_mask.as_deref();
|
||
let w = self.document.canvas_width as f32;
|
||
let h = self.document.canvas_height as f32;
|
||
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
||
let px_color = [
|
||
color[0],
|
||
color[1],
|
||
color[2],
|
||
(color[3] as f32 * tip.opacity * pressure).round() as u8,
|
||
];
|
||
draw_line(layer, x0, y0, x1, y1, px_color, tip.size, mask_ref);
|
||
layer.dirty = true;
|
||
if !layer.effects.is_empty() || !layer.styles.is_empty() {
|
||
layer
|
||
.effects_dirty
|
||
.store(true, std::sync::atomic::Ordering::Release);
|
||
}
|
||
let dirty_r = tip.size.max(2.0);
|
||
if x0 >= 0.0 && y0 >= 0.0 && x0 < w && y0 < h {
|
||
self.document.expand_dirty(x0 as u32, y0 as u32, dirty_r);
|
||
}
|
||
if x1 >= 0.0 && y1 >= 0.0 && x1 < w && y1 < h {
|
||
self.document.expand_dirty(x1 as u32, y1 as u32, dirty_r);
|
||
}
|
||
}
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
}
|
||
|
||
pub fn draw_stroke(&mut self, points: &[(f32, f32, f32)]) {
|
||
let tip = self.current_tip.clone();
|
||
let color = self.apply_cyclic_color(self.current_color);
|
||
let mask_ref = self.cached_selection_mask.as_deref();
|
||
let layer_idx = self.document.active_layer;
|
||
let before_pixels = if layer_idx < self.document.layers.len() {
|
||
Some(self.document.layers[layer_idx].pixels.clone())
|
||
} else {
|
||
None
|
||
};
|
||
|
||
if let Some(layer) = self.document.active_layer_mut() {
|
||
if Self::is_specialized_style(tip.style) {
|
||
draw_specialized_stroke(
|
||
&mut layer.pixels,
|
||
layer.width,
|
||
layer.height,
|
||
points,
|
||
tip.style,
|
||
tip.size,
|
||
tip.hardness,
|
||
color,
|
||
tip.opacity,
|
||
tip.spacing,
|
||
self.is_eraser,
|
||
if tip.style == BrushStyle::Sketch {
|
||
Some(&mut self.sketch_history)
|
||
} else {
|
||
None
|
||
},
|
||
tip.spray_particle_size,
|
||
tip.spray_density,
|
||
mask_ref,
|
||
self.active_stroke_mask.as_deref_mut(),
|
||
before_pixels.as_ref().map(|px| px.as_slice()),
|
||
tip.color_variant,
|
||
tip.variant_amount,
|
||
tip.density,
|
||
);
|
||
layer.dirty = true;
|
||
} else {
|
||
draw_brush_stroke(
|
||
layer,
|
||
points,
|
||
color,
|
||
&tip,
|
||
self.is_eraser,
|
||
mask_ref,
|
||
self.active_stroke_mask.as_deref_mut(),
|
||
before_pixels.as_ref().map(|px| px.as_slice()),
|
||
);
|
||
}
|
||
if !layer.effects.is_empty() || !layer.styles.is_empty() {
|
||
layer
|
||
.effects_dirty
|
||
.store(true, std::sync::atomic::Ordering::Release);
|
||
}
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
let dirty_r = match tip.style {
|
||
// Star/Spray: spread was reduced to 0.65× so 1.8× covers the footprint.
|
||
BrushStyle::Star | BrushStyle::Spray => tip.size * 1.8,
|
||
// Meadow/Leaf draw blades/leaves upward from center — asymmetric extent.
|
||
BrushStyle::Meadow | BrushStyle::Leaf => tip.size * 3.0,
|
||
// Clouds/Tree scatter particles in a wide area.
|
||
BrushStyle::Clouds | BrushStyle::Tree => tip.size * 2.5,
|
||
_ => tip.size.max(2.0),
|
||
};
|
||
for &(px, py, _) in points {
|
||
if px >= 0.0
|
||
&& py >= 0.0
|
||
&& px < self.document.canvas_width as f32
|
||
&& py < self.document.canvas_height as f32
|
||
{
|
||
self.document.expand_dirty(px as u32, py as u32, dirty_r);
|
||
}
|
||
}
|
||
}
|
||
|
||
if let Some(before) = before_pixels {
|
||
let desc = format!(
|
||
"Brush Stroke ({})",
|
||
crate::stroke_brush::brush_style_label(tip.style)
|
||
);
|
||
self.document.push_draw_snapshot(layer_idx, before, None, desc);
|
||
}
|
||
}
|
||
|
||
pub fn draw_line(&mut self, x0: f32, y0: f32, x1: f32, y1: f32) {
|
||
let mask_ref = self.cached_selection_mask.as_deref();
|
||
let layer_idx = self.document.active_layer;
|
||
let before_pixels = if layer_idx < self.document.layers.len() {
|
||
Some(self.document.layers[layer_idx].pixels.clone())
|
||
} else {
|
||
None
|
||
};
|
||
if let Some(layer) = self.document.active_layer_mut() {
|
||
let color = [255, 0, 0, 255];
|
||
draw_line(layer, x0, y0, x1, y1, color, 2.0, mask_ref);
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
}
|
||
if let Some(before) = before_pixels {
|
||
self.document
|
||
.push_draw_snapshot(layer_idx, before, None, "Line".to_string());
|
||
}
|
||
}
|
||
|
||
pub fn draw_rect(&mut self, x1: f32, y1: f32, x2: f32, y2: f32) {
|
||
let mask_ref = self.cached_selection_mask.as_deref();
|
||
let layer_idx = self.document.active_layer;
|
||
let before_pixels = if layer_idx < self.document.layers.len() {
|
||
Some(self.document.layers[layer_idx].pixels.clone())
|
||
} else {
|
||
None
|
||
};
|
||
if let Some(layer) = self.document.active_layer_mut() {
|
||
let color = [255, 0, 0, 255];
|
||
draw_filled_rect(layer, x1, y1, x2, y2, color, mask_ref);
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
}
|
||
if let Some(before) = before_pixels {
|
||
self.document
|
||
.push_draw_snapshot(layer_idx, before, None, "Rectangle".to_string());
|
||
}
|
||
}
|
||
|
||
pub fn draw_filled_rect_rgba(&mut self, x1: u32, y1: u32, x2: u32, y2: u32, color: [u8; 4]) {
|
||
let mask_ref = self.cached_selection_mask.as_deref();
|
||
let layer_idx = self.document.active_layer;
|
||
let before_pixels = if layer_idx < self.document.layers.len() {
|
||
Some(self.document.layers[layer_idx].pixels.clone())
|
||
} else {
|
||
None
|
||
};
|
||
if let Some(layer) = self.document.active_layer_mut() {
|
||
let fx1 = x1.min(x2) as f32;
|
||
let fy1 = y1.min(y2) as f32;
|
||
let fx2 = x2.max(x1) as f32;
|
||
let fy2 = y2.max(y1) as f32;
|
||
draw_filled_rect(layer, fx1, fy1, fx2, fy2, color, mask_ref);
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
}
|
||
if let Some(before) = before_pixels {
|
||
self.document
|
||
.push_draw_snapshot(layer_idx, before, None, "Fill Rectangle".to_string());
|
||
}
|
||
}
|
||
|
||
pub fn draw_ellipse(&mut self, cx: f32, cy: f32, rx: f32, ry: f32) {
|
||
let mask_ref = self.cached_selection_mask.as_deref();
|
||
let layer_idx = self.document.active_layer;
|
||
let before_pixels = if layer_idx < self.document.layers.len() {
|
||
Some(self.document.layers[layer_idx].pixels.clone())
|
||
} else {
|
||
None
|
||
};
|
||
if let Some(layer) = self.document.active_layer_mut() {
|
||
let color = [255, 0, 0, 255];
|
||
draw_filled_ellipse(layer, cx, cy, rx, ry, color, mask_ref);
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
}
|
||
if let Some(before) = before_pixels {
|
||
self.document
|
||
.push_draw_snapshot(layer_idx, before, None, "Ellipse".to_string());
|
||
}
|
||
}
|
||
|
||
/// Update hue for cyclic color and return the new RGBA color.
|
||
fn apply_cyclic_color(&mut self, base_color: [u8; 4]) -> [u8; 4] {
|
||
if !self.cyclic_color {
|
||
return base_color;
|
||
}
|
||
self.pen_hue = (self.pen_hue + self.cyclic_speed) % 360.0;
|
||
let (r, g, b) = Self::hsl_to_rgb(self.pen_hue);
|
||
[r, g, b, base_color[3]]
|
||
}
|
||
|
||
// Simple HSL to RGB: S=100%, L=50% (full saturation, half luminance)
|
||
fn hsl_to_rgb(h: f32) -> (u8, u8, u8) {
|
||
let h = h % 360.0;
|
||
let c = 1.0; // S=100%
|
||
let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs());
|
||
let m = 0.0; // L=50%, c=1.0 => m=0
|
||
let (r, g, b) = if h < 60.0 {
|
||
(c, x, 0.0)
|
||
} else if h < 120.0 {
|
||
(x, c, 0.0)
|
||
} else if h < 180.0 {
|
||
(0.0, c, x)
|
||
} else if h < 240.0 {
|
||
(0.0, x, c)
|
||
} else if h < 300.0 {
|
||
(x, 0.0, c)
|
||
} else {
|
||
(c, 0.0, x)
|
||
};
|
||
(
|
||
((r + m) * 255.0).round() as u8,
|
||
((g + m) * 255.0).round() as u8,
|
||
((b + m) * 255.0).round() as u8,
|
||
)
|
||
}
|
||
|
||
/// Check if the given brush style needs specialized procedural rendering.
|
||
/// Basic stamp-based styles (Round, Square, HardRound, SoftRound) return false.
|
||
pub(crate) fn is_specialized_style(s: BrushStyle) -> bool {
|
||
matches!(
|
||
s,
|
||
BrushStyle::Oil
|
||
| BrushStyle::Charcoal
|
||
| BrushStyle::Watercolor
|
||
| BrushStyle::Calligraphy
|
||
| BrushStyle::Marker
|
||
| BrushStyle::Glow
|
||
| BrushStyle::Airbrush
|
||
| BrushStyle::Spray
|
||
| BrushStyle::Pencil
|
||
| BrushStyle::Crayon
|
||
| BrushStyle::Tree
|
||
| BrushStyle::Meadow
|
||
| BrushStyle::Rock
|
||
| BrushStyle::Clouds
|
||
| BrushStyle::Dirt
|
||
| BrushStyle::Star
|
||
| BrushStyle::Bristle
|
||
| BrushStyle::Wood
|
||
| BrushStyle::Sketch
|
||
| BrushStyle::Hatch
|
||
| BrushStyle::Square
|
||
| BrushStyle::WetPaint
|
||
| BrushStyle::Leaf
|
||
| BrushStyle::Mixer
|
||
)
|
||
}
|
||
}
|
||
|
||
/// Returns a short human-readable label for a built-in brush style.
|
||
///
|
||
/// Mirrors the names used in the GUI brush preset list so history entries
|
||
/// can include the active style, e.g. "Brush Stroke (Round)".
|
||
pub fn brush_style_label(style: BrushStyle) -> &'static str {
|
||
match style {
|
||
BrushStyle::Round | BrushStyle::Default => "Round",
|
||
BrushStyle::Square => "Square",
|
||
BrushStyle::HardRound => "Hard Round",
|
||
BrushStyle::SoftRound => "Soft Round",
|
||
BrushStyle::Star => "Star",
|
||
BrushStyle::Noise => "Noise",
|
||
BrushStyle::Texture => "Texture",
|
||
BrushStyle::Spray => "Spray",
|
||
BrushStyle::Pencil => "Pencil",
|
||
BrushStyle::Pen => "Pen",
|
||
BrushStyle::Calligraphy => "Calligraphy",
|
||
BrushStyle::Oil => "Oil",
|
||
BrushStyle::Charcoal => "Charcoal",
|
||
BrushStyle::Leaf => "Leaf",
|
||
BrushStyle::Rock => "Rock",
|
||
BrushStyle::Meadow => "Meadow",
|
||
BrushStyle::Wood => "Wood",
|
||
BrushStyle::Watercolor => "Watercolor",
|
||
BrushStyle::Marker => "Marker",
|
||
BrushStyle::Sketch => "Sketch",
|
||
BrushStyle::Hatch => "Hatch",
|
||
BrushStyle::Glow => "Glow",
|
||
BrushStyle::Airbrush => "Airbrush",
|
||
BrushStyle::Crayon => "Crayon",
|
||
BrushStyle::WetPaint => "Wet Paint",
|
||
BrushStyle::InkPen => "Ink Pen",
|
||
BrushStyle::Clouds => "Clouds",
|
||
BrushStyle::Dirt => "Dirt",
|
||
BrushStyle::Tree => "Tree",
|
||
BrushStyle::Bristle => "Bristle",
|
||
BrushStyle::Mixer => "Mixer",
|
||
BrushStyle::Blender => "Blender",
|
||
BrushStyle::Bitmap => "Bitmap",
|
||
}
|
||
}
|