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)]
9pub struct PolygonizerOptions {
14 pub node_input: bool,
22
23 pub precision_model: PrecisionModel,
27
28 #[serde(default)]
36 pub pre_snap_tolerance: f64,
37
38 pub extract_only_polygonal: bool,
44
45 pub snap_strategy: SnapStrategy,
52
53 pub noding: NodingOptions,
55
56 pub containment: ContainmentOptions,
59
60 pub determinism: DeterminismOptions,
62
63 pub diagnostics: DiagnosticsOptions,
65
66 pub provenance: ProvenanceOptions,
68
69 pub z: ZOptions,
71
72 pub output_filter: OutputFilterOptions,
74
75 #[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)]
206pub enum PrecisionModel {
208 #[default]
210 Floating,
211 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)]
234pub 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 Centroid,
264 RepresentativePointInsidePolygon,
266 LexicographicMinVertex,
268 CanonicalBoundaryHash,
270}
271
272#[derive(Clone, Debug, Serialize, Deserialize, TS)]
273#[ts(export)]
274pub enum NodingBackend {
275 Snap,
277 Advanced,
282}
283
284#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, TS)]
285#[ts(export)]
286pub enum NodingGuarantee {
287 #[default]
289 Unchecked,
290 Validate,
292 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 #[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,
371 #[default]
373 InterpolateAlongEdge,
374 PreferNearestEndpoint,
376 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 pub conflict_tolerance: f64,
387}
388
389#[derive(Clone, Debug, Default, Serialize, Deserialize, TS)]
390#[serde(default)]
391#[ts(export)]
392pub struct OutputFilterOptions {
393 #[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}