use glam::{Affine2, Mat2, Mat3, Vec2, Vec3};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Transform2D(Mat3);
impl Default for Transform2D {
fn default() -> Self {
Self::IDENTITY
}
}
impl From<Affine2> for Transform2D {
fn from(affine: Affine2) -> Self {
Self::from_affine(affine)
}
}
impl From<&Affine2> for Transform2D {
fn from(affine: &Affine2) -> Self {
Self::from_affine(*affine)
}
}
impl std::ops::Mul for Transform2D {
type Output = Self;
fn mul(self, rhs: Self) -> Self {
Self(self.0 * rhs.0)
}
}
impl std::ops::Mul<Affine2> for Transform2D {
type Output = Self;
fn mul(self, rhs: Affine2) -> Self {
self * Self::from_affine(rhs)
}
}
impl std::ops::Mul<Transform2D> for Affine2 {
type Output = Transform2D;
fn mul(self, rhs: Transform2D) -> Transform2D {
Transform2D::from_affine(self) * rhs
}
}
impl Transform2D {
pub const IDENTITY: Self = Self(Mat3::IDENTITY);
pub fn from_affine(affine: Affine2) -> Self {
let m = affine.matrix2;
let t = affine.translation;
Self(Mat3::from_cols(
Vec3::new(m.x_axis.x, m.x_axis.y, 0.0),
Vec3::new(m.y_axis.x, m.y_axis.y, 0.0),
Vec3::new(t.x, t.y, 1.0),
))
}
pub fn to_affine(self) -> Option<Affine2> {
self.is_affine().then(|| {
let c = self.0.to_cols_array();
Affine2::from_mat2_translation(
Mat2::from_cols(Vec2::new(c[0], c[1]), Vec2::new(c[3], c[4])),
Vec2::new(c[6], c[7]),
)
})
}
pub fn is_affine(self) -> bool {
let c = self.0.to_cols_array();
c[2] == 0.0 && c[5] == 0.0 && c[8] == 1.0
}
pub fn from_column_major_4x4(m: &[f32; 16]) -> Self {
Self(Mat3::from_cols(
Vec3::new(m[0], m[1], m[3]),
Vec3::new(m[4], m[5], m[7]),
Vec3::new(m[12], m[13], m[15]),
))
}
pub fn to_column_major_4x4(self) -> [f32; 16] {
let c = self.0.to_cols_array();
[
c[0], c[1], 0.0, c[2], c[3], c[4], 0.0, c[5], 0.0, 0.0, 1.0, 0.0, c[6], c[7], 0.0, c[8],
]
}
pub fn project_point2(self, point: Vec2) -> Vec2 {
let h = self.project_homogeneous(point);
h.truncate() / h.z
}
pub fn project_homogeneous(self, point: Vec2) -> Vec3 {
self.0 * Vec3::new(point.x, point.y, 1.0)
}
pub fn inverse(self) -> Option<Self> {
if let Some(affine) = self.to_affine() {
let determinant = affine.matrix2.determinant();
if determinant.abs() <= 1e-9 || !determinant.is_finite() {
return None;
}
let inverse = affine.inverse();
return inverse.is_finite().then(|| Self::from_affine(inverse));
}
let determinant = self.0.determinant();
if determinant.abs() <= 1e-9 || !determinant.is_finite() {
return None;
}
let inverse = self.0.inverse();
inverse.is_finite().then_some(Self(inverse))
}
pub fn is_finite(self) -> bool {
self.0.is_finite()
}
pub fn determinant(self) -> f32 {
self.0.determinant()
}
pub fn transformed_bounds_of(self, min: Vec2, max: Vec2) -> Option<(Vec2, Vec2)> {
transformed_bounds(self, min, max)
}
pub fn to_cols_array(self) -> [f32; 9] {
self.0.to_cols_array()
}
pub fn max_scale_over(self, min: Vec2, max: Vec2) -> Option<f32> {
let c = self.0.to_cols_array();
let linear = Vec2::new(c[0], c[1])
.length()
.max(Vec2::new(c[3], c[4]).length());
if self.is_affine() {
return linear.is_finite().then_some(linear);
}
let corners = [min, Vec2::new(max.x, min.y), max, Vec2::new(min.x, max.y)];
let mut smallest_w = f32::INFINITY;
let mut furthest = 0.0f32;
for corner in corners {
let h = self.project_homogeneous(corner);
smallest_w = smallest_w.min(h.z);
furthest = furthest.max((h.truncate() / h.z).length());
}
if smallest_w.is_nan() || smallest_w <= VANISHING_EPSILON || !furthest.is_finite() {
return None;
}
let perspective_row = Vec2::new(c[2], c[5]).length();
let bound = (linear + furthest * perspective_row) / smallest_w;
if !bound.is_finite() {
return None;
}
Some(bound.min(linear * MAX_PERSPECTIVE_REFINEMENT))
}
}
const MAX_PERSPECTIVE_REFINEMENT: f32 = 64.0;
const VANISHING_EPSILON: f32 = 1e-6;
pub fn viewport_projection(width: u32, height: u32) -> Affine2 {
let w = if width == 0 { 1.0 } else { width as f32 };
let h = if height == 0 { 1.0 } else { height as f32 };
Affine2::from_cols(
Vec2::new(2.0 / w, 0.0),
Vec2::new(0.0, -2.0 / h),
Vec2::new(-1.0, 1.0),
)
}
pub fn max_scale(transform: &Affine2) -> f32 {
let x = transform.matrix2.x_axis.length();
let y = transform.matrix2.y_axis.length();
x.max(y)
}
pub fn preserves_axis_alignment(transform: impl Into<Transform2D>) -> bool {
let transform = transform.into();
let Some(affine) = transform.to_affine() else {
return false;
};
let m = affine.matrix2;
let magnitude = max_scale(&affine);
if magnitude == 0.0 || !magnitude.is_finite() {
return true;
}
let epsilon = 1e-6 * magnitude;
let diagonal = m.x_axis.y.abs() <= epsilon && m.y_axis.x.abs() <= epsilon;
let anti_diagonal = m.x_axis.x.abs() <= epsilon && m.y_axis.y.abs() <= epsilon;
diagonal || anti_diagonal
}
pub fn unbounded() -> (Vec2, Vec2) {
let far = f32::MAX / 4.0;
(Vec2::splat(-far), Vec2::splat(far))
}
pub fn transformed_bounds(
transform: impl Into<Transform2D>,
min: Vec2,
max: Vec2,
) -> Option<(Vec2, Vec2)> {
let transform = transform.into();
let corners = [min, Vec2::new(max.x, min.y), max, Vec2::new(min.x, max.y)];
let mut mapped = [Vec2::ZERO; 4];
for (out, corner) in mapped.iter_mut().zip(corners) {
let h = transform.project_homogeneous(corner);
if h.z.is_nan() || h.z <= VANISHING_EPSILON {
return None;
}
*out = h.truncate() / h.z;
}
Some(mapped.iter().fold(
(mapped[0], mapped[0]),
|(lo, hi): (Vec2, Vec2), c: &Vec2| (lo.min(*c), hi.max(*c)),
))
}
pub fn transform_points(points: &mut [Vec2], transform: &Affine2) {
for p in points.iter_mut() {
*p = transform.transform_point2(*p);
}
}
pub fn invert_to_local(local_to_clip: Transform2D) -> [f32; 12] {
to_local_columns(local_to_clip.inverse().unwrap_or(Transform2D::IDENTITY))
}
pub fn to_local_columns(clip_to_local: Transform2D) -> [f32; 12] {
let c = clip_to_local.to_cols_array();
let padded = [
c[0], c[1], c[2], 0.0, c[3], c[4], c[5], 0.0, c[6], c[7], c[8], 0.0,
];
if padded.iter().all(|v| v.is_finite()) {
padded
} else {
[
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0,
]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scales_translations_and_quarter_turns_keep_rectangles_rectangular() {
let quarter = std::f32::consts::FRAC_PI_2;
for transform in [
Affine2::IDENTITY,
Affine2::from_translation(Vec2::new(13.0, -4.0)),
Affine2::from_scale(Vec2::new(3.0, 0.5)),
Affine2::from_scale(Vec2::new(-1.0, 1.0)),
Affine2::from_angle(quarter),
Affine2::from_angle(-quarter),
Affine2::from_angle(2.0 * quarter),
Affine2::from_angle(quarter)
* Affine2::from_scale(Vec2::new(2.0, 7.0))
* Affine2::from_translation(Vec2::new(1.0, 1.0)),
] {
assert!(
preserves_axis_alignment(transform),
"{transform:?} was rejected"
);
}
}
#[test]
fn arbitrary_rotations_and_skews_do_not() {
let mut skew = Affine2::IDENTITY;
skew.matrix2.y_axis.x = 0.4;
for transform in [
Affine2::from_angle(0.3),
Affine2::from_angle(std::f32::consts::FRAC_PI_4),
skew,
] {
assert!(
!preserves_axis_alignment(transform),
"{transform:?} was accepted"
);
}
}
#[test]
fn the_threshold_scales_with_the_transform() {
let skew = 1e-3;
let mut large = Affine2::from_scale(Vec2::splat(1e4));
large.matrix2.y_axis.x = skew;
assert!(preserves_axis_alignment(large));
let mut small = Affine2::from_scale(Vec2::splat(1e-2));
small.matrix2.y_axis.x = skew;
assert!(!preserves_axis_alignment(small));
}
#[test]
fn a_quarter_turn_maps_a_rectangle_onto_the_other_axis() {
let quarter = Affine2::from_angle(std::f32::consts::FRAC_PI_2);
let (min, max) = transformed_bounds(quarter, Vec2::new(0.0, 0.0), Vec2::new(4.0, 1.0))
.expect("an affine always has bounds");
assert!(
(max.x - min.x - 1.0).abs() < 1e-5,
"width became {}",
max.x - min.x
);
assert!(
(max.y - min.y - 4.0).abs() < 1e-5,
"height became {}",
max.y - min.y
);
}
fn approx(a: Vec2, b: Vec2) -> bool {
(a - b).length() < 1e-5
}
#[test]
fn the_top_left_pixel_maps_to_the_top_of_clip_space() {
let p = viewport_projection(64, 64);
assert!(approx(
p.transform_point2(Vec2::new(0.0, 0.0)),
Vec2::new(-1.0, 1.0)
));
}
#[test]
fn the_bottom_right_corner_maps_to_the_bottom_of_clip_space() {
let p = viewport_projection(64, 64);
assert!(approx(
p.transform_point2(Vec2::new(64.0, 64.0)),
Vec2::new(1.0, -1.0)
));
}
#[test]
fn the_center_maps_to_the_origin() {
let p = viewport_projection(800, 600);
assert!(approx(
p.transform_point2(Vec2::new(400.0, 300.0)),
Vec2::ZERO
));
}
#[test]
fn the_y_axis_is_flipped_and_the_x_axis_is_not() {
let p = viewport_projection(100, 100);
let top = p.transform_point2(Vec2::new(50.0, 10.0));
let bottom = p.transform_point2(Vec2::new(50.0, 90.0));
assert!(top.y > bottom.y, "device Y down should map to clip Y up");
let left = p.transform_point2(Vec2::new(10.0, 50.0));
let right = p.transform_point2(Vec2::new(90.0, 50.0));
assert!(left.x < right.x, "X must not be flipped");
}
#[test]
fn a_non_square_target_scales_each_axis_independently() {
let p = viewport_projection(200, 100);
assert!(approx(
p.transform_point2(Vec2::new(200.0, 0.0)),
Vec2::new(1.0, 1.0)
));
assert!(approx(
p.transform_point2(Vec2::new(0.0, 100.0)),
Vec2::new(-1.0, -1.0)
));
}
#[test]
fn a_zero_sized_target_does_not_produce_non_finite_coordinates() {
let p = viewport_projection(0, 0);
let v = p.transform_point2(Vec2::new(1.0, 1.0));
assert!(v.is_finite(), "got {v:?}");
}
#[test]
fn max_scale_reports_the_larger_axis() {
let t = Affine2::from_scale(Vec2::new(3.0, 7.0));
assert!((max_scale(&t) - 7.0).abs() < 1e-5);
assert!((max_scale(&Affine2::IDENTITY) - 1.0).abs() < 1e-5);
}
#[test]
fn max_scale_is_unchanged_by_rotation_and_translation() {
let rotated = Affine2::from_angle(0.9) * Affine2::from_scale(Vec2::splat(2.0));
assert!((max_scale(&rotated) - 2.0).abs() < 1e-4);
let translated = Affine2::from_translation(Vec2::new(100.0, -50.0));
assert!((max_scale(&translated) - 1.0).abs() < 1e-5);
}
#[test]
fn transforming_points_matches_transforming_them_one_at_a_time() {
let t =
Affine2::from_scale_angle_translation(Vec2::new(2.0, 3.0), 0.4, Vec2::new(5.0, -1.0));
let source = [Vec2::new(1.0, 2.0), Vec2::new(-3.0, 4.0), Vec2::ZERO];
let mut batch = source;
transform_points(&mut batch, &t);
for (i, p) in source.iter().enumerate() {
assert!(approx(batch[i], t.transform_point2(*p)));
}
}
#[test]
fn a_homography_maps_a_square_to_the_quadrilateral_its_corners_describe() {
let t = Transform2D::from_column_major_4x4(&[
1.0, 0.0, 0.0, 0.002, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
]);
assert!(!t.is_affine());
let near = t.project_point2(Vec2::new(0.0, 100.0));
let far = t.project_point2(Vec2::new(100.0, 100.0));
assert!(approx(near, Vec2::new(0.0, 100.0)));
assert!(approx(far, Vec2::new(100.0 / 1.2, 100.0 / 1.2)));
let homogeneous = t.project_homogeneous(Vec2::new(100.0, 100.0));
assert!((homogeneous.z - 1.2).abs() < 1e-5);
}
#[test]
fn an_affine_lifts_and_lowers_without_moving_a_point() {
let affine = Affine2::from_angle(0.3)
* Affine2::from_scale(Vec2::new(2.0, 7.0))
* Affine2::from_translation(Vec2::new(11.0, -3.0));
let lifted = Transform2D::from(affine);
assert!(lifted.is_affine());
assert_eq!(lifted.to_affine(), Some(affine));
for p in [Vec2::ZERO, Vec2::new(5.0, -2.0), Vec2::new(-100.0, 40.0)] {
assert!(approx(lifted.project_point2(p), affine.transform_point2(p)));
assert_eq!(lifted.project_homogeneous(p).z, 1.0);
}
}
#[test]
fn a_transform_round_trips_through_the_four_by_four_form() {
let t = Transform2D::from_column_major_4x4(&[
2.0, 0.5, 0.0, 0.003, -1.0, 3.0, 0.0, 0.007, 0.0, 0.0, 1.0, 0.0, 9.0, -4.0, 0.0, 1.5,
]);
assert_eq!(
Transform2D::from_column_major_4x4(&t.to_column_major_4x4()),
t
);
}
#[test]
fn composing_two_homographies_agrees_with_transforming_twice() {
let a = Transform2D::from_column_major_4x4(&[
1.0, 0.0, 0.0, 0.002, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 5.0, 0.0, 0.0, 1.0,
]);
let b = Transform2D::from(Affine2::from_angle(0.4) * Affine2::from_scale(Vec2::splat(2.0)));
for p in [Vec2::new(3.0, 4.0), Vec2::new(-20.0, 60.0)] {
assert!(approx(
(a * b).project_point2(p),
a.project_point2(b.project_point2(p))
));
}
}
#[test]
fn composing_two_affines_stays_exactly_affine() {
let a = Transform2D::from(Affine2::from_angle(0.3));
let b = Transform2D::from(Affine2::from_scale(Vec2::new(1e6, 1e-6)));
assert!((a * b).is_affine());
assert!((b * a).is_affine());
}
#[test]
fn the_inverse_of_a_projective_map_undoes_it() {
let t = Transform2D::from_column_major_4x4(&[
1.0, 0.0, 0.0, 0.002, 0.0, 1.0, 0.0, 0.001, 0.0, 0.0, 1.0, 0.0, 7.0, -2.0, 0.0, 1.0,
]);
let inverse = t.inverse().expect("invertible");
for p in [Vec2::new(10.0, 20.0), Vec2::new(-30.0, 5.0)] {
assert!(approx(inverse.project_point2(t.project_point2(p)), p));
}
}
#[test]
fn the_inverse_keeps_the_sign_of_the_divisor() {
let t = Transform2D::from_column_major_4x4(&[
1.0, 0.0, 0.0, 0.002, 0.0, 1.0, 0.0, 0.001, 0.0, 0.0, 1.0, 0.0, 7.0, -2.0, 0.0, 1.0,
]);
let inverse = t.inverse().expect("invertible");
for p in [Vec2::new(10.0, 20.0), Vec2::new(-30.0, 5.0)] {
let forward = t.project_homogeneous(p);
assert!(forward.z > 0.0, "test point is in front");
let back = inverse.project_homogeneous(forward.truncate() / forward.z);
assert!(back.z > 0.0, "divisor flipped sign through the inverse");
}
}
#[test]
fn the_inverse_of_an_affine_is_still_exactly_affine() {
let affine = Affine2::from_angle(0.37)
* Affine2::from_scale(Vec2::new(1e4, 3e-3))
* Affine2::from_translation(Vec2::new(1234.0, -99.0));
let inverse = Transform2D::from(affine).inverse().expect("invertible");
assert!(inverse.is_affine());
let c = inverse.to_cols_array();
assert_eq!((c[2], c[5], c[8]), (0.0, 0.0, 1.0));
}
#[test]
fn a_singular_matrix_has_no_inverse_to_report() {
assert!(Transform2D::from(Affine2::from_scale(Vec2::new(0.0, 1.0)))
.inverse()
.is_none());
assert!(Transform2D::from(Affine2::from_scale(Vec2::ZERO))
.inverse()
.is_none());
let degenerate = Transform2D::from_column_major_4x4(&[
1.0, 2.0, 0.0, 3.0, 1.0, 2.0, 0.0, 3.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
]);
assert!(degenerate.inverse().is_none());
}
fn receding() -> Transform2D {
Transform2D::from_column_major_4x4(&[
1.0, 0.0, 0.0, 0.002, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
])
}
#[test]
fn an_affine_is_bounded_by_exactly_what_max_scale_reports() {
for affine in [
Affine2::IDENTITY,
Affine2::from_scale(Vec2::new(3.0, 0.5)),
Affine2::from_angle(0.7) * Affine2::from_scale(Vec2::new(9.0, 2.0)),
Affine2::from_translation(Vec2::new(500.0, -20.0)),
] {
let lifted = Transform2D::from(affine);
for (min, max) in [
(Vec2::ZERO, Vec2::splat(1.0)),
(Vec2::splat(-1e4), Vec2::splat(1e4)),
] {
assert_eq!(
lifted.max_scale_over(min, max),
Some(max_scale(&affine)),
"{affine:?} over {min:?}..{max:?}"
);
}
}
}
#[test]
fn perspective_is_bounded_more_loosely_where_it_magnifies() {
let t = receding();
let near = t
.max_scale_over(Vec2::new(-400.0, -10.0), Vec2::new(-300.0, 10.0))
.expect("in front");
let far = t
.max_scale_over(Vec2::new(300.0, -10.0), Vec2::new(400.0, 10.0))
.expect("in front");
assert!(near > far, "near {near} should exceed far {far}");
assert!(far > 0.0);
}
#[test]
fn a_box_reaching_the_vanishing_line_has_no_bound_to_give() {
let t = receding();
assert_eq!(
t.max_scale_over(Vec2::new(-600.0, -10.0), Vec2::new(-400.0, 10.0)),
None
);
assert_eq!(
t.max_scale_over(Vec2::new(-500.0, -10.0), Vec2::new(-499.0, 10.0)),
None
);
}
#[test]
fn the_bound_is_capped_against_what_the_same_shape_costs_flat() {
let t = receding();
let just_in_front = t
.max_scale_over(Vec2::new(-499.9, -1.0), Vec2::new(-499.5, 1.0))
.expect("in front");
assert_eq!(just_in_front, MAX_PERSPECTIVE_REFINEMENT);
}
#[test]
fn a_projective_row_is_not_axis_preserving_however_plain_the_two_by_two_looks() {
for row in [[0.002, 0.0], [0.0, 0.002], [-1e-4, 3e-5]] {
let projective = Transform2D::from_column_major_4x4(&[
3.0, 0.0, 0.0, row[0], 0.0, 7.0, 0.0, row[1], 0.0, 0.0, 1.0, 0.0, 20.0, -5.0, 0.0, 1.0,
]);
assert!(preserves_axis_alignment(Affine2::from_scale(Vec2::new(
3.0, 7.0
))));
assert!(
!preserves_axis_alignment(projective),
"a perspective row of {row:?} was accepted as axis-preserving"
);
}
}
#[test]
fn an_affine_always_has_bounds_and_a_box_across_the_horizon_has_none() {
let t = receding();
let (min, max) = t
.transformed_bounds_of(Vec2::new(-100.0, -50.0), Vec2::new(100.0, 50.0))
.expect("in front");
assert!(min.x < max.x && min.y < max.y);
assert_eq!(
t.transformed_bounds_of(Vec2::new(-600.0, -50.0), Vec2::new(-400.0, 50.0)),
None
);
assert!(transformed_bounds(
Affine2::from_scale(Vec2::new(1e6, 1e-6)),
Vec2::splat(-1e3),
Vec2::splat(1e3)
)
.is_some());
}
#[test]
fn unbounded_is_large_and_still_arithmetic() {
let (min, max) = unbounded();
assert!(min.x < -1e30 && max.x > 1e30);
assert!(
(max - min).is_finite(),
"a reach added to this must stay finite"
);
assert!((min - Vec2::splat(1e6)).is_finite());
}
}