use std::ops::RangeInclusive;
use super::tolerance::{
ARC_FAN_MAX_STEP, ARC_FAN_MIN_STEP, ARC_FAN_TOLERANCE, DEGENERATE_EPS as EPSILON,
};
use crate::color::Color;
use crate::geometry::{Point, Vec2};
use crate::mesh::Mesh;
use crate::stroke::{Cap, Join};
const DEFAULT_COLOR: Color = Color::new([0.0, 0.0, 0.0, 1.0]);
const CAP_FAN_SEGMENTS: RangeInclusive<usize> = 4..=64;
const JOIN_FAN_SEGMENTS: RangeInclusive<usize> = 2..=32;
const SEAM_BLEED_PX: f64 = 0.75;
#[derive(Clone, Copy, Debug)]
pub struct RibbonOptions {
pub half_width: f64,
pub cap: Cap,
pub join: Join,
pub miter_limit: f64,
}
impl Default for RibbonOptions {
fn default() -> Self {
Self {
half_width: 1.0,
cap: Cap::Butt,
join: Join::Miter,
miter_limit: 4.0,
}
}
}
pub fn polyline_ribbon(points: &[Point], color: Color, opts: &RibbonOptions) -> Mesh {
ribbon(
"polyline_ribbon",
points,
ColorSource::Constant(color),
None,
opts,
false,
)
}
pub fn polyline_gradient(points: &[Point], colors: &[Color], opts: &RibbonOptions) -> Mesh {
ribbon(
"polyline_gradient",
points,
ColorSource::PerVertex(colors),
None,
opts,
false,
)
}
pub fn polyline_ribbon_full(
points: &[Point],
colors: Option<&[Color]>,
half_widths: Option<&[f64]>,
opts: &RibbonOptions,
) -> Mesh {
ribbon(
"polyline_ribbon_full",
points,
ColorSource::from_optional(colors),
half_widths,
opts,
false,
)
}
pub fn polygon_ribbon(points: &[Point], color: Color, opts: &RibbonOptions) -> Mesh {
ribbon(
"polygon_ribbon",
points,
ColorSource::Constant(color),
None,
opts,
true,
)
}
pub fn polygon_gradient(points: &[Point], colors: &[Color], opts: &RibbonOptions) -> Mesh {
ribbon(
"polygon_gradient",
points,
ColorSource::PerVertex(colors),
None,
opts,
true,
)
}
pub fn polygon_ribbon_full(
points: &[Point],
colors: Option<&[Color]>,
half_widths: Option<&[f64]>,
opts: &RibbonOptions,
) -> Mesh {
ribbon(
"polygon_ribbon_full",
points,
ColorSource::from_optional(colors),
half_widths,
opts,
true,
)
}
fn ribbon(
who: &str,
points: &[Point],
colors: ColorSource<'_>,
half_widths: Option<&[f64]>,
opts: &RibbonOptions,
closed: bool,
) -> Mesh {
if let ColorSource::PerVertex(c) = colors {
assert_eq!(
points.len(),
c.len(),
"{who}: points.len() ({}) != colors.len() ({})",
points.len(),
c.len(),
);
}
if let Some(w) = half_widths {
assert_eq!(
points.len(),
w.len(),
"{who}: points.len() ({}) != half_widths.len() ({})",
points.len(),
w.len(),
);
}
ribbon_inner(points, colors, half_widths, opts, closed)
}
pub fn ribbon_band_mesh(
curve_a: &[Point],
curve_b: &[Point],
colors_a: &[Color],
colors_b: &[Color],
) -> Mesh {
assert_eq!(
curve_a.len(),
curve_b.len(),
"ribbon_band_mesh: curve_a.len() ({}) != curve_b.len() ({})",
curve_a.len(),
curve_b.len(),
);
assert_eq!(
curve_a.len(),
colors_a.len(),
"ribbon_band_mesh: colors_a.len() must match curve_a.len()"
);
assert_eq!(
curve_b.len(),
colors_b.len(),
"ribbon_band_mesh: colors_b.len() must match curve_b.len()"
);
let n = curve_a.len();
if n < 2 {
return Mesh::new(Vec::new(), Vec::new(), Vec::new());
}
let segs = n - 1;
let mut vertices: Vec<Point> = Vec::with_capacity(4 * segs);
let mut colors: Vec<Color> = Vec::with_capacity(4 * segs);
let mut indices: Vec<u32> = Vec::with_capacity(6 * segs);
for i in 0..segs {
let m0 = Vec2::new(
(curve_a[i].x + curve_b[i].x) * 0.5,
(curve_a[i].y + curve_b[i].y) * 0.5,
);
let m1 = Vec2::new(
(curve_a[i + 1].x + curve_b[i + 1].x) * 0.5,
(curve_a[i + 1].y + curve_b[i + 1].y) * 0.5,
);
let delta = m1 - m0;
let len = delta.hypot();
let tangent = if len > EPSILON {
delta / len
} else {
Vec2::new(0.0, 0.0)
};
let interior_bleed = SEAM_BLEED_PX / 3.0;
let near_bleed = if i > 0 { interior_bleed } else { 0.0 };
let far_bleed = if i + 1 < segs { interior_bleed } else { 0.0 };
let near_off = tangent * near_bleed;
let far_off = tangent * far_bleed;
let base = vertices.len() as u32;
vertices.push(curve_a[i] - near_off);
vertices.push(curve_b[i] - near_off);
vertices.push(curve_b[i + 1] + far_off);
vertices.push(curve_a[i + 1] + far_off);
colors.push(colors_a[i]);
colors.push(colors_b[i]);
colors.push(colors_b[i + 1]);
colors.push(colors_a[i + 1]);
indices.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
}
Mesh::new(vertices, colors, indices)
}
#[derive(Clone, Copy)]
enum ColorSource<'a> {
Constant(Color),
PerVertex(&'a [Color]),
}
impl<'a> ColorSource<'a> {
fn from_optional(colors: Option<&'a [Color]>) -> Self {
match colors {
Some(c) => ColorSource::PerVertex(c),
None => ColorSource::Constant(DEFAULT_COLOR),
}
}
fn at(&self, i: usize) -> Color {
match self {
ColorSource::Constant(c) => *c,
ColorSource::PerVertex(slice) => slice[i],
}
}
}
struct VertexLayout {
in_left: Point,
in_right: Point,
out_left: Point,
out_right: Point,
is_bevel: bool,
bevel_outside_left: bool,
}
fn ribbon_inner(
points: &[Point],
colors: ColorSource<'_>,
half_widths: Option<&[f64]>,
opts: &RibbonOptions,
closed: bool,
) -> Mesh {
let n = points.len();
let min_pts = if closed { 3 } else { 2 };
if n < min_pts {
return Mesh::new(Vec::new(), Vec::new(), Vec::new());
}
let hw = |i: usize| -> f64 {
match half_widths {
Some(w) => w[i],
None => opts.half_width,
}
};
let n_segs = if closed { n } else { n - 1 };
let mut seg_tangent: Vec<Vec2> = Vec::with_capacity(n_segs);
for i in 0..n_segs {
let delta = points[(i + 1) % n] - points[i];
let len = delta.hypot();
if len <= EPSILON {
let last = seg_tangent.last().copied().unwrap_or(Vec2::new(1.0, 0.0));
seg_tangent.push(last);
} else {
seg_tangent.push(delta / len);
}
}
let mut layouts: Vec<VertexLayout> = Vec::with_capacity(n);
for i in 0..n {
let t_in = if i == 0 {
if closed {
seg_tangent[n - 1]
} else {
seg_tangent[0]
}
} else {
seg_tangent[i - 1]
};
let t_out = if i + 1 == n {
if closed {
seg_tangent[n - 1]
} else {
seg_tangent[n - 2]
}
} else {
seg_tangent[i]
};
let pi = points[i];
let w = hw(i);
if !closed && (i == 0 || i + 1 == n) {
let t = if i == 0 { t_out } else { t_in };
let n_left = perp_left(t);
let l = pi + n_left * w;
let r = pi - n_left * w;
layouts.push(VertexLayout {
in_left: l,
in_right: r,
out_left: l,
out_right: r,
is_bevel: false,
bevel_outside_left: false,
});
continue;
}
let perp_in = perp_left(t_in);
let perp_out = perp_left(t_out);
let cross = t_in.x * t_out.y - t_in.y * t_out.x;
let dot = t_in.x * t_out.x + t_in.y * t_out.y;
let bevel_outside_left = cross < 0.0;
let denom = 1.0 + dot;
let miter_mag = if denom > EPSILON {
(2.0 / denom).sqrt()
} else {
f64::INFINITY
};
let want_miter = match opts.join {
Join::Miter => miter_mag <= opts.miter_limit && denom > EPSILON,
_ => false,
};
if want_miter {
let mitre = (perp_in + perp_out) * (w / denom);
let l = pi + mitre;
let r = pi - mitre;
layouts.push(VertexLayout {
in_left: l,
in_right: r,
out_left: l,
out_right: r,
is_bevel: false,
bevel_outside_left,
});
} else {
let in_l = pi + perp_in * w;
let in_r = pi - perp_in * w;
let out_l = pi + perp_out * w;
let out_r = pi - perp_out * w;
layouts.push(VertexLayout {
in_left: in_l,
in_right: in_r,
out_left: out_l,
out_right: out_r,
is_bevel: true,
bevel_outside_left,
});
}
}
let mut vertices: Vec<Point> = Vec::new();
let mut vcolors: Vec<Color> = Vec::new();
let mut indices: Vec<u32> = Vec::new();
let push_vertex =
|vertices: &mut Vec<Point>, vcolors: &mut Vec<Color>, p: Point, c: Color| -> u32 {
let idx = vertices.len() as u32;
vertices.push(p);
vcolors.push(c);
idx
};
if !closed {
emit_cap(
&mut vertices,
&mut vcolors,
&mut indices,
points[0],
layouts[0].out_left,
layouts[0].out_right,
-seg_tangent[0],
colors.at(0),
opts.cap,
hw(0),
);
}
let cap_bleed_amount = match opts.cap {
Cap::Butt => 0.0,
Cap::Square | Cap::Round => SEAM_BLEED_PX,
};
for i in 0..n_segs {
let i_next = (i + 1) % n;
let ci = colors.at(i);
let cj = colors.at(i_next);
let t = seg_tangent[i];
let near_bleed_amount = if closed || i > 0 {
SEAM_BLEED_PX
} else {
cap_bleed_amount
};
let far_bleed_amount = if closed || i + 1 < n - 1 {
SEAM_BLEED_PX
} else {
cap_bleed_amount
};
let near_bleed = t * near_bleed_amount;
let far_bleed = t * far_bleed_amount;
let a_pos = layouts[i].out_left - near_bleed;
let b_pos = layouts[i].out_right - near_bleed;
let c_pos = layouts[i_next].in_right + far_bleed;
let d_pos = layouts[i_next].in_left + far_bleed;
let a = push_vertex(&mut vertices, &mut vcolors, a_pos, ci);
let b = push_vertex(&mut vertices, &mut vcolors, b_pos, ci);
let c = push_vertex(&mut vertices, &mut vcolors, c_pos, cj);
let d = push_vertex(&mut vertices, &mut vcolors, d_pos, cj);
indices.extend_from_slice(&[a, b, c, a, c, d]);
let is_interior_join = closed || i_next < n - 1;
if is_interior_join && layouts[i_next].is_bevel {
emit_join_fill(
&mut vertices,
&mut vcolors,
&mut indices,
points[i_next],
&layouts[i_next],
colors.at(i_next),
opts.join,
);
}
}
if !closed {
let last = n - 1;
emit_cap(
&mut vertices,
&mut vcolors,
&mut indices,
points[last],
layouts[last].in_right,
layouts[last].in_left,
seg_tangent[n - 2],
colors.at(last),
opts.cap,
hw(last),
);
}
Mesh::new(vertices, vcolors, indices)
}
fn emit_join_fill(
vertices: &mut Vec<Point>,
vcolors: &mut Vec<Color>,
indices: &mut Vec<u32>,
pi: Point,
layout: &VertexLayout,
color: Color,
join: Join,
) {
let (outside_in, outside_out) = if layout.bevel_outside_left {
(layout.in_left, layout.out_left)
} else {
(layout.in_right, layout.out_right)
};
match join {
Join::Bevel | Join::Miter => {
let i_p = vertices.len() as u32;
vertices.push(pi);
vcolors.push(color);
let i_oi = vertices.len() as u32;
vertices.push(outside_in);
vcolors.push(color);
let i_oo = vertices.len() as u32;
vertices.push(outside_out);
vcolors.push(color);
indices.extend_from_slice(&[i_p, i_oi, i_oo]);
}
Join::Round => {
let va = outside_in - pi;
emit_arc_fan(
vertices,
vcolors,
indices,
pi,
outside_in,
va.hypot(),
va.y.atan2(va.x),
normalized_delta(va, outside_out - pi),
JOIN_FAN_SEGMENTS,
color,
);
}
}
}
#[allow(clippy::too_many_arguments, clippy::ptr_arg)]
fn emit_cap(
vertices: &mut Vec<Point>,
vcolors: &mut Vec<Color>,
indices: &mut Vec<u32>,
endpoint: Point,
a: Point,
b: Point,
outward: Vec2,
color: Color,
cap: Cap,
half_width: f64,
) {
match cap {
Cap::Butt => {} Cap::Square => {
let a_ext = a + outward * half_width;
let b_ext = b + outward * half_width;
let i_a = vertices.len() as u32;
vertices.push(a);
vcolors.push(color);
let i_b = vertices.len() as u32;
vertices.push(b);
vcolors.push(color);
let i_be = vertices.len() as u32;
vertices.push(b_ext);
vcolors.push(color);
let i_ae = vertices.len() as u32;
vertices.push(a_ext);
vcolors.push(color);
indices.extend_from_slice(&[i_a, i_b, i_be, i_a, i_be, i_ae]);
}
Cap::Round => {
let va = a - endpoint;
let mut delta = normalized_delta(va, b - endpoint);
if delta.abs() < std::f64::consts::PI - 1e-6 {
delta = if delta >= 0.0 {
delta - std::f64::consts::TAU
} else {
delta + std::f64::consts::TAU
};
}
emit_arc_fan(
vertices,
vcolors,
indices,
endpoint,
a,
half_width.max(EPSILON),
va.y.atan2(va.x),
delta,
CAP_FAN_SEGMENTS,
color,
);
}
}
}
fn normalized_delta(from: Vec2, to: Vec2) -> f64 {
let mut delta = to.y.atan2(to.x) - from.y.atan2(from.x);
while delta > std::f64::consts::PI {
delta -= std::f64::consts::TAU;
}
while delta <= -std::f64::consts::PI {
delta += std::f64::consts::TAU;
}
delta
}
#[allow(clippy::too_many_arguments)]
fn emit_arc_fan(
vertices: &mut Vec<Point>,
vcolors: &mut Vec<Color>,
indices: &mut Vec<u32>,
center: Point,
start: Point,
r: f64,
theta_a: f64,
delta: f64,
seg_clamp: RangeInclusive<usize>,
color: Color,
) {
let chord_step = (1.0 - (ARC_FAN_TOLERANCE / r.max(EPSILON)).clamp(0.0, 1.0)).acos() * 2.0;
let theta_step = chord_step.clamp(ARC_FAN_MIN_STEP, ARC_FAN_MAX_STEP);
let segments = (delta.abs() / theta_step).ceil() as usize;
let n_steps = segments.clamp(*seg_clamp.start(), *seg_clamp.end());
let step = delta / n_steps as f64;
let i_center = vertices.len() as u32;
vertices.push(center);
vcolors.push(color);
let i_start = vertices.len() as u32;
vertices.push(start);
vcolors.push(color);
let mut prev = i_start;
for k in 1..=n_steps {
let theta = theta_a + step * k as f64;
let p = Point::new(center.x + r * theta.cos(), center.y + r * theta.sin());
let idx = vertices.len() as u32;
vertices.push(p);
vcolors.push(color);
indices.extend_from_slice(&[i_center, prev, idx]);
prev = idx;
}
}
#[inline]
fn perp_left(v: Vec2) -> Vec2 {
Vec2::new(-v.y, v.x)
}
#[cfg(test)]
mod tests {
use super::*;
fn pt(x: f64, y: f64) -> Point {
Point::new(x, y)
}
fn red() -> Color {
Color::new([1.0, 0.0, 0.0, 1.0])
}
fn green() -> Color {
Color::new([0.0, 1.0, 0.0, 1.0])
}
fn blue() -> Color {
Color::new([0.0, 0.0, 1.0, 1.0])
}
fn approx(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9
}
#[test]
fn polyline_ribbon_two_point_butt() {
let pts = [pt(0.0, 0.0), pt(10.0, 0.0)];
let opts = RibbonOptions {
half_width: 1.0,
cap: Cap::Butt,
join: Join::Miter,
miter_limit: 4.0,
};
let mesh = polyline_ribbon(&pts, red(), &opts);
assert_eq!(mesh.vertex_count(), 4);
assert_eq!(mesh.triangle_count(), 2);
let mut ys: Vec<f64> = mesh.vertices.iter().map(|p| p.y).collect();
ys.sort_by(|a, b| a.partial_cmp(b).unwrap());
assert!(approx(ys[0], -1.0));
assert!(approx(ys[1], -1.0));
assert!(approx(ys[2], 1.0));
assert!(approx(ys[3], 1.0));
}
#[test]
fn polyline_ribbon_constant_color_all_vertices_match() {
let pts = [pt(0.0, 0.0), pt(10.0, 0.0)];
let mesh = polyline_ribbon(&pts, red(), &RibbonOptions::default());
for c in &mesh.colors {
assert_eq!(*c, red());
}
}
#[test]
fn polyline_gradient_endpoint_colors_preserved() {
let pts = [pt(0.0, 0.0), pt(10.0, 0.0)];
let cols = [red(), blue()];
let mesh = polyline_gradient(&pts, &cols, &RibbonOptions::default());
assert_eq!(mesh.vertex_count(), 4);
for (p, c) in mesh.vertices.iter().zip(mesh.colors.iter()) {
if approx(p.x, 0.0) {
assert_eq!(*c, red());
} else if approx(p.x, 10.0) {
assert_eq!(*c, blue());
}
}
}
#[test]
fn polyline_gradient_interior_color_shared_across_segments() {
let pts = [pt(0.0, 0.0), pt(10.0, 0.0), pt(20.0, 0.0)];
let cols = [red(), green(), blue()];
let mesh = polyline_gradient(&pts, &cols, &RibbonOptions::default());
let interior_greens = mesh
.vertices
.iter()
.zip(mesh.colors.iter())
.filter(|(p, _)| (p.x - 10.0).abs() < 2.0)
.map(|(_, c)| *c)
.collect::<Vec<_>>();
assert!(!interior_greens.is_empty());
for c in &interior_greens {
assert_eq!(*c, green(), "interior shoulder should be green");
}
}
#[test]
fn polyline_ribbon_full_variable_width_shoulder_offsets() {
let pts = [pt(0.0, 0.0), pt(5.0, 0.0), pt(10.0, 0.0)];
let widths = [1.0_f64, 2.0, 1.0];
let mesh = polyline_ribbon_full(&pts, None, Some(&widths), &RibbonOptions::default());
let mut shoulders_at_x: Vec<(f64, Vec<f64>)> =
vec![(0.0, Vec::new()), (5.0, Vec::new()), (10.0, Vec::new())];
for p in &mesh.vertices {
for (x, ys) in shoulders_at_x.iter_mut() {
if (p.x - *x).abs() < 1.0 {
ys.push(p.y);
}
}
}
for (x, ys) in shoulders_at_x {
let expected: Vec<f64> = if approx(x, 5.0) {
vec![-2.0, 2.0]
} else {
vec![-1.0, 1.0]
};
let mut sorted = ys.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
sorted.dedup_by(|a, b| approx(*a, *b));
assert_eq!(
sorted.len(),
expected.len(),
"at x={x}, unique shoulder ys = {sorted:?}"
);
for (s, e) in sorted.iter().zip(expected.iter()) {
assert!(approx(*s, *e), "at x={x}, got {s}, expected {e}");
}
}
}
#[test]
fn polyline_ribbon_90_corner_mitre() {
let pts = [pt(0.0, 0.0), pt(10.0, 0.0), pt(10.0, 10.0)];
let opts = RibbonOptions {
half_width: 1.0,
join: Join::Miter,
..RibbonOptions::default()
};
let mesh = polyline_ribbon(&pts, red(), &opts);
assert_eq!(mesh.triangle_count(), 4);
let near_mitre = mesh.vertices.iter().find(|p| {
(approx(p.x, 11.75) && approx(p.y, -1.0)) || (approx(p.x, 11.0) && approx(p.y, -0.25))
});
assert!(
near_mitre.is_some(),
"expected bled outer-mitre near (11, -1); got vertices = {:?}",
mesh.vertices
);
}
#[test]
fn polyline_ribbon_sharp_corner_clamps_to_bevel() {
let pts = [pt(0.0, 0.0), pt(10.0, 0.0), pt(0.0, 0.1)];
let opts = RibbonOptions {
half_width: 1.0,
join: Join::Miter,
miter_limit: 2.0,
..RibbonOptions::default()
};
let mesh = polyline_ribbon(&pts, red(), &opts);
assert_eq!(mesh.triangle_count(), 5);
}
#[test]
fn polyline_ribbon_bevel_join_emits_extra_triangle() {
let pts = [pt(0.0, 0.0), pt(10.0, 0.0), pt(10.0, 10.0)];
let opts = RibbonOptions {
half_width: 1.0,
join: Join::Bevel,
..RibbonOptions::default()
};
let mesh = polyline_ribbon(&pts, red(), &opts);
assert_eq!(mesh.triangle_count(), 5);
}
#[test]
fn polyline_ribbon_round_join_emits_fan() {
let pts = [pt(0.0, 0.0), pt(10.0, 0.0), pt(10.0, 10.0)];
let opts = RibbonOptions {
half_width: 5.0, join: Join::Round,
..RibbonOptions::default()
};
let mesh = polyline_ribbon(&pts, red(), &opts);
assert!(
mesh.triangle_count() >= 6,
"got {} triangles",
mesh.triangle_count()
);
}
#[test]
fn polyline_ribbon_square_cap_extends_endpoint() {
let pts = [pt(0.0, 0.0), pt(10.0, 0.0)];
let opts = RibbonOptions {
half_width: 1.0,
cap: Cap::Square,
..RibbonOptions::default()
};
let mesh = polyline_ribbon(&pts, red(), &opts);
assert_eq!(mesh.triangle_count(), 6);
let bb = mesh.bounding_box();
assert!(approx(bb.x0, -1.0));
assert!(approx(bb.x1, 11.0));
}
#[test]
fn polyline_ribbon_round_cap_emits_fan() {
let pts = [pt(0.0, 0.0), pt(10.0, 0.0)];
let opts = RibbonOptions {
half_width: 5.0,
cap: Cap::Round,
..RibbonOptions::default()
};
let mesh = polyline_ribbon(&pts, red(), &opts);
assert!(mesh.triangle_count() >= 2 + 2 * 4);
}
#[test]
fn polyline_ribbon_butt_cap_emits_no_cap_triangles() {
let pts = [pt(0.0, 0.0), pt(10.0, 0.0)];
let opts = RibbonOptions {
half_width: 1.0,
cap: Cap::Butt,
..RibbonOptions::default()
};
let mesh = polyline_ribbon(&pts, red(), &opts);
assert_eq!(mesh.triangle_count(), 2);
}
#[test]
fn polyline_ribbon_bounding_box_straight_butt() {
let pts = [pt(0.0, 0.0), pt(10.0, 0.0)];
let opts = RibbonOptions {
half_width: 1.0,
cap: Cap::Butt,
..RibbonOptions::default()
};
let mesh = polyline_ribbon(&pts, red(), &opts);
let bb = mesh.bounding_box();
assert!(approx(bb.x0, 0.0));
assert!(approx(bb.x1, 10.0));
assert!(approx(bb.y0, -1.0));
assert!(approx(bb.y1, 1.0));
}
#[test]
fn polyline_ribbon_under_two_points_returns_empty() {
let pts = [pt(0.0, 0.0)];
let mesh = polyline_ribbon(&pts, red(), &RibbonOptions::default());
assert!(mesh.is_empty());
}
#[test]
#[should_panic(expected = "colors.len()")]
fn polyline_gradient_panics_on_length_mismatch() {
let pts = [pt(0.0, 0.0), pt(10.0, 0.0)];
let cols = [red(), green(), blue()];
let _ = polyline_gradient(&pts, &cols, &RibbonOptions::default());
}
#[test]
fn polygon_ribbon_equilateral_triangle_segment_count() {
let pts = [pt(0.0, 0.0), pt(10.0, 0.0), pt(5.0, 8.66)];
let opts = RibbonOptions {
half_width: 1.0,
join: Join::Miter,
..RibbonOptions::default()
};
let mesh = polygon_ribbon(&pts, red(), &opts);
assert_eq!(mesh.triangle_count(), 6);
}
#[test]
fn polygon_ribbon_square_bevel_emits_four_extra_triangles() {
let pts = [pt(0.0, 0.0), pt(10.0, 0.0), pt(10.0, 10.0), pt(0.0, 10.0)];
let opts = RibbonOptions {
half_width: 1.0,
join: Join::Bevel,
..RibbonOptions::default()
};
let mesh = polygon_ribbon(&pts, red(), &opts);
assert_eq!(mesh.triangle_count(), 12);
}
#[test]
fn polygon_ribbon_too_few_points_returns_empty() {
for pts in [&[][..], &[pt(0.0, 0.0)], &[pt(0.0, 0.0), pt(10.0, 0.0)]] {
let mesh = polygon_ribbon(pts, red(), &RibbonOptions::default());
assert!(
mesh.is_empty(),
"expected empty mesh for {} points",
pts.len()
);
}
}
#[test]
fn polygon_ribbon_cap_setting_is_ignored() {
let pts = [pt(0.0, 0.0), pt(10.0, 0.0), pt(5.0, 8.66)];
let make = |cap| {
let opts = RibbonOptions {
half_width: 1.0,
cap,
join: Join::Miter,
..RibbonOptions::default()
};
polygon_ribbon(&pts, red(), &opts).triangle_count()
};
let butt = make(Cap::Butt);
assert_eq!(butt, make(Cap::Square));
assert_eq!(butt, make(Cap::Round));
}
#[test]
fn polygon_gradient_wrap_segment_closes_color_loop() {
let pts = [pt(0.0, 0.0), pt(10.0, 0.0), pt(5.0, 8.66)];
let cols = [red(), green(), blue()];
let opts = RibbonOptions {
half_width: 1.0,
join: Join::Miter,
..RibbonOptions::default()
};
let mesh = polygon_gradient(&pts, &cols, &opts);
let mut counts = [0_usize; 3];
for c in &mesh.colors {
if *c == red() {
counts[0] += 1;
} else if *c == green() {
counts[1] += 1;
} else if *c == blue() {
counts[2] += 1;
}
}
assert!(counts[0] >= 2, "expected red shoulders, got {counts:?}");
assert!(counts[1] >= 2, "expected green shoulders, got {counts:?}");
assert!(counts[2] >= 2, "expected blue shoulders, got {counts:?}");
}
#[test]
fn polygon_ribbon_full_variable_width_widens_with_width() {
let pts = [pt(0.0, 0.0), pt(10.0, 0.0), pt(10.0, 10.0), pt(0.0, 10.0)];
let opts = RibbonOptions {
half_width: 1.0,
join: Join::Miter,
..RibbonOptions::default()
};
let m_thin = polygon_ribbon_full(&pts, None, Some(&[1.0_f64; 4]), &opts);
let m_thick = polygon_ribbon_full(&pts, None, Some(&[5.0_f64; 4]), &opts);
let bb_thin = m_thin.bounding_box();
let bb_thick = m_thick.bounding_box();
assert!(
bb_thick.x0 < bb_thin.x0 - 3.0,
"expected thicker x0 ({}) at least 3 px outside thin x0 ({})",
bb_thick.x0,
bb_thin.x0,
);
assert!(
bb_thick.x1 > bb_thin.x1 + 3.0,
"expected thicker x1 ({}) at least 3 px outside thin x1 ({})",
bb_thick.x1,
bb_thin.x1,
);
assert!(bb_thick.y0 < bb_thin.y0 - 3.0);
assert!(bb_thick.y1 > bb_thin.y1 + 3.0);
}
#[test]
fn polygon_ribbon_full_per_vertex_width_changes_shoulder_offsets() {
let pts = [pt(0.0, 0.0), pt(10.0, 0.0), pt(5.0, 8.66)];
let widths = [1.0_f64, 4.0, 1.0];
let opts = RibbonOptions {
half_width: 1.0,
join: Join::Miter,
..RibbonOptions::default()
};
let mesh = polygon_ribbon_full(&pts, None, Some(&widths), &opts);
let mut max_offset = [0.0_f64; 3];
for v in &mesh.vertices {
let d = [
(*v - pts[0]).hypot(),
(*v - pts[1]).hypot(),
(*v - pts[2]).hypot(),
];
let (idx, dist) = d
.iter()
.enumerate()
.min_by(|a, b| a.1.partial_cmp(b.1).unwrap())
.unwrap();
if *dist > max_offset[idx] {
max_offset[idx] = *dist;
}
}
assert!(
max_offset[1] > max_offset[0] + 2.0,
"max shoulder offsets per vertex: {max_offset:?}",
);
assert!(max_offset[1] > max_offset[2] + 2.0);
}
#[test]
#[should_panic(expected = "colors.len()")]
fn polygon_gradient_panics_on_length_mismatch() {
let pts = [pt(0.0, 0.0), pt(10.0, 0.0), pt(5.0, 8.66)];
let cols = [red(), green()];
let _ = polygon_gradient(&pts, &cols, &RibbonOptions::default());
}
#[test]
fn ribbon_band_mesh_two_point_strip() {
let a = [pt(0.0, 0.0), pt(10.0, 0.0)];
let b = [pt(0.0, 5.0), pt(10.0, 5.0)];
let mesh = ribbon_band_mesh(&a, &b, &[red(); 2], &[blue(); 2]);
assert_eq!(mesh.vertex_count(), 4);
assert_eq!(mesh.triangle_count(), 2);
let bb = mesh.bounding_box();
assert!(approx(bb.x0, 0.0));
assert!(approx(bb.x1, 10.0));
assert!(approx(bb.y0, 0.0));
assert!(approx(bb.y1, 5.0));
}
#[test]
fn ribbon_band_mesh_quad_pair_index_pattern() {
let a = [pt(0.0, 0.0), pt(10.0, 0.0), pt(20.0, 0.0)];
let b = [pt(0.0, 5.0), pt(10.0, 5.0), pt(20.0, 5.0)];
let mesh = ribbon_band_mesh(&a, &b, &[red(); 3], &[blue(); 3]);
assert_eq!(mesh.indices.len(), 12);
assert_eq!(&mesh.indices[0..6], &[0, 1, 2, 0, 2, 3]);
assert_eq!(&mesh.indices[6..12], &[4, 5, 6, 4, 6, 7]);
}
#[test]
fn ribbon_band_mesh_per_side_colors_preserved() {
let a = [pt(0.0, 0.0), pt(10.0, 0.0)];
let b = [pt(0.0, 5.0), pt(10.0, 5.0)];
let mesh = ribbon_band_mesh(&a, &b, &[red(), red()], &[blue(), blue()]);
for (p, c) in mesh.vertices.iter().zip(mesh.colors.iter()) {
if approx(p.y, 0.0) {
assert_eq!(*c, red());
} else if approx(p.y, 5.0) {
assert_eq!(*c, blue());
}
}
}
#[test]
fn ribbon_band_mesh_under_two_points_returns_empty() {
let a = [pt(0.0, 0.0)];
let b = [pt(0.0, 5.0)];
let mesh = ribbon_band_mesh(&a, &b, &[red()], &[blue()]);
assert!(mesh.is_empty());
}
#[test]
#[should_panic(expected = "curve_a.len()")]
fn ribbon_band_mesh_panics_on_curve_length_mismatch() {
let a = [pt(0.0, 0.0), pt(10.0, 0.0)];
let b = [pt(0.0, 5.0)];
let _ = ribbon_band_mesh(&a, &b, &[red(); 2], &[blue(); 1]);
}
#[test]
#[should_panic(expected = "colors_a.len()")]
fn ribbon_band_mesh_panics_on_colors_a_mismatch() {
let a = [pt(0.0, 0.0), pt(10.0, 0.0)];
let b = [pt(0.0, 5.0), pt(10.0, 5.0)];
let _ = ribbon_band_mesh(&a, &b, &[red()], &[blue(); 2]);
}
}