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