projective_grid/topological/mod.rs
1//! Topological grid construction (axis-driven variant of SBF09; see References below).
2//!
3//! Builds a labelled `(i, j)` grid from a cloud of 2D corners by:
4//!
5//! 1. Delaunay-triangulating the points.
6//! 2. Classifying Delaunay grid edges from the per-corner ChESS axes, then
7//! inferring diagonals from local triangle topology — no image color
8//! sampling is required.
9//! 3. Merging triangle pairs whose shared edge is a diagonal into quads
10//! (one quad per chessboard cell).
11//! 4. Pruning corners with quad-degree > 4 (illegal), then quads with two
12//! illegal corners (SBF09 §4).
13//! 5. Pruning quads whose opposing edges differ in length by more than
14//! `edge_ratio_max` (SBF09 §4 geometric test).
15//! 6. Flood-filling integer `(i, j)` labels through the quad mesh
16//! (SBF09 §5 topological walking).
17//! 7. Rebasing labels per component so the bounding box starts at `(0, 0)`.
18//!
19//! The pipeline produces one [`TopologicalComponent`] per connected
20//! component of the surviving quad mesh. Component merging is handled by
21//! [`crate::component_merge`] so the same logic is reusable from the
22//! crate's seed-and-grow pipeline.
23//!
24//! Why an axis-driven test rather than the paper's color test:
25//!
26//! - The crate stays standalone (no image dependency, see workspace
27//! architecture rules).
28//! - At low view angles the global cell-size mode estimate becomes
29//! ambiguous, but ChESS axes (which encode local image-gradient
30//! direction at each corner) remain reliable.
31//! - The test naturally rejects background corners whose axes do not
32//! align with the dominant grid directions.
33//!
34//! Pre-conditions on inputs:
35//!
36//! - `positions[k]` and `axes[k]` describe the same corner for every `k`.
37//! - `axes[k][0]` and `axes[k][1]` follow the workspace convention:
38//! angles in radians, the two axes orthogonal up to ChESS noise, and
39//! `sigma = π` indicates "no information" (such corners are skipped).
40//!
41//! ## References
42//!
43//! - **SBF09**: Y. Shu, A. Brunton, M. Fiala — *Chessboard corner finding
44//! using triangulation and topology*, In Proc. SPIE 7239 (Electronic
45//! Imaging 2009).
46
47use std::collections::HashMap;
48
49use nalgebra::Point2;
50use serde::{Deserialize, Serialize};
51
52mod classify;
53mod delaunay;
54mod quads;
55mod topo_filter;
56pub mod trace;
57mod walk;
58
59#[cfg(test)]
60mod tests;
61
62pub use classify::EdgeKind;
63
64/// One local grid-axis direction at a corner with its 1σ angular uncertainty.
65///
66/// Generic workspace primitive for "this corner believes a grid axis points
67/// in direction θ with 1σ uncertainty σ". A corner carries two of these — an
68/// orthogonal pair of grid directions. Used by both the topological pipeline
69/// (per-half-edge classifier input) and the square pipeline (via the caller's
70/// [`GrowValidator`](crate::square::grow::GrowValidator) impl).
71///
72/// `AxisEstimate` is purely geometric: it is a pair of orthogonal directions
73/// with no notion of colour, parity, or which side of the axis is "dark". Any
74/// pattern-specific interpretation (e.g. a chessboard's dark-sector parity
75/// convention) lives in the pattern's detector crate, not here.
76///
77/// Convention:
78/// - `angle` is in radians. For a corner's two axes, axis 0 is canonicalised
79/// to `[0, π)` and axis 1 to `(axes[0].angle, axes[0].angle + π)`, so the
80/// pair is ordered and roughly orthogonal.
81/// - `sigma` is the 1σ angular uncertainty. `sigma >= max_sigma` is treated
82/// as "no information" by downstream consumers and the corner is skipped.
83/// - Default-constructed axes carry `sigma = π` (the no-info sentinel) so
84/// callers that weight by sigma naturally ignore them.
85#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
86pub struct AxisEstimate {
87 /// Axis angle in radians.
88 pub angle: f32,
89 /// 1σ angular uncertainty in radians. `sigma >= max_sigma` is treated
90 /// as "no information" and the corner is skipped.
91 pub sigma: f32,
92}
93
94impl Default for AxisEstimate {
95 fn default() -> Self {
96 // No-information sentinel. Downstream code that weights by `sigma`
97 // must treat `π` as "this axis is unusable".
98 Self {
99 angle: 0.0,
100 sigma: std::f32::consts::PI,
101 }
102 }
103}
104
105impl AxisEstimate {
106 /// Construct an `AxisEstimate` from a bare angle, with no uncertainty
107 /// information (`sigma = 0.0`). Useful for callers that only have
108 /// an angle (e.g. [`SeedQuadValidator::axes`] impls that do not track
109 /// per-corner uncertainty).
110 ///
111 /// [`SeedQuadValidator::axes`]: crate::square::seed::finder::SeedQuadValidator::axes
112 pub fn from_angle(angle: f32) -> Self {
113 Self { angle, sigma: 0.0 }
114 }
115}
116
117/// A corner consumed by [`detect_topological_grid`].
118///
119/// Bundles the pixel position with the two local grid-axis directions
120/// emitted by an upstream corner detector. The topological pipeline
121/// classifies every Delaunay half-edge by matching its angle against
122/// both endpoints' axes, so axes are part of the input contract.
123///
124/// The square pipeline has no equivalent per-corner input struct: it
125/// takes a bare `&[Point2<f32>]` and obtains per-corner axes through
126/// the caller's [`SeedQuadValidator`](crate::square::seed::finder::SeedQuadValidator)
127/// / [`GrowValidator`](crate::square::grow::GrowValidator) impl (or, on
128/// the zero-config path, through
129/// [`detect_regular_grid`](crate::detect_regular_grid)'s built-in
130/// regular-grid policy).
131#[derive(Clone, Copy, Debug, PartialEq)]
132pub struct TopologicalInputCorner {
133 /// Corner position in pixel coordinates.
134 pub position: Point2<f32>,
135 /// Two local grid-axis directions with per-axis 1σ uncertainty.
136 /// Default-constructed axes (`sigma = π`) mark the corner unusable.
137 pub axes: [AxisEstimate; 2],
138}
139
140impl TopologicalInputCorner {
141 /// Construct a [`TopologicalInputCorner`] from a position and axes.
142 pub fn new(position: Point2<f32>, axes: [AxisEstimate; 2]) -> Self {
143 Self { position, axes }
144 }
145}
146
147/// Two global grid-axis directions, in `[0, π)` with `theta0 < theta1`.
148///
149/// Lets a caller's prior cluster-axis estimate flow into the topological
150/// pre-Delaunay gate without `projective-grid` taking a dependency on a
151/// specific detector's types. The two directions are interpreted modulo
152/// π (axes are undirected). Construct via [`AxisClusterCenters::new`]
153/// which orders the inputs and wraps them into `[0, π)`.
154#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
155pub struct AxisClusterCenters {
156 /// First grid-axis direction in radians, in `[0, π)`, with `theta0 < theta1`.
157 pub theta0: f32,
158 /// Second grid-axis direction in radians, in `[0, π)`, with `theta0 < theta1`.
159 pub theta1: f32,
160}
161
162impl AxisClusterCenters {
163 /// Wrap both inputs into `[0, π)` and order so `theta0 < theta1`.
164 pub fn new(a: f32, b: f32) -> Self {
165 let (mut t0, mut t1) = (
166 crate::circular_stats::wrap_pi(a),
167 crate::circular_stats::wrap_pi(b),
168 );
169 if t0 > t1 {
170 std::mem::swap(&mut t0, &mut t1);
171 }
172 Self {
173 theta0: t0,
174 theta1: t1,
175 }
176 }
177}
178
179/// Tuning knobs for [`build_grid_topological`].
180#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
181#[non_exhaustive]
182pub struct TopologicalParams {
183 /// Maximum angular distance, in radians, between an edge's direction
184 /// and a corner's axis for the edge to be classified as a *grid edge*
185 /// at that corner. Default: `15° = 0.262` — paired with the
186 /// pre-Delaunay [`Self::axis_cluster_centers`] gate. The 22°/18°
187 /// pre-cluster-gate values were a workaround for the missing global
188 /// axis filter; with the gate active they're a precision risk.
189 pub axis_align_tol_rad: f32,
190 /// Maximum 1σ axis uncertainty (radians) for a corner to participate
191 /// in classification. Corners whose both axes have `sigma >=
192 /// max_axis_sigma_rad` are excluded. Default: `0.6` (≈ 34°).
193 pub max_axis_sigma_rad: f32,
194 /// Reject quads whose opposing edges differ in length by more than
195 /// this factor (matches the paper's parallelogram test). Default: `10.0`.
196 pub edge_ratio_max: f32,
197 /// Discard connected quad-mesh components below this size. Default: `1`
198 /// (keep all). Set higher to reject isolated noise quads.
199 pub min_quads_per_component: usize,
200 /// Optional global grid-direction centers. When `Some`, every corner
201 /// must have at least one axis within
202 /// [`Self::cluster_axis_tol_rad`] of either center to enter Delaunay
203 /// (a precision filter; the caller's prior cluster-axis estimate is
204 /// the typical source). When `None`, the gate is skipped, preserving
205 /// the legacy behaviour of this crate as a standalone primitive.
206 pub axis_cluster_centers: Option<AxisClusterCenters>,
207 /// Per-axis admission tolerance against [`Self::axis_cluster_centers`],
208 /// in radians. Only consulted when `axis_cluster_centers.is_some()`.
209 /// Default: `16° = 0.279` — wider than a typical chessboard
210 /// cluster-admission tolerance of `12°` to compensate for the lack
211 /// of sigma-bonus / booster recovery in this image-free pipeline.
212 /// Empirically the floor sits near 16° on real-world boards;
213 /// tightening below this should be paired with a sigma-aware
214 /// admission rule.
215 pub cluster_axis_tol_rad: f32,
216 /// Lower bound on a quad's perimeter edge length, expressed as a
217 /// fraction of the per-component median quad edge length. Quads
218 /// with any edge shorter than `quad_edge_min_rel * component_median`
219 /// are rejected as "below local cell scale". Default: `0.0`
220 /// (disabled). Empirically the lower bound rejects too many
221 /// legitimate small quads on heavily-distorted boards without
222 /// compensating recall elsewhere, so we lean on the upper bound only.
223 pub quad_edge_min_rel: f32,
224 /// Upper bound on a quad's perimeter edge length, expressed as a
225 /// fraction of the per-component median quad edge length. Quads
226 /// with any edge longer than `quad_edge_max_rel * component_median`
227 /// are rejected as "above local cell scale" (typically a quad
228 /// formed across a missing corner). Default: `1.8` — chosen above
229 /// the natural perspective stretch on heavily-distorted boards while
230 /// still excluding the double-cell hops that fragment dense grids.
231 /// Set to `f32::INFINITY` to disable.
232 pub quad_edge_max_rel: f32,
233}
234
235impl Default for TopologicalParams {
236 fn default() -> Self {
237 Self {
238 axis_align_tol_rad: 15.0_f32.to_radians(),
239 max_axis_sigma_rad: 0.6,
240 edge_ratio_max: 10.0,
241 min_quads_per_component: 1,
242 axis_cluster_centers: None,
243 cluster_axis_tol_rad: 16.0_f32.to_radians(),
244 quad_edge_min_rel: 0.0,
245 quad_edge_max_rel: 1.8,
246 }
247 }
248}
249
250/// Per-component output of the topological pipeline.
251#[derive(Clone, Debug, Default)]
252pub struct TopologicalComponent {
253 /// `(i, j) → corner_idx` mapping. Indices reference the original
254 /// `positions` slice. The bounding box of the labelled set always
255 /// starts at `(0, 0)` (workspace invariant).
256 pub labelled: HashMap<(i32, i32), usize>,
257}
258
259/// Diagnostic counters from one [`build_grid_topological`] run.
260#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)]
261#[non_exhaustive]
262pub struct TopologicalStats {
263 /// Corners passed in.
264 pub corners_in: usize,
265 /// Corners that survived the axis-validity pre-filter.
266 pub corners_used: usize,
267 /// Triangles produced by Delaunay triangulation.
268 pub triangles: usize,
269 /// Half-edges classified as `Grid` (counted twice, once per direction).
270 pub grid_edges: usize,
271 /// Half-edges classified as `Diagonal`.
272 pub diagonal_edges: usize,
273 /// Half-edges classified as `Spurious`.
274 pub spurious_edges: usize,
275 /// Triangles with exactly one Diagonal edge and two Grid edges
276 /// (i.e. eligible to merge into a quad if their buddy agrees).
277 pub triangles_mergeable: usize,
278 /// Triangles with all three edges classified as Grid (suggests
279 /// the triangle spans more than one cell — the paper's failure
280 /// mode at very low view angles).
281 pub triangles_all_grid: usize,
282 /// Triangles with multiple Diagonal edges (ambiguous).
283 pub triangles_multi_diag: usize,
284 /// Triangles with at least one Spurious edge.
285 pub triangles_has_spurious: usize,
286 /// Triangle pairs merged into quads.
287 pub quads_merged: usize,
288 /// Quads surviving topological + geometric filtering.
289 pub quads_kept: usize,
290 /// Connected quad-mesh components after walking.
291 pub components: usize,
292}
293
294/// Top-level result.
295#[derive(Clone, Debug, Default)]
296pub struct TopologicalGrid {
297 /// The recovered grid components — each a connected `(i, j)`-labelled
298 /// quad mesh. Multiple entries mean the corner cloud split into
299 /// disjoint regions.
300 pub components: Vec<TopologicalComponent>,
301 /// Per-stage counters describing how the pipeline reached this result.
302 pub diagnostics: TopologicalStats,
303}
304
305impl TopologicalGrid {
306 /// Run the generic local-geometry component merge on this topological
307 /// result.
308 ///
309 /// The returned components are still image-frame corner indices into the
310 /// same `positions` slice used to build this grid. This is the final
311 /// projective-grid-only post-stage; chessboard-specific recovery and
312 /// final precision gates live in `calib-targets-chessboard`.
313 pub fn merge_components_local(
314 &self,
315 positions: &[Point2<f32>],
316 params: &crate::component_merge::LocalMergeParams,
317 ) -> crate::component_merge::ComponentMergeResult {
318 let views: Vec<_> = self
319 .components
320 .iter()
321 .map(|component| crate::component_merge::ComponentInput {
322 labelled: &component.labelled,
323 positions,
324 })
325 .collect();
326 crate::component_merge::merge_components_local(&views, params)
327 }
328}
329
330/// Per-triangle edge-composition bucket used by diagnostics and tracing.
331#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
332#[serde(rename_all = "snake_case")]
333#[non_exhaustive]
334pub enum TriangleClass {
335 /// Exactly one diagonal edge and two grid edges.
336 Mergeable,
337 /// All three edges classified as grid.
338 AllGrid,
339 /// Two or three diagonal edges.
340 MultiDiagonal,
341 /// At least one spurious edge.
342 HasSpurious,
343}
344
345/// Errors from [`build_grid_topological`].
346#[derive(Clone, Copy, Debug, thiserror::Error)]
347pub enum TopologicalError {
348 /// The position and axes slices have mismatched length.
349 #[error("positions and axes must be the same length (got {positions} and {axes})")]
350 LengthMismatch {
351 /// Length of the positions slice.
352 positions: usize,
353 /// Length of the axes slice.
354 axes: usize,
355 },
356 /// Fewer than three usable corners survived the pre-filter, which is
357 /// the minimum for Delaunay triangulation.
358 #[error("not enough usable corners ({usable}) for Delaunay triangulation")]
359 NotEnoughCorners {
360 /// Number of corners that survived the pre-filter.
361 usable: usize,
362 },
363}
364
365#[inline]
366fn axis_passes_cluster(a: &AxisEstimate, centers: &AxisClusterCenters, tol: f32) -> bool {
367 use crate::circular_stats::{angular_dist_pi, wrap_pi};
368 if !a.sigma.is_finite() || a.sigma >= std::f32::consts::PI - f32::EPSILON {
369 return false;
370 }
371 let angle = wrap_pi(a.angle);
372 angular_dist_pi(angle, centers.theta0).min(angular_dist_pi(angle, centers.theta1)) < tol
373}
374
375#[cfg_attr(
376 feature = "tracing",
377 tracing::instrument(
378 level = "debug",
379 skip_all,
380 fields(num_corners = axes.len()),
381 )
382)]
383fn usable_mask(axes: &[[AxisEstimate; 2]], params: &TopologicalParams) -> Vec<bool> {
384 let centers = params.axis_cluster_centers.as_ref();
385 let tol = params.cluster_axis_tol_rad;
386 axes.iter()
387 .map(|a| {
388 let sigma_ok =
389 a[0].sigma < params.max_axis_sigma_rad || a[1].sigma < params.max_axis_sigma_rad;
390 if !sigma_ok {
391 return false;
392 }
393 match centers {
394 None => true,
395 Some(c) => axis_passes_cluster(&a[0], c, tol) || axis_passes_cluster(&a[1], c, tol),
396 }
397 })
398 .collect()
399}
400
401/// Triangulate only the usable corners and remap triangle vertex indices
402/// back into the global `positions` index space.
403///
404/// The returned [`delaunay::Triangulation`] indexes into the original
405/// `positions` slice (not the packed slice), so every downstream stage —
406/// classification, quad merging, label flood-fill — keeps using global
407/// indices and the rest of the pipeline is oblivious to the pre-filter.
408///
409/// Returns `(triangulation, packed_to_global)` where `packed_to_global[i]`
410/// is the global index of the `i`-th packed corner. The map is returned
411/// for callers that may want it (e.g. tracing); the production
412/// [`build_grid_topological`] does not need it.
413fn triangulate_usable(
414 positions: &[Point2<f32>],
415 usable: &[bool],
416) -> (delaunay::Triangulation, Vec<usize>) {
417 let mut packed_to_global: Vec<usize> = Vec::with_capacity(positions.len());
418 let mut packed_positions: Vec<Point2<f32>> = Vec::with_capacity(positions.len());
419 for (i, (&u, &p)) in usable.iter().zip(positions.iter()).enumerate() {
420 if u {
421 packed_to_global.push(i);
422 packed_positions.push(p);
423 }
424 }
425 let mut triangulation = delaunay::triangulate(&packed_positions);
426 for v in triangulation.triangles.iter_mut() {
427 *v = packed_to_global[*v];
428 }
429 (triangulation, packed_to_global)
430}
431
432pub(super) fn triangle_class(edge_kinds: &[EdgeKind], t: usize) -> TriangleClass {
433 let mut g = 0;
434 let mut d = 0;
435 let mut sp = 0;
436 for k in 0..3 {
437 match edge_kinds[3 * t + k] {
438 EdgeKind::Grid => g += 1,
439 EdgeKind::Diagonal => d += 1,
440 EdgeKind::Spurious => sp += 1,
441 }
442 }
443 if sp > 0 {
444 TriangleClass::HasSpurious
445 } else if d == 1 && g == 2 {
446 TriangleClass::Mergeable
447 } else if d == 0 && g == 3 {
448 TriangleClass::AllGrid
449 } else {
450 TriangleClass::MultiDiagonal
451 }
452}
453
454pub(super) fn update_edge_stats(stats: &mut TopologicalStats, edge_kinds: &[EdgeKind]) {
455 for &k in edge_kinds {
456 match k {
457 EdgeKind::Grid => stats.grid_edges += 1,
458 EdgeKind::Diagonal => stats.diagonal_edges += 1,
459 EdgeKind::Spurious => stats.spurious_edges += 1,
460 }
461 }
462}
463
464pub(super) fn update_triangle_stats(stats: &mut TopologicalStats, edge_kinds: &[EdgeKind]) {
465 for t in 0..stats.triangles {
466 match triangle_class(edge_kinds, t) {
467 TriangleClass::Mergeable => stats.triangles_mergeable += 1,
468 TriangleClass::AllGrid => stats.triangles_all_grid += 1,
469 TriangleClass::MultiDiagonal => stats.triangles_multi_diag += 1,
470 TriangleClass::HasSpurious => stats.triangles_has_spurious += 1,
471 }
472 }
473}
474
475/// Build labelled grid components from corners + per-corner axes.
476///
477/// Returns one [`TopologicalComponent`] per connected component of the
478/// surviving quad mesh. Use [`crate::component_merge`] to attempt to
479/// merge components into a single grid.
480#[cfg_attr(
481 feature = "tracing",
482 tracing::instrument(
483 level = "info",
484 skip_all,
485 fields(num_corners = positions.len()),
486 )
487)]
488pub fn build_grid_topological(
489 positions: &[Point2<f32>],
490 axes: &[[AxisEstimate; 2]],
491 params: &TopologicalParams,
492) -> Result<TopologicalGrid, TopologicalError> {
493 if positions.len() != axes.len() {
494 return Err(TopologicalError::LengthMismatch {
495 positions: positions.len(),
496 axes: axes.len(),
497 });
498 }
499 let mut stats = TopologicalStats {
500 corners_in: positions.len(),
501 ..Default::default()
502 };
503
504 // Pre-filter corners: at least one axis must have a usable sigma.
505 // Triangulating over the usable subset (rather than over every input
506 // corner) is a strict win — Delaunay is `O(n log n)`, so reducing `n`
507 // saves work, and excluding noise-only corners up front avoids them
508 // starving valid corners of cardinal Delaunay neighbours and producing
509 // edges that would only ever classify as `Spurious` downstream.
510 // Recovery / extension stages in the chessboard crate use ChESS-strong
511 // corners independently and are unaffected by this filter.
512 let usable_mask = usable_mask(axes, params);
513 stats.corners_used = usable_mask.iter().filter(|&&b| b).count();
514 if stats.corners_used < 3 {
515 return Err(TopologicalError::NotEnoughCorners {
516 usable: stats.corners_used,
517 });
518 }
519
520 let (triangulation, _packed_to_global) = triangulate_usable(positions, &usable_mask);
521 stats.triangles = triangulation.triangles.len() / 3;
522
523 // Classify every half-edge.
524 let edge_kinds = classify::classify_all_edges(positions, axes, &triangulation, params);
525 update_edge_stats(&mut stats, &edge_kinds);
526
527 // Per-triangle classification breakdown — tells us at a glance
528 // whether the merge step is starving on noise (all-spurious),
529 // saturated by perspective foreshortening (all-grid spans cells),
530 // or jammed by ambiguity (≥ 2 diagonals).
531 update_triangle_stats(&mut stats, &edge_kinds);
532
533 // Merge triangle pairs sharing a diagonal whose other edges are grid.
534 let raw_quads = quads::merge_triangle_pairs(&triangulation, &edge_kinds, positions);
535 stats.quads_merged = raw_quads.len();
536
537 // Topological + geometric filtering.
538 let kept_quads = topo_filter::filter_quads(&raw_quads, positions, params);
539 stats.quads_kept = kept_quads.len();
540
541 // Flood-fill labels per connected component.
542 let components = walk::label_components(&kept_quads, params.min_quads_per_component);
543 stats.components = components.len();
544
545 Ok(TopologicalGrid {
546 components,
547 diagnostics: stats,
548 })
549}
550
551/// User-facing entry point for the topological grid pipeline.
552///
553/// Equivalent to [`build_grid_topological`] but takes a single
554/// [`TopologicalInputCorner`] slice instead of parallel
555/// `positions` / `axes` arrays. Use [`recover_topological_grid`]
556/// for the variant that additionally runs the local component
557/// merge.
558///
559/// # Errors
560///
561/// Returns [`TopologicalError::NotEnoughCorners`] when fewer than
562/// three corners survive the per-axis usability filter.
563pub fn detect_topological_grid(
564 corners: &[TopologicalInputCorner],
565 params: &TopologicalParams,
566) -> Result<TopologicalGrid, TopologicalError> {
567 let positions: Vec<Point2<f32>> = corners.iter().map(|c| c.position).collect();
568 let axes: Vec<[AxisEstimate; 2]> = corners.iter().map(|c| c.axes).collect();
569 build_grid_topological(&positions, &axes, params)
570}
571
572/// Build a topological grid and run the generic local component merge.
573///
574/// This is the projective-grid-only final recovery path: input points and
575/// image-frame axis hints go in, merged square-grid components come out. It
576/// intentionally does not apply chessboard-specific precision gates or image
577/// sampling; those live in target-specific crates.
578pub fn recover_topological_grid(
579 positions: &[Point2<f32>],
580 axes: &[[AxisEstimate; 2]],
581 topo_params: &TopologicalParams,
582 merge_params: &crate::component_merge::LocalMergeParams,
583) -> Result<crate::component_merge::ComponentMergeResult, TopologicalError> {
584 let grid = build_grid_topological(positions, axes, topo_params)?;
585 Ok(grid.merge_components_local(positions, merge_params))
586}