mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-07 03:01:55 +00:00
New L3 crate over pdfcraft-prc: assemble places every product-tree tessellation into world space with its colours (pure `place` does the transform), Camera fits a view to the model, and render fills depth-buffered triangles with headlight or unlit shading into an RGBA image. Opaque triangles draw first, translucent ones blend over them; perspective views clip at a near plane. Never panics: MAX_RENDER_PIXELS and MAX_ASSEMBLED_TRIANGLES cap the work, every pixel and vertex index is checked, and damage returns RenderError. Single-threaded. Tests: 24 unit tests (two drive the product-tree placement path) plus an end-to-end one that builds a synthetic PRC stream in-test, renders it, and checks the background, the nearer triangle and the farther one by pixel; every truncation of that stream parses and assembles without panicking. Reverting the depth test or the placement transform fails the matching tests. Adapted from pdfcer (MIT, same author) at 912437c3, trimmed to the camera, raster and headlight parts and rewritten to the pdfcraft-prc types. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
146 lines
5.8 KiB
Rust
146 lines
5.8 KiB
Rust
//! End to end: a synthetic PRC stream parsed, assembled and rendered to pixels.
|
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
|
|
use pdfcraft_prc::PrcFile;
|
|
use pdfcraft_prc_render::{Camera, Image, RenderOptions, assemble, render};
|
|
use std::io::Write as _;
|
|
|
|
/// An MSB-first bit sink: the inverse of the PRC bit reader, for the few values these tests write.
|
|
#[derive(Default)]
|
|
struct W {
|
|
bits: Vec<bool>,
|
|
}
|
|
|
|
impl W {
|
|
fn put(&mut self, v: u64, n: u32) -> &mut Self {
|
|
for i in (0..n).rev() {
|
|
self.bits.push((v >> i) & 1 == 1);
|
|
}
|
|
self
|
|
}
|
|
|
|
fn bit(&mut self, b: bool) -> &mut Self {
|
|
self.bits.push(b);
|
|
self
|
|
}
|
|
|
|
/// An `UnsignedInteger`: bytes low first, each after a 1 bit, then a 0 bit.
|
|
fn uint(&mut self, mut v: u32) -> &mut Self {
|
|
while v != 0 {
|
|
self.bit(true).put(u64::from(v & 0xff), 8);
|
|
v >>= 8;
|
|
}
|
|
self.bit(false)
|
|
}
|
|
|
|
fn uints(&mut self, v: &[u32]) -> &mut Self {
|
|
self.uint(u32::try_from(v.len()).unwrap());
|
|
for &x in v {
|
|
self.uint(x);
|
|
}
|
|
self
|
|
}
|
|
|
|
/// A `Double` from the few the code table stores whole: 0, ±0.5, ±1, ±2.
|
|
fn double(&mut self, v: f64) -> &mut Self {
|
|
let negative = v < 0.0;
|
|
match v.abs() {
|
|
0.0 => self.put(0b01, 2),
|
|
0.5 => self.put(0x777, 11).bit(negative),
|
|
1.0 => self.put(0b0000, 4).bit(negative),
|
|
// The exponent row of [2, 4) with no mantissa bytes.
|
|
2.0 => self.put(0x1b, 5).bit(negative).bit(false),
|
|
_ => panic!("{v} has no short code here"),
|
|
}
|
|
}
|
|
|
|
fn bytes(&self) -> Vec<u8> {
|
|
self.bits.chunks(8).map(|c| c.iter().enumerate().fold(0u8, |a, (i, &b)| a | (u8::from(b) << (7 - i)))).collect()
|
|
}
|
|
}
|
|
|
|
/// A tessellation section of one `TESS_3D` (authoring version 8137) with explicit normals and
|
|
/// triangles of `(normal, point)` coordinate indices.
|
|
fn tess_section(points: &[[f64; 3]], normals: &[[f64; 3]], corners: &[(u32, u32)]) -> Vec<u8> {
|
|
let mut w = W::default();
|
|
w.uint(305).uint(0).bit(true).uint(1);
|
|
w.uint(172).bit(false).uint(u32::try_from(points.len() * 3).unwrap());
|
|
points.iter().flatten().for_each(|&c| _ = w.double(c));
|
|
// Faces present; no loop flag; normals not recalculated.
|
|
w.bit(true).bit(false).bit(false);
|
|
w.uint(u32::try_from(normals.len() * 3).unwrap());
|
|
normals.iter().flatten().for_each(|&c| _ = w.double(c));
|
|
w.uint(0).uints(&corners.iter().flat_map(|&(n, p)| [n, p]).collect::<Vec<_>>());
|
|
// One face of triangles with a normal per corner.
|
|
w.uint(1).uint(174).uint(0).uint(0).uint(0).uint(0x2).uint(0).uints(&[u32::try_from(corners.len() / 3).unwrap()]);
|
|
w.uint(0).bit(false);
|
|
w.uint(0).uint(0);
|
|
w.bytes()
|
|
}
|
|
|
|
/// A one-file-structure PRC stream whose tessellation section is `tess`, with no product tree.
|
|
fn prc_file(tess: &[u8]) -> Vec<u8> {
|
|
const HEADER_LEN: usize = 107;
|
|
let zlib = |data: &[u8]| {
|
|
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
|
e.write_all(data).unwrap();
|
|
e.finish().unwrap()
|
|
};
|
|
let u32s = |out: &mut Vec<u8>, vs: &[u32]| vs.iter().for_each(|v| out.extend_from_slice(&v.to_le_bytes()));
|
|
let mut globals = W::default();
|
|
globals.uint(0);
|
|
let sections: [&[u8]; 5] = [&globals.bytes(), b"", tess, b"", b""];
|
|
let mut body = b"PRC".to_vec();
|
|
u32s(&mut body, &[8137, 8137, 5, 6, 7, 8, 0, 0, 0, 0, 0]);
|
|
let mut offs = vec![HEADER_LEN];
|
|
for s in sections {
|
|
offs.push(HEADER_LEN + body.len());
|
|
body.extend(zlib(s));
|
|
}
|
|
let mf_start = HEADER_LEN + body.len();
|
|
body.extend(zlib(b""));
|
|
let mf_end = HEADER_LEN + body.len();
|
|
let mut out = b"PRC".to_vec();
|
|
u32s(&mut out, &[8137, 8137, 1, 2, 3, 4, 0, 0, 0, 0, 1]);
|
|
u32s(&mut out, &[5, 6, 7, 8, 0, 6]);
|
|
u32s(&mut out, &offs.iter().map(|&o| u32::try_from(o).unwrap()).collect::<Vec<_>>());
|
|
u32s(&mut out, &[u32::try_from(mf_start).unwrap(), u32::try_from(mf_end).unwrap(), 0]);
|
|
out.extend(body);
|
|
out
|
|
}
|
|
|
|
fn pixel(image: &Image, x: u32, y: u32) -> [u8; 4] {
|
|
let i = (y * image.width + x) as usize * 4;
|
|
image.rgba[i..i + 4].try_into().unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn a_synthetic_prc_renders_its_nearer_triangle_over_the_farther() {
|
|
// Listed first, a small triangle at z = 1 facing the viewer; behind it at z = -1, a larger
|
|
// one whose stored normal lies across the view, so the headlight shades it darkest.
|
|
let points = [[-1., -1., 1.], [1., -1., 1.], [0., 1., 1.], [-2., -2., -1.], [2., -2., -1.], [0., 2., -1.]];
|
|
let normals = [[0., 0., 1.], [1., 0., 0.]];
|
|
let corners = [(0, 0), (0, 3), (0, 6), (3, 9), (3, 12), (3, 15)];
|
|
let prc = PrcFile::parse(&prc_file(&tess_section(&points, &normals, &corners))).unwrap();
|
|
let meshes = assemble(&prc).unwrap();
|
|
assert_eq!(meshes.len(), 1);
|
|
assert_eq!(meshes[0].mesh.triangles.len(), 2);
|
|
let camera = Camera::fit_meshes(&meshes, [0.0, 0.0, -1.0], [0.0, 1.0, 0.0], false, 1.0).unwrap();
|
|
let image = render(&meshes, &camera, &RenderOptions { width: 40, height: 40, ..RenderOptions::default() }).unwrap();
|
|
assert_eq!(pixel(&image, 1, 1), [255, 255, 255, 255], "background corner");
|
|
assert_eq!(pixel(&image, 20, 20), [190, 192, 200, 255], "the nearer triangle, lit head on");
|
|
// Below the near triangle only the far one: 0.3 of the surface colour.
|
|
assert_eq!(pixel(&image, 20, 34), [57, 58, 60, 255], "the farther triangle, lit edge on");
|
|
}
|
|
|
|
#[test]
|
|
fn damaged_streams_are_errors_not_panics() {
|
|
let points = [[0., 0., 0.], [1., 0., 0.], [0., 1., 0.]];
|
|
let bytes = prc_file(&tess_section(&points, &[[0., 0., 1.]], &[(0, 0), (0, 3), (0, 6)]));
|
|
for end in 0..bytes.len() {
|
|
if let Ok(prc) = PrcFile::parse(&bytes[..end]) {
|
|
let _ = assemble(&prc);
|
|
}
|
|
}
|
|
}
|