mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-06 18:52:36 +00:00
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:
13 files changed
+1688
No files matched your search
Generated
+9
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
Reference in new issue
Block a user