diff --git a/crates/automation/tests/it/model_render.rs b/crates/automation/tests/it/model_render.rs index c7dcca1af6..4323ca50e7 100644 --- a/crates/automation/tests/it/model_render.rs +++ b/crates/automation/tests/it/model_render.rs @@ -9,6 +9,11 @@ use serde_json::{Value, json}; /// file structure with no product tree, in hexadecimal for the stream's `/ASCIIHexDecode`. const TRIANGLE_PRC_HEX: &str = "505243c91f0000c91f00000100000002000000030000000400000000000000000000000000000000000000010000000500000006000000070000000800000000000000060000006b0000009a000000a3000000ab000000cd000000d5000000dd000000e500000000000000505243c91f0000c91f0000050000000600000007000000080000000000000000000000000000000000000000000000789c63000000010001789c030000000001789c9b71c087758da2422b43084fe3140e038713464cd759381a162800006ed70828789c030000000001789c030000000001789c030000000001"; +/// The same triangle stored as a `TESS_3D_Compressed` whose normals are recalculated, so it is +/// drawn only once its triangles are rebuilt. `prc-render`'s tests build it +/// (`the_automation_fixture_is_the_compressed_triangle` keeps this copy their output). +const COMPRESSED_TRIANGLE_PRC_HEX: &str = "505243c91f0000c91f00000100000002000000030000000400000000000000000000000000000000000000010000000500000006000000070000000800000000000000060000006b0000009a000000a3000000ab000000d2000000da000000e2000000ea00000000000000505243c91f0000c91f0000050000000600000007000000080000000000000000000000000000000000000000000000789c63000000010001789c030000000001789c9b71c08775aded1d0624d0d2c808055a8d0c8c8ccc0c0c0c0a0a223ce2e50c2c00bed0061f789c030000000001789c030000000001789c030000000001"; + /// A one-page PDF whose page holds a 3D annotation over a stream of `/Subtype /{subtype}`. fn pdf(subtype: &str, hex: &str) -> Vec { let stream = format!("<< /Type /3D /Subtype /{subtype} /Filter /ASCIIHexDecode /Length {} >>\nstream\n{hex}>\nendstream", hex.len() + 1); @@ -82,6 +87,24 @@ fn a_prc_model_is_drawn_over_the_background() { assert!(a.call("model_render", &json!({ "doc": doc, "page": 1, "model": 1 })).is_ok()); } +#[test] +fn a_compressed_prc_model_is_rebuilt_and_drawn() { + let (dir, mut a) = setup("prc-compressed"); + std::fs::write(dir.join("in/compressed.pdf"), pdf("PRC", COMPRESSED_TRIANGLE_PRC_HEX)).unwrap(); + let doc = open(&mut a, "compressed.pdf"); + let args = json!({ "doc": doc, "page": 1, "width": 64, "height": 48, "direction": [0, 0, -1], "perspective": false }); + let out = a.call("model_render", &args).unwrap(); + let Content::Png { data, .. } = &out[0] else { panic!("expected a PNG") }; + assert_eq!(rgb(data, 0, 0), [255, 255, 255], "the corner is background"); + assert_eq!(rgb(data, 32, 24), [190, 192, 200], "the centre is the triangle, lit head on"); + // Its apex is up: the drawn span narrows from the base to the top, and the centre column + // is covered over many rows. + let (top, bottom) = rows(data, 32); + assert!(bottom - top > 20, "rows {top}..{bottom}"); + let (near_top, near_bottom) = (extent(data, top + 4), extent(data, bottom - 2)); + assert!(near_bottom.1 - near_bottom.0 > 2 * (near_top.1 - near_top.0), "{near_top:?} {near_bottom:?}"); +} + #[test] fn a_u3d_model_or_a_missing_one_is_a_clear_error() { let (_dir, mut a) = setup("u3d"); diff --git a/crates/prc-render/tests/model.rs b/crates/prc-render/tests/model.rs index 80e84252c6..ce94ceb4ce 100644 --- a/crates/prc-render/tests/model.rs +++ b/crates/prc-render/tests/model.rs @@ -78,6 +78,30 @@ fn tess_section(points: &[[f64; 3]], normals: &[[f64; 3]], corners: &[(u32, u32) w.bytes() } +/// A tessellation section of one `TESS_3D_Compressed` (authoring version 8137, origin 0, +/// tolerance 0.5) whose normals are recalculated: the seed triangle `seed`, in tolerances +/// [WD 7.8.9.7]. +fn compressed_section(seed: [i64; 9]) -> Vec { + let mut w = W::default(); + w.uint(305).uint(0).bit(true).uint(1); + w.uint(173).bit(false).bit(true).double(0.5).put(0, 32).put(0, 32).put(0, 32); + // point_array: 4-bit sign-magnitude integers. + w.bit(false).uint(9); + seed.iter().for_each(|_| _ = w.put(4, 8)); + seed.iter().for_each(|&p| _ = w.bit(p < 0).put(p.unsigned_abs(), 3)); + // Three edge statuses, none set; one triangle on face 0; three slots, none a reference. + w.bit(false).uint(3).put(0, 24); + w.bit(false).uint(1).put(1, 8).bit(false); + w.uint(3).put(0, 3).uint(0); + // Normals recalculated; no colours or multiple line attributes; no style; no texture or + // behaviours. + w.bit(true).bit(false).double(0.5).put(0, 8).bit(false).bit(false); + w.bit(false).uint(0).bit(true).bit(false); + // The section's user data. + w.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; @@ -133,6 +157,30 @@ fn a_synthetic_prc_renders_its_nearer_triangle_over_the_farther() { assert_eq!(pixel(&image, 20, 34), [57, 58, 60, 255], "the farther triangle, lit edge on"); } +/// The seed (-1, -1, 0), (1, -1, 0), then the midpoint (0, -1, 0) raised by 2 to (0, 1, 0). +const COMPRESSED_TRIANGLE: [i64; 9] = [-2, -2, 0, 4, 0, 0, 0, 4, 0]; + +#[test] +fn a_compressed_tessellation_is_rebuilt_and_rendered() { + let prc = PrcFile::parse(&prc_file(&compressed_section(COMPRESSED_TRIANGLE))).unwrap(); + let meshes = assemble(&prc).unwrap(); + assert_eq!(meshes.len(), 1); + assert_eq!(meshes[0].mesh.triangles.len(), 1); + 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_ne!(pixel(&image, 20, 24), [255, 255, 255, 255], "the triangle"); +} + +/// `model_render`'s end-to-end test embeds this builder's compressed triangle as hexadecimal; +/// this keeps that fixture the builder's output. +#[test] +fn the_automation_fixture_is_the_compressed_triangle() { + let hex: String = prc_file(&compressed_section(COMPRESSED_TRIANGLE)).iter().map(|b| format!("{b:02x}")).collect(); + let fixture = include_str!("../../automation/tests/it/model_render.rs"); + assert!(fixture.contains(&format!("const COMPRESSED_TRIANGLE_PRC_HEX: &str = \"{hex}\";")), "{hex}"); +} + #[test] fn damaged_streams_are_errors_not_panics() { let points = [[0., 0., 0.], [1., 0., 0.], [0., 1., 0.]]; diff --git a/crates/prc/src/bits.rs b/crates/prc/src/bits.rs index 837d281f3b..b6897e56f6 100644 --- a/crates/prc/src/bits.rs +++ b/crates/prc/src/bits.rs @@ -69,7 +69,6 @@ impl<'a> BitReader<'a> { } /// Array elements this reader may still decode. - #[cfg(test)] pub(crate) fn elements_left(&self) -> usize { self.budget.elements } diff --git a/crates/prc/src/tess.rs b/crates/prc/src/tess.rs index c9bffb30f4..a7614b0a5c 100644 --- a/crates/prc/src/tess.rs +++ b/crates/prc/src/tess.rs @@ -2,12 +2,14 @@ //! (172) with its `TESS_Face`s (174), `TESS_3D_Wire` (175), `TESS_Markup` (176) and //! `TESS_3D_Compressed` (173) [WD 7.3.7, 7.8]. //! -//! `TESS_3D_Compressed` is read field by field to its end [WD 7.8.9.7] so the entities after -//! it decode; its triangles are not rebuilt. +//! `TESS_3D_Compressed` is read field by field to its end [WD 7.8.9.7]. One whose normals are +//! recalculated is rebuilt into triangles ([`compressed`]); one that stores its normals, or +//! whose arrays do not fit the traversal, is not. //! //! The schema runs after the fields of each concrete type read here (201, 172, 173, 174, 175, //! 176, 305), not for the abstract levels (`PRCBase`, `TESS`). +mod compressed; mod triangulate; use crate::arrays; @@ -49,7 +51,8 @@ pub enum Tessellation { Wire(Vec>), /// A `TESS_Markup` (PMI drawing), read past but not decoded. Markup, - /// A `TESS_3D_Compressed` mesh, read past but not rebuilt. + /// A `TESS_3D_Compressed` mesh, read past but not rebuilt: it stores its normals, or its + /// arrays do not fit the traversal. One that is rebuilt is a [`Tessellation::Mesh`]. #[non_exhaustive] Compressed { /// The entity's triangle count. @@ -65,7 +68,8 @@ pub struct TriangleMesh { /// The coordinate array as points. pub positions: Vec<[f64; 3]>, /// Counter-clockwise triangles seen from the outside, as indices into - /// [`Self::positions`] [WD 7.8.5.2]. + /// [`Self::positions`] [WD 7.8.5.2]. A rebuilt `TESS_3D_Compressed` keeps its traversal's + /// winding, which is not oriented. pub triangles: Vec<[u32; 3]>, /// `triangles` split by `TESS_Face`: face `i` is `triangles[faces[i].clone()]`. pub faces: Vec>, @@ -136,7 +140,7 @@ impl Ctx<'_, '_> { self.markup()?; Tessellation::Markup } - TESS_3D_COMPRESSED => Tessellation::Compressed { triangles: self.tess_3d_compressed()? }, + TESS_3D_COMPRESSED => self.tess_3d_compressed()?, t => return Err(malformed(format!("entity type {t} in the tessellation array"))), }); } @@ -295,23 +299,25 @@ impl Ctx<'_, '_> { self.schema.skip_added_fields(TESS_MARKUP, &mut self.r) } - /// `TESS_3D_Compressed` (173) after its type code [WD 7.8.9.7], read past; returns its - /// triangle count. prc-rs: the field order and version gates follow its schema. + /// `TESS_3D_Compressed` (173) after its type code [WD 7.8.9.7]: rebuilt into a mesh when + /// its normals are recalculated and its arrays fit, else read past to its end. prc-rs: the + /// field order and version gates follow its schema. /// /// Sizes the WD leaves unstated: the face count is the largest face index + 1, /// `normal_is_reversed` is one bit per triangle, and the point-reference array's /// compressed flag is implicit (more than three references). - fn tess_3d_compressed(&mut self) -> Result { + fn tess_3d_compressed(&mut self) -> Result { let r = &mut self.r; r.bit()?; // is_calculated r.bit()?; // has_faces - r.double()?; // tolerance + let tolerance = r.double()?; + let mut origin = [0.0f64; 3]; if self.version >= ORIGIN_FROM { - for _ in 0..3 { - r.float_as_bytes()?; + for o in &mut origin { + *o = f64::from(r.float_as_bytes()?); } } - arrays::compressed_integer_array(r)?; // point_array + let points = arrays::compressed_integer_array(r)?; // point_array let edge_status = arrays::character_array(r, 2, None, false)?; let face_of = arrays::compressed_indice_array(r, None)?; let t = face_of.len(); @@ -320,42 +326,50 @@ impl Ctx<'_, '_> { } let faces = face_of.iter().max().map_or(0, |&m| (m as usize).saturating_add(1)); let n = r.unsigned_integer()? as usize; - let references = arrays::bool_array(r, n)?.iter().filter(|&&b| b).count(); - let refs = arrays::compressed_indice_array(r, Some(references > 3))?; - if refs.len() != references { - return Err(malformed(format!("{} point references for {references} flagged points", refs.len()))); + let is_reference = arrays::bool_array(r, n)?; + let flagged = is_reference.iter().filter(|&&b| b).count(); + let references = arrays::compressed_indice_array(r, Some(flagged > 3))?; + if references.len() != flagged { + return Err(malformed(format!("{} point references for {flagged} flagged points", references.len()))); } - self.compressed_normals(t, faces)?; - let r = &mut self.r; - if r.bit()? { - arrays::bool_array(r, faces)?; // is_point_color_on_face - arrays::character_array(r, 8, None, false)?; // point_color_array + let recalc = self.compressed_normals(t, faces)?; + let attributes = self.compressed_attributes(faces)?; + if !recalc { + return Ok(Tessellation::Compressed { triangles: t }); } - if r.bit()? { - arrays::bool_array(r, faces)?; // is_multiple_line_attribute_on_face - } - arrays::short_array(r, 16)?; // line_attributes - if !r.bit()? { - self.compressed_texture_parameter()?; - if !self.r.bit()? { - arrays::bool_array(&mut self.r, faces)?; // face_has_texture - } - } - if self.r.bit()? { - arrays::character_array(&mut self.r, 8, None, false)?; // behaviours - } - self.schema.skip_added_fields(TESS_3D_COMPRESSED, &mut self.r)?; - Ok(t) + let arrays = compressed::Arrays { + tolerance, + origin, + points: &points, + edge_status: &edge_status, + triangles: t, + is_reference: &is_reference, + references: &references, + limit: self.r.elements_left(), + }; + // A walk that would outgrow the allowance left stops there, and the arrays stay + // compressed, as do arrays the walk does not fit. + let Ok(m) = compressed::rebuild(&arrays) else { + return Ok(Tessellation::Compressed { triangles: t }); + }; + // The rebuilt triangles, their styles and every face range are charged once the arrays + // fit and before any of them is made, so arrays that do not fit spend nothing. + let styled = attributes.styled(faces); + let kept = m.triangles.len().saturating_mul(triangle_elements(false, 0, styled)); + self.r.take_elements(kept.saturating_add(faces.saturating_mul(RANGE_ELEMENTS)))?; + let styles = if styled { attributes.styles(&face_of, faces) } else { Vec::new() }; + Ok(Tessellation::Mesh(by_face(m, &face_of, faces, &styles))) } - /// A compressed tessellation's normals, from `must_recalculate_normals` on, read past. - fn compressed_normals(&mut self, triangles: usize, faces: usize) -> Result<(), PrcError> { + /// A compressed tessellation's normals, from `must_recalculate_normals` on, read past; + /// returns whether they are recalculated. + fn compressed_normals(&mut self, triangles: usize, faces: usize) -> Result { let r = &mut self.r; if r.bit()? { arrays::bool_array(r, triangles)?; // normal_is_reversed r.double()?; // crease_angle r.character()?; // normal_recalculation_flags - return Ok(()); + return Ok(true); } let angle_bits = u32::from(r.character()?); if angle_bits > MAX_ANGLE_BITS { @@ -365,7 +379,29 @@ impl Ctx<'_, '_> { arrays::bool_array(r, n)?; // is_binary_normal arrays::short_array(r, angle_bits)?; // normal angles arrays::bool_array(r, faces)?; // is_face_planar - Ok(()) + Ok(false) + } + + /// A compressed tessellation's colours, line attributes, texture and behaviours, through + /// its added schema fields; returns what its faces' styles are made from. + fn compressed_attributes(&mut self, faces: usize) -> Result { + let r = &mut self.r; + if r.bit()? { + arrays::bool_array(r, faces)?; // is_point_color_on_face + arrays::character_array(r, 8, None, false)?; // point_color_array + } + // is_multiple_line_attribute_on_face + let multi = if r.bit()? { arrays::bool_array(r, faces)? } else { Vec::new() }; + let line_attributes = arrays::short_array(r, 16)?; + if !r.bit()? { + self.compressed_texture_parameter()?; + if !self.r.bit()? { + arrays::bool_array(&mut self.r, faces)?; // face_has_texture + } + } + let behaviours = if self.r.bit()? { arrays::character_array(&mut self.r, 8, None, false)? } else { Vec::new() }; + self.schema.skip_added_fields(TESS_3D_COMPRESSED, &mut self.r)?; + Ok(FaceAttributes { multi, line_attributes, behaviours }) } /// `CompressedTextureParameter` [WD 7.8.9.8-7.8.9.9; pdf-issues #729, #749], read past. @@ -387,6 +423,71 @@ impl Ctx<'_, '_> { } } +/// Budget elements one face range (`Range`) takes. +const RANGE_ELEMENTS: usize = std::mem::size_of::>().div_ceil(8); + +/// What a compressed tessellation's faces are styled from [WD 7.8.9.5]: with no face flagged +/// `is_multiple_line_attribute_on_face` (`multi`), `line_attributes` holds one style index + 1 +/// per face (0 keeps the owner's graphics) and `behaviours` one behaviour byte per face. +struct FaceAttributes { + multi: Vec, + line_attributes: Vec, + behaviours: Vec, +} + +impl FaceAttributes { + /// Whether the faces give their triangles styles. The one-per-triangle layout of a flagged + /// face is not established, so such a tessellation keeps its owner's graphics, as does one + /// whose attributes and behaviours are all 0. + fn styled(&self, faces: usize) -> bool { + let behaviours = self.behaviours.len() == faces && self.behaviours.iter().any(|&b| b != 0); + !self.multi.iter().any(|&b| b) && self.line_attributes.len() == faces && (behaviours || self.line_attributes.iter().any(|&a| a != 0)) + } + + /// Per triangle, the style its face gives it; call only when [`Self::styled`]. + fn styles(&self, face_of: &[u32], faces: usize) -> Vec { + let behaviour = |f: usize| { + if self.behaviours.len() == faces { self.behaviours.get(f).and_then(|&b| u16::try_from(b).ok()).unwrap_or(0) } else { 0 } + }; + face_of + .iter() + .map(|&f| { + let f = f as usize; + TriangleStyle { style: self.line_attributes.get(f).and_then(|&a| u32::try_from(a).ok()).unwrap_or(0), behaviour: behaviour(f) } + }) + .collect() + } +} + +/// A rebuilt compressed mesh with its triangles grouped by face, in walk order within each, +/// so face `i` is `triangles[faces[i].clone()]` as for a `TESS_3D`. `faces` is charged to the +/// element budget by the caller. +fn by_face(m: compressed::Rebuilt, face_of: &[u32], faces: usize, styles: &[TriangleStyle]) -> TriangleMesh { + let mut order: Vec = (0..m.triangles.len()).collect(); + order.sort_by_key(|&i| face_of.get(i).copied().unwrap_or(0)); + let mut mesh = TriangleMesh { positions: m.positions, normals_recalculated: true, ..TriangleMesh::default() }; + mesh.faces = Vec::with_capacity(faces); + for &i in &order { + let f = face_of.get(i).map_or(0, |&f| f as usize); + while mesh.faces.len() <= f { + let at = mesh.triangles.len(); + mesh.faces.push(at..at); + } + if let (Some(&t), Some(r)) = (m.triangles.get(i), mesh.faces.last_mut()) { + mesh.triangles.push(t); + r.end = mesh.triangles.len(); + } + if let Some(&s) = styles.get(i) { + mesh.triangle_styles.push(s); + } + } + while mesh.faces.len() < faces { + let at = mesh.triangles.len(); + mesh.faces.push(at..at); + } + mesh +} + /// The normal array and each triangle's corner normals, or both empty when any slot fails to /// name a normal: a mesh shades from its stored normals wholly or not at all. fn stored_normals(coords: &[f64], slots: &[[u32; 3]], triangles: usize) -> (Vec<[f64; 3]>, Vec<[u32; 3]>) { diff --git a/crates/prc/src/tess/compressed.rs b/crates/prc/src/tess/compressed.rs new file mode 100644 index 0000000000..53e082b4d2 --- /dev/null +++ b/crates/prc/src/tess/compressed.rs @@ -0,0 +1,353 @@ +//! Triangle reconstruction for a `TESS_3D_Compressed` (173) whose normals are recalculated +//! [WD 7.8.9, 7.8.9.2, 7.8.9.7]. +//! +//! The WD names the arrays but not the traversal that wrote them; the rules below are the ones +//! the arrays fit when every slot, reference and point is consumed exactly. They are inferred +//! from compressed tessellations and are not WD text. +//! +//! Traversal. A component starts with a seed triangle of three slots; every later triangle +//! takes one slot, its apex. A slot flagged in `point_is_reference` takes the next +//! `point_reference_array` value, a vertex number; any other slot makes the next vertex from +//! the next `point_array` triple. Triangle `[A B C]`, entered across `[A B]`, reads +//! `edge_status & 3`: bit 1 continues across `[C B]`, bit 0 across `[A C]`; with both, +//! `[C B]` is taken and `[A C]` pushed. With neither, pushed edges are popped, skipping any +//! already shared by two triangles; an empty stack starts the next component. A triangle +//! whose continuation edge is already shared by two triangles, while the other edge is open, +//! continues across the open one. +//! +//! Geometry, in units of `tolerance`: +//! - seed: `V0 = origin + d`, `V1 = V0 + d`, `V2 = mid(V0, V1) + d`; +//! - apex across `[P Q]` with the triangle's third vertex `W` [WD 7.8.9.2]: with +//! `O = (P + Q) * 0.5`, `L` and `H` the lower- and higher-numbered of `P`, `Q`, +//! `X = unitize(L - H)`, `Z = unitize((W - O) x X)` and `Y = unitize(Z x X)`, the apex is +//! `((O + d.x X) - d.y Y) - d.z Z`. +//! +//! Arithmetic. The encoder frames each apex on the points it already approximated [WD 7.8.9], +//! so the decoder repeats the encoder's floating point: a last-bit difference compounds from +//! apex to apex. `unitize` therefore matches the WD's `PrcPt::Unitize` pseudocode [WD 12.3] as +//! read, whose length is not exact, and the operations keep the order above. +//! +//! Fold. A triangle folded against its parent swaps bits 1 and 0 for its own continuation. +//! Without stored normals to orient it, a new apex lying in its parent's plane on the parent's +//! side (`d.z == 0`, `d.y > 0`) is folded. A component that does not fit is rewound and walked +//! again with the fold inverted at its second triangle, where an apex that is a reference +//! leaves no other trace of its fold. +//! +//! Only the three-statuses-per-triangle `edge_status` form is reconstructed (the first `T` +//! are read); the one-per-triangle form walks differently. + +use std::collections::HashMap; + +type V = [f64; 3]; + +/// The arrays of one `TESS_3D_Compressed` the walk reads. +pub(super) struct Arrays<'a> { + pub(super) tolerance: f64, + pub(super) origin: V, + pub(super) points: &'a [i64], + pub(super) edge_status: &'a [i32], + pub(super) triangles: usize, + pub(super) is_reference: &'a [bool], + pub(super) references: &'a [u32], + /// Budget elements the walk may hold at once (see [`Walk::held`]). + pub(super) limit: usize, +} + +/// The rebuilt vertices and triangles, in walk order. +#[derive(Debug)] +pub(super) struct Rebuilt { + pub(super) positions: Vec, + pub(super) triangles: Vec<[u32; 3]>, +} + +/// Rebuilds the mesh, or says why the arrays do not fit the traversal. +pub(super) fn rebuild(a: &Arrays<'_>) -> Result { + if a.edge_status.len() != a.triangles.saturating_mul(3) { + return Err("the one-status-per-triangle edge form is not reconstructed"); + } + let mut w = Walk::new(a.triangles, a.limit); + w.walk(a)?; + if w.slot != a.is_reference.len() || w.ri != a.references.len() || w.pi != a.points.len() { + return Err("the decoded triangles do not use up the stored arrays"); + } + Ok(Rebuilt { positions: w.pos, triangles: w.tris }) +} + +fn add(a: V, b: V) -> V { + [a[0] + b[0], a[1] + b[1], a[2] + b[2]] +} +fn sub(a: V, b: V) -> V { + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} +fn scale(a: V, s: f64) -> V { + a.map(|c| c * s) +} +fn dot(a: V, b: V) -> f64 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} +fn cross(a: V, b: V) -> V { + [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] +} +fn key(a: u32, b: u32) -> (u32, u32) { + if a < b { (a, b) } else { (b, a) } +} + +/// `PrcPt::Length` [WD 12.3]: the squared length summed in `f64` and rounded to `f32`, then +/// Newton's square root in `f64`, seeded with that `f32` with its exponent halved. The printed +/// loop never advances its iterate; it is read as iterating until two successive values are +/// equal, `None` (the WD's -1) when 100 steps do not settle. +fn wd_length(a: V) -> Option { + let squared = (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]) as f32; + let bits = squared.to_bits(); + let exponent = (bits >> 23) & 0xff; + let seed = if exponent > 127 { f32::from_bits((bits & !(0xff << 23)) | (((exponent - 127) / 2 + 127) << 23)) } else { squared }; + let squared = f64::from(squared); + let mut x = f64::from(seed); + for _ in 0..100 { + let next = 0.5 * (x + squared / x); + if next == x { + return Some(next); + } + x = next; + } + None +} + +/// `PrcPt::Unitize` [WD 12.3]: each component divided by [`wd_length`]; `None` below +/// `FLT_EPSILON`. +fn unitize(a: V) -> Option { + let l = wd_length(a).filter(|&l| l >= f64::from(f32::EPSILON))?; + Some([a[0] / l, a[1] / l, a[2] / l]) +} + +/// The `Z` of `PrcPt::MakeOrthoRep` [WD 12.3] on `X`: `unitize(X x (0, 1, 0))`, or +/// `X x (1, 0, 0)` when that is null, from `X` unitized again as the pseudocode does. +fn make_ortho_rep_z(x: V) -> Option { + let x = unitize(x)?; + unitize(cross(x, [0.0, 1.0, 0.0])).or_else(|| unitize(cross(x, [1.0, 0.0, 0.0]))) +} + +/// The apex across `[lo hi]` (`lo` the lower-numbered vertex) opposite `w`, `d` already scaled +/// by the tolerance [WD 7.8.9.2]. A null `Z` or `Y` takes `MakeOrthoRep(X)`'s `Z` as the WD +/// prescribes, with `Y = unitize(Z x X)` from the frame's own `X` (not the copy `MakeOrthoRep` +/// unitized again, whose last bits differ), turned a half-turn about `X` when the residue of +/// `(W - O) x X` points against that `Z`. A null `X`, an edge the WD's non-degeneracy rule +/// excludes, is used as it is. +fn apex(lo: V, hi: V, w: V, d: V) -> V { + let o = scale(add(lo, hi), 0.5); + let x = sub(lo, hi); + let x = unitize(x).unwrap_or(x); + let residue = cross(sub(w, o), x); + let (y, z) = unitize(residue) + .and_then(|z| Some((unitize(cross(z, x))?, z))) + .or_else(|| { + let z = make_ortho_rep_z(x)?; + let y = unitize(cross(z, x))?; + Some(if dot(residue, z) < 0.0 { (scale(y, -1.0), scale(z, -1.0)) } else { (y, z) }) + }) + .unwrap_or_default(); + sub(sub(add(o, scale(x, d[0])), scale(y, d[1])), scale(z, d[2])) +} + +/// Traversal state; `log` records every edge-count increment so a component can be rewound. +/// Everything it holds is checked against [`Arrays::limit`] after each triangle. +#[derive(Default)] +struct Walk { + pos: Vec, + tris: Vec<[u32; 3]>, + edges: HashMap<(u32, u32), u8>, + log: Vec<(u32, u32)>, + stack: Vec<(u32, u32, u32)>, + next: Option<(u32, u32, u32)>, + slot: usize, + ri: usize, + pi: usize, +} + +/// Where a component starts, to rewind to. +#[derive(Clone, Copy)] +struct Mark { + tri: usize, + pos: usize, + log: usize, + slot: usize, + ri: usize, + pi: usize, +} + +impl Walk { + /// Reserves room for `t` triangles, no more than `limit` elements' worth (two each). + fn new(t: usize, limit: usize) -> Self { + Walk { tris: Vec::with_capacity(t.min(limit / 2).min(1 << 20)), ..Walk::default() } + } + + fn mark(&self, tri: usize) -> Mark { + Mark { tri, pos: self.pos.len(), log: self.log.len(), slot: self.slot, ri: self.ri, pi: self.pi } + } + + fn rewind(&mut self, m: Mark) { + for k in self.log.drain(m.log..) { + if let Some(n) = self.edges.get_mut(&k) { + *n = n.saturating_sub(1); + } + } + self.pos.truncate(m.pos); + self.tris.truncate(m.tri); + self.stack.clear(); + self.next = None; + (self.slot, self.ri, self.pi) = (m.slot, m.ri, m.pi); + } + + /// One slot: `Some(v)` a reference to vertex `v`, `None` a new point. + fn take(&mut self, a: &Arrays<'_>) -> Result, &'static str> { + let flagged = *a.is_reference.get(self.slot).ok_or("slots run out")?; + self.slot = self.slot.saturating_add(1); + if !flagged { + return Ok(None); + } + let v = *a.references.get(self.ri).ok_or("references run out")?; + self.ri = self.ri.saturating_add(1); + self.get(v)?; + Ok(Some(v)) + } + + fn point(&mut self, a: &Arrays<'_>) -> Result { + let Some(&[x, y, z]) = a.points.get(self.pi..self.pi.saturating_add(3)) else { + return Err("points run out"); + }; + self.pi = self.pi.saturating_add(3); + let tol = a.tolerance; + Ok([x as f64 * tol, y as f64 * tol, z as f64 * tol]) + } + + fn get(&self, v: u32) -> Result { + self.pos.get(v as usize).copied().ok_or("a triangle refers to a vertex not yet decoded") + } + + fn push(&mut self, p: V) -> Result { + let id = u32::try_from(self.pos.len()).map_err(|_| "too many vertices")?; + self.pos.push(p); + Ok(id) + } + + /// The next triangle and its default fold: the seed, or the apex across the edge to + /// continue from. + fn triangle(&mut self, a: &Arrays<'_>) -> Result<([u32; 3], bool), &'static str> { + if let Some((p, q, w)) = self.next.take() { + if let Some(r) = self.take(a)? { + return Ok(([p, q, r], false)); + } + let (pp, qq, ww) = (self.get(p)?, self.get(q)?, self.get(w)?); + let (lo, hi) = if p < q { (pp, qq) } else { (qq, pp) }; + let fold = matches!(a.points.get(self.pi..self.pi.saturating_add(3)), Some(&[_, dy, 0]) if dy > 0); + let d = self.point(a)?; + let r = self.push(apex(lo, hi, ww, d))?; + return Ok(([p, q, r], fold)); + } + let mut v = [0u32; 3]; + for k in 0..3 { + let id = match self.take(a)? { + Some(r) => r, + None => { + let d = self.point(a)?; + let p = match k { + 0 => add(a.origin, d), + 1 => add(self.get(v[0])?, d), + _ => add(scale(add(self.get(v[0])?, self.get(v[1])?), 0.5), d), + }; + self.push(p)? + } + }; + if let Some(s) = v.get_mut(k) { + *s = id; + } + } + Ok((v, false)) + } + + /// Budget elements (8 bytes each) the walk's arrays hold, by length: growth can add up to + /// as much again in spare capacity. + fn held(&self) -> usize { + let pos = self.pos.len().saturating_mul(3); + let tris = self.tris.len().saturating_mul(2); + let edges = self.edges.len().saturating_mul(2); + let stack = self.stack.len().saturating_mul(2); + pos.saturating_add(tris).saturating_add(edges).saturating_add(self.log.len()).saturating_add(stack) + } + + fn closed(&self, e: (u32, u32, u32)) -> bool { + self.edges.get(&key(e.0, e.1)).copied().unwrap_or(0) >= 2 + } + + /// Adds one triangle; `flip` inverts its fold. + fn step(&mut self, a: &Arrays<'_>, status: i32, flip: bool) -> Result<(), &'static str> { + let ([ta, tb, tc], fold) = self.triangle(a)?; + for (x, y) in [(ta, tb), (tb, tc), (tc, ta)] { + let n = self.edges.entry(key(x, y)).or_insert(0); + *n = n.saturating_add(1); + let shared = *n; + self.log.push(key(x, y)); + if shared > 2 { + return Err("an edge is shared by more than two triangles"); + } + } + self.tris.push([ta, tb, tc]); + if self.held() > a.limit { + return Err("the rebuild needs more than the decoded-element allowance left"); + } + let (mut left, mut right) = ((tc, tb, ta), (ta, tc, tb)); + if fold != flip { + std::mem::swap(&mut left, &mut right); + } + let (taken, other) = if status & 2 != 0 { (left, right) } else { (right, left) }; + if status & 3 != 0 && self.closed(taken) && !self.closed(other) { + std::mem::swap(&mut left, &mut right); + } + match (status & 2 != 0, status & 1 != 0) { + (true, true) => { + self.next = Some(left); + self.stack.push(right); + } + (true, false) => self.next = Some(left), + (false, true) => self.next = Some(right), + (false, false) => {} + } + while self.next.is_none() { + let Some(e) = self.stack.pop() else { break }; + if !self.closed(e) { + self.next = Some(e); + } + } + Ok(()) + } + + /// Walks every component. One that fails is rewound and walked once more with the fold + /// inverted at its second triangle, so the walk is at most twice the triangle count. + fn walk(&mut self, a: &Arrays<'_>) -> Result<(), &'static str> { + let mut start = self.mark(0); + let mut flip_at = None; + let mut i = 0; + while i < a.triangles { + if self.next.is_none() && !(flip_at.is_some() && i == start.tri) { + start = self.mark(i); + flip_at = None; + } + let status = a.edge_status.get(i).copied().unwrap_or(0); + match self.step(a, status, flip_at == Some(i)) { + Ok(()) => i = i.saturating_add(1), + Err(e) => { + if flip_at.is_some() || i == start.tri { + return Err(e); + } + self.rewind(start); + flip_at = Some(start.tri.saturating_add(1)); + i = start.tri; + } + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/prc/src/tess/compressed/tests.rs b/crates/prc/src/tess/compressed/tests.rs new file mode 100644 index 0000000000..d174ff657e --- /dev/null +++ b/crates/prc/src/tess/compressed/tests.rs @@ -0,0 +1,299 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::*; + +const S: f64 = std::f64::consts::SQRT_2; + +fn run(points: &[i64], status: &[i32], t: usize, is_ref: &[bool], refs: &[u32]) -> Result { + rebuild(&Arrays { + tolerance: 0.5, + origin: [10.0, 0.0, 0.0], + points, + edge_status: status, + triangles: t, + is_reference: is_ref, + references: refs, + limit: usize::MAX, + }) +} + +fn close(a: V, b: V) -> bool { + (0..3).all(|i| (a[i] - b[i]).abs() < 1e-9) +} + +/// Seed `[0 1 2]`, then the apex across `[2 1]`: `X` runs from vertex 2 to vertex 1, and +/// each of `d`'s three components lands on its own axis with its own sign. Tolerance 0.5 +/// halves every step. +#[test] +fn a_seed_and_one_apex_rebuild() { + let pts = [0, 0, 0, 4, 0, 0, -2, 4, 0, 2, -6, 2]; + let m = run(&pts, &[2, 0, 0, 0, 0, 0], 2, &[false; 4], &[]).unwrap(); + assert_eq!(m.triangles, [[0, 1, 2], [2, 1, 3]]); + let p: Vec = m.positions.iter().map(|v| [v[0] - 10.0, v[1], v[2]]).collect(); + assert!(close(p[0], [0.0, 0.0, 0.0]) && close(p[1], [2.0, 0.0, 0.0])); + assert!(close(p[2], [0.0, 2.0, 0.0]), "{:?}", p[2]); + assert!(close(p[3], [1.0 + 2.0 * S, 1.0 + S, -1.0]), "{:?}", p[3]); +} + +/// The walk stops once its arrays would hold more than `limit` elements: the seed-and-apex +/// pair ends holding four vertices (12), two triangles (4), five edges (10) and six logged +/// edges (6), 32 in all. +#[test] +fn a_walk_past_its_limit_stops() { + let pts = [0, 0, 0, 4, 0, 0, -2, 4, 0, 2, -6, 2]; + let with = |limit| { + rebuild(&Arrays { + tolerance: 0.5, + origin: [0.0; 3], + points: &pts, + edge_status: &[2, 0, 0, 0, 0, 0], + triangles: 2, + is_reference: &[false; 4], + references: &[], + limit, + }) + }; + assert_eq!(with(32).unwrap().triangles.len(), 2); + assert_eq!(with(31).unwrap_err(), "the rebuild needs more than the decoded-element allowance left"); +} + +/// The triangles reserved up front fit the limit too. +#[test] +fn the_walk_reserves_within_its_limit() { + assert!(Walk::new(1 << 20, 10).tris.capacity() < 1 << 20); + assert!(Walk::new(1 << 20, 10).tris.capacity() <= 5); + assert!(Walk::new(4, usize::MAX).tris.capacity() >= 4); +} + +/// Bit 0 alone continues across `[A C]`, keeping `W = B`. +#[test] +fn the_right_edge_is_entered_from_a_to_c() { + let pts = [0, 0, 0, 4, 0, 0, -2, 4, 0, 0, -2, 0]; + let m = run(&pts, &[1, 0, 0, 0, 0, 0], 2, &[false; 4], &[]).unwrap(); + assert_eq!(m.triangles[1], [0, 2, 3]); +} + +/// A reference slot reuses a vertex; a closed pushed edge is skipped and the next component +/// starts from a fresh seed. +#[test] +fn references_and_components() { + let pts = [0; 9 + 9]; + let is_ref = [false, false, false, true, false, false, false]; + let m = run(&pts, &[3, 0, 0, 0, 0, 0, 0, 0, 0], 3, &is_ref, &[0]).unwrap(); + assert_eq!(m.triangles, [[0, 1, 2], [2, 1, 0], [3, 4, 5]]); +} + +/// A double-sided panel: four coplanar vertices, four triangles. The second triangle's apex +/// folds back onto the seed's side, so its own continuation takes `[A C]` first; the last two +/// apexes are the seed's first two vertices. +#[test] +fn a_folded_apex_swaps_its_continuation() { + let is_ref = [false, false, false, false, true, true]; + let status = [3, 3, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + let m = run(&[0, 0, 0, 4, 0, 0, -2, 4, 0, 1, 3, 0], &status, 4, &is_ref, &[0, 1]).unwrap(); + assert_eq!(m.triangles, [[0, 1, 2], [2, 1, 3], [2, 3, 0], [0, 3, 1]]); + // The same panel unfolded is rescued by the component retry. + let m = run(&[0, 0, 0, 4, 0, 0, -2, 4, 0, 1, -3, 0], &status, 4, &is_ref, &[0, 1]).unwrap(); + assert_eq!(m.triangles, [[0, 1, 2], [2, 1, 3], [2, 3, 0], [0, 3, 1]]); +} + +/// The fourth apex `(1, 3, 0)` lies in its parent's plane on the parent's side, so its +/// triangle folds and continues across `[A C]` to the reference. +#[test] +fn a_fold_past_the_retry_point_is_detected() { + let pts = [0, 0, 0, 4, 0, 0, -2, 4, 0, 0, -3, 1, 1, 3, 0]; + let is_ref = [false, false, false, false, false, true]; + let m = run(&pts, &[3, 3, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0], 4, &is_ref, &[0]).unwrap(); + assert_eq!(m.triangles[3], [3, 4, 0]); +} + +/// A panel whose fold apex is a reference carries no fold signal: the unfolded walk closes an +/// edge it still needs, so the component is rewound and walked again folded at its second +/// triangle. +#[test] +fn a_reference_fold_is_found_by_retrying_the_component() { + let pts: Vec = (1..=30).collect(); + let mut is_ref = vec![false; 9]; + is_ref.extend([true, true, false, true, true, true]); + let mut status = vec![0; 21]; + status[3..7].copy_from_slice(&[3, 3, 2, 0]); + let m = run(&pts, &status, 7, &is_ref, &[0, 3, 6, 0, 3]).unwrap(); + assert_eq!(m.triangles[3..], [[0, 3, 9], [9, 3, 6], [9, 6, 0], [0, 6, 3]]); +} + +/// A tetrahedron closed by two references: after `[3 1 0]` the continuation edge `[0 1]` is +/// already shared twice, so the walk takes the open `[3 0]` instead. +#[test] +fn a_closed_continuation_edge_gives_way_to_the_open_one() { + let pts = [0, 0, 0, 4, 0, 0, -2, 4, 0, 0, 0, 4]; + let mut status = [0; 12]; + status[..3].copy_from_slice(&[2, 2, 2]); + let mut is_ref = [false; 6]; + is_ref[4..].copy_from_slice(&[true, true]); + let m = run(&pts, &status, 4, &is_ref, &[0, 2]).unwrap(); + assert_eq!(m.triangles, [[0, 1, 2], [2, 1, 3], [3, 1, 0], [3, 0, 2]]); +} + +#[test] +fn arrays_that_do_not_fit_are_refused() { + let pts = [0; 9]; + let ok = [false; 3]; + assert!(run(&pts, &[0, 0, 0], 1, &ok, &[]).is_ok()); + assert!(run(&pts, &[0], 1, &ok, &[]).is_err(), "T form"); + assert!(run(&[0; 12], &[0, 0, 0], 1, &ok, &[]).is_err(), "points left"); + assert!(run(&[0; 6], &[0, 0, 0], 1, &ok, &[]).is_err(), "points short"); + assert!(run(&pts, &[0, 0, 0], 1, &[false; 4], &[]).is_err(), "slots left"); + assert!(run(&[0; 6], &[0, 0, 0], 1, &[false, false, true], &[7]).is_err(), "forward reference"); + assert!(run(&[0; 9], &[0, 0, 0], 1, &[false, false, false, true], &[0]).is_err(), "references left"); + // A third triangle on the seed's edge [0 1], each apex a reference. + let three = [false, false, false, true, true]; + let shared = run(&[0; 9], &[2, 2, 0, 0, 0, 0, 0, 0, 0], 3, &three, &[0, 0]).map(|_| ()); + assert_eq!(shared, Err("an edge is shared by more than two triangles")); +} + +/// `PrcPt::Length` rounds the squared length to `f32` and takes Newton's root from a halved +/// exponent, which can settle an ulp off the correctly rounded root; `Unitize` refuses +/// lengths below `FLT_EPSILON` [WD 12.3]. +#[test] +fn length_and_unitize_follow_the_wd_pseudocode() { + assert_eq!(wd_length([3.0, 4.0, 0.0]), Some(5.0)); + let newton = f64::from_bits(S.to_bits() - 1); + assert_eq!(wd_length([1.0, 1.0, 0.0]), Some(newton)); + let long = 1.0 + 2f64.powi(-30); + assert_eq!(wd_length([long, 0.0, 0.0]), Some(1.0)); + assert_eq!(unitize([0.0, long, 0.0]), Some([0.0, long, 0.0])); + assert_eq!(unitize([1e-8, 0.0, 0.0]), None); + assert_eq!(unitize([0.0; 3]), None); +} + +/// A `W` on the edge's line leaves `Z` null, so the frame comes from `MakeOrthoRep(X)`: `Z` +/// from `X x (0, 1, 0)`, or from `X x (1, 0, 0)` when `X` is along `Y` [WD 7.8.9.2, 12.3]. +#[test] +fn a_null_z_takes_the_ortho_rep_of_x() { + let d = [1.0, 2.0, 3.0]; + assert_eq!(apex([0.0; 3], [-2.0, 0.0, 0.0], [5.0, 0.0, 0.0], d), [0.0, -2.0, -3.0]); + assert_eq!(apex([0.0; 3], [0.0, -2.0, 0.0], [0.0, 5.0, 0.0], d), [-2.0, 0.0, 3.0]); +} + +/// A degenerate frame is turned a half-turn about `X` when the sub-epsilon residue of +/// `(W - O) x X` points against `MakeOrthoRep`'s `Z`, and kept when it points along it. +#[test] +fn a_degenerate_frame_follows_its_residue() { + let (lo, hi, d) = ([0.0; 3], [-2.0, 0.0, 0.0], [1.0, 2.0, 3.0]); + assert_eq!(apex(lo, hi, [5.0, 1e-9, 0.0], d), [0.0, 2.0, 3.0]); + assert_eq!(apex(lo, hi, [5.0, -1e-9, 0.0], d), [0.0, -2.0, -3.0]); +} + +/// A degenerate frame's `Y` comes from the frame's own `X`, whose length `unitize` left off by +/// a few 1e-8, not from the copy `MakeOrthoRep` unitized again: the two differ by that much, +/// and the walk would carry the difference into later apexes. +#[test] +fn a_degenerate_frame_takes_y_from_its_own_x() { + let (lo, hi) = ([0.1, 0.2, 0.3], [0.7, -0.4, 0.9]); + let o = scale(add(lo, hi), 0.5); + let x = unitize(sub(lo, hi)).unwrap(); + let w = add(o, scale(x, 0.37)); + let d = [1.0, 2.0, 3.0]; + let z = make_ortho_rep_z(x).unwrap(); + let p = apex(lo, hi, w, d); + let own = unitize(cross(z, x)).unwrap(); + let again = unitize(cross(z, unitize(x).unwrap())).unwrap(); + assert_ne!(own, again, "the probe must tell the two Ys apart"); + let sign = if dot(cross(sub(w, o), x), z) < 0.0 { -1.0 } else { 1.0 }; + let expect = |y: V| sub(sub(add(o, scale(x, d[0])), scale(y, sign * d[1])), scale(z, sign * d[2])); + assert_eq!(p, expect(own)); + assert_ne!(p, expect(again)); +} + +/// A strip of thin, non-planar triangles compressed by an encoder written out from the WD: +/// each point is expressed in the frame of the working mesh, rounded to the tolerance and +/// reinjected [WD 7.8.9, 7.8.9.2], with `PrcPt::Length` and `Unitize` transcribed from the +/// pseudocode [WD 12.3] as read. The decoder must land exactly on that working mesh, which +/// keeps every vertex within the rounding bound of its source. +#[test] +fn a_strip_decodes_onto_the_encoders_working_mesh() { + fn length(v: V) -> f64 { + let f = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]) as f32; + let mut bits = f.to_bits(); + let exponent = (bits >> 23) & 0xff; + if exponent > 127 { + bits = (bits & 0x807f_ffff) | (((exponent - 127) / 2 + 127) << 23); + } + let (squared, mut x0) = (f64::from(f), f64::from(f32::from_bits(bits))); + for _ in 0..100 { + let xi = 0.5 * (x0 + squared / x0); + if xi == x0 { + return xi; + } + x0 = xi; + } + -1.0 + } + fn unitized(v: V) -> V { + let l = length(v); + assert!(l >= f64::from(f32::EPSILON), "null axis"); + [v[0] / l, v[1] / l, v[2] / l] + } + let (tol, n) = (1e-4, 48); + let mut state = 0x9e37_79b9_7f4a_7c15_u64; + let mut jitter = |scale: f64| { + state = state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); + ((state >> 11) as f64 / (1u64 << 53) as f64 - 0.5) * scale + }; + // Two rails 4 apart, 0.05 between a rail's points, 0.02 of relief. + let source: Vec = (0..n) + .map(|i| { + let (along, rail) = (i as f64 * 0.025, (i % 2) as f64 * 4.0); + [along + jitter(0.01), rail + jitter(0.01), jitter(0.02)] + }) + .collect(); + let round = |v: f64| (v / tol).round() as i64; + let (mut points, mut work) = (Vec::new(), Vec::::new()); + for (i, target) in source.iter().take(3).enumerate() { + let base = match i { + 0 => [0.0; 3], + 1 => work[0], + _ => scale(add(work[0], work[1]), 0.5), + }; + let d = [0, 1, 2].map(|c| round(target[c] - base[c])); + points.extend(d); + work.push(add(base, d.map(|c| c as f64 * tol))); + } + // Triangle k enters across vertices k and k + 1, opposite k - 1, and continues right on + // odd k, left on even k, unless it folds. + let mut status = vec![0; 3 * (n - 2)]; + status[0] = 2; + for k in 1..n - 2 { + let (lo, hi, w) = (work[k], work[k + 1], work[k - 1]); + let o = scale(add(hi, lo), 0.5); + let x = unitized(sub(lo, hi)); + let z = unitized(cross(sub(w, o), x)); + let y = unitized(cross(z, x)); + let r = sub(source[k + 2], o); + let d = [round(dot(r, x)), round(-dot(r, y)), round(-dot(r, z))]; + points.extend(d); + let [dx, dy, dz] = d.map(|c| c as f64 * tol); + work.push(sub(sub(add(o, scale(x, dx)), scale(y, dy)), scale(z, dz))); + if k < n - 3 { + let fold = d[2] == 0 && d[1] > 0; + status[k] = if (k % 2 == 1) != fold { 1 } else { 2 }; + } + } + let m = rebuild(&Arrays { + tolerance: tol, + origin: [0.0; 3], + points: &points, + edge_status: &status, + triangles: n - 2, + is_reference: &vec![false; n], + references: &[], + limit: usize::MAX, + }) + .unwrap(); + assert_eq!(m.triangles[1..4], [[2, 1, 3], [2, 3, 4], [4, 3, 5]]); + assert_eq!(m.positions, work); + for (p, s) in m.positions.iter().zip(&source) { + let off = dot(sub(*p, *s), sub(*p, *s)).sqrt() / tol; + assert!(off < 0.87, "{off} tolerances off"); + } +} diff --git a/crates/prc/src/tess/tests.rs b/crates/prc/src/tess/tests.rs index 26219f2865..253e1c9d5e 100644 --- a/crates/prc/src/tess/tests.rs +++ b/crates/prc/src/tess/tests.rs @@ -529,12 +529,281 @@ fn a_compressed_mesh_is_read_to_its_end() { let schema = Schema::default(); let mut ctx = Ctx::new(BitReader::new(&bytes), &schema, v); let t = ctx.file_structure_tessellation().unwrap(); - assert!(matches!(t[..], [Tessellation::Compressed { triangles: 1, .. }, Tessellation::Mesh(_)]), "full={full} v={v}: {t:?}"); assert_eq!(ctx.r.position(), w.len(), "full={full} v={v}"); + assert!(matches!(t[1], Tessellation::Mesh(_)), "full={full} v={v}: {t:?}"); + // Stored normals and the one-status-per-triangle form are not rebuilt; the + // recalculated 3T seed is. + if full || !three_t { + assert!(matches!(t[0], Tessellation::Compressed { triangles: 1, .. }), "full={full} v={v}: {t:?}"); + } else { + let m = mesh(&t[0]); + assert_eq!((m.triangles.as_slice(), m.faces.as_slice()), ([[0, 1, 2]].as_slice(), std::slice::from_ref(&(0..1))), "v={v}"); + assert!(m.normals_recalculated && m.normals.is_empty()); + assert_eq!(m.triangle_styles, [TriangleStyle { style: 1, behaviour: 0 }]); + // Each point component is 1, in units of the 0.001 tolerance, from the origin 0. + let want = [0.001, 0.002, 0.0025]; + assert!(m.positions.iter().zip(want).all(|(p, c)| p.iter().all(|x| (x - c).abs() < 1e-12)), "{:?}", m.positions); + } } } } +/// A `TESS_3D_Compressed` (authoring version 8137, origin 0) whose normals are recalculated, +/// with `points` in units of `tolerance`, the 3T `status`, each triangle's face, the slots' +/// reference flags, the references and one line attribute per face; every other optional +/// branch absent. +struct Packed<'a> { + tolerance: f64, + points: &'a [i64], + status: &'a [u8], + face_of: &'a [u32], + /// The width of each face index; 8 unless an index needs more. + face_bits: u32, + is_ref: &'a [bool], + refs: &'a [u32], + line_attributes: &'a [u16], +} + +/// A `CompressedIndiceArray` [WD 11.9] of `bits`-wide deltas, `implicit` with no compressed +/// flag. +fn indices(w: &mut W, v: &[u32], implicit: bool, bits: u32) { + if !implicit { + w.bit(false); + } + w.uint(u32::try_from(v.len()).unwrap()); + for i in 0..v.len() { + w.put(if i == 0 { u64::from(bits) } else { 0 }, 8); + } + let mut prev = 0i64; + for &x in v { + let d = i64::from(x) - prev; + w.bit(d < 0).put(d.unsigned_abs(), bits - 1); + prev = i64::from(x); + } +} + +fn packed(w: &mut W, c: &Packed<'_>) { + w.uint(173).bit(false).bit(true).double(c.tolerance); + w.put(0, 32).put(0, 32).put(0, 32); + // point_array: 8-bit sign-magnitude integers. + w.bit(false).uint(u32::try_from(c.points.len()).unwrap()); + for _ in c.points { + w.put(8, 8); + } + for &p in c.points { + w.bit(p < 0).put(p.unsigned_abs(), 7); + } + w.bit(false).uint(u32::try_from(c.status.len()).unwrap()); + for &e in c.status { + w.put(u64::from(e), 8); + } + indices(w, c.face_of, false, c.face_bits); + w.uint(u32::try_from(c.is_ref.len()).unwrap()); + for &b in c.is_ref { + w.bit(b); + } + assert!(c.refs.len() <= 3, "more than three references are Huffman-coded"); + indices(w, c.refs, true, 8); + // must_recalculate_normals, normal_is_reversed, crease angle, flags; no colours, no + // multiple line attributes. + w.bit(true); + for _ in c.face_of { + w.bit(false); + } + w.double(0.5).put(0, 8).bit(false).bit(false); + w.bit(false).uint(u32::try_from(c.line_attributes.len()).unwrap()); + for &a in c.line_attributes { + w.put(u64::from(a & 0xff), 8).put(u64::from(a >> 8), 8); + } + // No texture, no behaviours. + w.bit(true).bit(false); +} + +/// A double-sided panel of two faces packed in walk order, its fold apex and its last two +/// apexes references: rebuilt into a mesh grouped by face, each triangle styled by its face. +#[test] +fn a_recalculated_compressed_mesh_is_rebuilt_by_face() { + let mut b = W::default(); + packed( + &mut b, + &Packed { + tolerance: 0.5, + points: &[0, 0, 0, 4, 0, 0, -2, 4, 0, 1, 3, 0], + status: &[3, 3, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0], + face_of: &[1, 0, 1, 0], + face_bits: 8, + is_ref: &[false, false, false, false, true, true], + refs: &[0, 1], + line_attributes: &[3, 0], + }, + ); + let w = section(1, &b); + let bytes = w.bytes(); + let schema = Schema::default(); + let mut ctx = Ctx::new(BitReader::new(&bytes), &schema, 8137); + let t = ctx.file_structure_tessellation().unwrap(); + assert_eq!(ctx.r.position(), w.len()); + let m = mesh(&t[0]); + // Walk order [0 1 2] [2 1 3] [2 3 0] [0 3 1]; faces 1, 0, 1, 0. + assert_eq!(m.triangles, [[2, 1, 3], [0, 3, 1], [0, 1, 2], [2, 3, 0]]); + assert_eq!(m.faces, [0..2, 2..4]); + assert_eq!(m.positions[..3], [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [0.0, 2.0, 0.0]]); + let style = |style| TriangleStyle { style, behaviour: 0 }; + assert_eq!(m.triangle_styles, [style(3), style(3), style(0), style(0)]); + assert!(m.normals_recalculated); +} + +/// Arrays the walk cannot fit stay a [`Tessellation::Compressed`], read to their end. +#[test] +fn a_compressed_mesh_that_does_not_fit_is_left_compressed() { + let mut b = W::default(); + // One point too many for a lone seed. + let c = Packed { + tolerance: 0.5, + points: &[0; 12], + status: &[0, 0, 0], + face_of: &[0], + face_bits: 8, + is_ref: &[false; 3], + refs: &[], + line_attributes: &[0], + }; + packed(&mut b, &c); + let w = section(1, &b); + let t = decode(&w, &Schema::default(), 8137).unwrap(); + assert!(matches!(t[..], [Tessellation::Compressed { triangles: 1, .. }]), "{t:?}"); +} + +/// A face index near `u32::MAX` claims four billion faces: once the triangles rebuild, they are +/// charged before any face range is made, so the decode stops at the element allowance instead +/// of allocating them. +#[test] +fn a_huge_face_index_is_charged_not_allocated() { + let mut b = W::default(); + let c = Packed { + tolerance: 0.5, + points: &[0; 9], + status: &[0, 0, 0], + face_of: &[u32::MAX - 1], + face_bits: 33, + is_ref: &[false; 3], + refs: &[], + line_attributes: &[], + }; + packed(&mut b, &c); + let r = decode(§ion(1, &b), &Schema::default(), 8137); + assert!(matches!(r, Err(PrcError::TooManyElements { .. })), "{r:?}"); +} + +/// The same huge face index on arrays the walk cannot fit charges nothing for triangles or +/// faces: the tessellation stays compressed and the file reads on. +#[test] +fn arrays_that_do_not_fit_spend_no_triangles_or_faces() { + let mut b = W::default(); + let c = Packed { + tolerance: 0.5, + points: &[0; 12], + status: &[0, 0, 0], + face_of: &[u32::MAX - 1], + face_bits: 33, + is_ref: &[false; 3], + refs: &[], + line_attributes: &[], + }; + packed(&mut b, &c); + let t = decode(§ion(1, &b), &Schema::default(), 8137).unwrap(); + assert!(matches!(t[..], [Tessellation::Compressed { triangles: 1, .. }]), "{t:?}"); +} + +/// The two-face panel of [`a_recalculated_compressed_mesh_is_rebuilt_by_face`], its second +/// reference `last_ref`. +fn panel(last_ref: u32) -> W { + let mut b = W::default(); + packed( + &mut b, + &Packed { + tolerance: 0.5, + points: &[0, 0, 0, 4, 0, 0, -2, 4, 0, 1, 3, 0], + status: &[3, 3, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0], + face_of: &[1, 0, 1, 0], + face_bits: 8, + is_ref: &[false, false, false, false, true, true], + refs: &[0, last_ref], + line_attributes: &[3, 0], + }, + ); + section(1, &b) +} + +/// A rebuilt mesh is charged its triangles, each with its style, and each face range at its +/// size; the same arrays left compressed (a reference to a vertex not yet made) are charged +/// neither. +#[test] +fn a_rebuilt_compressed_mesh_is_charged_its_triangles_and_faces() { + let fits = panel(1); + let t = decode(&fits, &Schema::default(), 8137).unwrap(); + assert_eq!(mesh(&t[0]).triangles.len(), 4); + let left = panel(9); + let t = decode(&left, &Schema::default(), 8137).unwrap(); + assert!(matches!(t[..], [Tessellation::Compressed { triangles: 4, .. }]), "{t:?}"); + assert_eq!(spent(&fits) - spent(&left), 4 * triangle_elements(false, 0, true) + 2 * RANGE_ELEMENTS); + assert_eq!(triangle_elements(false, 0, true), 3); + #[cfg(target_pointer_width = "64")] + assert_eq!(RANGE_ELEMENTS, 2); +} + +/// A rebuild whose walk would outgrow the allowance left keeps the arrays compressed, even +/// when the rebuilt mesh alone would have fit, and the stream reads on. +#[test] +fn a_compressed_rebuild_is_bounded_by_the_allowance_left() { + let fits = panel(1); + let bytes = fits.bytes(); + let schema = Schema::default(); + let read = |n| Ctx::new(BitReader::with_elements(&bytes, n), &schema, 8137).file_structure_tessellation(); + let arrays = spent(&panel(9)); + let mesh_alone = spent(&fits) - arrays; + let t = read(arrays + mesh_alone).unwrap(); + assert!(matches!(t[..], [Tessellation::Compressed { triangles: 4, .. }]), "{t:?}"); + assert!(matches!(read(crate::MAX_DECODED_ELEMENTS).unwrap()[..], [Tessellation::Mesh(_)])); +} + +/// A face's nonzero behaviour styles its triangles even when its line attribute is 0 (the +/// owner's graphics); all zero, the faces give no styles. +#[test] +fn a_compressed_face_behaviour_alone_styles_its_triangles() { + let attributes = |behaviours: Vec| FaceAttributes { multi: Vec::new(), line_attributes: vec![0], behaviours }; + let a = attributes(vec![0x30]); + assert!(a.styled(1)); + assert_eq!(a.styles(&[0, 0], 1), [TriangleStyle { style: 0, behaviour: 0x30 }; 2]); + assert!(!attributes(vec![0]).styled(1)); + assert!(!attributes(Vec::new()).styled(1)); +} + +fn rebuilt(triangles: usize) -> compressed::Rebuilt { + compressed::Rebuilt { positions: vec![[0.0; 3]; 3], triangles: (0..triangles).map(|i| [0, 1, u32::try_from(i).unwrap()]).collect() } +} + +/// Triangles are grouped by face in walk order within each, faces no triangle names (between +/// and after) get empty ranges, and each style moves with its triangle. +#[test] +fn rebuilt_triangles_are_grouped_by_face_with_their_styles() { + let face_of = [2, 0, 2, 1, 0]; + let m = by_face(rebuilt(5), &face_of, 5, &[]); + let apex: Vec = m.triangles.iter().map(|t| t[2]).collect(); + assert_eq!(apex, [1, 4, 3, 0, 2]); + assert_eq!(m.faces, [0..2, 2..3, 3..5, 5..5, 5..5]); + assert!(m.triangle_styles.is_empty()); + assert!(m.normals_recalculated); + + let style = |style| TriangleStyle { style, behaviour: u16::try_from(style).unwrap() }; + let styles: Vec = face_of.iter().map(|&f| style(f + 1)).collect(); + let m = by_face(rebuilt(5), &[2, 0, 2, 3, 0], 4, &styles); + let apex: Vec = m.triangles.iter().map(|t| t[2]).collect(); + assert_eq!(apex, [1, 4, 0, 2, 3]); + assert_eq!(m.faces, [0..2, 2..2, 2..4, 4..5]); + assert_eq!(m.triangle_styles, [style(1), style(1), style(3), style(3), style(2)]); +} + #[test] fn refusals_and_damage_are_errors() { // Edge statuses neither T nor 3T long. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c721ce18e9..e533ecf6c7 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -10062,8 +10062,8 @@ milestone = "M12" status = "partial" commands = [] tools = ["model_render"] -evidence = ["crates/automation/tests/it/model_render.rs::a_prc_model_is_drawn_over_the_background", "crates/automation/tests/it/model_render.rs::an_orbit_frames_the_model_from_a_named_view_and_its_moves", "crates/prc-render/src/nav/orbit_tests.rs::a_pressed_point_off_centre_keeps_its_place_as_the_view_turns", "crates/annot/src/model3d/tests.rs::a_3dref_leads_to_the_stream_it_names", "crates/prc-render/tests/model.rs::a_synthetic_prc_renders_its_nearer_triangle_over_the_farther"] -notes = "headless only: model_render draws a page's PRC model (through a /3DRef too) to a PNG, framed from a chosen direction, or by orbit navigation (named views about a chosen up axis, turn, zoom about a picture point, pan) from prc-render's GUI-free nav module, with headlight shading. Missing: the 3D view in the app (no activation, no on-screen navigation; nav is not wired to a viewer yet), walk and fly navigation, U3D (an error), the annotation's /3DV views, lighting schemes and /RM render modes, materials and textures, compressed tessellation, picking, cross sections, the model tree, and measurements" +evidence = ["crates/automation/tests/it/model_render.rs::a_prc_model_is_drawn_over_the_background", "crates/automation/tests/it/model_render.rs::an_orbit_frames_the_model_from_a_named_view_and_its_moves", "crates/prc-render/src/nav/orbit_tests.rs::a_pressed_point_off_centre_keeps_its_place_as_the_view_turns", "crates/annot/src/model3d/tests.rs::a_3dref_leads_to_the_stream_it_names", "crates/prc-render/tests/model.rs::a_synthetic_prc_renders_its_nearer_triangle_over_the_farther", "crates/prc-render/tests/model.rs::a_compressed_tessellation_is_rebuilt_and_rendered", "crates/automation/tests/it/model_render.rs::a_compressed_prc_model_is_rebuilt_and_drawn"] +notes = "headless only: model_render draws a page's PRC model (through a /3DRef too) to a PNG, framed from a chosen direction, or by orbit navigation (named views about a chosen up axis, turn, zoom about a picture point, pan) from prc-render's GUI-free nav module, with headlight shading. Missing: the 3D view in the app (no activation, no on-screen navigation; nav is not wired to a viewer yet), walk and fly navigation, U3D (an error), the annotation's /3DV views, lighting schemes and /RM render modes, materials and textures, compressed tessellations that store their normals (those whose normals are recalculated are rebuilt), picking, cross sections, the model tree, and measurements" [[feature]] id = "misc.3d-add"