M12: pdfcraft-prc-render, a headless renderer for PRC models

New L3 crate over pdfcraft-prc: assemble places every product-tree
tessellation into world space with its colours (pure `place` does the
transform), Camera fits a view to the model, and render fills
depth-buffered triangles with headlight or unlit shading into an RGBA
image. Opaque triangles draw first, translucent ones blend over them;
perspective views clip at a near plane.

Never panics: MAX_RENDER_PIXELS and MAX_ASSEMBLED_TRIANGLES cap the
work, every pixel and vertex index is checked, and damage returns
RenderError. Single-threaded.

Tests: 24 unit tests (two drive the product-tree placement path) plus an end-to-end one that builds a synthetic
PRC stream in-test, renders it, and checks the background, the nearer
triangle and the farther one by pixel; every truncation of that stream
parses and assembles without panicking. Reverting the depth test or the
placement transform fails the matching tests.

Adapted from pdfcer (MIT, same author) at 912437c3, trimmed to
the camera, raster and headlight parts and rewritten to the
pdfcraft-prc types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
This commit is contained in:
KenM76andClaude Opus 5.5 committed 2026-11-03 20:50:36 -05:00
1 parent 58fca10da9
commit ea6d0c6bfb
13 files changed
+1347

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.0"
dependencies = [
"flate2",
"pdfcraft-prc",
"thiserror 2.0.21",
]
[[package]]
name = "pdfcraft-preflight"
version = "0.18.0"
+2
View File
@@ -59,6 +59,8 @@ pdfcraft-create = { path = "crates/create", version = "0.18.0" }
pdfcraft-forms = { path = "crates/forms", version = "0.18.0" }
pdfcraft-xfa = { path = "crates/xfa", version = "0.18.0" }
pdfcraft-render = { path = "crates/render", version = "0.18.0" }
pdfcraft-prc = { path = "crates/prc", version = "0.18.0" }
pdfcraft-prc-render = { path = "crates/prc-render", version = "0.18.0" }
pdfcraft-engine = { path = "crates/engine", version = "0.18.0" }
pdfcraft-platform = { path = "crates/platform", version = "0.18.0" }
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.18.0" }
+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
+28
View File
@@ -0,0 +1,28 @@
# 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; 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` (2^24) bounds one `assemble` call. Neither bounds time: a model of many large
triangles at full size is a long CPU render, 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.
+126
View File
@@ -0,0 +1,126 @@
//! 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;
/// 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;
/// [`RenderError::TooManyTriangles`] past [`MAX_ASSEMBLED_TRIANGLES`].
pub fn assemble(prc: &PrcFile) -> Result<Vec<WorldMesh>, RenderError> {
let tessellations = prc.file_structures.iter().map(|fs| fs.tessellations()).collect::<Result<Vec<_>, _>>()?;
let mut budget = Budget(0);
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)
}
/// 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 no
/// triangle mesh are skipped.
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(mesh) = tessellations.get(p.file_structure).and_then(|t| t.get(p.tessellation)).and_then(as_mesh) 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 placed so far.
pub(crate) struct Budget(pub(crate) usize);
impl Budget {
fn spend(&mut self, mesh: &TriangleMesh) -> Result<(), RenderError> {
self.0 = self.0.checked_add(mesh.triangles.len()).filter(|&n| n <= MAX_ASSEMBLED_TRIANGLES).ok_or(RenderError::TooManyTriangles)?;
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;
+88
View File
@@ -0,0 +1,88 @@
#![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(MAX_ASSEMBLED_TRIANGLES - 1);
assert!(b.spend(&triangle()).is_ok());
assert_eq!(b.spend(&triangle()), Err(RenderError::TooManyTriangles));
let mut b = Budget(usize::MAX);
assert_eq!(b.spend(&triangle()), Err(RenderError::TooManyTriangles));
}
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; one naming nothing.
let items = [item(0, 0, 3.0, Some(red)), item(0, 0, -4.0, None), item(1, 0, 0.0, None), item(0, 7, 0.0, None)];
let (mut budget, mut out) = (Budget(0), Vec::new());
place_items(&tessellations, items, &mut budget, &mut out).unwrap();
assert_eq!(out.len(), 2, "items naming no mesh are skipped");
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.0, 2, "each occurrence spends its triangles");
}
#[test]
fn a_placement_past_the_triangle_budget_is_an_error() {
let tessellations = vec![vec![Tessellation::Mesh(triangle())]];
let (mut budget, mut out) = (Budget(MAX_ASSEMBLED_TRIANGLES), 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());
}
+17
View File
@@ -0,0 +1,17 @@
//! 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`] and the assembled model by
//! [`MAX_ASSEMBLED_TRIANGLES`]; 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, WorldMesh, assemble, place};
pub use render::{Bounds, Camera, Image, Lighting, MAX_RENDER_PIXELS, Projection, RenderError, RenderOptions, render};
+226
View File
@@ -0,0 +1,226 @@
//! 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],
}
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 distance = length(sub(camera.target, camera.eye));
Ok(View::Perspective { focal: 1.0 / (fov_y.to_radians() / 2.0).tan(), aspect, near: (distance * 1e-4).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 {
let t = (near - z(p)) / (z(q) - z(p));
let mut cut = p;
for (c, d) in cut.iter_mut().zip(q) {
*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 {
View::Perspective { focal, aspect, .. } => (x * focal / (z * aspect), y * focal / z, 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] }
}
}
/// 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> {
pub(crate) width: u32,
pub(crate) height: u32,
pub(crate) rgba: &'b mut [u8],
pub(crate) depth: &'b mut [f64],
}
impl Target<'_> {
/// Fill the screen triangle of `corners` with straight-RGBA `colour`, depth-tested at pixel
/// centres, the shade interpolated across it. An opaque pixel replaces the pixel and its
/// depth; a translucent one is blended over it, leaving the depth.
pub(crate) fn fill(&mut self, corners: [Corner; 3], colour: [u8; 4]) {
let [a, b, c] = corners;
let area = edge(a.at, b.at, c.at);
if !area.is_finite() || area.abs() < 1e-12 {
return;
}
let Some((x0, x1, y0, y1)) = self.bounds(&corners) else {
return;
};
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 = [edge(b.at, c.at, p), edge(c.at, a.at, p), edge(a.at, b.at, p)].map(|e| e / area);
if w.iter().any(|&k| k < 0.0) {
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 { [0, 1, 2].map(|j| mix(&|c| at(c.shade, j))) };
self.blend(i, key, shaded(colour, shade));
}
}
}
/// 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))
}
/// 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);
}
}
+365
View File
@@ -0,0 +1,365 @@
//! 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, sub};
use pdfcraft_prc::TriangleMesh;
/// The largest image [`render`] draws, in pixels (width × height).
pub const MAX_RENDER_PIXELS: u64 = 16 * 1024 * 1024;
/// [`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' 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.
#[must_use]
pub fn centre(&self) -> [f64; 3] {
[0, 1, 2].map(|i| (at(self.min, i) + 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 finite vertex the meshes' triangles use.
fn vertices(meshes: &[WorldMesh]) -> impl Iterator<Item = [f64; 3]> + Clone + '_ {
meshes.iter().flat_map(|m| {
let mesh = &m.mesh;
mesh.triangles
.iter()
.flatten()
.filter_map(|&i| mesh.positions.get(usize::try_from(i).ok()?).copied())
.filter(|p| p.iter().all(|c| c.is_finite()))
})
}
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' 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] =
Camera { eye: sub(centre, dir), target: centre, up, projection: Projection::Orthographic { height: 1.0 } }.basis()?;
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"))?;
let right = normalize(cross(forward, self.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,
}
/// 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 what lies behind it.
///
/// # 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°, a non-positive orthographic height).
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 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 { width, height, rgba: &mut rgba, depth: &mut depth };
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`.
fn draw(&self, target: &mut Target, mesh: &TriangleMesh, t: usize, colour: [u8; 4]) {
let Some(world) = mesh.triangles.get(t).and_then(|tri| corners(mesh, tri)) else {
return;
};
let Some(face) = normalize(cross(sub(at3(&world, 1), at3(&world, 0)), sub(at3(&world, 2), at3(&world, 0)))) else {
return;
};
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);
}
}
}
}
}
/// 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;
+269
View File
@@ -0,0 +1,269 @@
#![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) },
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]));
assert_eq!(Bounds::of(&[m]).unwrap().max, [0.0, 1.0, 0.0]);
}
#[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]);
}
+53
View File
@@ -0,0 +1,53 @@
//! 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]
}
/// `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`.
pub(crate) fn length(a: Vec3) -> f64 {
dot(a, a).sqrt()
}
/// `a` scaled to unit length; `None` when its length is zero or not finite.
pub(crate) fn normalize(a: Vec3) -> Option<Vec3> {
let l = length(a);
(l.is_finite() && l > 0.0).then(|| scale(a, 1.0 / l))
}
#[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);
}
}
+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)),