1use serde::{Deserialize, Serialize};
2use ts_rs::TS;
3
4use crate::error::{PolygonizeError, Result};
5
6#[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)]
66pub struct PolygonizerOptions {
71 pub node_input: bool,
79
80 pub precision_model: PrecisionModel,
84
85 #[serde(default)]
93 pub pre_snap_tolerance: f64,
94
95 pub extract_only_polygonal: bool,
101
102 pub snap_strategy: SnapStrategy,
109
110 pub noding: NodingOptions,
112
113 pub containment: ContainmentOptions,
116
117 pub determinism: DeterminismOptions,
119
120 pub diagnostics: DiagnosticsOptions,
122
123 pub provenance: ProvenanceOptions,
125
126 pub z: ZOptions,
128
129 pub output_filter: OutputFilterOptions,
131
132 #[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)]
263pub enum PrecisionModel {
265 #[default]
267 Floating,
268 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)]
291pub 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 Centroid,
321 RepresentativePointInsidePolygon,
323 LexicographicMinVertex,
325 CanonicalBoundaryHash,
327}
328
329#[derive(Clone, Debug, Serialize, Deserialize, TS)]
330#[ts(export)]
331pub enum NodingBackend {
332 Snap,
334 Advanced,
339}
340
341#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, TS)]
342#[ts(export)]
343pub enum NodingGuarantee {
344 #[default]
346 Unchecked,
347 Validate,
349 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 #[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,
428 #[default]
430 InterpolateAlongEdge,
431 PreferNearestEndpoint,
433 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 pub conflict_tolerance: f64,
444}
445
446#[derive(Clone, Debug, Default, Serialize, Deserialize, TS)]
447#[serde(default)]
448#[ts(export)]
449pub struct OutputFilterOptions {
450 #[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}