#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Affine2D {
a: f64,
b: f64,
c: f64,
d: f64,
e: f64,
f: f64,
}
impl Default for Affine2D {
fn default() -> Self {
Self::identity()
}
}
impl Affine2D {
#[must_use]
pub const fn new(a: f64, b: f64, c: f64, d: f64, e: f64, f: f64) -> Self {
Self { a, b, c, d, e, f }
}
#[must_use]
pub const fn identity() -> Self {
Self::new(1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
}
#[must_use]
pub const fn from_translation(tx: f64, ty: f64) -> Self {
Self::new(1.0, 0.0, 0.0, 1.0, tx, ty)
}
#[must_use]
pub const fn from_scale(sx: f64, sy: f64) -> Self {
Self::new(sx, 0.0, 0.0, sy, 0.0, 0.0)
}
#[must_use]
pub fn from_rotation(theta_rad: f64) -> Self {
let (s, co) = theta_rad.sin_cos();
Self::new(co, s, -s, co, 0.0, 0.0)
}
#[must_use]
pub fn from_rotation_deg(deg: f64) -> Self {
Self::from_rotation(deg.to_radians())
}
#[must_use]
pub fn from_skew(x_rad: f64, y_rad: f64) -> Self {
Self::new(1.0, y_rad.tan(), x_rad.tan(), 1.0, 0.0, 0.0)
}
#[must_use]
pub fn then(&self, other: &Affine2D) -> Affine2D {
Affine2D {
a: other.a * self.a + other.c * self.b,
b: other.b * self.a + other.d * self.b,
c: other.a * self.c + other.c * self.d,
d: other.b * self.c + other.d * self.d,
e: other.a * self.e + other.c * self.f + other.e,
f: other.b * self.e + other.d * self.f + other.f,
}
}
#[must_use]
pub fn translate(&self, tx: f64, ty: f64) -> Self {
self.then(&Self::from_translation(tx, ty))
}
#[must_use]
pub fn scale(&self, sx: f64, sy: f64) -> Self {
self.then(&Self::from_scale(sx, sy))
}
#[must_use]
pub fn rotate(&self, theta_rad: f64) -> Self {
self.then(&Self::from_rotation(theta_rad))
}
#[must_use]
pub fn rotate_deg(&self, deg: f64) -> Self {
self.then(&Self::from_rotation_deg(deg))
}
#[must_use]
pub fn skew(&self, x_rad: f64, y_rad: f64) -> Self {
self.then(&Self::from_skew(x_rad, y_rad))
}
#[must_use]
pub fn transform_point(&self, p: (f64, f64)) -> (f64, f64) {
let (x, y) = p;
(
self.a * x + self.c * y + self.e,
self.b * x + self.d * y + self.f,
)
}
#[must_use]
pub fn transform_points(&self, points: &[(f64, f64)]) -> Vec<(f64, f64)> {
points.iter().map(|&p| self.transform_point(p)).collect()
}
#[must_use]
pub fn determinant(&self) -> f64 {
self.a * self.d - self.b * self.c
}
#[must_use]
pub fn inverted(&self) -> Option<Affine2D> {
let det = self.determinant();
if det == 0.0 {
return None;
}
let inv_det = 1.0 / det;
let a = self.d * inv_det;
let b = -self.b * inv_det;
let c = -self.c * inv_det;
let d = self.a * inv_det;
let e = -(a * self.e + c * self.f);
let f = -(b * self.e + d * self.f);
Some(Affine2D { a, b, c, d, e, f })
}
#[must_use]
pub fn is_identity(&self) -> bool {
*self == Self::identity()
}
#[must_use]
pub const fn matrix(&self) -> [f64; 6] {
[self.a, self.b, self.c, self.d, self.e, self.f]
}
}
impl std::ops::Mul for Affine2D {
type Output = Affine2D;
fn mul(self, rhs: Affine2D) -> Affine2D {
rhs.then(&self)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn approx(a: f64, b: f64) {
assert!((a - b).abs() < 1e-12, "expected {b}, got {a}");
}
fn approx_pt(p: (f64, f64), q: (f64, f64)) {
approx(p.0, q.0);
approx(p.1, q.1);
}
#[test]
fn identity_leaves_points() {
let id = Affine2D::identity();
assert!(id.is_identity());
approx_pt(id.transform_point((3.0, -7.0)), (3.0, -7.0));
approx(id.determinant(), 1.0);
}
#[test]
fn rotation_deg_90_maps_x_to_y() {
let r = Affine2D::from_rotation_deg(90.0);
approx_pt(r.transform_point((1.0, 0.0)), (0.0, 1.0));
approx_pt(r.transform_point((0.0, 1.0)), (-1.0, 0.0));
}
#[test]
fn translation_and_scale() {
let t = Affine2D::from_translation(2.0, 3.0);
approx_pt(t.transform_point((1.0, 1.0)), (3.0, 4.0));
let s = Affine2D::from_scale(2.0, 4.0);
approx_pt(s.transform_point((1.0, 1.0)), (2.0, 4.0));
}
#[test]
fn composition_order_translate_then_scale() {
let composed = Affine2D::from_translation(2.0, 3.0).scale(2.0, 2.0);
approx_pt(composed.transform_point((1.0, 1.0)), (6.0, 8.0));
let t = Affine2D::from_translation(2.0, 3.0);
let s = Affine2D::from_scale(2.0, 2.0);
let chained = t.then(&s);
approx_pt(
chained.transform_point((1.0, 1.0)),
s.transform_point(t.transform_point((1.0, 1.0))),
);
}
#[test]
fn scale_then_translate_differs() {
let composed = Affine2D::from_scale(2.0, 2.0).translate(2.0, 3.0);
approx_pt(composed.transform_point((1.0, 1.0)), (4.0, 5.0));
}
#[test]
fn mul_is_reverse_of_then() {
let t = Affine2D::from_translation(2.0, 3.0);
let s = Affine2D::from_scale(2.0, 2.0);
let by_mul = s * t;
let by_then = t.then(&s);
approx_pt(
by_mul.transform_point((1.0, 1.0)),
by_then.transform_point((1.0, 1.0)),
);
}
#[test]
fn inverted_round_trips() {
let m = Affine2D::from_translation(2.0, 3.0)
.scale(2.0, 4.0)
.rotate_deg(30.0);
let inv = m.inverted().expect("invertible");
let p = (1.7, -2.3);
approx_pt(inv.transform_point(m.transform_point(p)), p);
approx_pt(m.transform_point(inv.transform_point(p)), p);
}
#[test]
fn singular_has_no_inverse() {
let singular = Affine2D::from_scale(0.0, 1.0);
assert!(singular.inverted().is_none());
approx(singular.determinant(), 0.0);
}
#[test]
fn skew_matches_matplotlib_form() {
let x = 0.3_f64;
let y = -0.2_f64;
let sk = Affine2D::from_skew(x, y);
approx_pt(
sk.transform_point((1.0, 2.0)),
(1.0 + x.tan() * 2.0, y.tan() * 1.0 + 2.0),
);
}
#[test]
fn transform_points_batch() {
let s = Affine2D::from_scale(2.0, 3.0);
let out = s.transform_points(&[(1.0, 1.0), (2.0, 0.0)]);
approx_pt(out[0], (2.0, 3.0));
approx_pt(out[1], (4.0, 0.0));
}
#[test]
fn matrix_round_trip() {
let m = Affine2D::new(1.0, 2.0, 3.0, 4.0, 5.0, 6.0);
assert_eq!(m.matrix(), [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
}
}