Effigy/MeshTransform.cs

Mesh utilities for a modeling kernel. MeshTransform applies Xform to PolyMesh and handles winding; MeshWeld merges nearby vertices using spatial hashing and union-find; MeshModifiers provides mirror, array, subdivide and solidify modifier application and helpers for mirroring and arraying meshes.

Native Interop
using System;
using System.Collections.Generic;

namespace Effigy;

/// <summary>
/// Applying an <see cref="Xform"/> to a mesh.
///
/// Split out of Xform.cs so that file is pure arithmetic on Vec3 and Xform with no mesh types in
/// it. That matters for exactly one reason: the game assembly's sandbox forbids the filesystem, so
/// only part of this kernel can ever ship to it, and the smaller that part's dependencies are the
/// more of it a game can use. Xform pulling in PolyMesh dragged half the kernel across that line
/// for the sake of two functions that live here.
/// </summary>
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 );

		// Vertex colours merge the way the skin does: pad whichever side lacks them with transparent,
		// so an unpainted body merged into a painted one simply carries no paint of its own. Done
		// after positions are appended but keyed off the pre-merge offset, which is what keeps the
		// colours parallel to the positions they describe.
		if ( target.VertexColors is not null || source.VertexColors is not null )
		{
			target.VertexColors ??= new Vec4[offset];

			var merged = new Vec4[offset + source.Positions.Count];

			for ( var i = 0; i < offset; i++ )
				merged[i] = i < target.VertexColors.Length ? target.VertexColors[i] : Vec4.Zero;

			for ( var i = 0; i < source.Positions.Count; i++ )
				merged[offset + i] = source.VertexColors is not null && i < source.VertexColors.Length
					? source.VertexColors[i]
					: Vec4.Zero;

			target.VertexColors = merged;
		}

		// Paint is one atlas per body, not per vertex, so it cannot be padded the way colours are. A
		// merged mesh can show one body's atlas, so the target keeps its own and only adopts the
		// source's when it has none. TWO painted bodies is the case this drops, and it is named by
		// PaintBind.MergeDropsPaint so a caller can say so rather than discovering it on compile.
		if ( target.Paint is null && source.Paint is not null )
			target.Paint = source.Paint;

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

/// <summary>
/// Merge vertices that sit within a distance of each other, Blender's Merge by Distance.
///
/// The half of the mesh-authoring workflow a vertex count spends forever without: an import or a
/// mirror seam that left every face owning its own copy of every corner reads as valid from outside
/// but is topologically a pile of loose quads — Catmull-Clark sees all boundary, a weight brush
/// smears, and nothing is actually connected. This collapses coincident vertices back to one index,
/// drops the faces the weld squeezes flat, and hands back a mesh whose connectivity means what its
/// shape says.
///
/// Grouping is union-find over a spatial hash (cells the size of the tolerance, searching the 27
/// surrounding cells), so the result is the same regardless of vertex order. A welded vertex keeps
/// the lowest original index's position, skin weights and colour. Duplicate faces the weld makes
/// coincident are left for the caller to notice, not silently removed.
/// </summary>
public static class MeshWeld
{
	public static PolyMesh Weld( PolyMesh mesh, float tolerance )
	{
		if ( mesh is null )
			throw new ArgumentNullException( nameof( mesh ) );

		if ( tolerance <= 0f || mesh.VertexCount < 2 )
			return mesh.Clone();

		var parent = new int[mesh.VertexCount];
		var rank = new byte[mesh.VertexCount];

		for ( var i = 0; i < parent.Length; i++ )
			parent[i] = i;

		// Spatial hash, cells the size of the tolerance.
		var cells = new Dictionary<(long, long, long), List<int>>();

		for ( var v = 0; v < mesh.VertexCount; v++ )
		{
			var key = Cell( mesh.Positions[v], tolerance );

			if ( !cells.TryGetValue( key, out var list ) )
				cells[key] = list = new List<int>( 4 );

			list.Add( v );
		}

		var toleranceSquared = tolerance * tolerance;

		for ( var v = 0; v < mesh.VertexCount; v++ )
		{
			var p = mesh.Positions[v];
			var cell = Cell( p, tolerance );

			// Two points within the tolerance can straddle a cell boundary by at most one cell, so
			// the 27-neighbourhood covers every candidate pair.
			for ( var dx = -1; dx <= 1; dx++ )
			for ( var dy = -1; dy <= 1; dy++ )
			for ( var dz = -1; dz <= 1; dz++ )
			{
				var key = (cell.Item1 + dx, cell.Item2 + dy, cell.Item3 + dz);

				if ( !cells.TryGetValue( key, out var list ) )
					continue;

				foreach ( var u in list )
				{
					if ( u <= v )
						continue;

					if ( (mesh.Positions[u] - p).LengthSquared <= toleranceSquared )
						Union( parent, rank, v, u );
				}
			}
		}

		// Representative of each component is its lowest index, so the output order is deterministic.
		var representative = new int[mesh.VertexCount];
		var hasRepresentative = new bool[mesh.VertexCount];

		for ( var v = 0; v < mesh.VertexCount; v++ )
		{
			var root = Find( parent, v );

			if ( !hasRepresentative[root] || v < representative[root] )
			{
				representative[root] = v;
				hasRepresentative[root] = true;
			}
		}

		var compact = new int[mesh.VertexCount];
		Array.Fill( compact, -1 );
		var survivors = new List<int>();

		for ( var v = 0; v < mesh.VertexCount; v++ )
		{
			var rep = representative[Find( parent, v )];

			if ( compact[rep] >= 0 )
				continue;

			compact[rep] = survivors.Count;
			survivors.Add( rep );
		}

		var remap = new int[mesh.VertexCount];

		for ( var v = 0; v < mesh.VertexCount; v++ )
			remap[v] = compact[representative[Find( parent, v )]];

		var result = new PolyMesh();

		foreach ( var s in survivors )
			result.AddVertex( mesh.Positions[s] );

		if ( mesh.IsRigged )
		{
			var skin = new SkinWeights();

			foreach ( var s in survivors )
				skin.Vertices.Add( (BoneWeight[])mesh.Skin[s].Clone() );

			result.Skin = skin;
		}

		if ( mesh.VertexColors is not null )
		{
			var colors = new Vec4[survivors.Count];

			for ( var i = 0; i < survivors.Count; i++ )
				colors[i] = mesh.VertexColors[survivors[i]];

			result.VertexColors = colors;
		}

		if ( mesh.Paint is not null )
			result.Paint = mesh.Paint.Clone();

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

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

			if ( DistinctCount( indices ) < 3 )
				continue;

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

		return result;
	}

