1use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
19use ogeom_geom::Curve3d as _;
20use ogeom_geom::Surface as _;
21use ogeom_geom::{Curve, PlanarCurve, SurfaceGeometry};
22use ogeom_math::Point;
23
24pub type FittedPcurve = OgeomResult<(PlanarCurve, f64, bool, f64, Option<String>)>;
28
29pub(crate) fn chart_of(surface: &SurfaceGeometry, p: Point) -> Option<ogeom_math::Point2> {
30 let tau = core::f64::consts::TAU;
31 match surface {
32 SurfaceGeometry::Plane(s) => {
33 let l = s.plane().frame().to_local(p);
34 Some(ogeom_math::Point2::new(l.x, l.y))
35 }
36 SurfaceGeometry::Cylinder(s) => {
37 let l = s.cylinder().frame().to_local(p);
38 Some(ogeom_math::Point2::new(l.y.atan2(l.x).rem_euclid(tau), l.z))
39 }
40 SurfaceGeometry::Cone(s) => {
41 let l = s.cone().frame().to_local(p);
42 Some(ogeom_math::Point2::new(l.y.atan2(l.x).rem_euclid(tau), l.z))
43 }
44 SurfaceGeometry::Sphere(s) => {
45 let sphere = s.sphere();
46 let l = sphere.frame().to_local(p);
47 let lat = (l.z / sphere.radius()).clamp(-1.0, 1.0).asin();
48 Some(ogeom_math::Point2::new(l.y.atan2(l.x).rem_euclid(tau), lat))
49 }
50 SurfaceGeometry::Torus(s) => {
51 let torus = s.torus();
52 let l = torus.frame().to_local(p);
53 let u = l.y.atan2(l.x).rem_euclid(tau);
54 let radial = l.x.hypot(l.y) - torus.major_radius();
55 let v = l.z.atan2(radial).rem_euclid(tau);
56 Some(ogeom_math::Point2::new(u, v))
57 }
58 _ => None,
59 }
60}
61
62fn land(
74 surface: &SurfaceGeometry,
75 p: Point,
76 seed: Option<ogeom_math::Point2>,
77 tol: Tolerances,
78) -> OgeomResult<(ogeom_math::Point2, f64)> {
79 if let Some(uv) = chart_of(surface, p) {
80 let lifted = surface.point_at(uv.x, uv.y, tol)?;
81 let off = p.distance(lifted);
82 if off > tol.confusion()
88 && let SurfaceGeometry::Sphere(s) = surface
89 {
90 let l = s.sphere().frame().to_local(p);
91 let exact = ogeom_math::Point2::new(uv.x, l.z.atan2(l.x.hypot(l.y)));
92 let nearer = p.distance(surface.point_at(exact.x, exact.y, tol)?);
93 if nearer < off {
94 return Ok((exact, nearer));
95 }
96 }
97 return Ok((uv, off));
98 }
99 let near = seed.and_then(|luv| {
100 crate::measure::project_on_surface_from(surface, p, (luv.x, luv.y), tol).ok()
101 });
102 if let Some(close) = near.filter(|f| f.distance <= tol.confusion() * 1e5) {
103 return Ok((
104 ogeom_math::Point2::new(close.parameters.0, close.parameters.1),
105 close.distance,
106 ));
107 }
108 let mut projection = crate::measure::project_on_surface(surface, p, 24, tol)?;
109 if projection.distance > tol.confusion() * 1e5 {
110 let denser = crate::measure::project_on_surface(surface, p, 96, tol)?;
114 if denser.distance < projection.distance {
115 projection = denser;
116 }
117 }
118 Ok((
119 ogeom_math::Point2::new(projection.parameters.0, projection.parameters.1),
120 projection.distance,
121 ))
122}
123
124type Landed = (f64, Point, ogeom_math::Point2, f64);
127
128pub const TRIM_CAP: f64 = 1e7;
131
132pub fn fit_projected_pcurve(
142 curve: &Curve,
143 range: (f64, f64),
144 surface: &SurfaceGeometry,
145 tol: Tolerances,
146) -> FittedPcurve {
147 fit_projected_pcurve_capped(curve, range, surface, tol.confusion() * TRIM_CAP, tol)
148}
149
150pub fn fit_projected_pcurve_capped(
167 curve: &Curve,
168 range: (f64, f64),
169 surface: &SurfaceGeometry,
170 cap: f64,
171 tol: Tolerances,
172) -> FittedPcurve {
173 match fit_projected_pcurve_within(curve, range, surface, cap, tol)? {
174 CappedFit::Fitted(fitted) => Ok(fitted),
175 CappedFit::TooFar(worst_off) => ogeom_bail!(
176 Construction,
177 "the edge sits {worst_off:.2e} from the surface it should bound"
178 ),
179 }
180}
181
182#[derive(Debug, Clone)]
184pub enum CappedFit {
185 Fitted((PlanarCurve, f64, bool, f64, Option<String>)),
187 TooFar(f64),
189}
190
191pub fn fit_projected_pcurve_within(
199 curve: &Curve,
200 range: (f64, f64),
201 surface: &SurfaceGeometry,
202 cap: f64,
203 tol: Tolerances,
204) -> OgeomResult<CappedFit> {
205 fit_projected_pcurve_aimed(curve, range, surface, cap, tol.confusion() * 1e2, tol)
206}
207
208pub fn fit_projected_pcurve_aimed(
216 curve: &Curve,
217 range: (f64, f64),
218 surface: &SurfaceGeometry,
219 cap: f64,
220 aim: f64,
221 tol: Tolerances,
222) -> OgeomResult<CappedFit> {
223 const SAMPLES: usize = 96;
224 let mut parameters = Vec::with_capacity(SAMPLES + 1);
225 let mut trace = Vec::with_capacity(SAMPLES + 1);
226 let mut points = Vec::with_capacity(SAMPLES + 1);
227 let mut offs = Vec::with_capacity(SAMPLES + 1);
228 let mut previous: Option<(Point, ogeom_math::Point2)> = None;
229 for i in 0..=SAMPLES {
230 #[allow(clippy::cast_precision_loss)]
231 let t = range.0 + (range.1 - range.0) * i as f64 / SAMPLES as f64;
232 let p = curve.point_at(t, tol)?;
233 let (uv, off) = land(surface, p, previous.map(|(_, luv)| luv), tol)?;
234 previous = Some((p, uv));
235 parameters.push(t);
236 trace.push(uv);
237 points.push(p);
238 offs.push(off);
239 }
240 retry_stalled(surface, &points, &mut trace, &mut offs, tol);
241 let worst_off = offs.iter().copied().fold(0.0_f64, f64::max);
252 if worst_off > cap {
253 return Ok(CappedFit::TooFar(worst_off));
254 }
255 let mut space_run = 0.0;
256 let mut parameter_run = 0.0;
257 for i in 1..trace.len() {
258 space_run += points[i].distance(points[i - 1]);
259 parameter_run += trace[i].distance(trace[i - 1]);
260 }
261 let ((ua, ub), (va, vb)) = surface.domain();
269 let spans = (
270 if surface.is_periodic_u() || surface.is_closed_u(tol) {
271 ub - ua
272 } else {
273 0.0
274 },
275 if surface.is_periodic_v() || surface.is_closed_v(tol) {
276 vb - va
277 } else {
278 0.0
279 },
280 );
281 for i in 1..trace.len() {
282 if spans.0 > 0.0 {
283 while trace[i].x - trace[i - 1].x > spans.0 * 0.5 {
284 trace[i].x -= spans.0;
285 }
286 while trace[i].x - trace[i - 1].x < -spans.0 * 0.5 {
287 trace[i].x += spans.0;
288 }
289 }
290 if spans.1 > 0.0 {
291 while trace[i].y - trace[i - 1].y > spans.1 * 0.5 {
292 trace[i].y -= spans.1;
293 }
294 while trace[i].y - trace[i - 1].y < -spans.1 * 0.5 {
295 trace[i].y += spans.1;
296 }
297 }
298 }
299 slide_into_chart(&mut trace, spans, ((ua, ub), (va, vb)));
300 let (u_span, _) = {
317 let ((ua, ub), (va, vb)) = surface.domain();
318 (ub - ua, vb - va)
319 };
320 let weak: Vec<bool> = trace
321 .iter()
322 .map(|uv| {
323 surface
324 .d1_at(uv.x, uv.y, tol)
325 .is_ok_and(|(du, _)| du.magnitude() * u_span < tol.confusion() * 1e4)
326 })
327 .collect();
328 if weak.iter().all(|w| *w) && !weak.is_empty() {
329 let mut claimed: Vec<f64> = trace.iter().map(|uv| uv.x).collect();
338 claimed.sort_by(f64::total_cmp);
339 let held = claimed[claimed.len() / 2];
340 for uv in &mut trace {
341 uv.x = held;
342 }
343 } else if weak.iter().any(|w| *w) && weak.iter().filter(|w| !**w).count() >= 2 {
344 let strong: Vec<usize> = (0..trace.len()).filter(|&i| !weak[i]).collect();
345 let u_span = if surface.is_periodic_u() {
346 ua.max(ub) - ua.min(ub)
347 } else {
348 f64::INFINITY
349 };
350 for i in 0..trace.len() {
351 if !weak[i] {
352 continue;
353 }
354 let after = strong.iter().position(|&s| s > i);
355 let (a, b) = match after {
356 Some(0) => (strong[0], strong[1]),
357 Some(k) => (strong[k - 1], strong[k]),
358 None => (strong[strong.len() - 2], strong[strong.len() - 1]),
359 };
360 if a < i && i < b && (trace[b].x - trace[a].x).abs() > u_span * 0.25 {
364 continue;
365 }
366 let (ta, tb) = (parameters[a], parameters[b]);
367 let f = if (tb - ta).abs() <= f64::MIN_POSITIVE {
368 0.0
369 } else {
370 (parameters[i] - ta) / (tb - ta)
371 };
372 trace[i].x = trace[a].x + (trace[b].x - trace[a].x) * f;
373 }
374 }
375
376 let scale = if space_run > tol.confusion() {
379 parameter_run / space_run
380 } else {
381 1.0
382 };
383 let target = (aim * scale).max(f64::MIN_POSITIVE);
384 let fit_and_clamp = |parameters: &[f64],
385 trace: &[ogeom_math::Point2]|
386 -> OgeomResult<ogeom_geom::fit::Fitted<ogeom_geom::BSpline2d>> {
387 let fitted = ogeom_geom::fit::fit_points_2d_at(parameters, trace, 3, target, tol)?;
388 let ((wa, wb), (va2, vb2)) = surface.domain();
398 let clamp_u = !(surface.is_periodic_u() || surface.is_closed_u(tol));
399 let clamp_v = !(surface.is_periodic_v() || surface.is_closed_v(tol));
400 if !(clamp_u || clamp_v) {
401 return Ok(fitted);
402 }
403 let mut moved = 0.0_f64;
404 let knots = fitted.curve.knots().clone();
405 let control: Vec<ogeom_math::Point2> = fitted
406 .curve
407 .control_points()
408 .iter()
409 .map(|w| {
410 let p = w.point();
411 let q = ogeom_math::Point2::new(
412 if clamp_u { p.x.clamp(wa, wb) } else { p.x },
413 if clamp_v { p.y.clamp(va2, vb2) } else { p.y },
414 );
415 moved = moved.max(p.distance(q));
416 q
417 })
418 .collect();
419 if moved > 0.0 {
420 Ok(ogeom_geom::fit::Fitted {
421 curve: ogeom_geom::BSpline2d::new(knots, control, tol)?,
422 ..fitted
423 })
424 } else {
425 Ok(fitted)
426 }
427 };
428 let closed_form = points
447 .first()
448 .is_some_and(|p| chart_of(surface, *p).is_some());
449 let landing =
458 |trace: &[ogeom_math::Point2], offs: &[f64], index: usize, tm: f64| -> Option<Landed> {
459 let before = trace[index - 1];
460 let after = trace[index];
461 let p = curve.point_at(tm, tol).ok()?;
462 let (mut uv, off) = land(surface, p, Some(before), tol).ok()?;
463 if spans.0 > 0.0 {
464 while uv.x - before.x > spans.0 * 0.5 {
465 uv.x -= spans.0;
466 }
467 while uv.x - before.x < -spans.0 * 0.5 {
468 uv.x += spans.0;
469 }
470 }
471 if spans.1 > 0.0 {
472 while uv.y - before.y > spans.1 * 0.5 {
473 uv.y -= spans.1;
474 }
475 while uv.y - before.y < -spans.1 * 0.5 {
476 uv.y += spans.1;
477 }
478 }
479 if surface
480 .d1_at(uv.x, uv.y, tol)
481 .is_ok_and(|(du, _)| du.magnitude() * u_span < tol.confusion() * 1e4)
482 {
483 uv.x = 0.5 * (before.x + after.x);
484 }
485 let reach = before.distance(after).max(f64::EPSILON);
486 let mid =
487 ogeom_math::Point2::new(0.5 * (before.x + after.x), 0.5 * (before.y + after.y));
488 let sound = offs[index - 1].max(offs[index]).max(tol.confusion() * 1e5);
489 (off <= 2.0 * sound && mid.distance(uv) <= reach).then_some((tm, p, uv, off))
490 };
491 let deviation = |fitted: &ogeom_geom::fit::Fitted<ogeom_geom::BSpline2d>,
492 parameters: &[f64],
493 trace: &[ogeom_math::Point2],
494 offs: &[f64]|
495 -> (f64, f64, Vec<Landed>) {
496 let mut error = 0.0_f64;
497 let mut between_all = 0.0_f64;
498 let mut more = Vec::new();
499 let mut landed_middles = Vec::new();
500 let mut left = false;
501 let landing = |index: usize, tm: f64| landing(trace, offs, index, tm);
502 for (index, t) in parameters.iter().enumerate() {
503 let Ok(at) = ogeom_geom::Curve2d::point_at(&fitted.curve, *t, tol) else {
504 continue;
505 };
506 let (Ok(fitted_at), Ok(traced_at)) = (
507 surface.point_at(at.x, at.y, tol),
508 surface.point_at(trace[index].x, trace[index].y, tol),
509 ) else {
510 continue;
511 };
512 error = error.max(fitted_at.distance(traced_at));
513 if index == 0 {
514 continue;
515 }
516 let (ta, tb) = (parameters[index - 1], *t);
525 let tm = 0.5 * (ta + tb);
526 let quarters = [0.5 * (ta + tm), tm, 0.5 * (tm + tb)];
527 let probes: &[f64] = if closed_form {
528 &quarters
529 } else {
530 &quarters[1..2]
531 };
532 for &probe in probes {
533 let Ok(at) = ogeom_geom::Curve2d::point_at(&fitted.curve, probe, tol) else {
534 continue;
535 };
536 let Ok(fitted_at) = surface.point_at(at.x, at.y, tol) else {
537 continue;
538 };
539 if !closed_form {
540 let Ok(p) = curve.point_at(probe, tol) else {
548 continue;
549 };
550 let slop = offs[index - 1].max(offs[index]);
551 let rough = fitted_at.distance(p);
552 if rough <= tol.confusion() * 1e4 + slop {
553 between_all = between_all.max((rough - slop).max(0.0));
554 continue;
555 }
556 }
557 let Some(landed) = landing(index, probe) else {
558 continue;
559 };
560 let Ok(traced_at) = surface.point_at(landed.2.x, landed.2.y, tol) else {
561 continue;
562 };
563 let between = fitted_at.distance(traced_at);
564 between_all = between_all.max(between);
565 if between > tol.confusion() * 1e4 {
566 left = true;
567 }
568 if probe == tm {
569 landed_middles.push(landed);
570 }
571 }
572 }
573 if left {
577 more = landed_middles;
578 if !closed_form {
579 more = (1..parameters.len())
580 .filter_map(|index| {
581 landing(index, 0.5 * (parameters[index - 1] + parameters[index]))
582 })
583 .collect();
584 }
585 }
586 (error, between_all, more)
587 };
588 const DENSIFY: usize = 6;
597 const MOST: usize = 512;
598 let mut fitted = fit_and_clamp(¶meters, &trace)?;
599 #[allow(
600 clippy::type_complexity,
601 reason = "a fit, its two misses and its sample count"
602 )]
603 let mut best: Option<(
604 ogeom_geom::fit::Fitted<ogeom_geom::BSpline2d>,
605 f64,
606 f64,
607 usize,
608 )> = None;
609 let mut round = 0;
610 loop {
611 let (at_samples, between, more) = deviation(&fitted, ¶meters, &trace, &offs);
612 if best
615 .as_ref()
616 .is_none_or(|(_, a, b, _)| at_samples.max(between) < a.max(*b))
617 {
618 best = Some((fitted.clone(), at_samples, between, parameters.len()));
619 }
620 if more.is_empty() || round == DENSIFY || parameters.len() >= MOST {
621 break;
622 }
623 for (tm, p, uv, off) in more {
624 let k = parameters.partition_point(|&t| t < tm);
625 parameters.insert(k, tm);
626 trace.insert(k, uv);
627 points.insert(k, p);
628 offs.insert(k, off);
629 }
630 fitted = fit_and_clamp(¶meters, &trace)?;
631 round += 1;
632 }
633 let (fitted, at_samples, between, own) =
634 best.unwrap_or((fitted, f64::INFINITY, f64::INFINITY, 0));
635 let between = between.max(climbed_miss(
638 &fitted.curve,
639 (¶meters, own),
640 &trace,
641 &offs,
642 closed_form,
643 |t| curve.point_at(t, tol).ok(),
644 surface,
645 tol,
646 ));
647 let worst_off = offs.iter().copied().fold(worst_off, f64::max);
648 let error = at_samples.max(between);
651 let met = at_samples <= tol.confusion() * 1e2 && between <= tol.confusion() * 1e4;
652 let slop = (worst_off > tol.confusion() * 1e3).then(|| {
653 format!(
654 "an edge sits up to {worst_off:.2e} from the surface it \
655 bounds; the file's own slop, carried into the chart"
656 )
657 });
658 Ok(CappedFit::Fitted((
659 fitted.curve.into(),
660 error,
661 met,
662 worst_off,
663 slop,
664 )))
665}
666
667#[allow(
682 clippy::too_many_arguments,
683 reason = "the fit's context, each piece read"
684)]
685fn climbed_miss(
686 pcurve: &ogeom_geom::BSpline2d,
687 (parameters, own): (&[f64], usize),
688 trace: &[ogeom_math::Point2],
689 offs: &[f64],
690 closed_form: bool,
691 curve_at: impl Fn(f64) -> Option<Point>,
692 surface: &SurfaceGeometry,
693 tol: Tolerances,
694) -> f64 {
695 const PEAKS: usize = 4;
696 const NEAR_PEAK: f64 = 0.8;
697 let ratio = (5.0_f64.sqrt() - 1.0) / 2.0;
698 let lifted = |t: f64| -> Option<Point> {
699 let at = ogeom_geom::Curve2d::point_at(pcurve, t, tol).ok()?;
700 surface.point_at(at.x, at.y, tol).ok()
701 };
702 let reading = |t: f64| -> f64 {
703 let on = if closed_form {
704 curve_at(t)
705 .and_then(|p| chart_of(surface, p))
706 .and_then(|uv| surface.point_at(uv.x, uv.y, tol).ok())
707 } else {
708 curve_at(t)
709 };
710 match (lifted(t), on) {
711 (Some(fit), Some(on)) => fit.distance(on),
712 _ => 0.0,
713 }
714 };
715 let mut widest = 0.0_f64;
716 if parameters.len() != own {
719 for (&t, uv) in parameters.iter().zip(trace) {
720 if let (Some(fit), Ok(traced)) = (lifted(t), surface.point_at(uv.x, uv.y, tol)) {
721 widest = widest.max(fit.distance(traced));
722 }
723 }
724 }
725 let mut readings: Vec<(f64, usize, f64, f64)> = (1..parameters.len())
728 .map(|index| {
729 let (a, b) = (parameters[index - 1], parameters[index]);
730 let quarter = (b - a) / 4.0;
731 (1..=3)
732 .map(|k| {
733 let t = quarter.mul_add(f64::from(k), a);
734 (reading(t), index, t, quarter)
735 })
736 .fold((f64::NEG_INFINITY, index, a, quarter), |held, r| {
737 if r.0 > held.0 { r } else { held }
738 })
739 })
740 .collect();
741 readings.sort_by(|a, b| b.0.total_cmp(&a.0));
742 let tallest = readings.first().map_or(0.0, |r| r.0);
743 for &(height, index, at, quarter) in readings
744 .iter()
745 .take_while(|r| r.0 > 0.0 && r.0 >= tallest * NEAR_PEAK)
746 .take(PEAKS)
747 {
748 let (lo, hi) = (parameters[index - 1], parameters[index]);
749 let (mut a, mut b) = ((at - quarter).max(lo), (at + quarter).min(hi));
750 let (mut c, mut d) = (b - (b - a) * ratio, a + (b - a) * ratio);
751 let (mut rc, mut rd) = (reading(c), reading(d));
752 let mut top = height.max(rc).max(rd);
753 for _ in 0..20 {
754 if rc > rd {
755 (b, d, rd) = (d, c, rc);
756 c = b - (b - a) * ratio;
757 rc = reading(c);
758 } else {
759 (a, c, rc) = (c, d, rd);
760 d = a + (b - a) * ratio;
761 rd = reading(d);
762 }
763 top = top.max(rc).max(rd);
764 }
765 let slop = if closed_form {
766 0.0
767 } else {
768 offs[index - 1].max(offs[index])
769 };
770 widest = widest.max(top + slop);
771 }
772 widest
773}
774
775pub fn lifted_gap(
804 (curve, range): (&Curve, (f64, f64)),
805 (pcurve, pcurve_range): (&PlanarCurve, (f64, f64)),
806 surface: &SurfaceGeometry,
807 paced: bool,
808 tol: Tolerances,
809) -> OgeomResult<f64> {
810 use ogeom_geom::Curve2d as _;
811 const SAMPLES: u32 = 256;
813 const PEAKS: usize = 8;
819 const NEAR_PEAK: f64 = 0.7;
820 let ratio = (5.0_f64.sqrt() - 1.0) / 2.0;
821 let n = f64::from(SAMPLES);
822 let step = (range.1 - range.0) / n;
823 let (lo, hi) = (range.0.min(range.1), range.0.max(range.1));
824 let paced_at = |f: f64| -> OgeomResult<Option<(Point, f64)>> {
827 let uv = pcurve.point_at(
828 (pcurve_range.1 - pcurve_range.0).mul_add(f, pcurve_range.0),
829 tol,
830 )?;
831 let Ok(lifted) = surface.point_at(uv.x, uv.y, tol) else {
832 return Ok(None);
833 };
834 let t = (range.1 - range.0).mul_add(f, range.0);
835 Ok(Some((lifted, curve.point_at(t, tol)?.distance(lifted))))
836 };
837 let across_at = |f: f64, (lifted, gap): (Point, f64)| -> OgeomResult<f64> {
840 if paced {
841 return Ok(gap);
842 }
843 let t = (range.1 - range.0).mul_add(f, range.0);
844 let tangent = curve.d1_at(t, tol)?;
845 let length = tangent.magnitude();
846 if length <= f64::MIN_POSITIVE {
847 return Ok(gap);
848 }
849 let along = (lifted - curve.point_at(t, tol)?).dot(tangent) / length;
850 Ok(along.mul_add(-along, gap * gap).max(0.0).sqrt())
851 };
852 let gap_at = |f: f64, (lifted, gap): (Point, f64)| -> OgeomResult<f64> {
855 if paced || gap <= tol.confusion() {
856 return Ok(gap);
857 }
858 let at = |s: f64| -> OgeomResult<f64> { Ok(curve.point_at(s, tol)?.distance(lifted)) };
859 let t = (range.1 - range.0).mul_add(f, range.0);
860 let tangent = curve.d1_at(t, tol)?;
864 let along = (lifted - curve.point_at(t, tol)?).dot(tangent).abs();
865 if along <= gap * tangent.magnitude() * 1e-2 {
866 return Ok(gap);
867 }
868 let (mut a, mut b) = ((t - step.abs()).max(lo), (t + step.abs()).min(hi));
869 for _ in 0..24 {
870 let (c, d) = (b - (b - a) * ratio, a + (b - a) * ratio);
871 if at(c)? < at(d)? {
872 b = d;
873 } else {
874 a = c;
875 }
876 }
877 Ok(gap.min(at(f64::midpoint(a, b))?))
878 };
879 let samples = (0..=SAMPLES)
880 .map(|i| paced_at(f64::from(i) / n))
881 .collect::<OgeomResult<Vec<_>>>()?;
882 let read = |i: usize| samples[i].map_or(0.0, |(_, gap)| gap);
883 let across = (0..=SAMPLES)
884 .zip(&samples)
885 .map(|(i, sample)| sample.map_or(Ok(0.0), |sample| across_at(f64::from(i) / n, sample)))
886 .collect::<OgeomResult<Vec<_>>>()?;
887
888 let mut order: Vec<usize> = (0..samples.len()).collect();
891 order.sort_by(|&a, &b| read(b).total_cmp(&read(a)));
892 let mut widest = 0.0_f64;
893 for &i in &order {
894 let Some(sample) = samples[i] else { continue };
895 if sample.1 <= widest {
896 break;
897 }
898 #[allow(clippy::cast_precision_loss)]
899 let f = i as f64 / n;
900 widest = widest.max(gap_at(f, sample)?);
901 }
902
903 let mut by_height: Vec<usize> = (0..samples.len()).collect();
905 by_height.sort_by(|&a, &b| across[b].total_cmp(&across[a]));
906 let tallest = across[by_height[0]];
907 let mut peaks: Vec<usize> = by_height
908 .into_iter()
909 .filter(|&i| {
910 across[i] > tol.confusion()
911 && (i == 0 || across[i] >= across[i - 1])
912 && (i + 1 == samples.len() || across[i] >= across[i + 1])
913 })
914 .take(PEAKS)
915 .enumerate()
916 .take_while(|&(k, i)| k < 3 || across[i] >= tallest * NEAR_PEAK)
917 .map(|(_, i)| i)
918 .collect();
919 peaks.extend([0, samples.len() - 1]);
920 for i in peaks {
921 #[allow(clippy::cast_precision_loss)]
922 let (mut a, mut b) = (
923 i.saturating_sub(1) as f64 / n,
924 (i + 1).min(samples.len() - 1) as f64 / n,
925 );
926 let height = |f: f64| -> OgeomResult<f64> {
929 paced_at(f)?.map_or(Ok(0.0), |sample| across_at(f, sample))
930 };
931 let (mut c, mut d) = (b - (b - a) * ratio, a + (b - a) * ratio);
932 let (mut hc, mut hd) = (height(c)?, height(d)?);
933 for _ in 0..20 {
934 if hc > hd {
935 (b, d, hd) = (d, c, hc);
936 c = b - (b - a) * ratio;
937 hc = height(c)?;
938 } else {
939 (a, c, hc) = (c, d, hd);
940 d = a + (b - a) * ratio;
941 hd = height(d)?;
942 }
943 }
944 let top = if hc > hd { c } else { d };
945 if let Some(sample) = paced_at(top)? {
946 widest = widest.max(gap_at(top, sample)?);
947 }
948 }
949 Ok(widest)
950}
951
952pub fn lifted_gap_past_confusion(
968 curve: (&Curve, (f64, f64)),
969 pcurve: (&PlanarCurve, (f64, f64)),
970 surface: &SurfaceGeometry,
971 paced: bool,
972 tol: Tolerances,
973) -> OgeomResult<f64> {
974 let stated = tol.confusion();
975 let Some((gap, margin)) = crate::pcurve_gap::closed_gap(curve, pcurve, surface, stated, tol)
976 else {
977 return lifted_gap(curve, pcurve, surface, paced, tol);
978 };
979 if cfg!(debug_assertions) {
980 let sampled = lifted_gap(curve, pcurve, surface, paced, tol);
981 assert!(
982 sampled
983 .as_ref()
984 .is_ok_and(|s| (gap - s).abs() <= margin && *s <= stated),
985 "closed-form lifted gap {gap:e} (within {margin:e}) against sampled {sampled:?}, \
986 stated {stated:e}"
987 );
988 }
989 Ok(gap)
990}
991
992fn retry_stalled(
1005 surface: &SurfaceGeometry,
1006 points: &[Point],
1007 trace: &mut [ogeom_math::Point2],
1008 offs: &mut [f64],
1009 tol: Tolerances,
1010) {
1011 let sound = tol.confusion() * 1e5;
1012 if offs.iter().all(|off| *off <= sound) {
1013 return;
1014 }
1015 for backwards in [true, false] {
1016 let order: Vec<usize> = if backwards {
1017 (0..offs.len()).rev().collect()
1018 } else {
1019 (0..offs.len()).collect()
1020 };
1021 for i in order {
1022 if offs[i] <= sound {
1023 continue;
1024 }
1025 let Some(j) = (if backwards {
1026 i.checked_add(1)
1027 } else {
1028 i.checked_sub(1)
1029 }) else {
1030 continue;
1031 };
1032 if offs.get(j).is_none_or(|off| *off > sound) {
1033 continue;
1034 }
1035 let seed = (trace[j].x, trace[j].y);
1036 if let Ok(found) =
1037 crate::measure::project_on_surface_from(surface, points[i], seed, tol)
1038 && found.distance < offs[i]
1039 {
1040 trace[i] = ogeom_math::Point2::new(found.parameters.0, found.parameters.1);
1041 offs[i] = found.distance;
1042 }
1043 }
1044 }
1045}
1046
1047fn slide_into_chart(
1060 trace: &mut [ogeom_math::Point2],
1061 spans: (f64, f64),
1062 domain: ((f64, f64), (f64, f64)),
1063) {
1064 let ((ua, ub), (va, vb)) = domain;
1065 for (across, span, lo, hi) in [(true, spans.0, ua, ub), (false, spans.1, va, vb)] {
1066 if span <= 0.0 {
1067 continue;
1068 }
1069 let read = |uv: &ogeom_math::Point2| if across { uv.x } else { uv.y };
1070 let (mut least, mut most) = (f64::INFINITY, f64::NEG_INFINITY);
1071 for uv in trace.iter() {
1072 least = least.min(read(uv));
1073 most = most.max(read(uv));
1074 }
1075 if !(least.is_finite() && most.is_finite()) || most - least > span {
1076 continue;
1077 }
1078 let turns = {
1079 let up = ((lo - least) / span).ceil();
1080 let down = ((hi - most) / span).floor();
1081 if up <= down {
1082 up.max(down.min(0.0))
1084 } else {
1085 (f64::midpoint(lo, hi) - f64::midpoint(least, most)) / span
1086 }
1087 };
1088 let turns = if turns.is_finite() {
1089 turns.round()
1090 } else {
1091 0.0
1092 };
1093 if turns == 0.0 {
1094 continue;
1095 }
1096 for uv in trace.iter_mut() {
1097 if across {
1098 uv.x += turns * span;
1099 } else {
1100 uv.y += turns * span;
1101 }
1102 }
1103 }
1104}
1105
1106#[cfg(test)]
1107mod tests {
1108 #![allow(clippy::unwrap_used, reason = "test code")]
1109 use ogeom_math::Point2;
1110
1111 #[test]
1117 fn a_trace_that_walked_off_its_chart_is_slid_back() {
1118 let pi = core::f64::consts::PI;
1119 let chart = ((0.0, 1.0), (-pi, pi));
1120 let turn = (0.0, 2.0 * pi);
1121
1122 let mut trace = vec![
1124 Point2::new(0.5, -2.5 * pi),
1125 Point2::new(0.5, -2.0 * pi),
1126 Point2::new(0.5, -1.5 * pi),
1127 ];
1128 super::slide_into_chart(&mut trace, turn, chart);
1129 assert!(
1130 trace.iter().all(|at| at.y >= -pi && at.y <= pi),
1131 "slid into the chart: {trace:?}"
1132 );
1133 for pair in trace.windows(2) {
1134 assert!(
1135 (pair[1].y - pair[0].y - 0.5 * pi).abs() < 1e-12,
1136 "and rigidly"
1137 );
1138 }
1139
1140 let mut held = vec![Point2::new(0.5, -1.0), Point2::new(0.5, 1.0)];
1142 let was = held.clone();
1143 super::slide_into_chart(&mut held, turn, chart);
1144 assert_eq!(held, was);
1145
1146 let mut wide = vec![Point2::new(0.5, -4.0 * pi), Point2::new(0.5, 0.0)];
1148 let was = wide.clone();
1149 super::slide_into_chart(&mut wide, turn, chart);
1150 assert_eq!(wide, was);
1151
1152 let mut across = vec![Point2::new(9.0, 0.0), Point2::new(9.5, 0.0)];
1154 let was = across.clone();
1155 super::slide_into_chart(&mut across, (0.0, 2.0 * pi), chart);
1156 assert_eq!(across, was);
1157 }
1158
1159 use super::*;
1160 use ogeom_core::Tolerances;
1161 use ogeom_geom::{CircleCurve, CylinderSurface};
1162 use ogeom_math::{Circle, Cylinder, Frame};
1163
1164 const T: Tolerances = Tolerances::millimetres();
1165
1166 #[test]
1175 fn a_sample_stalled_at_a_pole_is_retried_from_its_neighbour() {
1176 use ogeom_geom::{BSplineSurface, Surface as _};
1177 use ogeom_math::{ControlGrid, KnotVector};
1178
1179 let mut control = Vec::new();
1180 for i in 0..4 {
1181 let across = -1.0 + 2.0 * f64::from(i) / 3.0;
1182 for j in 0..4 {
1183 let out = f64::from(j) / 3.0;
1184 control.push(Point::new(out, across * out, (0.5 + across * across) * out));
1185 }
1186 }
1187 let grid = ControlGrid::new(control, 4, 4).unwrap();
1188 let cone: SurfaceGeometry = BSplineSurface::new(
1189 KnotVector::clamped_uniform(3, 4).unwrap(),
1190 KnotVector::clamped_uniform(3, 4).unwrap(),
1191 &grid,
1192 T,
1193 )
1194 .unwrap()
1195 .into();
1196 assert!(
1197 cone.d1_at(0.5, 0.0, T).unwrap().0.magnitude() < 1e-12,
1198 "the v = 0 row is a pole"
1199 );
1200
1201 let at = cone.point_at(1.0, 0.2, T).unwrap();
1202 let stuck = crate::measure::project_on_surface_from(&cone, at, (0.0, 0.0), T).unwrap();
1203 assert!(
1204 stuck.distance > 0.1,
1205 "a projection seeded at the pole stays there: {stuck:?}"
1206 );
1207
1208 let mut trace = vec![
1209 ogeom_math::Point2::new(0.0, 0.0),
1210 ogeom_math::Point2::new(0.0, 0.0),
1211 ogeom_math::Point2::new(1.0, 0.5),
1212 ];
1213 let points = vec![
1214 cone.point_at(1.0, 0.1, T).unwrap(),
1215 at,
1216 cone.point_at(1.0, 0.5, T).unwrap(),
1217 ];
1218 let mut offs = vec![points[0].distance(Point::ORIGIN), stuck.distance, 0.0];
1219 retry_stalled(&cone, &points, &mut trace, &mut offs, T);
1220 for (index, off) in offs.iter().enumerate() {
1221 assert!(
1222 *off < T.confusion(),
1223 "sample {index} found its surface: {off:.3e}"
1224 );
1225 }
1226 assert!(
1227 (trace[1].x - 1.0).abs() < 1e-6 && (trace[1].y - 0.2).abs() < 1e-6,
1228 "and its own parameters: {:?}",
1229 trace[1]
1230 );
1231
1232 let adrift = Point::new(0.0, 0.0, -0.5);
1235 let mut honest = vec![trace[2], ogeom_math::Point2::new(1.0, 0.5)];
1236 let was = honest.clone();
1237 let mut misses = vec![0.0, 0.5];
1238 retry_stalled(&cone, &[points[2], adrift], &mut honest, &mut misses, T);
1239 assert_eq!(honest[1], was[1]);
1240 assert!((misses[1] - 0.5).abs() < 1e-12);
1241 }
1242
1243 #[test]
1253 fn a_direction_is_weak_by_what_crossing_it_moves() {
1254 use ogeom_geom::{BSplineSurface, LineCurve};
1255 use ogeom_math::{ControlGrid, KnotVector, Point};
1256 let mut control = Vec::new();
1257 for i in 0..2 {
1258 for j in 0..2 {
1259 control.push(Point::new(f64::from(i), 20.0 * f64::from(j), 0.0));
1260 }
1261 }
1262 let strip: SurfaceGeometry = BSplineSurface::new(
1263 KnotVector::new(vec![0.0, 0.0, 1.0, 1.0], 1).unwrap(),
1264 KnotVector::new(vec![0.0, 0.0, 0.01, 0.01], 1).unwrap(),
1265 &ControlGrid::new(control, 2, 2).unwrap(),
1266 T,
1267 )
1268 .unwrap()
1269 .into();
1270 let across: Curve =
1272 LineCurve::segment(Point::new(0.0, 10.0, 0.0), Point::new(1.0, 10.0, 0.0), T)
1273 .unwrap()
1274 .into();
1275 let (pcurve, error, _, _, _) =
1276 fit_projected_pcurve(&across, (0.0, 1.0), &strip, T).unwrap();
1277 let a = ogeom_geom::Curve2d::point_at(&pcurve, 0.0, T).unwrap();
1278 let b = ogeom_geom::Curve2d::point_at(&pcurve, 1.0, T).unwrap();
1279 assert!(
1280 (b.x - a.x).abs() > 0.99,
1281 "the edge crosses the whole of u: {a:?} -> {b:?}"
1282 );
1283 assert!(error < 1e-6, "and fits: {error:.2e}");
1284 }
1285
1286 fn flat_patch() -> SurfaceGeometry {
1289 use ogeom_geom::BSplineSurface;
1290 use ogeom_math::{ControlGrid, KnotVector, Point};
1291 let control = [(0.0, -1.0), (0.0, 1.0), (1.0, -1.0), (1.0, 1.0)]
1292 .map(|(x, y)| Point::new(x, y, 0.0))
1293 .to_vec();
1294 BSplineSurface::new(
1295 KnotVector::new(vec![0.0, 0.0, 1.0, 1.0], 1).unwrap(),
1296 KnotVector::new(vec![0.0, 0.0, 1.0, 1.0], 1).unwrap(),
1297 &ControlGrid::new(control, 2, 2).unwrap(),
1298 T,
1299 )
1300 .unwrap()
1301 .into()
1302 }
1303
1304 #[test]
1312 fn a_fit_on_a_patch_states_its_widest_miss_between_the_samples() {
1313 use ogeom_geom::{Curve2d as _, Curve3d as _, Surface as _};
1314 use ogeom_math::Point;
1315 let amplitude = 0.01;
1316 let sine = |t: f64| {
1317 Point::new(
1318 t,
1319 amplitude * (core::f64::consts::TAU * 96.0 * t).sin(),
1320 0.0,
1321 )
1322 };
1323 let ts: Vec<f64> = (0..=3840).map(|i| f64::from(i) / 3840.0).collect();
1324 let points: Vec<Point> = ts.iter().map(|t| sine(*t)).collect();
1325 let fitted = ogeom_geom::fit::fit_points_at(&ts, &points, 3, 1e-8, T).unwrap();
1326 let curve: Curve = fitted.curve.into();
1327 let patch = flat_patch();
1328 let (pcurve, error, met, _, _) =
1329 fit_projected_pcurve(&curve, (0.0, 1.0), &patch, T).unwrap();
1330 let mut worst = 0.0_f64;
1331 for i in 0..=20_000 {
1332 let t = f64::from(i) / 20_000.0;
1333 let uv = pcurve.point_at(t, T).unwrap();
1334 let lifted = patch.point_at(uv.x, uv.y, T).unwrap();
1335 worst = worst.max(lifted.distance(curve.point_at(t, T).unwrap()));
1336 }
1337 assert!(
1338 worst > 0.9 * amplitude,
1339 "the fit misses the crests: {worst:.3e}"
1340 );
1341 assert!(
1342 error >= worst,
1343 "the widest miss is stated: {error:.3e} for {worst:.3e}"
1344 );
1345 assert!(!met, "and the target is not met");
1346 }
1347
1348 #[test]
1356 fn a_pcurve_paced_differently_is_measured_at_its_widest_bump() {
1357 use ogeom_geom::{Curve2d as _, LineCurve};
1358 use ogeom_math::{Point, Point2};
1359 let edge: Curve =
1360 LineCurve::segment(Point::new(0.0, 0.0, 0.0), Point::new(1.0, 0.0, 0.0), T)
1361 .unwrap()
1362 .into();
1363 let patch = flat_patch();
1364 let lead = |t: f64| 0.002 * (core::f64::consts::PI * t).sin();
1367 let across = |t: f64| {
1368 let bump = (core::f64::consts::TAU * 37.3 * t).sin().max(0.0).powi(8);
1369 1e-5 * (1.0 + 0.5 * (-((t - 0.75) / 0.05).powi(2)).exp()) * bump
1370 };
1371 let ts: Vec<f64> = (0..=4000).map(|i| f64::from(i) / 4000.0).collect();
1372 let chart: Vec<Point2> = ts
1373 .iter()
1374 .map(|&t| Point2::new(t + lead(t), 0.5 + 0.5 * across(t)))
1375 .collect();
1376 let pcurve: PlanarCurve = ogeom_geom::fit::fit_points_2d_at(&ts, &chart, 3, 1e-10, T)
1377 .unwrap()
1378 .curve
1379 .into();
1380 let mut truth = 0.0_f64;
1381 for i in 0..=40_000 {
1382 let t = f64::from(i) / 40_000.0;
1383 let uv = pcurve.point_at(t, T).unwrap();
1384 let lifted = patch.point_at(uv.x, uv.y, T).unwrap();
1385 truth = truth.max(lifted.y.abs());
1387 }
1388 let gap = lifted_gap((&edge, (0.0, 1.0)), (&pcurve, (0.0, 1.0)), &patch, false, T).unwrap();
1389 assert!(
1390 gap >= 0.99 * truth && gap <= truth * 1.001 + 1e-12,
1391 "the widest bump, {truth:.4e}, is stated: {gap:.4e}"
1392 );
1393 }
1394
1395 #[test]
1404 fn slop_under_a_millimetre_fits_and_is_reported() {
1405 let wall: SurfaceGeometry =
1406 CylinderSurface::new(Cylinder::new(Frame::WORLD, 10.0, T).unwrap(), (-50.0, 50.0))
1407 .unwrap()
1408 .into();
1409 let rim = |radius: f64| -> Curve {
1410 CircleCurve::new(Circle::new(Frame::WORLD, radius, T).unwrap()).into()
1411 };
1412 let (_, _, _, worst_off, warning) =
1414 fit_projected_pcurve(&rim(10.3), (0.0, core::f64::consts::TAU), &wall, T).unwrap();
1415 assert!(
1416 (worst_off - 0.3).abs() < 1e-6,
1417 "the offset is measured: {worst_off}"
1418 );
1419 assert!(warning.is_some(), "slop this large is worth a warning");
1420 assert!(
1422 fit_projected_pcurve(&rim(13.0), (0.0, core::f64::consts::TAU), &wall, T).is_err(),
1423 "a miss of millimetres still refuses"
1424 );
1425 }
1426
1427 #[test]
1437 fn a_fit_is_held_between_its_samples_where_the_curve_runs_fast() {
1438 use ogeom_geom::{BSplineCurve, Curve, Curve2d, Curve3d, Surface, SurfaceGeometry};
1439 use ogeom_math::{KnotVector, Point};
1440 let radius = 10.0;
1441 let on = |angle: f64, z: f64| Point::new(radius * angle.cos(), radius * angle.sin(), z);
1442 let mut knots = vec![0.0, 0.0, 0.0, 0.0, 0.05];
1443 knots.extend((1..8).map(f64::from));
1444 knots.extend([8.0; 4]);
1445 let control = vec![
1446 on(0.12, 4.0),
1447 on(0.13, 3.7),
1448 on(0.14, 3.4),
1449 on(0.15, 3.1),
1450 on(0.10, 2.5),
1451 on(0.0, 1.5),
1452 on(-0.1, 0.5),
1453 on(-0.2, -0.5),
1454 on(-0.3, -1.5),
1455 on(-0.35, -2.5),
1456 on(-0.38, -3.3),
1457 on(-0.4, -4.0),
1458 ];
1459 let curve: Curve = BSplineCurve::new(KnotVector::new(knots, 3).unwrap(), control, T)
1460 .unwrap()
1461 .into();
1462 let drum: SurfaceGeometry =
1463 CylinderSurface::new(Cylinder::new(Frame::WORLD, radius, T).unwrap(), (-5.0, 5.0))
1464 .unwrap()
1465 .into();
1466 let (pcurve, error, _, _, _) = fit_projected_pcurve(&curve, (0.0, 8.0), &drum, T).unwrap();
1467 let mut worst = 0.0_f64;
1468 for i in 0..=2000 {
1469 let t = 8.0 * f64::from(i) / 2000.0;
1470 let p = curve.point_at(t, T).unwrap();
1471 let uv = pcurve.point_at(t, T).unwrap();
1472 let lifted = drum.point_at(uv.x, uv.y, T).unwrap();
1473 worst = worst.max(lifted.distance(on(p.y.atan2(p.x), p.z)));
1475 }
1476 assert!(
1477 worst <= T.confusion() * 3e4,
1478 "the pcurve leaves the curve by {worst:.3e} between the samples, \
1479 {error:.3e} reported"
1480 );
1481 assert!(
1482 error >= worst,
1483 "and the widest miss between the samples is reported: {error:.3e} for {worst:.3e}"
1484 );
1485 }
1486
1487 #[test]
1492 fn a_gap_past_the_confusion_distance_is_measured_as_sampling_measures_it() {
1493 use ogeom_core::Tolerances;
1494 use ogeom_geom::{Circle2d, CircleCurve, PlaneSurface};
1495 use ogeom_math::{Circle, Circle2, Direction2, Frame, Frame2, Plane, Vector2};
1496 const T: Tolerances = Tolerances::millimetres();
1497 let plane: super::SurfaceGeometry = PlaneSurface::new(Plane::new(Frame::WORLD)).into();
1498 let curve: super::Curve =
1499 CircleCurve::new(Circle::new(Frame::WORLD, 4.0, T).unwrap()).into();
1500 let range = (0.0, core::f64::consts::TAU);
1501 for (shift, past) in [(0.5e-7, false), (1e-6, true)] {
1502 let frame = Frame2::new(
1503 Point2::new(shift, 0.0),
1504 Direction2::new(Vector2::new(1.0, 0.0), T).unwrap(),
1505 );
1506 let image: super::PlanarCurve =
1507 Circle2d::new(Circle2::new(frame, 4.0, T).unwrap()).into();
1508 let sampled =
1509 super::lifted_gap((&curve, range), (&image, range), &plane, false, T).unwrap();
1510 let answered = super::lifted_gap_past_confusion(
1511 (&curve, range),
1512 (&image, range),
1513 &plane,
1514 false,
1515 T,
1516 )
1517 .unwrap();
1518 if past {
1519 assert_eq!(
1520 answered.to_bits(),
1521 sampled.to_bits(),
1522 "{answered:e} {sampled:e}"
1523 );
1524 } else {
1525 assert!(
1527 answered <= T.confusion() && (answered - shift).abs() <= 1e-12,
1528 "{answered:e}"
1529 );
1530 assert!((sampled - shift).abs() <= 1e-12, "{sampled:e}");
1531 }
1532 }
1533 }
1534}