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 this crate lowers has an
5//! exact law in its own arc length -- zero for `LINE`, `1/R` for
6//! `CIRCULARARC`, the family law for a transition spiral -- so a run of
7//! segments is one `Curve2::Intrinsic` carrying a `CurvatureLaw::Piecewise`
8//! with a seam at every segment boundary, anchored at the FIRST segment's
9//! `StartPoint` and `StartDirection`.
10//!
11//! This is what `Elevated3.plan` needs: a single `Curve2` whose parameter is
12//! distance along the plan. It is also why a piecewise law exists upstream.
13//! A separate curve per segment would need an absolute start frame at each
14//! seam, and after a spiral that frame's origin is a Fresnel-type integral.
15//! Anchoring the interior by arc length alone needs no interior position.
16//!
17//! # What the single curve implies
18//!
19//! Position and heading are continuous by construction. Later segments'
20//! authored `StartPoint`s and `StartDirection`s are not stored; they are
21//! checked against the curve by the rule in `seam.rs`:
22//!
23//! - a heading mismatch is refused at every seam, because one intrinsic
24//!   curve cannot carry a kink and storing it anyway would move the road;
25//! - a position mismatch after a `LINE` or `CIRCULARARC` is refused;
26//! - a position after a transition spiral is recorded as
27//!   [`SeamCheck::Authored`](super::SeamCheck::Authored), not verified.
28
29use axiolid_core::{Frame2, Vec2};
30use axiolid_curve::{CurvatureLaw, Curve2, Intrinsic2};
31use ifc_model::{EntityId, Model};
32
33use super::seam::{check_direction, check_position, HorizontalSeam};
34use super::spiral::{curvature_of, is_exactly_lowerable, refuse_unlowerable, transition_curvature};
35use super::terminal::split_closing;
36use crate::cant::CantLayout;
37use crate::error::{AlignmentError, AlignmentResult};
38use crate::horizontal::{
39    read_horizontal_segment, AlignmentUnits, HorizontalSegment, HorizontalSegmentType,
40};
41use crate::view::AlignmentView;
42
43/// A horizontal layout lowered to one exact plan curve.
44#[derive(Debug, Clone, PartialEq)]
45#[non_exhaustive]
46pub struct HorizontalPlan {
47    /// The whole layout as one `Curve2::Intrinsic`, parameterised by
48    /// distance along the plan from the first segment's start.
49    pub curve: Curve2,
50    /// The segments it was lowered from, in authored order.
51    pub sources: Vec<EntityId>,
52    /// Every seam between consecutive segments and how it was checked,
53    /// including the seam before a closing zero-length segment.
54    pub seams: Vec<HorizontalSeam>,
55}
56
57/// Lower an `IfcAlignmentHorizontal` to one exact plan curve.
58///
59/// The curve is a `Curve2::Intrinsic` anchored at the first segment and
60/// carrying one curvature piece per segment, so it is parameterised by
61/// distance along the plan and needs no interior position. A single-segment
62/// layout yields that segment's law without a piecewise wrapper.
63///
64/// `cant` is needed only by a `VIENNESEBEND`, whose law depends on the cant
65/// swing across it.
66///
67/// # Errors
68///
69/// Refuses a layout with no segments, any segment without an exact law (as
70/// [`lower_horizontal_layout`](super::lower_horizontal_layout) does), a
71/// heading kink at any seam, and a position gap at a seam whose predecessor
72/// has a closed-form end point. A seam after a transition spiral is not
73/// refused; it is reported in [`HorizontalPlan::seams`] as unverified.
74///
75/// The zero-length segment IFC4.3 requires at the end of a layout adds no
76/// piece; its start is checked against the curve's end like any seam, and
77/// that seam is reported last. A zero-length segment anywhere else, or as
78/// the only segment, is refused ([`AlignmentError::SemanticViolation`]).
79pub fn lower_horizontal_plan(
80    model: &Model,
81    entity: EntityId,
82    units: AlignmentUnits,
83    cant: Option<&CantLayout>,
84) -> AlignmentResult<HorizontalPlan> {
85    let view = AlignmentView::for_model(model)?;
86    let horizontal = model
87        .get(entity)
88        .ok_or(AlignmentError::MissingEntity { entity })?;
89    if !view
90        .schema
91        .is_a(&horizontal.type_name, "IfcAlignmentHorizontal")
92    {
93        return Err(AlignmentError::WrongType {
94            entity,
95            expected: "IfcAlignmentHorizontal",
96            actual: horizontal.type_name.to_string(),
97        });
98    }
99    let ids = view.segment_chain(entity, "IfcAlignmentHorizontalSegment")?;
100    let segments = ids
101        .iter()
102        .map(|id| read_horizontal_segment(model, *id, units))
103        .collect::<AlignmentResult<Vec<_>>>()?;
104    // The closing zero-length segment adds no curvature piece; its seam is
105    // checked below like any other.
106    let (body, closing) = split_closing(&segments, |s| s.segment_length, |s| s.entity)?;
107    let Some(first) = body.first() else {
108        return Err(AlignmentError::SemanticViolation {
109            entity: Some(entity),
110            rule: "IfcAlignmentHorizontal must nest at least one IfcAlignmentSegment",
111        });
112    };
113
114    let mut laws = Vec::with_capacity(segments.len());
115    let mut breaks = Vec::with_capacity(segments.len());
116    let mut seams = Vec::with_capacity(segments.len().saturating_sub(1));
117    let mut station = 0.0_f64;
118    let mut previous: Option<(&HorizontalSegment, CurvatureLaw)> = None;
119    for segment in body {
120        let law = segment_law(segment, cant, station)?;
121        if let Some((before, before_law)) = &previous {
122            check_direction(before, before_law, segment)?;
123            seams.push(check_position(before, segment, station)?);
124        }
125        let end = station + segment.segment_length;
126        // A piece too short to advance the station in f64 would put two
127        // seams at one distance, which the kernel reads as a malformed law.
128        if end > station {
129            if !laws.is_empty() {
130                breaks.push(station);
131            }
132            laws.push(law.clone());
133        }
134        station = end;
135        previous = Some((segment, law));
136    }
137    if let (Some(closing), Some((before, before_law))) = (closing, &previous) {
138        check_direction(before, before_law, closing)?;
139        seams.push(check_position(before, closing, station)?);
140    }
141
142    let curvature = if laws.len() == 1 {
143        laws.remove(0)
144    } else {
145        CurvatureLaw::piecewise(breaks, laws)
146    };
147    let direction = Vec2::new(first.start_direction.cos(), first.start_direction.sin());
148    let curve = Intrinsic2::new(
149        Frame2 {
150            origin: first.start_point,
151            x: direction,
152            y: Vec2::new(-direction.y, direction.x),
153        },
154        curvature,
155        station,
156    );
157    if !curve.curvature.is_well_formed()
158        || curve.total_turning().is_none_or(|turn| !turn.is_finite())
159    {
160        return Err(AlignmentError::Graph {
161            detail: "the horizontal layout did not assemble into a well-formed curvature law"
162                .to_owned(),
163        });
164    }
165    Ok(HorizontalPlan {
166        curve: Curve2::Intrinsic(curve),
167        sources: ids,
168        seams,
169    })
170}
171
172/// One segment's exact curvature law in its own arc length.
173///
174/// Validates `LINE` and `CIRCULARARC` exactly as their single-segment
175/// lowerings do, so the plan and the composite accept the same files.
176fn segment_law(
177    segment: &HorizontalSegment,
178    cant: Option<&CantLayout>,
179    station: f64,
180) -> AlignmentResult<CurvatureLaw> {
181    match &segment.segment_type {
182        HorizontalSegmentType::Line => {
183            if segment.start_radius != 0.0 || segment.end_radius != 0.0 {
184                return Err(AlignmentError::InvalidSegment {
185                    entity: segment.entity,
186                    detail: "LINE requires zero start and end radii",
187                });
188            }
189            Ok(CurvatureLaw::straight())
190        }
191        HorizontalSegmentType::CircularArc => {
192            if segment.start_radius == 0.0
193                || segment.start_radius != segment.end_radius
194                || !segment.start_radius.is_finite()
195            {
196                return Err(AlignmentError::InvalidSegment {
197                    entity: segment.entity,
198                    detail: "CIRCULARARC requires equal, finite, non-zero start and end radii",
199                });
200            }
201            // Signed: a positive radius turns counter-clockwise, as IFC
202            // states and as the kernel's curvature sign convention reads it.
203            Ok(CurvatureLaw::circular(curvature_of(segment.start_radius)))
204        }
205        HorizontalSegmentType::Transition(name) if is_exactly_lowerable(name, cant.is_some()) => {
206            transition_curvature(segment, name, cant, station)
207        }
208        _ => Err(refuse_unlowerable(segment)),
209    }
210}