Editor/Effigy/SculptFrames.cs
using System;
using System.Collections.Generic;
namespace Effigy;
/// <summary>
/// Per-vertex orthonormal basis plus a local length, derived from the mesh, never stored.
///
/// Deltas live in this frame so a sculpt rides a parametric edit: if they were world-space and
/// the cage grew 20% taller, every bump would sit where it used to and slide off the surface.
/// Normal is <see cref="PolyMesh.ComputeVertexNormals"/>. Tangent is the direction to the
/// lowest-indexed adjacent vertex, orthonormalised against the normal. The unused length of
/// that edge is Scale — capture and apply divide and multiply by it so a uniform resize keeps
/// the bump the same size relative to the cage.
/// </summary>
public readonly struct SculptFrame
{
public readonly Vec3 Normal;
public readonly Vec3 Tangent;
public readonly Vec3 Bitangent;
public readonly float Scale;
public SculptFrame( Vec3 normal, Vec3 tangent, Vec3 bitangent, float scale )
{
Normal = normal;
Tangent = tangent;
Bitangent = bitangent;
Scale = scale;
}
public Vec3 FromWorld( Vec3 world )
{
var s = Scale > 1e-12f ? Scale : 1f;
return new Vec3(
Vec3.Dot( world, Normal ) / s,
Vec3.Dot( world, Tangent ) / s,
Vec3.Dot( world, Bitangent ) / s );
}
public Vec3 ToWorld( Vec3 delta )
{
var s = Scale > 1e-12f ? Scale : 1f;
return (Normal * delta.x + Tangent * delta.y + Bitangent * delta.z) * s;
}
}
/// <summary>One frame per vertex, built from the mesh alone so it cannot drift out of sync.</summary>
public sealed class SculptFrames
{
public readonly SculptFrame[] At;
public SculptFrames( SculptFrame[] at )
{
At = at;
}
public int Count => At.Length;
public static SculptFrames Build( PolyMesh mesh )
{
if ( mesh is null )
throw new ArgumentNullException( nameof( mesh ) );
var normals = mesh.ComputeVertexNormals();
var vertexEdges = mesh.BuildVertexEdges();
var frames = new SculptFrame[mesh.VertexCount];
for ( var vi = 0; vi < mesh.VertexCount; vi++ )
{
var n = normals[vi];
if ( n.LengthSquared < 0.5f )
n = new Vec3( 0, 0, 1 );
var neighbour = LowestIndexedNeighbour( vi, vertexEdges[vi] );
var toNeighbour = neighbour >= 0
? mesh.Positions[neighbour] - mesh.Positions[vi]
: Vec3.Zero;
var scale = toNeighbour.Length;
if ( scale < 1e-12f )
scale = 1f;
var t = OrthonormalTangent( n, toNeighbour, vi, vertexEdges[vi], mesh );
var b = Vec3.Cross( n, t ).Normal;
// Cross of two unit orthogonal vectors is already unit; if n and t somehow weren't,
// rebuild t from n and b so the stored basis is orthonormal rather than "close".
if ( b.LengthSquared < 0.5f )
{
t = FallbackTangent( n );
b = Vec3.Cross( n, t ).Normal;
}
else
{
t = Vec3.Cross( b, n ).Normal;
}
frames[vi] = new SculptFrame( n, t, b, scale );
}
return new SculptFrames( frames );
}
static int LowestIndexedNeighbour( int vi, List<EdgeKey> edges )
{
var best = int.MaxValue;
foreach ( var key in edges )
{
var other = key.A == vi ? key.B : key.A;
if ( other < best )
best = other;
}
return best == int.MaxValue ? -1 : best;
}
static Vec3 OrthonormalTangent( Vec3 n, Vec3 toNeighbour, int vi, List<EdgeKey> edges, PolyMesh mesh )
{
var t = (toNeighbour - n * Vec3.Dot( toNeighbour, n )).Normal;
if ( t.LengthSquared >= 0.5f )
return t;
// The closest neighbour sat along the normal. Try the rest, lowest index first, so the
// fallback is still a function of the mesh alone.
var candidates = new List<int>( edges.Count );
foreach ( var key in edges )
candidates.Add( key.A == vi ? key.B : key.A );
candidates.Sort();
foreach ( var other in candidates )
{
var to = mesh.Positions[other] - mesh.Positions[vi];
t = (to - n * Vec3.Dot( to, n )).Normal;
if ( t.LengthSquared >= 0.5f )
return t;
}
return FallbackTangent( n );
}
static Vec3 FallbackTangent( Vec3 n )
{
var ax = MathF.Abs( n.x );
var ay = MathF.Abs( n.y );
var az = MathF.Abs( n.z );
var seed = ax < ay && ax < az ? new Vec3( 1, 0, 0 )
: ay < az ? new Vec3( 0, 1, 0 )
: new Vec3( 0, 0, 1 );
var t = Vec3.Cross( n, seed ).Normal;
return t.LengthSquared >= 0.5f ? t : new Vec3( 1, 0, 0 );
}
}