Files
hcie-rust-v3.05/hcie-brush-engine/src/lib.rs
T
Your Name fd205309b6
mandatory-regression-gate / deterministic-tests (push) Waiting to run
mandatory-regression-gate / protected-performance-path (push) Waiting to run
fix: resolve checkerboard artifact in brush strokes and update AGENTS.md for v3.05
2026-07-25 20:39:13 +03:00

4561 lines
135 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#![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<u8>,
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<u8>` of size `diameter * diameter`, values 0-255.
pub fn generate_brush_stamp(tip: &BrushTip) -> Vec<u8> {
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<u8> {
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<u8> {
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<u8> {
(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<u8> {
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::<f32>() * 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::<f32>() * 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::<f32>() * 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::<f32>() * 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::<f32>().sqrt() * radius;
let theta = rng.gen::<f32>() * std::f32::consts::PI * 2.0;
let px = cx + r * theta.cos();
let py = cy + r * theta.sin();
let p_size = (rng.gen::<f32>() * 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<R: rand::Rng + ?Sized>(
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<R: rand::Rng + ?Sized>(
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) = 610 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::collections::HashMap<u64, (f32, f32, f32)>> =
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<R: Rng>(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::<f32>().sqrt() * radius;
let theta = rng.gen::<f32>() * 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::<f32>() - 0.5) * effective_size * 0.75;
let x_off = (rng.gen::<f32>() - 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::<f32>() - 0.5) * effective_size;
let sy = cy + (rng.gen::<f32>() - 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::<f32>().sqrt() * spread;
let theta = rng.gen::<f32>() * 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],
);
}
}
}