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 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 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 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 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 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 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 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 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
199fn 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
208fn rider_speed(e_in: Vector2, e_out: Vector2) -> f64 {
210 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
220fn 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}