use ezu_core::{
seed::{next_unit, world_seed},
TileId, WorldPos,
};
use hokusai::tile_mem::MemSurface;
use hokusai::{Brush, BrushInput, BrushSetting, BrushState, InputMapping};
use tiny_skia::{PixmapPaint, Transform};
use crate::Canvas;
pub const LINE_STROKE_SALT: u32 = 0xEB_E2_C0_1E;
#[derive(Debug, Clone)]
pub struct LineStrokeStyle {
pub color: [f32; 3],
pub pressure_base: f32,
pub pressure_jitter: f32,
pub dtime: f32,
pub radius_stroke_curve: Option<Vec<(f32, f32)>>,
pub opacity_stroke_curve: Option<Vec<(f32, f32)>>,
pub hardness_stroke_curve: Option<Vec<(f32, f32)>>,
pub dtime_stroke_curve: Option<Vec<(f32, f32)>>,
}
impl Default for LineStrokeStyle {
fn default() -> Self {
Self {
color: [0.18, 0.13, 0.10],
pressure_base: 0.7,
pressure_jitter: 0.2,
dtime: 0.02,
radius_stroke_curve: None,
opacity_stroke_curve: None,
hardness_stroke_curve: None,
dtime_stroke_curve: None,
}
}
}
impl LineStrokeStyle {
fn has_brush_stroke_curves(&self) -> bool {
self.radius_stroke_curve.is_some()
|| self.opacity_stroke_curve.is_some()
|| self.hardness_stroke_curve.is_some()
}
}
pub fn paint_lines(
canvas: &mut Canvas,
lines: &[Vec<(i32, i32)>],
extent: u32,
tile: TileId,
brush: &Brush,
style: &LineStrokeStyle,
) {
let pw = canvas.width();
let ph = canvas.height();
if pw == 0 || ph == 0 || lines.is_empty() {
return;
}
let brush = color_overridden(brush, style.color);
let geom = StrokeGeom::from_canvas(canvas, extent, tile);
let lines = visible_lines(lines, &geom, &brush, pw, ph);
let mut surface = MemSurface::new();
for line in lines {
stroke_one(&mut surface, &brush, line, &geom, style);
}
composite(canvas, &surface);
}
#[cfg(feature = "parallel")]
pub fn paint_lines_parallel(
canvas: &mut Canvas,
lines: &[Vec<(i32, i32)>],
extent: u32,
tile: TileId,
brush: &Brush,
style: &LineStrokeStyle,
) {
use rayon::prelude::*;
let pw = canvas.width();
let ph = canvas.height();
if pw == 0 || ph == 0 || lines.is_empty() {
return;
}
let workers = rayon::current_num_threads().max(1);
if workers == 1 || lines.len() == 1 {
return paint_lines(canvas, lines, extent, tile, brush, style);
}
let geom = StrokeGeom::from_canvas(canvas, extent, tile);
let brush_template = color_overridden(brush, style.color);
let lines = visible_lines(lines, &geom, &brush_template, pw, ph);
if lines.is_empty() {
return;
}
let chunk_size = lines.len().div_ceil(workers).max(1);
let surfaces: Vec<MemSurface> = lines
.par_chunks(chunk_size)
.map(|chunk| {
let brush = brush_template.clone();
let mut surface = MemSurface::new();
for line in chunk {
stroke_one(&mut surface, &brush, line, &geom, style);
}
surface
})
.collect();
for surface in &surfaces {
composite(canvas, surface);
}
}
pub(crate) fn brush_radius_px(brush: &Brush) -> f32 {
setting_ceiling(brush.get(BrushSetting::Radius))
.exp()
.clamp(0.2, 1000.0)
}
const GAUSS_MAX: f32 = 3.464_102;
pub(crate) fn setting_ceiling(sv: &hokusai::SettingValue) -> f32 {
sv.base_value
+ sv.inputs
.iter()
.map(|m| m.points.iter().map(|&(_, y)| y).fold(0.0f32, f32::max))
.sum::<f32>()
}
pub(crate) fn max_dab_reach_px(brush: &Brush) -> f32 {
let radius_log = setting_ceiling(brush.get(BrushSetting::Radius));
let radius_jitter = setting_ceiling(brush.get(BrushSetting::RadiusByRandom)).max(0.0);
let radius = (radius_log + GAUSS_MAX * radius_jitter)
.exp()
.clamp(0.2, 1000.0);
let ratio = setting_ceiling(brush.get(BrushSetting::EllipticalDabRatio)).max(1.0);
let centre_jitter = setting_ceiling(brush.get(BrushSetting::OffsetByRandom)).max(0.0)
+ setting_ceiling(brush.get(BrushSetting::TrackingNoise)).max(0.0);
let reach = radius * ratio + radius * GAUSS_MAX * centre_jitter;
reach * 2.0 + 64.0
}
fn line_touches_canvas(line: &[(i32, i32)], geom: &StrokeGeom, w: f32, h: f32, reach: f32) -> bool {
let (mut x0, mut x1) = (f32::MAX, f32::MIN);
let (mut y0, mut y1) = (f32::MAX, f32::MIN);
for &(x, y) in line {
let px = x as f32 * geom.sx + geom.pad;
let py = y as f32 * geom.sy + geom.pad;
x0 = x0.min(px);
x1 = x1.max(px);
y0 = y0.min(py);
y1 = y1.max(py);
}
x1 >= -reach && y1 >= -reach && x0 <= w + reach && y0 <= h + reach
}
fn brush_reads_back(brush: &Brush) -> bool {
let smudge = brush.get(BrushSetting::Smudge);
smudge.base_value != 0.0 || !smudge.inputs.is_empty()
}
fn visible_lines<'a>(
lines: &'a [Vec<(i32, i32)>],
geom: &StrokeGeom,
brush: &Brush,
w: u32,
h: u32,
) -> Vec<&'a Vec<(i32, i32)>> {
if brush_reads_back(brush) {
return lines.iter().collect();
}
let reach = max_dab_reach_px(brush);
let (w, h) = (w as f32, h as f32);
lines
.iter()
.filter(|line| line_touches_canvas(line, geom, w, h, reach))
.collect()
}
struct StrokeGeom {
sx: f32,
sy: f32,
pad: f32,
world_origin_x: f64,
world_origin_y: f64,
world_per_px: f64,
}
impl StrokeGeom {
fn from_canvas(canvas: &Canvas, extent: u32, tile: TileId) -> Self {
let tile_w = canvas.tile_width();
let sx = tile_w as f32 / extent as f32;
let sy = canvas.tile_height() as f32 / extent as f32;
let axis_tiles = (1u64 << tile.z) as f64;
Self {
sx,
sy,
pad: canvas.pad() as f32,
world_origin_x: tile.x as f64 / axis_tiles,
world_origin_y: tile.y as f64 / axis_tiles,
world_per_px: 1.0 / (axis_tiles * tile_w as f64),
}
}
}
fn color_overridden(brush: &Brush, color: [f32; 3]) -> Brush {
let mut b = brush.clone();
let (hue, sat, val) = linear_rgb_to_hsv(color);
b.get_mut(BrushSetting::ColorH).base_value = hue;
b.get_mut(BrushSetting::ColorS).base_value = sat;
b.get_mut(BrushSetting::ColorV).base_value = val;
b
}
fn stroke_one(
surface: &mut MemSurface,
brush: &Brush,
line: &[(i32, i32)],
geom: &StrokeGeom,
style: &LineStrokeStyle,
) {
if line.len() < 2 {
return;
}
let need_t = style.has_brush_stroke_curves() || style.dtime_stroke_curve.is_some();
let (cum_lens, total_len) = if need_t {
cumulative_lengths(line, geom)
} else {
(Vec::new(), 0.0)
};
let owned;
let brush: &Brush = if style.has_brush_stroke_curves() {
let mut b = brush.clone();
apply_stroke_curves(&mut b, total_len, style);
owned = b;
&owned
} else {
brush
};
let mut state = BrushState::default();
let mut first = true;
let inv_total = if total_len > 0.0 {
1.0 / total_len
} else {
0.0
};
for (i, &(x, y)) in line.iter().enumerate() {
let px = x as f32 * geom.sx + geom.pad;
let py = y as f32 * geom.sy + geom.pad;
let wx = geom.world_origin_x + (px as f64 - geom.pad as f64) * geom.world_per_px;
let wy = geom.world_origin_y + (py as f64 - geom.pad as f64) * geom.world_per_px;
let mut seed = world_seed(WorldPos::new(wx, wy), LINE_STROKE_SALT);
let pj = (next_unit(&mut seed) - 0.5) * 2.0 * style.pressure_jitter;
let pressure = (style.pressure_base + pj).clamp(0.0, 1.0);
let dtime = if first {
10.0
} else {
let mut d = style.dtime as f64;
if let Some(curve) = style.dtime_stroke_curve.as_deref() {
let t = cum_lens[i] * inv_total;
d *= eval_curve(curve, t).max(0.0) as f64;
}
d
};
brush.stroke_to(&mut state, surface, px, py, pressure, 0.0, 0.0, dtime);
first = false;
}
}
fn cumulative_lengths(line: &[(i32, i32)], geom: &StrokeGeom) -> (Vec<f32>, f32) {
let mut cum = Vec::with_capacity(line.len());
let mut acc = 0.0f32;
cum.push(0.0);
for w in line.windows(2) {
let dx = (w[1].0 - w[0].0) as f32 * geom.sx;
let dy = (w[1].1 - w[0].1) as f32 * geom.sy;
acc += (dx * dx + dy * dy).sqrt();
cum.push(acc);
}
(cum, acc)
}
fn eval_curve(points: &[(f32, f32)], x: f32) -> f32 {
match points.len() {
0 => 0.0,
1 => points[0].1,
_ => {
let (mut x0, mut y0) = points[0];
let (mut x1, mut y1) = points[1];
for &(xi, yi) in &points[2..] {
if x <= x1 {
break;
}
x0 = x1;
y0 = y1;
x1 = xi;
y1 = yi;
}
if x0 == x1 || y0 == y1 {
y0
} else {
(y1 * (x - x0) + y0 * (x1 - x)) / (x1 - x0)
}
}
}
}
fn apply_stroke_curves(brush: &mut Brush, line_len_px: f32, style: &LineStrokeStyle) {
brush
.get_mut(BrushSetting::StrokeDurationLogarithmic)
.base_value = line_len_px.max(1.0).ln();
if let Some(pts) = &style.radius_stroke_curve {
set_stroke_input(brush, BrushSetting::Radius, pts);
}
if let Some(pts) = &style.opacity_stroke_curve {
set_stroke_input(brush, BrushSetting::Opaque, pts);
}
if let Some(pts) = &style.hardness_stroke_curve {
set_stroke_input(brush, BrushSetting::Hardness, pts);
}
}
fn set_stroke_input(brush: &mut Brush, setting: BrushSetting, points: &[(f32, f32)]) {
let sv = brush.get_mut(setting);
sv.inputs.retain(|m| m.input != BrushInput::Stroke);
sv.inputs.push(InputMapping {
input: BrushInput::Stroke,
points: points.to_vec(),
});
}
fn composite(canvas: &mut Canvas, surface: &MemSurface) {
let pw = canvas.width();
let ph = canvas.height();
let pixmap = hokusai::tiny_skia::flatten_transparent(surface, pw, ph);
canvas.pixmap_mut().draw_pixmap(
0,
0,
pixmap.as_ref(),
&PixmapPaint::default(),
Transform::identity(),
None,
);
}
fn linear_rgb_to_hsv(rgb: [f32; 3]) -> (f32, f32, f32) {
let r = linear_to_srgb(rgb[0]);
let g = linear_to_srgb(rgb[1]);
let b = linear_to_srgb(rgb[2]);
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let d = max - min;
let v = max;
let s = if max <= 0.0 { 0.0 } else { d / max };
let h = if d <= 0.0 {
0.0
} else if (max - r).abs() < f32::EPSILON {
(((g - b) / d).rem_euclid(6.0)) / 6.0
} else if (max - g).abs() < f32::EPSILON {
((b - r) / d + 2.0) / 6.0
} else {
((r - g) / d + 4.0) / 6.0
};
(h, s, v)
}
fn linear_to_srgb(c: f32) -> f32 {
let c = c.clamp(0.0, 1.0);
if c <= 0.003_130_8 {
12.92 * c
} else {
1.055 * c.powf(1.0 / 2.4) - 0.055
}
}
#[cfg(all(test, feature = "parallel"))]
mod parallel_tests {
use super::*;
use ezu_core::TileId;
fn fixture_brush() -> Brush {
let json = include_str!("../fixtures/watercolor_glazing.myb");
hokusai::myb::from_str(json).expect("parse fixture watercolor_glazing.myb")
}
fn synth_lines() -> Vec<Vec<(i32, i32)>> {
let extent = 4096i32;
(1..=8i32)
.map(|iy| {
let y = iy * (extent / 9);
(0..12i32)
.map(|ix| {
let jitter = if ix % 2 == 0 { 0 } else { 80 };
(ix * (extent / 12), y + jitter)
})
.collect()
})
.collect()
}
#[test]
fn parallel_single_line_is_byte_identical_to_serial() {
let lines = vec![(0..12).map(|ix| (ix * 300, 2000)).collect::<Vec<_>>()];
let brush = fixture_brush();
let style = LineStrokeStyle::default();
let tile = TileId::new(13, 7276, 3225);
let mut serial = Canvas::new_padded(256, 256, 12).expect("non-zero canvas dims");
paint_lines(&mut serial, &lines, 4096, tile, &brush, &style);
let mut parallel = Canvas::new_padded(256, 256, 12).expect("non-zero canvas dims");
paint_lines_parallel(&mut parallel, &lines, 4096, tile, &brush, &style);
assert_eq!(serial.pixmap().data(), parallel.pixmap().data());
}
#[test]
fn parallel_paint_lines_matches_serial_within_visual_tolerance() {
let lines = synth_lines();
let brush = fixture_brush();
let style = LineStrokeStyle::default();
let tile = TileId::new(13, 7276, 3225);
let extent = 4096;
let mut serial = Canvas::new_padded(256, 256, 12).expect("non-zero canvas dims");
paint_lines(&mut serial, &lines, extent, tile, &brush, &style);
let mut parallel = Canvas::new_padded(256, 256, 12).expect("non-zero canvas dims");
paint_lines_parallel(&mut parallel, &lines, extent, tile, &brush, &style);
let s = serial.pixmap().data();
let p = parallel.pixmap().data();
assert_eq!(s.len(), p.len());
let mut max_diff = 0i32;
let mut diff_px = 0usize;
for (a, b) in s.iter().zip(p.iter()) {
let d = (*a as i32 - *b as i32).abs();
if d > 0 {
diff_px += 1;
if d > max_diff {
max_diff = d;
}
}
}
eprintln!(
"parallel vs serial: diff bytes = {diff_px} / {}, max channel delta = {max_diff}",
s.len()
);
}
}
#[cfg(test)]
mod culling_tests {
use super::*;
use ezu_core::TileId;
const EXTENT: u32 = 4096;
fn smudging_brush() -> Brush {
let json = include_str!("../fixtures/watercolor_glazing.myb");
hokusai::myb::from_str(json).expect("parse fixture watercolor_glazing.myb")
}
fn dry_brush() -> Brush {
let mut b = smudging_brush();
b.get_mut(BrushSetting::Smudge).base_value = 0.0;
b.get_mut(BrushSetting::Smudge).inputs.clear();
b
}
fn paint(lines: &[Vec<(i32, i32)>], brush: &Brush) -> Vec<u8> {
let mut canvas = Canvas::new_padded(256, 256, 12).expect("non-zero canvas dims");
paint_lines(
&mut canvas,
lines,
EXTENT,
TileId::new(13, 7276, 3225),
brush,
&LineStrokeStyle::default(),
);
canvas.pixmap().data().to_vec()
}
fn crossing_line() -> Vec<(i32, i32)> {
(0..12).map(|ix| (ix * 300, 2000)).collect()
}
#[test]
fn a_line_that_cannot_reach_the_canvas_changes_no_pixel() {
let brush = dry_brush();
let far: Vec<(i32, i32)> = (0..12).map(|ix| (400_000 + ix * 300, 380_000)).collect();
assert_eq!(
paint(&[crossing_line()], &brush),
paint(&[crossing_line(), far], &brush),
);
}
#[test]
fn a_line_just_off_the_edge_still_marks_the_canvas() {
let brush = dry_brush();
let just_above: Vec<(i32, i32)> = (0..12).map(|ix| (ix * 300, -40)).collect();
let blank = paint(&[], &brush);
assert_ne!(paint(&[just_above], &brush), blank);
}
#[test]
fn a_smudging_brush_is_not_culled() {
assert!(brush_reads_back(&smudging_brush()));
assert!(!brush_reads_back(&dry_brush()));
let brush = smudging_brush();
let geom = StrokeGeom::from_canvas(
&Canvas::new_padded(256, 256, 12).expect("non-zero canvas dims"),
EXTENT,
TileId::new(13, 7276, 3225),
);
let far: Vec<Vec<(i32, i32)>> =
vec![(0..12).map(|ix| (400_000 + ix * 300, 380_000)).collect()];
assert_eq!(visible_lines(&far, &geom, &brush, 280, 280).len(), 1);
assert_eq!(visible_lines(&far, &geom, &dry_brush(), 280, 280).len(), 0);
}
#[test]
fn the_reach_bounds_the_widest_dab_the_brush_can_draw() {
let mut b = dry_brush();
b.get_mut(BrushSetting::Radius).base_value = 3.0; let plain = max_dab_reach_px(&b);
assert!(plain > 20.0, "reach {plain} does not cover a 20 px dab");
for setting in [
BrushSetting::RadiusByRandom,
BrushSetting::EllipticalDabRatio,
BrushSetting::OffsetByRandom,
BrushSetting::TrackingNoise,
] {
let mut wider = b.clone();
wider.get_mut(setting).base_value += 2.0;
assert!(
max_dab_reach_px(&wider) > plain,
"{setting:?} widens the dab but not the reach",
);
}
}
}