1use 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#[derive(Debug, Clone)]
26pub struct MedialAxis {
27 pub segments: Vec<(Point, Point)>,
30 pub clearance: Vec<f64>,
33}
34
35pub 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 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 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 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 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 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 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 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 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 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 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
223fn 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
232fn rider_speed(e_in: Vector2, e_out: Vector2) -> f64 {
234 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
244fn 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
261pub(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 #[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}