Skip to main content

falcon_library/model3d/
raster.rs

1//! A software rasterizer for 3D model thumbnails, as Eagle makes them at
2//! import: the model is drawn in the viewer's home view on a 1080 square
3//! with a transparent background, the empty border is cropped away and 40
4//! transparent pixels are added on every side.
5//!
6//! The view is the viewer's: Y up, the eye in direction (-1.5, 2, 3) from the
7//! centre of the bounding sphere, 45 degrees of vertical field of view, at
8//! the distance where the sphere fills the view. Lighting is
9//! [`geom::shade`] with the light at the eye.
10//!
11//! Triangles are filled with fixed-point edge functions (a consistent
12//! tie-break rule, so shared edges leave neither cracks nor double hits)
13//! into a 2x supersampled z-buffer, which is then box-filtered down with
14//! premultiplied alpha so the silhouette is antialiased.
15//!
16//! Textured meshes sample their picture per pixel ([`texture::Texture::sample`],
17//! bilinear in linear light) at perspective-correct coordinates; pixels
18//! below a texture's alpha cutoff are left out. Normal, bump and emissive
19//! maps are read at the same coordinates ([`geom::normal_mapped`],
20//! [`geom::bumped`]).
21//!
22//! See-through meshes are drawn after the rest, farthest first, each one's
23//! back faces and then its front faces, and write depth as they go: what
24//! Eagle's viewer (three.js) and the app's shader do.
25//!
26//! Triangles with any vertex nearer than the near plane are skipped rather
27//! than clipped: the fitted camera keeps the whole bounding sphere in front
28//! of the eye (the near plane is at most `d - 2r`), so nothing is lost.
29
30use image::{Rgba, RgbaImage};
31
32use super::geom;
33use super::math::{self, V3};
34use super::texture;
35use super::{DEFAULT_COLOR, Mesh, Model};
36
37/// Side of the square Eagle renders models on.
38pub const CANVAS: u32 = 1080;
39/// Transparent border added around the cropped render.
40pub const PADDING: u32 = 40;
41
42const FOV_DEG: f32 = 45.0;
43const HOME_DIRECTION: V3 = [-1.5, 2.0, 3.0];
44/// Samples per pixel side.
45const SS: u32 = 2;
46/// Sub-pixel precision of the fixed-point coordinates.
47const FRAC: i64 = 256;
48/// Triangles reaching further than this many pixels off the canvas are
49/// skipped (they only occur for cameras inside the model).
50const LIMIT: f32 = 1.0e6;
51
52/// Draws `model` on a transparent `size` x `size` image.
53pub fn render(model: &Model, size: u32) -> RgbaImage {
54    let size = size.max(1);
55    let mut out = RgbaImage::new(size, size);
56    let Some((center, radius)) = model.bounding_sphere() else { return out };
57    if !(radius.is_finite() && radius > 0.0) {
58        return out;
59    }
60    let s = size * SS;
61    let dir = math::normalize(HOME_DIRECTION);
62    let dist = radius / (FOV_DEG / 2.0).to_radians().sin();
63    let eye = math::add(center, math::scale(dir, dist));
64    let near = (dist - 2.0 * radius).max(dist * 0.001);
65    let far = dist + 2.0 * radius;
66    let view = math::look_at(eye, center, [0.0, 1.0, 0.0]);
67    let proj = math::perspective(FOV_DEG.to_radians(), 1.0, near, far);
68    let vp = math::mul(&proj, &view);
69
70    let mut target = Target { size: s, depth: vec![f32::INFINITY; (s * s) as usize], color: vec![[0; 4]; (s * s) as usize] };
71    draw_scene(&mut target, model.meshes.iter(), &[], &vp, near, dir, DEFAULT_COLOR);
72    downsample(&target, &mut out);
73    out
74}
75
76/// Draws `meshes` as the viewer shows them: a `w` x `h` picture through
77/// the camera matrix `vp` (projection times view, clip depth 0..1), lit
78/// from `to_eye` (unit, towards the camera), meshes without a colour in
79/// `default_color`, on a transparent background. The viewer's snapshot.
80/// [`render_view_lines`] adds the viewer's lines.
81///
82/// The square rasterizer draws a square of the longer side with the
83/// picture's window scaled into its middle, which is then cut out.
84pub fn render_view<'a>(meshes: impl IntoIterator<Item = &'a Mesh>, w: u32, h: u32, vp: &math::M4, to_eye: V3, default_color: V3) -> RgbaImage {
85    render_view_lines(meshes, &[], w, h, vp, to_eye, default_color)
86}
87
88/// [`render_view`] with line segments drawn over the faces, each in its
89/// colour, about a pixel wide and hidden where a face is in front of them
90/// (the edges, grid and axes the viewer shows).
91pub fn render_view_lines<'a>(meshes: impl IntoIterator<Item = &'a Mesh>, lines: &[([V3; 2], V3)], w: u32, h: u32, vp: &math::M4, to_eye: V3, default_color: V3) -> RgbaImage {
92    let (w, h) = (w.max(1), h.max(1));
93    let side = w.max(h);
94    let (sx, sy) = (w as f32 / side as f32, h as f32 / side as f32);
95    let mut m = *vp;
96    for col in &mut m {
97        col[0] *= sx;
98        col[1] *= sy;
99    }
100    let s = side * SS;
101    let mut target = Target { size: s, depth: vec![f32::INFINITY; (s * s) as usize], color: vec![[0; 4]; (s * s) as usize] };
102    // Clip w is the distance in front of the eye (1 for orthographic).
103    draw_scene(&mut target, meshes, lines, &m, 1e-6, to_eye, default_color);
104    let mut square = RgbaImage::new(side, side);
105    downsample(&target, &mut square);
106    image::imageops::crop_imm(&square, (side - w) / 2, (side - h) / 2, w, h).to_image()
107}
108
109/// Which of a mesh's faces to draw.
110#[derive(Clone, Copy, PartialEq)]
111enum Faces {
112    All,
113    Back,
114    Front,
115}
116
117/// Opaque meshes, then lines, then the see-through meshes.
118fn draw_scene<'a>(t: &mut Target, meshes: impl IntoIterator<Item = &'a Mesh>, lines: &[([V3; 2], V3)], vp: &math::M4, near: f32, to_eye: V3, default_color: V3) {
119    let (blended, opaque): (Vec<&Mesh>, Vec<&Mesh>) = meshes.into_iter().partition(|m| m.surface.blend);
120    for mesh in opaque {
121        draw_mesh(t, mesh, vp, near, to_eye, default_color, Faces::All);
122    }
123    for (seg, color) in lines {
124        draw_line(t, *seg, vp, near, *color);
125    }
126    let centers: Vec<V3> = blended.iter().map(|m| geom::center(m).unwrap_or_default()).collect();
127    for i in geom::back_to_front(&centers, vp) {
128        draw_mesh(t, blended[i], vp, near, to_eye, default_color, Faces::Back);
129        draw_mesh(t, blended[i], vp, near, to_eye, default_color, Faces::Front);
130    }
131}
132
133/// A straight-alpha colour `src` over `dst`.
134fn over(src: [u8; 4], dst: [u8; 4]) -> [u8; 4] {
135    let (sa, da) = (f32::from(src[3]) / 255.0, f32::from(dst[3]) / 255.0);
136    let a = sa + da * (1.0 - sa);
137    if a <= 0.0 {
138        return [0; 4];
139    }
140    let mix = |k: usize| ((f32::from(src[k]) * sa + f32::from(dst[k]) * da * (1.0 - sa)) / a).round() as u8;
141    [mix(0), mix(1), mix(2), (a * 255.0).round() as u8]
142}
143
144/// How far in front of a face (in clip depth) a line on it still shows,
145/// as the viewer's GPU depth bias lets its edges show.
146const LINE_BIAS: f32 = 2e-4;
147
148/// Draws a segment into `t`, one sample per step along its longer axis and
149/// `SS` samples across (about a pixel once filtered down), depth-tested.
150/// A segment with an end behind the near plane is skipped.
151fn draw_line(t: &mut Target, seg: [V3; 2], vp: &math::M4, near: f32, color: V3) {
152    let size = t.size as f32;
153    let ends = seg.map(|p| {
154        let v = [p[0], p[1], p[2], 1.0];
155        let c: [f32; 4] = std::array::from_fn(|r| (0..4).map(|k| vp[k][r] * v[k]).sum());
156        (c[3] >= near && c.iter().all(|x| x.is_finite())).then(|| [(c[0] / c[3] * 0.5 + 0.5) * size, (0.5 - c[1] / c[3] * 0.5) * size, c[2] / c[3]])
157    });
158    let [Some(a), Some(b)] = ends else { return };
159    let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
160    let steps = dx.abs().max(dy.abs()).ceil();
161    if !(steps.is_finite() && steps < LIMIT) {
162        return;
163    }
164    let steps = steps.max(1.0) as u32;
165    let across_x = dy.abs() > dx.abs();
166    let px = pack(color);
167    for i in 0..=steps {
168        let f = i as f32 / steps as f32;
169        let (x, y, z) = (a[0] + dx * f, a[1] + dy * f, a[2] + (b[2] - a[2]) * f);
170        for k in 0..SS as i64 {
171            let off = k - (SS as i64 - 1) / 2;
172            let (sx, sy) = if across_x { (x.floor() as i64 + off, y.floor() as i64) } else { (x.floor() as i64, y.floor() as i64 + off) };
173            if sx < 0 || sy < 0 || sx >= t.size as i64 || sy >= t.size as i64 {
174                continue;
175            }
176            let at = (sy * t.size as i64 + sx) as usize;
177            if z - LINE_BIAS <= t.depth[at] {
178                t.depth[at] = t.depth[at].min(z);
179                t.color[at] = px;
180            }
181        }
182    }
183}
184
185struct Target {
186    size: u32,
187    depth: Vec<f32>,
188    color: Vec<[u8; 4]>,
189}
190
191/// Screen position (pixels, y down), depth (0..1) and 1 / clip w of every
192/// vertex, or `None` where it is behind the near plane or not finite.
193fn project(mesh: &Mesh, vp: &math::M4, near: f32, size: f32) -> Vec<Option<[f32; 4]>> {
194    mesh.positions
195        .iter()
196        .map(|p| {
197            let v = [p[0], p[1], p[2], 1.0];
198            let c: [f32; 4] = std::array::from_fn(|r| (0..4).map(|k| vp[k][r] * v[k]).sum());
199            // w is the distance in front of the eye.
200            if c[3] < near || !c.iter().all(|x| x.is_finite()) {
201                return None;
202            }
203            let (x, y, z) = (c[0] / c[3], c[1] / c[3], c[2] / c[3]);
204            let (sx, sy) = ((x * 0.5 + 0.5) * size, (0.5 - y * 0.5) * size);
205            (sx.abs() < LIMIT && sy.abs() < LIMIT).then_some([sx, sy, z, 1.0 / c[3]])
206        })
207        .collect()
208}
209
210fn pack(c: V3) -> [u8; 4] {
211    let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
212    [b(c[0]), b(c[1]), b(c[2]), 255]
213}
214
215/// The normal, bump and emissive maps to read, with the coordinates they
216/// are read at; `None` for a mesh without coordinates.
217struct Maps<'a> {
218    uvs: &'a [[f32; 2]],
219    normal: Option<&'a texture::Texture>,
220    bump: Option<&'a texture::Texture>,
221    emissive: Option<&'a texture::Texture>,
222}
223
224/// The surface's normal at `uv` through its normal or bump map, on the
225/// triangle `i` of `mesh`, whose interpolated normal is `n`.
226fn mapped_normal(maps: &Maps, mesh: &Mesh, i: [usize; 3], n: V3, uv: [f32; 2]) -> V3 {
227    let edge = |a: usize, b: usize| math::sub(mesh.positions[b], mesh.positions[a]);
228    let st = |a: usize, b: usize| [maps.uvs[b][0] - maps.uvs[a][0], maps.uvs[b][1] - maps.uvs[a][1]];
229    let (q0, q1, st0, st1) = (edge(i[0], i[1]), edge(i[0], i[2]), st(i[0], i[1]), st(i[0], i[2]));
230    if let Some(map) = maps.normal {
231        geom::normal_mapped(n, q0, q1, st0, st1, map.sample_data(uv))
232    } else if let Some(map) = maps.bump {
233        // One pixel of the map either way along each axis.
234        let (du, dv) = (1.0 / map.image.width.max(1) as f32, 1.0 / map.image.height.max(1) as f32);
235        let h = |u: f32, v: f32| map.sample_data([u, v])[0];
236        let dh = [(h(uv[0] + du, uv[1]) - h(uv[0] - du, uv[1])) / (2.0 * du), (h(uv[0], uv[1] + dv) - h(uv[0], uv[1] - dv)) / (2.0 * dv)];
237        geom::bumped(n, q0, q1, st0, st1, dh)
238    } else {
239        n
240    }
241}
242
243fn draw_mesh(t: &mut Target, mesh: &Mesh, vp: &math::M4, near: f32, to_eye: V3, default_color: V3, faces: Faces) {
244    let verts = project(mesh, vp, near, t.size as f32);
245    let base = mesh.material.as_ref().map_or(default_color, |m| m.color);
246    let colors = mesh.colors.as_deref();
247    let normals = mesh.normals.as_deref();
248    let textured = mesh.textured();
249    let maps = mesh.uvs.as_deref().map(|uvs| Maps { uvs, normal: mesh.surface.normal_map.as_ref(), bump: mesh.surface.bump_map.as_ref(), emissive: mesh.surface.emissive_map.as_ref() });
250    let maps = maps.filter(|m| m.normal.is_some() || m.bump.is_some() || m.emissive.is_some());
251    let emissive = mesh.surface.emissive;
252    let (blend, opacity) = (mesh.surface.blend, mesh.opacity());
253    let size = t.size as i64;
254    for tri in mesh.indices.as_chunks::<3>().0 {
255        let idx = tri.map(|i| i as usize);
256        let (Some(a), Some(b), Some(c)) = (verts[idx[0]], verts[idx[1]], verts[idx[2]]) else { continue };
257        let fx = |p: [f32; 4]| [(p[0] * FRAC as f32).round() as i64, (p[1] * FRAC as f32).round() as i64];
258        let (p0, mut p1, mut p2) = (fx(a), fx(b), fx(c));
259        let (z0, mut z1, mut z2) = (a[2], b[2], c[2]);
260        let (q0, mut q1, mut q2) = (a[3], b[3], c[3]);
261        let (i0, mut i1, mut i2) = (idx[0], idx[1], idx[2]);
262        let mut area = (p1[0] - p0[0]) * (p2[1] - p0[1]) - (p1[1] - p0[1]) * (p2[0] - p0[0]);
263        if area == 0 {
264            continue;
265        }
266        // With y down, a triangle counter-clockwise to the eye has a
267        // negative area here.
268        let front = area < 0;
269        if (faces == Faces::Back && front) || (faces == Faces::Front && !front) {
270            continue;
271        }
272        if area < 0 {
273            std::mem::swap(&mut p1, &mut p2);
274            std::mem::swap(&mut z1, &mut z2);
275            std::mem::swap(&mut q1, &mut q2);
276            std::mem::swap(&mut i1, &mut i2);
277            area = -area;
278        }
279        let x0 = (p0[0].min(p1[0]).min(p2[0]) / FRAC).max(0);
280        let x1 = ((p0[0].max(p1[0]).max(p2[0]) + FRAC - 1) / FRAC).min(size - 1);
281        let y0 = (p0[1].min(p1[1]).min(p2[1]) / FRAC).max(0);
282        let y1 = ((p0[1].max(p1[1]).max(p2[1]) + FRAC - 1) / FRAC).min(size - 1);
283        if x0 > x1 || y0 > y1 {
284            continue;
285        }
286        // Edge i is opposite vertex i: e_i(p) = (b-a) x (p-a), positive
287        // inside. Steps per pixel and the tie-break bias per edge.
288        let edges = [(p1, p2), (p2, p0), (p0, p1)];
289        let step_x: [i64; 3] = edges.map(|(a, b)| -(b[1] - a[1]) * FRAC);
290        let step_y: [i64; 3] = edges.map(|(a, b)| (b[0] - a[0]) * FRAC);
291        // A point exactly on an edge belongs to the triangle on one side
292        // only: the edge owns it when its direction passes this test, which
293        // holds for exactly one of the two directions of a shared edge.
294        let owns: [bool; 3] = edges.map(|(a, b)| {
295            let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
296            dy > 0 || (dy == 0 && dx > 0)
297        });
298        let px0 = x0 * FRAC + FRAC / 2;
299        let py0 = y0 * FRAC + FRAC / 2;
300        let mut row: [i64; 3] = std::array::from_fn(|i| {
301            let (a, b) = edges[i];
302            (b[0] - a[0]) * (py0 - a[1]) - (b[1] - a[1]) * (px0 - a[0])
303        });
304        // Per-pixel attributes, or one colour for the whole face.
305        let per_pixel = colors.is_some() || normals.is_some() || textured.is_some() || maps.is_some() || blend || emissive != [0.0; 3];
306        let flat = if per_pixel {
307            [0; 4]
308        } else {
309            let n = geom::face_normal(mesh.positions[i0], mesh.positions[i1], mesh.positions[i2]);
310            pack(geom::shade(base, n, to_eye))
311        };
312        let inv_area = 1.0 / area as f32;
313        for y in y0..=y1 {
314            let mut e = row;
315            let line = (y * size) as usize;
316            for x in x0..=x1 {
317                let inside = (0..3).all(|i| e[i] > 0 || (e[i] == 0 && owns[i]));
318                if inside {
319                    let w = [e[0] as f32 * inv_area, e[1] as f32 * inv_area, e[2] as f32 * inv_area];
320                    let z = w[0] * z0 + w[1] * z1 + w[2] * z2;
321                    let k = line + x as usize;
322                    if (0.0..=1.0).contains(&z) && z < t.depth[k] {
323                        let px = if per_pixel {
324                            let mix = |f: &dyn Fn(usize) -> V3| {
325                                let (u, v, q) = (f(i0), f(i1), f(i2));
326                                std::array::from_fn(|c| w[0] * u[c] + w[1] * v[c] + w[2] * q[c])
327                            };
328                            let col = match textured {
329                                // The picture, times the vertex colours
330                                // and the tint, in linear light.
331                                Some((tex, uvs)) => {
332                                    let uv = perspective_uv(w, [q0, q1, q2], [uvs[i0], uvs[i1], uvs[i2]]);
333                                    let texel = tex.sample(uv);
334                                    let alpha = texel[3] * opacity;
335                                    if tex.alpha_cutoff.is_some_and(|cut| alpha < cut) {
336                                        None
337                                    } else {
338                                        let tint = colors.map_or(tex.tint, |cs| texture::modulate(mix(&|i| cs[i]), [tex.tint[0], tex.tint[1], tex.tint[2], 1.0]));
339                                        Some((texture::modulate(tint, texel), if blend { alpha } else { 1.0 }))
340                                    }
341                                }
342                                None => Some((colors.map_or(base, |cs| mix(&|i| cs[i])), opacity)),
343                            };
344                            col.map(|(col, alpha)| {
345                                let mut n = match normals {
346                                    Some(ns) => math::normalize(mix(&|i| ns[i])),
347                                    None => geom::face_normal(mesh.positions[i0], mesh.positions[i1], mesh.positions[i2]),
348                                };
349                                let mut light = emissive;
350                                if let Some(m) = &maps {
351                                    let uv = perspective_uv(w, [q0, q1, q2], [m.uvs[i0], m.uvs[i1], m.uvs[i2]]);
352                                    if m.normal.is_some() && !front {
353                                        n = math::scale(n, -1.0);
354                                    }
355                                    n = mapped_normal(m, mesh, [i0, i1, i2], n, uv);
356                                    if let Some(e) = m.emissive {
357                                        let px = e.sample_data(uv);
358                                        light = std::array::from_fn(|k| light[k] * px[k]);
359                                    }
360                                }
361                                let mut px = pack(geom::add_light(geom::shade(col, n, to_eye), light));
362                                px[3] = (alpha * 255.0).round() as u8;
363                                px
364                            })
365                        } else {
366                            Some(flat)
367                        };
368                        if let Some(px) = px {
369                            t.depth[k] = z;
370                            t.color[k] = if blend { over(px, t.color[k]) } else { px };
371                        }
372                    }
373                }
374                for i in 0..3 {
375                    e[i] += step_x[i];
376                }
377            }
378            for i in 0..3 {
379                row[i] += step_y[i];
380            }
381        }
382    }
383}
384
385/// Texture coordinates at screen weights `w` of a triangle whose corners
386/// have 1 / clip w `q` and coordinates `uv`: interpolated as uv / w and
387/// 1 / w, so they stay straight on the surface under perspective.
388fn perspective_uv(w: [f32; 3], q: [f32; 3], uv: [[f32; 2]; 3]) -> [f32; 2] {
389    let d = w[0] * q[0] + w[1] * q[1] + w[2] * q[2];
390    if !(d.is_finite() && d != 0.0) {
391        return uv[0];
392    }
393    std::array::from_fn(|k| (w[0] * q[0] * uv[0][k] + w[1] * q[1] * uv[1][k] + w[2] * q[2] * uv[2][k]) / d)
394}
395
396/// Averages each `SS` x `SS` block with premultiplied alpha.
397fn downsample(t: &Target, out: &mut RgbaImage) {
398    let n = SS * SS;
399    for (x, y, px) in out.enumerate_pixels_mut() {
400        let (mut a, mut c) = (0u32, [0u32; 3]);
401        for dy in 0..SS {
402            for dx in 0..SS {
403                let s = t.color[((y * SS + dy) * t.size + x * SS + dx) as usize];
404                let sa = u32::from(s[3]);
405                a += sa;
406                for k in 0..3 {
407                    c[k] += u32::from(s[k]) * sa;
408                }
409            }
410        }
411        if a > 0 {
412            let rgb = c.map(|v| (v as f32 / a as f32).round() as u8);
413            *px = Rgba([rgb[0], rgb[1], rgb[2], (a as f32 / n as f32).round() as u8]);
414        }
415    }
416}
417
418/// Crops `img` to the bounding box of its pixels with alpha above 0 and adds
419/// `pad` transparent pixels on every side; `None` when nothing is visible.
420pub fn trim_and_pad(img: &RgbaImage, pad: u32) -> Option<RgbaImage> {
421    let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0, 0);
422    for (x, y, p) in img.enumerate_pixels() {
423        if p[3] > 0 {
424            x0 = x0.min(x);
425            y0 = y0.min(y);
426            x1 = x1.max(x);
427            y1 = y1.max(y);
428        }
429    }
430    if x0 > x1 {
431        return None;
432    }
433    let (w, h) = (x1 - x0 + 1, y1 - y0 + 1);
434    let mut out = RgbaImage::new(w + 2 * pad, h + 2 * pad);
435    image::imageops::replace(&mut out, &image::imageops::crop_imm(img, x0, y0, w, h).to_image(), i64::from(pad), i64::from(pad));
436    Some(out)
437}
438
439/// Eagle's import thumbnail of the 3D file at `path`: the render on a
440/// [`CANVAS`] square, trimmed and padded by [`PADDING`]. `None` when the
441/// file can't be read as a model or shows nothing.
442pub fn thumbnail(path: &std::path::Path, ext: &str) -> Option<RgbaImage> {
443    let model = super::load(path, ext).ok()?;
444    trim_and_pad(&render(&model, CANVAS), PADDING)
445}
446
447#[cfg(test)]
448mod tests {
449    use super::super::Material;
450    use super::super::geom::tests::cube;
451    use super::*;
452    use std::collections::HashMap;
453
454    fn cube_model() -> Model {
455        Model { meshes: vec![cube(false)] }
456    }
457
458    fn lum(p: &Rgba<u8>) -> u32 {
459        u32::from(p[0]) + u32::from(p[1]) + u32::from(p[2])
460    }
461
462    #[test]
463    fn cube_fills_the_middle_and_leaves_the_corners_clear() {
464        let img = render(&cube_model(), 64);
465        assert_eq!(img.dimensions(), (64, 64));
466        assert_eq!(img.get_pixel(32, 32)[3], 255);
467        for (x, y) in [(0, 0), (63, 0), (0, 63), (63, 63)] {
468            assert_eq!(img.get_pixel(x, y)[3], 0);
469        }
470        // Edges are antialiased: some partial alpha exists.
471        assert!(img.pixels().any(|p| p[3] > 0 && p[3] < 255));
472    }
473
474    #[test]
475    fn visible_faces_differ_in_brightness() {
476        let img = render(&cube_model(), 128);
477        let mut counts: HashMap<[u8; 3], u32> = HashMap::new();
478        for p in img.pixels().filter(|p| p[3] == 255) {
479            *counts.entry([p[0], p[1], p[2]]).or_default() += 1;
480        }
481        // Top, front and left faces: three large flat areas.
482        let mut big: Vec<_> = counts.into_iter().filter(|&(_, n)| n > 300).collect();
483        big.sort();
484        assert_eq!(big.len(), 3, "{big:?}");
485        // The front (+z) faces the eye most directly of the three.
486        let top = img.get_pixel(64, 40);
487        let front = img.get_pixel(90, 100);
488        assert_ne!(top, front);
489    }
490
491    #[test]
492    fn material_colour_is_used() {
493        let mut m = cube(false);
494        m.material = Some(Material { name: "red".into(), color: [1.0, 0.0, 0.0], opacity: 1.0 });
495        let img = render(&Model { meshes: vec![m] }, 64);
496        let p = img.get_pixel(32, 32);
497        assert!(p[0] > 100 && p[1] == 0 && p[2] == 0, "{p:?}");
498    }
499
500    #[test]
501    fn vertex_colours_are_used() {
502        let mut m = cube(false);
503        m.colors = Some(vec![[0.0, 1.0, 0.0]; 8]);
504        let img = render(&Model { meshes: vec![m] }, 64);
505        let p = img.get_pixel(32, 32);
506        assert!(p[1] > 100 && p[0] == 0 && p[2] == 0, "{p:?}");
507    }
508
509    /// The split cube with each face's quad mapped over the whole of
510    /// `image`.
511    fn textured_cube(image: texture::Image) -> Model {
512        let mut m = cube(true);
513        let quad = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
514        m.uvs = Some((0..36).map(|i| quad[i % 6]).collect());
515        m.texture = Some(texture::Texture::new(std::sync::Arc::new(image)));
516        m.material = Some(Material { name: "grey".into(), color: [0.5; 3], opacity: 1.0 });
517        Model { meshes: vec![m] }
518    }
519
520    fn solid(w: u32, h: u32, rgba: [u8; 4]) -> texture::Image {
521        texture::Image { name: "t".into(), width: w, height: h, rgba: rgba.repeat((w * h) as usize) }
522    }
523
524    #[test]
525    fn textured_cube_shows_the_texture_colour() {
526        let img = render(&textured_cube(solid(4, 4, [0, 0, 255, 255])), 64);
527        let p = img.get_pixel(32, 32);
528        assert!(p[2] > 100 && p[0] == 0 && p[1] == 0, "the picture replaces the grey material: {p:?}");
529        // Without coordinates the material colour is drawn.
530        let mut m = textured_cube(solid(4, 4, [0, 0, 255, 255]));
531        m.meshes[0].uvs = None;
532        let p = *render(&m, 64).get_pixel(32, 32);
533        assert!(p[0] == p[1] && p[1] == p[2] && p[0] > 50, "{p:?}");
534    }
535
536    #[test]
537    fn both_halves_of_a_picture_show() {
538        // Left half red, right half green on every face.
539        let mut px = Vec::new();
540        for _y in 0..8 {
541            for x in 0..8 {
542                px.extend_from_slice(&if x < 4 { [255, 0, 0, 255] } else { [0, 255, 0, 255] });
543            }
544        }
545        let img = render(&textured_cube(texture::Image { name: "h".into(), width: 8, height: 8, rgba: px }), 128);
546        let red = img.pixels().filter(|p| p[3] == 255 && p[0] > 80 && p[1] < 20).count();
547        let green = img.pixels().filter(|p| p[3] == 255 && p[1] > 80 && p[0] < 20).count();
548        assert!(red > 500 && green > 500, "{red} red, {green} green");
549    }
550
551    #[test]
552    fn cutout_pixels_are_left_out() {
553        let mut m = textured_cube(solid(2, 2, [255, 255, 255, 10]));
554        m.meshes[0].texture.as_mut().unwrap().alpha_cutoff = Some(0.5);
555        assert!(render(&m, 32).pixels().all(|p| p[3] == 0), "everything below the cutoff");
556        // Without a cutoff the picture is opaque.
557        m.meshes[0].texture.as_mut().unwrap().alpha_cutoff = None;
558        assert_eq!(render(&m, 32).get_pixel(16, 16)[3], 255);
559    }
560
561    /// A square facing +z, the whole of a picture on it, seen head on, with
562    /// the light at the eye.
563    fn facing_square() -> Mesh {
564        Mesh {
565            positions: vec![[-1.0, -1.0, 0.0], [1.0, -1.0, 0.0], [1.0, 1.0, 0.0], [-1.0, 1.0, 0.0]],
566            indices: vec![0, 1, 2, 0, 2, 3],
567            uvs: Some(vec![[0.0, 1.0], [1.0, 1.0], [1.0, 0.0], [0.0, 0.0]]),
568            material: Some(Material { name: "m".into(), color: [0.5; 3], opacity: 1.0 }),
569            ..Default::default()
570        }
571    }
572
573    fn head_on(meshes: &[Mesh]) -> RgbaImage {
574        let view = math::look_at([0.0, 0.0, 4.0], [0.0; 3], [0.0, 1.0, 0.0]);
575        let vp = math::mul(&math::perspective(FOV_DEG.to_radians(), 1.0, 0.1, 100.0), &view);
576        render_view(meshes, 32, 32, &vp, [0.0, 0.0, 1.0], [1.0; 3])
577    }
578
579    #[test]
580    fn normal_maps_change_the_shading_and_flat_ones_do_not() {
581        let plain = *head_on(&[facing_square()]).get_pixel(16, 16);
582        let with = |rgb: [u8; 4]| {
583            let mut m = facing_square();
584            m.surface.normal_map = Some(texture::Texture::new(std::sync::Arc::new(solid(2, 2, rgb))));
585            *head_on(&[m]).get_pixel(16, 16)
586        };
587        assert!(with([128, 128, 255, 255])[0].abs_diff(plain[0]) <= 1, "flat");
588        let tilted = with([255, 128, 128, 255]);
589        assert!(tilted[0] < plain[0] - 20, "turned away from the light: {tilted:?} against {plain:?}");
590        // A back face takes the map too, without going black.
591        let mut back = facing_square();
592        back.indices = vec![0, 2, 1, 0, 3, 2];
593        back.surface.normal_map = Some(texture::Texture::new(std::sync::Arc::new(solid(2, 2, [128, 128, 255, 255]))));
594        assert!(head_on(&[back]).get_pixel(16, 16)[0].abs_diff(plain[0]) <= 1);
595    }
596
597    #[test]
598    fn bump_maps_show_their_slopes_and_normal_maps_win() {
599        // A ramp of height across the picture, steep enough to see.
600        let px: Vec<u8> = (0..64).flat_map(|x| [(x * 4) as u8, 0, 0, 255]).collect();
601        let ramp = std::sync::Arc::new(texture::Image { name: "h".into(), width: 64, height: 1, rgba: px });
602        let plain = *head_on(&[facing_square()]).get_pixel(16, 16);
603        let mut m = facing_square();
604        m.surface.bump_map = Some(texture::Texture::new(ramp.clone()));
605        let bumped = *head_on(std::slice::from_ref(&m)).get_pixel(16, 16);
606        assert!(bumped[0] < plain[0] - 3, "{bumped:?} against {plain:?}");
607        m.surface.normal_map = Some(texture::Texture::new(std::sync::Arc::new(solid(2, 2, [128, 128, 255, 255]))));
608        assert!(head_on(&[m]).get_pixel(16, 16)[0].abs_diff(plain[0]) <= 1, "the normal map is used instead");
609    }
610
611    #[test]
612    fn own_light_shows_through_its_map() {
613        let mut m = facing_square();
614        m.material.as_mut().unwrap().color = [0.0; 3];
615        let dark = *head_on(std::slice::from_ref(&m)).get_pixel(16, 16);
616        m.surface.emissive = [1.0, 0.0, 0.0];
617        let lit = *head_on(std::slice::from_ref(&m)).get_pixel(16, 16);
618        assert!(lit[0] == 255 && lit[1] == dark[1], "{lit:?}");
619        // The map multiplies it, and a black factor shows none of the map.
620        m.surface.emissive_map = Some(texture::Texture::new(std::sync::Arc::new(solid(2, 2, [51, 255, 255, 255]))));
621        let lit = *head_on(std::slice::from_ref(&m)).get_pixel(16, 16);
622        assert!(lit[0].abs_diff(dark[0] + 51) <= 1, "{lit:?} over {dark:?}");
623        m.surface.emissive = [0.0; 3];
624        assert_eq!(*head_on(&[m]).get_pixel(16, 16), dark);
625    }
626
627    #[test]
628    fn see_through_meshes_blend_over_what_is_behind() {
629        let mut far = facing_square();
630        far.material.as_mut().unwrap().color = [1.0, 0.0, 0.0];
631        let behind = |m: &Mesh| {
632            let mut m = m.clone();
633            m.positions.iter_mut().for_each(|p| p[2] = -1.0);
634            m
635        };
636        let far = behind(&far);
637        let mut near = facing_square();
638        near.material = Some(Material { name: "g".into(), color: [0.0, 1.0, 0.0], opacity: 0.5 });
639        // Without the flag the opacity is ignored, as in Eagle.
640        let p = *head_on(&[near.clone(), far.clone()]).get_pixel(16, 16);
641        assert!(p[1] > 100 && p[0] < 20 && p[3] == 255, "{p:?}");
642        near.surface.blend = true;
643        // Whatever order the file has them in, the far one is drawn first.
644        for scene in [[near.clone(), far.clone()], [far.clone(), near.clone()]] {
645            let p = *head_on(&scene).get_pixel(16, 16);
646            assert!(p[0] > 50 && p[1] > 50 && p[3] == 255, "both show: {p:?}");
647        }
648        // Alone it leaves the background partly clear.
649        let p = *head_on(std::slice::from_ref(&near)).get_pixel(16, 16);
650        assert!(p[3].abs_diff(128) <= 1 && p[1] > 100, "{p:?}");
651        // The picture's alpha counts too, and a cutoff still removes pixels.
652        near.material.as_mut().unwrap().opacity = 1.0;
653        near.texture = Some(texture::Texture::new(std::sync::Arc::new(solid(2, 2, [0, 255, 0, 64]))));
654        assert!(head_on(std::slice::from_ref(&near)).get_pixel(16, 16)[3].abs_diff(64) <= 1);
655        near.texture.as_mut().unwrap().alpha_cutoff = Some(0.5);
656        assert_eq!(head_on(&[near]).get_pixel(16, 16)[3], 0);
657    }
658
659    /// The back wall of a closed see-through box shows through its front:
660    /// back faces are drawn first even though they come later in the file.
661    #[test]
662    fn a_closed_see_through_box_shows_both_walls() {
663        let mut m = cube(false);
664        m.material = Some(Material { name: "g".into(), color: [0.0, 1.0, 0.0], opacity: 0.5 });
665        m.surface.blend = true;
666        let p = *render(&Model { meshes: vec![m] }, 64).get_pixel(32, 32);
667        assert!(p[3] > 180 && p[3] < 200, "two layers of a half: {p:?}");
668    }
669
670    #[test]
671    fn maps_without_coordinates_are_ignored() {
672        let mut m = facing_square();
673        let plain = *head_on(std::slice::from_ref(&m)).get_pixel(16, 16);
674        m.uvs = None;
675        m.surface.normal_map = Some(texture::Texture::new(std::sync::Arc::new(solid(2, 2, [255, 128, 128, 255]))));
676        assert_eq!(*head_on(std::slice::from_ref(&m)).get_pixel(16, 16), plain);
677        let mut sane = m.clone();
678        sane.sanitize();
679        assert!(sane.surface.normal_map.is_none());
680    }
681
682    #[test]
683    fn coordinates_are_perspective_correct() {
684        // Halfway across the screen between a near corner (w = 1) and a
685        // far one (w = 3), the surface point is a quarter of the way.
686        let uv = perspective_uv([0.5, 0.5, 0.0], [1.0, 1.0 / 3.0, 1.0], [[0.0, 0.0], [1.0, 0.0], [9.0, 9.0]]);
687        assert!((uv[0] - 0.25).abs() < 1e-6 && uv[1] == 0.0, "{uv:?}");
688        // Equal depths interpolate linearly.
689        assert_eq!(perspective_uv([0.25, 0.75, 0.0], [0.5; 3], [[0.0, 0.0], [1.0, 1.0], [0.0, 0.0]]), [0.75, 0.75]);
690    }
691
692    #[test]
693    fn import_thumbnail_of_a_textured_obj() {
694        let dir = tempfile::tempdir().unwrap();
695        let obj = "mtllib c.mtl\nusemtl t\nv 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nvt 0 0\nvt 1 0\nvt 1 1\nvt 0 1\nf 1/1 2/2 3/3 4/4\n";
696        std::fs::write(dir.path().join("c.obj"), obj).unwrap();
697        std::fs::write(dir.path().join("c.mtl"), "newmtl t\nKd 1 1 1\nmap_Kd tex/c.png\n").unwrap();
698        std::fs::create_dir(dir.path().join("tex")).unwrap();
699        std::fs::write(dir.path().join("tex/c.png"), texture::tests::png(4, 4, [255, 128, 0, 255])).unwrap();
700        let img = thumbnail(&dir.path().join("c.obj"), "obj").unwrap();
701        let (w, h) = img.dimensions();
702        let p = img.get_pixel(w / 2, h / 2);
703        assert!(p[3] == 255 && p[0] > p[1] && p[1] > p[2] && p[2] < 20, "orange from the picture: {p:?}");
704        // Without the picture, the white Kd.
705        std::fs::remove_file(dir.path().join("tex/c.png")).unwrap();
706        let img = thumbnail(&dir.path().join("c.obj"), "obj").unwrap();
707        let p = img.get_pixel(img.width() / 2, img.height() / 2);
708        assert!(p[0] == p[1] && p[1] == p[2], "{p:?}");
709    }
710
711    #[test]
712    fn file_normals_change_the_shading() {
713        let flat = render(&cube_model(), 64);
714        let mut m = cube(false);
715        m.normals = Some(vec![[0.0, 1.0, 0.0]; 8]);
716        let lit = render(&Model { meshes: vec![m] }, 64);
717        assert_ne!(lum(flat.get_pixel(32, 32)), 0);
718        // All normals up: one uniform colour over the whole cube.
719        let c = *lit.get_pixel(32, 32);
720        assert!(lit.pixels().filter(|p| p[3] == 255).all(|p| *p == c));
721    }
722
723    #[test]
724    fn no_cracks_between_triangles() {
725        // No pixel is left clear (or partly covered) with opaque neighbours
726        // on all four sides.
727        let img = render(&cube_model(), 128);
728        let mut holes = 0;
729        for y in 1..127 {
730            for x in 1..127 {
731                let full = |dx: i32, dy: i32| img.get_pixel((x as i32 + dx) as u32, (y as i32 + dy) as u32)[3] == 255;
732                if img.get_pixel(x, y)[3] != 255 && full(-1, 0) && full(1, 0) && full(0, -1) && full(0, 1) {
733                    holes += 1;
734                }
735            }
736        }
737        assert_eq!(holes, 0);
738    }
739
740    #[test]
741    fn a_flat_square_still_renders() {
742        let sq = Mesh {
743            positions: vec![[0., 0., 0.], [1., 0., 0.], [1., 0., 1.], [0., 0., 1.]],
744            indices: vec![0, 1, 2, 0, 2, 3],
745            ..Default::default()
746        };
747        let img = render(&Model { meshes: vec![sq] }, 64);
748        assert!(img.pixels().filter(|p| p[3] == 255).count() > 100);
749    }
750
751    #[test]
752    fn trimming_crops_and_pads() {
753        let mut img = RgbaImage::new(10, 8);
754        img.put_pixel(3, 2, Rgba([9, 8, 7, 255]));
755        img.put_pixel(5, 4, Rgba([1, 2, 3, 10]));
756        let out = trim_and_pad(&img, 2).unwrap();
757        assert_eq!(out.dimensions(), (3 + 4, 3 + 4));
758        assert_eq!(*out.get_pixel(2, 2), Rgba([9, 8, 7, 255]));
759        assert_eq!(*out.get_pixel(4, 4), Rgba([1, 2, 3, 10]));
760        assert_eq!(out.get_pixel(0, 0)[3], 0);
761        assert!(trim_and_pad(&RgbaImage::new(5, 5), 3).is_none());
762    }
763
764    #[test]
765    fn missing_file_gives_none() {
766        assert!(thumbnail(std::path::Path::new("/nonexistent/falcon/none.obj"), "obj").is_none());
767    }
768
769    #[test]
770    fn canvas_render_is_trimmed_and_padded() {
771        let img = render(&cube_model(), CANVAS);
772        let out = trim_and_pad(&img, PADDING).unwrap();
773        assert!(out.width() > 2 * PADDING && out.width() <= CANVAS + 2 * PADDING);
774    }
775
776    #[test]
777    fn views_render_at_their_own_shape() {
778        let eye = [0.5, 0.5, 4.0];
779        let view = math::look_at(eye, [0.5, 0.5, 0.5], [0.0, 1.0, 0.0]);
780        let proj = math::perspective(FOV_DEG.to_radians(), 2.0, 0.1, 10.0);
781        let vp = math::mul(&proj, &view);
782        let model = cube_model();
783        let img = render_view(&model.meshes, 80, 40, &vp, [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]);
784        assert_eq!(img.dimensions(), (80, 40));
785        let mid = img.get_pixel(40, 20);
786        assert_eq!(mid[3], 255);
787        assert!(mid[0] > mid[1].saturating_add(150) && mid[1] == mid[2], "the default colour is used: {mid:?}");
788        // Seen square-on, the cube is as tall as it is wide on the picture.
789        let wide = (0..80).filter(|&x| img.get_pixel(x, 20)[3] > 128).count() as i32;
790        let tall = (0..40).filter(|&y| img.get_pixel(40, y)[3] > 128).count() as i32;
791        assert!((wide - tall).abs() <= 2, "{wide} x {tall}");
792        assert_eq!(img.get_pixel(0, 0)[3], 0);
793    }
794
795    #[test]
796    fn lines_draw_over_faces_and_hide_behind_them() {
797        let eye = [0.5, 0.5, 4.0];
798        let view = math::look_at(eye, [0.5, 0.5, 0.5], [0.0, 1.0, 0.0]);
799        let proj = math::perspective(FOV_DEG.to_radians(), 1.0, 0.1, 10.0);
800        let vp = math::mul(&proj, &view);
801        let model = cube_model();
802        // Green across the front face; blue across the middle, behind the cube.
803        let lines = [([[0.0, 0.5, 1.0], [1.0, 0.5, 1.0]], [0.0, 1.0, 0.0]), ([[-3.0, 0.3, -0.5], [4.0, 0.3, -0.5]], [0.0, 0.0, 1.0])];
804        let img = render_view_lines(&model.meshes, &lines, 80, 80, &vp, [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]);
805        let mid = img.get_pixel(40, 40);
806        assert!(mid[1] > 100 && mid[0] < 100, "the front line shows on the face: {mid:?}");
807        let row = (0..80).find(|&y| img.get_pixel(2, y)[2] > 100).expect("the back line shows beside the cube");
808        assert!(img.get_pixel(40, row)[2] < 100, "and is hidden behind it");
809        assert!(img.get_pixel(40, row)[0] > 100);
810        // Without lines it is the plain view.
811        assert_eq!(render_view_lines(&model.meshes, &[], 80, 80, &vp, [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]), render_view(&model.meshes, 80, 80, &vp, [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]));
812    }
813}