use ezu_core::{seed::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 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 chunk_size = lines.len().div_ceil(workers).max(1);
let brush_template = color_overridden(brush, style.color);
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);
}
}
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,
);
}
#[inline]
fn next_unit(state: &mut u64) -> f32 {
*state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
let x = (*state >> 33) as u32;
(x as f32) * (1.0 / (1u64 << 32) as f32)
}
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!("builtin/watercolor_glazing.myb");
hokusai::myb::from_str(json).expect("parse builtin 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()
);
}
}