scena 1.7.2

A Rust-native scene-graph renderer with typed scene state, glTF assets, and explicit prepare/render lifecycles.
Documentation
#![cfg(not(target_arch = "wasm32"))]

use std::fs;
use std::path::{Path, PathBuf};

#[cfg(feature = "scene-host")]
use scena::{AssetPath, SceneHostCore, VisualPatchTransformV1, VisualPatchV1};
use scena::{
    Assets, Color, ExplodedView, GeometryDesc, MaterialDesc, Renderer, Scene, Transform, Vec3,
};

#[test]
fn exploded_view_factor_zero_identity_and_factor_one_separates_parts() {
    let assets = Assets::new();
    let geometry = assets.create_geometry(GeometryDesc::box_xyz(0.4, 0.4, 0.4));
    let material = assets.create_material(MaterialDesc::unlit(Color::WHITE));
    let mut scene = Scene::new();
    let assembly = scene
        .add_empty(scene.root(), Transform::IDENTITY)
        .expect("assembly inserts");
    let left = scene
        .mesh(geometry, material)
        .parent(assembly)
        .transform(Transform::at(Vec3::new(-1.0, 0.0, 0.0)))
        .add()
        .expect("left part inserts");
    let right = scene
        .mesh(geometry, material)
        .parent(assembly)
        .transform(Transform::at(Vec3::new(1.0, 0.0, 0.0)))
        .add()
        .expect("right part inserts");

    let identity = ExplodedView::from_node(assembly)
        .factor(0.0)
        .distance(1.0)
        .transforms(&scene, &assets)
        .expect("identity plan computes");
    assert_eq!(identity.len(), 2);
    assert_eq!(
        transform_for(&identity, left).translation,
        Vec3::new(-1.0, 0.0, 0.0)
    );
    assert_eq!(
        transform_for(&identity, right).translation,
        Vec3::new(1.0, 0.0, 0.0)
    );

    let exploded = ExplodedView::from_node(assembly)
        .factor(1.0)
        .distance(1.0)
        .transforms(&scene, &assets)
        .expect("exploded plan computes");
    assert_eq!(
        transform_for(&exploded, left).translation,
        Vec3::new(-2.0, 0.0, 0.0)
    );
    assert_eq!(
        transform_for(&exploded, right).translation,
        Vec3::new(2.0, 0.0, 0.0)
    );

    scene
        .set_transforms(&exploded.as_transform_updates())
        .expect("exploded transforms apply");
    let separated = scene
        .world_distance(left, right)
        .expect("distance computes");
    assert!(
        separated > 3.9,
        "factor 1.0 should visibly separate the direct children, got {separated}"
    );

    scene
        .set_transforms(&identity.as_transform_updates())
        .expect("identity transforms restore");
    assert_eq!(
        scene
            .node(left)
            .expect("left exists")
            .transform()
            .translation,
        Vec3::new(-1.0, 0.0, 0.0)
    );
}

#[test]
fn exploded_view_supports_axis_and_hierarchy_depth_modes() {
    let assets = Assets::new();
    let geometry = assets.create_geometry(GeometryDesc::box_xyz(0.4, 0.4, 0.4));
    let material = assets.create_material(MaterialDesc::unlit(Color::WHITE));
    let mut scene = Scene::new();
    let assembly = scene
        .add_empty(scene.root(), Transform::IDENTITY)
        .expect("assembly inserts");
    let left = scene
        .mesh(geometry, material)
        .parent(assembly)
        .transform(Transform::at(Vec3::new(-1.0, 0.5, 0.0)))
        .add()
        .expect("left part inserts");
    let right = scene
        .mesh(geometry, material)
        .parent(assembly)
        .transform(Transform::at(Vec3::new(1.0, -0.5, 0.0)))
        .add()
        .expect("right part inserts");
    let nested = scene
        .mesh(geometry, material)
        .parent(right)
        .transform(Transform::at(Vec3::new(0.0, 0.5, 0.0)))
        .add()
        .expect("nested part inserts");

    let axis = ExplodedView::from_node(assembly)
        .along_axis(Vec3::X)
        .factor(1.0)
        .distance(0.5)
        .transforms(&scene, &assets)
        .expect("axis plan computes");
    assert!(transform_for(&axis, left).translation.x < -1.4);
    assert!(transform_for(&axis, right).translation.x > 1.4);
    assert_eq!(
        transform_for(&axis, left).translation.y,
        0.5,
        "axis mode must offset along the selected axis only"
    );

    let depth = ExplodedView::from_node(assembly)
        .by_hierarchy_depth()
        .factor(1.0)
        .distance(0.5)
        .transforms(&scene, &assets)
        .expect("depth plan computes");
    let right_delta = transform_for(&depth, right).translation.x - 1.0;
    let nested_delta = transform_for(&depth, nested).translation.x;
    assert!(
        nested_delta > right_delta,
        "hierarchy-depth mode should push deeper parts farther: nested {nested_delta}, right {right_delta}"
    );
}

