Merge pull request #6069 from KenM76/feat/prc-render

M12: headless renderer for PRC models (`pdfcraft-prc-render`), stacked on #5786
This commit is contained in:
FredApps authored and GitHub committed 2026-11-04 19:42:33 +01:00
commit ef8c71ac9c
13 files changed
+1688

No files matched your search

Generated
+9
View File
@@ -4560,6 +4560,15 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "pdfcraft-prc-render"
version = "0.18.1"
dependencies = [
"flate2",
"pdfcraft-prc",
"thiserror 2.0.21",
]
[[package]]
name = "pdfcraft-preflight"
version = "0.18.1"
+2
View File
@@ -63,6 +63,8 @@ pdfcraft-engine = { path = "crates/engine", version = "0.18.1" }
pdfcraft-platform = { path = "crates/platform", version = "0.18.1" }
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.18.1" }
pdfcraft-automation = { path = "crates/automation", version = "0.18.1", default-features = false }
pdfcraft-prc = { path = "crates/prc", version = "0.18.1" }
pdfcraft-prc-render = { path = "crates/prc-render", version = "0.18.1" }
# pdf
hayro = "0.7.1"
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "pdfcraft-prc-render"
description = "Headless CPU renderer for PRC 3D models: camera, depth-buffered triangle fill, headlight shading"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[lints]
workspace = true
[dependencies]
pdfcraft-prc.workspace = true
thiserror.workspace = true
[dev-dependencies]
flate2.workspace = true
+29
View File
@@ -0,0 +1,29 @@
# pdfcraft-prc-render
A headless CPU renderer for PRC 3D models read by `pdfcraft-prc`: placed meshes in world space, a fitted camera, a
depth-buffered triangle fill and headlight shading, into a straight-RGBA image.
- **Layer:** L3. Depends on `pdfcraft-prc` only.
- **Licence:** MIT OR Apache-2.0.
- `#![forbid(unsafe_code)]`. Never panics on input: image size, triangle count and every index are checked, and
damage returns `RenderError`.
## API
```rust
assemble(&PrcFile) -> Result<Vec<WorldMesh>, RenderError> // every placement's mesh moved into the world and coloured
place(&TriangleMesh, &Matrix, colour, triangle_colours) -> WorldMesh
Camera::fit(&Bounds, direction, up, perspective, aspect) / Camera::fit_meshes(&[WorldMesh], ...)
render(&[WorldMesh], &Camera, &RenderOptions) -> Result<Image, RenderError>
```
- With no product tree, each tessellated mesh is drawn where it lies, in the default colour.
- A triangle's own colour wins over its mesh's, which wins over `RenderOptions::colour`.
- Opaque triangles are drawn first, then translucent ones blended over them; alpha 0 is not drawn.
- `Lighting::Headlight` (default) shades by the angle to the view direction; `Lighting::Unlit` draws flat colour.
- Perspective triangles are clipped at a near plane 1/10,000 of the eye-to-target distance from the eye; orthographic views are not clipped.
- `MAX_RENDER_PIXELS` (2^24, about 16 MP) bounds the image: at most 64 MB of RGBA plus 128 MB of depth.
`MAX_ASSEMBLED_TRIANGLES` and `MAX_ASSEMBLED_VERTICES` (2^24 each) bound one `assemble` call. `MAX_RENDER_WORK` (2^31) bounds
the pixel centres one `render` tests, summed over each triangle's clipped bounding box; past it the render returns
`RenderError::TooMuchWork`. That is still seconds of CPU at the top, so callers on a UI thread should render small or off-thread.
- Not yet: shadows, ambient occlusion, specular light, scene lights, textures, wires and markups.
+164
View File
@@ -0,0 +1,164 @@
//! A parsed PRC file's tessellations placed in the world, ready for [`crate::render`].
use crate::RenderError;
use crate::raster::channel;
use pdfcraft_prc::{Matrix, PrcFile, Rgba, Tessellation, TriangleMesh};
/// The most triangles [`assemble`] places, over every placement: a tessellation many
/// occurrences draw is copied once per occurrence.
pub const MAX_ASSEMBLED_TRIANGLES: usize = 1 << 24;
/// The most positions, normals and texture coordinates [`assemble`] copies, over every placement.
pub const MAX_ASSEMBLED_VERTICES: usize = 1 << 24;
/// A triangle mesh in world coordinates with the colours it draws in.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct WorldMesh {
/// The triangles, already placed.
pub mesh: TriangleMesh,
/// The mesh's straight-RGBA colour; `None` takes [`crate::RenderOptions::colour`], opaque.
pub colour: Option<[u8; 4]>,
/// Per triangle, its own colour when its face is styled; `None` (the whole list or an entry)
/// takes [`Self::colour`].
pub triangle_colours: Option<Vec<Option<[u8; 4]>>>,
}
impl WorldMesh {
/// `mesh`, uncoloured.
#[must_use]
pub fn new(mesh: TriangleMesh) -> Self {
WorldMesh { mesh, colour: None, triangle_colours: None }
}
/// The colour triangle `t` draws in, before the render options' default.
pub(crate) fn colour_of(&self, t: usize) -> Option<[u8; 4]> {
self.triangle_colours.as_ref().and_then(|c| c.get(t).copied().flatten()).or(self.colour)
}
}
/// `mesh` moved into the world by `matrix`, drawn in `colour` and, per triangle, `triangle_colours`.
#[must_use]
pub fn place(mesh: &TriangleMesh, matrix: &Matrix, colour: Option<[u8; 4]>, triangle_colours: Option<Vec<Option<[u8; 4]>>>) -> WorldMesh {
WorldMesh { mesh: mesh.transformed(matrix), colour, triangle_colours }
}
/// Every triangle mesh `prc` draws, in world coordinates and coloured as its product tree says
/// ([`PrcFile::placements`]). A file with no tree draws each file structure's meshes where they
/// are, uncoloured. Wires, markups and compressed tessellations are not drawn.
///
/// # Errors
/// [`RenderError::Model`] when a tessellation section or the tree cannot be read, or the tree
/// places a tessellation its file structure does not have; [`RenderError::TooManyTriangles`] past
/// [`MAX_ASSEMBLED_TRIANGLES`] and [`RenderError::TooManyVertices`] past [`MAX_ASSEMBLED_VERTICES`],
/// counting both what is decoded and what is placed.
pub fn assemble(prc: &PrcFile) -> Result<Vec<WorldMesh>, RenderError> {
let tessellations = decode(prc.file_structures.iter().map(|fs| fs.tessellations()), Budget::default())?;
let mut budget = Budget::default();
let mut out = Vec::new();
if !prc.stores_tree() {
for mesh in tessellations.iter().flatten().filter_map(as_mesh) {
budget.spend(mesh)?;
out.push(WorldMesh::new(mesh.clone()));
}
return Ok(out);
}
let items = prc.placements()?.into_iter().map(|p| Item {
file_structure: p.file_structure,
tessellation: p.tessellation,
matrix: p.matrix,
colour: p.colour,
triangle_colours: Box::new(move |mesh: &TriangleMesh| p.triangle_colours(mesh)),
});
place_items(&tessellations, items, &mut budget, &mut out)?;
Ok(out)
}
/// Each file structure's tessellations, decoded one structure at a time and charged to `budget`
/// as they are, so decoding stops at the structure that passes a ceiling.
pub(crate) fn decode(
structures: impl IntoIterator<Item = Result<Vec<Tessellation>, pdfcraft_prc::PrcError>>,
mut budget: Budget,
) -> Result<Vec<Vec<Tessellation>>, RenderError> {
let mut out = Vec::new();
for tessellations in structures {
let tessellations = tessellations?;
for mesh in tessellations.iter().filter_map(as_mesh) {
budget.spend(mesh)?;
}
out.push(tessellations);
}
Ok(out)
}
/// One placement as [`place_items`] reads it: a tessellation by file structure and index, where
/// it goes and how it is coloured.
pub(crate) struct Item<'a> {
pub(crate) file_structure: usize,
pub(crate) tessellation: usize,
pub(crate) matrix: Matrix,
pub(crate) colour: Option<Rgba>,
pub(crate) triangle_colours: Box<FaceColours<'a>>,
}
/// Per triangle of a mesh, the colour its face's style resolves to.
type FaceColours<'a> = dyn Fn(&TriangleMesh) -> Option<Vec<Option<Rgba>>> + 'a;
/// Each item's mesh moved into the world and coloured, appended to `out`. Items naming a wire,
/// markup or compressed tessellation are skipped; an item naming no tessellation at all is
/// [`PrcError::Malformed`](pdfcraft_prc::PrcError::Malformed).
pub(crate) fn place_items<'a>(
tessellations: &[Vec<Tessellation>],
items: impl IntoIterator<Item = Item<'a>>,
budget: &mut Budget,
out: &mut Vec<WorldMesh>,
) -> Result<(), RenderError> {
for p in items {
let Some(tessellation) = tessellations.get(p.file_structure).and_then(|t| t.get(p.tessellation)) else {
let what = format!("tessellation {} of file structure {}, which has none by that index", p.tessellation, p.file_structure);
return Err(pdfcraft_prc::PrcError::Malformed(what).into());
};
let Some(mesh) = as_mesh(tessellation) else {
continue;
};
budget.spend(mesh)?;
let triangle_colours = (p.triangle_colours)(mesh).map(|c| c.into_iter().map(|c| c.map(bytes)).collect());
out.push(place(mesh, &p.matrix, p.colour.map(bytes), triangle_colours));
}
Ok(())
}
/// Triangles and vertex entries (positions, normals, texture coordinates) placed so far.
#[derive(Debug, Default)]
pub(crate) struct Budget {
pub(crate) triangles: usize,
pub(crate) vertices: usize,
}
impl Budget {
fn spend(&mut self, mesh: &TriangleMesh) -> Result<(), RenderError> {
let triangles =
self.triangles.checked_add(mesh.triangles.len()).filter(|&n| n <= MAX_ASSEMBLED_TRIANGLES).ok_or(RenderError::TooManyTriangles)?;
let vertices = [mesh.positions.len(), mesh.normals.len(), mesh.uvs.len()]
.into_iter()
.try_fold(self.vertices, usize::checked_add)
.filter(|&n| n <= MAX_ASSEMBLED_VERTICES)
.ok_or(RenderError::TooManyVertices)?;
(self.triangles, self.vertices) = (triangles, vertices);
Ok(())
}
}
fn as_mesh(t: &Tessellation) -> Option<&TriangleMesh> {
match t {
Tessellation::Mesh(m) => Some(m),
_ => None,
}
}
/// A 0–1 colour as straight-RGBA bytes, each channel clamped.
fn bytes(c: Rgba) -> [u8; 4] {
[c.r, c.g, c.b, c.a].map(|v| channel(v.clamp(0.0, 1.0) * 255.0))
}
#[cfg(test)]
mod tests;
+127
View File
@@ -0,0 +1,127 @@
#![allow(clippy::unwrap_used)]
use super::*;
fn triangle() -> TriangleMesh {
let mut m = TriangleMesh::default();
m.positions = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
m.triangles = vec![[0, 1, 2]];
m
}
#[test]
fn a_placement_moves_its_mesh_into_the_world() {
let mut shift = Matrix::IDENTITY;
shift.0[0][3] = 5.0;
shift.0[2][3] = -2.0;
let placed = place(&triangle(), &shift, Some([1, 2, 3, 255]), None);
assert_eq!(placed.mesh.positions, [[5.0, 0.0, -2.0], [6.0, 0.0, -2.0], [5.0, 1.0, -2.0]]);
assert_eq!(placed.colour, Some([1, 2, 3, 255]));
// A mirror keeps the triangle facing the same way round.
let mut mirror = Matrix::IDENTITY;
mirror.0[0][0] = -1.0;
assert_eq!(place(&triangle(), &mirror, None, None).mesh.triangles, [[0, 2, 1]]);
}
#[test]
fn a_triangle_colour_wins_over_the_mesh_colour() {
let mut m = place(&triangle(), &Matrix::IDENTITY, Some([9, 9, 9, 255]), Some(vec![Some([1, 1, 1, 255]), None]));
m.mesh.triangles.push([0, 2, 1]);
m.mesh.triangles.push([1, 2, 0]);
assert_eq!(m.colour_of(0), Some([1, 1, 1, 255]));
assert_eq!(m.colour_of(1), Some([9, 9, 9, 255]), "an unstyled face takes the mesh's colour");
assert_eq!(m.colour_of(2), Some([9, 9, 9, 255]), "past the list too");
assert_eq!(WorldMesh::new(triangle()).colour_of(0), None);
}
#[test]
fn colours_are_clamped_into_bytes() {
let [r, g, b, a] = [0.0, 1.0, 0.5, 2.0];
let mut c = Rgba::default();
(c.r, c.g, c.b, c.a) = (r, g, b, a);
assert_eq!(bytes(c), [0, 255, 128, 255]);
c.r = f64::NAN;
c.g = -3.0;
assert_eq!(bytes(c)[..2], [0, 0]);
}
#[test]
fn the_triangle_budget_is_shared_and_refuses_past_the_ceiling() {
let mut b = Budget { triangles: MAX_ASSEMBLED_TRIANGLES - 1, vertices: 0 };
assert!(b.spend(&triangle()).is_ok());
assert_eq!(b.spend(&triangle()), Err(RenderError::TooManyTriangles));
let mut b = Budget { triangles: usize::MAX, vertices: 0 };
assert_eq!(b.spend(&triangle()), Err(RenderError::TooManyTriangles));
}
#[test]
fn the_vertex_budget_counts_positions_normals_and_uvs() {
let mut heavy = triangle();
heavy.normals = vec![[0.0, 0.0, 1.0]; 2];
heavy.uvs = vec![[0.0; 2]; 4];
let mut b = Budget::default();
b.spend(&heavy).unwrap();
assert_eq!((b.triangles, b.vertices), (1, 9));
// One triangle over many vertices: the triangle ceiling alone would let it through.
let mut b = Budget { triangles: 0, vertices: MAX_ASSEMBLED_VERTICES - 8 };
assert_eq!(b.spend(&heavy), Err(RenderError::TooManyVertices));
assert_eq!((b.triangles, b.vertices), (0, MAX_ASSEMBLED_VERTICES - 8), "a refused mesh spends nothing");
let mut b = Budget { triangles: 0, vertices: usize::MAX };
assert_eq!(b.spend(&triangle()), Err(RenderError::TooManyVertices));
}
fn item<'a>(fs: usize, tess: usize, x: f64, colour: Option<Rgba>) -> Item<'a> {
let mut matrix = Matrix::IDENTITY;
matrix.0[0][3] = x;
Item { file_structure: fs, tessellation: tess, matrix, colour, triangle_colours: Box::new(|_| None) }
}
#[test]
fn each_placement_draws_its_tessellation_where_and_in_the_colour_the_tree_says() {
let tessellations = vec![vec![Tessellation::Mesh(triangle())]];
let mut red = Rgba::default();
red.r = 1.0;
red.a = 1.0;
// Two occurrences of one tessellation at different places, one coloured.
let items = [item(0, 0, 3.0, Some(red)), item(0, 0, -4.0, None)];
let (mut budget, mut out) = (Budget::default(), Vec::new());
place_items(&tessellations, items, &mut budget, &mut out).unwrap();
assert_eq!(out.len(), 2);
assert_eq!(out[0].mesh.positions, [[3.0, 0.0, 0.0], [4.0, 0.0, 0.0], [3.0, 1.0, 0.0]]);
assert_eq!(out[0].colour, Some([255, 0, 0, 255]));
assert_eq!(out[1].mesh.positions, [[-4.0, 0.0, 0.0], [-3.0, 0.0, 0.0], [-4.0, 1.0, 0.0]]);
assert_eq!(out[1].colour, None);
assert_eq!(budget.triangles, 2, "each occurrence spends its triangles");
}
#[test]
fn a_placement_naming_a_missing_tessellation_is_malformed() {
let tessellations = vec![vec![Tessellation::Mesh(triangle())]];
for (fs, tess) in [(1, 0), (0, 7)] {
let (mut budget, mut out) = (Budget::default(), Vec::new());
let r = place_items(&tessellations, [item(fs, tess, 0.0, None)], &mut budget, &mut out);
assert!(matches!(r, Err(RenderError::Model(pdfcraft_prc::PrcError::Malformed(_)))), "{fs}/{tess}: {r:?}");
}
}
#[test]
fn a_placement_past_the_triangle_budget_is_an_error() {
let tessellations = vec![vec![Tessellation::Mesh(triangle())]];
let (mut budget, mut out) = (Budget { triangles: MAX_ASSEMBLED_TRIANGLES, vertices: 0 }, Vec::new());
let r = place_items(&tessellations, [item(0, 0, 0.0, None)], &mut budget, &mut out);
assert!(matches!(r, Err(RenderError::TooManyTriangles)));
assert!(out.is_empty());
}
#[test]
fn decoding_stops_at_the_structure_that_passes_the_ceiling() {
let pulled = std::cell::Cell::new(0);
let structures = std::iter::repeat_with(|| {
pulled.set(pulled.get() + 1);
Ok(vec![Tessellation::Mesh(triangle())])
})
.take(3);
let nearly_full = Budget { triangles: MAX_ASSEMBLED_TRIANGLES - 1, vertices: 0 };
assert_eq!(decode(structures, nearly_full).err(), Some(RenderError::TooManyTriangles));
assert_eq!(pulled.get(), 2, "the third structure is never decoded");
}
+18
View File
@@ -0,0 +1,18 @@
//! Headless CPU rendering of PRC 3D models (ISO 14739-1) to an RGBA image.
//!
//! Layer L3: depends only on `pdfcraft-prc`. [`assemble`] turns a parsed [`pdfcraft_prc::PrcFile`]
//! into world-space meshes; [`render`] draws them from a [`Camera`] with one depth-tested sample per
//! pixel centre and a light at the camera. Single-threaded and GUI-free, so it runs unchanged on wasm32.
//! The image size is capped by [`MAX_RENDER_PIXELS`], the drawing work by [`MAX_RENDER_WORK`] and the
//! assembled model by [`MAX_ASSEMBLED_TRIANGLES`] and [`MAX_ASSEMBLED_VERTICES`]; bad input is an
//! error, never a panic.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod assemble;
mod raster;
mod render;
mod vec3;
pub use assemble::{MAX_ASSEMBLED_TRIANGLES, MAX_ASSEMBLED_VERTICES, WorldMesh, assemble, place};
pub use render::{Bounds, Camera, Image, Lighting, MAX_RENDER_PIXELS, MAX_RENDER_WORK, Projection, RenderError, RenderOptions, render};
+321
View File
@@ -0,0 +1,321 @@
//! Projection, near-plane clipping and the depth-tested triangle fill behind [`crate::render`].
use crate::render::{Camera, Projection, RenderError};
use crate::vec3::{length, sub};
/// One view-space corner: x, y, z (z along the view direction), then its red, green and blue shade.
pub(crate) type ViewCorner = [f64; 6];
/// One screen-space corner: pixel x, pixel y and depth key, and its shade.
#[derive(Debug, Clone, Copy)]
pub(crate) struct Corner {
pub(crate) at: [f64; 3],
pub(crate) shade: [f64; 3],
}
/// The perspective near plane, as a fraction of the eye-to-target distance.
pub(crate) const NEAR_FRACTION: f64 = 1e-4;
pub(crate) enum View {
/// `focal` = 1 / tan(fov_y / 2); `near` = the clip distance.
Perspective {
focal: f64,
aspect: f64,
near: f64,
},
Orthographic {
half_height: f64,
aspect: f64,
},
}
impl View {
/// The projection for `camera` at width/height `aspect`; refuses a field of view outside
/// (0, 180) degrees or a non-positive orthographic height.
pub(crate) fn new(camera: &Camera, aspect: f64) -> Result<View, RenderError> {
match camera.projection {
Projection::Perspective { fov_y } => {
if !(fov_y > 0.0 && fov_y < 180.0) {
return Err(RenderError::Camera("the field of view is not between 0 and 180 degrees"));
}
let focal = 1.0 / (fov_y.to_radians() / 2.0).tan();
// A field of view too small to measure leaves no finite focal length.
if !focal.is_finite() {
return Err(RenderError::Camera("the field of view is too small"));
}
let distance = length(sub(camera.target, camera.eye));
Ok(View::Perspective { focal, aspect, near: (distance * NEAR_FRACTION).max(f64::MIN_POSITIVE) })
}
Projection::Orthographic { height } => {
if !(height.is_finite() && height > 0.0) {
return Err(RenderError::Camera("the orthographic height is not positive"));
}
Ok(View::Orthographic { half_height: height / 2.0, aspect })
}
}
}
/// The part of `tri` in front of the near plane (Sutherland–Hodgman against one plane):
/// 0, 3 or 4 corners, the shade interpolated along each cut edge.
pub(crate) fn clip(&self, tri: [ViewCorner; 3]) -> Vec<ViewCorner> {
let View::Perspective { near, .. } = *self else {
return tri.to_vec();
};
let mut out = Vec::with_capacity(4);
for (k, &p) in tri.iter().enumerate() {
let q = tri.get((k + 1) % 3).copied().unwrap_or(p);
let z = |v: ViewCorner| v.get(2).copied().unwrap_or(0.0);
let (p_in, q_in) = (z(p) >= near, z(q) >= near);
if p_in {
out.push(p);
}
if p_in != q_in {
// Cut from the same end whichever way round the edge is met, so two triangles
// sharing it get the same cut point.
let (s, e) = if first(&p, &q) { (p, q) } else { (q, p) };
let t = (near - z(s)) / (z(e) - z(s));
let mut cut = s;
for (c, d) in cut.iter_mut().zip(e) {
*c += t * (d - *c);
}
out.push(cut);
}
}
out
}
/// The screen corner of view corner `v`: pixel x, pixel y and a depth key that is linear
/// in screen space and larger for nearer points.
pub(crate) fn project(&self, [x, y, z, r, g, b]: ViewCorner, width: u32, height: u32) -> Corner {
let (nx, ny, key) = match *self {
// Divided before multiplied, so an on-screen point's intermediate products stay finite.
View::Perspective { focal, aspect, .. } => (x / z * focal / aspect, y / z * focal, 1.0 / z),
View::Orthographic { half_height, aspect } => (x / half_height / aspect, y / half_height, -z),
};
Corner { at: [(nx + 1.0) / 2.0 * f64::from(width), (1.0 - ny) / 2.0 * f64::from(height), key], shade: [r, g, b] }
}
}
/// Whether view corner `a`'s position comes before `b`'s, ordering x, then y, then z.
fn first(a: &ViewCorner, b: &ViewCorner) -> bool {
a.iter().zip(b).take(3).map(|(p, q)| p.total_cmp(q)).find(|o| o.is_ne()).is_none_or(std::cmp::Ordering::is_lt)
}
/// The image being drawn: `rgba` holds `width * height` straight-RGBA pixels and `depth` their
/// depth keys, row by row from the top.
pub(crate) struct Target<'b> {
/// Depth keys are 1/z (a perspective view), so the shade is interpolated perspective-correct.
pub(crate) perspective: bool,
pub(crate) width: u32,
pub(crate) height: u32,
pub(crate) rgba: &'b mut [u8],
pub(crate) depth: &'b mut [f64],
/// Pixel centres the fills may still test.
pub(crate) work: u64,
}
impl Target<'_> {
/// Fill the screen triangle of `corners` with straight-RGBA `colour`, depth-tested at pixel
/// centres, the shade interpolated across it. A pixel centre on an edge two triangles share
/// belongs to one of them only. An opaque pixel replaces the pixel and its depth; a
/// translucent one is blended over it, leaving the depth. The clipped bounding box's pixel
/// centres are charged to [`Self::work`] first: [`RenderError::TooMuchWork`], with nothing
/// drawn, when too few are left.
pub(crate) fn fill(&mut self, corners: [Corner; 3], colour: [u8; 4]) -> Result<(), RenderError> {
let [a, b, c] = corners;
let area = edge(a.at, b.at, c.at);
let edges = [(b.at, c.at), (c.at, a.at), (a.at, b.at)];
if !area.is_finite() || area.abs() < 1e-12 {
return Ok(());
}
let Some((x0, x1, y0, y1)) = self.bounds(&corners) else {
return Ok(());
};
let span = |lo: u32, hi: u32| u64::from(hi.saturating_sub(lo)).saturating_add(1);
self.work = self.work.checked_sub(span(x0, x1).saturating_mul(span(y0, y1))).ok_or(RenderError::TooMuchWork)?;
for y in y0..=y1 {
for x in x0..=x1 {
let p = [f64::from(x) + 0.5, f64::from(y) + 0.5, 0.0];
let w = edges.map(|(from, to)| edge(from, to, p) / area);
if w.iter().zip(edges).any(|(&k, (from, to))| k < 0.0 || (k == 0.0 && !owns(from, to, area))) {
continue;
}
let mix = |f: &dyn Fn(&Corner) -> f64| w.iter().zip(&corners).map(|(k, c)| k * f(c)).sum::<f64>();
let key = mix(&|c| at(c.at, 2));
let Some(i) = pixel_index(self.width, x, y) else {
continue;
};
if self.depth.get(i).is_none_or(|&d| key <= d) {
continue;
}
let shade = if a.shade == b.shade && b.shade == c.shade {
a.shade
} else if self.perspective && key > 0.0 {
// Screen weights times 1/z, renormalised, are the surface's own weights.
[0, 1, 2].map(|j| mix(&|c| at(c.shade, j) * at(c.at, 2)) / key)
} else {
[0, 1, 2].map(|j| mix(&|c| at(c.shade, j)))
};
self.blend(i, key, shaded(colour, shade));
}
}
Ok(())
}
/// The pixel rows and columns `corners` may cover, clipped to the image; `None` when off it.
fn bounds(&self, corners: &[Corner; 3]) -> Option<(u32, u32, u32, u32)> {
let lo = |i: usize| corners.iter().map(|c| at(c.at, i)).fold(f64::INFINITY, f64::min).floor().max(0.0);
let hi = |i: usize, limit: u32| corners.iter().map(|c| at(c.at, i)).fold(f64::NEG_INFINITY, f64::max).ceil().min(f64::from(limit) - 1.0);
let (x0, x1) = (lo(0), hi(0, self.width));
let (y0, y1) = (lo(1), hi(1, self.height));
// Each bound is finite and within 0..limit here, so the casts are exact.
(x0 <= x1 && y0 <= y1).then_some((x0 as u32, x1 as u32, y0 as u32, y1 as u32))
}
/// Write straight-RGBA `rgba` at pixel `i`: replacing it and its depth when opaque, blended
/// over it otherwise.
fn blend(&mut self, i: usize, key: f64, rgba: [u8; 4]) {
let opaque = rgba[3] == 255;
if opaque && let Some(d) = self.depth.get_mut(i) {
*d = key;
}
let Some(px) = i.checked_mul(4).and_then(|s| self.rgba.get_mut(s..s.checked_add(4)?)) else {
return;
};
if opaque {
px.copy_from_slice(&rgba);
} else if let [pr, pg, pb, pa] = px {
let [sr, sg, sb, sa] = rgba;
let src = f64::from(sa) / 255.0;
let dst = f64::from(*pa) / 255.0 * (1.0 - src);
let out = src + dst;
if out <= 0.0 {
return;
}
for (p, s) in [pr, pg, pb].into_iter().zip([sr, sg, sb]) {
*p = channel((f64::from(s) * src + f64::from(*p) * dst) / out);
}
*pa = channel(out * 255.0);
}
}
}
/// Pixel `x, y`'s index in an image `width` wide; `None` on overflow.
pub(crate) fn pixel_index(width: u32, x: u32, y: u32) -> Option<usize> {
usize::try_from(y).ok()?.checked_mul(usize::try_from(width).ok()?)?.checked_add(usize::try_from(x).ok()?)
}
/// `v` rounded and clamped to a colour channel; NaN is 0.
pub(crate) fn channel(v: f64) -> u8 {
// Clamped to 0..=255 first, so the cast is exact (NaN casts to 0).
v.round().clamp(0.0, 255.0) as u8
}
/// `colour`'s RGB scaled channel by channel by `shade`, saturating at 255; alpha kept.
fn shaded([r, g, b, a]: [u8; 4], [sr, sg, sb]: [f64; 3]) -> [u8; 4] {
let [r, g, b] = [(r, sr), (g, sg), (b, sb)].map(|(v, s)| channel(f64::from(v) * s));
[r, g, b, a]
}
/// Twice the signed area of (a, b, p) in the xy plane.
pub(crate) fn edge(a: [f64; 3], b: [f64; 3], p: [f64; 3]) -> f64 {
// Two triangles sharing an edge meet it in opposite orders; evaluating it in one canonical
// order makes their values exact negatives, so a pixel centre on the edge cannot round out
// of both and leave a crack.
if (at(a, 0), at(a, 1)) > (at(b, 0), at(b, 1)) {
return -edge(b, a, p);
}
(at(b, 0) - at(a, 0)) * (at(p, 1) - at(a, 1)) - (at(b, 1) - at(a, 1)) * (at(p, 0) - at(a, 0))
}
/// Whether a pixel centre exactly on the edge `from`-`to` of a triangle of signed doubled area
/// `area` is the triangle's: the side the triangle grows towards, +x first, then +y. Two
/// triangles sharing the edge lie on opposite sides of it, so exactly one owns it.
fn owns(from: [f64; 3], to: [f64; 3], area: f64) -> bool {
let s = area.signum();
let (gx, gy) = (-(at(to, 1) - at(from, 1)) * s, (at(to, 0) - at(from, 0)) * s);
gx > 0.0 || (gx == 0.0 && gy > 0.0)
}
/// Component `i` of `v`, 0.0 out of range.
pub(crate) fn at(v: [f64; 3], i: usize) -> f64 {
v.get(i).copied().unwrap_or(0.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_edge_seen_from_either_triangle_is_exactly_negated() {
// So no pixel centre on a shared edge is rejected by both.
let vals = [0.1, 0.7, 1.3, 2.9, 3.3, 5.5];
for (a, b, p) in [([0.1, 0.1], [0.7, 0.7], [0.1, 2.9]), ([5.5, 0.7], [1.3, 3.3], [2.9, 0.1])]
.into_iter()
.chain(vals.iter().map(|&v| ([v, 0.1], [0.7, v], [v, v])))
{
let [a, b, p] = [a, b, p].map(|[x, y]| [x, y, 0.0]);
assert_eq!(edge(a, b, p), -edge(b, a, p), "{a:?} {b:?} {p:?}");
}
}
#[test]
fn pixel_indices_and_channels_never_overflow() {
assert_eq!(pixel_index(10, 3, 2), Some(23));
assert_eq!(channel(f64::NAN), 0);
assert_eq!(channel(1e300), 255);
assert_eq!(channel(-4.0), 0);
assert_eq!(channel(127.5), 128);
}
#[test]
fn a_shared_edge_is_cut_at_the_same_point_from_either_triangle() {
// Cutting this edge from P towards Q and from Q towards P rounds differently.
let view = View::Perspective { focal: 1.0, aspect: 1.0, near: 0.5 };
let (p, q) = ([0.1, 0.1, 0.2, 1.0, 1.0, 1.0], [0.7, 0.2, 1.6, 1.0, 1.0, 1.0]);
let one = view.clip([p, q, [0.0, 3.0, 2.0, 1.0, 1.0, 1.0]]);
let other = view.clip([q, p, [3.0, 0.0, 2.0, 1.0, 1.0, 1.0]]);
let shared = one.iter().filter(|a| other.iter().any(|b| a[..3] == b[..3])).count();
assert_eq!(shared, 2, "Q and the cut on PQ: {one:?} {other:?}");
}
fn corner(x: f64, y: f64, key: f64, shade: f64) -> Corner {
Corner { at: [x, y, key], shade: [shade; 3] }
}
#[test]
fn a_pixel_on_an_edge_two_triangles_share_is_blended_once() {
let (mut rgba, mut depth) = (vec![0; 64], vec![f64::NEG_INFINITY; 16]);
let mut t = Target { perspective: false, width: 4, height: 4, rgba: &mut rgba, depth: &mut depth, work: u64::MAX };
let [a, b, c, d] = [(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)].map(|(x, y)| corner(x, y, 1.0, 1.0));
t.fill([a, b, c], [255, 0, 0, 128]).unwrap();
t.fill([a, c, d], [255, 0, 0, 128]).unwrap();
// The diagonal runs through four pixel centres; each must look like the rest.
for px in rgba.chunks(4) {
assert_eq!(px, [255, 0, 0, 128]);
}
}
#[test]
fn a_perspective_shade_is_interpolated_on_the_surface_not_the_screen() {
let (mut rgba, mut depth) = (vec![0; 64], vec![f64::NEG_INFINITY; 16]);
let mut t = Target { perspective: true, width: 4, height: 4, rgba: &mut rgba, depth: &mut depth, work: u64::MAX };
// The x = 4 corner is three times as far away as the others, and the only lit one.
t.fill([corner(0.0, 0.0, 1.0, 0.0), corner(4.0, 0.0, 1.0 / 3.0, 1.0), corner(0.0, 4.0, 1.0, 0.0)], [240, 240, 240, 255]).unwrap();
// At screen (1.5, 0.5) the weights are 0.5, 0.375, 0.125: on the surface the far corner
// counts 0.125 / 0.75 = 1/6, where on the screen it would count 0.375.
assert_eq!(rgba.get(4..8), Some(&[40, 40, 40, 255][..]));
}
#[test]
fn fills_stop_when_the_work_allowance_is_spent() {
let (mut rgba, mut depth) = (vec![0; 64], vec![f64::NEG_INFINITY; 16]);
// Each fill's box is the whole 4x4 image: 16 pixel centres.
let mut t = Target { perspective: false, width: 4, height: 4, rgba: &mut rgba, depth: &mut depth, work: 40 };
let tri = [corner(0.0, 0.0, 1.0, 1.0), corner(4.0, 0.0, 1.0, 1.0), corner(0.0, 4.0, 1.0, 1.0)];
assert_eq!(t.fill(tri, [255, 0, 0, 255]), Ok(()));
assert_eq!(t.fill(tri, [255, 0, 0, 255]), Ok(()));
assert_eq!(t.fill(tri, [255, 0, 0, 255]), Err(RenderError::TooMuchWork));
assert_eq!(t.work, 8);
}
}
+388
View File
@@ -0,0 +1,388 @@
//! The camera, the render options and [`render`]: world meshes to an RGBA image.
//!
//! One sample per pixel centre, no antialiasing. Faces are lit on both sides because a
//! producer's winding is not trusted to face outward.
use crate::WorldMesh;
use crate::raster::{Corner, Target, View, ViewCorner, at};
use crate::vec3::{cross, dot, length, normalize, scale, span, sub, unit_cross};
use pdfcraft_prc::TriangleMesh;
/// The largest image [`render`] draws, in pixels (width × height).
pub const MAX_RENDER_PIXELS: u64 = 16 * 1024 * 1024;
/// The most pixel centres one [`render`] tests, summed over each drawn triangle's bounding box
/// clipped to the image: 128 full layers of the largest image.
pub const MAX_RENDER_WORK: u64 = 1 << 31;
/// [`Camera::fit`]'s framed extent over the model's: 5% clear on each side.
const FIT_MARGIN: f64 = 1.1;
/// How the camera projects the scene onto the image.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Projection {
/// A pinhole camera with this vertical field of view, in degrees (exclusive range 0–180).
Perspective {
/// Vertical field of view, degrees.
fov_y: f64,
},
/// A parallel projection showing this much of the scene vertically, in model units.
Orthographic {
/// Visible height, model units.
height: f64,
},
}
/// Where the camera is and where it looks, in world coordinates.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Camera {
/// The camera position.
pub eye: [f64; 3],
/// The point at the image centre.
pub target: [f64; 3],
/// The direction that appears upward; must not be parallel to the view direction.
pub up: [f64; 3],
/// The projection.
pub projection: Projection,
}
/// An axis-aligned box around every point a set of meshes draws.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Bounds {
/// The smallest x, y and z.
pub min: [f64; 3],
/// The largest x, y and z.
pub max: [f64; 3],
}
impl Bounds {
/// The box around every finite vertex the meshes' drawn triangles use, or `None` when there
/// is none.
#[must_use]
pub fn of(meshes: &[WorldMesh]) -> Option<Bounds> {
let mut out: Option<Bounds> = None;
for p in vertices(meshes) {
let b = out.get_or_insert(Bounds { min: p, max: p });
for ((lo, hi), c) in b.min.iter_mut().zip(b.max.iter_mut()).zip(p) {
*lo = lo.min(c);
*hi = hi.max(c);
}
}
out
}
/// The box's centre, finite for any finite box.
#[must_use]
pub fn centre(&self) -> [f64; 3] {
[0, 1, 2].map(|i| at(self.min, i) / 2.0 + at(self.max, i) / 2.0)
}
/// Half the box's diagonal: the radius of a sphere holding it.
#[must_use]
pub fn radius(&self) -> f64 {
length(sub(self.max, self.min)) / 2.0
}
}
/// Every corner of the triangles [`render`] draws: those not fully transparent whose three
/// corners exist and are finite.
fn vertices(meshes: &[WorldMesh]) -> impl Iterator<Item = [f64; 3]> + Clone + '_ {
meshes
.iter()
.flat_map(|m| m.mesh.triangles.iter().enumerate().filter(|&(t, _)| !invisible(m, t)).filter_map(|(_, tri)| corners(&m.mesh, tri)).flatten())
}
/// Whether triangle `t` of `m` is fully transparent, so [`render`] does not draw it.
fn invisible(m: &WorldMesh, t: usize) -> bool {
m.colour_of(t).is_some_and(|c| c[3] == 0)
}
impl Camera {
/// A camera looking along `direction` that fits `bounds` in an image of the given aspect
/// ratio (width / height), with a 30° perspective or an orthographic projection: the box's
/// eight corners framed from this direction, about 5% clear on each side of the tighter axis.
///
/// # Errors
/// [`RenderError::Camera`] when `direction` is zero or parallel to `up`, or `aspect` is not positive.
pub fn fit(bounds: &Bounds, direction: [f64; 3], up: [f64; 3], perspective: bool, aspect: f64) -> Result<Camera, RenderError> {
let corners = (0..8).map(|k| [0, 1, 2].map(|i| if k >> i & 1 == 0 { at(bounds.min, i) } else { at(bounds.max, i) }));
Camera::frame(bounds, corners, direction, up, (perspective, aspect))
}
/// As [`Camera::fit`], framing every vertex the meshes' drawn triangles use rather than their box,
/// so an oblique view is not padded by empty box corners.
///
/// # Errors
/// As [`Camera::fit`], and when the meshes have no finite vertex.
pub fn fit_meshes(meshes: &[WorldMesh], direction: [f64; 3], up: [f64; 3], perspective: bool, aspect: f64) -> Result<Camera, RenderError> {
let bounds = Bounds::of(meshes).ok_or(RenderError::Camera("the model has no vertex"))?;
Camera::frame(&bounds, vertices(meshes), direction, up, (perspective, aspect))
}
/// Frames `points` (which `bounds` holds) from `direction`, aimed at the middle of their projection.
fn frame(
bounds: &Bounds,
points: impl Iterator<Item = [f64; 3]> + Clone,
direction: [f64; 3],
up: [f64; 3],
(perspective, aspect): (bool, f64),
) -> Result<Camera, RenderError> {
if !(aspect.is_finite() && aspect > 0.0) {
return Err(RenderError::Camera("the aspect ratio is not positive"));
}
let dir = normalize(direction).ok_or(RenderError::Camera("the view direction is zero"))?;
let centre = bounds.centre();
let radius = match bounds.radius() {
r if r.is_finite() && r > 0.0 => r,
_ => 1.0,
};
let [right, upward, forward] = axes(dir, up)?;
let view = |p: [f64; 3]| {
let r = sub(p, centre);
[dot(r, right), dot(r, upward), dot(r, forward)]
};
let ((x0, x1), (y0, y1)) = points
.clone()
.map(view)
.fold(((f64::INFINITY, f64::NEG_INFINITY), (f64::INFINITY, f64::NEG_INFINITY)), |((a0, a1), (b0, b1)), [x, y, _]| {
((a0.min(x), a1.max(x)), (b0.min(y), b1.max(y)))
});
if !(x0 <= x1 && y0 <= y1) {
return Err(RenderError::Camera("the model has no vertex"));
}
let (mx, my) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
let target = [0, 1, 2].map(|i| at(centre, i) + mx * at(right, i) + my * at(upward, i));
let floor = radius * 1e-3;
let (projection, distance) = if perspective {
let fov_y: f64 = 30.0;
let ty = (fov_y.to_radians() / 2.0).tan() / FIT_MARGIN;
let tx = ty * aspect;
// A point at lateral (x, y) from the target and depth f past it is in view when
// |x| <= (d + f)·tx and |y| <= (d + f)·ty.
let d = points.map(view).fold(floor, |d, [x, y, f]| {
let need = ((x - mx).abs() / tx).max((y - my).abs() / ty);
d.max(need - f).max(floor - f)
});
(Projection::Perspective { fov_y }, d)
} else {
let height = ((y1 - y0).max((x1 - x0) / aspect) * FIT_MARGIN).max(floor);
(Projection::Orthographic { height }, 2.0 * radius)
};
let camera = Camera { eye: sub(target, scale(dir, distance)), target, up, projection };
camera.basis()?;
Ok(camera)
}
/// Right, up and forward unit vectors.
pub(crate) fn basis(&self) -> Result<[[f64; 3]; 3], RenderError> {
let forward = normalize(sub(self.target, self.eye)).ok_or(RenderError::Camera("the eye and the target coincide"))?;
axes(forward, self.up)
}
}
/// Right, up and `forward` (a unit vector) for a camera whose up direction is `up`.
fn axes(forward: [f64; 3], up: [f64; 3]) -> Result<[[f64; 3]; 3], RenderError> {
let right = normalize(cross(forward, up)).ok_or(RenderError::Camera("the up direction is zero or parallel to the view direction"))?;
Ok([right, cross(right, forward), forward])
}
/// How surfaces are lit.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum Lighting {
/// One light at the camera: a surface's colour times `0.3 + 0.7·|n·v|`, where `v` points from
/// the surface to the eye (orthographic: against the view direction).
#[default]
Headlight,
/// No shading: every surface in its own colour.
Unlit,
}
/// What [`render`] draws.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RenderOptions {
/// Image width, pixels.
pub width: u32,
/// Image height, pixels.
pub height: u32,
/// Background colour, straight RGBA.
pub background: [u8; 4],
/// The colour of a surface with none of its own.
pub colour: [u8; 3],
/// How surfaces are lit.
pub lighting: Lighting,
}
impl Default for RenderOptions {
/// 1024 × 768, opaque white background, light grey surface, the headlight.
fn default() -> Self {
RenderOptions { width: 1024, height: 768, background: [255, 255, 255, 255], colour: [190, 192, 200], lighting: Lighting::Headlight }
}
}
/// A rendered image: `rgba` holds `width * height` straight-alpha pixels, row by row from the top.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Image {
/// Width, pixels.
pub width: u32,
/// Height, pixels.
pub height: u32,
/// The pixels.
pub rgba: Vec<u8>,
}
/// Why [`render`], [`Camera::fit`] or [`crate::assemble`] refused.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum RenderError {
/// The image is empty or larger than [`MAX_RENDER_PIXELS`].
#[error("a {width}x{height} image is empty or larger than the render ceiling")]
Size {
/// Requested width.
width: u32,
/// Requested height.
height: u32,
},
/// The camera cannot form a view.
#[error("invalid camera: {0}")]
Camera(&'static str),
/// The model could not be read.
#[error("unreadable model: {0}")]
Model(#[from] pdfcraft_prc::PrcError),
/// The placed model has more than [`crate::MAX_ASSEMBLED_TRIANGLES`] triangles.
#[error("the placed model has more triangles than the assembly ceiling")]
TooManyTriangles,
/// The placed model has more than [`crate::MAX_ASSEMBLED_VERTICES`] positions, normals and
/// texture coordinates.
#[error("the placed model has more vertices than the assembly ceiling")]
TooManyVertices,
/// Drawing would test more than [`MAX_RENDER_WORK`] pixel centres.
#[error("the render would test more pixels than the work ceiling")]
TooMuchWork,
}
/// Draw `meshes` as seen by `camera`.
///
/// Each triangle is filled in its own colour ([`WorldMesh::triangle_colours`]), else its mesh's
/// ([`WorldMesh::colour`]), else [`RenderOptions::colour`], and lit per [`RenderOptions::lighting`];
/// nearer surfaces hide farther ones. Opaque triangles are drawn first; translucent ones are then
/// blended over them, hidden by nearer opaque surfaces but not by each other, in mesh order; fully
/// transparent ones are not drawn. Triangles with a non-finite or out-of-range vertex are skipped.
/// A perspective camera clips at a near plane 1/10,000 of the way from the eye to the target, so
/// what lies behind the eye, or in front of it but nearer than that, is not drawn; an orthographic
/// camera does not clip.
///
/// # Errors
/// [`RenderError::Size`] for an empty or over-ceiling image; [`RenderError::Camera`] for a camera
/// that cannot form a view (eye on the target, `up` parallel to the view, a field of view outside
/// 0–180° or too small for a finite focal length, a non-positive orthographic height);
/// [`RenderError::TooMuchWork`] past [`MAX_RENDER_WORK`].
pub fn render(meshes: &[WorldMesh], camera: &Camera, options: &RenderOptions) -> Result<Image, RenderError> {
let (width, height) = (options.width, options.height);
let size = RenderError::Size { width, height };
let pixels = u64::from(width) * u64::from(height);
if pixels == 0 || pixels > MAX_RENDER_PIXELS {
return Err(size);
}
let pixels = usize::try_from(pixels).map_err(|_| size)?;
let view = View::new(camera, f64::from(width) / f64::from(height))?;
let perspective = matches!(view, View::Perspective { .. });
let scene = Scene { eye: camera.eye, basis: camera.basis()?, view, lighting: options.lighting };
let mut rgba = options.background.repeat(pixels);
let mut depth = vec![f64::NEG_INFINITY; pixels];
let mut target = Target { perspective, width, height, rgba: &mut rgba, depth: &mut depth, work: MAX_RENDER_WORK };
let [r, g, b] = options.colour;
for opaque in [true, false] {
for m in meshes {
for t in 0..m.mesh.triangles.len() {
let colour = m.colour_of(t).unwrap_or([r, g, b, 255]);
if (colour[3] == 255) == opaque && colour[3] != 0 {
scene.draw(&mut target, &m.mesh, t, colour)?;
}
}
}
}
Ok(Image { width, height, rgba })
}
/// The camera a frame is drawn from, with its basis and projection.
struct Scene {
eye: [f64; 3],
/// Right, up and forward.
basis: [[f64; 3]; 3],
view: View,
lighting: Lighting,
}
impl Scene {
/// The shade at world point `p` with normal `n`.
fn shade(&self, p: [f64; 3], n: [f64; 3]) -> [f64; 3] {
if self.lighting == Lighting::Unlit {
return [1.0; 3];
}
let towards = match self.view {
View::Perspective { .. } => normalize(sub(self.eye, p)),
View::Orthographic { .. } => Some(scale(at3(&self.basis, 2), -1.0)),
};
[0.3 + 0.7 * towards.map_or(1.0, |v| dot(n, v).abs().min(1.0)); 3]
}
/// Draw triangle `t` of `mesh` into `target` in straight-RGBA `colour`; refused once the
/// target's work allowance is spent.
fn draw(&self, target: &mut Target, mesh: &TriangleMesh, t: usize, colour: [u8; 4]) -> Result<(), RenderError> {
let Some(world) = mesh.triangles.get(t).and_then(|tri| corners(mesh, tri)) else {
return Ok(());
};
let Some(face) = unit_cross(span(at3(&world, 0), at3(&world, 1)), span(at3(&world, 0), at3(&world, 2))) else {
return Ok(());
};
let stored = stored_normals(mesh, t);
let normal = |k: usize| stored.and_then(|n| n.get(k).copied().flatten()).unwrap_or(face);
// Without stored normals the triangle is flat: one shade, lit along the view ray to its first corner.
let flat = stored.is_none().then(|| self.shade(at3(&world, 0), normal(0)));
let [right, up, forward] = self.basis;
let view_corners = [0, 1, 2].map(|k| -> ViewCorner {
let p = at3(&world, k);
let [r, g, b] = flat.unwrap_or_else(|| self.shade(p, normal(k)));
let d = sub(p, self.eye);
[dot(d, right), dot(d, up), dot(d, forward), r, g, b]
});
let screen: Vec<Corner> = self.view.clip(view_corners).into_iter().map(|v| self.view.project(v, target.width, target.height)).collect();
if let Some((&a, rest)) = screen.split_first() {
for pair in rest.windows(2) {
if let [b, c] = pair {
target.fill([a, *b, *c], colour)?;
}
}
}
Ok(())
}
}
/// Triangle `t`'s stored corner normals, unit length; a corner whose normal is zero or not finite
/// is `None`, and so is the whole when the mesh stores none.
fn stored_normals(mesh: &TriangleMesh, t: usize) -> Option<[Option<[f64; 3]>; 3]> {
let slots = mesh.triangle_normals.get(t)?;
Some(slots.map(|i| mesh.normals.get(usize::try_from(i).ok()?).copied().and_then(normalize)))
}
/// The three corners of a triangle, or `None` when one is missing or not finite.
fn corners(mesh: &TriangleMesh, tri: &[u32; 3]) -> Option<[[f64; 3]; 3]> {
let mut out = [[0.0; 3]; 3];
for (slot, &i) in out.iter_mut().zip(tri) {
let p = *mesh.positions.get(usize::try_from(i).ok()?)?;
if !p.iter().all(|c| c.is_finite()) {
return None;
}
*slot = p;
}
Some(out)
}
/// Corner `i` of a triangle, the origin when out of range.
fn at3(v: &[[f64; 3]; 3], i: usize) -> [f64; 3] {
v.get(i).copied().unwrap_or([0.0; 3])
}
#[cfg(test)]
mod tests;
+347
View File
@@ -0,0 +1,347 @@
#![allow(clippy::unwrap_used, clippy::panic, clippy::unreachable)]
use super::*;
fn mesh(positions: Vec<[f64; 3]>, triangles: Vec<[u32; 3]>) -> TriangleMesh {
let mut m = TriangleMesh::default();
m.positions = positions;
m.triangles = triangles;
m
}
fn quad_mesh(z: f64, half: f64, tilt: f64) -> TriangleMesh {
mesh(vec![[-half, -half, z - tilt], [half, -half, z + tilt], [half, half, z + tilt], [-half, half, z - tilt]], vec![[0, 1, 2], [0, 2, 3]])
}
fn quad(z: f64, half: f64, tilt: f64) -> WorldMesh {
WorldMesh::new(quad_mesh(z, half, tilt))
}
fn painted(m: WorldMesh, colour: Option<[u8; 4]>) -> WorldMesh {
WorldMesh { colour, ..m }
}
fn ortho(height: f64) -> Camera {
Camera { eye: [0.0, 0.0, 10.0], target: [0.0, 0.0, 0.0], up: [0.0, 1.0, 0.0], projection: Projection::Orthographic { height } }
}
fn small() -> RenderOptions {
RenderOptions { width: 40, height: 40, ..RenderOptions::default() }
}
fn pixel(image: &Image, x: u32, y: u32) -> [u8; 4] {
let i = (y * image.width + x) as usize * 4;
image.rgba[i..i + 4].try_into().unwrap()
}
#[test]
fn a_facing_square_fills_the_middle_and_leaves_the_corners() {
let image = render(&[quad(0.0, 1.0, 0.0)], &ortho(4.0), &small()).unwrap();
assert_eq!(pixel(&image, 20, 20), [190, 192, 200, 255]);
assert_eq!(pixel(&image, 1, 1), [255, 255, 255, 255]);
// Half the view height is covered: pixels 10..30.
assert_eq!(pixel(&image, 10, 20)[0], 190);
assert_eq!(pixel(&image, 9, 20)[0], 255);
}
#[test]
fn stored_normals_shade_smoothly_across_a_flat_square() {
let mut m = quad_mesh(0.0, 1.0, 0.0);
let s = 3f64.sqrt() / 2.0;
// The left corners lean 60° away from the viewer; the right face it.
m.normals = vec![[s, 0.0, 0.5], [0.0, 0.0, 2.0]];
m.triangle_normals = vec![[0, 1, 1], [0, 1, 0]];
let image = render(&[WorldMesh::new(m.clone())], &ortho(4.0), &small()).unwrap();
let red = |x| pixel(&image, x, 20)[0];
assert!(red(11) < red(20) && red(20) < red(29), "{} {} {}", red(11), red(20), red(29));
// Shade 0.3 + 0.7 cos θ at the edges: 0.65 left, 1.0 right.
assert!((i32::from(red(10)) - 128).abs() <= 4, "{}", red(10));
assert!(red(29) >= 186);
m.normals.clear();
m.triangle_normals.clear();
let flat = render(&[WorldMesh::new(m)], &ortho(4.0), &small()).unwrap();
assert_eq!(pixel(&flat, 11, 20), pixel(&flat, 29, 20));
}
#[test]
fn the_nearer_surface_hides_the_farther_whatever_the_order() {
let far = quad(-1.0, 2.0, 1.0);
let near = quad(1.0, 0.5, 0.0);
for meshes in [[far.clone(), near.clone()], [near, far]] {
let image = render(&meshes, &ortho(8.0), &small()).unwrap();
assert_eq!(pixel(&image, 20, 20), [190, 192, 200, 255]);
assert!(pixel(&image, 20, 12)[0] < 190, "the tilted far square is darker");
}
}
const RED: Option<[u8; 4]> = Some([255, 0, 0, 255]);
const GLASS: Option<[u8; 4]> = Some([0, 0, 255, 102]);
/// Glass (40% blue) in front of red blends whichever is listed first, glass behind red is hidden,
/// a clear mesh draws nothing, and a mesh with no colour takes the options' colour.
#[test]
fn meshes_draw_in_their_own_colours_and_glass_blends() {
let unlit = RenderOptions { lighting: Lighting::Unlit, ..small() };
let at_middle = |meshes: &[(f64, Option<[u8; 4]>)]| {
let meshes: Vec<WorldMesh> = meshes.iter().map(|&(z, c)| painted(quad(z, 1.0, 0.0), c)).collect();
pixel(&render(&meshes, &ortho(4.0), &unlit).unwrap(), 20, 20)
};
assert_eq!(at_middle(&[(1.0, GLASS), (-1.0, RED)]), [153, 0, 102, 255]);
assert_eq!(at_middle(&[(-1.0, RED), (1.0, GLASS)]), [153, 0, 102, 255]);
assert_eq!(at_middle(&[(-1.0, GLASS), (1.0, RED)]), [255, 0, 0, 255]);
assert_eq!(at_middle(&[(1.0, Some([0, 0, 255, 0])), (-1.0, RED)]), [255, 0, 0, 255]);
assert_eq!(at_middle(&[(1.0, None)]), [190, 192, 200, 255]);
assert_eq!(at_middle(&[(2.0, GLASS), (1.0, GLASS), (-1.0, RED)]), [92, 0, 163, 255], "glass does not hide glass");
let clear = RenderOptions { background: [0, 0, 0, 0], ..unlit };
let image = render(&[painted(quad(1.0, 1.0, 0.0), GLASS)], &ortho(4.0), &clear).unwrap();
assert_eq!(pixel(&image, 20, 20), [0, 0, 255, 102], "glass over nothing");
}
#[test]
fn a_triangle_colour_overrides_its_mesh_colour() {
let mut m = painted(quad(0.0, 1.0, 0.0), RED);
// The lower-right triangle is green; the upper-left keeps the mesh's red.
m.triangle_colours = Some(vec![Some([0, 255, 0, 255]), None]);
let unlit = RenderOptions { lighting: Lighting::Unlit, ..small() };
let image = render(&[m], &ortho(4.0), &unlit).unwrap();
assert_eq!(pixel(&image, 25, 25), [0, 255, 0, 255]);
assert_eq!(pixel(&image, 14, 14), [255, 0, 0, 255]);
}
#[test]
fn each_edge_bounds_a_triangle() {
let tri = WorldMesh::new(mesh(vec![[-1.0, -1.0, 0.0], [1.0, -1.0, 0.0], [0.0, 1.0, 0.0]], vec![[0, 1, 2]]));
let image = render(&[tri], &ortho(4.0), &small()).unwrap();
assert_ne!(pixel(&image, 20, 25)[0], 255);
// Just past each edge: below the base, left and right of the apex.
for (x, y) in [(20, 31), (13, 15), (27, 15)] {
assert_eq!(pixel(&image, x, y)[0], 255, "({x},{y})");
}
}
#[test]
fn a_face_turned_away_is_still_drawn() {
let mut back = quad_mesh(0.0, 1.0, 0.0);
for t in &mut back.triangles {
t.swap(1, 2);
}
let image = render(&[WorldMesh::new(back)], &ortho(4.0), &small()).unwrap();
assert_eq!(pixel(&image, 20, 20), [190, 192, 200, 255]);
}
#[test]
fn perspective_clips_what_is_behind_the_camera() {
let camera = Camera { projection: Projection::Perspective { fov_y: 60.0 }, ..ortho(1.0) };
let image = render(&[quad(20.0, 1.0, 0.0)], &camera, &small()).unwrap();
assert!(image.rgba.chunks(4).all(|p| p == [255, 255, 255, 255]));
// A floor running from behind the camera to beyond the target.
let floor = WorldMesh::new(mesh(vec![[-5.0, -1.0, 20.0], [5.0, -1.0, 20.0], [0.0, -1.0, -20.0]], vec![[0, 1, 2]]));
let image = render(&[floor], &camera, &small()).unwrap();
assert_ne!(pixel(&image, 20, 38), [255, 255, 255, 255]);
assert_eq!(pixel(&image, 20, 2), [255, 255, 255, 255]);
}
#[test]
fn a_fitted_camera_frames_the_model() {
// Depth matters in perspective: the nearer square looks larger.
let meshes = [quad(3.0, 3.0, 0.0), quad(-3.0, 3.0, 0.0)];
let bounds = Bounds::of(&meshes).unwrap();
for perspective in [true, false] {
let camera = Camera::fit(&bounds, [0.0, 0.0, -1.0], [0.0, 1.0, 0.0], perspective, 2.0).unwrap();
let image = render(&meshes, &camera, &RenderOptions { width: 80, height: 40, ..RenderOptions::default() }).unwrap();
assert_ne!(pixel(&image, 40, 20)[0], 255);
// Nothing drawn touches the image border.
for x in 0..80 {
assert_eq!(pixel(&image, x, 0)[0], 255);
assert_eq!(pixel(&image, x, 39)[0], 255);
}
}
}
#[test]
fn a_mesh_fit_ignores_empty_box_corners() {
// A thin strip along the box's diagonal: seen across the diagonal, the box is as tall as it
// is wide, the strip is not.
let meshes = [WorldMesh::new(mesh(vec![[0.0, 0.0, 0.0], [10.0, 10.0, 0.0], [10.0, 10.01, 0.0]], vec![[0, 1, 2]]))];
let (dir, up) = ([0.0, 0.0, -1.0], [1.0, -1.0, 0.0]);
let height = |c: Camera| match c.projection {
Projection::Orthographic { height } => height,
Projection::Perspective { .. } => unreachable!(),
};
let boxed = height(Camera::fit(&Bounds::of(&meshes).unwrap(), dir, up, false, 2.0).unwrap());
let tight = height(Camera::fit_meshes(&meshes, dir, up, false, 2.0).unwrap());
assert!((boxed / tight - 2.0).abs() < 0.01, "{boxed} vs {tight}");
let near = Camera::fit_meshes(&meshes, dir, up, true, 2.0).unwrap();
let far = Camera::fit(&Bounds::of(&meshes).unwrap(), dir, up, true, 2.0).unwrap();
assert!(length(sub(near.eye, near.target)) < 0.6 * length(sub(far.eye, far.target)));
// Lopsided in its box: the limiting vertex sits exactly on the margin, measured from the
// re-centred target. Narrow (width limits) and wide (height limits).
let tri = WorldMesh::new(mesh(vec![[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [1.0, 2.0, 1.0]], vec![[0, 1, 2]]));
for aspect in [0.5, 3.0] {
let c = Camera::fit_meshes(std::slice::from_ref(&tri), [0.0, 0.3, -1.0], [1.0, 1.0, 0.0], true, aspect).unwrap();
let [right, upward, forward] = c.basis().unwrap();
let ty = 15f64.to_radians().tan() / FIT_MARGIN;
let fill = tri
.mesh
.positions
.iter()
.map(|&p| {
let r = sub(p, c.eye);
(dot(r, right).abs() / (ty * aspect)).max(dot(r, upward).abs() / ty) / dot(r, forward)
})
.fold(0.0, f64::max);
assert!((fill - 1.0).abs() < 1e-6, "aspect {aspect}: {fill}");
}
}
#[test]
fn a_fitted_camera_fills_the_tighter_axis() {
let drawn = |image: &Image, horizontal: bool, line: u32| {
let n = if horizontal { image.width } else { image.height };
(0..n)
.filter(|&i| {
let (x, y) = if horizontal { (i, line) } else { (line, i) };
pixel(image, x, y)[0] != 255
})
.count()
};
// Height-limited: a 6 x 6 square in an 80 x 40 image.
let square = [quad(0.0, 3.0, 0.0)];
// Width-limited and off-origin: a 10 x 1 strip in a 40 x 40 image.
let strip = [WorldMesh::new(mesh(vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [10.0, 1.0, 0.0], [0.0, 1.0, 0.0]], vec![[0, 1, 2], [0, 2, 3]]))];
for perspective in [true, false] {
let fit =
|meshes: &[WorldMesh], aspect| Camera::fit(&Bounds::of(meshes).unwrap(), [0.0, 0.0, -1.0], [0.0, 1.0, 0.0], perspective, aspect).unwrap();
let wide = RenderOptions { width: 80, height: 40, ..RenderOptions::default() };
let rows = drawn(&render(&square, &fit(&square, 2.0), &wide).unwrap(), false, 40);
assert!((34..=37).contains(&rows), "{perspective}: {rows} of 40 rows");
let image = render(&strip, &fit(&strip, 1.0), &small()).unwrap();
let cols = drawn(&image, true, 20);
assert!((34..=37).contains(&cols), "{perspective}: {cols} of 40 columns");
// Centred: the margins differ by at most a pixel.
let left = (0..40).take_while(|&x| pixel(&image, x, 20)[0] == 255).count();
assert!(left.abs_diff(40 - cols - left) <= 1, "{perspective}: left {left}");
}
}
#[test]
fn bad_sizes_and_cameras_are_refused() {
let m = [quad(0.0, 1.0, 0.0)];
let size = |width, height| RenderOptions { width, height, ..RenderOptions::default() };
assert!(matches!(render(&m, &ortho(1.0), &size(0, 10)), Err(RenderError::Size { .. })));
assert!(matches!(render(&m, &ortho(1.0), &size(10_000, 10_000)), Err(RenderError::Size { .. })));
let bad = [
Camera { eye: [0.0; 3], target: [0.0; 3], ..ortho(1.0) },
Camera { up: [0.0, 0.0, 1.0], ..ortho(1.0) },
Camera { projection: Projection::Perspective { fov_y: 180.0 }, ..ortho(1.0) },
Camera { projection: Projection::Perspective { fov_y: f64::NAN }, ..ortho(1.0) },
Camera { projection: Projection::Perspective { fov_y: 1e-310 }, ..ortho(1.0) },
ortho(0.0),
ortho(f64::INFINITY),
];
for camera in bad {
assert!(matches!(render(&m, &camera, &small()), Err(RenderError::Camera(_))), "{camera:?}");
}
let b = Bounds::of(&m).unwrap();
assert!(Camera::fit(&b, [0.0; 3], [0.0, 1.0, 0.0], true, 1.0).is_err());
assert!(Camera::fit(&b, [0.0, 0.0, 1.0], [0.0, 1.0, 0.0], true, 0.0).is_err());
assert!(Camera::fit_meshes(&[], [0.0, 0.0, 1.0], [0.0, 1.0, 0.0], true, 1.0).is_err());
}
#[test]
fn non_finite_and_missing_vertices_are_skipped() {
let m = WorldMesh::new(mesh(vec![[0.0, 0.0, 0.0], [f64::NAN, 0.0, 0.0], [0.0, 1.0, 0.0]], vec![[0, 1, 2], [0, 2, 9], [0, 2, u32::MAX]]));
let image = render(std::slice::from_ref(&m), &ortho(4.0), &small()).unwrap();
assert!(image.rgba.chunks(4).all(|p| p == [255, 255, 255, 255]));
// No triangle can be drawn, so none frames the model.
assert_eq!(Bounds::of(&[m]), None);
}
#[test]
fn unlit_draws_the_plain_colour_and_the_headlight_shades_by_angle() {
let lit = |lighting, tilt| {
let image = render(&[quad(0.0, 1.0, tilt)], &ortho(4.0), &RenderOptions { lighting, ..small() }).unwrap();
pixel(&image, 20, 20)
};
assert_eq!(lit(Lighting::Headlight, 0.0), [190, 192, 200, 255]);
// Tilted 45°: 0.3 + 0.7 cos 45° ≈ 0.795 of the colour.
assert_eq!(lit(Lighting::Headlight, 1.0), [151, 153, 159, 255]);
assert_eq!(lit(Lighting::Unlit, 1.0), [190, 192, 200, 255]);
}
#[test]
fn a_huge_square_is_drawn() {
// Its edges' cross product (about 1e320) overflows unless they are scaled first.
let image = render(&[quad(0.0, 1e160, 0.0)], &ortho(4e160), &small()).unwrap();
assert_eq!(pixel(&image, 20, 20), [190, 192, 200, 255]);
assert_eq!(pixel(&image, 1, 1), [255, 255, 255, 255]);
}
#[test]
fn a_camera_very_far_away_still_draws() {
let camera = Camera { eye: [0.0, 0.0, 1e200], target: [0.0; 3], up: [0.0, 1.0, 0.0], projection: Projection::Perspective { fov_y: 30.0 } };
let image = render(&[quad(0.0, 1e199, 0.0)], &camera, &small()).unwrap();
// The headlight meets the square almost head-on at the centre pixel.
let [r, _, _, a] = pixel(&image, 20, 20);
assert!((185..=190).contains(&r) && a == 255, "{:?}", pixel(&image, 20, 20));
}
#[test]
fn the_centre_of_a_box_near_the_largest_float_is_finite() {
let b = Bounds { min: [1e308; 3], max: [1.5e308; 3] };
assert!(b.centre().iter().all(|c| c.is_finite()), "{:?}", b.centre());
}
#[test]
fn a_triangle_that_is_not_drawn_does_not_widen_the_box() {
// The second triangle has a non-finite corner, so its finite far corner is not drawn either.
let m = mesh(vec![[0.0; 3], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [f64::NAN, 0.0, 0.0], [1e6, 0.0, 0.0]], vec![[0, 1, 2], [3, 4, 0]]);
let b = Bounds::of(&[WorldMesh::new(m)]).unwrap();
assert_eq!(b.max, [1.0, 1.0, 0.0]);
}
#[test]
fn a_fully_transparent_triangle_does_not_widen_the_box_or_the_fit() {
// The far triangle is clear, so the camera frames the near one as if it were alone.
let m = mesh(vec![[0.0; 3], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [1e6, 0.0, 0.0], [1e6, 1.0, 0.0]], vec![[0, 1, 2], [3, 4, 1]]);
let clear = WorldMesh { triangle_colours: Some(vec![None, Some([0, 0, 0, 0])]), ..WorldMesh::new(m.clone()) };
assert_eq!(Bounds::of(std::slice::from_ref(&clear)).unwrap().max, [1.0, 1.0, 0.0]);
let alone = WorldMesh::new(mesh(m.positions, vec![[0, 1, 2]]));
let fit = |w: &WorldMesh| Camera::fit_meshes(std::slice::from_ref(w), [0.0, 0.0, -1.0], [0.0, 1.0, 0.0], true, 1.0).unwrap();
assert_eq!(fit(&clear), fit(&alone));
// A whole mesh painted clear is not framed either.
assert_eq!(
Bounds::of(&[painted(WorldMesh::new(mesh(vec![[0.0; 3], [9.0, 0.0, 0.0], [0.0, 9.0, 0.0]], vec![[0, 1, 2]])), Some([1, 2, 3, 0]))]),
None
);
}
#[test]
fn a_small_model_far_from_the_origin_can_be_fitted() {
let (o, d) = (1e16, 4.0);
let m = mesh(vec![[o, o, o], [o + d, o, o], [o, o + d, o]], vec![[0, 1, 2]]);
let camera = Camera::fit_meshes(&[WorldMesh::new(m)], [1.0, 1.0, 1.0], [0.0, 1.0, 0.0], false, 1.0);
assert!(camera.is_ok(), "{camera:?}");
}
#[test]
fn a_triangle_whose_edges_overflow_is_drawn() {
// Its corners are finite but 2e308 apart, so the plain edge differences are infinite.
let m = mesh(vec![[-1e308, -1e308, 0.0], [1e308, -1e308, 0.0], [0.0, 1e308, 0.0]], vec![[0, 1, 2]]);
let image = render(&[WorldMesh::new(m)], &ortho(1e308), &small()).unwrap();
assert_eq!(pixel(&image, 20, 20), [190, 192, 200, 255]);
}
#[test]
fn an_on_screen_triangle_is_drawn_when_projecting_it_would_overflow() {
let wide = RenderOptions { width: 30, height: 10, ..RenderOptions::default() };
let drawn = |image: &Image| image.rgba.chunks(4).filter(|px| px != &[255, 255, 255, 255]).count();
// A tiny field of view: x times the focal length (about 1.1e300) overflows, though x over z does not.
let camera = Camera { eye: [0.0; 3], target: [0.0, 0.0, 1e308], up: [0.0, 1.0, 0.0], projection: Projection::Perspective { fov_y: 1e-298 } };
let far = mesh(vec![[1.5e8, -3e7, 1e308], [2.5e8, -3e7, 1e308], [2e8, 3e7, 1e308]], vec![[0, 1, 2]]);
assert!(drawn(&render(&[WorldMesh::new(far)], &camera, &wide).unwrap()) > 0);
// Half the orthographic height times the aspect ratio overflows.
let big = mesh(vec![[-5e307, -5e307, 0.0], [5e307, -5e307, 0.0], [0.0, 5e307, 0.0]], vec![[0, 1, 2]]);
assert_eq!(pixel(&render(&[WorldMesh::new(big)], &ortho(1.5e308), &wide).unwrap(), 15, 5), [190, 192, 200, 255]);
}
+119
View File
@@ -0,0 +1,119 @@
//! Three-component vector arithmetic for the camera and the rasterizer.
/// A point or direction, `[x, y, z]`.
pub(crate) type Vec3 = [f64; 3];
/// `a - b`.
pub(crate) fn sub([ax, ay, az]: Vec3, [bx, by, bz]: Vec3) -> Vec3 {
[ax - bx, ay - by, az - bz]
}
/// The direction from `from` to `to`: `to - from`, halved when the full difference overflows.
pub(crate) fn span(from: Vec3, to: Vec3) -> Vec3 {
let d = sub(to, from);
if d.iter().all(|c| c.is_finite()) { d } else { sub(scale(to, 0.5), scale(from, 0.5)) }
}
/// `a` times `s`.
pub(crate) fn scale(a: Vec3, s: f64) -> Vec3 {
a.map(|c| c * s)
}
/// The dot product `a · b`.
pub(crate) fn dot([ax, ay, az]: Vec3, [bx, by, bz]: Vec3) -> f64 {
ax * bx + ay * by + az * bz
}
/// The right-handed cross product `a × b`.
pub(crate) fn cross([ax, ay, az]: Vec3, [bx, by, bz]: Vec3) -> Vec3 {
[ay * bz - az * by, az * bx - ax * bz, ax * by - ay * bx]
}
/// The Euclidean length of `a`, taken with `a` divided by its largest component so squaring
/// cannot overflow or underflow; infinite or NaN when a component is.
pub(crate) fn length(a: Vec3) -> f64 {
let big = largest(a);
if !(big.is_finite() && big > 0.0) {
return dot(a, a).sqrt();
}
let a = a.map(|c| c / big);
big * dot(a, a).sqrt()
}
/// The largest component of `a` by magnitude; NaN components are passed over.
fn largest(a: Vec3) -> f64 {
a.iter().fold(0.0_f64, |m, c| m.max(c.abs()))
}
/// `a` scaled to unit length; `None` when its length is zero or not finite. `a` is first divided
/// by its largest component, so squaring cannot overflow or underflow.
pub(crate) fn normalize(a: Vec3) -> Option<Vec3> {
let big = largest(a);
if !(big.is_finite() && big > 0.0) {
return None;
}
let a = a.map(|c| c / big);
let l = dot(a, a).sqrt();
(l.is_finite() && l > 0.0).then(|| scale(a, 1.0 / l))
}
/// The unit normal of the plane spanned by `a` and `b` (`a × b`, normalized); `None` when they are
/// parallel, zero or not finite. Each is normalized first, so neither huge edges nor edges of
/// very different sizes overflow or underflow the cross product.
pub(crate) fn unit_cross(a: Vec3, b: Vec3) -> Option<Vec3> {
normalize(cross(normalize(a)?, normalize(b)?))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cross_follows_the_right_hand_rule() {
assert_eq!(cross([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]), [0.0, 0.0, 1.0]);
assert_eq!(cross([0.0, 1.0, 0.0], [1.0, 0.0, 0.0]), [0.0, 0.0, -1.0]);
}
#[test]
fn normalize_rejects_degenerate_vectors() {
assert_eq!(normalize([0.0, 4.0, 0.0]), Some([0.0, 1.0, 0.0]));
assert_eq!(normalize([0.0; 3]), None);
assert_eq!(normalize([f64::NAN, 0.0, 0.0]), None);
assert_eq!(normalize([f64::INFINITY, 0.0, 0.0]), None);
}
#[test]
fn normalize_keeps_huge_and_tiny_vectors() {
assert_eq!(normalize([1e200, 0.0, 0.0]), Some([1.0, 0.0, 0.0]));
assert_eq!(normalize([0.0, -3e-320, 0.0]), Some([0.0, -1.0, 0.0]));
}
#[test]
fn unit_cross_survives_edges_whose_product_overflows() {
assert_eq!(unit_cross([1e160, 0.0, 0.0], [0.0, 1e160, 0.0]), Some([0.0, 0.0, 1.0]));
assert_eq!(unit_cross([1e-170, 0.0, 0.0], [0.0, 1e-170, 0.0]), Some([0.0, 0.0, 1.0]));
assert_eq!(unit_cross([2.0, 0.0, 0.0], [4.0, 0.0, 0.0]), None);
assert_eq!(unit_cross([f64::INFINITY, 0.0, 0.0], [0.0, 1.0, 0.0]), None);
}
#[test]
fn unit_cross_survives_edges_of_very_different_sizes() {
assert_eq!(unit_cross([1e-170, 0.0, 0.0], [0.0, 1e160, 0.0]), Some([0.0, 0.0, 1.0]));
}
#[test]
fn span_keeps_the_direction_of_a_difference_that_overflows() {
assert_eq!(span([1.0, 2.0, 3.0], [4.0, 0.0, 3.0]), [3.0, -2.0, 0.0]);
assert_eq!(span([-1e308, 0.0, 0.0], [1e308, 1e308, 0.0]), [1e308, 5e307, 0.0]);
}
#[test]
fn length_survives_huge_and_tiny_vectors() {
let near = |l: f64, want: f64| (l - want).abs() <= want * 1e-15;
assert!(near(length([3e200, 4e200, 0.0]), 5e200));
assert!(near(length([0.0, 3e-200, 4e-200]), 5e-200));
assert_eq!(length([0.0; 3]), 0.0);
assert!(length([f64::NAN, 1.0, 0.0]).is_nan());
assert_eq!(length([f64::INFINITY, 1.0, 0.0]), f64::INFINITY);
}
}
+145
View File
@@ -0,0 +1,145 @@
//! End to end: a synthetic PRC stream parsed, assembled and rendered to pixels.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use pdfcraft_prc::PrcFile;
use pdfcraft_prc_render::{Camera, Image, RenderOptions, assemble, render};
use std::io::Write as _;
/// An MSB-first bit sink: the inverse of the PRC bit reader, for the few values these tests write.
#[derive(Default)]
struct W {
bits: Vec<bool>,
}
impl W {
fn put(&mut self, v: u64, n: u32) -> &mut Self {
for i in (0..n).rev() {
self.bits.push((v >> i) & 1 == 1);
}
self
}
fn bit(&mut self, b: bool) -> &mut Self {
self.bits.push(b);
self
}
/// An `UnsignedInteger`: bytes low first, each after a 1 bit, then a 0 bit.
fn uint(&mut self, mut v: u32) -> &mut Self {
while v != 0 {
self.bit(true).put(u64::from(v & 0xff), 8);
v >>= 8;
}
self.bit(false)
}
fn uints(&mut self, v: &[u32]) -> &mut Self {
self.uint(u32::try_from(v.len()).unwrap());
for &x in v {
self.uint(x);
}
self
}
/// A `Double` from the few the code table stores whole: 0, ±0.5, ±1, ±2.
fn double(&mut self, v: f64) -> &mut Self {
let negative = v < 0.0;
match v.abs() {
0.0 => self.put(0b01, 2),
0.5 => self.put(0x777, 11).bit(negative),
1.0 => self.put(0b0000, 4).bit(negative),
// The exponent row of [2, 4) with no mantissa bytes.
2.0 => self.put(0x1b, 5).bit(negative).bit(false),
_ => panic!("{v} has no short code here"),
}
}
fn bytes(&self) -> Vec<u8> {
self.bits.chunks(8).map(|c| c.iter().enumerate().fold(0u8, |a, (i, &b)| a | (u8::from(b) << (7 - i)))).collect()
}
}
/// A tessellation section of one `TESS_3D` (authoring version 8137) with explicit normals and
/// triangles of `(normal, point)` coordinate indices.
fn tess_section(points: &[[f64; 3]], normals: &[[f64; 3]], corners: &[(u32, u32)]) -> Vec<u8> {
let mut w = W::default();
w.uint(305).uint(0).bit(true).uint(1);
w.uint(172).bit(false).uint(u32::try_from(points.len() * 3).unwrap());
points.iter().flatten().for_each(|&c| _ = w.double(c));
// Faces present; no loop flag; normals not recalculated.
w.bit(true).bit(false).bit(false);
w.uint(u32::try_from(normals.len() * 3).unwrap());
normals.iter().flatten().for_each(|&c| _ = w.double(c));
w.uint(0).uints(&corners.iter().flat_map(|&(n, p)| [n, p]).collect::<Vec<_>>());
// One face of triangles with a normal per corner.
w.uint(1).uint(174).uint(0).uint(0).uint(0).uint(0x2).uint(0).uints(&[u32::try_from(corners.len() / 3).unwrap()]);
w.uint(0).bit(false);
w.uint(0).uint(0);
w.bytes()
}
/// A one-file-structure PRC stream whose tessellation section is `tess`, with no product tree.
fn prc_file(tess: &[u8]) -> Vec<u8> {
const HEADER_LEN: usize = 107;
let zlib = |data: &[u8]| {
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
e.write_all(data).unwrap();
e.finish().unwrap()
};
let u32s = |out: &mut Vec<u8>, vs: &[u32]| vs.iter().for_each(|v| out.extend_from_slice(&v.to_le_bytes()));
let mut globals = W::default();
globals.uint(0);
let sections: [&[u8]; 5] = [&globals.bytes(), b"", tess, b"", b""];
let mut body = b"PRC".to_vec();
u32s(&mut body, &[8137, 8137, 5, 6, 7, 8, 0, 0, 0, 0, 0]);
let mut offs = vec![HEADER_LEN];
for s in sections {
offs.push(HEADER_LEN + body.len());
body.extend(zlib(s));
}
let mf_start = HEADER_LEN + body.len();
body.extend(zlib(b""));
let mf_end = HEADER_LEN + body.len();
let mut out = b"PRC".to_vec();
u32s(&mut out, &[8137, 8137, 1, 2, 3, 4, 0, 0, 0, 0, 1]);
u32s(&mut out, &[5, 6, 7, 8, 0, 6]);
u32s(&mut out, &offs.iter().map(|&o| u32::try_from(o).unwrap()).collect::<Vec<_>>());
u32s(&mut out, &[u32::try_from(mf_start).unwrap(), u32::try_from(mf_end).unwrap(), 0]);
out.extend(body);
out
}
fn pixel(image: &Image, x: u32, y: u32) -> [u8; 4] {
let i = (y * image.width + x) as usize * 4;
image.rgba[i..i + 4].try_into().unwrap()
}
#[test]
fn a_synthetic_prc_renders_its_nearer_triangle_over_the_farther() {
// Listed first, a small triangle at z = 1 facing the viewer; behind it at z = -1, a larger
// one whose stored normal lies across the view, so the headlight shades it darkest.
let points = [[-1., -1., 1.], [1., -1., 1.], [0., 1., 1.], [-2., -2., -1.], [2., -2., -1.], [0., 2., -1.]];
let normals = [[0., 0., 1.], [1., 0., 0.]];
let corners = [(0, 0), (0, 3), (0, 6), (3, 9), (3, 12), (3, 15)];
let prc = PrcFile::parse(&prc_file(&tess_section(&points, &normals, &corners))).unwrap();
let meshes = assemble(&prc).unwrap();
assert_eq!(meshes.len(), 1);
assert_eq!(meshes[0].mesh.triangles.len(), 2);
let camera = Camera::fit_meshes(&meshes, [0.0, 0.0, -1.0], [0.0, 1.0, 0.0], false, 1.0).unwrap();
let image = render(&meshes, &camera, &RenderOptions { width: 40, height: 40, ..RenderOptions::default() }).unwrap();
assert_eq!(pixel(&image, 1, 1), [255, 255, 255, 255], "background corner");
assert_eq!(pixel(&image, 20, 20), [190, 192, 200, 255], "the nearer triangle, lit head on");
// Below the near triangle only the far one: 0.3 of the surface colour.
assert_eq!(pixel(&image, 20, 34), [57, 58, 60, 255], "the farther triangle, lit edge on");
}
#[test]
fn damaged_streams_are_errors_not_panics() {
let points = [[0., 0., 0.], [1., 0., 0.], [0., 1., 0.]];
let bytes = prc_file(&tess_section(&points, &[[0., 0., 1.]], &[(0, 0), (0, 3), (0, 6)]));
for end in 0..bytes.len() {
if let Ok(prc) = PrcFile::parse(&bytes[..end]) {
let _ = assemble(&prc);
}
}
}
+1
View File
@@ -45,6 +45,7 @@ pub const TABLE: &[(&str, Class)] = &[
("ops", Class::Layer(2)),
// L3 core services
("render", Class::Layer(3)),
("prc-render", Class::Layer(3)),
("text", Class::Layer(3)),
("annot", Class::Layer(3)),
("forms", Class::Layer(3)),