incular-rendering 0.1.1

Renderer-neutral scene, canvas, display-list, and compositor APIs for Incular
Documentation
use crate::display_list::ImageSampling;
use incular_core::finite_or_zero;
use incular_core::{Color, Offset};
use std::sync::{
    Arc,
    atomic::{AtomicU64, Ordering},
};

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

fn next_gradient_id() -> GradientId {
    GradientId(
        NEXT_GRADIENT_ID
            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |id| id.checked_add(1))
            .expect("gradient identity space exhausted"),
    )
}

/// Stable identity for immutable normalized gradient stop data.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub struct GradientId(u64);

#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GradientStop {
    pub offset: f32,
    pub color: Color,
}
#[derive(Clone, Debug)]
pub struct GradientStops {
    id: GradientId,
    stops: Arc<[GradientStop]>,
}
impl PartialEq for GradientStops {
    fn eq(&self, other: &Self) -> bool {
        self.stops == other.stops
    }
}

/// Samples normalized stops using pad spread and premultiplied interpolation
/// in the encoded-sRGB working space (see [`crate::working_rgba`]). At a duplicate offset the last duplicate wins, allowing a
/// hard transition without division by zero.
#[must_use]
pub fn sample_gradient_stops(stops: &GradientStops, t: f32) -> [f32; 4] {
    let stops = stops.as_slice();
    let t = finite_or_zero(t);
    if t <= stops[0].offset {
        return premultiplied(stops[0].color);
    }
    let last = stops.last().expect("normalized stops are non-empty");
    if t >= last.offset {
        return premultiplied(last.color);
    }
    let mut left = 0;
    for index in 1..stops.len() {
        if stops[index].offset <= t {
            left = index;
        } else {
            let a = stops[left];
            let b = stops[index];
            let amount = ((t - a.offset) / (b.offset - a.offset)).clamp(0., 1.);
            let a = premultiplied(a.color);
            let b = premultiplied(b.color);
            return [
                a[0] + (b[0] - a[0]) * amount,
                a[1] + (b[1] - a[1]) * amount,
                a[2] + (b[2] - a[2]) * amount,
                a[3] + (b[3] - a[3]) * amount,
            ];
        }
    }
    premultiplied(last.color)
}
fn premultiplied(color: Color) -> [f32; 4] {
    crate::premultiplied_working_rgba(color)
}
/// Pad-spread linear parameter in local logical coordinates. A zero-length
/// axis deterministically chooses the last stop (`t = 1`).
#[must_use]
pub fn linear_gradient_t(start: Offset, end: Offset, point: Offset) -> f32 {
    let dx = end.x - start.x;
    let dy = end.y - start.y;
    let length_squared = dx * dx + dy * dy;
    if !length_squared.is_finite() || length_squared <= f32::EPSILON {
        return 1.;
    }
    (((point.x - start.x) * dx + (point.y - start.y) * dy) / length_squared).clamp(0., 1.)
}
/// Pad-spread radial parameter in local logical coordinates. A non-positive
/// radius deterministically chooses the last stop (`t = 1`).
#[must_use]
pub fn radial_gradient_t(center: Offset, radius: f32, point: Offset) -> f32 {
    if !radius.is_finite() || radius <= 0. {
        return 1.;
    }
    (((point.x - center.x).hypot(point.y - center.y)) / radius).clamp(0., 1.)
}
impl GradientStops {
    /// Stops are clamped to 0..=1 and stable-sorted. Empty becomes transparent;
    /// one stop is duplicated so shader sampling is always defined.
    #[must_use]
    pub fn new(mut stops: Vec<GradientStop>) -> Self {
        for s in &mut stops {
            s.offset = if s.offset.is_finite() {
                s.offset.clamp(0., 1.)
            } else {
                0.
            };
        }
        stops.sort_by(|a, b| a.offset.total_cmp(&b.offset));
        if stops.is_empty() {
            stops.push(GradientStop {
                offset: 0.,
                color: Color::TRANSPARENT,
            });
        }
        if stops.len() == 1 {
            stops.push(GradientStop {
                offset: 1.,
                color: stops[0].color,
            });
        }
        Self {
            id: next_gradient_id(),
            stops: stops.into(),
        }
    }
    #[must_use]
    pub fn as_slice(&self) -> &[GradientStop] {
        &self.stops
    }
    /// Identity is computed when normalized stops are constructed, never while
    /// rendering. Clones intentionally retain the same cached GPU resource.
    #[must_use]
    pub const fn id(&self) -> GradientId {
        self.id
    }
}
impl GradientId {
    #[must_use]
    pub const fn get(self) -> u64 {
        self.0
    }
}
/// Flutter `TileMode`: how a gradient paints outside its `0.0..=1.0`
/// parameter range.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum TileMode {
    /// Extends the edge colors (Flutter's default).
    #[default]
    Clamp,
    /// Repeats the gradient.
    Repeated,
    /// Repeats the gradient, reversing every other repetition.
    Mirror,
    /// Paints transparent black outside the gradient.
    Decal,
}

