Skip to main content

falcon_library/model3d/
texture.rs

1//! Textures of 3D models: the pictures a model's materials map onto its
2//! surfaces, decoded to RGBA within a memory bound, and how the viewer and
3//! the thumbnail renderer sample them.
4//!
5//! A reader asks a [`Loader`] for each picture, by the name the file gives
6//! (a sidecar file) or with its bytes (embedded in the model). Names are
7//! looked up only next to the model or in a folder below it: a relative
8//! path that stays inside the model's folder is tried as written, and any
9//! name (absolute, climbing out with `..`, or relative) is also tried as
10//! its file name alone, next to the model. Nothing outside the folder is
11//! ever read. A picture that is missing or can't be decoded gives `None`
12//! and the mesh keeps its material colour.
13//!
14//! Memory: each picture is decoded to at most [`MAX_SIDE`] pixels on its
15//! longer side (larger ones are scaled down, by ImageIO while decoding on
16//! macOS), and one model's pictures together take at most [`BUDGET`]
17//! bytes of RGBA; a picture that doesn't fit what is left is halved until
18//! it does. The same file or the same embedded bytes are decoded once.
19//! Normal, bump and emissive maps count too, but each may take only half of
20//! what is left ([`Loader::for_map`]), so they can't starve the pictures of
21//! the materials read after them.
22//!
23//! Textures always repeat, whatever wrap mode the file names: Eagle's
24//! viewer tiles every texture, so falcon's readers ignore the wrap modes
25//! of glTF samplers, COLLADA samplers, FBX textures, OBJ `-clamp` and 3DS
26//! map flags, and the viewer's GPU sampler repeats too.
27//!
28//! Texture coordinates are kept in picture space: u to the right and v
29//! downwards, (0, 0) the top left corner. Readers of formats that count v
30//! upwards (OBJ, FBX, COLLADA, 3DS) flip it.
31
32use std::collections::HashMap;
33use std::io::Cursor;
34use std::sync::{Arc, OnceLock};
35
36use image::imageops::FilterType;
37use image::{ImageReader, RgbaImage};
38
39use super::math::V3;
40
41/// Longest side a texture is decoded to.
42pub const MAX_SIDE: u32 = 4096;
43/// Bytes of RGBA one model's textures may take together (64 MB).
44pub const BUDGET: usize = 64 * 1024 * 1024;
45/// Most bytes the `image` crate may allocate decoding one picture at full
46/// size before it is scaled down (where ImageIO can't decode it smaller).
47const DECODE_LIMIT: u64 = 512 * 1024 * 1024;
48
49/// A decoded picture: straight-alpha RGBA8 in its file's own (sRGB)
50/// values, rows top to bottom.
51#[derive(Clone, PartialEq, Eq)]
52pub struct Image {
53    /// The file name or a description of where it came from.
54    pub name: String,
55    pub width: u32,
56    pub height: u32,
57    pub rgba: Vec<u8>,
58}
59
60impl std::fmt::Debug for Image {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        write!(f, "Image({:?}, {}x{})", self.name, self.width, self.height)
63    }
64}
65
66impl Image {
67    /// The pixel at (`x`, `y`), which must be inside.
68    fn texel(&self, x: u32, y: u32) -> [u8; 4] {
69        let i = (y as usize * self.width as usize + x as usize) * 4;
70        [self.rgba[i], self.rgba[i + 1], self.rgba[i + 2], self.rgba[i + 3]]
71    }
72
73    /// The average colour, weighted by alpha, over at most 64 x 64 evenly
74    /// spread pixels; `None` when it is fully transparent.
75    pub fn average(&self) -> Option<V3> {
76        let (sx, sy) = ((self.width / 64).max(1), (self.height / 64).max(1));
77        let (mut sum, mut weight) = ([0.0f64; 3], 0.0f64);
78        for y in (0..self.height).step_by(sy as usize) {
79            for x in (0..self.width).step_by(sx as usize) {
80                let p = self.texel(x, y);
81                let a = f64::from(p[3]) / 255.0;
82                for k in 0..3 {
83                    sum[k] += f64::from(p[k]) / 255.0 * a;
84                }
85                weight += a;
86            }
87        }
88        (weight > 0.0).then(|| sum.map(|s| (s / weight) as f32))
89    }
90}
91
92/// A material's texture: the picture and how it is laid on.
93#[derive(Clone, Debug, PartialEq)]
94pub struct Texture {
95    pub image: Arc<Image>,
96    /// Multiplies the picture (in linear light), in display values: glTF's
97    /// base colour factor; white for the other formats, whose viewers in
98    /// Eagle replace the material colour with the picture.
99    pub tint: V3,
100    /// Pixels with alpha below this are not drawn (glTF's `MASK` mode);
101    /// `None` draws every pixel opaque.
102    pub alpha_cutoff: Option<f32>,
103}
104
105impl Texture {
106    /// `image` tiled, untinted, opaque.
107    pub fn new(image: Arc<Image>) -> Texture {
108        Texture { image, tint: [1.0; 3], alpha_cutoff: None }
109    }
110
111    /// The four pixels around `uv` and the weight of each.
112    fn around(&self, uv: [f32; 2]) -> [(u32, u32, f32); 4] {
113        let img = &*self.image;
114        let (w, h) = (img.width.max(1), img.height.max(1));
115        // Texel centres sit at half-integers.
116        let fx = wrap_coord(uv[0]) * w as f32 - 0.5;
117        let fy = wrap_coord(uv[1]) * h as f32 - 0.5;
118        let (x0, y0) = (fx.floor(), fy.floor());
119        let (tx, ty) = (fx - x0, fy - y0);
120        let pick = |i: i64, n: u32| i.rem_euclid(i64::from(n)) as u32;
121        let (x0, y0) = (x0 as i64, y0 as i64);
122        let xs = [pick(x0, w), pick(x0 + 1, w)];
123        let ys = [pick(y0, h), pick(y0 + 1, h)];
124        [(xs[0], ys[0], (1.0 - tx) * (1.0 - ty)), (xs[1], ys[0], tx * (1.0 - ty)), (xs[0], ys[1], (1.0 - tx) * ty), (xs[1], ys[1], tx * ty)]
125    }
126
127    /// The picture at `uv` (picture space, 0..1 across it), filtered
128    /// bilinearly in linear light, as display values and straight alpha.
129    /// The tint is not applied.
130    pub fn sample(&self, uv: [f32; 2]) -> [f32; 4] {
131        let lut = linear_lut();
132        let mut acc = [0.0f32; 4];
133        for (x, y, wt) in self.around(uv) {
134            let p = self.image.texel(x, y);
135            let a = f32::from(p[3]) / 255.0;
136            // Weighted by alpha, so transparent pixels don't bleed their
137            // colour into the edge.
138            for k in 0..3 {
139                acc[k] += lut[p[k] as usize] * a * wt;
140            }
141            acc[3] += a * wt;
142        }
143        if acc[3] <= 0.0 {
144            return [0.0; 4];
145        }
146        [linear_to_srgb(acc[0] / acc[3]), linear_to_srgb(acc[1] / acc[3]), linear_to_srgb(acc[2] / acc[3]), acc[3]]
147    }
148
149    /// The map's stored values at `uv`, 0..1, filtered bilinearly as they
150    /// are: normals and heights are data, and Eagle's viewer takes emissive
151    /// maps the same way. Alpha plays no part.
152    pub fn sample_data(&self, uv: [f32; 2]) -> V3 {
153        let mut acc = [0.0f32; 3];
154        for (x, y, wt) in self.around(uv) {
155            let p = self.image.texel(x, y);
156            for k in 0..3 {
157                acc[k] += f32::from(p[k]) / 255.0 * wt;
158            }
159        }
160        acc
161    }
162}
163
164/// `color` (display values) times the texel `rgb` (display values),
165/// multiplied in linear light: the textured base colour the viewer's
166/// shader computes the same way.
167pub fn modulate(color: V3, rgb: [f32; 4]) -> V3 {
168    std::array::from_fn(|k| linear_to_srgb(srgb_to_linear(color[k]) * srgb_to_linear(rgb[k])))
169}
170
171/// A texture coordinate brought into 0..1 by repeating the picture.
172pub fn wrap_coord(t: f32) -> f32 {
173    if !t.is_finite() {
174        return 0.0;
175    }
176    t - t.floor()
177}
178
179pub fn srgb_to_linear(c: f32) -> f32 {
180    let c = c.clamp(0.0, 1.0);
181    if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
182}
183
184pub fn linear_to_srgb(c: f32) -> f32 {
185    let c = c.clamp(0.0, 1.0);
186    if c <= 0.003_130_8 { c * 12.92 } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 }
187}
188
189/// Linear light of each 8-bit sRGB value.
190fn linear_lut() -> &'static [f32; 256] {
191    static LUT: OnceLock<[f32; 256]> = OnceLock::new();
192    LUT.get_or_init(|| std::array::from_fn(|i| srgb_to_linear(i as f32 / 255.0)))
193}
194
195/// The size a `w` x `h` picture is decoded at: at most `max_side` on its
196/// longer side, then halved until it takes no more than `max_bytes` of
197/// RGBA. `None` when not even one pixel fits.
198pub fn fit_size(w: u32, h: u32, max_side: u32, max_bytes: usize) -> Option<(u32, u32)> {
199    if w == 0 || h == 0 || max_bytes < 4 {
200        return None;
201    }
202    let s = (f64::from(max_side) / f64::from(w.max(h))).min(1.0);
203    let (mut tw, mut th) = (((f64::from(w) * s).round() as u32).max(1), ((f64::from(h) * s).round() as u32).max(1));
204    while tw as usize * th as usize * 4 > max_bytes {
205        tw = (tw / 2).max(1);
206        th = (th / 2).max(1);
207    }
208    Some((tw, th))
209}
210
211/// Decodes a picture file's bytes to RGBA within `max_side` and
212/// `max_bytes` (see [`fit_size`]). Formats the `image` crate reads (PNG,
213/// JPEG, TGA, BMP, TIFF, DDS, …) go through it; anything else, and pictures
214/// to be scaled down, through ImageIO on macOS.
215pub fn decode(data: &[u8], max_side: u32, max_bytes: usize) -> Option<RgbaImage> {
216    let size = ImageReader::new(Cursor::new(data)).with_guessed_format().ok().and_then(|r| r.into_dimensions().ok());
217    let img = match size {
218        Some((w, h)) => {
219            let (tw, th) = fit_size(w, h, max_side, max_bytes)?;
220            if (tw, th) == (w, h) {
221                decode_full(data)?
222            } else {
223                match crate::imageio::decode_bytes_within(data, tw.max(th)) {
224                    Ok(img) => img,
225                    Err(_) => decode_full(data)?,
226                }
227            }
228        }
229        None => crate::imageio::decode_bytes_within(data, max_side).ok()?,
230    };
231    let (tw, th) = fit_size(img.width(), img.height(), max_side, max_bytes)?;
232    Some(if (tw, th) == img.dimensions() { img } else { image::imageops::resize(&img, tw, th, FilterType::Triangle) })
233}
234
235fn decode_full(data: &[u8]) -> Option<RgbaImage> {
236    let mut reader = ImageReader::new(Cursor::new(data)).with_guessed_format().ok()?;
237    let mut limits = image::Limits::default();
238    limits.max_alloc = Some(DECODE_LIMIT);
239    reader.limits(limits);
240    Some(reader.decode().ok()?.into_rgba8())
241}
242
243/// Where a texture named `path` in a model file may be read, relative to
244/// the model's folder, in the order to try: the path as written when it
245/// is relative and stays inside the folder, then its file name alone.
246/// Backslashes count as separators and a `file://` prefix is dropped.
247pub fn candidates(path: &str) -> Vec<String> {
248    let p = path.trim().replace('\\', "/");
249    let p = p.strip_prefix("file://").map_or(p.as_str(), |r| r.strip_prefix("localhost").unwrap_or(r));
250    let drive = p.len() >= 2 && p.as_bytes()[1] == b':' && p.as_bytes()[0].is_ascii_alphabetic();
251    let absolute = p.starts_with('/') || drive;
252    let parts: Vec<&str> = p.split('/').filter(|s| !s.is_empty() && *s != ".").collect();
253    let Some(&name) = parts.last() else { return Vec::new() };
254    let name = if drive && parts.len() == 1 { &name[2..] } else { name };
255    if name.is_empty() || name == ".." {
256        return Vec::new();
257    }
258    let mut out = Vec::new();
259    if !absolute && !parts.contains(&"..") {
260        out.push(parts.join("/"));
261    }
262    if out.first().is_none_or(|f| f != name) {
263        out.push(name.to_string());
264    }
265    out
266}
267
268/// Fetches a file next to the model by relative name.
269pub type Side<'a> = &'a dyn Fn(&str) -> Option<Vec<u8>>;
270
271/// Decodes one model's textures: looks pictures up next to the model,
272/// keeps the decoded total within [`BUDGET`] and decodes each file or
273/// embedded picture once.
274pub struct Loader<'a> {
275    side: Side<'a>,
276    left: usize,
277    /// What a picture may take: `left` divided by this.
278    share: usize,
279    /// By the path read and by the bytes' hash.
280    by_path: HashMap<String, Option<Arc<Image>>>,
281    by_hash: HashMap<[u8; 32], Option<Arc<Image>>>,
282}
283
284impl<'a> Loader<'a> {
285    pub fn new(side: Side<'a>) -> Loader<'a> {
286        Loader::with_budget(side, BUDGET)
287    }
288
289    /// A loader with its own byte budget.
290    pub fn with_budget(side: Side<'a>, budget: usize) -> Loader<'a> {
291        Loader { side, left: budget, share: 1, by_path: HashMap::new(), by_hash: HashMap::new() }
292    }
293
294    /// Runs `read`, a read of a normal, bump or emissive map: it may take
295    /// only half of the bytes still free.
296    pub fn for_map<T>(&mut self, read: impl FnOnce(&mut Self) -> T) -> T {
297        self.share = 2;
298        let out = read(self);
299        self.share = 1;
300        out
301    }
302
303    /// Bytes of RGBA still free.
304    pub fn left(&self) -> usize {
305        self.left
306    }
307
308    /// The picture a model names by `path` (see [`candidates`]).
309    pub fn file(&mut self, path: &str) -> Option<Arc<Image>> {
310        self.file_in("", path)
311    }
312
313    /// The picture named by `path` in a file that sits in the folder `dir`
314    /// (relative to the model, as an MTL library in a subfolder): tried
315    /// there first, then as [`Loader::file`] would.
316    pub fn file_in(&mut self, dir: &str, path: &str) -> Option<Arc<Image>> {
317        let mut tries = Vec::new();
318        let dir = dir.trim_matches('/');
319        if !dir.is_empty() {
320            for c in candidates(path) {
321                tries.extend(candidates(&format!("{dir}/{c}")).into_iter().take(1));
322            }
323        }
324        tries.extend(candidates(path));
325        for p in tries {
326            if let Some(hit) = self.by_path.get(&p) {
327                if hit.is_some() {
328                    return hit.clone();
329                }
330                continue;
331            }
332            let Some(bytes) = (self.side)(&p) else {
333                self.by_path.insert(p, None);
334                continue;
335            };
336            let name = p.rsplit('/').next().unwrap_or(&p).to_string();
337            let img = self.embedded(&name, &bytes);
338            self.by_path.insert(p, img.clone());
339            if img.is_some() {
340                return img;
341            }
342        }
343        None
344    }
345
346    /// A picture whose file bytes are `data` (embedded in the model, or
347    /// read by [`Loader::file`]), called `name`.
348    pub fn embedded(&mut self, name: &str, data: &[u8]) -> Option<Arc<Image>> {
349        let hash = *blake3::hash(data).as_bytes();
350        if let Some(hit) = self.by_hash.get(&hash) {
351            return hit.clone();
352        }
353        let img = decode(data, MAX_SIDE, self.left / self.share).map(|img| {
354            let (width, height) = img.dimensions();
355            Arc::new(Image { name: name.to_string(), width, height, rgba: img.into_raw() })
356        });
357        if let Some(i) = &img {
358            self.left -= i.rgba.len();
359        }
360        self.by_hash.insert(hash, img.clone());
361        img
362    }
363}
364
365/// The smaller levels of `img`'s mipmap chain, each half the one before
366/// (at least one pixel), down to 1 x 1: alpha-weighted 2 x 2 averages in
367/// linear light, in sRGB values like the picture. The viewer uploads
368/// them under the full picture.
369pub fn mip_chain(img: &Image) -> Vec<(u32, u32, Vec<u8>)> {
370    let lut = linear_lut();
371    let mut out: Vec<(u32, u32, Vec<u8>)> = Vec::new();
372    let (mut w, mut h) = (img.width, img.height);
373    while w > 1 || h > 1 {
374        let (pw, ph) = (w, h);
375        let src: &[u8] = out.last().map_or(&img.rgba, |l| &l.2);
376        let (nw, nh) = ((w / 2).max(1), (h / 2).max(1));
377        let mut level = vec![0u8; nw as usize * nh as usize * 4];
378        for y in 0..nh {
379            for x in 0..nw {
380                let mut acc = [0.0f32; 4];
381                let mut n = 0.0f32;
382                for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] {
383                    let (sx, sy) = ((x * 2 + dx).min(pw - 1), (y * 2 + dy).min(ph - 1));
384                    let i = (sy as usize * pw as usize + sx as usize) * 4;
385                    let a = f32::from(src[i + 3]) / 255.0;
386                    for k in 0..3 {
387                        acc[k] += lut[src[i + k] as usize] * a;
388                    }
389                    acc[3] += a;
390                    n += 1.0;
391                }
392                let o = (y as usize * nw as usize + x as usize) * 4;
393                for k in 0..3 {
394                    let v = if acc[3] > 0.0 { linear_to_srgb(acc[k] / acc[3]) } else { 0.0 };
395                    level[o + k] = (v * 255.0).round() as u8;
396                }
397                level[o + 3] = (acc[3] / n * 255.0).round() as u8;
398            }
399        }
400        out.push((nw, nh, level));
401        (w, h) = (nw, nh);
402    }
403    out
404}
405
406/// [`mip_chain`] for a map holding data: plain 2 x 2 averages of the stored
407/// values, alpha included and with no weight.
408pub fn mip_chain_data(img: &Image) -> Vec<(u32, u32, Vec<u8>)> {
409    let mut out: Vec<(u32, u32, Vec<u8>)> = Vec::new();
410    let (mut w, mut h) = (img.width, img.height);
411    while w > 1 || h > 1 {
412        let src: &[u8] = out.last().map_or(&img.rgba, |l| &l.2);
413        let (nw, nh) = ((w / 2).max(1), (h / 2).max(1));
414        let mut level = vec![0u8; nw as usize * nh as usize * 4];
415        for y in 0..nh {
416            for x in 0..nw {
417                let mut acc = [0u32; 4];
418                for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] {
419                    let (sx, sy) = ((x * 2 + dx).min(w - 1), (y * 2 + dy).min(h - 1));
420                    let i = (sy as usize * w as usize + sx as usize) * 4;
421                    for k in 0..4 {
422                        acc[k] += u32::from(src[i + k]);
423                    }
424                }
425                let o = (y as usize * nw as usize + x as usize) * 4;
426                for k in 0..4 {
427                    level[o + k] = ((acc[k] + 2) / 4) as u8;
428                }
429            }
430        }
431        out.push((nw, nh, level));
432        (w, h) = (nw, nh);
433    }
434    out
435}
436
437#[cfg(test)]
438pub(crate) mod tests {
439    use super::*;
440
441    /// A PNG of `w` x `h` pixels of one colour.
442    pub(crate) fn png(w: u32, h: u32, rgba: [u8; 4]) -> Vec<u8> {
443        let mut out = Vec::new();
444        RgbaImage::from_pixel(w, h, image::Rgba(rgba)).write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
445        out
446    }
447
448    /// A 2 x 2 PNG: red, green on top; blue, white below.
449    pub(crate) fn png_2x2() -> Vec<u8> {
450        let mut img = RgbaImage::new(2, 2);
451        img.put_pixel(0, 0, image::Rgba([255, 0, 0, 255]));
452        img.put_pixel(1, 0, image::Rgba([0, 255, 0, 255]));
453        img.put_pixel(0, 1, image::Rgba([0, 0, 255, 255]));
454        img.put_pixel(1, 1, image::Rgba([255, 255, 255, 255]));
455        let mut out = Vec::new();
456        img.write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
457        out
458    }
459
460    /// An indexed PNG of `w` x `h` pixels all of palette colour `rgb`,
461    /// written by hand: the pixel data is all zero bytes, deflated as one
462    /// literal and then matches of 258 bytes, so a picture of any size is
463    /// a few bytes per 258 pixels to make.
464    pub(crate) fn huge_png(w: u32, h: u32, rgb: [u8; 3]) -> Vec<u8> {
465        fn chunk(out: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) {
466            out.extend_from_slice(&(data.len() as u32).to_be_bytes());
467            let start = out.len();
468            out.extend_from_slice(kind);
469            out.extend_from_slice(data);
470            let crc = crc32(&out[start..]);
471            out.extend_from_slice(&crc.to_be_bytes());
472        }
473        fn crc32(b: &[u8]) -> u32 {
474            let mut c = !0u32;
475            for &x in b {
476                c ^= u32::from(x);
477                for _ in 0..8 {
478                    c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
479                }
480            }
481            !c
482        }
483        struct Bits {
484            out: Vec<u8>,
485            acc: u64,
486            n: u32,
487        }
488        impl Bits {
489            fn put(&mut self, v: u32, len: u32) {
490                self.acc |= u64::from(v) << self.n;
491                self.n += len;
492                while self.n >= 8 {
493                    self.out.push(self.acc as u8);
494                    self.acc >>= 8;
495                    self.n -= 8;
496                }
497            }
498            /// A fixed-Huffman code, written most significant bit first.
499            fn code(&mut self, v: u32, len: u32) {
500                let rev = v.reverse_bits() >> (32 - len);
501                self.put(rev, len);
502            }
503        }
504        let total = u64::from(h) * (u64::from(w) + 1);
505        let mut z = vec![0x78, 0x01];
506        let mut bits = Bits { out: Vec::new(), acc: 0, n: 0 };
507        bits.put(1, 1); // final block
508        bits.put(1, 2); // fixed Huffman
509        bits.code(0x30, 8); // literal 0
510        let mut left = total - 1;
511        while left >= 3 {
512            let len = left.min(258);
513            // Leave at least 3 for the last match, or finish with literals.
514            let len = if left - len > 0 && left - len < 3 { len - 3 } else { len };
515            match len {
516                258 => bits.code(0b1100_0101, 8), // code 285
517                l => {
518                    // Lengths 3..=257 by the deflate length table.
519                    let (code, base, extra) = length_code(l as u32);
520                    if code < 280 {
521                        bits.code(code - 256, 7);
522                    } else {
523                        bits.code(0b1100_0000 + (code - 280), 8);
524                    }
525                    bits.put(l as u32 - base, extra);
526                }
527            }
528            bits.code(0, 5); // distance 1
529            left -= len;
530        }
531        for _ in 0..left {
532            bits.code(0x30, 8);
533        }
534        bits.code(0, 7); // end of block
535        if bits.n > 0 {
536            bits.out.push(bits.acc as u8);
537        }
538        z.extend_from_slice(&bits.out);
539        // Adler-32 of `total` zero bytes.
540        let (a, b) = (1u64, total % 65521);
541        z.extend_from_slice(&(((b << 16) | a) as u32).to_be_bytes());
542        let mut out = b"\x89PNG\r\n\x1a\n".to_vec();
543        let mut ihdr = Vec::new();
544        ihdr.extend_from_slice(&w.to_be_bytes());
545        ihdr.extend_from_slice(&h.to_be_bytes());
546        ihdr.extend_from_slice(&[8, 3, 0, 0, 0]);
547        chunk(&mut out, b"IHDR", &ihdr);
548        chunk(&mut out, b"PLTE", &rgb);
549        chunk(&mut out, b"IDAT", &z);
550        chunk(&mut out, b"IEND", &[]);
551        out
552    }
553
554    fn length_code(l: u32) -> (u32, u32, u32) {
555        const BASE: [u32; 29] = [3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258];
556        const EXTRA: [u32; 29] = [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0];
557        let i = BASE.iter().rposition(|&b| b <= l).unwrap();
558        (257 + i as u32, BASE[i], EXTRA[i])
559    }
560
561    fn image(w: u32, h: u32, px: &[[u8; 4]]) -> Arc<Image> {
562        Arc::new(Image { name: "t".into(), width: w, height: h, rgba: px.iter().flatten().copied().collect() })
563    }
564
565    #[test]
566    fn paths_stay_next_to_the_model() {
567        assert_eq!(candidates("tex/wood.png"), ["tex/wood.png", "wood.png"]);
568        assert_eq!(candidates("./wood.png"), ["wood.png"]);
569        assert_eq!(candidates("tex\\wood.png"), ["tex/wood.png", "wood.png"]);
570        // Climbing out or absolute: only the file name, next to the model.
571        assert_eq!(candidates("../x.png"), ["x.png"]);
572        assert_eq!(candidates("a/../../x.png"), ["x.png"]);
573        assert_eq!(candidates("/etc/x.png"), ["x.png"]);
574        assert_eq!(candidates("C:\\work\\x.png"), ["x.png"]);
575        assert_eq!(candidates("C:x.png"), ["x.png"]);
576        assert_eq!(candidates("file:///C:/work/g.png"), ["g.png"]);
577        assert!(candidates("..").is_empty() && candidates("").is_empty() && candidates("/").is_empty());
578    }
579
580    #[test]
581    fn traversal_and_absolute_paths_are_not_read() {
582        let dir = tempfile::tempdir().unwrap();
583        let model = dir.path().join("m");
584        std::fs::create_dir_all(model.join("sub")).unwrap();
585        std::fs::write(dir.path().join("x.png"), png(1, 1, [255, 0, 0, 255])).unwrap();
586        std::fs::write(model.join("sub/y.png"), png(1, 1, [0, 255, 0, 255])).unwrap();
587        let asked = std::cell::RefCell::new(Vec::new());
588        let root = model.clone();
589        let side = |n: &str| {
590            asked.borrow_mut().push(n.to_string());
591            std::fs::read(root.join(n)).ok()
592        };
593        let mut l = Loader::new(&side);
594        assert!(l.file("../x.png").is_none(), "above the model");
595        assert!(l.file(dir.path().join("x.png").to_str().unwrap()).is_none(), "absolute");
596        assert!(asked.borrow().iter().all(|n| n == "x.png"), "only the bare name was asked for: {:?}", asked.borrow());
597        // Below the model, by its path or by name from a subfolder file.
598        assert_eq!(l.file("sub/y.png").unwrap().texel(0, 0), [0, 255, 0, 255]);
599        assert!(l.file_in("sub", "y.png").is_some());
600    }
601
602    #[test]
603    fn the_same_picture_decodes_once() {
604        let data = png(4, 4, [9, 9, 9, 255]);
605        let side = |n: &str| (n == "a.png" || n == "b.png").then(|| data.clone());
606        let mut l = Loader::new(&side);
607        let a = l.file("a.png").unwrap();
608        let b = l.file("b.png").unwrap();
609        assert!(Arc::ptr_eq(&a, &b), "same bytes, one picture");
610        assert_eq!(l.left(), BUDGET - 64);
611        assert!(l.file("missing.png").is_none());
612        assert!(l.embedded("junk", b"not a picture").is_none());
613        assert_eq!(l.left(), BUDGET - 64);
614        let q = l.embedded("q", &png_2x2()).unwrap();
615        assert_eq!((q.width, q.height, q.texel(0, 1)), (2, 2, [0, 0, 255, 255]));
616    }
617
618    #[test]
619    fn huge_pictures_are_scaled_down_within_the_budget() {
620        // A 10000 x 10000 picture comes back 4096 x 4096.
621        let big = huge_png(10_000, 10_000, [200, 10, 20]);
622        assert!(big.len() < 8_000_000);
623        let side = |_: &str| None;
624        let mut l = Loader::new(&side);
625        let img = l.embedded("big", &big).unwrap();
626        assert_eq!((img.width, img.height), (MAX_SIDE, MAX_SIDE));
627        let mid = img.texel(2000, 2000);
628        assert!(mid[0] > 190 && mid[1] < 30 && mid[3] == 255, "{mid:?}");
629        // 4096² RGBA is the whole budget: a second, different picture has
630        // nothing left and is not decoded.
631        assert_eq!(l.left(), 0);
632        assert!(l.embedded("other", &huge_png(5000, 5000, [1, 2, 3])).is_none());
633        // With half the budget used, the next is halved to fit.
634        let mut l = Loader::with_budget(&side, BUDGET);
635        let a = l.embedded("a", &huge_png(4096, 2048, [1, 1, 1])).unwrap();
636        assert_eq!((a.width, a.height), (4096, 2048));
637        let b = l.embedded("b", &huge_png(4096, 4096, [2, 2, 2])).unwrap();
638        assert_eq!((b.width, b.height), (2048, 2048));
639        assert_eq!(l.left(), BUDGET - (4096 * 2048 + 2048 * 2048) * 4);
640    }
641
642    #[test]
643    fn fitting_sizes() {
644        assert_eq!(fit_size(100, 50, 4096, BUDGET), Some((100, 50)));
645        assert_eq!(fit_size(10_000, 5_000, 4096, BUDGET), Some((4096, 2048)));
646        assert_eq!(fit_size(4096, 4096, 4096, BUDGET / 2), Some((2048, 2048)));
647        assert_eq!(fit_size(8, 8, 4096, 3), None);
648        assert_eq!(fit_size(0, 8, 4096, BUDGET), None);
649    }
650
651    #[test]
652    fn coordinates_repeat() {
653        assert_eq!(wrap_coord(1.25), 0.25);
654        assert_eq!(wrap_coord(-0.25), 0.75);
655        assert_eq!(wrap_coord(f32::NAN), 0.0);
656    }
657
658    fn close(a: [f32; 4], b: [f32; 4]) -> bool {
659        a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4)
660    }
661
662    #[test]
663    fn sampling_is_bilinear_and_wraps() {
664        let t = Texture::new(image(2, 2, &[[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [255, 255, 255, 255]]));
665        // Texel centres give the texels.
666        assert!(close(t.sample([0.25, 0.25]), [1.0, 0.0, 0.0, 1.0]));
667        assert!(close(t.sample([0.75, 0.75]), [1.0; 4]));
668        assert!(close(t.sample([0.25, 0.75]), [0.0, 0.0, 1.0, 1.0]), "v runs down the picture");
669        // Between red and green: half of each in linear light, brighter
670        // than 0.5 in display values.
671        let mid = t.sample([0.5, 0.25]);
672        assert!((mid[0] - mid[1]).abs() < 1e-5 && mid[0] > 0.7 && mid[2] == 0.0, "{mid:?}");
673        // Repeat: one past the right edge is the left column again.
674        assert!(close(t.sample([1.25, 0.25]), t.sample([0.25, 0.25])));
675        // At the corner the sample blends with the opposite side.
676        assert!(!close(t.sample([0.0, 0.0]), [1.0, 0.0, 0.0, 1.0]));
677        // Transparent texels don't darken their neighbours.
678        let a = Texture::new(image(2, 1, &[[255, 0, 0, 255], [0, 0, 0, 0]]));
679        assert!(close(a.sample([0.5, 0.5]), [1.0, 0.0, 0.0, 0.5]));
680    }
681
682    #[test]
683    fn modulating_multiplies_in_linear_light() {
684        assert_eq!(modulate([1.0; 3], [0.2, 0.4, 0.6, 1.0]).map(|c| (c * 1000.0).round()), [200.0, 400.0, 600.0]);
685        let half = modulate([0.5; 3], [0.5, 0.5, 0.5, 1.0]);
686        assert!(half[0] < 0.25 + 0.2 && half[0] > 0.2, "{half:?}");
687    }
688
689    #[test]
690    fn mipmaps_halve_to_one_pixel() {
691        let img = Image { name: String::new(), width: 4, height: 2, rgba: [[255u8, 0, 0, 255], [0, 0, 255, 255]].repeat(4).concat() };
692        let chain = mip_chain(&img);
693        assert_eq!(chain.iter().map(|l| (l.0, l.1)).collect::<Vec<_>>(), [(2, 1), (1, 1)]);
694        // Red and blue columns average in linear light.
695        let p = &chain[1].2;
696        assert!(p[0] > 180 && p[0] == p[2] && p[1] == 0 && p[3] == 255, "{p:?}");
697        assert!(mip_chain(&Image { name: String::new(), width: 1, height: 1, rgba: vec![0; 4] }).is_empty());
698    }
699
700    #[test]
701    fn data_mipmaps_average_the_stored_values() {
702        let img = Image { name: String::new(), width: 2, height: 1, rgba: vec![0, 100, 200, 0, 100, 100, 100, 255] };
703        let chain = mip_chain_data(&img);
704        assert_eq!(chain.len(), 1);
705        // Plain averages with the row repeated: no linear light, no alpha weight.
706        assert_eq!(chain[0].2, [50, 100, 150, 128]);
707        assert!(mip_chain_data(&Image { name: String::new(), width: 1, height: 1, rgba: vec![0; 4] }).is_empty());
708    }
709
710    #[test]
711    fn data_sampling_keeps_the_stored_values() {
712        let t = Texture::new(image(2, 1, &[[0, 0, 0, 0], [255, 51, 255, 255]]));
713        let mid = t.sample_data([0.5, 0.5]);
714        assert!((mid[0] - 0.5).abs() < 1e-5 && (mid[1] - 0.1).abs() < 1e-5, "{mid:?}");
715    }
716
717    #[test]
718    fn maps_take_at_most_half_of_what_is_left() {
719        let side = |_: &str| None;
720        let mut l = Loader::with_budget(&side, 64 * 64 * 4);
721        let big = png(64, 64, [1, 2, 3, 255]);
722        let map = l.for_map(|l| l.embedded("n", &big)).unwrap();
723        assert_eq!((map.width, map.height), (32, 32), "halved to fit half the budget");
724        // Pictures read afterwards have the rest.
725        let rest = l.embedded("b", &png(64, 32, [4, 5, 6, 255])).unwrap();
726        assert_eq!((rest.width, rest.height), (64, 32));
727        assert_eq!(l.left(), 64 * 64 * 4 - 32 * 32 * 4 - 64 * 32 * 4);
728    }
729
730    #[test]
731    fn averages_weigh_alpha() {
732        let img = image(2, 1, &[[0, 0, 255, 255], [255, 0, 0, 0]]);
733        assert_eq!(img.average(), Some([0.0, 0.0, 1.0]));
734        assert_eq!(image(1, 1, &[[1, 2, 3, 0]]).average(), None);
735    }
736}