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.
404///
405/// The cone pass runs before the cylinder pass: a cone strip one column
406/// wide (its vertices on the two rim circles) reads as a cylinder about its
407/// generatrix at 5.6e-5 against a 7.5e-5 bar, and such strips cut the cone
408/// into patches its own pass could no longer seed; claimed whole first, the
409/// cone leaves no strip to misread. A cylinder offered to the cone pass fits
410/// a cone of zero half-angle that the cascade reads as a cylinder, which the
411/// kind check declines, so the cylinder pass still claims it.
412const PEEL_KINDS: [(&str, usize); 4] =
413    [("cone", 18), ("cylinder", 15), ("sphere", 12), ("torus", 21)];
414
415/// Peel single-carrier groups off a tangent-smooth compound the cascade
416/// could not classify: a rounded outline (arcs of different radii joined on
417/// rulings), a fillet running out into a boss, a dome sitting on a chamfer
418/// ring.
419///
420/// The acceptance tolerance is far too loose to separate tangent surfaces:
421/// at the junction of two arcs the first strip on the far side deviates by
422/// a fraction of the acceptance bar and the normal gate admits several
423/// more.  Growth therefore runs at a precision bar and is judged by the
424/// re-fit residual of the grown group: a wave of neighboring facets is kept
425/// when the joint fit still sits at precision, otherwise each facet is
426/// tried alone.  Facets — coplanar triangle groups, the strips of a
427/// tessellated cylinder — move as a whole so a region boundary never runs
428/// down a strip's diagonal.  Each finished group is then accepted through
429/// the ordinary cascade at the user tolerance.  Cylinders are peeled
430/// first, then cones, then spheres.  Returns the pieces and the triangles
431/// no carrier claimed.
432fn peel_carriers(
433    data: &MeshData,
434    tri_ids: &[u32],
435    options: &SegmentOptions,
436) -> (Vec<(Vec<u32>, RegionOutcome)>, Vec<u32>) {
437    let debug = std::env::var("BREP_DEBUG_SEG").is_ok();
438    let started = debug.then(web_time::Instant::now);
439    let local: HashMap<u32, usize> = tri_ids
440        .iter()
441        .enumerate()
442        .map(|(index, &t)| (t, index))
443        .collect();
444    let vert_ids = region_vertices(data, tri_ids);
445    let (low, high) = region_bbox(data, &vert_ids);
446    let scale = {
447        let diag = high.sub(low).length();
448        if diag > 0.0 {
449            diag
450        } else {
451            data.diag.max(1e-12)
452        }
453    };
454    let accept_tol = options.fit_tolerance * scale;
455    let coordinate = vert_ids
456        .iter()
457        .map(|&v| {
458            let p = data.verts[v];
459            p.x.abs().max(p.y.abs()).max(p.z.abs())
460        })
461        .fold(0.0_f64, f64::max);
462    let bar = (scale * CYLINDER_PEEL_PRECISION_FRACTION)
463        .max(coordinate * CYLINDER_PEEL_COORDINATE_FRACTION);
464    if bar >= accept_tol {
465        return (Vec::new(), tri_ids.to_vec());
466    }
467    let cos_normal_gate = options.normal_tolerance_deg.to_radians().cos();
468    let cos_facet = CYLINDER_PEEL_FACET_ANGLE_RAD.cos();
469
470    // Facets: union of coplanar edge-adjacent member triangles.
471    let mut parent: Vec<usize> = (0..tri_ids.len()).collect();
472    fn find(parent: &mut [usize], x: usize) -> usize {
473        crate::union_find::find_root(parent, x)
474    }
475    let mut adjacency: Vec<Vec<usize>> = vec![Vec::new(); tri_ids.len()];
476    for (index, &t) in tri_ids.iter().enumerate() {
477        let tri = &data.tris[t as usize];
478        for corner in 0..3 {
479            let first = tri.verts[corner];
480            let second = tri.verts[(corner + 1) % 3];
481            if first == second {
482                continue;
483            }
484            let Some(incident) = data.edges.get(&edge_key(first, second)) else {
485                continue;
486            };
487            for &other in incident {
488                let Some(&other_index) = local.get(&other) else {
489                    continue;
490                };
491                if other_index == index {
492                    continue;
493                }
494                adjacency[index].push(other_index);
495                if tri.normal.dot(data.tris[other as usize].normal) >= cos_facet {
496                    let a = find(&mut parent, index);
497                    let b = find(&mut parent, other_index);
498                    if a != b {
499                        parent[a] = b;
500                    }
501                }
502            }
503        }
504    }
505    let mut facet_of = vec![usize::MAX; tri_ids.len()];
506    let mut facets: Vec<Vec<usize>> = Vec::new();
507    for index in 0..tri_ids.len() {
508        let root = find(&mut parent, index);
509        if facet_of[root] == usize::MAX {
510            facet_of[root] = facets.len();
511            facets.push(Vec::new());
512        }
513        facet_of[index] = facet_of[root];
514        facets[facet_of[index]].push(index);
515    }
516    let mut facet_neighbors: Vec<Vec<usize>> = vec![Vec::new(); facets.len()];
517    for (index, others) in adjacency.iter().enumerate() {
518        for &other in others {
519            let (a, b) = (facet_of[index], facet_of[other]);
520            if a != b && !facet_neighbors[a].contains(&b) {
521                facet_neighbors[a].push(b);
522            }
523        }
524    }
525    let facet_area: Vec<f64> = facets
526        .iter()
527        .map(|facet| {
528            facet
529                .iter()
530                .map(|&index| data.tris[tri_ids[index] as usize].area)
531                .sum()
532        })
533        .collect();
534
535    let facet_tris = |facet_ids: &[usize]| -> Vec<u32> {
536        facet_ids
537            .iter()
538            .flat_map(|&facet| facets[facet].iter().map(|&index| tri_ids[index]))
539            .collect()
540    };
541    let fit_kind = |kind: &str, facet_ids: &[usize]| -> Option<CandidateFit> {
542        let tris = facet_tris(facet_ids);
543        let verts = region_vertices(data, &tris);
544        match kind {
545            "cylinder" => fit_cylinder(data, &tris, &verts),
546            "cone" => fit_cone(data, &tris, &verts, accept_tol),
547            "sphere" => fit_sphere(data, &tris, &verts),
548            "torus" => fit_torus_refined(data, &tris, &verts, 10.0 * accept_tol),
549            _ => None,
550        }
551    };
552    let fit_facets = |kind: &str, facet_ids: &[usize]| -> Option<CandidateFit> {
553        fit_kind(kind, facet_ids).filter(|fit| fit.max_dev.is_finite() && fit.max_dev <= bar)
554    };
555    // A growing torus refits from its own current carrier: the cold fit
556    // (normal-line axis, then a vertex refinement) per wave is what made a
557    // dense fillet band cost a minute.
558    let grow_fit = |kind: &str, facet_ids: &[usize], carrier: &RegionCarrier| -> Option<CandidateFit> {
559        if kind != "torus" {
560            return fit_facets(kind, facet_ids);
561        }
562        let tris = facet_tris(facet_ids);
563        let verts = region_vertices(data, &tris);
564        refine_torus_carrier(data, &tris, &verts, carrier, TORUS_REFINE_ITERATIONS)
565            .filter(|fit| fit.max_dev.is_finite() && fit.max_dev <= bar)
566    };
567    // Every vertex of the facet on the carrier at the precision bar.
568    let held_by = |carrier: &RegionCarrier, facet: usize| -> bool {
569        facets[facet].iter().all(|&index| {
570            data.tris[tri_ids[index] as usize]
571                .verts
572                .iter()
573                .all(|&v| carrier_distance(carrier, data.verts[v]) <= bar)
574        })
575    };
576    // Loose pre-filter against the current carrier: the refit decides.
577    let plausible = |carrier: &RegionCarrier, facet: usize| -> bool {
578        let sense = carrier_sense(carrier);
579        facets[facet].iter().all(|&index| {
580            let tri = &data.tris[tri_ids[index] as usize];
581            let Some(normal) = carrier_normal(carrier, tri.centroid) else {
582                return false;
583            };
584            tri.normal.dot(normal) * sense >= cos_normal_gate
585                && tri
586                    .verts
587                    .iter()
588                    .all(|&v| carrier_distance(carrier, data.verts[v]) <= accept_tol)
589        })
590    };
591
592    // Dihedral between two neighbouring facets (their first triangles'
593    // normals: a facet is coplanar at 1e-4 rad).
594    let facet_dihedral = |a: usize, b: usize| -> f64 {
595        let na = data.tris[tri_ids[facets[a][0]] as usize].normal;
596        let nb = data.tris[tri_ids[facets[b][0]] as usize].normal;
597        na.dot(nb).clamp(-1.0, 1.0).acos()
598    };
599    // Three times the median dihedral of the seed's own in-region edges, or
600    // no gate at all for a seed with fewer than two neighbours.
601    let seed_dihedral_gate = |seed: usize| -> f64 {
602        let mut dihedrals: Vec<f64> = facet_neighbors[seed].iter().map(|&o| facet_dihedral(seed, o)).collect();
603        if dihedrals.len() < 2 {
604            return f64::INFINITY;
605        }
606        dihedrals.sort_by(f64::total_cmp);
607        3.0 * dihedrals[dihedrals.len() / 2]
608    };
609
610    let mut order: Vec<usize> = (0..facets.len()).collect();
611    order.sort_by(|&a, &b| facet_area[b].total_cmp(&facet_area[a]).then(a.cmp(&b)));
612    if let Some(started) = started {
613        eprintln!(
614            "[seg] peel timing: {} facets of {} tris built in {:.1} ms",
615            facets.len(),
616            tri_ids.len(),
617            started.elapsed().as_secs_f64() * 1e3
618        );
619    }
620    let mut assigned = vec![false; facets.len()];
621    let mut pieces: Vec<(Vec<u32>, RegionOutcome)> = Vec::new();
622    // A torus seed is tried once across all rounds: the rounds exist for a
623    // one-strip wall whose seed neighbourhood clears once its dome is
624    // claimed, and a torus band that failed once does not become one.
625    let mut torus_tried = vec![false; facets.len()];
626    // Rounds until nothing new is claimed: a wall one strip tall has its
627    // seed neighborhood polluted by the dome or fillet beyond its rim until
628    // that neighbor has been claimed by its own pass.
629    loop {
630    let claimed = pieces.len();
631    for (kind, min_vertices) in PEEL_KINDS {
632    let mut tried = vec![false; facets.len()];
633    // Seed-stage accounting for the trace: how many seeds the pass tried,
634    // how many seed fits it got at the bar, the best seed residual it saw
635    // refused, and how many grown groups fell under the support floor.
636    let (mut seeds_tried, mut seed_fits, mut small_groups) = (0usize, 0usize, 0usize);
637    let mut best_refused_seed = f64::INFINITY;
638    for &seed in &order {
639        if assigned[seed] || tried[seed] || (kind == "torus" && torus_tried[seed]) {
640            continue;
641        }
642        tried[seed] = true;
643        if kind == "torus" {
644            torus_tried[seed] = true;
645        }
646        let mut group = vec![seed];
647        let mut in_group = vec![false; facets.len()];
648        in_group[seed] = true;
649        let mut frontier = vec![seed];
650        // A ring neighbour across a crease is left out: on an anisotropic
651        // tessellation whose columns span a wall's full height, every seed
652        // neighbourhood of the bore touches the countersink cone across a
653        // 26.6-degree crease and no whole-neighbourhood fit can pass the bar.
654        // The crease is an outlier among the seed's own dihedrals (3.6 degrees
655        // between its columns), so a neighbour is admitted only when its
656        // dihedral across the shared edge is at most three times the median
657        // dihedral of the seed facet's own in-region edges.
658        let dihedral_gate = seed_dihedral_gate(seed);
659        for _ in 0..CYLINDER_PEEL_SEED_RINGS {
660            let mut next = Vec::new();
661            for &facet in &frontier {
662                for &other in &facet_neighbors[facet] {
663                    if !assigned[other] && !in_group[other] && facet_dihedral(facet, other) <= dihedral_gate {
664                        in_group[other] = true;
665                        group.push(other);
666                        next.push(other);
667                    }
668                }
669            }
670            frontier = next;
671        }
672        seeds_tried += 1;
673        let Some(seed_fit) = fit_facets(kind, &group) else {
674            if debug {
675                if let Some(fit) = fit_kind(kind, &group) {
676                    best_refused_seed = best_refused_seed.min(fit.max_dev);
677                }
678            }
679            continue;
680        };
681        seed_fits += 1;
682        let mut carrier = seed_fit.carrier;
683        let mut blocked = vec![false; facets.len()];
684        let mut blocked_list: Vec<usize> = Vec::new();
685        // Growth, then a sweep of the facets blocked while the carrier was
686        // still settling on a small support: those on its final estimate at
687        // the bar are admitted in one refit and growth resumes from them.
688        // Without the sweep a densely gridded band leaves hundreds of two-
689        // and four-triangle slivers behind, each re-fitted on its own later.
690        loop {
691        loop {
692            let mut wave = Vec::new();
693            for &facet in &group {
694                for &other in &facet_neighbors[facet] {
695                    if !assigned[other]
696                        && !in_group[other]
697                        && !blocked[other]
698                        && !wave.contains(&other)
699                        && plausible(&carrier, other)
700                    {
701                        wave.push(other);
702                    }
703                }
704            }
705            if wave.is_empty() {
706                break;
707            }
708            let mut trial = group.clone();
709            trial.extend(wave.iter().copied());
710            if let Some(fit) = grow_fit(kind, &trial, &carrier) {
711                for &facet in &wave {
712                    in_group[facet] = true;
713                }
714                group = trial;
715                carrier = fit.carrier;
716                continue;
717            }
718            // The wave crossed a junction: admit its facets one at a time.
719            // A torus admits the facets its current carrier already holds at
720            // the bar, then refits once: a refit per junction facet is what
721            // a dense freeform compound cannot afford.
722            if kind == "torus" {
723                let (inside, outside): (Vec<usize>, Vec<usize>) =
724                    wave.into_iter().partition(|&facet| held_by(&carrier, facet));
725                for facet in outside {
726                    blocked[facet] = true;
727                    blocked_list.push(facet);
728                }
729                if inside.is_empty() {
730                    break;
731                }
732                let mut trial = group.clone();
733                trial.extend(inside.iter().copied());
734                let Some(fit) = grow_fit(kind, &trial, &carrier) else {
735                    for facet in inside {
736                        blocked[facet] = true;
737                        blocked_list.push(facet);
738                    }
739                    break;
740                };
741                for &facet in &inside {
742                    in_group[facet] = true;
743                }
744                group = trial;
745                carrier = fit.carrier;
746                continue;
747            }
748            let mut accepted_any = false;
749            for facet in wave {
750                let mut trial = group.clone();
751                trial.push(facet);
752                if let Some(fit) = grow_fit(kind, &trial, &carrier) {
753                    in_group[facet] = true;
754                    group = trial;
755                    carrier = fit.carrier;
756                    accepted_any = true;
757                } else {
758                    blocked[facet] = true;
759                    blocked_list.push(facet);
760                }
761            }
762            if !accepted_any {
763                break;
764            }
765        }
766        blocked_list.retain(|&facet| blocked[facet] && !in_group[facet]);
767        let held: Vec<usize> = blocked_list
768            .iter()
769            .copied()
770            .filter(|&facet| held_by(&carrier, facet))
771            .collect();
772        if held.is_empty() {
773            break;
774        }
775        let mut trial = group.clone();
776        trial.extend(held.iter().copied());
777        let Some(fit) = grow_fit(kind, &trial, &carrier) else {
778            break;
779        };
780        for &facet in &held {
781            in_group[facet] = true;
782            blocked[facet] = false;
783        }
784        group = trial;
785        carrier = fit.carrier;
786        }
787        let mut tris = facet_tris(&group);
788        if tris.len() < options.min_region_triangles.max(4)
789            || region_vertices(data, &tris).len() < min_vertices
790        {
791            small_groups += 1;
792            continue;
793        }
794        tris.sort_unstable();
795        let outcome = fit_peeled(data, &tris, options);
796        if debug {
797            let size = match outcome.fit.carrier {
798                RegionCarrier::Cylinder { radius, .. } | RegionCarrier::Sphere { radius, .. } => {
799                    format!(" r={radius:.4}")
800                }
801                RegionCarrier::Cone { half_angle_rad, apex, axis_dir, .. } => {
802                    format!(
803                        " ha={:.2}deg apex=({:.3},{:.3},{:.3}) axis=({:+.4},{:+.4},{:+.4})",
804                        half_angle_rad.to_degrees(),
805                        apex.x, apex.y, apex.z,
806                        axis_dir.x, axis_dir.y, axis_dir.z
807                    )
808                }
809                RegionCarrier::Torus { center, axis_dir, major_radius, minor_radius, .. } => {
810                    format!(
811                        " R={major_radius:.4} r={minor_radius:.4} centre=({:.3},{:.3},{:.3}) axis=({:+.4},{:+.4},{:+.4})",
812                        center.x, center.y, center.z, axis_dir.x, axis_dir.y, axis_dir.z
813                    )
814                }
815                _ => String::new(),
816            };
817            let (low, high) = region_bbox(data, &region_vertices(data, &tris));
818            eprintln!(
819                "[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}]",
820                group.len(),
821                tris.len(),
822                outcome.fit.carrier.kind(),
823                outcome.accepted,
824                outcome.fit.max_dev,
825                outcome.fit.max_normal_angle_deg,
826                low.x, low.y, low.z, high.x, high.y, high.z
827            );
828        }
829        if !outcome.accepted || outcome.fit.carrier.kind() != kind {
830            continue;
831        }
832        // A torus is claimed only as a FULL band around its axis — the
833        // shape a mesh rebuild can bound by closed rings. A partial torus
834        // (a fillet running along an arc of an outline) stays with the
835        // planar strips it has always been read as.
836        if kind == "torus" && !covers_full_azimuth(data, &tris, &outcome.fit.carrier) {
837            continue;
838        }
839        for &facet in &group {
840            assigned[facet] = true;
841        }
842        pieces.push((tris, outcome));
843    }
844    if debug {
845        eprintln!(
846            "[seg] peel {kind} seeds: {seeds_tried} tried, {seed_fits} seed fits at {bar:.3e}, best refused seed max_dev={best_refused_seed:.3e}, {small_groups} grown groups under the support floor ({min_vertices} vertices)"
847        );
848    }
849    }
850    if pieces.len() == claimed {
851        break;
852    }
853    }
854    if let Some(started) = started {
855        eprintln!(
856            "[seg] peel timing: seeds done at {:.1} ms, {} pieces",
857            started.elapsed().as_secs_f64() * 1e3,
858            pieces.len()
859        );
860    }
861    if pieces.is_empty() {
862        return (pieces, tri_ids.to_vec());
863    }
864    absorb_held_pieces(data, options, bar, &mut pieces, &facets, &facet_of, &local, &facet_neighbors, debug);
865    if let Some(started) = started {
866        eprintln!(
867            "[seg] peel timing: absorption done at {:.1} ms, {} pieces",
868            started.elapsed().as_secs_f64() * 1e3,
869            pieces.len()
870        );
871    }
872    // A leftover strip whose every peeled neighbor is the same carrier is an
873    // interior gap — a tessellator T-vertex on a rim chord — not a
874    // junction: it rejoins that carrier when the union still passes the
875    // cascade at the user tolerance.  A strip straddling the tangent point
876    // of two different carriers stays out and becomes its own small facet.
877    let mut piece_of_facet = vec![usize::MAX; facets.len()];
878    for (piece, (tris, _)) in pieces.iter().enumerate() {
879        for &t in tris {
880            piece_of_facet[facet_of[local[&t]]] = piece;
881        }
882    }
883    let leftover_tris: Vec<u32> = (0..tri_ids.len())
884        .filter(|&index| !assigned[facet_of[index]])
885        .map(|index| tri_ids[index])
886        .collect();
887    let compat_tol = options.fit_tolerance * data.diag.max(1e-12);
888    let mut remainder = Vec::new();
889    for component in connected_components(data, &leftover_tris) {
890        let mut adjacent_pieces: Vec<usize> = Vec::new();
891        for &t in &component {
892            for &other in &facet_neighbors[facet_of[local[&t]]] {
893                let piece = piece_of_facet[other];
894                if piece != usize::MAX && !adjacent_pieces.contains(&piece) {
895                    adjacent_pieces.push(piece);
896                }
897            }
898        }
899        let Some(&first) = adjacent_pieces.first() else {
900            remainder.extend(component);
901            continue;
902        };
903        let same_carrier = adjacent_pieces.iter().all(|&piece| {
904            carriers_compatible(
905                &pieces[first].1.fit.carrier,
906                &pieces[piece].1.fit.carrier,
907                compat_tol,
908                true,
909            )
910        });
911        if !same_carrier {
912            remainder.extend(straddle_rejoin(data, options, bar, &mut pieces, &adjacent_pieces, component));
913            continue;
914        }
915        let mut union: Vec<u32> = pieces[first].0.iter().chain(component.iter()).copied().collect();
916        union.sort_unstable();
917        let outcome = fit_peeled(data, &union, options);
918        let rejoined = outcome.accepted
919            && outcome.fit.carrier.kind() == pieces[first].1.fit.carrier.kind();
920        if debug {
921            eprintln!(
922                "[seg] peel: leftover of {} tris between {} peeled piece(s): union {} accepted={} max_dev={:.3e}",
923                component.len(),
924                adjacent_pieces.len(),
925                outcome.fit.carrier.kind(),
926                outcome.accepted,
927                outcome.fit.max_dev
928            );
929        }
930        if rejoined {
931            pieces[first] = (union, outcome);
932        } else {
933            remainder.extend(straddle_rejoin(data, options, bar, &mut pieces, &adjacent_pieces, component));
934        }
935    }
936    remainder.sort_unstable();
937    if let Some(started) = started {
938        eprintln!(
939            "[seg] peel timing: leftovers done at {:.1} ms, {} remainder tris",
940            started.elapsed().as_secs_f64() * 1e3,
941            remainder.len()
942        );
943    }
944    (pieces, remainder)
945}
946
947/// A piece every vertex of which lies on a neighbouring, larger piece's
948/// carrier at the precision bar is a sub-patch of that carrier, not evidence
949/// of a kind of its own: the cone pass claimed 144 triangles off the plane
950/// rim of a densely gridded fillet torus at 3.9e-4 against a 4.1e-4 bar
951/// before the torus pass reached the band, and the rebuild then demoted the
952/// torus for the cone edge across it. Such a piece rejoins the neighbour
953/// whose carrier holds it closest when the union still passes the cascade
954/// as that neighbour's kind; a pair that fails stays apart. Repeats until
955/// nothing rejoins.
956#[allow(clippy::too_many_arguments)]
957fn absorb_held_pieces(
958    data: &MeshData,
959    options: &SegmentOptions,
960    bar: f64,
961    pieces: &mut Vec<(Vec<u32>, RegionOutcome)>,
962    facets: &[Vec<usize>],
963    facet_of: &[usize],
964    local: &HashMap<u32, usize>,
965    facet_neighbors: &[Vec<usize>],
966    debug: bool,
967) {
968    let mut refused: Vec<(Vec<u32>, Vec<u32>)> = Vec::new();
969    loop {
970        let mut piece_of_facet = vec![usize::MAX; facets.len()];
971        for (piece, (tris, _)) in pieces.iter().enumerate() {
972            for &t in tris {
973                piece_of_facet[facet_of[local[&t]]] = piece;
974            }
975        }
976        let area: Vec<f64> = pieces
977            .iter()
978            .map(|(tris, _)| tris.iter().map(|&t| data.tris[t as usize].area).sum())
979            .collect();
980        let mut by_area: Vec<usize> = (0..pieces.len()).collect();
981        by_area.sort_by(|&a, &b| area[a].total_cmp(&area[b]).then(a.cmp(&b)));
982        let mut merge = None;
983        for &small in &by_area {
984            let verts = region_vertices(data, &pieces[small].0);
985            let mut neighbours: Vec<usize> = Vec::new();
986            for &t in &pieces[small].0 {
987                for &other in &facet_neighbors[facet_of[local[&t]]] {
988                    let piece = piece_of_facet[other];
989                    if piece != usize::MAX && piece != small && !neighbours.contains(&piece) {
990                        neighbours.push(piece);
991                    }
992                }
993            }
994            let best = neighbours
995                .iter()
996                .copied()
997                .filter(|&large| area[large] > area[small])
998                .filter(|&large| {
999                    !refused
1000                        .iter()
1001                        .any(|(a, b)| *a == pieces[small].0 && *b == pieces[large].0)
1002                })
1003                .filter_map(|large| {
1004                    let carrier = &pieces[large].1.fit.carrier;
1005                    let worst = verts
1006                        .iter()
1007                        .map(|&v| carrier_distance(carrier, data.verts[v]))
1008                        .fold(0.0_f64, f64::max);
1009                    (worst <= bar).then_some((large, worst))
1010                })
1011                .min_by(|a, b| a.1.total_cmp(&b.1).then(a.0.cmp(&b.0)));
1012            if let Some((large, worst)) = best {
1013                merge = Some((small, large, worst));
1014                break;
1015            }
1016        }
1017        let Some((small, large, worst)) = merge else {
1018            break;
1019        };
1020        let mut union: Vec<u32> = pieces[large].0.iter().chain(pieces[small].0.iter()).copied().collect();
1021        union.sort_unstable();
1022        let outcome = fit_peeled(data, &union, options);
1023        let rejoined = outcome.accepted
1024            && outcome.fit.carrier.kind() == pieces[large].1.fit.carrier.kind();
1025        if debug {
1026            eprintln!(
1027                "[seg] peel absorb: {} piece of {} tris lies on the {} piece of {} tris at {worst:.3e} (bar {bar:.3e}); union {} accepted={} max_dev={:.3e}",
1028                pieces[small].1.fit.carrier.kind(),
1029                pieces[small].0.len(),
1030                pieces[large].1.fit.carrier.kind(),
1031                pieces[large].0.len(),
1032                outcome.fit.carrier.kind(),
1033                outcome.accepted,
1034                outcome.fit.max_dev
1035            );
1036        }
1037        if rejoined {
1038            pieces[large] = (union, outcome);
1039            pieces.remove(small);
1040        } else {
1041            refused.push((pieces[small].0.clone(), pieces[large].0.clone()));
1042        }
1043    }
1044}
1045
1046/// The cascade's verdict on a peeled piece. The cascade keeps its plain
1047/// torus fit; a torus the peel grew and judged at the precision bar carries
1048/// the vertex-refined carrier instead, so a mesh rebuild gets its true
1049/// axis and radii.
1050fn fit_peeled(data: &MeshData, tris: &[u32], options: &SegmentOptions) -> RegionOutcome {
1051    let mut outcome = fit_region(data, tris, options);
1052    if outcome.accepted && outcome.fit.carrier.kind() == "torus" {
1053        let verts = region_vertices(data, tris);
1054        if let Some(refined) =
1055            refine_torus_carrier(data, tris, &verts, &outcome.fit.carrier, TORUS_REFINE_ITERATIONS)
1056        {
1057            if refined.max_dev < outcome.fit.max_dev {
1058                outcome.fit = refined;
1059            }
1060        }
1061    }
1062    outcome
1063}
1064
1065/// Whether a torus region's vertices surround its axis with no azimuthal gap
1066/// wider than a twelfth of a turn.
1067fn covers_full_azimuth(data: &MeshData, tri_ids: &[u32], carrier: &RegionCarrier) -> bool {
1068    let RegionCarrier::Torus { center, axis_dir, .. } = carrier else {
1069        return false;
1070    };
1071    let Ok(axis) = axis_dir.normalized() else {
1072        return false;
1073    };
1074    let Ok(x_axis) = axis.perpendicular() else {
1075        return false;
1076    };
1077    let y_axis = axis.cross(x_axis);
1078    let mut angles = region_vertices(data, tri_ids)
1079        .into_iter()
1080        .map(|v| {
1081            let d = data.verts[v].sub(*center);
1082            d.dot(y_axis).atan2(d.dot(x_axis))
1083        })
1084        .collect::<Vec<_>>();
1085    if angles.len() < 3 {
1086        return false;
1087    }
1088    angles.sort_by(f64::total_cmp);
1089    let wrap = angles[0] + std::f64::consts::TAU - angles[angles.len() - 1];
1090    let widest = angles
1091        .windows(2)
1092        .map(|pair| pair[1] - pair[0])
1093        .fold(wrap, f64::max);
1094    widest <= std::f64::consts::TAU / 12.0
1095}
1096
1097/// A leftover strip at the tangent junction of DIFFERENT carriers (the
1098/// first row of a fillet torus against its cylinder) is not one carrier's
1099/// gap, but each of its triangles still lies on exactly one side. A triangle
1100/// joins the adjacent piece whose carrier holds all three of its vertices
1101/// within the peel's precision bar (the closest when several do), and a
1102/// piece that took any is re-fitted and must still pass the cascade as the
1103/// same kind, or it keeps its old triangles and they stay out. A triangle
1104/// no adjacent carrier holds at the bar is returned.
1105fn straddle_rejoin(
1106    data: &MeshData,
1107    options: &SegmentOptions,
1108    bar: f64,
1109    pieces: &mut [(Vec<u32>, RegionOutcome)],
1110    adjacent_pieces: &[usize],
1111    component: Vec<u32>,
1112) -> Vec<u32> {
1113    let mut taken: Vec<Vec<u32>> = vec![Vec::new(); adjacent_pieces.len()];
1114    let mut left = Vec::new();
1115    for t in component {
1116        let tri = &data.tris[t as usize];
1117        let best = adjacent_pieces
1118            .iter()
1119            .enumerate()
1120            .filter_map(|(slot, &piece)| {
1121                let carrier = &pieces[piece].1.fit.carrier;
1122                let worst = tri
1123                    .verts
1124                    .iter()
1125                    .map(|&v| carrier_distance(carrier, data.verts[v]))
1126                    .fold(0.0_f64, f64::max);
1127                (worst <= bar).then_some((slot, worst))
1128            })
1129            .min_by(|a, b| a.1.total_cmp(&b.1));
1130        match best {
1131            Some((slot, _)) => taken[slot].push(t),
1132            None => left.push(t),
1133        }
1134    }
1135    for (slot, extra) in taken.into_iter().enumerate() {
1136        if extra.is_empty() {
1137            continue;
1138        }
1139        let piece = adjacent_pieces[slot];
1140        let mut union: Vec<u32> = pieces[piece].0.iter().chain(extra.iter()).copied().collect();
1141        union.sort_unstable();
1142        let outcome = fit_peeled(data, &union, options);
1143        if outcome.accepted && outcome.fit.carrier.kind() == pieces[piece].1.fit.carrier.kind() {
1144            pieces[piece] = (union, outcome);
1145        } else {
1146            left.extend(extra);
1147        }
1148    }
1149    left
1150}
1151
1152// ---------------------------------------------------------------------------
1153// Entry point
1154// ---------------------------------------------------------------------------
1155
1156/// Segment a triangle mesh into smooth regions and recognize each region's
1157/// analytic carrier.  `positions` are xyz triples; `indices` is a triangle
1158/// index buffer, or empty to treat `positions` as a raw soup of consecutive
1159/// triangles (the STL layout of `read_stl`).
1160pub fn segment_mesh_faces(
1161    positions: &[f64],
1162    indices: &[u32],
1163    options: &SegmentOptions,
1164) -> Result<MeshSegmentation, String> {
1165    if !(options.deflection_angle_deg > 0.0) || !options.deflection_angle_deg.is_finite() {
1166        return Err("segment_mesh_faces: deflection angle must be positive".into());
1167    }
1168    if !(options.fit_tolerance > 0.0) || !options.fit_tolerance.is_finite() {
1169        return Err("segment_mesh_faces: fit tolerance must be positive".into());
1170    }
1171    let data = build_mesh_data(positions, indices, options)?;
1172    Ok(segment_prepared(&data, options))
1173}
1174
1175/// Segmentation core on an already-welded mesh: shared by the analysis entry
1176/// (`segment_mesh_faces`) and the BREP rebuild (`mesh_regions_to_brep`), so
1177/// both see identical triangle/vertex indexing.
1178pub(super) fn segment_prepared(data: &MeshData, options: &SegmentOptions) -> MeshSegmentation {
1179    let mut raw_regions = grow_regions(data, options);
1180    absorb_tiny_regions(data, &mut raw_regions);
1181
1182    let mut final_regions: Vec<(Vec<u32>, RegionOutcome)> = Vec::new();
1183    for region in raw_regions {
1184        let outcome = fit_region(data, &region, options);
1185        if outcome.accepted {
1186            final_regions.push((region, outcome));
1187            continue;
1188        }
1189        // Tangent-smooth compound (or genuinely freeform): try to split it.
1190        let pieces = refine_region(data, &region, options);
1191        if pieces.len() <= 1 {
1192            // Refinement did not split anything; keep the region whole.
1193            final_regions.push((region, outcome));
1194        } else {
1195            final_regions.extend(pieces);
1196        }
1197    }
1198
1199    // Reunite regions the growing/refinement pipeline split although they
1200    // lie on ONE carrier (a cap triangle isolated behind skipped slivers, a
1201    // blend peeled into two coaxial pieces).  Downstream consumers — the
1202    // BREP rebuild above all — rely on "one region per face".
1203    merge_compatible_regions(data, &mut final_regions, options);
1204
1205    let mut triangle_region_ids = vec![UNASSIGNED_REGION; data.tris.len()];
1206    let mut regions = Vec::with_capacity(final_regions.len());
1207    for (id, (tri_ids, outcome)) in final_regions.into_iter().enumerate() {
1208        for &t in &tri_ids {
1209            triangle_region_ids[t as usize] = id as u32;
1210        }
1211        regions.push(MeshRegion {
1212            id: id as u32,
1213            triangle_count: outcome.triangle_count,
1214            area: outcome.area,
1215            bbox_min: outcome.bbox.0,
1216            bbox_max: outcome.bbox.1,
1217            carrier: outcome.fit.carrier,
1218            max_deviation: outcome.fit.max_dev,
1219            rms_deviation: outcome.fit.rms_dev,
1220            max_normal_angle_deg: outcome.fit.max_normal_angle_deg,
1221        });
1222    }
1223
1224    // Attach degenerate/micro triangles to an adjacent region when one
1225    // exists: the neighbor whose carrier the sliver's vertices actually sit
1226    // on (a T-junction needle lies in one of its neighboring planes, not
1227    // on the fillet it happens to touch first).  Multi-pass so chains of
1228    // skipped slivers resolve through their assigned neighbors.
1229    let mut changed = true;
1230    while changed {
1231        changed = false;
1232        for t in 0..data.tris.len() {
1233            if triangle_region_ids[t] != UNASSIGNED_REGION {
1234                continue;
1235            }
1236            let tri = &data.tris[t];
1237            let mut adopted: Option<(f64, u32)> = None;
1238            for corner in 0..3 {
1239                let first = tri.verts[corner];
1240                let second = tri.verts[(corner + 1) % 3];
1241                if first == second {
1242                    continue;
1243                }
1244                if let Some(incident) = data.edges.get(&edge_key(first, second)) {
1245                    for &other in incident {
1246                        let region = triangle_region_ids[other as usize];
1247                        if other as usize == t || region == UNASSIGNED_REGION {
1248                            continue;
1249                        }
1250                        let carrier = &regions[region as usize].carrier;
1251                        let distance = tri
1252                            .verts
1253                            .iter()
1254                            .map(|&v| carrier_distance(carrier, data.verts[v]))
1255                            .fold(0.0_f64, f64::max);
1256                        let candidate = (distance, region);
1257                        adopted = Some(match adopted {
1258                            Some(current)
1259                                if current.0 < candidate.0
1260                                    || (current.0 == candidate.0 && current.1 <= candidate.1) =>
1261                            {
1262                                current
1263                            }
1264                            _ => candidate,
1265                        });
1266                    }
1267                }
1268            }
1269            if let Some((_, region)) = adopted {
1270                triangle_region_ids[t] = region;
1271                changed = true;
1272            }
1273        }
1274    }
1275
1276    MeshSegmentation {
1277        triangle_region_ids,
1278        regions,
1279        triangle_count: data.tris.len(),
1280        welded_vertex_count: data.verts.len(),
1281    }
1282}
1283
1284/// Outward carrier normal at a point (before the region's sense), or `None`
1285/// where the field is singular (on an axis, at a center) or for `Freeform`.
1286fn carrier_normal(carrier: &RegionCarrier, p: Vec3) -> Option<Vec3> {
1287    match carrier {
1288        RegionCarrier::Plane { normal, .. } => Some(*normal),
1289        RegionCarrier::Cylinder {
1290            axis_point,
1291            axis_dir,
1292            ..
1293        } => {
1294            let d = p.sub(*axis_point);
1295            d.sub(axis_dir.scale(d.dot(*axis_dir))).normalized().ok()
1296        }
1297        RegionCarrier::Cone {
1298            apex,
1299            axis_dir,
1300            half_angle_rad,
1301            ..
1302        } => {
1303            let d = p.sub(*apex);
1304            let radial = d.sub(axis_dir.scale(d.dot(*axis_dir))).normalized().ok()?;
1305            let (sin_a, cos_a) = half_angle_rad.sin_cos();
1306            Some(radial.scale(cos_a).sub(axis_dir.scale(sin_a)))
1307        }
1308        RegionCarrier::Sphere { center, .. } => p.sub(*center).normalized().ok(),
1309        RegionCarrier::Torus {
1310            center,
1311            axis_dir,
1312            major_radius,
1313            ..
1314        } => {
1315            let d = p.sub(*center);
1316            let s = d.dot(*axis_dir);
1317            let radial = d.sub(axis_dir.scale(s)).normalized().ok()?;
1318            let tube_center = center.add(radial.scale(*major_radius));
1319            p.sub(tube_center).normalized().ok()
1320        }
1321        RegionCarrier::Freeform => None,
1322    }
1323}
1324
1325/// The region's orientation sense as a sign, `1.0` for planes and freeform.
1326fn carrier_sense(carrier: &RegionCarrier) -> f64 {
1327    match carrier {
1328        RegionCarrier::Cylinder { sense, .. }
1329        | RegionCarrier::Cone { sense, .. }
1330        | RegionCarrier::Sphere { sense, .. }
1331        | RegionCarrier::Torus { sense, .. } => f64::from(*sense),
1332        _ => 1.0,
1333    }
1334}
1335
1336/// Distance of a point from a carrier; `Freeform` is infinitely far.
1337fn carrier_distance(carrier: &RegionCarrier, p: Vec3) -> f64 {
1338    match carrier {
1339        RegionCarrier::Plane { origin, normal } => p.sub(*origin).dot(*normal).abs(),
1340        RegionCarrier::Cylinder {
1341            axis_point,
1342            axis_dir,
1343            radius,
1344            ..
1345        } => {
1346            let d = p.sub(*axis_point);
1347            (d.sub(axis_dir.scale(d.dot(*axis_dir))).length() - radius).abs()
1348        }
1349        RegionCarrier::Cone {
1350            apex,
1351            axis_dir,
1352            half_angle_rad,
1353            ..
1354        } => {
1355            let d = p.sub(*apex);
1356            let s = d.dot(*axis_dir);
1357            let r = d.sub(axis_dir.scale(s)).length();
1358            ((r - s * half_angle_rad.tan()) * half_angle_rad.cos()).abs()
1359        }
1360        RegionCarrier::Sphere { center, radius, .. } => (p.sub(*center).length() - radius).abs(),
1361        RegionCarrier::Torus {
1362            center,
1363            axis_dir,
1364            major_radius,
1365            minor_radius,
1366            ..
1367        } => {
1368            let d = p.sub(*center);
1369            let s = d.dot(*axis_dir);
1370            let rho = d.sub(axis_dir.scale(s)).length();
1371            ((rho - major_radius).hypot(s) - minor_radius).abs()
1372        }
1373        RegionCarrier::Freeform => f64::INFINITY,
1374    }
1375}
1376
1377/// Do two accepted carriers describe the same surface (within `tol` for
1378/// distances, tight angular bands for directions)?  Freeform never matches.
1379/// `adjacent` says the regions share an edge: only then may two cylinder
1380/// fits disagree on their axis by the noise over the axial extent, because
1381/// two disjoint faces of one infinite cylinder (the two ends of a mirrored
1382/// part, the two levels of a slot) must stay two regions.
1383fn carriers_compatible(a: &RegionCarrier, b: &RegionCarrier, tol: f64, adjacent: bool) -> bool {
1384    const ANGULAR: f64 = 1e-6;
1385    match (a, b) {
1386        (
1387            RegionCarrier::Plane {
1388                origin: o1,
1389                normal: n1,
1390            },
1391            RegionCarrier::Plane {
1392                origin: o2,
1393                normal: n2,
1394            },
1395        ) => {
1396            n1.cross(*n2).length() <= ANGULAR
1397                && n1.dot(*n2) > 0.0
1398                && o2.sub(*o1).dot(*n1).abs() <= tol
1399        }
1400        (
1401            RegionCarrier::Cylinder {
1402                axis_point: p1,
1403                axis_dir: a1,
1404                radius: r1,
1405                sense: s1,
1406            },
1407            RegionCarrier::Cylinder {
1408                axis_point: p2,
1409                axis_dir: a2,
1410                radius: r2,
1411                sense: s2,
1412            },
1413        ) => {
1414            // Independent fits of one cylinder from noisy vertices disagree
1415            // on the axis direction by the noise over the axial extent; the
1416            // band is the angle that moves the surface by `tol` at the rim.
1417            // The union re-fit is the real test.
1418            let angular = if adjacent {
1419                ANGULAR.max(tol / r1.max(*r2).max(1e-12))
1420            } else {
1421                ANGULAR
1422            };
1423            let offset = p2.sub(*p1);
1424            s1 == s2
1425                && a1.cross(*a2).length() <= angular
1426                && (r1 - r2).abs() <= tol
1427                && offset.sub(a1.scale(offset.dot(*a1))).length() <= tol
1428        }
1429        (
1430            RegionCarrier::Cone {
1431                apex: p1,
1432                axis_dir: a1,
1433                half_angle_rad: h1,
1434                sense: s1,
1435            },
1436            RegionCarrier::Cone {
1437                apex: p2,
1438                axis_dir: a2,
1439                half_angle_rad: h2,
1440                sense: s2,
1441            },
1442        ) => {
1443            s1 == s2
1444                && a1.cross(*a2).length() <= ANGULAR
1445                && a1.dot(*a2) > 0.0
1446                && (h1 - h2).abs() <= ANGULAR
1447                && p2.sub(*p1).length() <= tol
1448        }
1449        (
1450            RegionCarrier::Sphere {
1451                center: c1,
1452                radius: r1,
1453                sense: s1,
1454            },
1455            RegionCarrier::Sphere {
1456                center: c2,
1457                radius: r2,
1458                sense: s2,
1459            },
1460        ) => s1 == s2 && c2.sub(*c1).length() <= tol && (r1 - r2).abs() <= tol,
1461        (
1462            RegionCarrier::Torus {
1463                center: c1,
1464                axis_dir: a1,
1465                major_radius: mj1,
1466                minor_radius: mn1,
1467                sense: s1,
1468            },
1469            RegionCarrier::Torus {
1470                center: c2,
1471                axis_dir: a2,
1472                major_radius: mj2,
1473                minor_radius: mn2,
1474                sense: s2,
1475            },
1476        ) => {
1477            s1 == s2
1478                && a1.cross(*a2).length() <= ANGULAR
1479                && c2.sub(*c1).length() <= tol
1480                && (mj1 - mj2).abs() <= tol
1481                && (mn1 - mn2).abs() <= tol
1482        }
1483        _ => false,
1484    }
1485}
1486
1487/// Merge edge-adjacent regions whose accepted carriers agree, repeating
1488/// until stable.  Adjacency counts paths THROUGH skipped (degenerate/micro)
1489/// triangles — exactly the fences that isolate cap islands in the first
1490/// place.  A merge is only applied when the union re-fits cleanly on the
1491/// same carrier kind, so this pass can only improve the segmentation.
1492fn merge_compatible_regions(
1493    data: &MeshData,
1494    regions: &mut Vec<(Vec<u32>, RegionOutcome)>,
1495    options: &SegmentOptions,
1496) {
1497    let tol = options.fit_tolerance * data.diag.max(1e-12);
1498    loop {
1499        let mut region_of = vec![usize::MAX; data.tris.len()];
1500        for (index, (tris, _)) in regions.iter().enumerate() {
1501            for &t in tris {
1502                region_of[t as usize] = index;
1503            }
1504        }
1505        // Directly edge-adjacent pairs.
1506        let mut direct: std::collections::BTreeSet<(usize, usize)> =
1507            std::collections::BTreeSet::new();
1508        for incident in data.edges.values() {
1509            if incident.len() != 2 {
1510                continue;
1511            }
1512            let (a, b) = (
1513                region_of[incident[0] as usize],
1514                region_of[incident[1] as usize],
1515            );
1516            if a != b && a != usize::MAX && b != usize::MAX {
1517                direct.insert((a.min(b), a.max(b)));
1518            }
1519        }
1520        let mut pairs = direct.clone();
1521        // Pairs bridged by connected runs of region-less (skipped) triangles.
1522        let mut seen = vec![false; data.tris.len()];
1523        for seed in 0..data.tris.len() {
1524            if seen[seed] || region_of[seed] != usize::MAX {
1525                continue;
1526            }
1527            let mut stack = vec![seed];
1528            let mut touched: Vec<usize> = Vec::new();
1529            seen[seed] = true;
1530            while let Some(t) = stack.pop() {
1531                let tri = &data.tris[t];
1532                for corner in 0..3 {
1533                    let first = tri.verts[corner];
1534                    let second = tri.verts[(corner + 1) % 3];
1535                    if first == second {
1536                        continue;
1537                    }
1538                    if let Some(incident) = data.edges.get(&edge_key(first, second)) {
1539                        for &other in incident {
1540                            let other = other as usize;
1541                            if other == t {
1542                                continue;
1543                            }
1544                            let region = region_of[other];
1545                            if region == usize::MAX {
1546                                if !seen[other] {
1547                                    seen[other] = true;
1548                                    stack.push(other);
1549                                }
1550                            } else if !touched.contains(&region) {
1551                                touched.push(region);
1552                            }
1553                        }
1554                    }
1555                }
1556            }
1557            touched.sort_unstable();
1558            for i in 0..touched.len() {
1559                for j in i + 1..touched.len() {
1560                    pairs.insert((touched[i], touched[j]));
1561                }
1562            }
1563        }
1564
1565        let mut merged_any = false;
1566        'pairs: for (a, b) in pairs {
1567            let (first, second) = (&regions[a], &regions[b]);
1568            if !first.1.accepted || !second.1.accepted {
1569                continue;
1570            }
1571            if !carriers_compatible(
1572                &first.1.fit.carrier,
1573                &second.1.fit.carrier,
1574                tol,
1575                direct.contains(&(a, b)),
1576            ) {
1577                continue;
1578            }
1579            let mut union: Vec<u32> = first.0.iter().chain(second.0.iter()).copied().collect();
1580            union.sort_unstable();
1581            let outcome = fit_region(data, &union, options);
1582            if !outcome.accepted || outcome.fit.carrier.kind() != first.1.fit.carrier.kind() {
1583                continue;
1584            }
1585            regions[a] = (union, outcome);
1586            regions.remove(b);
1587            merged_any = true;
1588            break 'pairs;
1589        }
1590        if !merged_any {
1591            return;
1592        }
1593    }
1594}