Skip to main content

motion_core/path2d/
metrics.rs

1//! Arc-length path metrics for length, sampling, trimming, dashing and cubic intervals.
2
3use kurbo::{ParamCurve, ParamCurveDeriv, PathSeg};
4
5use super::*;
6
7const BINS: usize = 16;
8
9const MAX_WORK: usize = 16_777_216;
10
11#[derive(Clone, Debug)]
12pub(super) struct Metrics {
13    segments: Vec<Segment>,
14    total: f64,
15}
16
17#[derive(Clone, Debug)]
18struct Segment {
19    curve: PathSeg,
20    start: f64,
21    length: f64,
22    table: [f64; BINS + 1],
23    contour_start: f64,
24    contour: usize,
25    close: bool,
26}
27
28fn point(value: kurbo::Point) -> Point {
29    Point::new(value.x, value.y)
30}
31
32fn subcurve(curve: PathSeg, lower: f64, upper: f64) -> PathSeg {
33    if lower == 0.0 && upper == 1.0 {
34        curve
35    } else {
36        let controls = match curve {
37            PathSeg::Line(line) => [line.p0, line.p1, line.p1, line.p1],
38            PathSeg::Quad(quad) => [quad.p0, quad.p1, quad.p2, quad.p2],
39            PathSeg::Cubic(cubic) => [cubic.p0, cubic.p1, cubic.p2, cubic.p3],
40        };
41        let constant_x = controls.iter().all(|point| point.x == controls[0].x);
42        let constant_y = controls.iter().all(|point| point.y == controls[0].y);
43        let mut result = curve.subsegment(lower..upper);
44        let preserve = |point: &mut kurbo::Point| {
45            if constant_x {
46                point.x = controls[0].x;
47            }
48            if constant_y {
49                point.y = controls[0].y;
50            }
51        };
52        match &mut result {
53            PathSeg::Line(line) => {
54                preserve(&mut line.p0);
55                preserve(&mut line.p1);
56            }
57            PathSeg::Quad(quad) => {
58                preserve(&mut quad.p0);
59                preserve(&mut quad.p1);
60                preserve(&mut quad.p2);
61            }
62            PathSeg::Cubic(cubic) => {
63                preserve(&mut cubic.p0);
64                preserve(&mut cubic.p1);
65                preserve(&mut cubic.p2);
66                preserve(&mut cubic.p3);
67            }
68        }
69        result
70    }
71}
72
73fn derivative(curve: PathSeg, parameter: f64) -> Vector2 {
74    let value = match curve {
75        PathSeg::Line(line) => line.deriv().eval(parameter),
76        PathSeg::Quad(quad) => quad.deriv().eval(parameter),
77        PathSeg::Cubic(cubic) => cubic.deriv().eval(parameter),
78    };
79    Vector2::new(value.x, value.y)
80}
81
82fn gauss(curve: PathSeg, start: f64, end: f64, work: &mut usize) -> Result<f64, ValidationError> {
83    *work += 8;
84    if *work > MAX_WORK {
85        return Err(ValidationError::limit(
86            "path2d.metrics",
87            "work",
88            "Arc-length integration exhausted its work allowance",
89        ));
90    }
91    let midpoint = (start + end) * 0.5;
92    let radius = (end - start) * 0.5;
93    let mut sum = 0.0;
94    for (node, weight) in [
95        (0.1834346424956498, 0.362683783378362),
96        (0.525532409916329, 0.3137066458778873),
97        (0.7966664774136267, 0.2223810344533745),
98        (0.9602898564975363, 0.1012285362903763),
99    ] {
100        sum += weight
101            * (derivative(curve, midpoint - radius * node).length()
102                + derivative(curve, midpoint + radius * node).length());
103    }
104    let length = sum * radius;
105    if !length.is_finite() {
106        return Err(invalid("length", "Arc length must remain finite"));
107    }
108    Ok(length)
109}
110
111fn integrate(
112    curve: PathSeg,
113    start: f64,
114    end: f64,
115    depth: usize,
116    work: &mut usize,
117) -> Result<f64, ValidationError> {
118    if start == end {
119        return Ok(0.0);
120    }
121    let midpoint = (start + end) * 0.5;
122    let coarse = gauss(curve, start, end, work)?;
123    let fine = gauss(curve, start, midpoint, work)? + gauss(curve, midpoint, end, work)?;
124    let scale = match curve {
125        PathSeg::Line(line) => point(line.p0).distance(point(line.p1)),
126        PathSeg::Quad(quad) => {
127            point(quad.p0).distance(point(quad.p1)) + point(quad.p1).distance(point(quad.p2))
128        }
129        PathSeg::Cubic(cubic) => {
130            point(cubic.p0).distance(point(cubic.p1))
131                + point(cubic.p1).distance(point(cubic.p2))
132                + point(cubic.p2).distance(point(cubic.p3))
133        }
134    };
135    if !scale.is_finite() {
136        return Err(invalid("length", "Curve metric scale must remain finite"));
137    }
138    if fine == 0.0 && scale > 0.0 {
139        return Err(invalid(
140            "length",
141            "Curve length is below integration precision",
142        ));
143    }
144    if (fine - coarse).abs() <= fine * 1e-11 + scale * 1e-13 || fine == 0.0 {
145        return Ok(fine);
146    }
147    if depth == 32 {
148        return Err(ValidationError::limit(
149            "path2d.metrics",
150            "precision",
151            "Arc-length integration could not meet its relative tolerance",
152        ));
153    }
154    Ok(integrate(curve, start, midpoint, depth + 1, work)?
155        + integrate(curve, midpoint, end, depth + 1, work)?)
156}
157
158impl Segment {
159    fn parameter(&self, distance: f64) -> Result<f64, ValidationError> {
160        if distance <= 0.0 {
161            return Ok(0.0);
162        }
163        if distance >= self.length {
164            return Ok(1.0);
165        }
166        if matches!(self.curve, PathSeg::Line(_)) {
167            return Ok(distance / self.length);
168        }
169        let bin = self
170            .table
171            .partition_point(|value| *value <= distance)
172            .saturating_sub(1)
173            .min(BINS - 1);
174        let origin = bin as f64 / BINS as f64;
175        let mut lower = origin;
176        let mut upper = (bin + 1) as f64 / BINS as f64;
177        let mut parameter = lower
178            + (upper - lower) * (distance - self.table[bin])
179                / (self.table[bin + 1] - self.table[bin]);
180        let mut work = 0;
181        for _ in 0..48 {
182            let error = self.table[bin] + integrate(self.curve, origin, parameter, 0, &mut work)?
183                - distance;
184            if error.abs() <= self.length * 2e-11 {
185                return Ok(parameter);
186            }
187            if error < 0.0 {
188                lower = parameter;
189            } else {
190                upper = parameter;
191            }
192            let candidate = parameter - error / derivative(self.curve, parameter).length();
193            parameter = if candidate.is_finite() && candidate > lower && candidate < upper {
194                candidate
195            } else {
196                (lower + upper) * 0.5
197            };
198        }
199        Ok((lower + upper) * 0.5)
200    }
201}
202
203impl Metrics {
204    fn new(path: &Path2D) -> Result<Self, ValidationError> {
205        let mut result = Self {
206            segments: Vec::new(),
207            total: 0.0,
208        };
209        let mut current = kurbo::Point::ZERO;
210        let mut start = current;
211        let mut contour = 0;
212        let mut contour_start = 0.0;
213        let mut work = 0;
214        for verb in path.verbs() {
215            let to_kurbo = |value: Point| kurbo::Point::new(value.x, value.y);
216            let curve = match *verb {
217                PathVerb::MoveTo(value) => {
218                    current = to_kurbo(value);
219                    start = current;
220                    contour += 1;
221                    contour_start = result.total;
222                    continue;
223                }
224                PathVerb::LineTo(value) => {
225                    PathSeg::Line(kurbo::Line::new(current, to_kurbo(value)))
226                }
227                PathVerb::QuadraticTo(control, end) => PathSeg::Quad(kurbo::QuadBez::new(
228                    current,
229                    to_kurbo(control),
230                    to_kurbo(end),
231                )),
232                PathVerb::CubicTo(first, second, end) => PathSeg::Cubic(kurbo::CubicBez::new(
233                    current,
234                    to_kurbo(first),
235                    to_kurbo(second),
236                    to_kurbo(end),
237                )),
238                PathVerb::Close => PathSeg::Line(kurbo::Line::new(current, start)),
239            };
240            let mut table = [0.0; BINS + 1];
241            let line_length = if let PathSeg::Line(line) = curve {
242                Some(point(line.p0).distance(point(line.p1)))
243            } else {
244                None
245            };
246            for bin in 0..BINS {
247                table[bin + 1] = if let Some(length) = line_length {
248                    length * ((bin + 1) as f64 / BINS as f64)
249                } else {
250                    table[bin]
251                        + integrate(
252                            curve,
253                            bin as f64 / BINS as f64,
254                            (bin + 1) as f64 / BINS as f64,
255                            0,
256                            &mut work,
257                        )?
258                };
259            }
260            let length = table[BINS];
261            result.segments.push(Segment {
262                curve,
263                start: result.total,
264                length,
265                table,
266                contour_start,
267                contour,
268                close: matches!(verb, PathVerb::Close),
269            });
270            let total = result.total + length;
271            if length > 0.0 && total == result.total {
272                return Err(invalid(
273                    "length",
274                    "Segment length is below cumulative metric precision",
275                ));
276            }
277            result.total = total;
278            if !result.total.is_finite() {
279                return Err(invalid("length", "Total path length must remain finite"));
280            }
281            current = curve.end();
282        }
283        Ok(result)
284    }
285
286    fn append(
287        &self,
288        start: f64,
289        end: f64,
290        output: &mut Path2D,
291        connect: bool,
292    ) -> Result<(), ValidationError> {
293        let first = self
294            .segments
295            .partition_point(|segment| segment.start + segment.length <= start);
296        let mut previous = None;
297        for segment in &self.segments[first..] {
298            if segment.start > end || (segment.start == end && segment.length > 0.0) {
299                break;
300            }
301            if segment.length == 0.0 {
302                if segment.close
303                    && start <= segment.contour_start
304                    && previous == Some(segment.contour)
305                {
306                    output.try_close()?;
307                }
308                continue;
309            }
310            let lower = segment.parameter((start - segment.start).max(0.0))?;
311            let upper = segment.parameter((end - segment.start).min(segment.length))?;
312            if lower >= upper {
313                continue;
314            }
315            let curve = subcurve(segment.curve, lower, upper);
316            let first_point = point(curve.start());
317            if (previous != Some(segment.contour)
318                && !(connect && previous.is_none() && output.data.current == Some(first_point)))
319                || output.data.current != Some(first_point)
320            {
321                output.try_move_to(first_point)?;
322            }
323            if segment.close && lower == 0.0 && upper == 1.0 && start <= segment.contour_start {
324                output.try_close()?;
325            } else {
326                match curve {
327                    PathSeg::Line(line) => output.try_line_to(point(line.p1))?,
328                    PathSeg::Quad(quad) => {
329                        output.try_quadratic_to(point(quad.p1), point(quad.p2))?
330                    }
331                    PathSeg::Cubic(cubic) => {
332                        output.try_cubic_to(point(cubic.p1), point(cubic.p2), point(cubic.p3))?
333                    }
334                }
335            }
336            previous = Some(segment.contour);
337        }
338        Ok(())
339    }
340}
341
342impl Path2D {
343    /// Returns normalized arc-length boundaries for curve morphing.
344    pub fn cubic_breakpoints(&self) -> Result<Vec<f64>, ValidationError> {
345        let metrics = self.metrics()?;
346        let mut points = vec![0.0];
347        let mut last = 0.0;
348        if metrics.total > 0.0 {
349            for segment in &metrics.segments {
350                if segment.length > 0.0 {
351                    let fraction = (segment.start + segment.length) / metrics.total;
352                    if fraction > last {
353                        last = fraction.min(1.0);
354                        points.push(last);
355                    }
356                }
357            }
358        }
359        if points.last() != Some(&1.0) {
360            points.push(1.0);
361        }
362        Ok(points)
363    }
364
365    /// Extracts cubic representations of checked normalized arc-length intervals.
366    pub fn cubic_intervals(&self, fractions: &[f64]) -> Result<Vec<[Point; 4]>, ValidationError> {
367        if fractions.len() < 2
368            || fractions.len() > MAX_PATH_VERBS
369            || fractions
370                .iter()
371                .any(|value| !value.is_finite() || !(0.0..=1.0).contains(value))
372            || fractions.windows(2).any(|pair| pair[0] >= pair[1])
373        {
374            return Err(invalid(
375                "segments",
376                "Cubic interval boundaries must be finite, increasing and bounded",
377            ));
378        }
379        let metrics = self.metrics()?;
380        if metrics.total == 0.0 {
381            return Ok(vec![[self.try_point_at(0.0)?.0; 4]; fractions.len() - 1]);
382        }
383        let mut output = Vec::with_capacity(fractions.len() - 1);
384        for pair in fractions.windows(2) {
385            let middle = ((pair[0] + pair[1]) * 0.5) * metrics.total;
386            let index = metrics
387                .segments
388                .partition_point(|segment| segment.start + segment.length <= middle);
389            let segment = metrics
390                .segments
391                .get(index)
392                .ok_or_else(|| invalid("segments", "Cubic interval has no measurable segment"))?;
393            let start = pair[0] * metrics.total;
394            let end = pair[1] * metrics.total;
395            let slack = metrics.total * 2e-12;
396            if start < segment.start - slack || end > segment.start + segment.length + slack {
397                return Err(invalid(
398                    "segments",
399                    "Cubic interval crosses an original curve boundary",
400                ));
401            }
402            let curve = subcurve(
403                segment.curve,
404                segment.parameter((start - segment.start).clamp(0.0, segment.length))?,
405                segment.parameter((end - segment.start).clamp(0.0, segment.length))?,
406            );
407            output.push(match curve {
408                PathSeg::Line(line) => {
409                    let first = point(line.p0);
410                    let last = point(line.p1);
411                    [
412                        first,
413                        first.interpolate(&last, 1.0 / 3.0),
414                        first.interpolate(&last, 2.0 / 3.0),
415                        last,
416                    ]
417                }
418                PathSeg::Quad(quad) => {
419                    let cubic = quad.raise();
420                    [
421                        point(cubic.p0),
422                        point(cubic.p1),
423                        point(cubic.p2),
424                        point(cubic.p3),
425                    ]
426                }
427                PathSeg::Cubic(cubic) => [
428                    point(cubic.p0),
429                    point(cubic.p1),
430                    point(cubic.p2),
431                    point(cubic.p3),
432                ],
433            });
434        }
435        Ok(output)
436    }
437
438    fn metrics(&self) -> Result<&Metrics, ValidationError> {
439        self.data
440            .metrics
441            .get_or_init(|| Metrics::new(self).map(Arc::new))
442            .as_ref()
443            .map(Arc::as_ref)
444            .map_err(Clone::clone)
445    }
446
447    /// Returns cached total contour length, including explicit closing edges.
448    /// Panics if integration overflows or exhausts its bounded precision/work.
449    pub fn length(&self) -> f64 {
450        self.try_length()
451            .expect("Path arc-length calculation failed")
452    }
453
454    /// Checked arc length with bounded adaptive eight-point Gaussian integration.
455    pub fn try_length(&self) -> Result<f64, ValidationError> {
456        Ok(self.metrics()?.total)
457    }
458
459    /// Samples normalized arc distance and its unit forward tangent.
460    /// Progress clamps to 0..=1; nonfinite inputs or metric failures panic.
461    pub fn point_at(&self, progress: f64) -> (Point, Vector2) {
462        self.try_point_at(progress)
463            .expect("Path arc-length sampling failed")
464    }
465
466    /// Checked sampling. Empty/move-only paths have a zero tangent. At a cusp,
467    /// uses the forward one-sided direction, or backward direction at the end.
468    pub fn try_point_at(&self, progress: f64) -> Result<(Point, Vector2), ValidationError> {
469        if !progress.is_finite() {
470            return Err(invalid("progress", "Path progress must be finite"));
471        }
472        let metrics = self.metrics()?;
473        if metrics.total == 0.0 {
474            return Ok((
475                self.verbs()
476                    .iter()
477                    .find_map(|verb| {
478                        if let PathVerb::MoveTo(value) = verb {
479                            Some(*value)
480                        } else {
481                            None
482                        }
483                    })
484                    .unwrap_or(Point::ORIGIN),
485                Vector2::ZERO,
486            ));
487        }
488        let distance = progress.clamp(0.0, 1.0) * metrics.total;
489        let segment = metrics
490            .segments
491            .iter()
492            .find(|segment| segment.length > 0.0 && segment.start + segment.length > distance)
493            .or_else(|| {
494                metrics
495                    .segments
496                    .iter()
497                    .rev()
498                    .find(|segment| segment.length > 0.0)
499            })
500            .expect("positive total length");
501        let parameter = segment.parameter((distance - segment.start).clamp(0.0, segment.length))?;
502        let position = point(segment.curve.eval(parameter));
503        let mut direction = derivative(segment.curve, parameter);
504        if direction == Vector2::ZERO {
505            direction = if parameter < 1.0 {
506                point(segment.curve.eval((parameter + 1e-6).min(1.0))) - position
507            } else {
508                position - point(segment.curve.eval((parameter - 1e-6).max(0.0)))
509            };
510        }
511        Ok((position, direction.normalize()))
512    }
513
514    /// Extracts a normalized arc-length interval, preserving Bezier segments.
515    /// Clamps finite endpoints; empty/reversed intervals produce an empty path.
516    /// Panics on invalid inputs or numerical/resource failure; see try_trim.
517    pub fn trim(&self, start: f64, end: f64) -> Self {
518        self.try_trim(start, end).expect("Path trimming failed")
519    }
520
521    /// Checked de Casteljau extraction. Full range preserves all original verbs.
522    pub fn try_trim(&self, start: f64, end: f64) -> Result<Self, ValidationError> {
523        if !start.is_finite() || !end.is_finite() {
524            return Err(invalid("trim", "Trim endpoints must be finite"));
525        }
526        let start = start.clamp(0.0, 1.0);
527        let end = end.clamp(0.0, 1.0);
528        if start >= end {
529            return Ok(Self::new());
530        }
531        if start == 0.0 && end == 1.0 {
532            return Ok(self.clone());
533        }
534        let metrics = self.metrics()?;
535        let mut output = Self::new();
536        metrics.append(
537            start * metrics.total,
538            end * metrics.total,
539            &mut output,
540            false,
541        )?;
542        Ok(output)
543    }
544
545    /// Cuts positive finite dash/gap lengths in logical units. Odd patterns
546    /// repeat twice; phase restarts per contour and positive offsets advance it.
547    /// Empty patterns retain the path. At most 256 entries and 8192 visible
548    /// intervals per contour are admitted; generated verbs remain bounded.
549    pub fn dashed(&self, pattern: &[f64], offset: f64) -> Result<Self, ValidationError> {
550        if !offset.is_finite()
551            || pattern
552                .iter()
553                .any(|value| !value.is_finite() || *value <= 0.0)
554        {
555            return Err(invalid(
556                "dash",
557                "Dash lengths must be finite and positive; offset must be finite",
558            ));
559        }
560        if pattern.len() > 256 {
561            return Err(ValidationError::limit(
562                "path2d",
563                "dash",
564                "Dash pattern exceeds 256 entries",
565            ));
566        }
567        if pattern.is_empty() {
568            return Ok(self.clone());
569        }
570        let count = pattern.len()
571            * if pattern.len().is_multiple_of(2) {
572                1
573            } else {
574                2
575            };
576        let cycle = pattern.iter().sum::<f64>() * if count == pattern.len() { 1.0 } else { 2.0 };
577        if !cycle.is_finite() {
578            return Err(invalid("dash", "Dash cycle must remain finite"));
579        }
580        let metrics = self.metrics()?;
581        let mut output = Self::new();
582        let mut first = 0;
583        while first < metrics.segments.len() {
584            let contour = metrics.segments[first].contour;
585            let end_index = first
586                + metrics.segments[first..].partition_point(|segment| segment.contour == contour);
587            let last = &metrics.segments[end_index - 1];
588            let origin = metrics.segments[first].start;
589            let length = last.start + last.length - origin;
590            let mut phase = offset.rem_euclid(cycle);
591            let mut index = 0;
592            while phase >= pattern[index % pattern.len()] {
593                phase -= pattern[index % pattern.len()];
594                index = (index + 1) % count;
595            }
596            let mut remaining = pattern[index % pattern.len()] - phase;
597            let mut position = 0.0;
598            let mut ranges = Vec::new();
599            while position < length {
600                let next = (position + remaining).min(length);
601                if next <= position {
602                    return Err(invalid(
603                        "dash",
604                        "Dash interval is below local floating-point precision",
605                    ));
606                }
607                if index % 2 == 0 {
608                    if ranges.len() >= MAX_PATH_VERBS / 2 {
609                        return Err(ValidationError::limit(
610                            "path2d",
611                            "dash",
612                            "Dash interval allowance exhausted",
613                        ));
614                    }
615                    ranges.push((origin + position, origin + next));
616                }
617                position = next;
618                index = (index + 1) % count;
619                remaining = pattern[index % pattern.len()];
620            }
621            if last.close
622                && ranges.len() > 1
623                && ranges[0].0 == origin
624                && let Some(tail) = ranges.pop_if(|tail| tail.1 == origin + length)
625            {
626                metrics.append(tail.0, tail.1, &mut output, false)?;
627                let head = ranges.remove(0);
628                metrics.append(head.0, head.1, &mut output, true)?;
629            }
630            for (start, end) in ranges {
631                metrics.append(start, end, &mut output, false)?;
632            }
633            first = end_index;
634        }
635        Ok(output)
636    }
637}
638
639#[cfg(test)]
640mod tests {
641    use super::*;
642
643    #[test]
644    fn kurbo_subdivide_retains_exact_control_polygons() {
645        let curve = PathSeg::Cubic(kurbo::CubicBez::new(
646            (0.0, 0.0),
647            (2.0, 4.0),
648            (6.0, 4.0),
649            (8.0, 0.0),
650        ));
651        let (left, right) = curve.subdivide();
652        assert_eq!(
653            left,
654            PathSeg::Cubic(kurbo::CubicBez::new(
655                (0.0, 0.0),
656                (1.0, 2.0),
657                (2.5, 3.0),
658                (4.0, 3.0)
659            ))
660        );
661        assert_eq!(
662            right,
663            PathSeg::Cubic(kurbo::CubicBez::new(
664                (4.0, 3.0),
665                (5.5, 3.0),
666                (7.0, 2.0),
667                (8.0, 0.0)
668            ))
669        );
670    }
671
672    #[test]
673    fn kurbo_subsegments_preserve_parameters_and_full_snapshots() {
674        let curves = [
675            PathSeg::Line(kurbo::Line::new((0.0, 0.0), (12.0, 9.0))),
676            PathSeg::Quad(kurbo::QuadBez::new((0.0, 0.0), (4.0, 12.0), (12.0, 9.0))),
677            PathSeg::Cubic(kurbo::CubicBez::new(
678                (0.0, 0.0),
679                (2.0, 14.0),
680                (10.0, -4.0),
681                (12.0, 9.0),
682            )),
683        ];
684        for curve in curves {
685            assert_eq!(subcurve(curve, 0.0, 1.0), curve);
686            for (lower, upper) in [(0.0, 0.5), (0.25, 1.0), (0.13, 0.77)] {
687                let part = subcurve(curve, lower, upper);
688                for parameter in [0.0, 0.125, 0.5, 0.875, 1.0] {
689                    let expected = curve.eval(lower + (upper - lower) * parameter);
690                    assert!(part.eval(parameter).distance(expected) < 1e-12);
691                }
692            }
693        }
694        let line = Path2D::parse_svg("M0 0L12 9").unwrap();
695        let controls = line.cubic_intervals(&[0.0, 1.0]).unwrap()[0];
696        assert_eq!(
697            controls,
698            [
699                Point::ORIGIN,
700                Point::new(4.0, 3.0),
701                Point::new(8.0, 6.0),
702                Point::new(12.0, 9.0)
703            ]
704        );
705    }
706}