tilezz 0.2.0

Utilities to work with perfect-precision polygonal tiles built on top of cyclotomic integer rings.
Documentation
//! Generic point-cloud rendering: lay out a sequence of same-bounds point
//! clouds either as a PNG grid or as an animated GIF, one rainbow-colored
//! layer per cell / frame, each drawn as a light bordered box with origin
//! crosshairs and outlined markers.
//!
//! Domain-agnostic -- it operates on `P64` float points and a shared bounding
//! box, so any caller with layered point sets (e.g. the cyclotomic lattice
//! explorer's per-round reachable clouds) gets the scene assembly + grid / GIF
//! layout without reimplementing it.

use crate::vis::draw::rainbow;
use crate::vis::plotutils::{P64, R64};
use crate::vis::scene::{Color, Fill, Item, MarkerShape, Scene, Stroke, Viewport};

/// How to size the point markers relative to a cell.
#[derive(Clone, Copy, Debug)]
pub enum MarkerScale {
    /// Fixed marker diameter as a fraction of the cell width (resolution
    /// independent).
    CellFraction(f64),
    /// Marker diameter targeting roughly `px` device pixels at the render
    /// resolution (`cell_px_w` pixels per cell).
    Pixels(f64),
}

impl MarkerScale {
    fn size(self, cell_w: f64, cell_px_w: f64) -> f64 {
        match self {
            MarkerScale::CellFraction(f) => f * cell_w,
            MarkerScale::Pixels(px) => px * cell_w / cell_px_w,
        }
    }
}

/// Draw one point-cloud layer into `scene`: a light border box + origin
/// crosshairs framing `bounds`, then `color` markers for every point, all
/// shifted by `(dx, dy)` math units.
fn add_cell(
    scene: &mut Scene,
    points: &[P64],
    bounds: R64,
    (dx, dy): (f64, f64),
    marker_size: f64,
    color: Color,
) {
    let ((mn_x, mn_y), (mx_x, mx_y)) = bounds;
    // Light cell border.
    scene.push(Item::Polygon {
        points: vec![
            (dx + mn_x, dy + mn_y),
            (dx + mx_x, dy + mn_y),
            (dx + mx_x, dy + mx_y),
            (dx + mn_x, dy + mx_y),
        ],
        fill: None,
        stroke: Some(Stroke::solid(
            Color::rgb(180, 180, 180),
            0.005 * (mx_x - mn_x),
        )),
        arrow: None,
    });
    // Cross-hairs at origin: helps see scale + symmetry.
    scene.push(Item::Segment {
        a: (dx + mn_x, dy),
        b: (dx + mx_x, dy),
        stroke: Stroke::solid(Color::rgb(225, 225, 225), 0.003 * (mx_x - mn_x)),
        arrow: None,
    });
    scene.push(Item::Segment {
        a: (dx, dy + mn_y),
        b: (dx, dy + mx_y),
        stroke: Stroke::solid(Color::rgb(225, 225, 225), 0.003 * (mx_x - mn_x)),
        arrow: None,
    });
    // Points. Thin black outline so light-coloured palette entries (yellow,
    // cyan, ...) stand out against the white background; ratio 0.10 keeps the
    // outline visible without swallowing the fill at ~6-pixel diameters.
    let outline_width = marker_size * 0.10;
    for &(x, y) in points {
        scene.push(Item::Marker {
            center: (x + dx, y + dy),
            shape: MarkerShape::Circle,
            size: marker_size,
            fill: Some(Fill::solid(color)),
            stroke: Some(Stroke::solid(Color::BLACK, outline_width)),
        });
    }
}

/// Render `layers` (each a point cloud over the same `bounds`) as a single PNG
/// laid out in a grid `cols` cells wide, one rainbow-colored layer per cell
/// (layer 0 top-left, row 0 at the top). `img_w` is the output width in
/// pixels; the height follows the grid aspect. Returns the PNG bytes.
pub fn grid_png(
    layers: &[Vec<P64>],
    bounds: R64,
    cols: usize,
    img_w: u32,
    marker: MarkerScale,
) -> Vec<u8> {
    let n = layers.len();
    let palette: Vec<Color> = rainbow(n.max(1), 1.0, 0.5);
    let ((mn_x, mn_y), (mx_x, mx_y)) = bounds;
    let cell_w = mx_x - mn_x;
    let cell_h = mx_y - mn_y;
    let cols = cols.max(1);
    let rows = n.div_ceil(cols);
    let gap = 0.05 * cell_w;
    let cell_px_w = img_w as f64 / cols as f64;
    let marker_size = marker.size(cell_w, cell_px_w);

    let mut scene = Scene::new().with_background(Color::WHITE);
    for (i, pts) in layers.iter().enumerate() {
        let col = i % cols;
        let row = i / cols;
        // Place cells with row 0 at the TOP (highest math y).
        let dx = col as f64 * (cell_w + gap) - mn_x;
        let dy = (rows - 1 - row) as f64 * (cell_h + gap) - mn_y;
        add_cell(&mut scene, pts, bounds, (dx, dy), marker_size, palette[i]);
    }
    let total_w = cols as f64 * cell_w + (cols.saturating_sub(1)) as f64 * gap;
    let total_h = rows as f64 * cell_h + (rows.saturating_sub(1)) as f64 * gap;
    let total_h_px = ((total_h / total_w) * img_w as f64).round().max(1.0) as u32;
    let vp = Viewport::rect_for(img_w, total_h_px, ((0.0, 0.0), (total_w, total_h)), 16);
    scene.to_png(&vp).expect("render PNG")
}

/// Render `layers` as an animated GIF, one full-frame rainbow-colored layer
/// per frame at `delay_ms` per frame. `img_w` is the (square) frame width in
/// pixels. Returns the GIF bytes.
#[cfg(feature = "animation")]
pub fn animation_gif(
    layers: &[Vec<P64>],
    bounds: R64,
    img_w: u32,
    delay_ms: u16,
    marker: MarkerScale,
) -> Vec<u8> {
    let n = layers.len();
    let palette: Vec<Color> = rainbow(n.max(1), 1.0, 0.5);
    let ((mn_x, mn_y), (mx_x, mx_y)) = bounds;
    let cell_w = mx_x - mn_x;
    let cell_h = mx_y - mn_y;
    // Each frame fills the whole image, so the cell-pixel-width is `img_w`.
    let marker_size = marker.size(cell_w, img_w as f64);

    let frames: Vec<Scene> = layers
        .iter()
        .enumerate()
        .map(|(i, pts)| {
            let mut frame = Scene::new().with_background(Color::WHITE);
            add_cell(
                &mut frame,
                pts,
                bounds,
                (-mn_x, -mn_y),
                marker_size,
                palette[i],
            );
            frame
        })
        .collect();
    let vp = Viewport::square_for(img_w, ((0.0, 0.0), (cell_w, cell_h)), 16);
    crate::vis::animation::render_gif(&frames, &vp, delay_ms).expect("render GIF")
}