4561 lines
135 KiB
Rust
4561 lines
135 KiB
Rust
#![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) = 6–10 blades.
|
||
let density_factor = density.clamp(0.2, 3.0);
|
||
let blade_count = ((rng.gen_range(6..=10) as f32 * density_factor).round() as u32).max(2);
|
||
|
||
for _ in 0..blade_count {
|
||
// Base direction: upward with ±55° spread for a natural fan.
|
||
let base_angle = -std::f32::consts::FRAC_PI_2 + rng.gen_range(-1.0..=1.0);
|
||
// Blade length and lateral offset of the control point create curvature.
|
||
// Lower density = longer blades so the tuft still covers the area.
|
||
let length = effective_size * rng.gen_range(0.35..=1.15) * (1.0 + 0.2 / density_factor);
|
||
let bend = rng.gen_range(-0.45..=0.45) * length;
|
||
// Opacity per blade varies for a layered look.
|
||
let blade_opacity = particle_opacity * rng.gen_range(0.55..=1.0);
|
||
// Slight root offset within the tuft footprint.
|
||
let root_offset = effective_size * rng.gen_range(-0.15..=0.15);
|
||
let root_x = cx + root_offset * base_angle.cos();
|
||
let root_y = cy + root_offset * base_angle.sin();
|
||
|
||
// Bezier endpoints: root → tip.
|
||
let tip_x = root_x + length * base_angle.cos();
|
||
let tip_y = root_y + length * base_angle.sin();
|
||
// Control point bends sideways to make the blade curve.
|
||
let ctrl_x = root_x + length * 0.5 * base_angle.cos() + bend * base_angle.sin();
|
||
let ctrl_y = root_y + length * 0.5 * base_angle.sin() - bend * base_angle.cos();
|
||
|
||
let blade_color = if color_variant {
|
||
vary_color_hsl(color, variant_amount, &mut rng)
|
||
} else {
|
||
color
|
||
};
|
||
|
||
// Trace the blade as a smooth curved line with tapering thickness.
|
||
// Use the line helper so the stroke looks like a bristle mark instead
|
||
// of a string of round dots.
|
||
let mut prev_x = quadratic_bezier(root_x, ctrl_x, tip_x, 0.0);
|
||
let mut prev_y = quadratic_bezier(root_y, ctrl_y, tip_y, 0.0);
|
||
let segments = (length * 2.5).max(10.0).min(40.0) as u32;
|
||
for s in 1..=segments {
|
||
let t = s as f32 / segments as f32;
|
||
let wiggle =
|
||
(t * std::f32::consts::PI * 4.0 + rng.gen_range(0.0..std::f32::consts::PI)).sin()
|
||
* length
|
||
* 0.08
|
||
* rng.gen_range(0.5..1.0);
|
||
let x = quadratic_bezier(root_x, ctrl_x, tip_x, t) + wiggle * base_angle.sin();
|
||
let y = quadratic_bezier(root_y, ctrl_y, tip_y, t) - wiggle * base_angle.cos();
|
||
// Taper: thick at root, whisper-thin at tip. Keep a soft edge by
|
||
// capping the minimum thickness to a sub-pixel value.
|
||
let taper = (1.0 - t * 0.95).clamp(0.04, 1.0);
|
||
let thick = (effective_size * 0.05 * taper).max(0.25);
|
||
let seg_opacity = blade_opacity * (taper * 0.75 + 0.25);
|
||
draw_line_segment(
|
||
pixels,
|
||
width,
|
||
height,
|
||
prev_x,
|
||
prev_y,
|
||
x,
|
||
y,
|
||
thick,
|
||
thick * 0.5,
|
||
blade_color,
|
||
seg_opacity,
|
||
is_eraser,
|
||
mask,
|
||
);
|
||
prev_x = x;
|
||
prev_y = y;
|
||
}
|
||
}
|
||
}
|
||
|
||
#[inline]
|
||
fn quadratic_bezier(p0: f32, p1: f32, p2: f32, t: f32) -> f32 {
|
||
let one_minus_t = 1.0 - t;
|
||
one_minus_t * one_minus_t * p0 + 2.0 * one_minus_t * t * p1 + t * t * p2
|
||
}
|
||
|
||
// Per-thread remembered HSL walk state for one base color. The remembered offsets keep
|
||
// successive color-variant dabs smooth while keys prevent unrelated colors from interacting.
|
||
thread_local! {
|
||
static LAST_HSL_WALK: std::cell::RefCell<std::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],
|
||
);
|
||
}
|
||
}
|
||
}
|