1use ogeom_core::Tolerances;
39use ogeom_geom::{
40 BSpline2d, BSplineCurve, Curve, Curve2d as _, Curve3d as _, LineCurve, PlanarCurve, Surface,
41 SurfaceGeometry,
42};
43use ogeom_math::bspline::{self, Spline, Weighted};
44use ogeom_math::{
45 Axis, Axis2, Direction, Direction2, Frame, KnotVector, Point, Point2, Vector, Vector2,
46};
47
48use crate::march::TANGENTIAL_SINE;
49use crate::section::{SectionCurve, exact_pcurve_over, inside_box, touches_box};
50
51pub(crate) fn plane_extrusion(
56 a: &SurfaceGeometry,
57 b: &SurfaceGeometry,
58 tol: Tolerances,
59) -> Option<Vec<SectionCurve>> {
60 plane_extrusion_with(a, b, false, tol)
61}
62
63#[must_use]
76pub fn plane_extrusion_at_rims(
77 a: &SurfaceGeometry,
78 b: &SurfaceGeometry,
79 tol: Tolerances,
80) -> Option<Vec<SectionCurve>> {
81 if !crosses_extrusion_rim(a, b, tol) {
82 return None;
83 }
84 plane_extrusion_with(a, b, true, tol)
85}
86
87#[must_use]
93pub fn crosses_extrusion_rim(a: &SurfaceGeometry, b: &SurfaceGeometry, tol: Tolerances) -> bool {
94 let Some((carry, spline, sweep)) = spline_pair(a, b, tol) else {
95 return false;
96 };
97 let heights = spline.heights(&carry);
98 [sweep.0, sweep.1]
99 .iter()
100 .any(|&end| heights.iter().any(|&v| v < end) && heights.iter().any(|&v| v > end))
101}
102
103fn spline_pair(
107 a: &SurfaceGeometry,
108 b: &SurfaceGeometry,
109 tol: Tolerances,
110) -> Option<(Carry, SplineProfile, (f64, f64))> {
111 let (p, extrusion) = match (a, b) {
112 (SurfaceGeometry::Plane(p), SurfaceGeometry::Extrusion(e))
113 | (SurfaceGeometry::Extrusion(e), SurfaceGeometry::Plane(p)) => (p, &**e),
114 _ => return None,
115 };
116 let plane = p.plane();
117 let (n, d) = (plane.normal().vector(), extrusion.direction().vector());
118 let along = n.dot(d);
119 if along.abs() < TANGENTIAL_SINE {
120 return None;
121 }
122 let (window, sweep) = extrusion.domain();
123 let spline = SplineProfile::of(extrusion.curve(), window, tol)?;
124 let carry = Carry {
125 n,
126 o: plane.origin(),
127 d,
128 along,
129 };
130 Some((carry, spline, sweep))
131}
132
133pub(crate) fn drum_extrusion(
151 a: &SurfaceGeometry,
152 b: &SurfaceGeometry,
153 tol: Tolerances,
154) -> Option<Vec<SectionCurve>> {
155 let (drum_surface, extrusion, drum_first) = match (a, b) {
156 (SurfaceGeometry::Cylinder(_), SurfaceGeometry::Extrusion(e)) => (a, &**e, true),
157 (SurfaceGeometry::Extrusion(e), SurfaceGeometry::Cylinder(_)) => (b, &**e, false),
158 _ => return None,
159 };
160 let SurfaceGeometry::Cylinder(c) = drum_surface else {
161 return None;
162 };
163 let drum = c.cylinder();
164 let frame = drum.frame();
165 let (o, r) = (frame.origin(), drum.radius());
166 let (x, y) = (frame.x().vector(), frame.y().vector());
167 let d = extrusion.direction();
168 let (window, sweep) = extrusion.domain();
169 let reach = sweep.0.abs().max(sweep.1.abs());
170 if d.vector().cross(frame.z().vector()).magnitude() * reach > tol.confusion() {
171 return None;
172 }
173 let spline = SplineProfile::of(extrusion.curve(), window, tol)?;
174 let ends = (spline.control.first()?, spline.control.last()?);
177 if [ends.0, ends.1]
178 .iter()
179 .any(|w| drum.distance_to(w.point()) <= tol.confusion())
180 {
181 return None;
182 }
183 let across: Vec<Weighted<Point2>> = spline
184 .control
185 .iter()
186 .map(|w| {
187 let q = w.point() - o;
188 Weighted {
189 scaled: Point2::new(w.weight * q.dot(x), w.weight * q.dot(y)),
190 weight: w.weight,
191 }
192 })
193 .collect();
194 let noise = spline
198 .control
199 .iter()
200 .map(|w| {
201 let size = w.point().to_vector().magnitude() + o.to_vector().magnitude() + r;
202 w.weight * w.weight * size * size
203 })
204 .fold(0.0, f64::max)
205 * f64::EPSILON
206 * 64.0;
207 let pieces = bspline::to_bezier_segments(&spline.knots, &across, tol).ok()?;
208 let mut roots = Vec::new();
209 for ((lo, hi), piece) in pieces {
210 let sx: Vec<f64> = piece.iter().map(|w| w.scaled.x).collect();
211 let sy: Vec<f64> = piece.iter().map(|w| w.scaled.y).collect();
212 let sw: Vec<f64> = piece.iter().map(|w| w.weight).collect();
213 let (xx, yy, ww) = (
214 bernstein_product(&sx, &sx),
215 bernstein_product(&sy, &sy),
216 bernstein_product(&sw, &sw),
217 );
218 let values: Vec<f64> = (0..xx.len())
219 .map(|k| (-r * r).mul_add(ww[k], xx[k] + yy[k]))
220 .collect();
221 bezier_roots(&values, (lo, hi), noise, tol, &mut roots)?;
222 }
223 roots.sort_by(f64::total_cmp);
224 roots.dedup_by(|p, q| (*p - *q).abs() <= tol.parametric());
225 let mut out = Vec::with_capacity(roots.len());
226 for u in roots {
227 let at = bspline::evaluate_rational(&spline.knots, &spline.control, u, tol).ok()?;
228 let axis = Axis {
229 location: at,
230 direction: d,
231 };
232 let line: Curve = LineCurve::over(axis, sweep.0, sweep.1).ok()?.into();
233 let on_drum = exact_pcurve_over(&line, sweep, drum_surface, tol)?;
234 let Some((lo, hi)) = inside_box(&on_drum, drum_surface) else {
235 continue;
236 };
237 let (lo, hi) = (lo.max(sweep.0), hi.min(sweep.1));
238 if hi - lo <= tol.parametric() {
239 continue;
240 }
241 let line: Curve = LineCurve::over(axis, lo, hi).ok()?.into();
242 let on_drum = exact_pcurve_over(&line, (lo, hi), drum_surface, tol)?;
243 let on_extrusion: PlanarCurve =
244 ogeom_geom::Line2d::over(Axis2::new(Point2::new(u, 0.0), Direction2::Y), lo, hi)
245 .ok()?
246 .into();
247 lands_on_both(&line, &on_drum, &on_extrusion, drum_surface, extrusion, tol)?;
248 let (on_a, on_b) = if drum_first {
249 (on_drum, on_extrusion)
250 } else {
251 (on_extrusion, on_drum)
252 };
253 out.push(SectionCurve {
254 curve: line,
255 on_a: Some(on_a),
256 on_b: Some(on_b),
257 tolerance: 0.0,
258 exact: true,
259 closed: false,
260 tangential: false,
261 });
262 }
263 Some(out)
264}
265
266fn bernstein_product(a: &[f64], b: &[f64]) -> Vec<f64> {
270 let (m, n) = (a.len() - 1, b.len() - 1);
271 let choose = |top: usize, k: usize| -> f64 {
272 (0..k).fold(1.0, |acc, j| {
273 #[allow(clippy::cast_precision_loss, reason = "small degrees")]
274 let (t, j) = ((top - j) as f64, (j + 1) as f64);
275 acc * t / j
276 })
277 };
278 (0..=m + n)
279 .map(|k| {
280 let terms: f64 = (k.saturating_sub(n)..=k.min(m))
281 .map(|i| choose(m, i) * choose(n, k - i) * a[i] * b[k - i])
282 .sum();
283 terms / choose(m + n, k)
284 })
285 .collect()
286}
287
288fn plane_extrusion_with(
291 a: &SurfaceGeometry,
292 b: &SurfaceGeometry,
293 at_rims: bool,
294 tol: Tolerances,
295) -> Option<Vec<SectionCurve>> {
296 let (plane_surface, extrusion, plane_first) = match (a, b) {
297 (SurfaceGeometry::Plane(_), SurfaceGeometry::Extrusion(e)) => (a, &**e, true),
298 (SurfaceGeometry::Extrusion(e), SurfaceGeometry::Plane(_)) => (b, &**e, false),
299 _ => return None,
300 };
301 let SurfaceGeometry::Plane(p) = plane_surface else {
302 return None;
303 };
304 let plane = p.plane();
305 let n = plane.normal().vector();
306 let d = extrusion.direction().vector();
307 let o = plane.origin();
308 let (window, sweep) = extrusion.domain();
309 let along = n.dot(d);
310 let sections = if let Some(spline) = SplineProfile::of(extrusion.curve(), window, tol) {
311 if along.abs() < TANGENTIAL_SINE {
314 return None;
315 }
316 spline.carried(&Carry { n, o, d, along }, sweep, at_rims, tol)?
317 } else {
318 let (profile, window, whole_turn) = Profile::of(extrusion.curve(), window, tol)?;
319 planar_sections(&profile, window, whole_turn, (n, o, d), extrusion, tol)?
320 };
321 finish(sections, plane_surface, extrusion, plane_first, tol)
322}
323
324fn planar_sections(
327 profile: &Profile,
328 window: (f64, f64),
329 whole_turn: bool,
330 (n, o, d): (Vector, Point, Vector),
331 extrusion: &ogeom_geom::ExtrusionSurface,
332 tol: Tolerances,
333) -> Option<Vec<(Curve, PlanarCurve)>> {
334 let (_, sweep) = extrusion.domain();
335 let along = n.dot(d);
336 let reach = sweep.0.abs().max(sweep.1.abs());
337 Some(if along.abs() * reach <= tol.confusion() {
338 rulings(profile, window, whole_turn, (n, o), extrusion, tol)?
339 } else {
340 if along.abs() < TANGENTIAL_SINE {
345 return None;
346 }
347 let carry = Carry { n, o, d, along };
348 carried(profile, window, whole_turn, &carry, sweep, tol)?
349 })
350}
351
352fn finish(
355 sections: Vec<(Curve, PlanarCurve)>,
356 plane_surface: &SurfaceGeometry,
357 extrusion: &ogeom_geom::ExtrusionSurface,
358 plane_first: bool,
359 tol: Tolerances,
360) -> Option<Vec<SectionCurve>> {
361 let mut out = Vec::with_capacity(sections.len());
364 for (curve, on_extrusion) in sections {
365 let Some((curve, on_extrusion, on_plane)) =
366 within_plane_window(curve, on_extrusion, plane_surface, tol)?
367 else {
368 continue;
369 };
370 lands_on_both(
371 &curve,
372 &on_plane,
373 &on_extrusion,
374 plane_surface,
375 extrusion,
376 tol,
377 )?;
378 let closed = curve.is_closed(tol);
379 let (on_a, on_b) = if plane_first {
380 (on_plane, on_extrusion)
381 } else {
382 (on_extrusion, on_plane)
383 };
384 out.push(SectionCurve {
385 curve,
386 on_a: Some(on_a),
387 on_b: Some(on_b),
388 tolerance: 0.0,
389 exact: true,
390 closed,
391 tangential: false,
392 });
393 }
394 Some(out)
395}
396
397enum Profile {
400 Line { p: Point, t: Vector },
402 Conic { c: Point, x: Vector, y: Vector },
404 Parabola { apex: Point, x: Vector, y: Vector },
406}
407
408impl Profile {
409 fn of(curve: &Curve, window: (f64, f64), tol: Tolerances) -> Option<(Self, (f64, f64), bool)> {
413 let (basis, whole) = match curve {
414 Curve::Trimmed(t) if !t.is_reversed() => (t.basis(), false),
415 Curve::Trimmed(_) => return None,
416 other => (other, other.is_periodic()),
417 };
418 let profile = match basis {
419 Curve::Line(line) => {
420 let axis = line.axis();
421 Self::Line {
422 p: axis.location,
423 t: axis.direction.vector(),
424 }
425 }
426 Curve::Circle(c) => {
427 let circle = c.forward(tol).ok()?.circle();
428 let frame = circle.frame();
429 Self::Conic {
430 c: circle.centre(),
431 x: frame.x().vector() * circle.radius(),
432 y: frame.y().vector() * circle.radius(),
433 }
434 }
435 Curve::Ellipse(e) => {
436 let ellipse = e.forward(tol).ok()?.ellipse();
437 let frame = ellipse.frame();
438 Self::Conic {
439 c: ellipse.centre(),
440 x: frame.x().vector() * ellipse.major_radius(),
441 y: frame.y().vector() * ellipse.minor_radius(),
442 }
443 }
444 Curve::Parabola(parabola) if !parabola.is_reversed() => {
445 let parabola = parabola.parabola();
446 let frame = parabola.frame();
447 Self::Parabola {
448 apex: parabola.apex(),
449 x: frame.x().vector() / (4.0 * parabola.focal()),
450 y: frame.y().vector(),
451 }
452 }
453 _ => return None,
454 };
455 Some((profile, window, whole))
456 }
457
458 fn point(&self, u: f64) -> Point {
460 match self {
461 Self::Line { p, t } => *p + *t * u,
462 Self::Conic { c, x, y } => *c + *x * u.cos() + *y * u.sin(),
463 Self::Parabola { apex, x, y } => *apex + *x * (u * u) + *y * u,
464 }
465 }
466
467 fn tangent(&self, u: f64) -> Vector {
469 match self {
470 Self::Line { t, .. } => *t,
471 Self::Conic { x, y, .. } => *y * u.cos() - *x * u.sin(),
472 Self::Parabola { x, y, .. } => *x * (2.0 * u) + *y,
473 }
474 }
475
476 fn crossings(
481 &self,
482 n: Vector,
483 k: f64,
484 (lo, hi): (f64, f64),
485 tol: Tolerances,
486 ) -> Option<Vec<f64>> {
487 let near = tol.confusion();
488 let mut found = Vec::new();
489 match self {
490 Self::Line { p, t } => {
491 let (rate, at) = (n.dot(*t), n.dot(p.to_vector()) - k);
492 if rate.abs() * (hi - lo) <= near {
493 if rate.mul_add(lo, at).abs() <= near {
494 return None;
495 }
496 } else {
497 found.push(-at / rate);
498 }
499 }
500 Self::Conic { c, x, y } => {
501 let (a, b, s) = (n.dot(c.to_vector()) - k, n.dot(*x), n.dot(*y));
503 let r = b.hypot(s);
504 if (a.abs() - r).abs() <= near {
505 return None;
506 }
507 if a.abs() < r {
508 let phase = s.atan2(b);
509 let half = (-a / r).acos();
510 let tau = core::f64::consts::TAU;
511 for root in [phase - half, phase + half] {
512 let mut u = lo + (root - lo).rem_euclid(tau);
513 while u <= hi + tol.parametric() {
514 found.push(u);
515 u += tau;
516 }
517 }
518 }
519 }
520 Self::Parabola { apex, x, y } => {
521 let (a, b, c) = (n.dot(*x), n.dot(*y), n.dot(apex.to_vector()) - k);
522 if a.abs() > 0.0 {
523 let turn = -b / (2.0 * a);
524 if (lo..=hi).contains(&turn)
525 && a.mul_add(turn * turn, b.mul_add(turn, c)).abs() <= near
526 {
527 return None;
528 }
529 } else if b.abs() * (hi - lo) <= near && b.mul_add(lo, c).abs() <= near {
530 return None;
531 }
532 found.extend(ogeom_math::solve::quadratic_roots(a, b, c));
533 }
534 }
535 let slack = tol.parametric();
536 let mut kept: Vec<f64> = found
537 .into_iter()
538 .filter(|u| u.is_finite() && *u >= lo - slack && *u <= hi + slack)
539 .map(|u| u.clamp(lo, hi))
540 .collect();
541 kept.sort_by(f64::total_cmp);
542 kept.dedup_by(|x, y| (*x - *y).abs() <= slack);
543 Some(kept)
544 }
545}
546
547struct SplineProfile {
552 knots: KnotVector,
553 control: Vec<Weighted<Point>>,
554}
555
556impl SplineProfile {
557 fn of(curve: &Curve, window: (f64, f64), tol: Tolerances) -> Option<Self> {
560 let basis = match curve {
561 Curve::Trimmed(t) if !t.is_reversed() => t.basis(),
562 other => other,
563 };
564 let Curve::BSpline(spline) = basis else {
565 return None;
566 };
567 if basis.is_periodic() || !spline.knots().is_clamped() {
568 return None;
569 }
570 let whole: Spline<Weighted<Point>> =
571 (spline.knots().clone(), spline.control_points().to_vec());
572 let (knots, control) = cut_to(whole, window, tol)?;
573 Some(Self { knots, control })
574 }
575
576 fn carried(
583 &self,
584 carry: &Carry,
585 sweep: (f64, f64),
586 at_rims: bool,
587 tol: Tolerances,
588 ) -> Option<Vec<(Curve, PlanarCurve)>> {
589 let heights = self.heights(carry);
592 if heights.iter().all(|&v| v < sweep.0) || heights.iter().all(|&v| v > sweep.1) {
593 return Some(Vec::new());
594 }
595 if heights.iter().all(|&v| v >= sweep.0 && v <= sweep.1) {
596 return Some(vec![self.carried_whole(carry, &heights, tol)?]);
597 }
598 if !at_rims {
599 return None;
600 }
601 let window = self.knots.domain();
602 let mut cuts = vec![window.0, window.1];
603 for end in [sweep.0, sweep.1] {
604 if heights.iter().any(|&v| v < end) && heights.iter().any(|&v| v > end) {
605 cuts.extend(level_crossings(
606 &self.knots,
607 &self.control,
608 carry,
609 end,
610 tol,
611 )?);
612 }
613 }
614 cuts.sort_by(f64::total_cmp);
615 cuts.dedup_by(|x, y| (*x - *y).abs() <= tol.parametric());
616 let mut out = Vec::new();
617 for pair in cuts.windows(2) {
618 let (lo, hi) = (pair[0], pair[1]);
619 if hi - lo <= tol.parametric() {
620 continue;
621 }
622 let mid =
623 bspline::evaluate_rational(&self.knots, &self.control, f64::midpoint(lo, hi), tol)
624 .ok()?;
625 let v = carry.height(mid);
626 if v < sweep.0 || v > sweep.1 {
627 continue;
628 }
629 let (knots, control) =
630 cut_to((self.knots.clone(), self.control.clone()), (lo, hi), tol)?;
631 let piece = Self { knots, control };
632 let heights = piece.heights(carry);
633 out.push(piece.carried_whole(carry, &heights, tol)?);
634 }
635 Some(out)
636 }
637
638 fn heights(&self, carry: &Carry) -> Vec<f64> {
640 self.control
641 .iter()
642 .map(|w| carry.height(w.point()))
643 .collect()
644 }
645
646 fn carried_whole(
648 &self,
649 carry: &Carry,
650 heights: &[f64],
651 tol: Tolerances,
652 ) -> Option<(Curve, PlanarCurve)> {
653 let image: Vec<Weighted<Point>> = self
654 .control
655 .iter()
656 .map(|w| Weighted::new(carry.point(w.point()), w.weight, tol))
657 .collect::<Result<_, _>>()
658 .ok()?;
659 let curve: Curve = BSplineCurve::rational(self.knots.clone(), image)
660 .ok()?
661 .into();
662 let chart = self.chart(heights, tol)?;
663 Some((curve, chart.into()))
664 }
665
666 fn chart(&self, heights: &[f64], tol: Tolerances) -> Option<BSpline2d> {
673 let p = self.knots.degree();
674 let lifted: Vec<Weighted<Point2>> = self
675 .control
676 .iter()
677 .zip(heights)
678 .map(|(w, &v)| Weighted {
679 scaled: Point2::new(0.0, v * w.weight),
680 weight: w.weight,
681 })
682 .collect();
683 let pieces = bspline::to_bezier_segments(&self.knots, &lifted, tol).ok()?;
684 let up = p + 1;
685 #[allow(clippy::cast_precision_loss)]
686 let share = |k: usize| k as f64 / up as f64;
687 let mut knots: Vec<f64> = Vec::with_capacity(pieces.len() * up + 2);
688 let mut control: Vec<Weighted<Point2>> = Vec::with_capacity(pieces.len() * up + 1);
689 for (index, ((a, b), piece)) in pieces.iter().enumerate() {
690 for k in 0..=up {
691 if index > 0 && k == 0 {
692 continue;
693 }
694 let (before, here) = ((k > 0).then(|| piece[k - 1]), (k <= p).then(|| piece[k]));
695 let blend = |f: fn(&Weighted<Point2>) -> f64| {
696 before.map_or(0.0, |w| share(k) * f(&w))
697 + here.map_or(0.0, |w| (1.0 - share(k)) * f(&w))
698 };
699 let weight = blend(|w| w.weight);
700 let rise = blend(|w| w.scaled.y);
701 let run = (1.0 - share(k)) * here.map_or(0.0, |w| w.weight) * a
702 + share(k) * before.map_or(0.0, |w| w.weight) * b;
703 control.push(Weighted {
704 scaled: Point2::new(run, rise),
705 weight,
706 });
707 }
708 let ends = if index == 0 { up + 1 } else { up };
709 knots.extend(core::iter::repeat_n(*a, ends));
710 if index == pieces.len() - 1 {
711 knots.extend(core::iter::repeat_n(*b, up + 1));
712 }
713 }
714 BSpline2d::rational(KnotVector::new(knots, up).ok()?, control).ok()
715 }
716}
717
718fn cut_to<P: bspline::Blend>(
720 mut piece: Spline<P>,
721 (lo, hi): (f64, f64),
722 tol: Tolerances,
723) -> Option<Spline<P>> {
724 let (start, end) = piece.0.domain();
725 if lo > start + tol.parametric() {
726 piece = bspline::split(&piece.0, &piece.1, lo, tol).ok()?.1;
727 }
728 if hi < end - tol.parametric() {
729 piece = bspline::split(&piece.0, &piece.1, hi, tol).ok()?.0;
730 }
731 Some(piece)
732}
733
734fn level_crossings(
752 knots: &KnotVector,
753 control: &[Weighted<Point>],
754 carry: &Carry,
755 level: f64,
756 tol: Tolerances,
757) -> Option<Vec<f64>> {
758 let signed: Vec<Weighted<f64>> = control
759 .iter()
760 .map(|w| Weighted {
761 scaled: w.weight * (carry.height(w.point()) - level),
762 weight: w.weight,
763 })
764 .collect();
765 let noise = control
768 .iter()
769 .map(|w| w.weight * (carry.height(w.point()).abs() + level.abs()))
770 .fold(0.0, f64::max)
771 * f64::EPSILON
772 * 64.0;
773 let pieces = bspline::to_bezier_segments(knots, &signed, tol).ok()?;
774 let mut found = Vec::new();
775 for ((a, b), piece) in pieces {
776 let values: Vec<f64> = piece.iter().map(|w| w.scaled).collect();
777 bezier_roots(&values, (a, b), noise, tol, &mut found)?;
778 }
779 found.sort_by(f64::total_cmp);
780 found.dedup_by(|x, y| (*x - *y).abs() <= tol.parametric());
781 Some(found)
782}
783
784fn bezier_roots(
787 values: &[f64],
788 (a, b): (f64, f64),
789 noise: f64,
790 tol: Tolerances,
791 found: &mut Vec<f64>,
792) -> Option<()> {
793 if values.iter().all(|&c| c > 0.0) || values.iter().all(|&c| c < 0.0) {
794 return Some(());
795 }
796 if values.iter().all(|&c| c.abs() <= noise) {
797 return None;
798 }
799 let (first, last) = (values[0], values[values.len() - 1]);
800 let changes = values
801 .windows(2)
802 .filter(|pair| pair[0] * pair[1] < 0.0)
803 .count();
804 if changes == 1 && first * last < 0.0 && values.iter().all(|&c| c != 0.0) {
805 let (mut lo, mut hi) = (0.0_f64, 1.0_f64);
806 loop {
807 let mid = f64::midpoint(lo, hi);
808 if mid <= lo || mid >= hi {
809 break;
810 }
811 if de_casteljau(values, mid).0[values.len() - 1] * first > 0.0 {
812 lo = mid;
813 } else {
814 hi = mid;
815 }
816 }
817 found.push((b - a).mul_add(f64::midpoint(lo, hi), a));
818 return Some(());
819 }
820 if b - a <= tol.parametric() {
821 return None;
822 }
823 let (left, right) = de_casteljau(values, 0.5);
824 let mid = f64::midpoint(a, b);
825 bezier_roots(&left, (a, mid), noise, tol, found)?;
826 bezier_roots(&right, (mid, b), noise, tol, found)
827}
828
829fn de_casteljau(values: &[f64], s: f64) -> (Vec<f64>, Vec<f64>) {
833 let mut row = values.to_vec();
834 let mut left = Vec::with_capacity(values.len());
835 let mut right = Vec::with_capacity(values.len());
836 left.push(row[0]);
837 right.push(row[row.len() - 1]);
838 for len in (1..row.len()).rev() {
839 for k in 0..len {
840 row[k] = (row[k + 1] - row[k]).mul_add(s, row[k]);
841 }
842 left.push(row[0]);
843 right.push(row[len - 1]);
844 }
845 right.reverse();
846 (left, right)
847}
848
849fn rulings(
855 profile: &Profile,
856 window: (f64, f64),
857 whole_turn: bool,
858 (n, o): (Vector, Point),
859 extrusion: &ogeom_geom::ExtrusionSurface,
860 tol: Tolerances,
861) -> Option<Vec<(Curve, PlanarCurve)>> {
862 let d = extrusion.direction();
863 let (_, sweep) = extrusion.domain();
864 let k = n.dot(o.to_vector());
865 let mut roots = profile.crossings(n, k, window, tol)?;
866 for end in [window.0, window.1] {
869 let gap = n.dot(profile.point(end).to_vector()) - k;
870 if gap.abs() <= tol.confusion()
871 && !roots.iter().any(|r| (r - end).abs() <= tol.parametric())
872 {
873 roots.push(end);
874 }
875 }
876 roots.sort_by(f64::total_cmp);
877 if whole_turn
878 && roots.len() > 1
879 && (roots[0] - window.0).abs() <= tol.parametric()
880 && (roots[roots.len() - 1] - window.1).abs() <= tol.parametric()
881 {
882 roots.pop();
883 }
884 let mut out = Vec::with_capacity(roots.len());
885 for u in roots {
886 let tangent = profile.tangent(u);
887 let across = tangent - d.vector() * tangent.dot(d.vector());
888 let size = across.magnitude();
889 if size <= tol.confusion() || n.dot(tangent).abs() < TANGENTIAL_SINE * size {
890 return None;
891 }
892 let axis = Axis {
893 location: profile.point(u),
894 direction: d,
895 };
896 let line: Curve = LineCurve::over(axis, sweep.0, sweep.1).ok()?.into();
897 let chart: PlanarCurve = ogeom_geom::Line2d::over(
898 Axis2::new(Point2::new(u, 0.0), Direction2::Y),
899 sweep.0,
900 sweep.1,
901 )
902 .ok()?
903 .into();
904 out.push((line, chart));
905 }
906 Some(out)
907}
908
909struct Carry {
912 n: Vector,
913 o: Point,
914 d: Vector,
915 along: f64,
916}
917
918impl Carry {
919 fn height(&self, p: Point) -> f64 {
921 self.n.dot(self.o - p) / self.along
922 }
923
924 fn point(&self, p: Point) -> Point {
926 p + self.d * self.height(p)
927 }
928
929 fn vector(&self, w: Vector) -> Vector {
931 w - self.d * (self.n.dot(w) / self.along)
932 }
933}
934
935fn carried(
940 profile: &Profile,
941 window: (f64, f64),
942 whole_turn: bool,
943 carry: &Carry,
944 sweep: (f64, f64),
945 tol: Tolerances,
946) -> Option<Vec<(Curve, PlanarCurve)>> {
947 let level = |v: f64| carry.n.dot(carry.o.to_vector()) - v * carry.along;
948 let mut cuts = vec![window.0, window.1];
949 cuts.extend(profile.crossings(carry.n, level(sweep.0), window, tol)?);
950 cuts.extend(profile.crossings(carry.n, level(sweep.1), window, tol)?);
951 cuts.sort_by(f64::total_cmp);
952 cuts.dedup_by(|x, y| (*x - *y).abs() <= tol.parametric());
953 let mut stretches: Vec<(f64, f64)> = Vec::new();
954 for pair in cuts.windows(2) {
955 let (lo, hi) = (pair[0], pair[1]);
956 let v = carry.height(profile.point(f64::midpoint(lo, hi)));
957 if v < sweep.0 || v > sweep.1 {
958 continue;
959 }
960 match stretches.last_mut() {
961 Some(last) if (last.1 - lo).abs() <= tol.parametric() => last.1 = hi,
962 _ => stretches.push((lo, hi)),
963 }
964 }
965 let period = window.1 - window.0;
968 if whole_turn && stretches.len() > 1 {
969 let (first, last) = (stretches[0], stretches[stretches.len() - 1]);
970 if (first.0 - window.0).abs() <= tol.parametric()
971 && (last.1 - window.1).abs() <= tol.parametric()
972 {
973 stretches.pop();
974 stretches[0] = (last.0, first.1 + period);
975 }
976 }
977 let image = Image::of(profile, carry, tol)?;
978 let mut out = Vec::with_capacity(stretches.len());
979 for (lo, hi) in stretches {
980 if hi - lo <= tol.parametric() {
981 continue;
982 }
983 let whole = whole_turn && (hi - lo - period).abs() <= tol.parametric();
984 out.push(image.over((lo, hi), whole, profile, carry, tol)?);
985 }
986 Some(out)
987}
988
989enum Image {
991 Line { axis: Axis, scale: f64 },
993 Conic { curve: Curve, phase: f64 },
995 Parabola {
997 parabola: ogeom_math::Parabola,
998 vertex: f64,
999 scale: f64,
1000 },
1001}
1002
1003impl Image {
1004 fn of(profile: &Profile, carry: &Carry, tol: Tolerances) -> Option<Self> {
1005 Some(match profile {
1006 Profile::Line { p, t } => {
1007 let run = carry.vector(*t);
1008 let scale = run.magnitude();
1009 Self::Line {
1010 axis: Axis {
1011 location: carry.point(*p),
1012 direction: Direction::new(run, tol).ok()?,
1013 },
1014 scale,
1015 }
1016 }
1017 Profile::Conic { c, x, y } => {
1018 let (p, q) = (carry.vector(*x), carry.vector(*y));
1021 let phase = 0.5 * (2.0 * p.dot(q)).atan2(p.dot(p) - q.dot(q));
1022 let (cos, sin) = (phase.cos(), phase.sin());
1023 let major = p * cos + q * sin;
1024 let minor = q * cos - p * sin;
1025 let (ra, rb) = (major.magnitude(), minor.magnitude());
1026 let xa = Direction::new(major, tol).ok()?;
1027 let ya = Direction::new(minor - xa.vector() * minor.dot(xa.vector()), tol).ok()?;
1028 let za = Direction::new(xa.vector().cross(ya.vector()), tol).ok()?;
1029 let frame = Frame::from_axes(carry.point(*c), xa, ya, za, tol).ok()?;
1030 let curve: Curve = if ra - rb <= tol.confusion() {
1031 ogeom_geom::CircleCurve::new(ogeom_math::Circle::new(frame, ra, tol).ok()?)
1032 .into()
1033 } else {
1034 ogeom_geom::EllipseCurve::new(
1035 ogeom_math::Ellipse::new(frame, ra, rb, tol).ok()?,
1036 )
1037 .into()
1038 };
1039 Self::Conic { curve, phase }
1040 }
1041 Profile::Parabola { apex, x, y } => {
1042 let (p, q) = (carry.vector(*x), carry.vector(*y));
1045 let vertex = -p.dot(q) / (2.0 * p.dot(p));
1046 let across = q - p * (p.dot(q) / p.dot(p));
1047 let scale = across.magnitude();
1048 let xa = Direction::new(p, tol).ok()?;
1049 let ya = Direction::new(across, tol).ok()?;
1050 let za = Direction::new(xa.vector().cross(ya.vector()), tol).ok()?;
1051 let origin = carry.point(*apex) + p * (vertex * vertex) + q * vertex;
1052 let frame = Frame::from_axes(origin, xa, ya, za, tol).ok()?;
1053 let focal = scale * scale / (4.0 * p.magnitude());
1054 Self::Parabola {
1055 parabola: ogeom_math::Parabola::new(frame, focal, tol).ok()?,
1056 vertex,
1057 scale,
1058 }
1059 }
1060 })
1061 }
1062
1063 fn over(
1067 &self,
1068 (lo, hi): (f64, f64),
1069 whole: bool,
1070 profile: &Profile,
1071 carry: &Carry,
1072 tol: Tolerances,
1073 ) -> Option<(Curve, PlanarCurve)> {
1074 let height = |u: f64| carry.height(profile.point(u));
1075 match self {
1076 Self::Line { axis, scale } => {
1077 let (t0, t1) = (lo * scale, hi * scale);
1078 let curve: Curve = LineCurve::over(*axis, t0, t1).ok()?.into();
1079 let chart = BSpline2d::new(
1080 KnotVector::new(vec![t0, t0, t1, t1], 1).ok()?,
1081 vec![Point2::new(lo, height(lo)), Point2::new(hi, height(hi))],
1082 tol,
1083 )
1084 .ok()?;
1085 Some((curve, chart.into()))
1086 }
1087 Self::Conic { curve, phase } => {
1088 let Profile::Conic { c, x, y } = profile else {
1090 return None;
1091 };
1092 let alpha = carry.height(*c);
1093 let (beta, gamma) = (
1094 -carry.n.dot(*x) / carry.along,
1095 -carry.n.dot(*y) / carry.along,
1096 );
1097 let (cos, sin) = (phase.cos(), phase.sin());
1098 let range = if whole {
1099 curve.domain()
1100 } else {
1101 (lo - phase, hi - phase)
1102 };
1103 let chart = ogeom_geom::Trig2d::new(
1104 Point2::new(*phase, alpha),
1105 Vector2::new(1.0, 0.0),
1106 Vector2::new(0.0, beta.mul_add(cos, gamma * sin)),
1107 Vector2::new(0.0, gamma.mul_add(cos, -beta * sin)),
1108 range,
1109 )
1110 .ok()?;
1111 let piece = if whole {
1112 curve.clone()
1113 } else {
1114 ogeom_geom::TrimmedCurve::new(curve.clone(), range.0, range.1, tol)
1115 .ok()?
1116 .into()
1117 };
1118 Some((piece, PlanarCurve::Trig(chart)))
1119 }
1120 Self::Parabola {
1121 parabola,
1122 vertex,
1123 scale,
1124 } => {
1125 let (t0, t1) = ((lo - vertex) * scale, (hi - vertex) * scale);
1126 let curve: Curve = ogeom_geom::ParabolaCurve::over(*parabola, t0, t1)
1127 .ok()?
1128 .into();
1129 let rise = -carry.n.dot(profile.tangent(lo)) / carry.along;
1132 let start = Point2::new(lo, height(lo));
1133 let middle = Point2::new(
1134 f64::midpoint(lo, hi),
1135 rise.mul_add((hi - lo) / 2.0, start.y),
1136 );
1137 let end = Point2::new(hi, height(hi));
1138 let chart = BSpline2d::new(
1139 KnotVector::new(vec![t0, t0, t0, t1, t1, t1], 2).ok()?,
1140 vec![start, middle, end],
1141 tol,
1142 )
1143 .ok()?;
1144 Some((curve, chart.into()))
1145 }
1146 }
1147 }
1148}
1149
1150#[allow(clippy::type_complexity, reason = "a piece and its two traces")]
1156fn within_plane_window(
1157 curve: Curve,
1158 on_extrusion: PlanarCurve,
1159 plane: &SurfaceGeometry,
1160 tol: Tolerances,
1161) -> Option<Option<(Curve, PlanarCurve, PlanarCurve)>> {
1162 let range = curve.domain();
1163 let on_plane = exact_pcurve_over(&curve, range, plane, tol)?;
1164 let Curve::Line(line) = &curve else {
1165 return Some(touches_box(&on_plane, plane, tol).then_some((curve, on_extrusion, on_plane)));
1166 };
1167 let Some((lo, hi)) = inside_box(&on_plane, plane) else {
1168 return Some(None);
1169 };
1170 let (lo, hi) = (lo.max(range.0), hi.min(range.1));
1171 if hi - lo <= tol.parametric() {
1172 return Some(None);
1173 }
1174 if lo <= range.0 && hi >= range.1 {
1175 return Some(Some((curve, on_extrusion, on_plane)));
1176 }
1177 let cut: Curve = LineCurve::over(line.axis(), lo, hi).ok()?.into();
1178 let on_extrusion: PlanarCurve = match on_extrusion {
1179 PlanarCurve::Line(l) => ogeom_geom::Line2d::over(l.axis(), lo, hi).ok()?.into(),
1180 other => {
1181 let (a, b) = (other.point_at(lo, tol).ok()?, other.point_at(hi, tol).ok()?);
1182 BSpline2d::new(
1183 KnotVector::new(vec![lo, lo, hi, hi], 1).ok()?,
1184 vec![a, b],
1185 tol,
1186 )
1187 .ok()?
1188 .into()
1189 }
1190 };
1191 let on_plane = exact_pcurve_over(&cut, (lo, hi), plane, tol)?;
1192 Some(Some((cut, on_extrusion, on_plane)))
1193}
1194
1195fn lands_on_both(
1199 curve: &Curve,
1200 on_plane: &PlanarCurve,
1201 on_extrusion: &PlanarCurve,
1202 plane: &SurfaceGeometry,
1203 extrusion: &ogeom_geom::ExtrusionSurface,
1204 tol: Tolerances,
1205) -> Option<()> {
1206 const STATIONS: u32 = 16;
1207 let (lo, hi) = curve.domain();
1208 for i in 0..=STATIONS {
1209 let t = (hi - lo).mul_add(f64::from(i) / f64::from(STATIONS), lo);
1210 let point = curve.point_at(t, tol).ok()?;
1211 let flat = on_plane.point_at(t, tol).ok()?;
1212 let swept = on_extrusion.point_at(t, tol).ok()?;
1213 let on_plane_at = plane.point_at(flat.x, flat.y, tol).ok()?;
1214 let on_extrusion_at = extrusion.point_at(swept.x, swept.y, tol).ok()?;
1215 if point.distance(on_plane_at) > tol.confusion()
1216 || point.distance(on_extrusion_at) > tol.confusion()
1217 {
1218 return None;
1219 }
1220 }
1221 Some(())
1222}
1223
1224#[cfg(test)]
1225#[allow(clippy::unwrap_used, clippy::expect_used)]
1226mod tests {
1227 use ogeom_geom::{
1228 BSplineCurve, CircleCurve, Curve, Curve2d as _, Curve3d as _, EllipseCurve,
1229 ExtrusionSurface, LineCurve, ParabolaCurve, PlanarCurve, PlaneSurface, Surface as _,
1230 SurfaceGeometry, TrimmedCurve,
1231 };
1232 use ogeom_math::bspline::Weighted;
1233 use ogeom_math::{
1234 Axis, Circle, Direction, Ellipse, Frame, KnotVector, Parabola, Plane, Point, Vector,
1235 };
1236 use proptest::prelude::*;
1237
1238 use crate::{IntersectOptions, SectionCurve, SurfaceIntersection, intersect_surfaces};
1239
1240 const T: ogeom_core::Tolerances = ogeom_core::Tolerances::millimetres();
1241
1242 fn direction(x: f64, y: f64, z: f64) -> Direction {
1243 Direction::new(Vector::new(x, y, z), T).unwrap()
1244 }
1245
1246 fn cut(
1251 profile: Curve,
1252 along: Direction,
1253 sweep: (f64, f64),
1254 at: Point,
1255 normal: Direction,
1256 ) -> (Vec<SectionCurve>, SurfaceGeometry, SurfaceGeometry) {
1257 let extrusion: SurfaceGeometry = ExtrusionSurface::over(profile, along, sweep)
1258 .unwrap()
1259 .into();
1260 let plane: SurfaceGeometry = PlaneSurface::new(Plane::through(at, normal)).into();
1261 let ask = |a: &SurfaceGeometry, b: &SurfaceGeometry| -> Vec<SectionCurve> {
1262 match intersect_surfaces(a, b, IntersectOptions::default(), T).unwrap() {
1263 SurfaceIntersection::Along(found) => found,
1264 SurfaceIntersection::Apart => Vec::new(),
1265 other => panic!("not a section: {other:?}"),
1266 }
1267 };
1268 let first = ask(&plane, &extrusion);
1269 let second = ask(&extrusion, &plane);
1270 assert_eq!(first.len(), second.len());
1271 for (x, y) in first.iter().zip(&second) {
1272 assert_eq!(x.curve, y.curve);
1273 assert_eq!((&x.on_a, &x.on_b), (&y.on_b, &y.on_a));
1274 }
1275 (first, plane, extrusion)
1276 }
1277
1278 fn stray(section: &SectionCurve, plane: &SurfaceGeometry, extrusion: &SurfaceGeometry) -> f64 {
1281 let SurfaceGeometry::Plane(flat) = plane else {
1282 unreachable!()
1283 };
1284 let (lo, hi) = section.curve.domain();
1285 let (Some(on_plane), Some(on_extrusion)) = (§ion.on_a, §ion.on_b) else {
1286 panic!("a section without its traces");
1287 };
1288 (0..=64)
1289 .map(|i| {
1290 let t = (hi - lo).mul_add(f64::from(i) / 64.0, lo);
1291 let p = section.curve.point_at(t, T).unwrap();
1292 let a = on_plane.point_at(t, T).unwrap();
1293 let b = on_extrusion.point_at(t, T).unwrap();
1294 flat.plane()
1295 .signed_distance_to(p)
1296 .abs()
1297 .max(plane.point_at(a.x, a.y, T).unwrap().distance(p))
1298 .max(extrusion.point_at(b.x, b.y, T).unwrap().distance(p))
1299 })
1300 .fold(0.0, f64::max)
1301 }
1302
1303 fn exact_on_both(found: &[SectionCurve], plane: &SurfaceGeometry, extrusion: &SurfaceGeometry) {
1304 for section in found {
1305 assert!(section.exact && section.tolerance == 0.0 && !section.tangential);
1306 let off = stray(section, plane, extrusion);
1307 assert!(off <= 1e-12, "a section stands {off} off");
1308 }
1309 }
1310
1311 #[test]
1314 fn a_swept_line_cut_level_is_the_line_at_that_height() {
1315 let profile: Curve = LineCurve::over(
1316 Axis {
1317 location: Point::ORIGIN,
1318 direction: Direction::X,
1319 },
1320 -2.0,
1321 3.0,
1322 )
1323 .unwrap()
1324 .into();
1325 let (found, plane, extrusion) = cut(
1326 profile,
1327 direction(0.0, 1.0, 1.0),
1328 (-5.0, 5.0),
1329 Point::new(0.0, 0.0, 1.0),
1330 Direction::Z,
1331 );
1332 exact_on_both(&found, &plane, &extrusion);
1333 let [section] = found.as_slice() else {
1334 panic!("{} sections", found.len());
1335 };
1336 assert!(matches!(section.curve, Curve::Line(_)));
1337 let (lo, hi) = section.curve.domain();
1338 let ends = [lo, hi].map(|t| section.curve.point_at(t, T).unwrap());
1339 assert!(ends[0].distance(Point::new(-2.0, 1.0, 1.0)) < 1e-12);
1340 assert!(ends[1].distance(Point::new(3.0, 1.0, 1.0)) < 1e-12);
1341 }
1342
1343 #[test]
1346 fn a_swept_circle_cut_at_a_tilt_is_an_ellipse_of_the_tilt() {
1347 let (radius, tilt) = (2.0, 0.6_f64);
1348 let centre = Point::new(1.0, 0.0, 0.0);
1349 let circle = Circle::new(
1350 Frame::new(centre, Direction::Z, Direction::X, T).unwrap(),
1351 radius,
1352 T,
1353 )
1354 .unwrap();
1355 let (found, plane, extrusion) = cut(
1356 CircleCurve::new(circle).into(),
1357 Direction::Z,
1358 (-10.0, 10.0),
1359 centre,
1360 direction(tilt.sin(), 0.0, tilt.cos()),
1361 );
1362 exact_on_both(&found, &plane, &extrusion);
1363 let [section] = found.as_slice() else {
1364 panic!("{} sections", found.len());
1365 };
1366 let Curve::Ellipse(e) = §ion.curve else {
1367 panic!("not an ellipse: {:?}", section.curve);
1368 };
1369 let e = e.ellipse();
1370 assert!(section.closed);
1371 assert!((e.major_radius() - radius / tilt.cos()).abs() < 1e-12);
1372 assert!((e.minor_radius() - radius).abs() < 1e-12);
1373 assert!(e.centre().distance(centre) < 1e-12);
1374 }
1375
1376 #[test]
1379 fn a_swept_ellipse_cut_parallel_to_it_is_the_ellipse_moved() {
1380 let ellipse = Ellipse::new(Frame::WORLD, 3.0, 1.0, T).unwrap();
1381 let (found, plane, extrusion) = cut(
1382 EllipseCurve::new(ellipse).into(),
1383 direction(1.0, 0.0, 1.0),
1384 (-1.0, 5.0),
1385 Point::new(0.0, 0.0, 2.0),
1386 Direction::Z,
1387 );
1388 exact_on_both(&found, &plane, &extrusion);
1389 let [section] = found.as_slice() else {
1390 panic!("{} sections", found.len());
1391 };
1392 let Curve::Ellipse(e) = §ion.curve else {
1393 panic!("not an ellipse: {:?}", section.curve);
1394 };
1395 let e = e.ellipse();
1396 assert!((e.major_radius() - 3.0).abs() < 1e-12);
1397 assert!((e.minor_radius() - 1.0).abs() < 1e-12);
1398 assert!(e.centre().distance(Point::new(2.0, 0.0, 2.0)) < 1e-12);
1399 }
1400
1401 #[test]
1405 fn a_swept_parabola_cut_at_a_tilt_has_its_focal_length_shortened() {
1406 let (focal, tilt) = (0.5, 0.4_f64);
1407 let parabola = Parabola::new(Frame::WORLD, focal, T).unwrap();
1408 let (found, plane, extrusion) = cut(
1409 ParabolaCurve::over(parabola, -2.0, 2.0).unwrap().into(),
1410 Direction::Z,
1411 (-10.0, 10.0),
1412 Point::ORIGIN,
1413 direction(-tilt.sin(), 0.0, tilt.cos()),
1414 );
1415 exact_on_both(&found, &plane, &extrusion);
1416 let [section] = found.as_slice() else {
1417 panic!("{} sections", found.len());
1418 };
1419 let Curve::Parabola(p) = §ion.curve else {
1420 panic!("not a parabola: {:?}", section.curve);
1421 };
1422 assert!((p.parabola().focal() - focal * tilt.cos()).abs() < 1e-12);
1423 assert!(p.parabola().apex().distance(Point::ORIGIN) < 1e-12);
1424 }
1425
1426 #[test]
1430 fn a_plane_along_the_sweep_cuts_it_in_rulings() {
1431 let circle = Circle::new(Frame::WORLD, 2.0, T).unwrap();
1432 let (found, plane, extrusion) = cut(
1433 CircleCurve::new(circle).into(),
1434 Direction::Z,
1435 (0.0, 3.0),
1436 Point::new(1.0, 0.0, 0.0),
1437 Direction::X,
1438 );
1439 exact_on_both(&found, &plane, &extrusion);
1440 assert_eq!(found.len(), 2);
1441 let third = core::f64::consts::FRAC_PI_3;
1442 for (section, (y, u)) in found
1443 .iter()
1444 .zip([(3.0_f64.sqrt(), third), (-(3.0_f64.sqrt()), 5.0 * third)])
1445 {
1446 let Curve::Line(line) = §ion.curve else {
1447 panic!("not a ruling: {:?}", section.curve);
1448 };
1449 assert!(line.axis().direction.is_parallel(Direction::Z, T));
1450 let (lo, hi) = section.curve.domain();
1451 let ends = [lo, hi].map(|t| section.curve.point_at(t, T).unwrap());
1452 assert!(ends[0].distance(Point::new(1.0, y, 0.0)) < 1e-12);
1453 assert!(ends[1].distance(Point::new(1.0, y, 3.0)) < 1e-12);
1454 let Some(PlanarCurve::Line(chart)) = §ion.on_b else {
1455 panic!("a ruling charted as {:?}", section.on_b);
1456 };
1457 assert!((chart.axis().location.x - u).abs() < 1e-12);
1458 }
1459 }
1460
1461 #[test]
1465 fn a_section_leaving_the_sweep_is_cut_to_it() {
1466 let circle = Circle::new(Frame::WORLD, 1.0, T).unwrap();
1467 let (found, plane, extrusion) = cut(
1468 CircleCurve::new(circle).into(),
1469 Direction::Z,
1470 (0.0, 1.0),
1471 Point::new(0.0, 0.0, 0.5),
1472 direction(-1.0, 0.0, 1.0),
1473 );
1474 exact_on_both(&found, &plane, &extrusion);
1475 assert_eq!(found.len(), 2);
1476 for section in &found {
1477 assert!(!section.closed);
1478 let (lo, hi) = section.curve.domain();
1479 let mut heights = [lo, hi].map(|t| section.curve.point_at(t, T).unwrap().z);
1480 heights.sort_by(f64::total_cmp);
1481 assert!(heights[0].abs() < 1e-12 && (heights[1] - 1.0).abs() < 1e-12);
1482 }
1483 }
1484
1485 #[test]
1490 fn an_arc_across_the_profile_s_start_is_one_section() {
1491 let circle = Circle::new(Frame::WORLD, 1.0, T).unwrap();
1492 let (found, plane, extrusion) = cut(
1493 CircleCurve::new(circle).into(),
1494 Direction::Z,
1495 (0.0, 1.0),
1496 Point::new(0.0, 0.0, 0.5),
1497 direction(0.0, -0.8, 1.0),
1498 );
1499 exact_on_both(&found, &plane, &extrusion);
1500 assert_eq!(found.len(), 2);
1501 let across = found
1502 .iter()
1503 .filter(|section| {
1504 let (lo, hi) = section.curve.domain();
1505 let at = |t: f64| section.curve.point_at(t, T).unwrap();
1506 let mid = at(f64::midpoint(lo, hi));
1507 let ends = [at(lo).z, at(hi).z];
1508 assert!(ends.iter().any(|z| z.abs() < 1e-12));
1509 assert!(ends.iter().any(|z| (z - 1.0).abs() < 1e-12));
1510 mid.distance(Point::new(1.0, 0.0, 0.5)) < 1e-12
1511 })
1512 .count();
1513 assert_eq!(across, 1);
1514 }
1515
1516 #[test]
1522 fn a_swept_arc_spline_cut_at_a_tilt_is_the_arc_lifted_to_the_plane() {
1523 let (radius, tilt) = (2.0, 0.6_f64);
1524 let centre = Point::new(1.0, 0.0, 0.0);
1525 let circle = Circle::new(
1526 Frame::new(centre, Direction::Z, Direction::X, T).unwrap(),
1527 radius,
1528 T,
1529 )
1530 .unwrap();
1531 let arc: Curve = Curve::from(CircleCurve::new(circle))
1532 .to_bspline_over((0.3, 2.5), T)
1533 .unwrap()
1534 .into();
1535 let (found, plane, extrusion) = cut(
1536 arc.clone(),
1537 Direction::Z,
1538 (-10.0, 10.0),
1539 centre,
1540 direction(tilt.sin(), 0.0, tilt.cos()),
1541 );
1542 exact_on_both(&found, &plane, &extrusion);
1543 let [section] = found.as_slice() else {
1544 panic!("{} sections", found.len());
1545 };
1546 assert!(matches!(section.curve, Curve::BSpline(_)));
1547 assert_eq!(section.curve.domain(), arc.domain());
1548 let (lo, hi) = arc.domain();
1549 for i in 0..=32 {
1550 let t = (hi - lo).mul_add(f64::from(i) / 32.0, lo);
1551 let p = section.curve.point_at(t, T).unwrap();
1552 let q = arc.point_at(t, T).unwrap();
1553 let lifted = Point::new(q.x, q.y, -tilt.tan() * (q.x - 1.0));
1554 assert!(p.distance(lifted) < 1e-12);
1555 let (x, y) = ((p.x - 1.0).mul_add(tilt.cos(), -p.z * tilt.sin()), p.y);
1556 let on = (x * tilt.cos() / radius).powi(2) + (y / radius).powi(2);
1557 assert!((on - 1.0).abs() < 1e-12);
1558 }
1559 }
1560
1561 #[test]
1567 fn a_swept_cubic_cut_level_is_the_cubic_carried_along_the_sweep() {
1568 let control = vec![
1569 Point::new(0.0, 0.0, 0.0),
1570 Point::new(1.0, 2.0, 0.5),
1571 Point::new(2.0, -1.0, -0.4),
1572 Point::new(3.0, 1.0, 0.3),
1573 Point::new(4.0, 0.0, 0.0),
1574 ];
1575 let cubic: Curve =
1576 BSplineCurve::new(KnotVector::clamped_uniform(3, 5).unwrap(), control, T)
1577 .unwrap()
1578 .into();
1579 let along = direction(0.0, 1.0, 1.0);
1580 let (found, plane, extrusion) = cut(
1581 cubic.clone(),
1582 along,
1583 (-5.0, 5.0),
1584 Point::new(0.0, 0.0, 1.0),
1585 Direction::Z,
1586 );
1587 exact_on_both(&found, &plane, &extrusion);
1588 let [section] = found.as_slice() else {
1589 panic!("{} sections", found.len());
1590 };
1591 let Some(trace) = §ion.on_b else {
1592 panic!("a section without its trace");
1593 };
1594 let d = along.vector();
1595 let (lo, hi) = cubic.domain();
1596 for i in 0..=32 {
1597 let t = (hi - lo).mul_add(f64::from(i) / 32.0, lo);
1598 let q = cubic.point_at(t, T).unwrap();
1599 let height = (1.0 - q.z) / d.z;
1600 assert!(
1601 section
1602 .curve
1603 .point_at(t, T)
1604 .unwrap()
1605 .distance(q + d * height)
1606 < 1e-12
1607 );
1608 let uv = trace.point_at(t, T).unwrap();
1609 assert!((uv.x - t).abs() < 1e-12 && (uv.y - height).abs() < 1e-12);
1610 }
1611 let plane: SurfaceGeometry =
1612 PlaneSurface::new(Plane::through(Point::new(0.0, 0.0, 1.0), Direction::Z)).into();
1613 let short: SurfaceGeometry = ExtrusionSurface::over(cubic.clone(), along, (-5.0, 0.5))
1614 .unwrap()
1615 .into();
1616 assert_eq!(
1617 super::plane_extrusion(&plane, &short, T).map(|f| f.len()),
1618 Some(0)
1619 );
1620 let straddling: SurfaceGeometry = ExtrusionSurface::over(cubic, along, (-5.0, 1.0))
1621 .unwrap()
1622 .into();
1623 assert!(super::plane_extrusion(&plane, &straddling, T).is_none());
1624 }
1625
1626 #[test]
1632 fn a_swept_cubic_straddling_its_rim_is_split_there() {
1633 let control = vec![
1634 Point::new(0.0, 0.0, 0.0),
1635 Point::new(1.0, 2.0, 0.5),
1636 Point::new(2.0, -1.0, -0.4),
1637 Point::new(3.0, 1.0, 0.3),
1638 Point::new(4.0, 0.0, 0.0),
1639 ];
1640 let cubic: Curve =
1641 BSplineCurve::new(KnotVector::clamped_uniform(3, 5).unwrap(), control, T)
1642 .unwrap()
1643 .into();
1644 let along = direction(0.0, 1.0, 1.0);
1645 let plane: SurfaceGeometry =
1646 PlaneSurface::new(Plane::through(Point::new(0.0, 0.0, 1.0), Direction::Z)).into();
1647 let top = 1.6;
1648 let extrusion: SurfaceGeometry = ExtrusionSurface::over(cubic.clone(), along, (-5.0, top))
1649 .unwrap()
1650 .into();
1651 assert!(super::crosses_extrusion_rim(&plane, &extrusion, T));
1652 let found = super::plane_extrusion_at_rims(&plane, &extrusion, T).unwrap();
1653 assert!(!found.is_empty());
1654 exact_on_both(&found, &plane, &extrusion);
1655 let height = |t: f64| (1.0 - cubic.point_at(t, T).unwrap().z) / along.vector().z;
1656 let (lo, hi) = cubic.domain();
1657 let mut spans = Vec::new();
1658 for section in &found {
1659 let trace = section.on_b.as_ref().unwrap();
1660 let (a, b) = trace.domain();
1661 for end in [a, b] {
1662 let uv = trace.point_at(end, T).unwrap();
1663 assert!(
1664 (uv.x - lo).abs() < 1e-12
1665 || (uv.x - hi).abs() < 1e-12
1666 || (uv.y - top).abs() < 1e-12,
1667 "an end at {uv:?} neither on the profile's end nor on the rim"
1668 );
1669 }
1670 spans.push((
1671 trace.point_at(a, T).unwrap().x,
1672 trace.point_at(b, T).unwrap().x,
1673 ));
1674 }
1675 for i in 0..=400 {
1676 let t = (hi - lo).mul_add(f64::from(i) / 400.0, lo);
1677 if height(t) >= top - 1e-9 {
1678 continue;
1679 }
1680 assert!(
1681 spans
1682 .iter()
1683 .any(|&(a, b)| t >= a.min(b) - 1e-12 && t <= a.max(b) + 1e-12),
1684 "t {t} at height {} is on no section",
1685 height(t)
1686 );
1687 }
1688 }
1689
1690 #[test]
1703 fn a_rational_arc_grazing_its_rim_is_split_where_the_plane_crosses_it() {
1704 let (radius, lean, depth) = (5.0_f64, 1e-5_f64, 1e-4_f64);
1705 let centre = Point::new(5.0, 10.0, 0.0);
1706 let circle = Circle::new(
1707 Frame::new(centre, Direction::Z, Direction::X, T).unwrap(),
1708 radius,
1709 T,
1710 )
1711 .unwrap();
1712 let arc: Curve = Curve::from(CircleCurve::new(circle))
1713 .to_bspline_over((0.0, core::f64::consts::PI), T)
1714 .unwrap()
1715 .into();
1716 let chord = radius - depth;
1717 let plane: SurfaceGeometry = PlaneSurface::new(Plane::through(
1718 Point::new(5.0, 10.0 + chord, 10.0),
1719 direction(0.0, -lean.sin(), lean.cos()),
1720 ))
1721 .into();
1722 let extrusion: SurfaceGeometry = ExtrusionSurface::over(arc, Direction::Z, (0.0, 10.0))
1723 .unwrap()
1724 .into();
1725 let found = super::plane_extrusion_at_rims(&plane, &extrusion, T).unwrap();
1726 exact_on_both(&found, &plane, &extrusion);
1727 assert_eq!(found.len(), 2);
1728 let half = radius.mul_add(radius, -chord * chord).sqrt();
1729 let mut crossed = Vec::new();
1730 for section in &found {
1731 let (lo, hi) = section.curve.domain();
1732 let ends = [lo, hi].map(|t| section.curve.point_at(t, T).unwrap());
1733 let (base, rim): (Vec<Point>, Vec<Point>) =
1734 ends.iter().partition(|p| (p.y - 10.0).abs() < 1e-12);
1735 assert_eq!((base.len(), rim.len()), (1, 1), "ends {ends:?}");
1736 assert!((rim[0].z - 10.0).abs() < 1e-12);
1737 crossed.push(rim[0].x);
1738 }
1739 crossed.sort_by(f64::total_cmp);
1740 assert!((crossed[0] - (5.0 - half)).abs() < 1e-6, "{crossed:?}");
1741 assert!((crossed[1] - (5.0 + half)).abs() < 1e-6, "{crossed:?}");
1742 assert!(super::plane_extrusion(&plane, &extrusion, T).is_none());
1743 }
1744
1745 fn profile() -> impl Strategy<Value = Curve> {
1748 let point = || (-2.0..2.0f64, -2.0..2.0f64, -2.0..2.0f64);
1749 let frame = (point(), -3.0..3.0f64, -3.0..3.0f64).prop_map(|((x, y, z), a, b)| {
1750 let axis = direction(a.cos() * b.sin(), a.sin() * b.sin(), b.cos());
1751 let lean = Vector::new(1.0, 0.3, -0.2);
1752 let x_axis = Direction::new(lean - axis.vector() * lean.dot(axis.vector()), T).unwrap();
1753 Frame::new(Point::new(x, y, z), axis, x_axis, T).unwrap()
1754 });
1755 let kind = 0..5u32;
1756 let trim = proptest::option::of((0.05..0.45f64, 0.55..0.95f64));
1757 (kind, frame, 0.3..2.0f64, 0.2..1.0f64, trim).prop_map(
1758 |(kind, frame, size, ratio, trim)| {
1759 let curve: Curve = match kind {
1760 0 => LineCurve::over(
1761 Axis {
1762 location: frame.origin(),
1763 direction: frame.x(),
1764 },
1765 -size,
1766 size * 2.0,
1767 )
1768 .unwrap()
1769 .into(),
1770 1 => CircleCurve::new(Circle::new(frame, size, T).unwrap()).into(),
1771 2 => EllipseCurve::new(Ellipse::new(frame, size, size * ratio, T).unwrap())
1772 .into(),
1773 3 => ParabolaCurve::over(
1774 Parabola::new(frame, size * ratio, T).unwrap(),
1775 -size,
1776 size,
1777 )
1778 .unwrap()
1779 .into(),
1780 _ => {
1782 let control = (0..6u32)
1783 .map(|i| {
1784 let k = f64::from(i);
1785 let p = frame.origin()
1786 + frame.x().vector() * (size * (k - 2.5))
1787 + frame.y().vector() * (size * ratio * (k * 1.7).sin())
1788 + frame.z().vector() * (size * 0.3 * (k * 0.9).cos());
1789 Weighted::new(p, 1.0 + ratio * f64::from(i % 2), T).unwrap()
1790 })
1791 .collect();
1792 BSplineCurve::rational(KnotVector::clamped_uniform(3, 6).unwrap(), control)
1793 .unwrap()
1794 .into()
1795 }
1796 };
1797 match trim {
1798 Some((a, b)) => {
1799 let (lo, hi) = curve.domain();
1800 let at = |f: f64| (hi - lo).mul_add(f, lo);
1801 TrimmedCurve::new(curve, at(a), at(b), T).unwrap().into()
1802 }
1803 None => curve,
1804 }
1805 },
1806 )
1807 }
1808
1809 proptest! {
1810 #[test]
1815 fn a_carried_section_lies_on_both_and_misses_nothing(
1816 profile in profile(),
1817 (a, b) in (-3.0..3.0f64, 0.0..3.0f64),
1818 (c, e) in (-3.0..3.0f64, 0.0..3.0f64),
1819 at in (-1.0..1.0f64, -1.0..1.0f64, -1.0..1.0f64),
1820 sweep in (-4.0..-0.5f64, 0.5..4.0f64),
1821 ) {
1822 let along = direction(a.cos() * b.sin(), a.sin() * b.sin(), b.cos());
1823 let normal = direction(c.cos() * e.sin(), c.sin() * e.sin(), e.cos());
1824 prop_assume!(normal.vector().dot(along.vector()).abs() >= 0.2);
1825 let at = Point::new(at.0, at.1, at.2);
1826 let extrusion = ExtrusionSurface::over(profile.clone(), along, sweep).unwrap();
1827 let plane_surface: SurfaceGeometry = PlaneSurface::new(Plane::through(at, normal)).into();
1828 let swept: SurfaceGeometry = extrusion.clone().into();
1829 let Some(found) = super::plane_extrusion(&plane_surface, &swept, T)
1830 .or_else(|| super::plane_extrusion_at_rims(&plane_surface, &swept, T))
1831 else {
1832 return Ok(());
1835 };
1836 let extrusion_surface: SurfaceGeometry = extrusion.into();
1837 for section in &found {
1838 prop_assert!(stray(section, &plane_surface, &extrusion_surface) <= 1e-12);
1839 }
1840 let spans: Vec<(f64, f64)> = found
1843 .iter()
1844 .map(|s| {
1845 let trace = s.on_b.as_ref().unwrap();
1846 let (lo, hi) = trace.domain();
1847 let (p, q) = (trace.point_at(lo, T).unwrap().x, trace.point_at(hi, T).unwrap().x);
1848 (p.min(q), p.max(q))
1849 })
1850 .collect();
1851 let (lo, hi) = profile.domain();
1852 let period = core::f64::consts::TAU;
1853 for i in 0..=200 {
1854 let u = (hi - lo).mul_add(f64::from(i) / 200.0, lo);
1855 let p = profile.point_at(u, T).unwrap();
1856 let v = normal.vector().dot(at - p) / normal.vector().dot(along.vector());
1857 if v <= sweep.0 + 1e-6 || v >= sweep.1 - 1e-6 {
1858 continue;
1859 }
1860 let covered = spans.iter().any(|&(s0, s1)| {
1861 [0.0, period, -period].iter().any(|k| u + k >= s0 - 1e-9 && u + k <= s1 + 1e-9)
1862 });
1863 prop_assert!(covered, "u {u} at height {v} is on no section");
1864 }
1865 }
1866 }
1867
1868 fn drum_and_side(y: f64) -> (SurfaceGeometry, SurfaceGeometry) {
1871 let drum: SurfaceGeometry = ogeom_geom::CylinderSurface::new(
1872 ogeom_math::Cylinder::new(Frame::WORLD, 20.0, T).unwrap(),
1873 (0.0, 1.0),
1874 )
1875 .unwrap()
1876 .into();
1877 let side = BSplineCurve::new(
1878 KnotVector::clamped_uniform(1, 2).unwrap(),
1879 vec![Point::new(-1.0, y, 0.0), Point::new(1.0, y, 0.0)],
1880 T,
1881 )
1882 .unwrap();
1883 let swept: SurfaceGeometry =
1884 ExtrusionSurface::over(side.into(), direction(0.0, 0.0, 1.0), (0.0, 1.0))
1885 .unwrap()
1886 .into();
1887 (drum, swept)
1888 }
1889
1890 #[test]
1895 fn a_spline_side_crosses_a_drum_on_rulings_at_its_crossings() {
1896 for d in [1e-2, 1e-3, 1e-5] {
1897 let (drum, swept) = drum_and_side(20.0 - d);
1898 let want = d.mul_add(-d, 40.0 * d).sqrt();
1899 for (a, b) in [(&drum, &swept), (&swept, &drum)] {
1900 let found = super::drum_extrusion(a, b, T).unwrap();
1901 assert_eq!(found.len(), 2, "dip {d}");
1902 for (section, sign) in found.iter().zip([-1.0, 1.0]) {
1903 let Curve::Line(line) = §ion.curve else {
1904 panic!("dip {d}: a ruling is a line");
1905 };
1906 let at = line.axis().location;
1907 assert!(
1908 (at.x - sign * want).abs() <= 1e-10,
1909 "dip {d}: x {} against {}",
1910 at.x,
1911 sign * want
1912 );
1913 assert!(section.exact && section.tolerance == 0.0);
1914 let (lo, hi) = section.curve.domain();
1915 assert!(
1916 lo.abs() <= 1e-12 && (hi - 1.0).abs() <= 1e-12,
1917 "dip {d}: {lo}..{hi}"
1918 );
1919 let (Some(on_a), Some(on_b)) = (§ion.on_a, §ion.on_b) else {
1920 panic!("dip {d}: a section without its traces");
1921 };
1922 for t in [lo, f64::midpoint(lo, hi), hi] {
1923 let p = section.curve.point_at(t, T).unwrap();
1924 let (ua, ub) = (on_a.point_at(t, T).unwrap(), on_b.point_at(t, T).unwrap());
1925 assert!(a.point_at(ua.x, ua.y, T).unwrap().distance(p) <= 1e-11);
1926 assert!(b.point_at(ub.x, ub.y, T).unwrap().distance(p) <= 1e-11);
1927 }
1928 }
1929 }
1930 }
1931 }
1932
1933 #[test]
1937 fn a_spline_side_touching_a_drum_is_left_to_the_marcher() {
1938 for y in [20.0, 399.0_f64.sqrt()] {
1939 let (drum, swept) = drum_and_side(y);
1940 assert!(super::drum_extrusion(&drum, &swept, T).is_none(), "y {y}");
1941 }
1942 let (drum, swept) = drum_and_side(20.5);
1943 assert!(super::drum_extrusion(&drum, &swept, T).unwrap().is_empty());
1944 }
1945}