Skip to main content

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}