axiolid_overlay/arc_overlay.rs
1//! Arc-aware planar boolean (ADR 0050; exact since ADR 0070).
2//!
3//! The polygon path ([`crate::overlay`], [`crate::Region`]) runs on the same
4//! exact core since #173. This path handles boundaries that carry arcs.
5//!
6//! # Exact, not tolerant
7//!
8//! Every topological decision is an exact sign over the given `f64` input
9//! (see `exact_arc`): where boundaries cross, in which order, what lies
10//! inside what, how the result links into rings, which ring is a hole of
11//! which. None of them uses the tolerance, so a scene gives the same answer
12//! in millimetres and in metres. Crossing points of two curves are in
13//! general irrational; they are rounded to `f64` once, in the output
14//! (correctly rounded where they are rational, as segment crossings are).
15//!
16//! The tolerance still validates operands ([`validate_arc_ring`]), the
17//! same contract the polygon path applies.
18
19use axiolid_core::Tolerance;
20
21use crate::arc::{arc_ring_area, validate_arc_ring, ArcRing};
22use crate::exact_arc;
23use crate::{OverlayError, OverlayOperation};
24
25/// An arc-aware region: one outer boundary and its holes.
26#[derive(Debug, Clone, PartialEq)]
27pub struct ArcPolygon {
28 /// Outer boundary, counter-clockwise.
29 pub outer: ArcRing,
30 /// Inner boundaries, each clockwise.
31 pub holes: Vec<ArcRing>,
32}
33
34/// Evidence that the arc path did what it claims.
35///
36/// `arc_edges` is the load-bearing number: a tessellating backend
37/// would return zero here while still producing a plausible area.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub struct ArcOverlayEvidence {
40 /// Outer boundaries in the result.
41 pub regions: usize,
42 /// Hole boundaries across all regions.
43 pub holes: usize,
44 /// Curved edges preserved as arcs, never tessellated.
45 pub arc_edges: usize,
46 /// Straight edges in the result.
47 pub line_edges: usize,
48}
49
50/// The result of an arc-aware boolean.
51#[derive(Debug, Clone, PartialEq)]
52pub struct ArcOverlayResult {
53 pub regions: Vec<ArcPolygon>,
54 pub evidence: ArcOverlayEvidence,
55}
56
57/// Count arc and straight edges in a ring.
58fn count_edges(ring: &ArcRing) -> (usize, usize) {
59 let arcs = ring
60 .vertices
61 .iter()
62 .filter(|vertex| vertex.bulge != 0.0)
63 .count();
64 (arcs, ring.vertices.len() - arcs)
65}
66
67/// Orient a ring so its signed area matches the wanted sign.
68///
69/// Outer boundaries are counter-clockwise and holes clockwise, so a
70/// consumer can rely on winding without recomputing areas. Operands are
71/// normalised on the way in (the exact core assumes the region lies left of
72/// its boundary) and results on the way out.
73fn oriented(ring: ArcRing, want_positive: bool) -> ArcRing {
74 if (arc_ring_area(&ring) > 0.0) == want_positive {
75 ring
76 } else {
77 crate::arc::reverse_arc_ring(&ring)
78 }
79}
80
81/// Collapse edges no longer than `tolerance` after output rounding.
82///
83/// Exact topology keeps pieces of any length. A crossing that lies within
84/// rounding of a vertex (a circle drawn through a corner, with coordinates
85/// like 0.3 that binary cannot hold) leaves a piece far shorter than any
86/// meaningful length, and once both ends are rounded to `f64` it can become
87/// a repeated vertex. Such an edge is dropped: its end vertex goes, and the
88/// following edge's bulge moves to its start, which lies within
89/// `tolerance` of the dropped vertex. A ring left without a valid shape
90/// (fewer vertices than a boundary needs, or no area) was a sliver below
91/// the tolerance and is removed.
92///
93/// This is the only place the tolerance acts on a result, and it acts on
94/// presentation only: which pieces exist and how they link were decided
95/// exactly beforehand.
96pub(crate) fn presented(mut ring: ArcRing, tolerance: Tolerance) -> Option<ArcRing> {
97 loop {
98 let count = ring.vertices.len();
99 if count < 2 {
100 return None;
101 }
102 let short = (0..count).find(|&i| {
103 let (a, b) = (ring.vertices[i].point, ring.vertices[(i + 1) % count].point);
104 (b - a).length() <= tolerance.linear()
105 });
106 let Some(i) = short else {
107 break;
108 };
109 let j = (i + 1) % count;
110 ring.vertices[i].bulge = ring.vertices[j].bulge;
111 ring.vertices.remove(j);
112 }
113 match validate_arc_ring(&ring, tolerance) {
114 Err(OverlayError::RingTooShort | OverlayError::ZeroArea) => None,
115 _ => Some(ring),
116 }
117}
118
119/// Boolean of two arc-capable regions.
120///
121/// Both operands are validated by the same contract the polygon path
122/// uses, so malformed input is refused by reason before any work happens.
123///
124/// Returns regions with outer boundaries counter-clockwise and holes
125/// clockwise. An empty result is not an error: an intersection of
126/// disjoint shapes is legitimately empty.
127///
128/// # Errors
129///
130/// Any [`validate_arc_ring`] refusal, and
131/// [`OverlayError::SelfIntersection`] when an operand's boundary crosses
132/// itself, which the exact linking detects.
133pub fn arc_overlay(
134 subject: &ArcRing,
135 clip: &ArcRing,
136 operation: OverlayOperation,
137 tolerance: Tolerance,
138) -> Result<ArcOverlayResult, OverlayError> {
139 validate_arc_ring(subject, tolerance)?;
140 validate_arc_ring(clip, tolerance)?;
141 let subject = oriented(subject.clone(), true);
142 let clip = oriented(clip.clone(), true);
143
144 let regions: Vec<ArcPolygon> = exact_arc::boolean(&subject, &clip, operation)?
145 .into_iter()
146 .filter_map(|(outer, holes)| {
147 Some(ArcPolygon {
148 outer: oriented(presented(outer, tolerance)?, true),
149 holes: holes
150 .into_iter()
151 .filter_map(|h| presented(h, tolerance))
152 .map(|h| oriented(h, false))
153 .collect(),
154 })
155 })
156 .collect();
157
158 let mut arc_edges = 0;
159 let mut line_edges = 0;
160 let mut holes = 0;
161 for region in ®ions {
162 holes += region.holes.len();
163 for ring in std::iter::once(®ion.outer).chain(®ion.holes) {
164 let (arcs, lines) = count_edges(ring);
165 arc_edges += arcs;
166 line_edges += lines;
167 }
168 }
169
170 Ok(ArcOverlayResult {
171 evidence: ArcOverlayEvidence {
172 regions: regions.len(),
173 holes,
174 arc_edges,
175 line_edges,
176 },
177 regions,
178 })
179}