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
7pub 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 pub fn pixel_count(&self) -> usize {
34 self.size.w as usize * self.size.h as usize
35 }
36 pub fn set_wrap(&mut self, wrap: ImgWrap) { self.wrap = wrap; }
38 pub fn set_filter(&mut self, filter: ImgFilter) { self.filter = filter; }
40
41 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 pub fn fallback() -> OpticResult<Self> {
49 Self::from_disk("optic/assets/txtr/fallback.png")
50 }
51}
52
53#[cfg(debug_assertions)]
55impl TextureFile {
56 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 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
102impl TextureFile {
104 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 #[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}