ifc_lite_processing/analytic_export/
checks.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Source-path geometry checks. These are not fabrication-code checks.
6
7use super::SweptDiskOccurrence;
8use ifc_lite_geometry::analytic::{AnalyticCurveSegment, AnalyticStatus};
9use serde::Serialize;
10
11/// Tolerances in world metres and radians. All must be finite and nonnegative.
12#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
13#[non_exhaustive]
14pub struct SweptDiskCheckOptions {
15    /// A segment at or below this centreline length is considered degenerate.
16    pub zero_length_tolerance_m: f64,
17    /// Consecutive endpoints farther apart than this are disconnected.
18    pub gap_tolerance_m: f64,
19    /// Consecutive traversal tangents differing by more than this are sharp.
20    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    /// Reject invalid caller input before examining any occurrences.
35    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/// A caller-supplied tolerance is invalid.
50#[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/// Stable machine-readable source-geometry finding codes.
65#[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/// One measured source-geometry finding. `next_segment_index` identifies the
76/// second side of a join and is absent for a finding on one segment.
77#[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/// Diagnostic report for the authored source before any enclosing CSG change.
89#[derive(Debug, Clone, PartialEq, Serialize)]
90#[non_exhaustive]
91pub struct SweptDiskCheckReport {
92    pub source_modified: bool,
93    /// Why no checks ran; absent only for a complete, measurable directrix.
94    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
109/// Check an extracted world-space swept disk without decoding or tessellation.
110/// Findings concern source geometry only; they do not certify a bar for cutting,
111/// bending, material, or building-code compliance.
112pub 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    // Treat every measured degenerate segment as tangentless, even when a
159    // tiny nonzero frame has a unit tangent. Preserve the last real segment's
160    // index so a degenerate segment cannot hide a wider corner.
161    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            // Unlike acos(dot), atan2 retains small turns when dot rounds to 1.
189            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        // A missing tangent on a nondegenerate curve breaks the chain.
201        carried_tangent = right.end_tangent.map(|tangent| (index + 1, tangent));
202    }
203    Ok(report)
204}