Editor/Effigy/SculptMask.cs
using System;
namespace Effigy;
/// <summary>
/// Per-vertex protection for one sculpt level. 1 means "brush me normally", 0 means "leave me
/// alone", and everything between is a soft edge.
///
/// THE SENSE IS THE WAY IT IS BECAUSE <see cref="Brush"/> ALREADY MULTIPLIES BY IT. A stroke's
/// weight is falloff x strength x mask, so an all-ones mask is the same as no mask at all and a
/// freshly made one changes nothing. Storing "how protected" instead would invert every stroke in
/// the tool the moment a mask existed, which is the sort of thing that reads as the brush being
/// broken.
///
/// It is deliberately NOT persisted. A mask is a working aid — the sculpting equivalent of a
/// selection — and it belongs to the session that painted it. Saving one would mean a format
/// change and a promise to keep it correct across a cage edit, for something whose whole use is
/// "hold this bit still while I do the next ten strokes".
/// </summary>
public sealed class SculptMask
{
readonly float[] _values;
public SculptMask( int count )
{
if ( count < 0 )
throw new ArgumentOutOfRangeException( nameof( count ) );
_values = new float[count];
Clear();
}
public int Count => _values.Length;
/// <summary>Bumped by every change. A display cache keyed on the sculpt's revision alone cannot
/// see a mask move, and hide-by-mask would serve the mesh from before the last stroke.</summary>
public int Revision { get; private set; }
/// <summary>The array <see cref="Brush.Apply"/> takes. Live, not a copy.</summary>
public float[] Values => _values;
public float this[int index]
{
get => _values[index];
set
{
_values[index] = Math.Clamp( value, 0f, 1f );
Revision++;
}
}
/// <summary>Nothing protected — the state in which the mask does not exist as far as a brush
/// is concerned.</summary>
public void Clear()
{
Array.Fill( _values, 1f );
Revision++;
}
/// <summary>Protect everything, which is where "mask all but this" starts.</summary>
public void Protect()
{
Array.Fill( _values, 0f );
Revision++;
}
public void Invert()
{
for ( var i = 0; i < _values.Length; i++ )
_values[i] = 1f - _values[i];
Revision++;
}
/// <summary>Whether anything is protected at all, so a UI can say so and a brush can skip the
/// multiply.</summary>
public bool Any
{
get
{
foreach ( var v in _values )
{
if ( v < 0.999f )
return true;
}
return false;
}
}
/// <summary>How much of the mesh is protected, 0 to 1 — what a readout shows.</summary>
public float ProtectedFraction
{
get
{
if ( _values.Length == 0 )
return 0f;
var sum = 0f;
foreach ( var v in _values )
sum += 1f - v;
return sum / _values.Length;
}
}
/// <summary>
/// Paint one dab, in the same shape a brush stroke has: a point, a radius, a falloff and a
/// strength. Positive strength protects, negative releases, so one control does both ways round.
/// </summary>
public void Paint( PolyMesh mesh, MeshBVH bvh, Vec3 point, float radius, float strength, BrushFalloff falloff )
{
if ( mesh is null )
throw new ArgumentNullException( nameof( mesh ) );
if ( mesh.VertexCount != _values.Length )
throw new ArgumentException(
$"This mask has {_values.Length} values and the mesh has {mesh.VertexCount} vertices." );
if ( radius <= 0f )
return;
var found = new System.Collections.Generic.List<int>();
if ( bvh is not null )
{
bvh.VerticesInRadius( mesh, point, radius, found );
}
else
{
var r2 = radius * radius;
for ( var i = 0; i < mesh.VertexCount; i++ )
{
if ( (mesh.Positions[i] - point).LengthSquared <= r2 )
found.Add( i );
}
}
foreach ( var vi in found )
{
var t = (mesh.Positions[vi] - point).Length / radius;
var w = Brush.Falloff( t, falloff ) * strength;
// Protecting SUBTRACTS, because 1 is unprotected. Getting this backwards is the one
// mistake this whole file's comment header exists to prevent.
_values[vi] = Math.Clamp( _values[vi] - w, 0f, 1f );
}
Revision++;
}
/// <summary>
/// A copy of <paramref name="mesh"/> with the fully protected parts dropped — "hide by mask".
///
/// A face goes only when EVERY corner of it is protected past the threshold. Dropping a face
/// because one corner was masked would eat the boundary of every mask, so the visible edge would
/// creep inward each time it was used.
///
/// Vertices are kept in place rather than compacted, so a vertex index still means the same
/// vertex — a hidden mesh is for looking at, and anything else here is indexed per vertex.
/// </summary>
public PolyMesh Hide( PolyMesh mesh, float threshold = 0.5f )
{
if ( mesh is null )
throw new ArgumentNullException( nameof( mesh ) );
if ( mesh.VertexCount != _values.Length )
throw new ArgumentException(
$"This mask has {_values.Length} values and the mesh has {mesh.VertexCount} vertices." );
var result = new PolyMesh();
foreach ( var p in mesh.Positions )
result.Positions.Add( p );
foreach ( var face in mesh.Faces )
{
var allProtected = true;
foreach ( var index in face.Indices )
{
if ( _values[index] > 1f - threshold )
{
allProtected = false;
break;
}
}
if ( allProtected )
continue;
result.Faces.Add( new Face( (int[])face.Indices.Clone(), (Vec2[])face.UVs.Clone(), face.Material ) );
}
return result;
}
}