brepkit_math/nurbs/intersection/
chaining.rs1use 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
10pub(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 let threshold = estimate_chain_threshold(points);
23
24 let chains = chain_intersection_points(points, threshold);
26
27 build_curves_from_chains(&chains)
28}
29
30pub(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 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 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 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 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#[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 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 let avg = total_min_dist / count as f64;
166
167 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 let floor = diag * 0.05;
186 (avg * 3.0).max(floor).max(1e-4)
187}
188
189#[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 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 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 let mut chains = Vec::with_capacity(components.len());
245 for comp in &components {
246 if comp.is_empty() {
247 continue;
248 }
249
250 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 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}