1use crate::topological::AxisEstimate;
35use crate::Float;
36use kiddo::{KdTree, SquaredEuclidean};
37use nalgebra::{Point2, RealField, Vector2};
38
39#[derive(Clone, Copy, Debug, PartialEq)]
41pub struct LocalStep<F: Float = f32> {
42 pub step_u: F,
45 pub step_v: F,
47 pub confidence: F,
50 pub supporters_u: usize,
52 pub supporters_v: usize,
54}
55
56impl<F: Float> Default for LocalStep<F> {
57 fn default() -> Self {
58 Self {
59 step_u: F::zero(),
60 step_v: F::zero(),
61 confidence: F::zero(),
62 supporters_u: 0_usize,
63 supporters_v: 0_usize,
64 }
65 }
66}
67
68#[derive(Clone, Copy, Debug)]
79pub struct LocalStepPointData<F: Float = f32> {
80 pub position: Point2<F>,
82 pub axes: [AxisEstimate; 2],
85}
86
87#[derive(Clone, Copy, Debug)]
89pub struct LocalStepParams<F: Float = f32> {
90 pub k_neighbors: usize,
94 pub max_step_factor: F,
97 pub sector_half_width_rad: F,
103 pub bandwidth_rel: F,
106 pub mean_shift_max_iters: u32,
109 pub mean_shift_convergence_rel: F,
112 pub confidence_denominator: F,
117}
118
119impl<F: Float> Default for LocalStepParams<F> {
120 fn default() -> Self {
121 Self {
122 k_neighbors: 8,
123 max_step_factor: F::from_subset(&3.0),
124 sector_half_width_rad: F::pi() / F::from_subset(&6.0),
125 bandwidth_rel: F::from_subset(&0.15),
126 mean_shift_max_iters: 20,
127 mean_shift_convergence_rel: F::from_subset(&1e-3),
128 confidence_denominator: F::from_subset(&4.0),
129 }
130 }
131}
132
133#[cfg_attr(
139 feature = "tracing",
140 tracing::instrument(
141 level = "debug",
142 skip_all,
143 fields(num_points = points.len()),
144 )
145)]
146pub fn estimate_local_steps<F: Float + kiddo::float::kdtree::Axis>(
147 points: &[LocalStepPointData<F>],
148 params: &LocalStepParams<F>,
149) -> Vec<LocalStep<F>> {
150 if points.is_empty() {
151 return Vec::new();
152 }
153
154 let coords: Vec<[F; 2]> = points
156 .iter()
157 .map(|p| [p.position.x, p.position.y])
158 .collect();
159 let tree: KdTree<F, 2> = (&coords).into();
160
161 let mut out = Vec::with_capacity(points.len());
162 for (i, p) in points.iter().enumerate() {
163 out.push(estimate_one(i, p, &tree, points, params));
164 }
165 out
166}
167
168fn estimate_one<F: Float + kiddo::float::kdtree::Axis>(
169 source_index: usize,
170 source: &LocalStepPointData<F>,
171 tree: &KdTree<F, 2>,
172 points: &[LocalStepPointData<F>],
173 params: &LocalStepParams<F>,
174) -> LocalStep<F> {
175 let k = params.k_neighbors.saturating_add(1); let results =
177 tree.nearest_n::<SquaredEuclidean>(&[source.position.x, source.position.y], k.max(2));
178
179 let mut offsets: Vec<Vector2<F>> = Vec::with_capacity(k);
181 for nn in results {
182 let j = nn.item as usize;
183 if j == source_index {
184 continue;
185 }
186 let other = &points[j];
187 let offset = other.position - source.position;
188 if offset.norm_squared().is_zero() {
189 continue;
190 }
191 offsets.push(offset);
192 }
193
194 if offsets.is_empty() {
195 return LocalStep::default();
196 }
197
198 let distances: Vec<F> = offsets.iter().map(|o| o.norm()).collect();
200 let median_dist = median_f(&mut distances.clone());
201 let cutoff = median_dist * params.max_step_factor;
202 let mut kept: Vec<Vector2<F>> = offsets
203 .into_iter()
204 .zip(distances.iter())
205 .filter_map(|(o, d)| if *d <= cutoff { Some(o) } else { None })
206 .collect();
207
208 if kept.is_empty() {
209 return LocalStep::default();
210 }
211
212 let line_u = fold_to_line(F::from_subset(&(source.axes[0].angle as f64)));
214 let line_v = fold_to_line(F::from_subset(&(source.axes[1].angle as f64)));
215 let mut u_steps: Vec<F> = Vec::new();
216 let mut v_steps: Vec<F> = Vec::new();
217
218 while let Some(offset) = kept.pop() {
219 let edge_line = fold_to_line(offset.y.atan2(offset.x));
220 let diff_u = line_diff(edge_line, line_u);
221 let diff_v = line_diff(edge_line, line_v);
222 if RealField::min(diff_u, diff_v) > params.sector_half_width_rad {
223 continue;
224 }
225 let d = offset.norm();
226 if diff_u <= diff_v {
227 u_steps.push(d);
228 } else {
229 v_steps.push(d);
230 }
231 }
232
233 let (step_u, sup_u) = sector_mode(&mut u_steps, params);
234 let (step_v, sup_v) = sector_mode(&mut v_steps, params);
235
236 let total_sup = F::from_subset(&((sup_u + sup_v) as f64));
237 let confidence = RealField::max(
238 RealField::min(total_sup / params.confidence_denominator, F::one()),
239 F::zero(),
240 );
241
242 LocalStep {
243 step_u,
244 step_v,
245 confidence,
246 supporters_u: sup_u as usize,
247 supporters_v: sup_v as usize,
248 }
249}
250
251fn sector_mode<F: Float>(values: &mut [F], params: &LocalStepParams<F>) -> (F, u32) {
254 if values.is_empty() {
255 return (F::zero(), 0);
256 }
257 values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
258 let med = median_sorted(values);
259 let sup = values.len() as u32;
260
261 if values.len() < 2 {
262 return (med, sup);
263 }
264 let bandwidth = med * params.bandwidth_rel;
265 if bandwidth.is_zero() {
266 return (med, sup);
267 }
268
269 let mut center = med;
270 let convergence = bandwidth * params.mean_shift_convergence_rel;
271 for _ in 0..params.mean_shift_max_iters {
272 let mut sum = F::zero();
273 let mut weight = F::zero();
274 for &v in values.iter() {
275 let diff = v - center;
276 if diff.abs() > bandwidth {
277 continue;
278 }
279 let t = diff / bandwidth;
281 let w = F::one() - t * t;
282 let w = if w < F::zero() { F::zero() } else { w };
283 sum += v * w;
284 weight += w;
285 }
286 if weight.is_zero() {
287 return (med, sup);
288 }
289 let next = sum / weight;
290 if (next - center).abs() <= convergence {
291 return (next, sup);
292 }
293 center = next;
294 }
295 (med, sup)
297}
298
299#[inline]
301fn fold_to_line<F: Float>(theta: F) -> F {
302 let pi = F::pi();
303 let two_pi = pi + pi;
304 let mut t = theta - two_pi * (theta / two_pi).floor();
305 if t >= pi {
306 t -= pi;
307 }
308 if t < F::zero() {
309 t += pi;
310 }
311 t
312}
313
314#[inline]
317fn line_diff<F: Float>(a: F, b: F) -> F {
318 let pi = F::pi();
319 let frac_pi_2 = F::frac_pi_2();
320 let mut diff = (a - b).abs();
321 if diff > frac_pi_2 {
322 diff = pi - diff;
323 }
324 diff
325}
326
327fn median_f<F: Float>(values: &mut [F]) -> F {
328 if values.is_empty() {
329 return F::zero();
330 }
331 values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
332 median_sorted(values)
333}
334
335fn median_sorted<F: Float>(sorted: &[F]) -> F {
336 let n = sorted.len();
337 if n == 0 {
338 return F::zero();
339 }
340 if n % 2 == 1 {
341 sorted[n / 2]
342 } else {
343 (sorted[n / 2 - 1] + sorted[n / 2]) * F::from_subset(&0.5)
344 }
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350 use nalgebra::Point2;
351
352 fn lspd(x: f32, y: f32, axis_u: f32) -> LocalStepPointData<f32> {
353 LocalStepPointData {
354 position: Point2::new(x, y),
355 axes: [
356 AxisEstimate::from_angle(axis_u),
357 AxisEstimate::from_angle(axis_u + std::f32::consts::FRAC_PI_2),
358 ],
359 }
360 }
361
362 fn regular_grid(
363 rows: u32,
364 cols: u32,
365 spacing: f32,
366 angle: f32,
367 ) -> Vec<LocalStepPointData<f32>> {
368 let (cx, sx) = (angle.cos(), angle.sin());
369 let mut out = Vec::new();
370 for j in 0..rows {
371 for i in 0..cols {
372 let i_f = i as f32 * spacing;
373 let j_f = j as f32 * spacing;
374 let x = i_f * cx - j_f * sx;
375 let y = i_f * sx + j_f * cx;
376 out.push(lspd(x, y, angle));
377 }
378 }
379 out
380 }
381
382 #[test]
383 fn regular_grid_recovers_spacing_at_multiple_scales() {
384 let params = LocalStepParams::<f32>::default();
385 for &spacing in &[10.0_f32, 20.0, 40.0] {
386 let pts = regular_grid(5, 5, spacing, 0.0);
387 let steps = estimate_local_steps(&pts, ¶ms);
388 let s = &steps[12];
390 assert!(
391 (s.step_u - spacing).abs() / spacing < 0.05,
392 "spacing {spacing}: step_u {} off >5%",
393 s.step_u
394 );
395 assert!((s.step_v - spacing).abs() / spacing < 0.05);
396 assert!(s.supporters_u >= 2 && s.supporters_v >= 2);
397 assert!(s.confidence > 0.8);
398 }
399 }
400
401 #[test]
402 fn rotated_grid_is_sector_invariant() {
403 let params = LocalStepParams::<f32>::default();
404 for ° in &[0.0_f32, 15.0, 30.0, 45.0] {
405 let angle = deg.to_radians();
406 let pts = regular_grid(5, 5, 20.0, angle);
407 let steps = estimate_local_steps(&pts, ¶ms);
408 let s = &steps[12];
409 assert!(
410 (s.step_u - 20.0).abs() < 1.0,
411 "angle {deg}°: step_u {} deviates",
412 s.step_u
413 );
414 assert!((s.step_v - 20.0).abs() < 1.0);
415 }
416 }
417
418 #[test]
419 fn mild_barrel_distortion_is_tolerated() {
420 let spacing = 25.0;
424 let mut pts = regular_grid(7, 7, spacing, 0.0);
425 for p in &mut pts {
426 let cx = 3.0 * spacing;
427 let cy = 3.0 * spacing;
428 let dx = p.position.x - cx;
429 let dy = p.position.y - cy;
430 let r2 = dx * dx + dy * dy;
431 let scale = 1.0 + 1e-5 * r2;
432 p.position = Point2::new(cx + dx * scale, cy + dy * scale);
433 }
434 let steps = estimate_local_steps(&pts, &LocalStepParams::<f32>::default());
435 let interior = 24usize; let s = &steps[interior];
437 assert!(
438 (s.step_u - spacing).abs() / spacing < 0.1,
439 "step_u {} far from spacing {spacing}",
440 s.step_u
441 );
442 }
443
444 #[test]
445 fn dual_scale_grid_picks_dominant_mode() {
446 let mut pts = regular_grid(5, 5, 20.0, 0.0);
448 let marker_angle = 20.0_f32.to_radians();
455 let interior_pts: Vec<usize> = (1..4)
456 .flat_map(|j| (1..4).map(move |i| j * 5 + i))
457 .collect();
458 for &idx in &interior_pts {
459 let c = pts[idx].position;
460 pts.push(LocalStepPointData {
461 position: Point2::new(c.x + 3.0, c.y + 3.0),
462 axes: [
463 AxisEstimate::from_angle(marker_angle),
464 AxisEstimate::from_angle(marker_angle + std::f32::consts::FRAC_PI_2),
465 ],
466 });
467 }
468 let steps = estimate_local_steps(&pts, &LocalStepParams::<f32>::default());
469 let s = &steps[12]; assert!(
472 (s.step_u - 20.0).abs() < 2.0,
473 "expected board step ~20 for u, got {}",
474 s.step_u
475 );
476 assert!(
477 (s.step_v - 20.0).abs() < 2.0,
478 "expected board step ~20 for v, got {}",
479 s.step_v
480 );
481 }
482
483 #[test]
484 fn isolated_point_reports_zero_confidence() {
485 let pts = vec![lspd(0.0, 0.0, 0.0)];
486 let steps = estimate_local_steps(&pts, &LocalStepParams::<f32>::default());
487 assert_eq!(steps.len(), 1);
488 assert_eq!(steps[0].confidence, 0.0);
489 assert_eq!(steps[0].step_u, 0.0);
490 assert_eq!(steps[0].step_v, 0.0);
491 }
492
493 #[test]
494 fn fold_and_line_diff_roundtrip() {
495 let pi = std::f32::consts::PI;
496 for &theta in &[-pi, -0.5, 0.0, 0.5, pi - 1e-3, pi, 1.5 * pi, 2.5 * pi] {
497 let folded = fold_to_line(theta);
498 assert!(
499 (0.0..pi).contains(&folded),
500 "fold({theta}) = {folded} escaped [0, π)"
501 );
502 }
503 assert!(
505 (line_diff(0.0, std::f32::consts::FRAC_PI_2) - std::f32::consts::FRAC_PI_2).abs()
506 < 1e-5
507 );
508 assert!(line_diff(0.0, pi - 1e-3) < 1e-2);
510 }
511}