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}