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