Skip to main content

optic_render/asset/
img.rs

1use image::{ColorType, GenericImageView};
2use optic_core::consts::{OPTIC_CACHE_VERSION, OPTIC_MAGIC};
3use optic_core::{ImgFilter, ImgFormat, ImgWrap, OpticError, OpticErrorKind, OpticResult, Size2D};
4
5use crate::handles::texture::{create_texture, Texture2D};
6
7/// A texture loaded from disk (or cache) with metadata.
8///
9/// # Loading
10///
11/// ```ignore
12/// use optic_render::asset::TextureFile;
13///
14/// let tex = TextureFile::from_disk("textures/wood.png")?;
15/// let gpu_tex = tex.ship(); // uploads to GPU
16/// ```
17///
18/// # Caching
19///
20/// In debug builds, `from_disk` loads the source image and writes a binary
21/// cache (`.otxtr`). In release builds, it reads the cache directly for
22/// faster startup.
23pub struct TextureFile {
24    pub bytes: Vec<u8>,
25    pub size: Size2D,
26    pub fmt: ImgFormat,
27    pub filter: ImgFilter,
28    pub wrap: ImgWrap,
29}
30
31impl TextureFile {
32    /// Returns the total pixel count (width × height).
33    pub fn pixel_count(&self) -> usize {
34        self.size.w as usize * self.size.h as usize
35    }
36    /// Overrides the wrap mode (used before [`ship`](TextureFile::ship)).
37    pub fn set_wrap(&mut self, wrap: ImgWrap) { self.wrap = wrap; }
38    /// Overrides the filter mode (used before [`ship`](TextureFile::ship)).
39    pub fn set_filter(&mut self, filter: ImgFilter) { self.filter = filter; }
40
41    /// Uploads this texture to the GPU and returns a [`Texture2D`] handle.
42    pub fn ship(&self) -> Texture2D {
43        let id = create_texture(&self.bytes, self.size, &self.fmt, &self.filter, &self.wrap);
44        Texture2D::new(id, self.size, self.fmt, self.filter, self.wrap)
45    }
46
47    /// Loads the fallback texture from `optic/assets/txtr/fallback.png`.
48    pub fn fallback() -> OpticResult<Self> {
49        Self::from_disk("optic/assets/txtr/fallback.png")
50    }
51}
52
53// --- from_disk: debug loads source + overwrites cache; release loads cache only ---
54#[cfg(debug_assertions)]
55impl TextureFile {
56    /// Loads a texture from disk, caching it for release builds.
57    pub fn from_disk(path: &str) -> OpticResult<Self> {
58        let img = image::open(path)
59            .map_err(|e| OpticError::new(OpticErrorKind::File, &format!("failed to load image {path}: {e}")))?;
60
61        let (w, h) = img.dimensions();
62        let color = img.color();
63        let bytes = img.as_bytes().to_vec();
64
65        let fmt = match color {
66            ColorType::L8 => ImgFormat::R(8),
67            ColorType::La8 => ImgFormat::RG(8),
68            ColorType::Rgb8 => ImgFormat::RGB(8),
69            ColorType::Rgba8 => ImgFormat::RGBA(8),
70            ColorType::L16 => ImgFormat::R(16),
71            ColorType::La16 => ImgFormat::RG(16),
72            ColorType::Rgb16 => ImgFormat::RGB(16),
73            ColorType::Rgba16 => ImgFormat::RGBA(16),
74            ColorType::Rgb32F => ImgFormat::RGB(32),
75            ColorType::Rgba32F => ImgFormat::RGBA(32),
76            _ => ImgFormat::RGBA(8),
77        };
78
79        let tex = Self {
80            bytes,
81            size: Size2D::from(w, h),
82            fmt,
83            filter: ImgFilter::Closest,
84            wrap: ImgWrap::Clip,
85        };
86
87        let cache = optic_file::cached_path(path, "otxtr");
88        tex.save_cached(&cache)?;
89        Ok(tex)
90    }
91}
92
93#[cfg(not(debug_assertions))]
94impl TextureFile {
95    /// Loads a texture from the binary cache (release only).
96    pub fn from_disk(path: &str) -> OpticResult<Self> {
97        let cache = optic_file::cached_path(path, "otxtr");
98        Self::from_cached(&cache)
99    }
100}
101
102// --- binary cache read/write (internal) ---
103impl TextureFile {
104    /// Saves this texture to a binary cache file.
105    pub fn save_cached(&self, path: &str) -> OpticResult<()> {
106        let mut data = Vec::with_capacity(22 + self.bytes.len());
107        data.extend_from_slice(&OPTIC_MAGIC);
108        data.extend_from_slice(&OPTIC_CACHE_VERSION.to_le_bytes());
109        data.push(self.fmt.channels());
110        data.push(self.fmt.bit_depth());
111        data.extend_from_slice(&(self.size.w as u32).to_le_bytes());
112        data.extend_from_slice(&(self.size.h as u32).to_le_bytes());
113        data.push(match self.filter {
114            ImgFilter::Closest => 0u8,
115            ImgFilter::Linear => 1u8,
116        });
117        data.push(match self.wrap {
118            ImgWrap::Repeat => 0u8,
119            ImgWrap::Extend => 1u8,
120            ImgWrap::Clip => 2u8,
121        });
122        data.extend_from_slice(&self.bytes);
123        optic_file::write_bytes(path, &data)
124    }
125
126    /// Loads a texture from a binary cache file.
127    #[cfg_attr(debug_assertions, allow(dead_code))]
128    pub(crate) fn from_cached(path: &str) -> OpticResult<Self> {
129        let data = optic_file::read_bytes(path)?;
130        if data.len() < 22 {
131            return Err(OpticError::new(OpticErrorKind::Asset, &format!("cached texture too short: {path}")));
132        }
133        if data[0..8] != OPTIC_MAGIC {
134            return Err(OpticError::new(OpticErrorKind::Asset, &format!("not a valid Optic cache file (bad magic): {path}")));
135        }
136        let version = u16::from_le_bytes([data[8], data[9]]);
137        if version != OPTIC_CACHE_VERSION {
138            return Err(OpticError::new(OpticErrorKind::Asset, &format!(
139                "cache file version {version} is not supported (expected {OPTIC_CACHE_VERSION}): {path}"
140            )));
141        }
142        let channels = data[10];
143        let bit_depth = data[11];
144        let w = u32::from_le_bytes([data[12], data[13], data[14], data[15]]);
145        let h = u32::from_le_bytes([data[16], data[17], data[18], data[19]]);
146        let filter = match data[20] {
147            0 => ImgFilter::Closest,
148            1 => ImgFilter::Linear,
149            _ => ImgFilter::Closest,
150        };
151        let wrap = match data[21] {
152            0 => ImgWrap::Repeat,
153            1 => ImgWrap::Extend,
154            _ => ImgWrap::Clip,
155        };
156        let bytes = data[22..].to_vec();
157        let expected = w as usize * h as usize * channels as usize * (bit_depth as usize / 8);
158        if bytes.len() != expected {
159            return Err(OpticError::new(OpticErrorKind::Asset, &format!(
160                "cached texture size mismatch: expected {expected} bytes, got {} for {path}", bytes.len()
161            )));
162        }
163        Ok(Self {
164            bytes,
165            size: Size2D::from(w, h),
166            fmt: ImgFormat::from(channels, bit_depth),
167            filter,
168            wrap,
169        })
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn pixel_count() {
179        let img = TextureFile {
180            bytes: vec![0u8; 1920 * 1080 * 4],
181            size: Size2D::from(1920, 1080),
182            fmt: ImgFormat::RGBA(8),
183            filter: ImgFilter::Closest,
184            wrap: ImgWrap::Clip,
185        };
186        assert_eq!(img.pixel_count(), 1920 * 1080);
187    }
188
189    #[test]
190    fn pixel_count_zero() {
191        let img = TextureFile {
192            bytes: vec![],
193            size: Size2D::empty(),
194            fmt: ImgFormat::RGBA(8),
195            filter: ImgFilter::Closest,
196            wrap: ImgWrap::Clip,
197        };
198        assert_eq!(img.pixel_count(), 0);
199    }
200
201    #[test]
202    fn image_cached_roundtrip() {
203        let img = TextureFile {
204            bytes: vec![128u8; 16 * 16 * 4],
205            size: Size2D::from(16, 16),
206            fmt: ImgFormat::RGBA(8),
207            filter: ImgFilter::Linear,
208            wrap: ImgWrap::Repeat,
209        };
210        let path = "/tmp/optic_test_img_cache.otxtr";
211        img.save_cached(path).unwrap();
212        let loaded = TextureFile::from_cached(path).unwrap();
213        assert_eq!(loaded.bytes, img.bytes);
214        assert_eq!(loaded.size, img.size);
215        assert_eq!(loaded.fmt, img.fmt);
216        assert_eq!(loaded.filter, img.filter);
217        assert_eq!(loaded.wrap, img.wrap);
218        let _ = std::fs::remove_file(path);
219    }
220
221    #[test]
222    fn image_from_cached_bad_magic() {
223        let path = "/tmp/optic_test_img_badmagic.bin";
224        optic_file::write_bytes(path, &[0u8; 30]).unwrap();
225        let result = TextureFile::from_cached(path);
226        assert!(result.is_err());
227        let _ = std::fs::remove_file(path);
228    }
229
230    #[test]
231    fn image_from_cached_too_short() {
232        let path = "/tmp/optic_test_img_short.bin";
233        optic_file::write_bytes(path, b"tooshrt").unwrap();
234        let result = TextureFile::from_cached(path);
235        assert!(result.is_err());
236        let _ = std::fs::remove_file(path);
237    }
238
239    #[test]
240    fn set_wrap_filter() {
241        let mut img = TextureFile {
242            bytes: vec![],
243            size: Size2D::from(1, 1),
244            fmt: ImgFormat::RGBA(8),
245            filter: ImgFilter::Closest,
246            wrap: ImgWrap::Clip,
247        };
248        assert_eq!(img.filter, ImgFilter::Closest);
249        assert_eq!(img.wrap, ImgWrap::Clip);
250
251        img.set_filter(ImgFilter::Linear);
252        img.set_wrap(ImgWrap::Repeat);
253        assert_eq!(img.filter, ImgFilter::Linear);
254        assert_eq!(img.wrap, ImgWrap::Repeat);
255    }
256}