Skip to main content

ifc_alignment/referent/
station.rs

1//! Stationing referents and station equations.
2//!
3//! Stationing is a labeling system layered onto distance-along, not distance
4//! itself: a `Pset_Stationing.Station` value can jump, run backwards, or
5//! restart at an `IncomingStation`/`Station` pair (a "station equation" --
6//! e.g. two survey crews meeting mid-alignment with a small chainage gap or
7//! overlap). Treating station as interchangeable with distance-along is
8//! exactly the linear-referencing bug this module exists to prevent: this
9//! type never returns a bare number, only the mapping.
10
11use ifc_model::value::Value;
12use ifc_model::{EntityId, Model};
13
14use crate::error::{AlignmentError, AlignmentResult};
15use crate::horizontal::AlignmentUnits;
16
17/// One `IfcReferent` carrying `Pset_Stationing`, resolved to its distance
18/// along the alignment and station value.
19///
20/// `incoming_station` is `Some` exactly when the referent marks a station
21/// equation: the station value that would have continued from the *previous*
22/// segment, immediately before it is replaced by `station` at this point.
23#[derive(Debug, Clone, PartialEq)]
24pub struct StationEquation {
25    /// The `IfcReferent` entity carrying `Pset_Stationing`.
26    pub referent: EntityId,
27    /// Absolute distance along the alignment's basis curve, from the
28    /// referent's `IfcLinearPlacement`.
29    pub distance_along: f64,
30    /// `Pset_Stationing.IncomingStation`, present exactly when this referent
31    /// marks a station equation: the station value that would have
32    /// continued from the previous segment, immediately before `station`
33    /// replaces it here.
34    pub incoming_station: Option<f64>,
35    /// `Pset_Stationing.Station`: the station value in effect from this
36    /// point onward.
37    pub station: f64,
38    /// `Pset_Stationing.HasIncreasingStation`; absent in the source means
39    /// `true` per the property definition's default.
40    pub has_increasing_station: bool,
41}
42
43/// Resolve every `IfcReferent` in `model` that carries a `Pset_Stationing`
44/// property set into its distance-along/station mapping.
45///
46/// Distance along comes from the referent's own `IfcLinearPlacement`
47/// (`DistanceAlong` of the underlying `IfcPointByDistanceExpression`), not
48/// from station: the two are related only by this returned table, never
49/// interchangeable.
50pub fn station_equations(
51    model: &Model,
52    units: AlignmentUnits,
53) -> AlignmentResult<Vec<StationEquation>> {
54    let mut out = Vec::new();
55    for (id, entity) in model.iter() {
56        if !entity.is_type("IFCREFERENT") {
57            continue;
58        }
59        if let Some(equation) = resolve_referent_stationing(model, id, units)? {
60            out.push(equation);
61        }
62    }
63    // Stable, order-independent output: callers reason about a mapping, and
64    // model iteration order is an implementation detail of storage, not of
65    // the alignment's stationing scheme.
66    out.sort_by(|a, b| {
67        a.distance_along
68            .partial_cmp(&b.distance_along)
69            .unwrap_or(std::cmp::Ordering::Equal)
70    });
71    Ok(out)
72}
73
74fn resolve_referent_stationing(
75    model: &Model,
76    referent: EntityId,
77    units: AlignmentUnits,
78) -> AlignmentResult<Option<StationEquation>> {
79    let Some(pset) = find_pset_stationing(model, referent)? else {
80        return Ok(None);
81    };
82    let (station, incoming_station, has_increasing_station) =
83        read_stationing_properties(model, pset, referent)?;
84    let distance_along = referent_distance_along(model, referent, units)?;
85
86    Ok(Some(StationEquation {
87        referent,
88        distance_along,
89        incoming_station: incoming_station.map(|v| v * units.length_to_metres),
90        station: station * units.length_to_metres,
91        has_increasing_station,
92    }))
93}
94
95/// Find the `Pset_Stationing` `IfcPropertySet` attached to `referent` via
96/// `IfcRelDefinesByProperties`, if any.
97fn find_pset_stationing(model: &Model, referent: EntityId) -> AlignmentResult<Option<EntityId>> {
98    for (_id, entity) in model.iter() {
99        if !entity.is_type("IFCRELDEFINESBYPROPERTIES") {
100            continue;
101        }
102        // IfcRelDefinesByProperties: inherited GlobalId/OwnerHistory/Name/
103        // Description are slots 0..3; RelatedObjects and
104        // RelatingPropertyDefinition are this declaration's own slots 4..5.
105        let values = &entity.attributes;
106        let related = values.get(4).and_then(Value::as_list).unwrap_or(&[]);
107        let targets_referent = related.iter().any(|v| v.as_ref_id() == Some(referent));
108        if !targets_referent {
109            continue;
110        }
111        let Some(pset_id) = values.get(5).and_then(Value::as_ref_id) else {
112            continue;
113        };
114        let Some(pset_entity) = model.get(pset_id) else {
115            continue;
116        };
117        if !pset_entity.is_type("IFCPROPERTYSET") {
118            continue;
119        }
120        // IfcPropertySet: inherited slots 0..3, own Name is slot 2 (already
121        // covered by IfcRoot); HasProperties is this declaration's own slot 4.
122        let is_stationing = pset_entity
123            .attributes
124            .get(2)
125            .and_then(Value::as_text)
126            .is_some_and(|name| name == "Pset_Stationing");
127        if is_stationing {
128            return Ok(Some(pset_id));
129        }
130    }
131    Ok(None)
132}
133
134fn read_stationing_properties(
135    model: &Model,
136    pset: EntityId,
137    referent: EntityId,
138) -> AlignmentResult<(f64, Option<f64>, bool)> {
139    let pset_entity = model
140        .get(pset)
141        .ok_or(AlignmentError::MissingEntity { entity: pset })?;
142    let properties = pset_entity
143        .attributes
144        .get(4)
145        .and_then(Value::as_list)
146        .unwrap_or(&[]);
147
148    let mut station = None;
149    let mut incoming_station = None;
150    let mut has_increasing_station = true; // spec default: absent means true.
151
152    for property in properties {
153        let Some(property_id) = property.as_ref_id() else {
154            continue;
155        };
156        let Some(property_entity) = model.get(property_id) else {
157            continue;
158        };
159        if !property_entity.is_type("IFCPROPERTYSINGLEVALUE") {
160            continue;
161        }
162        // IfcPropertySingleValue: IfcProperty own Name/Specification are
163        // slots 0..1 (IfcPropertyAbstraction contributes none); NominalValue
164        // and Unit are this declaration's own slots 2..3.
165        let name = property_entity
166            .attributes
167            .first()
168            .and_then(Value::as_text)
169            .ok_or(AlignmentError::InvalidAttribute {
170                entity: property_id,
171                index: 0,
172                name: "Name",
173            })?;
174        let nominal = property_entity.attributes.get(2);
175        match name {
176            "Station" => {
177                station = nominal.and_then(|v| v.unwrap_typed().as_f64());
178            }
179            "IncomingStation" => {
180                incoming_station = nominal.and_then(|v| v.unwrap_typed().as_f64());
181            }
182            "HasIncreasingStation" => {
183                if let Some(value) = nominal.and_then(|v| v.unwrap_typed().as_bool()) {
184                    has_increasing_station = value;
185                }
186            }
187            _ => {}
188        }
189    }
190
191    let station = station.ok_or(AlignmentError::SemanticViolation {
192        entity: Some(referent),
193        rule: "Pset_Stationing.Station is required to interpret a station equation",
194    })?;
195    Ok((station, incoming_station, has_increasing_station))
196}
197
198/// `IfcReferent.ObjectPlacement`, resolved to an absolute distance along its
199/// basis curve. A station equation anchored to a *parameter* rather than a
200/// length is refused: parameters are basis-curve-relative and not comparable
201/// across segments the way an absolute distance-along is.
202fn referent_distance_along(
203    model: &Model,
204    referent: EntityId,
205    units: AlignmentUnits,
206) -> AlignmentResult<f64> {
207    let entity = model
208        .get(referent)
209        .ok_or(AlignmentError::MissingEntity { entity: referent })?;
210    let placement_id = entity.attributes.get(5).and_then(Value::as_ref_id).ok_or(
211        AlignmentError::InvalidAttribute {
212            entity: referent,
213            index: 5,
214            name: "ObjectPlacement",
215        },
216    )?;
217    let placement = crate::placement::resolve_linear_placement(model, placement_id, units)?;
218    match placement.relative_placement.distance_along {
219        crate::placement::CurveMeasure::Length(value) => Ok(value),
220        crate::placement::CurveMeasure::Parameter(_) => Err(AlignmentError::Unsupported {
221            entity: referent,
222            type_name: "IFCPOINTBYDISTANCEEXPRESSION".to_owned(),
223            detail: "a station equation requires an absolute DistanceAlong, not a curve parameter",
224        }),
225    }
226}