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| {
1216        let mut options = RecognitionOptions::default();
1217        options.distance_tolerance = distance;
1218        options.relative_tolerance = 0.0;
1219        // The facets' normals are the chords' and not the surface's: the
1220        // user asserts the family, the positional residual is the judge.
1221        options.normal_tolerance = normal;
1222        options.minimum_support = 1;
1223        options.minimum_support_area = 0.0;
1224        options.discover_regions = false;
1225        options.sampling = SamplingMode::Vertices;
1226        options
1227    };
1228    // First at the caller's tolerance; if nothing fits there, again with a
1229    // loose one so the refusal can quote the residual it missed by.
1230    // The retry gates on nothing (distance the model's size, normals any
1231    // angle): its only job is the carrier whose residual the refusal quotes.
1232    let reconstruct = |options: &RecognitionOptions| {
1233        // A small selection (or one plane) may have too few ridges to fit.
1234        // In that case retain the original all-vertex fitting path.
1235        if !samples.fit_vertices.is_empty() && samples.fit_vertices.len() < mesh.vertices.len() {
1236            if let Ok(fit) = brep_ransac::reconstruct_surface_from_vertices(
1237                &mesh,
1238                &samples.fit_vertices,
1239                &hint,
1240                options,
1241            ) {
1242                // A few ridges can alias a simpler carrier (two cylinder
1243                // rulings are coplanar). Fall back if it misses the full region.
1244                if samples.points.iter().all(|p| {
1245                    fit.surface.signed_distance(to_ransac(*p)).abs() <= options.distance_tolerance
1246                }) {
1247                    return Ok(fit);
1248                }
1249            }
1250        }
1251        brep_ransac::reconstruct_surface(&mesh, &triangles, &hint, options)
1252    };
1253    let fit = reconstruct(&options(tolerance, 60.0_f64.to_radians()))
1254        .or_else(|_| reconstruct(&options(scale * 10.0, PI)))
1255        .map_err(|e| {
1256            refuse_unsupported(
1257                KernelStage::Refine,
1258                "refit_no_fit",
1259                format!("{NO_FIT}: the recognition pass found no {} ({e})", kind.name()),
1260            )
1261        })?;
1262    let carrier = match fit.surface {
1263        AnalyticSurface::Plane(p) => RefitCarrier::Plane {
1264            origin: from_ransac(p.origin),
1265            normal: unit(from_ransac(p.normal))?,
1266        },
1267        AnalyticSurface::Cylinder(c) => RefitCarrier::Cylinder {
1268            axis_origin: from_ransac(c.axis_origin),
1269            axis: unit(from_ransac(c.axis))?,
1270            radius: c.radius,
1271        },
1272        AnalyticSurface::Cone(c) => RefitCarrier::Cone {
1273            apex: from_ransac(c.apex),
1274            axis: unit(from_ransac(c.axis))?,
1275            half_angle: c.half_angle,
1276        },
1277        AnalyticSurface::Sphere(s) => {
1278            RefitCarrier::Sphere { center: from_ransac(s.center), radius: s.radius }
1279        }
1280        AnalyticSurface::Torus(t) => RefitCarrier::Torus {
1281            center: from_ransac(t.center),
1282            axis: unit(from_ransac(t.axis))?,
1283            major_radius: t.major_radius,
1284            minor_radius: t.minor_radius,
1285        },
1286    };
1287    Ok(Fitted { carrier })
1288}
1289
1290fn unit(v: Vec3) -> Result<Vec3, KernelRefusal> {
1291    v.normalized()
1292        .map_err(|e| refuse_unsupported(KernelStage::Refine, "refit_no_fit", format!("{NO_FIT}: {e}")))
1293}
1294
1295fn residuals(carrier: &RefitCarrier, points: &[Vec3]) -> (f64, f64) {
1296    let mut sum = 0.0;
1297    let mut max = 0.0_f64;
1298    for p in points {
1299        let d = carrier.distance(*p);
1300        sum += d * d;
1301        max = max.max(d);
1302    }
1303    ((sum / points.len().max(1) as f64).sqrt(), max)
1304}
1305
1306// ---------------------------------------------------------------------------
1307// The carrier surface, framed so the region avoids its seam and poles
1308// ---------------------------------------------------------------------------
1309
1310/// Unit x/y frame perpendicular to `axis`, x pointing at the middle of the
1311/// largest angular gap the points leave around the axis (so the surface's
1312/// parameter seam falls where the region is not; a full band has no gap and
1313/// the seam is arbitrary — its boundary is two closed rings).
1314fn seam_frame(axis: Vec3, origin: Vec3, points: &[Vec3]) -> Result<(Vec3, Vec3), KernelRefusal> {
1315    let x0 = axis.perpendicular().map_err(|e| refuse_input("axis", e))?;
1316    let y0 = axis.cross(x0);
1317    let mut angles: Vec<f64> = points
1318        .iter()
1319        .filter_map(|p| {
1320            let rel = p.sub(origin);
1321            let (x, y) = (rel.dot(x0), rel.dot(y0));
1322            (x.hypot(y) > 1e-12).then(|| y.atan2(x))
1323        })
1324        .collect();
1325    if angles.is_empty() {
1326        return Ok((x0, y0));
1327    }
1328    angles.sort_by(f64::total_cmp);
1329    let mut best_gap = angles[0] + TAU - angles[angles.len() - 1];
1330    let mut best_mid = angles[angles.len() - 1] + best_gap * 0.5;
1331    for pair in angles.windows(2) {
1332        let gap = pair[1] - pair[0];
1333        if gap > best_gap {
1334            best_gap = gap;
1335            best_mid = pair[0] + gap * 0.5;
1336        }
1337    }
1338    let x = x0.scale(best_mid.cos()).add(y0.scale(best_mid.sin()));
1339    Ok((x, axis.cross(x)))
1340}
1341
1342fn carrier_surface(
1343    carrier: &RefitCarrier,
1344    points: &[Vec3],
1345    scale: f64,
1346) -> Result<NurbsSurface, KernelRefusal> {
1347    let fail = |e: String| {
1348        refuse_unsupported(KernelStage::Refine, "refit_surface", format!("{NO_FIT}: {e}"))
1349    };
1350    match *carrier {
1351        RefitCarrier::Plane { origin, normal } => {
1352            let u = normal.perpendicular().map_err(fail)?;
1353            let v = normal.cross(u);
1354            let (mut u0, mut u1, mut v0, mut v1) = (f64::MAX, f64::MIN, f64::MAX, f64::MIN);
1355            for p in points {
1356                let rel = p.sub(origin);
1357                u0 = u0.min(rel.dot(u));
1358                u1 = u1.max(rel.dot(u));
1359                v0 = v0.min(rel.dot(v));
1360                v1 = v1.max(rel.dot(v));
1361            }
1362            let margin = ((u1 - u0).max(v1 - v0) * 0.1).max(scale * 1e-3);
1363            let corner = origin.add(u.scale(u0 - margin)).add(v.scale(v0 - margin));
1364            make_plane(corner, u, v, u1 - u0 + 2.0 * margin, v1 - v0 + 2.0 * margin).map_err(fail)
1365        }
1366        RefitCarrier::Cylinder { axis_origin, axis, radius } => {
1367            let (h0, h1) = axial_range(axis_origin, axis, points);
1368            let margin = ((h1 - h0) * 0.1).max(scale * 1e-3);
1369            let (x, _) = seam_frame(axis, axis_origin, points)?;
1370            let base = axis_origin.add(axis.scale(h0 - margin));
1371            let start = base.add(x.scale(radius));
1372            let generatrix =
1373                make_line(start, start.add(axis.scale(h1 - h0 + 2.0 * margin))).map_err(fail)?;
1374            make_revolution(base, axis, &generatrix, TAU).map_err(fail)
1375        }
1376        RefitCarrier::Cone { apex, axis, half_angle } => {
1377            let (h0, h1) = axial_range(apex, axis, points);
1378            if h0 <= 0.0 {
1379                return Err(refuse_unsupported(
1380                    KernelStage::Refine,
1381                    "cone_apex",
1382                    format!(
1383                        "the fitted cone's apex lies inside the region; a pointed cone face is \
1384                         {NOT_DISK_OR_ANNULUS} this edit builds (v1)"
1385                    ),
1386                ));
1387            }
1388            let margin = ((h1 - h0) * 0.1).max(scale * 1e-3).min(h0 * 0.5);
1389            let (x, _) = seam_frame(axis, apex, points)?;
1390            let tan = half_angle.tan();
1391            let (a, b) = (h0 - margin, h1 + margin);
1392            let generatrix = make_line(
1393                apex.add(axis.scale(a)).add(x.scale(a * tan)),
1394                apex.add(axis.scale(b)).add(x.scale(b * tan)),
1395            )
1396            .map_err(fail)?;
1397            make_revolution(apex, axis, &generatrix, TAU).map_err(fail)
1398        }
1399        RefitCarrier::Sphere { center, radius } => {
1400            // Park the poles 90 degrees from the region's mean direction and
1401            // the seam opposite it.
1402            let mean = points
1403                .iter()
1404                .fold(Vec3::new(0.0, 0.0, 0.0), |s, p| s.add(p.sub(center).normalized().unwrap_or(*p)));
1405            let mean = mean.normalized().unwrap_or(Vec3::new(1.0, 0.0, 0.0));
1406            let pole = mean.perpendicular().map_err(fail)?;
1407            let limit = (85.0_f64).to_radians().sin();
1408            // Choose, among directions perpendicular to the mean, the pole the
1409            // region stays farthest from.
1410            let other = mean.cross(pole);
1411            let mut best = (f64::MAX, pole);
1412            for k in 0..36 {
1413                let angle = PI * k as f64 / 36.0;
1414                let candidate = pole.scale(angle.cos()).add(other.scale(angle.sin()));
1415                let worst = points
1416                    .iter()
1417                    .map(|p| (p.sub(center).dot(candidate) / radius).abs())
1418                    .fold(0.0_f64, f64::max);
1419                if worst < best.0 {
1420                    best = (worst, candidate);
1421                }
1422            }
1423            if best.0 > limit {
1424                return Err(refuse_unsupported(
1425                    KernelStage::Refine,
1426                    "sphere_extent",
1427                    format!(
1428                        "the spherical region reaches within 5 degrees of every pole this edit \
1429                         can place; a region that large is {NOT_DISK_OR_ANNULUS} this edit \
1430                         builds (v1)"
1431                    ),
1432                ));
1433            }
1434            let pole = best.1;
1435            let seam = mean.scale(-1.0);
1436            let x_axis = seam.sub(pole.scale(seam.dot(pole))).normalized().map_err(fail)?;
1437            let meridian =
1438                make_arc(center, x_axis, pole, radius, -PI / 2.0, PI / 2.0).map_err(fail)?;
1439            make_revolution(center, pole, &meridian, TAU).map_err(fail)
1440        }
1441        RefitCarrier::Torus { center, axis, major_radius, minor_radius } => {
1442            if !(minor_radius > 0.0 && major_radius > minor_radius) {
1443                return Err(refuse_unsupported(
1444                    KernelStage::Refine,
1445                    "torus_radii",
1446                    format!(
1447                        "the fitted torus (radii {major_radius:.6}/{minor_radius:.6}) \
1448                         self-intersects; {NO_FIT} as one face (v1)"
1449                    ),
1450                ));
1451            }
1452            let (x, _) = seam_frame(axis, center, points)?;
1453            // Tube angle gap, in the (radial, axial) plane.
1454            let mut angles: Vec<f64> = points
1455                .iter()
1456                .map(|p| {
1457                    let rel = p.sub(center);
1458                    let along = rel.dot(axis);
1459                    let radial = rel.sub(axis.scale(along)).length() - major_radius;
1460                    along.atan2(radial)
1461                })
1462                .collect();
1463            angles.sort_by(f64::total_cmp);
1464            let mut start = 0.0;
1465            if !angles.is_empty() {
1466                let mut best_gap = angles[0] + TAU - angles[angles.len() - 1];
1467                let mut best_mid = angles[angles.len() - 1] + best_gap * 0.5;
1468                for pair in angles.windows(2) {
1469                    if pair[1] - pair[0] > best_gap {
1470                        best_gap = pair[1] - pair[0];
1471                        best_mid = pair[0] + best_gap * 0.5;
1472                    }
1473                }
1474                start = best_mid;
1475            }
1476            let tube_center = center.add(x.scale(major_radius));
1477            let tube = make_arc(tube_center, x, axis, minor_radius, start, start + TAU)
1478                .map_err(fail)?;
1479            make_revolution(center, axis, &tube, TAU).map_err(fail)
1480        }
1481    }
1482}
1483
1484fn axial_range(origin: Vec3, axis: Vec3, points: &[Vec3]) -> (f64, f64) {
1485    points.iter().fold((f64::MAX, f64::MIN), |(lo, hi), p| {
1486        let h = p.sub(origin).dot(axis);
1487        (lo.min(h), hi.max(h))
1488    })
1489}
1490
1491fn region_same_sense(surface: &NurbsSurface, samples: &Samples) -> Result<bool, KernelRefusal> {
1492    let mut vote = 0.0;
1493    for (point, outward) in samples.oriented.iter().step_by((samples.oriented.len() / 64).max(1)) {
1494        let Ok(projection) = crate::project_point_to_surface(surface, *point) else { continue };
1495        let Ok(normal) = surface.normal(projection.u, projection.v) else { continue };
1496        vote += normal.dot(*outward).signum() * outward.length();
1497    }
1498    if vote == 0.0 {
1499        return Err(refuse_unsupported(
1500            KernelStage::Classify,
1501            "orientation",
1502            format!("{NO_FIT}: the selection's outward side could not be read"),
1503        ));
1504    }
1505    Ok(vote > 0.0)
1506}
1507
1508// ---------------------------------------------------------------------------
1509// Boundary chains and their exact curves
1510// ---------------------------------------------------------------------------
1511
1512fn split_chains(cycle: &[BoundaryUse]) -> Vec<Chain> {
1513    let n = cycle.len();
1514    let Some(change) = (0..n).find(|&i| cycle[i].neighbour != cycle[(i + n - 1) % n].neighbour)
1515    else {
1516        return vec![Chain { uses: cycle.to_vec(), closed: true, neighbour: cycle[0].neighbour }];
1517    };
1518    let mut chains: Vec<Chain> = Vec::new();
1519    for k in 0..n {
1520        let use_ = cycle[(change + k) % n];
1521        match chains.last_mut() {
1522            Some(chain) if chain.neighbour == use_.neighbour => chain.uses.push(use_),
1523            _ => chains.push(Chain { uses: vec![use_], closed: false, neighbour: use_.neighbour }),
1524        }
1525    }
1526    chains
1527}
1528
1529fn chain_points(chain: &Chain, vertices: &HashMap<u64, Vec3>) -> Vec<Vec3> {
1530    let mut points: Vec<Vec3> = chain.uses.iter().map(|u| vertices[&u.start]).collect();
1531    if !chain.closed {
1532        points.push(vertices[&chain.uses.last().unwrap().end]);
1533    }
1534    points
1535}
1536
1537/// Boundary traversal samples for choosing the rebuilt arc's sense and extent.
1538/// Endpoints alone lose that information for a full circle made of one or two
1539/// curved edges (two semicircles have only two antipodal vertices). Keep the
1540/// original vertices for intersection-distance checks, but trace curved edges
1541/// here so iso_ring can recognize the ring and start it on the fitted seam.
1542/// Straight facet chords contribute only their vertices: their interiors are
1543/// deliberately off the analytic carrier being reconstructed.
1544fn chain_trace_points(
1545    chain: &Chain,
1546    vertices: &HashMap<u64, Vec3>,
1547    edges: &HashMap<u64, &EdgeRecord>,
1548) -> Result<Vec<Vec3>, KernelRefusal> {
1549    let mut points = Vec::new();
1550    for use_ in &chain.uses {
1551        points.push(vertices[&use_.start]);
1552        let edge = edges[&use_.edge_id];
1553        if edge.curve.degree <= 1 {
1554            continue;
1555        }
1556        let mut parameters = Vec::new();
1557        for span in edge.curve.knots.windows(2) {
1558            let lo = span[0].max(edge.t0);
1559            let hi = span[1].min(edge.t1);
1560            if hi <= lo { continue; }
1561            for step in 0..4 {
1562                let t = lo + (hi - lo) * step as f64 / 4.0;
1563                if t > edge.t0 && t < edge.t1 { parameters.push(t); }
1564            }
1565        }
1566        if !use_.forward { parameters.reverse(); }
1567        for t in parameters {
1568            points.push(edge.curve.evaluate(t).map_err(|e| refuse_input("curve", e))?);
1569        }
1570    }
1571    if !chain.closed {
1572        points.push(vertices[&chain.uses.last().unwrap().end]);
1573    }
1574    Ok(points)
1575}
1576
1577/// The point where two curves meet near `seed`, by alternating projection,
1578/// and the gap left between them there.
1579fn curve_meeting(
1580    first: &NurbsCurve,
1581    second: &NurbsCurve,
1582    seed: Vec3,
1583) -> Result<(Vec3, f64), KernelRefusal> {
1584    let project = |curve: &NurbsCurve, p: Vec3| {
1585        project_point_to_curve(curve, p).map_err(|e| {
1586            refuse_unsupported(KernelStage::Intersect, "corner", format!("{NO_EXACT_BOUNDARY}: {e}"))
1587        })
1588    };
1589    let mut p = seed;
1590    let mut gap = f64::MAX;
1591    for _ in 0..200 {
1592        let a = project(first, p)?.point;
1593        let b = project(second, a)?.point;
1594        gap = a.sub(b).length();
1595        let next = a.add(b).scale(0.5);
1596        let step = next.sub(p).length();
1597        p = next;
1598        if step < 1e-14 * (1.0 + p.length()) {
1599            break;
1600        }
1601    }
1602    let a = project(first, p)?.point;
1603    let b = project(second, p)?.point;
1604    gap = gap.min(a.sub(b).length());
1605    Ok((a.add(b).scale(0.5), gap))
1606}
1607
1608/// A circle through `curve`'s points, if the curve is one: (center, normal, radius).
1609fn as_circle(curve: &NurbsCurve, scale: f64) -> Option<(Vec3, Vec3, f64)> {
1610    let [t0, t1] = curve.domain().ok()?;
1611    let samples: Vec<Vec3> = (0..24)
1612        .filter_map(|k| curve.evaluate(t0 + (t1 - t0) * k as f64 / 24.0).ok())
1613        .collect();
1614    if samples.len() < 24 {
1615        return None;
1616    }
1617    let (a, b, c) = (samples[0], samples[8], samples[16]);
1618    let ab = b.sub(a);
1619    let ac = c.sub(a);
1620    let normal = ab.cross(ac);
1621    let center = crate::circumcenter(a, b, c)?;
1622    let radius = a.sub(center).length();
1623    let unit_normal = normal.normalized().ok()?;
1624    let tol = scale * 1e-9 + radius * 1e-9;
1625    for p in &samples {
1626        if (p.sub(center).length() - radius).abs() > tol || p.sub(center).dot(unit_normal).abs() > tol {
1627            return None;
1628        }
1629    }
1630    Some((center, unit_normal, radius))
1631}
1632
1633fn angle_in(center: Vec3, x: Vec3, y: Vec3, p: Vec3) -> f64 {
1634    let rel = p.sub(center);
1635    let angle = rel.dot(y).atan2(rel.dot(x));
1636    if angle < 0.0 {
1637        angle + TAU
1638    } else {
1639        angle
1640    }
1641}
1642
1643/// A closed boundary ring: the exact closed curve, oriented and started so
1644/// the region traverses it forward from its first chain point when possible.
1645fn closed_ring_curve(
1646    curve: &NurbsCurve,
1647    points: &[Vec3],
1648    scale: f64,
1649) -> Result<(NurbsCurve, bool), KernelRefusal> {
1650    let fail = |e: String| {
1651        refuse_unsupported(KernelStage::Intersect, "ring", format!("{NO_EXACT_BOUNDARY}: {e}"))
1652    };
1653    if let Some((center, normal, radius)) = as_circle(curve, scale) {
1654        let start = points[0];
1655        let x = start.sub(center);
1656        let x = x.sub(normal.scale(x.dot(normal))).normalized().map_err(fail)?;
1657        let mut n = normal;
1658        let y = n.cross(x);
1659        // The region runs from points[0] toward points[1]: keep that sense.
1660        if angle_in(center, x, y, points[1]) > PI {
1661            n = n.scale(-1.0);
1662        }
1663        let y = n.cross(x);
1664        let arc = make_arc(center, x, y, radius, 0.0, TAU).map_err(fail)?;
1665        return Ok((arc, true));
1666    }
1667    // Not a circle: keep the curve, read the direction from the chain.
1668    let p0 = project_point_to_curve(curve, points[0]).map_err(fail)?;
1669    let p1 = project_point_to_curve(curve, points[1]).map_err(fail)?;
1670    let [t0, t1] = curve.domain().map_err(fail)?;
1671    let mut delta = p1.u - p0.u;
1672    let period = t1 - t0;
1673    if delta > period * 0.5 {
1674        delta -= period;
1675    } else if delta < -period * 0.5 {
1676        delta += period;
1677    }
1678    Ok((curve.clone(), delta > 0.0))
1679}
1680
1681/// An open boundary run from `start_point` to `end_point` along `curve`,
1682/// through `mid_point`.
1683fn open_arc(
1684    curve: &NurbsCurve,
1685    start: u64,
1686    end: u64,
1687    start_point: Vec3,
1688    end_point: Vec3,
1689    mid_point: Vec3,
1690    scale: f64,
1691) -> Result<BuiltEdge, KernelRefusal> {
1692    let fail = |e: String| {
1693        refuse_unsupported(KernelStage::Intersect, "arc", format!("{NO_EXACT_BOUNDARY}: {e}"))
1694    };
1695    if let Some((center, normal, radius)) = as_circle(curve, scale) {
1696        let x = start_point.sub(center);
1697        let x = x.sub(normal.scale(x.dot(normal))).normalized().map_err(fail)?;
1698        let mut n = normal;
1699        let mut y = n.cross(x);
1700        let mut sweep = angle_in(center, x, y, end_point);
1701        if angle_in(center, x, y, mid_point) > sweep {
1702            n = n.scale(-1.0);
1703            y = n.cross(x);
1704            sweep = angle_in(center, x, y, end_point);
1705        }
1706        let arc = make_arc(center, x, y, radius, 0.0, sweep).map_err(fail)?;
1707        let [t0, t1] = arc.domain().map_err(fail)?;
1708        return Ok(BuiltEdge { curve: arc, t0, t1, start, end, region_forward: true, region_pcurve: None });
1709    }
1710    let a = project_point_to_curve(curve, start_point).map_err(fail)?.u;
1711    let b = project_point_to_curve(curve, end_point).map_err(fail)?.u;
1712    let m = project_point_to_curve(curve, mid_point).map_err(fail)?.u;
1713    if !((a < m && m < b) || (b < m && m < a)) {
1714        return Err(refuse_unsupported(
1715            KernelStage::Intersect,
1716            "arc_seam",
1717            format!(
1718                "{NO_EXACT_BOUNDARY}: a boundary run crosses its intersection curve's seam (v1)"
1719            ),
1720        ));
1721    }
1722    let (lo, hi) = if a < b { (a, b) } else { (b, a) };
1723    let trimmed = crate::feature_pipeline::features::common::trimmed_curve(curve, lo, hi)
1724        .map_err(fail)?;
1725    let [t0, t1] = trimmed.domain().map_err(fail)?;
1726    if a < b {
1727        Ok(BuiltEdge { curve: trimmed, t0, t1, start, end, region_forward: true, region_pcurve: None })
1728    } else {
1729        Ok(BuiltEdge {
1730            curve: trimmed,
1731            t0,
1732            t1,
1733            start: end,
1734            end: start,
1735            region_forward: false,
1736            region_pcurve: None,
1737        })
1738    }
1739}
1740
1741/// A closed boundary ring that is an iso-parameter circle of the fitted
1742/// (revolved) surface, closed in that direction: the ring is taken as the
1743/// surface's own iso curve, starting on its seam, so its trim is a straight
1744/// line across the whole period — the way the kernel's revolved solids carry
1745/// their cap circles. `None` unless the iso curve IS the intersection with
1746/// the neighbour to `tolerance` (a tilted cap cuts an ellipse, not a circle).
1747fn iso_ring(
1748    surface: &NurbsSurface,
1749    intersection: &NurbsCurve,
1750    points: &[Vec3],
1751    tolerance: f64,
1752) -> Option<(NurbsCurve, NurbsCurve, bool)> {
1753    let uv: Vec<(f64, f64)> = points
1754        .iter()
1755        .map(|p| crate::project_point_to_surface(surface, *p).map(|q| (q.u, q.v)))
1756        .collect::<Result<_, _>>()
1757        .ok()?;
1758    if uv.len() < 3 {
1759        return None;
1760    }
1761    let [u0, u1] = [surface.knots_u[0], *surface.knots_u.last()?];
1762    let [v0, v1] = [surface.knots_v[0], *surface.knots_v.last()?];
1763    let mean_u = uv.iter().map(|q| q.0).sum::<f64>() / uv.len() as f64;
1764    let mean_v = uv.iter().map(|q| q.1).sum::<f64>() / uv.len() as f64;
1765    let wrap = |delta: f64, period: f64| {
1766        let mut d = delta % period;
1767        if d > period * 0.5 {
1768            d -= period;
1769        } else if d < -period * 0.5 {
1770            d += period;
1771        }
1772        d
1773    };
1774    let candidates = [
1775        // Constant v: the ring runs along u.
1776        (surface.iso_curve_v(mean_v).ok(), true),
1777        // Constant u: the ring runs along v.
1778        (surface.iso_curve_u(mean_u).ok(), false),
1779    ];
1780    for (curve, along_u) in candidates {
1781        let Some(curve) = curve else { continue };
1782        let [t0, t1] = curve.domain().ok()?;
1783        let (Ok(start), Ok(end)) = (curve.evaluate(t0), curve.evaluate(t1)) else { continue };
1784        if start.sub(end).length() > tolerance * 1e-3 {
1785            continue; // not closed in this direction
1786        }
1787        let on_intersection = (0..16).all(|k| {
1788            curve
1789                .evaluate(t0 + (t1 - t0) * (k as f64 + 0.5) / 16.0)
1790                .ok()
1791                .and_then(|p| project_point_to_curve(intersection, p).ok())
1792                .is_some_and(|q| q.distance <= tolerance)
1793        });
1794        let through_points =
1795            points.iter().all(|p| project_point_to_curve(&curve, *p).is_ok_and(|q| q.distance <= tolerance));
1796        if !(on_intersection && through_points) {
1797            continue;
1798        }
1799        let (period, a, b) = if along_u {
1800            (u1 - u0, uv[0].0, uv[1].0)
1801        } else {
1802            (v1 - v0, uv[0].1, uv[1].1)
1803        };
1804        let forward = wrap(b - a, period) > 0.0;
1805        let (from, to) = if along_u {
1806            ([u0, mean_v], [u1, mean_v])
1807        } else {
1808            ([mean_u, v0], [mean_u, v1])
1809        };
1810        let (from, to) = if forward { (from, to) } else { (to, from) };
1811        let pcurve =
1812            make_line(Vec3::new(from[0], from[1], 0.0), Vec3::new(to[0], to[1], 0.0)).ok()?;
1813        return Some((curve, pcurve, forward));
1814    }
1815    None
1816}
1817
1818/// The iso-parameter curve of `surface` through `points` (constant v, then
1819/// constant u), when it lies on `other` to `tolerance` everywhere sampled —
1820/// the exact boundary for pairs the closed-form intersector does not list
1821/// (a plane square to a torus's axis cuts it in iso circles).
1822fn iso_curve_on(
1823    surface: &NurbsSurface,
1824    other: &NurbsSurface,
1825    points: &[Vec3],
1826    tolerance: f64,
1827) -> Vec<NurbsCurve> {
1828    let Ok(uv) = points
1829        .iter()
1830        .map(|p| crate::project_point_to_surface(surface, *p).map(|q| (q.u, q.v)))
1831        .collect::<Result<Vec<_>, _>>()
1832    else {
1833        return Vec::new();
1834    };
1835    if uv.is_empty() {
1836        return Vec::new();
1837    }
1838    let mean_u = uv.iter().map(|q| q.0).sum::<f64>() / uv.len() as f64;
1839    let mean_v = uv.iter().map(|q| q.1).sum::<f64>() / uv.len() as f64;
1840    let mut out = Vec::new();
1841    for curve in [surface.iso_curve_v(mean_v), surface.iso_curve_u(mean_u)].into_iter().flatten() {
1842        let Ok([t0, t1]) = curve.domain() else { continue };
1843        let through = points
1844            .iter()
1845            .all(|p| project_point_to_curve(&curve, *p).is_ok_and(|q| q.distance <= tolerance));
1846        let on_other = (0..=32).all(|k| {
1847            curve
1848                .evaluate(t0 + (t1 - t0) * k as f64 / 32.0)
1849                .ok()
1850                .is_some_and(|p| {
1851                    // Test the carrier, not the finite patch: the fitted
1852                    // surface's margin extends an iso ruling beyond the
1853                    // neighbour. open_arc trims it to the shared run later.
1854                    if let Some(crate::AnalyticSurface::Plane { origin, u_dir, v_dir, .. }) =
1855                        other.analytic()
1856                    {
1857                        u_dir.cross(*v_dir).normalized()
1858                            .is_ok_and(|n| p.sub(*origin).dot(n).abs() <= tolerance)
1859                    } else {
1860                        crate::project_point_to_surface(other, p).is_ok_and(|q| q.distance <= tolerance)
1861                    }
1862                })
1863        });
1864        if through && on_other {
1865            out.push(curve);
1866        }
1867    }
1868    out
1869}
1870
1871/// The plane of the planar closed `ring`, framed like `old` (same normal
1872/// side, same in-plane directions as nearly as the tilt allows, a patch
1873/// covering the ring). `None` when the ring is not planar or already lies
1874/// in `old`.
1875fn reseated_plane(old: &NurbsSurface, ring: &NurbsCurve, scale: f64) -> Option<NurbsSurface> {
1876    let [t0, t1] = ring.domain().ok()?;
1877    let points: Vec<Vec3> =
1878        (0..48).filter_map(|k| ring.evaluate(t0 + (t1 - t0) * k as f64 / 48.0).ok()).collect();
1879    if points.len() < 48 {
1880        return None;
1881    }
1882    let centroid = points
1883        .iter()
1884        .fold(Vec3::new(0.0, 0.0, 0.0), |s, p| s.add(*p))
1885        .scale(1.0 / points.len() as f64);
1886    // Newell normal of the ring polygon.
1887    let mut normal = Vec3::new(0.0, 0.0, 0.0);
1888    for (i, a) in points.iter().enumerate() {
1889        let b = points[(i + 1) % points.len()];
1890        normal = normal.add(a.sub(centroid).cross(b.sub(centroid)));
1891    }
1892    let normal = normal.normalized().ok()?;
1893    let flat = points.iter().all(|p| p.sub(centroid).dot(normal).abs() <= scale * 1e-10);
1894    if !flat {
1895        return None;
1896    }
1897    let [u0, u1] = [old.knots_u[0], *old.knots_u.last()?];
1898    let [v0, v1] = [old.knots_v[0], *old.knots_v.last()?];
1899    let origin = old.evaluate(u0, v0).ok()?;
1900    let su = old.evaluate(u1, v0).ok()?.sub(origin);
1901    let sv = old.evaluate(u0, v1).ok()?.sub(origin);
1902    let old_normal = su.cross(sv).normalized().ok()?;
1903    let off = points.iter().map(|p| p.sub(origin).dot(old_normal).abs()).fold(0.0, f64::max);
1904    if off <= scale * 1e-12 {
1905        return None;
1906    }
1907    let n = if normal.dot(old_normal) >= 0.0 { normal } else { normal.scale(-1.0) };
1908    let u_dir = su.sub(n.scale(su.dot(n))).normalized().ok()?;
1909    let v_dir = n.cross(u_dir);
1910    // Cover the ring and the old patch, with a margin.
1911    let mut lo = [f64::MAX; 2];
1912    let mut hi = [f64::MIN; 2];
1913    let corners = [origin, origin.add(su), origin.add(sv), origin.add(su).add(sv)];
1914    for p in points.iter().chain(corners.iter()) {
1915        let rel = p.sub(centroid);
1916        let (a, b) = (rel.dot(u_dir), rel.dot(v_dir));
1917        lo = [lo[0].min(a), lo[1].min(b)];
1918        hi = [hi[0].max(a), hi[1].max(b)];
1919    }
1920    let margin = (hi[0] - lo[0]).max(hi[1] - lo[1]) * 0.05;
1921    let corner = centroid.add(u_dir.scale(lo[0] - margin)).add(v_dir.scale(lo[1] - margin));
1922    make_plane(corner, u_dir, v_dir, hi[0] - lo[0] + 2.0 * margin, hi[1] - lo[1] + 2.0 * margin).ok()
1923}
1924
1925// ---------------------------------------------------------------------------
1926// v2: a whole closed shell becomes one sphere or torus
1927// ---------------------------------------------------------------------------
1928
1929#[allow(clippy::too_many_arguments)]
1930fn refit_closed_shell(
1931    solid: &BrepSolid,
1932    shell_index: usize,
1933    selected_indices: &[usize],
1934    euler: i64,
1935    kind: RefitSurfaceKind,
1936    tolerance: f64,
1937    scale: f64,
1938    edge_by_id: &HashMap<u64, &EdgeRecord>,
1939    vertex_by_id: &HashMap<u64, Vec3>,
1940) -> Result<(BrepSolid, RefitReport), KernelRefusal> {
1941    let shell = &solid.shells[shell_index];
1942    if selected_indices.len() != shell.faces.len() {
1943        return Err(refuse_unsupported(
1944            KernelStage::Classify,
1945            "closed_region",
1946            format!(
1947                "the selection has no boundary but is not every face of its shell; the region \
1948                 is {NOT_DISK_OR_ANNULUS}"
1949            ),
1950        ));
1951    }
1952    // A sphere closes with Euler characteristic 2, a torus with 0. AUTO
1953    // takes the one the shell's own topology names.
1954    let kind = match kind {
1955        RefitSurfaceKind::Auto => match euler {
1956            2 => RefitSurfaceKind::Sphere,
1957            0 => RefitSurfaceKind::Torus,
1958            _ => {
1959                return Err(refuse_unsupported(
1960                    KernelStage::Classify,
1961                    "closed_shell_genus",
1962                    format!(
1963                        "the selected closed shell has Euler characteristic {euler}; \
1964                         {CLOSED_SHELL_KIND} (2 or 0)"
1965                    ),
1966                ))
1967            }
1968        },
1969        RefitSurfaceKind::Plane | RefitSurfaceKind::Cylinder | RefitSurfaceKind::Cone => {
1970            return Err(refuse_unsupported(
1971                KernelStage::Classify,
1972                "closed_shell_kind",
1973                format!(
1974                    "the selection is a whole closed shell and no {} can be one: \
1975                     {CLOSED_SHELL_KIND}",
1976                    kind.name()
1977                ),
1978            ))
1979        }
1980        other => other,
1981    };
1982    let wanted = if kind == RefitSurfaceKind::Sphere { 2 } else { 0 };
1983    if euler != wanted {
1984        return Err(refuse_unsupported(
1985            KernelStage::Classify,
1986            "closed_shell_genus",
1987            format!(
1988                "a {} {CLOSED_SHELL_GENUS}: the selected closed shell has Euler characteristic \
1989                 {euler}, a {} has {wanted}",
1990                kind.name(),
1991                kind.name()
1992            ),
1993        ));
1994    }
1995
1996    let samples = fit_samples(shell, selected_indices, edge_by_id, vertex_by_id)?;
1997    let carrier = fit_carrier(&samples, kind, tolerance, scale)?.carrier;
1998    let (rms_residual, max_residual) = residuals(&carrier, &samples.points);
1999    if max_residual > tolerance {
2000        return Err(KernelRefusal::ill_posed(
2001            KernelStage::Refine,
2002            "refit_residual",
2003            format!(
2004                "Refit Faces: the {} {RESIDUAL_OVER_TOLERANCE} {max_residual:.6} (rms \
2005                 {rms_residual:.6}) is over the tolerance {tolerance}; these faces are not \
2006                 one {} within it",
2007                carrier.kind().name(),
2008                carrier.kind().name()
2009            ),
2010        ));
2011    }
2012    coarse_facet_gate(&carrier, shell, selected_indices, edge_by_id, vertex_by_id)?;
2013    let fail = |e: String| {
2014        refuse_unsupported(KernelStage::Refine, "refit_surface", format!("{NO_FIT}: {e}"))
2015    };
2016    let primitive = match carrier {
2017        RefitCarrier::Sphere { center, radius } => {
2018            // Poles on the axis the facets' own layout suggests is irrelevant
2019            // for a whole ball; z is as good as any.
2020            crate::make_sphere_brep(center, radius, Vec3::new(0.0, 0.0, 1.0)).map_err(fail)?
2021        }
2022        RefitCarrier::Torus { center, axis, major_radius, minor_radius } => {
2023            if !(minor_radius > 0.0 && major_radius > minor_radius) {
2024                return Err(refuse_unsupported(
2025                    KernelStage::Refine,
2026                    "torus_radii",
2027                    format!(
2028                        "the fitted torus (radii {major_radius:.6}/{minor_radius:.6}) \
2029                         self-intersects; {NO_FIT} as one face (v1)"
2030                    ),
2031                ));
2032            }
2033            crate::make_torus_brep(center, axis, major_radius, minor_radius).map_err(fail)?
2034        }
2035        other => {
2036            return Err(refuse_unsupported(
2037                KernelStage::Classify,
2038                "closed_shell_kind",
2039                format!(
2040                    "the fit returned a {}; {CLOSED_SHELL_KIND}",
2041                    other.kind().name()
2042                ),
2043            ))
2044        }
2045    };
2046
2047    // The primitive is an outward solid. The selection's own outward side
2048    // decides whether its shell bounds material (outer shell) or a cavity.
2049    let prim_face = &primitive.shells[0].faces[0];
2050    let vote = region_same_sense(&prim_face.surface, &samples)?;
2051    let cavity = vote != prim_face.same_sense;
2052
2053    // Re-id the primitive's records above everything the solid holds.
2054    let base = solid
2055        .vertices
2056        .iter()
2057        .map(|v| v.id)
2058        .chain(solid.edges.iter().map(|e| e.id))
2059        .chain(solid.shells.iter().map(|s| s.id))
2060        .chain(solid.shells.iter().flat_map(|s| s.faces.iter()).flat_map(|f| {
2061            std::iter::once(f.id).chain(
2062                f.loops
2063                    .iter()
2064                    .flat_map(|l| std::iter::once(l.id).chain(l.coedges.iter().map(|c| c.id))),
2065            )
2066        }))
2067        .max()
2068        .unwrap_or(0)
2069        + 1;
2070    let keeper = &shell.faces[selected_indices[0]];
2071    let mut new_shell = primitive.shells[0].clone();
2072    new_shell.id = shell.id;
2073    for face in &mut new_shell.faces {
2074        face.id = keeper.id;
2075        face.name = keeper.name.clone();
2076        for lp in &mut face.loops {
2077            lp.id += base;
2078            for coedge in &mut lp.coedges {
2079                coedge.id += base;
2080                coedge.edge_id += base;
2081            }
2082        }
2083        if cavity {
2084            flip_face(face).map_err(fail)?;
2085        }
2086    }
2087    let new_edges: Vec<EdgeRecord> = primitive
2088        .edges
2089        .iter()
2090        .map(|e| {
2091            let mut e = e.clone();
2092            e.id += base;
2093            e.start_vertex_id += base;
2094            e.end_vertex_id += base;
2095            e
2096        })
2097        .collect();
2098    let new_vertices: Vec<VertexRecord> = primitive
2099        .vertices
2100        .iter()
2101        .map(|v| VertexRecord { id: v.id + base, point: v.point })
2102        .collect();
2103
2104    // Drop the old shell's edges and vertices (no other shell shares them).
2105    let old_edges: HashSet<u64> = shell
2106        .faces
2107        .iter()
2108        .flat_map(|f| f.loops.iter())
2109        .flat_map(|l| l.coedges.iter().map(|c| c.edge_id))
2110        .collect();
2111    let mut out = solid.clone();
2112    out.edges.retain(|e| !old_edges.contains(&e.id));
2113    out.edges.extend(new_edges);
2114    out.shells[shell_index] = new_shell;
2115    let used: HashSet<u64> =
2116        out.edges.iter().flat_map(|e| [e.start_vertex_id, e.end_vertex_id]).collect();
2117    out.vertices.retain(|v| used.contains(&v.id));
2118    out.vertices.extend(new_vertices);
2119    // The shell's genus is unchanged (checked above), so `out.genus` stands.
2120
2121    let issues = out.validate();
2122    if !issues.is_empty() {
2123        let first: Vec<String> = issues.iter().take(3).map(|i| format!("{i:?}")).collect();
2124        return Err(KernelRefusal::new(
2125            RefusalClass::InvalidResultTopology { issues: issues.len() as u32 },
2126            KernelStage::Validate,
2127            format!(
2128                "Refit Faces: the {RESULT_INVALID} ({} issue(s): {})",
2129                issues.len(),
2130                first.join("; ")
2131            ),
2132        ));
2133    }
2134    let report = RefitReport {
2135        carrier,
2136        rms_residual,
2137        max_residual,
2138        tolerance,
2139        samples: samples.points.len(),
2140        replaced_faces: selected_indices.len(),
2141        boundary_edges_before: 0,
2142        boundary_edges_after: 0,
2143        boundary_loops: 0,
2144        new_face_id: keeper.id,
2145    };
2146    Ok((out, report))
2147}
2148
2149/// Turn a face inside out: the other normal side, each loop walked the other
2150/// way (the `offset_shell::orientation` operation).
2151pub(super) fn flip_face(face: &mut FaceRecord) -> Result<(), String> {
2152    face.same_sense = !face.same_sense;
2153    for lp in &mut face.loops {
2154        lp.coedges.reverse();
2155        for coedge in &mut lp.coedges {
2156            coedge.forward = !coedge.forward;
2157            coedge.pcurve = coedge.pcurve.reversed()?;
2158        }
2159    }
2160    Ok(())
2161}
2162
2163/// The widest arc, in degrees, any selected flat face may span on the fitted
2164/// surface: a facet may be no wider than the radius of the surface it
2165/// approximates. A chord spanning `t` on radius `R` is `2 R sin(t / 2)` long,
2166/// which is at most `R` exactly when `t <= 60` degrees. The bound is inclusive:
2167/// a regular hexagonal prism (chord = radius) refits as a cylinder, and a
2168/// pentagonal one (72 degrees, chord 1.18 R) is refused.
2169///
2170/// The fit reads vertices only, and every regular polygon is cyclic, so the
2171/// corners alone never refute a coarse fit. The 16 corners of an 8-sided can
2172/// lie exactly on one sphere, whose flat caps would each span about 98
2173/// degrees of it; a box's four walls lie on a cylinder at 90 degrees each.
2174pub const MAX_FACET_SPAN_DEGREES: f64 = 60.0;
2175
2176/// Rounding slack on [`MAX_FACET_SPAN_DEGREES`], so a facet exactly on the
2177/// bound (a hexagon read through f32 coordinates and a fitted radius) is
2178/// not refused by the last digit.
2179pub const FACET_SPAN_SLACK_DEGREES: f64 = 0.1;
2180
2181/// Refuse when a selected flat face spans more than
2182/// [`MAX_FACET_SPAN_DEGREES`] of the fitted curved surface, read from how far
2183/// its vertex centroid sags below the surface: a chord of half-angle `a` on
2184/// a radius `R` sags `R (1 - cos a)`.
2185fn coarse_facet_gate(
2186    carrier: &RefitCarrier,
2187    shell: &crate::topology::ShellRecord,
2188    selected: &[usize],
2189    edges: &HashMap<u64, &EdgeRecord>,
2190    vertices: &HashMap<u64, Vec3>,
2191) -> Result<(), KernelRefusal> {
2192    if matches!(carrier, RefitCarrier::Plane { .. }) {
2193        return Ok(());
2194    }
2195    let mut worst: Option<(f64, &FaceRecord)> = None;
2196    for &f in selected {
2197        let face = &shell.faces[f];
2198        if !face.surface.is_affine().unwrap_or(false) {
2199            continue;
2200        }
2201        let mut sum = Vec3::new(0.0, 0.0, 0.0);
2202        let mut count = 0usize;
2203        for coedge in face.loops.iter().flat_map(|l| l.coedges.iter()) {
2204            let Some(edge) = edges.get(&coedge.edge_id) else { continue };
2205            let id = if coedge.forward { edge.start_vertex_id } else { edge.end_vertex_id };
2206            if let Some(p) = vertices.get(&id) {
2207                sum = sum.add(*p);
2208                count += 1;
2209            }
2210        }
2211        if count < 3 {
2212            continue;
2213        }
2214        let centroid = sum.scale(1.0 / count as f64);
2215        let radius = local_radius(carrier, centroid);
2216        if !(radius > 0.0) {
2217            continue;
2218        }
2219        let sag = carrier.distance(centroid);
2220        let span = 2.0 * (1.0 - (sag / radius).min(2.0)).clamp(-1.0, 1.0).acos().to_degrees();
2221        if worst.map_or(true, |(w, _)| span > w) {
2222            worst = Some((span, face));
2223        }
2224    }
2225    match worst {
2226        Some((span, face)) if span > MAX_FACET_SPAN_DEGREES + FACET_SPAN_SLACK_DEGREES => {
2227            Err(KernelRefusal::ill_posed(
2228            KernelStage::Refine,
2229            "refit_facets_coarse",
2230            format!(
2231                "Refit Faces: the selection is {FACETS_TOO_COARSE} to be one {}: {} spans \
2232                 {span:.1} degrees of the fitted surface (at most {MAX_FACET_SPAN_DEGREES}); \
2233                 a facet may be no wider than the radius it approximates",
2234                carrier.kind().name(),
2235                face_label(face)
2236            ),
2237        ))
2238        }
2239        _ => Ok(()),
2240    }
2241}
2242
2243/// The surface's smaller principal radius of curvature near `point`.
2244fn local_radius(carrier: &RefitCarrier, point: Vec3) -> f64 {
2245    match *carrier {
2246        RefitCarrier::Plane { .. } => f64::INFINITY,
2247        RefitCarrier::Cylinder { radius, .. } => radius,
2248        RefitCarrier::Sphere { radius, .. } => radius,
2249        RefitCarrier::Torus { minor_radius, .. } => minor_radius,
2250        RefitCarrier::Cone { apex, axis, half_angle } => {
2251            let rel = point.sub(apex);
2252            let radial = rel.sub(axis.scale(rel.dot(axis))).length();
2253            radial / half_angle.cos()
2254        }
2255    }
2256}
2257