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