ifc_alignment/curve/seam.rs
1//! Seams between consecutive horizontal segments, and the rule for checking
2//! them without numerical integration.
3//!
4//! Every `IfcAlignmentHorizontalSegment` restates its own `StartPoint` and
5//! `StartDirection`. IFC names the checks that follow: the computed end of
6//! the previous segment should match both. This crate computes nothing it
7//! cannot compute in closed form, so the rule is:
8//!
9//! - **Direction** is checkable after every segment with a curvature law.
10//! Each law this crate lowers has an elementary antiderivative, so the
11//! heading at a segment's end is its `StartDirection` plus a closed-form
12//! turning integral. A `CUBIC` has no curvature law in arc length: its
13//! end heading is `atan(x_e^2 / (2 R L))` at the abscissa `x_e` where its
14//! arc length reaches `L`, an elliptic-integral inverse.
15//! - **Position** is checkable only after a `LINE` or `CIRCULARARC`, whose
16//! end point is elementary. After a transition spiral the end point is a
17//! Fresnel-type integral, after a `CUBIC` the same elliptic inverse.
18//! Rather than quadrature it, or refusing every real alignment, the seam
19//! is recorded as [`SeamCheck::Authored`]: the authored `StartPoint` (and,
20//! after a `CUBIC`, the authored `StartDirection`) is named as unverified.
21//!
22//! A mismatch that IS checkable is refused, never smoothed over.
23
24use axiolid_core::{Frame2, Point2, Vec2};
25use axiolid_curve::{CurvatureLaw, Intrinsic2};
26use ifc_model::EntityId;
27
28use crate::error::{AlignmentError, AlignmentResult};
29use crate::horizontal::{HorizontalSegment, HorizontalSegmentType};
30
31/// Largest gap, in metres, accepted between a closed-form end point and the
32/// next segment's authored `StartPoint`.
33pub(super) const POSITION_TOLERANCE: f64 = 1e-6;
34
35/// Largest difference, in radians, accepted between a closed-form end
36/// heading and the next segment's authored `StartDirection`.
37pub(super) const DIRECTION_TOLERANCE: f64 = 1e-6;
38
39/// How the position at a seam between two horizontal segments was checked.
40#[non_exhaustive]
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum SeamCheck {
43 /// The previous segment's end point is closed form and matches the
44 /// authored `StartPoint` of the next segment within 1e-6 m.
45 Verified,
46 /// The previous segment is a transition spiral or a `CUBIC`, whose end
47 /// point is a non-elementary integral this crate does not evaluate. The
48 /// authored `StartPoint` of the next segment was accepted as stated, not
49 /// verified. After a `CUBIC` its `StartDirection` is not verified either:
50 /// the cubic's end heading is not closed form.
51 Authored,
52}
53
54/// One seam between consecutive horizontal segments.
55#[derive(Debug, Clone, PartialEq)]
56#[non_exhaustive]
57pub struct HorizontalSeam {
58 /// The segment that ends at this seam.
59 pub previous: EntityId,
60 /// The segment that starts at this seam.
61 pub next: EntityId,
62 /// Distance along the layout from its first segment to the seam, in
63 /// metres: the sum of the preceding `SegmentLength`s.
64 pub distance_along: f64,
65 /// How the seam position was checked.
66 pub position: SeamCheck,
67}
68
69/// Exact end point of a segment, in closed form.
70///
71/// `None` for every transition-spiral family and `CUBIC`: their end point
72/// is a Fresnel-type or elliptic integral, so there is no closed form to
73/// return and this crate will not quadrature one into existence.
74pub(super) fn closed_form_end_point(segment: &HorizontalSegment) -> Option<Point2> {
75 match segment.segment_type {
76 HorizontalSegmentType::Line => {
77 let direction = Vec2::new(segment.start_direction.cos(), segment.start_direction.sin());
78 Some(segment.start_point + direction * segment.segment_length)
79 }
80 HorizontalSegmentType::CircularArc if segment.start_radius != 0.0 => {
81 let direction = Vec2::new(segment.start_direction.cos(), segment.start_direction.sin());
82 let left = Vec2::new(-direction.y, direction.x);
83 let centre = segment.start_point + left * segment.start_radius;
84 let sweep = segment.segment_length / segment.start_radius;
85 let radial = segment.start_point - centre;
86 let (sin_s, cos_s) = sweep.sin_cos();
87 Some(
88 centre
89 + Vec2::new(
90 radial.x * cos_s - radial.y * sin_s,
91 radial.x * sin_s + radial.y * cos_s,
92 ),
93 )
94 }
95 _ => None,
96 }
97}
98
99/// Check the position where `next` starts against where `previous` ends.
100///
101/// Returns how the seam was checked, or refuses a closed-form mismatch.
102pub(super) fn check_position(
103 previous: &HorizontalSegment,
104 next: &HorizontalSegment,
105 distance_along: f64,
106) -> AlignmentResult<HorizontalSeam> {
107 let position = match closed_form_end_point(previous) {
108 Some(end) if end.distance(next.start_point) <= POSITION_TOLERANCE => SeamCheck::Verified,
109 Some(_) => {
110 return Err(AlignmentError::SemanticViolation {
111 entity: Some(next.entity),
112 rule: "consecutive horizontal segments must share an endpoint exactly",
113 })
114 }
115 None => SeamCheck::Authored,
116 };
117 Ok(HorizontalSeam {
118 previous: previous.entity,
119 next: next.entity,
120 distance_along,
121 position,
122 })
123}
124
125/// Check the heading where `next` starts against where `previous` ends.
126///
127/// `law` is the previous segment's exact curvature law. The end heading is
128/// its `StartDirection` plus the closed-form turning integral of that law,
129/// so this check holds for spirals too. Directions are compared modulo a
130/// full turn: IFC allows the same bearing to be written as `-pi/2` or
131/// `3pi/2`.
132pub(super) fn check_direction(
133 previous: &HorizontalSegment,
134 law: &CurvatureLaw,
135 next: &HorizontalSegment,
136) -> AlignmentResult<()> {
137 let turning = if previous.segment_length == 0.0 {
138 0.0
139 } else {
140 Intrinsic2::new(unit_frame(), law.clone(), previous.segment_length)
141 .total_turning()
142 .ok_or(AlignmentError::InvalidSegment {
143 entity: previous.entity,
144 detail: "horizontal segment curvature law has no finite turning integral",
145 })?
146 };
147 let end = previous.start_direction + turning;
148 let difference = (next.start_direction - end).rem_euclid(core::f64::consts::TAU);
149 let difference = difference.min(core::f64::consts::TAU - difference);
150 if difference.is_finite() && difference <= DIRECTION_TOLERANCE {
151 Ok(())
152 } else {
153 Err(AlignmentError::SemanticViolation {
154 entity: Some(next.entity),
155 rule: "consecutive horizontal segments must share a tangent direction: \
156 one exact plan curve cannot carry a kink",
157 })
158 }
159}
160
161/// The unit frame at the origin. Turning does not depend on placement, so
162/// any frame serves to ask the kernel for a law's closed-form integral.
163fn unit_frame() -> Frame2 {
164 Frame2 {
165 origin: Point2::new(0.0, 0.0),
166 x: Vec2::new(1.0, 0.0),
167 y: Vec2::new(0.0, 1.0),
168 }
169}