Skip to main content

brep_kernel/edit/direct_edit/
refit_faces.rs

1//! Refit Faces: replace a connected set of faces of one solid with ONE face on
2//! a surface fitted to them — the human-guided cleanup after a mesh import,
3//! where the automatic recognition left a curved wall as many small facets.
4//!
5//! The fit is the mesh import's own single-region pass
6//! ([`brep_ransac::reconstruct_surface`]: RANSAC hypotheses, then a
7//! least-squares refine), run on the selected faces' vertices (a facet's
8//! vertices lie on the surface it approximates; its interior does not) plus
9//! the tessellation of any selected face that is already curved.
10//!
11//! The new face is trimmed by the region's own boundary, rebuilt EXACTLY: every
12//! run of boundary edges shared with one neighbour becomes a single edge on the
13//! curve where the fitted surface meets that neighbour's carrier
14//! ([`crate::intersect_analytic_pair`]), so a faceted cylinder's polygonal cap
15//! comes back bounded by a true circle. The neighbours keep their ids, names and
16//! carriers; only the loop run they shared with the region is replaced.
17//!
18//! Scope (v1), each refused by name with nothing changed:
19//! - the faces must be edge-connected, on one shell;
20//! - the region must be a disk (one boundary loop) or an annulus (two, e.g. a
21//!   full cylindrical band between two caps);
22//! - the fit's maximum vertex residual must be within the caller's tolerance;
23//! - every boundary run must lie on an exact analytic intersection of the
24//!   fitted surface with its neighbour's carrier;
25//! - the result must pass `validate()` and the soundness floor.
26
27use crate::{KernelRefusal, KernelStage, RefusalClass};
28use crate::topology::{BrepSolid, CoedgeRecord, EdgeRecord, FaceRecord, LoopRecord, VertexRecord};
29use crate::{
30    build_pcurve_on_surface_range, intersect_analytic_pair, make_arc, make_line, make_plane,
31    make_revolution, project_point_to_curve, solid_model_scale, NurbsCurve, NurbsSurface, Vec3,
32};
33use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
34use serde::{Deserialize, Serialize};
35use std::f64::consts::{PI, TAU};
36
37/// The surface family a Refit Faces fit is asked for.
38#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
39#[serde(rename_all = "lowercase")]
40pub enum RefitSurfaceKind {
41    /// The best of the five by the recognition pass's own model ranking.
42    Auto,
43    Plane,
44    Cylinder,
45    Cone,
46    Sphere,
47    Torus,
48}
49
50impl RefitSurfaceKind {
51    /// Parse the feature's `surfaceType` option (case-insensitive).
52    pub fn parse(text: &str) -> Option<Self> {
53        match text.trim().to_ascii_lowercase().as_str() {
54            "" | "auto" => Some(Self::Auto),
55            "plane" | "planar" => Some(Self::Plane),
56            "cylinder" | "cylindrical" => Some(Self::Cylinder),
57            "cone" | "conical" => Some(Self::Cone),
58            "sphere" | "spherical" => Some(Self::Sphere),
59            "torus" | "toroidal" => Some(Self::Torus),
60            _ => None,
61        }
62    }
63
64    pub fn name(self) -> &'static str {
65        match self {
66            Self::Auto => "auto",
67            Self::Plane => "plane",
68            Self::Cylinder => "cylinder",
69            Self::Cone => "cone",
70            Self::Sphere => "sphere",
71            Self::Torus => "torus",
72        }
73    }
74}
75
76/// The phrases a Refit Faces refusal is recognised by (formatted into every
77/// refusal that carries it, so a reader and the mint share one definition).
78pub mod refit_refusal_phrases {
79    pub const NOT_EDGE_CONNECTED: &str = "not edge-connected";
80    pub const RESIDUAL_OVER_TOLERANCE: &str = "fit residual";
81    pub const NOT_DISK_OR_ANNULUS: &str = "not a disk or an annulus";
82    pub const NO_EXACT_BOUNDARY: &str = "no exact boundary";
83    pub const NO_FIT: &str = "no surface fits";
84    pub const RESULT_INVALID: &str = "result does not validate";
85    pub const FACETS_TOO_COARSE: &str = "too coarse";
86    pub const CLOSED_SHELL_KIND: &str = "only a sphere or a torus closes on itself";
87    pub const CLOSED_SHELL_GENUS: &str = "does not match the shell's topology";
88}
89use refit_refusal_phrases::*;
90
91/// The fitted surface, in the recognition pass's terms.
92#[derive(Clone, Copy, Debug, Serialize)]
93#[serde(tag = "type", rename_all = "lowercase")]
94pub enum RefitCarrier {
95    Plane { origin: Vec3, normal: Vec3 },
96    Cylinder { axis_origin: Vec3, axis: Vec3, radius: f64 },
97    Cone { apex: Vec3, axis: Vec3, half_angle: f64 },
98    Sphere { center: Vec3, radius: f64 },
99    Torus { center: Vec3, axis: Vec3, major_radius: f64, minor_radius: f64 },
100}
101
102impl RefitCarrier {
103    pub fn kind(&self) -> RefitSurfaceKind {
104        match self {
105            Self::Plane { .. } => RefitSurfaceKind::Plane,
106            Self::Cylinder { .. } => RefitSurfaceKind::Cylinder,
107            Self::Cone { .. } => RefitSurfaceKind::Cone,
108            Self::Sphere { .. } => RefitSurfaceKind::Sphere,
109            Self::Torus { .. } => RefitSurfaceKind::Torus,
110        }
111    }
112
113    /// Unsigned distance from `point` to the (untrimmed) carrier.
114    pub fn distance(&self, point: Vec3) -> f64 {
115        match *self {
116            Self::Plane { origin, normal } => point.sub(origin).dot(normal).abs(),
117            Self::Cylinder { axis_origin, axis, radius } => {
118                let rel = point.sub(axis_origin);
119                (rel.sub(axis.scale(rel.dot(axis))).length() - radius).abs()
120            }
121            Self::Cone { apex, axis, half_angle } => {
122                let rel = point.sub(apex);
123                let along = rel.dot(axis);
124                let radial = rel.sub(axis.scale(along)).length();
125                // Distance to the generator line in the (along, radial) half-plane.
126                let (s, c) = half_angle.sin_cos();
127                let normal_offset = radial * c - along * s;
128                let foot = along * c + radial * s;
129                if foot >= 0.0 {
130                    normal_offset.abs()
131                } else {
132                    rel.length()
133                }
134            }
135            Self::Sphere { center, radius } => (point.sub(center).length() - radius).abs(),
136            Self::Torus { center, axis, major_radius, minor_radius } => {
137                let rel = point.sub(center);
138                let along = rel.dot(axis);
139                let radial = rel.sub(axis.scale(along)).length();
140                ((radial - major_radius).hypot(along) - minor_radius).abs()
141            }
142        }
143    }
144
145    /// A one-line reading of the parameters, for the feature's report.
146    pub fn describe(&self) -> String {
147        let v = |p: Vec3| format!("({:.6}, {:.6}, {:.6})", p.x, p.y, p.z);
148        match *self {
149            Self::Plane { origin, normal } => {
150                format!("plane through {} normal {}", v(origin), v(normal))
151            }
152            Self::Cylinder { axis_origin, axis, radius } => format!(
153                "cylinder radius {radius:.6} axis {} through {}",
154                v(axis),
155                v(axis_origin)
156            ),
157            Self::Cone { apex, axis, half_angle } => format!(
158                "cone half-angle {:.4} deg apex {} axis {}",
159                half_angle.to_degrees(),
160                v(apex),
161                v(axis)
162            ),
163            Self::Sphere { center, radius } => {
164                format!("sphere radius {radius:.6} center {}", v(center))
165            }
166            Self::Torus { center, axis, major_radius, minor_radius } => format!(
167                "torus radii {major_radius:.6}/{minor_radius:.6} center {} axis {}",
168                v(center),
169                v(axis)
170            ),
171        }
172    }
173}
174
175/// What a successful refit measured and built.
176#[derive(Clone, Debug, Serialize)]
177pub struct RefitReport {
178    pub carrier: RefitCarrier,
179    /// Root-mean-square distance of the fitted samples from the carrier.
180    pub rms_residual: f64,
181    /// Largest distance of any fitted sample from the carrier.
182    pub max_residual: f64,
183    pub tolerance: f64,
184    pub samples: usize,
185    pub replaced_faces: usize,
186    /// Boundary edges of the region before, and the exact edges replacing them.
187    pub boundary_edges_before: usize,
188    pub boundary_edges_after: usize,
189    pub boundary_loops: usize,
190    pub new_face_id: u64,
191}
192
193fn refuse_input(what: &str, message: String) -> KernelRefusal {
194    KernelRefusal::input(KernelStage::Collect, what, format!("Refit Faces: {message}"))
195}
196
197fn refuse_unsupported(stage: KernelStage, what: &str, message: String) -> KernelRefusal {
198    KernelRefusal::unsupported(stage, what, format!("Refit Faces: {message}"))
199}
200
201fn face_label(face: &FaceRecord) -> String {
202    face.name.clone().unwrap_or_else(|| format!("face {}", face.id))
203}
204
205#[derive(Clone, Copy, Debug)]
206struct BoundaryUse {
207    edge_id: u64,
208    start: u64,
209    end: u64,
210    /// The neighbouring (unselected) face index across this edge.
211    neighbour: usize,
212}
213
214/// One run of boundary coedges shared with a single neighbour.
215struct Chain {
216    uses: Vec<BoundaryUse>,
217    closed: bool,
218    neighbour: usize,
219}
220
221/// A rebuilt boundary edge: the curve, its range, and how the REGION's
222/// coedge runs along it.
223struct BuiltEdge {
224    curve: NurbsCurve,
225    t0: f64,
226    t1: f64,
227    start: u64,
228    end: u64,
229    region_forward: bool,
230    /// The region-side trim, when it is built exactly here (an iso ring of
231    /// the fitted surface: a straight line in its parameter plane).
232    region_pcurve: Option<NurbsCurve>,
233}
234
235/// Replace the faces `face_ids` of `solid` with one face on a surface of
236/// family `kind` fitted to them, within `tolerance` (maximum vertex residual,
237/// model units). See the module docs for the scope and the refusals.
238pub fn refit_faces_as_one(
239    solid: &BrepSolid,
240    face_ids: &[u64],
241    kind: RefitSurfaceKind,
242    tolerance: f64,
243) -> Result<(BrepSolid, RefitReport), KernelRefusal> {
244    let (result, report) = refit_faces_as_one_impl(solid, face_ids, kind, tolerance)?;
245    let result = crate::accept_sound(result, "refitFaces")?;
246    Ok((result, report))
247}
248
249fn refit_faces_as_one_impl(
250    solid: &BrepSolid,
251    face_ids: &[u64],
252    kind: RefitSurfaceKind,
253    tolerance: f64,
254) -> Result<(BrepSolid, RefitReport), KernelRefusal> {
255    if !(tolerance.is_finite() && tolerance > 0.0) {
256        return Err(refuse_input(
257            "tolerance",
258            format!("tolerance must be a positive number, got {tolerance}"),
259        ));
260    }
261    let mut unique: Vec<u64> = Vec::new();
262    for id in face_ids {
263        if !unique.contains(id) {
264            unique.push(*id);
265        }
266    }
267    if unique.is_empty() {
268        return Err(refuse_input("face_ids", "no faces selected".into()));
269    }
270    // ---- Locate the faces: one shell. --------------------------------------
271    let mut shell_index = None;
272    let mut selected_indices: Vec<usize> = Vec::new();
273    for id in &unique {
274        let found = solid.shells.iter().enumerate().find_map(|(s, shell)| {
275            shell.faces.iter().position(|face| face.id == *id).map(|f| (s, f))
276        });
277        let Some((s, f)) = found else {
278            return Err(refuse_input("face_ids", format!("no face with id {id}")));
279        };
280        if *shell_index.get_or_insert(s) != s {
281            return Err(refuse_input(
282                "face_ids",
283                format!("the selected faces are {NOT_EDGE_CONNECTED}: they lie on different shells"),
284            ));
285        }
286        selected_indices.push(f);
287    }
288    let shell_index = shell_index.unwrap_or(0);
289    let shell = &solid.shells[shell_index];
290    let selected: HashSet<usize> = selected_indices.iter().copied().collect();
291    let scale = solid_model_scale(solid).max(1e-9);
292    let fit_tol = (scale * 1e-7).max(1e-9);
293
294    let edge_by_id: HashMap<u64, &EdgeRecord> =
295        solid.edges.iter().map(|edge| (edge.id, edge)).collect();
296    let vertex_by_id: HashMap<u64, Vec3> = solid
297        .vertices
298        .iter()
299        .map(|vertex| (vertex.id, vertex.point))
300        .collect();
301
302    // ---- Edge uses within the shell. ---------------------------------------
303    let mut uses: HashMap<u64, Vec<(usize, usize, usize)>> = HashMap::default();
304    for (f, face) in shell.faces.iter().enumerate() {
305        for (l, lp) in face.loops.iter().enumerate() {
306            for (c, coedge) in lp.coedges.iter().enumerate() {
307                uses.entry(coedge.edge_id).or_default().push((f, l, c));
308            }
309        }
310    }
311
312    // ---- Connectivity over shared edges. -----------------------------------
313    {
314        let mut seen: HashSet<usize> = HashSet::default();
315        let mut stack = vec![selected_indices[0]];
316        seen.insert(selected_indices[0]);
317        while let Some(f) = stack.pop() {
318            for lp in &shell.faces[f].loops {
319                for coedge in &lp.coedges {
320                    for &(g, _, _) in uses.get(&coedge.edge_id).into_iter().flatten() {
321                        if selected.contains(&g) && seen.insert(g) {
322                            stack.push(g);
323                        }
324                    }
325                }
326            }
327        }
328        if seen.len() != selected.len() {
329            let stray: Vec<String> = selected_indices
330                .iter()
331                .filter(|f| !seen.contains(f))
332                .map(|&f| face_label(&shell.faces[f]))
333                .collect();
334            return Err(refuse_input(
335                "face_ids",
336                format!(
337                    "the selected faces are {NOT_EDGE_CONNECTED}: {} {} no edge with the rest",
338                    stray.join(", "),
339                    if stray.len() == 1 { "shares" } else { "share" }
340                ),
341            ));
342        }
343    }
344
345    // ---- Classify the region's edges; collect its boundary. ----------------
346    let mut internal_edges: HashSet<u64> = HashSet::default();
347    let mut boundary: Vec<BoundaryUse> = Vec::new();
348    let mut region_vertices: HashSet<u64> = HashSet::default();
349    let mut loop_total = 0usize;
350    for &f in &selected_indices {
351        loop_total += shell.faces[f].loops.len();
352        for lp in &shell.faces[f].loops {
353            for coedge in &lp.coedges {
354                let edge = edge_by_id.get(&coedge.edge_id).ok_or_else(|| {
355                    refuse_input("solid", format!("coedge names missing edge {}", coedge.edge_id))
356                })?;
357                region_vertices.insert(edge.start_vertex_id);
358                region_vertices.insert(edge.end_vertex_id);
359                let edge_uses = &uses[&coedge.edge_id];
360                let outside: Vec<usize> = edge_uses
361                    .iter()
362                    .map(|(g, _, _)| *g)
363                    .filter(|g| !selected.contains(g))
364                    .collect();
365                if outside.is_empty() {
366                    internal_edges.insert(coedge.edge_id);
367                    continue;
368                }
369                if edge_uses.len() != 2 || outside.len() != 1 {
370                    return Err(refuse_unsupported(
371                        KernelStage::Classify,
372                        "non_manifold_boundary",
373                        format!(
374                            "edge {} of the selection is used by {} faces; the region is \
375                             {NOT_DISK_OR_ANNULUS}",
376                            coedge.edge_id,
377                            edge_uses.len()
378                        ),
379                    ));
380                }
381                let (start, end) = if coedge.forward {
382                    (edge.start_vertex_id, edge.end_vertex_id)
383                } else {
384                    (edge.end_vertex_id, edge.start_vertex_id)
385                };
386                boundary.push(BoundaryUse {
387                    edge_id: coedge.edge_id,
388                    start,
389                    end,
390                    neighbour: outside[0],
391                });
392            }
393        }
394    }
395    if boundary.is_empty() {
396        // Every face of one closed shell: the shell itself becomes the
397        // primitive (v2). Only a sphere or a torus closes on itself.
398        let euler = region_vertices.len() as i64 - internal_edges.len() as i64
399            + (2 * selected_indices.len() as i64 - loop_total as i64);
400        return refit_closed_shell(
401            solid,
402            shell_index,
403            &selected_indices,
404            euler,
405            kind,
406            tolerance,
407            scale,
408            &edge_by_id,
409            &vertex_by_id,
410        );
411    }
412
413    // ---- Walk the boundary into cycles. ------------------------------------
414    let mut outgoing: HashMap<u64, usize> = HashMap::default();
415    for (index, use_) in boundary.iter().enumerate() {
416        if outgoing.insert(use_.start, index).is_some() {
417            return Err(refuse_unsupported(
418                KernelStage::Classify,
419                "pinched_region",
420                format!(
421                    "the region's boundary touches itself at a vertex; the region is \
422                     {NOT_DISK_OR_ANNULUS}"
423                ),
424            ));
425        }
426    }
427    let mut cycles: Vec<Vec<BoundaryUse>> = Vec::new();
428    let mut visited = vec![false; boundary.len()];
429    for first in 0..boundary.len() {
430        if visited[first] {
431            continue;
432        }
433        let mut cycle = Vec::new();
434        let mut at = first;
435        loop {
436            if visited[at] {
437                break;
438            }
439            visited[at] = true;
440            cycle.push(boundary[at]);
441            match outgoing.get(&boundary[at].end) {
442                Some(&next) => at = next,
443                None => {
444                    return Err(refuse_unsupported(
445                        KernelStage::Classify,
446                        "open_boundary",
447                        format!("the region's boundary does not close; it is {NOT_DISK_OR_ANNULUS}"),
448                    ))
449                }
450            }
451        }
452        if at != first {
453            return Err(refuse_unsupported(
454                KernelStage::Classify,
455                "open_boundary",
456                format!("the region's boundary does not close; it is {NOT_DISK_OR_ANNULUS}"),
457            ));
458        }
459        cycles.push(cycle);
460    }
461    // Euler characteristic of the region as a surface: V - E + sum(2 - loops).
462    let boundary_edges: HashSet<u64> = boundary.iter().map(|b| b.edge_id).collect();
463    let euler = region_vertices.len() as i64
464        - (internal_edges.len() + boundary_edges.len()) as i64
465        + (2 * selected_indices.len() as i64 - loop_total as i64);
466    if !(cycles.len() == 1 || cycles.len() == 2) || euler != 2 - cycles.len() as i64 {
467        return Err(refuse_unsupported(
468            KernelStage::Classify,
469            "region_topology",
470            format!(
471                "the selected region has {} boundary loop(s) and Euler characteristic {euler}; \
472                 it is {NOT_DISK_OR_ANNULUS}, which one face can take",
473                cycles.len()
474            ),
475        ));
476    }
477
478    // ---- Fit. ---------------------------------------------------------------
479    let samples = fit_samples(shell, &selected_indices, &edge_by_id, &vertex_by_id)?;
480    let fitted = fit_carrier(&samples, kind, tolerance, scale)?;
481    let carrier = fitted.carrier;
482    let (rms_residual, max_residual) = residuals(&carrier, &samples.points);
483    if max_residual > tolerance {
484        return Err(KernelRefusal::ill_posed(
485            KernelStage::Refine,
486            "refit_residual",
487            format!(
488                "Refit Faces: the {} {RESIDUAL_OVER_TOLERANCE} {max_residual:.6} (rms \
489                 {rms_residual:.6}) is over the tolerance {tolerance}; these faces are not \
490                 one {} within it",
491                carrier.kind().name(),
492                carrier.kind().name()
493            ),
494        ));
495    }
496
497    coarse_facet_gate(&carrier, shell, &selected_indices, &edge_by_id, &vertex_by_id)?;
498
499    // Every boundary vertex, too, must lie on the carrier: the boundary is
500    // what the new face is trimmed by.
501    let surface = carrier_surface(&carrier, &samples.points, scale)?;
502
503    // Orientation: agree with the selected faces' outward sense.
504    let same_sense = region_same_sense(&surface, &samples)?;
505
506    // ---- Chains: runs of boundary shared with one neighbour. ---------------
507    let mut chains_per_cycle: Vec<Vec<Chain>> = Vec::new();
508    for cycle in &cycles {
509        chains_per_cycle.push(split_chains(cycle));
510    }
511
512    // ---- Rebuild each chain as one exact edge. ------------------------------
513    let chain_tol = (tolerance * 2.0).max(fit_tol * 10.0);
514    let mut next_edge_id = solid.edges.iter().map(|e| e.id).max().unwrap_or(0) + 1;
515    let mut next_vertex_id = solid.vertices.iter().map(|v| v.id).max().unwrap_or(0) + 1;
516    let mut moved_vertices: HashMap<u64, Vec3> = HashMap::default();
517    let mut new_vertices: Vec<VertexRecord> = Vec::new();
518
519    // Candidate curves per chain.
520    let mut chain_curves: Vec<Vec<NurbsCurve>> = Vec::new();
521    for chains in &chains_per_cycle {
522        for chain in chains {
523            let neighbour = &shell.faces[chain.neighbour];
524            let points = chain_points(chain, &vertex_by_id);
525            // The closed-form intersection when the kernel knows the pair; an
526            // iso circle of the fitted surface that provably lies on the
527            // neighbour otherwise (a plane square to a torus's axis).
528            let mut curves = intersect_analytic_pair(&surface, &neighbour.surface, fit_tol)
529                .unwrap_or_default();
530            // Prefer the exact intersection. An approximate iso ruling can
531            // be closer to a chord's trim vertices while missing the neighbour
532            // carrier; choosing it would leave a gap in the rebuilt shell.
533            if curves.is_empty() {
534                curves = iso_curve_on(&surface, &neighbour.surface, &points, chain_tol);
535            }
536            if curves.is_empty() {
537                return Err(refuse_unsupported(
538                    KernelStage::Intersect,
539                    "no_exact_boundary",
540                    format!(
541                        "{NO_EXACT_BOUNDARY} with {}: its carrier is not one whose \
542                         intersection with the fitted {} is known in closed form",
543                        face_label(neighbour),
544                        carrier.kind().name()
545                    ),
546                ));
547            }
548            let mut best: Option<(f64, NurbsCurve)> = None;
549            for curve in curves {
550                let mut worst = 0.0_f64;
551                for point in &points {
552                    let d = project_point_to_curve(&curve, *point)
553                        .map(|p| p.distance)
554                        .unwrap_or(f64::INFINITY);
555                    worst = worst.max(d);
556                }
557                if best.as_ref().map_or(true, |(w, _)| worst < *w) {
558                    best = Some((worst, curve));
559                }
560            }
561            match best {
562                Some((worst, curve)) if worst <= chain_tol => chain_curves.push(vec![curve]),
563                Some((worst, _)) => {
564                    return Err(refuse_unsupported(
565                        KernelStage::Intersect,
566                        "no_exact_boundary",
567                        format!(
568                            "{NO_EXACT_BOUNDARY} with {}: the shared edges sit {worst:.6} off \
569                             the curve where the fitted {} meets it (allowed {chain_tol:.6})",
570                            face_label(neighbour),
571                            carrier.kind().name()
572                        ),
573                    ))
574                }
575                None => {
576                    return Err(refuse_unsupported(
577                        KernelStage::Intersect,
578                        "no_exact_boundary",
579                        format!(
580                            "{NO_EXACT_BOUNDARY} with {}: the fitted {} does not meet it",
581                            face_label(neighbour),
582                            carrier.kind().name()
583                        ),
584                    ))
585                }
586            }
587        }
588    }
589
590    // Corners: where one chain hands over to the next, the vertex is moved to
591    // the meeting point of the two curves.
592    {
593        let mut flat = 0usize;
594        for chains in &chains_per_cycle {
595            let base = flat;
596            let count = chains.len();
597            for index in 0..count {
598                let chain = &chains[index];
599                flat += 1;
600                if chain.closed {
601                    continue;
602                }
603                let next = (index + 1) % count;
604                let corner = chain.uses.last().expect("chain has uses").end;
605                let original = vertex_by_id[&corner];
606                let first = &chain_curves[base + index][0];
607                let second = &chain_curves[base + next][0];
608                let (point, gap) = curve_meeting(first, second, original)?;
609                if gap > chain_tol {
610                    return Err(refuse_unsupported(
611                        KernelStage::Intersect,
612                        "corner_gap",
613                        format!(
614                            "{NO_EXACT_BOUNDARY}: at a corner the rebuilt edges along {} and \
615                             {} miss each other by {gap:.6}",
616                            face_label(&shell.faces[chain.neighbour]),
617                            face_label(&shell.faces[chains[next].neighbour])
618                        ),
619                    ));
620                }
621                moved_vertices.insert(corner, point);
622            }
623        }
624    }
625
626    let mut built: Vec<Vec<BuiltEdge>> = Vec::new();
627    {
628        let mut flat = 0usize;
629        for chains in &chains_per_cycle {
630            let mut row = Vec::new();
631            for chain in chains {
632                let curve = chain_curves[flat][0].clone();
633                flat += 1;
634                let points = chain_points(chain, &vertex_by_id);
635                let edge = if chain.closed {
636                    if let Some((curve, pcurve, region_forward)) =
637                        iso_ring(&surface, &curve, &points, chain_tol)
638                    {
639                        let [t0, t1] = curve.domain().map_err(|e| refuse_input("curve", e))?;
640                        let point = curve.evaluate(t0).map_err(|e| refuse_input("curve", e))?;
641                        let id = next_vertex_id;
642                        next_vertex_id += 1;
643                        new_vertices.push(VertexRecord { id, point });
644                        row.push(BuiltEdge {
645                            curve,
646                            t0,
647                            t1,
648                            start: id,
649                            end: id,
650                            region_forward,
651                            region_pcurve: Some(pcurve),
652                        });
653                        continue;
654                    }
655                    let start_point = points[0];
656                    let (curve, region_forward) = closed_ring_curve(&curve, &points, scale)?;
657                    let start_point = curve.evaluate(curve.domain().map_err(|e| {
658                        refuse_input("curve", e)
659                    })?[0])
660                    .unwrap_or(start_point);
661                    let id = next_vertex_id;
662                    next_vertex_id += 1;
663                    new_vertices.push(VertexRecord { id, point: start_point });
664                    let [t0, t1] = curve.domain().map_err(|e| refuse_input("curve", e))?;
665                    BuiltEdge { curve, t0, t1, start: id, end: id, region_forward, region_pcurve: None }
666                } else {
667                    let start = chain.uses.first().unwrap().start;
668                    let end = chain.uses.last().unwrap().end;
669                    let start_point = moved_vertices.get(&start).copied().unwrap_or(points[0]);
670                    let end_point =
671                        moved_vertices.get(&end).copied().unwrap_or(*points.last().unwrap());
672                    let middle = points[points.len() / 2];
673                    let mid_point = if points.len() > 2 {
674                        middle
675                    } else {
676                        points[0].add(points[1]).scale(0.5)
677                    };
678                    open_arc(&curve, start, end, start_point, end_point, mid_point, scale)?
679                };
680                row.push(edge);
681            }
682            built.push(row);
683        }
684    }
685
686    // ---- Assemble the new solid. --------------------------------------------
687    let mut out = solid.clone();
688    // Edges to drop: internal and replaced boundary edges.
689    let mut dropped_edges: HashSet<u64> = internal_edges.clone();
690    dropped_edges.extend(boundary_edges.iter().copied());
691    let mut new_edges: Vec<EdgeRecord> = Vec::new();
692    let mut new_edge_ids: Vec<Vec<u64>> = Vec::new();
693    for row in &built {
694        let mut ids = Vec::new();
695        for edge in row {
696            let id = next_edge_id;
697            next_edge_id += 1;
698            new_edges.push(EdgeRecord {
699                id,
700                curve: edge.curve.clone(),
701                t0: edge.t0,
702                t1: edge.t1,
703                start_vertex_id: edge.start,
704                end_vertex_id: edge.end,
705                degenerate: false,
706                name: None,
707            });
708            ids.push(id);
709        }
710        new_edge_ids.push(ids);
711    }
712
713    // Moved corners: update the points, and re-straighten straight edges that
714    // stay (their pcurves follow below).
715    for vertex in &mut out.vertices {
716        if let Some(point) = moved_vertices.get(&vertex.id) {
717            vertex.point = *point;
718        }
719    }
720    let point_of = |out: &BrepSolid, id: u64| -> Vec3 {
721        out.vertices
722            .iter()
723            .find(|v| v.id == id)
724            .map(|v| v.point)
725            .or_else(|| new_vertices.iter().find(|v| v.id == id).map(|v| v.point))
726            .unwrap_or(Vec3::new(0.0, 0.0, 0.0))
727    };
728    let mut restraightened: HashSet<u64> = HashSet::default();
729    let snapshot = out.clone();
730    for edge in &mut out.edges {
731        if dropped_edges.contains(&edge.id) {
732            continue;
733        }
734        let touches = moved_vertices.contains_key(&edge.start_vertex_id)
735            || moved_vertices.contains_key(&edge.end_vertex_id);
736        if !touches {
737            continue;
738        }
739        if edge.curve.degree == 1 && edge.curve.control_points.len() == 2 {
740            let a = point_of(&snapshot, edge.start_vertex_id);
741            let b = point_of(&snapshot, edge.end_vertex_id);
742            if let Ok(line) = make_line(a, b) {
743                edge.curve = line;
744                edge.t0 = 0.0;
745                edge.t1 = 1.0;
746                restraightened.insert(edge.id);
747            }
748        }
749    }
750
751    // A planar neighbour whose WHOLE boundary is one rebuilt ring (a cap) is
752    // re-seated onto the ring's own plane. The ring lies on the fitted
753    // surface exactly and on the old cap plane only to the fit's residual —
754    // float32 mesh vertices put it ~2e-6 off, which the cap's projected trim
755    // turns into a closure residual over the bar (the user's 3MF pipe bend).
756    for (cycle_index, chains) in chains_per_cycle.iter().enumerate() {
757        for (chain_index, chain) in chains.iter().enumerate() {
758            if !chain.closed {
759                continue;
760            }
761            let neighbour = &out.shells[shell_index].faces[chain.neighbour];
762            let chain_edges: HashSet<u64> = chain.uses.iter().map(|u| u.edge_id).collect();
763            let whole_boundary = neighbour.loops.len() == 1
764                && neighbour.loops[0].coedges.iter().all(|c| chain_edges.contains(&c.edge_id));
765            if !whole_boundary || !neighbour.surface.is_affine().unwrap_or(false) {
766                continue;
767            }
768            let ring = &built[cycle_index][chain_index].curve;
769            if let Some(plane) = reseated_plane(&neighbour.surface, ring, scale) {
770                out.shells[shell_index].faces[chain.neighbour].surface = plane;
771            }
772        }
773    }
774
775    let shell_out = &mut out.shells[shell_index];
776    let mut flat = 0usize;
777    for (cycle_index, chains) in chains_per_cycle.iter().enumerate() {
778        for (chain_index, chain) in chains.iter().enumerate() {
779            let built_edge = &built[cycle_index][chain_index];
780            let edge_id = new_edge_ids[cycle_index][chain_index];
781            flat += 1;
782            let neighbour = &mut shell_out.faces[chain.neighbour];
783            let chain_edges: HashSet<u64> = chain.uses.iter().map(|u| u.edge_id).collect();
784            let coedge_forward = !built_edge.region_forward;
785            let pcurve = build_pcurve_on_surface_range(
786                &neighbour.surface,
787                &built_edge.curve,
788                built_edge.t0,
789                built_edge.t1,
790                coedge_forward,
791                fit_tol,
792            )
793            .map_err(|e| {
794                refuse_unsupported(
795                    KernelStage::Refine,
796                    "neighbour_pcurve",
797                    format!(
798                        "{NO_EXACT_BOUNDARY}: no trim curve on {} for the rebuilt edge ({e})",
799                        face_label(neighbour)
800                    ),
801                )
802            })?;
803            let mut replaced = false;
804            for lp in &mut neighbour.loops {
805                let positions: Vec<usize> = lp
806                    .coedges
807                    .iter()
808                    .enumerate()
809                    .filter(|(_, c)| chain_edges.contains(&c.edge_id))
810                    .map(|(i, _)| i)
811                    .collect();
812                if positions.is_empty() {
813                    continue;
814                }
815                if positions.len() != chain_edges.len() {
816                    return Err(refuse_unsupported(
817                        KernelStage::Sew,
818                        "neighbour_loop",
819                        format!(
820                            "the edges shared with {} are split across its loops",
821                            face_label(neighbour)
822                        ),
823                    ));
824                }
825                // Rotate the loop so the run is contiguous at the front.
826                let n = lp.coedges.len();
827                let run_start = (0..n)
828                    .find(|&i| {
829                        chain_edges.contains(&lp.coedges[i].edge_id)
830                            && !chain_edges.contains(&lp.coedges[(i + n - 1) % n].edge_id)
831                    })
832                    .unwrap_or(0);
833                lp.coedges.rotate_left(run_start);
834                if !lp.coedges[..positions.len()]
835                    .iter()
836                    .all(|c| chain_edges.contains(&c.edge_id))
837                {
838                    return Err(refuse_unsupported(
839                        KernelStage::Sew,
840                        "neighbour_loop",
841                        format!(
842                            "the edges shared with {} are not one run of its loop",
843                            face_label(neighbour)
844                        ),
845                    ));
846                }
847                let first_id = lp.coedges[0].id;
848                lp.coedges.splice(
849                    0..positions.len(),
850                    [CoedgeRecord { id: first_id, edge_id, forward: coedge_forward, pcurve }],
851                );
852                replaced = true;
853                break;
854            }
855            if !replaced {
856                return Err(refuse_unsupported(
857                    KernelStage::Sew,
858                    "neighbour_loop",
859                    format!("{} does not use the edges it shares", face_label(neighbour)),
860                ));
861            }
862        }
863    }
864    let _ = flat;
865
866    // Pcurves of re-straightened edges, on every face that uses them.
867    if !restraightened.is_empty() {
868        let edges: HashMap<u64, EdgeRecord> = out
869            .edges
870            .iter()
871            .filter(|e| restraightened.contains(&e.id))
872            .map(|e| (e.id, e.clone()))
873            .collect();
874        for face in &mut out.shells[shell_index].faces {
875            let surface = face.surface.clone();
876            for lp in &mut face.loops {
877                for coedge in &mut lp.coedges {
878                    if let Some(edge) = edges.get(&coedge.edge_id) {
879                        coedge.pcurve = build_pcurve_on_surface_range(
880                            &surface,
881                            &edge.curve,
882                            edge.t0,
883                            edge.t1,
884                            coedge.forward,
885                            fit_tol,
886                        )
887                        .map_err(|e| {
888                            refuse_unsupported(
889                                KernelStage::Refine,
890                                "neighbour_pcurve",
891                                format!("a straight edge at a moved corner lost its trim ({e})"),
892                            )
893                        })?;
894                    }
895                }
896            }
897        }
898    }
899
900    // The new face.
901    let keeper = &shell.faces[selected_indices[0]];
902    let mut next_coedge_id = out
903        .shells
904        .iter()
905        .flat_map(|s| s.faces.iter())
906        .flat_map(|f| f.loops.iter())
907        .flat_map(|l| l.coedges.iter().map(|c| c.id))
908        .max()
909        .unwrap_or(0)
910        + 1;
911    let mut next_loop_id = out
912        .shells
913        .iter()
914        .flat_map(|s| s.faces.iter())
915        .flat_map(|f| f.loops.iter().map(|l| l.id))
916        .max()
917        .unwrap_or(0)
918        + 1;
919    let mut loops = Vec::new();
920    for (cycle_index, row) in built.iter().enumerate() {
921        let mut coedges = Vec::new();
922        for (chain_index, edge) in row.iter().enumerate() {
923            let pcurve = match &edge.region_pcurve {
924                Some(pcurve) => Ok(pcurve.clone()),
925                None => build_pcurve_on_surface_range(
926                &surface,
927                &edge.curve,
928                edge.t0,
929                edge.t1,
930                edge.region_forward,
931                fit_tol,
932            ),
933            }
934            .map_err(|e| {
935                refuse_unsupported(
936                    KernelStage::Refine,
937                    "region_pcurve",
938                    format!("{NO_EXACT_BOUNDARY}: no trim curve on the fitted surface ({e})"),
939                )
940            })?;
941            coedges.push(CoedgeRecord {
942                id: next_coedge_id,
943                edge_id: new_edge_ids[cycle_index][chain_index],
944                forward: edge.region_forward,
945                pcurve,
946            });
947            next_coedge_id += 1;
948        }
949        loops.push(LoopRecord { id: next_loop_id, coedges });
950        next_loop_id += 1;
951    }
952    let new_face = FaceRecord {
953        id: keeper.id,
954        surface: surface.clone(),
955        same_sense,
956        loops,
957        name: keeper.name.clone(),
958    };
959    let new_face_id = new_face.id;
960    let shell_out = &mut out.shells[shell_index];
961    let keep_at = selected_indices[0];
962    let mut faces = Vec::with_capacity(shell_out.faces.len());
963    for (index, face) in shell_out.faces.drain(..).enumerate() {
964        if index == keep_at {
965            faces.push(new_face.clone());
966        } else if !selected.contains(&index) {
967            faces.push(face);
968        }
969    }
970    shell_out.faces = faces;
971    out.edges.retain(|edge| !dropped_edges.contains(&edge.id));
972    out.edges.extend(new_edges);
973    out.vertices.extend(new_vertices);
974    // Drop vertices nothing uses any more.
975    let used: HashSet<u64> = out
976        .edges
977        .iter()
978        .flat_map(|e| [e.start_vertex_id, e.end_vertex_id])
979        .collect();
980    out.vertices.retain(|v| used.contains(&v.id));
981
982    let issues = out.validate();
983    if !issues.is_empty() {
984        let first: Vec<String> = issues.iter().take(3).map(|i| format!("{i:?}")).collect();
985        return Err(KernelRefusal::new(
986            RefusalClass::InvalidResultTopology {
987                issues: issues.len() as u32,
988            },
989            KernelStage::Validate,
990            format!(
991                "Refit Faces: the {RESULT_INVALID} ({} issue(s): {})",
992                issues.len(),
993                first.join("; ")
994            ),
995        ));
996    }
997
998    let report = RefitReport {
999        carrier,
1000        rms_residual,
1001        max_residual,
1002        tolerance,
1003        samples: samples.points.len(),
1004        replaced_faces: selected_indices.len(),
1005        boundary_edges_before: boundary_edges.len(),
1006        boundary_edges_after: built.iter().map(Vec::len).sum(),
1007        boundary_loops: built.len(),
1008        new_face_id,
1009    };
1010    Ok((out, report))
1011}
1012
1013// ---------------------------------------------------------------------------
1014// Sampling and fitting
1015// ---------------------------------------------------------------------------
1016
1017struct Samples {
1018    /// Points the carrier must pass through (vertices; curved faces'
1019    /// tessellation; curved edges' samples).
1020    points: Vec<Vec3>,
1021    /// A triangle mesh over the selection for the recognition pass: the
1022    /// planar faces fanned over their vertices, curved faces tessellated.
1023    mesh_vertices: Vec<Vec3>,
1024    mesh_triangles: Vec<[u32; 3]>,
1025    /// Vertices supported by intersecting selected facets, plus curved-face
1026    /// samples. Boundary-only cuts through a flat chord are not samples of
1027    /// the curved carrier. All points still participate in the residual gate.
1028    fit_vertices: Vec<usize>,
1029    /// Outward normals at a few points of the selection (area-weighted
1030    /// triangle normals), for the orientation decision.
1031    oriented: Vec<(Vec3, Vec3)>,
1032}
1033
1034fn fit_samples(
1035    shell: &crate::topology::ShellRecord,
1036    selected: &[usize],
1037    edges: &HashMap<u64, &EdgeRecord>,
1038    vertices: &HashMap<u64, Vec3>,
1039) -> Result<Samples, KernelRefusal> {
1040    let mut welded: HashMap<(i64, i64, i64), u32> = HashMap::default();
1041    let mut mesh_vertices: Vec<Vec3> = Vec::new();
1042    let mut mesh_triangles: Vec<[u32; 3]> = Vec::new();
1043    let mut points: Vec<Vec3> = Vec::new();
1044    let mut oriented = Vec::new();
1045    let extent = vertices
1046        .values()
1047        .fold(0.0_f64, |m, p| m.max(p.x.abs()).max(p.y.abs()).max(p.z.abs()))
1048        .max(1.0);
1049    let quantum = extent * 1e-9;
1050    let mut weld = |p: Vec3, mesh_vertices: &mut Vec<Vec3>| -> u32 {
1051        let key = (
1052            (p.x / quantum).round() as i64,
1053            (p.y / quantum).round() as i64,
1054            (p.z / quantum).round() as i64,
1055        );
1056        *welded.entry(key).or_insert_with(|| {
1057            mesh_vertices.push(p);
1058            (mesh_vertices.len() - 1) as u32
1059        })
1060    };
1061    // A trim can introduce vertices inside a planar facet's chord. They
1062    // lie below the original curved surface, unlike vertices on the ridges
1063    // between facets. Including them biases both radius and axis; at a
1064    // tangent neighbour even a minute axis tilt changes a ruling to an ellipse.
1065    let mut support: HashMap<u64, Vec<Vec3>> = HashMap::default();
1066    for &f in selected {
1067        let face = &shell.faces[f];
1068        if !face.surface.is_affine().unwrap_or(false) {
1069            continue;
1070        }
1071        let normal = face.surface.domain_u().and_then(|u| {
1072            face.surface.domain_v().and_then(|v| face.surface.normal(u[0], v[0]))
1073        });
1074        let Ok(normal) = normal else { continue };
1075        for c in face.loops.iter().flat_map(|l| &l.coedges) {
1076            let Some(edge) = edges.get(&c.edge_id) else { continue };
1077            for id in [edge.start_vertex_id, edge.end_vertex_id] {
1078                support.entry(id).or_default().push(normal);
1079            }
1080        }
1081    }
1082    let ridge_vertices: HashSet<u64> = support
1083        .iter()
1084        .filter_map(|(&id, normals)| {
1085            normals.iter().any(|n| normals[0].cross(*n).length() > 1e-9).then_some(id)
1086        })
1087        .collect();
1088    let mut fit_vertices: HashSet<usize> = HashSet::default();
1089    for &f in selected {
1090        let face = &shell.faces[f];
1091        let affine = face.surface.is_affine().unwrap_or(false);
1092        // Loop vertex points, in loop order.
1093        let mut polygon: Vec<Vec3> = Vec::new();
1094        let mut polygon_ids = Vec::new();
1095        for lp in &face.loops {
1096            for coedge in &lp.coedges {
1097                let Some(edge) = edges.get(&coedge.edge_id) else { continue };
1098                let start = if coedge.forward { edge.start_vertex_id } else { edge.end_vertex_id };
1099                if let Some(p) = vertices.get(&start) {
1100                    polygon.push(*p);
1101                    polygon_ids.push(start);
1102                    points.push(*p);
1103                }
1104                let is_line = edge.curve.degree == 1 && edge.curve.control_points.len() == 2;
1105                if !is_line {
1106                    for k in 1..8 {
1107                        let t = edge.t0 + (edge.t1 - edge.t0) * k as f64 / 8.0;
1108                        if let Ok(p) = edge.curve.evaluate(t) {
1109                            points.push(p);
1110                        }
1111                    }
1112                }
1113            }
1114        }
1115        if affine && face.loops.len() == 1 && polygon.len() >= 3 {
1116            // Keep the facet mesh intact for support areas and normals;
1117            // select the fitting vertices independently of its triangulation.
1118            let ids: Vec<u32> = polygon.iter().map(|p| weld(*p, &mut mesh_vertices)).collect();
1119            for (&vertex_id, &mesh_id) in polygon_ids.iter().zip(&ids) {
1120                if ridge_vertices.contains(&vertex_id) {
1121                    fit_vertices.insert(mesh_id as usize);
1122                }
1123            }
1124            let mut normal = Vec3::new(0.0, 0.0, 0.0);
1125            for i in 1..polygon.len() - 1 {
1126                let a = polygon[0];
1127                let b = polygon[i];
1128                let c = polygon[i + 1];
1129                normal = normal.add(b.sub(a).cross(c.sub(a)));
1130                if ids[0] != ids[i] && ids[i] != ids[i + 1] && ids[0] != ids[i + 1] {
1131                    mesh_triangles.push([ids[0], ids[i], ids[i + 1]]);
1132                }
1133            }
1134            let centroid = polygon
1135                .iter()
1136                .fold(Vec3::new(0.0, 0.0, 0.0), |s, p| s.add(*p))
1137                .scale(1.0 / polygon.len() as f64);
1138            // Loop order is counter-clockwise about the face's OUTWARD normal.
1139            if normal.length() > 0.0 {
1140                oriented.push((centroid, normal));
1141            }
1142        } else {
1143            let mesh = crate::tessellate_face(face, crate::TessellationOptions::default(), 0)
1144                .map_err(|e| refuse_input("face", format!("could not sample {}: {e}", face_label(face))))?;
1145            let base: Vec<u32> = mesh
1146                .positions
1147                .chunks_exact(3)
1148                .map(|p| weld(Vec3::new(p[0], p[1], p[2]), &mut mesh_vertices))
1149                .collect();
1150            if !affine {
1151                fit_vertices.extend(base.iter().map(|&i| i as usize));
1152                points.extend(mesh.positions.chunks_exact(3).map(|p| Vec3::new(p[0], p[1], p[2])));
1153            }
1154            for tri in mesh.indices.chunks_exact(3) {
1155                let [a, b, c] = [base[tri[0] as usize], base[tri[1] as usize], base[tri[2] as usize]];
1156                if a != b && b != c && a != c {
1157                    mesh_triangles.push([a, b, c]);
1158                    let (pa, pb, pc) =
1159                        (mesh_vertices[a as usize], mesh_vertices[b as usize], mesh_vertices[c as usize]);
1160                    let normal = pb.sub(pa).cross(pc.sub(pa));
1161                    if normal.length() > 0.0 {
1162                        oriented.push((pa.add(pb).add(pc).scale(1.0 / 3.0), normal));
1163                    }
1164                }
1165            }
1166        }
1167    }
1168    if points.len() < 3 || mesh_triangles.is_empty() {
1169        return Err(refuse_input(
1170            "face_ids",
1171            format!("{NO_FIT}: the selection has too few points to fit ({})", points.len()),
1172        ));
1173    }
1174    let mut fit_vertices: Vec<usize> = fit_vertices.into_iter().collect();
1175    fit_vertices.sort_unstable();
1176    Ok(Samples { points, mesh_vertices, mesh_triangles, fit_vertices, oriented })
1177}
1178
1179struct Fitted {
1180    carrier: RefitCarrier,
1181}
1182
1183fn to_ransac(p: Vec3) -> brep_ransac::Vec3 {
1184    brep_ransac::Vec3::new(p.x, p.y, p.z)
1185}
1186
1187fn from_ransac(p: brep_ransac::Vec3) -> Vec3 {
1188    Vec3::new(p.x, p.y, p.z)
1189}
1190
1191fn fit_carrier(
1192    samples: &Samples,
1193    kind: RefitSurfaceKind,
1194    tolerance: f64,
1195    scale: f64,
1196) -> Result<Fitted, KernelRefusal> {
1197    use brep_ransac::{AnalyticSurface, RecognitionOptions, SamplingMode, SurfaceHint, SurfaceType};
1198    let mesh = brep_ransac::Mesh::new(
1199        samples.mesh_vertices.iter().copied().map(to_ransac).collect(),
1200        samples.mesh_triangles.clone(),
1201    );
1202    let triangles: Vec<usize> = (0..samples.mesh_triangles.len()).collect();
1203    let hint = match kind {
1204        RefitSurfaceKind::Auto => SurfaceHint::Unknown,
1205        RefitSurfaceKind::Plane => SurfaceHint::KnownType { surface_type: SurfaceType::Plane },
1206        RefitSurfaceKind::Cylinder => {
1207            SurfaceHint::KnownType { surface_type: SurfaceType::Cylinder }
1208        }
1209        RefitSurfaceKind::Cone => SurfaceHint::KnownType { surface_type: SurfaceType::Cone },
1210        RefitSurfaceKind::Sphere => SurfaceHint::KnownType { surface_type: SurfaceType::Sphere },
1211        RefitSurfaceKind::Torus => SurfaceHint::KnownType { surface_type: SurfaceType::Torus },
1212    };
1213    let options = |distance: f64, normal: f64| RecognitionOptions {
1214        distance_tolerance: distance,
1215        relative_tolerance: 0.0,
1216        // The facets' normals are the chords' and not the surface's: the
1217        // user asserts the family, the positional residual is the judge.
1218        normal_tolerance: normal,
1219        minimum_support: 1,
1220        minimum_support_area: 0.0,
1221        discover_regions: false,
1222        sampling: SamplingMode::Vertices,
1223        ..RecognitionOptions::default()
1224    };
1225    // First at the caller's tolerance; if nothing fits there, again with a
1226    // loose one so the refusal can quote the residual it missed by.
1227    // The retry gates on nothing (distance the model's size, normals any
1228    // angle): its only job is the carrier whose residual the refusal quotes.
1229    let reconstruct = |options: &RecognitionOptions| {
1230        // A small selection (or one plane) may have too few ridges to fit.
1231        // In that case retain the original all-vertex fitting path.
1232        if !samples.fit_vertices.is_empty() && samples.fit_vertices.len() < mesh.vertices.len() {
1233            if let Ok(fit) = brep_ransac::reconstruct_surface_from_vertices(
1234                &mesh,
1235                &samples.fit_vertices,
1236                &hint,
1237                options,
1238            ) {
1239                // A few ridges can alias a simpler carrier (two cylinder
1240                // rulings are coplanar). Fall back if it misses the full region.
1241                if samples.points.iter().all(|p| {
1242                    fit.surface.signed_distance(to_ransac(*p)).abs() <= options.distance_tolerance
1243                }) {
1244                    return Ok(fit);
1245                }
1246            }
1247        }
1248        brep_ransac::reconstruct_surface(&mesh, &triangles, &hint, options)
1249    };
1250    let fit = reconstruct(&options(tolerance, 60.0_f64.to_radians()))
1251        .or_else(|_| reconstruct(&options(scale * 10.0, PI)))
1252        .map_err(|e| {
1253            refuse_unsupported(
1254                KernelStage::Refine,
1255                "refit_no_fit",
1256                format!("{NO_FIT}: the recognition pass found no {} ({e})", kind.name()),
1257            )
1258        })?;
1259    let carrier = match fit.surface {
1260        AnalyticSurface::Plane(p) => RefitCarrier::Plane {
1261            origin: from_ransac(p.origin),
1262            normal: unit(from_ransac(p.normal))?,
1263        },
1264        AnalyticSurface::Cylinder(c) => RefitCarrier::Cylinder {
1265            axis_origin: from_ransac(c.axis_origin),
1266            axis: unit(from_ransac(c.axis))?,
1267            radius: c.radius,
1268        },
1269        AnalyticSurface::Cone(c) => RefitCarrier::Cone {
1270            apex: from_ransac(c.apex),
1271            axis: unit(from_ransac(c.axis))?,
1272            half_angle: c.half_angle,
1273        },
1274        AnalyticSurface::Sphere(s) => {
1275            RefitCarrier::Sphere { center: from_ransac(s.center), radius: s.radius }
1276        }
1277        AnalyticSurface::Torus(t) => RefitCarrier::Torus {
1278            center: from_ransac(t.center),
1279            axis: unit(from_ransac(t.axis))?,
1280            major_radius: t.major_radius,
1281            minor_radius: t.minor_radius,
1282        },
1283    };
1284    Ok(Fitted { carrier })
1285}
1286
1287fn unit(v: Vec3) -> Result<Vec3, KernelRefusal> {
1288    v.normalized()
1289        .map_err(|e| refuse_unsupported(KernelStage::Refine, "refit_no_fit", format!("{NO_FIT}: {e}")))
1290}
1291
1292fn residuals(carrier: &RefitCarrier, points: &[Vec3]) -> (f64, f64) {
1293    let mut sum = 0.0;
1294    let mut max = 0.0_f64;
1295    for p in points {
1296        let d = carrier.distance(*p);
1297        sum += d * d;
1298        max = max.max(d);
1299    }
1300    ((sum / points.len().max(1) as f64).sqrt(), max)
1301}
1302
1303// ---------------------------------------------------------------------------
1304// The carrier surface, framed so the region avoids its seam and poles
1305// ---------------------------------------------------------------------------
1306
1307/// Unit x/y frame perpendicular to `axis`, x pointing at the middle of the
1308/// largest angular gap the points leave around the axis (so the surface's
1309/// parameter seam falls where the region is not; a full band has no gap and
1310/// the seam is arbitrary — its boundary is two closed rings).
1311fn seam_frame(axis: Vec3, origin: Vec3, points: &[Vec3]) -> Result<(Vec3, Vec3), KernelRefusal> {
1312    let x0 = axis.perpendicular().map_err(|e| refuse_input("axis", e))?;
1313    let y0 = axis.cross(x0);
1314    let mut angles: Vec<f64> = points
1315        .iter()
1316        .filter_map(|p| {
1317            let rel = p.sub(origin);
1318            let (x, y) = (rel.dot(x0), rel.dot(y0));
1319            (x.hypot(y) > 1e-12).then(|| y.atan2(x))
1320        })
1321        .collect();
1322    if angles.is_empty() {
1323        return Ok((x0, y0));
1324    }
1325    angles.sort_by(f64::total_cmp);
1326    let mut best_gap = angles[0] + TAU - angles[angles.len() - 1];
1327    let mut best_mid = angles[angles.len() - 1] + best_gap * 0.5;
1328    for pair in angles.windows(2) {
1329        let gap = pair[1] - pair[0];
1330        if gap > best_gap {
1331            best_gap = gap;
1332            best_mid = pair[0] + gap * 0.5;
1333        }
1334    }
1335    let x = x0.scale(best_mid.cos()).add(y0.scale(best_mid.sin()));
1336    Ok((x, axis.cross(x)))
1337}
1338
1339fn carrier_surface(
1340    carrier: &RefitCarrier,
1341    points: &[Vec3],
1342    scale: f64,
1343) -> Result<NurbsSurface, KernelRefusal> {
1344    let fail = |e: String| {
1345        refuse_unsupported(KernelStage::Refine, "refit_surface", format!("{NO_FIT}: {e}"))
1346    };
1347    match *carrier {
1348        RefitCarrier::Plane { origin, normal } => {
1349            let u = normal.perpendicular().map_err(fail)?;
1350            let v = normal.cross(u);
1351            let (mut u0, mut u1, mut v0, mut v1) = (f64::MAX, f64::MIN, f64::MAX, f64::MIN);
1352            for p in points {
1353                let rel = p.sub(origin);
1354                u0 = u0.min(rel.dot(u));
1355                u1 = u1.max(rel.dot(u));
1356                v0 = v0.min(rel.dot(v));
1357                v1 = v1.max(rel.dot(v));
1358            }
1359            let margin = ((u1 - u0).max(v1 - v0) * 0.1).max(scale * 1e-3);
1360            let corner = origin.add(u.scale(u0 - margin)).add(v.scale(v0 - margin));
1361            make_plane(corner, u, v, u1 - u0 + 2.0 * margin, v1 - v0 + 2.0 * margin).map_err(fail)
1362        }
1363        RefitCarrier::Cylinder { axis_origin, axis, radius } => {
1364            let (h0, h1) = axial_range(axis_origin, axis, points);
1365            let margin = ((h1 - h0) * 0.1).max(scale * 1e-3);
1366            let (x, _) = seam_frame(axis, axis_origin, points)?;
1367            let base = axis_origin.add(axis.scale(h0 - margin));
1368            let start = base.add(x.scale(radius));
1369            let generatrix =
1370                make_line(start, start.add(axis.scale(h1 - h0 + 2.0 * margin))).map_err(fail)?;
1371            make_revolution(base, axis, &generatrix, TAU).map_err(fail)
1372        }
1373        RefitCarrier::Cone { apex, axis, half_angle } => {
1374            let (h0, h1) = axial_range(apex, axis, points);
1375            if h0 <= 0.0 {
1376                return Err(refuse_unsupported(
1377                    KernelStage::Refine,
1378                    "cone_apex",
1379                    format!(
1380                        "the fitted cone's apex lies inside the region; a pointed cone face is \
1381                         {NOT_DISK_OR_ANNULUS} this edit builds (v1)"
1382                    ),
1383                ));
1384            }
1385            let margin = ((h1 - h0) * 0.1).max(scale * 1e-3).min(h0 * 0.5);
1386            let (x, _) = seam_frame(axis, apex, points)?;
1387            let tan = half_angle.tan();
1388            let (a, b) = (h0 - margin, h1 + margin);
1389            let generatrix = make_line(
1390                apex.add(axis.scale(a)).add(x.scale(a * tan)),
1391                apex.add(axis.scale(b)).add(x.scale(b * tan)),
1392            )
1393            .map_err(fail)?;
1394            make_revolution(apex, axis, &generatrix, TAU).map_err(fail)
1395        }
1396        RefitCarrier::Sphere { center, radius } => {
1397            // Park the poles 90 degrees from the region's mean direction and
1398            // the seam opposite it.
1399            let mean = points
1400                .iter()
1401                .fold(Vec3::new(0.0, 0.0, 0.0), |s, p| s.add(p.sub(center).normalized().unwrap_or(*p)));
1402            let mean = mean.normalized().unwrap_or(Vec3::new(1.0, 0.0, 0.0));
1403            let pole = mean.perpendicular().map_err(fail)?;
1404            let limit = (85.0_f64).to_radians().sin();
1405            // Choose, among directions perpendicular to the mean, the pole the
1406            // region stays farthest from.
1407            let other = mean.cross(pole);
1408            let mut best = (f64::MAX, pole);
1409            for k in 0..36 {
1410                let angle = PI * k as f64 / 36.0;
1411                let candidate = pole.scale(angle.cos()).add(other.scale(angle.sin()));
1412                let worst = points
1413                    .iter()
1414                    .map(|p| (p.sub(center).dot(candidate) / radius).abs())
1415                    .fold(0.0_f64, f64::max);
1416                if worst < best.0 {
1417                    best = (worst, candidate);
1418                }
1419            }
1420            if best.0 > limit {
1421                return Err(refuse_unsupported(
1422                    KernelStage::Refine,
1423                    "sphere_extent",
1424                    format!(
1425                        "the spherical region reaches within 5 degrees of every pole this edit \
1426                         can place; a region that large is {NOT_DISK_OR_ANNULUS} this edit \
1427                         builds (v1)"
1428                    ),
1429                ));
1430            }
1431            let pole = best.1;
1432            let seam = mean.scale(-1.0);
1433            let x_axis = seam.sub(pole.scale(seam.dot(pole))).normalized().map_err(fail)?;
1434            let meridian =
1435                make_arc(center, x_axis, pole, radius, -PI / 2.0, PI / 2.0).map_err(fail)?;
1436            make_revolution(center, pole, &meridian, TAU).map_err(fail)
1437        }
1438        RefitCarrier::Torus { center, axis, major_radius, minor_radius } => {
1439            if !(minor_radius > 0.0 && major_radius > minor_radius) {
1440                return Err(refuse_unsupported(
1441                    KernelStage::Refine,
1442                    "torus_radii",
1443                    format!(
1444                        "the fitted torus (radii {major_radius:.6}/{minor_radius:.6}) \
1445                         self-intersects; {NO_FIT} as one face (v1)"
1446                    ),
1447                ));
1448            }
1449            let (x, _) = seam_frame(axis, center, points)?;
1450            // Tube angle gap, in the (radial, axial) plane.
1451            let mut angles: Vec<f64> = points
1452                .iter()
1453                .map(|p| {
1454                    let rel = p.sub(center);
1455                    let along = rel.dot(axis);
1456                    let radial = rel.sub(axis.scale(along)).length() - major_radius;
1457                    along.atan2(radial)
1458                })
1459                .collect();
1460            angles.sort_by(f64::total_cmp);
1461            let mut start = 0.0;
1462            if !angles.is_empty() {
1463                let mut best_gap = angles[0] + TAU - angles[angles.len() - 1];
1464                let mut best_mid = angles[angles.len() - 1] + best_gap * 0.5;
1465                for pair in angles.windows(2) {
1466                    if pair[1] - pair[0] > best_gap {
1467                        best_gap = pair[1] - pair[0];
1468                        best_mid = pair[0] + best_gap * 0.5;
1469                    }
1470                }
1471                start = best_mid;
1472            }
1473            let tube_center = center.add(x.scale(major_radius));
1474            let tube = make_arc(tube_center, x, axis, minor_radius, start, start + TAU)
1475                .map_err(fail)?;
1476            make_revolution(center, axis, &tube, TAU).map_err(fail)
1477        }
1478    }
1479}
1480
1481fn axial_range(origin: Vec3, axis: Vec3, points: &[Vec3]) -> (f64, f64) {
1482    points.iter().fold((f64::MAX, f64::MIN), |(lo, hi), p| {
1483        let h = p.sub(origin).dot(axis);
1484        (lo.min(h), hi.max(h))
1485    })
1486}
1487
1488fn region_same_sense(surface: &NurbsSurface, samples: &Samples) -> Result<bool, KernelRefusal> {
1489    let mut vote = 0.0;
1490    for (point, outward) in samples.oriented.iter().step_by((samples.oriented.len() / 64).max(1)) {
1491        let Ok(projection) = crate::project_point_to_surface(surface, *point) else { continue };
1492        let Ok(normal) = surface.normal(projection.u, projection.v) else { continue };
1493        vote += normal.dot(*outward).signum() * outward.length();
1494    }
1495    if vote == 0.0 {
1496        return Err(refuse_unsupported(
1497            KernelStage::Classify,
1498            "orientation",
1499            format!("{NO_FIT}: the selection's outward side could not be read"),
1500        ));
1501    }
1502    Ok(vote > 0.0)
1503}
1504
1505// ---------------------------------------------------------------------------
1506// Boundary chains and their exact curves
1507// ---------------------------------------------------------------------------
1508
1509fn split_chains(cycle: &[BoundaryUse]) -> Vec<Chain> {
1510    let n = cycle.len();
1511    let Some(change) = (0..n).find(|&i| cycle[i].neighbour != cycle[(i + n - 1) % n].neighbour)
1512    else {
1513        return vec![Chain { uses: cycle.to_vec(), closed: true, neighbour: cycle[0].neighbour }];
1514    };
1515    let mut chains: Vec<Chain> = Vec::new();
1516    for k in 0..n {
1517        let use_ = cycle[(change + k) % n];
1518        match chains.last_mut() {
1519            Some(chain) if chain.neighbour == use_.neighbour => chain.uses.push(use_),
1520            _ => chains.push(Chain { uses: vec![use_], closed: false, neighbour: use_.neighbour }),
1521        }
1522    }
1523    chains
1524}
1525
1526fn chain_points(chain: &Chain, vertices: &HashMap<u64, Vec3>) -> Vec<Vec3> {
1527    let mut points: Vec<Vec3> = chain.uses.iter().map(|u| vertices[&u.start]).collect();
1528    if !chain.closed {
1529        points.push(vertices[&chain.uses.last().unwrap().end]);
1530    }
1531    points
1532}
1533
1534/// The point where two curves meet near `seed`, by alternating projection,
1535/// and the gap left between them there.
1536fn curve_meeting(
1537    first: &NurbsCurve,
1538    second: &NurbsCurve,
1539    seed: Vec3,
1540) -> Result<(Vec3, f64), KernelRefusal> {
1541    let project = |curve: &NurbsCurve, p: Vec3| {
1542        project_point_to_curve(curve, p).map_err(|e| {
1543            refuse_unsupported(KernelStage::Intersect, "corner", format!("{NO_EXACT_BOUNDARY}: {e}"))
1544        })
1545    };
1546    let mut p = seed;
1547    let mut gap = f64::MAX;
1548    for _ in 0..200 {
1549        let a = project(first, p)?.point;
1550        let b = project(second, a)?.point;
1551        gap = a.sub(b).length();
1552        let next = a.add(b).scale(0.5);
1553        let step = next.sub(p).length();
1554        p = next;
1555        if step < 1e-14 * (1.0 + p.length()) {
1556            break;
1557        }
1558    }
1559    let a = project(first, p)?.point;
1560    let b = project(second, p)?.point;
1561    gap = gap.min(a.sub(b).length());
1562    Ok((a.add(b).scale(0.5), gap))
1563}
1564
1565/// A circle through `curve`'s points, if the curve is one: (center, normal, radius).
1566fn as_circle(curve: &NurbsCurve, scale: f64) -> Option<(Vec3, Vec3, f64)> {
1567    let [t0, t1] = curve.domain().ok()?;
1568    let samples: Vec<Vec3> = (0..24)
1569        .filter_map(|k| curve.evaluate(t0 + (t1 - t0) * k as f64 / 24.0).ok())
1570        .collect();
1571    if samples.len() < 24 {
1572        return None;
1573    }
1574    let (a, b, c) = (samples[0], samples[8], samples[16]);
1575    let ab = b.sub(a);
1576    let ac = c.sub(a);
1577    let normal = ab.cross(ac);
1578    let denom = 2.0 * normal.length_squared();
1579    if denom <= 1e-30 {
1580        return None;
1581    }
1582    let center = a.add(
1583        normal
1584            .cross(ab)
1585            .scale(ac.length_squared())
1586            .add(ac.cross(normal).scale(ab.length_squared()))
1587            .scale(1.0 / denom),
1588    );
1589    let radius = a.sub(center).length();
1590    let unit_normal = normal.normalized().ok()?;
1591    let tol = scale * 1e-9 + radius * 1e-9;
1592    for p in &samples {
1593        if (p.sub(center).length() - radius).abs() > tol || p.sub(center).dot(unit_normal).abs() > tol {
1594            return None;
1595        }
1596    }
1597    Some((center, unit_normal, radius))
1598}
1599
1600fn angle_in(center: Vec3, x: Vec3, y: Vec3, p: Vec3) -> f64 {
1601    let rel = p.sub(center);
1602    let angle = rel.dot(y).atan2(rel.dot(x));
1603    if angle < 0.0 {
1604        angle + TAU
1605    } else {
1606        angle
1607    }
1608}
1609
1610/// A closed boundary ring: the exact closed curve, oriented and started so
1611/// the region traverses it forward from its first chain point when possible.
1612fn closed_ring_curve(
1613    curve: &NurbsCurve,
1614    points: &[Vec3],
1615    scale: f64,
1616) -> Result<(NurbsCurve, bool), KernelRefusal> {
1617    let fail = |e: String| {
1618        refuse_unsupported(KernelStage::Intersect, "ring", format!("{NO_EXACT_BOUNDARY}: {e}"))
1619    };
1620    if let Some((center, normal, radius)) = as_circle(curve, scale) {
1621        let start = points[0];
1622        let x = start.sub(center);
1623        let x = x.sub(normal.scale(x.dot(normal))).normalized().map_err(fail)?;
1624        let mut n = normal;
1625        let y = n.cross(x);
1626        // The region runs from points[0] toward points[1]: keep that sense.
1627        if angle_in(center, x, y, points[1]) > PI {
1628            n = n.scale(-1.0);
1629        }
1630        let y = n.cross(x);
1631        let arc = make_arc(center, x, y, radius, 0.0, TAU).map_err(fail)?;
1632        return Ok((arc, true));
1633    }
1634    // Not a circle: keep the curve, read the direction from the chain.
1635    let p0 = project_point_to_curve(curve, points[0]).map_err(fail)?;
1636    let p1 = project_point_to_curve(curve, points[1]).map_err(fail)?;
1637    let [t0, t1] = curve.domain().map_err(fail)?;
1638    let mut delta = p1.u - p0.u;
1639    let period = t1 - t0;
1640    if delta > period * 0.5 {
1641        delta -= period;
1642    } else if delta < -period * 0.5 {
1643        delta += period;
1644    }
1645    Ok((curve.clone(), delta > 0.0))
1646}
1647
1648/// An open boundary run from `start_point` to `end_point` along `curve`,
1649/// through `mid_point`.
1650fn open_arc(
1651    curve: &NurbsCurve,
1652    start: u64,
1653    end: u64,
1654    start_point: Vec3,
1655    end_point: Vec3,
1656    mid_point: Vec3,
1657    scale: f64,
1658) -> Result<BuiltEdge, KernelRefusal> {
1659    let fail = |e: String| {
1660        refuse_unsupported(KernelStage::Intersect, "arc", format!("{NO_EXACT_BOUNDARY}: {e}"))
1661    };
1662    if let Some((center, normal, radius)) = as_circle(curve, scale) {
1663        let x = start_point.sub(center);
1664        let x = x.sub(normal.scale(x.dot(normal))).normalized().map_err(fail)?;
1665        let mut n = normal;
1666        let mut y = n.cross(x);
1667        let mut sweep = angle_in(center, x, y, end_point);
1668        if angle_in(center, x, y, mid_point) > sweep {
1669            n = n.scale(-1.0);
1670            y = n.cross(x);
1671            sweep = angle_in(center, x, y, end_point);
1672        }
1673        let arc = make_arc(center, x, y, radius, 0.0, sweep).map_err(fail)?;
1674        let [t0, t1] = arc.domain().map_err(fail)?;
1675        return Ok(BuiltEdge { curve: arc, t0, t1, start, end, region_forward: true, region_pcurve: None });
1676    }
1677    let a = project_point_to_curve(curve, start_point).map_err(fail)?.u;
1678    let b = project_point_to_curve(curve, end_point).map_err(fail)?.u;
1679    let m = project_point_to_curve(curve, mid_point).map_err(fail)?.u;
1680    if !((a < m && m < b) || (b < m && m < a)) {
1681        return Err(refuse_unsupported(
1682            KernelStage::Intersect,
1683            "arc_seam",
1684            format!(
1685                "{NO_EXACT_BOUNDARY}: a boundary run crosses its intersection curve's seam (v1)"
1686            ),
1687        ));
1688    }
1689    let (lo, hi) = if a < b { (a, b) } else { (b, a) };
1690    let trimmed = crate::feature_pipeline::features::common::trimmed_curve(curve, lo, hi)
1691        .map_err(fail)?;
1692    let [t0, t1] = trimmed.domain().map_err(fail)?;
1693    if a < b {
1694        Ok(BuiltEdge { curve: trimmed, t0, t1, start, end, region_forward: true, region_pcurve: None })
1695    } else {
1696        Ok(BuiltEdge {
1697            curve: trimmed,
1698            t0,
1699            t1,
1700            start: end,
1701            end: start,
1702            region_forward: false,
1703            region_pcurve: None,
1704        })
1705    }
1706}
1707
1708/// A closed boundary ring that is an iso-parameter circle of the fitted
1709/// (revolved) surface, closed in that direction: the ring is taken as the
1710/// surface's own iso curve, starting on its seam, so its trim is a straight
1711/// line across the whole period — the way the kernel's revolved solids carry
1712/// their cap circles. `None` unless the iso curve IS the intersection with
1713/// the neighbour to `tolerance` (a tilted cap cuts an ellipse, not a circle).
1714fn iso_ring(
1715    surface: &NurbsSurface,
1716    intersection: &NurbsCurve,
1717    points: &[Vec3],
1718    tolerance: f64,
1719) -> Option<(NurbsCurve, NurbsCurve, bool)> {
1720    let uv: Vec<(f64, f64)> = points
1721        .iter()
1722        .map(|p| crate::project_point_to_surface(surface, *p).map(|q| (q.u, q.v)))
1723        .collect::<Result<_, _>>()
1724        .ok()?;
1725    if uv.len() < 3 {
1726        return None;
1727    }
1728    let [u0, u1] = [surface.knots_u[0], *surface.knots_u.last()?];
1729    let [v0, v1] = [surface.knots_v[0], *surface.knots_v.last()?];
1730    let mean_u = uv.iter().map(|q| q.0).sum::<f64>() / uv.len() as f64;
1731    let mean_v = uv.iter().map(|q| q.1).sum::<f64>() / uv.len() as f64;
1732    let wrap = |delta: f64, period: f64| {
1733        let mut d = delta % period;
1734        if d > period * 0.5 {
1735            d -= period;
1736        } else if d < -period * 0.5 {
1737            d += period;
1738        }
1739        d
1740    };
1741    let candidates = [
1742        // Constant v: the ring runs along u.
1743        (surface.iso_curve_v(mean_v).ok(), true),
1744        // Constant u: the ring runs along v.
1745        (surface.iso_curve_u(mean_u).ok(), false),
1746    ];
1747    for (curve, along_u) in candidates {
1748        let Some(curve) = curve else { continue };
1749        let [t0, t1] = curve.domain().ok()?;
1750        let (Ok(start), Ok(end)) = (curve.evaluate(t0), curve.evaluate(t1)) else { continue };
1751        if start.sub(end).length() > tolerance * 1e-3 {
1752            continue; // not closed in this direction
1753        }
1754        let on_intersection = (0..16).all(|k| {
1755            curve
1756                .evaluate(t0 + (t1 - t0) * (k as f64 + 0.5) / 16.0)
1757                .ok()
1758                .and_then(|p| project_point_to_curve(intersection, p).ok())
1759                .is_some_and(|q| q.distance <= tolerance)
1760        });
1761        let through_points =
1762            points.iter().all(|p| project_point_to_curve(&curve, *p).is_ok_and(|q| q.distance <= tolerance));
1763        if !(on_intersection && through_points) {
1764            continue;
1765        }
1766        let (period, a, b) = if along_u {
1767            (u1 - u0, uv[0].0, uv[1].0)
1768        } else {
1769            (v1 - v0, uv[0].1, uv[1].1)
1770        };
1771        let forward = wrap(b - a, period) > 0.0;
1772        let (from, to) = if along_u {
1773            ([u0, mean_v], [u1, mean_v])
1774        } else {
1775            ([mean_u, v0], [mean_u, v1])
1776        };
1777        let (from, to) = if forward { (from, to) } else { (to, from) };
1778        let pcurve =
1779            make_line(Vec3::new(from[0], from[1], 0.0), Vec3::new(to[0], to[1], 0.0)).ok()?;
1780        return Some((curve, pcurve, forward));
1781    }
1782    None
1783}
1784
1785/// The iso-parameter curve of `surface` through `points` (constant v, then
1786/// constant u), when it lies on `other` to `tolerance` everywhere sampled —
1787/// the exact boundary for pairs the closed-form intersector does not list
1788/// (a plane square to a torus's axis cuts it in iso circles).
1789fn iso_curve_on(
1790    surface: &NurbsSurface,
1791    other: &NurbsSurface,
1792    points: &[Vec3],
1793    tolerance: f64,
1794) -> Vec<NurbsCurve> {
1795    let Ok(uv) = points
1796        .iter()
1797        .map(|p| crate::project_point_to_surface(surface, *p).map(|q| (q.u, q.v)))
1798        .collect::<Result<Vec<_>, _>>()
1799    else {
1800        return Vec::new();
1801    };
1802    if uv.is_empty() {
1803        return Vec::new();
1804    }
1805    let mean_u = uv.iter().map(|q| q.0).sum::<f64>() / uv.len() as f64;
1806    let mean_v = uv.iter().map(|q| q.1).sum::<f64>() / uv.len() as f64;
1807    let mut out = Vec::new();
1808    for curve in [surface.iso_curve_v(mean_v), surface.iso_curve_u(mean_u)].into_iter().flatten() {
1809        let Ok([t0, t1]) = curve.domain() else { continue };
1810        let through = points
1811            .iter()
1812            .all(|p| project_point_to_curve(&curve, *p).is_ok_and(|q| q.distance <= tolerance));
1813        let on_other = (0..=32).all(|k| {
1814            curve
1815                .evaluate(t0 + (t1 - t0) * k as f64 / 32.0)
1816                .ok()
1817                .is_some_and(|p| {
1818                    // Test the carrier, not the finite patch: the fitted
1819                    // surface's margin extends an iso ruling beyond the
1820                    // neighbour. open_arc trims it to the shared run later.
1821                    if let Some(crate::AnalyticSurface::Plane { origin, u_dir, v_dir, .. }) =
1822                        other.analytic()
1823                    {
1824                        u_dir.cross(*v_dir).normalized()
1825                            .is_ok_and(|n| p.sub(*origin).dot(n).abs() <= tolerance)
1826                    } else {
1827                        crate::project_point_to_surface(other, p).is_ok_and(|q| q.distance <= tolerance)
1828                    }
1829                })
1830        });
1831        if through && on_other {
1832            out.push(curve);
1833        }
1834    }
1835    out
1836}
1837
1838/// The plane of the planar closed `ring`, framed like `old` (same normal
1839/// side, same in-plane directions as nearly as the tilt allows, a patch
1840/// covering the ring). `None` when the ring is not planar or already lies
1841/// in `old`.
1842fn reseated_plane(old: &NurbsSurface, ring: &NurbsCurve, scale: f64) -> Option<NurbsSurface> {
1843    let [t0, t1] = ring.domain().ok()?;
1844    let points: Vec<Vec3> =
1845        (0..48).filter_map(|k| ring.evaluate(t0 + (t1 - t0) * k as f64 / 48.0).ok()).collect();
1846    if points.len() < 48 {
1847        return None;
1848    }
1849    let centroid = points
1850        .iter()
1851        .fold(Vec3::new(0.0, 0.0, 0.0), |s, p| s.add(*p))
1852        .scale(1.0 / points.len() as f64);
1853    // Newell normal of the ring polygon.
1854    let mut normal = Vec3::new(0.0, 0.0, 0.0);
1855    for (i, a) in points.iter().enumerate() {
1856        let b = points[(i + 1) % points.len()];
1857        normal = normal.add(a.sub(centroid).cross(b.sub(centroid)));
1858    }
1859    let normal = normal.normalized().ok()?;
1860    let flat = points.iter().all(|p| p.sub(centroid).dot(normal).abs() <= scale * 1e-10);
1861    if !flat {
1862        return None;
1863    }
1864    let [u0, u1] = [old.knots_u[0], *old.knots_u.last()?];
1865    let [v0, v1] = [old.knots_v[0], *old.knots_v.last()?];
1866    let origin = old.evaluate(u0, v0).ok()?;
1867    let su = old.evaluate(u1, v0).ok()?.sub(origin);
1868    let sv = old.evaluate(u0, v1).ok()?.sub(origin);
1869    let old_normal = su.cross(sv).normalized().ok()?;
1870    let off = points.iter().map(|p| p.sub(origin).dot(old_normal).abs()).fold(0.0, f64::max);
1871    if off <= scale * 1e-12 {
1872        return None;
1873    }
1874    let n = if normal.dot(old_normal) >= 0.0 { normal } else { normal.scale(-1.0) };
1875    let u_dir = su.sub(n.scale(su.dot(n))).normalized().ok()?;
1876    let v_dir = n.cross(u_dir);
1877    // Cover the ring and the old patch, with a margin.
1878    let mut lo = [f64::MAX; 2];
1879    let mut hi = [f64::MIN; 2];
1880    let corners = [origin, origin.add(su), origin.add(sv), origin.add(su).add(sv)];
1881    for p in points.iter().chain(corners.iter()) {
1882        let rel = p.sub(centroid);
1883        let (a, b) = (rel.dot(u_dir), rel.dot(v_dir));
1884        lo = [lo[0].min(a), lo[1].min(b)];
1885        hi = [hi[0].max(a), hi[1].max(b)];
1886    }
1887    let margin = (hi[0] - lo[0]).max(hi[1] - lo[1]) * 0.05;
1888    let corner = centroid.add(u_dir.scale(lo[0] - margin)).add(v_dir.scale(lo[1] - margin));
1889    make_plane(corner, u_dir, v_dir, hi[0] - lo[0] + 2.0 * margin, hi[1] - lo[1] + 2.0 * margin).ok()
1890}
1891
1892// ---------------------------------------------------------------------------
1893// v2: a whole closed shell becomes one sphere or torus
1894// ---------------------------------------------------------------------------
1895
1896#[allow(clippy::too_many_arguments)]
1897fn refit_closed_shell(
1898    solid: &BrepSolid,
1899    shell_index: usize,
1900    selected_indices: &[usize],
1901    euler: i64,
1902    kind: RefitSurfaceKind,
1903    tolerance: f64,
1904    scale: f64,
1905    edge_by_id: &HashMap<u64, &EdgeRecord>,
1906    vertex_by_id: &HashMap<u64, Vec3>,
1907) -> Result<(BrepSolid, RefitReport), KernelRefusal> {
1908    let shell = &solid.shells[shell_index];
1909    if selected_indices.len() != shell.faces.len() {
1910        return Err(refuse_unsupported(
1911            KernelStage::Classify,
1912            "closed_region",
1913            format!(
1914                "the selection has no boundary but is not every face of its shell; the region \
1915                 is {NOT_DISK_OR_ANNULUS}"
1916            ),
1917        ));
1918    }
1919    // A sphere closes with Euler characteristic 2, a torus with 0. AUTO
1920    // takes the one the shell's own topology names.
1921    let kind = match kind {
1922        RefitSurfaceKind::Auto => match euler {
1923            2 => RefitSurfaceKind::Sphere,
1924            0 => RefitSurfaceKind::Torus,
1925            _ => {
1926                return Err(refuse_unsupported(
1927                    KernelStage::Classify,
1928                    "closed_shell_genus",
1929                    format!(
1930                        "the selected closed shell has Euler characteristic {euler}; \
1931                         {CLOSED_SHELL_KIND} (2 or 0)"
1932                    ),
1933                ))
1934            }
1935        },
1936        RefitSurfaceKind::Plane | RefitSurfaceKind::Cylinder | RefitSurfaceKind::Cone => {
1937            return Err(refuse_unsupported(
1938                KernelStage::Classify,
1939                "closed_shell_kind",
1940                format!(
1941                    "the selection is a whole closed shell and no {} can be one: \
1942                     {CLOSED_SHELL_KIND}",
1943                    kind.name()
1944                ),
1945            ))
1946        }
1947        other => other,
1948    };
1949    let wanted = if kind == RefitSurfaceKind::Sphere { 2 } else { 0 };
1950    if euler != wanted {
1951        return Err(refuse_unsupported(
1952            KernelStage::Classify,
1953            "closed_shell_genus",
1954            format!(
1955                "a {} {CLOSED_SHELL_GENUS}: the selected closed shell has Euler characteristic \
1956                 {euler}, a {} has {wanted}",
1957                kind.name(),
1958                kind.name()
1959            ),
1960        ));
1961    }
1962
1963    let samples = fit_samples(shell, selected_indices, edge_by_id, vertex_by_id)?;
1964    let carrier = fit_carrier(&samples, kind, tolerance, scale)?.carrier;
1965    let (rms_residual, max_residual) = residuals(&carrier, &samples.points);
1966    if max_residual > tolerance {
1967        return Err(KernelRefusal::ill_posed(
1968            KernelStage::Refine,
1969            "refit_residual",
1970            format!(
1971                "Refit Faces: the {} {RESIDUAL_OVER_TOLERANCE} {max_residual:.6} (rms \
1972                 {rms_residual:.6}) is over the tolerance {tolerance}; these faces are not \
1973                 one {} within it",
1974                carrier.kind().name(),
1975                carrier.kind().name()
1976            ),
1977        ));
1978    }
1979    coarse_facet_gate(&carrier, shell, selected_indices, edge_by_id, vertex_by_id)?;
1980    let fail = |e: String| {
1981        refuse_unsupported(KernelStage::Refine, "refit_surface", format!("{NO_FIT}: {e}"))
1982    };
1983    let primitive = match carrier {
1984        RefitCarrier::Sphere { center, radius } => {
1985            // Poles on the axis the facets' own layout suggests is irrelevant
1986            // for a whole ball; z is as good as any.
1987            crate::make_sphere_brep(center, radius, Vec3::new(0.0, 0.0, 1.0)).map_err(fail)?
1988        }
1989        RefitCarrier::Torus { center, axis, major_radius, minor_radius } => {
1990            if !(minor_radius > 0.0 && major_radius > minor_radius) {
1991                return Err(refuse_unsupported(
1992                    KernelStage::Refine,
1993                    "torus_radii",
1994                    format!(
1995                        "the fitted torus (radii {major_radius:.6}/{minor_radius:.6}) \
1996                         self-intersects; {NO_FIT} as one face (v1)"
1997                    ),
1998                ));
1999            }
2000            crate::make_torus_brep(center, axis, major_radius, minor_radius).map_err(fail)?
2001        }
2002        other => {
2003            return Err(refuse_unsupported(
2004                KernelStage::Classify,
2005                "closed_shell_kind",
2006                format!(
2007                    "the fit returned a {}; {CLOSED_SHELL_KIND}",
2008                    other.kind().name()
2009                ),
2010            ))
2011        }
2012    };
2013
2014    // The primitive is an outward solid. The selection's own outward side
2015    // decides whether its shell bounds material (outer shell) or a cavity.
2016    let prim_face = &primitive.shells[0].faces[0];
2017    let vote = region_same_sense(&prim_face.surface, &samples)?;
2018    let cavity = vote != prim_face.same_sense;
2019
2020    // Re-id the primitive's records above everything the solid holds.
2021    let base = solid
2022        .vertices
2023        .iter()
2024        .map(|v| v.id)
2025        .chain(solid.edges.iter().map(|e| e.id))
2026        .chain(solid.shells.iter().map(|s| s.id))
2027        .chain(solid.shells.iter().flat_map(|s| s.faces.iter()).flat_map(|f| {
2028            std::iter::once(f.id).chain(
2029                f.loops
2030                    .iter()
2031                    .flat_map(|l| std::iter::once(l.id).chain(l.coedges.iter().map(|c| c.id))),
2032            )
2033        }))
2034        .max()
2035        .unwrap_or(0)
2036        + 1;
2037    let keeper = &shell.faces[selected_indices[0]];
2038    let mut new_shell = primitive.shells[0].clone();
2039    new_shell.id = shell.id;
2040    for face in &mut new_shell.faces {
2041        face.id = keeper.id;
2042        face.name = keeper.name.clone();
2043        for lp in &mut face.loops {
2044            lp.id += base;
2045            for coedge in &mut lp.coedges {
2046                coedge.id += base;
2047                coedge.edge_id += base;
2048            }
2049        }
2050        if cavity {
2051            flip_face(face).map_err(fail)?;
2052        }
2053    }
2054    let new_edges: Vec<EdgeRecord> = primitive
2055        .edges
2056        .iter()
2057        .map(|e| {
2058            let mut e = e.clone();
2059            e.id += base;
2060            e.start_vertex_id += base;
2061            e.end_vertex_id += base;
2062            e
2063        })
2064        .collect();
2065    let new_vertices: Vec<VertexRecord> = primitive
2066        .vertices
2067        .iter()
2068        .map(|v| VertexRecord { id: v.id + base, point: v.point })
2069        .collect();
2070
2071    // Drop the old shell's edges and vertices (no other shell shares them).
2072    let old_edges: HashSet<u64> = shell
2073        .faces
2074        .iter()
2075        .flat_map(|f| f.loops.iter())
2076        .flat_map(|l| l.coedges.iter().map(|c| c.edge_id))
2077        .collect();
2078    let mut out = solid.clone();
2079    out.edges.retain(|e| !old_edges.contains(&e.id));
2080    out.edges.extend(new_edges);
2081    out.shells[shell_index] = new_shell;
2082    let used: HashSet<u64> =
2083        out.edges.iter().flat_map(|e| [e.start_vertex_id, e.end_vertex_id]).collect();
2084    out.vertices.retain(|v| used.contains(&v.id));
2085    out.vertices.extend(new_vertices);
2086    // The shell's genus is unchanged (checked above), so `out.genus` stands.
2087
2088    let issues = out.validate();
2089    if !issues.is_empty() {
2090        let first: Vec<String> = issues.iter().take(3).map(|i| format!("{i:?}")).collect();
2091        return Err(KernelRefusal::new(
2092            RefusalClass::InvalidResultTopology { issues: issues.len() as u32 },
2093            KernelStage::Validate,
2094            format!(
2095                "Refit Faces: the {RESULT_INVALID} ({} issue(s): {})",
2096                issues.len(),
2097                first.join("; ")
2098            ),
2099        ));
2100    }
2101    let report = RefitReport {
2102        carrier,
2103        rms_residual,
2104        max_residual,
2105        tolerance,
2106        samples: samples.points.len(),
2107        replaced_faces: selected_indices.len(),
2108        boundary_edges_before: 0,
2109        boundary_edges_after: 0,
2110        boundary_loops: 0,
2111        new_face_id: keeper.id,
2112    };
2113    Ok((out, report))
2114}
2115
2116/// Turn a face inside out: the other normal side, each loop walked the other
2117/// way (the `offset_shell::orientation` operation).
2118pub(super) fn flip_face(face: &mut FaceRecord) -> Result<(), String> {
2119    face.same_sense = !face.same_sense;
2120    for lp in &mut face.loops {
2121        lp.coedges.reverse();
2122        for coedge in &mut lp.coedges {
2123            coedge.forward = !coedge.forward;
2124            coedge.pcurve = coedge.pcurve.reversed()?;
2125        }
2126    }
2127    Ok(())
2128}
2129
2130/// The widest arc, in degrees, any selected flat face may span on the fitted
2131/// surface: a facet may be no wider than the radius of the surface it
2132/// approximates. A chord spanning `t` on radius `R` is `2 R sin(t / 2)` long,
2133/// which is at most `R` exactly when `t <= 60` degrees. The bound is inclusive:
2134/// a regular hexagonal prism (chord = radius) refits as a cylinder, and a
2135/// pentagonal one (72 degrees, chord 1.18 R) is refused.
2136///
2137/// The fit reads vertices only, and every regular polygon is cyclic, so the
2138/// corners alone never refute a coarse fit. The 16 corners of an 8-sided can
2139/// lie exactly on one sphere, whose flat caps would each span about 98
2140/// degrees of it; a box's four walls lie on a cylinder at 90 degrees each.
2141pub const MAX_FACET_SPAN_DEGREES: f64 = 60.0;
2142
2143/// Rounding slack on [`MAX_FACET_SPAN_DEGREES`], so a facet exactly on the
2144/// bound (a hexagon read through f32 coordinates and a fitted radius) is
2145/// not refused by the last digit.
2146pub const FACET_SPAN_SLACK_DEGREES: f64 = 0.1;
2147
2148/// Refuse when a selected flat face spans more than
2149/// [`MAX_FACET_SPAN_DEGREES`] of the fitted curved surface, read from how far
2150/// its vertex centroid sags below the surface: a chord of half-angle `a` on
2151/// a radius `R` sags `R (1 - cos a)`.
2152fn coarse_facet_gate(
2153    carrier: &RefitCarrier,
2154    shell: &crate::topology::ShellRecord,
2155    selected: &[usize],
2156    edges: &HashMap<u64, &EdgeRecord>,
2157    vertices: &HashMap<u64, Vec3>,
2158) -> Result<(), KernelRefusal> {
2159    if matches!(carrier, RefitCarrier::Plane { .. }) {
2160        return Ok(());
2161    }
2162    let mut worst: Option<(f64, &FaceRecord)> = None;
2163    for &f in selected {
2164        let face = &shell.faces[f];
2165        if !face.surface.is_affine().unwrap_or(false) {
2166            continue;
2167        }
2168        let mut sum = Vec3::new(0.0, 0.0, 0.0);
2169        let mut count = 0usize;
2170        for coedge in face.loops.iter().flat_map(|l| l.coedges.iter()) {
2171            let Some(edge) = edges.get(&coedge.edge_id) else { continue };
2172            let id = if coedge.forward { edge.start_vertex_id } else { edge.end_vertex_id };
2173            if let Some(p) = vertices.get(&id) {
2174                sum = sum.add(*p);
2175                count += 1;
2176            }
2177        }
2178        if count < 3 {
2179            continue;
2180        }
2181        let centroid = sum.scale(1.0 / count as f64);
2182        let radius = local_radius(carrier, centroid);
2183        if !(radius > 0.0) {
2184            continue;
2185        }
2186        let sag = carrier.distance(centroid);
2187        let span = 2.0 * (1.0 - (sag / radius).min(2.0)).clamp(-1.0, 1.0).acos().to_degrees();
2188        if worst.map_or(true, |(w, _)| span > w) {
2189            worst = Some((span, face));
2190        }
2191    }
2192    match worst {
2193        Some((span, face)) if span > MAX_FACET_SPAN_DEGREES + FACET_SPAN_SLACK_DEGREES => {
2194            Err(KernelRefusal::ill_posed(
2195            KernelStage::Refine,
2196            "refit_facets_coarse",
2197            format!(
2198                "Refit Faces: the selection is {FACETS_TOO_COARSE} to be one {}: {} spans \
2199                 {span:.1} degrees of the fitted surface (at most {MAX_FACET_SPAN_DEGREES}); \
2200                 a facet may be no wider than the radius it approximates",
2201                carrier.kind().name(),
2202                face_label(face)
2203            ),
2204        ))
2205        }
2206        _ => Ok(()),
2207    }
2208}
2209
2210/// The surface's smaller principal radius of curvature near `point`.
2211fn local_radius(carrier: &RefitCarrier, point: Vec3) -> f64 {
2212    match *carrier {
2213        RefitCarrier::Plane { .. } => f64::INFINITY,
2214        RefitCarrier::Cylinder { radius, .. } => radius,
2215        RefitCarrier::Sphere { radius, .. } => radius,
2216        RefitCarrier::Torus { minor_radius, .. } => minor_radius,
2217        RefitCarrier::Cone { apex, axis, half_angle } => {
2218            let rel = point.sub(apex);
2219            let radial = rel.sub(axis.scale(rel.dot(axis))).length();
2220            radial / half_angle.cos()
2221        }
2222    }
2223}
2224