lib_curveball/curve/extrude/
path.rs

1// Copyright 2025 Jordan Johnson
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! The [Path] trait and a number of structs implementing that trait.
5
6use std::f64::consts::PI;
7
8use glam::{DVec2, DVec3};
9use lerp::Lerp;
10use thiserror::Error;
11
12use super::FrenetFrame;
13use itertools::Itertools;
14
15pub type PathResult<T> = Result<T, PathError>;
16
17/// A trait representing a path in 3D space.
18pub trait Path {
19    /// Given a parameter `t` that varies between `0.0` and `1.0`, produce a point in 3D space.
20    fn point(&self, t: f64) -> DVec3;
21
22    /// Given a parameter `t` that varies between `0.0` and `1.0`, produce a [FrenetFrame]. See
23    /// [FrenetFrame] for more information.
24    ///
25    /// Implementing this function is required, though if you do not plan to use
26    /// `ProfileOrientation::FollowPath`, you may have this function return placeholder vectors
27    /// like [DVec3::default].
28    fn frame(&self, t: f64) -> FrenetFrame;
29}
30
31// Make Box<dyn Path> implement Path
32impl Path for Box<dyn Path + '_> {
33    fn point(&self, t: f64) -> DVec3 {
34        (**self).point(t)
35    }
36    fn frame(&self, t: f64) -> FrenetFrame {
37        (**self).frame(t)
38    }
39}
40
41#[derive(Error, Debug)]
42pub enum PathError {
43    #[error("{0}")]
44    SinusoidError(#[from] SinusoidError),
45    #[error("{0}")]
46    BezierError(#[from] BezierError),
47    #[error("{0}")]
48    CatenaryError(#[from] CatenaryError),
49    #[error("{0}")]
50    SerpentineError(#[from] SerpentineError),
51}
52
53// Tip: the tangent vector in the frenet frame should always be the derivative of the path function
54// with respect to the parameter, normalized.
55
56// ==================== Line ====================
57
58/// A line from the origin to another point in 3D space.
59#[derive(Debug, Clone)]
60pub struct Line {
61    x: f64,
62    y: f64,
63    z: f64,
64}
65
66impl Line {
67    pub fn new(x: f64, y: f64, z: f64) -> Self {
68        Self { x, y, z }
69    }
70}
71
72impl Path for Line {
73    fn point(&self, t: f64) -> DVec3 {
74        DVec3 {
75            x: self.x,
76            y: self.y,
77            z: self.z,
78        } * t
79    }
80    fn frame(&self, _t: f64) -> FrenetFrame {
81        let tangent = DVec3 {
82            x: self.x,
83            y: self.y,
84            z: self.z,
85        }
86        .normalize_or_zero();
87        let normal = DVec3 {
88            x: -self.y,
89            y: self.x,
90            z: 0.0,
91        }
92        .normalize_or_zero();
93        let binormal = tangent.cross(normal);
94        FrenetFrame {
95            tangent,
96            normal,
97            binormal,
98        }
99    }
100}
101
102// ==================== Revolve ====================
103
104/// A circular path around the vertical axis at the origin.
105#[derive(Debug, Clone)]
106pub struct Revolve {
107    radius: f64,
108    start_angle_rad: f64,
109    end_angle_rad: f64,
110}
111
112impl Revolve {
113    pub fn new(radius: f64, start_angle: f64, end_angle: f64) -> Self {
114        Self {
115            radius,
116            start_angle_rad: start_angle * PI / 180.0,
117            end_angle_rad: end_angle * PI / 180.0,
118        }
119    }
120}
121
122impl Path for Revolve {
123    fn point(&self, t: f64) -> DVec3 {
124        let theta = self.start_angle_rad.lerp(self.end_angle_rad, t);
125        DVec3 {
126            x: self.radius * theta.cos(),
127            y: self.radius * theta.sin(),
128            z: 0.0,
129        }
130    }
131    fn frame(&self, t: f64) -> FrenetFrame {
132        let theta = self.start_angle_rad.lerp(self.end_angle_rad, t);
133        FrenetFrame {
134            tangent: DVec3 {
135                x: -theta.sin(),
136                y: theta.cos(),
137                z: 0.0,
138            },
139            normal: DVec3 {
140                x: -theta.cos(),
141                y: -theta.sin(),
142                z: 0.0,
143            },
144            binormal: DVec3 {
145                x: 0.0,
146                y: 0.0,
147                z: 1.0,
148            },
149        }
150    }
151}
152
153// Period is in units of space.
154// Phase is also in units of space.
155
156// ==================== Sinusoid ====================
157
158/// A sinusoidal path with a particular phase, amplitude, and period.
159#[derive(Debug, Clone)]
160pub struct Sinusoid {
161    amplitude: f64,
162    period: f64,
163    phase: f64,
164    start: f64,
165    end: f64,
166}
167
168impl Sinusoid {
169    pub fn new(amplitude: f64, period: f64, phase: f64, start: f64, end: f64) -> PathResult<Self> {
170        if period <= 0.0 {
171            return Err(SinusoidError::SinusoidInfiniteFrequency(period))?;
172        }
173        Ok(Self {
174            amplitude,
175            period,
176            phase,
177            start,
178            end,
179        })
180    }
181}
182
183impl Path for Sinusoid {
184    fn point(&self, t: f64) -> DVec3 {
185        let x = self.start.lerp(self.end, t);
186        let omega = 2.0 * PI / self.period;
187        DVec3 {
188            x,
189            y: 0.0,
190            z: self.amplitude * f64::sin(omega * (x + self.phase)),
191        }
192    }
193    fn frame(&self, t: f64) -> FrenetFrame {
194        let x = self.start.lerp(self.end, t);
195        let omega = 2.0 * PI / self.period;
196        FrenetFrame {
197            tangent: DVec3 {
198                x: 1.0,
199                y: 0.0,
200                z: self.amplitude * f64::cos(omega * (x + self.phase)) * omega,
201            }
202            .normalize_or_zero(),
203            normal: DVec3 {
204                x: 0.0,
205                y: 1.0,
206                z: 0.0,
207            },
208            binormal: DVec3 {
209                x: -self.amplitude * f64::cos(omega * (x + self.phase)) * omega,
210                y: 0.0,
211                z: 1.0,
212            }
213            .normalize_or_zero(),
214        }
215    }
216}
217
218#[derive(Error, Debug)]
219pub enum SinusoidError {
220    #[error("Period of {0} is invalid; must be positive")]
221    SinusoidInfiniteFrequency(f64),
222}
223
224// ==================== Bezier ====================
225
226/// A [Bezier curve](https://en.wikipedia.org/wiki/B%C3%A9zier_curve) in 3D space, defined by a
227/// vector of control points.
228#[derive(Debug, Clone)]
229pub struct Bezier {
230    points: Vec<DVec2>,
231}
232
233impl Bezier {
234    pub fn new(points: Vec<DVec2>) -> Result<Self, BezierError> {
235        if points.len() < 2 {
236            return Err(BezierError::NotEnoughPoints(points.len()));
237        }
238        Ok(Self { points })
239    }
240}
241
242impl Path for Bezier {
243    fn point(&self, t: f64) -> DVec3 {
244        let point2d = bezier(&self.points, t);
245        DVec3 {
246            x: point2d.x,
247            y: 0.0,
248            z: point2d.y,
249        }
250    }
251    fn frame(&self, t: f64) -> FrenetFrame {
252        let point2d = bezier_derivative(&self.points, t);
253        let tangent = DVec3 {
254            x: point2d.x,
255            y: 0.0,
256            z: point2d.y,
257        }
258        .normalize_or_zero();
259        let normal = DVec3 {
260            x: 0.0,
261            y: 1.0,
262            z: 0.0,
263        };
264        let binormal = tangent.cross(normal);
265        FrenetFrame {
266            tangent,
267            normal,
268            binormal,
269        }
270    }
271}
272
273#[derive(Error, Debug)]
274pub enum BezierError {
275    #[error("Bezier curve requires at least two points; {0} provided")]
276    NotEnoughPoints(usize),
277}
278
279fn bezier(points: &Vec<DVec2>, t: f64) -> DVec2 {
280    let result = recursive_bezier(points, t);
281    result[0]
282}
283
284// possible clippy bug?
285#[allow(clippy::ptr_arg)]
286fn recursive_bezier(points: &Vec<DVec2>, t: f64) -> Vec<DVec2> {
287    if points.len() == 1 {
288        vec![points[0]]
289    } else {
290        recursive_bezier(
291            &points
292                .iter()
293                .tuple_windows()
294                .map(|(point1, point2)| point1.lerp(*point2, t))
295                .collect(),
296            t,
297        )
298    }
299}
300
301fn bezier_derivative(points: &[DVec2], t: f64) -> DVec2 {
302    let n = points.len() as f64;
303    let intersparsed_points = points
304        .iter()
305        .tuple_windows()
306        .map(|(point1, point2)| n * (point2 - point1))
307        .collect();
308    bezier(&intersparsed_points, t)
309}
310
311// ==================== Catenary ====================
312
313/// A [Catenary](https://en.wikipedia.org/wiki/Catenary) curve; the shape a cable takes on when
314/// hung between two points.
315#[derive(Debug, Clone)]
316pub struct Catenary {
317    a: f64,
318    k: f64,
319    c: f64,
320    span: f64,
321}
322
323impl Catenary {
324    pub fn new(span: f64, height: f64, s: f64) -> Result<Self, CatenaryError> {
325        // get delta values
326        let v = height;
327        let h = span;
328
329        let initial_guess = 1.0
330            / f64::sqrt(f64::sqrt(f64::powi(s, 2) - f64::powi(v, 2)) / h - 1.0)
331            / (2.0 * f64::sqrt(6.0));
332
333        let min_s = f64::sqrt(f64::powi(height, 2) + f64::powi(span, 2));
334        if s <= min_s {
335            return Err(CatenaryError::LengthTooShort {
336                given: s,
337                min: min_s,
338            })?;
339        }
340
341        let a = newton_a(v, h, s, 0.0, 0.0, span, height, initial_guess)?;
342
343        // Find the other catenary parameters. Thankfully these aren't as bad.
344        let k: f64 = 0.5 * (h - a * f64::ln((s + v) / (s - v)));
345        let c: f64 = -a * f64::cosh((-k) / a);
346
347        Ok(Self { a, k, c, span })
348    }
349}
350
351impl Path for Catenary {
352    fn point(&self, t: f64) -> DVec3 {
353        let x = 0.0.lerp(self.span, t);
354        DVec3 {
355            x,
356            y: 0.0,
357            z: catenary(x, self.a, self.k, self.c),
358        }
359    }
360    fn frame(&self, t: f64) -> FrenetFrame {
361        let x = 0.0.lerp(self.span, t);
362        let tangent = DVec3 {
363            x: 1.0,
364            y: 0.0,
365            z: f64::sinh((x - self.k) / self.a), // Derivative of catenary
366        }
367        .normalize_or_zero();
368        let normal = DVec3 {
369            x: 0.0,
370            y: 1.0,
371            z: 0.0,
372        };
373        let binormal = tangent.cross(normal);
374        FrenetFrame {
375            tangent,
376            normal,
377            binormal,
378        }
379    }
380}
381
382fn catenary(x: f64, a: f64, k: f64, c: f64) -> f64 {
383    a * f64::cosh((x - k) / a) + c
384}
385
386// Newton's method. Has the potential to fail to find a solution.
387#[allow(clippy::too_many_arguments)]
388fn newton_a(
389    v: f64,
390    h: f64,
391    s: f64,
392    x0: f64,
393    z0: f64,
394    x1: f64,
395    z1: f64,
396    initial_guess: f64,
397) -> Result<f64, CatenaryError> {
398    let iteration_limit = 10_000;
399
400    // Limit for how inaccurate our points can be. We only need accuracy to six decimal places,
401    // so this should be just fine.
402
403    let epsilon: f64 = f64::powi(10.0, -9);
404
405    // Initial guess
406    let mut b_i: f64 = initial_guess;
407    let mut b_ip1: f64 = b_i;
408    let mut icount: i32 = 0;
409    while catenary_bounds_err(x0, z0, x1, z1, b_ip1 * h, s) > epsilon && icount < iteration_limit {
410        b_i = b_ip1;
411        b_ip1 = b_i - f2b(b_i, v, h, s) / df2b(b_i);
412        icount += 1;
413    }
414    // a = bh
415    if icount >= iteration_limit
416        || !f64::is_finite(catenary_bounds_err(x0, z0, x1, z1, b_ip1 * h, s))
417    {
418        Err(CatenaryError::NewtonFail {
419            iterations: iteration_limit,
420            initial: initial_guess,
421        })
422    } else {
423        Ok(b_ip1 * h)
424    }
425}
426
427fn f2b(b: f64, v: f64, h: f64, s: f64) -> f64 {
428    1.0 / f64::sqrt(2.0 * b * f64::sinh(1.0 / (2.0 * b)) - 1.0)
429        - 1.0 / f64::sqrt(f64::sqrt(f64::powi(s, 2) - f64::powi(v, 2)) / h - 1.0)
430}
431
432fn df2b(b: f64) -> f64 {
433    let m = 1.0 / (2.0 * b);
434    (m * f64::cosh(m) - f64::sinh(m)) * f64::powf(1.0 / m * f64::sinh(m) - 1.0, -1.5)
435}
436
437fn catenary_bounds_err(x0: f64, z0: f64, x1: f64, z1: f64, a: f64, s: f64) -> f64 {
438    let v = z1 - z0;
439    let h = x1 - x0;
440    let k: f64 = x0 + 0.5 * (h - a * f64::ln((s + v) / (s - v)));
441    let c: f64 = z0 - a * f64::cosh((x0 - k) / a);
442    let error0 = f64::abs(catenary(x0, a, k, c) - z0);
443    let error1 = f64::abs(catenary(x1, a, k, c) - z1);
444    f64::max(error0, error1)
445}
446
447#[derive(Error, Debug)]
448pub enum CatenaryError {
449    #[error("Given length {given} is too short; must be greater than {min}.")]
450    LengthTooShort { given: f64, min: f64 },
451    #[error(
452        "Newton's method failed to converge to an accurate solution after {iterations} iterations. The initial guess was {initial}. Change the parameters to a less extreme catenary curve, or try again with a different initial guess."
453    )]
454    NewtonFail { iterations: i32, initial: f64 },
455}
456
457// ==================== Serpentine ====================
458
459/// A curve consisting of two tangent circles.
460///
461/// Named after a [serpentine belt](https://en.wikipedia.org/wiki/Serpentine_belt) in a car.
462#[derive(Debug, Clone)]
463pub struct Serpentine {
464    x: f64,
465    z: f64,
466}
467
468impl Serpentine {
469    pub fn new(x: f64, z: f64) -> Result<Self, SerpentineError> {
470        if z <= 0.0 {
471            return Err(SerpentineError::OrderedHeight);
472        }
473        if z > x {
474            return Err(SerpentineError::TooTall);
475        }
476
477        Ok(Self { x, z })
478    }
479}
480
481impl Path for Serpentine {
482    // Expected for t to vary between 0 and 1
483    fn point(&self, t: f64) -> DVec3 {
484        let xm = self.x / 2.0;
485        let zm = self.z / 2.0;
486
487        let zd = self.z - zm;
488        let xd = self.x - xm;
489
490        let r0 = ((zm * zm) + (xm * xm)) / (2.0 * zm);
491        let r1 = ((zd * zd) + (xd * xd)) / (2.0 * zd);
492
493        let theta0_start = -PI / 2.0;
494        let theta0_end = f64::asin(xm / r0) - PI / 2.0;
495
496        let theta1_start = PI / 2.0 + f64::asin(xd / r1);
497        let theta1_end = PI / 2.0;
498
499        if t < 0.5 {
500            let theta = theta0_start.lerp(theta0_end, t * 2.0);
501            DVec3 {
502                x: r0 * f64::cos(theta),
503                y: 0.0,
504                z: r0 * f64::sin(theta) + r0,
505            }
506        } else {
507            let theta = theta1_start.lerp(theta1_end, (t - 0.5) * 2.0);
508            DVec3 {
509                x: r1 * f64::cos(theta) + self.x,
510                y: 0.0,
511                z: r1 * f64::sin(theta) - r0 + self.z,
512            }
513        }
514    }
515    fn frame(&self, t: f64) -> FrenetFrame {
516        let xm = self.x / 2.0;
517        let zm = self.z / 2.0;
518
519        let zd = self.z - zm;
520        let xd = self.x - xm;
521
522        let r0 = ((zm * zm) + (xm * xm)) / (2.0 * zm);
523        let r1 = ((zd * zd) + (xd * xd)) / (2.0 * zd);
524
525        let theta0_start = -PI / 2.0;
526        let theta0_end = f64::asin(xm / r0) - PI / 2.0;
527
528        let theta1_start = PI / 2.0 + f64::asin(xd / r1);
529        let theta1_end = PI / 2.0;
530
531        let tangent = if t < 0.5 {
532            let theta = theta0_start.lerp(theta0_end, t * 2.0);
533            DVec3 {
534                x: -f64::sin(theta),
535                y: 0.0,
536                z: f64::cos(theta),
537            }
538        } else {
539            let theta = theta1_start.lerp(theta1_end, (t - 0.5) * 2.0);
540            DVec3 {
541                x: f64::sin(theta),
542                y: 0.0,
543                z: -f64::cos(theta),
544            }
545        };
546        let normal = DVec3 {
547            x: 0.0,
548            y: 1.0,
549            z: 0.0,
550        };
551        let binormal = tangent.cross(normal);
552        FrenetFrame {
553            tangent,
554            normal,
555            binormal,
556        }
557    }
558}
559
560#[derive(Error, Debug)]
561pub enum SerpentineError {
562    #[error("Ending height must be greater than the starting height.")]
563    OrderedHeight,
564    #[error("Serpentine curve height cannot be greater than its length.")]
565    TooTall,
566}