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