Skip to main content

ogeom_algo/
outline.rs

1//! An outline of lines, arcs and circles fitted around a silhouette,
2//! clear of it by a play and within a tolerance band.
3//!
4//! The band is every point whose signed distance from the silhouette,
5//! positive outside it and negative inside, lies between `play` and
6//! `play + 2 tolerance`. The fit has three stages, and only the last one
7//! decides what is returned.
8//!
9//! 1. **The middle of the band.** The silhouette is first traced afresh as
10//!    a region, so its loops bound it exactly, and with `fill_below` set it
11//!    is closed (see [Filling](#filling)) and stands for the silhouette
12//!    from then on. Its offset by `play +
13//!    tolerance` is then the union of the silhouette with a rectangle along
14//!    each edge, on the outside, and a sector at each convex corner, or for
15//!    a negative offset the silhouette less such rectangles inside and
16//!    sectors at the reflex corners. The sectors' arcs are chords sagging a
17//!    quarter of the tolerance, the offset is taken an eighth further out
18//!    to centre the sag, and the region engine of
19//!    [`ogeom_mesh::planar`] traces the result: loops whose every point is
20//!    within an eighth of the tolerance, and the engine's snapping, of the
21//!    middle of the band. Holes the band closes over and parts it pinches
22//!    off are gone from them, and as the boundary of a region they never
23//!    run out and back.
24//! 2. **The fit.** Each loop is split into runs a line or a circle follows
25//!    within three quarters of the tolerance, less the snapping: the whole
26//!    loop as one circle where that holds. A line or an arc spanning a run
27//!    lies that close to the loop, so within the band. Consecutive pieces
28//!    are joined where their curves meet: a corner turning less than
29//!    `round_below` takes the largest tangent arc found to fit the band, a
30//!    sharper one stays a corner where that fits and takes an arc where it
31//!    does not, and a joint where neither fits is bridged by the short line
32//!    between the pieces' ends, which lies near the loop too. No piece is
33//!    moved past its middle by a joint, so the joints at its two ends
34//!    never meet.
35//! 3. **The check.** Every piece is sampled at a quarter of the tolerance
36//!    and every sample's signed distance from the silhouette must lie an
37//!    eighth of the tolerance inside the band. Distance is 1-Lipschitz, so
38//!    the whole piece then lies in the band. A loop with any piece failing
39//!    is returned as the polygon of its middle-of-the-band loop, which is
40//!    checked the same way.
41//!
42//! # Filling
43//!
44//! With `fill_below = w > 0` the silhouette `S` is replaced by its
45//! morphological closing by a disc of radius `r = w / 2`: dilated by `r`,
46//! then eroded by `r`. A point outside `S` stays out of the exact closing
47//! `C` exactly when some closed disc of radius `r` holds it and misses `S`.
48//! So a hole, or a notch, whose largest inscribed circle is no wider than
49//! `w` across is filled, and one wider keeps the points such discs reach:
50//! a round hole wider than `w` stays whole, a slot of width `s < w` closes
51//! but for a dent where the disc dips into its mouth, `r - sqrt(r^2 -
52//! s^2/4)` deep. A convex corner with nothing else of `S` within `w` of
53//! it is kept sharp, every point outside it near it lying in such a disc,
54//! while a reflex corner takes a fillet of radius `r`, the disc's own.
55//!
56//! Both steps are offsets as in stage 1, their arcs chords sagging `sigma
57//! = min(tolerance, r) / 32`: the dilation by `r + sigma`, its chords
58//! covering the disc of radius `r`, and the erosion by `r`, its chords
59//! removing at least the disc of radius `r - sigma`. The result `F`
60//! therefore holds `C`, and lies within the closing by `r - sigma` of `S`
61//! dilated by `2 sigma`: everything `C` fills is filled, a hole or notch
62//! more than `w + 2 sigma` across is kept, and a straight side moves out
63//! by `sigma` at most; all to within the engine's snapping. `F` holds
64//! `S`, so the outline clears `S` by the play wherever it clears `F`, and
65//! the band holds about `F`.
66
67use ogeom_core::{Exact, OgeomResult, Predicates, Sign, ogeom_bail};
68use ogeom_math::{Point2, Vector2};
69use ogeom_mesh::{Edge2, Loop2, region};
70
71use crate::fit::segments::{Shape, circle_through, deviation, runs};
72
73/// The corner arcs of the band's middle sag from their circles by at most
74/// this fraction of the tolerance.
75const SAG: f64 = 32.0;
76
77/// How an outline is fitted around a silhouette.
78#[derive(Debug, Clone, Copy, PartialEq)]
79pub struct Outline {
80    /// The least clearance from the silhouette. Negative for an outline
81    /// inside it, a press fit.
82    pub play: f64,
83    /// Half the width of the band: every point of the outline lies between
84    /// `play` and `play + 2 tolerance` outside the silhouette.
85    pub tolerance: f64,
86    /// Corners turning by less than this, in radians, are rounded by the
87    /// largest tangent arc found to fit the band.
88    pub round_below: f64,
89    /// Holes and notches no wider than this across, by their largest
90    /// inscribed circle, are filled before the fit, and the band is taken
91    /// about the silhouette so filled. Zero, the true silhouette, fills
92    /// nothing. See [Filling](self#filling).
93    pub fill_below: f64,
94}
95
96/// One piece of a fitted outline.
97#[derive(Debug, Clone, Copy, PartialEq)]
98pub enum Piece2 {
99    /// A straight piece.
100    Line {
101        /// Where it starts.
102        from: Point2,
103        /// Where it ends.
104        to: Point2,
105    },
106    /// A circular arc, counter-clockwise for a positive sweep.
107    Arc {
108        /// Its centre.
109        centre: Point2,
110        /// Its radius.
111        radius: f64,
112        /// The angle it starts at, from the `x` axis.
113        start: f64,
114        /// The signed angle it turns through, less than a full turn.
115        sweep: f64,
116    },
117    /// A full circle, a whole outline on its own.
118    Circle {
119        /// Its centre.
120        centre: Point2,
121        /// Its radius.
122        radius: f64,
123        /// Whether it runs counter-clockwise, as an outer boundary does.
124        counter_clockwise: bool,
125    },
126}
127
128impl Piece2 {
129    /// The point a fraction `t` of the way along, from `0` at the start to
130    /// `1` at the end.
131    #[must_use]
132    pub fn point_at(&self, t: f64) -> Point2 {
133        match *self {
134            Self::Line { from, to } => from.lerp(to, t),
135            Self::Arc {
136                centre,
137                radius,
138                start,
139                sweep,
140            } => on_circle(centre, radius, sweep.mul_add(t, start)),
141            Self::Circle {
142                centre,
143                radius,
144                counter_clockwise,
145            } => {
146                let turn = if counter_clockwise { 1.0 } else { -1.0 };
147                on_circle(centre, radius, turn * core::f64::consts::TAU * t)
148            }
149        }
150    }
151
152    /// Where it starts.
153    #[must_use]
154    pub fn start(&self) -> Point2 {
155        self.point_at(0.0)
156    }
157
158    /// Where it ends: where it starts, for a circle.
159    #[must_use]
160    pub fn end(&self) -> Point2 {
161        self.point_at(1.0)
162    }
163
164    /// Its length.
165    #[must_use]
166    pub fn length(&self) -> f64 {
167        match *self {
168            Self::Line { from, to } => from.distance(to),
169            Self::Arc { radius, sweep, .. } => radius * sweep.abs(),
170            Self::Circle { radius, .. } => radius * core::f64::consts::TAU,
171        }
172    }
173}
174
175/// A closed outline: pieces end to end, each starting where the one
176/// before ends and the last ending where the first starts. An outer
177/// boundary runs counter-clockwise and a hole clockwise.
178#[derive(Debug, Clone, PartialEq, Default)]
179pub struct Wire2d {
180    /// The pieces in order.
181    pub pieces: Vec<Piece2>,
182}
183
184/// A whole number held in a double, as a count.
185#[allow(
186    clippy::cast_possible_truncation,
187    clippy::cast_sign_loss,
188    reason = "callers pass whole numbers from zero to below 2^32"
189)]
190const fn whole(v: f64) -> usize {
191    v as usize
192}
193
194/// `k / n` for counts, exactly: counts are below `2^32`.
195#[allow(clippy::cast_precision_loss, reason = "counts below 2^32 are exact")]
196fn fraction(k: usize, n: usize) -> f64 {
197    k as f64 / n as f64
198}
199
200fn on_circle(centre: Point2, radius: f64, angle: f64) -> Point2 {
201    Point2::new(
202        radius.mul_add(angle.cos(), centre.x),
203        radius.mul_add(angle.sin(), centre.y),
204    )
205}
206
207/// Fit an outline of lines, tangent arcs and circles around a silhouette,
208/// every point of it between `play` and `play + 2 tolerance` outside the
209/// region the loops bound. See the module documentation.
210///
211/// The loops bound the points they wind around at least once, as
212/// [`shadow`](ogeom_mesh::shadow()) gives them: outer boundaries
213/// counter-clockwise, holes clockwise. The outline has one wire for each
214/// loop of the band's middle: a hole narrower than the band is closed
215/// over, and with a negative play a part thinner than the band is gone.
216/// With `fill_below` set, the band is about the silhouette with its
217/// narrow holes and notches filled.
218///
219/// # Errors
220///
221/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
222/// the tolerance is not positive, the play is not finite, `round_below`
223/// is not an angle from zero to a half turn, or `fill_below` is not a
224/// finite width of zero or more, and
225/// [`OgeomError::Numeric`](ogeom_core::OgeomError::Numeric) if a point is not
226/// finite.
227pub fn fit_outline(loops: &[Loop2], outline: &Outline) -> OgeomResult<Vec<Wire2d>> {
228    let Outline {
229        play,
230        tolerance,
231        round_below,
232        fill_below,
233    } = *outline;
234    if !(tolerance > 0.0 && tolerance.is_finite()) {
235        ogeom_bail!(
236            Construction,
237            "an outline tolerance of {tolerance} is no band"
238        );
239    }
240    if !play.is_finite() {
241        ogeom_bail!(Construction, "an outline play of {play} is not a distance");
242    }
243    if !(0.0..=core::f64::consts::PI).contains(&round_below) {
244        ogeom_bail!(
245            Construction,
246            "rounding corners turning less than {round_below} rad is not a turn"
247        );
248    }
249    if !(fill_below >= 0.0 && fill_below.is_finite()) {
250        ogeom_bail!(
251            Construction,
252            "filling below {fill_below} across is not a width"
253        );
254    }
255    let mut silhouette = region(&chain(loops), |w| w[0] > 0)?;
256    if silhouette.is_empty() {
257        return Ok(Vec::new());
258    }
259    if fill_below > 0.0 {
260        silhouette = closed(&silhouette, fill_below / 2.0, tolerance)?;
261    }
262    let snap = snapping(&silhouette);
263    let middle = play + tolerance;
264    let targets = if middle == 0.0 {
265        silhouette.clone()
266    } else {
267        offset(&silhouette, middle, tolerance / SAG)?
268    };
269    let spacing = tolerance / 4.0;
270    // The margin covers half the spacing, and the silhouette's own
271    // snapping off the loops it was traced from.
272    let margin = spacing / 2.0 + snap;
273    let band = Band::new(
274        &silhouette,
275        play + margin,
276        2.0f64.mul_add(tolerance, play) - margin,
277    );
278    let fit = Fit {
279        band: &band,
280        within: tolerance
281            - tolerance / (2.0 * SAG)
282            - spacing / 2.0
283            - 2.0 * (snap + snapping(&targets)),
284        round_below,
285        spacing,
286    };
287    let mut wires = Vec::with_capacity(targets.len());
288    for target in &targets {
289        if target.points.len() < 3 {
290            continue;
291        }
292        wires.push(fit.wire(target)?);
293    }
294    Ok(wires)
295}
296
297/// The loops as a chain counted in the first count.
298fn chain(loops: &[Loop2]) -> Vec<Edge2> {
299    let mut edges = Vec::new();
300    for l in loops {
301        let n = l.points.len();
302        for i in 0..n {
303            edges.push(Edge2 {
304                from: l.points[i],
305                to: l.points[(i + 1) % n],
306                weight: [1, 0],
307            });
308        }
309    }
310    edges
311}
312
313/// A bound on how far the region engine moves a boundary traced from
314/// these loops: a cell's diagonal, the cell at most `2^-37` of the extent.
315fn snapping(loops: &[Loop2]) -> f64 {
316    let mut lo = [f64::INFINITY; 2];
317    let mut hi = [f64::NEG_INFINITY; 2];
318    for p in loops.iter().flat_map(|l| &l.points) {
319        lo = [lo[0].min(p.x), lo[1].min(p.y)];
320        hi = [hi[0].max(p.x), hi[1].max(p.y)];
321    }
322    let extent = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(0.0);
323    extent * 2f64.powi(-36)
324}
325
326/// The silhouette closed by a disc of radius `r`, holding the exact
327/// closing: see [Filling](self#filling).
328fn closed(silhouette: &[Loop2], r: f64, tolerance: f64) -> OgeomResult<Vec<Loop2>> {
329    let sag = tolerance.min(r) / SAG;
330    let dilated = offset(silhouette, r + sag / 2.0, sag)?;
331    offset(&dilated, -(r - sag / 2.0), sag)
332}
333
334/// The silhouette offset by `distance`, outward for a positive one, its
335/// corner arcs as chords sagging at most `sag`, the offset taken half the
336/// sag further so that the boundary lies within half the sag of the exact
337/// offset either way.
338fn offset(silhouette: &[Loop2], distance: f64, sag: f64) -> OgeomResult<Vec<Loop2>> {
339    let outward = distance > 0.0;
340    let r = distance.abs() + sag / 2.0;
341    // The widest chord sagging `sag` from a circle of radius `r`, and no
342    // more than a quarter turn so that no sector is flat.
343    let widest = if sag < r {
344        (2.0 * (1.0 - sag / r).acos()).min(core::f64::consts::FRAC_PI_2)
345    } else {
346        core::f64::consts::FRAC_PI_2
347    };
348    let count = if outward { 0 } else { 1 };
349    let mut edges = chain(silhouette);
350    let mut piece = |ring: &[Point2]| {
351        let n = ring.len();
352        for i in 0..n {
353            let mut weight = [0, 0];
354            weight[count] = 1;
355            edges.push(Edge2 {
356                from: ring[i],
357                to: ring[(i + 1) % n],
358                weight,
359            });
360        }
361    };
362    for l in silhouette {
363        let n = l.points.len();
364        for i in 0..n {
365            let (a, b, c) = (l.points[i], l.points[(i + 1) % n], l.points[(i + 2) % n]);
366            let (u, v) = (b - a, c - b);
367            let (lu, lv) = (u.magnitude(), v.magnitude());
368            if lu == 0.0 || lv == 0.0 {
369                continue;
370            }
371            let (u, v) = (u / lu, v / lv);
372            // The right of travel is outside, the region being on the left.
373            let side = |d: Vector2| {
374                if outward {
375                    -d.perpendicular()
376                } else {
377                    d.perpendicular()
378                }
379            };
380            let nu = side(u);
381            if outward {
382                piece(&[a, a + nu * r, b + nu * r, b]);
383            } else {
384                piece(&[a, b, b + nu * r, a + nu * r]);
385            }
386            let turn = u.cross(v).atan2(u.dot(v));
387            // A sector where the corner opens away from the region's side
388            // being offset: convex corners outward, reflex ones inward.
389            if (outward && turn > 0.0) || (!outward && turn < 0.0) {
390                let steps = whole((turn.abs() / widest).ceil()).max(1);
391                let mut ring = vec![b];
392                for k in 0..=steps {
393                    let t = turn * fraction(k, steps);
394                    let (s, co) = t.sin_cos();
395                    let d = Vector2::new(nu.x * co - nu.y * s, nu.x * s + nu.y * co);
396                    ring.push(b + d * r);
397                }
398                if !outward {
399                    ring[1..].reverse();
400                }
401                piece(&ring);
402            }
403        }
404    }
405    if outward {
406        region(&edges, |w| w[0] > 0)
407    } else {
408        region(&edges, |w| w[0] > 0 && w[1] == 0)
409    }
410}
411
412/// The band about a silhouette: which points lie between two signed
413/// distances from it.
414struct Band {
415    segments: Vec<[Point2; 2]>,
416    lo: f64,
417    hi: f64,
418    reach: f64,
419    origin: Point2,
420    cell: f64,
421    columns: usize,
422    rows: usize,
423    start: Vec<usize>,
424    held: Vec<usize>,
425}
426
427impl Band {
428    fn new(silhouette: &[Loop2], lo: f64, hi: f64) -> Self {
429        let mut segments = Vec::new();
430        for l in silhouette {
431            let n = l.points.len();
432            for i in 0..n {
433                segments.push([l.points[i], l.points[(i + 1) % n]]);
434            }
435        }
436        let mut min = Point2::new(f64::INFINITY, f64::INFINITY);
437        let mut max = Point2::new(f64::NEG_INFINITY, f64::NEG_INFINITY);
438        for p in segments.iter().flatten() {
439            min = Point2::new(min.x.min(p.x), min.y.min(p.y));
440            max = Point2::new(max.x.max(p.x), max.y.max(p.y));
441        }
442        let reach = lo.abs().max(hi.abs());
443        let extent = (max.x - min.x).max(max.y - min.y);
444        let per_side = fraction(segments.len(), 1).sqrt().ceil().max(1.0);
445        let cell = (extent / per_side).max(reach / 2.0).max(f64::MIN_POSITIVE);
446        let columns = whole((max.x - min.x) / cell) + 1;
447        let rows = whole((max.y - min.y) / cell) + 1;
448        let mut band = Self {
449            segments,
450            lo,
451            hi,
452            reach,
453            origin: min,
454            cell,
455            columns,
456            rows,
457            start: Vec::new(),
458            held: Vec::new(),
459        };
460        let mut pairs: Vec<(usize, usize)> = Vec::new();
461        for (k, s) in band.segments.iter().enumerate() {
462            let (c0, r0) = band.cell_of(Point2::new(s[0].x.min(s[1].x), s[0].y.min(s[1].y)));
463            let (c1, r1) = band.cell_of(Point2::new(s[0].x.max(s[1].x), s[0].y.max(s[1].y)));
464            for r in r0..=r1 {
465                for c in c0..=c1 {
466                    pairs.push((r * columns + c, k));
467                }
468            }
469        }
470        pairs.sort_unstable();
471        let mut start = vec![0usize; rows * columns + 1];
472        for &(b, _) in &pairs {
473            start[b + 1] += 1;
474        }
475        for k in 0..rows * columns {
476            start[k + 1] += start[k];
477        }
478        band.start = start;
479        band.held = pairs.into_iter().map(|(_, k)| k).collect();
480        band
481    }
482
483    fn cell_of(&self, p: Point2) -> (usize, usize) {
484        let at = |v: f64, o: f64, n: usize| {
485            let k = ((v - o) / self.cell).floor();
486            if k < 0.0 {
487                0
488            } else {
489                whole(k.min(fraction(n - 1, 1)))
490            }
491        };
492        (
493            at(p.x, self.origin.x, self.columns),
494            at(p.y, self.origin.y, self.rows),
495        )
496    }
497
498    fn cell(&self, c: usize, r: usize) -> &[usize] {
499        let b = r * self.columns + c;
500        &self.held[self.start[b]..self.start[b + 1]]
501    }
502
503    /// Whether the point's signed distance lies within the band.
504    fn holds(&self, p: Point2) -> bool {
505        let (c0, r0) = self.cell_of(Point2::new(p.x - self.reach, p.y - self.reach));
506        let (c1, r1) = self.cell_of(Point2::new(p.x + self.reach, p.y + self.reach));
507        let mut nearest = f64::INFINITY;
508        for r in r0..=r1 {
509            for c in c0..=c1 {
510                for &k in self.cell(c, r) {
511                    let [a, b] = self.segments[k];
512                    nearest = nearest.min(distance_to_segment(p, a, b));
513                }
514            }
515        }
516        if nearest > self.reach {
517            return false;
518        }
519        let signed = if self.inside(p) { -nearest } else { nearest };
520        self.lo <= signed && signed <= self.hi
521    }
522
523    /// Whether the point is inside the silhouette, by the exact winding of
524    /// the loops about it.
525    fn inside(&self, p: Point2) -> bool {
526        let (c0, r) = self.cell_of(p);
527        let mut seen: Vec<usize> = (c0..self.columns)
528            .flat_map(|c| self.cell(c, r).iter().copied())
529            .collect();
530        seen.sort_unstable();
531        seen.dedup();
532        let at = [p.x, p.y];
533        let mut winding = 0i32;
534        for k in seen {
535            let [a, b] = self.segments[k];
536            let side = Exact::orient2d([a.x, a.y], [b.x, b.y], at);
537            if a.y <= p.y && p.y < b.y && side == Sign::Positive {
538                winding += 1;
539            } else if b.y <= p.y && p.y < a.y && side == Sign::Negative {
540                winding -= 1;
541            }
542        }
543        winding > 0
544    }
545
546    /// Whether every sample of a stretch holds, the samples a sixteenth
547    /// closer than `spacing` so that the difference covers the rounding of
548    /// the distances.
549    fn holds_along(&self, point: impl Fn(f64) -> Point2, length: f64, spacing: f64) -> bool {
550        let n = (length / (spacing * 15.0 / 16.0)).ceil().max(1.0);
551        if n > 1e8 {
552            return false;
553        }
554        let n = whole(n);
555        (0..=n).all(|k| self.holds(point(fraction(k, n))))
556    }
557
558    fn holds_piece(&self, piece: &Piece2, spacing: f64) -> bool {
559        self.holds_along(|t| piece.point_at(t), piece.length(), spacing)
560    }
561}
562
563fn distance_to_segment(p: Point2, a: Point2, b: Point2) -> f64 {
564    let d = b - a;
565    let len2 = d.dot(d);
566    let t = if len2 > 0.0 {
567        ((p - a).dot(d) / len2).clamp(0.0, 1.0)
568    } else {
569        0.0
570    };
571    (a + d * t).distance(p)
572}
573
574/// A curve a piece runs along, in its direction of travel.
575#[derive(Debug, Clone, Copy)]
576pub(crate) enum Path {
577    /// `at + t along`, `along` a unit vector.
578    Line { at: Point2, along: Vector2 },
579    /// `centre + radius (cos t, sin t)`, `t` rising if counter-clockwise.
580    Circle {
581        centre: Point2,
582        radius: f64,
583        ccw: bool,
584    },
585}
586
587impl Path {
588    fn point(self, t: f64) -> Point2 {
589        match self {
590            Self::Line { at, along } => at + along * t,
591            Self::Circle { centre, radius, .. } => on_circle(centre, radius, t),
592        }
593    }
594
595    fn tangent(self, t: f64) -> Vector2 {
596        match self {
597            Self::Line { along, .. } => along,
598            Self::Circle { ccw, .. } => {
599                let (s, c) = t.sin_cos();
600                if ccw {
601                    Vector2::new(-s, c)
602                } else {
603                    Vector2::new(s, -c)
604                }
605            }
606        }
607    }
608
609    /// The parameter of a point on the path, an angle taken nearest `near`.
610    fn parameter(self, p: Point2, near: f64) -> f64 {
611        match self {
612            Self::Line { at, along } => (p - at).dot(along),
613            Self::Circle { centre, .. } => {
614                let a = (p.y - centre.y).atan2(p.x - centre.x);
615                let tau = core::f64::consts::TAU;
616                a + ((near - a) / tau).round() * tau
617            }
618        }
619    }
620
621    /// The nearest point of the path's curve.
622    pub(crate) fn foot(self, p: Point2) -> Option<Point2> {
623        match self {
624            Self::Line { at, along } => Some(at + along * (p - at).dot(along)),
625            Self::Circle { centre, radius, .. } => {
626                let d = p - centre;
627                let len = d.magnitude();
628                (len > 0.0).then(|| centre + d * (radius / len))
629            }
630        }
631    }
632
633    /// The path moved `by` to the left of travel.
634    fn moved(self, by: f64) -> Option<Self> {
635        match self {
636            Self::Line { at, along } => Some(Self::Line {
637                at: at + along.perpendicular() * by,
638                along,
639            }),
640            Self::Circle {
641                centre,
642                radius,
643                ccw,
644            } => {
645                let radius = if ccw { radius - by } else { radius + by };
646                (radius > 0.0).then_some(Self::Circle {
647                    centre,
648                    radius,
649                    ccw,
650                })
651            }
652        }
653    }
654}
655
656/// Where two paths' curves cross.
657pub(crate) fn meet(a: Path, b: Path) -> Vec<Point2> {
658    match (a, b) {
659        (Path::Line { at: p, along: u }, Path::Line { at: q, along: v }) => {
660            let den = u.cross(v);
661            if den == 0.0 {
662                return Vec::new();
663            }
664            vec![p + u * ((q - p).cross(v) / den)]
665        }
666        (Path::Line { at, along }, Path::Circle { centre, radius, .. })
667        | (Path::Circle { centre, radius, .. }, Path::Line { at, along }) => {
668            let foot = at + along * (centre - at).dot(along);
669            let d2 = radius.mul_add(radius, -foot.square_distance(centre));
670            if d2 < 0.0 {
671                return Vec::new();
672            }
673            let h = d2.sqrt();
674            vec![foot + along * h, foot - along * h]
675        }
676        (
677            Path::Circle {
678                centre: c0,
679                radius: r0,
680                ..
681            },
682            Path::Circle {
683                centre: c1,
684                radius: r1,
685                ..
686            },
687        ) => {
688            let d = c1 - c0;
689            let len = d.magnitude();
690            if len == 0.0 {
691                return Vec::new();
692            }
693            let along = (len.mul_add(len, r0.mul_add(r0, -r1 * r1))) / (2.0 * len);
694            let h2 = r0.mul_add(r0, -along * along);
695            if h2 < 0.0 {
696                return Vec::new();
697            }
698            let u = d / len;
699            let mid = c0 + u * along;
700            let h = h2.sqrt();
701            vec![mid + u.perpendicular() * h, mid - u.perpendicular() * h]
702        }
703    }
704}
705
706fn nearest_to(points: &[Point2], p: Point2) -> Option<Point2> {
707    points
708        .iter()
709        .copied()
710        .min_by(|a, b| a.square_distance(p).total_cmp(&b.square_distance(p)))
711}
712
713/// A run's path and the parameters its fit spans.
714#[derive(Debug, Clone, Copy)]
715struct Span {
716    path: Path,
717    from: f64,
718    to: f64,
719}
720
721impl Span {
722    /// How far along the span's own range a parameter lies: 0 at its
723    /// start, 1 at its end.
724    fn progress(&self, t: f64) -> f64 {
725        (t - self.from) / (self.to - self.from)
726    }
727
728    fn length(&self) -> f64 {
729        match self.path {
730            Path::Line { .. } => (self.to - self.from).abs(),
731            Path::Circle { radius, .. } => radius * (self.to - self.from).abs(),
732        }
733    }
734
735    /// The piece between two parameters.
736    fn piece(&self, from: f64, to: f64) -> Piece2 {
737        match self.path {
738            Path::Line { .. } => Piece2::Line {
739                from: self.path.point(from),
740                to: self.path.point(to),
741            },
742            Path::Circle { centre, radius, .. } => Piece2::Arc {
743                centre,
744                radius,
745                start: from,
746                sweep: to - from,
747            },
748        }
749    }
750}
751
752/// How two consecutive pieces meet.
753#[derive(Debug, Clone, Copy)]
754enum Joint {
755    /// The first ends at `a`, the second starts at `b`, both at `at`.
756    Corner { a: f64, b: f64, at: Point2 },
757    /// The first ends at `a`, the arc follows, the second starts at `b`.
758    Round { a: f64, b: f64, arc: Piece2 },
759    /// Each keeps its own end and a line joins them.
760    Bridge,
761}
762
763struct Fit<'a> {
764    band: &'a Band,
765    within: f64,
766    round_below: f64,
767    spacing: f64,
768}
769
770impl Fit<'_> {
771    /// The outline of one loop of the band's middle.
772    fn wire(&self, target: &Loop2) -> OgeomResult<Wire2d> {
773        if let Some(w) = self.fitted(target)
774            && w.pieces
775                .iter()
776                .all(|p| self.band.holds_piece(p, self.spacing))
777        {
778            return Ok(w);
779        }
780        let n = target.points.len();
781        let polygon = Wire2d {
782            pieces: (0..n)
783                .map(|i| Piece2::Line {
784                    from: target.points[i],
785                    to: target.points[(i + 1) % n],
786                })
787                .collect(),
788        };
789        if polygon
790            .pieces
791            .iter()
792            .all(|p| self.band.holds_piece(p, self.spacing))
793        {
794            return Ok(polygon);
795        }
796        ogeom_bail!(
797            Construction,
798            "the middle of the band leaves the band; the outline is not fitted"
799        )
800    }
801
802    /// The pieces fitted to a loop, before the check.
803    fn fitted(&self, target: &Loop2) -> Option<Wire2d> {
804        let points = dense(target, self.within);
805        if points.len() < 4 || self.within.is_nan() || self.within <= 0.0 {
806            return None;
807        }
808        if let Some(Shape::Circle { centre, radius }) = circle_through(&points[..points.len() - 1])
809            && deviation(&points, Shape::Circle { centre, radius }) <= self.within
810        {
811            let circle = Piece2::Circle {
812                centre,
813                radius,
814                counter_clockwise: target.area() > 0.0,
815            };
816            if self.band.holds_piece(&circle, self.spacing) {
817                return Some(Wire2d {
818                    pieces: vec![circle],
819                });
820            }
821        }
822        let runs = runs(&points, self.within);
823        if runs.len() < 2 {
824            return None;
825        }
826        let mut spans = Vec::with_capacity(runs.len());
827        for run in &runs {
828            let (p, q) = (points[run.first], points[run.last]);
829            let span = match run.shape {
830                Shape::Line { at, along } => Span {
831                    path: Path::Line { at, along },
832                    from: (p - at).dot(along),
833                    to: (q - at).dot(along),
834                },
835                Shape::Circle { centre, radius } => {
836                    let angle = |x: Point2| (x.y - centre.y).atan2(x.x - centre.x);
837                    let mut sweep = 0.0;
838                    for k in run.first..run.last {
839                        let (u, v) = (points[k] - centre, points[k + 1] - centre);
840                        sweep += u.cross(v).atan2(u.dot(v));
841                    }
842                    let from = angle(p);
843                    Span {
844                        path: Path::Circle {
845                            centre,
846                            radius,
847                            ccw: sweep > 0.0,
848                        },
849                        from,
850                        to: from + sweep,
851                    }
852                }
853            };
854            let forward = match span.path {
855                Path::Line { .. } => span.to > span.from,
856                Path::Circle { .. } => span.to != span.from,
857            };
858            if !forward {
859                return None;
860            }
861            spans.push(span);
862        }
863        // Each span with the loop point it shares with the next.
864        let mut chain: Vec<(Span, Point2)> = spans
865            .into_iter()
866            .zip(runs.iter().map(|r| points[r.last]))
867            .collect();
868        let mut joints: Vec<Option<Joint>> = vec![None; chain.len()];
869        // A piece between two longer lines goes where the lines themselves
870        // meet at a corner or round one off: a corner's own rounding is the
871        // largest arc that fits, which the run fit only approximates by an
872        // arc or cuts off by a chamfer.
873        let mut k = 0;
874        while chain.len() > 3 && k < chain.len() {
875            let n = chain.len();
876            let (before, after) = ((k + n - 1) % n, (k + 1) % n);
877            let is_line = |s: &Span| matches!(s.path, Path::Line { .. });
878            let shorter =
879                chain[k].0.length() < chain[before].0.length().min(chain[after].0.length());
880            if shorter && is_line(&chain[before].0) && is_line(&chain[after].0) {
881                let arc = &chain[k].0;
882                let middle = arc.path.point(0.5 * (arc.from + arc.to));
883                let joint = self.joint(&chain[before].0, &chain[after].0, middle);
884                if !matches!(joint, Joint::Bridge) {
885                    chain.remove(k);
886                    joints.remove(k);
887                    let before = if k == 0 { chain.len() - 1 } else { k - 1 };
888                    joints[before] = Some(joint);
889                    continue;
890                }
891            }
892            k += 1;
893        }
894        let count = chain.len();
895        let joints: Vec<Joint> = (0..count)
896            .map(|k| {
897                joints[k].unwrap_or_else(|| {
898                    self.joint(&chain[k].0, &chain[(k + 1) % count].0, chain[k].1)
899                })
900            })
901            .collect();
902        let spans: Vec<Span> = chain.into_iter().map(|(s, _)| s).collect();
903        let mut pieces = Vec::with_capacity(2 * count);
904        for k in 0..count {
905            let span = &spans[k];
906            let from = match joints[(k + count - 1) % count] {
907                Joint::Corner { b, .. } | Joint::Round { b, .. } => b,
908                Joint::Bridge => span.from,
909            };
910            let (to, after) = match joints[k] {
911                Joint::Corner { a, .. } => (a, None),
912                Joint::Round { a, arc, .. } => (a, Some(arc)),
913                Joint::Bridge => {
914                    let next = &spans[(k + 1) % count];
915                    let line = Piece2::Line {
916                        from: span.path.point(span.to),
917                        to: next.path.point(next.from),
918                    };
919                    (span.to, Some(line))
920                }
921            };
922            let mut piece = span.piece(from, to);
923            // Corners meet at one computed point, shared by both lines.
924            if let Piece2::Line {
925                from: start,
926                to: end,
927            } = &mut piece
928            {
929                if let Joint::Corner { at, .. } = joints[(k + count - 1) % count] {
930                    *start = at;
931                }
932                if let Joint::Corner { at, .. } = joints[k] {
933                    *end = at;
934                }
935            }
936            if piece.length() > 0.0 {
937                pieces.push(piece);
938            }
939            if let Some(p) = after
940                && p.length() > 0.0
941            {
942                pieces.push(p);
943            }
944        }
945        Some(Wire2d { pieces })
946    }
947
948    /// How the piece on `a` meets the piece on `b`, both fitted through
949    /// the loop point `q`.
950    fn joint(&self, a: &Span, b: &Span, q: Point2) -> Joint {
951        let corner = nearest_to(&meet(a.path, b.path), q).and_then(|x| {
952            let (ta, tb) = (a.path.parameter(x, a.to), b.path.parameter(x, b.from));
953            (a.progress(ta) >= 0.5 && b.progress(tb) <= 0.5).then_some((ta, tb, x))
954        });
955        let (ta, tb) = corner.map_or((a.to, b.from), |(ta, tb, _)| (ta, tb));
956        let (u, v) = (a.path.tangent(ta), b.path.tangent(tb));
957        let turn = u.cross(v).atan2(u.dot(v));
958        let sharp = corner.filter(|&(ta, tb, _)| self.extension_holds(a, b, ta, tb));
959        let at_corner = sharp.map(|(a, b, at)| Joint::Corner { a, b, at });
960        if turn.abs() >= self.round_below
961            && let Some(j) = at_corner
962        {
963            return j;
964        }
965        if turn != 0.0
966            && let Some(j) = self.round(a, b, turn, corner.map_or(q, |c| c.2))
967        {
968            return j;
969        }
970        at_corner.unwrap_or(Joint::Bridge)
971    }
972
973    /// Whether the stretches by which a joint lengthens either piece
974    /// beyond its fitted range hold.
975    fn extension_holds(&self, a: &Span, b: &Span, ta: f64, tb: f64) -> bool {
976        let stretch = |s: &Span, t0: f64, t1: f64| {
977            let length = s.piece(t0, t1).length();
978            self.band
979                .holds_along(|t| s.path.point(t0 + (t1 - t0) * t), length, self.spacing)
980        };
981        (a.progress(ta) <= 1.0 || stretch(a, a.to, ta))
982            && (b.progress(tb) >= 0.0 || stretch(b, tb, b.from))
983    }
984
985    /// The tangent arc of radius `r` from the piece on `a` to the piece on
986    /// `b` about a centre on the inside of the turn, nearest `near`.
987    fn arc(&self, a: &Span, b: &Span, r: f64, turn: f64, near: Point2) -> Option<Joint> {
988        let side = turn.signum();
989        let centre = nearest_to(
990            &meet(a.path.moved(side * r)?, b.path.moved(side * r)?),
991            near,
992        )?;
993        let (pa, pb) = (a.path.foot(centre)?, b.path.foot(centre)?);
994        let (ta, tb) = (a.path.parameter(pa, a.to), b.path.parameter(pb, b.from));
995        if a.progress(ta) < 0.5 || b.progress(tb) > 0.5 {
996            return None;
997        }
998        let (u, v) = (pa - centre, pb - centre);
999        let sweep = u.cross(v).atan2(u.dot(v));
1000        if sweep.signum() != side {
1001            return None;
1002        }
1003        // The arc leaves the first piece and enters the second along them.
1004        let start = u.y.atan2(u.x);
1005        let arc = Piece2::Arc {
1006            centre,
1007            radius: r,
1008            start,
1009            sweep,
1010        };
1011        let along = |t: f64| {
1012            let d = on_circle(Point2::ORIGIN, 1.0, sweep.mul_add(t, start)).to_vector();
1013            d.perpendicular() * side
1014        };
1015        if along(0.0).dot(a.path.tangent(ta)) <= 0.0 || along(1.0).dot(b.path.tangent(tb)) <= 0.0 {
1016            return None;
1017        }
1018        Some(Joint::Round { a: ta, b: tb, arc })
1019    }
1020
1021    fn arc_holds(&self, a: &Span, b: &Span, joint: &Joint) -> bool {
1022        match joint {
1023            Joint::Round { a: ta, b: tb, arc } => {
1024                self.band.holds_piece(arc, self.spacing) && self.extension_holds(a, b, *ta, *tb)
1025            }
1026            _ => false,
1027        }
1028    }
1029
1030    /// The largest tangent arc found to fit the band: radii shrinking by
1031    /// a factor from one leaving each piece half its length, then halving
1032    /// the factor between the last that failed and the first that held.
1033    fn round(&self, a: &Span, b: &Span, turn: f64, near: Point2) -> Option<Joint> {
1034        let reach = 0.5 * a.length().min(b.length());
1035        let largest = reach / (turn.abs() / 2.0).tan().max(1e-9);
1036        let shrink: f64 = 0.7;
1037        let mut failed = None;
1038        for k in 0..80 {
1039            let r = largest * shrink.powi(k);
1040            let Some(joint) = self.arc(a, b, r, turn, near) else {
1041                failed = Some(r);
1042                continue;
1043            };
1044            if !self.arc_holds(a, b, &joint) {
1045                failed = Some(r);
1046                continue;
1047            }
1048            let Some(mut above) = failed else {
1049                return Some(joint);
1050            };
1051            let (mut below, mut best) = (r, joint);
1052            for _ in 0..12 {
1053                let mid = (above * below).sqrt();
1054                match self.arc(a, b, mid, turn, near) {
1055                    Some(j) if self.arc_holds(a, b, &j) => {
1056                        below = mid;
1057                        best = j;
1058                    }
1059                    _ => above = mid,
1060                }
1061            }
1062            return Some(best);
1063        }
1064        None
1065    }
1066}
1067
1068/// A loop's points from its sharpest corner round and back to it, with
1069/// points added so that none are farther apart than `spacing`, as the run
1070/// fit wants them.
1071fn dense(target: &Loop2, spacing: f64) -> Vec<Point2> {
1072    let p = &target.points;
1073    let n = p.len();
1074    let turn = |i: usize| {
1075        let (u, v) = (p[i] - p[(i + n - 1) % n], p[(i + 1) % n] - p[i]);
1076        u.cross(v).atan2(u.dot(v)).abs()
1077    };
1078    let first = (0..n)
1079        .max_by(|&i, &j| turn(i).total_cmp(&turn(j)))
1080        .unwrap_or(0);
1081    let perimeter: f64 = (0..n).map(|i| p[i].distance(p[(i + 1) % n])).sum();
1082    // Two million points at most.
1083    let spacing = spacing.max(perimeter / 2e6);
1084    let mut out = Vec::new();
1085    for k in 0..n {
1086        let (a, b) = (p[(first + k) % n], p[(first + k + 1) % n]);
1087        let steps = whole((a.distance(b) / spacing).ceil()).max(1);
1088        for s in 0..steps {
1089            out.push(a.lerp(b, fraction(s, steps)));
1090        }
1091    }
1092    out.push(p[first]);
1093    out
1094}
1095
1096#[cfg(test)]
1097mod tests {
1098    #![allow(clippy::unwrap_used, reason = "test code")]
1099    use super::*;
1100
1101    fn square(s: f64) -> Loop2 {
1102        Loop2 {
1103            points: vec![
1104                Point2::new(0.0, 0.0),
1105                Point2::new(s, 0.0),
1106                Point2::new(s, s),
1107                Point2::new(0.0, s),
1108            ],
1109        }
1110    }
1111
1112    /// A square with no play and a tolerance wide enough for its corners
1113    /// comes back as four lines meeting at corners, one tolerance out.
1114    #[test]
1115    fn a_square_keeps_its_corners() {
1116        let o = Outline {
1117            play: 0.0,
1118            tolerance: 0.1,
1119            round_below: 0.5,
1120            fill_below: 0.0,
1121        };
1122        let wires = fit_outline(&[square(10.0)], &o).unwrap();
1123        assert_eq!(wires.len(), 1);
1124        let w = &wires[0];
1125        assert_eq!(w.pieces.len(), 4, "{w:?}");
1126        for p in &w.pieces {
1127            assert!(matches!(p, Piece2::Line { .. }), "{w:?}");
1128        }
1129    }
1130
1131    /// With a play too large for a sharp corner to stay in the band, the
1132    /// corners round.
1133    #[test]
1134    fn a_large_play_rounds_the_corners() {
1135        let o = Outline {
1136            play: 1.0,
1137            tolerance: 0.1,
1138            round_below: 0.5,
1139            fill_below: 0.0,
1140        };
1141        let wires = fit_outline(&[square(10.0)], &o).unwrap();
1142        assert_eq!(wires.len(), 1);
1143        let arcs = wires[0]
1144            .pieces
1145            .iter()
1146            .filter(|p| matches!(p, Piece2::Arc { .. }))
1147            .count();
1148        assert_eq!(arcs, 4, "{:?}", wires[0]);
1149    }
1150
1151    /// A 40 by 30 plate with a slot `width` wide and 5 deep down from the
1152    /// middle of its top side, and a round hole of `diameter` about
1153    /// `(10, 10)`, the hole's polygon inscribed in its circle.
1154    fn plate(width: f64, diameter: f64) -> Vec<Loop2> {
1155        let h = width / 2.0;
1156        let outer = Loop2 {
1157            points: [
1158                (0.0, 0.0),
1159                (40.0, 0.0),
1160                (40.0, 30.0),
1161                (20.0 + h, 30.0),
1162                (20.0 + h, 25.0),
1163                (20.0 - h, 25.0),
1164                (20.0 - h, 30.0),
1165                (0.0, 30.0),
1166            ]
1167            .map(|(x, y)| Point2::new(x, y))
1168            .to_vec(),
1169        };
1170        let hole = Loop2 {
1171            points: (0..64)
1172                .map(|k| {
1173                    let a = -fraction(k, 64) * core::f64::consts::TAU;
1174                    on_circle(Point2::new(10.0, 10.0), diameter / 2.0, a)
1175                })
1176                .collect(),
1177        };
1178        vec![outer, hole]
1179    }
1180
1181    /// Signed distance from the region the loops bound, positive outside,
1182    /// over every edge.
1183    fn signed_distance(loops: &[Loop2], p: Point2) -> f64 {
1184        let mut nearest = f64::INFINITY;
1185        let mut winding = 0;
1186        for l in loops {
1187            let n = l.points.len();
1188            for i in 0..n {
1189                let (a, b) = (l.points[i], l.points[(i + 1) % n]);
1190                nearest = nearest.min(distance_to_segment(p, a, b));
1191                let side = (b - a).cross(p - a);
1192                if a.y <= p.y && p.y < b.y && side > 0.0 {
1193                    winding += 1;
1194                } else if b.y <= p.y && p.y < a.y && side < 0.0 {
1195                    winding -= 1;
1196                }
1197            }
1198        }
1199        if winding > 0 { -nearest } else { nearest }
1200    }
1201
1202    fn samples(wires: &[Wire2d], step: f64) -> Vec<Point2> {
1203        let mut out = Vec::new();
1204        for p in wires.iter().flat_map(|w| &w.pieces) {
1205            let n = whole((p.length() / step).ceil().max(1.0));
1206            out.extend((0..=n).map(|k| p.point_at(fraction(k, n))));
1207        }
1208        out
1209    }
1210
1211    fn filling(fill_below: f64) -> Outline {
1212        Outline {
1213            play: 0.05,
1214            tolerance: 0.05,
1215            round_below: 0.5,
1216            fill_below,
1217        }
1218    }
1219
1220    /// With `fill_below = 1`, a 0.5 slot and a 0.4 hole are filled: the
1221    /// closing holds the exact closing, whose top dips into the slot's
1222    /// mouth by `r - sqrt(r^2 - s^2/4)`, and lies within the closing by
1223    /// `r - sigma` of the plate dilated by `2 sigma`, whose top is
1224    /// `2 sigma` higher and dips less; the convex corners stay and the
1225    /// sides move out by `sigma` at most. The outline is one wire, in the
1226    /// band about the filled plate, clear of the true one by the play.
1227    #[test]
1228    fn fill_below_fills_a_narrow_slot_and_a_small_hole() {
1229        let loops = plate(0.5, 0.4);
1230        let o = filling(1.0);
1231        let (r, s) = (0.5, 0.5);
1232        let sigma = o.tolerance.min(r) / SAG;
1233        let silhouette = region(&chain(&loops), |w| w[0] > 0).unwrap();
1234        let filled = closed(&silhouette, r, o.tolerance).unwrap();
1235        assert_eq!(filled.len(), 1, "{filled:?}");
1236        assert!(!filled[0].is_hole());
1237        let inside = |x: f64, y: f64| signed_distance(&filled, Point2::new(x, y)) < 0.0;
1238        assert!(inside(10.0, 10.0) && inside(20.0, 27.5));
1239        let dip = |r: f64, s: f64| r - (r * r - s * s / 4.0).sqrt();
1240        let deepest = 30.0 - dip(r, s);
1241        let shallowest = 2.0f64.mul_add(sigma, 30.0) - dip(r - sigma, 4.0f64.mul_add(-sigma, s));
1242        assert!(inside(20.0, deepest - 1e-6));
1243        assert!(!inside(20.0, shallowest + 1e-6));
1244        assert!(inside(40.0 - 1e-6, 30.0 - 1e-6) && inside(1e-6, 1e-6));
1245        assert!(!inside(40.0 + sigma + 1e-6, 15.0) && !inside(15.0, -sigma - 1e-6));
1246
1247        let wires = fit_outline(&loops, &o).unwrap();
1248        assert_eq!(wires.len(), 1, "{wires:?}");
1249        for q in samples(&wires, o.tolerance / 100.0) {
1250            let d = signed_distance(&filled, q);
1251            assert!(
1252                o.play <= d && d <= 2.0f64.mul_add(o.tolerance, o.play),
1253                "{q:?} at {d} from the filled plate"
1254            );
1255            assert!(signed_distance(&loops, q) >= o.play, "{q:?}");
1256        }
1257    }
1258
1259    /// With `fill_below = 0` the slot and the hole are kept: the outline
1260    /// runs down into the slot, and the hole has its own wire.
1261    #[test]
1262    fn no_filling_keeps_the_slot_and_the_hole() {
1263        let wires = fit_outline(&plate(0.5, 0.4), &filling(0.0)).unwrap();
1264        assert_eq!(wires.len(), 2, "{wires:?}");
1265        let points = samples(&wires, 0.01);
1266        assert!(
1267            points
1268                .iter()
1269                .any(|q| (q.x - 20.0).abs() < 0.25 && q.y < 26.0)
1270        );
1271        assert!(
1272            points
1273                .iter()
1274                .any(|q| q.distance(Point2::new(10.0, 10.0)) < 0.2)
1275        );
1276    }
1277
1278    /// A slot of 1.2 and a hole of 1.01, both wider than `fill_below = 1`
1279    /// and its fuzz of `2 sigma`, are kept, the hole within `2 sigma`; a
1280    /// hole of 1 inscribed in its circle, its inscribed circle narrower
1281    /// than 1, is filled.
1282    #[test]
1283    fn fill_below_keeps_what_is_wider() {
1284        let o = filling(1.0);
1285        let sigma = o.tolerance.min(0.5) / SAG;
1286        let mut loops = plate(1.2, 1.01);
1287        loops.push(Loop2 {
1288            points: plate(1.2, 1.0)[1]
1289                .points
1290                .iter()
1291                .map(|&p| Point2::new(p.x + 20.0, p.y))
1292                .collect(),
1293        });
1294        let silhouette = region(&chain(&loops), |w| w[0] > 0).unwrap();
1295        let filled = closed(&silhouette, 0.5, o.tolerance).unwrap();
1296        assert_eq!(filled.len(), 2, "{filled:?}");
1297        let at = |x: f64, y: f64| signed_distance(&filled, Point2::new(x, y));
1298        assert!(at(20.0, 27.5) > 0.0);
1299        assert!(at(30.0, 10.0) < 0.0);
1300        let rim = 0.505 * (core::f64::consts::PI / 64.0).cos();
1301        assert!(
1302            at(10.0, 10.0) > rim - 2.0 * sigma - 1e-6,
1303            "{}",
1304            at(10.0, 10.0)
1305        );
1306
1307        let wires = fit_outline(&loops, &o).unwrap();
1308        assert_eq!(wires.len(), 2, "{wires:?}");
1309        assert!(
1310            samples(&wires, 0.01)
1311                .iter()
1312                .any(|q| (q.x - 20.0).abs() < 0.6 && q.y < 26.0)
1313        );
1314    }
1315
1316    /// A negative or unbounded `fill_below` is no width.
1317    #[test]
1318    fn fill_below_must_be_a_width() {
1319        for w in [-1.0, f64::NAN, f64::INFINITY] {
1320            assert!(fit_outline(&plate(0.5, 0.4), &filling(w)).is_err());
1321        }
1322    }
1323}