Skip to main content

brep_kernel/healing/
faceted_repair.rs

1use crate::topology::{
2    BrepSolid, CoedgeRecord, EdgeRecord, FaceRecord, LoopRecord, ShellRecord, VertexRecord,
3};
4use crate::{make_line, make_plane, tessellate_face, Mesh, TessellationOptions, Vec3};
5use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
6
7fn quantize(point: Vec3, tolerance: f64) -> [i64; 3] {
8    [
9        (point.x / tolerance).round() as i64,
10        (point.y / tolerance).round() as i64,
11        (point.z / tolerance).round() as i64,
12    ]
13}
14
15fn weld_vertex(
16    point: Vec3,
17    tolerance: f64,
18    vertices: &mut Vec<Vec3>,
19    buckets: &mut HashMap<[i64; 3], Vec<usize>>,
20) -> usize {
21    let key = quantize(point, tolerance);
22    for dx in -1..=1 {
23        for dy in -1..=1 {
24            for dz in -1..=1 {
25                if let Some(indices) = buckets.get(&[key[0] + dx, key[1] + dy, key[2] + dz]) {
26                    if let Some(index) = indices
27                        .iter()
28                        .copied()
29                        .find(|index| vertices[*index].sub(point).length() <= tolerance)
30                    {
31                        return index;
32                    }
33                }
34            }
35        }
36    }
37    let index = vertices.len();
38    vertices.push(point);
39    buckets.entry(key).or_default().push(index);
40    index
41}
42
43fn edge_key(first: usize, second: usize) -> (usize, usize) {
44    if first < second {
45        (first, second)
46    } else {
47        (second, first)
48    }
49}
50
51/// Convert an open exact assembly to a closed, welded triangular BREP.
52///
53/// This is a last-resort native recovery for self-intersection transition
54/// values where carrier selection leaves an otherwise useful surface mesh
55/// with boundary cycles. Exact assemblies never pass through this function.
56pub(crate) fn close_as_faceted_brep(
57    solid: &BrepSolid,
58    tolerance: f64,
59) -> Result<BrepSolid, String> {
60    let options = TessellationOptions {
61        slabs_per_span_u: 12,
62        steps_per_span_v: 12,
63    };
64    let mut mesh = Mesh::default();
65    let mut face_id = 0;
66    for face in solid.shells.iter().flat_map(|shell| &shell.faces) {
67        if let Ok(face_mesh) = tessellate_face(face, options, face_id) {
68            let base = (mesh.positions.len() / 3) as u32;
69            mesh.positions.extend(face_mesh.positions);
70            mesh.normals.extend(face_mesh.normals);
71            mesh.indices
72                .extend(face_mesh.indices.into_iter().map(|index| index + base));
73            mesh.face_ids.extend(face_mesh.face_ids);
74        }
75        face_id += 1;
76    }
77    if mesh.indices.is_empty() {
78        return Err("faceted shell repair could not tessellate any faces".into());
79    }
80    triangle_soup_to_faceted_brep(&mesh.positions, Some(&mesh.indices), tolerance.max(2e-3))
81}
82
83/// Build a faceted (planar-triangle) BREP solid from a triangle soup or an
84/// indexed triangle mesh — the STL/mesh import entry (§8.6 inverse: mesh →
85/// body). Vertices weld within `tolerance` (pass `<= 0` to derive it from the
86/// bounding-box diagonal), duplicate and degenerate triangles are dropped,
87/// surplus sheets at non-manifold edges are pruned, and remaining boundary
88/// cycles are capped with a fan so the result closes watertight whenever the
89/// input is close to a manifold. Validation is authoritative: an input too
90/// broken to close returns an error, never a silently-invalid solid.
91pub fn mesh_to_faceted_brep(
92    positions: &[f64],
93    indices: Option<&[u32]>,
94    tolerance: f64,
95) -> Result<BrepSolid, String> {
96    if positions.is_empty() || positions.len() % 3 != 0 {
97        return Err("mesh_to_faceted_brep: positions must be xyz triples".into());
98    }
99    let weld_tolerance = if tolerance > 0.0 && tolerance.is_finite() {
100        tolerance
101    } else {
102        let mut low = [f64::INFINITY; 3];
103        let mut high = [f64::NEG_INFINITY; 3];
104        for point in positions.chunks_exact(3) {
105            for axis in 0..3 {
106                low[axis] = low[axis].min(point[axis]);
107                high[axis] = high[axis].max(point[axis]);
108            }
109        }
110        let diagonal = (0..3)
111            .map(|axis| (high[axis] - low[axis]).powi(2))
112            .sum::<f64>()
113            .sqrt();
114        (diagonal * 1e-6).max(1e-12)
115    };
116    triangle_soup_to_faceted_brep(positions, indices, weld_tolerance)
117}
118
119/// Shared weld → prune → cap → build pipeline. `indices` of `None` treats
120/// `positions` as a raw soup (three consecutive vertices per triangle, the
121/// binary-STL layout).
122fn triangle_soup_to_faceted_brep(
123    positions: &[f64],
124    indices: Option<&[u32]>,
125    weld_tolerance: f64,
126) -> Result<BrepSolid, String> {
127    let mut points = Vec::<Vec3>::new();
128    let mut buckets = HashMap::<[i64; 3], Vec<usize>>::default();
129    let mut mesh_to_welded = Vec::with_capacity(positions.len() / 3);
130    for position in positions.chunks_exact(3) {
131        mesh_to_welded.push(weld_vertex(
132            Vec3::new(position[0], position[1], position[2]),
133            weld_tolerance,
134            &mut points,
135            &mut buckets,
136        ));
137    }
138    let sequential;
139    let indices = match indices {
140        Some(indices) => indices,
141        None => {
142            sequential = (0..mesh_to_welded.len() as u32).collect::<Vec<_>>();
143            &sequential
144        }
145    };
146    let mut triangles = Vec::<[usize; 3]>::new();
147    let mut seen = HashSet::<[usize; 3]>::default();
148    for triangle in indices.chunks_exact(3) {
149        if triangle
150            .iter()
151            .any(|index| *index as usize >= mesh_to_welded.len())
152        {
153            return Err("mesh_to_faceted_brep: triangle index outside positions".into());
154        }
155        let value = [
156            mesh_to_welded[triangle[0] as usize],
157            mesh_to_welded[triangle[1] as usize],
158            mesh_to_welded[triangle[2] as usize],
159        ];
160        if value[0] == value[1] || value[1] == value[2] || value[2] == value[0] {
161            continue;
162        }
163        let mut key = value;
164        key.sort_unstable();
165        if seen.insert(key) {
166            triangles.push(value);
167        }
168    }
169    if triangles.is_empty() {
170        return Err("mesh_to_faceted_brep: no non-degenerate triangles".into());
171    }
172
173    // Remove surplus coincident sheets at non-manifold triangle edges,
174    // retaining one use in each direction whenever possible.
175    loop {
176        let mut edge_uses = HashMap::<(usize, usize), Vec<(usize, bool)>>::default();
177        for (triangle_index, triangle) in triangles.iter().enumerate() {
178            for side in 0..3 {
179                let first = triangle[side];
180                let second = triangle[(side + 1) % 3];
181                edge_uses
182                    .entry(edge_key(first, second))
183                    .or_default()
184                    .push((triangle_index, first < second));
185            }
186        }
187        let Some(uses) = edge_uses.values().find(|uses| uses.len() > 2) else {
188            break;
189        };
190        let keep_forward = uses.iter().find(|(_, forward)| *forward).map(|use_| use_.0);
191        let keep_reverse = uses
192            .iter()
193            .find(|(_, forward)| !*forward)
194            .map(|use_| use_.0);
195        let keep = [keep_forward, keep_reverse]
196            .into_iter()
197            .flatten()
198            .collect::<HashSet<_>>();
199        let remove = uses
200            .iter()
201            .map(|use_| use_.0)
202            .filter(|index| !keep.contains(index))
203            .collect::<HashSet<_>>();
204        if remove.is_empty() {
205            break;
206        }
207        triangles = triangles
208            .into_iter()
209            .enumerate()
210            .filter_map(|(index, triangle)| (!remove.contains(&index)).then_some(triangle))
211            .collect();
212    }
213
214    let mut directed_boundary = Vec::<(usize, usize)>::new();
215    let mut counts = HashMap::<(usize, usize), usize>::default();
216    for triangle in &triangles {
217        for side in 0..3 {
218            *counts
219                .entry(edge_key(triangle[side], triangle[(side + 1) % 3]))
220                .or_default() += 1;
221        }
222    }
223    for triangle in &triangles {
224        for side in 0..3 {
225            let first = triangle[side];
226            let second = triangle[(side + 1) % 3];
227            if counts[&edge_key(first, second)] == 1 {
228                directed_boundary.push((first, second));
229            }
230        }
231    }
232    while let Some((start, mut end)) = directed_boundary.pop() {
233        let mut loop_vertices = vec![start, end];
234        while end != start {
235            let Some(index) = directed_boundary
236                .iter()
237                .position(|(candidate_start, _)| *candidate_start == end)
238            else {
239                break;
240            };
241            let (_, next) = directed_boundary.swap_remove(index);
242            end = next;
243            if end != start {
244                loop_vertices.push(end);
245            }
246            if loop_vertices.len() > points.len() + 1 {
247                break;
248            }
249        }
250        if end != start || loop_vertices.len() < 3 {
251            continue;
252        }
253        let center = loop_vertices
254            .iter()
255            .fold(Vec3::default(), |sum, index| sum.add(points[*index]))
256            .scale(1.0 / loop_vertices.len() as f64);
257        let center_index = points.len();
258        points.push(center);
259        for index in 0..loop_vertices.len() {
260            let first = loop_vertices[index];
261            let second = loop_vertices[(index + 1) % loop_vertices.len()];
262            // Reverse the existing boundary direction on the new cap.
263            triangles.push([second, first, center_index]);
264        }
265    }
266
267    let vertices = points
268        .iter()
269        .enumerate()
270        .map(|(index, point)| VertexRecord {
271            id: index as u64 + 1,
272            point: *point,
273        })
274        .collect::<Vec<_>>();
275    let mut edges = Vec::<EdgeRecord>::new();
276    let mut edge_ids = HashMap::<(usize, usize), u64>::default();
277    let mut faces = Vec::<FaceRecord>::new();
278    let mut next_id = vertices.len() as u64 + 1;
279    for triangle in triangles {
280        let a = points[triangle[0]];
281        let b = points[triangle[1]];
282        let c = points[triangle[2]];
283        let u = b.sub(a);
284        let u_length = u.length();
285        if u_length <= 1e-10 {
286            continue;
287        }
288        let u_direction = u.scale(1.0 / u_length);
289        let c_delta = c.sub(a);
290        let c_u = c_delta.dot(u_direction);
291        let v = c_delta.sub(u_direction.scale(c_u));
292        let v_length = v.length();
293        if v_length <= 1e-10 {
294            continue;
295        }
296        let v_direction = v.scale(1.0 / v_length);
297        // An OBTUSE triangle puts the apex's u coordinate outside [0, |ab|];
298        // evaluation clamps to the surface domain, so the plane must span the
299        // triangle's full uv bounding box or the apex edges' pcurves map off
300        // the triangle (sphere tessellations expose this; boxes never do).
301        let u_low = c_u.min(0.0);
302        let u_high = c_u.max(u_length);
303        let surface = make_plane(
304            a.add(u_direction.scale(u_low)),
305            u_direction,
306            v_direction,
307            u_high - u_low,
308            v_length,
309        )?;
310        let uv = [
311            Vec3::new(-u_low, 0.0, 0.0),
312            Vec3::new(u_length - u_low, 0.0, 0.0),
313            Vec3::new(c_u - u_low, v_length, 0.0),
314        ];
315        let mut coedges = Vec::with_capacity(3);
316        for side in 0..3 {
317            let first = triangle[side];
318            let second = triangle[(side + 1) % 3];
319            let key = edge_key(first, second);
320            let edge_id = if let Some(id) = edge_ids.get(&key) {
321                *id
322            } else {
323                let id = next_id;
324                next_id += 1;
325                edges.push(EdgeRecord {
326                    id,
327                    curve: make_line(points[key.0], points[key.1])?,
328                    t0: 0.0,
329                    t1: 1.0,
330                    start_vertex_id: key.0 as u64 + 1,
331                    end_vertex_id: key.1 as u64 + 1,
332                    degenerate: false,
333                    name: None,
334                });
335                edge_ids.insert(key, id);
336                id
337            };
338            coedges.push(CoedgeRecord {
339                id: next_id,
340                edge_id,
341                forward: first < second,
342                pcurve: make_line(uv[side], uv[(side + 1) % 3])?,
343            });
344            next_id += 1;
345        }
346        let loop_record = LoopRecord {
347            id: next_id,
348            coedges,
349        };
350        next_id += 1;
351        faces.push(FaceRecord {
352            id: next_id,
353            surface,
354            same_sense: true,
355            loops: vec![loop_record],
356            name: None,
357        });
358        next_id += 1;
359    }
360    let mut result = BrepSolid {
361        id: next_id + 1,
362        vertices,
363        edges,
364        shells: vec![ShellRecord { id: next_id, faces }],
365        genus: 0,
366    };
367    let vertex_count = result.vertices.len() as i64;
368    let edge_count = result.edges.len() as i64;
369    let face_count = result.shells[0].faces.len() as i64;
370    let numerator = 2 - (vertex_count - edge_count + face_count);
371    if numerator >= 0 && numerator % 2 == 0 {
372        result.genus = numerator / 2;
373    }
374    let issues = result.validate();
375    if !issues.is_empty() {
376        return Err(format!(
377            "faceted shell repair produced invalid topology: {issues:?}"
378        ));
379    }
380    for (index, face) in result.shells[0].faces.iter().enumerate() {
381        tessellate_face(face, TessellationOptions::default(), index as u32)
382            .map_err(|error| format!("faceted face {index} cannot be tessellated: {error}"))?;
383    }
384    Ok(result)
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use crate::{
391        make_box_brep, make_sphere_brep, read_binary_stl, solid_mass_properties,
392        tessellate_brep_watertight, write_binary_stl,
393    };
394
395    fn box_stl_bytes() -> Vec<u8> {
396        let solid = make_box_brep(Vec3::default(), 2.0, 3.0, 4.0).unwrap();
397        let mesh = tessellate_brep_watertight(&solid, 1e-3).unwrap();
398        write_binary_stl(&mesh, "box").unwrap()
399    }
400
401    #[test]
402    fn stl_round_trip_imports_a_watertight_box_solid() {
403        let stl = read_binary_stl(&box_stl_bytes()).unwrap();
404        // Binary STL is an unindexed soup: three vertices per triangle.
405        let solid = mesh_to_faceted_brep(&stl.positions, None, -1.0).unwrap();
406        assert!(solid.validate().is_empty(), "{:?}", solid.validate());
407        assert_eq!(solid.genus, 0);
408        let volume = solid_mass_properties(&solid).unwrap().volume;
409        assert!((volume - 24.0).abs() < 1e-9, "{volume}");
410    }
411
412    #[test]
413    fn caps_a_missing_triangle_hole() {
414        let stl = read_binary_stl(&box_stl_bytes()).unwrap();
415        // Drop one triangle (9 floats): the boundary cycle it leaves must be
416        // capped by the fan so the import still closes.
417        let truncated = &stl.positions[..stl.positions.len() - 9];
418        let solid = mesh_to_faceted_brep(truncated, None, -1.0).unwrap();
419        assert!(solid.validate().is_empty(), "{:?}", solid.validate());
420        let volume = solid_mass_properties(&solid).unwrap().volume;
421        assert!((volume - 24.0).abs() < 1e-9, "{volume}");
422    }
423
424    #[test]
425    fn drops_degenerate_and_duplicate_triangles() {
426        let stl = read_binary_stl(&box_stl_bytes()).unwrap();
427        let mut positions = stl.positions.clone();
428        // A zero-area triangle (three coincident vertices)…
429        positions.extend([9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0]);
430        // …and an exact duplicate of the first triangle.
431        let first = stl.positions[..9].to_vec();
432        positions.extend(first);
433        let solid = mesh_to_faceted_brep(&positions, None, -1.0).unwrap();
434        assert!(solid.validate().is_empty(), "{:?}", solid.validate());
435        let volume = solid_mass_properties(&solid).unwrap().volume;
436        assert!((volume - 24.0).abs() < 1e-9, "{volume}");
437    }
438
439    #[test]
440    fn imports_a_curved_tessellation_within_chord_error() {
441        let sphere = make_sphere_brep(Vec3::default(), 1.0, Vec3::new(0.0, 0.0, 1.0)).unwrap();
442        let mesh = tessellate_brep_watertight(&sphere, 5e-3).unwrap();
443        let solid = mesh_to_faceted_brep(&mesh.positions, Some(&mesh.indices), -1.0).unwrap();
444        assert!(solid.validate().is_empty(), "{:?}", solid.validate());
445        assert_eq!(solid.genus, 0);
446        let volume = solid_mass_properties(&solid).unwrap().volume;
447        let exact = 4.0 / 3.0 * std::f64::consts::PI;
448        // An inscribed chordal tessellation always under-measures; the 5e-3
449        // chord bound keeps it within a percent.
450        assert!(
451            volume < exact && volume > exact * 0.97,
452            "{volume} vs {exact}"
453        );
454    }
455
456    #[test]
457    fn imports_a_torus_mesh_with_genus_one() {
458        let torus =
459            crate::make_torus_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 2.0, 0.5).unwrap();
460        let mesh = tessellate_brep_watertight(&torus, 5e-3).unwrap();
461        let solid = mesh_to_faceted_brep(&mesh.positions, Some(&mesh.indices), -1.0).unwrap();
462        assert!(solid.validate().is_empty(), "{:?}", solid.validate());
463        assert_eq!(solid.genus, 1, "Euler-derived genus of a torus");
464        let volume = solid_mass_properties(&solid).unwrap().volume;
465        let exact = 2.0 * std::f64::consts::PI.powi(2) * 2.0 * 0.5f64.powi(2);
466        assert!(
467            volume < exact && volume > exact * 0.97,
468            "{volume} vs {exact}"
469        );
470    }
471
472    #[test]
473    fn rejects_malformed_input_honestly() {
474        assert!(mesh_to_faceted_brep(&[], None, -1.0).is_err());
475        assert!(mesh_to_faceted_brep(&[1.0, 2.0], None, -1.0).is_err());
476        let soup = [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0];
477        assert!(mesh_to_faceted_brep(&soup, Some(&[0, 1, 9]), -1.0).is_err());
478    }
479}