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 they are load-bearing rather than an
7//! 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.
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 at its own
459    /// parameters, measured between the samples as well as at them and
460    /// the grid refined where it misses, until the tolerance holds or the
461    /// budget runs out. The fit spans the patch's own domain, but its
462    /// knots are its own, so a pcurve spoken against the patch is
463    /// re-derived against the result.
464    ///
465    /// # Errors
466    ///
467    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
468    /// `max_degree` is zero or the tolerance is not a distance; as
469    /// [`fit::fit_surface_grid`].
470    pub fn restricted_to_degree(
471        &self,
472        max_degree: usize,
473        tolerance: f64,
474        tol: Tolerances,
475    ) -> OgeomResult<Fitted<Self>> {
476        if max_degree == 0 {
477            ogeom_bail!(Construction, "a patch needs a degree of at least one");
478        }
479        if self.u_knots().degree() <= max_degree && self.v_knots().degree() <= max_degree {
480            return Ok(Fitted {
481                curve: self.clone(),
482                error: 0.0,
483                met: true,
484            });
485        }
486        grid_fitted(
487            |u, v| self.point_at(u, v, tol),
488            self.domain(),
489            max_degree,
490            tolerance,
491            tol,
492        )
493    }
494}
495
496/// A patch fitted at `degree` to `point` over `domain` by
497/// [`fit::fit_surface_sampled`]: at the surface's own parameters, from
498/// sixteen spans a side, measured between the samples as well as at them
499/// and refined where it misses, to at most 512 spans a side.
500///
501/// Where `point` refuses (an offset's basis has no normal at a pole), the
502/// fit takes the limit of the points around it, as [`limit_at`] reads it,
503/// and refuses only where they close on no one point. The limit's own
504/// slack is added to the error stated; the fit aims a thousandth under
505/// `tolerance` so that slack still leaves it met.
506fn grid_fitted(
507    point: impl Fn(f64, f64) -> OgeomResult<Point>,
508    domain: ((f64, f64), (f64, f64)),
509    degree: usize,
510    tolerance: f64,
511    tol: Tolerances,
512) -> OgeomResult<Fitted<crate::surface::BSplineSurface>> {
513    const LIMIT_SHARE: f64 = 1e-3;
514    let ((ua, ub), (va, vb)) = domain;
515    if ![ua, ub, va, vb].iter().all(|x| x.is_finite()) || ub <= ua || vb <= va {
516        ogeom_bail!(
517            Construction,
518            "an unbounded or empty surface cannot be fitted"
519        );
520    }
521    let slack = core::cell::Cell::new(0.0_f64);
522    let limited = |u: f64, v: f64| -> OgeomResult<Point> {
523        point(u, v).or_else(|refused| {
524            let (at, spread) = limit_at(&point, domain, (u, v), tolerance * LIMIT_SHARE)
525                .ok_or_else(|| {
526                    ogeom_err!(
527                        Construction,
528                        "the surface cannot be evaluated at ({u}, {v}) and has no limit there: \
529                         {refused}"
530                    )
531                })?;
532            slack.set(slack.get().max(spread));
533            Ok(at)
534        })
535    };
536    let mut fitted = fit::fit_surface_sampled(
537        limited,
538        &fit::Sampling::even(domain, 16),
539        degree,
540        tolerance * (1.0 - LIMIT_SHARE),
541        tol,
542    )?;
543    fitted.error += slack.get();
544    fitted.met = fitted.error <= tolerance;
545    Ok(fitted)
546}
547
548/// The limit of `point` at `(u, v)`, where it cannot be evaluated: the
549/// mean of the points read on a ring of parameters around it, the ring
550/// shrunk from a hundred-thousandth of the domain until every point on it
551/// lies within `spread` of their mean, with the widest of them from it. A
552/// ring point outside the domain is pulled back onto its edge; one `point`
553/// refuses too is passed over. `None` where no ring closes that tight: the
554/// points around do not close on one point, as an offset cone's around
555/// its apex do not.
556fn limit_at(
557    point: &impl Fn(f64, f64) -> OgeomResult<Point>,
558    ((ua, ub), (va, vb)): ((f64, f64), (f64, f64)),
559    (u, v): (f64, f64),
560    spread: f64,
561) -> Option<(Point, f64)> {
562    const AROUND: u32 = 16;
563    let mut h = 1e-5;
564    for _ in 0..6 {
565        let ring: Vec<Point> = (0..AROUND)
566            .filter_map(|i| {
567                let a = TAU * f64::from(i) / f64::from(AROUND);
568                let pu = (u + h * (ub - ua) * a.cos()).clamp(ua, ub);
569                let pv = (v + h * (vb - va) * a.sin()).clamp(va, vb);
570                point(pu, pv).ok()
571            })
572            .collect();
573        h *= 0.1;
574        if ring.len() < 2 {
575            continue;
576        }
577        let sum = ring
578            .iter()
579            .fold(ogeom_math::Vector::ZERO, |s, p| s + p.to_vector());
580        let centre = Point::from_vector(sum / f64::from(u32::try_from(ring.len()).ok()?));
581        let widest = ring.iter().map(|p| p.distance(centre)).fold(0.0, f64::max);
582        if widest <= spread {
583            return Some((centre, widest));
584        }
585    }
586    None
587}
588
589impl crate::surface::SurfaceGeometry {
590    /// This surface as a B-spline *fitted* over its own domain to a stated
591    /// tolerance: the approximation [`to_bspline`](Self::to_bspline)
592    /// refuses to make silently, for an offset surface or anything else
593    /// with no exact rational form. A grid of the surface's points is
594    /// fitted at degree three at the surface's own parameters, the fit
595    /// measured against the surface between the samples as well as at
596    /// them, and the grid refined where it misses until the tolerance
597    /// holds or the budget runs out; `error` is the worst measured, not
598    /// what was asked. The fit spans the surface's own domain, but its
599    /// knots are its own, so a pcurve spoken against the surface is
600    /// re-derived against the result. Where the surface cannot be evaluated
601    /// (an offset sphere's poles, where the basis has no normal) the fit
602    /// takes the limit of the points around, and the error stated counts
603    /// how closely they close on it.
604    ///
605    /// # Errors
606    ///
607    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
608    /// the domain is unbounded, the tolerance is not a distance, or the
609    /// surface cannot be evaluated somewhere and the points around do not
610    /// close on one (an offset cone's apex); as [`fit::fit_surface_grid`].
611    pub fn fitted_bspline(
612        &self,
613        tolerance: f64,
614        tol: Tolerances,
615    ) -> OgeomResult<Fitted<crate::surface::BSplineSurface>> {
616        use crate::traits::Surface as _;
617        grid_fitted(
618            |u, v| self.point_at(u, v, tol),
619            self.domain(),
620            3,
621            tolerance,
622            tol,
623        )
624    }
625}
626
627/// A fit of `curve` over `range` at the curve's own parameters, measured
628/// between the samples as well as at them, the sampling doubled until the
629/// tolerance is met or the budget runs out.
630fn same_parameter_fit(
631    curve: &Curve,
632    range: (f64, f64),
633    degree: usize,
634    tolerance: f64,
635    tol: Tolerances,
636) -> OgeomResult<Fitted<BSplineCurve>> {
637    let (lo, hi) = range;
638    if !lo.is_finite() || !hi.is_finite() || hi <= lo + tol.parametric() {
639        ogeom_bail!(Construction, "cannot fit over an empty range [{lo}, {hi}]");
640    }
641    if !(tolerance > 0.0 && tolerance.is_finite()) {
642        ogeom_bail!(Construction, "a tolerance of {tolerance} is not a distance");
643    }
644    let mut samples = 32usize;
645    let mut best: Option<Fitted<BSplineCurve>> = None;
646    for _ in 0..8 {
647        #[allow(clippy::cast_precision_loss)]
648        let params: Vec<f64> = (0..=samples)
649            .map(|i| lo + (hi - lo) * i as f64 / samples as f64)
650            .collect();
651        let points = params
652            .iter()
653            .map(|t| curve.point_at(*t, tol))
654            .collect::<OgeomResult<Vec<Point>>>()?;
655        let fitted = fit::fit_points_at(&params, &points, degree, tolerance, tol)?;
656        let mut error = fitted.error;
657        for pair in params.windows(2) {
658            let t = f64::midpoint(pair[0], pair[1]);
659            error = error.max(
660                curve
661                    .point_at(t, tol)?
662                    .distance(fitted.curve.point_at(t, tol)?),
663            );
664        }
665        let candidate = Fitted {
666            curve: fitted.curve,
667            error,
668            met: error <= tolerance,
669        };
670        if candidate.met {
671            return Ok(candidate);
672        }
673        if best.as_ref().is_none_or(|b| error < b.error) {
674            best = Some(candidate);
675        }
676        samples *= 2;
677    }
678    best.ok_or_else(|| ogeom_err!(Construction, "the curve could not be sampled"))
679}
680
681fn normalized(curve: BSplineCurve) -> OgeomResult<BSplineCurve> {
682    let knots = curve.knots().reparameterized(0.0, 1.0)?;
683    BSplineCurve::rational(knots, curve.control_points().to_vec())
684}
685
686#[cfg(test)]
687#[allow(clippy::unwrap_used)]
688mod tests {
689    use super::*;
690    use core::f64::consts::{FRAC_PI_2, PI};
691    use ogeom_math::{Circle, Direction, Ellipse, Hyperbola, Parabola, Vector};
692
693    use crate::curve::{
694        CircleCurve, EllipseCurve, HyperbolaCurve, LineCurve, ParabolaCurve, TrimmedCurve,
695    };
696
697    const T: Tolerances = Tolerances::millimetres();
698
699    /// A helix has no exact form and the exact conversion still says so;
700    /// asked for a fit at a stated tolerance, it comes back same-parameter
701    /// with the helix, within the tolerance everywhere, the error reported
702    /// as measured.
703    #[test]
704    fn a_helix_fits_to_a_stated_tolerance_at_its_own_parameters() {
705        let helix: Curve = crate::curve::HelixCurve::new(ogeom_math::Frame::WORLD, 5.0, 4.0, 2.0)
706            .unwrap()
707            .into();
708        assert!(helix.to_bspline(T).is_err(), "the exact conversion refuses");
709        let domain = helix.domain();
710        let fitted = helix.fitted_bspline_over(domain, 1e-3, T).unwrap();
711        assert!(fitted.met && fitted.error <= 1e-3, "error {}", fitted.error);
712        let (lo, hi) = fitted.curve.domain();
713        assert!((lo - domain.0).abs() < 1e-12 && (hi - domain.1).abs() < 1e-12);
714        for i in 0..=200 {
715            let t = domain.0 + (domain.1 - domain.0) * f64::from(i) / 200.0;
716            let gap = helix
717                .point_at(t, T)
718                .unwrap()
719                .distance(fitted.curve.point_at(t, T).unwrap());
720            assert!(gap <= 1e-3, "same-parameter within tolerance at {t}: {gap}");
721        }
722    }
723
724    /// A cubic raised to a quintic restricted back to degree three is the
725    /// cubic again, to rounding. One already low enough is itself.
726    #[test]
727    fn a_curve_restricted_in_degree_holds_its_tolerance() {
728        let cubic = BSplineCurve::new(
729            ogeom_math::KnotVector::clamped_uniform(3, 6).unwrap(),
730            vec![
731                Point::new(0.0, 0.0, 0.0),
732                Point::new(1.0, 2.0, 0.5),
733                Point::new(2.5, 1.0, -0.5),
734                Point::new(4.0, 3.0, 1.0),
735                Point::new(5.0, 0.5, 0.0),
736                Point::new(6.0, 2.0, 2.0),
737            ],
738            T,
739        )
740        .unwrap();
741        let quintic = cubic.elevated(T).unwrap().elevated(T).unwrap();
742        assert_eq!(quintic.degree(), 5);
743        let same = quintic.restricted_to_degree(5, 1e-6, T).unwrap();
744        assert!(same.met && same.error == 0.0 && same.curve.degree() == 5);
745        let back = quintic.restricted_to_degree(3, 1e-6, T).unwrap();
746        assert!(back.met && back.curve.degree() == 3, "error {}", back.error);
747        let (lo, hi) = cubic.domain();
748        for i in 0..=50 {
749            let t = lo + (hi - lo) * f64::from(i) / 50.0;
750            let gap = cubic
751                .point_at(t, T)
752                .unwrap()
753                .distance(back.curve.point_at(t, T).unwrap());
754            assert!(gap < 1e-6, "the cubic again at {t}: {gap}");
755        }
756    }
757
758    /// A bicubic patch restricted to degree two holds every sample of
759    /// itself to the tolerance asked, at the lower degree both ways.
760    #[test]
761    fn a_patch_restricted_in_degree_holds_its_samples() {
762        let (nu, nv) = (5, 4);
763        let mut points = Vec::with_capacity(nu * nv);
764        for i in 0..nu {
765            for j in 0..nv {
766                #[allow(clippy::cast_precision_loss)]
767                let (x, y) = (i as f64, j as f64);
768                points.push(Point::new(x, y, (x * 0.7).sin() * (y * 0.5).cos() * 0.3));
769            }
770        }
771        let patch = crate::surface::BSplineSurface::new(
772            ogeom_math::KnotVector::clamped_uniform(3, nu).unwrap(),
773            ogeom_math::KnotVector::clamped_uniform(3, nv).unwrap(),
774            &ogeom_math::ControlGrid::new(points, nu, nv).unwrap(),
775            T,
776        )
777        .unwrap();
778        let lower = patch.restricted_to_degree(2, 1e-2, T).unwrap();
779        assert!(lower.met && lower.error <= 1e-2, "error {}", lower.error);
780        assert!(lower.curve.u_knots().degree() == 2 && lower.curve.v_knots().degree() == 2);
781        let same = patch.restricted_to_degree(3, 1e-2, T).unwrap();
782        assert!(same.met && same.error == 0.0);
783    }
784
785    /// The greatest distance from any point of the converted curve to the
786    /// original, sampled densely.
787    ///
788    /// The parameterizations differ, so the comparison is *geometric*: for each
789    /// sample of the conversion, how far is it from the curve it came from. For
790    /// a conic that distance has a closed form, so no search is involved.
791    fn deviation(original: &Curve, converted: &BSplineCurve, samples: usize) -> f64 {
792        let (a, b) = converted.knots().domain();
793        let mut worst = 0.0_f64;
794        for i in 0..=samples {
795            #[allow(clippy::cast_precision_loss)]
796            let u = a + (b - a) * i as f64 / samples as f64;
797            let p = converted.point_at(u, T).unwrap();
798            worst = worst.max(distance_to(original, p));
799        }
800        worst
801    }
802
803    /// Distance from a point to an analytic curve, in closed form.
804    fn distance_to(curve: &Curve, p: Point) -> f64 {
805        match curve {
806            Curve::Circle(c) => c.circle().distance_to(p),
807            Curve::Ellipse(e) => {
808                // Not closed form, so fall back to a dense parameter search.
809                nearest(curve, p, e.ellipse().major_radius())
810            }
811            _ => nearest(curve, p, 1.0),
812        }
813    }
814
815    /// Distance from a point to a curve, by a coarse scan and then a local
816    /// refinement.
817    ///
818    /// The scan alone is not good enough to measure a conversion by: its error
819    /// is set by the step it takes, so a test built on it reports the sampling
820    /// resolution rather than the conversion's accuracy. Refining around the
821    /// best sample takes it to where the answer is about the curve again.
822    fn nearest(curve: &Curve, p: Point, _scale: f64) -> f64 {
823        const SCAN: usize = 2_000;
824        let (a, b) = curve.domain();
825        let at = |u: f64| curve.point_at(u, T).map_or(f64::MAX, |q| p.distance(q));
826
827        let mut best = (a, f64::MAX);
828        for i in 0..=SCAN {
829            #[allow(clippy::cast_precision_loss)]
830            let u = a + (b - a) * i as f64 / SCAN as f64;
831            let d = at(u);
832            if d < best.1 {
833                best = (u, d);
834            }
835        }
836        // Ternary search on the bracket either side of the best sample. The
837        // distance is unimodal there for every curve this is used on.
838        #[allow(clippy::cast_precision_loss)]
839        let step = (b - a) / SCAN as f64;
840        let (mut lo, mut hi) = (best.0 - step, best.0 + step);
841        for _ in 0..200 {
842            let one = lo + (hi - lo) / 3.0;
843            let two = hi - (hi - lo) / 3.0;
844            if at(one) < at(two) {
845                hi = two;
846            } else {
847                lo = one;
848            }
849        }
850        at(f64::midpoint(lo, hi)).min(best.1)
851    }
852
853    #[test]
854    fn a_line_becomes_a_degree_one_spline_through_its_own_ends() {
855        let from = Point::new(1.0, 2.0, 3.0);
856        let to = Point::new(4.0, -1.0, 0.5);
857        let line: Curve = LineCurve::segment(from, to, T).unwrap().into();
858        let spline = line.to_bspline(T).unwrap();
859
860        assert_eq!(spline.degree(), 1);
861        assert!(!spline.is_rational(), "a line needs no weights");
862        assert!(spline.point_at(0.0, T).unwrap().is_equal(from, T));
863        assert!(spline.point_at(1.0, T).unwrap().is_equal(to, T));
864        assert!(spline.point_at(0.5, T).unwrap().is_equal(
865            Point::from_vector((from.to_vector() + to.to_vector()) * 0.5),
866            T
867        ));
868    }
869
870    #[test]
871    fn a_full_circle_becomes_an_exact_rational_quadratic() {
872        // Exact, not fitted: every sample lands on the circle to rounding. This
873        // is the property that makes rational weights load-bearing: a
874        // polynomial spline cannot represent a circle at all, only approach it.
875        let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 2.5, T).unwrap()).into();
876        let spline = circle.to_bspline(T).unwrap();
877
878        assert_eq!(spline.degree(), 2);
879        assert!(spline.is_rational(), "a circle needs its weights");
880        assert!(
881            deviation(&circle, &spline, 500) < 1e-12,
882            "off the circle by {}",
883            deviation(&circle, &spline, 500)
884        );
885        // And it closes.
886        assert!(
887            spline
888                .point_at(0.0, T)
889                .unwrap()
890                .is_equal(spline.point_at(1.0, T).unwrap(), T)
891        );
892    }
893
894    #[test]
895    fn an_arc_covers_its_own_span_and_no_more() {
896        // Built as an arc rather than built whole and trimmed, so the ends are
897        // the arc's ends exactly.
898        let circle = Circle::new(Frame::WORLD, 3.0, T).unwrap();
899        let curve: Curve = CircleCurve::new(circle).into();
900        for (from, to) in [
901            (0.0, FRAC_PI_2),
902            (0.3, 1.9),
903            (PI, PI * 1.5),
904            (0.0, PI * 1.75),
905        ] {
906            let spline = curve.to_bspline_over((from, to), T).unwrap();
907            assert!(
908                spline
909                    .point_at(0.0, T)
910                    .unwrap()
911                    .is_equal(curve.point_at(from, T).unwrap(), T),
912                "arc [{from}, {to}] starts in the wrong place"
913            );
914            assert!(
915                spline
916                    .point_at(1.0, T)
917                    .unwrap()
918                    .is_equal(curve.point_at(to, T).unwrap(), T),
919                "arc [{from}, {to}] ends in the wrong place"
920            );
921            assert!(deviation(&curve, &spline, 200) < 1e-12);
922        }
923    }
924
925    #[test]
926    fn an_ellipse_uses_the_same_construction_and_the_same_weights() {
927        // An ellipse is the affine image of a circle, and an affine map carries
928        // a rational quadratic to a rational quadratic with the weights
929        // untouched. One routine, not two.
930        let ellipse: Curve =
931            EllipseCurve::new(Ellipse::new(Frame::WORLD, 5.0, 2.0, T).unwrap()).into();
932        let spline = ellipse.to_bspline(T).unwrap();
933        assert_eq!(spline.degree(), 2);
934        assert!(deviation(&ellipse, &spline, 400) < 1e-9);
935
936        let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 5.0, T).unwrap()).into();
937        let round = circle.to_bspline(T).unwrap();
938        let weights: Vec<f64> = spline.control_points().iter().map(|c| c.weight).collect();
939        let same: Vec<f64> = round.control_points().iter().map(|c| c.weight).collect();
940        assert_eq!(weights, same, "the weights should not depend on the radii");
941    }
942
943    #[test]
944    fn a_conic_in_a_tilted_frame_converts_where_it_actually_is() {
945        let frame = Frame::new(
946            Point::new(3.0, -2.0, 7.0),
947            Direction::from_coords(1.0, 1.0, 1.0, T).unwrap(),
948            Direction::from_coords(1.0, -1.0, 0.0, T).unwrap(),
949            T,
950        )
951        .unwrap();
952        let circle: Curve = CircleCurve::new(Circle::new(frame, 4.0, T).unwrap()).into();
953        let spline = circle.to_bspline(T).unwrap();
954        assert!(deviation(&circle, &spline, 300) < 1e-12);
955    }
956
957    #[test]
958    fn a_parabola_is_a_polynomial_quadratic_exactly() {
959        // Degree two and no weights: a parabola *is* a quadratic, so the
960        // conversion is not even rational.
961        let parabola: Curve = ParabolaCurve::new(Parabola::new(Frame::WORLD, 1.5, T).unwrap(), 4.0)
962            .unwrap()
963            .into();
964        let spline = parabola.to_bspline(T).unwrap();
965        assert_eq!(spline.degree(), 2);
966        assert!(deviation(&parabola, &spline, 300) < 1e-9);
967        for c in spline.control_points() {
968            assert!(
969                (c.weight - 1.0).abs() < 1e-9,
970                "a parabola should need no weights, got {}",
971                c.weight
972            );
973        }
974    }
975
976    #[test]
977    fn a_hyperbola_becomes_a_rational_quadratic() {
978        let hyperbola: Curve =
979            HyperbolaCurve::new(Hyperbola::new(Frame::WORLD, 2.0, 1.0, T).unwrap(), 1.0)
980                .unwrap()
981                .into();
982        let spline = hyperbola.to_bspline(T).unwrap();
983        assert_eq!(spline.degree(), 2);
984        assert!(
985            deviation(&hyperbola, &spline, 300) < 1e-9,
986            "off by {}",
987            deviation(&hyperbola, &spline, 300)
988        );
989    }
990
991    #[test]
992    fn a_spline_converts_to_itself_and_a_trimmed_one_to_its_piece() {
993        let knots = KnotVector::new(vec![0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0], 2).unwrap();
994        let control = vec![
995            Point::ORIGIN,
996            Point::new(1.0, 2.0, 0.0),
997            Point::new(3.0, 2.0, 1.0),
998            Point::new(4.0, 0.0, 0.0),
999        ];
1000        let spline: Curve = BSplineCurve::new(knots, control, T).unwrap().into();
1001        let same = spline.to_bspline(T).unwrap();
1002        assert!(deviation(&spline, &same, 200) < 1e-12);
1003
1004        let trimmed: Curve = Curve::Trimmed(Box::new(
1005            TrimmedCurve::new(spline.clone(), 0.25, 0.75, T).unwrap(),
1006        ));
1007        let piece = trimmed.to_bspline(T).unwrap();
1008        assert!(
1009            piece
1010                .point_at(0.0, T)
1011                .unwrap()
1012                .is_equal(spline.point_at(0.25, T).unwrap(), T)
1013        );
1014        assert!(
1015            piece
1016                .point_at(1.0, T)
1017                .unwrap()
1018                .is_equal(spline.point_at(0.75, T).unwrap(), T)
1019        );
1020        assert!(deviation(&spline, &piece, 200) < 1e-12);
1021    }
1022
1023    #[test]
1024    fn a_reversed_conic_converts_to_the_curve_it_actually_traces() {
1025        use crate::traits::Reversible;
1026        let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 1.0, T).unwrap()).into();
1027        let backwards = circle.reversed();
1028        let spline = backwards.to_bspline_over((0.0, FRAC_PI_2), T).unwrap();
1029
1030        assert!(
1031            spline
1032                .point_at(0.0, T)
1033                .unwrap()
1034                .is_equal(backwards.point_at(0.0, T).unwrap(), T),
1035            "a reversed arc should start where the reversed curve does"
1036        );
1037        assert!(
1038            spline
1039                .point_at(1.0, T)
1040                .unwrap()
1041                .is_equal(backwards.point_at(FRAC_PI_2, T).unwrap(), T)
1042        );
1043    }
1044
1045    #[test]
1046    fn an_empty_range_is_refused() {
1047        let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 1.0, T).unwrap()).into();
1048        assert!(circle.to_bspline_over((1.0, 1.0), T).is_err());
1049        assert!(circle.to_bspline_over((1.0, 0.0), T).is_err());
1050        assert!(circle.to_bspline_over((0.0, f64::NAN), T).is_err());
1051        let _ = Vector::ZERO;
1052    }
1053}
1054
1055// --- surfaces ----------------------------------------------------------------
1056
1057impl crate::surface::SurfaceGeometry {
1058    /// This surface as a B-spline patch, exactly, over `[0, 1]` in both
1059    /// directions.
1060    ///
1061    /// Every analytic surface here is a surface of revolution or a ruled one,
1062    /// so its exact form falls out of the curve conversion above rather than
1063    /// needing a construction of its own: revolve an exactly-converted profile
1064    /// and the patch is exact wherever the profile was.
1065    ///
1066    /// As for curves, the *parameterization* does not survive: a cylinder's
1067    /// `u` is an angle and the patch's is not. See the module documentation.
1068    ///
1069    /// # Errors
1070    ///
1071    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
1072    /// surface's own extents are degenerate, or its geometry cannot be
1073    /// evaluated.
1074    pub fn to_bspline(&self, tol: Tolerances) -> OgeomResult<crate::surface::BSplineSurface> {
1075        self.to_bspline_over(self.domain(), tol)
1076    }
1077
1078    /// This surface over `window` of its own parameters as a B-spline patch,
1079    /// exactly: [`to_bspline`](Self::to_bspline) for a window other than the
1080    /// surface's own extents. A trimmed surface converts as its basis over
1081    /// the trim's window.
1082    ///
1083    /// # Errors
1084    ///
1085    /// As [`to_bspline`](Self::to_bspline).
1086    fn to_bspline_over(
1087        &self,
1088        window: ((f64, f64), (f64, f64)),
1089        tol: Tolerances,
1090    ) -> OgeomResult<crate::surface::BSplineSurface> {
1091        use crate::surface::SurfaceGeometry as S;
1092        let ((ua, ub), (va, vb)) = window;
1093        match self {
1094            // An offset of a free-form basis has no exact spline form
1095            // (the unit normal is a quotient), and this function's contract
1096            // is exactness.
1097            S::Offset(_) => ogeom_bail!(
1098                Construction,
1099                "an offset surface has no exact B-spline form; offset the basis analytically \
1100                 or fit at a stated tolerance instead"
1101            ),
1102
1103            // Ruled both ways: four corners and a bilinear patch, which is a
1104            // degree-one B-spline in each direction and exact for a plane.
1105            S::Plane(_) => {
1106                let corner = |u: f64, v: f64| self.point_at(u, v, tol);
1107                bilinear(
1108                    corner(ua, va)?,
1109                    corner(ub, va)?,
1110                    corner(ua, vb)?,
1111                    corner(ub, vb)?,
1112                )
1113            }
1114
1115            // Ruled along `v`: the profile at each end of the height, lofted.
1116            // Exact because a cylinder and a cone are straight in `v`.
1117            S::Cylinder(_) | S::Cone(_) => {
1118                let profile = |v: f64| -> OgeomResult<Vec<Weighted<Point>>> {
1119                    let mut out = Vec::new();
1120                    let ring = self.section_at(v, (ua, ub), tol)?;
1121                    out.extend_from_slice(ring.control_points());
1122                    Ok(out)
1123                };
1124                let (knots, _) = {
1125                    let ring = self.section_at(va, (ua, ub), tol)?;
1126                    (ring.knots().clone(), ())
1127                };
1128                loft(&knots, &profile(va)?, &profile(vb)?)
1129            }
1130
1131            // Closed in both directions and revolved: build the profile once
1132            // and turn it, which is the same construction the surface is.
1133            S::Sphere(sp) => {
1134                let sphere = sp.sphere();
1135                let frame = sphere.frame();
1136                // The meridian at u = 0, framed so its own angle is the
1137                // latitude exactly: x one radius out along the equator, y
1138                // toward the north pole.
1139                let meridian = Frame::new(frame.origin(), -frame.y(), frame.x(), tol)?;
1140                let circle = ogeom_math::Circle::new(meridian, sphere.radius(), tol)?;
1141                let profile: Curve = crate::curve::CircleCurve::new(circle).into();
1142                revolved_patch(
1143                    &profile,
1144                    (va, vb),
1145                    ogeom_math::Axis {
1146                        location: frame.origin(),
1147                        direction: frame.z(),
1148                    },
1149                    (ua, ub),
1150                    tol,
1151                )
1152            }
1153            S::Torus(t) => {
1154                let torus = t.torus();
1155                let frame = torus.frame();
1156                let centre = frame.origin() + frame.x().vector() * torus.major_radius();
1157                let tube = Frame::new(centre, -frame.y(), frame.x(), tol)?;
1158                let circle = ogeom_math::Circle::new(tube, torus.minor_radius(), tol)?;
1159                let profile: Curve = crate::curve::CircleCurve::new(circle).into();
1160                revolved_patch(
1161                    &profile,
1162                    (va, vb),
1163                    ogeom_math::Axis {
1164                        location: frame.origin(),
1165                        direction: frame.z(),
1166                    },
1167                    (ua, ub),
1168                    tol,
1169                )
1170            }
1171            S::Revolution(r) => revolved_patch(r.curve(), (va, vb), r.axis(), (ua, ub), tol),
1172
1173            S::Extrusion(e) => {
1174                let base = e.curve().to_bspline_over((ua, ub), tol)?;
1175                let along = e.direction().vector() * (vb - va);
1176                let start: Vec<Weighted<Point>> = base
1177                    .control_points()
1178                    .iter()
1179                    .map(|c| Weighted {
1180                        scaled: Point::from_vector(
1181                            c.scaled.to_vector() + e.direction().vector() * va * c.weight,
1182                        ),
1183                        weight: c.weight,
1184                    })
1185                    .collect();
1186                let end: Vec<Weighted<Point>> = start
1187                    .iter()
1188                    .map(|c| Weighted {
1189                        scaled: Point::from_vector(c.scaled.to_vector() + along * c.weight),
1190                        weight: c.weight,
1191                    })
1192                    .collect();
1193                loft(base.knots(), &start, &end)
1194            }
1195
1196            S::BSpline(s) => {
1197                let whole = s.domain();
1198                let near = |a: (f64, f64), b: (f64, f64)| {
1199                    (a.0 - b.0).abs() <= tol.parametric() && (a.1 - b.1).abs() <= tol.parametric()
1200                };
1201                if near(whole.0, (ua, ub)) && near(whole.1, (va, vb)) {
1202                    Ok(s.clone())
1203                } else {
1204                    s.segment((ua, ub), (va, vb), tol)
1205                }
1206            }
1207
1208            S::Trimmed(t) => t.basis().to_bspline_over(window, tol),
1209        }
1210    }
1211
1212    /// The profile of a surface of revolution at one height, as a spline.
1213    fn section_at(
1214        &self,
1215        v: f64,
1216        u_range: (f64, f64),
1217        tol: Tolerances,
1218    ) -> OgeomResult<crate::curve::BSplineCurve> {
1219        use crate::surface::SurfaceGeometry as S;
1220        let (frame, radius) = match self {
1221            S::Cylinder(c) => (c.cylinder().frame(), c.cylinder().radius()),
1222            S::Cone(c) => (c.cone().frame(), c.cone().radius_at(v).abs()),
1223            _ => ogeom_bail!(Construction, "this surface has no circular section"),
1224        };
1225        // The surface's own frame raised, of whichever hand it is: a
1226        // mirrored cylinder's section turns its way, and its normals stay.
1227        let at = Frame::from_axes(
1228            frame.origin() + frame.z() * v,
1229            frame.x(),
1230            frame.y(),
1231            frame.z(),
1232            tol,
1233        )?;
1234        conic_arc(at, radius, radius, u_range.0, u_range.1, tol)
1235    }
1236}
1237
1238/// A rational profile revolved about an axis, exactly.
1239///
1240/// The construction every surface of revolution shares. The profile converts
1241/// to its exact rational form; the turn is the exact rational unit circle
1242/// over the swept angle; and each surface control point is a profile control
1243/// point *rotated by an arc control point*, with the weights multiplied. The
1244/// identity behind it: rotation about the axis is linear in `(cos u, sin u)`,
1245/// the tensor product factors, and the patch evaluates to precisely
1246/// `Rot_u(profile(v))` wherever both conversions were exact.
1247fn revolved_patch(
1248    profile: &Curve,
1249    v_range: (f64, f64),
1250    axis: ogeom_math::Axis,
1251    u_range: (f64, f64),
1252    tol: Tolerances,
1253) -> OgeomResult<crate::surface::BSplineSurface> {
1254    let frame = Frame::about(axis.location, axis.direction);
1255    let turn: Curve =
1256        crate::curve::CircleCurve::new(ogeom_math::Circle::new(frame, 1.0, tol)?).into();
1257    let arc = turn.to_bspline_over(u_range, tol)?;
1258    let pro = profile.to_bspline_over(v_range, tol)?;
1259
1260    let locals: Vec<(f64, f64, f64, f64)> = pro
1261        .control_points()
1262        .iter()
1263        .map(|c| {
1264            let l = frame.to_local(Point::from_vector(c.scaled.to_vector() / c.weight));
1265            (l.x, l.y, l.z, c.weight)
1266        })
1267        .collect();
1268    let mut points = Vec::with_capacity(arc.control_points().len() * locals.len());
1269    for ci in arc.control_points() {
1270        let l = frame.to_local(Point::from_vector(ci.scaled.to_vector() / ci.weight));
1271        let (a, b) = (l.x, l.y);
1272        for &(x, y, z, wj) in &locals {
1273            let weight = ci.weight * wj;
1274            let rotated =
1275                frame.to_world(Point::new(a.mul_add(x, -(b * y)), b.mul_add(x, a * y), z));
1276            points.push(Weighted {
1277                scaled: Point::from_vector(rotated.to_vector() * weight),
1278                weight,
1279            });
1280        }
1281    }
1282    let grid = ogeom_math::ControlGrid::new(points, arc.control_points().len(), locals.len())?;
1283    crate::surface::BSplineSurface::rational(arc.knots().clone(), pro.knots().clone(), grid)
1284}
1285
1286/// A bilinear patch through four corners.
1287fn bilinear(a: Point, b: Point, c: Point, d: Point) -> OgeomResult<crate::surface::BSplineSurface> {
1288    let line = KnotVector::new(vec![0.0, 0.0, 1.0, 1.0], 1)?;
1289    let grid = ogeom_math::ControlGrid::new(
1290        vec![
1291            Weighted {
1292                scaled: a,
1293                weight: 1.0,
1294            },
1295            Weighted {
1296                scaled: c,
1297                weight: 1.0,
1298            },
1299            Weighted {
1300                scaled: b,
1301                weight: 1.0,
1302            },
1303            Weighted {
1304                scaled: d,
1305                weight: 1.0,
1306            },
1307        ],
1308        2,
1309        2,
1310    )?;
1311    crate::surface::BSplineSurface::rational(line.clone(), line, grid)
1312}
1313
1314/// A patch ruled between two rows of control points sharing one knot vector.
1315fn loft(
1316    across: &KnotVector,
1317    start: &[Weighted<Point>],
1318    end: &[Weighted<Point>],
1319) -> OgeomResult<crate::surface::BSplineSurface> {
1320    if start.len() != end.len() {
1321        ogeom_bail!(
1322            Dimension,
1323            "a ruled patch needs the same control points at each end, got {} \
1324             and {}",
1325            start.len(),
1326            end.len()
1327        );
1328    }
1329    let mut points = Vec::with_capacity(start.len() * 2);
1330    for (a, b) in start.iter().zip(end) {
1331        points.push(*a);
1332        points.push(*b);
1333    }
1334    let grid = ogeom_math::ControlGrid::new(points, start.len(), 2)?;
1335    let along = KnotVector::new(vec![0.0, 0.0, 1.0, 1.0], 1)?;
1336    crate::surface::BSplineSurface::rational(across.clone(), along, grid)
1337}
1338
1339#[cfg(test)]
1340#[allow(clippy::unwrap_used)]
1341mod surface_tests {
1342    use super::*;
1343    use crate::surface::{
1344        ConeSurface, CylinderSurface, ExtrusionSurface, PlaneSurface, SphereSurface,
1345        SurfaceGeometry, TorusSurface, TrimmedSurface,
1346    };
1347    use ogeom_math::{Circle, Cone, Cylinder, Direction, Plane, Sphere, Torus};
1348
1349    const T: Tolerances = Tolerances::millimetres();
1350
1351    /// A mirrored cylinder or cone converts to a spline that turns the way
1352    /// the original does: the normal at each of its points agrees with the
1353    /// original's there.
1354    #[test]
1355    fn a_mirrored_cylinder_or_cone_converts_turning_its_way() {
1356        use crate::traits::Transformable as _;
1357        use ogeom_math::{Transform, elementary};
1358        let mirror = Transform::plane_mirror(Point::ORIGIN, Direction::X);
1359        let surfaces: Vec<SurfaceGeometry> = vec![
1360            CylinderSurface::new(Cylinder::new(Frame::WORLD, 5.0, T).unwrap(), (0.0, 10.0))
1361                .unwrap()
1362                .into(),
1363            ConeSurface::new(Cone::new(Frame::WORLD, 5.0, 0.3, T).unwrap(), (0.0, 10.0))
1364                .unwrap()
1365                .into(),
1366        ];
1367        for original in surfaces {
1368            let mirrored = original.transformed(&mirror, T).unwrap();
1369            let spline = mirrored.to_bspline(T).unwrap();
1370            let ((pa, pb), (qa, qb)) = spline.domain();
1371            for i in 1..8 {
1372                for j in 1..8 {
1373                    let (u, v) = (
1374                        pa + (pb - pa) * f64::from(i) / 8.0,
1375                        qa + (qb - qa) * f64::from(j) / 8.0,
1376                    );
1377                    let p = spline.point_at(u, v, T).unwrap();
1378                    let (au, av) = match &mirrored {
1379                        SurfaceGeometry::Cylinder(c) => {
1380                            elementary::cylinder_parameters(&c.cylinder(), p, T).unwrap()
1381                        }
1382                        SurfaceGeometry::Cone(c) => {
1383                            elementary::cone_parameters(&c.cone(), p, T).unwrap()
1384                        }
1385                        _ => unreachable!(),
1386                    };
1387                    let (a, b) = (
1388                        spline.normal_at(u, v, T).unwrap(),
1389                        mirrored.normal_at(au, av, T).unwrap(),
1390                    );
1391                    assert!(
1392                        a.vector().dot(b.vector()) > 0.99,
1393                        "{mirrored:?}: {a:?} against {b:?}"
1394                    );
1395                }
1396            }
1397        }
1398    }
1399
1400    /// How far the converted patch strays from the surface it came from.
1401    ///
1402    /// Measured *implicitly* (the distance from each sampled point of the
1403    /// patch to the analytic surface) rather than by comparing the two at
1404    /// proportional parameters. Comparing parameters would be measuring the
1405    /// wrong thing: a rational quadratic's parameter is not proportional to the
1406    /// angle it sweeps, so even an exact conversion disagrees pointwise, and a
1407    /// test built that way reports the reparameterization instead of the error.
1408    fn deviation(distance: impl Fn(Point) -> f64, patch: &crate::surface::BSplineSurface) -> f64 {
1409        let ((pa, pb), (qa, qb)) = patch.domain();
1410        let mut worst = 0.0_f64;
1411        for i in 0..=60 {
1412            for j in 0..=60 {
1413                #[allow(clippy::cast_precision_loss)]
1414                let (s, t) = (i as f64 / 60.0, j as f64 / 60.0);
1415                if let Ok(p) = patch.point_at(pa + (pb - pa) * s, qa + (qb - qa) * t, T) {
1416                    worst = worst.max(distance(p).abs());
1417                }
1418            }
1419        }
1420        worst
1421    }
1422
1423    /// Whether a patch reaches the same corners as the surface it converted.
1424    ///
1425    /// The implicit test says the patch lies *on* the surface. This says it
1426    /// covers the same piece of it, which the implicit test alone cannot.
1427    fn spans_the_same(original: &SurfaceGeometry, patch: &crate::surface::BSplineSurface) -> bool {
1428        let ((ua, ub), (va, vb)) = original.domain();
1429        let ((pa, pb), (qa, qb)) = patch.domain();
1430        [
1431            (ua, va, pa, qa),
1432            (ua, vb, pa, qb),
1433            (ub, va, pb, qa),
1434            (ub, vb, pb, qb),
1435        ]
1436        .iter()
1437        .all(|(u, v, p, q)| {
1438            match (original.point_at(*u, *v, T), patch.point_at(*p, *q, T)) {
1439                (Ok(a), Ok(b)) => a.is_equal(b, T),
1440                _ => false,
1441            }
1442        })
1443    }
1444
1445    #[test]
1446    fn a_plane_becomes_a_bilinear_patch() {
1447        let plane: SurfaceGeometry =
1448            PlaneSurface::over(Plane::new(Frame::WORLD), (-2.0, 5.0), (-1.0, 3.0))
1449                .unwrap()
1450                .into();
1451        let patch = plane.to_bspline(T).unwrap();
1452        assert!(!patch.is_rational(), "a plane needs no weights");
1453        let flat = Plane::new(Frame::WORLD);
1454        assert!(deviation(|p| flat.distance_to(p), &patch) < 1e-12);
1455        assert!(spans_the_same(&plane, &patch));
1456    }
1457
1458    #[test]
1459    fn a_cylinder_becomes_an_exact_rational_patch() {
1460        // Circular in `u` and straight in `v`, so the exact patch is the exact
1461        // circle lofted, and it lands on the cylinder everywhere, not near it.
1462        let cylinder: SurfaceGeometry =
1463            CylinderSurface::new(Cylinder::new(Frame::WORLD, 2.0, T).unwrap(), (0.0, 5.0))
1464                .unwrap()
1465                .into();
1466        let patch = cylinder.to_bspline(T).unwrap();
1467        assert!(patch.is_rational());
1468        let exact = Cylinder::new(Frame::WORLD, 2.0, T).unwrap();
1469        let off = deviation(|p| exact.distance_to(p), &patch);
1470        assert!(off < 1e-12, "off the cylinder by {off}");
1471        assert!(spans_the_same(&cylinder, &patch));
1472    }
1473
1474    #[test]
1475    fn a_cone_becomes_an_exact_rational_patch() {
1476        let cone: SurfaceGeometry = ConeSurface::new(
1477            Cone::new(Frame::WORLD, 1.0, 0.4_f64.atan(), T).unwrap(),
1478            (0.0, 4.0),
1479        )
1480        .unwrap()
1481        .into();
1482        let patch = cone.to_bspline(T).unwrap();
1483        let exact = Cone::new(Frame::WORLD, 1.0, 0.4_f64.atan(), T).unwrap();
1484        let off = deviation(|p| exact.distance_to(p), &patch);
1485        assert!(off < 1e-12, "off the cone by {off}");
1486        assert!(spans_the_same(&cone, &patch));
1487    }
1488
1489    #[test]
1490    fn an_extrusion_becomes_its_profile_lofted() {
1491        let circle = crate::curve::CircleCurve::new(Circle::new(Frame::WORLD, 3.0, T).unwrap());
1492        let extrusion: SurfaceGeometry = ExtrusionSurface::new(circle.into(), Direction::Z, 6.0)
1493            .unwrap()
1494            .into();
1495        let patch = extrusion.to_bspline(T).unwrap();
1496        // A circle swept along its own axis is a cylinder, so the implicit test
1497        // is the cylinder's.
1498        let exact = Cylinder::new(Frame::WORLD, 3.0, T).unwrap();
1499        let off = deviation(|p| exact.distance_to(p), &patch);
1500        assert!(off < 1e-12, "off the swept circle by {off}");
1501        assert!(spans_the_same(&extrusion, &patch));
1502    }
1503
1504    #[test]
1505    fn a_patch_converts_to_itself() {
1506        let cylinder: SurfaceGeometry =
1507            CylinderSurface::new(Cylinder::new(Frame::WORLD, 1.0, T).unwrap(), (0.0, 1.0))
1508                .unwrap()
1509                .into();
1510        let patch = cylinder.to_bspline(T).unwrap();
1511        let again: SurfaceGeometry = patch.clone().into();
1512        let twice = again.to_bspline(T).unwrap();
1513        assert_eq!(patch, twice);
1514    }
1515
1516    /// A drum spelt as an offset, fitted to a tolerance, holds it between
1517    /// its samples: every point of a check grid off the sample lines lies
1518    /// within the tolerance of the drum it traces, and of the surface's
1519    /// own point at the same parameters.
1520    #[test]
1521    fn a_fitted_surface_holds_its_tolerance_between_the_samples() {
1522        let basis: SurfaceGeometry =
1523            CylinderSurface::new(Cylinder::new(Frame::WORLD, 1.5, T).unwrap(), (0.0, 5.0))
1524                .unwrap()
1525                .into();
1526        let offset = SurfaceGeometry::Offset(Box::new(
1527            crate::surface::OffsetSurface::new(basis, 0.5).unwrap(),
1528        ));
1529        let tolerance = 1e-4;
1530        let fitted = offset.fitted_bspline(tolerance, T).unwrap();
1531        assert!(fitted.met && fitted.error <= tolerance, "{}", fitted.error);
1532        let patch = fitted.curve;
1533        let ((u0, u1), (v0, v1)) = offset.domain();
1534        let (nu, nv) = (157, 131);
1535        let mut worst = 0.0_f64;
1536        for j in 0..=nv {
1537            let v = v0 + (v1 - v0) * f64::from(j) / f64::from(nv);
1538            for i in 0..=nu {
1539                let u = u0 + (u1 - u0) * f64::from(i) / f64::from(nu);
1540                let p = patch.point_at(u, v, T).unwrap();
1541                let radial = (p.x.hypot(p.y) - 2.0).abs();
1542                let across = p.distance(offset.point_at(u, v, T).unwrap());
1543                worst = worst.max(radial).max(across);
1544            }
1545        }
1546        assert!(
1547            worst <= tolerance,
1548            "the fit strays {worst} between its samples"
1549        );
1550    }
1551
1552    /// The error a surface fit states is no less than its widest miss read
1553    /// on a grid far finer than the fit's checks: the miss peaks between
1554    /// the quarter points the fit is checked at, on a drum spelt as an
1555    /// offset as on the offset of a wavy patch.
1556    #[test]
1557    fn a_fitted_surface_states_no_less_than_its_widest_miss() {
1558        let drum: SurfaceGeometry =
1559            CylinderSurface::new(Cylinder::new(Frame::WORLD, 1.5, T).unwrap(), (0.0, 5.0))
1560                .unwrap()
1561                .into();
1562        let n = 8_u32;
1563        let mut points = Vec::new();
1564        for i in 0..n {
1565            for j in 0..n {
1566                let (x, y) = (
1567                    10.0 * f64::from(i) / f64::from(n - 1),
1568                    10.0 * f64::from(j) / f64::from(n - 1),
1569                );
1570                points.push(Point::new(x, y, (0.8 * x).sin() * (0.56 * y).cos()));
1571            }
1572        }
1573        let mut knots = vec![0.0; 4];
1574        knots.extend((1..=4).map(|i| f64::from(i) / 5.0));
1575        knots.extend([1.0; 4]);
1576        let knots = ogeom_math::KnotVector::new(knots, 3).unwrap();
1577        let grid = ogeom_math::ControlGrid::new(points, 8, 8).unwrap();
1578        let wavy: SurfaceGeometry =
1579            crate::surface::BSplineSurface::new(knots.clone(), knots, &grid, T)
1580                .unwrap()
1581                .into();
1582        for basis in [drum, wavy] {
1583            let offset = SurfaceGeometry::Offset(Box::new(
1584                crate::surface::OffsetSurface::new(basis, 0.5).unwrap(),
1585            ));
1586            let fitted = offset.fitted_bspline(1e-4, T).unwrap();
1587            let ((u0, u1), (v0, v1)) = offset.domain();
1588            let n = 400;
1589            let mut worst = 0.0_f64;
1590            for j in 0..=n {
1591                let v = v0 + (v1 - v0) * f64::from(j) / f64::from(n);
1592                for i in 0..=n {
1593                    let u = u0 + (u1 - u0) * f64::from(i) / f64::from(n);
1594                    let p = fitted.curve.point_at(u, v, T).unwrap();
1595                    worst = worst.max(p.distance(offset.point_at(u, v, T).unwrap()));
1596                }
1597            }
1598            assert!(
1599                worst <= fitted.error,
1600                "the fit misses by {worst:.4e} and states {:.4e}",
1601                fitted.error
1602            );
1603        }
1604    }
1605
1606    /// An offset sphere has no normal at its basis's poles, but its points
1607    /// there are the poles of the grown sphere: the fit takes that limit,
1608    /// meets its tolerance, and states no less than its widest miss from
1609    /// the grown sphere, poles included.
1610    #[test]
1611    fn an_offset_sphere_fits_through_its_poles() {
1612        let sphere: SurfaceGeometry =
1613            SphereSurface::new(Sphere::new(Frame::WORLD, 2.0, T).unwrap()).into();
1614        let offset = SurfaceGeometry::Offset(Box::new(
1615            crate::surface::OffsetSurface::new(sphere, 0.5).unwrap(),
1616        ));
1617        let ((u0, u1), (v0, v1)) = offset.domain();
1618        assert!(offset.point_at(u0, v1, T).is_err(), "no normal at a pole");
1619        let fitted = offset.fitted_bspline(1e-4, T).unwrap();
1620        assert!(fitted.met, "stays {}", fitted.error);
1621        let n = 200;
1622        let mut worst = 0.0_f64;
1623        for j in 0..=n {
1624            let v = v0 + (v1 - v0) * f64::from(j) / f64::from(n);
1625            for i in 0..=n {
1626                let u = u0 + (u1 - u0) * f64::from(i) / f64::from(n);
1627                let p = fitted.curve.point_at(u, v, T).unwrap();
1628                worst = worst.max((p.to_vector().magnitude() - 2.5).abs());
1629            }
1630        }
1631        assert!(
1632            worst <= fitted.error,
1633            "the fit misses by {worst:.4e} and states {:.4e}",
1634            fitted.error
1635        );
1636        for v in [v0, v1] {
1637            let pole = Point::new(0.0, 0.0, 2.5 * v.signum());
1638            let p = fitted.curve.point_at(f64::midpoint(u0, u1), v, T).unwrap();
1639            assert!(p.distance(pole) <= fitted.error, "{p:?}");
1640        }
1641    }
1642
1643    /// An offset cone's apex has no limit: the offset points around it lie
1644    /// on a circle the offset's width across, whichever way they close in.
1645    /// The fit refuses there rather than invent a point.
1646    #[test]
1647    fn an_offset_cone_through_its_apex_is_refused() {
1648        let cone = Cone::new(Frame::WORLD, 1.0, 0.5, T).unwrap();
1649        let apex = -1.0 / 0.5_f64.tan();
1650        let basis: SurfaceGeometry = ConeSurface::new(cone, (apex, 1.0)).unwrap().into();
1651        let offset = SurfaceGeometry::Offset(Box::new(
1652            crate::surface::OffsetSurface::new(basis, 0.25).unwrap(),
1653        ));
1654        let refused = offset.fitted_bspline(1e-4, T).unwrap_err();
1655        assert!(refused.to_string().contains("no limit"), "{refused}");
1656    }
1657
1658    #[test]
1659    fn a_sphere_becomes_an_exact_rational_patch() {
1660        let sphere = Sphere::new(Frame::WORLD, 2.5, T).unwrap();
1661        let surface: SurfaceGeometry = SphereSurface::new(sphere).into();
1662        let patch = surface.to_bspline(T).unwrap();
1663        assert!(patch.is_rational(), "a sphere needs weights");
1664        assert!(deviation(|p| sphere.distance_to(p), &patch) < 1e-12);
1665        assert!(spans_the_same(&surface, &patch));
1666    }
1667
1668    #[test]
1669    fn a_torus_becomes_an_exact_rational_patch() {
1670        let torus = Torus::new(Frame::WORLD, 3.0, 1.0, T).unwrap();
1671        let surface: SurfaceGeometry = TorusSurface::new(torus).into();
1672        let patch = surface.to_bspline(T).unwrap();
1673        assert!(patch.is_rational(), "a torus needs weights");
1674        assert!(deviation(|p| torus.distance_to(p), &patch) < 1e-12);
1675        assert!(spans_the_same(&surface, &patch));
1676    }
1677
1678    #[test]
1679    fn a_revolution_becomes_the_exact_patch_its_own_construction_is() {
1680        // A line parallel to the axis, revolved three quarters of a turn: the
1681        // surface is a cylinder wall, so the patch can be measured against
1682        // the cylinder's own signed distance, an independent authority, not
1683        // the revolution evaluating itself.
1684        use crate::curve::LineCurve;
1685        let line =
1686            LineCurve::segment(Point::new(2.0, 0.0, 0.0), Point::new(2.0, 0.0, 5.0), T).unwrap();
1687        let surface: SurfaceGeometry = crate::surface::RevolutionSurface::new(
1688            line.into(),
1689            ogeom_math::Axis {
1690                location: Point::new(0.0, 0.0, 0.0),
1691                direction: ogeom_math::Direction::Z,
1692            },
1693            1.5 * core::f64::consts::PI,
1694        )
1695        .unwrap()
1696        .into();
1697        let cylinder = ogeom_math::Cylinder::new(Frame::WORLD, 2.0, T).unwrap();
1698        let patch = surface.to_bspline(T).unwrap();
1699        assert!(deviation(|p| cylinder.distance_to(p), &patch) < 1e-12);
1700        assert!(spans_the_same(&surface, &patch));
1701    }
1702
1703    #[test]
1704    fn a_trimmed_surface_converts_as_its_basis_over_the_window() {
1705        let plane: SurfaceGeometry = PlaneSurface::new(Plane::new(Frame::WORLD)).into();
1706        let trimmed: SurfaceGeometry = SurfaceGeometry::Trimmed(Box::new(
1707            TrimmedSurface::new(plane, (0.0, 1.0), (2.0, 5.0), T).unwrap(),
1708        ));
1709        let patch = trimmed.to_bspline(T).unwrap();
1710        let flat = Plane::new(Frame::WORLD);
1711        assert!(deviation(|p| flat.distance_to(p), &patch) < 1e-12);
1712        assert!(spans_the_same(&trimmed, &patch));
1713    }
1714
1715    #[test]
1716    fn a_trimmed_spline_converts_to_its_piece() {
1717        let plane: SurfaceGeometry =
1718            PlaneSurface::over(Plane::new(Frame::WORLD), (0.0, 4.0), (0.0, 4.0))
1719                .unwrap()
1720                .into();
1721        let spline: SurfaceGeometry = plane.to_bspline(T).unwrap().into();
1722        let trimmed: SurfaceGeometry = SurfaceGeometry::Trimmed(Box::new(
1723            TrimmedSurface::new(spline, (0.25, 0.5), (0.0, 0.75), T).unwrap(),
1724        ));
1725        let patch = trimmed.to_bspline(T).unwrap();
1726        assert!(spans_the_same(&trimmed, &patch));
1727    }
1728
1729    #[test]
1730    fn a_spline_segment_keeps_its_parameters() {
1731        let cylinder: SurfaceGeometry = CylinderSurface::new(
1732            ogeom_math::Cylinder::new(Frame::WORLD, 2.0, T).unwrap(),
1733            (0.0, 3.0),
1734        )
1735        .unwrap()
1736        .into();
1737        let whole = cylinder.to_bspline(T).unwrap();
1738        let ((ua, ub), (va, vb)) = whole.domain();
1739        let u = (ua + 0.2 * (ub - ua), ua + 0.7 * (ub - ua));
1740        let v = (va + 0.1 * (vb - va), va + 0.6 * (vb - va));
1741        let piece = whole.segment(u, v, T).unwrap();
1742        assert_eq!(piece.domain(), (u, v));
1743        for i in 0..=8 {
1744            for j in 0..=8 {
1745                let s = u.0 + (u.1 - u.0) * f64::from(i) / 8.0;
1746                let t = v.0 + (v.1 - v.0) * f64::from(j) / 8.0;
1747                let off = piece
1748                    .point_at(s, t, T)
1749                    .unwrap()
1750                    .distance(whole.point_at(s, t, T).unwrap());
1751                assert!(off < 1e-12, "{off} off at ({s}, {t})");
1752            }
1753        }
1754    }
1755}
1756
1757// --- general affine transforms ------------------------------------------------
1758
1759impl Curve {
1760    /// This curve carried through a general affine transform.
1761    ///
1762    /// A shear or an uneven scale is not a placement: it carries a circle to an
1763    /// ellipse, and an ellipse to a conic with no analytic name here. So the
1764    /// curve is converted to its exact B-spline form first and the *control
1765    /// points* are moved, which an affine map does exactly: a B-spline is an
1766    /// affine combination of its control points, so transforming them and
1767    /// transforming every point of the curve are the same thing.
1768    ///
1769    /// The result is a B-spline whatever went in, and its parameterization is
1770    /// the converted one rather than the original. That is the price of a
1771    /// transform the analytic types cannot express, and it is why
1772    /// [`transformed`](crate::traits::Transformable::transformed) takes only a
1773    /// [`Transform`](ogeom_math::Transform): a placement keeps the type, and only
1774    /// this does not.
1775    ///
1776    /// # Errors
1777    ///
1778    /// As [`Curve::to_bspline`], plus
1779    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
1780    /// transform collapses the curve to a point.
1781    pub fn general_transformed(
1782        &self,
1783        t: &ogeom_math::GeneralTransform,
1784        tol: Tolerances,
1785    ) -> OgeomResult<BSplineCurve> {
1786        let spline = self.to_bspline(tol)?;
1787        let moved: Vec<Weighted<Point>> = spline
1788            .control_points()
1789            .iter()
1790            .map(|c| {
1791                // Stored homogeneous, so the point has already been multiplied
1792                // by its weight. An affine map is not linear (it has a
1793                // translation), so the translation has to be scaled by the
1794                // weight too, or a rational curve's control points drift apart
1795                // from its weights and the curve leaves the shape entirely.
1796                let position = c.point();
1797                Weighted {
1798                    scaled: Point::from_vector(t.apply(position).to_vector() * c.weight),
1799                    weight: c.weight,
1800                }
1801            })
1802            .collect();
1803        if moved
1804            .iter()
1805            .all(|c| c.point().is_equal(moved[0].point(), tol))
1806        {
1807            ogeom_bail!(
1808                Construction,
1809                "this transform collapses the curve to a point; it is singular \
1810                 in the curve's own directions"
1811            );
1812        }
1813        BSplineCurve::rational(spline.knots().clone(), moved)
1814    }
1815}
1816
1817#[cfg(test)]
1818#[allow(clippy::unwrap_used)]
1819mod affine_tests {
1820    use super::*;
1821    use crate::curve::{CircleCurve, LineCurve};
1822    use ogeom_math::{Circle, GeneralTransform, Matrix3, Vector};
1823
1824    const T: Tolerances = Tolerances::millimetres();
1825
1826    #[test]
1827    fn an_uneven_scale_carries_a_circle_onto_the_ellipse_it_should() {
1828        // The case a placement cannot express. A circle of radius one scaled by
1829        // three in x and one in y is the ellipse with those radii, and the
1830        // converted curve lands on it exactly rather than near it.
1831        let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 1.0, T).unwrap()).into();
1832        let stretch = GeneralTransform::new(
1833            Matrix3::new([[3.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]),
1834            Vector::ZERO,
1835        );
1836        let moved = circle.general_transformed(&stretch, T).unwrap();
1837
1838        let (a, b) = moved.knots().domain();
1839        for i in 0..=400 {
1840            #[allow(clippy::cast_precision_loss)]
1841            let u = a + (b - a) * i as f64 / 400.0;
1842            let p = moved.point_at(u, T).unwrap();
1843            let on_ellipse = (p.x / 3.0).powi(2) + p.y.powi(2);
1844            assert!(
1845                (on_ellipse - 1.0).abs() < 1e-12,
1846                "at {u} the point {p:?} is not on the ellipse: {on_ellipse}"
1847            );
1848        }
1849    }
1850
1851    #[test]
1852    fn a_shear_is_exact_because_a_spline_is_an_affine_combination() {
1853        // Transforming the control points and transforming every point of the
1854        // curve are the same operation, which is what makes this exact rather
1855        // than fitted.
1856        let line: Curve = LineCurve::segment(Point::ORIGIN, Point::new(2.0, 3.0, 0.0), T)
1857            .unwrap()
1858            .into();
1859        let shear = GeneralTransform::new(
1860            Matrix3::new([[1.0, 0.7, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]),
1861            Vector::new(1.0, 2.0, 3.0),
1862        );
1863        let moved = line.general_transformed(&shear, T).unwrap();
1864
1865        for i in 0..=50 {
1866            #[allow(clippy::cast_precision_loss)]
1867            let u = i as f64 / 50.0;
1868            let before =
1869                line.point_at(line.domain().0 + (line.domain().1 - line.domain().0) * u, T);
1870            let after = moved.point_at(u, T).unwrap();
1871            assert!(shear.apply(before.unwrap()).is_equal(after, T));
1872        }
1873    }
1874
1875    #[test]
1876    fn a_rational_curves_weights_survive_the_move() {
1877        // The trap: control points are stored already multiplied by their
1878        // weight, so a transform with a translation has to scale the
1879        // translation by the weight too. Getting that wrong leaves a circle's
1880        // control points and weights inconsistent, and the curve wanders off
1881        // the shape entirely, most visibly under a pure translation, where
1882        // nothing should change but the position.
1883        let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 2.0, T).unwrap()).into();
1884        let shift = GeneralTransform::new(Matrix3::IDENTITY, Vector::new(10.0, -4.0, 6.0));
1885        let moved = circle.general_transformed(&shift, T).unwrap();
1886
1887        let centre = Point::new(10.0, -4.0, 6.0);
1888        let (a, b) = moved.knots().domain();
1889        for i in 0..=300 {
1890            #[allow(clippy::cast_precision_loss)]
1891            let u = a + (b - a) * i as f64 / 300.0;
1892            let p = moved.point_at(u, T).unwrap();
1893            assert!(
1894                (p.distance(centre) - 2.0).abs() < 1e-12,
1895                "at {u} the radius is {}",
1896                p.distance(centre)
1897            );
1898        }
1899    }
1900
1901    #[test]
1902    fn a_transform_that_collapses_the_curve_is_refused() {
1903        // Projecting a circle in the xy plane onto the z axis leaves a point.
1904        // A "curve" that is one point is not a curve, and returning it would
1905        // hand back something every later algorithm divides by the length of.
1906        let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 1.0, T).unwrap()).into();
1907        let squash = GeneralTransform::new(Matrix3::new([[0.0; 3]; 3]), Vector::ZERO);
1908        assert!(circle.general_transformed(&squash, T).is_err());
1909    }
1910}