Skip to main content

ogeom_algo/
medial_graph.rs

1//! The medial axis of any planar face: holes, reflex corners and curved
2//! boundaries included, each branch its exact bisector.
3//!
4//! The boundary is taken apart into *sites*: every edge's open interior,
5//! and every reflex corner as a point (a convex corner is where its two
6//! edges' bisector reaches the boundary, not a site of its own). The axis
7//! is the set of points with two or more nearest sites, and where two
8//! sites meet it is a curve with a closed form: two segments' bisector is a
9//! line, a segment and a point's a parabola, and a circular arc speaks as
10//! a circle, so an arc against a segment is a parabola too and an arc
11//! against a point or an arc is a hyperbola or an ellipse. A site on any
12//! other curve has no closed form, and its branches are fitted through
13//! points solved onto the bisector exactly.
14//!
15//! Which sites meet where (the axis's topology) is read off a sampling:
16//! the Delaunay triangulation of points along every site has, inside the
17//! region, circumcentres whose three corners name their sites. A centre
18//! naming two sites is a point on their branch, one naming three a branch
19//! point. Each branch point is then solved exactly (equidistant from its
20//! sites, by Newton), and each branch is its exact bisector between the
21//! branch points the sampling says it runs between. The result is checked:
22//! along every branch no third site may come nearer than the branch's own
23//! two, and every sampled centre must lie on some branch. A result that
24//! fails is sampled again, finer.
25
26use crate::medial::signed_area;
27use ogeom_core::FastMap;
28use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
29use ogeom_geom::Curve3d as _;
30use ogeom_math::{Direction, Frame, Point, Point2, Vector2};
31use ogeom_topo::{EdgeRepr, Model, NodeData, Orientation, Shape};
32use spade::{DelaunayTriangulation, Point2 as SpadePoint, Triangulation as _};
33
34/// A boundary element a medial branch keeps its distance from.
35#[derive(Debug, Clone)]
36pub enum MedialSite {
37    /// The open interior of one curve along the boundary: an edge, or
38    /// neighbouring edges on one line or one circle, which the boundary
39    /// splits and the axis does not see.
40    Edges(Vec<Shape>),
41    /// A reflex corner, as a point.
42    Vertex(Shape),
43}
44
45/// A branch point of the medial axis, or a branch's end on the boundary.
46#[derive(Debug, Clone)]
47pub struct MedialVertex {
48    /// Where it stands.
49    pub point: Point,
50    /// The radius of the largest disc centred here inside the face: the
51    /// distance to the nearest boundary. Zero where a branch reaches a
52    /// convex corner.
53    pub clearance: f64,
54}
55
56/// One branch: the curve of points equidistant from two sites.
57#[derive(Debug, Clone)]
58pub struct MedialBranch {
59    /// The branch's curve, exact wherever the two sites have a closed-form
60    /// bisector (a line, a parabola, a hyperbola or an ellipse), fitted
61    /// otherwise.
62    pub curve: ogeom_geom::Curve,
63    /// The portion of `curve` the branch covers.
64    pub range: (f64, f64),
65    /// The vertices at `range.0` and `range.1`, by index. A closed branch
66    /// no branch point touches (a washer's, between its two circles) runs
67    /// a full period and names one vertex on it twice.
68    pub ends: [usize; 2],
69    /// The two sites the branch is equidistant from.
70    pub sites: [MedialSite; 2],
71}
72
73/// The medial axis of a planar face as a graph of exact branches.
74#[derive(Debug, Clone)]
75pub struct MedialGraph {
76    /// Branch points and boundary ends.
77    pub vertices: Vec<MedialVertex>,
78    /// The branches between them.
79    pub branches: Vec<MedialBranch>,
80    /// The largest amount, measured along the branches, by which a site
81    /// other than a branch's own two comes nearer than they do: zero for a
82    /// branch that is all medial, and held under the tolerance asked for.
83    pub deviation: f64,
84    frame: Frame,
85    sites: Vec<Site>,
86    branch_sites: Vec<[usize; 2]>,
87}
88
89impl MedialGraph {
90    /// The clearance at parameter `t` of branch `branch`: its distance from
91    /// either of its two sites.
92    ///
93    /// # Errors
94    ///
95    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
96    /// the branch does not exist or the curve cannot be evaluated there.
97    pub fn clearance_at(&self, branch: usize, t: f64, tol: Tolerances) -> OgeomResult<f64> {
98        let Some(b) = self.branches.get(branch) else {
99            ogeom_bail!(Construction, "no branch {branch}");
100        };
101        let p = self.frame.to_local(b.curve.point_at(t, tol)?);
102        let site = &self.sites[self.branch_sites[branch][0]];
103        site.distance(Point2::new(p.x, p.y), tol)
104    }
105}
106
107/// How many times the sampling may be refined before the construction
108/// gives up.
109const REFINEMENTS: usize = 4;
110
111/// The medial axis of a planar face: every point with two or more nearest
112/// boundary elements, as exact branches between branch points.
113///
114/// Holes, reflex corners and circular arcs are handled exactly; an edge on
115/// any other curve bisects along a fitted branch held to `tolerance`. The
116/// result's own check is reported in [`MedialGraph::deviation`].
117///
118/// # Errors
119///
120/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
121/// face is not planar or its boundary cannot be read;
122/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if the branches
123/// still fail their check after every refinement of the sampling.
124pub fn medial_graph(
125    model: &Model,
126    face: &Shape,
127    tolerance: f64,
128    tol: Tolerances,
129) -> OgeomResult<MedialGraph> {
130    if !tolerance.is_finite() || tolerance <= tol.confusion() {
131        ogeom_bail!(
132            Construction,
133            "a medial axis to {tolerance} is not a distance"
134        );
135    }
136    let boundary = Boundary::read(model, face, tol)?;
137    let mut spacing = boundary.extent / 256.0;
138    let mut last = None;
139    for _ in 0..REFINEMENTS {
140        match build(&boundary, spacing, tolerance, tol) {
141            Ok(graph) => return Ok(graph),
142            Err(e) => last = Some(e),
143        }
144        spacing *= 0.5;
145    }
146    ogeom_bail!(
147        NotDone,
148        "the medial axis did not settle under refinement: {}",
149        last.map_or_else(String::new, |e| e.to_string())
150    )
151}
152
153// --- the boundary, in the face's own plane ------------------------------
154
155#[derive(Debug, Clone)]
156enum SiteKind {
157    Segment {
158        a: Point2,
159        b: Point2,
160    },
161    /// A circular arc: centre, radius, start angle and signed sweep.
162    Arc {
163        centre: Point2,
164        radius: f64,
165        start: f64,
166        sweep: f64,
167    },
168    Point {
169        at: Point2,
170    },
171    /// Any other curve, placed, over its range in the direction of travel,
172    /// with a dense polyline for seeding.
173    Curve {
174        curve: Box<ogeom_geom::Curve>,
175        range: (f64, f64),
176        reversed: bool,
177        polyline: Vec<(f64, Point2)>,
178        frame: Frame,
179    },
180}
181
182#[derive(Debug, Clone)]
183struct Site {
184    kind: SiteKind,
185    origin: MedialSite,
186}
187
188impl Site {
189    fn foot(&self, p: Point2, tol: Tolerances) -> OgeomResult<Point2> {
190        Ok(match &self.kind {
191            SiteKind::Segment { a, b } => {
192                let d = *b - *a;
193                let s = ((p - *a).dot(d) / d.dot(d)).clamp(0.0, 1.0);
194                *a + d * s
195            }
196            SiteKind::Arc {
197                centre,
198                radius,
199                start,
200                sweep,
201            } => {
202                let v = p - *centre;
203                let theta = v.y.atan2(v.x);
204                let within = if *sweep >= 0.0 {
205                    (theta - start).rem_euclid(core::f64::consts::TAU) <= *sweep
206                } else {
207                    (start - theta).rem_euclid(core::f64::consts::TAU) <= -sweep
208                };
209                let on = |angle: f64| *centre + Vector2::new(angle.cos(), angle.sin()) * *radius;
210                if within && v.magnitude() > 0.0 {
211                    on(theta)
212                } else {
213                    let (s, e) = (on(*start), on(start + sweep));
214                    if s.distance(p) <= e.distance(p) { s } else { e }
215                }
216            }
217            SiteKind::Point { at } => *at,
218            SiteKind::Curve {
219                curve,
220                range,
221                polyline,
222                frame,
223                ..
224            } => {
225                let seed = polyline
226                    .iter()
227                    .min_by(|x, y| x.1.distance(p).total_cmp(&y.1.distance(p)))
228                    .map_or(range.0, |s| s.0);
229                let lifted = lift(frame, p);
230                let mut t = seed;
231                for _ in 0..30 {
232                    let d = curve.d1_at(t, tol)?;
233                    let q = curve.point_at(t, tol)?;
234                    let step = (lifted - q).dot(d) / d.dot(d).max(1e-300);
235                    let next = (t + step).clamp(range.0.min(range.1), range.0.max(range.1));
236                    if (next - t).abs() <= 1e-14 * (1.0 + t.abs()) {
237                        t = next;
238                        break;
239                    }
240                    t = next;
241                }
242                flat(frame, curve.point_at(t, tol)?)
243            }
244        })
245    }
246
247    fn distance(&self, p: Point2, tol: Tolerances) -> OgeomResult<f64> {
248        Ok(self.foot(p, tol)?.distance(p))
249    }
250
251    /// Points along the site at about `spacing` apart, its ends left out:
252    /// a site is its open interior.
253    fn samples(&self, spacing: f64, tol: Tolerances) -> OgeomResult<Vec<Point2>> {
254        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
255        let count = |length: f64| (length / spacing).ceil().max(4.0) as usize;
256        Ok(match &self.kind {
257            SiteKind::Segment { a, b } => {
258                let n = count(a.distance(*b));
259                (0..n)
260                    .map(|i| {
261                        #[allow(clippy::cast_precision_loss)]
262                        let f = (i as f64 + 0.5) / n as f64;
263                        a.lerp(*b, f)
264                    })
265                    .collect()
266            }
267            SiteKind::Arc {
268                centre,
269                radius,
270                start,
271                sweep,
272            } => {
273                let n = count(radius * sweep.abs());
274                (0..n)
275                    .map(|i| {
276                        #[allow(clippy::cast_precision_loss)]
277                        let angle = start + sweep * (i as f64 + 0.5) / n as f64;
278                        *centre + Vector2::new(angle.cos(), angle.sin()) * *radius
279                    })
280                    .collect()
281            }
282            SiteKind::Point { at } => vec![*at],
283            SiteKind::Curve {
284                curve,
285                range,
286                polyline,
287                frame,
288                ..
289            } => {
290                let length: f64 = polyline.windows(2).map(|w| w[0].1.distance(w[1].1)).sum();
291                let n = count(length);
292                let mut out = Vec::with_capacity(n);
293                for i in 0..n {
294                    #[allow(clippy::cast_precision_loss)]
295                    let f = (i as f64 + 0.5) / n as f64;
296                    let t = range.0 + (range.1 - range.0) * f;
297                    out.push(flat(frame, curve.point_at(t, tol)?));
298                }
299                out
300            }
301        })
302    }
303
304    /// The site as the circle or line its exact bisectors speak: a segment
305    /// by its line and inward normal, an arc by its circle and the side the
306    /// region lies on, a point as a circle of no radius.
307    fn primitive(&self) -> Option<Primitive> {
308        match &self.kind {
309            SiteKind::Segment { a, b } => {
310                let d = *b - *a;
311                let m = d.magnitude();
312                // Inward is to the left: the boundary is wound so.
313                Some(Primitive::Line {
314                    normal: Vector2::new(-d.y / m, d.x / m),
315                    through: *a,
316                })
317            }
318            SiteKind::Arc {
319                centre,
320                radius,
321                sweep,
322                ..
323            } => Some(Primitive::Circle {
324                centre: *centre,
325                radius: *radius,
326                // Turning left the arc holds the region inside its circle.
327                outside: *sweep < 0.0,
328            }),
329            SiteKind::Point { at } => Some(Primitive::Circle {
330                centre: *at,
331                radius: 0.0,
332                outside: true,
333            }),
334            SiteKind::Curve { .. } => None,
335        }
336    }
337}
338
339#[derive(Debug, Clone, Copy)]
340enum Primitive {
341    Line {
342        normal: Vector2,
343        through: Point2,
344    },
345    Circle {
346        centre: Point2,
347        radius: f64,
348        outside: bool,
349    },
350}
351
352/// The face's boundary as sites, with its corners and a fine outline.
353struct Boundary {
354    frame: Frame,
355    sites: Vec<Site>,
356    /// Convex corners: the point and the two sites meeting there.
357    convex: Vec<(Point2, usize, usize)>,
358    /// Reflex corners: the point's own site and its two edges' sites.
359    reflex: Vec<(usize, usize, usize)>,
360    /// Every loop as a fine polyline, for the inside test.
361    outline: Vec<Vec<Point2>>,
362    extent: f64,
363}
364
365fn lift(frame: &Frame, p: Point2) -> Point {
366    frame.origin() + frame.x().vector() * p.x + frame.y().vector() * p.y
367}
368
369fn flat(frame: &Frame, p: Point) -> Point2 {
370    let l = frame.to_local(p);
371    Point2::new(l.x, l.y)
372}
373
374/// One edge as travelled round its loop: its site and its tangents in and
375/// out, with points along it for the outline.
376struct Travel {
377    site: SiteKind,
378    edges: Vec<Shape>,
379    /// The vertices the travel leaves and arrives at.
380    from: Shape,
381    to: Shape,
382    enter: Vector2,
383    leave: Vector2,
384    start: Point2,
385    points: Vec<Point2>,
386}
387
388impl Boundary {
389    fn read(model: &Model, face: &Shape, tol: Tolerances) -> OgeomResult<Self> {
390        let Some(data) = model.node(face).and_then(|n| match n.data() {
391            NodeData::Face(d) => Some(d.clone()),
392            _ => None,
393        }) else {
394            ogeom_bail!(Construction, "the shape is not a face");
395        };
396        let Some(ogeom_geom::SurfaceGeometry::Plane(plane)) =
397            model.geometry().surface(data.surface)
398        else {
399            ogeom_bail!(
400                Construction,
401                "the medial axis is computed for planar faces; this face's \
402                 surface is not a plane"
403            );
404        };
405        let placement = face.transform(model.datums())?;
406        let frame = {
407            let f = plane.plane().frame();
408            Frame::new(
409                placement.apply(f.origin()),
410                Direction::new(placement.apply_vector(f.z().vector()), tol)?,
411                Direction::new(placement.apply_vector(f.x().vector()), tol)?,
412                tol,
413            )?
414        };
415
416        let mut loops: Vec<Vec<Travel>> = Vec::new();
417        for wire in model.ordered_children_of(face)? {
418            let mut travel = Vec::new();
419            for edge in model.ordered_children_of(&wire)? {
420                travel.push(read_edge(model, &edge, &frame, tol)?);
421            }
422            if !travel.is_empty() {
423                loops.push(travel);
424            }
425        }
426        if loops.is_empty() {
427            ogeom_bail!(Construction, "the face has no boundary to read");
428        }
429        // Wind the loops so the region lies to the left of every one: the
430        // outer loop counter-clockwise and each hole clockwise, whatever
431        // sense each was stored in. The outer is the loop of largest area.
432        let areas: Vec<f64> = loops
433            .iter()
434            .map(|l| signed_area(&l.iter().flat_map(|t| t.points.clone()).collect::<Vec<_>>()))
435            .collect();
436        let outer = (0..loops.len())
437            .max_by(|&a, &b| areas[a].abs().total_cmp(&areas[b].abs()))
438            .unwrap_or(0);
439        for (i, l) in loops.iter_mut().enumerate() {
440            let wound_wrong = if i == outer {
441                areas[i] < 0.0
442            } else {
443                areas[i] > 0.0
444            };
445            if wound_wrong {
446                l.reverse();
447                for t in l.iter_mut() {
448                    reverse_travel(t);
449                }
450            }
451        }
452
453        // Neighbours on one line or one circle are one site: the boundary
454        // split them, and a join between them is no corner.
455        for l in &mut loops {
456            merge_continuations(l, tol);
457        }
458
459        let mut sites: Vec<Site> = Vec::new();
460        let mut convex = Vec::new();
461        let mut reflex = Vec::new();
462        let mut outline = Vec::new();
463        let mut extent = 0.0_f64;
464        let mut all: Vec<Point2> = Vec::new();
465        for l in &loops {
466            let first = sites.len();
467            for t in l {
468                sites.push(Site {
469                    kind: t.site.clone(),
470                    origin: MedialSite::Edges(t.edges.clone()),
471                });
472            }
473            let n = l.len();
474            for i in 0..n {
475                let j = (i + 1) % n;
476                let (into, out) = (l[i].leave, l[j].enter);
477                let turn = into.cross(out);
478                let at = l[j].start;
479                if turn > tol.angular() {
480                    convex.push((at, first + i, first + j));
481                } else if turn < -tol.angular() || into.dot(out) < 0.0 {
482                    let vertex = l[j].from.clone();
483                    let point_site = sites.len();
484                    sites.push(Site {
485                        kind: SiteKind::Point { at },
486                        origin: MedialSite::Vertex(vertex),
487                    });
488                    reflex.push((point_site, first + i, first + j));
489                }
490            }
491            let ring: Vec<Point2> = l.iter().flat_map(|t| t.points.clone()).collect();
492            all.extend(ring.iter().copied());
493            outline.push(ring);
494        }
495        if let (Some(lo), Some(hi)) = (
496            all.iter()
497                .copied()
498                .reduce(|a, b| Point2::new(a.x.min(b.x), a.y.min(b.y))),
499            all.iter()
500                .copied()
501                .reduce(|a, b| Point2::new(a.x.max(b.x), a.y.max(b.y))),
502        ) {
503            extent = lo.distance(hi);
504        }
505        if extent <= tol.confusion() {
506            ogeom_bail!(Construction, "the face has no extent");
507        }
508        Ok(Self {
509            frame,
510            sites,
511            convex,
512            reflex,
513            outline,
514            extent,
515        })
516    }
517
518    fn inside(&self, p: Point2) -> bool {
519        let mut inside = false;
520        for ring in &self.outline {
521            for i in 0..ring.len() {
522                let (a, b) = (ring[i], ring[(i + 1) % ring.len()]);
523                if (a.y > p.y) != (b.y > p.y) {
524                    let x = a.x + (p.y - a.y) / (b.y - a.y) * (b.x - a.x);
525                    if x > p.x {
526                        inside = !inside;
527                    }
528                }
529            }
530        }
531        inside
532    }
533}
534
535/// The traversal start vertex of an edge (its first vertex as used), and
536/// the vertex it arrives at.
537fn edge_ends(model: &Model, edge: &Shape) -> OgeomResult<Option<(Shape, Shape)>> {
538    let Some((a, b)) = crate::edge_vertices(model, edge)? else {
539        return Ok(None);
540    };
541    Ok(Some(if edge.orientation() == Orientation::Reversed {
542        (b, a)
543    } else {
544        (a, b)
545    }))
546}
547
548fn reverse_travel(t: &mut Travel) {
549    t.points.reverse();
550    let (enter, leave) = (-t.leave, -t.enter);
551    t.enter = enter;
552    t.leave = leave;
553    t.start = t.points[0];
554    t.site = match &t.site {
555        SiteKind::Segment { a, b } => SiteKind::Segment { a: *b, b: *a },
556        SiteKind::Arc {
557            centre,
558            radius,
559            start,
560            sweep,
561        } => SiteKind::Arc {
562            centre: *centre,
563            radius: *radius,
564            start: start + sweep,
565            sweep: -sweep,
566        },
567        SiteKind::Point { at } => SiteKind::Point { at: *at },
568        SiteKind::Curve {
569            curve,
570            range,
571            reversed,
572            polyline,
573            frame,
574        } => SiteKind::Curve {
575            curve: curve.clone(),
576            range: (range.1, range.0),
577            reversed: !reversed,
578            polyline: polyline.iter().rev().copied().collect(),
579            frame: *frame,
580        },
581    };
582    core::mem::swap(&mut t.from, &mut t.to);
583}
584
585fn read_edge(model: &Model, edge: &Shape, frame: &Frame, tol: Tolerances) -> OgeomResult<Travel> {
586    let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
587        ogeom_bail!(Construction, "a boundary edge holds no data");
588    };
589    let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
590        ogeom_bail!(Construction, "a boundary edge carries no curve");
591    };
592    let Some(geometry) = model.geometry().curve(*curve) else {
593        ogeom_bail!(Construction, "a boundary curve is not in this model");
594    };
595    use ogeom_geom::Transformable as _;
596    let placed = geometry
597        .clone()
598        .transformed(&edge.transform(model.datums())?, tol)?;
599    let reversed = edge.orientation() == Orientation::Reversed;
600    let (t0, t1) = if reversed {
601        (range.1, range.0)
602    } else {
603        (range.0, range.1)
604    };
605    let at = |t: f64| -> OgeomResult<Point2> { Ok(flat(frame, placed.point_at(t, tol)?)) };
606    let heading = |t: f64| -> OgeomResult<Vector2> {
607        let d = placed.d1_at(t, tol)?;
608        let l = frame.to_local(frame.origin() + d);
609        let v = Vector2::new(l.x, l.y);
610        let v = v.normalized(tol)?;
611        Ok(if reversed { -v } else { v })
612    };
613    let mut points = Vec::with_capacity(65);
614    for i in 0..64 {
615        let t = t0 + (t1 - t0) * f64::from(i) / 64.0;
616        points.push(at(t)?);
617    }
618    let (start, end) = (at(t0)?, at(t1)?);
619    let site = match &placed {
620        ogeom_geom::Curve::Line(_) => SiteKind::Segment { a: start, b: end },
621        ogeom_geom::Curve::Circle(c) => {
622            let centre = flat(frame, c.circle().frame().origin());
623            let radius = c.circle().radius();
624            let angle = |p: Point2| (p.y - centre.y).atan2(p.x - centre.x);
625            let a0 = angle(start);
626            let mid = at(f64::midpoint(t0, t1))?;
627            // The sweep from start through the midpoint to the end: turning
628            // left is positive.
629            let turning = (mid - start).cross(end - mid);
630            let travel = (angle(end) - a0).rem_euclid(core::f64::consts::TAU);
631            let closed = start.distance(end) <= tol.confusion();
632            let left = if closed {
633                heading(t0)?.cross(start - centre) < 0.0
634                    || (start - centre).cross(heading(t0)?) > 0.0
635            } else {
636                turning > 0.0
637                    || (turning.abs() <= tol.confusion() && (mid - centre).cross(end - start) > 0.0)
638            };
639            let sweep = if closed {
640                if left {
641                    core::f64::consts::TAU
642                } else {
643                    -core::f64::consts::TAU
644                }
645            } else if left {
646                if travel <= 0.0 {
647                    core::f64::consts::TAU
648                } else {
649                    travel
650                }
651            } else {
652                -(core::f64::consts::TAU - travel)
653            };
654            SiteKind::Arc {
655                centre,
656                radius,
657                start: a0,
658                sweep,
659            }
660        }
661        other => {
662            let mut polyline = Vec::with_capacity(129);
663            for i in 0..=128 {
664                let t = t0 + (t1 - t0) * f64::from(i) / 128.0;
665                polyline.push((t, at(t)?));
666            }
667            SiteKind::Curve {
668                curve: Box::new(other.clone()),
669                range: (t0, t1),
670                reversed,
671                polyline,
672                frame: *frame,
673            }
674        }
675    };
676    let Some((from, to)) = edge_ends(model, edge)? else {
677        ogeom_bail!(Construction, "a boundary edge has no vertices");
678    };
679    Ok(Travel {
680        site,
681        edges: vec![edge.clone()],
682        from,
683        to,
684        enter: heading(t0)?,
685        leave: heading(t1)?,
686        start,
687        points,
688    })
689}
690
691/// Fuse neighbouring travels that continue one another: two segments on
692/// one line, or two arcs on one circle turning the same way. A loop that
693/// is one circle all round becomes a single full-turn arc.
694fn merge_continuations(l: &mut Vec<Travel>, tol: Tolerances) {
695    let same = |a: &SiteKind, b: &SiteKind| -> Option<SiteKind> {
696        match (a, b) {
697            (SiteKind::Segment { a: p, b: q }, SiteKind::Segment { a: r, b: t }) => {
698                let d1 = (*q - *p).normalized(tol).ok()?;
699                let d2 = (*t - *r).normalized(tol).ok()?;
700                (d1.cross(d2).abs() <= tol.angular()
701                    && d1.dot(d2) > 0.0
702                    && q.distance(*r) <= tol.confusion())
703                .then_some(SiteKind::Segment { a: *p, b: *t })
704            }
705            (
706                SiteKind::Arc {
707                    centre: c1,
708                    radius: r1,
709                    start,
710                    sweep: s1,
711                },
712                SiteKind::Arc {
713                    centre: c2,
714                    radius: r2,
715                    sweep: s2,
716                    ..
717                },
718            ) => (c1.distance(*c2) <= tol.confusion()
719                && (r1 - r2).abs() <= tol.confusion()
720                && s1.signum() == s2.signum())
721            .then(|| SiteKind::Arc {
722                centre: *c1,
723                radius: *r1,
724                start: *start,
725                sweep: (s1 + s2).clamp(-core::f64::consts::TAU, core::f64::consts::TAU),
726            }),
727            _ => None,
728        }
729    };
730    let mut i = 0;
731    while l.len() > 1 && i < l.len() {
732        let j = (i + 1) % l.len();
733        if let Some(kind) = same(&l[i].site, &l[j].site) {
734            let next = l.remove(j);
735            let i = if j < i { i - 1 } else { i };
736            let t = &mut l[i];
737            t.site = kind;
738            t.edges.extend(next.edges);
739            t.leave = next.leave;
740            t.to = next.to;
741            t.points.extend(next.points);
742            continue;
743        }
744        i += 1;
745    }
746}
747
748// --- exact bisectors ------------------------------------------------------
749
750/// Where two sites' distances agree, as a curve with a parameter.
751#[derive(Debug, Clone, Copy)]
752enum Bisector {
753    Line {
754        origin: Point2,
755        dir: Vector2,
756    },
757    Parabola {
758        apex: Point2,
759        x: Vector2,
760        focal: f64,
761    },
762    Hyperbola {
763        centre: Point2,
764        x: Vector2,
765        a: f64,
766        b: f64,
767    },
768    Ellipse {
769        centre: Point2,
770        x: Vector2,
771        a: f64,
772        b: f64,
773    },
774}
775
776fn perp(v: Vector2) -> Vector2 {
777    Vector2::new(-v.y, v.x)
778}
779
780impl Bisector {
781    fn between(p: Primitive, q: Primitive) -> Option<Self> {
782        const EPS: f64 = 1e-12;
783        match (p, q) {
784            (
785                Primitive::Line {
786                    normal: n1,
787                    through: a1,
788                },
789                Primitive::Line {
790                    normal: n2,
791                    through: a2,
792                },
793            ) => {
794                let m = n1 - n2;
795                let mm = m.dot(m);
796                if mm <= EPS {
797                    return None;
798                }
799                let k = n1.dot(a1.to_vector()) - n2.dot(a2.to_vector());
800                Some(Self::Line {
801                    origin: Point2::ORIGIN + m * (k / mm),
802                    dir: perp(m) * (1.0 / mm.sqrt()),
803                })
804            }
805            (
806                Primitive::Line { normal, through },
807                Primitive::Circle {
808                    centre,
809                    radius,
810                    outside,
811                },
812            )
813            | (
814                Primitive::Circle {
815                    centre,
816                    radius,
817                    outside,
818                },
819                Primitive::Line { normal, through },
820            ) => {
821                // n·(p − a) = σ(|p − c| − r)  ⇔  |p − c| = σ n·(p − a*),
822                // a* = a − σ r n: focus c, directrix through a*.
823                let sigma = if outside { 1.0 } else { -1.0 };
824                let x = normal * sigma;
825                let on_directrix = through - normal * (sigma * radius);
826                let focal = x.dot(centre - on_directrix) / 2.0;
827                if focal <= EPS {
828                    return Some(Self::Line {
829                        origin: centre,
830                        dir: x,
831                    });
832                }
833                Some(Self::Parabola {
834                    apex: centre - x * focal,
835                    x,
836                    focal,
837                })
838            }
839            (
840                Primitive::Circle {
841                    centre: c1,
842                    radius: r1,
843                    outside: o1,
844                },
845                Primitive::Circle {
846                    centre: c2,
847                    radius: r2,
848                    outside: o2,
849                },
850            ) => {
851                let mid = c1.lerp(c2, 0.5);
852                let span = c2 - c1;
853                let e = span.magnitude() / 2.0;
854                if o1 == o2 {
855                    // |p − c1| − |p − c2| = r1 − r2.
856                    let delta = r1 - r2;
857                    if delta.abs() <= EPS {
858                        if e <= EPS {
859                            return None;
860                        }
861                        return Some(Self::Line {
862                            origin: mid,
863                            dir: perp(span) * (1.0 / span.magnitude()),
864                        });
865                    }
866                    let a = delta.abs() / 2.0;
867                    if e <= a + EPS {
868                        return None;
869                    }
870                    let toward = if delta > 0.0 { span } else { -span };
871                    Some(Self::Hyperbola {
872                        centre: mid,
873                        x: toward * (1.0 / toward.magnitude()),
874                        a,
875                        b: (e * e - a * a).sqrt(),
876                    })
877                } else {
878                    // |p − c1| + |p − c2| = r1 + r2.
879                    let a = (r1 + r2) / 2.0;
880                    if a <= e + EPS {
881                        return None;
882                    }
883                    let x = if e > EPS {
884                        span * (1.0 / span.magnitude())
885                    } else {
886                        Vector2::new(1.0, 0.0)
887                    };
888                    Some(Self::Ellipse {
889                        centre: mid,
890                        x,
891                        a,
892                        b: (a * a - e * e).sqrt(),
893                    })
894                }
895            }
896        }
897    }
898
899    fn param(&self, p: Point2) -> f64 {
900        match *self {
901            Self::Line { origin, dir } => (p - origin).dot(dir),
902            Self::Parabola { apex, x, .. } => (p - apex).dot(perp(x)),
903            Self::Hyperbola { centre, x, b, .. } => ((p - centre).dot(perp(x)) / b).asinh(),
904            Self::Ellipse { centre, x, a, b } => {
905                let d = p - centre;
906                (d.dot(perp(x)) / b).atan2(d.dot(x) / a)
907            }
908        }
909    }
910
911    fn at(&self, t: f64) -> Point2 {
912        match *self {
913            Self::Line { origin, dir } => origin + dir * t,
914            Self::Parabola { apex, x, focal } => apex + x * (t * t / (4.0 * focal)) + perp(x) * t,
915            Self::Hyperbola { centre, x, a, b } => {
916                centre + x * (a * t.cosh()) + perp(x) * (b * t.sinh())
917            }
918            Self::Ellipse { centre, x, a, b } => {
919                centre + x * (a * t.cos()) + perp(x) * (b * t.sin())
920            }
921        }
922    }
923
924    fn periodic(&self) -> bool {
925        matches!(self, Self::Ellipse { .. })
926    }
927
928    fn curve(
929        &self,
930        frame: &Frame,
931        range: (f64, f64),
932        tol: Tolerances,
933    ) -> OgeomResult<ogeom_geom::Curve> {
934        let plane_frame = |origin: Point2, x: Vector2| -> OgeomResult<Frame> {
935            Frame::new(
936                lift(frame, origin),
937                frame.z(),
938                Direction::new(frame.x().vector() * x.x + frame.y().vector() * x.y, tol)?,
939                tol,
940            )
941        };
942        Ok(match *self {
943            Self::Line { .. } => ogeom_geom::LineCurve::segment(
944                lift(frame, self.at(range.0)),
945                lift(frame, self.at(range.1)),
946                tol,
947            )?
948            .into(),
949            Self::Parabola { apex, x, focal } => ogeom_geom::ParabolaCurve::over(
950                ogeom_math::Parabola::new(plane_frame(apex, x)?, focal, tol)?,
951                range.0,
952                range.1,
953            )?
954            .into(),
955            Self::Hyperbola { centre, x, a, b } => ogeom_geom::HyperbolaCurve::over(
956                ogeom_math::Hyperbola::new(plane_frame(centre, x)?, a, b, tol)?,
957                range.0,
958                range.1,
959            )?
960            .into(),
961            Self::Ellipse { centre, x, a, b } => ogeom_geom::EllipseCurve::new(
962                ogeom_math::Ellipse::new(plane_frame(centre, x)?, a, b, tol)?,
963            )
964            .into(),
965        })
966    }
967}
968
969// --- the construction -----------------------------------------------------
970
971/// A branch point being found: the sites it is equidistant from and where.
972struct Node {
973    at: Point2,
974    sites: Vec<usize>,
975    boundary: bool,
976}
977
978#[allow(
979    clippy::too_many_lines,
980    reason = "one construction, read top to bottom"
981)]
982fn build(
983    boundary: &Boundary,
984    spacing: f64,
985    tolerance: f64,
986    tol: Tolerances,
987) -> OgeomResult<MedialGraph> {
988    let sites = &boundary.sites;
989    // Sample every site, and triangulate the samples.
990    let mut owner: Vec<usize> = Vec::new();
991    let mut dt: DelaunayTriangulation<SpadePoint<f64>> = DelaunayTriangulation::new();
992    let mut index_of: FastMap<spade::handles::FixedVertexHandle, usize> = FastMap::default();
993    for (s, site) in sites.iter().enumerate() {
994        for p in site.samples(spacing, tol)? {
995            let handle = dt.insert(SpadePoint::new(p.x, p.y)).map_err(|e| {
996                ogeom_core::ogeom_err!(Construction, "sampling the boundary: {e:?}")
997            })?;
998            index_of.insert(handle, owner.len());
999            owner.push(s);
1000        }
1001    }
1002
1003    // Circumcentres inside the region, labelled by the sites they touch.
1004    let mut pair_samples: FastMap<(usize, usize), Vec<Point2>> = FastMap::default();
1005    let mut triples: Vec<(Point2, Vec<usize>)> = Vec::new();
1006    for face in dt.inner_faces() {
1007        let corners = face.vertices();
1008        let pts: Vec<Point2> = corners
1009            .iter()
1010            .map(|v| Point2::new(v.position().x, v.position().y))
1011            .collect();
1012        let Some(centre) = circumcentre(pts[0], pts[1], pts[2]) else {
1013            continue;
1014        };
1015        if !boundary.inside(centre) {
1016            continue;
1017        }
1018        let mut labels: Vec<usize> = corners
1019            .iter()
1020            .filter_map(|v| index_of.get(&v.fix()).map(|&i| owner[i]))
1021            .collect();
1022        labels.sort_unstable();
1023        labels.dedup();
1024        // Two sites meeting at one foot (a smooth join) are one boundary
1025        // there, not two: drop the pair.
1026        let mut distinct: Vec<usize> = Vec::new();
1027        for &s in &labels {
1028            let fs = sites[s].foot(centre, tol)?;
1029            let mut keep = true;
1030            for &t in &distinct {
1031                if sites[t].foot(centre, tol)?.distance(fs) <= spacing * 2.0 {
1032                    keep = false;
1033                }
1034            }
1035            if keep {
1036                distinct.push(s);
1037            }
1038        }
1039        // A branch point keeps every site it touches, feet shared or not:
1040        // where a reflex corner's point and its own edge share a foot, the
1041        // axis hands over from the one's branch to the other's there.
1042        if labels.len() >= 3 && distinct.len() >= 2 {
1043            triples.push((centre, labels));
1044        } else if distinct.len() == 2 {
1045            pair_samples
1046                .entry((distinct[0], distinct[1]))
1047                .or_default()
1048                .push(centre);
1049        }
1050    }
1051
1052    // Solve each triple before merging coincident solutions: nearby branch
1053    // points can have different sites with no common equidistant centre.
1054    let merge = spacing * 4.0;
1055    let mut nodes: Vec<Node> = Vec::new();
1056    for (at, labels) in triples {
1057        // A centre naming only sites of a branch point found near it is
1058        // that branch point again.
1059        if nodes
1060            .iter()
1061            .any(|n| n.at.distance(at) <= merge && labels.iter().all(|s| n.sites.contains(s)))
1062        {
1063            continue;
1064        }
1065        let Some(node) = branch_point(sites, &labels, at, merge, spacing, tolerance, tol)? else {
1066            continue;
1067        };
1068        if let Some(found) = nodes
1069            .iter_mut()
1070            .find(|n| n.at.distance(node.at) <= tolerance)
1071        {
1072            for s in node.sites {
1073                if !found.sites.contains(&s) {
1074                    found.sites.push(s);
1075                }
1076            }
1077            continue;
1078        }
1079        nodes.push(node);
1080    }
1081    // A full circle holds its own centre, equidistant from all of it.
1082    for (s, site) in sites.iter().enumerate() {
1083        if let SiteKind::Arc {
1084            centre,
1085            radius,
1086            sweep,
1087            ..
1088        } = site.kind
1089            && sweep >= core::f64::consts::TAU - 1e-9
1090            && boundary.inside(centre)
1091            && !nodes.iter().any(|n| n.at.distance(centre) <= merge)
1092        {
1093            let nearer = sites.iter().enumerate().any(|(t, other)| {
1094                t != s
1095                    && other
1096                        .distance(centre, tol)
1097                        .is_ok_and(|d| d < radius - tolerance)
1098            });
1099            if !nearer {
1100                nodes.push(Node {
1101                    at: centre,
1102                    sites: vec![s],
1103                    boundary: false,
1104                });
1105            }
1106        }
1107    }
1108    // Boundary ends: convex corners and reflex corners.
1109    for &(at, s1, s2) in &boundary.convex {
1110        nodes.push(Node {
1111            at,
1112            sites: vec![s1, s2],
1113            boundary: true,
1114        });
1115    }
1116    for &(point_site, s1, s2) in &boundary.reflex {
1117        let SiteKind::Point { at } = sites[point_site].kind else {
1118            continue;
1119        };
1120        nodes.push(Node {
1121            at,
1122            sites: vec![point_site, s1, s2],
1123            boundary: true,
1124        });
1125    }
1126
1127    let mut vertices: Vec<MedialVertex> = Vec::with_capacity(nodes.len());
1128    for node in &nodes {
1129        let clearance = if node.boundary {
1130            0.0
1131        } else {
1132            sites[node.sites[0]].distance(node.at, tol)?
1133        };
1134        vertices.push(MedialVertex {
1135            point: lift(&boundary.frame, node.at),
1136            clearance,
1137        });
1138    }
1139
1140    // Branches: each pair's bisector between consecutive ends along it that
1141    // the sampling puts material between.
1142    let mut branches: Vec<MedialBranch> = Vec::new();
1143    let mut branch_sites: Vec<[usize; 2]> = Vec::new();
1144    let mut pairs: Vec<(usize, usize)> = pair_samples.keys().copied().collect();
1145    pairs.sort_unstable();
1146    for pair in pairs {
1147        let samples = &pair_samples[&pair];
1148        let ends: Vec<usize> = (0..nodes.len())
1149            .filter(|&i| nodes[i].sites.contains(&pair.0) && nodes[i].sites.contains(&pair.1))
1150            .collect();
1151        let exact = match (sites[pair.0].primitive(), sites[pair.1].primitive()) {
1152            (Some(p), Some(q)) => Bisector::between(p, q),
1153            _ => None,
1154        };
1155        let Some(bisector) = exact else {
1156            // No closed form: a fitted branch between exactly two ends.
1157            if ends.len() != 2 {
1158                if samples
1159                    .iter()
1160                    .all(|s| nodes.iter().any(|n| n.at.distance(*s) <= merge))
1161                {
1162                    continue;
1163                }
1164                ogeom_bail!(
1165                    NotDone,
1166                    "a fitted branch found {} ends where it needs two",
1167                    ends.len()
1168                );
1169            }
1170            let curve = fitted_branch(
1171                sites,
1172                pair,
1173                (nodes[ends[0]].at, nodes[ends[1]].at),
1174                samples,
1175                &boundary.frame,
1176                tolerance,
1177                tol,
1178            )?;
1179            let range = curve.domain();
1180            branches.push(MedialBranch {
1181                curve,
1182                range,
1183                ends: [ends[0], ends[1]],
1184                sites: [sites[pair.0].origin.clone(), sites[pair.1].origin.clone()],
1185            });
1186            branch_sites.push([pair.0, pair.1]);
1187            continue;
1188        };
1189        // A closed bisector no branch point touches (a washer's, between
1190        // its two circles) is one closed branch: a single vertex on it,
1191        // where the ellipse's major axis meets it on the second site's
1192        // side, both ends of a full period.
1193        if ends.is_empty() && bisector.periodic() {
1194            let at = bisector.at(0.0);
1195            let index = vertices.len();
1196            vertices.push(MedialVertex {
1197                point: lift(&boundary.frame, at),
1198                clearance: sites[pair.0].distance(at, tol)?,
1199            });
1200            nodes.push(Node {
1201                at,
1202                sites: vec![pair.0, pair.1],
1203                boundary: false,
1204            });
1205            let range = (0.0, core::f64::consts::TAU);
1206            branches.push(MedialBranch {
1207                curve: bisector.curve(&boundary.frame, range, tol)?,
1208                range,
1209                ends: [index, index],
1210                sites: [sites[pair.0].origin.clone(), sites[pair.1].origin.clone()],
1211            });
1212            branch_sites.push([pair.0, pair.1]);
1213            continue;
1214        }
1215        let unwrap = |t: f64, about: f64| -> f64 {
1216            if bisector.periodic() {
1217                let tau = core::f64::consts::TAU;
1218                about + (t - about + core::f64::consts::PI).rem_euclid(tau) - core::f64::consts::PI
1219            } else {
1220                t
1221            }
1222        };
1223        let about = bisector.param(samples[0]);
1224        let mut at_ends: Vec<(f64, usize)> = ends
1225            .iter()
1226            .map(|&i| (unwrap(bisector.param(nodes[i].at), about), i))
1227            .collect();
1228        at_ends.sort_by(|a, b| a.0.total_cmp(&b.0));
1229        let params: Vec<f64> = samples
1230            .iter()
1231            .map(|s| unwrap(bisector.param(*s), about))
1232            .collect();
1233        for w in at_ends.windows(2) {
1234            let ((t0, i0), (t1, i1)) = (w[0], w[1]);
1235            if (t1 - t0).abs() <= 1e-12 {
1236                continue;
1237            }
1238            // Samples strictly between the two ends, clear of both.
1239            let between = params.iter().zip(samples).any(|(t, s)| {
1240                *t > t0
1241                    && *t < t1
1242                    && s.distance(nodes[i0].at) > spacing
1243                    && s.distance(nodes[i1].at) > spacing
1244            });
1245            if !between {
1246                continue;
1247            }
1248            let curve = bisector.curve(&boundary.frame, (t0, t1), tol)?;
1249            let range = if matches!(bisector, Bisector::Line { .. }) {
1250                curve.domain()
1251            } else {
1252                (t0, t1)
1253            };
1254            branches.push(MedialBranch {
1255                curve,
1256                range,
1257                ends: [i0, i1],
1258                sites: [sites[pair.0].origin.clone(), sites[pair.1].origin.clone()],
1259            });
1260            branch_sites.push([pair.0, pair.1]);
1261        }
1262    }
1263
1264    // The check: along every branch no third site nearer than its own two,
1265    // and every sampled centre on some branch.
1266    let mut deviation = 0.0_f64;
1267    for (b, branch) in branches.iter().enumerate() {
1268        let [s1, s2] = branch_sites[b];
1269        for k in 1..16 {
1270            let t = branch.range.0 + (branch.range.1 - branch.range.0) * f64::from(k) / 16.0;
1271            let p = flat(&boundary.frame, branch.curve.point_at(t, tol)?);
1272            let own = sites[s1].distance(p, tol)?;
1273            let other = sites[s2].distance(p, tol)?;
1274            deviation = deviation.max((own - other).abs());
1275            for (s, site) in sites.iter().enumerate() {
1276                if s == s1 || s == s2 {
1277                    continue;
1278                }
1279                let d = site.distance(p, tol)?;
1280                deviation = deviation.max(own - d);
1281            }
1282        }
1283    }
1284    if deviation > tolerance {
1285        ogeom_bail!(
1286            NotDone,
1287            "a branch strays {deviation} nearer another site than its own"
1288        );
1289    }
1290    for (pair, samples) in &pair_samples {
1291        for s in samples {
1292            if nodes.iter().any(|n| n.at.distance(*s) <= merge) {
1293                continue;
1294            }
1295            let on_some = branches.iter().enumerate().any(|(b, _)| {
1296                let [a, c] = branch_sites[b];
1297                (a, c) == *pair
1298            });
1299            if !on_some {
1300                ogeom_bail!(NotDone, "sampled axis points lie on no branch");
1301            }
1302        }
1303    }
1304    Ok(MedialGraph {
1305        vertices,
1306        branches,
1307        deviation,
1308        frame: boundary.frame,
1309        sites: sites.clone(),
1310        branch_sites,
1311    })
1312}
1313
1314fn circumcentre(a: Point2, b: Point2, c: Point2) -> Option<Point2> {
1315    let d = 2.0 * (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y));
1316    if d.abs() <= 1e-300 {
1317        return None;
1318    }
1319    let (a2, b2, c2) = (
1320        a.x * a.x + a.y * a.y,
1321        b.x * b.x + b.y * b.y,
1322        c.x * c.x + c.y * c.y,
1323    );
1324    Some(Point2::new(
1325        (a2 * (b.y - c.y) + b2 * (c.y - a.y) + c2 * (a.y - b.y)) / d,
1326        (a2 * (c.x - b.x) + b2 * (a.x - c.x) + c2 * (b.x - a.x)) / d,
1327    ))
1328}
1329
1330/// The branch point the sites `labels` of one sampled centre `at` name,
1331/// solved exactly, with the sites that truly stand nearest there.
1332///
1333/// `None` where they meet in no branch point: a centre beside a branch can
1334/// name a third site a sampling's width farther, and no point near it is
1335/// equidistant from all three, so the sites farther than the nearest are
1336/// dropped and a point left with fewer than three is no branch point; nor
1337/// is one that another site of the boundary comes nearer to than its own.
1338fn branch_point(
1339    sites: &[Site],
1340    labels: &[usize],
1341    at: Point2,
1342    merge: f64,
1343    spacing: f64,
1344    tolerance: f64,
1345    tol: Tolerances,
1346) -> OgeomResult<Option<Node>> {
1347    // A branch point touching an arc the region lies inside may be the
1348    // arc's own centre, where the arc's distance has no gradient for Newton
1349    // to follow: tried first, and kept where every site stands at one
1350    // distance from it.
1351    let centre_of = labels.iter().find_map(|&s| match sites[s].kind {
1352        SiteKind::Arc {
1353            centre,
1354            sweep,
1355            radius,
1356            ..
1357        } if sweep > 0.0 && centre.distance(at) <= merge => Some((centre, radius)),
1358        _ => None,
1359    });
1360    let mut point = None;
1361    if let Some((centre, radius)) = centre_of {
1362        let mut agrees = true;
1363        for &s in labels {
1364            agrees &= (sites[s].distance(centre, tol)? - radius).abs() <= tol.confusion();
1365        }
1366        if agrees {
1367            point = Some(centre);
1368        }
1369    }
1370    let point = match point {
1371        Some(centre) => centre,
1372        None => solve_node(sites, labels, at, spacing, tol)?,
1373    };
1374    let mut distances = Vec::with_capacity(labels.len());
1375    for &s in labels {
1376        distances.push((s, sites[s].distance(point, tol)?));
1377    }
1378    let nearest = distances.iter().map(|d| d.1).fold(f64::INFINITY, f64::min);
1379    let kept: Vec<usize> = distances
1380        .iter()
1381        .filter(|(_, d)| *d <= nearest + tol.confusion() * 10.0)
1382        .map(|(s, _)| *s)
1383        .collect();
1384    let whole_circle = kept.len() == 1
1385        && matches!(sites[kept[0]].kind, SiteKind::Arc { sweep, .. } if sweep >= core::f64::consts::TAU - 1e-9);
1386    if kept.len() < 3 && !whole_circle {
1387        return Ok(None);
1388    }
1389    for (s, site) in sites.iter().enumerate() {
1390        if !labels.contains(&s) && site.distance(point, tol)? < nearest - tolerance {
1391            return Ok(None);
1392        }
1393    }
1394    Ok(Some(Node {
1395        at: point,
1396        sites: kept,
1397        boundary: false,
1398    }))
1399}
1400
1401/// The point equidistant from a branch point's sites, by Newton from the
1402/// sampled estimate: the first site's distance against each other's.
1403fn solve_node(
1404    sites: &[Site],
1405    labels: &[usize],
1406    guess: Point2,
1407    spacing: f64,
1408    tol: Tolerances,
1409) -> OgeomResult<Point2> {
1410    if labels.len() < 3 {
1411        return Ok(guess);
1412    }
1413    // A handover: two of the sites share a foot (a reflex corner and its
1414    // own edge, a line and the arc it runs on into), so their distances
1415    // agree all along the normal there, and the axis passes from one to
1416    // the other where that normal meets a third site's bisector.
1417    let mut feet = Vec::with_capacity(labels.len());
1418    for &s in labels {
1419        feet.push(sites[s].foot(guess, tol)?);
1420    }
1421    for i in 0..labels.len() {
1422        for j in i + 1..labels.len() {
1423            if feet[i].distance(feet[j]) > spacing * 2.0 {
1424                continue;
1425            }
1426            let Some(k) = (0..labels.len())
1427                .find(|&k| k != i && k != j && feet[k].distance(feet[i]) > spacing * 2.0)
1428            else {
1429                continue;
1430            };
1431            let joint = shared_joint(&sites[labels[i]], &sites[labels[j]]);
1432            let normal = shared_normal(&sites[labels[i]], &sites[labels[j]], joint, guess, tol);
1433            let third = &sites[labels[k]];
1434            let mut t = (guess - joint).dot(normal);
1435            for _ in 0..60 {
1436                let p = joint + normal * t;
1437                let f = third.distance(p, tol)? - t;
1438                let h = (t.abs() + 1.0) * 1e-7;
1439                let g = (third.distance(joint + normal * (t + h), tol)? - (t + h) - f) / h;
1440                if g.abs() <= 1e-300 {
1441                    break;
1442                }
1443                let step = f / g;
1444                t -= step;
1445                if step.abs() <= tol.confusion() * 1e-4 {
1446                    break;
1447                }
1448            }
1449            return Ok(joint + normal * t);
1450        }
1451    }
1452    let gradient = |s: usize, p: Point2| -> OgeomResult<(f64, Vector2)> {
1453        let foot = sites[s].foot(p, tol)?;
1454        let d = p - foot;
1455        let m = d.magnitude();
1456        Ok((
1457            m,
1458            if m > 0.0 {
1459                d * (1.0 / m)
1460            } else {
1461                Vector2::new(0.0, 0.0)
1462            },
1463        ))
1464    };
1465    let mut p = guess;
1466    for _ in 0..60 {
1467        let (d0, g0) = gradient(labels[0], p)?;
1468        // Least squares over every other site: a branch point of four
1469        // sites (a square's centre) is still one point.
1470        let (mut jtj, mut jtf) = ([[0.0_f64; 2]; 2], [0.0_f64; 2]);
1471        let mut worst = 0.0_f64;
1472        for &s in &labels[1..] {
1473            let (d, g) = gradient(s, p)?;
1474            let f = d0 - d;
1475            let row = g0 - g;
1476            worst = worst.max(f.abs());
1477            jtj[0][0] += row.x * row.x;
1478            jtj[0][1] += row.x * row.y;
1479            jtj[1][0] += row.y * row.x;
1480            jtj[1][1] += row.y * row.y;
1481            jtf[0] += row.x * f;
1482            jtf[1] += row.y * f;
1483        }
1484        if worst <= tol.confusion() * 1e-3 {
1485            return Ok(p);
1486        }
1487        let det = jtj[0][0] * jtj[1][1] - jtj[0][1] * jtj[1][0];
1488        if det.abs() <= 1e-300 {
1489            break;
1490        }
1491        let dx = (jtj[1][1] * jtf[0] - jtj[0][1] * jtf[1]) / det;
1492        let dy = (jtj[0][0] * jtf[1] - jtj[1][0] * jtf[0]) / det;
1493        p = Point2::new(p.x - dx, p.y - dy);
1494    }
1495    Ok(p)
1496}
1497
1498/// Where two sites meet: a corner's own point, or the ends of the two
1499/// that coincide.
1500fn shared_joint(a: &Site, b: &Site) -> Point2 {
1501    let ends = |site: &Site| -> Vec<Point2> {
1502        match &site.kind {
1503            SiteKind::Segment { a, b } => vec![*a, *b],
1504            SiteKind::Arc {
1505                centre,
1506                radius,
1507                start,
1508                sweep,
1509            } => {
1510                let at = |angle: f64| *centre + Vector2::new(angle.cos(), angle.sin()) * *radius;
1511                vec![at(*start), at(start + sweep)]
1512            }
1513            SiteKind::Point { at } => vec![*at],
1514            SiteKind::Curve { polyline, .. } => {
1515                vec![polyline[0].1, polyline[polyline.len() - 1].1]
1516            }
1517        }
1518    };
1519    let (ea, eb) = (ends(a), ends(b));
1520    let mut best = (f64::INFINITY, ea[0]);
1521    for p in &ea {
1522        for q in &eb {
1523            if p.distance(*q) < best.0 {
1524                best = (p.distance(*q), if eb.len() == 1 { *q } else { *p });
1525            }
1526        }
1527    }
1528    best.1
1529}
1530
1531/// The inward normal two sites share at their common foot: a segment's
1532/// own, an arc's radius, and otherwise the direction from the foot to the
1533/// estimate.
1534fn shared_normal(a: &Site, b: &Site, joint: Point2, guess: Point2, tol: Tolerances) -> Vector2 {
1535    let own = |site: &Site| -> Option<Vector2> {
1536        match site.kind {
1537            SiteKind::Segment { a, b } => {
1538                let d = b - a;
1539                Vector2::new(-d.y, d.x).normalized(tol).ok()
1540            }
1541            SiteKind::Arc { centre, sweep, .. } => {
1542                let radial = (centre - joint).normalized(tol).ok()?;
1543                Some(if sweep > 0.0 { radial } else { -radial })
1544            }
1545            _ => None,
1546        }
1547    };
1548    own(a)
1549        .or_else(|| own(b))
1550        .or_else(|| (guess - joint).normalized(tol).ok())
1551        .unwrap_or(Vector2::new(0.0, 1.0))
1552}
1553
1554/// A branch with no closed form: points solved onto the bisector exactly,
1555/// ordered from one end to the other, and fitted.
1556fn fitted_branch(
1557    sites: &[Site],
1558    pair: (usize, usize),
1559    ends: (Point2, Point2),
1560    samples: &[Point2],
1561    frame: &Frame,
1562    tolerance: f64,
1563    tol: Tolerances,
1564) -> OgeomResult<ogeom_geom::Curve> {
1565    let f = |p: Point2| -> OgeomResult<(f64, Vector2)> {
1566        let (fa, fb) = (sites[pair.0].foot(p, tol)?, sites[pair.1].foot(p, tol)?);
1567        let (da, db) = (p - fa, p - fb);
1568        let (ma, mb) = (da.magnitude(), db.magnitude());
1569        let g = da * (1.0 / ma.max(1e-300)) - db * (1.0 / mb.max(1e-300));
1570        Ok((ma - mb, g))
1571    };
1572    let chord = ends.1 - ends.0;
1573    let mut placed: Vec<(f64, Point2)> = Vec::with_capacity(samples.len());
1574    for s in samples {
1575        let mut p = *s;
1576        for _ in 0..30 {
1577            let (value, g) = f(p)?;
1578            let gg = g.dot(g);
1579            if value.abs() <= tol.confusion() * 1e-3 || gg <= 1e-300 {
1580                break;
1581            }
1582            p -= g * (value / gg);
1583        }
1584        placed.push(((p - ends.0).dot(chord), p));
1585    }
1586    placed.sort_by(|a, b| a.0.total_cmp(&b.0));
1587    let mut points: Vec<Point> = vec![lift(frame, ends.0)];
1588    for (_, p) in &placed {
1589        if p.distance(ends.0) > tol.confusion() * 10.0
1590            && p.distance(ends.1) > tol.confusion() * 10.0
1591        {
1592            points.push(lift(frame, *p));
1593        }
1594    }
1595    points.push(lift(frame, ends.1));
1596    let fitted = ogeom_geom::fit::fit_points(&points, 3, tolerance * 0.5, tol)?;
1597    Ok(ogeom_geom::Curve::BSpline(fitted.curve))
1598}