	static (long, long, long) Cell( Vec3 p, float tolerance )
	{
		var scale = 1f / tolerance;
		return (
			(long)MathF.Floor( p.x * scale ),
			(long)MathF.Floor( p.y * scale ),
			(long)MathF.Floor( p.z * scale ));
	}

	static int DistinctCount( int[] values )
	{
		var count = 0;

		for ( var i = 0; i < values.Length; i++ )
		{
			var seen = false;

			for ( var j = 0; j < i; j++ )
			{
				if ( values[i] == values[j] )
				{
					seen = true;
					break;
				}
			}

			if ( !seen )
				count++;
		}

		return count;
	}

	static int Find( int[] parent, int v )
	{
		while ( parent[v] != v )
		{
			parent[v] = parent[parent[v]];
			v = parent[v];
		}

		return v;
	}

	static void Union( int[] parent, byte[] rank, int a, int b )
	{
		a = Find( parent, a );
		b = Find( parent, b );

		if ( a == b )
			return;

		if ( rank[a] < rank[b] )
		{
			parent[a] = b;
		}
		else if ( rank[a] > rank[b] )
		{
			parent[b] = a;
		}
		else
		{
			parent[b] = a;
			rank[a]++;
		}
	}
}

/// <summary>
/// The live modifiers a mesh edit carries — Blender's modifier stack, cut down to the four that
/// modelling a character actually leans on. They run on the way out of the edit, so the mesh you
/// edit stays the simple cage and the body downstream gets the result: model half a head and see
/// the whole one, block a limb out in a few quads and see it subdivided, draw a garment as a sheet
/// and see its thickness.
///
/// Always in one order — mirror, array, subdivide, solidify — the order that gives the answer
/// people expect: the mirror joins before it is smoothed, and the thickness follows the smoothed
/// surface rather than the cage.
/// </summary>
public static class MeshModifiers
{
	public static PolyMesh Apply( PolyMesh mesh, bool mirrorX, int arrayCount, float arrayGap, int subdivide, float solidify, float seam = 1e-3f ) =>
		Apply( mesh, mirrorX, arrayCount, arrayGap, subdivide, solidify, null, seam );

