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}