Effigy/Xform.cs
using System;
namespace Effigy;
/// <summary>
/// A rigid-plus-scale transform: a 3x3 basis and a translation.
///
/// Not a full 4x4 — nothing here needs projection, and keeping it to 12 floats makes the mirror
/// case easier to reason about. Mirroring is the reason this type exists at all rather than a
/// position/rotation/scale triple: a mirror is a basis with negative determinant, which no
/// non-negative scale can express.
/// </summary>
public readonly struct Xform
{
// Column vectors of the basis: where the unit X, Y and Z axes land.
public readonly Vec3 X, Y, Z, Origin;
public Xform( Vec3 x, Vec3 y, Vec3 z, Vec3 origin )
{
X = x;
Y = y;
Z = z;
Origin = origin;
}
public static readonly Xform Identity = new(
new Vec3( 1, 0, 0 ), new Vec3( 0, 1, 0 ), new Vec3( 0, 0, 1 ), Vec3.Zero );
public Vec3 TransformPoint( Vec3 p ) => X * p.x + Y * p.y + Z * p.z + Origin;
/// <summary>Directions ignore translation — used for normals and axes.</summary>
public Vec3 TransformDirection( Vec3 d ) => X * d.x + Y * d.y + Z * d.z;
/// <summary>
/// Negative when the transform flips handedness, which happens for a mirror and for an odd
/// number of negative scale axes.
///
/// THIS IS LOAD-BEARING. A flipped transform turns every face inside out — the geometry looks
/// correct in a solid render right up until you notice the lighting is wrong and the normals
/// point into the model. Anything applying an Xform to a mesh has to check this and reverse
/// the winding. See MeshTransform.Apply.
/// </summary>
public float Determinant =>
X.x * (Y.y * Z.z - Y.z * Z.y)
- Y.x * (X.y * Z.z - X.z * Z.y)
+ Z.x * (X.y * Y.z - X.z * Y.y);
public bool FlipsWinding => Determinant < 0f;
public static Xform Translate( Vec3 offset ) =>
new( Identity.X, Identity.Y, Identity.Z, offset );
public static Xform Scale( Vec3 s ) =>
new( new Vec3( s.x, 0, 0 ), new Vec3( 0, s.y, 0 ), new Vec3( 0, 0, s.z ), Vec3.Zero );
/// <summary>Rotation about an arbitrary axis through the origin, via Rodrigues' formula.</summary>
public static Xform Rotate( Vec3 axis, float radians )
{
var a = axis.Normal;
if ( a.LengthSquared < 0.5f )
return Identity;
var c = MathF.Cos( radians );
var s = MathF.Sin( radians );
var t = 1f - c;
return new Xform(
new Vec3( t * a.x * a.x + c, t * a.x * a.y + s * a.z, t * a.x * a.z - s * a.y ),
new Vec3( t * a.x * a.y - s * a.z, t * a.y * a.y + c, t * a.y * a.z + s * a.x ),
new Vec3( t * a.x * a.z + s * a.y, t * a.y * a.z - s * a.x, t * a.z * a.z + c ),
Vec3.Zero );
}
/// <summary>Reflection in the plane through `point` with the given normal.</summary>
public static Xform Mirror( Vec3 planePoint, Vec3 planeNormal )
{
var n = planeNormal.Normal;
if ( n.LengthSquared < 0.5f )
return Identity;
// Householder reflection: I - 2nn^T, then translated so the plane passes through the point.
Vec3 Reflect( Vec3 v ) => v - n * (2f * Vec3.Dot( v, n ));
var basisX = Reflect( new Vec3( 1, 0, 0 ) );
var basisY = Reflect( new Vec3( 0, 1, 0 ) );
var basisZ = Reflect( new Vec3( 0, 0, 1 ) );
var origin = n * (2f * Vec3.Dot( planePoint, n ));
return new Xform( basisX, basisY, basisZ, origin );
}
/// <summary>Rotation about an axis that does not pass through the origin.</summary>
public static Xform RotateAbout( Vec3 axisPoint, Vec3 axisDirection, float radians )
{
var r = Rotate( axisDirection, radians );
return Translate( axisPoint ) * r * Translate( -axisPoint );
}
/// <summary>Applies `b` first, then `a` — the usual matrix convention.</summary>
public static Xform operator *( Xform a, Xform b ) => new(
a.TransformDirection( b.X ),
a.TransformDirection( b.Y ),
a.TransformDirection( b.Z ),
a.TransformPoint( b.Origin ) );
/// <summary>
/// The inverse transform. General 3x3 inverse rather than the transpose shortcut, because an
/// Xform is allowed to carry scale and a mirror — the transpose is only correct for a pure
/// rotation, and silently wrong for everything else this type can hold.
///
/// Throws on a singular basis. A zero-scaled transform has no inverse and returning identity
/// would hide the mistake somewhere far away from its cause.
/// </summary>
public Xform Inverse => InverseOf( this );
static Xform InverseOf( Xform t )
{
var det = t.Determinant;
if ( MathF.Abs( det ) < 1e-12f )
throw new InvalidOperationException( "Xform is singular and cannot be inverted" );
// For a matrix whose COLUMNS are X, Y, Z, the inverse has ROWS (Y×Z)/det, (Z×X)/det,
// (X×Y)/det. Xform stores columns, so the rows are transposed back into columns below.
var r0 = Vec3.Cross( t.Y, t.Z ) / det;
var r1 = Vec3.Cross( t.Z, t.X ) / det;
var r2 = Vec3.Cross( t.X, t.Y ) / det;
var origin = new Vec3(
-Vec3.Dot( r0, t.Origin ),
-Vec3.Dot( r1, t.Origin ),
-Vec3.Dot( r2, t.Origin ) );
return new Xform(
new Vec3( r0.x, r1.x, r2.x ),
new Vec3( r0.y, r1.y, r2.y ),
new Vec3( r0.z, r1.z, r2.z ),
origin );
}
/// <summary>
/// Euler angles in radians, in the convention R = Rz(z) * Ry(y) * Rx(x) — X applied first.
///
/// This is general maths, not an export detail, but it is worth saying where it gets used:
/// SMD stores bone rotations exactly this way, and getting the composition order backwards
/// produces a skeleton that looks plausible in the node list and is wrong the moment a bone
/// is not axis-aligned. There is a round-trip test.
///
/// The basis is normalised first, so a transform carrying scale still yields the rotation it
/// represents. A mirrored basis has no Euler representation at all — bones are never mirrored,
/// and a caller that manages it gets a wrong answer rather than an exception, which is the one
/// rough edge here.
/// </summary>
public Vec3 ToEulerXyz()
{
var x = X.Normal;
var y = Y.Normal;
var z = Z.Normal;
// Matrix entry naming below is [row][column]; Xform stores columns, so m20 is X.z.
var m20 = x.z;
// cos(pitch) collapses at the poles and the remaining two angles stop being separable —
// the classic gimbal case. Pin roll to zero and fold everything into the first angle.
if ( MathF.Abs( m20 ) > 0.999999f )
{
var pitch = m20 < 0f ? MathF.PI / 2f : -MathF.PI / 2f;
return m20 < 0f
? new Vec3( MathF.Atan2( y.x, y.y ), pitch, 0f )
: new Vec3( MathF.Atan2( -y.x, y.y ), pitch, 0f );
}
return new Vec3(
MathF.Atan2( y.z, z.z ),
MathF.Asin( -m20 ),
MathF.Atan2( x.y, x.x ) );
}
/// <summary>Rebuilds a rotation from ToEulerXyz's angles. Same convention, so the two
/// round-trip.</summary>
public static Xform FromEulerXyz( Vec3 radians )
{
return Rotate( new Vec3( 0, 0, 1 ), radians.z )
* Rotate( new Vec3( 0, 1, 0 ), radians.y )
* Rotate( new Vec3( 1, 0, 0 ), radians.x );
}
}