Implement ManagedImage and codec abstraction (#38)

This commit is contained in:
2026-08-09 02:11:02 +00:00
parent d980692933
commit 2c806d80ff
9 changed files with 850 additions and 92 deletions

View File

@@ -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.

View File

@@ -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 |

View File

@@ -32,4 +32,3 @@ impl SkiaTextureCodec {
)
}
}
impl libremetaverse_imaging::ITextureCodec for SkiaTextureCodec {}

View File

@@ -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<dyn libremetaverse_types::compat::ReadWrite + Send>,
) -> Result<libremetaverse_imaging::ManagedImage, Error> {
Self::decode(self, stream)
}
}

View File

@@ -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<dyn libremetaverse_types::compat::ReadWrite + Send>,
) -> Result<libremetaverse_imaging::ManagedImage, crate::Error> {
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<u8>,
/// C# member: `F:LibreMetaverse.Imaging.ManagedImage.Blue`.
pub blue: Vec<u8>,
/// C# member: `F:LibreMetaverse.Imaging.ManagedImage.Bump`.
pub bump: Vec<u8>,
/// C# member: `F:LibreMetaverse.Imaging.ManagedImage.Channels`.
pub channels: libremetaverse_imaging::ManagedImageImageChannels,
/// C# member: `F:LibreMetaverse.Imaging.ManagedImage.Green`.
pub green: Vec<u8>,
/// C# member: `F:LibreMetaverse.Imaging.ManagedImage.Height`.
pub height: i32,
/// C# member: `F:LibreMetaverse.Imaging.ManagedImage.Red`.
pub red: Vec<u8>,
/// C# member: `F:LibreMetaverse.Imaging.ManagedImage.Width`.
pub width: i32,
data: Vec<u8>,
}
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<Self, crate::Error> {
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_imaging::ManagedImage, crate::Error> {
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<Vec<u8>, 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;

View File

@@ -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};

View File

@@ -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<dyn ReadWrite + Send>) -> Result<ManagedImage, Error>;
}
/// 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<u8>,
/// Blue samples, or an empty vector when color is absent.
pub blue: Vec<u8>,
/// Bump samples, or an empty vector when bump is absent.
pub bump: Vec<u8>,
/// Channel flags describing the planar buffers.
pub channels: ManagedImageImageChannels,
/// Green samples, or an empty vector when color is absent.
pub green: Vec<u8>,
/// Image height in pixels.
pub height: i32,
/// Red or gray samples, or an empty vector when neither is present.
pub red: Vec<u8>,
/// 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<Self, Error> {
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<Self, Error>.
pub fn clone(&self) -> Result<Self, Error> {
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<Vec<u8>, 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<Self, Error> {
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<Vec<u8>, 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<usize, Error> {
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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<usize, Error> {
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<usize, Error> {
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<dyn ReadWrite + Send>) -> Result<ManagedImage, Error> {
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]);
}
}

View File

@@ -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
}

View File

@@ -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<u8>")],
}
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} {{}}")