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