Skip to main content

siplot/core/
scatter_viz.rs

1//! Scatter visualization algorithms (silx `Scatter` / `ScatterVisualizationMixIn`).
2//!
3//! Pure, GPU-free transforms that turn unstructured `(x, y, value)` points into
4//! renderable structures that the existing render paths can later consume:
5//!
6//! - [`delaunay`] — Bowyer-Watson incremental Delaunay triangulation of 2D
7//!   points, the basis for silx's `SOLID` and `IRREGULAR_GRID` visualizations
8//!   (silx uses matplotlib's `Triangulation`; see
9//!   `gui/plot/items/scatter.py::_getTriangulationFuture`).
10//! - [`solid_triangles`] — `Visualization.SOLID`: per-vertex-colored
11//!   [`Triangles`] (silx `scatter.py:610-625`, `backend.addTriangles`).
12//! - [`irregular_grid_image`] — `Visualization.IRREGULAR_GRID`: the
13//!   triangulation rasterized to a value image by barycentric linear
14//!   interpolation (silx `LinearTriInterpolator`).
15//! - [`detect_regular_grid`] — `Visualization.REGULAR_GRID` auto-detection
16//!   (`scatter.py::_guess_grid` / `_guess_z_grid_shape`, `core.py:1303-1308`).
17//! - [`binned_statistic`] — `Visualization.BINNED_STATISTIC`: 2D binning with
18//!   per-bin mean/count/sum (`core.py:1325-1329`, `scatter.py::__getHistogramInfo`).
19//! - [`PointsViz`] — `Visualization.POINTS` data carrier with the optional
20//!   per-point alpha array (silx `scatter.py` per-point alpha).
21//!
22//! Faithful to the silx semantics; GPU wiring (feeding these into
23//! `backend_wgpu.rs` / `high_level.rs`) is deferred to a later wave.
24//!
25//! Mode-specific picking (silx `Scatter.pick`, `scatter.py:804-861`) is provided
26//! for the grid modes that map a rendered cell back to source points:
27//! [`regular_grid_pick`] (REGULAR_GRID) and [`BinnedStatistic::pick`]
28//! (BINNED_STATISTIC). POINTS/SOLID use plain nearest-point picking (silx's
29//! default `super().pick()`); IRREGULAR_GRID has no 1:1 cell→point mapping in
30//! siplot's interpolated-image render (silx picks Delaunay triangles), so it is
31//! intentionally not covered here.
32
33use egui::Color32;
34
35use crate::core::triangles::Triangles;
36
37/// Major order of points in a regular grid (silx
38/// `VisualizationParameter.GRID_MAJOR_ORDER`, `core.py:1303-1308`).
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40pub enum GridMajorOrder {
41    /// `"row"`: X (column) is the fast dimension — points fill the first row
42    /// left-to-right, then the next row, etc.
43    Row,
44    /// `"column"`: Y (row) is the fast dimension — points fill the first
45    /// column, then the next column, etc.
46    Column,
47}
48
49/// The reduction function applied to each bin in a binned statistic (silx
50/// `VisualizationParameter.BINNED_STATISTIC_FUNCTION`, `core.py:1325-1329`).
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub enum BinnedStatisticFunction {
53    /// `"mean"` (default): per-bin `sum / count`, `NaN` for empty bins.
54    Mean,
55    /// `"count"`: number of points in the bin.
56    Count,
57    /// `"sum"`: sum of values in the bin.
58    Sum,
59}
60
61// ===========================================================================
62// 1. Delaunay triangulation (Bowyer-Watson)
63// ===========================================================================
64
65/// A planar Delaunay triangulation: `triangles[i]` holds three indices into the
66/// input point array (silx's matplotlib `Triangulation.triangles`).
67#[derive(Clone, Debug, PartialEq, Eq)]
68pub struct Triangulation {
69    /// Triangle vertex-index triples into the input `(x, y)` arrays.
70    pub triangles: Vec<[usize; 3]>,
71}
72
73impl Triangulation {
74    /// Number of triangles.
75    #[must_use]
76    pub fn len(&self) -> usize {
77        self.triangles.len()
78    }
79
80    /// Whether the triangulation is empty (degenerate / collinear input).
81    #[must_use]
82    pub fn is_empty(&self) -> bool {
83        self.triangles.is_empty()
84    }
85}
86
87/// Twice the signed area of triangle `(a, b, c)`; positive when the vertices are
88/// counter-clockwise. Used for orientation and degeneracy tests.
89fn orient2d(a: [f64; 2], b: [f64; 2], c: [f64; 2]) -> f64 {
90    (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
91}
92
93/// Returns `true` when point `p` lies strictly inside the circumcircle of the
94/// triangle `(a, b, c)`, which is assumed counter-clockwise. This is the
95/// classic 3x3 in-circle determinant; with CCW orientation a positive
96/// determinant means `p` is inside.
97fn in_circumcircle(a: [f64; 2], b: [f64; 2], c: [f64; 2], p: [f64; 2]) -> bool {
98    let ax = a[0] - p[0];
99    let ay = a[1] - p[1];
100    let bx = b[0] - p[0];
101    let by = b[1] - p[1];
102    let cx = c[0] - p[0];
103    let cy = c[1] - p[1];
104
105    let a2 = ax * ax + ay * ay;
106    let b2 = bx * bx + by * by;
107    let c2 = cx * cx + cy * cy;
108
109    let det = ax * (by * c2 - b2 * cy) - ay * (bx * c2 - b2 * cx) + a2 * (bx * cy - by * cx);
110    det > 0.0
111}
112
113/// Bowyer-Watson incremental Delaunay triangulation of 2D points.
114///
115/// Points are inserted one at a time into a super-triangle large enough to
116/// contain all of them; triangles whose circumcircle contains the new point are
117/// removed and the resulting cavity is re-triangulated. Triangles touching the
118/// super-triangle vertices are discarded at the end, leaving only triangles
119/// over the input points.
120///
121/// Returns an empty triangulation when fewer than 3 finite points are given or
122/// when all points are collinear/coincident (no triangle has nonzero area) —
123/// silx surfaces this as "Cannot get a triangulation" and skips the renderer.
124///
125/// `x` and `y` must have the same length. Non-finite points are ignored, as
126/// silx masks them out before triangulating
127/// (`scatter.py::_getTriangulationFuture`).
128#[must_use]
129pub fn delaunay(x: &[f64], y: &[f64]) -> Triangulation {
130    assert_eq!(x.len(), y.len(), "x and y must have the same length");
131
132    // Collect finite points, remembering their original indices so the output
133    // refers to the caller's arrays.
134    let pts: Vec<(usize, [f64; 2])> = x
135        .iter()
136        .zip(y)
137        .enumerate()
138        .filter(|&(_, (&xi, &yi))| xi.is_finite() && yi.is_finite())
139        .map(|(i, (&xi, &yi))| (i, [xi, yi]))
140        .collect();
141
142    if pts.len() < 3 {
143        return Triangulation { triangles: vec![] };
144    }
145
146    // Reject fully-degenerate input (all collinear/coincident): no triangle can
147    // have nonzero area, so a Delaunay triangulation does not exist.
148    let p0 = pts[0].1;
149    let has_area = pts.iter().enumerate().any(|(i, &(_, pi))| {
150        pts[i + 1..]
151            .iter()
152            .any(|&(_, pj)| orient2d(p0, pi, pj).abs() > 0.0)
153    });
154    if !has_area {
155        return Triangulation { triangles: vec![] };
156    }
157
158    // Build a super-triangle that strictly contains every point.
159    let (mut min_x, mut min_y, mut max_x, mut max_y) = (
160        f64::INFINITY,
161        f64::INFINITY,
162        f64::NEG_INFINITY,
163        f64::NEG_INFINITY,
164    );
165    for &(_, [px, py]) in &pts {
166        min_x = min_x.min(px);
167        min_y = min_y.min(py);
168        max_x = max_x.max(px);
169        max_y = max_y.max(py);
170    }
171    let dx = max_x - min_x;
172    let dy = max_y - min_y;
173    let d = dx.max(dy).max(f64::MIN_POSITIVE);
174    let mid_x = 0.5 * (min_x + max_x);
175    let mid_y = 0.5 * (min_y + max_y);
176    // Three vertices well outside the bounding box. The factor 20 gives ample
177    // margin so the super-triangle contains the whole point set.
178    let st0 = [mid_x - 20.0 * d, mid_y - d];
179    let st1 = [mid_x, mid_y + 20.0 * d];
180    let st2 = [mid_x + 20.0 * d, mid_y - d];
181
182    // Working vertex list: input points first, then the three super vertices.
183    let n = pts.len();
184    let mut verts: Vec<[f64; 2]> = pts.iter().map(|&(_, p)| p).collect();
185    verts.push(st0);
186    verts.push(st1);
187    verts.push(st2);
188    let s0 = n;
189    let s1 = n + 1;
190    let s2 = n + 2;
191
192    // Each triangle stored CCW.
193    let mut tris: Vec<[usize; 3]> = vec![ccw(&verts, [s0, s1, s2])];
194
195    for ip in 0..n {
196        let p = verts[ip];
197
198        // Find triangles whose circumcircle contains p ("bad" triangles).
199        let mut bad: Vec<usize> = Vec::new();
200        for (ti, t) in tris.iter().enumerate() {
201            if in_circumcircle(verts[t[0]], verts[t[1]], verts[t[2]], p) {
202                bad.push(ti);
203            }
204        }
205
206        // Collect the boundary of the cavity: edges that belong to exactly one
207        // bad triangle.
208        let mut boundary: Vec<[usize; 2]> = Vec::new();
209        for &bi in &bad {
210            let t = tris[bi];
211            for &(a, b) in &[(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
212                // Is edge (a,b) shared with another bad triangle?
213                let shared = bad
214                    .iter()
215                    .any(|&oi| oi != bi && triangle_has_edge(&tris[oi], a, b));
216                if !shared {
217                    boundary.push([a, b]);
218                }
219            }
220        }
221
222        // Remove bad triangles (descending index order keeps indices valid).
223        bad.sort_unstable();
224        for &bi in bad.iter().rev() {
225            tris.swap_remove(bi);
226        }
227
228        // Re-triangulate the cavity by connecting p to each boundary edge.
229        for [a, b] in boundary {
230            tris.push(ccw(&verts, [a, b, ip]));
231        }
232    }
233
234    // Drop triangles that touch a super-triangle vertex and remap to original
235    // input indices.
236    let original: Vec<usize> = pts.iter().map(|&(i, _)| i).collect();
237    let triangles: Vec<[usize; 3]> = tris
238        .into_iter()
239        .filter(|t| t.iter().all(|&v| v < n))
240        .map(|t| [original[t[0]], original[t[1]], original[t[2]]])
241        .collect();
242
243    Triangulation { triangles }
244}
245
246/// Order a triangle's three vertices counter-clockwise (positive signed area).
247fn ccw(verts: &[[f64; 2]], t: [usize; 3]) -> [usize; 3] {
248    if orient2d(verts[t[0]], verts[t[1]], verts[t[2]]) < 0.0 {
249        [t[0], t[2], t[1]]
250    } else {
251        t
252    }
253}
254
255/// Whether triangle `t` has the undirected edge `(a, b)`.
256fn triangle_has_edge(t: &[usize; 3], a: usize, b: usize) -> bool {
257    let edges = [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])];
258    edges
259        .iter()
260        .any(|&(u, v)| (u == a && v == b) || (u == b && v == a))
261}
262
263// ===========================================================================
264// 2. SOLID / IRREGULAR_GRID
265// ===========================================================================
266
267/// Build the per-vertex-colored [`Triangles`] for `Visualization.SOLID`.
268///
269/// Each input point keeps its color (the caller maps `value` through a colormap
270/// before calling, as silx does in `__applyColormapToData`), and the Delaunay
271/// triangulation over the points provides the mesh
272/// (silx `scatter.py:610-625`, `backend.addTriangles`).
273///
274/// `x`, `y`, and `colors` must have equal length. Returns `None` when the
275/// points cannot be triangulated (fewer than 3 finite points or collinear),
276/// matching silx's "Cannot display as solid surface" early-out.
277#[must_use]
278pub fn solid_triangles(x: &[f64], y: &[f64], colors: &[Color32]) -> Option<Triangles> {
279    assert_eq!(x.len(), y.len(), "x and y must have the same length");
280    assert_eq!(
281        colors.len(),
282        x.len(),
283        "colors must have one entry per vertex"
284    );
285
286    let tri = delaunay(x, y);
287    if tri.is_empty() {
288        return None;
289    }
290
291    let indices: Vec<[u32; 3]> = tri
292        .triangles
293        .iter()
294        .map(|t| {
295            [
296                u32::try_from(t[0]).expect("vertex index fits in u32"),
297                u32::try_from(t[1]).expect("vertex index fits in u32"),
298                u32::try_from(t[2]).expect("vertex index fits in u32"),
299            ]
300        })
301        .collect();
302
303    Some(Triangles::new(
304        x.to_vec(),
305        y.to_vec(),
306        indices,
307        colors.to_vec(),
308    ))
309}
310
311/// Compute the `(dim0+1) × (dim1+1)` grid of quadrilateral cell corners for a
312/// `dim0 × dim1` row-major grid of points, faithful to silx
313/// `_quadrilateral_grid_coords` (items/scatter.py:191-235). Each interior
314/// corner is the mean of its four surrounding points; the edge midpoints and
315/// the four outer corners are linearly extrapolated so every input point sits
316/// inside its own cell. `points[r * dim1 + c]` is the point at grid row `r`,
317/// column `c`. Requires `dim0 >= 2` and `dim1 >= 2`.
318fn quadrilateral_grid_coords(points: &[[f64; 2]], dim0: usize, dim1: usize) -> Vec<[f64; 2]> {
319    debug_assert!(dim0 >= 2 && dim1 >= 2);
320    debug_assert_eq!(points.len(), dim0 * dim1);
321    let gw = dim1 + 1; // corner-grid width
322    let mut grid = vec![[0.0_f64; 2]; (dim0 + 1) * gw];
323    let p = |r: usize, c: usize| points[r * dim1 + c];
324    // Mean of the four points around interior corner (r+1, c+1) of the corner
325    // grid (silx inner_points); `r`,`c` index the dim0-1 × dim1-1 inner block.
326    let inner = |r: usize, c: usize| -> [f64; 2] {
327        let (a, b, d, e) = (p(r, c), p(r, c + 1), p(r + 1, c), p(r + 1, c + 1));
328        [
329            (a[0] + b[0] + d[0] + e[0]) / 4.0,
330            (a[1] + b[1] + d[1] + e[1]) / 4.0,
331        ]
332    };
333    for r in 0..dim0 - 1 {
334        for c in 0..dim1 - 1 {
335            grid[(r + 1) * gw + (c + 1)] = inner(r, c);
336        }
337    }
338    // Vertical sides: left corner column (index 0) and right (index dim1).
339    // silx: x = points[r][cc] + points[r+1][cc] - inner.x ; y = inner.y.
340    for r in 0..dim0 - 1 {
341        let il = inner(r, 0);
342        grid[(r + 1) * gw] = [p(r, 0)[0] + p(r + 1, 0)[0] - il[0], il[1]];
343        let ir = inner(r, dim1 - 2);
344        grid[(r + 1) * gw + dim1] = [p(r, dim1 - 1)[0] + p(r + 1, dim1 - 1)[0] - ir[0], ir[1]];
345    }
346    // Horizontal sides: top corner row (index 0) and bottom (index dim0).
347    // silx: x = inner.x ; y = points[rr][c] + points[rr][c+1] - inner.y.
348    for c in 0..dim1 - 1 {
349        let it = inner(0, c);
350        grid[c + 1] = [it[0], p(0, c)[1] + p(0, c + 1)[1] - it[1]];
351        let ib = inner(dim0 - 2, c);
352        grid[dim0 * gw + (c + 1)] = [ib[0], p(dim0 - 1, c)[1] + p(dim0 - 1, c + 1)[1] - ib[1]];
353    }
354    // Four outer corners: grid_corner = 2 * point_corner - inner_corner.
355    let corner = |pr: usize, pc: usize, ir: usize, ic: usize| -> [f64; 2] {
356        let (pp, ii) = (p(pr, pc), inner(ir, ic));
357        [2.0 * pp[0] - ii[0], 2.0 * pp[1] - ii[1]]
358    };
359    grid[0] = corner(0, 0, 0, 0);
360    grid[dim1] = corner(0, dim1 - 1, 0, dim1 - 2);
361    grid[dim0 * gw + dim1] = corner(dim0 - 1, dim1 - 1, dim0 - 2, dim1 - 2);
362    grid[dim0 * gw] = corner(dim0 - 1, 0, dim0 - 2, 0);
363    grid
364}
365
366/// Build the quadrilateral grid as triangles for a `dim0 × dim1` row-major grid
367/// of points, faithful to silx `_quadrilateral_grid_as_triangles`
368/// (items/scatter.py:238-263). Returns `(coords, indices)` with `4 * N` corner
369/// vertices and `2 * N` triangles (`N = dim0 * dim1`): point `k`'s quad owns
370/// vertices `4k..4k+4` and triangles `2k`/`2k+1`, so a picked triangle `t` maps
371/// to source point `t / 2` (silx's vertex `// 4`).
372fn quadrilateral_grid_as_triangles(
373    points: &[[f64; 2]],
374    dim0: usize,
375    dim1: usize,
376) -> (Vec<[f64; 2]>, Vec<[u32; 3]>) {
377    let nbpoints = dim0 * dim1;
378    let grid = quadrilateral_grid_coords(points, dim0, dim1);
379    let gw = dim1 + 1;
380    let mut coords = vec![[0.0_f64; 2]; 4 * nbpoints];
381    for r in 0..dim0 {
382        for c in 0..dim1 {
383            let k = r * dim1 + c;
384            coords[4 * k] = grid[r * gw + c];
385            coords[4 * k + 1] = grid[(r + 1) * gw + c];
386            coords[4 * k + 2] = grid[r * gw + (c + 1)];
387            coords[4 * k + 3] = grid[(r + 1) * gw + (c + 1)];
388        }
389    }
390    let mut indices = Vec::with_capacity(2 * nbpoints);
391    for k in 0..nbpoints {
392        let b = u32::try_from(4 * k).expect("vertex index fits in u32");
393        indices.push([b, b + 1, b + 2]);
394        indices.push([b + 1, b + 2, b + 3]);
395    }
396    (coords, indices)
397}
398
399/// Arrange unstructured `(x, y)` points onto a detected grid for
400/// `Visualization.IRREGULAR_GRID`, faithful to silx's points-array construction
401/// (items/scatter.py:682-775). Returns `(points, dim0, dim1, swap_xy)`: a flat
402/// row-major `dim0 × dim1` grid whose first `x.len()` entries are the input
403/// points in data order (later entries pad an incomplete grid), and `swap_xy`
404/// telling the caller to swap the resulting coordinates back (silx stores a
405/// column-major grid transposed as `(y, x)`).
406///
407/// Returns `None` when fewer than two points are given (silx renders a single
408/// point as a square marker, handled by the caller) or no grid can be guessed.
409fn arrange_irregular_grid_points(
410    x: &[f64],
411    y: &[f64],
412) -> Option<(Vec<[f64; 2]>, usize, usize, bool)> {
413    let nbpoints = x.len();
414    if nbpoints < 2 {
415        return None;
416    }
417    let grid = detect_regular_grid(x, y)?;
418    let (mut s0, mut s1) = grid.shape; // (rows, cols)
419    let order = grid.order;
420    // silx: grow the shape so it includes every point.
421    if nbpoints != s0 * s1 {
422        match order {
423            GridMajorOrder::Row => s0 = nbpoints.div_ceil(s1),
424            GridMajorOrder::Column => s1 = nbpoints.div_ceil(s0),
425        }
426    }
427
428    // Single-line case (silx 721-741): a grid dimension collapses to < 2.
429    if s0 < 2 || s1 < 2 {
430        let row_order = s0 == 1;
431        // First line in silx's (a, b) convention: (x, y) row, (y, x) column.
432        let line: Vec<[f64; 2]> = (0..nbpoints)
433            .map(|i| {
434                if row_order {
435                    [x[i], y[i]]
436                } else {
437                    [y[i], x[i]]
438                }
439            })
440            .collect();
441        // Second line: each point offset by the perpendicular of the local
442        // segment direction — silx's cross with the +z axis, (dx, dy) ↦ (dy, -dx)
443        // — so the swept cells have area. The last point reuses the prior step.
444        let mut points = Vec::with_capacity(2 * nbpoints);
445        points.extend_from_slice(&line);
446        for i in 0..nbpoints {
447            let (dx, dy) = if i + 1 < nbpoints {
448                (line[i + 1][0] - line[i][0], line[i + 1][1] - line[i][1])
449            } else {
450                (line[i][0] - line[i - 1][0], line[i][1] - line[i - 1][1])
451            };
452            points.push([line[i][0] + dy, line[i][1] - dx]);
453        }
454        return Some((points, 2, nbpoints, !row_order));
455    }
456
457    // Full / partial 2D grid (silx 743-775).
458    let total = s0 * s1;
459    let mut points = vec![[0.0_f64; 2]; total];
460    match order {
461        GridMajorOrder::Row => {
462            for i in 0..nbpoints {
463                points[i] = [x[i], y[i]];
464            }
465            if nbpoints != total {
466                // Pad the incomplete last row with a tail slice of x and the
467                // last y (silx 744-755).
468                let index = (nbpoints / s1) * s1; // start of last full row
469                let pad = total - nbpoints;
470                let last_y = y[nbpoints - 1];
471                for j in 0..pad {
472                    points[nbpoints + j] = [x[index - pad + j], last_y];
473                }
474            }
475            Some((points, s0, s1, false))
476        }
477        GridMajorOrder::Column => {
478            // silx stores column-major as (y, x) with dims transposed.
479            for i in 0..nbpoints {
480                points[i] = [y[i], x[i]];
481            }
482            if nbpoints != total {
483                let index = (nbpoints / s0) * s0; // start of last full column
484                let pad = total - nbpoints;
485                let last_x = x[nbpoints - 1];
486                for j in 0..pad {
487                    points[nbpoints + j] = [y[index - pad + j], last_x];
488                }
489            }
490            Some((points, s1, s0, true))
491        }
492    }
493}
494
495/// Build the per-vertex-colored [`Triangles`] for `Visualization.IRREGULAR_GRID`,
496/// faithful to silx's quadrilateral-grid render (items/scatter.py:682-797).
497///
498/// The points are arranged onto the detected grid
499/// (`arrange_irregular_grid_points`), expanded into a dual grid of cell
500/// corners, and emitted as `2` flat-shaded triangles per input point: every
501/// point owns four consecutive vertices carrying its colormapped `colors[k]`
502/// (silx `gridcolors[first::4] = rgbacolors[:nbpoints]`). The caller maps
503/// `value` through a colormap before calling, as silx's `__applyColormapToData`
504/// does.
505///
506/// `x`, `y`, and `colors` must have equal length. Returns `None` when the points
507/// do not form a guessable grid or fewer than two are given (silx renders a lone
508/// point as a square marker).
509#[must_use]
510pub fn irregular_grid_triangles(x: &[f64], y: &[f64], colors: &[Color32]) -> Option<Triangles> {
511    assert_eq!(x.len(), y.len(), "x and y must have the same length");
512    assert_eq!(
513        colors.len(),
514        x.len(),
515        "colors must have one entry per point"
516    );
517    let nbpoints = x.len();
518    let (points, dim0, dim1, swap_xy) = arrange_irregular_grid_points(x, y)?;
519    let (mut coords, mut indices) = quadrilateral_grid_as_triangles(&points, dim0, dim1);
520    // Keep only the real points' quads (silx coords[:4*nb], indices[:2*nb]).
521    coords.truncate(4 * nbpoints);
522    indices.truncate(2 * nbpoints);
523    let (vx, vy): (Vec<f64>, Vec<f64>) = if swap_xy {
524        coords.iter().map(|c| (c[1], c[0])).unzip()
525    } else {
526        coords.iter().map(|c| (c[0], c[1])).unzip()
527    };
528    // Flat-shade each cell: point k's four vertices share colors[k].
529    let mut vcolors = Vec::with_capacity(4 * nbpoints);
530    for &c in colors {
531        vcolors.extend_from_slice(&[c; 4]);
532    }
533    Some(Triangles::new(vx, vy, indices, vcolors))
534}
535
536/// The scatter point index for an `IRREGULAR_GRID` pick at data coordinates
537/// `(px, py)`, mirroring silx `Scatter.pick`'s IRREGULAR_GRID branch
538/// (items/scatter.py:810-813: picked vertex `// 4`).
539///
540/// `mesh` is the triangle mesh built by [`irregular_grid_triangles`]. The first
541/// triangle whose interior (with a tiny edge tolerance) contains the cursor maps
542/// to its source point via `triangle_index / 2` (each point owns two triangles).
543/// Returns `None` when the cursor lies outside every cell.
544#[must_use]
545pub fn irregular_grid_pick(mesh: &Triangles, px: f64, py: f64) -> Option<usize> {
546    for (t, tri) in mesh.indices.iter().enumerate() {
547        let v = |i: usize| [mesh.x[i], mesh.y[i]];
548        let (a, b, c) = (v(tri[0] as usize), v(tri[1] as usize), v(tri[2] as usize));
549        if barycentric(a, b, c, [px, py]).is_some() {
550            return Some(t / 2);
551        }
552    }
553    None
554}
555
556/// Barycentric coordinates of point `p` within triangle `(a, b, c)`.
557///
558/// Returns `None` when `p` lies outside the triangle (any coordinate negative
559/// beyond `eps`) or the triangle is degenerate (zero area). Coordinates sum to
560/// `1` and weight `a`, `b`, `c` respectively.
561fn barycentric(a: [f64; 2], b: [f64; 2], c: [f64; 2], p: [f64; 2]) -> Option<[f64; 3]> {
562    let det = orient2d(a, b, c);
563    if det == 0.0 {
564        return None;
565    }
566    // w_a uses (b, c), w_b uses (c, a), w_c uses (a, b); each is the sub-triangle
567    // signed area over the total, all divided by the same det.
568    let wa = orient2d(b, c, p) / det;
569    let wb = orient2d(c, a, p) / det;
570    let wc = orient2d(a, b, p) / det;
571    // Allow a tiny tolerance on edges so vertices/edges sample as inside.
572    let eps = -1e-9;
573    if wa >= eps && wb >= eps && wc >= eps {
574        Some([wa, wb, wc])
575    } else {
576        None
577    }
578}
579
580/// Linearly interpolate the value at `p` over a triangulation by barycentric
581/// weighting of the three triangle-vertex values (silx `LinearTriInterpolator`).
582///
583/// Returns `None` when `p` lies outside every triangle of the triangulation —
584/// matching `LinearTriInterpolator`, which yields masked (no) values outside the
585/// convex hull.
586///
587/// `values[i]` is the value at point `(x[i], y[i])`. The three arrays must have
588/// equal length and match the indices stored in `tri`.
589#[must_use]
590pub fn interpolate(
591    tri: &Triangulation,
592    x: &[f64],
593    y: &[f64],
594    values: &[f64],
595    px: f64,
596    py: f64,
597) -> Option<f64> {
598    let p = [px, py];
599    for t in &tri.triangles {
600        let a = [x[t[0]], y[t[0]]];
601        let b = [x[t[1]], y[t[1]]];
602        let c = [x[t[2]], y[t[2]]];
603        if let Some([wa, wb, wc]) = barycentric(a, b, c, p) {
604            return Some(wa * values[t[0]] + wb * values[t[1]] + wc * values[t[2]]);
605        }
606    }
607    None
608}
609
610/// A line profile sampled across scattered data — the result of
611/// [`scatter_line_profile`] (silx `ScatterProfileToolBar` profile).
612#[derive(Clone, Debug, PartialEq, Default)]
613pub struct ScatterLineProfile {
614    /// Sample positions `[x, y]` evenly spaced along the profile segment
615    /// (silx `points` from `numpy.linspace`).
616    pub points: Vec<[f64; 2]>,
617    /// Interpolated value at each sample, index-aligned with `points`. `None`
618    /// where the sample falls outside the scatter's convex hull (silx `NaN`).
619    pub values: Vec<Option<f64>>,
620}
621
622impl ScatterLineProfile {
623    /// Convert the profile to a `(distance, value)` curve for plotting against
624    /// distance along the segment — the form silx `ScatterProfileToolBar` shows
625    /// in its profile window. `distance[i]` is the Euclidean distance from the
626    /// first sample to `points[i]` (`0` at the start, increasing along the line);
627    /// `value[i]` is the interpolated value with out-of-hull samples (`None`)
628    /// mapped to `f64::NAN` so they render as gaps (silx keeps them `NaN`).
629    /// Returns empty vectors for an empty profile.
630    #[must_use]
631    pub fn distance_value_curve(&self) -> (Vec<f64>, Vec<f64>) {
632        let Some(&[x0, y0]) = self.points.first() else {
633            return (Vec::new(), Vec::new());
634        };
635        let distance = self
636            .points
637            .iter()
638            .map(|&[x, y]| (x - x0).hypot(y - y0))
639            .collect();
640        let value = self.values.iter().map(|v| v.unwrap_or(f64::NAN)).collect();
641        (distance, value)
642    }
643}
644
645/// Sample a line profile across scattered `(x, y, values)` data — silx
646/// `ScatterProfileToolBar` / `_computeProfile` (`tools/profile/rois.py:737-762`).
647///
648/// Places `n_points` samples evenly along the segment `start`..`end`
649/// (`numpy.linspace(.., endpoint=True)`) and interpolates each through the
650/// scatter's Delaunay triangulation (silx `LinearNDInterpolator`, via
651/// [`delaunay`] + [`interpolate`]): a sample outside the convex hull (no
652/// containing triangle) yields `None`, mirroring silx's `NaN`. Returns the
653/// sample positions paired with their interpolated values.
654///
655/// Fewer than 3 input points — or collinear points — build no triangles, so
656/// every value is `None`. With `n_points == 1` the `start` point is the sole
657/// sample; `n_points == 0` returns empty vectors. `values` must be index-aligned
658/// with `x`/`y` (same length), like [`interpolate`].
659pub fn scatter_line_profile(
660    x: &[f64],
661    y: &[f64],
662    values: &[f64],
663    start: (f64, f64),
664    end: (f64, f64),
665    n_points: usize,
666) -> ScatterLineProfile {
667    let tri = delaunay(x, y);
668    let mut points = Vec::with_capacity(n_points);
669    let mut profile = Vec::with_capacity(n_points);
670    for i in 0..n_points {
671        let t = if n_points <= 1 {
672            0.0
673        } else {
674            i as f64 / (n_points - 1) as f64
675        };
676        let px = start.0 + (end.0 - start.0) * t;
677        let py = start.1 + (end.1 - start.1) * t;
678        points.push([px, py]);
679        profile.push(interpolate(&tri, x, y, values, px, py));
680    }
681    ScatterLineProfile {
682        points,
683        values: profile,
684    }
685}
686
687/// An image grid of interpolated/binned values with an affine data placement
688/// (silx `addImage(data, origin, scale)`).
689#[derive(Clone, Debug, PartialEq)]
690pub struct GridImage {
691    /// Row-major value grid, `shape.0` rows by `shape.1` columns. Cells with no
692    /// value hold `NaN`.
693    pub data: Vec<f64>,
694    /// `(rows, cols)` of `data` (silx `(height, width)`).
695    pub shape: (usize, usize),
696    /// Data coordinate of the lower-left pixel center origin `(x, y)`
697    /// (silx `origin`).
698    pub origin: (f64, f64),
699    /// Data-space size of one pixel `(sx, sy)` (silx `scale`).
700    pub scale: (f64, f64),
701}
702
703impl GridImage {
704    /// Value at row `r`, column `c` (row-major), or `None` if out of bounds.
705    #[must_use]
706    pub fn get(&self, r: usize, c: usize) -> Option<f64> {
707        if r < self.shape.0 && c < self.shape.1 {
708            Some(self.data[r * self.shape.1 + c])
709        } else {
710            None
711        }
712    }
713
714    /// The grid cell `(row, col)` containing data coordinates `(x, y)`, or `None`
715    /// when the point falls outside the image. Mirrors a backend image pick:
716    /// `col = ⌊(x − ox) / sx⌋`, `row = ⌊(y − oy) / sy⌋`, bounds-checked against
717    /// the shape. A negative scale (a reversed-bounds regular grid) is handled —
718    /// numerator and scale flip sign together so the cell index stays in range.
719    #[must_use]
720    pub fn cell(&self, x: f64, y: f64) -> Option<(usize, usize)> {
721        grid_cell(self.shape, self.origin, self.scale, x, y)
722    }
723}
724
725/// Map data coordinates `(x, y)` to the row-major grid cell `(row, col)` for a
726/// grid of `shape` (rows, cols) placed at `origin` with per-axis `scale`, or
727/// `None` when the point is outside the grid or `scale` is zero / non-finite.
728/// Shared by [`GridImage::cell`] and [`BinnedStatistic::pick`].
729fn grid_cell(
730    shape: (usize, usize),
731    origin: (f64, f64),
732    scale: (f64, f64),
733    x: f64,
734    y: f64,
735) -> Option<(usize, usize)> {
736    let (sx, sy) = scale;
737    if sx == 0.0 || sy == 0.0 {
738        return None;
739    }
740    let cf = (x - origin.0) / sx;
741    let rf = (y - origin.1) / sy;
742    if !cf.is_finite() || !rf.is_finite() || cf < 0.0 || rf < 0.0 {
743        return None;
744    }
745    let col = cf.floor() as usize;
746    let row = rf.floor() as usize;
747    if row < shape.0 && col < shape.1 {
748        Some((row, col))
749    } else {
750        None
751    }
752}
753
754/// The scatter point index for a [`crate::core::scatter_viz`]
755/// `Visualization.REGULAR_GRID` pick at data coordinates `(x, y)`, mirroring silx
756/// `Scatter.pick` REGULAR_GRID branch (items/scatter.py:815-835).
757///
758/// `image` is the rendered grid (its `shape`/`origin`/`scale`), `order` the grid
759/// major order, and `point_count` the number of scatter points. The picked image
760/// cell `(row, col)` maps to a source index by the major order — `row * cols +
761/// col` for [`GridMajorOrder::Row`], `row + col * rows` for
762/// [`GridMajorOrder::Column`] (siplot stores column-major points transposed into
763/// the row-major image, so this inverts that placement). Returns `None` when the
764/// cursor is off the grid, or when the cell maps past the last point (silx: "image
765/// can be larger than scatter").
766#[must_use]
767pub fn regular_grid_pick(
768    image: &GridImage,
769    order: GridMajorOrder,
770    point_count: usize,
771    x: f64,
772    y: f64,
773) -> Option<usize> {
774    let (row, col) = image.cell(x, y)?;
775    let (rows, cols) = image.shape;
776    let index = match order {
777        GridMajorOrder::Row => row * cols + col,
778        GridMajorOrder::Column => row + col * rows,
779    };
780    if index < point_count {
781        Some(index)
782    } else {
783        None
784    }
785}
786
787/// Rasterize a triangulation to a `rows x cols` value image by barycentric
788/// linear interpolation, for `Visualization.IRREGULAR_GRID`.
789///
790/// The image covers the axis-aligned bounding box of the finite input points.
791/// Pixel `(r, c)` samples the value at the pixel center; pixels whose center
792/// falls outside the triangulated convex hull are left `NaN` (no value), like
793/// `LinearTriInterpolator`'s masked output.
794///
795/// Returns `None` when the points cannot be triangulated or `rows`/`cols` is 0.
796#[must_use]
797pub fn irregular_grid_image(
798    x: &[f64],
799    y: &[f64],
800    values: &[f64],
801    rows: usize,
802    cols: usize,
803) -> Option<GridImage> {
804    assert_eq!(x.len(), y.len(), "x and y must have the same length");
805    assert_eq!(
806        values.len(),
807        x.len(),
808        "values must have one entry per point"
809    );
810    if rows == 0 || cols == 0 {
811        return None;
812    }
813
814    let tri = delaunay(x, y);
815    if tri.is_empty() {
816        return None;
817    }
818
819    let (mut min_x, mut min_y, mut max_x, mut max_y) = (
820        f64::INFINITY,
821        f64::INFINITY,
822        f64::NEG_INFINITY,
823        f64::NEG_INFINITY,
824    );
825    for (&xi, &yi) in x.iter().zip(y) {
826        if xi.is_finite() && yi.is_finite() {
827            min_x = min_x.min(xi);
828            min_y = min_y.min(yi);
829            max_x = max_x.max(xi);
830            max_y = max_y.max(yi);
831        }
832    }
833
834    // Pixel size: span divided by the number of pixels.
835    let sx = if cols > 0 {
836        (max_x - min_x) / cols as f64
837    } else {
838        1.0
839    };
840    let sy = if rows > 0 {
841        (max_y - min_y) / rows as f64
842    } else {
843        1.0
844    };
845
846    let mut data = vec![f64::NAN; rows * cols];
847    for r in 0..rows {
848        // Pixel-center Y, row 0 at the bottom (data min).
849        let py = min_y + (r as f64 + 0.5) * sy;
850        for c in 0..cols {
851            let px = min_x + (c as f64 + 0.5) * sx;
852            if let Some(v) = interpolate(&tri, x, y, values, px, py) {
853                data[r * cols + c] = v;
854            }
855        }
856    }
857
858    Some(GridImage {
859        data,
860        shape: (rows, cols),
861        origin: (min_x, min_y),
862        scale: (sx, sy),
863    })
864}
865
866// ===========================================================================
867// 3. REGULAR_GRID detection
868// ===========================================================================
869
870/// The detected shape and ordering of a regular grid (silx `_guess_grid`
871/// result + `_RegularGridInfo.order`).
872#[derive(Clone, Copy, Debug, PartialEq, Eq)]
873pub struct RegularGrid {
874    /// `(height, width)` = `(rows, cols)` of the grid (silx shape convention).
875    pub shape: (usize, usize),
876    /// Major order of the points (silx `GRID_MAJOR_ORDER`).
877    pub order: GridMajorOrder,
878}
879
880/// Length of one line of a Z-like (snake-free, same-direction) regular grid
881/// from a coordinate array, faithful to silx `_get_z_line_length`.
882///
883/// Looks at the sign of consecutive differences: a regular grid scanned the
884/// same way each line shows the fast coordinate stepping in one sign, then a
885/// single reversal (the opposite sign) at each line boundary. The line length
886/// is the constant spacing between those reversals. Returns 0 when no constant
887/// line length is found.
888fn get_z_line_length(array: &[f64]) -> usize {
889    if array.len() < 2 {
890        return 0;
891    }
892    let sign: Vec<i8> = array
893        .windows(2)
894        .map(|w| {
895            let d = w[1] - w[0];
896            if d > 0.0 {
897                1
898            } else if d < 0.0 {
899                -1
900            } else {
901                0
902            }
903        })
904        .collect();
905    // silx: if no diffs or the first diff is flat, give up.
906    if sign.is_empty() || sign[0] == 0 {
907        return 0;
908    }
909    let first = sign[0];
910    // Indices (in the original array) where the coordinate reverses direction:
911    // these mark the beginning of a new line. silx uses `where(sign == -sign[0]) + 1`.
912    let beginnings: Vec<usize> = sign
913        .iter()
914        .enumerate()
915        .filter(|&(_, &s)| s == -first)
916        .map(|(i, _)| i + 1)
917        .collect();
918    if beginnings.is_empty() {
919        return 0;
920    }
921    let length = beginnings[0];
922    // All inter-beginning gaps must equal the first line length.
923    let uniform = beginnings.windows(2).all(|w| w[1] - w[0] == length);
924    if uniform { length } else { 0 }
925}
926
927/// Guess a Z-like regular grid shape from `(x, y)` coordinates, faithful to silx
928/// `_guess_z_grid_shape`.
929///
930/// Tries X as the fast (row-major) dimension first; if X yields a line length,
931/// the grid is row-major with that width. Otherwise tries Y as the fast
932/// (column-major) dimension. Returns `None` when neither yields a line length.
933fn guess_z_grid_shape(x: &[f64], y: &[f64]) -> Option<RegularGrid> {
934    let n = x.len();
935    let width = get_z_line_length(x);
936    if width != 0 {
937        let height = n.div_ceil(width);
938        return Some(RegularGrid {
939            shape: (height, width),
940            order: GridMajorOrder::Row,
941        });
942    }
943    let height = get_z_line_length(y);
944    if height != 0 {
945        let width = n.div_ceil(height);
946        return Some(RegularGrid {
947            shape: (height, width),
948            order: GridMajorOrder::Column,
949        });
950    }
951    None
952}
953
954/// Whether `array` is monotonic: `1` increasing, `-1` decreasing, `0` neither
955/// (silx `is_monotonic`). Equal consecutive elements count as both directions.
956fn is_monotonic(array: &[f64]) -> i8 {
957    if array.len() < 2 {
958        // numpy.diff of length<2 is empty; all() of empty is True for both.
959        return 1;
960    }
961    let diffs: Vec<f64> = array.windows(2).map(|w| w[1] - w[0]).collect();
962    if diffs.iter().all(|&d| d >= 0.0) {
963        1
964    } else if diffs.iter().all(|&d| d <= 0.0) {
965        -1
966    } else {
967        0
968    }
969}
970
971/// Auto-detect that the points lie on a regular grid, faithful to silx
972/// `_guess_grid` (`scatter.py:148-188`).
973///
974/// First tries a Z-like 2D grid via `guess_z_grid_shape`. Failing that, falls
975/// back to a single line when either coordinate is monotonic (the line runs
976/// along whichever axis varies more), reported as row-major. Returns `None`
977/// when the points form neither a grid nor a guessable line.
978///
979/// `x` and `y` must have equal length.
980#[must_use]
981pub fn detect_regular_grid(x: &[f64], y: &[f64]) -> Option<RegularGrid> {
982    assert_eq!(x.len(), y.len(), "x and y must have the same length");
983    if x.is_empty() {
984        return None;
985    }
986
987    if let Some(grid) = guess_z_grid_shape(x, y) {
988        return Some(grid);
989    }
990
991    // Fallback: a single line if either coordinate is monotonic.
992    let y_monotonic = is_monotonic(y) != 0;
993    let x_monotonic = is_monotonic(x) != 0;
994    if x_monotonic || y_monotonic {
995        let (x_min, x_max) = min_max(x);
996        let (y_min, y_max) = min_max(y);
997        let shape = if !y_monotonic || (x_max - x_min) >= (y_max - y_min) {
998            // line along X
999            (1, x.len())
1000        } else {
1001            // line along Y
1002            (y.len(), 1)
1003        };
1004        Some(RegularGrid {
1005            shape,
1006            order: GridMajorOrder::Row, // order does not matter for a single line
1007        })
1008    } else {
1009        None
1010    }
1011}
1012
1013/// Min and max of `array`, ignoring non-finite values (silx `min_max`).
1014/// Returns `(NaN, NaN)` for an all-non-finite or empty array.
1015fn min_max(array: &[f64]) -> (f64, f64) {
1016    let mut min = f64::INFINITY;
1017    let mut max = f64::NEG_INFINITY;
1018    for &v in array {
1019        if v.is_finite() {
1020            min = min.min(v);
1021            max = max.max(v);
1022        }
1023    }
1024    if min > max {
1025        (f64::NAN, f64::NAN)
1026    } else {
1027        (min, max)
1028    }
1029}
1030
1031// ===========================================================================
1032// 4. BINNED_STATISTIC
1033// ===========================================================================
1034
1035/// Per-bin statistics over a 2D binning of scatter points (silx
1036/// `_HistogramInfo` / `Histogramnd`, `core.py:1325-1329`).
1037///
1038/// All three grids are row-major, `shape.0` rows (Y) by `shape.1` columns (X),
1039/// matching silx's `(height, width)` convention.
1040#[derive(Clone, Debug, PartialEq)]
1041pub struct BinnedStatistic {
1042    /// Per-bin mean `sum / count`; `NaN` where `count == 0` (silx
1043    /// `numpy.errstate(divide="ignore")` then `sums / counts`).
1044    pub mean: Vec<f64>,
1045    /// Per-bin point count.
1046    pub count: Vec<u64>,
1047    /// Per-bin value sum; `0.0` for an empty bin.
1048    pub sum: Vec<f64>,
1049    /// `(rows, cols)` = `(height, width)` of the binning (silx shape).
1050    pub shape: (usize, usize),
1051    /// Data coordinate of the lower-left bin-edge origin `(x, y)`
1052    /// (silx `origin = xEdges[0], yEdges[0]`).
1053    pub origin: (f64, f64),
1054    /// Data-space bin width `(sx, sy)` (silx `scale`).
1055    pub scale: (f64, f64),
1056}
1057
1058impl BinnedStatistic {
1059    /// The reduction grid selected by `func`, as `f64` (count promoted),
1060    /// row-major (silx `getattr(histoInfo, function)`).
1061    #[must_use]
1062    pub fn select(&self, func: BinnedStatisticFunction) -> Vec<f64> {
1063        match func {
1064            BinnedStatisticFunction::Mean => self.mean.clone(),
1065            BinnedStatisticFunction::Count => self.count.iter().map(|&c| c as f64).collect(),
1066            BinnedStatisticFunction::Sum => self.sum.clone(),
1067        }
1068    }
1069
1070    /// The indices of all scatter points falling in the bin under data
1071    /// coordinates `(px, py)`, mirroring silx `Scatter.pick` BINNED_STATISTIC
1072    /// branch (items/scatter.py:837-859).
1073    ///
1074    /// The picked bin `(row, col)` expands back to its data range
1075    /// `[ox + sx·col, ox + sx·(col+1)) × [oy + sy·row, oy + sy·(row+1))` and
1076    /// every point inside it is returned (silx
1077    /// `numpy.nonzero(logical_and(...))`). The upper bin edges are exclusive, as
1078    /// in silx. `x` and `y` are the scatter point coordinates; pairs beyond the
1079    /// shorter slice are ignored. Returns `None` when the cursor is off the grid
1080    /// or no point lies in the bin (silx returns no pick).
1081    #[must_use]
1082    pub fn pick(&self, x: &[f64], y: &[f64], px: f64, py: f64) -> Option<Vec<usize>> {
1083        let (row, col) = grid_cell(self.shape, self.origin, self.scale, px, py)?;
1084        let (ox, oy) = self.origin;
1085        let (sx, sy) = self.scale;
1086        let x_lo = ox + sx * col as f64;
1087        let x_hi = ox + sx * (col + 1) as f64;
1088        let y_lo = oy + sy * row as f64;
1089        let y_hi = oy + sy * (row + 1) as f64;
1090        let indices: Vec<usize> = x
1091            .iter()
1092            .zip(y.iter())
1093            .enumerate()
1094            .filter(|&(_, (&xi, &yi))| xi >= x_lo && xi < x_hi && yi >= y_lo && yi < y_hi)
1095            .map(|(i, _)| i)
1096            .collect();
1097        if indices.is_empty() {
1098            None
1099        } else {
1100            Some(indices)
1101        }
1102    }
1103}
1104
1105/// Bin `(x, y, value)` points into a `rows x cols` grid over the data extent and
1106/// compute per-bin mean/count/sum (silx `Visualization.BINNED_STATISTIC`,
1107/// `scatter.py::__getHistogramInfo`).
1108///
1109/// The grid spans the finite-point bounding box of X and Y. A point is assigned
1110/// to the bin `floor((coord - min) / binsize)`, with the upper edge clamped into
1111/// the last bin so the maximum point is included (matching `Histogramnd`'s
1112/// inclusive last edge). Non-finite points and points with a non-finite value
1113/// are skipped.
1114///
1115/// Returns `None` when there are no finite points or `rows`/`cols` is 0.
1116///
1117/// `x`, `y`, and `values` must have equal length.
1118#[must_use]
1119pub fn binned_statistic(
1120    x: &[f64],
1121    y: &[f64],
1122    values: &[f64],
1123    rows: usize,
1124    cols: usize,
1125) -> Option<BinnedStatistic> {
1126    assert_eq!(x.len(), y.len(), "x and y must have the same length");
1127    assert_eq!(
1128        values.len(),
1129        x.len(),
1130        "values must have one entry per point"
1131    );
1132    if rows == 0 || cols == 0 {
1133        return None;
1134    }
1135
1136    let (x_min, x_max) = min_max(x);
1137    let (y_min, y_max) = min_max(y);
1138    if !x_min.is_finite() || !y_min.is_finite() {
1139        return None; // no finite points
1140    }
1141
1142    // Bin sizes; degenerate (single-valued) ranges get a unit bin to avoid /0.
1143    let sx = {
1144        let span = x_max - x_min;
1145        if span > 0.0 { span / cols as f64 } else { 1.0 }
1146    };
1147    let sy = {
1148        let span = y_max - y_min;
1149        if span > 0.0 { span / rows as f64 } else { 1.0 }
1150    };
1151
1152    let mut count = vec![0u64; rows * cols];
1153    let mut sum = vec![0.0f64; rows * cols];
1154
1155    for ((&xi, &yi), &vi) in x.iter().zip(y).zip(values) {
1156        if !xi.is_finite() || !yi.is_finite() || !vi.is_finite() {
1157            continue;
1158        }
1159        // Column from X, row from Y.
1160        let mut c = ((xi - x_min) / sx).floor() as isize;
1161        let mut r = ((yi - y_min) / sy).floor() as isize;
1162        // Clamp the inclusive upper edge into the last bin.
1163        if c >= cols as isize {
1164            c = cols as isize - 1;
1165        }
1166        if r >= rows as isize {
1167            r = rows as isize - 1;
1168        }
1169        if c < 0 || r < 0 {
1170            continue; // outside the lower edge (only non-finite would do this)
1171        }
1172        let idx = r as usize * cols + c as usize;
1173        count[idx] += 1;
1174        sum[idx] += vi;
1175    }
1176
1177    let mean: Vec<f64> = count
1178        .iter()
1179        .zip(&sum)
1180        .map(|(&c, &s)| if c == 0 { f64::NAN } else { s / c as f64 })
1181        .collect();
1182
1183    Some(BinnedStatistic {
1184        mean,
1185        count,
1186        sum,
1187        shape: (rows, cols),
1188        origin: (x_min, y_min),
1189        scale: (sx, sy),
1190    })
1191}
1192
1193// ===========================================================================
1194// 5. Per-point alpha (POINTS-mode data carrier)
1195// ===========================================================================
1196
1197/// `Visualization.POINTS` data carrier with optional per-point alpha (silx
1198/// `Scatter` per-point alpha array, applied in `__applyColormapToData`).
1199///
1200/// Pure data only; GPU blending of the per-point alpha is deferred to a later
1201/// wave. Colors are pre-mapped through the colormap by the caller, as in silx.
1202#[derive(Clone, Debug, PartialEq)]
1203pub struct PointsViz {
1204    /// Point X coordinates.
1205    pub x: Vec<f64>,
1206    /// Point Y coordinates (same length as `x`).
1207    pub y: Vec<f64>,
1208    /// Per-point value (same length as `x`).
1209    pub values: Vec<f64>,
1210    /// Per-point colormap colors (same length as `x`).
1211    pub colors: Vec<Color32>,
1212    /// Optional per-point alpha in `[0, 1]` (silx `__alpha`), same length as `x`
1213    /// when present. `None` means a uniform global alpha applies instead.
1214    pub alpha: Option<Vec<f64>>,
1215}
1216
1217impl PointsViz {
1218    /// Build a POINTS carrier with no per-point alpha. Panics if `y`, `values`,
1219    /// or `colors` do not match `x` in length.
1220    #[must_use]
1221    pub fn new(x: Vec<f64>, y: Vec<f64>, values: Vec<f64>, colors: Vec<Color32>) -> Self {
1222        assert_eq!(x.len(), y.len(), "x and y must have the same length");
1223        assert_eq!(
1224            values.len(),
1225            x.len(),
1226            "values must have one entry per point"
1227        );
1228        assert_eq!(
1229            colors.len(),
1230            x.len(),
1231            "colors must have one entry per point"
1232        );
1233        Self {
1234            x,
1235            y,
1236            values,
1237            colors,
1238            alpha: None,
1239        }
1240    }
1241
1242    /// Attach a per-point alpha array (silx `setData(..., alpha=...)`), each
1243    /// clamped to `[0, 1]`. Panics if `alpha` does not match `x` in length.
1244    #[must_use]
1245    pub fn with_alpha(mut self, alpha: Vec<f64>) -> Self {
1246        assert_eq!(
1247            alpha.len(),
1248            self.x.len(),
1249            "alpha must have one entry per point"
1250        );
1251        self.alpha = Some(alpha.into_iter().map(|a| a.clamp(0.0, 1.0)).collect());
1252        self
1253    }
1254
1255    /// Number of points.
1256    #[must_use]
1257    pub fn len(&self) -> usize {
1258        self.x.len()
1259    }
1260
1261    /// Whether there are no points.
1262    #[must_use]
1263    pub fn is_empty(&self) -> bool {
1264        self.x.is_empty()
1265    }
1266}
1267
1268#[cfg(test)]
1269mod tests {
1270    use super::*;
1271
1272    // --- Delaunay triangulation ---------------------------------------------
1273
1274    #[test]
1275    fn delaunay_three_points_one_triangle() {
1276        let tri = delaunay(&[0.0, 1.0, 0.0], &[0.0, 0.0, 1.0]);
1277        assert_eq!(tri.len(), 1);
1278        // The single triangle references all three input points.
1279        let mut refs = tri.triangles[0];
1280        refs.sort_unstable();
1281        assert_eq!(refs, [0, 1, 2]);
1282    }
1283
1284    #[test]
1285    fn delaunay_four_convex_points_two_triangles() {
1286        // Unit square, convex position -> exactly two triangles.
1287        let x = [0.0, 1.0, 1.0, 0.0];
1288        let y = [0.0, 0.0, 1.0, 1.0];
1289        let tri = delaunay(&x, &y);
1290        assert_eq!(tri.len(), 2);
1291        // Every input point is referenced by at least one triangle.
1292        let mut seen = [false; 4];
1293        for t in &tri.triangles {
1294            for &v in t {
1295                seen[v] = true;
1296            }
1297        }
1298        assert!(seen.iter().all(|&s| s), "every input point referenced");
1299    }
1300
1301    #[test]
1302    fn delaunay_collinear_points_empty() {
1303        // All on the line y = x: no triangle has area.
1304        let tri = delaunay(&[0.0, 1.0, 2.0, 3.0], &[0.0, 1.0, 2.0, 3.0]);
1305        assert!(tri.is_empty(), "collinear input -> empty triangulation");
1306    }
1307
1308    #[test]
1309    fn delaunay_fewer_than_three_points_empty() {
1310        assert!(delaunay(&[0.0, 1.0], &[0.0, 1.0]).is_empty());
1311        assert!(delaunay(&[], &[]).is_empty());
1312    }
1313
1314    #[test]
1315    fn delaunay_ignores_non_finite_points() {
1316        let x = [0.0, 1.0, 0.0, f64::NAN];
1317        let y = [0.0, 0.0, 1.0, 5.0];
1318        let tri = delaunay(&x, &y);
1319        // The NaN point is dropped, leaving a single triangle over indices 0,1,2.
1320        assert_eq!(tri.len(), 1);
1321        for t in &tri.triangles {
1322            assert!(
1323                t.iter().all(|&v| v < 3),
1324                "no triangle references the NaN point"
1325            );
1326        }
1327    }
1328
1329    #[test]
1330    fn delaunay_property_no_point_inside_circumcircle() {
1331        // Fixed small set in general position.
1332        let x = [0.0, 1.0, 2.0, 0.5, 1.5, 1.0];
1333        let y = [0.0, 0.2, 0.0, 1.0, 1.1, 2.0];
1334        let tri = delaunay(&x, &y);
1335        assert!(!tri.is_empty());
1336        // Delaunay property: no input point lies strictly inside any triangle's
1337        // circumcircle (excluding the triangle's own vertices).
1338        for t in &tri.triangles {
1339            let a = [x[t[0]], y[t[0]]];
1340            let b = [x[t[1]], y[t[1]]];
1341            let c = [x[t[2]], y[t[2]]];
1342            let (a, b, c) = if orient2d(a, b, c) < 0.0 {
1343                (a, c, b)
1344            } else {
1345                (a, b, c)
1346            };
1347            for i in 0..x.len() {
1348                if i == t[0] || i == t[1] || i == t[2] {
1349                    continue;
1350                }
1351                let p = [x[i], y[i]];
1352                assert!(
1353                    !in_circumcircle(a, b, c, p),
1354                    "point {i} inside circumcircle of triangle {t:?}"
1355                );
1356            }
1357        }
1358    }
1359
1360    // --- SOLID --------------------------------------------------------------
1361
1362    #[test]
1363    fn solid_triangles_colors_each_vertex() {
1364        let x = [0.0, 1.0, 0.0];
1365        let y = [0.0, 0.0, 1.0];
1366        let colors = [Color32::RED, Color32::GREEN, Color32::BLUE];
1367        let t = solid_triangles(&x, &y, &colors).expect("triangulable");
1368        assert_eq!(t.indices.len(), 1);
1369        assert_eq!(t.colors, colors);
1370        assert_eq!(t.x, x);
1371        assert_eq!(t.y, y);
1372    }
1373
1374    #[test]
1375    fn solid_triangles_none_for_collinear() {
1376        let x = [0.0, 1.0, 2.0];
1377        let y = [0.0, 1.0, 2.0];
1378        let colors = [Color32::RED; 3];
1379        assert!(solid_triangles(&x, &y, &colors).is_none());
1380    }
1381
1382    // --- Barycentric interpolation ------------------------------------------
1383
1384    #[test]
1385    fn interpolate_at_vertices_returns_vertex_value() {
1386        let x = [0.0, 1.0, 0.0];
1387        let y = [0.0, 0.0, 1.0];
1388        let values = [10.0, 20.0, 30.0];
1389        let tri = delaunay(&x, &y);
1390        for i in 0..3 {
1391            let v = interpolate(&tri, &x, &y, &values, x[i], y[i]).expect("inside");
1392            assert!((v - values[i]).abs() < 1e-9, "vertex {i}: {v}");
1393        }
1394    }
1395
1396    #[test]
1397    fn interpolate_at_centroid_returns_mean() {
1398        let x = [0.0, 1.0, 0.0];
1399        let y = [0.0, 0.0, 1.0];
1400        let values = [10.0, 20.0, 30.0];
1401        let tri = delaunay(&x, &y);
1402        let cx = (x[0] + x[1] + x[2]) / 3.0;
1403        let cy = (y[0] + y[1] + y[2]) / 3.0;
1404        let v = interpolate(&tri, &x, &y, &values, cx, cy).expect("inside");
1405        let mean = (10.0 + 20.0 + 30.0) / 3.0;
1406        assert!((v - mean).abs() < 1e-9, "centroid value {v} != mean {mean}");
1407    }
1408
1409    #[test]
1410    fn interpolate_outside_returns_none() {
1411        let x = [0.0, 1.0, 0.0];
1412        let y = [0.0, 0.0, 1.0];
1413        let values = [10.0, 20.0, 30.0];
1414        let tri = delaunay(&x, &y);
1415        // Well outside the triangle.
1416        assert!(interpolate(&tri, &x, &y, &values, 5.0, 5.0).is_none());
1417        assert!(interpolate(&tri, &x, &y, &values, -1.0, -1.0).is_none());
1418    }
1419
1420    #[test]
1421    fn scatter_line_profile_interpolates_affine_field_along_line() {
1422        // Triangle with an affine field v = x + 2y: (0,0)=0, (2,0)=2, (0,2)=4.
1423        // Linear (barycentric) interpolation reproduces it exactly, so a line
1424        // from (0,0) to (1,1) samples 0, 1.5, 3.0 at t = 0, 0.5, 1.
1425        let x = [0.0, 2.0, 0.0];
1426        let y = [0.0, 0.0, 2.0];
1427        let values = [0.0, 2.0, 4.0];
1428        let prof = scatter_line_profile(&x, &y, &values, (0.0, 0.0), (1.0, 1.0), 3);
1429        assert_eq!(prof.points, vec![[0.0, 0.0], [0.5, 0.5], [1.0, 1.0]]);
1430        let got: Vec<f64> = prof
1431            .values
1432            .iter()
1433            .map(|v| v.expect("inside hull"))
1434            .collect();
1435        for (g, want) in got.iter().zip([0.0, 1.5, 3.0]) {
1436            assert!((g - want).abs() < 1e-9, "got {g}, want {want}");
1437        }
1438    }
1439
1440    #[test]
1441    fn scatter_line_profile_outside_hull_is_none() {
1442        // A segment entirely outside the triangle's convex hull: every sample
1443        // falls in no triangle, so every value is None (silx NaN).
1444        let x = [0.0, 2.0, 0.0];
1445        let y = [0.0, 0.0, 2.0];
1446        let values = [0.0, 2.0, 4.0];
1447        let prof = scatter_line_profile(&x, &y, &values, (5.0, 5.0), (9.0, 9.0), 4);
1448        assert!(prof.values.iter().all(Option::is_none), "{:?}", prof.values);
1449    }
1450
1451    #[test]
1452    fn scatter_line_profile_too_few_points_yields_no_values() {
1453        // Fewer than 3 input points build no triangles -> all None.
1454        let x = [0.0, 1.0];
1455        let y = [0.0, 1.0];
1456        let values = [1.0, 2.0];
1457        let prof = scatter_line_profile(&x, &y, &values, (0.0, 0.0), (1.0, 1.0), 2);
1458        assert_eq!(prof.points.len(), 2);
1459        assert!(prof.values.iter().all(Option::is_none));
1460    }
1461
1462    #[test]
1463    fn distance_value_curve_is_distance_from_start_with_nan_gaps() {
1464        // Samples at (0,0),(3,4),(6,8): distances from the first are 0, 5, 10.
1465        // The middle value is None (out of hull) -> NaN in the curve.
1466        let prof = ScatterLineProfile {
1467            points: vec![[0.0, 0.0], [3.0, 4.0], [6.0, 8.0]],
1468            values: vec![Some(1.0), None, Some(2.0)],
1469        };
1470        let (distance, value) = prof.distance_value_curve();
1471        assert_eq!(distance, vec![0.0, 5.0, 10.0]);
1472        assert_eq!(value[0], 1.0);
1473        assert!(value[1].is_nan(), "out-of-hull sample maps to NaN");
1474        assert_eq!(value[2], 2.0);
1475    }
1476
1477    #[test]
1478    fn distance_value_curve_empty_profile_is_empty() {
1479        let prof = ScatterLineProfile::default();
1480        assert_eq!(prof.distance_value_curve(), (Vec::new(), Vec::new()));
1481    }
1482
1483    // --- IRREGULAR_GRID image -----------------------------------------------
1484
1485    #[test]
1486    fn irregular_grid_image_interpolates_inside_nan_outside() {
1487        // Right triangle with a plane value field z = x (value equals x coord).
1488        let x = [0.0, 4.0, 0.0];
1489        let y = [0.0, 0.0, 4.0];
1490        let values = [0.0, 4.0, 0.0];
1491        let img = irregular_grid_image(&x, &y, &values, 4, 4).expect("triangulable");
1492        assert_eq!(img.shape, (4, 4));
1493        // Bottom-left pixel center (0.5, 0.5) is inside; value ~= x = 0.5.
1494        let v = img.get(0, 0).unwrap();
1495        assert!((v - 0.5).abs() < 1e-9, "interior value {v}");
1496        // Top-right pixel center (3.5, 3.5) is outside the triangle -> NaN.
1497        let outside = img.get(3, 3).unwrap();
1498        assert!(
1499            outside.is_nan(),
1500            "exterior pixel should be NaN, got {outside}"
1501        );
1502        assert_eq!(img.origin, (0.0, 0.0));
1503        assert_eq!(img.scale, (1.0, 1.0));
1504    }
1505
1506    #[test]
1507    fn irregular_grid_image_none_for_degenerate() {
1508        assert!(
1509            irregular_grid_image(&[0.0, 1.0, 2.0], &[0.0, 1.0, 2.0], &[1.0, 2.0, 3.0], 4, 4)
1510                .is_none()
1511        );
1512        assert!(
1513            irregular_grid_image(&[0.0, 1.0, 0.0], &[0.0, 0.0, 1.0], &[1.0, 2.0, 3.0], 0, 4)
1514                .is_none()
1515        );
1516    }
1517
1518    // --- REGULAR_GRID detection ---------------------------------------------
1519
1520    /// Build a 3 rows x 4 cols row-major grid (X fast).
1521    fn grid_3x4_row_major() -> (Vec<f64>, Vec<f64>) {
1522        let (rows, cols) = (3usize, 4usize);
1523        let mut x = Vec::new();
1524        let mut y = Vec::new();
1525        for r in 0..rows {
1526            for c in 0..cols {
1527                x.push(c as f64);
1528                y.push(r as f64);
1529            }
1530        }
1531        (x, y)
1532    }
1533
1534    #[test]
1535    fn detect_regular_grid_row_major_3x4() {
1536        let (x, y) = grid_3x4_row_major();
1537        let grid = detect_regular_grid(&x, &y).expect("grid detected");
1538        assert_eq!(grid.shape, (3, 4));
1539        assert_eq!(grid.order, GridMajorOrder::Row);
1540    }
1541
1542    #[test]
1543    fn detect_regular_grid_column_major_3x4() {
1544        // Y fast: fill column 0 top-to-bottom, then column 1, etc. (height=3).
1545        let (rows, cols) = (3usize, 4usize);
1546        let mut x = Vec::new();
1547        let mut y = Vec::new();
1548        for c in 0..cols {
1549            for r in 0..rows {
1550                x.push(c as f64);
1551                y.push(r as f64);
1552            }
1553        }
1554        let grid = detect_regular_grid(&x, &y).expect("grid detected");
1555        assert_eq!(grid.shape, (3, 4));
1556        assert_eq!(grid.order, GridMajorOrder::Column);
1557    }
1558
1559    #[test]
1560    fn detect_regular_grid_rejects_random_scatter() {
1561        // Both coords have irregular direction reversals (no Z-grid line length)
1562        // and neither is monotonic -> no grid, no line.
1563        // x signs: + - + + -  (reversals at idx 2,5: gaps 2 then 3, non-uniform)
1564        // y signs: - + + - +  (reversals at idx 1,5: gaps 1 then 4, non-uniform)
1565        let x = [0.0, 1.0, 0.5, 2.0, 3.0, 1.0];
1566        let y = [2.0, 0.0, 1.0, 3.0, 0.5, 4.0];
1567        assert!(detect_regular_grid(&x, &y).is_none());
1568    }
1569
1570    #[test]
1571    fn detect_regular_grid_single_line_along_x() {
1572        // X strictly increasing (monotonic, no Z-grid line length), Y has
1573        // irregular reversals (no Z-grid, not monotonic) -> falls to the line
1574        // branch; Y not monotonic forces the line along X -> shape (1, N).
1575        let x = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0];
1576        let y = [2.0, 0.0, 1.0, 3.0, 0.5, 4.0];
1577        let grid = detect_regular_grid(&x, &y).expect("line detected");
1578        assert_eq!(grid.shape, (1, 6));
1579    }
1580
1581    // --- BINNED_STATISTIC ---------------------------------------------------
1582
1583    #[test]
1584    fn binned_statistic_2x2_mean_count_sum() {
1585        // Points in [0,2]x[0,2], 2x2 bins -> bin size 1x1.
1586        // Bin layout (row=Y, col=X):
1587        //   (r0,c0): x in [0,1), y in [0,1)
1588        //   (r0,c1): x in [1,2], y in [0,1)
1589        //   (r1,c0): x in [0,1), y in [1,2]
1590        //   (r1,c1): x in [1,2], y in [1,2]
1591        // Place: two points in (r0,c0); one in (r1,c1); leave (r0,c1),(r1,c0) empty.
1592        // (0,0) and (2,2) pin the data extent to [0,2]x[0,2] so bins are 1x1.
1593        let x = [0.0, 0.5, 2.0];
1594        let y = [0.0, 0.5, 2.0];
1595        let v = [10.0, 30.0, 7.0];
1596        let bs = binned_statistic(&x, &y, &v, 2, 2).expect("binned");
1597        assert_eq!(bs.shape, (2, 2));
1598
1599        // (r0,c0) = index 0: count 2, sum 40, mean 20.
1600        assert_eq!(bs.count[0], 2);
1601        assert!((bs.sum[0] - 40.0).abs() < 1e-12);
1602        assert!((bs.mean[0] - 20.0).abs() < 1e-12);
1603
1604        // (r0,c1) = index 1: empty.
1605        assert_eq!(bs.count[1], 0);
1606        assert_eq!(bs.sum[1], 0.0);
1607        assert!(bs.mean[1].is_nan(), "empty bin mean is NaN");
1608
1609        // (r1,c0) = index 2: empty.
1610        assert_eq!(bs.count[2], 0);
1611
1612        // (r1,c1) = index 3: count 1, sum 7, mean 7.
1613        assert_eq!(bs.count[3], 1);
1614        assert!((bs.sum[3] - 7.0).abs() < 1e-12);
1615        assert!((bs.mean[3] - 7.0).abs() < 1e-12);
1616
1617        // Geometry.
1618        assert_eq!(bs.origin, (0.0, 0.0));
1619        assert_eq!(bs.scale, (1.0, 1.0));
1620    }
1621
1622    #[test]
1623    fn binned_statistic_max_point_clamped_into_last_bin() {
1624        // Point exactly at the max edge must land in the last bin, not overflow.
1625        let x = [0.0, 2.0];
1626        let y = [0.0, 2.0];
1627        let v = [1.0, 2.0];
1628        let bs = binned_statistic(&x, &y, &v, 2, 2).expect("binned");
1629        // (2,2) is the upper corner -> last bin (r1,c1) = index 3.
1630        assert_eq!(bs.count[3], 1);
1631        assert!((bs.sum[3] - 2.0).abs() < 1e-12);
1632        // (0,0) -> first bin.
1633        assert_eq!(bs.count[0], 1);
1634    }
1635
1636    #[test]
1637    fn binned_statistic_select_returns_chosen_grid() {
1638        let x = [0.2, 1.5];
1639        let y = [0.2, 1.5];
1640        let v = [10.0, 7.0];
1641        let bs = binned_statistic(&x, &y, &v, 2, 2).expect("binned");
1642        let counts = bs.select(BinnedStatisticFunction::Count);
1643        assert_eq!(counts, vec![1.0, 0.0, 0.0, 1.0]);
1644        let sums = bs.select(BinnedStatisticFunction::Sum);
1645        assert_eq!(sums, vec![10.0, 0.0, 0.0, 7.0]);
1646        let means = bs.select(BinnedStatisticFunction::Mean);
1647        assert!((means[0] - 10.0).abs() < 1e-12);
1648        assert!(means[1].is_nan());
1649    }
1650
1651    #[test]
1652    fn binned_statistic_none_for_empty_or_zero_shape() {
1653        assert!(binned_statistic(&[], &[], &[], 2, 2).is_none());
1654        assert!(binned_statistic(&[0.0], &[0.0], &[1.0], 0, 2).is_none());
1655    }
1656
1657    #[test]
1658    fn binned_statistic_skips_non_finite_value() {
1659        let x = [0.2, 0.4];
1660        let y = [0.2, 0.4];
1661        let v = [10.0, f64::NAN];
1662        let bs = binned_statistic(&x, &y, &v, 2, 2).expect("binned");
1663        // Only the finite-valued point is counted in bin 0.
1664        assert_eq!(bs.count[0], 1);
1665        assert!((bs.sum[0] - 10.0).abs() < 1e-12);
1666    }
1667
1668    // --- mode-specific picking (silx Scatter.pick) --------------------------
1669
1670    #[test]
1671    fn regular_grid_pick_row_major_maps_cell_to_index() {
1672        // 2 rows x 3 cols, row-major, unit scale at origin (0,0); 6 points.
1673        let image = GridImage {
1674            data: vec![0.0; 6],
1675            shape: (2, 3),
1676            origin: (0.0, 0.0),
1677            scale: (1.0, 1.0),
1678        };
1679        // (col 2, row 0) -> 0*3 + 2 = 2.
1680        assert_eq!(
1681            regular_grid_pick(&image, GridMajorOrder::Row, 6, 2.5, 0.5),
1682            Some(2)
1683        );
1684        // (col 0, row 1) -> 1*3 + 0 = 3.
1685        assert_eq!(
1686            regular_grid_pick(&image, GridMajorOrder::Row, 6, 0.5, 1.5),
1687            Some(3)
1688        );
1689        // Off the grid (col 3 >= cols).
1690        assert_eq!(
1691            regular_grid_pick(&image, GridMajorOrder::Row, 6, 3.5, 0.5),
1692            None
1693        );
1694    }
1695
1696    #[test]
1697    fn regular_grid_pick_column_major_inverts_transposed_placement() {
1698        // Column-major points are stored transposed -> index = row + col*rows.
1699        let image = GridImage {
1700            data: vec![0.0; 6],
1701            shape: (2, 3),
1702            origin: (0.0, 0.0),
1703            scale: (1.0, 1.0),
1704        };
1705        // (col 2, row 1) -> 1 + 2*2 = 5.
1706        assert_eq!(
1707            regular_grid_pick(&image, GridMajorOrder::Column, 6, 2.5, 1.5),
1708            Some(5)
1709        );
1710        // (col 1, row 0) -> 0 + 1*2 = 2.
1711        assert_eq!(
1712            regular_grid_pick(&image, GridMajorOrder::Column, 6, 1.5, 0.5),
1713            Some(2)
1714        );
1715    }
1716
1717    #[test]
1718    fn regular_grid_pick_none_past_last_point() {
1719        // 6-cell image but only 5 points: the trailing cell maps past the data
1720        // (silx "image can be larger than scatter").
1721        let image = GridImage {
1722            data: vec![0.0; 6],
1723            shape: (2, 3),
1724            origin: (0.0, 0.0),
1725            scale: (1.0, 1.0),
1726        };
1727        // Last cell (col 2, row 1) -> index 5 >= point_count 5 -> None.
1728        assert_eq!(
1729            regular_grid_pick(&image, GridMajorOrder::Row, 5, 2.5, 1.5),
1730            None
1731        );
1732        // (col 1, row 1) -> index 4 < 5 -> Some(4).
1733        assert_eq!(
1734            regular_grid_pick(&image, GridMajorOrder::Row, 5, 1.5, 1.5),
1735            Some(4)
1736        );
1737    }
1738
1739    #[test]
1740    fn regular_grid_pick_handles_negative_scale() {
1741        // Reversed-bounds grid: x descending 10,8,6 -> scale -2, origin 11.
1742        let image = GridImage {
1743            data: vec![0.0; 3],
1744            shape: (1, 3),
1745            origin: (11.0, -0.5),
1746            scale: (-2.0, 1.0),
1747        };
1748        // x=10 -> col 0; x=6 -> col 2.
1749        assert_eq!(
1750            regular_grid_pick(&image, GridMajorOrder::Row, 3, 10.0, 0.0),
1751            Some(0)
1752        );
1753        assert_eq!(
1754            regular_grid_pick(&image, GridMajorOrder::Row, 3, 6.0, 0.0),
1755            Some(2)
1756        );
1757        // x beyond the begin edge (12 > 11) -> negative cell -> None.
1758        assert_eq!(
1759            regular_grid_pick(&image, GridMajorOrder::Row, 3, 12.0, 0.0),
1760            None
1761        );
1762    }
1763
1764    #[test]
1765    fn binned_statistic_pick_returns_points_in_bin() {
1766        // 4 points pin [0,2]x[0,2] into 2x2 unit bins.
1767        let x = [0.0, 0.5, 1.5, 2.0];
1768        let y = [0.0, 0.5, 1.5, 2.0];
1769        let v = [10.0, 30.0, 5.0, 7.0];
1770        let bs = binned_statistic(&x, &y, &v, 2, 2).expect("binned");
1771        assert_eq!(bs.origin, (0.0, 0.0));
1772        assert_eq!(bs.scale, (1.0, 1.0));
1773        // Bin (row0,col0) range x[0,1) y[0,1): points 0 and 1.
1774        assert_eq!(bs.pick(&x, &y, 0.5, 0.5), Some(vec![0, 1]));
1775        // Bin (row1,col1) range x[1,2) y[1,2): point 2 in; point 3 at the max
1776        // edge is excluded by the strict upper bound (silx behaviour).
1777        assert_eq!(bs.pick(&x, &y, 1.5, 1.5), Some(vec![2]));
1778    }
1779
1780    #[test]
1781    fn binned_statistic_pick_none_off_grid_or_empty_bin() {
1782        let x = [0.0, 0.5, 2.0];
1783        let y = [0.0, 0.5, 2.0];
1784        let v = [10.0, 30.0, 7.0];
1785        let bs = binned_statistic(&x, &y, &v, 2, 2).expect("binned");
1786        // Empty bin (row0,col1): x[1,2) y[0,1) holds no point.
1787        assert_eq!(bs.pick(&x, &y, 1.5, 0.5), None);
1788        // Cursor off the grid (right of, and left of).
1789        assert_eq!(bs.pick(&x, &y, 2.5, 0.5), None);
1790        assert_eq!(bs.pick(&x, &y, -0.5, 0.5), None);
1791    }
1792
1793    // --- Per-point alpha ----------------------------------------------------
1794
1795    #[test]
1796    fn points_viz_default_no_alpha() {
1797        let p = PointsViz::new(
1798            vec![0.0, 1.0],
1799            vec![0.0, 1.0],
1800            vec![5.0, 6.0],
1801            vec![Color32::RED, Color32::BLUE],
1802        );
1803        assert_eq!(p.len(), 2);
1804        assert!(p.alpha.is_none());
1805    }
1806
1807    #[test]
1808    fn points_viz_with_alpha_clamps() {
1809        let p = PointsViz::new(
1810            vec![0.0, 1.0],
1811            vec![0.0, 1.0],
1812            vec![5.0, 6.0],
1813            vec![Color32::RED, Color32::BLUE],
1814        )
1815        .with_alpha(vec![-0.5, 2.0]);
1816        assert_eq!(p.alpha, Some(vec![0.0, 1.0]));
1817    }
1818
1819    #[test]
1820    #[should_panic(expected = "alpha must have one entry per point")]
1821    fn points_viz_alpha_length_mismatch_panics() {
1822        let _ = PointsViz::new(
1823            vec![0.0, 1.0],
1824            vec![0.0, 1.0],
1825            vec![5.0, 6.0],
1826            vec![Color32::RED, Color32::BLUE],
1827        )
1828        .with_alpha(vec![0.5]);
1829    }
1830
1831    // --- IRREGULAR_GRID quadrilateral mesh ----------------------------------
1832
1833    #[test]
1834    fn quadrilateral_grid_coords_unit_2x2_offsets_by_half() {
1835        // Points on a unit grid: row 0 at y=0, row 1 at y=1; x fast.
1836        let points = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]];
1837        let grid = quadrilateral_grid_coords(&points, 2, 2);
1838        // 3x3 corner grid, each cell a unit square centered on its point.
1839        let expect = [
1840            [-0.5, -0.5],
1841            [0.5, -0.5],
1842            [1.5, -0.5],
1843            [-0.5, 0.5],
1844            [0.5, 0.5],
1845            [1.5, 0.5],
1846            [-0.5, 1.5],
1847            [0.5, 1.5],
1848            [1.5, 1.5],
1849        ];
1850        assert_eq!(grid.len(), 9);
1851        for (got, want) in grid.iter().zip(&expect) {
1852            assert!((got[0] - want[0]).abs() < 1e-12, "x: {got:?} vs {want:?}");
1853            assert!((got[1] - want[1]).abs() < 1e-12, "y: {got:?} vs {want:?}");
1854        }
1855    }
1856
1857    #[test]
1858    fn irregular_grid_triangles_builds_one_cell_per_point() {
1859        let x = [0.0, 1.0, 0.0, 1.0];
1860        let y = [0.0, 0.0, 1.0, 1.0];
1861        let colors = [Color32::RED, Color32::GREEN, Color32::BLUE, Color32::WHITE];
1862        let mesh = irregular_grid_triangles(&x, &y, &colors).expect("buildable grid");
1863        // 4 points -> 16 vertices, 8 triangles (2 per point).
1864        assert_eq!(mesh.x.len(), 16);
1865        assert_eq!(mesh.colors.len(), 16);
1866        assert_eq!(mesh.indices.len(), 8);
1867        // silx flat-shades: each point's 4 vertices share its color.
1868        for (k, &c) in colors.iter().enumerate() {
1869            for v in 0..4 {
1870                assert_eq!(mesh.colors[4 * k + v], c, "point {k} vertex {v}");
1871            }
1872        }
1873    }
1874
1875    #[test]
1876    fn irregular_grid_pick_maps_cursor_to_owning_cell() {
1877        let x = [0.0, 1.0, 0.0, 1.0];
1878        let y = [0.0, 0.0, 1.0, 1.0];
1879        let mesh = irregular_grid_triangles(&x, &y, &[Color32::RED; 4]).expect("buildable grid");
1880        // Each unit cell is centered on its data point (silx vertex // 4).
1881        assert_eq!(irregular_grid_pick(&mesh, 0.0, 0.0), Some(0));
1882        assert_eq!(irregular_grid_pick(&mesh, 1.0, 0.0), Some(1));
1883        assert_eq!(irregular_grid_pick(&mesh, 0.0, 1.0), Some(2));
1884        assert_eq!(irregular_grid_pick(&mesh, 1.0, 1.0), Some(3));
1885        // Far outside every cell -> no pick.
1886        assert_eq!(irregular_grid_pick(&mesh, 10.0, 10.0), None);
1887    }
1888
1889    #[test]
1890    fn irregular_grid_single_line_builds_one_cell_per_point_and_picks() {
1891        // Collinear points fall back to silx's single-line grid (a 2xN strip).
1892        let x = [0.0, 1.0, 2.0, 3.0];
1893        let y = [0.0, 0.0, 0.0, 0.0];
1894        let mesh = irregular_grid_triangles(&x, &y, &[Color32::RED; 4]).expect("buildable line");
1895        assert_eq!(mesh.x.len(), 16, "4 points -> 16 vertices");
1896        assert_eq!(mesh.indices.len(), 8, "4 points -> 8 triangles");
1897        // Each data point lies inside its own cell.
1898        assert_eq!(irregular_grid_pick(&mesh, 1.0, 0.0), Some(1));
1899        assert_eq!(irregular_grid_pick(&mesh, 2.0, 0.0), Some(2));
1900    }
1901
1902    #[test]
1903    fn irregular_grid_triangles_none_for_too_few_points() {
1904        // A single point cannot form a quadrilateral mesh (silx renders a square).
1905        assert!(irregular_grid_triangles(&[0.0], &[0.0], &[Color32::RED]).is_none());
1906    }
1907}