Files
gridcraft/crates/chart/src/raster.rs
T
Dylan M. Taylor 10bec74810 M6: misc hardening — unbounded picture cache, stats walk, signing tmpdir, web clipboard, dash budget, fs cycle, rezip loop (#2422)
- floating_pictures: draw without retaining when eviction can't free
  enough room, so a large workbook can't pin the cache past its budget
- xtask stats: skip unreadable subtrees instead of aborting the walk
- import-cert.sh: per-run private temp dir instead of predictable
  paths in a shared root
- gridcraft-web: gate clipboard ops behind user activation
- chart raster: cap dash rendering to the stroked budget
- cli check: mark directories visited by identity on unix so hard-link
  loops can't recurse; reuse one decompression of a reused part
2026-10-24 06:33:19 -04:00

292 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! A small self-written rasteriser: every primitive becomes one or more even-odd filled paths,
//! scan-converted with 4 vertical sub-samples and exact horizontal coverage (anti-aliased).
//! Text is not drawn (tests check geometry, the UI draws real glyphs).
use crate::{Prim, Rgba};
/// Largest image we allocate (pixels).
const MAX_PIXELS: u64 = 64 * 1024 * 1024;
const SUB: usize = 4;
type Path = Vec<Vec<[f32; 2]>>;
struct Canvas {
w: usize,
h: usize,
buf: Vec<u8>,
acc: Vec<f32>,
}
impl Canvas {
fn blend(&mut self, x: usize, y: usize, c: Rgba, cov: f32) {
let a = (cov.clamp(0.0, 1.0) * c[3] as f32 / 255.0).clamp(0.0, 1.0);
if a <= 0.0 {
return;
}
let i = (y * self.w + x) * 4;
if let Some(px) = self.buf.get_mut(i..i + 4) {
let da = px[3] as f32 / 255.0;
let oa = a + da * (1.0 - a);
if oa <= 0.0 {
return;
}
for k in 0..3 {
let s = c[k] as f32;
let d = px[k] as f32;
px[k] = ((s * a + d * da * (1.0 - a)) / oa).round().clamp(0.0, 255.0) as u8;
}
px[3] = (oa * 255.0).round().clamp(0.0, 255.0) as u8;
}
}
/// Fills a multi-contour path with the even-odd rule.
fn fill(&mut self, path: &Path, color: Rgba) {
let mut edges: Vec<[f32; 4]> = Vec::new();
let (mut minx, mut miny, mut maxx, mut maxy) = (f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY);
for c in path {
if c.len() < 3 || !c.iter().all(|p| p[0].is_finite() && p[1].is_finite()) {
continue;
}
for (k, p) in c.iter().enumerate() {
let q = c.get((k + 1) % c.len()).copied().unwrap_or(*p);
minx = minx.min(p[0]);
maxx = maxx.max(p[0]);
miny = miny.min(p[1]);
maxy = maxy.max(p[1]);
if p[1] != q[1] {
edges.push([p[0], p[1], q[0], q[1]]);
}
}
}
if edges.is_empty() {
return;
}
let x0 = minx.floor().max(0.0) as usize;
let x1 = (maxx.ceil().max(0.0) as usize).min(self.w);
let y0 = miny.floor().max(0.0) as usize;
let y1 = (maxy.ceil().max(0.0) as usize).min(self.h);
if x0 >= x1 || y0 >= y1 {
return;
}
let bw = x1 - x0;
if self.acc.len() < bw + 1 {
self.acc.resize(bw + 1, 0.0);
}
let mut xs: Vec<f32> = Vec::new();
for py in y0..y1 {
for v in self.acc.iter_mut().take(bw) {
*v = 0.0;
}
let mut any = false;
for s in 0..SUB {
let sy = py as f32 + (s as f32 + 0.5) / SUB as f32;
xs.clear();
for e in &edges {
let (ya, yb) = if e[1] < e[3] { (e[1], e[3]) } else { (e[3], e[1]) };
if sy >= ya && sy < yb {
xs.push(e[0] + (sy - e[1]) * (e[2] - e[0]) / (e[3] - e[1]));
}
}
xs.sort_by(f32::total_cmp);
for pair in xs.as_chunks::<2>().0 {
let (a, b) = (pair[0].max(x0 as f32), pair[1].min(x1 as f32));
if b <= a {
continue;
}
any = true;
let ia = a.floor() as usize;
let ib = (b.ceil() as usize).min(x1);
for px in ia..ib {
let l = a.max(px as f32);
let r = b.min(px as f32 + 1.0);
if r > l
&& let Some(v) = self.acc.get_mut(px - x0)
{
*v += (r - l) / SUB as f32;
}
}
}
}
if !any {
continue;
}
for px in x0..x1 {
let cov = self.acc.get(px - x0).copied().unwrap_or(0.0);
if cov > 0.0 {
self.blend(px, py, color, cov);
}
}
}
}
}
fn arc(cx: f32, cy: f32, r: f32, a0: f32, a1: f32) -> Vec<[f32; 2]> {
let sweep = a1 - a0;
let n = ((r.abs() * sweep.abs() / 2.0).ceil() as usize).clamp(4, 512);
(0..=n)
.map(|k| {
let a = a0 + sweep * k as f32 / n as f32;
[cx + r * a.sin(), cy - r * a.cos()]
})
.collect()
}
fn circle(cx: f32, cy: f32, r: f32) -> Vec<[f32; 2]> {
let mut c = arc(cx, cy, r, 0.0, std::f32::consts::TAU);
c.pop();
c
}
fn rounded_rect(x: f32, y: f32, w: f32, h: f32, r: f32) -> Vec<[f32; 2]> {
// A negative or NaN extent gives an upper bound of 0 (clamp panics if max < min or NaN).
let r = r.clamp(0.0, (w.min(h) / 2.0).max(0.0));
if r < 0.5 {
return vec![[x, y], [x + w, y], [x + w, y + h], [x, y + h]];
}
use std::f32::consts::{FRAC_PI_2, PI};
let mut v = Vec::new();
v.extend(arc(x + w - r, y + r, r, 0.0, FRAC_PI_2));
v.extend(arc(x + w - r, y + h - r, r, FRAC_PI_2, PI));
v.extend(arc(x + r, y + h - r, r, PI, PI + FRAC_PI_2));
v.extend(arc(x + r, y + r, r, PI + FRAC_PI_2, 2.0 * PI));
v
}
/// Thick polyline → one quad per segment (each a separate path so overlaps don't cancel).
fn stroke_paths(pts: &[[f32; 2]], width: f32, dash: bool, closed: bool) -> Vec<Path> {
let width = width.clamp(1.0, 50.0);
let mut segs: Vec<([f32; 2], [f32; 2])> = pts.windows(2).map(|w| (w[0], w[1])).collect();
if closed && let (Some(a), Some(b)) = (pts.last(), pts.first()) {
segs.push((*a, *b));
}
if dash {
// The per-segment dash cap bounds each input line, but a caller-supplied vector of many
// long dashed lines multiplies: together they would eagerly allocate megabytes of
// geometry even when nothing of it reaches the canvas, so the total is capped too and
// the line is drawn solid once the budget is gone.
let (on, off) = (width * 4.0, width * 3.0);
let mut out = Vec::new();
let mut budget = 65_536usize;
for (a, b) in segs {
if budget == 0 {
return vec![vec![pts.to_vec()]];
}
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let len = (dx * dx + dy * dy).sqrt();
if !(len.is_finite() && len > 0.0) {
continue;
}
let mut t = 0.0;
while t < len && budget > 0 {
let e = (t + on).min(len);
out.push(([a[0] + dx * t / len, a[1] + dy * t / len], [a[0] + dx * e / len, a[1] + dy * e / len]));
t += on + off;
budget -= 1;
}
}
segs = out;
}
let hw = width / 2.0;
segs.into_iter()
.filter_map(|(a, b)| {
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let len = (dx * dx + dy * dy).sqrt();
if !(len.is_finite() && len > 1e-6) {
return None;
}
let (nx, ny) = (-dy / len * hw, dx / len * hw);
Some(vec![vec![[a[0] + nx, a[1] + ny], [b[0] + nx, b[1] + ny], [b[0] - nx, b[1] - ny], [a[0] - nx, a[1] - ny]]])
})
.collect()
}
/// Rasterises primitives to straight RGBA8 (`w*h*4` bytes) over `background`. Empty for a zero
/// or oversized image. Text primitives are skipped.
pub fn rasterize(prims: &[Prim], w: u32, h: u32, background: Rgba) -> Vec<u8> {
let px = (w as u64).saturating_mul(h as u64);
if w == 0 || h == 0 || px > MAX_PIXELS {
return vec![];
}
let (w, h) = (w as usize, h as usize);
let mut buf = Vec::with_capacity(w * h * 4);
for _ in 0..w * h {
buf.extend_from_slice(&background);
}
let mut cv = Canvas { w, h, buf, acc: Vec::new() };
for p in prims {
match p {
Prim::Rect { x, y, w, h, fill, stroke, radius } => {
if let Some(f) = fill {
cv.fill(&vec![rounded_rect(*x, *y, *w, *h, *radius)], *f);
}
if let Some((c, sw)) = stroke {
let sw = sw.clamp(0.5, 50.0);
let outer = rounded_rect(x - sw / 2.0, y - sw / 2.0, w + sw, h + sw, radius + sw / 2.0);
let inner = rounded_rect(x + sw / 2.0, y + sw / 2.0, (w - sw).max(0.0), (h - sw).max(0.0), (radius - sw / 2.0).max(0.0));
cv.fill(&vec![outer, inner], *c);
}
}
Prim::Line { pts, color, width, dash } => {
for path in stroke_paths(pts, *width, *dash, false) {
cv.fill(&path, *color);
}
}
Prim::Polygon { pts, fill, stroke } => {
cv.fill(&vec![pts.clone()], *fill);
if let Some((c, sw)) = stroke {
for path in stroke_paths(pts, *sw, false, true) {
cv.fill(&path, *c);
}
}
}
Prim::Wedge { cx, cy, r_outer, r_inner, a0, a1, fill, stroke } => {
let mut c = arc(*cx, *cy, *r_outer, *a0, *a1);
if *r_inner > 0.0 {
let mut inner = arc(*cx, *cy, *r_inner, *a0, *a1);
inner.reverse();
c.extend(inner);
} else {
c.push([*cx, *cy]);
}
cv.fill(&vec![c.clone()], *fill);
if let Some((sc, sw)) = stroke {
for path in stroke_paths(&c, *sw, false, true) {
cv.fill(&path, *sc);
}
}
}
Prim::Circle { cx, cy, r, fill, stroke } => {
if let Some(f) = fill {
cv.fill(&vec![circle(*cx, *cy, *r)], *f);
}
if let Some((c, sw)) = stroke {
let sw = sw.clamp(0.5, 50.0);
cv.fill(&vec![circle(*cx, *cy, r + sw / 2.0), circle(*cx, *cy, (r - sw / 2.0).max(0.0))], *c);
}
}
Prim::Text { .. } => {}
}
}
cv.buf
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dashed_geometry_stays_within_the_dash_budget() {
// Forty very long dashed lines, each allowed 10_000 dashes per segment: before the
// shared cap they eagerly allocated ~1.6 MB of segment pairs × points and a like
// number of quads before any of it reached a 4-pixel canvas (finding: dash expansion).
let prims: Vec<Prim> =
(0..40).map(|i| Prim::Line { pts: vec![[0.0, i as f32], [900.0, i as f32]], color: [0, 0, 0, 255], width: 1.0, dash: true }).collect();
let img = rasterize(&prims, 2, 2, [255, 255, 255, 255]);
assert_eq!(img.len(), 2 * 2 * 4);
// And the geometry itself is bounded even for one huge dashed polyline.
assert!(stroke_paths(&[[0.0, 0.0], [1e6, 0.0]], 1.0, true, false).len() <= 65_536);
let prim = [Prim::Line { pts: vec![[0.0, 0.0], [10.0, 0.0]], color: [0, 0, 0, 255], width: 1.0, dash: true }];
assert_eq!(rasterize(&prim, 1, 1, [0, 0, 0, 255]).len(), 4);
}
}