1use std::ops::RangeInclusive;
43
44use super::tolerance::{
45 ARC_FAN_MAX_STEP, ARC_FAN_MIN_STEP, ARC_FAN_TOLERANCE, DEGENERATE_EPS as EPSILON,
46};
47use crate::color::Color;
48use crate::geometry::{Point, Vec2};
49use crate::mesh::Mesh;
50use crate::stroke::{Cap, Join};
51
52const DEFAULT_COLOR: Color = Color::new([0.0, 0.0, 0.0, 1.0]);
55
56const CAP_FAN_SEGMENTS: RangeInclusive<usize> = 4..=64;
60const JOIN_FAN_SEGMENTS: RangeInclusive<usize> = 2..=32;
64
65const SEAM_BLEED_PX: f64 = 0.75;
74
75#[derive(Clone, Copy, Debug)]
81pub struct RibbonOptions {
82 pub half_width: f64,
86 pub cap: Cap,
89 pub join: Join,
91 pub miter_limit: f64,
95}
96
97impl Default for RibbonOptions {
98 fn default() -> Self {
99 Self {
100 half_width: 1.0,
101 cap: Cap::Butt,
102 join: Join::Miter,
103 miter_limit: 4.0,
104 }
105 }
106}
107
108pub fn polyline_ribbon(points: &[Point], color: Color, opts: &RibbonOptions) -> Mesh {
113 ribbon(
114 "polyline_ribbon",
115 points,
116 ColorSource::Constant(color),
117 None,
118 opts,
119 false,
120 )
121}
122
123pub fn polyline_gradient(points: &[Point], colors: &[Color], opts: &RibbonOptions) -> Mesh {
126 ribbon(
127 "polyline_gradient",
128 points,
129 ColorSource::PerVertex(colors),
130 None,
131 opts,
132 false,
133 )
134}
135
136pub fn polyline_ribbon_full(
141 points: &[Point],
142 colors: Option<&[Color]>,
143 half_widths: Option<&[f64]>,
144 opts: &RibbonOptions,
145) -> Mesh {
146 ribbon(
147 "polyline_ribbon_full",
148 points,
149 ColorSource::from_optional(colors),
150 half_widths,
151 opts,
152 false,
153 )
154}
155
156pub fn polygon_ribbon(points: &[Point], color: Color, opts: &RibbonOptions) -> Mesh {
162 ribbon(
163 "polygon_ribbon",
164 points,
165 ColorSource::Constant(color),
166 None,
167 opts,
168 true,
169 )
170}
171
172pub fn polygon_gradient(points: &[Point], colors: &[Color], opts: &RibbonOptions) -> Mesh {
178 ribbon(
179 "polygon_gradient",
180 points,
181 ColorSource::PerVertex(colors),
182 None,
183 opts,
184 true,
185 )
186}
187
188pub fn polygon_ribbon_full(
194 points: &[Point],
195 colors: Option<&[Color]>,
196 half_widths: Option<&[f64]>,
197 opts: &RibbonOptions,
198) -> Mesh {
199 ribbon(
200 "polygon_ribbon_full",
201 points,
202 ColorSource::from_optional(colors),
203 half_widths,
204 opts,
205 true,
206 )
207}
208
209fn ribbon(
212 who: &str,
213 points: &[Point],
214 colors: ColorSource<'_>,
215 half_widths: Option<&[f64]>,
216 opts: &RibbonOptions,
217 closed: bool,
218) -> Mesh {
219 if let ColorSource::PerVertex(c) = colors {
220 assert_eq!(
221 points.len(),
222 c.len(),
223 "{who}: points.len() ({}) != colors.len() ({})",
224 points.len(),
225 c.len(),
226 );
227 }
228 if let Some(w) = half_widths {
229 assert_eq!(
230 points.len(),
231 w.len(),
232 "{who}: points.len() ({}) != half_widths.len() ({})",
233 points.len(),
234 w.len(),
235 );
236 }
237 ribbon_inner(points, colors, half_widths, opts, closed)
238}
239
240pub fn ribbon_band_mesh(
269 curve_a: &[Point],
270 curve_b: &[Point],
271 colors_a: &[Color],
272 colors_b: &[Color],
273) -> Mesh {
274 assert_eq!(
275 curve_a.len(),
276 curve_b.len(),
277 "ribbon_band_mesh: curve_a.len() ({}) != curve_b.len() ({})",
278 curve_a.len(),
279 curve_b.len(),
280 );
281 assert_eq!(
282 curve_a.len(),
283 colors_a.len(),
284 "ribbon_band_mesh: colors_a.len() must match curve_a.len()"
285 );
286 assert_eq!(
287 curve_b.len(),
288 colors_b.len(),
289 "ribbon_band_mesh: colors_b.len() must match curve_b.len()"
290 );
291 let n = curve_a.len();
292 if n < 2 {
293 return Mesh::new(Vec::new(), Vec::new(), Vec::new());
294 }
295
296 let segs = n - 1;
297 let mut vertices: Vec<Point> = Vec::with_capacity(4 * segs);
298 let mut colors: Vec<Color> = Vec::with_capacity(4 * segs);
299 let mut indices: Vec<u32> = Vec::with_capacity(6 * segs);
300
301 for i in 0..segs {
302 let m0 = Vec2::new(
304 (curve_a[i].x + curve_b[i].x) * 0.5,
305 (curve_a[i].y + curve_b[i].y) * 0.5,
306 );
307 let m1 = Vec2::new(
308 (curve_a[i + 1].x + curve_b[i + 1].x) * 0.5,
309 (curve_a[i + 1].y + curve_b[i + 1].y) * 0.5,
310 );
311 let delta = m1 - m0;
312 let len = delta.hypot();
313 let tangent = if len > EPSILON {
314 delta / len
315 } else {
316 Vec2::new(0.0, 0.0)
317 };
318 let interior_bleed = SEAM_BLEED_PX / 3.0;
325 let near_bleed = if i > 0 { interior_bleed } else { 0.0 };
326 let far_bleed = if i + 1 < segs { interior_bleed } else { 0.0 };
327 let near_off = tangent * near_bleed;
328 let far_off = tangent * far_bleed;
329
330 let base = vertices.len() as u32;
331 vertices.push(curve_a[i] - near_off);
332 vertices.push(curve_b[i] - near_off);
333 vertices.push(curve_b[i + 1] + far_off);
334 vertices.push(curve_a[i + 1] + far_off);
335 colors.push(colors_a[i]);
336 colors.push(colors_b[i]);
337 colors.push(colors_b[i + 1]);
338 colors.push(colors_a[i + 1]);
339 indices.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
340 }
341
342 Mesh::new(vertices, colors, indices)
343}
344
345#[derive(Clone, Copy)]
348enum ColorSource<'a> {
349 Constant(Color),
350 PerVertex(&'a [Color]),
351}
352
353impl<'a> ColorSource<'a> {
354 fn from_optional(colors: Option<&'a [Color]>) -> Self {
357 match colors {
358 Some(c) => ColorSource::PerVertex(c),
359 None => ColorSource::Constant(DEFAULT_COLOR),
360 }
361 }
362
363 fn at(&self, i: usize) -> Color {
364 match self {
365 ColorSource::Constant(c) => *c,
366 ColorSource::PerVertex(slice) => slice[i],
367 }
368 }
369}
370
371struct VertexLayout {
373 in_left: Point,
376 in_right: Point,
377 out_left: Point,
380 out_right: Point,
381 is_bevel: bool,
384 bevel_outside_left: bool,
387}
388
389fn ribbon_inner(
390 points: &[Point],
391 colors: ColorSource<'_>,
392 half_widths: Option<&[f64]>,
393 opts: &RibbonOptions,
394 closed: bool,
395) -> Mesh {
396 let n = points.len();
397 let min_pts = if closed { 3 } else { 2 };
400 if n < min_pts {
401 return Mesh::new(Vec::new(), Vec::new(), Vec::new());
402 }
403
404 let hw = |i: usize| -> f64 {
406 match half_widths {
407 Some(w) => w[i],
408 None => opts.half_width,
409 }
410 };
411
412 let n_segs = if closed { n } else { n - 1 };
417 let mut seg_tangent: Vec<Vec2> = Vec::with_capacity(n_segs);
418 for i in 0..n_segs {
419 let delta = points[(i + 1) % n] - points[i];
420 let len = delta.hypot();
421 if len <= EPSILON {
422 let last = seg_tangent.last().copied().unwrap_or(Vec2::new(1.0, 0.0));
427 seg_tangent.push(last);
428 } else {
429 seg_tangent.push(delta / len);
430 }
431 }
432
433 let mut layouts: Vec<VertexLayout> = Vec::with_capacity(n);
435 for i in 0..n {
436 let t_in = if i == 0 {
442 if closed {
443 seg_tangent[n - 1]
444 } else {
445 seg_tangent[0]
446 }
447 } else {
448 seg_tangent[i - 1]
449 };
450 let t_out = if i + 1 == n {
451 if closed {
452 seg_tangent[n - 1]
453 } else {
454 seg_tangent[n - 2]
455 }
456 } else {
457 seg_tangent[i]
458 };
459 let pi = points[i];
460 let w = hw(i);
461
462 if !closed && (i == 0 || i + 1 == n) {
463 let t = if i == 0 { t_out } else { t_in };
465 let n_left = perp_left(t);
466 let l = pi + n_left * w;
467 let r = pi - n_left * w;
468 layouts.push(VertexLayout {
469 in_left: l,
470 in_right: r,
471 out_left: l,
472 out_right: r,
473 is_bevel: false,
474 bevel_outside_left: false,
475 });
476 continue;
477 }
478
479 let perp_in = perp_left(t_in);
481 let perp_out = perp_left(t_out);
482 let cross = t_in.x * t_out.y - t_in.y * t_out.x;
485 let dot = t_in.x * t_out.x + t_in.y * t_out.y;
486 let bevel_outside_left = cross < 0.0;
487
488 let denom = 1.0 + dot;
490 let miter_mag = if denom > EPSILON {
491 (2.0 / denom).sqrt()
496 } else {
497 f64::INFINITY
498 };
499
500 let want_miter = match opts.join {
501 Join::Miter => miter_mag <= opts.miter_limit && denom > EPSILON,
502 _ => false,
506 };
507
508 if want_miter {
509 let mitre = (perp_in + perp_out) * (w / denom);
510 let l = pi + mitre;
511 let r = pi - mitre;
512 layouts.push(VertexLayout {
513 in_left: l,
514 in_right: r,
515 out_left: l,
516 out_right: r,
517 is_bevel: false,
518 bevel_outside_left,
519 });
520 } else {
521 let in_l = pi + perp_in * w;
524 let in_r = pi - perp_in * w;
525 let out_l = pi + perp_out * w;
526 let out_r = pi - perp_out * w;
527 layouts.push(VertexLayout {
528 in_left: in_l,
529 in_right: in_r,
530 out_left: out_l,
531 out_right: out_r,
532 is_bevel: true,
533 bevel_outside_left,
534 });
535 }
536 }
537
538 let mut vertices: Vec<Point> = Vec::new();
540 let mut vcolors: Vec<Color> = Vec::new();
541 let mut indices: Vec<u32> = Vec::new();
542
543 let push_vertex =
545 |vertices: &mut Vec<Point>, vcolors: &mut Vec<Color>, p: Point, c: Color| -> u32 {
546 let idx = vertices.len() as u32;
547 vertices.push(p);
548 vcolors.push(c);
549 idx
550 };
551
552 if !closed {
574 emit_cap(
575 &mut vertices,
576 &mut vcolors,
577 &mut indices,
578 points[0],
579 layouts[0].out_left,
580 layouts[0].out_right,
581 -seg_tangent[0],
582 colors.at(0),
583 opts.cap,
584 hw(0),
585 );
586 }
587
588 let cap_bleed_amount = match opts.cap {
600 Cap::Butt => 0.0,
601 Cap::Square | Cap::Round => SEAM_BLEED_PX,
602 };
603 for i in 0..n_segs {
604 let i_next = (i + 1) % n;
605 let ci = colors.at(i);
606 let cj = colors.at(i_next);
607 let t = seg_tangent[i];
608 let near_bleed_amount = if closed || i > 0 {
612 SEAM_BLEED_PX
613 } else {
614 cap_bleed_amount
615 };
616 let far_bleed_amount = if closed || i + 1 < n - 1 {
617 SEAM_BLEED_PX
618 } else {
619 cap_bleed_amount
620 };
621 let near_bleed = t * near_bleed_amount;
622 let far_bleed = t * far_bleed_amount;
623 let a_pos = layouts[i].out_left - near_bleed;
624 let b_pos = layouts[i].out_right - near_bleed;
625 let c_pos = layouts[i_next].in_right + far_bleed;
626 let d_pos = layouts[i_next].in_left + far_bleed;
627 let a = push_vertex(&mut vertices, &mut vcolors, a_pos, ci);
628 let b = push_vertex(&mut vertices, &mut vcolors, b_pos, ci);
629 let c = push_vertex(&mut vertices, &mut vcolors, c_pos, cj);
630 let d = push_vertex(&mut vertices, &mut vcolors, d_pos, cj);
631 indices.extend_from_slice(&[a, b, c, a, c, d]);
632
633 let is_interior_join = closed || i_next < n - 1;
638 if is_interior_join && layouts[i_next].is_bevel {
639 emit_join_fill(
640 &mut vertices,
641 &mut vcolors,
642 &mut indices,
643 points[i_next],
644 &layouts[i_next],
645 colors.at(i_next),
646 opts.join,
647 );
648 }
649 }
650
651 if !closed {
653 let last = n - 1;
654 emit_cap(
655 &mut vertices,
656 &mut vcolors,
657 &mut indices,
658 points[last],
659 layouts[last].in_right,
660 layouts[last].in_left,
661 seg_tangent[n - 2],
662 colors.at(last),
663 opts.cap,
664 hw(last),
665 );
666 }
667
668 Mesh::new(vertices, vcolors, indices)
669}
670
671fn emit_join_fill(
675 vertices: &mut Vec<Point>,
676 vcolors: &mut Vec<Color>,
677 indices: &mut Vec<u32>,
678 pi: Point,
679 layout: &VertexLayout,
680 color: Color,
681 join: Join,
682) {
683 let (outside_in, outside_out) = if layout.bevel_outside_left {
684 (layout.in_left, layout.out_left)
685 } else {
686 (layout.in_right, layout.out_right)
687 };
688 match join {
689 Join::Bevel | Join::Miter => {
690 let i_p = vertices.len() as u32;
691 vertices.push(pi);
692 vcolors.push(color);
693 let i_oi = vertices.len() as u32;
694 vertices.push(outside_in);
695 vcolors.push(color);
696 let i_oo = vertices.len() as u32;
697 vertices.push(outside_out);
698 vcolors.push(color);
699 indices.extend_from_slice(&[i_p, i_oi, i_oo]);
700 }
701 Join::Round => {
702 let va = outside_in - pi;
705 emit_arc_fan(
706 vertices,
707 vcolors,
708 indices,
709 pi,
710 outside_in,
711 va.hypot(),
712 va.y.atan2(va.x),
713 normalized_delta(va, outside_out - pi),
714 JOIN_FAN_SEGMENTS,
715 color,
716 );
717 }
718 }
719}
720
721#[allow(clippy::too_many_arguments, clippy::ptr_arg)]
729fn emit_cap(
730 vertices: &mut Vec<Point>,
731 vcolors: &mut Vec<Color>,
732 indices: &mut Vec<u32>,
733 endpoint: Point,
734 a: Point,
735 b: Point,
736 outward: Vec2,
737 color: Color,
738 cap: Cap,
739 half_width: f64,
740) {
741 match cap {
742 Cap::Butt => {} Cap::Square => {
744 let a_ext = a + outward * half_width;
747 let b_ext = b + outward * half_width;
748 let i_a = vertices.len() as u32;
749 vertices.push(a);
750 vcolors.push(color);
751 let i_b = vertices.len() as u32;
752 vertices.push(b);
753 vcolors.push(color);
754 let i_be = vertices.len() as u32;
755 vertices.push(b_ext);
756 vcolors.push(color);
757 let i_ae = vertices.len() as u32;
758 vertices.push(a_ext);
759 vcolors.push(color);
760 indices.extend_from_slice(&[i_a, i_b, i_be, i_a, i_be, i_ae]);
761 }
762 Cap::Round => {
763 let va = a - endpoint;
767 let mut delta = normalized_delta(va, b - endpoint);
768 if delta.abs() < std::f64::consts::PI - 1e-6 {
773 delta = if delta >= 0.0 {
774 delta - std::f64::consts::TAU
775 } else {
776 delta + std::f64::consts::TAU
777 };
778 }
779 emit_arc_fan(
780 vertices,
781 vcolors,
782 indices,
783 endpoint,
784 a,
785 half_width.max(EPSILON),
786 va.y.atan2(va.x),
787 delta,
788 CAP_FAN_SEGMENTS,
789 color,
790 );
791 }
792 }
793}
794
795fn normalized_delta(from: Vec2, to: Vec2) -> f64 {
798 let mut delta = to.y.atan2(to.x) - from.y.atan2(from.x);
799 while delta > std::f64::consts::PI {
800 delta -= std::f64::consts::TAU;
801 }
802 while delta <= -std::f64::consts::PI {
803 delta += std::f64::consts::TAU;
804 }
805 delta
806}
807
808#[allow(clippy::too_many_arguments)]
820fn emit_arc_fan(
821 vertices: &mut Vec<Point>,
822 vcolors: &mut Vec<Color>,
823 indices: &mut Vec<u32>,
824 center: Point,
825 start: Point,
826 r: f64,
827 theta_a: f64,
828 delta: f64,
829 seg_clamp: RangeInclusive<usize>,
830 color: Color,
831) {
832 let chord_step = (1.0 - (ARC_FAN_TOLERANCE / r.max(EPSILON)).clamp(0.0, 1.0)).acos() * 2.0;
833 let theta_step = chord_step.clamp(ARC_FAN_MIN_STEP, ARC_FAN_MAX_STEP);
834 let segments = (delta.abs() / theta_step).ceil() as usize;
835 let n_steps = segments.clamp(*seg_clamp.start(), *seg_clamp.end());
836 let step = delta / n_steps as f64;
837
838 let i_center = vertices.len() as u32;
839 vertices.push(center);
840 vcolors.push(color);
841 let i_start = vertices.len() as u32;
842 vertices.push(start);
843 vcolors.push(color);
844 let mut prev = i_start;
845 for k in 1..=n_steps {
846 let theta = theta_a + step * k as f64;
847 let p = Point::new(center.x + r * theta.cos(), center.y + r * theta.sin());
848 let idx = vertices.len() as u32;
849 vertices.push(p);
850 vcolors.push(color);
851 indices.extend_from_slice(&[i_center, prev, idx]);
852 prev = idx;
853 }
854}
855
856#[inline]
857fn perp_left(v: Vec2) -> Vec2 {
858 Vec2::new(-v.y, v.x)
859}
860
861#[cfg(test)]
864mod tests {
865 use super::*;
866
867 fn pt(x: f64, y: f64) -> Point {
868 Point::new(x, y)
869 }
870 fn red() -> Color {
871 Color::new([1.0, 0.0, 0.0, 1.0])
872 }
873 fn green() -> Color {
874 Color::new([0.0, 1.0, 0.0, 1.0])
875 }
876 fn blue() -> Color {
877 Color::new([0.0, 0.0, 1.0, 1.0])
878 }
879
880 fn approx(a: f64, b: f64) -> bool {
881 (a - b).abs() < 1e-9
882 }
883
884 #[test]
885 fn polyline_ribbon_two_point_butt() {
886 let pts = [pt(0.0, 0.0), pt(10.0, 0.0)];
890 let opts = RibbonOptions {
891 half_width: 1.0,
892 cap: Cap::Butt,
893 join: Join::Miter,
894 miter_limit: 4.0,
895 };
896 let mesh = polyline_ribbon(&pts, red(), &opts);
897 assert_eq!(mesh.vertex_count(), 4);
898 assert_eq!(mesh.triangle_count(), 2);
899 let mut ys: Vec<f64> = mesh.vertices.iter().map(|p| p.y).collect();
901 ys.sort_by(|a, b| a.partial_cmp(b).unwrap());
902 assert!(approx(ys[0], -1.0));
903 assert!(approx(ys[1], -1.0));
904 assert!(approx(ys[2], 1.0));
905 assert!(approx(ys[3], 1.0));
906 }
907
908 #[test]
909 fn polyline_ribbon_constant_color_all_vertices_match() {
910 let pts = [pt(0.0, 0.0), pt(10.0, 0.0)];
911 let mesh = polyline_ribbon(&pts, red(), &RibbonOptions::default());
912 for c in &mesh.colors {
913 assert_eq!(*c, red());
914 }
915 }
916
917 #[test]
918 fn polyline_gradient_endpoint_colors_preserved() {
919 let pts = [pt(0.0, 0.0), pt(10.0, 0.0)];
924 let cols = [red(), blue()];
925 let mesh = polyline_gradient(&pts, &cols, &RibbonOptions::default());
926 assert_eq!(mesh.vertex_count(), 4);
927 for (p, c) in mesh.vertices.iter().zip(mesh.colors.iter()) {
930 if approx(p.x, 0.0) {
931 assert_eq!(*c, red());
932 } else if approx(p.x, 10.0) {
933 assert_eq!(*c, blue());
934 }
935 }
936 }
937
938 #[test]
939 fn polyline_gradient_interior_color_shared_across_segments() {
940 let pts = [pt(0.0, 0.0), pt(10.0, 0.0), pt(20.0, 0.0)];
948 let cols = [red(), green(), blue()];
949 let mesh = polyline_gradient(&pts, &cols, &RibbonOptions::default());
950 let interior_greens = mesh
953 .vertices
954 .iter()
955 .zip(mesh.colors.iter())
956 .filter(|(p, _)| (p.x - 10.0).abs() < 2.0)
957 .map(|(_, c)| *c)
958 .collect::<Vec<_>>();
959 assert!(!interior_greens.is_empty());
960 for c in &interior_greens {
961 assert_eq!(*c, green(), "interior shoulder should be green");
962 }
963 }
964
965 #[test]
966 fn polyline_ribbon_full_variable_width_shoulder_offsets() {
967 let pts = [pt(0.0, 0.0), pt(5.0, 0.0), pt(10.0, 0.0)];
973 let widths = [1.0_f64, 2.0, 1.0];
974 let mesh = polyline_ribbon_full(&pts, None, Some(&widths), &RibbonOptions::default());
975 let mut shoulders_at_x: Vec<(f64, Vec<f64>)> =
978 vec![(0.0, Vec::new()), (5.0, Vec::new()), (10.0, Vec::new())];
979 for p in &mesh.vertices {
980 for (x, ys) in shoulders_at_x.iter_mut() {
981 if (p.x - *x).abs() < 1.0 {
982 ys.push(p.y);
983 }
984 }
985 }
986 for (x, ys) in shoulders_at_x {
987 let expected: Vec<f64> = if approx(x, 5.0) {
988 vec![-2.0, 2.0]
989 } else {
990 vec![-1.0, 1.0]
991 };
992 let mut sorted = ys.clone();
993 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
994 sorted.dedup_by(|a, b| approx(*a, *b));
995 assert_eq!(
996 sorted.len(),
997 expected.len(),
998 "at x={x}, unique shoulder ys = {sorted:?}"
999 );
1000 for (s, e) in sorted.iter().zip(expected.iter()) {
1001 assert!(approx(*s, *e), "at x={x}, got {s}, expected {e}");
1002 }
1003 }
1004 }
1005
1006 #[test]
1007 fn polyline_ribbon_90_corner_mitre() {
1008 let pts = [pt(0.0, 0.0), pt(10.0, 0.0), pt(10.0, 10.0)];
1012 let opts = RibbonOptions {
1013 half_width: 1.0,
1014 join: Join::Miter,
1015 ..RibbonOptions::default()
1016 };
1017 let mesh = polyline_ribbon(&pts, red(), &opts);
1018 assert_eq!(mesh.triangle_count(), 4);
1020 let near_mitre = mesh.vertices.iter().find(|p| {
1028 (approx(p.x, 11.75) && approx(p.y, -1.0)) || (approx(p.x, 11.0) && approx(p.y, -0.25))
1029 });
1030 assert!(
1031 near_mitre.is_some(),
1032 "expected bled outer-mitre near (11, -1); got vertices = {:?}",
1033 mesh.vertices
1034 );
1035 }
1036
1037 #[test]
1038 fn polyline_ribbon_sharp_corner_clamps_to_bevel() {
1039 let pts = [pt(0.0, 0.0), pt(10.0, 0.0), pt(0.0, 0.1)];
1043 let opts = RibbonOptions {
1044 half_width: 1.0,
1045 join: Join::Miter,
1046 miter_limit: 2.0,
1047 ..RibbonOptions::default()
1048 };
1049 let mesh = polyline_ribbon(&pts, red(), &opts);
1050 assert_eq!(mesh.triangle_count(), 5);
1052 }
1053
1054 #[test]
1055 fn polyline_ribbon_bevel_join_emits_extra_triangle() {
1056 let pts = [pt(0.0, 0.0), pt(10.0, 0.0), pt(10.0, 10.0)];
1057 let opts = RibbonOptions {
1058 half_width: 1.0,
1059 join: Join::Bevel,
1060 ..RibbonOptions::default()
1061 };
1062 let mesh = polyline_ribbon(&pts, red(), &opts);
1063 assert_eq!(mesh.triangle_count(), 5);
1065 }
1066
1067 #[test]
1068 fn polyline_ribbon_round_join_emits_fan() {
1069 let pts = [pt(0.0, 0.0), pt(10.0, 0.0), pt(10.0, 10.0)];
1070 let opts = RibbonOptions {
1071 half_width: 5.0, join: Join::Round,
1073 ..RibbonOptions::default()
1074 };
1075 let mesh = polyline_ribbon(&pts, red(), &opts);
1076 assert!(
1078 mesh.triangle_count() >= 6,
1079 "got {} triangles",
1080 mesh.triangle_count()
1081 );
1082 }
1083
1084 #[test]
1085 fn polyline_ribbon_square_cap_extends_endpoint() {
1086 let pts = [pt(0.0, 0.0), pt(10.0, 0.0)];
1087 let opts = RibbonOptions {
1088 half_width: 1.0,
1089 cap: Cap::Square,
1090 ..RibbonOptions::default()
1091 };
1092 let mesh = polyline_ribbon(&pts, red(), &opts);
1093 assert_eq!(mesh.triangle_count(), 6);
1096 let bb = mesh.bounding_box();
1099 assert!(approx(bb.x0, -1.0));
1100 assert!(approx(bb.x1, 11.0));
1101 }
1102
1103 #[test]
1104 fn polyline_ribbon_round_cap_emits_fan() {
1105 let pts = [pt(0.0, 0.0), pt(10.0, 0.0)];
1106 let opts = RibbonOptions {
1107 half_width: 5.0,
1108 cap: Cap::Round,
1109 ..RibbonOptions::default()
1110 };
1111 let mesh = polyline_ribbon(&pts, red(), &opts);
1112 assert!(mesh.triangle_count() >= 2 + 2 * 4);
1114 }
1115
1116 #[test]
1117 fn polyline_ribbon_butt_cap_emits_no_cap_triangles() {
1118 let pts = [pt(0.0, 0.0), pt(10.0, 0.0)];
1119 let opts = RibbonOptions {
1120 half_width: 1.0,
1121 cap: Cap::Butt,
1122 ..RibbonOptions::default()
1123 };
1124 let mesh = polyline_ribbon(&pts, red(), &opts);
1125 assert_eq!(mesh.triangle_count(), 2);
1126 }
1127
1128 #[test]
1129 fn polyline_ribbon_bounding_box_straight_butt() {
1130 let pts = [pt(0.0, 0.0), pt(10.0, 0.0)];
1131 let opts = RibbonOptions {
1132 half_width: 1.0,
1133 cap: Cap::Butt,
1134 ..RibbonOptions::default()
1135 };
1136 let mesh = polyline_ribbon(&pts, red(), &opts);
1137 let bb = mesh.bounding_box();
1138 assert!(approx(bb.x0, 0.0));
1139 assert!(approx(bb.x1, 10.0));
1140 assert!(approx(bb.y0, -1.0));
1141 assert!(approx(bb.y1, 1.0));
1142 }
1143
1144 #[test]
1145 fn polyline_ribbon_under_two_points_returns_empty() {
1146 let pts = [pt(0.0, 0.0)];
1147 let mesh = polyline_ribbon(&pts, red(), &RibbonOptions::default());
1148 assert!(mesh.is_empty());
1149 }
1150
1151 #[test]
1152 #[should_panic(expected = "colors.len()")]
1153 fn polyline_gradient_panics_on_length_mismatch() {
1154 let pts = [pt(0.0, 0.0), pt(10.0, 0.0)];
1155 let cols = [red(), green(), blue()];
1156 let _ = polyline_gradient(&pts, &cols, &RibbonOptions::default());
1157 }
1158
1159 #[test]
1162 fn polygon_ribbon_equilateral_triangle_segment_count() {
1163 let pts = [pt(0.0, 0.0), pt(10.0, 0.0), pt(5.0, 8.66)];
1167 let opts = RibbonOptions {
1168 half_width: 1.0,
1169 join: Join::Miter,
1170 ..RibbonOptions::default()
1171 };
1172 let mesh = polygon_ribbon(&pts, red(), &opts);
1173 assert_eq!(mesh.triangle_count(), 6);
1174 }
1175
1176 #[test]
1177 fn polygon_ribbon_square_bevel_emits_four_extra_triangles() {
1178 let pts = [pt(0.0, 0.0), pt(10.0, 0.0), pt(10.0, 10.0), pt(0.0, 10.0)];
1181 let opts = RibbonOptions {
1182 half_width: 1.0,
1183 join: Join::Bevel,
1184 ..RibbonOptions::default()
1185 };
1186 let mesh = polygon_ribbon(&pts, red(), &opts);
1187 assert_eq!(mesh.triangle_count(), 12);
1188 }
1189
1190 #[test]
1191 fn polygon_ribbon_too_few_points_returns_empty() {
1192 for pts in [&[][..], &[pt(0.0, 0.0)], &[pt(0.0, 0.0), pt(10.0, 0.0)]] {
1194 let mesh = polygon_ribbon(pts, red(), &RibbonOptions::default());
1195 assert!(
1196 mesh.is_empty(),
1197 "expected empty mesh for {} points",
1198 pts.len()
1199 );
1200 }
1201 }
1202
1203 #[test]
1204 fn polygon_ribbon_cap_setting_is_ignored() {
1205 let pts = [pt(0.0, 0.0), pt(10.0, 0.0), pt(5.0, 8.66)];
1208 let make = |cap| {
1209 let opts = RibbonOptions {
1210 half_width: 1.0,
1211 cap,
1212 join: Join::Miter,
1213 ..RibbonOptions::default()
1214 };
1215 polygon_ribbon(&pts, red(), &opts).triangle_count()
1216 };
1217 let butt = make(Cap::Butt);
1218 assert_eq!(butt, make(Cap::Square));
1219 assert_eq!(butt, make(Cap::Round));
1220 }
1221
1222 #[test]
1223 fn polygon_gradient_wrap_segment_closes_color_loop() {
1224 let pts = [pt(0.0, 0.0), pt(10.0, 0.0), pt(5.0, 8.66)];
1228 let cols = [red(), green(), blue()];
1229 let opts = RibbonOptions {
1230 half_width: 1.0,
1231 join: Join::Miter,
1232 ..RibbonOptions::default()
1233 };
1234 let mesh = polygon_gradient(&pts, &cols, &opts);
1235 let mut counts = [0_usize; 3];
1236 for c in &mesh.colors {
1237 if *c == red() {
1238 counts[0] += 1;
1239 } else if *c == green() {
1240 counts[1] += 1;
1241 } else if *c == blue() {
1242 counts[2] += 1;
1243 }
1244 }
1245 assert!(counts[0] >= 2, "expected red shoulders, got {counts:?}");
1248 assert!(counts[1] >= 2, "expected green shoulders, got {counts:?}");
1249 assert!(counts[2] >= 2, "expected blue shoulders, got {counts:?}");
1250 }
1251
1252 #[test]
1253 fn polygon_ribbon_full_variable_width_widens_with_width() {
1254 let pts = [pt(0.0, 0.0), pt(10.0, 0.0), pt(10.0, 10.0), pt(0.0, 10.0)];
1260 let opts = RibbonOptions {
1261 half_width: 1.0,
1262 join: Join::Miter,
1263 ..RibbonOptions::default()
1264 };
1265 let m_thin = polygon_ribbon_full(&pts, None, Some(&[1.0_f64; 4]), &opts);
1266 let m_thick = polygon_ribbon_full(&pts, None, Some(&[5.0_f64; 4]), &opts);
1267 let bb_thin = m_thin.bounding_box();
1268 let bb_thick = m_thick.bounding_box();
1269 assert!(
1271 bb_thick.x0 < bb_thin.x0 - 3.0,
1272 "expected thicker x0 ({}) at least 3 px outside thin x0 ({})",
1273 bb_thick.x0,
1274 bb_thin.x0,
1275 );
1276 assert!(
1277 bb_thick.x1 > bb_thin.x1 + 3.0,
1278 "expected thicker x1 ({}) at least 3 px outside thin x1 ({})",
1279 bb_thick.x1,
1280 bb_thin.x1,
1281 );
1282 assert!(bb_thick.y0 < bb_thin.y0 - 3.0);
1283 assert!(bb_thick.y1 > bb_thin.y1 + 3.0);
1284 }
1285
1286 #[test]
1287 fn polygon_ribbon_full_per_vertex_width_changes_shoulder_offsets() {
1288 let pts = [pt(0.0, 0.0), pt(10.0, 0.0), pt(5.0, 8.66)];
1292 let widths = [1.0_f64, 4.0, 1.0];
1293 let opts = RibbonOptions {
1294 half_width: 1.0,
1295 join: Join::Miter,
1296 ..RibbonOptions::default()
1297 };
1298 let mesh = polygon_ribbon_full(&pts, None, Some(&widths), &opts);
1299 let mut max_offset = [0.0_f64; 3];
1305 for v in &mesh.vertices {
1306 let d = [
1307 (*v - pts[0]).hypot(),
1308 (*v - pts[1]).hypot(),
1309 (*v - pts[2]).hypot(),
1310 ];
1311 let (idx, dist) = d
1312 .iter()
1313 .enumerate()
1314 .min_by(|a, b| a.1.partial_cmp(b.1).unwrap())
1315 .unwrap();
1316 if *dist > max_offset[idx] {
1317 max_offset[idx] = *dist;
1318 }
1319 }
1320 assert!(
1323 max_offset[1] > max_offset[0] + 2.0,
1324 "max shoulder offsets per vertex: {max_offset:?}",
1325 );
1326 assert!(max_offset[1] > max_offset[2] + 2.0);
1327 }
1328
1329 #[test]
1330 #[should_panic(expected = "colors.len()")]
1331 fn polygon_gradient_panics_on_length_mismatch() {
1332 let pts = [pt(0.0, 0.0), pt(10.0, 0.0), pt(5.0, 8.66)];
1333 let cols = [red(), green()];
1334 let _ = polygon_gradient(&pts, &cols, &RibbonOptions::default());
1335 }
1336
1337 #[test]
1340 fn ribbon_band_mesh_two_point_strip() {
1341 let a = [pt(0.0, 0.0), pt(10.0, 0.0)];
1343 let b = [pt(0.0, 5.0), pt(10.0, 5.0)];
1344 let mesh = ribbon_band_mesh(&a, &b, &[red(); 2], &[blue(); 2]);
1345 assert_eq!(mesh.vertex_count(), 4);
1346 assert_eq!(mesh.triangle_count(), 2);
1347 let bb = mesh.bounding_box();
1348 assert!(approx(bb.x0, 0.0));
1349 assert!(approx(bb.x1, 10.0));
1350 assert!(approx(bb.y0, 0.0));
1351 assert!(approx(bb.y1, 5.0));
1352 }
1353
1354 #[test]
1355 fn ribbon_band_mesh_quad_pair_index_pattern() {
1356 let a = [pt(0.0, 0.0), pt(10.0, 0.0), pt(20.0, 0.0)];
1360 let b = [pt(0.0, 5.0), pt(10.0, 5.0), pt(20.0, 5.0)];
1361 let mesh = ribbon_band_mesh(&a, &b, &[red(); 3], &[blue(); 3]);
1362 assert_eq!(mesh.indices.len(), 12);
1363 assert_eq!(&mesh.indices[0..6], &[0, 1, 2, 0, 2, 3]);
1365 assert_eq!(&mesh.indices[6..12], &[4, 5, 6, 4, 6, 7]);
1367 }
1368
1369 #[test]
1370 fn ribbon_band_mesh_per_side_colors_preserved() {
1371 let a = [pt(0.0, 0.0), pt(10.0, 0.0)];
1372 let b = [pt(0.0, 5.0), pt(10.0, 5.0)];
1373 let mesh = ribbon_band_mesh(&a, &b, &[red(), red()], &[blue(), blue()]);
1374 for (p, c) in mesh.vertices.iter().zip(mesh.colors.iter()) {
1375 if approx(p.y, 0.0) {
1376 assert_eq!(*c, red());
1377 } else if approx(p.y, 5.0) {
1378 assert_eq!(*c, blue());
1379 }
1380 }
1381 }
1382
1383 #[test]
1384 fn ribbon_band_mesh_under_two_points_returns_empty() {
1385 let a = [pt(0.0, 0.0)];
1386 let b = [pt(0.0, 5.0)];
1387 let mesh = ribbon_band_mesh(&a, &b, &[red()], &[blue()]);
1388 assert!(mesh.is_empty());
1389 }
1390
1391 #[test]
1392 #[should_panic(expected = "curve_a.len()")]
1393 fn ribbon_band_mesh_panics_on_curve_length_mismatch() {
1394 let a = [pt(0.0, 0.0), pt(10.0, 0.0)];
1395 let b = [pt(0.0, 5.0)];
1396 let _ = ribbon_band_mesh(&a, &b, &[red(); 2], &[blue(); 1]);
1397 }
1398
1399 #[test]
1400 #[should_panic(expected = "colors_a.len()")]
1401 fn ribbon_band_mesh_panics_on_colors_a_mismatch() {
1402 let a = [pt(0.0, 0.0), pt(10.0, 0.0)];
1403 let b = [pt(0.0, 5.0), pt(10.0, 5.0)];
1404 let _ = ribbon_band_mesh(&a, &b, &[red()], &[blue(); 2]);
1405 }
1406}