scena 1.5.1

A Rust-native scene-graph renderer with typed scene state, glTF assets, and explicit prepare/render lifecycles.
Documentation
use super::*;

const NEUTRAL_STUDIO_FIXTURE: &str =
    include_str!("../../../tests/assets/environment/generated/neutral-studio-cubemap.fixture.toml");

#[test]
fn cubemap_fixture_parser_decodes_six_faces_with_real_radiance_values() {
    let parsed = EnvironmentCubemapFaces::try_parse_fixture(NEUTRAL_STUDIO_FIXTURE)
        .expect("bundled SCENA_CUBEMAP_V1 fixture must parse");
    assert_eq!(parsed.resolution, 256, "fixture declares 256-pixel faces");
    assert_eq!(
        parsed.face_radiance,
        [
            [0.78, 0.82, 0.88],
            [0.62, 0.68, 0.76],
            [1.00, 0.98, 0.92],
            [0.28, 0.30, 0.34],
            [0.70, 0.74, 0.82],
            [0.56, 0.60, 0.68],
        ],
        "parser must read face radiance in the WebGPU px/nx/py/ny/pz/nz layer order"
    );
}

#[test]
fn equirectangular_hdr_default_cubemap_resolution_matches_specular_ibl_fixture_resolution() {
    assert_eq!(
        DEFAULT_ENVIRONMENT_CUBEMAP_FACE_RESOLUTION, 256,
        "real HDR environments need 256-pixel cubemap faces so smooth metals keep \
         enough environment detail for specular IBL; 64-pixel faces flatten chrome"
    );
}

#[test]
fn cubemap_fixture_parser_rejects_invalid_magic_header() {
    assert!(
        EnvironmentCubemapFaces::try_parse_fixture(
            "OOPS_NOT_A_CUBEMAP\n[face.px]\nradiance = 1.0 1.0 1.0"
        )
        .is_none(),
        "missing magic header must not silently degrade to a default cubemap"
    );
}

#[test]
fn cubemap_fixture_parser_rejects_negative_radiance() {
    let bad = "SCENA_CUBEMAP_V1\nresolution = 4\n[face.px]\nradiance = -0.1 0.0 0.0\n";
    assert!(
        EnvironmentCubemapFaces::try_parse_fixture(bad).is_none(),
        "negative radiance is physically meaningless and must fail parsing"
    );
}

#[test]
fn cube_face_direction_at_face_center_returns_face_normal() {
    for (face_index, normal) in ENVIRONMENT_CUBEMAP_FACE_NORMALS.iter().enumerate() {
        let direction = cube_face_direction(face_index, 0.0, 0.0);
        let expected = Vec3::new(normal[0], normal[1], normal[2]);
        let dx = direction.x - expected.x;
        let dy = direction.y - expected.y;
        let dz = direction.z - expected.z;
        assert!(
            dx * dx + dy * dy + dz * dz < 1e-6,
            "face {face_index} center direction must equal the face normal"
        );
    }
}

#[test]
fn cubemap_face_pixels_at_face_center_recover_face_radiance() {
    let mut radiance = [[0.0_f32; 3]; 6];
    radiance[0] = [0.9, 0.1, 0.1];
    radiance[1] = [0.1, 0.9, 0.1];
    radiance[2] = [0.1, 0.1, 0.9];
    radiance[3] = [0.5, 0.4, 0.3];
    radiance[4] = [0.3, 0.4, 0.5];
    radiance[5] = [0.7, 0.7, 0.7];
    let cube = EnvironmentCubemapFaces {
        face_radiance: radiance,
        resolution: 8,
        face_pixels: None,
    };
    let pixels = cube.build_face_pixels_rgba32f();
    for (face_index, face_pixels) in pixels.iter().enumerate() {
        let center_pixel_index = ((4 * 8) + 4) * 4;
        let r = face_pixels[center_pixel_index];
        let g = face_pixels[center_pixel_index + 1];
        let b = face_pixels[center_pixel_index + 2];
        let a = face_pixels[center_pixel_index + 3];
        let expected = radiance[face_index];
        let dominant = expected.iter().copied().fold(f32::NEG_INFINITY, f32::max);
        assert!(
            a == 1.0 && (r - g - b).abs() < 1.0,
            "face {face_index} center alpha must be 1 and the radiance triplet is finite",
        );
        for (channel, raw) in [r, g, b].iter().enumerate() {
            if (expected[channel] - dominant).abs() < 1e-6 {
                assert!(
                    *raw > expected[channel] * 0.6,
                    "face {face_index} dominant channel must retain >60% of its face-center radiance"
                );
            }
        }
    }
}

#[test]
fn cubemap_face_pixels_at_face_corners_blend_three_adjacent_faces() {
    let radiance = [
        [1.0, 0.0, 0.0],
        [0.0, 0.0, 0.0],
        [0.0, 1.0, 0.0],
        [0.0, 0.0, 0.0],
        [0.0, 0.0, 1.0],
        [0.0, 0.0, 0.0],
    ];
    let cube = EnvironmentCubemapFaces {
        face_radiance: radiance,
        resolution: 4,
        face_pixels: None,
    };
    let pixels = cube.build_face_pixels_rgba32f();
    let resolution = 4_usize;
    let face_pixels = &pixels[0];
    let top_left_index = 0;
    let r = face_pixels[top_left_index];
    let g = face_pixels[top_left_index + 1];
    let b = face_pixels[top_left_index + 2];
    assert!(
        r > 0.0 && g > 0.0 && b > 0.0,
        "px face top-left corner direction (+X,+Y,+Z) must blend px=red, py=green, pz=blue \
         radiances; got r={r} g={g} b={b}"
    );
    let bottom_right_index = ((resolution - 1) * resolution + (resolution - 1)) * 4;
    let r2 = face_pixels[bottom_right_index];
    let g2 = face_pixels[bottom_right_index + 1];
    let b2 = face_pixels[bottom_right_index + 2];
    assert!(
        r2 > 0.0 && g2 == 0.0 && b2 == 0.0,
        "px face (-Y,-Z) corner must keep red but drop py/pz contributions; \
         got r={r2} g={g2} b={b2}"
    );
}

#[test]
fn lambertian_irradiance_averages_six_face_radiances() {
    let radiance = [
        [0.78, 0.82, 0.88],
        [0.62, 0.68, 0.76],
        [1.00, 0.98, 0.92],
        [0.28, 0.30, 0.34],
        [0.70, 0.74, 0.82],
        [0.56, 0.60, 0.68],
    ];
    let cube = EnvironmentCubemapFaces {
        face_radiance: radiance,
        resolution: 64,
        face_pixels: None,
    };
    let irradiance = cube.lambertian_irradiance();
    let expected = [
        (0.78 + 0.62 + 1.00 + 0.28 + 0.70 + 0.56) / 6.0,
        (0.82 + 0.68 + 0.98 + 0.30 + 0.74 + 0.60) / 6.0,
        (0.88 + 0.76 + 0.92 + 0.34 + 0.82 + 0.68) / 6.0,
    ];
    for channel in 0..3 {
        assert!(
            (irradiance[channel] - expected[channel]).abs() < 1e-5,
            "channel {channel} mean = {} must equal six-face average = {}",
            irradiance[channel],
            expected[channel]
        );
    }
}