9eead719b0
Full project snapshot migrated to new Gitea remote without history: engine, editor, physics, script, examples, tests, docs, and assets. Relicensed from GPLv3 to MIT and updated repo URLs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
373 lines
12 KiB
Rust
373 lines
12 KiB
Rust
//! Affine [`Transform`]: translation, rotation, and (non-uniform) scale.
|
|
//!
|
|
//! A `Transform` is the canonical way to place an object in space. It composes
|
|
//! as `parent * child`, matching the convention used by the scene graph in
|
|
//! later stages. Internally it is stored in decomposed (TRS) form so that
|
|
//! individual components stay editable without matrix round-trips.
|
|
|
|
use glam::{Affine3A, Mat4, Quat, Vec3};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// A 3D affine transform stored as translation, rotation, and scale.
|
|
///
|
|
/// The effective matrix is `T * R * S` (scale applied first, then rotation,
|
|
/// then translation), which is the standard convention for scene hierarchies.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, crate::reflect::Reflect)]
|
|
pub struct Transform {
|
|
/// World/local-space position.
|
|
pub translation: Vec3,
|
|
/// Orientation as a unit quaternion.
|
|
pub rotation: Quat,
|
|
/// Per-axis scale. May be non-uniform; zero or negative components are
|
|
/// permitted but make the transform non-invertible / mirror-inducing.
|
|
pub scale: Vec3,
|
|
}
|
|
|
|
impl Default for Transform {
|
|
/// The identity transform: no translation, no rotation, unit scale.
|
|
fn default() -> Self {
|
|
Self::IDENTITY
|
|
}
|
|
}
|
|
|
|
impl Transform {
|
|
/// The identity transform.
|
|
pub const IDENTITY: Self = Self {
|
|
translation: Vec3::ZERO,
|
|
rotation: Quat::IDENTITY,
|
|
scale: Vec3::ONE,
|
|
};
|
|
|
|
/// Creates a transform from a translation only (identity rotation, unit scale).
|
|
#[inline]
|
|
pub const fn from_translation(translation: Vec3) -> Self {
|
|
Self {
|
|
translation,
|
|
rotation: Quat::IDENTITY,
|
|
scale: Vec3::ONE,
|
|
}
|
|
}
|
|
|
|
/// Creates a transform from a rotation only.
|
|
#[inline]
|
|
pub const fn from_rotation(rotation: Quat) -> Self {
|
|
Self {
|
|
translation: Vec3::ZERO,
|
|
rotation,
|
|
scale: Vec3::ONE,
|
|
}
|
|
}
|
|
|
|
/// Creates a transform from a uniform scale.
|
|
#[inline]
|
|
pub const fn from_scale(scale: Vec3) -> Self {
|
|
Self {
|
|
translation: Vec3::ZERO,
|
|
rotation: Quat::IDENTITY,
|
|
scale,
|
|
}
|
|
}
|
|
|
|
/// Creates a transform from all three components.
|
|
#[inline]
|
|
pub const fn from_trs(translation: Vec3, rotation: Quat, scale: Vec3) -> Self {
|
|
Self {
|
|
translation,
|
|
rotation,
|
|
scale,
|
|
}
|
|
}
|
|
|
|
/// Decomposes a 4x4 matrix back into a TRS transform.
|
|
///
|
|
/// Negative determinants (mirrored matrices) are handled by `glam`'s
|
|
/// decomposition, which folds the sign into the scale.
|
|
#[inline]
|
|
pub fn from_matrix(matrix: Mat4) -> Self {
|
|
let (scale, rotation, translation) = matrix.to_scale_rotation_translation();
|
|
Self {
|
|
translation,
|
|
rotation,
|
|
scale,
|
|
}
|
|
}
|
|
|
|
/// Returns the equivalent 4x4 homogeneous matrix.
|
|
#[inline]
|
|
pub fn to_matrix(&self) -> Mat4 {
|
|
Mat4::from_scale_rotation_translation(self.scale, self.rotation, self.translation)
|
|
}
|
|
|
|
/// Returns the equivalent [`Affine3A`], which is cheaper to compose than a
|
|
/// full `Mat4` and is what the renderer/scene graph use internally.
|
|
#[inline]
|
|
pub fn to_affine(&self) -> Affine3A {
|
|
Affine3A::from_scale_rotation_translation(self.scale, self.rotation, self.translation)
|
|
}
|
|
|
|
/// Composes two transforms: `self * rhs` applies `rhs` first, then `self`.
|
|
///
|
|
/// This is exact for the translation and rotation channels. When either
|
|
/// operand carries non-uniform scale combined with rotation, the true
|
|
/// product is no longer a pure TRS transform; in that case the result is
|
|
/// re-decomposed from the composed matrix so the returned `Transform`
|
|
/// remains the closest TRS approximation. For uniform scale (the common
|
|
/// scene-graph case) the composition is exact.
|
|
#[inline]
|
|
pub fn mul_transform(&self, rhs: &Transform) -> Transform {
|
|
// Fast path: uniform scale composes exactly in TRS form.
|
|
if is_uniform(self.scale) {
|
|
let scale = self.scale * rhs.scale;
|
|
let rotation = self.rotation * rhs.rotation;
|
|
let translation = self.translation + self.rotation * (self.scale * rhs.translation);
|
|
Transform {
|
|
translation,
|
|
rotation,
|
|
scale,
|
|
}
|
|
} else {
|
|
Transform::from_matrix(self.to_matrix() * rhs.to_matrix())
|
|
}
|
|
}
|
|
|
|
/// Transforms a point (affected by translation, rotation, and scale).
|
|
#[inline]
|
|
pub fn transform_point(&self, point: Vec3) -> Vec3 {
|
|
self.translation + self.rotation * (self.scale * point)
|
|
}
|
|
|
|
/// Transforms a direction vector (rotation and scale only, no translation).
|
|
#[inline]
|
|
pub fn transform_vector(&self, vector: Vec3) -> Vec3 {
|
|
self.rotation * (self.scale * vector)
|
|
}
|
|
|
|
/// Returns the inverse transform, such that
|
|
/// `t.mul_transform(&t.inverse())` is approximately the identity.
|
|
///
|
|
/// # Panics
|
|
/// Does not panic, but if any scale component is zero the inverse scale
|
|
/// will contain infinities — the transform is not invertible in that case.
|
|
#[inline]
|
|
pub fn inverse(&self) -> Transform {
|
|
let inv_scale = Vec3::ONE / self.scale;
|
|
let inv_rotation = self.rotation.inverse();
|
|
let inv_translation = inv_rotation * (inv_scale * -self.translation);
|
|
Transform {
|
|
translation: inv_translation,
|
|
rotation: inv_rotation,
|
|
scale: inv_scale,
|
|
}
|
|
}
|
|
|
|
/// The local forward direction (`-Z`) rotated into this transform's space.
|
|
#[inline]
|
|
pub fn forward(&self) -> Vec3 {
|
|
self.rotation * Vec3::NEG_Z
|
|
}
|
|
|
|
/// The local up direction (`+Y`) rotated into this transform's space.
|
|
#[inline]
|
|
pub fn up(&self) -> Vec3 {
|
|
self.rotation * Vec3::Y
|
|
}
|
|
|
|
/// The local right direction (`+X`) rotated into this transform's space.
|
|
#[inline]
|
|
pub fn right(&self) -> Vec3 {
|
|
self.rotation * Vec3::X
|
|
}
|
|
|
|
/// Builds a transform positioned at `eye` looking toward `target`.
|
|
///
|
|
/// `up` is the reference up vector. Returns the identity rotation if `eye`
|
|
/// and `target` coincide.
|
|
pub fn looking_at(eye: Vec3, target: Vec3, up: Vec3) -> Transform {
|
|
let forward = target - eye;
|
|
let rotation = if forward.length_squared() <= f32::EPSILON {
|
|
Quat::IDENTITY
|
|
} else {
|
|
// glam's look_to is right-handed with -Z forward; invert the view
|
|
// rotation to get an object-space orientation.
|
|
Quat::from_mat4(&Mat4::look_to_rh(eye, forward.normalize(), up)).inverse()
|
|
};
|
|
Transform {
|
|
translation: eye,
|
|
rotation,
|
|
scale: Vec3::ONE,
|
|
}
|
|
}
|
|
|
|
/// Returns `true` if every component is finite (no NaN/inf).
|
|
#[inline]
|
|
pub fn is_finite(&self) -> bool {
|
|
self.translation.is_finite() && self.rotation.is_finite() && self.scale.is_finite()
|
|
}
|
|
}
|
|
|
|
/// Returns `true` if all three components of `scale` are equal.
|
|
#[inline]
|
|
fn is_uniform(scale: Vec3) -> bool {
|
|
(scale.x - scale.y).abs() <= f32::EPSILON && (scale.y - scale.z).abs() <= f32::EPSILON
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::f32::consts::{FRAC_PI_2, PI};
|
|
|
|
const EPS: f32 = 1e-4;
|
|
|
|
fn approx_vec(a: Vec3, b: Vec3) -> bool {
|
|
(a - b).length() <= EPS
|
|
}
|
|
|
|
#[test]
|
|
fn identity_is_default() {
|
|
assert_eq!(Transform::default(), Transform::IDENTITY);
|
|
let p = Vec3::new(1.0, 2.0, 3.0);
|
|
assert_eq!(Transform::IDENTITY.transform_point(p), p);
|
|
}
|
|
|
|
#[test]
|
|
fn translation_moves_points() {
|
|
let t = Transform::from_translation(Vec3::new(1.0, 2.0, 3.0));
|
|
assert!(approx_vec(
|
|
t.transform_point(Vec3::ZERO),
|
|
Vec3::new(1.0, 2.0, 3.0)
|
|
));
|
|
// Vectors ignore translation.
|
|
assert!(approx_vec(t.transform_vector(Vec3::X), Vec3::X));
|
|
}
|
|
|
|
#[test]
|
|
fn rotation_rotates_points() {
|
|
let t = Transform::from_rotation(Quat::from_rotation_z(FRAC_PI_2));
|
|
assert!(approx_vec(t.transform_point(Vec3::X), Vec3::Y));
|
|
}
|
|
|
|
#[test]
|
|
fn scale_scales_points() {
|
|
let t = Transform::from_scale(Vec3::new(2.0, 3.0, 4.0));
|
|
assert!(approx_vec(
|
|
t.transform_point(Vec3::ONE),
|
|
Vec3::new(2.0, 3.0, 4.0)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn matrix_round_trip() {
|
|
let t = Transform::from_trs(
|
|
Vec3::new(5.0, -2.0, 1.0),
|
|
Quat::from_euler(glam::EulerRot::XYZ, 0.3, -0.7, 1.1),
|
|
Vec3::new(2.0, 2.0, 2.0),
|
|
);
|
|
let back = Transform::from_matrix(t.to_matrix());
|
|
assert!(approx_vec(t.translation, back.translation));
|
|
assert!(approx_vec(t.scale, back.scale));
|
|
// Quaternions q and -q represent the same rotation.
|
|
let dot = t.rotation.dot(back.rotation).abs();
|
|
assert!((dot - 1.0).abs() <= EPS, "rotation mismatch: dot={dot}");
|
|
}
|
|
|
|
#[test]
|
|
fn inverse_cancels() {
|
|
let t = Transform::from_trs(
|
|
Vec3::new(3.0, 4.0, 5.0),
|
|
Quat::from_rotation_y(0.9),
|
|
Vec3::splat(2.0),
|
|
);
|
|
let id = t.mul_transform(&t.inverse());
|
|
assert!(approx_vec(id.translation, Vec3::ZERO));
|
|
assert!(approx_vec(id.scale, Vec3::ONE));
|
|
assert!(approx_vec(
|
|
id.transform_point(Vec3::new(7.0, 8.0, 9.0)),
|
|
Vec3::new(7.0, 8.0, 9.0)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn composition_matches_matrix() {
|
|
let a = Transform::from_trs(
|
|
Vec3::new(1.0, 0.0, -2.0),
|
|
Quat::from_rotation_x(0.4),
|
|
Vec3::splat(1.5),
|
|
);
|
|
let b = Transform::from_trs(
|
|
Vec3::new(-3.0, 2.0, 1.0),
|
|
Quat::from_rotation_z(-0.8),
|
|
Vec3::splat(0.5),
|
|
);
|
|
let composed = a.mul_transform(&b);
|
|
let p = Vec3::new(2.0, -1.0, 3.0);
|
|
let via_transform = composed.transform_point(p);
|
|
let via_matrix = (a.to_matrix() * b.to_matrix()).transform_point3(p);
|
|
assert!(approx_vec(via_transform, via_matrix));
|
|
}
|
|
|
|
#[test]
|
|
fn nonuniform_composition_falls_back_to_matrix() {
|
|
let a = Transform::from_trs(
|
|
Vec3::new(0.0, 1.0, 0.0),
|
|
Quat::from_rotation_z(FRAC_PI_2),
|
|
Vec3::new(2.0, 1.0, 1.0),
|
|
);
|
|
let b = Transform::from_trs(
|
|
Vec3::new(1.0, 0.0, 0.0),
|
|
Quat::IDENTITY,
|
|
Vec3::new(1.0, 3.0, 1.0),
|
|
);
|
|
let composed = a.mul_transform(&b);
|
|
let p = Vec3::new(1.0, 2.0, -1.0);
|
|
let via_transform = composed.transform_point(p);
|
|
let via_matrix = (a.to_matrix() * b.to_matrix()).transform_point3(p);
|
|
// Re-decomposition keeps this close even with non-uniform scale.
|
|
assert!(
|
|
approx_vec(via_transform, via_matrix),
|
|
"{via_transform} vs {via_matrix}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn zero_scale_is_non_invertible() {
|
|
let t = Transform::from_scale(Vec3::new(0.0, 1.0, 1.0));
|
|
let inv = t.inverse();
|
|
assert!(!inv.scale.x.is_finite());
|
|
assert!(t.is_finite()); // the forward transform itself is still finite
|
|
}
|
|
|
|
#[test]
|
|
fn gimbal_lock_path_stays_stable() {
|
|
// Pitch to +90° (a classic gimbal-lock orientation) and confirm the
|
|
// basis vectors remain orthonormal after round-tripping through a matrix.
|
|
let t =
|
|
Transform::from_rotation(Quat::from_euler(glam::EulerRot::YXZ, 0.0, FRAC_PI_2, 0.0));
|
|
let back = Transform::from_matrix(t.to_matrix());
|
|
assert!(approx_vec(back.forward(), t.forward()));
|
|
assert!(approx_vec(back.up(), t.up()));
|
|
// Orthonormality.
|
|
assert!(t.forward().dot(t.up()).abs() <= EPS);
|
|
assert!(t.right().dot(t.up()).abs() <= EPS);
|
|
}
|
|
|
|
#[test]
|
|
fn looking_at_faces_target() {
|
|
let t = Transform::looking_at(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y);
|
|
// Forward should point toward the target (-Z world direction).
|
|
assert!(approx_vec(t.forward(), Vec3::NEG_Z));
|
|
}
|
|
|
|
#[test]
|
|
fn looking_at_degenerate_is_identity_rotation() {
|
|
let t = Transform::looking_at(Vec3::ONE, Vec3::ONE, Vec3::Y);
|
|
assert_eq!(t.rotation, Quat::IDENTITY);
|
|
}
|
|
|
|
#[test]
|
|
fn basis_vectors_for_half_turn() {
|
|
let t = Transform::from_rotation(Quat::from_rotation_y(PI));
|
|
assert!(approx_vec(t.forward(), Vec3::Z));
|
|
assert!(approx_vec(t.right(), Vec3::NEG_X));
|
|
}
|
|
}
|