Skip to main content

ogeom_algo/
medial.rs

1//! The medial axis of a planar region: the locus of centres of maximal
2//! inscribed circles: what tool-path generation and midline extraction are
3//! built on.
4//!
5//! For a **convex** polygon the medial axis coincides with the straight
6//! skeleton, and the shrinking-polygon construction computes it exactly:
7//! every edge moves inward at unit speed, every vertex rides its angular
8//! bisector, and each event (two neighbouring bisectors meeting) retires
9//! an edge and starts a new skeleton branch. Convexity is what makes this
10//! exact: no reflex vertex, so no split events, so every branch is a
11//! straight segment between circumcentre-like meets.
12//!
13//! Everything else is refused here by name: a face with holes, a reflex
14//! corner, a curved boundary. Each of those gives the axis curved branches
15//! (a reflex corner bisects its far walls along parabolas, an arc along
16//! conics), which straight segments cannot hold; [`medial_graph`](fn@crate::medial_graph)
17//! builds them exactly.
18
19use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
20use ogeom_geom::Curve3d as _;
21use ogeom_math::{Point, Point2, Vector2};
22use ogeom_topo::{EdgeRepr, Model, NodeData, Shape};
23
24/// The medial axis of a face, as straight segments between branch points.
25#[derive(Debug, Clone)]
26pub struct MedialAxis {
27    /// The skeleton's segments, each an inward branch: from a boundary
28    /// vertex or an earlier meet, to a meet or the centre.
29    pub segments: Vec<(Point, Point)>,
30    /// The inscribed-circle radius at each segment's inner end: the
31    /// clearance a tool of that radius has there.
32    pub clearance: Vec<f64>,
33}
34
35/// The medial axis of a convex planar polygonal face.
36///
37/// # Errors
38///
39/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction), each
40/// by name: a face that is not planar, carries inner wires, has a curved
41/// edge, or turns a reflex corner. Those have curved branches, which
42/// [`medial_graph`](fn@crate::medial_graph) builds.
43pub fn medial_axis(model: &Model, face: &Shape, tol: Tolerances) -> OgeomResult<MedialAxis> {
44    let Some(data) = model.node(face).and_then(|n| match n.data() {
45        NodeData::Face(d) => Some(d.clone()),
46        _ => None,
47    }) else {
48        ogeom_bail!(Construction, "the shape is not a face");
49    };
50    let Some(ogeom_geom::SurfaceGeometry::Plane(plane)) = model.geometry().surface(data.surface)
51    else {
52        ogeom_bail!(
53            Construction,
54            "the medial axis is computed for planar faces; this face's \
55             surface is not a plane"
56        );
57    };
58    let frame = plane.plane().frame();
59    let placement = face.transform(model.datums())?;
60
61    let wires = model.ordered_children_of(face)?;
62    if wires.len() != 1 {
63        ogeom_bail!(
64            Construction,
65            "a face with inner wires has curved medial branches; \
66             medial_graph builds them"
67        );
68    }
69    let mut ring: Vec<Point2> = Vec::new();
70    for edge in model.ordered_children_of(&wires[0])? {
71        let Some(edge_data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
72            continue;
73        };
74        let Some(EdgeRepr::Curve3d { curve, range, .. }) = edge_data.curve3d() else {
75            ogeom_bail!(Construction, "a boundary edge carries no curve");
76        };
77        let Some(geometry) = model.geometry().curve(*curve) else {
78            ogeom_bail!(Construction, "a boundary curve is not in this model");
79        };
80        if !matches!(geometry, ogeom_geom::Curve::Line(_)) {
81            ogeom_bail!(
82                Construction,
83                "a curved boundary bisects along conics; medial_graph \
84                 builds them"
85            );
86        }
87        let (t0, t1) = if edge.orientation() == ogeom_topo::Orientation::Reversed {
88            (range.1, range.0)
89        } else {
90            (range.0, range.1)
91        };
92        let start = placement.apply(geometry.point_at(t0, tol)?);
93        let _ = t1;
94        let local = frame.to_local(start);
95        ring.push(Point2::new(local.x, local.y));
96    }
97    if ring.len() < 3 {
98        ogeom_bail!(Construction, "a polygon needs three corners");
99    }
100    // Wind counter-clockwise, so inward is to the left.
101    if signed_area(&ring) < 0.0 {
102        ring.reverse();
103    }
104    for i in 0..ring.len() {
105        let a = ring[i];
106        let b = ring[(i + 1) % ring.len()];
107        let c = ring[(i + 2) % ring.len()];
108        let cross = (b - a).cross(c - b);
109        if cross < -tol.confusion() {
110            ogeom_bail!(
111                Construction,
112                "a reflex corner bisects along parabolas; medial_graph \
113                 builds them"
114            );
115        }
116    }
117
118    // The shrink loop. Each active vertex rides the bisector of its two
119    // neighbouring edges; the earliest meeting of adjacent riders retires
120    // the edge between them.
121    let lift = |p: Point2, out: &mut Vec<(Point, Point)>, q: Point2| {
122        let a = frame.origin() + frame.x().vector() * p.x + frame.y().vector() * p.y;
123        let b = frame.origin() + frame.x().vector() * q.x + frame.y().vector() * q.y;
124        out.push((a, b));
125    };
126    let mut segments: Vec<(Point, Point)> = Vec::new();
127    let mut clearance: Vec<f64> = Vec::new();
128    // Active loop: (position, the two edge directions meeting there).
129    let n = ring.len();
130    let mut active: Vec<(Point2, Vector2, Vector2, f64)> = Vec::with_capacity(n);
131    for i in 0..n {
132        let prev = ring[(i + n - 1) % n];
133        let here = ring[i];
134        let next = ring[(i + 1) % n];
135        let e_in = (here - prev).normalized(tol)?;
136        let e_out = (next - here).normalized(tol)?;
137        active.push((here, e_in, e_out, 0.0));
138    }
139
140    while active.len() > 2 {
141        // The earliest adjacent meet.
142        let m = active.len();
143        let mut best: Option<(usize, Point2, f64)> = None;
144        for i in 0..m {
145            let j = (i + 1) % m;
146            let (pi, ei_in, ei_out, ti) = active[i];
147            let (pj, ej_in, ej_out, tj) = active[j];
148            let bi = bisector_dir(ei_in, ei_out);
149            let bj = bisector_dir(ej_in, ej_out);
150            let Some(meet) = ray_meet(pi, bi, pj, bj) else {
151                continue;
152            };
153            // The event time is the inward distance of the shared edge:
154            // both riders reach the meet as that edge's offset sweeps it.
155            let speed_i = rider_speed(ei_in, ei_out);
156            let t = ti + (meet - pi).magnitude() / speed_i;
157            let _ = tj;
158            let _ = ej_in;
159            let _ = ej_out;
160            if best.as_ref().is_none_or(|(_, _, held)| t < *held) {
161                best = Some((i, meet, t));
162            }
163        }
164        let Some((i, meet, t)) = best else {
165            ogeom_bail!(
166                Construction,
167                "the shrink found no event; the ring is degenerate"
168            );
169        };
170        let j = (i + 1) % active.len();
171        let (pi, ei_in, _, _) = active[i];
172        let (pj, _, ej_out, _) = active[j];
173        lift(pi, &mut segments, meet);
174        lift(pj, &mut segments, meet);
175        clearance.push(t);
176        clearance.push(t);
177        // The two riders merge into one on the bisector of the surviving
178        // outer edges.
179        let merged = (meet, ei_in, ej_out, t);
180        if i < j {
181            active[i] = merged;
182            active.remove(j);
183        } else {
184            active[j] = merged;
185            active.remove(i);
186        }
187    }
188    // Two riders left: they close on one another along the shared axis.
189    if let [a, b] = active.as_slice() {
190        lift(a.0, &mut segments, b.0);
191        clearance.push(a.3.max(b.3));
192    }
193    Ok(MedialAxis {
194        segments,
195        clearance,
196    })
197}
198
199/// The inward bisector direction at a corner between edge directions.
200fn bisector_dir(e_in: Vector2, e_out: Vector2) -> Vector2 {
201    let n_in = Vector2::new(-e_in.y, e_in.x);
202    let n_out = Vector2::new(-e_out.y, e_out.x);
203    (n_in + n_out)
204        .normalized(Tolerances::millimetres())
205        .unwrap_or(n_in)
206}
207
208/// How fast a rider moves along its bisector per unit of inward offset.
209fn rider_speed(e_in: Vector2, e_out: Vector2) -> f64 {
210    // The bisector makes angle θ/2 with each edge normal, where θ is the
211    // turn; unit inward speed of the edges means 1/cos(θ/2) along it, but
212    // 1/sin(half interior angle) in edge terms. Derived from the offset of
213    // both edges staying on the rider.
214    let n_in = Vector2::new(-e_in.y, e_in.x);
215    let b = bisector_dir(e_in, e_out);
216    let denom = b.dot(n_in).max(1e-12);
217    1.0 / denom
218}
219
220/// Where two rays meet, `None` when parallel or behind either origin.
221fn ray_meet(p: Point2, d: Point2Dir, q: Point2, e: Point2Dir) -> Option<Point2> {
222    let denom = d.cross(e);
223    if denom.abs() < 1e-14 {
224        return None;
225    }
226    let w = q - p;
227    let t = w.cross(e) / denom;
228    let s = w.cross(d) / denom;
229    if t < -1e-9 || s < -1e-9 {
230        return None;
231    }
232    Some(p + d * t)
233}
234
235type Point2Dir = Vector2;
236
237fn signed_area(ring: &[Point2]) -> f64 {
238    let mut sum = 0.0;
239    for i in 0..ring.len() {
240        let a = ring[i];
241        let b = ring[(i + 1) % ring.len()];
242        sum += a.x * b.y - b.x * a.y;
243    }
244    sum / 2.0
245}