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