use crate::core::color::default_colormap;
use crate::core::{Affine2D, Colormap, LinearNorm, Normalize, Path, color::Rgba};
use crate::render::{GraphicsContext, Renderer};
use crate::skia::SkiaRenderer;
#[derive(Debug, Clone, Copy)]
struct Range {
min: f64,
max: f64,
}
impl Range {
fn empty() -> Self {
Self {
min: f64::INFINITY,
max: f64::NEG_INFINITY,
}
}
fn expand(&mut self, v: f64) {
if v.is_finite() {
self.min = self.min.min(v);
self.max = self.max.max(v);
}
}
fn mid(&self) -> f64 {
if self.min <= self.max {
0.5 * (self.min + self.max)
} else {
0.0
}
}
fn span(&self) -> f64 {
let s = self.max - self.min;
if s.is_finite() && s > 0.0 { s } else { 1.0 }
}
}
#[derive(Debug, Clone)]
struct Line3D {
points: Vec<[f64; 3]>,
color: Rgba,
width: f64,
}
#[derive(Debug, Clone)]
struct Scatter3D {
points: Vec<[f64; 3]>,
color: Rgba,
size: f64,
}
#[derive(Debug, Clone)]
struct SurfaceQuad {
corners: [[f64; 3]; 4],
color: Rgba,
}
#[derive(Debug, Clone)]
struct Surface3D {
quads: Vec<SurfaceQuad>,
edge: Rgba,
}
#[derive(Debug, Clone)]
struct WireEdge {
a: [f64; 3],
b: [f64; 3],
}
#[derive(Debug, Clone)]
struct Wireframe3D {
edges: Vec<WireEdge>,
color: Rgba,
}
#[derive(Debug, Clone)]
struct Bar3D {
faces: Vec<SurfaceQuad>,
edge: Rgba,
}
#[derive(Debug, Clone)]
struct Quiver3D {
segments: Vec<([f64; 3], [f64; 3])>,
color: Rgba,
}
#[derive(Debug, Clone)]
struct Text3D {
pos: [f64; 3],
text: String,
}
#[derive(Debug, Clone)]
pub struct Axes3D {
elev_deg: f64,
azim_deg: f64,
xr: Range,
yr: Range,
zr: Range,
lines: Vec<Line3D>,
scatters: Vec<Scatter3D>,
surfaces: Vec<Surface3D>,
wireframes: Vec<Wireframe3D>,
bars: Vec<Bar3D>,
quivers: Vec<Quiver3D>,
texts: Vec<Text3D>,
title: Option<String>,
cycle: usize,
}
const COLOR_CYCLE: &[Rgba] = &[
Rgba::new(0.121, 0.466, 0.705, 1.0), Rgba::new(1.0, 0.498, 0.054, 1.0), Rgba::new(0.172, 0.627, 0.172, 1.0), Rgba::new(0.839, 0.152, 0.156, 1.0), Rgba::new(0.580, 0.403, 0.741, 1.0), ];
const BOX_COLOR: Rgba = Rgba::new(0.7, 0.7, 0.7, 1.0);
const SURFACE_EDGE_COLOR: Rgba = Rgba::new(0.15, 0.15, 0.15, 0.55);
const BAR_EDGE_COLOR: Rgba = Rgba::new(0.1, 0.1, 0.1, 0.8);
const BAR_SIDE_SHADE: f64 = 0.72;
const MARGIN_FRAC: f64 = 0.12;
const TEXT3D_SIZE: f64 = 10.0;
const TITLE_SIZE: f64 = 12.0;
const TITLE_PAD: f64 = 6.0;
impl Axes3D {
#[must_use]
pub fn new() -> Self {
Self {
elev_deg: 30.0,
azim_deg: -60.0,
xr: Range::empty(),
yr: Range::empty(),
zr: Range::empty(),
lines: Vec::new(),
scatters: Vec::new(),
surfaces: Vec::new(),
wireframes: Vec::new(),
bars: Vec::new(),
quivers: Vec::new(),
texts: Vec::new(),
title: None,
cycle: 0,
}
}
pub fn set_title(&mut self, title: impl Into<String>) -> &mut Self {
self.title = Some(title.into());
self
}
#[must_use]
pub fn with_view(mut self, elev: f64, azim: f64) -> Self {
self.elev_deg = elev;
self.azim_deg = azim;
self
}
pub fn set_view(&mut self, elev: f64, azim: f64) -> &mut Self {
self.elev_deg = elev;
self.azim_deg = azim;
self
}
#[must_use]
pub fn view(&self) -> (f64, f64) {
(self.elev_deg, self.azim_deg)
}
fn next_color(&mut self) -> Rgba {
let c = COLOR_CYCLE[self.cycle % COLOR_CYCLE.len()];
self.cycle += 1;
c
}
fn expand_bounds(&mut self, x: &[f64], y: &[f64], z: &[f64]) {
let n = x.len().min(y.len()).min(z.len());
for i in 0..n {
self.xr.expand(x[i]);
self.yr.expand(y[i]);
self.zr.expand(z[i]);
}
}
pub fn plot3d(&mut self, x: &[f64], y: &[f64], z: &[f64]) -> &mut Self {
self.expand_bounds(x, y, z);
let n = x.len().min(y.len()).min(z.len());
let points = (0..n).map(|i| [x[i], y[i], z[i]]).collect();
let color = self.next_color();
self.lines.push(Line3D {
points,
color,
width: 1.5,
});
self
}
pub fn scatter3d(&mut self, x: &[f64], y: &[f64], z: &[f64]) -> &mut Self {
self.expand_bounds(x, y, z);
let n = x.len().min(y.len()).min(z.len());
let points = (0..n).map(|i| [x[i], y[i], z[i]]).collect();
let color = self.next_color();
self.scatters.push(Scatter3D {
points,
color,
size: 5.0,
});
self
}
pub fn quiver3d(
&mut self,
x: &[f64],
y: &[f64],
z: &[f64],
u: &[f64],
v: &[f64],
w: &[f64],
) -> &mut Self {
let n = [x.len(), y.len(), z.len(), u.len(), v.len(), w.len()]
.into_iter()
.min()
.unwrap_or(0);
let mut segments = Vec::with_capacity(n);
let (mut tx, mut ty, mut tz) = (
Vec::with_capacity(n),
Vec::with_capacity(n),
Vec::with_capacity(n),
);
for i in 0..n {
let tip = [x[i] + u[i], y[i] + v[i], z[i] + w[i]];
segments.push(([x[i], y[i], z[i]], tip));
tx.push(tip[0]);
ty.push(tip[1]);
tz.push(tip[2]);
}
self.expand_bounds(&x[..n], &y[..n], &z[..n]);
self.expand_bounds(&tx, &ty, &tz);
let color = self.next_color();
self.quivers.push(Quiver3D { segments, color });
self
}
pub fn text3d(&mut self, x: f64, y: f64, z: f64, s: impl Into<String>) -> &mut Self {
self.texts.push(Text3D {
pos: [x, y, z],
text: s.into(),
});
self
}
pub fn plot_surface(&mut self, x: &[f64], y: &[f64], z: &[f64]) -> &mut Self {
let nx = x.len();
let ny = y.len();
if nx < 2 || ny < 2 || z.len() != nx * ny {
return self;
}
let mut zmin = f64::INFINITY;
let mut zmax = f64::NEG_INFINITY;
for &v in z {
if v.is_finite() {
zmin = zmin.min(v);
zmax = zmax.max(v);
}
}
for &xi in x {
self.xr.expand(xi);
}
for &yj in y {
self.yr.expand(yj);
}
for &zv in z {
self.zr.expand(zv);
}
let norm = LinearNorm::new(zmin, zmax);
let cmap = default_colormap();
let at = |i: usize, j: usize| z[j * nx + i];
let mut quads = Vec::with_capacity((nx - 1) * (ny - 1));
for j in 0..ny - 1 {
for i in 0..nx - 1 {
let z00 = at(i, j);
let z10 = at(i + 1, j);
let z11 = at(i + 1, j + 1);
let z01 = at(i, j + 1);
let corners = [
[x[i], y[j], z00],
[x[i + 1], y[j], z10],
[x[i + 1], y[j + 1], z11],
[x[i], y[j + 1], z01],
];
let mean = 0.25 * (z00 + z10 + z11 + z01);
let color = cmap.sample(norm.normalize(mean));
quads.push(SurfaceQuad { corners, color });
}
}
self.surfaces.push(Surface3D {
quads,
edge: SURFACE_EDGE_COLOR,
});
self
}
pub fn plot_wireframe(&mut self, x: &[f64], y: &[f64], z: &[f64]) -> &mut Self {
let nx = x.len();
let ny = y.len();
if nx < 2 || ny < 2 || z.len() != nx * ny {
return self;
}
for &xi in x {
self.xr.expand(xi);
}
for &yj in y {
self.yr.expand(yj);
}
for &zv in z {
self.zr.expand(zv);
}
let at = |i: usize, j: usize| z[j * nx + i];
let vertex = |i: usize, j: usize| [x[i], y[j], at(i, j)];
let mut edges = Vec::with_capacity(nx * (ny - 1) + ny * (nx - 1));
for j in 0..ny {
for i in 0..nx - 1 {
edges.push(WireEdge {
a: vertex(i, j),
b: vertex(i + 1, j),
});
}
}
for i in 0..nx {
for j in 0..ny - 1 {
edges.push(WireEdge {
a: vertex(i, j),
b: vertex(i, j + 1),
});
}
}
let color = self.next_color();
self.wireframes.push(Wireframe3D { edges, color });
self
}
pub fn bar3d(&mut self, x: &[f64], y: &[f64], z: &[f64], dx: f64, dy: f64) -> &mut Self {
let n = x.len().min(y.len()).min(z.len());
if n == 0 {
return self;
}
let mut zmin = f64::INFINITY;
let mut zmax = f64::NEG_INFINITY;
for &v in z.iter().take(n) {
if v.is_finite() {
zmin = zmin.min(v);
zmax = zmax.max(v);
}
}
for i in 0..n {
self.xr.expand(x[i]);
self.xr.expand(x[i] + dx);
self.yr.expand(y[i]);
self.yr.expand(y[i] + dy);
self.zr.expand(0.0);
self.zr.expand(z[i]);
}
let norm = LinearNorm::new(zmin, zmax);
let cmap = default_colormap();
for i in 0..n {
let base = cmap.sample(norm.normalize(z[i]));
let faces = bar_faces([x[i], y[i]], [dx, dy], z[i], base);
self.bars.push(Bar3D {
faces,
edge: BAR_EDGE_COLOR,
});
}
self
}
fn normalize(&self, x: f64, y: f64, z: f64) -> (f64, f64, f64) {
let nx = (x - self.xr.mid()) / self.xr.span();
let ny = (y - self.yr.mid()) / self.yr.span();
let nz = (z - self.zr.mid()) / self.zr.span();
(nx, ny, nz)
}
#[must_use]
pub fn project(&self, x: f64, y: f64, z: f64, width: f64, height: f64) -> (f64, f64, f64) {
let (nx, ny, nz) = self.normalize(x, y, z);
let a = self.azim_deg.to_radians();
let e = self.elev_deg.to_radians();
let x2 = nx * a.cos() - ny * a.sin();
let y2 = nx * a.sin() + ny * a.cos();
let screen_x = x2;
let screen_y = -y2 * e.sin() + nz * e.cos();
let depth = y2 * e.cos() + nz * e.sin();
let side = width.min(height);
let usable = side * (1.0 - 2.0 * MARGIN_FRAC);
let half = 0.866_025_4_f64;
let scale = 0.5 * usable / half;
let px = 0.5 * width + screen_x * scale;
let py = 0.5 * height + screen_y * scale;
(px, py, depth)
}
fn collect_drawables(&self, width: f64, height: f64) -> Vec<Drawable> {
let mut items: Vec<Drawable> = Vec::new();
for (a, b) in cube_edges(&self.xr, &self.yr, &self.zr) {
let (ax, ay, ad) = self.project(a[0], a[1], a[2], width, height);
let (bx, by, bd) = self.project(b[0], b[1], b[2], width, height);
items.push(Drawable {
depth: 0.5 * (ad + bd),
kind: DrawKind::Line {
points: vec![[ax, ay], [bx, by]],
color: BOX_COLOR,
width: 1.0,
},
});
}
for quiver in &self.quivers {
for &(base, tip) in &quiver.segments {
let (bx, by, bd) = self.project(base[0], base[1], base[2], width, height);
let (tx, ty, td) = self.project(tip[0], tip[1], tip[2], width, height);
let (dx, dy) = (tx - bx, ty - by);
let len = dx.hypot(dy);
if len < 1e-9 {
continue;
}
let (ux, uy) = (dx / len, dy / len);
let head_len = (0.25 * len).clamp(4.0, 12.0).min(len);
let half_w = 0.45 * head_len;
let head_base = [tx - ux * head_len, ty - uy * head_len];
let (px, py) = (-uy, ux);
items.push(Drawable {
depth: 0.5 * (bd + td),
kind: DrawKind::Arrow {
shaft: [[bx, by], head_base],
head: [
[tx, ty],
[head_base[0] + px * half_w, head_base[1] + py * half_w],
[head_base[0] - px * half_w, head_base[1] - py * half_w],
],
color: quiver.color,
},
});
}
}
for label in &self.texts {
let (px, py, depth) =
self.project(label.pos[0], label.pos[1], label.pos[2], width, height);
items.push(Drawable {
depth,
kind: DrawKind::Text {
anchor: [px + 5.0, py],
text: label.text.clone(),
},
});
}
for surface in &self.surfaces {
for quad in &surface.quads {
let mut projected = [[0.0; 2]; 4];
let mut depth = 0.0;
for (k, c) in quad.corners.iter().enumerate() {
let (px, py, d) = self.project(c[0], c[1], c[2], width, height);
projected[k] = [px, py];
depth += d;
}
depth *= 0.25;
items.push(Drawable {
depth,
kind: DrawKind::Quad {
points: projected,
fill: quad.color,
edge: surface.edge,
},
});
}
}
for wire in &self.wireframes {
for edge in &wire.edges {
let (ax, ay, ad) = self.project(edge.a[0], edge.a[1], edge.a[2], width, height);
let (bx, by, bd) = self.project(edge.b[0], edge.b[1], edge.b[2], width, height);
items.push(Drawable {
depth: 0.5 * (ad + bd),
kind: DrawKind::Line {
points: vec![[ax, ay], [bx, by]],
color: wire.color,
width: 1.0,
},
});
}
}
for bar in &self.bars {
for face in &bar.faces {
let mut projected = [[0.0; 2]; 4];
let mut depth = 0.0;
for (k, c) in face.corners.iter().enumerate() {
let (px, py, d) = self.project(c[0], c[1], c[2], width, height);
projected[k] = [px, py];
depth += d;
}
depth *= 0.25;
items.push(Drawable {
depth,
kind: DrawKind::Quad {
points: projected,
fill: face.color,
edge: bar.edge,
},
});
}
}
for line in &self.lines {
let projected: Vec<[f64; 2]> = line
.points
.iter()
.map(|p| {
let (px, py, _) = self.project(p[0], p[1], p[2], width, height);
[px, py]
})
.collect();
let depth = if line.points.is_empty() {
0.0
} else {
line.points
.iter()
.map(|p| self.project(p[0], p[1], p[2], width, height).2)
.sum::<f64>()
/ line.points.len() as f64
};
items.push(Drawable {
depth,
kind: DrawKind::Line {
points: projected,
color: line.color,
width: line.width,
},
});
}
for sc in &self.scatters {
for p in &sc.points {
let (px, py, depth) = self.project(p[0], p[1], p[2], width, height);
items.push(Drawable {
depth,
kind: DrawKind::Marker {
center: [px, py],
color: sc.color,
size: sc.size,
},
});
}
}
items.sort_by(|a, b| a.depth.total_cmp(&b.depth));
items
}
pub fn draw(&self, renderer: &mut dyn Renderer) {
let (width, height) = renderer.canvas_size();
let dpi = renderer.points_to_pixels(72.0) / 72.0;
let font = (!self.texts.is_empty() || self.title.is_some())
.then(crate::text::FontSource::dejavu_sans);
for item in self.collect_drawables(width, height) {
match item.kind {
DrawKind::Line {
points,
color,
width: lw,
} => {
if points.len() < 2 {
continue;
}
let path = Path::from_polyline(&points);
let gc = GraphicsContext::new()
.with_stroke(color)
.with_line_width(lw);
renderer.draw_path(&gc, &path, &Affine2D::identity(), None);
}
DrawKind::Quad { points, fill, edge } => {
let path = Path::from_polyline(&[
points[0], points[1], points[2], points[3], points[0],
]);
let gc = GraphicsContext::new()
.with_stroke(edge)
.with_line_width(0.5);
renderer.draw_path(&gc, &path, &Affine2D::identity(), Some(fill));
}
DrawKind::Marker {
center,
color,
size,
} => {
let r = size * dpi;
let marker = Path::unit_circle()
.transformed(&Affine2D::from_scale(r, r))
.transformed(&Affine2D::from_translation(center[0], center[1]));
let gc = GraphicsContext::new()
.with_stroke(color)
.with_line_width(1.0);
renderer.draw_path(&gc, &marker, &Affine2D::identity(), Some(color));
}
DrawKind::Arrow { shaft, head, color } => {
let gc = GraphicsContext::new()
.with_stroke(color)
.with_line_width(1.5);
renderer.draw_path(
&gc,
&Path::from_polyline(&shaft),
&Affine2D::identity(),
None,
);
let head_path = Path::from_polyline(&[head[0], head[1], head[2], head[0]]);
renderer.draw_path(
&GraphicsContext::new(),
&head_path,
&Affine2D::identity(),
Some(color),
);
}
DrawKind::Text { anchor, text } => {
if let Some(font) = &font {
let s = renderer.decoration_scale();
let anchor = [anchor[0] + 5.0 * s, anchor[1]];
let path = font.text_to_path(&text, TEXT3D_SIZE * s, anchor);
renderer.draw_path(
&GraphicsContext::new(),
&path,
&Affine2D::identity(),
Some(Rgba::BLACK),
);
}
}
}
}
if let (Some(title), Some(font)) = (&self.title, &font) {
let s = renderer.decoration_scale();
let size = TITLE_SIZE * s;
let extent = font.measure(title, size);
let x = 0.5 * (width - extent.width);
let y = height - TITLE_PAD * s - extent.ascent;
let path = font.text_to_path(title, size, [x, y]);
renderer.draw_path(
&GraphicsContext::new(),
&path,
&Affine2D::identity(),
Some(Rgba::BLACK),
);
}
}
#[must_use]
pub fn render_png(&self, width_px: u32, height_px: u32, dpi: f64) -> SkiaRenderer {
let mut renderer = SkiaRenderer::new(width_px, height_px, dpi);
let bg = Path::from_polyline(&[
[0.0, 0.0],
[f64::from(width_px), 0.0],
[f64::from(width_px), f64::from(height_px)],
[0.0, f64::from(height_px)],
[0.0, 0.0],
]);
let gc = GraphicsContext::new();
renderer.draw_path(&gc, &bg, &Affine2D::identity(), Some(Rgba::WHITE));
self.draw(&mut renderer);
renderer
}
pub fn save_png<P: AsRef<std::path::Path>>(
&self,
path: P,
width_px: u32,
height_px: u32,
dpi: f64,
) -> Result<(), crate::skia::PngError> {
self.render_png(width_px, height_px, dpi).save_png(path)
}
}
impl Default for Axes3D {
fn default() -> Self {
Self::new()
}
}
struct Drawable {
depth: f64,
kind: DrawKind,
}
enum DrawKind {
Line {
points: Vec<[f64; 2]>,
color: Rgba,
width: f64,
},
Quad {
points: [[f64; 2]; 4],
fill: Rgba,
edge: Rgba,
},
Marker {
center: [f64; 2],
color: Rgba,
size: f64,
},
Arrow {
shaft: [[f64; 2]; 2],
head: [[f64; 2]; 3],
color: Rgba,
},
Text {
anchor: [f64; 2],
text: String,
},
}
fn shade(c: Rgba, factor: f64) -> Rgba {
Rgba::new(c.r * factor, c.g * factor, c.b * factor, c.a)
}
fn bar_faces(base: [f64; 2], d: [f64; 2], height: f64, color: Rgba) -> Vec<SurfaceQuad> {
let x0 = base[0];
let y0 = base[1];
let x1 = base[0] + d[0];
let y1 = base[1] + d[1];
let z0 = 0.0;
let z1 = height;
let side = shade(color, BAR_SIDE_SHADE);
vec![
SurfaceQuad {
corners: [[x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0]],
color: side,
},
SurfaceQuad {
corners: [[x0, y0, z1], [x1, y0, z1], [x1, y1, z1], [x0, y1, z1]],
color,
},
SurfaceQuad {
corners: [[x0, y0, z0], [x1, y0, z0], [x1, y0, z1], [x0, y0, z1]],
color: side,
},
SurfaceQuad {
corners: [[x0, y1, z0], [x1, y1, z0], [x1, y1, z1], [x0, y1, z1]],
color: side,
},
SurfaceQuad {
corners: [[x0, y0, z0], [x0, y1, z0], [x0, y1, z1], [x0, y0, z1]],
color: side,
},
SurfaceQuad {
corners: [[x1, y0, z0], [x1, y1, z0], [x1, y1, z1], [x1, y0, z1]],
color: side,
},
]
}
fn cube_edges(xr: &Range, yr: &Range, zr: &Range) -> Vec<([f64; 3], [f64; 3])> {
let xs = bounds(xr);
let ys = bounds(yr);
let zs = bounds(zr);
let corner = |i: usize, j: usize, k: usize| [xs[i], ys[j], zs[k]];
let mut edges = Vec::with_capacity(12);
for j in 0..2 {
for k in 0..2 {
edges.push((corner(0, j, k), corner(1, j, k)));
}
}
for i in 0..2 {
for k in 0..2 {
edges.push((corner(i, 0, k), corner(i, 1, k)));
}
}
for i in 0..2 {
for j in 0..2 {
edges.push((corner(i, j, 0), corner(i, j, 1)));
}
}
edges
}
fn bounds(r: &Range) -> [f64; 2] {
if r.min < r.max {
[r.min, r.max]
} else {
let m = r.mid();
[m - 0.5, m + 0.5]
}
}
#[cfg(test)]
mod tests {
use super::*;
const W: f64 = 400.0;
const H: f64 = 400.0;
#[test]
fn cube_corners_project_to_distinct_points() {
let mut ax = Axes3D::new();
ax.plot3d(&[-1.0, 1.0], &[-1.0, 1.0], &[-1.0, 1.0]);
let mut pts = Vec::new();
for &x in &[-1.0, 1.0] {
for &y in &[-1.0, 1.0] {
for &z in &[-1.0, 1.0] {
let (px, py, depth) = ax.project(x, y, z, W, H);
assert!(px.is_finite() && py.is_finite() && depth.is_finite());
pts.push((px, py));
}
}
}
for i in 0..pts.len() {
for j in (i + 1)..pts.len() {
let dx = pts[i].0 - pts[j].0;
let dy = pts[i].1 - pts[j].1;
assert!(
dx.hypot(dy) > 1e-6,
"corners {i} and {j} projected to the same point"
);
}
}
}
#[test]
fn larger_z_yields_greater_depth() {
let mut ax = Axes3D::new();
ax.plot3d(&[0.0, 0.0], &[0.0, 0.0], &[-1.0, 1.0]);
let (_, _, d_low) = ax.project(0.0, 0.0, -1.0, W, H);
let (_, _, d_high) = ax.project(0.0, 0.0, 1.0, W, H);
assert!(
d_high > d_low,
"expected larger z to be nearer the viewer: {d_high} vs {d_low}"
);
}
#[test]
fn plot_and_scatter_expand_bounds() {
let mut ax = Axes3D::new();
ax.plot3d(&[0.0, 2.0], &[0.0, 4.0], &[0.0, 6.0]);
assert_eq!((ax.xr.min, ax.xr.max), (0.0, 2.0));
assert_eq!((ax.yr.min, ax.yr.max), (0.0, 4.0));
assert_eq!((ax.zr.min, ax.zr.max), (0.0, 6.0));
ax.scatter3d(&[-3.0, 5.0], &[-1.0, 9.0], &[-2.0, 8.0]);
assert_eq!((ax.xr.min, ax.xr.max), (-3.0, 5.0));
assert_eq!((ax.yr.min, ax.yr.max), (-1.0, 9.0));
assert_eq!((ax.zr.min, ax.zr.max), (-2.0, 8.0));
}
#[test]
fn degenerate_data_does_not_panic() {
let mut ax = Axes3D::new();
ax.scatter3d(&[1.0], &[1.0], &[1.0]);
let (px, py, depth) = ax.project(1.0, 1.0, 1.0, W, H);
assert!(px.is_finite() && py.is_finite() && depth.is_finite());
let _ = ax.render_png(64, 64, 72.0);
}
#[test]
fn empty_axes_renders() {
let ax = Axes3D::new();
let r = ax.render_png(32, 32, 72.0);
assert_eq!(r.pixmap().width(), 32);
}
#[test]
fn view_angles() {
assert_eq!(Axes3D::new().view(), (30.0, -60.0));
assert_eq!(Axes3D::new().with_view(45.0, 10.0).view(), (45.0, 10.0));
}
fn ramp_surface(nx: usize, ny: usize) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
let x: Vec<f64> = (0..nx).map(|i| i as f64).collect();
let y: Vec<f64> = (0..ny).map(|j| j as f64).collect();
let z: Vec<f64> = (0..nx * ny).map(|k| k as f64).collect();
(x, y, z)
}
fn quad_count(ax: &Axes3D) -> usize {
ax.collect_drawables(W, H)
.iter()
.filter(|d| matches!(d.kind, DrawKind::Quad { .. }))
.count()
}
#[test]
fn plot_surface_emits_one_quad_per_cell() {
let (nx, ny) = (5_usize, 3_usize);
let (x, y, z) = ramp_surface(nx, ny);
let mut ax = Axes3D::new();
ax.plot_surface(&x, &y, &z);
assert_eq!(ax.surfaces[0].quads.len(), (nx - 1) * (ny - 1));
assert_eq!(quad_count(&ax), (nx - 1) * (ny - 1));
}
#[test]
fn surface_min_and_max_quads_differ_in_color() {
let (x, y, z) = ramp_surface(4, 4);
let mut ax = Axes3D::new();
ax.plot_surface(&x, &y, &z);
let quads = &ax.surfaces[0].quads;
let first = quads.first().unwrap().color;
let last = quads.last().unwrap().color;
assert!(
(first.r - last.r).abs() + (first.g - last.g).abs() + (first.b - last.b).abs() > 1e-3,
"min and max quads should map to different colors: {first:?} vs {last:?}"
);
}
#[test]
fn surface_rejects_degenerate_input() {
let mut ax = Axes3D::new();
ax.plot_surface(&[0.0, 1.0, 2.0], &[0.0, 1.0], &[0.0, 1.0, 2.0, 3.0, 4.0]);
ax.plot_surface(&[0.0], &[0.0, 1.0], &[0.0, 1.0]);
ax.plot_surface(&[0.0, 1.0], &[0.0], &[0.0, 1.0]);
ax.plot_surface(&[], &[], &[]);
assert!(ax.surfaces.is_empty());
assert_eq!(quad_count(&ax), 0);
}
#[test]
fn surface_expands_bounds() {
let x = vec![-2.0, 0.0, 3.0];
let y = vec![1.0, 4.0];
let z = vec![-5.0, 0.0, 2.0, 7.0, 1.0, -1.0];
let mut ax = Axes3D::new();
ax.plot_surface(&x, &y, &z);
assert_eq!((ax.xr.min, ax.xr.max), (-2.0, 3.0));
assert_eq!((ax.yr.min, ax.yr.max), (1.0, 4.0));
assert_eq!((ax.zr.min, ax.zr.max), (-5.0, 7.0));
}
#[test]
fn surface_renders() {
let (x, y, z) = ramp_surface(6, 6);
let mut ax = Axes3D::new();
ax.plot_surface(&x, &y, &z);
let r = ax.render_png(128, 128, 72.0);
assert_eq!(r.pixmap().width(), 128);
}
#[test]
fn bar3d_emits_six_faces_per_bar() {
let mut ax = Axes3D::new();
ax.bar3d(
&[0.0, 2.0, 4.0],
&[0.0, 0.0, 0.0],
&[1.0, 2.0, 3.0],
1.0,
1.0,
);
assert_eq!(ax.bars.len(), 3);
assert_eq!(quad_count(&ax), 6 * 3);
}
#[test]
fn taller_bar_top_projects_higher() {
let mut ax = Axes3D::new();
ax.bar3d(&[0.0, 0.0], &[0.0, 0.0], &[1.0, 5.0], 1.0, 1.0);
let (_, py_short, _) = ax.project(0.0, 0.0, 1.0, W, H);
let (_, py_tall, _) = ax.project(0.0, 0.0, 5.0, W, H);
assert!(
py_tall > py_short,
"taller bar top should project higher (larger py in y-up): {py_tall} vs {py_short}"
);
}
#[test]
fn bar3d_rejects_empty_input() {
let mut ax = Axes3D::new();
ax.bar3d(&[], &[], &[], 1.0, 1.0);
ax.bar3d(&[1.0, 2.0], &[], &[3.0], 1.0, 1.0);
assert!(ax.bars.is_empty());
assert_eq!(quad_count(&ax), 0);
let _ = ax.render_png(32, 32, 72.0);
}
#[test]
fn bar3d_expands_bounds_over_footprint_and_height() {
let mut ax = Axes3D::new();
ax.bar3d(&[2.0], &[3.0], &[5.0], 0.5, 0.75);
assert_eq!((ax.xr.min, ax.xr.max), (2.0, 2.5));
assert_eq!((ax.yr.min, ax.yr.max), (3.0, 3.75));
assert_eq!((ax.zr.min, ax.zr.max), (0.0, 5.0));
}
#[test]
fn bar3d_colors_by_height() {
let mut ax = Axes3D::new();
ax.bar3d(&[0.0, 2.0], &[0.0, 0.0], &[1.0, 9.0], 1.0, 1.0);
let short_top = ax.bars[0].faces[1].color;
let tall_top = ax.bars[1].faces[1].color;
assert!(
(short_top.r - tall_top.r).abs()
+ (short_top.g - tall_top.g).abs()
+ (short_top.b - tall_top.b).abs()
> 1e-3,
"bars of different height should differ in color: {short_top:?} vs {tall_top:?}"
);
}
#[test]
fn bar3d_side_faces_darker_than_top() {
let mut ax = Axes3D::new();
ax.bar3d(&[0.0], &[0.0], &[3.0], 1.0, 1.0);
let top = ax.bars[0].faces[1].color;
let front = ax.bars[0].faces[2].color;
assert!(front.r <= top.r && front.g <= top.g && front.b <= top.b);
assert!(front.r + front.g + front.b < top.r + top.g + top.b);
}
fn line_count(ax: &Axes3D) -> usize {
ax.collect_drawables(W, H)
.iter()
.filter(|d| matches!(d.kind, DrawKind::Line { .. }))
.count()
}
#[test]
fn wireframe_emits_expected_edge_count() {
let (nx, ny) = (5_usize, 3_usize);
let (x, y, z) = ramp_surface(nx, ny);
let mut ax = Axes3D::new();
ax.plot_wireframe(&x, &y, &z);
let expected = nx * (ny - 1) + ny * (nx - 1);
assert_eq!(ax.wireframes[0].edges.len(), expected);
assert_eq!(line_count(&ax), expected + 12);
}
#[test]
fn wireframe_uses_uniform_color() {
let (x, y, z) = ramp_surface(4, 4);
let mut ax = Axes3D::new();
ax.plot_wireframe(&x, &y, &z);
let color = ax.wireframes[0].color;
assert!(ax.wireframes[0].edges.len() > 1);
for d in ax.collect_drawables(W, H) {
if let DrawKind::Line { color: c, .. } = d.kind {
if c == color {
assert_eq!(c, color);
}
}
}
}
#[test]
fn wireframe_rejects_degenerate_input() {
let mut ax = Axes3D::new();
ax.plot_wireframe(&[0.0, 1.0, 2.0], &[0.0, 1.0], &[0.0, 1.0, 2.0, 3.0, 4.0]);
ax.plot_wireframe(&[0.0], &[0.0, 1.0], &[0.0, 1.0]);
ax.plot_wireframe(&[0.0, 1.0], &[0.0], &[0.0, 1.0]);
ax.plot_wireframe(&[], &[], &[]);
assert!(ax.wireframes.is_empty());
assert_eq!(line_count(&ax), 12);
}
#[test]
fn wireframe_expands_bounds() {
let x = vec![-2.0, 0.0, 3.0];
let y = vec![1.0, 4.0];
let z = vec![-5.0, 0.0, 2.0, 7.0, 1.0, -1.0];
let mut ax = Axes3D::new();
ax.plot_wireframe(&x, &y, &z);
assert_eq!((ax.xr.min, ax.xr.max), (-2.0, 3.0));
assert_eq!((ax.yr.min, ax.yr.max), (1.0, 4.0));
assert_eq!((ax.zr.min, ax.zr.max), (-5.0, 7.0));
}
#[test]
fn wireframe_renders() {
let (x, y, z) = ramp_surface(6, 6);
let mut ax = Axes3D::new();
ax.plot_wireframe(&x, &y, &z);
let r = ax.render_png(128, 128, 72.0);
assert_eq!(r.pixmap().width(), 128);
}
#[test]
fn bar3d_renders() {
let mut ax = Axes3D::new();
ax.bar3d(
&[0.0, 1.0, 2.0],
&[0.0, 1.0, 2.0],
&[1.0, 3.0, 2.0],
0.8,
0.8,
);
let r = ax.render_png(128, 128, 72.0);
assert_eq!(r.pixmap().width(), 128);
}
fn ink(r: &crate::skia::SkiaRenderer) -> usize {
r.pixmap()
.pixels()
.iter()
.filter(|p| {
let d = p.demultiply();
(d.red(), d.green(), d.blue()) != (255, 255, 255)
})
.count()
}
#[test]
fn quiver3d_expands_bounds_over_tips_and_draws_ink() {
let mut ax = Axes3D::new();
ax.quiver3d(&[0.0], &[0.0], &[0.0], &[2.0], &[3.0], &[4.0]);
assert_eq!((ax.xr.min, ax.xr.max), (0.0, 2.0));
assert_eq!((ax.yr.min, ax.yr.max), (0.0, 3.0));
assert_eq!((ax.zr.min, ax.zr.max), (0.0, 4.0));
let mut bare = Axes3D::new();
bare.scatter3d(&[0.0, 2.0], &[0.0, 3.0], &[0.0, 4.0]);
let baseline = ink(&bare.render_png(160, 160, 72.0));
bare.quiver3d(&[0.0], &[0.0], &[0.0], &[2.0], &[3.0], &[4.0]);
let with_arrow = ink(&bare.render_png(160, 160, 72.0));
assert!(
with_arrow > baseline + 20,
"arrow must add shaft/head ink: {with_arrow} vs {baseline}"
);
}
#[test]
fn quiver3d_skips_zero_length_vectors() {
let mut ax = Axes3D::new();
ax.scatter3d(&[0.0, 1.0], &[0.0, 1.0], &[0.0, 1.0]);
let before = ink(&ax.render_png(140, 140, 72.0));
ax.quiver3d(&[0.5], &[0.5], &[0.5], &[0.0], &[0.0], &[0.0]);
let after = ink(&ax.render_png(140, 140, 72.0));
assert_eq!(before, after, "a zero vector must draw nothing");
}
#[test]
fn text3d_draws_glyphs_near_the_projected_anchor() {
let mut ax = Axes3D::new();
ax.scatter3d(&[0.0, 1.0], &[0.0, 1.0], &[0.0, 1.0]);
let before = ink(&ax.render_png(200, 200, 72.0));
ax.text3d(1.0, 1.0, 1.0, "target");
let r = ax.render_png(200, 200, 72.0);
assert!(ink(&r) > before + 30, "label must add glyph ink");
let (px, py, _) = ax.project(1.0, 1.0, 1.0, 200.0, 200.0);
let (cx, cy) = (px as i64 + 5, 200 - py as i64);
let mut near = 0;
for dy in -12..12 {
for dx in 0..45 {
let (x, y) = (cx + dx, cy + dy);
if (0..200).contains(&x)
&& (0..200).contains(&y)
&& r.pixmap().pixel(x as u32, y as u32).is_some_and(|p| {
let d = p.demultiply();
(d.red(), d.green(), d.blue()) != (255, 255, 255)
})
{
near += 1;
}
}
}
assert!(near > 10, "expected glyph ink near the anchor, got {near}");
}
#[test]
fn title_renders_ink_in_the_top_band() {
let mut ax = Axes3D::new();
ax.scatter3d(&[0.0, 1.0], &[0.0, 1.0], &[0.0, 1.0]);
ax.set_title("axes3d");
let r = ax.render_png(200, 200, 100.0);
let px = r.pixmap();
let mut ink = 0;
for y in 0..18u32 {
for x in 0..px.width() {
let d = px.pixel(x, y).unwrap().demultiply();
if (d.red(), d.green(), d.blue()) != (255, 255, 255) {
ink += 1;
}
}
}
assert!(ink > 20, "expected title ink in the top band, got {ink} px");
}
}