Files
hcie-rust-v3.05/hcie-engine-api/src/stroke_brush.rs
T

477 lines
19 KiB
Rust
Raw Normal View History

2026-07-09 02:59:53 +03:00
//! 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};
2026-07-09 02:59:53 +03:00
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;
}
2026-07-09 02:59:53 +03:00
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)]
};
2026-07-09 02:59:53 +03:00
draw_specialized_stroke(
&mut layer.pixels,
layer.width,
layer.height,
&points_vec,
2026-07-09 02:59:53 +03:00
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
},
2026-07-09 02:59:53 +03:00
tip.spray_particle_size,
tip.spray_density,
mask_ref,
self.active_stroke_mask.as_deref_mut(),
self.stroke_before_buf.as_deref(),
2026-07-09 02:59:53 +03:00
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;
2026-07-09 02:59:53 +03:00
}
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,
2026-07-09 02:59:53 +03:00
_ => 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(),
2026-07-09 02:59:53 +03:00
);
layer.dirty = true;
if !layer.effects.is_empty() || !layer.styles.is_empty() {
layer
.effects_dirty
.store(true, std::sync::atomic::Ordering::Release);
2026-07-09 02:59:53 +03:00
}
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,
2026-07-09 02:59:53 +03:00
) {
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,
];
2026-07-09 02:59:53 +03:00
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);
2026-07-09 02:59:53 +03:00
}
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
},
2026-07-09 02:59:53 +03:00
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);
2026-07-09 02:59:53 +03:00
}
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,
2026-07-09 02:59:53 +03:00
_ => 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
{
2026-07-09 02:59:53 +03:00
self.document.expand_dirty(px as u32, py as u32, dirty_r);
}
}
}
if let Some(before) = before_pixels {
self.document
.push_draw_snapshot(layer_idx, before, None, "Brush Stroke".to_string());
2026-07-09 02:59:53 +03:00
}
}
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());
2026-07-09 02:59:53 +03:00
}
}
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());
2026-07-09 02:59:53 +03:00
}
}
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 Rect".to_string());
2026-07-09 02:59:53 +03:00
}
}
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());
2026-07-09 02:59:53 +03:00
}
}
/// 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
)
2026-07-09 02:59:53 +03:00
}
}