#![cfg(not(target_arch = "wasm32"))]
use std::collections::BTreeMap;
use std::future::{Ready, ready};
use std::sync::{Arc, Mutex};
use base64::Engine as _;
use scena::{
AssetError, AssetFetcher, AssetPath, Assets, LookupError, NodeKind, Scene, SceneImport,
};
#[derive(Clone)]
struct MemoryFetcher {
files: Arc<Mutex<BTreeMap<AssetPath, Vec<u8>>>>,
}
impl MemoryFetcher {
fn new(files: Vec<(AssetPath, Vec<u8>)>) -> Self {
Self {
files: Arc::new(Mutex::new(files.into_iter().collect())),
}
}
}
impl AssetFetcher for MemoryFetcher {
type Future<'a> = Ready<Result<Vec<u8>, AssetError>>;
fn fetch<'a>(&'a self, path: &'a AssetPath) -> Self::Future<'a> {
ready(
self.files
.lock()
.expect("memory fetcher mutex should not be poisoned")
.get(path)
.cloned()
.ok_or_else(|| AssetError::NotFound {
path: path.as_str().to_string(),
}),
)
}
}
fn variants_gltf_with_two_materials() -> Vec<u8> {
let mut buffer = Vec::new();
for value in [-0.6_f32, -0.6, 0.0, 0.6, -0.6, 0.0, 0.0, 0.6, 0.0] {
buffer.extend_from_slice(&value.to_le_bytes());
}
for value in [0_u16, 1, 2] {
buffer.extend_from_slice(&value.to_le_bytes());
}
let encoded = base64::engine::general_purpose::STANDARD.encode(&buffer);
let gltf = format!(
r#"{{
"asset": {{ "version": "2.0" }},
"extensionsUsed": ["KHR_materials_variants", "KHR_materials_unlit"],
"extensionsRequired": ["KHR_materials_variants"],
"extensions": {{
"KHR_materials_variants": {{
"variants": [
{{ "name": "midnight" }},
{{ "name": "noon" }}
]
}}
}},
"materials": [
{{ "pbrMetallicRoughness": {{ "baseColorFactor": [1.0, 0.0, 0.0, 1.0] }}, "extensions": {{ "KHR_materials_unlit": {{}} }} }},
{{ "pbrMetallicRoughness": {{ "baseColorFactor": [0.0, 0.0, 1.0, 1.0] }}, "extensions": {{ "KHR_materials_unlit": {{}} }} }},
{{ "pbrMetallicRoughness": {{ "baseColorFactor": [0.0, 1.0, 0.0, 1.0] }}, "extensions": {{ "KHR_materials_unlit": {{}} }} }}
],
"meshes": [{{
"primitives": [{{
"attributes": {{ "POSITION": 0 }},
"indices": 1,
"material": 0,
"extensions": {{
"KHR_materials_variants": {{
"mappings": [
{{ "material": 1, "variants": [0] }},
{{ "material": 2, "variants": [1] }}
]
}}
}}
}}]
}}],
"nodes": [{{ "name": "VariantTriangle", "mesh": 0 }}],
"scenes": [{{ "nodes": [0] }}],
"scene": 0,
"buffers": [{{ "byteLength": 42, "uri": "data:application/octet-stream;base64,{encoded}" }}],
"bufferViews": [
{{ "buffer": 0, "byteOffset": 0, "byteLength": 36 }},
{{ "buffer": 0, "byteOffset": 36, "byteLength": 6 }}
],
"accessors": [
{{ "bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3", "min": [-0.6, -0.6, 0.0], "max": [0.6, 0.6, 0.0] }},
{{ "bufferView": 1, "componentType": 5123, "count": 3, "type": "SCALAR" }}
]
}}"#
);
gltf.into_bytes()
}
fn load_variants_scene() -> (Assets<MemoryFetcher>, scena::SceneAsset) {
let path = AssetPath::from("memory://variants/scene.gltf");
let fetcher = MemoryFetcher::new(vec![(path.clone(), variants_gltf_with_two_materials())]);
let assets = Assets::with_fetcher(fetcher);
let scene_asset =
pollster::block_on(assets.load_scene(path)).expect("variants gltf loads from memory");
(assets, scene_asset)
}
fn variant_mesh_material(scene: &Scene, import: &SceneImport) -> scena::MaterialHandle {
for root in import.roots() {
if let Some(handle) = walk_for_mesh(scene, *root) {
return handle;
}
}
panic!("scene has no mesh node under variant import");
}
fn walk_for_mesh(scene: &Scene, node_key: scena::NodeKey) -> Option<scena::MaterialHandle> {
let node = scene.node(node_key)?;
if let NodeKind::Mesh(mesh) = node.kind() {
return Some(mesh.material());
}
for child in node.children() {
if let Some(handle) = walk_for_mesh(scene, *child) {
return Some(handle);
}
}
None
}
#[test]
fn m8_set_active_variant_swaps_imported_mesh_material_handle() {
let (_assets, scene_asset) = load_variants_scene();
let mut scene = Scene::new();
let import = scene
.instantiate(&scene_asset)
.expect("variants scene instantiates");
assert_eq!(
import.material_variants(),
&["midnight".to_string(), "noon".to_string()],
"SceneImport must surface declared variant names in declaration order",
);
assert_eq!(
import.active_variant(),
None,
"no variant active by default"
);
let default_material = variant_mesh_material(&scene, &import);
scene
.set_active_variant(&import, Some("midnight"))
.expect("midnight variant resolves to a known name");
let midnight_material = variant_mesh_material(&scene, &import);
assert_ne!(
default_material, midnight_material,
"midnight variant must swap the MeshNode material to the variant-bound handle",
);
assert_eq!(import.active_variant(), Some("midnight".to_string()));
scene
.set_active_variant(&import, Some("noon"))
.expect("noon variant resolves to a known name");
let noon_material = variant_mesh_material(&scene, &import);
assert_ne!(
midnight_material, noon_material,
"noon variant must swap the MeshNode material to a different bound handle",
);
scene
.set_active_variant(&import, None)
.expect("clearing the active variant succeeds");
let cleared_material = variant_mesh_material(&scene, &import);
assert_eq!(
cleared_material, default_material,
"clearing the active variant must restore the primitive's default material",
);
assert_eq!(import.active_variant(), None);
}
#[test]
fn m8_set_active_variant_returns_typed_error_for_unknown_name() {
let (_assets, scene_asset) = load_variants_scene();
let mut scene = Scene::new();
let import = scene
.instantiate(&scene_asset)
.expect("variants scene instantiates");
scene
.set_active_variant(&import, Some("midnight"))
.expect("midnight variant resolves before the unknown-name probe");
match scene.set_active_variant(&import, Some("nonexistent")) {
Err(LookupError::VariantNotFound { name }) => {
assert_eq!(name, "nonexistent");
}
other => panic!("expected VariantNotFound, got {other:?}"),
}
assert_eq!(
import.active_variant(),
Some("midnight".to_string()),
"failed lookup must not stomp the previously-active variant",
);
}
#[test]
fn m8_active_variant_carries_across_replace_import_when_user_reapplies() {
let (_assets, scene_asset) = load_variants_scene();
let mut scene = Scene::new();
let import = scene
.instantiate(&scene_asset)
.expect("variants scene instantiates");
scene
.set_active_variant(&import, Some("midnight"))
.expect("midnight variant selects before replace_import");
let replacement = scene
.replace_import(&import, &scene_asset)
.expect("replace_import succeeds with the same scene asset");
let previous_name = import
.active_variant()
.expect("the stale import's variant name is still readable after replace_import");
assert_eq!(previous_name, "midnight");
assert_eq!(
replacement.active_variant(),
None,
"freshly-instantiated import starts at the default variant slot",
);
scene
.set_active_variant(&replacement, Some(&previous_name))
.expect("re-applying the previous variant on the replacement import succeeds");
assert_eq!(
replacement.active_variant(),
Some("midnight".to_string()),
"user-driven variant rebind across replace_import preserves the selection",
);
}
#[test]
fn m8_imports_without_variants_expose_empty_material_variants() {
let assets = Assets::new();
let scene_asset =
pollster::block_on(assets.load_scene("tests/assets/gltf/khronos/UnlitTest/UnlitTest.gltf"))
.expect("UnlitTest fixture loads");
let mut scene = Scene::new();
let import = scene
.instantiate(&scene_asset)
.expect("UnlitTest instantiates");
assert!(
import.material_variants().is_empty(),
"fixtures without KHR_materials_variants must report no variants",
);
assert_eq!(import.active_variant(), None);
assert!(
scene.set_active_variant(&import, None).is_ok(),
"clearing variants on an asset without variants must succeed (no-op)",
);
}