ifc_alignment/referent/stationing.rs
1//! One alignment's stationing, and station to distance-along lookup.
2//!
3//! An alignment states its stationing through the `IfcReferent`s it nests
4//! (`IfcRelNests`, "Object Nesting") or positions (`IfcRelPositions`), each
5//! carrying `Pset_Stationing`. Referents of other alignments never enter
6//! the table, which is what keeps two alignments' chainages apart.
7//!
8//! # The mapping
9//!
10//! Sorted by distance along, referent `i` at distance `d_i` with
11//! `Station = s_i` governs `[d_i, d_{i+1})` (the last one is open-ended):
12//!
13//! ```text
14//! station(d) = s_i + k_i * (d - d_i), k_i = +1 if HasIncreasingStation else -1
15//! ```
16//!
17//! At an equation the referent's `Station` is the value ahead of it and
18//! its `IncomingStation` the value the previous range reaches there. Both
19//! are checked: `IncomingStation` must equal `s_{i-1} + k_{i-1} * (d_i -
20//! d_{i-1})`, and a referent without one must continue that value, within
21//! [`STATION_TOLERANCE`]. A contradiction is refused rather than resolved
22//! in favour of either number.
23//!
24//! The inverse treats each range as closed at its end, so the station a
25//! range reaches at an equation (the incoming value) maps back to that
26//! equation's distance. A station that occurs in several ranges, as after
27//! an equation that steps back, is reported by [`Stationing::distances_at`]
28//! and refused by [`Stationing::distance_at`].
29
30use ifc_model::{EntityId, Model};
31
32use super::station::{resolve_referent_stationing, StationEquation};
33use crate::error::{AlignmentError, AlignmentResult};
34use crate::horizontal::AlignmentUnits;
35use crate::view::AlignmentView;
36
37/// How far, in metres, a referent's stated station may differ from the
38/// value carried from the previous referent before the two are treated as
39/// contradictory.
40///
41/// One millimetre: stations and distances are authored independently and
42/// commonly rounded to the millimetre, so a tighter bound would refuse
43/// sound files, while any real equation is a deliberate, larger jump that
44/// is stated through `IncomingStation`.
45pub const STATION_TOLERANCE: f64 = 1e-3;
46
47/// The stationing of one `IfcAlignment`: its station equations in distance
48/// order, and lookups across them.
49#[derive(Debug, Clone, PartialEq)]
50#[non_exhaustive]
51pub struct Stationing {
52 /// The `IfcAlignment` this stationing belongs to.
53 pub alignment: EntityId,
54 equations: Vec<StationEquation>,
55}
56
57impl Stationing {
58 /// Resolve the stationing of `alignment` from the referents it nests
59 /// or positions.
60 ///
61 /// Referents without `Pset_Stationing` (a bridge pier marker, say) are
62 /// not part of the stationing and are skipped. An alignment with no
63 /// stationing referent resolves to an empty table, whose lookups are
64 /// refused.
65 ///
66 /// # Errors
67 ///
68 /// Refuses a model that is not IFC4X3, an `alignment` that is not an
69 /// `IfcAlignment`, a malformed relationship or referent, two stationing
70 /// referents at one distance, a non-finite station, and a station
71 /// that contradicts the one carried from the previous referent.
72 pub fn resolve(
73 model: &Model,
74 alignment: EntityId,
75 units: AlignmentUnits,
76 ) -> AlignmentResult<Self> {
77 let view = AlignmentView::for_model(model)?;
78 let hierarchy = view.hierarchy(alignment)?;
79 let mut referents = hierarchy.referents;
80 for id in view.filter_is_a(hierarchy.positioned, "IfcReferent") {
81 if !referents.contains(&id) {
82 referents.push(id);
83 }
84 }
85 let mut equations = Vec::with_capacity(referents.len());
86 for referent in referents {
87 if let Some(equation) = resolve_referent_stationing(model, referent, units)? {
88 equations.push(equation);
89 }
90 }
91 Self::from_equations(alignment, equations)
92 }
93
94 fn from_equations(
95 alignment: EntityId,
96 mut equations: Vec<StationEquation>,
97 ) -> AlignmentResult<Self> {
98 for equation in &equations {
99 let finite = equation.distance_along.is_finite()
100 && equation.station.is_finite()
101 && equation.incoming_station.is_none_or(f64::is_finite);
102 if !finite {
103 return Err(AlignmentError::SemanticViolation {
104 entity: Some(equation.referent),
105 rule: "a stationing referent's distance and stations must be finite",
106 });
107 }
108 }
109 equations.sort_by(|a, b| a.distance_along.total_cmp(&b.distance_along));
110 for pair in equations.windows(2) {
111 let [previous, next] = pair else {
112 unreachable!("windows(2) yields pairs")
113 };
114 if next.distance_along - previous.distance_along <= STATION_TOLERANCE {
115 return Err(AlignmentError::SemanticViolation {
116 entity: Some(next.referent),
117 rule:
118 "two stationing referents of one alignment sit at the same distance along",
119 });
120 }
121 let carried = station_in(previous, next.distance_along);
122 let stated = next.incoming_station.unwrap_or(next.station);
123 if (stated - carried).abs() > STATION_TOLERANCE {
124 return Err(AlignmentError::SemanticViolation {
125 entity: Some(next.referent),
126 rule: if next.incoming_station.is_some() {
127 "IncomingStation disagrees with the station carried from the previous referent"
128 } else {
129 "a referent without IncomingStation must continue the previous station"
130 },
131 });
132 }
133 }
134 Ok(Self {
135 alignment,
136 equations,
137 })
138 }
139
140 /// The stationing referents, ascending in distance along.
141 #[must_use]
142 pub fn equations(&self) -> &[StationEquation] {
143 &self.equations
144 }
145
146 /// The station at `distance_along` (metres along the alignment).
147 ///
148 /// At an equation the station ahead of it applies.
149 ///
150 /// # Errors
151 ///
152 /// [`AlignmentError::OutOfRange`] for a non-finite distance, an empty
153 /// table, or a distance before the first stationing referent.
154 pub fn station_at(&self, distance_along: f64) -> AlignmentResult<f64> {
155 let out_of_range = AlignmentError::OutOfRange {
156 entity: self.alignment,
157 quantity: "distance along",
158 value: distance_along,
159 };
160 if !distance_along.is_finite() {
161 return Err(out_of_range);
162 }
163 let governing = self
164 .equations
165 .iter()
166 .rev()
167 .find(|equation| equation.distance_along <= distance_along)
168 .ok_or(out_of_range)?;
169 Ok(station_in(governing, distance_along))
170 }
171
172 /// Every distance along labelled `station`, ascending.
173 ///
174 /// Usually one. Several when an equation steps the station back, so
175 /// that the same value occurs in two ranges.
176 ///
177 /// # Errors
178 ///
179 /// [`AlignmentError::OutOfRange`] for a non-finite station and a
180 /// station no range reaches.
181 pub fn distances_at(&self, station: f64) -> AlignmentResult<Vec<f64>> {
182 let out_of_range = AlignmentError::OutOfRange {
183 entity: self.alignment,
184 quantity: "station",
185 value: station,
186 };
187 if !station.is_finite() {
188 return Err(out_of_range);
189 }
190 let mut distances: Vec<f64> = Vec::new();
191 for (index, equation) in self.equations.iter().enumerate() {
192 let span = self.equations.get(index + 1).map_or(f64::INFINITY, |next| {
193 next.distance_along - equation.distance_along
194 });
195 let offset = (station - equation.station) * direction(equation);
196 let tolerance = 1e-9 * station.abs().max(1.0);
197 if offset < -tolerance || offset > span + tolerance {
198 continue;
199 }
200 let distance = equation.distance_along + offset.clamp(0.0, span);
201 let duplicate = distances
202 .last()
203 .is_some_and(|last| (distance - last).abs() <= tolerance);
204 if !duplicate {
205 distances.push(distance);
206 }
207 }
208 if distances.is_empty() {
209 return Err(out_of_range);
210 }
211 Ok(distances)
212 }
213
214 /// The one distance along labelled `station`.
215 ///
216 /// # Errors
217 ///
218 /// As [`Self::distances_at`], and [`AlignmentError::AmbiguousStation`]
219 /// when the station labels several distances.
220 pub fn distance_at(&self, station: f64) -> AlignmentResult<f64> {
221 let distances = self.distances_at(station)?;
222 match distances.as_slice() {
223 [only] => Ok(*only),
224 _ => Err(AlignmentError::AmbiguousStation {
225 alignment: self.alignment,
226 station,
227 distances,
228 }),
229 }
230 }
231}
232
233/// `+1` for increasing stationing, `-1` for decreasing.
234fn direction(equation: &StationEquation) -> f64 {
235 if equation.has_increasing_station {
236 1.0
237 } else {
238 -1.0
239 }
240}
241
242/// The station `equation`'s range assigns to `distance_along`.
243fn station_in(equation: &StationEquation, distance_along: f64) -> f64 {
244 equation.station + direction(equation) * (distance_along - equation.distance_along)
245}