leibniz 0.2.0

The package provides a differentiable vector graphics rasterization loss.
Documentation
//! Rasterization configuration.

/// Rasterization and loss configuration.
///
/// A `Config` describes how predicted contours are turned into grayscale pixels
/// and how that rendering is differentiated. The same settings are applied
/// consistently in both passes, mirroring DiffVG:
///
/// - `x_sample_count` and `y_sample_count` control antialiasing supersampling:
///   how finely each pixel is sampled before it is reconstructed.
/// - `radius` controls the box reconstruction filter: how those sub-samples are
///   combined into a pixel value, and how the loss signal is gathered around
///   boundary points in the backward pass.
///
/// `Config::default` reproduces DiffVG's defaults.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Config {
    /// Antialiasing sub-samples per pixel along the x axis.
    x_sample_count: usize,
    /// Antialiasing sub-samples per pixel along the y axis.
    y_sample_count: usize,
    /// Box reconstruction filter radius, in pixels.
    radius: f32,
}

impl Config {
    /// Create a configuration.
    ///
    /// `x_sample_count` and `y_sample_count` are the per-pixel antialiasing
    /// sample counts, and `radius` is the box reconstruction filter radius in
    /// pixels. See the accessors below for what each one controls.
    ///
    /// # Panics
    ///
    /// Panics if either sample count is zero, or if the radius is not finite and
    /// positive, or is smaller than `0.5 / samples` on an axis with an even
    /// sample count, which would leave some pixel with no sub-sample inside the
    /// filter.
    pub const fn new(x_sample_count: usize, y_sample_count: usize, radius: f32) -> Self {
        assert!(
            x_sample_count > 0 && y_sample_count > 0,
            "antialiasing sample counts must be positive"
        );
        // An odd sample count places a sub-sample exactly at the pixel center,
        // so any positive radius captures it; an even count needs at least half
        // the sub-sample spacing to reach the nearest one.
        assert!(
            radius.is_finite()
                && radius > 0.0
                && (!x_sample_count.is_multiple_of(2) || radius >= 0.5 / x_sample_count as f32)
                && (!y_sample_count.is_multiple_of(2) || radius >= 0.5 / y_sample_count as f32),
            "filter radius must be finite and positive, and at least 0.5 / \
             samples on any axis with an even sample count, so every pixel has a \
             sub-sample within the filter"
        );

        Self {
            x_sample_count,
            y_sample_count,
            radius,
        }
    }

    /// Box reconstruction filter radius, in pixels.
    ///
    /// The same box filter is used in both passes. In the forward raster it sets
    /// how far each sub-sample's coverage spreads: a pixel value is the average
    /// coverage over every sub-sample whose center lies within `radius` of the
    /// pixel center. In the backward pass it sets the footprint of the
    /// loss-signal lookup around each boundary point.
    ///
    /// A radius of `0.5` is a one-pixel box, so each sub-sample contributes only
    /// to its own pixel and reconstruction reduces to a plain per-pixel average;
    /// this is DiffVG's default. Larger radii blur coverage across neighboring
    /// pixels, softening edges and widening the gather; smaller radii sharpen
    /// them. The radius must be finite and positive; on an axis with an even
    /// sample count it must also be at least `0.5 / samples`, so every pixel
    /// center has a sub-sample within it (an odd count places one at the
    /// center).
    pub const fn radius(&self) -> f32 {
        self.radius
    }

    /// Number of antialiasing sub-samples per pixel along the x axis.
    ///
    /// Each pixel is split into a `x_sample_count` by `y_sample_count` grid of
    /// sub-samples. The rasterizer classifies every sub-sample as inside or
    /// outside with a hard winding test, and the box filter reconstructs the
    /// grayscale pixel value from them, so more samples render smoother edges at
    /// a higher cost. The same grid sets the number of boundary samples used to
    /// estimate the gradient (`height * width * x_sample_count *
    /// y_sample_count`), so higher counts lower gradient noise. The gradient is
    /// normalized, so its magnitude does not grow with the sample counts.
    pub const fn x_sample_count(&self) -> usize {
        self.x_sample_count
    }

    /// Number of antialiasing sub-samples per pixel along the y axis.
    ///
    /// The y-axis counterpart of `x_sample_count`.
    pub const fn y_sample_count(&self) -> usize {
        self.y_sample_count
    }
}

impl Default for Config {
    /// DiffVG's defaults: 2 by 2 antialiasing samples and a box filter of radius
    /// 0.5 (a one-pixel box, i.e. a plain per-pixel average).
    fn default() -> Self {
        Self::new(2, 2, 0.5)
    }
}

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

    #[test]
    fn accepts_small_radius_on_odd_grid() {
        // An odd sample count places a sub-sample at each pixel center, so any
        // positive radius captures it; a 3x3 grid accepts a radius below 0.5 / 3.
        assert_eq!(Config::new(3, 3, 0.1).radius(), 0.1);
    }

    #[test]
    fn accepts_subpixel_radius_on_finer_grid() {
        // The minimum radius is grid-dependent: a 4x4 grid resolves sub-samples
        // at distance 0.5 / 4 = 0.125, so a radius of 0.2 is valid.
        assert_eq!(Config::new(4, 4, 0.2).radius(), 0.2);
    }

    #[test]
    fn defaults_match_explicit_construction() {
        assert_eq!(Config::default(), Config::new(2, 2, 0.5));
    }

    #[test]
    fn exposes_radius() {
        assert_eq!(Config::new(2, 2, 0.5).radius(), 0.5);
    }

    #[test]
    #[should_panic]
    fn rejects_nonfinite_radius() {
        let _ = Config::new(2, 2, f32::INFINITY);
    }

    #[test]
    #[should_panic]
    fn rejects_subpixel_radius() {
        // On a 2x2 grid the nearest sub-sample is 0.25 away, so 0.1 selects no
        // samples and would zero the filter weight.
        let _ = Config::new(2, 2, 0.1);
    }

    #[test]
    #[should_panic]
    fn rejects_zero_radius() {
        // Zero radius is degenerate for the gather even on an odd grid.
        let _ = Config::new(3, 3, 0.0);
    }
}