spatial/r3: implement Rotate

Updates gonum/gonum#1513.
diff --git a/spatial/r3/vector.go b/spatial/r3/vector.go
index 163903f..d87bc2a 100644
--- a/spatial/r3/vector.go
+++ b/spatial/r3/vector.go
@@ -4,7 +4,11 @@
 
 package r3
 
-import "math"
+import (
+	"math"
+
+	"gonum.org/v1/gonum/num/quat"
+)
 
 // Vec is a 3D vector.
 type Vec struct {
@@ -49,6 +53,11 @@
 	}
 }
 
+// Rotate returns a new vector, rotated by alpha around the provided axis.
+func (p Vec) Rotate(alpha float64, axis Vec) Vec {
+	return NewRotation(alpha, axis).Rotate(p)
+}
+
 // Norm returns the Euclidean norm of p
 //  |p| = sqrt(p_x^2 + p_y^2 + p_z^2).
 func Norm(p Vec) float64 {
@@ -79,3 +88,50 @@
 type Box struct {
 	Min, Max Vec
 }
+
+// TODO: possibly useful additions to the current rotation API:
+//  - create rotations from Euler angles (NewRotationFromEuler?)
+//  - create rotations from rotation matrices (NewRotationFromMatrix?)
+//  - return the equivalent Euler angles from a Rotation
+//  - return the equivalent rotation matrix from a Rotation
+//
+// Euler angles have issues (see [1] for a discussion).
+// We should think carefully before adding them in.
+// [1]: http://www.euclideanspace.com/maths/geometry/rotations/conversions/quaternionToEuler/
+
+// Rotation describes a rotation in space.
+type Rotation quat.Number
+
+// NewRotation creates a rotation by alpha, around axis.
+func NewRotation(alpha float64, axis Vec) Rotation {
+	if alpha == 0 {
+		return Rotation{Real: 1}
+	}
+	q := raise(axis)
+	sin, cos := math.Sincos(0.5 * alpha)
+	q = quat.Scale(sin/quat.Abs(q), q)
+	q.Real += cos
+	if len := quat.Abs(q); len != 1 {
+		q = quat.Scale(1/len, q)
+	}
+
+	return Rotation(q)
+}
+
+// Rotate returns the rotated vector according to the definition of rot.
+func (r Rotation) Rotate(p Vec) Vec {
+	if r.isIdentity() {
+		return p
+	}
+	qq := quat.Number(r)
+	pp := quat.Mul(quat.Mul(qq, raise(p)), quat.Conj(qq))
+	return Vec{X: pp.Imag, Y: pp.Jmag, Z: pp.Kmag}
+}
+
+func (r Rotation) isIdentity() bool {
+	return r == Rotation{Real: 1}
+}
+
+func raise(p Vec) quat.Number {
+	return quat.Number{Imag: p.X, Jmag: p.Y, Kmag: p.Z}
+}
diff --git a/spatial/r3/vector_test.go b/spatial/r3/vector_test.go
index f2d8f9b..f534b49 100644
--- a/spatial/r3/vector_test.go
+++ b/spatial/r3/vector_test.go
@@ -236,6 +236,32 @@
 	}
 }
 
+func TestRotate(t *testing.T) {
+	const tol = 1e-14
+	for _, test := range []struct {
+		v, axis Vec
+		alpha   float64
+		want    Vec
+	}{
+		{Vec{1, 0, 0}, Vec{1, 0, 0}, math.Pi / 2, Vec{1, 0, 0}},
+		{Vec{1, 0, 0}, Vec{1, 0, 0}, 0, Vec{1, 0, 0}},
+		{Vec{1, 0, 0}, Vec{1, 0, 0}, 2 * math.Pi, Vec{1, 0, 0}},
+		{Vec{1, 0, 0}, Vec{0, 0, 0}, math.Pi / 2, Vec{math.NaN(), math.NaN(), math.NaN()}},
+		{Vec{1, 0, 0}, Vec{0, 1, 0}, math.Pi / 2, Vec{0, 0, -1}},
+		{Vec{1, 0, 0}, Vec{0, 1, 0}, math.Pi, Vec{-1, 0, 0}},
+		{Vec{2, 0, 0}, Vec{0, 1, 0}, math.Pi, Vec{-2, 0, 0}},
+		{Vec{1, 2, 3}, Vec{1, 1, 1}, 2. / 3. * math.Pi, Vec{3, 1, 2}},
+	} {
+		got := test.v.Rotate(test.alpha, test.axis)
+		if !vecApproxEqual(got, test.want, tol) {
+			t.Errorf(
+				"rotate(%v, %v, %v)= %v, want=%v",
+				test.v, test.alpha, test.axis, got, test.want,
+			)
+		}
+	}
+}
+
 func vecIsNaN(v Vec) bool {
 	return math.IsNaN(v.X) && math.IsNaN(v.Y) && math.IsNaN(v.Z)
 }
@@ -250,3 +276,12 @@
 	}
 	return a == b
 }
+
+func vecApproxEqual(a, b Vec, tol float64) bool {
+	if vecIsNaNAny(a) || vecIsNaNAny(b) {
+		return vecIsNaN(a) && vecIsNaN(b)
+	}
+	return scalar.EqualWithinAbs(a.X, b.X, tol) &&
+		scalar.EqualWithinAbs(a.Y, b.Y, tol) &&
+		scalar.EqualWithinAbs(a.Z, b.Z, tol)
+}