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