#[test]
#[cfg(feature = "scene-host")]
fn scene_host_exploded_view_emits_visual_patch_transform_channels() {
    let mut host = SceneHostCore::headless(128, 96).expect("host builds");
    let import = pollster::block_on(host.instantiate_url(AssetPath::from(
        "tests/assets/gltf/exploded_view_assembly.gltf",
    )))
    .expect("assembly imports");
    let assembly = host
        .node_handle(import, "Assembly")
        .expect("assembly handle resolves");

    let immediate: VisualPatchV1 = serde_json::from_str(
        &host
            .exploded_view_patch_json(
                assembly,
                r#"{"mode":"axis","axis":[1.0,0.0,0.0],"factor":1.0,"distance":0.35}"#,
            )
            .expect("immediate patch serializes"),
    )
    .expect("visual patch decodes");
    assert_eq!(immediate.schema, "scena.visual_patch.v1");
    assert_eq!(immediate.transforms.len(), 2);
    assert!(immediate.transforms_eased.is_empty());
    let restore_patch: VisualPatchV1 = serde_json::from_value(
        immediate
            .metadata
            .as_ref()
            .and_then(|metadata| metadata.get("scena_exploded_view_restore_patch"))
            .cloned()
            .expect("SceneHost exploded-view patch includes a restore VisualPatch"),
    )
    .expect("restore patch decodes");
    assert_eq!(restore_patch.schema, "scena.visual_patch.v1");
    assert_eq!(restore_patch.transforms.len(), immediate.transforms.len());
    assert!(restore_patch.transforms_eased.is_empty());
    assert!(
        restore_patch
            .transforms
            .iter()
            .zip(immediate.transforms.iter())
            .all(|(restore, exploded)| restore.node == exploded.node
                && restore.transform.translation != exploded.transform.translation),
        "restore patch must target the same nodes with the pre-exploded transforms"
    );

    let eased: VisualPatchV1 = serde_json::from_str(
        &host
            .exploded_view_patch_json(
                assembly,
                r#"{"mode":"axis","axis":[1.0,0.0,0.0],"factor":1.0,"distance":0.35,"duration_seconds":0.25,"easing":"ease_in_out"}"#,
            )
            .expect("eased patch serializes"),
    )
    .expect("visual patch decodes");
    assert!(eased.transforms.is_empty());
    assert_eq!(eased.transforms_eased.len(), 2);
    assert_eq!(eased.transforms_eased[0].duration_seconds, 0.25);
    let eased_restore = restore_transforms_from_metadata(&eased);
    assert_eq!(eased_restore.len(), eased.transforms_eased.len());
    assert!(
        eased_restore
            .iter()
            .zip(eased.transforms_eased.iter())
            .all(|(restore, exploded)| restore.node == exploded.node
                && restore.transform.translation != exploded.transform.translation),
        "eased exploded-view patches must carry immediate restore transforms for JSON/WASM hosts"
    );
}

