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}