re_ui 0.36.0

Rerun GUI theme and helpers, built around egui
Documentation
use egui::Vec2;
use egui_kittest::{HarnessBuilder, OsThreshold, SnapshotOptions};

/// What is the purpose of the test?
#[derive(Clone, Copy, Debug)]
pub enum TestOptions {
    /// Pure egui
    Gui,

    /// Some 3D rendering (requires higher thresholds)
    Rendering3D,
}

pub fn new_harness<T>(option: TestOptions, size: impl Into<Vec2>) -> HarnessBuilder<T> {
    re_log::setup_logging(); // It's nice with log output from tests

    let size = size.into();

    let options = match option {
        TestOptions::Gui => default_snapshot_options_for_ui(),
        TestOptions::Rendering3D => default_snapshot_options_for_3d(size),
    };

    egui_kittest::Harness::builder()
        .wgpu()
        .with_size(size)
        .with_options(options)
}

/// Are we running on CI?
///
/// On CI we always render with the lavapipe software rasterizer (on all platforms),
/// which makes the renders a lot more predictable than on a developer machine,
/// where we render with whatever GPU happens to be available.
pub fn is_ci() -> bool {
    static IS_CI: std::sync::LazyLock<bool> =
        std::sync::LazyLock::new(|| std::env::var("CI").is_ok());
    *IS_CI
}

/// Allow a small number of failing pixels where the snapshots are generated, i.e. on Linux CI.
///
/// Every snapshot in the repository is generated by Linux CI using the lavapipe software
/// rasterizer, so on Linux CI a small `max_failed_pixels` absorbs isolated
/// higher-magnitude differences.
/// A generous threshold hides out-of-date snapshots and regressions.
///
/// macOS/Windows CI use the same rasterizer but a different CPU architecture,
/// and locally we render on real GPUs, so there we use `lenient` instead.
///
/// Note that this is for the pixel _count_ only. The per-pixel `threshold` must stay non-zero
/// even on Linux CI: lavapipe JIT-compiles its shaders for the host CPU,
/// and the CI runners are not all the same, so we get tiny differences all over the image.
///
/// The argument can be either a plain value or an [`OsThreshold`],
/// so you can be extra lenient on a specific platform:
/// `strict_on_ci(10, OsThreshold::new(10).macos(150))`.
///
/// If a test is nondeterministic even on Linux CI (e.g. because of a video decoder,
/// or GPU-order-dependent blending), set the count explicitly instead,
/// and motivate it with a comment.
///
/// TODO(aedm): warn users if they generate snapshots on a GPU.
pub fn strict_on_ci<T: Copy>(strict: T, lenient: impl Into<OsThreshold<T>>) -> OsThreshold<T> {
    let lenient = lenient.into();
    if is_ci() {
        lenient.linux(strict)
    } else {
        lenient
    }
}

/// Default snapshot options for a pure egui test.
///
/// This is one of the two blessed ways of creating [`SnapshotOptions`]
/// (the other being [`default_snapshot_options_for_3d`]),
/// so that every snapshot test gets strict thresholds on CI.
/// Constructing [`SnapshotOptions`] directly is forbidden by `clippy.toml`.
///
/// egui renders text on the CPU, so these thresholds hold up even on a real GPU.
/// If some platform turns out to need more leniency,
/// bump it here (with [`OsThreshold`]) rather than in the individual tests.
pub fn default_snapshot_options_for_ui() -> SnapshotOptions {
    #[expect(clippy::disallowed_methods)]
    // We sometimes have a few wrong pixels in text rendering in egui for unknown reasons.
    SnapshotOptions::new().threshold(1.0).max_failed_pixels(10)
}

/// Default snapshot options for a test that renders 3D with `re_renderer`.
///
/// Has slightly higher tolerances than [`default_snapshot_options_for_ui`].
pub fn default_snapshot_options_for_3d(viewport_size: Vec2) -> SnapshotOptions {
    // We sometime have "binary" failures, e.g. a pixel being categorized
    // as either inside or outside a primitive due to platform differences.
    // How many depend on the size of the image.
    let num_total_pixels = viewport_size.x * viewport_size.y;

    let broken_pixels_fraction = 0.04 / 100.0;
    let max_broken_pixels = (num_total_pixels * broken_pixels_fraction).round() as usize;

    // Need a bit higher than the default to accommodate for various filtering artifacts, typically caused by the grid shader.
    let threshold = 2.0;

    #[expect(clippy::disallowed_methods)]
    SnapshotOptions::new()
        .threshold(threshold)
        .max_failed_pixels(strict_on_ci(10, max_broken_pixels))
}