Skip to main content

brep_kernel/meshing/mesh_segment/
segmentation.rs

1use super::*;
2
3struct RegionOutcome {
4    fit: CandidateFit,
5    accepted: bool,
6    area: f64,
7    bbox: (Vec3, Vec3),
8    triangle_count: usize,
9}
10
11/// Try the analytic cascade plane → cylinder → cone → sphere → torus on one
12/// region; the first carrier within tolerance wins.
13fn fit_region(data: &MeshData, tri_ids: &[u32], options: &SegmentOptions) -> RegionOutcome {
14    let vert_ids = region_vertices(data, tri_ids);
15    let bbox = region_bbox(data, &vert_ids);
16    let area: f64 = tri_ids.iter().map(|&t| data.tris[t as usize].area).sum();
17    let scale = {
18        let diag = bbox.1.sub(bbox.0).length();
19        if diag > 0.0 {
20            diag
21        } else {
22            data.diag.max(1e-12)
23        }
24    };
25    let tol_abs = options.fit_tolerance * scale;
26    let mut outcome = RegionOutcome {
27        fit: CandidateFit {
28            carrier: RegionCarrier::Freeform,
29            max_dev: 0.0,
30            rms_dev: 0.0,
31            max_normal_angle_deg: 0.0,
32        },
33        accepted: false,
34        area,
35        bbox,
36        triangle_count: tri_ids.len(),
37    };
38    if tri_ids.len() < options.min_region_triangles || vert_ids.len() < 3 {
39        return outcome;
40    }
41    let candidates = [
42        fit_plane(data, tri_ids, &vert_ids),
43        fit_cylinder(data, tri_ids, &vert_ids),
44        fit_cone(data, tri_ids, &vert_ids, tol_abs),
45        fit_sphere(data, tri_ids, &vert_ids),
46        fit_torus(data, tri_ids, &vert_ids),
47    ];
48    for candidate in candidates {
49        if let Some(fit) = candidate {
50            if std::env::var("BREP_DEBUG_SEG").is_ok() {
51                eprintln!(
52                    "[seg] region tris={} candidate={} max_dev={:.3e} tol_abs={:.3e} max_ang={:.3}",
53                    tri_ids.len(),
54                    fit.carrier.kind(),
55                    fit.max_dev,
56                    tol_abs,
57                    fit.max_normal_angle_deg
58                );
59            }
60            if fit.max_dev.is_finite()
61                && fit.max_dev <= tol_abs
62                && fit.max_normal_angle_deg <= options.normal_tolerance_deg
63            {
64                outcome.fit = fit;
65                outcome.accepted = true;
66                return outcome;
67            }
68        }
69    }
70    outcome
71}
72
73// ---------------------------------------------------------------------------
74// Region growing and refinement
75// ---------------------------------------------------------------------------
76
77fn grow_regions(data: &MeshData, options: &SegmentOptions) -> Vec<Vec<u32>> {
78    let cos_gate = options.deflection_angle_deg.to_radians().cos();
79    let tri_count = data.tris.len();
80    let mut assigned = vec![false; tri_count];
81    let mut regions = Vec::new();
82    let mut stack = Vec::new();
83    for seed in 0..tri_count {
84        if assigned[seed] || data.tris[seed].skip {
85            continue;
86        }
87        assigned[seed] = true;
88        stack.push(seed as u32);
89        let mut region = Vec::new();
90        while let Some(t) = stack.pop() {
91            region.push(t);
92            let tri = &data.tris[t as usize];
93            for corner in 0..3 {
94                let first = tri.verts[corner];
95                let second = tri.verts[(corner + 1) % 3];
96                if first == second {
97                    continue;
98                }
99                let Some(incident) = data.edges.get(&edge_key(first, second)) else {
100                    continue;
101                };
102                // Only manifold (two-sided) edges are smooth crossings.
103                if incident.len() != 2 {
104                    continue;
105                }
106                for &other in incident {
107                    if other == t || assigned[other as usize] {
108                        continue;
109                    }
110                    let neighbor = &data.tris[other as usize];
111                    if neighbor.skip {
112                        continue;
113                    }
114                    if tri.normal.dot(neighbor.normal) >= cos_gate {
115                        assigned[other as usize] = true;
116                        stack.push(other);
117                    }
118                }
119            }
120        }
121        region.sort_unstable();
122        regions.push(region);
123    }
124    regions
125}
126
127/// Regions whose area is below this fraction of the total mesh area are
128/// dissolved into their largest neighboring region before fitting.  Needle
129/// triangles at tessellation poles wall themselves off into 1–2-triangle
130/// islands; the app's minimum-region-size gating served the same purpose.
131const REGION_ABSORB_AREA_FRACTION: f64 = 1e-6;
132
133/// Merge negligible regions into their largest-area neighbor (repeats until
134/// stable so chains of islands resolve).  Regions with no neighbor at all
135/// stay as they are.
136fn absorb_tiny_regions(data: &MeshData, regions: &mut Vec<Vec<u32>>) {
137    let total_area: f64 = data
138        .tris
139        .iter()
140        .filter(|tri| !tri.skip)
141        .map(|tri| tri.area)
142        .sum();
143    let floor = total_area * REGION_ABSORB_AREA_FRACTION;
144    if !(floor > 0.0) {
145        return;
146    }
147    let mut region_of = vec![usize::MAX; data.tris.len()];
148    for (r, list) in regions.iter().enumerate() {
149        for &t in list {
150            region_of[t as usize] = r;
151        }
152    }
153    let mut areas: Vec<f64> = regions
154        .iter()
155        .map(|list| list.iter().map(|&t| data.tris[t as usize].area).sum())
156        .collect();
157    let mut changed = true;
158    while changed {
159        changed = false;
160        for r in 0..regions.len() {
161            if regions[r].is_empty() || areas[r] >= floor {
162                continue;
163            }
164            // Find adjacent regions, traversing THROUGH region-less
165            // (skipped sliver) triangles — pole islands are often fenced
166            // off from the main region by a ring of skipped needles.
167            let mut target: Option<usize> = None;
168            let mut visited: rustc_hash::FxHashSet<u32> = regions[r].iter().copied().collect();
169            let mut frontier: Vec<u32> = regions[r].clone();
170            while let Some(t) = frontier.pop() {
171                let tri = &data.tris[t as usize];
172                for corner in 0..3 {
173                    let first = tri.verts[corner];
174                    let second = tri.verts[(corner + 1) % 3];
175                    if first == second {
176                        continue;
177                    }
178                    if let Some(incident) = data.edges.get(&edge_key(first, second)) {
179                        for &other in incident {
180                            if visited.contains(&other) {
181                                continue;
182                            }
183                            let neighbor = region_of[other as usize];
184                            if neighbor == usize::MAX {
185                                // Skipped triangle: pass through it.
186                                visited.insert(other);
187                                frontier.push(other);
188                                continue;
189                            }
190                            if neighbor == r {
191                                continue;
192                            }
193                            target = match target {
194                                None => Some(neighbor),
195                                Some(current) => Some(if areas[neighbor] > areas[current] {
196                                    neighbor
197                                } else {
198                                    current
199                                }),
200                            };
201                        }
202                    }
203                }
204            }
205            if let Some(target) = target {
206                let moved = std::mem::take(&mut regions[r]);
207                for &t in &moved {
208                    region_of[t as usize] = target;
209                }
210                areas[target] += areas[r];
211                areas[r] = 0.0;
212                regions[target].extend(moved);
213                changed = true;
214            }
215        }
216    }
217    regions.retain(|list| !list.is_empty());
218    for list in regions.iter_mut() {
219        list.sort_unstable();
220    }
221}
222
223/// Connected components of `tri_ids` across shared (welded) edges,
224/// restricted to the given set.
225fn connected_components(data: &MeshData, tri_ids: &[u32]) -> Vec<Vec<u32>> {
226    let member: rustc_hash::FxHashSet<u32> = tri_ids.iter().copied().collect();
227    let mut assigned: rustc_hash::FxHashSet<u32> = rustc_hash::FxHashSet::default();
228    let mut components = Vec::new();
229    let mut stack = Vec::new();
230    for &seed in tri_ids {
231        if assigned.contains(&seed) {
232            continue;
233        }
234        assigned.insert(seed);
235        stack.push(seed);
236        let mut component = Vec::new();
237        while let Some(t) = stack.pop() {
238            component.push(t);
239            let tri = &data.tris[t as usize];
240            for corner in 0..3 {
241                let first = tri.verts[corner];
242                let second = tri.verts[(corner + 1) % 3];
243                if first == second {
244                    continue;
245                }
246                if let Some(incident) = data.edges.get(&edge_key(first, second)) {
247                    for &other in incident {
248                        if other != t && member.contains(&other) && !assigned.contains(&other) {
249                            assigned.insert(other);
250                            stack.push(other);
251                        }
252                    }
253                }
254            }
255        }
256        component.sort_unstable();
257        components.push(component);
258    }
259    components
260}
261
262/// Split a region the cascade could not classify: peel tangent cylinders,
263/// cones and spheres out of it first (the most specific test — a group
264/// must lie on ONE carrier at the mesh's precision), then seed-plane-
265/// anchored coplanar groups (the app's planar extraction) out of what is
266/// left, and re-fit the remaining connected components.  Planes come second
267/// because a coarse strip of a cylinder is a coplanar group too, and one
268/// wide enough to pass the planar area gate would otherwise be peeled as a
269/// plane before the cylinder it belongs to was ever tried.  Returns the
270/// partition with each piece's fit outcome.
271fn refine_region(
272    data: &MeshData,
273    tri_ids: &[u32],
274    options: &SegmentOptions,
275) -> Vec<(Vec<u32>, RegionOutcome)> {
276    let vert_ids = region_vertices(data, tri_ids);
277    let (low, high) = region_bbox(data, &vert_ids);
278    let scale = {
279        let diag = high.sub(low).length();
280        if diag > 0.0 {
281            diag
282        } else {
283            data.diag.max(1e-12)
284        }
285    };
286    let dist_gate = options.fit_tolerance * scale;
287    let cos_gate = options.planar_extraction_angle_deg.to_radians().cos();
288    let region_area: f64 = tri_ids.iter().map(|&t| data.tris[t as usize].area).sum();
289    let min_group_area = region_area * (options.planar_min_area_percent / 100.0).clamp(0.0, 1.0);
290    let min_group_tris = options.min_region_triangles.max(2);
291
292    let (mut pieces, leftover) = peel_carriers(data, tri_ids, options);
293    let member: rustc_hash::FxHashSet<u32> = leftover.iter().copied().collect();
294    let mut order: Vec<u32> = leftover.clone();
295    order.sort_by(|&a, &b| {
296        data.tris[b as usize]
297            .area
298            .total_cmp(&data.tris[a as usize].area)
299            .then(a.cmp(&b))
300    });
301
302    let mut group_of: HashMap<u32, usize> = HashMap::default();
303    let mut planar_groups: Vec<Vec<u32>> = Vec::new();
304    let mut stack = Vec::new();
305    for &seed in &order {
306        if group_of.contains_key(&seed) {
307            continue;
308        }
309        let seed_tri = &data.tris[seed as usize];
310        let seed_normal = seed_tri.normal;
311        let seed_origin = seed_tri.centroid;
312        let coplanar = |t: u32| -> bool {
313            let tri = &data.tris[t as usize];
314            if tri.normal.dot(seed_normal) < cos_gate {
315                return false;
316            }
317            tri.verts
318                .iter()
319                .all(|&v| data.verts[v].sub(seed_origin).dot(seed_normal).abs() <= dist_gate)
320        };
321        if !coplanar(seed) {
322            continue;
323        }
324        // Grow the seed plane over not-yet-grouped region triangles.
325        let mut visited: rustc_hash::FxHashSet<u32> = rustc_hash::FxHashSet::default();
326        visited.insert(seed);
327        stack.push(seed);
328        let mut group = Vec::new();
329        while let Some(t) = stack.pop() {
330            group.push(t);
331            let tri = &data.tris[t as usize];
332            for corner in 0..3 {
333                let first = tri.verts[corner];
334                let second = tri.verts[(corner + 1) % 3];
335                if first == second {
336                    continue;
337                }
338                if let Some(incident) = data.edges.get(&edge_key(first, second)) {
339                    for &other in incident {
340                        if other == t
341                            || !member.contains(&other)
342                            || group_of.contains_key(&other)
343                            || visited.contains(&other)
344                        {
345                            continue;
346                        }
347                        if coplanar(other) {
348                            visited.insert(other);
349                            stack.push(other);
350                        }
351                    }
352                }
353            }
354        }
355        let group_area: f64 = group.iter().map(|&t| data.tris[t as usize].area).sum();
356        if group.len() >= min_group_tris && group_area >= min_group_area {
357            let id = planar_groups.len();
358            for &t in &group {
359                group_of.insert(t, id);
360            }
361            group.sort_unstable();
362            planar_groups.push(group);
363        }
364    }
365
366    let remainder: Vec<u32> = leftover
367        .iter()
368        .copied()
369        .filter(|t| !group_of.contains_key(t))
370        .collect();
371
372    for group in planar_groups {
373        let outcome = fit_region(data, &group, options);
374        pieces.push((group, outcome));
375    }
376    for component in connected_components(data, &remainder) {
377        let outcome = fit_region(data, &component, options);
378        pieces.push((component, outcome));
379    }
380    pieces
381}
382
383/// Precision bar of the cylinder peel as a fraction of the compound
384/// region's scale: far below the acceptance tolerance (so the first strip
385/// past a tangent junction is refused) and far above the vertex noise of an
386/// exact tessellation.
387const CYLINDER_PEEL_PRECISION_FRACTION: f64 = 1e-5;
388/// Floor of that bar from the coordinate magnitude: single-precision
389/// sources quantize at a few ulps of their largest coordinate.
390const CYLINDER_PEEL_COORDINATE_FRACTION: f64 = 1e-6;
391/// Adjacent triangles whose normals agree within this angle are one planar
392/// facet (a tessellated strip) and are peeled together.
393const CYLINDER_PEEL_FACET_ANGLE_RAD: f64 = 1e-4;
394/// Rings of facets gathered around a seed facet before the seed fit: three
395/// rows at least, because two rows of cells lie on two coaxial circles,
396/// which every revolve fits exactly.
397const CYLINDER_PEEL_SEED_RINGS: usize = 3;
398
399/// Carrier kinds the peel tries, in cascade order, with the smallest vertex
400/// support that makes a claim at the precision bar evidence rather than an
401/// interpolation: three times the carrier's degrees of freedom (a cylinder
402/// has five, a cone six, a sphere four).  Each pass works on what the
403/// previous ones left.
404const PEEL_KINDS: [(&str, usize); 3] = [("cylinder", 15), ("cone", 18), ("sphere", 12)];
405
406/// Peel single-carrier groups off a tangent-smooth compound the cascade
407/// could not classify: a rounded outline (arcs of different radii joined on
408/// rulings), a fillet running out into a boss, a dome sitting on a chamfer
409/// ring.
410///
411/// The acceptance tolerance is far too loose to separate tangent surfaces:
412/// at the junction of two arcs the first strip on the far side deviates by
413/// a fraction of the acceptance bar and the normal gate admits several
414/// more.  Growth therefore runs at a precision bar and is judged by the
415/// re-fit residual of the grown group: a wave of neighboring facets is kept
416/// when the joint fit still sits at precision, otherwise each facet is
417/// tried alone.  Facets — coplanar triangle groups, the strips of a
418/// tessellated cylinder — move as a whole so a region boundary never runs
419/// down a strip's diagonal.  Each finished group is then accepted through
420/// the ordinary cascade at the user tolerance.  Cylinders are peeled
421/// first, then cones, then spheres.  Returns the pieces and the triangles
422/// no carrier claimed.
423fn peel_carriers(
424    data: &MeshData,
425    tri_ids: &[u32],
426    options: &SegmentOptions,
427) -> (Vec<(Vec<u32>, RegionOutcome)>, Vec<u32>) {
428    let debug = std::env::var("BREP_DEBUG_SEG").is_ok();
429    let local: HashMap<u32, usize> = tri_ids
430        .iter()
431        .enumerate()
432        .map(|(index, &t)| (t, index))
433        .collect();
434    let vert_ids = region_vertices(data, tri_ids);
435    let (low, high) = region_bbox(data, &vert_ids);
436    let scale = {
437        let diag = high.sub(low).length();
438        if diag > 0.0 {
439            diag
440        } else {
441            data.diag.max(1e-12)
442        }
443    };
444    let accept_tol = options.fit_tolerance * scale;
445    let coordinate = vert_ids
446        .iter()
447        .map(|&v| {
448            let p = data.verts[v];
449            p.x.abs().max(p.y.abs()).max(p.z.abs())
450        })
451        .fold(0.0_f64, f64::max);
452    let bar = (scale * CYLINDER_PEEL_PRECISION_FRACTION)
453        .max(coordinate * CYLINDER_PEEL_COORDINATE_FRACTION);
454    if bar >= accept_tol {
455        return (Vec::new(), tri_ids.to_vec());
456    }
457    let cos_normal_gate = options.normal_tolerance_deg.to_radians().cos();
458    let cos_facet = CYLINDER_PEEL_FACET_ANGLE_RAD.cos();
459
460    // Facets: union of coplanar edge-adjacent member triangles.
461    let mut parent: Vec<usize> = (0..tri_ids.len()).collect();
462    fn find(parent: &mut [usize], mut x: usize) -> usize {
463        while parent[x] != x {
464            parent[x] = parent[parent[x]];
465            x = parent[x];
466        }
467        x
468    }
469    let mut adjacency: Vec<Vec<usize>> = vec![Vec::new(); tri_ids.len()];
470    for (index, &t) in tri_ids.iter().enumerate() {
471        let tri = &data.tris[t as usize];
472        for corner in 0..3 {
473            let first = tri.verts[corner];
474            let second = tri.verts[(corner + 1) % 3];
475            if first == second {
476                continue;
477            }
478            let Some(incident) = data.edges.get(&edge_key(first, second)) else {
479                continue;
480            };
481            for &other in incident {
482                let Some(&other_index) = local.get(&other) else {
483                    continue;
484                };
485                if other_index == index {
486                    continue;
487                }
488                adjacency[index].push(other_index);
489                if tri.normal.dot(data.tris[other as usize].normal) >= cos_facet {
490                    let a = find(&mut parent, index);
491                    let b = find(&mut parent, other_index);
492                    if a != b {
493                        parent[a] = b;
494                    }
495                }
496            }
497        }
498    }
499    let mut facet_of = vec![usize::MAX; tri_ids.len()];
500    let mut facets: Vec<Vec<usize>> = Vec::new();
501    for index in 0..tri_ids.len() {
502        let root = find(&mut parent, index);
503        if facet_of[root] == usize::MAX {
504            facet_of[root] = facets.len();
505            facets.push(Vec::new());
506        }
507        facet_of[index] = facet_of[root];
508        facets[facet_of[index]].push(index);
509    }
510    let mut facet_neighbors: Vec<Vec<usize>> = vec![Vec::new(); facets.len()];
511    for (index, others) in adjacency.iter().enumerate() {
512        for &other in others {
513            let (a, b) = (facet_of[index], facet_of[other]);
514            if a != b && !facet_neighbors[a].contains(&b) {
515                facet_neighbors[a].push(b);
516            }
517        }
518    }
519    let facet_area: Vec<f64> = facets
520        .iter()
521        .map(|facet| {
522            facet
523                .iter()
524                .map(|&index| data.tris[tri_ids[index] as usize].area)
525                .sum()
526        })
527        .collect();
528
529    let facet_tris = |facet_ids: &[usize]| -> Vec<u32> {
530        facet_ids
531            .iter()
532            .flat_map(|&facet| facets[facet].iter().map(|&index| tri_ids[index]))
533            .collect()
534    };
535    let fit_kind = |kind: &str, facet_ids: &[usize]| -> Option<CandidateFit> {
536        let tris = facet_tris(facet_ids);
537        let verts = region_vertices(data, &tris);
538        match kind {
539            "cylinder" => fit_cylinder(data, &tris, &verts),
540            "cone" => fit_cone(data, &tris, &verts, accept_tol),
541            "sphere" => fit_sphere(data, &tris, &verts),
542            _ => None,
543        }
544    };
545    let fit_facets = |kind: &str, facet_ids: &[usize]| -> Option<CandidateFit> {
546        fit_kind(kind, facet_ids).filter(|fit| fit.max_dev.is_finite() && fit.max_dev <= bar)
547    };
548    // Loose pre-filter against the current carrier: the refit decides.
549    let plausible = |carrier: &RegionCarrier, facet: usize| -> bool {
550        let sense = carrier_sense(carrier);
551        facets[facet].iter().all(|&index| {
552            let tri = &data.tris[tri_ids[index] as usize];
553            let Some(normal) = carrier_normal(carrier, tri.centroid) else {
554                return false;
555            };
556            tri.normal.dot(normal) * sense >= cos_normal_gate
557                && tri
558                    .verts
559                    .iter()
560                    .all(|&v| carrier_distance(carrier, data.verts[v]) <= accept_tol)
561        })
562    };
563
564    let mut order: Vec<usize> = (0..facets.len()).collect();
565    order.sort_by(|&a, &b| facet_area[b].total_cmp(&facet_area[a]).then(a.cmp(&b)));
566    let mut assigned = vec![false; facets.len()];
567    let mut pieces: Vec<(Vec<u32>, RegionOutcome)> = Vec::new();
568    // Rounds until nothing new is claimed: a wall one strip tall has its
569    // seed neighborhood polluted by the dome or fillet beyond its rim until
570    // that neighbor has been claimed by its own pass.
571    loop {
572    let claimed = pieces.len();
573    for (kind, min_vertices) in PEEL_KINDS {
574    let mut tried = vec![false; facets.len()];
575    for &seed in &order {
576        if assigned[seed] || tried[seed] {
577            continue;
578        }
579        tried[seed] = true;
580        let mut group = vec![seed];
581        let mut in_group = vec![false; facets.len()];
582        in_group[seed] = true;
583        let mut frontier = vec![seed];
584        for _ in 0..CYLINDER_PEEL_SEED_RINGS {
585            let mut next = Vec::new();
586            for &facet in &frontier {
587                for &other in &facet_neighbors[facet] {
588                    if !assigned[other] && !in_group[other] {
589                        in_group[other] = true;
590                        group.push(other);
591                        next.push(other);
592                    }
593                }
594            }
595            frontier = next;
596        }
597        let Some(seed_fit) = fit_facets(kind, &group) else {
598            continue;
599        };
600        let mut carrier = seed_fit.carrier;
601        let mut blocked = vec![false; facets.len()];
602        loop {
603            let mut wave = Vec::new();
604            for &facet in &group {
605                for &other in &facet_neighbors[facet] {
606                    if !assigned[other]
607                        && !in_group[other]
608                        && !blocked[other]
609                        && !wave.contains(&other)
610                        && plausible(&carrier, other)
611                    {
612                        wave.push(other);
613                    }
614                }
615            }
616            if wave.is_empty() {
617                break;
618            }
619            let mut trial = group.clone();
620            trial.extend(wave.iter().copied());
621            if let Some(fit) = fit_facets(kind, &trial) {
622                for &facet in &wave {
623                    in_group[facet] = true;
624                }
625                group = trial;
626                carrier = fit.carrier;
627                continue;
628            }
629            // The wave crossed a junction: admit its facets one at a time.
630            let mut accepted_any = false;
631            for facet in wave {
632                let mut trial = group.clone();
633                trial.push(facet);
634                if let Some(fit) = fit_facets(kind, &trial) {
635                    in_group[facet] = true;
636                    group = trial;
637                    carrier = fit.carrier;
638                    accepted_any = true;
639                } else {
640                    blocked[facet] = true;
641                }
642            }
643            if !accepted_any {
644                break;
645            }
646        }
647        let mut tris = facet_tris(&group);
648        if tris.len() < options.min_region_triangles.max(4)
649            || region_vertices(data, &tris).len() < min_vertices
650        {
651            continue;
652        }
653        tris.sort_unstable();
654        let outcome = fit_region(data, &tris, options);
655        if debug {
656            let size = match outcome.fit.carrier {
657                RegionCarrier::Cylinder { radius, .. } | RegionCarrier::Sphere { radius, .. } => {
658                    format!(" r={radius:.4}")
659                }
660                RegionCarrier::Cone { half_angle_rad, .. } => {
661                    format!(" ha={:.2}deg", half_angle_rad.to_degrees())
662                }
663                _ => String::new(),
664            };
665            let (low, high) = region_bbox(data, &region_vertices(data, &tris));
666            eprintln!(
667                "[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}]",
668                group.len(),
669                tris.len(),
670                outcome.fit.carrier.kind(),
671                outcome.accepted,
672                outcome.fit.max_dev,
673                outcome.fit.max_normal_angle_deg,
674                low.x, low.y, low.z, high.x, high.y, high.z
675            );
676        }
677        if !outcome.accepted || outcome.fit.carrier.kind() != kind {
678            continue;
679        }
680        for &facet in &group {
681            assigned[facet] = true;
682        }
683        pieces.push((tris, outcome));
684    }
685    }
686    if pieces.len() == claimed {
687        break;
688    }
689    }
690    if pieces.is_empty() {
691        return (pieces, tri_ids.to_vec());
692    }
693    // A leftover strip whose every peeled neighbor is the same carrier is an
694    // interior gap — a tessellator T-vertex on a rim chord — not a
695    // junction: it rejoins that carrier when the union still passes the
696    // cascade at the user tolerance.  A strip straddling the tangent point
697    // of two different carriers stays out and becomes its own small facet.
698    let mut piece_of_facet = vec![usize::MAX; facets.len()];
699    for (piece, (tris, _)) in pieces.iter().enumerate() {
700        for &t in tris {
701            piece_of_facet[facet_of[local[&t]]] = piece;
702        }
703    }
704    let leftover_tris: Vec<u32> = (0..tri_ids.len())
705        .filter(|&index| !assigned[facet_of[index]])
706        .map(|index| tri_ids[index])
707        .collect();
708    let compat_tol = options.fit_tolerance * data.diag.max(1e-12);
709    let mut remainder = Vec::new();
710    for component in connected_components(data, &leftover_tris) {
711        let mut adjacent_pieces: Vec<usize> = Vec::new();
712        for &t in &component {
713            for &other in &facet_neighbors[facet_of[local[&t]]] {
714                let piece = piece_of_facet[other];
715                if piece != usize::MAX && !adjacent_pieces.contains(&piece) {
716                    adjacent_pieces.push(piece);
717                }
718            }
719        }
720        let Some(&first) = adjacent_pieces.first() else {
721            remainder.extend(component);
722            continue;
723        };
724        let same_carrier = adjacent_pieces.iter().all(|&piece| {
725            carriers_compatible(
726                &pieces[first].1.fit.carrier,
727                &pieces[piece].1.fit.carrier,
728                compat_tol,
729                true,
730            )
731        });
732        if !same_carrier {
733            remainder.extend(component);
734            continue;
735        }
736        let mut union: Vec<u32> = pieces[first].0.iter().chain(component.iter()).copied().collect();
737        union.sort_unstable();
738        let outcome = fit_region(data, &union, options);
739        let rejoined = outcome.accepted
740            && outcome.fit.carrier.kind() == pieces[first].1.fit.carrier.kind();
741        if debug {
742            eprintln!(
743                "[seg] peel: leftover of {} tris between {} peeled piece(s): union {} accepted={} max_dev={:.3e}",
744                component.len(),
745                adjacent_pieces.len(),
746                outcome.fit.carrier.kind(),
747                outcome.accepted,
748                outcome.fit.max_dev
749            );
750        }
751        if rejoined {
752            pieces[first] = (union, outcome);
753        } else {
754            remainder.extend(component);
755        }
756    }
757    remainder.sort_unstable();
758    (pieces, remainder)
759}
760
761// ---------------------------------------------------------------------------
762// Entry point
763// ---------------------------------------------------------------------------
764
765/// Segment a triangle mesh into smooth regions and recognize each region's
766/// analytic carrier.  `positions` are xyz triples; `indices` is a triangle
767/// index buffer, or empty to treat `positions` as a raw soup of consecutive
768/// triangles (the binary-STL layout of `read_binary_stl`).
769pub fn segment_mesh_faces(
770    positions: &[f64],
771    indices: &[u32],
772    options: &SegmentOptions,
773) -> Result<MeshSegmentation, String> {
774    if !(options.deflection_angle_deg > 0.0) || !options.deflection_angle_deg.is_finite() {
775        return Err("segment_mesh_faces: deflection angle must be positive".into());
776    }
777    if !(options.fit_tolerance > 0.0) || !options.fit_tolerance.is_finite() {
778        return Err("segment_mesh_faces: fit tolerance must be positive".into());
779    }
780    let data = build_mesh_data(positions, indices, options)?;
781    Ok(segment_prepared(&data, options))
782}
783
784/// Segmentation core on an already-welded mesh: shared by the analysis entry
785/// (`segment_mesh_faces`) and the BREP rebuild (`mesh_regions_to_brep`), so
786/// both see identical triangle/vertex indexing.
787pub(super) fn segment_prepared(data: &MeshData, options: &SegmentOptions) -> MeshSegmentation {
788    let mut raw_regions = grow_regions(data, options);
789    absorb_tiny_regions(data, &mut raw_regions);
790
791    let mut final_regions: Vec<(Vec<u32>, RegionOutcome)> = Vec::new();
792    for region in raw_regions {
793        let outcome = fit_region(data, &region, options);
794        if outcome.accepted {
795            final_regions.push((region, outcome));
796            continue;
797        }
798        // Tangent-smooth compound (or genuinely freeform): try to split it.
799        let pieces = refine_region(data, &region, options);
800        if pieces.len() <= 1 {
801            // Refinement did not split anything; keep the region whole.
802            final_regions.push((region, outcome));
803        } else {
804            final_regions.extend(pieces);
805        }
806    }
807
808    // Reunite regions the growing/refinement pipeline split although they
809    // lie on ONE carrier (a cap triangle isolated behind skipped slivers, a
810    // blend peeled into two coaxial pieces).  Downstream consumers — the
811    // BREP rebuild above all — rely on "one region per face".
812    merge_compatible_regions(data, &mut final_regions, options);
813
814    let mut triangle_region_ids = vec![UNASSIGNED_REGION; data.tris.len()];
815    let mut regions = Vec::with_capacity(final_regions.len());
816    for (id, (tri_ids, outcome)) in final_regions.into_iter().enumerate() {
817        for &t in &tri_ids {
818            triangle_region_ids[t as usize] = id as u32;
819        }
820        regions.push(MeshRegion {
821            id: id as u32,
822            triangle_count: outcome.triangle_count,
823            area: outcome.area,
824            bbox_min: outcome.bbox.0,
825            bbox_max: outcome.bbox.1,
826            carrier: outcome.fit.carrier,
827            max_deviation: outcome.fit.max_dev,
828            rms_deviation: outcome.fit.rms_dev,
829            max_normal_angle_deg: outcome.fit.max_normal_angle_deg,
830        });
831    }
832
833    // Attach degenerate/micro triangles to an adjacent region when one
834    // exists: the neighbor whose carrier the sliver's vertices actually sit
835    // on (a T-junction needle lies in one of its neighboring planes, not
836    // on the fillet it happens to touch first).  Multi-pass so chains of
837    // skipped slivers resolve through their assigned neighbors.
838    let mut changed = true;
839    while changed {
840        changed = false;
841        for t in 0..data.tris.len() {
842            if triangle_region_ids[t] != UNASSIGNED_REGION {
843                continue;
844            }
845            let tri = &data.tris[t];
846            let mut adopted: Option<(f64, u32)> = None;
847            for corner in 0..3 {
848                let first = tri.verts[corner];
849                let second = tri.verts[(corner + 1) % 3];
850                if first == second {
851                    continue;
852                }
853                if let Some(incident) = data.edges.get(&edge_key(first, second)) {
854                    for &other in incident {
855                        let region = triangle_region_ids[other as usize];
856                        if other as usize == t || region == UNASSIGNED_REGION {
857                            continue;
858                        }
859                        let carrier = &regions[region as usize].carrier;
860                        let distance = tri
861                            .verts
862                            .iter()
863                            .map(|&v| carrier_distance(carrier, data.verts[v]))
864                            .fold(0.0_f64, f64::max);
865                        let candidate = (distance, region);
866                        adopted = Some(match adopted {
867                            Some(current)
868                                if current.0 < candidate.0
869                                    || (current.0 == candidate.0 && current.1 <= candidate.1) =>
870                            {
871                                current
872                            }
873                            _ => candidate,
874                        });
875                    }
876                }
877            }
878            if let Some((_, region)) = adopted {
879                triangle_region_ids[t] = region;
880                changed = true;
881            }
882        }
883    }
884
885    MeshSegmentation {
886        triangle_region_ids,
887        regions,
888        triangle_count: data.tris.len(),
889        welded_vertex_count: data.verts.len(),
890    }
891}
892
893/// Outward carrier normal at a point (before the region's sense), or `None`
894/// where the field is singular (on an axis, at a center) or for `Freeform`.
895fn carrier_normal(carrier: &RegionCarrier, p: Vec3) -> Option<Vec3> {
896    match carrier {
897        RegionCarrier::Plane { normal, .. } => Some(*normal),
898        RegionCarrier::Cylinder {
899            axis_point,
900            axis_dir,
901            ..
902        } => {
903            let d = p.sub(*axis_point);
904            d.sub(axis_dir.scale(d.dot(*axis_dir))).normalized().ok()
905        }
906        RegionCarrier::Cone {
907            apex,
908            axis_dir,
909            half_angle_rad,
910            ..
911        } => {
912            let d = p.sub(*apex);
913            let radial = d.sub(axis_dir.scale(d.dot(*axis_dir))).normalized().ok()?;
914            let (sin_a, cos_a) = half_angle_rad.sin_cos();
915            Some(radial.scale(cos_a).sub(axis_dir.scale(sin_a)))
916        }
917        RegionCarrier::Sphere { center, .. } => p.sub(*center).normalized().ok(),
918        RegionCarrier::Torus {
919            center,
920            axis_dir,
921            major_radius,
922            ..
923        } => {
924            let d = p.sub(*center);
925            let s = d.dot(*axis_dir);
926            let radial = d.sub(axis_dir.scale(s)).normalized().ok()?;
927            let tube_center = center.add(radial.scale(*major_radius));
928            p.sub(tube_center).normalized().ok()
929        }
930        RegionCarrier::Freeform => None,
931    }
932}
933
934/// The region's orientation sense as a sign, `1.0` for planes and freeform.
935fn carrier_sense(carrier: &RegionCarrier) -> f64 {
936    match carrier {
937        RegionCarrier::Cylinder { sense, .. }
938        | RegionCarrier::Cone { sense, .. }
939        | RegionCarrier::Sphere { sense, .. }
940        | RegionCarrier::Torus { sense, .. } => f64::from(*sense),
941        _ => 1.0,
942    }
943}
944
945/// Distance of a point from a carrier; `Freeform` is infinitely far.
946fn carrier_distance(carrier: &RegionCarrier, p: Vec3) -> f64 {
947    match carrier {
948        RegionCarrier::Plane { origin, normal } => p.sub(*origin).dot(*normal).abs(),
949        RegionCarrier::Cylinder {
950            axis_point,
951            axis_dir,
952            radius,
953            ..
954        } => {
955            let d = p.sub(*axis_point);
956            (d.sub(axis_dir.scale(d.dot(*axis_dir))).length() - radius).abs()
957        }
958        RegionCarrier::Cone {
959            apex,
960            axis_dir,
961            half_angle_rad,
962            ..
963        } => {
964            let d = p.sub(*apex);
965            let s = d.dot(*axis_dir);
966            let r = d.sub(axis_dir.scale(s)).length();
967            ((r - s * half_angle_rad.tan()) * half_angle_rad.cos()).abs()
968        }
969        RegionCarrier::Sphere { center, radius, .. } => (p.sub(*center).length() - radius).abs(),
970        RegionCarrier::Torus {
971            center,
972            axis_dir,
973            major_radius,
974            minor_radius,
975            ..
976        } => {
977            let d = p.sub(*center);
978            let s = d.dot(*axis_dir);
979            let rho = d.sub(axis_dir.scale(s)).length();
980            ((rho - major_radius).hypot(s) - minor_radius).abs()
981        }
982        RegionCarrier::Freeform => f64::INFINITY,
983    }
984}
985
986/// Do two accepted carriers describe the same surface (within `tol` for
987/// distances, tight angular bands for directions)?  Freeform never matches.
988/// `adjacent` says the regions share an edge: only then may two cylinder
989/// fits disagree on their axis by the noise over the axial extent, because
990/// two disjoint faces of one infinite cylinder (the two ends of a mirrored
991/// part, the two levels of a slot) must stay two regions.
992fn carriers_compatible(a: &RegionCarrier, b: &RegionCarrier, tol: f64, adjacent: bool) -> bool {
993    const ANGULAR: f64 = 1e-6;
994    match (a, b) {
995        (
996            RegionCarrier::Plane {
997                origin: o1,
998                normal: n1,
999            },
1000            RegionCarrier::Plane {
1001                origin: o2,
1002                normal: n2,
1003            },
1004        ) => {
1005            n1.cross(*n2).length() <= ANGULAR
1006                && n1.dot(*n2) > 0.0
1007                && o2.sub(*o1).dot(*n1).abs() <= tol
1008        }
1009        (
1010            RegionCarrier::Cylinder {
1011                axis_point: p1,
1012                axis_dir: a1,
1013                radius: r1,
1014                sense: s1,
1015            },
1016            RegionCarrier::Cylinder {
1017                axis_point: p2,
1018                axis_dir: a2,
1019                radius: r2,
1020                sense: s2,
1021            },
1022        ) => {
1023            // Independent fits of one cylinder from noisy vertices disagree
1024            // on the axis direction by the noise over the axial extent; the
1025            // band is the angle that moves the surface by `tol` at the rim.
1026            // The union re-fit is the real test.
1027            let angular = if adjacent {
1028                ANGULAR.max(tol / r1.max(*r2).max(1e-12))
1029            } else {
1030                ANGULAR
1031            };
1032            let offset = p2.sub(*p1);
1033            s1 == s2
1034                && a1.cross(*a2).length() <= angular
1035                && (r1 - r2).abs() <= tol
1036                && offset.sub(a1.scale(offset.dot(*a1))).length() <= tol
1037        }
1038        (
1039            RegionCarrier::Cone {
1040                apex: p1,
1041                axis_dir: a1,
1042                half_angle_rad: h1,
1043                sense: s1,
1044            },
1045            RegionCarrier::Cone {
1046                apex: p2,
1047                axis_dir: a2,
1048                half_angle_rad: h2,
1049                sense: s2,
1050            },
1051        ) => {
1052            s1 == s2
1053                && a1.cross(*a2).length() <= ANGULAR
1054                && a1.dot(*a2) > 0.0
1055                && (h1 - h2).abs() <= ANGULAR
1056                && p2.sub(*p1).length() <= tol
1057        }
1058        (
1059            RegionCarrier::Sphere {
1060                center: c1,
1061                radius: r1,
1062                sense: s1,
1063            },
1064            RegionCarrier::Sphere {
1065                center: c2,
1066                radius: r2,
1067                sense: s2,
1068            },
1069        ) => s1 == s2 && c2.sub(*c1).length() <= tol && (r1 - r2).abs() <= tol,
1070        (
1071            RegionCarrier::Torus {
1072                center: c1,
1073                axis_dir: a1,
1074                major_radius: mj1,
1075                minor_radius: mn1,
1076                sense: s1,
1077            },
1078            RegionCarrier::Torus {
1079                center: c2,
1080                axis_dir: a2,
1081                major_radius: mj2,
1082                minor_radius: mn2,
1083                sense: s2,
1084            },
1085        ) => {
1086            s1 == s2
1087                && a1.cross(*a2).length() <= ANGULAR
1088                && c2.sub(*c1).length() <= tol
1089                && (mj1 - mj2).abs() <= tol
1090                && (mn1 - mn2).abs() <= tol
1091        }
1092        _ => false,
1093    }
1094}
1095
1096/// Merge edge-adjacent regions whose accepted carriers agree, repeating
1097/// until stable.  Adjacency counts paths THROUGH skipped (degenerate/micro)
1098/// triangles — exactly the fences that isolate cap islands in the first
1099/// place.  A merge is only applied when the union re-fits cleanly on the
1100/// same carrier kind, so this pass can only improve the segmentation.
1101fn merge_compatible_regions(
1102    data: &MeshData,
1103    regions: &mut Vec<(Vec<u32>, RegionOutcome)>,
1104    options: &SegmentOptions,
1105) {
1106    let tol = options.fit_tolerance * data.diag.max(1e-12);
1107    loop {
1108        let mut region_of = vec![usize::MAX; data.tris.len()];
1109        for (index, (tris, _)) in regions.iter().enumerate() {
1110            for &t in tris {
1111                region_of[t as usize] = index;
1112            }
1113        }
1114        // Directly edge-adjacent pairs.
1115        let mut direct: std::collections::BTreeSet<(usize, usize)> =
1116            std::collections::BTreeSet::new();
1117        for incident in data.edges.values() {
1118            if incident.len() != 2 {
1119                continue;
1120            }
1121            let (a, b) = (
1122                region_of[incident[0] as usize],
1123                region_of[incident[1] as usize],
1124            );
1125            if a != b && a != usize::MAX && b != usize::MAX {
1126                direct.insert((a.min(b), a.max(b)));
1127            }
1128        }
1129        let mut pairs = direct.clone();
1130        // Pairs bridged by connected runs of region-less (skipped) triangles.
1131        let mut seen = vec![false; data.tris.len()];
1132        for seed in 0..data.tris.len() {
1133            if seen[seed] || region_of[seed] != usize::MAX {
1134                continue;
1135            }
1136            let mut stack = vec![seed];
1137            let mut touched: Vec<usize> = Vec::new();
1138            seen[seed] = true;
1139            while let Some(t) = stack.pop() {
1140                let tri = &data.tris[t];
1141                for corner in 0..3 {
1142                    let first = tri.verts[corner];
1143                    let second = tri.verts[(corner + 1) % 3];
1144                    if first == second {
1145                        continue;
1146                    }
1147                    if let Some(incident) = data.edges.get(&edge_key(first, second)) {
1148                        for &other in incident {
1149                            let other = other as usize;
1150                            if other == t {
1151                                continue;
1152                            }
1153                            let region = region_of[other];
1154                            if region == usize::MAX {
1155                                if !seen[other] {
1156                                    seen[other] = true;
1157                                    stack.push(other);
1158                                }
1159                            } else if !touched.contains(&region) {
1160                                touched.push(region);
1161                            }
1162                        }
1163                    }
1164                }
1165            }
1166            touched.sort_unstable();
1167            for i in 0..touched.len() {
1168                for j in i + 1..touched.len() {
1169                    pairs.insert((touched[i], touched[j]));
1170                }
1171            }
1172        }
1173
1174        let mut merged_any = false;
1175        'pairs: for (a, b) in pairs {
1176            let (first, second) = (&regions[a], &regions[b]);
1177            if !first.1.accepted || !second.1.accepted {
1178                continue;
1179            }
1180            if !carriers_compatible(
1181                &first.1.fit.carrier,
1182                &second.1.fit.carrier,
1183                tol,
1184                direct.contains(&(a, b)),
1185            ) {
1186                continue;
1187            }
1188            let mut union: Vec<u32> = first.0.iter().chain(second.0.iter()).copied().collect();
1189            union.sort_unstable();
1190            let outcome = fit_region(data, &union, options);
1191            if !outcome.accepted || outcome.fit.carrier.kind() != first.1.fit.carrier.kind() {
1192                continue;
1193            }
1194            regions[a] = (union, outcome);
1195            regions.remove(b);
1196            merged_any = true;
1197            break 'pairs;
1198        }
1199        if !merged_any {
1200            return;
1201        }
1202    }
1203}