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 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 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
184pub 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
202pub 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
220fn 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
228fn 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
257fn 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
264fn 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 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 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 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
382fn 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 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 d < 120.0 && (p[0] as f32 > p[2] as f32) == (target[0] > target[2])
434 })
435 }
436
437 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 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)); 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}