	/// <summary>The modifiers with edge creases for the subdivide. Creases follow the mesh through
	/// the mirror and the array: a crease on the left is a crease on the right.</summary>
	public static PolyMesh Apply( PolyMesh mesh, bool mirrorX, int arrayCount, float arrayGap, int subdivide, float solidify, IReadOnlyDictionary<EdgeKey, float> creases, float seam = 1e-3f )
	{
		var result = mesh.Clone();
		var live = creases is null || creases.Count == 0 ? null : new Dictionary<EdgeKey, float>( creases );

		if ( mirrorX )
		{
			result = MirrorX( result, seam, out var map );
			if ( live is not null )
			{
				foreach ( var (key, weight) in creases )
					if ( key.A < map.Length && key.B < map.Length )
						live[new EdgeKey( map[key.A], map[key.B] )] = weight;
			}
		}

		if ( arrayCount > 1 )
		{
			var n = result.VertexCount;
			result = ArrayX( result, arrayCount, arrayGap );
			if ( live is not null )
			{
				foreach ( var (key, weight) in new List<KeyValuePair<EdgeKey, float>>( live ) )
					for ( var k = 1; k < arrayCount; k++ )
						live[new EdgeKey( key.A + n * k, key.B + n * k )] = weight;
			}
		}

		if ( subdivide > 0 )
			result = CatmullClark.Subdivide( result, Math.Min( subdivide, 3 ), live, out _ );

		if ( solidify > 0f )
			result = MeshSolidify.Solidify( result, solidify );

		return result;
	}

	/// <summary>
	/// The mesh plus its mirror image across x = 0. Vertices on the plane (within
	/// <paramref name="seam"/>) are shared by both halves, so a half-model joins up down the middle
	/// instead of leaving a crack. Skin weights are copied as they are, not swapped left for right:
	/// mirror, then rig.
	/// </summary>
	public static PolyMesh MirrorX( PolyMesh mesh, float seam = 1e-3f ) => MirrorX( mesh, seam, out _ );

	/// <summary>Mirror, and say where each vertex's reflection landed: <paramref name="map"/>[v]
	/// is the mirrored copy of v, or v itself on the plane.</summary>
	public static PolyMesh MirrorX( PolyMesh mesh, float seam, out int[] map )
	{
		var result = mesh.Clone();
		var n = mesh.VertexCount;
		map = new int[n];
		var colours = result.VertexColors is null ? null : new List<Vec4>( result.VertexColors );

		for ( var v = 0; v < n; v++ )
		{
			var p = mesh.Positions[v];

			if ( MathF.Abs( p.x ) <= seam )
			{
				map[v] = v;
				result.Positions[v] = new Vec3( 0f, p.y, p.z );
				continue;
			}

			map[v] = result.Positions.Count;
			result.Positions.Add( new Vec3( -p.x, p.y, p.z ) );

			if ( result.Skin is not null && result.Skin.Count > v )
				result.Skin.Vertices.Add( result.Skin[v] );

			colours?.Add( colours[v] );
		}

		if ( colours is not null )
			result.VertexColors = colours.ToArray();

		foreach ( var face in mesh.Faces )
		{
			var count = face.Indices.Length;
			var idx = new int[count];
			var uvs = new Vec2[count];

			// Mirroring flips handedness, so each face is wound backwards to keep facing out.
			for ( var i = 0; i < count; i++ )
			{
				idx[i] = map[face.Indices[count - 1 - i]];
				uvs[i] = face.UVs[count - 1 - i];
			}

			result.AddFace( idx, uvs, face.Material );
		}

		return result;
	}

	/// <summary>The mesh repeated <paramref name="count"/> times along X, each copy one width plus
	/// <paramref name="gap"/> further on.</summary>
	public static PolyMesh ArrayX( PolyMesh mesh, int count, float gap )
	{
		if ( mesh.VertexCount == 0 || count < 2 )
			return mesh.Clone();

		var minX = float.MaxValue;
		var maxX = float.MinValue;
		foreach ( var p in mesh.Positions )
		{
			minX = MathF.Min( minX, p.x );
			maxX = MathF.Max( maxX, p.x );
		}

		var stride = maxX - minX + gap;
		var result = mesh.Clone();
		var colours = result.VertexColors is null ? null : new List<Vec4>( result.VertexColors );
		var n = mesh.VertexCount;

		for ( var k = 1; k < count; k++ )
		{
			var start = result.Positions.Count;

			for ( var v = 0; v < n; v++ )
			{
				result.Positions.Add( mesh.Positions[v] + new Vec3( stride * k, 0f, 0f ) );

				if ( result.Skin is not null && mesh.Skin is not null && mesh.Skin.Count > v )
					result.Skin.Vertices.Add( mesh.Skin[v] );

				if ( colours is not null && mesh.VertexColors is not null )
					colours.Add( mesh.VertexColors[v] );
			}

			foreach ( var face in mesh.Faces )
			{
				var idx = new int[face.Indices.Length];
				for ( var i = 0; i < idx.Length; i++ )
					idx[i] = face.Indices[i] + start;

				result.AddFace( idx, (Vec2[])face.UVs.Clone(), face.Material );
			}
		}

		if ( colours is not null )
			result.VertexColors = colours.ToArray();

		return result;
	}
}