Skip to main content

brep_cam/planar/
mod.rs

1//! The 2D geometry engine (lane C5): regions bounded by lines and EXACT arcs,
2//! robust booleans and offsets, point containment and distances.
3//!
4//! A [`Region`] is a set of oriented closed [`Loop`]s with the positive-winding
5//! rule: a point is inside when its winding number is positive, so outer loops
6//! run counter-clockwise and holes clockwise. Every arc is kept exact (centre,
7//! sense) through booleans and offsets; discretisation happens only when a
8//! consumer asks for chords at a tolerance.
9//!
10//! Robustness comes from one arrangement step shared by every operation
11//! ([`arrange`]): all pairwise intersections are computed in closed form,
12//! vertices within `point_eps` are merged, coincident pieces are grouped, and
13//! classification then works on the pieces of that arrangement with exact
14//! winding bookkeeping ([`ops`]). Arcs are canonicalised into quadrant pieces
15//! (split at the axis extremes, sweep at most 90 degrees) so every piece is
16//! monotone in both axes and a horizontal ray meets it at most once.
17//!
18//! Everything is deterministic: no hashing, every sort has an index tie-break.
19
20use crate::model::Point2;
21use crate::numeric::Tolerance;
22use serde::{Deserialize, Serialize};
23use std::f64::consts::{FRAC_PI_2, TAU};
24
25pub mod arrange;
26pub mod extract;
27pub mod machining;
28pub mod ops;
29
30pub use ops::{boolean, chain_capsule, clip_line, offset_chain, offset_region, BoolOp, Side};
31
32/// Why a planar operation refused its input or could not close its output.
33#[derive(Clone, Debug, PartialEq)]
34pub enum PlanarError {
35    /// A coordinate was NaN or infinite.
36    NonFinite { what: String },
37    /// A segment or loop is degenerate (zero length, unequal arc radii, open loop, ...).
38    Degenerate(String),
39    /// The arrangement's kept pieces do not close into loops at `at`.
40    OpenBoundary { at: Point2 },
41    /// A user-facing refusal.
42    InvalidInput(String),
43    /// A result that contradicts an invariant of its operation (an offset
44    /// that loses a loop it cannot reach, a boolean outside its area bounds).
45    Inconsistent(String),
46}
47
48impl std::fmt::Display for PlanarError {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        match self {
51            PlanarError::NonFinite { what } => write!(f, "planar: {what} is not finite"),
52            PlanarError::Degenerate(m) => write!(f, "planar: degenerate input: {m}"),
53            PlanarError::OpenBoundary { at } => {
54                write!(f, "planar: the result boundary does not close at ({}, {})", at.x, at.y)
55            }
56            PlanarError::InvalidInput(m) => write!(f, "planar: {m}"),
57            PlanarError::Inconsistent(m) => write!(f, "planar: inconsistent result: {m}"),
58        }
59    }
60}
61
62impl std::error::Error for PlanarError {}
63
64impl From<PlanarError> for crate::CamError {
65    fn from(e: PlanarError) -> Self {
66        crate::CamError::InvalidInput(e.to_string())
67    }
68}
69
70// ---------------------------------------------------------------------------
71// 2D vector helpers (Point2 is C0's plain value type; the arithmetic lives here)
72// ---------------------------------------------------------------------------
73
74pub(crate) trait Vec2 {
75    fn add(self, o: Point2) -> Point2;
76    fn sub(self, o: Point2) -> Point2;
77    fn scale(self, s: f64) -> Point2;
78    fn dot(self, o: Point2) -> f64;
79    fn cross(self, o: Point2) -> f64;
80    fn length(self) -> f64;
81    fn distance(self, o: Point2) -> f64;
82    /// Rotated +90 degrees (the left normal of a direction).
83    fn perp(self) -> Point2;
84    fn normalized(self) -> Option<Point2>;
85    fn angle(self) -> f64;
86}
87
88impl Vec2 for Point2 {
89    fn add(self, o: Point2) -> Point2 {
90        Point2::new(self.x + o.x, self.y + o.y)
91    }
92    fn sub(self, o: Point2) -> Point2 {
93        Point2::new(self.x - o.x, self.y - o.y)
94    }
95    fn scale(self, s: f64) -> Point2 {
96        Point2::new(self.x * s, self.y * s)
97    }
98    fn dot(self, o: Point2) -> f64 {
99        self.x * o.x + self.y * o.y
100    }
101    fn cross(self, o: Point2) -> f64 {
102        self.x * o.y - self.y * o.x
103    }
104    fn length(self) -> f64 {
105        self.x.hypot(self.y)
106    }
107    fn distance(self, o: Point2) -> f64 {
108        self.sub(o).length()
109    }
110    fn perp(self) -> Point2 {
111        Point2::new(-self.y, self.x)
112    }
113    fn normalized(self) -> Option<Point2> {
114        let l = self.length();
115        if l > 0.0 && l.is_finite() {
116            Some(self.scale(1.0 / l))
117        } else {
118            None
119        }
120    }
121    fn angle(self) -> f64 {
122        self.y.atan2(self.x)
123    }
124}
125
126pub(crate) fn dir(a: f64) -> Point2 {
127    Point2::new(a.cos(), a.sin())
128}
129
130// ---------------------------------------------------------------------------
131// Segments
132// ---------------------------------------------------------------------------
133
134/// The circular part of an arc segment: its centre and sense.
135#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
136#[serde(rename_all = "camelCase")]
137pub struct Arc {
138    pub center: Point2,
139    /// Counter-clockwise from the segment's start to its end.
140    pub ccw: bool,
141}
142
143/// A directed line or arc. An arc runs from `start` to `end` about
144/// `arc.center` in the sense `arc.ccw`; full circles are two arcs.
145#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
146#[serde(rename_all = "camelCase")]
147pub struct Segment {
148    pub start: Point2,
149    pub end: Point2,
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub arc: Option<Arc>,
152}
153
154/// Where two segments meet.
155#[derive(Clone, Copy, Debug, PartialEq)]
156pub struct Hit {
157    pub point: Point2,
158    /// Parameter on the first segment, `0..=1`.
159    pub ta: f64,
160    /// Parameter on the second segment, `0..=1`.
161    pub tb: f64,
162}
163
164impl Segment {
165    pub fn line(start: Point2, end: Point2) -> Self {
166        Self { start, end, arc: None }
167    }
168
169    pub fn arc(start: Point2, end: Point2, center: Point2, ccw: bool) -> Self {
170        Self { start, end, arc: Some(Arc { center, ccw }) }
171    }
172
173    pub fn is_arc(&self) -> bool {
174        self.arc.is_some()
175    }
176
177    pub fn reversed(&self) -> Self {
178        Self { start: self.end, end: self.start, arc: self.arc.map(|a| Arc { center: a.center, ccw: !a.ccw }) }
179    }
180
181    /// Radius of an arc (from its start), `0` for a line.
182    pub fn radius(&self) -> f64 {
183        self.arc.map_or(0.0, |a| self.start.distance(a.center))
184    }
185
186    /// Start angle and SIGNED sweep (positive counter-clockwise) of an arc.
187    /// For a line both are zero.
188    pub fn angles(&self) -> (f64, f64) {
189        match self.arc {
190            None => (0.0, 0.0),
191            Some(a) => {
192                let a0 = self.start.sub(a.center).angle();
193                let a1 = self.end.sub(a.center).angle();
194                let ccw = (a1 - a0).rem_euclid(TAU);
195                if a.ccw {
196                    (a0, ccw)
197                } else {
198                    (a0, ccw - TAU)
199                }
200            }
201        }
202    }
203
204    /// Arc length (chord length for a line).
205    pub fn length(&self) -> f64 {
206        match self.arc {
207            None => self.start.distance(self.end),
208            Some(_) => self.radius() * self.angles().1.abs(),
209        }
210    }
211
212    pub fn point_at(&self, t: f64) -> Point2 {
213        let t = t.clamp(0.0, 1.0);
214        if t == 0.0 {
215            return self.start;
216        }
217        if t == 1.0 {
218            return self.end;
219        }
220        match self.arc {
221            None => self.start.add(self.end.sub(self.start).scale(t)),
222            Some(a) => {
223                let (a0, sweep) = self.angles();
224                a.center.add(dir(a0 + sweep * t).scale(self.radius()))
225            }
226        }
227    }
228
229    /// Unit tangent in the direction of travel at parameter `t`.
230    pub fn tangent_at(&self, t: f64) -> Point2 {
231        match self.arc {
232            None => self.end.sub(self.start).normalized().unwrap_or(Point2::new(1.0, 0.0)),
233            Some(a) => {
234                let (a0, sweep) = self.angles();
235                let radial = dir(a0 + sweep * t.clamp(0.0, 1.0));
236                if a.ccw {
237                    radial.perp()
238                } else {
239                    radial.perp().scale(-1.0)
240                }
241            }
242        }
243    }
244
245    pub fn midpoint(&self) -> Point2 {
246        self.point_at(0.5)
247    }
248
249    /// Axis-aligned bounds `(min, max)`.
250    pub fn bounds(&self) -> (Point2, Point2) {
251        let mut lo = Point2::new(self.start.x.min(self.end.x), self.start.y.min(self.end.y));
252        let mut hi = Point2::new(self.start.x.max(self.end.x), self.start.y.max(self.end.y));
253        if let Some(a) = self.arc {
254            let r = self.radius();
255            let (a0, sweep) = self.angles();
256            let (lo_a, hi_a) = if sweep >= 0.0 { (a0, a0 + sweep) } else { (a0 + sweep, a0) };
257            // Every axis extreme angle inside the sweep extends the box.
258            let k0 = (lo_a / FRAC_PI_2).floor() as i64;
259            for k in k0..=k0 + 5 {
260                let ang = k as f64 * FRAC_PI_2;
261                if ang > lo_a && ang < hi_a {
262                    let p = a.center.add(dir(ang).scale(r));
263                    lo = Point2::new(lo.x.min(p.x), lo.y.min(p.y));
264                    hi = Point2::new(hi.x.max(p.x), hi.y.max(p.y));
265                }
266            }
267        }
268        (lo, hi)
269    }
270
271    /// The part of the segment between parameters `t0 < t1`, with the given
272    /// exact endpoint coordinates (so split pieces share vertices bit for bit).
273    pub fn sub_segment(&self, start: Point2, end: Point2) -> Segment {
274        Segment { start, end, arc: self.arc }
275    }
276
277    /// Parameter of the nearest point on the supporting curve to `p`, clamped
278    /// to the segment, and that point.
279    pub fn nearest(&self, p: Point2) -> (Point2, f64) {
280        match self.arc {
281            None => {
282                let d = self.end.sub(self.start);
283                let l2 = d.dot(d);
284                let t = if l2 > 0.0 { (p.sub(self.start).dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 };
285                (self.point_at(t), t)
286            }
287            Some(a) => {
288                let (a0, sweep) = self.angles();
289                let v = p.sub(a.center);
290                if v.length() == 0.0 {
291                    return (self.start, 0.0);
292                }
293                let ang = v.angle();
294                // Fraction of the sweep at which the radial direction lies.
295                let rel = if sweep >= 0.0 { (ang - a0).rem_euclid(TAU) } else { -((a0 - ang).rem_euclid(TAU)) };
296                let t = rel / sweep;
297                if t.is_finite() && (0.0..=1.0).contains(&t) {
298                    (a.center.add(dir(ang).scale(self.radius())), t)
299                } else if p.distance(self.start) <= p.distance(self.end) {
300                    (self.start, 0.0)
301                } else {
302                    (self.end, 1.0)
303                }
304            }
305        }
306    }
307
308    pub fn distance(&self, p: Point2) -> f64 {
309        self.nearest(p).0.distance(p)
310    }
311
312    /// Signed area between the chord and the arc (positive for a
313    /// counter-clockwise arc, which bulges to the right of its chord).
314    pub fn bulge_area(&self) -> f64 {
315        match self.arc {
316            None => 0.0,
317            Some(_) => {
318                let (_, s) = self.angles();
319                let r = self.radius();
320                0.5 * r * r * (s - s.sin())
321            }
322        }
323    }
324
325    /// Chord points from `start` to `end` with sagitta at most `tol`.
326    pub fn discretize(&self, tol: f64) -> Vec<Point2> {
327        match self.arc {
328            None => vec![self.start, self.end],
329            Some(_) => {
330                let r = self.radius();
331                let (_, sweep) = self.angles();
332                let max_step = if tol >= r || tol <= 0.0 { FRAC_PI_2 } else { 2.0 * (1.0 - tol / r).acos() };
333                let n = ((sweep.abs() / max_step.max(1e-9)).ceil() as usize).clamp(1, 100_000);
334                let mut pts = Vec::with_capacity(n + 1);
335                pts.push(self.start);
336                for i in 1..n {
337                    pts.push(self.point_at(i as f64 / n as f64));
338                }
339                pts.push(self.end);
340                pts
341            }
342        }
343    }
344
345    /// Chord points whose chords never come closer to the arc's centre than
346    /// the arc itself: the circumscribed polygon. Its vertices sit at the
347    /// half-step angles at `radius / cos(step / 2)`, so every chord lies on
348    /// a tangent line of the arc (the first and last chords on the tangents
349    /// at the arc's ends) and the polyline deviates outward by at most
350    /// `tol`. Lines are unchanged. Used for tool-centre arcs whose centre
351    /// side holds material.
352    pub fn discretize_outward(&self, tol: f64) -> Vec<Point2> {
353        let Some(a) = self.arc else { return vec![self.start, self.end] };
354        let r = self.radius();
355        let (a0, sweep) = self.angles();
356        // 1/cos(s/2) - 1 <= tol/r bounds the outward deviation.
357        let max_step = if tol >= r || tol <= 0.0 { FRAC_PI_2 } else { 2.0 * (r / (r + tol)).acos() };
358        let n = ((sweep.abs() / max_step.max(1e-9)).ceil() as usize).clamp(1, 100_000);
359        let step = sweep / n as f64;
360        let outer = r / (0.5 * step).cos();
361        let mut pts = Vec::with_capacity(n + 2);
362        pts.push(self.start);
363        for i in 0..n {
364            pts.push(a.center.add(dir(a0 + step * (i as f64 + 0.5)).scale(outer)));
365        }
366        pts.push(self.end);
367        pts
368    }
369
370    pub fn validate(&self, tol: &Tolerance) -> Result<(), PlanarError> {
371        if !self.start.is_finite() || !self.end.is_finite() {
372            return Err(PlanarError::NonFinite { what: "segment endpoint".into() });
373        }
374        if self.start.distance(self.end) <= tol.point_eps {
375            return Err(PlanarError::Degenerate(format!("segment of zero length at ({}, {})", self.start.x, self.start.y)));
376        }
377        if let Some(a) = self.arc {
378            if !a.center.is_finite() {
379                return Err(PlanarError::NonFinite { what: "arc centre".into() });
380            }
381            let r0 = self.start.distance(a.center);
382            let r1 = self.end.distance(a.center);
383            if r0 <= tol.sample_eps {
384                return Err(PlanarError::Degenerate("arc radius is below the sample epsilon".into()));
385            }
386            // Each end vertex may sit up to a point epsilon off the circle
387            // (the arrangement cuts arcs between merged vertices), so the end
388            // radii of an arc the engine itself produced differ by up to two;
389            // more than that is a malformed arc.
390            if (r0 - r1).abs() > (2.0 * tol.point_eps).max(tol.distance_eps).max(1e-9 * r0) {
391                return Err(PlanarError::Degenerate(format!("arc endpoints have radii {r0} and {r1}")));
392            }
393        }
394        Ok(())
395    }
396
397    /// The canonical pieces: lines unchanged, arcs split at every axis
398    /// extreme so each piece lies in one quadrant about its centre.
399    pub fn canonical(&self, tol: &Tolerance) -> Vec<Segment> {
400        let Some(a) = self.arc else { return vec![*self] };
401        let r = self.radius();
402        let (a0, sweep) = self.angles();
403        let (lo, hi) = if sweep >= 0.0 { (a0, a0 + sweep) } else { (a0 + sweep, a0) };
404        let mut cuts: Vec<f64> = Vec::new();
405        let k0 = (lo / FRAC_PI_2).floor() as i64;
406        for k in k0..=k0 + 5 {
407            let ang = k as f64 * FRAC_PI_2;
408            // Skip cuts that would leave a piece shorter than the point epsilon.
409            if ang - lo > tol.point_eps / r && hi - ang > tol.point_eps / r {
410                cuts.push(ang);
411            }
412        }
413        if cuts.is_empty() {
414            return vec![*self];
415        }
416        if sweep < 0.0 {
417            cuts.reverse();
418        }
419        let mut out = Vec::with_capacity(cuts.len() + 1);
420        let mut from = self.start;
421        for c in cuts {
422            let p = a.center.add(dir(c).scale(r));
423            out.push(Segment::arc(from, p, a.center, a.ccw));
424            from = p;
425        }
426        out.push(Segment::arc(from, self.end, a.center, a.ccw));
427        out
428    }
429
430    /// Closed-form intersections of two segments, including the endpoints of
431    /// overlapping collinear / co-circular pairs (each endpoint of one that
432    /// lies on the other is a hit, so both get split there).
433    pub fn intersect(&self, other: &Segment, tol: &Tolerance) -> Vec<Hit> {
434        let eps = tol.distance_eps;
435        let mut hits = Vec::new();
436        // Quick reject on bounds.
437        let (alo, ahi) = self.bounds();
438        let (blo, bhi) = other.bounds();
439        if alo.x > bhi.x + eps || blo.x > ahi.x + eps || alo.y > bhi.y + eps || blo.y > ahi.y + eps {
440            return hits;
441        }
442        match (self.arc, other.arc) {
443            (None, None) => {
444                let d1 = self.end.sub(self.start);
445                let d2 = other.end.sub(other.start);
446                let denom = d1.cross(d2);
447                let w = other.start.sub(self.start);
448                let l1 = d1.length();
449                let l2 = d2.length();
450                if denom.abs() <= tol.angle_eps * l1 * l2 {
451                    // Parallel: overlapping only when collinear.
452                    if w.cross(d1).abs() <= eps * l1 {
453                        overlap_hits(self, other, &mut hits);
454                    }
455                    return hits;
456                }
457                let ta = w.cross(d2) / denom;
458                let tb = w.cross(d1) / denom;
459                let slack_a = eps / l1;
460                let slack_b = eps / l2;
461                if (-slack_a..=1.0 + slack_a).contains(&ta) && (-slack_b..=1.0 + slack_b).contains(&tb) {
462                    let p = self.start.add(d1.scale(ta.clamp(0.0, 1.0)));
463                    hits.push(Hit { point: p, ta: ta.clamp(0.0, 1.0), tb: tb.clamp(0.0, 1.0) });
464                }
465            }
466            (None, Some(_)) => {
467                for h in other.intersect(self, tol) {
468                    hits.push(Hit { point: h.point, ta: h.tb, tb: h.ta });
469                }
470            }
471            (Some(a), None) => {
472                // Endpoints lying on the other curve are exact hits; roots
473                // within the tangency uncertainty of one are its numerical
474                // shadow (a line through a point of a circle that meets the
475                // circle again nearby is nearly tangent) and are dropped.
476                let r = self.radius();
477                self.incidences(other, tol, &mut hits);
478                let shadow = 2.0 * (2.0 * r * eps).sqrt();
479                // |s + t d - c|^2 = r^2
480                let d = other.end.sub(other.start);
481                let f = other.start.sub(a.center);
482                let qa = d.dot(d);
483                let qb = 2.0 * f.dot(d);
484                let qc = f.dot(f) - r * r;
485                let disc = qb * qb - 4.0 * qa * qc;
486                let l = d.length();
487                // Tangency within tolerance counts as one touch.
488                let tangent_slack = 4.0 * qa * (2.0 * r * eps);
489                if disc < -tangent_slack {
490                    return hits;
491                }
492                let roots: Vec<f64> = if disc <= tangent_slack {
493                    vec![-qb / (2.0 * qa)]
494                } else {
495                    let s = disc.sqrt();
496                    vec![(-qb - s) / (2.0 * qa), (-qb + s) / (2.0 * qa)]
497                };
498                let known: Vec<Point2> = hits.iter().map(|h| h.point).collect();
499                for tb in roots {
500                    let slack = eps / l;
501                    if !(-slack..=1.0 + slack).contains(&tb) {
502                        continue;
503                    }
504                    let p = other.start.add(d.scale(tb.clamp(0.0, 1.0)));
505                    if known.iter().any(|k| k.distance(p) <= shadow) {
506                        continue;
507                    }
508                    if let Some(ta) = self.arc_param(p, tol) {
509                        hits.push(Hit { point: p, ta, tb: tb.clamp(0.0, 1.0) });
510                    }
511                }
512            }
513            (Some(a), Some(b)) => {
514                let r0 = self.radius();
515                let r1 = other.radius();
516                let cd = b.center.sub(a.center);
517                let dist = cd.length();
518                if dist <= tol.point_eps && (r0 - r1).abs() <= eps {
519                    overlap_hits(self, other, &mut hits);
520                    return hits;
521                }
522                if dist <= tol.point_eps {
523                    return hits; // concentric, different radii
524                }
525                if dist > r0 + r1 + eps || dist < (r0 - r1).abs() - eps {
526                    return hits;
527                }
528                self.incidences(other, tol, &mut hits);
529                let shadow = 2.0 * (2.0 * r0.max(r1) * eps).sqrt();
530                let known: Vec<Point2> = hits.iter().map(|h| h.point).collect();
531                let x = (dist * dist + r0 * r0 - r1 * r1) / (2.0 * dist);
532                let h2 = r0 * r0 - x * x;
533                let u = cd.scale(1.0 / dist);
534                let base = a.center.add(u.scale(x));
535                // Tangency within tolerance counts as one touch: the circles'
536                // gap (outside each other or one inside the other) is at most
537                // the distance epsilon. Deciding it on `h2` against `2 r0 eps`
538                // took circles overlapping by up to about three epsilons for
539                // tangent (`h2` is `2 g r0 r1 / (r0 + r1)` for an overlap `g`),
540                // and the single touch point between two real crossings can
541                // lie off both arcs: a 0.69 and a 0.35 arc overlapping by
542                // 2.3e-6 lost the crossing one of them ends past, and the
543                // union left a piece without continuation (sweep 51 run 1).
544                let gap = (r0 + r1 - dist).min(dist - (r0 - r1).abs());
545                let candidates: Vec<Point2> = if gap <= eps {
546                    vec![base]
547                } else {
548                    let h = h2.max(0.0).sqrt();
549                    vec![base.sub(u.perp().scale(h)), base.add(u.perp().scale(h))]
550                };
551                for p in candidates {
552                    if known.iter().any(|k| k.distance(p) <= shadow) {
553                        continue;
554                    }
555                    if let (Some(ta), Some(tb)) = (self.arc_param(p, tol), other.arc_param(p, tol)) {
556                        hits.push(Hit { point: p, ta, tb });
557                    }
558                }
559            }
560        }
561        hits
562    }
563
564    /// Hits at endpoints of either segment that lie on the other segment
565    /// (within the distance epsilon), with exact parameters 0 or 1 on the
566    /// owning side.
567    fn incidences(&self, other: &Segment, tol: &Tolerance, hits: &mut Vec<Hit>) {
568        let eps = tol.distance_eps;
569        for (tb, p) in [(0.0, other.start), (1.0, other.end)] {
570            let (q, ta) = self.nearest(p);
571            if q.distance(p) <= eps && !hits.iter().any(|h| h.point.distance(p) <= tol.point_eps) {
572                hits.push(Hit { point: p, ta, tb });
573            }
574        }
575        for (ta, p) in [(0.0, self.start), (1.0, self.end)] {
576            let (q, tb) = other.nearest(p);
577            if q.distance(p) <= eps && !hits.iter().any(|h| h.point.distance(p) <= tol.point_eps) {
578                hits.push(Hit { point: p, ta, tb });
579            }
580        }
581    }
582
583    /// Parameter of a point known to lie on the arc's circle, when it lies
584    /// within the arc's sweep (with endpoint slack of the distance epsilon).
585    fn arc_param(&self, p: Point2, tol: &Tolerance) -> Option<f64> {
586        let a = self.arc?;
587        let (a0, sweep) = self.angles();
588        let r = self.radius();
589        let ang = p.sub(a.center).angle();
590        let rel = if sweep >= 0.0 { (ang - a0).rem_euclid(TAU) } else { -((a0 - ang).rem_euclid(TAU)) };
591        let slack = tol.distance_eps / r;
592        let t = rel / sweep;
593        if (0.0..=1.0).contains(&t) {
594            return Some(t);
595        }
596        // Just before the start (wrapped around) or just past the end.
597        if (rel.abs() - TAU).abs() <= slack || rel.abs() <= slack {
598            return Some(0.0);
599        }
600        if (rel.abs() - sweep.abs()).abs() <= slack {
601            return Some(1.0);
602        }
603        None
604    }
605}
606
607/// Endpoint hits for overlapping segments on the same line or circle.
608fn overlap_hits(a: &Segment, b: &Segment, hits: &mut Vec<Hit>) {
609    for (tb, p) in [(0.0, b.start), (1.0, b.end)] {
610        let (q, ta) = a.nearest(p);
611        if q.distance(p) <= 1e-9 * (1.0 + a.length()) {
612            hits.push(Hit { point: p, ta, tb });
613        }
614    }
615    for (ta, p) in [(0.0, a.start), (1.0, a.end)] {
616        let (q, tb) = b.nearest(p);
617        if q.distance(p) <= 1e-9 * (1.0 + b.length()) {
618            hits.push(Hit { point: p, ta, tb });
619        }
620    }
621}
622
623// ---------------------------------------------------------------------------
624// Loops, chains and regions
625// ---------------------------------------------------------------------------
626
627/// A closed, directed sequence of segments (each ends where the next starts).
628#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
629#[serde(rename_all = "camelCase")]
630pub struct Loop {
631    pub segments: Vec<Segment>,
632}
633
634impl Loop {
635    pub fn new(segments: Vec<Segment>) -> Self {
636        Self { segments }
637    }
638
639    /// A closed polygon through `points` (last joins back to first).
640    pub fn polygon(points: &[Point2]) -> Self {
641        let n = points.len();
642        Self::new((0..n).map(|i| Segment::line(points[i], points[(i + 1) % n])).collect())
643    }
644
645    /// An axis-aligned rectangle, counter-clockwise.
646    pub fn rectangle(min: Point2, max: Point2) -> Self {
647        Self::polygon(&[min, Point2::new(max.x, min.y), max, Point2::new(min.x, max.y)])
648    }
649
650    /// A full circle as four quadrant arcs, counter-clockwise when `ccw`.
651    pub fn circle(center: Point2, radius: f64, ccw: bool) -> Self {
652        let p = |k: usize| center.add(dir(k as f64 * FRAC_PI_2).scale(radius));
653        let mut segs: Vec<Segment> = (0..4).map(|k| Segment::arc(p(k), p(k + 1), center, true)).collect();
654        if !ccw {
655            segs.reverse();
656            for s in &mut segs {
657                *s = s.reversed();
658            }
659        }
660        Self::new(segs)
661    }
662
663    /// A stadium (slot) around the centre line `a`-`b` with the given
664    /// radius, counter-clockwise.
665    pub fn stadium(a: Point2, b: Point2, radius: f64) -> Self {
666        let u = b.sub(a).normalized().unwrap_or(Point2::new(1.0, 0.0));
667        let n = u.perp().scale(radius);
668        let mut segs = vec![Segment::line(a.sub(n), b.sub(n))];
669        segs.extend(Segment::arc(b.sub(n), b.add(n), b, true).canonical(&Tolerance::default()));
670        segs.push(Segment::line(b.add(n), a.add(n)));
671        segs.extend(Segment::arc(a.add(n), a.sub(n), a, true).canonical(&Tolerance::default()));
672        Self::new(segs)
673    }
674
675    pub fn reversed(&self) -> Self {
676        Self::new(self.segments.iter().rev().map(Segment::reversed).collect())
677    }
678
679    /// Shoelace area of the chords plus the arc bulges; positive counter-clockwise.
680    pub fn signed_area(&self) -> f64 {
681        let mut a = 0.0;
682        for s in &self.segments {
683            a += 0.5 * s.start.cross(s.end) + s.bulge_area();
684        }
685        a
686    }
687
688    pub fn is_ccw(&self) -> bool {
689        self.signed_area() > 0.0
690    }
691
692    pub fn length(&self) -> f64 {
693        self.segments.iter().map(Segment::length).sum()
694    }
695
696    pub fn bounds(&self) -> (Point2, Point2) {
697        bounds_of(self.segments.iter())
698    }
699
700    /// Chord points around the loop (first point not repeated at the end).
701    pub fn discretize(&self, tol: f64) -> Vec<Point2> {
702        let mut out = Vec::new();
703        for s in &self.segments {
704            let pts = s.discretize(tol);
705            out.extend_from_slice(&pts[..pts.len() - 1]);
706        }
707        out
708    }
709
710    /// Winding number of `p` about this loop alone.
711    pub fn winding(&self, p: Point2, tol: &Tolerance) -> i32 {
712        let canon: Vec<Segment> = self.segments.iter().flat_map(|s| s.canonical(tol)).collect();
713        crossings(canon.iter(), p, true)
714    }
715
716    /// Closed (consecutive endpoints coincide within `point_eps`), every
717    /// segment valid, at least two segments. Snaps consecutive endpoints to
718    /// the same coordinates.
719    pub fn validate(&mut self, tol: &Tolerance) -> Result<(), PlanarError> {
720        if self.segments.len() < 2 {
721            return Err(PlanarError::Degenerate("a loop needs at least two segments".into()));
722        }
723        for s in &self.segments {
724            s.validate(tol)?;
725        }
726        let n = self.segments.len();
727        for i in 0..n {
728            let a = self.segments[i].end;
729            let b = self.segments[(i + 1) % n].start;
730            if a.distance(b) > tol.point_eps {
731                return Err(PlanarError::Degenerate(format!(
732                    "loop is open between ({}, {}) and ({}, {})",
733                    a.x, a.y, b.x, b.y
734                )));
735            }
736            self.segments[(i + 1) % n].start = a;
737        }
738        Ok(())
739    }
740
741    /// The loop as canonical pieces (arcs split per quadrant).
742    pub fn canonical(&self, tol: &Tolerance) -> Loop {
743        Loop::new(self.segments.iter().flat_map(|s| s.canonical(tol)).collect())
744    }
745}
746
747/// An open or closed directed curve (edge chains, trace paths).
748#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
749#[serde(rename_all = "camelCase")]
750pub struct Chain {
751    pub segments: Vec<Segment>,
752    pub closed: bool,
753}
754
755impl Chain {
756    pub fn open(segments: Vec<Segment>) -> Self {
757        Self { segments, closed: false }
758    }
759
760    pub fn from_loop(l: &Loop) -> Self {
761        Self { segments: l.segments.clone(), closed: true }
762    }
763
764    pub fn length(&self) -> f64 {
765        self.segments.iter().map(Segment::length).sum()
766    }
767
768    pub fn start(&self) -> Option<Point2> {
769        self.segments.first().map(|s| s.start)
770    }
771
772    pub fn end(&self) -> Option<Point2> {
773        self.segments.last().map(|s| s.end)
774    }
775
776    pub fn reversed(&self) -> Self {
777        Self { segments: self.segments.iter().rev().map(Segment::reversed).collect(), closed: self.closed }
778    }
779
780    /// Chord points along the chain; a closed chain repeats its first point last.
781    pub fn discretize(&self, tol: f64) -> Vec<Point2> {
782        let mut out = Vec::new();
783        for (i, s) in self.segments.iter().enumerate() {
784            let pts = s.discretize(tol);
785            if i == 0 {
786                out.push(pts[0]);
787            }
788            out.extend_from_slice(&pts[1..]);
789        }
790        out
791    }
792
793    pub fn validate(&mut self, tol: &Tolerance) -> Result<(), PlanarError> {
794        if self.segments.is_empty() {
795            return Err(PlanarError::Degenerate("a chain needs at least one segment".into()));
796        }
797        for s in &self.segments {
798            s.validate(tol)?;
799        }
800        let n = self.segments.len();
801        let last = if self.closed { n } else { n - 1 };
802        for i in 0..last {
803            let a = self.segments[i].end;
804            let b = self.segments[(i + 1) % n].start;
805            if a.distance(b) > tol.point_eps {
806                return Err(PlanarError::Degenerate(format!("chain is broken between ({}, {}) and ({}, {})", a.x, a.y, b.x, b.y)));
807            }
808            self.segments[(i + 1) % n].start = a;
809        }
810        Ok(())
811    }
812}
813
814/// One outer loop with its holes, from [`Region::shells`].
815#[derive(Clone, Debug, Default, PartialEq)]
816pub struct Shell {
817    pub outer: Loop,
818    pub holes: Vec<Loop>,
819}
820
821impl Shell {
822    pub fn region(&self) -> Region {
823        let mut loops = vec![self.outer.clone()];
824        loops.extend(self.holes.iter().cloned());
825        Region { loops }
826    }
827}
828
829/// A planar region: oriented loops under the positive-winding rule.
830#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
831#[serde(rename_all = "camelCase")]
832pub struct Region {
833    pub loops: Vec<Loop>,
834}
835
836impl Region {
837    /// A region from already oriented loops (outer CCW, holes CW), validated.
838    pub fn new(loops: Vec<Loop>, tol: &Tolerance) -> Result<Self, PlanarError> {
839        let mut loops = loops;
840        for l in &mut loops {
841            l.validate(tol)?;
842        }
843        Ok(Self { loops })
844    }
845
846    /// A region from loops of unknown orientation, assuming they do not cross:
847    /// each loop is oriented by its nesting depth (even depth outer, odd hole).
848    pub fn from_unoriented(loops: Vec<Loop>, tol: &Tolerance) -> Result<Self, PlanarError> {
849        let mut loops = loops;
850        for l in &mut loops {
851            l.validate(tol)?;
852        }
853        let canon: Vec<Loop> = loops.iter().map(|l| l.canonical(tol)).collect();
854        let mut out = Vec::with_capacity(loops.len());
855        for (i, l) in loops.iter().enumerate() {
856            let probe = l.segments[0].midpoint();
857            let depth = canon
858                .iter()
859                .enumerate()
860                .filter(|(j, _)| *j != i)
861                .filter(|(_, other)| crossings(other.segments.iter(), probe, true) != 0)
862                .count();
863            let want_ccw = depth % 2 == 0;
864            out.push(if l.is_ccw() == want_ccw { l.clone() } else { l.reversed() });
865        }
866        Ok(Self { loops: out })
867    }
868
869    pub fn empty() -> Self {
870        Self { loops: Vec::new() }
871    }
872
873    pub fn is_empty(&self) -> bool {
874        self.loops.is_empty()
875    }
876
877    pub fn area(&self) -> f64 {
878        self.loops.iter().map(Loop::signed_area).sum()
879    }
880
881    pub fn bounds(&self) -> (Point2, Point2) {
882        bounds_of(self.loops.iter().flat_map(|l| l.segments.iter()))
883    }
884
885    /// Every segment of every loop in canonical form.
886    pub fn canonical_segments(&self, tol: &Tolerance) -> Vec<Segment> {
887        self.loops.iter().flat_map(|l| l.segments.iter().flat_map(|s| s.canonical(tol))).collect()
888    }
889
890    /// Winding number of `p`; positive inside.
891    pub fn winding(&self, p: Point2, tol: &Tolerance) -> i32 {
892        let canon = self.canonical_segments(tol);
893        crossings(canon.iter(), p, true)
894    }
895
896    pub fn contains(&self, p: Point2, tol: &Tolerance) -> bool {
897        self.winding(p, tol) > 0
898    }
899
900    /// Nearest boundary point to `p` and the distance to it.
901    pub fn nearest_boundary(&self, p: Point2) -> Option<(Point2, f64)> {
902        let mut best: Option<(Point2, f64)> = None;
903        for s in self.loops.iter().flat_map(|l| l.segments.iter()) {
904            let (q, _) = s.nearest(p);
905            let d = q.distance(p);
906            if best.map_or(true, |(_, bd)| d < bd) {
907                best = Some((q, d));
908            }
909        }
910        best
911    }
912
913    /// Signed distance: positive inside, negative outside, zero on the boundary.
914    pub fn signed_distance(&self, p: Point2, tol: &Tolerance) -> f64 {
915        let d = self.nearest_boundary(p).map_or(f64::INFINITY, |(_, d)| d);
916        if self.contains(p, tol) {
917            d
918        } else {
919            -d
920        }
921    }
922
923    /// Group loops into outer loops with their holes. Loops are assumed not to
924    /// cross (true for every operation output). An island inside a hole is its
925    /// own shell.
926    pub fn shells(&self, tol: &Tolerance) -> Vec<Shell> {
927        let outers: Vec<usize> = (0..self.loops.len()).filter(|&i| self.loops[i].is_ccw()).collect();
928        let canon: Vec<Loop> = self.loops.iter().map(|l| l.canonical(tol)).collect();
929        let mut shells: Vec<Shell> = outers.iter().map(|&i| Shell { outer: self.loops[i].clone(), holes: Vec::new() }).collect();
930        for l in &self.loops {
931            if l.is_ccw() {
932                continue;
933            }
934            let probe = l.segments[0].midpoint();
935            // The smallest outer containing the hole is its parent.
936            let mut parent: Option<(usize, f64)> = None;
937            for (k, &o) in outers.iter().enumerate() {
938                if crossings(canon[o].segments.iter(), probe, true) != 0 {
939                    let a = self.loops[o].signed_area();
940                    if parent.map_or(true, |(_, pa)| a < pa) {
941                        parent = Some((k, a));
942                    }
943                }
944            }
945            if let Some((k, _)) = parent {
946                shells[k].holes.push(l.clone());
947            }
948        }
949        shells
950    }
951
952    /// Offset by `distance`: positive grows the region, negative shrinks it.
953    pub fn offset(&self, distance: f64, tol: &Tolerance) -> Result<Region, PlanarError> {
954        offset_region(self, distance, tol)
955    }
956
957    pub fn union(&self, other: &Region, tol: &Tolerance) -> Result<Region, PlanarError> {
958        boolean(self, other, BoolOp::Union, tol)
959    }
960
961    pub fn difference(&self, other: &Region, tol: &Tolerance) -> Result<Region, PlanarError> {
962        boolean(self, other, BoolOp::Difference, tol)
963    }
964
965    pub fn intersection(&self, other: &Region, tol: &Tolerance) -> Result<Region, PlanarError> {
966        boolean(self, other, BoolOp::Intersection, tol)
967    }
968
969    /// Chord loops at `tol`, for consumers that take polylines
970    /// (e.g. [`crate::model::Containment::boundary`]).
971    pub fn to_polylines(&self, tol: f64) -> Vec<Vec<Point2>> {
972        self.loops.iter().map(|l| l.discretize(tol)).collect()
973    }
974}
975
976/// A loop with collinear consecutive lines merged (co-circular consecutive
977/// arcs too), so raw offset curves carry no degenerate vertex circles.
978pub(crate) fn simplified(l: &Loop, tol: &Tolerance) -> Loop {
979    let same_support = |a: &Segment, b: &Segment| -> bool {
980        match (a.arc, b.arc) {
981            (None, None) => {
982                let (Some(u), Some(v)) = (a.end.sub(a.start).normalized(), b.end.sub(b.start).normalized()) else { return false };
983                u.cross(v).abs() <= tol.angle_eps && u.dot(v) > 0.0
984            }
985            (Some(x), Some(y)) => x.ccw == y.ccw && x.center.distance(y.center) <= tol.point_eps,
986            _ => false,
987        }
988    };
989    let mut out: Vec<Segment> = Vec::with_capacity(l.segments.len());
990    for s in &l.segments {
991        if let Some(last) = out.last_mut() {
992            if same_support(last, s) && s.end.distance(last.start) > tol.point_eps {
993                let merged = Segment { start: last.start, end: s.end, arc: last.arc };
994                if merged.arc.is_none() || (merged.length() >= last.length() + s.length() - tol.distance_eps && merged.length() <= std::f64::consts::PI * merged.radius() + tol.distance_eps) {
995                    *last = merged;
996                    continue;
997                }
998            }
999        }
1000        out.push(*s);
1001    }
1002    if out.len() > 2 {
1003        let n = out.len();
1004        if same_support(&out[n - 1], &out[0]) && out[n - 1].start.distance(out[0].end) > tol.point_eps {
1005            let last = out[n - 1];
1006            let merged = Segment { start: last.start, end: out[0].end, arc: last.arc };
1007            if merged.arc.is_none() || (merged.length() >= last.length() + out[0].length() - tol.distance_eps && merged.length() <= std::f64::consts::PI * merged.radius() + tol.distance_eps) {
1008                out.pop();
1009                out[0] = merged;
1010            }
1011        }
1012    }
1013    Loop::new(out)
1014}
1015
1016pub(crate) fn bounds_of<'a>(segments: impl Iterator<Item = &'a Segment>) -> (Point2, Point2) {
1017    let mut lo = Point2::new(f64::INFINITY, f64::INFINITY);
1018    let mut hi = Point2::new(f64::NEG_INFINITY, f64::NEG_INFINITY);
1019    for s in segments {
1020        let (a, b) = s.bounds();
1021        lo = Point2::new(lo.x.min(a.x), lo.y.min(a.y));
1022        hi = Point2::new(hi.x.max(b.x), hi.y.max(b.y));
1023    }
1024    (lo, hi)
1025}
1026
1027/// Signed crossings of the horizontal ray from `p` towards +X with CANONICAL
1028/// segments (lines, or arcs within one quadrant about their centre, so each
1029/// crosses a horizontal line at most once). `upper` selects the half-open
1030/// convention: `true` counts a segment when `y_low <= p.y < y_high` (the ray
1031/// taken infinitesimally above `p`), `false` when `y_low < p.y <= y_high`.
1032/// Upward segments count +1, downward -1. The result is the winding number
1033/// of `p` about the loops the segments form, for any `p` off the boundary.
1034pub(crate) fn crossings<'a>(segments: impl Iterator<Item = &'a Segment>, p: Point2, upper: bool) -> i32 {
1035    let mut w = 0;
1036    for s in segments {
1037        let (ylo, yhi) = if s.start.y <= s.end.y { (s.start.y, s.end.y) } else { (s.end.y, s.start.y) };
1038        let inside = if upper { ylo <= p.y && p.y < yhi } else { ylo < p.y && p.y <= yhi };
1039        if !inside {
1040            continue;
1041        }
1042        let x = match s.arc {
1043            None => s.start.x + (p.y - s.start.y) * (s.end.x - s.start.x) / (s.end.y - s.start.y),
1044            Some(a) => {
1045                let r = s.radius();
1046                let dy = p.y - a.center.y;
1047                let h = (r * r - dy * dy).max(0.0).sqrt();
1048                if s.midpoint().x >= a.center.x {
1049                    a.center.x + h
1050                } else {
1051                    a.center.x - h
1052                }
1053            }
1054        };
1055        if x > p.x {
1056            w += if s.end.y > s.start.y { 1 } else { -1 };
1057        }
1058    }
1059    w
1060}
1061