Skip to main content

geop_core_geometry/nurb_surface/
offset.rs

1//! Offsetting a surface along its normal.
2//!
3//! The offset of a NURBS surface is in general not a NURBS surface at all.
4//! Every surface's offset is fitted in its own basis, and widened to
5//! enclose the true one by a bound proven over the whole domain (see
6//! `offset_fit`): exact to the rounding where the offset is a NURBS of that
7//! basis, as a plane's, a cylinder's, a cone's, a sphere's or a torus's
8//! is, and no branch on what kind of surface it is.
9//!
10//! **An apex.** A surface whose row collapses to a point with no tangent
11//! plane — a cone's tip — has no normal there, and next to it its offset
12//! is no surface the patch could take: on one side the offset rows pass
13//! through each other across the axis (a fold), on the other they leave a
14//! hole round it. A shell or a moved face wants the offset cone, closed in
15//! its own apex. So the surface is first cut back to where its offset's
16//! row closes to a point, or carried on through its apex, the continued
17//! patch's normal continued with it, to there; and parametrized over its
18//! old domain again, so that the apex stays where it was in `(u, v)`.
19
20use geop_core_math::{
21    geop_error::{GeopError, GeopResult},
22    scalars::Scalar,
23};
24
25use super::{NurbSurface3D, net::Side};
26
27/// How a surface was moved before it was offset so that its offset closes
28/// at an apex: the side its offset's row closes along, and where the apex
29/// itself is across that side, if the surface was carried on through it
30/// (otherwise it was cut back short of it).
31pub(super) struct Closing {
32    pub side: Side,
33    pub apex: Option<Scalar>,
34}
35
36/// How many secant steps the parameter where an offset closes is sought
37/// with: it converges to the last bit in a handful, and stops where the
38/// rest could be zero or a step repeats.
39const CLOSING_STEPS: usize = 64;
40
41impl NurbSurface3D {
42    /// The surface `distance` along its normal — against it for a negative
43    /// distance — with the same parametrization: the offset of the point at
44    /// `(u, v)` is at `(u, v)` of the result, so a trim loop's `(u, v)` stay
45    /// meaningful on it. An apex is the exception (see the module docs):
46    /// it goes to the offset's apex, at the same `(u, v)`, and along the
47    /// lines from it the points move along those lines too.
48    ///
49    /// `None` where the offset collapses: every point of the surface passes
50    /// its centre of curvature, as a cylinder shrunk past its axis, a
51    /// sphere or a torus's tube past its centre, so the offset has no
52    /// area — a blend narrower than the walls a shell builds are thick.
53    /// Fails where only part of it would, where the offset folds, and at an
54    /// apex whose offset does not close in a point.
55    pub fn offset(&self, distance: Scalar) -> GeopResult<Option<Self>> {
56        if distance.is_zero() {
57            return Ok(Some(self.clone()));
58        }
59        let ctx =
60            |e: GeopError| e.with_context(format!("NurbSurface::offset(distance={distance:?})"));
61        match self.closed(distance).map_err(ctx)? {
62            Some((surface, closing)) => surface.fitted_offset(distance, Some(&closing)),
63            None => self.fitted_offset(distance, None).map_err(|e| {
64                // How its rows turn at its sides, which said it closes
65                // nowhere inside.
66                let turning: Vec<_> = (Side::ALL.iter())
67                    .map(|side| {
68                        let ((lo, hi), (a, b)) = if side.u_fixed {
69                            (self.domain_u(), self.domain_v())
70                        } else {
71                            (self.domain_v(), self.domain_u())
72                        };
73                        let x = if side.at_end { hi } else { lo };
74                        let middle = a.add(b).div(Scalar::TWO)?.sharpen();
75                        self.row_turning(side.u_fixed, middle, x, distance, false)
76                    })
77                    .collect();
78                e.with_context(format!(
79                    "closing nowhere inside over u={:?}, v={:?}: its rows turn by {turning:?} at its sides",
80                    self.domain_u(),
81                    self.domain_v()
82                ))
83            }),
84        }
85    }
86
87    /// The offset by `distance` of the row through `x` across the side
88    /// (`across_u`: `x` is a `u`), on the parameter line through `middle`:
89    /// `O_t·S_t = S_t·S_t − d N·S_tt`, `t` along the row, the normal
90    /// turned round if `flipped`. Positive where the offset's row runs the
91    /// way the surface's does, zero where it closes to a point, negative
92    /// where it has turned over.
93    fn row_turning(
94        &self,
95        across_u: bool,
96        middle: Scalar,
97        x: Scalar,
98        distance: Scalar,
99        flipped: bool,
100    ) -> GeopResult<Scalar> {
101        let (u, v) = if across_u { (x, middle) } else { (middle, x) };
102        let s = self.partials(u, v, 2)?;
103        let (along, bend) = if across_u {
104            (s[0][1], s[0][2])
105        } else {
106            (s[1][0], s[2][0])
107        };
108        let normal = self.normal(u, v)?;
109        let normal = if flipped { normal.neg() } else { normal };
110        Ok(along
111            .prod_dot(&along)
112            .sub(distance.mul(normal.prod_dot(&bend))))
113    }
114
115    /// This surface cut back to, or carried on through an apex to, where
116    /// its offset by `distance` closes in a point, over its own domain
117    /// again (see the module docs). `None` where it closes nowhere.
118    fn closed(&self, distance: Scalar) -> GeopResult<Option<(Self, Closing)>> {
119        let apexes: Vec<Side> = Side::ALL
120            .into_iter()
121            .filter(|s| self.is_apex(s.u_fixed, !s.at_end))
122            .collect();
123        let (side, cut, longer) = match apexes.as_slice() {
124            [] => match self.closes_inside(distance)? {
125                Some((side, cut)) => (side, cut, None),
126                None => return Ok(None),
127            },
128            [side] => {
129                let (cut, longer) = self.closes_at_apex(*side, distance)?;
130                (*side, cut, longer)
131            }
132            _ => {
133                return Err(GeopError::new(format!(
134                    "the surface has apexes at {apexes:?}: offsetting more than one is not supported"
135                )));
136            }
137        };
138        let across_u = side.u_fixed;
139        let ((lo, hi), (other_lo, other_hi)) = if across_u {
140            (self.domain_u(), self.domain_v())
141        } else {
142            (self.domain_v(), self.domain_u())
143        };
144        let far = if side.at_end { lo } else { hi };
145        let (surface, apex_inside) = match &longer {
146            Some((longer, apex)) => (longer, Some(*apex)),
147            None => (self, None),
148        };
149        let (c0, c1) = if side.at_end { (far, cut) } else { (cut, far) };
150        let whole = (other_lo, other_hi);
151        let piece = if across_u {
152            surface.sub_surface((c0, c1), whole)?
153        } else {
154            surface.sub_surface(whole, (c0, c1))?
155        };
156        // Over the old domain again: `c0 ↦ lo`, `c1 ↦ hi`, affinely.
157        let scale = hi.sub(lo).div(c1.sub(c0))?;
158        let map = |k: Scalar| lo.add(k.sub(c0).mul(scale));
159        let relabel = |knots: &[Scalar], degree: usize| -> Vec<Scalar> {
160            let len = knots.len();
161            knots
162                .iter()
163                .enumerate()
164                .map(|(i, &k)| {
165                    if i <= degree {
166                        lo
167                    } else if i >= len - degree - 1 {
168                        hi
169                    } else {
170                        map(k)
171                    }
172                })
173                .collect()
174        };
175        let (knots_u, knots_v) = if across_u {
176            (
177                relabel(&piece.knot_vector_u, piece.degree_u),
178                piece.knot_vector_v.clone(),
179            )
180        } else {
181            (
182                piece.knot_vector_u.clone(),
183                relabel(&piece.knot_vector_v, piece.degree_v),
184            )
185        };
186        // The apex inside is where the carried-on piece was joined on, a
187        // knot: the relabelled knot itself, so that the Greville points
188        // there find it.
189        let apex_inside =
190            match apex_inside {
191                Some(apex) => {
192                    let (old, new) = if across_u {
193                        (&piece.knot_vector_u, &knots_u)
194                    } else {
195                        (&piece.knot_vector_v, &knots_v)
196                    };
197                    let i = old.iter().position(|k| k.could_be_equal(apex)).ok_or_else(|| {
198                    GeopError::new(format!(
199                        "the apex at {apex:?} is no knot of the surface carried on through it"
200                    ))
201                })?;
202                    Some(new[i])
203                }
204                None => None,
205            };
206        let closed = Self::try_new(
207            piece.degree_u,
208            piece.degree_v,
209            piece.control_points,
210            knots_u,
211            knots_v,
212        )?;
213        Ok(Some((
214            closed,
215            Closing {
216                side,
217                apex: apex_inside,
218            },
219        )))
220    }
221
222    /// Where the offset by `distance` of this surface, which has no apex,
223    /// closes inside its domain: the side whose rows have turned over, and
224    /// the parameter across it where the row closes. Asked of the row's
225    /// turning ([`Self::row_turning`]) at the two sides, on the parameter
226    /// line through the middle — a free choice of line; that the whole row
227    /// closes there is checked on the offset. `None` where it does not
228    /// change sign definitely between them: the offset closes nowhere, or
229    /// everywhere (it collapses), or folds, which the fit finds.
230    fn closes_inside(&self, distance: Scalar) -> GeopResult<Option<(Side, Scalar)>> {
231        let mut found: Option<(Side, Scalar)> = None;
232        for across_u in [true, false] {
233            let ((lo, hi), (other_lo, other_hi)) = if across_u {
234                (self.domain_u(), self.domain_v())
235            } else {
236                (self.domain_v(), self.domain_u())
237            };
238            let middle = other_lo.add(other_hi).div(Scalar::TWO)?.sharpen();
239            let turning = |x: Scalar| self.row_turning(across_u, middle, x, distance, false);
240            let (at_lo, at_hi) = (turning(lo)?, turning(hi)?);
241            let positive = |k: Scalar| k.definitely_greater(Scalar::ZERO);
242            let negative = |k: Scalar| k.definitely_less(Scalar::ZERO);
243            let at_end = if positive(at_lo) && negative(at_hi) {
244                true
245            } else if positive(at_hi) && negative(at_lo) {
246                false
247            } else {
248                continue;
249            };
250            // A bracket from where the row runs on to where it has turned
251            // over, narrowed by false position (Illinois): where to split
252            // it is a free choice.
253            let (mut good, mut bad, mut k_good, mut k_bad) = if at_end {
254                (lo, hi, at_lo, at_hi)
255            } else {
256                (hi, lo, at_hi, at_lo)
257            };
258            let mut kept: Option<bool> = None;
259            for _ in 0..CLOSING_STEPS {
260                let step = k_good.mul(bad.sub(good)).div(k_good.sub(k_bad))?;
261                let mut next = good.add(step).sharpen();
262                let between = |x: Scalar| {
263                    let (a, b) = if good.definitely_less(bad) {
264                        (good, bad)
265                    } else {
266                        (bad, good)
267                    };
268                    x.definitely_greater(a) && x.definitely_less(b)
269                };
270                if !between(next) {
271                    next = good.add(bad).div(Scalar::TWO)?.sharpen();
272                    if !between(next) {
273                        break;
274                    }
275                }
276                let k = turning(next)?;
277                if k.could_be_equal(Scalar::ZERO) {
278                    good = next;
279                    break;
280                }
281                if positive(k) {
282                    (good, k_good) = (next, k);
283                    if kept == Some(false) {
284                        k_bad = k_bad.div(Scalar::TWO)?;
285                    }
286                    kept = Some(false);
287                } else {
288                    (bad, k_bad) = (next, k);
289                    if kept == Some(true) {
290                        k_good = k_good.div(Scalar::TWO)?;
291                    }
292                    kept = Some(true);
293                }
294            }
295            // The row kept is the last one known to run on: the offset's
296            // row there is a point to the bracket's width, which the fit
297            // encloses and checks.
298            let side = Side {
299                u_fixed: across_u,
300                at_end,
301            };
302            if let Some((first, _)) = found {
303                return Err(GeopError::new(format!(
304                    "the offset by {distance:?} closes inside the surface along {first:?} and along {side:?}: not supported"
305                )));
306            }
307            found = Some((side, good));
308        }
309        Ok(found)
310    }
311
312    /// Where the offset by `distance` of this surface, with an apex along
313    /// `side`, closes: cut back short of the apex, or — the second value —
314    /// carried on through it, the surface carried on and the apex's
315    /// parameter. `S_t·S_t − d N·S_tt` vanishes at the apex too, once:
316    /// divided by the distance from it, what is left runs nearly straight
317    /// through the root, which secant steps find.
318    fn closes_at_apex(
319        &self,
320        side: Side,
321        distance: Scalar,
322    ) -> GeopResult<(Scalar, Option<(Self, Scalar)>)> {
323        let across_u = side.u_fixed;
324        let ((lo, hi), (other_lo, other_hi), breaks) = if across_u {
325            (self.domain_u(), self.domain_v(), self.breakpoints_u())
326        } else {
327            (self.domain_v(), self.domain_u(), self.breakpoints_v())
328        };
329        let (apex, far) = if side.at_end { (hi, lo) } else { (lo, hi) };
330        // Room through the apex: as far as the span next to it is long, a
331        // free choice.
332        let n = breaks.len();
333        let room = if side.at_end {
334            breaks[n - 1].sub(breaks[n - 2])
335        } else {
336            breaks[1].sub(breaks[0])
337        }
338        .sharpen();
339        let longer = self.extended(across_u, side.at_end, room)?;
340        let middle = other_lo.add(other_hi).div(Scalar::TWO)?.sharpen();
341        let closes = |x: Scalar| -> GeopResult<Scalar> {
342            // Past the apex the carried-on patch faces the other way.
343            let past = if side.at_end {
344                x.definitely_greater(apex)
345            } else {
346                x.definitely_less(apex)
347            };
348            longer
349                .row_turning(across_u, middle, x, distance, past)?
350                .div(x.sub(apex))
351        };
352        let inward = if side.at_end { room.neg() } else { room };
353        let mut a = apex.add(inward.div(Scalar::from_i64(4))?).sharpen();
354        let mut b = apex.add(inward.div(Scalar::TWO)?).sharpen();
355        let (mut ka, mut kb) = (closes(a)?, closes(b)?);
356        let (reach_lo, reach_hi) = if side.at_end {
357            (far, apex.add(room))
358        } else {
359            (apex.sub(room), far)
360        };
361        for _ in 0..CLOSING_STEPS {
362            if kb.could_be_equal(Scalar::ZERO) {
363                break;
364            }
365            let slope = kb.sub(ka).div(b.sub(a))?;
366            let next = b.sub(kb.div(slope)?).sharpen();
367            if next.could_be_equal(b) {
368                break;
369            }
370            if !next.definitely_greater(reach_lo) || !next.definitely_less(reach_hi) {
371                return Err(GeopError::new(format!(
372                    "the offset by {distance:?} closes past the apex at {apex:?} farther than {room:?}, or not before the far side {far:?}: at {next:?}"
373                )));
374            }
375            if next.could_be_equal(apex) {
376                return Err(GeopError::new(format!(
377                    "the offset by {distance:?} closes at the apex itself, {apex:?}"
378                )));
379            }
380            (a, ka) = (b, kb);
381            b = next;
382            kb = closes(b)?;
383        }
384        // Cut back short of the apex, or carried on through it.
385        let inside = if side.at_end {
386            b.definitely_less(apex)
387        } else {
388            b.definitely_greater(apex)
389        };
390        Ok((b, (!inside).then_some((longer, apex))))
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use crate::test_fixtures::knots;
397    use geop_core_math::{
398        scalars::Scalar,
399        vector::{Vector3, Vector4},
400    };
401
402    use super::*;
403
404    const R2: f64 = std::f64::consts::FRAC_1_SQRT_2;
405
406    fn h(x: f64, y: f64, z: f64, w: f64) -> Vector4 {
407        Vector4::from_array([x * w, y * w, z * w, w].map(Scalar::from_f64))
408    }
409
410    /// Whether `offset` is `surface` moved `distance` along its normal, at
411    /// a grid of samples.
412    fn assert_offset(surface: &NurbSurface3D, offset: &NurbSurface3D, distance: f64) {
413        let ((u0, u1), (v0, v1)) = (surface.domain_u(), surface.domain_v());
414        for a in 0..=4 {
415            for b in 0..=4 {
416                let u = Scalar::interpolate(u0, u1, Scalar::from_f64(a as f64 / 4.0));
417                let v = Scalar::interpolate(v0, v1, Scalar::from_f64(b as f64 / 4.0));
418                let expected = surface.evaluate(u, v).unwrap().add(
419                    &surface
420                        .normal(u, v)
421                        .unwrap()
422                        .prod_scalar(Scalar::from_f64(distance)),
423                );
424                let got = offset.evaluate(u, v).unwrap();
425                assert!(
426                    got.could_be_equal(&expected),
427                    "at ({a}, {b}): {got:?}, expected {expected:?}"
428                );
429            }
430        }
431    }
432
433    /// Whether `offset` is `surface`'s offset by `distance` (see
434    /// [`assert_offset`]) on `surface`'s own net, as narrow as the rounding
435    /// of the polynomials that bound it (measured: up to `1.2e-13`).
436    fn assert_exact(surface: &NurbSurface3D, offset: &NurbSurface3D, distance: f64) {
437        assert_offset(surface, offset, distance);
438        assert_eq!(
439            (offset.degree_u, offset.degree_v, offset.num_u, offset.num_v),
440            (
441                surface.degree_u,
442                surface.degree_v,
443                surface.num_u,
444                surface.num_v
445            )
446        );
447        for cp in &offset.control_points {
448            for k in 0..4 {
449                assert!(cp[k].width().to_f64() < 2e-13, "{cp:?}");
450            }
451        }
452    }
453
454    #[test]
455    fn planes_move_along_their_normal() {
456        let flat = NurbSurface3D::try_new(
457            1,
458            1,
459            vec![
460                h(0., 0., 1., 1.),
461                h(0., 2., 1.5, 1.),
462                h(1., 0., 1., 1.),
463                h(1., 2., 1.5, 1.),
464            ],
465            knots(&[0., 0., 1., 1.]),
466            knots(&[0., 0., 1., 1.]),
467        )
468        .unwrap();
469        assert_exact(
470            &flat,
471            &flat.offset(Scalar::from_f64(0.3)).unwrap().unwrap(),
472            0.3,
473        );
474        assert_exact(
475            &flat,
476            &flat.offset(Scalar::from_f64(-0.3)).unwrap().unwrap(),
477            -0.3,
478        );
479    }
480
481    /// A quarter of a cylinder of radius 2 around the z axis, `u` around it
482    /// and `v` along it, 3 high; its normal points away from the axis.
483    fn quarter_cylinder() -> NurbSurface3D {
484        let ring = [(2., 0., 1.), (2., 2., R2), (0., 2., 1.)];
485        let cps = ring
486            .iter()
487            .flat_map(|&(x, y, w)| [h(x, y, 0., w), h(x, y, 3., w)])
488            .collect();
489        NurbSurface3D::try_new(
490            2,
491            1,
492            cps,
493            knots(&[0., 0., 0., 1., 1., 1.]),
494            knots(&[0., 0., 1., 1.]),
495        )
496        .unwrap()
497    }
498
499    #[test]
500    fn cylinders_change_radius() {
501        let cylinder = quarter_cylinder();
502        for d in [0.5, -0.5, -1.5] {
503            assert_exact(
504                &cylinder,
505                &cylinder.offset(Scalar::from_f64(d)).unwrap().unwrap(),
506                d,
507            );
508        }
509        assert!(cylinder.offset(Scalar::from_f64(-2.5)).unwrap().is_none());
510        // Offset by exactly its radius, a cylinder is its axis: a factor
511        // `1 − d κ` is zero all over, which no box tells from a fold, and
512        // nowhere is the offset a surface either.
513        assert!(cylinder.offset(Scalar::from_f64(-2.0)).unwrap().is_none());
514    }
515
516    /// A quarter of a cone around the z axis, from radius 2 at z = 0 to
517    /// radius 1 at z = 1.
518    #[test]
519    fn cones_move_along_their_normal() {
520        let ring = [(1., 0., 1.), (1., 1., R2), (0., 1., 1.)];
521        let cps = ring
522            .iter()
523            .flat_map(|&(x, y, w)| [h(2. * x, 2. * y, 0., w), h(x, y, 1., w)])
524            .collect();
525        let cone = NurbSurface3D::try_new(
526            2,
527            1,
528            cps,
529            knots(&[0., 0., 0., 1., 1., 1.]),
530            knots(&[0., 0., 1., 1.]),
531        )
532        .unwrap();
533        assert_exact(
534            &cone,
535            &cone.offset(Scalar::from_f64(-0.25)).unwrap().unwrap(),
536            -0.25,
537        );
538    }
539
540    /// An eighth of the sphere of radius 2 around the origin: `u` around
541    /// the z axis, `v` from the equator up to the pole.
542    #[test]
543    fn spheres_change_radius() {
544        let meridian = [(2., 0., 1.), (2., 2., R2), (0., 2., 1.)];
545        let ring = [(1., 0., 1.), (1., 1., R2), (0., 1., 1.)];
546        let cps = ring
547            .iter()
548            .flat_map(|&(x, y, wr)| {
549                meridian
550                    .iter()
551                    .map(move |&(r, z, wm)| h(r * x, r * y, z, wr * wm))
552            })
553            .collect();
554        let sphere = NurbSurface3D::try_new(
555            2,
556            2,
557            cps,
558            knots(&[0., 0., 0., 1., 1., 1.]),
559            knots(&[0., 0., 0., 1., 1., 1.]),
560        )
561        .unwrap();
562        // Its natural normal: whichever way, the offset follows it.
563        assert_exact(
564            &sphere,
565            &sphere.offset(Scalar::from_f64(0.5)).unwrap().unwrap(),
566            0.5,
567        );
568        assert_exact(
569            &sphere,
570            &sphere.offset(Scalar::from_f64(-0.5)).unwrap().unwrap(),
571            -0.5,
572        );
573    }
574
575    /// A quarter of a torus around the z axis, tube radius 1 around the
576    /// circle of radius 3, the outer half of the tube.
577    #[test]
578    fn tori_change_tube_radius() {
579        let tube = [(4., 0., 1.), (4., 1., R2), (3., 1., 1.)];
580        let ring = [(1., 0., 1.), (1., 1., R2), (0., 1., 1.)];
581        let cps = ring
582            .iter()
583            .flat_map(|&(x, y, wr)| {
584                tube.iter()
585                    .map(move |&(r, z, wt)| h(r * x, r * y, z, wr * wt))
586            })
587            .collect();
588        let torus = NurbSurface3D::try_new(
589            2,
590            2,
591            cps,
592            knots(&[0., 0., 0., 1., 1., 1.]),
593            knots(&[0., 0., 0., 1., 1., 1.]),
594        )
595        .unwrap();
596        assert_exact(
597            &torus,
598            &torus.offset(Scalar::from_f64(0.25)).unwrap().unwrap(),
599            0.25,
600        );
601        assert_exact(
602            &torus,
603            &torus.offset(Scalar::from_f64(-0.25)).unwrap().unwrap(),
604            -0.25,
605        );
606    }
607
608    /// A quarter of the cone of half-angle 45° with its apex at z = 1 and
609    /// radius 1 at z = 0 (`v` from the apex to the rim): the apex goes to
610    /// the offset cone's apex, `√2` times the distance along the axis, and
611    /// the rim moves along the normal.
612    #[test]
613    fn a_cones_apex_goes_to_the_offset_cones_apex() {
614        let cone = crate::test_fixtures::revolved(1., 0.);
615        for d in [0.25, -0.25] {
616            let offset = cone.offset(Scalar::from_f64(d)).unwrap().unwrap();
617            let rim =
618                |s: &NurbSurface3D, u: f64| s.evaluate(Scalar::from_f64(u), Scalar::ONE).unwrap();
619            let shift = rim(&offset, 0.5).sub(&rim(&cone, 0.5));
620            let pole = offset.pole_at(Scalar::ZERO, Scalar::ZERO).unwrap();
621            // The pole moves the way the rim moves along the axis.
622            let up = shift[2].definitely_greater(Scalar::ZERO);
623            let z = 1. + if up { 0.25 } else { -0.25 } * std::f64::consts::SQRT_2;
624            let expected = Vector3::from_array([0., 0., z].map(Scalar::from_f64));
625            assert!(pole.could_be_equal(&expected), "{d}: {pole:?}");
626            for u in [0., 0.5, 1.] {
627                let (u, v) = (Scalar::from_f64(u), Scalar::ONE);
628                let expected = cone
629                    .evaluate(u, v)
630                    .unwrap()
631                    .add(&cone.normal(u, v).unwrap().prod_scalar(Scalar::from_f64(d)));
632                assert!(
633                    offset.evaluate(u, v).unwrap().could_be_equal(&expected),
634                    "{d}: {u:?}"
635                );
636            }
637        }
638    }
639
640    /// A quarter of a cone cut off short of its tip, from radius 1 at
641    /// z = 0 to radius 0.1 at z = 2 (`v` up it), offset 0.2 inward: the
642    /// offset cone's apex, `0.2 / sin α` below the cone's at `z = 2 + 0.1 /
643    /// 0.45`, lies inside the patch, and the offset is closed there, its
644    /// `v = 1` side the apex; outward it is the plain offset.
645    #[test]
646    fn a_cone_cut_off_short_of_its_tip_closes_inside() {
647        let ring = [(1., 0., 1.), (1., 1., R2), (0., 1., 1.)];
648        let cps = ring
649            .iter()
650            .flat_map(|&(x, y, w)| [h(x, y, 0., w), h(0.1 * x, 0.1 * y, 2., w)])
651            .collect();
652        let cone = NurbSurface3D::try_new(
653            2,
654            1,
655            cps,
656            knots(&[0., 0., 0., 1., 1., 1.]),
657            knots(&[0., 0., 1., 1.]),
658        )
659        .unwrap();
660        let sin = 0.45 / (1f64 + 0.45 * 0.45).sqrt();
661        let apex = Vector3::from_array([0., 0., 2. + 0.1 / 0.45 - 0.2 / sin].map(Scalar::from_f64));
662        let mut closed = 0;
663        for d in [0.2, -0.2] {
664            let offset = cone.offset(Scalar::from_f64(d)).unwrap().unwrap();
665            match offset.pole_at(Scalar::ZERO, Scalar::ONE) {
666                Some(pole) => {
667                    assert!(pole.could_be_equal(&apex), "{d}: {pole:?} vs {apex:?}");
668                    closed += 1;
669                }
670                None => assert_exact(&cone, &offset, d),
671            }
672        }
673        assert_eq!(closed, 1);
674    }
675
676    /// A quarter cylinder bent out of round: no revolution any more, so
677    /// its offset is fitted, and encloses the true one.
678    #[test]
679    fn freeform_surfaces_are_fitted() {
680        let mut bent = quarter_cylinder();
681        bent.control_points[2] = h(2.5, 2., 0., R2);
682        assert_offset(
683            &bent,
684            &bent.offset(Scalar::from_f64(0.1)).unwrap().unwrap(),
685            0.1,
686        );
687    }
688}