impl TileMode {
    /// The stop parameter sampled for a raw gradient parameter `t`, or
    /// `None` where [`Self::Decal`] paints nothing.
    #[must_use]
    pub fn apply(self, t: f32) -> Option<f32> {
        let t = finite_or_zero(t);
        match self {
            Self::Clamp => Some(t.clamp(0., 1.)),
            Self::Repeated => Some(t - t.floor()),
            Self::Mirror => {
                let period = t * 0.5 - (t * 0.5).floor();
                Some(1. - (period * 2. - 1.).abs())
            }
            Self::Decal => (0. ..=1.).contains(&t).then_some(t),
        }
    }

    /// The code the GPU shaders read.
    #[must_use]
    pub const fn shader_code(self) -> f32 {
        match self {
            Self::Clamp => 0.,
            Self::Repeated => 1.,
            Self::Mirror => 2.,
            Self::Decal => 3.,
        }
    }
}

#[derive(Clone, Debug, PartialEq)]
pub struct LinearGradient {
    pub start: Offset,
    pub end: Offset,
    pub stops: GradientStops,
    /// Flutter `tileMode`, default [`TileMode::Clamp`].
    pub tile_mode: TileMode,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RadialGradient {
    pub center: Offset,
    pub radius: f32,
    pub stops: GradientStops,
    /// Flutter `tileMode`, default [`TileMode::Clamp`].
    pub tile_mode: TileMode,
    /// Flutter `focal`: where the gradient's `0.0` circle is centered. With
    /// `None` it is `center`, a plain radial gradient.
    pub focal: Option<Offset>,
    /// Flutter `focalRadius` (default `0.0`): the radius of the `0.0`
    /// circle around [`Self::focal`].
    pub focal_radius: f32,
}
#[derive(Clone, Debug, PartialEq)]
pub struct SweepGradient {
    pub center: Offset,
    pub start_angle: f32,
    pub stops: GradientStops,
    /// Flutter `tileMode`, default [`TileMode::Clamp`].
    pub tile_mode: TileMode,
}

impl LinearGradient {
    /// A clamped linear gradient from `start` to `end`.
    #[must_use]
    pub fn new(start: Offset, end: Offset, stops: GradientStops) -> Self {
        Self {
            start,
            end,
            stops,
            tile_mode: TileMode::Clamp,
        }
    }
}

impl RadialGradient {
    /// A clamped radial gradient without a focal point.
    #[must_use]
    pub fn new(center: Offset, radius: f32, stops: GradientStops) -> Self {
        Self {
            center,
            radius,
            stops,
            tile_mode: TileMode::Clamp,
            focal: None,
            focal_radius: 0.,
        }
    }

