1use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
23use ogeom_geom::{Curve, Curve3d};
24use ogeom_math::Point;
25
26#[derive(Debug, Clone, Copy, PartialEq)]
37pub struct Deflection {
38 pub chord: f64,
40 pub angular: f64,
42 pub min_segments: usize,
53 pub max_segments: usize,
58}
59
60impl Default for Deflection {
61 fn default() -> Self {
62 Self {
63 chord: 1e-1,
69 angular: 0.5,
74 min_segments: 2,
75 max_segments: 4096,
76 }
77 }
78}
79
80impl Deflection {
81 pub fn with_chord(chord: f64) -> OgeomResult<Self> {
88 if !chord.is_finite() || chord <= 0.0 {
89 ogeom_bail!(
90 Construction,
91 "chord deflection {chord} must be finite and positive"
92 );
93 }
94 Ok(Self {
95 chord,
96 ..Self::default()
97 })
98 }
99
100 pub fn curvature_only(chord: f64, angular: f64) -> OgeomResult<Self> {
113 let deflection = Self {
114 chord,
115 angular,
116 ..Self::default()
117 };
118 deflection.validate()?;
119 Ok(deflection)
120 }
121
122 pub fn relative(size: f64, fraction: f64) -> OgeomResult<Self> {
133 if !size.is_finite() || size <= 0.0 || !fraction.is_finite() || fraction <= 0.0 {
134 ogeom_bail!(
135 Construction,
136 "relative deflection needs a positive size and fraction, got {size} and {fraction}"
137 );
138 }
139 Self::with_chord(size * fraction)
140 }
141
142 pub fn validate(&self) -> OgeomResult<()> {
149 if !self.chord.is_finite() || self.chord <= 0.0 {
150 ogeom_bail!(
151 Construction,
152 "chord deflection {} must be positive",
153 self.chord
154 );
155 }
156 if !self.angular.is_finite() || self.angular <= 0.0 {
157 ogeom_bail!(
158 Construction,
159 "angular deflection {} must be positive",
160 self.angular
161 );
162 }
163 if self.min_segments == 0 {
164 ogeom_bail!(Construction, "a polyline needs at least one segment");
165 }
166 if self.max_segments < self.min_segments {
167 ogeom_bail!(
168 Construction,
169 "segment ceiling {} is below the floor {}",
170 self.max_segments,
171 self.min_segments
172 );
173 }
174 Ok(())
175 }
176}
177
178#[derive(Debug, Clone, PartialEq)]
184pub struct Polyline {
185 pub points: Vec<Point>,
187 pub parameters: Vec<f64>,
189 pub deflection_met: bool,
195}
196
197impl Polyline {
198 #[must_use]
200 pub fn segment_count(&self) -> usize {
201 self.points.len().saturating_sub(1)
202 }
203
204 #[must_use]
209 pub fn length(&self) -> f64 {
210 self.points.windows(2).map(|w| w[0].distance(w[1])).sum()
211 }
212
213 #[must_use]
215 pub fn is_closed(&self, tol: Tolerances) -> bool {
216 match (self.points.first(), self.points.last()) {
217 (Some(a), Some(b)) => self.points.len() > 2 && a.is_equal(*b, tol),
218 _ => false,
219 }
220 }
221}
222
223#[must_use]
229pub fn is_straight(curve: &Curve) -> bool {
230 match curve {
231 Curve::Line(_) => true,
232 Curve::Trimmed(t) => is_straight(t.basis()),
233 _ => false,
234 }
235}
236
237#[must_use]
239pub fn is_straight_planar(curve: &ogeom_geom::PlanarCurve) -> bool {
240 match curve {
241 ogeom_geom::PlanarCurve::Line(_) => true,
242 ogeom_geom::PlanarCurve::Trimmed(t) => is_straight_planar(t.basis()),
243 _ => false,
244 }
245}
246
247pub fn discretize(
261 curve: &Curve,
262 range: (f64, f64),
263 deflection: Deflection,
264 tol: Tolerances,
265) -> OgeomResult<Polyline> {
266 deflection.validate()?;
267 let (lo, hi) = range;
268 if !lo.is_finite() || !hi.is_finite() || hi <= lo + tol.parametric() {
269 ogeom_bail!(Construction, "range [{lo}, {hi}] is empty");
270 }
271
272 let start = if is_straight(curve) {
276 1
277 } else {
278 deflection.min_segments
279 };
280 let parameters: Vec<f64> = (0..=start)
281 .map(|i| {
282 #[allow(clippy::cast_precision_loss)]
283 let t = i as f64 / start as f64;
284 lo + (hi - lo) * t
285 })
286 .collect();
287 if let Some(uniform) = circle_halvings(curve, (lo, hi), start, deflection) {
288 return halved(curve, parameters, uniform, tol);
289 }
290 bisect(curve, (lo, hi), parameters, deflection, tol)
291}
292
293fn halved(curve: &Curve, seeds: Vec<f64>, times: u32, tol: Tolerances) -> OgeomResult<Polyline> {
296 let mut parameters = seeds;
297 for _ in 0..times {
298 let mut finer = Vec::with_capacity(parameters.len() * 2);
299 for pair in parameters.windows(2) {
300 finer.push(pair[0]);
301 finer.push(f64::midpoint(pair[0], pair[1]));
302 }
303 finer.extend(parameters.last().copied());
304 parameters = finer;
305 }
306 let points = parameters
307 .iter()
308 .map(|u| curve.point_at(*u, tol))
309 .collect::<OgeomResult<_>>()?;
310 Ok(Polyline {
311 points,
312 parameters,
313 deflection_met: true,
314 })
315}
316
317fn bisect(
323 curve: &Curve,
324 whole: (f64, f64),
325 parameters: Vec<f64>,
326 deflection: Deflection,
327 tol: Tolerances,
328) -> OgeomResult<Polyline> {
329 let seeds = parameters.clone();
330 let line = bisect_leftmost(curve, whole, parameters, deflection, tol)?;
331 if line.deflection_met || line.parameters.len() < deflection.max_segments {
332 return Ok(line);
333 }
334 bisect_by_rounds(curve, whole, seeds, deflection, tol)
335}
336
337fn bisect_by_rounds(
342 curve: &Curve,
343 whole: (f64, f64),
344 parameters: Vec<f64>,
345 deflection: Deflection,
346 tol: Tolerances,
347) -> OgeomResult<Polyline> {
348 let mut points: Vec<Point> = parameters
349 .iter()
350 .map(|u| curve.point_at(*u, tol))
351 .collect::<OgeomResult<_>>()?;
352 let mut parameters = parameters;
353 loop {
354 let mut splits: Vec<(usize, f64, Point)> = Vec::new();
355 for i in 0..parameters.len().saturating_sub(1) {
356 let (t0, t1) = (parameters[i], parameters[i + 1]);
357 if let Some((mid, on_curve)) = needs_split(
358 curve,
359 (t0, t1),
360 (points[i], points[i + 1]),
361 whole,
362 deflection,
363 tol,
364 )? && mid > t0
365 && mid < t1
366 {
367 splits.push((i, mid, on_curve));
368 }
369 }
370 if splits.is_empty() {
371 return Ok(Polyline {
372 points,
373 parameters,
374 deflection_met: true,
375 });
376 }
377 if parameters.len() + splits.len() > deflection.max_segments {
378 return Ok(Polyline {
379 points,
380 parameters,
381 deflection_met: false,
382 });
383 }
384 let mut next_parameters = Vec::with_capacity(parameters.len() + splits.len());
385 let mut next_points = Vec::with_capacity(parameters.len() + splits.len());
386 let mut pending = splits.into_iter().peekable();
387 for i in 0..parameters.len() {
388 next_parameters.push(parameters[i]);
389 next_points.push(points[i]);
390 if let Some(&(at, mid, on_curve)) = pending.peek()
391 && at == i
392 {
393 next_parameters.push(mid);
394 next_points.push(on_curve);
395 pending.next();
396 }
397 }
398 parameters = next_parameters;
399 points = next_points;
400 }
401}
402
403fn bisect_leftmost(
405 curve: &Curve,
406 (lo, hi): (f64, f64),
407 mut parameters: Vec<f64>,
408 deflection: Deflection,
409 tol: Tolerances,
410) -> OgeomResult<Polyline> {
411 let mut points: Vec<Point> = parameters
412 .iter()
413 .map(|u| curve.point_at(*u, tol))
414 .collect::<OgeomResult<_>>()?;
415
416 let mut met = true;
422 let seeds = core::mem::take(&mut parameters);
423 let seed_points = core::mem::take(&mut points);
424 parameters.push(seeds[0]);
425 points.push(seed_points[0]);
426 let mut pending: Vec<(f64, Point)> = seeds[1..]
429 .iter()
430 .copied()
431 .zip(seed_points[1..].iter().copied())
432 .rev()
433 .collect();
434 let mut splitting = true;
435 while let Some((t1, p1)) = pending.pop() {
436 let t0 = *parameters.last().unwrap_or(&t1);
437 let p0 = points.last().copied().unwrap_or(p1);
438 if splitting
439 && let Some((mid, on_curve)) =
440 needs_split(curve, (t0, t1), (p0, p1), (lo, hi), deflection, tol)?
441 {
442 if parameters.len() + pending.len() + 1 > deflection.max_segments {
445 met = false;
446 splitting = false;
447 } else if mid <= t0 || mid >= t1 {
448 met = false;
452 splitting = false;
453 } else {
454 pending.push((t1, p1));
455 pending.push((mid, on_curve));
456 continue;
457 }
458 }
459 parameters.push(t1);
460 points.push(p1);
461 }
462
463 Ok(Polyline {
464 points,
465 parameters,
466 deflection_met: met,
467 })
468}
469
470fn circle_halvings(
477 curve: &Curve,
478 (lo, hi): (f64, f64),
479 seeds: usize,
480 deflection: Deflection,
481) -> Option<u32> {
482 let Curve::Circle(circle) = curve else {
483 return None;
484 };
485 let radius = circle.circle().radius();
486 let whole = hi - lo;
487 let mut segments = seeds;
488 for depth in 0..48 {
489 #[allow(clippy::cast_precision_loss, reason = "a segment count")]
490 let span = whole / segments as f64;
491 let sag = radius * (1.0 - (span / 2.0).cos());
492 let reach = 2.0 * radius * (span / 2.0).sin();
493 let near = |x: f64, limit: f64| (x - limit).abs() <= limit.abs() * 1e-9;
497 if near(sag, deflection.chord) {
498 return None;
499 }
500 let split = if sag > deflection.chord {
501 true
502 } else {
503 if near(reach, deflection.chord) {
504 return None;
505 }
506 let short = reach < deflection.chord && {
507 if near(span, whole / 16.0) {
508 return None;
509 }
510 span < whole / 16.0
511 };
512 if short {
513 false
514 } else {
515 if near(span, deflection.angular) {
516 return None;
517 }
518 span > deflection.angular
519 }
520 };
521 if !split {
522 return Some(depth);
523 }
524 segments *= 2;
525 if segments + 1 > deflection.max_segments {
526 return None;
527 }
528 }
529 None
530}
531
532fn needs_split(
535 curve: &Curve,
536 parameters: (f64, f64),
537 ends: (Point, Point),
538 whole: (f64, f64),
539 deflection: Deflection,
540 tol: Tolerances,
541) -> OgeomResult<Option<(f64, Point)>> {
542 let (a, b) = parameters;
543 let mid = f64::midpoint(a, b);
544 let on_curve = curve.point_at(mid, tol)?;
545 let split = Some((mid, on_curve));
546
547 let chord = ogeom_math::Axis::through(ends.0, ends.1, tol).map_or_else(
551 |_| ends.0.distance(on_curve),
552 |axis| axis.distance_to(on_curve),
553 );
554 if chord > deflection.chord {
555 return Ok(split);
556 }
557
558 if ends.0.distance(ends.1) <= deflection.chord && (b - a) <= (whole.1 - whole.0) / 16.0 {
574 return Ok(None);
575 }
576 let (Ok(start), Ok(end)) = (curve.tangent_at(a, tol), curve.tangent_at(b, tol)) else {
577 return Ok(None);
579 };
580 Ok(split.filter(|_| start.angle(end) > deflection.angular))
581}
582
583pub fn discretize_on_surface(
597 curve: &ogeom_geom::PlanarCurve,
598 range: (f64, f64),
599 surface: &ogeom_geom::SurfaceGeometry,
600 deflection: Deflection,
601 tol: Tolerances,
602) -> OgeomResult<(Vec<ogeom_math::Point2>, Vec<f64>)> {
603 use ogeom_geom::Curve2d;
604 use ogeom_geom::Surface as _;
605
606 deflection.validate()?;
607 let (lo, hi) = range;
608 if !lo.is_finite() || !hi.is_finite() || hi <= lo + tol.parametric() {
609 ogeom_bail!(Construction, "range [{lo}, {hi}] is empty");
610 }
611 let lift = |uv: ogeom_math::Point2| -> OgeomResult<ogeom_math::Point> {
612 surface.point_at(uv.x, uv.y, tol)
613 };
614
615 let start = if is_straight_planar(curve) {
616 1
617 } else {
618 deflection.min_segments
619 };
620 let mut parameters: Vec<f64> = (0..=start)
621 .map(|i| {
622 #[allow(clippy::cast_precision_loss)]
623 let t = i as f64 / start as f64;
624 lo + (hi - lo) * t
625 })
626 .collect();
627 let mut points: Vec<ogeom_math::Point2> = parameters
628 .iter()
629 .map(|u| curve.point_at(*u, tol))
630 .collect::<OgeomResult<_>>()?;
631 let mut lifted: Vec<ogeom_math::Point> = points
632 .iter()
633 .map(|uv| lift(*uv))
634 .collect::<OgeomResult<_>>()?;
635
636 while points.len() <= deflection.max_segments {
637 let mut split_at = None;
638 for i in 0..points.len() - 1 {
639 let mid = f64::midpoint(parameters[i], parameters[i + 1]);
640 let on_curve = curve.point_at(mid, tol)?;
641 let in_space = lift(on_curve)?;
642 let (a, b) = (lifted[i], lifted[i + 1]);
645 let chord_vector = b - a;
646 let length = chord_vector.magnitude();
647 let sagitta = if length <= tol.confusion() {
648 in_space.distance(a)
649 } else {
650 (in_space - a).cross(chord_vector).magnitude() / length
651 };
652 if sagitta > deflection.chord {
653 split_at = Some((i, mid, on_curve, in_space));
654 break;
655 }
656 }
657 let Some((i, mid, on_curve, in_space)) = split_at else {
658 break;
659 };
660 parameters.insert(i + 1, mid);
661 points.insert(i + 1, on_curve);
662 lifted.insert(i + 1, in_space);
663 }
664 Ok((points, parameters))
665}
666
667pub fn discretize_planar(
681 curve: &ogeom_geom::PlanarCurve,
682 range: (f64, f64),
683 deflection: Deflection,
684 tol: Tolerances,
685) -> OgeomResult<(Vec<ogeom_math::Point2>, Vec<f64>)> {
686 use ogeom_geom::Curve2d;
687
688 deflection.validate()?;
689 let (lo, hi) = range;
690 if !lo.is_finite() || !hi.is_finite() || hi <= lo + tol.parametric() {
691 ogeom_bail!(Construction, "range [{lo}, {hi}] is empty");
692 }
693
694 let start = if is_straight_planar(curve) {
695 1
696 } else {
697 deflection.min_segments
698 };
699 let mut parameters: Vec<f64> = (0..=start)
700 .map(|i| {
701 #[allow(clippy::cast_precision_loss)]
702 let t = i as f64 / start as f64;
703 lo + (hi - lo) * t
704 })
705 .collect();
706 let mut points: Vec<ogeom_math::Point2> = parameters
707 .iter()
708 .map(|u| curve.point_at(*u, tol))
709 .collect::<OgeomResult<_>>()?;
710
711 while points.len() <= deflection.max_segments {
712 let mut split_at = None;
713 for i in 0..points.len() - 1 {
714 let mid = f64::midpoint(parameters[i], parameters[i + 1]);
715 let on_curve = curve.point_at(mid, tol)?;
716 let chord = ogeom_math::Axis2::through(points[i], points[i + 1], tol).map_or_else(
717 |_| points[i].distance(on_curve),
718 |axis| axis.distance_to(on_curve),
719 );
720 if chord > deflection.chord {
721 split_at = Some(i);
722 break;
723 }
724 }
725 let Some(i) = split_at else { break };
726 let mid = f64::midpoint(parameters[i], parameters[i + 1]);
727 if mid <= parameters[i] || mid >= parameters[i + 1] {
728 break;
729 }
730 parameters.insert(i + 1, mid);
731 points.insert(i + 1, curve.point_at(mid, tol)?);
732 }
733
734 Ok((points, parameters))
735}
736
737#[cfg(test)]
738#[allow(clippy::unwrap_used)]
739mod tests {
740 use super::*;
741 use approx::assert_relative_eq;
742 use ogeom_geom::{BSplineCurve, CircleCurve, LineCurve};
743 use ogeom_math::{Circle, Frame, KnotVector};
744
745 const T: Tolerances = Tolerances::millimetres();
746
747 fn circle(radius: f64) -> Curve {
748 CircleCurve::new(Circle::new(Frame::WORLD, radius, T).unwrap()).into()
749 }
750
751 #[test]
755 fn a_circle_halves_as_the_bisection_would() {
756 for radius in [1e-3, 0.37, 1.0, 2.5, 40.0, 900.0] {
757 for (lo, hi) in [(0.0, core::f64::consts::TAU), (0.3, 1.9), (-2.0, 0.05)] {
758 for chord in [1e-4, 1e-3, 0.01, 0.1] {
759 for angular in [0.05, 0.2, 0.5] {
760 let deflection = Deflection {
761 chord,
762 angular,
763 ..Deflection::default()
764 };
765 let curve = circle(radius);
766 let fast = discretize(&curve, (lo, hi), deflection, T).unwrap();
767 let seeds: Vec<f64> = (0..=deflection.min_segments)
768 .map(|i| {
769 #[allow(clippy::cast_precision_loss)]
770 let t = i as f64 / deflection.min_segments as f64;
771 lo + (hi - lo) * t
772 })
773 .collect();
774 let slow = bisect(&curve, (lo, hi), seeds, deflection, T).unwrap();
775 assert_eq!(
776 fast.parameters, slow.parameters,
777 "r {radius} ({lo}, {hi}) chord {chord} angular {angular}"
778 );
779 assert_eq!(fast.deflection_met, slow.deflection_met);
780 }
781 }
782 }
783 }
784 }
785
786 fn worst_error(curve: &Curve, line: &Polyline) -> f64 {
788 let mut worst: f64 = 0.0;
789 for window in line.parameters.windows(2) {
790 for k in 1..16 {
791 let t = f64::from(k) / 16.0;
792 let u = window[0] + (window[1] - window[0]) * t;
793 let on_curve = curve.point_at(u, T).unwrap();
794 let a = curve.point_at(window[0], T).unwrap();
795 let b = curve.point_at(window[1], T).unwrap();
796 let chord = ogeom_math::Axis::through(a, b, T)
797 .map_or(0.0, |axis| axis.distance_to(on_curve));
798 worst = worst.max(chord);
799 }
800 }
801 worst
802 }
803
804 #[test]
805 fn straightness_sees_through_a_trim() {
806 use ogeom_geom::TrimmedCurve;
810 let line: Curve = LineCurve::segment(Point::ORIGIN, Point::new(10.0, 0.0, 0.0), T)
811 .unwrap()
812 .into();
813 assert!(is_straight(&line));
814 assert!(is_straight(
815 &TrimmedCurve::new(line, 2.0, 8.0, T).unwrap().into()
816 ));
817 assert!(!is_straight(&circle(1.0)));
818 }
819
820 #[test]
821 fn a_line_needs_no_more_than_the_minimum_segments() {
822 let curve: Curve = LineCurve::segment(Point::ORIGIN, Point::new(100.0, 0.0, 0.0), T)
825 .unwrap()
826 .into();
827 let line = discretize(&curve, curve.domain(), Deflection::default(), T).unwrap();
828 assert_eq!(line.segment_count(), 1, "a line is its own polyline");
829 assert!(line.deflection_met);
830 assert_relative_eq!(line.length(), 100.0, epsilon = 1e-9);
831 }
832
833 #[test]
834 fn a_circle_is_refined_until_the_chord_tolerance_is_met() {
835 let curve = circle(10.0);
836 for chord in [1.0_f64, 0.1, 0.01, 0.001] {
837 let deflection = Deflection {
838 chord,
839 ..Deflection::default()
840 };
841 let line = discretize(&curve, curve.domain(), deflection, T).unwrap();
842 assert!(line.deflection_met, "gave up at chord {chord}");
843 assert!(
844 worst_error(&curve, &line) <= chord * 1.5,
845 "chord {chord}: worst error {}",
846 worst_error(&curve, &line)
847 );
848 }
849 }
850
851 #[test]
852 fn a_tighter_tolerance_always_gives_at_least_as_many_segments() {
853 let curve = circle(10.0);
854 let mut previous = 0;
855 for chord in [2.0_f64, 1.0, 0.5, 0.1, 0.01] {
856 let line = discretize(
857 &curve,
858 curve.domain(),
859 Deflection {
860 chord,
861 ..Deflection::default()
862 },
863 T,
864 )
865 .unwrap();
866 assert!(
867 line.segment_count() >= previous,
868 "chord {chord} gave fewer segments than a looser one"
869 );
870 previous = line.segment_count();
871 }
872 }
873
874 #[test]
875 fn a_polylines_length_underestimates_the_curve_and_converges_to_it() {
876 let radius = 10.0;
879 let curve = circle(radius);
880 let exact = core::f64::consts::TAU * radius;
881
882 let coarse = discretize(
883 &curve,
884 curve.domain(),
885 Deflection {
886 chord: 1.0,
887 ..Deflection::default()
888 },
889 T,
890 )
891 .unwrap();
892 let fine = discretize(
893 &curve,
894 curve.domain(),
895 Deflection {
896 chord: 1e-4,
897 ..Deflection::default()
898 },
899 T,
900 )
901 .unwrap();
902
903 assert!(coarse.length() < exact);
904 assert!(fine.length() < exact);
905 assert!(fine.length() > coarse.length());
906 assert_relative_eq!(fine.length(), exact, max_relative = 1e-3);
907 }
908
909 #[test]
910 fn the_angular_tolerance_catches_what_the_chord_one_misses() {
911 let curve = circle(1000.0);
915 let chord_only = Deflection {
916 chord: 50.0,
917 angular: 10.0,
918 ..Deflection::default()
919 };
920 let with_angle = Deflection {
921 chord: 50.0,
922 angular: 0.1,
923 ..Deflection::default()
924 };
925
926 let loose = discretize(&curve, curve.domain(), chord_only, T).unwrap();
927 let tight = discretize(&curve, curve.domain(), with_angle, T).unwrap();
928 assert!(
929 tight.segment_count() > loose.segment_count(),
930 "the angular limit did nothing: {} vs {}",
931 tight.segment_count(),
932 loose.segment_count()
933 );
934
935 for window in tight.parameters.windows(2) {
937 let a = curve.tangent_at(window[0], T).unwrap();
938 let b = curve.tangent_at(window[1], T).unwrap();
939 assert!(a.angle(b) <= 0.1 + 1e-9);
940 }
941 }
942
943 #[test]
944 fn a_closed_curve_gets_enough_segments_to_enclose_something() {
945 let curve = circle(5.0);
949 let line = discretize(
950 &curve,
951 curve.domain(),
952 Deflection {
953 chord: 1e6,
954 angular: 1e6,
955 ..Deflection::default()
956 },
957 T,
958 )
959 .unwrap();
960 assert!(line.segment_count() >= 2);
961 assert!(line.length() > 0.0);
962 assert!(line.is_closed(T));
963 }
964
965 #[test]
966 fn reaching_the_ceiling_is_reported_rather_than_passed_off_as_success() {
967 let curve = circle(10.0);
970 let line = discretize(
971 &curve,
972 curve.domain(),
973 Deflection {
974 chord: 1e-12,
975 angular: 1e-12,
976 min_segments: 2,
977 max_segments: 16,
978 },
979 T,
980 )
981 .unwrap();
982 assert!(!line.deflection_met);
983 assert!(line.segment_count() <= 20);
984 }
985
986 #[test]
987 fn a_curve_the_ceiling_stops_is_sampled_all_along() {
988 let control: Vec<Point> = (0..40)
994 .map(|i| Point::new(f64::from(i), if i % 2 == 0 { 0.0 } else { 1.0 }, 0.0))
995 .collect();
996 let knots = KnotVector::clamped_uniform(3, control.len()).unwrap();
997 let curve: Curve = BSplineCurve::new(knots, control, T).unwrap().into();
998 let (lo, hi) = curve.domain();
999 let line = discretize(
1000 &curve,
1001 (lo, hi),
1002 Deflection {
1003 chord: 1e-9,
1004 angular: 0.5,
1005 min_segments: 2,
1006 max_segments: 256,
1007 },
1008 T,
1009 )
1010 .unwrap();
1011 assert!(!line.deflection_met);
1012 let widest = line
1013 .parameters
1014 .windows(2)
1015 .map(|w| w[1] - w[0])
1016 .fold(0.0_f64, f64::max);
1017 assert!(
1018 widest <= (hi - lo) / 64.0,
1019 "a chord spans {widest} of {}",
1020 hi - lo
1021 );
1022 }
1023
1024 #[test]
1025 fn parameters_are_kept_alongside_the_points() {
1026 let curve = circle(3.0);
1030 let line = discretize(&curve, curve.domain(), Deflection::default(), T).unwrap();
1031 assert_eq!(line.points.len(), line.parameters.len());
1032 for (u, p) in line.parameters.iter().zip(&line.points) {
1033 assert!(curve.point_at(*u, T).unwrap().is_equal(*p, T));
1034 }
1035 assert!(line.parameters.windows(2).all(|w| w[1] > w[0]));
1037 }
1038
1039 #[test]
1040 fn a_spline_is_refined_where_it_curves_and_not_where_it_does_not() {
1041 let control = vec![
1045 Point::new(0.0, 0.0, 0.0),
1046 Point::new(10.0, 0.0, 0.0),
1047 Point::new(20.0, 0.0, 0.0),
1048 Point::new(21.0, 8.0, 0.0),
1049 Point::new(22.0, 0.0, 0.0),
1050 ];
1051 let curve: Curve = BSplineCurve::new(
1052 KnotVector::clamped_uniform(3, control.len()).unwrap(),
1053 control,
1054 T,
1055 )
1056 .unwrap()
1057 .into();
1058
1059 let line = discretize(
1060 &curve,
1061 curve.domain(),
1062 Deflection {
1063 chord: 0.05,
1064 ..Deflection::default()
1065 },
1066 T,
1067 )
1068 .unwrap();
1069 assert!(line.deflection_met);
1070
1071 let mid = line.points.len() / 2;
1074 let mean = |points: &[Point]| {
1075 let gaps: Vec<f64> = points.windows(2).map(|w| w[0].distance(w[1])).collect();
1076 #[allow(clippy::cast_precision_loss)]
1077 let count = gaps.len() as f64;
1078 gaps.iter().sum::<f64>() / count
1079 };
1080 let (early, late) = (mean(&line.points[..mid]), mean(&line.points[mid..]));
1081 assert!(early > late, "uniform spacing: {early} vs {late}");
1082 }
1083
1084 #[test]
1085 fn discretizing_part_of_a_curve_covers_only_that_part() {
1086 let curve = circle(4.0);
1087 let line = discretize(&curve, (1.0, 2.0), Deflection::default(), T).unwrap();
1088 assert_relative_eq!(line.parameters[0], 1.0);
1089 assert_relative_eq!(line.parameters[line.parameters.len() - 1], 2.0);
1090 assert!(!line.is_closed(T));
1091 assert_relative_eq!(line.length(), 4.0, max_relative = 1e-2);
1092 }
1093
1094 #[test]
1095 fn unusable_settings_are_refused() {
1096 let curve = circle(1.0);
1097 let bad = [
1098 Deflection {
1099 chord: 0.0,
1100 ..Deflection::default()
1101 },
1102 Deflection {
1103 chord: f64::NAN,
1104 ..Deflection::default()
1105 },
1106 Deflection {
1107 angular: -1.0,
1108 ..Deflection::default()
1109 },
1110 Deflection {
1111 min_segments: 0,
1112 ..Deflection::default()
1113 },
1114 Deflection {
1115 min_segments: 10,
1116 max_segments: 5,
1117 ..Deflection::default()
1118 },
1119 ];
1120 for deflection in bad {
1121 assert!(
1122 discretize(&curve, curve.domain(), deflection, T).is_err(),
1123 "accepted {deflection:?}"
1124 );
1125 }
1126 assert!(discretize(&curve, (1.0, 1.0), Deflection::default(), T).is_err());
1127 assert!(Deflection::with_chord(-1.0).is_err());
1128 assert!(Deflection::relative(0.0, 0.001).is_err());
1129 assert!(Deflection::relative(100.0, 0.001).is_ok());
1130 }
1131
1132 #[test]
1133 fn a_relative_deflection_scales_with_the_model() {
1134 let small = Deflection::relative(1.0, 0.001).unwrap();
1137 let large = Deflection::relative(1000.0, 0.001).unwrap();
1138 assert_relative_eq!(large.chord, small.chord * 1000.0);
1139 }
1140
1141 #[test]
1142 fn a_planar_curve_discretizes_in_parameter_space() {
1143 let curve: ogeom_geom::PlanarCurve = ogeom_geom::Circle2d::new(
1144 ogeom_math::Circle2::centred(ogeom_math::Point2::ORIGIN, 5.0, T).unwrap(),
1145 )
1146 .into();
1147 let (points, parameters) = discretize_planar(
1148 &curve,
1149 (0.0, core::f64::consts::TAU),
1150 Deflection {
1151 chord: 0.05,
1152 ..Deflection::default()
1153 },
1154 T,
1155 )
1156 .unwrap();
1157 assert_eq!(points.len(), parameters.len());
1158 assert!(points.len() > 20, "only {} points", points.len());
1159 for p in &points {
1160 assert_relative_eq!(p.to_vector().magnitude(), 5.0, epsilon = 1e-12);
1161 }
1162 }
1163}
1164#[cfg(test)]
1165#[allow(clippy::unwrap_used)]
1166mod on_surface_tests {
1167 use super::*;
1168 use ogeom_core::Tolerances;
1169 use ogeom_geom::{Circle2d, PlaneSurface, Surface as _};
1170 use ogeom_math::{Circle2, Frame, Plane, Point2};
1171
1172 const T: Tolerances = Tolerances::millimetres();
1173
1174 #[test]
1175 fn the_spatial_chord_scales_with_the_surface_not_the_chart() {
1176 let plane: ogeom_geom::SurfaceGeometry =
1180 PlaneSurface::over(Plane::new(Frame::WORLD), (-200.0, 200.0), (-200.0, 200.0))
1181 .unwrap()
1182 .into();
1183 let deflection = Deflection {
1184 chord: 0.05,
1185 ..Deflection::default()
1186 };
1187 let counts: Vec<usize> = [5.0, 100.0]
1188 .iter()
1189 .map(|&radius| {
1190 let circle: ogeom_geom::PlanarCurve =
1191 Circle2d::new(Circle2::centred(Point2::new(0.0, 0.0), radius, T).unwrap())
1192 .into();
1193 let (points, parameters) = discretize_on_surface(
1194 &circle,
1195 (0.0, core::f64::consts::TAU),
1196 &plane,
1197 deflection,
1198 T,
1199 )
1200 .unwrap();
1201 for (pair, params) in points.windows(2).zip(parameters.windows(2)) {
1204 use ogeom_geom::Curve2d as _;
1205 let a = plane.point_at(pair[0].x, pair[0].y, T).unwrap();
1206 let b = plane.point_at(pair[1].x, pair[1].y, T).unwrap();
1207 let mid = circle
1208 .point_at(f64::midpoint(params[0], params[1]), T)
1209 .unwrap();
1210 let on = plane.point_at(mid.x, mid.y, T).unwrap();
1211 let chord = b - a;
1212 let sagitta = (on - a).cross(chord).magnitude() / chord.magnitude();
1213 assert!(
1214 sagitta <= deflection.chord * 1.5,
1215 "sagitta {sagitta} at radius {radius}"
1216 );
1217 }
1218 points.len()
1219 })
1220 .collect();
1221 assert!(
1222 counts[1] > counts[0] * 2,
1223 "the larger circle refines further: {counts:?}"
1224 );
1225 }
1226}