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