telar-renderer-core 0.1.5

Renderer-agnostic drawing vocabulary for Telar: draw commands, culling, dirty tracking and GPU sync.
Documentation
use geometry_core::{Point, Rect};
use std::sync::atomic::{AtomicU64, Ordering};

static NEXT_PATH_ID: AtomicU64 = AtomicU64::new(1);

#[derive(Debug, Clone, PartialEq)]
pub enum PathVerb {
    MoveTo(Point),
    LineTo(Point),
    QuadTo {
        ctrl: Point,
        to: Point,
    },
    CubicTo {
        ctrl1: Point,
        ctrl2: Point,
        to: Point,
    },
    Close,
}

#[derive(Debug, Clone)]
pub struct PathData {
    pub id: u64,
    pub(crate) verbs: Vec<PathVerb>,
    // OnceLock is both Send and Sync, required for Arc<PathData>: Send when crossing thread boundaries.
    bounds_cache: std::sync::OnceLock<Option<Rect>>,
}

// Equal when the geometry (verbs) matches, ignoring the per-instance `id` and the lazily-filled bounds cache. Two structurally-identical paths rebuilt across frames must compare equal so dirty-tracking (scroll-blit, dirty-rect) treats them as unchanged — otherwise every rebuild's fresh `id` would force a full-screen repaint.
impl PartialEq for PathData {
    fn eq(&self, other: &Self) -> bool {
        self.verbs == other.verbs
    }
}

impl Default for PathData {
    fn default() -> Self {
        Self {
            id: NEXT_PATH_ID.fetch_add(1, Ordering::Relaxed),
            verbs: Vec::new(),
            bounds_cache: std::sync::OnceLock::new(),
        }
    }
}

impl PathData {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn verbs(&self) -> &[PathVerb] {
        &self.verbs
    }

    pub fn move_to(mut self, p: Point) -> Self {
        self.verbs.push(PathVerb::MoveTo(p));
        self.bounds_cache = std::sync::OnceLock::new();
        self
    }

    pub fn line_to(mut self, p: Point) -> Self {
        self.verbs.push(PathVerb::LineTo(p));
        self.bounds_cache = std::sync::OnceLock::new();
        self
    }

    pub fn quad_to(mut self, ctrl: Point, to: Point) -> Self {
        self.verbs.push(PathVerb::QuadTo { ctrl, to });
        self.bounds_cache = std::sync::OnceLock::new();
        self
    }

    pub fn cubic_to(mut self, ctrl1: Point, ctrl2: Point, to: Point) -> Self {
        self.verbs.push(PathVerb::CubicTo { ctrl1, ctrl2, to });
        self.bounds_cache = std::sync::OnceLock::new();
        self
    }

    pub fn close(mut self) -> Self {
        self.verbs.push(PathVerb::Close);
        self.bounds_cache = std::sync::OnceLock::new();
        self
    }

    /// Re-fits every point by `p' = p * s + (dx, dy)` (uniform scale + translation), returning a new path.
    ///
    /// Baking transforms points into path coordinates (not a matrix) so lyon never facets curves; this applies the runtime letterbox fit the same way, keeping the baked path equivalent to the dynamic one.
    pub fn refit(&self, s: f32, dx: f32, dy: f32) -> Self {
        self.refit_xy(s, s, dx, dy)
    }

    /// Non-uniform variant of `refit`: `p' = (p.x * sx + dx, p.y * sy + dy)`. `object-fit: fill` uses `sx != sy` to stretch the path to the box (distorting the aspect ratio).
    pub fn refit_xy(&self, sx: f32, sy: f32, dx: f32, dy: f32) -> Self {
        let map = |p: Point| Point::new(p.x * sx + dx, p.y * sy + dy);
        let mut out = Self::new();
        for verb in &self.verbs {
            out = match verb {
                PathVerb::MoveTo(p) => out.move_to(map(*p)),
                PathVerb::LineTo(p) => out.line_to(map(*p)),
                PathVerb::QuadTo { ctrl, to } => out.quad_to(map(*ctrl), map(*to)),
                PathVerb::CubicTo { ctrl1, ctrl2, to } => {
                    out.cubic_to(map(*ctrl1), map(*ctrl2), map(*to))
                }
                PathVerb::Close => out.close(),
            };
        }
        out
    }

