ifc_lite_landxml/alignment/
numeric.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//! Deterministic, mesh-free horizontal-alignment evaluation.
6
7mod geometry;
8mod render;
9mod station;
10
11use super::{LandXmlAlignment, LandXmlAlignmentSegment, LandXmlStationEquation};
12use crate::LandXmlSourceId;
13use geometry::{evaluate_segment, segment_endpoints, validate_segment};
14pub use render::LandXmlAlignmentRenderSpan;
15
16pub(super) const EPSILON: f64 = 1e-9;
17/// Maximum number of equal displayed-station matches returned to an
18/// interactive caller. More is an explicit refusal, not an unbounded result.
19pub const MAX_INTERACTIVE_STATION_PROBES: usize = 128;
20#[derive(Clone, Debug, Eq, PartialEq)]
21pub struct LandXmlNumericDiagnostic {
22    pub source_id: LandXmlSourceId,
23    pub code: &'static str,
24    pub message: String,
25}
26impl std::fmt::Display for LandXmlNumericDiagnostic {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        write!(f, "{}: {}", self.code, self.message)
29    }
30}
31impl std::error::Error for LandXmlNumericDiagnostic {}
32pub(super) type Result<T> = std::result::Result<T, LandXmlNumericDiagnostic>;
33
34/// All station labels at a geometric boundary. Equations deliberately retain
35/// both sides: a displayed label can be duplicated or a numeric gap can occur.
36#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize)]
37pub struct LandXmlStationMapping {
38    pub geometric_distance: f64,
39    pub displayed_back: f64,
40    pub displayed_ahead: f64,
41    pub is_equation_boundary: bool,
42}
43/// A source-level pick/probe result, independent of renderer chunks and IFC.
44#[derive(Clone, Debug, PartialEq, serde::Serialize)]
45pub struct LandXmlAlignmentProbe {
46    pub alignment_source_id: LandXmlSourceId,
47    pub segment_source_id: LandXmlSourceId,
48    pub geometric_distance: f64,
49    pub station: LandXmlStationMapping,
50    pub northing: f64,
51    pub easting: f64,
52    pub tangent_northing: f64,
53    pub tangent_easting: f64,
54}
55
56fn validate_numeric_alignment(alignment: &LandXmlAlignment) -> Result<()> {
57    valid_alignment_length(alignment)?;
58    let end = finite_add(
59        &alignment.source_id,
60        alignment.sta_start,
61        alignment.length,
62        "alignment station range overflows",
63    )?;
64    if alignment.segments.is_empty() {
65        return Err(diagnostic(
66            &alignment.source_id,
67            "LXMLA224",
68            "alignment has no numeric geometry",
69        ));
70    }
71    let mut total: f64 = 0.0;
72    let mut previous_end = None;
73    for segment in &alignment.segments {
74        let length = validate_segment(segment)?;
75        let (start, finish) = segment_endpoints(segment)?;
76        if let Some(previous) = previous_end {
77            if planar_distance(previous, start) > geometry_tolerance(total.max(length)) {
78                return Err(diagnostic(
79                    &segment.source_id,
80                    "LXMLA225",
81                    "ordered alignment segments are discontinuous",
82                ));
83            }
84        }
85        total = finite_add(
86            &alignment.source_id,
87            total,
88            length,
89            "alignment segment lengths overflow",
90        )?;
91        previous_end = Some(finish);
92    }
93    if (alignment.length - total).abs() > geometry_tolerance(alignment.length.max(total)) {
94        return Err(diagnostic(
95            &alignment.source_id,
96            "LXMLA226",
97            "alignment length is inconsistent with ordered segment spans",
98        ));
99    }
100    validate_station_equations(alignment, end)
101}
102fn valid_alignment_length(alignment: &LandXmlAlignment) -> Result<()> {
103    if !alignment.length.is_finite() || alignment.length <= EPSILON {
104        return Err(diagnostic(
105            &alignment.source_id,
106            "LXMLA200",
107            "alignment length must be positive and finite",
108        ));
109    }
110    if !alignment.sta_start.is_finite() {
111        return Err(diagnostic(
112            &alignment.source_id,
113            "LXMLA200",
114            "alignment start station must be finite",
115        ));
116    }
117    Ok(())
118}
119fn station_mapping(
120    source_id: &LandXmlSourceId,
121    sta_start: f64,
122    internal: f64,
123    equations: &[LandXmlStationEquation],
124) -> Result<LandXmlStationMapping> {
125    let mut previous_internal = sta_start;
126    let mut displayed = sta_start;
127    let mut direction = 1.0;
128    for (index, equation) in equations.iter().enumerate() {
129        if !equation.sta_internal.is_finite()
130            || (index == 0 && equation.sta_internal < previous_internal - EPSILON)
131            || (index > 0 && equation.sta_internal <= previous_internal + EPSILON)
132        {
133            return Err(diagnostic(
134                source_id,
135                "LXMLA207",
136                "station equations must be strictly increasing",
137            ));
138        }
139        if internal < equation.sta_internal - EPSILON {
140            let value = displayed + direction * (internal - previous_internal);
141            return Ok(LandXmlStationMapping {
142                geometric_distance: internal - sta_start,
143                displayed_back: value,
144                displayed_ahead: value,
145                is_equation_boundary: false,
146            });
147        }
148        if (internal - equation.sta_internal).abs() <= EPSILON {
149            return Ok(LandXmlStationMapping {
150                geometric_distance: internal - sta_start,
151                displayed_back: equation
152                    .sta_back
153                    .unwrap_or(displayed + direction * (internal - previous_internal)),
154                displayed_ahead: equation.sta_ahead,
155                is_equation_boundary: true,
156            });
157        }
158        previous_internal = equation.sta_internal;
159        displayed = equation.sta_ahead;
160        direction = equation_direction(equation);
161    }
162    let value = displayed + direction * (internal - previous_internal);
163    Ok(LandXmlStationMapping {
164        geometric_distance: internal - sta_start,
165        displayed_back: value,
166        displayed_ahead: value,
167        is_equation_boundary: false,
168    })
169}
170fn validate_station_equations(alignment: &LandXmlAlignment, end: f64) -> Result<()> {
171    let mut previous = alignment.sta_start;
172    let mut displayed = alignment.sta_start;
173    let mut direction = 1.0;
174    for (index, equation) in alignment.station_equations.iter().enumerate() {
175        if !equation.sta_internal.is_finite()
176            || !equation.sta_ahead.is_finite()
177            || equation.sta_back.is_some_and(|value| !value.is_finite())
178            || equation.sta_internal > end + EPSILON
179            || (index == 0 && equation.sta_internal < previous - EPSILON)
180            || (index > 0 && equation.sta_internal <= previous + EPSILON)
181        {
182            return Err(diagnostic(
183                &alignment.source_id,
184                "LXMLA207",
185                "station equations must lie on the alignment and be strictly ordered",
186            ));
187        }
188        let expected_back = finite_add(
189            &alignment.source_id,
190            displayed,
191            direction * (equation.sta_internal - previous),
192            "station equation derived station overflows",
193        )?;
194        if equation.sta_back.is_some_and(|back| {
195            (back - expected_back).abs() > station_tolerance(back, expected_back)
196        }) {
197            return Err(diagnostic(
198                &equation.source_id,
199                "LXMLA227",
200                "StaEquation staBack is inconsistent with the preceding station axis",
201            ));
202        }
203        previous = equation.sta_internal;
204        displayed = equation.sta_ahead;
205        direction = equation_direction(equation);
206    }
207    let _ = finite_add(
208        &alignment.source_id,
209        displayed,
210        direction * (end - previous),
211        "station equation derived station overflows",
212    )?;
213    Ok(())
214}
215fn finite_add(
216    source_id: &LandXmlSourceId,
217    left: f64,
218    right: f64,
219    message: &'static str,
220) -> Result<f64> {
221    let value = left + right;
222    if value.is_finite() {
223        Ok(value)
224    } else {
225        Err(diagnostic(source_id, "LXMLA228", message))
226    }
227}
228fn planar_distance(
229    left: crate::alignment::LandXmlPlanPoint,
230    right: crate::alignment::LandXmlPlanPoint,
231) -> f64 {
232    (right.northing - left.northing).hypot(right.easting - left.easting)
233}
234fn geometry_tolerance(length: f64) -> f64 {
235    (length * 1e-6).max(1e-6)
236}
237fn station_tolerance(left: f64, right: f64) -> f64 {
238    left.abs().max(right.abs()).mul_add(1e-12, EPSILON)
239}
240fn equation_direction(equation: &LandXmlStationEquation) -> f64 {
241    if equation.sta_increment.as_deref() == Some("decreasing") {
242        -1.0
243    } else {
244        1.0
245    }
246}
247pub(super) fn diagnostic(
248    source_id: &LandXmlSourceId,
249    code: &'static str,
250    message: impl Into<String>,
251) -> LandXmlNumericDiagnostic {
252    LandXmlNumericDiagnostic {
253        source_id: source_id.clone(),
254        code,
255        message: message.into(),
256    }
257}