Implement native archive and model workflows (#74)
Some checks failed
Native code generation / deterministic (push) Failing after 1m48s
Imaging and meshing gate / native (push) Successful in 5m26s
JPEG 2000 feature / linux (push) Successful in 2m51s
Native Rust workspace compile / compile (push) Successful in 5m28s
Skia feature / linux (push) Successful in 31m53s

This commit is contained in:
2026-08-10 20:31:34 +00:00
parent 763e9e5044
commit 55424dbd7f
15 changed files with 5862 additions and 2024 deletions

View File

@@ -0,0 +1,288 @@
use std::fs;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use libremetaverse::GridClient;
use libremetaverse::assets::{
OarFile, OarFileAssetLoadedCallback, OarFileSceneObjectLoadedCallback,
OarFileSettingsLoadedCallback, OarFileTerrainLoadedCallback, RegionSettings,
};
use libremetaverse::import_export::{ColladaLoader, ModelFace, ModelPrim, ModelUploader};
use libremetaverse::rendering::Vertex;
use libremetaverse_structured_data::OSD;
use libremetaverse_types::{Quaternion, UUID, Vector2, Vector3};
fn temporary_directory(label: &str) -> PathBuf {
let id = UUID::random().expect("temporary UUID").to_string();
let path = std::env::temp_dir().join(format!("metacrate-{label}-{id}"));
fs::create_dir_all(&path).expect("create temporary directory");
path
}
#[test]
fn oar_packaging_is_deterministic_and_sorted() {
let root = temporary_directory("oar");
for directory in ["assets", "objects", "terrains", "landdata", "settings"] {
fs::create_dir_all(root.join(directory)).expect("create OAR directory");
}
fs::write(root.join("assets/z.texture"), b"z").expect("write z asset");
fs::write(root.join("assets/a.texture"), b"a").expect("write a asset");
fs::write(root.join("terrains/region.r32"), 1.0_f32.to_le_bytes()).expect("write terrain");
let first = root.join("first.oar");
let second = root.join("second.oar");
OarFile::package_archive(
root.to_string_lossy().into_owned(),
first.to_string_lossy().into_owned(),
)
.expect("first package");
OarFile::package_archive(
root.to_string_lossy().into_owned(),
second.to_string_lossy().into_owned(),
)
.expect("second package");
assert_eq!(
fs::read(first).expect("first bytes"),
fs::read(second).expect("second bytes")
);
fs::remove_dir_all(root).expect("remove temporary OAR directory");
}
#[test]
fn oar_canonical_asset_and_settings_names_round_trip() {
let root = temporary_directory("oar-roundtrip");
for directory in ["assets", "objects", "terrains", "landdata", "settings"] {
fs::create_dir_all(root.join(directory)).expect("create OAR directory");
}
let asset_id = UUID::random().expect("asset UUID");
let asset_bytes = b"native-j2c-payload".to_vec();
fs::write(
root.join(format!("assets/{asset_id}_texture.jp2")),
&asset_bytes,
)
.expect("write canonical asset");
let mut settings = RegionSettings::new().expect("region settings");
settings.allow_damage = true;
settings.water_height = 23.5;
settings
.to_xml(
root.join("settings/Native Region.xml")
.to_string_lossy()
.into_owned(),
)
.expect("write settings");
let archive = root.join("roundtrip.oar");
OarFile::package_archive(
root.to_string_lossy().into_owned(),
archive.to_string_lossy().into_owned(),
)
.expect("package round trip");
let loaded_asset = Arc::new(Mutex::new(None));
let loaded_asset_out = Arc::clone(&loaded_asset);
let loaded_settings = Arc::new(Mutex::new(None));
let loaded_settings_out = Arc::clone(&loaded_settings);
OarFile::unpackage_archive(
archive.to_string_lossy().into_owned(),
OarFileAssetLoadedCallback::from_callback(move |asset, _, _| {
*loaded_asset_out.lock().expect("asset lock") =
Some((asset.asset_id(), asset.asset_type(), asset.asset_data));
}),
OarFileTerrainLoadedCallback::from_callback(|_, _, _| {}),
OarFileSceneObjectLoadedCallback::from_callback(|_, _, _| {}),
OarFileSettingsLoadedCallback::from_callback(move |name, settings| {
*loaded_settings_out.lock().expect("settings lock") =
Some((name, settings.allow_damage, settings.water_height));
}),
)
.expect("unpackage round trip");
let asset = loaded_asset.lock().expect("loaded asset lock").clone();
assert_eq!(
asset,
Some((
asset_id,
libremetaverse_types::AssetType::Texture,
asset_bytes
))
);
assert_eq!(
loaded_settings
.lock()
.expect("loaded settings lock")
.clone(),
Some(("Native Region".to_owned(), true, 23.5))
);
fs::remove_dir_all(root).expect("remove round-trip directory");
}
#[test]
fn model_face_deduplicates_vertices_and_asset_is_deterministic() {
let vertex = || Vertex {
position: Vector3 {
x: -0.5,
y: 0.25,
z: 0.5,
},
normal: Vector3 {
x: 0.0,
y: 0.0,
z: 1.0,
},
tex_coord: Vector2 { x: 0.0, y: 1.0 },
};
let mut face = ModelFace::new().expect("model face");
face.add_vertex(vertex()).expect("first vertex");
face.add_vertex(vertex()).expect("duplicate vertex");
assert_eq!(face.vertices.len(), 1);
assert_eq!(face.indices, [0, 0]);
let mut prim = ModelPrim::new().expect("model prim");
prim.faces.push(face);
prim.create_asset(UUID::zero()).expect("first model asset");
let first = prim.asset.clone();
prim.create_asset(UUID::zero()).expect("second model asset");
assert!(!first.is_empty());
assert_eq!(prim.asset, first);
}
#[test]
fn model_upload_resources_are_deterministic_and_offline() {
let mut face = ModelFace::new().expect("model face");
face.material.texture = "surface.jp2".into();
face.material.texture_data = vec![1, 2, 3, 4];
for position in [
Vector3::zero(),
Vector3 {
x: 1.0,
y: 0.0,
z: 0.0,
},
Vector3 {
x: 0.0,
y: 1.0,
z: 0.0,
},
] {
face.add_vertex(Vertex {
position,
normal: Vector3 {
x: 0.0,
y: 0.0,
z: 1.0,
},
tex_coord: Vector2::zero(),
})
.expect("upload vertex");
}
let mut prim = ModelPrim::new().expect("model prim");
prim.faces.push(face);
prim.create_asset(UUID::zero()).expect("mesh asset");
let uploader = ModelUploader::new(
GridClient::new().expect("offline client"),
vec![prim],
"Offline mesh".into(),
"No network required".into(),
)
.expect("model uploader");
let first = uploader.asset_resources(false).expect("offline resources");
let second = uploader.asset_resources(false).expect("repeat resources");
assert_eq!(first, second);
let OSD::Map(map) = first else {
panic!("resource map");
};
let OSD::Array(textures) = map.get("texture_list").expect("texture list") else {
panic!("texture array");
};
assert_eq!(textures, &[OSD::Binary(Vec::new())]);
let OSD::Map(upload) = uploader.asset_resources(true).expect("upload resources") else {
panic!("upload map");
};
assert_eq!(
upload.get("texture_list"),
Some(&OSD::Array(vec![OSD::Binary(vec![1, 2, 3, 4])]))
);
}
#[test]
fn collada_triangle_converts_to_native_model_asset() {
let root = temporary_directory("collada");
let file = root.join("triangle.dae");
fs::write(
&file,
r##"<?xml version="1.0" encoding="utf-8"?>
<COLLADA xmlns="http://www.collada.org/2005/11/COLLADASchema" version="1.4.1">
<library_geometries><geometry id="triangle"><mesh>
<source id="positions"><float_array id="positions-array" count="9">0 0 0 1 0 0 0 1 0</float_array>
<technique_common><accessor source="#positions-array" count="3" stride="3"/></technique_common></source>
<vertices id="vertices"><input semantic="POSITION" source="#positions"/></vertices>
<triangles count="1"><input semantic="VERTEX" source="#vertices" offset="0"/><p>0 1 2</p></triangles>
</mesh></geometry></library_geometries>
</COLLADA>"##,
)
.expect("write Collada fixture");
let prims = ColladaLoader::new(None)
.expect("Collada loader")
.load(file.to_string_lossy().into_owned(), false)
.expect("load Collada");
assert_eq!(prims.len(), 1);
assert_eq!(prims[0].faces.len(), 1);
assert_eq!(prims[0].faces[0].indices, [0, 1, 2]);
assert!(!prims[0].asset.is_empty());
assert_eq!(prims[0].rotation, Quaternion::identity());
fs::remove_dir_all(root).expect("remove temporary Collada directory");
}
#[test]
fn collada_applies_units_effects_and_instance_material_bindings() {
let root = temporary_directory("collada-material");
let file = root.join("material.dae");
fs::write(
&file,
r##"<COLLADA xmlns="http://www.collada.org/2005/11/COLLADASchema">
<asset><unit meter="2"/><up_axis>Z_UP</up_axis></asset>
<library_effects><effect id="green"><profile_COMMON><technique sid="common"><lambert>
<diffuse><color>0.1 0.8 0.2 1</color></diffuse>
</lambert></technique></profile_COMMON></effect></library_effects>
<library_materials><material id="surface"><instance_effect url="#green"/></material></library_materials>
<library_geometries><geometry id="triangle"><mesh>
<source id="positions"><float_array>0 0 0 1 0 0 0 1 0</float_array>
<technique_common><accessor stride="3"/></technique_common></source>
<vertices id="vertices"><input semantic="POSITION" source="#positions"/></vertices>
<triangles count="1" material="slot"><input semantic="VERTEX" source="#vertices" offset="0"/><p>0 1 2</p></triangles>
</mesh></geometry></library_geometries>
<library_visual_scenes><visual_scene id="scene"><node id="instance"><instance_geometry url="#triangle">
<bind_material><technique_common><instance_material symbol="slot" target="#surface"/></technique_common></bind_material>
</instance_geometry></node></visual_scene></library_visual_scenes>
</COLLADA>"##,
)
.expect("write material Collada fixture");
let prims = ColladaLoader::new(None)
.expect("Collada loader")
.load(file.to_string_lossy().into_owned(), false)
.expect("load material Collada");
assert_eq!(prims.len(), 1);
assert_eq!(prims[0].scale.x, 2.0);
assert_eq!(prims[0].scale.y, 2.0);
assert_eq!(prims[0].faces[0].material.id, "surface");
assert_eq!(prims[0].faces[0].material.diffuse_color.g, 0.8);
fs::remove_dir_all(root).expect("remove material Collada directory");
}
#[test]
fn collada_texture_path_cannot_escape_document_root() {
let root = temporary_directory("collada-traversal");
let file = root.join("traversal.dae");
fs::write(
&file,
r##"<COLLADA><library_images><image id="escape"><init_from>../secret.jp2</init_from></image></library_images>
<library_materials><material id="mat"><diffuse><texture texture="escape"/></diffuse></material></library_materials></COLLADA>"##,
)
.expect("write traversal fixture");
assert!(
ColladaLoader::new(None)
.expect("Collada loader")
.load(file.to_string_lossy().into_owned(), true)
.is_err()
);
fs::remove_dir_all(root).expect("remove traversal directory");
}

View File

@@ -515,3 +515,67 @@ fn gltf_node_explicit_matrix() {
assert_close(matrix.m24, 6.0, 1e-5);
assert_close(matrix.m34, 7.0, 1e-5);
}
#[test]
fn gltf_rejects_buffer_view_outside_declared_buffer() {
let json = r#"{
"asset":{"version":"2.0"},
"buffers":[{"byteLength":4,"uri":"data:application/octet-stream;base64,AAAAAA=="}],
"bufferViews":[{"buffer":0,"byteOffset":3,"byteLength":4}]
}"#;
assert!(GltfDocument::load_gltf(json.into(), None).is_err());
}
#[test]
fn gltf_rejects_cyclic_node_graphs() {
let json = r#"{
"asset":{"version":"2.0"},
"nodes":[{"children":[1]},{"children":[0]}],
"scenes":[{"nodes":[0]}],
"scene":0
}"#;
assert!(GltfDocument::load_gltf(json.into(), None).is_err());
}
#[test]
fn gltf_surface_skin_and_animation_round_trip() {
let json = r#"{
"asset":{"version":"2.0"},
"accessors":[
{"componentType":5126,"count":0,"type":"SCALAR"},
{"componentType":5126,"count":0,"type":"VEC3"}
],
"images":[{"uri":"data:image/png;base64,iVBORw0KGgo=","mimeType":"image/png","name":"image"}],
"samplers":[{"magFilter":9729,"minFilter":9987,"wrapS":10497,"wrapT":33071,"name":"sampler"}],
"textures":[{"sampler":0,"source":0,"name":"texture"}],
"materials":[{
"name":"surface",
"pbrMetallicRoughness":{"baseColorTexture":{"index":0},"metallicRoughnessTexture":{"index":0}},
"normalTexture":{"index":0,"scale":0.5},
"occlusionTexture":{"index":0,"strength":0.75},
"emissiveTexture":{"index":0},
"emissiveFactor":[0.1,0.2,0.3]
}],
"nodes":[{"skin":0}],
"skins":[{"joints":[0],"skeleton":0,"name":"skin"}],
"animations":[{
"name":"move",
"samplers":[{"input":0,"output":1,"interpolation":"LINEAR"}],
"channels":[{"sampler":0,"target":{"node":0,"path":"translation"}}]
}]
}"#;
let document = GltfDocument::load_gltf(json.into(), None).expect("load complete surface");
let serialized = document.to_json(None).expect("serialize complete surface");
let round_trip = GltfDocument::load_gltf(serialized, None).expect("reload complete surface");
assert_eq!(round_trip.images().len(), 1);
assert_eq!(round_trip.samplers().len(), 1);
assert_eq!(round_trip.textures().len(), 1);
assert_eq!(round_trip.skins()[0].joints(), [0]);
assert_eq!(round_trip.animations()[0].channels().len(), 1);
let material = &round_trip.materials()[0];
assert!(material.base_color_texture().is_some());
assert!(material.metallic_roughness_texture().is_some());
assert!(material.normal_texture().is_some());
assert!(material.occlusion_texture().is_some());
assert!(material.emissive_texture().is_some());
}