    /// Whether this is Flutter's two-point conical form: a focal point other
    /// than the center, or a focal radius.
    #[must_use]
    pub fn is_focal(&self) -> bool {
        self.focal.is_some_and(|focal| focal != self.center) || self.focal_radius != 0.
    }
}

impl SweepGradient {
    /// A clamped sweep gradient starting at `start_angle`.
    #[must_use]
    pub fn new(center: Offset, start_angle: f32, stops: GradientStops) -> Self {
        Self {
            center,
            start_angle,
            stops,
            tile_mode: TileMode::Clamp,
        }
    }
}

/// The raw parameter of Flutter's focal radial gradient (Skia's two-point
/// conical gradient from the circle `(focal, focal_radius)` at `0.0` to
/// `(center, radius)` at `1.0`): the largest `t` whose interpolated circle,
/// with a non-negative radius, passes through `point`. `None` where no such
/// circle exists, which paints nothing.
#[must_use]
pub fn two_point_conical_gradient_t(
    focal: Offset,
    focal_radius: f32,
    center: Offset,
    radius: f32,
    point: Offset,
) -> Option<f32> {
    let cd = Offset::new(center.x - focal.x, center.y - focal.y);
    let dr = radius - focal_radius;
    let pd = Offset::new(point.x - focal.x, point.y - focal.y);
    let a = cd.x * cd.x + cd.y * cd.y - dr * dr;
    let b = pd.x * cd.x + pd.y * cd.y + focal_radius * dr;
    let c = pd.x * pd.x + pd.y * pd.y - focal_radius * focal_radius;
    let radius_at = |t: f32| focal_radius + t * dr;
    if a.abs() <= f32::EPSILON {
        // The circles touch: the quadratic degenerates to `-2bt + c = 0`.
        if b == 0. {
            return None;
        }
        let t = c / (2. * b);
        return (radius_at(t) >= 0.).then_some(t);
    }
    let discriminant = b * b - a * c;
    if discriminant < 0. {
        return None;
    }
    let root = discriminant.sqrt();
    let (high, low) = {
        let first = (b + root) / a;
        let second = (b - root) / a;
        (first.max(second), first.min(second))
    };
    if radius_at(high) >= 0. {
        Some(high)
    } else if radius_at(low) >= 0. {
        Some(low)
    } else {
        None
    }
}
#[derive(Clone, Debug, PartialEq)]
pub enum Brush {
    Solid(Color),
    LinearGradient(LinearGradient),
    RadialGradient(RadialGradient),
    SweepGradient(SweepGradient),
}

/// A renderer-neutral shader value.
///
/// Incular's gradients are already immutable, cacheable shader descriptions;
/// keeping this alias means widgets and text do not need a second shader
/// hierarchy that would eventually drift from the renderer's brush model.
pub type Shader = Brush;

/// Image filtering policy matching Flutter's public quality vocabulary.
///
/// The current display-list backend exposes nearest-neighbour and linear
/// sampling. `Low`, `Medium`, and `High` intentionally lower to linear until
/// a backend grows a more expensive filter; callers still retain a stable,
/// renderer-independent policy value.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum FilterQuality {
    None,
    #[default]
    Low,
    Medium,
    High,
}

impl FilterQuality {
    #[must_use]
    pub const fn sampling(self) -> ImageSampling {
        match self {
            Self::None => ImageSampling::Nearest,
            Self::Low | Self::Medium | Self::High => ImageSampling::Linear,
        }
    }
}

impl From<FilterQuality> for ImageSampling {
    fn from(value: FilterQuality) -> Self {
        value.sampling()
    }
}
impl From<Color> for Brush {
    fn from(color: Color) -> Self {
        Self::Solid(color)
    }
}
impl From<LinearGradient> for Brush {
    fn from(gradient: LinearGradient) -> Self {
        Self::LinearGradient(gradient)
    }
}
impl From<RadialGradient> for Brush {
    fn from(gradient: RadialGradient) -> Self {
        Self::RadialGradient(gradient)
    }
}
impl From<SweepGradient> for Brush {
    fn from(gradient: SweepGradient) -> Self {
        Self::SweepGradient(gradient)
    }
}
#[must_use]
pub fn sweep_gradient_t(center: Offset, start_angle: f32, point: Offset) -> f32 {
    ((point.y - center.y).atan2(point.x - center.x) - start_angle).rem_euclid(std::f32::consts::TAU)
        / std::f32::consts::TAU
}