From ea6d0c6bfbba5ca8985c6880791c8c46cbabd8ba Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 3 Nov 2026 20:46:33 -0500 Subject: [PATCH 1/6] 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 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- Cargo.lock | 9 + Cargo.toml | 2 + crates/prc-render/Cargo.toml | 18 ++ crates/prc-render/README.md | 28 ++ crates/prc-render/src/assemble.rs | 126 ++++++++ crates/prc-render/src/assemble/tests.rs | 88 ++++++ crates/prc-render/src/lib.rs | 17 ++ crates/prc-render/src/raster.rs | 226 +++++++++++++++ crates/prc-render/src/render.rs | 365 ++++++++++++++++++++++++ crates/prc-render/src/render/tests.rs | 269 +++++++++++++++++ crates/prc-render/src/vec3.rs | 53 ++++ crates/prc-render/tests/model.rs | 145 ++++++++++ xtask/src/layers.rs | 1 + 13 files changed, 1347 insertions(+) create mode 100644 crates/prc-render/Cargo.toml create mode 100644 crates/prc-render/README.md create mode 100644 crates/prc-render/src/assemble.rs create mode 100644 crates/prc-render/src/assemble/tests.rs create mode 100644 crates/prc-render/src/lib.rs create mode 100644 crates/prc-render/src/raster.rs create mode 100644 crates/prc-render/src/render.rs create mode 100644 crates/prc-render/src/render/tests.rs create mode 100644 crates/prc-render/src/vec3.rs create mode 100644 crates/prc-render/tests/model.rs diff --git a/Cargo.lock b/Cargo.lock index 6e92f0fcf0..bc76adfde5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 4e1dbffaea..1515269906 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/crates/prc-render/Cargo.toml b/crates/prc-render/Cargo.toml new file mode 100644 index 0000000000..11ccd3346f --- /dev/null +++ b/crates/prc-render/Cargo.toml @@ -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 diff --git a/crates/prc-render/README.md b/crates/prc-render/README.md new file mode 100644 index 0000000000..43730040f2 --- /dev/null +++ b/crates/prc-render/README.md @@ -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, 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 +``` + +- 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. diff --git a/crates/prc-render/src/assemble.rs b/crates/prc-render/src/assemble.rs new file mode 100644 index 0000000000..3f8fa54caf --- /dev/null +++ b/crates/prc-render/src/assemble.rs @@ -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>>, +} + +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>>) -> 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, RenderError> { + let tessellations = prc.file_structures.iter().map(|fs| fs.tessellations()).collect::, _>>()?; + 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, + pub(crate) triangle_colours: Box>, +} + +/// Per triangle of a mesh, the colour its face's style resolves to. +type FaceColours<'a> = dyn Fn(&TriangleMesh) -> Option>> + '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], + items: impl IntoIterator>, + budget: &mut Budget, + out: &mut Vec, +) -> 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; diff --git a/crates/prc-render/src/assemble/tests.rs b/crates/prc-render/src/assemble/tests.rs new file mode 100644 index 0000000000..87c133f4da --- /dev/null +++ b/crates/prc-render/src/assemble/tests.rs @@ -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) -> 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()); +} diff --git a/crates/prc-render/src/lib.rs b/crates/prc-render/src/lib.rs new file mode 100644 index 0000000000..da0ed41230 --- /dev/null +++ b/crates/prc-render/src/lib.rs @@ -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}; diff --git a/crates/prc-render/src/raster.rs b/crates/prc-render/src/raster.rs new file mode 100644 index 0000000000..dddb3b394d --- /dev/null +++ b/crates/prc-render/src/raster.rs @@ -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 { + 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 { + 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::(); + 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::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); + } +} diff --git a/crates/prc-render/src/render.rs b/crates/prc-render/src/render.rs new file mode 100644 index 0000000000..146266434b --- /dev/null +++ b/crates/prc-render/src/render.rs @@ -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 { + let mut out: Option = 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 + 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 { + 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 { + 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 + Clone, + direction: [f64; 3], + up: [f64; 3], + (perspective, aspect): (bool, f64), + ) -> Result { + 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, +} + +/// 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 { + 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 = 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; diff --git a/crates/prc-render/src/render/tests.rs b/crates/prc-render/src/render/tests.rs new file mode 100644 index 0000000000..008f177491 --- /dev/null +++ b/crates/prc-render/src/render/tests.rs @@ -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 = 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]); +} diff --git a/crates/prc-render/src/vec3.rs b/crates/prc-render/src/vec3.rs new file mode 100644 index 0000000000..90af0f3ba2 --- /dev/null +++ b/crates/prc-render/src/vec3.rs @@ -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 { + 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); + } +} diff --git a/crates/prc-render/tests/model.rs b/crates/prc-render/tests/model.rs new file mode 100644 index 0000000000..80e84252c6 --- /dev/null +++ b/crates/prc-render/tests/model.rs @@ -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, +} + +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 { + 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 { + 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::>()); + // 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 { + 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, 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::>()); + 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); + } + } +} diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 4f59c1dc94..923b7121ab 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -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)), From ce393b276be374a8cb27fbc0f6ce574862c3b4a1 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 3 Nov 2026 21:41:05 -0500 Subject: [PATCH 2/6] M12: prc-render review fixes (missing tessellations, vertex ceiling, seams, perspective shade) - assemble: a placement naming a tessellation its file structure does not have is now PrcError::Malformed instead of a silently missing part; wires, markups and compressed tessellations are still skipped. - assemble: positions, normals and texture coordinates are charged against a new MAX_ASSEMBLED_VERTICES (2^24) with RenderError::TooManyVertices, so a one-triangle mesh with many vertices placed many times cannot outgrow the triangle ceiling's memory bound. A refused mesh spends nothing. - raster: a field of view too small for a finite focal length is a Camera error, not a background-only image. - raster: a pixel centre exactly on an edge belongs to one triangle only (the side the triangle lies on, +x first, then +y), so translucent quads no longer blend their diagonal twice. - raster: under a perspective camera the shade is interpolated with the 1/z depth key, so it follows the surface rather than the screen. - vec3: normalize divides by the largest component first, so a cross product above ~1e154 (or a subnormal one) still normalizes. - render docs and README state the near plane: 1/10,000 of the eye-to-target distance. Each new test fails with its fix reverted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/prc-render/README.md | 4 +- crates/prc-render/src/assemble.rs | 38 ++++++++++---- crates/prc-render/src/assemble/tests.rs | 42 ++++++++++++--- crates/prc-render/src/lib.rs | 4 +- crates/prc-render/src/raster.rs | 68 ++++++++++++++++++++++--- crates/prc-render/src/render.rs | 13 +++-- crates/prc-render/src/render/tests.rs | 1 + crates/prc-render/src/vec3.rs | 15 +++++- 8 files changed, 154 insertions(+), 31 deletions(-) diff --git a/crates/prc-render/README.md b/crates/prc-render/README.md index 43730040f2..c3d4db867e 100644 --- a/crates/prc-render/README.md +++ b/crates/prc-render/README.md @@ -21,8 +21,8 @@ render(&[WorldMesh], &Camera, &RenderOptions) -> Result - 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. +- 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` (2^24) bounds one `assemble` call. Neither bounds time: a model of many large + `MAX_ASSEMBLED_TRIANGLES` and `MAX_ASSEMBLED_VERTICES` (2^24 each) bound one `assemble` call. None 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. diff --git a/crates/prc-render/src/assemble.rs b/crates/prc-render/src/assemble.rs index 3f8fa54caf..7fbb9bd914 100644 --- a/crates/prc-render/src/assemble.rs +++ b/crates/prc-render/src/assemble.rs @@ -8,6 +8,9 @@ use pdfcraft_prc::{Matrix, PrcFile, Rgba, Tessellation, TriangleMesh}; /// 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 { @@ -44,11 +47,12 @@ pub fn place(mesh: &TriangleMesh, matrix: &Matrix, colour: Option<[u8; 4]>, tria /// 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`]. +/// [`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`]. pub fn assemble(prc: &PrcFile) -> Result, RenderError> { let tessellations = prc.file_structures.iter().map(|fs| fs.tessellations()).collect::, _>>()?; - let mut budget = Budget(0); + let mut budget = Budget::default(); let mut out = Vec::new(); if !prc.stores_tree() { for mesh in tessellations.iter().flatten().filter_map(as_mesh) { @@ -81,8 +85,9 @@ pub(crate) struct Item<'a> { /// Per triangle of a mesh, the colour its face's style resolves to. type FaceColours<'a> = dyn Fn(&TriangleMesh) -> Option>> + 'a; -/// Each item's mesh moved into the world and coloured, appended to `out`. Items naming no -/// triangle mesh are skipped. +/// 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], items: impl IntoIterator>, @@ -90,7 +95,11 @@ pub(crate) fn place_items<'a>( out: &mut Vec, ) -> 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 { + 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)?; @@ -100,12 +109,23 @@ pub(crate) fn place_items<'a>( Ok(()) } -/// Triangles placed so far. -pub(crate) struct Budget(pub(crate) usize); +/// 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> { - self.0 = self.0.checked_add(mesh.triangles.len()).filter(|&n| n <= MAX_ASSEMBLED_TRIANGLES).ok_or(RenderError::TooManyTriangles)?; + 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(()) } } diff --git a/crates/prc-render/src/assemble/tests.rs b/crates/prc-render/src/assemble/tests.rs index 87c133f4da..6947e78033 100644 --- a/crates/prc-render/src/assemble/tests.rs +++ b/crates/prc-render/src/assemble/tests.rs @@ -47,13 +47,29 @@ fn colours_are_clamped_into_bytes() { #[test] fn the_triangle_budget_is_shared_and_refuses_past_the_ceiling() { - let mut b = Budget(MAX_ASSEMBLED_TRIANGLES - 1); + 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(usize::MAX); + 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) -> Item<'a> { let mut matrix = Matrix::IDENTITY; matrix.0[0][3] = x; @@ -66,22 +82,32 @@ fn each_placement_draws_its_tessellation_where_and_in_the_colour_the_tree_says() 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()); + // 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, "items naming no mesh are skipped"); + 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.0, 2, "each occurrence spends its triangles"); + 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(MAX_ASSEMBLED_TRIANGLES), Vec::new()); + 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()); diff --git a/crates/prc-render/src/lib.rs b/crates/prc-render/src/lib.rs index da0ed41230..4044f110bb 100644 --- a/crates/prc-render/src/lib.rs +++ b/crates/prc-render/src/lib.rs @@ -4,7 +4,7 @@ //! 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. +//! [`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)] @@ -13,5 +13,5 @@ mod raster; mod render; mod vec3; -pub use assemble::{MAX_ASSEMBLED_TRIANGLES, WorldMesh, assemble, place}; +pub use assemble::{MAX_ASSEMBLED_TRIANGLES, MAX_ASSEMBLED_VERTICES, WorldMesh, assemble, place}; pub use render::{Bounds, Camera, Image, Lighting, MAX_RENDER_PIXELS, Projection, RenderError, RenderOptions, render}; diff --git a/crates/prc-render/src/raster.rs b/crates/prc-render/src/raster.rs index dddb3b394d..cced24db90 100644 --- a/crates/prc-render/src/raster.rs +++ b/crates/prc-render/src/raster.rs @@ -13,6 +13,9 @@ pub(crate) struct Corner { 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 { @@ -35,8 +38,13 @@ impl View { 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: 1.0 / (fov_y.to_radians() / 2.0).tan(), aspect, near: (distance * 1e-4).max(f64::MIN_POSITIVE) }) + Ok(View::Perspective { focal, aspect, near: (distance * NEAR_FRACTION).max(f64::MIN_POSITIVE) }) } Projection::Orthographic { height } => { if !(height.is_finite() && height > 0.0) { @@ -87,6 +95,8 @@ impl View { /// 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], @@ -95,11 +105,13 @@ pub(crate) struct Target<'b> { 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. + /// 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. 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); + let edges = [(b.at, c.at), (c.at, a.at), (a.at, b.at)]; if !area.is_finite() || area.abs() < 1e-12 { return; } @@ -109,8 +121,8 @@ impl Target<'_> { 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) { + 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::(); @@ -121,7 +133,14 @@ impl Target<'_> { 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))) }; + 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)); } } @@ -193,6 +212,15 @@ pub(crate) fn edge(a: [f64; 3], b: [f64; 3], p: [f64; 3]) -> f64 { (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) @@ -223,4 +251,32 @@ mod tests { assert_eq!(channel(-4.0), 0); assert_eq!(channel(127.5), 128); } + + 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 }; + 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]); + t.fill([a, c, d], [255, 0, 0, 128]); + // 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 }; + // 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]); + // 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][..])); + } } diff --git a/crates/prc-render/src/render.rs b/crates/prc-render/src/render.rs index 146266434b..d9072a3055 100644 --- a/crates/prc-render/src/render.rs +++ b/crates/prc-render/src/render.rs @@ -242,6 +242,10 @@ pub enum RenderError { /// 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, } /// Draw `meshes` as seen by `camera`. @@ -251,12 +255,14 @@ pub enum RenderError { /// 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. +/// 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°, a non-positive orthographic height). +/// 0–180° or too small for a finite focal length, a non-positive orthographic height). pub fn render(meshes: &[WorldMesh], camera: &Camera, options: &RenderOptions) -> Result { let (width, height) = (options.width, options.height); let size = RenderError::Size { width, height }; @@ -266,10 +272,11 @@ pub fn render(meshes: &[WorldMesh], camera: &Camera, options: &RenderOptions) -> } 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 { width, height, rgba: &mut rgba, depth: &mut depth }; + let mut target = Target { perspective, width, height, rgba: &mut rgba, depth: &mut depth }; let [r, g, b] = options.colour; for opaque in [true, false] { for m in meshes { diff --git a/crates/prc-render/src/render/tests.rs b/crates/prc-render/src/render/tests.rs index 008f177491..8d0764315d 100644 --- a/crates/prc-render/src/render/tests.rs +++ b/crates/prc-render/src/render/tests.rs @@ -236,6 +236,7 @@ fn bad_sizes_and_cameras_are_refused() { 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), ]; diff --git a/crates/prc-render/src/vec3.rs b/crates/prc-render/src/vec3.rs index 90af0f3ba2..8ebf844087 100644 --- a/crates/prc-render/src/vec3.rs +++ b/crates/prc-render/src/vec3.rs @@ -28,8 +28,14 @@ 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. +/// `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 { + let big = a.iter().fold(0.0_f64, |m, c| m.max(c.abs())); + if !(big.is_finite() && big > 0.0) { + return None; + } + let a = a.map(|c| c / big); let l = length(a); (l.is_finite() && l > 0.0).then(|| scale(a, 1.0 / l)) } @@ -49,5 +55,12 @@ mod tests { 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])); } } From ca21d4fb7bb763e3dc49d6d9b8f05b5de7a8f8e7 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 3 Nov 2026 23:06:54 -0500 Subject: [PATCH 3/6] M12: prc-render: scale a triangle's edges before their cross product normalize already scales by the largest component, but the face normal was the normalized cross product of two edges, and edges near 1e160 overflow that product to infinity first, so the triangle was dropped. unit_cross divides both edges by their largest component before the cross product. a_huge_square_is_drawn fails with the old line (white instead of [190,192,200]). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/prc-render/src/render.rs | 4 ++-- crates/prc-render/src/render/tests.rs | 8 ++++++++ crates/prc-render/src/vec3.rs | 19 +++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/prc-render/src/render.rs b/crates/prc-render/src/render.rs index d9072a3055..3670878e17 100644 --- a/crates/prc-render/src/render.rs +++ b/crates/prc-render/src/render.rs @@ -5,7 +5,7 @@ use crate::WorldMesh; use crate::raster::{Corner, Target, View, ViewCorner, at}; -use crate::vec3::{cross, dot, length, normalize, scale, sub}; +use crate::vec3::{cross, dot, length, normalize, scale, sub, unit_cross}; use pdfcraft_prc::TriangleMesh; /// The largest image [`render`] draws, in pixels (width × height). @@ -318,7 +318,7 @@ impl Scene { 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 { + let Some(face) = unit_cross(sub(at3(&world, 1), at3(&world, 0)), sub(at3(&world, 2), at3(&world, 0))) else { return; }; let stored = stored_normals(mesh, t); diff --git a/crates/prc-render/src/render/tests.rs b/crates/prc-render/src/render/tests.rs index 8d0764315d..ec8104a260 100644 --- a/crates/prc-render/src/render/tests.rs +++ b/crates/prc-render/src/render/tests.rs @@ -268,3 +268,11 @@ fn unlit_draws_the_plain_colour_and_the_headlight_shades_by_angle() { 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]); +} diff --git a/crates/prc-render/src/vec3.rs b/crates/prc-render/src/vec3.rs index 8ebf844087..7a673e0055 100644 --- a/crates/prc-render/src/vec3.rs +++ b/crates/prc-render/src/vec3.rs @@ -40,6 +40,17 @@ pub(crate) fn normalize(a: Vec3) -> Option { (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. Both are first divided by their largest component, so the cross +/// product of two huge edges cannot overflow before it is normalized. +pub(crate) fn unit_cross(a: Vec3, b: Vec3) -> Option { + let big = a.iter().chain(b.iter()).fold(0.0_f64, |m, c| m.max(c.abs())); + if !(big.is_finite() && big > 0.0) { + return None; + } + normalize(cross(a.map(|c| c / big), b.map(|c| c / big))) +} + #[cfg(test)] mod tests { use super::*; @@ -63,4 +74,12 @@ mod tests { 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); + } } From c336c59cfb69342f2e12ca4cb2e489b0b3d88142 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Wed, 4 Nov 2026 00:03:28 -0500 Subject: [PATCH 4/6] M12: prc-render: scale-safe lengths, a render work ceiling, fit fixes - vec3: length divides by the largest component before squaring, so a camera 1e200 away keeps a finite near plane; unit_cross normalizes each edge first, so edges of very different sizes keep their normal. - render: MAX_RENDER_WORK (2^31) caps the pixel centres one render tests, charged per triangle's clipped bounding box before scanning; past it, RenderError::TooMuchWork. - Bounds::centre halves each end before adding, finite for any finite box. Bounds::of and fit_meshes use only corners of triangles render draws. Camera::frame builds its basis from the direction, not from points near the model, so a small model far from the origin fits. Each new test fails with its fix reverted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/prc-render/README.md | 5 ++- crates/prc-render/src/lib.rs | 7 ++-- crates/prc-render/src/raster.rs | 37 ++++++++++++++----- crates/prc-render/src/render.rs | 53 +++++++++++++++------------ crates/prc-render/src/render/tests.rs | 34 ++++++++++++++++- crates/prc-render/src/vec3.rs | 44 ++++++++++++++++------ 6 files changed, 131 insertions(+), 49 deletions(-) diff --git a/crates/prc-render/README.md b/crates/prc-render/README.md index c3d4db867e..000d673179 100644 --- a/crates/prc-render/README.md +++ b/crates/prc-render/README.md @@ -23,6 +23,7 @@ render(&[WorldMesh], &Camera, &RenderOptions) -> Result - `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. None 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. + `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. diff --git a/crates/prc-render/src/lib.rs b/crates/prc-render/src/lib.rs index 4044f110bb..9cfd49efbe 100644 --- a/crates/prc-render/src/lib.rs +++ b/crates/prc-render/src/lib.rs @@ -3,8 +3,9 @@ //! 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`] and [`MAX_ASSEMBLED_VERTICES`]; bad input is an error, never a panic. +//! 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)] @@ -14,4 +15,4 @@ 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, Projection, RenderError, RenderOptions, render}; +pub use render::{Bounds, Camera, Image, Lighting, MAX_RENDER_PIXELS, MAX_RENDER_WORK, Projection, RenderError, RenderOptions, render}; diff --git a/crates/prc-render/src/raster.rs b/crates/prc-render/src/raster.rs index cced24db90..e30105352c 100644 --- a/crates/prc-render/src/raster.rs +++ b/crates/prc-render/src/raster.rs @@ -101,23 +101,29 @@ pub(crate) struct Target<'b> { 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. - pub(crate) fn fill(&mut self, corners: [Corner; 3], colour: [u8; 4]) { + /// 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; + return Ok(()); } let Some((x0, x1, y0, y1)) = self.bounds(&corners) else { - return; + 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]; @@ -144,6 +150,7 @@ impl Target<'_> { self.blend(i, key, shaded(colour, shade)); } } + Ok(()) } /// The pixel rows and columns `corners` may cover, clipped to the image; `None` when off it. @@ -259,10 +266,10 @@ mod tests { #[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 }; + 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]); - t.fill([a, c, d], [255, 0, 0, 128]); + 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]); @@ -272,11 +279,23 @@ mod tests { #[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 }; + 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]); + 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); + } } diff --git a/crates/prc-render/src/render.rs b/crates/prc-render/src/render.rs index 3670878e17..9d7da84ecd 100644 --- a/crates/prc-render/src/render.rs +++ b/crates/prc-render/src/render.rs @@ -11,6 +11,10 @@ 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; @@ -66,10 +70,10 @@ impl Bounds { out } - /// The box's centre. + /// 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) + at(self.max, i)) / 2.0) + [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. @@ -79,16 +83,9 @@ impl Bounds { } } -/// Every finite vertex the meshes' triangles use. +/// Every corner of the triangles [`render`] draws: those whose three corners exist and are finite. fn vertices(meshes: &[WorldMesh]) -> impl Iterator + 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())) - }) + meshes.iter().flat_map(|m| m.mesh.triangles.iter().filter_map(|tri| corners(&m.mesh, tri)).flatten()) } impl Camera { @@ -130,8 +127,7 @@ impl Camera { 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 [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)] @@ -171,11 +167,16 @@ impl 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]) + 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] @@ -246,6 +247,9 @@ pub enum RenderError { /// 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`. @@ -262,7 +266,8 @@ pub enum RenderError { /// # 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). +/// 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 { let (width, height) = (options.width, options.height); let size = RenderError::Size { width, height }; @@ -276,14 +281,14 @@ pub fn render(meshes: &[WorldMesh], camera: &Camera, options: &RenderOptions) -> 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 }; + 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); + scene.draw(&mut target, &m.mesh, t, colour)?; } } } @@ -313,13 +318,14 @@ impl Scene { [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]) { + /// 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; + return Ok(()); }; let Some(face) = unit_cross(sub(at3(&world, 1), at3(&world, 0)), sub(at3(&world, 2), at3(&world, 0))) else { - return; + return Ok(()); }; let stored = stored_normals(mesh, t); let normal = |k: usize| stored.and_then(|n| n.get(k).copied().flatten()).unwrap_or(face); @@ -336,10 +342,11 @@ impl Scene { 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); + target.fill([a, *b, *c], colour)?; } } } + Ok(()) } } diff --git a/crates/prc-render/src/render/tests.rs b/crates/prc-render/src/render/tests.rs index ec8104a260..24a2d6b936 100644 --- a/crates/prc-render/src/render/tests.rs +++ b/crates/prc-render/src/render/tests.rs @@ -254,7 +254,8 @@ 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]); + // No triangle can be drawn, so none frames the model. + assert_eq!(Bounds::of(&[m]), None); } #[test] @@ -276,3 +277,34 @@ fn a_huge_square_is_drawn() { 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_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:?}"); +} diff --git a/crates/prc-render/src/vec3.rs b/crates/prc-render/src/vec3.rs index 7a673e0055..3bb438438a 100644 --- a/crates/prc-render/src/vec3.rs +++ b/crates/prc-render/src/vec3.rs @@ -23,32 +23,39 @@ 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`. +/// 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 { - dot(a, a).sqrt() + 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 { - let big = a.iter().fold(0.0_f64, |m, c| m.max(c.abs())); + let big = largest(a); if !(big.is_finite() && big > 0.0) { return None; } let a = a.map(|c| c / big); - let l = length(a); + 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. Both are first divided by their largest component, so the cross -/// product of two huge edges cannot overflow before it is normalized. +/// 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 { - let big = a.iter().chain(b.iter()).fold(0.0_f64, |m, c| m.max(c.abs())); - if !(big.is_finite() && big > 0.0) { - return None; - } - normalize(cross(a.map(|c| c / big), b.map(|c| c / big))) + normalize(cross(normalize(a)?, normalize(b)?)) } #[cfg(test)] @@ -82,4 +89,19 @@ mod tests { 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 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); + } } From 51b68a8b356a30588eb1e3b733d3f3c7d8e0880c Mon Sep 17 00:00:00 2001 From: KenM76 Date: Wed, 4 Nov 2026 05:12:00 -0500 Subject: [PATCH 5/6] M12: prc-render: overflow-free projection and edges, shared near cuts, decode ceiling - View::project divides by z (or the orthographic half-height) before it multiplies, so an on-screen corner no longer turns NaN when x times the focal length, or the half-height times the aspect ratio, overflows. - View::clip cuts each edge from the same end whichever triangle meets it, so two triangles sharing an edge across the near plane get one cut point. - Scene::draw takes each edge with vec3::span, which halves the difference when it overflows, so a finite triangle wider than f64::MAX keeps its face normal. - assemble charges each file structure's meshes to the assembly ceilings as they are decoded, so decoding stops at the structure that passes one. Tests, each shown failing with its fix reverted: a_shared_edge_is_cut_at_the_same_point_from_either_triangle, an_on_screen_triangle_is_drawn_when_projecting_it_would_overflow, a_triangle_whose_edges_overflow_is_drawn, span_keeps_the_direction_of_a_difference_that_overflows, decoding_stops_at_the_structure_that_passes_the_ceiling. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/prc-render/src/assemble.rs | 22 ++++++++++++++++-- crates/prc-render/src/assemble/tests.rs | 13 +++++++++++ crates/prc-render/src/raster.rs | 30 ++++++++++++++++++++----- crates/prc-render/src/render.rs | 4 ++-- crates/prc-render/src/render/tests.rs | 21 +++++++++++++++++ crates/prc-render/src/vec3.rs | 12 ++++++++++ 6 files changed, 93 insertions(+), 9 deletions(-) diff --git a/crates/prc-render/src/assemble.rs b/crates/prc-render/src/assemble.rs index 7fbb9bd914..c9264d620e 100644 --- a/crates/prc-render/src/assemble.rs +++ b/crates/prc-render/src/assemble.rs @@ -49,9 +49,10 @@ pub fn place(mesh: &TriangleMesh, matrix: &Matrix, colour: Option<[u8; 4]>, tria /// # 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`]. +/// [`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, RenderError> { - let tessellations = prc.file_structures.iter().map(|fs| fs.tessellations()).collect::, _>>()?; + 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() { @@ -72,6 +73,23 @@ pub fn assemble(prc: &PrcFile) -> Result, RenderError> { 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, pdfcraft_prc::PrcError>>, + mut budget: Budget, +) -> Result>, 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> { diff --git a/crates/prc-render/src/assemble/tests.rs b/crates/prc-render/src/assemble/tests.rs index 6947e78033..5018bdbe8c 100644 --- a/crates/prc-render/src/assemble/tests.rs +++ b/crates/prc-render/src/assemble/tests.rs @@ -112,3 +112,16 @@ fn a_placement_past_the_triangle_budget_is_an_error() { 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"); +} diff --git a/crates/prc-render/src/raster.rs b/crates/prc-render/src/raster.rs index e30105352c..4427df5973 100644 --- a/crates/prc-render/src/raster.rs +++ b/crates/prc-render/src/raster.rs @@ -70,9 +70,12 @@ impl View { 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) { + // 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); @@ -85,13 +88,19 @@ impl View { /// 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), + // 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> { @@ -259,6 +268,17 @@ mod tests { 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] } } diff --git a/crates/prc-render/src/render.rs b/crates/prc-render/src/render.rs index 9d7da84ecd..a27cfbe5fa 100644 --- a/crates/prc-render/src/render.rs +++ b/crates/prc-render/src/render.rs @@ -5,7 +5,7 @@ use crate::WorldMesh; use crate::raster::{Corner, Target, View, ViewCorner, at}; -use crate::vec3::{cross, dot, length, normalize, scale, sub, unit_cross}; +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). @@ -324,7 +324,7 @@ impl Scene { let Some(world) = mesh.triangles.get(t).and_then(|tri| corners(mesh, tri)) else { return Ok(()); }; - let Some(face) = unit_cross(sub(at3(&world, 1), at3(&world, 0)), sub(at3(&world, 2), at3(&world, 0))) else { + 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); diff --git a/crates/prc-render/src/render/tests.rs b/crates/prc-render/src/render/tests.rs index 24a2d6b936..df089bfea5 100644 --- a/crates/prc-render/src/render/tests.rs +++ b/crates/prc-render/src/render/tests.rs @@ -308,3 +308,24 @@ fn a_small_model_far_from_the_origin_can_be_fitted() { 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]); +} diff --git a/crates/prc-render/src/vec3.rs b/crates/prc-render/src/vec3.rs index 3bb438438a..bdbed70aed 100644 --- a/crates/prc-render/src/vec3.rs +++ b/crates/prc-render/src/vec3.rs @@ -8,6 +8,12 @@ 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) @@ -95,6 +101,12 @@ mod tests { 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; From c2d8c5f32782d716ac944572190b650a336a9d07 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Wed, 4 Nov 2026 06:53:36 -0500 Subject: [PATCH 6/6] M12: prc-render: fully transparent triangles are not framed Review fix from #6087. Bounds::of and Camera::fit_meshes framed every finite triangle, but render skips those whose colour has zero alpha, so a clear, distant mesh could shrink the visible model in the image. The vertices they frame now skip fully transparent triangles, as render does. Test, shown failing with the fix reverted: a_fully_transparent_triangle_does_not_widen_the_box_or_the_fit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/prc-render/src/render.rs | 17 +++++++++++++---- crates/prc-render/src/render/tests.rs | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/crates/prc-render/src/render.rs b/crates/prc-render/src/render.rs index a27cfbe5fa..bcaf8acced 100644 --- a/crates/prc-render/src/render.rs +++ b/crates/prc-render/src/render.rs @@ -56,7 +56,8 @@ pub struct Bounds { } impl Bounds { - /// The box around every finite vertex the meshes' triangles use, or `None` when there is none. + /// 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 { let mut out: Option = None; @@ -83,9 +84,17 @@ impl Bounds { } } -/// Every corner of the triangles [`render`] draws: those whose three corners exist and are finite. +/// Every corner of the triangles [`render`] draws: those not fully transparent whose three +/// corners exist and are finite. fn vertices(meshes: &[WorldMesh]) -> impl Iterator + Clone + '_ { - meshes.iter().flat_map(|m| m.mesh.triangles.iter().filter_map(|tri| corners(&m.mesh, tri)).flatten()) + 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 { @@ -100,7 +109,7 @@ impl Camera { Camera::frame(bounds, corners, direction, up, (perspective, aspect)) } - /// As [`Camera::fit`], framing every vertex the meshes' triangles use rather than their box, + /// 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 diff --git a/crates/prc-render/src/render/tests.rs b/crates/prc-render/src/render/tests.rs index df089bfea5..586292ef80 100644 --- a/crates/prc-render/src/render/tests.rs +++ b/crates/prc-render/src/render/tests.rs @@ -301,6 +301,22 @@ fn a_triangle_that_is_not_drawn_does_not_widen_the_box() { 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);