axiolid_overlay/
arc.rs

1//! Arc-capable planar boundaries (ADR 0050, step 1).
2//!
3//! # Why a second ring type
4//!
5//! [`Ring`](crate::Ring) is a point list, so every edge between
6//! consecutive points is a straight segment. A cylinder cross-section is a
7//! circle, and approximating it by segments would destroy the exactness
8//! that the exact boolean path exists to provide.
9//!
10//! [`ArcRing`] instead stores a bulge per edge, the DXF convention:
11//! `bulge = tan(theta / 4)` where `theta` is the signed included angle of
12//! the arc from one vertex to the next. `bulge == 0` is exactly a straight
13//! segment, so a polygonal boundary is representable without a special
14//! case, and a circle needs only two vertices.
15//!
16//! The sign carries direction: a positive bulge turns counter-clockwise
17//! (bulging left of the chord), a negative one clockwise. That is what
18//! makes a bite and a bump distinguishable on the same chord.
19//!
20//! # What this module does NOT do
21//!
22//! No boolean, no backend, no conversion to any third-party type. This is
23//! the neutral contract and its validation only; ADR 0050 keeps the
24//! adapter in a later step so that library types never reach this API.
25
26use axiolid_core::{Point2, Tolerance};
27
28use crate::OverlayError;
29
30/// One boundary vertex and the bulge of the edge leaving it.
31///
32/// The bulge describes the edge from this vertex to the NEXT one, so a
33/// ring of `n` vertices has exactly `n` edges and needs no separate edge
34/// list. Storing it per departing vertex keeps insertion and reversal
35/// local operations rather than re-indexing an edge array.
36#[derive(Debug, Clone, Copy, PartialEq)]
37pub struct ArcVertex {
38    /// Vertex position.
39    pub point: Point2,
40    /// `tan(theta / 4)` for the edge leaving `point`; `0` means straight.
41    pub bulge: f64,
42}
43
44impl ArcVertex {
45    /// A vertex whose departing edge is a straight segment.
46    pub const fn straight(point: Point2) -> Self {
47        Self { point, bulge: 0.0 }
48    }
49
50    /// A vertex whose departing edge bulges by `bulge`.
51    pub const fn bulged(point: Point2, bulge: f64) -> Self {
52        Self { point, bulge }
53    }
54
55    /// True when the departing edge is straight.
56    ///
57    /// Exact comparison is deliberate: a bulge is either exactly zero, in
58    /// which case the edge IS a segment, or it is an arc whose radius is
59    /// whatever the value implies. Treating tiny bulges as straight would
60    /// silently discard curvature the caller asked for.
61    pub fn is_straight(&self) -> bool {
62        self.bulge == 0.0
63    }
64}
65
66/// A closed boundary whose edges may be arcs.
67///
68/// Always closed: the edge leaving the last vertex returns to the first.
69/// An explicitly repeated closing vertex is therefore a zero-length edge
70/// and is refused by [`validate_arc_ring`].
71#[derive(Debug, Clone, PartialEq)]
72pub struct ArcRing {
73    /// Boundary vertices in order.
74    pub vertices: Vec<ArcVertex>,
75}
76
77impl ArcRing {
78    /// Build a ring from vertices.
79    pub fn new(vertices: Vec<ArcVertex>) -> Self {
80        Self { vertices }
81    }
82
83    /// Build a straight-edged ring, the polygonal case.
84    pub fn from_points(points: &[Point2]) -> Self {
85        Self {
86            vertices: points.iter().copied().map(ArcVertex::straight).collect(),
87        }
88    }
89
90    /// A full circle as two semicircular edges.
91    ///
92    /// Two vertices is the minimum honest encoding of a circle: one vertex
93    /// would make the chord degenerate and the centre ambiguous, which is
94    /// why [`validate_arc_ring`] requires at least two.
95    pub fn circle(centre: Point2, radius: f64) -> Self {
96        let left = Point2::new(centre.x - radius, centre.y);
97        let right = Point2::new(centre.x + radius, centre.y);
98        Self {
99            vertices: vec![ArcVertex::bulged(left, 1.0), ArcVertex::bulged(right, 1.0)],
100        }
101    }
102
103    /// Number of edges, which equals the number of vertices.
104    pub fn edge_count(&self) -> usize {
105        self.vertices.len()
106    }
107
108    /// True when every edge is straight.
109    pub fn is_polygonal(&self) -> bool {
110        self.vertices.iter().all(ArcVertex::is_straight)
111    }
112}
113
114/// Signed area enclosed by an arc ring.
115///
116/// Positive is counter-clockwise. The value is the polygon area over the
117/// vertices plus, for each arc edge, the signed area of its circular
118/// segment:
119///
120/// ```text
121/// theta = 4 * atan(bulge)                 signed included angle
122/// R     = chord * (1 + bulge^2) / (4 |bulge|)
123/// extra = R^2 * (theta - sin theta) / 2   signed by theta
124/// ```
125///
126/// Keeping `theta` signed is what makes a reversed ring negate exactly: a
127/// clockwise circle must return `-pi r^2`, not `+pi r^2`. An unsigned
128/// segment term gets the disc right and the reversed disc wrong.
129pub fn arc_ring_area(ring: &ArcRing) -> f64 {
130    let count = ring.vertices.len();
131    if count < 2 {
132        return 0.0;
133    }
134    let mut area = 0.0;
135    for index in 0..count {
136        let from = ring.vertices[index];
137        let to = ring.vertices[(index + 1) % count];
138        area += from.point.x * to.point.y - to.point.x * from.point.y;
139    }
140    area *= 0.5;
141    for index in 0..count {
142        let from = ring.vertices[index];
143        if from.is_straight() {
144            continue;
145        }
146        let to = ring.vertices[(index + 1) % count];
147        let chord = (to.point - from.point).length();
148        if chord == 0.0 {
149            continue;
150        }
151        let bulge = from.bulge;
152        let theta = 4.0 * bulge.atan();
153        let radius = chord * (1.0 + bulge * bulge) / (4.0 * bulge.abs());
154        area += 0.5 * radius * radius * (theta - theta.sin());
155    }
156    area
157}
158
159/// Arc radius implied by an edge, or `None` for a straight edge.
160pub fn arc_edge_radius(from: ArcVertex, to: ArcVertex) -> Option<f64> {
161    if from.is_straight() {
162        return None;
163    }
164    let chord = (to.point - from.point).length();
165    if chord == 0.0 {
166        return None;
167    }
168    let bulge = from.bulge;
169    Some(chord * (1.0 + bulge * bulge) / (4.0 * bulge.abs()))
170}
171
172/// Validate an arc ring against the same contract as [`crate::Ring`],
173/// plus the arc-specific degeneracies.
174///
175/// Refused, each with a reason the caller can act on:
176///
177/// - fewer than two vertices: a circle needs two, a polygon needs three,
178///   and one vertex cannot describe either
179/// - a polygonal ring with fewer than three vertices: two straight edges
180///   enclose no area
181/// - non-finite coordinates or a non-finite bulge
182/// - a zero-length edge, which leaves the arc centre undefined
183/// - an arc whose implied radius is below tolerance, the zero-radius case
184///   ADR 0050 flagged: such an arc is a point, not a boundary
185/// - a ring enclosing no measurable area
186///
187/// Self-intersection is NOT checked here. Arc/arc and arc/segment crossing
188/// tests are genuinely part of the overlay algorithm, and a cheap
189/// approximation would either reject valid input or pass invalid input.
190/// The straight-edge contract checks it because there the test is exact;
191/// claiming the same guarantee for arcs without the machinery would be
192/// dishonest, so the gap is named instead.
193pub fn validate_arc_ring(ring: &ArcRing, tolerance: Tolerance) -> Result<(), OverlayError> {
194    let count = ring.vertices.len();
195    if count < 2 {
196        return Err(OverlayError::RingTooShort);
197    }
198    if ring.is_polygonal() && count < 3 {
199        return Err(OverlayError::RingTooShort);
200    }
201    if !ring
202        .vertices
203        .iter()
204        .all(|vertex| vertex.point.is_finite() && vertex.bulge.is_finite())
205    {
206        return Err(OverlayError::NonFinitePoint);
207    }
208    for index in 0..count {
209        let from = ring.vertices[index];
210        let to = ring.vertices[(index + 1) % count];
211        if (to.point - from.point).length() <= tolerance.linear() {
212            return Err(OverlayError::RepeatedVertex);
213        }
214        // A bulge implying a sub-tolerance radius is a point masquerading
215        // as an arc: the chord is shorter than the tolerance relative to
216        // the curvature, so no circle can be recovered from it.
217        if let Some(radius) = arc_edge_radius(from, to) {
218            if radius <= tolerance.linear() {
219                return Err(OverlayError::ZeroRadiusArc);
220            }
221        }
222    }
223    if arc_ring_area(ring).abs() <= tolerance.linear().powi(2) {
224        return Err(OverlayError::ZeroArea);
225    }
226    Ok(())
227}
228
229/// Reverse a ring's orientation, preserving its geometry.
230///
231/// Bulges are stored per departing edge, so reversing the vertex order
232/// alone would attach each bulge to the wrong edge. The bulge list must
233/// shift by one and negate: the edge that left vertex `i` toward `i + 1`
234/// becomes the edge leaving `i + 1` toward `i`, curving the other way.
235pub fn reverse_arc_ring(ring: &ArcRing) -> ArcRing {
236    let count = ring.vertices.len();
237    if count == 0 {
238        return ArcRing::new(Vec::new());
239    }
240    let mut vertices = Vec::with_capacity(count);
241    for index in (0..count).rev() {
242        let point = ring.vertices[index].point;
243        // The edge arriving at `index` came from its predecessor.
244        let predecessor = (index + count - 1) % count;
245        vertices.push(ArcVertex::bulged(point, -ring.vertices[predecessor].bulge));
246    }
247    ArcRing::new(vertices)
248}