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