Skip to main content

brep_kernel/healing/
faceted_repair.rs

1use crate::mesh_weld::{edge_key, weld_vertex};
2use crate::topology::{
3    BrepSolid, CoedgeRecord, EdgeRecord, FaceRecord, LoopRecord, ShellRecord, VertexRecord,
4};
5use crate::{make_line, make_plane, tessellate_face, Mesh, TessellationOptions, Vec3};
6use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
7
8/// Convert an open exact assembly to a closed, welded triangular BREP.
9///
10/// This is a last-resort native recovery for self-intersection transition
11/// values where carrier selection leaves an otherwise useful surface mesh
12/// with boundary cycles. Exact assemblies never pass through this function.
13pub(crate) fn close_as_faceted_brep(
14    solid: &BrepSolid,
15    tolerance: f64,
16) -> Result<BrepSolid, String> {
17    let options = TessellationOptions {
18        slabs_per_span_u: 12,
19        steps_per_span_v: 12,
20    };
21    let mut mesh = Mesh::default();
22    let mut face_id = 0;
23    for face in solid.shells.iter().flat_map(|shell| &shell.faces) {
24        if let Ok(face_mesh) = tessellate_face(face, options, face_id) {
25            let base = (mesh.positions.len() / 3) as u32;
26            mesh.positions.extend(face_mesh.positions);
27            mesh.normals.extend(face_mesh.normals);
28            mesh.indices
29                .extend(face_mesh.indices.into_iter().map(|index| index + base));
30            mesh.face_ids.extend(face_mesh.face_ids);
31        }
32        face_id += 1;
33    }
34    if mesh.indices.is_empty() {
35        return Err("faceted shell repair could not tessellate any faces".into());
36    }
37    triangle_soup_to_faceted_brep(&mesh.positions, Some(&mesh.indices), tolerance.max(2e-3))
38}
39
40/// Build a faceted (planar-triangle) BREP solid from a triangle soup or an
41/// indexed triangle mesh — the STL/mesh import entry (§8.6 inverse: mesh →
42/// body). Vertices weld within `tolerance` (pass `<= 0` to derive it from the
43/// bounding-box diagonal), duplicate and degenerate triangles are dropped,
44/// surplus sheets at non-manifold edges are pruned, and remaining boundary
45/// cycles are capped with a fan so the result closes watertight whenever the
46/// input is close to a manifold. Validation is authoritative: an input too
47/// broken to close returns an error, never a silently-invalid solid.
48pub fn mesh_to_faceted_brep(
49    positions: &[f64],
50    indices: Option<&[u32]>,
51    tolerance: f64,
52) -> Result<BrepSolid, String> {
53    if positions.is_empty() || positions.len() % 3 != 0 {
54        return Err("mesh_to_faceted_brep: positions must be xyz triples".into());
55    }
56    let weld_tolerance = derived_weld_tolerance(positions, tolerance);
57    triangle_soup_to_faceted_brep(positions, indices, weld_tolerance)
58}
59
60/// A non-positive request derives the weld distance from the bounding-box
61/// diagonal, so the mesh repair and anything rebuilt from its output share
62/// one vertex identity.
63fn derived_weld_tolerance(positions: &[f64], tolerance: f64) -> f64 {
64    if tolerance > 0.0 && tolerance.is_finite() {
65        return tolerance;
66    }
67    let mut low = [f64::INFINITY; 3];
68    let mut high = [f64::NEG_INFINITY; 3];
69    for point in positions.chunks_exact(3) {
70        for axis in 0..3 {
71            low[axis] = low[axis].min(point[axis]);
72            high[axis] = high[axis].max(point[axis]);
73        }
74    }
75    let diagonal = (0..3)
76        .map(|axis| (high[axis] - low[axis]).powi(2))
77        .sum::<f64>()
78        .sqrt();
79    (diagonal * 1e-6).max(1e-12)
80}
81
82/// What [`repair_triangle_soup`] had to change to close the input.
83///
84/// Every count is of WELDED triangles, so a mesh whose only defect is
85/// duplicated vertices reports zeroes: welding alone made it a closed
86/// two-manifold.
87#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
88pub struct MeshRepairReport {
89    /// Vertices after the weld.
90    pub welded_vertices: usize,
91    /// Triangles after the repair.
92    pub triangles: usize,
93    /// Welded triangles dropped as degenerate (two corners welded together)
94    /// or as a duplicate of a triangle already kept.
95    pub dropped_triangles: usize,
96    /// Surplus coincident sheets pruned at edges used by three or more
97    /// triangles.
98    pub pruned_triangles: usize,
99    /// Triangles flipped so every edge-connected patch is wound one way
100    /// (an inverted end cap, say), and the patch encloses positive volume.
101    pub reoriented_triangles: usize,
102    /// Boundary cycles closed with a fan.
103    pub capped_loops: usize,
104    /// Triangles the caps added.
105    pub cap_triangles: usize,
106}
107
108impl MeshRepairReport {
109    /// Whether the repair changed the welded triangle set at all.
110    pub fn changed(&self) -> bool {
111        self.dropped_triangles != 0
112            || self.pruned_triangles != 0
113            || self.reoriented_triangles != 0
114            || self.capped_loops != 0
115    }
116}
117
118/// The mesh half of [`mesh_to_faceted_brep`]: weld, drop degenerate and
119/// duplicate triangles, prune surplus sheets at non-manifold edges, and cap
120/// the remaining boundary cycles with a fan — returning the repaired triangle
121/// soup instead of a BREP.
122///
123/// This is what lets a defective mesh still reach the ANALYTIC import lanes.
124/// They all require a closed two-manifold, and the defect that costs a part
125/// every recognized cylinder is usually a handful of triangles far away from
126/// any of them; running them on this output instead of on the raw file keeps
127/// the surfaces a repairable mesh can still support. Pass `weld_tolerance`
128/// `<= 0` to derive it from the bounding-box diagonal. The result is not
129/// guaranteed closed — an input too broken to close comes back as whatever
130/// the repair could reach, and the caller must re-test it.
131pub fn repair_triangle_soup(
132    positions: &[f64],
133    indices: Option<&[u32]>,
134    weld_tolerance: f64,
135) -> Result<(Vec<f64>, Vec<u32>, MeshRepairReport), String> {
136    if positions.is_empty() || !positions.len().is_multiple_of(3) {
137        return Err("repair_triangle_soup: positions must be xyz triples".into());
138    }
139    let weld_tolerance = derived_weld_tolerance(positions, weld_tolerance);
140    let (points, triangles, report) = repair_soup(positions, indices, weld_tolerance)?;
141    let flat = points
142        .iter()
143        .flat_map(|point| [point.x, point.y, point.z])
144        .collect::<Vec<_>>();
145    let flat_indices = triangles
146        .iter()
147        .flatten()
148        .map(|&index| index as u32)
149        .collect::<Vec<_>>();
150    Ok((flat, flat_indices, report))
151}
152
153/// Shared weld → prune → cap → build pipeline. `indices` of `None` treats
154/// `positions` as a raw soup (three consecutive vertices per triangle, the
155/// binary-STL layout).
156fn triangle_soup_to_faceted_brep(
157    positions: &[f64],
158    indices: Option<&[u32]>,
159    weld_tolerance: f64,
160) -> Result<BrepSolid, String> {
161    let (points, triangles, _) = repair_soup(positions, indices, weld_tolerance)?;
162    build_faceted_brep(&points, &triangles)
163}
164
165/// The weld → dedup → prune → cap phase, shared by the BREP builder above and
166/// by the public [`repair_triangle_soup`].
167fn repair_soup(
168    positions: &[f64],
169    indices: Option<&[u32]>,
170    weld_tolerance: f64,
171) -> Result<(Vec<Vec3>, Vec<[usize; 3]>, MeshRepairReport), String> {
172    let mut report = MeshRepairReport::default();
173    let mut points = Vec::<Vec3>::new();
174    let mut buckets = HashMap::<[i64; 3], Vec<usize>>::default();
175    let mut mesh_to_welded = Vec::with_capacity(positions.len() / 3);
176    for position in positions.chunks_exact(3) {
177        mesh_to_welded.push(weld_vertex(
178            Vec3::new(position[0], position[1], position[2]),
179            weld_tolerance,
180            &mut points,
181            &mut buckets,
182        ));
183    }
184    let sequential;
185    let indices = match indices {
186        Some(indices) => indices,
187        None => {
188            sequential = (0..mesh_to_welded.len() as u32).collect::<Vec<_>>();
189            &sequential
190        }
191    };
192    let mut triangles = Vec::<[usize; 3]>::new();
193    let mut seen = HashSet::<[usize; 3]>::default();
194    for triangle in indices.chunks_exact(3) {
195        if triangle
196            .iter()
197            .any(|index| *index as usize >= mesh_to_welded.len())
198        {
199            return Err("mesh_to_faceted_brep: triangle index outside positions".into());
200        }
201        let value = [
202            mesh_to_welded[triangle[0] as usize],
203            mesh_to_welded[triangle[1] as usize],
204            mesh_to_welded[triangle[2] as usize],
205        ];
206        if value[0] == value[1] || value[1] == value[2] || value[2] == value[0] {
207            report.dropped_triangles += 1;
208            continue;
209        }
210        let mut key = value;
211        key.sort_unstable();
212        if seen.insert(key) {
213            triangles.push(value);
214        } else {
215            report.dropped_triangles += 1;
216        }
217    }
218    if triangles.is_empty() {
219        return Err("mesh_to_faceted_brep: no non-degenerate triangles".into());
220    }
221
222    // Remove surplus coincident sheets at non-manifold triangle edges,
223    // retaining one use in each direction whenever possible.
224    loop {
225        let mut edge_uses = HashMap::<(usize, usize), Vec<(usize, bool)>>::default();
226        for (triangle_index, triangle) in triangles.iter().enumerate() {
227            for side in 0..3 {
228                let first = triangle[side];
229                let second = triangle[(side + 1) % 3];
230                edge_uses
231                    .entry(edge_key(first, second))
232                    .or_default()
233                    .push((triangle_index, first < second));
234            }
235        }
236        let Some(uses) = edge_uses.values().find(|uses| uses.len() > 2) else {
237            break;
238        };
239        let keep_forward = uses.iter().find(|(_, forward)| *forward).map(|use_| use_.0);
240        let keep_reverse = uses
241            .iter()
242            .find(|(_, forward)| !*forward)
243            .map(|use_| use_.0);
244        let keep = [keep_forward, keep_reverse]
245            .into_iter()
246            .flatten()
247            .collect::<HashSet<_>>();
248        let remove = uses
249            .iter()
250            .map(|use_| use_.0)
251            .filter(|index| !keep.contains(index))
252            .collect::<HashSet<_>>();
253        if remove.is_empty() {
254            break;
255        }
256        report.pruned_triangles += remove.len();
257        triangles = triangles
258            .into_iter()
259            .enumerate()
260            .filter_map(|(index, triangle)| (!remove.contains(&index)).then_some(triangle))
261            .collect();
262    }
263
264    report.reoriented_triangles = orient_consistently(&points, &mut triangles)?;
265
266    let mut directed_boundary = Vec::<(usize, usize)>::new();
267    let mut counts = HashMap::<(usize, usize), usize>::default();
268    for triangle in &triangles {
269        for side in 0..3 {
270            *counts
271                .entry(edge_key(triangle[side], triangle[(side + 1) % 3]))
272                .or_default() += 1;
273        }
274    }
275    for triangle in &triangles {
276        for side in 0..3 {
277            let first = triangle[side];
278            let second = triangle[(side + 1) % 3];
279            if counts[&edge_key(first, second)] == 1 {
280                directed_boundary.push((first, second));
281            }
282        }
283    }
284    while let Some((start, mut end)) = directed_boundary.pop() {
285        let mut loop_vertices = vec![start, end];
286        while end != start {
287            let Some(index) = directed_boundary
288                .iter()
289                .position(|(candidate_start, _)| *candidate_start == end)
290            else {
291                break;
292            };
293            let (_, next) = directed_boundary.swap_remove(index);
294            end = next;
295            if end != start {
296                loop_vertices.push(end);
297            }
298            if loop_vertices.len() > points.len() + 1 {
299                break;
300            }
301        }
302        if end != start || loop_vertices.len() < 3 {
303            continue;
304        }
305        let center = loop_vertices
306            .iter()
307            .fold(Vec3::default(), |sum, index| sum.add(points[*index]))
308            .scale(1.0 / loop_vertices.len() as f64);
309        let center_index = points.len();
310        points.push(center);
311        for index in 0..loop_vertices.len() {
312            let first = loop_vertices[index];
313            let second = loop_vertices[(index + 1) % loop_vertices.len()];
314            // Reverse the existing boundary direction on the new cap.
315            triangles.push([second, first, center_index]);
316        }
317        report.capped_loops += 1;
318        report.cap_triangles += loop_vertices.len();
319    }
320
321    report.welded_vertices = points.len();
322    report.triangles = triangles.len();
323    Ok((points, triangles, report))
324}
325
326/// Make the winding agree across every two-use edge, and return how many
327/// triangles were flipped.
328///
329/// A shell exported with a patch wound the wrong way (a pipe whose end caps
330/// face inward) reaches here with edges used TWICE in the SAME direction:
331/// neither the sheet prune (three or more uses) nor the cap (one use) sees
332/// them, and the builder's validator refuses the result with "coedges with the
333/// same sense". Each edge-connected patch is walked breadth-first from its
334/// lowest triangle, flipping a neighbour whose shared edge runs the same way.
335/// A patch that needed any flip then takes the sign that makes its enclosed
336/// volume positive (measured about its own centroid, so an open patch reads
337/// the same wherever it sits), so the repaired shell ends outward whichever
338/// side the walk started on. A patch that was already consistent is left
339/// exactly as it came — its winding, inward or not, is the file's.
340///
341/// A patch whose walk meets itself with the opposite parity (a Möbius band,
342/// or a shell glued to itself) has no consistent winding; it is refused by
343/// name rather than guessed.
344fn orient_consistently(points: &[Vec3], triangles: &mut [[usize; 3]]) -> Result<usize, String> {
345    let mut edge_uses = HashMap::<(usize, usize), Vec<usize>>::default();
346    for (triangle_index, triangle) in triangles.iter().enumerate() {
347        for side in 0..3 {
348            edge_uses
349                .entry(edge_key(triangle[side], triangle[(side + 1) % 3]))
350                .or_default()
351                .push(triangle_index);
352        }
353    }
354    let runs_forward = |triangle: &[usize; 3], first: usize, second: usize| {
355        (0..3).any(|side| triangle[side] == first && triangle[(side + 1) % 3] == second)
356    };
357
358    let mut flip = vec![None::<bool>; triangles.len()];
359    let mut flipped = 0;
360    for seed in 0..triangles.len() {
361        if flip[seed].is_some() {
362            continue;
363        }
364        flip[seed] = Some(false);
365        let mut patch = vec![seed];
366        let mut queue = std::collections::VecDeque::from([seed]);
367        while let Some(current) = queue.pop_front() {
368            let current_flip = flip[current] == Some(true);
369            for side in 0..3 {
370                let first = triangles[current][side];
371                let second = triangles[current][(side + 1) % 3];
372                let uses = &edge_uses[&edge_key(first, second)];
373                if uses.len() != 2 {
374                    continue;
375                }
376                let neighbour = if uses[0] == current { uses[1] } else { uses[0] };
377                // After its own flip, `current` runs first→second exactly when
378                // it is not flipped; the neighbour must then run second→first.
379                let neighbour_same_way = runs_forward(&triangles[neighbour], first, second);
380                let wanted = neighbour_same_way != current_flip;
381                match flip[neighbour] {
382                    None => {
383                        flip[neighbour] = Some(wanted);
384                        patch.push(neighbour);
385                        queue.push_back(neighbour);
386                    }
387                    Some(assigned) if assigned != wanted => {
388                        return Err(format!(
389                            "mesh_to_faceted_brep: the shell through triangle {seed} cannot be \
390                             consistently oriented (non-orientable at the edge ({}, {}, {})–({}, {}, {}))",
391                            points[first].x, points[first].y, points[first].z,
392                            points[second].x, points[second].y, points[second].z
393                        ));
394                    }
395                    Some(_) => {}
396                }
397            }
398        }
399        if patch.iter().all(|&index| flip[index] == Some(false)) {
400            continue;
401        }
402        for &index in &patch {
403            if flip[index] == Some(true) {
404                triangles[index].swap(1, 2);
405            }
406        }
407        let centroid = patch
408            .iter()
409            .flat_map(|&index| triangles[index])
410            .fold(Vec3::default(), |sum, corner| sum.add(points[corner]))
411            .scale(1.0 / (3 * patch.len()) as f64);
412        let volume = patch
413            .iter()
414            .map(|&index| {
415                let [a, b, c] = triangles[index].map(|corner| points[corner].sub(centroid));
416                a.dot(b.cross(c))
417            })
418            .sum::<f64>();
419        if volume < 0.0 {
420            for &index in &patch {
421                triangles[index].swap(1, 2);
422            }
423            flipped += patch.iter().filter(|&&index| flip[index] == Some(false)).count();
424        } else {
425            flipped += patch.iter().filter(|&&index| flip[index] == Some(true)).count();
426        }
427    }
428    Ok(flipped)
429}
430
431/// One planar face per repaired triangle, validated.
432fn build_faceted_brep(points: &[Vec3], triangles: &[[usize; 3]]) -> Result<BrepSolid, String> {
433    let vertices = points
434        .iter()
435        .enumerate()
436        .map(|(index, point)| VertexRecord {
437            id: index as u64 + 1,
438            point: *point,
439        })
440        .collect::<Vec<_>>();
441    let mut edges = Vec::<EdgeRecord>::new();
442    let mut edge_ids = HashMap::<(usize, usize), u64>::default();
443    let mut faces = Vec::<FaceRecord>::new();
444    let mut next_id = vertices.len() as u64 + 1;
445    for triangle in triangles.iter().copied() {
446        let a = points[triangle[0]];
447        let b = points[triangle[1]];
448        let c = points[triangle[2]];
449        let u = b.sub(a);
450        let u_length = u.length();
451        if u_length <= 1e-10 {
452            continue;
453        }
454        let u_direction = u.scale(1.0 / u_length);
455        let c_delta = c.sub(a);
456        let c_u = c_delta.dot(u_direction);
457        let v = c_delta.sub(u_direction.scale(c_u));
458        let v_length = v.length();
459        if v_length <= 1e-10 {
460            continue;
461        }
462        let v_direction = v.scale(1.0 / v_length);
463        // An OBTUSE triangle puts the apex's u coordinate outside [0, |ab|];
464        // evaluation clamps to the surface domain, so the plane must span the
465        // triangle's full uv bounding box or the apex edges' pcurves map off
466        // the triangle (sphere tessellations expose this; boxes never do).
467        let u_low = c_u.min(0.0);
468        let u_high = c_u.max(u_length);
469        let surface = make_plane(
470            a.add(u_direction.scale(u_low)),
471            u_direction,
472            v_direction,
473            u_high - u_low,
474            v_length,
475        )?;
476        let uv = [
477            Vec3::new(-u_low, 0.0, 0.0),
478            Vec3::new(u_length - u_low, 0.0, 0.0),
479            Vec3::new(c_u - u_low, v_length, 0.0),
480        ];
481        let mut coedges = Vec::with_capacity(3);
482        for side in 0..3 {
483            let first = triangle[side];
484            let second = triangle[(side + 1) % 3];
485            let key = edge_key(first, second);
486            let edge_id = if let Some(id) = edge_ids.get(&key) {
487                *id
488            } else {
489                let id = next_id;
490                next_id += 1;
491                edges.push(EdgeRecord {
492                    id,
493                    curve: make_line(points[key.0], points[key.1])?,
494                    t0: 0.0,
495                    t1: 1.0,
496                    start_vertex_id: key.0 as u64 + 1,
497                    end_vertex_id: key.1 as u64 + 1,
498                    degenerate: false,
499                    name: None,
500                });
501                edge_ids.insert(key, id);
502                id
503            };
504            coedges.push(CoedgeRecord {
505                id: next_id,
506                edge_id,
507                forward: first < second,
508                pcurve: make_line(uv[side], uv[(side + 1) % 3])?,
509            });
510            next_id += 1;
511        }
512        let loop_record = LoopRecord {
513            id: next_id,
514            coedges,
515        };
516        next_id += 1;
517        faces.push(FaceRecord {
518            id: next_id,
519            surface,
520            same_sense: true,
521            loops: vec![loop_record],
522            name: None,
523        });
524        next_id += 1;
525    }
526    let mut result = BrepSolid {
527        mass_properties_cache: Default::default(),
528        id: next_id + 1,
529        vertices,
530        edges,
531        shells: vec![ShellRecord { id: next_id, faces }],
532        genus: 0,
533    };
534    let vertex_count = result.vertices.len() as i64;
535    let edge_count = result.edges.len() as i64;
536    let face_count = result.shells[0].faces.len() as i64;
537    let numerator = 2 - (vertex_count - edge_count + face_count);
538    if numerator >= 0 && numerator % 2 == 0 {
539        result.genus = numerator / 2;
540    }
541    let issues = result.validate();
542    if !issues.is_empty() {
543        return Err(format!(
544            "faceted shell repair produced invalid topology: {issues:?}"
545        ));
546    }
547    for (index, face) in result.shells[0].faces.iter().enumerate() {
548        tessellate_face(face, TessellationOptions::default(), index as u32)
549            .map_err(|error| format!("faceted face {index} cannot be tessellated: {error}"))?;
550    }
551    Ok(result)
552}
553