Skip to main content

ifc_alignment/curve/
plan.rs

1//! A whole horizontal layout as ONE exact plan curve.
2//!
3//! A plane curve is fixed by its start frame and its curvature as a
4//! function of arc length. Every horizontal segment with a curvature law
5//! -- zero for `LINE`, `1/R` for `CIRCULARARC`, the family law for a
6//! transition spiral -- is a piece of that law in its own arc length, so a
7//! run of such segments is one `Curve2::Intrinsic` carrying a
8//! `CurvatureLaw::Piecewise` with a seam at every segment boundary, anchored
9//! at the FIRST segment's `StartPoint` and `StartDirection`.
10//!
11//! A `CUBIC` has no curvature law in arc length (see `cubic.rs`). A layout
12//! holding one is a `Curve2::Chain` instead: the same start frame, one
13//! `ChainPiece2::Intrinsic` per segment with a law and one
14//! `ChainPiece2::Parametric` cubic parabola, read by arc length, per
15//! `CUBIC`. Each piece starts where the previous one ends, in the direction
16//! it ends in. A layout without a `CUBIC` keeps the single intrinsic curve.
17//!
18//! This is what `Elevated3.plan` needs: a single `Curve2` whose parameter is
19//! distance along the plan. A separate curve per segment would need an
20//! absolute start frame at each seam, and after a spiral or a cubic that
21//! frame is a non-elementary integral. Anchoring the interior by arc length
22//! alone needs no interior position.
23//!
24//! # What the single curve implies
25//!
26//! Position and heading are continuous by construction. Later segments'
27//! authored `StartPoint`s and `StartDirection`s are not stored; they are
28//! checked against the curve by the rule in `seam.rs`:
29//!
30//! - a heading mismatch is refused at every seam after a segment with a
31//!   curvature law, because one plan curve cannot carry a kink and storing
32//!   it anyway would move the road;
33//! - a position mismatch after a `LINE` or `CIRCULARARC` is refused;
34//! - a position after a transition spiral, and a position and heading after
35//!   a `CUBIC`, are recorded as
36//!   [`SeamCheck::Authored`](super::SeamCheck::Authored), not verified.
37
38use axiolid_core::{Frame2, Vec2};
39use axiolid_curve::{Chain2, ChainPiece2, CurvatureLaw, Curve2, Intrinsic2};
40use ifc_model::{EntityId, Model};
41
42use super::cubic::{cubic_curve, local_frame, CUBIC};
43use super::seam::{check_direction, check_position, HorizontalSeam};
44use super::spiral::{curvature_of, is_exactly_lowerable, refuse_unlowerable, transition_curvature};
45use super::terminal::split_closing;
46use crate::cant::CantLayout;
47use crate::error::{AlignmentError, AlignmentResult};
48use crate::horizontal::{
49    read_horizontal_segment, AlignmentUnits, HorizontalSegment, HorizontalSegmentType,
50};
51use crate::view::AlignmentView;
52
53/// A horizontal layout lowered to one exact plan curve.
54#[derive(Debug, Clone, PartialEq)]
55#[non_exhaustive]
56pub struct HorizontalPlan {
57    /// The whole layout as one `Curve2::Intrinsic`, or a `Curve2::Chain`
58    /// when it holds a `CUBIC`, parameterised by distance along the plan
59    /// from the first segment's start.
60    pub curve: Curve2,
61    /// The segments it was lowered from, in authored order.
62    pub sources: Vec<EntityId>,
63    /// Every seam between consecutive segments and how it was checked,
64    /// including the seam before a closing zero-length segment.
65    pub seams: Vec<HorizontalSeam>,
66}
67
68/// Lower an `IfcAlignmentHorizontal` to one exact plan curve.
69///
70/// The curve is a `Curve2::Intrinsic` anchored at the first segment and
71/// carrying one curvature piece per segment, so it is parameterised by
72/// distance along the plan and needs no interior position. A single-segment
73/// layout yields that segment's law without a piecewise wrapper. A layout
74/// with a `CUBIC` is a `Curve2::Chain` of the same pieces, the cubic as a
75/// parametric piece read by arc length.
76///
77/// `cant` is needed only by a `VIENNESEBEND`, whose law depends on the cant
78/// swing across it.
79///
80/// # Errors
81///
82/// Refuses a layout with no segments, any segment without an exact law (as
83/// [`lower_horizontal_layout`](super::lower_horizontal_layout) does), a
84/// heading kink at any seam whose predecessor has a curvature law, and a
85/// position gap at a seam whose predecessor has a closed-form end point. A
86/// seam after a transition spiral or a `CUBIC` is not refused; it is
87/// reported in [`HorizontalPlan::seams`] as unverified.
88///
89/// The zero-length segment IFC4.3 requires at the end of a layout adds no
90/// piece; its start is checked against the curve's end like any seam, and
91/// that seam is reported last. A zero-length segment anywhere else, or as
92/// the only segment, is refused ([`AlignmentError::SemanticViolation`]).
93pub fn lower_horizontal_plan(
94    model: &Model,
95    entity: EntityId,
96    units: AlignmentUnits,
97    cant: Option<&CantLayout>,
98) -> AlignmentResult<HorizontalPlan> {
99    let view = AlignmentView::for_model(model)?;
100    let horizontal = model
101        .get(entity)
102        .ok_or(AlignmentError::MissingEntity { entity })?;
103    if !view
104        .schema
105        .is_a(&horizontal.type_name, "IfcAlignmentHorizontal")
106    {
107        return Err(AlignmentError::WrongType {
108            entity,
109            expected: "IfcAlignmentHorizontal",
110            actual: horizontal.type_name.to_string(),
111        });
112    }
113    let ids = view.segment_chain(entity, "IfcAlignmentHorizontalSegment")?;
114    let segments = ids
115        .iter()
116        .map(|id| read_horizontal_segment(model, *id, units))
117        .collect::<AlignmentResult<Vec<_>>>()?;
118    // The closing zero-length segment adds no curvature piece; its seam is
119    // checked below like any other.
120    let (body, closing) = split_closing(&segments, |s| s.segment_length, |s| s.entity)?;
121    let Some(first) = body.first() else {
122        return Err(AlignmentError::SemanticViolation {
123            entity: Some(entity),
124            rule: "IfcAlignmentHorizontal must nest at least one IfcAlignmentSegment",
125        });
126    };
127
128    // Each kept piece with the station it starts at.
129    let mut pieces: Vec<(f64, Piece)> = Vec::with_capacity(segments.len());
130    let mut seams = Vec::with_capacity(segments.len().saturating_sub(1));
131    let mut station = 0.0_f64;
132    let mut previous: Option<(&HorizontalSegment, Piece)> = None;
133    for segment in body {
134        let piece = segment_piece(segment, cant, station)?;
135        if let Some((before, before_piece)) = &previous {
136            seams.push(check_seam(before, before_piece, segment, station)?);
137        }
138        let end = station + segment.segment_length;
139        // A piece too short to advance the station in f64 would put two
140        // seams at one distance, which the kernel reads as a malformed law.
141        if end > station {
142            pieces.push((station, piece.clone()));
143        }
144        station = end;
145        previous = Some((segment, piece));
146    }
147    if let (Some(closing), Some((before, before_piece))) = (closing, &previous) {
148        seams.push(check_seam(before, before_piece, closing, station)?);
149    }
150
151    let direction = Vec2::new(first.start_direction.cos(), first.start_direction.sin());
152    let start = Frame2 {
153        origin: first.start_point,
154        x: direction,
155        y: Vec2::new(-direction.y, direction.x),
156    };
157    let malformed = || AlignmentError::Graph {
158        detail: "the horizontal layout did not assemble into a well-formed plan curve".to_owned(),
159    };
160    let curve = if pieces
161        .iter()
162        .all(|(_, piece)| matches!(piece, Piece::Law(_)))
163    {
164        let mut breaks = Vec::with_capacity(pieces.len());
165        let mut laws = Vec::with_capacity(pieces.len());
166        for (at, piece) in pieces {
167            if let Piece::Law(law) = piece {
168                if !laws.is_empty() {
169                    breaks.push(at);
170                }
171                laws.push(law);
172            }
173        }
174        let curvature = if laws.len() == 1 {
175            laws.remove(0)
176        } else {
177            CurvatureLaw::piecewise(breaks, laws)
178        };
179        let curve = Intrinsic2::new(start, curvature, station);
180        if !curve.curvature.is_well_formed()
181            || curve.total_turning().is_none_or(|turn| !turn.is_finite())
182        {
183            return Err(malformed());
184        }
185        Curve2::Intrinsic(curve)
186    } else {
187        // Each piece's length is the gap to the next kept start, so the
188        // chain's joins fall exactly where the intrinsic law's breaks would.
189        let ends: Vec<f64> = pieces
190            .iter()
191            .skip(1)
192            .map(|(at, _)| *at)
193            .chain([station])
194            .collect();
195        let chain = Chain2::new(
196            start,
197            pieces
198                .into_iter()
199                .zip(ends)
200                .map(|((at, piece), end)| {
201                    let length = end - at;
202                    match piece {
203                        Piece::Law(curvature) => ChainPiece2::Intrinsic { curvature, length },
204                        Piece::Cubic(curve) => ChainPiece2::Parametric {
205                            curve,
206                            start: 0.0,
207                            length,
208                        },
209                    }
210                })
211                .collect(),
212        );
213        if !chain.is_well_formed() {
214            return Err(malformed());
215        }
216        Curve2::Chain(chain)
217    };
218    Ok(HorizontalPlan {
219        curve,
220        sources: ids,
221        seams,
222    })
223}
224
225/// One segment of the plan: a curvature law in its own arc length, or a
226/// `CUBIC`'s cubic parabola in its local frame, read by arc length.
227#[derive(Debug, Clone)]
228enum Piece {
229    Law(CurvatureLaw),
230    Cubic(Curve2),
231}
232
233/// The seam where `next` starts after `before`: heading checked in closed
234/// form after a curvature law, position by the rule in `seam.rs`.
235fn check_seam(
236    before: &HorizontalSegment,
237    before_piece: &Piece,
238    next: &HorizontalSegment,
239    station: f64,
240) -> AlignmentResult<HorizontalSeam> {
241    // A cubic's end heading is not closed form; its seam reports the
242    // position, and with it the heading, as authored.
243    if let Piece::Law(law) = before_piece {
244        check_direction(before, law, next)?;
245    }
246    check_position(before, next, station)
247}
248
249/// One segment's piece of the plan.
250fn segment_piece(
251    segment: &HorizontalSegment,
252    cant: Option<&CantLayout>,
253    station: f64,
254) -> AlignmentResult<Piece> {
255    match &segment.segment_type {
256        HorizontalSegmentType::Transition(name) if name == CUBIC => {
257            cubic_curve(segment, local_frame()).map(Piece::Cubic)
258        }
259        _ => segment_law(segment, cant, station).map(Piece::Law),
260    }
261}
262
263/// One segment's exact curvature law in its own arc length.
264///
265/// Validates `LINE` and `CIRCULARARC` exactly as their single-segment
266/// lowerings do, so the plan and the composite accept the same files.
267fn segment_law(
268    segment: &HorizontalSegment,
269    cant: Option<&CantLayout>,
270    station: f64,
271) -> AlignmentResult<CurvatureLaw> {
272    match &segment.segment_type {
273        HorizontalSegmentType::Line => {
274            if segment.start_radius != 0.0 || segment.end_radius != 0.0 {
275                return Err(AlignmentError::InvalidSegment {
276                    entity: segment.entity,
277                    detail: "LINE requires zero start and end radii",
278                });
279            }
280            Ok(CurvatureLaw::straight())
281        }
282        HorizontalSegmentType::CircularArc => {
283            if segment.start_radius == 0.0
284                || segment.start_radius != segment.end_radius
285                || !segment.start_radius.is_finite()
286            {
287                return Err(AlignmentError::InvalidSegment {
288                    entity: segment.entity,
289                    detail: "CIRCULARARC requires equal, finite, non-zero start and end radii",
290                });
291            }
292            // Signed: a positive radius turns counter-clockwise, as IFC
293            // states and as the kernel's curvature sign convention reads it.
294            Ok(CurvatureLaw::circular(curvature_of(segment.start_radius)))
295        }
296        HorizontalSegmentType::Transition(name) if is_exactly_lowerable(name, cant.is_some()) => {
297            transition_curvature(segment, name, cant, station)
298        }
299        _ => Err(refuse_unlowerable(segment)),
300    }
301}