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