Skip to main content

geop_core_geometry/
shape.rs

1//! Recognizing the elementary shapes a NURBS curve or surface can be — a
2//! straight line, a circular arc, a plane, a surface of revolution — for
3//! whatever needs to know *what* an entity is, not just where it is: a
4//! reference axis built along an edge, a sketch placed on a face.
5//!
6//! Every test is a question about the control net, answered with the
7//! kernel's three-valued comparisons: a shape is recognized when the net
8//! *could* be exactly that shape — rounding can't rule it out — and never
9//! because it is merely close to it. A curve that bends by a micron is not a
10//! line, however short.
11//!
12//! The shapes themselves ([`Axis`], [`Circle`], [`Arc`], [`Plane`]) carry
13//! the few constructions everything built on them needs: projecting onto
14//! them, intersecting them.
15
16use geop_core_math::{
17    geop_error::{GeopError, GeopResult},
18    primitives::Motion,
19    scalars::Scalar,
20    vector::Vector3,
21};
22
23use crate::{
24    nurb_curve::{NurbCurve, NurbCurve3D, dehomogenize},
25    nurb_surface::NurbSurface3D,
26};
27
28/// Whether `v` could be the zero vector.
29fn could_be_zero(v: &Vector3) -> bool {
30    v.norm_sq().could_be_equal(Scalar::ZERO)
31}
32
33/// A straight line: through `point`, along the unit vector `direction`.
34#[derive(Clone, Debug, PartialEq)]
35pub struct Axis {
36    pub point: Vector3,
37    pub direction: Vector3,
38}
39
40impl Axis {
41    /// The line through `point` along `direction`, which need not be unit
42    /// length but must not be zero.
43    pub fn try_new(point: Vector3, direction: Vector3) -> GeopResult<Self> {
44        Ok(Self {
45            point,
46            direction: direction.normalize()?,
47        })
48    }
49
50    /// The point of the line closest to `p`: the foot of the perpendicular
51    /// from `p`.
52    pub fn project(&self, p: &Vector3) -> Vector3 {
53        let along = p.sub(&self.point).prod_dot(&self.direction);
54        self.point.add(&self.direction.prod_scalar(along))
55    }
56
57    /// Whether `p` could lie on the line.
58    pub fn could_contain(&self, p: &Vector3) -> bool {
59        could_be_zero(&p.sub(&self.point).prod_cross(&self.direction))
60    }
61
62    /// Whether `other` could run parallel to this line, either way.
63    pub fn could_be_parallel(&self, other: &Axis) -> bool {
64        self.direction.could_be_parallel(&other.direction)
65    }
66
67    /// Where the two lines come closest: halfway between the point of each
68    /// nearest the other — where they cross, if they do. Fails if they could
69    /// be parallel: then every point is as near as any other.
70    pub fn nearest(&self, other: &Axis) -> GeopResult<Vector3> {
71        let n = self.direction.prod_cross(&other.direction);
72        let n2 = n.norm_sq();
73        if n2.could_be_equal(Scalar::ZERO) {
74            return Err(GeopError::new(
75                "the lines are parallel, so no point of them is nearer the other than any other",
76            ));
77        }
78        let r = other.point.sub(&self.point);
79        let s = r.prod_cross(&other.direction).prod_dot(&n).div(n2)?;
80        let t = r.prod_cross(&self.direction).prod_dot(&n).div(n2)?;
81        let a = self.point.add(&self.direction.prod_scalar(s));
82        let b = other.point.add(&other.direction.prod_scalar(t));
83        Ok(Vector3::interpolate(&a, &b, Scalar::ONE.div(Scalar::TWO)?))
84    }
85
86    /// The line moved by `motion`.
87    pub fn transform(&self, motion: &Motion) -> Self {
88        Self {
89            point: motion.apply(&self.point),
90            direction: motion.rotate(&self.direction),
91        }
92    }
93}
94
95/// A circle: around `center`, in the plane normal to the unit vector
96/// `normal`, of `radius`.
97#[derive(Clone, Debug)]
98pub struct Circle {
99    pub center: Vector3,
100    pub normal: Vector3,
101    pub radius: Scalar,
102}
103
104impl Circle {
105    /// The line through the center along the normal: what the circle turns
106    /// around.
107    pub fn axis(&self) -> Axis {
108        Axis {
109            point: self.center,
110            direction: self.normal,
111        }
112    }
113
114    /// The circle moved by `motion`.
115    pub fn transform(&self, motion: &Motion) -> Self {
116        Self {
117            center: motion.apply(&self.center),
118            normal: motion.rotate(&self.normal),
119            radius: self.radius,
120        }
121    }
122
123    /// Whether `p` could lie on the circle: in its plane, `radius` from
124    /// its center.
125    pub fn could_contain(&self, p: &Vector3) -> bool {
126        let radial = p.sub(&self.center);
127        radial.prod_dot(&self.normal).could_be_equal(Scalar::ZERO)
128            && radial.norm().could_be_equal(self.radius)
129    }
130
131    fn could_be_equal(&self, other: &Circle) -> bool {
132        self.center.could_be_equal(&other.center)
133            && self.normal.could_be_equal(&other.normal)
134            && self.radius.could_be_equal(other.radius)
135    }
136
137    /// The smallest circle description enclosing both — two enclosures of
138    /// one circle, combined (see `AGENTS.md` on `union`).
139    fn union(&self, other: &Circle) -> Circle {
140        Circle {
141            center: self.center.union(&other.center),
142            normal: self.normal.union(&other.normal),
143            radius: self.radius.union(other.radius),
144        }
145    }
146}
147
148/// A circular arc: the part of `circle` from `start` counter-clockwise
149/// (seen with `circle.normal` towards the viewer) to `end` — all of it when
150/// `start` and `end` coincide.
151#[derive(Clone, Debug)]
152pub struct Arc {
153    pub circle: Circle,
154    pub start: Vector3,
155    pub end: Vector3,
156}
157
158impl Arc {
159    /// The arc moved by `motion`. A mirror turns counter-clockwise into
160    /// clockwise, so the mirrored arc runs from its end to its start.
161    pub fn transform(&self, motion: &Motion) -> Self {
162        let (start, end) = (motion.apply(&self.start), motion.apply(&self.end));
163        let (start, end) = match motion.mirrors() {
164            true => (end, start),
165            false => (start, end),
166        };
167        Self {
168            circle: self.circle.transform(motion),
169            start,
170            end,
171        }
172    }
173
174    /// Whether the arc could be the whole circle.
175    pub fn could_be_closed(&self) -> bool {
176        self.start.could_be_equal(&self.end)
177    }
178
179    /// The angle the arc turns through, in radians, in `(0, 2 pi]`: an
180    /// enclosure, for what is measured — its length ([`Arc::length`]).
181    /// The whole turn for an arc that could be closed, as [`Arc::sweep`]
182    /// takes it.
183    pub fn angle(&self) -> GeopResult<Scalar> {
184        if self.could_be_closed() {
185            return Ok(Scalar::TWO.mul(Scalar::PI));
186        }
187        let c = &self.circle;
188        let x = self.start.sub(&c.center);
189        let e = self.end.sub(&c.center);
190        let angle = c.normal.prod_dot(&x.prod_cross(&e)).atan2(x.prod_dot(&e));
191        if angle.definitely_greater(Scalar::ZERO) {
192            Ok(angle)
193        } else if angle.definitely_less(Scalar::ZERO) {
194            Ok(angle.add(Scalar::TWO.mul(Scalar::PI)))
195        } else {
196            Err(GeopError::new(format!(
197                "Arc::angle: the arc could turn by nothing or all round, from {:?} to {:?} about {:?}",
198                self.start, self.end, c.center
199            )))
200        }
201    }
202
203    /// How long the arc is: its radius times the angle it turns through.
204    pub fn length(&self) -> GeopResult<Scalar> {
205        Ok(self.circle.radius.mul(self.angle()?))
206    }
207
208    /// The angle the arc turns through, in radians, in `(0, 2 pi]`, as a
209    /// plain `f64`: only ever used to *choose* a point along the arc (see
210    /// [`Arc::point_at`]); what is measured takes [`Arc::angle`].
211    pub fn sweep(&self) -> f64 {
212        if self.could_be_closed() {
213            return std::f64::consts::TAU;
214        }
215        let c = &self.circle;
216        let x = self.start.sub(&c.center);
217        let e = self.end.sub(&c.center);
218        let cos = x.prod_dot(&e).to_f64();
219        let sin = c.normal.prod_dot(&x.prod_cross(&e)).to_f64();
220        let angle = sin.atan2(cos);
221        if angle > 0.0 {
222            angle
223        } else {
224            angle + std::f64::consts::TAU
225        }
226    }
227
228    /// The point `fraction` of the way along the arc, by angle: `start` at
229    /// 0, `end` at 1.
230    ///
231    /// The angle is computed in `f64` (see [`Arc::sweep`]) and taken as
232    /// sharp — legitimately: which point is "0.3 of the way" is a choice
233    /// the caller makes, and any angle within rounding of it serves that
234    /// choice equally well. What the result has to be *honestly* is a point
235    /// on the circle, and it is, since it is built from the circle itself.
236    pub fn point_at(&self, fraction: f64) -> GeopResult<Vector3> {
237        let c = &self.circle;
238        let x = self.start.sub(&c.center);
239        let y = c.normal.prod_cross(&x);
240        let angle = Scalar::from_f64(fraction * self.sweep());
241        Ok(c.center
242            .add(&x.prod_scalar(angle.cos()))
243            .add(&y.prod_scalar(angle.sin())))
244    }
245
246    /// The unit tangent at `p`, a point of the arc, pointing the way the arc
247    /// runs.
248    pub fn tangent_at(&self, p: &Vector3) -> GeopResult<Vector3> {
249        self.circle
250            .normal
251            .prod_cross(&p.sub(&self.circle.center))
252            .normalize()
253    }
254
255    /// The arc as a curve, the way the kernel builds every arc: rational
256    /// quadratic pieces of at most a quarter turn each, on `[0, 1]`,
257    /// running from `start` to `end` exactly.
258    ///
259    /// Where the pieces meet is a free choice (see [`Arc::point_at`]): any
260    /// point of the circle serves. Each piece from `P0` to `P2` around the
261    /// center `C`, turning through `phi`, has its middle control point
262    /// where the tangents at its ends meet, `C + (P0 - C + P2 - C) / (1 +
263    /// cos phi)`, weighted `cos(phi / 2) = sqrt((1 + cos phi) / 2)` — both
264    /// read off the ends, so no angle is taken.
265    pub fn to_curve(&self) -> GeopResult<NurbCurve3D> {
266        let quarter = std::f64::consts::FRAC_PI_2;
267        let pieces = ((self.sweep() / quarter).ceil() as usize).max(1);
268        let mut points = vec![self.start];
269        for k in 1..pieces {
270            points.push(self.point_at(k as f64 / pieces as f64)?);
271        }
272        points.push(self.end);
273        // Relative to a sharp point of the arc, a free choice, and added
274        // back with one rounding: the middle control point is reached
275        // through the centre, and far from the origin each rounding of a
276        // coordinate costs its size. The weight is what multiplies the
277        // far coordinate, so its width is kept to what the local figure
278        // gives it.
279        let origin = points[0].sharpen();
280        let c = self.circle.center.sub(&origin);
281        let r2 = self.circle.radius.mul(self.circle.radius);
282        let mut control_points = vec![points[0].homogeneous(Scalar::ONE)];
283        let mut knots = vec![Scalar::ZERO; 3];
284        for k in 0..pieces {
285            let (a, b) = (
286                points[k].sub(&origin).sub(&c),
287                points[k + 1].sub(&origin).sub(&c),
288            );
289            let one_plus_cos = Scalar::ONE.add(a.prod_dot(&b).div(r2)?);
290            let middle = origin.add(&c.add(&a.add(&b).prod_scalar(Scalar::ONE.div(one_plus_cos)?)));
291            let weight = one_plus_cos.div(Scalar::TWO)?.sqrt()?;
292            control_points.push(middle.homogeneous(weight));
293            control_points.push(points[k + 1].homogeneous(Scalar::ONE));
294            if k + 1 < pieces {
295                let knot = Scalar::from_ratio(k as i64 + 1, pieces as i64)?;
296                knots.extend([knot, knot]);
297            }
298        }
299        knots.extend([Scalar::ONE; 3]);
300        NurbCurve::try_new(2, control_points, knots)
301    }
302}
303
304/// A plane: through `point`, normal to the unit vector `normal`.
305#[derive(Clone, Debug)]
306pub struct Plane {
307    pub point: Vector3,
308    pub normal: Vector3,
309}
310
311impl Plane {
312    /// The plane through `point` normal to `normal`, which need not be unit
313    /// length but must not be zero.
314    pub fn try_new(point: Vector3, normal: Vector3) -> GeopResult<Self> {
315        Ok(Self {
316            point,
317            normal: normal.normalize()?,
318        })
319    }
320
321    /// How far `p` lies in front of the plane (along the normal); negative
322    /// behind it.
323    pub fn signed_distance(&self, p: &Vector3) -> Scalar {
324        p.sub(&self.point).prod_dot(&self.normal)
325    }
326
327    /// The point of the plane closest to `p`: the foot of the perpendicular
328    /// from `p`.
329    pub fn project(&self, p: &Vector3) -> Vector3 {
330        p.sub(&self.normal.prod_scalar(self.signed_distance(p)))
331    }
332
333    /// Where `axis` pierces the plane. Fails if it could run parallel to it.
334    pub fn intersect_axis(&self, axis: &Axis) -> GeopResult<Vector3> {
335        let along = axis.direction.prod_dot(&self.normal);
336        if along.could_be_equal(Scalar::ZERO) {
337            return Err(GeopError::new(
338                "the line runs parallel to the plane, so it does not pierce it",
339            ));
340        }
341        let t = self.signed_distance(&axis.point).div(along)?;
342        Ok(axis.point.sub(&axis.direction.prod_scalar(t)))
343    }
344
345    /// The line the two planes meet in, running along `self.normal x
346    /// other.normal`. Fails if they could be parallel.
347    pub fn intersect_plane(&self, other: &Plane) -> GeopResult<Axis> {
348        let direction = self.normal.prod_cross(&other.normal);
349        let n2 = direction.norm_sq();
350        if n2.could_be_equal(Scalar::ZERO) {
351            return Err(GeopError::new(
352                "the planes are parallel, so they do not meet",
353            ));
354        }
355        // The point of the line closest to the origin: the combination of
356        // both normals that lies on both planes.
357        let (d1, d2) = (
358            self.point.prod_dot(&self.normal),
359            other.point.prod_dot(&other.normal),
360        );
361        let point = other
362            .normal
363            .prod_cross(&direction)
364            .prod_scalar(d1)
365            .add(&direction.prod_cross(&self.normal).prod_scalar(d2))
366            .prod_scalar(Scalar::ONE.div(n2)?);
367        Axis::try_new(point, direction)
368    }
369}
370
371// ── curves ────────────────────────────────────────────────────────────────────
372
373impl NurbCurve3D {
374    /// The line the curve runs along, if it is straight: every control point
375    /// on the line from the first to the last, which is then the curve's
376    /// direction. `None` for a curve that bends, or whose ends coincide.
377    pub fn as_line(&self) -> GeopResult<Option<Axis>> {
378        let points = dehomogenize::<4, 3>(&self.control_points);
379        let (first, last) = (points[0], points[points.len() - 1]);
380        let d = last.sub(&first);
381        if could_be_zero(&d) {
382            return Ok(None);
383        }
384        let axis = Axis::try_new(first, d)?;
385        Ok(points.iter().all(|p| axis.could_contain(p)).then_some(axis))
386    }
387
388    /// The arc the curve traces, if it is a circular one: rational quadratic
389    /// pieces, each an exact arc, all of one circle — how the kernel builds
390    /// every arc and circle, and what splitting one leaves. `None` for any
391    /// other curve.
392    pub fn as_arc(&self) -> GeopResult<Option<Arc>> {
393        if self.degree != 2 {
394            return Ok(None);
395        }
396        let mut circle: Option<Circle> = None;
397        for piece in self.bezier_pieces()? {
398            let Some(c) = bezier_circle(&piece)? else {
399                return Ok(None);
400            };
401            circle = Some(match circle {
402                None => c,
403                Some(prev) if prev.could_be_equal(&c) => prev.union(&c),
404                Some(_) => return Ok(None),
405            });
406        }
407        let Some(circle) = circle else {
408            return Ok(None);
409        };
410        let (t0, t1) = self.domain();
411        Ok(Some(Arc {
412            circle,
413            start: self.evaluate(t0)?,
414            end: self.evaluate(t1)?,
415        }))
416    }
417}
418
419/// The circle a rational quadratic Bézier piece traces, if it is an exact
420/// circular arc.
421///
422/// With end points `P0`, `P2`, middle control point `P1` and weights `w0`,
423/// `w1`, `w2`, the piece is an arc exactly when `P1` is where the arc's end
424/// tangents meet — equally far from both ends, `|P1 - P0| = |P1 - P2|` — and
425/// the weight normalized to `w0 = w2 = 1`, `w1 / sqrt(w0 w2)`, is the cosine
426/// of the angle `theta` between the chord and those tangents:
427/// `cos theta = |P2 - P0| / (2 |P1 - P0|)`. Squared, so no root is taken.
428fn bezier_circle(piece: &NurbCurve3D) -> GeopResult<Option<Circle>> {
429    let [h0, h1, h2] = match piece.control_points.as_slice() {
430        [a, b, c] => [*a, *b, *c],
431        _ => return Ok(None),
432    };
433    let points = dehomogenize::<4, 3>(&[h0, h1, h2]);
434    // Relative to a sharp point beside the piece, a free choice: far from
435    // the origin every rounding of a coordinate costs its size, and the
436    // centre is reached through a dozen of them. Added back at the end with
437    // one rounding (see "Evaluate relative to the patch, not the origin").
438    let origin = points[0].sharpen();
439    let (p0, p1, p2) = (
440        points[0].sub(&origin),
441        points[1].sub(&origin),
442        points[2].sub(&origin),
443    );
444    let (w0, w1, w2) = (h0[3], h1[3], h2[3]);
445    let tangent = p1.sub(&p0).norm_sq();
446    if !tangent.could_be_equal(p1.sub(&p2).norm_sq()) {
447        return Ok(None);
448    }
449    let chord = p2.sub(&p0).norm_sq();
450    let four = Scalar::TWO.add(Scalar::TWO);
451    if !four
452        .mul(w1)
453        .mul(w1)
454        .mul(tangent)
455        .could_be_equal(w0.mul(w2).mul(chord))
456    {
457        return Ok(None);
458    }
459    // The center lies on the line from `P1` through the chord's midpoint
460    // `M`, `|P1 - P0|^2 / |P1 - M|^2` times as far from `P1` as `M` is: the
461    // triangle `P0 P1 center` has its right angle at `P0`.
462    let m = p0.add(&p2).prod_scalar(Scalar::ONE.div(Scalar::TWO)?);
463    let h = m.sub(&p1).norm_sq();
464    if h.could_be_equal(Scalar::ZERO) {
465        return Ok(None);
466    }
467    let center = p1.add(&m.sub(&p1).prod_scalar(tangent.div(h)?));
468    let radial = p0.sub(&center);
469    // The piece leaves `P0` towards `P1`: turning counter-clockwise about
470    // `radial x (P1 - P0)`.
471    let normal = radial.prod_cross(&p1.sub(&p0)).normalize()?;
472    Ok(Some(Circle {
473        center: origin.add(&center),
474        normal,
475        radius: radial.norm(),
476    }))
477}
478
479// ── surfaces ──────────────────────────────────────────────────────────────────
480
481impl NurbSurface3D {
482    /// The plane the surface lies in, if it is flat: every control point on
483    /// the plane through its middle, normal to it there — the normal pointing
484    /// the way the surface's own does. `None` for a surface that bends.
485    pub fn as_plane(&self) -> GeopResult<Option<Plane>> {
486        let ((u0, u1), (v0, v1)) = (self.domain_u(), self.domain_v());
487        let (u, v) = (u0.add(u1).div(Scalar::TWO)?, v0.add(v1).div(Scalar::TWO)?);
488        let plane = Plane {
489            point: self.evaluate(u, v)?,
490            normal: self.normal(u, v)?,
491        };
492        let points = dehomogenize::<4, 3>(&self.control_points);
493        Ok(points
494            .iter()
495            .all(|p| plane.signed_distance(p).could_be_equal(Scalar::ZERO))
496            .then_some(plane))
497    }
498
499    /// The axis the surface turns around, if it is a surface of revolution —
500    /// a cylinder, a cone, a sphere, a torus, a disc: one of its parameter
501    /// directions sweeps circular arcs around a common axis. `None` for any
502    /// other surface.
503    ///
504    /// Checked on the control net: every row of control points along that
505    /// direction is an arc (see [`NurbCurve3D::as_arc`]) around the one
506    /// axis, starting at the same angle, with weights proportional to every
507    /// other row's — or a single point on the axis, as at a sphere's pole.
508    /// Then every row turns through the same angles at the same parameters,
509    /// and a blend of them across the other direction is the blended
510    /// profile, turned: a surface of revolution.
511    pub fn axis_of_revolution(&self) -> GeopResult<Option<Axis>> {
512        for along_u in [true, false] {
513            if let Some(axis) = self.revolution_along(along_u)? {
514                return Ok(Some(axis));
515            }
516        }
517        Ok(None)
518    }
519
520    /// The round wall the surface lies on, if it is a piece of one: a
521    /// surface of revolution (see [`NurbSurface3D::axis_of_revolution`])
522    /// whose rows of control points are arcs whose centres along the axis
523    /// and radii lie on one straight line — the revolution of a straight
524    /// profile, with no row collapsed to a pole. Every point of the surface
525    /// is a blend of the rows with the same weights for its height and its
526    /// radius, so it lies on that line too. A cylinder has no taper; `None`
527    /// for a sphere, a disc, a torus or any other surface.
528    pub fn as_round_wall(&self) -> GeopResult<Option<RoundWall>> {
529        for along_u in [true, false] {
530            let Some(revolution) = self.revolution_rows(along_u)? else {
531                continue;
532            };
533            if !revolution.poles.is_empty() {
534                continue;
535            }
536            let axis = revolution.axis;
537            let along = |c: &Vector3| c.sub(&axis.point).prod_dot(&axis.direction);
538            let rows: Vec<(Scalar, Scalar)> = (revolution.rows.iter())
539                .map(|(c, r)| (along(c), *r))
540                .collect();
541            let (&(z0, r0), &(z1, r1)) = match (rows.first(), rows.last()) {
542                (Some(first), Some(last)) => (first, last),
543                _ => continue,
544            };
545            let straight = rows.iter().all(|(_, r)| r.could_be_equal(r0));
546            let taper = if straight {
547                Scalar::ZERO
548            } else if z1.could_be_equal(z0) {
549                continue;
550            } else {
551                r1.sub(r0).div(z1.sub(z0))?
552            };
553            let radius = r0.sub(taper.mul(z0));
554            let on_line = |&(z, r): &(Scalar, Scalar)| r.could_be_equal(radius.add(taper.mul(z)));
555            if !rows.iter().all(on_line) {
556                continue;
557            }
558            let radius = if straight {
559                // The radii's union, whatever the rows' heights.
560                rows.iter().fold(r0, |a, &(_, r)| a.union(r))
561            } else {
562                radius
563            };
564            return Ok(Some(RoundWall {
565                axis,
566                radius,
567                taper,
568            }));
569        }
570        Ok(None)
571    }
572
573    /// [`NurbSurface3D::axis_of_revolution`], for rows along `u` or along `v`.
574    pub(crate) fn revolution_along(&self, along_u: bool) -> GeopResult<Option<Axis>> {
575        Ok(self.revolution_rows(along_u)?.map(|r| r.axis))
576    }
577
578    /// [`NurbSurface3D::revolution_along`], with the radius of every row
579    /// that is an arc and the point of every row that is a pole.
580    fn revolution_rows(&self, along_u: bool) -> GeopResult<Option<Revolution>> {
581        let (rows, len, degree, knots) = if along_u {
582            (self.num_v, self.num_u, self.degree_u, &self.knot_vector_u)
583        } else {
584            (self.num_u, self.num_v, self.degree_v, &self.knot_vector_v)
585        };
586        let row = |j: usize| -> Vec<_> {
587            (0..len)
588                .map(|i| {
589                    let index = if along_u {
590                        i * self.num_v + j
591                    } else {
592                        j * self.num_v + i
593                    };
594                    self.control_points[index]
595                })
596                .collect()
597        };
598        // The first row that is an arc sets the axis, the angle every row
599        // starts at, and the weights every row is proportional to.
600        let mut reference: Option<(Arc, Vec<Scalar>)> = None;
601        let mut poles = Vec::new();
602        let mut arcs = Vec::new();
603        for j in 0..rows {
604            let cps = row(j);
605            let weights: Vec<Scalar> = cps.iter().map(|p| p[3]).collect();
606            if let Some((_, reference_weights)) = &reference {
607                let proportional = (0..len).all(|i| {
608                    weights[i]
609                        .mul(reference_weights[0])
610                        .could_be_equal(reference_weights[i].mul(weights[0]))
611                });
612                if !proportional {
613                    return Ok(None);
614                }
615            }
616            let points = dehomogenize::<4, 3>(&cps);
617            if points.iter().all(|p| p.could_be_equal(&points[0])) {
618                poles.push(points[0]);
619                continue;
620            }
621            let Some(arc) = NurbCurve::try_new(degree, cps, knots.clone())?.as_arc()? else {
622                return Ok(None);
623            };
624            arcs.push((arc.circle.center, arc.circle.radius));
625            match &reference {
626                None => reference = Some((arc, weights)),
627                Some((first, _)) => {
628                    let axis = first.circle.axis();
629                    let same_angle = arc
630                        .start
631                        .sub(&arc.circle.center)
632                        .normalize()?
633                        .could_be_equal(&first.start.sub(&first.circle.center).normalize()?);
634                    if !arc.circle.normal.could_be_equal(&axis.direction)
635                        || !axis.could_contain(&arc.circle.center)
636                        || !same_angle
637                    {
638                        return Ok(None);
639                    }
640                }
641            }
642        }
643        let Some((first, _)) = reference else {
644            return Ok(None);
645        };
646        let axis = first.circle.axis();
647        Ok(poles
648            .iter()
649            .all(|p| axis.could_contain(p))
650            .then_some(Revolution {
651                axis,
652                rows: arcs,
653                poles,
654            }))
655    }
656}
657
658/// What [`NurbSurface3D::revolution_rows`] finds a surface of revolution's
659/// control rows to be: arcs around `axis`, each by its centre and radius,
660/// and `poles` on it.
661struct Revolution {
662    axis: Axis,
663    rows: Vec<(Vector3, Scalar)>,
664    poles: Vec<Vector3>,
665}
666
667/// A round wall: the points `radius + taper t` away from `axis` at `t`
668/// along it from `axis.point` — a cylinder with no taper, else a piece of a
669/// cone, as a tapered hole's wall or a pipe thread's.
670#[derive(Clone, Debug)]
671pub struct RoundWall {
672    pub axis: Axis,
673    pub radius: Scalar,
674    /// How much the radius grows per unit along `axis.direction`.
675    pub taper: Scalar,
676}
677
678impl RoundWall {
679    /// How far along its axis `p` lies, from `axis.point`.
680    pub fn along(&self, p: &Vector3) -> Scalar {
681        p.sub(&self.axis.point).prod_dot(&self.axis.direction)
682    }
683
684    /// Its radius where `p` lies along its axis.
685    pub fn radius_at(&self, p: &Vector3) -> Scalar {
686        self.radius.add(self.taper.mul(self.along(p)))
687    }
688
689    /// Whether `other` could be the same wall: on the same axis, of the
690    /// same radius where `other`'s axis point lies and the same taper,
691    /// whichever way either axis runs.
692    pub fn could_be_equal(&self, other: &RoundWall) -> bool {
693        let same_way = self.axis.direction.prod_dot(&other.axis.direction);
694        self.axis.could_be_parallel(&other.axis)
695            && self.axis.could_contain(&other.axis.point)
696            && self
697                .radius_at(&other.axis.point)
698                .could_be_equal(other.radius)
699            && self.taper.mul(same_way).could_be_equal(other.taper)
700    }
701}
702
703#[cfg(test)]
704mod tests {
705    use crate::test_fixtures::knots;
706    use geop_core_math::{
707        scalars::Scalar,
708        vector::{Vector3, Vector4},
709    };
710
711    use super::*;
712    use crate::nurb_surface::NurbSurface;
713
714    fn v(x: f64, y: f64, z: f64) -> Vector3 {
715        Vector3::from_array([x, y, z].map(Scalar::from_f64))
716    }
717
718    /// `(x, y, z)` with weight `w`, homogeneous.
719    fn h(x: f64, y: f64, z: f64, w: f64) -> Vector4 {
720        Vector4::from_array([x * w, y * w, z * w, w].map(Scalar::from_f64))
721    }
722
723    const R2: f64 = std::f64::consts::FRAC_1_SQRT_2;
724
725    /// The unit circle around the origin in the xy plane, counter-clockwise
726    /// from `(1, 0, 0)`: four quarter arcs, the kernel's usual form.
727    fn unit_circle() -> NurbCurve3D {
728        let corners = [
729            (1., 0.),
730            (1., 1.),
731            (0., 1.),
732            (-1., 1.),
733            (-1., 0.),
734            (-1., -1.),
735            (0., -1.),
736            (1., -1.),
737            (1., 0.),
738        ];
739        let cps = corners
740            .iter()
741            .enumerate()
742            .map(|(i, &(x, y))| h(x, y, 0., if i % 2 == 1 { R2 } else { 1. }))
743            .collect();
744        NurbCurve::try_new(
745            2,
746            cps,
747            knots(&[0., 0., 0., 1., 1., 2., 2., 3., 3., 4., 4., 4.]),
748        )
749        .unwrap()
750    }
751
752    #[test]
753    fn straight_curves_are_lines() {
754        let line = NurbCurve::<4>::try_new(
755            2,
756            vec![h(0., 0., 0., 1.), h(1., 2., 2., 0.5), h(3., 6., 6., 1.)],
757            knots(&[0., 0., 0., 1., 1., 1.]),
758        )
759        .unwrap();
760        let axis = line.as_line().unwrap().expect("collinear control points");
761        assert!(axis.direction.could_be_equal(&v(1. / 3., 2. / 3., 2. / 3.)));
762        assert!(unit_circle().as_line().unwrap().is_none());
763    }
764
765    #[test]
766    fn a_circle_is_recognized_with_its_turning_sense() {
767        let arc = unit_circle().as_arc().unwrap().expect("a circle");
768        assert!(arc.circle.center.could_be_equal(&v(0., 0., 0.)));
769        assert!(arc.circle.normal.could_be_equal(&v(0., 0., 1.)));
770        assert!(arc.circle.radius.could_be_equal(Scalar::ONE));
771        assert!(arc.could_be_closed());
772        assert!(arc.point_at(0.5).unwrap().could_be_equal(&v(-1., 0., 0.)));
773        let ninety = arc.point_at(0.25).unwrap();
774        assert!(ninety.could_be_equal(&v(0., 1., 0.)));
775        assert!(
776            arc.tangent_at(&ninety)
777                .unwrap()
778                .could_be_equal(&v(-1., 0., 0.))
779        );
780    }
781
782    /// A piece split off an arc — as a boolean leaves one — is still that
783    /// arc's circle, with its own ends.
784    #[test]
785    fn a_split_arc_is_still_an_arc() {
786        let (left, _) = unit_circle().split(Scalar::from_f64(1.3)).unwrap();
787        let arc = left.as_arc().unwrap().expect("still circular");
788        assert!(arc.circle.radius.could_be_equal(Scalar::ONE));
789        assert!(!arc.could_be_closed());
790        assert!(arc.sweep() > std::f64::consts::FRAC_PI_2);
791        assert!(arc.sweep() < std::f64::consts::PI);
792        // Measured, the angle and the length are enclosures of the turn.
793        let angle = arc.angle().unwrap();
794        assert!(
795            angle.could_be_equal(Scalar::from_f64(arc.sweep())),
796            "{angle:?}"
797        );
798        let length = arc.length().unwrap();
799        assert!(length.could_be_equal(angle), "{length:?}");
800        assert!(length.width().to_f64() < 1e-5, "{length:?}");
801        // Clockwise, seen from below: the same circle, the other normal.
802        let reversed = left.reverse().as_arc().unwrap().expect("still circular");
803        assert!(reversed.circle.normal.could_be_equal(&v(0., 0., -1.)));
804    }
805
806    /// A conic that is not a circle: the right corner, weighted wrong.
807    #[test]
808    fn other_conics_are_not_arcs() {
809        let conic = NurbCurve::<4>::try_new(
810            2,
811            vec![h(1., 0., 0., 1.), h(1., 1., 0., 0.5), h(0., 1., 0., 1.)],
812            knots(&[0., 0., 0., 1., 1., 1.]),
813        )
814        .unwrap();
815        assert!(conic.as_arc().unwrap().is_none());
816        let lopsided = NurbCurve::<4>::try_new(
817            2,
818            vec![h(1., 0., 0., 1.), h(1., 2., 0., R2), h(0., 1., 0., 1.)],
819            knots(&[0., 0., 0., 1., 1., 1.]),
820        )
821        .unwrap();
822        assert!(lopsided.as_arc().unwrap().is_none());
823    }
824
825    #[test]
826    fn arcs_become_curves() {
827        // Around (1, 1, 2), radius 2, turning about -z.
828        let circle = Circle {
829            center: v(1., 1., 2.),
830            normal: v(0., 0., -1.),
831            radius: Scalar::from_f64(2.),
832        };
833        let at = |angle: f64| v(1. + 2. * angle.cos(), 1. - 2. * angle.sin(), 2.);
834        for (from, to) in [(0.3, 1.2), (0.3, 3.0), (-2.0, 3.5), (0.5, 0.5)] {
835            let arc = Arc {
836                circle: circle.clone(),
837                start: at(from),
838                end: at(to),
839            };
840            let curve = arc.to_curve().unwrap();
841            let traced = curve.as_arc().unwrap().expect("an arc");
842            assert!(traced.circle.could_be_equal(&circle), "{traced:?}");
843            assert!(traced.start.could_be_equal(&arc.start));
844            assert!(traced.end.could_be_equal(&arc.end));
845            // It runs the way the arc does, all the way round for a circle:
846            // its pieces turn alike, so it is halfway round halfway along.
847            let sweep = if from == to {
848                std::f64::consts::TAU
849            } else {
850                to - from
851            };
852            let mid = curve.evaluate(Scalar::from_f64(0.5)).unwrap();
853            let expected = at(from + sweep / 2.);
854            let off = (0..3)
855                .map(|k| mid[k].sub(expected[k]).abs().to_f64())
856                .fold(0., f64::max);
857            assert!(off < 1e-7, "{mid:?} vs {expected:?}");
858        }
859    }
860
861    /// A quarter of a cylinder of radius 2 around the z axis, `u` around it
862    /// and `v` along it, 3 high.
863    fn quarter_cylinder() -> NurbSurface3D {
864        let ring = [(2., 0., 1.), (2., 2., R2), (0., 2., 1.)];
865        let cps = ring
866            .iter()
867            .flat_map(|&(x, y, w)| [h(x, y, 0., w), h(x, y, 3., w)])
868            .collect();
869        NurbSurface::try_new(
870            2,
871            1,
872            cps,
873            knots(&[0., 0., 0., 1., 1., 1.]),
874            knots(&[0., 0., 1., 1.]),
875        )
876        .unwrap()
877    }
878
879    #[test]
880    fn a_cylinder_turns_around_its_axis() {
881        let cylinder = quarter_cylinder();
882        let axis = cylinder.axis_of_revolution().unwrap().expect("a cylinder");
883        assert!(axis.could_contain(&v(0., 0., 7.)));
884        assert!(axis.direction.could_be_parallel(&v(0., 0., 1.)));
885        assert!(cylinder.as_plane().unwrap().is_none());
886
887        // Twisted: the top ring starts a little further round.
888        let mut twisted = cylinder.clone();
889        twisted.control_points[1] = h(2., 0.1, 3., 1.);
890        assert!(twisted.axis_of_revolution().unwrap().is_none());
891
892        let found = cylinder.as_round_wall().unwrap().expect("a cylinder");
893        assert!(found.radius.could_be_equal(Scalar::from_f64(2.0)));
894        assert!(found.taper.could_be_equal(Scalar::ZERO));
895        assert!(found.axis.could_contain(&v(0., 0., -1.)));
896        // Narrowing towards the top: a cone, which turns around the same
897        // axis, its radius shrinking by a third per unit up.
898        let mut cone = cylinder.clone();
899        for (k, &(x, y, w)) in [(1., 0., 1.), (1., 1., R2), (0., 1., 1.)]
900            .iter()
901            .enumerate()
902        {
903            cone.control_points[2 * k + 1] = h(x, y, 3., w);
904        }
905        assert!(cone.axis_of_revolution().unwrap().is_some());
906        let found = cone.as_round_wall().unwrap().expect("a cone");
907        let up = found.axis.direction[2].to_f64().signum();
908        assert!(
909            found
910                .taper
911                .mul(Scalar::from_f64(up))
912                .could_be_equal(Scalar::from_f64(-1. / 3.))
913        );
914        assert!(
915            found
916                .radius_at(&v(0., 0., 1.5))
917                .could_be_equal(Scalar::from_f64(1.5))
918        );
919        // Bulging in the middle: no straight profile.
920        let bulged = NurbSurface::try_new(
921            2,
922            2,
923            [(2., 0., 1.), (2., 2., R2), (0., 2., 1.)]
924                .iter()
925                .flat_map(|&(x, y, w)| {
926                    [h(x, y, 0., w), h(1.5 * x, 1.5 * y, 1.5, w), h(x, y, 3., w)]
927                })
928                .collect(),
929            knots(&[0., 0., 0., 1., 1., 1.]),
930            knots(&[0., 0., 0., 1., 1., 1.]),
931        )
932        .unwrap();
933        assert!(bulged.axis_of_revolution().unwrap().is_some());
934        assert!(bulged.as_round_wall().unwrap().is_none());
935    }
936
937    #[test]
938    fn flat_surfaces_are_planes() {
939        let flat = NurbSurface::<4>::try_new(
940            1,
941            1,
942            vec![
943                h(0., 0., 1., 1.),
944                h(0., 1., 1., 1.),
945                h(1., 0., 1., 1.),
946                h(1., 1., 1., 1.),
947            ],
948            knots(&[0., 0., 1., 1.]),
949            knots(&[0., 0., 1., 1.]),
950        )
951        .unwrap();
952        let plane = flat.as_plane().unwrap().expect("flat");
953        assert!(plane.normal.could_be_equal(&v(0., 0., 1.)));
954        assert!(
955            plane
956                .signed_distance(&v(5., 5., 1.))
957                .could_be_equal(Scalar::ZERO)
958        );
959        let mut bent = flat.clone();
960        bent.control_points[3] = h(1., 1., 1.2, 1.);
961        assert!(bent.as_plane().unwrap().is_none());
962    }
963
964    #[test]
965    fn planes_and_axes_meet_where_they_should() {
966        let z = Plane::try_new(v(0., 0., 2.), v(0., 0., 3.)).unwrap();
967        let x = Plane::try_new(v(1., 0., 0.), v(1., 0., 0.)).unwrap();
968        assert!(z.project(&v(4., 5., 6.)).could_be_equal(&v(4., 5., 2.)));
969        let line = z.intersect_plane(&x).unwrap();
970        assert!(line.could_contain(&v(1., 7., 2.)));
971        assert!(line.direction.could_be_parallel(&v(0., 1., 0.)));
972        let slanted = Axis::try_new(v(0., 0., 0.), v(1., 1., 1.)).unwrap();
973        assert!(
974            z.intersect_axis(&slanted)
975                .unwrap()
976                .could_be_equal(&v(2., 2., 2.))
977        );
978        assert!(
979            slanted
980                .project(&v(3., 0., 0.))
981                .could_be_equal(&v(1., 1., 1.))
982        );
983        let crossing = Axis::try_new(v(2., 0., 2.), v(0., 1., 0.)).unwrap();
984        assert!(
985            slanted
986                .nearest(&crossing)
987                .unwrap()
988                .could_be_equal(&v(2., 2., 2.))
989        );
990        // Missing each other: halfway along the common perpendicular, from
991        // (2.5, 2.5, 2.5) to (2, 2.5, 3).
992        let skew = Axis::try_new(v(2., 0., 3.), v(0., 1., 0.)).unwrap();
993        assert!(
994            slanted
995                .nearest(&skew)
996                .unwrap()
997                .could_be_equal(&v(2.25, 2.5, 2.75))
998        );
999        assert!(slanted.nearest(&slanted).is_err());
1000        assert!(z.intersect_plane(&z).is_err());
1001    }
1002}