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}