ifc_lite_export/
rebar_fabrication.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//! Optional source-geometry prechecks, without fabrication certification.
6
7use std::collections::BTreeMap;
8
9use ifc_lite_processing::{DirectrixMetrics, SweptDiskCheckError, SweptDiskCheckReport, SweptDiskOccurrence};
10use serde::Serialize;
11
12use crate::rebar_preflight::{assess_sweep, RebarPreflightLimits};
13use crate::rebar_schedule::{AuthoredRebarAttribute, AuthoredRebarValue, RebarSource};
14
15/// Caller-selected SI comparisons. `None` means the check was not requested.
16#[derive(Debug, Default, Clone, Copy, PartialEq, Serialize)]
17#[non_exhaustive]
18pub struct RebarFabricationPolicy {
19    pub min_inside_bend_radius_m: Option<f64>,
20    pub min_straight_segment_length_m: Option<f64>,
21    /// Inclusive minimum and maximum arc sweep magnitude, in radians.
22    pub allowed_bend_angle_rad: Option<[f64; 2]>,
23    /// Maximum absolute difference between authored `NominalDiameter` and
24    /// twice the effective world swept-disk outer `Radius`.
25    pub max_nominal_geometric_diameter_delta_m: Option<f64>,
26    pub max_developed_centreline_length_m: Option<f64>,
27}
28
29impl RebarFabricationPolicy {
30    pub fn validate(&self) -> Result<(), RebarFabricationPolicyError> {
31        for (name, value) in [
32            ("min_inside_bend_radius_m", self.min_inside_bend_radius_m),
33            ("min_straight_segment_length_m", self.min_straight_segment_length_m),
34            ("max_nominal_geometric_diameter_delta_m", self.max_nominal_geometric_diameter_delta_m),
35            ("max_developed_centreline_length_m", self.max_developed_centreline_length_m),
36        ] {
37            if value.is_some_and(|number| !number.is_finite() || number < 0.0) {
38                return Err(RebarFabricationPolicyError { option: name, reason: "must be finite and nonnegative" });
39            }
40        }
41        if let Some([minimum, maximum]) = self.allowed_bend_angle_rad {
42            if !minimum.is_finite() || !maximum.is_finite() || minimum < 0.0
43                || minimum > maximum
44            {
45                return Err(RebarFabricationPolicyError {
46                    option: "allowed_bend_angle_rad",
47                    reason: "must be a finite nonnegative ordered range in radians",
48                });
49            }
50        }
51        Ok(())
52    }
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct RebarFabricationPolicyError {
57    pub option: &'static str,
58    pub reason: &'static str,
59}
60
61impl std::fmt::Display for RebarFabricationPolicyError {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        write!(f, "{} {}", self.option, self.reason)
64    }
65}
66
67impl std::error::Error for RebarFabricationPolicyError {}
68
69#[derive(Debug)]
70pub enum RebarScheduleFabricationError {
71    Policy(RebarFabricationPolicyError),
72    SweepChecks(SweptDiskCheckError),
73}
74
75impl std::fmt::Display for RebarScheduleFabricationError {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        match self {
78            Self::Policy(error) => error.fmt(f),
79            Self::SweepChecks(error) => error.fmt(f),
80        }
81    }
82}
83
84impl std::error::Error for RebarScheduleFabricationError {}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
87#[serde(rename_all = "snake_case")]
88pub enum RebarFabricationCheckStatus { Pass, Fail, Uncheckable }
89
90/// One requested comparison, including the source identity and absent-value reason.
91#[derive(Debug, Clone, PartialEq, Serialize)]
92#[non_exhaustive]
93pub struct RebarFabricationCheck {
94    pub kind: &'static str,
95    pub status: RebarFabricationCheckStatus,
96    pub bar_id: u32,
97    pub solid_id: u32,
98    pub directrix_id: u32,
99    pub segment_index: Option<usize>,
100    pub measured: Option<f64>,
101    pub minimum: Option<f64>,
102    pub maximum: Option<f64>,
103    pub units: &'static str,
104    /// `None` for a passing comparison; otherwise why it failed or could not run.
105    pub reason: Option<String>,
106    /// Set only for an authored diameter comparison.
107    pub authored_source: Option<RebarSource>,
108    pub authored_source_id: Option<u32>,
109    pub nominal_diameter_m: Option<f64>,
110    pub geometric_diameter_m: Option<f64>,
111}
112
113/// A geometric precheck only. Even all passing checks do not establish a
114/// physical bar count, material, process, cutting length, or code compliance.
115#[derive(Debug, Clone, PartialEq, Serialize)]
116#[non_exhaustive]
117pub struct RebarFabricationReport {
118    pub outcome: &'static str,
119    pub mapping_path: Vec<u32>,
120    pub source_modified: bool,
121    pub checks: Vec<RebarFabricationCheck>,
122    pub limitations: Vec<String>,
123    pub unchecked_factors: Vec<&'static str>,
124}
125
126const UNCHECKED: [&str; 5] = [
127    "material_properties", "bending_process", "fabrication_allowances",
128    "physical_bar_count", "jurisdictional_rules",
129];
130
131pub(super) struct AuthoredDiameterContext<'a> {
132    pub authored: &'a BTreeMap<String, AuthoredRebarAttribute>,
133    pub conflicting_type_assignments: bool,
134    pub nominal_diameter_conflict: bool,
135}
136
137fn check(kind: &'static str, units: &'static str, bar_id: u32,
138    disk: &SweptDiskOccurrence, segment_index: Option<usize>, minimum: Option<f64>,
139    maximum: Option<f64>) -> RebarFabricationCheck {
140    RebarFabricationCheck {
141        kind, status: RebarFabricationCheckStatus::Uncheckable,
142        bar_id, solid_id: disk.solid_id, directrix_id: disk.directrix_id,
143        segment_index, measured: None, minimum, maximum, units, reason: None,
144        authored_source: None, authored_source_id: None,
145        nominal_diameter_m: None, geometric_diameter_m: None,
146    }
147}
148
149fn measured(mut item: RebarFabricationCheck, value: f64) -> RebarFabricationCheck {
150    if !value.is_finite() {
151        item.reason = Some("source measurement is non-finite".into());
152        return item;
153    }
154    item.measured = Some(value);
155    item.status = if item.minimum.is_none_or(|minimum| value >= minimum)
156        && item.maximum.is_none_or(|maximum| value <= maximum) {
157        RebarFabricationCheckStatus::Pass
158    } else {
159        RebarFabricationCheckStatus::Fail
160    };
161    if item.status == RebarFabricationCheckStatus::Fail {
162        item.reason = Some(if item.minimum.is_some_and(|minimum| value < minimum) {
163            "measured value is below the caller's minimum"
164        } else {
165            "measured value exceeds the caller's maximum"
166        }.into());
167    }
168    item
169}
170
171/// Project the existing exact swept-disk measurements into optional checks.
172/// Authored diameter provenance arrives after schedule attribute resolution.
173pub(super) fn assess_fabrication(
174    bar_id: u32,
175    disk: &SweptDiskOccurrence,
176    checks: &SweptDiskCheckReport,
177    metrics: Option<&DirectrixMetrics>,
178    context: AuthoredDiameterContext<'_>,
179    policy: &RebarFabricationPolicy,
180) -> RebarFabricationReport {
181    let legacy_limits = RebarPreflightLimits {
182        min_inside_bend_radius_m: policy.min_inside_bend_radius_m.unwrap_or(0.0),
183        min_straight_segment_length_m: policy.min_straight_segment_length_m.unwrap_or(0.0),
184        max_developed_centreline_length_m: policy.max_developed_centreline_length_m,
185    };
186    // This is the sole radius/straight/length evaluator; no IFC re-decode.
187    let geometry = assess_sweep(disk, checks, metrics, &legacy_limits);
188    let unavailable = geometry.skipped_reason.as_deref();
189    let mut report = RebarFabricationReport {
190        outcome: "precheck_only", mapping_path: disk.mapping_path.clone(),
191        source_modified: disk.source_modified, checks: Vec::new(),
192        limitations: Vec::new(), unchecked_factors: UNCHECKED.to_vec(),
193    };
194    if let Some(reason) = unavailable { report.limitations.push(reason.to_owned()); }
195    if disk.source_modified { report.limitations.push("enclosing CSG modifies source geometry".into()); }
196    if !disk.mapping_path.is_empty() { report.limitations.push("mapped occurrence; source identity is distinct from physical count".into()); }
197    if context.conflicting_type_assignments {
198        report.limitations.push("conflicting type assignments".into());
199    }
200    if context.nominal_diameter_conflict {
201        report.limitations.push("occurrence and type NominalDiameter differ".into());
202    }
203
204    for (kind, requested) in [
205        ("inside_bend_radius", policy.min_inside_bend_radius_m),
206        ("straight_segment_length", policy.min_straight_segment_length_m),
207        ("developed_centreline_length", policy.max_developed_centreline_length_m),
208    ] {
209        let Some(limit) = requested else { continue };
210        let (minimum, maximum) = if kind == "developed_centreline_length" {
211            (None, Some(limit))
212        } else { (Some(limit), None) };
213        let matching = geometry.comparisons.as_ref().into_iter().flatten().filter(|part| part.kind == kind);
214        let mut found = false;
215        for part in matching {
216            found = true;
217            let mut item = check(kind, "m", bar_id, disk, part.segment_index, minimum, maximum);
218            item = measured(item, part.measured_m);
219            if kind == "inside_bend_radius" && part.measured_m <= 0.0 {
220                item.status = RebarFabricationCheckStatus::Fail;
221                item.reason = Some("effective inside radius is nonpositive".into());
222            }
223            report.checks.push(item);
224        }
225        if !found {
226            let mut item = check(kind, "m", bar_id, disk, None, minimum, maximum);
227            item.reason = Some(unavailable.unwrap_or(if kind == "inside_bend_radius" {
228                "no arc segments"
229            } else if kind == "straight_segment_length" {
230                "no line segments"
231            } else { "directrix measurement unavailable" }).into());
232            report.checks.push(item);
233        }
234    }
235
236    if let Some([minimum, maximum]) = policy.allowed_bend_angle_rad {
237        let mut found = false;
238        if unavailable.is_none() {
239            for metric in metrics.into_iter().flat_map(|value| &value.segments) {
240                if let Some(angle) = metric.bend_angle {
241                    found = true;
242                    let item = check("bend_angle", "rad", bar_id, disk,
243                        Some(metric.segment_index), Some(minimum), Some(maximum));
244                    report.checks.push(measured(item, angle));
245                }
246            }
247        }
248        if !found {
249            let mut item = check("bend_angle", "rad", bar_id, disk, None,
250                Some(minimum), Some(maximum));
251            item.reason = Some(unavailable.unwrap_or("no arc segments").into());
252            report.checks.push(item);
253        }
254    }
255
256    if let Some(tolerance) = policy.max_nominal_geometric_diameter_delta_m {
257        let mut item = check("nominal_geometric_diameter_delta", "m", bar_id, disk,
258            None, None, Some(tolerance));
259        if let Some(attribute) = context.authored.get("NominalDiameter") {
260            item.authored_source = Some(attribute.source);
261            item.authored_source_id = Some(attribute.source_id);
262            if let AuthoredRebarValue::Measure { value_si, .. } = &attribute.value {
263                if value_si.is_finite() && *value_si > 0.0 { item.nominal_diameter_m = Some(*value_si); }
264            }
265        }
266        let conflict = context.conflicting_type_assignments || context.nominal_diameter_conflict;
267        if unavailable.is_some() {
268            item.reason = unavailable.map(str::to_owned);
269        } else if conflict {
270            item.reason = Some("ambiguous authored NominalDiameter or type assignment".into());
271        } else if let Some(nominal) = item.nominal_diameter_m {
272            let geometric = 2.0 * disk.radius;
273            if geometric.is_finite() && geometric > 0.0 {
274                item.geometric_diameter_m = Some(geometric);
275                item = measured(item, (nominal - geometric).abs());
276            } else {
277                item.reason = Some("effective world geometric diameter is unavailable".into());
278            }
279        } else {
280            item.reason = Some("positive authored NominalDiameter is unavailable".into());
281        }
282        report.checks.push(item);
283    }
284    report
285}
286
287#[cfg(test)]
288#[path = "rebar_fabrication_tests.rs"]
289mod tests;