Skip to main content

ifc_alignment/curve/
gradient.rs

1//! The 3D centreline: a horizontal layout paired with its vertical profile.
2//!
3//! This is the `IfcGradientCurve` idea. The plan and the profile are both
4//! exact and stay exact: `Curve3::Elevated` composes them rather than
5//! re-encoding either, so a clothoid stays a clothoid and a parabolic
6//! vertical curve stays a parabola.
7//!
8//! Height is a function of distance along the PLAN, not along the 3D curve.
9//! The two diverge by sqrt(1 + g^2) wherever grade is non-zero, and the
10//! kernel names that convention in the type, so nothing here re-derives it.
11//!
12//! One limit is structural, not an omission here. `Elevated3.plan` is a
13//! single `Curve2`, and the neutral vocabulary has no composite `Curve2`:
14//! a multi-segment plan lowers to a `CurveRelation::Composite`, which is a
15//! graph node rather than a curve. Such a layout is refused by name.
16
17use axiolid_curve::{Curve2, Curve3, Elevated3};
18use axiolid_model::{CurveRelation, GeometryGraphBuilder, GeometryNode};
19use ifc_model::{EntityId, Model};
20
21use super::assemble::{finish, LoweredAlignmentCurve};
22use super::elevation::profile_law;
23use crate::cant::CantLayout;
24use crate::error::{AlignmentError, AlignmentResult};
25use crate::horizontal::AlignmentUnits;
26use crate::vertical::read_vertical_segment;
27use crate::view::AlignmentView;
28
29/// Lower an `IfcAlignment` to an exact 3D centreline.
30///
31/// Pairs the alignment horizontal layout with its vertical profile as a
32/// `Curve3::Elevated`. Both halves stay exact and either can be recovered
33/// unchanged.
34///
35/// # Errors
36///
37/// Refuses an alignment without both layouts, a vertical segment family
38/// with no exact elevation law, a non-contiguous profile, and a plan that
39/// lowers to a composite rather than a single curve.
40pub fn lower_gradient_curve(
41    model: &Model,
42    entity: EntityId,
43    units: AlignmentUnits,
44) -> AlignmentResult<LoweredAlignmentCurve> {
45    let view = AlignmentView::for_model(model)?;
46    let alignment = model
47        .get(entity)
48        .ok_or(AlignmentError::MissingEntity { entity })?;
49    if !view.schema.is_a(&alignment.type_name, "IfcAlignment") {
50        return Err(AlignmentError::WrongType {
51            entity,
52            expected: "IfcAlignment",
53            actual: alignment.type_name.to_string(),
54        });
55    }
56
57    let horizontal = sole_layout(&view, entity, "IfcAlignmentHorizontal")?;
58    let vertical = sole_layout(&view, entity, "IfcAlignmentVertical")?;
59
60    // The plan must be a single curve: `Elevated3.plan` is one `Curve2`.
61    let cant = optional_cant(model, &view, entity, units)?;
62    let (curve, ids) = compose(model, &view, horizontal, vertical, units, cant.as_ref())?;
63
64    let mut builder = GeometryGraphBuilder::new();
65    let root =
66        builder
67            .push(GeometryNode::Curve3(curve))
68            .map_err(|error| AlignmentError::Graph {
69                detail: error.to_string(),
70            })?;
71
72    let mut sources = vec![horizontal, vertical];
73    sources.extend(ids);
74    finish(builder, root, sources)
75}
76
77/// The single nested layout of a given type.
78///
79/// IFC allows an alignment to nest several layouts; pairing an arbitrary one
80/// would silently pick a road other than the one the caller meant.
81fn sole_layout(
82    view: &AlignmentView,
83    alignment: EntityId,
84    expected: &'static str,
85) -> AlignmentResult<EntityId> {
86    let children = view.nested_children(alignment, expected)?;
87    match children.as_slice() {
88        [only] => Ok(*only),
89        [] => Err(AlignmentError::SemanticViolation {
90            entity: Some(alignment),
91            rule: "a gradient curve needs both a horizontal and a vertical layout",
92        }),
93        _ => Err(AlignmentError::SemanticViolation {
94            entity: Some(alignment),
95            rule: "an alignment with several layouts of one kind is ambiguous to compose",
96        }),
97    }
98}
99
100/// The plan as one `Curve2`, or a refusal naming why it is not.
101///
102/// A single-segment layout lowers to a trim over one basis curve, which is
103/// the curve wanted here. A multi-segment layout lowers to a composite, and
104/// the neutral vocabulary has no composite `Curve2` to put in `plan`.
105/// Flattening it to a B-spline would discard the exact spirals this
106/// composition exists to preserve, so it is refused instead.
107fn sole_plan_curve(
108    model: &Model,
109    horizontal: EntityId,
110    units: AlignmentUnits,
111    cant: Option<&CantLayout>,
112) -> AlignmentResult<Curve2> {
113    let lowered = super::assemble::lower_horizontal_layout(model, horizontal, units, cant)?;
114    // A layout always lowers to a composite, even with one segment. Resolve
115    // through it rather than scanning the graph: picking an arbitrary curve
116    // node would silently elevate a fragment of the road.
117    let Some(GeometryNode::CurveRelation(CurveRelation::Composite { segments })) =
118        lowered.graph.get(lowered.root)
119    else {
120        return Err(AlignmentError::Unsupported {
121            entity: horizontal,
122            type_name: "IfcAlignmentHorizontal".to_owned(),
123            detail: "plan did not lower to a composite curve",
124        });
125    };
126    let [only] = segments.as_slice() else {
127        return Err(AlignmentError::Unsupported {
128            entity: horizontal,
129            type_name: "IfcAlignmentHorizontal".to_owned(),
130            detail: "a multi-segment plan has no single Curve2 to elevate; the neutral vocabulary has no composite Curve2",
131        });
132    };
133    // The segment is a trim over the basis curve carrying the real geometry.
134    if let Some(GeometryNode::CurveRelation(CurveRelation::Trimmed { basis, .. })) =
135        lowered.graph.get(only.curve)
136    {
137        if let Some(GeometryNode::Curve2(curve)) = lowered.graph.get(*basis) {
138            return Ok(curve.clone());
139        }
140    }
141    Err(AlignmentError::Unsupported {
142        entity: horizontal,
143        type_name: "IfcAlignmentHorizontal".to_owned(),
144        detail: "plan has no single basis curve to elevate",
145    })
146}
147
148/// The composed centreline, without a surrounding graph.
149///
150/// Shared by the graph lowering and by callers that need the curve itself
151/// to hand to an evaluator. One composition, so the two cannot drift.
152fn compose(
153    model: &Model,
154    view: &AlignmentView<'_>,
155    horizontal: EntityId,
156    vertical: EntityId,
157    units: AlignmentUnits,
158    cant: Option<&CantLayout>,
159) -> AlignmentResult<(Curve3, Vec<EntityId>)> {
160    let plan = sole_plan_curve(model, horizontal, units, cant)?;
161    let ids = view.segment_chain(vertical, "IfcAlignmentVerticalSegment")?;
162    let mut segments = Vec::with_capacity(ids.len());
163    for id in &ids {
164        segments.push(read_vertical_segment(model, *id, units)?);
165    }
166    let elevation = profile_law(&segments)?;
167    Ok((Curve3::Elevated(Elevated3::new(plan, elevation)), ids))
168}
169
170/// The exact 3D centreline of `entity`, an `IfcAlignment`.
171///
172/// Same composition the graph lowering uses, returned directly so a caller
173/// can pass it to a `CurveEvaluator`. Returning the curve rather than a
174/// point keeps evaluation the caller's choice: this crate stores exact
175/// geometry and never computes on it.
176pub fn gradient_curve3(
177    model: &Model,
178    entity: EntityId,
179    units: AlignmentUnits,
180) -> AlignmentResult<Curve3> {
181    let view = AlignmentView::for_model(model)?;
182    let alignment = model
183        .get(entity)
184        .ok_or(AlignmentError::MissingEntity { entity })?;
185    if !view.schema.is_a(&alignment.type_name, "IfcAlignment") {
186        return Err(AlignmentError::WrongType {
187            entity,
188            expected: "IfcAlignment",
189            actual: alignment.type_name.to_string(),
190        });
191    }
192    let horizontal = sole_layout(&view, entity, "IfcAlignmentHorizontal")?;
193    let vertical = sole_layout(&view, entity, "IfcAlignmentVertical")?;
194    let cant = optional_cant(model, &view, entity, units)?;
195    let (curve, _) = compose(model, &view, horizontal, vertical, units, cant.as_ref())?;
196    Ok(curve)
197}
198
199/// The alignment cant layout, when it has exactly one.
200///
201/// Cant is optional: a road alignment has none, and its absence is not an
202/// error. Several cant layouts are ambiguous, which is.
203fn optional_cant(
204    model: &Model,
205    view: &AlignmentView,
206    alignment: EntityId,
207    units: AlignmentUnits,
208) -> AlignmentResult<Option<CantLayout>> {
209    let children = view.nested_children(alignment, "IfcAlignmentCant")?;
210    match children.as_slice() {
211        [] => Ok(None),
212        [only] => CantLayout::resolve(model, *only, units).map(Some),
213        _ => Err(AlignmentError::SemanticViolation {
214            entity: Some(alignment),
215            rule: "an alignment with several cant layouts is ambiguous to compose",
216        }),
217    }
218}