Skip to main content

brep_ransac/
mesh.rs

1use crate::numerical;
2use crate::{RecognitionError, SurfaceHint, Vec3};
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5
6#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
7/// An indexed triangle mesh and optional recognition metadata.
8pub struct Mesh {
9    /// Vertex positions in model coordinates.
10    pub vertices: Vec<Vec3>,
11    /// Triangle vertex indices; winding defines the geometric normal.
12    pub triangles: Vec<[u32; 3]>,
13    /// Optional unit-normal hints, one per vertex. These improve curved-surface
14    /// fitting without replacing triangle winding as the topology authority.
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub vertex_normals: Option<Vec<Vec3>>,
17    #[serde(default)]
18    /// Versioned metadata associated with subsets of triangles.
19    pub source_metadata: Vec<SourceMetadata>,
20}
21
22impl Mesh {
23    /// Creates a mesh without vertex-normal hints or source metadata.
24    pub fn new(vertices: Vec<Vec3>, triangles: Vec<[u32; 3]>) -> Self {
25        Self {
26            vertices,
27            triangles,
28            vertex_normals: None,
29            source_metadata: Vec::new(),
30        }
31    }
32
33    /// Attach per-vertex normal hints. Length and values are validated by
34    /// [`Mesh::analyze`], where finite non-zero vectors are normalized.
35    pub fn with_vertex_normals(mut self, normals: Vec<Vec3>) -> Self {
36        self.vertex_normals = Some(normals);
37        self
38    }
39    /// Validates the mesh and computes geometry and adjacency information.
40    pub fn analyze(&self, options: &MeshAnalysisOptions) -> Result<AnalyzedMesh, RecognitionError> {
41        AnalyzedMesh::new(self, options)
42    }
43}
44
45/// Versioned sidecar metadata. Stable source IDs are deliberately distinct
46/// from sequential tessellation face indices.
47#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
48pub struct SourceMetadata {
49    /// Sidecar schema version. Version 1 is currently supported.
50    pub version: u32,
51    /// Mesh triangles to which this metadata applies.
52    pub triangle_indices: Vec<usize>,
53    /// Prior knowledge about the source surface.
54    pub hint: SurfaceHint,
55    /// Stable source-system face identifier, when available.
56    pub source_face_id: Option<u64>,
57    /// Human-readable source-system face name, when available.
58    pub source_face_name: Option<String>,
59    /// Stable source-system surface identifier, when available.
60    pub source_surface_id: Option<String>,
61    /// Source orientation relative to triangle winding, as `-1` or `1`.
62    pub orientation: Option<i8>,
63    /// Positive source-model geometric tolerance, when available.
64    pub source_tolerance: Option<f64>,
65}
66
67#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)]
68#[serde(default)]
69/// Controls mesh validation and feature-edge classification.
70pub struct MeshAnalysisOptions {
71    /// Triangles at or below this area are treated as degenerate.
72    pub minimum_triangle_area: f64,
73    /// Dihedral-angle threshold, in radians, for feature edges.
74    pub feature_angle: f64,
75}
76impl Default for MeshAnalysisOptions {
77    fn default() -> Self {
78        Self {
79            minimum_triangle_area: 0.0,
80            feature_angle: 30_f64.to_radians(),
81        }
82    }
83}
84
85#[derive(Clone, Debug, PartialEq)]
86/// Geometry and topology derived for one input triangle.
87pub struct TriangleData {
88    /// Indices of the triangle's three vertices.
89    pub vertices: [usize; 3],
90    /// Arithmetic mean of the three vertex positions.
91    pub centroid: Vec3,
92    /// Unit normal derived from winding, or zero for a rejected triangle.
93    pub normal: Vec3,
94    /// Triangle area, or zero when the triangle is degenerate.
95    pub area: f64,
96    /// Adjacent triangle across each directed edge, when manifold and present.
97    pub neighbors: [Option<usize>; 3],
98    /// Whether each edge is a boundary, non-manifold edge, or sharp feature.
99    pub feature_edges: [bool; 3],
100}
101
102#[derive(Clone, Debug)]
103/// A validated mesh with cached geometry, topology, and sampling weights.
104pub struct AnalyzedMesh {
105    /// Validated vertex positions.
106    pub vertices: Vec<Vec3>,
107    /// Normalized supplied vertex normals, when present on the source mesh.
108    pub vertex_normals: Option<Vec<Vec3>>,
109    /// Derived data for every input triangle.
110    pub triangles: Vec<TriangleData>,
111    /// Component-wise minimum of all vertex positions.
112    pub bbox_min: Vec3,
113    /// Component-wise maximum of all vertex positions.
114    pub bbox_max: Vec3,
115    /// Length of the axis-aligned bounding-box diagonal.
116    pub diagonal: f64,
117    /// One-third of each incident usable triangle's area, accumulated per vertex.
118    pub vertex_area_weights: Vec<f64>,
119    /// Validated source metadata copied from the input mesh.
120    pub source_metadata: Vec<SourceMetadata>,
121    /// Indices of triangles rejected as degenerate.
122    pub degenerate_triangles: Vec<usize>,
123}
124
125impl AnalyzedMesh {
126    /// Validates `mesh` and constructs its analyzed representation.
127    pub fn new(mesh: &Mesh, options: &MeshAnalysisOptions) -> Result<Self, RecognitionError> {
128        if !options.minimum_triangle_area.is_finite() || options.minimum_triangle_area < 0.0 {
129            return Err(RecognitionError::InvalidOptions(
130                "minimum_triangle_area must be finite and non-negative".into(),
131            ));
132        }
133        if !(0.0..=std::f64::consts::PI).contains(&options.feature_angle) {
134            return Err(RecognitionError::InvalidOptions(
135                "feature_angle must be finite and in [0, pi]".into(),
136            ));
137        }
138        if mesh.vertices.is_empty() {
139            return Err(RecognitionError::InvalidMesh("no vertices".into()));
140        }
141        if mesh.triangles.is_empty() {
142            return Err(RecognitionError::InvalidMesh("no triangles".into()));
143        }
144        if mesh.vertices.iter().any(|v| !v.is_finite()) {
145            return Err(RecognitionError::InvalidMesh(
146                "vertex coordinates must be finite".into(),
147            ));
148        }
149        let vertex_normals = match &mesh.vertex_normals {
150            None => None,
151            Some(normals) => {
152                if normals.len() != mesh.vertices.len() {
153                    return Err(RecognitionError::InvalidMesh(format!(
154                        "vertex-normal buffer has {} entries for {} vertices",
155                        normals.len(),
156                        mesh.vertices.len()
157                    )));
158                }
159                let mut normalized = Vec::with_capacity(normals.len());
160                for (index, &normal) in normals.iter().enumerate() {
161                    if !normal.is_finite() {
162                        return Err(RecognitionError::InvalidMesh(format!(
163                            "vertex normal {index} is not finite"
164                        )));
165                    }
166                    normalized.push(normal.normalized().ok_or_else(|| {
167                        RecognitionError::InvalidMesh(format!(
168                            "vertex normal {index} has zero length"
169                        ))
170                    })?);
171                }
172                Some(normalized)
173            }
174        };
175        let mut bbox_min = mesh.vertices[0];
176        let mut bbox_max = mesh.vertices[0];
177        for p in &mesh.vertices[1..] {
178            bbox_min.x = bbox_min.x.min(p.x);
179            bbox_min.y = bbox_min.y.min(p.y);
180            bbox_min.z = bbox_min.z.min(p.z);
181            bbox_max.x = bbox_max.x.max(p.x);
182            bbox_max.y = bbox_max.y.max(p.y);
183            bbox_max.z = bbox_max.z.max(p.z);
184        }
185        let diagonal = (bbox_max - bbox_min).length();
186        if diagonal <= 0.0 {
187            return Err(RecognitionError::InvalidMesh(
188                "zero-size bounding box".into(),
189            ));
190        }
191        let mut triangles = Vec::with_capacity(mesh.triangles.len());
192        let mut degenerate = Vec::new();
193        let mut vertex_area_weights = vec![0.0; mesh.vertices.len()];
194        for (id, raw) in mesh.triangles.iter().enumerate() {
195            let vi = raw.map(|x| x as usize);
196            if vi.iter().any(|&x| x >= mesh.vertices.len()) {
197                return Err(RecognitionError::InvalidMesh(format!(
198                    "triangle {id} has an out-of-range vertex"
199                )));
200            }
201            if vi[0] == vi[1] || vi[1] == vi[2] || vi[2] == vi[0] {
202                degenerate.push(id);
203                triangles.push(TriangleData {
204                    vertices: vi,
205                    centroid: Vec3::ZERO,
206                    normal: Vec3::ZERO,
207                    area: 0.0,
208                    neighbors: [None; 3],
209                    feature_edges: [true; 3],
210                });
211                continue;
212            }
213            let [a, b, c] = vi.map(|i| mesh.vertices[i]);
214            let ab = b - a;
215            let ac = c - a;
216            let bc = c - b;
217            let cross = ab.cross(ac);
218            let area = 0.5 * cross.length();
219            // A component's triangles must not become "degenerate" merely
220            // because an unrelated component makes the mesh-wide bounding box
221            // enormous. Bound cross-product uncertainty from this triangle's
222            // own edge scale and the coordinate cancellation involved in its
223            // vertex subtractions. This retains valid mixed-scale components
224            // while still rejecting geometry smaller than representable
225            // precision at a far world-space origin.
226            let edge_scale = ab.length().max(ac.length()).max(bc.length());
227            let coordinate_scale = [a, b, c]
228                .into_iter()
229                .map(|point| point.x.abs().max(point.y.abs()).max(point.z.abs()))
230                .fold(edge_scale, f64::max);
231            let subtraction_error = f64::EPSILON * coordinate_scale;
232            let machine_area_floor = numerical::mesh::MACHINE_AREA_FLOOR_MULTIPLIER
233                * (edge_scale * subtraction_error + subtraction_error * subtraction_error);
234            let area_floor = options.minimum_triangle_area.max(machine_area_floor);
235            if area <= area_floor {
236                degenerate.push(id);
237                triangles.push(TriangleData {
238                    vertices: vi,
239                    centroid: (a + b + c) / 3.0,
240                    normal: Vec3::ZERO,
241                    // Downstream topology and selection APIs use zero area as
242                    // the canonical marker for a rejected triangle.
243                    area: 0.0,
244                    neighbors: [None; 3],
245                    feature_edges: [true; 3],
246                });
247                continue;
248            }
249            let normal = cross / (2.0 * area);
250            for &v in &vi {
251                vertex_area_weights[v] += area / 3.0;
252            }
253            triangles.push(TriangleData {
254                vertices: vi,
255                centroid: (a + b + c) / 3.0,
256                normal,
257                area,
258                neighbors: [None; 3],
259                feature_edges: [true; 3],
260            });
261        }
262        if degenerate.len() == mesh.triangles.len() {
263            return Err(RecognitionError::DegenerateData(
264                "all triangles are degenerate".into(),
265            ));
266        }
267        let mut edges: BTreeMap<(usize, usize), Vec<(usize, usize)>> = BTreeMap::new();
268        for (tid, t) in triangles.iter().enumerate() {
269            if t.area <= 0.0 {
270                continue;
271            }
272            for edge in 0..3 {
273                let a = t.vertices[edge];
274                let b = t.vertices[(edge + 1) % 3];
275                edges
276                    .entry(if a < b { (a, b) } else { (b, a) })
277                    .or_default()
278                    .push((tid, edge));
279            }
280        }
281        for incidents in edges.values() {
282            if incidents.len() == 2 {
283                let (ta, ea) = incidents[0];
284                let (tb, eb) = incidents[1];
285                triangles[ta].neighbors[ea] = Some(tb);
286                triangles[tb].neighbors[eb] = Some(ta);
287                let cosine = triangles[ta]
288                    .normal
289                    .dot(triangles[tb].normal)
290                    .clamp(-1.0, 1.0);
291                let feature = cosine.acos() > options.feature_angle;
292                triangles[ta].feature_edges[ea] = feature;
293                triangles[tb].feature_edges[eb] = feature;
294            }
295        }
296        for metadata in &mesh.source_metadata {
297            if metadata.version != 1 {
298                return Err(RecognitionError::InvalidMesh(format!(
299                    "unsupported metadata version {}",
300                    metadata.version
301                )));
302            }
303            if metadata
304                .triangle_indices
305                .iter()
306                .any(|&t| t >= mesh.triangles.len())
307            {
308                return Err(RecognitionError::InvalidMesh(
309                    "metadata references an out-of-range triangle".into(),
310                ));
311            }
312            if metadata
313                .orientation
314                .is_some_and(|sense| !matches!(sense, -1 | 1))
315            {
316                return Err(RecognitionError::InvalidMesh(
317                    "metadata orientation must be -1 or +1".into(),
318                ));
319            }
320            if metadata
321                .source_tolerance
322                .is_some_and(|tolerance| !tolerance.is_finite() || tolerance <= 0.0)
323            {
324                return Err(RecognitionError::InvalidMesh(
325                    "metadata source_tolerance must be finite and positive".into(),
326                ));
327            }
328        }
329        Ok(Self {
330            vertices: mesh.vertices.clone(),
331            vertex_normals,
332            triangles,
333            bbox_min,
334            bbox_max,
335            diagonal,
336            vertex_area_weights,
337            source_metadata: mesh.source_metadata.clone(),
338            degenerate_triangles: degenerate,
339        })
340    }
341    /// Returns the indices of all triangles with positive analyzed area.
342    pub fn all_non_degenerate(&self) -> Vec<usize> {
343        self.triangles
344            .iter()
345            .enumerate()
346            .filter_map(|(i, t)| (t.area > 0.0).then_some(i))
347            .collect()
348    }
349    /// Checks that a selection is non-empty, in range, and contains usable geometry.
350    pub fn validate_selection(&self, ids: &[usize]) -> Result<(), RecognitionError> {
351        if ids.is_empty() {
352            return Err(RecognitionError::InvalidSelection(
353                "selection is empty".into(),
354            ));
355        }
356        if ids.iter().any(|&i| i >= self.triangles.len()) {
357            return Err(RecognitionError::InvalidSelection(
358                "triangle index out of range".into(),
359            ));
360        }
361        if ids.iter().all(|&i| self.triangles[i].area == 0.0) {
362            return Err(RecognitionError::DegenerateData(
363                "selection contains no usable triangles".into(),
364            ));
365        }
366        Ok(())
367    }
368    /// Checks that a vertex selection is non-empty, unique, in range, and
369    /// incident to usable mesh geometry.
370    pub fn validate_vertex_selection(&self, ids: &[usize]) -> Result<(), RecognitionError> {
371        if ids.is_empty() {
372            return Err(RecognitionError::InvalidSelection(
373                "vertex selection is empty".into(),
374            ));
375        }
376        if ids.iter().any(|&i| i >= self.vertices.len()) {
377            return Err(RecognitionError::InvalidSelection(
378                "vertex index out of range".into(),
379            ));
380        }
381        let mut unique = ids.to_vec();
382        unique.sort_unstable();
383        if unique.windows(2).any(|pair| pair[0] == pair[1]) {
384            return Err(RecognitionError::InvalidSelection(
385                "vertex selection contains duplicate indices".into(),
386            ));
387        }
388        if ids.iter().any(|&i| self.vertex_area_weights[i] == 0.0) {
389            return Err(RecognitionError::DegenerateData(
390                "vertex selection contains a vertex with no usable incident geometry".into(),
391            ));
392        }
393        Ok(())
394    }
395    /// Returns area weights for vertices incident to the selected usable triangles.
396    pub fn selected_vertex_weights(&self, ids: &[usize]) -> Vec<(usize, f64)> {
397        let mut weights = BTreeMap::<usize, f64>::new();
398        for &tid in ids {
399            let t = &self.triangles[tid];
400            if t.area > 0.0 {
401                for &v in &t.vertices {
402                    *weights.entry(v).or_default() += t.area / 3.0;
403                }
404            }
405        }
406        weights.into_iter().collect()
407    }
408    /// Returns the area-weighted centroid of selected triangles.
409    ///
410    /// Returns the zero vector when the selection has no positive-area triangle.
411    pub fn selection_centroid(&self, ids: &[usize]) -> Vec3 {
412        let mut sum = Vec3::ZERO;
413        let mut area = 0.0;
414        for &i in ids {
415            let t = &self.triangles[i];
416            sum += t.centroid * t.area;
417            area += t.area;
418        }
419        if area > 0.0 {
420            sum / area
421        } else {
422            Vec3::ZERO
423        }
424    }
425    /// Partitions selected usable triangles into edge-connected components.
426    ///
427    /// When `respect_features` is true, traversal does not cross feature edges.
428    pub fn connected_components(&self, ids: &[usize], respect_features: bool) -> Vec<Vec<usize>> {
429        let mut allowed = vec![false; self.triangles.len()];
430        for &i in ids {
431            if i < allowed.len() {
432                allowed[i] = true;
433            }
434        }
435        let mut seen = vec![false; self.triangles.len()];
436        let mut result = Vec::new();
437        for &seed in ids {
438            if seen[seed] || self.triangles[seed].area == 0.0 {
439                continue;
440            }
441            let mut stack = vec![seed];
442            seen[seed] = true;
443            let mut part = Vec::new();
444            while let Some(i) = stack.pop() {
445                part.push(i);
446                for edge in 0..3 {
447                    if respect_features && self.triangles[i].feature_edges[edge] {
448                        continue;
449                    }
450                    if let Some(n) = self.triangles[i].neighbors[edge] {
451                        if allowed[n] && !seen[n] {
452                            seen[n] = true;
453                            stack.push(n);
454                        }
455                    }
456                }
457            }
458            part.sort_unstable();
459            result.push(part);
460        }
461        result
462    }
463}
464
465// BREP private tests: ad6d57bb4d559e49