Skip to main content

threecrate_io/
render.rs

1//! Headless preview images of point clouds and meshes, drawn on the CPU.
2//!
3//! Made for checking work, including by AI agents that can read images: the
4//! object is framed automatically and drawn from a few fixed angles. Mesh
5//! faces seen from the front are blue-grey and faces seen from behind are
6//! orange, so holes (you see the inside) and flipped faces stand out. No GPU
7//! or window is needed.
8
9use std::io::Write;
10use std::path::Path;
11use threecrate_core::{Error, Point3f, Result, TriangleMesh, Vector3f};
12
13/// Something to draw.
14#[derive(Clone, Copy)]
15pub enum Drawable<'a> {
16    Points(&'a [Point3f]),
17    Mesh(&'a TriangleMesh),
18}
19
20/// Direction the camera looks from.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum View {
23    Front,
24    Back,
25    Left,
26    Right,
27    Top,
28    Bottom,
29    /// From the front-right, above: shows three sides at once.
30    Angled,
31}
32
33impl View {
34    pub fn parse(name: &str) -> Result<Self> {
35        Ok(match name.to_ascii_lowercase().as_str() {
36            "front" => View::Front,
37            "back" => View::Back,
38            "left" => View::Left,
39            "right" | "side" => View::Right,
40            "top" => View::Top,
41            "bottom" => View::Bottom,
42            "angled" | "iso" => View::Angled,
43            _ => {
44                return Err(Error::InvalidData(format!(
45                    "unknown view '{name}'; use front, back, left, right, top, bottom or angled"
46                )))
47            }
48        })
49    }
50
51    /// (direction from the object to the camera, up direction), with z up.
52    fn camera(self) -> (Vector3f, Vector3f) {
53        let z = Vector3f::z();
54        match self {
55            View::Front => (-Vector3f::y(), z),
56            View::Back => (Vector3f::y(), z),
57            View::Left => (-Vector3f::x(), z),
58            View::Right => (Vector3f::x(), z),
59            View::Top => (z, Vector3f::y()),
60            View::Bottom => (-z, -Vector3f::y()),
61            View::Angled => (Vector3f::new(1.0, -1.0, 0.8).normalize(), z),
62        }
63    }
64}
65
66/// Which axis points up in the data.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum UpAxis {
69    Z,
70    Y,
71}
72
73/// Options for [`render_grid`].
74#[derive(Debug, Clone)]
75pub struct RenderOptions {
76    /// Views drawn left to right in each row.
77    pub views: Vec<View>,
78    /// Size of each tile in pixels.
79    pub tile_size: u32,
80    pub up: UpAxis,
81    /// Side of the square drawn for each point, in pixels.
82    pub point_size: u32,
83}
84
85impl Default for RenderOptions {
86    fn default() -> Self {
87        Self {
88            // Bottom is included because scans most often miss their base.
89            views: vec![
90                View::Angled,
91                View::Front,
92                View::Right,
93                View::Top,
94                View::Bottom,
95            ],
96            tile_size: 384,
97            up: UpAxis::Z,
98            point_size: 2,
99        }
100    }
101}
102
103/// An RGB image, row by row.
104#[derive(Debug, Clone)]
105pub struct Image {
106    pub width: u32,
107    pub height: u32,
108    pub pixels: Vec<u8>,
109}
110
111const BACKGROUND: [u8; 3] = [255, 255, 255];
112const FRONT_FACE: [f32; 3] = [110.0, 140.0, 190.0];
113const BACK_FACE: [f32; 3] = [235.0, 140.0, 40.0];
114const POINT: [f32; 3] = [40.0, 70.0, 130.0];
115const DIVIDER: [u8; 3] = [200, 200, 200];
116
117/// Draw each item as one row and each view as one column, all at the same
118/// scale, so before/after pairs compare directly.
119pub fn render_grid(items: &[Drawable], options: &RenderOptions) -> Result<Image> {
120    if items.is_empty() || options.views.is_empty() {
121        return Err(Error::InvalidData("nothing to render".to_string()));
122    }
123    let tile = options.tile_size.clamp(32, 4096);
124    // Each view is framed on what it actually shows; every row of a column
125    // shares that framing, so before/after stay at the same scale. Items in
126    // different units (a scan in metres, its print in millimetres) would
127    // shrink one to a dot, so then each row is framed on its own.
128    let framing = |group: &[Drawable]| -> Result<(Vector3f, f32, Vec<f32>)> {
129        let (center, radius) = frame(group, options.up)?;
130        let reach = options
131            .views
132            .iter()
133            .map(|v| view_reach(group, *v, center, options.up))
134            .collect();
135        Ok((center, radius, reach))
136    };
137    let radii = items
138        .iter()
139        .map(|item| frame(std::slice::from_ref(item), options.up).map(|(_, r)| r))
140        .collect::<Result<Vec<f32>>>()?;
141    let spread = radii.iter().cloned().fold(0.0, f32::max)
142        / radii.iter().cloned().fold(f32::INFINITY, f32::min);
143    let frames = if spread > 50.0 {
144        items
145            .iter()
146            .map(|item| framing(std::slice::from_ref(item)))
147            .collect::<Result<Vec<_>>>()?
148    } else {
149        vec![framing(items)?; items.len()]
150    };
151
152    let (cols, rows) = (options.views.len() as u32, items.len() as u32);
153    let mut grid = Image {
154        width: cols * tile,
155        height: rows * tile,
156        pixels: vec![255; (cols * tile * rows * tile * 3) as usize],
157    };
158    for (r, item) in items.iter().enumerate() {
159        let (center, radius, reach) = &frames[r];
160        for (c, view) in options.views.iter().enumerate() {
161            let image = render_tile(*item, *view, *center, *radius, reach[c], tile, options);
162            for y in 0..tile {
163                let from = (y * tile * 3) as usize;
164                let to = (((r as u32 * tile + y) * grid.width + c as u32 * tile) * 3) as usize;
165                grid.pixels[to..to + (tile * 3) as usize]
166                    .copy_from_slice(&image.pixels[from..from + (tile * 3) as usize]);
167            }
168        }
169    }
170    // Thin dividers between tiles.
171    for x in (1..cols).map(|c| c * tile) {
172        for y in 0..grid.height {
173            set(&mut grid, x, y, DIVIDER);
174        }
175    }
176    for y in (1..rows).map(|r| r * tile) {
177        for x in 0..grid.width {
178            set(&mut grid, x, y, DIVIDER);
179        }
180    }
181    Ok(grid)
182}
183
184/// Encode `image` as a PNG file.
185pub fn write_png<P: AsRef<Path>>(image: &Image, path: P) -> Result<()> {
186    let file = std::fs::File::create(path)?;
187    let mut encoder = png::Encoder::new(std::io::BufWriter::new(file), image.width, image.height);
188    encoder.set_color(png::ColorType::Rgb);
189    encoder.set_depth(png::BitDepth::Eight);
190    let mut writer = encoder
191        .write_header()
192        .map_err(|e| Error::Io(std::io::Error::other(e)))?;
193    writer
194        .write_image_data(&image.pixels)
195        .map_err(|e| Error::Io(std::io::Error::other(e)))?;
196    writer
197        .finish()
198        .map_err(|e| Error::Io(std::io::Error::other(e)))?;
199    Ok(())
200}
201
202/// Write `image` as PNG bytes in memory.
203pub fn png_bytes(image: &Image) -> Result<Vec<u8>> {
204    let mut out = Vec::new();
205    {
206        let mut encoder = png::Encoder::new(&mut out, image.width, image.height);
207        encoder.set_color(png::ColorType::Rgb);
208        encoder.set_depth(png::BitDepth::Eight);
209        let mut writer = encoder
210            .write_header()
211            .map_err(|e| Error::Io(std::io::Error::other(e)))?;
212        writer
213            .write_image_data(&image.pixels)
214            .map_err(|e| Error::Io(std::io::Error::other(e)))?;
215    }
216    out.flush()?;
217    Ok(out)
218}
219
220/// The data's up axis mapped to z.
221fn to_z_up(p: &Point3f, up: UpAxis) -> Vector3f {
222    match up {
223        UpAxis::Z => p.coords,
224        UpAxis::Y => Vector3f::new(p.x, -p.z, p.y),
225    }
226}
227
228/// Centre and radius of everything to draw (finite points only).
229fn frame(items: &[Drawable], up: UpAxis) -> Result<(Vector3f, f32)> {
230    let mut lo = Vector3f::repeat(f32::INFINITY);
231    let mut hi = Vector3f::repeat(f32::NEG_INFINITY);
232    let mut any = false;
233    for item in items {
234        let points: &[Point3f] = match item {
235            Drawable::Points(p) => p,
236            Drawable::Mesh(m) => &m.vertices,
237        };
238        for p in points
239            .iter()
240            .filter(|p| p.coords.iter().all(|c| c.is_finite()))
241        {
242            let v = to_z_up(p, up);
243            lo = lo.inf(&v);
244            hi = hi.sup(&v);
245            any = true;
246        }
247    }
248    if !any {
249        return Err(Error::InvalidData(
250            "nothing to render: no finite points".to_string(),
251        ));
252    }
253    let center = (lo + hi) * 0.5;
254    Ok((center, ((hi - lo).norm() * 0.5).max(1e-9)))
255}
256
257/// Camera axes (right, up, toward the camera) for `view`.
258fn camera_axes(view: View) -> (Vector3f, Vector3f, Vector3f) {
259    let (eye, up) = view.camera();
260    let right = up.cross(&eye).normalize();
261    (right, eye.cross(&right), eye)
262}
263
264/// Largest distance from `center` across or up the image in `view`, over
265/// every finite point of every item.
266fn view_reach(items: &[Drawable], view: View, center: Vector3f, up: UpAxis) -> f32 {
267    let (right, up_axis, _) = camera_axes(view);
268    let mut reach = 0.0f32;
269    for item in items {
270        let points: &[Point3f] = match item {
271            Drawable::Points(p) => p,
272            Drawable::Mesh(m) => &m.vertices,
273        };
274        for p in points
275            .iter()
276            .filter(|p| p.coords.iter().all(|c| c.is_finite()))
277        {
278            let v = to_z_up(p, up) - center;
279            reach = reach.max(v.dot(&right).abs()).max(v.dot(&up_axis).abs());
280        }
281    }
282    reach.max(1e-9)
283}
284
285fn set(image: &mut Image, x: u32, y: u32, color: [u8; 3]) {
286    let i = ((y * image.width + x) * 3) as usize;
287    image.pixels[i..i + 3].copy_from_slice(&color);
288}
289
290fn render_tile(
291    item: Drawable,
292    view: View,
293    center: Vector3f,
294    radius: f32,
295    reach: f32,
296    size: u32,
297    options: &RenderOptions,
298) -> Image {
299    let (right, up, eye) = camera_axes(view);
300    // Orthographic, filling the tile with a small margin.
301    let scale = size as f32 * 0.46 / reach;
302    let half = size as f32 * 0.5;
303    let project = |p: &Point3f| {
304        let v = to_z_up(p, options.up) - center;
305        (
306            half + v.dot(&right) * scale,
307            half - v.dot(&up) * scale,
308            v.dot(&eye),
309        )
310    };
311
312    let mut image = Image {
313        width: size,
314        height: size,
315        pixels: BACKGROUND.repeat((size * size) as usize),
316    };
317    // Larger depth = closer to the camera.
318    let mut depth = vec![f32::NEG_INFINITY; (size * size) as usize];
319    let mut plot = |x: i64, y: i64, z: f32, color: [f32; 3], image: &mut Image| {
320        if x < 0 || y < 0 || x >= size as i64 || y >= size as i64 {
321            return;
322        }
323        let i = (y as u32 * size + x as u32) as usize;
324        if z > depth[i] {
325            depth[i] = z;
326            set(
327                image,
328                x as u32,
329                y as u32,
330                color.map(|c| c.clamp(0.0, 255.0) as u8),
331            );
332        }
333    };
334
335    match item {
336        Drawable::Points(points) => {
337            let s = options.point_size.max(1) as i64;
338            for p in points
339                .iter()
340                .filter(|p| p.coords.iter().all(|c| c.is_finite()))
341            {
342                let (x, y, z) = project(p);
343                // Closer points are darker, which reads as depth.
344                let shade = 0.55 + 0.45 * (1.0 - (z / radius + 1.0) * 0.5);
345                let color = POINT.map(|c| c + (255.0 - c) * (1.0 - shade));
346                let (x0, y0) = (x as i64 - s / 2, y as i64 - s / 2);
347                for dy in 0..s {
348                    for dx in 0..s {
349                        plot(x0 + dx, y0 + dy, z, color, &mut image);
350                    }
351                }
352            }
353        }
354        Drawable::Mesh(mesh) => {
355            let n = mesh.vertices.len();
356            for face in mesh.faces.iter().filter(|f| f.iter().all(|&v| v < n)) {
357                let corners = face.map(|v| mesh.vertices[v]);
358                if corners
359                    .iter()
360                    .any(|p| !p.coords.iter().all(|c| c.is_finite()))
361                {
362                    continue;
363                }
364                let [a, b, c] = corners.map(|p| to_z_up(&p, options.up));
365                let normal = (b - a).cross(&(c - a));
366                let len = normal.norm();
367                if len == 0.0 {
368                    continue;
369                }
370                let facing = normal.dot(&eye) / len;
371                let base = if facing >= 0.0 { FRONT_FACE } else { BACK_FACE };
372                let light = 0.35 + 0.65 * facing.abs();
373                let color = base.map(|ch| ch * light + 40.0 * (1.0 - light));
374                let [p0, p1, p2] = corners.map(|p| project(&p));
375                fill_triangle(p0, p1, p2, |x, y, z| plot(x, y, z, color, &mut image));
376            }
377        }
378    }
379    image
380}
381
382/// Call `plot` for every pixel centre inside the triangle, with interpolated
383/// depth.
384fn fill_triangle(
385    a: (f32, f32, f32),
386    b: (f32, f32, f32),
387    c: (f32, f32, f32),
388    mut plot: impl FnMut(i64, i64, f32),
389) {
390    let area = (b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0);
391    if area.abs() < 1e-12 {
392        return;
393    }
394    let min_x = a.0.min(b.0).min(c.0).floor().max(-1.0) as i64;
395    let max_x = a.0.max(b.0).max(c.0).ceil().min(1e6) as i64;
396    let min_y = a.1.min(b.1).min(c.1).floor().max(-1.0) as i64;
397    let max_y = a.1.max(b.1).max(c.1).ceil().min(1e6) as i64;
398    for y in min_y..=max_y {
399        for x in min_x..=max_x {
400            let (px, py) = (x as f32 + 0.5, y as f32 + 0.5);
401            let w0 = ((b.0 - px) * (c.1 - py) - (b.1 - py) * (c.0 - px)) / area;
402            let w1 = ((c.0 - px) * (a.1 - py) - (c.1 - py) * (a.0 - px)) / area;
403            let w2 = 1.0 - w0 - w1;
404            if w0 >= 0.0 && w1 >= 0.0 && w2 >= 0.0 {
405                plot(x, y, w0 * a.2 + w1 * b.2 + w2 * c.2);
406            }
407        }
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    /// Share of a tile's pixels that are not background.
416    fn coverage(image: &Image, col: u32, row: u32, tile: u32) -> f32 {
417        let mut drawn = 0;
418        for y in row * tile + 1..(row + 1) * tile {
419            for x in col * tile + 1..(col + 1) * tile {
420                let i = ((y * image.width + x) * 3) as usize;
421                if image.pixels[i..i + 3] != BACKGROUND {
422                    drawn += 1;
423                }
424            }
425        }
426        drawn as f32 / ((tile - 1) * (tile - 1)) as f32
427    }
428
429    fn has_color_near(image: &Image, target: [f32; 3]) -> bool {
430        image.pixels.chunks_exact(3).any(|p| {
431            let d: f32 = (0..3).map(|i| (p[i] as f32 - target[i]).abs()).sum();
432            // Shading darkens colours, so compare hue roughly via the ratio.
433            d < 120.0 && (p[0] as f32 > p[2] as f32) == (target[0] > target[2])
434        })
435    }
436
437    /// Unit cube, wound outward.
438    fn cube() -> TriangleMesh {
439        let v = (0..8)
440            .map(|i| Point3f::new((i & 1) as f32, ((i >> 1) & 1) as f32, ((i >> 2) & 1) as f32))
441            .collect();
442        let faces = vec![
443            [0, 2, 1],
444            [1, 2, 3],
445            [4, 5, 6],
446            [5, 7, 6],
447            [0, 1, 4],
448            [1, 5, 4],
449            [2, 6, 3],
450            [3, 6, 7],
451            [0, 4, 2],
452            [2, 4, 6],
453            [1, 3, 5],
454            [3, 7, 5],
455        ];
456        TriangleMesh::from_vertices_and_faces(v, faces)
457    }
458
459    #[test]
460    fn grid_has_one_tile_per_item_and_view_and_every_tile_shows_the_object() {
461        let cube = cube();
462        let cloud: Vec<Point3f> = (0..2000)
463            .map(|i| {
464                let t = i as f32 * 0.37;
465                Point3f::new(t.sin(), t.cos(), (i as f32 / 2000.0) * 2.0 - 1.0)
466            })
467            .collect();
468        let options = RenderOptions {
469            tile_size: 128,
470            ..Default::default()
471        };
472        let image =
473            render_grid(&[Drawable::Mesh(&cube), Drawable::Points(&cloud)], &options).unwrap();
474        assert_eq!((image.width, image.height), (5 * 128, 2 * 128));
475        for row in 0..2 {
476            for col in 0..5 {
477                let c = coverage(&image, col, row, 128);
478                assert!(c > 0.02, "tile row {row} col {col} is nearly empty ({c})");
479            }
480        }
481    }
482
483    #[test]
484    fn items_in_different_units_each_fill_their_row() {
485        // The same cube in metres and in millimetres: neither may shrink to a dot.
486        let metres = cube();
487        let mut mm = cube();
488        mm.vertices.iter_mut().for_each(|v| *v *= 1000.0);
489        let options = RenderOptions {
490            tile_size: 128,
491            ..Default::default()
492        };
493        let image = render_grid(&[Drawable::Mesh(&metres), Drawable::Mesh(&mm)], &options).unwrap();
494        for row in 0..2 {
495            let c = coverage(&image, 0, row, 128);
496            assert!(c > 0.1, "row {row} is nearly empty ({c})");
497        }
498    }
499
500    #[test]
501    fn closed_mesh_shows_no_back_faces_and_an_open_one_does() {
502        let options = RenderOptions {
503            views: vec![View::Angled],
504            tile_size: 128,
505            ..Default::default()
506        };
507        let closed = cube();
508        let image = render_grid(&[Drawable::Mesh(&closed)], &options).unwrap();
509        assert!(
510            !has_color_near(&image, BACK_FACE),
511            "closed cube shows orange"
512        );
513
514        let mut open = cube();
515        open.faces.retain(|f| f.iter().any(|&v| v < 4)); // remove the top (z = 1)
516        let image = render_grid(&[Drawable::Mesh(&open)], &options).unwrap();
517        assert!(
518            has_color_near(&image, BACK_FACE),
519            "the hole should show the orange inside"
520        );
521    }
522
523    #[test]
524    fn png_round_trip_and_bad_input() {
525        let cube = cube();
526        let image = render_grid(&[Drawable::Mesh(&cube)], &RenderOptions::default()).unwrap();
527        let bytes = png_bytes(&image).unwrap();
528        assert_eq!(&bytes[1..4], b"PNG");
529        assert!(render_grid(&[], &RenderOptions::default()).is_err());
530        let nan = [Point3f::new(f32::NAN, 0.0, 0.0)];
531        assert!(render_grid(&[Drawable::Points(&nan)], &RenderOptions::default()).is_err());
532        assert!(View::parse("sideways").is_err());
533        assert_eq!(View::parse("Side").unwrap(), View::Right);
534    }
535}