Skip to main content

geop_core_geometry/nurb_curve/
helix.rs

1//! Helices: [`NurbCurve3D::helix`], and the control rows it is built from
2//! ([`helix_rows`]), which a screw sweep carries a whole profile along.
3//!
4//! A helix is not a rational curve: its height grows linearly with the angle
5//! it has turned, and no rational parametrization of a circle turns at a
6//! constant rate. So it is approximated, the same way every circle of the
7//! kernel is built: one rational quadratic per span of at most a quarter
8//! turn, its middle control point where the tangents of the circle meet,
9//! weighted `cos(step / 2)`. That part is exact, so **the curve lies exactly
10//! on its cylinder**: seen down the axis it is the kernel's own circle. Only
11//! the height is approximate. It rises from span end to span end exactly as
12//! a helix does, the middle control row sits at the height halfway between
13//! (where the curve, by symmetry, passes at the span's middle angle, exactly
14//! on the helix), and in between the height runs ahead of or behind the true
15//! helix by at most **0.53 % of the pitch** for quarter-turn spans (`5.3e-3`
16//! times the pitch, measured over the span; see the test). Any other middle height
17//! is worse: the error is odd about the middle of the span, and moving the
18//! middle row adds an even term.
19//!
20//! For an ISO metric thread that is 5 µm on an M6 (pitch 1 mm), well inside
21//! the tolerance of the thread itself.
22//!
23//! A helix may also be **tapered** ([`Helix::taper`]), its radius growing in
24//! step with its height, as a pipe thread's (NPT) does: on a cone instead of
25//! a cylinder. The radius is then built as the height is, exactly at every
26//! span's end and its middle row at the mean of the two, so the curve leaves
27//! the cone between them as the height leaves the helix, by a fraction of
28//! how much the radius grows in a quarter turn (see the test).
29
30use geop_core_math::{
31    geop_error::{GeopError, GeopResult},
32    primitives::CoordinateSystem,
33    scalars::Scalar,
34    vector::{Vector2, Vector3},
35};
36
37use super::{NurbCurve, NurbCurve3D};
38
39/// Which way a helix turns as it rises: right-handed, like an ordinary
40/// screw — counter-clockwise seen from where it rises to — or left-handed.
41#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
42pub enum Handedness {
43    #[default]
44    Right,
45    Left,
46}
47
48/// The screw motion a helix follows: rising `pitch` per turn through
49/// `turns` turns, turning as `handedness` says, and moving out from the axis
50/// by `taper` for every unit it rises — none for a helix on a cylinder.
51#[derive(Clone, Copy, Debug)]
52pub struct Helix {
53    pub pitch: Scalar,
54    pub turns: f64,
55    pub handedness: Handedness,
56    pub taper: Scalar,
57}
58
59impl Helix {
60    /// The motion along a helix on a cylinder.
61    pub fn new(pitch: Scalar, turns: f64, handedness: Handedness) -> Self {
62        Self {
63            pitch,
64            turns,
65            handedness,
66            taper: Scalar::ZERO,
67        }
68    }
69
70    /// This motion, moving out from the axis by `taper` for every unit it
71    /// rises: on a cone whose radius grows by `taper` per unit of height,
72    /// half a pipe thread's taper on the diameter.
73    pub fn tapered(self, taper: Scalar) -> Self {
74        Self { taper, ..self }
75    }
76}
77
78/// One control row of a helix around `frame.w()` through `frame.origin()`:
79/// a point at distance `r` from the axis, raised by `z` above the row, lies
80/// at `origin + (r + out) radial + (rise + z) w`, weighted `weight` — none
81/// for a row at a span's end, whose weight is one.
82#[derive(Clone, Debug)]
83pub struct HelixRow {
84    /// Unit length at a span's end; at a span's middle, as far out as the
85    /// tangents of the unit circle at the span's ends meet.
86    pub radial: Vector3,
87    pub rise: Scalar,
88    /// How far a tapered helix has moved out from its axis here: its taper
89    /// times the rise. None on a cylinder.
90    pub out: Option<Scalar>,
91    pub weight: Option<Scalar>,
92}
93
94/// `(cos, sin)` of `degrees` — exact at every quarter turn, so that a
95/// revolve through a right angle lands exactly on the plane it should, and
96/// a helix's spans of a quarter turn sit on exactly the circle a revolve's
97/// do.
98pub fn cos_sin(degrees: f64) -> (f64, f64) {
99    match degrees.rem_euclid(360.0) {
100        0.0 => (1.0, 0.0),
101        90.0 => (0.0, 1.0),
102        180.0 => (-1.0, 0.0),
103        270.0 => (0.0, -1.0),
104        d => (d.to_radians().cos(), d.to_radians().sin()),
105    }
106}
107
108/// `sqrt(2) / 2`, the weight of the middle control point that makes a
109/// rational quadratic a quarter of a circle exactly.
110pub fn sqrt2_over_2() -> Scalar {
111    Scalar::from_f64(std::f64::consts::SQRT_2 / 2.0)
112}
113
114/// The middle control point of an arc through `step` degrees, at most a
115/// half turn either way: `(weight, outwards)`. It lies where the tangents
116/// at the arc's ends meet, `outwards = 1 / (1 + cos step)` along the sum of
117/// the unit directions to the ends, weighted `cos(step / 2)` —
118/// [`sqrt2_over_2`] for a quarter turn. Revolves and helices both build
119/// their spans with it, so a helix's quarter lies on the very circle a
120/// revolved quarter does.
121pub fn arc_span(step: f64) -> (Scalar, Scalar) {
122    let (cos_step, _) = cos_sin(step);
123    let weight = if step.abs() == 90.0 {
124        sqrt2_over_2()
125    } else {
126        Scalar::from_f64((step / 2.0).to_radians().cos())
127    };
128    (weight, Scalar::from_f64(1.0 / (1.0 + cos_step)))
129}
130
131/// The control rows of a helix around `frame.w()`, from `frame.u()`, moving
132/// as `helix` says along `w`: `2 n + 1` rows for `n` equal spans of at most
133/// a quarter turn — a span's ends at even indices, its middle in between.
134pub fn helix_rows(frame: &CoordinateSystem, helix: &Helix) -> GeopResult<Vec<HelixRow>> {
135    let Helix {
136        pitch,
137        turns,
138        handedness,
139        taper,
140    } = *helix;
141    if !(turns.is_finite() && turns > 0.0) {
142        return Err(GeopError::new(format!(
143            "helix: cannot turn {turns} times: it must be more than none"
144        )));
145    }
146    if !pitch.definitely_greater(Scalar::ZERO) {
147        return Err(GeopError::new(format!(
148            "helix: the pitch {pitch:?} must be definitely positive"
149        )));
150    }
151    let spans = (turns * 4.0).ceil() as usize;
152    let sign = match handedness {
153        Handedness::Right => 1.0,
154        Handedness::Left => -1.0,
155    };
156    let step = sign * 360.0 * turns / spans as f64;
157    let direction = |degrees: f64| {
158        let (c, s) = cos_sin(degrees);
159        frame.uv_direction(&Vector2::from_array([
160            Scalar::from_f64(c),
161            Scalar::from_f64(s),
162        ]))
163    };
164    let (weight, outwards) = arc_span(step);
165    let total = pitch.mul(Scalar::from_f64(turns));
166    // The height at the end of span `k`: none at the start and all of it at
167    // the end, taken as they are rather than multiplied by 0 or 1.
168    let rise = |k: usize| -> GeopResult<Scalar> {
169        Ok(match k {
170            0 => Scalar::ZERO,
171            k if k == spans => total,
172            k => total.mul(Scalar::from_ratio(k as i64, spans as i64)?),
173        })
174    };
175    // Out by the taper times the rise, built as the rise is: none where
176    // there is no taper, rather than multiplied by zero.
177    let out = |rise: Scalar| (!taper.is_zero()).then(|| taper.mul(rise));
178    let mut rows = Vec::with_capacity(2 * spans + 1);
179    let mut start = HelixRow {
180        radial: direction(0.0),
181        rise: Scalar::ZERO,
182        out: out(Scalar::ZERO),
183        weight: None,
184    };
185    for k in 0..spans {
186        let rise_end = rise(k + 1)?;
187        let end = HelixRow {
188            radial: direction((k + 1) as f64 * step),
189            rise: rise_end,
190            out: out(rise_end),
191            weight: None,
192        };
193        let rise_middle = start.rise.add(end.rise).div(Scalar::TWO)?;
194        let middle = HelixRow {
195            radial: start.radial.add(&end.radial).prod_scalar(outwards),
196            rise: rise_middle,
197            out: out(rise_middle),
198            weight: Some(weight),
199        };
200        rows.push(start);
201        rows.push(middle);
202        start = end;
203    }
204    rows.push(start);
205    Ok(rows)
206}
207
208impl NurbCurve3D {
209    /// The helix of `radius` around `frame.w()` through `frame.origin()`:
210    /// starting at `origin + radius u`, moving as `helix` says along `w` —
211    /// right-handed turning from `u` towards `v`, left-handed the other
212    /// way. One rational quadratic per span of at most a quarter turn,
213    /// exactly on the cylinder, its height within 0.53 % of the pitch of
214    /// the true helix (see the module docs); tapered, on the cone as
215    /// closely. The parameter runs over `[0, 1]`, a span's end at every
216    /// `k / n`. Refused where the radius could come to nothing.
217    pub fn helix(frame: &CoordinateSystem, radius: Scalar, helix: &Helix) -> GeopResult<Self> {
218        let rows = helix_rows(frame, helix)?;
219        let radius_at = |row: &HelixRow| row.out.map_or(radius, |out| radius.add(out));
220        if let Some(row) = rows
221            .iter()
222            .find(|row| !radius_at(row).definitely_greater(Scalar::ZERO))
223        {
224            return Err(GeopError::new(format!(
225                "helix: the radius {:?} must be definitely positive, starting at {radius:?}",
226                radius_at(row)
227            )));
228        }
229        let spans = rows.len() / 2;
230        let (origin, w) = (frame.origin(), frame.w());
231        let control_points = rows
232            .iter()
233            .map(|row| {
234                origin
235                    .add(&row.radial.prod_scalar(radius_at(row)))
236                    .add(&w.prod_scalar(row.rise))
237                    .homogeneous(row.weight.unwrap_or(Scalar::ONE))
238            })
239            .collect();
240        let mut knots = vec![Scalar::ZERO; 3];
241        for k in 1..spans {
242            let knot = Scalar::from_ratio(k as i64, spans as i64)?;
243            knots.extend([knot, knot]);
244        }
245        knots.extend([Scalar::ONE; 3]);
246        NurbCurve::try_new(2, control_points, knots)
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use geop_core_math::{primitives::CoordinateSystem, scalars::Scalar, vector::Vector3};
253
254    use super::*;
255
256    fn frame() -> CoordinateSystem {
257        CoordinateSystem::world_at(Vector3::from_array([1.0, 2.0, 3.0].map(Scalar::from_f64)))
258    }
259
260    /// Every point of the helix lies on its cylinder, it ends where it
261    /// should, and its height stays within 0.53 % of the pitch of the true
262    /// helix's at the same angle — turning the right way round.
263    #[test]
264    fn helix_is_on_its_cylinder_and_close_to_the_true_helix() {
265        let (radius, pitch, turns) = (2.5, 0.8, 2.3);
266        for handedness in [Handedness::Right, Handedness::Left] {
267            let curve = NurbCurve3D::helix(
268                &frame(),
269                Scalar::from_f64(radius),
270                &Helix::new(Scalar::from_f64(pitch), turns, handedness),
271            )
272            .unwrap();
273            let sign = if handedness == Handedness::Right {
274                1.0
275            } else {
276                -1.0
277            };
278            let samples = 997;
279            let mut unwrapped = 0.0f64;
280            let mut last = 0.0f64;
281            let mut worst = 0.0f64;
282            for i in 0..=samples {
283                let t = Scalar::from_ratio(i, samples).unwrap();
284                let p = curve.evaluate(t).unwrap();
285                let centre = Vector3::from_array([1.0, 2.0, p[2].to_f64()].map(Scalar::from_f64));
286                let off = p.sub(&centre);
287                let r2 = off[0].mul(off[0]).add(off[1].mul(off[1]));
288                assert!(
289                    r2.could_be_equal(Scalar::from_f64(radius * radius)),
290                    "{p:?} is off the cylinder"
291                );
292                let (x, y, z) = (
293                    p[0].to_f64() - 1.0,
294                    p[1].to_f64() - 2.0,
295                    p[2].to_f64() - 3.0,
296                );
297                let angle = sign * y.atan2(x);
298                let mut d = angle - last;
299                if d < -std::f64::consts::PI {
300                    d += 2.0 * std::f64::consts::PI;
301                }
302                if d > std::f64::consts::PI {
303                    d -= 2.0 * std::f64::consts::PI;
304                }
305                // It never turns back.
306                assert!(d >= -1e-12, "{i}: {d}");
307                unwrapped += d;
308                last = angle;
309                let expected = pitch * unwrapped / (2.0 * std::f64::consts::PI);
310                worst = worst.max((z - expected).abs());
311            }
312            assert!(
313                (unwrapped - turns * 2.0 * std::f64::consts::PI).abs() < 1e-9,
314                "{unwrapped}"
315            );
316            assert!(worst <= 5.3e-3 * pitch, "{worst}");
317            // Not merely a circle lifted: it does rise with the angle.
318            assert!(worst > 1e-4 * pitch, "{worst}");
319            let end = curve.evaluate(Scalar::ONE).unwrap();
320            assert!(
321                end[2].could_be_equal(
322                    Scalar::from_f64(3.0).add(Scalar::from_f64(pitch).mul(Scalar::from_f64(turns)))
323                ),
324                "{end:?}"
325            );
326        }
327    }
328
329    /// Nothing to turn, no pitch, no radius: refused.
330    #[test]
331    fn degenerate_helices_are_refused() {
332        use geop_core_math::scalars::Scalar;
333        let f = frame();
334        let one = Scalar::from_f64(1.0);
335        let right = |pitch: Scalar, turns: f64| Helix::new(pitch, turns, Handedness::Right);
336        assert!(NurbCurve3D::helix(&f, one, &right(one, 0.0)).is_err());
337        assert!(NurbCurve3D::helix(&f, one, &right(Scalar::ZERO, 1.0)).is_err());
338        assert!(NurbCurve3D::helix(&f, Scalar::ZERO, &right(one, 1.0)).is_err());
339        // Tapered in so far that it would reach its axis.
340        let inwards = right(one, 2.0).tapered(Scalar::from_f64(-0.6));
341        assert!(NurbCurve3D::helix(&f, one, &inwards).is_err());
342    }
343
344    /// A pipe thread's helix, tapered 1 in 32 on the radius: it starts and
345    /// ends where the cone has it, at every quarter turn it is on the cone,
346    /// and in between it stays within a hundredth of what the radius grows
347    /// in a quarter turn.
348    #[test]
349    fn tapered_helix_is_on_its_cone_at_every_quarter_and_close_between() {
350        let (radius, pitch, turns, taper) = (10.0, 1.8, 3.5, 1.0 / 32.0);
351        let helix = Helix::new(Scalar::from_f64(pitch), turns, Handedness::Right)
352            .tapered(Scalar::from_f64(taper));
353        let curve = NurbCurve3D::helix(&frame(), Scalar::from_f64(radius), &helix).unwrap();
354        let off_cone = |p: &Vector3| {
355            let (x, y, z) = (
356                p[0].to_f64() - 1.0,
357                p[1].to_f64() - 2.0,
358                p[2].to_f64() - 3.0,
359            );
360            x.hypot(y) - (radius + taper * z)
361        };
362        let spans = (turns * 4.0).ceil() as i64;
363        for k in 0..=spans {
364            let p = curve
365                .evaluate(Scalar::from_ratio(k, spans).unwrap())
366                .unwrap();
367            assert!(off_cone(&p).abs() < 1e-12, "quarter {k}: {p:?}");
368        }
369        let quarter = taper * pitch / 4.0;
370        let samples = 997;
371        let worst = (0..=samples)
372            .map(|i| {
373                off_cone(
374                    &curve
375                        .evaluate(Scalar::from_ratio(i, samples).unwrap())
376                        .unwrap(),
377                )
378            })
379            .fold(0.0f64, |w, d| w.max(d.abs()));
380        assert!(worst < 1e-2 * quarter, "{worst} against {quarter}");
381        assert!(worst > 0.0);
382        let end = curve.evaluate(Scalar::ONE).unwrap();
383        assert!(
384            off_cone(&end).abs() < 1e-12 && (end[2].to_f64() - 3.0 - pitch * turns).abs() < 1e-12,
385            "{end:?}"
386        );
387    }
388}