Skip to main content

geop_core_geometry/intersection/
surface_surface.rs

1//! Where to look for the closed loops two surfaces meet in: points of
2//! `S_a(u_a, v_a) = S_b(u_b, v_b)` at which the curve they meet in turns
3//! back along a direction `d` ([`turning_points`]).
4//!
5//! # Why turning points
6//!
7//! A boolean finds the curves two faces meet in by tracing them from the
8//! points where an edge of one pierces the other. A closed loop lying inside
9//! both faces is pierced by no edge, and is never found that way: a bump of
10//! one face poking through the other, two freeform faces meeting in an oval.
11//!
12//! Every closed loop has a point furthest along any direction `d`, and one
13//! least far: there the curve's tangent, `n_a × n_b`, is square to `d`, so
14//!
15//! ```text
16//! g = (n_a × n_b) · d = 0.
17//! ```
18//!
19//! With the three equations of `S_a = S_b` that is a square system in the
20//! four parameters, and every loop gives at least two of its solutions.
21//! Where the surfaces cross transversally the solutions are isolated, so a
22//! subdivision search finds every one ("Subdivide to isolate, then Newton
23//! to refine", `AGENTS.md`): pairs of patches whose control-point boxes are
24//! apart hold no point of both; pairs over which `g`, enclosed from the
25//! patches' normals, is definitely not zero hold no turning point. What is
26//! left at the handoff is a candidate, for the caller to refine onto the
27//! curve.
28//!
29//! # What is not searched
30//!
31//! Where the two normals could be parallel the curve has no tangent to turn,
32//! and `g` vanishes for every `d`: tangent contact, a face flush with
33//! another, a node where two branches cross. Those pairs are subdivided only
34//! down to a coarser handoff and then set aside ([`TurningPoints::tangent`]),
35//! for [`refine_node`] to find the isolated nodes among them: a loop
36//! shrinking into a tangency is the one case left to the caller to notice,
37//! as a face the boolean cannot classify.
38
39use geop_core_math::{
40    geop_error::{GeopError, GeopResult, WithContext},
41    scalars::Scalar,
42    vector::Vector3,
43};
44
45use geop_core_math::{
46    matrix::Matrix, solvers::linear::solve_linear_system, union_find::UnionFind, vector::Vector,
47};
48
49use crate::{nearest::SurfacePatch, nurb_surface::NurbSurface3D};
50
51/// How many times finer than the smaller surface a pair of patches is cut
52/// before it is handed over as a candidate: where the search hands over to
53/// Newton. Bounds effort, and how many candidates near one turning point the
54/// caller refines; never what a candidate means.
55const HANDOFF_PARTS: i64 = 64;
56
57/// How many times finer than the smaller surface a pair of patches whose
58/// normals could be parallel is cut before it is set aside (see the module
59/// docs). Coarser than [`HANDOFF_PARTS`]: two faces flush with each other
60/// are such a pair everywhere they overlap, and are set aside whole.
61const TANGENT_PARTS: i64 = 8;
62
63/// A patch of one of the two surfaces, single-span, with an enclosure of
64/// the direction of its normal over all of it (see [`normal_directions`]).
65#[derive(Clone)]
66struct Piece {
67    patch: SurfacePatch,
68    normal: Vector3,
69}
70
71/// A box holding a positive multiple of the normal `S_u × S_v` at every
72/// point of the single-span `patch`, from its control net.
73///
74/// With `A` the homogeneous points and `w` the weights, `w² S_u = w A_u -
75/// w_u A`, so `w S_u = A_u - w_u S`: in the direction of `A_u - w_u S`.
76/// Over the patch `A_u` and `w_u` are positive multiples of convex
77/// combinations of the differences of the net along `u` (its hodograph,
78/// all with one factor), and `S` lies in the hull of the Cartesian control
79/// points; each is enclosed by its box, and likewise along `v`. For a
80/// polynomial patch `w_u` vanishes, and the box is the hodographs' own.
81/// Relative to a sharp point of the net, a free choice, so the box does
82/// not grow with the patch's distance from the origin.
83fn normal_directions(patch: &NurbSurface3D) -> Vector3 {
84    let (nu, nv) = (patch.num_u, patch.num_v);
85    let at = |i: usize, j: usize| patch.control_points[i * nv + j];
86    let origin =
87        Vector3::from_array([0, 1, 2].map(|k| at(0, 0)[k].div(at(0, 0)[3]).unwrap_or(at(0, 0)[k])))
88            .sharpen();
89    // The net relative to `origin`, homogeneous: `A - w origin`.
90    let local = |i: usize, j: usize| -> [Scalar; 4] {
91        let q = at(i, j);
92        [
93            q[0].sub(q[3].mul(origin[0])),
94            q[1].sub(q[3].mul(origin[1])),
95            q[2].sub(q[3].mul(origin[2])),
96            q[3],
97        ]
98    };
99    let union = |acc: Option<[Scalar; 4]>, x: [Scalar; 4]| -> Option<[Scalar; 4]> {
100        Some(match acc {
101            None => x,
102            Some(a) => std::array::from_fn(|k| a[k].union(x[k])),
103        })
104    };
105    let mut points: Option<[Scalar; 4]> = None;
106    for i in 0..nu {
107        for j in 0..nv {
108            let q = local(i, j);
109            let cartesian = [0, 1, 2].map(|k| q[k].div(q[3]).unwrap_or(Scalar::ENTIRE));
110            points = union(
111                points,
112                [cartesian[0], cartesian[1], cartesian[2], Scalar::ZERO],
113            );
114        }
115    }
116    let points = points.expect("a net has a point");
117    let hodograph = |along_u: bool| -> [Scalar; 4] {
118        let mut acc = None;
119        let (ni, nj) = if along_u { (nu - 1, nv) } else { (nu, nv - 1) };
120        for i in 0..ni {
121            for j in 0..nj {
122                let (a, b) = if along_u {
123                    (local(i, j), local(i + 1, j))
124                } else {
125                    (local(i, j), local(i, j + 1))
126                };
127                acc = union(acc, std::array::from_fn(|k| b[k].sub(a[k])));
128            }
129        }
130        acc.unwrap_or([Scalar::ZERO; 4])
131    };
132    let direction = |d: [Scalar; 4]| {
133        Vector3::from_array(std::array::from_fn(|k| d[k].sub(d[3].mul(points[k]))))
134    };
135    direction(hodograph(true)).prod_cross(&direction(hodograph(false)))
136}
137
138impl Piece {
139    fn new(surface: NurbSurface3D) -> GeopResult<Self> {
140        Ok(Self {
141            normal: normal_directions(&surface),
142            patch: SurfacePatch(surface),
143        })
144    }
145
146    fn size(&self) -> GeopResult<Scalar> {
147        self.patch.0.size()
148    }
149
150    fn halves(&self) -> GeopResult<[Piece; 2]> {
151        let (a, b) = self.patch.halves()?;
152        Ok([Piece::new(a.0)?, Piece::new(b.0)?])
153    }
154
155    /// The parameters of the middle of the patch, and their range over it.
156    fn middle(&self) -> GeopResult<[(Scalar, Scalar); 2]> {
157        let (u, v) = self.patch.middle()?;
158        let over = |(lo, hi): (Scalar, Scalar)| lo.union(hi);
159        Ok([
160            (u, over(self.patch.0.domain_u())),
161            (v, over(self.patch.0.domain_v())),
162        ])
163    }
164}
165
166/// `surface` cut at every knot, into its polynomial (or rational) pieces:
167/// a normal enclosed over a parameter box holds only within one span.
168fn spans(surface: &NurbSurface3D) -> GeopResult<Vec<Piece>> {
169    let mut rows = vec![surface.clone()];
170    for (along_u, breaks) in [
171        (true, surface.breakpoints_u()),
172        (false, surface.breakpoints_v()),
173    ] {
174        let inner = &breaks[1..breaks.len() - 1];
175        let mut cut = Vec::new();
176        for row in rows {
177            let mut rest = row;
178            for &t in inner {
179                let (lo, hi) = if along_u {
180                    rest.split_u(t)?
181                } else {
182                    rest.split_v(t)?
183                };
184                cut.push(lo);
185                rest = hi;
186            }
187            cut.push(rest);
188        }
189        rows = cut;
190    }
191    rows.into_iter().map(Piece::new).collect()
192}
193
194/// What [`turning_points`] finds.
195#[derive(Clone, Debug)]
196pub struct TurningPoints {
197    /// The direction the curves turn back along at the candidates: sharp,
198    /// and a free choice (see [`direction`]).
199    pub direction: Vector3,
200    /// One candidate per cluster of touching boxes left at the handoff,
201    /// where the two surfaces' normals are definitely not parallel: the
202    /// sharp middle of one box of it, `(u_a, v_a, u_b, v_b)`, a seed for
203    /// Newton onto the curve, and the box of the whole cluster. Several
204    /// turning points closer than the handoff come out as one candidate.
205    pub candidates: Vec<Candidate>,
206    /// Clusters of the pairs of patches set aside because the normals could
207    /// be parallel over them (see the module docs): where the surfaces may
208    /// touch, along a curve, over an area or at isolated points, the nodes
209    /// where branches cross ([`refine_node`] tells those apart).
210    pub tangent: Vec<Candidate>,
211}
212
213/// A candidate turning point (see [`TurningPoints::candidates`]).
214#[derive(Clone, Debug)]
215pub struct Candidate {
216    /// A sharp seed in the cluster, `(u_a, v_a, u_b, v_b)`.
217    pub seed: [Scalar; 4],
218    /// The parameters' range over the whole cluster.
219    pub cluster: [Scalar; 4],
220}
221
222/// The direction the loops' turning points are sought along, a free choice
223/// (any `d` gives every loop two turning points), made to keep `g` away
224/// from zero where it can: square to both surfaces' normals at their
225/// middles, where `g` is then `|n_a × n_b|²` (for two planes it never
226/// vanishes), unless those normals are nearly parallel, and square to one
227/// of them otherwise, across the axis that normal runs least along. The
228/// first is preferred unless it is less than half as long as the others.
229fn direction(a: &NurbSurface3D, b: &NurbSurface3D) -> Vector3 {
230    let normal = |s: &NurbSurface3D| -> Option<Vector3> {
231        let (u, v) = SurfacePatch(s.clone()).middle().ok()?;
232        s.normal(u, v).ok().map(|n| n.sharpen())
233    };
234    let axes = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]
235        .map(|a| Vector3::from_array(a.map(Scalar::from_f64)));
236    let across = |n: &Vector3| -> Vector3 {
237        let least = axes
238            .iter()
239            .min_by(|x, y| {
240                let (dx, dy) = (n.prod_dot(x).abs(), n.prod_dot(y).abs());
241                dx.to_f64().total_cmp(&dy.to_f64())
242            })
243            .expect("three axes");
244        n.prod_cross(least)
245    };
246    let (na, nb) = (normal(a), normal(b));
247    let mut options: Vec<(f64, Vector3)> = Vec::new();
248    if let (Some(na), Some(nb)) = (&na, &nb) {
249        let d = na.prod_cross(nb);
250        options.push((2.0 * d.norm().to_f64(), d));
251    }
252    for n in [&na, &nb].into_iter().flatten() {
253        let d = across(n);
254        options.push((d.norm().to_f64(), d));
255    }
256    options
257        .into_iter()
258        .max_by(|x, y| x.0.total_cmp(&y.0))
259        .and_then(|(_, d)| d.normalize().ok())
260        .map(|d| d.sharpen())
261        .unwrap_or(axes[0])
262}
263
264/// Candidates for every point at which the curves `a` and `b` meet in turn
265/// back along a direction chosen here — at least two on every closed loop
266/// of them (see the module docs). Errors once `max_nodes` pairs of patches
267/// have been searched with pairs left: a search that did not finish proves
268/// nothing.
269pub fn turning_points(
270    a: &NurbSurface3D,
271    b: &NurbSurface3D,
272    max_nodes: usize,
273) -> GeopResult<TurningPoints> {
274    let ctx = |e: GeopError| e.with_context("turning_points(a, b)");
275    let d = direction(a, b);
276    let mut found = TurningPoints {
277        direction: d,
278        candidates: Vec::new(),
279        tangent: Vec::new(),
280    };
281    if !a.could_overlap(b) {
282        return Ok(found);
283    }
284    let size = a
285        .size()
286        .with_context(&ctx)?
287        .min(b.size().with_context(&ctx)?);
288    let parts = |n: i64| -> GeopResult<Scalar> { size.div(Scalar::from_i64(n)).map(|s| s.upper()) };
289    let (handoff, tangent_handoff) = (
290        parts(HANDOFF_PARTS).with_context(&ctx)?,
291        parts(TANGENT_PARTS).with_context(&ctx)?,
292    );
293
294    let (pieces_a, pieces_b) = (spans(a).with_context(&ctx)?, spans(b).with_context(&ctx)?);
295    let mut pending: Vec<(Piece, Piece)> = Vec::new();
296    for pa in &pieces_a {
297        for pb in &pieces_b {
298            pending.push((pa.clone(), pb.clone()));
299        }
300    }
301    // Each leaf's seed and box, regular and tangent apart.
302    let mut leaves: [Vec<([Scalar; 4], [Scalar; 4])>; 2] = [Vec::new(), Vec::new()];
303    let mut visited = 0usize;
304    while let Some((pa, pb)) = pending.pop() {
305        if !pa.patch.0.could_overlap(&pb.patch.0) {
306            continue;
307        }
308        let cross = pa.normal.prod_cross(&pb.normal);
309        if !cross.prod_dot(&d).could_be_equal(Scalar::ZERO) {
310            continue;
311        }
312        if visited >= max_nodes {
313            return Err(ctx(GeopError::new(format!(
314                "searched {max_nodes} pairs of patches with {} left, {} candidates so far: the search did not finish",
315                pending.len() + 1,
316                leaves[0].len()
317            ))));
318        }
319        visited += 1;
320        let regular = (0..3).any(|k| !cross[k].could_be_equal(Scalar::ZERO));
321        let limit = if regular { handoff } else { tangent_handoff };
322        let (size_a, size_b) = (pa.size()?, pb.size()?);
323        let (split_a, split_b) = (
324            size_a.definitely_greater(limit),
325            size_b.definitely_greater(limit),
326        );
327        if !split_a && !split_b {
328            let ([(ua, ua_box), (va, va_box)], [(ub, ub_box), (vb, vb_box)]) =
329                (pa.middle()?, pb.middle()?);
330            leaves[usize::from(!regular)]
331                .push(([ua, va, ub, vb], [ua_box, va_box, ub_box, vb_box]));
332            continue;
333        }
334        let cut_a = split_a && (!split_b || size_a.to_f64() >= size_b.to_f64());
335        if cut_a {
336            for half in pa.halves().with_context(&ctx)? {
337                pending.push((half, pb.clone()));
338            }
339        } else {
340            for half in pb.halves().with_context(&ctx)? {
341                pending.push((pa.clone(), half));
342            }
343        }
344    }
345
346    let [regular, tangent] = leaves;
347    found.candidates = clusters(&regular);
348    found.tangent = clusters(&tangent);
349    Ok(found)
350}
351
352/// Clusters of `leaves` (each a seed and a box) whose boxes touch: one
353/// point found from several sides, or several closer than the handoff. Each
354/// gets the seed of the member nearest the middle of the cluster: a free
355/// choice, taken where Newton has least far to go to any of it.
356fn clusters(leaves: &[([Scalar; 4], [Scalar; 4])]) -> Vec<Candidate> {
357    let mut sets = UnionFind::new(leaves.len());
358    for i in 0..leaves.len() {
359        for j in 0..i {
360            let touch = (0..4).all(|k| {
361                !leaves[i].1[k].definitely_less(leaves[j].1[k].lower())
362                    && !leaves[i].1[k].definitely_greater(leaves[j].1[k].upper())
363            });
364            if touch {
365                sets.union(i, j);
366            }
367        }
368    }
369    let mut groups = sets.groups();
370    for group in &mut groups {
371        group.sort();
372    }
373    groups.sort_by_key(|g| g[0]);
374    groups
375        .into_iter()
376        .map(|members| {
377            let whole: [Scalar; 4] = std::array::from_fn(|k| {
378                members
379                    .iter()
380                    .map(|&i| leaves[i].1[k])
381                    .reduce(Scalar::union)
382                    .expect("a cluster has a member")
383            });
384            let middle = whole.map(|x| x.midpoint().to_f64());
385            let seed = members
386                .iter()
387                .map(|&i| leaves[i].0)
388                .min_by(|x, y| {
389                    let dist = |s: &[Scalar; 4]| {
390                        (0..4)
391                            .map(|k| (s[k].to_f64() - middle[k]).powi(2))
392                            .sum::<f64>()
393                    };
394                    dist(x).total_cmp(&dist(y))
395                })
396                .expect("a cluster has a member");
397            Candidate {
398                seed,
399                cluster: whole,
400            }
401        })
402        .collect()
403}
404
405/// Newton steps [`refine_node`] takes at most.
406const NODE_ITERATIONS: usize = 30;
407
408/// The residual of the node system at `x = (u_a, v_a, u_b, v_b)` and its
409/// Jacobian: `S_a - S_b`, and the cross product of the two (unnormalized)
410/// normals `N = S_u × S_v` along two tangents `t` of `a`, which vanish
411/// together where the surfaces touch.
412fn node_system(
413    a: &NurbSurface3D,
414    b: &NurbSurface3D,
415    [ua, va, ub, vb]: [Scalar; 4],
416    t: &[Vector3; 2],
417) -> GeopResult<([Scalar; 5], [[Scalar; 4]; 5])> {
418    let (pa, pb) = (a.evaluate(ua, va)?, b.evaluate(ub, vb)?);
419    let (sau, sav) = a.derivatives(ua, va)?;
420    let (sbu, sbv) = b.derivatives(ub, vb)?;
421    let [sauu, sauv, savv] = a.second_derivatives(ua, va)?;
422    let [sbuu, sbuv, sbvv] = b.second_derivatives(ub, vb)?;
423    let (na, nb) = (sau.prod_cross(&sav), sbu.prod_cross(&sbv));
424    let na_u = sauu.prod_cross(&sav).add(&sau.prod_cross(&sauv));
425    let na_v = sauv.prod_cross(&sav).add(&sau.prod_cross(&savv));
426    let nb_u = sbuu.prod_cross(&sbv).add(&sbu.prod_cross(&sbuv));
427    let nb_v = sbuv.prod_cross(&sbv).add(&sbu.prod_cross(&sbvv));
428    let c = na.prod_cross(&nb);
429    let dc = [
430        na_u.prod_cross(&nb),
431        na_v.prod_cross(&nb),
432        na.prod_cross(&nb_u),
433        na.prod_cross(&nb_v),
434    ];
435    let delta = pa.sub(&pb);
436    let mut f = [Scalar::ZERO; 5];
437    let mut j = [[Scalar::ZERO; 4]; 5];
438    for k in 0..3 {
439        f[k] = delta[k];
440        j[k] = [sau[k], sav[k], sbu[k].neg(), sbv[k].neg()];
441    }
442    for (row, tangent) in t.iter().enumerate() {
443        f[3 + row] = c.prod_dot(tangent);
444        j[3 + row] = dc.map(|d| d.prod_dot(tangent));
445    }
446    Ok((f, j))
447}
448
449/// The Gauss-Newton step `-(J^T J)^-1 J^T f`, with `J^T J` taken sharp when
450/// `sharp` (a preconditioner: the step's width is then the residual's) and
451/// over the box otherwise, where solving it at all proves `J` of full rank
452/// over the whole box.
453fn node_step(f: &[Scalar; 5], j: &[[Scalar; 4]; 5], sharp: bool) -> GeopResult<[Scalar; 4]> {
454    let mut jtj = [[Scalar::ZERO; 4]; 4];
455    let mut jtf = [Scalar::ZERO; 4];
456    for r in 0..4 {
457        for c in 0..4 {
458            let sum = (0..5).fold(Scalar::ZERO, |sum, k| sum.add(j[k][r].mul(j[k][c])));
459            jtj[r][c] = if sharp { sum.sharpen() } else { sum };
460        }
461        jtf[r] = (0..5)
462            .fold(Scalar::ZERO, |sum, k| sum.add(j[k][r].mul(f[k])))
463            .neg();
464    }
465    let step = solve_linear_system(&Matrix::from_rows(jtj), &Vector::from_array(jtf))?;
466    Ok(step.to_array())
467}
468
469/// The node near `seed` where `a` and `b` touch at an isolated point —
470/// branches of the curve they meet in cross there, or they touch at that
471/// point alone — as its parameters `(u_a, v_a, u_b, v_b)` and the point,
472/// or `None` where Newton finds no such point, or one not isolated: where
473/// the surfaces touch along a curve or over an area, the system is singular
474/// and no node is claimed.
475///
476/// Gauss-Newton on `S_a = S_b` with the normals parallel, five equations in
477/// four unknowns, each iterate sharpened as the seed of the next but the
478/// last, whose width says how well the data pins the node down (see
479/// `AGENTS.md`). The answer is then checked against every condition it was
480/// asked to meet, over its box: both surfaces could be there, the normals
481/// could be parallel, and the system is of full rank all over it.
482pub fn refine_node(
483    a: &NurbSurface3D,
484    b: &NurbSurface3D,
485    seed: [Scalar; 4],
486) -> Option<([Scalar; 4], Vector3)> {
487    let clamp = |x: [Scalar; 4]| -> [Scalar; 4] {
488        let domains = [a.domain_u(), a.domain_v(), b.domain_u(), b.domain_v()];
489        std::array::from_fn(|k| x[k].clamped(domains[k].0, domains[k].1))
490    };
491    let mut x = clamp(seed.map(Scalar::sharpen));
492    let tangents = |x: &[Scalar; 4]| -> Option<[Vector3; 2]> {
493        let (su, sv) = a.derivatives(x[0], x[1]).ok()?;
494        Some([su.sharpen(), sv.sharpen()])
495    };
496    let t = tangents(&x)?;
497    let mut answer = None;
498    for _ in 0..NODE_ITERATIONS {
499        let (f, j) = node_system(a, b, x, &t).ok()?;
500        let step = node_step(&f, &j, true).ok()?;
501        let honest = clamp(std::array::from_fn(|k| x[k].add(step[k])));
502        if f.iter().all(|r| r.could_be_equal(Scalar::ZERO)) {
503            answer = Some(honest);
504            break;
505        }
506        x = clamp(honest.map(Scalar::sharpen));
507    }
508    let x = answer?;
509    let (pa, pb) = (a.evaluate(x[0], x[1]).ok()?, b.evaluate(x[2], x[3]).ok()?);
510    if !pa.could_be_equal(&pb) {
511        return None;
512    }
513    let (f, j) = node_system(a, b, x, &t).ok()?;
514    if !f.iter().all(|r| r.could_be_equal(Scalar::ZERO)) {
515        return None;
516    }
517    node_step(&f, &j, false).ok()?;
518    Some((x, pa.union(&pb)))
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524    use crate::{nurb_surface::NurbSurface, test_fixtures::knots};
525    use geop_core_math::vector::Vector4;
526
527    fn h(x: f64, y: f64, z: f64) -> Vector4 {
528        Vector4::from_array([x, y, z, 1.0].map(Scalar::from_f64))
529    }
530
531    /// A biquadratic dome over `[0, 1]²`, its middle control point raised to
532    /// `top`: the surface reaches a quarter of that.
533    fn dome(top: f64) -> NurbSurface3D {
534        let mut cps = Vec::new();
535        for i in 0..3 {
536            for j in 0..3 {
537                let z = if i == 1 && j == 1 { top } else { 0.0 };
538                cps.push(h(i as f64 / 2.0, j as f64 / 2.0, z));
539            }
540        }
541        let k = knots(&[0., 0., 0., 1., 1., 1.]);
542        NurbSurface::try_new(2, 2, cps, k.clone(), k).unwrap()
543    }
544
545    /// The plane `z = z` over `[-1, 2]²`.
546    fn level(z: f64) -> NurbSurface3D {
547        NurbSurface::try_new(
548            1,
549            1,
550            vec![h(-1., -1., z), h(-1., 2., z), h(2., -1., z), h(2., 2., z)],
551            knots(&[0., 0., 1., 1.]),
552            knots(&[0., 0., 1., 1.]),
553        )
554        .unwrap()
555    }
556
557    /// A dome poking through a plane meets it in an oval inside both, which
558    /// turns back at least twice along any direction: found, and every
559    /// candidate a seed Newton can start from. Below the dome's top the
560    /// plane meets nothing, and a plane above it neither.
561    #[test]
562    fn a_dome_through_a_plane_turns_twice() {
563        let found = turning_points(&dome(2.4), &level(0.3), 20_000).unwrap();
564        assert_eq!(found.candidates.len(), 2, "{found:?}");
565        let found = turning_points(&dome(2.4), &level(0.9), 20_000).unwrap();
566        assert!(found.candidates.is_empty(), "{found:?}");
567    }
568
569    /// Two planes crossing meet in a line, which turns nowhere.
570    #[test]
571    fn crossing_planes_turn_nowhere() {
572        let tilted = NurbSurface::try_new(
573            1,
574            1,
575            vec![h(0., 0., -1.), h(0., 1., -1.), h(1., 0., 1.), h(1., 1., 1.)],
576            knots(&[0., 0., 1., 1.]),
577            knots(&[0., 0., 1., 1.]),
578        )
579        .unwrap();
580        let found = turning_points(&tilted, &level(0.0), 20_000).unwrap();
581        assert!(found.candidates.is_empty(), "{found:?}");
582        assert!(found.tangent.is_empty(), "{found:?}");
583    }
584
585    /// A plane's normal over the whole of it is the one direction it has.
586    #[test]
587    fn a_planes_normal_is_sharp() {
588        let piece = Piece::new(level(0.0)).unwrap();
589        assert!(
590            (0..3).all(|k| piece.normal[k].width().to_f64() < 1e-12)
591                && piece.normal[2].definitely_greater(Scalar::ZERO),
592            "{:?}",
593            piece.normal
594        );
595    }
596
597    /// Two cylinders of radius one half, around `z` and around `x`, each
598    /// turned over a half turn with the point `(0, 0.5, 0)` in the middle of
599    /// it: their walls meet in two ellipses crossing there, the walls
600    /// tangent. The search sets the node aside with the tangent pairs, and
601    /// `refine_node` finds it there.
602    #[test]
603    fn crossing_cylinders_have_a_node() {
604        use crate::shape::Axis;
605        let v = |p: [f64; 3]| Vector3::from_array(p.map(Scalar::from_f64));
606        let pi = std::f64::consts::PI;
607        let around_z = NurbSurface3D::revolve(
608            &crate::test_fixtures::line([0.5, 0.0, -1.0], [0.5, 0.0, 1.0]),
609            &Axis::try_new(v([0.0, 0.0, 0.0]), v([0.0, 0.0, 1.0])).unwrap(),
610            pi / 4.0,
611            3.0 * pi / 4.0,
612        )
613        .unwrap();
614        let around_x = NurbSurface3D::revolve(
615            &crate::test_fixtures::line([-1.0, 0.5, 0.0], [1.0, 0.5, 0.0]),
616            &Axis::try_new(v([0.0, 0.0, 0.0]), v([1.0, 0.0, 0.0])).unwrap(),
617            -pi / 4.0,
618            pi / 4.0,
619        )
620        .unwrap();
621        let found = turning_points(&around_z, &around_x, 20_000).unwrap();
622        let nodes: Vec<Vector3> = found
623            .tangent
624            .iter()
625            .filter_map(|c| refine_node(&around_z, &around_x, c.seed))
626            .map(|(_, p)| p)
627            .collect();
628        assert_eq!(nodes.len(), 1, "{nodes:?} from {:?}", found.tangent);
629        assert!(nodes[0].could_be_equal(&v([0.0, 0.5, 0.0])), "{nodes:?}");
630        assert!(
631            (0..3).all(|k| nodes[0][k].width().to_f64() < 1e-9),
632            "{nodes:?}"
633        );
634    }
635}