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    let (center, radius) = frame(items, options.up)?;
125
126    let (cols, rows) = (options.views.len() as u32, items.len() as u32);
127    let mut grid = Image {
128        width: cols * tile,
129        height: rows * tile,
130        pixels: vec![255; (cols * tile * rows * tile * 3) as usize],
131    };
132    // Each view is framed on what it actually shows; every row of a column
133    // shares that framing, so before/after stay at the same scale.
134    let reach: Vec<f32> = options
135        .views
136        .iter()
137        .map(|v| view_reach(items, *v, center, options.up))
138        .collect();
139    for (r, item) in items.iter().enumerate() {
140        for (c, view) in options.views.iter().enumerate() {
141            let image = render_tile(*item, *view, center, radius, reach[c], tile, options);
142            for y in 0..tile {
143                let from = (y * tile * 3) as usize;
144                let to = (((r as u32 * tile + y) * grid.width + c as u32 * tile) * 3) as usize;
145                grid.pixels[to..to + (tile * 3) as usize]
146                    .copy_from_slice(&image.pixels[from..from + (tile * 3) as usize]);
147            }
148        }
149    }
150    // Thin dividers between tiles.
151    for x in (1..cols).map(|c| c * tile) {
152        for y in 0..grid.height {
153            set(&mut grid, x, y, DIVIDER);
154        }
155    }
156    for y in (1..rows).map(|r| r * tile) {
157        for x in 0..grid.width {
158            set(&mut grid, x, y, DIVIDER);
159        }
160    }
161    Ok(grid)
162}
163
164/// Encode `image` as a PNG file.
165pub fn write_png<P: AsRef<Path>>(image: &Image, path: P) -> Result<()> {
166    let file = std::fs::File::create(path)?;
167    let mut encoder = png::Encoder::new(std::io::BufWriter::new(file), image.width, image.height);
168    encoder.set_color(png::ColorType::Rgb);
169    encoder.set_depth(png::BitDepth::Eight);
170    let mut writer = encoder
171        .write_header()
172        .map_err(|e| Error::Io(std::io::Error::other(e)))?;
173    writer
174        .write_image_data(&image.pixels)
175        .map_err(|e| Error::Io(std::io::Error::other(e)))?;
176    writer
177        .finish()
178        .map_err(|e| Error::Io(std::io::Error::other(e)))?;
179    Ok(())
180}
181
182/// Write `image` as PNG bytes in memory.
183pub fn png_bytes(image: &Image) -> Result<Vec<u8>> {
184    let mut out = Vec::new();
185    {
186        let mut encoder = png::Encoder::new(&mut out, image.width, image.height);
187        encoder.set_color(png::ColorType::Rgb);
188        encoder.set_depth(png::BitDepth::Eight);
189        let mut writer = encoder
190            .write_header()
191            .map_err(|e| Error::Io(std::io::Error::other(e)))?;
192        writer
193            .write_image_data(&image.pixels)
194            .map_err(|e| Error::Io(std::io::Error::other(e)))?;
195    }
196    out.flush()?;
197    Ok(out)
198}
199
200/// The data's up axis mapped to z.
201fn to_z_up(p: &Point3f, up: UpAxis) -> Vector3f {
202    match up {
203        UpAxis::Z => p.coords,
204        UpAxis::Y => Vector3f::new(p.x, -p.z, p.y),
205    }
206}
207
208/// Centre and radius of everything to draw (finite points only).
209fn frame(items: &[Drawable], up: UpAxis) -> Result<(Vector3f, f32)> {
210    let mut lo = Vector3f::repeat(f32::INFINITY);
211    let mut hi = Vector3f::repeat(f32::NEG_INFINITY);
212    let mut any = false;
213    for item in items {
214        let points: &[Point3f] = match item {
215            Drawable::Points(p) => p,
216            Drawable::Mesh(m) => &m.vertices,
217        };
218        for p in points
219            .iter()
220            .filter(|p| p.coords.iter().all(|c| c.is_finite()))
221        {
222            let v = to_z_up(p, up);
223            lo = lo.inf(&v);
224            hi = hi.sup(&v);
225            any = true;
226        }
227    }
228    if !any {
229        return Err(Error::InvalidData(
230            "nothing to render: no finite points".to_string(),
231        ));
232    }
233    let center = (lo + hi) * 0.5;
234    Ok((center, ((hi - lo).norm() * 0.5).max(1e-9)))
235}
236
237/// Camera axes (right, up, toward the camera) for `view`.
238fn camera_axes(view: View) -> (Vector3f, Vector3f, Vector3f) {
239    let (eye, up) = view.camera();
240    let right = up.cross(&eye).normalize();
241    (right, eye.cross(&right), eye)
242}
243
244/// Largest distance from `center` across or up the image in `view`, over
245/// every finite point of every item.
246fn view_reach(items: &[Drawable], view: View, center: Vector3f, up: UpAxis) -> f32 {
247    let (right, up_axis, _) = camera_axes(view);
248    let mut reach = 0.0f32;
249    for item in items {
250        let points: &[Point3f] = match item {
251            Drawable::Points(p) => p,
252            Drawable::Mesh(m) => &m.vertices,
253        };
254        for p in points
255            .iter()
256            .filter(|p| p.coords.iter().all(|c| c.is_finite()))
257        {
258            let v = to_z_up(p, up) - center;
259            reach = reach.max(v.dot(&right).abs()).max(v.dot(&up_axis).abs());
260        }
261    }
262    reach.max(1e-9)
263}
264
265fn set(image: &mut Image, x: u32, y: u32, color: [u8; 3]) {
266    let i = ((y * image.width + x) * 3) as usize;
267    image.pixels[i..i + 3].copy_from_slice(&color);
268}
269
270fn render_tile(
271    item: Drawable,
272    view: View,
273    center: Vector3f,
274    radius: f32,
275    reach: f32,
276    size: u32,
277    options: &RenderOptions,
278) -> Image {
279    let (right, up, eye) = camera_axes(view);
280    // Orthographic, filling the tile with a small margin.
281    let scale = size as f32 * 0.46 / reach;
282    let half = size as f32 * 0.5;
283    let project = |p: &Point3f| {
284        let v = to_z_up(p, options.up) - center;
285        (
286            half + v.dot(&right) * scale,
287            half - v.dot(&up) * scale,
288            v.dot(&eye),
289        )
290    };
291
292    let mut image = Image {
293        width: size,
294        height: size,
295        pixels: BACKGROUND.repeat((size * size) as usize),
296    };
297    // Larger depth = closer to the camera.
298    let mut depth = vec![f32::NEG_INFINITY; (size * size) as usize];
299    let mut plot = |x: i64, y: i64, z: f32, color: [f32; 3], image: &mut Image| {
300        if x < 0 || y < 0 || x >= size as i64 || y >= size as i64 {
301            return;
302        }
303        let i = (y as u32 * size + x as u32) as usize;
304        if z > depth[i] {
305            depth[i] = z;
306            set(
307                image,
308                x as u32,
309                y as u32,
310                color.map(|c| c.clamp(0.0, 255.0) as u8),
311            );
312        }
313    };
314
315    match item {
316        Drawable::Points(points) => {
317            let s = options.point_size.max(1) as i64;
318            for p in points
319                .iter()
320                .filter(|p| p.coords.iter().all(|c| c.is_finite()))
321            {
322                let (x, y, z) = project(p);
323                // Closer points are darker, which reads as depth.
324                let shade = 0.55 + 0.45 * (1.0 - (z / radius + 1.0) * 0.5);
325                let color = POINT.map(|c| c + (255.0 - c) * (1.0 - shade));
326                let (x0, y0) = (x as i64 - s / 2, y as i64 - s / 2);
327                for dy in 0..s {
328                    for dx in 0..s {
329                        plot(x0 + dx, y0 + dy, z, color, &mut image);
330                    }
331                }
332            }
333        }
334        Drawable::Mesh(mesh) => {
335            let n = mesh.vertices.len();
336            for face in mesh.faces.iter().filter(|f| f.iter().all(|&v| v < n)) {
337                let corners = face.map(|v| mesh.vertices[v]);
338                if corners
339                    .iter()
340                    .any(|p| !p.coords.iter().all(|c| c.is_finite()))
341                {
342                    continue;
343                }
344                let [a, b, c] = corners.map(|p| to_z_up(&p, options.up));
345                let normal = (b - a).cross(&(c - a));
346                let len = normal.norm();
347                if len == 0.0 {
348                    continue;
349                }
350                let facing = normal.dot(&eye) / len;
351                let base = if facing >= 0.0 { FRONT_FACE } else { BACK_FACE };
352                let light = 0.35 + 0.65 * facing.abs();
353                let color = base.map(|ch| ch * light + 40.0 * (1.0 - light));
354                let [p0, p1, p2] = corners.map(|p| project(&p));
355                fill_triangle(p0, p1, p2, |x, y, z| plot(x, y, z, color, &mut image));
356            }
357        }
358    }
359    image
360}
361
362/// Call `plot` for every pixel centre inside the triangle, with interpolated
363/// depth.
364fn fill_triangle(
365    a: (f32, f32, f32),
366    b: (f32, f32, f32),
367    c: (f32, f32, f32),
368    mut plot: impl FnMut(i64, i64, f32),
369) {
370    let area = (b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0);
371    if area.abs() < 1e-12 {
372        return;
373    }
374    let min_x = a.0.min(b.0).min(c.0).floor().max(-1.0) as i64;
375    let max_x = a.0.max(b.0).max(c.0).ceil().min(1e6) as i64;
376    let min_y = a.1.min(b.1).min(c.1).floor().max(-1.0) as i64;
377    let max_y = a.1.max(b.1).max(c.1).ceil().min(1e6) as i64;
378    for y in min_y..=max_y {
379        for x in min_x..=max_x {
380            let (px, py) = (x as f32 + 0.5, y as f32 + 0.5);
381            let w0 = ((b.0 - px) * (c.1 - py) - (b.1 - py) * (c.0 - px)) / area;
382            let w1 = ((c.0 - px) * (a.1 - py) - (c.1 - py) * (a.0 - px)) / area;
383            let w2 = 1.0 - w0 - w1;
384            if w0 >= 0.0 && w1 >= 0.0 && w2 >= 0.0 {
385                plot(x, y, w0 * a.2 + w1 * b.2 + w2 * c.2);
386            }
387        }
388    }
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    /// Share of a tile's pixels that are not background.
396    fn coverage(image: &Image, col: u32, row: u32, tile: u32) -> f32 {
397        let mut drawn = 0;
398        for y in row * tile + 1..(row + 1) * tile {
399            for x in col * tile + 1..(col + 1) * tile {
400                let i = ((y * image.width + x) * 3) as usize;
401                if image.pixels[i..i + 3] != BACKGROUND {
402                    drawn += 1;
403                }
404            }
405        }
406        drawn as f32 / ((tile - 1) * (tile - 1)) as f32
407    }
408
409    fn has_color_near(image: &Image, target: [f32; 3]) -> bool {
410        image.pixels.chunks_exact(3).any(|p| {
411            let d: f32 = (0..3).map(|i| (p[i] as f32 - target[i]).abs()).sum();
412            // Shading darkens colours, so compare hue roughly via the ratio.
413            d < 120.0 && (p[0] as f32 > p[2] as f32) == (target[0] > target[2])
414        })
415    }
416
417    /// Unit cube, wound outward.
418    fn cube() -> TriangleMesh {
419        let v = (0..8)
420            .map(|i| Point3f::new((i & 1) as f32, ((i >> 1) & 1) as f32, ((i >> 2) & 1) as f32))
421            .collect();
422        let faces = vec![
423            [0, 2, 1],
424            [1, 2, 3],
425            [4, 5, 6],
426            [5, 7, 6],
427            [0, 1, 4],
428            [1, 5, 4],
429            [2, 6, 3],
430            [3, 6, 7],
431            [0, 4, 2],
432            [2, 4, 6],
433            [1, 3, 5],
434            [3, 7, 5],
435        ];
436        TriangleMesh::from_vertices_and_faces(v, faces)
437    }
438
439    #[test]
440    fn grid_has_one_tile_per_item_and_view_and_every_tile_shows_the_object() {
441        let cube = cube();
442        let cloud: Vec<Point3f> = (0..2000)
443            .map(|i| {
444                let t = i as f32 * 0.37;
445                Point3f::new(t.sin(), t.cos(), (i as f32 / 2000.0) * 2.0 - 1.0)
446            })
447            .collect();
448        let options = RenderOptions {
449            tile_size: 128,
450            ..Default::default()
451        };
452        let image =
453            render_grid(&[Drawable::Mesh(&cube), Drawable::Points(&cloud)], &options).unwrap();
454        assert_eq!((image.width, image.height), (5 * 128, 2 * 128));
455        for row in 0..2 {
456            for col in 0..5 {
457                let c = coverage(&image, col, row, 128);
458                assert!(c > 0.02, "tile row {row} col {col} is nearly empty ({c})");
459            }
460        }
461    }
462
463    #[test]
464    fn closed_mesh_shows_no_back_faces_and_an_open_one_does() {
465        let options = RenderOptions {
466            views: vec![View::Angled],
467            tile_size: 128,
468            ..Default::default()
469        };
470        let closed = cube();
471        let image = render_grid(&[Drawable::Mesh(&closed)], &options).unwrap();
472        assert!(
473            !has_color_near(&image, BACK_FACE),
474            "closed cube shows orange"
475        );
476
477        let mut open = cube();
478        open.faces.retain(|f| f.iter().any(|&v| v < 4)); // remove the top (z = 1)
479        let image = render_grid(&[Drawable::Mesh(&open)], &options).unwrap();
480        assert!(
481            has_color_near(&image, BACK_FACE),
482            "the hole should show the orange inside"
483        );
484    }
485
486    #[test]
487    fn png_round_trip_and_bad_input() {
488        let cube = cube();
489        let image = render_grid(&[Drawable::Mesh(&cube)], &RenderOptions::default()).unwrap();
490        let bytes = png_bytes(&image).unwrap();
491        assert_eq!(&bytes[1..4], b"PNG");
492        assert!(render_grid(&[], &RenderOptions::default()).is_err());
493        let nan = [Point3f::new(f32::NAN, 0.0, 0.0)];
494        assert!(render_grid(&[Drawable::Points(&nan)], &RenderOptions::default()).is_err());
495        assert!(View::parse("sideways").is_err());
496        assert_eq!(View::parse("Side").unwrap(), View::Right);
497    }
498}