Skip to main content

ogeom_intersect/
extrusion.rs

1//! A plane across a linear extrusion, in closed form.
2//!
3//! An extrusion sweeps a curve `C` along a unit direction `d`: its point at
4//! `(u, v)` is `C(u) + v d`. A plane through `o` with unit normal `n` holds
5//! that point where `v = n·(o − C(u)) / n·d`, so where `n·d` is not zero the
6//! section is `C` carried along `d` onto the plane, at `C`'s own parameter:
7//! the image of `C` under an affine map. An affine map takes a line to a
8//! line, a circle or an ellipse to an ellipse, a parabola to a parabola,
9//! and a B-spline, polynomial or rational, to the B-spline of its control
10//! points carried, on its own knots and weights. Where `n·d` is zero the
11//! plane holds `d`, and the section is the rulings through `C`'s crossings
12//! with the plane.
13//!
14//! Each section is cut to the extrusion's own window: to the stretches of
15//! `C`'s domain where the height `v` stays within the sweep, found as `C`'s
16//! crossings with the plane moved to each end of the sweep, each a root of
17//! a closed form. A B-spline's crossings have none, and its section is
18//! taken only where its control points' heights all lie within the sweep,
19//! which holds every point's. Where they straddle an end,
20//! [`plane_extrusion_at_rims`] splits it at its crossings with the rim
21//! there, roots of a polynomial B-spline found to rounding, for a caller
22//! that asks.
23//!
24//! The pair is left to the marcher, as before, wherever the closed form
25//! does not hold cleanly: a profile this does not map (a helix, an offset,
26//! a hyperbola, whose chart trace on the extrusion no planar curve states,
27//! a reversed trim, a periodic B-spline), a B-spline whose heights straddle
28//! an end of the sweep or that a plane holding `d` cuts, a profile lying in
29//! a plane it is cut by, a crossing it only touches, a plane or a ruling
30//! the extrusion meets within the sine the marcher takes as tangent, or a
31//! section that does not land on both surfaces within the confusion
32//! distance when checked.
33//!
34//! A cylinder whose axis runs along the sweep meets an extrusion of a
35//! B-spline profile in the rulings through the profile's crossings with
36//! it, found the same way ([`drum_extrusion`]).
37
38use 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
51/// Where a plane meets an extrusion, exactly: `None` where the pair is not
52/// a plane and an extrusion, or the closed form does not hold cleanly (see
53/// the module's notes). An empty answer is a plane missing the extrusion's
54/// window.
55pub(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/// Where a plane meets an extrusion of a B-spline profile whose heights to
64/// the plane straddle an end of the sweep, the section split where it
65/// crosses the rim there: `None` for any other pair, which
66/// [`intersect_surfaces`](crate::intersect_surfaces) answers as before,
67/// and where a crossing is not resolved cleanly.
68///
69/// The profile's crossings with the plane moved to the sweep's end are
70/// the roots of a polynomial B-spline, isolated on its Bernstein
71/// coefficients and bisected to rounding; each stretch of the profile whose height stays
72/// within the sweep is carried onto the plane in closed form and ends
73/// exactly on the rim, and a stretch past the end, where the section
74/// leaves the extrusion, is dropped.
75#[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/// Whether `a` and `b` are a plane and an extrusion of a B-spline profile
88/// whose control points' heights to the plane straddle an end of the
89/// sweep: the pair [`plane_extrusion_at_rims`] answers, and
90/// [`intersect_surfaces`](crate::intersect_surfaces) leaves to the
91/// marcher.
92#[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
103/// The carry of a plane across an extrusion of a B-spline profile, the
104/// profile and the sweep, where the pair is one and the plane crosses the
105/// sweep.
106fn 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
133/// Where a cylinder meets an extrusion of a B-spline profile swept along
134/// its axis, exactly: the rulings through the profile's crossings with the
135/// cylinder, each over the sweep and cut to the cylinder's window. `None`
136/// where the pair is not one, the sweep leans off the axis by more than
137/// the confusion distance over its reach, or a crossing is not resolved
138/// cleanly; an empty answer is a profile missing the cylinder.
139///
140/// A point `P` of the profile lies on the cylinder where `X² + Y² = r²`,
141/// `X` and `Y` its coordinates across the axis in the cylinder's frame.
142/// With the profile's weights `w`, `w²(X² + Y² − r²)` is a polynomial
143/// B-spline of twice the degree whose sign is the condition's, its
144/// Bernstein coefficients over each Bézier piece the products of the
145/// piece's `wX`, `wY` and `w` (see [`level_crossings`] for how its roots
146/// are isolated). A root isolated that way is simple, so the profile
147/// crosses the cylinder there however shallow the angle, as a line
148/// crossing it does in [`surface_surface`](crate::surface_surface)'s
149/// closed form.
150pub(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    // An end of the profile on the cylinder is the extrusion's border
175    // lying in it, its sign there rounding's: left to the marcher.
176    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    // Each coefficient is a blend of products of the control points'
195    // coordinates across the axis, each rounded to their size and to the
196    // origin's.
197    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
266/// The Bernstein coefficients of the product of two polynomials given by
267/// theirs over the same interval: `cₖ = Σ C(m, i) C(n, k − i) aᵢ bₖ₋ᵢ /
268/// C(m + n, k)`, of degree `m + n`.
269fn 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
288/// [`plane_extrusion`], with a B-spline profile straddling an end of the
289/// sweep split at the rim there where `at_rims` holds.
290fn 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        // A spline profile's crossings with a plane holding `d` have no
312        // closed form, so its rulings are not taken here.
313        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
324/// The sections of an extrusion of a profile in closed form by the plane
325/// through `o` with normal `n`, the extrusion swept along `d`.
326fn 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        // The extrusion's normal is square to `d`, so the sine between it
341        // and `n` is at least `|n·d|`: past the tangency the marcher and
342        // the contact handling answer, the two cross at an angle all
343        // along every section.
344        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
352/// The pieces checked against both surfaces and stated as sections, each
353/// trace on the surface asked first first.
354fn 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    // Each piece checked against both surfaces, kept where it reaches the
362    // plane's window, and cut to it where it is straight.
363    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
397/// The profile in the forms the map acts on, each at the profile's own
398/// parameter `u`.
399enum Profile {
400    /// `p + u t`, `t` of unit length.
401    Line { p: Point, t: Vector },
402    /// `c + cos u · x + sin u · y`: a circle or an ellipse.
403    Conic { c: Point, x: Vector, y: Vector },
404    /// `apex + u² · x + u · y`.
405    Parabola { apex: Point, x: Vector, y: Vector },
406}
407
408impl Profile {
409    /// The profile of `curve` over `window`, the window, and whether that
410    /// is a whole turn of a periodic profile. `None` for a profile this
411    /// does not map.
412    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    /// The point at `u`.
459    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    /// The first derivative at `u`.
468    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    /// Where `n·C(u) = k` over `window`, in order. `None` where the
477    /// profile lies within the confusion distance of that plane along a
478    /// stretch, or touches it, or two crossings stand closer than the
479    /// parametric confusion: there the plane is no clean crossing.
480    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                // `A + B cos u + C sin u`: `R cos(u − φ) = −A`.
502                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
547/// A clamped B-spline profile over the extrusion's window, polynomial or
548/// rational: carried onto a plane, its control points carried with their
549/// weights are the image's, since the carry is affine and the weights are
550/// positive.
551struct SplineProfile {
552    knots: KnotVector,
553    control: Vec<Weighted<Point>>,
554}
555
556impl SplineProfile {
557    /// The profile of `curve` cut to `window`, where it is a B-spline, a
558    /// forward trim of one, and not periodic.
559    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    /// The profile carried onto the plane, with its chart trace on the
577    /// extrusion: nothing where every control point's height, and so every
578    /// point's, falls outside the sweep on one side. Where the heights
579    /// straddle an end of the sweep, `None` unless `at_rims` holds, and
580    /// then each stretch whose height stays within the sweep, split at its
581    /// crossings with the rims (see [`level_crossings`]).
582    fn carried(
583        &self,
584        carry: &Carry,
585        sweep: (f64, f64),
586        at_rims: bool,
587        tol: Tolerances,
588    ) -> Option<Vec<(Curve, PlanarCurve)>> {
589        // A point of the profile is a convex blend of its control points,
590        // and so is its height of theirs.
591        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    /// Each control point's height to the plane.
639    fn heights(&self, carry: &Carry) -> Vec<f64> {
640        self.control
641            .iter()
642            .map(|w| carry.height(w.point()))
643            .collect()
644    }
645
646    /// The whole profile carried onto the plane, with its chart trace.
647    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    /// The chart trace `(u, v(u))` at the profile's own parameter, where
667    /// `v` is the height blended as the profile's points are. Over each
668    /// Bézier piece on `[a, b]` with weights `wₖ`, `v` is the blend of the
669    /// heights with those weights, and `u` is `u W(u) / W(u)`, `u W(u)` one
670    /// degree up with coefficients `((p + 1 − k) wₖ a + k wₖ₋₁ b) / (p + 1)`.
671    /// The pieces join on their shared ends.
672    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
718/// A clamped B-spline cut to `(lo, hi)` within its domain.
719fn 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
734/// Where a B-spline profile's height to the plane is `level`, in order:
735/// its crossings with the plane moved `level` along the sweep, which on
736/// the extrusion is the rim the profile sweeps to there.
737///
738/// The height less `level` is `Σ Nₖ wₖ (vₖ − level) / Σ Nₖ wₖ`, so its sign
739/// is that of a polynomial B-spline with coefficients `wₖ (vₖ − level)`, a
740/// blend of the control points' heights with their weights. Over each
741/// Bézier piece its values lie within its coefficients' hull, and the
742/// number of the coefficients' sign changes bounds its roots from above
743/// with the same parity (Bernstein's form of Descartes' rule). A piece
744/// whose coefficients keep one sign holds no root, one with a single sign
745/// change and its ends of opposite signs holds exactly one, bisected down
746/// to rounding, and any other is halved by de Casteljau's construction.
747/// `None` where a piece is halved down to the parametric confusion
748/// without settling (a profile touching the level there, or meeting it at
749/// a piece's joint), or where every coefficient of a piece is within the
750/// rounding they were computed to (a profile lying in the level).
751fn 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    // Each coefficient is a difference of two heights, rounded to the
766    // larger's last bits, and so is every blend of them.
767    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
784/// The roots over `(a, b)` of the polynomial whose Bernstein coefficients
785/// over it are `values`, pushed onto `found` (see [`level_crossings`]).
786fn 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
829/// Bernstein coefficients split at `s` by de Casteljau's construction:
830/// those of the stretch before it and after it, the first's last value
831/// the polynomial's at `s`.
832fn 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
849/// The sections of an extrusion by a plane holding its direction: the
850/// rulings through the profile's crossings with the plane, each over the
851/// whole sweep and charted at its `u`. A crossing at an open end of the
852/// profile is the extrusion's border lying in the plane, and is kept where
853/// the extrusion leaves the plane there at an angle.
854fn 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    // An end of the profile on the plane to within the confusion distance
867    // is a crossing there, whichever side rounding left it.
868    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
909/// The affine map carrying a point along `d` onto the plane through `o`
910/// with normal `n`, where `along = n·d` is not zero.
911struct Carry {
912    n: Vector,
913    o: Point,
914    d: Vector,
915    along: f64,
916}
917
918impl Carry {
919    /// How far along `d` the point `p` is carried to reach the plane.
920    fn height(&self, p: Point) -> f64 {
921        self.n.dot(self.o - p) / self.along
922    }
923
924    /// The point `p` carried onto the plane.
925    fn point(&self, p: Point) -> Point {
926        p + self.d * self.height(p)
927    }
928
929    /// A vector carried as the difference of two points is.
930    fn vector(&self, w: Vector) -> Vector {
931        w - self.d * (self.n.dot(w) / self.along)
932    }
933}
934
935/// The sections of an extrusion by a plane across its direction: the
936/// profile carried onto the plane, over each stretch of the profile whose
937/// height stays within the sweep, each with its chart trace on the
938/// extrusion.
939fn 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    // Round a whole turn, a stretch ending on its end runs on into one
966    // starting on its start.
967    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
989/// The profile carried onto the plane, in its own spelling.
990enum Image {
991    /// A line, at `scale` times the profile's parameter.
992    Line { axis: Axis, scale: f64 },
993    /// An ellipse or a circle, at the profile's parameter less `phase`.
994    Conic { curve: Curve, phase: f64 },
995    /// A parabola, at `scale` times the profile's parameter less `vertex`.
996    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                // Conjugate half-diameters `p`, `q`; turned by `phase` they
1019                // are the principal ones, `p cos φ + q sin φ` the major.
1020                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                // `V + s² p + s q⊥` from the vertex, where the tangent is
1043                // square to the axis `p`.
1044                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    /// The image over the profile's stretch `(lo, hi)`, the whole curve
1064    /// where `whole`, and its chart trace on the extrusion: `(u, v)` at
1065    /// the image's own parameter.
1066    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                // `v = α + β cos u + γ sin u` at `u = t + phase`.
1089                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                // Linear in `u`, quadratic in `v`: one quadratic Bézier, its
1130                // middle control point where the end tangents meet.
1131                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/// The piece and its chart traces cut to the plane's window: a straight
1151/// one to the stretch the window holds, any other kept whole where it
1152/// reaches the window and dropped where it does not, as the closed forms'
1153/// sections are. `Some(None)` for a piece wholly off the window, `None`
1154/// where a trace has no closed form.
1155#[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
1195/// Whether a section lands where it says: on the plane, and on each
1196/// surface at the chart point its trace gives, within the confusion
1197/// distance at stations along it. `None` where it does not.
1198fn 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    /// The sections of `profile` swept along `along` over `sweep` by the
1247    /// plane through `at` square to `normal`, asked with the plane first;
1248    /// asked the other way round they must be the same curves, their
1249    /// traces swapped.
1250    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    /// The farthest a section stands off either surface, measured at its
1279    /// own points and at each trace's chart point, at stations along it.
1280    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)) = (&section.on_a, &section.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    /// A line swept along a slant and cut level is the line where the
1312    /// sweep reaches the plane's height, over the profile's own stretch.
1313    #[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    /// A drum's circle swept along its axis and cut at a tilt `θ` is the
1344    /// closed ellipse of radii `R / cos θ` and `R` about the axis.
1345    #[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) = &section.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    /// An ellipse swept along a slant and cut by a plane parallel to its
1377    /// own is the same ellipse, moved along the slant to the plane.
1378    #[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) = &section.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    /// A parabola of focal length `f` swept square to its plane and cut at
1402    /// a tilt `θ` about its axis's normal is the parabola of focal length
1403    /// `f cos θ` from the same apex: its axis stretched by `1 / cos θ`.
1404    #[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) = &section.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    /// A plane holding the sweep's direction cuts it in the rulings
1427    /// through the profile's crossings with it, over the whole sweep, each
1428    /// charted at the angle it stands at.
1429    #[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) = &section.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)) = &section.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    /// A circle swept over `[0, 1]` and cut by `z = 1/2 + x`: the height
1462    /// `1/2 + cos u` leaves the sweep, and what stays is the two arcs over
1463    /// `cos u` in `[−1/2, 1/2]`, each ending on the sweep's two rims.
1464    #[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    /// Cut by `z = 1/2 + 4y/5`, the circle swept over `[0, 1]` keeps
1486    /// `sin u` in `[−5/8, 5/8]`: two arcs, one through `(1, 0, 1/2)` across
1487    /// the profile's own start, stated as one section rather than two
1488    /// halves.
1489    #[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    /// A circle's arc spelled as a rational quadratic B-spline, swept along
1517    /// its axis and cut at a tilt `θ`: every point of the section lies on
1518    /// the drum and in the plane, on the ellipse of radii `R / cos θ` and
1519    /// `R`, and the section at the profile's parameter `t` is the arc's
1520    /// point there lifted to the plane.
1521    #[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    /// A polynomial cubic winding in space, swept along a slant `d` and cut
1562    /// by `z = 1`: the section at `t` is `C(t) + d (1 − C_z(t)) / d_z`, and
1563    /// its trace on the extrusion is `(t, (1 − C_z(t)) / d_z)`. Swept short
1564    /// of the plane it is apart, and swept to straddle it the pair is left
1565    /// to the marcher.
1566    #[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) = &section.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    /// The cubic above swept over `[−5, 1.6]` and cut by `z = 1`, its
1627    /// heights straddling the sweep's top: split at its rim, each section
1628    /// lies on both, ends at the profile's own end or on the rim,
1629    /// and every point of the profile whose height falls inside the sweep
1630    /// is on one.
1631    #[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    /// A half circle of radius five spelled as a rational quadratic, swept
1691    /// ten up, and cut by a plane leaning a hundred-thousandth of a radian
1692    /// that crosses the swept top a ten-thousandth inside the arc's crown,
1693    /// at the chord `d = r − 10⁻⁴` from its centre. Past the chord the plane
1694    /// stands above the top by at most `10⁻⁹`, within the confusion distance
1695    /// over the whole crown, and meets the top rim all but tangentially. It
1696    /// is split there, in two: each section lies on both, runs from the
1697    /// arc's end to the rim, and ends on the rim where the plane crosses
1698    /// it, at `x = 5 ± √(r² − d²)`. The section meets the rim within a
1699    /// rounding `δ` of height over a stretch `δ / (θ |dy/dx|)` long, at the
1700    /// lean `θ` and the arc's slope there `√(r² − d²) / d`, a few tenths of a
1701    /// micron for `δ` a few times `10⁻¹⁵`, and is held to a micron.
1702    #[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    /// The profiles the property test sweeps: a line, a circle, an
1746    /// ellipse, a parabola and a rational cubic, whole or trimmed.
1747    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                    // A rational cubic winding off its frame's plane.
1781                    _ => {
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        /// Whatever the profile, sweep and plane across it, every section
1811        /// lies on both surfaces to within 1e-12, at its own points and at
1812        /// its traces' chart points; and every point of the profile whose
1813        /// height to the plane falls inside the sweep is on a section.
1814        #[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                // Declined: a touch, or a section meeting the extrusion
1833                // all but tangentially. Neither is what this measures.
1834                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            // Every point whose height falls inside the sweep is charted
1841            // by a section: its `u` lies within one's trace.
1842            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    /// A cylinder of radius 20 about `z`, 1 tall, and the extrusion along
1869    /// `z` of the degree-1 B-spline from `(-1, y)` to `(1, y)`.
1870    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    /// A straight B-spline side `d` inside the drum's wall crosses it on
1891    /// the rulings at `x = ±sqrt(2rd − d²)`, however shallow the chord,
1892    /// each exact, on both surfaces at its traces and over the drum's
1893    /// height; asked the other way round, the same rulings.
1894    #[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) = &section.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)) = (&section.on_a, &section.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    /// A side touching the drum, or ending on it, is no crossing to take in
1934    /// closed form: both are left to the marcher and the contact handling,
1935    /// and the side clear of it misses.
1936    #[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}