779 lines
30 KiB
Rust
779 lines
30 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 {
|
|
/// Returns a conservative dirty radius including procedural and preset offsets.
|
|
fn brush_dirty_radius(tip: &BrushTip) -> f32 {
|
|
let procedural = match tip.style {
|
|
BrushStyle::Star | BrushStyle::Spray => 1.8,
|
|
BrushStyle::Meadow | BrushStyle::Leaf => 3.0,
|
|
BrushStyle::Clouds | BrushStyle::Tree => 2.5,
|
|
BrushStyle::Watercolor => 1.4,
|
|
BrushStyle::WetPaint => 1.2,
|
|
_ => 1.0,
|
|
};
|
|
let offset = tip.jitter_amount.max(0.0) * 0.5 + tip.scatter_amount.max(0.0) * 0.3;
|
|
(tip.size * (procedural + offset)).max(2.0)
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
|
|
/// Helper method to execute a drawing action either on active layer pixels (when editing_mask is false)
|
|
/// or on the active layer mask (when editing_mask is true), converting between RGBA and greyscale mask.
|
|
/// Ensures active layer's mask is expanded to full canvas dimensions if present.
|
|
pub fn ensure_full_canvas_mask(&mut self, layer_id: u64) {
|
|
let cw = self.document.canvas_width;
|
|
let ch = self.document.canvas_height;
|
|
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
|
let full_size = (cw * ch) as usize;
|
|
if let Some(ref mask) = layer.mask_pixels {
|
|
if mask.len() != full_size {
|
|
let mut full_mask = vec![layer.mask_default_color; full_size];
|
|
if let Some(mb) = layer.mask_bounds {
|
|
let (m_top, m_left, m_bottom, m_right) = (
|
|
mb[0].max(0) as u32,
|
|
mb[1].max(0) as u32,
|
|
mb[2].max(0) as u32,
|
|
mb[3].max(0) as u32,
|
|
);
|
|
let mw = (m_right - m_left) as usize;
|
|
let mh = (m_bottom - m_top) as usize;
|
|
if mw > 0 && mh > 0 && mask.len() == mw * mh {
|
|
for y in 0..mh {
|
|
let gy = m_top as usize + y;
|
|
if gy >= ch as usize {
|
|
break;
|
|
}
|
|
for x in 0..mw {
|
|
let gx = m_left as usize + x;
|
|
if gx >= cw as usize {
|
|
break;
|
|
}
|
|
full_mask[gy * cw as usize + gx] = mask[y * mw + x];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
layer.mask_pixels = Some(full_mask);
|
|
layer.mask_bounds = Some([0, 0, ch as i32, cw as i32]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn draw_target_pixels_or_mask<F>(&mut self, layer_id: u64, f: F)
|
|
where
|
|
F: FnOnce(
|
|
&mut [u8],
|
|
u32,
|
|
u32,
|
|
Option<&[u8]>,
|
|
Option<&mut [u8]>,
|
|
Option<&[u8]>,
|
|
Option<&mut Vec<(f32, f32)>>,
|
|
),
|
|
{
|
|
let is_editing = self.editing_mask;
|
|
if is_editing {
|
|
self.ensure_full_canvas_mask(layer_id);
|
|
}
|
|
|
|
if !is_editing {
|
|
let mask_ref = self.cached_selection_mask.as_deref();
|
|
let active_stroke_mask = self.active_stroke_mask.as_deref_mut();
|
|
let stroke_before_buf = self.stroke_before_buf.as_deref();
|
|
let sketch_hist = &mut self.sketch_history;
|
|
|
|
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
|
let w = layer.width;
|
|
let h = layer.height;
|
|
f(
|
|
&mut layer.pixels,
|
|
w,
|
|
h,
|
|
mask_ref,
|
|
active_stroke_mask,
|
|
stroke_before_buf,
|
|
Some(sketch_hist),
|
|
);
|
|
layer.dirty = true;
|
|
}
|
|
} else {
|
|
let cw = self.document.canvas_width as usize;
|
|
let ch = self.document.canvas_height as usize;
|
|
let mask_len = cw * ch;
|
|
if mask_len == 0 {
|
|
return;
|
|
}
|
|
|
|
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
|
if layer.mask_pixels.is_none() {
|
|
layer.mask_pixels = Some(vec![255u8; mask_len]);
|
|
layer.mask_bounds = Some([0, 0, ch as i32, cw as i32]);
|
|
layer.mask_default_color = 255;
|
|
}
|
|
}
|
|
|
|
let (rx0, ry0, rx1, ry1) = match self.last_stroke_bounds {
|
|
Some([x0, y0, x1, y1]) => (
|
|
(x0.saturating_sub(15) as usize).min(cw),
|
|
(y0.saturating_sub(15) as usize).min(ch),
|
|
((x1 + 15) as usize).min(cw),
|
|
((y1 + 15) as usize).min(ch),
|
|
),
|
|
None => (0, 0, cw, ch),
|
|
};
|
|
|
|
let needed_len = mask_len * 4;
|
|
let mut scratch = self.mask_rgba_scratch.take().unwrap_or_default();
|
|
if scratch.len() != needed_len {
|
|
scratch.resize(needed_len, 0);
|
|
}
|
|
|
|
if let Some(layer) = self.document.get_layer_by_id(layer_id) {
|
|
if let Some(ref mask) = layer.mask_pixels {
|
|
for y in ry0..ry1 {
|
|
let row_off = y * cw;
|
|
for x in rx0..rx1 {
|
|
let idx = row_off + x;
|
|
let v = mask[idx];
|
|
let off = idx * 4;
|
|
scratch[off] = v;
|
|
scratch[off + 1] = v;
|
|
scratch[off + 2] = v;
|
|
scratch[off + 3] = 255;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
{
|
|
let mask_ref = self.cached_selection_mask.as_deref();
|
|
let active_stroke_mask = self.active_stroke_mask.as_deref_mut();
|
|
let stroke_before_buf = self.stroke_before_buf.as_deref();
|
|
let sketch_hist = &mut self.sketch_history;
|
|
f(
|
|
&mut scratch,
|
|
cw as u32,
|
|
ch as u32,
|
|
mask_ref,
|
|
active_stroke_mask,
|
|
stroke_before_buf,
|
|
Some(sketch_hist),
|
|
);
|
|
}
|
|
|
|
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
|
if let Some(ref mut mask) = layer.mask_pixels {
|
|
for y in ry0..ry1 {
|
|
let row_off = y * cw;
|
|
for x in rx0..rx1 {
|
|
let idx = row_off + x;
|
|
let off = idx * 4;
|
|
let r = scratch[off] as u32;
|
|
let g = scratch[off + 1] as u32;
|
|
let b = scratch[off + 2] as u32;
|
|
let a = scratch[off + 3] as u32;
|
|
let gray = (r * 299 + g * 587 + b * 114) / 1000;
|
|
mask[idx] = ((gray * a) / 255) as u8;
|
|
}
|
|
}
|
|
}
|
|
layer.dirty = true;
|
|
}
|
|
|
|
self.mask_rgba_scratch = Some(scratch);
|
|
}
|
|
self.below_cache_dirty = true;
|
|
self.document.composite_dirty = true;
|
|
self.document.modified = true;
|
|
}
|
|
|
|
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;
|
|
let tip = self.brush_tip_with_time_dynamics(x, y);
|
|
let color = self.apply_cyclic_color(self.current_color);
|
|
|
|
if Self::is_specialized_style(tip.style) {
|
|
if let Some((lx, ly, lp)) = self.last_stroke_pos {
|
|
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)]
|
|
};
|
|
let is_eraser = self.is_eraser;
|
|
|
|
let dirty_r = Self::brush_dirty_radius(&tip);
|
|
if lx >= 0.0 && ly >= 0.0 && lx < w && ly < h {
|
|
self.document.expand_dirty(lx as u32, ly as u32, dirty_r);
|
|
self.expand_stroke_bounds(lx as u32, ly as u32, dirty_r);
|
|
}
|
|
if inside {
|
|
self.document.expand_dirty(x as u32, y as u32, dirty_r);
|
|
self.expand_stroke_bounds(x as u32, y as u32, dirty_r);
|
|
}
|
|
|
|
self.draw_target_pixels_or_mask(
|
|
layer_id,
|
|
|pixels,
|
|
lw,
|
|
lh,
|
|
mask_ref,
|
|
active_stroke_mask,
|
|
stroke_before_buf,
|
|
sketch_history| {
|
|
let sketch_hist = if tip.style == BrushStyle::Sketch {
|
|
sketch_history
|
|
} else {
|
|
None
|
|
};
|
|
draw_specialized_stroke(
|
|
pixels,
|
|
lw,
|
|
lh,
|
|
&points_vec,
|
|
tip.style,
|
|
tip.size,
|
|
tip.hardness,
|
|
color,
|
|
tip.opacity,
|
|
tip.spacing,
|
|
is_eraser,
|
|
sketch_hist,
|
|
tip.spray_particle_size,
|
|
tip.spray_density,
|
|
mask_ref,
|
|
active_stroke_mask,
|
|
stroke_before_buf,
|
|
tip.color_variant,
|
|
tip.variant_amount,
|
|
tip.density,
|
|
tip.jitter_amount,
|
|
tip.scatter_amount,
|
|
tip.angle,
|
|
tip.roundness,
|
|
tip.rotation_random,
|
|
tip.drawing_angle,
|
|
false,
|
|
);
|
|
},
|
|
);
|
|
|
|
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
|
if !layer.effects.is_empty() || !layer.styles.is_empty() {
|
|
*layer.effects_cache.lock().unwrap() = None;
|
|
}
|
|
}
|
|
self.document.composite_dirty = true;
|
|
self.document.modified = true;
|
|
} else {
|
|
self.last_stroke_pos = Some((x, y, pressure));
|
|
return;
|
|
}
|
|
} else {
|
|
let is_eraser = self.is_eraser;
|
|
|
|
let dirty_r = tip.size.max(2.0);
|
|
if let Some((lx, ly, _)) = self.last_stroke_pos {
|
|
if lx >= 0.0 && ly >= 0.0 && lx < w && ly < h {
|
|
self.document.expand_dirty(lx as u32, ly as u32, dirty_r);
|
|
self.expand_stroke_bounds(lx as u32, ly as u32, dirty_r);
|
|
}
|
|
}
|
|
if inside {
|
|
self.document.expand_dirty(x as u32, y as u32, dirty_r);
|
|
self.expand_stroke_bounds(x as u32, y as u32, dirty_r);
|
|
}
|
|
|
|
if self.editing_mask {
|
|
self.draw_target_pixels_or_mask(
|
|
layer_id,
|
|
|pixels,
|
|
lw,
|
|
lh,
|
|
mask_ref,
|
|
active_stroke_mask,
|
|
stroke_before_buf,
|
|
_sketch_history| {
|
|
let mut temp_layer =
|
|
hcie_protocol::Layer::from_rgba("temp", lw, lh, pixels.to_vec());
|
|
draw_brush_stroke(
|
|
&mut temp_layer,
|
|
&[(x, y, pressure)],
|
|
color,
|
|
&tip,
|
|
is_eraser,
|
|
mask_ref,
|
|
active_stroke_mask,
|
|
stroke_before_buf,
|
|
);
|
|
pixels.copy_from_slice(&temp_layer.pixels);
|
|
},
|
|
);
|
|
} else {
|
|
let mask_ref = self.cached_selection_mask.as_deref();
|
|
let active_stroke_mask = self.active_stroke_mask.as_deref_mut();
|
|
let stroke_before_buf = self.stroke_before_buf.as_deref();
|
|
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
|
draw_brush_stroke(
|
|
layer,
|
|
&[(x, y, pressure)],
|
|
color,
|
|
&tip,
|
|
is_eraser,
|
|
mask_ref,
|
|
active_stroke_mask,
|
|
stroke_before_buf,
|
|
);
|
|
}
|
|
}
|
|
|
|
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
|
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;
|
|
}
|
|
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,
|
|
tip.jitter_amount,
|
|
tip.scatter_amount,
|
|
tip.angle,
|
|
tip.roundness,
|
|
tip.rotation_random,
|
|
tip.drawing_angle,
|
|
true,
|
|
);
|
|
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 = Self::brush_dirty_radius(&tip);
|
|
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
|
|
| BrushStyle::Blender
|
|
| BrushStyle::Noise
|
|
| BrushStyle::Texture
|
|
| BrushStyle::Pen
|
|
| BrushStyle::InkPen
|
|
)
|
|
}
|
|
}
|
|
|
|
/// 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",
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod dirty_radius_tests {
|
|
use super::*;
|
|
|
|
/// Ensures partial uploads include procedural watercolor spread and preset offsets.
|
|
#[test]
|
|
fn watercolor_dirty_radius_covers_jitter_and_scatter() {
|
|
let tip = BrushTip {
|
|
style: BrushStyle::Watercolor,
|
|
size: 40.0,
|
|
jitter_amount: 0.4,
|
|
scatter_amount: 0.9,
|
|
..BrushTip::default()
|
|
};
|
|
assert!((Engine::brush_dirty_radius(&tip) - 74.8).abs() < 0.001);
|
|
}
|
|
}
|