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