Skip to main content

ogeom_algo/
sheet.rs

1//! A flat sheet laid onto a face: the chart from the plane to the face's
2//! surface that carries a sketch onto it.
3//!
4//! The sheet is laid by geodesic polar coordinates, the exponential map at
5//! its origin: the sheet point at distance `r` and angle `t` from the
6//! origin lands where the geodesic leaving the origin's point at angle `t`
7//! from the sheet's `+x` is after length `r`. Lengths along every straight
8//! line through the origin are kept; across those lines a length is
9//! stretched by `J(r) / r`, `J` the Jacobi field along the geodesic
10//! (`J'' = -K J`, `J(0) = 0`, `J'(0) = 1`, `K` the Gaussian curvature), and
11//! so is an area, since the radial length is kept. Where the surface is
12//! flat (a plane, a cylinder, a cone) the map is the surface's development
13//! and stretches nothing.
14//!
15//! A plane, a cylinder, a cone and a sphere are laid in closed form. Every
16//! other surface has its geodesics integrated in its own chart, the
17//! geodesic equation and the Jacobi equation together, by classical
18//! Runge-Kutta steps whose error is estimated by step doubling and held,
19//! per unit of length, to the share of the accuracy asked that the whole
20//! length leaves it. A chart position is reached continuously along the
21//! sheet's straight line from the origin, so it runs on past a periodic
22//! seam unwrapped; a carried curve's chart curve is continued along the
23//! curve itself instead, so one going round a pole of the chart winds a
24//! whole period, as a parallel does.
25
26use core::f64::consts::PI;
27
28use ogeom_core::{OgeomResult, Tolerances, ogeom_bail, ogeom_err};
29use ogeom_geom::{
30    BSpline2d, Curve, Curve2d as _, CurveOnSurface, PlanarCurve, Surface as _, SurfaceGeometry,
31    Transformable as _,
32};
33use ogeom_math::{Direction, KnotVector, Point, Point2, Vector, Vector2};
34use ogeom_topo::{Location, Model, Orientation, Shape, ShapeType};
35
36use crate::Built;
37use crate::build::{attach_pcurve, make_edge};
38
39/// How the flat sheet is carried away from its origin.
40#[derive(Debug, Clone, Copy, PartialEq)]
41#[non_exhaustive]
42pub enum SheetMethod {
43    /// Geodesic polar coordinates: the sheet point at distance `r` and
44    /// angle `t` from the origin lands where the geodesic leaving the
45    /// origin at angle `t` from the sheet's `+x` is after length `r` (the
46    /// exponential map). Lengths along lines through the origin are kept.
47    Geodesic,
48    /// The conformal flattening of the geodesic disc the sheet covers that
49    /// stretches nothing along the disc's rim, pinned at the origin with
50    /// its `+x` along the sheet's, and scaled to keep the disc's area as a
51    /// whole. On a sphere it is the stereographic projection from the
52    /// origin's antipode.
53    ///
54    /// It is conformal, not area-keeping: angles are kept, and lengths
55    /// stretch alike in every direction at a point but by different
56    /// amounts from point to point, so a region's area on the surface is
57    /// not its area on the sheet. On a sphere of radius 10 with reach 5
58    /// the stretch runs from 1.031 at the origin to 0.968 at the reach, and
59    /// a 6 x 4 rectangle at the origin covers 25.07 rather than 24.
60    ///
61    /// The flattening is computed on a mesh, and
62    /// [`SurfaceSheet::flattening_error`] is an estimate, not a bound, of
63    /// how far the laid map stands from the smooth one; see there for the
64    /// assumption it rests on. On a plane, a cylinder and a cone it is
65    /// their exact development, as `Geodesic` is.
66    LeastDistortion {
67        /// The estimated departure from the smooth flattening to refine
68        /// down to, as a length on the sheet.
69        resolution: f64,
70    },
71    /// The as-rigid-as-possible flattening of the geodesic disc the sheet
72    /// covers: each triangle of a mesh of the disc as near a rotation of
73    /// itself on the surface as the whole allows, so lengths stretch as
74    /// little as they can in every direction at once, and areas with them.
75    /// Pinned at the origin with its `+x` along the sheet's, and not
76    /// scaled.
77    ///
78    /// The flattening is a minimum of a non-convex energy, settled from
79    /// the conformal flattening, and not unique: nothing is said of how
80    /// far it stands from any smooth map. The sheet is judged by what it
81    /// does. Its length ratio, against the surface's own metric, is
82    /// measured at every vertex and triangle centroid of the flattened mesh
83    /// within the reach, and the sheet is refused where any falls outside
84    /// `1 / (1 + stretch)` to `1 + stretch`, or where it folds over. These
85    /// are measurements at those points, not a bound between them.
86    /// [`SurfaceSheet::distortion`] starts from the range measured.
87    /// [`SurfaceSheet::flattening_error`] is the miss of the smooth map
88    /// carried, a polynomial, against the mesh's own map, within half of
89    /// `resolution` at the same points. On a plane, a cylinder and a cone it
90    /// is their exact development.
91    AsRigidAsPossible {
92        /// How closely the smooth map carried follows the flattened mesh,
93        /// as a length on the sheet.
94        resolution: f64,
95        /// How far the length ratio may stand from one, as a fraction.
96        stretch: f64,
97    },
98}
99
100/// How a flat sheet is laid on a face.
101#[derive(Debug, Clone, Copy, PartialEq)]
102pub struct SheetPlacement {
103    /// The point of the face the sheet's origin lands on.
104    pub at: Point,
105    /// The direction the sheet's `+x` runs along at `at`, its part in the
106    /// tangent plane taken.
107    pub x_direction: Direction,
108    /// How the sheet is carried away from `at`.
109    pub method: SheetMethod,
110}
111
112/// How far a laid sheet stretches, over the sheet points it has carried.
113///
114/// Each is a pair: the least and the most ratio of a length (or an area)
115/// on the surface to the same on the sheet. Geodesic polar coordinates
116/// keep the radial length, so the length's range always holds 1, and the
117/// area's ratio is the across-ray stretch alone.
118#[derive(Debug, Clone, Copy, PartialEq)]
119pub struct SheetDistortion {
120    /// The least and the most length ratio.
121    pub length: (f64, f64),
122    /// The least and the most area ratio.
123    pub area: (f64, f64),
124}
125
126/// A flat sheet laid on a face's surface, as placed.
127#[derive(Debug, Clone)]
128pub struct SurfaceSheet {
129    surface: SurfaceGeometry,
130    /// The face's own surface, where the face stands unplaced, for an edge
131    /// to carry its pcurve on.
132    own: Option<ogeom_topo::SurfaceId>,
133    chart: Chart,
134    origin: Point2,
135    /// The sheet's `+x` and `+y` at the origin, unit tangents in space, `y`
136    /// the face's outward normal turned against `x`.
137    axes: (Vector, Vector),
138    reach: f64,
139    tol: Tolerances,
140    /// The geodesic sheet's own reach, past which no geodesic polar
141    /// position is landed.
142    geodesic_reach: f64,
143    /// A flattened sheet's map back to geodesic polar coordinates: the
144    /// least-distortion or the as-rigid-as-possible one.
145    flat: Option<Box<crate::sheet_flat::Flattening>>,
146    /// The least and the most length ratio, then area ratio, carried so
147    /// far.
148    seen: core::cell::Cell<[f64; 4]>,
149}
150
151/// How a surface's chart is reached from the sheet.
152#[derive(Debug, Clone, Copy, PartialEq)]
153enum Chart {
154    /// A plane's chart, isometric to the sheet.
155    Plane,
156    /// A cylinder's development.
157    Cylinder,
158    /// A cone's development: slant `rho(v) = radius_at(v) / sin(half)`
159    /// from the apex, chart angle `u` developed to `u sin(half)`.
160    Cone { sin: f64, tan: f64, reference: f64 },
161    /// A sphere's exponential map in closed form.
162    Sphere {
163        frame: ogeom_math::Frame,
164        centre: Point,
165        radius: f64,
166    },
167    /// Geodesics integrated in the surface's chart.
168    Integrated,
169}
170
171impl SurfaceSheet {
172    /// The sheet laid on `face` of `model`, its surface as placed.
173    ///
174    /// `reach` is the radius of the sheet region that will be asked for:
175    /// a sheet point farther from the origin is refused.
176    ///
177    /// # Errors
178    ///
179    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction)
180    /// where `face` is not a face, `at` stands off its surface by more
181    /// than the face's tolerance, `x_direction` is along the normal there,
182    /// `at` is a pole of the surface's chart, `reach` is not a positive
183    /// length, or a sphere's sheet reaches its origin's antipode.
184    pub fn new(
185        model: &Model,
186        face: &Shape,
187        placement: &SheetPlacement,
188        reach: f64,
189        tol: Tolerances,
190    ) -> OgeomResult<Self> {
191        if model.kind_of(face)? != ShapeType::Face {
192            ogeom_bail!(Construction, "a sheet is laid on a face");
193        }
194        if !(reach > 0.0 && reach.is_finite()) {
195            ogeom_bail!(Construction, "a sheet's reach of {reach} is not a length");
196        }
197        let Some(data) = model.node(face).and_then(|n| n.data().as_face()) else {
198            ogeom_bail!(Dangling, "face is not in this model");
199        };
200        let Some(stored) = model.geometry().surface(data.surface) else {
201            ogeom_bail!(Dangling, "surface is not in this model");
202        };
203        let unplaced = face.location().is_identity() && data.location.is_identity();
204        let surface = if unplaced {
205            stored.clone()
206        } else {
207            let placement =
208                face.transform(model.datums())? * data.location.composed(model.datums())?;
209            stored.transformed(&placement, tol)?
210        };
211        let found = ogeom_geom::project_on_surface(&surface, placement.at, 24, tol)?;
212        let off = data.tolerance.get().max(tol.confusion());
213        if found.distance > off {
214            ogeom_bail!(
215                Construction,
216                "the sheet's origin {:?} stands {:.3e} off the face's surface",
217                placement.at,
218                found.distance
219            );
220        }
221        let origin = Point2::new(found.parameters.0, found.parameters.1);
222        let (su, sv) = surface.d1_at(origin.x, origin.y, tol)?;
223        if surface.is_degenerate_at(origin.x, origin.y, tol)? {
224            ogeom_bail!(
225                Construction,
226                "the sheet's origin {:?} is a pole of the surface's chart",
227                placement.at
228            );
229        }
230        let mut normal = Direction::new(su.cross(sv), tol)?.vector();
231        if face.orientation() == Orientation::Reversed {
232            normal = -normal;
233        }
234        let along = placement.x_direction.vector();
235        let x = along - normal * along.dot(normal);
236        let Ok(x) = Direction::new(x, tol) else {
237            ogeom_bail!(
238                Construction,
239                "the sheet's x direction runs along the face's normal at its origin"
240            );
241        };
242        let x = x.vector();
243        let y = normal.cross(x);
244        let chart = match &surface {
245            SurfaceGeometry::Plane(_) => Chart::Plane,
246            SurfaceGeometry::Cylinder(_) => Chart::Cylinder,
247            SurfaceGeometry::Cone(c) => {
248                let cone = c.cone();
249                let (sin, cos) = cone.half_angle().sin_cos();
250                Chart::Cone {
251                    sin,
252                    tan: sin / cos,
253                    reference: cone.reference_radius(),
254                }
255            }
256            SurfaceGeometry::Sphere(s) => {
257                let sphere = s.sphere();
258                if reach >= PI * sphere.radius() {
259                    ogeom_bail!(
260                        Construction,
261                        "a sheet of reach {reach} on a sphere of radius {} reaches the \
262                         point opposite its origin, where every line through the origin \
263                         meets",
264                        sphere.radius()
265                    );
266                }
267                Chart::Sphere {
268                    frame: sphere.frame(),
269                    centre: sphere.centre(),
270                    radius: sphere.radius(),
271                }
272            }
273            _ => Chart::Integrated,
274        };
275        let most = match chart {
276            Chart::Sphere { radius, .. } => PI * radius,
277            _ => f64::INFINITY,
278        };
279        let mut sheet = Self {
280            surface,
281            own: unplaced.then_some(data.surface),
282            chart,
283            origin,
284            axes: (x, y),
285            reach,
286            tol,
287            geodesic_reach: reach,
288            flat: None,
289            seen: core::cell::Cell::new([1.0; 4]),
290        };
291        let (resolution, rigid) = match placement.method {
292            SheetMethod::Geodesic => (None, None),
293            SheetMethod::LeastDistortion { resolution } => (Some(resolution), None),
294            SheetMethod::AsRigidAsPossible {
295                resolution,
296                stretch,
297            } => {
298                if !(stretch > 0.0 && stretch.is_finite()) {
299                    ogeom_bail!(
300                        Construction,
301                        "an as-rigid-as-possible sheet's stretch of {stretch} is not a fraction"
302                    );
303                }
304                (Some(resolution), Some(stretch))
305            }
306        };
307        if let Some(resolution) = resolution
308            && !matches!(chart, Chart::Plane | Chart::Cylinder | Chart::Cone { .. })
309        {
310            sheet.geodesic_reach = most;
311            let flat = {
312                let land = |q: Point2| -> OgeomResult<Point> {
313                    let (uv, _) = sheet.geodesic(q, tol.confusion())?;
314                    sheet.surface.point_at(uv.x, uv.y, tol)
315                };
316                crate::sheet_flat::Flattening::build(
317                    &land,
318                    reach,
319                    most,
320                    resolution,
321                    rigid.is_some(),
322                )?
323            };
324            if let Some(stretch) = rigid {
325                let (least, most) = (1.0 / (1.0 + stretch), 1.0 + stretch);
326                let mut seen = [f64::INFINITY, 0.0, f64::INFINITY, 0.0];
327                for &p in &flat.samples {
328                    let (q, d) = flat.at(p);
329                    let (_, across) = sheet.geodesic(q, tol.confusion())?;
330                    let (short, long) = carried_stretch(q, across, d);
331                    if short.is_nan() || short <= 0.0 {
332                        ogeom_bail!(
333                            Construction,
334                            "the as-rigid-as-possible sheet folds over at the sheet point {p:?}"
335                        );
336                    }
337                    seen = [
338                        seen[0].min(short),
339                        seen[1].max(long),
340                        seen[2].min(short * long),
341                        seen[3].max(short * long),
342                    ];
343                }
344                if seen[0] < least || seen[1] > most {
345                    ogeom_bail!(
346                        Construction,
347                        "the as-rigid-as-possible sheet of reach {reach} stretches lengths by \
348                         {:.4} to {:.4}, past the {least:.4} to {most:.4} its stretch of \
349                         {stretch} allows",
350                        seen[0],
351                        seen[1]
352                    );
353                }
354                sheet.seen.set(seen);
355            }
356            sheet.flat = Some(Box::new(flat));
357        }
358        Ok(sheet)
359    }
360
361    /// An estimate of how far the least-distortion sheet's map stands from
362    /// the smooth conformal flattening, as a length on the sheet: the most
363    /// a vertex shared by the last two meshes moved between them, and the
364    /// most the polynomial back to geodesic coordinates misses the finer
365    /// mesh's map at its vertices and triangle centroids, together. For
366    /// the as-rigid-as-possible sheet, the polynomial's miss alone. Zero
367    /// for a sheet laid by geodesic polar coordinates or by an exact
368    /// development, which have no mesh.
369    ///
370    /// It is a bound only if each doubling of the rings at least halves
371    /// the finer mesh's own departure from the smooth map, in which case
372    /// that departure is no more than the move between the two meshes.
373    /// That contraction is measured, not proved for this discretisation:
374    /// from 8 to 64 rings the moves shrank by 3.8 to 4.0 per doubling on a
375    /// sphere of radius 10 and a torus 20/5, second order as the
376    /// cotangent Laplacian is on such meshes, but nothing here shows it
377    /// holds on every surface. The polynomial's miss is measured at the
378    /// mesh's vertices and centroids only.
379    #[must_use]
380    pub fn flattening_error(&self) -> f64 {
381        self.flat.as_ref().map_or(0.0, |f| f.error)
382    }
383
384    /// The surface the sheet is laid on, as placed: the one the chart
385    /// positions are on.
386    #[must_use]
387    pub const fn surface(&self) -> &SurfaceGeometry {
388        &self.surface
389    }
390
391    /// The `(u, v)` of the surface a sheet point lands on, to the model's
392    /// confusion distance in space.
393    ///
394    /// # Errors
395    ///
396    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction)
397    /// where the point lies past the sheet's reach, lands on a pole of the
398    /// chart, runs off a bounded surface's domain, or where the geodesics
399    /// from the origin cross before reaching it.
400    pub fn chart_point(&self, p: Point2) -> OgeomResult<Point2> {
401        self.landed(p, self.tol.confusion())
402    }
403
404    /// A planar curve over `range` of its parameter carried onto the
405    /// surface: its chart curve, at the same parameter, whose lift onto
406    /// the surface stands within `tolerance` of the carried curve, ready
407    /// for [`CurveOnSurface::new`] with [`SurfaceSheet::surface`].
408    ///
409    /// A line on a plane or a cylinder is carried exactly; anything else
410    /// is fitted, half of `tolerance` left to the fit and half to the
411    /// geodesics, and the fit is measured in space between its samples.
412    /// The chart curve is continued along the curve across every period
413    /// of the chart, so a curve going round a pole of the chart (a circle
414    /// about a sphere's pole) is one chart curve whose ends stand a whole
415    /// turn apart, as a parallel's do.
416    ///
417    /// # Errors
418    ///
419    /// As [`SurfaceSheet::chart_point`] at any point of the curve;
420    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction)
421    /// where `tolerance` is not a length, where the curve runs through a
422    /// pole of the chart, so its chart curve cannot be continued along
423    /// it, or where the fit cannot be held to `tolerance`.
424    pub fn chart_curve(
425        &self,
426        curve: &PlanarCurve,
427        range: (f64, f64),
428        tolerance: f64,
429    ) -> OgeomResult<PlanarCurve> {
430        let tol = self.tol;
431        if !(tolerance > 0.0 && tolerance.is_finite()) {
432            ogeom_bail!(Construction, "a tolerance of {tolerance} is not a length");
433        }
434        if range.0.is_nan() || range.1.is_nan() || range.1 <= range.0 {
435            ogeom_bail!(Construction, "a curve is carried over an increasing range");
436        }
437        if let PlanarCurve::Line(_) = curve
438            && matches!(self.chart, Chart::Plane | Chart::Cylinder)
439        {
440            // The chart is affine on the sheet: a line's image is the line
441            // through its ends' images, at the same parameter.
442            let ends = [curve.point_at(range.0, tol)?, curve.point_at(range.1, tol)?];
443            let mut control = Vec::with_capacity(2);
444            for end in ends {
445                control.push(self.chart_point(end)?);
446            }
447            let knots = KnotVector::new(vec![range.0, range.0, range.1, range.1], 1)?;
448            return Ok(BSpline2d::new(knots, control, tol)?.into());
449        }
450        let half = tolerance / 2.0;
451        let land = |t: f64| self.landed(curve.point_at(t, tol)?, half);
452        self.fitted_chart(&land, range, half)
453    }
454
455    /// The chart curve over `range` through the chart positions `land`
456    /// gives, continued across the chart's periods, fitted to `half` in
457    /// space.
458    fn fitted_chart(
459        &self,
460        land: &dyn Fn(f64) -> OgeomResult<Point2>,
461        range: (f64, f64),
462        half: f64,
463    ) -> OgeomResult<PlanarCurve> {
464        let tol = self.tol;
465        let surface = &self.surface;
466        let lift = Unwrapped::along(surface, land, range)?;
467        let (fitted, image) = ogeom_geom::fit::fit_trace_sampled(
468            |t| {
469                let uv = lift.at(land, t)?;
470                Ok((surface.point_at(uv.x, uv.y, tol)?, uv))
471            },
472            |uv| surface.point_at(uv.x, uv.y, tol),
473            &lift.ts,
474            3,
475            half,
476            tol,
477        )?;
478        if !fitted.met {
479            ogeom_bail!(
480                Construction,
481                "the carried curve's chart curve misses it by {:.3e}, over the {half:.3e} \
482                 asked",
483                fitted.error
484            );
485        }
486        Ok(image.into())
487    }
488
489    /// A planar curve over `range` carried onto the surface as chart
490    /// curves, cut where the curve runs through a pole of a sphere's
491    /// chart: each with the range of the curve's parameter it covers, in
492    /// order. A piece's end at a pole stands on the pole's row of the chart
493    /// at the longitude the curve arrives at or leaves the pole along (the
494    /// longitude of its tangent there), so the pieces meet the pole's
495    /// degenerate edge where [`split_face`](../../ogeom_heal/fn.split_face.html)
496    /// cuts it. Elsewhere, and on every other surface, the one chart curve
497    /// [`SurfaceSheet::chart_curve`] gives.
498    ///
499    /// # Errors
500    ///
501    /// As [`SurfaceSheet::chart_curve`], for each piece.
502    pub fn chart_pieces(
503        &self,
504        curve: &PlanarCurve,
505        range: (f64, f64),
506        tolerance: f64,
507    ) -> OgeomResult<Vec<(PlanarCurve, (f64, f64))>> {
508        let tol = self.tol;
509        if !(tolerance > 0.0 && tolerance.is_finite()) {
510            ogeom_bail!(Construction, "a tolerance of {tolerance} is not a length");
511        }
512        if range.0.is_nan() || range.1.is_nan() || range.1 <= range.0 {
513            ogeom_bail!(Construction, "a curve is carried over an increasing range");
514        }
515        // How far the sheet point at `t` lands from the nearest pole, which,
516        // and the chart position a piece ending there at `t` takes, the
517        // curve arriving at the pole or leaving it.
518        type Off<'a> = Box<dyn Fn(f64) -> OgeomResult<(f64, usize)> + 'a>;
519        type End<'a> = Box<dyn Fn(f64, bool, usize, f64) -> OgeomResult<Point2> + 'a>;
520        let (off, end): (Off<'_>, End<'_>) = match self.chart {
521            Chart::Sphere {
522                frame,
523                centre,
524                radius,
525            } => {
526                let poles = [
527                    (
528                        centre + frame.z().vector() * radius,
529                        core::f64::consts::FRAC_PI_2,
530                    ),
531                    (
532                        centre - frame.z().vector() * radius,
533                        -core::f64::consts::FRAC_PI_2,
534                    ),
535                ];
536                // Where the sheet point at `t` lands in space, through the
537                // pole.
538                let point = move |t: f64| -> OgeomResult<Point> {
539                    let p = curve.point_at(t, tol)?;
540                    let q = self.flat.as_ref().map_or(p, |f| f.at(p).0);
541                    let along = self.axes.0 * q.x + self.axes.1 * q.y;
542                    self.on_sphere_point(along, q.x.hypot(q.y), centre, radius)
543                };
544                // The longitude of a direction at a pole, read in the
545                // sphere's frame.
546                let longitude =
547                    move |d: Vector| d.dot(frame.y().vector()).atan2(d.dot(frame.x().vector()));
548                (
549                    Box::new(move |t: f64| {
550                        let p = point(t)?;
551                        let (n, s) = (p.distance(poles[0].0), p.distance(poles[1].0));
552                        Ok(if n <= s { (n, 0) } else { (s, 1) })
553                    }),
554                    Box::new(move |t: f64, leaving: bool, pole: usize, step: f64| {
555                        let d = if leaving {
556                            point(t + step)? - point(t)?
557                        } else {
558                            point(t)? - point(t - step)?
559                        };
560                        let d = if leaving { d } else { -d };
561                        Ok(Point2::new(longitude(d), poles[pole].1))
562                    }),
563                )
564            }
565            Chart::Cone {
566                sin,
567                tan,
568                reference,
569            } if self.flat.is_none() => {
570                // The developed point: the apex at its origin, so its length
571                // is the distance to the apex on the surface.
572                let developed = move |t: f64| -> OgeomResult<(Vector2, f64, f64)> {
573                    let p = curve.point_at(t, tol)?;
574                    self.cone_developed(self.axes.0 * p.x + self.axes.1 * p.y, sin, tan, reference)
575                };
576                let u0 = self.origin.x;
577                (
578                    Box::new(move |t: f64| Ok((developed(t)?.0.magnitude(), 0))),
579                    // The developed point just past the apex on the curve's
580                    // side, read as `developed_cone` reads a point.
581                    Box::new(move |t: f64, leaving: bool, _, step: f64| {
582                        let near = if leaving { t + step } else { t - step };
583                        let (d, rho, phi) = developed(near)?;
584                        let sign = rho.signum();
585                        let turn = wrapped((d.y * sign).atan2(d.x * sign) - phi);
586                        Ok(Point2::new(u0 + turn / sin, -reference / tan))
587                    }),
588                )
589            }
590            _ => return Ok(vec![(self.chart_curve(curve, range, tolerance)?, range)]),
591        };
592        // The curve's nearest approaches to a pole: each local least of
593        // the distance among samples, refined by golden sections to the
594        // parameter's own resolution; a hit where it reaches the pole
595        // within the confusion distance.
596        const SAMPLES: u32 = 256;
597        let at = |k: u32| (range.1 - range.0).mul_add(f64::from(k) / f64::from(SAMPLES), range.0);
598        let distances = (0..=SAMPLES)
599            .map(|k| off(at(k)).map(|d| d.0))
600            .collect::<OgeomResult<Vec<_>>>()?;
601        let mut hits: Vec<(f64, usize)> = Vec::new();
602        for k in 0..=SAMPLES {
603            let i = usize::try_from(k).unwrap_or(0);
604            let left = if k == 0 {
605                f64::INFINITY
606            } else {
607                distances[i - 1]
608            };
609            let right = if k == SAMPLES {
610                f64::INFINITY
611            } else {
612                distances[i + 1]
613            };
614            if distances[i] > left || distances[i] > right {
615                continue;
616            }
617            let (mut a, mut b) = (at(k.saturating_sub(1)), at((k + 1).min(SAMPLES)));
618            let ratio = (5.0_f64.sqrt() - 1.0) / 2.0;
619            let resolution = f64::EPSILON * range.0.abs().max(range.1.abs()).max(range.1 - range.0);
620            while b - a > resolution {
621                let (c, d) = ((b - a).mul_add(-ratio, b), (b - a).mul_add(ratio, a));
622                if off(c)?.0 <= off(d)?.0 {
623                    b = d;
624                } else {
625                    a = c;
626                }
627            }
628            let t = f64::midpoint(a, b);
629            let (d, pole) = off(t)?;
630            if d <= tol.confusion() && hits.last().is_none_or(|h| t - h.0 > resolution) {
631                hits.push((t, pole));
632            }
633        }
634        if hits.is_empty() {
635            return Ok(vec![(self.chart_curve(curve, range, tolerance)?, range)]);
636        }
637        let half = tolerance / 2.0;
638        let mut cuts: Vec<f64> = vec![range.0];
639        cuts.extend(hits.iter().map(|h| h.0));
640        cuts.push(range.1);
641        let mut pieces = Vec::new();
642        for pair in cuts.windows(2) {
643            let (lo, hi) = (pair[0], pair[1]);
644            if hi - lo <= 0.0 {
645                continue;
646            }
647            // The one-sided difference's step, the root of the arithmetic's
648            // resolution of the parameter, balancing its truncation against
649            // its rounding.
650            let step = |t: f64| f64::EPSILON.sqrt() * t.abs().max(hi - lo);
651            let mut ends: Vec<(f64, Point2)> = Vec::new();
652            for &(t, pole) in &hits {
653                if t == lo {
654                    ends.push((t, end(t, true, pole, step(t))?));
655                } else if t == hi {
656                    ends.push((t, end(t, false, pole, step(t))?));
657                }
658            }
659            let land = |t: f64| -> OgeomResult<Point2> {
660                match ends.iter().find(|e| e.0 == t) {
661                    Some(e) => Ok(e.1),
662                    None => self.landed(curve.point_at(t, tol)?, half),
663                }
664            };
665            pieces.push((self.fitted_chart(&land, (lo, hi), half)?, (lo, hi)));
666        }
667        Ok(pieces)
668    }
669
670    /// Edges on the surface along a planar curve carried by
671    /// [`SurfaceSheet::chart_pieces`], one per piece, each as
672    /// [`SurfaceSheet::chart_edge`] makes it.
673    ///
674    /// # Errors
675    ///
676    /// As [`SurfaceSheet::chart_pieces`] and [`make_edge`].
677    pub fn chart_edges(
678        &self,
679        model: &mut Model,
680        curve: &PlanarCurve,
681        range: (f64, f64),
682        tolerance: f64,
683    ) -> OgeomResult<Vec<Shape>> {
684        let mut out = Vec::new();
685        for (pcurve, piece) in self.chart_pieces(curve, range, tolerance)? {
686            out.push(self.edge_on(model, pcurve, piece)?);
687        }
688        Ok(out)
689    }
690
691    /// An edge on the surface along a planar curve carried by
692    /// [`SurfaceSheet::chart_curve`]: its curve the chart curve on the
693    /// surface, and, on a face standing unplaced, the chart curve its
694    /// pcurve on the face's surface.
695    ///
696    /// # Errors
697    ///
698    /// As [`SurfaceSheet::chart_curve`] and
699    /// [`make_edge`].
700    pub fn chart_edge(
701        &self,
702        model: &mut Model,
703        curve: &PlanarCurve,
704        range: (f64, f64),
705        tolerance: f64,
706    ) -> OgeomResult<Built> {
707        let pcurve = self.chart_curve(curve, range, tolerance)?;
708        let on = Curve::OnSurface(Box::new(CurveOnSurface::new(
709            pcurve.clone(),
710            self.surface.clone(),
711        )));
712        let built = make_edge(model, on, range, self.tol)?;
713        self.own_pcurve(model, &built.shape, pcurve, range)?;
714        Ok(built)
715    }
716
717    /// An edge whose curve is `pcurve` on the surface over `range`.
718    fn edge_on(
719        &self,
720        model: &mut Model,
721        pcurve: PlanarCurve,
722        range: (f64, f64),
723    ) -> OgeomResult<Shape> {
724        let on = Curve::OnSurface(Box::new(CurveOnSurface::new(
725            pcurve.clone(),
726            self.surface.clone(),
727        )));
728        let edge = make_edge(model, on, range, self.tol)?.shape;
729        self.own_pcurve(model, &edge, pcurve, range)?;
730        Ok(edge)
731    }
732
733    /// On a face standing unplaced, `pcurve` attached to `edge` on the
734    /// face's own surface, and the edge stated same-parameter: its curve is
735    /// that pcurve on that surface, so the two agree at every parameter.
736    fn own_pcurve(
737        &self,
738        model: &mut Model,
739        edge: &Shape,
740        pcurve: PlanarCurve,
741        range: (f64, f64),
742    ) -> OgeomResult<()> {
743        let Some(surface) = self.own else {
744            return Ok(());
745        };
746        attach_pcurve(model, edge, pcurve, surface, Location::identity(), range)?;
747        let Some(node) = model.node_mut(edge) else {
748            ogeom_bail!(Dangling, "edge is not in this model");
749        };
750        let ogeom_topo::NodeData::Edge(data) = node.data_mut() else {
751            ogeom_bail!(Construction, "edge node holds no edge data");
752        };
753        data.assert_same_parameter(true);
754        Ok(())
755    }
756
757    /// How far the sheet has stretched, over every sheet point carried so
758    /// far.
759    #[must_use]
760    pub fn distortion(&self) -> SheetDistortion {
761        let [short, long, small, large] = self.seen.get();
762        SheetDistortion {
763            length: (short, long),
764            area: (small, large),
765        }
766    }
767
768    /// The chart position a sheet point lands on, to `accuracy` in space,
769    /// its stretch recorded.
770    fn landed(&self, p: Point2, accuracy: f64) -> OgeomResult<Point2> {
771        let r = p.x.hypot(p.y);
772        if !r.is_finite() {
773            ogeom_bail!(Construction, "the sheet point {p:?} is not a point");
774        }
775        if r > self.reach + self.tol.confusion() {
776            ogeom_bail!(
777                Construction,
778                "the sheet point {p:?} lies past the sheet's reach of {}",
779                self.reach
780            );
781        }
782        let (uv, short, long) = match &self.flat {
783            None => {
784                let (uv, stretch) = self.geodesic(p, accuracy)?;
785                (uv, stretch.min(1.0), stretch.max(1.0))
786            }
787            Some(flat) => {
788                let (q, d) = flat.at(p);
789                let (uv, stretch) = self.geodesic(q, accuracy)?;
790                let (short, long) = carried_stretch(q, stretch, d);
791                if short.is_nan() || short <= 0.0 {
792                    ogeom_bail!(
793                        Construction,
794                        "the least-distortion sheet folds over at the sheet point {p:?}"
795                    );
796                }
797                (uv, short, long)
798            }
799        };
800        let [a, b, c, e] = self.seen.get();
801        self.seen.set([
802            a.min(short),
803            b.max(long),
804            c.min(short * long),
805            e.max(short * long),
806        ]);
807        Ok(uv)
808    }
809
810    /// The chart position a geodesic polar position lands on, to
811    /// `accuracy` in space, and its across-ray stretch.
812    fn geodesic(&self, p: Point2, accuracy: f64) -> OgeomResult<(Point2, f64)> {
813        let r = p.x.hypot(p.y);
814        if !r.is_finite() || r > self.geodesic_reach + self.tol.confusion() {
815            ogeom_bail!(
816                Construction,
817                "the sheet point {p:?} lies past the sheet's reach of {}",
818                self.geodesic_reach
819            );
820        }
821        let along = self.axes.0 * p.x + self.axes.1 * p.y;
822        let (uv, stretch) = match self.chart {
823            Chart::Plane | Chart::Cylinder => (self.developed(along)?, 1.0),
824            Chart::Cone {
825                sin,
826                tan,
827                reference,
828            } => (self.developed_cone(along, sin, tan, reference, p)?, 1.0),
829            Chart::Sphere {
830                frame,
831                centre,
832                radius,
833            } => self.on_sphere(along, r, &frame, centre, radius, p)?,
834            Chart::Integrated => self.integrated(along, r, accuracy, p)?,
835        };
836        if stretch.is_nan() || stretch <= 0.0 {
837            ogeom_bail!(
838                Construction,
839                "the geodesics from the sheet's origin cross before the sheet point \
840                 {p:?}: the sheet folds there"
841            );
842        }
843        Ok((uv, stretch))
844    }
845
846    /// The chart step from the origin along the tangent vector `t` there,
847    /// read in the origin's chart derivatives: where the chart is affine
848    /// on the development (a plane, a cylinder), the position itself.
849    fn developed(&self, t: Vector) -> OgeomResult<Point2> {
850        let (su, sv) = self.surface.d1_at(self.origin.x, self.origin.y, self.tol)?;
851        let step = in_chart(su, sv, t)?;
852        Ok(self.origin + step)
853    }
854
855    /// A cone's development: the origin's developed point stepped along
856    /// `t`, read back as slant and angle from the apex.
857    fn developed_cone(
858        &self,
859        t: Vector,
860        sin: f64,
861        tan: f64,
862        reference: f64,
863        p: Point2,
864    ) -> OgeomResult<Point2> {
865        let u0 = self.origin.x;
866        let (d, rho, phi) = self.cone_developed(t, sin, tan, reference)?;
867        let slant = d.magnitude();
868        if slant <= self.tol.confusion() {
869            ogeom_bail!(
870                Construction,
871                "the sheet point {p:?} lands on the cone's apex, a pole of its chart"
872            );
873        }
874        // The developed point read on the origin's own side of the apex.
875        let sign = rho.signum();
876        let turn = wrapped((d.y * sign).atan2(d.x * sign) - phi);
877        let rho = slant * sign;
878        Ok(Point2::new(u0 + turn / sin, (rho * sin - reference) / tan))
879    }
880
881    /// A cone's developed point for the tangent step `t` from the origin,
882    /// with the origin's signed slant `rho = radius / sin` from the apex and
883    /// developed angle `phi = u sin`: the apex at the development's origin.
884    fn cone_developed(
885        &self,
886        t: Vector,
887        sin: f64,
888        tan: f64,
889        reference: f64,
890    ) -> OgeomResult<(Vector2, f64, f64)> {
891        let (u0, v0) = (self.origin.x, self.origin.y);
892        let (su, sv) = self.surface.d1_at(u0, v0, self.tol)?;
893        let step = in_chart(su, sv, t)?;
894        // Slant `rho = radius / sin`, signed, and developed angle `u sin`.
895        let rho = tan.mul_add(v0, reference) / sin;
896        let phi = u0 * sin;
897        let (s, c) = phi.sin_cos();
898        let start = Vector2::new(rho * c, rho * s);
899        let du = Vector2::new(-rho * sin * s, rho * sin * c);
900        let dv = Vector2::new(c, s) * (tan / sin);
901        Ok((start + du * step.x + dv * step.y, rho, phi))
902    }
903
904    /// A sphere's exponential map: the great circle leaving the origin
905    /// along `t`, after length `r`, read in the sphere's chart with its
906    /// longitude continued from the origin's; the stretch is
907    /// `sin(r / R) / (r / R)`.
908    fn on_sphere(
909        &self,
910        t: Vector,
911        r: f64,
912        frame: &ogeom_math::Frame,
913        centre: Point,
914        radius: f64,
915        p: Point2,
916    ) -> OgeomResult<(Point2, f64)> {
917        let tol = self.tol;
918        let landed = self.on_sphere_point(t, r, centre, radius)?;
919        let angle = r / radius;
920        let local = landed - centre;
921        let (x, y, z) = (
922            local.dot(frame.x().vector()),
923            local.dot(frame.y().vector()),
924            local.dot(frame.z().vector()),
925        );
926        if x.hypot(y) <= tol.confusion() {
927            ogeom_bail!(
928                Construction,
929                "the sheet point {p:?} lands on a pole of the sphere's chart, at {landed:?}"
930            );
931        }
932        // Latitude against the ring's radius, which stays exact near a pole
933        // where the sine of the latitude loses half its digits.
934        let latitude = z.atan2(x.hypot(y));
935        let longitude = self.origin.x + wrapped(y.atan2(x) - self.origin.x);
936        let stretch = if angle > 0.0 {
937            angle.sin() / angle
938        } else {
939            1.0
940        };
941        Ok((Point2::new(longitude, latitude), stretch))
942    }
943
944    /// The point of a sphere the great circle leaving the origin along `t`
945    /// reaches after length `r`, `t` of length `r`.
946    fn on_sphere_point(&self, t: Vector, r: f64, centre: Point, radius: f64) -> OgeomResult<Point> {
947        let start = self
948            .surface
949            .point_at(self.origin.x, self.origin.y, self.tol)?;
950        if r <= 0.0 {
951            return Ok(start);
952        }
953        let out = (start - centre) * (1.0 / radius);
954        let (s, c) = (r / radius).sin_cos();
955        Ok(centre + (out * c + t * (s / r)) * radius)
956    }
957
958    /// The geodesic leaving the origin along the unit-length direction
959    /// `t / r`, after length `r`, integrated in the chart with its Jacobi
960    /// field, to `accuracy` in space: its chart position and `J(r) / r`.
961    fn integrated(
962        &self,
963        t: Vector,
964        r: f64,
965        accuracy: f64,
966        p: Point2,
967    ) -> OgeomResult<(Point2, f64)> {
968        if r == 0.0 {
969            return Ok((self.origin, 1.0));
970        }
971        let tol = self.tol;
972        let (su, sv) = self.surface.d1_at(self.origin.x, self.origin.y, tol)?;
973        let velocity = in_chart(su, sv, t * (1.0 / r))?;
974        let mut state = [
975            self.origin.x,
976            self.origin.y,
977            velocity.x,
978            velocity.y,
979            0.0,
980            1.0,
981        ];
982        let mut s = 0.0;
983        let mut h = r;
984        while s < r {
985            h = h.min(r - s);
986            if h <= tol.confusion() && s + h < r {
987                ogeom_bail!(
988                    Construction,
989                    "the geodesic to the sheet point {p:?} cannot be followed past \
990                     chart position ({}, {}): the chart degenerates there",
991                    state[0],
992                    state[1]
993                );
994            }
995            let whole = self.rk4(&state, h, p)?;
996            let first = self.rk4(&state, h / 2.0, p)?;
997            let both = self.rk4(&first, h / 2.0, p)?;
998            let (du, dv) = self.surface.d1_at(both[0], both[1], tol)?;
999            // Step doubling: the two halves' error is a fifteenth of their
1000            // difference from the whole step, the classical fourth order's
1001            // Richardson estimate.
1002            let miss = (du * (both[0] - whole[0]) + dv * (both[1] - whole[1]))
1003                .magnitude()
1004                .max((both[4] - whole[4]).abs())
1005                / 15.0;
1006            let allowed = accuracy * h / r;
1007            // A last remnant no longer than the confusion distance is taken
1008            // as it is: it cannot move the point by more than its length.
1009            if miss <= allowed || h <= tol.confusion() {
1010                state = both;
1011                // The extrapolated fifth-order value.
1012                for (k, value) in state.iter_mut().enumerate() {
1013                    *value += (both[k] - whole[k]) / 15.0;
1014                }
1015                s += h;
1016            }
1017            // The step that would just meet the allowance, its error
1018            // growing with its fifth power, its allowance with its first;
1019            // the clamp and the margin on a refused step only pace the
1020            // search, the allowance alone decides what is kept.
1021            let grow = if miss > 0.0 {
1022                (allowed / miss).powf(0.25)
1023            } else {
1024                2.0
1025            };
1026            h *= grow.clamp(0.25, 2.0) * if miss > allowed { 0.9 } else { 1.0 };
1027        }
1028        Ok((Point2::new(state[0], state[1]), state[4] / r))
1029    }
1030
1031    /// One classical Runge-Kutta step of length `h`.
1032    fn rk4(&self, y: &[f64; 6], h: f64, p: Point2) -> OgeomResult<[f64; 6]> {
1033        let k1 = self.slope(y, p)?;
1034        let k2 = self.slope(&ahead(y, &k1, h / 2.0), p)?;
1035        let k3 = self.slope(&ahead(y, &k2, h / 2.0), p)?;
1036        let k4 = self.slope(&ahead(y, &k3, h), p)?;
1037        let mut out = *y;
1038        for k in 0..6 {
1039            out[k] += h / 6.0 * (k1[k] + 2.0 * (k2[k] + k3[k]) + k4[k]);
1040        }
1041        Ok(out)
1042    }
1043
1044    /// The geodesic and Jacobi equations at the state
1045    /// `(u, v, u', v', J, J')`: the chart acceleration that keeps the
1046    /// curve's acceleration along the normal, and `J'' = -K J`.
1047    fn slope(&self, y: &[f64; 6], p: Point2) -> OgeomResult<[f64; 6]> {
1048        let tol = self.tol;
1049        let (u, v) = (y[0], y[1]);
1050        let ((ua, ub), (va, vb)) = self.surface.domain();
1051        let slack = tol.parametric();
1052        let off_u = !self.surface.is_periodic_u() && (u < ua - slack || u > ub + slack);
1053        let off_v = !self.surface.is_periodic_v() && (v < va - slack || v > vb + slack);
1054        if off_u || off_v {
1055            // Across a side of the chart that collapses to a point (a
1056            // surface of revolution meeting its axis, a spline's
1057            // degenerate side) the geodesic runs on past the pole on the
1058            // far side of it, half a turn round in the chart, where the
1059            // chart's metric has degenerated and no integrated step
1060            // follows it.
1061            let side = if off_v {
1062                (0, if v > vb { vb } else { va })
1063            } else {
1064                (1, if u > ub { ub } else { ua })
1065            };
1066            if collapsed_side(&self.surface, side, tol) {
1067                ogeom_bail!(
1068                    Construction,
1069                    "the geodesic to the sheet point {p:?} crosses a pole of the \
1070                     surface's chart, its side collapsed to a point at chart \
1071                     position ({u}, {v}); a sheet laid by integrated geodesics \
1072                     is not carried past a pole"
1073                );
1074            }
1075            ogeom_bail!(
1076                Construction,
1077                "the sheet point {p:?} runs off the surface's domain at chart \
1078                 position ({u}, {v})"
1079            );
1080        }
1081        let jet = self.surface.jet_at(u, v, tol)?;
1082        let (e, f, g) = (jet.du.dot(jet.du), jet.du.dot(jet.dv), jet.dv.dot(jet.dv));
1083        let det = e.mul_add(g, -f * f);
1084        let area = jet.du.cross(jet.dv);
1085        if det.is_nan() || det <= 0.0 || area.magnitude() <= tol.angular() * e.max(g) {
1086            return Err(ogeom_err!(
1087                Construction,
1088                "the geodesic to the sheet point {p:?} reaches a pole of the surface's \
1089                 chart at ({u}, {v})"
1090            ));
1091        }
1092        let (a, b) = (y[2], y[3]);
1093        let bend = jet.d2u * (a * a) + jet.duv * (2.0 * a * b) + jet.d2v * (b * b);
1094        let (ru, rv) = (-jet.du.dot(bend), -jet.dv.dot(bend));
1095        let accel_u = g.mul_add(ru, -f * rv) / det;
1096        let accel_v = e.mul_add(rv, -f * ru) / det;
1097        let normal = area * (1.0 / area.magnitude());
1098        let (l, m, n) = (
1099            jet.d2u.dot(normal),
1100            jet.duv.dot(normal),
1101            jet.d2v.dot(normal),
1102        );
1103        let gaussian = l.mul_add(n, -m * m) / det;
1104        Ok([a, b, accel_u, accel_v, y[5], -gaussian * y[4]])
1105    }
1106}
1107
1108/// Whether the chart's side `(axis, bound)` collapses to one point in
1109/// space: the row of the other parameter at `bound`, `axis` naming which
1110/// parameter runs along it (0 for u at a v bound, 1 for v at a u bound),
1111/// lifting within the confusion distance of its first point at every one
1112/// of evenly spaced samples.
1113fn collapsed_side(surface: &SurfaceGeometry, (axis, bound): (usize, f64), tol: Tolerances) -> bool {
1114    const PIECES: u32 = 16;
1115    let ((ua, ub), (va, vb)) = surface.domain();
1116    let (lo, hi) = if axis == 0 { (ua, ub) } else { (va, vb) };
1117    if !(lo.is_finite() && hi.is_finite() && bound.is_finite()) {
1118        return false;
1119    }
1120    let at = |k: u32| {
1121        let t = (hi - lo).mul_add(f64::from(k) / f64::from(PIECES), lo);
1122        if axis == 0 {
1123            surface.point_at(t, bound, tol)
1124        } else {
1125            surface.point_at(bound, t, tol)
1126        }
1127    };
1128    let Ok(first) = at(0) else {
1129        return false;
1130    };
1131    (1..=PIECES).all(|k| at(k).is_ok_and(|q| q.distance(first) <= tol.confusion()))
1132}
1133
1134/// A carried curve's chart positions continued along it: each periodic
1135/// chart coordinate taken at the period nearest its neighbours', read
1136/// from a table of positions dense enough that no step between two of
1137/// them turns by as much as a quarter of a period. A position away from
1138/// the table is its ray's position moved by the whole periods that put it
1139/// nearest the table's line between the samples either side.
1140struct Unwrapped {
1141    periods: [Option<f64>; 2],
1142    ts: Vec<f64>,
1143    uvs: Vec<Point2>,
1144}
1145
1146impl Unwrapped {
1147    /// The table over `range` of the chart positions `land` gives.
1148    fn along(
1149        surface: &SurfaceGeometry,
1150        land: &dyn Fn(f64) -> OgeomResult<Point2>,
1151        range: (f64, f64),
1152    ) -> OgeomResult<Self> {
1153        let ((ua, ub), (va, vb)) = surface.domain();
1154        let periods = [
1155            surface.is_periodic_u().then_some(ub - ua),
1156            surface.is_periodic_v().then_some(vb - va),
1157        ];
1158        let mut ts: Vec<f64> = (0..=64)
1159            .map(|k| (range.1 - range.0).mul_add(f64::from(k) / 64.0, range.0))
1160            .collect();
1161        let mut seen: ogeom_core::FastMap<u64, Point2> = ogeom_core::FastMap::default();
1162        loop {
1163            let mut uvs: Vec<Point2> = Vec::with_capacity(ts.len());
1164            for &t in &ts {
1165                let ray = match seen.get(&t.to_bits()) {
1166                    Some(uv) => *uv,
1167                    None => {
1168                        let uv = land(t)?;
1169                        seen.insert(t.to_bits(), uv);
1170                        uv
1171                    }
1172                };
1173                uvs.push(match uvs.last() {
1174                    Some(previous) => nearest(ray, *previous, periods),
1175                    None => ray,
1176                });
1177            }
1178            let mut next = Vec::with_capacity(ts.len() * 2);
1179            for (k, pair) in ts.windows(2).enumerate() {
1180                next.push(pair[0]);
1181                let step = uvs[k + 1] - uvs[k];
1182                let wide = [step.x, step.y]
1183                    .iter()
1184                    .zip(periods)
1185                    .any(|(d, p)| p.is_some_and(|p| d.abs() > p / 4.0));
1186                if wide {
1187                    let middle = f64::midpoint(pair[0], pair[1]);
1188                    if middle <= pair[0] || middle >= pair[1] {
1189                        ogeom_bail!(
1190                            Construction,
1191                            "the carried curve runs through a pole of the surface's chart \
1192                             near its parameter {middle}: its chart curve cannot be \
1193                             continued along it"
1194                        );
1195                    }
1196                    next.push(middle);
1197                }
1198            }
1199            next.push(ts[ts.len() - 1]);
1200            if next.len() == ts.len() {
1201                return Ok(Self { periods, ts, uvs });
1202            }
1203            if next.len() > ogeom_geom::fit::SAMPLED_SPANS + 1 {
1204                ogeom_bail!(
1205                    Construction,
1206                    "the carried curve runs through a pole of the surface's chart: its \
1207                     chart curve turns too fast to be continued along it"
1208                );
1209            }
1210            ts = next;
1211        }
1212    }
1213
1214    /// The continued chart position at `t`, `land` the table's own.
1215    fn at(&self, land: &dyn Fn(f64) -> OgeomResult<Point2>, t: f64) -> OgeomResult<Point2> {
1216        let ray = land(t)?;
1217        let last = self.ts.len() - 1;
1218        let k = self.ts.partition_point(|s| *s <= t).clamp(1, last);
1219        let (t0, t1) = (self.ts[k - 1], self.ts[k]);
1220        let f = ((t - t0) / (t1 - t0)).clamp(0.0, 1.0);
1221        let guide = self.uvs[k - 1] + (self.uvs[k] - self.uvs[k - 1]) * f;
1222        let out = nearest(ray, guide, self.periods);
1223        Ok(out)
1224    }
1225}
1226
1227/// `uv` moved by the whole periods that bring each periodic coordinate
1228/// nearest `guide`'s.
1229fn nearest(uv: Point2, guide: Point2, periods: [Option<f64>; 2]) -> Point2 {
1230    let near = |x: f64, g: f64, p: Option<f64>| match p {
1231        Some(p) => ((g - x) / p).round().mul_add(p, x),
1232        None => x,
1233    };
1234    Point2::new(
1235        near(uv.x, guide.x, periods[0]),
1236        near(uv.y, guide.y, periods[1]),
1237    )
1238}
1239
1240/// The least and the most length ratio of the least-distortion map at a
1241/// sheet point: the polynomial's derivative `d` there, carried to geodesic
1242/// polar position `q`, followed by the geodesic sheet, which keeps the
1243/// radial length and stretches across the ray by `stretch`. The singular
1244/// values of the composite.
1245fn carried_stretch(q: Point2, stretch: f64, d: [[f64; 2]; 2]) -> (f64, f64) {
1246    let r = q.x.hypot(q.y);
1247    let (c, s) = if r > 0.0 {
1248        (q.x / r, q.y / r)
1249    } else {
1250        (1.0, 0.0)
1251    };
1252    // Rows of the geodesic sheet's derivative in the polar frame at `q`:
1253    // the radial component kept, the across one stretched.
1254    let m = [
1255        [c * d[0][0] + s * d[1][0], c * d[0][1] + s * d[1][1]],
1256        [
1257            stretch * (-s * d[0][0] + c * d[1][0]),
1258            stretch * (-s * d[0][1] + c * d[1][1]),
1259        ],
1260    ];
1261    let frobenius = m[0][0].powi(2) + m[0][1].powi(2) + m[1][0].powi(2) + m[1][1].powi(2);
1262    let det = m[0][0].mul_add(m[1][1], -m[0][1] * m[1][0]);
1263    let spread = frobenius
1264        .mul_add(frobenius, -4.0 * det * det)
1265        .max(0.0)
1266        .sqrt();
1267    let long = (f64::midpoint(frobenius, spread)).sqrt();
1268    let short = if det <= 0.0 { det } else { det / long };
1269    (short, long)
1270}
1271
1272/// The state `y` stepped `h` along the slope `k`.
1273fn ahead(y: &[f64; 6], k: &[f64; 6], h: f64) -> [f64; 6] {
1274    let mut out = *y;
1275    for i in 0..6 {
1276        out[i] += h * k[i];
1277    }
1278    out
1279}
1280
1281/// The chart step `(du, dv)` whose image under the chart derivatives
1282/// `su`, `sv` is the tangent vector `t`: the normal equations of the
1283/// first fundamental form.
1284fn in_chart(su: Vector, sv: Vector, t: Vector) -> OgeomResult<Vector2> {
1285    let (e, f, g) = (su.dot(su), su.dot(sv), sv.dot(sv));
1286    let det = e.mul_add(g, -f * f);
1287    if det.is_nan() || det <= 0.0 {
1288        ogeom_bail!(
1289            Construction,
1290            "the surface's chart degenerates at the sheet's origin"
1291        );
1292    }
1293    let (a, b) = (t.dot(su), t.dot(sv));
1294    Ok(Vector2::new(
1295        g.mul_add(a, -f * b) / det,
1296        e.mul_add(b, -f * a) / det,
1297    ))
1298}
1299
1300/// An angle brought into `(-pi, pi]`.
1301fn wrapped(angle: f64) -> f64 {
1302    let turned = (angle + PI).rem_euclid(2.0 * PI) - PI;
1303    if turned <= -PI {
1304        turned + 2.0 * PI
1305    } else {
1306        turned
1307    }
1308}
1309
1310#[cfg(test)]
1311mod tests {
1312    #![allow(clippy::unwrap_used, clippy::expect_used, reason = "test code")]
1313
1314    use super::*;
1315    use ogeom_math::Frame;
1316    use ogeom_topo::explore_unique;
1317
1318    const T: Tolerances = Tolerances::millimetres();
1319
1320    #[test]
1321    fn integrated_geodesics_on_a_sphere_meet_its_closed_form() {
1322        // The sphere laid in closed form and the same sphere laid by
1323        // integrating its geodesics in its chart: where every ray lands,
1324        // and how far across the rays it stretches, sin(r / R) / (r / R).
1325        let mut model = Model::new();
1326        let sphere = crate::make_sphere(&mut model, Frame::WORLD, 10.0, T)
1327            .unwrap()
1328            .shape;
1329        let face = explore_unique(&model, &sphere, ShapeType::Face).unwrap()[0].clone();
1330        // Off the equator, where the chart's metric varies along every ray.
1331        let at = Point::new(3.0, -9.0, 1.0);
1332        let placement = SheetPlacement {
1333            at: Point::from_vector(at.to_vector() * (10.0 / at.to_vector().magnitude())),
1334            x_direction: Direction::X,
1335            method: SheetMethod::Geodesic,
1336        };
1337        let exact = SurfaceSheet::new(&model, &face, &placement, 9.0, T).unwrap();
1338        let mut integrated = exact.clone();
1339        integrated.chart = Chart::Integrated;
1340        let mut worst: f64 = 0.0;
1341        for k in 0..24 {
1342            let angle = f64::from(k) * PI / 12.0;
1343            for r in [0.5, 2.0, 5.0, 9.0] {
1344                let p = Point2::new(r * angle.cos(), r * angle.sin());
1345                let (a, b) = (
1346                    exact.chart_point(p).unwrap(),
1347                    integrated.chart_point(p).unwrap(),
1348                );
1349                let (pa, pb) = (
1350                    exact.surface.point_at(a.x, a.y, T).unwrap(),
1351                    integrated.surface.point_at(b.x, b.y, T).unwrap(),
1352                );
1353                worst = worst.max(pa.distance(pb));
1354            }
1355        }
1356        assert!(worst <= 1e-6, "{worst}");
1357        let (e, i) = (exact.distortion(), integrated.distortion());
1358        assert!((e.area.0 - 0.9_f64.sin() / 0.9).abs() <= 1e-12, "{e:?}");
1359        assert!((i.area.0 - e.area.0).abs() <= 1e-6, "{i:?} {e:?}");
1360    }
1361}