1use alloc::vec::Vec;
2use he3d_math::scalar::floor;
3
4use crate::{ColorRgba8, Error, Rgb};
5
6pub const MAX_TEXTURE_DIMENSION: u32 = 8192;
9
10#[derive(Clone, Debug, PartialEq)]
13pub struct Texture {
14 width: u32,
15 height: u32,
16 pixels: Vec<ColorRgba8>,
17}
18
19impl Texture {
20 pub fn new(width: u32, height: u32, pixels: Vec<ColorRgba8>) -> Result<Self, Error> {
28 if width == 0
29 || height == 0
30 || width > MAX_TEXTURE_DIMENSION
31 || height > MAX_TEXTURE_DIMENSION
32 {
33 return Err(Error::InvalidSize);
34 }
35 let expected = (width as usize)
36 .checked_mul(height as usize)
37 .ok_or(Error::Overflow)?;
38 if pixels.len() != expected {
39 return Err(Error::BufferMismatch);
40 }
41 Ok(Self {
42 width,
43 height,
44 pixels,
45 })
46 }
47
48 pub fn from_pixels(width: u32, height: u32, pixels: &[ColorRgba8]) -> Result<Self, Error> {
55 Self::new(width, height, pixels.to_vec())
56 }
57
58 #[must_use]
61 pub fn solid(color: ColorRgba8) -> Self {
62 Self {
63 width: 1,
64 height: 1,
65 pixels: alloc::vec![color],
66 }
67 }
68
69 #[must_use]
72 pub fn width(&self) -> u32 {
73 self.width
74 }
75
76 #[must_use]
79 pub fn height(&self) -> u32 {
80 self.height
81 }
82
83 #[must_use]
86 pub fn pixels(&self) -> &[ColorRgba8] {
87 &self.pixels
88 }
89
90 #[must_use]
96 pub fn sample(&self, u: f32, v: f32) -> ColorRgba8 {
97 let x = sample_axis(u, self.width);
98 let y = sample_axis(v, self.height);
99 self.pixels[y * self.width as usize + x]
100 }
101
102 #[must_use]
105 pub fn sample_linear(&self, u: f32, v: f32) -> Rgb {
106 self.sample(u, v).to_linear()
107 }
108}
109
110#[expect(
118 clippy::cast_possible_truncation,
119 clippy::cast_precision_loss,
120 clippy::cast_sign_loss
121)]
122fn sample_axis(coord: f32, size: u32) -> usize {
123 let wrapped = wrap_uv(coord);
124 let scaled = wrapped * size as f32;
125 let index = scaled as i64;
126 index.clamp(0, i64::from(size) - 1) as usize
127}
128
129fn wrap_uv(x: f32) -> f32 {
132 let frac = x - floor(x);
133 if frac < 0.0 { frac + 1.0 } else { frac }
134}
135
136#[cfg(test)]
137mod tests {
138 use alloc::vec;
139
140 use super::*;
141
142 fn checker() -> Texture {
143 let pixels = vec![
146 ColorRgba8::from_rgb(255, 0, 0),
147 ColorRgba8::from_rgb(0, 255, 0),
148 ColorRgba8::from_rgb(0, 0, 255),
149 ColorRgba8::from_rgb(255, 255, 255),
150 ];
151 Texture::new(2, 2, pixels).unwrap()
152 }
153
154 #[test]
155 fn samples_corners() {
156 let tex = checker();
157 assert_eq!(tex.sample(0.0, 0.0), ColorRgba8::from_rgb(255, 0, 0));
158 assert_eq!(tex.sample(0.99, 0.99), ColorRgba8::from_rgb(255, 255, 255));
159 }
160
161 #[test]
162 fn wraps_out_of_range_coordinates() {
163 let tex = checker();
164 assert_eq!(tex.sample(1.5, 0.0), ColorRgba8::from_rgb(0, 255, 0));
167 assert_eq!(tex.sample(0.0, -0.5), tex.sample(0.0, 0.5));
170 }
171
172 #[test]
173 fn rejects_zero_dimension() {
174 assert_eq!(Texture::new(0, 2, Vec::new()), Err(Error::InvalidSize));
175 }
176
177 #[test]
178 fn rejects_oversized_dimension() {
179 assert_eq!(
180 Texture::new(MAX_TEXTURE_DIMENSION + 1, 2, Vec::new()),
181 Err(Error::InvalidSize)
182 );
183 }
184
185 #[test]
186 fn rejects_buffer_mismatch() {
187 let pixels = vec![ColorRgba8::BLACK; 3];
188 assert_eq!(Texture::new(2, 2, pixels), Err(Error::BufferMismatch));
189 }
190
191 #[test]
192 fn solid_samples_constant_color() {
193 let tex = Texture::solid(ColorRgba8::from_rgb(10, 20, 30));
194 assert_eq!(tex.width(), 1);
195 assert_eq!(tex.height(), 1);
196 assert_eq!(tex.sample(0.0, 0.0), ColorRgba8::from_rgb(10, 20, 30));
197 assert_eq!(tex.sample(0.5, 0.5), ColorRgba8::from_rgb(10, 20, 30));
198 assert_eq!(tex.sample(1.5, -0.5), ColorRgba8::from_rgb(10, 20, 30));
199 }
200}