Skip to main content

ogeom_geom/
convert.rs

1//! Exact B-spline forms for the analytic curves.
2//!
3//! Every curve here has a *rational* B-spline form that is exact: not a fit, not
4//! an approximation to a tolerance. A circle is a piecewise rational quadratic
5//! and lands on the circle at every parameter, which is the whole reason
6//! rational weights exist and why `docs/PLAN.md` calls them load-bearing rather
7//! than an optional extra.
8//!
9//! # What conversion is for
10//!
11//! Three things need it. Exchange formats describe free-form geometry and
12//! nothing else, so an exact circle has to become a NURBS to be written at all.
13//! A general affine transform (a shear, a non-uniform scale) carries a circle
14//! to an ellipse and an ellipse to something with no analytic name, but carries
15//! a NURBS to a NURBS by moving its control points, exactly. And an algorithm
16//! that only knows one representation can be given every shape in it.
17//!
18//! # The parameter does not survive, and cannot
19//!
20//! A circle's parameter is its angle. Its rational quadratic form's is not, and
21//! no reparameterization of a rational quadratic makes it one; the two are
22//! related by an arctangent. So conversion preserves the *curve* and not the
23//! parameterization, and every converted curve is handed back on `[0, 1]`.
24//!
25//! That is why this is a geometry operation rather than a topology one. An edge
26//! converted this way needs its range restated and each of its pcurves re-derived
27//! against a surface whose parameterization has also moved, and re-deriving a
28//! pcurve is a fit rather than a construction. See `docs/PLAN.md`.
29//!
30//! Because the parameterization moves, an *arc* is built as an arc rather than
31//! built whole and trimmed: the span is what is converted, so the result covers
32//! exactly it.
33
34use core::f64::consts::TAU;
35
36use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
37use ogeom_math::{Frame, KnotVector, Point, Weighted};
38
39use crate::curve::{BSplineCurve, Curve};
40use crate::fit::{self, Fitted};
41use crate::traits::{Curve3d, Surface};
42use ogeom_core::ogeom_err;
43
44/// The widest span one rational quadratic Bézier is allowed to cover.
45///
46/// A quarter turn. The construction degrades as the span approaches half a
47/// turn (the tangents meet further and further away and the weight falls to
48/// zero), so the arc is split until every span is comfortably inside that.
49const MAX_SPAN: f64 = core::f64::consts::FRAC_PI_2;
50
51impl Curve {
52    /// This curve as a B-spline, exactly, over `[0, 1]`.
53    ///
54    /// Exact rather than fitted: the result passes through the same points as
55    /// the original at corresponding parameters, to rounding. The
56    /// *correspondence* is not the identity; see the module documentation for
57    /// why it cannot be.
58    ///
59    /// # Errors
60    ///
61    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the curve's
62    /// range is degenerate or its geometry cannot be evaluated.
63    pub fn to_bspline(&self, tol: Tolerances) -> OgeomResult<BSplineCurve> {
64        let (lo, hi) = self.domain();
65        self.to_bspline_over((lo, hi), tol)
66    }
67
68    /// This curve as a B-spline over part of its range.
69    ///
70    /// The span is what gets converted, rather than the whole curve being
71    /// converted and then trimmed. For a conic those differ: trimming a
72    /// rational quadratic needs knot insertion at a parameter that has to be
73    /// solved for, while building the arc directly is a closed form.
74    ///
75    /// # Errors
76    ///
77    /// As [`Curve::to_bspline`].
78    pub fn to_bspline_over(&self, range: (f64, f64), tol: Tolerances) -> OgeomResult<BSplineCurve> {
79        let (lo, hi) = range;
80        if !lo.is_finite() || !hi.is_finite() || hi <= lo + tol.parametric() {
81            ogeom_bail!(Construction, "cannot convert an empty range [{lo}, {hi}]");
82        }
83        match self {
84            // A segment is a degree-one B-spline with two control points, and
85            // that is not an approximation of a line, it is a line.
86            Self::Line(_) => segment(self.point_at(lo, tol)?, self.point_at(hi, tol)?),
87
88            Self::Circle(c) => {
89                let circle = c.circle();
90                let (a, b) = oriented(lo, hi, c.is_reversed());
91                conic_arc(circle.frame(), circle.radius(), circle.radius(), a, b, tol)
92            }
93            Self::Ellipse(e) => {
94                let ellipse = e.ellipse();
95                let (a, b) = oriented(lo, hi, e.is_reversed());
96                conic_arc(
97                    ellipse.frame(),
98                    ellipse.major_radius(),
99                    ellipse.minor_radius(),
100                    a,
101                    b,
102                    tol,
103                )
104            }
105
106            // A parabola is a quadratic, so one *polynomial* Bézier covers any
107            // span of it exactly, no weights needed. The middle control point
108            // is where the tangents at the ends meet.
109            Self::Parabola(_) | Self::Hyperbola(_) => tangent_quadratic(self, lo, hi, tol),
110
111            // Already one. Trimmed to the span, which for a spline is knot
112            // insertion and therefore exact.
113            Self::BSpline(s) => {
114                let (a, b) = s.knots().domain();
115                let mut out = s.clone();
116                if hi < b - tol.parametric() {
117                    out = out.split_at(hi, tol)?.0;
118                }
119                if lo > a + tol.parametric() {
120                    out = out.split_at(lo, tol)?.1;
121                }
122                normalized(out)
123            }
124
125            // A helix is transcendental: no rational B-spline states it
126            // exactly, and this function's contract is exactness. Fit it
127            // through the fitting machinery at a stated tolerance instead.
128            Self::Helix(_) => ogeom_bail!(
129                Construction,
130                "a helix has no exact B-spline form; fit it at a stated tolerance instead"
131            ),
132
133            // The same contract refuses the derived types whose spelling is
134            // a quotient or a composition: nothing rational states them.
135            Self::Offset(_) => ogeom_bail!(
136                Construction,
137                "an offset curve has no exact B-spline form; fit it at a stated tolerance instead"
138            ),
139            Self::OnSurface(_) => ogeom_bail!(
140                Construction,
141                "a surface curve has no exact B-spline form in general; fit it at a stated \
142                 tolerance instead"
143            ),
144
145            Self::Trimmed(t) => {
146                // A trim does not renumber anything: its parameter *is* its
147                // basis's, restricted to a sub-interval. Only a reversed trim
148                // moves one, and it mirrors within the trim's own range.
149                let (ta, tb) = t.domain();
150                if !t.is_reversed() {
151                    return t.basis().to_bspline_over((lo, hi), tol);
152                }
153                let at = |u: f64| ta + tb - u;
154                // Mirroring swaps the ends, so the basis is converted forwards
155                // and the result turned round.
156                let forwards = t.basis().to_bspline_over((at(hi), at(lo)), tol)?;
157                reverse(&forwards)
158            }
159        }
160    }
161}
162
163/// A degree-one B-spline between two points.
164fn segment(from: Point, to: Point) -> OgeomResult<BSplineCurve> {
165    let knots = KnotVector::new(vec![0.0, 0.0, 1.0, 1.0], 1)?;
166    BSplineCurve::rational(
167        knots,
168        vec![
169            Weighted {
170                scaled: from,
171                weight: 1.0,
172            },
173            Weighted {
174                scaled: to,
175                weight: 1.0,
176            },
177        ],
178    )
179}
180
181/// The angular range to build, accounting for a curve that runs backwards.
182///
183/// A reversed conic evaluates at the *negated* angle (not at a mirrored one
184/// within its range), so converting the span `[lo, hi]` of it means walking the
185/// underlying conic from `-lo` to `-hi`, which runs the other way round.
186const fn oriented(lo: f64, hi: f64, reversed: bool) -> (f64, f64) {
187    if reversed { (-lo, -hi) } else { (lo, hi) }
188}
189
190/// A circular or elliptical arc as a piecewise rational quadratic.
191///
192/// One construction serves both, because an ellipse is the affine image of a
193/// circle and the rational quadratic form is preserved by an affine map: the
194/// control points move with it and the *weights do not change at all*. Writing
195/// the ellipse case separately would be writing the same thing twice with two
196/// chances to get it wrong.
197fn conic_arc(
198    frame: Frame,
199    major: f64,
200    minor: f64,
201    from: f64,
202    to: f64,
203    tol: Tolerances,
204) -> OgeomResult<BSplineCurve> {
205    let sweep = to - from;
206    if sweep.abs() > TAU + tol.parametric() {
207        ogeom_bail!(
208            Construction,
209            "an arc of {sweep} radians covers the conic more than once"
210        );
211    }
212    #[allow(
213        clippy::cast_possible_truncation,
214        clippy::cast_sign_loss,
215        clippy::cast_precision_loss,
216        reason = "a span count bounded by four; the ceiling below is exact"
217    )]
218    let spans = ((sweep.abs() / MAX_SPAN).ceil() as usize).max(1);
219    #[allow(clippy::cast_precision_loss)]
220    let step = sweep / spans as f64;
221    // Half the span, which is the angle between a chord and the tangent at its
222    // end. Its cosine is the middle control point's weight, and the reciprocal
223    // is how far along the bisector that point sits.
224    let half = step * 0.5;
225    let (cos_half, reach) = (half.cos(), 1.0 / half.cos());
226    if cos_half <= tol.confusion() {
227        ogeom_bail!(
228            Construction,
229            "a span of {step} radians is too wide for one rational quadratic"
230        );
231    }
232
233    let at = |angle: f64| frame.to_world(Point::new(major * angle.cos(), minor * angle.sin(), 0.0));
234    let shoulder = |angle: f64| {
235        frame.to_world(Point::new(
236            major * reach * angle.cos(),
237            minor * reach * angle.sin(),
238            0.0,
239        ))
240    };
241
242    let mut control = Vec::with_capacity(2 * spans + 1);
243    control.push(Weighted {
244        scaled: at(from),
245        weight: 1.0,
246    });
247    for k in 0..spans {
248        #[allow(clippy::cast_precision_loss)]
249        let start = from + step * k as f64;
250        let middle = start + half;
251        let end = start + step;
252        // Stored homogeneous (the point already multiplied by its weight)
253        // because that is the form evaluation wants and converting on the way
254        // in and out again would only add rounding.
255        control.push(Weighted {
256            scaled: Point::from_vector(shoulder(middle).to_vector() * cos_half),
257            weight: cos_half,
258        });
259        control.push(Weighted {
260            scaled: at(end),
261            weight: 1.0,
262        });
263    }
264
265    let mut knots = vec![0.0, 0.0, 0.0];
266    for k in 1..spans {
267        #[allow(clippy::cast_precision_loss)]
268        let at_knot = k as f64 / spans as f64;
269        knots.push(at_knot);
270        knots.push(at_knot);
271    }
272    knots.extend([1.0, 1.0, 1.0]);
273    BSplineCurve::rational(KnotVector::new(knots, 2)?, control)
274}
275
276/// A span of a curve whose second derivative is constant in its own frame, as
277/// one quadratic Bézier through the meeting point of its end tangents.
278///
279/// Exact for a parabola, which *is* a quadratic. For a hyperbola the same
280/// construction is exact with a weight on the middle point, and the weight
281/// falls out of requiring the curve to pass through its own midpoint, which is
282/// what is solved for here rather than quoted from a table, so it stays right
283/// for any span.
284fn tangent_quadratic(
285    curve: &Curve,
286    lo: f64,
287    hi: f64,
288    tol: Tolerances,
289) -> OgeomResult<BSplineCurve> {
290    let (start, end) = (curve.point_at(lo, tol)?, curve.point_at(hi, tol)?);
291    let (ta, tb) = (curve.d1_at(lo, tol)?, curve.d1_at(hi, tol)?);
292
293    // Where the two end tangents meet. For a conic that point is the middle
294    // control point of its quadratic form.
295    let Some(shoulder) = meet(start, ta, end, tb, tol) else {
296        ogeom_bail!(
297            Construction,
298            "the end tangents of this span are parallel, so it has no quadratic \
299             form; split the range"
300        );
301    };
302
303    // The weight that makes the Bézier pass through the curve's own midpoint.
304    // At the Bézier's centre the three basis values are 1/4, 1/2, 1/4, so the
305    // point there is (A + 2wS + B) / (2 + 2w), and solving for w against the
306    // real midpoint is one division.
307    let middle = curve.point_at(f64::midpoint(lo, hi), tol)?;
308    let numerator = (start.to_vector() + end.to_vector()) * 0.5 - middle.to_vector();
309    let denominator = middle.to_vector() - shoulder.to_vector();
310    let weight = if denominator.magnitude() <= tol.confusion() {
311        1.0
312    } else {
313        let w = numerator.dot(denominator) / denominator.square_magnitude();
314        if !w.is_finite() || w <= tol.confusion() {
315            ogeom_bail!(
316                Construction,
317                "this span has no rational quadratic form; split the range"
318            );
319        }
320        w
321    };
322
323    let knots = KnotVector::new(vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0], 2)?;
324    BSplineCurve::rational(
325        knots,
326        vec![
327            Weighted {
328                scaled: start,
329                weight: 1.0,
330            },
331            Weighted {
332                scaled: Point::from_vector(shoulder.to_vector() * weight),
333                weight,
334            },
335            Weighted {
336                scaled: end,
337                weight: 1.0,
338            },
339        ],
340    )
341}
342
343/// Where two lines meet, or `None` if they are parallel or skew.
344fn meet(
345    a: Point,
346    along_a: ogeom_math::Vector,
347    b: Point,
348    along_b: ogeom_math::Vector,
349    tol: Tolerances,
350) -> Option<Point> {
351    let between = b - a;
352    let cross = along_a.cross(along_b);
353    let denominator = cross.square_magnitude();
354    if denominator <= tol.confusion() * tol.confusion() {
355        return None;
356    }
357    let t = between.cross(along_b).dot(cross) / denominator;
358    let found = a + along_a * t;
359    // Skew lines have a nearest approach rather than a meeting; only a real
360    // intersection is a control point.
361    let s = between.cross(along_a).dot(cross) / denominator;
362    if found.distance(b + along_b * s) > tol.confusion() {
363        return None;
364    }
365    Some(found)
366}
367
368/// A spline traced the other way.
369///
370/// The control points reverse and the knots mirror within their own span. No
371/// geometry moves; this is the same curve, walked backwards.
372fn reverse(curve: &BSplineCurve) -> OgeomResult<BSplineCurve> {
373    let (a, b) = curve.knots().domain();
374    let mut knots: Vec<f64> = curve.knots().knots().iter().map(|k| a + b - k).collect();
375    knots.reverse();
376    let mut control = curve.control_points().to_vec();
377    control.reverse();
378    BSplineCurve::rational(KnotVector::new(knots, curve.degree())?, control)
379}
380
381/// A spline over `[0, 1]`, whatever it was over.
382impl Curve {
383    /// This curve as a B-spline *fitted* over `range` to a stated
384    /// tolerance, at the curve's own parameters.
385    ///
386    /// The approximation [`Curve::to_bspline`] refuses to make silently:
387    /// a helix, an offset curve, a curve on a surface (anything with no
388    /// exact rational form) is sampled at its own parameters and fitted
389    /// through them, the fit measured against the curve *between* the
390    /// samples as well as at them, and the sampling doubled until the
391    /// tolerance is met or the budget runs out. The result is
392    /// same-parameter with the curve, so every chart already speaking the
393    /// curve's parameter still does, and its `error` is what was measured,
394    /// not what was asked.
395    ///
396    /// # Errors
397    ///
398    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
399    /// the range is empty or the tolerance is not a distance; as
400    /// [`fit::fit_points_at`].
401    pub fn fitted_bspline_over(
402        &self,
403        range: (f64, f64),
404        tolerance: f64,
405        tol: Tolerances,
406    ) -> OgeomResult<Fitted<BSplineCurve>> {
407        same_parameter_fit(self, range, 3, tolerance, tol)
408    }
409}
410
411impl BSplineCurve {
412    /// This curve at a degree no higher than `max_degree`, to a stated
413    /// tolerance: itself where it already is, and otherwise a fit at its
414    /// own parameters: what an exchange format with a degree limit needs
415    /// written. Same-parameter with the original, error measured.
416    ///
417    /// # Errors
418    ///
419    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
420    /// `max_degree` is zero, the curve is periodic (a ring has no ends to
421    /// fit between), or the tolerance is not a distance.
422    pub fn restricted_to_degree(
423        &self,
424        max_degree: usize,
425        tolerance: f64,
426        tol: Tolerances,
427    ) -> OgeomResult<Fitted<Self>> {
428        if max_degree == 0 {
429            ogeom_bail!(Construction, "a curve needs a degree of at least one");
430        }
431        if self.degree() <= max_degree {
432            return Ok(Fitted {
433                curve: self.clone(),
434                error: 0.0,
435                met: true,
436            });
437        }
438        if self.is_periodic() {
439            ogeom_bail!(
440                Construction,
441                "a periodic curve has no ends to fit between; reseam it first"
442            );
443        }
444        let domain = self.domain();
445        same_parameter_fit(
446            &Curve::BSpline(self.clone()),
447            domain,
448            max_degree,
449            tolerance,
450            tol,
451        )
452    }
453}
454
455impl crate::surface::BSplineSurface {
456    /// This patch at degrees no higher than `max_degree` in either
457    /// direction, to a stated tolerance: itself where it already is, and
458    /// otherwise a fit through a grid of its own points, the grid doubled
459    /// until the fit holds every sample to the tolerance or the budget
460    /// runs out. The fit's parameterization is its own (chord-length
461    /// through the grid, not the patch's), so a pcurve spoken against the
462    /// patch must be re-derived against the result.
463    ///
464    /// # Errors
465    ///
466    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
467    /// `max_degree` is zero or the tolerance is not a distance; as
468    /// [`fit::fit_surface_grid`].
469    pub fn restricted_to_degree(
470        &self,
471        max_degree: usize,
472        tolerance: f64,
473        tol: Tolerances,
474    ) -> OgeomResult<Fitted<Self>> {
475        if max_degree == 0 {
476            ogeom_bail!(Construction, "a patch needs a degree of at least one");
477        }
478        if self.u_knots().degree() <= max_degree && self.v_knots().degree() <= max_degree {
479            return Ok(Fitted {
480                curve: self.clone(),
481                error: 0.0,
482                met: true,
483            });
484        }
485        grid_fitted(
486            |u, v| self.point_at(u, v, tol),
487            self.domain(),
488            max_degree,
489            tolerance,
490            tol,
491        )
492    }
493}
494
495/// A patch fitted at `degree` through a grid of `point` over `domain`, the
496/// grid doubled until the fit holds every sample to `tolerance` or the
497/// budget runs out; the best fit either way, its error measured at the
498/// samples.
499fn grid_fitted(
500    point: impl Fn(f64, f64) -> OgeomResult<Point>,
501    domain: ((f64, f64), (f64, f64)),
502    degree: usize,
503    tolerance: f64,
504    tol: Tolerances,
505) -> OgeomResult<Fitted<crate::surface::BSplineSurface>> {
506    if !(tolerance > 0.0 && tolerance.is_finite()) {
507        ogeom_bail!(Construction, "a tolerance of {tolerance} is not a distance");
508    }
509    let ((ua, ub), (va, vb)) = domain;
510    if ![ua, ub, va, vb].iter().all(|x| x.is_finite()) {
511        ogeom_bail!(Construction, "an unbounded surface cannot be fitted");
512    }
513    let mut samples = 16usize;
514    let mut best: Option<Fitted<crate::surface::BSplineSurface>> = None;
515    for _ in 0..4 {
516        let mut rows: Vec<Vec<Point>> = Vec::with_capacity(samples + 1);
517        for j in 0..=samples {
518            #[allow(clippy::cast_precision_loss)]
519            let v = va + (vb - va) * j as f64 / samples as f64;
520            let mut row = Vec::with_capacity(samples + 1);
521            for i in 0..=samples {
522                #[allow(clippy::cast_precision_loss)]
523                let u = ua + (ub - ua) * i as f64 / samples as f64;
524                row.push(point(u, v)?);
525            }
526            rows.push(row);
527        }
528        let fitted = fit::fit_surface_grid(&rows, degree, tolerance, tol)?;
529        if fitted.met {
530            return Ok(fitted);
531        }
532        if best.as_ref().is_none_or(|b| fitted.error < b.error) {
533            best = Some(fitted);
534        }
535        samples *= 2;
536    }
537    best.ok_or_else(|| ogeom_err!(Construction, "the patch could not be sampled"))
538}
539
540impl crate::surface::SurfaceGeometry {
541    /// This surface as a B-spline *fitted* over its own domain to a stated
542    /// tolerance: the approximation [`to_bspline`](Self::to_bspline)
543    /// refuses to make silently, for an offset surface or anything else
544    /// with no exact rational form. A grid of the surface's points is
545    /// fitted at degree three, the grid doubled until every sample is
546    /// within the tolerance or the budget runs out, and `error` is what
547    /// was measured. The fit's parameterization is its own, so a pcurve
548    /// spoken against the surface must be re-derived against the result.
549    ///
550    /// # Errors
551    ///
552    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
553    /// the domain is unbounded or the tolerance is not a distance; as
554    /// [`fit::fit_surface_grid`].
555    pub fn fitted_bspline(
556        &self,
557        tolerance: f64,
558        tol: Tolerances,
559    ) -> OgeomResult<Fitted<crate::surface::BSplineSurface>> {
560        use crate::traits::Surface as _;
561        grid_fitted(
562            |u, v| self.point_at(u, v, tol),
563            self.domain(),
564            3,
565            tolerance,
566            tol,
567        )
568    }
569}
570
571/// A fit of `curve` over `range` at the curve's own parameters, measured
572/// between the samples as well as at them, the sampling doubled until the
573/// tolerance is met or the budget runs out.
574fn same_parameter_fit(
575    curve: &Curve,
576    range: (f64, f64),
577    degree: usize,
578    tolerance: f64,
579    tol: Tolerances,
580) -> OgeomResult<Fitted<BSplineCurve>> {
581    let (lo, hi) = range;
582    if !lo.is_finite() || !hi.is_finite() || hi <= lo + tol.parametric() {
583        ogeom_bail!(Construction, "cannot fit over an empty range [{lo}, {hi}]");
584    }
585    if !(tolerance > 0.0 && tolerance.is_finite()) {
586        ogeom_bail!(Construction, "a tolerance of {tolerance} is not a distance");
587    }
588    let mut samples = 32usize;
589    let mut best: Option<Fitted<BSplineCurve>> = None;
590    for _ in 0..8 {
591        #[allow(clippy::cast_precision_loss)]
592        let params: Vec<f64> = (0..=samples)
593            .map(|i| lo + (hi - lo) * i as f64 / samples as f64)
594            .collect();
595        let points = params
596            .iter()
597            .map(|t| curve.point_at(*t, tol))
598            .collect::<OgeomResult<Vec<Point>>>()?;
599        let fitted = fit::fit_points_at(&params, &points, degree, tolerance, tol)?;
600        let mut error = fitted.error;
601        for pair in params.windows(2) {
602            let t = f64::midpoint(pair[0], pair[1]);
603            error = error.max(
604                curve
605                    .point_at(t, tol)?
606                    .distance(fitted.curve.point_at(t, tol)?),
607            );
608        }
609        let candidate = Fitted {
610            curve: fitted.curve,
611            error,
612            met: error <= tolerance,
613        };
614        if candidate.met {
615            return Ok(candidate);
616        }
617        if best.as_ref().is_none_or(|b| error < b.error) {
618            best = Some(candidate);
619        }
620        samples *= 2;
621    }
622    best.ok_or_else(|| ogeom_err!(Construction, "the curve could not be sampled"))
623}
624
625fn normalized(curve: BSplineCurve) -> OgeomResult<BSplineCurve> {
626    let knots = curve.knots().reparameterized(0.0, 1.0)?;
627    BSplineCurve::rational(knots, curve.control_points().to_vec())
628}
629
630#[cfg(test)]
631#[allow(clippy::unwrap_used)]
632mod tests {
633    use super::*;
634    use core::f64::consts::{FRAC_PI_2, PI};
635    use ogeom_math::{Circle, Direction, Ellipse, Hyperbola, Parabola, Vector};
636
637    use crate::curve::{
638        CircleCurve, EllipseCurve, HyperbolaCurve, LineCurve, ParabolaCurve, TrimmedCurve,
639    };
640
641    const T: Tolerances = Tolerances::millimetres();
642
643    /// A helix has no exact form and the exact conversion still says so;
644    /// asked for a fit at a stated tolerance, it comes back same-parameter
645    /// with the helix, within the tolerance everywhere, the error reported
646    /// as measured.
647    #[test]
648    fn a_helix_fits_to_a_stated_tolerance_at_its_own_parameters() {
649        let helix: Curve = crate::curve::HelixCurve::new(ogeom_math::Frame::WORLD, 5.0, 4.0, 2.0)
650            .unwrap()
651            .into();
652        assert!(helix.to_bspline(T).is_err(), "the exact conversion refuses");
653        let domain = helix.domain();
654        let fitted = helix.fitted_bspline_over(domain, 1e-3, T).unwrap();
655        assert!(fitted.met && fitted.error <= 1e-3, "error {}", fitted.error);
656        let (lo, hi) = fitted.curve.domain();
657        assert!((lo - domain.0).abs() < 1e-12 && (hi - domain.1).abs() < 1e-12);
658        for i in 0..=200 {
659            let t = domain.0 + (domain.1 - domain.0) * f64::from(i) / 200.0;
660            let gap = helix
661                .point_at(t, T)
662                .unwrap()
663                .distance(fitted.curve.point_at(t, T).unwrap());
664            assert!(gap <= 1e-3, "same-parameter within tolerance at {t}: {gap}");
665        }
666    }
667
668    /// A cubic raised to a quintic restricted back to degree three is the
669    /// cubic again, to rounding; one already low enough is itself.
670    #[test]
671    fn a_curve_restricted_in_degree_holds_its_tolerance() {
672        let cubic = BSplineCurve::new(
673            ogeom_math::KnotVector::clamped_uniform(3, 6).unwrap(),
674            vec![
675                Point::new(0.0, 0.0, 0.0),
676                Point::new(1.0, 2.0, 0.5),
677                Point::new(2.5, 1.0, -0.5),
678                Point::new(4.0, 3.0, 1.0),
679                Point::new(5.0, 0.5, 0.0),
680                Point::new(6.0, 2.0, 2.0),
681            ],
682            T,
683        )
684        .unwrap();
685        let quintic = cubic.elevated(T).unwrap().elevated(T).unwrap();
686        assert_eq!(quintic.degree(), 5);
687        let same = quintic.restricted_to_degree(5, 1e-6, T).unwrap();
688        assert!(same.met && same.error == 0.0 && same.curve.degree() == 5);
689        let back = quintic.restricted_to_degree(3, 1e-6, T).unwrap();
690        assert!(back.met && back.curve.degree() == 3, "error {}", back.error);
691        let (lo, hi) = cubic.domain();
692        for i in 0..=50 {
693            let t = lo + (hi - lo) * f64::from(i) / 50.0;
694            let gap = cubic
695                .point_at(t, T)
696                .unwrap()
697                .distance(back.curve.point_at(t, T).unwrap());
698            assert!(gap < 1e-6, "the cubic again at {t}: {gap}");
699        }
700    }
701
702    /// A bicubic patch restricted to degree two holds every sample of
703    /// itself to the tolerance asked, at the lower degree both ways.
704    #[test]
705    fn a_patch_restricted_in_degree_holds_its_samples() {
706        let (nu, nv) = (5, 4);
707        let mut points = Vec::with_capacity(nu * nv);
708        for i in 0..nu {
709            for j in 0..nv {
710                #[allow(clippy::cast_precision_loss)]
711                let (x, y) = (i as f64, j as f64);
712                points.push(Point::new(x, y, (x * 0.7).sin() * (y * 0.5).cos() * 0.3));
713            }
714        }
715        let patch = crate::surface::BSplineSurface::new(
716            ogeom_math::KnotVector::clamped_uniform(3, nu).unwrap(),
717            ogeom_math::KnotVector::clamped_uniform(3, nv).unwrap(),
718            &ogeom_math::ControlGrid::new(points, nu, nv).unwrap(),
719            T,
720        )
721        .unwrap();
722        let lower = patch.restricted_to_degree(2, 1e-2, T).unwrap();
723        assert!(lower.met && lower.error <= 1e-2, "error {}", lower.error);
724        assert!(lower.curve.u_knots().degree() == 2 && lower.curve.v_knots().degree() == 2);
725        let same = patch.restricted_to_degree(3, 1e-2, T).unwrap();
726        assert!(same.met && same.error == 0.0);
727    }
728
729    /// The greatest distance from any point of the converted curve to the
730    /// original, sampled densely.
731    ///
732    /// The parameterizations differ, so the comparison is *geometric*: for each
733    /// sample of the conversion, how far is it from the curve it came from. For
734    /// a conic that distance has a closed form, so no search is involved.
735    fn deviation(original: &Curve, converted: &BSplineCurve, samples: usize) -> f64 {
736        let (a, b) = converted.knots().domain();
737        let mut worst = 0.0_f64;
738        for i in 0..=samples {
739            #[allow(clippy::cast_precision_loss)]
740            let u = a + (b - a) * i as f64 / samples as f64;
741            let p = converted.point_at(u, T).unwrap();
742            worst = worst.max(distance_to(original, p));
743        }
744        worst
745    }
746
747    /// Distance from a point to an analytic curve, in closed form.
748    fn distance_to(curve: &Curve, p: Point) -> f64 {
749        match curve {
750            Curve::Circle(c) => c.circle().distance_to(p),
751            Curve::Ellipse(e) => {
752                // Not closed form, so fall back to a dense parameter search.
753                nearest(curve, p, e.ellipse().major_radius())
754            }
755            _ => nearest(curve, p, 1.0),
756        }
757    }
758
759    /// Distance from a point to a curve, by a coarse scan and then a local
760    /// refinement.
761    ///
762    /// The scan alone is not good enough to measure a conversion by: its error
763    /// is set by the step it takes, so a test built on it reports the sampling
764    /// resolution rather than the conversion's accuracy. Refining around the
765    /// best sample takes it to where the answer is about the curve again.
766    fn nearest(curve: &Curve, p: Point, _scale: f64) -> f64 {
767        const SCAN: usize = 2_000;
768        let (a, b) = curve.domain();
769        let at = |u: f64| curve.point_at(u, T).map_or(f64::MAX, |q| p.distance(q));
770
771        let mut best = (a, f64::MAX);
772        for i in 0..=SCAN {
773            #[allow(clippy::cast_precision_loss)]
774            let u = a + (b - a) * i as f64 / SCAN as f64;
775            let d = at(u);
776            if d < best.1 {
777                best = (u, d);
778            }
779        }
780        // Ternary search on the bracket either side of the best sample. The
781        // distance is unimodal there for every curve this is used on.
782        #[allow(clippy::cast_precision_loss)]
783        let step = (b - a) / SCAN as f64;
784        let (mut lo, mut hi) = (best.0 - step, best.0 + step);
785        for _ in 0..200 {
786            let one = lo + (hi - lo) / 3.0;
787            let two = hi - (hi - lo) / 3.0;
788            if at(one) < at(two) {
789                hi = two;
790            } else {
791                lo = one;
792            }
793        }
794        at(f64::midpoint(lo, hi)).min(best.1)
795    }
796
797    #[test]
798    fn a_line_becomes_a_degree_one_spline_through_its_own_ends() {
799        let from = Point::new(1.0, 2.0, 3.0);
800        let to = Point::new(4.0, -1.0, 0.5);
801        let line: Curve = LineCurve::segment(from, to, T).unwrap().into();
802        let spline = line.to_bspline(T).unwrap();
803
804        assert_eq!(spline.degree(), 1);
805        assert!(!spline.is_rational(), "a line needs no weights");
806        assert!(spline.point_at(0.0, T).unwrap().is_equal(from, T));
807        assert!(spline.point_at(1.0, T).unwrap().is_equal(to, T));
808        assert!(spline.point_at(0.5, T).unwrap().is_equal(
809            Point::from_vector((from.to_vector() + to.to_vector()) * 0.5),
810            T
811        ));
812    }
813
814    #[test]
815    fn a_full_circle_becomes_an_exact_rational_quadratic() {
816        // Exact, not fitted: every sample lands on the circle to rounding. This
817        // is the property that makes rational weights load-bearing: a
818        // polynomial spline cannot represent a circle at all, only approach it.
819        let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 2.5, T).unwrap()).into();
820        let spline = circle.to_bspline(T).unwrap();
821
822        assert_eq!(spline.degree(), 2);
823        assert!(spline.is_rational(), "a circle needs its weights");
824        assert!(
825            deviation(&circle, &spline, 500) < 1e-12,
826            "off the circle by {}",
827            deviation(&circle, &spline, 500)
828        );
829        // And it closes.
830        assert!(
831            spline
832                .point_at(0.0, T)
833                .unwrap()
834                .is_equal(spline.point_at(1.0, T).unwrap(), T)
835        );
836    }
837
838    #[test]
839    fn an_arc_covers_its_own_span_and_no_more() {
840        // Built as an arc rather than built whole and trimmed, so the ends are
841        // the arc's ends exactly.
842        let circle = Circle::new(Frame::WORLD, 3.0, T).unwrap();
843        let curve: Curve = CircleCurve::new(circle).into();
844        for (from, to) in [
845            (0.0, FRAC_PI_2),
846            (0.3, 1.9),
847            (PI, PI * 1.5),
848            (0.0, PI * 1.75),
849        ] {
850            let spline = curve.to_bspline_over((from, to), T).unwrap();
851            assert!(
852                spline
853                    .point_at(0.0, T)
854                    .unwrap()
855                    .is_equal(curve.point_at(from, T).unwrap(), T),
856                "arc [{from}, {to}] starts in the wrong place"
857            );
858            assert!(
859                spline
860                    .point_at(1.0, T)
861                    .unwrap()
862                    .is_equal(curve.point_at(to, T).unwrap(), T),
863                "arc [{from}, {to}] ends in the wrong place"
864            );
865            assert!(deviation(&curve, &spline, 200) < 1e-12);
866        }
867    }
868
869    #[test]
870    fn an_ellipse_uses_the_same_construction_and_the_same_weights() {
871        // An ellipse is the affine image of a circle, and an affine map carries
872        // a rational quadratic to a rational quadratic with the weights
873        // untouched. One routine, not two.
874        let ellipse: Curve =
875            EllipseCurve::new(Ellipse::new(Frame::WORLD, 5.0, 2.0, T).unwrap()).into();
876        let spline = ellipse.to_bspline(T).unwrap();
877        assert_eq!(spline.degree(), 2);
878        assert!(deviation(&ellipse, &spline, 400) < 1e-9);
879
880        let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 5.0, T).unwrap()).into();
881        let round = circle.to_bspline(T).unwrap();
882        let weights: Vec<f64> = spline.control_points().iter().map(|c| c.weight).collect();
883        let same: Vec<f64> = round.control_points().iter().map(|c| c.weight).collect();
884        assert_eq!(weights, same, "the weights should not depend on the radii");
885    }
886
887    #[test]
888    fn a_conic_in_a_tilted_frame_converts_where_it_actually_is() {
889        let frame = Frame::new(
890            Point::new(3.0, -2.0, 7.0),
891            Direction::from_coords(1.0, 1.0, 1.0, T).unwrap(),
892            Direction::from_coords(1.0, -1.0, 0.0, T).unwrap(),
893            T,
894        )
895        .unwrap();
896        let circle: Curve = CircleCurve::new(Circle::new(frame, 4.0, T).unwrap()).into();
897        let spline = circle.to_bspline(T).unwrap();
898        assert!(deviation(&circle, &spline, 300) < 1e-12);
899    }
900
901    #[test]
902    fn a_parabola_is_a_polynomial_quadratic_exactly() {
903        // Degree two and no weights: a parabola *is* a quadratic, so the
904        // conversion is not even rational.
905        let parabola: Curve = ParabolaCurve::new(Parabola::new(Frame::WORLD, 1.5, T).unwrap(), 4.0)
906            .unwrap()
907            .into();
908        let spline = parabola.to_bspline(T).unwrap();
909        assert_eq!(spline.degree(), 2);
910        assert!(deviation(&parabola, &spline, 300) < 1e-9);
911        for c in spline.control_points() {
912            assert!(
913                (c.weight - 1.0).abs() < 1e-9,
914                "a parabola should need no weights, got {}",
915                c.weight
916            );
917        }
918    }
919
920    #[test]
921    fn a_hyperbola_becomes_a_rational_quadratic() {
922        let hyperbola: Curve =
923            HyperbolaCurve::new(Hyperbola::new(Frame::WORLD, 2.0, 1.0, T).unwrap(), 1.0)
924                .unwrap()
925                .into();
926        let spline = hyperbola.to_bspline(T).unwrap();
927        assert_eq!(spline.degree(), 2);
928        assert!(
929            deviation(&hyperbola, &spline, 300) < 1e-9,
930            "off by {}",
931            deviation(&hyperbola, &spline, 300)
932        );
933    }
934
935    #[test]
936    fn a_spline_converts_to_itself_and_a_trimmed_one_to_its_piece() {
937        let knots = KnotVector::new(vec![0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0], 2).unwrap();
938        let control = vec![
939            Point::ORIGIN,
940            Point::new(1.0, 2.0, 0.0),
941            Point::new(3.0, 2.0, 1.0),
942            Point::new(4.0, 0.0, 0.0),
943        ];
944        let spline: Curve = BSplineCurve::new(knots, control, T).unwrap().into();
945        let same = spline.to_bspline(T).unwrap();
946        assert!(deviation(&spline, &same, 200) < 1e-12);
947
948        let trimmed: Curve = Curve::Trimmed(Box::new(
949            TrimmedCurve::new(spline.clone(), 0.25, 0.75, T).unwrap(),
950        ));
951        let piece = trimmed.to_bspline(T).unwrap();
952        assert!(
953            piece
954                .point_at(0.0, T)
955                .unwrap()
956                .is_equal(spline.point_at(0.25, T).unwrap(), T)
957        );
958        assert!(
959            piece
960                .point_at(1.0, T)
961                .unwrap()
962                .is_equal(spline.point_at(0.75, T).unwrap(), T)
963        );
964        assert!(deviation(&spline, &piece, 200) < 1e-12);
965    }
966
967    #[test]
968    fn a_reversed_conic_converts_to_the_curve_it_actually_traces() {
969        use crate::traits::Reversible;
970        let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 1.0, T).unwrap()).into();
971        let backwards = circle.reversed();
972        let spline = backwards.to_bspline_over((0.0, FRAC_PI_2), T).unwrap();
973
974        assert!(
975            spline
976                .point_at(0.0, T)
977                .unwrap()
978                .is_equal(backwards.point_at(0.0, T).unwrap(), T),
979            "a reversed arc should start where the reversed curve does"
980        );
981        assert!(
982            spline
983                .point_at(1.0, T)
984                .unwrap()
985                .is_equal(backwards.point_at(FRAC_PI_2, T).unwrap(), T)
986        );
987    }
988
989    #[test]
990    fn an_empty_range_is_refused() {
991        let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 1.0, T).unwrap()).into();
992        assert!(circle.to_bspline_over((1.0, 1.0), T).is_err());
993        assert!(circle.to_bspline_over((1.0, 0.0), T).is_err());
994        assert!(circle.to_bspline_over((0.0, f64::NAN), T).is_err());
995        let _ = Vector::ZERO;
996    }
997}
998
999// --- surfaces ----------------------------------------------------------------
1000
1001impl crate::surface::SurfaceGeometry {
1002    /// This surface as a B-spline patch, exactly, over `[0, 1]` in both
1003    /// directions.
1004    ///
1005    /// Every analytic surface here is a surface of revolution or a ruled one,
1006    /// so its exact form falls out of the curve conversion above rather than
1007    /// needing a construction of its own: revolve an exactly-converted profile
1008    /// and the patch is exact wherever the profile was.
1009    ///
1010    /// As for curves, the *parameterization* does not survive: a cylinder's
1011    /// `u` is an angle and the patch's is not. See the module documentation.
1012    ///
1013    /// # Errors
1014    ///
1015    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
1016    /// surface's own extents are degenerate, or its geometry cannot be
1017    /// evaluated.
1018    pub fn to_bspline(&self, tol: Tolerances) -> OgeomResult<crate::surface::BSplineSurface> {
1019        self.to_bspline_over(self.domain(), tol)
1020    }
1021
1022    /// This surface over `window` of its own parameters as a B-spline patch,
1023    /// exactly: [`to_bspline`](Self::to_bspline) for a window other than the
1024    /// surface's own extents. A trimmed surface converts as its basis over
1025    /// the trim's window.
1026    ///
1027    /// # Errors
1028    ///
1029    /// As [`to_bspline`](Self::to_bspline).
1030    fn to_bspline_over(
1031        &self,
1032        window: ((f64, f64), (f64, f64)),
1033        tol: Tolerances,
1034    ) -> OgeomResult<crate::surface::BSplineSurface> {
1035        use crate::surface::SurfaceGeometry as S;
1036        let ((ua, ub), (va, vb)) = window;
1037        match self {
1038            // An offset of a free-form basis has no exact spline form
1039            // (the unit normal is a quotient), and this function's contract
1040            // is exactness.
1041            S::Offset(_) => ogeom_bail!(
1042                Construction,
1043                "an offset surface has no exact B-spline form; offset the basis analytically \
1044                 or fit at a stated tolerance instead"
1045            ),
1046
1047            // Ruled both ways: four corners and a bilinear patch, which is a
1048            // degree-one B-spline in each direction and exact for a plane.
1049            S::Plane(_) => {
1050                let corner = |u: f64, v: f64| self.point_at(u, v, tol);
1051                bilinear(
1052                    corner(ua, va)?,
1053                    corner(ub, va)?,
1054                    corner(ua, vb)?,
1055                    corner(ub, vb)?,
1056                )
1057            }
1058
1059            // Ruled along `v`: the profile at each end of the height, lofted.
1060            // Exact because a cylinder and a cone are straight in `v`.
1061            S::Cylinder(_) | S::Cone(_) => {
1062                let profile = |v: f64| -> OgeomResult<Vec<Weighted<Point>>> {
1063                    let mut out = Vec::new();
1064                    let ring = self.section_at(v, (ua, ub), tol)?;
1065                    out.extend_from_slice(ring.control_points());
1066                    Ok(out)
1067                };
1068                let (knots, _) = {
1069                    let ring = self.section_at(va, (ua, ub), tol)?;
1070                    (ring.knots().clone(), ())
1071                };
1072                loft(&knots, &profile(va)?, &profile(vb)?)
1073            }
1074
1075            // Closed in both directions and revolved: build the profile once
1076            // and turn it, which is the same construction the surface is.
1077            S::Sphere(sp) => {
1078                let sphere = sp.sphere();
1079                let frame = sphere.frame();
1080                // The meridian at u = 0, framed so its own angle is the
1081                // latitude exactly: x one radius out along the equator, y
1082                // toward the north pole.
1083                let meridian = Frame::new(frame.origin(), -frame.y(), frame.x(), tol)?;
1084                let circle = ogeom_math::Circle::new(meridian, sphere.radius(), tol)?;
1085                let profile: Curve = crate::curve::CircleCurve::new(circle).into();
1086                revolved_patch(
1087                    &profile,
1088                    (va, vb),
1089                    ogeom_math::Axis {
1090                        location: frame.origin(),
1091                        direction: frame.z(),
1092                    },
1093                    (ua, ub),
1094                    tol,
1095                )
1096            }
1097            S::Torus(t) => {
1098                let torus = t.torus();
1099                let frame = torus.frame();
1100                let centre = frame.origin() + frame.x().vector() * torus.major_radius();
1101                let tube = Frame::new(centre, -frame.y(), frame.x(), tol)?;
1102                let circle = ogeom_math::Circle::new(tube, torus.minor_radius(), tol)?;
1103                let profile: Curve = crate::curve::CircleCurve::new(circle).into();
1104                revolved_patch(
1105                    &profile,
1106                    (va, vb),
1107                    ogeom_math::Axis {
1108                        location: frame.origin(),
1109                        direction: frame.z(),
1110                    },
1111                    (ua, ub),
1112                    tol,
1113                )
1114            }
1115            S::Revolution(r) => revolved_patch(r.curve(), (va, vb), r.axis(), (ua, ub), tol),
1116
1117            S::Extrusion(e) => {
1118                let base = e.curve().to_bspline_over((ua, ub), tol)?;
1119                let along = e.direction().vector() * (vb - va);
1120                let start: Vec<Weighted<Point>> = base
1121                    .control_points()
1122                    .iter()
1123                    .map(|c| Weighted {
1124                        scaled: Point::from_vector(
1125                            c.scaled.to_vector() + e.direction().vector() * va * c.weight,
1126                        ),
1127                        weight: c.weight,
1128                    })
1129                    .collect();
1130                let end: Vec<Weighted<Point>> = start
1131                    .iter()
1132                    .map(|c| Weighted {
1133                        scaled: Point::from_vector(c.scaled.to_vector() + along * c.weight),
1134                        weight: c.weight,
1135                    })
1136                    .collect();
1137                loft(base.knots(), &start, &end)
1138            }
1139
1140            S::BSpline(s) => {
1141                let whole = s.domain();
1142                let near = |a: (f64, f64), b: (f64, f64)| {
1143                    (a.0 - b.0).abs() <= tol.parametric() && (a.1 - b.1).abs() <= tol.parametric()
1144                };
1145                if near(whole.0, (ua, ub)) && near(whole.1, (va, vb)) {
1146                    Ok(s.clone())
1147                } else {
1148                    s.segment((ua, ub), (va, vb), tol)
1149                }
1150            }
1151
1152            S::Trimmed(t) => t.basis().to_bspline_over(window, tol),
1153        }
1154    }
1155
1156    /// The profile of a surface of revolution at one height, as a spline.
1157    fn section_at(
1158        &self,
1159        v: f64,
1160        u_range: (f64, f64),
1161        tol: Tolerances,
1162    ) -> OgeomResult<crate::curve::BSplineCurve> {
1163        use crate::surface::SurfaceGeometry as S;
1164        let (frame, radius) = match self {
1165            S::Cylinder(c) => (c.cylinder().frame(), c.cylinder().radius()),
1166            S::Cone(c) => (c.cone().frame(), c.cone().radius_at(v).abs()),
1167            _ => ogeom_bail!(Construction, "this surface has no circular section"),
1168        };
1169        let at = Frame::new(frame.origin() + frame.z() * v, frame.z(), frame.x(), tol)?;
1170        conic_arc(at, radius, radius, u_range.0, u_range.1, tol)
1171    }
1172}
1173
1174/// A rational profile revolved about an axis, exactly.
1175///
1176/// The construction every surface of revolution shares. The profile converts
1177/// to its exact rational form; the turn is the exact rational unit circle
1178/// over the swept angle; and each surface control point is a profile control
1179/// point *rotated by an arc control point*, with the weights multiplied. The
1180/// identity behind it: rotation about the axis is linear in `(cos u, sin u)`,
1181/// the tensor product factors, and the patch evaluates to precisely
1182/// `Rot_u(profile(v))` wherever both conversions were exact.
1183fn revolved_patch(
1184    profile: &Curve,
1185    v_range: (f64, f64),
1186    axis: ogeom_math::Axis,
1187    u_range: (f64, f64),
1188    tol: Tolerances,
1189) -> OgeomResult<crate::surface::BSplineSurface> {
1190    let frame = Frame::about(axis.location, axis.direction);
1191    let turn: Curve =
1192        crate::curve::CircleCurve::new(ogeom_math::Circle::new(frame, 1.0, tol)?).into();
1193    let arc = turn.to_bspline_over(u_range, tol)?;
1194    let pro = profile.to_bspline_over(v_range, tol)?;
1195
1196    let locals: Vec<(f64, f64, f64, f64)> = pro
1197        .control_points()
1198        .iter()
1199        .map(|c| {
1200            let l = frame.to_local(Point::from_vector(c.scaled.to_vector() / c.weight));
1201            (l.x, l.y, l.z, c.weight)
1202        })
1203        .collect();
1204    let mut points = Vec::with_capacity(arc.control_points().len() * locals.len());
1205    for ci in arc.control_points() {
1206        let l = frame.to_local(Point::from_vector(ci.scaled.to_vector() / ci.weight));
1207        let (a, b) = (l.x, l.y);
1208        for &(x, y, z, wj) in &locals {
1209            let weight = ci.weight * wj;
1210            let rotated =
1211                frame.to_world(Point::new(a.mul_add(x, -(b * y)), b.mul_add(x, a * y), z));
1212            points.push(Weighted {
1213                scaled: Point::from_vector(rotated.to_vector() * weight),
1214                weight,
1215            });
1216        }
1217    }
1218    let grid = ogeom_math::ControlGrid::new(points, arc.control_points().len(), locals.len())?;
1219    crate::surface::BSplineSurface::rational(arc.knots().clone(), pro.knots().clone(), grid)
1220}
1221
1222/// A bilinear patch through four corners.
1223fn bilinear(a: Point, b: Point, c: Point, d: Point) -> OgeomResult<crate::surface::BSplineSurface> {
1224    let line = KnotVector::new(vec![0.0, 0.0, 1.0, 1.0], 1)?;
1225    let grid = ogeom_math::ControlGrid::new(
1226        vec![
1227            Weighted {
1228                scaled: a,
1229                weight: 1.0,
1230            },
1231            Weighted {
1232                scaled: c,
1233                weight: 1.0,
1234            },
1235            Weighted {
1236                scaled: b,
1237                weight: 1.0,
1238            },
1239            Weighted {
1240                scaled: d,
1241                weight: 1.0,
1242            },
1243        ],
1244        2,
1245        2,
1246    )?;
1247    crate::surface::BSplineSurface::rational(line.clone(), line, grid)
1248}
1249
1250/// A patch ruled between two rows of control points sharing one knot vector.
1251fn loft(
1252    across: &KnotVector,
1253    start: &[Weighted<Point>],
1254    end: &[Weighted<Point>],
1255) -> OgeomResult<crate::surface::BSplineSurface> {
1256    if start.len() != end.len() {
1257        ogeom_bail!(
1258            Dimension,
1259            "a ruled patch needs the same control points at each end, got {} \
1260             and {}",
1261            start.len(),
1262            end.len()
1263        );
1264    }
1265    let mut points = Vec::with_capacity(start.len() * 2);
1266    for (a, b) in start.iter().zip(end) {
1267        points.push(*a);
1268        points.push(*b);
1269    }
1270    let grid = ogeom_math::ControlGrid::new(points, start.len(), 2)?;
1271    let along = KnotVector::new(vec![0.0, 0.0, 1.0, 1.0], 1)?;
1272    crate::surface::BSplineSurface::rational(across.clone(), along, grid)
1273}
1274
1275#[cfg(test)]
1276#[allow(clippy::unwrap_used)]
1277mod surface_tests {
1278    use super::*;
1279    use crate::surface::{
1280        ConeSurface, CylinderSurface, ExtrusionSurface, PlaneSurface, SphereSurface,
1281        SurfaceGeometry, TorusSurface, TrimmedSurface,
1282    };
1283    use ogeom_math::{Circle, Cone, Cylinder, Direction, Plane, Sphere, Torus};
1284
1285    const T: Tolerances = Tolerances::millimetres();
1286
1287    /// How far the converted patch strays from the surface it came from.
1288    ///
1289    /// Measured *implicitly* (the distance from each sampled point of the
1290    /// patch to the analytic surface) rather than by comparing the two at
1291    /// proportional parameters. Comparing parameters would be measuring the
1292    /// wrong thing: a rational quadratic's parameter is not proportional to the
1293    /// angle it sweeps, so even an exact conversion disagrees pointwise, and a
1294    /// test built that way reports the reparameterization instead of the error.
1295    fn deviation(distance: impl Fn(Point) -> f64, patch: &crate::surface::BSplineSurface) -> f64 {
1296        let ((pa, pb), (qa, qb)) = patch.domain();
1297        let mut worst = 0.0_f64;
1298        for i in 0..=60 {
1299            for j in 0..=60 {
1300                #[allow(clippy::cast_precision_loss)]
1301                let (s, t) = (i as f64 / 60.0, j as f64 / 60.0);
1302                if let Ok(p) = patch.point_at(pa + (pb - pa) * s, qa + (qb - qa) * t, T) {
1303                    worst = worst.max(distance(p).abs());
1304                }
1305            }
1306        }
1307        worst
1308    }
1309
1310    /// Whether a patch reaches the same corners as the surface it converted.
1311    ///
1312    /// The implicit test says the patch lies *on* the surface; this says it
1313    /// covers the same piece of it, which the implicit test alone cannot.
1314    fn spans_the_same(original: &SurfaceGeometry, patch: &crate::surface::BSplineSurface) -> bool {
1315        let ((ua, ub), (va, vb)) = original.domain();
1316        let ((pa, pb), (qa, qb)) = patch.domain();
1317        [
1318            (ua, va, pa, qa),
1319            (ua, vb, pa, qb),
1320            (ub, va, pb, qa),
1321            (ub, vb, pb, qb),
1322        ]
1323        .iter()
1324        .all(|(u, v, p, q)| {
1325            match (original.point_at(*u, *v, T), patch.point_at(*p, *q, T)) {
1326                (Ok(a), Ok(b)) => a.is_equal(b, T),
1327                _ => false,
1328            }
1329        })
1330    }
1331
1332    #[test]
1333    fn a_plane_becomes_a_bilinear_patch() {
1334        let plane: SurfaceGeometry =
1335            PlaneSurface::over(Plane::new(Frame::WORLD), (-2.0, 5.0), (-1.0, 3.0))
1336                .unwrap()
1337                .into();
1338        let patch = plane.to_bspline(T).unwrap();
1339        assert!(!patch.is_rational(), "a plane needs no weights");
1340        let flat = Plane::new(Frame::WORLD);
1341        assert!(deviation(|p| flat.distance_to(p), &patch) < 1e-12);
1342        assert!(spans_the_same(&plane, &patch));
1343    }
1344
1345    #[test]
1346    fn a_cylinder_becomes_an_exact_rational_patch() {
1347        // Circular in `u` and straight in `v`, so the exact patch is the exact
1348        // circle lofted, and it lands on the cylinder everywhere, not near it.
1349        let cylinder: SurfaceGeometry =
1350            CylinderSurface::new(Cylinder::new(Frame::WORLD, 2.0, T).unwrap(), (0.0, 5.0))
1351                .unwrap()
1352                .into();
1353        let patch = cylinder.to_bspline(T).unwrap();
1354        assert!(patch.is_rational());
1355        let exact = Cylinder::new(Frame::WORLD, 2.0, T).unwrap();
1356        let off = deviation(|p| exact.distance_to(p), &patch);
1357        assert!(off < 1e-12, "off the cylinder by {off}");
1358        assert!(spans_the_same(&cylinder, &patch));
1359    }
1360
1361    #[test]
1362    fn a_cone_becomes_an_exact_rational_patch() {
1363        let cone: SurfaceGeometry = ConeSurface::new(
1364            Cone::new(Frame::WORLD, 1.0, 0.4_f64.atan(), T).unwrap(),
1365            (0.0, 4.0),
1366        )
1367        .unwrap()
1368        .into();
1369        let patch = cone.to_bspline(T).unwrap();
1370        let exact = Cone::new(Frame::WORLD, 1.0, 0.4_f64.atan(), T).unwrap();
1371        let off = deviation(|p| exact.distance_to(p), &patch);
1372        assert!(off < 1e-12, "off the cone by {off}");
1373        assert!(spans_the_same(&cone, &patch));
1374    }
1375
1376    #[test]
1377    fn an_extrusion_becomes_its_profile_lofted() {
1378        let circle = crate::curve::CircleCurve::new(Circle::new(Frame::WORLD, 3.0, T).unwrap());
1379        let extrusion: SurfaceGeometry = ExtrusionSurface::new(circle.into(), Direction::Z, 6.0)
1380            .unwrap()
1381            .into();
1382        let patch = extrusion.to_bspline(T).unwrap();
1383        // A circle swept along its own axis is a cylinder, so the implicit test
1384        // is the cylinder's.
1385        let exact = Cylinder::new(Frame::WORLD, 3.0, T).unwrap();
1386        let off = deviation(|p| exact.distance_to(p), &patch);
1387        assert!(off < 1e-12, "off the swept circle by {off}");
1388        assert!(spans_the_same(&extrusion, &patch));
1389    }
1390
1391    #[test]
1392    fn a_patch_converts_to_itself() {
1393        let cylinder: SurfaceGeometry =
1394            CylinderSurface::new(Cylinder::new(Frame::WORLD, 1.0, T).unwrap(), (0.0, 1.0))
1395                .unwrap()
1396                .into();
1397        let patch = cylinder.to_bspline(T).unwrap();
1398        let again: SurfaceGeometry = patch.clone().into();
1399        let twice = again.to_bspline(T).unwrap();
1400        assert_eq!(patch, twice);
1401    }
1402
1403    #[test]
1404    fn a_sphere_becomes_an_exact_rational_patch() {
1405        let sphere = Sphere::new(Frame::WORLD, 2.5, T).unwrap();
1406        let surface: SurfaceGeometry = SphereSurface::new(sphere).into();
1407        let patch = surface.to_bspline(T).unwrap();
1408        assert!(patch.is_rational(), "a sphere needs weights");
1409        assert!(deviation(|p| sphere.distance_to(p), &patch) < 1e-12);
1410        assert!(spans_the_same(&surface, &patch));
1411    }
1412
1413    #[test]
1414    fn a_torus_becomes_an_exact_rational_patch() {
1415        let torus = Torus::new(Frame::WORLD, 3.0, 1.0, T).unwrap();
1416        let surface: SurfaceGeometry = TorusSurface::new(torus).into();
1417        let patch = surface.to_bspline(T).unwrap();
1418        assert!(patch.is_rational(), "a torus needs weights");
1419        assert!(deviation(|p| torus.distance_to(p), &patch) < 1e-12);
1420        assert!(spans_the_same(&surface, &patch));
1421    }
1422
1423    #[test]
1424    fn a_revolution_becomes_the_exact_patch_its_own_construction_is() {
1425        // A line parallel to the axis, revolved three quarters of a turn: the
1426        // surface is a cylinder wall, so the patch can be measured against
1427        // the cylinder's own signed distance, an independent authority, not
1428        // the revolution evaluating itself.
1429        use crate::curve::LineCurve;
1430        let line =
1431            LineCurve::segment(Point::new(2.0, 0.0, 0.0), Point::new(2.0, 0.0, 5.0), T).unwrap();
1432        let surface: SurfaceGeometry = crate::surface::RevolutionSurface::new(
1433            line.into(),
1434            ogeom_math::Axis {
1435                location: Point::new(0.0, 0.0, 0.0),
1436                direction: ogeom_math::Direction::Z,
1437            },
1438            1.5 * core::f64::consts::PI,
1439        )
1440        .unwrap()
1441        .into();
1442        let cylinder = ogeom_math::Cylinder::new(Frame::WORLD, 2.0, T).unwrap();
1443        let patch = surface.to_bspline(T).unwrap();
1444        assert!(deviation(|p| cylinder.distance_to(p), &patch) < 1e-12);
1445        assert!(spans_the_same(&surface, &patch));
1446    }
1447
1448    #[test]
1449    fn a_trimmed_surface_converts_as_its_basis_over_the_window() {
1450        let plane: SurfaceGeometry = PlaneSurface::new(Plane::new(Frame::WORLD)).into();
1451        let trimmed: SurfaceGeometry = SurfaceGeometry::Trimmed(Box::new(
1452            TrimmedSurface::new(plane, (0.0, 1.0), (2.0, 5.0), T).unwrap(),
1453        ));
1454        let patch = trimmed.to_bspline(T).unwrap();
1455        let flat = Plane::new(Frame::WORLD);
1456        assert!(deviation(|p| flat.distance_to(p), &patch) < 1e-12);
1457        assert!(spans_the_same(&trimmed, &patch));
1458    }
1459
1460    #[test]
1461    fn a_trimmed_spline_converts_to_its_piece() {
1462        let plane: SurfaceGeometry =
1463            PlaneSurface::over(Plane::new(Frame::WORLD), (0.0, 4.0), (0.0, 4.0))
1464                .unwrap()
1465                .into();
1466        let spline: SurfaceGeometry = plane.to_bspline(T).unwrap().into();
1467        let trimmed: SurfaceGeometry = SurfaceGeometry::Trimmed(Box::new(
1468            TrimmedSurface::new(spline, (0.25, 0.5), (0.0, 0.75), T).unwrap(),
1469        ));
1470        let patch = trimmed.to_bspline(T).unwrap();
1471        assert!(spans_the_same(&trimmed, &patch));
1472    }
1473
1474    #[test]
1475    fn a_spline_segment_keeps_its_parameters() {
1476        let cylinder: SurfaceGeometry = CylinderSurface::new(
1477            ogeom_math::Cylinder::new(Frame::WORLD, 2.0, T).unwrap(),
1478            (0.0, 3.0),
1479        )
1480        .unwrap()
1481        .into();
1482        let whole = cylinder.to_bspline(T).unwrap();
1483        let ((ua, ub), (va, vb)) = whole.domain();
1484        let u = (ua + 0.2 * (ub - ua), ua + 0.7 * (ub - ua));
1485        let v = (va + 0.1 * (vb - va), va + 0.6 * (vb - va));
1486        let piece = whole.segment(u, v, T).unwrap();
1487        assert_eq!(piece.domain(), (u, v));
1488        for i in 0..=8 {
1489            for j in 0..=8 {
1490                let s = u.0 + (u.1 - u.0) * f64::from(i) / 8.0;
1491                let t = v.0 + (v.1 - v.0) * f64::from(j) / 8.0;
1492                let off = piece
1493                    .point_at(s, t, T)
1494                    .unwrap()
1495                    .distance(whole.point_at(s, t, T).unwrap());
1496                assert!(off < 1e-12, "{off} off at ({s}, {t})");
1497            }
1498        }
1499    }
1500}
1501
1502// --- general affine transforms ------------------------------------------------
1503
1504impl Curve {
1505    /// This curve carried through a general affine transform.
1506    ///
1507    /// A shear or an uneven scale is not a placement: it carries a circle to an
1508    /// ellipse, and an ellipse to a conic with no analytic name here. So the
1509    /// curve is converted to its exact B-spline form first and the *control
1510    /// points* are moved, which an affine map does exactly: a B-spline is an
1511    /// affine combination of its control points, so transforming them and
1512    /// transforming every point of the curve are the same thing.
1513    ///
1514    /// The result is a B-spline whatever went in, and its parameterization is
1515    /// the converted one rather than the original. That is the price of a
1516    /// transform the analytic types cannot express, and it is why
1517    /// [`transformed`](crate::traits::Transformable::transformed) takes only a
1518    /// [`Transform`](ogeom_math::Transform): a placement keeps the type, and only
1519    /// this does not.
1520    ///
1521    /// # Errors
1522    ///
1523    /// As [`Curve::to_bspline`], plus
1524    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
1525    /// transform collapses the curve to a point.
1526    pub fn general_transformed(
1527        &self,
1528        t: &ogeom_math::GeneralTransform,
1529        tol: Tolerances,
1530    ) -> OgeomResult<BSplineCurve> {
1531        let spline = self.to_bspline(tol)?;
1532        let moved: Vec<Weighted<Point>> = spline
1533            .control_points()
1534            .iter()
1535            .map(|c| {
1536                // Stored homogeneous, so the point has already been multiplied
1537                // by its weight. An affine map is not linear (it has a
1538                // translation), so the translation has to be scaled by the
1539                // weight too, or a rational curve's control points drift apart
1540                // from its weights and the curve leaves the shape entirely.
1541                let position = c.point();
1542                Weighted {
1543                    scaled: Point::from_vector(t.apply(position).to_vector() * c.weight),
1544                    weight: c.weight,
1545                }
1546            })
1547            .collect();
1548        if moved
1549            .iter()
1550            .all(|c| c.point().is_equal(moved[0].point(), tol))
1551        {
1552            ogeom_bail!(
1553                Construction,
1554                "this transform collapses the curve to a point; it is singular \
1555                 in the curve's own directions"
1556            );
1557        }
1558        BSplineCurve::rational(spline.knots().clone(), moved)
1559    }
1560}
1561
1562#[cfg(test)]
1563#[allow(clippy::unwrap_used)]
1564mod affine_tests {
1565    use super::*;
1566    use crate::curve::{CircleCurve, LineCurve};
1567    use ogeom_math::{Circle, GeneralTransform, Matrix3, Vector};
1568
1569    const T: Tolerances = Tolerances::millimetres();
1570
1571    #[test]
1572    fn an_uneven_scale_carries_a_circle_onto_the_ellipse_it_should() {
1573        // The case a placement cannot express. A circle of radius one scaled by
1574        // three in x and one in y is the ellipse with those radii, and the
1575        // converted curve lands on it exactly rather than near it.
1576        let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 1.0, T).unwrap()).into();
1577        let stretch = GeneralTransform::new(
1578            Matrix3::new([[3.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]),
1579            Vector::ZERO,
1580        );
1581        let moved = circle.general_transformed(&stretch, T).unwrap();
1582
1583        let (a, b) = moved.knots().domain();
1584        for i in 0..=400 {
1585            #[allow(clippy::cast_precision_loss)]
1586            let u = a + (b - a) * i as f64 / 400.0;
1587            let p = moved.point_at(u, T).unwrap();
1588            let on_ellipse = (p.x / 3.0).powi(2) + p.y.powi(2);
1589            assert!(
1590                (on_ellipse - 1.0).abs() < 1e-12,
1591                "at {u} the point {p:?} is not on the ellipse: {on_ellipse}"
1592            );
1593        }
1594    }
1595
1596    #[test]
1597    fn a_shear_is_exact_because_a_spline_is_an_affine_combination() {
1598        // Transforming the control points and transforming every point of the
1599        // curve are the same operation, which is what makes this exact rather
1600        // than fitted.
1601        let line: Curve = LineCurve::segment(Point::ORIGIN, Point::new(2.0, 3.0, 0.0), T)
1602            .unwrap()
1603            .into();
1604        let shear = GeneralTransform::new(
1605            Matrix3::new([[1.0, 0.7, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]),
1606            Vector::new(1.0, 2.0, 3.0),
1607        );
1608        let moved = line.general_transformed(&shear, T).unwrap();
1609
1610        for i in 0..=50 {
1611            #[allow(clippy::cast_precision_loss)]
1612            let u = i as f64 / 50.0;
1613            let before =
1614                line.point_at(line.domain().0 + (line.domain().1 - line.domain().0) * u, T);
1615            let after = moved.point_at(u, T).unwrap();
1616            assert!(shear.apply(before.unwrap()).is_equal(after, T));
1617        }
1618    }
1619
1620    #[test]
1621    fn a_rational_curves_weights_survive_the_move() {
1622        // The trap: control points are stored already multiplied by their
1623        // weight, so a transform with a translation has to scale the
1624        // translation by the weight too. Getting that wrong leaves a circle's
1625        // control points and weights inconsistent, and the curve wanders off
1626        // the shape entirely, most visibly under a pure translation, where
1627        // nothing should change but the position.
1628        let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 2.0, T).unwrap()).into();
1629        let shift = GeneralTransform::new(Matrix3::IDENTITY, Vector::new(10.0, -4.0, 6.0));
1630        let moved = circle.general_transformed(&shift, T).unwrap();
1631
1632        let centre = Point::new(10.0, -4.0, 6.0);
1633        let (a, b) = moved.knots().domain();
1634        for i in 0..=300 {
1635            #[allow(clippy::cast_precision_loss)]
1636            let u = a + (b - a) * i as f64 / 300.0;
1637            let p = moved.point_at(u, T).unwrap();
1638            assert!(
1639                (p.distance(centre) - 2.0).abs() < 1e-12,
1640                "at {u} the radius is {}",
1641                p.distance(centre)
1642            );
1643        }
1644    }
1645
1646    #[test]
1647    fn a_transform_that_collapses_the_curve_is_refused() {
1648        // Projecting a circle in the xy plane onto the z axis leaves a point.
1649        // A "curve" that is one point is not a curve, and returning it would
1650        // hand back something every later algorithm divides by the length of.
1651        let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 1.0, T).unwrap()).into();
1652        let squash = GeneralTransform::new(Matrix3::new([[0.0; 3]; 3]), Vector::ZERO);
1653        assert!(circle.general_transformed(&squash, T).is_err());
1654    }
1655}