use crate::geometry::Rect;
use crate::scales::geometry::{Coord, Polygon as GeoPolygon};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ChromeStrategy {
PatchSlots,
InsidePanel,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub enum Projection {
#[default]
Cartesian,
Polar(PolarProjection),
Custom(CustomProjection),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum PolarEdgeStyle {
#[default]
Geodesic,
Chord,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PolarProjection {
angle_channel: String,
radius_channel: String,
theta_start: f64,
theta_end: f64,
inner_radius_frac: f64,
edge_style: PolarEdgeStyle,
theta_break_fracs: Vec<f64>,
fit_to_bbox: bool,
outer_radius_frac: f64,
}
const MAX_INNER_RADIUS_FRAC: f64 = 1.0 - f64::EPSILON;
impl PolarProjection {
pub fn full_circle() -> Self {
PolarProjection {
angle_channel: "x".into(),
radius_channel: "y".into(),
theta_start: std::f64::consts::FRAC_PI_2,
theta_end: std::f64::consts::FRAC_PI_2 - std::f64::consts::TAU,
inner_radius_frac: 0.0,
edge_style: PolarEdgeStyle::Geodesic,
theta_break_fracs: Vec::new(),
fit_to_bbox: true,
outer_radius_frac: 1.0,
}
}
pub fn gauge() -> Self {
PolarProjection {
angle_channel: "x".into(),
radius_channel: "y".into(),
theta_start: std::f64::consts::PI,
theta_end: 0.0,
inner_radius_frac: 0.4,
edge_style: PolarEdgeStyle::Geodesic,
theta_break_fracs: Vec::new(),
fit_to_bbox: true,
outer_radius_frac: 1.0,
}
}
pub fn radar(n_categories: usize) -> Self {
let n = n_categories.max(2);
let theta_break_fracs: Vec<f64> = (0..n).map(|i| (i as f64 + 0.5) / n as f64).collect();
PolarProjection {
angle_channel: "x".into(),
radius_channel: "y".into(),
theta_start: std::f64::consts::FRAC_PI_2,
theta_end: std::f64::consts::FRAC_PI_2 - std::f64::consts::TAU,
inner_radius_frac: 0.0,
edge_style: PolarEdgeStyle::Chord,
theta_break_fracs,
fit_to_bbox: true,
outer_radius_frac: 1.0,
}
}
pub fn channels(mut self, angle: impl Into<String>, radius: impl Into<String>) -> Self {
self.angle_channel = angle.into();
self.radius_channel = radius.into();
self
}
pub fn theta_range(mut self, start: f64, end: f64) -> Self {
if start.is_finite() && end.is_finite() {
self.theta_start = start;
self.theta_end = end;
}
self
}
pub fn inner_radius(mut self, frac: f64) -> Self {
self.inner_radius_frac = if frac.is_finite() {
frac.clamp(0.0, MAX_INNER_RADIUS_FRAC)
} else {
0.0
};
self
}
pub fn outer_radius(mut self, frac: f64) -> Self {
self.outer_radius_frac = if frac.is_finite() {
frac.clamp(0.0, 1.0)
} else {
1.0
};
self
}
pub fn edges(mut self, style: PolarEdgeStyle) -> Self {
self.edge_style = style;
self
}
pub fn theta_breaks(mut self, fracs: impl IntoIterator<Item = f64>) -> Self {
let mut fracs: Vec<f64> = fracs.into_iter().filter(|f| f.is_finite()).collect();
fracs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
self.theta_break_fracs = fracs;
self
}
pub fn fit_to_bbox(mut self, fit: bool) -> Self {
self.fit_to_bbox = fit;
self
}
pub fn angle_channel(&self) -> &str {
&self.angle_channel
}
pub fn radius_channel(&self) -> &str {
&self.radius_channel
}
pub fn theta_start(&self) -> f64 {
self.theta_start
}
pub fn theta_end(&self) -> f64 {
self.theta_end
}
pub fn inner_radius_frac(&self) -> f64 {
self.inner_radius_frac
}
pub fn outer_radius_frac(&self) -> f64 {
self.outer_radius_frac
}
pub fn edge_style(&self) -> PolarEdgeStyle {
self.edge_style
}
pub fn theta_break_fracs(&self) -> &[f64] {
&self.theta_break_fracs
}
pub fn is_fit_to_bbox(&self) -> bool {
self.fit_to_bbox
}
pub fn bounding_box_units(&self) -> (f64, f64, f64, f64) {
let mut min_x = f64::INFINITY;
let mut max_x = f64::NEG_INFINITY;
let mut min_y = f64::INFINITY;
let mut max_y = f64::NEG_INFINITY;
let mut accumulate = |x: f64, y: f64| {
min_x = min_x.min(x);
max_x = max_x.max(x);
min_y = min_y.min(y);
max_y = max_y.max(y);
};
let use_polygon =
matches!(self.edge_style, PolarEdgeStyle::Chord) && !self.theta_break_fracs.is_empty();
if use_polygon {
for &frac in &self.theta_break_fracs {
let theta = self.theta_for_frac(frac);
accumulate(theta.cos(), theta.sin());
}
accumulate(self.theta_start.cos(), self.theta_start.sin());
accumulate(self.theta_end.cos(), self.theta_end.sin());
} else {
accumulate(self.theta_start.cos(), self.theta_start.sin());
accumulate(self.theta_end.cos(), self.theta_end.sin());
for k in -2..=2 {
let target = k as f64 * std::f64::consts::FRAC_PI_2;
if angle_in_sweep(target, self.theta_start, self.theta_end) {
accumulate(target.cos(), target.sin());
}
}
}
if self.inner_radius_frac > 0.0 {
let inner = self.inner_radius_frac;
if use_polygon {
for &frac in &self.theta_break_fracs {
let theta = self.theta_for_frac(frac);
accumulate(inner * theta.cos(), inner * theta.sin());
}
} else {
accumulate(
inner * self.theta_start.cos(),
inner * self.theta_start.sin(),
);
accumulate(inner * self.theta_end.cos(), inner * self.theta_end.sin());
}
} else {
accumulate(0.0, 0.0);
}
(min_x, min_y, max_x, max_y)
}
pub(crate) fn geometry(&self, panel: Rect) -> PolarGeometry {
let panel_w = (panel.x1 - panel.x0).max(0.0);
let panel_h = (panel.y1 - panel.y0).max(0.0);
if panel_w <= 0.0 || panel_h <= 0.0 {
return PolarGeometry {
cx: panel.x0,
cy: panel.y0,
r_outer: 0.0,
r_inner: 0.0,
};
}
let (cx, cy, max_radius) = if self.fit_to_bbox {
let (min_x, min_y, max_x, max_y) = self.bounding_box_units();
let bbox_w = (max_x - min_x).max(f64::EPSILON);
let bbox_h = (max_y - min_y).max(f64::EPSILON);
let scale = (panel_w / bbox_w).min(panel_h / bbox_h);
let scaled_bbox_w = bbox_w * scale;
let scaled_bbox_h = bbox_h * scale;
let bbox_x0_px = panel.x0 + (panel_w - scaled_bbox_w) * 0.5;
let bbox_y0_px = panel.y0 + (panel_h - scaled_bbox_h) * 0.5;
let centre_rel_x = -min_x / bbox_w;
let centre_rel_y = -min_y / bbox_h;
let cx = bbox_x0_px + centre_rel_x * scaled_bbox_w;
let cy = bbox_y0_px + (1.0 - centre_rel_y) * scaled_bbox_h;
(cx, cy, scale)
} else {
let cx = panel.x0 + panel_w * 0.5;
let cy = panel.y0 + panel_h * 0.5;
let max_radius = panel_w.min(panel_h) * 0.5;
(cx, cy, max_radius)
};
let r_outer = max_radius * self.outer_radius_frac;
let r_inner = r_outer * self.inner_radius_frac;
PolarGeometry {
cx,
cy,
r_outer,
r_inner,
}
}
pub(crate) fn polar_point(
centre: crate::geometry::Point,
radius: f64,
theta: f64,
) -> crate::geometry::Point {
crate::geometry::Point::new(
centre.x + radius * theta.cos(),
centre.y - radius * theta.sin(),
)
}
pub(crate) fn theta_for_frac(&self, frac: f64) -> f64 {
self.theta_start + frac * (self.theta_end - self.theta_start)
}
pub(crate) fn theta_r_from_xy(&self, x: f64, y: f64) -> (f64, f64) {
if self.angle_channel == "y" || self.radius_channel == "x" {
(y, x)
} else {
(x, y)
}
}
pub(crate) fn project_frac(&self, panel: Rect, theta_frac: f64, r_frac: f64) -> (f64, f64) {
let g = self.geometry(panel);
let (ux, uy) = self.unit_position(theta_frac);
let r = g.r_inner + r_frac * (g.r_outer - g.r_inner);
(g.cx + r * ux, g.cy - r * uy)
}
pub(crate) fn unit_position(&self, theta_frac: f64) -> (f64, f64) {
if matches!(self.edge_style, PolarEdgeStyle::Chord) && !self.theta_break_fracs.is_empty() {
self.chord_unit_position(theta_frac)
} else {
let theta = self.theta_for_frac(theta_frac);
(theta.cos(), theta.sin())
}
}
pub fn is_full_circle(&self) -> bool {
((self.theta_end - self.theta_start).abs() - std::f64::consts::TAU).abs() < 1e-6
}
fn chord_unit_position(&self, theta_frac: f64) -> (f64, f64) {
let is_full_circle = self.is_full_circle();
let theta_frac = if is_full_circle {
theta_frac.rem_euclid(1.0)
} else {
theta_frac
};
let mut verts: Vec<(f64, (f64, f64))> =
Vec::with_capacity(self.theta_break_fracs.len() + 2);
if !is_full_circle {
let th = self.theta_for_frac(0.0);
verts.push((0.0, (th.cos(), th.sin())));
}
for &b in &self.theta_break_fracs {
let th = self.theta_for_frac(b);
verts.push((b, (th.cos(), th.sin())));
}
if !is_full_circle {
let th = self.theta_for_frac(1.0);
verts.push((1.0, (th.cos(), th.sin())));
}
verts.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
if verts.is_empty() {
let th = self.theta_for_frac(theta_frac);
return (th.cos(), th.sin());
}
let t = theta_frac;
for w in verts.windows(2) {
let (f_lo, p_lo) = w[0];
let (f_hi, p_hi) = w[1];
if t >= f_lo && t <= f_hi && f_hi > f_lo {
let u = (t - f_lo) / (f_hi - f_lo);
return (
p_lo.0 * (1.0 - u) + p_hi.0 * u,
p_lo.1 * (1.0 - u) + p_hi.1 * u,
);
}
}
if is_full_circle {
let n = verts.len();
let (f_lo, p_lo) = verts[n - 1];
let (f_hi, p_hi) = verts[0];
let total = (1.0 - f_lo) + f_hi;
let u = if t >= f_lo {
(t - f_lo) / total
} else {
(1.0 - f_lo + t) / total
};
(
p_lo.0 * (1.0 - u) + p_hi.0 * u,
p_lo.1 * (1.0 - u) + p_hi.1 * u,
)
} else {
if t < verts[0].0 {
verts[0].1
} else {
verts.last().unwrap().1
}
}
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct PolarGeometry {
pub cx: f64,
pub cy: f64,
pub r_outer: f64,
pub r_inner: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CustomProjection {
pub outline: Vec<GeoPolygon>,
pub x_major: Vec<Vec<Coord>>,
pub x_minor: Vec<Vec<Coord>>,
pub y_major: Vec<Vec<Coord>>,
pub y_minor: Vec<Vec<Coord>>,
pub x_channel: String,
pub y_channel: String,
}
impl CustomProjection {
pub fn new(outline: impl IntoIterator<Item = GeoPolygon>) -> Self {
Self {
outline: outline.into_iter().collect(),
x_major: Vec::new(),
x_minor: Vec::new(),
y_major: Vec::new(),
y_minor: Vec::new(),
x_channel: "x".to_string(),
y_channel: "y".to_string(),
}
}
pub fn x_major(mut self, lines: impl IntoIterator<Item = Vec<Coord>>) -> Self {
self.x_major = lines.into_iter().collect();
self
}
pub fn x_minor(mut self, lines: impl IntoIterator<Item = Vec<Coord>>) -> Self {
self.x_minor = lines.into_iter().collect();
self
}
pub fn y_major(mut self, lines: impl IntoIterator<Item = Vec<Coord>>) -> Self {
self.y_major = lines.into_iter().collect();
self
}
pub fn y_minor(mut self, lines: impl IntoIterator<Item = Vec<Coord>>) -> Self {
self.y_minor = lines.into_iter().collect();
self
}
pub fn channels(mut self, x: impl Into<String>, y: impl Into<String>) -> Self {
self.x_channel = x.into();
self.y_channel = y.into();
self
}
pub fn resolved_outline_fracs(
&self,
x_scale: Option<&crate::plot::scale::Scale>,
y_scale: Option<&crate::plot::scale::Scale>,
) -> Vec<Vec<Coord>> {
let mut rings: Vec<Vec<Coord>> = Vec::new();
for polygon in &self.outline {
let exterior = resolve_ring(&polygon.exterior, x_scale, y_scale);
if exterior.len() < 3 {
continue;
}
rings.push(exterior);
for interior in &polygon.interiors {
let ring = resolve_ring(interior, x_scale, y_scale);
if ring.len() >= 3 {
rings.push(ring);
}
}
}
clip_outline_to_unit_rect(&rings)
}
#[allow(dead_code)]
pub(crate) fn resolve_graticule(
&self,
line: &[Coord],
x_scale: Option<&crate::plot::scale::Scale>,
y_scale: Option<&crate::plot::scale::Scale>,
) -> Vec<Coord> {
resolve_ring(line, x_scale, y_scale)
}
}
fn resolve_ring(
ring: &[Coord],
x_scale: Option<&crate::plot::scale::Scale>,
y_scale: Option<&crate::plot::scale::Scale>,
) -> Vec<Coord> {
use crate::plot::geom::resolve::resolve_position;
use crate::plot::value::Value;
let mut out = Vec::with_capacity(ring.len());
for (x, y) in ring {
let xf = resolve_position(Value::Number(*x), x_scale, 0.0);
let yf = resolve_position(Value::Number(*y), y_scale, 0.0);
if xf.is_finite() && yf.is_finite() {
out.push((xf, yf));
}
}
out
}
fn clip_outline_to_unit_rect(rings: &[Vec<Coord>]) -> Vec<Vec<Coord>> {
if rings.is_empty() {
return Vec::new();
}
use crate::geometry::Point;
use crate::primitives::intersect_polygons;
let ring_pts: Vec<Vec<Point>> = rings
.iter()
.map(|ring| ring.iter().map(|(x, y)| Point::new(*x, *y)).collect())
.collect();
let subject_rings: Vec<&[Point]> = ring_pts.iter().map(|ring| ring.as_slice()).collect();
let unit_rect = [
Point::new(0.0, 0.0),
Point::new(1.0, 0.0),
Point::new(1.0, 1.0),
Point::new(0.0, 1.0),
];
let clip_rings: [&[Point]; 1] = [&unit_rect];
let trimmed = intersect_polygons(&subject_rings, &clip_rings);
trimmed
.into_iter()
.map(|ring| ring.into_iter().map(|p| (p.x, p.y)).collect())
.collect()
}
impl Projection {
pub const fn cartesian() -> Self {
Projection::Cartesian
}
pub fn polar() -> Self {
Projection::Polar(PolarProjection::full_circle())
}
pub fn gauge() -> Self {
Projection::Polar(PolarProjection::gauge())
}
pub fn radar(n_categories: usize) -> Self {
Projection::Polar(PolarProjection::radar(n_categories))
}
pub fn custom(outline: impl IntoIterator<Item = GeoPolygon>) -> Self {
Projection::Custom(CustomProjection::new(outline))
}
pub fn consume_channels(&self) -> Vec<&str> {
match self {
Projection::Cartesian => vec!["x", "y"],
Projection::Polar(p) => vec![p.angle_channel.as_str(), p.radius_channel.as_str()],
Projection::Custom(c) => vec![c.x_channel.as_str(), c.y_channel.as_str()],
}
}
pub fn project_to_panel_px(&self, panel: Rect, channels: &[f64]) -> (f64, f64) {
let x_frac = channels.first().copied().unwrap_or(0.0);
let y_frac = channels.get(1).copied().unwrap_or(0.0);
match self {
Projection::Cartesian | Projection::Custom(_) => {
let panel_w = panel.x1 - panel.x0;
let panel_h = panel.y1 - panel.y0;
(panel.x0 + x_frac * panel_w, panel.y1 - y_frac * panel_h)
}
Projection::Polar(p) => {
let (theta_frac, r_frac) = p.theta_r_from_xy(x_frac, y_frac);
p.project_frac(panel, theta_frac, r_frac)
}
}
}
pub const fn chrome_strategy(&self) -> ChromeStrategy {
match self {
Projection::Cartesian => ChromeStrategy::PatchSlots,
Projection::Polar(_) | Projection::Custom(_) => ChromeStrategy::InsidePanel,
}
}
pub const fn is_linear(&self) -> bool {
matches!(self, Projection::Cartesian | Projection::Custom(_))
}
pub fn as_polar(&self) -> Option<&PolarProjection> {
match self {
Projection::Polar(p) => Some(p),
_ => None,
}
}
pub fn as_custom(&self) -> Option<&CustomProjection> {
match self {
Projection::Custom(c) => Some(c),
_ => None,
}
}
pub fn interpolate_segment(
&self,
panel: Rect,
start_channels: &[f64],
end_channels: &[f64],
out: &mut Vec<(f64, f64)>,
) {
let mut samples: Vec<InteriorSample> = Vec::new();
self.interpolate_segment_with_t(panel, start_channels, end_channels, &mut samples);
for s in samples {
out.push((s.px, s.py));
}
}
pub fn interpolate_closing_segment(
&self,
panel: Rect,
start_channels: &[f64],
end_channels: &[f64],
out: &mut Vec<(f64, f64)>,
) {
let mut samples: Vec<InteriorSample> = Vec::new();
self.interpolate_closing_segment_with_t(panel, start_channels, end_channels, &mut samples);
for s in samples {
out.push((s.px, s.py));
}
}
pub fn interpolate_closing_segment_with_t(
&self,
panel: Rect,
start_channels: &[f64],
end_channels: &[f64],
out: &mut Vec<InteriorSample>,
) {
self.interpolate_channel_segment(panel, start_channels, end_channels, true, out);
}
pub fn interpolate_segment_with_t(
&self,
panel: Rect,
start_channels: &[f64],
end_channels: &[f64],
out: &mut Vec<InteriorSample>,
) {
self.interpolate_channel_segment(panel, start_channels, end_channels, false, out);
}
fn interpolate_channel_segment(
&self,
panel: Rect,
start_channels: &[f64],
end_channels: &[f64],
closing: bool,
out: &mut Vec<InteriorSample>,
) {
match self {
Projection::Cartesian | Projection::Custom(_) => {
}
Projection::Polar(p) => {
let (theta_a_frac, r_a_frac) = p.theta_r_from_xy(
start_channels.first().copied().unwrap_or(0.0),
start_channels.get(1).copied().unwrap_or(0.0),
);
let (mut theta_b_frac, r_b_frac) = p.theta_r_from_xy(
end_channels.first().copied().unwrap_or(0.0),
end_channels.get(1).copied().unwrap_or(0.0),
);
if closing && p.is_full_circle() {
let direct = theta_b_frac - theta_a_frac;
if direct > 0.5 {
theta_b_frac -= 1.0;
} else if direct < -0.5 {
theta_b_frac += 1.0;
}
}
match p.edge_style {
PolarEdgeStyle::Geodesic => {
polar_geodesic_samples(
p,
panel,
theta_a_frac,
r_a_frac,
theta_b_frac,
r_b_frac,
out,
);
}
PolarEdgeStyle::Chord => {
polar_chord_samples(
p,
panel,
theta_a_frac,
r_a_frac,
theta_b_frac,
r_b_frac,
out,
);
}
}
}
}
}
}
fn polar_geodesic_samples(
p: &PolarProjection,
panel: Rect,
theta_a_frac: f64,
r_a_frac: f64,
theta_b_frac: f64,
r_b_frac: f64,
out: &mut Vec<InteriorSample>,
) {
let theta_a = p.theta_for_frac(theta_a_frac);
let theta_b = p.theta_for_frac(theta_b_frac);
let theta_delta = (theta_b - theta_a).abs();
if theta_delta < 1e-9 {
return;
}
let g = p.geometry(panel);
let r_a_px = g.r_inner + r_a_frac * (g.r_outer - g.r_inner);
let r_b_px = g.r_inner + r_b_frac * (g.r_outer - g.r_inner);
let r_max_px = r_a_px.max(r_b_px).max(1.0);
let dr_px = (r_b_px - r_a_px).abs();
let err_n1 = r_max_px * theta_delta * theta_delta / 8.0 + dr_px * theta_delta / 4.0;
let n_chord = ((err_n1 / CHORD_ERROR_PX).sqrt().ceil() as usize).max(1);
let n_angle = (theta_delta / MAX_THETA_STEP_RAD).ceil() as usize;
let n_steps = n_chord.max(n_angle).clamp(1, MAX_INTERPOLATION_STEPS);
for i in 1..n_steps {
let t = i as f64 / n_steps as f64;
let theta_frac_i = theta_a_frac + t * (theta_b_frac - theta_a_frac);
let r_frac_i = r_a_frac + t * (r_b_frac - r_a_frac);
let (px, py) = p.project_frac(panel, theta_frac_i, r_frac_i);
out.push(InteriorSample { px, py, t });
}
}
fn polar_chord_samples(
p: &PolarProjection,
panel: Rect,
theta_a_frac: f64,
r_a_frac: f64,
theta_b_frac: f64,
r_b_frac: f64,
out: &mut Vec<InteriorSample>,
) {
let theta_delta = theta_b_frac - theta_a_frac;
if !theta_delta.is_finite() || theta_delta.abs() < 1e-12 {
return;
}
let cyclic = p.is_full_circle() && theta_delta.abs() <= MAX_CHORD_TURNS;
let (frac_lo, frac_hi) = if theta_delta > 0.0 {
(theta_a_frac, theta_b_frac)
} else {
(theta_b_frac, theta_a_frac)
};
let mut crossings: Vec<f64> = Vec::new();
for &break_frac in &p.theta_break_fracs {
let (k_lo, k_hi) = if cyclic {
(
(frac_lo - break_frac).ceil() as i64,
(frac_hi - break_frac).floor() as i64,
)
} else {
(0, 0)
};
for k in k_lo..=k_hi {
let t = (break_frac + k as f64 - theta_a_frac) / theta_delta;
if t > 1e-9 && t < 1.0 - 1e-9 {
crossings.push(t);
}
}
}
crossings.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
for t in crossings {
let theta_frac_i = theta_a_frac + t * theta_delta;
let r_frac_i = r_a_frac + t * (r_b_frac - r_a_frac);
let (px, py) = p.project_frac(panel, theta_frac_i, r_frac_i);
out.push(InteriorSample { px, py, t });
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct InteriorSample {
pub px: f64,
pub py: f64,
pub t: f64,
}
const CHORD_ERROR_PX: f64 = 0.25;
const MAX_THETA_STEP_RAD: f64 = std::f64::consts::PI / 120.0;
const MAX_INTERPOLATION_STEPS: usize = 720;
const MAX_CHORD_TURNS: f64 = 64.0;
fn angle_in_sweep(target: f64, theta_start: f64, theta_end: f64) -> bool {
let span = theta_end - theta_start;
if span.abs() < 1e-12 {
return (target - theta_start).abs() < 1e-9;
}
for k in -2..=2 {
let t_target = target + k as f64 * std::f64::consts::TAU;
let t = (t_target - theta_start) / span;
if (0.0..=1.0).contains(&t) {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
fn approx(a: f64, b: f64, msg: &str) {
assert!((a - b).abs() < 1e-9, "{msg}: {a} ≠ {b}");
}
fn panel_400_300() -> Rect {
Rect::new(50.0, 30.0, 450.0, 330.0)
}
#[test]
fn cartesian_is_default() {
let p = Projection::default();
assert_eq!(p, Projection::Cartesian);
}
#[test]
fn cartesian_consume_channels() {
let p = Projection::Cartesian;
assert_eq!(p.consume_channels(), &["x", "y"]);
}
#[test]
fn cartesian_chrome_strategy_is_patch_slots() {
assert_eq!(
Projection::Cartesian.chrome_strategy(),
ChromeStrategy::PatchSlots
);
}
#[test]
fn cartesian_origin_maps_to_bottom_left() {
let panel = panel_400_300();
let (px, py) = Projection::Cartesian.project_to_panel_px(panel, &[0.0, 0.0]);
approx(px, panel.x0, "x");
approx(py, panel.y1, "y (bottom)");
}
#[test]
fn cartesian_corner_maps_to_top_right() {
let panel = panel_400_300();
let (px, py) = Projection::Cartesian.project_to_panel_px(panel, &[1.0, 1.0]);
approx(px, panel.x1, "x");
approx(py, panel.y0, "y (top)");
}
#[test]
fn cartesian_centre_maps_to_panel_centre() {
let panel = panel_400_300();
let (px, py) = Projection::Cartesian.project_to_panel_px(panel, &[0.5, 0.5]);
approx(px, (panel.x0 + panel.x1) * 0.5, "x");
approx(py, (panel.y0 + panel.y1) * 0.5, "y");
}
#[test]
fn cartesian_matches_legacy_inline_math() {
let panel = panel_400_300();
let panel_w = panel.x1 - panel.x0;
let panel_h = panel.y1 - panel.y0;
for x_frac in [-0.5, 0.0, 0.25, 0.5, 0.75, 1.0, 1.5] {
for y_frac in [-0.5, 0.0, 0.5, 1.0, 1.5] {
let (px, py) = Projection::Cartesian.project_to_panel_px(panel, &[x_frac, y_frac]);
let expected_px = panel.x0 + x_frac * panel_w;
let expected_py = panel.y1 - y_frac * panel_h;
approx(px, expected_px, "px");
approx(py, expected_py, "py");
}
}
}
#[test]
fn cartesian_short_slice_defaults_to_zero() {
let panel = panel_400_300();
let (px, py) = Projection::Cartesian.project_to_panel_px(panel, &[]);
approx(px, panel.x0, "px");
approx(py, panel.y1, "py");
}
#[test]
fn cartesian_extra_channels_are_ignored() {
let panel = panel_400_300();
let (px, py) = Projection::Cartesian.project_to_panel_px(panel, &[0.25, 0.5, 999.0, 999.0]);
let panel_w = panel.x1 - panel.x0;
let panel_h = panel.y1 - panel.y0;
approx(px, panel.x0 + 0.25 * panel_w, "px");
approx(py, panel.y1 - 0.5 * panel_h, "py");
}
#[test]
fn cartesian_is_linear() {
assert!(Projection::Cartesian.is_linear());
}
#[test]
fn cartesian_interpolate_segment_is_noop() {
let panel = panel_400_300();
let mut out = vec![(999.0, 999.0)];
Projection::Cartesian.interpolate_segment(panel, &[0.0, 0.0], &[1.0, 1.0], &mut out);
assert_eq!(out.len(), 1);
assert_eq!(out[0], (999.0, 999.0));
}
fn square_panel() -> Rect {
Rect::new(0.0, 0.0, 400.0, 400.0)
}
fn approx_pt(actual: (f64, f64), expected: (f64, f64), tol: f64, msg: &str) {
assert!(
(actual.0 - expected.0).abs() < tol && (actual.1 - expected.1).abs() < tol,
"{msg}: actual={actual:?}, expected={expected:?}"
);
}
#[test]
fn polar_is_not_linear() {
assert!(!Projection::polar().is_linear());
assert!(!Projection::gauge().is_linear());
}
#[test]
fn polar_chrome_strategy_is_inside_panel() {
assert_eq!(
Projection::polar().chrome_strategy(),
ChromeStrategy::InsidePanel
);
assert_eq!(
Projection::gauge().chrome_strategy(),
ChromeStrategy::InsidePanel
);
}
#[test]
fn polar_default_consume_channels() {
let p = Projection::polar();
let chans = p.consume_channels();
assert_eq!(chans, vec!["x", "y"]);
}
#[test]
fn polar_zero_radius_maps_to_panel_centre() {
let panel = square_panel();
let proj = Projection::polar();
for theta_frac in [0.0, 0.1, 0.25, 0.5, 0.75, 1.0] {
let pt = proj.project_to_panel_px(panel, &[theta_frac, 0.0]);
approx_pt(pt, (200.0, 200.0), 1e-9, "centre");
}
}
#[test]
fn polar_default_full_radius_at_zero_theta_is_top() {
let panel = square_panel();
let pt = Projection::polar().project_to_panel_px(panel, &[0.0, 1.0]);
approx_pt(pt, (200.0, 0.0), 1e-9, "12 o'clock top");
}
#[test]
fn polar_default_clockwise_sweep_at_quarters() {
let panel = square_panel();
let proj = Projection::polar();
approx_pt(
proj.project_to_panel_px(panel, &[0.25, 1.0]),
(400.0, 200.0),
1e-9,
"3 o'clock",
);
approx_pt(
proj.project_to_panel_px(panel, &[0.5, 1.0]),
(200.0, 400.0),
1e-9,
"6 o'clock",
);
approx_pt(
proj.project_to_panel_px(panel, &[0.75, 1.0]),
(0.0, 200.0),
1e-9,
"9 o'clock",
);
}
#[test]
fn polar_non_square_panel_uses_inscribed_square() {
let panel = Rect::new(0.0, 0.0, 600.0, 300.0);
let proj = Projection::polar();
approx_pt(
proj.project_to_panel_px(panel, &[0.0, 1.0]),
(300.0, 0.0),
1e-9,
"12 o'clock on wide panel",
);
approx_pt(
proj.project_to_panel_px(panel, &[0.25, 1.0]),
(450.0, 150.0),
1e-9,
"3 o'clock on wide panel",
);
}
#[test]
fn polar_inner_radius_frac_offsets_origin() {
let panel = square_panel();
let proj = Projection::gauge();
let pt = proj.project_to_panel_px(panel, &[0.0, 0.0]);
approx_pt(pt, (120.0, 300.0), 1e-9, "gauge inner @ 9 o'clock");
let pt = proj.project_to_panel_px(panel, &[0.0, 1.0]);
approx_pt(pt, (0.0, 300.0), 1e-9, "gauge outer @ 9 o'clock");
}
#[test]
fn polar_gauge_partial_arc_endpoints() {
let panel = square_panel();
let proj = Projection::gauge();
approx_pt(
proj.project_to_panel_px(panel, &[0.0, 1.0]),
(0.0, 300.0),
1e-9,
"gauge start (9 o'clock)",
);
approx_pt(
proj.project_to_panel_px(panel, &[1.0, 1.0]),
(400.0, 300.0),
1e-9,
"gauge end (3 o'clock)",
);
approx_pt(
proj.project_to_panel_px(panel, &[0.5, 1.0]),
(200.0, 100.0),
1e-9,
"gauge middle (12 o'clock)",
);
}
#[test]
fn polar_full_circle_bounding_box_is_unit_square() {
let (mn_x, mn_y, mx_x, mx_y) = PolarProjection::full_circle().bounding_box_units();
approx_pt((mn_x, mn_y), (-1.0, -1.0), 1e-9, "full-circle min");
approx_pt((mx_x, mx_y), (1.0, 1.0), 1e-9, "full-circle max");
}
#[test]
fn polar_gauge_bounding_box_is_top_half() {
let (mn_x, mn_y, mx_x, mx_y) = PolarProjection::gauge().bounding_box_units();
approx_pt((mn_x, mn_y), (-1.0, 0.0), 1e-9, "gauge min");
approx_pt((mx_x, mx_y), (1.0, 1.0), 1e-9, "gauge max");
}
#[test]
fn polar_quarter_pie_bounding_box() {
let pie = PolarProjection::full_circle().theta_range(0.0, std::f64::consts::FRAC_PI_2);
let (mn_x, mn_y, mx_x, mx_y) = pie.bounding_box_units();
approx_pt((mn_x, mn_y), (0.0, 0.0), 1e-9, "quarter-pie min");
approx_pt((mx_x, mx_y), (1.0, 1.0), 1e-9, "quarter-pie max");
}
#[test]
fn polar_gauge_uses_full_panel_width_on_tall_panel() {
let panel = Rect::new(0.0, 0.0, 200.0, 400.0);
let proj = Projection::gauge();
approx_pt(
proj.project_to_panel_px(panel, &[0.0, 1.0]),
(0.0, 250.0),
1e-9,
"gauge 9 o'clock on tall panel",
);
approx_pt(
proj.project_to_panel_px(panel, &[1.0, 1.0]),
(200.0, 250.0),
1e-9,
"gauge 3 o'clock on tall panel",
);
}
#[test]
fn polar_interpolate_segment_radial_line_emits_nothing() {
let panel = square_panel();
let proj = Projection::polar();
let mut out = Vec::new();
proj.interpolate_segment(panel, &[0.25, 0.0], &[0.25, 1.0], &mut out);
assert!(out.is_empty(), "expected no interior points: {out:?}");
}
#[test]
fn polar_interpolate_segment_arc_emits_samples() {
let panel = square_panel();
let proj = Projection::polar();
let mut out = Vec::new();
proj.interpolate_segment(panel, &[0.0, 1.0], &[0.25, 1.0], &mut out);
assert!(
out.len() >= 50,
"expected ≥50 interior samples, got {}",
out.len()
);
assert!(out.len() < 80, "too many samples: {}", out.len());
for (px, py) in &out {
let d = ((*px - 200.0).powi(2) + (*py - 200.0).powi(2)).sqrt();
assert!(
(d - 200.0).abs() < 1.0,
"sample {:?} not on circle (d={d})",
(px, py)
);
}
}
#[test]
fn cartesian_interpolate_segment_with_t_is_noop() {
let panel = panel_400_300();
let mut out: Vec<InteriorSample> = Vec::new();
Projection::Cartesian.interpolate_segment_with_t(panel, &[0.0, 0.0], &[1.0, 1.0], &mut out);
assert!(out.is_empty());
}
#[test]
fn polar_interpolate_segment_with_t_yields_evenly_spaced_t() {
let panel = square_panel();
let proj = Projection::polar();
let mut out: Vec<InteriorSample> = Vec::new();
proj.interpolate_segment_with_t(panel, &[0.0, 1.0], &[0.25, 1.0], &mut out);
assert!(!out.is_empty());
assert!(out.first().unwrap().t > 0.0);
assert!(out.last().unwrap().t < 1.0);
for w in out.windows(2) {
assert!(w[1].t > w[0].t, "t not monotonic: {:?}", out);
}
if let Projection::Polar(p) = &proj {
for s in &out {
let theta_frac = 0.0 + s.t * (0.25 - 0.0);
let r_frac = 1.0;
let (px, py) = p.project_frac(panel, theta_frac, r_frac);
approx_pt((s.px, s.py), (px, py), 1e-9, "sample position");
}
}
}
#[test]
fn polar_interpolate_segment_and_with_t_agree_on_positions() {
let panel = square_panel();
let proj = Projection::polar();
let mut a: Vec<(f64, f64)> = Vec::new();
let mut b: Vec<InteriorSample> = Vec::new();
proj.interpolate_segment(panel, &[0.0, 1.0], &[0.25, 1.0], &mut a);
proj.interpolate_segment_with_t(panel, &[0.0, 1.0], &[0.25, 1.0], &mut b);
assert_eq!(a.len(), b.len());
for (ap, bs) in a.iter().zip(b.iter()) {
approx_pt(*ap, (bs.px, bs.py), 1e-9, "agree");
}
}
#[test]
fn radar_closing_edge_hops_the_seam_instead_of_retracing() {
let panel = square_panel();
let proj = Projection::radar(5);
let mut closing = Vec::new();
proj.interpolate_closing_segment(panel, &[0.9, 1.0], &[0.1, 1.0], &mut closing);
assert!(
closing.is_empty(),
"closing edge should cross no spokes: {closing:?}"
);
let mut direct = Vec::new();
proj.interpolate_segment(panel, &[0.9, 1.0], &[0.1, 1.0], &mut direct);
assert_eq!(direct.len(), 3);
}
#[test]
fn radar_closing_edge_bends_at_a_spoke_beyond_the_seam() {
let panel = square_panel();
let proj = Projection::Polar(
PolarProjection::full_circle()
.edges(PolarEdgeStyle::Chord)
.theta_breaks([0.0, 0.2, 0.4, 0.6, 0.8]),
);
let mut out = Vec::new();
proj.interpolate_closing_segment_with_t(panel, &[0.9, 1.0], &[0.1, 1.0], &mut out);
assert_eq!(out.len(), 1, "expected the seam spoke only: {out:?}");
assert!((out[0].t - 0.5).abs() < 1e-9);
let p = proj.as_polar().expect("polar");
approx_pt(
(out[0].px, out[0].py),
p.project_frac(panel, 0.0, 1.0),
1e-9,
"seam spoke position",
);
}
#[test]
fn geodesic_closing_edge_takes_the_short_arc_across_the_seam() {
let panel = square_panel();
let proj = Projection::polar();
let mut closing = Vec::new();
let mut direct = Vec::new();
proj.interpolate_closing_segment(panel, &[0.9, 1.0], &[0.1, 1.0], &mut closing);
proj.interpolate_segment(panel, &[0.9, 1.0], &[0.1, 1.0], &mut direct);
assert!(
closing.len() < direct.len(),
"short arc should need fewer samples: closing={} direct={}",
closing.len(),
direct.len()
);
assert!(!closing.is_empty(), "the short arc still needs samples");
let cy = 0.5 * (panel.y0 + panel.y1);
for (px, py) in &closing {
assert!(*py < cy, "sample {px},{py} left the upper half");
}
}
#[test]
fn closing_edge_within_half_the_domain_does_not_wrap() {
let panel = square_panel();
let proj = Projection::radar(5);
let mut closing = Vec::new();
let mut direct = Vec::new();
proj.interpolate_closing_segment(panel, &[0.5, 1.0], &[0.1, 1.0], &mut closing);
proj.interpolate_segment(panel, &[0.5, 1.0], &[0.1, 1.0], &mut direct);
assert_eq!(closing.len(), 1);
assert_eq!(closing, direct);
}
#[test]
fn partial_arc_closing_edge_matches_the_plain_segment() {
let panel = square_panel();
let proj = Projection::gauge();
let mut closing = Vec::new();
let mut direct = Vec::new();
proj.interpolate_closing_segment(panel, &[0.9, 1.0], &[0.1, 0.5], &mut closing);
proj.interpolate_segment(panel, &[0.9, 1.0], &[0.1, 0.5], &mut direct);
assert!(!direct.is_empty());
assert_eq!(closing, direct);
}
#[test]
fn full_domain_ordinary_edge_sweeps_the_whole_circle() {
let panel = square_panel();
let proj = Projection::polar();
let mut out = Vec::new();
proj.interpolate_segment(panel, &[0.0, 1.0], &[1.0, 1.0], &mut out);
assert!(
out.len() > 100,
"full sweep should densify heavily: {}",
out.len()
);
}
#[test]
fn cartesian_closing_edge_is_a_no_op() {
let mut out = Vec::new();
Projection::Cartesian.interpolate_closing_segment(
square_panel(),
&[0.9, 1.0],
&[0.1, 0.0],
&mut out,
);
assert!(out.is_empty());
}
#[test]
fn chord_position_repeats_every_turn() {
let p = match Projection::radar(12) {
Projection::Polar(p) => p,
_ => panic!("expected Polar"),
};
for f in [0.2f64, 0.9583, 1.0] {
approx_pt(
p.unit_position(f + 1.0),
p.unit_position(f),
1e-12,
"one turn later",
);
approx_pt(
p.unit_position(f + 3.0),
p.unit_position(f),
1e-12,
"three turns later",
);
}
for f in [1.2f64, 1.5, 4.05, -0.3] {
let (ux, uy) = p.unit_position(f);
assert!(
(ux * ux + uy * uy).sqrt() <= 1.0 + 1e-9,
"frac {f} landed off the polygon: ({ux}, {uy})"
);
}
}
#[test]
fn chord_segment_bends_at_spokes_on_a_later_turn() {
let panel = square_panel();
let proj = Projection::radar(12);
let mut out = Vec::new();
proj.interpolate_segment_with_t(panel, &[3.95, 0.6], &[4.05, 0.6], &mut out);
assert_eq!(out.len(), 2, "expected two spoke crossings: {out:?}");
let p = proj.as_polar().expect("polar");
approx_pt(
(out[0].px, out[0].py),
p.project_frac(panel, 23.0 / 24.0, 0.6),
1e-6,
"last spoke of turn 3",
);
approx_pt(
(out[1].px, out[1].py),
p.project_frac(panel, 1.0 / 24.0, 0.6),
1e-6,
"first spoke of turn 4",
);
}
#[test]
fn chord_segment_spanning_absurdly_many_turns_stays_bounded() {
let panel = square_panel();
let proj = Projection::radar(12);
let mut out = Vec::new();
proj.interpolate_segment(panel, &[0.0, 1.0], &[1.0e6, 1.0], &mut out);
assert!(out.len() <= 12, "unbounded crossings: {}", out.len());
}
#[test]
fn radar_is_still_non_linear() {
assert!(!Projection::radar(6).is_linear());
}
#[test]
fn radar_interpolate_segment_emits_one_sample_per_break_crossing() {
let panel = square_panel();
let proj = Projection::radar(6);
let mut out = Vec::new();
proj.interpolate_segment_with_t(panel, &[0.05, 0.5], &[0.45, 0.5], &mut out);
assert_eq!(out.len(), 3, "expected 3 break crossings: {out:?}");
let span = 0.45 - 0.05;
let t0_expected = (1.0 / 12.0 - 0.05) / span;
let t1_expected = (3.0 / 12.0 - 0.05) / span;
let t2_expected = (5.0 / 12.0 - 0.05) / span;
assert!((out[0].t - t0_expected).abs() < 1e-9);
assert!((out[1].t - t1_expected).abs() < 1e-9);
assert!((out[2].t - t2_expected).abs() < 1e-9);
}
#[test]
fn radar_segment_inside_one_break_span_emits_nothing() {
let panel = square_panel();
let proj = Projection::radar(6);
let mut out = Vec::new();
proj.interpolate_segment(panel, &[0.10, 0.3], &[0.20, 0.7], &mut out);
assert!(out.is_empty(), "expected no break crossings: {out:?}");
}
#[test]
fn radar_segment_with_no_configured_breaks_emits_nothing() {
let panel = square_panel();
let radar_no_breaks =
Projection::Polar(PolarProjection::full_circle().edges(PolarEdgeStyle::Chord));
let mut out = Vec::new();
radar_no_breaks.interpolate_segment(panel, &[0.0, 1.0], &[0.5, 1.0], &mut out);
assert!(out.is_empty());
}
#[test]
fn radar_point_projection_matches_polar_at_same_angle_and_radius() {
let panel = square_panel();
let radar_cw =
Projection::Polar(PolarProjection::full_circle().edges(PolarEdgeStyle::Chord));
let polar = Projection::polar();
for (theta_frac, r_frac) in [(0.0, 1.0), (0.25, 1.0), (0.5, 0.5), (0.75, 0.0)] {
let a = polar.project_to_panel_px(panel, &[theta_frac, r_frac]);
let b = radar_cw.project_to_panel_px(panel, &[theta_frac, r_frac]);
approx_pt(a, b, 1e-9, "polar vs radar (cw)");
}
}
#[test]
fn radar_chrome_strategy_is_inside_panel() {
assert_eq!(
Projection::radar(6).chrome_strategy(),
ChromeStrategy::InsidePanel
);
}
#[test]
fn radar_default_has_n_band_centre_break_fracs() {
if let Projection::Polar(p) = Projection::radar(6) {
assert_eq!(p.theta_break_fracs().len(), 6);
for (i, frac) in p.theta_break_fracs().iter().enumerate() {
assert!((frac - (i as f64 + 0.5) / 6.0).abs() < 1e-9);
}
} else {
panic!("expected Polar");
}
}
#[test]
fn theta_breaks_builder_sorts_and_drops_non_finite() {
let p = PolarProjection::full_circle().theta_breaks([0.8, f64::NAN, 0.2, 0.5, 0.1]);
assert_eq!(p.theta_break_fracs(), &[0.1, 0.2, 0.5, 0.8]);
}
#[test]
fn inner_radius_builder_clamps_below_one() {
assert_eq!(
PolarProjection::full_circle()
.inner_radius(-2.0)
.inner_radius_frac(),
0.0
);
assert_eq!(
PolarProjection::full_circle()
.inner_radius(f64::NAN)
.inner_radius_frac(),
0.0
);
let hot = PolarProjection::full_circle().inner_radius(7.0);
assert!(hot.inner_radius_frac() < 1.0);
assert!(hot.inner_radius_frac() > 0.999);
}
#[test]
fn outer_radius_builder_clamps_to_the_unit_interval() {
assert_eq!(
PolarProjection::full_circle()
.outer_radius(3.0)
.outer_radius_frac(),
1.0
);
assert_eq!(
PolarProjection::full_circle()
.outer_radius(-1.0)
.outer_radius_frac(),
0.0
);
assert_eq!(
PolarProjection::full_circle()
.outer_radius(0.45)
.outer_radius_frac(),
0.45
);
}
#[test]
fn theta_range_builder_ignores_non_finite_endpoints() {
let base = PolarProjection::full_circle();
let p = base.clone().theta_range(f64::INFINITY, 0.0);
assert_eq!(p.theta_start(), base.theta_start());
assert_eq!(p.theta_end(), base.theta_end());
}
#[test]
fn unsorted_breaks_still_draw_a_convex_polygon() {
let shuffled = PolarProjection::full_circle()
.edges(PolarEdgeStyle::Chord)
.theta_breaks([0.75, 0.0, 0.5, 0.25]);
for i in 0..40 {
let frac = i as f64 / 40.0;
let (ux, uy) = shuffled.unit_position(frac);
let r = (ux * ux + uy * uy).sqrt();
assert!(r <= 1.0 + 1e-9, "frac {frac} landed off the polygon");
assert!(r > 0.5, "frac {frac} collapsed toward the centre (r={r})");
}
}
#[test]
fn polar_interpolate_segment_chord_dominates_at_huge_panel() {
let panel = Rect::new(0.0, 0.0, 8000.0, 8000.0);
let proj = Projection::polar();
let mut small_r = Vec::new();
let mut large_r = Vec::new();
proj.interpolate_segment(panel, &[0.0, 0.1], &[0.25, 0.1], &mut small_r);
proj.interpolate_segment(panel, &[0.0, 1.0], &[0.25, 1.0], &mut large_r);
assert!(
small_r.len() < large_r.len(),
"expected fewer samples at smaller r: small_r={} large_r={}",
small_r.len(),
large_r.len()
);
}
}