1use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
34use ogeom_geom::{BSpline2d, BSplineCurve, Surface, SurfaceGeometry};
35use ogeom_math::Point2;
36
37use crate::march::Traced;
38
39#[derive(Debug, Clone, PartialEq)]
41pub struct IntersectionCurve {
42 pub curve: BSplineCurve,
44 pub on_a: BSpline2d,
46 pub on_b: BSpline2d,
48 pub fit_error: f64,
54 pub met: bool,
56 pub closed: bool,
58}
59
60pub fn approximate_branch(
67 a: &SurfaceGeometry,
68 b: &SurfaceGeometry,
69 branch: &Traced,
70 tolerance: f64,
71 tol: Tolerances,
72) -> OgeomResult<IntersectionCurve> {
73 if branch.points.len() < 2 {
74 ogeom_bail!(
75 Construction,
76 "a branch of {} points is not a curve",
77 branch.points.len()
78 );
79 }
80
81 let mut points: Vec<ogeom_math::Point> = Vec::with_capacity(branch.points.len());
87 let mut kept_a = Vec::with_capacity(branch.on_a.len());
88 let mut kept_b = Vec::with_capacity(branch.on_b.len());
89 let agrees = |i: usize, p: &ogeom_math::Point| -> bool {
95 let limit = tolerance.max(tol.confusion());
96 let (ua, va) = branch.on_a[i];
97 let (ub, vb) = branch.on_b[i];
98 a.point_at(ua, va, tol)
99 .is_ok_and(|q| q.distance(*p) <= limit)
100 && b.point_at(ub, vb, tol)
101 .is_ok_and(|q| q.distance(*p) <= limit)
102 };
103 for (i, p) in branch.points.iter().enumerate() {
104 let end = i == 0 || i + 1 == branch.points.len();
105 if let Some(last) = points.last()
106 && last.distance(*p) <= tol.confusion() * 10.0
107 && i + 1 != branch.points.len()
108 {
109 continue;
110 }
111 if !end && !agrees(i, p) {
112 continue;
113 }
114 points.push(*p);
115 kept_a.push(branch.on_a[i]);
116 kept_b.push(branch.on_b[i]);
117 }
118 if points.len() < 2 {
119 ogeom_bail!(Construction, "a branch of coincident points is not a curve");
120 }
121
122 let unwrapped_a = unwrap_periodic(a, &kept_a, tol);
130 let unwrapped_b = unwrap_periodic(b, &kept_b, tol);
131 let winds_periodically = |surface: &SurfaceGeometry, image: &[Point2]| {
142 let (first, last) = (image[0], image[image.len() - 1]);
143 ((last.x - first.x).abs() <= tol.parametric() || surface.is_periodic_u())
144 && ((last.y - first.y).abs() <= tol.parametric() || surface.is_periodic_v())
145 };
146 let (space, on_a, on_b) = if branch.closed() {
147 let closed = ogeom_geom::fit::fit_points_joint_closed(
148 &points,
149 &unwrapped_a,
150 &unwrapped_b,
151 3,
152 tolerance,
153 tol,
154 )?;
155 if !closed.0.met
156 && winds_periodically(a, &unwrapped_a)
157 && winds_periodically(b, &unwrapped_b)
158 {
159 let winding = ogeom_geom::fit::fit_points_joint_winding(
160 &points,
161 &unwrapped_a,
162 &unwrapped_b,
163 3,
164 tolerance,
165 tol,
166 )?;
167 if winding.0.error < closed.0.error {
168 winding
169 } else {
170 closed
171 }
172 } else {
173 closed
174 }
175 } else {
176 ogeom_geom::fit::fit_points_joint(&points, &unwrapped_a, &unwrapped_b, 3, tolerance, tol)?
177 };
178
179 let lifted =
184 lift_error(a, &on_a, &space.curve, tol).max(lift_error(b, &on_b, &space.curve, tol));
185 let fit_error = space
186 .error
187 .max(space_error(a, &(on_a.clone(), space.met, space.error), tol))
188 .max(space_error(b, &(on_b.clone(), space.met, space.error), tol))
189 .max(lifted);
190 Ok(IntersectionCurve {
191 fit_error,
192 met: space.met,
193 curve: space.curve,
194 on_a,
195 on_b,
196 closed: branch.closed(),
197 })
198}
199
200fn lift_error(
204 surface: &SurfaceGeometry,
205 pcurve: &BSpline2d,
206 curve: &BSplineCurve,
207 tol: Tolerances,
208) -> f64 {
209 use ogeom_geom::{Curve2d as _, Curve3d as _};
210 let (lo, hi) = curve.knots().domain();
211 let spans = curve.knots().distinct().len().saturating_sub(1).max(1);
212 let stations = (4 * spans).max(200);
213 let mut worst = 0.0_f64;
214 for k in 0..=stations {
215 #[allow(clippy::cast_precision_loss)]
216 let t = lo + (hi - lo) * k as f64 / stations as f64;
217 let (Ok(on), Ok(at)) = (curve.point_at(t, tol), pcurve.point_at(t, tol)) else {
218 continue;
219 };
220 let Ok(lifted) = surface.point_at(at.x, at.y, tol) else {
221 continue;
222 };
223 worst = worst.max(lifted.distance(on));
224 }
225 worst
226}
227
228fn space_error(surface: &SurfaceGeometry, fitted: &(BSpline2d, bool, f64), tol: Tolerances) -> f64 {
238 use ogeom_geom::Curve2d;
239 let (pcurve, _, parameter_error) = fitted;
240 let (lo, hi) = pcurve.domain();
243 let mut worst = 0.0_f64;
244 for i in 0..=16 {
245 #[allow(clippy::cast_precision_loss)]
246 let u = lo + (hi - lo) * f64::from(i) / 16.0;
247 let Ok(at) = pcurve.point_at(u, tol) else {
248 continue;
249 };
250 let Ok((du, dv)) = surface.d1_at(at.x, at.y, tol) else {
251 continue;
252 };
253 let stretch = du.magnitude().max(dv.magnitude());
254 worst = worst.max(parameter_error * stretch);
255 }
256 worst
257}
258
259fn unwrap_periodic(
266 surface: &SurfaceGeometry,
267 samples: &[(f64, f64)],
268 tol: Tolerances,
269) -> Vec<Point2> {
270 let ((ua, ub), (va, vb)) = surface.domain();
271 let u_period = if surface.is_periodic_u() || surface.is_closed_u(tol) {
278 Some(ub - ua)
279 } else {
280 None
281 };
282 let v_period = if surface.is_periodic_v() || surface.is_closed_v(tol) {
283 Some(vb - va)
284 } else {
285 None
286 };
287 let fold = |previous: f64, next: f64, period: Option<f64>| match period {
288 None => next,
289 Some(period) => {
290 let mut candidate = next;
291 while candidate - previous > period * 0.5 {
292 candidate -= period;
293 }
294 while previous - candidate > period * 0.5 {
295 candidate += period;
296 }
297 candidate
298 }
299 };
300
301 let mut out = Vec::with_capacity(samples.len());
302 let mut at = Point2::new(samples[0].0, samples[0].1);
303 out.push(at);
304 for sample in &samples[1..] {
305 at = Point2::new(
306 fold(at.x, sample.0, u_period),
307 fold(at.y, sample.1, v_period),
308 );
309 out.push(at);
310 }
311 out
312}
313
314#[cfg(test)]
315#[allow(clippy::unwrap_used)]
316mod tests {
317 use super::*;
318 use crate::march::{Marching, branches};
319 use ogeom_geom::{Curve2d, Curve3d, CylinderSurface, PlaneSurface, SphereSurface};
320 use ogeom_math::{Cylinder, Direction, Frame, Plane, Point, Sphere, Vector};
321
322 const T: Tolerances = Tolerances::millimetres();
323
324 fn sphere(radius: f64) -> SurfaceGeometry {
325 SphereSurface::new(Sphere::centred(Point::ORIGIN, radius, T).unwrap()).into()
326 }
327
328 fn cylinder(radius: f64) -> SurfaceGeometry {
329 CylinderSurface::new(Cylinder::new(Frame::WORLD, radius, T).unwrap(), (-4.0, 4.0))
330 .unwrap()
331 .into()
332 }
333
334 fn plane(origin: Point, normal: Vector) -> SurfaceGeometry {
335 PlaneSurface::over(
336 Plane::through(origin, Direction::new(normal, T).unwrap()),
337 (-6.0, 6.0),
338 (-6.0, 6.0),
339 )
340 .unwrap()
341 .into()
342 }
343
344 fn options() -> Marching {
345 Marching {
346 chord: 1e-5,
347 ..Marching::default()
348 }
349 }
350
351 fn fitted_deviation(a: &SurfaceGeometry, b: &SurfaceGeometry, curve: &BSplineCurve) -> f64 {
357 let off = |surface: &SurfaceGeometry, p: Point| match surface {
358 SurfaceGeometry::Plane(x) => x.plane().distance_to(p),
359 SurfaceGeometry::Sphere(x) => x.sphere().distance_to(p),
360 SurfaceGeometry::Cylinder(x) => x.cylinder().distance_to(p),
361 _ => 0.0,
362 };
363 let (lo, hi) = curve.knots().domain();
364 let mut worst = 0.0_f64;
365 for i in 0..=800 {
366 #[allow(clippy::cast_precision_loss)]
367 let u = lo + (hi - lo) * f64::from(i) / 800.0;
368 if let Ok(p) = curve.point_at(u, T) {
369 worst = worst.max(off(a, p).abs().max(off(b, p).abs()));
370 }
371 }
372 worst
373 }
374
375 #[test]
376 fn a_fitted_branch_lies_on_both_surfaces_to_the_stated_total() {
377 let a = sphere(3.0);
381 let b = cylinder(1.5);
382 let found = branches(&a, &b, options(), T).unwrap();
383 assert_eq!(found.len(), 2);
384
385 for branch in &found {
386 let fitted = approximate_branch(&a, &b, branch, 1e-4, T).unwrap();
387 assert!(fitted.met, "fit error {:e}", fitted.fit_error);
388 assert!(fitted.closed);
389 let off = fitted_deviation(&a, &b, &fitted.curve);
390 assert!(
391 off <= 1e-4 + 1e-5,
392 "the fitted curve is {off:e} off the surfaces"
393 );
394 assert!(
396 fitted.curve.control_points().len() * 4 < branch.points.len(),
397 "{} control points for {} samples",
398 fitted.curve.control_points().len(),
399 branch.points.len()
400 );
401 }
402 }
403
404 #[test]
405 fn the_pcurves_lift_back_onto_the_curve() {
406 let a = sphere(3.0);
410 let b = cylinder(1.5);
411 let found = branches(&a, &b, options(), T).unwrap();
412 let branch = &found[0];
413 let fitted = approximate_branch(&a, &b, branch, 1e-4, T).unwrap();
414
415 for (surface, pcurve) in [(&a, &fitted.on_a), (&b, &fitted.on_b)] {
416 let (lo, hi) = pcurve.domain();
417 for i in 0..=200 {
418 #[allow(clippy::cast_precision_loss)]
419 let u = lo + (hi - lo) * f64::from(i) / 200.0;
420 let at = pcurve.point_at(u, T).unwrap();
421 let lifted = surface.point_at(at.x, at.y, T).unwrap();
422 let off = match (surface as &SurfaceGeometry, &a, &b) {
426 _ if core::ptr::eq(surface, &a) => match &b {
427 SurfaceGeometry::Cylinder(c) => c.cylinder().distance_to(lifted),
428 _ => 0.0,
429 },
430 _ => match &a {
431 SurfaceGeometry::Sphere(s) => s.sphere().distance_to(lifted),
432 _ => 0.0,
433 },
434 };
435 assert!(
436 off.abs() < 5e-4,
437 "a lifted pcurve point is {off:e} off the intersection"
438 );
439 }
440 }
441 }
442
443 #[test]
444 fn a_branch_across_the_seam_gets_a_continuous_pcurve() {
445 let a = cylinder(2.0);
450 let b = plane(Point::ORIGIN, Vector::new(0.0, 0.4, 1.0));
451 let found = branches(&a, &b, options(), T).unwrap();
452 assert_eq!(found.len(), 1, "an oblique plane cuts one ellipse");
453 let fitted = approximate_branch(&a, &b, &found[0], 1e-4, T).unwrap();
454
455 let (lo, hi) = fitted.on_a.domain();
458 let mut previous = fitted.on_a.point_at(lo, T).unwrap();
459 for i in 1..=400 {
460 #[allow(clippy::cast_precision_loss)]
461 let u = lo + (hi - lo) * f64::from(i) / 400.0;
462 let at = fitted.on_a.point_at(u, T).unwrap();
463 assert!(
464 (at.x - previous.x).abs() < 1.0,
465 "the pcurve tears at the seam: {} to {}",
466 previous.x,
467 at.x
468 );
469 previous = at;
470 }
471 }
472
473 #[test]
483 fn a_loop_cut_at_a_converted_drum_s_seam_is_closed() {
484 let drum: SurfaceGeometry = cylinder(2.0).to_bspline(T).unwrap().into();
485 assert!(matches!(drum, SurfaceGeometry::BSpline(_)));
486 let cut = plane(Point::new(0.0, 0.0, 1.0), Vector::new(0.0, 0.2, 1.0));
487 let found = branches(&drum, &cut, options(), T).unwrap();
488 assert_eq!(found.len(), 1, "an oblique plane cuts one loop");
489 assert!(found[0].closed(), "the loop closes on the seam");
490 let fitted = approximate_branch(&drum, &cut, &found[0], 1e-4, T).unwrap();
491 assert!(fitted.closed);
492 assert!(
493 fitted.fit_error < 1e-3,
494 "the loop fits as one: {}",
495 fitted.fit_error
496 );
497 let (lo, hi) = fitted.on_a.domain();
498 let mut previous = fitted.on_a.point_at(lo, T).unwrap();
499 for i in 1..=400 {
500 let u = lo + (hi - lo) * f64::from(i) / 400.0;
501 let at = fitted.on_a.point_at(u, T).unwrap();
502 assert!(
503 (at.x - previous.x).abs() < 0.5,
504 "the chart image tears at the seam: {} to {}",
505 previous.x,
506 at.x
507 );
508 previous = at;
509 }
510 }
511
512 #[test]
513 fn what_cannot_be_fitted_is_refused() {
514 let a = sphere(1.0);
515 let b = plane(Point::ORIGIN, Vector::Z);
516 let found = branches(&a, &b, options(), T).unwrap();
517 assert!(approximate_branch(&a, &b, &found[0], 0.0, T).is_err());
518 assert!(approximate_branch(&a, &b, &found[0], -1.0, T).is_err());
519
520 let empty = Traced {
521 points: vec![],
522 on_a: vec![],
523 on_b: vec![],
524 stopped: crate::march::Stopped::Stalled,
525 };
526 assert!(approximate_branch(&a, &b, &empty, 1e-4, T).is_err());
527 }
528}