    /// A closed polygon through `points` (first point is the start; the path is closed back to it).
    pub fn polygon(points: &[Point]) -> Self {
        let mut path = Self::new();
        let Some((first, rest)) = points.split_first() else {
            return path;
        };
        path = path.move_to(*first);
        for p in rest {
            path = path.line_to(*p);
        }
        path.close()
    }

    pub fn bounds(&self) -> Option<Rect> {
        *self.bounds_cache.get_or_init(|| {
            let mut min_x = f32::INFINITY;
            let mut min_y = f32::INFINITY;
            let mut max_x = f32::NEG_INFINITY;
            let mut max_y = f32::NEG_INFINITY;
            let mut has_geometry = false;

            for verb in &self.verbs {
                match verb {
                    PathVerb::MoveTo(p) | PathVerb::LineTo(p) => {
                        min_x = min_x.min(p.x);
                        min_y = min_y.min(p.y);
                        max_x = max_x.max(p.x);
                        max_y = max_y.max(p.y);
                        has_geometry = true;
                    }
                    PathVerb::QuadTo { ctrl, to } => {
                        // Bézier curves are bounded by their control polygon (convex hull property).
                        for p in &[ctrl, to] {
                            min_x = min_x.min(p.x);
                            min_y = min_y.min(p.y);
                            max_x = max_x.max(p.x);
                            max_y = max_y.max(p.y);
                        }
                        has_geometry = true;
                    }
                    PathVerb::CubicTo { ctrl1, ctrl2, to } => {
                        for p in &[ctrl1, ctrl2, to] {
                            min_x = min_x.min(p.x);
                            min_y = min_y.min(p.y);
                            max_x = max_x.max(p.x);
                            max_y = max_y.max(p.y);
                        }
                        has_geometry = true;
                    }
                    PathVerb::Close => {}
                }
            }

            if has_geometry {
                Some(Rect::new(min_x, min_y, max_x - min_x, max_y - min_y))
            } else {
                None
            }
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn path_data_new_is_empty() {
        let path = PathData::new();
        assert!(path.verbs().is_empty());
    }

    #[test]
    fn path_data_move_to_adds_verb() {
        let path = PathData::new().move_to(Point::new(0.0, 0.0));
        assert_eq!(path.verbs().len(), 1);
        assert!(matches!(path.verbs()[0], PathVerb::MoveTo(_)));
    }

    #[test]
    fn path_data_line_to_adds_verb() {
        let path = PathData::new()
            .move_to(Point::new(0.0, 0.0))
            .line_to(Point::new(1.0, 1.0));
        assert_eq!(path.verbs().len(), 2);
        assert!(matches!(path.verbs()[1], PathVerb::LineTo(_)));
    }

    #[test]
    fn path_data_close_adds_verb() {
        let path = PathData::new().move_to(Point::new(0.0, 0.0)).close();
        assert!(matches!(path.verbs().last().unwrap(), PathVerb::Close));
    }

    #[test]
    fn path_data_quad_to_adds_verb() {
        let path = PathData::new().quad_to(Point::new(1.0, 0.0), Point::new(2.0, 0.0));
        assert!(matches!(path.verbs()[0], PathVerb::QuadTo { .. }));
    }

    #[test]
    fn path_data_cubic_to_adds_verb() {
        let path = PathData::new().cubic_to(
            Point::new(1.0, 0.0),
            Point::new(2.0, 0.0),
            Point::new(3.0, 0.0),
        );
        assert!(matches!(path.verbs()[0], PathVerb::CubicTo { .. }));
    }

    #[test]
    fn path_data_builder_accumulates_verbs() {
        let path = PathData::new()
            .move_to(Point::new(0.0, 0.0))
            .line_to(Point::new(1.0, 0.0))
            .line_to(Point::new(1.0, 1.0))
            .close();
        assert_eq!(path.verbs().len(), 4);
    }
}