View File

@@ -6,16 +6,8 @@ use libremetaverse::messages::linden::AgentDropGroupMessage;
use libremetaverse::packets::UseCircuitCodePacket;
use libremetaverse::{BitPack, PacketFrequency, PermissionMask, Permissions, Primitive};
fn member_id<T>(result: Result<T, libremetaverse::Error>) -> &'static str {
result
.err()
.expect("failure-only shim unexpectedly returned a value")
.csharp_member()
.expect("failure-only shim error")
}
#[test]
fn each_wire_data_domain_is_callable_and_failure_only() {
fn each_wire_data_domain_is_callable_at_its_native_boundary() {
let packet = UseCircuitCodePacket::new_with_constructor()
.expect("generated packet constructor is native");
assert_eq!(packet.circuit_code.code, 0);
@@ -26,20 +18,26 @@ fn each_wire_data_domain_is_callable_and_failure_only() {
.is_empty()
);
assert_eq!(
member_id(AssetTexture::new_with_constructor()),
"M:LibreMetaverse.Assets.AssetTexture.#ctor"
AssetTexture::new_with_constructor()
.expect("native asset texture constructor")
.components,
0
);
assert_eq!(
member_id(GltfDocument::new()),
"M:LibreMetaverse.Assets.Gltf.GltfDocument.#ctor"
GltfDocument::new()
.expect("native glTF constructor")
.version(),
"2.0"
);
assert!(
ModelPrim::new()
.expect("native model constructor")
.asset
.is_empty()
);
assert_eq!(
member_id(ModelPrim::new()),
"M:LibreMetaverse.ImportExport.ModelPrim.#ctor"
);
assert_eq!(
member_id(OarFile::package_archive(String::new(), String::new())),
"M:LibreMetaverse.Assets.OarFile.PackageArchive(System.String,System.String)"
OarFile::package_archive(String::new(), String::new()),
Err(libremetaverse::Error::InvalidOperation)
);
let bit_pack = BitPack::new(Vec::new(), 0).expect("native bit pack constructor");
assert_eq!(bit_pack.byte_pos(), 0);