1use 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#[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 pub allowed_bend_angle_rad: Option<[f64; 2]>,
23 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#[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 pub reason: Option<String>,
106 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#[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
171pub(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 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;