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 tangent cylinders,
263/// cones and spheres out of it first (the most specific test — a group
264/// must lie on ONE carrier at the mesh's precision), then seed-plane-
265/// anchored coplanar groups (the app's planar extraction) out of what is
266/// left, and re-fit the remaining connected components.  Planes come second
267/// because a coarse strip of a cylinder is a coplanar group too, and one
268/// wide enough to pass the planar area gate would otherwise be peeled as a
269/// plane before the cylinder it belongs to was ever tried.  Returns the
270/// partition with each piece's fit outcome.
271fn refine_region(
272    data: &MeshData,
273    tri_ids: &[u32],
274    options: &SegmentOptions,
275) -> Vec<(Vec<u32>, RegionOutcome)> {
276    let vert_ids = region_vertices(data, tri_ids);
277    let (low, high) = region_bbox(data, &vert_ids);
278    let scale = {
279        let diag = high.sub(low).length();
280        if diag > 0.0 {
281            diag
282        } else {
283            data.diag.max(1e-12)
284        }
285    };
286    let dist_gate = options.fit_tolerance * scale;
287    let cos_gate = options.planar_extraction_angle_deg.to_radians().cos();
288    let region_area: f64 = tri_ids.iter().map(|&t| data.tris[t as usize].area).sum();
289    let min_group_area = region_area * (options.planar_min_area_percent / 100.0).clamp(0.0, 1.0);
290    let min_group_tris = options.min_region_triangles.max(2);
291
292    let (mut pieces, leftover) = peel_carriers(data, tri_ids, options);
293    let member: rustc_hash::FxHashSet<u32> = leftover.iter().copied().collect();
294    let mut order: Vec<u32> = leftover.clone();
295    order.sort_by(|&a, &b| {
296        data.tris[b as usize]
297            .area
298            .total_cmp(&data.tris[a as usize].area)
299            .then(a.cmp(&b))
300    });
301
302    let mut group_of: HashMap<u32, usize> = HashMap::default();
303    let mut planar_groups: Vec<Vec<u32>> = Vec::new();
304    let mut stack = Vec::new();
305    for &seed in &order {
306        if group_of.contains_key(&seed) {
307            continue;
308        }
309        let seed_tri = &data.tris[seed as usize];
310        let seed_normal = seed_tri.normal;
311        let seed_origin = seed_tri.centroid;
312        let coplanar = |t: u32| -> bool {
313            let tri = &data.tris[t as usize];
314            if tri.normal.dot(seed_normal) < cos_gate {
315                return false;
316            }
317            tri.verts
318                .iter()
319                .all(|&v| data.verts[v].sub(seed_origin).dot(seed_normal).abs() <= dist_gate)
320        };
321        if !coplanar(seed) {
322            continue;
323        }
324        // Grow the seed plane over not-yet-grouped region triangles.
325        let mut visited: rustc_hash::FxHashSet<u32> = rustc_hash::FxHashSet::default();
326        visited.insert(seed);
327        stack.push(seed);
328        let mut group = Vec::new();
329        while let Some(t) = stack.pop() {
330            group.push(t);
331            let tri = &data.tris[t as usize];
332            for corner in 0..3 {
333                let first = tri.verts[corner];
334                let second = tri.verts[(corner + 1) % 3];
335                if first == second {
336                    continue;
337                }
338                if let Some(incident) = data.edges.get(&edge_key(first, second)) {
339                    for &other in incident {
340                        if other == t
341                            || !member.contains(&other)
342                            || group_of.contains_key(&other)
343                            || visited.contains(&other)
344                        {
345                            continue;
346                        }
347                        if coplanar(other) {
348                            visited.insert(other);
349                            stack.push(other);
350                        }
351                    }
352                }
353            }
354        }
355        let group_area: f64 = group.iter().map(|&t| data.tris[t as usize].area).sum();
356        if group.len() >= min_group_tris && group_area >= min_group_area {
357            let id = planar_groups.len();
358            for &t in &group {
359                group_of.insert(t, id);
360            }
361            group.sort_unstable();
362            planar_groups.push(group);
363        }
364    }
365
366    let remainder: Vec<u32> = leftover
367        .iter()
368        .copied()
369        .filter(|t| !group_of.contains_key(t))
370        .collect();
371
372    for group in planar_groups {
373        let outcome = fit_region(data, &group, options);
374        pieces.push((group, outcome));
375    }
376    for component in connected_components(data, &remainder) {
377        let outcome = fit_region(data, &component, options);
378        pieces.push((component, outcome));
379    }
380    pieces
381}
382
383/// Precision bar of the cylinder peel as a fraction of the compound
384/// region's scale: far below the acceptance tolerance (so the first strip
385/// past a tangent junction is refused) and far above the vertex noise of an
386/// exact tessellation.
387const CYLINDER_PEEL_PRECISION_FRACTION: f64 = 1e-5;
388/// Floor of that bar from the coordinate magnitude: single-precision
389/// sources quantize at a few ulps of their largest coordinate.
390const CYLINDER_PEEL_COORDINATE_FRACTION: f64 = 1e-6;
391/// Adjacent triangles whose normals agree within this angle are one planar
392/// facet (a tessellated strip) and are peeled together.
393const CYLINDER_PEEL_FACET_ANGLE_RAD: f64 = 1e-4;
394/// Rings of facets gathered around a seed facet before the seed fit: three
395/// rows at least, because two rows of cells lie on two coaxial circles,
396/// which every revolve fits exactly.
397const CYLINDER_PEEL_SEED_RINGS: usize = 3;
398
399/// Carrier kinds the peel tries, in cascade order, with the smallest vertex
400/// support that makes a claim at the precision bar evidence rather than an
401/// interpolation: three times the carrier's degrees of freedom (a cylinder
402/// has five, a cone six, a sphere four).  Each pass works on what the
403/// previous ones left.
404const PEEL_KINDS: [(&str, usize); 4] =
405    [("cylinder", 15), ("cone", 18), ("sphere", 12), ("torus", 21)];
406
407/// Peel single-carrier groups off a tangent-smooth compound the cascade
408/// could not classify: a rounded outline (arcs of different radii joined on
409/// rulings), a fillet running out into a boss, a dome sitting on a chamfer
410/// ring.
411///
412/// The acceptance tolerance is far too loose to separate tangent surfaces:
413/// at the junction of two arcs the first strip on the far side deviates by
414/// a fraction of the acceptance bar and the normal gate admits several
415/// more.  Growth therefore runs at a precision bar and is judged by the
416/// re-fit residual of the grown group: a wave of neighboring facets is kept
417/// when the joint fit still sits at precision, otherwise each facet is
418/// tried alone.  Facets — coplanar triangle groups, the strips of a
419/// tessellated cylinder — move as a whole so a region boundary never runs
420/// down a strip's diagonal.  Each finished group is then accepted through
421/// the ordinary cascade at the user tolerance.  Cylinders are peeled
422/// first, then cones, then spheres.  Returns the pieces and the triangles
423/// no carrier claimed.
424fn peel_carriers(
425    data: &MeshData,
426    tri_ids: &[u32],
427    options: &SegmentOptions,
428) -> (Vec<(Vec<u32>, RegionOutcome)>, Vec<u32>) {
429    let debug = std::env::var("BREP_DEBUG_SEG").is_ok();
430    let local: HashMap<u32, usize> = tri_ids
431        .iter()
432        .enumerate()
433        .map(|(index, &t)| (t, index))
434        .collect();
435    let vert_ids = region_vertices(data, tri_ids);
436    let (low, high) = region_bbox(data, &vert_ids);
437    let scale = {
438        let diag = high.sub(low).length();
439        if diag > 0.0 {
440            diag
441        } else {
442            data.diag.max(1e-12)
443        }
444    };
445    let accept_tol = options.fit_tolerance * scale;
446    let coordinate = vert_ids
447        .iter()
448        .map(|&v| {
449            let p = data.verts[v];
450            p.x.abs().max(p.y.abs()).max(p.z.abs())
451        })
452        .fold(0.0_f64, f64::max);
453    let bar = (scale * CYLINDER_PEEL_PRECISION_FRACTION)
454        .max(coordinate * CYLINDER_PEEL_COORDINATE_FRACTION);
455    if bar >= accept_tol {
456        return (Vec::new(), tri_ids.to_vec());
457    }
458    let cos_normal_gate = options.normal_tolerance_deg.to_radians().cos();
459    let cos_facet = CYLINDER_PEEL_FACET_ANGLE_RAD.cos();
460
461    // Facets: union of coplanar edge-adjacent member triangles.
462    let mut parent: Vec<usize> = (0..tri_ids.len()).collect();
463    fn find(parent: &mut [usize], mut x: usize) -> usize {
464        while parent[x] != x {
465            parent[x] = parent[parent[x]];
466            x = parent[x];
467        }
468        x
469    }
470    let mut adjacency: Vec<Vec<usize>> = vec![Vec::new(); tri_ids.len()];
471    for (index, &t) in tri_ids.iter().enumerate() {
472        let tri = &data.tris[t as usize];
473        for corner in 0..3 {
474            let first = tri.verts[corner];
475            let second = tri.verts[(corner + 1) % 3];
476            if first == second {
477                continue;
478            }
479            let Some(incident) = data.edges.get(&edge_key(first, second)) else {
480                continue;
481            };
482            for &other in incident {
483                let Some(&other_index) = local.get(&other) else {
484                    continue;
485                };
486                if other_index == index {
487                    continue;
488                }
489                adjacency[index].push(other_index);
490                if tri.normal.dot(data.tris[other as usize].normal) >= cos_facet {
491                    let a = find(&mut parent, index);
492                    let b = find(&mut parent, other_index);
493                    if a != b {
494                        parent[a] = b;
495                    }
496                }
497            }
498        }
499    }
500    let mut facet_of = vec![usize::MAX; tri_ids.len()];
501    let mut facets: Vec<Vec<usize>> = Vec::new();
502    for index in 0..tri_ids.len() {
503        let root = find(&mut parent, index);
504        if facet_of[root] == usize::MAX {
505            facet_of[root] = facets.len();
506            facets.push(Vec::new());
507        }
508        facet_of[index] = facet_of[root];
509        facets[facet_of[index]].push(index);
510    }
511    let mut facet_neighbors: Vec<Vec<usize>> = vec![Vec::new(); facets.len()];
512    for (index, others) in adjacency.iter().enumerate() {
513        for &other in others {
514            let (a, b) = (facet_of[index], facet_of[other]);
515            if a != b && !facet_neighbors[a].contains(&b) {
516                facet_neighbors[a].push(b);
517            }
518        }
519    }
520    let facet_area: Vec<f64> = facets
521        .iter()
522        .map(|facet| {
523            facet
524                .iter()
525                .map(|&index| data.tris[tri_ids[index] as usize].area)
526                .sum()
527        })
528        .collect();
529
530    let facet_tris = |facet_ids: &[usize]| -> Vec<u32> {
531        facet_ids
532            .iter()
533            .flat_map(|&facet| facets[facet].iter().map(|&index| tri_ids[index]))
534            .collect()
535    };
536    let fit_kind = |kind: &str, facet_ids: &[usize]| -> Option<CandidateFit> {
537        let tris = facet_tris(facet_ids);
538        let verts = region_vertices(data, &tris);
539        match kind {
540            "cylinder" => fit_cylinder(data, &tris, &verts),
541            "cone" => fit_cone(data, &tris, &verts, accept_tol),
542            "sphere" => fit_sphere(data, &tris, &verts),
543            "torus" => fit_torus_refined(data, &tris, &verts, 10.0 * accept_tol),
544            _ => None,
545        }
546    };
547    let fit_facets = |kind: &str, facet_ids: &[usize]| -> Option<CandidateFit> {
548        fit_kind(kind, facet_ids).filter(|fit| fit.max_dev.is_finite() && fit.max_dev <= bar)
549    };
550    // A growing torus refits from its own current carrier: the cold fit
551    // (normal-line axis, then a vertex refinement) per wave is what made a
552    // dense fillet band cost a minute.
553    let grow_fit = |kind: &str, facet_ids: &[usize], carrier: &RegionCarrier| -> Option<CandidateFit> {
554        if kind != "torus" {
555            return fit_facets(kind, facet_ids);
556        }
557        let tris = facet_tris(facet_ids);
558        let verts = region_vertices(data, &tris);
559        refine_torus_carrier(data, &tris, &verts, carrier, TORUS_REFINE_ITERATIONS)
560            .filter(|fit| fit.max_dev.is_finite() && fit.max_dev <= bar)
561    };
562    // Loose pre-filter against the current carrier: the refit decides.
563    let plausible = |carrier: &RegionCarrier, facet: usize| -> bool {
564        let sense = carrier_sense(carrier);
565        facets[facet].iter().all(|&index| {
566            let tri = &data.tris[tri_ids[index] as usize];
567            let Some(normal) = carrier_normal(carrier, tri.centroid) else {
568                return false;
569            };
570            tri.normal.dot(normal) * sense >= cos_normal_gate
571                && tri
572                    .verts
573                    .iter()
574                    .all(|&v| carrier_distance(carrier, data.verts[v]) <= accept_tol)
575        })
576    };
577
578    let mut order: Vec<usize> = (0..facets.len()).collect();
579    order.sort_by(|&a, &b| facet_area[b].total_cmp(&facet_area[a]).then(a.cmp(&b)));
580    let mut assigned = vec![false; facets.len()];
581    let mut pieces: Vec<(Vec<u32>, RegionOutcome)> = Vec::new();
582    // A torus seed is tried once across all rounds: the rounds exist for a
583    // one-strip wall whose seed neighbourhood clears once its dome is
584    // claimed, and a torus band that failed once does not become one.
585    let mut torus_tried = vec![false; facets.len()];
586    // Rounds until nothing new is claimed: a wall one strip tall has its
587    // seed neighborhood polluted by the dome or fillet beyond its rim until
588    // that neighbor has been claimed by its own pass.
589    loop {
590    let claimed = pieces.len();
591    for (kind, min_vertices) in PEEL_KINDS {
592    let mut tried = vec![false; facets.len()];
593    for &seed in &order {
594        if assigned[seed] || tried[seed] || (kind == "torus" && torus_tried[seed]) {
595            continue;
596        }
597        tried[seed] = true;
598        if kind == "torus" {
599            torus_tried[seed] = true;
600        }
601        let mut group = vec![seed];
602        let mut in_group = vec![false; facets.len()];
603        in_group[seed] = true;
604        let mut frontier = vec![seed];
605        for _ in 0..CYLINDER_PEEL_SEED_RINGS {
606            let mut next = Vec::new();
607            for &facet in &frontier {
608                for &other in &facet_neighbors[facet] {
609                    if !assigned[other] && !in_group[other] {
610                        in_group[other] = true;
611                        group.push(other);
612                        next.push(other);
613                    }
614                }
615            }
616            frontier = next;
617        }
618        let Some(seed_fit) = fit_facets(kind, &group) else {
619            continue;
620        };
621        let mut carrier = seed_fit.carrier;
622        let mut blocked = vec![false; facets.len()];
623        loop {
624            let mut wave = Vec::new();
625            for &facet in &group {
626                for &other in &facet_neighbors[facet] {
627                    if !assigned[other]
628                        && !in_group[other]
629                        && !blocked[other]
630                        && !wave.contains(&other)
631                        && plausible(&carrier, other)
632                    {
633                        wave.push(other);
634                    }
635                }
636            }
637            if wave.is_empty() {
638                break;
639            }
640            let mut trial = group.clone();
641            trial.extend(wave.iter().copied());
642            if let Some(fit) = grow_fit(kind, &trial, &carrier) {
643                for &facet in &wave {
644                    in_group[facet] = true;
645                }
646                group = trial;
647                carrier = fit.carrier;
648                continue;
649            }
650            // The wave crossed a junction: admit its facets one at a time.
651            // A torus admits the facets its current carrier already holds at
652            // the bar, then refits once: a refit per junction facet is what
653            // a dense freeform compound cannot afford.
654            if kind == "torus" {
655                let holds = |facet: usize| {
656                    facets[facet].iter().all(|&index| {
657                        data.tris[tri_ids[index] as usize]
658                            .verts
659                            .iter()
660                            .all(|&v| carrier_distance(&carrier, data.verts[v]) <= bar)
661                    })
662                };
663                let (inside, outside): (Vec<usize>, Vec<usize>) =
664                    wave.into_iter().partition(|&facet| holds(facet));
665                for facet in outside {
666                    blocked[facet] = true;
667                }
668                if inside.is_empty() {
669                    break;
670                }
671                let mut trial = group.clone();
672                trial.extend(inside.iter().copied());
673                let Some(fit) = grow_fit(kind, &trial, &carrier) else {
674                    for facet in inside {
675                        blocked[facet] = true;
676                    }
677                    break;
678                };
679                for &facet in &inside {
680                    in_group[facet] = true;
681                }
682                group = trial;
683                carrier = fit.carrier;
684                continue;
685            }
686            let mut accepted_any = false;
687            for facet in wave {
688                let mut trial = group.clone();
689                trial.push(facet);
690                if let Some(fit) = grow_fit(kind, &trial, &carrier) {
691                    in_group[facet] = true;
692                    group = trial;
693                    carrier = fit.carrier;
694                    accepted_any = true;
695                } else {
696                    blocked[facet] = true;
697                }
698            }
699            if !accepted_any {
700                break;
701            }
702        }
703        let mut tris = facet_tris(&group);
704        if tris.len() < options.min_region_triangles.max(4)
705            || region_vertices(data, &tris).len() < min_vertices
706        {
707            continue;
708        }
709        tris.sort_unstable();
710        let outcome = fit_peeled(data, &tris, options);
711        if debug {
712            let size = match outcome.fit.carrier {
713                RegionCarrier::Cylinder { radius, .. } | RegionCarrier::Sphere { radius, .. } => {
714                    format!(" r={radius:.4}")
715                }
716                RegionCarrier::Cone { half_angle_rad, .. } => {
717                    format!(" ha={:.2}deg", half_angle_rad.to_degrees())
718                }
719                _ => String::new(),
720            };
721            let (low, high) = region_bbox(data, &region_vertices(data, &tris));
722            eprintln!(
723                "[seg] peel {kind}: seed facet {seed} grew {} facets / {} tris at {bar:.3e}; cascade {}{size} accepted={} max_dev={:.3e} max_ang={:.2} bbox [{:.2},{:.2},{:.2}]..[{:.2},{:.2},{:.2}]",
724                group.len(),
725                tris.len(),
726                outcome.fit.carrier.kind(),
727                outcome.accepted,
728                outcome.fit.max_dev,
729                outcome.fit.max_normal_angle_deg,
730                low.x, low.y, low.z, high.x, high.y, high.z
731            );
732        }
733        if !outcome.accepted || outcome.fit.carrier.kind() != kind {
734            continue;
735        }
736        // A torus is claimed only as a FULL band around its axis — the
737        // shape a mesh rebuild can bound by closed rings. A partial torus
738        // (a fillet running along an arc of an outline) stays with the
739        // planar strips it has always been read as.
740        if kind == "torus" && !covers_full_azimuth(data, &tris, &outcome.fit.carrier) {
741            continue;
742        }
743        for &facet in &group {
744            assigned[facet] = true;
745        }
746        pieces.push((tris, outcome));
747    }
748    }
749    if pieces.len() == claimed {
750        break;
751    }
752    }
753    if pieces.is_empty() {
754        return (pieces, tri_ids.to_vec());
755    }
756    // A leftover strip whose every peeled neighbor is the same carrier is an
757    // interior gap — a tessellator T-vertex on a rim chord — not a
758    // junction: it rejoins that carrier when the union still passes the
759    // cascade at the user tolerance.  A strip straddling the tangent point
760    // of two different carriers stays out and becomes its own small facet.
761    let mut piece_of_facet = vec![usize::MAX; facets.len()];
762    for (piece, (tris, _)) in pieces.iter().enumerate() {
763        for &t in tris {
764            piece_of_facet[facet_of[local[&t]]] = piece;
765        }
766    }
767    let leftover_tris: Vec<u32> = (0..tri_ids.len())
768        .filter(|&index| !assigned[facet_of[index]])
769        .map(|index| tri_ids[index])
770        .collect();
771    let compat_tol = options.fit_tolerance * data.diag.max(1e-12);
772    let mut remainder = Vec::new();
773    for component in connected_components(data, &leftover_tris) {
774        let mut adjacent_pieces: Vec<usize> = Vec::new();
775        for &t in &component {
776            for &other in &facet_neighbors[facet_of[local[&t]]] {
777                let piece = piece_of_facet[other];
778                if piece != usize::MAX && !adjacent_pieces.contains(&piece) {
779                    adjacent_pieces.push(piece);
780                }
781            }
782        }
783        let Some(&first) = adjacent_pieces.first() else {
784            remainder.extend(component);
785            continue;
786        };
787        let same_carrier = adjacent_pieces.iter().all(|&piece| {
788            carriers_compatible(
789                &pieces[first].1.fit.carrier,
790                &pieces[piece].1.fit.carrier,
791                compat_tol,
792                true,
793            )
794        });
795        if !same_carrier {
796            remainder.extend(straddle_rejoin(data, options, bar, &mut pieces, &adjacent_pieces, component));
797            continue;
798        }
799        let mut union: Vec<u32> = pieces[first].0.iter().chain(component.iter()).copied().collect();
800        union.sort_unstable();
801        let outcome = fit_peeled(data, &union, options);
802        let rejoined = outcome.accepted
803            && outcome.fit.carrier.kind() == pieces[first].1.fit.carrier.kind();
804        if debug {
805            eprintln!(
806                "[seg] peel: leftover of {} tris between {} peeled piece(s): union {} accepted={} max_dev={:.3e}",
807                component.len(),
808                adjacent_pieces.len(),
809                outcome.fit.carrier.kind(),
810                outcome.accepted,
811                outcome.fit.max_dev
812            );
813        }
814        if rejoined {
815            pieces[first] = (union, outcome);
816        } else {
817            remainder.extend(straddle_rejoin(data, options, bar, &mut pieces, &adjacent_pieces, component));
818        }
819    }
820    remainder.sort_unstable();
821    (pieces, remainder)
822}
823
824/// The cascade's verdict on a peeled piece. The cascade keeps its plain
825/// torus fit; a torus the peel grew and judged at the precision bar carries
826/// the vertex-refined carrier instead, so a mesh rebuild gets its true
827/// axis and radii.
828fn fit_peeled(data: &MeshData, tris: &[u32], options: &SegmentOptions) -> RegionOutcome {
829    let mut outcome = fit_region(data, tris, options);
830    if outcome.accepted && outcome.fit.carrier.kind() == "torus" {
831        let verts = region_vertices(data, tris);
832        if let Some(refined) =
833            refine_torus_carrier(data, tris, &verts, &outcome.fit.carrier, TORUS_REFINE_ITERATIONS)
834        {
835            if refined.max_dev < outcome.fit.max_dev {
836                outcome.fit = refined;
837            }
838        }
839    }
840    outcome
841}
842
843/// Whether a torus region's vertices surround its axis with no azimuthal gap
844/// wider than a twelfth of a turn.
845fn covers_full_azimuth(data: &MeshData, tri_ids: &[u32], carrier: &RegionCarrier) -> bool {
846    let RegionCarrier::Torus { center, axis_dir, .. } = carrier else {
847        return false;
848    };
849    let Ok(axis) = axis_dir.normalized() else {
850        return false;
851    };
852    let Ok(x_axis) = axis.perpendicular() else {
853        return false;
854    };
855    let y_axis = axis.cross(x_axis);
856    let mut angles = region_vertices(data, tri_ids)
857        .into_iter()
858        .map(|v| {
859            let d = data.verts[v].sub(*center);
860            d.dot(y_axis).atan2(d.dot(x_axis))
861        })
862        .collect::<Vec<_>>();
863    if angles.len() < 3 {
864        return false;
865    }
866    angles.sort_by(f64::total_cmp);
867    let wrap = angles[0] + std::f64::consts::TAU - angles[angles.len() - 1];
868    let widest = angles
869        .windows(2)
870        .map(|pair| pair[1] - pair[0])
871        .fold(wrap, f64::max);
872    widest <= std::f64::consts::TAU / 12.0
873}
874
875/// A leftover strip at the tangent junction of DIFFERENT carriers (the
876/// first row of a fillet torus against its cylinder) is not one carrier's
877/// gap, but each of its triangles still lies on exactly one side. A triangle
878/// joins the adjacent piece whose carrier holds all three of its vertices
879/// within the peel's precision bar (the closest when several do), and a
880/// piece that took any is re-fitted and must still pass the cascade as the
881/// same kind, or it keeps its old triangles and they stay out. A triangle
882/// no adjacent carrier holds at the bar is returned.
883fn straddle_rejoin(
884    data: &MeshData,
885    options: &SegmentOptions,
886    bar: f64,
887    pieces: &mut [(Vec<u32>, RegionOutcome)],
888    adjacent_pieces: &[usize],
889    component: Vec<u32>,
890) -> Vec<u32> {
891    let mut taken: Vec<Vec<u32>> = vec![Vec::new(); adjacent_pieces.len()];
892    let mut left = Vec::new();
893    for t in component {
894        let tri = &data.tris[t as usize];
895        let best = adjacent_pieces
896            .iter()
897            .enumerate()
898            .filter_map(|(slot, &piece)| {
899                let carrier = &pieces[piece].1.fit.carrier;
900                let worst = tri
901                    .verts
902                    .iter()
903                    .map(|&v| carrier_distance(carrier, data.verts[v]))
904                    .fold(0.0_f64, f64::max);
905                (worst <= bar).then_some((slot, worst))
906            })
907            .min_by(|a, b| a.1.total_cmp(&b.1));
908        match best {
909            Some((slot, _)) => taken[slot].push(t),
910            None => left.push(t),
911        }
912    }
913    for (slot, extra) in taken.into_iter().enumerate() {
914        if extra.is_empty() {
915            continue;
916        }
917        let piece = adjacent_pieces[slot];
918        let mut union: Vec<u32> = pieces[piece].0.iter().chain(extra.iter()).copied().collect();
919        union.sort_unstable();
920        let outcome = fit_peeled(data, &union, options);
921        if outcome.accepted && outcome.fit.carrier.kind() == pieces[piece].1.fit.carrier.kind() {
922            pieces[piece] = (union, outcome);
923        } else {
924            left.extend(extra);
925        }
926    }
927    left
928}
929
930// ---------------------------------------------------------------------------
931// Entry point
932// ---------------------------------------------------------------------------
933
934/// Segment a triangle mesh into smooth regions and recognize each region's
935/// analytic carrier.  `positions` are xyz triples; `indices` is a triangle
936/// index buffer, or empty to treat `positions` as a raw soup of consecutive
937/// triangles (the binary-STL layout of `read_binary_stl`).
938pub fn segment_mesh_faces(
939    positions: &[f64],
940    indices: &[u32],
941    options: &SegmentOptions,
942) -> Result<MeshSegmentation, String> {
943    if !(options.deflection_angle_deg > 0.0) || !options.deflection_angle_deg.is_finite() {
944        return Err("segment_mesh_faces: deflection angle must be positive".into());
945    }
946    if !(options.fit_tolerance > 0.0) || !options.fit_tolerance.is_finite() {
947        return Err("segment_mesh_faces: fit tolerance must be positive".into());
948    }
949    let data = build_mesh_data(positions, indices, options)?;
950    Ok(segment_prepared(&data, options))
951}
952
953/// Segmentation core on an already-welded mesh: shared by the analysis entry
954/// (`segment_mesh_faces`) and the BREP rebuild (`mesh_regions_to_brep`), so
955/// both see identical triangle/vertex indexing.
956pub(super) fn segment_prepared(data: &MeshData, options: &SegmentOptions) -> MeshSegmentation {
957    let mut raw_regions = grow_regions(data, options);
958    absorb_tiny_regions(data, &mut raw_regions);
959
960    let mut final_regions: Vec<(Vec<u32>, RegionOutcome)> = Vec::new();
961    for region in raw_regions {
962        let outcome = fit_region(data, &region, options);
963        if outcome.accepted {
964            final_regions.push((region, outcome));
965            continue;
966        }
967        // Tangent-smooth compound (or genuinely freeform): try to split it.
968        let pieces = refine_region(data, &region, options);
969        if pieces.len() <= 1 {
970            // Refinement did not split anything; keep the region whole.
971            final_regions.push((region, outcome));
972        } else {
973            final_regions.extend(pieces);
974        }
975    }
976
977    // Reunite regions the growing/refinement pipeline split although they
978    // lie on ONE carrier (a cap triangle isolated behind skipped slivers, a
979    // blend peeled into two coaxial pieces).  Downstream consumers — the
980    // BREP rebuild above all — rely on "one region per face".
981    merge_compatible_regions(data, &mut final_regions, options);
982
983    let mut triangle_region_ids = vec![UNASSIGNED_REGION; data.tris.len()];
984    let mut regions = Vec::with_capacity(final_regions.len());
985    for (id, (tri_ids, outcome)) in final_regions.into_iter().enumerate() {
986        for &t in &tri_ids {
987            triangle_region_ids[t as usize] = id as u32;
988        }
989        regions.push(MeshRegion {
990            id: id as u32,
991            triangle_count: outcome.triangle_count,
992            area: outcome.area,
993            bbox_min: outcome.bbox.0,
994            bbox_max: outcome.bbox.1,
995            carrier: outcome.fit.carrier,
996            max_deviation: outcome.fit.max_dev,
997            rms_deviation: outcome.fit.rms_dev,
998            max_normal_angle_deg: outcome.fit.max_normal_angle_deg,
999        });
1000    }
1001
1002    // Attach degenerate/micro triangles to an adjacent region when one
1003    // exists: the neighbor whose carrier the sliver's vertices actually sit
1004    // on (a T-junction needle lies in one of its neighboring planes, not
1005    // on the fillet it happens to touch first).  Multi-pass so chains of
1006    // skipped slivers resolve through their assigned neighbors.
1007    let mut changed = true;
1008    while changed {
1009        changed = false;
1010        for t in 0..data.tris.len() {
1011            if triangle_region_ids[t] != UNASSIGNED_REGION {
1012                continue;
1013            }
1014            let tri = &data.tris[t];
1015            let mut adopted: Option<(f64, u32)> = None;
1016            for corner in 0..3 {
1017                let first = tri.verts[corner];
1018                let second = tri.verts[(corner + 1) % 3];
1019                if first == second {
1020                    continue;
1021                }
1022                if let Some(incident) = data.edges.get(&edge_key(first, second)) {
1023                    for &other in incident {
1024                        let region = triangle_region_ids[other as usize];
1025                        if other as usize == t || region == UNASSIGNED_REGION {
1026                            continue;
1027                        }
1028                        let carrier = &regions[region as usize].carrier;
1029                        let distance = tri
1030                            .verts
1031                            .iter()
1032                            .map(|&v| carrier_distance(carrier, data.verts[v]))
1033                            .fold(0.0_f64, f64::max);
1034                        let candidate = (distance, region);
1035                        adopted = Some(match adopted {
1036                            Some(current)
1037                                if current.0 < candidate.0
1038                                    || (current.0 == candidate.0 && current.1 <= candidate.1) =>
1039                            {
1040                                current
1041                            }
1042                            _ => candidate,
1043                        });
1044                    }
1045                }
1046            }
1047            if let Some((_, region)) = adopted {
1048                triangle_region_ids[t] = region;
1049                changed = true;
1050            }
1051        }
1052    }
1053
1054    MeshSegmentation {
1055        triangle_region_ids,
1056        regions,
1057        triangle_count: data.tris.len(),
1058        welded_vertex_count: data.verts.len(),
1059    }
1060}
1061
1062/// Outward carrier normal at a point (before the region's sense), or `None`
1063/// where the field is singular (on an axis, at a center) or for `Freeform`.
1064fn carrier_normal(carrier: &RegionCarrier, p: Vec3) -> Option<Vec3> {
1065    match carrier {
1066        RegionCarrier::Plane { normal, .. } => Some(*normal),
1067        RegionCarrier::Cylinder {
1068            axis_point,
1069            axis_dir,
1070            ..
1071        } => {
1072            let d = p.sub(*axis_point);
1073            d.sub(axis_dir.scale(d.dot(*axis_dir))).normalized().ok()
1074        }
1075        RegionCarrier::Cone {
1076            apex,
1077            axis_dir,
1078            half_angle_rad,
1079            ..
1080        } => {
1081            let d = p.sub(*apex);
1082            let radial = d.sub(axis_dir.scale(d.dot(*axis_dir))).normalized().ok()?;
1083            let (sin_a, cos_a) = half_angle_rad.sin_cos();
1084            Some(radial.scale(cos_a).sub(axis_dir.scale(sin_a)))
1085        }
1086        RegionCarrier::Sphere { center, .. } => p.sub(*center).normalized().ok(),
1087        RegionCarrier::Torus {
1088            center,
1089            axis_dir,
1090            major_radius,
1091            ..
1092        } => {
1093            let d = p.sub(*center);
1094            let s = d.dot(*axis_dir);
1095            let radial = d.sub(axis_dir.scale(s)).normalized().ok()?;
1096            let tube_center = center.add(radial.scale(*major_radius));
1097            p.sub(tube_center).normalized().ok()
1098        }
1099        RegionCarrier::Freeform => None,
1100    }
1101}
1102
1103/// The region's orientation sense as a sign, `1.0` for planes and freeform.
1104fn carrier_sense(carrier: &RegionCarrier) -> f64 {
1105    match carrier {
1106        RegionCarrier::Cylinder { sense, .. }
1107        | RegionCarrier::Cone { sense, .. }
1108        | RegionCarrier::Sphere { sense, .. }
1109        | RegionCarrier::Torus { sense, .. } => f64::from(*sense),
1110        _ => 1.0,
1111    }
1112}
1113
1114/// Distance of a point from a carrier; `Freeform` is infinitely far.
1115fn carrier_distance(carrier: &RegionCarrier, p: Vec3) -> f64 {
1116    match carrier {
1117        RegionCarrier::Plane { origin, normal } => p.sub(*origin).dot(*normal).abs(),
1118        RegionCarrier::Cylinder {
1119            axis_point,
1120            axis_dir,
1121            radius,
1122            ..
1123        } => {
1124            let d = p.sub(*axis_point);
1125            (d.sub(axis_dir.scale(d.dot(*axis_dir))).length() - radius).abs()
1126        }
1127        RegionCarrier::Cone {
1128            apex,
1129            axis_dir,
1130            half_angle_rad,
1131            ..
1132        } => {
1133            let d = p.sub(*apex);
1134            let s = d.dot(*axis_dir);
1135            let r = d.sub(axis_dir.scale(s)).length();
1136            ((r - s * half_angle_rad.tan()) * half_angle_rad.cos()).abs()
1137        }
1138        RegionCarrier::Sphere { center, radius, .. } => (p.sub(*center).length() - radius).abs(),
1139        RegionCarrier::Torus {
1140            center,
1141            axis_dir,
1142            major_radius,
1143            minor_radius,
1144            ..
1145        } => {
1146            let d = p.sub(*center);
1147            let s = d.dot(*axis_dir);
1148            let rho = d.sub(axis_dir.scale(s)).length();
1149            ((rho - major_radius).hypot(s) - minor_radius).abs()
1150        }
1151        RegionCarrier::Freeform => f64::INFINITY,
1152    }
1153}
1154
1155/// Do two accepted carriers describe the same surface (within `tol` for
1156/// distances, tight angular bands for directions)?  Freeform never matches.
1157/// `adjacent` says the regions share an edge: only then may two cylinder
1158/// fits disagree on their axis by the noise over the axial extent, because
1159/// two disjoint faces of one infinite cylinder (the two ends of a mirrored
1160/// part, the two levels of a slot) must stay two regions.
1161fn carriers_compatible(a: &RegionCarrier, b: &RegionCarrier, tol: f64, adjacent: bool) -> bool {
1162    const ANGULAR: f64 = 1e-6;
1163    match (a, b) {
1164        (
1165            RegionCarrier::Plane {
1166                origin: o1,
1167                normal: n1,
1168            },
1169            RegionCarrier::Plane {
1170                origin: o2,
1171                normal: n2,
1172            },
1173        ) => {
1174            n1.cross(*n2).length() <= ANGULAR
1175                && n1.dot(*n2) > 0.0
1176                && o2.sub(*o1).dot(*n1).abs() <= tol
1177        }
1178        (
1179            RegionCarrier::Cylinder {
1180                axis_point: p1,
1181                axis_dir: a1,
1182                radius: r1,
1183                sense: s1,
1184            },
1185            RegionCarrier::Cylinder {
1186                axis_point: p2,
1187                axis_dir: a2,
1188                radius: r2,
1189                sense: s2,
1190            },
1191        ) => {
1192            // Independent fits of one cylinder from noisy vertices disagree
1193            // on the axis direction by the noise over the axial extent; the
1194            // band is the angle that moves the surface by `tol` at the rim.
1195            // The union re-fit is the real test.
1196            let angular = if adjacent {
1197                ANGULAR.max(tol / r1.max(*r2).max(1e-12))
1198            } else {
1199                ANGULAR
1200            };
1201            let offset = p2.sub(*p1);
1202            s1 == s2
1203                && a1.cross(*a2).length() <= angular
1204                && (r1 - r2).abs() <= tol
1205                && offset.sub(a1.scale(offset.dot(*a1))).length() <= tol
1206        }
1207        (
1208            RegionCarrier::Cone {
1209                apex: p1,
1210                axis_dir: a1,
1211                half_angle_rad: h1,
1212                sense: s1,
1213            },
1214            RegionCarrier::Cone {
1215                apex: p2,
1216                axis_dir: a2,
1217                half_angle_rad: h2,
1218                sense: s2,
1219            },
1220        ) => {
1221            s1 == s2
1222                && a1.cross(*a2).length() <= ANGULAR
1223                && a1.dot(*a2) > 0.0
1224                && (h1 - h2).abs() <= ANGULAR
1225                && p2.sub(*p1).length() <= tol
1226        }
1227        (
1228            RegionCarrier::Sphere {
1229                center: c1,
1230                radius: r1,
1231                sense: s1,
1232            },
1233            RegionCarrier::Sphere {
1234                center: c2,
1235                radius: r2,
1236                sense: s2,
1237            },
1238        ) => s1 == s2 && c2.sub(*c1).length() <= tol && (r1 - r2).abs() <= tol,
1239        (
1240            RegionCarrier::Torus {
1241                center: c1,
1242                axis_dir: a1,
1243                major_radius: mj1,
1244                minor_radius: mn1,
1245                sense: s1,
1246            },
1247            RegionCarrier::Torus {
1248                center: c2,
1249                axis_dir: a2,
1250                major_radius: mj2,
1251                minor_radius: mn2,
1252                sense: s2,
1253            },
1254        ) => {
1255            s1 == s2
1256                && a1.cross(*a2).length() <= ANGULAR
1257                && c2.sub(*c1).length() <= tol
1258                && (mj1 - mj2).abs() <= tol
1259                && (mn1 - mn2).abs() <= tol
1260        }
1261        _ => false,
1262    }
1263}
1264
1265/// Merge edge-adjacent regions whose accepted carriers agree, repeating
1266/// until stable.  Adjacency counts paths THROUGH skipped (degenerate/micro)
1267/// triangles — exactly the fences that isolate cap islands in the first
1268/// place.  A merge is only applied when the union re-fits cleanly on the
1269/// same carrier kind, so this pass can only improve the segmentation.
1270fn merge_compatible_regions(
1271    data: &MeshData,
1272    regions: &mut Vec<(Vec<u32>, RegionOutcome)>,
1273    options: &SegmentOptions,
1274) {
1275    let tol = options.fit_tolerance * data.diag.max(1e-12);
1276    loop {
1277        let mut region_of = vec![usize::MAX; data.tris.len()];
1278        for (index, (tris, _)) in regions.iter().enumerate() {
1279            for &t in tris {
1280                region_of[t as usize] = index;
1281            }
1282        }
1283        // Directly edge-adjacent pairs.
1284        let mut direct: std::collections::BTreeSet<(usize, usize)> =
1285            std::collections::BTreeSet::new();
1286        for incident in data.edges.values() {
1287            if incident.len() != 2 {
1288                continue;
1289            }
1290            let (a, b) = (
1291                region_of[incident[0] as usize],
1292                region_of[incident[1] as usize],
1293            );
1294            if a != b && a != usize::MAX && b != usize::MAX {
1295                direct.insert((a.min(b), a.max(b)));
1296            }
1297        }
1298        let mut pairs = direct.clone();
1299        // Pairs bridged by connected runs of region-less (skipped) triangles.
1300        let mut seen = vec![false; data.tris.len()];
1301        for seed in 0..data.tris.len() {
1302            if seen[seed] || region_of[seed] != usize::MAX {
1303                continue;
1304            }
1305            let mut stack = vec![seed];
1306            let mut touched: Vec<usize> = Vec::new();
1307            seen[seed] = true;
1308            while let Some(t) = stack.pop() {
1309                let tri = &data.tris[t];
1310                for corner in 0..3 {
1311                    let first = tri.verts[corner];
1312                    let second = tri.verts[(corner + 1) % 3];
1313                    if first == second {
1314                        continue;
1315                    }
1316                    if let Some(incident) = data.edges.get(&edge_key(first, second)) {
1317                        for &other in incident {
1318                            let other = other as usize;
1319                            if other == t {
1320                                continue;
1321                            }
1322                            let region = region_of[other];
1323                            if region == usize::MAX {
1324                                if !seen[other] {
1325                                    seen[other] = true;
1326                                    stack.push(other);
1327                                }
1328                            } else if !touched.contains(&region) {
1329                                touched.push(region);
1330                            }
1331                        }
1332                    }
1333                }
1334            }
1335            touched.sort_unstable();
1336            for i in 0..touched.len() {
1337                for j in i + 1..touched.len() {
1338                    pairs.insert((touched[i], touched[j]));
1339                }
1340            }
1341        }
1342
1343        let mut merged_any = false;
1344        'pairs: for (a, b) in pairs {
1345            let (first, second) = (&regions[a], &regions[b]);
1346            if !first.1.accepted || !second.1.accepted {
1347                continue;
1348            }
1349            if !carriers_compatible(
1350                &first.1.fit.carrier,
1351                &second.1.fit.carrier,
1352                tol,
1353                direct.contains(&(a, b)),
1354            ) {
1355                continue;
1356            }
1357            let mut union: Vec<u32> = first.0.iter().chain(second.0.iter()).copied().collect();
1358            union.sort_unstable();
1359            let outcome = fit_region(data, &union, options);
1360            if !outcome.accepted || outcome.fit.carrier.kind() != first.1.fit.carrier.kind() {
1361                continue;
1362            }
1363            regions[a] = (union, outcome);
1364            regions.remove(b);
1365            merged_any = true;
1366            break 'pairs;
1367        }
1368        if !merged_any {
1369            return;
1370        }
1371    }
1372}