Skip to main content

brep_kernel/meshing/mesh_segment/
segmentation.rs

1use super::*;
2
3struct RegionOutcome {
4    fit: CandidateFit,
5    accepted: bool,
6    area: f64,
7    bbox: (Vec3, Vec3),
8    triangle_count: usize,
9}
10
11/// Try the analytic cascade plane → cylinder → cone → sphere → torus on one
12/// region; the first carrier within tolerance wins.
13fn fit_region(data: &MeshData, tri_ids: &[u32], options: &SegmentOptions) -> RegionOutcome {
14    let vert_ids = region_vertices(data, tri_ids);
15    let bbox = region_bbox(data, &vert_ids);
16    let area: f64 = tri_ids.iter().map(|&t| data.tris[t as usize].area).sum();
17    let scale = {
18        let diag = bbox.1.sub(bbox.0).length();
19        if diag > 0.0 {
20            diag
21        } else {
22            data.diag.max(1e-12)
23        }
24    };
25    let tol_abs = options.fit_tolerance * scale;
26    let mut outcome = RegionOutcome {
27        fit: CandidateFit {
28            carrier: RegionCarrier::Freeform,
29            max_dev: 0.0,
30            rms_dev: 0.0,
31            max_normal_angle_deg: 0.0,
32        },
33        accepted: false,
34        area,
35        bbox,
36        triangle_count: tri_ids.len(),
37    };
38    if tri_ids.len() < options.min_region_triangles || vert_ids.len() < 3 {
39        return outcome;
40    }
41    let candidates = [
42        fit_plane(data, tri_ids, &vert_ids),
43        fit_cylinder(data, tri_ids, &vert_ids),
44        fit_cone(data, tri_ids, &vert_ids, tol_abs),
45        fit_sphere(data, tri_ids, &vert_ids),
46        fit_torus(data, tri_ids, &vert_ids),
47    ];
48    for candidate in candidates {
49        if let Some(fit) = candidate {
50            if std::env::var("BREP_DEBUG_SEG").is_ok() {
51                eprintln!(
52                    "[seg] region tris={} candidate={} max_dev={:.3e} tol_abs={:.3e} max_ang={:.3}",
53                    tri_ids.len(),
54                    fit.carrier.kind(),
55                    fit.max_dev,
56                    tol_abs,
57                    fit.max_normal_angle_deg
58                );
59            }
60            if fit.max_dev.is_finite()
61                && fit.max_dev <= tol_abs
62                && fit.max_normal_angle_deg <= options.normal_tolerance_deg
63            {
64                outcome.fit = fit;
65                outcome.accepted = true;
66                return outcome;
67            }
68        }
69    }
70    outcome
71}
72
73// ---------------------------------------------------------------------------
74// Region growing and refinement
75// ---------------------------------------------------------------------------
76
77fn grow_regions(data: &MeshData, options: &SegmentOptions) -> Vec<Vec<u32>> {
78    let cos_gate = options.deflection_angle_deg.to_radians().cos();
79    let tri_count = data.tris.len();
80    let mut assigned = vec![false; tri_count];
81    let mut regions = Vec::new();
82    let mut stack = Vec::new();
83    for seed in 0..tri_count {
84        if assigned[seed] || data.tris[seed].skip {
85            continue;
86        }
87        assigned[seed] = true;
88        stack.push(seed as u32);
89        let mut region = Vec::new();
90        while let Some(t) = stack.pop() {
91            region.push(t);
92            let tri = &data.tris[t as usize];
93            for corner in 0..3 {
94                let first = tri.verts[corner];
95                let second = tri.verts[(corner + 1) % 3];
96                if first == second {
97                    continue;
98                }
99                let Some(incident) = data.edges.get(&edge_key(first, second)) else {
100                    continue;
101                };
102                // Only manifold (two-sided) edges are smooth crossings.
103                if incident.len() != 2 {
104                    continue;
105                }
106                for &other in incident {
107                    if other == t || assigned[other as usize] {
108                        continue;
109                    }
110                    let neighbor = &data.tris[other as usize];
111                    if neighbor.skip {
112                        continue;
113                    }
114                    if tri.normal.dot(neighbor.normal) >= cos_gate {
115                        assigned[other as usize] = true;
116                        stack.push(other);
117                    }
118                }
119            }
120        }
121        region.sort_unstable();
122        regions.push(region);
123    }
124    regions
125}
126
127/// Regions whose area is below this fraction of the total mesh area are
128/// dissolved into their largest neighboring region before fitting.  Needle
129/// triangles at tessellation poles wall themselves off into 1–2-triangle
130/// islands; the app's minimum-region-size gating served the same purpose.
131const REGION_ABSORB_AREA_FRACTION: f64 = 1e-6;
132
133/// Merge negligible regions into their largest-area neighbor (repeats until
134/// stable so chains of islands resolve).  Regions with no neighbor at all
135/// stay as they are.
136fn absorb_tiny_regions(data: &MeshData, regions: &mut Vec<Vec<u32>>) {
137    let total_area: f64 = data
138        .tris
139        .iter()
140        .filter(|tri| !tri.skip)
141        .map(|tri| tri.area)
142        .sum();
143    let floor = total_area * REGION_ABSORB_AREA_FRACTION;
144    if !(floor > 0.0) {
145        return;
146    }
147    let mut region_of = vec![usize::MAX; data.tris.len()];
148    for (r, list) in regions.iter().enumerate() {
149        for &t in list {
150            region_of[t as usize] = r;
151        }
152    }
153    let mut areas: Vec<f64> = regions
154        .iter()
155        .map(|list| list.iter().map(|&t| data.tris[t as usize].area).sum())
156        .collect();
157    let mut changed = true;
158    while changed {
159        changed = false;
160        for r in 0..regions.len() {
161            if regions[r].is_empty() || areas[r] >= floor {
162                continue;
163            }
164            // Find adjacent regions, traversing THROUGH region-less
165            // (skipped sliver) triangles — pole islands are often fenced
166            // off from the main region by a ring of skipped needles.
167            let mut target: Option<usize> = None;
168            let mut visited: rustc_hash::FxHashSet<u32> = regions[r].iter().copied().collect();
169            let mut frontier: Vec<u32> = regions[r].clone();
170            while let Some(t) = frontier.pop() {
171                let tri = &data.tris[t as usize];
172                for corner in 0..3 {
173                    let first = tri.verts[corner];
174                    let second = tri.verts[(corner + 1) % 3];
175                    if first == second {
176                        continue;
177                    }
178                    if let Some(incident) = data.edges.get(&edge_key(first, second)) {
179                        for &other in incident {
180                            if visited.contains(&other) {
181                                continue;
182                            }
183                            let neighbor = region_of[other as usize];
184                            if neighbor == usize::MAX {
185                                // Skipped triangle: pass through it.
186                                visited.insert(other);
187                                frontier.push(other);
188                                continue;
189                            }
190                            if neighbor == r {
191                                continue;
192                            }
193                            target = match target {
194                                None => Some(neighbor),
195                                Some(current) => Some(if areas[neighbor] > areas[current] {
196                                    neighbor
197                                } else {
198                                    current
199                                }),
200                            };
201                        }
202                    }
203                }
204            }
205            if let Some(target) = target {
206                let moved = std::mem::take(&mut regions[r]);
207                for &t in &moved {
208                    region_of[t as usize] = target;
209                }
210                areas[target] += areas[r];
211                areas[r] = 0.0;
212                regions[target].extend(moved);
213                changed = true;
214            }
215        }
216    }
217    regions.retain(|list| !list.is_empty());
218    for list in regions.iter_mut() {
219        list.sort_unstable();
220    }
221}
222
223/// Connected components of `tri_ids` across shared (welded) edges,
224/// restricted to the given set.
225fn connected_components(data: &MeshData, tri_ids: &[u32]) -> Vec<Vec<u32>> {
226    let member: rustc_hash::FxHashSet<u32> = tri_ids.iter().copied().collect();
227    let mut assigned: rustc_hash::FxHashSet<u32> = rustc_hash::FxHashSet::default();
228    let mut components = Vec::new();
229    let mut stack = Vec::new();
230    for &seed in tri_ids {
231        if assigned.contains(&seed) {
232            continue;
233        }
234        assigned.insert(seed);
235        stack.push(seed);
236        let mut component = Vec::new();
237        while let Some(t) = stack.pop() {
238            component.push(t);
239            let tri = &data.tris[t as usize];
240            for corner in 0..3 {
241                let first = tri.verts[corner];
242                let second = tri.verts[(corner + 1) % 3];
243                if first == second {
244                    continue;
245                }
246                if let Some(incident) = data.edges.get(&edge_key(first, second)) {
247                    for &other in incident {
248                        if other != t && member.contains(&other) && !assigned.contains(&other) {
249                            assigned.insert(other);
250                            stack.push(other);
251                        }
252                    }
253                }
254            }
255        }
256        component.sort_unstable();
257        components.push(component);
258    }
259    components
260}
261
262/// Split a region the cascade could not classify: peel off seed-plane-
263/// anchored coplanar groups (the app's planar extraction), then re-fit the
264/// remaining connected components.  Returns the partition with each piece's
265/// fit outcome.
266fn refine_region(
267    data: &MeshData,
268    tri_ids: &[u32],
269    options: &SegmentOptions,
270) -> Vec<(Vec<u32>, RegionOutcome)> {
271    let vert_ids = region_vertices(data, tri_ids);
272    let (low, high) = region_bbox(data, &vert_ids);
273    let scale = {
274        let diag = high.sub(low).length();
275        if diag > 0.0 {
276            diag
277        } else {
278            data.diag.max(1e-12)
279        }
280    };
281    let dist_gate = options.fit_tolerance * scale;
282    let cos_gate = options.planar_extraction_angle_deg.to_radians().cos();
283    let region_area: f64 = tri_ids.iter().map(|&t| data.tris[t as usize].area).sum();
284    let min_group_area = region_area * (options.planar_min_area_percent / 100.0).clamp(0.0, 1.0);
285    let min_group_tris = options.min_region_triangles.max(2);
286
287    let member: rustc_hash::FxHashSet<u32> = tri_ids.iter().copied().collect();
288    let mut order: Vec<u32> = tri_ids.to_vec();
289    order.sort_by(|&a, &b| {
290        data.tris[b as usize]
291            .area
292            .total_cmp(&data.tris[a as usize].area)
293            .then(a.cmp(&b))
294    });
295
296    let mut group_of: HashMap<u32, usize> = HashMap::default();
297    let mut planar_groups: Vec<Vec<u32>> = Vec::new();
298    let mut stack = Vec::new();
299    for &seed in &order {
300        if group_of.contains_key(&seed) {
301            continue;
302        }
303        let seed_tri = &data.tris[seed as usize];
304        let seed_normal = seed_tri.normal;
305        let seed_origin = seed_tri.centroid;
306        let coplanar = |t: u32| -> bool {
307            let tri = &data.tris[t as usize];
308            if tri.normal.dot(seed_normal) < cos_gate {
309                return false;
310            }
311            tri.verts
312                .iter()
313                .all(|&v| data.verts[v].sub(seed_origin).dot(seed_normal).abs() <= dist_gate)
314        };
315        if !coplanar(seed) {
316            continue;
317        }
318        // Grow the seed plane over not-yet-grouped region triangles.
319        let mut visited: rustc_hash::FxHashSet<u32> = rustc_hash::FxHashSet::default();
320        visited.insert(seed);
321        stack.push(seed);
322        let mut group = Vec::new();
323        while let Some(t) = stack.pop() {
324            group.push(t);
325            let tri = &data.tris[t as usize];
326            for corner in 0..3 {
327                let first = tri.verts[corner];
328                let second = tri.verts[(corner + 1) % 3];
329                if first == second {
330                    continue;
331                }
332                if let Some(incident) = data.edges.get(&edge_key(first, second)) {
333                    for &other in incident {
334                        if other == t
335                            || !member.contains(&other)
336                            || group_of.contains_key(&other)
337                            || visited.contains(&other)
338                        {
339                            continue;
340                        }
341                        if coplanar(other) {
342                            visited.insert(other);
343                            stack.push(other);
344                        }
345                    }
346                }
347            }
348        }
349        let group_area: f64 = group.iter().map(|&t| data.tris[t as usize].area).sum();
350        if group.len() >= min_group_tris && group_area >= min_group_area {
351            let id = planar_groups.len();
352            for &t in &group {
353                group_of.insert(t, id);
354            }
355            group.sort_unstable();
356            planar_groups.push(group);
357        }
358    }
359
360    let remainder: Vec<u32> = tri_ids
361        .iter()
362        .copied()
363        .filter(|t| !group_of.contains_key(t))
364        .collect();
365
366    let mut pieces = Vec::new();
367    for group in planar_groups {
368        let outcome = fit_region(data, &group, options);
369        pieces.push((group, outcome));
370    }
371    for component in connected_components(data, &remainder) {
372        let outcome = fit_region(data, &component, options);
373        pieces.push((component, outcome));
374    }
375    pieces
376}
377
378// ---------------------------------------------------------------------------
379// Entry point
380// ---------------------------------------------------------------------------
381
382/// Segment a triangle mesh into smooth regions and recognize each region's
383/// analytic carrier.  `positions` are xyz triples; `indices` is a triangle
384/// index buffer, or empty to treat `positions` as a raw soup of consecutive
385/// triangles (the binary-STL layout of `read_binary_stl`).
386pub fn segment_mesh_faces(
387    positions: &[f64],
388    indices: &[u32],
389    options: &SegmentOptions,
390) -> Result<MeshSegmentation, String> {
391    if !(options.deflection_angle_deg > 0.0) || !options.deflection_angle_deg.is_finite() {
392        return Err("segment_mesh_faces: deflection angle must be positive".into());
393    }
394    if !(options.fit_tolerance > 0.0) || !options.fit_tolerance.is_finite() {
395        return Err("segment_mesh_faces: fit tolerance must be positive".into());
396    }
397    let data = build_mesh_data(positions, indices, options)?;
398    Ok(segment_prepared(&data, options))
399}
400
401/// Segmentation core on an already-welded mesh: shared by the analysis entry
402/// (`segment_mesh_faces`) and the BREP rebuild (`mesh_regions_to_brep`), so
403/// both see identical triangle/vertex indexing.
404pub(super) fn segment_prepared(data: &MeshData, options: &SegmentOptions) -> MeshSegmentation {
405    let mut raw_regions = grow_regions(data, options);
406    absorb_tiny_regions(data, &mut raw_regions);
407
408    let mut final_regions: Vec<(Vec<u32>, RegionOutcome)> = Vec::new();
409    for region in raw_regions {
410        let outcome = fit_region(data, &region, options);
411        if outcome.accepted {
412            final_regions.push((region, outcome));
413            continue;
414        }
415        // Tangent-smooth compound (or genuinely freeform): try to split it.
416        let pieces = refine_region(data, &region, options);
417        if pieces.len() <= 1 {
418            // Refinement did not split anything; keep the region whole.
419            final_regions.push((region, outcome));
420        } else {
421            final_regions.extend(pieces);
422        }
423    }
424
425    // Reunite regions the growing/refinement pipeline split although they
426    // lie on ONE carrier (a cap triangle isolated behind skipped slivers, a
427    // blend peeled into two coaxial pieces).  Downstream consumers — the
428    // BREP rebuild above all — rely on "one region per face".
429    merge_compatible_regions(data, &mut final_regions, options);
430
431    let mut triangle_region_ids = vec![UNASSIGNED_REGION; data.tris.len()];
432    let mut regions = Vec::with_capacity(final_regions.len());
433    for (id, (tri_ids, outcome)) in final_regions.into_iter().enumerate() {
434        for &t in &tri_ids {
435            triangle_region_ids[t as usize] = id as u32;
436        }
437        regions.push(MeshRegion {
438            id: id as u32,
439            triangle_count: outcome.triangle_count,
440            area: outcome.area,
441            bbox_min: outcome.bbox.0,
442            bbox_max: outcome.bbox.1,
443            carrier: outcome.fit.carrier,
444            max_deviation: outcome.fit.max_dev,
445            rms_deviation: outcome.fit.rms_dev,
446            max_normal_angle_deg: outcome.fit.max_normal_angle_deg,
447        });
448    }
449
450    // Attach degenerate/micro triangles to an adjacent region when one
451    // exists.  Multi-pass so chains of skipped slivers resolve through
452    // their assigned neighbors.
453    let mut changed = true;
454    while changed {
455        changed = false;
456        for t in 0..data.tris.len() {
457            if triangle_region_ids[t] != UNASSIGNED_REGION {
458                continue;
459            }
460            let tri = &data.tris[t];
461            let mut adopted = UNASSIGNED_REGION;
462            for corner in 0..3 {
463                let first = tri.verts[corner];
464                let second = tri.verts[(corner + 1) % 3];
465                if first == second {
466                    continue;
467                }
468                if let Some(incident) = data.edges.get(&edge_key(first, second)) {
469                    for &other in incident {
470                        let region = triangle_region_ids[other as usize];
471                        if other as usize != t && region != UNASSIGNED_REGION {
472                            adopted = adopted.min(region);
473                        }
474                    }
475                }
476            }
477            if adopted != UNASSIGNED_REGION {
478                triangle_region_ids[t] = adopted;
479                changed = true;
480            }
481        }
482    }
483
484    MeshSegmentation {
485        triangle_region_ids,
486        regions,
487        triangle_count: data.tris.len(),
488        welded_vertex_count: data.verts.len(),
489    }
490}
491
492/// Do two accepted carriers describe the same surface (within `tol` for
493/// distances, tight angular bands for directions)?  Freeform never matches.
494fn carriers_compatible(a: &RegionCarrier, b: &RegionCarrier, tol: f64) -> bool {
495    const ANGULAR: f64 = 1e-6;
496    match (a, b) {
497        (
498            RegionCarrier::Plane {
499                origin: o1,
500                normal: n1,
501            },
502            RegionCarrier::Plane {
503                origin: o2,
504                normal: n2,
505            },
506        ) => {
507            n1.cross(*n2).length() <= ANGULAR
508                && n1.dot(*n2) > 0.0
509                && o2.sub(*o1).dot(*n1).abs() <= tol
510        }
511        (
512            RegionCarrier::Cylinder {
513                axis_point: p1,
514                axis_dir: a1,
515                radius: r1,
516                sense: s1,
517            },
518            RegionCarrier::Cylinder {
519                axis_point: p2,
520                axis_dir: a2,
521                radius: r2,
522                sense: s2,
523            },
524        ) => {
525            let offset = p2.sub(*p1);
526            s1 == s2
527                && a1.cross(*a2).length() <= ANGULAR
528                && (r1 - r2).abs() <= tol
529                && offset.sub(a1.scale(offset.dot(*a1))).length() <= tol
530        }
531        (
532            RegionCarrier::Cone {
533                apex: p1,
534                axis_dir: a1,
535                half_angle_rad: h1,
536                sense: s1,
537            },
538            RegionCarrier::Cone {
539                apex: p2,
540                axis_dir: a2,
541                half_angle_rad: h2,
542                sense: s2,
543            },
544        ) => {
545            s1 == s2
546                && a1.cross(*a2).length() <= ANGULAR
547                && a1.dot(*a2) > 0.0
548                && (h1 - h2).abs() <= ANGULAR
549                && p2.sub(*p1).length() <= tol
550        }
551        (
552            RegionCarrier::Sphere {
553                center: c1,
554                radius: r1,
555                sense: s1,
556            },
557            RegionCarrier::Sphere {
558                center: c2,
559                radius: r2,
560                sense: s2,
561            },
562        ) => s1 == s2 && c2.sub(*c1).length() <= tol && (r1 - r2).abs() <= tol,
563        (
564            RegionCarrier::Torus {
565                center: c1,
566                axis_dir: a1,
567                major_radius: mj1,
568                minor_radius: mn1,
569                sense: s1,
570            },
571            RegionCarrier::Torus {
572                center: c2,
573                axis_dir: a2,
574                major_radius: mj2,
575                minor_radius: mn2,
576                sense: s2,
577            },
578        ) => {
579            s1 == s2
580                && a1.cross(*a2).length() <= ANGULAR
581                && c2.sub(*c1).length() <= tol
582                && (mj1 - mj2).abs() <= tol
583                && (mn1 - mn2).abs() <= tol
584        }
585        _ => false,
586    }
587}
588
589/// Merge edge-adjacent regions whose accepted carriers agree, repeating
590/// until stable.  Adjacency counts paths THROUGH skipped (degenerate/micro)
591/// triangles — exactly the fences that isolate cap islands in the first
592/// place.  A merge is only applied when the union re-fits cleanly on the
593/// same carrier kind, so this pass can only improve the segmentation.
594fn merge_compatible_regions(
595    data: &MeshData,
596    regions: &mut Vec<(Vec<u32>, RegionOutcome)>,
597    options: &SegmentOptions,
598) {
599    let tol = options.fit_tolerance * data.diag.max(1e-12);
600    loop {
601        let mut region_of = vec![usize::MAX; data.tris.len()];
602        for (index, (tris, _)) in regions.iter().enumerate() {
603            for &t in tris {
604                region_of[t as usize] = index;
605            }
606        }
607        // Directly edge-adjacent pairs.
608        let mut pairs: std::collections::BTreeSet<(usize, usize)> =
609            std::collections::BTreeSet::new();
610        for incident in data.edges.values() {
611            if incident.len() != 2 {
612                continue;
613            }
614            let (a, b) = (
615                region_of[incident[0] as usize],
616                region_of[incident[1] as usize],
617            );
618            if a != b && a != usize::MAX && b != usize::MAX {
619                pairs.insert((a.min(b), a.max(b)));
620            }
621        }
622        // Pairs bridged by connected runs of region-less (skipped) triangles.
623        let mut seen = vec![false; data.tris.len()];
624        for seed in 0..data.tris.len() {
625            if seen[seed] || region_of[seed] != usize::MAX {
626                continue;
627            }
628            let mut stack = vec![seed];
629            let mut touched: Vec<usize> = Vec::new();
630            seen[seed] = true;
631            while let Some(t) = stack.pop() {
632                let tri = &data.tris[t];
633                for corner in 0..3 {
634                    let first = tri.verts[corner];
635                    let second = tri.verts[(corner + 1) % 3];
636                    if first == second {
637                        continue;
638                    }
639                    if let Some(incident) = data.edges.get(&edge_key(first, second)) {
640                        for &other in incident {
641                            let other = other as usize;
642                            if other == t {
643                                continue;
644                            }
645                            let region = region_of[other];
646                            if region == usize::MAX {
647                                if !seen[other] {
648                                    seen[other] = true;
649                                    stack.push(other);
650                                }
651                            } else if !touched.contains(&region) {
652                                touched.push(region);
653                            }
654                        }
655                    }
656                }
657            }
658            touched.sort_unstable();
659            for i in 0..touched.len() {
660                for j in i + 1..touched.len() {
661                    pairs.insert((touched[i], touched[j]));
662                }
663            }
664        }
665
666        let mut merged_any = false;
667        'pairs: for (a, b) in pairs {
668            let (first, second) = (&regions[a], &regions[b]);
669            if !first.1.accepted || !second.1.accepted {
670                continue;
671            }
672            if !carriers_compatible(&first.1.fit.carrier, &second.1.fit.carrier, tol) {
673                continue;
674            }
675            let mut union: Vec<u32> = first.0.iter().chain(second.0.iter()).copied().collect();
676            union.sort_unstable();
677            let outcome = fit_region(data, &union, options);
678            if !outcome.accepted || outcome.fit.carrier.kind() != first.1.fit.carrier.kind() {
679                continue;
680            }
681            regions[a] = (union, outcome);
682            regions.remove(b);
683            merged_any = true;
684            break 'pairs;
685        }
686        if !merged_any {
687            return;
688        }
689    }
690}