//! 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", } }