diff --git a/.gitea/workflows/rust-workspace.yml b/.gitea/workflows/rust-workspace.yml index 1dec897..d4b72c8 100644 --- a/.gitea/workflows/rust-workspace.yml +++ b/.gitea/workflows/rust-workspace.yml @@ -8,6 +8,7 @@ on: - "tools/check_milestone_09.py" - "tools/test_milestone_09.py" - "tools/check_milestone_10_issue_76.py" + - "tools/check_milestone_10_issue_77.py" - "**/*.rs" - "**/Cargo.toml" - "Cargo.lock" @@ -18,6 +19,7 @@ on: - "tools/check_milestone_09.py" - "tools/test_milestone_09.py" - "tools/check_milestone_10_issue_76.py" + - "tools/check_milestone_10_issue_77.py" - "**/*.rs" - "**/Cargo.toml" - "Cargo.lock" @@ -42,7 +44,7 @@ jobs: tools/install_openjpeg_2_5_4.sh "$OPENJPEG_PREFIX" - name: Check formatting run: cargo fmt --all -- --check - - name: Audit native milestone 09 boundaries + - name: Audit native implementation boundaries run: | python3 tools/check_milestone_09_issue_59.py python3 tools/check_milestone_09_issue_60.py @@ -54,6 +56,7 @@ jobs: python3 tools/check_milestone_09_issue_66.py python3 tools/check_milestone_09.py python3 tools/check_milestone_10_issue_76.py + python3 tools/check_milestone_10_issue_77.py - name: Test the complete native world milestone run: python3 tools/test_milestone_09.py - name: Compile every workspace target with bounded memory diff --git a/Cargo.lock b/Cargo.lock index b6505ef..8a452b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -957,11 +957,14 @@ dependencies = [ name = "libremetaverse-rendering-mesh-foundry" version = "0.0.1" dependencies = [ + "flate2", "libremetaverse", "libremetaverse-imaging", "libremetaverse-prim-mesher", + "libremetaverse-rendering-simple", "libremetaverse-structured-data", "libremetaverse-types", + "stats_alloc", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 49321f3..857e5d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,3 +33,13 @@ unsafe_code = "forbid" [workspace.lints.clippy] all = { level = "warn", priority = -1 } pedantic = { level = "warn", priority = -1 } + +# The generated core API is intentionally very large; full release LTO-style +# optimization can exceed small Gitea worker memory before diagnostic fixture +# benchmarks run. This stable profile retains optimization without debug or +# incremental artifacts and is used only when explicitly selected. +[profile.benchmark] +inherits = "dev" +opt-level = 1 +debug = 0 +incremental = false diff --git a/README.md b/README.md index d94fa8f..6865ec2 100644 --- a/README.md +++ b/README.md @@ -453,3 +453,12 @@ primitive UUID. It deliberately does not fetch or decode mesh assets; that surface remains with MeshFoundry. Supported topology, 16-bit geometry budgets, determinism, and the asset boundary are documented in the [`SimpleRenderer` guide](crates/libremetaverse-rendering-simple/README.md). + +The native `MeshFoundry` pipeline builds on that reference path for prims and +sculpts, and adds bounded packed-LLSD mesh assets, deterministic LOD fallback, +terrain and convex hulls, independent UV domains, generated or stored normals +and tangents, inherited material metadata, mirror/invert transforms, and +rigged-mesh skin matrices and normalized weights. A large executable fixture +reports decode allocations and timing without replacing correctness gates. The +format, ownership, error, and tangent-output contracts are documented in the +[`MeshFoundry` guide](crates/libremetaverse-rendering-mesh-foundry/README.md). diff --git a/api/SHIM-COVERAGE.md b/api/SHIM-COVERAGE.md index ff06a5c..2348827 100644 --- a/api/SHIM-COVERAGE.md +++ b/api/SHIM-COVERAGE.md @@ -10,7 +10,7 @@ Generated by `python3 tools/generate_api_shims.py`; do not edit by hand. | `LibreMetaverse.LslTools` | 164 | 768 | callable failure-only shim | | `LibreMetaverse.PrimMesher` | 17 | 207 | native implementation: 15 types / 200 members; remaining surface is callable failure-only shims | | `LibreMetaverse.RLV` | 28 | 499 | callable failure-only shim | -| `LibreMetaverse.Rendering.MeshFoundry` | 1 | 14 | callable failure-only shim | +| `LibreMetaverse.Rendering.MeshFoundry` | 1 | 14 | native implementation: 1 type / 14 members; no generated shims remain | | `LibreMetaverse.Rendering.Simple` | 1 | 6 | native implementation: 1 type / 6 members; no generated shims remain | | `LibreMetaverse.StructuredData` | 16 | 295 | native implementation: 16 types / 295 members; no generated shims remain | | `LibreMetaverse.Types` | 45 | 942 | native implementation: 45 types / 942 members; no generated shims remain | diff --git a/crates/libremetaverse-rendering-mesh-foundry/Cargo.toml b/crates/libremetaverse-rendering-mesh-foundry/Cargo.toml index 18c3ac8..c34727c 100644 --- a/crates/libremetaverse-rendering-mesh-foundry/Cargo.toml +++ b/crates/libremetaverse-rendering-mesh-foundry/Cargo.toml @@ -5,14 +5,23 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true -description = "MeshFoundry renderer shims for the MetaCrate LibreMetaverse rewrite" +description = "Bounded native MeshFoundry renderer for the MetaCrate LibreMetaverse rewrite" [dependencies] +flate2 = "1.1.2" libremetaverse = { path = "../libremetaverse" } libremetaverse-imaging = { path = "../libremetaverse-imaging" } libremetaverse-prim-mesher = { path = "../libremetaverse-prim-mesher" } +libremetaverse-rendering-simple = { path = "../libremetaverse-rendering-simple" } libremetaverse-structured-data = { path = "../libremetaverse-structured-data" } libremetaverse-types = { path = "../libremetaverse-types" } +[dev-dependencies] +stats_alloc = "0.1.10" + +[[bench]] +name = "mesh_decode" +harness = false + [lints] workspace = true diff --git a/crates/libremetaverse-rendering-mesh-foundry/README.md b/crates/libremetaverse-rendering-mesh-foundry/README.md new file mode 100644 index 0000000..dbf2300 --- /dev/null +++ b/crates/libremetaverse-rendering-mesh-foundry/README.md @@ -0,0 +1,85 @@ +# LibreMetaverse MeshFoundry renderer + +`libremetaverse-rendering-mesh-foundry` is the bounded native renderer for +legacy prims, sculpt maps, terrain, and Second Life LLSD mesh assets. It uses +the checked SimpleRenderer/PrimMesher path for legacy geometry and the native +StructuredData binary codec for mesh containers. It does not wrap, load, or +invoke the C# renderer or duplicate the LLSD and image codecs. + +## Pipelines + +The mapped `MeshFoundry` methods cover: + +- simple and face-grouped legacy prim geometry at every `DetailLevel`, always + retaining finite normals and UVs; +- plane, cylinder, sphere, and torus sculpt maps; +- rectangular height-map terrain with generated normals and UVs; +- packed mesh-asset headers and individually zlib-compressed LLSD sections; +- requested visual LOD selection (`high_lod`, `medium_lod`, `low_lod`, and + `lowest_lod`) with deterministic highest-to-lowest fallback; +- single-section simple-mesh extraction and physics convex/bounding hulls; and +- default or planar texture transforms, repeats, offsets, rotation, and prim + scale through the common `IRendering` contract. + +Mesh vertices remain in prim-local coordinates. Quantized positions use each +submesh's `PositionDomain`; normals and tangent components use the format's +fixed `[-1, 1]` domain; UV0 and optional UV1 use their independent domains. +Triangle and face order is preserved, while flattened multi-face output rebases +indices without reordering vertices. Missing normals and tangents are generated +deterministically from triangles and UVs. Degenerate UV gradients receive a +stable normal-orthogonal fallback tangent. + +The mapped `Face` type has no tangent field. Mesh tangents are therefore kept +without changing the public C# layout in a public `MeshFaceAux` value stored in +`Face.user_data`; recover it with +`face.user_data.downcast_ref::()`. Its `Vector4.w` is the bitangent +handedness. UV1 remains in `Face.tex_coords1`. + +## Rigged meshes and materials + +The `skin` section decodes bounded joint names, inverse-bind matrices, +bind-shape matrix, alternative inverse-bind matrices, pelvis offset, and the +joint-position scale lock into the shared `MeshSkinData`. Per-vertex weight +streams accept the protocol's sentinel form and its exactly-four-influence +form, validate every joint index, clamp protocol weights, normalize four +influences, and bind missing vertices to joint zero. The output is directly +consumable by `RiggedSkinMath` and `AnimeshSkinning`. + +Each submesh retains its original face ID and the effective inherited +`PrimitiveTextureEntryFace`, including texture, legacy material, and render +material overrides. `NormalizedScale`, face bounds, centers, optional UV1, +weights, normals, tangents, and winding are kept together. Mesh sculpt mirror +and invert flags apply the viewer-compatible reflection, normal inversion, and +XOR winding rule to every face and tangent domain. + +## Bounds and failure contract + +Assets and compressed/decompressed sections are capped at 64 MiB. Header +sections and submeshes are capped at 256, each face remains within the 16-bit +65,536-vertex index domain, aggregate visual geometry is capped at 1,000,000 +vertices and 6,000,000 indices, skins at 512 joints, and convex data at 65,536 +hulls and 1,000,000 vertices. All sizes, offsets, index rebasing, buffer strides, +and allocation products use checked arithmetic before slicing or allocation. + +Malformed LLSD, zlib data, domains, parallel buffers, matrices, weights, +offsets, and indices return `Error::Rendering` with the source primitive UUID +and a stable operation context. `UnpackMesh` has no primitive parameter and +uses the zero UUID. This crate performs no asset download, scene traversal, +world transform, GPU upload, material shading, texture fetch, or drawing. + +## Reproducible gates and benchmark + +```console +python3 tools/check_milestone_10_issue_77.py +CARGO_BUILD_JOBS=1 cargo test -p libremetaverse-compat-tests --test imaging_meshing_semantics --locked -j1 +CARGO_BUILD_JOBS=1 cargo test -p libremetaverse-rendering-mesh-foundry --locked -j1 +CARGO_BUILD_JOBS=1 cargo bench -p libremetaverse-rendering-mesh-foundry --bench mesh_decode --profile benchmark --locked -j1 +``` + +The named `benchmark` profile uses bounded-memory moderate optimization because +full release optimization of the generated core surface can exceed small +Gitea-worker memory before the fixture runs. The non-statistical benchmark +builds and decodes a deterministic 57,600-vertex, +342,726-index mesh asset and reports elapsed time plus allocation counts. It is +diagnostic evidence for regressions; correctness and malformed-input tests +remain the release gates. diff --git a/crates/libremetaverse-rendering-mesh-foundry/benches/mesh_decode.rs b/crates/libremetaverse-rendering-mesh-foundry/benches/mesh_decode.rs new file mode 100644 index 0000000..a5b5268 --- /dev/null +++ b/crates/libremetaverse-rendering-mesh-foundry/benches/mesh_decode.rs @@ -0,0 +1,82 @@ +#![allow(clippy::cast_precision_loss)] // The benchmark grid is fixed at 240×240. + +use std::alloc::System; +use std::hint::black_box; +use std::time::Instant; + +use libremetaverse::Primitive; +use libremetaverse::import_export::{ModelFace, ModelPrim}; +use libremetaverse::rendering::{DetailLevel, Vertex}; +use libremetaverse_rendering_mesh_foundry::MeshFoundry; +use libremetaverse_types::{UUID, Vector2, Vector3}; +use stats_alloc::{INSTRUMENTED_SYSTEM, Region, StatsAlloc}; + +#[global_allocator] +static ALLOCATOR: &StatsAlloc = &INSTRUMENTED_SYSTEM; + +fn fixture(side: usize) -> Vec { + let mut model = ModelPrim::new().expect("model prim"); + let mut face = ModelFace::new().expect("model face"); + face.vertices.reserve(side * side); + for y in 0..side { + for x in 0..side { + let u = x as f32 / (side - 1) as f32; + let v = y as f32 / (side - 1) as f32; + face.vertices.push(Vertex { + position: Vector3 { + x: u - 0.5, + y: v - 0.5, + z: (u * std::f32::consts::TAU).sin() * 0.05, + }, + normal: Vector3::unit_z(), + tex_coord: Vector2 { x: u, y: v }, + }); + } + } + for y in 0..side - 1 { + for x in 0..side - 1 { + let a = u32::try_from(y * side + x).expect("bounded benchmark index"); + let b = a + 1; + let c = a + u32::try_from(side).expect("bounded side"); + let d = c + 1; + face.indices.extend_from_slice(&[a, b, c, b, d, c]); + } + } + model.faces.push(face); + model + .create_asset(UUID::zero()) + .expect("bounded benchmark mesh asset"); + model.asset +} + +fn main() { + let asset = fixture(240); + let renderer = MeshFoundry::new().expect("MeshFoundry constructor"); + let primitive = Primitive::new_with_constructor().expect("Primitive constructor"); + let allocation_region = Region::new(ALLOCATOR); + let started = Instant::now(); + let mesh = renderer + .generate_faceted_mesh_mesh_with_primitive_bytes_detail_level( + primitive, + asset.clone(), + DetailLevel::Highest, + ) + .expect("large fixture decode") + .expect("large fixture geometry"); + let elapsed = started.elapsed(); + let allocation_stats = allocation_region.change(); + + assert_eq!(mesh.faces.len(), 1); + assert_eq!(mesh.faces[0].vertices.len(), 57_600); + assert_eq!(mesh.faces[0].indices.len(), 342_726); + black_box((&asset, &mesh)); + println!( + "mesh decode: {} compressed bytes, {} vertices, {} indices in {elapsed:?}, {} allocations, {} reallocations, {} bytes allocated", + asset.len(), + mesh.faces[0].vertices.len(), + mesh.faces[0].indices.len(), + allocation_stats.allocations, + allocation_stats.reallocations, + allocation_stats.bytes_allocated, + ); +} diff --git a/crates/libremetaverse-rendering-mesh-foundry/src/generated.rs b/crates/libremetaverse-rendering-mesh-foundry/src/generated.rs index e9e6677..d7c4bc3 100644 --- a/crates/libremetaverse-rendering-mesh-foundry/src/generated.rs +++ b/crates/libremetaverse-rendering-mesh-foundry/src/generated.rs @@ -6,11 +6,11 @@ #![allow(non_snake_case)] /// C# type: `T:LibreMetaverse.Rendering.MeshFoundry`. -pub struct MeshFoundry; +pub use crate::mesh_foundry::MeshFoundry; impl MeshFoundry { /// C# member: `M:LibreMetaverse.Rendering.MeshFoundry.#ctor`. pub fn new() -> Result { - libremetaverse_types::not_implemented("M:LibreMetaverse.Rendering.MeshFoundry.#ctor") + Self::native_new() } /// C# member: `M:LibreMetaverse.Rendering.MeshFoundry.GenerateFacetedMesh(LibreMetaverse.Primitive,LibreMetaverse.Rendering.DetailLevel)`. pub fn generate_faceted_mesh( @@ -18,9 +18,7 @@ impl MeshFoundry { prim: libremetaverse::Primitive, lod: libremetaverse::rendering::DetailLevel, ) -> Result, crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Rendering.MeshFoundry.GenerateFacetedMesh(LibreMetaverse.Primitive,LibreMetaverse.Rendering.DetailLevel)", - ) + self.native_generate_faceted_mesh(prim, lod) } /// C# member: `M:LibreMetaverse.Rendering.MeshFoundry.GenerateFacetedMeshMesh(LibreMetaverse.Primitive,System.Byte[])`. pub fn generate_faceted_mesh_mesh_with_primitive_bytes( @@ -28,8 +26,10 @@ impl MeshFoundry { prim: libremetaverse::Primitive, mesh_data: Vec, ) -> Result, crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Rendering.MeshFoundry.GenerateFacetedMeshMesh(LibreMetaverse.Primitive,System.Byte[])", + self.native_generate_faceted_mesh_mesh( + prim, + mesh_data, + libremetaverse::rendering::DetailLevel::Highest, ) } /// C# member: `M:LibreMetaverse.Rendering.MeshFoundry.GenerateFacetedMeshMesh(LibreMetaverse.Primitive,System.Byte[],LibreMetaverse.Rendering.DetailLevel)`. @@ -39,9 +39,7 @@ impl MeshFoundry { mesh_data: Vec, lod: libremetaverse::rendering::DetailLevel, ) -> Result, crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Rendering.MeshFoundry.GenerateFacetedMeshMesh(LibreMetaverse.Primitive,System.Byte[],LibreMetaverse.Rendering.DetailLevel)", - ) + self.native_generate_faceted_mesh_mesh(prim, mesh_data, lod) } /// C# member: `M:LibreMetaverse.Rendering.MeshFoundry.GenerateFacetedSculptMesh(LibreMetaverse.Primitive,LibreMetaverse.Imaging.ManagedImage,LibreMetaverse.Rendering.DetailLevel)`. pub fn generate_faceted_sculpt_mesh( @@ -50,9 +48,7 @@ impl MeshFoundry { sculpt_texture: libremetaverse_imaging::ManagedImage, lod: libremetaverse::rendering::DetailLevel, ) -> Result, crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Rendering.MeshFoundry.GenerateFacetedSculptMesh(LibreMetaverse.Primitive,LibreMetaverse.Imaging.ManagedImage,LibreMetaverse.Rendering.DetailLevel)", - ) + self.native_generate_faceted_sculpt_mesh(prim, sculpt_texture, lod) } /// C# member: `M:LibreMetaverse.Rendering.MeshFoundry.GenerateSimpleMesh(LibreMetaverse.Primitive,LibreMetaverse.Rendering.DetailLevel)`. pub fn generate_simple_mesh( @@ -60,9 +56,7 @@ impl MeshFoundry { prim: libremetaverse::Primitive, lod: libremetaverse::rendering::DetailLevel, ) -> Result, crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Rendering.MeshFoundry.GenerateSimpleMesh(LibreMetaverse.Primitive,LibreMetaverse.Rendering.DetailLevel)", - ) + self.native_generate_simple_mesh(prim, lod, false) } /// C# member: `M:LibreMetaverse.Rendering.MeshFoundry.GenerateSimpleMeshWithNormals(LibreMetaverse.Primitive,LibreMetaverse.Rendering.DetailLevel)`. pub fn generate_simple_mesh_with_normals( @@ -70,9 +64,7 @@ impl MeshFoundry { prim: libremetaverse::Primitive, lod: libremetaverse::rendering::DetailLevel, ) -> Result, crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Rendering.MeshFoundry.GenerateSimpleMeshWithNormals(LibreMetaverse.Primitive,LibreMetaverse.Rendering.DetailLevel)", - ) + self.native_generate_simple_mesh(prim, lod, true) } /// C# member: `M:LibreMetaverse.Rendering.MeshFoundry.GenerateSimpleSculptMesh(LibreMetaverse.Primitive,LibreMetaverse.Imaging.ManagedImage,LibreMetaverse.Rendering.DetailLevel)`. pub fn generate_simple_sculpt_mesh( @@ -81,9 +73,7 @@ impl MeshFoundry { sculpt_texture: libremetaverse_imaging::ManagedImage, lod: libremetaverse::rendering::DetailLevel, ) -> Result, crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Rendering.MeshFoundry.GenerateSimpleSculptMesh(LibreMetaverse.Primitive,LibreMetaverse.Imaging.ManagedImage,LibreMetaverse.Rendering.DetailLevel)", - ) + self.native_generate_simple_sculpt_mesh(prim, sculpt_texture, lod) } /// C# member: `M:LibreMetaverse.Rendering.MeshFoundry.MeshSubMeshAsConvexHulls(LibreMetaverse.Primitive,System.Byte[])`. pub fn mesh_sub_mesh_as_convex_hulls_with_primitive_bytes( @@ -91,9 +81,7 @@ impl MeshFoundry { prim: libremetaverse::Primitive, compressed_mesh_data: Vec, ) -> Result>, crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Rendering.MeshFoundry.MeshSubMeshAsConvexHulls(LibreMetaverse.Primitive,System.Byte[])", - ) + self.native_mesh_sub_mesh_as_convex_hulls(prim, compressed_mesh_data, None) } /// C# member: `M:LibreMetaverse.Rendering.MeshFoundry.MeshSubMeshAsConvexHulls(LibreMetaverse.Primitive,System.Byte[],System.Collections.Generic.List{LibreMetaverse.Vector3}@)`. pub fn mesh_sub_mesh_as_convex_hulls_with_primitive_bytes_list( @@ -102,9 +90,7 @@ impl MeshFoundry { compressed_mesh_data: Vec, bounding_hull: &mut Vec, ) -> Result>, crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Rendering.MeshFoundry.MeshSubMeshAsConvexHulls(LibreMetaverse.Primitive,System.Byte[],System.Collections.Generic.List{LibreMetaverse.Vector3}@)", - ) + self.native_mesh_sub_mesh_as_convex_hulls(prim, compressed_mesh_data, Some(bounding_hull)) } /// C# member: `M:LibreMetaverse.Rendering.MeshFoundry.MeshSubMeshAsSimpleMesh(LibreMetaverse.Primitive,System.Byte[])`. pub fn mesh_sub_mesh_as_simple_mesh( @@ -112,9 +98,7 @@ impl MeshFoundry { prim: libremetaverse::Primitive, compressed_mesh_data: Vec, ) -> Result, crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Rendering.MeshFoundry.MeshSubMeshAsSimpleMesh(LibreMetaverse.Primitive,System.Byte[])", - ) + self.native_mesh_sub_mesh_as_simple_mesh(prim, compressed_mesh_data) } /// C# member: `M:LibreMetaverse.Rendering.MeshFoundry.TerrainMesh(System.Single[0:,0:],System.Single,System.Single,System.Single,System.Single)`. pub fn terrain_mesh( @@ -125,9 +109,7 @@ impl MeshFoundry { y_begin: f32, y_end: f32, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Rendering.MeshFoundry.TerrainMesh(System.Single[0:,0:],System.Single,System.Single,System.Single,System.Single)", - ) + self.native_terrain_mesh(z_map, x_begin, x_end, y_begin, y_end) } /// C# member: `M:LibreMetaverse.Rendering.MeshFoundry.TransformTexCoords(System.Collections.Generic.List{LibreMetaverse.Rendering.Vertex},LibreMetaverse.Vector3,LibreMetaverse.Primitive.TextureEntryFace,LibreMetaverse.Vector3)`. pub fn transform_tex_coords( @@ -137,18 +119,13 @@ impl MeshFoundry { te_face: libremetaverse::PrimitiveTextureEntryFace, prim_scale: libremetaverse_types::Vector3, ) -> Result<(), crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Rendering.MeshFoundry.TransformTexCoords(System.Collections.Generic.List{LibreMetaverse.Rendering.Vertex},LibreMetaverse.Vector3,LibreMetaverse.Primitive.TextureEntryFace,LibreMetaverse.Vector3)", - ) + self.native_transform_tex_coords(vertices, center, te_face, prim_scale) } /// C# member: `M:LibreMetaverse.Rendering.MeshFoundry.UnpackMesh(System.Byte[])`. pub fn unpack_mesh( &self, asset_data: Vec, ) -> Result, crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Rendering.MeshFoundry.UnpackMesh(System.Byte[])", - ) + self.native_unpack_mesh(asset_data).map(Some) } } -impl libremetaverse::rendering::IRendering for MeshFoundry {} diff --git a/crates/libremetaverse-rendering-mesh-foundry/src/lib.rs b/crates/libremetaverse-rendering-mesh-foundry/src/lib.rs index e5f331d..013ba63 100644 --- a/crates/libremetaverse-rendering-mesh-foundry/src/lib.rs +++ b/crates/libremetaverse-rendering-mesh-foundry/src/lib.rs @@ -1,8 +1,9 @@ -//! `MeshFoundry` renderer corresponding to `LibreMetaverse.Rendering.MeshFoundry`. +//! Bounded native prim, sculpt, terrain, and LLSD mesh-asset rendering. extern crate self as libremetaverse_rendering_mesh_foundry; mod generated; +mod mesh_foundry; pub use generated::*; pub use libremetaverse as core; @@ -11,3 +12,4 @@ pub use libremetaverse_prim_mesher as prim_mesher; pub use libremetaverse_structured_data as structured_data; pub use libremetaverse_types as types; pub use libremetaverse_types::Error; +pub use mesh_foundry::MeshFaceAux; diff --git a/crates/libremetaverse-rendering-mesh-foundry/src/mesh_foundry.rs b/crates/libremetaverse-rendering-mesh-foundry/src/mesh_foundry.rs new file mode 100644 index 0000000..74a0296 --- /dev/null +++ b/crates/libremetaverse-rendering-mesh-foundry/src/mesh_foundry.rs @@ -0,0 +1,1436 @@ +//! Native `MeshFoundry` implementation and bounded Second Life mesh decoding. + +#![allow(clippy::cast_possible_truncation)] // Quantized protocol values are checked first. +#![allow(clippy::missing_errors_doc)] // Public signatures mirror the mapped API. +#![allow(clippy::must_use_candidate)] // Attributes are not part of the mapped surface. +#![allow(clippy::needless_pass_by_value)] // Mapped value parameters are owned. +#![allow(clippy::unnecessary_wraps)] // Mapped constructors are fallible. +#![allow(clippy::unused_self)] // IRendering operations are instance methods. + +use std::collections::HashMap; +use std::io::Read as _; + +use flate2::read::ZlibDecoder; +use libremetaverse::rendering::{ + DetailLevel, Face, FaceMask, FacetedMesh, IRendering, MeshSkinData, SimpleMesh, Vertex, + VertexWeight, +}; +use libremetaverse::{Primitive, PrimitiveTextureEntryFace}; +use libremetaverse_imaging::ManagedImage; +use libremetaverse_prim_mesher::SculptMesh; +use libremetaverse_rendering_simple::SimpleRenderer; +use libremetaverse_structured_data::{OSD, OSDMap, deserialize_llsd_binary_prefix}; +use libremetaverse_types::compat::Object; +use libremetaverse_types::{Error, Vector2, Vector3, Vector4}; + +const MAX_ASSET_BYTES: usize = 64 * 1024 * 1024; +const MAX_SECTION_BYTES: usize = 64 * 1024 * 1024; +const MAX_SECTIONS: usize = 256; +const MAX_SUBMESHES: usize = 256; +const MAX_FACE_VERTICES: usize = 65_536; +const MAX_TOTAL_VERTICES: usize = 1_000_000; +const MAX_TOTAL_INDICES: usize = 6_000_000; +const MAX_JOINTS: usize = 512; +const MAX_HULLS: usize = 65_536; +const MAX_HULL_VERTICES: usize = 1_000_000; + +/// Mesh-only per-face domains that are not representable by the mapped `Face` +/// fields. Consumers can recover this value with `face.user_data.downcast_ref`. +#[derive(Debug)] +pub struct MeshFaceAux { + /// Tangent vectors in vertex order; `w` is bitangent handedness. + pub tangents: Vec, +} + +/// Full native renderer for prims, sculpts, terrain, and LLSD mesh assets. +pub struct MeshFoundry; + +impl MeshFoundry { + pub(crate) fn native_new() -> Result { + Ok(Self) + } + + pub(crate) fn native_generate_faceted_mesh( + &self, + prim: Primitive, + lod: DetailLevel, + ) -> Result, Error> { + SimpleRenderer::new()? + .generate_faceted_mesh(prim, lod) + .map(Some) + } + + pub(crate) fn native_generate_simple_mesh( + &self, + prim: Primitive, + lod: DetailLevel, + _with_normals: bool, + ) -> Result, Error> { + // The native pipeline always retains normals and UVs. This is a strict + // improvement over the legacy non-normal overload and keeps one stable + // vertex contract for downstream consumers. + SimpleRenderer::new()? + .generate_simple_mesh(prim, lod) + .map(Some) + } + + pub(crate) fn native_generate_faceted_sculpt_mesh( + &self, + prim: Primitive, + sculpt_texture: ManagedImage, + lod: DetailLevel, + ) -> Result, Error> { + SimpleRenderer::new()? + .generate_faceted_sculpt_mesh(prim, sculpt_texture, lod) + .map(Some) + } + + pub(crate) fn native_generate_simple_sculpt_mesh( + &self, + prim: Primitive, + sculpt_texture: ManagedImage, + lod: DetailLevel, + ) -> Result, Error> { + SimpleRenderer::new()? + .generate_simple_sculpt_mesh(prim, sculpt_texture, lod) + .map(Some) + } + + pub(crate) fn native_transform_tex_coords( + &self, + vertices: &mut Vec, + center: Vector3, + te_face: PrimitiveTextureEntryFace, + prim_scale: Vector3, + ) -> Result<(), Error> { + SimpleRenderer::new()?.transform_tex_coords(vertices, center, te_face, prim_scale) + } + + pub(crate) fn native_generate_faceted_mesh_mesh( + &self, + prim: Primitive, + mesh_data: Vec, + requested_lod: DetailLevel, + ) -> Result, Error> { + let source = prim.id; + let unpacked = unpack_mesh_map(&mesh_data, source)?; + let (lod, faces) = select_lod(&unpacked, requested_lod) + .ok_or_else(|| render_error(source, "mesh asset has no visual LOD"))?; + let skin = unpacked + .get("skin") + .map(|value| decode_skin(value, source)) + .transpose()?; + let mut mesh = decode_faceted(&prim, faces, skin, lod, source)?; + apply_sculpt_modifiers(&prim, &mut mesh)?; + Ok(Some(mesh)) + } + + pub(crate) fn native_unpack_mesh(&self, asset_data: Vec) -> Result { + let decoded = unpack_mesh_map(&asset_data, libremetaverse_types::UUID::zero())?; + OSDMap::new_with_dictionary(decoded) + } + + pub(crate) fn native_mesh_sub_mesh_as_simple_mesh( + &self, + prim: Primitive, + compressed_mesh_data: Vec, + ) -> Result, Error> { + let source = prim.id; + let value = decompress_osd(&compressed_mesh_data, source)?; + let OSD::Array(submeshes) = value else { + return Err(render_error(source, "mesh section is not a submesh array")); + }; + if submeshes.len() > MAX_SUBMESHES { + return Err(render_error(source, "mesh submesh count exceeds limit")); + } + let mut faces = Vec::new(); + let mut total_vertices = 0usize; + let mut total_indices = 0usize; + for (index, submesh) in submeshes.iter().enumerate() { + if let Some(face) = decode_submesh(&prim, submesh, index, None, source)? { + total_vertices = checked_total( + total_vertices, + face.vertices.len(), + MAX_TOTAL_VERTICES, + source, + "mesh vertex budget exceeded", + )?; + total_indices = checked_total( + total_indices, + face.indices.len(), + MAX_TOTAL_INDICES, + source, + "mesh index budget exceeded", + )?; + faces.push(face); + } + } + flatten_faces(faces, source).map(Some) + } + + pub(crate) fn native_mesh_sub_mesh_as_convex_hulls( + &self, + prim: Primitive, + compressed_mesh_data: Vec, + bounding_hull: Option<&mut Vec>, + ) -> Result>, Error> { + let source = prim.id; + let value = decompress_osd(&compressed_mesh_data, source)?; + let map = as_map(&value) + .ok_or_else(|| render_error(source, "physics convex section is not a map"))?; + let min = map + .get("Min") + .map(|value| osd_vector3(value, source, "invalid convex minimum")) + .transpose()? + .unwrap_or(Vector3 { + x: -0.5, + y: -0.5, + z: -0.5, + }); + let max = map + .get("Max") + .map(|value| osd_vector3(value, source, "invalid convex maximum")) + .transpose()? + .unwrap_or(Vector3 { + x: 0.5, + y: 0.5, + z: 0.5, + }); + validate_domain3(min, max, source, "invalid convex position domain")?; + + let decoded_bounding = map + .get("BoundingVerts") + .and_then(osd_binary) + .map(|bytes| decode_quantized_positions(bytes, min, max, source)) + .transpose()?; + if let Some(output) = bounding_hull { + output.clear(); + if let Some(vertices) = decoded_bounding { + *output = vertices; + } + } + + let Some(counts) = map.get("HullList").and_then(osd_binary) else { + return Ok(Vec::new()); + }; + let positions = map + .get("Positions") + .and_then(osd_binary) + .ok_or_else(|| render_error(source, "convex hull positions are missing"))?; + if counts.len() > MAX_HULLS { + return Err(render_error(source, "convex hull count exceeds limit")); + } + let mut cursor = 0usize; + let mut total = 0usize; + let mut hulls = Vec::with_capacity(counts.len()); + for encoded_count in counts { + let count = if *encoded_count == 0 { + 256usize + } else { + usize::from(*encoded_count) + }; + total = checked_total( + total, + count, + MAX_HULL_VERTICES, + source, + "convex hull vertex budget exceeded", + )?; + let byte_count = count + .checked_mul(6) + .ok_or_else(|| render_error(source, "convex hull byte count overflow"))?; + let end = cursor + .checked_add(byte_count) + .ok_or_else(|| render_error(source, "convex hull offset overflow"))?; + let bytes = positions + .get(cursor..end) + .ok_or_else(|| render_error(source, "truncated convex hull positions"))?; + hulls.push(decode_quantized_positions(bytes, min, max, source)?); + cursor = end; + } + if cursor != positions.len() { + return Err(render_error(source, "trailing convex hull positions")); + } + Ok(hulls) + } + + pub(crate) fn native_terrain_mesh( + &self, + z_map: Vec>, + x_begin: f32, + x_end: f32, + y_begin: f32, + y_end: f32, + ) -> Result { + let source = libremetaverse_types::UUID::zero(); + let mesh = SculptMesh::from_height_rows(z_map, x_begin, x_end, y_begin, y_end, true) + .map_err(|_| render_error(source, "generate bounded terrain geometry"))?; + if mesh.coords.len() > MAX_FACE_VERTICES + || mesh.coords.len() != mesh.normals.len() + || mesh.coords.len() != mesh.uvs.len() + { + return Err(render_error(source, "invalid terrain vertex domains")); + } + let vertices = mesh + .coords + .iter() + .zip(&mesh.normals) + .zip(&mesh.uvs) + .map(|((position, normal), uv)| { + checked_vertex( + Vector3 { + x: position.x, + y: position.y, + z: position.z, + }, + Vector3 { + x: normal.x, + y: normal.y, + z: normal.z, + }, + Vector2 { x: uv.u, y: uv.v }, + source, + ) + }) + .collect::, _>>()?; + let mut indices = Vec::with_capacity( + mesh.faces + .len() + .checked_mul(3) + .ok_or_else(|| render_error(source, "terrain index count overflow"))?, + ); + for face in mesh.faces { + push_triangle( + &mut indices, + face.v1, + face.v2, + face.v3, + vertices.len(), + source, + )?; + } + make_face( + 0, + vertices, + indices, + PrimitiveTextureEntryFace::default(), + Object::Undefined, + Vector3::one(), + source, + ) + } +} + +impl IRendering for MeshFoundry { + fn generate_faceted_mesh( + &self, + prim: Primitive, + lod: DetailLevel, + ) -> Result, Error> { + self.native_generate_faceted_mesh(prim, lod) + } + + fn generate_faceted_sculpt_mesh( + &self, + prim: Primitive, + sculpt_texture: ManagedImage, + lod: DetailLevel, + ) -> Result, Error> { + self.native_generate_faceted_sculpt_mesh(prim, sculpt_texture, lod) + } + + fn generate_simple_mesh( + &self, + prim: Primitive, + lod: DetailLevel, + ) -> Result, Error> { + self.native_generate_simple_mesh(prim, lod, true) + } + + fn generate_simple_sculpt_mesh( + &self, + prim: Primitive, + sculpt_texture: ManagedImage, + lod: DetailLevel, + ) -> Result, Error> { + self.native_generate_simple_sculpt_mesh(prim, sculpt_texture, lod) + } + + fn transform_tex_coords( + &self, + vertices: &mut Vec, + center: Vector3, + te_face: PrimitiveTextureEntryFace, + prim_scale: Vector3, + ) -> Result<(), Error> { + self.native_transform_tex_coords(vertices, center, te_face, prim_scale) + } +} + +fn unpack_mesh_map( + asset_data: &[u8], + source: libremetaverse_types::UUID, +) -> Result, Error> { + if asset_data.is_empty() || asset_data.len() > MAX_ASSET_BYTES { + return Err(render_error(source, "mesh asset size is out of range")); + } + let (header_value, payload_start) = deserialize_llsd_binary_prefix(asset_data) + .map_err(|_| render_error(source, "decode mesh asset header"))?; + let OSD::Map(header) = header_value else { + return Err(render_error(source, "mesh asset header is not a map")); + }; + if header.len() > MAX_SECTIONS { + return Err(render_error(source, "mesh section count exceeds limit")); + } + let mut output = HashMap::with_capacity(header.len() + 1); + output.insert("asset_header".to_owned(), OSD::Map(header.clone())); + for (name, value) in header { + let OSD::Map(info) = &value else { + output.insert(name, value); + continue; + }; + let (Some(offset), Some(size)) = ( + info.get("offset").and_then(osd_integer), + info.get("size").and_then(osd_integer), + ) else { + output.insert(name, value); + continue; + }; + if offset < 0 || size == 0 { + output.insert(name, value); + continue; + } + let offset = usize::try_from(offset) + .map_err(|_| render_error(source, "mesh section offset is invalid"))?; + let size = usize::try_from(size) + .map_err(|_| render_error(source, "mesh section size is invalid"))?; + if size > MAX_SECTION_BYTES { + return Err(render_error( + source, + "compressed mesh section exceeds limit", + )); + } + let begin = payload_start + .checked_add(offset) + .ok_or_else(|| render_error(source, "mesh section offset overflow"))?; + let end = begin + .checked_add(size) + .ok_or_else(|| render_error(source, "mesh section size overflow"))?; + let section = asset_data + .get(begin..end) + .ok_or_else(|| render_error(source, "mesh section is outside the asset"))?; + output.insert(name, decompress_osd(section, source)?); + } + Ok(output) +} + +fn decompress_osd(compressed: &[u8], source: libremetaverse_types::UUID) -> Result { + if compressed.is_empty() || compressed.len() > MAX_SECTION_BYTES { + return Err(render_error( + source, + "compressed mesh section size is out of range", + )); + } + let mut zlib = ZlibDecoder::new(compressed); + let mut output = Vec::new(); + (&mut zlib) + .take((MAX_SECTION_BYTES + 1) as u64) + .read_to_end(&mut output) + .map_err(|_| render_error(source, "decompress mesh section"))?; + if output.len() > MAX_SECTION_BYTES { + return Err(render_error( + source, + "decompressed mesh section exceeds limit", + )); + } + let (value, _) = deserialize_llsd_binary_prefix(&output) + .map_err(|_| render_error(source, "decode mesh section LLSD"))?; + Ok(value) +} + +fn select_lod( + unpacked: &HashMap, + requested: DetailLevel, +) -> Option<(DetailLevel, &[OSD])> { + let order = [ + requested, + DetailLevel::Highest, + DetailLevel::High, + DetailLevel::Medium, + DetailLevel::Low, + ]; + for (index, candidate) in order.into_iter().enumerate() { + if index > 0 && candidate == requested { + continue; + } + let key = lod_key(candidate); + if let Some(OSD::Array(values)) = unpacked.get(key) + && !values.is_empty() + { + return Some((candidate, values)); + } + } + None +} + +const fn lod_key(lod: DetailLevel) -> &'static str { + match lod { + DetailLevel::Highest => "high_lod", + DetailLevel::High => "medium_lod", + DetailLevel::Medium => "low_lod", + DetailLevel::Low => "lowest_lod", + } +} + +fn decode_faceted( + prim: &Primitive, + submeshes: &[OSD], + skin_data: Option, + _lod: DetailLevel, + source: libremetaverse_types::UUID, +) -> Result { + if submeshes.len() > MAX_SUBMESHES { + return Err(render_error(source, "mesh submesh count exceeds limit")); + } + let mut faces = Vec::with_capacity(submeshes.len()); + let mut total_vertices = 0usize; + let mut total_indices = 0usize; + for (index, submesh) in submeshes.iter().enumerate() { + if let Some(face) = decode_submesh(prim, submesh, index, skin_data.as_ref(), source)? { + total_vertices = checked_total( + total_vertices, + face.vertices.len(), + MAX_TOTAL_VERTICES, + source, + "mesh vertex budget exceeded", + )?; + total_indices = checked_total( + total_indices, + face.indices.len(), + MAX_TOTAL_INDICES, + source, + "mesh index budget exceeded", + )?; + faces.push(face); + } + } + Ok(FacetedMesh { faces, skin_data }) +} + +#[allow(clippy::too_many_lines)] // One ordered pass validates parallel mesh domains. +fn decode_submesh( + prim: &Primitive, + value: &OSD, + face_index: usize, + skin: Option<&MeshSkinData>, + source: libremetaverse_types::UUID, +) -> Result, Error> { + let map = as_map(value).ok_or_else(|| render_error(source, "mesh submesh is not a map"))?; + if map.get("NoGeometry").and_then(osd_boolean).unwrap_or(false) { + return Ok(None); + } + let positions = map + .get("Position") + .and_then(osd_binary) + .ok_or_else(|| render_error(source, "mesh positions are missing"))?; + if positions.is_empty() || !positions.len().is_multiple_of(6) { + return Err(render_error(source, "mesh position buffer is malformed")); + } + let vertex_count = positions.len() / 6; + if vertex_count > MAX_FACE_VERTICES { + return Err(render_error( + source, + "mesh face exceeds 16-bit vertex range", + )); + } + let (position_min, position_max) = domain3( + map.get("PositionDomain"), + Vector3 { + x: -0.5, + y: -0.5, + z: -0.5, + }, + Vector3 { + x: 0.5, + y: 0.5, + z: 0.5, + }, + source, + "invalid mesh position domain", + )?; + let normals = map.get("Normal").and_then(osd_binary); + if normals.is_some_and(|bytes| bytes.len() != vertex_count * 6) { + return Err(render_error(source, "mesh normal buffer is misaligned")); + } + let tex_coords = map.get("TexCoord0").and_then(osd_binary); + if tex_coords.is_some_and(|bytes| bytes.len() != vertex_count * 4) { + return Err(render_error(source, "mesh primary UV buffer is misaligned")); + } + let (primary_min, primary_max) = if tex_coords.is_some() { + domain2( + Some( + map.get("TexCoord0Domain") + .ok_or_else(|| render_error(source, "primary UV domain is missing"))?, + ), + Vector2::zero(), + Vector2::zero(), + source, + "invalid primary UV domain", + )? + } else { + (Vector2::zero(), Vector2::zero()) + }; + let tex_coords1 = map.get("TexCoord1").and_then(osd_binary); + if tex_coords1.is_some_and(|bytes| bytes.len() != vertex_count * 4) { + return Err(render_error( + source, + "mesh secondary UV buffer is misaligned", + )); + } + let (secondary_min, secondary_max) = if tex_coords1.is_some() { + domain2( + map.get("TexCoord1Domain"), + Vector2::zero(), + Vector2::zero(), + source, + "invalid secondary UV domain", + )? + } else { + (Vector2::zero(), Vector2::zero()) + }; + let tangent_bytes = map.get("Tangent").and_then(osd_binary); + if tangent_bytes.is_some_and(|bytes| bytes.len() != vertex_count * 8) { + return Err(render_error(source, "mesh tangent buffer is misaligned")); + } + + let mut vertices = Vec::with_capacity(vertex_count); + let mut secondary = tex_coords1.map(|_| Vec::with_capacity(vertex_count)); + for index in 0..vertex_count { + let p = index * 6; + let t = index * 4; + let position = Vector3 { + x: dequantize( + read_u16(positions, p, source)?, + position_min.x, + position_max.x, + ), + y: dequantize( + read_u16(positions, p + 2, source)?, + position_min.y, + position_max.y, + ), + z: dequantize( + read_u16(positions, p + 4, source)?, + position_min.z, + position_max.z, + ), + }; + let normal = if let Some(bytes) = normals { + normalize3( + Vector3 { + x: dequantize(read_u16(bytes, p, source)?, -1.0, 1.0), + y: dequantize(read_u16(bytes, p + 2, source)?, -1.0, 1.0), + z: dequantize(read_u16(bytes, p + 4, source)?, -1.0, 1.0), + }, + Vector3::unit_z(), + ) + } else { + Vector3::zero() + }; + let tex_coord = if let Some(bytes) = tex_coords { + Vector2 { + x: dequantize(read_u16(bytes, t, source)?, primary_min.x, primary_max.x), + y: dequantize( + read_u16(bytes, t + 2, source)?, + primary_min.y, + primary_max.y, + ), + } + } else { + Vector2::zero() + }; + vertices.push(checked_vertex(position, normal, tex_coord, source)?); + if let (Some(bytes), Some(output)) = (tex_coords1, secondary.as_mut()) { + output.push(Vector2 { + x: dequantize( + read_u16(bytes, t, source)?, + secondary_min.x, + secondary_max.x, + ), + y: dequantize( + read_u16(bytes, t + 2, source)?, + secondary_min.y, + secondary_max.y, + ), + }); + } + } + + let triangles = map + .get("TriangleList") + .and_then(osd_binary) + .ok_or_else(|| render_error(source, "mesh triangle list is missing"))?; + if !triangles.len().is_multiple_of(6) || triangles.len() / 2 > MAX_TOTAL_INDICES { + return Err(render_error(source, "mesh triangle buffer is malformed")); + } + let mut indices = Vec::with_capacity(triangles.len() / 2); + for offset in (0..triangles.len()).step_by(6) { + let a = read_u16(triangles, offset, source)?; + let b = read_u16(triangles, offset + 2, source)?; + let c = read_u16(triangles, offset + 4, source)?; + for index in [a, b, c] { + if usize::from(index) >= vertex_count { + return Err(render_error(source, "mesh triangle index is out of range")); + } + indices.push(index); + } + } + if normals.is_none() { + generate_normals(&mut vertices, &indices); + } + let tangents = if let Some(bytes) = tangent_bytes { + decode_tangents(bytes, vertex_count, source)? + } else { + generate_tangents(&vertices, &indices) + }; + let weights = match (skin, map.get("Weights").and_then(osd_binary)) { + (Some(skin), Some(bytes)) => Some(decode_weights( + bytes, + skin.joint_names.len(), + vertex_count, + source, + )?), + (None, Some(_)) => { + return Err(render_error(source, "mesh weights have no skin section")); + } + _ => None, + }; + let normalized_scale = map + .get("NormalizedScale") + .map(|value| osd_vector3(value, source, "invalid normalized face scale")) + .transpose()? + .unwrap_or(Vector3::one()); + let texture = texture_for_face(prim, face_index, source)?; + let mut face = make_face( + i32::try_from(face_index).map_err(|_| render_error(source, "mesh face id overflow"))?, + vertices, + indices, + texture, + Object::opaque(MeshFaceAux { tangents }), + normalized_scale, + source, + )?; + face.tex_coords1 = secondary; + face.weights = weights; + Ok(Some(face)) +} + +fn decode_skin(value: &OSD, source: libremetaverse_types::UUID) -> Result { + let map = + as_map(value).ok_or_else(|| render_error(source, "mesh skin section is not a map"))?; + let names = match map.get("joint_names") { + Some(OSD::Array(values)) if values.len() <= MAX_JOINTS => values + .iter() + .map(|value| { + value + .as_string() + .map_err(|_| render_error(source, "invalid skin joint name")) + }) + .collect::, _>>()?, + Some(OSD::Array(_)) => return Err(render_error(source, "skin joint count exceeds limit")), + Some(_) => return Err(render_error(source, "skin joint names are not an array")), + None => Vec::new(), + }; + let mut data = MeshSkinData::new()?; + data.joint_names = names; + data.inverse_bind_matrices = decode_matrix_array( + map.get("inverse_bind_matrix"), + data.joint_names.len(), + false, + source, + "invalid inverse bind matrices", + )?; + data.alt_inverse_bind_matrices = decode_matrix_array( + map.get("alt_inverse_bind_matrix"), + data.joint_names.len(), + true, + source, + "invalid alternate inverse bind matrices", + )?; + data.bind_shape_matrix = match map.get("bind_shape_matrix") { + Some(value) => decode_matrix(value, source, "invalid bind shape matrix")?, + None => identity_matrix().to_vec(), + }; + data.pelvis_offset = match map.get("pelvis_offset") { + Some(value) => { + let value = value + .as_real() + .map_err(|_| render_error(source, "invalid pelvis offset"))? + as f32; + if !value.is_finite() { + return Err(render_error(source, "non-finite pelvis offset")); + } + value + } + None => 0.0, + }; + data.lock_scale_if_joint_position = map + .get("lock_scale_if_joint_position") + .map(OSD::as_boolean) + .transpose() + .map_err(|_| render_error(source, "invalid skin scale lock"))? + .unwrap_or(false); + Ok(data) +} + +fn decode_matrix_array( + value: Option<&OSD>, + joint_count: usize, + optional: bool, + source: libremetaverse_types::UUID, + context: &'static str, +) -> Result, Error> { + let Some(value) = value else { + if optional { + return Ok(Vec::new()); + } + return Ok((0..joint_count).flat_map(|_| identity_matrix()).collect()); + }; + let OSD::Array(matrices) = value else { + return Err(render_error(source, context)); + }; + if matrices.len() != joint_count { + return Err(render_error(source, context)); + } + let mut output = Vec::with_capacity(joint_count * 16); + for matrix in matrices { + output.extend(decode_matrix(matrix, source, context)?); + } + Ok(output) +} + +fn decode_matrix( + value: &OSD, + source: libremetaverse_types::UUID, + context: &'static str, +) -> Result, Error> { + let OSD::Array(values) = value else { + return Err(render_error(source, context)); + }; + if values.len() != 16 { + return Err(render_error(source, context)); + } + values + .iter() + .map(|value| { + let value = value.as_real().map_err(|_| render_error(source, context))? as f32; + if value.is_finite() { + Ok(value) + } else { + Err(render_error(source, context)) + } + }) + .collect() +} + +fn decode_weights( + bytes: &[u8], + joint_count: usize, + vertex_count: usize, + source: libremetaverse_types::UUID, +) -> Result, Error> { + if joint_count == 0 || bytes.len() > vertex_count.saturating_mul(13) { + return Err(render_error(source, "mesh skin weights are out of range")); + } + let mut cursor = 0usize; + let mut output = Vec::with_capacity(vertex_count); + for _ in 0..vertex_count { + if cursor >= bytes.len() { + output.push(default_weight()); + continue; + } + let mut joints = [0_i32; 4]; + let mut weights = [0.0_f32; 4]; + let mut count = 0usize; + while cursor < bytes.len() && count < 4 { + let joint = bytes[cursor]; + cursor += 1; + if joint == 0xff { + break; + } + if usize::from(joint) >= joint_count { + return Err(render_error( + source, + "mesh skin joint index is out of range", + )); + } + let end = cursor + .checked_add(2) + .ok_or_else(|| render_error(source, "mesh weight offset overflow"))?; + let raw = bytes + .get(cursor..end) + .ok_or_else(|| render_error(source, "truncated mesh skin weight"))?; + cursor = end; + joints[count] = i32::from(joint); + weights[count] = + (f32::from(u16::from_le_bytes([raw[0], raw[1]])) / 65_535.0).clamp(0.001, 0.999); + count += 1; + } + let total: f32 = weights.iter().sum(); + if total > 0.0 { + for weight in &mut weights { + *weight /= total; + } + } else { + weights[0] = 1.0; + } + output.push(VertexWeight { + joint0: joints[0], + joint1: joints[1], + joint2: joints[2], + joint3: joints[3], + weight0: weights[0], + weight1: weights[1], + weight2: weights[2], + weight3: weights[3], + }); + } + if cursor != bytes.len() { + return Err(render_error(source, "trailing mesh skin weights")); + } + Ok(output) +} + +const fn default_weight() -> VertexWeight { + VertexWeight { + joint0: 0, + joint1: 0, + joint2: 0, + joint3: 0, + weight0: 1.0, + weight1: 0.0, + weight2: 0.0, + weight3: 0.0, + } +} + +fn decode_tangents( + bytes: &[u8], + vertex_count: usize, + source: libremetaverse_types::UUID, +) -> Result, Error> { + let mut tangents = Vec::with_capacity(vertex_count); + for index in 0..vertex_count { + let offset = index * 8; + let tangent = normalize3( + Vector3 { + x: dequantize(read_u16(bytes, offset, source)?, -1.0, 1.0), + y: dequantize(read_u16(bytes, offset + 2, source)?, -1.0, 1.0), + z: dequantize(read_u16(bytes, offset + 4, source)?, -1.0, 1.0), + }, + Vector3::unit_x(), + ); + let handedness = dequantize(read_u16(bytes, offset + 6, source)?, -1.0, 1.0); + tangents.push(Vector4 { + x: tangent.x, + y: tangent.y, + z: tangent.z, + w: if handedness < 0.0 { -1.0 } else { 1.0 }, + }); + } + Ok(tangents) +} + +fn generate_normals(vertices: &mut [Vertex], indices: &[u16]) { + let mut normals = vec![Vector3::zero(); vertices.len()]; + for triangle in indices.chunks_exact(3) { + let a = vertices[usize::from(triangle[0])].position; + let b = vertices[usize::from(triangle[1])].position; + let c = vertices[usize::from(triangle[2])].position; + let normal = cross(sub3(b, a), sub3(c, a)); + for index in triangle { + normals[usize::from(*index)] = add3(normals[usize::from(*index)], normal); + } + } + for (vertex, normal) in vertices.iter_mut().zip(normals) { + vertex.normal = normalize3(normal, Vector3::unit_z()); + } +} + +fn generate_tangents(vertices: &[Vertex], indices: &[u16]) -> Vec { + let mut tangent_sum = vec![Vector3::zero(); vertices.len()]; + let mut bitangent_sum = vec![Vector3::zero(); vertices.len()]; + for triangle in indices.chunks_exact(3) { + let ia = usize::from(triangle[0]); + let ib = usize::from(triangle[1]); + let ic = usize::from(triangle[2]); + let edge1 = sub3(vertices[ib].position, vertices[ia].position); + let edge2 = sub3(vertices[ic].position, vertices[ia].position); + let duv1 = sub2(vertices[ib].tex_coord, vertices[ia].tex_coord); + let duv2 = sub2(vertices[ic].tex_coord, vertices[ia].tex_coord); + let determinant = duv1.x * duv2.y - duv1.y * duv2.x; + if determinant.abs() <= f32::EPSILON || !determinant.is_finite() { + continue; + } + let inverse = 1.0 / determinant; + let tangent = scale3(sub3(scale3(edge1, duv2.y), scale3(edge2, duv1.y)), inverse); + let bitangent = scale3(sub3(scale3(edge2, duv1.x), scale3(edge1, duv2.x)), inverse); + for index in [ia, ib, ic] { + tangent_sum[index] = add3(tangent_sum[index], tangent); + bitangent_sum[index] = add3(bitangent_sum[index], bitangent); + } + } + vertices + .iter() + .enumerate() + .map(|(index, vertex)| { + let projected = sub3( + tangent_sum[index], + scale3(vertex.normal, dot(vertex.normal, tangent_sum[index])), + ); + let tangent = normalize3(projected, orthogonal_tangent(vertex.normal)); + let handedness = if dot(cross(vertex.normal, tangent), bitangent_sum[index]) < 0.0 { + -1.0 + } else { + 1.0 + }; + Vector4 { + x: tangent.x, + y: tangent.y, + z: tangent.z, + w: handedness, + } + }) + .collect() +} + +fn apply_sculpt_modifiers(prim: &Primitive, mesh: &mut FacetedMesh) -> Result<(), Error> { + let Some(sculpt) = prim.sculpt.as_ref() else { + return Ok(()); + }; + let reflect = sculpt.mirror(); + let invert = sculpt.invert(); + let reverse = reflect ^ invert; + if !reflect && !invert { + return Ok(()); + } + for face in &mut mesh.faces { + for vertex in &mut face.vertices { + if reflect { + vertex.position.x = -vertex.position.x; + vertex.normal.x = -vertex.normal.x; + } + if invert { + vertex.normal = scale3(vertex.normal, -1.0); + } + } + if let Some(aux) = face.user_data.downcast_ref::() { + let tangents = aux + .tangents + .iter() + .map(|tangent| Vector4 { + x: if reflect { -tangent.x } else { tangent.x }, + y: tangent.y, + z: tangent.z, + w: if reverse { -tangent.w } else { tangent.w }, + }) + .collect(); + face.user_data = Object::opaque(MeshFaceAux { tangents }); + } + if reverse { + for triangle in face.indices.chunks_exact_mut(3) { + triangle.swap(1, 2); + } + } + update_face_bounds(face)?; + } + Ok(()) +} + +fn flatten_faces( + faces: Vec, + source: libremetaverse_types::UUID, +) -> Result { + let mut vertices = Vec::new(); + let mut indices = Vec::new(); + for face in faces { + let base = vertices.len(); + let combined = base + .checked_add(face.vertices.len()) + .ok_or_else(|| render_error(source, "simple mesh vertex count overflow"))?; + if combined > MAX_FACE_VERTICES { + return Err(render_error( + source, + "simple mesh exceeds 16-bit vertex range", + )); + } + for index in face.indices { + let global = base + .checked_add(usize::from(index)) + .ok_or_else(|| render_error(source, "simple mesh index overflow"))?; + if global >= combined { + return Err(render_error(source, "simple mesh index is out of range")); + } + indices.push( + u16::try_from(global) + .map_err(|_| render_error(source, "simple mesh index exceeds 16 bits"))?, + ); + } + vertices.extend(face.vertices); + } + Ok(SimpleMesh { vertices, indices }) +} + +fn make_face( + id: i32, + vertices: Vec, + indices: Vec, + texture_face: PrimitiveTextureEntryFace, + user_data: Object, + normalized_scale: Vector3, + source: libremetaverse_types::UUID, +) -> Result { + if vertices.is_empty() { + return Err(render_error(source, "render face has no vertices")); + } + let mut face = Face { + begin_s: 0, + begin_t: 0, + center: Vector3::zero(), + edge: Vec::new(), + id, + indices, + mask: FaceMask::SINGLE, + max_extent: Vector3::zero(), + min_extent: Vector3::zero(), + normalized_scale, + num_s: 0, + num_t: 0, + tex_coords1: None, + texture_face, + user_data, + vertices, + weights: None, + }; + update_face_bounds(&mut face)?; + Ok(face) +} + +fn update_face_bounds(face: &mut Face) -> Result<(), Error> { + let Some(first) = face.vertices.first() else { + return Err(Error::Argument); + }; + let mut min = first.position; + let mut max = first.position; + for vertex in &face.vertices[1..] { + min.x = min.x.min(vertex.position.x); + min.y = min.y.min(vertex.position.y); + min.z = min.z.min(vertex.position.z); + max.x = max.x.max(vertex.position.x); + max.y = max.y.max(vertex.position.y); + max.z = max.z.max(vertex.position.z); + } + face.min_extent = min; + face.max_extent = max; + face.center = Vector3 { + x: (min.x + max.x) * 0.5, + y: (min.y + max.y) * 0.5, + z: (min.z + max.z) * 0.5, + }; + Ok(()) +} + +fn texture_for_face( + prim: &Primitive, + index: usize, + source: libremetaverse_types::UUID, +) -> Result { + let Some(textures) = prim.textures.as_ref() else { + return Ok(PrimitiveTextureEntryFace::default()); + }; + let index = + u32::try_from(index).map_err(|_| render_error(source, "texture face index overflow"))?; + textures + .get_face(index) + .map_err(|_| render_error(source, "texture face index is out of range")) + .map(|face| face.cloned().unwrap_or_default()) +} + +fn checked_vertex( + position: Vector3, + normal: Vector3, + tex_coord: Vector2, + source: libremetaverse_types::UUID, +) -> Result { + if !finite3(position) || !finite3(normal) || !finite2(tex_coord) { + return Err(render_error( + source, + "renderer produced a non-finite vertex", + )); + } + Ok(Vertex { + position, + normal, + tex_coord, + }) +} + +fn push_triangle( + indices: &mut Vec, + a: i32, + b: i32, + c: i32, + vertex_count: usize, + source: libremetaverse_types::UUID, +) -> Result<(), Error> { + for index in [a, b, c] { + let index = + usize::try_from(index).map_err(|_| render_error(source, "negative renderer index"))?; + if index >= vertex_count { + return Err(render_error(source, "renderer index is out of range")); + } + indices.push( + u16::try_from(index) + .map_err(|_| render_error(source, "renderer index exceeds 16 bits"))?, + ); + } + Ok(()) +} + +fn domain3( + value: Option<&OSD>, + default_min: Vector3, + default_max: Vector3, + source: libremetaverse_types::UUID, + context: &'static str, +) -> Result<(Vector3, Vector3), Error> { + let Some(value) = value else { + return Ok((default_min, default_max)); + }; + let map = as_map(value).ok_or_else(|| render_error(source, context))?; + let min = map + .get("Min") + .ok_or_else(|| render_error(source, context)) + .and_then(|value| osd_vector3(value, source, context))?; + let max = map + .get("Max") + .ok_or_else(|| render_error(source, context)) + .and_then(|value| osd_vector3(value, source, context))?; + validate_domain3(min, max, source, context)?; + Ok((min, max)) +} + +fn domain2( + value: Option<&OSD>, + default_min: Vector2, + default_max: Vector2, + source: libremetaverse_types::UUID, + context: &'static str, +) -> Result<(Vector2, Vector2), Error> { + let Some(value) = value else { + return Ok((default_min, default_max)); + }; + let map = as_map(value).ok_or_else(|| render_error(source, context))?; + let min = map + .get("Min") + .ok_or_else(|| render_error(source, context))? + .as_vector2() + .map_err(|_| render_error(source, context))?; + let max = map + .get("Max") + .ok_or_else(|| render_error(source, context))? + .as_vector2() + .map_err(|_| render_error(source, context))?; + if !finite2(min) || !finite2(max) || min.x > max.x || min.y > max.y { + return Err(render_error(source, context)); + } + Ok((min, max)) +} + +fn validate_domain3( + min: Vector3, + max: Vector3, + source: libremetaverse_types::UUID, + context: &'static str, +) -> Result<(), Error> { + if !finite3(min) || !finite3(max) || min.x > max.x || min.y > max.y || min.z > max.z { + Err(render_error(source, context)) + } else { + Ok(()) + } +} + +fn decode_quantized_positions( + bytes: &[u8], + min: Vector3, + max: Vector3, + source: libremetaverse_types::UUID, +) -> Result, Error> { + if !bytes.len().is_multiple_of(6) || bytes.len() / 6 > MAX_HULL_VERTICES { + return Err(render_error(source, "convex position buffer is malformed")); + } + (0..bytes.len()) + .step_by(6) + .map(|offset| { + Ok(Vector3 { + x: dequantize(read_u16(bytes, offset, source)?, min.x, max.x), + y: dequantize(read_u16(bytes, offset + 2, source)?, min.y, max.y), + z: dequantize(read_u16(bytes, offset + 4, source)?, min.z, max.z), + }) + }) + .collect() +} + +fn read_u16(bytes: &[u8], offset: usize, source: libremetaverse_types::UUID) -> Result { + let bytes = bytes + .get(offset..offset + 2) + .ok_or_else(|| render_error(source, "truncated quantized mesh value"))?; + Ok(u16::from_le_bytes([bytes[0], bytes[1]])) +} + +fn checked_total( + current: usize, + additional: usize, + limit: usize, + source: libremetaverse_types::UUID, + context: &'static str, +) -> Result { + let total = current + .checked_add(additional) + .ok_or_else(|| render_error(source, context))?; + if total > limit { + Err(render_error(source, context)) + } else { + Ok(total) + } +} + +fn osd_vector3( + value: &OSD, + source: libremetaverse_types::UUID, + context: &'static str, +) -> Result { + let value = value + .as_vector3() + .map_err(|_| render_error(source, context))?; + if finite3(value) { + Ok(value) + } else { + Err(render_error(source, context)) + } +} + +const fn as_map(value: &OSD) -> Option<&HashMap> { + if let OSD::Map(map) = value { + Some(map) + } else { + None + } +} + +const fn osd_binary(value: &OSD) -> Option<&Vec> { + if let OSD::Binary(bytes) = value { + Some(bytes) + } else { + None + } +} + +const fn osd_integer(value: &OSD) -> Option { + if let OSD::Integer(value) = value { + Some(*value) + } else { + None + } +} + +const fn osd_boolean(value: &OSD) -> Option { + if let OSD::Boolean(value) = value { + Some(*value) + } else { + None + } +} + +const fn dequantize(value: u16, min: f32, max: f32) -> f32 { + min + (value as f32 / 65_535.0) * (max - min) +} + +fn normalize3(value: Vector3, fallback: Vector3) -> Vector3 { + let length_squared = dot(value, value); + if length_squared > f32::EPSILON && length_squared.is_finite() { + scale3(value, length_squared.sqrt().recip()) + } else { + fallback + } +} + +fn orthogonal_tangent(normal: Vector3) -> Vector3 { + let axis = if normal.z.abs() < 0.9 { + Vector3::unit_z() + } else { + Vector3::unit_y() + }; + normalize3(cross(axis, normal), Vector3::unit_x()) +} + +const fn identity_matrix() -> [f32; 16] { + [ + 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, + ] +} + +const fn add3(left: Vector3, right: Vector3) -> Vector3 { + Vector3 { + x: left.x + right.x, + y: left.y + right.y, + z: left.z + right.z, + } +} + +const fn sub3(left: Vector3, right: Vector3) -> Vector3 { + Vector3 { + x: left.x - right.x, + y: left.y - right.y, + z: left.z - right.z, + } +} + +const fn sub2(left: Vector2, right: Vector2) -> Vector2 { + Vector2 { + x: left.x - right.x, + y: left.y - right.y, + } +} + +const fn scale3(value: Vector3, scalar: f32) -> Vector3 { + Vector3 { + x: value.x * scalar, + y: value.y * scalar, + z: value.z * scalar, + } +} + +const fn dot(left: Vector3, right: Vector3) -> f32 { + left.x * right.x + left.y * right.y + left.z * right.z +} + +const fn cross(left: Vector3, right: Vector3) -> Vector3 { + Vector3 { + x: left.y * right.z - left.z * right.y, + y: left.z * right.x - left.x * right.z, + z: left.x * right.y - left.y * right.x, + } +} + +const fn finite2(value: Vector2) -> bool { + value.x.is_finite() && value.y.is_finite() +} + +const fn finite3(value: Vector3) -> bool { + value.x.is_finite() && value.y.is_finite() && value.z.is_finite() +} + +const fn render_error(source: libremetaverse_types::UUID, context: &'static str) -> Error { + Error::Rendering { source, context } +} diff --git a/crates/libremetaverse-rendering-mesh-foundry/tests/mesh_assets.rs b/crates/libremetaverse-rendering-mesh-foundry/tests/mesh_assets.rs new file mode 100644 index 0000000..ffc0fda --- /dev/null +++ b/crates/libremetaverse-rendering-mesh-foundry/tests/mesh_assets.rs @@ -0,0 +1,570 @@ +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::float_cmp, + clippy::too_many_lines +)] // Fixture quantization is explicitly clamped to the u16 domain. + +use std::collections::{BTreeMap, HashMap}; +use std::io::Write as _; + +use flate2::{Compression, write::ZlibEncoder}; +use libremetaverse::import_export::{ModelFace, ModelPrim}; +use libremetaverse::rendering::{DetailLevel, RiggedSkinMath, SimpleMesh, Vertex}; +use libremetaverse::{Primitive, PrimitiveSculptData, PrimitiveTextureEntry}; +use libremetaverse_rendering_mesh_foundry::{MeshFaceAux, MeshFoundry}; +use libremetaverse_structured_data::{OSD, OSDParser}; +use libremetaverse_types::{Error, SculptType, UUID, Vector2, Vector3}; + +fn compress(value: OSD) -> Vec { + let bytes = OSDParser::serialize_llsd_binary_with_osd_boolean(value, false) + .expect("serialize fixture section"); + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(&bytes).expect("compress fixture section"); + encoder.finish().expect("finish fixture section") +} + +fn pack(sections: BTreeMap<&str, OSD>) -> Vec { + let mut payload = Vec::new(); + let mut header = HashMap::new(); + for (name, value) in sections { + let section = compress(value); + header.insert( + name.to_owned(), + OSD::Map(HashMap::from([ + ( + "offset".to_owned(), + OSD::Integer(i32::try_from(payload.len()).expect("fixture offset")), + ), + ( + "size".to_owned(), + OSD::Integer(i32::try_from(section.len()).expect("fixture size")), + ), + ])), + ); + payload.extend(section); + } + header.insert("version".to_owned(), OSD::Integer(1)); + let mut asset = OSDParser::serialize_llsd_binary_with_osd_boolean(OSD::Map(header), false) + .expect("serialize fixture header"); + asset.extend(payload); + asset +} + +fn quantized3(values: &[[f32; 3]], min: [f32; 3], max: [f32; 3]) -> Vec { + values + .iter() + .flat_map(|value| { + (0..3).flat_map(|axis| quantize(value[axis], min[axis], max[axis]).to_le_bytes()) + }) + .collect() +} + +fn quantized2(values: &[[f32; 2]], min: [f32; 2], max: [f32; 2]) -> Vec { + values + .iter() + .flat_map(|value| { + (0..2).flat_map(|axis| quantize(value[axis], min[axis], max[axis]).to_le_bytes()) + }) + .collect() +} + +fn quantize(value: f32, min: f32, max: f32) -> u16 { + (((value - min) / (max - min)).clamp(0.0, 1.0) * 65_535.0).round() as u16 +} + +fn domain2(min: Vector2, max: Vector2) -> OSD { + OSD::Map(HashMap::from([ + ( + "Min".to_owned(), + OSD::from_vector2(min).expect("domain min"), + ), + ( + "Max".to_owned(), + OSD::from_vector2(max).expect("domain max"), + ), + ])) +} + +fn domain3(min: Vector3, max: Vector3) -> OSD { + OSD::Map(HashMap::from([ + ( + "Min".to_owned(), + OSD::from_vector3(min).expect("domain min"), + ), + ( + "Max".to_owned(), + OSD::from_vector3(max).expect("domain max"), + ), + ])) +} + +fn identity() -> OSD { + OSD::Array( + [ + 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, + ] + .into_iter() + .map(OSD::Real) + .collect(), + ) +} + +fn rigged_submesh(triangle: [u16; 3], weights: Vec) -> OSD { + let positions = [[-0.5, -0.5, 0.0], [0.5, -0.5, 0.0], [-0.5, 0.5, 0.0]]; + let normals = [[0.0, 0.0, 1.0]; 3]; + let uv0 = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]]; + let uv1 = [[0.25, 0.25], [0.75, 0.25], [0.25, 0.75]]; + let tangents = [[1.0, 0.0, 0.0, -1.0]; 3]; + let tangent_bytes: Vec = tangents + .iter() + .flat_map(|value| { + value + .iter() + .flat_map(|component| quantize(*component, -1.0, 1.0).to_le_bytes()) + }) + .collect(); + let triangle_bytes: Vec = triangle.into_iter().flat_map(u16::to_le_bytes).collect(); + let mut map = HashMap::from([ + ( + "PositionDomain".to_owned(), + domain3( + Vector3 { + x: -0.5, + y: -0.5, + z: -0.5, + }, + Vector3 { + x: 0.5, + y: 0.5, + z: 0.5, + }, + ), + ), + ( + "Position".to_owned(), + OSD::Binary(quantized3(&positions, [-0.5; 3], [0.5; 3])), + ), + ( + "Normal".to_owned(), + OSD::Binary(quantized3(&normals, [-1.0; 3], [1.0; 3])), + ), + ( + "TexCoord0Domain".to_owned(), + domain2(Vector2::zero(), Vector2 { x: 1.0, y: 1.0 }), + ), + ( + "TexCoord0".to_owned(), + OSD::Binary(quantized2(&uv0, [0.0; 2], [1.0; 2])), + ), + ( + "TexCoord1Domain".to_owned(), + domain2(Vector2::zero(), Vector2 { x: 1.0, y: 1.0 }), + ), + ( + "TexCoord1".to_owned(), + OSD::Binary(quantized2(&uv1, [0.0; 2], [1.0; 2])), + ), + ("Tangent".to_owned(), OSD::Binary(tangent_bytes)), + ("TriangleList".to_owned(), OSD::Binary(triangle_bytes)), + ( + "NormalizedScale".to_owned(), + OSD::from_vector3(Vector3 { + x: 2.0, + y: 3.0, + z: 4.0, + }) + .expect("normalized scale"), + ), + ]); + if !weights.is_empty() { + map.insert("Weights".to_owned(), OSD::Binary(weights)); + } + OSD::Map(map) +} + +fn skin() -> OSD { + OSD::Map(HashMap::from([ + ( + "joint_names".to_owned(), + OSD::Array( + [ + OSD::String("mPelvis".to_owned()), + OSD::String("mTorso".to_owned()), + ] + .into(), + ), + ), + ( + "inverse_bind_matrix".to_owned(), + OSD::Array(vec![identity(), identity()]), + ), + ( + "alt_inverse_bind_matrix".to_owned(), + OSD::Array(vec![identity(), identity()]), + ), + ("bind_shape_matrix".to_owned(), identity()), + ("pelvis_offset".to_owned(), OSD::Real(0.25)), + ( + "lock_scale_if_joint_position".to_owned(), + OSD::Boolean(true), + ), + ])) +} + +fn weights() -> Vec { + let quarter = (0.25_f32 * 65_535.0).round() as u16; + let three_quarters = (0.75_f32 * 65_535.0).round() as u16; + let half = (0.5_f32 * 65_535.0).round() as u16; + let mut bytes = vec![0]; + bytes.extend(quarter.to_le_bytes()); + bytes.push(1); + bytes.extend(three_quarters.to_le_bytes()); + bytes.push(0xff); + bytes.push(0); + bytes.extend(half.to_le_bytes()); + bytes.push(1); + bytes.extend(half.to_le_bytes()); + bytes.push(0xff); + bytes.push(0xff); + bytes +} + +fn rigged_asset(triangle: [u16; 3], weight_bytes: Vec) -> Vec { + pack(BTreeMap::from([ + ( + "high_lod", + OSD::Array(vec![rigged_submesh(triangle, weight_bytes)]), + ), + ("skin", skin()), + ])) +} + +fn mesh_hash(mesh: &SimpleMesh) -> u64 { + let mut hash = 0xcbf2_9ce4_8422_2325_u64; + for vertex in &mesh.vertices { + for value in [ + vertex.position.x, + vertex.position.y, + vertex.position.z, + vertex.normal.x, + vertex.normal.y, + vertex.normal.z, + vertex.tex_coord.x, + vertex.tex_coord.y, + ] { + hash ^= u64::from(value.to_bits()); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + } + for index in &mesh.indices { + hash ^= u64::from(*index); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} + +#[test] +fn model_workflow_asset_round_trips_and_falls_back_deterministically() { + let mut model = ModelPrim::new().expect("model prim"); + let mut face = ModelFace::new().expect("model face"); + face.vertices = vec![ + Vertex { + position: Vector3 { + x: -0.5, + y: -0.5, + z: 0.0, + }, + normal: Vector3::unit_z(), + tex_coord: Vector2::zero(), + }, + Vertex { + position: Vector3 { + x: 0.5, + y: -0.5, + z: 0.0, + }, + normal: Vector3::unit_z(), + tex_coord: Vector2 { x: 1.0, y: 0.0 }, + }, + Vertex { + position: Vector3 { + x: -0.5, + y: 0.5, + z: 0.0, + }, + normal: Vector3::unit_z(), + tex_coord: Vector2 { x: 0.0, y: 1.0 }, + }, + ]; + face.indices = vec![0, 1, 2]; + model.faces.push(face); + model.create_asset(UUID::zero()).expect("mesh asset"); + let renderer = MeshFoundry::new().expect("renderer"); + let primitive = Primitive::new_with_constructor().expect("primitive"); + + let highest = renderer + .generate_faceted_mesh_mesh_with_primitive_bytes_detail_level( + primitive.clone(), + model.asset.clone(), + DetailLevel::Highest, + ) + .expect("highest decode") + .expect("highest mesh"); + let fallback = renderer + .generate_faceted_mesh_mesh_with_primitive_bytes_detail_level( + primitive, + model.asset.clone(), + DetailLevel::Low, + ) + .expect("fallback decode") + .expect("fallback mesh"); + assert_eq!(highest.faces.len(), 1); + assert_eq!(highest.faces[0].vertices.len(), 3); + assert_eq!(highest.faces[0].indices, [0, 1, 2]); + assert_eq!(fallback.faces[0].indices, highest.faces[0].indices); + let aux = highest.faces[0] + .user_data + .downcast_ref::() + .expect("generated tangent domain"); + assert_eq!(aux.tangents.len(), 3); + assert!(aux.tangents.iter().all(|tangent| tangent.w.abs() == 1.0)); + + let unpacked = renderer + .unpack_mesh(model.asset) + .expect("unpack call") + .expect("unpacked map"); + assert!(matches!(unpacked.get("high_lod"), Some(OSD::Array(_)))); + assert!(matches!(unpacked.get("physics_convex"), Some(OSD::Map(_)))); +} + +#[test] +fn rigged_mesh_preserves_skin_uv_tangent_material_and_ordering_domains() { + let texture = + UUID::new_with_string("11111111-2222-3333-4444-555555555555".into()).expect("texture UUID"); + let material = UUID::new_with_string("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee".into()) + .expect("material UUID"); + let mut textures = PrimitiveTextureEntry::new_with_uuid(texture).expect("texture entry"); + textures + .create_face(0) + .expect("face override") + .set_render_material_id(material); + let mut primitive = Primitive::new_with_constructor().expect("primitive"); + primitive.textures = Some(textures); + let renderer = MeshFoundry::new().expect("renderer"); + let mesh = renderer + .generate_faceted_mesh_mesh_with_primitive_bytes( + primitive, + rigged_asset([0, 1, 2], weights()), + ) + .expect("rigged decode") + .expect("rigged mesh"); + + let skin = mesh.skin_data.as_ref().expect("skin data"); + assert_eq!(skin.joint_names, ["mPelvis", "mTorso"]); + assert_eq!(skin.inverse_bind_matrices.len(), 32); + assert_eq!(skin.alt_inverse_bind_matrices.len(), 32); + assert_eq!(skin.bind_shape_matrix.len(), 16); + assert_eq!(skin.pelvis_offset, 0.25); + assert!(skin.lock_scale_if_joint_position); + assert_eq!( + RiggedSkinMath::build_inv_bind_matrices(skin.clone()) + .expect("decoded inverse bind matrices") + .len(), + 2 + ); + assert_eq!( + RiggedSkinMath::extract_joint_position_overrides(skin.clone()) + .expect("decoded joint overrides") + .len(), + 2 + ); + let face = &mesh.faces[0]; + assert_eq!(face.indices, [0, 1, 2]); + assert_eq!( + face.normalized_scale, + Vector3 { + x: 2.0, + y: 3.0, + z: 4.0 + } + ); + assert_eq!(face.texture_face.texture_id(), texture); + assert_eq!(face.texture_face.render_material_id(), material); + assert_eq!(face.tex_coords1.as_ref().expect("UV1").len(), 3); + let weights = face.weights.as_ref().expect("weights"); + assert_eq!(weights.len(), 3); + for weight in weights { + let total = weight.weight0 + weight.weight1 + weight.weight2 + weight.weight3; + assert!((total - 1.0).abs() < 1.0e-6); + } + assert_eq!(weights[0].joint0, 0); + assert_eq!(weights[0].joint1, 1); + assert_eq!(weights[2].weight0, 1.0); + let tangents = &face + .user_data + .downcast_ref::() + .expect("stored tangents") + .tangents; + assert_eq!(tangents.len(), 3); + assert!( + tangents + .iter() + .all(|tangent| tangent.x > 0.999 && tangent.w == -1.0) + ); +} + +#[test] +fn compressed_submesh_decode_has_stable_golden_output() { + let section = compress(OSD::Array(vec![rigged_submesh([0, 1, 2], Vec::new())])); + let renderer = MeshFoundry::new().expect("renderer"); + let mesh = renderer + .mesh_sub_mesh_as_simple_mesh( + Primitive::new_with_constructor().expect("primitive"), + section, + ) + .expect("submesh decode") + .expect("simple mesh"); + assert_eq!(mesh_hash(&mesh), 0xcb32_07be_6dea_73c8); +} + +#[test] +fn compressed_submesh_decode_rebases_indices_in_face_order() { + let section = compress(OSD::Array(vec![ + rigged_submesh([0, 1, 2], Vec::new()), + rigged_submesh([2, 1, 0], Vec::new()), + ])); + let mesh = MeshFoundry::new() + .expect("renderer") + .mesh_sub_mesh_as_simple_mesh( + Primitive::new_with_constructor().expect("primitive"), + section, + ) + .expect("submesh decode") + .expect("simple mesh"); + assert_eq!(mesh.vertices.len(), 6); + assert_eq!(mesh.indices, [0, 1, 2, 5, 4, 3]); +} + +#[test] +fn convex_hulls_decode_bounding_and_decomposed_domains() { + let positions = quantized3( + &[[-0.5, -0.5, -0.5], [0.5, -0.5, -0.5], [-0.5, 0.5, 0.5]], + [-0.5; 3], + [0.5; 3], + ); + let section = compress(OSD::Map(HashMap::from([ + ( + "Min".to_owned(), + OSD::from_vector3(Vector3 { + x: -0.5, + y: -0.5, + z: -0.5, + }) + .expect("min"), + ), + ( + "Max".to_owned(), + OSD::from_vector3(Vector3 { + x: 0.5, + y: 0.5, + z: 0.5, + }) + .expect("max"), + ), + ("BoundingVerts".to_owned(), OSD::Binary(positions.clone())), + ("HullList".to_owned(), OSD::Binary(vec![3])), + ("Positions".to_owned(), OSD::Binary(positions)), + ]))); + let renderer = MeshFoundry::new().expect("renderer"); + let primitive = Primitive::new_with_constructor().expect("primitive"); + let mut bounding = Vec::new(); + let hulls = renderer + .mesh_sub_mesh_as_convex_hulls_with_primitive_bytes_list( + primitive.clone(), + section.clone(), + &mut bounding, + ) + .expect("convex decode"); + assert_eq!(bounding.len(), 3); + assert_eq!(hulls.len(), 1); + assert_eq!(hulls[0], bounding); + assert_eq!( + renderer + .mesh_sub_mesh_as_convex_hulls_with_primitive_bytes(primitive, section) + .expect("convex overload"), + hulls + ); +} + +#[test] +fn malformed_offsets_indices_and_weights_return_source_context() { + let source = + UUID::new_with_string("12345678-1234-5678-9abc-def012345678".into()).expect("source UUID"); + let mut primitive = Primitive::new_with_constructor().expect("primitive"); + primitive.id = source; + let renderer = MeshFoundry::new().expect("renderer"); + let invalid_index = renderer.generate_faceted_mesh_mesh_with_primitive_bytes( + primitive.clone(), + rigged_asset([0, 1, 3], weights()), + ); + assert!(matches!( + invalid_index, + Err(Error::Rendering { source: actual, context: "mesh triangle index is out of range" }) + if actual == source + )); + + let invalid_weights = renderer.generate_faceted_mesh_mesh_with_primitive_bytes( + primitive.clone(), + rigged_asset([0, 1, 2], vec![2, 0, 128, 0xff, 0xff, 0xff]), + ); + assert!(matches!( + invalid_weights, + Err(Error::Rendering { source: actual, context: "mesh skin joint index is out of range" }) + if actual == source + )); + + let header = OSD::Map(HashMap::from([( + "high_lod".to_owned(), + OSD::Map(HashMap::from([ + ("offset".to_owned(), OSD::Integer(i32::MAX)), + ("size".to_owned(), OSD::Integer(64)), + ])), + )])); + let invalid_offset = OSDParser::serialize_llsd_binary_with_osd_boolean(header, false) + .expect("invalid fixture header"); + assert!(matches!( + renderer.generate_faceted_mesh_mesh_with_primitive_bytes(primitive, invalid_offset), + Err(Error::Rendering { source: actual, context: "mesh section is outside the asset" }) + if actual == source + )); +} + +#[test] +fn mesh_sculpt_mirror_and_invert_update_coordinates_normals_and_tangents() { + let mut primitive = Primitive::new_with_constructor().expect("primitive"); + let mut bytes = UUID::zero().get_bytes().expect("UUID bytes"); + bytes.push(SculptType::Mesh as u8 | SculptType::Mirror as u8 | SculptType::Invert as u8); + primitive.sculpt = + Some(PrimitiveSculptData::new_with_bytes_int32(bytes, 0).expect("sculpt flags")); + let mesh = MeshFoundry::new() + .expect("renderer") + .generate_faceted_mesh_mesh_with_primitive_bytes( + primitive, + rigged_asset([0, 1, 2], weights()), + ) + .expect("modified decode") + .expect("modified mesh"); + let face = &mesh.faces[0]; + // Mirror and invert each reverse winding, so applying both preserves it. + assert_eq!(face.indices, [0, 1, 2]); + assert_eq!(face.vertices[0].position.x, 0.5); + assert!(face.vertices[0].normal.z < -0.999); + let tangents = &face + .user_data + .downcast_ref::() + .expect("tangents") + .tangents; + assert!(tangents[0].x < -0.999); + assert_eq!(tangents[0].w, -1.0); +} diff --git a/crates/libremetaverse-structured-data/src/binary.rs b/crates/libremetaverse-structured-data/src/binary.rs index afe57f5..670b401 100644 --- a/crates/libremetaverse-structured-data/src/binary.rs +++ b/crates/libremetaverse-structured-data/src/binary.rs @@ -21,10 +21,14 @@ const fn is_binary_whitespace(byte: u8) -> bool { } pub(crate) fn deserialize_bytes(data: Vec) -> Result { + deserialize_prefix(&data).map(|(value, _)| value) +} + +pub(crate) fn deserialize_prefix(data: &[u8]) -> Result<(OSD, usize), Error> { if data.len() > MAX_INPUT_BYTES { return Err(parse_error(0, "binary LLSD input exceeds allocation limit")); } - let mut parser = Parser::new(&data); + let mut parser = Parser::new(data); parser.skip_whitespace(); if parser.consume_prefix_ignore_ascii_case(ALT_HEADER) || parser.consume_prefix_ignore_ascii_case(HEADER) @@ -37,7 +41,7 @@ pub(crate) fn deserialize_bytes(data: Vec) -> Result { OSD::DEFAULT_MAX_NODES, OSD::DEFAULT_MAX_BINARY_BYTES, )?; - Ok(value) + Ok((value, parser.position)) } pub(crate) fn deserialize_stream(mut stream: Box) -> Result { @@ -596,6 +600,18 @@ mod tests { ); } + #[test] + fn prefix_decode_reports_exact_payload_boundary() { + let value = OSD::Map(HashMap::from([("offset".into(), OSD::Integer(7))])); + let encoded = serialize_with_header(value.clone(), false).unwrap(); + let mut container = encoded.clone(); + container.extend_from_slice(b"\x78\x9c appended payload"); + let (decoded, consumed) = deserialize_prefix(&container).unwrap(); + assert_eq!(decoded, value); + assert_eq!(consumed, encoded.len()); + assert_eq!(&container[consumed..], b"\x78\x9c appended payload"); + } + #[test] fn malformed_seed_corpus_is_rejected_with_positions() { let expected: &[(&str, &str)] = &[ diff --git a/crates/libremetaverse-structured-data/src/lib.rs b/crates/libremetaverse-structured-data/src/lib.rs index 9bc123d..e8eddd5 100644 --- a/crates/libremetaverse-structured-data/src/lib.rs +++ b/crates/libremetaverse-structured-data/src/lib.rs @@ -54,3 +54,15 @@ pub mod xml { pub use generated::*; pub use libremetaverse_types as types; pub use libremetaverse_types::Error; + +/// Decodes one bounded binary LLSD value and returns the number of consumed +/// bytes, allowing native container formats to locate payloads appended after +/// an LLSD header without duplicating the codec. +/// +/// # Errors +/// +/// Returns a positioned parse error when the prefix is malformed, truncated, +/// or exceeds the shared LLSD depth, node, input, or allocation bounds. +pub fn deserialize_llsd_binary_prefix(data: &[u8]) -> Result<(OSD, usize), Error> { + binary::deserialize_prefix(data) +} diff --git a/docs/imaging-meshing.md b/docs/imaging-meshing.md index 8bcb0be..6d993b0 100644 --- a/docs/imaging-meshing.md +++ b/docs/imaging-meshing.md @@ -5,10 +5,11 @@ Milestone 06 owns the native implementations in `libremetaverse-imaging`, imaging and meshing crates contain no CLR bridge, subprocess adapter, foreign RPC, or generated failure-only shim. The rendering crates consume the checked mesh data but remain a separate milestone: `SimpleRenderer` now provides the -reference prim/sculpt conversion pipeline, while mesh-asset decoding and -rigging remain owned by the separate `MeshFoundry` issue. See the +reference prim/sculpt conversion pipeline. `MeshFoundry` now adds bounded mesh +asset, rigging, tangent, secondary-UV, terrain, and convex-hull handling without +changing the milestone 06 ownership boundary. See the [`SimpleRenderer` guide](../crates/libremetaverse-rendering-simple/README.md) -for that boundary. +and [`MeshFoundry` guide](../crates/libremetaverse-rendering-mesh-foundry/README.md). ## Feature boundary diff --git a/tools/check_milestone_10_issue_77.py b/tools/check_milestone_10_issue_77.py new file mode 100644 index 0000000..6676d31 --- /dev/null +++ b/tools/check_milestone_10_issue_77.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Audit issue 77's native MeshFoundry ownership and evidence boundary.""" + +from __future__ import annotations + +from pathlib import Path +import re + +import generate_api_shims + + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "crates" / "libremetaverse-rendering-mesh-foundry" / "src" / "mesh_foundry.rs" +GENERATED = ROOT / "crates" / "libremetaverse-rendering-mesh-foundry" / "src" / "generated.rs" +MANIFEST = ROOT / "crates" / "libremetaverse-rendering-mesh-foundry" / "Cargo.toml" +ASSET_TESTS = ROOT / "crates" / "libremetaverse-rendering-mesh-foundry" / "tests" / "mesh_assets.rs" +COMPAT = ROOT / "tests" / "compat" / "tests" / "imaging_meshing_semantics.rs" +BENCH = ROOT / "crates" / "libremetaverse-rendering-mesh-foundry" / "benches" / "mesh_decode.rs" +WORKSPACE = ROOT / "Cargo.toml" +DOC = ROOT / "crates" / "libremetaverse-rendering-mesh-foundry" / "README.md" +STRUCTURED = ROOT / "crates" / "libremetaverse-structured-data" / "src" / "lib.rs" +WORKFLOW = ROOT / ".gitea" / "workflows" / "rust-workspace.yml" +STUB_RE = re.compile(r"\b(?:not_implemented|unimplemented_api)\b|\b(?:todo|unimplemented)!\s*\(") + +TYPE = "T:LibreMetaverse.Rendering.MeshFoundry" +MEMBERS = { + "M:LibreMetaverse.Rendering.MeshFoundry.#ctor", + "M:LibreMetaverse.Rendering.MeshFoundry.GenerateFacetedMesh(LibreMetaverse.Primitive,LibreMetaverse.Rendering.DetailLevel)", + "M:LibreMetaverse.Rendering.MeshFoundry.GenerateFacetedMeshMesh(LibreMetaverse.Primitive,System.Byte[])", + "M:LibreMetaverse.Rendering.MeshFoundry.GenerateFacetedMeshMesh(LibreMetaverse.Primitive,System.Byte[],LibreMetaverse.Rendering.DetailLevel)", + "M:LibreMetaverse.Rendering.MeshFoundry.GenerateFacetedSculptMesh(LibreMetaverse.Primitive,LibreMetaverse.Imaging.ManagedImage,LibreMetaverse.Rendering.DetailLevel)", + "M:LibreMetaverse.Rendering.MeshFoundry.GenerateSimpleMesh(LibreMetaverse.Primitive,LibreMetaverse.Rendering.DetailLevel)", + "M:LibreMetaverse.Rendering.MeshFoundry.GenerateSimpleMeshWithNormals(LibreMetaverse.Primitive,LibreMetaverse.Rendering.DetailLevel)", + "M:LibreMetaverse.Rendering.MeshFoundry.GenerateSimpleSculptMesh(LibreMetaverse.Primitive,LibreMetaverse.Imaging.ManagedImage,LibreMetaverse.Rendering.DetailLevel)", + "M:LibreMetaverse.Rendering.MeshFoundry.MeshSubMeshAsConvexHulls(LibreMetaverse.Primitive,System.Byte[])", + "M:LibreMetaverse.Rendering.MeshFoundry.MeshSubMeshAsConvexHulls(LibreMetaverse.Primitive,System.Byte[],System.Collections.Generic.List{LibreMetaverse.Vector3}@)", + "M:LibreMetaverse.Rendering.MeshFoundry.MeshSubMeshAsSimpleMesh(LibreMetaverse.Primitive,System.Byte[])", + "M:LibreMetaverse.Rendering.MeshFoundry.TerrainMesh(System.Single[0:,0:],System.Single,System.Single,System.Single,System.Single)", + "M:LibreMetaverse.Rendering.MeshFoundry.TransformTexCoords(System.Collections.Generic.List{LibreMetaverse.Rendering.Vertex},LibreMetaverse.Vector3,LibreMetaverse.Primitive.TextureEntryFace,LibreMetaverse.Vector3)", + "M:LibreMetaverse.Rendering.MeshFoundry.UnpackMesh(System.Byte[])", +} + + +def require_markers(path: Path, markers: tuple[str, ...]) -> None: + text = path.read_text() + missing = [marker for marker in markers if marker not in text] + if missing: + raise SystemExit(f"{path.name}: audit evidence missing: " + ", ".join(missing)) + + +def main() -> None: + if generate_api_shims.NATIVE_DECLARATIONS.get(TYPE) != "crate::mesh_foundry::MeshFoundry": + raise SystemExit("issue 77 MeshFoundry native declaration is missing") + missing = sorted(MEMBERS - set(generate_api_shims.NATIVE_MEMBER_BODIES)) + if missing: + raise SystemExit("issue 77 native members missing: " + ", ".join(missing)) + for source in (SOURCE, GENERATED): + if STUB_RE.search(source.read_text()): + raise SystemExit(f"issue 77 owned Rust stubs remain in {source.name}") + + require_markers(SOURCE, ( + "impl IRendering for MeshFoundry", "SimpleRenderer", "SculptMesh::from_height_rows", + "deserialize_llsd_binary_prefix", "ZlibDecoder", "select_lod", "decode_submesh", + "TexCoord1", "Tangent", "generate_normals", "generate_tangents", "MeshFaceAux", + "decode_skin", "decode_weights", "apply_sculpt_modifiers", "texture_for_face", + "MAX_ASSET_BYTES", "MAX_TOTAL_VERTICES", "MAX_JOINTS", "checked_add", + "mesh triangle index is out of range", "Error::Rendering", + )) + manifest = MANIFEST.read_text() + required_deps = ( + "flate2", "libremetaverse-prim-mesher", "libremetaverse-rendering-simple", + "libremetaverse-structured-data", "stats_alloc", + ) + missing_deps = [dependency for dependency in required_deps if dependency not in manifest] + if missing_deps: + raise SystemExit("issue 77 dependencies/evidence missing: " + ", ".join(missing_deps)) + + require_markers(ASSET_TESTS, ( + "model_workflow_asset_round_trips_and_falls_back_deterministically", + "rigged_mesh_preserves_skin_uv_tangent_material_and_ordering_domains", + "RiggedSkinMath::build_inv_bind_matrices", "compressed_submesh_decode_has_stable_golden_output", + "0xcb32_07be_6dea_73c8", "rebases_indices_in_face_order", + "convex_hulls_decode_bounding_and_decomposed_domains", + "malformed_offsets_indices_and_weights_return_source_context", + "mirror_and_invert_update_coordinates_normals_and_tangents", + )) + require_markers(COMPAT, ( + "reviewed_meshfoundrytests_generatesimplemesh_box_returnsnonnull", + "reviewed_meshfoundrytests_generatefacetedmesh_box_has6faces", + "reviewed_meshfoundrytests_transformtexcoords_identitytransform_preservesuvs", + "reviewed_meshfoundrytests_terrainmesh_allindicesinrange", + )) + require_markers(BENCH, ("fixture(240)", "57_600", "342_726", "Region::new")) + require_markers(WORKSPACE, ("[profile.benchmark]", 'opt-level = 1', "incremental = false")) + require_markers(STRUCTURED, ("pub fn deserialize_llsd_binary_prefix", "binary::deserialize_prefix")) + require_markers(DOC, ( + "prim-local coordinates", "MeshFaceAux", "bitangent", "UV1", "RiggedSkinMath", + "exactly-four-influence", "XOR winding", "64 MiB", "65,536", "6,000,000", + "source primitive UUID", "57,600-vertex", + )) + require_markers(WORKFLOW, ("python3 tools/check_milestone_10_issue_77.py",)) + print( + "issue 77 audit: native prim/sculpt/terrain and packed mesh pipelines, bounded LLSD/zlib, " + "LOD, normals/tangents/UVs, materials, rigging, convex data, tests, benchmark, and docs are present" + ) + + +if __name__ == "__main__": + main() diff --git a/tools/generate_api_shims.py b/tools/generate_api_shims.py index d133ebe..4f9fb07 100644 --- a/tools/generate_api_shims.py +++ b/tools/generate_api_shims.py @@ -504,9 +504,37 @@ NATIVE_DECLARATIONS = { "T:LibreMetaverse.Simulator": "crate::network_manager::Simulator", "T:LibreMetaverse.StructuredData.OSDParser": "crate::model::OSDParser", "T:LibreMetaverse.Rendering.SimpleRenderer": "crate::simple_renderer::SimpleRenderer", + "T:LibreMetaverse.Rendering.MeshFoundry": "crate::mesh_foundry::MeshFoundry", } NATIVE_MEMBER_BODIES = { + "M:LibreMetaverse.Rendering.MeshFoundry.#ctor": "Self::native_new()", + "M:LibreMetaverse.Rendering.MeshFoundry.GenerateFacetedMesh(LibreMetaverse.Primitive,LibreMetaverse.Rendering.DetailLevel)": + "self.native_generate_faceted_mesh(prim, lod)", + "M:LibreMetaverse.Rendering.MeshFoundry.GenerateFacetedMeshMesh(LibreMetaverse.Primitive,System.Byte[])": + "self.native_generate_faceted_mesh_mesh(prim, mesh_data, libremetaverse::rendering::DetailLevel::Highest)", + "M:LibreMetaverse.Rendering.MeshFoundry.GenerateFacetedMeshMesh(LibreMetaverse.Primitive,System.Byte[],LibreMetaverse.Rendering.DetailLevel)": + "self.native_generate_faceted_mesh_mesh(prim, mesh_data, lod)", + "M:LibreMetaverse.Rendering.MeshFoundry.GenerateFacetedSculptMesh(LibreMetaverse.Primitive,LibreMetaverse.Imaging.ManagedImage,LibreMetaverse.Rendering.DetailLevel)": + "self.native_generate_faceted_sculpt_mesh(prim, sculpt_texture, lod)", + "M:LibreMetaverse.Rendering.MeshFoundry.GenerateSimpleMesh(LibreMetaverse.Primitive,LibreMetaverse.Rendering.DetailLevel)": + "self.native_generate_simple_mesh(prim, lod, false)", + "M:LibreMetaverse.Rendering.MeshFoundry.GenerateSimpleMeshWithNormals(LibreMetaverse.Primitive,LibreMetaverse.Rendering.DetailLevel)": + "self.native_generate_simple_mesh(prim, lod, true)", + "M:LibreMetaverse.Rendering.MeshFoundry.GenerateSimpleSculptMesh(LibreMetaverse.Primitive,LibreMetaverse.Imaging.ManagedImage,LibreMetaverse.Rendering.DetailLevel)": + "self.native_generate_simple_sculpt_mesh(prim, sculpt_texture, lod)", + "M:LibreMetaverse.Rendering.MeshFoundry.MeshSubMeshAsConvexHulls(LibreMetaverse.Primitive,System.Byte[])": + "self.native_mesh_sub_mesh_as_convex_hulls(prim, compressed_mesh_data, None)", + "M:LibreMetaverse.Rendering.MeshFoundry.MeshSubMeshAsConvexHulls(LibreMetaverse.Primitive,System.Byte[],System.Collections.Generic.List{LibreMetaverse.Vector3}@)": + "self.native_mesh_sub_mesh_as_convex_hulls(prim, compressed_mesh_data, Some(bounding_hull))", + "M:LibreMetaverse.Rendering.MeshFoundry.MeshSubMeshAsSimpleMesh(LibreMetaverse.Primitive,System.Byte[])": + "self.native_mesh_sub_mesh_as_simple_mesh(prim, compressed_mesh_data)", + "M:LibreMetaverse.Rendering.MeshFoundry.TerrainMesh(System.Single[0:,0:],System.Single,System.Single,System.Single,System.Single)": + "self.native_terrain_mesh(z_map, x_begin, x_end, y_begin, y_end)", + "M:LibreMetaverse.Rendering.MeshFoundry.TransformTexCoords(System.Collections.Generic.List{LibreMetaverse.Rendering.Vertex},LibreMetaverse.Vector3,LibreMetaverse.Primitive.TextureEntryFace,LibreMetaverse.Vector3)": + "self.native_transform_tex_coords(vertices, center, te_face, prim_scale)", + "M:LibreMetaverse.Rendering.MeshFoundry.UnpackMesh(System.Byte[])": + "self.native_unpack_mesh(asset_data).map(Some)", "M:LibreMetaverse.Rendering.SimpleRenderer.#ctor": "Self::native_new()", "M:LibreMetaverse.Rendering.SimpleRenderer.GenerateFacetedMesh(LibreMetaverse.Primitive,LibreMetaverse.Rendering.DetailLevel)": "self.native_generate_faceted_mesh(prim, lod)",