Files
gridcraft/crates/chart/src/render/lines.rs
T
Dylan M. Taylor 1920db4da6 chart: bound scan conversion, spline points and category label bytes
- The rasteriser tested every edge of a path at every sub-sample of every
  covered row. It now keeps an active-edge list (edges sorted by top, joining
  and leaving as the scan passes them), and one rasterize call has a total
  scan-work allowance after which remaining fills are skipped.
- Smoothed lines budgeted interpolated points per run, so gap-separated short
  runs escaped both decimation and the cap. The budget is now shared by all
  runs of a line; once spent, runs are drawn through their data points.
- Category labels copied every text value in full. Labels are clipped to 255
  characters before copying, within an 8 MiB budget per series.
2026-11-02 22:18:13 -05:00

187 lines
7.4 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.
///
/// The points interpolation adds are budgeted across all the runs of the line, not per run:
/// short runs between gaps escape decimation, and 100 sub-segments each would turn a
/// 100,000-point series of three-point runs into millions of points. Past the budget the
/// remaining runs are drawn through their data points only.
pub(super) fn draw_runs(ctx: &mut Ctx, pts: &[Option<[f32; 2]>], color: Rgba, width: f32, smooth: Option<Bx>) {
let mut budget = MAX_LINE_PTS * 2;
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) => {
let r = smooth_run(r, plot, budget);
budget = budget.saturating_sub(r.len());
r
}
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 `cap` points (one per data point when `cap` is
/// smaller than that). 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, cap: usize) -> Vec<[f32; 2]> {
let n = pts.len();
if n < 3 {
return pts;
}
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, MAX_LINE_PTS * 2);
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, MAX_LINE_PTS * 2);
assert!(out.len() > 5);
assert!(out.iter().all(|p| (0.0..=300.0).contains(&p[0]) && (10.0..=110.0).contains(&p[1])));
}
/// Security: smoothing budgets its added points across every run of a line. A series of
/// three-point runs between gaps used to get 100 sub-segments per run, 67 times its data.
#[test]
fn gap_separated_runs_share_one_smoothing_budget() {
let plot = Bx { x: 0.0, y: 0.0, w: 1e6, h: 100.0 };
let pts: Vec<Option<[f32; 2]>> =
(0..100_000).map(|i| if i % 4 == 3 { None } else { Some([i as f32, if i % 2 == 0 { 20.0 } else { 80.0 }]) }).collect();
let m = crate::ApproxMeasure;
let mut ctx = Ctx { out: Vec::new(), m: &m };
draw_runs(&mut ctx, &pts, WHITE, 1.0, Some(plot));
let total: usize = ctx.out.iter().map(|p| if let Prim::Line { pts, .. } = p { pts.len() } else { 0 }).sum();
// 75,000 data points, plus at most the budget added by interpolation.
assert!(total <= 75_000 + MAX_LINE_PTS * 2, "{total}");
assert_eq!(ctx.out.len(), 25_000);
// The first runs are still smoothed.
assert!(matches!(ctx.out.first(), Some(Prim::Line { pts, .. }) if pts.len() > 3));
}
}