rosu_map/section/hit_objects/slider/
curve.rs

1use std::{borrow::Cow, cmp::Ordering, convert::identity, f64::consts::PI, iter, mem};
2
3use crate::{section::general::GameMode, util::Pos};
4
5use super::{path_type::SplineType, PathControlPoint};
6
7const BEZIER_TOLERANCE: f32 = 0.25;
8const CATMULL_DETAIL: usize = 50;
9const CIRCULAR_ARC_TOLERANCE: f32 = 0.1;
10
11/// Collection of buffers required to calculate a [`Curve`].
12#[derive(Clone, Debug, Default)]
13pub struct CurveBuffers {
14    path: Vec<Pos>,
15    lengths: Vec<f64>,
16    vertices: Vec<Pos>,
17    bezier: BezierBuffers,
18}
19
20#[derive(Clone, Debug, Default)]
21struct BezierBuffers {
22    left: Vec<Pos>,
23    right: Vec<Pos>,
24    midpoints: Vec<Pos>,
25    left_child: Vec<Pos>,
26}
27
28impl BezierBuffers {
29    /// Fill the buffers with new elements until a
30    /// length of `len` is reached. Does nothing if `len`
31    /// is already smaller than the current buffer size.
32    fn extend_exact(&mut self, len: usize) {
33        if len <= self.left.len() {
34            return;
35        }
36
37        let additional = len - self.left.len();
38
39        self.left
40            .extend(iter::repeat(Pos::default()).take(additional));
41        self.right
42            .extend(iter::repeat(Pos::default()).take(additional));
43        self.midpoints
44            .extend(iter::repeat(Pos::default()).take(additional));
45        self.left_child
46            .extend(iter::repeat(Pos::default()).take(additional));
47    }
48}
49
50struct CircularArcProperties {
51    theta_start: f64,
52    theta_range: f64,
53    direction: f64,
54    radius: f32,
55    centre: Pos,
56}
57
58/// A curve with owned lists of path points and segment lengths.
59#[derive(Clone, Debug, PartialEq)]
60pub struct Curve {
61    path: Vec<Pos>,
62    lengths: Vec<f64>,
63}
64
65impl Curve {
66    /// Create a new [`Curve`].
67    pub fn new(
68        mode: GameMode,
69        points: &[PathControlPoint],
70        expected_len: Option<f64>,
71        bufs: &mut CurveBuffers,
72    ) -> Self {
73        let mut optimized_len = 0.0;
74        calculate_path(mode, points, bufs, &mut optimized_len);
75        calculate_length(bufs, expected_len, optimized_len);
76
77        Self {
78            path: mem::take(&mut bufs.path),
79            lengths: mem::take(&mut bufs.lengths),
80        }
81    }
82
83    /// Path points of the [`Curve`].
84    pub fn path(&self) -> &[Pos] {
85        &self.path
86    }
87
88    /// The [`Curve`]'s length at each path point.
89    pub fn lengths(&self) -> &[f64] {
90        &self.lengths
91    }
92
93    /// The interpolated position at the given progress.
94    pub fn position_at(&self, progress: f64) -> Pos {
95        position_at(&self.path, &self.lengths, progress)
96    }
97
98    /// Value between 0.0 and the curve's distance, depending on the given
99    /// progress between 0.0 and 1.0.
100    pub fn progress_to_dist(&self, progress: f64) -> f64 {
101        progress_to_dist(&self.lengths, progress)
102    }
103
104    /// The total distance of the [`Curve`].
105    pub fn dist(&self) -> f64 {
106        dist(&self.lengths)
107    }
108
109    /// The index into [`Curve::lengths`] to reach the distance `d`.
110    pub fn idx_of_dist(&self, d: f64) -> usize {
111        idx_of_dist(&self.lengths, d)
112    }
113
114    /// Interpolate the position between the i'th and i+1'th path position
115    /// at distance `d`.
116    pub fn interpolate_vertices(&self, i: usize, d: f64) -> Pos {
117        interpolate_vertices(&self.path, &self.lengths, i, d)
118    }
119
120    /// Cheaply transform [`Curve`] into [`BorrowedCurve`].
121    pub fn as_borrowed_curve(&self) -> BorrowedCurve<'_> {
122        BorrowedCurve {
123            path: &self.path,
124            lengths: &self.lengths,
125        }
126    }
127}
128
129/// A curve with borrowed lists of path points and segment lengths.
130#[derive(Clone, Debug, PartialEq)]
131pub struct BorrowedCurve<'bufs> {
132    path: &'bufs [Pos],
133    lengths: &'bufs [f64],
134}
135
136impl<'bufs> BorrowedCurve<'bufs> {
137    /// Create a new [`BorrowedCurve`] which borrows from [`CurveBuffers`].
138    pub fn new(
139        mode: GameMode,
140        points: &[PathControlPoint],
141        expected_len: Option<f64>,
142        bufs: &'bufs mut CurveBuffers,
143    ) -> Self {
144        let mut optimized_len = 0.0;
145        calculate_path(mode, points, bufs, &mut optimized_len);
146        calculate_length(bufs, expected_len, optimized_len);
147
148        Self {
149            path: &bufs.path,
150            lengths: &bufs.lengths,
151        }
152    }
153
154    /// Path points of the [`BorrowedCurve`].
155    pub const fn path(&self) -> &[Pos] {
156        self.path
157    }
158
159    /// The [`BorrowedCurve`]'s length at each path point.
160    pub const fn lengths(&self) -> &[f64] {
161        self.lengths
162    }
163
164    /// The interpolated position at the given progress.
165    pub fn position_at(&self, progress: f64) -> Pos {
166        position_at(self.path, self.lengths, progress)
167    }
168
169    /// Value between 0.0 and the curve's distance, depending on the given
170    /// progress between 0.0 and 1.0.
171    pub fn progress_to_dist(&self, progress: f64) -> f64 {
172        progress_to_dist(self.lengths, progress)
173    }
174
175    /// The total distance of the [`BorrowedCurve`].
176    pub fn dist(&self) -> f64 {
177        dist(self.lengths)
178    }
179
180    /// The index into [`BorrowedCurve::lengths`] to reach the distance `d`.
181    pub fn idx_of_dist(&self, d: f64) -> usize {
182        idx_of_dist(self.lengths, d)
183    }
184
185    /// Interpolate the position between the i'th and i+1'th path position
186    /// at distance `d`.
187    pub fn interpolate_vertices(&self, i: usize, d: f64) -> Pos {
188        interpolate_vertices(self.path, self.lengths, i, d)
189    }
190
191    /// Allocates the borrowed lists to transform [`BorrowedCurve`] into [`Curve`].
192    pub fn to_owned_curve(&self) -> Curve {
193        Curve {
194            path: self.path.to_owned(),
195            lengths: self.lengths.to_owned(),
196        }
197    }
198}
199
200fn position_at(path: &[Pos], lengths: &[f64], progress: f64) -> Pos {
201    let d = progress_to_dist(lengths, progress);
202    let i = idx_of_dist(lengths, d);
203
204    interpolate_vertices(path, lengths, i, d)
205}
206
207fn progress_to_dist(lengths: &[f64], progress: f64) -> f64 {
208    progress.clamp(0.0, 1.0) * dist(lengths)
209}
210
211fn dist(lengths: &[f64]) -> f64 {
212    lengths.last().copied().unwrap_or(0.0)
213}
214
215fn idx_of_dist(lengths: &[f64], d: f64) -> usize {
216    lengths
217        .binary_search_by(|len| len.partial_cmp(&d).unwrap_or(Ordering::Equal))
218        .map_or_else(identity, identity)
219}
220
221fn interpolate_vertices(path: &[Pos], lengths: &[f64], i: usize, d: f64) -> Pos {
222    if path.is_empty() {
223        return Pos::default();
224    }
225
226    let p1 = if i == 0 {
227        return path[0];
228    } else if let Some(p) = path.get(i) {
229        *p
230    } else {
231        return path[path.len() - 1];
232    };
233
234    let p0 = path[i - 1];
235
236    let d0 = lengths[i - 1];
237    let d1 = lengths[i];
238
239    // * Avoid division by an almost-zero number in case
240    // * two points are extremely close to each other
241    if (d0 - d1).abs() <= f64::EPSILON {
242        return p0;
243    }
244
245    let w = (d - d0) / (d1 - d0);
246
247    p0 + (p1 - p0) * w as f32
248}
249
250fn calculate_path(
251    mode: GameMode,
252    points: &[PathControlPoint],
253    bufs: &mut CurveBuffers,
254    optimized_len: &mut f64,
255) {
256    if points.is_empty() {
257        return;
258    }
259
260    let CurveBuffers {
261        vertices,
262        bezier,
263        path,
264        ..
265    } = bufs;
266
267    path.clear();
268    *optimized_len = 0.0;
269
270    vertices.clear();
271    vertices.extend(points.iter().map(|p| p.pos));
272
273    let mut start = 0;
274
275    for i in 0..points.len() {
276        if points[i].path_type.is_none() && i < points.len() - 1 {
277            continue;
278        }
279
280        // * The current vertex ends the segment
281        let segment_vertices = &vertices[start..=i];
282
283        match segment_vertices.len() {
284            0 => {
285                // The slice comes from `..=` indexing which always includes at
286                // least one item.
287                unreachable!();
288            }
289            1 => {
290                // * No need to calculate path when there is only 1 vertex
291                path.push(segment_vertices[0]);
292            }
293            _ => {
294                let segment_kind = points[start]
295                    .path_type
296                    .map_or(SplineType::Linear, |path_type| path_type.kind);
297
298                let path_len = path.len();
299
300                calculate_subpath(
301                    mode,
302                    path,
303                    segment_vertices,
304                    segment_kind,
305                    optimized_len,
306                    bezier,
307                );
308
309                // * Skip the first vertex if it is the same as the last vertex from the previous segment
310                let skip_first = path_len
311                    .checked_sub(1)
312                    .zip(path.get(path_len))
313                    .map_or(false, |(idx, first)| &path[idx] == first);
314
315                if skip_first {
316                    path[path_len..].rotate_left(1);
317                    path.pop();
318                }
319            }
320        }
321
322        // We don't care about segment ends so we can skip that
323
324        // * Start the new segment at the current vertex
325        start = i;
326    }
327}
328
329fn calculate_length(bufs: &mut CurveBuffers, expected_len: Option<f64>, optimized_len: f64) {
330    let CurveBuffers {
331        path,
332        lengths: cumulative_len,
333        ..
334    } = bufs;
335
336    cumulative_len.clear();
337    let mut calculated_len = optimized_len;
338    cumulative_len.reserve(path.len());
339    cumulative_len.push(0.0);
340
341    let length_iter = path.iter().zip(path.iter().skip(1)).map(|(&curr, &next)| {
342        calculated_len += f64::from((next - curr).length());
343
344        calculated_len
345    });
346
347    cumulative_len.extend(length_iter);
348
349    if let Some(expected_len) =
350        expected_len.filter(|&len| (calculated_len - len).abs() >= f64::EPSILON)
351    {
352        // * In osu-stable, if the last two path points of a slider are equal, extension is not performed
353        if matches!(path.as_slice() , [.., a, b] if a == b && expected_len > calculated_len) {
354            cumulative_len.push(calculated_len);
355            return;
356        }
357
358        // Shortcut when it's just (0,0) since there's nothing to do anyway
359        if cumulative_len.len() == 1 {
360            return;
361        }
362
363        // * The last length is always incorrect
364        cumulative_len.pop();
365
366        let last_valid = cumulative_len
367            .iter()
368            .rev()
369            .position(|l| *l < expected_len)
370            .map_or(0, |idx| cumulative_len.len() - idx);
371
372        // * The path will be shortened further, in which case we should trim
373        // * any more unnecessary lengths and their associated path segments
374        if last_valid < cumulative_len.len() {
375            cumulative_len.truncate(last_valid);
376            path.truncate(last_valid + 1);
377
378            if cumulative_len.is_empty() {
379                // * The expected distance is negative or zero
380                // * Perhaps negative path lengths should be disallowed altogether
381                cumulative_len.push(0.0);
382
383                return;
384            }
385        }
386
387        let end_idx = cumulative_len.len();
388        let prev_idx = end_idx - 1;
389
390        // * The direction of the segment to shorten or lengthen
391        let dir = (path[end_idx] - path[prev_idx]).normalize();
392
393        path[end_idx] = path[prev_idx] + dir * (expected_len - cumulative_len[prev_idx]) as f32;
394        cumulative_len.push(expected_len);
395    }
396}
397
398fn calculate_subpath(
399    mode: GameMode,
400    path: &mut Vec<Pos>,
401    sub_points: &[Pos],
402    path_type: SplineType,
403    optimized_len: &mut f64,
404    bufs: &mut BezierBuffers,
405) {
406    match path_type {
407        SplineType::Linear => approximate_linear(path, sub_points),
408        SplineType::PerfectCurve => {
409            if let [a, b, c] = sub_points {
410                if approximate_circular_arc(path, *a, *b, *c) {
411                    return;
412                }
413            }
414
415            approximate_bezier(path, sub_points, bufs);
416        }
417        SplineType::Catmull => {
418            let start_len = path.len();
419            approximate_catmull(path, sub_points);
420
421            if !matches!(mode, GameMode::Osu) {
422                return;
423            }
424
425            let sub_path = path.split_off(start_len);
426
427            let mut last_start = None;
428            let mut len_removed_since_start = 0.0;
429
430            for (i, curr) in sub_path.iter().copied().enumerate() {
431                let Some(ref last_start_) = last_start else {
432                    path.push(curr);
433                    last_start = Some(curr);
434
435                    continue;
436                };
437
438                let dist_from_start = f64::from(last_start_.distance(curr));
439                len_removed_since_start += f64::from(sub_path[i - 1].distance(curr));
440
441                // Keeping the same order as lazer's code
442                #[allow(clippy::items_after_statements)]
443                const CATMULL_SEGMENT_LEN: usize = CATMULL_DETAIL * 2;
444
445                // * Either 6px from the start, the last vertex at every knot, or the end of the path.
446                if dist_from_start > 6.0
447                    || ((i + 1) % CATMULL_SEGMENT_LEN) == 0
448                    || i == sub_path.len() - 1
449                {
450                    path.push(curr);
451                    *optimized_len += len_removed_since_start - dist_from_start;
452
453                    last_start = None;
454                    len_removed_since_start = 0.0;
455                }
456            }
457        }
458        SplineType::BSpline => approximate_bezier(path, sub_points, bufs),
459    }
460}
461
462fn approximate_bezier(path: &mut Vec<Pos>, points: &[Pos], bufs: &mut BezierBuffers) {
463    bufs.extend_exact(points.len());
464
465    approximate_bspline(path, points, bufs);
466}
467
468fn approximate_catmull(path: &mut Vec<Pos>, points: &[Pos]) {
469    if points.len() == 1 {
470        return;
471    }
472
473    path.reserve((points.len() - 1) * CATMULL_DETAIL * 2);
474
475    // Handle first iteration distinctly because of v1
476    let v1 = points[0];
477    let v2 = points[0];
478    let v3 = points.get(1).copied().unwrap_or(v2);
479    let v4 = points.get(2).copied().unwrap_or_else(|| v3 * 2.0 - v2);
480
481    catmull_subpath(path, v1, v2, v3, v4);
482
483    // Remaining iterations
484    for (i, (&v1, &v2)) in (2..points.len()).zip(points.iter().zip(points.iter().skip(1))) {
485        let v3 = points.get(i).copied().unwrap_or_else(|| v2 * 2.0 - v1);
486        let v4 = points.get(i + 1).copied().unwrap_or_else(|| v3 * 2.0 - v2);
487
488        catmull_subpath(path, v1, v2, v3, v4);
489    }
490}
491
492fn approximate_linear(path: &mut Vec<Pos>, points: &[Pos]) {
493    path.extend(points);
494}
495
496fn approximate_circular_arc(path: &mut Vec<Pos>, a: Pos, b: Pos, c: Pos) -> bool {
497    let Some(pr) = circular_arc_properties(a, b, c) else {
498        return false;
499    };
500
501    // * We select the amount of points for the approximation by requiring the discrete curvature
502    // * to be smaller than the provided tolerance. The exact angle required to meet the tolerance
503    // * is: 2 * Math.Acos(1 - TOLERANCE / r)
504    // * The special case is required for extremely short sliders where the radius is smaller than
505    // * the tolerance. This is a pathological rather than a realistic case.
506    let sub_points = if 2.0 * pr.radius <= CIRCULAR_ARC_TOLERANCE {
507        2
508    } else {
509        let divisor = 2.0 * (1.0 - (CIRCULAR_ARC_TOLERANCE / pr.radius)).acos();
510
511        // In C# it holds `(int)Infinity == -2147483648` whereas in Rust it's 2147483647
512        // so we need to workaround this edge case, see map /b/2568364
513        if divisor.abs() <= f32::EPSILON {
514            2
515        } else {
516            ((pr.theta_range / f64::from(divisor)).ceil() as usize).max(2)
517        }
518    };
519
520    // * 1000 subpoints requires an arc length of at least ~120 thousand to occur
521    // * See here for calculations https://www.desmos.com/calculator/umj6jvmcz7
522    if sub_points >= 1000 {
523        return false;
524    }
525
526    path.reserve(sub_points);
527    let divisor = (sub_points - 1) as f64;
528    let directed_range = pr.direction * pr.theta_range;
529
530    let subpath = (0..sub_points).map(|i| {
531        let fract = i as f64 / divisor;
532        let theta = pr.theta_start + fract * directed_range;
533        let (sin, cos) = theta.sin_cos();
534
535        let origin = Pos {
536            x: cos as f32,
537            y: sin as f32,
538        };
539
540        pr.centre + origin * pr.radius
541    });
542
543    path.extend(subpath);
544
545    true
546}
547
548fn approximate_bspline(path: &mut Vec<Pos>, points: &[Pos], bufs: &mut BezierBuffers) {
549    let p = points.len();
550
551    let mut to_flatten = Vec::new();
552    let mut free_bufs = Vec::new();
553
554    // In osu!lazer's code, `p` is always 0 so the first big `if` can be omitted
555
556    to_flatten.push(Cow::Borrowed(points));
557
558    // * "toFlatten" contains all the curves which are not yet approximated well enough.
559    // * We use a stack to emulate recursion without the risk of running into a stack overflow.
560    // * (More specifically, we iteratively and adaptively refine our curve with a
561    // * <a href="https://en.wikipedia.org/wiki/Depth-first_search">Depth-first search</a>
562    // * over the tree resulting from the subdivisions we make.)
563
564    let BezierBuffers {
565        left,
566        right,
567        midpoints,
568        left_child,
569    } = bufs;
570
571    while let Some(mut parent) = to_flatten.pop() {
572        if bezier_is_flat_enough(&parent) {
573            // * If the control points we currently operate on are sufficiently "flat", we use
574            // * an extension to De Casteljau's algorithm to obtain a piecewise-linear approximation
575            // * of the bezier curve represented by our control points, consisting of the same amount
576            // * of points as there are control points.
577            bezier_approximate(&parent, path, left, right, midpoints);
578            free_bufs.push(parent);
579
580            continue;
581        }
582
583        // * If we do not yet have a sufficiently "flat" (in other words, detailed) approximation we keep
584        // * subdividing the curve we are currently operating on.
585        let mut right_child = free_bufs
586            .pop()
587            .unwrap_or_else(|| Cow::Owned(vec![Pos::default(); p]));
588
589        bezier_subdivide(&parent, left_child, right_child.to_mut(), midpoints);
590
591        // * We re-use the buffer of the parent for one of the children, so that we save one allocation per iteration.
592        parent.to_mut().copy_from_slice(&left_child[..p]);
593
594        to_flatten.push(right_child);
595        to_flatten.push(parent);
596    }
597
598    path.push(points[p - 1]);
599}
600
601fn bezier_is_flat_enough(points: &[Pos]) -> bool {
602    let limit = BEZIER_TOLERANCE * BEZIER_TOLERANCE * 4.0;
603
604    !points
605        .iter()
606        .zip(points.iter().skip(1))
607        .zip(points.iter().skip(2))
608        .any(|((&prev, &curr), &next)| (prev - curr * 2.0 + next).length_squared() > limit)
609}
610
611fn bezier_subdivide(points: &[Pos], l: &mut [Pos], r: &mut [Pos], midpoints: &mut [Pos]) {
612    let count = points.len();
613    midpoints[..count].copy_from_slice(&points[..count]);
614
615    for i in (1..count).rev() {
616        l[count - i - 1] = midpoints[0];
617        r[i] = midpoints[i];
618
619        for j in 0..i {
620            midpoints[j] = (midpoints[j] + midpoints[j + 1]) / 2.0;
621        }
622    }
623
624    l[count - 1] = midpoints[0];
625    r[0] = midpoints[0];
626}
627
628// * https://en.wikipedia.org/wiki/De_Casteljau%27s_algorithm
629fn bezier_approximate(
630    points: &[Pos],
631    path: &mut Vec<Pos>,
632    l: &mut [Pos],
633    r: &mut [Pos],
634    midpoints: &mut [Pos],
635) {
636    let count = points.len();
637
638    bezier_subdivide(points, l, r, midpoints);
639    path.push(points[0]);
640
641    let l = &l[..count];
642    let r = &r[1..count];
643
644    let subpath = l
645        .iter()
646        .chain(r)
647        .skip(1)
648        .zip(l.iter().chain(r).skip(2))
649        .zip(l.iter().chain(r).skip(3))
650        .step_by(2)
651        .map(|((&prev, &curr), &next)| (prev + curr * 2.0 + next) * 0.25);
652
653    path.extend(subpath);
654}
655
656fn catmull_subpath(path: &mut Vec<Pos>, v1: Pos, v2: Pos, v3: Pos, v4: Pos) {
657    let x1 = 2.0 * v2.x;
658    let x2 = -v1.x + v3.x;
659    let x3 = 2.0 * v1.x - 5.0 * v2.x + 4.0 * v3.x - v4.x;
660    let x4 = -v1.x + 3.0 * (v2.x - v3.x) + v4.x;
661
662    let y1 = 2.0 * v2.y;
663    let y2 = -v1.y + v3.y;
664    let y3 = 2.0 * v1.y - 5.0 * v2.y + 4.0 * v3.y - v4.y;
665    let y4 = -v1.y + 3.0 * (v2.y - v3.y) + v4.y;
666
667    let catmull_detail = CATMULL_DETAIL as f32;
668
669    let subpath = (0..CATMULL_DETAIL).flat_map(|c| {
670        let c = c as f32;
671        let t1 = c / catmull_detail;
672        let t2 = t1 * t1;
673        let t3 = t2 * t1;
674
675        let pos1 = Pos {
676            x: 0.5 * (x1 + x2 * t1 + x3 * t2 + x4 * t3),
677            y: 0.5 * (y1 + y2 * t1 + y3 * t2 + y4 * t3),
678        };
679
680        let t1 = (c + 1.0) / catmull_detail;
681        let t2 = t1 * t1;
682        let t3 = t2 * t1;
683
684        let pos2 = Pos {
685            x: 0.5 * (x1 + x2 * t1 + x3 * t2 + x4 * t3),
686            y: 0.5 * (y1 + y2 * t1 + y3 * t2 + y4 * t3),
687        };
688
689        iter::once(pos1).chain(iter::once(pos2))
690    });
691
692    path.extend(subpath);
693}
694
695fn circular_arc_properties(a: Pos, b: Pos, c: Pos) -> Option<CircularArcProperties> {
696    // * If we have a degenerate triangle where a side-length is almost zero,
697    // * then give up and fallback to a more numerically stable method.
698    if ((b.y - a.y) * (c.x - a.x) - (b.x - a.x) * (c.y - a.y)).abs() <= f32::EPSILON {
699        return None;
700    }
701
702    // * See: https://en.wikipedia.org/wiki/Circumscribed_circle#Cartesian_coordinates_2
703    let d = 2.0 * (a.x * (b - c).y + b.x * (c - a).y + c.x * (a - b).y);
704    let a_sq = a.length_squared();
705    let b_sq = b.length_squared();
706    let c_sq = c.length_squared();
707
708    let centre = Pos {
709        x: (a_sq * (b - c).y + b_sq * (c - a).y + c_sq * (a - b).y) / d,
710        y: (a_sq * (c - b).x + b_sq * (a - c).x + c_sq * (b - a).x) / d,
711    };
712
713    let d_a = a - centre;
714    let d_c = c - centre;
715
716    let radius = d_a.length();
717
718    let theta_start = f64::from(d_a.y).atan2(f64::from(d_a.x));
719    let mut theta_end = f64::from(d_c.y).atan2(f64::from(d_c.x));
720
721    while theta_end < theta_start {
722        theta_end += 2.0 * PI;
723    }
724
725    let mut direction = 1.0;
726    let mut theta_range = theta_end - theta_start;
727
728    // * Decide in which direction to draw the circle,
729    // * depending on which side of AC B lies.
730    let mut ortho_a_to_c = c - a;
731
732    ortho_a_to_c = Pos {
733        x: ortho_a_to_c.y,
734        y: -ortho_a_to_c.x,
735    };
736
737    if ortho_a_to_c.dot(b - a) < 0.0 {
738        direction = -direction;
739        theta_range = 2.0 * PI - theta_range;
740    }
741
742    Some(CircularArcProperties {
743        theta_start,
744        theta_range,
745        direction,
746        radius,
747        centre,
748    })
749}