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 for (i, p) in branch.points.iter().enumerate() {
90 if let Some(last) = points.last()
91 && last.distance(*p) <= tol.confusion() * 10.0
92 && i + 1 != branch.points.len()
93 {
94 continue;
95 }
96 points.push(*p);
97 kept_a.push(branch.on_a[i]);
98 kept_b.push(branch.on_b[i]);
99 }
100 if points.len() < 2 {
101 ogeom_bail!(Construction, "a branch of coincident points is not a curve");
102 }
103
104 let unwrapped_a = unwrap_periodic(a, &kept_a, tol);
112 let unwrapped_b = unwrap_periodic(b, &kept_b, tol);
113 let (space, on_a, on_b) = if branch.closed() {
117 ogeom_geom::fit::fit_points_joint_closed(
118 &points,
119 &unwrapped_a,
120 &unwrapped_b,
121 3,
122 tolerance,
123 tol,
124 )?
125 } else {
126 ogeom_geom::fit::fit_points_joint(&points, &unwrapped_a, &unwrapped_b, 3, tolerance, tol)?
127 };
128
129 Ok(IntersectionCurve {
130 fit_error: space
131 .error
132 .max(space_error(a, &(on_a.clone(), space.met, space.error), tol))
133 .max(space_error(b, &(on_b.clone(), space.met, space.error), tol)),
134 met: space.met,
135 curve: space.curve,
136 on_a,
137 on_b,
138 closed: branch.closed(),
139 })
140}
141
142fn space_error(surface: &SurfaceGeometry, fitted: &(BSpline2d, bool, f64), tol: Tolerances) -> f64 {
152 use ogeom_geom::Curve2d;
153 let (pcurve, _, parameter_error) = fitted;
154 let (lo, hi) = pcurve.domain();
157 let mut worst = 0.0_f64;
158 for i in 0..=16 {
159 #[allow(clippy::cast_precision_loss)]
160 let u = lo + (hi - lo) * f64::from(i) / 16.0;
161 let Ok(at) = pcurve.point_at(u, tol) else {
162 continue;
163 };
164 let Ok((du, dv)) = surface.d1_at(at.x, at.y, tol) else {
165 continue;
166 };
167 let stretch = du.magnitude().max(dv.magnitude());
168 worst = worst.max(parameter_error * stretch);
169 }
170 worst
171}
172
173fn unwrap_periodic(
180 surface: &SurfaceGeometry,
181 samples: &[(f64, f64)],
182 tol: Tolerances,
183) -> Vec<Point2> {
184 let ((ua, ub), (va, vb)) = surface.domain();
185 let u_period = if surface.is_periodic_u() || surface.is_closed_u(tol) {
192 Some(ub - ua)
193 } else {
194 None
195 };
196 let v_period = if surface.is_periodic_v() || surface.is_closed_v(tol) {
197 Some(vb - va)
198 } else {
199 None
200 };
201 let fold = |previous: f64, next: f64, period: Option<f64>| match period {
202 None => next,
203 Some(period) => {
204 let mut candidate = next;
205 while candidate - previous > period * 0.5 {
206 candidate -= period;
207 }
208 while previous - candidate > period * 0.5 {
209 candidate += period;
210 }
211 candidate
212 }
213 };
214
215 let mut out = Vec::with_capacity(samples.len());
216 let mut at = Point2::new(samples[0].0, samples[0].1);
217 out.push(at);
218 for sample in &samples[1..] {
219 at = Point2::new(
220 fold(at.x, sample.0, u_period),
221 fold(at.y, sample.1, v_period),
222 );
223 out.push(at);
224 }
225 out
226}
227
228#[cfg(test)]
229#[allow(clippy::unwrap_used)]
230mod tests {
231 use super::*;
232 use crate::march::{Marching, branches};
233 use ogeom_geom::{Curve2d, Curve3d, CylinderSurface, PlaneSurface, SphereSurface};
234 use ogeom_math::{Cylinder, Direction, Frame, Plane, Point, Sphere, Vector};
235
236 const T: Tolerances = Tolerances::millimetres();
237
238 fn sphere(radius: f64) -> SurfaceGeometry {
239 SphereSurface::new(Sphere::centred(Point::ORIGIN, radius, T).unwrap()).into()
240 }
241
242 fn cylinder(radius: f64) -> SurfaceGeometry {
243 CylinderSurface::new(Cylinder::new(Frame::WORLD, radius, T).unwrap(), (-4.0, 4.0))
244 .unwrap()
245 .into()
246 }
247
248 fn plane(origin: Point, normal: Vector) -> SurfaceGeometry {
249 PlaneSurface::over(
250 Plane::through(origin, Direction::new(normal, T).unwrap()),
251 (-6.0, 6.0),
252 (-6.0, 6.0),
253 )
254 .unwrap()
255 .into()
256 }
257
258 fn options() -> Marching {
259 Marching {
260 chord: 1e-5,
261 ..Marching::default()
262 }
263 }
264
265 fn fitted_deviation(a: &SurfaceGeometry, b: &SurfaceGeometry, curve: &BSplineCurve) -> f64 {
271 let off = |surface: &SurfaceGeometry, p: Point| match surface {
272 SurfaceGeometry::Plane(x) => x.plane().distance_to(p),
273 SurfaceGeometry::Sphere(x) => x.sphere().distance_to(p),
274 SurfaceGeometry::Cylinder(x) => x.cylinder().distance_to(p),
275 _ => 0.0,
276 };
277 let (lo, hi) = curve.knots().domain();
278 let mut worst = 0.0_f64;
279 for i in 0..=800 {
280 #[allow(clippy::cast_precision_loss)]
281 let u = lo + (hi - lo) * f64::from(i) / 800.0;
282 if let Ok(p) = curve.point_at(u, T) {
283 worst = worst.max(off(a, p).abs().max(off(b, p).abs()));
284 }
285 }
286 worst
287 }
288
289 #[test]
290 fn a_fitted_branch_lies_on_both_surfaces_to_the_stated_total() {
291 let a = sphere(3.0);
295 let b = cylinder(1.5);
296 let found = branches(&a, &b, options(), T).unwrap();
297 assert_eq!(found.len(), 2);
298
299 for branch in &found {
300 let fitted = approximate_branch(&a, &b, branch, 1e-4, T).unwrap();
301 assert!(fitted.met, "fit error {:e}", fitted.fit_error);
302 assert!(fitted.closed);
303 let off = fitted_deviation(&a, &b, &fitted.curve);
304 assert!(
305 off <= 1e-4 + 1e-5,
306 "the fitted curve is {off:e} off the surfaces"
307 );
308 assert!(
310 fitted.curve.control_points().len() * 4 < branch.points.len(),
311 "{} control points for {} samples",
312 fitted.curve.control_points().len(),
313 branch.points.len()
314 );
315 }
316 }
317
318 #[test]
319 fn the_pcurves_lift_back_onto_the_curve() {
320 let a = sphere(3.0);
324 let b = cylinder(1.5);
325 let found = branches(&a, &b, options(), T).unwrap();
326 let branch = &found[0];
327 let fitted = approximate_branch(&a, &b, branch, 1e-4, T).unwrap();
328
329 for (surface, pcurve) in [(&a, &fitted.on_a), (&b, &fitted.on_b)] {
330 let (lo, hi) = pcurve.domain();
331 for i in 0..=200 {
332 #[allow(clippy::cast_precision_loss)]
333 let u = lo + (hi - lo) * f64::from(i) / 200.0;
334 let at = pcurve.point_at(u, T).unwrap();
335 let lifted = surface.point_at(at.x, at.y, T).unwrap();
336 let off = match (surface as &SurfaceGeometry, &a, &b) {
340 _ if core::ptr::eq(surface, &a) => match &b {
341 SurfaceGeometry::Cylinder(c) => c.cylinder().distance_to(lifted),
342 _ => 0.0,
343 },
344 _ => match &a {
345 SurfaceGeometry::Sphere(s) => s.sphere().distance_to(lifted),
346 _ => 0.0,
347 },
348 };
349 assert!(
350 off.abs() < 5e-4,
351 "a lifted pcurve point is {off:e} off the intersection"
352 );
353 }
354 }
355 }
356
357 #[test]
358 fn a_branch_across_the_seam_gets_a_continuous_pcurve() {
359 let a = cylinder(2.0);
364 let b = plane(Point::ORIGIN, Vector::new(0.0, 0.4, 1.0));
365 let found = branches(&a, &b, options(), T).unwrap();
366 assert_eq!(found.len(), 1, "an oblique plane cuts one ellipse");
367 let fitted = approximate_branch(&a, &b, &found[0], 1e-4, T).unwrap();
368
369 let (lo, hi) = fitted.on_a.domain();
372 let mut previous = fitted.on_a.point_at(lo, T).unwrap();
373 for i in 1..=400 {
374 #[allow(clippy::cast_precision_loss)]
375 let u = lo + (hi - lo) * f64::from(i) / 400.0;
376 let at = fitted.on_a.point_at(u, T).unwrap();
377 assert!(
378 (at.x - previous.x).abs() < 1.0,
379 "the pcurve tears at the seam: {} to {}",
380 previous.x,
381 at.x
382 );
383 previous = at;
384 }
385 }
386
387 #[test]
397 fn a_loop_cut_at_a_converted_drum_s_seam_is_closed() {
398 let drum: SurfaceGeometry = cylinder(2.0).to_bspline(T).unwrap().into();
399 assert!(matches!(drum, SurfaceGeometry::BSpline(_)));
400 let cut = plane(Point::new(0.0, 0.0, 1.0), Vector::new(0.0, 0.2, 1.0));
401 let found = branches(&drum, &cut, options(), T).unwrap();
402 assert_eq!(found.len(), 1, "an oblique plane cuts one loop");
403 assert!(found[0].closed(), "the loop closes on the seam");
404 let fitted = approximate_branch(&drum, &cut, &found[0], 1e-4, T).unwrap();
405 assert!(fitted.closed);
406 assert!(
407 fitted.fit_error < 1e-3,
408 "the loop fits as one: {}",
409 fitted.fit_error
410 );
411 let (lo, hi) = fitted.on_a.domain();
412 let mut previous = fitted.on_a.point_at(lo, T).unwrap();
413 for i in 1..=400 {
414 let u = lo + (hi - lo) * f64::from(i) / 400.0;
415 let at = fitted.on_a.point_at(u, T).unwrap();
416 assert!(
417 (at.x - previous.x).abs() < 0.5,
418 "the chart image tears at the seam: {} to {}",
419 previous.x,
420 at.x
421 );
422 previous = at;
423 }
424 }
425
426 #[test]
427 fn what_cannot_be_fitted_is_refused() {
428 let a = sphere(1.0);
429 let b = plane(Point::ORIGIN, Vector::Z);
430 let found = branches(&a, &b, options(), T).unwrap();
431 assert!(approximate_branch(&a, &b, &found[0], 0.0, T).is_err());
432 assert!(approximate_branch(&a, &b, &found[0], -1.0, T).is_err());
433
434 let empty = Traced {
435 points: vec![],
436 on_a: vec![],
437 on_b: vec![],
438 stopped: crate::march::Stopped::Stalled,
439 };
440 assert!(approximate_branch(&a, &b, &empty, 1e-4, T).is_err());
441 }
442}