From c336c59cfb69342f2e12ca4cb2e489b0b3d88142 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Wed, 4 Nov 2026 00:03:28 -0500 Subject: [PATCH] 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); + } }