mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-31 22:26:57 -06:00
159 lines
5.8 KiB
Rust
159 lines
5.8 KiB
Rust
use super::frame::Bx;
|
|
use super::{Ctx, MAX_LINE_PTS, MAX_MARKERS, Prim, Rgba, WHITE};
|
|
use std::collections::HashSet;
|
|
|
|
// ---------------------------------------------------------------- helpers
|
|
|
|
/// Reduces a long polyline to first/min/max/last per pixel column.
|
|
pub(super) fn decimate(pts: Vec<[f32; 2]>, max: usize) -> Vec<[f32; 2]> {
|
|
if pts.len() <= max {
|
|
return pts;
|
|
}
|
|
let mut out = Vec::with_capacity(max * 2);
|
|
let mut i = 0;
|
|
while let Some(p) = pts.get(i) {
|
|
let col = p[0].floor();
|
|
let (start, mut mn, mut mx) = (i, i, i);
|
|
let mut k = i + 1;
|
|
while let Some(q) = pts.get(k) {
|
|
if q[0].floor() != col {
|
|
break;
|
|
}
|
|
if pts.get(mn).is_some_and(|m| q[1] < m[1]) {
|
|
mn = k;
|
|
}
|
|
if pts.get(mx).is_some_and(|m| q[1] > m[1]) {
|
|
mx = k;
|
|
}
|
|
k += 1;
|
|
}
|
|
let mut idx = [start, mn, mx, k - 1];
|
|
idx.sort_unstable();
|
|
let mut last = usize::MAX;
|
|
for ix in idx {
|
|
if ix != last
|
|
&& let Some(q) = pts.get(ix)
|
|
{
|
|
out.push(*q);
|
|
last = ix;
|
|
}
|
|
}
|
|
i = k;
|
|
}
|
|
if out.len() > max * 4 {
|
|
let step = out.len() / (max * 2) + 1;
|
|
let last = out.last().copied();
|
|
let mut s: Vec<[f32; 2]> = out.into_iter().step_by(step).collect();
|
|
if let Some(l) = last
|
|
&& s.last() != Some(&l)
|
|
{
|
|
s.push(l);
|
|
}
|
|
return s;
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Draws a polyline split at gaps (`None`), decimated when long. With `smooth` (the plot box),
|
|
/// each run is interpolated with a Catmull-Rom spline so the line curves through the data points.
|
|
pub(super) fn draw_runs(ctx: &mut Ctx, pts: &[Option<[f32; 2]>], color: Rgba, width: f32, smooth: Option<Bx>) {
|
|
let mut run: Vec<[f32; 2]> = Vec::new();
|
|
for p in pts.iter().chain(std::iter::once(&None)) {
|
|
match p {
|
|
Some(p) => run.push(*p),
|
|
None => {
|
|
if run.len() >= 2 {
|
|
let r = decimate(std::mem::take(&mut run), MAX_LINE_PTS);
|
|
let r = match smooth {
|
|
Some(plot) => smooth_run(r, plot),
|
|
None => r,
|
|
};
|
|
ctx.line(r, color, width);
|
|
} else {
|
|
run.clear();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Catmull-Rom spline through `pts`: every segment gets the same number of sub-segments (up to
|
|
/// 100), chosen so the whole curve stays within `MAX_LINE_PTS * 2` points. Endpoints are
|
|
/// duplicated so the curve passes through the first and last points, and the curve is clamped
|
|
/// to the `plot` box (a spline overshoots its data near sharp turns).
|
|
fn smooth_run(pts: Vec<[f32; 2]>, plot: Bx) -> Vec<[f32; 2]> {
|
|
let n = pts.len();
|
|
if n < 3 {
|
|
return pts;
|
|
}
|
|
let cap = MAX_LINE_PTS * 2;
|
|
let steps = (cap / n).clamp(1, 100);
|
|
let mut out: Vec<[f32; 2]> = Vec::with_capacity(n.saturating_mul(steps).min(cap) + 1);
|
|
let at = |i: isize| -> [f32; 2] {
|
|
let j = i.clamp(0, n as isize - 1) as usize;
|
|
pts.get(j).copied().unwrap_or([0.0, 0.0])
|
|
};
|
|
let (x0, x1) = (plot.x.min(plot.right()), plot.x.max(plot.right()));
|
|
let (y0, y1) = (plot.y.min(plot.bottom()), plot.y.max(plot.bottom()));
|
|
let clip = |p: [f32; 2]| [p[0].clamp(x0, x1), p[1].clamp(y0, y1)];
|
|
for i in 0..n.saturating_sub(1) {
|
|
let (p0, p1, p2, p3) = (at(i as isize - 1), at(i as isize), at(i as isize + 1), at(i as isize + 2));
|
|
for s in 0..steps {
|
|
let t = s as f32 / steps as f32;
|
|
out.push(clip(catmull(p0, p1, p2, p3, t)));
|
|
}
|
|
}
|
|
out.push(clip(at(n as isize - 1)));
|
|
out
|
|
}
|
|
|
|
/// One Catmull-Rom point between `p1` and `p2` (uniform parameterisation), tangent-scaled by 0.5.
|
|
fn catmull(p0: [f32; 2], p1: [f32; 2], p2: [f32; 2], p3: [f32; 2], t: f32) -> [f32; 2] {
|
|
let t2 = t * t;
|
|
let t3 = t2 * t;
|
|
let mut o = [0.0f32; 2];
|
|
for k in 0..2 {
|
|
let (a0, a1, a2, a3) = (p0[k], p1[k], p2[k], p3[k]);
|
|
o[k] = 0.5 * ((2.0 * a1) + (-a0 + a2) * t + (2.0 * a0 - 5.0 * a1 + 4.0 * a2 - a3) * t2 + (-a0 + 3.0 * a1 - 3.0 * a2 + a3) * t3);
|
|
}
|
|
o
|
|
}
|
|
|
|
pub(super) fn draw_markers(ctx: &mut Ctx, pts: impl Iterator<Item = [f32; 2]>, r: f32, color: Rgba) {
|
|
let mut seen = HashSet::new();
|
|
for p in pts {
|
|
if seen.len() >= MAX_MARKERS {
|
|
break;
|
|
}
|
|
if seen.insert(((p[0] * 0.5) as i32, (p[1] * 0.5) as i32)) {
|
|
ctx.out.push(Prim::Circle { cx: p[0], cy: p[1], r, fill: Some(color), stroke: Some((WHITE, 0.75)) });
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod smooth_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn long_runs_are_smoothed_uniformly_within_the_cap() {
|
|
let plot = Bx { x: 0.0, y: 0.0, w: 3000.0, h: 100.0 };
|
|
let pts: Vec<[f32; 2]> = (0..1500).map(|i| [i as f32 * 2.0, if i % 2 == 0 { 20.0 } else { 80.0 }]).collect();
|
|
let out = smooth_run(pts, plot);
|
|
assert!(out.len() <= MAX_LINE_PTS * 2 + 1, "{}", out.len());
|
|
// Two sub-segments per segment all the way to the end, not 100 each and then a straight tail.
|
|
assert_eq!(out.len(), 1499 * 2 + 1);
|
|
assert_eq!(out.last().copied(), Some([2998.0, 80.0]));
|
|
}
|
|
|
|
#[test]
|
|
fn spline_overshoot_is_clamped_to_the_plot() {
|
|
let plot = Bx { x: 0.0, y: 10.0, w: 300.0, h: 100.0 };
|
|
// A step right at the plot edges: Catmull-Rom overshoots past both.
|
|
let pts = vec![[0.0, 110.0], [100.0, 110.0], [110.0, 10.0], [200.0, 10.0], [300.0, 10.0]];
|
|
let out = smooth_run(pts, plot);
|
|
assert!(out.len() > 5);
|
|
assert!(out.iter().all(|p| (0.0..=300.0).contains(&p[0]) && (10.0..=110.0).contains(&p[1])));
|
|
}
|
|
}
|