1use std::io::Write;
10use std::path::Path;
11use threecrate_core::{Error, Point3f, Result, TriangleMesh, Vector3f};
12
13#[derive(Clone, Copy)]
15pub enum Drawable<'a> {
16 Points(&'a [Point3f]),
17 Mesh(&'a TriangleMesh),
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum View {
23 Front,
24 Back,
25 Left,
26 Right,
27 Top,
28 Bottom,
29 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum UpAxis {
69 Z,
70 Y,
71}
72
73#[derive(Debug, Clone)]
75pub struct RenderOptions {
76 pub views: Vec<View>,
78 pub tile_size: u32,
80 pub up: UpAxis,
81 pub point_size: u32,
83}
84
85impl Default for RenderOptions {
86 fn default() -> Self {
87 Self {
88 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#[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
117pub 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 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 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
164pub 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
182pub 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
200fn 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
208fn 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
237fn 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
244fn 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 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 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 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
362fn 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 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 d < 120.0 && (p[0] as f32 > p[2] as f32) == (target[0] > target[2])
414 })
415 }
416
417 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)); 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}