Import Oxide engine (Stages 0–10) under MIT license
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>
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
//! 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user