Merge remote-tracking branch 'fork/feat/prc-render' into feat/model3d-render

This commit is contained in:
KenM76 committed 2026-11-04 00:03:33 -05:00
commit 6fbc1eb9d6
14 files changed
+228 -70

No files matched your search

+3 -2
View File
@@ -23,6 +23,7 @@ render(&[WorldMesh], &Camera, &RenderOptions) -> Result<Image, RenderError>
- `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.
+4 -3
View File
@@ -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};
+28 -9
View File
@@ -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);
}
}
+30 -23
View File
@@ -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<Item = [f64; 3]> + Clone + '_ {
meshes.iter().flat_map(|m| {
let mesh = &m.mesh;
mesh.triangles
.iter()
.flatten()
.filter_map(|&i| mesh.positions.get(usize::try_from(i).ok()?).copied())
.filter(|p| p.iter().all(|c| c.is_finite()))
})
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<Image, RenderError> {
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(())
}
}
+33 -1
View File
@@ -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:?}");
}
+33 -11
View File
@@ -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<Vec3> {
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<Vec3> {
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);
}
}
+20 -9
View File
@@ -17,7 +17,8 @@ pub const PRC_READER_VERSION: u32 = 8137;
/// The most file structures [`PrcFile::parse`] accepts.
pub const MAX_FILE_STRUCTURES: usize = 65_536;
/// Ceiling on the total inflated size of every section in one PRC stream.
/// Ceiling on the total inflated size of every section in one PRC stream, together with the
/// picture and uncompressed blocks it copies.
pub const MAX_INFLATED_BYTES: usize = 512 * 1024 * 1024;
/// Sections per file structure [WD 6.1.2]: its header, then the five in [`SectionKind`]
@@ -163,8 +164,9 @@ impl<'a> Bytes<'a> {
}
}
/// `UncompressedBlock`s: a count, then size-prefixed blocks [WD 10.2.2-3].
fn blocks(&mut self, what: &'static str) -> Result<Vec<Vec<u8>>, PrcError> {
/// `UncompressedBlock`s: a count, then size-prefixed blocks [WD 10.2.2-3], each charged to
/// `budget` before it is copied.
fn blocks(&mut self, what: &'static str, budget: &mut Budget) -> Result<Vec<Vec<u8>>, PrcError> {
let n = offset(self.u32(what)?);
// Each block needs at least its 4-byte size, so `n` is bounded by the data left.
if n > self.data.len().saturating_sub(self.pos) / 4 {
@@ -173,7 +175,9 @@ impl<'a> Bytes<'a> {
(0..n)
.map(|_| {
let len = offset(self.u32(what)?);
Ok(self.take(len, what)?.to_vec())
let block = self.take(len, what)?;
budget.charge(block.len())?;
Ok(block.to_vec())
})
.collect()
}
@@ -235,7 +239,8 @@ fn file_structure(data: &[u8], id: UniqueId, extent: &Extent, budget: &mut Budge
return Err(PrcError::Malformed("file structure id differs from its description".into()));
}
let _application = h.id("file structure header")?;
let pictures = h.blocks("file structure pictures")?;
// Headers may overlap, so each structure's copy of its pictures is charged.
let pictures = h.blocks("file structure pictures", budget)?;
let mut sections: [Vec<u8>; 5] = Default::default();
for (i, (slot, kind)) in sections.iter_mut().zip(SectionKind::ALL).enumerate() {
let end = if i + 2 < SECTIONS { extent.bound(i + 2) } else { extent.last };
@@ -262,9 +267,15 @@ impl Budget {
// One byte past the budget is enough to know it was exceeded.
let cap = u64::try_from(self.left).unwrap_or(u64::MAX).saturating_add(1);
flate2::read::ZlibDecoder::new(src).take(cap).read_to_end(&mut out).map_err(|e| PrcError::Inflate { section, reason: e.to_string() })?;
self.left = self.left.checked_sub(out.len()).ok_or(PrcError::TooLarge { limit: self.limit })?;
self.charge(out.len())?;
Ok(out)
}
/// Spend `n` bytes of the ceiling.
fn charge(&mut self, n: usize) -> Result<(), PrcError> {
self.left = self.left.checked_sub(n).ok_or(PrcError::TooLarge { limit: self.limit })?;
Ok(())
}
}
impl PrcFile {
@@ -278,7 +289,7 @@ impl PrcFile {
/// [`PrcError::NotPrc`] without the magic; [`PrcError::Truncated`] or
/// [`PrcError::Malformed`] for a damaged header or offset table; [`PrcError::Inflate`]
/// for a section that is not zlib; [`PrcError::TooLarge`] past [`MAX_INFLATED_BYTES`]
/// in total.
/// in total, inflated sections and copied blocks together.
///
/// # Examples
/// ```
@@ -310,7 +321,8 @@ impl PrcFile {
let descriptions = (0..n_fs).map(|_| file_structure_description(&mut r)).collect::<Result<Vec<_>, _>>()?;
let mf_start = offset(r.u32("model file offsets")?);
let mf_end = offset(r.u32("model file offsets")?);
let uncompressed_files = r.blocks("uncompressed files")?;
let mut budget = Budget { left: limit, limit };
let uncompressed_files = r.blocks("uncompressed files", &mut budget)?;
if mf_end < mf_start {
return Err(PrcError::Malformed("model file ends before it starts".into()));
}
@@ -318,7 +330,6 @@ impl PrcFile {
return Err(PrcError::Malformed(format!("model file ends at {mf_end}, past the {}-byte stream", data.len())));
}
let mut budget = Budget { left: limit, limit };
// prc-rs: the last section runs to the model file's start.
let last = mf_start.min(data.len());
let file_structures =
+2 -2
View File
@@ -24,8 +24,8 @@ pub enum PrcError {
/// The decoder's message.
reason: String,
},
/// The inflated sections together exceed the ceiling ([`crate::MAX_INFLATED_BYTES`] or
/// the caller's limit).
/// The inflated sections and copied blocks together exceed the ceiling
/// ([`crate::MAX_INFLATED_BYTES`] or the caller's limit).
#[error("PRC sections inflate past the {limit}-byte ceiling")]
TooLarge {
/// The ceiling that was hit.
+24 -3
View File
@@ -79,10 +79,15 @@ impl Matrix {
/// Whether mapping points through this matrix turns them inside out. Where the point is
/// divided by `w`, the Jacobian's sign is the sign of the 4×4 determinant; a last row of
/// zeros (no division) falls back to the 3×3 block.
/// zeros (no division) falls back to the 3×3 block. Each row is first divided by its
/// largest entry, which keeps both signs while the products stay in range.
fn mirrors(&self) -> bool {
let det = self.det4();
if det == 0.0 { self.det3() < 0.0 } else { det < 0.0 }
let scaled = Matrix(self.0.map(|row| {
let big = row.iter().fold(0.0_f64, |m, c| m.max(c.abs()));
if big.is_finite() && big > 0.0 { row.map(|c| c / big) } else { row }
}));
let det = scaled.det4();
if det == 0.0 { scaled.det3() < 0.0 } else { det < 0.0 }
}
/// Cofactor `(i, j)` of the upper-left 3×3 block (`i`, `j` < 3).
@@ -247,6 +252,22 @@ mod tests {
assert_eq!(mesh.transformed(&positive).triangles, [[0, 1, 2]]);
}
#[test]
fn a_mirror_whose_determinant_underflows_still_reverses_the_winding() {
let mesh = TriangleMesh {
positions: vec![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
triangles: vec![[0, 1, 2]],
..TriangleMesh::default()
};
let diagonal = |x: f64| {
let mut m = Matrix::IDENTITY;
(m.0[0][0], m.0[1][1], m.0[2][2]) = (x, 1e-200, 1e-200);
m
};
assert_eq!(mesh.transformed(&diagonal(-1e200)).triangles, [[0, 2, 1]]);
assert_eq!(mesh.transformed(&diagonal(1e200)).triangles, [[0, 1, 2]]);
}
impl Matrix {
fn transformed_normal(&self, mesh: &TriangleMesh) -> [f64; 3] {
mesh.transformed(self).normals.first().copied().unwrap_or_default()
+12 -3
View File
@@ -303,11 +303,12 @@ impl Exec<'_, '_, '_> {
if !run {
return Ok(0);
}
let overflow = || malformed("a schema expression overflows");
Ok(match t {
29 => a.wrapping_mul(b),
29 => a.checked_mul(b).ok_or_else(overflow)?,
30 => a.checked_div(b).ok_or_else(|| malformed("division by zero"))?,
31 => a.wrapping_add(b),
32 => a.wrapping_sub(b),
31 => a.checked_add(b).ok_or_else(overflow)?,
32 => a.checked_sub(b).ok_or_else(overflow)?,
33 => i64::from(a < b),
34 => i64::from(a <= b),
35 => i64::from(a > b),
@@ -492,6 +493,14 @@ mod tests {
assert_eq!(used, want);
}
#[test]
fn an_expression_that_overflows_is_malformed() {
// For ((2^31 * 2^31) * 4) { UInt }: 2^64 would wrap to 0 and read nothing.
let prog: &[u32] = &[15, 29, 29, 26, 1 << 31, 26, 1 << 31, 26, 4, 3];
let (res, _) = run(&[(172, prog)], &W::default());
assert!(matches!(res, Err(PrcError::Malformed(m)) if m.contains("overflows")));
}
#[test]
fn version_blocks_run_only_when_newer_than_the_reader() {
let mut d = W::default();
+3 -2
View File
@@ -203,10 +203,11 @@ impl Ctx<'_, '_> {
let styled = faces.iter().any(|f| !f.styles.is_empty());
let mut corners = Corners::default();
for f in &faces {
// Faces may reuse one index range, so each face's triangles are charged before
// they are made.
self.r.take_elements(f.triangle_count()?)?;
let start = mesh.triangles.len();
let entities = f.triangulate(&indices, !recalc, mesh.positions.len(), &mut mesh.triangles, &mut corners)?;
// Faces may reuse one index range, so the triangles are charged as they are made.
self.r.take_elements(mesh.triangles.len().saturating_sub(start))?;
mesh.faces.push(start..mesh.triangles.len());
if styled {
f.styles_into(&entities, &mut mesh.triangle_styles);
+12
View File
@@ -690,6 +690,18 @@ fn faces_reusing_one_index_range_are_charged_for_each_triangle_they_make() {
assert_eq!(three - one, 2 * 4);
}
#[test]
fn a_face_is_charged_before_it_is_triangulated() {
// One face claiming a million triangles over a 12-entry index array.
const INDICES: [u32; 12] = [0, 0, 0, 3, 0, 6, 0, 0, 0, 6, 0, 9];
let mut b = W::default();
tess_3d(&mut b, 8137, &Mesh { indices: &INDICES, faces: &[(0x2, 0, &[1_000_000])], ..Mesh::default() });
let bytes = section(1, &b).bytes();
let schema = Schema::default();
let r = Ctx::new(BitReader::with_elements(&bytes, 1000), &schema, 8137).file_structure_tessellation();
assert!(matches!(r, Err(PrcError::TooManyElements { .. })), "{r:?}");
}
#[test]
fn the_globals_schema_and_the_tessellations_share_one_allowance() {
// A schema of one entry with three tokens: four elements.
+13
View File
@@ -119,6 +119,19 @@ impl Face {
Ok(n)
}
/// Triangles [`Self::triangulate`] makes: one per triangle-block count, two fewer than the
/// points of each fan and strip.
pub(super) fn triangle_count(&self) -> Result<usize, PrcError> {
let mut n = 0usize;
for (b, sizes) in self.entities()? {
for s in sizes {
let s = (s & COUNT_MASK) as usize;
n = n.saturating_add(if matches!(b.shape, Shape::Triangles) { s } else { s.saturating_sub(2) });
}
}
Ok(n)
}
/// Emit the face's triangles into `out`; `normals` = the index array stores normal slots,
/// in which case each triangle's corner normal slots go to `corners`, as do its corner
/// texture slots. Returns the triangles each triangulation entity emitted, in order: one
+11 -2
View File
@@ -95,9 +95,18 @@ fn damaged_containers_are_refused_by_name() {
#[test]
fn the_inflation_ceiling_is_reported_not_exceeded() {
// 1,200 inflated bytes and the 3-byte picture.
let f = build([&[0u8; 600], &[0u8; 600], b"", b"", b""], b"", 0);
assert!(PrcFile::parse_with_limit(&f, 1200).is_ok());
assert_eq!(PrcFile::parse_with_limit(&f, 1199).unwrap_err(), PrcError::TooLarge { limit: 1199 });
assert!(PrcFile::parse_with_limit(&f, 1203).is_ok());
assert_eq!(PrcFile::parse_with_limit(&f, 1202).unwrap_err(), PrcError::TooLarge { limit: 1202 });
}
#[test]
fn copied_picture_blocks_are_charged_to_the_ceiling() {
// Nothing inflates to more than 0 bytes; the header's one picture is 3 bytes.
let f = build([b"", b"", b"", b"", b""], b"", 0);
assert!(PrcFile::parse_with_limit(&f, 3).is_ok());
assert_eq!(PrcFile::parse_with_limit(&f, 2).unwrap_err(), PrcError::TooLarge { limit: 2 });
}
#[test]