Skip to main content

plotkit_core/
primitives.rs

1//! Core primitive types for plotkit rendering.
2//!
3//! This module defines the fundamental drawing primitives used throughout the
4//! plotkit rendering pipeline: geometric shapes, colors, strokes, text styles,
5//! paths, and images. These types form the interface between chart logic and
6//! backend renderers — no backend-specific types appear here.
7
8pub use kurbo::Affine;
9
10// ---------------------------------------------------------------------------
11// Point
12// ---------------------------------------------------------------------------
13
14/// A 2D point in device-independent coordinates.
15#[derive(Debug, Clone, Copy, PartialEq)]
16pub struct Point {
17    /// The x (horizontal) coordinate.
18    pub x: f64,
19    /// The y (vertical) coordinate.
20    pub y: f64,
21}
22
23impl Point {
24    /// Creates a new point at `(x, y)`.
25    pub fn new(x: f64, y: f64) -> Self {
26        Self { x, y }
27    }
28}
29
30// ---------------------------------------------------------------------------
31// Rect
32// ---------------------------------------------------------------------------
33
34/// An axis-aligned rectangle defined by its top-left corner and dimensions.
35#[derive(Debug, Clone, Copy, PartialEq)]
36pub struct Rect {
37    /// The x coordinate of the left edge.
38    pub x: f64,
39    /// The y coordinate of the top edge.
40    pub y: f64,
41    /// The width of the rectangle (must be non-negative for well-formed rects).
42    pub width: f64,
43    /// The height of the rectangle (must be non-negative for well-formed rects).
44    pub height: f64,
45}
46
47impl Rect {
48    /// Creates a new rectangle from a top-left corner and dimensions.
49    pub fn new(x: f64, y: f64, width: f64, height: f64) -> Self {
50        Self { x, y, width, height }
51    }
52
53    /// Creates the smallest axis-aligned rectangle that contains both points.
54    ///
55    /// The two points may be any pair of opposite corners; the result is always
56    /// a rectangle with non-negative width and height.
57    pub fn from_points(p1: Point, p2: Point) -> Self {
58        let x = p1.x.min(p2.x);
59        let y = p1.y.min(p2.y);
60        let width = (p1.x - p2.x).abs();
61        let height = (p1.y - p2.y).abs();
62        Self { x, y, width, height }
63    }
64
65    /// Returns `true` if `p` lies inside or on the boundary of this rectangle.
66    pub fn contains(&self, p: Point) -> bool {
67        p.x >= self.x
68            && p.x <= self.x + self.width
69            && p.y >= self.y
70            && p.y <= self.y + self.height
71    }
72
73    /// Returns the center point of the rectangle.
74    pub fn center(&self) -> Point {
75        Point::new(self.x + self.width / 2.0, self.y + self.height / 2.0)
76    }
77
78    /// Returns the x coordinate of the right edge (`x + width`).
79    pub fn right(&self) -> f64 {
80        self.x + self.width
81    }
82
83    /// Returns the y coordinate of the bottom edge (`y + height`).
84    pub fn bottom(&self) -> f64 {
85        self.y + self.height
86    }
87}
88
89// ---------------------------------------------------------------------------
90// Color
91// ---------------------------------------------------------------------------
92
93/// An RGBA color with 8 bits per channel.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
95pub struct Color {
96    /// Red channel (0–255).
97    pub r: u8,
98    /// Green channel (0–255).
99    pub g: u8,
100    /// Blue channel (0–255).
101    pub b: u8,
102    /// Alpha channel (0 = fully transparent, 255 = fully opaque).
103    pub a: u8,
104}
105
106impl Color {
107    /// Creates a new color from individual RGBA components.
108    pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self {
109        Self { r, g, b, a }
110    }
111
112    /// Creates a fully opaque color from RGB components (alpha = 255).
113    pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
114        Self { r, g, b, a: 255 }
115    }
116
117    /// Returns a copy of this color with the alpha channel set to `a`.
118    pub fn with_alpha(self, a: u8) -> Self {
119        Self { a, ..self }
120    }
121
122    /// Parses a hex color string such as `"#4E79A7"` or `"4E79A7"`.
123    ///
124    /// Returns `None` if the string is not a valid 6-digit hex color.
125    pub fn from_hex(hex: &str) -> Option<Self> {
126        let hex = hex.strip_prefix('#').unwrap_or(hex);
127        if hex.len() != 6 {
128            return None;
129        }
130        let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
131        let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
132        let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
133        Some(Self::rgb(r, g, b))
134    }
135
136    // -- Tableau-10 categorical palette (exact hex values) ------------------
137
138    /// Tableau-10 blue (`#4E79A7`).
139    pub const TAB_BLUE: Self = Self::rgb(0x4E, 0x79, 0xA7);
140    /// Tableau-10 orange (`#F28E2B`).
141    pub const TAB_ORANGE: Self = Self::rgb(0xF2, 0x8E, 0x2B);
142    /// Tableau-10 green (`#59A14F`).
143    pub const TAB_GREEN: Self = Self::rgb(0x59, 0xA1, 0x4F);
144    /// Tableau-10 red (`#E15759`).
145    pub const TAB_RED: Self = Self::rgb(0xE1, 0x57, 0x59);
146    /// Tableau-10 purple (`#B07AA1`).
147    pub const TAB_PURPLE: Self = Self::rgb(0xB0, 0x7A, 0xA1);
148    /// Tableau-10 brown (`#9C755F`).
149    pub const TAB_BROWN: Self = Self::rgb(0x9C, 0x75, 0x5F);
150    /// Tableau-10 pink (`#FF9DA7`).
151    pub const TAB_PINK: Self = Self::rgb(0xFF, 0x9D, 0xA7);
152    /// Tableau-10 grey (`#BAB0AC`).
153    pub const TAB_GREY: Self = Self::rgb(0xBA, 0xB0, 0xAC);
154    /// Tableau-10 olive / yellow (`#EDC948`).
155    pub const TAB_OLIVE: Self = Self::rgb(0xED, 0xC9, 0x48);
156    /// Tableau-10 cyan / teal (`#76B7B2`).
157    pub const TAB_CYAN: Self = Self::rgb(0x76, 0xB7, 0xB2);
158
159    /// Pure white (`#FFFFFF`).
160    pub const WHITE: Self = Self::rgb(255, 255, 255);
161    /// Pure black (`#000000`).
162    pub const BLACK: Self = Self::rgb(0, 0, 0);
163    /// Fully transparent black.
164    pub const TRANSPARENT: Self = Self::new(0, 0, 0, 0);
165
166    /// The complete Tableau-10 categorical palette, in canonical order.
167    pub const TABLEAU_10: [Self; 10] = [
168        Self::TAB_BLUE,
169        Self::TAB_ORANGE,
170        Self::TAB_GREEN,
171        Self::TAB_RED,
172        Self::TAB_PURPLE,
173        Self::TAB_BROWN,
174        Self::TAB_PINK,
175        Self::TAB_GREY,
176        Self::TAB_OLIVE,
177        Self::TAB_CYAN,
178    ];
179}
180
181// ---------------------------------------------------------------------------
182// Paint
183// ---------------------------------------------------------------------------
184
185/// Describes how a filled region should be painted.
186#[derive(Debug, Clone, Copy)]
187pub struct Paint {
188    /// The fill color.
189    pub color: Color,
190    /// Whether anti-aliasing is enabled for this fill.
191    pub anti_alias: bool,
192}
193
194impl Paint {
195    /// Creates a new paint with the given color and anti-aliasing enabled.
196    pub fn new(color: Color) -> Self {
197        Self {
198            color,
199            anti_alias: true,
200        }
201    }
202}
203
204// ---------------------------------------------------------------------------
205// Stroke
206// ---------------------------------------------------------------------------
207
208/// Describes the visual style of a stroked path.
209#[derive(Debug, Clone)]
210pub struct Stroke {
211    /// The width of the stroke in device-independent units.
212    pub width: f64,
213    /// The shape used at the endpoints of open sub-paths.
214    pub cap: StrokeCap,
215    /// The shape used at corners where two path segments meet.
216    pub join: StrokeJoin,
217    /// An optional dash pattern; `None` means a solid stroke.
218    pub dash: Option<DashPattern>,
219}
220
221/// The shape applied at the endpoints of an open sub-path.
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub enum StrokeCap {
224    /// The stroke ends exactly at the endpoint with no extension.
225    Butt,
226    /// The stroke is extended by a half-circle at each endpoint.
227    Round,
228    /// The stroke is extended by a half-square at each endpoint.
229    Square,
230}
231
232/// The shape applied at the join between two path segments.
233#[derive(Debug, Clone, Copy, PartialEq, Eq)]
234pub enum StrokeJoin {
235    /// A sharp corner is drawn (subject to the miter limit).
236    Miter,
237    /// A circular arc is drawn at the join.
238    Round,
239    /// A flat diagonal is drawn across the join.
240    Bevel,
241}
242
243/// A repeating dash pattern for stroked paths.
244#[derive(Debug, Clone)]
245pub struct DashPattern {
246    /// Alternating lengths of painted and unpainted segments.
247    pub dashes: Vec<f64>,
248    /// Offset into the dash pattern at which the stroke begins.
249    pub offset: f64,
250}
251
252impl Stroke {
253    /// Creates a solid stroke with the given width.
254    ///
255    /// Defaults to [`StrokeCap::Butt`], [`StrokeJoin::Miter`], and no dash
256    /// pattern.
257    pub fn new(width: f64) -> Self {
258        Self {
259            width,
260            cap: StrokeCap::Butt,
261            join: StrokeJoin::Miter,
262            dash: None,
263        }
264    }
265
266    /// Sets the dash pattern on this stroke (builder-style).
267    pub fn with_dash(mut self, pattern: DashPattern) -> Self {
268        self.dash = Some(pattern);
269        self
270    }
271}
272
273// ---------------------------------------------------------------------------
274// TextStyle
275// ---------------------------------------------------------------------------
276
277/// Controls how text is rendered: size, color, weight, font, and alignment.
278#[derive(Debug, Clone)]
279pub struct TextStyle {
280    /// Font size in device-independent units (points).
281    pub size: f64,
282    /// The color used to render the glyphs.
283    pub color: Color,
284    /// Font weight (normal or bold).
285    pub weight: FontWeight,
286    /// Optional font family name (e.g. `"Helvetica"`). `None` uses the
287    /// renderer's default.
288    pub family: Option<String>,
289    /// Horizontal alignment relative to the anchor point.
290    pub halign: HAlign,
291    /// Vertical alignment relative to the anchor point.
292    pub valign: VAlign,
293}
294
295/// Font weight selector.
296#[derive(Debug, Clone, Copy, PartialEq, Eq)]
297pub enum FontWeight {
298    /// Normal (regular) weight.
299    Normal,
300    /// Bold weight.
301    Bold,
302}
303
304/// Horizontal text alignment.
305#[derive(Debug, Clone, Copy, PartialEq, Eq)]
306pub enum HAlign {
307    /// Align the left edge of the text to the anchor point.
308    Left,
309    /// Center the text horizontally on the anchor point.
310    Center,
311    /// Align the right edge of the text to the anchor point.
312    Right,
313}
314
315/// Vertical text alignment.
316#[derive(Debug, Clone, Copy, PartialEq, Eq)]
317pub enum VAlign {
318    /// Align the top of the text bounding box to the anchor point.
319    Top,
320    /// Center the text vertically on the anchor point.
321    Middle,
322    /// Align the bottom of the text bounding box to the anchor point.
323    Bottom,
324    /// Align the text baseline to the anchor point.
325    Baseline,
326}
327
328impl TextStyle {
329    /// Creates a new text style with the given font size.
330    ///
331    /// Defaults: color [`Color::BLACK`], weight [`FontWeight::Normal`], no
332    /// explicit font family, horizontal alignment [`HAlign::Left`], vertical
333    /// alignment [`VAlign::Baseline`].
334    pub fn new(size: f64) -> Self {
335        Self {
336            size,
337            color: Color::BLACK,
338            weight: FontWeight::Normal,
339            family: None,
340            halign: HAlign::Left,
341            valign: VAlign::Baseline,
342        }
343    }
344}
345
346// ---------------------------------------------------------------------------
347// Image
348// ---------------------------------------------------------------------------
349
350/// A raster image stored as raw RGBA pixel data.
351#[derive(Debug, Clone)]
352pub struct Image {
353    /// Raw pixel data in RGBA order, row-major, with `4 * width * height`
354    /// bytes.
355    pub data: Vec<u8>,
356    /// Width of the image in pixels.
357    pub width: u32,
358    /// Height of the image in pixels.
359    pub height: u32,
360}
361
362// ---------------------------------------------------------------------------
363// Path / PathEl
364// ---------------------------------------------------------------------------
365
366/// A vector path composed of a sequence of [`PathEl`] elements.
367///
368/// Paths are the primary geometric primitive passed to renderers. Use the
369/// builder methods ([`move_to`](Path::move_to), [`line_to`](Path::line_to),
370/// etc.) to construct paths incrementally, or the convenience constructors
371/// [`Path::rect`] and [`Path::circle`] for common shapes.
372#[derive(Debug, Clone, Default)]
373pub struct Path {
374    /// The ordered sequence of path elements.
375    pub elements: Vec<PathEl>,
376}
377
378/// A single element within a [`Path`].
379#[derive(Debug, Clone, Copy)]
380pub enum PathEl {
381    /// Begins a new sub-path at the given point.
382    MoveTo(Point),
383    /// Draws a straight line from the current point to the given point.
384    LineTo(Point),
385    /// Draws a quadratic Bezier curve with one control point and an endpoint.
386    QuadTo(Point, Point),
387    /// Draws a cubic Bezier curve with two control points and an endpoint.
388    CurveTo(Point, Point, Point),
389    /// Closes the current sub-path by drawing a straight line back to its
390    /// starting point.
391    ClosePath,
392}
393
394impl Path {
395    /// Creates a new, empty path.
396    pub fn new() -> Self {
397        Self {
398            elements: Vec::new(),
399        }
400    }
401
402    /// Begins a new sub-path at `(x, y)`.
403    pub fn move_to(&mut self, x: f64, y: f64) -> &mut Self {
404        self.elements.push(PathEl::MoveTo(Point::new(x, y)));
405        self
406    }
407
408    /// Appends a straight line from the current point to `(x, y)`.
409    pub fn line_to(&mut self, x: f64, y: f64) -> &mut Self {
410        self.elements.push(PathEl::LineTo(Point::new(x, y)));
411        self
412    }
413
414    /// Appends a quadratic Bezier curve through control point `(x1, y1)` to
415    /// endpoint `(x, y)`.
416    pub fn quad_to(&mut self, x1: f64, y1: f64, x: f64, y: f64) -> &mut Self {
417        self.elements.push(PathEl::QuadTo(
418            Point::new(x1, y1),
419            Point::new(x, y),
420        ));
421        self
422    }
423
424    /// Appends a cubic Bezier curve through control points `(x1, y1)` and
425    /// `(x2, y2)` to endpoint `(x, y)`.
426    pub fn curve_to(
427        &mut self,
428        x1: f64,
429        y1: f64,
430        x2: f64,
431        y2: f64,
432        x: f64,
433        y: f64,
434    ) -> &mut Self {
435        self.elements.push(PathEl::CurveTo(
436            Point::new(x1, y1),
437            Point::new(x2, y2),
438            Point::new(x, y),
439        ));
440        self
441    }
442
443    /// Closes the current sub-path.
444    pub fn close(&mut self) -> &mut Self {
445        self.elements.push(PathEl::ClosePath);
446        self
447    }
448
449    /// Creates a closed rectangular path from the given [`Rect`].
450    pub fn rect(r: Rect) -> Self {
451        let mut p = Self::new();
452        p.move_to(r.x, r.y)
453            .line_to(r.right(), r.y)
454            .line_to(r.right(), r.bottom())
455            .line_to(r.x, r.bottom())
456            .close();
457        p
458    }
459
460    /// Creates a closed circular path centered at `center` with the given
461    /// `radius`, approximated by four cubic Bezier curves.
462    ///
463    /// The approximation uses the standard constant `kappa ≈ 0.5522847498`,
464    /// which gives a maximum radial error of about 0.027%.
465    pub fn circle(center: Point, radius: f64) -> Self {
466        // Magic number for a 4-segment cubic Bezier circle approximation.
467        const KAPPA: f64 = 0.552_284_749_8;
468        let k = radius * KAPPA;
469        let cx = center.x;
470        let cy = center.y;
471
472        let mut p = Self::new();
473        // Start at the rightmost point and go counter-clockwise.
474        p.move_to(cx + radius, cy);
475        // Top-right quarter arc.
476        p.curve_to(cx + radius, cy - k, cx + k, cy - radius, cx, cy - radius);
477        // Top-left quarter arc.
478        p.curve_to(cx - k, cy - radius, cx - radius, cy - k, cx - radius, cy);
479        // Bottom-left quarter arc.
480        p.curve_to(cx - radius, cy + k, cx - k, cy + radius, cx, cy + radius);
481        // Bottom-right quarter arc.
482        p.curve_to(cx + k, cy + radius, cx + radius, cy + k, cx + radius, cy);
483        p.close();
484        p
485    }
486
487    /// Returns `true` if the path contains no elements.
488    pub fn is_empty(&self) -> bool {
489        self.elements.is_empty()
490    }
491}
492
493// ---------------------------------------------------------------------------
494// Tests
495// ---------------------------------------------------------------------------
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500
501    #[test]
502    fn point_new() {
503        let p = Point::new(1.0, 2.0);
504        assert_eq!(p.x, 1.0);
505        assert_eq!(p.y, 2.0);
506    }
507
508    #[test]
509    fn rect_basics() {
510        let r = Rect::new(10.0, 20.0, 100.0, 50.0);
511        assert_eq!(r.right(), 110.0);
512        assert_eq!(r.bottom(), 70.0);
513        assert_eq!(r.center(), Point::new(60.0, 45.0));
514        assert!(r.contains(Point::new(60.0, 45.0)));
515        assert!(!r.contains(Point::new(0.0, 0.0)));
516    }
517
518    #[test]
519    fn rect_from_points() {
520        let r = Rect::from_points(Point::new(10.0, 20.0), Point::new(5.0, 30.0));
521        assert_eq!(r.x, 5.0);
522        assert_eq!(r.y, 20.0);
523        assert_eq!(r.width, 5.0);
524        assert_eq!(r.height, 10.0);
525    }
526
527    #[test]
528    fn color_hex_parsing() {
529        assert_eq!(Color::from_hex("#4E79A7"), Some(Color::TAB_BLUE));
530        assert_eq!(Color::from_hex("4E79A7"), Some(Color::TAB_BLUE));
531        assert_eq!(Color::from_hex("invalid"), None);
532        assert_eq!(Color::from_hex("#FFF"), None);
533    }
534
535    #[test]
536    fn color_with_alpha() {
537        let c = Color::TAB_BLUE.with_alpha(128);
538        assert_eq!(c.r, 0x4E);
539        assert_eq!(c.a, 128);
540    }
541
542    #[test]
543    fn tableau_10_length() {
544        assert_eq!(Color::TABLEAU_10.len(), 10);
545        assert_eq!(Color::TABLEAU_10[0], Color::TAB_BLUE);
546        assert_eq!(Color::TABLEAU_10[9], Color::TAB_CYAN);
547    }
548
549    #[test]
550    fn stroke_defaults() {
551        let s = Stroke::new(2.0);
552        assert_eq!(s.width, 2.0);
553        assert_eq!(s.cap, StrokeCap::Butt);
554        assert_eq!(s.join, StrokeJoin::Miter);
555        assert!(s.dash.is_none());
556    }
557
558    #[test]
559    fn stroke_with_dash() {
560        let s = Stroke::new(1.0).with_dash(DashPattern {
561            dashes: vec![5.0, 3.0],
562            offset: 0.0,
563        });
564        assert!(s.dash.is_some());
565        assert_eq!(s.dash.as_ref().unwrap().dashes, vec![5.0, 3.0]);
566    }
567
568    #[test]
569    fn text_style_defaults() {
570        let ts = TextStyle::new(12.0);
571        assert_eq!(ts.size, 12.0);
572        assert_eq!(ts.color, Color::BLACK);
573        assert_eq!(ts.weight, FontWeight::Normal);
574        assert!(ts.family.is_none());
575        assert_eq!(ts.halign, HAlign::Left);
576        assert_eq!(ts.valign, VAlign::Baseline);
577    }
578
579    #[test]
580    fn path_rect() {
581        let p = Path::rect(Rect::new(0.0, 0.0, 10.0, 10.0));
582        // MoveTo + 3 LineTo + ClosePath = 5 elements
583        assert_eq!(p.elements.len(), 5);
584        assert!(!p.is_empty());
585    }
586
587    #[test]
588    fn path_circle() {
589        let p = Path::circle(Point::new(0.0, 0.0), 50.0);
590        // MoveTo + 4 CurveTo + ClosePath = 6 elements
591        assert_eq!(p.elements.len(), 6);
592    }
593
594    #[test]
595    fn path_builder() {
596        let mut p = Path::new();
597        assert!(p.is_empty());
598        p.move_to(0.0, 0.0)
599            .line_to(10.0, 0.0)
600            .quad_to(15.0, 5.0, 10.0, 10.0)
601            .curve_to(5.0, 15.0, -5.0, 15.0, -10.0, 10.0)
602            .close();
603        assert_eq!(p.elements.len(), 5);
604    }
605
606    #[test]
607    fn paint_defaults() {
608        let p = Paint::new(Color::BLACK);
609        assert!(p.anti_alias);
610        assert_eq!(p.color, Color::BLACK);
611    }
612}