diff --git a/.gitea/workflows/imaging-meshing.yml b/.gitea/workflows/imaging-meshing.yml new file mode 100644 index 0000000..80f0dfb --- /dev/null +++ b/.gitea/workflows/imaging-meshing.yml @@ -0,0 +1,48 @@ +name: Imaging and meshing gate + +on: + push: + pull_request: + +jobs: + native: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + env: + RUSTDOCFLAGS: -D warnings + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy,rustfmt + - name: Audit milestone ownership and feature isolation + run: python tools/check_milestone_06.py + - name: Test native imaging and meshing crates + run: | + cargo test -p libremetaverse-imaging + cargo test -p libremetaverse-imaging-skia --no-default-features + cargo test -p libremetaverse-prim-mesher + - name: Test reviewed milestone parity cases + run: | + cargo test -p libremetaverse-compat-tests --test imaging_meshing_semantics reviewed_managedimagetests_ + cargo test -p libremetaverse-compat-tests --test imaging_meshing_semantics reviewed_coordtests_ + cargo test -p libremetaverse-compat-tests --test imaging_meshing_semantics reviewed_quattests_ + cargo test -p libremetaverse-compat-tests --test imaging_meshing_semantics reviewed_primmeshtests_ + - name: Compile workspace tests and performance baselines + run: | + cargo test --workspace --no-run + cargo bench -p libremetaverse-imaging --bench image_pipeline --no-run + cargo bench -p libremetaverse-prim-mesher --bench meshing --no-run + - name: Lint and document milestone crates + run: | + cargo clippy -p libremetaverse-imaging -p libremetaverse-imaging-skia -p libremetaverse-prim-mesher --all-targets -- -D warnings + cargo doc -p libremetaverse-imaging -p libremetaverse-imaging-skia -p libremetaverse-prim-mesher --no-deps + - name: Verify formatting + if: runner.os == 'Linux' + run: cargo fmt --all -- --check diff --git a/Cargo.lock b/Cargo.lock index 9ea1d73..b72ac66 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -395,6 +395,7 @@ version = "0.0.1" dependencies = [ "libremetaverse-openjpeg", "libremetaverse-types", + "stats_alloc", ] [[package]] @@ -427,6 +428,7 @@ version = "0.0.1" dependencies = [ "libremetaverse-imaging", "libremetaverse-types", + "stats_alloc", ] [[package]] diff --git a/README.md b/README.md index 919e2d4..4d313e7 100644 --- a/README.md +++ b/README.md @@ -172,5 +172,9 @@ indexing deduplicates vertices per prim face, while bounded Wavefront OBJ ingestion preserves object/group output and position/UV/normal associations. Malformed dimensions, channel layouts, topology, and indices return typed errors without partial meshes or panics. +The milestone-owned feature matrix, rendering-data contract, reproducible +audit, cross-platform CI commands, bounds, and large-input performance +baselines are documented in +[`docs/imaging-meshing.md`](docs/imaging-meshing.md). The controlled audit aggregates every expected failure by standardized C# member ID and rejects unrelated fixture, assertion, compile, or symbol errors. diff --git a/crates/libremetaverse-imaging/Cargo.toml b/crates/libremetaverse-imaging/Cargo.toml index b11d743..8a8f050 100644 --- a/crates/libremetaverse-imaging/Cargo.toml +++ b/crates/libremetaverse-imaging/Cargo.toml @@ -15,5 +15,12 @@ jpeg2000 = ["dep:libremetaverse-openjpeg"] libremetaverse-types = { path = "../libremetaverse-types" } libremetaverse-openjpeg = { path = "../libremetaverse-openjpeg", optional = true } +[dev-dependencies] +stats_alloc = "0.1.10" + +[[bench]] +name = "image_pipeline" +harness = false + [lints] workspace = true diff --git a/crates/libremetaverse-imaging/benches/image_pipeline.rs b/crates/libremetaverse-imaging/benches/image_pipeline.rs new file mode 100644 index 0000000..18eb00b --- /dev/null +++ b/crates/libremetaverse-imaging/benches/image_pipeline.rs @@ -0,0 +1,48 @@ +use std::alloc::System; +use std::hint::black_box; +use std::time::Instant; + +use libremetaverse_imaging::{ManagedImage, ManagedImageImageChannels}; +use stats_alloc::{INSTRUMENTED_SYSTEM, Region, StatsAlloc}; + +#[global_allocator] +static ALLOCATOR: &StatsAlloc = &INSTRUMENTED_SYSTEM; + +fn main() { + const INPUT_SIDE: i32 = 2_048; + const OUTPUT_SIDE: i32 = 1_024; + + let channels = ManagedImageImageChannels( + ManagedImageImageChannels::COLOR.0 | ManagedImageImageChannels::ALPHA.0, + ); + let mut source = ManagedImage::new(INPUT_SIDE, INPUT_SIDE, channels) + .expect("large benchmark image must fit the documented pixel bound"); + for (index, red) in source.red.iter_mut().enumerate() { + *red = u8::try_from(index & 0xff).expect("masked sample"); + } + source.green.fill(97); + source.blue.fill(193); + source.alpha.fill(255); + + // Warm the validation path before measuring the owned clone, resize, and export pipeline. + source.validate().expect("valid benchmark image"); + let allocation_region = Region::new(ALLOCATOR); + let started = Instant::now(); + let mut resized = source.clone().expect("bounded image clone"); + resized + .resize_bilinear(OUTPUT_SIDE, OUTPUT_SIDE) + .expect("bounded bilinear resize"); + let rgba = resized.export_raw().expect("bounded RGBA export"); + let elapsed = started.elapsed(); + let allocation_stats = allocation_region.change(); + + assert_eq!((resized.width, resized.height), (OUTPUT_SIDE, OUTPUT_SIDE)); + assert_eq!(rgba.len(), 4 * OUTPUT_SIDE as usize * OUTPUT_SIDE as usize); + black_box((resized, rgba)); + println!( + "image pipeline: {INPUT_SIDE}x{INPUT_SIDE} -> {OUTPUT_SIDE}x{OUTPUT_SIDE} in {elapsed:?}, {} allocations, {} reallocations, {} bytes allocated", + allocation_stats.allocations, + allocation_stats.reallocations, + allocation_stats.bytes_allocated, + ); +} diff --git a/crates/libremetaverse-prim-mesher/Cargo.toml b/crates/libremetaverse-prim-mesher/Cargo.toml index 9dc6bf3..d966b53 100644 --- a/crates/libremetaverse-prim-mesher/Cargo.toml +++ b/crates/libremetaverse-prim-mesher/Cargo.toml @@ -11,5 +11,12 @@ description = "Primitive meshing shims for the MetaCrate LibreMetaverse rewrite" libremetaverse-imaging = { path = "../libremetaverse-imaging" } libremetaverse-types = { path = "../libremetaverse-types" } +[dev-dependencies] +stats_alloc = "0.1.10" + +[[bench]] +name = "meshing" +harness = false + [lints] workspace = true diff --git a/crates/libremetaverse-prim-mesher/benches/meshing.rs b/crates/libremetaverse-prim-mesher/benches/meshing.rs new file mode 100644 index 0000000..ae5d092 --- /dev/null +++ b/crates/libremetaverse-prim-mesher/benches/meshing.rs @@ -0,0 +1,54 @@ +use std::alloc::System; +use std::hint::black_box; +use std::time::Instant; + +use libremetaverse_prim_mesher::{PathType, PrimMesh}; +use stats_alloc::{INSTRUMENTED_SYSTEM, Region, StatsAlloc}; + +#[global_allocator] +static ALLOCATOR: &StatsAlloc = &INSTRUMENTED_SYSTEM; + +fn main() { + let mut mesh = PrimMesh::new(128, 0.03, 0.97, 0.35, 64) + .expect("large benchmark profile must fit the documented bounds"); + mesh.viewer_mode = true; + mesh.calc_vertex_normals = true; + mesh.path_cut_begin = 0.02; + mesh.path_cut_end = 0.98; + mesh.hole_size_x = 0.7; + mesh.hole_size_y = 0.3; + mesh.twist_begin = -45; + mesh.twist_end = 270; + mesh.taper_x = 0.15; + mesh.taper_y = -0.1; + mesh.skew = 0.2; + mesh.radius = 0.1; + mesh.revolutions = 1.5; + mesh.steps_per_revolution = 96; + + let allocation_region = Region::new(ALLOCATOR); + let started = Instant::now(); + mesh.extrude(PathType::Circular) + .expect("bounded circular extrusion"); + let indexer = mesh + .get_vertex_indexer() + .expect("checked viewer indexing") + .expect("viewer-mode indexer"); + let elapsed = started.elapsed(); + let allocation_stats = allocation_region.change(); + + assert!(!mesh.coords.is_empty()); + assert!(!mesh.faces.is_empty()); + assert!(!mesh.viewer_faces.is_empty()); + assert!(mesh.viewer_faces.len() <= mesh.faces.len()); + black_box((&mesh, &indexer)); + println!( + "meshing pipeline: {} coordinates, {} triangles, {} prim faces in {elapsed:?}, {} allocations, {} reallocations, {} bytes allocated", + mesh.coords.len(), + mesh.faces.len(), + indexer.num_prim_faces, + allocation_stats.allocations, + allocation_stats.reallocations, + allocation_stats.bytes_allocated, + ); +} diff --git a/crates/libremetaverse-prim-mesher/tests/rendering_contract.rs b/crates/libremetaverse-prim-mesher/tests/rendering_contract.rs new file mode 100644 index 0000000..ef7ccc5 --- /dev/null +++ b/crates/libremetaverse-prim-mesher/tests/rendering_contract.rs @@ -0,0 +1,111 @@ +//! Integration contracts for the data consumed by the future rendering pipeline. + +use libremetaverse_imaging::{ManagedImage, ManagedImageImageChannels}; +use libremetaverse_prim_mesher::{ + PathType, PrimMesh, SculptMesh, SculptMeshSculptType, ViewerPolygon, ViewerVertex, +}; + +fn assert_rendering_vertices(vertices: &[ViewerVertex], polygons: &[ViewerPolygon]) { + assert!(vertices.len() <= usize::from(u16::MAX) + 1); + for vertex in vertices { + assert!(vertex.v.x.is_finite() && vertex.v.y.is_finite() && vertex.v.z.is_finite()); + assert!(vertex.n.x.is_finite() && vertex.n.y.is_finite() && vertex.n.z.is_finite()); + assert!(vertex.uv.u.is_finite() && vertex.uv.v.is_finite()); + } + for polygon in polygons { + for index in [polygon.v1, polygon.v2, polygon.v3] { + let index = usize::try_from(index).expect("non-negative viewer index"); + assert!(index < vertices.len()); + u16::try_from(index).expect("MeshFoundry-facing index fits u16"); + } + } +} + +#[test] +fn prim_mesh_viewer_groups_are_rendering_ready() { + let mut mesh = PrimMesh::new(24, 0.05, 0.95, 0.25, 12).expect("checked profile"); + mesh.viewer_mode = true; + mesh.calc_vertex_normals = true; + mesh.path_cut_begin = 0.03; + mesh.path_cut_end = 0.96; + mesh.twist_begin = -30; + mesh.twist_end = 120; + mesh.taper_x = 0.1; + mesh.skew = 0.15; + mesh.revolutions = 1.25; + mesh.steps_per_revolution = 48; + mesh.extrude(PathType::Circular) + .expect("checked viewer extrusion"); + + let indexer = mesh + .get_vertex_indexer() + .expect("checked viewer indexing") + .expect("viewer mode produces an indexer"); + assert_eq!(indexer.num_prim_faces, mesh.num_prim_faces); + assert_eq!(indexer.viewer_vertices.len(), indexer.viewer_polygons.len()); + for (vertices, polygons) in indexer + .viewer_vertices + .iter() + .zip(indexer.viewer_polygons.iter()) + { + assert_rendering_vertices( + vertices, + polygons + .as_deref() + .expect("allocated prim-face polygon group"), + ); + } +} + +#[test] +fn sculpt_lods_and_topologies_keep_rendering_indices_and_attributes_aligned() { + let mut image = ManagedImage::new(128, 128, ManagedImageImageChannels::COLOR) + .expect("bounded sculpt fixture"); + for index in 0..image.red.len() { + image.red[index] = u8::try_from(index & 0xff).expect("masked sample"); + image.green[index] = u8::try_from((index / 128) & 0xff).expect("masked sample"); + image.blue[index] = u8::try_from((index * 17) & 0xff).expect("masked sample"); + } + + for lod in [8, 16, 32] { + for topology in [ + SculptMeshSculptType::Plane, + SculptMeshSculptType::Sphere, + SculptMeshSculptType::Torus, + SculptMeshSculptType::Cylinder, + ] { + let mesh = + SculptMesh::new_with_managed_image_sculpt_type_int32_boolean_boolean_boolean( + image.clone().expect("independent image fixture"), + topology, + lod, + true, + true, + false, + ) + .expect("checked sculpt mesh"); + assert_eq!(mesh.coords.len(), mesh.normals.len()); + assert_eq!(mesh.coords.len(), mesh.uvs.len()); + assert_eq!(mesh.faces.len(), mesh.viewer_faces.len()); + assert!(mesh.coords.len() <= usize::from(u16::MAX) + 1); + for face in &mesh.faces { + for index in [face.v1, face.v2, face.v3] { + let index = usize::try_from(index).expect("non-negative sculpt index"); + assert!(index < mesh.coords.len()); + u16::try_from(index).expect("MeshFoundry-facing sculpt index fits u16"); + } + } + for viewer in &mesh.viewer_faces { + for coord in [viewer.v1, viewer.v2, viewer.v3] { + assert!(coord.x.is_finite() && coord.y.is_finite() && coord.z.is_finite()); + } + for normal in [viewer.n1, viewer.n2, viewer.n3] { + assert!(normal.x.is_finite() && normal.y.is_finite() && normal.z.is_finite()); + } + for uv in [viewer.uv1, viewer.uv2, viewer.uv3] { + assert!(uv.u.is_finite() && uv.v.is_finite()); + } + } + } + } +} diff --git a/docs/imaging-meshing.md b/docs/imaging-meshing.md new file mode 100644 index 0000000..3ea1f69 --- /dev/null +++ b/docs/imaging-meshing.md @@ -0,0 +1,67 @@ +# Imaging and meshing integration + +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. + +## Feature boundary + +| Surface | Default build | Opt-in build | Native dependency | +| --- | --- | --- | --- | +| Managed images and TGA | enabled | n/a | none | +| JPEG 2000 | disabled | `libremetaverse-imaging/jpeg2000` | OpenJPEG 2.5.x | +| BMP/GIF/ICO/JPEG/PNG/WBMP/WebP | disabled | `libremetaverse-imaging-skia/skia` | Skia | +| Prim and sculpt meshing, OBJ ingestion | enabled | n/a | none | + +OpenJPEG is excluded from the default workspace and is reachable only through +the optional dependency. Skia is an optional, target-specific dependency; +Linux and Windows use the Vulkan-capable binary configuration while macOS uses +the corresponding non-Vulkan binary configuration. Dedicated workflows build +both optional features on Linux, macOS, and Windows. The default +`imaging-meshing` workflow proves that no native codec is needed for the owned +Rust and translated compatibility tests. + +## Bounds and rendering contract + +Decoded images are limited to 4,096 by 4,096 pixels and encoded inputs to 64 +MiB. Channel sizes, resize products, codec buffers, sculpt-map dimensions, +profile/path sizes, mesh vertex counts, face indices, viewer groups, and OBJ +indices use checked arithmetic before allocation or indexing. Malformed input +returns a typed error. + +The rendering-facing integration tests verify that PrimMesh and SculptMesh +produce finite positions, normals, and UVs; aligned face and viewer-face data; +preserved prim-face grouping; in-range indices; and the 16-bit index range used +by the pinned MeshFoundry reference at every supported sculpt topology and LOD. + +## Reproducible gates + +Run the fixed ownership and feature audit with: + +```console +python3 tools/check_milestone_06.py +``` + +The audit rejects owned Rust stubs, feature leakage, missing platform jobs, +missing performance targets, or drift in the 48 reviewed ManagedImage, +Coord/Quat, and PrimMesh compatibility cases. The cross-platform workflow also +runs each reviewed group explicitly, native crate tests, workspace test +compilation, Clippy with warnings denied, rustdoc with warnings denied, and +benchmark compilation. + +The focused performance baselines are executable Rust programs rather than +wall-clock assertions, so slower shared runners do not weaken or destabilize +correctness tests: + +```console +cargo bench -p libremetaverse-imaging --bench image_pipeline +cargo bench -p libremetaverse-prim-mesher --bench meshing +``` + +The image baseline clones, bilinearly resizes, and exports a 2,048-square RGBA +image. The meshing baseline builds and indexes a high-detail cut, hollow, +twisted circular extrusion. Both report elapsed time and allocation counts so +regressions can be compared using the same inputs. diff --git a/tests/red-suite-baseline.json b/tests/red-suite-baseline.json index d2ed0a6..2178008 100644 --- a/tests/red-suite-baseline.json +++ b/tests/red-suite-baseline.json @@ -146,5 +146,5 @@ "LibreMetaverse.Tests/XmlLLSDTests.cs::XmlSDTests.DeserializeUUID::test", "LibreMetaverse.Tests/XmlLLSDTests.cs::XmlSDTests.DeserializeUndef::test" ], - "support_passes": 132 + "support_passes": 134 } diff --git a/tools/check_milestone_06.py b/tools/check_milestone_06.py new file mode 100644 index 0000000..3712148 --- /dev/null +++ b/tools/check_milestone_06.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Audit the fixed ownership and feature boundary for milestone 06.""" + +from __future__ import annotations + +import json +from pathlib import Path +import re +import tomllib + + +ROOT = Path(__file__).resolve().parents[1] +OWNED_CRATES = ( + ROOT / "crates" / "libremetaverse-imaging", + ROOT / "crates" / "libremetaverse-imaging-skia", + ROOT / "crates" / "libremetaverse-prim-mesher", +) +OWNED_FIXTURES = { + "LibreMetaverse.Tests/ManagedImageTests.cs": 3, + "LibreMetaverse.Rendering.Tests/PrimMesher/CoordQuatTests.cs": 18, + "LibreMetaverse.Rendering.Tests/PrimMesher/PrimMeshTests.cs": 27, +} +STUB_RE = re.compile(r"\b(?:not_implemented|unimplemented_api)\b|\b(?:todo|unimplemented)!\s*\(") + + +def load_toml(path: Path) -> dict: + with path.open("rb") as stream: + return tomllib.load(stream) + + +def check_owned_rust() -> None: + offenders: list[str] = [] + for crate in OWNED_CRATES: + for path in sorted((crate / "src").rglob("*.rs")): + if STUB_RE.search(path.read_text()): + offenders.append(path.relative_to(ROOT).as_posix()) + if offenders: + raise SystemExit("milestone-owned Rust stubs remain: " + ", ".join(offenders)) + + +def check_features() -> None: + imaging = load_toml(OWNED_CRATES[0] / "Cargo.toml") + if imaging["features"].get("default") != []: + raise SystemExit("imaging default features must stay empty") + if imaging["features"].get("jpeg2000") != ["dep:libremetaverse-openjpeg"]: + raise SystemExit("JPEG 2000 must stay isolated behind its optional feature") + if not imaging["dependencies"]["libremetaverse-openjpeg"].get("optional"): + raise SystemExit("the OpenJPEG adapter dependency must stay optional") + + skia = load_toml(OWNED_CRATES[1] / "Cargo.toml") + if skia["features"].get("default") != []: + raise SystemExit("Skia adapter default features must stay empty") + if skia["features"].get("skia") != ["dep:skia-safe"]: + raise SystemExit("Skia must stay isolated behind its optional feature") + targets = skia.get("target", {}).values() + skia_dependencies = [ + target.get("dependencies", {}).get("skia-safe") for target in targets + ] + if not skia_dependencies or any( + dependency is None or not dependency.get("optional") + for dependency in skia_dependencies + ): + raise SystemExit("every platform-specific Skia dependency must stay optional") + + workspace = load_toml(ROOT / "Cargo.toml") + if "crates/libremetaverse-openjpeg" not in workspace["workspace"].get("exclude", []): + raise SystemExit("the native OpenJPEG adapter must stay outside default workspace builds") + + +def check_parity() -> None: + catalog = json.loads((ROOT / "tests" / "upstream-tests.json").read_text()) + grouped: dict[str, list[dict]] = {source: [] for source in OWNED_FIXTURES} + for case in catalog["tests"]: + if case["source"] in grouped: + grouped[case["source"]].append(case) + for source, expected in OWNED_FIXTURES.items(): + cases = grouped[source] + if len(cases) != expected: + raise SystemExit(f"{source}: expected {expected} reviewed cases, found {len(cases)}") + incomplete = [ + case["id"] + for case in cases + if case["status"] != "translated" + or case.get("semantic_review") != "reviewed" + or not (ROOT / case["rust_file"]).is_file() + ] + if incomplete: + raise SystemExit(f"{source}: incomplete parity cases: " + ", ".join(incomplete)) + + +def check_benchmarks_and_ci() -> None: + expected_benches = { + OWNED_CRATES[0]: "image_pipeline", + OWNED_CRATES[2]: "meshing", + } + for crate, name in expected_benches.items(): + manifest = load_toml(crate / "Cargo.toml") + benches = {entry["name"]: entry for entry in manifest.get("bench", [])} + if benches.get(name, {}).get("harness") is not False: + raise SystemExit(f"missing harness-free {name} benchmark declaration") + if not (crate / "benches" / f"{name}.rs").is_file(): + raise SystemExit(f"missing {name} benchmark source") + + workflows = { + "imaging-meshing": ROOT / ".gitea" / "workflows" / "imaging-meshing.yml", + "jpeg2000": ROOT / ".gitea" / "workflows" / "jpeg2000.yml", + "skia": ROOT / ".gitea" / "workflows" / "skia.yml", + } + for name, path in workflows.items(): + if not path.is_file(): + raise SystemExit(f"missing {name} native-feature workflow") + text = path.read_text().lower() + missing = [platform for platform in ("linux", "macos", "windows") if platform not in text] + if missing: + raise SystemExit(f"{name} workflow lacks platforms: " + ", ".join(missing)) + + +def main() -> None: + check_owned_rust() + check_features() + check_parity() + check_benchmarks_and_ci() + print( + "milestone 06 audit: owned Rust has no stubs; 48 reviewed parity cases, " + "optional native features, benchmarks, and cross-platform workflows are complete" + ) + + +if __name__ == "__main__": + main()