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