ifc_lite_export/
rebar_preflight.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//! Caller-supplied geometric comparisons for represented bar source sweeps.
6
7use ifc_lite_geometry::analytic::{AnalyticCurveSegment, AnalyticStatus};
8use ifc_lite_processing::{
9    DirectrixMetrics, SweptDiskCheckError, SweptDiskCheckReport, SweptDiskFindingCode,
10    SweptDiskOccurrence,
11};
12use serde::Serialize;
13
14/// Project-specific limits in SI metres. No default fabrication code is implied.
15#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
16#[non_exhaustive]
17pub struct RebarPreflightLimits {
18    pub min_inside_bend_radius_m: f64,
19    pub min_straight_segment_length_m: f64,
20    pub max_developed_centreline_length_m: Option<f64>,
21}
22
23impl RebarPreflightLimits {
24    pub fn new(min_inside_bend_radius_m: f64, min_straight_segment_length_m: f64,
25        max_developed_centreline_length_m: Option<f64>) -> Result<Self, RebarPreflightError> {
26        let limits = Self { min_inside_bend_radius_m, min_straight_segment_length_m,
27            max_developed_centreline_length_m };
28        limits.validate()?;
29        Ok(limits)
30    }
31
32    pub fn validate(&self) -> Result<(), RebarPreflightError> {
33        for (name, value) in [
34            ("min_inside_bend_radius_m", self.min_inside_bend_radius_m),
35            ("min_straight_segment_length_m", self.min_straight_segment_length_m),
36        ] {
37            if !value.is_finite() || value < 0.0 {
38                return Err(RebarPreflightError { option: name });
39            }
40        }
41        if self.max_developed_centreline_length_m.is_some_and(|value| !value.is_finite() || value < 0.0) {
42            return Err(RebarPreflightError { option: "max_developed_centreline_length_m" });
43        }
44        Ok(())
45    }
46}
47
48/// Invalid caller-supplied fabrication threshold.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct RebarPreflightError { pub option: &'static str }
51
52impl std::fmt::Display for RebarPreflightError {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        write!(f, "{} must be finite and nonnegative", self.option)
55    }
56}
57
58impl std::error::Error for RebarPreflightError {}
59
60/// Failure to validate caller thresholds or existing sweep-check tolerances.
61#[derive(Debug)]
62pub enum RebarSchedulePreflightError {
63    Limits(RebarPreflightError),
64    SweepChecks(SweptDiskCheckError),
65}
66
67impl std::fmt::Display for RebarSchedulePreflightError {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        match self {
70            Self::Limits(error) => error.fmt(f),
71            Self::SweepChecks(error) => error.fmt(f),
72        }
73    }
74}
75
76impl std::error::Error for RebarSchedulePreflightError {}
77
78/// One measured comparison. Segment index is absent for the whole directrix.
79#[derive(Debug, Clone, PartialEq, Serialize)]
80#[non_exhaustive]
81pub struct RebarPreflightComparison {
82    pub kind: &'static str,
83    pub segment_index: Option<usize>,
84    pub measured_m: f64,
85    pub limit_m: f64,
86    pub passed: bool,
87}
88
89/// Source geometry assessment. A skipped sweep has unavailable comparisons.
90#[derive(Debug, Clone, PartialEq, Serialize)]
91#[non_exhaustive]
92pub struct RebarPreflightReport {
93    pub skipped_reason: Option<String>,
94    /// `None` means the source could not be assessed; `Some` contains the measured checks.
95    pub comparisons: Option<Vec<RebarPreflightComparison>>,
96    /// Requested segment checks for which no matching geometry exists.
97    pub unassessed_reasons: Vec<String>,
98}
99
100pub(super) fn assess_sweep(
101    disk: &SweptDiskOccurrence,
102    checks: &SweptDiskCheckReport,
103    metrics: Option<&DirectrixMetrics>,
104    limits: &RebarPreflightLimits,
105) -> RebarPreflightReport {
106    let mut report = RebarPreflightReport { skipped_reason: None, comparisons: None, unassessed_reasons: Vec::new() };
107    if disk.source_modified {
108        report.skipped_reason = Some("source is modified by enclosing CSG; finished bar geometry is unavailable".into());
109        return report;
110    }
111    if let AnalyticStatus::Unsupported(reason) = &disk.status {
112        report.skipped_reason = Some(format!("unsupported directrix: {reason}"));
113        return report;
114    }
115    if let Some(reason) = &checks.skipped_reason {
116        report.skipped_reason = Some(format!("source geometry checks could not run: {reason}"));
117        return report;
118    }
119    if let Some(finding) = checks.findings.iter().find(|finding| matches!(
120        finding.code,
121        SweptDiskFindingCode::ConsecutiveGap | SweptDiskFindingCode::ZeroLengthSegment
122    )) {
123        let defect = match finding.code {
124            SweptDiskFindingCode::ConsecutiveGap => "a consecutive gap",
125            SweptDiskFindingCode::ZeroLengthSegment => "a zero-length segment",
126            _ => unreachable!(),
127        };
128        report.skipped_reason = Some(format!(
129            "source directrix has {defect} at segment {}; whole-sweep comparisons are unavailable",
130            finding.segment_index,
131        ));
132        return report;
133    }
134    let Some(metrics) = metrics else {
135        report.skipped_reason = Some("directrix measurements are unavailable".into());
136        return report;
137    };
138    if disk.directrix.is_empty() || !disk.radius.is_finite() || disk.radius <= 0.0 {
139        report.skipped_reason = Some("directrix or swept outer radius is unavailable".into());
140        return report;
141    }
142    if disk.directrix.len() != metrics.segments.len() {
143        report.skipped_reason = Some("directrix segment measurements are incomplete".into());
144        return report;
145    }
146    let mut has_arc = false;
147    let mut has_line = false;
148    let mut comparisons = Vec::with_capacity(
149        disk.directrix.len() + usize::from(limits.max_developed_centreline_length_m.is_some()),
150    );
151    for (segment, metric) in disk.directrix.iter().zip(metrics.segments.iter()) {
152        let (kind, measured_m, limit_m, passed) = match segment {
153            AnalyticCurveSegment::Line { .. } => { has_line = true; (
154                "straight_segment_length",
155                metric.length,
156                limits.min_straight_segment_length_m,
157                metric.length >= limits.min_straight_segment_length_m,
158            ) },
159            AnalyticCurveSegment::Arc { radius, .. } => {
160                has_arc = true;
161                let inside_radius = radius - disk.radius;
162                (
163                    "inside_bend_radius",
164                    inside_radius,
165                    limits.min_inside_bend_radius_m,
166                    inside_radius >= limits.min_inside_bend_radius_m,
167                )
168            }
169        };
170        comparisons.push(RebarPreflightComparison {
171            kind, segment_index: Some(metric.segment_index), measured_m, limit_m, passed,
172        });
173    }
174    if !has_arc {
175        report.unassessed_reasons.push("inside bend radius: no arc segments".into());
176    }
177    if !has_line {
178        report.unassessed_reasons.push("straight segment length: no line segments".into());
179    }
180    if let Some(limit_m) = limits.max_developed_centreline_length_m {
181        comparisons.push(RebarPreflightComparison {
182            kind: "developed_centreline_length", segment_index: None,
183            measured_m: metrics.total_length, limit_m,
184            passed: metrics.total_length <= limit_m,
185        });
186    }
187    report.comparisons = Some(comparisons);
188    report
189}