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