1use image::{Rgba, RgbaImage};
31
32use super::geom;
33use super::math::{self, V3};
34use super::texture;
35use super::{DEFAULT_COLOR, Mesh, Model};
36
37pub const CANVAS: u32 = 1080;
39pub const PADDING: u32 = 40;
41
42const FOV_DEG: f32 = 45.0;
43const HOME_DIRECTION: V3 = [-1.5, 2.0, 3.0];
44const SS: u32 = 2;
46const FRAC: i64 = 256;
48const LIMIT: f32 = 1.0e6;
51
52pub 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
76pub 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
88pub 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 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#[derive(Clone, Copy, PartialEq)]
111enum Faces {
112 All,
113 Back,
114 Front,
115}
116
117fn 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(¢ers, 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
133fn 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
144const LINE_BIAS: f32 = 2e-4;
147
148fn 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
191fn 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 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
215struct 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
224fn 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 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 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 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 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 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 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
385fn 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
396fn 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
418pub 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
439pub 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 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 let mut big: Vec<_> = counts.into_iter().filter(|&(_, n)| n > 300).collect();
483 big.sort();
484 assert_eq!(big.len(), 3, "{big:?}");
485 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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}