Skip to main content

projective_grid/topological/
mod.rs

1//! Axis-driven topological grid finder (Shu/Brunton/Fiala 2009) for the
2//! `(LatticeKind::Square, Evidence::Oriented2)` slot.
3//!
4//! Pipeline overview:
5//!
6//! 1. Pre-filter features whose both axes are uninformative under
7//!    [`TopologicalParams::max_axis_sigma_rad`].
8//! 2. Delaunay-triangulate the surviving feature positions.
9//! 3. Classify every Delaunay half-edge as `Grid`, `Diagonal`, or
10//!    `Spurious` via the per-corner axes (no image-color sampling).
11//! 4. Merge triangle pairs sharing a `Diagonal` edge into quads (one
12//!    quad per lattice cell).
13//! 5. Drop quads with two illegal corners (quad-mesh degree > 4),
14//!    extreme parallelograms, or out-of-band edge lengths against the
15//!    per-component median.
16//! 6. Flood-fill integer `(u, v)` labels through the surviving quad
17//!    mesh and rebase each connected component to `(0, 0)`.
18//! 7. Reunite the labelled components in label space with the shared
19//!    [`crate::shared::merge::merge_components_local`]
20//!    pass (local geometry only, radial-distortion safe), so the topological
21//!    path no longer leaves an un-merged quad-mesh component per disconnected
22//!    patch.
23//! 8. Reuse the shared advanced [`validate`](crate::shared::validate)
24//!    post-stage to drop labelled corners flagged by line-collinearity and
25//!    local-H checks.
26//! 9. Fit a projective transform on the surviving labels and report
27//!    per-corner residuals.
28//!
29//! Multi-component output is represented directly: the orchestrator returns
30//! one [`GridSolution`] per qualifying component, ordered by labelled count
31//! descending.
32
33mod axis;
34mod classify;
35mod delaunay;
36mod filter;
37pub(crate) mod hex;
38mod quads;
39mod walk;
40
41use std::collections::HashSet;
42
43use nalgebra::Point2;
44
45use crate::detect::DetectionParams;
46use crate::error::{GridError, Result};
47use crate::feature::OrientedFeature;
48use crate::lattice::{Coord, GridDimensions, LatticeKind};
49use crate::result::{
50    GridEntry, GridSolution, LabelledGrid, LatticeFit, RejectedFeature, RejectionReason,
51};
52use crate::shared::merge::{merge_components_local, ComponentInput, LocalMergeParams};
53use crate::shared::validate as pg_validate;
54
55use self::axis::{build_axis_caches, AxisCache};
56use super::shared::{fit_component, FitComponentResult};
57
58/// Minimum number of usable features for Delaunay triangulation.
59pub(super) const MIN_USABLE_FOR_DELAUNAY: usize = 3;
60
61/// Tuning knobs for the axis-driven topological pipeline.
62///
63/// Defaults are conservative values pinned by the crate's regression tests.
64/// Adding new fields is non-breaking via `#[non_exhaustive]`;
65/// literal-construction from outside the crate goes through [`Self::default`]
66/// + struct-update syntax or [`Self::new`].
67#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
68#[non_exhaustive]
69pub struct TopologicalParams {
70    /// Maximum angular distance, in radians, between an edge's
71    /// direction and a corner's axis for the edge to classify as a
72    /// grid edge at that corner. Default: 15° = 0.262 rad.
73    pub axis_align_tol_rad: f32,
74    /// Maximum 1σ axis uncertainty (radians) for a feature axis to be
75    /// considered informative. Features whose both axes have
76    /// `sigma_rad ≥ max_axis_sigma_rad` are excluded from Delaunay;
77    /// classification skips individual axes above the threshold.
78    /// Default: `0.6 ≈ 34°`. `sigma_rad = None` is treated as informative.
79    pub max_axis_sigma_rad: f32,
80    /// Reject quads whose opposing edges differ in length by more than
81    /// this factor (paper's parallelogram test). Default: `1.5`.
82    pub opposing_edge_ratio_max: f32,
83    /// Lower bound on a quad's perimeter edge length, expressed as a
84    /// fraction of the per-component median quad edge length. Quads
85    /// with any edge shorter than `edge_length_min_rel * component_median`
86    /// are rejected as "below local cell scale". Default: `0.4`.
87    /// Set to `0.0` to disable the lower bound entirely.
88    pub edge_length_min_rel: f32,
89    /// Upper bound on a quad's perimeter edge length, expressed as a
90    /// fraction of the per-component median quad edge length. Quads
91    /// with any edge longer than `edge_length_max_rel * component_median`
92    /// are rejected as "above local cell scale" (typically a quad formed
93    /// across a missing corner). Default: `2.5`. Set to `+inf` to
94    /// disable the upper bound entirely.
95    pub edge_length_max_rel: f32,
96    /// Discard labelled components with fewer than this many corners.
97    /// Default: `4` (one quad of four corners).
98    pub min_corners_for_component: usize,
99    /// Discard connected quad-mesh components below this size. Default:
100    /// `1` (keep all). Set higher to reject isolated noise quads.
101    pub min_quads_per_component: usize,
102    /// Optional global grid-direction centers, in radians, interpreted
103    /// modulo π. When `Some([θ₀, θ₁])`, a feature is admitted to
104    /// Delaunay only if at least one of its informative axes is within
105    /// [`Self::cluster_axis_tol_rad`] of one of the centers. When
106    /// `None`, the gate is skipped.
107    pub axis_cluster_centers: Option<[f32; 2]>,
108    /// Per-axis admission tolerance against
109    /// [`Self::axis_cluster_centers`], in radians. Only consulted when
110    /// `axis_cluster_centers.is_some()`. Default: `16° = 0.279`.
111    pub cluster_axis_tol_rad: f32,
112}
113
114impl Default for TopologicalParams {
115    fn default() -> Self {
116        Self {
117            axis_align_tol_rad: 15.0_f32.to_radians(),
118            max_axis_sigma_rad: 0.6,
119            opposing_edge_ratio_max: 1.5,
120            edge_length_min_rel: 0.4,
121            edge_length_max_rel: 2.5,
122            min_corners_for_component: 4,
123            min_quads_per_component: 1,
124            axis_cluster_centers: None,
125            cluster_axis_tol_rad: 16.0_f32.to_radians(),
126        }
127    }
128}
129
130impl TopologicalParams {
131    /// Construct topological params from the two most commonly tuned
132    /// knobs; the remaining fields take their defaults.
133    pub fn new(axis_align_tol_rad: f32, max_axis_sigma_rad: f32) -> Self {
134        Self {
135            axis_align_tol_rad,
136            max_axis_sigma_rad,
137            ..Self::default()
138        }
139    }
140
141    /// Builder-style override for [`Self::axis_align_tol_rad`].
142    pub fn with_axis_align_tol_rad(mut self, value: f32) -> Self {
143        self.axis_align_tol_rad = value;
144        self
145    }
146
147    /// Builder-style override for [`Self::max_axis_sigma_rad`].
148    pub fn with_max_axis_sigma_rad(mut self, value: f32) -> Self {
149        self.max_axis_sigma_rad = value;
150        self
151    }
152
153    /// Builder-style override for [`Self::opposing_edge_ratio_max`].
154    pub fn with_opposing_edge_ratio_max(mut self, value: f32) -> Self {
155        self.opposing_edge_ratio_max = value;
156        self
157    }
158
159    /// Builder-style override for [`Self::edge_length_min_rel`].
160    pub fn with_edge_length_min_rel(mut self, value: f32) -> Self {
161        self.edge_length_min_rel = value;
162        self
163    }
164
165    /// Builder-style override for [`Self::edge_length_max_rel`].
166    pub fn with_edge_length_max_rel(mut self, value: f32) -> Self {
167        self.edge_length_max_rel = value;
168        self
169    }
170
171    /// Set both edge-length bounds in one call. Equivalent to
172    /// `.with_edge_length_min_rel(min_rel).with_edge_length_max_rel(max_rel)`.
173    ///
174    /// Pass `min_rel = 0.0` to disable the lower bound; pass
175    /// `max_rel = f32::INFINITY` to disable the upper bound.
176    pub fn with_edge_length_band(mut self, min_rel: f32, max_rel: f32) -> Self {
177        self.edge_length_min_rel = min_rel;
178        self.edge_length_max_rel = max_rel;
179        self
180    }
181
182    /// Builder-style override for [`Self::min_corners_for_component`].
183    pub fn with_min_corners_for_component(mut self, value: usize) -> Self {
184        self.min_corners_for_component = value;
185        self
186    }
187
188    /// Builder-style override for [`Self::min_quads_per_component`].
189    pub fn with_min_quads_per_component(mut self, value: usize) -> Self {
190        self.min_quads_per_component = value;
191        self
192    }
193
194    /// Builder-style override for [`Self::axis_cluster_centers`]. The
195    /// two centers are stored as supplied; the caller is responsible
196    /// for wrapping them into `[0, π)` if their source might emit
197    /// signed angles. The internal alignment check works modulo π so
198    /// either convention is accepted.
199    pub fn with_axis_cluster_centers(mut self, centers: [f32; 2]) -> Self {
200        self.axis_cluster_centers = Some(centers);
201        self
202    }
203
204    /// Builder-style override for [`Self::cluster_axis_tol_rad`].
205    pub fn with_cluster_axis_tol_rad(mut self, tol_rad: f32) -> Self {
206        self.cluster_axis_tol_rad = tol_rad;
207        self
208    }
209}
210
211/// Multi-component axis-driven topological grid detector for
212/// `(Square, Oriented2)`.
213///
214/// Returns one [`GridSolution`] per qualifying connected quad-mesh
215/// component, ordered by component size descending. Features that no
216/// component admitted (uninformative axes, gated by the cluster prior,
217/// not picked up by Delaunay, etc.) appear in the **first** solution's
218/// `rejected` vector tagged [`RejectionReason::Unlabelled`]; features
219/// dropped by the per-component validation stage appear in that
220/// component's own `rejected` vector tagged
221/// [`RejectionReason::ValidationDropped`].
222///
223/// The empty-result case maps to `Vec::new()`, *not* an error; the
224/// caller-facing `detect_grid_all` wrapper turns an empty solutions
225/// vector into [`GridError::InsufficientEvidence`] when the request
226/// reached the orchestrator with enough features.
227pub(crate) fn detect_square_oriented2_topological_all(
228    features: &[OrientedFeature<2>],
229    dimensions: Option<GridDimensions>,
230    params: &DetectionParams,
231    synthesized_axes: bool,
232) -> Result<Vec<GridSolution>> {
233    if features.len() < MIN_USABLE_FOR_DELAUNAY {
234        return Err(GridError::InsufficientEvidence);
235    }
236
237    let topo = &params.topological;
238    let axes = build_axis_caches(features, topo.max_axis_sigma_rad);
239    // Apply the optional axis-cluster gate. When no centers are supplied
240    // the predicate is the identity and `usable` matches the ungated
241    // behaviour exactly.
242    #[cfg(feature = "tracing")]
243    let usable: Vec<bool> = {
244        let _span = tracing::debug_span!("usable_mask", num_features = features.len()).entered();
245        build_usable_mask(features, &axes, topo)
246    };
247    #[cfg(not(feature = "tracing"))]
248    let usable: Vec<bool> = build_usable_mask(features, &axes, topo);
249    let n_usable = usable.iter().filter(|&&b| b).count();
250    if n_usable < MIN_USABLE_FOR_DELAUNAY {
251        return Err(GridError::InsufficientEvidence);
252    }
253
254    // Triangulate over the packed usable set; remap triangles back into
255    // the global feature index space so the downstream stages share
256    // indices with `features` / `axes`.
257    let positions: Vec<Point2<f32>> = features.iter().map(|f| f.point.position).collect();
258    let triangulation = triangulate_usable(&positions, &usable);
259    if triangulation.num_tri() == 0 {
260        return Err(GridError::DegenerateGeometry);
261    }
262
263    let edge_kinds =
264        classify::classify_all_edges(&positions, &axes, &triangulation, topo.axis_align_tol_rad);
265    let raw_quads = quads::merge_triangle_pairs(&triangulation, &edge_kinds, &positions);
266    let kept_quads = filter::filter_quads(
267        raw_quads,
268        &positions,
269        topo.opposing_edge_ratio_max,
270        topo.edge_length_min_rel,
271        topo.edge_length_max_rel,
272    );
273    let components = walk::label_components(
274        &kept_quads,
275        topo.min_quads_per_component,
276        topo.min_corners_for_component,
277    );
278
279    if components.is_empty() {
280        return Err(GridError::DegenerateGeometry);
281    }
282
283    // Reunite the labelled components in label space with the shared
284    // `merge_components_local` step. The topological walk leaves one quad-mesh
285    // component per disconnected patch; hosting the merge here lets the
286    // chessboard adapter consume a single already-merged output (see
287    // `calib-targets-chessboard::topological`).
288    let merged = merge_walk_components(&components, &positions);
289    if merged.is_empty() {
290        return Err(GridError::DegenerateGeometry);
291    }
292
293    // Geometry-only recovery schedule for the synthesized-axis path (enabled
294    // under `RecoverySchedule::Auto` when `synthesized_axes`). Disabled for the
295    // chessboard topological adapter, which sets `RecoverySchedule::Off` and
296    // runs its own `CornerStage`-coupled recovery — so its production output
297    // stays byte-identical. The recovery operates on the `(i32, i32)`-keyed
298    // shape, so convert to/from `Coord`.
299    let merged = if let Some(rec_params) = params.recovery.resolve(synthesized_axes) {
300        let ij_in: Vec<std::collections::HashMap<(i32, i32), usize>> = merged
301            .iter()
302            .map(|m| m.iter().map(|(c, &idx)| ((c.u, c.v), idx)).collect())
303            .collect();
304        let local_pitch = crate::shared::recovery_schedule::local_pitch_of(&positions);
305        let recovered = crate::shared::recovery_schedule::recover_components(
306            ij_in,
307            crate::shared::recovery_schedule::RecoveryInputs {
308                features,
309                positions: &positions,
310                local_pitch: &local_pitch,
311                params: &rec_params,
312                validate_params: &params.validate,
313            },
314        );
315        recovered
316            .into_iter()
317            .map(|m| {
318                m.into_iter()
319                    .map(|((u, v), idx)| (Coord::new(u, v), idx))
320                    .collect()
321            })
322            .collect()
323    } else {
324        merged
325    };
326
327    // Process each merged component independently; preserve the labelled
328    // source-indices of every component that yielded a valid solution
329    // so the orchestrator can build the global "unlabelled" set
330    // afterwards.
331    let mut component_outputs: Vec<ComponentOutput> = Vec::new();
332    for labelled in &merged {
333        if labelled.len() < 4 {
334            continue;
335        }
336        match build_component_solution(labelled, features, &positions, params) {
337            Some(out) => component_outputs.push(out),
338            None => continue,
339        }
340    }
341
342    if component_outputs.is_empty() {
343        return Err(GridError::DegenerateGeometry);
344    }
345
346    // Sort components by labelled count descending; ties broken by the
347    // smallest source_index seen so the order is deterministic.
348    component_outputs.sort_by(|a, b| {
349        b.kept_source_indices
350            .len()
351            .cmp(&a.kept_source_indices.len())
352            .then_with(|| a.min_source_index.cmp(&b.min_source_index))
353    });
354
355    let solutions = assemble_solutions(component_outputs, features, dimensions);
356    Ok(solutions)
357}
358
359/// Reunite the walk's labelled components in label space via the shared
360/// local-geometry merge, then return one `Coord`-keyed map per surviving
361/// merged component.
362///
363/// The merge input is ordered exactly as the per-component solutions were
364/// historically presented to consumers: by labelled count descending, ties
365/// broken by the smallest feature index. The previous architecture ran the
366/// per-component validate + fit first and sorted the resulting solutions by
367/// `(kept_source_indices.len() desc, min_source_index asc)`; with this
368/// facade-hosted merge the validate/fit run *after* the merge, so the
369/// pre-merge ordering is reconstructed directly from the walk components
370/// (validate is membership-preserving for the merge-input ordering keys —
371/// labelled count and minimum feature index — so the two orderings agree).
372///
373/// `merge_components_local` re-sorts its working set by size on every
374/// iteration and rebases its output, so this ordering only fixes the
375/// tie-break among equal-size components; pinning it keeps the merge
376/// deterministic and byte-compatible with the prior chessboard-side merge.
377fn merge_walk_components(
378    components: &[walk::TopologicalComponent],
379    positions: &[Point2<f32>],
380) -> Vec<std::collections::HashMap<Coord, usize>> {
381    // Order the walk components by the historical solution-presentation key.
382    let mut ordered: Vec<&walk::TopologicalComponent> = components.iter().collect();
383    ordered.sort_by(|a, b| {
384        b.labelled
385            .len()
386            .cmp(&a.labelled.len())
387            .then_with(|| min_feature_index(a).cmp(&min_feature_index(b)))
388    });
389
390    // Convert each `Coord`-keyed walk map into the `(i32, i32)`-keyed shape
391    // the shared merge consumes. Hold the owned maps alive so the
392    // `ComponentInput` borrows stay valid for the merge call.
393    let owned: Vec<std::collections::HashMap<(i32, i32), usize>> = ordered
394        .iter()
395        .map(|c| {
396            c.labelled
397                .iter()
398                .map(|(coord, &idx)| ((coord.u, coord.v), idx))
399                .collect()
400        })
401        .collect();
402    let views: Vec<ComponentInput<'_>> = owned
403        .iter()
404        .map(|labelled| ComponentInput {
405            labelled,
406            positions,
407        })
408        .collect();
409
410    let merged = merge_components_local(&views, &LocalMergeParams::default());
411    let merged = if merged.components.is_empty() {
412        // Defensive: an empty merge result means no component qualified.
413        // Fall back to the (rebased) input maps so a degenerate merge can't
414        // silently drop everything.
415        owned
416    } else {
417        merged.components
418    };
419
420    merged
421        .into_iter()
422        .map(|m| {
423            m.into_iter()
424                .map(|((u, v), idx)| (Coord::new(u, v), idx))
425                .collect()
426        })
427        .collect()
428}
429
430/// Smallest feature index referenced by a walk component, used as the
431/// tie-break in [`merge_walk_components`]'s ordering.
432fn min_feature_index(component: &walk::TopologicalComponent) -> usize {
433    component
434        .labelled
435        .values()
436        .copied()
437        .min()
438        .unwrap_or(usize::MAX)
439}
440
441/// Build the global "unlabelled" set and assemble the per-component
442/// solutions, attributing every globally-unseen feature to the largest
443/// component so callers that read solely `solutions[0].rejected` see the
444/// same shape as the single-solution path.
445fn assemble_solutions(
446    component_outputs: Vec<ComponentOutput>,
447    features: &[OrientedFeature<2>],
448    dimensions: Option<GridDimensions>,
449) -> Vec<GridSolution> {
450    let mut globally_kept: HashSet<usize> = HashSet::new();
451    let mut globally_validation_dropped: HashSet<usize> = HashSet::new();
452    for out in &component_outputs {
453        for &src in &out.kept_source_indices {
454            globally_kept.insert(src);
455        }
456        for &src in &out.validation_drop_source_indices {
457            globally_validation_dropped.insert(src);
458        }
459    }
460    let mut global_unlabelled: Vec<RejectedFeature> = Vec::new();
461    for feature in features {
462        let src = feature.point.source_index;
463        if globally_kept.contains(&src) {
464            continue;
465        }
466        if globally_validation_dropped.contains(&src) {
467            global_unlabelled.push(RejectedFeature::new(
468                src,
469                None,
470                None,
471                RejectionReason::ValidationDropped,
472            ));
473            continue;
474        }
475        global_unlabelled.push(RejectedFeature::new(
476            src,
477            None,
478            None,
479            RejectionReason::Unlabelled,
480        ));
481    }
482
483    let mut solutions: Vec<GridSolution> = Vec::with_capacity(component_outputs.len());
484    for (idx, out) in component_outputs.into_iter().enumerate() {
485        let ComponentOutput {
486            entries,
487            fit,
488            mut rejected,
489            ..
490        } = out;
491        if idx == 0 {
492            rejected.extend(global_unlabelled.iter().copied());
493        }
494        let grid = LabelledGrid::new(LatticeKind::Square, entries, dimensions);
495        solutions.push(GridSolution::new(grid, Some(fit), rejected));
496    }
497    solutions
498}
499
500pub(super) struct ComponentOutput {
501    pub(super) entries: Vec<GridEntry>,
502    pub(super) fit: LatticeFit,
503    pub(super) rejected: Vec<RejectedFeature>,
504    pub(super) kept_source_indices: HashSet<usize>,
505    pub(super) validation_drop_source_indices: HashSet<usize>,
506    pub(super) min_source_index: usize,
507}
508
509fn build_usable_mask(
510    features: &[OrientedFeature<2>],
511    axes: &[AxisCache],
512    topo: &TopologicalParams,
513) -> Vec<bool> {
514    features
515        .iter()
516        .zip(axes.iter())
517        .map(|(f, cache)| cache.any_informative() && axes_pass_cluster_gate(&f.axes, cache, topo))
518        .collect()
519}
520
521fn build_component_solution(
522    labelled: &std::collections::HashMap<Coord, usize>,
523    features: &[OrientedFeature<2>],
524    positions: &[Point2<f32>],
525    params: &DetectionParams,
526) -> Option<ComponentOutput> {
527    // Reuse the shared advanced validate post-stage (the same module the
528    // chessboard topological adapter and the recovery schedule consume).
529    let validate_entries: Vec<pg_validate::LabelledEntry> = labelled
530        .iter()
531        .map(|(coord, &idx)| pg_validate::LabelledEntry {
532            idx,
533            pixel: features[idx].point.position,
534            grid: (coord.u, coord.v),
535        })
536        .collect();
537    let cell_size = estimate_cell_size(labelled, positions);
538    let validation = pg_validate::validate(&validate_entries, cell_size, &params.validate);
539
540    // Working label set after the validation drop, keyed by Coord.
541    let mut kept: Vec<(Coord, usize)> = labelled
542        .iter()
543        .filter(|(_, &idx)| !validation.blacklist.contains(&idx))
544        .map(|(&coord, &idx)| (coord, idx))
545        .collect();
546    if kept.len() < 4 {
547        return None;
548    }
549
550    let lattice = LatticeKind::Square;
551    let fit_result = run_fit_with_residual_drop(&mut kept, features, positions, lattice, params)?;
552    let FitComponentResult {
553        entries: entries_out,
554        fit,
555        over_threshold,
556    } = fit_result;
557
558    let kept_source_indices: HashSet<usize> = kept
559        .iter()
560        .map(|&(_, idx)| features[idx].point.source_index)
561        .collect();
562    let validation_drop_source_indices: HashSet<usize> = validation
563        .blacklist
564        .iter()
565        .map(|&idx| features[idx].point.source_index)
566        .collect();
567
568    let mut rejected: Vec<RejectedFeature> = Vec::new();
569    for &src in &validation_drop_source_indices {
570        rejected.push(RejectedFeature::new(
571            src,
572            None,
573            None,
574            RejectionReason::ValidationDropped,
575        ));
576    }
577    for r in over_threshold {
578        rejected.push(r);
579    }
580
581    let min_source_index = kept_source_indices
582        .iter()
583        .copied()
584        .min()
585        .unwrap_or(usize::MAX);
586
587    Some(ComponentOutput {
588        entries: entries_out,
589        fit,
590        rejected,
591        kept_source_indices,
592        validation_drop_source_indices,
593        min_source_index,
594    })
595}
596
597/// Run the shared `fit_component` helper, drop over-threshold entries
598/// once, and refit on the remaining set. Mutates `kept` to the surviving
599/// label set. Returns `None` when fewer than four entries survive.
600fn run_fit_with_residual_drop(
601    kept: &mut Vec<(Coord, usize)>,
602    features: &[OrientedFeature<2>],
603    positions: &[Point2<f32>],
604    lattice: LatticeKind,
605    params: &DetectionParams,
606) -> Option<FitComponentResult> {
607    let first = fit_component(kept, features, positions, lattice, params).ok()?;
608    if first.over_threshold.is_empty() {
609        return Some(first);
610    }
611    let drop: HashSet<usize> = first
612        .over_threshold
613        .iter()
614        .map(|r| r.source_index)
615        .collect();
616    kept.retain(|&(_, idx)| !drop.contains(&features[idx].point.source_index));
617    if kept.len() < 4 {
618        return None;
619    }
620    let refit = fit_component(kept, features, positions, lattice, params).ok()?;
621    // Preserve the first pass's over-threshold attribution.
622    Some(FitComponentResult {
623        entries: refit.entries,
624        fit: refit.fit,
625        over_threshold: first.over_threshold,
626    })
627}
628
629/// Per-feature alignment check against the optional axis-cluster
630/// centers in [`TopologicalParams`]. Returns `true` when the gate is
631/// disabled (`axis_cluster_centers.is_none()`) or when at least one
632/// informative axis is within `cluster_axis_tol_rad` of one of the
633/// centers under undirected (mod π) distance.
634fn axes_pass_cluster_gate(
635    axes: &[crate::feature::LocalAxis; 2],
636    cache: &AxisCache,
637    params: &TopologicalParams,
638) -> bool {
639    let Some(centers) = params.axis_cluster_centers else {
640        return true;
641    };
642    let tol = params.cluster_axis_tol_rad;
643    for (axis, &informative) in axes.iter().zip(cache.informative.iter()) {
644        if !informative {
645            continue;
646        }
647        let angle = axis.angle_rad;
648        let d0 = angular_dist_pi(angle, centers[0]);
649        let d1 = angular_dist_pi(angle, centers[1]);
650        if d0 < tol || d1 < tol {
651            return true;
652        }
653    }
654    false
655}
656
657/// Smallest angular distance on the circle with period π. Result in
658/// `[0, π/2]`. The topological cluster gate is the only consumer in this
659/// crate, so the helper is local.
660#[inline]
661fn angular_dist_pi(a: f32, b: f32) -> f32 {
662    let pi = std::f32::consts::PI;
663    // `(diff % π + π) % π` keeps the result in `[0, π)` for any sign
664    // of `diff`. Bare `%` in Rust is the truncated remainder, so the
665    // double `+ π) % π` is necessary.
666    let diff_raw = (a - b) % pi;
667    let positive = (diff_raw + pi) % pi;
668    let complement = pi - positive;
669    if positive < complement {
670        positive
671    } else {
672        complement
673    }
674}
675
676/// Triangulate only the usable features and remap triangle vertex
677/// indices back into the global feature index space.
678pub(in crate::topological) fn triangulate_usable(
679    positions: &[Point2<f32>],
680    usable: &[bool],
681) -> delaunay::Triangulation {
682    let mut packed_to_global: Vec<usize> = Vec::with_capacity(positions.len());
683    let mut packed_positions: Vec<Point2<f32>> = Vec::with_capacity(positions.len());
684    for (i, (&u, &p)) in usable.iter().zip(positions.iter()).enumerate() {
685        if u {
686            packed_to_global.push(i);
687            packed_positions.push(p);
688        }
689    }
690    let mut triangulation = delaunay::triangulate(&packed_positions);
691    // After triangulation, indices reference the packed slice. We remap
692    // `triangles` to global indices; `halfedges` stay valid because
693    // half-edges are offsets into `triangles`, not vertex indices.
694    for v in triangulation.triangles.iter_mut() {
695        *v = packed_to_global[*v];
696    }
697    triangulation
698}
699
700/// Mean labelled-pair edge length over cardinal lattice neighbours.
701/// Used as the `cell_size` input to the shared validate post-stage.
702///
703/// Falls back to `1.0` when no cardinal pair exists, in which case the
704/// validate caller's relative tolerances reduce to absolute thresholds.
705/// In practice the topological pipeline only reaches this helper with
706/// at least one labelled quad, so the fallback is defensive.
707fn estimate_cell_size(
708    labelled: &std::collections::HashMap<Coord, usize>,
709    positions: &[Point2<f32>],
710) -> f32 {
711    use crate::lattice::SQUARE_CARDINAL_OFFSETS;
712
713    let mut sum = 0.0_f32;
714    let mut count: usize = 0;
715    for (&coord, &idx) in labelled {
716        let here = positions[idx];
717        for offset in &SQUARE_CARDINAL_OFFSETS {
718            let neigh = Coord::new(coord.u + offset.u, coord.v + offset.v);
719            if let Some(&n_idx) = labelled.get(&neigh) {
720                let nb = positions[n_idx];
721                let dx = nb.x - here.x;
722                let dy = nb.y - here.y;
723                sum += (dx * dx + dy * dy).sqrt();
724                count += 1;
725            }
726        }
727    }
728    if count == 0 {
729        return 1.0;
730    }
731    sum / count as f32
732}
733
734#[cfg(test)]
735mod tests {
736    use super::*;
737    use crate::feature::{LocalAxis, PointFeature};
738
739    fn axis_aligned_features(rows: i32, cols: i32, s: f32) -> Vec<OrientedFeature<2>> {
740        let origin = 50.0_f32;
741        let mut out = Vec::with_capacity((rows * cols) as usize);
742        let mut idx = 0_usize;
743        for j in 0..rows {
744            for i in 0..cols {
745                let x = (i as f32) * s + origin;
746                let y = (j as f32) * s + origin;
747                let point = PointFeature::new(idx, Point2::new(x, y));
748                let axes = [
749                    LocalAxis::new(0.0_f32, Some(0.05)),
750                    LocalAxis::new(std::f32::consts::FRAC_PI_2, Some(0.05)),
751                ];
752                out.push(OrientedFeature::new(point, axes));
753                idx += 1;
754            }
755        }
756        out
757    }
758
759    #[test]
760    fn default_params_match_regression_values() {
761        let p = TopologicalParams::default();
762        assert!((p.axis_align_tol_rad - 15.0_f32.to_radians()).abs() < 1e-5);
763        assert!((p.max_axis_sigma_rad - 0.6).abs() < 1e-5);
764        assert!((p.opposing_edge_ratio_max - 1.5).abs() < 1e-5);
765        assert!((p.edge_length_min_rel - 0.4).abs() < 1e-5);
766        assert!((p.edge_length_max_rel - 2.5).abs() < 1e-5);
767        assert_eq!(p.min_corners_for_component, 4);
768        assert_eq!(p.min_quads_per_component, 1);
769        assert!(p.axis_cluster_centers.is_none());
770        assert!((p.cluster_axis_tol_rad - 16.0_f32.to_radians()).abs() < 1e-5);
771    }
772
773    #[test]
774    fn clean_5x5_grid_is_fully_labelled() {
775        let features = axis_aligned_features(5, 5, 20.0);
776        let params = DetectionParams::default();
777        let mut solutions =
778            detect_square_oriented2_topological_all(&features, None, &params, false).unwrap();
779        assert_eq!(solutions.len(), 1);
780        let solution = solutions.remove(0);
781        assert_eq!(solution.grid.entries.len(), 25);
782        let fit = solution.fit.unwrap();
783        assert!(fit.residuals.max_px < 0.01, "{}", fit.residuals.max_px);
784    }
785
786    #[test]
787    fn fewer_than_three_features_errors() {
788        let features = axis_aligned_features(1, 2, 20.0);
789        let params = DetectionParams::default();
790        let err =
791            detect_square_oriented2_topological_all(&features, None, &params, false).unwrap_err();
792        assert_eq!(err, GridError::InsufficientEvidence);
793    }
794
795    #[test]
796    fn cluster_gate_drops_off_axis_features() {
797        // 5×5 axis-aligned grid (axes at 0°, 90°) + 4 noise features
798        // whose axes both sit near 45°. With the cluster gate centered
799        // at [0, π/2] and a 16° tolerance, the noise features must be
800        // dropped pre-Delaunay; with the gate disabled they survive.
801        let mut features = axis_aligned_features(5, 5, 20.0);
802        let extra: [(f32, f32); 4] = [(40.0, 40.0), (180.0, 40.0), (40.0, 180.0), (180.0, 180.0)];
803        let next = features.len();
804        for (i, &(x, y)) in extra.iter().enumerate() {
805            let point = PointFeature::new(next + i, Point2::new(x, y));
806            let off_axis = std::f32::consts::FRAC_PI_4;
807            let axes = [
808                LocalAxis::new(off_axis, Some(0.05)),
809                LocalAxis::new(off_axis + std::f32::consts::FRAC_PI_2, Some(0.05)),
810            ];
811            features.push(OrientedFeature::new(point, axes));
812        }
813
814        let params_on = DetectionParams::default().with_topological(
815            TopologicalParams::default()
816                .with_axis_cluster_centers([0.0, std::f32::consts::FRAC_PI_2]),
817        );
818        let mut sol_on =
819            detect_square_oriented2_topological_all(&features, None, &params_on, false).unwrap();
820        assert_eq!(sol_on.len(), 1);
821        let primary = sol_on.remove(0);
822        assert_eq!(primary.grid.entries.len(), 25, "gate must keep the 5×5");
823
824        let params_off = DetectionParams::default();
825        let mut sol_off =
826            detect_square_oriented2_topological_all(&features, None, &params_off, false).unwrap();
827        assert_eq!(sol_off.len(), 1);
828        let primary_off = sol_off.remove(0);
829        assert_eq!(primary_off.grid.entries.len(), 25);
830        let noise_ids: std::collections::HashSet<usize> = (next..next + 4).collect();
831        for r in &primary.rejected {
832            if noise_ids.contains(&r.source_index) {
833                assert_eq!(r.reason, RejectionReason::Unlabelled);
834            }
835        }
836    }
837
838    #[test]
839    fn axes_pass_cluster_gate_with_no_centers_is_identity() {
840        let cache = AxisCache {
841            angle_rad: [std::f32::consts::FRAC_PI_4, std::f32::consts::FRAC_PI_4],
842            informative: [true, true],
843        };
844        let axes = [
845            LocalAxis::new(std::f32::consts::FRAC_PI_4, Some(0.05_f32)),
846            LocalAxis::new(std::f32::consts::FRAC_PI_4, Some(0.05_f32)),
847        ];
848        let params_off = TopologicalParams::default();
849        assert!(axes_pass_cluster_gate(&axes, &cache, &params_off));
850        let params_on = TopologicalParams::default()
851            .with_axis_cluster_centers([0.0_f32, std::f32::consts::FRAC_PI_2]);
852        assert!(!axes_pass_cluster_gate(&axes, &cache, &params_on));
853    }
854
855    #[test]
856    fn angular_dist_pi_is_undirected() {
857        let pi = std::f32::consts::PI;
858        let d_zero = angular_dist_pi(0.0, pi);
859        assert!(d_zero < 1e-5, "{d_zero}");
860        let d_perp = angular_dist_pi(0.0, std::f32::consts::FRAC_PI_2);
861        assert!((d_perp - std::f32::consts::FRAC_PI_2).abs() < 1e-5);
862        let d_signed = angular_dist_pi(-0.1, std::f32::consts::PI + 0.1);
863        assert!((d_signed - 0.2).abs() < 1e-4, "{d_signed}");
864        let d_seam = angular_dist_pi(std::f32::consts::PI - 0.05, 0.05);
865        assert!((d_seam - 0.1).abs() < 1e-5, "{d_seam}");
866    }
867}
868
869// Relocated from detect/advanced/square/topological_trace.rs.
870pub mod trace;
871
872mod hex_detect;
873pub(crate) use hex_detect::detect_hex_oriented3_topological_all;