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::error::{AlignmentError, AlignmentResult};
15use crate::horizontal::AlignmentUnits;
16use crate::view::AlignmentView;
17
18/// One resolved, ordered, continuity-checked cant profile.
19#[derive(Debug, Clone, PartialEq)]
20#[non_exhaustive]
21pub struct CantLayout {
22    /// The `IfcAlignmentCant` entity this layout was resolved from.
23    pub entity: EntityId,
24    /// `RailHeadDistance`: track gauge used to convert cant to superelevation
25    /// angle, in metres.
26    pub rail_head_distance: f64,
27    segments: Vec<CantSegment>,
28}
29
30impl CantLayout {
31    /// Resolve `IfcAlignmentCant#entity`'s nested `IfcAlignmentSegment`
32    /// chain into an ordered, continuity-checked cant profile.
33    pub fn resolve(
34        model: &Model,
35        entity: EntityId,
36        units: AlignmentUnits,
37    ) -> AlignmentResult<Self> {
38        let view = AlignmentView::for_model(model)?;
39        let cant_entity = model
40            .get(entity)
41            .ok_or(AlignmentError::MissingEntity { entity })?;
42        if !view.schema.is_a(&cant_entity.type_name, "IfcAlignmentCant") {
43            return Err(AlignmentError::WrongType {
44                entity,
45                expected: "IfcAlignmentCant",
46                actual: cant_entity.type_name.to_string(),
47            });
48        }
49        // IFC4X3_ADD2: IfcAlignmentCant contributes exactly one attribute,
50        // RailHeadDistance, past the inherited IfcRoot/IfcObject/IfcProduct/
51        // IfcLinearElement slots.
52        let rail_head_distance = cant_entity
53            .attributes
54            .last()
55            .and_then(|value| value.unwrap_typed().as_f64())
56            .ok_or(AlignmentError::InvalidAttribute {
57                entity,
58                index: cant_entity.attributes.len().saturating_sub(1),
59                name: "RailHeadDistance",
60            })?
61            * units.length_to_metres;
62        if !(rail_head_distance.is_finite() && rail_head_distance > 0.0) {
63            return Err(AlignmentError::InvalidAttribute {
64                entity,
65                index: cant_entity.attributes.len().saturating_sub(1),
66                name: "RailHeadDistance",
67            });
68        }
69
70        let ids = view.segment_chain(entity, "IfcAlignmentCantSegment")?;
71        if ids.is_empty() {
72            return Err(AlignmentError::SemanticViolation {
73                entity: Some(entity),
74                rule: "IfcAlignmentCant must nest at least one IfcAlignmentSegment",
75            });
76        }
77        let mut segments = Vec::with_capacity(ids.len());
78        for id in ids {
79            segments.push(read_cant_segment(model, id, units)?);
80        }
81
82        // Ordering by nesting order is authoritative (IFC4X3 does not use a
83        // numeric sequence field here), but a malformed file could still
84        // nest segments out of distance order or with gaps/overlaps; check
85        // explicitly rather than trusting nesting order blindly.
86        for pair in segments.windows(2) {
87            let [previous, next] = pair else {
88                unreachable!()
89            };
90            let previous_end = previous.start_dist_along + previous.horizontal_length;
91            if (next.start_dist_along - previous_end).abs() > 1e-6 {
92                return Err(AlignmentError::SemanticViolation {
93                    entity: Some(next.entity),
94                    rule: "cant segments must be contiguous in StartDistAlong order",
95                });
96            }
97            let previous_end_left = previous.end_cant_left.unwrap_or(previous.start_cant_left);
98            let previous_end_right = previous.end_cant_right.unwrap_or(previous.start_cant_right);
99            if (previous_end_left - next.start_cant_left).abs() > 1e-9
100                || (previous_end_right - next.start_cant_right).abs() > 1e-9
101            {
102                return Err(AlignmentError::SemanticViolation {
103                    entity: Some(next.entity),
104                    rule: "cant segments must be C0 continuous: end cant must equal the next segment's start cant",
105                });
106            }
107        }
108
109        Ok(Self {
110            entity,
111            rail_head_distance,
112            segments,
113        })
114    }
115
116    /// Segments in authored (distance-along) order.
117    #[must_use]
118    pub fn segments(&self) -> &[CantSegment] {
119        &self.segments
120    }
121
122    /// Total distance-along span covered by this profile.
123    #[must_use]
124    pub fn length(&self) -> f64 {
125        self.segments
126            .last()
127            .map(|last| {
128                last.start_dist_along + last.horizontal_length - self.segments[0].start_dist_along
129            })
130            .unwrap_or(0.0)
131    }
132
133    /// Cant at an absolute distance-along value within this profile's span.
134    pub fn cant_at_distance(&self, distance_along: f64) -> AlignmentResult<CantAtStation> {
135        if !distance_along.is_finite() {
136            return Err(AlignmentError::InvalidUnits {
137                detail: "distance along must be finite",
138            });
139        }
140        let segment = self
141            .segments
142            .iter()
143            .find(|segment| {
144                let start = segment.start_dist_along;
145                let end = start + segment.horizontal_length;
146                distance_along >= start - 1e-9 && distance_along <= end + 1e-9
147            })
148            .ok_or(AlignmentError::InvalidUnits {
149                detail: "distance along lies outside the cant profile's span",
150            })?;
151        let xi = if segment.horizontal_length > 0.0 {
152            ((distance_along - segment.start_dist_along) / segment.horizontal_length)
153                .clamp(0.0, 1.0)
154        } else {
155            0.0
156        };
157        cant_at(segment, xi, Some(self.rail_head_distance))
158    }
159}