From 2c806d80ff7405ac5271cd65cdb6a2b96b887a6a Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sun, 9 Aug 2026 02:11:02 +0000 Subject: [PATCH] Implement ManagedImage and codec abstraction (#38) --- README.md | 7 + api/SHIM-COVERAGE.md | 2 +- .../src/generated.rs | 1 - crates/libremetaverse-imaging-skia/src/lib.rs | 12 + .../libremetaverse-imaging/src/generated.rs | 111 +-- crates/libremetaverse-imaging/src/lib.rs | 2 + .../src/managed_image.rs | 793 ++++++++++++++++++ tests/red-suite-baseline.json | 5 +- tools/generate_api_shims.py | 9 +- 9 files changed, 850 insertions(+), 92 deletions(-) create mode 100644 crates/libremetaverse-imaging/src/managed_image.rs diff --git a/README.md b/README.md index 11ae4a2..4a19c3f 100644 --- a/README.md +++ b/README.md @@ -122,5 +122,12 @@ five parsers without an external fuzzing runtime. They cover truncation, tags, lengths, payloads, delimiters, nesting, entity rejection, contextual errors, and the parser resource limits. Every encoder sorts map keys where necessary, so serialization is deterministic across platforms. +Milestone 06 starts with a native `ManagedImage`: top-left-origin, +byte-per-sample planar storage has an explicit width-byte row stride, with gray +stored in the red plane and optional color, alpha, and bump planes. Checked +constructors and resizers cap decoded images at 4096 by 4096 worth of pixels, +codec adapters cap buffered encoded input at 64 MiB, and canonical +interleaving accepts explicit row strides without exposing codec-library types +through the core abstraction. The controlled audit aggregates every expected failure by standardized C# member ID and rejects unrelated fixture, assertion, compile, or symbol errors. diff --git a/api/SHIM-COVERAGE.md b/api/SHIM-COVERAGE.md index 7e696ef..9296d08 100644 --- a/api/SHIM-COVERAGE.md +++ b/api/SHIM-COVERAGE.md @@ -5,7 +5,7 @@ Generated by `python3 tools/generate_api_shims.py`; do not edit by hand. | Assembly | Types | Members | Status | |---|---:|---:|---| | `LibreMetaverse` | 2,711 | 27,281 | callable failure-only shim | -| `LibreMetaverse.Imaging.Abstractions` | 3 | 20 | callable failure-only shim | +| `LibreMetaverse.Imaging.Abstractions` | 3 | 20 | native implementation: 3 types / 20 members; no generated shims remain | | `LibreMetaverse.Imaging.Skia` | 1 | 3 | callable failure-only shim | | `LibreMetaverse.LslTools` | 164 | 768 | callable failure-only shim | | `LibreMetaverse.PrimMesher` | 17 | 207 | callable failure-only shim | diff --git a/crates/libremetaverse-imaging-skia/src/generated.rs b/crates/libremetaverse-imaging-skia/src/generated.rs index 9d816f2..7122e4b 100644 --- a/crates/libremetaverse-imaging-skia/src/generated.rs +++ b/crates/libremetaverse-imaging-skia/src/generated.rs @@ -32,4 +32,3 @@ impl SkiaTextureCodec { ) } } -impl libremetaverse_imaging::ITextureCodec for SkiaTextureCodec {} diff --git a/crates/libremetaverse-imaging-skia/src/lib.rs b/crates/libremetaverse-imaging-skia/src/lib.rs index 1ec9e0e..86956fa 100644 --- a/crates/libremetaverse-imaging-skia/src/lib.rs +++ b/crates/libremetaverse-imaging-skia/src/lib.rs @@ -12,3 +12,15 @@ mod generated; pub use generated::*; pub use libremetaverse_imaging as imaging; pub use libremetaverse_types::Error; + +// The native codec abstraction requires an explicit implementation. Until the +// optional Skia adapter is ported, forward through its cataloged typed-failure +// method rather than manufacturing an image or silently accepting input. +impl libremetaverse_imaging::ITextureCodec for SkiaTextureCodec { + fn decode( + &self, + stream: Box, + ) -> Result { + Self::decode(self, stream) + } +} diff --git a/crates/libremetaverse-imaging/src/generated.rs b/crates/libremetaverse-imaging/src/generated.rs index 6f7387b..99083f7 100644 --- a/crates/libremetaverse-imaging/src/generated.rs +++ b/crates/libremetaverse-imaging/src/generated.rs @@ -6,95 +6,30 @@ #![allow(non_snake_case)] /// C# type: `T:LibreMetaverse.Imaging.ITextureCodec`. -pub trait ITextureCodec { - /// C# member: `M:LibreMetaverse.Imaging.ITextureCodec.Decode(System.IO.Stream)`. - fn decode( - &self, - stream: Box, - ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Imaging.ITextureCodec.Decode(System.IO.Stream)", - ) - } -} +/// C# member: `M:LibreMetaverse.Imaging.ITextureCodec.Decode(System.IO.Stream)`. +pub use crate::managed_image::ITextureCodec; /// C# type: `T:LibreMetaverse.Imaging.ManagedImage`. -pub struct ManagedImage { - /// C# member: `F:LibreMetaverse.Imaging.ManagedImage.Alpha`. - pub alpha: Vec, - /// C# member: `F:LibreMetaverse.Imaging.ManagedImage.Blue`. - pub blue: Vec, - /// C# member: `F:LibreMetaverse.Imaging.ManagedImage.Bump`. - pub bump: Vec, - /// C# member: `F:LibreMetaverse.Imaging.ManagedImage.Channels`. - pub channels: libremetaverse_imaging::ManagedImageImageChannels, - /// C# member: `F:LibreMetaverse.Imaging.ManagedImage.Green`. - pub green: Vec, - /// C# member: `F:LibreMetaverse.Imaging.ManagedImage.Height`. - pub height: i32, - /// C# member: `F:LibreMetaverse.Imaging.ManagedImage.Red`. - pub red: Vec, - /// C# member: `F:LibreMetaverse.Imaging.ManagedImage.Width`. - pub width: i32, - data: Vec, -} -impl ManagedImage { - /// C# member: `M:LibreMetaverse.Imaging.ManagedImage.#ctor(System.Int32,System.Int32,LibreMetaverse.Imaging.ManagedImage.ImageChannels)`. - pub fn new( - width: i32, - height: i32, - channels: libremetaverse_imaging::ManagedImageImageChannels, - ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Imaging.ManagedImage.#ctor(System.Int32,System.Int32,LibreMetaverse.Imaging.ManagedImage.ImageChannels)", - ) - } - /// C# member: `M:LibreMetaverse.Imaging.ManagedImage.Clear`. - pub fn clear(&mut self) -> Result<(), crate::Error> { - libremetaverse_types::not_implemented("M:LibreMetaverse.Imaging.ManagedImage.Clear") - } - /// C# member: `M:LibreMetaverse.Imaging.ManagedImage.Clone`. - pub fn clone(&self) -> Result { - libremetaverse_types::not_implemented("M:LibreMetaverse.Imaging.ManagedImage.Clone") - } - /// C# member: `M:LibreMetaverse.Imaging.ManagedImage.ConvertChannels(LibreMetaverse.Imaging.ManagedImage.ImageChannels)`. - pub fn convert_channels( - &mut self, - channels: libremetaverse_imaging::ManagedImageImageChannels, - ) -> Result<(), crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Imaging.ManagedImage.ConvertChannels(LibreMetaverse.Imaging.ManagedImage.ImageChannels)", - ) - } - /// C# member: `M:LibreMetaverse.Imaging.ManagedImage.ExportRaw`. - pub fn export_raw(&self) -> Result, crate::Error> { - libremetaverse_types::not_implemented("M:LibreMetaverse.Imaging.ManagedImage.ExportRaw") - } - /// C# member: `M:LibreMetaverse.Imaging.ManagedImage.ResizeBilinear(System.Int32,System.Int32)`. - pub fn resize_bilinear(&mut self, width: i32, height: i32) -> Result<(), crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Imaging.ManagedImage.ResizeBilinear(System.Int32,System.Int32)", - ) - } - /// C# member: `M:LibreMetaverse.Imaging.ManagedImage.ResizeNearestNeighbor(System.Int32,System.Int32)`. - pub fn resize_nearest_neighbor(&mut self, width: i32, height: i32) -> Result<(), crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Imaging.ManagedImage.ResizeNearestNeighbor(System.Int32,System.Int32)", - ) - } -} +/// C# member: `F:LibreMetaverse.Imaging.ManagedImage.Alpha`. +/// C# member: `F:LibreMetaverse.Imaging.ManagedImage.Blue`. +/// C# member: `F:LibreMetaverse.Imaging.ManagedImage.Bump`. +/// C# member: `F:LibreMetaverse.Imaging.ManagedImage.Channels`. +/// C# member: `F:LibreMetaverse.Imaging.ManagedImage.Green`. +/// C# member: `F:LibreMetaverse.Imaging.ManagedImage.Height`. +/// C# member: `F:LibreMetaverse.Imaging.ManagedImage.Red`. +/// C# member: `F:LibreMetaverse.Imaging.ManagedImage.Width`. +/// C# member: `M:LibreMetaverse.Imaging.ManagedImage.#ctor(System.Int32,System.Int32,LibreMetaverse.Imaging.ManagedImage.ImageChannels)`. +/// C# member: `M:LibreMetaverse.Imaging.ManagedImage.Clear`. +/// C# member: `M:LibreMetaverse.Imaging.ManagedImage.Clone`. +/// C# member: `M:LibreMetaverse.Imaging.ManagedImage.ConvertChannels(LibreMetaverse.Imaging.ManagedImage.ImageChannels)`. +/// C# member: `M:LibreMetaverse.Imaging.ManagedImage.ExportRaw`. +/// C# member: `M:LibreMetaverse.Imaging.ManagedImage.ResizeBilinear(System.Int32,System.Int32)`. +/// C# member: `M:LibreMetaverse.Imaging.ManagedImage.ResizeNearestNeighbor(System.Int32,System.Int32)`. +pub use crate::managed_image::ManagedImage; /// C# type: `T:LibreMetaverse.Imaging.ManagedImage.ImageChannels`. -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] -#[repr(transparent)] -pub struct ManagedImageImageChannels(pub i32); -impl ManagedImageImageChannels { - /// C# member: `F:LibreMetaverse.Imaging.ManagedImage.ImageChannels.Alpha`. - pub const ALPHA: Self = Self(4); - /// C# member: `F:LibreMetaverse.Imaging.ManagedImage.ImageChannels.Bump`. - pub const BUMP: Self = Self(8); - /// C# member: `F:LibreMetaverse.Imaging.ManagedImage.ImageChannels.Color`. - pub const COLOR: Self = Self(2); - /// C# member: `F:LibreMetaverse.Imaging.ManagedImage.ImageChannels.Gray`. - pub const GRAY: Self = Self(1); -} +/// C# member: `F:LibreMetaverse.Imaging.ManagedImage.ImageChannels.Alpha`. +/// C# member: `F:LibreMetaverse.Imaging.ManagedImage.ImageChannels.Bump`. +/// C# member: `F:LibreMetaverse.Imaging.ManagedImage.ImageChannels.Color`. +/// C# member: `F:LibreMetaverse.Imaging.ManagedImage.ImageChannels.Gray`. +pub use crate::managed_image::ManagedImageImageChannels; diff --git a/crates/libremetaverse-imaging/src/lib.rs b/crates/libremetaverse-imaging/src/lib.rs index 8ba1f2d..ef742a0 100644 --- a/crates/libremetaverse-imaging/src/lib.rs +++ b/crates/libremetaverse-imaging/src/lib.rs @@ -11,6 +11,8 @@ pub mod codec { } mod generated; +mod managed_image; pub use generated::*; pub use libremetaverse_types::Error; +pub use managed_image::{DEFAULT_MAX_ENCODED_BYTES, DEFAULT_MAX_PIXELS}; diff --git a/crates/libremetaverse-imaging/src/managed_image.rs b/crates/libremetaverse-imaging/src/managed_image.rs new file mode 100644 index 0000000..6c56767 --- /dev/null +++ b/crates/libremetaverse-imaging/src/managed_image.rs @@ -0,0 +1,793 @@ +//! Owned planar image storage and the codec abstraction boundary. + +use crate::Error; +use libremetaverse_types::compat::ReadWrite; +use std::ops::{BitAnd, BitOr, BitXor, Not}; + +/// Maximum number of decoded pixels accepted by [`ManagedImage`]. +/// +/// This permits a 4096 by 4096 texture while rejecting hostile dimensions +/// before any channel allocation occurs. +pub const DEFAULT_MAX_PIXELS: usize = 4096 * 4096; + +/// Maximum encoded input size codec adapters may buffer by default. +pub const DEFAULT_MAX_ENCODED_BYTES: usize = 64 * 1024 * 1024; + +const MAX_CHANNEL_BYTES: usize = DEFAULT_MAX_PIXELS * 5; + +/// Object-safe boundary for compressed-image decoders. +/// +/// Implementations must bound buffered input to [`DEFAULT_MAX_ENCODED_BYTES`], +/// decode through the checked [`ManagedImage`] constructors, and return a typed +/// error for malformed data. Native codec-specific types do not cross this +/// boundary. +pub trait ITextureCodec { + /// Decodes a stream positioned at the beginning of an encoded image. + /// + /// # Errors + /// + /// Returns a typed parse, argument, allocation, or I/O-class error when + /// the encoded stream cannot be decoded within the documented limits. + fn decode(&self, stream: Box) -> Result; +} + +/// Flags selecting the planar channels stored by [`ManagedImage`]. +/// +/// Unknown bits are retained for C# flag-enum compatibility. Gray and color +/// are both legal bits; when both are present, the reference constructor gives +/// gray storage precedence. +#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] +#[repr(transparent)] +pub struct ManagedImageImageChannels(pub i32); + +impl ManagedImageImageChannels { + /// One gray plane stored in [`ManagedImage::red`]. + pub const GRAY: Self = Self(1); + /// Three color planes stored in red, green, blue order. + pub const COLOR: Self = Self(2); + /// One alpha plane. + pub const ALPHA: Self = Self(4); + /// One bump plane. + pub const BUMP: Self = Self(8); + + /// Returns whether all bits in `other` are present. + #[must_use] + pub const fn contains(self, other: Self) -> bool { + self.0 & other.0 == other.0 + } +} + +impl BitAnd for ManagedImageImageChannels { + type Output = Self; + + fn bitand(self, rhs: Self) -> Self::Output { + Self(self.0 & rhs.0) + } +} + +impl BitOr for ManagedImageImageChannels { + type Output = Self; + + fn bitor(self, rhs: Self) -> Self::Output { + Self(self.0 | rhs.0) + } +} + +impl BitXor for ManagedImageImageChannels { + type Output = Self; + + fn bitxor(self, rhs: Self) -> Self::Output { + Self(self.0 ^ rhs.0) + } +} + +impl Not for ManagedImageImageChannels { + type Output = Self; + + fn not(self) -> Self::Output { + Self(!self.0) + } +} + +/// An owned image with one byte per sample and one allocation per channel. +/// +/// Rows use a top-left origin. Every present plane has exactly `width * height` +/// bytes and a row stride of `width`; gray samples occupy [`Self::red`]. The +/// fields remain public to match the C# surface, so methods validate the layout +/// before indexing it and return [`Error::InvalidOperation`] if a caller has +/// supplied inconsistent buffers. +#[derive(Debug, Eq, PartialEq)] +pub struct ManagedImage { + /// Alpha samples, or an empty vector when alpha is absent. + pub alpha: Vec, + /// Blue samples, or an empty vector when color is absent. + pub blue: Vec, + /// Bump samples, or an empty vector when bump is absent. + pub bump: Vec, + /// Channel flags describing the planar buffers. + pub channels: ManagedImageImageChannels, + /// Green samples, or an empty vector when color is absent. + pub green: Vec, + /// Image height in pixels. + pub height: i32, + /// Red or gray samples, or an empty vector when neither is present. + pub red: Vec, + /// Image width in pixels. + pub width: i32, +} + +impl ManagedImage { + /// Creates a blank image using the reference planar channel layout. + /// + /// # Errors + /// + /// Returns [`Error::Argument`] for non-positive, overflowing, or over-limit + /// dimensions, and [`Error::InvalidOperation`] if allocation fails. + pub fn new( + width: i32, + height: i32, + channels: ManagedImageImageChannels, + ) -> Result { + let pixels = checked_pixels(width, height)?; + let gray = channels.contains(ManagedImageImageChannels::GRAY); + let color = channels.contains(ManagedImageImageChannels::COLOR); + let alpha = channels.contains(ManagedImageImageChannels::ALPHA); + let bump = channels.contains(ManagedImageImageChannels::BUMP); + checked_storage( + pixels, + usize::from(gray || color) + + 2 * usize::from(color && !gray) + + usize::from(alpha) + + usize::from(bump), + )?; + + let red = allocate_plane(if gray || color { pixels } else { 0 }, 0)?; + let (green, blue) = if color && !gray { + (allocate_plane(pixels, 0)?, allocate_plane(pixels, 0)?) + } else { + (Vec::new(), Vec::new()) + }; + Ok(Self { + alpha: allocate_plane(if alpha { pixels } else { 0 }, 0)?, + blue, + bump: allocate_plane(if bump { pixels } else { 0 }, 0)?, + channels, + green, + height, + red, + width, + }) + } + + /// Validates dimensions, limits, and all public channel-buffer lengths. + /// + /// # Errors + /// + /// Returns [`Error::Argument`] for invalid dimensions and + /// [`Error::InvalidOperation`] for inconsistent public channel buffers. + pub fn validate(&self) -> Result<(), Error> { + let pixels = checked_pixels(self.width, self.height)?; + let gray = self.channels.contains(ManagedImageImageChannels::GRAY); + let color = self.channels.contains(ManagedImageImageChannels::COLOR); + let expected_red = if gray || color { pixels } else { 0 }; + let expected_color = if color && !gray { pixels } else { 0 }; + let expected_alpha = if self.channels.contains(ManagedImageImageChannels::ALPHA) { + pixels + } else { + 0 + }; + let expected_bump = if self.channels.contains(ManagedImageImageChannels::BUMP) { + pixels + } else { + 0 + }; + checked_storage( + pixels, + usize::from(expected_red != 0) + + 2 * usize::from(expected_color != 0) + + usize::from(expected_alpha != 0) + + usize::from(expected_bump != 0), + )?; + if self.red.len() != expected_red + || self.green.len() != expected_color + || self.blue.len() != expected_color + || self.alpha.len() != expected_alpha + || self.bump.len() != expected_bump + { + return Err(Error::InvalidOperation); + } + Ok(()) + } + + /// Sets every allocated channel sample to zero. + /// + /// # Errors + /// + /// This fixed compatibility signature currently cannot fail. + pub fn clear(&mut self) -> Result<(), Error> { + self.red.fill(0); + self.green.fill(0); + self.blue.fill(0); + self.alpha.fill(0); + self.bump.fill(0); + Ok(()) + } + + /// Creates an independent deep copy of the image and its channel buffers. + /// + /// # Errors + /// + /// Returns an error for an invalid source layout or failed bounded + /// allocation. + #[allow(clippy::should_implement_trait)] // The mapped C# Clone returns Result. + pub fn clone(&self) -> Result { + self.validate()?; + Ok(Self { + alpha: copy_plane(&self.alpha)?, + blue: copy_plane(&self.blue)?, + bump: copy_plane(&self.bump)?, + channels: self.channels, + green: copy_plane(&self.green)?, + height: self.height, + red: copy_plane(&self.red)?, + width: self.width, + }) + } + + /// Converts channel storage using the same add/remove rules as the C# type. + /// + /// # Errors + /// + /// Returns an error for invalid dimensions or failed bounded allocation. + pub fn convert_channels(&mut self, channels: ManagedImageImageChannels) -> Result<(), Error> { + if self.channels == channels { + return Ok(()); + } + let pixels = checked_pixels(self.width, self.height)?; + let add = (self.channels ^ channels) & channels; + let delete = (self.channels ^ channels) & self.channels; + + let added_color = if add.contains(ManagedImageImageChannels::COLOR) { + Some(( + allocate_plane(pixels, 0)?, + allocate_plane(pixels, 0)?, + allocate_plane(pixels, 0)?, + )) + } else { + None + }; + let added_alpha = if add.contains(ManagedImageImageChannels::ALPHA) { + Some(allocate_plane(pixels, u8::MAX)?) + } else { + None + }; + let added_bump = if add.contains(ManagedImageImageChannels::BUMP) { + Some(allocate_plane(pixels, 0)?) + } else { + None + }; + + if let Some((red, green, blue)) = added_color { + self.red = red; + self.green = green; + self.blue = blue; + } else if delete.contains(ManagedImageImageChannels::COLOR) { + self.red.clear(); + self.green.clear(); + self.blue.clear(); + } + if let Some(alpha) = added_alpha { + self.alpha = alpha; + } else if delete.contains(ManagedImageImageChannels::ALPHA) { + self.alpha.clear(); + } + if let Some(bump) = added_bump { + self.bump = bump; + } else if delete.contains(ManagedImageImageChannels::BUMP) { + self.bump.clear(); + } + self.channels = channels; + Ok(()) + } + + /// Exports bottom-left-origin, interleaved 32-bit RGBA data as in C#. + /// + /// # Errors + /// + /// Returns an error when dimensions or public planes are inconsistent, + /// the reference RGBA conversion is undefined, or allocation fails. + pub fn export_raw(&self) -> Result, Error> { + self.validate()?; + let pixels = checked_pixels(self.width, self.height)?; + let length = pixels.checked_mul(4).ok_or(Error::Argument)?; + if length > MAX_CHANNEL_BYTES { + return Err(Error::Argument); + } + let alpha = self.channels.contains(ManagedImageImageChannels::ALPHA); + let color = self.channels.contains(ManagedImageImageChannels::COLOR); + if !alpha && !color { + return Err(Error::InvalidOperation); + } + if color && (self.green.len() != pixels || self.blue.len() != pixels) { + return Err(Error::InvalidOperation); + } + let mut raw = allocate_plane(length, 0)?; + let width = usize::try_from(self.width).map_err(|_| Error::Argument)?; + let height = usize::try_from(self.height).map_err(|_| Error::Argument)?; + for y in 0..height { + for x in 0..width { + let source = y * width + x; + let target = ((height - 1 - y) * width + x) * 4; + if alpha && !color { + raw[target..target + 3].fill(self.alpha[source]); + raw[target + 3] = u8::MAX; + } else { + raw[target] = self.red[source]; + raw[target + 1] = self.green[source]; + raw[target + 2] = self.blue[source]; + raw[target + 3] = if alpha { self.alpha[source] } else { u8::MAX }; + } + } + } + Ok(raw) + } + + /// Resizes every present plane using nearest-neighbor sampling. + /// + /// # Errors + /// + /// Returns an error for invalid target dimensions, inconsistent source + /// planes, checked arithmetic failure, or failed bounded allocation. + pub fn resize_nearest_neighbor(&mut self, width: i32, height: i32) -> Result<(), Error> { + checked_pixels(width, height)?; + self.validate()?; + if width == self.width && height == self.height { + return Ok(()); + } + let old_width = usize::try_from(self.width).map_err(|_| Error::Argument)?; + let old_height = usize::try_from(self.height).map_err(|_| Error::Argument)?; + let new_width = usize::try_from(width).map_err(|_| Error::Argument)?; + let new_height = usize::try_from(height).map_err(|_| Error::Argument)?; + + let red = resize_nearest_plane(&self.red, old_width, old_height, new_width, new_height)?; + let green = + resize_nearest_plane(&self.green, old_width, old_height, new_width, new_height)?; + let blue = resize_nearest_plane(&self.blue, old_width, old_height, new_width, new_height)?; + let alpha = + resize_nearest_plane(&self.alpha, old_width, old_height, new_width, new_height)?; + let bump = resize_nearest_plane(&self.bump, old_width, old_height, new_width, new_height)?; + self.width = width; + self.height = height; + self.red = red; + self.green = green; + self.blue = blue; + self.alpha = alpha; + self.bump = bump; + Ok(()) + } + + /// Resizes every present plane using C#-compatible bilinear interpolation. + /// + /// # Errors + /// + /// Returns an error for invalid target dimensions, inconsistent source + /// planes, checked arithmetic failure, or failed bounded allocation. + pub fn resize_bilinear(&mut self, width: i32, height: i32) -> Result<(), Error> { + checked_pixels(width, height)?; + self.validate()?; + if width == self.width && height == self.height { + return Ok(()); + } + if self.width <= 1 || self.height <= 1 { + return self.resize_nearest_neighbor(width, height); + } + let old_width = usize::try_from(self.width).map_err(|_| Error::Argument)?; + let old_height = usize::try_from(self.height).map_err(|_| Error::Argument)?; + let new_width = usize::try_from(width).map_err(|_| Error::Argument)?; + let new_height = usize::try_from(height).map_err(|_| Error::Argument)?; + + let red = resize_bilinear_plane(&self.red, old_width, old_height, new_width, new_height)?; + let green = + resize_bilinear_plane(&self.green, old_width, old_height, new_width, new_height)?; + let blue = resize_bilinear_plane(&self.blue, old_width, old_height, new_width, new_height)?; + let alpha = + resize_bilinear_plane(&self.alpha, old_width, old_height, new_width, new_height)?; + let bump = resize_bilinear_plane(&self.bump, old_width, old_height, new_width, new_height)?; + self.width = width; + self.height = height; + self.red = red; + self.green = green; + self.blue = blue; + self.alpha = alpha; + self.bump = bump; + Ok(()) + } + + /// Builds planar storage from canonical top-left-origin interleaved bytes. + /// + /// Component order is gray or RGB, followed by alpha and then bump when + /// those flags are present. `stride` is the byte distance between rows and + /// may include trailing padding. + /// + /// # Errors + /// + /// Returns an error for invalid dimensions, ambiguous channel flags, + /// insufficient input/stride, checked arithmetic failure, or allocation + /// beyond the image limits. + pub fn from_interleaved( + width: i32, + height: i32, + channels: ManagedImageImageChannels, + stride: usize, + bytes: &[u8], + ) -> Result { + let pixels = checked_pixels(width, height)?; + let components = canonical_components(channels)?; + let width_usize = usize::try_from(width).map_err(|_| Error::Argument)?; + let height_usize = usize::try_from(height).map_err(|_| Error::Argument)?; + let row_bytes = width_usize.checked_mul(components).ok_or(Error::Argument)?; + let required = required_interleaved_bytes(height_usize, stride, row_bytes)?; + if stride < row_bytes || required > bytes.len() || required > MAX_CHANNEL_BYTES { + return Err(Error::Argument); + } + let mut image = Self::new(width, height, channels)?; + for y in 0..height_usize { + for x in 0..width_usize { + let pixel = y * width_usize + x; + let mut source = y * stride + x * components; + if channels.contains(ManagedImageImageChannels::GRAY) { + image.red[pixel] = bytes[source]; + source += 1; + } else if channels.contains(ManagedImageImageChannels::COLOR) { + image.red[pixel] = bytes[source]; + image.green[pixel] = bytes[source + 1]; + image.blue[pixel] = bytes[source + 2]; + source += 3; + } + if channels.contains(ManagedImageImageChannels::ALPHA) { + image.alpha[pixel] = bytes[source]; + source += 1; + } + if channels.contains(ManagedImageImageChannels::BUMP) { + image.bump[pixel] = bytes[source]; + } + } + } + debug_assert_eq!( + pixels, + image.red.len().max(image.alpha.len()).max(image.bump.len()) + ); + Ok(image) + } + + /// Exports canonical top-left-origin interleaved bytes with a caller-chosen stride. + /// + /// # Errors + /// + /// Returns an error for an inconsistent image, ambiguous channel flags, + /// insufficient stride, checked arithmetic failure, or failed allocation. + pub fn to_interleaved(&self, stride: usize) -> Result, Error> { + self.validate()?; + let components = canonical_components(self.channels)?; + let width = usize::try_from(self.width).map_err(|_| Error::Argument)?; + let height = usize::try_from(self.height).map_err(|_| Error::Argument)?; + let row_bytes = width.checked_mul(components).ok_or(Error::Argument)?; + let minimum = required_interleaved_bytes(height, stride, row_bytes)?; + let length = stride.checked_mul(height).ok_or(Error::Argument)?; + if stride < row_bytes || minimum > length || length > MAX_CHANNEL_BYTES { + return Err(Error::Argument); + } + let mut bytes = allocate_plane(length, 0)?; + for y in 0..height { + for x in 0..width { + let pixel = y * width + x; + let mut target = y * stride + x * components; + if self.channels.contains(ManagedImageImageChannels::GRAY) { + bytes[target] = self.red[pixel]; + target += 1; + } else if self.channels.contains(ManagedImageImageChannels::COLOR) { + bytes[target] = self.red[pixel]; + bytes[target + 1] = self.green[pixel]; + bytes[target + 2] = self.blue[pixel]; + target += 3; + } + if self.channels.contains(ManagedImageImageChannels::ALPHA) { + bytes[target] = self.alpha[pixel]; + target += 1; + } + if self.channels.contains(ManagedImageImageChannels::BUMP) { + bytes[target] = self.bump[pixel]; + } + } + } + Ok(bytes) + } +} + +fn checked_pixels(width: i32, height: i32) -> Result { + let width = usize::try_from(width).map_err(|_| Error::Argument)?; + let height = usize::try_from(height).map_err(|_| Error::Argument)?; + if width == 0 || height == 0 { + return Err(Error::Argument); + } + let pixels = width.checked_mul(height).ok_or(Error::Argument)?; + if pixels > DEFAULT_MAX_PIXELS { + return Err(Error::Argument); + } + Ok(pixels) +} + +fn checked_storage(pixels: usize, planes: usize) -> Result<(), Error> { + if pixels.checked_mul(planes).ok_or(Error::Argument)? > MAX_CHANNEL_BYTES { + Err(Error::Argument) + } else { + Ok(()) + } +} + +fn allocate_plane(length: usize, value: u8) -> Result, Error> { + if length > MAX_CHANNEL_BYTES { + return Err(Error::Argument); + } + let mut plane = Vec::new(); + plane + .try_reserve_exact(length) + .map_err(|_| Error::InvalidOperation)?; + plane.resize(length, value); + Ok(plane) +} + +fn copy_plane(source: &[u8]) -> Result, Error> { + let mut copy = allocate_plane(source.len(), 0)?; + copy.copy_from_slice(source); + Ok(copy) +} + +fn resize_nearest_plane( + source: &[u8], + old_width: usize, + old_height: usize, + new_width: usize, + new_height: usize, +) -> Result, Error> { + if source.is_empty() { + return Ok(Vec::new()); + } + let mut target = allocate_plane(new_width.checked_mul(new_height).ok_or(Error::Argument)?, 0)?; + for y in 0..new_height { + let source_y = y.checked_mul(old_height).ok_or(Error::Argument)? / new_height; + for x in 0..new_width { + let source_x = x.checked_mul(old_width).ok_or(Error::Argument)? / new_width; + target[y * new_width + x] = source[source_y * old_width + source_x]; + } + } + Ok(target) +} + +#[allow(clippy::cast_precision_loss)] // C# explicitly performs these coordinates in f32. +fn resize_bilinear_plane( + source: &[u8], + old_width: usize, + old_height: usize, + new_width: usize, + new_height: usize, +) -> Result, Error> { + if source.is_empty() { + return Ok(Vec::new()); + } + let mut target = allocate_plane(new_width.checked_mul(new_height).ok_or(Error::Argument)?, 0)?; + let x_scale = (old_width - 1) as f32 / (new_width.saturating_sub(1).max(1)) as f32; + let y_scale = (old_height - 1) as f32 / (new_height.saturating_sub(1).max(1)) as f32; + for y in 0..new_height { + let source_y = if new_height > 1 { + y as f32 * y_scale + } else { + 0.0 + }; + for x in 0..new_width { + let source_x = if new_width > 1 { + x as f32 * x_scale + } else { + 0.0 + }; + target[y * new_width + x] = + bilinear_sample(source, old_width, old_height, source_x, source_y); + } + } + Ok(target) +} + +#[allow( + clippy::cast_possible_truncation, + clippy::cast_precision_loss, + clippy::cast_sign_loss +)] // Coordinates are finite, non-negative, bounded image indices; output is clamped. +fn bilinear_sample(source: &[u8], width: usize, height: usize, x: f32, y: f32) -> u8 { + let x0 = x as usize; + let y0 = y as usize; + let x1 = (x0 + 1).min(width - 1); + let y1 = (y0 + 1).min(height - 1); + let fraction_x = x - x0 as f32; + let fraction_y = y - y0 as f32; + let top = f32::from(source[y0 * width + x0]) * (1.0 - fraction_x) + + f32::from(source[y0 * width + x1]) * fraction_x; + let bottom = f32::from(source[y1 * width + x0]) * (1.0 - fraction_x) + + f32::from(source[y1 * width + x1]) * fraction_x; + (top * (1.0 - fraction_y) + bottom * fraction_y) + .clamp(0.0, 255.0) + .round_ties_even() as u8 +} + +fn canonical_components(channels: ManagedImageImageChannels) -> Result { + let gray = channels.contains(ManagedImageImageChannels::GRAY); + let color = channels.contains(ManagedImageImageChannels::COLOR); + if gray && color { + return Err(Error::Argument); + } + let count = usize::from(gray) + + 3 * usize::from(color) + + usize::from(channels.contains(ManagedImageImageChannels::ALPHA)) + + usize::from(channels.contains(ManagedImageImageChannels::BUMP)); + if count == 0 { + Err(Error::Argument) + } else { + Ok(count) + } +} + +fn required_interleaved_bytes( + height: usize, + stride: usize, + row_bytes: usize, +) -> Result { + height + .checked_sub(1) + .and_then(|rows| rows.checked_mul(stride)) + .and_then(|prefix| prefix.checked_add(row_bytes)) + .ok_or(Error::Argument) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Cursor, Read as _}; + + fn rgba() -> ManagedImageImageChannels { + ManagedImageImageChannels::COLOR | ManagedImageImageChannels::ALPHA + } + + #[test] + fn construction_layout_and_limits_are_checked_before_allocation() { + let gray_alpha = ManagedImage::new( + 3, + 2, + ManagedImageImageChannels::GRAY | ManagedImageImageChannels::ALPHA, + ) + .unwrap(); + assert_eq!(gray_alpha.red.len(), 6); + assert_eq!(gray_alpha.alpha.len(), 6); + assert!(gray_alpha.green.is_empty()); + assert_eq!(ManagedImage::new(0, 1, rgba()), Err(Error::Argument)); + assert_eq!(ManagedImage::new(-1, 1, rgba()), Err(Error::Argument)); + assert_eq!( + ManagedImage::new(i32::MAX, i32::MAX, rgba()), + Err(Error::Argument) + ); + let unknown = ManagedImageImageChannels(16) | ManagedImageImageChannels::ALPHA; + assert_eq!(ManagedImage::new(1, 1, unknown).unwrap().channels, unknown); + } + + #[test] + fn channel_conversion_clear_and_clone_match_reference_ownership() { + let mut image = ManagedImage::new(2, 2, ManagedImageImageChannels::COLOR).unwrap(); + image.red.copy_from_slice(&[1, 2, 3, 4]); + image + .convert_channels(rgba() | ManagedImageImageChannels::BUMP) + .unwrap(); + assert_eq!(image.alpha, vec![255; 4]); + assert_eq!(image.bump, vec![0; 4]); + let mut copy = image.clone().unwrap(); + copy.red[0] = 99; + assert_eq!(image.red[0], 1); + copy.clear().unwrap(); + assert!(copy.red.iter().all(|value| *value == 0)); + } + + #[test] + fn canonical_interleave_round_trip_honors_padded_stride() { + let bytes = [ + 1, 2, 3, 4, 5, 6, 7, 8, 99, 99, 9, 10, 11, 12, 13, 14, 15, 16, 99, 99, + ]; + let image = ManagedImage::from_interleaved(2, 2, rgba(), 10, &bytes).unwrap(); + assert_eq!(image.red, vec![1, 5, 9, 13]); + assert_eq!(image.green, vec![2, 6, 10, 14]); + assert_eq!(image.blue, vec![3, 7, 11, 15]); + assert_eq!(image.alpha, vec![4, 8, 12, 16]); + assert_eq!( + image.to_interleaved(10).unwrap(), + [ + 1, 2, 3, 4, 5, 6, 7, 8, 0, 0, 9, 10, 11, 12, 13, 14, 15, 16, 0, 0, + ] + ); + assert_eq!( + ManagedImage::from_interleaved(2, 2, rgba(), 7, &bytes), + Err(Error::Argument) + ); + } + + #[test] + fn nearest_and_bilinear_resize_all_present_planes() { + let mut nearest = + ManagedImage::new(2, 2, rgba() | ManagedImageImageChannels::BUMP).unwrap(); + nearest.red.copy_from_slice(&[1, 2, 3, 4]); + nearest.green.copy_from_slice(&[10, 20, 30, 40]); + nearest.blue.copy_from_slice(&[100, 110, 120, 130]); + nearest.alpha.copy_from_slice(&[200, 201, 202, 203]); + nearest.bump.copy_from_slice(&[50, 51, 52, 53]); + nearest.resize_nearest_neighbor(4, 4).unwrap(); + assert_eq!( + nearest.red, + vec![1, 1, 2, 2, 1, 1, 2, 2, 3, 3, 4, 4, 3, 3, 4, 4] + ); + assert_eq!(nearest.green[15], 40); + assert_eq!(nearest.blue[15], 130); + assert_eq!(nearest.alpha[15], 203); + assert_eq!(nearest.bump[15], 53); + + let mut bilinear = ManagedImage::new(2, 2, ManagedImageImageChannels::GRAY).unwrap(); + bilinear.red.copy_from_slice(&[0, 10, 20, 30]); + bilinear.resize_bilinear(3, 3).unwrap(); + assert_eq!(bilinear.red, vec![0, 5, 10, 10, 15, 20, 20, 25, 30]); + } + + #[test] + fn export_raw_preserves_reference_flip_and_alpha_only_behavior() { + let mut image = ManagedImage::new(1, 2, rgba()).unwrap(); + image.red.copy_from_slice(&[1, 2]); + image.green.copy_from_slice(&[3, 4]); + image.blue.copy_from_slice(&[5, 6]); + image.alpha.copy_from_slice(&[7, 8]); + assert_eq!(image.export_raw().unwrap(), [2, 4, 6, 8, 1, 3, 5, 7]); + + let mut alpha = ManagedImage::new(1, 1, ManagedImageImageChannels::ALPHA).unwrap(); + alpha.alpha[0] = 42; + assert_eq!(alpha.export_raw().unwrap(), [42, 42, 42, 255]); + } + + #[test] + fn malformed_public_layouts_return_typed_errors_without_partial_resize() { + let mut image = ManagedImage::new(2, 2, ManagedImageImageChannels::COLOR).unwrap(); + image.green.pop(); + assert_eq!(image.validate(), Err(Error::InvalidOperation)); + assert_eq!(image.export_raw(), Err(Error::InvalidOperation)); + assert_eq!( + image.resize_nearest_neighbor(4, 4), + Err(Error::InvalidOperation) + ); + assert_eq!((image.width, image.height), (2, 2)); + } + + struct OnePixelCodec; + + impl ITextureCodec for OnePixelCodec { + fn decode(&self, mut stream: Box) -> Result { + let mut sample = [0]; + stream.read_exact(&mut sample).map_err(|_| Error::Parse { + position: 0, + context: "missing gray sample", + })?; + let mut image = ManagedImage::new(1, 1, ManagedImageImageChannels::GRAY)?; + image.red[0] = sample[0]; + Ok(image) + } + } + + #[test] + fn texture_codec_is_object_safe_and_uses_only_core_abstractions() { + let codec: &dyn ITextureCodec = &OnePixelCodec; + let image = codec + .decode(Box::new(Cursor::new(vec![73]))) + .expect("one-pixel decode"); + assert_eq!(image.red, [73]); + } +} diff --git a/tests/red-suite-baseline.json b/tests/red-suite-baseline.json index 285b008..56c34d7 100644 --- a/tests/red-suite-baseline.json +++ b/tests/red-suite-baseline.json @@ -38,6 +38,9 @@ "LibreMetaverse.Tests/BinaryLLSDTests.cs::BinarySDTests.SerializeURI::test", "LibreMetaverse.Tests/BinaryLLSDTests.cs::BinarySDTests.SerializeUUID::test", "LibreMetaverse.Tests/BinaryLLSDTests.cs::BinarySDTests.SerializeUndef::test", + "LibreMetaverse.Tests/ManagedImageTests.cs::ManagedImageTests.ConvertChannels_AddsAlphaAndInitializesTo255::test", + "LibreMetaverse.Tests/ManagedImageTests.cs::ManagedImageTests.ExportRaw_RGBA_OrderAndFlip::test", + "LibreMetaverse.Tests/ManagedImageTests.cs::ManagedImageTests.ResizeNearestNeighbor_RepeatsSourcePixels::test", "LibreMetaverse.Tests/MarketplaceFolderClassifierTests.cs::MarketplaceFolderClassifierTests.ValidateListing_ValidFlags_AreBitmaskComposable::test", "LibreMetaverse.Tests/NotationLLSDTests.cs::NotationSDTests.DeserializeArray::test", "LibreMetaverse.Tests/NotationLLSDTests.cs::NotationSDTests.DeserializeBoolean::test", @@ -98,5 +101,5 @@ "LibreMetaverse.Tests/XmlLLSDTests.cs::XmlSDTests.DeserializeUUID::test", "LibreMetaverse.Tests/XmlLLSDTests.cs::XmlSDTests.DeserializeUndef::test" ], - "support_passes": 97 + "support_passes": 104 } diff --git a/tools/generate_api_shims.py b/tools/generate_api_shims.py index 6dc3978..2bdf4da 100644 --- a/tools/generate_api_shims.py +++ b/tools/generate_api_shims.py @@ -38,6 +38,9 @@ TARGETS = { # implementations. The generated module keeps catalog markers and re-exports # the hand-written type so coverage remains deterministic. NATIVE_TYPES = { + "T:LibreMetaverse.Imaging.ITextureCodec": "crate::managed_image::ITextureCodec", + "T:LibreMetaverse.Imaging.ManagedImage": "crate::managed_image::ManagedImage", + "T:LibreMetaverse.Imaging.ManagedImage.ImageChannels": "crate::managed_image::ManagedImageImageChannels", "T:LibreMetaverse.StructuredData.OSD": "crate::model::OSD", "T:LibreMetaverse.StructuredData.OSDArray": "crate::model::OSDArray", "T:LibreMetaverse.StructuredData.OSDBinary": "crate::model::OSDBinary", @@ -507,7 +510,6 @@ PRIVATE_LAYOUTS = { "T:LibreMetaverse.StructuredData.OSDString": [("value", "String")], "T:LibreMetaverse.StructuredData.OSDUUID": [("value", "libremetaverse_types::UUID")], "T:LibreMetaverse.StructuredData.OSDUri": [("value", "libremetaverse_types::compat::Uri")], - "T:LibreMetaverse.Imaging.ManagedImage": [("data", "Vec")], } VALUE_DERIVES = { "T:LibreMetaverse.Matrix4": "Clone, Copy, Debug", @@ -838,6 +840,11 @@ def render_type(item: dict, type_row: dict, member_rows: dict[str, dict[str, str base, arguments, _ = mapping.type_parts(interface) if base not in mapper.interfaces or not base.startswith("LibreMetaverse."): continue + # Native traits can have required methods. Their concrete adapters + # provide explicit implementations beside the adapter code instead + # of receiving an invalid empty generated impl. + if f"T:{base}" in NATIVE_TYPES: + continue mapped_arguments = [mapper.type(argument, None, set(names)) for argument in arguments] trait_path = mapper.resolved[base] + generic_suffix(mapped_arguments) lines.append(f"impl{suffix} {trait_path} for {rust_name}{suffix} {{}}")