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    // The plane where the face stands: its frame carried by the face's
59    // placement, so the boundary is read, and the axis lifted back, there.
60    let placement = face.transform(model.datums())?;
61    let frame = placement.apply_frame(&plane.plane().frame(), tol)?;
62
63    let wires = model.ordered_children_of(face)?;
64    if wires.len() != 1 {
65        ogeom_bail!(
66            Construction,
67            "a face with inner wires has curved medial branches; \
68             medial_graph builds them"
69        );
70    }
71    let mut ring: Vec<Point2> = Vec::new();
72    for edge in model.ordered_children_of(&wires[0])? {
73        let Some(edge_data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
74            continue;
75        };
76        let Some(EdgeRepr::Curve3d { curve, range, .. }) = edge_data.curve3d() else {
77            ogeom_bail!(Construction, "a boundary edge carries no curve");
78        };
79        let Some(geometry) = model.geometry().curve(*curve) else {
80            ogeom_bail!(Construction, "a boundary curve is not in this model");
81        };
82        if !matches!(geometry, ogeom_geom::Curve::Line(_)) {
83            ogeom_bail!(
84                Construction,
85                "a curved boundary bisects along conics; medial_graph \
86                 builds them"
87            );
88        }
89        let (t0, t1) = if edge.orientation() == ogeom_topo::Orientation::Reversed {
90            (range.1, range.0)
91        } else {
92            (range.0, range.1)
93        };
94        let start = edge
95            .transform(model.datums())?
96            .apply(geometry.point_at(t0, tol)?);
97        let _ = t1;
98        let local = frame.to_local(start);
99        ring.push(Point2::new(local.x, local.y));
100    }
101    // How far a corner stands off the line through its neighbours, signed
102    // to the left of the run: a distance, so it is held to a length.
103    let turn = |a: Point2, b: Point2, c: Point2| -> f64 {
104        let chord = (c - a).magnitude();
105        if chord <= tol.confusion() {
106            return f64::INFINITY;
107        }
108        (b - a).cross(c - a) / chord
109    };
110    // A corner on a straight run is no corner: its rider would never meet
111    // a neighbour, and the axis is the run's without it.
112    let mut i = 0;
113    while ring.len() > 3 && i < ring.len() {
114        let n = ring.len();
115        let (a, b, c) = (ring[(i + n - 1) % n], ring[i], ring[(i + 1) % n]);
116        if turn(a, b, c).abs() <= tol.confusion() && (b - a).dot(c - b) > 0.0 {
117            ring.remove(i);
118        } else {
119            i += 1;
120        }
121    }
122    if ring.len() < 3 {
123        ogeom_bail!(Construction, "a polygon needs three corners");
124    }
125    // Wind counter-clockwise, so inward is to the left.
126    if signed_area(&ring) < 0.0 {
127        ring.reverse();
128    }
129    for i in 0..ring.len() {
130        let a = ring[i];
131        let b = ring[(i + 1) % ring.len()];
132        let c = ring[(i + 2) % ring.len()];
133        if turn(a, b, c) < -tol.confusion() {
134            ogeom_bail!(
135                Construction,
136                "a reflex corner bisects along parabolas; medial_graph \
137                 builds them"
138            );
139        }
140    }
141
142    // The shrink loop. Each active vertex rides the bisector of its two
143    // neighbouring edges; the earliest meeting of adjacent riders retires
144    // the edge between them.
145    let lift = |p: Point2, out: &mut Vec<(Point, Point)>, q: Point2| {
146        let a = frame.origin() + frame.x().vector() * p.x + frame.y().vector() * p.y;
147        let b = frame.origin() + frame.x().vector() * q.x + frame.y().vector() * q.y;
148        out.push((a, b));
149    };
150    let mut segments: Vec<(Point, Point)> = Vec::new();
151    let mut clearance: Vec<f64> = Vec::new();
152    // Active loop: (position, the two edge directions meeting there).
153    let n = ring.len();
154    let mut active: Vec<(Point2, Vector2, Vector2, f64)> = Vec::with_capacity(n);
155    for i in 0..n {
156        let prev = ring[(i + n - 1) % n];
157        let here = ring[i];
158        let next = ring[(i + 1) % n];
159        let e_in = (here - prev).normalized(tol)?;
160        let e_out = (next - here).normalized(tol)?;
161        active.push((here, e_in, e_out, 0.0));
162    }
163
164    while active.len() > 2 {
165        // The earliest adjacent meet.
166        let m = active.len();
167        let mut best: Option<(usize, Point2, f64)> = None;
168        for i in 0..m {
169            let j = (i + 1) % m;
170            let (pi, ei_in, ei_out, ti) = active[i];
171            let (pj, ej_in, ej_out, tj) = active[j];
172            let bi = bisector_dir(ei_in, ei_out);
173            let bj = bisector_dir(ej_in, ej_out);
174            let Some(meet) = ray_meet(pi, bi, pj, bj) else {
175                continue;
176            };
177            // The event time is the inward distance of the shared edge:
178            // both riders reach the meet as that edge's offset sweeps it.
179            let speed_i = rider_speed(ei_in, ei_out);
180            let t = ti + (meet - pi).magnitude() / speed_i;
181            let _ = tj;
182            let _ = ej_in;
183            let _ = ej_out;
184            if best.as_ref().is_none_or(|(_, _, held)| t < *held) {
185                best = Some((i, meet, t));
186            }
187        }
188        let Some((i, meet, t)) = best else {
189            ogeom_bail!(
190                Construction,
191                "the shrink found no event; the ring is degenerate"
192            );
193        };
194        let j = (i + 1) % active.len();
195        let (pi, ei_in, _, _) = active[i];
196        let (pj, _, ej_out, _) = active[j];
197        lift(pi, &mut segments, meet);
198        lift(pj, &mut segments, meet);
199        clearance.push(t);
200        clearance.push(t);
201        // The two riders merge into one on the bisector of the surviving
202        // outer edges.
203        let merged = (meet, ei_in, ej_out, t);
204        if i < j {
205            active[i] = merged;
206            active.remove(j);
207        } else {
208            active[j] = merged;
209            active.remove(i);
210        }
211    }
212    // Two riders left: they close on one another along the shared axis.
213    if let [a, b] = active.as_slice() {
214        lift(a.0, &mut segments, b.0);
215        clearance.push(a.3.max(b.3));
216    }
217    Ok(MedialAxis {
218        segments,
219        clearance,
220    })
221}
222
223/// The inward bisector direction at a corner between edge directions.
224fn bisector_dir(e_in: Vector2, e_out: Vector2) -> Vector2 {
225    let n_in = Vector2::new(-e_in.y, e_in.x);
226    let n_out = Vector2::new(-e_out.y, e_out.x);
227    (n_in + n_out)
228        .normalized(Tolerances::millimetres())
229        .unwrap_or(n_in)
230}
231
232/// How fast a rider moves along its bisector per unit of inward offset.
233fn rider_speed(e_in: Vector2, e_out: Vector2) -> f64 {
234    // The bisector makes angle θ/2 with each edge normal, where θ is the
235    // turn; unit inward speed of the edges means 1/cos(θ/2) along it, but
236    // 1/sin(half interior angle) in edge terms. Derived from the offset of
237    // both edges staying on the rider.
238    let n_in = Vector2::new(-e_in.y, e_in.x);
239    let b = bisector_dir(e_in, e_out);
240    let denom = b.dot(n_in).max(1e-12);
241    1.0 / denom
242}
243
244/// Where two rays meet, `None` when parallel or behind either origin.
245fn ray_meet(p: Point2, d: Point2Dir, q: Point2, e: Point2Dir) -> Option<Point2> {
246    let denom = d.cross(e);
247    if denom.abs() < 1e-14 {
248        return None;
249    }
250    let w = q - p;
251    let t = w.cross(e) / denom;
252    let s = w.cross(d) / denom;
253    if t < -1e-9 || s < -1e-9 {
254        return None;
255    }
256    Some(p + d * t)
257}
258
259type Point2Dir = Vector2;
260
261/// The area a ring encloses in its plane, positive counter-clockwise.
262///
263/// Summed about the ring's first point: the same sum as about the plane's
264/// origin, which may stand far off, where it is a difference of huge
265/// products.
266pub(crate) fn signed_area(ring: &[Point2]) -> f64 {
267    let Some(&anchor) = ring.first() else {
268        return 0.0;
269    };
270    let mut sum = 0.0;
271    for i in 0..ring.len() {
272        sum += (ring[i] - anchor).cross(ring[(i + 1) % ring.len()] - anchor);
273    }
274    sum / 2.0
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    /// A strip one long and a hundredth wide encloses its own area wherever
282    /// it stands in its plane, up to 2e7 from the plane's origin.
283    #[test]
284    fn a_ring_far_out_in_its_plane_encloses_its_own_area() {
285        for o in [0.0, 1e6, 1e7, 2e7] {
286            let (x, y) = (o, -0.7 * o);
287            let ring = [(0.0, 0.0), (1.0, 0.0), (1.0, 0.01), (0.0, 0.01)]
288                .map(|(dx, dy)| Point2::new(x + dx, y + dy));
289            let area = signed_area(&ring);
290            assert!((area - 0.01).abs() < 1e-9, "o {o:e}: area {area}");
291        }
292    }
293}