#[cfg(feature = "scene-host")]
fn restore_transforms_from_metadata(patch: &VisualPatchV1) -> Vec<VisualPatchTransformV1> {
    let restore_patch: VisualPatchV1 = serde_json::from_value(
        patch
            .metadata
            .as_ref()
            .and_then(|metadata| metadata.get("scena_exploded_view_restore_patch"))
            .cloned()
            .expect("restore patch metadata exists"),
    )
    .expect("restore patch decodes");
    restore_patch.transforms
}

#[test]
fn exploded_view_renders_imported_assembly_with_wider_part_span() {
    let assets = Assets::new();
    let scene_asset =
        pollster::block_on(assets.load_scene("tests/assets/gltf/exploded_view_assembly.gltf"))
            .expect("assembly glTF loads");
    let (mut scene, camera) = Scene::with_default_camera().expect("camera inserts");
    let import = scene
        .instantiate(&scene_asset)
        .expect("assembly instantiates");
    let assembly = import.node("Assembly").expect("assembly node resolves");
    let bounds = import.bounds_world(&scene).expect("import has bounds");
    scene.frame(camera, bounds).expect("assembled view frames");

    let assembled = render_frame(&mut scene, &assets);
    let assembled_rect = nonblack_pixel_rect(&assembled, 160, 120).expect("assembled visible");

    let exploded = ExplodedView::from_node(assembly)
        .along_axis(Vec3::X)
        .factor(1.0)
        .distance(0.35)
        .transforms(&scene, &assets)
        .expect("exploded plan computes");
    scene
        .set_transforms(&exploded.as_transform_updates())
        .expect("exploded transforms apply");

    let exploded_frame = render_frame(&mut scene, &assets);
    let exploded_rect = nonblack_pixel_rect(&exploded_frame, 160, 120).expect("exploded visible");
    assert!(
        exploded_rect.width() > assembled_rect.width() + 8,
        "exploded imported assembly should span more pixels: assembled={assembled_rect:?}, exploded={exploded_rect:?}"
    );

    let artifact_dir =
        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target/gate-artifacts/exploded-view");
    fs::create_dir_all(&artifact_dir).expect("artifact dir exists");
    write_ppm_artifact(&artifact_dir, "assembled", 160, 120, &assembled);
    write_ppm_artifact(&artifact_dir, "exploded", 160, 120, &exploded_frame);
}

fn transform_for(plan: &scena::ExplodedViewPlan, node: scena::NodeKey) -> Transform {
    plan.updates()
        .iter()
        .find(|update| update.node == node)
        .expect("node update appears")
        .transform
}

fn render_frame(scene: &mut Scene, assets: &Assets) -> Vec<u8> {
    let mut renderer = Renderer::headless(160, 120).expect("renderer builds");
    renderer
        .prepare_with_assets(scene, assets)
        .expect("scene prepares");
    renderer.render_active(scene).expect("scene renders");
    renderer.frame_rgba8().to_vec()
}

#[derive(Debug, Clone, Copy)]
struct PixelRect {
    min_x: u32,
    max_x: u32,
}

impl PixelRect {
    fn width(self) -> u32 {
        self.max_x - self.min_x + 1
    }
}

fn nonblack_pixel_rect(rgba: &[u8], width: u32, height: u32) -> Option<PixelRect> {
    let mut rect: Option<PixelRect> = None;
    for y in 0..height {
        for x in 0..width {
            let index = ((y * width + x) * 4) as usize;
            let pixel = &rgba[index..index + 4];
            if pixel[0] == 0 && pixel[1] == 0 && pixel[2] == 0 {
                continue;
            }
            rect = Some(match rect {
                Some(existing) => PixelRect {
                    min_x: existing.min_x.min(x),
                    max_x: existing.max_x.max(x),
                },
                None => PixelRect { min_x: x, max_x: x },
            });
        }
    }
    rect
}

fn write_ppm_artifact(dir: &Path, name: &str, width: u32, height: u32, rgba: &[u8]) {
    let mut ppm = format!("P6\n{} {}\n255\n", width, height).into_bytes();
    for pixel in rgba.chunks_exact(4) {
        ppm.extend_from_slice(&pixel[..3]);
    }
    fs::write(dir.join(format!("{name}.ppm")), ppm).expect("PPM artifact writes");
}