Editor/Effigy/Primitives.cs
using System;
using System.Collections.Generic;
namespace Effigy;
/// <summary>
/// Welds vertices by position so shared corners become one index rather than several coincident
/// ones.
///
/// This matters more than it looks. A mesh built face-by-face without welding is topologically a
/// pile of loose quads — every edge reads as a boundary, Catmull-Clark treats the whole thing as
/// border, and the result falls apart into disconnected sheets. Anything constructed here goes
/// through the welder.
/// </summary>
sealed class VertexWelder
{
readonly Dictionary<(long, long, long), int> _map = new();
readonly PolyMesh _mesh;
readonly float _scale;
public VertexWelder( PolyMesh mesh, float tolerance = 1e-5f )
{
_mesh = mesh;
_scale = 1f / tolerance;
}
public int Add( Vec3 p )
{
// Quantising to an integer lattice, rather than comparing floats pairwise, keeps this O(1)
// per vertex. Two positions closer than the tolerance land in the same bucket.
var key = (
(long)MathF.Round( p.x * _scale ),
(long)MathF.Round( p.y * _scale ),
(long)MathF.Round( p.z * _scale ));
if ( _map.TryGetValue( key, out var existing ) )
return existing;
var index = _mesh.AddVertex( p );
_map[key] = index;
return index;
}
}
/// <summary>
/// Parametric primitives, all quad-dominant on purpose.
///
/// QUADS ARE A HARD REQUIREMENT, NOT A PREFERENCE. These feed Catmull-Clark, which turns clean
/// quads into a clean subdivision surface and turns triangle soup into a lumpy one. That is why
/// there is no UV sphere here — its pole fans are triangles and they pinch visibly under a sculpt
/// brush. QuadSphere costs nothing extra and has no poles.
///
/// Every primitive is closed and manifold, which is what lets collision fall out of the primitive
/// list later instead of needing convex decomposition of the triangles.
/// </summary>
public static class Primitives
{
/// <summary>Axis-aligned box. 8 vertices, 6 quads, one UV island per face.</summary>
public static PolyMesh Box( float sizeX = 1f, float sizeY = 1f, float sizeZ = 1f, int material = 0 )
{
var hx = sizeX * 0.5f;
var hy = sizeY * 0.5f;
var hz = sizeZ * 0.5f;
var m = new PolyMesh();
// 0..3 bottom (z-), 4..7 top (z+), counter-clockwise seen from +Z
m.AddVertex( new Vec3( -hx, -hy, -hz ) );
m.AddVertex( new Vec3( hx, -hy, -hz ) );
m.AddVertex( new Vec3( hx, hy, -hz ) );
m.AddVertex( new Vec3( -hx, hy, -hz ) );
m.AddVertex( new Vec3( -hx, -hy, hz ) );
m.AddVertex( new Vec3( hx, -hy, hz ) );
m.AddVertex( new Vec3( hx, hy, hz ) );
m.AddVertex( new Vec3( -hx, hy, hz ) );
var uv = new[] { new Vec2( 0, 0 ), new Vec2( 1, 0 ), new Vec2( 1, 1 ), new Vec2( 0, 1 ) };
// Winding is chosen so each face's Newell normal points out of the box. Verified by test.
m.AddFace( new[] { 0, 3, 2, 1 }, (Vec2[])uv.Clone(), material ); // -Z
m.AddFace( new[] { 4, 5, 6, 7 }, (Vec2[])uv.Clone(), material ); // +Z
m.AddFace( new[] { 0, 1, 5, 4 }, (Vec2[])uv.Clone(), material ); // -Y
m.AddFace( new[] { 1, 2, 6, 5 }, (Vec2[])uv.Clone(), material ); // +X
m.AddFace( new[] { 2, 3, 7, 6 }, (Vec2[])uv.Clone(), material ); // +Y
m.AddFace( new[] { 3, 0, 4, 7 }, (Vec2[])uv.Clone(), material ); // -X
return m;
}
/// <summary>Flat grid in XY. Open — this is the one primitive with a boundary, which makes it
/// the useful case for testing the boundary rules in Catmull-Clark.</summary>
public static PolyMesh Plane( float sizeX = 1f, float sizeY = 1f, int segmentsX = 1, int segmentsY = 1, int material = 0 )
{
segmentsX = Math.Max( 1, segmentsX );
segmentsY = Math.Max( 1, segmentsY );
var m = new PolyMesh();
for ( var iy = 0; iy <= segmentsY; iy++ )
{
for ( var ix = 0; ix <= segmentsX; ix++ )
{
m.AddVertex( new Vec3(
(ix / (float)segmentsX - 0.5f) * sizeX,
(iy / (float)segmentsY - 0.5f) * sizeY,
0f ) );
}
}
var stride = segmentsX + 1;
for ( var iy = 0; iy < segmentsY; iy++ )
{
for ( var ix = 0; ix < segmentsX; ix++ )
{
var a = iy * stride + ix;
var b = a + 1;
var c = a + stride + 1;
var d = a + stride;
var u0 = ix / (float)segmentsX;
var u1 = (ix + 1) / (float)segmentsX;
var v0 = iy / (float)segmentsY;
var v1 = (iy + 1) / (float)segmentsY;
m.AddFace(
new[] { a, b, c, d },
new[] { new Vec2( u0, v0 ), new Vec2( u1, v0 ), new Vec2( u1, v1 ), new Vec2( u0, v1 ) },
material );
}
}
return m;
}
/// <summary>Cylinder about Z. Sides are quads; the caps are single n-gons rather than triangle
/// fans, because Catmull-Clark turns an n-gon into n clean quads and turns a fan into a mess
/// around a high-valence hub.</summary>
public static PolyMesh Cylinder( float radius = 0.5f, float height = 1f, int segments = 16, int material = 0 )
{
segments = Math.Max( 3, segments );
var m = new PolyMesh();
var hz = height * 0.5f;
for ( var i = 0; i < segments; i++ )
{
var a = i / (float)segments * MathF.Tau;
m.AddVertex( new Vec3( MathF.Cos( a ) * radius, MathF.Sin( a ) * radius, -hz ) );
}
for ( var i = 0; i < segments; i++ )
{
var a = i / (float)segments * MathF.Tau;
m.AddVertex( new Vec3( MathF.Cos( a ) * radius, MathF.Sin( a ) * radius, hz ) );
}
for ( var i = 0; i < segments; i++ )
{
var next = (i + 1) % segments;
var u0 = i / (float)segments;
var u1 = (i + 1) / (float)segments;
m.AddFace(
new[] { i, next, segments + next, segments + i },
new[] { new Vec2( u0, 0 ), new Vec2( u1, 0 ), new Vec2( u1, 1 ), new Vec2( u0, 1 ) },
material );
}
// Bottom cap runs backwards so its normal points -Z.
var bottom = new int[segments];
var bottomUV = new Vec2[segments];
for ( var i = 0; i < segments; i++ )
{
bottom[i] = segments - 1 - i;
var a = (segments - 1 - i) / (float)segments * MathF.Tau;
bottomUV[i] = new Vec2( MathF.Cos( a ) * 0.5f + 0.5f, MathF.Sin( a ) * 0.5f + 0.5f );
}
m.AddFace( bottom, bottomUV, material );
var top = new int[segments];
var topUV = new Vec2[segments];
for ( var i = 0; i < segments; i++ )
{
top[i] = segments + i;
var a = i / (float)segments * MathF.Tau;
topUV[i] = new Vec2( MathF.Cos( a ) * 0.5f + 0.5f, MathF.Sin( a ) * 0.5f + 0.5f );
}
m.AddFace( top, topUV, material );
return m;
}
/// <summary>
/// Sphere built as a subdivided cube projected onto the sphere.
///
/// Deliberately not a UV sphere. A UV sphere concentrates a triangle fan at each pole and
/// crowds vertices near them; both artefacts survive every subdivision level and show up the
/// first time anyone drags a brush across a pole. A quad sphere is all quads with only eight
/// valence-3 vertices, spread evenly.
/// </summary>
public static PolyMesh QuadSphere( float radius = 0.5f, int segments = 4, int material = 0 )
{
segments = Math.Max( 1, segments );
var m = new PolyMesh();
var weld = new VertexWelder( m );
// The six cube faces, each as an origin corner plus two edge vectors.
var faces = new (Vec3 Origin, Vec3 U, Vec3 V)[]
{
(new Vec3( -1, -1, -1 ), new Vec3( 0, 2, 0 ), new Vec3( 2, 0, 0 )), // -Z
(new Vec3( -1, -1, 1 ), new Vec3( 2, 0, 0 ), new Vec3( 0, 2, 0 )), // +Z
(new Vec3( -1, -1, -1 ), new Vec3( 2, 0, 0 ), new Vec3( 0, 0, 2 )), // -Y
(new Vec3( 1, -1, -1 ), new Vec3( 0, 2, 0 ), new Vec3( 0, 0, 2 )), // +X
(new Vec3( 1, 1, -1 ), new Vec3( -2, 0, 0 ), new Vec3( 0, 0, 2 )), // +Y
(new Vec3( -1, 1, -1 ), new Vec3( 0, -2, 0 ), new Vec3( 0, 0, 2 )), // -X
};
foreach ( var (origin, uDir, vDir) in faces )
{
for ( var iv = 0; iv < segments; iv++ )
{
for ( var iu = 0; iu < segments; iu++ )
{
var corners = new int[4];
var uvs = new Vec2[4];
var offsets = new[] { (0, 0), (1, 0), (1, 1), (0, 1) };
for ( var c = 0; c < 4; c++ )
{
var (du, dv) = offsets[c];
var fu = (iu + du) / (float)segments;
var fv = (iv + dv) / (float)segments;
// Project the cube point onto the sphere by normalising it.
var cube = origin + uDir * fu + vDir * fv;
corners[c] = weld.Add( cube.Normal * radius );
uvs[c] = new Vec2( fu, fv );
}
m.AddFace( corners, uvs, material );
}
}
}
return m;
}
/// <summary>Right-angle wedge — a ramp. The two ends are triangles, which is unavoidable for
/// this shape; everything else is quads.</summary>
public static PolyMesh Wedge( float sizeX = 1f, float sizeY = 1f, float sizeZ = 1f, int material = 0 )
{
var hx = sizeX * 0.5f;
var hy = sizeY * 0.5f;
var hz = sizeZ * 0.5f;
var m = new PolyMesh();
m.AddVertex( new Vec3( -hx, -hy, -hz ) ); // 0
m.AddVertex( new Vec3( hx, -hy, -hz ) ); // 1
m.AddVertex( new Vec3( hx, hy, -hz ) ); // 2
m.AddVertex( new Vec3( -hx, hy, -hz ) ); // 3
m.AddVertex( new Vec3( -hx, -hy, hz ) ); // 4 high edge, -Y side
m.AddVertex( new Vec3( -hx, hy, hz ) ); // 5 high edge, +Y side
var quadUV = new[] { new Vec2( 0, 0 ), new Vec2( 1, 0 ), new Vec2( 1, 1 ), new Vec2( 0, 1 ) };
var triUV = new[] { new Vec2( 0, 0 ), new Vec2( 1, 0 ), new Vec2( 0, 1 ) };
m.AddFace( new[] { 0, 3, 2, 1 }, (Vec2[])quadUV.Clone(), material ); // base, -Z
m.AddFace( new[] { 1, 2, 5, 4 }, (Vec2[])quadUV.Clone(), material ); // slope
m.AddFace( new[] { 3, 0, 4, 5 }, (Vec2[])quadUV.Clone(), material ); // back, -X
m.AddFace( new[] { 0, 1, 4 }, (Vec2[])triUV.Clone(), material ); // -Y end
m.AddFace( new[] { 2, 3, 5 }, (Vec2[])triUV.Clone(), material ); // +Y end
return m;
}
/// <summary>Hollow tube about Z. All quads, closed, and a good stress case for the validator
/// because it is genus 1 — Euler characteristic 0, not 2.</summary>
public static PolyMesh Tube( float outerRadius = 0.5f, float innerRadius = 0.3f, float height = 1f, int segments = 16, int material = 0 )
{
segments = Math.Max( 3, segments );
if ( innerRadius >= outerRadius )
throw new ArgumentException( "innerRadius must be smaller than outerRadius" );
var m = new PolyMesh();
var hz = height * 0.5f;
// Four rings: outer-bottom, outer-top, inner-bottom, inner-top.
void Ring( float radius, float z )
{
for ( var i = 0; i < segments; i++ )
{
var a = i / (float)segments * MathF.Tau;
m.AddVertex( new Vec3( MathF.Cos( a ) * radius, MathF.Sin( a ) * radius, z ) );
}
}
Ring( outerRadius, -hz ); // 0
Ring( outerRadius, hz ); // 1
Ring( innerRadius, -hz ); // 2
Ring( innerRadius, hz ); // 3
var ob = 0;
var ot = segments;
var ib = segments * 2;
var it = segments * 3;
Vec2[] UV( int i ) => new[]
{
new Vec2( i / (float)segments, 0 ),
new Vec2( (i + 1) / (float)segments, 0 ),
new Vec2( (i + 1) / (float)segments, 1 ),
new Vec2( i / (float)segments, 1 )
};
for ( var i = 0; i < segments; i++ )
{
var n = (i + 1) % segments;
m.AddFace( new[] { ob + i, ob + n, ot + n, ot + i }, UV( i ), material ); // outer wall
m.AddFace( new[] { ib + n, ib + i, it + i, it + n }, UV( i ), material ); // inner wall, reversed
m.AddFace( new[] { ot + i, ot + n, it + n, it + i }, UV( i ), material ); // top annulus
m.AddFace( new[] { ib + i, ib + n, ob + n, ob + i }, UV( i ), material ); // bottom annulus
}
return m;
}
}