Skip to main content

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}