ifc_lite_geometry/
alignment_sampling.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//! Opt-in alignment samples: f64 absolute IFC Z-up metres, never renderer lines.
6use 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/// Output bounds include both endpoints. Distance starts at the physical start,
17/// irrespective of authored chainage; spacing is horizontal distance in metres.
18#[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    /// Number suppressed after the bounded diagnostic buffer filled.
39    pub diagnostics_omitted: usize,
40}
41#[derive(Debug, Serialize)]
42#[allow(non_snake_case)] // Public IFC EXPRESS attribute names are exact.
43pub 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/// Shared evaluated axis. Retains the canonical evaluator before renderer
91/// conversion, so a future WASM consumer can request an f64 frame at any distance.
92#[allow(non_snake_case)] // Public IFC EXPRESS attribute names are exact.
93pub 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    /// Resolve one retained evaluator. Missing units, placement and directrix
104    /// fail explicitly; no fallback identity, scale or truncated success.
105    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
212/// Sample every resolvable alignment in source order. Failed axes are reported;
213/// no identity-placement or implicit-metre fallback is permitted. When bounds
214/// coarsen spacing both endpoints remain; exhausted total budgets omit later
215/// axes with explicit diagnostics. The cap applies to emitted samples, not IFC
216/// parsing memory (which is bounded by input size and canonical refwalk guards).
217pub 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            // Compare in f64 before casting so even a subnormal spacing cannot
277            // overflow an integer or allocate unbounded output.
278            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                // Failed axes still consume frame work: a late invalid frame
292                // must not reset the model-wide sampling budget.
293                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;