1use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
23use ogeom_geom::{Curve, Curve3d};
24use ogeom_math::Point;
25
26#[derive(Debug, Clone, Copy, PartialEq)]
28pub struct Deflection {
29 pub chord: f64,
31 pub angular: f64,
33 pub min_segments: usize,
44 pub max_segments: usize,
49}
50
51impl Default for Deflection {
52 fn default() -> Self {
53 Self {
54 chord: 1e-1,
60 angular: 0.5,
66 min_segments: 2,
67 max_segments: 4096,
68 }
69 }
70}
71
72impl Deflection {
73 pub fn with_chord(chord: f64) -> OgeomResult<Self> {
80 if !chord.is_finite() || chord <= 0.0 {
81 ogeom_bail!(
82 Construction,
83 "chord deflection {chord} must be finite and positive"
84 );
85 }
86 Ok(Self {
87 chord,
88 ..Self::default()
89 })
90 }
91
92 pub fn relative(size: f64, fraction: f64) -> OgeomResult<Self> {
103 if !size.is_finite() || size <= 0.0 || !fraction.is_finite() || fraction <= 0.0 {
104 ogeom_bail!(
105 Construction,
106 "relative deflection needs a positive size and fraction, got {size} and {fraction}"
107 );
108 }
109 Self::with_chord(size * fraction)
110 }
111
112 pub fn validate(&self) -> OgeomResult<()> {
119 if !self.chord.is_finite() || self.chord <= 0.0 {
120 ogeom_bail!(
121 Construction,
122 "chord deflection {} must be positive",
123 self.chord
124 );
125 }
126 if !self.angular.is_finite() || self.angular <= 0.0 {
127 ogeom_bail!(
128 Construction,
129 "angular deflection {} must be positive",
130 self.angular
131 );
132 }
133 if self.min_segments == 0 {
134 ogeom_bail!(Construction, "a polyline needs at least one segment");
135 }
136 if self.max_segments < self.min_segments {
137 ogeom_bail!(
138 Construction,
139 "segment ceiling {} is below the floor {}",
140 self.max_segments,
141 self.min_segments
142 );
143 }
144 Ok(())
145 }
146}
147
148#[derive(Debug, Clone, PartialEq)]
154pub struct Polyline {
155 pub points: Vec<Point>,
157 pub parameters: Vec<f64>,
159 pub deflection_met: bool,
165}
166
167impl Polyline {
168 #[must_use]
170 pub fn segment_count(&self) -> usize {
171 self.points.len().saturating_sub(1)
172 }
173
174 #[must_use]
179 pub fn length(&self) -> f64 {
180 self.points.windows(2).map(|w| w[0].distance(w[1])).sum()
181 }
182
183 #[must_use]
185 pub fn is_closed(&self, tol: Tolerances) -> bool {
186 match (self.points.first(), self.points.last()) {
187 (Some(a), Some(b)) => self.points.len() > 2 && a.is_equal(*b, tol),
188 _ => false,
189 }
190 }
191}
192
193#[must_use]
199pub fn is_straight(curve: &Curve) -> bool {
200 match curve {
201 Curve::Line(_) => true,
202 Curve::Trimmed(t) => is_straight(t.basis()),
203 _ => false,
204 }
205}
206
207#[must_use]
209pub fn is_straight_planar(curve: &ogeom_geom::PlanarCurve) -> bool {
210 match curve {
211 ogeom_geom::PlanarCurve::Line(_) => true,
212 ogeom_geom::PlanarCurve::Trimmed(t) => is_straight_planar(t.basis()),
213 _ => false,
214 }
215}
216
217pub fn discretize(
231 curve: &Curve,
232 range: (f64, f64),
233 deflection: Deflection,
234 tol: Tolerances,
235) -> OgeomResult<Polyline> {
236 deflection.validate()?;
237 let (lo, hi) = range;
238 if !lo.is_finite() || !hi.is_finite() || hi <= lo + tol.parametric() {
239 ogeom_bail!(Construction, "range [{lo}, {hi}] is empty");
240 }
241
242 let start = if is_straight(curve) {
246 1
247 } else {
248 deflection.min_segments
249 };
250 let mut parameters: Vec<f64> = (0..=start)
251 .map(|i| {
252 #[allow(clippy::cast_precision_loss)]
253 let t = i as f64 / start as f64;
254 lo + (hi - lo) * t
255 })
256 .collect();
257 let mut points: Vec<Point> = parameters
258 .iter()
259 .map(|u| curve.point_at(*u, tol))
260 .collect::<OgeomResult<_>>()?;
261
262 let mut met = true;
270 let seeds = core::mem::take(&mut parameters);
271 let seed_points = core::mem::take(&mut points);
272 parameters.push(seeds[0]);
273 points.push(seed_points[0]);
274 let mut pending: Vec<(f64, Point)> = seeds[1..]
277 .iter()
278 .copied()
279 .zip(seed_points[1..].iter().copied())
280 .rev()
281 .collect();
282 let mut splitting = true;
283 while let Some((t1, p1)) = pending.pop() {
284 let t0 = *parameters.last().unwrap_or(&t1);
285 let p0 = points.last().copied().unwrap_or(p1);
286 if splitting && needs_split(curve, (t0, t1), (p0, p1), (lo, hi), deflection, tol)? {
287 if parameters.len() + pending.len() + 1 > deflection.max_segments {
290 met = false;
291 splitting = false;
292 } else {
293 let mid = f64::midpoint(t0, t1);
294 if mid <= t0 || mid >= t1 {
298 met = false;
299 splitting = false;
300 } else {
301 pending.push((t1, p1));
302 pending.push((mid, curve.point_at(mid, tol)?));
303 continue;
304 }
305 }
306 }
307 parameters.push(t1);
308 points.push(p1);
309 }
310
311 Ok(Polyline {
312 points,
313 parameters,
314 deflection_met: met,
315 })
316}
317
318fn needs_split(
320 curve: &Curve,
321 parameters: (f64, f64),
322 ends: (Point, Point),
323 whole: (f64, f64),
324 deflection: Deflection,
325 tol: Tolerances,
326) -> OgeomResult<bool> {
327 let (a, b) = parameters;
328 let mid = f64::midpoint(a, b);
329 let on_curve = curve.point_at(mid, tol)?;
330
331 let chord = ogeom_math::Axis::through(ends.0, ends.1, tol).map_or_else(
335 |_| ends.0.distance(on_curve),
336 |axis| axis.distance_to(on_curve),
337 );
338 if chord > deflection.chord {
339 return Ok(true);
340 }
341
342 if ends.0.distance(ends.1) <= deflection.chord && (b - a) <= (whole.1 - whole.0) / 16.0 {
358 return Ok(false);
359 }
360 let (Ok(start), Ok(end)) = (curve.tangent_at(a, tol), curve.tangent_at(b, tol)) else {
361 return Ok(false);
363 };
364 Ok(start.angle(end) > deflection.angular)
365}
366
367pub fn discretize_on_surface(
381 curve: &ogeom_geom::PlanarCurve,
382 range: (f64, f64),
383 surface: &ogeom_geom::SurfaceGeometry,
384 deflection: Deflection,
385 tol: Tolerances,
386) -> OgeomResult<(Vec<ogeom_math::Point2>, Vec<f64>)> {
387 use ogeom_geom::Curve2d;
388 use ogeom_geom::Surface as _;
389
390 deflection.validate()?;
391 let (lo, hi) = range;
392 if !lo.is_finite() || !hi.is_finite() || hi <= lo + tol.parametric() {
393 ogeom_bail!(Construction, "range [{lo}, {hi}] is empty");
394 }
395 let lift = |uv: ogeom_math::Point2| -> OgeomResult<ogeom_math::Point> {
396 surface.point_at(uv.x, uv.y, tol)
397 };
398
399 let start = if is_straight_planar(curve) {
400 1
401 } else {
402 deflection.min_segments
403 };
404 let mut parameters: Vec<f64> = (0..=start)
405 .map(|i| {
406 #[allow(clippy::cast_precision_loss)]
407 let t = i as f64 / start as f64;
408 lo + (hi - lo) * t
409 })
410 .collect();
411 let mut points: Vec<ogeom_math::Point2> = parameters
412 .iter()
413 .map(|u| curve.point_at(*u, tol))
414 .collect::<OgeomResult<_>>()?;
415 let mut lifted: Vec<ogeom_math::Point> = points
416 .iter()
417 .map(|uv| lift(*uv))
418 .collect::<OgeomResult<_>>()?;
419
420 while points.len() <= deflection.max_segments {
421 let mut split_at = None;
422 for i in 0..points.len() - 1 {
423 let mid = f64::midpoint(parameters[i], parameters[i + 1]);
424 let on_curve = curve.point_at(mid, tol)?;
425 let in_space = lift(on_curve)?;
426 let (a, b) = (lifted[i], lifted[i + 1]);
429 let chord_vector = b - a;
430 let length = chord_vector.magnitude();
431 let sagitta = if length <= tol.confusion() {
432 in_space.distance(a)
433 } else {
434 (in_space - a).cross(chord_vector).magnitude() / length
435 };
436 if sagitta > deflection.chord {
437 split_at = Some((i, mid, on_curve, in_space));
438 break;
439 }
440 }
441 let Some((i, mid, on_curve, in_space)) = split_at else {
442 break;
443 };
444 parameters.insert(i + 1, mid);
445 points.insert(i + 1, on_curve);
446 lifted.insert(i + 1, in_space);
447 }
448 Ok((points, parameters))
449}
450
451pub fn discretize_planar(
465 curve: &ogeom_geom::PlanarCurve,
466 range: (f64, f64),
467 deflection: Deflection,
468 tol: Tolerances,
469) -> OgeomResult<(Vec<ogeom_math::Point2>, Vec<f64>)> {
470 use ogeom_geom::Curve2d;
471
472 deflection.validate()?;
473 let (lo, hi) = range;
474 if !lo.is_finite() || !hi.is_finite() || hi <= lo + tol.parametric() {
475 ogeom_bail!(Construction, "range [{lo}, {hi}] is empty");
476 }
477
478 let start = if is_straight_planar(curve) {
479 1
480 } else {
481 deflection.min_segments
482 };
483 let mut parameters: Vec<f64> = (0..=start)
484 .map(|i| {
485 #[allow(clippy::cast_precision_loss)]
486 let t = i as f64 / start as f64;
487 lo + (hi - lo) * t
488 })
489 .collect();
490 let mut points: Vec<ogeom_math::Point2> = parameters
491 .iter()
492 .map(|u| curve.point_at(*u, tol))
493 .collect::<OgeomResult<_>>()?;
494
495 while points.len() <= deflection.max_segments {
496 let mut split_at = None;
497 for i in 0..points.len() - 1 {
498 let mid = f64::midpoint(parameters[i], parameters[i + 1]);
499 let on_curve = curve.point_at(mid, tol)?;
500 let chord = ogeom_math::Axis2::through(points[i], points[i + 1], tol).map_or_else(
501 |_| points[i].distance(on_curve),
502 |axis| axis.distance_to(on_curve),
503 );
504 if chord > deflection.chord {
505 split_at = Some(i);
506 break;
507 }
508 }
509 let Some(i) = split_at else { break };
510 let mid = f64::midpoint(parameters[i], parameters[i + 1]);
511 if mid <= parameters[i] || mid >= parameters[i + 1] {
512 break;
513 }
514 parameters.insert(i + 1, mid);
515 points.insert(i + 1, curve.point_at(mid, tol)?);
516 }
517
518 Ok((points, parameters))
519}
520
521#[cfg(test)]
522#[allow(clippy::unwrap_used)]
523mod tests {
524 use super::*;
525 use approx::assert_relative_eq;
526 use ogeom_geom::{BSplineCurve, CircleCurve, LineCurve};
527 use ogeom_math::{Circle, Frame, KnotVector};
528
529 const T: Tolerances = Tolerances::millimetres();
530
531 fn circle(radius: f64) -> Curve {
532 CircleCurve::new(Circle::new(Frame::WORLD, radius, T).unwrap()).into()
533 }
534
535 fn worst_error(curve: &Curve, line: &Polyline) -> f64 {
537 let mut worst: f64 = 0.0;
538 for window in line.parameters.windows(2) {
539 for k in 1..16 {
540 let t = f64::from(k) / 16.0;
541 let u = window[0] + (window[1] - window[0]) * t;
542 let on_curve = curve.point_at(u, T).unwrap();
543 let a = curve.point_at(window[0], T).unwrap();
544 let b = curve.point_at(window[1], T).unwrap();
545 let chord = ogeom_math::Axis::through(a, b, T)
546 .map_or(0.0, |axis| axis.distance_to(on_curve));
547 worst = worst.max(chord);
548 }
549 }
550 worst
551 }
552
553 #[test]
554 fn straightness_sees_through_a_trim() {
555 use ogeom_geom::TrimmedCurve;
559 let line: Curve = LineCurve::segment(Point::ORIGIN, Point::new(10.0, 0.0, 0.0), T)
560 .unwrap()
561 .into();
562 assert!(is_straight(&line));
563 assert!(is_straight(
564 &TrimmedCurve::new(line, 2.0, 8.0, T).unwrap().into()
565 ));
566 assert!(!is_straight(&circle(1.0)));
567 }
568
569 #[test]
570 fn a_line_needs_no_more_than_the_minimum_segments() {
571 let curve: Curve = LineCurve::segment(Point::ORIGIN, Point::new(100.0, 0.0, 0.0), T)
574 .unwrap()
575 .into();
576 let line = discretize(&curve, curve.domain(), Deflection::default(), T).unwrap();
577 assert_eq!(line.segment_count(), 1, "a line is its own polyline");
578 assert!(line.deflection_met);
579 assert_relative_eq!(line.length(), 100.0, epsilon = 1e-9);
580 }
581
582 #[test]
583 fn a_circle_is_refined_until_the_chord_tolerance_is_met() {
584 let curve = circle(10.0);
585 for chord in [1.0_f64, 0.1, 0.01, 0.001] {
586 let deflection = Deflection {
587 chord,
588 ..Deflection::default()
589 };
590 let line = discretize(&curve, curve.domain(), deflection, T).unwrap();
591 assert!(line.deflection_met, "gave up at chord {chord}");
592 assert!(
593 worst_error(&curve, &line) <= chord * 1.5,
594 "chord {chord}: worst error {}",
595 worst_error(&curve, &line)
596 );
597 }
598 }
599
600 #[test]
601 fn a_tighter_tolerance_always_gives_at_least_as_many_segments() {
602 let curve = circle(10.0);
603 let mut previous = 0;
604 for chord in [2.0_f64, 1.0, 0.5, 0.1, 0.01] {
605 let line = discretize(
606 &curve,
607 curve.domain(),
608 Deflection {
609 chord,
610 ..Deflection::default()
611 },
612 T,
613 )
614 .unwrap();
615 assert!(
616 line.segment_count() >= previous,
617 "chord {chord} gave fewer segments than a looser one"
618 );
619 previous = line.segment_count();
620 }
621 }
622
623 #[test]
624 fn a_polylines_length_underestimates_the_curve_and_converges_to_it() {
625 let radius = 10.0;
628 let curve = circle(radius);
629 let exact = core::f64::consts::TAU * radius;
630
631 let coarse = discretize(
632 &curve,
633 curve.domain(),
634 Deflection {
635 chord: 1.0,
636 ..Deflection::default()
637 },
638 T,
639 )
640 .unwrap();
641 let fine = discretize(
642 &curve,
643 curve.domain(),
644 Deflection {
645 chord: 1e-4,
646 ..Deflection::default()
647 },
648 T,
649 )
650 .unwrap();
651
652 assert!(coarse.length() < exact);
653 assert!(fine.length() < exact);
654 assert!(fine.length() > coarse.length());
655 assert_relative_eq!(fine.length(), exact, max_relative = 1e-3);
656 }
657
658 #[test]
659 fn the_angular_tolerance_catches_what_the_chord_one_misses() {
660 let curve = circle(1000.0);
664 let chord_only = Deflection {
665 chord: 50.0,
666 angular: 10.0,
667 ..Deflection::default()
668 };
669 let with_angle = Deflection {
670 chord: 50.0,
671 angular: 0.1,
672 ..Deflection::default()
673 };
674
675 let loose = discretize(&curve, curve.domain(), chord_only, T).unwrap();
676 let tight = discretize(&curve, curve.domain(), with_angle, T).unwrap();
677 assert!(
678 tight.segment_count() > loose.segment_count(),
679 "the angular limit did nothing: {} vs {}",
680 tight.segment_count(),
681 loose.segment_count()
682 );
683
684 for window in tight.parameters.windows(2) {
686 let a = curve.tangent_at(window[0], T).unwrap();
687 let b = curve.tangent_at(window[1], T).unwrap();
688 assert!(a.angle(b) <= 0.1 + 1e-9);
689 }
690 }
691
692 #[test]
693 fn a_closed_curve_gets_enough_segments_to_enclose_something() {
694 let curve = circle(5.0);
698 let line = discretize(
699 &curve,
700 curve.domain(),
701 Deflection {
702 chord: 1e6,
703 angular: 1e6,
704 ..Deflection::default()
705 },
706 T,
707 )
708 .unwrap();
709 assert!(line.segment_count() >= 2);
710 assert!(line.length() > 0.0);
711 assert!(line.is_closed(T));
712 }
713
714 #[test]
715 fn reaching_the_ceiling_is_reported_rather_than_passed_off_as_success() {
716 let curve = circle(10.0);
719 let line = discretize(
720 &curve,
721 curve.domain(),
722 Deflection {
723 chord: 1e-12,
724 angular: 1e-12,
725 min_segments: 2,
726 max_segments: 16,
727 },
728 T,
729 )
730 .unwrap();
731 assert!(!line.deflection_met);
732 assert!(line.segment_count() <= 20);
733 }
734
735 #[test]
736 fn parameters_are_kept_alongside_the_points() {
737 let curve = circle(3.0);
741 let line = discretize(&curve, curve.domain(), Deflection::default(), T).unwrap();
742 assert_eq!(line.points.len(), line.parameters.len());
743 for (u, p) in line.parameters.iter().zip(&line.points) {
744 assert!(curve.point_at(*u, T).unwrap().is_equal(*p, T));
745 }
746 assert!(line.parameters.windows(2).all(|w| w[1] > w[0]));
748 }
749
750 #[test]
751 fn a_spline_is_refined_where_it_curves_and_not_where_it_does_not() {
752 let control = vec![
756 Point::new(0.0, 0.0, 0.0),
757 Point::new(10.0, 0.0, 0.0),
758 Point::new(20.0, 0.0, 0.0),
759 Point::new(21.0, 8.0, 0.0),
760 Point::new(22.0, 0.0, 0.0),
761 ];
762 let curve: Curve = BSplineCurve::new(
763 KnotVector::clamped_uniform(3, control.len()).unwrap(),
764 control,
765 T,
766 )
767 .unwrap()
768 .into();
769
770 let line = discretize(
771 &curve,
772 curve.domain(),
773 Deflection {
774 chord: 0.05,
775 ..Deflection::default()
776 },
777 T,
778 )
779 .unwrap();
780 assert!(line.deflection_met);
781
782 let mid = line.points.len() / 2;
785 let mean = |points: &[Point]| {
786 let gaps: Vec<f64> = points.windows(2).map(|w| w[0].distance(w[1])).collect();
787 #[allow(clippy::cast_precision_loss)]
788 let count = gaps.len() as f64;
789 gaps.iter().sum::<f64>() / count
790 };
791 let (early, late) = (mean(&line.points[..mid]), mean(&line.points[mid..]));
792 assert!(early > late, "uniform spacing: {early} vs {late}");
793 }
794
795 #[test]
796 fn discretizing_part_of_a_curve_covers_only_that_part() {
797 let curve = circle(4.0);
798 let line = discretize(&curve, (1.0, 2.0), Deflection::default(), T).unwrap();
799 assert_relative_eq!(line.parameters[0], 1.0);
800 assert_relative_eq!(line.parameters[line.parameters.len() - 1], 2.0);
801 assert!(!line.is_closed(T));
802 assert_relative_eq!(line.length(), 4.0, max_relative = 1e-2);
803 }
804
805 #[test]
806 fn unusable_settings_are_refused() {
807 let curve = circle(1.0);
808 let bad = [
809 Deflection {
810 chord: 0.0,
811 ..Deflection::default()
812 },
813 Deflection {
814 chord: f64::NAN,
815 ..Deflection::default()
816 },
817 Deflection {
818 angular: -1.0,
819 ..Deflection::default()
820 },
821 Deflection {
822 min_segments: 0,
823 ..Deflection::default()
824 },
825 Deflection {
826 min_segments: 10,
827 max_segments: 5,
828 ..Deflection::default()
829 },
830 ];
831 for deflection in bad {
832 assert!(
833 discretize(&curve, curve.domain(), deflection, T).is_err(),
834 "accepted {deflection:?}"
835 );
836 }
837 assert!(discretize(&curve, (1.0, 1.0), Deflection::default(), T).is_err());
838 assert!(Deflection::with_chord(-1.0).is_err());
839 assert!(Deflection::relative(0.0, 0.001).is_err());
840 assert!(Deflection::relative(100.0, 0.001).is_ok());
841 }
842
843 #[test]
844 fn a_relative_deflection_scales_with_the_model() {
845 let small = Deflection::relative(1.0, 0.001).unwrap();
848 let large = Deflection::relative(1000.0, 0.001).unwrap();
849 assert_relative_eq!(large.chord, small.chord * 1000.0);
850 }
851
852 #[test]
853 fn a_planar_curve_discretizes_in_parameter_space() {
854 let curve: ogeom_geom::PlanarCurve = ogeom_geom::Circle2d::new(
855 ogeom_math::Circle2::centred(ogeom_math::Point2::ORIGIN, 5.0, T).unwrap(),
856 )
857 .into();
858 let (points, parameters) = discretize_planar(
859 &curve,
860 (0.0, core::f64::consts::TAU),
861 Deflection {
862 chord: 0.05,
863 ..Deflection::default()
864 },
865 T,
866 )
867 .unwrap();
868 assert_eq!(points.len(), parameters.len());
869 assert!(points.len() > 20, "only {} points", points.len());
870 for p in &points {
871 assert_relative_eq!(p.to_vector().magnitude(), 5.0, epsilon = 1e-12);
872 }
873 }
874}
875#[cfg(test)]
876#[allow(clippy::unwrap_used)]
877mod on_surface_tests {
878 use super::*;
879 use ogeom_core::Tolerances;
880 use ogeom_geom::{Circle2d, PlaneSurface, Surface as _};
881 use ogeom_math::{Circle2, Frame, Plane, Point2};
882
883 const T: Tolerances = Tolerances::millimetres();
884
885 #[test]
886 fn the_spatial_chord_scales_with_the_surface_not_the_chart() {
887 let plane: ogeom_geom::SurfaceGeometry =
891 PlaneSurface::over(Plane::new(Frame::WORLD), (-200.0, 200.0), (-200.0, 200.0))
892 .unwrap()
893 .into();
894 let deflection = Deflection {
895 chord: 0.05,
896 ..Deflection::default()
897 };
898 let counts: Vec<usize> = [5.0, 100.0]
899 .iter()
900 .map(|&radius| {
901 let circle: ogeom_geom::PlanarCurve =
902 Circle2d::new(Circle2::centred(Point2::new(0.0, 0.0), radius, T).unwrap())
903 .into();
904 let (points, parameters) = discretize_on_surface(
905 &circle,
906 (0.0, core::f64::consts::TAU),
907 &plane,
908 deflection,
909 T,
910 )
911 .unwrap();
912 for (pair, params) in points.windows(2).zip(parameters.windows(2)) {
915 use ogeom_geom::Curve2d as _;
916 let a = plane.point_at(pair[0].x, pair[0].y, T).unwrap();
917 let b = plane.point_at(pair[1].x, pair[1].y, T).unwrap();
918 let mid = circle
919 .point_at(f64::midpoint(params[0], params[1]), T)
920 .unwrap();
921 let on = plane.point_at(mid.x, mid.y, T).unwrap();
922 let chord = b - a;
923 let sagitta = (on - a).cross(chord).magnitude() / chord.magnitude();
924 assert!(
925 sagitta <= deflection.chord * 1.5,
926 "sagitta {sagitta} at radius {radius}"
927 );
928 }
929 points.len()
930 })
931 .collect();
932 assert!(
933 counts[1] > counts[0] * 2,
934 "the larger circle refines further: {counts:?}"
935 );
936 }
937}