Files
hcie-rust-v3.05/hcie-brush-engine/src/lib.rs
T
phantom 2d5b7a37e8 feat: Enhance canvas texture management and selection encoding
- Introduced `SharedCompositePixels` for stable full-canvas pixel storage, enabling efficient pipeline creation and recovery.
- Added `TextureUpdate` struct for tightly packed dirty rectangle uploads, reducing unnecessary data copying.
- Refactored `upload_dirty_region` to utilize `TextureUpdate`, improving performance by eliminating full buffer scans.
- Implemented `encode_selection_texture` to generate an encoded R8 selection mask, optimizing selection rendering.
- Updated shader to sample selection texture only once, reducing GPU workload.
- Added comprehensive tests for texture updates, selection encoding, and performance diagnostics.
- Documented design decisions and validation sequences to ensure future performance stability.
2026-07-22 02:49:22 +03:00

3467 lines
102 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 points = 5u32;
let inner_r = r * 0.4;
(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 / points as f32;
let a = angle.rem_euclid(point_angle);
let edge = if a < point_angle / 2.0 {
inner_r + (r - inner_r) * (a / (point_angle / 2.0))
} else {
r - (r - inner_r) * ((a - point_angle / 2.0) / (point_angle / 2.0))
};
if dist >= edge {
0
} else {
let alpha = 1.0 - (dist / edge).powf(1.0 - hardness.clamp(0.0, 0.99));
(alpha * 255.0).round() as u8
}
})
.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 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::Oil => draw_oil_brush(
pixels,
width,
height,
cx,
cy,
size,
pressure,
color,
opacity,
brush_hardness,
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(
pixels,
width,
height,
cx,
cy,
size,
pressure,
color,
opacity,
brush_hardness,
is_eraser,
mask,
),
BrushStyle::Calligraphy => draw_calligraphy_brush(
pixels,
width,
height,
cx,
cy,
size,
pressure,
color,
opacity,
brush_hardness,
is_eraser,
mask,
),
BrushStyle::Marker => draw_marker_brush(
pixels,
width,
height,
cx,
cy,
size,
pressure,
color,
opacity,
brush_hardness,
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::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_stipple_brush(
pixels,
width,
height,
cx,
cy,
size,
pressure,
color,
opacity,
brush_hardness,
is_eraser,
mask,
),
BrushStyle::Bristle => draw_bristle_brush(
pixels,
width,
height,
cx,
cy,
size,
pressure,
color,
opacity,
brush_hardness,
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 {
draw_dab_rotated(
sm,
width,
height,
cx,
cy,
size * pressure.max(0.1),
brush_hardness,
rot,
effective_roundness,
[255, 255, 255, (max_stroke_opacity * 255.0).round() as u8],
1.0,
false,
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]>,
) {
draw_dab_with_stamp(
pixels, width, height, cx, cy, size, hardness, color, opacity, is_eraser, mask, None,
);
}
/// 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]>,
) {
// 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];
}
}
}
}
/// Apply oil paint brush - multiple bristle-like dabs
#[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]>,
) {
let mut rng = rand::thread_rng();
let effective_size = size * pressure;
let bristle_count = (effective_size / 3.0).max(5.0) as u32;
for _ in 0..bristle_count {
let off_x = (rng.gen::<f32>() - 0.5) * effective_size;
let off_y = (rng.gen::<f32>() - 0.5) * effective_size;
let bristle_r = (effective_size / 10.0).max(1.0);
draw_dab(
pixels,
width,
height,
cx + off_x,
cy + off_y,
bristle_r,
hardness,
color,
opacity,
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;
let particle_count = (effective_size / 2.0).max(10.0) as u32;
let radius = effective_size / 2.0;
let particle_opacity = opacity * 0.3;
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]>,
) {
let mut rng = rand::thread_rng();
draw_watercolor_brush_with_rng(
pixels, width, height, cx, cy, size, pressure, color, opacity, hardness, is_eraser, mask,
&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,
rng: &mut R,
) -> (usize, usize, usize) {
let satellites = ((effective_size / 24.0).ceil() as usize).clamp(1, 2);
let splatters = ((effective_size / 16.0).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]>,
rng: &mut R,
) {
let effective_size = size * pressure;
if effective_size < 0.5 {
return;
}
let base_opacity = opacity * 0.3 * pressure;
let center_color = color;
let (satellite_count, splatter_count, bloom_count) =
watercolor_detail_counts(effective_size, 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,
is_eraser: bool,
mask: Option<&[u8]>,
) {
let effective_size = size * pressure;
let particle_opacity = opacity * pressure;
for i in 0..3 {
let off = (i as f32 - 1.0) * (effective_size / 4.0);
draw_dab(
pixels,
width,
height,
cx,
cy + off,
effective_size,
hardness,
color,
particle_opacity,
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,
is_eraser: bool,
mask: Option<&[u8]>,
) {
let effective_size = size * pressure;
let particle_opacity = opacity * pressure * 0.7;
draw_dab(
pixels,
width,
height,
cx,
cy,
effective_size,
hardness,
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 {
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 {
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 brush_alpha = (base_opacity * 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 fa = (base_opacity * 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]>,
) {
let effective_size = size * pressure;
draw_dab(
pixels,
width,
height,
cx,
cy,
effective_size,
hardness,
color,
opacity,
is_eraser,
mask,
);
}
/// 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]>,
) {
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 * (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;
let mut rng = rand::thread_rng();
// Create a more organic "liquid" look by overlapping several slightly offset dabs
let dab_count = rng.gen_range(3..=6);
for _ in 0..dab_count {
let ox = rng.gen_range(-effective_size * 0.2..effective_size * 0.2);
let oy = rng.gen_range(-effective_size * 0.2..effective_size * 0.2);
let s = effective_size * rng.gen_range(0.8..1.1);
draw_dab(
pixels,
width,
height,
cx + ox,
cy + oy,
s,
hardness,
color,
opacity * 0.8,
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]>,
) {
let sample_x = cx.round() as i32;
let sample_y = cy.round() as i32;
let mut mixed_color = color;
// Sample pixel color directly from the active canvas underneath the brush center
if sample_x >= 0 && sample_x < w as i32 && sample_y >= 0 && sample_y < h as i32 {
let idx = ((sample_y as usize * w as usize) + sample_x as usize) * 4;
let canvas_a = pixels[idx + 3] as f32 / 255.0;
if canvas_a > 0.1 {
// Mix 60% of the canvas paint with 40% of the active brush color
mixed_color[0] = ((color[0] as f32 * 0.40) + (pixels[idx] as f32 * 0.60)) as u8;
mixed_color[1] = ((color[1] as f32 * 0.40) + (pixels[idx + 1] as f32 * 0.60)) as u8;
mixed_color[2] = ((color[2] as f32 * 0.40) + (pixels[idx + 2] as f32 * 0.60)) as u8;
}
}
// Draw the mixed paint stamp
draw_dab(
pixels,
w,
h,
cx,
cy,
size * pressure,
hardness,
mixed_color,
opacity,
is_eraser,
mask,
);
}
/// 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;
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)
};
// Deterministic pseudo-random coordinate noise for dry crayon/chalk wax texture
let noise = ((px as f32 * 12.9898 + py as f32 * 78.233).sin() * 43758.545)
.fract()
.abs();
let grain = 0.35 + 0.65 * noise;
let brush_alpha =
(alpha_factor * opacity * grain * (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
}
/// Vary an RGBA color in HSL space by up to `amount` spread.
/// `amount` is expected in [0, 1]. Hue is shifted by ±(amount*60°),
/// lightness by ±(amount*0.4). Saturation is preserved to keep the
/// color within the same family (green stays green, purple stays purple).
/// Per-thread remembered HSL walk state for one base color.
///
/// Keeps the last hue/lightness/saturation offsets so successive dabs move smoothly
/// instead of jumping to independent random values. The state is keyed by the
/// base color so unrelated colors do not interfere.
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;
let count = (effective_size / 4.0).max(3.0) as u32;
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(
pixels,
width,
height,
px,
py,
p_size,
hardness,
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]>,
) {
draw_airbrush(
pixels, width, height, cx, cy, size, pressure, color, opacity, hardness, 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 thick = rng.gen_range(0.35..effective_size * 0.07).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 - based on oil
#[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_oil_brush(
pixels, width, height, cx, cy, size, pressure, color, opacity, hardness, 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 mut rng = rand::thread_rng();
let effective_size = size * pressure;
let particle_opacity = opacity * pressure;
let w = effective_size * 1.5;
let h = effective_size * 0.4;
for i in 0..4 {
let off_x = (i as f32 - 1.5) * (w / 4.0);
let off_y = (rng.gen::<f32>() - 0.5) * (h / 2.0);
draw_dab(
pixels,
width,
height,
cx + off_x,
cy + off_y,
effective_size,
hardness,
color,
particle_opacity,
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;
history.push((cx, cy));
if history.len() > 30 {
history.remove(0);
}
let particle_opacity = opacity * pressure * 2.0;
let threshold = effective_size * 3.0;
draw_dab(
pixels,
width,
height,
cx,
cy,
effective_size * 0.2,
hardness,
color,
particle_opacity,
is_eraser,
mask,
);
for &(hx, hy) in history.iter().rev().take(12) {
let dx = cx - hx;
let dy = cy - hy;
let dist = (dx * dx + dy * dy).sqrt();
if dist > 0.0 && dist < threshold {
let steps = (dist / 3.0).max(1.0) as u32;
for s in 0..=steps {
let t = s as f32 / steps as f32;
let px = cx + (hx - cx) * t;
let py = cy + (hy - cy) * t;
draw_dab(
pixels,
width,
height,
px,
py,
1.0,
hardness,
color,
particle_opacity * (1.0 - dist / threshold),
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]>,
) {
let r = size / 2.0;
let count = 5;
for i in 0..count {
let off = (i as f32 - (count as f32 / 2.0)) * (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, 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,
) {
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);
for i in 0..points.len() {
let (cx, cy, pressure) = points[i];
let dx = if i < points.len() - 1 {
points[i + 1].0 - cx
} else if i > 0 {
cx - points[i - 1].0
} else {
0.0
};
let dy = if i < points.len() - 1 {
points[i + 1].1 - cy
} else if i > 0 {
cy - points[i - 1].1
} else {
0.0
};
let seg_angle = effective_dab_angle(
&BrushTip {
angle: 0.0,
drawing_angle: true,
rotation_random: 0.0,
..Default::default()
},
dx,
dy,
);
draw_brush_style_dab(
pixels,
width,
height,
cx,
cy,
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,
seg_angle,
1.0,
0.0,
false,
);
if i < points.len() - 1 {
let (nx, ny, np) = points[i + 1];
let dx = nx - cx;
let dy = ny - cy;
let dist = (dx * dx + dy * dy).sqrt();
if dist == 0.0 {
continue;
}
let steps = (dist / spacing).max(1.0) as u32;
for s in 1..=steps {
let t = s as f32 / steps as f32;
let ix = cx + dx * t;
let iy = cy + dy * t;
let ip = pressure + (np - pressure) * t;
draw_brush_style_dab(
pixels,
width,
height,
ix,
iy,
size,
ip,
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,
seg_angle,
1.0,
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_dab, draw_watercolor_brush_with_rng, watercolor_detail_counts,
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, &mut rng);
let total = 1 + satellites + splatters + blooms;
assert!(satellites <= 2);
assert!(splatters <= 4);
assert!(blooms <= 1);
assert!(total <= WATERCOLOR_MAX_DABS_PER_SAMPLE);
}
}
}
/// 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,
&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],
);
}
}
}