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