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}