diff --git a/.gitea/workflows/rust-workspace.yml b/.gitea/workflows/rust-workspace.yml index 151399f..1dec897 100644 --- a/.gitea/workflows/rust-workspace.yml +++ b/.gitea/workflows/rust-workspace.yml @@ -7,6 +7,7 @@ on: - "tools/install_openjpeg_2_5_4.sh" - "tools/check_milestone_09.py" - "tools/test_milestone_09.py" + - "tools/check_milestone_10_issue_76.py" - "**/*.rs" - "**/Cargo.toml" - "Cargo.lock" @@ -16,6 +17,7 @@ on: - "tools/install_openjpeg_2_5_4.sh" - "tools/check_milestone_09.py" - "tools/test_milestone_09.py" + - "tools/check_milestone_10_issue_76.py" - "**/*.rs" - "**/Cargo.toml" - "Cargo.lock" @@ -51,6 +53,7 @@ jobs: python3 tools/check_milestone_09_issue_65.py python3 tools/check_milestone_09_issue_66.py python3 tools/check_milestone_09.py + python3 tools/check_milestone_10_issue_76.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 4d37b58..b6505ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -970,6 +970,7 @@ version = "0.0.1" dependencies = [ "libremetaverse", "libremetaverse-imaging", + "libremetaverse-prim-mesher", "libremetaverse-types", ] diff --git a/README.md b/README.md index d3e219f..d94fa8f 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ host, bind to, invoke, or ship the .NET implementation. The current stage is a compiling structural shell: crate boundaries mirror the .NET library projects, public C# type names have Rust declarations, every upstream NUnit invocation has a traceable catalog entry, and every sample/tool project has a -Rust binary target. Types, StructuredData, Imaging, PrimMesher, both rendering adapters, and +Rust binary target. Types, StructuredData, Imaging, PrimMesher, the MeshFoundry rendering adapter, and the main assembly's packet/message/asset/primitive wire-data, core runtime/networking, avatar-facing manager, world/social/service manager, RLV, LSL tools, Utilities, Vivox, and WebRTC slices now have complete callable, @@ -441,3 +441,15 @@ application forms with stable escaping, while interest-list modes and unknown simulator features are preserved through LLSD capabilities. The cache, pagination, cancellation, and compatibility contracts are documented in [`docs/discovery.md`](docs/discovery.md). + +### Milestone 10 + +The native `SimpleRenderer` is the milestone's deterministic reference +pipeline for legacy prim and decoded sculpt-map geometry. It converts checked +PrimMesher output into shared faceted and simple meshes, preserves prim-face +texture/material metadata, supplies finite normals and UVs, applies default or +planar texture transforms, and reports bounded failures with the source +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). diff --git a/api/SHIM-COVERAGE.md b/api/SHIM-COVERAGE.md index f3bb01e..ff06a5c 100644 --- a/api/SHIM-COVERAGE.md +++ b/api/SHIM-COVERAGE.md @@ -11,7 +11,7 @@ Generated by `python3 tools/generate_api_shims.py`; do not edit by hand. | `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.Simple` | 1 | 6 | callable failure-only shim | +| `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 | | `LibreMetaverse.Utilities` | 3 | 13 | callable failure-only shim | diff --git a/crates/libremetaverse-rendering-simple/Cargo.toml b/crates/libremetaverse-rendering-simple/Cargo.toml index c218802..a6ce47c 100644 --- a/crates/libremetaverse-rendering-simple/Cargo.toml +++ b/crates/libremetaverse-rendering-simple/Cargo.toml @@ -10,6 +10,7 @@ description = "Simple renderer shims for the MetaCrate LibreMetaverse rewrite" [dependencies] libremetaverse = { path = "../libremetaverse" } libremetaverse-imaging = { path = "../libremetaverse-imaging" } +libremetaverse-prim-mesher = { path = "../libremetaverse-prim-mesher" } libremetaverse-types = { path = "../libremetaverse-types" } [lints] diff --git a/crates/libremetaverse-rendering-simple/README.md b/crates/libremetaverse-rendering-simple/README.md new file mode 100644 index 0000000..690e118 --- /dev/null +++ b/crates/libremetaverse-rendering-simple/README.md @@ -0,0 +1,58 @@ +# LibreMetaverse simple renderer + +`libremetaverse-rendering-simple` is the small, deterministic reference +pipeline for geometry that is already represented by a `Primitive` or a +decoded sculpt map. It has no windowing, GPU, Skia, voice, RLV, or LSL +dependency. The only geometry backend is the workspace's checked native +`libremetaverse-prim-mesher` crate. + +## Supported inputs and output + +`SimpleRenderer` implements both its concrete C#-mapped methods and the shared +`IRendering` trait. It supports: + +- linear and flexible paths, plus circular prim paths; +- square, circular, half-circle, and all three triangular profiles; +- profile/path cuts, hollow profiles, shear, taper, twist, skew, radius offset, + and revolutions at all four `DetailLevel` values; +- plane, cylinder, sphere, and torus sculpt maps supplied as `ManagedImage`; +- faceted output grouped by prim face, and flattened simple output that keeps + each face's independent vertex/normal/UV domain; +- per-face texture-entry inheritance, including texture, legacy material, and + render-material UUIDs; and +- default and planar texture transforms with repeat, offset, rotation, and + primitive scale. + +Positions are emitted in prim-local coordinates. Every face contains finite +positions, normals, and UVs, in-range triangle indices, its local bounds and +center, and its effective texture-entry face. Identical inputs produce +byte-for-byte-identical floating-point and index sequences; the compatibility +suite pins a golden high-detail box. + +## Bounds and errors + +Each face and flattened mesh is limited to the 16-bit index domain (65,536 +vertices), and aggregate vertex/index growth uses checked arithmetic before +allocation or conversion. Non-finite construction data, malformed mesher +domains, invalid indices, unsupported topology, and budget overflow return +`Error::Rendering`. That error includes the source primitive UUID and a static +operation context so callers can identify the object that failed. Invalid +standalone texture-transform arguments return `Error::Argument` because that +API has no primitive parameter from which to derive source context. + +## Deliberate boundary + +A sculpt whose type is `Mesh` names an encoded mesh asset rather than a sculpt +height map. `SimpleRenderer` does not fetch or decode assets, so that input +returns a typed `mesh sculpt requires a decoded mesh asset` error. Mesh-asset +decoding, skin weights, secondary UVs, and rigged-mesh metadata belong to the +separate MeshFoundry renderer. The simple renderer also performs no world +transform, scene traversal, material shading, texture download, or drawing. + +Run the focused implementation gates with: + +```console +python3 tools/check_milestone_10_issue_76.py +CARGO_BUILD_JOBS=1 cargo test -p libremetaverse-compat-tests --test rendering_shims --locked -j1 +CARGO_BUILD_JOBS=1 cargo test -p libremetaverse-rendering-simple --locked -j1 +``` diff --git a/crates/libremetaverse-rendering-simple/src/generated.rs b/crates/libremetaverse-rendering-simple/src/generated.rs index bb5e233..22e68b4 100644 --- a/crates/libremetaverse-rendering-simple/src/generated.rs +++ b/crates/libremetaverse-rendering-simple/src/generated.rs @@ -6,11 +6,11 @@ #![allow(non_snake_case)] /// C# type: `T:LibreMetaverse.Rendering.SimpleRenderer`. -pub struct SimpleRenderer; +pub use crate::simple_renderer::SimpleRenderer; impl SimpleRenderer { /// C# member: `M:LibreMetaverse.Rendering.SimpleRenderer.#ctor`. pub fn new() -> Result { - libremetaverse_types::not_implemented("M:LibreMetaverse.Rendering.SimpleRenderer.#ctor") + Self::native_new() } /// C# member: `M:LibreMetaverse.Rendering.SimpleRenderer.GenerateFacetedMesh(LibreMetaverse.Primitive,LibreMetaverse.Rendering.DetailLevel)`. pub fn generate_faceted_mesh( @@ -18,9 +18,7 @@ impl SimpleRenderer { prim: libremetaverse::Primitive, lod: libremetaverse::rendering::DetailLevel, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Rendering.SimpleRenderer.GenerateFacetedMesh(LibreMetaverse.Primitive,LibreMetaverse.Rendering.DetailLevel)", - ) + self.native_generate_faceted_mesh(prim, lod) } /// C# member: `M:LibreMetaverse.Rendering.SimpleRenderer.GenerateFacetedSculptMesh(LibreMetaverse.Primitive,LibreMetaverse.Imaging.ManagedImage,LibreMetaverse.Rendering.DetailLevel)`. pub fn generate_faceted_sculpt_mesh( @@ -29,9 +27,7 @@ impl SimpleRenderer { sculpt_texture: libremetaverse_imaging::ManagedImage, lod: libremetaverse::rendering::DetailLevel, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Rendering.SimpleRenderer.GenerateFacetedSculptMesh(LibreMetaverse.Primitive,LibreMetaverse.Imaging.ManagedImage,LibreMetaverse.Rendering.DetailLevel)", - ) + self.native_generate_faceted_sculpt_mesh(prim, sculpt_texture, lod) } /// C# member: `M:LibreMetaverse.Rendering.SimpleRenderer.GenerateSimpleMesh(LibreMetaverse.Primitive,LibreMetaverse.Rendering.DetailLevel)`. pub fn generate_simple_mesh( @@ -39,9 +35,7 @@ impl SimpleRenderer { prim: libremetaverse::Primitive, lod: libremetaverse::rendering::DetailLevel, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Rendering.SimpleRenderer.GenerateSimpleMesh(LibreMetaverse.Primitive,LibreMetaverse.Rendering.DetailLevel)", - ) + self.native_generate_simple_mesh(prim, lod) } /// C# member: `M:LibreMetaverse.Rendering.SimpleRenderer.GenerateSimpleSculptMesh(LibreMetaverse.Primitive,LibreMetaverse.Imaging.ManagedImage,LibreMetaverse.Rendering.DetailLevel)`. pub fn generate_simple_sculpt_mesh( @@ -50,9 +44,7 @@ impl SimpleRenderer { sculpt_texture: libremetaverse_imaging::ManagedImage, lod: libremetaverse::rendering::DetailLevel, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Rendering.SimpleRenderer.GenerateSimpleSculptMesh(LibreMetaverse.Primitive,LibreMetaverse.Imaging.ManagedImage,LibreMetaverse.Rendering.DetailLevel)", - ) + self.native_generate_simple_sculpt_mesh(prim, sculpt_texture, lod) } /// C# member: `M:LibreMetaverse.Rendering.SimpleRenderer.TransformTexCoords(System.Collections.Generic.List{LibreMetaverse.Rendering.Vertex},LibreMetaverse.Vector3,LibreMetaverse.Primitive.TextureEntryFace,LibreMetaverse.Vector3)`. pub fn transform_tex_coords( @@ -62,9 +54,6 @@ impl SimpleRenderer { te_face: libremetaverse::PrimitiveTextureEntryFace, prim_scale: libremetaverse_types::Vector3, ) -> Result<(), crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Rendering.SimpleRenderer.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) } } -impl libremetaverse::rendering::IRendering for SimpleRenderer {} diff --git a/crates/libremetaverse-rendering-simple/src/lib.rs b/crates/libremetaverse-rendering-simple/src/lib.rs index eaf64c5..6195267 100644 --- a/crates/libremetaverse-rendering-simple/src/lib.rs +++ b/crates/libremetaverse-rendering-simple/src/lib.rs @@ -1,8 +1,13 @@ -//! Simple renderer corresponding to `LibreMetaverse.Rendering.Simple`. +//! Deterministic reference rendering for legacy prims and decoded sculpt maps. +//! +//! [`SimpleRenderer`] converts checked `PrimMesher` geometry into the shared +//! rendering types without a graphics backend. See the crate README for the +//! supported topology, resource bounds, and mesh-asset boundary. extern crate self as libremetaverse_rendering_simple; mod generated; +mod simple_renderer; pub use generated::*; pub use libremetaverse as core; diff --git a/crates/libremetaverse-rendering-simple/src/simple_renderer.rs b/crates/libremetaverse-rendering-simple/src/simple_renderer.rs new file mode 100644 index 0000000..b7ced2b --- /dev/null +++ b/crates/libremetaverse-rendering-simple/src/simple_renderer.rs @@ -0,0 +1,665 @@ +//! Deterministic reference renderer built on the checked native prim mesher. + +#![allow(clippy::cast_possible_truncation)] // Checked C# float-to-int twist conversion. +#![allow(clippy::missing_errors_doc)] // Public signatures are fixed by the API mapping. +#![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)] // The mapped constructor is fallible in the C# API. +#![allow(clippy::unused_self)] // IRendering exposes these operations as instance methods. + +use libremetaverse::rendering::{ + DetailLevel, Face, FaceMask, FacetedMesh, IRendering, SimpleMesh, Vertex, +}; +use libremetaverse::{MappingType, Primitive, PrimitiveTextureEntryFace}; +use libremetaverse_imaging::ManagedImage; +use libremetaverse_prim_mesher::{ + PathType, PrimMesh, SculptMesh, SculptMeshSculptType, VertexIndexer, +}; +use libremetaverse_types::compat::Object; +use libremetaverse_types::{ + Error, HoleType, PathCurve, ProfileCurve, SculptType, Vector2, Vector3, +}; + +const MAX_FACE_VERTICES: usize = 65_536; +const MAX_TOTAL_VERTICES: usize = MAX_FACE_VERTICES; +const MAX_TOTAL_INDICES: usize = MAX_TOTAL_VERTICES * 6; + +/// Small, deterministic renderer for prim and sculpt geometry. +pub struct SimpleRenderer; + +impl SimpleRenderer { + pub(crate) fn native_new() -> Result { + Ok(Self) + } + + pub(crate) fn native_generate_simple_mesh( + &self, + prim: Primitive, + lod: DetailLevel, + ) -> Result { + let source = prim.id; + let faceted = self.native_generate_faceted_mesh(prim, lod)?; + flatten_faces(faceted.faces, source) + } + + pub(crate) fn native_generate_simple_sculpt_mesh( + &self, + prim: Primitive, + sculpt_texture: ManagedImage, + lod: DetailLevel, + ) -> Result { + let source = prim.id; + let faceted = self.native_generate_faceted_sculpt_mesh(prim, sculpt_texture, lod)?; + flatten_faces(faceted.faces, source) + } + + pub(crate) fn native_generate_faceted_mesh( + &self, + prim: Primitive, + lod: DetailLevel, + ) -> Result { + let source = prim.id; + let mesh = generate_prim_mesh(&prim, lod, source)?; + let indexer = mesh + .get_vertex_indexer() + .map_err(|_| render_error(source, "index primitive viewer faces"))? + .ok_or_else(|| render_error(source, "primitive viewer faces are unavailable"))?; + let faces = indexed_faces(&prim, indexer, source)?; + Ok(FacetedMesh { + faces, + skin_data: None, + }) + } + + pub(crate) fn native_generate_faceted_sculpt_mesh( + &self, + prim: Primitive, + sculpt_texture: ManagedImage, + lod: DetailLevel, + ) -> Result { + let source = prim.id; + let sculpt = prim + .sculpt + .as_ref() + .ok_or_else(|| render_error(source, "primitive has no sculpt metadata"))?; + let sculpt_type = match sculpt.type_() { + SculptType::Cylinder => SculptMeshSculptType::Cylinder, + SculptType::Plane => SculptMeshSculptType::Plane, + SculptType::Sphere => SculptMeshSculptType::Sphere, + SculptType::Torus => SculptMeshSculptType::Torus, + SculptType::Mesh => { + return Err(render_error( + source, + "mesh sculpt requires a decoded mesh asset", + )); + } + SculptType::None | SculptType::Invert | SculptType::Mirror => { + return Err(render_error(source, "unsupported sculpt topology")); + } + }; + let mesher_lod = match lod { + DetailLevel::Highest | DetailLevel::High => 32, + DetailLevel::Medium => 16, + DetailLevel::Low => 8, + }; + let mesh = SculptMesh::new_with_managed_image_sculpt_type_int32_boolean_boolean_boolean( + sculpt_texture, + sculpt_type, + mesher_lod, + true, + sculpt.mirror(), + sculpt.invert(), + ) + .map_err(|_| render_error(source, "generate bounded sculpt 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 sculpt 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, "sculpt index count overflow"))?, + ); + for face in mesh.faces { + push_triangle( + &mut indices, + face.v1, + face.v2, + face.v3, + vertices.len(), + source, + )?; + } + let texture = texture_for_face(&prim, 0, source)?; + let face = make_face(0, vertices, indices, texture, source)?; + Ok(FacetedMesh { + faces: vec![face], + skin_data: None, + }) + } + + pub(crate) fn native_transform_tex_coords( + &self, + vertices: &mut Vec, + center: Vector3, + te_face: PrimitiveTextureEntryFace, + prim_scale: Vector3, + ) -> Result<(), Error> { + if !finite3(center) + || !finite3(prim_scale) + || ![ + te_face.repeat_u(), + te_face.repeat_v(), + te_face.offset_u(), + te_face.offset_v(), + te_face.rotation(), + ] + .into_iter() + .all(f32::is_finite) + { + return Err(Error::Argument); + } + let cosine = te_face.rotation().cos(); + let sine = te_face.rotation().sin(); + for vertex in vertices { + if !finite3(vertex.position) || !finite3(vertex.normal) || !finite2(vertex.tex_coord) { + return Err(Error::Argument); + } + if te_face.tex_map_type() == MappingType::Planar { + let mut binormal; + let normal_x = vertex.normal.x; + if (-0.5..0.5).contains(&normal_x) { + binormal = Vector3::unit_x(); + if vertex.normal.y > 0.0 { + binormal.x = -1.0; + } + } else { + binormal = Vector3::unit_y(); + if normal_x < 0.0 { + binormal.y = -1.0; + } + } + let tangent = cross(binormal, vertex.normal); + let scaled = Vector3 { + x: vertex.position.x * prim_scale.x, + y: vertex.position.y * prim_scale.y, + z: vertex.position.z * prim_scale.z, + }; + vertex.tex_coord.x = 0.5 + dot(binormal, scaled) * 2.0; + vertex.tex_coord.y = 0.5 - dot(tangent, scaled) * 2.0; + } + let x = vertex.tex_coord.x - 0.5; + let y = vertex.tex_coord.y - 0.5; + vertex.tex_coord.x = + (x * cosine + y * sine) * te_face.repeat_u() + te_face.offset_u() + 0.5; + vertex.tex_coord.y = + (-x * sine + y * cosine) * te_face.repeat_v() + te_face.offset_v() + 0.5; + if !finite2(vertex.tex_coord) { + return Err(Error::Argument); + } + } + Ok(()) + } +} + +impl IRendering for SimpleRenderer { + fn generate_faceted_mesh( + &self, + prim: Primitive, + lod: DetailLevel, + ) -> Result, Error> { + self.native_generate_faceted_mesh(prim, lod).map(Some) + } + + 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) + .map(Some) + } + + fn generate_simple_mesh( + &self, + prim: Primitive, + lod: DetailLevel, + ) -> Result, Error> { + self.native_generate_simple_mesh(prim, lod).map(Some) + } + + 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) + .map(Some) + } + + 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 generate_prim_mesh( + prim: &Primitive, + lod: DetailLevel, + source: libremetaverse_types::UUID, +) -> Result { + let data = &prim.prim_data; + let parameters = [ + data.profile_begin, + data.profile_end, + data.profile_hollow, + data.path_scale_x, + data.path_scale_y, + data.path_begin, + data.path_end, + data.path_shear_x, + data.path_shear_y, + data.path_radius_offset, + data.path_revolutions, + data.path_skew, + data.path_taper_x, + data.path_taper_y, + data.path_twist_begin, + data.path_twist, + ]; + if !parameters.into_iter().all(f32::is_finite) { + return Err(render_error( + source, + "non-finite primitive construction data", + )); + } + let (mut sides, mut profile_begin, mut profile_end, sphere_mode) = + match data.profile_curve_with_property() { + ProfileCurve::Circle => (lod_sides(lod), data.profile_begin, data.profile_end, false), + ProfileCurve::EqualTriangle + | ProfileCurve::IsoTriangle + | ProfileCurve::RightTriangle => (3, data.profile_begin, data.profile_end, false), + ProfileCurve::HalfCircle => ( + lod_sides(lod), + data.profile_begin.mul_add(0.5, 0.5), + data.profile_end.mul_add(0.5, 0.5), + true, + ), + ProfileCurve::Square => (4, data.profile_begin, data.profile_end, false), + }; + if sides < 3 { + sides = 3; + } + profile_begin = profile_begin.clamp(0.0, 1.0); + profile_end = profile_end.clamp(0.0, 1.0); + let hollow_sides = if data.profile_hole() == HoleType::Circle { + lod_sides(lod) + } else if data.profile_hole() == HoleType::Triangle { + 3 + } else if data.profile_hole() == HoleType::Same { + sides + } else { + 4 + }; + let mut mesh = PrimMesh::new( + sides, + profile_begin, + profile_end, + data.profile_hollow, + hollow_sides, + ) + .map_err(|_| render_error(source, "initialize primitive profile"))?; + mesh.viewer_mode = true; + mesh.sphere_mode = sphere_mode; + mesh.hole_size_x = data.path_scale_x; + mesh.hole_size_y = data.path_scale_y; + mesh.path_cut_begin = data.path_begin; + mesh.path_cut_end = data.path_end; + mesh.top_shear_x = data.path_shear_x; + mesh.top_shear_y = data.path_shear_y; + mesh.radius = data.path_radius_offset; + mesh.revolutions = data.path_revolutions; + mesh.skew = data.path_skew; + mesh.steps_per_revolution = lod_sides(lod); + let path_type = if matches!(data.path_curve, PathCurve::Line | PathCurve::Flexible) { + mesh.taper_x = 1.0 - data.path_scale_x; + mesh.taper_y = 1.0 - data.path_scale_y; + mesh.twist_begin = checked_twist(data.path_twist_begin, 180.0, source)?; + mesh.twist_end = checked_twist(data.path_twist, 180.0, source)?; + PathType::Linear + } else { + mesh.taper_x = data.path_taper_x; + mesh.taper_y = data.path_taper_y; + mesh.twist_begin = checked_twist(data.path_twist_begin, 360.0, source)?; + mesh.twist_end = checked_twist(data.path_twist, 360.0, source)?; + PathType::Circular + }; + mesh.extrude(path_type) + .map_err(|_| render_error(source, "extrude primitive geometry"))?; + if mesh.coords.len() > MAX_TOTAL_VERTICES || mesh.viewer_faces.len() > MAX_TOTAL_INDICES / 3 { + return Err(render_error( + source, + "primitive geometry exceeds renderer bounds", + )); + } + Ok(mesh) +} + +fn indexed_faces( + prim: &Primitive, + indexer: VertexIndexer, + source: libremetaverse_types::UUID, +) -> Result, Error> { + let count = usize::try_from(indexer.num_prim_faces) + .map_err(|_| render_error(source, "invalid primitive face count"))?; + if count != indexer.viewer_vertices.len() || count != indexer.viewer_polygons.len() { + return Err(render_error(source, "misaligned primitive face domains")); + } + let mut total_vertices = 0usize; + let mut total_indices = 0usize; + let mut faces = Vec::with_capacity(count); + for face_index in 0..count { + let source_vertices = &indexer.viewer_vertices[face_index]; + let polygons = indexer.viewer_polygons[face_index] + .as_ref() + .ok_or_else(|| render_error(source, "primitive face has no polygon domain"))?; + if source_vertices.is_empty() { + if polygons.is_empty() { + continue; + } + return Err(render_error( + source, + "primitive face polygons have no vertex domain", + )); + } + if source_vertices.len() > MAX_FACE_VERTICES { + return Err(render_error( + source, + "primitive face exceeds 16-bit vertex range", + )); + } + total_vertices = total_vertices + .checked_add(source_vertices.len()) + .ok_or_else(|| render_error(source, "primitive vertex count overflow"))?; + if total_vertices > MAX_TOTAL_VERTICES { + return Err(render_error(source, "primitive vertex budget exceeded")); + } + let vertices = source_vertices + .iter() + .map(|vertex| { + checked_vertex( + Vector3 { + x: vertex.v.x, + y: vertex.v.y, + z: vertex.v.z, + }, + Vector3 { + x: vertex.n.x, + y: vertex.n.y, + z: vertex.n.z, + }, + Vector2 { + x: vertex.uv.u, + y: 1.0 - vertex.uv.v, + }, + source, + ) + }) + .collect::, _>>()?; + let mut indices = Vec::with_capacity( + polygons + .len() + .checked_mul(3) + .ok_or_else(|| render_error(source, "primitive index count overflow"))?, + ); + for polygon in polygons { + if polygon.v1 == polygon.v2 || polygon.v1 == polygon.v3 || polygon.v2 == polygon.v3 { + continue; + } + push_triangle( + &mut indices, + polygon.v1, + polygon.v2, + polygon.v3, + vertices.len(), + source, + )?; + } + total_indices = total_indices + .checked_add(indices.len()) + .ok_or_else(|| render_error(source, "primitive index count overflow"))?; + if total_indices > MAX_TOTAL_INDICES { + return Err(render_error(source, "primitive index budget exceeded")); + } + let texture = texture_for_face(prim, face_index, source)?; + faces.push(make_face( + i32::try_from(face_index) + .map_err(|_| render_error(source, "primitive face id overflow"))?, + vertices, + indices, + texture, + source, + )?); + } + Ok(faces) +} + +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_TOTAL_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); + } + if indices.len() > MAX_TOTAL_INDICES { + return Err(render_error(source, "simple mesh index budget exceeded")); + } + Ok(SimpleMesh { indices, vertices }) +} + +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 make_face( + id: i32, + vertices: Vec, + indices: Vec, + texture_face: PrimitiveTextureEntryFace, + source: libremetaverse_types::UUID, +) -> Result { + let first = vertices + .first() + .ok_or_else(|| render_error(source, "render face has no vertices"))?; + let mut min = first.position; + let mut max = first.position; + for vertex in &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); + } + let center = Vector3 { + x: (min.x + max.x) * 0.5, + y: (min.y + max.y) * 0.5, + z: (min.z + max.z) * 0.5, + }; + Ok(Face { + begin_s: 0, + begin_t: 0, + center, + edge: Vec::new(), + id, + indices, + mask: FaceMask::SINGLE, + max_extent: max, + min_extent: min, + normalized_scale: Vector3 { + x: max.x - min.x, + y: max.y - min.y, + z: max.z - min.z, + }, + num_s: 0, + num_t: 0, + tex_coords1: None, + texture_face, + user_data: Object::Undefined, + vertices, + weights: None, + }) +} + +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, + v1: i32, + v2: i32, + v3: i32, + vertex_count: usize, + source: libremetaverse_types::UUID, +) -> Result<(), Error> { + for index in [v1, v2, v3] { + 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 checked_twist( + value: f32, + multiplier: f32, + source: libremetaverse_types::UUID, +) -> Result { + let degrees = value * multiplier; + if !degrees.is_finite() || degrees.abs() > 360_000.0 { + return Err(render_error(source, "primitive twist is out of range")); + } + Ok(degrees as i32) +} + +const fn lod_sides(lod: DetailLevel) -> i32 { + match lod { + DetailLevel::Low => 6, + DetailLevel::Medium => 12, + DetailLevel::High | DetailLevel::Highest => 24, + } +} + +const fn render_error(source: libremetaverse_types::UUID, context: &'static str) -> Error { + Error::Rendering { source, context } +} + +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 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, + } +} diff --git a/crates/libremetaverse-rendering-simple/tests/reference_pipeline.rs b/crates/libremetaverse-rendering-simple/tests/reference_pipeline.rs new file mode 100644 index 0000000..4b22e26 --- /dev/null +++ b/crates/libremetaverse-rendering-simple/tests/reference_pipeline.rs @@ -0,0 +1,117 @@ +use libremetaverse::rendering::{DetailLevel, SimpleMesh}; +use libremetaverse::{ + Primitive, PrimitiveConstructionData, PrimitiveSculptData, PrimitiveTextureEntry, +}; +use libremetaverse_imaging::{ManagedImage, ManagedImageImageChannels}; +use libremetaverse_rendering_simple::SimpleRenderer; +use libremetaverse_types::{HoleType, PCode, PathCurve, ProfileCurve, SculptType, UUID}; + +fn primitive(profile: ProfileCurve, path: PathCurve) -> Primitive { + let mut primitive = Primitive::new_with_constructor().expect("Primitive constructor"); + let mut data = + PrimitiveConstructionData::new_with_constructor().expect("ConstructionData constructor"); + data.p_code = PCode::Prim; + data.path_curve = path; + data.set_profile_curve_with_property(profile); + data.path_scale_x = 0.8; + data.path_scale_y = 0.7; + data.path_begin = 0.05; + data.path_end = 0.95; + data.profile_begin = 0.03; + data.profile_end = 0.97; + data.profile_hollow = 0.15; + data.set_profile_hole(HoleType::Circle); + data.path_shear_x = 0.1; + data.path_shear_y = -0.1; + data.path_revolutions = 1.25; + data.path_twist = 0.1; + primitive.prim_data = data; + primitive.textures = Some( + PrimitiveTextureEntry::new_with_uuid(UUID::zero()).expect("texture entry constructor"), + ); + primitive +} + +fn assert_valid(mesh: &SimpleMesh) { + assert!(!mesh.vertices.is_empty()); + assert!(!mesh.indices.is_empty()); + assert_eq!(mesh.indices.len() % 3, 0); + for index in &mesh.indices { + assert!(usize::from(*index) < mesh.vertices.len()); + } + for vertex in &mesh.vertices { + assert!(vertex.position.x.is_finite()); + assert!(vertex.position.y.is_finite()); + assert!(vertex.position.z.is_finite()); + assert!(vertex.normal.x.is_finite()); + assert!(vertex.normal.y.is_finite()); + assert!(vertex.normal.z.is_finite()); + assert!(vertex.tex_coord.x.is_finite()); + assert!(vertex.tex_coord.y.is_finite()); + } +} + +fn sculpt_image() -> ManagedImage { + let mut image = ManagedImage::new(32, 32, ManagedImageImageChannels::COLOR) + .expect("sculpt image constructor"); + for (index, red) in image.red.iter_mut().enumerate() { + *red = u8::try_from(index % 256).expect("bounded sample"); + image.green[index] = u8::try_from((index * 3) % 256).expect("bounded sample"); + image.blue[index] = u8::try_from((index * 7) % 256).expect("bounded sample"); + } + image +} + +#[test] +fn every_profile_path_and_lod_produces_checked_geometry() { + let renderer = SimpleRenderer::new().expect("renderer"); + for profile in [ + ProfileCurve::Circle, + ProfileCurve::EqualTriangle, + ProfileCurve::HalfCircle, + ProfileCurve::IsoTriangle, + ProfileCurve::RightTriangle, + ProfileCurve::Square, + ] { + for path in [PathCurve::Flexible, PathCurve::Line, PathCurve::Circle] { + for lod in [ + DetailLevel::Low, + DetailLevel::Medium, + DetailLevel::High, + DetailLevel::Highest, + ] { + let mesh = renderer + .generate_simple_mesh(primitive(profile, path), lod) + .expect("supported prim topology"); + assert_valid(&mesh); + } + } + } +} + +#[test] +fn every_sculpt_topology_produces_checked_geometry_at_each_lod() { + let renderer = SimpleRenderer::new().expect("renderer"); + for sculpt_type in [ + SculptType::Plane, + SculptType::Cylinder, + SculptType::Sphere, + SculptType::Torus, + ] { + for lod in [ + DetailLevel::Low, + DetailLevel::Medium, + DetailLevel::High, + DetailLevel::Highest, + ] { + let mut prim = primitive(ProfileCurve::Square, PathCurve::Line); + let mut sculpt = PrimitiveSculptData::new_with_constructor().expect("sculpt metadata"); + sculpt.set_type_(sculpt_type); + prim.sculpt = Some(sculpt); + let mesh = renderer + .generate_simple_sculpt_mesh(prim, sculpt_image(), lod) + .expect("supported sculpt topology"); + assert_valid(&mesh); + } + } +} diff --git a/crates/libremetaverse-types/src/shim.rs b/crates/libremetaverse-types/src/shim.rs index b190653..9a2cb38 100644 --- a/crates/libremetaverse-types/src/shim.rs +++ b/crates/libremetaverse-types/src/shim.rs @@ -68,6 +68,13 @@ pub enum Error { HttpRequest, /// A host lookup or socket operation failed. Socket, + /// Rendering failed while processing one source object at a named stage. + Rendering { + /// UUID of the primitive whose geometry could not be produced. + source: crate::UUID, + /// Stable stage suitable for diagnostics and fixture assertions. + context: &'static str, + }, } impl Error { @@ -83,7 +90,8 @@ impl Error { | Self::Parse { .. } | Self::Cancelled | Self::HttpRequest - | Self::Socket => None, + | Self::Socket + | Self::Rendering { .. } => None, } } } @@ -110,6 +118,9 @@ impl fmt::Display for Error { Self::Cancelled => formatter.write_str("operation was cancelled"), Self::HttpRequest => formatter.write_str("HTTP request failed"), Self::Socket => formatter.write_str("socket operation failed"), + Self::Rendering { source, context } => { + write!(formatter, "rendering object {source} failed: {context}") + } } } } diff --git a/docs/imaging-meshing.md b/docs/imaging-meshing.md index 3ea1f69..8bcb0be 100644 --- a/docs/imaging-meshing.md +++ b/docs/imaging-meshing.md @@ -4,8 +4,11 @@ Milestone 06 owns the native implementations in `libremetaverse-imaging`, `libremetaverse-imaging-skia`, and `libremetaverse-prim-mesher`. The core 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: this boundary does not hide or -reclassify their future `MeshFoundry` and `SimpleRenderer` work. +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 +[`SimpleRenderer` guide](../crates/libremetaverse-rendering-simple/README.md) +for that boundary. ## Feature boundary diff --git a/tests/compat/tests/rendering_shims.rs b/tests/compat/tests/rendering_shims.rs index aa31367..8142a69 100644 --- a/tests/compat/tests/rendering_shims.rs +++ b/tests/compat/tests/rendering_shims.rs @@ -1,8 +1,115 @@ -use libremetaverse::Primitive; -use libremetaverse::rendering::{DetailLevel, SimpleMesh}; +#![allow(clippy::float_cmp, clippy::too_many_lines)] + +use libremetaverse::rendering::{DetailLevel, IRendering, SimpleMesh, Vertex}; +use libremetaverse::{ + MappingType, Primitive, PrimitiveConstructionData, PrimitiveSculptData, PrimitiveTextureEntry, + PrimitiveTextureEntryFace, +}; +use libremetaverse_imaging::{ManagedImage, ManagedImageImageChannels}; use libremetaverse_prim_mesher::PrimMesh; use libremetaverse_rendering_mesh_foundry::MeshFoundry; use libremetaverse_rendering_simple::SimpleRenderer; +use libremetaverse_types::{ + Error, HoleType, PCode, PathCurve, ProfileCurve, SculptType, UUID, Vector2, Vector3, +}; + +fn uuid(value: &str) -> UUID { + UUID::new_with_string(value.into()).expect("fixture UUID") +} + +fn primitive(profile: ProfileCurve, path: PathCurve, path_scale_y: f32) -> Primitive { + let mut primitive = Primitive::new_with_constructor().expect("Primitive constructor"); + let mut data = + PrimitiveConstructionData::new_with_constructor().expect("ConstructionData constructor"); + data.p_code = PCode::Prim; + data.path_curve = path; + data.set_profile_curve_with_property(profile); + data.path_scale_x = 1.0; + data.path_scale_y = path_scale_y; + data.path_begin = 0.0; + data.path_end = 1.0; + data.profile_begin = 0.0; + data.profile_end = 1.0; + data.profile_hollow = 0.0; + data.set_profile_hole(HoleType::Same); + data.path_revolutions = 1.0; + primitive.prim_data = data; + primitive +} + +fn box_primitive() -> Primitive { + primitive(ProfileCurve::Square, PathCurve::Line, 1.0) +} + +fn mesh_hash(mesh: &SimpleMesh) -> u64 { + let mut hash = 0xcbf2_9ce4_8422_2325_u64; + let mut mix = |value: u32| { + hash ^= u64::from(value); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + }; + mix(u32::try_from(mesh.vertices.len()).expect("fixture vertex count")); + mix(u32::try_from(mesh.indices.len()).expect("fixture index count")); + 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, + ] { + mix(value.to_bits()); + } + } + for index in &mesh.indices { + mix(u32::from(*index)); + } + hash +} + +fn assert_valid_mesh(mesh: &SimpleMesh) { + assert!(!mesh.vertices.is_empty()); + assert!(!mesh.indices.is_empty()); + assert_eq!(mesh.indices.len() % 3, 0); + for index in &mesh.indices { + assert!(usize::from(*index) < mesh.vertices.len()); + } + 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, + ] { + assert!(value.is_finite()); + } + } +} + +fn sculpt_image() -> ManagedImage { + let mut image = ManagedImage::new(8, 8, ManagedImageImageChannels::COLOR) + .expect("sculpt image constructor"); + for index in 0_u8..64 { + image.red[usize::from(index)] = index.wrapping_mul(3); + image.green[usize::from(index)] = index.wrapping_mul(5); + image.blue[usize::from(index)] = index.wrapping_mul(7); + } + image +} + +fn vertex(x: f32, y: f32, normal: Vector3, position: Vector3) -> Vertex { + Vertex { + position, + normal, + tex_coord: Vector2 { x, y }, + } +} #[test] fn mesh_and_renderer_calls_compile_with_typed_elements() { @@ -23,17 +130,205 @@ fn mesh_and_renderer_calls_compile_with_typed_elements() { } #[test] -fn native_mesher_and_remaining_shims_report_their_current_status() { - let mesh = PrimMesh::new(4, 0.0, 1.0, 0.0, 4) - .expect("native PrimMesh constructor should create a checked mesh"); - assert!(mesh.coords.is_empty()); - assert!(mesh.faces.is_empty()); - - let Err(renderer_error) = SimpleRenderer::new() else { - panic!("failure-only renderer constructor unexpectedly returned a value"); - }; +fn simple_renderer_box_pipeline_is_deterministic_and_matches_golden_mesh() { + let renderer = SimpleRenderer::new().expect("SimpleRenderer constructor"); + let faceted = renderer + .generate_faceted_mesh(box_primitive(), DetailLevel::High) + .expect("faceted box"); + assert_eq!(faceted.faces.len(), 6); assert_eq!( - renderer_error.csharp_member(), - Some("M:LibreMetaverse.Rendering.SimpleRenderer.#ctor") + faceted + .faces + .iter() + .map(|face| face.vertices.len()) + .sum::(), + 24 + ); + assert_eq!( + faceted + .faces + .iter() + .map(|face| face.indices.len()) + .sum::(), + 36 + ); + for face in &faceted.faces { + assert!(face.min_extent.x <= face.max_extent.x); + assert!(face.min_extent.y <= face.max_extent.y); + assert!(face.min_extent.z <= face.max_extent.z); + } + + let first = renderer + .generate_simple_mesh(box_primitive(), DetailLevel::High) + .expect("first simple box"); + let second = renderer + .generate_simple_mesh(box_primitive(), DetailLevel::High) + .expect("second simple box"); + assert_valid_mesh(&first); + assert_eq!(first.vertices.len(), 24); + assert_eq!(first.indices.len(), 36); + assert_eq!(mesh_hash(&first), mesh_hash(&second)); + assert_eq!(mesh_hash(&first), 0x3d2b_10e3_7da0_3d2e); +} + +#[test] +fn simple_renderer_maps_default_and_per_face_texture_metadata() { + let default_texture = uuid("11111111-1111-1111-1111-111111111111"); + let override_texture = uuid("22222222-2222-2222-2222-222222222222"); + let material = uuid("33333333-3333-3333-3333-333333333333"); + let render_material = uuid("44444444-4444-4444-4444-444444444444"); + let mut textures = + PrimitiveTextureEntry::new_with_uuid(default_texture).expect("texture entry constructor"); + let face = textures.create_face(0).expect("face override"); + face.set_texture_id(override_texture); + face.set_material_id(material); + face.set_render_material_id(render_material); + let mut prim = box_primitive(); + prim.textures = Some(textures); + + let mesh = SimpleRenderer::new() + .expect("renderer") + .generate_faceted_mesh(prim, DetailLevel::Medium) + .expect("textured box"); + assert_eq!(mesh.faces[0].texture_face.texture_id(), override_texture); + assert_eq!(mesh.faces[0].texture_face.material_id(), material); + assert_eq!( + mesh.faces[0].texture_face.render_material_id(), + render_material + ); + assert_eq!(mesh.faces[1].texture_face.texture_id(), default_texture); +} + +#[test] +fn simple_renderer_transforms_default_and_planar_texture_coordinates() { + let renderer = SimpleRenderer::new().expect("renderer"); + let mut face = PrimitiveTextureEntryFace::new(None).expect("texture face"); + face.set_repeat_u(2.0); + face.set_repeat_v(3.0); + face.set_offset_u(0.1); + face.set_offset_v(-0.2); + face.set_rotation(0.0); + face.set_tex_map_type(MappingType::Default); + let mut vertices = vec![vertex(0.25, 0.75, Vector3::unit_z(), Vector3::zero())]; + renderer + .transform_tex_coords( + &mut vertices, + Vector3::zero(), + face, + Vector3 { + x: 1.0, + y: 1.0, + z: 1.0, + }, + ) + .expect("default texture transform"); + assert!((vertices[0].tex_coord.x - 0.1).abs() < 1.0e-6); + assert!((vertices[0].tex_coord.y - 1.05).abs() < 1.0e-6); + + let mut planar = PrimitiveTextureEntryFace::new(None).expect("planar face"); + planar.set_tex_map_type(MappingType::Planar); + let mut vertices = vec![vertex( + 99.0, + 99.0, + Vector3::unit_z(), + Vector3 { + x: 0.25, + y: -0.25, + z: 0.5, + }, + )]; + renderer + .transform_tex_coords( + &mut vertices, + Vector3::zero(), + planar, + Vector3 { + x: 2.0, + y: 4.0, + z: 1.0, + }, + ) + .expect("planar texture transform"); + assert!((vertices[0].tex_coord.x - 1.5).abs() < 1.0e-6); + assert!((vertices[0].tex_coord.y - -1.5).abs() < 1.0e-6); +} + +#[test] +fn simple_renderer_generates_deterministic_sculpt_geometry() { + let mut prim = box_primitive(); + let mut sculpt = PrimitiveSculptData::new_with_constructor().expect("sculpt metadata"); + sculpt.set_type_(SculptType::Plane); + prim.sculpt = Some(sculpt); + let renderer = SimpleRenderer::new().expect("renderer"); + let first = renderer + .generate_simple_sculpt_mesh(prim.clone(), sculpt_image(), DetailLevel::Low) + .expect("first sculpt mesh"); + let second = renderer + .generate_simple_sculpt_mesh(prim, sculpt_image(), DetailLevel::Low) + .expect("second sculpt mesh"); + assert_valid_mesh(&first); + assert_eq!(mesh_hash(&first), mesh_hash(&second)); +} + +#[test] +fn simple_renderer_trait_dispatch_returns_geometry() { + let renderer = SimpleRenderer::new().expect("renderer"); + let mesh = IRendering::generate_simple_mesh(&renderer, box_primitive(), DetailLevel::Low) + .expect("trait call") + .expect("renderer returned geometry"); + assert_valid_mesh(&mesh); +} + +#[test] +fn simple_renderer_reports_source_context_for_invalid_and_mesh_sculpt_inputs() { + let renderer = SimpleRenderer::new().expect("renderer"); + let source = uuid("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + let mut invalid = box_primitive(); + invalid.id = source; + invalid.prim_data.path_end = f32::NAN; + assert!(matches!( + renderer.generate_simple_mesh(invalid, DetailLevel::High), + Err(Error::Rendering { + source: actual, + context: "non-finite primitive construction data" + }) if actual == source + )); + + let mut mesh_sculpt = box_primitive(); + mesh_sculpt.id = source; + let mut sculpt = PrimitiveSculptData::new_with_constructor().expect("sculpt metadata"); + sculpt.set_type_(SculptType::Mesh); + mesh_sculpt.sculpt = Some(sculpt); + assert!(matches!( + renderer.generate_simple_sculpt_mesh( + mesh_sculpt, + sculpt_image(), + DetailLevel::High, + ), + Err(Error::Rendering { + source: actual, + context: "mesh sculpt requires a decoded mesh asset" + }) if actual == source + )); +} + +#[test] +fn simple_renderer_rejects_non_finite_texture_inputs() { + let renderer = SimpleRenderer::new().expect("renderer"); + let mut face = PrimitiveTextureEntryFace::new(None).expect("texture face"); + face.set_repeat_u(f32::INFINITY); + let mut vertices = vec![vertex(0.0, 0.0, Vector3::unit_z(), Vector3::zero())]; + assert_eq!( + renderer.transform_tex_coords( + &mut vertices, + Vector3::zero(), + face, + Vector3 { + x: 1.0, + y: 1.0, + z: 1.0 + }, + ), + Err(Error::Argument) ); } diff --git a/tools/check_milestone_10_issue_76.py b/tools/check_milestone_10_issue_76.py new file mode 100644 index 0000000..fd1bd2e --- /dev/null +++ b/tools/check_milestone_10_issue_76.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Audit issue 76's native SimpleRenderer 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-simple" / "src" / "simple_renderer.rs" +GENERATED = ROOT / "crates" / "libremetaverse-rendering-simple" / "src" / "generated.rs" +MANIFEST = ROOT / "crates" / "libremetaverse-rendering-simple" / "Cargo.toml" +COMPAT = ROOT / "tests" / "compat" / "tests" / "rendering_shims.rs" +REFERENCE = ( + ROOT + / "crates" + / "libremetaverse-rendering-simple" + / "tests" + / "reference_pipeline.rs" +) +DOC = ROOT / "crates" / "libremetaverse-rendering-simple" / "README.md" +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.SimpleRenderer" +MEMBERS = { + "M:LibreMetaverse.Rendering.SimpleRenderer.#ctor", + "M:LibreMetaverse.Rendering.SimpleRenderer.GenerateFacetedMesh(LibreMetaverse.Primitive,LibreMetaverse.Rendering.DetailLevel)", + "M:LibreMetaverse.Rendering.SimpleRenderer.GenerateFacetedSculptMesh(LibreMetaverse.Primitive,LibreMetaverse.Imaging.ManagedImage,LibreMetaverse.Rendering.DetailLevel)", + "M:LibreMetaverse.Rendering.SimpleRenderer.GenerateSimpleMesh(LibreMetaverse.Primitive,LibreMetaverse.Rendering.DetailLevel)", + "M:LibreMetaverse.Rendering.SimpleRenderer.GenerateSimpleSculptMesh(LibreMetaverse.Primitive,LibreMetaverse.Imaging.ManagedImage,LibreMetaverse.Rendering.DetailLevel)", + "M:LibreMetaverse.Rendering.SimpleRenderer.TransformTexCoords(System.Collections.Generic.List{LibreMetaverse.Rendering.Vertex},LibreMetaverse.Vector3,LibreMetaverse.Primitive.TextureEntryFace,LibreMetaverse.Vector3)", +} + + +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::simple_renderer::SimpleRenderer" + ): + raise SystemExit("issue 76 SimpleRenderer native declaration is missing") + missing = sorted(MEMBERS - set(generate_api_shims.NATIVE_MEMBER_BODIES)) + if missing: + raise SystemExit("issue 76 native members missing: " + ", ".join(missing)) + for source in (SOURCE, GENERATED): + if STUB_RE.search(source.read_text()): + raise SystemExit(f"issue 76 owned Rust stubs remain in {source.name}") + + require_markers(SOURCE, ( + "impl IRendering for SimpleRenderer", "PrimMesh", "SculptMesh", + "MAX_FACE_VERTICES", "checked_add", "u16::try_from", "checked_vertex", + "texture_for_face", "MappingType::Planar", "Error::Rendering", + "mesh sculpt requires a decoded mesh asset", + )) + manifest = MANIFEST.read_text() + if "libremetaverse-prim-mesher" not in manifest: + raise SystemExit("issue 76 must depend on the checked PrimMesher crate") + forbidden = ("skia", "voice", "libremetaverse-rlv", "libremetaverse-lsl") + leaked = [name for name in forbidden if name in manifest.lower()] + if leaked: + raise SystemExit("issue 76 dependency boundary leaked: " + ", ".join(leaked)) + + require_markers(COMPAT, ( + "matches_golden_mesh", "0x3d2b_10e3_7da0_3d2e", + "maps_default_and_per_face_texture_metadata", "transforms_default_and_planar", + "generates_deterministic_sculpt_geometry", "trait_dispatch_returns_geometry", + "reports_source_context", "rejects_non_finite_texture_inputs", + )) + require_markers(REFERENCE, ( + "every_profile_path_and_lod_produces_checked_geometry", + "ProfileCurve::RightTriangle", "PathCurve::Flexible", "DetailLevel::Highest", + "every_sculpt_topology_produces_checked_geometry_at_each_lod", + "SculptType::Cylinder", "SculptType::Sphere", "SculptType::Torus", + )) + require_markers(DOC, ( + "all four `DetailLevel`", "65,536", "Error::Rendering", + "source primitive UUID", "MeshFoundry", "prim-local coordinates", + )) + require_markers(WORKFLOW, ("python3 tools/check_milestone_10_issue_76.py",)) + print( + "issue 76 audit: native prim/sculpt conversion, face metadata, texture mapping, " + "bounded errors, deterministic evidence, dependency isolation, and docs are present" + ) + + +if __name__ == "__main__": + main() diff --git a/tools/generate_api_shims.py b/tools/generate_api_shims.py index 41acd79..d133ebe 100644 --- a/tools/generate_api_shims.py +++ b/tools/generate_api_shims.py @@ -503,9 +503,21 @@ NATIVE_DECLARATIONS = { "T:LibreMetaverse.Settings": "crate::client_core::Settings", "T:LibreMetaverse.Simulator": "crate::network_manager::Simulator", "T:LibreMetaverse.StructuredData.OSDParser": "crate::model::OSDParser", + "T:LibreMetaverse.Rendering.SimpleRenderer": "crate::simple_renderer::SimpleRenderer", } NATIVE_MEMBER_BODIES = { + "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)", + "M:LibreMetaverse.Rendering.SimpleRenderer.GenerateFacetedSculptMesh(LibreMetaverse.Primitive,LibreMetaverse.Imaging.ManagedImage,LibreMetaverse.Rendering.DetailLevel)": + "self.native_generate_faceted_sculpt_mesh(prim, sculpt_texture, lod)", + "M:LibreMetaverse.Rendering.SimpleRenderer.GenerateSimpleMesh(LibreMetaverse.Primitive,LibreMetaverse.Rendering.DetailLevel)": + "self.native_generate_simple_mesh(prim, lod)", + "M:LibreMetaverse.Rendering.SimpleRenderer.GenerateSimpleSculptMesh(LibreMetaverse.Primitive,LibreMetaverse.Imaging.ManagedImage,LibreMetaverse.Rendering.DetailLevel)": + "self.native_generate_simple_sculpt_mesh(prim, sculpt_texture, lod)", + "M:LibreMetaverse.Rendering.SimpleRenderer.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)", "F:LibreMetaverse.Assets.ArchiveConstants.ASSET_TYPE_TO_EXTENSION": "crate::asset_archive::asset_type_to_extension()", "F:LibreMetaverse.Assets.ArchiveConstants.EXTENSION_TO_ASSET_TYPE": diff --git a/tools/generate_rust_mapping.py b/tools/generate_rust_mapping.py index 20fb631..2221b22 100644 --- a/tools/generate_rust_mapping.py +++ b/tools/generate_rust_mapping.py @@ -1029,6 +1029,7 @@ def validate_generated_shims() -> None: "crate::transfers::", "crate::xml_codec::", "stored_visual_params", + "Self::native_", "self.native_", "left.native_equals(", )