1use 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#[derive(Debug, Clone, Copy, PartialEq)]
41#[non_exhaustive]
42pub enum SheetMethod {
43 Geodesic,
48 LeastDistortion {
67 resolution: f64,
70 },
71 AsRigidAsPossible {
92 resolution: f64,
95 stretch: f64,
97 },
98}
99
100#[derive(Debug, Clone, Copy, PartialEq)]
102pub struct SheetPlacement {
103 pub at: Point,
105 pub x_direction: Direction,
108 pub method: SheetMethod,
110}
111
112#[derive(Debug, Clone, Copy, PartialEq)]
119pub struct SheetDistortion {
120 pub length: (f64, f64),
122 pub area: (f64, f64),
124}
125
126#[derive(Debug, Clone)]
128pub struct SurfaceSheet {
129 surface: SurfaceGeometry,
130 own: Option<ogeom_topo::SurfaceId>,
133 chart: Chart,
134 origin: Point2,
135 axes: (Vector, Vector),
138 reach: f64,
139 tol: Tolerances,
140 geodesic_reach: f64,
143 flat: Option<Box<crate::sheet_flat::Flattening>>,
146 seen: core::cell::Cell<[f64; 4]>,
149}
150
151#[derive(Debug, Clone, Copy, PartialEq)]
153enum Chart {
154 Plane,
156 Cylinder,
158 Cone { sin: f64, tan: f64, reference: f64 },
161 Sphere {
163 frame: ogeom_math::Frame,
164 centre: Point,
165 radius: f64,
166 },
167 Integrated,
169}
170
171impl SurfaceSheet {
172 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 #[must_use]
380 pub fn flattening_error(&self) -> f64 {
381 self.flat.as_ref().map_or(0.0, |f| f.error)
382 }
383
384 #[must_use]
387 pub const fn surface(&self) -> &SurfaceGeometry {
388 &self.surface
389 }
390
391 pub fn chart_point(&self, p: Point2) -> OgeomResult<Point2> {
401 self.landed(p, self.tol.confusion())
402 }
403
404 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 if miss <= allowed || h <= tol.confusion() {
1010 state = both;
1011 for (k, value) in state.iter_mut().enumerate() {
1013 *value += (both[k] - whole[k]) / 15.0;
1014 }
1015 s += h;
1016 }
1017 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 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 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 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
1108fn 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
1134struct Unwrapped {
1141 periods: [Option<f64>; 2],
1142 ts: Vec<f64>,
1143 uvs: Vec<Point2>,
1144}
1145
1146impl Unwrapped {
1147 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 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
1227fn 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
1240fn 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 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
1272fn 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
1281fn 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
1300fn 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 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 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}