Skip to main content

motion_core/shape/
primitives.rs

1//! Built-in geometric primitives.
2//!
3//! Invalid constructors retain a checked error. Use try_path to inspect it;
4//! drawing validates invalid bounds before Shape::path could panic.
5//!
6//! ```
7//! use motion_core::prelude::*;
8//! use motion_core::{
9//!     DisplayList, RasterLimits, ResourcePool, ValidatedDisplayList, MAX_PATH_VERBS,
10//!     MAX_STROKE_VERBS, MAX_SVG_PATH_BYTES, sample_offsets,
11//! };
12//! let curve = Spline::through([
13//!     Point::new(1.0, 6.0),
14//!     Point::new(4.0, 1.0),
15//!     Point::new(7.0, 6.0),
16//! ])
17//! .stroke(0.5, Color::WHITE)
18//! .trim(0.0, 0.8)
19//! .dash(&[1.0, 0.5], -0.2);
20//! Node::from(curve).compile(
21//!     Size::new(8.0, 8.0),
22//!     PixelSize::new(8, 8),
23//!     ValidationLimits::default(),
24//! )?;
25//! # Ok::<(), motion_core::Error>(())
26//! ```
27
28use crate::{MAX_PATH_VERBS, Path2D, Point, Rect, Shape, ValidationError, Vector2};
29use std::{ops::Range, sync::Arc as Shared};
30
31fn invalid(message: &str) -> ValidationError {
32    ValidationError::invalid("shape", "geometry", message)
33}
34
35fn radius(value: f64) -> Result<(), ValidationError> {
36    if value.is_finite() && value >= 0.0 {
37        Ok(())
38    } else {
39        Err(invalid(
40            "Radii and dimensions must be finite and nonnegative",
41        ))
42    }
43}
44
45fn points(values: impl IntoIterator<Item = Point>) -> Result<Shared<[Point]>, ValidationError> {
46    let mut result = Vec::new();
47    for value in values {
48        if result.len() >= MAX_PATH_VERBS - 1 {
49            return Err(ValidationError::limit(
50                "shape",
51                "points",
52                "Shape point allowance exceeded",
53            ));
54        }
55        if !value.x.is_finite() || !value.y.is_finite() {
56            return Err(invalid("Shape points must be finite"));
57        }
58        result.push(value);
59    }
60    Ok(result.into())
61}
62
63fn polyline(values: &[Point], closed: bool) -> Result<Path2D, ValidationError> {
64    let mut path = Path2D::new();
65    if let Some(first) = values.first() {
66        path.try_move_to(*first)?;
67        for value in &values[1..] {
68            path.try_line_to(*value)?;
69        }
70        if closed {
71            path.try_close()?;
72        }
73    }
74    Ok(path)
75}
76
77macro_rules! shape_impl {
78    ($name:ident) => {
79        impl Shape for $name {
80            fn path(&self) -> Path2D {
81                self.try_path()
82                    .expect("Shape::path requires valid geometry")
83            }
84            fn bounding_box(&self) -> Rect {
85                self.try_path()
86                    .map(|path| path.bounding_box())
87                    .unwrap_or(Rect::new(f64::NAN, 0.0, 0.0, 0.0))
88            }
89        }
90    };
91}
92
93macro_rules! stored_shape {
94    ($name:ident, $description:literal) => {
95        #[doc = $description]
96        #[derive(Clone, Debug)]
97        pub struct $name {
98            path: Result<Path2D, ValidationError>,
99        }
100        impl $name {
101            /// Returns checked geometry without resource work or narrowing.
102            pub fn try_path(&self) -> Result<Path2D, ValidationError> {
103                self.path.clone()
104            }
105        }
106        shape_impl!($name);
107    };
108}
109
110stored_shape!(
111    Circle,
112    "A circular contour, using bounded cubic arc approximation."
113);
114
115impl Circle {
116    /// Builds a clockwise closed contour; zero radius is degenerate geometry.
117    pub fn new(center: Point, radius: f64) -> Self {
118        Self {
119            path: Ellipse::new(center, Vector2::new(radius, radius)).try_path(),
120        }
121    }
122}
123
124stored_shape!(Ellipse, "An axis-aligned elliptical contour.");
125
126impl Ellipse {
127    /// Builds a clockwise closed ellipse from nonnegative horizontal/vertical radii.
128    pub fn new(center: Point, radii: Vector2) -> Self {
129        Self {
130            path: (|| {
131                let mut path = Path2D::new();
132                path.try_arc_to(center, radii, 0.0, std::f64::consts::TAU)?;
133                path.try_close()?;
134                Ok(path)
135            })(),
136        }
137    }
138}
139
140stored_shape!(
141    Arc,
142    "An open circular arc; fill closes its chord, stroke keeps open ends."
143);
144
145impl Arc {
146    /// Builds a clockwise-positive arc in radians, connecting no implicit center.
147    pub fn new(center: Point, radius: f64, start_angle: f64, sweep_angle: f64) -> Self {
148        Self {
149            path: (|| {
150                let mut path = Path2D::new();
151                path.try_arc_to(
152                    center,
153                    Vector2::new(radius, radius),
154                    start_angle,
155                    sweep_angle,
156                )?;
157                Ok(path)
158            })(),
159        }
160    }
161}
162
163stored_shape!(
164    RoundedRect,
165    "A rectangle with uniformly rounded, clamped corner radii."
166);
167
168impl RoundedRect {
169    /// Clamps the radius to half the smaller valid rectangle dimension.
170    pub fn new(rect: Rect, corner_radius: f64) -> Self {
171        Self {
172            path: (|| {
173                crate::drawing::validate_rect(rect, "shape", "bounds")?;
174                radius(corner_radius)?;
175                let corner_radius = corner_radius
176                    .min(rect.width() * 0.5)
177                    .min(rect.height() * 0.5);
178                if corner_radius == 0.0 {
179                    return Path2D::from_rect(rect);
180                }
181                let mut path = Path2D::new();
182                path.try_move_to(Point::new(rect.left + corner_radius, rect.top))?;
183                let radii = Vector2::new(corner_radius, corner_radius);
184                for (center, start) in [
185                    (
186                        Point::new(rect.right - corner_radius, rect.top + corner_radius),
187                        -std::f64::consts::FRAC_PI_2,
188                    ),
189                    (
190                        Point::new(rect.right - corner_radius, rect.bottom - corner_radius),
191                        0.0,
192                    ),
193                    (
194                        Point::new(rect.left + corner_radius, rect.bottom - corner_radius),
195                        std::f64::consts::FRAC_PI_2,
196                    ),
197                    (
198                        Point::new(rect.left + corner_radius, rect.top + corner_radius),
199                        std::f64::consts::PI,
200                    ),
201                ] {
202                    path.try_arc_to(center, radii, start, std::f64::consts::FRAC_PI_2)?;
203                }
204                path.try_close()?;
205                Ok(path)
206            })(),
207        }
208    }
209}
210
211stored_shape!(Line, "An open line segment with f64 endpoints.");
212
213impl Line {
214    /// Builds a segment without an implicit closed interior.
215    pub fn new(from: Point, to: Point) -> Self {
216        Self {
217            path: polyline(&[from, to], false),
218        }
219    }
220}
221
222stored_shape!(Polyline, "An open bounded sequence of line segments.");
223
224impl Polyline {
225    /// Captures at most MAX_PATH_VERBS-1 finite points; empty input is empty.
226    pub fn new(values: impl IntoIterator<Item = Point>) -> Self {
227        Self {
228            path: points(values).and_then(|values| polyline(&values, false)),
229        }
230    }
231}
232
233stored_shape!(
234    Polygon,
235    "A closed bounded polygon; self-intersections use the fill rule."
236);
237
238impl Polygon {
239    /// Captures points and closes the last edge to the first point.
240    pub fn new(values: impl IntoIterator<Item = Point>) -> Self {
241        Self {
242            path: points(values).and_then(|values| polyline(&values, true)),
243        }
244    }
245}
246
247fn radial(
248    center: Point,
249    outer: f64,
250    inner: Option<f64>,
251    count: usize,
252) -> Result<Path2D, ValidationError> {
253    radius(outer)?;
254    if let Some(inner) = inner {
255        radius(inner)?;
256        if inner > outer {
257            return Err(invalid(
258                "Star inner radius must not exceed its outer radius",
259            ));
260        }
261    }
262    let minimum = if inner.is_some() { 2 } else { 3 };
263    let vertices = count
264        .checked_mul(if inner.is_some() { 2 } else { 1 })
265        .filter(|count| *count < MAX_PATH_VERBS - 1);
266    let vertices = vertices
267        .filter(|_| count >= minimum)
268        .ok_or_else(|| invalid("Radial shape point count is outside its supported range"))?;
269    let values = (0..vertices)
270        .map(|index| {
271            let length = if index % 2 == 1 {
272                inner.unwrap_or(outer)
273            } else {
274                outer
275            };
276            center
277                + Vector2::from_angle(
278                    -std::f64::consts::FRAC_PI_2
279                        + std::f64::consts::TAU * index as f64 / vertices as f64,
280                ) * length
281        })
282        .collect::<Vec<_>>();
283    polyline(&values, true)
284}
285
286stored_shape!(
287    RegularPolygon,
288    "A regular polygon with its first vertex above the center."
289);
290
291impl RegularPolygon {
292    /// Builds at least three equally spaced vertices on a radius.
293    pub fn new(center: Point, radius: f64, sides: usize) -> Self {
294        Self {
295            path: radial(center, radius, None, sides),
296        }
297    }
298}
299
300stored_shape!(Star, "A closed star alternating outer and inner radii.");
301
302impl Star {
303    /// Builds at least two tips, alternating finite outer and inner radii.
304    pub fn new(center: Point, count: usize, outer_radius: f64, inner_radius: f64) -> Self {
305        Self {
306            path: radial(center, outer_radius, Some(inner_radius), count),
307        }
308    }
309}
310
311/// A annulus or closed annular sector with opposite-winding inner contour.
312#[derive(Clone, Debug)]
313pub struct Ring {
314    center: Point,
315    inner: f64,
316    outer: f64,
317    start: f64,
318    sweep: f64,
319}
320
321impl Ring {
322    /// Builds a complete ring with 0 <= inner <= outer.
323    pub fn new(center: Point, inner_radius: f64, outer_radius: f64) -> Self {
324        Self {
325            center,
326            inner: inner_radius,
327            outer: outer_radius,
328            start: 0.0,
329            sweep: std::f64::consts::TAU,
330        }
331    }
332
333    /// Selects a clockwise-positive annular sector in radians; clamps to one turn.
334    pub fn sweep(mut self, start_angle: f64, sweep_angle: f64) -> Self {
335        self.start = start_angle;
336        self.sweep = sweep_angle;
337        self
338    }
339
340    /// Returns checked annular geometry; full rings retain a real winding hole.
341    pub fn try_path(&self) -> Result<Path2D, ValidationError> {
342        radius(self.inner)?;
343        radius(self.outer)?;
344        if self.inner > self.outer || !self.sweep.is_finite() {
345            return Err(invalid("Ring requires inner <= outer and a finite sweep"));
346        }
347        let sweep = self
348            .sweep
349            .clamp(-std::f64::consts::TAU, std::f64::consts::TAU);
350        let mut path = Path2D::new();
351        path.try_arc_to(
352            self.center,
353            Vector2::new(self.outer, self.outer),
354            self.start,
355            sweep,
356        )?;
357        if sweep.abs() == std::f64::consts::TAU {
358            path.try_close()?;
359            if self.inner > 0.0 {
360                let inner = Arc::new(self.center, self.inner, self.start, -sweep).try_path()?;
361                for verb in inner.verbs() {
362                    append(&mut path, *verb)?;
363                }
364                path.try_close()?;
365            }
366        } else {
367            path.try_arc_to(
368                self.center,
369                Vector2::new(self.inner, self.inner),
370                self.start + sweep,
371                -sweep,
372            )?;
373            path.try_close()?;
374        }
375        Ok(path)
376    }
377}
378shape_impl!(Ring);
379
380fn append(path: &mut Path2D, verb: crate::PathVerb) -> Result<(), ValidationError> {
381    match verb {
382        crate::PathVerb::MoveTo(value) => path.try_move_to(value),
383        crate::PathVerb::LineTo(value) => path.try_line_to(value),
384        crate::PathVerb::QuadraticTo(control, end) => path.try_quadratic_to(control, end),
385        crate::PathVerb::CubicTo(first, second, end) => path.try_cubic_to(first, second, end),
386        crate::PathVerb::Close => path.try_close(),
387    }
388}
389
390/// arrow-head geometry; Vee is an open strokable head.
391#[derive(Clone, Copy, Debug, Default)]
392pub enum ArrowHead {
393    /// Filled triangular head integrated with the shaft.
394    #[default]
395    Triangle,
396    /// Two open sides, intended for stroke paint.
397    Vee,
398    /// Shaft segment only.
399    None,
400}
401
402/// A single-outline arrow, with independent shaft and head geometry.
403#[derive(Clone, Debug)]
404pub struct Arrow {
405    from: Point,
406    to: Point,
407    head: ArrowHead,
408    size: f64,
409    head_width: Option<f64>,
410    width: f64,
411}
412
413impl Arrow {
414    /// Creates a width-one shaft and a ten-unit triangular head.
415    pub fn new(from: Point, to: Point) -> Self {
416        Self {
417            from,
418            to,
419            head: ArrowHead::Triangle,
420            size: 10.0,
421            head_width: None,
422            width: 1.0,
423        }
424    }
425
426    /// Replaces head geometry.
427    pub fn head(mut self, head: ArrowHead) -> Self {
428        self.head = head;
429        self
430    }
431
432    /// Sets head length and width; head length clamps to endpoint distance.
433    pub fn head_size(mut self, size: f64) -> Self {
434        self.size = size;
435        self.head_width = None;
436        self
437    }
438
439    /// Sets independent positive head length and full width. Length clamps to
440    /// endpoint distance; explicit width stays unchanged for a short arrow.
441    pub fn head_dimensions(mut self, length: f64, width: f64) -> Self {
442        self.size = length;
443        self.head_width = Some(width);
444        self
445    }
446
447    /// Sets the full shaft width in logical coordinates.
448    pub fn width(mut self, width: f64) -> Self {
449        self.width = width;
450        self
451    }
452
453    /// Returns a single filled arrow, an open Vee or shaft-only segment.
454    pub fn try_path(&self) -> Result<Path2D, ValidationError> {
455        radius(self.size)?;
456        radius(self.width)?;
457        if let Some(width) = self.head_width {
458            radius(width)?;
459            if self.size <= 0.0 || width <= 0.0 {
460                return Err(invalid("Explicit arrow head dimensions must be positive"));
461            }
462        }
463        let line = Line::new(self.from, self.to).try_path()?;
464        let displacement = self.to - self.from;
465        let length = displacement.length();
466        if !length.is_finite() {
467            return Err(invalid("Arrow length must remain finite"));
468        }
469        if length == 0.0 {
470            return Ok(Path2D::new());
471        }
472        if matches!(self.head, ArrowHead::None) {
473            return Ok(line);
474        }
475        let direction = displacement.normalize();
476        let normal = Vector2::new(-direction.y, direction.x);
477        let head = self.size.min(length);
478        let half_head_width = self.head_width.unwrap_or(head) * 0.5;
479        let neck = self.to - direction * head;
480        if matches!(self.head, ArrowHead::Vee) {
481            let mut path = line;
482            path.try_move_to(neck + normal * half_head_width)?;
483            path.try_line_to(self.to)?;
484            path.try_line_to(neck - normal * half_head_width)?;
485            return Ok(path);
486        }
487        polyline(
488            &[
489                self.from + normal * (self.width * 0.5),
490                neck + normal * (self.width * 0.5),
491                neck + normal * half_head_width,
492                self.to,
493                neck - normal * half_head_width,
494                neck - normal * (self.width * 0.5),
495                self.from - normal * (self.width * 0.5),
496            ],
497            true,
498        )
499    }
500}
501shape_impl!(Arrow);
502
503/// A cardinal Catmull-Rom interpolating spline with clamped endpoint neighbors.
504#[derive(Clone, Debug)]
505pub struct Spline {
506    points: Result<Shared<[Point]>, ValidationError>,
507    tension: f64,
508}
509
510impl Spline {
511    /// Captures a bounded point list, including empty/single-point degeneracies.
512    pub fn through(values: impl IntoIterator<Item = Point>) -> Self {
513        Self {
514            points: points(values),
515            tension: 0.0,
516        }
517    }
518
519    /// Sets tension in 0..=1; zero is Catmull-Rom, one has zero endpoint tangents.
520    pub fn tension(mut self, tension: f64) -> Self {
521        self.tension = tension;
522        self
523    }
524
525    /// Produces cubic spans passing through every supplied point.
526    pub fn try_path(&self) -> Result<Path2D, ValidationError> {
527        if !self.tension.is_finite() || !(0.0..=1.0).contains(&self.tension) {
528            return Err(invalid("Spline tension must be in 0..=1"));
529        }
530        let values = self.points.as_ref().map_err(Clone::clone)?;
531        let mut path = Path2D::new();
532        if let Some(first) = values.first() {
533            path.try_move_to(*first)?;
534        }
535        let factor = (1.0 - self.tension) / 6.0;
536        for index in 0..values.len().saturating_sub(1) {
537            let before = values[index.saturating_sub(1)];
538            let after = values[(index + 2).min(values.len() - 1)];
539            path.try_cubic_to(
540                values[index] + (values[index + 1] - before) * factor,
541                values[index + 1] - (after - values[index]) * factor,
542                values[index + 1],
543            )?;
544        }
545        Ok(path)
546    }
547}
548shape_impl!(Spline);
549
550/// A deterministically sampled function/parametric curve with finite output.
551/// Callbacks must be pure; their panics propagate like other user callbacks.
552#[derive(Clone)]
553pub struct FunctionGraph {
554    function: Shared<dyn Fn(f64) -> Point + Send + Sync>,
555    domain: Range<f64>,
556    samples: usize,
557}
558
559impl std::fmt::Debug for FunctionGraph {
560    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
561        formatter
562            .debug_struct("FunctionGraph")
563            .field("domain", &self.domain)
564            .field("samples", &self.samples)
565            .finish_non_exhaustive()
566    }
567}
568
569impl FunctionGraph {
570    /// Samples y=f(x), including both domain endpoints, with 128 default points.
571    pub fn new(function: impl Fn(f64) -> f64 + Send + Sync + 'static, domain: Range<f64>) -> Self {
572        Self::parametric(
573            move |parameter| Point::new(parameter, function(parameter)),
574            domain,
575        )
576    }
577
578    /// Samples an explicit two-dimensional curve over the given parameter domain.
579    pub fn parametric(
580        function: impl Fn(f64) -> Point + Send + Sync + 'static,
581        domain: Range<f64>,
582    ) -> Self {
583        Self {
584            function: Shared::new(function),
585            domain,
586            samples: 128,
587        }
588    }
589
590    /// Sets 2..MAX_PATH_VERBS sample points; excessive requests fail before callbacks.
591    pub fn samples(mut self, samples: usize) -> Self {
592        self.samples = samples;
593        self
594    }
595
596    /// Evaluates a bounded sample list and rejects nonfinite output, never bridges gaps.
597    pub fn try_path(&self) -> Result<Path2D, ValidationError> {
598        if !self.domain.start.is_finite()
599            || !self.domain.end.is_finite()
600            || self.domain.start >= self.domain.end
601            || !(2..MAX_PATH_VERBS).contains(&self.samples)
602        {
603            return Err(invalid(
604                "Function graph requires a finite increasing domain and bounded sample count",
605            ));
606        }
607        let mut path = Path2D::new();
608        for index in 0..self.samples {
609            let progress = index as f64 / (self.samples - 1) as f64;
610            let parameter = if index == 0 {
611                self.domain.start
612            } else if index + 1 == self.samples {
613                self.domain.end
614            } else {
615                self.domain.start * (1.0 - progress) + self.domain.end * progress
616            };
617            let value = (self.function)(parameter);
618            if index == 0 {
619                path.try_move_to(value)?;
620            } else {
621                path.try_line_to(value)?;
622            }
623        }
624        Ok(path)
625    }
626}
627shape_impl!(FunctionGraph);
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632    use crate::{Color, Node, PixelSize, Point, Rect, Size, ValidationLimits};
633
634    #[test]
635    fn arrows_keep_independent_head_dimensions_single_fill_and_empty_endpoints() {
636        for length in [40.0, 3.0] {
637            let arrow = Arrow::new(Point::ORIGIN, Point::new(length, 0.0))
638                .width(2.0)
639                .head_dimensions(12.0, 10.0);
640            let path = arrow.try_path().unwrap();
641            assert_eq!(path.bounding_box(), Rect::new(0.0, -5.0, length, 5.0));
642            assert!(path.verbs().iter().any(|verb| matches!(verb, crate::PathVerb::LineTo(point) if *point == Point::new(length, 0.0))));
643            let commands = crate::DrawContext::default()
644                .record(
645                    &arrow.fill(Color::from_hex(0x118677)),
646                    ValidationLimits::default(),
647                )
648                .unwrap();
649            assert_eq!(commands.len(), 1);
650            assert!(matches!(
651                commands[0],
652                crate::DrawCommand::Fill {
653                    rule: crate::FillRule::NonZero,
654                    ..
655                }
656            ));
657        }
658        let empty = Arrow::new(Point::new(2.0, 3.0), Point::new(2.0, 3.0))
659            .head_dimensions(12.0, 10.0)
660            .try_path()
661            .unwrap();
662        assert!(empty.verbs().is_empty());
663        for invalid in [
664            Arrow::new(Point::ORIGIN, Point::ORIGIN).head_dimensions(0.0, 10.0),
665            Arrow::new(Point::ORIGIN, Point::ORIGIN).head_dimensions(12.0, f64::NAN),
666        ] {
667            assert!(invalid.try_path().is_err());
668        }
669    }
670
671    #[test]
672    fn shapes_validate_bounds_degeneracies_splines_and_function_samples() {
673        assert_eq!(
674            Circle::new(Point::ORIGIN, 3.0).bounding_box(),
675            Rect::new(-3.0, -3.0, 3.0, 3.0)
676        );
677        assert_eq!(
678            RoundedRect::new(Rect::new(0.0, 0.0, 6.0, 4.0), 100.0).bounding_box(),
679            Rect::new(0.0, 0.0, 6.0, 4.0)
680        );
681        assert!(Circle::new(Point::ORIGIN, -1.0).try_path().is_err());
682        assert!(
683            Star::new(Point::ORIGIN, usize::MAX, 2.0, 1.0)
684                .try_path()
685                .is_err()
686        );
687        assert!(
688            RegularPolygon::new(Point::ORIGIN, 1.0, 2)
689                .try_path()
690                .is_err()
691        );
692        assert!(Ring::new(Point::ORIGIN, 3.0, 2.0).try_path().is_err());
693        assert!(
694            Arrow::new(Point::ORIGIN, Point::ORIGIN)
695                .try_path()
696                .unwrap()
697                .verbs()
698                .is_empty()
699        );
700        assert!(Circle::new(Point::ORIGIN, 0.0).try_path().unwrap().length() == 0.0);
701        let knots = [Point::ORIGIN, Point::new(2.0, 3.0), Point::new(4.0, 1.0)];
702        let spline = Spline::through(knots).try_path().unwrap();
703        assert!(
704            matches!(spline.verbs()[1], crate::PathVerb::CubicTo(_, _, end) if end == knots[1])
705        );
706        assert!(Spline::through(knots).tension(f64::NAN).try_path().is_err());
707        let graph = FunctionGraph::new(|value| value * value, -2.0..2.0)
708            .samples(5)
709            .try_path()
710            .unwrap();
711        assert_eq!(
712            graph.verbs()[0],
713            crate::PathVerb::MoveTo(Point::new(-2.0, 4.0))
714        );
715        assert_eq!(
716            graph.verbs().last(),
717            Some(&crate::PathVerb::LineTo(Point::new(2.0, 4.0)))
718        );
719        assert!(
720            FunctionGraph::new(|_| f64::NAN, 0.0..1.0)
721                .try_path()
722                .is_err()
723        );
724        assert!(
725            FunctionGraph::new(|_| panic!("Invalid count must precede callback"), 0.0..1.0)
726                .samples(usize::MAX)
727                .try_path()
728                .is_err()
729        );
730        assert!(
731            Node::from(Circle::new(Point::ORIGIN, -1.0).fill(Color::WHITE))
732                .compile(
733                    Size::new(8.0, 8.0),
734                    PixelSize::new(8, 8),
735                    ValidationLimits::default()
736                )
737                .is_err()
738        );
739    }
740}