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///
28/// Only the chord and the angle subdivide; there is no cap on a segment's or
29/// a triangle's length. A straight edge is drawn as its two ends and a planar
30/// face as the triangulation of its outline, however long either is. A
31/// curved edge or face is subdivided until it meets both tolerances. Inside
32/// a curved face the grid the tolerances chose may also be spread so that
33/// no cell is many times longer than it is wide: a ratio of that grid's own
34/// spacing, which keeps the triangles between its rows from bridging
35/// columns, not a length.
36#[derive(Debug, Clone, Copy, PartialEq)]
37pub struct Deflection {
38    /// The greatest allowed distance from the polyline to the curve.
39    pub chord: f64,
40    /// The greatest allowed turn in the tangent across one segment, in radians.
41    pub angular: f64,
42    /// A floor on the number of segments for a curve that bends.
43    ///
44    /// A closed curve needs at least three to enclose anything, and a nearly
45    /// straight arc of one would otherwise collapse to a chord that misses the
46    /// bulge entirely: the midpoint test is a sample, and one sample can be
47    /// placed exactly where the curve happens to cross its own chord.
48    ///
49    /// It does *not* apply to a straight curve, which is exactly represented by
50    /// its endpoints. Splitting a line adds points that no tolerance asked for,
51    /// and every one of them becomes a vertex in every face the edge bounds.
52    pub min_segments: usize,
53    /// A ceiling, so a pathological curve cannot exhaust memory.
54    ///
55    /// Reaching it is reported rather than passed off as success; see
56    /// [`Polyline::deflection_met`].
57    pub max_segments: usize,
58}
59
60impl Default for Deflection {
61    fn default() -> Self {
62        Self {
63            // A tenth of a millimetre at unit scale: invisible on screen. On
64            // a small curved part the angular limit below rules instead, and
65            // a mesh at this default can be a part in a hundred off in
66            // volume. Mass properties integrate a face on its exact surface
67            // wherever its pcurves allow, and mesh only what they cannot.
68            chord: 1e-1,
69            // Half a radian (twenty-eight degrees, a circle in thirteen
70            // segments): coarse enough for a viewer that lets the user
71            // tighten it. The interior of a face is held to it as its edges
72            // are, so a tighter default is paid for on every curved face.
73            angular: 0.5,
74            min_segments: 2,
75            max_segments: 4096,
76        }
77    }
78}
79
80impl Deflection {
81    /// A deflection with a given chord tolerance and the default angular one.
82    ///
83    /// # Errors
84    ///
85    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `chord` is
86    /// not finite and positive.
87    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    /// A deflection held to `chord` and `angular` alone, with the default
101    /// segment bounds.
102    ///
103    /// The tessellation a measurement is taken against: flat faces stay as
104    /// few triangles as their outlines allow, and curved ones are drawn to
105    /// the chord. Every deflection subdivides this way (see [`Deflection`]);
106    /// this names both tolerances and checks them.
107    ///
108    /// # Errors
109    ///
110    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `chord` or
111    /// `angular` is not finite and positive.
112    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    /// A deflection scaled to a model of the given size.
123    ///
124    /// Relative tolerances are what a caller usually means: "a thousandth of the
125    /// part" is a statement that survives the part being modelled in metres
126    /// rather than millimetres, and an absolute default does not.
127    ///
128    /// # Errors
129    ///
130    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `size` or
131    /// `fraction` is not finite and positive.
132    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    /// Check that the settings are usable.
143    ///
144    /// # Errors
145    ///
146    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if a tolerance
147    /// is not positive, or the segment bounds are inconsistent.
148    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/// A curve approximated by points, with the parameters they came from.
179///
180/// The parameters are kept, not discarded, because the pcurve of the same edge
181/// on an adjacent face has to be sampled at exactly these values for the two
182/// faces to meet.
183#[derive(Debug, Clone, PartialEq)]
184pub struct Polyline {
185    /// The points, in order along the curve.
186    pub points: Vec<Point>,
187    /// The parameter each point came from.
188    pub parameters: Vec<f64>,
189    /// Whether the requested deflection was actually achieved.
190    ///
191    /// `false` when the segment ceiling was reached first. Reporting it beats
192    /// silently returning a coarser polyline than asked for, which would make
193    /// a downstream tolerance claim untrue with nothing to show why.
194    pub deflection_met: bool,
195}
196
197impl Polyline {
198    /// Number of segments.
199    #[must_use]
200    pub fn segment_count(&self) -> usize {
201        self.points.len().saturating_sub(1)
202    }
203
204    /// Total length of the polyline.
205    ///
206    /// An underestimate of the curve's own length, since a chord is shorter
207    /// than the arc it spans, and one that improves as the deflection tightens.
208    #[must_use]
209    pub fn length(&self) -> f64 {
210        self.points.windows(2).map(|w| w[0].distance(w[1])).sum()
211    }
212
213    /// Whether the polyline returns to where it started.
214    #[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/// Whether a curve is a straight line, seeing through any trimming.
224///
225/// A line is its own polyline, so subdividing it is pure cost: the extra points
226/// land on the curve, satisfy every tolerance, and then propagate into the
227/// triangulation of every face the edge bounds.
228#[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/// Whether a planar curve is a straight line in parameter space.
238#[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
247/// Approximate `curve` over `range` within `deflection`.
248///
249/// Adaptive bisection: split a segment whenever its midpoint is further from the
250/// chord than allowed, or the tangent turns too far across it. Uniform sampling
251/// is the obvious alternative and wastes points on the straight parts of a curve
252/// while still missing the tight ones; the whole difficulty of tessellation is
253/// that curvature is not uniform.
254///
255/// # Errors
256///
257/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the deflection
258/// settings are unusable or the range is empty;
259/// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) if the range leaves the curve.
260pub 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    // Start from the floor, so a closed curve is never approximated by a single
273    // chord from a point back to itself. A straight curve is exempt: its
274    // endpoints already represent it exactly.
275    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
293/// The seed parameters halved `times` over, by the midpoints the bisection
294/// would take, and the curve's points there.
295fn 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
317/// The leftmost-first bisection from the seed parameters: each segment
318/// split while [`needs_split`] asks it to, up to the segment cap. A curve
319/// the cap stops part-way is split again by rounds instead, so the points
320/// the cap allows spread over the whole curve rather than crowding its
321/// start and leaving the rest at its seeds.
322fn 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
337/// Every segment that [`needs_split`] asks to split, split at once, round
338/// after round, until none asks or the next round would pass the segment
339/// cap: the polyline is as fine everywhere as the cap lets it be, never
340/// fine at one end and bare at the other.
341fn 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
403/// [`bisect`] leftmost first, stopping where the segment cap is reached.
404fn 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    // Leftmost-first subdivision with a worklist: each segment is settled
417    // before the walk moves right, and a split pushes its halves back for
418    // re-examination. The splits come in the same order a rescan from zero
419    // would make them, so the same floats come out, without re-asking every
420    // settled segment on every pass or shifting the vectors per split.
421    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    // The unsettled boundary, rightmost at the bottom; the top is always the
427    // segment just right of the settled prefix.
428    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            // The count the cap sees is every point currently alive, settled
443            // and pending together.
444            if parameters.len() + pending.len() + 1 > deflection.max_segments {
445                met = false;
446                splitting = false;
447            } else if mid <= t0 || mid >= t1 {
448                // A split that does not actually divide the interval means
449                // the parameters have reached the resolution of f64;
450                // refining further would loop without improving anything.
451                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
470/// How many times a circular arc's seed segments are halved, all of them
471/// alike: on a circle every segment of one depth spans the same angle, so
472/// [`needs_split`] answers them all the same, and the depth it settles at
473/// is the first whose span passes. `None` for any other curve, where the
474/// segment cap would stop the halving part-way, or where a span stands
475/// within rounding of a test's limit.
476fn 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        // Where a test that decides stands within rounding of its limit, the
494        // bisection's own segments, each rounded its own way, fall either
495        // side of it, and only the bisection says which.
496        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
532/// Where one segment that violates either tolerance splits: its middle
533/// parameter and the curve's point there. `None` for a segment within both.
534fn 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    // Chord error, measured at the midpoint. Not a bound on the true maximum
548    // deviation, which would need the curve's second derivative over the span;
549    // it is the standard estimate, and bisection drives it down regardless.
550    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    // Angular error: how far the tangent turns across the segment. Chord error
559    // alone misses a long, gently curving span, which is exactly where a
560    // silhouette goes visibly polygonal.
561    //
562    // Not over a segment that is both shorter than the chord tolerance and
563    // a small fraction of the whole edge. A fitted edge often ends in a
564    // hook a few microns long (the fit overshooting its vertex and turning
565    // back), and the tangent turns through a right angle across it at
566    // every scale. Asked of it, the angular test bisects the hook down to
567    // the resolution of the parameter and hands the face a fan of hairs at
568    // one corner, each a fin off the surface. A turn across a span under
569    // the deflection the caller accepted is below what they can see; the
570    // fraction keeps the test on a hole a fifth of a millimetre across,
571    // every segment of which is under the chord and a thirteenth of the
572    // whole.
573    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        // A cusp has no tangent to compare; the chord test still governs.
578        return Ok(None);
579    };
580    Ok(split.filter(|_| start.angle(end) > deflection.angular))
581}
582
583/// Discretize a pcurve with its chord tolerance measured *in space*,
584/// through the surface that gives its chart a metric.
585///
586/// [`discretize_planar`] measures in parameter units because it has no
587/// surface to convert through; this is the version that does. Each candidate
588/// segment is lifted to the surface and the sagitta measured between world
589/// points, so one chord tolerance means one thing whatever the chart's
590/// scale: a quarter-turn on a large cylinder refines further than the same
591/// quarter-turn on a small one.
592///
593/// # Errors
594///
595/// As [`discretize_planar`].
596pub 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            // Sagitta in world units: the lifted midpoint against the lifted
643            // chord.
644            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
667/// Approximate a planar curve in a surface's parameter space.
668///
669/// The deflection is measured in parameter units here, not in space, so a caller
670/// wanting a spatial tolerance has to convert through the surface's own scale:
671/// the two differ by orders of magnitude near a pole. [`discretize_on_surface`]
672/// is the version that measures through the surface. This exists for boundary
673/// work in parameter space; for a face's actual boundary, discretize the edge's
674/// 3D curve and evaluate the pcurve at those parameters instead, which is what
675/// keeps adjacent faces watertight.
676///
677/// # Errors
678///
679/// As [`discretize`].
680pub 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    /// A circle is halved uniformly in closed form, and comes out exactly
752    /// as the bisection would make it: the same parameters, bit for bit,
753    /// over radii, arcs and deflections either side of each test's limit.
754    #[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    /// The greatest distance from the curve to the polyline, sampled densely.
787    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        // The exemption has to survive trimming, or an edge built from a
807        // trimmed line (which is what a solid's edges usually are) pays the
808        // floor anyway and the saving evaporates.
809        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        // A straight curve has no chord error and no turn, so refinement must
823        // stop immediately rather than subdividing to the ceiling.
824        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        // Each chord is shorter than the arc it spans, so the polyline is always
877        // short, and refining closes the gap.
878        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        // A large circle with a loose chord tolerance: the chord error over a
912        // whole quadrant is huge in absolute terms but the *ratio* to the radius
913        // is what a viewer sees, and the tangent turn is what bounds it.
914        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        // Every segment really does turn by less than the limit.
936        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        // With a minimum of one, a full circle would come out as a single chord
946        // from a point back to itself: zero length, zero area, and no error
947        // reported anywhere.
948        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        // A downstream tolerance claim built on a polyline that never met its
968        // own would be untrue, with nothing to show why.
969        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        // A wavy spline drawn to a chord far finer than the ceiling allows:
989        // the points the ceiling does allow spread over the whole curve,
990        // or the end the bisection never reached is drawn as a few long
991        // chords across its waves, and a face bounded by it meshes past
992        // its rim.
993        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        // Two faces meeting along an edge must sample its pcurves at exactly
1027        // these values, or the join is not watertight. Discarding them would
1028        // make that impossible to arrange.
1029        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        // And they increase along the curve.
1036        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        // The reason for adaptive rather than uniform sampling: a curve that is
1042        // straight for half its length and tight for the rest should not pay
1043        // for the tight part everywhere.
1044        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        // Segments in the straight first half are longer than in the curved
1072        // second half.
1073        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        // "A thousandth of the part" survives the part being modelled in metres
1135        // rather than millimetres; an absolute default does not.
1136        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        // Two circles in a plane's chart, radii 5 and 100: one chord
1177        // tolerance, measured in space, refines the big one further and
1178        // holds both to the same sagitta.
1179        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                // Every lifted segment's sagitta is within the chord,
1202                // measured at the segment's own parameter midpoint.
1203                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}