#![allow(dead_code)] extern crate self as hcie_protocol; pub mod dynamics; pub mod presets; #[derive( Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize, )] #[repr(i32)] pub enum BrushStyle { #[default] Round = 0, Square = 1, HardRound = 2, SoftRound = 3, Star = 4, Noise = 5, Texture = 6, Spray = 7, Pencil = 8, Pen = 9, Calligraphy = 10, Oil = 11, Charcoal = 12, Leaf = 13, Rock = 14, Meadow = 15, Wood = 16, Watercolor = 17, Marker = 18, Sketch = 19, Hatch = 20, Glow = 21, Airbrush = 22, Crayon = 23, WetPaint = 24, InkPen = 25, Clouds = 26, Dirt = 27, Tree = 28, Bristle = 29, Mixer = 30, Blender = 31, Bitmap = 32, } #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct BrushTip { pub style: BrushStyle, pub size: f32, pub opacity: f32, pub hardness: f32, pub spacing: f32, pub flow: f32, pub jitter_amount: f32, pub scatter_amount: f32, pub angle: f32, pub roundness: f32, pub spray_particle_size: f32, pub spray_density: u32, pub bitmap_pixels: Vec, pub bitmap_w: u32, pub bitmap_h: u32, pub color_variant: bool, pub variant_amount: f32, pub density: f32, /// When true, override the brush angle with the stroke segment direction. pub drawing_angle: bool, /// Per-dab random angle offset magnitude (radians). pub rotation_random: f32, } impl Default for BrushTip { fn default() -> Self { Self { style: BrushStyle::Round, size: 10.0, opacity: 1.0, hardness: 0.8, spacing: 0.25, flow: 1.0, jitter_amount: 0.0, scatter_amount: 0.0, angle: 0.0, roundness: 1.0, spray_particle_size: 2.0, spray_density: 100, bitmap_pixels: Vec::new(), bitmap_w: 0, bitmap_h: 0, color_variant: false, variant_amount: 0.0, density: 1.0, drawing_angle: false, rotation_random: 0.0, } } } use rand::Rng; use rand_distr::{Distribution, Normal}; /// Generate procedural brush stamp alpha mask. /// Output: `Vec` of size `diameter * diameter`, values 0-255. pub fn generate_brush_stamp(tip: &BrushTip) -> Vec { let d = (tip.size * 2.0).ceil() as usize; let r = tip.size; let center = d as f32 / 2.0; match tip.style { BrushStyle::Round => generate_round_stamp(d, center, r, tip.hardness), BrushStyle::Square => generate_square_stamp(d, center, r, tip.hardness), BrushStyle::Star => generate_star_stamp(d, center, r, tip.hardness), BrushStyle::SoftRound => generate_round_stamp(d, center, r, 0.0), BrushStyle::HardRound => generate_round_stamp(d, center, r, 1.0), BrushStyle::Bitmap => { if tip.bitmap_pixels.is_empty() || tip.bitmap_w == 0 || tip.bitmap_h == 0 { generate_round_stamp(d, center, r, tip.hardness) } else { scale_bitmap_to_stamp( &tip.bitmap_pixels, tip.bitmap_w as usize, tip.bitmap_h as usize, d, ) } } _ => generate_round_stamp(d, center, r, tip.hardness), } } fn scale_bitmap_to_stamp(src: &[u8], src_w: usize, src_h: usize, dst_d: usize) -> Vec { if src_w == dst_d && src_h == dst_d { return src.to_vec(); } let mut dst = vec![0u8; dst_d * dst_d]; for dy in 0..dst_d { for dx in 0..dst_d { let sx = (dx as f32 * src_w as f32 / dst_d as f32) as usize; let sy = (dy as f32 * src_h as f32 / dst_d as f32) as usize; let si = sy.min(src_h.saturating_sub(1)) * src_w + sx.min(src_w.saturating_sub(1)); dst[dy * dst_d + dx] = src[si]; } } dst } fn generate_round_stamp(d: usize, center: f32, r: f32, hardness: f32) -> Vec { let r2 = r * r; (0..d * d) .map(|i| { let x = (i % d) as f32 - center; let y = (i / d) as f32 - center; let dist2 = x * x + y * y; if dist2 >= r2 { 0 } else { let dist = dist2.sqrt(); let t = (dist / r).clamp(0.0, 1.0); let alpha = if hardness >= 1.0 { if t < 1.0 { 1.0 } else { 0.0 } } else { let smooth = 1.0 - t * t * (3.0 - 2.0 * t); smooth.powf(1.0 - hardness.clamp(0.0, 0.99)) }; (alpha * 255.0).round() as u8 } }) .collect() } fn generate_square_stamp(d: usize, center: f32, r: f32, hardness: f32) -> Vec { (0..d * d) .map(|i| { let x = ((i % d) as f32 - center).abs(); let y = ((i / d) as f32 - center).abs(); let max_dist = x.max(y); if max_dist >= r { 0 } else { let t = (max_dist / r).clamp(0.0, 1.0); let alpha = if hardness >= 1.0 { if t < 1.0 { 1.0 } else { 0.0 } } else { let smooth = 1.0 - t; smooth.powf(1.0 - hardness.clamp(0.0, 0.99)) }; (alpha * 255.0).round() as u8 } }) .collect() } fn generate_star_stamp(d: usize, center: f32, r: f32, hardness: f32) -> Vec { let arm_count = 4u32; let arm_width = r * 0.22; let inner_r = r * 0.08; (0..d * d) .map(|i| { let x = (i % d) as f32 - center; let y = (i / d) as f32 - center; let angle = y.atan2(x); let dist = (x * x + y * y).sqrt(); let point_angle = std::f32::consts::PI * 2.0 / arm_count as f32; let a = angle.rem_euclid(point_angle); let arm_center = point_angle / 2.0; let arm_dist = (a - arm_center).abs(); let max_arm_dist = arm_width / r; if dist > r { 0 } else if arm_dist < max_arm_dist && dist > inner_r { let arm_falloff = 1.0 - (arm_dist / max_arm_dist); let radial = ((dist - inner_r) / (r - inner_r)).clamp(0.0, 1.0); let alpha = arm_falloff * (1.0 - radial.powf(1.0 - hardness.clamp(0.0, 0.99))); (alpha * 255.0).round() as u8 } else if dist <= inner_r { let alpha = (1.0 - dist / inner_r).max(0.5); (alpha * 255.0).round() as u8 } else { 0 } }) .collect() } pub fn sample_stamp(stamp: &[u8], diameter: usize, x: f32, y: f32) -> f32 { let ix = x.floor() as i32; let iy = y.floor() as i32; let fx = x - x.floor(); let fy = y - y.floor(); let get = |x: i32, y: i32| -> f32 { if x < 0 || y < 0 || x >= diameter as i32 || y >= diameter as i32 { 0.0 } else { stamp[(y as usize) * diameter + (x as usize)] as f32 / 255.0 } }; let v00 = get(ix, iy); let v10 = get(ix + 1, iy); let v01 = get(ix, iy + 1); let v11 = get(ix + 1, iy + 1); let top = v00 * (1.0 - fx) + v10 * fx; let bot = v01 * (1.0 - fx) + v11 * fx; top * (1.0 - fy) + bot * fy } pub fn brush_spacing_pixels(size: f32, spacing_ratio: f32) -> f32 { size * spacing_ratio.clamp(0.01, 5.0) } pub fn jitter_offset(jitter_amount: f32, size: f32) -> (f32, f32) { if jitter_amount <= 0.0 { return (0.0, 0.0); } let dist = jitter_amount * size * 0.5; let angle = rand::random::() * std::f32::consts::PI * 2.0; (angle.cos() * dist, angle.sin() * dist) } pub fn scatter_offset(scatter_amount: f32, size: f32) -> (f32, f32) { if scatter_amount <= 0.0 { return (0.0, 0.0); } let dist = scatter_amount * size * 0.3; let angle = rand::random::() * std::f32::consts::PI * 2.0; (angle.cos() * dist, angle.sin() * dist) } // ───────────────────────────────────────────────────────────────────────────── // Specialized Brush Implementations (ported from V2 hcie-drawing/src/drawing.rs) // ───────────────────────────────────────────────────────────────────────────── /// Unified brush dab dispatcher. Each specialized brush function handles its own /// pressure application so `size` and `opacity` here must be RAW (pre-pressure). /// `mask`: optional selection mask constraining drawing to selected pixels. #[allow(clippy::too_many_arguments)] fn draw_brush_style_dab( pixels: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, size: f32, pressure: f32, color: [u8; 4], opacity: f32, brush_style: BrushStyle, is_eraser: bool, sketch_history: &mut Option<&mut Vec<(f32, f32)>>, spray_particle_size: f32, spray_density: u32, brush_hardness: f32, mask: Option<&[u8]>, mut stroke_mask: Option<&mut [u8]>, bg_pixels: Option<&[u8]>, max_stroke_opacity: f32, color_variant: bool, variant_amount: f32, density: f32, angle: f32, roundness: f32, rotation_random: f32, drawing_angle: bool, ) { let pressure = pressure.clamp(0.0, 1.0); if pressure <= 0.0 || size <= 0.0 || opacity <= 0.0 || width == 0 || height == 0 { return; } let color = if color_variant && variant_amount > 0.0 { let mut rng = rand::thread_rng(); vary_color_hsl(color, variant_amount, &mut rng) } else { color }; match brush_style { BrushStyle::Noise => draw_grain_brush( pixels, width, height, cx, cy, size, pressure, color, opacity, brush_hardness, is_eraser, mask, density, false, ), BrushStyle::Texture => draw_grain_brush( pixels, width, height, cx, cy, size, pressure, color, opacity, brush_hardness, is_eraser, mask, density, true, ), BrushStyle::Pen | BrushStyle::InkPen => draw_pen_brush( pixels, width, height, cx, cy, size, pressure, color, opacity, if brush_style == BrushStyle::InkPen { roundness.min(0.38) } else { roundness }, angle, is_eraser, mask, stroke_mask, bg_pixels, max_stroke_opacity, ), BrushStyle::Oil => draw_oil_brush_oriented( pixels, width, height, cx, cy, size, pressure, color, opacity, brush_hardness, angle, is_eraser, mask, ), BrushStyle::Charcoal => draw_charcoal_brush( pixels, width, height, cx, cy, size, pressure, color, opacity, brush_hardness, is_eraser, mask, ), BrushStyle::Watercolor => draw_watercolor_brush_media( pixels, width, height, cx, cy, size, pressure, color, opacity, brush_hardness, is_eraser, mask, density, ), BrushStyle::Calligraphy => draw_calligraphy_brush( pixels, width, height, cx, cy, size, pressure, color, opacity, brush_hardness, angle, roundness, is_eraser, mask, ), BrushStyle::Marker => draw_marker_brush( pixels, width, height, cx, cy, size, pressure, color, opacity, brush_hardness, angle, roundness, is_eraser, mask, ), BrushStyle::Glow => draw_glow_brush( pixels, width, height, cx, cy, size, pressure, color, opacity, brush_hardness, is_eraser, mask, ), BrushStyle::Airbrush => draw_airbrush( pixels, width, height, cx, cy, size, pressure, color, opacity, brush_hardness, is_eraser, mask, ), BrushStyle::Spray => draw_spray_brush( pixels, width, height, cx, cy, size, spray_particle_size, (spray_density as f32 * (0.5 + (size / 100.0).min(0.5))) as u32, pressure, color, opacity, brush_hardness, is_eraser, mask, ), BrushStyle::Pencil => draw_pencil_brush( pixels, width, height, cx, cy, size, pressure, color, opacity, brush_hardness, is_eraser, mask, ), BrushStyle::Square => draw_square_brush( pixels, width, height, cx, cy, size, pressure, color, opacity, brush_hardness, is_eraser, mask, ), BrushStyle::WetPaint => draw_wetpaint_brush( pixels, width, height, cx, cy, size, pressure, color, opacity, brush_hardness, is_eraser, mask, ), BrushStyle::Leaf => draw_leaf_brush( pixels, width, height, cx, cy, size, pressure, color, opacity, brush_hardness, is_eraser, mask, color_variant, variant_amount, density, ), BrushStyle::Mixer => draw_mixer_brush( pixels, width, height, cx, cy, size, pressure, color, opacity, brush_hardness, is_eraser, mask, ), BrushStyle::Blender => { draw_blender_brush(pixels, width, height, cx, cy, size, pressure, opacity, mask) } BrushStyle::Crayon => draw_crayon_brush( pixels, width, height, cx, cy, size, pressure, color, opacity, brush_hardness, is_eraser, mask, ), BrushStyle::Tree => draw_tree_brush( pixels, width, height, cx, cy, size, pressure, color, opacity, brush_hardness, is_eraser, mask, ), BrushStyle::Meadow => draw_meadow_brush( pixels, width, height, cx, cy, size, pressure, color, opacity, brush_hardness, is_eraser, mask, color_variant, variant_amount, density, ), BrushStyle::Dirt => draw_dirt_brush( pixels, width, height, cx, cy, size, pressure, color, opacity, brush_hardness, is_eraser, mask, color_variant, variant_amount, density, ), BrushStyle::Star => draw_star_brush( pixels, width, height, cx, cy, size, pressure, color, opacity, brush_hardness, is_eraser, mask, ), BrushStyle::Rock => draw_rock_brush( pixels, width, height, cx, cy, size, pressure, color, opacity, brush_hardness, is_eraser, mask, ), BrushStyle::Clouds => draw_clouds_brush( pixels, width, height, cx, cy, size, pressure, color, opacity, brush_hardness, is_eraser, mask, ), BrushStyle::Bristle => draw_bristle_brush_oriented( pixels, width, height, cx, cy, size, pressure, color, opacity, brush_hardness, angle, is_eraser, mask, ), BrushStyle::Wood => draw_wood_brush( pixels, width, height, cx, cy, size, pressure, color, opacity, brush_hardness, is_eraser, mask, ), BrushStyle::Sketch => { if let Some(ref mut history) = sketch_history { draw_sketch_brush( pixels, width, height, cx, cy, size, pressure, color, opacity, brush_hardness, is_eraser, history, mask, ); } } BrushStyle::Hatch => draw_hatch_brush( pixels, width, height, cx, cy, size, pressure, color, opacity, brush_hardness, is_eraser, mask, ), _ => { // Apply angle / roundness / rotation / drawing-angle for the // default round-style fallback path. let effective_angle = if drawing_angle { angle } else { angle }; let effective_roundness = roundness.clamp(0.01, 1.0); if effective_roundness >= 0.9995 && rotation_random <= 0.0 && !drawing_angle { draw_dab_capped( pixels, width, height, cx, cy, size * pressure.max(0.1), brush_hardness, color, opacity * pressure, is_eraser, mask, stroke_mask.as_deref_mut(), bg_pixels, max_stroke_opacity, ); } else { let mut rng = rand::thread_rng(); let rot = effective_angle + if rotation_random > 0.0 { rng.gen::() * std::f32::consts::TAU * rotation_random } else { 0.0 }; draw_dab_rotated( pixels, width, height, cx, cy, size * pressure.max(0.1), brush_hardness, rot, effective_roundness, color, opacity * pressure, is_eraser, mask, ); // For stroke-mask builds, also accumulate the same footprint // into the stroke mask if requested so eraser strokes stay // consistent with the capped path. if let Some(ref mut sm) = stroke_mask { accumulate_rotated_stroke_mask( sm, width, height, cx, cy, size * pressure.max(0.1), brush_hardness, rot, effective_roundness, max_stroke_opacity, mask, ); } } } } } /// Compute the effective dab angle for a brush stamp. /// /// **Purpose:** Combines the base brush angle, optional stroke-segment /// direction (`drawing_angle`), and a random rotation offset /// (`rotation_random`) into the final angle used for elliptical stamping. /// /// **Arguments:** /// - `tip`: The brush tip carrying base angle, `drawing_angle`, and `rotation_random`. /// - `dx`, `dy`: Stroke segment direction vector from the last dab to this dab. /// /// **Returns:** Effective angle in radians. #[inline] pub fn effective_dab_angle(tip: &BrushTip, dx: f32, dy: f32) -> f32 { let base = if tip.drawing_angle { if dx == 0.0 && dy == 0.0 { tip.angle } else { dy.atan2(dx) } } else { tip.angle }; if tip.rotation_random > 0.0 { let offset = rand::random::() * std::f32::consts::TAU * tip.rotation_random; base + offset } else { base } } /// Apply a single brush dab with pressure sensitivity support. /// `mask`: optional selection mask — pixels with mask value 0 are skipped. #[allow(clippy::too_many_arguments)] pub fn draw_dab( pixels: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, size: f32, hardness: f32, color: [u8; 4], opacity: f32, is_eraser: bool, mask: Option<&[u8]>, ) { if width == 0 || height == 0 || pixels.len() < width as usize * height as usize * 4 { return; } draw_dab_with_stamp( pixels, width, height, cx, cy, size, hardness, color, opacity, is_eraser, mask, None, ); } /// Accumulates a rotated dab into the one-byte-per-pixel stroke opacity mask. #[allow(clippy::too_many_arguments)] fn accumulate_rotated_stroke_mask( stroke_mask: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, size: f32, hardness: f32, angle: f32, roundness: f32, opacity: f32, selection_mask: Option<&[u8]>, ) { if width == 0 || height == 0 || stroke_mask.len() < width as usize * height as usize { return; } let radius = (size * 0.5).max(0.5); let short_radius = radius * roundness.clamp(0.01, 1.0); let x_min = ((cx - radius).floor() as i32).max(0).min(width as i32 - 1); let x_max = ((cx + radius).ceil() as i32).max(0).min(width as i32 - 1); let y_min = ((cy - radius).floor() as i32).max(0).min(height as i32 - 1); let y_max = ((cy + radius).ceil() as i32).max(0).min(height as i32 - 1); let cos_angle = angle.cos(); let sin_angle = angle.sin(); let hard_squared = (radius * hardness.clamp(0.0, 1.0)).powi(2); let radius_squared = radius * radius; let soft_range_inverse = 1.0 / (radius_squared - hard_squared + 1e-6); for y in y_min..=y_max { for x in x_min..=x_max { let dx = x as f32 - cx; let dy = y as f32 - cy; let local_x = dx * cos_angle + dy * sin_angle; let local_y = -dx * sin_angle + dy * cos_angle; let normalized_squared = (local_x / radius).powi(2) + (local_y / short_radius.max(1e-3)).powi(2); if normalized_squared > 1.0 { continue; } let index = y as usize * width as usize + x as usize; let selection = selection_mask .and_then(|mask| mask.get(index)) .copied() .unwrap_or(255) as f32 / 255.0; let distance_squared = normalized_squared * radius_squared; let falloff = if distance_squared <= hard_squared { 1.0 } else { (1.0 - (distance_squared - hard_squared) * soft_range_inverse).clamp(0.0, 1.0) }; let alpha = (falloff * opacity * selection * 255.0) .round() .clamp(0.0, 255.0) as u8; stroke_mask[index] = stroke_mask[index].max(alpha); } } } /// Apply a single brush dab, optionally using a precomputed bitmap stamp. /// When `stamp` is provided it overrides the procedural circle/square shape. /// `stamp_diameter` is the width/height of the square stamp buffer. #[allow(clippy::too_many_arguments)] fn draw_dab_with_stamp( pixels: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, size: f32, hardness: f32, color: [u8; 4], opacity: f32, is_eraser: bool, mask: Option<&[u8]>, stamp: Option<&[u8]>, ) { let r = (size / 2.0).max(0.5); let r_sq = r * r; let hard_sq = (r * hardness).powi(2); let soft_range_inv = 1.0 / (r_sq - hard_sq + 1e-6); let x_min = ((cx - r).floor() as i32).max(0).min(width as i32 - 1); let x_max = ((cx + r).ceil() as i32).max(0).min(width as i32 - 1); let y_min = ((cy - r).floor() as i32).max(0).min(height as i32 - 1); let y_max = ((cy + r).ceil() as i32).max(0).min(height as i32 - 1); let [fr, fg, fb, _] = color; // If a stamp is supplied, scale its local coordinates to the requested dab size. let stamp_d = if let Some(s) = stamp { (s.len() as f32).sqrt().round() as usize } else { 0 }; let stamp_f = stamp_d as f32; for py in y_min..=y_max { for px in x_min..=x_max { let dx = px as f32 - cx; let dy = py as f32 - cy; let d_sq = dx * dx + dy * dy; if d_sq > r_sq { continue; } let idx = (py as usize) * (width as usize) + (px as usize); let mask_val = if let Some(m) = mask { *m.get(idx).unwrap_or(&0) } else { 255 }; if mask_val == 0 { continue; } let alpha_factor = if let Some(s) = stamp { if stamp_d == 0 { 0.0 } else { // Map dab footprint back into stamp coordinates. let sx = ((dx + r) * stamp_f / size).round() as isize; let sy = ((dy + r) * stamp_f / size).round() as isize; if sx < 0 || sy < 0 || sx as usize >= stamp_d || sy as usize >= stamp_d { 0.0 } else { s[(sy as usize) * stamp_d + (sx as usize)] as f32 / 255.0 } } } else if d_sq <= hard_sq { 1.0 } else { (1.0 - (d_sq - hard_sq) * soft_range_inv).clamp(0.0, 1.0) }; if alpha_factor <= 0.0 { continue; } let dab_alpha = alpha_factor * opacity * (color[3] as f32 / 255.0) * (mask_val as f32 / 255.0); let brush_alpha = (dab_alpha * 255.0).round() as u8; let i = idx * 4; if is_eraser { let da = pixels[i + 3] as f32 / 255.0; let ba = brush_alpha as f32 / 255.0; pixels[i + 3] = (da * (1.0 - ba) * 255.0).round() as u8; } else { let bg = [pixels[i], pixels[i + 1], pixels[i + 2], pixels[i + 3]]; let out = alpha_blend(bg, [fr, fg, fb, brush_alpha]); pixels[i] = out[0]; pixels[i + 1] = out[1]; pixels[i + 2] = out[2]; pixels[i + 3] = out[3]; } } } } /// Draw an elliptical brush dab with rotation. /// /// **Purpose:** Supports the `angle` and `roundness` BrushTip fields by scaling /// the dab footprint along the effective angle direction. /// /// **Logic & Workflow:** /// 1. Determine the effective angle via `effective_dab_angle`. /// 2. Iterate over the axis-aligned bounding box of the rotated ellipse. /// 3. For each pixel, transform its offset into the ellipse's local coordinate /// system (inverse rotation + inverse roundness scaling) and test against the /// unit circle for falloff. /// 4. Blend the resulting alpha using the same falloff model as `draw_dab`. #[allow(clippy::too_many_arguments)] pub fn draw_dab_rotated( pixels: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, size: f32, hardness: f32, angle: f32, roundness: f32, color: [u8; 4], opacity: f32, is_eraser: bool, mask: Option<&[u8]>, ) { if width == 0 || height == 0 || pixels.len() < width as usize * height as usize * 4 { return; } // Unrotated radii: roundness scales the radius perpendicular to the angle. let r = (size / 2.0).max(0.5); let r_long = r; let r_short = r * roundness.clamp(0.01, 1.0); // Bounding radius after rotation is the long axis (safe over-estimate). let bbox_r = r_long; let x_min = ((cx - bbox_r).floor() as i32).max(0).min(width as i32 - 1); let x_max = ((cx + bbox_r).ceil() as i32).max(0).min(width as i32 - 1); let y_min = ((cy - bbox_r).floor() as i32).max(0).min(height as i32 - 1); let y_max = ((cy + bbox_r).ceil() as i32).max(0).min(height as i32 - 1); let cos_a = angle.cos(); let sin_a = angle.sin(); let hard_sq = (r_long * hardness).powi(2); let r_long_sq = r_long * r_long; let soft_range_inv = 1.0 / (r_long_sq - hard_sq + 1e-6); let [fr, fg, fb, _] = color; for py in y_min..=y_max { for px in x_min..=x_max { let dx = px as f32 - cx; let dy = py as f32 - cy; // Inverse rotate by -angle. let lx = dx * cos_a + dy * sin_a; let ly = -dx * sin_a + dy * cos_a; // Scale to unit circle using roundness. let ux = lx / r_long; let uy = ly / r_short.max(1e-3); let d_sq = ux * ux + uy * uy; if d_sq > 1.0 { continue; } let idx = (py as usize) * (width as usize) + (px as usize); let mask_val = if let Some(m) = mask { *m.get(idx).unwrap_or(&0) } else { 255 }; if mask_val == 0 { continue; } let alpha_factor = if d_sq * r_long_sq <= hard_sq { 1.0 } else { (1.0 - (d_sq * r_long_sq - hard_sq) * soft_range_inv).clamp(0.0, 1.0) }; let dab_alpha = alpha_factor * opacity * (color[3] as f32 / 255.0) * (mask_val as f32 / 255.0); let brush_alpha = (dab_alpha * 255.0).round() as u8; let i = idx * 4; if is_eraser { let da = pixels[i + 3] as f32 / 255.0; let ba = brush_alpha as f32 / 255.0; pixels[i + 3] = (da * (1.0 - ba) * 255.0).round() as u8; } else { let bg = [pixels[i], pixels[i + 1], pixels[i + 2], pixels[i + 3]]; let out = alpha_blend(bg, [fr, fg, fb, brush_alpha]); pixels[i] = out[0]; pixels[i + 1] = out[1]; pixels[i + 2] = out[2]; pixels[i + 3] = out[3]; } } } } /// Apply a single brush dab with pressure sensitivity support, background pixels, and stroke-level alpha mask. /// `mask`: optional selection mask — pixels with mask value 0 are skipped. #[allow(clippy::too_many_arguments)] pub fn draw_dab_capped( pixels: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, size: f32, hardness: f32, color: [u8; 4], opacity: f32, is_eraser: bool, mask: Option<&[u8]>, stroke_mask: Option<&mut [u8]>, bg_pixels: Option<&[u8]>, max_stroke_opacity: f32, ) { draw_dab_capped_with_stamp( pixels, width, height, cx, cy, size, hardness, color, opacity, is_eraser, mask, stroke_mask, bg_pixels, max_stroke_opacity, None, ); } /// `draw_dab_capped` variant that uses a pre-generated bitmap stamp for the dab shape. #[allow(clippy::too_many_arguments)] pub fn draw_dab_capped_with_stamp( pixels: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, size: f32, hardness: f32, color: [u8; 4], opacity: f32, is_eraser: bool, mask: Option<&[u8]>, mut stroke_mask: Option<&mut [u8]>, bg_pixels: Option<&[u8]>, max_stroke_opacity: f32, stamp: Option<&[u8]>, ) { if stroke_mask.is_none() || bg_pixels.is_none() { draw_dab_with_stamp( pixels, width, height, cx, cy, size, hardness, color, opacity, is_eraser, mask, stamp, ); return; } let r = (size / 2.0).max(0.5); let r_sq = r * r; let hard_sq = (r * hardness).powi(2); let soft_range_inv = 1.0 / (r_sq - hard_sq + 1e-6); let x_min = ((cx - r).floor() as i32).max(0).min(width as i32 - 1); let x_max = ((cx + r).ceil() as i32).max(0).min(width as i32 - 1); let y_min = ((cy - r).floor() as i32).max(0).min(height as i32 - 1); let y_max = ((cy + r).ceil() as i32).max(0).min(height as i32 - 1); let [fr, fg, fb, _] = color; let stroke_mask_ref = stroke_mask.as_mut().unwrap(); let bg = bg_pixels.unwrap(); // Bitmap stamp support: if a stamp is provided, use it as the per-pixel alpha mask. let stamp_d = if let Some(s) = stamp { (s.len() as f32).sqrt().round() as usize } else { 0 }; let stamp_f = stamp_d as f32; for py in y_min..=y_max { for px in x_min..=x_max { let dx = px as f32 - cx; let dy = py as f32 - cy; let d_sq = dx * dx + dy * dy; if d_sq > r_sq { continue; } let idx = (py as usize) * (width as usize) + (px as usize); let mask_val = if let Some(m) = mask { *m.get(idx).unwrap_or(&0) } else { 255 }; if mask_val == 0 { continue; } let alpha_factor = if let Some(s) = stamp { if stamp_d == 0 { 0.0 } else { let sx = ((dx + r) * stamp_f / size).round() as isize; let sy = ((dy + r) * stamp_f / size).round() as isize; if sx < 0 || sy < 0 || sx as usize >= stamp_d || sy as usize >= stamp_d { 0.0 } else { s[(sy as usize) * stamp_d + (sx as usize)] as f32 / 255.0 } } } else if d_sq <= hard_sq { 1.0 } else { (1.0 - (d_sq - hard_sq) * soft_range_inv).clamp(0.0, 1.0) }; if alpha_factor <= 0.0 { continue; } let dab_alpha = alpha_factor * opacity * (color[3] as f32 / 255.0) * (mask_val as f32 / 255.0); let i = idx * 4; let current_stroke_a = stroke_mask_ref[idx] as f32 / 255.0; // Blend dab alpha into the accumulated stroke mask (capped at max_stroke_opacity) let new_stroke_a = (current_stroke_a + dab_alpha * (1.0 - current_stroke_a)).min(max_stroke_opacity); stroke_mask_ref[idx] = (new_stroke_a * 255.0).round() as u8; if is_eraser { let bg_a = bg[i + 3] as f32 / 255.0; pixels[i + 3] = (bg_a * (1.0 - new_stroke_a) * 255.0).round() as u8; } else { // Blend brush color onto original background pixel (bg) using the capped new_stroke_a let bg_pixel = [bg[i], bg[i + 1], bg[i + 2], bg[i + 3]]; let brush_stamp = [fr, fg, fb, (new_stroke_a * 255.0).round() as u8]; let out = alpha_blend(bg_pixel, brush_stamp); pixels[i] = out[0]; pixels[i + 1] = out[1]; pixels[i + 2] = out[2]; pixels[i + 3] = out[3]; } } } } /// Mixes two opaque pigment colors in optical-density space. /// /// **Arguments:** `base` is sampled canvas pigment, `paint` is loaded brush pigment, and `amount` /// is the paint fraction. **Returns:** A subtractive-looking RGB mixture. **Side Effects:** None. pub fn mix_pigments(base: [u8; 3], paint: [u8; 3], amount: f32) -> [u8; 3] { let amount = amount.clamp(0.0, 1.0); if amount <= 0.0 { return base; } if amount >= 1.0 { return paint; } let mix_channel = |a: u8, b: u8| { let reflect_a = (a as f32 / 255.0).powf(2.2).max(0.003); let reflect_b = (b as f32 / 255.0).powf(2.2).max(0.003); let density = -reflect_a.ln() * (1.0 - amount) - reflect_b.ln() * amount; ((-density).exp().powf(1.0 / 2.2) * 255.0) .round() .clamp(0.0, 255.0) as u8 }; [ mix_channel(base[0], paint[0]), mix_channel(base[1], paint[1]), mix_channel(base[2], paint[2]), ] } /// Samples a premultiplied-alpha neighborhood without allowing one outlier to dominate. fn sample_neighborhood_color( pixels: &[u8], width: u32, height: u32, cx: f32, cy: f32, radius: i32, ) -> Option<[u8; 3]> { let mut rgb = [0.0f32; 3]; let mut weight = 0.0f32; let center_x = cx.round() as i32; let center_y = cy.round() as i32; for y in center_y - radius..=center_y + radius { for x in center_x - radius..=center_x + radius { if x < 0 || y < 0 || x >= width as i32 || y >= height as i32 { continue; } let index = (y as usize * width as usize + x as usize) * 4; let alpha = pixels[index + 3] as f32 / 255.0; if alpha <= 0.01 { continue; } for channel in 0..3 { rgb[channel] += pixels[index + channel] as f32 * alpha; } weight += alpha; } } (weight > 0.0).then(|| { [ (rgb[0] / weight).round() as u8, (rgb[1] / weight).round() as u8, (rgb[2] / weight).round() as u8, ] }) } /// Applies a deterministic paper-grain or woven-texture footprint. #[allow(clippy::too_many_arguments)] fn draw_grain_brush( pixels: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, size: f32, pressure: f32, color: [u8; 4], opacity: f32, hardness: f32, is_eraser: bool, mask: Option<&[u8]>, density: f32, woven: bool, ) { let radius = size * pressure * 0.5; if radius < 0.5 { return; } let threshold = (0.72 - density.clamp(0.2, 2.0) * 0.24).clamp(0.12, 0.68); let x0 = (cx - radius).floor().max(0.0) as u32; let x1 = (cx + radius).ceil().min(width.saturating_sub(1) as f32) as u32; let y0 = (cy - radius).floor().max(0.0) as u32; let y1 = (cy + radius).ceil().min(height.saturating_sub(1) as f32) as u32; let seed_x = (cx.to_bits().wrapping_mul(73_856_093) ^ cy.to_bits().wrapping_mul(19_349_663)) as u32; let seed_y = seed_x.wrapping_mul(668_265_263); for y in y0..=y1 { for x in x0..=x1 { let dx = x as f32 - cx; let dy = y as f32 - cy; let distance = (dx * dx + dy * dy).sqrt() / radius; if distance > 1.0 { continue; } let ox = x.wrapping_add(seed_x); let oy = y.wrapping_add(seed_y); let hash = ((ox.wrapping_mul(73_856_093) ^ oy.wrapping_mul(19_349_663)) & 1023) as f32 / 1023.0; let texture = if woven { 0.55 + 0.45 * ((x as f32 * 0.42 + seed_x as f32 * 0.001).sin() * (y as f32 * 0.31 + seed_y as f32 * 0.001).cos()) .abs() } else { hash }; let grain_alpha = if texture < threshold { (texture / threshold).clamp(0.0, 1.0) } else { 0.55 + 0.45 * texture }; let falloff = if distance <= hardness { 1.0 } else { ((1.0 - distance) / (1.0 - hardness.clamp(0.0, 0.99))).clamp(0.0, 1.0) }; let index = (y * width + x) as usize; let mask_alpha = mask .and_then(|selection| selection.get(index)) .copied() .unwrap_or(255) as f32 / 255.0; let alpha = opacity * pressure * falloff * mask_alpha * grain_alpha * (color[3] as f32 / 255.0); let offset = index * 4; if is_eraser { pixels[offset + 3] = (pixels[offset + 3] as f32 * (1.0 - alpha)).round() as u8; } else { let out = alpha_blend( [ pixels[offset], pixels[offset + 1], pixels[offset + 2], pixels[offset + 3], ], [color[0], color[1], color[2], (alpha * 255.0).round() as u8], ); pixels[offset..offset + 4].copy_from_slice(&out); } } } } /// Renders a hard technical or narrow ink nib. #[allow(clippy::too_many_arguments)] fn draw_pen_brush( pixels: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, size: f32, pressure: f32, color: [u8; 4], opacity: f32, roundness: f32, angle: f32, is_eraser: bool, mask: Option<&[u8]>, stroke_mask: Option<&mut [u8]>, bg_pixels: Option<&[u8]>, max_stroke_opacity: f32, ) { if let Some(stroke_mask) = stroke_mask { draw_dab_rotated_capped( pixels, width, height, cx, cy, size * pressure, 0.98, angle, roundness.clamp(0.08, 1.0), color, opacity * pressure, is_eraser, mask, stroke_mask, bg_pixels, max_stroke_opacity, ); } else { draw_dab_rotated( pixels, width, height, cx, cy, size * pressure, 0.98, angle, roundness.clamp(0.08, 1.0), color, opacity * pressure, is_eraser, mask, ); } } /// Draws a rotated nib against the stroke-start buffer while capping cumulative opacity. #[allow(clippy::too_many_arguments)] fn draw_dab_rotated_capped( pixels: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, size: f32, hardness: f32, angle: f32, roundness: f32, color: [u8; 4], opacity: f32, is_eraser: bool, selection_mask: Option<&[u8]>, stroke_mask: &mut [u8], background: Option<&[u8]>, max_stroke_opacity: f32, ) { let pixel_count = width as usize * height as usize; if width == 0 || height == 0 || pixels.len() < pixel_count * 4 || stroke_mask.len() < pixel_count { return; } let radius = (size * 0.5).max(0.5); let short_radius = radius * roundness.clamp(0.01, 1.0); let x_min = ((cx - radius).floor() as i32).max(0).min(width as i32 - 1); let x_max = ((cx + radius).ceil() as i32).max(0).min(width as i32 - 1); let y_min = ((cy - radius).floor() as i32).max(0).min(height as i32 - 1); let y_max = ((cy + radius).ceil() as i32).max(0).min(height as i32 - 1); let cos_angle = angle.cos(); let sin_angle = angle.sin(); let radius_squared = radius * radius; let hard_squared = (radius * hardness.clamp(0.0, 1.0)).powi(2); let soft_range_inverse = 1.0 / (radius_squared - hard_squared + 1e-6); let opacity_cap = max_stroke_opacity.clamp(0.0, 1.0); for y in y_min..=y_max { for x in x_min..=x_max { let dx = x as f32 - cx; let dy = y as f32 - cy; let local_x = dx * cos_angle + dy * sin_angle; let local_y = -dx * sin_angle + dy * cos_angle; let normalized_squared = (local_x / radius).powi(2) + (local_y / short_radius.max(1e-3)).powi(2); if normalized_squared > 1.0 { continue; } let index = y as usize * width as usize + x as usize; let selection = selection_mask .and_then(|mask| mask.get(index)) .copied() .unwrap_or(255) as f32 / 255.0; if selection <= 0.0 { continue; } let distance_squared = normalized_squared * radius_squared; let falloff = if distance_squared <= hard_squared { 1.0 } else { (1.0 - (distance_squared - hard_squared) * soft_range_inverse).clamp(0.0, 1.0) }; let dab_alpha = falloff * opacity * selection * (color[3] as f32 / 255.0); let previous = stroke_mask[index] as f32 / 255.0; let cumulative = (previous + dab_alpha * (1.0 - previous)).min(opacity_cap); stroke_mask[index] = (cumulative * 255.0).round() as u8; let offset = index * 4; let base = background .and_then(|source| source.get(offset..offset + 4)) .map(|source| [source[0], source[1], source[2], source[3]]) .unwrap_or([ pixels[offset], pixels[offset + 1], pixels[offset + 2], pixels[offset + 3], ]); let output = if is_eraser { [ base[0], base[1], base[2], (base[3] as f32 * (1.0 - cumulative)).round() as u8, ] } else { alpha_blend( base, [ color[0], color[1], color[2], (cumulative * 255.0).round() as u8, ], ) }; pixels[offset..offset + 4].copy_from_slice(&output); } } } /// Renders one five-point star matching the generated stamp preview. #[allow(clippy::too_many_arguments)] fn draw_star_brush( pixels: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, size: f32, pressure: f32, color: [u8; 4], opacity: f32, hardness: f32, is_eraser: bool, mask: Option<&[u8]>, ) { let effective_size = size * pressure; let tip = BrushTip { style: BrushStyle::Star, size: effective_size * 0.5, hardness, ..BrushTip::default() }; let stamp = generate_brush_stamp(&tip); draw_dab_with_stamp( pixels, width, height, cx, cy, effective_size, hardness, color, opacity * pressure, is_eraser, mask, Some(&stamp), ); let sparkle_count = ((effective_size * 0.15) as u32).clamp(2, 8); let sparkle_r = effective_size * 0.12; let tip_sm = BrushTip { style: BrushStyle::Star, size: sparkle_r, hardness, ..BrushTip::default() }; let stamp_sm = generate_brush_stamp(&tip_sm); let seed = cx.to_bits().wrapping_mul(73_856_093) ^ cy.to_bits().wrapping_mul(19_349_663); for i in 0..sparkle_count { let hash_i = seed.wrapping_add(i.wrapping_mul(668_265_263)); let angle = (hash_i & 0xFFFF) as f32 / 65536.0 * std::f32::consts::TAU; let dist = ((hash_i >> 16) & 0xFFFF) as f32 / 65536.0 * effective_size * 0.6; let sx = cx + angle.cos() * dist; let sy = cy + angle.sin() * dist; draw_dab_with_stamp( pixels, width, height, sx, sy, sparkle_r * 2.0, hardness, color, opacity * pressure * 0.5, is_eraser, mask, Some(&stamp_sm), ); } } /// Apply oil paint brush - coherent, directional bristle tracks with pigment pickup. #[allow(clippy::too_many_arguments)] pub fn draw_oil_brush( pixels: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, size: f32, pressure: f32, color: [u8; 4], opacity: f32, hardness: f32, is_eraser: bool, mask: Option<&[u8]>, ) { draw_oil_brush_oriented( pixels, width, height, cx, cy, size, pressure, color, opacity, hardness, 0.0, is_eraser, mask, ); } /// Direction-aware oil renderer used by the stroke dispatcher. #[allow(clippy::too_many_arguments)] fn draw_oil_brush_oriented( pixels: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, size: f32, pressure: f32, color: [u8; 4], opacity: f32, _hardness: f32, angle: f32, is_eraser: bool, mask: Option<&[u8]>, ) { let mut rng = rand::thread_rng(); let effective_size = size * pressure; if effective_size < 0.5 { return; } let sampled = sample_neighborhood_color(pixels, width, height, cx, cy, 2); let mixed = sampled.map_or([color[0], color[1], color[2]], |base| { mix_pigments(base, [color[0], color[1], color[2]], 0.72) }); let mixed_color = [mixed[0], mixed[1], mixed[2], color[3]]; let bristle_count = ((effective_size / 4.0).round() as usize).clamp(4, 16); let tangent = (angle.cos(), angle.sin()); let normal = (-tangent.1, tangent.0); for index in 0..bristle_count { let lane = (index as f32 + 0.5) / bristle_count as f32 - 0.5; let offset = lane * effective_size * 0.82 + rng.gen_range(-0.4..0.4); let length = effective_size * rng.gen_range(0.24..0.48); let center_x = cx + normal.0 * offset; let center_y = cy + normal.1 * offset; let thickness = (effective_size / bristle_count as f32 * 0.55).max(0.45); draw_line_segment( pixels, width, height, center_x - tangent.0 * length * 0.5, center_y - tangent.1 * length * 0.5, center_x + tangent.0 * length * 0.5, center_y + tangent.1 * length * 0.5, thickness, thickness * rng.gen_range(0.45..0.9), mixed_color, opacity * pressure * rng.gen_range(0.65..1.0), is_eraser, mask, ); } } /// Apply charcoal brush - scattered particles #[allow(clippy::too_many_arguments)] pub fn draw_charcoal_brush( pixels: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, size: f32, pressure: f32, color: [u8; 4], opacity: f32, hardness: f32, is_eraser: bool, mask: Option<&[u8]>, ) { let mut rng = rand::thread_rng(); let effective_size = size * pressure; if effective_size < 0.5 { return; } draw_grain_brush( pixels, width, height, cx, cy, size, pressure, color, opacity * 0.72, hardness, is_eraser, mask, 0.75 + hardness * 0.65, false, ); let particle_count = ((effective_size / 10.0).round() as u32).clamp(2, 8); let radius = effective_size / 2.0; let particle_opacity = opacity * pressure * 0.22; for _ in 0..particle_count { let r = rng.gen::().sqrt() * radius; let theta = rng.gen::() * std::f32::consts::PI * 2.0; let px = cx + r * theta.cos(); let py = cy + r * theta.sin(); let p_size = (rng.gen::() * 2.0 + 1.0) * (effective_size / 20.0).max(1.0); draw_dab( pixels, width, height, px, py, p_size, hardness, color, particle_opacity, is_eraser, mask, ); } } /// Apply watercolor brush - very soft low-opacity #[allow(clippy::too_many_arguments)] pub fn draw_watercolor_brush( pixels: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, size: f32, pressure: f32, color: [u8; 4], opacity: f32, hardness: f32, is_eraser: bool, mask: Option<&[u8]>, ) { draw_watercolor_brush_media( pixels, width, height, cx, cy, size, pressure, color, opacity, hardness, is_eraser, mask, 1.0, ); } /// Renders watercolor with preset-controlled pigment density. #[allow(clippy::too_many_arguments)] fn draw_watercolor_brush_media( pixels: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, size: f32, pressure: f32, color: [u8; 4], opacity: f32, hardness: f32, is_eraser: bool, mask: Option<&[u8]>, density: f32, ) { let mut rng = rand::thread_rng(); draw_watercolor_brush_with_rng( pixels, width, height, cx, cy, size, pressure, color, opacity, hardness, is_eraser, mask, density, &mut rng, ); } /// Maximum raster dabs emitted for one interpolated watercolor sample. const WATERCOLOR_MAX_DABS_PER_SAMPLE: usize = 8; /// Computes bounded watercolor detail counts for one effective brush size. /// /// **Arguments:** `effective_size` is pressure-adjusted diameter and `rng` supplies the optional /// bloom decision. **Returns:** Satellite, splatter, and bloom counts whose total plus one core is /// at most eight. **Side Effects / Dependencies:** Advances the provided random generator once for /// eligible blooms. fn watercolor_detail_counts( effective_size: f32, density: f32, rng: &mut R, ) -> (usize, usize, usize) { let density = density.clamp(0.35, 1.75); let satellites = ((effective_size / 24.0 * density).ceil() as usize).clamp(1, 2); let splatters = ((effective_size / 16.0 * density).ceil() as usize).clamp(1, 4); let bloom = usize::from(effective_size > 25.0 && rng.gen_bool(0.3)); debug_assert!(1 + satellites + splatters + bloom <= WATERCOLOR_MAX_DABS_PER_SAMPLE); (satellites, splatters, bloom) } /// Renders one bounded watercolor sample using a caller-provided random generator. /// /// **Arguments:** Pixel target, dimensions, center, brush settings, optional selection mask, and /// random generator. **Returns:** Nothing. **Logic & Workflow:** Draws one irregular full-size core, /// up to two satellites, up to four tiny splatters, and at most one faint bloom. **Side Effects / /// Dependencies:** Mutates target pixels and advances `rng`; never exceeds eight raster dabs. #[allow(clippy::too_many_arguments)] fn draw_watercolor_brush_with_rng( pixels: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, size: f32, pressure: f32, color: [u8; 4], opacity: f32, hardness: f32, is_eraser: bool, mask: Option<&[u8]>, density: f32, rng: &mut R, ) { let effective_size = size * pressure; if effective_size < 0.5 { return; } let base_opacity = opacity * 0.3 * pressure * (0.82 + density.clamp(0.35, 1.75) * 0.18); let mixed_rgb = sample_neighborhood_color(pixels, width, height, cx, cy, 2) .map(|base| mix_pigments(base, [color[0], color[1], color[2]], 0.58)) .unwrap_or([color[0], color[1], color[2]]); let center_color = [mixed_rgb[0], mixed_rgb[1], mixed_rgb[2], color[3]]; let (satellite_count, splatter_count, bloom_count) = watercolor_detail_counts(effective_size, density, rng); // ── Central irregular core ────────────────────────────────────────────── let core_angle = rng.gen_range(0.0..std::f32::consts::TAU); let core_offset = effective_size * rng.gen_range(0.0..0.08); draw_dab( pixels, width, height, cx + core_angle.cos() * core_offset, cy + core_angle.sin() * core_offset, effective_size * rng.gen_range(0.95..1.1), hardness, center_color, base_opacity * rng.gen_range(0.85..1.15), is_eraser, mask, ); // ── Satellite puddles ─────────────────────────────────────────────────── // Secondary dabs pulled away from the stroke path by water tension, // giving the edge its characteristic ragged bloom. for _ in 0..satellite_count { let angle = rng.gen_range(0.0..std::f32::consts::TAU); let dist = effective_size * rng.gen_range(0.25..0.65); let dx = angle.cos() * dist; let dy = angle.sin() * dist; let s = effective_size * rng.gen_range(0.18..0.38); let a = base_opacity * rng.gen_range(0.25..0.75); draw_dab( pixels, width, height, cx + dx, cy + dy, s, hardness * rng.gen_range(0.5..1.0), center_color, a, is_eraser, mask, ); } // ── Tiny splatter particles ─────────────────────────────────────────── // Small droplets flung outward, visible on high-resolution strokes. for _ in 0..splatter_count { let angle = rng.gen_range(0.0..std::f32::consts::TAU); let dist = effective_size * rng.gen_range(0.45..1.15); let dx = angle.cos() * dist; let dy = angle.sin() * dist; let s = effective_size * rng.gen_range(0.03..0.14); let a = base_opacity * rng.gen_range(0.4..1.3); draw_dab( pixels, width, height, cx + dx, cy + dy, s, rng.gen_range(0.0..0.4), center_color, a, is_eraser, mask, ); } // ── Back-run / blossom bursts ─────────────────────────────────────────── // A few very large, faint "blooms" that extend beyond the main stroke, // simulating water pushing pigment to the edges. for _ in 0..bloom_count { let angle = rng.gen_range(0.0..std::f32::consts::TAU); let dist = effective_size * rng.gen_range(0.5..0.9); let dx = angle.cos() * dist; let dy = angle.sin() * dist; let s = effective_size * rng.gen_range(0.45..0.7); let a = base_opacity * rng.gen_range(0.15..0.35); draw_dab( pixels, width, height, cx + dx, cy + dy, s, 0.0, center_color, a, is_eraser, mask, ); } } /// Apply calligraphy brush - multiple parallel dabs #[allow(clippy::too_many_arguments)] pub fn draw_calligraphy_brush( pixels: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, size: f32, pressure: f32, color: [u8; 4], opacity: f32, hardness: f32, angle: f32, roundness: f32, is_eraser: bool, mask: Option<&[u8]>, ) { let effective_size = size * pressure; draw_dab_rotated( pixels, width, height, cx, cy, effective_size, hardness.max(0.85), angle, roundness.clamp(0.08, 0.45), color, opacity * pressure, is_eraser, mask, ); } /// Apply marker brush - solid coverage #[allow(clippy::too_many_arguments)] pub fn draw_marker_brush( pixels: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, size: f32, pressure: f32, color: [u8; 4], opacity: f32, hardness: f32, angle: f32, roundness: f32, is_eraser: bool, mask: Option<&[u8]>, ) { let effective_size = size * pressure; let particle_opacity = opacity * pressure * 0.72; draw_dab_rotated( pixels, width, height, cx, cy, effective_size, hardness.max(0.65), angle, roundness.clamp(0.25, 1.0), color, particle_opacity, is_eraser, mask, ); } /// Apply glow brush - multiple overlapping soft dabs #[allow(clippy::too_many_arguments)] pub fn draw_glow_brush( pixels: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, size: f32, pressure: f32, color: [u8; 4], opacity: f32, _hardness: f32, is_eraser: bool, mask: Option<&[u8]>, ) { let effective_size = size * pressure; let glow_opacity = opacity * pressure * 0.45; // Layer 1: Soft wide outer saturated color glow (hardness = 0.0) draw_dab( pixels, width, height, cx, cy, effective_size, 0.0, color, glow_opacity * 0.35, is_eraser, mask, ); // Layer 2: Moderately soft colored inner core glow (hardness = 0.25) draw_dab( pixels, width, height, cx, cy, effective_size * 0.65, 0.25, color, glow_opacity * 0.65, is_eraser, mask, ); // Layer 3: Central Central Central Central pure white core line (hardness = 0.85) if !is_eraser { draw_dab( pixels, width, height, cx, cy, effective_size * 0.25, 0.85, [255, 255, 255, 255], glow_opacity * 0.9, false, mask, ); } else { // If erasing, white core acts as a sharp eraser instead draw_dab( pixels, width, height, cx, cy, effective_size * 0.25, 0.85, color, glow_opacity * 0.9, true, mask, ); } } /// Apply airbrush - maximum softness #[allow(clippy::too_many_arguments)] pub fn draw_airbrush( pixels: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, size: f32, pressure: f32, color: [u8; 4], opacity: f32, hardness: f32, is_eraser: bool, mask: Option<&[u8]>, ) { let effective_size = size * pressure; let particle_opacity = opacity * pressure * 0.1; draw_dab( pixels, width, height, cx, cy, effective_size, hardness * 0.0, color, particle_opacity, is_eraser, mask, ); } /// Apply spray brush - Gaussian-distributed particles #[allow(clippy::too_many_arguments)] pub fn draw_spray_brush( pixels: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, size: f32, dot_size: f32, density: u32, pressure: f32, color: [u8; 4], opacity: f32, hardness: f32, is_eraser: bool, mask: Option<&[u8]>, ) { if size <= 0.0 || density == 0 || pressure <= 0.0 { return; } let mut rng = rand::thread_rng(); let normal = Normal::new(0.0, size / 4.0).unwrap(); let [fr, fg, fb, _] = color; let base_opacity = opacity * pressure; for _ in 0..density.min(512) { let dx = normal.sample(&mut rng); let dy = normal.sample(&mut rng); if dx.abs() > size || dy.abs() > size { continue; } let px = (cx + dx).round() as i32; let py = (cy + dy).round() as i32; if px < 0 || px >= width as i32 || py < 0 || py >= height as i32 { continue; } let ux = px as u32; let uy = py as u32; if let Some(m) = mask { let idx = (uy * width + ux) as usize; if m.get(idx).copied().unwrap_or(0) == 0 { continue; } } if dot_size <= 3.0 { let hardness_alpha = 0.3 + hardness * 0.7; let selection_alpha = mask .and_then(|selection| selection.get((uy * width + ux) as usize)) .copied() .unwrap_or(255) as f32 / 255.0; let brush_alpha = (base_opacity * (color[3] as f32 / 255.0) * selection_alpha * 255.0 * hardness_alpha) as u8; let i = (uy * width + ux) as usize * 4; if is_eraser { let da = pixels[i + 3] as f32 / 255.0; let ba = brush_alpha as f32 / 255.0; pixels[i + 3] = (da * (1.0 - ba) * 255.0).round() as u8; } else { let dst = [pixels[i], pixels[i + 1], pixels[i + 2], pixels[i + 3]]; let out = alpha_blend(dst, [fr, fg, fb, brush_alpha]); pixels[i] = out[0]; pixels[i + 1] = out[1]; pixels[i + 2] = out[2]; pixels[i + 3] = out[3]; } if dot_size > 1.2 { for dy in -1..=1 { for dx in -1..=1 { if dx == 0 && dy == 0 { continue; } let nx = ux as i32 + dx; let ny = uy as i32 + dy; if nx >= 0 && nx < width as i32 && ny >= 0 && ny < height as i32 { let nidx = (ny as u32 * width + nx as u32) as usize; if let Some(m) = mask { if m.get(nidx).copied().unwrap_or(0) == 0 { continue; } } let na = (brush_alpha as f32 * 0.3) as u8; let ni = nidx * 4; if is_eraser { let da = pixels[ni + 3] as f32 / 255.0; let ba = na as f32 / 255.0; pixels[ni + 3] = (da * (1.0 - ba) * 255.0).round() as u8; } else { let ndst = [pixels[ni], pixels[ni + 1], pixels[ni + 2], pixels[ni + 3]]; let nout = alpha_blend(ndst, [fr, fg, fb, na]); pixels[ni] = nout[0]; pixels[ni + 1] = nout[1]; pixels[ni + 2] = nout[2]; pixels[ni + 3] = nout[3]; } } } } } } else { let r = (dot_size / 2.0).ceil() as i32; let edge_factor = 0.5 + hardness * 0.5; let radius = dot_size / 2.0; let edge = radius * edge_factor; let falloff = 1.0 / (radius - edge + 1e-6); for dy in -r..=r { for dx in -r..=r { let d2 = (dx * dx + dy * dy) as f32; if d2 > (dot_size * dot_size / 4.0) { continue; } let nx = ux as i32 + dx; let ny = uy as i32 + dy; if nx < 0 || nx >= width as i32 || ny < 0 || ny >= height as i32 { continue; } let nidx = (ny as u32 * width + nx as u32) as usize; if let Some(m) = mask { if m.get(nidx).copied().unwrap_or(0) == 0 { continue; } } let dist = d2.sqrt(); let alpha_factor = if dist <= edge { 1.0 } else { (1.0 - (dist - edge) * falloff).clamp(0.0, 1.0) }; let selection_alpha = mask .and_then(|selection| selection.get(nidx)) .copied() .unwrap_or(255) as f32 / 255.0; let fa = (base_opacity * (color[3] as f32 / 255.0) * selection_alpha * alpha_factor * 255.0) as u8; let ni = nidx * 4; if is_eraser { let da = pixels[ni + 3] as f32 / 255.0; let ba = fa as f32 / 255.0; pixels[ni + 3] = (da * (1.0 - ba) * 255.0).round() as u8; } else { let ndst = [pixels[ni], pixels[ni + 1], pixels[ni + 2], pixels[ni + 3]]; let nout = alpha_blend(ndst, [fr, fg, fb, fa]); pixels[ni] = nout[0]; pixels[ni + 1] = nout[1]; pixels[ni + 2] = nout[2]; pixels[ni + 3] = nout[3]; } } } } } } /// Apply pencil brush - hard edges with size pressure #[allow(clippy::too_many_arguments)] pub fn draw_pencil_brush( pixels: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, size: f32, pressure: f32, color: [u8; 4], opacity: f32, hardness: f32, is_eraser: bool, mask: Option<&[u8]>, ) { draw_grain_brush( pixels, width, height, cx, cy, size, pressure, color, opacity, hardness, is_eraser, mask, 0.75 + pressure * 0.8, false, ); } /// Apply square flat brush - Chebyshev distance #[allow(clippy::too_many_arguments)] pub fn draw_square_brush( pixels: &mut [u8], w: u32, h: u32, cx: f32, cy: f32, size: f32, pressure: f32, color: [u8; 4], opacity: f32, hardness: f32, is_eraser: bool, mask: Option<&[u8]>, ) { if pressure <= 0.0 || w == 0 || h == 0 { return; } let r = (size * pressure / 2.0).max(0.5); let x_min = ((cx - r).floor() as i32).max(0).min(w as i32 - 1); let x_max = ((cx + r).ceil() as i32).max(0).min(w as i32 - 1); let y_min = ((cy - r).floor() as i32).max(0).min(h as i32 - 1); let y_max = ((cy + r).ceil() as i32).max(0).min(h as i32 - 1); let [fr, fg, fb, _] = color; for py in y_min..=y_max { for px in x_min..=x_max { let dx = (px as f32 - cx).abs(); let dy = (py as f32 - cy).abs(); let max_dist = dx.max(dy); // Chebyshev distance (square boundary) if max_dist > r { continue; } let idx = (py as usize) * (w as usize) + (px as usize); let mask_val = if let Some(m) = mask { *m.get(idx).unwrap_or(&0) } else { 255 }; if mask_val == 0 { continue; } // Calculate hardness drop-off towards square boundary let alpha_factor = if max_dist <= r * hardness { 1.0 } else { (1.0 - (max_dist - r * hardness) / (r - r * hardness + 1e-6)).clamp(0.0, 1.0) }; let brush_alpha = (alpha_factor * opacity * pressure * (color[3] as f32 / 255.0) * (mask_val as f32 / 255.0) * 255.0) .round() as u8; let i = idx * 4; if is_eraser { let da = pixels[i + 3] as f32 / 255.0; let ba = brush_alpha as f32 / 255.0; pixels[i + 3] = (da * (1.0 - ba) * 255.0).round() as u8; } else { let bg = [pixels[i], pixels[i + 1], pixels[i + 2], pixels[i + 3]]; let out = alpha_blend(bg, [fr, fg, fb, brush_alpha]); pixels[i] = out[0]; pixels[i + 1] = out[1]; pixels[i + 2] = out[2]; pixels[i + 3] = out[3]; } } } } /// Apply wet paint drips brush - main paint dab + downward gravity drips #[allow(clippy::too_many_arguments)] pub fn draw_wetpaint_brush( pixels: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, size: f32, pressure: f32, color: [u8; 4], opacity: f32, hardness: f32, is_eraser: bool, mask: Option<&[u8]>, ) { let effective_size = size * pressure; if effective_size < 0.5 { return; } let mut rng = rand::thread_rng(); let mixed_rgb = sample_neighborhood_color(pixels, width, height, cx, cy, 2) .map(|base| mix_pigments(base, [color[0], color[1], color[2]], 0.68)) .unwrap_or([color[0], color[1], color[2]]); let wet_color = [mixed_rgb[0], mixed_rgb[1], mixed_rgb[2], color[3]]; draw_dab( pixels, width, height, cx, cy, effective_size, hardness * 0.65, wet_color, opacity * pressure * 0.75, is_eraser, mask, ); let drip_count = if effective_size > 8.0 { rng.gen_range(0..=2) } else { 0 }; for _ in 0..drip_count { let x = cx + rng.gen_range(-effective_size * 0.35..effective_size * 0.35); let length = effective_size * rng.gen_range(0.35..0.9); let thickness = (effective_size * rng.gen_range(0.04..0.1)).max(0.6); draw_line_segment( pixels, width, height, x, cy + effective_size * 0.2, x + rng.gen_range(-thickness..thickness), cy + effective_size * 0.2 + length, thickness, thickness * 0.3, wet_color, opacity * pressure * 0.55, is_eraser, mask, ); } } /// Apply smudge mixer brush - sampling and wet-blending colors #[allow(clippy::too_many_arguments)] pub fn draw_mixer_brush( pixels: &mut [u8], w: u32, h: u32, cx: f32, cy: f32, size: f32, pressure: f32, color: [u8; 4], opacity: f32, hardness: f32, is_eraser: bool, mask: Option<&[u8]>, ) { if pressure <= 0.0 { return; } let sampled = sample_neighborhood_color(pixels, w, h, cx, cy, 2); let mixed_rgb = sampled.map_or([color[0], color[1], color[2]], |base| { mix_pigments(base, [color[0], color[1], color[2]], 0.38) }); let mixed_color = [mixed_rgb[0], mixed_rgb[1], mixed_rgb[2], color[3]]; // Draw the mixed paint stamp draw_dab( pixels, w, h, cx, cy, size * pressure, hardness, mixed_color, opacity * pressure, is_eraser, mask, ); } /// Blends existing pixels locally without introducing the active foreground color. #[allow(clippy::too_many_arguments)] pub fn draw_blender_brush( pixels: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, size: f32, pressure: f32, opacity: f32, mask: Option<&[u8]>, ) { let radius = size * pressure * 0.5; if radius < 0.5 || width == 0 || height == 0 { return; } let max_x = width.saturating_sub(1) as f32; let max_y = height.saturating_sub(1) as f32; if cx + radius + 1.0 < 0.0 || cy + radius + 1.0 < 0.0 || cx - radius - 1.0 > max_x || cy - radius - 1.0 > max_y { return; } let x0 = (cx - radius - 1.0).floor().clamp(0.0, max_x) as u32; let x1 = (cx + radius + 1.0).ceil().clamp(0.0, max_x) as u32; let y0 = (cy - radius - 1.0).floor().clamp(0.0, max_y) as u32; let y1 = (cy + radius + 1.0).ceil().clamp(0.0, max_y) as u32; if x1 <= x0 || y1 <= y0 { return; } let region_width = (x1 - x0 + 1) as usize; let mut source = vec![0u8; region_width * (y1 - y0 + 1) as usize * 4]; for y in y0..=y1 { let source_offset = ((y - y0) as usize * region_width) * 4; let canvas_offset = (y as usize * width as usize + x0 as usize) * 4; source[source_offset..source_offset + region_width * 4] .copy_from_slice(&pixels[canvas_offset..canvas_offset + region_width * 4]); } for y in y0.saturating_add(1)..y1 { for x in x0.saturating_add(1)..x1 { let dx = x as f32 - cx; let dy = y as f32 - cy; let distance = (dx * dx + dy * dy).sqrt() / radius; if distance > 1.0 { continue; } let canvas_index = (y * width + x) as usize; if mask .and_then(|selection| selection.get(canvas_index)) .copied() .unwrap_or(255) == 0 { continue; } let mut sum = [0u32; 4]; for oy in -1i32..=1 { for ox in -1i32..=1 { let local_x = (x as i32 + ox - x0 as i32) as usize; let local_y = (y as i32 + oy - y0 as i32) as usize; let index = (local_y * region_width + local_x) * 4; for channel in 0..4 { sum[channel] += source[index + channel] as u32; } } } let strength = opacity * pressure * (1.0 - distance) * 0.55; let offset = canvas_index * 4; for channel in 0..4 { let average = sum[channel] as f32 / 9.0; pixels[offset + channel] = (pixels[offset + channel] as f32 * (1.0 - strength) + average * strength) .round() as u8; } } } } /// Apply leaf scatter brush - clustered multi-oval leaf dabs #[allow(clippy::too_many_arguments)] /// Apply leaf brush - clusters of small leaf silhouettes. /// /// Purpose: Each stamp scatters a few individual leaf shapes around the dab /// center. Leaves are drawn as tapered curved lines forming a simple /// heart/oval silhouette, with optional per-leaf color variation. #[allow(clippy::too_many_arguments)] pub fn draw_leaf_brush( pixels: &mut [u8], w: u32, h: u32, cx: f32, cy: f32, size: f32, pressure: f32, color: [u8; 4], opacity: f32, _hardness: f32, is_eraser: bool, mask: Option<&[u8]>, color_variant: bool, variant_amount: f32, density: f32, ) { let effective_size = (size * pressure).max(2.0); let mut rng = rand::thread_rng(); let density_factor = density.clamp(0.2, 3.0); let leaf_count = ((rng.gen_range(3..=6) as f32 * density_factor).round() as u32).max(1); for i in 0..leaf_count { let angle = (i as f32 / leaf_count.max(2) as f32) * std::f32::consts::PI * 2.0 + rng.gen_range(-0.5..0.5); let dist = effective_size * rng.gen_range(0.15..0.4); let lx = cx + angle.cos() * dist; let ly = cy + angle.sin() * dist * 0.7; let leaf_size = effective_size * rng.gen_range(0.22..0.42); let leaf_angle = rng.gen_range(-0.4..0.4) + angle + std::f32::consts::FRAC_PI_2; let leaf_color = if color_variant { vary_color_hsl(color, variant_amount, &mut rng) } else { color }; draw_leaf_shape( pixels, w, h, lx, ly, leaf_size, leaf_angle, leaf_color, opacity * 0.9, is_eraser, mask, ); } } /// Draw a single simple leaf silhouette as a filled shape. The leaf is built /// from stacked horizontal tapered line segments whose length follows a /// smooth profile (zero at the tip and base, widest in the middle). This gives /// a solid, organic leaf look instead of a wire-frame outline. #[allow(clippy::too_many_arguments)] fn draw_leaf_shape( pixels: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, size: f32, angle: f32, color: [u8; 4], opacity: f32, is_eraser: bool, mask: Option<&[u8]>, ) { let c = angle.cos(); let s = angle.sin(); let rot = |x: f32, y: f32| (cx + x * c - y * s, cy + x * s + y * c); let tip = rot(0.0, -size * 0.55); let stem = rot(0.0, size * 0.55); // Build the leaf as stacked horizontal ribs from tip (t=0) to stem (t=1). let ribs = 14; for i in 0..=ribs { let t = i as f32 / ribs as f32; // Smooth leaf-width profile: 0 at tip and base, max near the middle. let profile = (std::f32::consts::PI * t).sin(); let half_width = size * 0.42 * profile; if half_width < size * 0.03 { continue; } // Position along the center axis. let center = rot(0.0, -size * 0.55 + t * size * 1.1); // Rib thickness scales with width, so segments overlap and fill the area. let rib_thick = half_width * 0.45; let rib_opacity = opacity * 0.65; let start = (center.0 - half_width * c, center.1 - half_width * s); let end = (center.0 + half_width * c, center.1 + half_width * s); draw_line_segment( pixels, width, height, start.0, start.1, end.0, end.1, rib_thick, rib_thick * 0.6, color, rib_opacity, is_eraser, mask, ); } // A subtle outline to sharpen the silhouette. let left_chain = [ tip, rot(-size * 0.40, -size * 0.10), rot(-size * 0.20, size * 0.20), stem, ]; let right_chain = [ tip, rot(size * 0.40, -size * 0.10), rot(size * 0.20, size * 0.20), stem, ]; let thick_base = size * 0.07; let thick_tip = size * 0.015; draw_curve_chain( pixels, width, height, &left_chain, thick_tip, thick_base, color, opacity * 0.9, is_eraser, mask, ); draw_curve_chain( pixels, width, height, &right_chain, thick_tip, thick_base, color, opacity * 0.9, is_eraser, mask, ); // Center vein. draw_line_segment( pixels, width, height, tip.0, tip.1, stem.0, stem.1, thick_tip, thick_base * 0.5, color, opacity * 0.75, is_eraser, mask, ); // Small stem extension. let stem_end = rot(0.0, size * 0.78); draw_line_segment( pixels, width, height, stem.0, stem.1, stem_end.0, stem_end.1, size * 0.04, size * 0.02, color, opacity * 0.7, is_eraser, mask, ); } /// Produce a smooth chain of points along a quadratic Bézier through `pts`. fn smooth_bezier_chain(pts: &[(f32, f32)], steps_per_segment: u32) -> Vec<(f32, f32)> { if pts.len() < 2 { return pts.to_vec(); } let mut out = Vec::new(); for i in 0..pts.len() - 1 { let p0 = pts[i.max(1) - 1]; let p1 = pts[i]; let p2 = pts[(i + 1).min(pts.len() - 1)]; let p3 = pts[(i + 2).min(pts.len() - 1)]; let steps = if i == 0 || i == pts.len() - 2 { steps_per_segment } else { steps_per_segment / 2 }; let steps = steps.max(2); for s in 0..steps { let t = s as f32 / steps as f32; // Catmull-Rom-like interpolation for smooth chain through control points. let q = catmull_rom(p0, p1, p2, p3, t); out.push(q); } } out.push(*pts.last().unwrap()); out } fn catmull_rom( p0: (f32, f32), p1: (f32, f32), p2: (f32, f32), p3: (f32, f32), t: f32, ) -> (f32, f32) { let t2 = t * t; let t3 = t2 * t; let (x, y) = ( 0.5 * ((2.0 * p1.0) + (-p0.0 + p2.0) * t + (2.0 * p0.0 - 5.0 * p1.0 + 4.0 * p2.0 - p3.0) * t2 + (-p0.0 + 3.0 * p1.0 - 3.0 * p2.0 + p3.0) * t3), 0.5 * ((2.0 * p1.1) + (-p0.1 + p2.1) * t + (2.0 * p0.1 - 5.0 * p1.1 + 4.0 * p2.1 - p3.1) * t2 + (-p0.1 + 3.0 * p1.1 - 3.0 * p2.1 + p3.1) * t3), ); (x, y) } fn bezier_point(pts: &[(f32, f32)], t: f32) -> (f32, f32) { // Very cheap de Casteljau reduction for 2-4 control points. match pts.len() { 2 => { let t1 = 1.0 - t; (pts[0].0 * t1 + pts[1].0 * t, pts[0].1 * t1 + pts[1].1 * t) } 3 => { let t1 = 1.0 - t; let a = (pts[0].0 * t1 + pts[1].0 * t, pts[0].1 * t1 + pts[1].1 * t); let b = (pts[1].0 * t1 + pts[2].0 * t, pts[1].1 * t1 + pts[2].1 * t); (a.0 * t1 + b.0 * t, a.1 * t1 + b.1 * t) } _ => { let mut tmp = pts.to_vec(); while tmp.len() > 1 { let mut next = Vec::with_capacity(tmp.len() - 1); let t1 = 1.0 - t; for i in 0..tmp.len() - 1 { next.push(( tmp[i].0 * t1 + tmp[i + 1].0 * t, tmp[i].1 * t1 + tmp[i + 1].1 * t, )); } tmp = next; } tmp[0] } } } /// Draw a smooth chain of points with linearly tapering thickness. fn draw_curve_chain( pixels: &mut [u8], width: u32, height: u32, pts: &[(f32, f32)], thick_start: f32, thick_end: f32, color: [u8; 4], opacity: f32, is_eraser: bool, mask: Option<&[u8]>, ) { if pts.len() < 2 { return; } let n = pts.len() - 1; for i in 0..n { let t0 = i as f32 / n as f32; let t1 = (i + 1) as f32 / n as f32; let (x0, y0) = pts[i]; let (x1, y1) = pts[i + 1]; let thick0 = thick_start * (1.0 - t0) + thick_end * t0; let thick1 = thick_start * (1.0 - t1) + thick_end * t1; draw_line_segment( pixels, width, height, x0, y0, x1, y1, thick0, thick1, color, opacity, is_eraser, mask, ); } } /// Apply crayon brush - textured low-opacity #[allow(clippy::too_many_arguments)] pub fn draw_crayon_brush( pixels: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, size: f32, pressure: f32, color: [u8; 4], opacity: f32, hardness: f32, is_eraser: bool, mask: Option<&[u8]>, ) { let r = (size * pressure / 2.0).max(0.5); let r_sq = r * r; let hard_sq = (r * hardness).powi(2); let soft_range_inv = 1.0 / (r_sq - hard_sq + 1e-6); let x_min = ((cx - r).floor() as i32).max(0).min(width as i32 - 1); let x_max = ((cx + r).ceil() as i32).max(0).min(width as i32 - 1); let y_min = ((cy - r).floor() as i32).max(0).min(height as i32 - 1); let y_max = ((cy + r).ceil() as i32).max(0).min(height as i32 - 1); let [fr, fg, fb, _] = color; let seed_x = (cx.to_bits().wrapping_mul(73_856_093) ^ cy.to_bits().wrapping_mul(19_349_663)) as u32; let seed_y = seed_x.wrapping_mul(668_265_263); for py in y_min..=y_max { for px in x_min..=x_max { let dx = px as f32 - cx; let dy = py as f32 - cy; let d_sq = dx * dx + dy * dy; if d_sq > r_sq { continue; } let idx = (py as usize) * (width as usize) + (px as usize); let mask_val = if let Some(m) = mask { *m.get(idx).unwrap_or(&0) } else { 255 }; if mask_val == 0 { continue; } let alpha_factor = if d_sq <= hard_sq { 1.0 } else { (1.0 - (d_sq - hard_sq) * soft_range_inv).clamp(0.0, 1.0) }; let ox = (px as u32).wrapping_add(seed_x); let oy = (py as u32).wrapping_add(seed_y); let noise = ox.wrapping_mul(73_856_093) ^ oy.wrapping_mul(19_349_663); let grain = (noise & 1023) as f32 / 1023.0; let threshold = 0.18 * (1.0 - pressure) + 0.08; let grain_alpha = if grain < threshold { (grain / threshold).clamp(0.0, 1.0) } else { 0.35 + 0.65 * grain }; let brush_alpha = (alpha_factor * opacity * pressure * (color[3] as f32 / 255.0) * grain_alpha * (mask_val as f32 / 255.0) * 255.0) .round() as u8; let i = idx * 4; if is_eraser { let da = pixels[i + 3] as f32 / 255.0; let ba = brush_alpha as f32 / 255.0; pixels[i + 3] = (da * (1.0 - ba) * 255.0).round() as u8; } else { let bg = [pixels[i], pixels[i + 1], pixels[i + 2], pixels[i + 3]]; let out = alpha_blend(bg, [fr, fg, fb, brush_alpha]); pixels[i] = out[0]; pixels[i + 1] = out[1]; pixels[i + 2] = out[2]; pixels[i + 3] = out[3]; } } } } /// Apply tree brush - scattered leaf-like dabs #[allow(clippy::too_many_arguments)] /// Apply tree brush - scattered wood branches and foliage terminals #[allow(clippy::too_many_arguments)] pub fn draw_tree_brush( pixels: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, size: f32, pressure: f32, color: [u8; 4], opacity: f32, hardness: f32, is_eraser: bool, mask: Option<&[u8]>, ) { let effective_size = size * pressure; let mut rng = rand::thread_rng(); let stem_color = if is_eraser { color } else { [ ((color[0] as f32 * 0.6) + 40.0).min(255.0) as u8, ((color[1] as f32 * 0.4) + 20.0).min(255.0) as u8, (color[2] as f32 * 0.3) as u8, color[3], ] }; // 1. Draw a trunk with variable width (tapers from bottom to top) let trunk_len = effective_size * rng.gen_range(0.8..1.4); let trunk_angle: f32 = rng.gen_range(-0.3..0.3); let segments = 5; for i in 0..segments { let t = i as f32 / segments as f32; let next_t = (i + 1) as f32 / segments as f32; let mid_t = (t + next_t) * 0.5; let sx = cx + trunk_angle.sin() * trunk_len * t; let sy = cy - trunk_len * t; let nx = cx + trunk_angle.sin() * trunk_len * next_t; let ny = cy - trunk_len * next_t; let seg_w = effective_size * (0.22 - 0.12 * mid_t); let bark = rng.gen_range(0.85..1.15); let seg_color = [ (stem_color[0] as f32 * bark).min(255.0) as u8, (stem_color[1] as f32 * bark).min(255.0) as u8, (stem_color[2] as f32 * bark).min(255.0) as u8, stem_color[3], ]; let steps = 3; for s in 0..=steps { let lt = s as f32 / steps as f32; let lx = sx + (nx - sx) * lt; let ly = sy + (ny - sy) * lt; let wobble = rng.gen_range(-seg_w * 0.15..seg_w * 0.15); draw_dab( pixels, width, height, lx + wobble, ly, seg_w, hardness.max(0.8), seg_color, opacity, is_eraser, mask, ); } } // 2. Draw branches let branch_count = rng.gen_range(2..=4); for _ in 0..branch_count { let b_angle = rng.gen_range(-std::f32::consts::PI..std::f32::consts::PI); let b_len = effective_size * rng.gen_range(0.3..0.8); let start_t = rng.gen_range(0.3..0.9); let bx0 = cx + trunk_angle.sin() * trunk_len * start_t; let by0 = cy - trunk_len * start_t; let bx1 = bx0 + b_angle.cos() * b_len; let by1 = by0 + b_angle.sin() * b_len * 0.6; let b_width = effective_size * rng.gen_range(0.04..0.1); let steps = 3; for s in 0..=steps { let t = s as f32 / steps as f32; let px = bx0 + (bx1 - bx0) * t; let py = by0 + (by1 - by0) * t; let seg_w = b_width * (1.0 - t * 0.6); draw_dab( pixels, width, height, px, py, seg_w, 1.0, stem_color, opacity * 0.8, is_eraser, mask, ); } // 3. Leaf clusters at branch tips if !is_eraser { for _ in 0..rng.gen_range(6..=12) { let lx = bx1 + rng.gen_range(-b_len * 0.35..b_len * 0.35); let ly = by1 + rng.gen_range(-b_len * 0.3..b_len * 0.3); let green_shift: f32 = rng.gen_range(-30.0..60.0); let bright_shift: f32 = rng.gen_range(-20.0..40.0); let leaf_color = [ ((color[0] as f32 * rng.gen_range(0.4..0.8)) + bright_shift * 0.3) .clamp(0.0, 255.0) as u8, ((color[1] as f32 * rng.gen_range(0.8..1.3)) + green_shift).clamp(0.0, 255.0) as u8, ((color[2] as f32 * rng.gen_range(0.2..0.5)) + bright_shift * 0.1) .clamp(0.0, 255.0) as u8, color[3], ]; let ls = effective_size * rng.gen_range(0.08..0.28); draw_dab( pixels, width, height, lx, ly, ls, rng.gen_range(0.2..0.6), leaf_color, opacity * rng.gen_range(0.6..1.0), false, mask, ); } } else { for _ in 0..4 { let lx = bx1 + rng.gen_range(-b_len * 0.3..b_len * 0.3); let ly = by1 + rng.gen_range(-b_len * 0.3..b_len * 0.3); let ls = effective_size * rng.gen_range(0.1..0.3); draw_dab( pixels, width, height, lx, ly, ls, 0.4, color, opacity, true, mask, ); } } } } /// Apply meadow brush - organic grass blade tufts. /// /// Purpose: Renders a natural-looking clump of curved grass blades at the /// requested position. Unlike the old version, each dab produces several /// blades with random length, angle, curvature and thickness so successive /// stamps never look identical. /// /// Logic & Workflow: /// 1. Compute an effective size from the brush size and pressure. /// 2. For each blade pick a base angle (upward ±45°), length (0.3..1.1 × size), /// lateral bend and per-blade opacity. /// 3. Trace the blade as a quadratic Bézier curve split into small segments. /// 4. Draw each segment with a tapered hard dab: thick at the root, thin at the tip. /// 5. If `color_variant` is enabled, vary the base color in HSL space using /// `variant_amount` before drawing each blade. #[allow(clippy::too_many_arguments)] pub fn draw_meadow_brush( pixels: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, size: f32, pressure: f32, color: [u8; 4], opacity: f32, _hardness: f32, is_eraser: bool, mask: Option<&[u8]>, color_variant: bool, variant_amount: f32, density: f32, ) { let mut rng = rand::thread_rng(); let effective_size = (size * pressure).max(2.0); let particle_opacity = opacity * pressure; // Density controls how many blades are emitted. Default (1.0) = 6–10 blades. let density_factor = density.clamp(0.2, 3.0); let blade_count = ((rng.gen_range(6..=10) as f32 * density_factor).round() as u32).max(2); for _ in 0..blade_count { // Base direction: upward with ±55° spread for a natural fan. let base_angle = -std::f32::consts::FRAC_PI_2 + rng.gen_range(-1.0..=1.0); // Blade length and lateral offset of the control point create curvature. // Lower density = longer blades so the tuft still covers the area. let length = effective_size * rng.gen_range(0.35..=1.15) * (1.0 + 0.2 / density_factor); let bend = rng.gen_range(-0.45..=0.45) * length; // Opacity per blade varies for a layered look. let blade_opacity = particle_opacity * rng.gen_range(0.55..=1.0); // Slight root offset within the tuft footprint. let root_offset = effective_size * rng.gen_range(-0.15..=0.15); let root_x = cx + root_offset * base_angle.cos(); let root_y = cy + root_offset * base_angle.sin(); // Bezier endpoints: root → tip. let tip_x = root_x + length * base_angle.cos(); let tip_y = root_y + length * base_angle.sin(); // Control point bends sideways to make the blade curve. let ctrl_x = root_x + length * 0.5 * base_angle.cos() + bend * base_angle.sin(); let ctrl_y = root_y + length * 0.5 * base_angle.sin() - bend * base_angle.cos(); let blade_color = if color_variant { vary_color_hsl(color, variant_amount, &mut rng) } else { color }; // Trace the blade as a smooth curved line with tapering thickness. // Use the line helper so the stroke looks like a bristle mark instead // of a string of round dots. let mut prev_x = quadratic_bezier(root_x, ctrl_x, tip_x, 0.0); let mut prev_y = quadratic_bezier(root_y, ctrl_y, tip_y, 0.0); let segments = (length * 2.5).max(10.0).min(40.0) as u32; for s in 1..=segments { let t = s as f32 / segments as f32; let wiggle = (t * std::f32::consts::PI * 4.0 + rng.gen_range(0.0..std::f32::consts::PI)).sin() * length * 0.08 * rng.gen_range(0.5..1.0); let x = quadratic_bezier(root_x, ctrl_x, tip_x, t) + wiggle * base_angle.sin(); let y = quadratic_bezier(root_y, ctrl_y, tip_y, t) - wiggle * base_angle.cos(); // Taper: thick at root, whisper-thin at tip. Keep a soft edge by // capping the minimum thickness to a sub-pixel value. let taper = (1.0 - t * 0.95).clamp(0.04, 1.0); let thick = (effective_size * 0.05 * taper).max(0.25); let seg_opacity = blade_opacity * (taper * 0.75 + 0.25); draw_line_segment( pixels, width, height, prev_x, prev_y, x, y, thick, thick * 0.5, blade_color, seg_opacity, is_eraser, mask, ); prev_x = x; prev_y = y; } } } #[inline] fn quadratic_bezier(p0: f32, p1: f32, p2: f32, t: f32) -> f32 { let one_minus_t = 1.0 - t; one_minus_t * one_minus_t * p0 + 2.0 * one_minus_t * t * p1 + t * t * p2 } // Per-thread remembered HSL walk state for one base color. The remembered offsets keep // successive color-variant dabs smooth while keys prevent unrelated colors from interacting. thread_local! { static LAST_HSL_WALK: std::cell::RefCell> = std::cell::RefCell::new(std::collections::HashMap::new()); } /// Hash helper for the base color used as thread-local state key. fn color_hash(color: [u8; 4]) -> u64 { ((color[0] as u64) << 24) | ((color[1] as u64) << 16) | ((color[2] as u64) << 8) | (color[3] as u64) } /// Vary the base color smoothly in HSL space and avoid harsh sequential contrasts. /// /// **Logic & Workflow:** /// 1. Loads the previous HSL offset for this base color from thread-local state. /// 2. Takes a small random step so the transition from the previous dab is gradual. /// 3. Rejects offsets that land in the complementary hue zone (150°..210° from base), /// which is where high-contrast color combinations occur. /// 4. Stores the new offset back into thread-local state. /// /// **Arguments:** /// * `color` — base RGBA color. /// * `amount` — 0.0..1.0 overall variation strength. /// * `rng` — random source. pub fn vary_color_hsl(color: [u8; 4], amount: f32, rng: &mut R) -> [u8; 4] { let clamped = amount.clamp(0.0, 1.0); if clamped <= 0.0 { return color; } let (base_h, base_s, base_l) = rgb_to_hsl(color[0], color[1], color[2]); let key = color_hash(color); let (mut hue_off, mut light_off, mut sat_off) = LAST_HSL_WALK.with_borrow(|m| m.get(&key).copied().unwrap_or((0.0, 0.0, 0.0))); // Maximum per-dab step: keeps transitions smooth. let max_hue_step = clamped * 18.0; // ±18° per dab at amount=1 let max_light_step = clamped * 0.12; let max_sat_step = clamped * 0.10; // Total range stays within a harmonious neighbourhood of the base color. let max_hue_off = clamped * 60.0; let max_light_off = clamped * 0.35; let max_sat_off = clamped * 0.25; for _ in 0..8 { let step_h = rng.gen_range(-1.0..=1.0) * max_hue_step; hue_off = (hue_off + step_h).clamp(-max_hue_off, max_hue_off); let step_l = rng.gen_range(-1.0..=1.0) * max_light_step; light_off = (light_off + step_l).clamp(-max_light_off, max_light_off); let step_s = rng.gen_range(-1.0..=1.0) * max_sat_step; sat_off = (sat_off + step_s).clamp(-max_sat_off, max_sat_off); // Reject complementary (high-contrast) hue offsets. // The complementary zone is 150°..210° away from the base hue. let abs_hue = hue_off.abs(); if abs_hue < 150.0 || abs_hue > 210.0 { break; } // If we land in the complementary zone, nudge back toward base and retry. hue_off *= 0.7; } LAST_HSL_WALK.with_borrow_mut(|m| { m.insert(key, (hue_off, light_off, sat_off)); }); let new_h = (base_h + hue_off).rem_euclid(360.0); let new_s = (base_s + sat_off).clamp(0.0, 1.0); let new_l = (base_l + light_off).clamp(0.0, 1.0); let (r, g, b) = hsl_to_rgb(new_h, new_s, new_l); [r, g, b, color[3]] } /// Reset the smooth color-walk state. Useful when starting a fresh stroke or test. pub fn reset_color_variant_walk() { LAST_HSL_WALK.with_borrow_mut(|m| m.clear()); } fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f32, f32, f32) { let rf = r as f32 / 255.0; let gf = g as f32 / 255.0; let bf = b as f32 / 255.0; let max = rf.max(gf).max(bf); let min = rf.min(gf).min(bf); let l = (max + min) * 0.5; if max == min { return (0.0, 0.0, l); } let d = max - min; let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) }; let h = 60.0 * if max == rf { ((gf - bf) / d + if gf < bf { 6.0 } else { 0.0 }) % 6.0 } else if max == gf { (bf - rf) / d + 2.0 } else { (rf - gf) / d + 4.0 }; (h, s, l) } fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) { let c = (1.0 - (2.0 * l - 1.0).abs()) * s; let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs()); let m = l - c * 0.5; let (r1, g1, b1) = 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) }; ( ((r1 + m) * 255.0).round().clamp(0.0, 255.0) as u8, ((g1 + m) * 255.0).round().clamp(0.0, 255.0) as u8, ((b1 + m) * 255.0).round().clamp(0.0, 255.0) as u8, ) } /// Apply rock brush - scattered hard-edged dabs #[allow(clippy::too_many_arguments)] pub fn draw_rock_brush( pixels: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, size: f32, pressure: f32, color: [u8; 4], opacity: f32, hardness: f32, is_eraser: bool, mask: Option<&[u8]>, ) { let mut rng = rand::thread_rng(); let effective_size = size * pressure; let radius = effective_size / 2.0; let particle_opacity = opacity * pressure * 0.8; if effective_size < 0.5 { return; } let count = ((effective_size / 12.0).round() as u32).clamp(3, 6); for _ in 0..count { let r_dist = rng.gen::().sqrt() * radius; let theta = rng.gen::() * std::f32::consts::PI * 2.0; let px = cx + r_dist * theta.cos(); let py = cy + r_dist * theta.sin(); let p_size = rng.gen_range(effective_size * 0.2..=effective_size * 0.5); draw_dab_rotated( pixels, width, height, px, py, p_size, hardness.max(0.75), rng.gen_range(0.0..std::f32::consts::TAU), rng.gen_range(0.45..0.78), color, particle_opacity, is_eraser, mask, ); } } /// Apply cloud brush - very soft airbrush-like #[allow(clippy::too_many_arguments)] pub fn draw_clouds_brush( pixels: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, size: f32, pressure: f32, color: [u8; 4], opacity: f32, hardness: f32, is_eraser: bool, mask: Option<&[u8]>, ) { let effective_size = size * pressure; if effective_size < 0.5 { return; } let lobes = [ (-0.28, 0.08, 0.58), (-0.08, -0.12, 0.72), (0.18, -0.08, 0.64), (0.34, 0.1, 0.48), ]; for (ox, oy, scale) in lobes { draw_dab( pixels, width, height, cx + ox * effective_size, cy + oy * effective_size, effective_size * scale, hardness.min(0.18), color, opacity * pressure * 0.24, is_eraser, mask, ); } } /// Apply dirt brush - now a textured "stamp floor" surface. /// /// Purpose: Each stamp produces a small patch of rough horizontal floor/succo /// texture made of short, irregular bristle strokes. When spaced along a stroke, /// these patches merge into a continuous, grainy horizontal band. /// /// Logic: Generates many short line segments within the stamp radius, mostly /// horizontal but with slight angle wobble. Each line has its own color /// variation (if enabled), length, thickness and opacity, creating a layered /// weathered look. #[allow(clippy::too_many_arguments)] pub fn draw_dirt_brush( pixels: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, size: f32, pressure: f32, color: [u8; 4], opacity: f32, _hardness: f32, is_eraser: bool, mask: Option<&[u8]>, color_variant: bool, variant_amount: f32, density: f32, ) { let mut rng = rand::thread_rng(); let effective_size = (size * pressure).max(2.0); let patch_opacity = opacity * pressure; let density_factor = density.clamp(0.2, 3.0); // Density controls how many horizontal strokes per stamp. Default ~size*1.2. let stroke_count = ((effective_size * 1.2 * density_factor).round() as u32) .max(3) .min(48); for _ in 0..stroke_count { // Horizontal-ish center for this bristle stroke. let y_off = (rng.gen::() - 0.5) * effective_size * 0.75; let x_off = (rng.gen::() - 0.5) * effective_size * 0.2; // Length varies, but high density keeps strokes shorter so they don't // all fuse into a solid rectangle. let length = effective_size * rng.gen_range(0.2..0.7) / density_factor.sqrt(); let x0 = cx + x_off - length * 0.5; let y0 = cy + y_off; // Slight vertical drift to mimic uneven plaster. let y1 = y0 + rng.gen_range(-effective_size * 0.08..effective_size * 0.08); let x1 = x0 + length; let stroke_color = if color_variant { vary_color_hsl(color, variant_amount, &mut rng) } else { color }; let max_thickness = (effective_size * 0.07).max(0.36); let thick = rng.gen_range(0.35..max_thickness).max(0.3); let stroke_opacity = patch_opacity * rng.gen_range(0.35..0.9); draw_line_segment( pixels, width, height, x0, y0, x1, y1, thick, thick * 0.6, stroke_color, stroke_opacity, is_eraser, mask, ); } // Add a few scattered specks/dots for fine grain. let speck_count = ((effective_size * 0.6 * density_factor).round() as u32) .max(2) .min(20); for _ in 0..speck_count { let sx = cx + (rng.gen::() - 0.5) * effective_size; let sy = cy + (rng.gen::() - 0.5) * effective_size * 0.6; let speck_color = if color_variant { vary_color_hsl(color, variant_amount, &mut rng) } else { color }; draw_dab( pixels, width, height, sx, sy, rng.gen_range(0.3..0.9), 0.5, speck_color, patch_opacity * rng.gen_range(0.2..0.5), is_eraser, mask, ); } } /// Apply stipple brush - sharp, four-pointed tapering sparkling stars #[allow(clippy::too_many_arguments)] pub fn draw_stipple_brush( pixels: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, size: f32, pressure: f32, color: [u8; 4], opacity: f32, hardness: f32, is_eraser: bool, mask: Option<&[u8]>, ) { let mut rng = rand::thread_rng(); let effective_size = size * pressure; // Spread controls how far particles scatter from the center. // Previous value (2.2×) caused the brush to paint ~4.4× the nominal // diameter. 0.65× keeps the footprint close to the user-specified size. let spread = effective_size * 0.65; let particle_opacity = opacity * pressure; let count = (effective_size / 4.0).max(3.0) as u32; for _ in 0..count { let r = rng.gen::().sqrt() * spread; let theta = rng.gen::() * std::f32::consts::PI * 2.0; let px = cx + r * theta.cos(); let py = cy + r * theta.sin(); // Smaller star particles to match the tighter spread. let star_size = rng.gen_range(2.0..(effective_size * 0.15).max(3.0)); // A. Small soft center core draw_dab( pixels, width, height, px, py, star_size * 0.35, hardness, color, particle_opacity, is_eraser, mask, ); // B. Long, tapering arm dabs stretching out along X and Y axes // Arm distance scaled down to keep the star within the spread radius. for i in 1..=3 { let arm_dist = star_size * 0.35 * i as f32; let arm_size = star_size * 0.25 * (1.0 - (i as f32 / 4.5)); // Tapering diameter let arm_op = particle_opacity * (1.0 - (i as f32 / 5.0)); // Concentric opacity decay draw_dab( pixels, width, height, px + arm_dist, py, arm_size, hardness, color, arm_op, is_eraser, mask, ); draw_dab( pixels, width, height, px - arm_dist, py, arm_size, hardness, color, arm_op, is_eraser, mask, ); draw_dab( pixels, width, height, px, py + arm_dist, arm_size, hardness, color, arm_op, is_eraser, mask, ); draw_dab( pixels, width, height, px, py - arm_dist, arm_size, hardness, color, arm_op, is_eraser, mask, ); } } } /// Apply bristle brush with a fixed comb of parallel filaments. #[allow(clippy::too_many_arguments)] pub fn draw_bristle_brush( pixels: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, size: f32, pressure: f32, color: [u8; 4], opacity: f32, hardness: f32, is_eraser: bool, mask: Option<&[u8]>, ) { draw_bristle_brush_oriented( pixels, width, height, cx, cy, size, pressure, color, opacity, hardness, 0.0, is_eraser, mask, ); } /// Direction-aware bristle renderer used by paint presets. #[allow(clippy::too_many_arguments)] fn draw_bristle_brush_oriented( pixels: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, size: f32, pressure: f32, color: [u8; 4], opacity: f32, _hardness: f32, angle: f32, is_eraser: bool, mask: Option<&[u8]>, ) { let effective_size = size * pressure; if effective_size < 0.5 { return; } let tangent = (angle.cos(), angle.sin()); let normal = (-tangent.1, tangent.0); let count = ((effective_size / 2.5).round() as usize).clamp(4, 18); for index in 0..count { let lane = (index as f32 + 0.5) / count as f32 - 0.5; let offset = lane * effective_size * 0.88; let start_x = cx + normal.0 * offset - tangent.0 * effective_size * 0.28; let start_y = cy + normal.1 * offset - tangent.1 * effective_size * 0.28; draw_line_segment( pixels, width, height, start_x, start_y, start_x + tangent.0 * effective_size * 0.56, start_y + tangent.1 * effective_size * 0.56, (effective_size / count as f32 * 0.42).max(0.4), 0.35, color, opacity * pressure * (0.55 + (index % 3) as f32 * 0.12), is_eraser, mask, ); } } /// Apply wood brush - lined dabs #[allow(clippy::too_many_arguments)] pub fn draw_wood_brush( pixels: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, size: f32, pressure: f32, color: [u8; 4], opacity: f32, _hardness: f32, is_eraser: bool, mask: Option<&[u8]>, ) { let effective_size = size * pressure; let particle_opacity = opacity * pressure; if effective_size < 0.5 { return; } for band in -3i32..=3 { let y = cy + band as f32 * effective_size * 0.09; let phase = (cy * 0.07 + band as f32).sin() * effective_size * 0.05; draw_line_segment( pixels, width, height, cx - effective_size * 0.72, y + phase, cx + effective_size * 0.72, y - phase, (effective_size * 0.035).max(0.45), (effective_size * 0.02).max(0.3), color, particle_opacity * (0.45 + (band.unsigned_abs() % 3) as f32 * 0.15), is_eraser, mask, ); } } /// Apply sketch brush - history-based line connection #[allow(clippy::too_many_arguments)] pub fn draw_sketch_brush( pixels: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, size: f32, pressure: f32, color: [u8; 4], opacity: f32, hardness: f32, is_eraser: bool, history: &mut Vec<(f32, f32)>, mask: Option<&[u8]>, ) { let effective_size = size * pressure; if effective_size < 0.5 { return; } let previous = history.last().copied(); history.push((cx, cy)); if history.len() > 2 { history.remove(0); } draw_grain_brush( pixels, width, height, cx, cy, effective_size.max(1.0), 1.0, color, opacity * pressure * 0.45, hardness, is_eraser, mask, 0.7, false, ); if let Some((hx, hy)) = previous { let dx = cx - hx; let dy = cy - hy; let distance = (dx * dx + dy * dy).sqrt(); if distance > f32::EPSILON { let normal = (-dy / distance, dx / distance); for lane in -1i32..=1 { let offset = lane as f32 * effective_size * 0.12; draw_line_segment( pixels, width, height, hx + normal.0 * offset, hy + normal.1 * offset, cx + normal.0 * offset, cy + normal.1 * offset, (effective_size * 0.12).max(0.45), (effective_size * 0.08).max(0.35), color, opacity * pressure * (0.24 + (lane + 1) as f32 * 0.08), is_eraser, mask, ); } } } } /// Apply hatch brush #[allow(clippy::too_many_arguments)] pub fn draw_hatch_brush( pixels: &mut [u8], width: u32, height: u32, cx: f32, cy: f32, size: f32, pressure: f32, color: [u8; 4], opacity: f32, hardness: f32, is_eraser: bool, mask: Option<&[u8]>, ) { if pressure <= 0.0 { return; } let effective_size = size * pressure; let r = effective_size / 2.0; let count = 5; for i in 0..count { let off = (i as f32 - (count as f32 / 2.0)) * (effective_size / count as f32); let x1 = cx - r + off; let y1 = cy - r - off; let x2 = cx + r + off; let y2 = cy + r - off; let dist = ((x2 - x1).powi(2) + (y2 - y1).powi(2)).sqrt(); let steps = dist.max(1.0) as u32; for s in 0..=steps { let t = s as f32 / steps as f32; let px = x1 + (x2 - x1) * t; let py = y1 + (y2 - y1) * t; let d_dab = ((px - cx).powi(2) + (py - cy).powi(2)).sqrt(); if d_dab < r { draw_dab( pixels, width, height, px, py, 1.0, hardness, color, opacity * pressure, is_eraser, mask, ); } } } } /// Draw a brush stroke using specialized brush implementation based on style. /// `mask`: optional selection mask constraining drawing to selected pixels. #[allow(clippy::too_many_arguments)] pub fn draw_specialized_stroke( pixels: &mut [u8], width: u32, height: u32, points: &[(f32, f32, f32)], brush_style: BrushStyle, size: f32, hardness: f32, color: [u8; 4], opacity: f32, spacing_ratio: f32, is_eraser: bool, mut sketch_history: Option<&mut Vec<(f32, f32)>>, spray_particle_size: f32, spray_density: u32, mask: Option<&[u8]>, mut stroke_mask: Option<&mut [u8]>, bg_pixels: Option<&[u8]>, color_variant: bool, variant_amount: f32, density: f32, jitter_amount: f32, scatter_amount: f32, angle: f32, roundness: f32, rotation_random: f32, drawing_angle: bool, include_first_dab: bool, ) { if points.is_empty() { return; } // Start each stroke from the base color so successive strokes do not inherit // a strong leftover offset from the previous stroke. if color_variant && variant_amount > 0.0 { reset_color_variant_walk(); } let spacing = brush_spacing_pixels(size, spacing_ratio).max(0.1); let angle_tip = BrushTip { angle, roundness, rotation_random, drawing_angle, ..Default::default() }; if points.len() == 1 { let (cx, cy, pressure) = points[0]; let dab_angle = effective_dab_angle(&angle_tip, 0.0, 0.0); let (jitter_x, jitter_y) = jitter_offset(jitter_amount, size); let (scatter_x, scatter_y) = scatter_offset(scatter_amount, size); draw_brush_style_dab( pixels, width, height, cx + jitter_x + scatter_x, cy + jitter_y + scatter_y, size, pressure, color, opacity, brush_style, is_eraser, &mut sketch_history, spray_particle_size, spray_density, hardness, mask, stroke_mask.as_deref_mut(), bg_pixels, opacity, color_variant, variant_amount, density, dab_angle, roundness, 0.0, false, ); return; } for index in 0..points.len() - 1 { let (cx, cy, pressure) = points[index]; let (nx, ny, next_pressure) = points[index + 1]; let dx = nx - cx; let dy = ny - cy; let distance = (dx * dx + dy * dy).sqrt(); if index == 0 && (include_first_dab || distance <= f32::EPSILON) { let (jitter_x, jitter_y) = jitter_offset(jitter_amount, size); let (scatter_x, scatter_y) = scatter_offset(scatter_amount, size); let dab_angle = effective_dab_angle(&angle_tip, dx, dy); draw_brush_style_dab( pixels, width, height, cx + jitter_x + scatter_x, cy + jitter_y + scatter_y, size, pressure, color, opacity, brush_style, is_eraser, &mut sketch_history, spray_particle_size, spray_density, hardness, mask, stroke_mask.as_deref_mut(), bg_pixels, opacity, color_variant, variant_amount, density, dab_angle, roundness, 0.0, false, ); } if distance <= f32::EPSILON { continue; } let steps = (distance / spacing).ceil().max(1.0) as u32; for step in 1..=steps { let t = step as f32 / steps as f32; let (jitter_x, jitter_y) = jitter_offset(jitter_amount, size); let (scatter_x, scatter_y) = scatter_offset(scatter_amount, size); let dab_angle = effective_dab_angle(&angle_tip, dx, dy); draw_brush_style_dab( pixels, width, height, cx + dx * t + jitter_x + scatter_x, cy + dy * t + jitter_y + scatter_y, size, pressure + (next_pressure - pressure) * t, color, opacity, brush_style, is_eraser, &mut sketch_history, spray_particle_size, spray_density, hardness, mask, stroke_mask.as_deref_mut(), bg_pixels, opacity, color_variant, variant_amount, density, dab_angle, roundness, 0.0, false, ); } } } /// Draw an anti-aliased line segment with variable thickness onto the RGBA buffer. /// /// Purpose: Helper used by natural brushes (Meadow, Leaves, StampFloor) to produce /// thin, curved strokes that look like bristle marks instead of round dabs. /// /// Logic: Samples the segment in small steps, at each point drawing a soft circular /// dab whose diameter equals the local thickness. The high step count ensures the /// overlapping dabs merge into a smooth line, while the tapering thickness produces /// a natural root-to-tip or start-to-end fade. #[allow(clippy::too_many_arguments)] fn draw_line_segment( pixels: &mut [u8], width: u32, height: u32, x0: f32, y0: f32, x1: f32, y1: f32, thickness_start: f32, thickness_end: f32, color: [u8; 4], opacity: f32, is_eraser: bool, mask: Option<&[u8]>, ) { let dx = x1 - x0; let dy = y1 - y0; let len = (dx * dx + dy * dy).sqrt(); if len < 1e-3 { return; } // Step size slightly smaller than the minimum thickness so dabs overlap. let min_thick = thickness_start.min(thickness_end).max(0.5); let step = min_thick * 0.35; let steps = (len / step).max(1.0).min(64.0) as u32; for s in 0..=steps { let t = s as f32 / steps as f32; let x = x0 + dx * t; let y = y0 + dy * t; let thick = thickness_start * (1.0 - t) + thickness_end * t; // Use a harder dab (0.9) so opacity translates to coverage, not blur. let dab_size = thick.max(0.4); // Opacity falls slightly toward the tip if thickness tapers to nothing. let seg_opacity = opacity * (0.6 + 0.4 * (thick / min_thick).min(1.0)); draw_dab( pixels, width, height, x, y, dab_size, 0.9, color, seg_opacity, is_eraser, mask, ); } } #[inline] fn alpha_blend(dst: [u8; 4], src: [u8; 4]) -> [u8; 4] { let sa = src[3] as f32 / 255.0; if sa <= 0.0 { return dst; } if sa >= 1.0 { return src; } let da = dst[3] as f32 / 255.0; let out_a = sa + da * (1.0 - sa); if out_a <= 0.0 { return [0, 0, 0, 0]; } [ ((src[0] as f32 * sa + dst[0] as f32 * da * (1.0 - sa)) / out_a).round() as u8, ((src[1] as f32 * sa + dst[1] as f32 * da * (1.0 - sa)) / out_a).round() as u8, ((src[2] as f32 * sa + dst[2] as f32 * da * (1.0 - sa)) / out_a).round() as u8, (out_a * 255.0).round() as u8, ] } #[cfg(test)] mod watercolor_performance_tests { use super::{ draw_blender_brush, draw_dab, draw_specialized_stroke, draw_watercolor_brush_with_rng, mix_pigments, watercolor_detail_counts, BrushStyle, WATERCOLOR_MAX_DABS_PER_SAMPLE, }; use rand::{rngs::StdRng, SeedableRng}; /// Ensures every supported size stays within the fixed raster-work budget. #[test] fn watercolor_detail_count_is_hard_bounded() { let mut rng = StdRng::seed_from_u64(0x0057_4154_4552); for size in [0.5, 1.0, 8.0, 24.0, 64.0, 128.0, 1024.0] { for _ in 0..1000 { let (satellites, splatters, blooms) = watercolor_detail_counts(size, 1.0, &mut rng); let total = 1 + satellites + splatters + blooms; assert!(satellites <= 2); assert!(splatters <= 4); assert!(blooms <= 1); assert!(total <= WATERCOLOR_MAX_DABS_PER_SAMPLE); } } } /// Ensures no named specialized medium deposits paint at zero pressure. #[test] fn every_specialized_style_is_noop_at_zero_pressure() { let styles = [ BrushStyle::Noise, BrushStyle::Texture, BrushStyle::Spray, BrushStyle::Pencil, BrushStyle::Pen, BrushStyle::InkPen, BrushStyle::Calligraphy, BrushStyle::Oil, BrushStyle::Charcoal, BrushStyle::Leaf, BrushStyle::Rock, BrushStyle::Meadow, BrushStyle::Wood, BrushStyle::Watercolor, BrushStyle::Marker, BrushStyle::Sketch, BrushStyle::Hatch, BrushStyle::Glow, BrushStyle::Airbrush, BrushStyle::Crayon, BrushStyle::WetPaint, BrushStyle::Clouds, BrushStyle::Dirt, BrushStyle::Tree, BrushStyle::Bristle, BrushStyle::Mixer, BrushStyle::Blender, BrushStyle::Star, ]; for style in styles { let mut pixels = vec![0u8; 64 * 64 * 4]; draw_specialized_stroke( &mut pixels, 64, 64, &[(32.0, 32.0, 0.0)], style, 24.0, 0.6, [200, 80, 20, 255], 0.8, 0.1, false, None, 2.0, 100, None, None, None, false, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, false, true, ); assert!( pixels.iter().all(|value| *value == 0), "{style:?} painted at zero pressure" ); } } /// Confirms the Blender redistributes existing color without changing a uniform field. #[test] fn blender_preserves_uniform_color_and_transparency() { let mut uniform = [90, 120, 150, 255].repeat(32 * 32); let original = uniform.clone(); draw_blender_brush(&mut uniform, 32, 32, 16.0, 16.0, 18.0, 1.0, 1.0, None); assert_eq!(uniform, original); let mut transparent = vec![0u8; 32 * 32 * 4]; draw_blender_brush(&mut transparent, 32, 32, 16.0, 16.0, 18.0, 1.0, 1.0, None); assert!(transparent.iter().all(|value| *value == 0)); draw_blender_brush( &mut transparent, 32, 32, 200.0, -100.0, 18.0, 1.0, 1.0, None, ); } /// Ensures elliptical dabs accumulate into the one-byte stroke mask without RGBA indexing. #[test] fn rotated_dab_uses_single_channel_stroke_mask() { let mut pixels = vec![0u8; 32 * 32 * 4]; let mut stroke_mask = vec![0u8; 32 * 32]; draw_specialized_stroke( &mut pixels, 32, 32, &[(16.0, 16.0, 1.0)], BrushStyle::Round, 12.0, 0.8, [200, 80, 20, 255], 0.7, 0.1, false, None, 2.0, 100, None, Some(&mut stroke_mask), None, false, 0.0, 1.0, 0.0, 0.0, 0.4, 0.35, 0.0, false, true, ); assert!(stroke_mask.iter().any(|alpha| *alpha > 0)); } /// Ensures overlapping rotated pen dabs cannot exceed the configured stroke opacity. #[test] fn rotated_pen_respects_stroke_opacity_cap() { let background = vec![0u8; 32 * 32 * 4]; let mut pixels = background.clone(); let mut stroke_mask = vec![0u8; 32 * 32]; for _ in 0..3 { draw_specialized_stroke( &mut pixels, 32, 32, &[(16.0, 16.0, 1.0)], BrushStyle::Pen, 12.0, 1.0, [200, 80, 20, 255], 0.5, 0.1, false, None, 2.0, 100, None, Some(&mut stroke_mask), Some(&background), false, 0.0, 1.0, 0.0, 0.0, 0.25, 0.3, 0.0, false, true, ); } let center = (16 * 32 + 16) * 4; assert!((126..=128).contains(&pixels[center + 3])); } /// Verifies optical-density mixing yields a dark subtractive secondary instead of RGB averaging. #[test] fn pigment_mix_is_subtractive_and_bounded() { let mixed = mix_pigments([255, 0, 0], [0, 0, 255], 0.5); assert!(mixed[0] > mixed[1] && mixed[2] > mixed[1]); assert!(mixed[0] < 128 && mixed[2] < 128); assert_eq!( mix_pigments([10, 20, 30], [200, 210, 220], 0.0), [10, 20, 30] ); assert_eq!( mix_pigments([10, 20, 30], [200, 210, 220], 1.0), [200, 210, 220] ); } /// Reports p50/p95/max raster time for one round dab and the bounded watercolor sample. #[test] #[ignore = "diagnostic benchmark; run with --ignored --nocapture"] fn diagnostic_watercolor_sample_latency() { let mut pixels = vec![0u8; 512 * 512 * 4]; let mut rng = StdRng::seed_from_u64(0x0050_4149_4e54); for size in [24.0, 64.0, 128.0] { let mut round_samples = Vec::with_capacity(100); let mut watercolor_samples = Vec::with_capacity(100); for sample in 0..110 { let started = std::time::Instant::now(); draw_dab( &mut pixels, 512, 512, 256.0, 256.0, size, 0.5, [20, 80, 180, 255], 0.8, false, None, ); let round = started.elapsed().as_secs_f64() * 1000.0; let started = std::time::Instant::now(); draw_watercolor_brush_with_rng( &mut pixels, 512, 512, 256.0, 256.0, size, 1.0, [20, 80, 180, 255], 0.8, 0.5, false, None, 1.0, &mut rng, ); let watercolor = started.elapsed().as_secs_f64() * 1000.0; if sample >= 10 { round_samples.push(round); watercolor_samples.push(watercolor); } } round_samples.sort_by(f64::total_cmp); watercolor_samples.sort_by(f64::total_cmp); let p95_index = (round_samples.len() as f64 * 0.95).floor() as usize; println!( "watercolor size={size:.0}: round_p95={:.3}ms watercolor_p50={:.3}ms watercolor_p95={:.3}ms watercolor_max={:.3}ms", round_samples[p95_index.min(round_samples.len() - 1)], watercolor_samples[watercolor_samples.len() / 2], watercolor_samples[p95_index.min(watercolor_samples.len() - 1)], watercolor_samples[watercolor_samples.len() - 1], ); } } }