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}