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    /// Boundary cycles closed with a fan.
100    pub capped_loops: usize,
101    /// Triangles the caps added.
102    pub cap_triangles: usize,
103}
104
105impl MeshRepairReport {
106    /// Whether the repair changed the welded triangle set at all.
107    pub fn changed(&self) -> bool {
108        self.dropped_triangles != 0 || self.pruned_triangles != 0 || self.capped_loops != 0
109    }
110}
111
112/// The mesh half of [`mesh_to_faceted_brep`]: weld, drop degenerate and
113/// duplicate triangles, prune surplus sheets at non-manifold edges, and cap
114/// the remaining boundary cycles with a fan — returning the repaired triangle
115/// soup instead of a BREP.
116///
117/// This is what lets a defective mesh still reach the ANALYTIC import lanes.
118/// They all require a closed two-manifold, and the defect that costs a part
119/// every recognized cylinder is usually a handful of triangles far away from
120/// any of them; running them on this output instead of on the raw file keeps
121/// the surfaces a repairable mesh can still support. Pass `weld_tolerance`
122/// `<= 0` to derive it from the bounding-box diagonal. The result is not
123/// guaranteed closed — an input too broken to close comes back as whatever
124/// the repair could reach, and the caller must re-test it.
125pub fn repair_triangle_soup(
126    positions: &[f64],
127    indices: Option<&[u32]>,
128    weld_tolerance: f64,
129) -> Result<(Vec<f64>, Vec<u32>, MeshRepairReport), String> {
130    if positions.is_empty() || !positions.len().is_multiple_of(3) {
131        return Err("repair_triangle_soup: positions must be xyz triples".into());
132    }
133    let weld_tolerance = derived_weld_tolerance(positions, weld_tolerance);
134    let (points, triangles, report) = repair_soup(positions, indices, weld_tolerance)?;
135    let flat = points
136        .iter()
137        .flat_map(|point| [point.x, point.y, point.z])
138        .collect::<Vec<_>>();
139    let flat_indices = triangles
140        .iter()
141        .flatten()
142        .map(|&index| index as u32)
143        .collect::<Vec<_>>();
144    Ok((flat, flat_indices, report))
145}
146
147/// Shared weld → prune → cap → build pipeline. `indices` of `None` treats
148/// `positions` as a raw soup (three consecutive vertices per triangle, the
149/// binary-STL layout).
150fn triangle_soup_to_faceted_brep(
151    positions: &[f64],
152    indices: Option<&[u32]>,
153    weld_tolerance: f64,
154) -> Result<BrepSolid, String> {
155    let (points, triangles, _) = repair_soup(positions, indices, weld_tolerance)?;
156    build_faceted_brep(&points, &triangles)
157}
158
159/// The weld → dedup → prune → cap phase, shared by the BREP builder above and
160/// by the public [`repair_triangle_soup`].
161fn repair_soup(
162    positions: &[f64],
163    indices: Option<&[u32]>,
164    weld_tolerance: f64,
165) -> Result<(Vec<Vec3>, Vec<[usize; 3]>, MeshRepairReport), String> {
166    let mut report = MeshRepairReport::default();
167    let mut points = Vec::<Vec3>::new();
168    let mut buckets = HashMap::<[i64; 3], Vec<usize>>::default();
169    let mut mesh_to_welded = Vec::with_capacity(positions.len() / 3);
170    for position in positions.chunks_exact(3) {
171        mesh_to_welded.push(weld_vertex(
172            Vec3::new(position[0], position[1], position[2]),
173            weld_tolerance,
174            &mut points,
175            &mut buckets,
176        ));
177    }
178    let sequential;
179    let indices = match indices {
180        Some(indices) => indices,
181        None => {
182            sequential = (0..mesh_to_welded.len() as u32).collect::<Vec<_>>();
183            &sequential
184        }
185    };
186    let mut triangles = Vec::<[usize; 3]>::new();
187    let mut seen = HashSet::<[usize; 3]>::default();
188    for triangle in indices.chunks_exact(3) {
189        if triangle
190            .iter()
191            .any(|index| *index as usize >= mesh_to_welded.len())
192        {
193            return Err("mesh_to_faceted_brep: triangle index outside positions".into());
194        }
195        let value = [
196            mesh_to_welded[triangle[0] as usize],
197            mesh_to_welded[triangle[1] as usize],
198            mesh_to_welded[triangle[2] as usize],
199        ];
200        if value[0] == value[1] || value[1] == value[2] || value[2] == value[0] {
201            report.dropped_triangles += 1;
202            continue;
203        }
204        let mut key = value;
205        key.sort_unstable();
206        if seen.insert(key) {
207            triangles.push(value);
208        } else {
209            report.dropped_triangles += 1;
210        }
211    }
212    if triangles.is_empty() {
213        return Err("mesh_to_faceted_brep: no non-degenerate triangles".into());
214    }
215
216    // Remove surplus coincident sheets at non-manifold triangle edges,
217    // retaining one use in each direction whenever possible.
218    loop {
219        let mut edge_uses = HashMap::<(usize, usize), Vec<(usize, bool)>>::default();
220        for (triangle_index, triangle) in triangles.iter().enumerate() {
221            for side in 0..3 {
222                let first = triangle[side];
223                let second = triangle[(side + 1) % 3];
224                edge_uses
225                    .entry(edge_key(first, second))
226                    .or_default()
227                    .push((triangle_index, first < second));
228            }
229        }
230        let Some(uses) = edge_uses.values().find(|uses| uses.len() > 2) else {
231            break;
232        };
233        let keep_forward = uses.iter().find(|(_, forward)| *forward).map(|use_| use_.0);
234        let keep_reverse = uses
235            .iter()
236            .find(|(_, forward)| !*forward)
237            .map(|use_| use_.0);
238        let keep = [keep_forward, keep_reverse]
239            .into_iter()
240            .flatten()
241            .collect::<HashSet<_>>();
242        let remove = uses
243            .iter()
244            .map(|use_| use_.0)
245            .filter(|index| !keep.contains(index))
246            .collect::<HashSet<_>>();
247        if remove.is_empty() {
248            break;
249        }
250        report.pruned_triangles += remove.len();
251        triangles = triangles
252            .into_iter()
253            .enumerate()
254            .filter_map(|(index, triangle)| (!remove.contains(&index)).then_some(triangle))
255            .collect();
256    }
257
258    let mut directed_boundary = Vec::<(usize, usize)>::new();
259    let mut counts = HashMap::<(usize, usize), usize>::default();
260    for triangle in &triangles {
261        for side in 0..3 {
262            *counts
263                .entry(edge_key(triangle[side], triangle[(side + 1) % 3]))
264                .or_default() += 1;
265        }
266    }
267    for triangle in &triangles {
268        for side in 0..3 {
269            let first = triangle[side];
270            let second = triangle[(side + 1) % 3];
271            if counts[&edge_key(first, second)] == 1 {
272                directed_boundary.push((first, second));
273            }
274        }
275    }
276    while let Some((start, mut end)) = directed_boundary.pop() {
277        let mut loop_vertices = vec![start, end];
278        while end != start {
279            let Some(index) = directed_boundary
280                .iter()
281                .position(|(candidate_start, _)| *candidate_start == end)
282            else {
283                break;
284            };
285            let (_, next) = directed_boundary.swap_remove(index);
286            end = next;
287            if end != start {
288                loop_vertices.push(end);
289            }
290            if loop_vertices.len() > points.len() + 1 {
291                break;
292            }
293        }
294        if end != start || loop_vertices.len() < 3 {
295            continue;
296        }
297        let center = loop_vertices
298            .iter()
299            .fold(Vec3::default(), |sum, index| sum.add(points[*index]))
300            .scale(1.0 / loop_vertices.len() as f64);
301        let center_index = points.len();
302        points.push(center);
303        for index in 0..loop_vertices.len() {
304            let first = loop_vertices[index];
305            let second = loop_vertices[(index + 1) % loop_vertices.len()];
306            // Reverse the existing boundary direction on the new cap.
307            triangles.push([second, first, center_index]);
308        }
309        report.capped_loops += 1;
310        report.cap_triangles += loop_vertices.len();
311    }
312
313    report.welded_vertices = points.len();
314    report.triangles = triangles.len();
315    Ok((points, triangles, report))
316}
317
318/// One planar face per repaired triangle, validated.
319fn build_faceted_brep(points: &[Vec3], triangles: &[[usize; 3]]) -> Result<BrepSolid, String> {
320    let vertices = points
321        .iter()
322        .enumerate()
323        .map(|(index, point)| VertexRecord {
324            id: index as u64 + 1,
325            point: *point,
326        })
327        .collect::<Vec<_>>();
328    let mut edges = Vec::<EdgeRecord>::new();
329    let mut edge_ids = HashMap::<(usize, usize), u64>::default();
330    let mut faces = Vec::<FaceRecord>::new();
331    let mut next_id = vertices.len() as u64 + 1;
332    for triangle in triangles.iter().copied() {
333        let a = points[triangle[0]];
334        let b = points[triangle[1]];
335        let c = points[triangle[2]];
336        let u = b.sub(a);
337        let u_length = u.length();
338        if u_length <= 1e-10 {
339            continue;
340        }
341        let u_direction = u.scale(1.0 / u_length);
342        let c_delta = c.sub(a);
343        let c_u = c_delta.dot(u_direction);
344        let v = c_delta.sub(u_direction.scale(c_u));
345        let v_length = v.length();
346        if v_length <= 1e-10 {
347            continue;
348        }
349        let v_direction = v.scale(1.0 / v_length);
350        // An OBTUSE triangle puts the apex's u coordinate outside [0, |ab|];
351        // evaluation clamps to the surface domain, so the plane must span the
352        // triangle's full uv bounding box or the apex edges' pcurves map off
353        // the triangle (sphere tessellations expose this; boxes never do).
354        let u_low = c_u.min(0.0);
355        let u_high = c_u.max(u_length);
356        let surface = make_plane(
357            a.add(u_direction.scale(u_low)),
358            u_direction,
359            v_direction,
360            u_high - u_low,
361            v_length,
362        )?;
363        let uv = [
364            Vec3::new(-u_low, 0.0, 0.0),
365            Vec3::new(u_length - u_low, 0.0, 0.0),
366            Vec3::new(c_u - u_low, v_length, 0.0),
367        ];
368        let mut coedges = Vec::with_capacity(3);
369        for side in 0..3 {
370            let first = triangle[side];
371            let second = triangle[(side + 1) % 3];
372            let key = edge_key(first, second);
373            let edge_id = if let Some(id) = edge_ids.get(&key) {
374                *id
375            } else {
376                let id = next_id;
377                next_id += 1;
378                edges.push(EdgeRecord {
379                    id,
380                    curve: make_line(points[key.0], points[key.1])?,
381                    t0: 0.0,
382                    t1: 1.0,
383                    start_vertex_id: key.0 as u64 + 1,
384                    end_vertex_id: key.1 as u64 + 1,
385                    degenerate: false,
386                    name: None,
387                });
388                edge_ids.insert(key, id);
389                id
390            };
391            coedges.push(CoedgeRecord {
392                id: next_id,
393                edge_id,
394                forward: first < second,
395                pcurve: make_line(uv[side], uv[(side + 1) % 3])?,
396            });
397            next_id += 1;
398        }
399        let loop_record = LoopRecord {
400            id: next_id,
401            coedges,
402        };
403        next_id += 1;
404        faces.push(FaceRecord {
405            id: next_id,
406            surface,
407            same_sense: true,
408            loops: vec![loop_record],
409            name: None,
410        });
411        next_id += 1;
412    }
413    let mut result = BrepSolid {
414        id: next_id + 1,
415        vertices,
416        edges,
417        shells: vec![ShellRecord { id: next_id, faces }],
418        genus: 0,
419    };
420    let vertex_count = result.vertices.len() as i64;
421    let edge_count = result.edges.len() as i64;
422    let face_count = result.shells[0].faces.len() as i64;
423    let numerator = 2 - (vertex_count - edge_count + face_count);
424    if numerator >= 0 && numerator % 2 == 0 {
425        result.genus = numerator / 2;
426    }
427    let issues = result.validate();
428    if !issues.is_empty() {
429        return Err(format!(
430            "faceted shell repair produced invalid topology: {issues:?}"
431        ));
432    }
433    for (index, face) in result.shells[0].faces.iter().enumerate() {
434        tessellate_face(face, TessellationOptions::default(), index as u32)
435            .map_err(|error| format!("faceted face {index} cannot be tessellated: {error}"))?;
436    }
437    Ok(result)
438}
439