Skip to main content

brep_reconstruction/
import_session.rs

1//! Interactive mesh import: the phases of a reconstruction as data an
2//! application can show while it runs, and the per-region refit a user drives
3//! from them.
4//!
5//! The one-shot conversion ([`crate::stl_conversion`]) is unchanged by this
6//! module: it reads the same segmentation the mixed builder starts from
7//! ([`crate::segment_for_hybrid`], per SOURCE triangle) and reports it, and it
8//! refits one region at a time on its own triangles. Nothing here is a second
9//! segmentation or a second classifier.
10//!
11//! * [`analyze_regions`] — the regions the builder will try to fit, each with
12//!   the carrier the segmentation proposed (or none: the region stays
13//!   triangles) and the residual against the bar it had to meet.
14//! * [`fit_region`] — refit ONE region as a chosen kind (or the best of all
15//!   kinds) against a user tolerance: plane, cylinder, cone, sphere, torus
16//!   through RANSAC's constrained fit, or a least-squares B-spline surface
17//!   ([`fit_nurbs_region`]) where no analytic surface works.
18
19use crate::stl_conversion::{segmentation_options, StlConversionOptions};
20use crate::{
21    reconstruct_surface, AnalyticSurface, Mesh, RecognitionOptions, SurfaceHint, SurfaceType, Vec3,
22};
23use brep_kernel::{RegionCarrier, UNASSIGNED_REGION};
24use serde::{Deserialize, Serialize};
25
26/// What a region is (proposed or fitted) as.
27#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
28#[serde(rename_all = "snake_case")]
29pub enum RegionKind {
30    Plane,
31    Cylinder,
32    Cone,
33    Sphere,
34    Torus,
35    /// A least-squares B-spline surface.
36    Nurbs,
37    /// No surface: the region stays mesh triangles.
38    Faceted,
39}
40
41impl RegionKind {
42    pub fn label(self) -> &'static str {
43        match self {
44            Self::Plane => "plane",
45            Self::Cylinder => "cylinder",
46            Self::Cone => "cone",
47            Self::Sphere => "sphere",
48            Self::Torus => "torus",
49            Self::Nurbs => "NURBS surface",
50            Self::Faceted => "triangles",
51        }
52    }
53
54    fn of_carrier(carrier: &RegionCarrier) -> Self {
55        match carrier {
56            RegionCarrier::Plane { .. } => Self::Plane,
57            RegionCarrier::Cylinder { .. } => Self::Cylinder,
58            RegionCarrier::Cone { .. } => Self::Cone,
59            RegionCarrier::Sphere { .. } => Self::Sphere,
60            RegionCarrier::Torus { .. } => Self::Torus,
61            RegionCarrier::Freeform => Self::Faceted,
62        }
63    }
64}
65
66/// One region of the segmentation the mixed builder starts from.
67#[derive(Clone, Debug, Deserialize, Serialize)]
68pub struct ImportRegion {
69    /// Segmentation region id (the value in [`ImportRegions::triangle_regions`]).
70    pub id: u32,
71    /// Source triangles in it.
72    pub triangles: usize,
73    pub area: f64,
74    /// The proposed kind; [`RegionKind::Faceted`] when no carrier met the bar.
75    pub kind: RegionKind,
76    /// The segmentation's carrier (`Freeform` when faceted).
77    pub carrier: RegionCarrier,
78    /// Largest vertex distance from the carrier (0 for a faceted region:
79    /// nothing was accepted to measure against).
80    pub max_deviation: f64,
81    pub rms_deviation: f64,
82    pub max_normal_angle_deg: f64,
83    /// The absolute distance a carrier had to meet: the relative fit tolerance
84    /// times the region's bounding-box diagonal.
85    pub fit_bar: f64,
86    /// Bounding-box diagonal of the region, mm.
87    pub diagonal: f64,
88    /// Why the region stays triangles, in words; `None` when it is proposed
89    /// as a surface.
90    pub reason: Option<String>,
91}
92
93/// The segmentation phase of an import.
94#[derive(Clone, Debug, Default, Deserialize, Serialize)]
95pub struct ImportRegions {
96    /// Region id of every source triangle ([`UNASSIGNED_REGION`] for a
97    /// degenerate triangle no region took).
98    pub triangle_regions: Vec<u32>,
99    pub regions: Vec<ImportRegion>,
100    /// The relative fit tolerance and normal gate the segmentation used.
101    pub fit_tolerance: f64,
102    pub normal_tolerance_deg: f64,
103}
104
105impl ImportRegions {
106    /// Source triangles of region `id`, ascending.
107    pub fn triangles_of(&self, id: u32) -> Vec<u32> {
108        self.triangle_regions
109            .iter()
110            .enumerate()
111            .filter(|(_, &region)| region == id)
112            .map(|(t, _)| t as u32)
113            .collect()
114    }
115}
116
117/// The regions the mixed builder will start from, per source triangle.
118///
119/// `positions`/`indices` are the welded buffers the conversion reads (the
120/// reader's output); `options` the conversion's own, so the segmentation is
121/// the one the builder runs.
122pub fn analyze_regions(
123    positions: &[f64],
124    indices: &[u32],
125    options: &StlConversionOptions,
126) -> Result<ImportRegions, String> {
127    let segment_options = segmentation_options(positions, options);
128    let segmentation = crate::segment_for_hybrid(positions, indices, &segment_options)?;
129    let triangle_count = indices.len() / 3;
130    if segmentation.triangle_region_ids.len() != triangle_count {
131        return Err(format!(
132            "the segmentation labelled {} triangles of {triangle_count}",
133            segmentation.triangle_region_ids.len()
134        ));
135    }
136    let regions = segmentation
137        .regions
138        .iter()
139        .filter(|region| region.triangle_count > 0)
140        .map(|region| {
141            let (low, high) = (region.bbox_min, region.bbox_max);
142            let diagonal = ((high.x - low.x).powi(2) + (high.y - low.y).powi(2) + (high.z - low.z).powi(2)).sqrt();
143            let fit_bar = segment_options.fit_tolerance * diagonal;
144            let kind = RegionKind::of_carrier(&region.carrier);
145            let reason = (kind == RegionKind::Faceted).then(|| {
146                if region.triangle_count < segment_options.min_region_triangles {
147                    format!(
148                        "too few triangles to fit ({} < {})",
149                        region.triangle_count, segment_options.min_region_triangles
150                    )
151                } else {
152                    format!(
153                        "no plane, cylinder, cone, sphere or torus within {} mm ({:.3}% of the region size) \
154                         and {:.1}° of the triangle normals",
155                        short(fit_bar),
156                        segment_options.fit_tolerance * 100.0,
157                        segment_options.normal_tolerance_deg
158                    )
159                }
160            });
161            ImportRegion {
162                id: region.id,
163                triangles: region.triangle_count,
164                area: region.area,
165                kind,
166                carrier: region.carrier.clone(),
167                max_deviation: region.max_deviation,
168                rms_deviation: region.rms_deviation,
169                max_normal_angle_deg: region.max_normal_angle_deg,
170                fit_bar,
171                diagonal,
172                reason,
173            }
174        })
175        .collect();
176    Ok(ImportRegions {
177        triangle_regions: segmentation.triangle_region_ids,
178        regions,
179        fit_tolerance: segment_options.fit_tolerance,
180        normal_tolerance_deg: segment_options.normal_tolerance_deg,
181    })
182}
183
184/// A distance in mm for a message: three significant figures.
185fn short(value: f64) -> String {
186    if value == 0.0 || !value.is_finite() {
187        return format!("{value}");
188    }
189    let digits = (2 - value.abs().log10().floor() as i32).max(0) as usize;
190    format!("{value:.digits$}")
191}
192
193/// What to fit a region as.
194#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Deserialize, Serialize)]
195#[serde(rename_all = "snake_case")]
196pub enum FitKind {
197    /// The simplest analytic kind within tolerance, else a NURBS surface.
198    #[default]
199    Auto,
200    Plane,
201    Cylinder,
202    Cone,
203    Sphere,
204    Torus,
205    Nurbs,
206}
207
208impl FitKind {
209    pub const ALL: [FitKind; 7] = [
210        FitKind::Auto,
211        FitKind::Plane,
212        FitKind::Cylinder,
213        FitKind::Cone,
214        FitKind::Sphere,
215        FitKind::Torus,
216        FitKind::Nurbs,
217    ];
218
219    pub fn label(self) -> &'static str {
220        match self {
221            Self::Auto => "Best fit",
222            Self::Plane => "Plane",
223            Self::Cylinder => "Cylinder",
224            Self::Cone => "Cone",
225            Self::Sphere => "Sphere",
226            Self::Torus => "Torus",
227            Self::Nurbs => "NURBS surface",
228        }
229    }
230
231    fn surface_type(self) -> Option<SurfaceType> {
232        match self {
233            Self::Plane => Some(SurfaceType::Plane),
234            Self::Cylinder => Some(SurfaceType::Cylinder),
235            Self::Cone => Some(SurfaceType::Cone),
236            Self::Sphere => Some(SurfaceType::Sphere),
237            Self::Torus => Some(SurfaceType::Torus),
238            Self::Auto | Self::Nurbs => None,
239        }
240    }
241}
242
243/// Refit one region.
244#[derive(Clone, Debug, Deserialize, Serialize)]
245pub struct RegionFitRequest {
246    /// The region's source triangles.
247    pub triangles: Vec<u32>,
248    pub kind: FitKind,
249    /// Largest vertex distance accepted, mm.
250    pub tolerance: f64,
251    /// Largest angle between a triangle normal and the surface normal at its
252    /// centroid, degrees (analytic kinds).
253    pub normal_tolerance_deg: f64,
254}
255
256/// One kind tried by [`fit_region`].
257#[derive(Clone, Debug, Deserialize, Serialize)]
258pub struct FitAttempt {
259    pub kind: RegionKind,
260    /// `None` when the kind could not be fitted at all.
261    pub max_deviation: Option<f64>,
262    pub max_normal_angle_deg: Option<f64>,
263    pub within_tolerance: bool,
264    pub note: Option<String>,
265}
266
267/// The result of [`fit_region`].
268#[derive(Clone, Debug, Deserialize, Serialize)]
269pub struct RegionFit {
270    /// The kind kept: the requested one, or for [`FitKind::Auto`] the first
271    /// within tolerance (or the closest when none is).
272    pub kind: Option<RegionKind>,
273    pub within_tolerance: bool,
274    pub max_deviation: f64,
275    pub rms_deviation: f64,
276    pub max_normal_angle_deg: f64,
277    pub tolerance: f64,
278    pub surface: Option<AnalyticSurface>,
279    pub nurbs: Option<NurbsFit>,
280    /// Every kind tried, in order.
281    pub attempts: Vec<FitAttempt>,
282    /// The result in words, for the panel.
283    pub message: String,
284}
285
286/// The region's triangles as a mesh of their own (vertices renumbered).
287fn region_mesh(positions: &[f64], indices: &[u32], triangles: &[u32]) -> Result<Mesh, String> {
288    let vertex_count = positions.len() / 3;
289    let mut map = std::collections::HashMap::<u32, u32>::new();
290    let mut vertices = Vec::new();
291    let mut local = Vec::with_capacity(triangles.len());
292    for &t in triangles {
293        let t = t as usize;
294        if 3 * t + 2 >= indices.len() {
295            return Err(format!("triangle {t} is outside the mesh"));
296        }
297        let mut corners = [0u32; 3];
298        for (k, corner) in corners.iter_mut().enumerate() {
299            let v = indices[3 * t + k];
300            if v as usize >= vertex_count {
301                return Err(format!("triangle {t} names vertex {v} outside the mesh"));
302            }
303            *corner = *map.entry(v).or_insert_with(|| {
304                let p = &positions[3 * v as usize..3 * v as usize + 3];
305                vertices.push(Vec3::new(p[0], p[1], p[2]));
306                (vertices.len() - 1) as u32
307            });
308        }
309        local.push(corners);
310    }
311    if local.is_empty() {
312        return Err("the region has no triangles".into());
313    }
314    Ok(Mesh::new(vertices, local))
315}
316
317fn triangle_normal(mesh: &Mesh, triangle: [u32; 3]) -> Option<(Vec3, Vec3)> {
318    let [a, b, c] = triangle.map(|v| mesh.vertices[v as usize]);
319    let normal = (b - a).cross(c - a).normalized()?;
320    Some(((a + b + c) * (1.0 / 3.0), normal))
321}
322
323/// Deviation of the mesh from an analytic surface: (max, rms) vertex
324/// distance and the largest triangle-normal angle (degrees, orientation-free).
325fn analytic_deviation(mesh: &Mesh, surface: AnalyticSurface) -> (f64, f64, f64) {
326    let mut max = 0.0f64;
327    let mut sum = 0.0;
328    for &p in &mesh.vertices {
329        let d = surface.signed_distance(p).abs();
330        max = max.max(d);
331        sum += d * d;
332    }
333    let rms = (sum / mesh.vertices.len().max(1) as f64).sqrt();
334    let mut angle = 0.0f64;
335    for &triangle in &mesh.triangles {
336        let Some((centroid, normal)) = triangle_normal(mesh, triangle) else { continue };
337        if let Some(surface_normal) = surface.normal_at(centroid) {
338            let cos = normal.dot(surface_normal).abs().min(1.0);
339            angle = angle.max(cos.acos().to_degrees());
340        }
341    }
342    (max, rms, angle)
343}
344
345fn mesh_diagonal(mesh: &Mesh) -> f64 {
346    let mut low = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
347    let mut high = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
348    for p in &mesh.vertices {
349        low = Vec3::new(low.x.min(p.x), low.y.min(p.y), low.z.min(p.z));
350        high = Vec3::new(high.x.max(p.x), high.y.max(p.y), high.z.max(p.z));
351    }
352    (high - low).length()
353}
354
355/// The best analytic `kind` for the region, measured against nothing yet:
356/// RANSAC's constrained fit with its gates opened to the region's size, so a
357/// fit that misses the user's tolerance still comes back with its residual.
358fn fit_analytic(mesh: &Mesh, kind: SurfaceType) -> Result<AnalyticSurface, String> {
359    let diagonal = mesh_diagonal(mesh).max(f64::MIN_POSITIVE);
360    let mut options = RecognitionOptions::default();
361    options.distance_tolerance = diagonal;
362    options.relative_tolerance = 0.0;
363    options.normal_tolerance = 89.0f64.to_radians();
364    options.minimum_support = 1;
365    options.minimum_support_area = 0.0;
366    options.deterministic_seed = Some(0);
367    options.respect_features = false;
368    options.collect_phase_timings = false;
369    options.threads = 1;
370    // Welded mesh vertices are the samples ON the design surface; facet
371    // centroids lie on chords (the conversion's own default).
372    options.sampling = crate::SamplingMode::Vertices;
373    let triangles: Vec<usize> = (0..mesh.triangles.len()).collect();
374    reconstruct_surface(mesh, &triangles, &SurfaceHint::KnownType { surface_type: kind }, &options)
375        .map(|fit| fit.surface)
376        .map_err(|error| error.to_string())
377}
378
379fn kind_of(surface_type: SurfaceType) -> RegionKind {
380    match surface_type {
381        SurfaceType::Plane => RegionKind::Plane,
382        SurfaceType::Cylinder => RegionKind::Cylinder,
383        SurfaceType::Cone => RegionKind::Cone,
384        SurfaceType::Sphere => RegionKind::Sphere,
385        SurfaceType::Torus => RegionKind::Torus,
386    }
387}
388
389/// Refit one region as `request.kind` against `request.tolerance`.
390pub fn fit_region(positions: &[f64], indices: &[u32], request: &RegionFitRequest) -> RegionFit {
391    let mut fit = RegionFit {
392        kind: None,
393        within_tolerance: false,
394        max_deviation: f64::INFINITY,
395        rms_deviation: f64::INFINITY,
396        max_normal_angle_deg: 0.0,
397        tolerance: request.tolerance,
398        surface: None,
399        nurbs: None,
400        attempts: Vec::new(),
401        message: String::new(),
402    };
403    if !(request.tolerance.is_finite() && request.tolerance > 0.0) {
404        fit.message = "the tolerance must be a positive distance".into();
405        return fit;
406    }
407    let mesh = match region_mesh(positions, indices, &request.triangles) {
408        Ok(mesh) => mesh,
409        Err(error) => {
410            fit.message = error;
411            return fit;
412        }
413    };
414    let analytic: Vec<SurfaceType> = match request.kind {
415        FitKind::Auto => vec![
416            SurfaceType::Plane,
417            SurfaceType::Cylinder,
418            SurfaceType::Cone,
419            SurfaceType::Sphere,
420            SurfaceType::Torus,
421        ],
422        FitKind::Nurbs => Vec::new(),
423        kind => vec![kind.surface_type().unwrap()],
424    };
425    // The best attempt so far, by (within tolerance first, then distance).
426    let mut best: Option<(bool, f64)> = None;
427    for surface_type in analytic {
428        let kind = kind_of(surface_type);
429        match fit_analytic(&mesh, surface_type) {
430            Ok(surface) => {
431                let (max, rms, angle) = analytic_deviation(&mesh, surface);
432                let within = max <= request.tolerance && angle <= request.normal_tolerance_deg;
433                fit.attempts.push(FitAttempt {
434                    kind,
435                    max_deviation: Some(max),
436                    max_normal_angle_deg: Some(angle),
437                    within_tolerance: within,
438                    note: None,
439                });
440                let better = match best {
441                    None => true,
442                    Some((best_within, best_max)) => (within && !best_within) || (within == best_within && !best_within && max < best_max),
443                };
444                if better {
445                    best = Some((within, max));
446                    fit.kind = Some(kind);
447                    fit.surface = Some(surface);
448                    fit.max_deviation = max;
449                    fit.rms_deviation = rms;
450                    fit.max_normal_angle_deg = angle;
451                    fit.within_tolerance = within;
452                }
453                // Auto keeps the SIMPLEST kind within tolerance.
454                if within && request.kind == FitKind::Auto {
455                    break;
456                }
457            }
458            Err(error) => fit.attempts.push(FitAttempt {
459                kind,
460                max_deviation: None,
461                max_normal_angle_deg: None,
462                within_tolerance: false,
463                note: Some(error),
464            }),
465        }
466    }
467    let try_nurbs = request.kind == FitKind::Nurbs || (request.kind == FitKind::Auto && !fit.within_tolerance);
468    if try_nurbs {
469        match fit_nurbs_region(&mesh, request.tolerance) {
470            Ok(nurbs) => {
471                let within = nurbs.max_deviation <= request.tolerance;
472                fit.attempts.push(FitAttempt {
473                    kind: RegionKind::Nurbs,
474                    max_deviation: Some(nurbs.max_deviation),
475                    max_normal_angle_deg: None,
476                    within_tolerance: within,
477                    note: Some(format!("{} × {} control points", nurbs.count_u, nurbs.count_v)),
478                });
479                let better = match best {
480                    None => true,
481                    Some((best_within, best_max)) => (within && !best_within) || (within == best_within && !best_within && nurbs.max_deviation < best_max),
482                } || request.kind == FitKind::Nurbs;
483                if better {
484                    fit.kind = Some(RegionKind::Nurbs);
485                    fit.surface = None;
486                    fit.max_deviation = nurbs.max_deviation;
487                    fit.rms_deviation = nurbs.rms_deviation;
488                    fit.max_normal_angle_deg = 0.0;
489                    fit.within_tolerance = within;
490                    fit.nurbs = Some(nurbs);
491                }
492            }
493            Err(error) => fit.attempts.push(FitAttempt {
494                kind: RegionKind::Nurbs,
495                max_deviation: None,
496                max_normal_angle_deg: None,
497                within_tolerance: false,
498                note: Some(error),
499            }),
500        }
501    }
502    fit.message = match fit.kind {
503        None => {
504            let notes = fit
505                .attempts
506                .iter()
507                .filter_map(|attempt| attempt.note.as_ref().map(|note| format!("{}: {note}", attempt.kind.label())))
508                .collect::<Vec<_>>();
509            format!("No surface could be fitted. {}", notes.join("; "))
510        }
511        Some(kind) if fit.within_tolerance => format!(
512            "Fits as a {} — largest deviation {} mm (tolerance {} mm).",
513            kind.label(),
514            short(fit.max_deviation),
515            short(request.tolerance)
516        ),
517        Some(kind) => {
518            let normal = kind != RegionKind::Nurbs
519                && fit.max_deviation <= request.tolerance
520                && fit.max_normal_angle_deg > request.normal_tolerance_deg;
521            if normal {
522                format!(
523                    "Closest is a {}, within {} mm, but its normals differ from the triangles' by {:.2}° (limit {:.2}°).",
524                    kind.label(),
525                    short(fit.max_deviation),
526                    fit.max_normal_angle_deg,
527                    request.normal_tolerance_deg
528                )
529            } else {
530                format!(
531                    "Does not fit: the closest is a {} at {} mm, over the {} mm tolerance.",
532                    kind.label(),
533                    short(fit.max_deviation),
534                    short(request.tolerance)
535                )
536            }
537        }
538    };
539    fit
540}
541
542/// A least-squares B-spline surface fitted to a region.
543#[derive(Clone, Debug, Deserialize, Serialize)]
544pub struct NurbsFit {
545    pub degree: usize,
546    pub count_u: usize,
547    pub count_v: usize,
548    pub knots_u: Vec<f64>,
549    pub knots_v: Vec<f64>,
550    /// `count_u * count_v` control points, `u`-major (`[i * count_v + j]`).
551    pub control_points: Vec<[f64; 3]>,
552    /// Largest distance from a vertex to the surface point at its fitting
553    /// parameter — an upper bound on its distance to the surface.
554    pub max_deviation: f64,
555    pub rms_deviation: f64,
556}
557
558impl NurbsFit {
559    /// The fit as a kernel surface (unit weights).
560    pub fn to_kernel_surface(&self) -> Result<brep_kernel::NurbsSurface, String> {
561        let rows = (0..self.count_u)
562            .map(|i| {
563                (0..self.count_v)
564                    .map(|j| {
565                        let p = self.control_points[i * self.count_v + j];
566                        brep_kernel::Vec4::from_point(brep_kernel::Vec3::new(p[0], p[1], p[2]), 1.0)
567                    })
568                    .collect()
569            })
570            .collect();
571        brep_kernel::NurbsSurface::new(
572            self.degree,
573            self.degree,
574            self.knots_u.clone(),
575            self.knots_v.clone(),
576            rows,
577        )
578    }
579
580    /// The surface point at `(u, v)` in `[0, 1]²`.
581    pub fn evaluate(&self, u: f64, v: f64) -> [f64; 3] {
582        let (span_u, basis_u) = basis(&self.knots_u, self.degree, self.count_u, u);
583        let (span_v, basis_v) = basis(&self.knots_v, self.degree, self.count_v, v);
584        let mut point = [0.0; 3];
585        for (a, bu) in basis_u.iter().enumerate() {
586            for (b, bv) in basis_v.iter().enumerate() {
587                let p = self.control_points[(span_u - self.degree + a) * self.count_v + span_v - self.degree + b];
588                for k in 0..3 {
589                    point[k] += bu * bv * p[k];
590                }
591            }
592        }
593        point
594    }
595}
596
597/// Clamped uniform knots for `count` control points of `degree` on [0, 1].
598fn clamped_knots(count: usize, degree: usize) -> Vec<f64> {
599    let interior = count - degree - 1;
600    let mut knots = vec![0.0; degree + 1];
601    knots.extend((1..=interior).map(|k| k as f64 / (interior + 1) as f64));
602    knots.extend(std::iter::repeat(1.0).take(degree + 1));
603    knots
604}
605
606/// The span index and the `degree + 1` non-zero basis values at `t`
607/// (Cox–de Boor, The NURBS Book A2.1/A2.2).
608fn basis(knots: &[f64], degree: usize, count: usize, t: f64) -> (usize, Vec<f64>) {
609    let t = t.clamp(knots[degree], knots[count]);
610    let span = if t >= knots[count] {
611        count - 1
612    } else {
613        let mut low = degree;
614        let mut high = count;
615        while high - low > 1 {
616            let mid = (low + high) / 2;
617            if t < knots[mid] {
618                high = mid;
619            } else {
620                low = mid;
621            }
622        }
623        low
624    };
625    let mut values = vec![0.0; degree + 1];
626    let mut left = vec![0.0; degree + 1];
627    let mut right = vec![0.0; degree + 1];
628    values[0] = 1.0;
629    for j in 1..=degree {
630        left[j] = t - knots[span + 1 - j];
631        right[j] = knots[span + j] - t;
632        let mut saved = 0.0;
633        for r in 0..j {
634            let denominator = right[r + 1] + left[j - r];
635            let temp = if denominator == 0.0 { 0.0 } else { values[r] / denominator };
636            values[r] = saved + right[r + 1] * temp;
637            saved = left[j - r] * temp;
638        }
639        values[j] = saved;
640    }
641    (span, values)
642}
643
644/// Principal axes of a point set: centroid and three unit axes, largest
645/// spread first (Jacobi on the 3×3 covariance).
646fn principal_frame(points: &[Vec3]) -> (Vec3, [Vec3; 3]) {
647    let n = points.len().max(1) as f64;
648    let centroid = points.iter().fold(Vec3::new(0.0, 0.0, 0.0), |sum, &p| sum + p) * (1.0 / n);
649    let mut c = [[0.0f64; 3]; 3];
650    for &p in points {
651        let d = [p.x - centroid.x, p.y - centroid.y, p.z - centroid.z];
652        for i in 0..3 {
653            for j in 0..3 {
654                c[i][j] += d[i] * d[j];
655            }
656        }
657    }
658    let mut v = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
659    for _ in 0..50 {
660        let (mut p, mut q, mut largest) = (0, 1, 0.0f64);
661        for i in 0..3 {
662            for j in i + 1..3 {
663                if c[i][j].abs() > largest {
664                    largest = c[i][j].abs();
665                    p = i;
666                    q = j;
667                }
668            }
669        }
670        if largest <= 1e-300 {
671            break;
672        }
673        let theta = 0.5 * (2.0 * c[p][q]).atan2(c[q][q] - c[p][p]);
674        let (s, co) = theta.sin_cos();
675        let mut next = c;
676        for k in 0..3 {
677            next[k][p] = co * c[k][p] - s * c[k][q];
678            next[k][q] = s * c[k][p] + co * c[k][q];
679        }
680        let rotated = next;
681        for k in 0..3 {
682            next[p][k] = co * rotated[p][k] - s * rotated[q][k];
683            next[q][k] = s * rotated[p][k] + co * rotated[q][k];
684        }
685        c = next;
686        for row in &mut v {
687            let (a, b) = (row[p], row[q]);
688            row[p] = co * a - s * b;
689            row[q] = s * a + co * b;
690        }
691    }
692    let mut order = [0usize, 1, 2];
693    order.sort_by(|&a, &b| c[b][b].total_cmp(&c[a][a]));
694    let axis = |k: usize| Vec3::new(v[0][k], v[1][k], v[2][k]);
695    (centroid, [axis(order[0]), axis(order[1]), axis(order[2])])
696}
697
698/// Dense symmetric positive-definite solve for several right-hand sides
699/// (Cholesky). `matrix` is `n × n` row-major.
700fn cholesky_solve(mut matrix: Vec<f64>, n: usize, rhs: &mut [Vec<f64>]) -> Result<(), String> {
701    for j in 0..n {
702        let mut diagonal = matrix[j * n + j];
703        for k in 0..j {
704            diagonal -= matrix[j * n + k] * matrix[j * n + k];
705        }
706        if !(diagonal > 0.0) {
707            return Err("the least-squares system is singular".into());
708        }
709        let diagonal = diagonal.sqrt();
710        matrix[j * n + j] = diagonal;
711        for i in j + 1..n {
712            let mut value = matrix[i * n + j];
713            for k in 0..j {
714                value -= matrix[i * n + k] * matrix[j * n + k];
715            }
716            matrix[i * n + j] = value / diagonal;
717        }
718    }
719    for b in rhs.iter_mut() {
720        for i in 0..n {
721            let mut value = b[i];
722            for k in 0..i {
723                value -= matrix[i * n + k] * b[k];
724            }
725            b[i] = value / matrix[i * n + i];
726        }
727        for i in (0..n).rev() {
728            let mut value = b[i];
729            for k in i + 1..n {
730                value -= matrix[k * n + i] * b[k];
731            }
732            b[i] = value / matrix[i * n + i];
733        }
734    }
735    Ok(())
736}
737
738/// Control-net sizes tried, coarse to fine; the first whose fit meets the
739/// tolerance is kept (the finest otherwise).
740const NURBS_NETS: [usize; 9] = [4, 5, 6, 8, 10, 12, 16, 20, 24];
741const NURBS_DEGREE: usize = 3;
742
743/// Fit a cubic B-spline surface to a region's vertices by least squares.
744///
745/// The vertices are parametrized by projection onto the region's best-fit
746/// plane, so the region must not fold over itself seen from that plane (a
747/// closed tube, a deep cup): such a region is refused with the reason. A
748/// small smoothing term keeps control points with no vertex under them
749/// determined. Nets grow from 4 × 4 until the largest deviation meets
750/// `tolerance`.
751pub fn fit_nurbs_region(mesh: &Mesh, tolerance: f64) -> Result<NurbsFit, String> {
752    let points = &mesh.vertices;
753    if points.len() < 4 {
754        return Err("a NURBS surface needs at least four vertices".into());
755    }
756    let (centroid, [e1, e2, e3]) = principal_frame(points);
757    let uv: Vec<[f64; 2]> = points
758        .iter()
759        .map(|&p| {
760            let d = p - centroid;
761            [d.dot(e1), d.dot(e2)]
762        })
763        .collect();
764    // Fold check: the triangles must keep one winding in the projection.
765    // Scan noise flips the odd sliver, so a fold is a flipped AREA beyond a
766    // thousandth of the whole, not one flipped triangle.
767    let mut positive = 0.0;
768    let mut negative = 0.0;
769    for triangle in &mesh.triangles {
770        let [a, b, c] = triangle.map(|v| uv[v as usize]);
771        let area = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
772        if area > 0.0 {
773            positive += area;
774        } else {
775            negative -= area;
776        }
777    }
778    let total = positive + negative;
779    let surface_area: f64 = mesh
780        .triangles
781        .iter()
782        .map(|triangle| {
783            let [a, b, c] = triangle.map(|v| points[v as usize]);
784            0.5 * (b - a).cross(c - a).length()
785        })
786        .sum();
787    // Twice the projected area against twice the true area: a wall seen
788    // edge-on (a tube's) projects to nothing, a fold cancels itself.
789    if total <= 1e-3 * 2.0 * surface_area || positive.min(negative) > 1e-3 * total {
790        return Err(
791            "the region folds over itself seen from its best-fit plane (a tube or a deep cup); \
792             split it, or fit it as a cylinder, cone, sphere or torus"
793                .into(),
794        );
795    }
796    let _ = e3;
797    let (mut low, mut high) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]);
798    for p in &uv {
799        for k in 0..2 {
800            low[k] = low[k].min(p[k]);
801            high[k] = high[k].max(p[k]);
802        }
803    }
804    let span = [(high[0] - low[0]).max(f64::MIN_POSITIVE), (high[1] - low[1]).max(f64::MIN_POSITIVE)];
805    let params: Vec<[f64; 2]> = uv
806        .iter()
807        .map(|p| [(p[0] - low[0]) / span[0], (p[1] - low[1]) / span[1]])
808        .collect();
809    let mut last: Option<NurbsFit> = None;
810    for &net in &NURBS_NETS {
811        let fit = nurbs_least_squares(points, &params, net, net)?;
812        let done = fit.max_deviation <= tolerance;
813        last = Some(fit);
814        if done {
815            break;
816        }
817    }
818    Ok(last.unwrap())
819}
820
821fn nurbs_least_squares(points: &[Vec3], params: &[[f64; 2]], count_u: usize, count_v: usize) -> Result<NurbsFit, String> {
822    let degree = NURBS_DEGREE.min(count_u - 1).min(count_v - 1);
823    let knots_u = clamped_knots(count_u, degree);
824    let knots_v = clamped_knots(count_v, degree);
825    let n = count_u * count_v;
826    let mut normal = vec![0.0; n * n];
827    let mut rhs = vec![vec![0.0; n]; 3];
828    let mut rows = Vec::with_capacity(points.len());
829    for (point, param) in points.iter().zip(params) {
830        let (span_u, basis_u) = basis(&knots_u, degree, count_u, param[0]);
831        let (span_v, basis_v) = basis(&knots_v, degree, count_v, param[1]);
832        let mut row = Vec::with_capacity((degree + 1) * (degree + 1));
833        for (a, bu) in basis_u.iter().enumerate() {
834            for (b, bv) in basis_v.iter().enumerate() {
835                row.push(((span_u - degree + a) * count_v + span_v - degree + b, bu * bv));
836            }
837        }
838        for &(i, wi) in &row {
839            for &(j, wj) in &row {
840                normal[i * n + j] += wi * wj;
841            }
842            rhs[0][i] += wi * point.x;
843            rhs[1][i] += wi * point.y;
844            rhs[2][i] += wi * point.z;
845        }
846        rows.push(row);
847    }
848    // Smoothing: second differences of the control net in both directions,
849    // weighted to a small fraction of the data term.
850    let trace = (0..n).map(|i| normal[i * n + i]).sum::<f64>() / n as f64;
851    let weight = 1e-6 * trace.max(1e-12);
852    let mut add_difference = |cells: [usize; 3]| {
853        let coefficients = [1.0, -2.0, 1.0];
854        for (a, &i) in cells.iter().enumerate() {
855            for (b, &j) in cells.iter().enumerate() {
856                normal[i * n + j] += weight * coefficients[a] * coefficients[b];
857            }
858        }
859    };
860    for i in 0..count_u {
861        for j in 1..count_v - 1 {
862            add_difference([i * count_v + j - 1, i * count_v + j, i * count_v + j + 1]);
863        }
864    }
865    for j in 0..count_v {
866        for i in 1..count_u - 1 {
867            add_difference([(i - 1) * count_v + j, i * count_v + j, (i + 1) * count_v + j]);
868        }
869    }
870    // A tiny ridge keeps an all-empty corner of the net determined.
871    for i in 0..n {
872        normal[i * n + i] += 1e-12 * trace.max(1e-12);
873    }
874    cholesky_solve(normal, n, &mut rhs)?;
875    let control_points: Vec<[f64; 3]> = (0..n).map(|i| [rhs[0][i], rhs[1][i], rhs[2][i]]).collect();
876    let mut max = 0.0f64;
877    let mut sum = 0.0;
878    for (point, row) in points.iter().zip(&rows) {
879        let mut s = [0.0; 3];
880        for &(i, w) in row {
881            for k in 0..3 {
882                s[k] += w * control_points[i][k];
883            }
884        }
885        let d = Vec3::new(s[0], s[1], s[2]).distance(*point);
886        max = max.max(d);
887        sum += d * d;
888    }
889    Ok(NurbsFit {
890        degree,
891        count_u,
892        count_v,
893        knots_u,
894        knots_v,
895        control_points,
896        max_deviation: max,
897        rms_deviation: (sum / points.len() as f64).sqrt(),
898    })
899}
900
901#[path = "import_session/nurbs_face.rs"]
902mod nurbs_face;
903pub use nurbs_face::{apply_nurbs_faces, NurbsFaceChoice, NurbsFaceOutcome};
904
905/// [`principal_frame`] on kernel vectors.
906fn principal_frame_kernel(points: &[brep_kernel::Vec3]) -> (brep_kernel::Vec3, [brep_kernel::Vec3; 3]) {
907    let converted: Vec<Vec3> = points.iter().map(|p| Vec3::new(p.x, p.y, p.z)).collect();
908    let (c, axes) = principal_frame(&converted);
909    let k = |v: Vec3| brep_kernel::Vec3::new(v.x, v.y, v.z);
910    (k(c), axes.map(k))
911}
912
913/// The accepted import with the user's NURBS faces: the conversion's STEP
914/// with each chosen region's facets replaced by one NURBS face
915/// ([`apply_nurbs_faces`]). The STEP is returned unchanged when no choice
916/// applies. Only a single-body import takes them (a body is matched by its
917/// mesh vertices; a multi-body document is refused whole, with the reason).
918pub fn apply_region_choices(
919    step_text: &str,
920    positions: &[f64],
921    indices: &[u32],
922    choices: &[NurbsFaceChoice],
923) -> Result<(String, Vec<NurbsFaceOutcome>), String> {
924    let mut solids = brep_kernel::import_step(step_text)?;
925    if solids.len() != 1 {
926        let reason = format!("the import holds {} bodies; NURBS faces are placed on single-body imports", solids.len());
927        return Ok((step_text.to_string(), choices.iter().map(|_| NurbsFaceOutcome { applied: false, max_deviation: None, control_points: None, reason: Some(reason.clone()) }).collect()));
928    }
929    let outcomes = apply_nurbs_faces(&mut solids[0], positions, indices, choices);
930    if !outcomes.iter().any(|outcome| outcome.applied) {
931        return Ok((step_text.to_string(), outcomes));
932    }
933    let report = brep_kernel::export_step_report(&solids, "Imported mesh", "MM", "")?;
934    let text = report.text;
935    // The file must read back as the body it was written from.
936    let back = brep_kernel::import_step(&text)?;
937    if back.len() != 1 || !back[0].validate().is_empty() {
938        return Err("the STEP written with the NURBS faces does not read back as one valid body".into());
939    }
940    Ok((text, outcomes))
941}
942
943/// Unassigned triangles' region id, re-exported for callers colouring them.
944pub const UNASSIGNED: u32 = UNASSIGNED_REGION;
945