Skip to main content

ogeom_mesh/
discretize.rs

1//! Turning a curve into a polyline within a stated deflection.
2//!
3//! The first half of tessellation, and the half that decides whether the second
4//! half can succeed: a face's triangulation is built from the polylines of its
5//! boundary edges, so two faces meeting along an edge produce a watertight join
6//! only if they discretize that edge to the *same* points. That is why
7//! discretization is a property of the edge rather than of the face, and why it
8//! is a separate step from triangulating the face itself.
9//!
10//! # What deflection means
11//!
12//! The chord deflection is the greatest distance between the polyline and the
13//! curve it approximates. It is a length, so it scales with the model, and it
14//! is the number a caller actually has an opinion about: "no visible error at
15//! this zoom", "within machining tolerance".
16//!
17//! The angular deflection bounds how far the tangent may turn across one
18//! segment. Without it a nearly straight curve gets two points and a
19//! near-circular one gets far too few near its flattest part; chord error
20//! alone does not notice a long, gently curving span.
21
22use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
23use ogeom_geom::{Curve, Curve3d};
24use ogeom_math::Point;
25
26/// How finely to approximate a curve.
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub struct Deflection {
29    /// The greatest allowed distance from the polyline to the curve.
30    pub chord: f64,
31    /// The greatest allowed turn in the tangent across one segment, in radians.
32    pub angular: f64,
33    /// A floor on the number of segments for a curve that bends.
34    ///
35    /// A closed curve needs at least three to enclose anything, and a nearly
36    /// straight arc of one would otherwise collapse to a chord that misses the
37    /// bulge entirely: the midpoint test is a sample, and one sample can be
38    /// placed exactly where the curve happens to cross its own chord.
39    ///
40    /// It does *not* apply to a straight curve, which is exactly represented by
41    /// its endpoints. Splitting a line adds points that no tolerance asked for,
42    /// and every one of them becomes a vertex in every face the edge bounds.
43    pub min_segments: usize,
44    /// A ceiling, so a pathological curve cannot exhaust memory.
45    ///
46    /// Reaching it is reported rather than passed off as success; see
47    /// [`Polyline::deflection_met`].
48    pub max_segments: usize,
49}
50
51impl Default for Deflection {
52    fn default() -> Self {
53        Self {
54            // A tenth of a millimetre at unit scale: invisible on screen. On
55            // a small curved part the angular limit below rules instead, and
56            // a mesh at this default can be a part in a hundred off in
57            // volume; mass properties integrate a face on its exact surface
58            // wherever its pcurves allow, and mesh only what they cannot.
59            chord: 1e-1,
60            // Half a radian (twenty-eight degrees, a circle in thirteen
61            // segments), which is what B-rep kernels have long defaulted
62            // to, and what a viewer that lets the user tighten it asks for.
63            // The interior of a face is held to it as its edges are, so a
64            // tighter default is paid for on every curved face.
65            angular: 0.5,
66            min_segments: 2,
67            max_segments: 4096,
68        }
69    }
70}
71
72impl Deflection {
73    /// A deflection with a given chord tolerance and the default angular one.
74    ///
75    /// # Errors
76    ///
77    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `chord` is
78    /// not finite and positive.
79    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    /// A deflection scaled to a model of the given size.
93    ///
94    /// Relative tolerances are what a caller usually means: "a thousandth of the
95    /// part" is a statement that survives the part being modelled in metres
96    /// rather than millimetres, and an absolute default does not.
97    ///
98    /// # Errors
99    ///
100    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `size` or
101    /// `fraction` is not finite and positive.
102    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    /// Check that the settings are usable.
113    ///
114    /// # Errors
115    ///
116    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if a tolerance
117    /// is not positive, or the segment bounds are inconsistent.
118    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/// A curve approximated by points, with the parameters they came from.
149///
150/// The parameters are kept, not discarded, because the pcurve of the same edge
151/// on an adjacent face has to be sampled at exactly these values for the two
152/// faces to meet.
153#[derive(Debug, Clone, PartialEq)]
154pub struct Polyline {
155    /// The points, in order along the curve.
156    pub points: Vec<Point>,
157    /// The parameter each point came from.
158    pub parameters: Vec<f64>,
159    /// Whether the requested deflection was actually achieved.
160    ///
161    /// `false` when the segment ceiling was reached first. Reporting it beats
162    /// silently returning a coarser polyline than asked for, which would make
163    /// a downstream tolerance claim untrue with nothing to show why.
164    pub deflection_met: bool,
165}
166
167impl Polyline {
168    /// Number of segments.
169    #[must_use]
170    pub fn segment_count(&self) -> usize {
171        self.points.len().saturating_sub(1)
172    }
173
174    /// Total length of the polyline.
175    ///
176    /// An underestimate of the curve's own length, since a chord is shorter
177    /// than the arc it spans, and one that improves as the deflection tightens.
178    #[must_use]
179    pub fn length(&self) -> f64 {
180        self.points.windows(2).map(|w| w[0].distance(w[1])).sum()
181    }
182
183    /// Whether the polyline returns to where it started.
184    #[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/// Whether a curve is a straight line, seeing through any trimming.
194///
195/// A line is its own polyline, so subdividing it is pure cost: the extra points
196/// land on the curve, satisfy every tolerance, and then propagate into the
197/// triangulation of every face the edge bounds.
198#[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/// Whether a planar curve is a straight line in parameter space.
208#[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
217/// Approximate `curve` over `range` within `deflection`.
218///
219/// Adaptive bisection: split a segment whenever its midpoint is further from the
220/// chord than allowed, or the tangent turns too far across it. Uniform sampling
221/// is the obvious alternative and wastes points on the straight parts of a curve
222/// while still missing the tight ones; the whole difficulty of tessellation is
223/// that curvature is not uniform.
224///
225/// # Errors
226///
227/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the deflection
228/// settings are unusable or the range is empty;
229/// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) if the range leaves the curve.
230pub 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    // Start from the floor, so a closed curve is never approximated by a single
243    // chord from a point back to itself. A straight curve is exempt: its
244    // endpoints already represent it exactly.
245    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    // Leftmost-first subdivision with a worklist: each segment is settled
263    // before the walk moves right, and a split pushes its halves back for
264    // re-examination: the exact split sequence the old
265    // rescan-from-zero-and-insert loop produced, without re-asking every
266    // settled segment on every pass or shifting the vectors per split. Same
267    // splits in the same order, so the same floats come out; a circle that
268    // took ~8 000 midpoint measurements to reach 128 points now takes ~250.
269    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    // The unsettled boundary, rightmost at the bottom; the top is always the
275    // segment just right of the settled prefix.
276    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            // The count the cap sees is every point currently alive, exactly
288            // as the old loop counted before each split.
289            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                // A split that does not actually divide the interval means
295                // the parameters have reached the resolution of f64;
296                // refining further would loop without improving anything.
297                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
318/// Whether one segment violates either tolerance.
319fn 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    // Chord error, measured at the midpoint. Not a bound on the true maximum
332    // deviation, which would need the curve's second derivative over the span;
333    // it is the standard estimate, and bisection drives it down regardless.
334    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    // Angular error: how far the tangent turns across the segment. Chord error
343    // alone misses a long, gently curving span, which is exactly where a
344    // silhouette goes visibly polygonal.
345    //
346    // Not over a segment that is both shorter than the chord tolerance and
347    // a small fraction of the whole edge. A fitted edge often ends in a
348    // hook a few microns long (the fit overshooting its vertex and turning
349    // back), and the tangent turns through a right angle across it at
350    // every scale; asked of it, the angular test bisects the hook down to
351    // the resolution of the parameter and hands the face a fan of hairs at
352    // one corner, each a fin off the surface. A turn across a span under
353    // the deflection the caller accepted is below what they can see; the
354    // fraction keeps the test on a hole a fifth of a millimetre across,
355    // every segment of which is under the chord and a thirteenth of the
356    // whole.
357    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        // A cusp has no tangent to compare; the chord test still governs.
362        return Ok(false);
363    };
364    Ok(start.angle(end) > deflection.angular)
365}
366
367/// Discretize a pcurve with its chord tolerance measured *in space*,
368/// through the surface that gives its chart a metric.
369///
370/// [`discretize_planar`] measures in parameter units because it has no
371/// surface to convert through; this is the version that does. Each candidate
372/// segment is lifted to the surface and the sagitta measured between world
373/// points, so one chord tolerance means one thing whatever the chart's
374/// scale: a quarter-turn on a large cylinder refines further than the same
375/// quarter-turn on a small one.
376///
377/// # Errors
378///
379/// As [`discretize_planar`].
380pub 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            // Sagitta in world units: the lifted midpoint against the lifted
427            // chord.
428            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
451/// Approximate a planar curve in a surface's parameter space.
452///
453/// The deflection is measured in parameter units here, not in space, so a caller
454/// wanting a spatial tolerance has to convert through the surface's own scale:
455/// the two differ by orders of magnitude near a pole; [`discretize_on_surface`]
456/// is the version that measures through the surface. This exists for boundary
457/// work in parameter space; for a face's actual boundary, discretize the edge's
458/// 3D curve and evaluate the pcurve at those parameters instead, which is what
459/// keeps adjacent faces watertight.
460///
461/// # Errors
462///
463/// As [`discretize`].
464pub 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    /// The greatest distance from the curve to the polyline, sampled densely.
536    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        // The exemption has to survive trimming, or an edge built from a
556        // trimmed line (which is what a solid's edges usually are) pays the
557        // floor anyway and the saving evaporates.
558        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        // A straight curve has no chord error and no turn, so refinement must
572        // stop immediately rather than subdividing to the ceiling.
573        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        // Each chord is shorter than the arc it spans, so the polyline is always
626        // short, and refining closes the gap.
627        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        // A large circle with a loose chord tolerance: the chord error over a
661        // whole quadrant is huge in absolute terms but the *ratio* to the radius
662        // is what a viewer sees, and the tangent turn is what bounds it.
663        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        // Every segment really does turn by less than the limit.
685        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        // With a minimum of one, a full circle would come out as a single chord
695        // from a point back to itself: zero length, zero area, and no error
696        // reported anywhere.
697        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        // A downstream tolerance claim built on a polyline that never met its
717        // own would be untrue, with nothing to show why.
718        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        // Two faces meeting along an edge must sample its pcurves at exactly
738        // these values, or the join is not watertight. Discarding them would
739        // make that impossible to arrange.
740        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        // And they increase along the curve.
747        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        // The reason for adaptive rather than uniform sampling: a curve that is
753        // straight for half its length and tight for the rest should not pay
754        // for the tight part everywhere.
755        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        // Segments in the straight first half are longer than in the curved
783        // second half.
784        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        // "A thousandth of the part" survives the part being modelled in metres
846        // rather than millimetres; an absolute default does not.
847        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        // Two circles in a plane's chart, radii 5 and 100: one chord
888        // tolerance, measured in space, refines the big one further and
889        // holds both to the same sagitta.
890        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                // Every lifted segment's sagitta is within the chord,
913                // measured at the segment's own parameter midpoint.
914                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}