Skip to main content

ifc_alignment/cant/
layout.rs

1//! Ordering, continuity, and station-query assembly for `IfcAlignmentCant`.
2//!
3//! This is the "parent/layout curve assembly" `ALIGN-CANT` names: given the
4//! parent `IfcAlignmentCant`, resolve its nested segments in authored order,
5//! validate that each segment's start cant agrees with the previous
6//! segment's end cant (C0 continuity -- cant profiles are not required to be
7//! tangent-continuous the way horizontal/vertical curves are), and expose a
8//! single station-domain query across the whole profile.
9
10use ifc_model::{EntityId, Model};
11
12use crate::cant::evaluate::{cant_at, CantAtStation};
13use crate::cant::segment::{read_cant_segment, CantSegment};
14use crate::curve::terminal::split_closing;
15use crate::error::{AlignmentError, AlignmentResult};
16use crate::horizontal::AlignmentUnits;
17use crate::view::AlignmentView;
18
19/// One resolved, ordered, continuity-checked cant profile.
20#[derive(Debug, Clone, PartialEq)]
21#[non_exhaustive]
22pub struct CantLayout {
23    /// The `IfcAlignmentCant` entity this layout was resolved from.
24    pub entity: EntityId,
25    /// `RailHeadDistance`: track gauge used to convert cant to superelevation
26    /// angle, in metres.
27    pub rail_head_distance: f64,
28    segments: Vec<CantSegment>,
29}
30
31impl CantLayout {
32    /// Resolve `IfcAlignmentCant#entity`'s nested `IfcAlignmentSegment`
33    /// chain into an ordered, continuity-checked cant profile.
34    ///
35    /// The zero-length segment IFC4.3 requires at the end of a layout is
36    /// kept in [`Self::segments`]; its station and start cant are checked
37    /// against the previous segment's end like any seam. A zero-length
38    /// segment anywhere else, or as the only segment, is refused
39    /// ([`AlignmentError::SemanticViolation`]).
40    pub fn resolve(
41        model: &Model,
42        entity: EntityId,
43        units: AlignmentUnits,
44    ) -> AlignmentResult<Self> {
45        let view = AlignmentView::for_model(model)?;
46        let cant_entity = model
47            .get(entity)
48            .ok_or(AlignmentError::MissingEntity { entity })?;
49        if !view.schema.is_a(&cant_entity.type_name, "IfcAlignmentCant") {
50            return Err(AlignmentError::WrongType {
51                entity,
52                expected: "IfcAlignmentCant",
53                actual: cant_entity.type_name.to_string(),
54            });
55        }
56        // IFC4X3_ADD2: IfcAlignmentCant contributes exactly one attribute,
57        // RailHeadDistance, past the inherited IfcRoot/IfcObject/IfcProduct/
58        // IfcLinearElement slots.
59        let rail_head_distance = cant_entity
60            .attributes
61            .last()
62            .and_then(|value| value.unwrap_typed().as_f64())
63            .ok_or(AlignmentError::InvalidAttribute {
64                entity,
65                index: cant_entity.attributes.len().saturating_sub(1),
66                name: "RailHeadDistance",
67            })?
68            * units.length_to_metres;
69        if !(rail_head_distance.is_finite() && rail_head_distance > 0.0) {
70            return Err(AlignmentError::InvalidAttribute {
71                entity,
72                index: cant_entity.attributes.len().saturating_sub(1),
73                name: "RailHeadDistance",
74            });
75        }
76
77        let ids = view.segment_chain(entity, "IfcAlignmentCantSegment")?;
78        if ids.is_empty() {
79            return Err(AlignmentError::SemanticViolation {
80                entity: Some(entity),
81                rule: "IfcAlignmentCant must nest at least one IfcAlignmentSegment",
82            });
83        }
84        let mut segments = Vec::with_capacity(ids.len());
85        for id in ids {
86            segments.push(read_cant_segment(model, id, units)?);
87        }
88        // IFC4.3 closes every layout with a zero-length segment; one
89        // anywhere else is refused. The closing segment stays in the layout
90        // and its start cant is checked below like any seam.
91        split_closing(&segments, |s| s.horizontal_length, |s| s.entity)?;
92
93        // Ordering by nesting order is authoritative (IFC4X3 does not use a
94        // numeric sequence field here), but a malformed file could still
95        // nest segments out of distance order or with gaps/overlaps; check
96        // explicitly rather than trusting nesting order blindly.
97        for pair in segments.windows(2) {
98            let [previous, next] = pair else {
99                unreachable!()
100            };
101            let previous_end = previous.start_dist_along + previous.horizontal_length;
102            if (next.start_dist_along - previous_end).abs() > 1e-6 {
103                return Err(AlignmentError::SemanticViolation {
104                    entity: Some(next.entity),
105                    rule: "cant segments must be contiguous in StartDistAlong order",
106                });
107            }
108            let previous_end_left = previous.end_cant_left.unwrap_or(previous.start_cant_left);
109            let previous_end_right = previous.end_cant_right.unwrap_or(previous.start_cant_right);
110            if (previous_end_left - next.start_cant_left).abs() > 1e-9
111                || (previous_end_right - next.start_cant_right).abs() > 1e-9
112            {
113                return Err(AlignmentError::SemanticViolation {
114                    entity: Some(next.entity),
115                    rule: "cant segments must be C0 continuous: end cant must equal the next segment's start cant",
116                });
117            }
118        }
119
120        Ok(Self {
121            entity,
122            rail_head_distance,
123            segments,
124        })
125    }
126
127    /// The cant layout of `IfcAlignment#alignment`.
128    ///
129    /// Unlike the gradient-curve composition, where a road without cant is
130    /// ordinary, this is the call for work that NEEDS cant (the section
131    /// frames, the `IfcSegmentedReferenceCurve` role), so its absence is a
132    /// refusal rather than `None`.
133    ///
134    /// # Errors
135    ///
136    /// Refuses a model that is not IFC4X3, an entity that is not an
137    /// `IfcAlignment`, an alignment nesting no `IfcAlignmentCant` or several
138    /// of them (`SemanticViolation`: picking one would silently choose a
139    /// track), and everything [`Self::resolve`] refuses.
140    pub fn for_alignment(
141        model: &Model,
142        alignment: EntityId,
143        units: AlignmentUnits,
144    ) -> AlignmentResult<Self> {
145        let view = AlignmentView::for_model(model)?;
146        let entity = model
147            .get(alignment)
148            .ok_or(AlignmentError::MissingEntity { entity: alignment })?;
149        if !view.schema.is_a(&entity.type_name, "IfcAlignment") {
150            return Err(AlignmentError::WrongType {
151                entity: alignment,
152                expected: "IfcAlignment",
153                actual: entity.type_name.to_string(),
154            });
155        }
156        match view
157            .nested_children(alignment, "IfcAlignmentCant")?
158            .as_slice()
159        {
160            [only] => Self::resolve(model, *only, units),
161            [] => Err(AlignmentError::SemanticViolation {
162                entity: Some(alignment),
163                rule: "the alignment nests no IfcAlignmentCant layout",
164            }),
165            _ => Err(AlignmentError::SemanticViolation {
166                entity: Some(alignment),
167                rule: "an alignment with several cant layouts is ambiguous to compose",
168            }),
169        }
170    }
171
172    /// Segments in authored (distance-along) order.
173    #[must_use]
174    pub fn segments(&self) -> &[CantSegment] {
175        &self.segments
176    }
177
178    /// Total distance-along span covered by this profile.
179    #[must_use]
180    pub fn length(&self) -> f64 {
181        self.segments
182            .last()
183            .map(|last| {
184                last.start_dist_along + last.horizontal_length - self.segments[0].start_dist_along
185            })
186            .unwrap_or(0.0)
187    }
188
189    /// Cant at an absolute distance-along value within this profile's span.
190    pub fn cant_at_distance(&self, distance_along: f64) -> AlignmentResult<CantAtStation> {
191        if !distance_along.is_finite() {
192            return Err(AlignmentError::InvalidUnits {
193                detail: "distance along must be finite",
194            });
195        }
196        let segment = self
197            .segments
198            .iter()
199            .find(|segment| {
200                let start = segment.start_dist_along;
201                let end = start + segment.horizontal_length;
202                distance_along >= start - 1e-9 && distance_along <= end + 1e-9
203            })
204            .ok_or(AlignmentError::InvalidUnits {
205                detail: "distance along lies outside the cant profile's span",
206            })?;
207        let xi = if segment.horizontal_length > 0.0 {
208            ((distance_along - segment.start_dist_along) / segment.horizontal_length)
209                .clamp(0.0, 1.0)
210        } else {
211            0.0
212        };
213        cant_at(segment, xi, Some(self.rail_head_distance))
214    }
215}