Editor/Effigy/Brush.cs
using System;
using System.Collections.Generic;
namespace Effigy;
public enum BrushKind
{
Smooth,
Draw,
Inflate,
Grab,
Flatten,
Pinch
}
public enum BrushFalloff
{
Smooth,
Linear,
Sharp,
Constant
}
/// <summary>One sample on a stroke. The editor produces these; the kernel never learns what a mouse is.</summary>
public readonly struct BrushSample
{
public readonly Vec3 Position;
public readonly Vec3 Normal;
public readonly Vec3 Direction;
public readonly float Radius;
public readonly float Strength;
public BrushSample( Vec3 position, Vec3 normal, float radius, float strength, Vec3 direction = default )
{
Position = position;
Normal = normal;
Direction = direction;
Radius = radius;
Strength = strength;
}
}
/// <summary>A list of samples plus the brush that consumes them.</summary>
public sealed class BrushStroke
{
public BrushKind Kind;
public BrushFalloff Falloff = BrushFalloff.Smooth;
public bool MirrorX;
public readonly List<BrushSample> Samples = new();
}
/// <summary>
/// Per-stroke undo: the original position of every vertex the stroke actually moved.
/// A naive undo snapshots the whole mesh; this stores only the working set.
/// </summary>
public sealed class BrushUndo
{
readonly Dictionary<int, Vec3> _previous = new();
public int Count => _previous.Count;
/// <summary>Every vertex this stroke moved, with the position it had BEFORE.</summary>
public IReadOnlyDictionary<int, Vec3> Previous => _previous;
internal void Remember( int vertex, Vec3 position ) => _previous.TryAdd( vertex, position );
/// <summary>
/// Fold a later undo into this one, so several brush applications read as a single stroke.
///
/// EARLIEST WINS, which is what TryAdd gives: a vertex moved by three dabs in one stroke has to
/// go back to where it was before the FIRST of them, not before the last. Absorbing in the other
/// order would leave the model two thirds sculpted after an undo, which looks like a brush bug
/// rather than an undo one.
/// </summary>
public void Absorb( BrushUndo later )
{
if ( later is null )
throw new ArgumentNullException( nameof( later ) );
foreach ( var (vertex, position) in later._previous )
_previous.TryAdd( vertex, position );
}
public void Restore( PolyMesh mesh )
{
if ( mesh is null )
throw new ArgumentNullException( nameof( mesh ) );
foreach ( var (vertex, position) in _previous )
mesh.Positions[vertex] = position;
}
}
/// <summary>
/// Brushes as pure functions over a mesh and a stroke. Spatial queries go through
/// <see cref="MeshBVH"/>; the kernel does not know about a cursor.
/// </summary>
public static class Brush
{
public static BrushUndo Apply( PolyMesh mesh, BrushStroke stroke, SculptFrames frames, float[] mask = null, MeshBVH bvh = null )
{
if ( mesh is null )
throw new ArgumentNullException( nameof( mesh ) );
if ( stroke is null )
throw new ArgumentNullException( nameof( stroke ) );
if ( frames is null )
throw new ArgumentNullException( nameof( frames ) );
if ( frames.Count != mesh.VertexCount )
throw new ArgumentException( $"frames ({frames.Count}) and mesh ({mesh.VertexCount}) disagree" );
if ( mask is not null && mask.Length != mesh.VertexCount )
throw new ArgumentException( $"mask ({mask.Length}) and mesh ({mesh.VertexCount}) disagree" );
bvh ??= MeshBVH.Build( mesh );
var neighbors = mesh.BuildVertexEdges();
var found = new List<int>();
var undo = new BrushUndo();
foreach ( var sample in stroke.Samples )
{
ApplySample( mesh, stroke, frames, mask, bvh, neighbors, found, undo, sample );
if ( !stroke.MirrorX )
continue;
ApplySample( mesh, stroke, frames, mask, bvh, neighbors, found, undo, MirrorX( sample ) );
}
return undo;
}
static void ApplySample(
PolyMesh mesh, BrushStroke stroke, SculptFrames frames, float[] mask,
MeshBVH bvh, List<EdgeKey>[] neighbors, List<int> found, BrushUndo undo, BrushSample sample )
{
if ( sample.Radius <= 0f )
return;
bvh.VerticesInRadius( mesh, sample.Position, sample.Radius, found );
var n = sample.Normal.LengthSquared >= 0.5f ? sample.Normal.Normal : new Vec3( 0, 0, 1 );
var planePoint = Vec3.Zero;
var planeCount = 0;
if ( stroke.Kind == BrushKind.Flatten )
{
foreach ( var vi in found )
{
planePoint += mesh.Positions[vi];
planeCount++;
}
if ( planeCount > 0 )
planePoint /= planeCount;
else
planePoint = sample.Position;
}
foreach ( var vi in found )
{
var pos = mesh.Positions[vi];
var dist = (pos - sample.Position).Length;
var t = dist / sample.Radius;
var weight = Falloff( t, stroke.Falloff ) * sample.Strength * (mask is null ? 1f : mask[vi]);
if ( MathF.Abs( weight ) < 1e-8f )
continue;
Vec3 next;
switch ( stroke.Kind )
{
case BrushKind.Smooth:
next = Vec3.Lerp( pos, NeighbourAverage( mesh, neighbors[vi], vi ), Math.Clamp( weight, 0f, 1f ) );
break;
case BrushKind.Draw:
next = pos + n * weight;
break;
case BrushKind.Inflate:
next = pos + frames.At[vi].Normal * weight;
break;
case BrushKind.Grab:
next = pos + sample.Direction * weight;
break;
case BrushKind.Flatten:
var d = Vec3.Dot( pos - planePoint, n );
next = pos - n * (d * Math.Clamp( weight, 0f, 1f ));
break;
case BrushKind.Pinch:
var along = Vec3.Dot( pos - sample.Position, n );
var closest = sample.Position + n * along;
next = Vec3.Lerp( pos, closest, Math.Clamp( weight, 0f, 1f ) );
break;
default:
continue;
}
if ( next.AlmostEquals( pos, 1e-8f ) )
continue;
undo.Remember( vi, pos );
mesh.Positions[vi] = next;
}
bvh.Refit( mesh );
}
public static float Falloff( float t, BrushFalloff kind )
{
t = Math.Clamp( t, 0f, 1f );
return kind switch
{
BrushFalloff.Constant => 1f,
BrushFalloff.Linear => 1f - t,
BrushFalloff.Sharp => (1f - t) * (1f - t),
_ => 1f - t * t * (3f - 2f * t)
};
}
static Vec3 NeighbourAverage( PolyMesh mesh, List<EdgeKey> edges, int vi )
{
if ( edges.Count == 0 )
return mesh.Positions[vi];
var sum = Vec3.Zero;
foreach ( var key in edges )
sum += mesh.Positions[key.A == vi ? key.B : key.A];
return sum / edges.Count;
}
static BrushSample MirrorX( BrushSample s ) =>
new(
new Vec3( -s.Position.x, s.Position.y, s.Position.z ),
new Vec3( -s.Normal.x, s.Normal.y, s.Normal.z ),
s.Radius,
s.Strength,
new Vec3( -s.Direction.x, s.Direction.y, s.Direction.z ) );
}