1use crate::{locate_axis_curve, AlignmentCurve, Error, GeometryRouter, Result};
7use ifc_lite_core::{
8 build_entity_index, decode_ifc_string, extract_length_unit_scale, keyword_eq, EntityDecoder,
9 EntityScanner, IfcType, ProjectUnits,
10};
11use serde::Serialize;
12
13const MAX_OUTPUT_SAMPLES: usize = 1_000_000;
14const MAX_DIAGNOSTICS: usize = 1_000;
15
16#[derive(Debug, Clone, Copy)]
19pub struct AlignmentSamplingOptions {
20 pub spacing_m: f64,
21 pub max_samples_per_axis: usize,
22 pub max_total_samples: usize,
23}
24impl Default for AlignmentSamplingOptions {
25 fn default() -> Self {
26 Self {
27 spacing_m: 1.0,
28 max_samples_per_axis: 5_001,
29 max_total_samples: 100_000,
30 }
31 }
32}
33
34#[derive(Debug, Serialize)]
35pub struct AlignmentSamplingReport {
36 pub axes: Vec<SampledAlignmentAxis>,
37 pub diagnostics: Vec<AlignmentSamplingDiagnostic>,
38 pub diagnostics_omitted: usize,
40}
41#[derive(Debug, Serialize)]
42#[allow(non_snake_case)] pub struct SampledAlignmentAxis {
44 pub express_id: u32,
45 pub GlobalId: Option<String>,
46 pub Name: Option<String>,
47 pub geometric_horizontal_length_m: f64,
48 pub samples: Vec<AlignmentSample>,
49}
50#[derive(Debug, Serialize)]
51pub struct AlignmentSample {
52 pub geometric_horizontal_distance_m: f64,
53 pub point: [f64; 3],
54 pub tangent: [f64; 3],
55}
56#[derive(Debug, Serialize)]
57pub struct AlignmentSamplingDiagnostic {
58 pub express_id: Option<u32>,
59 pub code: AlignmentSamplingDiagnosticCode,
60 pub message: String,
61}
62#[derive(Debug, Serialize)]
63#[serde(rename_all = "snake_case")]
64pub enum AlignmentSamplingDiagnosticCode {
65 UnitResolution,
66 InvalidAxis,
67 ApproximateCurve,
68 AxisSampleLimit,
69 TotalSampleLimit,
70}
71impl AlignmentSamplingReport {
72 fn diagnostic(
73 &mut self,
74 id: Option<u32>,
75 code: AlignmentSamplingDiagnosticCode,
76 message: impl Into<String>,
77 ) {
78 if self.diagnostics.len() == MAX_DIAGNOSTICS {
79 self.diagnostics_omitted = self.diagnostics_omitted.saturating_add(1);
80 return;
81 }
82 self.diagnostics.push(AlignmentSamplingDiagnostic {
83 express_id: id,
84 code,
85 message: message.into(),
86 });
87 }
88}
89
90#[allow(non_snake_case)] pub struct AlignmentAxis {
94 pub express_id: u32,
95 pub GlobalId: Option<String>,
96 pub Name: Option<String>,
97 pub approximate: bool,
98 curve: AlignmentCurve,
99 placement: [f64; 16],
100 unit_scale: f64,
101}
102impl AlignmentAxis {
103 pub fn from_content(content: &str, express_id: u32) -> Result<Self> {
106 let mut decoder = EntityDecoder::with_index(content, build_entity_index(content));
107 let mut scanner = EntityScanner::new(content);
108 let mut project = None;
109 while let Some((id, name, _, _)) = scanner.next_entity() {
110 if keyword_eq(name, "IFCPROJECT") {
111 project = Some(id);
112 break;
113 }
114 }
115 let scale = resolve_scale(&mut decoder, project).ok_or_else(|| {
116 Error::geometry(
117 "No declared, resolvable LENGTHUNIT matching the canonical geometry scale",
118 )
119 })?;
120 Self::from_decoder(express_id, &mut decoder, scale)
121 }
122 fn from_decoder(id: u32, decoder: &mut EntityDecoder, unit_scale: f64) -> Result<Self> {
123 let entity = decoder.decode_by_id(id)?;
124 if entity.ifc_type != IfcType::IfcAlignment {
125 return Err(Error::geometry("Requested entity is not IfcAlignment"));
126 }
127 let directrix = locate_axis_curve(&entity, decoder)
128 .ok_or_else(|| Error::geometry("No supported Axis or FootPrint directrix"))?;
129 let approximate = matches!(
130 directrix.ifc_type,
131 IfcType::IfcGradientCurve | IfcType::IfcCompositeCurve
132 );
133 let curve = AlignmentCurve::parse_for_sampling(&directrix, decoder)?
134 .ok_or_else(|| Error::geometry("Unsupported or incomplete directrix"))?;
135 crate::alignment_sampling_placement::validate_placement(&entity, decoder)?;
136 let approximate = approximate || curve.uses_approximation();
137 let placement = GeometryRouter::with_scale(unit_scale)
138 .resolve_scaled_placement_strict(&entity, decoder)?;
139 let axis = Self {
140 curve,
141 placement,
142 unit_scale,
143 express_id: id,
144 GlobalId: entity
145 .get_string(0)
146 .map(|s| decode_ifc_string(s).into_owned()),
147 Name: entity
148 .get_string(2)
149 .map(|s| decode_ifc_string(s).into_owned()),
150 approximate,
151 };
152 if !axis.length_m().is_finite() || axis.length_m() <= 0.0 {
153 return Err(Error::geometry("Axis length must be finite and positive"));
154 }
155 Ok(axis)
156 }
157 pub fn length_m(&self) -> f64 {
158 self.curve.horizontal_length() * self.unit_scale
159 }
160 pub fn evaluate(&self, distance_m: f64) -> Result<AlignmentSample> {
161 if !distance_m.is_finite() || distance_m < 0.0 || distance_m > self.length_m() {
162 return Err(Error::geometry(
163 "Distance must be within the geometric horizontal length",
164 ));
165 }
166 let frame = self.curve.evaluate(distance_m / self.unit_scale);
167 let p = frame.origin * self.unit_scale;
168 let t = frame.tangent;
169 let m = &self.placement;
170 let point = [
171 m[0] * p.x + m[4] * p.y + m[8] * p.z + m[12],
172 m[1] * p.x + m[5] * p.y + m[9] * p.z + m[13],
173 m[2] * p.x + m[6] * p.y + m[10] * p.z + m[14],
174 ];
175 let mut tangent = [
176 m[0] * t.x + m[4] * t.y + m[8] * t.z,
177 m[1] * t.x + m[5] * t.y + m[9] * t.z,
178 m[2] * t.x + m[6] * t.y + m[10] * t.z,
179 ];
180 let norm = tangent.iter().map(|v| v * v).sum::<f64>().sqrt();
181 if !point.iter().chain(tangent.iter()).all(|v| v.is_finite())
182 || !norm.is_finite()
183 || norm <= 0.0
184 {
185 return Err(Error::geometry(format!(
186 "Nonfinite or degenerate frame at geometric distance {distance_m} m"
187 )));
188 }
189 tangent.iter_mut().for_each(|v| *v /= norm);
190 Ok(AlignmentSample {
191 geometric_horizontal_distance_m: distance_m,
192 point,
193 tangent,
194 })
195 }
196}
197
198fn resolve_scale(decoder: &mut EntityDecoder, project: Option<u32>) -> Option<f64> {
199 let id = project?;
200 let declared = ProjectUnits::resolve(decoder, id)
201 .resolved_for_unit_type("LENGTHUNIT")?
202 .si_scale;
203 let geometry = extract_length_unit_scale(decoder, id).ok()?;
204 (declared.is_finite()
205 && declared > 0.0
206 && geometry.is_finite()
207 && geometry > 0.0
208 && (declared - geometry).abs() <= 1e-12 * declared.abs())
209 .then_some(geometry)
210}
211
212pub fn sample_alignment_axes(
218 content: &str,
219 options: AlignmentSamplingOptions,
220) -> Result<AlignmentSamplingReport> {
221 if !options.spacing_m.is_finite()
222 || options.spacing_m <= 0.0
223 || options.max_samples_per_axis < 2
224 || options.max_total_samples < 2
225 || options.max_samples_per_axis > MAX_OUTPUT_SAMPLES
226 || options.max_total_samples > MAX_OUTPUT_SAMPLES
227 {
228 return Err(Error::geometry(
229 "spacing_m must be finite and positive; sample bounds must be 2..=1000000",
230 ));
231 }
232 let mut report = AlignmentSamplingReport {
233 axes: vec![],
234 diagnostics: vec![],
235 diagnostics_omitted: 0,
236 };
237 let mut scanner = EntityScanner::new(content);
238 let mut project = None;
239 let mut ids = Vec::new();
240 while let Some((id, name, _, _)) = scanner.next_entity() {
241 if keyword_eq(name, "IFCPROJECT") && project.is_none() {
242 project = Some(id);
243 }
244 if keyword_eq(name, "IFCALIGNMENT") {
245 ids.push(id);
246 }
247 }
248 if ids.is_empty() {
249 return Ok(report);
250 }
251 let mut decoder = EntityDecoder::with_index(content, build_entity_index(content));
252 let scale = resolve_scale(&mut decoder, project);
253 let Some(unit_scale) = scale else {
254 report.diagnostic(
255 None,
256 AlignmentSamplingDiagnosticCode::UnitResolution,
257 "No declared, resolvable LENGTHUNIT matching the canonical geometry scale",
258 );
259 return Ok(report);
260 };
261 let mut evaluated = 0;
262 for id in ids {
263 let remaining = options.max_total_samples - evaluated;
264 if remaining < 2 {
265 report.diagnostic(
266 Some(id),
267 AlignmentSamplingDiagnosticCode::TotalSampleLimit,
268 "Axis omitted: fewer than two samples remain in total budget",
269 );
270 continue;
271 }
272 let result = (|| -> Result<(SampledAlignmentAxis, bool, bool, bool)> {
273 let axis = AlignmentAxis::from_decoder(id, &mut decoder, unit_scale)?;
274 let approximate = axis.approximate;
275 let length = axis.length_m();
276 let requested_segments = (length / options.spacing_m).ceil().max(1.0);
279 let cap = options.max_samples_per_axis.min(remaining);
280 let segments = requested_segments.min((cap - 1) as f64) as usize;
281 let capped = requested_segments > segments as f64;
282 let mut samples = Vec::with_capacity(segments + 1);
283 for i in 0..=segments {
284 let distance = if i == segments {
285 length
286 } else if capped {
287 length * (i as f64 / segments as f64)
288 } else {
289 i as f64 * options.spacing_m
290 };
291 evaluated += 1;
294 samples.push(axis.evaluate(distance)?);
295 }
296 Ok((
297 SampledAlignmentAxis {
298 express_id: id,
299 GlobalId: axis.GlobalId,
300 Name: axis.Name,
301 geometric_horizontal_length_m: length,
302 samples,
303 },
304 approximate,
305 requested_segments >= options.max_samples_per_axis as f64
306 && options.max_samples_per_axis <= remaining,
307 requested_segments >= remaining as f64 && remaining <= options.max_samples_per_axis,
308 ))
309 })();
310 match result {
311 Ok((axis, approximate, axis_capped, total_capped)) => {
312 if approximate {
313 report.diagnostic(Some(id), AlignmentSamplingDiagnosticCode::ApproximateCurve, "Uses canonical curve approximation: medium-quality tessellation or approximated transition subtype");
314 }
315 if axis_capped {
316 report.diagnostic(
317 Some(id),
318 AlignmentSamplingDiagnosticCode::AxisSampleLimit,
319 "Per-axis sample bound coarsened spacing; endpoints retained",
320 );
321 }
322 if total_capped {
323 report.diagnostic(
324 Some(id),
325 AlignmentSamplingDiagnosticCode::TotalSampleLimit,
326 "Total sample bound coarsened spacing; endpoints retained",
327 );
328 }
329 report.axes.push(axis);
330 }
331 Err(error) => report.diagnostic(
332 Some(id),
333 AlignmentSamplingDiagnosticCode::InvalidAxis,
334 error.to_string(),
335 ),
336 }
337 }
338 Ok(report)
339}
340
341#[cfg(test)]
342#[path = "alignment_sampling_tests.rs"]
343mod tests;