Skip to main content

ifc_alignment/curve/
reference.rs

1//! The cant-carrying 3D centreline: the `IfcSegmentedReferenceCurve` role,
2//! as an exact banked curve (#93).
3//!
4//! IFC4.3 ADD2 represents an alignment with cant as an
5//! `IfcSegmentedReferenceCurve` whose base curve is the `IfcGradientCurve`.
6//! The neutral value is `Curve3::Banked`: the composed centreline
7//! (`Elevated3`, as [`gradient_curve3`](super::gradient_curve3) builds it)
8//! with two laws over plan distance and the rail-head distance:
9//!
10//! - **cant** `D(d) = left - right`, one `CantPiece` per cant segment in the
11//!   segment's own `xi = s / L`, exactly the base formula
12//!   `IfcAlignmentCantSegmentTypeEnum` (8.7.2.1) states:
13//!   `CONSTANTCANT`, `LINEARTRANSITION`, `BLOSSCURVE` `(3 - 2 xi) xi^2`,
14//!   `HELMERTCURVE` as its two quadratic halves, `COSINECURVE`, `SINECURVE`,
15//!   and `VIENNESEBEND` as its angle form
16//!   `psi(xi) = psi1 + dpsi xi^4 (35 - 84 xi + 70 xi^2 - 20 xi^3)` with
17//!   `psi = arcsin(D / b)`;
18//! - **pivot** `e(d) = (left + right) / 2`: the rail heads are "measured
19//!   relatively to vertical alignment" (`IfcAlignmentCantSegment`), so the
20//!   section's rotation point sits midway between them, at the profile for
21//!   a rotation about the centreline and at `D / 2` for one about the low
22//!   rail. Each rail follows the segment's form, so their mean follows the
23//!   same form with the mean values.
24//!
25//! # The convention: rotation about the tangent
26//!
27//! `Banked3` names how a cant reads on a grade, with no default. IFC4.3 ADD2
28//! states cant as an angle: "the cant value D, Railhead distance b and cant
29//! angle psi" are related by `psi = arcsin(D / b)`, psi being the "Angle of
30//! cant (cross slope angle, bank angle)" (`IfcAlignmentCantSegmentTypeEnum`,
31//! 8.7.2.1, Table 8.7.2.1.1.2.A), and the Viennese bend is written for psi
32//! itself. `IfcSegmentedReferenceCurve` (8.9.3.62) carries cant by
33//! interpolating the placement axes (`Axis`, `RefDirection`) between
34//! segments: a rotation of the section frame. So the section is the
35//! level-track section turned by `psi` about the 3D tangent,
36//! `BankConvention::TangentRotation`. On a grade the rail heads then differ
37//! in height by `D cos(theta)`, not `D`; the standard does not ask for the
38//! other reading, and this is the rotation `CantFrame` documents.
39//!
40//! # Refusals that remain
41//!
42//! - A pivot that moves through a Viennese bend: the bend gives the bank
43//!   angle, so a low-rail pivot there is `b sin(psi) / 2`, an angle form a
44//!   height-form pivot law cannot carry (Axiolid refuses it as
45//!   `BankError::AngleInPivot`). A pivot that stays put through the bend,
46//!   such as rotation about the centreline, lowers.
47//! - A cant layout that does not cover the plan from its start to its end:
48//!   the banked curve's span is the cant law's, and a station without cant
49//!   is not zero cant.
50//! - `USERDEFINED`, `NOTDEFINED` and unknown cant types, which state no law.
51
52use axiolid_curve::{bank_angle, BankConvention, Banked3, CantLaw, CantPiece, Curve3};
53use axiolid_model::{GeometryGraphBuilder, GeometryNode};
54use ifc_model::{EntityId, Model};
55
56use super::assemble::{finish, LoweredAlignmentCurve};
57use super::gradient::{compose, sole_layout};
58use super::seam::HorizontalSeam;
59use super::tolerance::SeamTolerance;
60use crate::cant::{CantLayout, CantSegment, CantSegmentType};
61use crate::error::{AlignmentError, AlignmentResult};
62use crate::horizontal::AlignmentUnits;
63use crate::view::AlignmentView;
64
65/// Lower an `IfcAlignment` with cant to its exact cant-carrying centreline,
66/// a `Curve3::Banked`.
67///
68/// The graph holds one `Curve3::Banked` node: the centreline
69/// [`lower_gradient_curve`](super::lower_gradient_curve) builds, the cant
70/// and pivot laws, and the rail-head distance, under
71/// `BankConvention::TangentRotation` (see the module documentation).
72/// `sources` lists the horizontal, vertical and cant layouts, then the
73/// vertical and cant segments; `seams` are the plan's.
74///
75/// # Errors
76///
77/// - Everything [`CantLayout::for_alignment`] refuses: a wrong entity type,
78///   no `IfcAlignmentCant` or several (`SemanticViolation`), a malformed or
79///   discontinuous cant layout.
80/// - Everything the gradient curve refuses.
81/// - [`AlignmentError::Unsupported`] for a cant layout that does not cover
82///   the whole plan, a cant segment type with no law, and a Viennese bend
83///   whose pivot moves.
84/// - [`AlignmentError::SemanticViolation`] for a cant beyond the rail-head
85///   distance (`|D| > b`) at a segment end; every form is monotone between
86///   its ends, so none exceeds it inside.
87pub fn lower_segmented_reference_curve(
88    model: &Model,
89    alignment: EntityId,
90    units: AlignmentUnits,
91) -> AlignmentResult<LoweredAlignmentCurve> {
92    let banked = banked(model, alignment, units)?;
93    let mut builder = GeometryGraphBuilder::new();
94    let root = builder
95        .push(GeometryNode::Curve3(Curve3::Banked(banked.curve)))
96        .map_err(|error| AlignmentError::Graph {
97            detail: error.to_string(),
98        })?;
99    let mut lowered = finish(builder, root, banked.sources)?;
100    lowered.seams = banked.seams;
101    Ok(lowered)
102}
103
104/// The exact cant-carrying centreline of `alignment`, an `IfcAlignment`.
105///
106/// The composition [`lower_segmented_reference_curve`] uses, returned
107/// directly so a caller can hand it to an evaluator; this crate never
108/// evaluates it.
109///
110/// # Errors
111///
112/// As [`lower_segmented_reference_curve`].
113pub fn segmented_reference_curve3(
114    model: &Model,
115    alignment: EntityId,
116    units: AlignmentUnits,
117) -> AlignmentResult<Curve3> {
118    banked(model, alignment, units).map(|banked| Curve3::Banked(banked.curve))
119}
120
121/// The banked curve and what the graph lowering reports with it.
122struct Lowered {
123    curve: Banked3,
124    sources: Vec<EntityId>,
125    seams: Vec<HorizontalSeam>,
126}
127
128fn banked(model: &Model, alignment: EntityId, units: AlignmentUnits) -> AlignmentResult<Lowered> {
129    // Resolving the cant layout first names a missing or ambiguous one, or a
130    // malformed segment, before anything about the centreline.
131    let cant = CantLayout::for_alignment(model, alignment, units)?;
132    let view = AlignmentView::for_model(model)?;
133    let horizontal = sole_layout(&view, alignment, "IfcAlignmentHorizontal")?;
134    let vertical = sole_layout(&view, alignment, "IfcAlignmentVertical")?;
135    let composed = compose(model, &view, horizontal, vertical, units, Some(&cant))?;
136    let tolerance = SeamTolerance::for_model(model, units)?;
137    let (cant_law, pivot) = cant_laws(&cant, composed.plan_length, tolerance)?;
138    if !(cant_law.is_well_formed() && pivot.is_well_formed()) {
139        return Err(AlignmentError::Graph {
140            detail: format!(
141                "the cant layout {} did not assemble into well-formed laws",
142                cant.entity
143            ),
144        });
145    }
146    let mut sources = vec![horizontal, vertical, cant.entity];
147    sources.extend(composed.vertical_ids);
148    sources.extend(cant.segments().iter().map(|segment| segment.entity));
149    Ok(Lowered {
150        curve: Banked3::new(
151            composed.centreline,
152            cant_law,
153            pivot,
154            cant.rail_head_distance,
155            BankConvention::TangentRotation,
156        ),
157        sources,
158        seams: composed.seams,
159    })
160}
161
162/// The cant and pivot laws of `layout`, from plan distance zero.
163///
164/// The cant layout's stations are distances along the horizontal layout,
165/// the distance the plan is parameterised by, so the laws need no
166/// re-indexing; the layout must start at the plan start and end at its end,
167/// at the model's seam tolerance. The closing zero-length segment adds no
168/// piece.
169fn cant_laws(
170    layout: &CantLayout,
171    plan_length: f64,
172    tolerance: SeamTolerance,
173) -> AlignmentResult<(CantLaw, CantLaw)> {
174    let segments: Vec<&CantSegment> = layout
175        .segments()
176        .iter()
177        .filter(|segment| segment.horizontal_length > 0.0)
178        .collect();
179    let start = segments.first().map_or(0.0, |s| s.start_dist_along);
180    let end = segments
181        .last()
182        .map_or(0.0, |s| s.start_dist_along + s.horizontal_length);
183    if !tolerance.same_length(start, 0.0) || !tolerance.same_length(end, plan_length) {
184        return Err(AlignmentError::Unsupported {
185            entity: layout.entity,
186            type_name: "IfcAlignmentCant".to_owned(),
187            detail: "the cant layout does not cover the whole plan; a banked curve's span is its \
188                     cant law's, and a station without cant is not zero cant",
189        });
190    }
191    let b = layout.rail_head_distance;
192    let mut cant = Vec::with_capacity(segments.len());
193    let mut pivot = Vec::with_capacity(segments.len());
194    for segment in segments {
195        let (left, right) = ends(segment)?;
196        let d = [left[0] - right[0], left[1] - right[1]];
197        let e = [0.5 * (left[0] + right[0]), 0.5 * (left[1] + right[1])];
198        if d.iter().any(|value| bank_angle(*value, b).is_err()) {
199            return Err(AlignmentError::SemanticViolation {
200                entity: Some(segment.entity),
201                rule: "cant must not exceed the rail head distance (|D| <= b)",
202            });
203        }
204        let length = segment.horizontal_length;
205        match &segment.predefined_type {
206            CantSegmentType::ConstantCant => {
207                cant.push(CantPiece::constant(length, d[0]));
208                pivot.push(CantPiece::constant(length, e[0]));
209            }
210            CantSegmentType::LinearTransition => {
211                cant.push(CantPiece::linear(length, d[0], d[1]));
212                pivot.push(CantPiece::linear(length, e[0], e[1]));
213            }
214            CantSegmentType::BlossCurve => {
215                cant.push(CantPiece::bloss(length, d[0], d[1]));
216                pivot.push(CantPiece::bloss(length, e[0], e[1]));
217            }
218            CantSegmentType::HelmertCurve => {
219                cant.extend(CantPiece::helmert(length, d[0], d[1]));
220                pivot.extend(CantPiece::helmert(length, e[0], e[1]));
221            }
222            CantSegmentType::CosineCurve => {
223                cant.push(CantPiece::cosine(length, d[0], d[1]));
224                pivot.push(CantPiece::cosine(length, e[0], e[1]));
225            }
226            CantSegmentType::SineCurve => {
227                cant.push(CantPiece::sine(length, d[0], d[1]));
228                pivot.push(CantPiece::sine(length, e[0], e[1]));
229            }
230            CantSegmentType::VienneseBend => {
231                if !tolerance.same_length(e[0], e[1]) {
232                    return Err(AlignmentError::Unsupported {
233                        entity: segment.entity,
234                        type_name: "VIENNESEBEND".to_owned(),
235                        detail: "the section's rotation point moves through a Viennese bend: \
236                                 the bend gives the bank angle, so a pivot such as the low rail \
237                                 follows b sin(psi) / 2, an angle form a height-form pivot law \
238                                 cannot carry (Axiolid BankError::AngleInPivot)",
239                    });
240                }
241                // |D| <= b was checked above, so both angles exist.
242                let psi = |value: f64| (value / b).asin();
243                cant.push(CantPiece::viennese_bend(length, psi(d[0]), psi(d[1])));
244                pivot.push(CantPiece::constant(length, e[0]));
245            }
246            _ => {
247                return Err(AlignmentError::Unsupported {
248                    entity: segment.entity,
249                    type_name: segment.predefined_type_name().to_owned(),
250                    detail: "cant PredefinedType has no defined base formula",
251                })
252            }
253        }
254    }
255    Ok((CantLaw::new(cant), CantLaw::new(pivot)))
256}
257
258/// `[start, end]` heights of the left and right rail heads above the
259/// profile. A constant cant may omit its end values; a transition needs
260/// both.
261fn ends(segment: &CantSegment) -> AlignmentResult<([f64; 2], [f64; 2])> {
262    let constant = segment.predefined_type == CantSegmentType::ConstantCant;
263    match (segment.end_cant_left, segment.end_cant_right) {
264        (Some(left), Some(right)) => {
265            if constant && (left != segment.start_cant_left || right != segment.start_cant_right) {
266                return Err(AlignmentError::InvalidSegment {
267                    entity: segment.entity,
268                    detail: "CONSTANTCANT requires equal (or absent) start and end cant",
269                });
270            }
271            Ok((
272                [segment.start_cant_left, left],
273                [segment.start_cant_right, right],
274            ))
275        }
276        _ if constant => Ok(([segment.start_cant_left; 2], [segment.start_cant_right; 2])),
277        _ => Err(AlignmentError::InvalidSegment {
278            entity: segment.entity,
279            detail: "transition cant segments require both end cant values",
280        }),
281    }
282}