Editor/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 );
	}
}

public static class MeshTransform
{
	/// <summary>
	/// Transform a mesh in place, reversing face winding when the transform flips handedness.
	///
	/// Forgetting the reversal is the single most common mirror bug: the mirrored half renders
	/// black or lit from inside, and the model reads as fine in wireframe. There is a test for it.
	/// </summary>
	public static void Apply( PolyMesh mesh, Xform xform )
	{
		for ( var i = 0; i < mesh.Positions.Count; i++ )
			mesh.Positions[i] = xform.TransformPoint( mesh.Positions[i] );

		if ( !xform.FlipsWinding )
			return;

		foreach ( var f in mesh.Faces )
		{
			Array.Reverse( f.Indices );
			Array.Reverse( f.UVs );
		}
	}

	public static PolyMesh Transformed( PolyMesh mesh, Xform xform )
	{
		var copy = mesh.Clone();
		Apply( copy, xform );
		return copy;
	}

	/// <summary>
	/// Merge `source` into `target`, offsetting indices. Does not weld — two bodies combined this
	/// way stay topologically separate, which is correct for a pattern and is why Validate reports
	/// them as one mesh with several shells rather than as non-manifold.
	/// </summary>
	public static void Append( PolyMesh target, PolyMesh source )
	{
		var offset = target.Positions.Count;

		// Weights have to be reconciled BEFORE the position lists merge, because both sides are
		// padded against their own current vertex count. Merging an unrigged body into a rigged one
		// is normal — a rig usually arrives after some of the model does — so the unrigged side is
		// padded with empty influences rather than treated as an error.
		if ( target.Skin is not null || source.Skin is not null )
		{
			target.Skin ??= new SkinWeights( target.Positions.Count );

			while ( target.Skin.Count < target.Positions.Count )
				target.Skin.Vertices.Add( new[] { new BoneWeight( 0, 1f ) } );

			// An unrigged body merged into a rigged one gets bound to the FIRST BONE rather than
			// left empty. Empty influences pass IsRigged, fail Validate, and export as "no links",
			// which studiomdl reads as the parent bone column - so the body silently ends up rigged
			// to whatever bone 0 happens to be, discovered much later and somewhere else. Binding it
			// explicitly is the same outcome, stated out loud, and it keeps the partition of unity
			// that everything downstream assumes.
			var unrigged = new[] { new BoneWeight( 0, 1f ) };

			for ( var i = 0; i < source.Positions.Count; i++ )
			{
				target.Skin.Vertices.Add( source.Skin is not null && i < source.Skin.Count && source.Skin[i].Length > 0
					? (BoneWeight[])source.Skin[i].Clone()
					: unrigged );
			}
		}

		target.Positions.AddRange( source.Positions );

		foreach ( var f in source.Faces )
		{
			var indices = new int[f.Count];

			for ( var i = 0; i < f.Count; i++ )
				indices[i] = f.Indices[i] + offset;

			target.AddFace( indices, (Vec2[])f.UVs.Clone(), f.Material );
		}
	}
}