ifc_lite_processing/analytic_export/
checks.rs1use super::SweptDiskOccurrence;
8use ifc_lite_geometry::analytic::{AnalyticCurveSegment, AnalyticStatus};
9use serde::Serialize;
10
11#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
13#[non_exhaustive]
14pub struct SweptDiskCheckOptions {
15 pub zero_length_tolerance_m: f64,
17 pub gap_tolerance_m: f64,
19 pub tangent_tolerance_rad: f64,
21}
22
23impl Default for SweptDiskCheckOptions {
24 fn default() -> Self {
25 Self {
26 zero_length_tolerance_m: 1e-9,
27 gap_tolerance_m: 1e-6,
28 tangent_tolerance_rad: 1e-6,
29 }
30 }
31}
32
33impl SweptDiskCheckOptions {
34 pub fn validate(&self) -> Result<(), SweptDiskCheckError> {
36 for (name, value) in [
37 ("zero_length_tolerance_m", self.zero_length_tolerance_m),
38 ("gap_tolerance_m", self.gap_tolerance_m),
39 ("tangent_tolerance_rad", self.tangent_tolerance_rad),
40 ] {
41 if !value.is_finite() || value < 0.0 {
42 return Err(SweptDiskCheckError { option: name });
43 }
44 }
45 Ok(())
46 }
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51#[non_exhaustive]
52pub struct SweptDiskCheckError {
53 pub option: &'static str,
54}
55
56impl std::fmt::Display for SweptDiskCheckError {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 write!(f, "{} must be finite and nonnegative", self.option)
59 }
60}
61
62impl std::error::Error for SweptDiskCheckError {}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
66#[serde(rename_all = "snake_case")]
67#[non_exhaustive]
68pub enum SweptDiskFindingCode {
69 ZeroLengthSegment,
70 ConsecutiveGap,
71 TangentDiscontinuity,
72 ArcRadiusNotGreaterThanDiskRadius,
73}
74
75#[derive(Debug, Clone, PartialEq, Serialize)]
78#[non_exhaustive]
79pub struct SweptDiskCheckFinding {
80 pub code: SweptDiskFindingCode,
81 pub segment_index: usize,
82 pub next_segment_index: Option<usize>,
83 pub measured: f64,
84 pub threshold: f64,
85 pub units: &'static str,
86}
87
88#[derive(Debug, Clone, PartialEq, Serialize)]
90#[non_exhaustive]
91pub struct SweptDiskCheckReport {
92 pub source_modified: bool,
93 pub skipped_reason: Option<String>,
95 pub findings: Vec<SweptDiskCheckFinding>,
96}
97
98fn finding(
99 code: SweptDiskFindingCode,
100 segment_index: usize,
101 next_segment_index: Option<usize>,
102 measured: f64,
103 threshold: f64,
104 units: &'static str,
105) -> SweptDiskCheckFinding {
106 SweptDiskCheckFinding { code, segment_index, next_segment_index, measured, threshold, units }
107}
108
109pub fn check_swept_disk(
113 occurrence: &SweptDiskOccurrence,
114 options: &SweptDiskCheckOptions,
115) -> Result<SweptDiskCheckReport, SweptDiskCheckError> {
116 options.validate()?;
117 let mut report = SweptDiskCheckReport {
118 source_modified: occurrence.source_modified,
119 skipped_reason: None,
120 findings: Vec::new(),
121 };
122 if let AnalyticStatus::Unsupported(reason) = &occurrence.status {
123 report.skipped_reason = Some(reason.clone());
124 return Ok(report);
125 }
126 if occurrence.directrix.is_empty() {
127 report.skipped_reason = Some("directrix has no segments".into());
128 return Ok(report);
129 }
130 let Some(metrics) = occurrence.directrix_metrics() else {
131 report.skipped_reason = Some("directrix measurements are unavailable".into());
132 return Ok(report);
133 };
134 if !occurrence.radius.is_finite() || occurrence.radius <= 0.0 {
135 report.skipped_reason = Some("world disk radius is invalid".into());
136 return Ok(report);
137 }
138 let mut frames = Vec::with_capacity(occurrence.directrix.len());
139 for (index, segment) in occurrence.directrix.iter().enumerate() {
140 let Some(frame) = segment.endpoint_frame() else {
141 report.skipped_reason = Some(format!("directrix segment {index} has an invalid frame"));
142 return Ok(report);
143 };
144 frames.push(frame);
145 }
146 for (index, (segment, metric)) in occurrence.directrix.iter().zip(&metrics.segments).enumerate() {
147 if metric.length <= options.zero_length_tolerance_m {
148 report.findings.push(finding(SweptDiskFindingCode::ZeroLengthSegment,
149 index, None, metric.length, options.zero_length_tolerance_m, "m"));
150 }
151 if let AnalyticCurveSegment::Arc { radius, .. } = segment {
152 if *radius <= occurrence.radius {
153 report.findings.push(finding(SweptDiskFindingCode::ArcRadiusNotGreaterThanDiskRadius,
154 index, None, *radius, occurrence.radius, "m"));
155 }
156 }
157 }
158 let mut carried_tangent = if metrics.segments[0].length > options.zero_length_tolerance_m {
162 frames[0].end_tangent.map(|tangent| (0, tangent))
163 } else {
164 None
165 };
166 for index in 0..frames.len().saturating_sub(1) {
167 let left = frames[index];
168 let right = frames[index + 1];
169 let Some(gap) = left.end.distance_to(right.start) else {
170 report.findings.clear();
171 report.skipped_reason = Some(format!("join {index} has a non-finite gap"));
172 return Ok(report);
173 };
174 let right_is_degenerate = metrics.segments[index + 1].length <= options.zero_length_tolerance_m;
175 if gap > options.gap_tolerance_m {
176 report.findings.push(finding(SweptDiskFindingCode::ConsecutiveGap,
177 index, Some(index + 1), gap, options.gap_tolerance_m, "m"));
178 carried_tangent = if right_is_degenerate { None } else {
179 right.end_tangent.map(|tangent| (index + 1, tangent))
180 };
181 continue;
182 }
183 if right_is_degenerate {
184 continue;
185 }
186 if let (Some((from_index, a)), Some(b)) = (carried_tangent, right.start_tangent) {
187 let dot = a.iter().zip(b).map(|(x, y)| x * y).sum::<f64>().clamp(-1.0, 1.0);
188 let cross = [
190 a[1] * b[2] - a[2] * b[1],
191 a[2] * b[0] - a[0] * b[2],
192 a[0] * b[1] - a[1] * b[0],
193 ];
194 let angle = cross[0].hypot(cross[1]).hypot(cross[2]).atan2(dot);
195 if angle > options.tangent_tolerance_rad {
196 report.findings.push(finding(SweptDiskFindingCode::TangentDiscontinuity,
197 from_index, Some(index + 1), angle, options.tangent_tolerance_rad, "rad"));
198 }
199 }
200 carried_tangent = right.end_tangent.map(|tangent| (index + 1, tangent));
202 }
203 Ok(report)
204}