Skip to main content

geo_polygonize_core/
options.rs

1use serde::{Deserialize, Serialize};
2use ts_rs::TS;
3
4use crate::error::{PolygonizeError, Result};
5use std::sync::{
6    atomic::{AtomicBool, Ordering},
7    Arc,
8};
9
10/// Maximum noding work items between cooperative cancellation checks.
11pub(crate) const CANCELLATION_CHECK_INTERVAL: usize = 256;
12/// Maximum items accepted by an uninterruptible standard-library sort when
13/// cooperative cancellation is enabled.
14pub(crate) const MAX_UNCANCELLABLE_SORT_ITEMS: usize = 1_000_000;
15
16/// Cooperative native cancellation handle for an [`ExecutionPolicy`].
17#[derive(Clone, Debug, Default)]
18pub struct CancellationToken(Arc<AtomicBool>);
19
20impl CancellationToken {
21    pub fn new() -> Self {
22        Self::default()
23    }
24
25    pub fn cancel(&self) {
26        self.0.store(true, Ordering::Release);
27    }
28
29    pub fn reset(&self) {
30        self.0.store(false, Ordering::Release);
31    }
32
33    pub fn is_cancelled(&self) -> bool {
34        self.0.load(Ordering::Acquire)
35    }
36}
37
38/// Non-semantic limits applied before polygonization begins.
39///
40/// Limits are opt-in. They are intentionally separate from `PolygonizerOptions`
41/// so equivalent topology options retain identical meaning across callers.
42#[derive(Clone, Debug, Default)]
43pub struct ExecutionPolicy {
44    pub cancellation_token: Option<CancellationToken>,
45    #[cfg(test)]
46    pub(crate) cancel_at_work_item: Option<(CancellationToken, usize)>,
47    pub max_input_line_strings: Option<usize>,
48    pub max_input_segments: Option<usize>,
49    pub max_input_coordinates: Option<usize>,
50    pub max_noded_segments: Option<usize>,
51    pub max_candidate_pairs: Option<usize>,
52    pub max_exact_intersection_calls: Option<usize>,
53    pub max_split_events: Option<usize>,
54    pub max_noding_iterations: Option<usize>,
55    pub max_graph_nodes: Option<usize>,
56    pub max_graph_edges: Option<usize>,
57    pub max_rings: Option<usize>,
58    pub max_output_polygons: Option<usize>,
59    pub max_output_coordinates: Option<usize>,
60}
61
62impl ExecutionPolicy {
63    pub(crate) fn check(&self, stage: &str, limit: Option<usize>, observed: usize) -> Result<()> {
64        if let Some(limit) = limit.filter(|limit| observed > *limit) {
65            return Err(PolygonizeError::ResourceLimitExceeded {
66                stage: stage.to_string(),
67                limit,
68                observed,
69            });
70        }
71        Ok(())
72    }
73
74    pub(crate) fn has_noding_work_budgets(&self) -> bool {
75        self.max_candidate_pairs.is_some()
76            || self.max_exact_intersection_calls.is_some()
77            || self.max_split_events.is_some()
78            || self.max_noding_iterations.is_some()
79    }
80
81    pub(crate) fn has_cancellation(&self) -> bool {
82        self.cancellation_token.is_some()
83    }
84
85    pub(crate) fn check_noding_work(
86        &self,
87        candidate_pairs: usize,
88        exact_intersection_calls: usize,
89    ) -> Result<()> {
90        self.check("candidate_pairs", self.max_candidate_pairs, candidate_pairs)?;
91        self.check(
92            "exact_intersection_calls",
93            self.max_exact_intersection_calls,
94            exact_intersection_calls,
95        )
96    }
97
98    pub(crate) fn check_split_events(&self, observed: usize) -> Result<()> {
99        self.check("split_events", self.max_split_events, observed)
100    }
101
102    pub(crate) fn check_noding_iterations(&self, observed: usize) -> Result<()> {
103        self.check("noding_iterations", self.max_noding_iterations, observed)
104    }
105
106    pub(crate) fn check_cancelled(&self, stage: &str) -> Result<()> {
107        if self
108            .cancellation_token
109            .as_ref()
110            .is_some_and(CancellationToken::is_cancelled)
111        {
112            return Err(PolygonizeError::Cancelled {
113                stage: stage.to_string(),
114            });
115        }
116        Ok(())
117    }
118
119    pub(crate) fn check_cancelled_every(&self, stage: &str, work_items: usize) -> Result<()> {
120        #[cfg(test)]
121        if let Some((token, cancel_at)) = &self.cancel_at_work_item {
122            if work_items == *cancel_at {
123                token.cancel();
124            }
125        }
126        if work_items.is_multiple_of(CANCELLATION_CHECK_INTERVAL) {
127            self.check_cancelled(stage)?;
128        }
129        Ok(())
130    }
131
132    pub(crate) fn check_uncancellable_sort(&self, stage: &str, items: usize) -> Result<()> {
133        self.check_cancelled(stage)?;
134        if self.has_cancellation() {
135            self.check(stage, Some(MAX_UNCANCELLABLE_SORT_ITEMS), items)?;
136        }
137        Ok(())
138    }
139}
140
141#[derive(Clone, Debug, Serialize, Deserialize, TS)]
142#[serde(default, deny_unknown_fields)]
143#[ts(export)]
144/// The canonical configuration object for the `geo-polygonize` engine.
145///
146/// This struct controls every aspect of the polygonization pipeline, including
147/// topological robustness, feature output, containment policies, noding, and determinism.
148pub struct PolygonizerOptions {
149    /// Whether to robustly node the input before polygonization.
150    ///
151    /// Enable this for real-world linework where segment intersections may not
152    /// already exist as explicit vertices. This is slower than the fast path but
153    /// avoids missing faces and unresolved crossings.
154    ///
155    /// Default: `false`
156    pub node_input: bool,
157
158    /// Coordinate precision used for topology and noding.
159    ///
160    /// Default: `PrecisionModel::Floating`
161    pub precision_model: PrecisionModel,
162
163    /// Snap input segments to nearby vertices from exact-noded input linework before grid noding.
164    ///
165    /// A value of `0.0` disables pre-snap. This mirrors the CFB/Shapely
166    /// `snap(line, unary_union(all_lines), tolerance)` step closely enough to
167    /// close small CAD gaps before polygonization.
168    ///
169    /// Default: `0.0`
170    #[serde(default)]
171    pub pre_snap_tolerance: f64,
172
173    /// If `true`, only pure, outermost polygonal shells are returned.
174    ///
175    /// Floating dangles, internal cut-lines, or invalid rings will be discarded.
176    ///
177    /// Default: `false`
178    pub extract_only_polygonal: bool,
179
180    /// Controls robust snap noding and output coordinate handling.
181    ///
182    /// See `SnapStrategy` for differences between strict `Grid` snapping and
183    /// Shapely/GEOS `GeosCompat` strategies.
184    ///
185    /// Default: `SnapStrategy::Grid`
186    pub snap_strategy: SnapStrategy,
187
188    /// Configures the noding engine backend and behavior.
189    pub noding: NodingOptions,
190
191    /// Configures how topological relationships (containment) are calculated
192    /// during face formation.
193    pub containment: ContainmentOptions,
194
195    /// Configuration for enforcing exact topological determinism.
196    pub determinism: DeterminismOptions,
197
198    /// Options for capturing diagnostic topology failures.
199    pub diagnostics: DiagnosticsOptions,
200
201    /// Options for mapping final faces back to original input geometry IDs.
202    pub provenance: ProvenanceOptions,
203
204    /// Controls Z reconstruction and same-XY conflict handling.
205    pub z: ZOptions,
206
207    /// Optional application-level filtering applied after topology is established.
208    pub output_filter: OutputFilterOptions,
209
210    /// An optional identifier for the input dataset.
211    #[ts(optional)]
212    pub input_profile_id: Option<String>,
213}
214
215impl Default for PolygonizerOptions {
216    fn default() -> Self {
217        Self {
218            node_input: false,
219            precision_model: PrecisionModel::Floating,
220            pre_snap_tolerance: 0.0,
221            extract_only_polygonal: false,
222            snap_strategy: SnapStrategy::Grid,
223            noding: NodingOptions::default(),
224            containment: ContainmentOptions::default(),
225            determinism: DeterminismOptions::default(),
226            diagnostics: DiagnosticsOptions::default(),
227            provenance: ProvenanceOptions::default(),
228            z: ZOptions::default(),
229            output_filter: OutputFilterOptions::default(),
230            input_profile_id: None,
231        }
232    }
233}
234
235impl PolygonizerOptions {
236    pub fn cfb_robust_v1() -> Self {
237        Self {
238            node_input: true,
239            precision_model: PrecisionModel::FixedGrid { grid_size: 0.1 },
240            pre_snap_tolerance: 0.5,
241            extract_only_polygonal: false,
242            snap_strategy: SnapStrategy::GeosCompat,
243            noding: NodingOptions {
244                backend: NodingBackend::Snap,
245                guarantee: NodingGuarantee::Unchecked,
246            },
247            containment: ContainmentOptions {
248                touch_policy: TouchPolicy::AllowPointTouchDisallowEdgeShare,
249            },
250            determinism: DeterminismOptions {
251                canonical_sort: true,
252                canonical_ring_rotation: true,
253                stable_tie_breaks: true,
254            },
255            diagnostics: DiagnosticsOptions {
256                enabled: true,
257                report_mode: true,
258                timings: false,
259            },
260            provenance: ProvenanceOptions {
261                enabled: true,
262                include_boundary_line_ids: true,
263            },
264            z: ZOptions::default(),
265            output_filter: OutputFilterOptions::default(),
266            input_profile_id: Some("cfb_robust_v1".to_string()),
267        }
268    }
269
270    pub fn validate(&self) -> Result<()> {
271        for (field, value) in [("pre_snap_tolerance", self.pre_snap_tolerance)] {
272            if !value.is_finite() || value < 0.0 {
273                return Err(PolygonizeError::InvalidArgumentType {
274                    field: field.to_string(),
275                    expected: "a finite non-negative number".to_string(),
276                    actual: value.to_string(),
277                });
278            }
279        }
280
281        if let PrecisionModel::FixedGrid { grid_size } = self.precision_model {
282            if !grid_size.is_finite() || grid_size <= 0.0 {
283                return Err(PolygonizeError::InvalidArgumentType {
284                    field: "precision_model.grid_size".to_string(),
285                    expected: "a finite positive number".to_string(),
286                    actual: grid_size.to_string(),
287                });
288            }
289            if self.node_input && matches!(self.noding.backend, NodingBackend::Advanced) {
290                return Err(PolygonizeError::UnsupportedOptionCombination {
291                    reason: "the Advanced compatibility noder supports floating precision only"
292                        .to_string(),
293                });
294            }
295        }
296
297        if self.pre_snap_tolerance > 0.0 && !self.node_input {
298            return Err(PolygonizeError::UnsupportedOptionCombination {
299                reason: "pre_snap_tolerance requires node_input=true".to_string(),
300            });
301        }
302
303        if matches!(
304            self.noding.guarantee,
305            NodingGuarantee::CertifiedFixedPrecision
306        ) && (!self.node_input
307            || !matches!(self.precision_model, PrecisionModel::FixedGrid { .. })
308            || !matches!(self.noding.backend, NodingBackend::Snap)
309            || !matches!(self.snap_strategy, SnapStrategy::Grid))
310        {
311            return Err(PolygonizeError::UnsupportedOptionCombination {
312                reason: "CertifiedFixedPrecision requires node_input=true, FixedGrid precision, the Snap backend, and the Grid snap strategy".to_string(),
313            });
314        }
315
316        if let Some(value) = self.output_filter.minimum_face_area {
317            if !value.is_finite() || value < 0.0 {
318                return Err(PolygonizeError::InvalidArgumentType {
319                    field: "output_filter.minimum_face_area".to_string(),
320                    expected: "a finite non-negative number".to_string(),
321                    actual: value.to_string(),
322                });
323            }
324        }
325
326        if !self.z.conflict_tolerance.is_finite() || self.z.conflict_tolerance < 0.0 {
327            return Err(PolygonizeError::InvalidArgumentType {
328                field: "z.conflict_tolerance".to_string(),
329                expected: "a finite non-negative number".to_string(),
330                actual: self.z.conflict_tolerance.to_string(),
331            });
332        }
333
334        Ok(())
335    }
336}
337
338#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, TS)]
339#[serde(tag = "type", rename_all = "snake_case")]
340#[ts(export)]
341/// Coordinate precision used by the topology pipeline.
342pub enum PrecisionModel {
343    /// Preserve input coordinates and compute intersections in floating point.
344    #[default]
345    Floating,
346    /// Round topology coordinates to a fixed grid of positive cell size.
347    FixedGrid { grid_size: f64 },
348}
349
350impl PrecisionModel {
351    pub fn grid_size(self) -> f64 {
352        match self {
353            Self::Floating => 0.0,
354            Self::FixedGrid { grid_size } => grid_size,
355        }
356    }
357
358    pub fn from_grid_size(grid_size: f64) -> Self {
359        if grid_size == 0.0 {
360            Self::Floating
361        } else {
362            Self::FixedGrid { grid_size }
363        }
364    }
365}
366
367#[derive(Clone, Debug, Serialize, Deserialize, TS)]
368#[ts(export)]
369/// Strategy for robust snap noding and output coordinates.
370///
371/// `Grid` uses the precision grid for both topology and output coordinates.
372/// `GeosCompat` uses the grid for topology, then restores one deterministic nearest source XY
373/// coordinate per snapped node. This targets Shapely-style `snap` followed by full-precision
374/// noding and polygonization; it does not emulate `set_precision` output.
375///
376/// **Scale Guidance:**
377/// Use `Grid` when an explicit precision model is the desired output contract.
378/// Use `GeosCompat` when the grid is a robustness aid but source-coordinate fidelity matters.
379/// Both strategies are deterministic; exact GEOS/Shapely parity is not guaranteed for
380/// degenerate or many-to-one snaps.
381pub enum SnapStrategy {
382    Grid,
383    GeosCompat,
384}
385
386#[derive(Clone, Debug, Serialize, Deserialize, TS)]
387#[ts(export)]
388pub enum TouchPolicy {
389    AllowPointTouchDisallowEdgeShare,
390    TreatAnyTouchAsDisjoint,
391    AllowEdgeShare,
392}
393
394#[derive(Clone, Debug, Serialize, Deserialize, TS)]
395#[ts(export)]
396pub enum TileOwnershipPolicy {
397    /// Fast ownership using the polygon centroid, which may lie outside a concave polygon.
398    Centroid,
399    /// Ownership using a point guaranteed to intersect the polygon interior when one exists.
400    RepresentativePointInsidePolygon,
401    /// Ownership using the smallest boundary vertex in XY order.
402    LexicographicMinVertex,
403    /// Legacy deterministic ownership policy; now uses a safe interior point.
404    CanonicalBoundaryHash,
405}
406
407#[derive(Clone, Debug, Serialize, Deserialize, TS)]
408#[ts(export)]
409pub enum NodingBackend {
410    /// Snap-rounding noder using the configured precision grid.
411    Snap,
412    /// Deprecated compatibility alias for exact (`grid_size = 0`) snap noding.
413    ///
414    /// The experimental sweep-line implementation was retired because it did not
415    /// maintain the invariants required for complete intersection enumeration.
416    Advanced,
417}
418
419#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, TS)]
420#[ts(export)]
421pub enum NodingGuarantee {
422    /// Trust the selected noder without checking its output.
423    #[default]
424    Unchecked,
425    /// Verify that the resulting segments are fully noded and normalized.
426    Validate,
427    /// Use hot-pixel snap rounding and verify the fixed-grid result.
428    CertifiedFixedPrecision,
429}
430
431#[derive(Clone, Debug, Serialize, Deserialize, TS)]
432#[serde(default)]
433#[ts(export)]
434pub struct NodingOptions {
435    pub backend: NodingBackend,
436    pub guarantee: NodingGuarantee,
437}
438
439impl Default for NodingOptions {
440    fn default() -> Self {
441        Self {
442            backend: NodingBackend::Snap,
443            guarantee: NodingGuarantee::Unchecked,
444        }
445    }
446}
447
448#[derive(Clone, Debug, Serialize, Deserialize, TS)]
449#[serde(default)]
450#[ts(export)]
451pub struct ContainmentOptions {
452    pub touch_policy: TouchPolicy,
453}
454
455impl Default for ContainmentOptions {
456    fn default() -> Self {
457        Self {
458            touch_policy: TouchPolicy::AllowPointTouchDisallowEdgeShare,
459        }
460    }
461}
462
463#[derive(Clone, Debug, Serialize, Deserialize, TS)]
464#[serde(default)]
465#[ts(export)]
466pub struct DeterminismOptions {
467    pub canonical_sort: bool,
468    pub canonical_ring_rotation: bool,
469    pub stable_tie_breaks: bool,
470}
471
472impl Default for DeterminismOptions {
473    fn default() -> Self {
474        Self {
475            canonical_sort: true,
476            canonical_ring_rotation: true,
477            stable_tie_breaks: true,
478        }
479    }
480}
481
482#[derive(Clone, Debug, Default, Serialize, Deserialize, TS)]
483#[serde(default)]
484#[ts(export)]
485pub struct DiagnosticsOptions {
486    pub enabled: bool,
487    pub report_mode: bool,
488    // Collect phase timings without enabling the more expensive work counters.
489    #[serde(default)]
490    pub timings: bool,
491}
492
493#[derive(Clone, Debug, Default, Serialize, Deserialize, TS)]
494#[serde(default)]
495#[ts(export)]
496pub struct ProvenanceOptions {
497    pub enabled: bool,
498    pub include_boundary_line_ids: bool,
499}
500
501#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, TS)]
502#[ts(export)]
503pub enum ZPolicy {
504    /// Ignore input Z and emit `0.0`.
505    Ignore,
506    /// Interpolate split-vertex Z along each source edge before graph reconciliation.
507    #[default]
508    InterpolateAlongEdge,
509    /// Use the nearest source endpoint's Z for each split vertex.
510    PreferNearestEndpoint,
511    /// Interpolate Z, then fail if one XY node receives conflicting values.
512    ErrorOnConflict,
513}
514
515#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, TS)]
516#[serde(default)]
517#[ts(export)]
518pub struct ZOptions {
519    pub policy: ZPolicy,
520    /// Z values at one XY node conflict when their difference exceeds this value.
521    pub conflict_tolerance: f64,
522}
523
524#[derive(Clone, Debug, Default, Serialize, Deserialize, TS)]
525#[serde(default)]
526#[ts(export)]
527pub struct OutputFilterOptions {
528    /// Keep faces whose area is greater than or equal to this value.
529    #[ts(type = "number | null", optional)]
530    pub minimum_face_area: Option<f64>,
531}
532
533#[derive(Clone, Debug, Default, Serialize, Deserialize, TS)]
534#[ts(export)]
535pub enum DedupPolicy {
536    #[default]
537    KeepAll,
538    CanonicalRingHash,
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544
545    #[test]
546    fn partial_json_uses_defaults() {
547        let options: PolygonizerOptions = serde_json::from_str(
548            r#"{"diagnostics":{"enabled":true},"output_filter":{"minimum_face_area":2.0}}"#,
549        )
550        .unwrap();
551
552        assert!(options.diagnostics.enabled);
553        assert!(!options.diagnostics.report_mode);
554        assert_eq!(options.precision_model, PrecisionModel::Floating);
555        assert_eq!(options.output_filter.minimum_face_area, Some(2.0));
556        assert_eq!(options.z, ZOptions::default());
557
558        let fixed: PolygonizerOptions =
559            serde_json::from_str(r#"{"precision_model":{"type":"fixed_grid","grid_size":0.25}}"#)
560                .unwrap();
561        assert_eq!(
562            fixed.precision_model,
563            PrecisionModel::FixedGrid { grid_size: 0.25 }
564        );
565
566        let validated: PolygonizerOptions =
567            serde_json::from_str(r#"{"noding":{"guarantee":"Validate"}}"#).unwrap();
568        assert_eq!(validated.noding.guarantee, NodingGuarantee::Validate);
569
570        let z: PolygonizerOptions =
571            serde_json::from_str(r#"{"z":{"policy":"ErrorOnConflict"}}"#).unwrap();
572        assert_eq!(z.z.policy, ZPolicy::ErrorOnConflict);
573        assert_eq!(z.z.conflict_tolerance, 0.0);
574    }
575
576    #[test]
577    fn legacy_snap_grid_size_is_rejected_instead_of_ignored() {
578        let error =
579            serde_json::from_str::<PolygonizerOptions>(r#"{"snap_grid_size":0.1}"#).unwrap_err();
580        assert!(error.to_string().contains("unknown field `snap_grid_size`"));
581    }
582
583    #[test]
584    fn validation_rejects_invalid_options() {
585        for value in [-1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
586            let options = PolygonizerOptions {
587                node_input: true,
588                pre_snap_tolerance: value,
589                ..Default::default()
590            };
591            assert!(options.validate().is_err());
592
593            let options = PolygonizerOptions {
594                output_filter: OutputFilterOptions {
595                    minimum_face_area: Some(value),
596                },
597                ..Default::default()
598            };
599            assert!(options.validate().is_err());
600
601            let options = PolygonizerOptions {
602                z: ZOptions {
603                    conflict_tolerance: value,
604                    ..Default::default()
605                },
606                ..Default::default()
607            };
608            assert!(options.validate().is_err());
609        }
610
611        for grid_size in [-1.0, 0.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
612            let options = PolygonizerOptions {
613                precision_model: PrecisionModel::FixedGrid { grid_size },
614                ..Default::default()
615            };
616            assert!(options.validate().is_err());
617        }
618
619        let options = PolygonizerOptions {
620            pre_snap_tolerance: 1.0,
621            ..Default::default()
622        };
623        assert!(matches!(
624            options.validate(),
625            Err(PolygonizeError::UnsupportedOptionCombination { .. })
626        ));
627
628        let options = PolygonizerOptions {
629            node_input: true,
630            precision_model: PrecisionModel::FixedGrid { grid_size: 1.0 },
631            noding: NodingOptions {
632                backend: NodingBackend::Advanced,
633                guarantee: NodingGuarantee::Unchecked,
634            },
635            ..Default::default()
636        };
637        assert!(matches!(
638            options.validate(),
639            Err(PolygonizeError::UnsupportedOptionCombination { .. })
640        ));
641
642        let certified = PolygonizerOptions {
643            node_input: true,
644            precision_model: PrecisionModel::FixedGrid { grid_size: 1.0 },
645            noding: NodingOptions {
646                guarantee: NodingGuarantee::CertifiedFixedPrecision,
647                ..Default::default()
648            },
649            ..Default::default()
650        };
651        assert!(certified.validate().is_ok());
652        assert!(PolygonizerOptions {
653            node_input: false,
654            ..certified.clone()
655        }
656        .validate()
657        .is_err());
658        assert!(PolygonizerOptions {
659            precision_model: PrecisionModel::Floating,
660            ..certified
661        }
662        .validate()
663        .is_err());
664
665        assert!(PolygonizerOptions::default().validate().is_ok());
666    }
667
668    #[test]
669    fn cancellation_enabled_sorts_have_a_fixed_item_ceiling() {
670        let policy = ExecutionPolicy {
671            cancellation_token: Some(CancellationToken::new()),
672            ..Default::default()
673        };
674        assert!(policy
675            .check_uncancellable_sort("test_sort", MAX_UNCANCELLABLE_SORT_ITEMS)
676            .is_ok());
677        assert!(matches!(
678            policy.check_uncancellable_sort(
679                "test_sort",
680                MAX_UNCANCELLABLE_SORT_ITEMS + 1
681            ),
682            Err(PolygonizeError::ResourceLimitExceeded {
683                stage,
684                limit: MAX_UNCANCELLABLE_SORT_ITEMS,
685                observed,
686            }) if stage == "test_sort" && observed == MAX_UNCANCELLABLE_SORT_ITEMS + 1
687        ));
688    }
689}