Editor/Effigy/UVProjection.cs
using System;
namespace Effigy;
/// <summary>
/// Assign UVs by projecting the mesh onto planes.
///
/// WHY THIS MATTERS MORE THAN IT SOUNDS. The whole argument for starting parametric is that the
/// cage arrives already unwrapped, so nobody ever has to do a retopology-and-unwrap pass — and the
/// sculpt stage's normal-map bake reads exactly these UVs. Placeholder UVs would push that work
/// back onto the user at the worst possible moment, which is the failure this pipeline exists to
/// avoid. Planar projection looks crude next to a real unwrapper and is close to ideal on hard
/// surface, which is what this tool builds.
///
/// BOX PROJECTION picks, per face, whichever of the six axis directions the face most faces, and
/// projects onto that plane. Every face gets an undistorted mapping, seams land on the corners
/// where the dominant axis changes, and because UVs live per corner a seam costs nothing — the two
/// faces simply disagree about the UV at a shared position, which is exactly what a seam is.
///
/// HANDEDNESS IS THE PART THAT GOES WRONG. Each direction needs its u and v chosen so that
/// (u, v, normal) is right-handed. Get one of the six backwards and that side of the model renders
/// its texture mirrored — text reads backwards, and nothing else looks wrong enough to notice. The
/// six pairs below are chosen for that property and there is a test that checks all six agree.
/// </summary>
public static class UVProjection
{
/// <summary>
/// Project each face onto whichever axis plane it most faces.
///
/// `scale` is world units per texture tile: 32 means the texture repeats every 32 units. Larger
/// scale, larger apparent texture.
/// </summary>
public static void BoxProject( PolyMesh mesh, float scale = 1f )
{
if ( mesh is null )
throw new ArgumentNullException( nameof( mesh ) );
if ( scale <= 0f )
throw new InvalidOperationException( "UV scale must be positive" );
foreach ( var face in mesh.Faces )
{
var normal = mesh.FaceNormal( face );
var (u, v) = AxisPair( normal );
for ( var i = 0; i < face.Count; i++ )
{
var p = mesh.Positions[face.Indices[i]];
face.UVs[i] = new Vec2( Vec3.Dot( p, u ) / scale, Vec3.Dot( p, v ) / scale );
}
}
}
/// <summary>
/// Project the whole mesh along one direction, ignoring which way each face points.
///
/// Useful where box projection's seams fall somewhere unhelpful — a sign, a decal, a floor that
/// wants one continuous texture. Faces perpendicular to the direction get their UVs squashed to
/// a line, which is inherent to projecting rather than a bug, and is why this is not the
/// default.
/// </summary>
public static void PlanarProject( PolyMesh mesh, Vec3 direction, float scale = 1f )
{
if ( mesh is null )
throw new ArgumentNullException( nameof( mesh ) );
if ( scale <= 0f )
throw new InvalidOperationException( "UV scale must be positive" );
var normal = direction.Normal;
if ( normal.LengthSquared < 0.5f )
throw new InvalidOperationException( "Projection direction has no length" );
// Any axis not parallel to the direction works as a seed; picking the one it leans on least
// keeps the cross product well conditioned.
var seed = MathF.Abs( normal.x ) < 0.9f ? new Vec3( 1, 0, 0 ) : new Vec3( 0, 0, 1 );
var u = Vec3.Cross( seed, normal ).Normal;
var v = Vec3.Cross( normal, u );
foreach ( var face in mesh.Faces )
{
for ( var i = 0; i < face.Count; i++ )
{
var p = mesh.Positions[face.Indices[i]];
face.UVs[i] = new Vec2( Vec3.Dot( p, u ) / scale, Vec3.Dot( p, v ) / scale );
}
}
}
/// <summary>
/// The u and v axes for whichever of the six directions a normal most points along.
///
/// Each pair satisfies cross(u, v) == the face direction, which is what keeps the texture the
/// right way round rather than mirrored. Ties — a normal exactly 45 degrees between two axes —
/// resolve to the earlier axis, deterministically, so the same mesh always projects the same
/// way.
/// </summary>
static (Vec3 U, Vec3 V) AxisPair( Vec3 normal )
{
var ax = MathF.Abs( normal.x );
var ay = MathF.Abs( normal.y );
var az = MathF.Abs( normal.z );
if ( ax >= ay && ax >= az )
{
return normal.x >= 0f
? (new Vec3( 0, 1, 0 ), new Vec3( 0, 0, 1 )) // cross(+Y, +Z) = +X
: (new Vec3( 0, 0, 1 ), new Vec3( 0, 1, 0 )); // cross(+Z, +Y) = -X
}
if ( ay >= az )
{
return normal.y >= 0f
? (new Vec3( 0, 0, 1 ), new Vec3( 1, 0, 0 )) // cross(+Z, +X) = +Y
: (new Vec3( 1, 0, 0 ), new Vec3( 0, 0, 1 )); // cross(+X, +Z) = -Y
}
return normal.z >= 0f
? (new Vec3( 1, 0, 0 ), new Vec3( 0, 1, 0 )) // cross(+X, +Y) = +Z
: (new Vec3( 0, 1, 0 ), new Vec3( 1, 0, 0 )); // cross(+Y, +X) = -Z
}
}