Skip to main content

geo_polygonize_core/
options.rs

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