Skip to main content

brepkit_math/nurbs/intersection/
chaining.rs

1//! Chaining intersection points into curves.
2
3use crate::MathError;
4use crate::nurbs::fitting::{approximate_lspia, chord_length_params, interpolate};
5use crate::nurbs::projection::project_point_to_curve;
6use crate::vec::Point3;
7
8use super::{IntersectionCurve, IntersectionPoint};
9
10/// Build intersection curves from a set of points by chaining and fitting.
11///
12/// First chains points into connected components (separate intersection
13/// branches), then fits a NURBS curve through each chain independently.
14pub(super) fn build_curves_from_points(
15    points: &[IntersectionPoint],
16) -> Result<Vec<IntersectionCurve>, MathError> {
17    if points.is_empty() {
18        return Ok(Vec::new());
19    }
20
21    // Estimate a chaining threshold from the average spacing.
22    let threshold = estimate_chain_threshold(points);
23
24    // Chain points into connected components.
25    let chains = chain_intersection_points(points, threshold);
26
27    build_curves_from_chains(&chains)
28}
29
30/// Fit a NURBS curve through each ALREADY-ORDERED chain of points.
31///
32/// The proximity chainer above cannot serve callers that know connectivity
33/// exactly (the plane x NURBS grid scan links crossings through shared
34/// cells): its distance threshold cannot separate a sampling gap along one
35/// branch from two genuinely-close branches, and no statistic fixes that.
36pub(super) fn build_curves_from_chains(
37    chains: &[Vec<IntersectionPoint>],
38) -> Result<Vec<IntersectionCurve>, MathError> {
39    let mut curves = Vec::with_capacity(chains.len());
40
41    for chain in chains {
42        // Deduplicate closely spaced points within the chain.
43        let mut deduped: Vec<IntersectionPoint> = Vec::new();
44        for pt in chain {
45            let is_dup = deduped
46                .last()
47                .is_some_and(|last: &IntersectionPoint| (last.point - pt.point).length() < 1e-6);
48            if !is_dup {
49                deduped.push(*pt);
50            }
51        }
52
53        if deduped.len() < 2 {
54            continue;
55        }
56
57        // Fit a NURBS curve through this chain's points.
58        let positions: Vec<Point3> = deduped.iter().map(|p| p.point).collect();
59        let degree = if positions.len() <= 3 {
60            1
61        } else {
62            3.min(positions.len() - 1)
63        };
64        let curve = if positions.len() > 50 {
65            let num_cps = (positions.len() / 3).max(degree + 1).min(positions.len());
66            let fitted = approximate_lspia(&positions, degree, num_cps, 1e-6, 100)?;
67
68            // Validate fit quality: re-evaluate residual at each sample point
69            // using the same chord-length parameterisation used during fitting.
70            // Use a relative threshold (residual / point-cloud diagonal) so the
71            // check is scale-independent.  A relative residual > 1% warrants a
72            // warning; the intersection curve may be geometrically inaccurate.
73            let fit_params = chord_length_params(&positions);
74            let mut max_residual = 0.0f64;
75            let mut bbox_min = positions[0];
76            let mut bbox_max = positions[0];
77            for (i, &t) in fit_params.iter().enumerate() {
78                let src = positions[i];
79                // Nearest-point projection gives the true geometric residual.
80                // Fall back to parametric evaluation only for degenerate curves.
81                let d = if let Ok(proj) = project_point_to_curve(&fitted, src, 1e-6) {
82                    proj.distance
83                } else {
84                    let pt = fitted.evaluate(t);
85                    (pt.x() - src.x()).hypot((pt.y() - src.y()).hypot(pt.z() - src.z()))
86                };
87                max_residual = max_residual.max(d);
88                bbox_min = Point3::new(
89                    bbox_min.x().min(src.x()),
90                    bbox_min.y().min(src.y()),
91                    bbox_min.z().min(src.z()),
92                );
93                bbox_max = Point3::new(
94                    bbox_max.x().max(src.x()),
95                    bbox_max.y().max(src.y()),
96                    bbox_max.z().max(src.z()),
97                );
98            }
99            let diagonal = (bbox_max.x() - bbox_min.x())
100                .hypot((bbox_max.y() - bbox_min.y()).hypot(bbox_max.z() - bbox_min.z()));
101            let rel_residual = if diagonal > 1e-12 {
102                max_residual / diagonal
103            } else {
104                max_residual
105            };
106            if rel_residual > 1e-2 {
107                log::warn!(
108                    "SSI: LSPIA fit relative residual {rel_residual:.2e} (abs={max_residual:.2e}) \
109                     exceeds 1% of curve extent — intersection curve may be inaccurate \
110                     (degree={degree}, num_cps={num_cps}, samples={})",
111                    positions.len()
112                );
113            }
114            fitted
115        } else {
116            interpolate(&positions, degree)?
117        };
118
119        curves.push(IntersectionCurve {
120            curve,
121            points: deduped,
122        });
123    }
124
125    Ok(curves)
126}
127
128/// Estimate a reasonable chaining threshold from point spacing.
129#[allow(clippy::cast_precision_loss)]
130#[must_use]
131pub(super) fn estimate_chain_threshold(points: &[IntersectionPoint]) -> f64 {
132    if points.len() < 2 {
133        return 1.0;
134    }
135
136    // Compute average nearest-neighbor distance (sample up to 100 points for speed).
137    let sample_size = points.len().min(100);
138    let mut total_min_dist = 0.0_f64;
139    let mut count = 0_usize;
140    for i in 0..sample_size {
141        let mut min_d = f64::MAX;
142        for (j, q) in points.iter().enumerate() {
143            if i == j {
144                continue;
145            }
146            let d = (points[i].point - q.point).length();
147            if d < min_d {
148                min_d = d;
149            }
150        }
151        if min_d < f64::MAX {
152            total_min_dist += min_d;
153            count += 1;
154        }
155    }
156
157    if count == 0 {
158        return 1.0;
159    }
160
161    // Use 3x average nearest-neighbor distance as threshold.
162    // The threshold must be large enough to chain adjacent sampling
163    // points along the same intersection branch. We also compute
164    // the bounding box diagonal as an upper-bound reference.
165    let avg = total_min_dist / count as f64;
166
167    // Also compute the bounding box diagonal of all points.
168    let mut bb_min = [f64::MAX; 3];
169    let mut bb_max = [f64::MIN; 3];
170    for p in points {
171        bb_min[0] = bb_min[0].min(p.point.x());
172        bb_min[1] = bb_min[1].min(p.point.y());
173        bb_min[2] = bb_min[2].min(p.point.z());
174        bb_max[0] = bb_max[0].max(p.point.x());
175        bb_max[1] = bb_max[1].max(p.point.y());
176        bb_max[2] = bb_max[2].max(p.point.z());
177    }
178    let diag = ((bb_max[0] - bb_min[0]).powi(2)
179        + (bb_max[1] - bb_min[1]).powi(2)
180        + (bb_max[2] - bb_min[2]).powi(2))
181    .sqrt();
182
183    // Floor: 5% of the bounding diagonal, which handles cases where
184    // many points converge to the same location after Newton refinement.
185    let floor = diag * 0.05;
186    (avg * 3.0).max(floor).max(1e-4)
187}
188
189/// Chain intersection points into connected components using proximity.
190///
191/// Points within `threshold` distance are considered connected. Returns
192/// ordered chains (each chain is a connected component, ordered by
193/// nearest-neighbor walk). Closed loops are detected when the last
194/// point is within `threshold` of the first.
195#[must_use]
196pub fn chain_intersection_points(
197    points: &[IntersectionPoint],
198    threshold: f64,
199) -> Vec<Vec<IntersectionPoint>> {
200    if points.is_empty() {
201        return Vec::new();
202    }
203
204    let n = points.len();
205    let threshold_sq = threshold * threshold;
206
207    // Build adjacency: for each point, find neighbors within threshold.
208    let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
209    for i in 0..n {
210        for j in (i + 1)..n {
211            let d = points[i].point - points[j].point;
212            if d.x().mul_add(d.x(), d.y().mul_add(d.y(), d.z() * d.z())) < threshold_sq {
213                adj[i].push(j);
214                adj[j].push(i);
215            }
216        }
217    }
218
219    // BFS to find connected components.
220    let mut visited = vec![false; n];
221    let mut components: Vec<Vec<usize>> = Vec::new();
222
223    for start in 0..n {
224        if visited[start] {
225            continue;
226        }
227        let mut component = Vec::new();
228        let mut queue = std::collections::VecDeque::new();
229        queue.push_back(start);
230        visited[start] = true;
231        while let Some(idx) = queue.pop_front() {
232            component.push(idx);
233            for &neighbor in &adj[idx] {
234                if !visited[neighbor] {
235                    visited[neighbor] = true;
236                    queue.push_back(neighbor);
237                }
238            }
239        }
240        components.push(component);
241    }
242
243    // Order each component via nearest-neighbor walk.
244    let mut chains = Vec::with_capacity(components.len());
245    for comp in &components {
246        if comp.is_empty() {
247            continue;
248        }
249
250        // Find endpoint: a point with degree <= 1 in the adjacency (within component).
251        let start_idx = comp
252            .iter()
253            .copied()
254            .min_by_key(|&i| adj[i].iter().filter(|&&j| comp.contains(&j)).count())
255            .unwrap_or(comp[0]);
256
257        let mut chain = Vec::with_capacity(comp.len());
258        let mut used = vec![false; n];
259        let mut current = start_idx;
260        used[current] = true;
261        chain.push(points[current]);
262
263        for _ in 1..comp.len() {
264            // Find nearest unused point in the component.
265            let mut best_dist = f64::MAX;
266            let mut best_idx = None;
267            for &idx in comp {
268                if used[idx] {
269                    continue;
270                }
271                let d = points[current].point - points[idx].point;
272                let dist_sq = d.x().mul_add(d.x(), d.y().mul_add(d.y(), d.z() * d.z()));
273                if dist_sq < best_dist {
274                    best_dist = dist_sq;
275                    best_idx = Some(idx);
276                }
277            }
278
279            if let Some(next) = best_idx {
280                used[next] = true;
281                chain.push(points[next]);
282                current = next;
283            } else {
284                break;
285            }
286        }
287
288        chains.push(chain);
289    }
290
291    chains
292}