Effigy.Tests/SculptTests.cs
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using Effigy;
using static Effigy.Tests.Report;
namespace Effigy.Tests;
/// <summary>
/// Sculpt stage, from the first kernel step that everything else rests on.
///
/// A sculpt is persisted per-vertex data. If two rebuilds of the same cage produce different
/// subdivision vertex layouts, every stored delta lands on the wrong point. Dictionary
/// enumeration is not a contract; this file is.
/// </summary>
public static class SculptTests
{
public static void Run()
{
Section( "sculpt: subdivision correspondence is a contract" );
TestDocumentedLayout();
TestSameCageSameMap();
TestParentsInRange();
TestEdgeBlockIsSorted();
TestFaceOrderDoesNotShuffleEdges();
TestSubdivideAgreesWithMap();
Section( "sculpt: frames are derived, orthonormal, and stable" );
TestFramesAreOrthonormal();
TestSameMeshSameFrames();
Section( "sculpt: capture then apply is the identity" );
TestCaptureApplyIdentity();
TestZeroDeltaIsIdentity();
Section( "sculpt: frame-space deltas ride a cage edit" );
TestUniformScaleKeepsRelativeSize();
TestTallerCageKeepsTheBumpOnTheSurface();
Section( "sculpt: BVH ray hits match the linear scan" );
TestBvhRayAgreesWithLinear();
TestBvhMissesAgree();
Section( "sculpt: BVH radius query matches brute force" );
TestBvhRadiusMatchesBruteForce();
Section( "sculpt: BVH refit after displacement stays correct" );
TestBvhRefitAfterDisplacement();
Section( "sculpt: brushes are volume-sane and undoable" );
TestDrawRaisesVolume();
TestInflateRaisesVolume();
TestSmoothReducesCurvature();
TestGrabZeroStrengthIsIdentity();
TestGrabMoves();
TestFlattenPullsTowardPlane();
TestPinchPullsTowardAxis();
TestUndoRestoresPositions();
TestMirrorIsSymmetric();
TestBrushHasAStopwatch();
Section( "sculpt: multires levels" );
TestFreshSculptIsTheCage();
TestAddedLevelsAreTheSubdividedCage();
TestALevelSubdividesTheDisplacedMeshBelow();
TestDroppingTheViewKeepsTheHigherLevels();
TestLowLevelEditCarriesTheHighDetail();
TestCageEditCarriesEveryLevel();
TestTopologyChangeIsRefused();
TestRemoveTopLevelKeepsWhatIsBelow();
TestRecordRefusesAMeshOfTheWrongSize();
TestStrokeLandsInTheLevelItWasMadeAt();
TestStrokeUndoPutsTheLevelBack();
TestAStrokeFollowsTheSurfaceYouCanSee();
TestEvaluateIsRepeatable();
TestTopologyIdIgnoresPositions();
TestMultiresHasAStopwatch();
Section( "sculpt: the feature in the tree" );
TestSculptFeaturePassesTheCageThroughUntouched();
TestSculptFeatureOutputsTheTopLevel();
TestAParametricEditCarriesTheSculpt();
TestATopologyChangeIsRefusedAndTheDeltasKept();
Section( "sculpt: the tool between the pointer and the mesh" );
TestHoverFindsTheSurfaceAndMissesPastIt();
TestAMissedClickStartsNothing();
TestAClickLeavesAMark();
TestInvertFlipsTheDisplacementSign();
TestHoldingStillDoesNotPileUpSamples();
TestAFastDragFillsTheGapInsteadOfDotting();
TestOneStrokeIsOneRevisionAndOneUndo();
TestUndoAndRedoRoundTrip();
TestTheStrokeLandsAtTheLevelBeingWorkedAt();
TestDraggingOffTheModelKeepsTheStroke();
TestCancellingAStrokeLeavesTheModelAlone();
TestTheDisplayMeshIsCachedUntilSomethingMoves();
TestRemovingALevelIsUndoable();
TestPuttingALevelBackOntoAChangedBaseIsRefused();
Section( "sculpt: masking holds part of the model still" );
TestAFreshMaskChangesNothing();
TestAMaskedRegionResistsTheBrush();
TestInvertSwapsWhatIsHeld();
TestAMaskStrokeIsUndoable();
TestHideByMaskDropsOnlyFullyMaskedFaces();
TestEachLevelHasItsOwnMask();
TestHidingMaskedGeometryIsAViewOnly();
Section( "sculpt: reprojecting onto a cage it was not made on" );
TestReprojectionCarriesTheShapeToANewCage();
TestReprojectionReportsWhatItManaged();
TestTheFeatureRefusesUntilAskedToReproject();
Section( "sculpt: baking the sculpt down onto the cage" );
TestAnUnsculptedBakeIsFlatAndNotEmpty();
TestABumpTiltsTheMapTheWayItLeans();
TestFlippingGreenFlipsOnlyGreen();
TestPaddingBleedsPastTheIsland();
TestMirroredUVsBakeTheSameWayUp();
TestTheBakeIsRepeatable();
TestUVsAreCheckedBeforeTheyAreTrusted();
Section( "sculpt: deltas persist beside the document" );
TestBlobRoundTripsTheDeltas();
TestAnUntouchedLevelComesBackExact();
TestBlobCostsSixBytesAVertex();
TestBlobRefusesTheWrongCage();
TestBlobRefusesSomethingThatIsNotOne();
TestTheSidecarCarriesTheSculptAcrossASaveAndLoad();
TestSavingDoesNotDeleteABlobItDidNotWrite();
}
static void TestDocumentedLayout()
{
foreach ( var (name, mesh) in Program.Closed() )
{
var (sub, map) = CatmullClark.SubdivideWithMap( mesh );
var v = mesh.VertexCount;
var e = mesh.BuildEdgeFaces().Count;
var f = mesh.FaceCount;
Check( $"{name}: map length is V+E+F",
map.OutputVertexCount == v + e + f && sub.VertexCount == map.OutputVertexCount,
$"{map.OutputVertexCount} map / {sub.VertexCount} mesh, expected {v + e + f}" );
Check( $"{name}: source counts match the cage",
map.SourceVertexCount == v && map.SourceEdgeCount == e && map.SourceFaceCount == f,
$"src {map.SourceVertexCount}v/{map.SourceEdgeCount}e/{map.SourceFaceCount}f" );
var originals = 0;
var edges = 0;
var faces = 0;
var layout = true;
for ( var i = 0; i < map.Vertices.Length; i++ )
{
var origin = map.Vertices[i].Origin;
if ( i < v )
layout &= origin == SubdivisionOrigin.Original;
else if ( i < v + e )
layout &= origin == SubdivisionOrigin.Edge;
else
layout &= origin == SubdivisionOrigin.Face;
if ( origin == SubdivisionOrigin.Original ) originals++;
else if ( origin == SubdivisionOrigin.Edge ) edges++;
else faces++;
}
Check( $"{name}: layout is originals, then edges, then faces", layout );
Check( $"{name}: block sizes are V, E, F",
originals == v && edges == e && faces == f,
$"{originals}/{edges}/{faces}" );
}
}
static void TestSameCageSameMap()
{
foreach ( var (name, mesh) in Program.Closed() )
{
var a = CatmullClark.SubdivideWithMap( mesh ).Map;
var b = CatmullClark.SubdivideWithMap( mesh ).Map;
Check( $"{name}: two subdivisions produce identical maps", MapsEqual( a, b ) );
}
var plane = Primitives.Plane( 2, 2, 2, 2 );
Check( "open plane: two subdivisions produce identical maps",
MapsEqual(
CatmullClark.SubdivideWithMap( plane ).Map,
CatmullClark.SubdivideWithMap( plane ).Map ) );
}
static void TestParentsInRange()
{
foreach ( var (name, mesh) in Program.Closed() )
{
var map = CatmullClark.SubdivideWithMap( mesh ).Map;
var edges = mesh.BuildEdgeFaces();
var ok = true;
var detail = "";
for ( var i = 0; i < map.Vertices.Length; i++ )
{
var vert = map.Vertices[i];
switch ( vert.Origin )
{
case SubdivisionOrigin.Original:
if ( vert.A != i || vert.A < 0 || vert.A >= map.SourceVertexCount )
{
ok = false;
detail = $"original {i} parent {vert.A}";
}
break;
case SubdivisionOrigin.Edge:
if ( vert.A < 0 || vert.B < 0
|| vert.A >= map.SourceVertexCount || vert.B >= map.SourceVertexCount
|| vert.A >= vert.B
|| !edges.ContainsKey( new EdgeKey( vert.A, vert.B ) ) )
{
ok = false;
detail = $"edge {i} parent [{vert.A}-{vert.B}]";
}
break;
case SubdivisionOrigin.Face:
if ( vert.A < 0 || vert.A >= map.SourceFaceCount )
{
ok = false;
detail = $"face {i} parent {vert.A}";
}
break;
}
if ( !ok )
break;
}
Check( $"{name}: every parent index is in range and names a real element", ok, detail );
}
}
static void TestEdgeBlockIsSorted()
{
foreach ( var (name, mesh) in Program.Closed() )
{
var map = CatmullClark.SubdivideWithMap( mesh ).Map;
var start = map.SourceVertexCount;
var end = start + map.SourceEdgeCount;
var sorted = true;
for ( var i = start + 1; i < end; i++ )
{
var prev = map.Vertices[i - 1];
var cur = map.Vertices[i];
if ( prev.A > cur.A || (prev.A == cur.A && prev.B >= cur.B) )
{
sorted = false;
break;
}
}
Check( $"{name}: edge block is sorted by (A, B)", sorted );
}
}
static void TestFaceOrderDoesNotShuffleEdges()
{
// Face points follow face indices, which is correct: a sculpt names them that way. Edge
// points must not: they are identified by their endpoints, and shuffling faces used to
// shuffle Dictionary insertion order. After the sort, reversing the face list keeps the
// edge block identical.
var box = Primitives.Box( 2, 2, 2 );
var shuffled = box.Clone();
shuffled.Faces.Reverse();
var a = CatmullClark.SubdivideWithMap( box ).Map;
var b = CatmullClark.SubdivideWithMap( shuffled ).Map;
Check( "reversed faces: same number of edge points",
a.SourceEdgeCount == b.SourceEdgeCount );
var edgesMatch = true;
for ( var i = 0; i < a.SourceEdgeCount; i++ )
{
var va = a.Vertices[a.SourceVertexCount + i];
var vb = b.Vertices[b.SourceVertexCount + i];
if ( va.Origin != SubdivisionOrigin.Edge || vb.Origin != SubdivisionOrigin.Edge
|| va.A != vb.A || va.B != vb.B )
{
edgesMatch = false;
break;
}
}
Check( "reversed faces: edge block is unchanged", edgesMatch );
// Face points are named by index in the source face list, so reversing the list does not
// rename them — face-point i is still Faces[i]. Topology that actually changed is a
// different cage; that is a hash miss later, not a shuffled correspondence here.
var faceParentsAreIndices = true;
for ( var i = 0; i < b.SourceFaceCount; i++ )
{
var vb = b.Vertices[b.SourceVertexCount + b.SourceEdgeCount + i];
if ( vb.Origin != SubdivisionOrigin.Face || vb.A != i )
{
faceParentsAreIndices = false;
break;
}
}
Check( "reversed faces: face-point i is still Faces[i]", faceParentsAreIndices );
}
static void TestSubdivideAgreesWithMap()
{
foreach ( var (name, mesh) in Program.Closed() )
{
var plain = CatmullClark.Subdivide( mesh, 1 );
var mapped = CatmullClark.SubdivideWithMap( mesh ).Mesh;
Check( $"{name}: Subdivide and SubdivideWithMap agree on vertex count",
plain.VertexCount == mapped.VertexCount,
$"{plain.VertexCount} vs {mapped.VertexCount}" );
Check( $"{name}: Subdivide and SubdivideWithMap agree on face count",
plain.FaceCount == mapped.FaceCount,
$"{plain.FaceCount} vs {mapped.FaceCount}" );
var positions = true;
for ( var i = 0; i < plain.VertexCount; i++ )
{
if ( !plain.Positions[i].AlmostEquals( mapped.Positions[i] ) )
{
positions = false;
break;
}
}
Check( $"{name}: Subdivide and SubdivideWithMap agree on positions", positions );
}
}
static void TestFramesAreOrthonormal()
{
foreach ( var (name, mesh) in Program.Closed() )
{
var dense = CatmullClark.Subdivide( mesh, 1 );
var frames = SculptFrames.Build( dense );
var ok = true;
var detail = "";
for ( var i = 0; i < frames.Count; i++ )
{
var f = frames.At[i];
if ( MathF.Abs( f.Normal.Length - 1f ) > 1e-4f
|| MathF.Abs( f.Tangent.Length - 1f ) > 1e-4f
|| MathF.Abs( f.Bitangent.Length - 1f ) > 1e-4f )
{
ok = false;
detail = $"vert {i} lengths {f.Normal.Length:0.####}/{f.Tangent.Length:0.####}/{f.Bitangent.Length:0.####}";
break;
}
if ( MathF.Abs( Vec3.Dot( f.Normal, f.Tangent ) ) > 1e-4f
|| MathF.Abs( Vec3.Dot( f.Normal, f.Bitangent ) ) > 1e-4f
|| MathF.Abs( Vec3.Dot( f.Tangent, f.Bitangent ) ) > 1e-4f )
{
ok = false;
detail = $"vert {i} not orthogonal";
break;
}
var handed = Vec3.Cross( f.Normal, f.Tangent );
if ( !handed.AlmostEquals( f.Bitangent, 1e-4f ) )
{
ok = false;
detail = $"vert {i} bitangent is not N×T";
break;
}
if ( f.Scale <= 0f )
{
ok = false;
detail = $"vert {i} scale {f.Scale}";
break;
}
}
Check( $"{name}: every frame is a right-handed orthonormal basis", ok, detail );
}
}
static void TestSameMeshSameFrames()
{
var dense = CatmullClark.Subdivide( Primitives.Box( 2, 2, 2 ), 1 );
var a = SculptFrames.Build( dense );
var b = SculptFrames.Build( dense );
var ok = true;
for ( var i = 0; i < a.Count; i++ )
{
if ( !a.At[i].Normal.AlmostEquals( b.At[i].Normal )
|| !a.At[i].Tangent.AlmostEquals( b.At[i].Tangent )
|| MathF.Abs( a.At[i].Scale - b.At[i].Scale ) > 1e-5f )
{
ok = false;
break;
}
}
Check( "building twice on the same mesh is identical", ok );
}
static void TestCaptureApplyIdentity()
{
var rest = CatmullClark.Subdivide( Primitives.Box( 2, 2, 2 ), 1 );
var frames = SculptFrames.Build( rest );
var displaced = rest.Clone();
var bump = FindMostAligned( rest, frames, new Vec3( 0, 0, 1 ) );
var height = 0.1f;
displaced.Positions[bump] += frames.At[bump].Normal * height;
var layer = SculptLayer.Capture( rest, displaced, frames );
var round = rest.Clone();
layer.Apply( round, frames );
var ok = true;
var worst = 0f;
for ( var i = 0; i < rest.VertexCount; i++ )
{
var err = (round.Positions[i] - displaced.Positions[i]).Length;
if ( err > worst )
worst = err;
if ( err > 1e-4f )
ok = false;
}
Check( "capture then apply restores the displaced mesh", ok, $"worst {worst:0.#######}" );
Check( "the bumped vertex actually moved",
(displaced.Positions[bump] - rest.Positions[bump]).Length > height * 0.5f );
}
static void TestZeroDeltaIsIdentity()
{
var rest = CatmullClark.Subdivide( Primitives.Box( 2, 2, 2 ), 1 );
var frames = SculptFrames.Build( rest );
var layer = SculptLayer.Capture( rest, rest, frames );
var round = rest.Clone();
layer.Apply( round, frames );
var moved = 0;
for ( var i = 0; i < rest.VertexCount; i++ )
{
if ( !round.Positions[i].AlmostEquals( rest.Positions[i], 1e-5f ) )
moved++;
}
Check( "capturing a mesh against itself applies as a no-op", moved == 0, $"{moved} vertices moved" );
}
static void TestUniformScaleKeepsRelativeSize()
{
var cage = Primitives.Box( 2, 2, 2 );
var rest = CatmullClark.Subdivide( cage, 1 );
var frames = SculptFrames.Build( rest );
var bump = FindMostAligned( rest, frames, new Vec3( 0, 0, 1 ) );
var height = 0.1f;
var displaced = rest.Clone();
displaced.Positions[bump] += frames.At[bump].Normal * height;
var layer = SculptLayer.Capture( rest, displaced, frames );
var authored = (displaced.Positions[bump] - rest.Positions[bump]).Length;
var cageSize = 2f;
var authoredRelative = authored / cageSize;
var scaledCage = MeshTransform.Transformed( cage, Xform.Scale( new Vec3( 2, 2, 2 ) ) );
var scaledRest = CatmullClark.Subdivide( scaledCage, 1 );
var scaledFrames = SculptFrames.Build( scaledRest );
var applied = scaledRest.Clone();
layer.Apply( applied, scaledFrames );
var world = applied.Positions[bump] - scaledRest.Positions[bump];
var alongNormal = Vec3.Dot( world, scaledFrames.At[bump].Normal );
var sideways = (world - scaledFrames.At[bump].Normal * alongNormal).Length;
var relative = world.Length / 4f;
Check( "after 2x scale the bump is still along the new normal",
alongNormal > authored * 0.5f && sideways < authored * 0.1f,
$"along {alongNormal:0.####}, sideways {sideways:0.####}" );
Check( "and it is still the same size relative to the cage",
MathF.Abs( relative - authoredRelative ) < 0.01f,
$"authored {authoredRelative:0.####}, after scale {relative:0.####}" );
}
static void TestTallerCageKeepsTheBumpOnTheSurface()
{
// The case the whole representation exists for: a parametric edit that is not a uniform
// scale. The bump has to ride the surface rather than stay at its old world height.
var cage = Primitives.Box( 2, 2, 2 );
var rest = CatmullClark.Subdivide( cage, 1 );
var frames = SculptFrames.Build( rest );
var bump = FindMostAligned( rest, frames, new Vec3( 0, 0, 1 ) );
var height = 0.1f;
var displaced = rest.Clone();
displaced.Positions[bump] += frames.At[bump].Normal * height;
var restZ = rest.Positions[bump].z;
var layer = SculptLayer.Capture( rest, displaced, frames );
var taller = MeshTransform.Transformed( cage, Xform.Scale( new Vec3( 1, 1, 1.2f ) ) );
var tallerRest = CatmullClark.Subdivide( taller, 1 );
var tallerFrames = SculptFrames.Build( tallerRest );
var applied = tallerRest.Clone();
layer.Apply( applied, tallerFrames );
var world = applied.Positions[bump] - tallerRest.Positions[bump];
var alongNormal = Vec3.Dot( world, tallerFrames.At[bump].Normal );
var sideways = (world - tallerFrames.At[bump].Normal * alongNormal).Length;
Check( "a 20% taller cage still has the bump on the surface",
alongNormal > height * 0.5f && sideways < height * 0.15f,
$"along {alongNormal:0.####}, sideways {sideways:0.####}" );
Check( "and the surface itself actually moved",
tallerRest.Positions[bump].z > restZ + 0.05f,
$"z {restZ:0.####} → {tallerRest.Positions[bump].z:0.####}" );
}
static void TestBvhRayAgreesWithLinear()
{
var rays = new (Vec3 Origin, Vec3 Dir)[]
{
(new Vec3( 0, 0, 5 ), new Vec3( 0, 0, -1 )),
(new Vec3( 5, 0, 0 ), new Vec3( -1, 0, 0 )),
(new Vec3( 0, 5, 0 ), new Vec3( 0, -1, 0 )),
(new Vec3( 4, 3, 5 ), new Vec3( -0.4f, -0.3f, -0.5f )),
(new Vec3( -3, -2, 4 ), new Vec3( 0.3f, 0.2f, -0.8f )),
};
foreach ( var (name, mesh) in Program.Closed() )
{
foreach ( var dense in new[] { mesh, CatmullClark.Subdivide( mesh, 1 ) } )
{
var bvh = MeshBVH.Build( dense );
var label = dense.FaceCount == mesh.FaceCount ? name : name + " L1";
var ok = true;
var detail = "";
foreach ( var (origin, dir) in rays )
{
var linear = MeshRaycast.Raycast( dense, origin, dir );
var tree = bvh.Raycast( dense, origin, dir );
if ( linear is null && tree is null )
continue;
if ( linear is null || tree is null )
{
ok = false;
detail = $"{origin} → linear {(linear is null ? "miss" : "hit")}, bvh {(tree is null ? "miss" : "hit")}";
break;
}
if ( MathF.Abs( linear.Value.Distance - tree.Value.Distance ) > 1e-4f
|| !linear.Value.Point.AlmostEquals( tree.Value.Point, 1e-4f ) )
{
ok = false;
detail = $"{origin} face {linear.Value.FaceIndex}/{tree.Value.FaceIndex} t {linear.Value.Distance:0.#####}/{tree.Value.Distance:0.#####}";
break;
}
}
Check( $"{label}: BVH and linear raycast agree", ok, detail );
}
}
}
static void TestBvhMissesAgree()
{
var box = Primitives.Box( 2, 2, 2 );
var bvh = MeshBVH.Build( box );
var origin = new Vec3( 0, 0, 5 );
var dir = new Vec3( 1, 0, 0 );
Check( "a ray that misses the box misses in both",
MeshRaycast.Raycast( box, origin, dir ) is null
&& bvh.Raycast( box, origin, dir ) is null );
var empty = MeshBVH.Build( new PolyMesh() );
Check( "an empty mesh produces an empty tree", empty.IsEmpty );
Check( "and raycasts nothing", empty.Raycast( new PolyMesh(), new Vec3( 0, 0, 1 ), new Vec3( 0, 0, -1 ) ) is null );
}
static void TestBvhRadiusMatchesBruteForce()
{
foreach ( var (name, mesh) in Program.Closed() )
{
var dense = CatmullClark.Subdivide( mesh, 1 );
var bvh = MeshBVH.Build( dense );
var queries = new (Vec3 Point, float Radius)[]
{
(Vec3.Zero, 0.25f),
(Vec3.Zero, 2f),
(dense.Positions[0], 0.15f),
(dense.FaceCentroid( dense.Faces[0] ), 0.4f),
};
var found = new System.Collections.Generic.List<int>();
var ok = true;
var detail = "";
foreach ( var (point, radius) in queries )
{
bvh.VerticesInRadius( dense, point, radius, found );
found.Sort();
var brute = new System.Collections.Generic.List<int>();
var r2 = radius * radius;
for ( var i = 0; i < dense.VertexCount; i++ )
{
if ( (dense.Positions[i] - point).LengthSquared <= r2 )
brute.Add( i );
}
if ( found.Count != brute.Count )
{
ok = false;
detail = $"{point} r={radius}: {found.Count} vs brute {brute.Count}";
break;
}
for ( var i = 0; i < brute.Count; i++ )
{
if ( found[i] != brute[i] )
{
ok = false;
detail = $"{point} r={radius}: set mismatch at {i}";
break;
}
}
if ( !ok )
break;
}
Check( $"{name} L1: radius query is exactly the brute-force set", ok, detail );
}
}
static void TestBvhRefitAfterDisplacement()
{
var mesh = CatmullClark.Subdivide( Primitives.Box( 2, 2, 2 ), 2 );
var bvh = MeshBVH.Build( mesh );
var frames = SculptFrames.Build( mesh );
var bump = FindMostAligned( mesh, frames, new Vec3( 0, 0, 1 ) );
mesh.Positions[bump] += frames.At[bump].Normal * 0.35f;
bvh.Refit( mesh );
var origin = new Vec3( 0, 0, 8 );
var dir = new Vec3( 0, 0, -1 );
var linear = MeshRaycast.Raycast( mesh, origin, dir );
var tree = bvh.Raycast( mesh, origin, dir );
Check( "after a pull, BVH ray still matches linear",
linear is not null && tree is not null
&& linear.Value.FaceIndex == tree.Value.FaceIndex
&& MathF.Abs( linear.Value.Distance - tree.Value.Distance ) < 1e-4f,
linear is null || tree is null
? "miss"
: $"face {linear.Value.FaceIndex}/{tree.Value.FaceIndex}" );
var point = mesh.Positions[bump];
var found = new System.Collections.Generic.List<int>();
bvh.VerticesInRadius( mesh, point, 0.2f, found );
found.Sort();
var brute = new System.Collections.Generic.List<int>();
const float r2 = 0.2f * 0.2f;
for ( var i = 0; i < mesh.VertexCount; i++ )
{
if ( (mesh.Positions[i] - point).LengthSquared <= r2 )
brute.Add( i );
}
var match = found.Count == brute.Count;
if ( match )
{
for ( var i = 0; i < brute.Count; i++ )
{
if ( found[i] != brute[i] )
match = false;
}
}
Check( "after a pull, radius query still matches brute force", match,
$"{found.Count} vs {brute.Count}" );
Check( "the pulled vertex is in its own radius query", found.Contains( bump ) );
}
static void TestDrawRaisesVolume()
{
var mesh = Sphere();
var before = mesh.SignedVolume();
var areaBefore = SurfaceArea( mesh );
Stroke( mesh, BrushKind.Draw, new Vec3( 0, 0, 0.5f ), strength: 0.08f, radius: 0.35f );
Check( "draw along +Z increases volume", mesh.SignedVolume() > before * 1.01f,
$"{before:0.####} → {mesh.SignedVolume():0.####}" );
Check( "and the surface area stays finite and positive",
SurfaceArea( mesh ) > areaBefore * 0.5f && float.IsFinite( SurfaceArea( mesh ) ) );
}
static void TestInflateRaisesVolume()
{
var mesh = Sphere();
var before = mesh.SignedVolume();
Stroke( mesh, BrushKind.Inflate, new Vec3( 0, 0, 0.5f ), strength: 0.06f, radius: 0.4f );
Check( "inflate increases volume", mesh.SignedVolume() > before * 1.005f,
$"{before:0.####} → {mesh.SignedVolume():0.####}" );
}
static void TestSmoothReducesCurvature()
{
var mesh = Sphere();
var frames = SculptFrames.Build( mesh );
var bump = FindMostAligned( mesh, frames, new Vec3( 0, 0, 1 ) );
mesh.Positions[bump] += frames.At[bump].Normal * 0.2f;
var before = LaplacianEnergy( mesh );
Stroke( mesh, BrushKind.Smooth, mesh.Positions[bump], strength: 0.8f, radius: 0.5f );
var after = LaplacianEnergy( mesh );
Check( "smooth strictly reduces Laplacian energy", after < before - 1e-6f,
$"{before:0.#####} → {after:0.#####}" );
}
static void TestGrabZeroStrengthIsIdentity()
{
var mesh = Sphere();
var copy = mesh.Clone();
var stroke = new BrushStroke { Kind = BrushKind.Grab };
stroke.Samples.Add( new BrushSample( new Vec3( 0, 0, 0.5f ), new Vec3( 0, 0, 1 ), 0.4f, 0f, new Vec3( 1, 0, 0 ) ) );
Brush.Apply( mesh, stroke, SculptFrames.Build( mesh ) );
var moved = 0;
for ( var i = 0; i < mesh.VertexCount; i++ )
{
if ( !mesh.Positions[i].AlmostEquals( copy.Positions[i], 1e-6f ) )
moved++;
}
Check( "grab at zero strength is the identity", moved == 0, $"{moved} vertices moved" );
}
static void TestGrabMoves()
{
var mesh = Sphere();
var before = Centroid( mesh );
var stroke = new BrushStroke { Kind = BrushKind.Grab };
stroke.Samples.Add( new BrushSample( new Vec3( 0, 0, 0.5f ), new Vec3( 0, 0, 1 ), 0.4f, 1f, new Vec3( 0.2f, 0, 0 ) ) );
Brush.Apply( mesh, stroke, SculptFrames.Build( mesh ) );
Check( "grab translates the working set", Centroid( mesh ).x > before.x + 0.001f,
$"x {before.x:0.####} → {Centroid( mesh ).x:0.####}" );
}
static void TestFlattenPullsTowardPlane()
{
var mesh = Sphere();
var frames = SculptFrames.Build( mesh );
var bump = FindMostAligned( mesh, frames, new Vec3( 0, 0, 1 ) );
mesh.Positions[bump] += frames.At[bump].Normal * 0.25f;
var heightBefore = mesh.Positions[bump].z;
Stroke( mesh, BrushKind.Flatten, mesh.Positions[bump], strength: 1f, radius: 0.45f );
Check( "flatten lowers a protruding vertex toward the patch",
mesh.Positions[bump].z < heightBefore - 0.01f,
$"z {heightBefore:0.####} → {mesh.Positions[bump].z:0.####}" );
}
static void TestPinchPullsTowardAxis()
{
var mesh = Sphere();
var point = new Vec3( 0, 0, 0.5f );
var before = MeanDistanceToAxis( mesh, point, new Vec3( 0, 0, 1 ), 0.4f );
Stroke( mesh, BrushKind.Pinch, point, strength: 0.7f, radius: 0.4f );
var after = MeanDistanceToAxis( mesh, point, new Vec3( 0, 0, 1 ), 0.4f );
Check( "pinch pulls vertices toward the stroke axis", after < before - 1e-4f,
$"{before:0.####} → {after:0.####}" );
}
static void TestUndoRestoresPositions()
{
var mesh = Sphere();
var copy = mesh.Clone();
var stroke = new BrushStroke { Kind = BrushKind.Draw };
stroke.Samples.Add( new BrushSample( new Vec3( 0, 0, 0.5f ), new Vec3( 0, 0, 1 ), 0.3f, 0.1f ) );
var undo = Brush.Apply( mesh, stroke, SculptFrames.Build( copy ) );
Check( "a real stroke records a working set", undo.Count > 0, $"{undo.Count} verts" );
undo.Restore( mesh );
var moved = 0;
for ( var i = 0; i < mesh.VertexCount; i++ )
{
if ( !mesh.Positions[i].AlmostEquals( copy.Positions[i], 1e-5f ) )
moved++;
}
Check( "restore puts every affected vertex back", moved == 0, $"{moved} still moved" );
}
static void TestMirrorIsSymmetric()
{
foreach ( var axis in new[] { MirrorAxis.X, MirrorAxis.Y, MirrorAxis.Z } )
{
var mesh = Sphere();
var stroke = new BrushStroke { Kind = BrushKind.Draw, Mirror = axis };
stroke.Samples.Add( new BrushSample( new Vec3( 0.3f, 0, 0.3f ), new Vec3( 0.5f, 0, 0.8f ).Normal, 0.25f, 0.08f ) );
Brush.Apply( mesh, stroke, SculptFrames.Build( mesh ) );
Check( $"a mirrored stroke leaves a mesh symmetric across {axis}", IsSymmetric( mesh, axis ) );
}
}
static void TestBrushHasAStopwatch()
{
var mesh = CatmullClark.Subdivide( Primitives.QuadSphere( 0.5f, 4 ), 2 );
var frames = SculptFrames.Build( mesh );
var bvh = MeshBVH.Build( mesh );
var stroke = new BrushStroke { Kind = BrushKind.Smooth };
for ( var i = 0; i < 8; i++ )
stroke.Samples.Add( new BrushSample( new Vec3( 0, 0, 0.5f ), new Vec3( 0, 0, 1 ), 0.3f, 0.5f ) );
var sw = Stopwatch.StartNew();
Brush.Apply( mesh, stroke, frames, bvh: bvh );
sw.Stop();
Check( $"8 smooth samples on {mesh.VertexCount} verts finish in under two seconds",
sw.ElapsedMilliseconds < 2000, $"{sw.ElapsedMilliseconds} ms" );
}
static void TestFreshSculptIsTheCage()
{
var cage = Primitives.Box( 2, 2, 2 );
var m = new MultiresSculpt( cage );
Check( "a new sculpt has only level 0", m.TopLevel == 0 && m.LevelCount == 1,
$"top {m.TopLevel}, count {m.LevelCount}" );
Check( "and displays the cage untouched", SamePositions( m.Display(), cage ) );
Check( "with no detail on it", !m.HasDetail( 0 ) );
}
static void TestAddedLevelsAreTheSubdividedCage()
{
var cage = Primitives.Box( 2, 2, 2 );
var m = new MultiresSculpt( cage );
m.AddLevel();
m.AddLevel();
var plain = CatmullClark.Subdivide( cage, 2 );
Check( "adding two levels puts the view on the top one", m.ViewLevel == 2 && m.TopLevel == 2,
$"view {m.ViewLevel}, top {m.TopLevel}" );
Check( "a level with zero deltas is exactly the subdivided cage", SamePositions( m.Display(), plain ),
$"{m.Display().VertexCount} verts vs {plain.VertexCount}" );
var (vertices, faces) = m.Cost( 2 );
Check( "and the predicted cost matches what it actually built",
vertices == plain.VertexCount && faces == plain.FaceCount,
$"predicted {vertices}v/{faces}f, built {plain.VertexCount}v/{plain.FaceCount}f" );
}
static void TestALevelSubdividesTheDisplacedMeshBelow()
{
// The rule the whole class rests on. Subdividing the level below AT REST instead is the
// obvious implementation, produces a perfectly plausible mesh, and quietly makes every
// lower-level edit unable to carry detail — so it is checked directly rather than inferred
// from the behaviour it causes.
var m = new MultiresSculpt( Primitives.Box( 2, 2, 2 ) );
m.AddLevel();
var flat = m.Rest( 1 );
Bump( m, 1, new Vec3( 0, 0, 1 ), 0.2f );
var displaced = m.Evaluate( 1 );
m.AddLevel();
Check( "level 2's rest surface is the subdivision of the DISPLACED level 1",
SamePositions( m.Rest( 2 ), CatmullClark.Subdivide( displaced, 1 ) ) );
Check( "and not of level 1 at rest",
!SamePositions( m.Rest( 2 ), CatmullClark.Subdivide( flat, 1 ) ) );
Check( "the new level starts with no detail of its own", !m.HasDetail( 2 ) );
}
static void TestDroppingTheViewKeepsTheHigherLevels()
{
var m = Levels( Primitives.Box( 2, 2, 2 ), 3 );
Bump( m, 3, new Vec3( 0, 0, 1 ), 0.05f );
var atTop = m.Display().Clone();
var coarse = m.Rest( 1 ).VertexCount;
m.ViewLevel = 1;
Check( "dropping the view shows the coarse mesh", m.Display().VertexCount == coarse,
$"{m.Display().VertexCount} verts, expected {coarse}" );
Check( "and does not discard the level above it", m.HasDetail( 3 ) && m.TopLevel == 3 );
m.ViewLevel = 3;
Check( "returning to level 3 is unchanged", SamePositions( m.Display(), atTop ) );
}
static void TestLowLevelEditCarriesTheHighDetail()
{
// THE feature. A pore sculpted at L3 has to ride a jaw pulled at L1, rather than being
// flattened by it or left floating where the surface used to be.
var m = Levels( Primitives.Box( 2, 2, 2 ), 3 );
var height = 0.05f;
var bump = Bump( m, 3, new Vec3( 0, 0, 1 ), height );
var restBefore = m.Rest( 3 ).Positions[bump];
var shownBefore = m.Evaluate( 3 ).Positions[bump];
// Now the coarse edit: lift the top half of level 1.
var lift = 0.3f;
var coarse = m.Evaluate( 1 );
for ( var i = 0; i < coarse.VertexCount; i++ )
{
if ( coarse.Positions[i].z > 0.5f )
coarse.Positions[i] += new Vec3( 0, 0, lift );
}
m.Record( 1, coarse );
var restAfter = m.Rest( 3 );
var shownAfter = m.Evaluate( 3 );
Check( "a level-1 edit moves the surface level 3 is written against",
restAfter.Positions[bump].z > restBefore.z + lift * 0.5f,
$"rest z {restBefore.z:0.####} → {restAfter.Positions[bump].z:0.####}" );
var world = shownAfter.Positions[bump] - restAfter.Positions[bump];
var n = m.FramesFor( 3 ).At[bump].Normal;
var along = Vec3.Dot( world, n );
var sideways = (world - n * along).Length;
Check( "and the level-3 detail rides it rather than being flattened",
along > height * 0.5f && sideways < height * 0.15f,
$"along {along:0.####}, sideways {sideways:0.####}, authored {height:0.####}" );
Check( "so the detail travelled with the surface instead of staying put",
shownAfter.Positions[bump].z > shownBefore.z + lift * 0.5f,
$"shown z {shownBefore.z:0.####} → {shownAfter.Positions[bump].z:0.####}" );
Check( "the level-1 deltas are what changed, and level 3's are untouched",
m.HasDetail( 1 ) && m.HasDetail( 3 ) );
}
static void TestCageEditCarriesEveryLevel()
{
// Same property one floor down: the parametric edit is upstream of every level at once.
var cage = Primitives.Box( 2, 2, 2 );
var m = Levels( cage, 3 );
var height = 0.05f;
var bump = Bump( m, 3, new Vec3( 0, 0, 1 ), height );
var restBefore = m.Rest( 3 ).Positions[bump].z;
m.SetCage( MeshTransform.Transformed( cage, Xform.Scale( new Vec3( 1, 1, 1.2f ) ) ) );
var rest = m.Rest( 3 );
var shown = m.Evaluate( 3 );
var world = shown.Positions[bump] - rest.Positions[bump];
var n = m.FramesFor( 3 ).At[bump].Normal;
var along = Vec3.Dot( world, n );
var sideways = (world - n * along).Length;
Check( "a 20% taller cage moves the level-3 surface", rest.Positions[bump].z > restBefore + 0.05f,
$"rest z {restBefore:0.####} → {rest.Positions[bump].z:0.####}" );
Check( "and the sculpt stays on it", along > height * 0.5f && sideways < height * 0.15f,
$"along {along:0.####}, sideways {sideways:0.####}" );
}
static void TestTopologyChangeIsRefused()
{
var cage = Primitives.Box( 2, 2, 2 );
var m = Levels( cage, 2 );
var bump = Bump( m, 2, new Vec3( 0, 0, 1 ), 0.05f );
var before = m.Evaluate( 2 ).Positions[bump];
var different = Primitives.QuadSphere( 1f, 4 );
var refusedCounts = !m.CanRebase( different, out var whyCounts );
Check( "a cage with different counts is refused", refusedCounts );
Check( "and the refusal names both models' numbers",
whyCounts is not null
&& whyCounts.Contains( different.VertexCount.ToString() )
&& whyCounts.Contains( cage.VertexCount.ToString() ),
whyCounts );
// Same counts, different wiring — the case a count check alone waves through.
var rewired = cage.Clone();
Array.Reverse( rewired.Faces[0].Indices );
Check( "a cage with the same counts but different faces is refused too",
!m.CanRebase( rewired, out var whyFaces ) && whyFaces is not null, "accepted" );
var threw = false;
try
{
m.SetCage( different );
}
catch ( InvalidOperationException )
{
threw = true;
}
Check( "SetCage throws rather than misapplying the deltas", threw );
Check( "and the sculpt is left exactly as it was",
m.TopLevel == 2 && m.HasDetail( 2 ) && m.Evaluate( 2 ).Positions[bump].AlmostEquals( before ) );
Check( "a cage that only moved is accepted",
m.CanRebase( MeshTransform.Transformed( cage, Xform.Scale( new Vec3( 1, 1, 1.2f ) ) ), out _ ) );
}
static void TestRemoveTopLevelKeepsWhatIsBelow()
{
var m = Levels( Primitives.Box( 2, 2, 2 ), 2 );
Bump( m, 1, new Vec3( 0, 0, 1 ), 0.1f );
Bump( m, 2, new Vec3( 0, 0, 1 ), 0.05f );
var atOne = m.Evaluate( 1 ).Clone();
var dropped = m.RemoveTopLevel();
Check( "removing the top level hands its deltas back", dropped is not null && dropped.Count > 0 );
Check( "the level below keeps its own sculpt",
m.TopLevel == 1 && m.HasDetail( 1 ) && SamePositions( m.Evaluate( 1 ), atOne ) );
Check( "and the view follows it down", m.ViewLevel == 1, $"view {m.ViewLevel}" );
m.RemoveTopLevel();
var threw = false;
try
{
m.RemoveTopLevel();
}
catch ( InvalidOperationException )
{
threw = true;
}
Check( "the cage level cannot be removed", threw && m.TopLevel == 0 );
}
static void TestRecordRefusesAMeshOfTheWrongSize()
{
var m = Levels( Primitives.Box( 2, 2, 2 ), 1 );
var threw = false;
try
{
m.Record( 1, Primitives.Box( 2, 2, 2 ) );
}
catch ( ArgumentException )
{
threw = true;
}
Check( "recording a mesh with the wrong vertex count is refused", threw );
Check( "and nothing was stored", !m.HasDetail( 1 ) );
}
static void TestStrokeLandsInTheLevelItWasMadeAt()
{
var m = Levels( Primitives.QuadSphere( 0.5f, 4 ), 2 );
var before = m.Evaluate( 2 ).Clone();
var stroke = new BrushStroke { Kind = BrushKind.Draw };
stroke.Samples.Add( new BrushSample( new Vec3( 0, 0, 0.5f ), new Vec3( 0, 0, 1 ), 0.3f, 0.05f ) );
var undo = m.Stroke( 2, stroke );
Check( "a stroke moved something", undo.Count > 0, $"{undo.Count} vertices" );
Check( "and it landed in the level it was made at", m.HasDetail( 2 ) && !SamePositions( m.Evaluate( 2 ), before ) );
Check( "leaving the levels below alone", !m.HasDetail( 0 ) && !m.HasDetail( 1 ) );
}
static void TestStrokeUndoPutsTheLevelBack()
{
var m = Levels( Primitives.QuadSphere( 0.5f, 4 ), 2 );
var before = m.Evaluate( 2 ).Clone();
var stroke = new BrushStroke { Kind = BrushKind.Draw };
stroke.Samples.Add( new BrushSample( new Vec3( 0, 0, 0.5f ), new Vec3( 0, 0, 1 ), 0.3f, 0.05f ) );
var undo = m.Stroke( 2, stroke );
m.Undo( 2, undo );
Check( "undoing a stroke restores the level", SamePositions( m.Evaluate( 2 ), before ) );
Check( "and leaves no detail behind", !m.HasDetail( 2 ) );
}
static void TestAStrokeFollowsTheSurfaceYouCanSee()
{
// Inflate is the one brush that reads the frames, so it is the one that can tell whether a
// stroke was handed the visible surface or the rest surface underneath it. On a fresh level
// the two are the same mesh and the question does not arise; this makes them differ first.
var m = Levels( Primitives.QuadSphere( 0.5f, 4 ), 2 );
var centre = new Vec3( 0, 0, 0.5f );
var grab = new BrushStroke { Kind = BrushKind.Grab };
grab.Samples.Add( new BrushSample( centre, new Vec3( 0, 0, 1 ), 0.35f, 1f, new Vec3( 0.2f, 0, 0 ) ) );
m.Stroke( 2, grab );
var restFrames = m.FramesFor( 2 );
var shown = m.Evaluate( 2 );
var shownFrames = SculptFrames.Build( shown );
// Where the two disagree most is the only place the choice is observable.
var vi = -1;
var lowest = float.MaxValue;
for ( var i = 0; i < shown.VertexCount; i++ )
{
if ( (shown.Positions[i] - centre).Length > 0.3f )
continue;
var d = Vec3.Dot( restFrames.At[i].Normal, shownFrames.At[i].Normal );
if ( d < lowest )
{
lowest = d;
vi = i;
}
}
Check( "the grab left the visible normals pointing somewhere else than the rest ones",
vi >= 0 && lowest < 0.99f, $"closest agreement {lowest:0.####}" );
var before = shown.Positions[vi];
var inflate = new BrushStroke { Kind = BrushKind.Inflate };
inflate.Samples.Add( new BrushSample( before, shownFrames.At[vi].Normal, 0.1f, 0.05f ) );
m.Stroke( 2, inflate );
var moved = (m.Evaluate( 2 ).Positions[vi] - before).Normal;
var alongShown = Vec3.Dot( moved, shownFrames.At[vi].Normal );
var alongRest = Vec3.Dot( moved, restFrames.At[vi].Normal );
Check( "and the stroke followed the visible surface rather than the rest surface",
alongShown > alongRest, $"visible {alongShown:0.####}, rest {alongRest:0.####}" );
}
static void TestEvaluateIsRepeatable()
{
// The cached rest surfaces are an optimisation and must not be observable. A cache that
// misses an invalidation shows up here and nowhere else until a user loses work.
var m = Levels( Primitives.Box( 2, 2, 2 ), 3 );
Bump( m, 2, new Vec3( 0, 0, 1 ), 0.1f );
var first = m.Evaluate( 3 ).Clone();
m.ViewLevel = 0;
m.Display();
m.ViewLevel = 3;
Check( "evaluating twice gives the same mesh", SamePositions( m.Evaluate( 3 ), first ) );
var rebuilt = Levels( Primitives.Box( 2, 2, 2 ), 3 );
rebuilt.Record( 2, ApplyLayer( rebuilt, 2, m.LayerAt( 2 ) ) );
Check( "and a fresh sculpt with the same deltas agrees with it",
SamePositions( rebuilt.Evaluate( 3 ), first ) );
}
static void TestTopologyIdIgnoresPositions()
{
var cage = Primitives.Box( 2, 2, 2 );
var moved = MeshTransform.Transformed( cage, Xform.Scale( new Vec3( 1, 1, 1.2f ) ) );
var rewired = cage.Clone();
Array.Reverse( rewired.Faces[0].Indices );
Check( "the topology id ignores a cage that only moved",
MultiresSculpt.TopologyId( cage ) == MultiresSculpt.TopologyId( moved ) );
Check( "and changes when the faces do",
MultiresSculpt.TopologyId( cage ) != MultiresSculpt.TopologyId( rewired ) );
Check( "and differs between two different primitives",
MultiresSculpt.TopologyId( cage ) != MultiresSculpt.TopologyId( Primitives.QuadSphere( 1f, 4 ) ) );
}
static void TestMultiresHasAStopwatch()
{
// Every evaluation walks the stack from the cage up, building frames at each level. The
// level slider is what makes that cost visible, so the suite watches it from the start.
var m = Levels( Primitives.QuadSphere( 0.5f, 4 ), 3 );
Bump( m, 1, new Vec3( 0, 0, 1 ), 0.05f );
var sw = Stopwatch.StartNew();
var mesh = m.Evaluate( 3 );
sw.Stop();
Check( $"a level-3 evaluation on {mesh.VertexCount} verts finishes in under two seconds",
sw.ElapsedMilliseconds < 2000, $"{sw.ElapsedMilliseconds} ms" );
}
static void TestAFreshMaskChangesNothing()
{
// The sense of the values is the trap: Brush multiplies by the mask, so 1 must mean
// "unprotected". A fresh mask that read 0 would silently stop every brush in the tool.
var s = Session();
var mask = s.MaskFor( 1 );
Check( "a fresh mask protects nothing", !mask.Any && mask.ProtectedFraction < 1e-6f,
$"{mask.ProtectedFraction:P0} protected" );
Check( "and is not even handed to the brush", s.ActiveMask is null );
var before = s.Sculpt.Evaluate( 1 );
s.BeginStroke( Down(), Into() );
s.EndStroke();
Check( "so a stroke under it behaves exactly as an unmasked one",
!SamePositions( s.Sculpt.Evaluate( 1 ), before ) );
}
static void TestAMaskedRegionResistsTheBrush()
{
// Two identical strokes, one on a protected patch and one not. The protected one has to move
// the surface strictly less - not "differently", less.
var free = Session();
free.BeginStroke( Down(), Into() );
free.EndStroke();
var freeMoved = Moved( free.Sculpt.Evaluate( 1 ), Session().Sculpt.Evaluate( 1 ) );
var held = Session();
var mask = held.MaskFor( 1 );
var mesh = held.Sculpt.Evaluate( 1 );
for ( var i = 0; i < mesh.VertexCount; i++ )
{
if ( mesh.Positions[i].z > 0.2f )
mask[i] = 0f;
}
Check( "painting the mask registers as protection", mask.Any && held.ActiveMask is not null );
held.BeginStroke( Down(), Into() );
held.EndStroke();
var heldMoved = Moved( held.Sculpt.Evaluate( 1 ), Session().Sculpt.Evaluate( 1 ) );
Check( "the same stroke over a masked region moves far less",
heldMoved < freeMoved * 0.2f, $"{heldMoved:0.#####} against {freeMoved:0.#####}" );
}
static void TestInvertSwapsWhatIsHeld()
{
var s = Session();
var mask = s.MaskFor( 1 );
mask[0] = 0f;
mask[1] = 0.25f;
var fraction = mask.ProtectedFraction;
s.InvertMask();
Check( "invert turns protection into freedom and back",
mask[0] == 1f && MathF.Abs( mask[1] - 0.75f ) < 1e-6f, $"{mask[0]}, {mask[1]}" );
s.InvertMask();
Check( "and inverting twice is where it started",
mask[0] == 0f && MathF.Abs( mask.ProtectedFraction - fraction ) < 1e-6f );
s.ClearMask();
Check( "clearing releases everything", !mask.Any );
}
static void TestAMaskStrokeIsUndoable()
{
var s = Session();
s.Masking = true;
s.Strength = 1f;
s.BeginStroke( Down(), Into() );
s.MoveTo( new Vec3( 0.2f, 0, 3 ), Into() );
var edit = s.EndStroke();
Check( "a mask stroke commits a mask edit, not a geometry one",
edit is not null && edit.IsMask && edit.Count > 0,
edit is null ? "nothing committed" : $"mask {edit.IsMask}, {edit.Count} vertices" );
Check( "and it painted something", s.MaskFor( 1 ).Any );
Check( "while leaving the surface alone", !s.Sculpt.HasDetail( 1 ) );
Check( "undo releases it again", s.Undo() && !s.MaskFor( 1 ).Any );
Check( "and redo paints it back", s.Redo() && s.MaskFor( 1 ).Any );
}
static void TestHideByMaskDropsOnlyFullyMaskedFaces()
{
// Dropping a face because ONE corner is masked would eat the boundary of every mask, so the
// visible edge creeps inward each time it is used.
var s = Session();
var mesh = s.Sculpt.Evaluate( 1 );
var mask = s.MaskFor( 1 );
var face = mesh.Faces[0];
mask[face.Indices[0]] = 0f;
Check( "one masked corner hides nothing", s.HiddenByMask().FaceCount == mesh.FaceCount,
$"{s.HiddenByMask().FaceCount} of {mesh.FaceCount}" );
foreach ( var index in face.Indices )
mask[index] = 0f;
var hidden = s.HiddenByMask();
Check( "a fully masked face is dropped", hidden.FaceCount == mesh.FaceCount - 1,
$"{hidden.FaceCount} of {mesh.FaceCount}" );
Check( "and the vertices stay put, so indices still mean what they meant",
hidden.VertexCount == mesh.VertexCount );
}
static void TestEachLevelHasItsOwnMask()
{
var m = Levels( Primitives.QuadSphere( 0.5f, 4 ), 2 );
var s = new SculptSession( m ) { Radius = 0.2f, Strength = 0.05f };
s.Level = 1;
var coarse = s.MaskFor( 1 );
coarse[0] = 0f;
s.Level = 2;
var fine = s.MaskFor( 2 );
Check( "a mask is sized to its own level", coarse.Count == m.Rest( 1 ).VertexCount
&& fine.Count == m.Rest( 2 ).VertexCount && coarse.Count != fine.Count,
$"{coarse.Count} and {fine.Count}" );
Check( "and the level above does not inherit what was painted below", !fine.Any );
s.Level = 1;
Check( "going back down finds the mask still there", s.MaskFor( 1 ).Any );
}
static void TestHidingMaskedGeometryIsAViewOnly()
{
// Hide-by-mask is a view, like the level, and must reach the model exactly as far as that one
// does: nowhere. Hiding half a head to reach inside it must not export half a head.
var s = Session();
var full = s.DisplayMesh.FaceCount;
var mesh = s.Sculpt.Evaluate( 1 );
var mask = s.MaskFor( 1 );
foreach ( var index in mesh.Faces[0].Indices )
mask[index] = 0f;
Check( "with hiding off, a mask changes nothing on screen", s.DisplayMesh.FaceCount == full,
$"{s.DisplayMesh.FaceCount} of {full}" );
s.HideMasked = true;
Check( "turning it on drops the masked face", s.DisplayMesh.FaceCount == full - 1,
$"{s.DisplayMesh.FaceCount} of {full}" );
Check( "and the model underneath is untouched", s.Sculpt.Evaluate( 1 ).FaceCount == full );
// The display cache is keyed on the sculpt's revision, and painting a mask does not change
// the sculpt at all - so without the mask's own revision in that key, this stays stale and
// hide-by-mask looks like it stopped working after the first use.
foreach ( var index in mesh.Faces[1].Indices )
mask[index] = 0f;
Check( "painting more mask updates the view rather than serving a stale mesh",
s.DisplayMesh.FaceCount == full - 2, $"{s.DisplayMesh.FaceCount} of {full}" );
s.HideMasked = false;
Check( "and turning it off brings everything back", s.DisplayMesh.FaceCount == full );
}
static void TestReprojectionCarriesTheShapeToANewCage()
{
// The last resort, and the point of it: the edit WAS meant, and an approximation of the
// sculpt beats losing it.
// Two plane cages of the same size and different tessellation: the same surface, no shared
// vertex indices, so nothing can be carried across by luck.
var old = Levels( Primitives.Plane( 2, 2, 4, 4 ), 2 );
var height = 0.25f;
Dome( old, 2, radius: 0.6f, height: height );
var newCage = Primitives.Plane( 2, 2, 6, 6 );
Check( "the new cage really is a different topology",
!old.CanRebase( newCage, out _ ), "it could have been rebased" );
var moved = SculptReprojection.Reproject( old, newCage, out var report );
Check( "reprojection produces a sculpt on the new cage", moved.TopLevel == 2 && moved.HasDetail( 2 ) );
Check( "most of the new surface found the old one", report.Coverage > 0.9f, report.ToString() );
// The dome has to still be there, at about its height, in about the right place.
var surface = moved.Evaluate( 2 );
var peak = float.MinValue;
var peakAt = Vec3.Zero;
for ( var i = 0; i < surface.VertexCount; i++ )
{
if ( surface.Positions[i].z > peak )
{
peak = surface.Positions[i].z;
peakAt = surface.Positions[i];
}
}
Check( "and the sculpted dome came with it, at its height",
peak > height * 0.9f && peak < height * 1.1f, $"peak {peak:0.###}, authored {height:0.###}" );
Check( "in the place it was sculpted",
MathF.Abs( peakAt.x ) < 0.2f && MathF.Abs( peakAt.y ) < 0.2f,
$"({peakAt.x:0.##}, {peakAt.y:0.##})" );
}
static void TestReprojectionReportsWhatItManaged()
{
// Coverage is what tells a caller the two shapes had nothing to do with each other, which is
// the case where the result is not worth keeping.
var old = Levels( Primitives.Plane( 2, 2, 4, 4 ), 1 );
Dome( old, 1, radius: 0.6f, height: 0.1f );
SculptReprojection.Reproject( old, Primitives.Plane( 2, 2, 6, 6 ), out var near );
// A cage nowhere near the old surface. Note that "search a tiny distance" is NOT the same
// test: two surfaces that sit on each other still hit at any reach at all, so a small radius
// on a coincident cage reports most of the mesh found - correctly. Only moving it away makes
// the shapes genuinely unrelated.
var elsewhere = Primitives.Plane( 2, 2, 6, 6 );
for ( var i = 0; i < elsewhere.VertexCount; i++ )
elsewhere.Positions[i] += new Vec3( 0, 0, 5 );
SculptReprojection.Reproject( old, elsewhere, out var lost );
Check( "a cage sitting on the old surface reports high coverage", near.Coverage > 0.9f, near.ToString() );
Check( "and one nowhere near it reports finding nothing", lost.Coverage < 0.05f, lost.ToString() );
Check( "which is what tells a caller the result is not worth keeping",
lost.Coverage < near.Coverage * 0.1f, $"{lost.Coverage:P0} against {near.Coverage:P0}" );
Check( "the report says what it searched", near.MaxDistance > 0f && near.Vertices > 0 );
}
static void TestTheFeatureRefusesUntilAskedToReproject()
{
var (studio, box, sculpt) = SculptStudio();
sculpt.Sculpt.AddLevel();
Bump( sculpt.Sculpt, 1, new Vec3( 0, 0, 1 ), 0.15f );
studio.Rebuild();
box.Shape.Index = 1;
studio.MarkDirty( box );
studio.Rebuild();
Check( "by default a changed cage is still refused", sculpt.Error is not null );
Check( "and the refusal offers reprojection as a way out",
sculpt.Diagnostic is not null
&& sculpt.Diagnostic.Remedies.Exists( r => r.Contains( "Reproject" ) ),
sculpt.Diagnostic is null ? "no diagnostic" : string.Join( "; ", sculpt.Diagnostic.Remedies ) );
sculpt.Reproject.Value = true;
studio.MarkDirty( sculpt );
studio.Rebuild();
Check( "asked for it, the model builds", sculpt.Error is null, sculpt.Error ?? "" );
Check( "the sculpt is on the new cage", sculpt.Sculpt.HasDetail( sculpt.Sculpt.TopLevel ) );
Check( "and it warns rather than saying nothing, because the deltas are gone",
sculpt.Warning is not null && sculpt.Diagnostic is not null
&& sculpt.Diagnostic.Remedies.Count > 0,
sculpt.Warning ?? "silent" );
}
/// <summary>Total distance between two meshes of the same size - how far a stroke moved things.</summary>
static float Moved( PolyMesh a, PolyMesh b )
{
var total = 0f;
for ( var i = 0; i < a.VertexCount; i++ )
total += (a.Positions[i] - b.Positions[i]).Length;
return total;
}
static void TestAnUnsculptedBakeIsFlatAndNotEmpty()
{
// "Flat" alone proves nothing — a bake whose rays all missed is also flat. The filled count
// is what separates "the sculpt matches the cage" from "nothing was measured at all".
var m = BakeFixture();
var map = NormalBake.Bake( m.Cage, m.Evaluate( 2 ), 64 );
Check( "an unsculpted bake actually hit the surface", map.FilledCount > 64 * 64 * 0.9f,
$"{map.FilledCount} of {64 * 64} texels" );
var worst = 0f;
for ( var y = 0; y < 64; y++ )
{
for ( var x = 0; x < 64; x++ )
worst = MathF.Max( worst, (map.NormalAt( x, y ) - new Vec3( 0, 0, 1 )).Length );
}
Check( "and with nothing sculpted every texel points straight out", worst < 0.02f,
$"worst deviation {worst:0.####}" );
}
static void TestABumpTiltsTheMapTheWayItLeans()
{
// The check the whole step exists for, and the one that catches a swapped or flipped tangent:
// a dome's flanks have to lean OUTWARD, in opposite directions, in the cage's own frame.
var m = BakeFixture();
Dome( m, 2, radius: 0.6f, height: 0.2f );
var map = NormalBake.Bake( m.Cage, m.Evaluate( 2 ), 64 );
var centre = map.NormalAt( 32, 32 );
var right = map.NormalAt( Texel( 0.3f ), 32 );
var left = map.NormalAt( Texel( -0.3f ), 32 );
var far = map.NormalAt( 32, Texel( 0.3f ) );
var near = map.NormalAt( 32, Texel( -0.3f ) );
Check( "the top of the bump still points along the cage normal", centre.z > 0.98f, $"z {centre.z:0.###}" );
Check( "the +u flank leans towards +u", right.x > 0.05f, $"x {right.x:0.###}" );
Check( "the -u flank leans the other way", left.x < -0.05f, $"x {left.x:0.###}" );
Check( "and the two are mirror images, not merely both non-zero",
MathF.Abs( right.x + left.x ) < 0.05f, $"{right.x:0.###} vs {left.x:0.###}" );
Check( "the +v flank leans towards +v", far.y > 0.05f, $"y {far.y:0.###}" );
Check( "the -v flank leans the other way", near.y < -0.05f, $"y {near.y:0.###}" );
// A map that came out flat would pass every sign test above by accident if the thresholds
// were loose, so say plainly that the thing is not flat.
Check( "and the map is a bump rather than a flat sheet", MathF.Abs( right.x ) > 0.1f,
$"lean {right.x:0.###}" );
// A map baked from FACE normals passes every check above and is faceted — the one thing a
// normal map exists to avoid, and invisible in numbers unless something looks for it.
//
// AN ABSOLUTE THRESHOLD CANNOT TELL THE TWO APART, which is the trap here: a smooth map of a
// steep dome has a large step between neighbouring texels too. What separates them is how the
// step behaves as the map gets finer. A smooth bake samples a continuous function, so doubling
// the resolution roughly halves the step; a faceted one is stuck to the source geometry and
// barely improves. Measured on this fixture: 0.086 at 64px, 0.048 at 128, 0.024 at 256.
var shape = m.Evaluate( 2 );
var coarse = WorstStep( NormalBake.Bake( m.Cage, shape, 64 ), 64 );
var fine = WorstStep( NormalBake.Bake( m.Cage, shape, 128 ), 128 );
Check( "the flank is smooth, not faceted: twice the resolution halves the step",
fine < coarse * 0.65f, $"{coarse:0.####} at 64px became {fine:0.####} at 128px" );
}
static void TestFlippingGreenFlipsOnlyGreen()
{
// Two conventions differ only in the sign of Y, and the wrong one lights every dent as a bump
// while looking entirely plausible in a thumbnail. Which one s&box wants still has to be
// confirmed on screen; what this pins down is that the switch does what it says.
var m = BakeFixture();
Dome( m, 2, radius: 0.6f, height: 0.2f );
var sculpted = m.Evaluate( 2 );
var normal = NormalBake.Bake( m.Cage, sculpted, 64 );
var flipped = NormalBake.Bake( m.Cage, sculpted, 64, new BakeOptions { FlipGreen = true } );
var x = 32;
var y = Texel( 0.3f );
var (r0, g0, b0) = normal.At( x, y );
var (r1, g1, b1) = flipped.At( x, y );
Check( "flipping green leaves red and blue alone", r0 == r1 && b0 == b1, $"{r0},{b0} vs {r1},{b1}" );
Check( "and mirrors green about the midpoint", Math.Abs( (g0 - 128) + (g1 - 128) ) <= 1,
$"{g0} and {g1}" );
}
static void TestPaddingBleedsPastTheIsland()
{
// Without a bleed, a shader filtering across the island edge picks up whatever is outside it,
// and seams glow once mipmaps get involved.
var m = BakeFixture();
ScaleUVs( m, 0.5f ); // the island now covers the lower-left quarter
Ramp( m, 2, 0.25f ); // tilt everything, so an edge texel is visibly not flat
var sculpted = m.Evaluate( 2 );
var bare = NormalBake.Bake( m.Cage, sculpted, 64, new BakeOptions { Padding = 0 } );
var padded = NormalBake.Bake( m.Cage, sculpted, 64, new BakeOptions { Padding = 4 } );
// Two texels past the island's edge at u = 0.5.
const int x = 34;
const int y = 16;
Check( "outside the island an unpadded bake is left at flat", bare.NormalAt( x, y ).z > 0.999f,
$"{bare.NormalAt( x, y ).z:0.####}" );
Check( "and padding carries the island's edge outwards", padded.NormalAt( x, y ).z < 0.999f,
$"{padded.NormalAt( x, y ).z:0.####}" );
Check( "without claiming those texels were measured", padded.FilledCount == bare.FilledCount,
$"{bare.FilledCount} became {padded.FilledCount}" );
}
static void TestMirroredUVsBakeTheSameWayUp()
{
// Mirrored UVs are ordinary — half a character is usually the other half flipped, and this
// tool has a Mirror feature. A mirrored island's tangent runs backwards, so the frame's
// handedness has to be read off the UVs rather than assumed; get it wrong and that island's
// green channel comes out inverted, which lights every bump on one side of the model as a
// dent. Nothing else in this file can see it, because an unmirrored fixture never takes the
// branch.
// Tilted in both axes on purpose: the y tilt is what the handedness check reads, and the x
// tilt is what makes "its u runs the other way" a real comparison rather than 0 against 0.
var m = Levels( MirroredUVPlane(), 2 );
Ramp( m, 2, 0.25f );
SlopeAlongY( m, 2, 0.25f );
var map = NormalBake.Bake( m.Cage, m.Evaluate( 2 ), 64 );
// Same v, one island each side of the seam at u = 0.5.
var normal = map.NormalAt( 16, 32 );
var mirrored = map.NormalAt( 48, 32 );
Check( "the ramp leans the map in v at all", MathF.Abs( normal.y ) > 0.1f, $"y {normal.y:0.###}" );
Check( "a mirrored island leans the same way in v as an unmirrored one",
MathF.Sign( normal.y ) == MathF.Sign( mirrored.y )
&& MathF.Abs( normal.y - mirrored.y ) < 0.05f,
$"{normal.y:0.###} vs {mirrored.y:0.###}" );
Check( "and its u runs the other way, because that is what mirrored means",
MathF.Sign( normal.x ) != MathF.Sign( mirrored.x ) || MathF.Abs( normal.x ) < 1e-3f,
$"{normal.x:0.###} vs {mirrored.x:0.###}" );
}
/// <summary>
/// Two quads side by side, the right one's UVs mirrored into its own half of the square. No
/// overlap, one seam, and opposite UV handedness either side of it.
/// </summary>
static PolyMesh MirroredUVPlane()
{
var m = new PolyMesh();
m.AddVertex( new Vec3( -1, -1, 0 ) );
m.AddVertex( new Vec3( 0, -1, 0 ) );
m.AddVertex( new Vec3( 1, -1, 0 ) );
m.AddVertex( new Vec3( -1, 1, 0 ) );
m.AddVertex( new Vec3( 0, 1, 0 ) );
m.AddVertex( new Vec3( 1, 1, 0 ) );
m.AddFace( new[] { 0, 1, 4, 3 }, new[]
{
new Vec2( 0f, 0f ), new Vec2( 0.5f, 0f ), new Vec2( 0.5f, 1f ), new Vec2( 0f, 1f )
} );
// u DECREASES with x here: the island runs backwards across 0.5 to 1.
m.AddFace( new[] { 1, 2, 5, 4 }, new[]
{
new Vec2( 1f, 0f ), new Vec2( 0.5f, 0f ), new Vec2( 0.5f, 1f ), new Vec2( 1f, 1f )
} );
return m;
}
/// <summary>Tilt the level along y, so the lean shows up in the green channel.</summary>
static void SlopeAlongY( MultiresSculpt m, int level, float slope )
{
var mesh = m.Evaluate( level );
for ( var i = 0; i < mesh.VertexCount; i++ )
{
var p = mesh.Positions[i];
mesh.Positions[i] = new Vec3( p.x, p.y, p.z + p.y * slope );
}
m.Record( level, mesh );
}
static void TestTheBakeIsRepeatable()
{
var m = BakeFixture();
Dome( m, 2, radius: 0.6f, height: 0.2f );
var sculpted = m.Evaluate( 2 );
var a = NormalBake.Bake( m.Cage, sculpted, 48 );
var b = NormalBake.Bake( m.Cage, sculpted, 48 );
var same = a.Rgb.Length == b.Rgb.Length;
for ( var i = 0; same && i < a.Rgb.Length; i++ )
same = a.Rgb[i] == b.Rgb[i];
Check( "baking the same pair twice gives the same bytes", same );
}
static void TestUVsAreCheckedBeforeTheyAreTrusted()
{
// The plan has said since it was written that a bake needs non-overlapping UVs and that
// nothing checked it. An overlapping bake does not fail: it produces a plausible map that is
// wrong wherever two faces shared a texel, which is the worst way for this to go wrong.
var plane = Primitives.Plane( 2, 2, 4, 4 );
var clean = NormalBake.Measure( plane, 128 );
Check( "a plane's own UVs can carry a bake", clean.CanBake && clean.Problem is null, clean.Problem );
Check( "and they cover the square", clean.CoveredFraction > 0.95f, $"{clean.CoveredFraction:P0}" );
Check( "with nothing claimed twice", clean.OverlappingTexels == 0, $"{clean.OverlappingTexels}" );
// Every face on the same square: overlap without anything leaving the 0-1 range, so the
// overlap test is what has to catch it rather than the bounds test.
var stacked = Primitives.Plane( 2, 2, 2, 2 );
foreach ( var face in stacked.Faces )
face.UVs = new[] { new Vec2( 0, 0 ), new Vec2( 1, 0 ), new Vec2( 1, 1 ), new Vec2( 0, 1 ) };
var piled = NormalBake.Measure( stacked, 128 );
Check( "four faces stacked on one square is refused", !piled.CanBake && piled.OverlappingTexels > 0,
$"{piled.OverlappingTexels} overlapping" );
Check( "and the refusal counts them", piled.Problem is not null && piled.Problem.Contains( "more" ),
piled.Problem );
// The tool's own default. Box projection tiles on purpose, which is right for a wall and
// wrong for a bake, and saying so here is the point of the check.
var box = Primitives.Box( 2, 2, 2 );
UVProjection.BoxProject( box );
var projected = NormalBake.Measure( box, 128 );
Check( "and box-projected UVs are named as unbakeable rather than quietly baked",
!projected.CanBake && projected.Problem is not null, projected.Problem ?? "it accepted them" );
}
/// <summary>A flat cage with clean 0-1 UVs and two sculpt levels — the simplest thing a bake can
/// be judged on, because every answer is known in advance.</summary>
static MultiresSculpt BakeFixture() => Levels( Primitives.Plane( 2, 2, 4, 4 ), 2 );
/// <summary>Largest change between neighbouring texels across the middle half of a map's centre row.</summary>
static float WorstStep( BakedMap map, int res )
{
var worst = 0f;
for ( var x = (int)(0.25f * res); x < (int)(0.75f * res); x++ )
worst = MathF.Max( worst, (map.NormalAt( x + 1, res / 2 ) - map.NormalAt( x, res / 2 )).Length );
return worst;
}
/// <summary>Texel column (or row) for a world coordinate on the 2-unit fixture plane.</summary>
static int Texel( float world ) => (int)((world / 2f + 0.5f) * 64);
/// <summary>Raise a smooth dome at the middle of the fixture, centred on UV (0.5, 0.5).</summary>
static void Dome( MultiresSculpt m, int level, float radius, float height )
{
var mesh = m.Evaluate( level );
for ( var i = 0; i < mesh.VertexCount; i++ )
{
var p = mesh.Positions[i];
var r = MathF.Sqrt( p.x * p.x + p.y * p.y );
if ( r >= radius )
continue;
var t = 1f - r / radius;
mesh.Positions[i] = new Vec3( p.x, p.y, p.z + height * t * t * (3f - 2f * t) );
}
m.Record( level, mesh );
}
/// <summary>Tilt the whole level, so every baked texel leans and an edge one is visibly not flat.</summary>
static void Ramp( MultiresSculpt m, int level, float slope )
{
var mesh = m.Evaluate( level );
for ( var i = 0; i < mesh.VertexCount; i++ )
{
var p = mesh.Positions[i];
mesh.Positions[i] = new Vec3( p.x, p.y, p.z + p.x * slope );
}
m.Record( level, mesh );
}
/// <summary>Shrink the cage's UVs so the island covers part of the square rather than all of it.</summary>
static void ScaleUVs( MultiresSculpt m, float scale )
{
var cage = m.Cage;
foreach ( var face in cage.Faces )
{
for ( var i = 0; i < face.UVs.Length; i++ )
face.UVs[i] = new Vec2( face.UVs[i].x * scale, face.UVs[i].y * scale );
}
m.SetCage( cage );
}
static void TestHoverFindsTheSurfaceAndMissesPastIt()
{
var s = Session();
var hit = s.Hover( new Vec3( 0, 0, 3 ), new Vec3( 0, 0, -1 ) );
Check( "the cursor finds the surface under the ray",
hit is not null && hit.Value.Point.z > 0.4f && hit.Value.Point.z < 0.6f,
hit is null ? "missed" : $"{hit.Value.Point.z:0.###}" );
Check( "and a ray pointing away finds nothing",
s.Hover( new Vec3( 0, 0, 3 ), new Vec3( 0, 0, 1 ) ) is null );
}
static void TestAMissedClickStartsNothing()
{
var s = Session();
var before = s.Sculpt.Revision;
Check( "clicking past the model does not begin a stroke",
!s.BeginStroke( new Vec3( 0, 0, 3 ), new Vec3( 0, 0, 1 ) ) && !s.IsStroking );
Check( "and changes nothing", s.Sculpt.Revision == before && !s.Sculpt.HasDetail( 1 ) );
}
static void TestAClickLeavesAMark()
{
// A click that lands and does nothing is the failure mode worth guarding: the first sample
// has to be applied on the press, not on the first move, or a tap reads as a dead tool.
var s = Session();
var before = s.Sculpt.Evaluate( 1 );
Check( "the press lands on the model", s.BeginStroke( Down(), Into() ) );
var edit = s.EndStroke();
Check( "and a single click leaves a mark", edit is not null && edit.Count > 0,
edit is null ? "nothing was committed" : "0 vertices" );
Check( "which is recorded at the level", s.Sculpt.HasDetail( 1 ) );
Check( "and shows on the model", !SamePositions( s.Sculpt.Evaluate( 1 ), before ) );
}
static void TestInvertFlipsTheDisplacementSign()
{
// Draw pushes the surface out along its normal, so a Ctrl-drag has to do the reverse — carve
// in. Invert is a session flag rather than a negated Strength because the number in the bar
// must not flip while a modifier is held; this checks both halves: the displacement sign
// flips, and Strength itself does not.
var outward = Session();
var rest = outward.Sculpt.Evaluate( 1 ).Clone();
var flatPeak = HighestZ( rest );
outward.BeginStroke( Down(), Into() );
outward.EndStroke();
var peakOut = HighestZ( outward.Sculpt.Evaluate( 1 ) );
var inward = Session();
inward.Inverted = true;
inward.BeginStroke( Down(), Into() );
inward.EndStroke();
var peakIn = HighestZ( inward.Sculpt.Evaluate( 1 ) );
Check( "draw raises the surface", peakOut > flatPeak,
$"{flatPeak:0.####} → {peakOut:0.####}" );
Check( "an inverted draw lowers it instead", peakIn < flatPeak,
$"{flatPeak:0.####} → {peakIn:0.####}" );
Check( "and Strength itself never changed", inward.Strength == 0.05f,
$"{inward.Strength:0.####}" );
}
static void TestHoldingStillDoesNotPileUpSamples()
{
// A pointer reports far faster than a brush needs. Without spacing, holding still would bite
// harder the longer you hovered, and a slow drag would cut deeper than a quick one for the
// same gesture.
var s = Session();
s.BeginStroke( Down(), Into() );
var afterPress = s.DisplayMesh.Clone();
var samples = 0;
for ( var i = 0; i < 10; i++ )
samples += s.MoveTo( Down(), Into() );
Check( "ten reports from a still pointer produce no extra samples", samples == 0, $"{samples} samples" );
Check( "and the mesh does not creep", SamePositions( s.DisplayMesh, afterPress ) );
s.EndStroke();
}
static void TestAFastDragFillsTheGapInsteadOfDotting()
{
var s = Session();
s.Radius = 0.15f;
s.BeginStroke( new Vec3( -0.35f, 0, 3 ), Into() );
var before = s.DisplayMesh.Clone();
var samples = s.MoveTo( new Vec3( 0.35f, 0, 3 ), Into() );
Check( "one big jump becomes several samples rather than one", samples > 1, $"{samples} samples" );
// The point of filling it in: the middle of the path is sculpted too, not just the ends.
var middle = false;
for ( var i = 0; i < before.VertexCount; i++ )
{
var p = before.Positions[i];
if ( MathF.Abs( p.x ) < 0.1f && p.z > 0.3f && !p.AlmostEquals( s.DisplayMesh.Positions[i], 1e-5f ) )
middle = true;
}
Check( "and the middle of the drag was sculpted, not skipped over", middle );
s.EndStroke();
}
static void TestOneStrokeIsOneRevisionAndOneUndo()
{
// The design this asserts is a performance one: a stroke brushes a working mesh and records
// once. Recording per sample would be correct and unusably slow, and this is what says which
// of the two is in the file.
var s = Session();
var before = s.Sculpt.Revision;
s.BeginStroke( new Vec3( -0.3f, 0, 3 ), Into() );
var samples = s.MoveTo( new Vec3( 0.3f, 0, 3 ), Into() );
s.EndStroke();
Check( $"a stroke of {samples} samples is one revision", s.Sculpt.Revision == before + 1,
$"revision {before} became {s.Sculpt.Revision}" );
Check( "and one undo step", s.CanUndo && !s.CanRedo );
}
static void TestUndoAndRedoRoundTrip()
{
var s = Session();
var before = s.Sculpt.Evaluate( 1 );
s.BeginStroke( new Vec3( -0.3f, 0, 3 ), Into() );
s.MoveTo( new Vec3( 0.3f, 0, 3 ), Into() );
var edit = s.EndStroke();
var after = s.Sculpt.Evaluate( 1 );
Check( "the stroke moved a working set rather than the whole level",
edit.Count > 0 && edit.Count < before.VertexCount,
$"{edit.Count} of {before.VertexCount} vertices" );
Check( "undo puts the surface back", s.Undo() && SamePositions( s.Sculpt.Evaluate( 1 ), before ) );
Check( "and there is nothing left to undo", !s.CanUndo && s.CanRedo );
Check( "redo puts the stroke back", s.Redo() && SamePositions( s.Sculpt.Evaluate( 1 ), after ) );
}
static void TestTheStrokeLandsAtTheLevelBeingWorkedAt()
{
var m = Levels( Primitives.QuadSphere( 0.5f, 4 ), 2 );
var s = new SculptSession( m ) { Radius = 0.2f, Strength = 0.05f };
s.Level = 1;
s.BeginStroke( Down(), Into() );
s.EndStroke();
Check( "the stroke lands at the level being worked at", m.HasDetail( 1 ) );
Check( "and not at the one above it", !m.HasDetail( 2 ) );
Check( "the level and the view are one value, not two that can disagree",
s.Level == m.ViewLevel && s.Level == 1 );
}
static void TestDraggingOffTheModelKeepsTheStroke()
{
// Dragging off the silhouette and back on is an ordinary gesture. Ending the stroke there
// would make the tool feel like it drops what you were doing.
var s = Session();
s.BeginStroke( Down(), Into() );
var samples = s.MoveTo( new Vec3( 0, 0, 3 ), new Vec3( 0, 0, 1 ) );
Check( "a ray that misses adds nothing", samples == 0 );
Check( "but the stroke is still running", s.IsStroking );
s.MoveTo( new Vec3( 0.3f, 0, 3 ), Into() );
Check( "and it picks up again on the way back", s.EndStroke() is not null );
}
static void TestCancellingAStrokeLeavesTheModelAlone()
{
var s = Session();
var before = s.Sculpt.Evaluate( 1 );
s.BeginStroke( Down(), Into() );
s.MoveTo( new Vec3( 0.3f, 0, 3 ), Into() );
s.CancelStroke();
Check( "cancelling leaves the model as it was",
!s.IsStroking && SamePositions( s.Sculpt.Evaluate( 1 ), before ) );
Check( "and leaves nothing on the undo stack", !s.CanUndo );
}
static void TestTheDisplayMeshIsCachedUntilSomethingMoves()
{
// A viewport asks every frame, and evaluating the level stack is not a per-frame cost.
var s = Session();
var first = s.DisplayMesh;
Check( "asking twice does not evaluate twice", ReferenceEquals( first, s.DisplayMesh ) );
s.BeginStroke( Down(), Into() );
Check( "mid-stroke the viewport gets the live working mesh", !ReferenceEquals( first, s.DisplayMesh ) );
s.EndStroke();
Check( "and after the stroke it is rebuilt rather than served stale",
!ReferenceEquals( first, s.DisplayMesh ) && !SamePositions( s.DisplayMesh, first ) );
}
static void TestRemovingALevelIsUndoable()
{
// REMOVING A LEVEL THROWS AWAY EVERY DELTA ON IT. That is why it sat in the kernel unexposed
// until the session could hold what it dropped: a destructive button with no way back is one
// nobody should be given, and this is the check that says it now has one.
var m = Levels( Primitives.QuadSphere( 0.5f, 4 ), 2 );
var s = new SculptSession( m ) { Radius = 0.2f, Strength = 0.05f };
Bump( m, 1, new Vec3( 0, 0, 1 ), 0.1f );
Bump( m, 2, new Vec3( 0, 0, 1 ), 0.05f );
var atTop = m.Evaluate( 2 ).Clone();
var atOne = m.Evaluate( 1 ).Clone();
Check( "the fine level is removed", s.RemoveTopLevel() && m.TopLevel == 1 );
Check( "and the coarse one is untouched by it", SamePositions( m.Evaluate( 1 ), atOne ) );
Check( "undo puts the level back", s.Undo() && m.TopLevel == 2 );
Check( "with the detail it had", SamePositions( m.Evaluate( 2 ), atTop ) );
Check( "and redo takes it away again", s.Redo() && m.TopLevel == 1 );
Check( "which undo can still reverse", s.Undo() && m.TopLevel == 2
&& SamePositions( m.Evaluate( 2 ), atTop ) );
Check( "the cage level can never be removed",
!new SculptSession( new MultiresSculpt( Primitives.Box( 1, 1, 1 ) ) ).RemoveTopLevel() );
}
static void TestPuttingALevelBackOntoAChangedBaseIsRefused()
{
// The layer only fits if the levels below it are as they were. Sculpting underneath does not
// change the vertex COUNT, so that case still fits and should still work - but a cage swap
// that changes the count must be refused rather than land old detail on new vertices.
var m = Levels( Primitives.QuadSphere( 0.5f, 4 ), 1 );
Bump( m, 1, new Vec3( 0, 0, 1 ), 0.1f );
var dropped = m.RemoveTopLevel();
Bump( m, 0, new Vec3( 0, 0, 1 ), 0.05f );
var threw = false;
try
{
m.RestoreTopLevel( dropped );
}
catch ( ArgumentException )
{
threw = true;
}
Check( "sculpting the level below does not stop the level going back", !threw
&& m.TopLevel == 1, "it was refused" );
var wrongSize = new SculptLayer( new Vec3[3] );
var refused = false;
try
{
m.RestoreTopLevel( wrongSize );
}
catch ( ArgumentException )
{
refused = true;
}
Check( "but a layer of the wrong size is", refused );
Check( "and the refusal leaves the sculpt as it found it", m.TopLevel == 1 );
}
/// <summary>A one-level sculpt on a sphere, with a session on it.</summary>
static SculptSession Session() =>
new( Levels( Primitives.QuadSphere( 0.5f, 4 ), 1 ) ) { Radius = 0.25f, Strength = 0.05f };
/// <summary>A ray origin above the sphere, and the direction that reaches it.</summary>
static Vec3 Down() => new( 0, 0, 3 );
static Vec3 Into() => new( 0, 0, -1 );
static void TestSculptFeaturePassesTheCageThroughUntouched()
{
var (studio, _, sculpt) = SculptStudio();
Check( "a sculpt feature with nothing sculpted builds cleanly",
sculpt.Error is null && studio.Bodies.Count == 1, sculpt.Error ?? $"{studio.Bodies.Count} bodies" );
Check( "and hands the cage through unchanged",
SamePositions( studio.Bodies[0].Mesh, Primitives.Box( 2, 2, 2 ) ),
$"{studio.Bodies[0].Mesh.VertexCount} verts" );
Check( "it got its cage from the body underneath it", sculpt.Sculpt is not null && sculpt.Sculpt.TopLevel == 0 );
}
static void TestSculptFeatureOutputsTheTopLevel()
{
var (studio, _, sculpt) = SculptStudio();
sculpt.Sculpt.AddLevel();
sculpt.Sculpt.AddLevel();
Bump( sculpt.Sculpt, 2, new Vec3( 0, 0, 1 ), 0.1f );
// The view is a UI state and must not reach the model — an L1 preview that exported an L1
// model would lose the sculpt silently, which is the failure worth designing against.
sculpt.Sculpt.ViewLevel = 1;
studio.Rebuild();
Check( "the feature builds the top level, not the level being viewed",
sculpt.Error is null && studio.Bodies[0].Mesh.VertexCount == sculpt.Sculpt.Rest( 2 ).VertexCount,
sculpt.Error ?? $"{studio.Bodies[0].Mesh.VertexCount} verts" );
Check( "and the body is exactly what the sculpt evaluates to",
SamePositions( studio.Bodies[0].Mesh, sculpt.Sculpt.Evaluate( 2 ) ) );
}
static void TestAParametricEditCarriesTheSculpt()
{
// The claim the whole pipeline is sold on: change the CAD after sculpting and keep the sculpt.
var (studio, box, sculpt) = SculptStudio();
sculpt.Sculpt.AddLevel();
sculpt.Sculpt.AddLevel();
var height = 0.1f;
var bump = Bump( sculpt.Sculpt, 2, new Vec3( 0, 0, 1 ), height );
studio.Rebuild();
var before = studio.Bodies[0].Mesh.Positions[bump].z;
box.SizeZ.Value = 3f;
studio.MarkDirty( box );
studio.Rebuild();
Check( "a taller box rebuilds with no complaint from the sculpt", sculpt.Error is null, sculpt.Error ?? "" );
var rest = sculpt.Sculpt.Rest( 2 );
var shown = studio.Bodies[0].Mesh;
var n = sculpt.Sculpt.FramesFor( 2 ).At[bump].Normal;
var world = shown.Positions[bump] - rest.Positions[bump];
var along = Vec3.Dot( world, n );
Check( "the cage actually moved", shown.Positions[bump].z > before + 0.1f,
$"z {before:0.####} → {shown.Positions[bump].z:0.####}" );
Check( "and the sculpt is still on the surface", along > height * 0.5f,
$"along {along:0.####}, authored {height:0.####}" );
}
static void TestATopologyChangeIsRefusedAndTheDeltasKept()
{
var (studio, box, sculpt) = SculptStudio();
sculpt.Sculpt.AddLevel();
Bump( sculpt.Sculpt, 1, new Vec3( 0, 0, 1 ), 0.1f );
studio.Rebuild();
var sculpted = studio.Bodies[0].Mesh.Clone();
box.Shape.Index = 1; // a cylinder is a different cage, not a moved one
studio.MarkDirty( box );
studio.Rebuild();
Check( "changing the cage's topology is an error, not a silent reshape", sculpt.Error is not null,
"it accepted it" );
Check( "and the refusal has a cause and a way out",
sculpt.Diagnostic is not null
&& !string.IsNullOrWhiteSpace( sculpt.Diagnostic.Cause )
&& sculpt.Diagnostic.Remedies.Count > 0,
sculpt.Diagnostic is null ? "no diagnostic" : sculpt.Diagnostic.Cause );
// Guarded rather than bare: a regression that restarts the sculpt leaves no level 1 at all,
// and asking an absent level for its detail would abort the run instead of failing this line.
Check( "the deltas are kept rather than dropped",
sculpt.Sculpt.TopLevel >= 1 && sculpt.Sculpt.HasDetail( 1 ),
$"top level {sculpt.Sculpt.TopLevel}" );
// The point of keeping them: undoing the upstream edit brings the sculpt back exactly.
box.Shape.Index = 0;
studio.MarkDirty( box );
studio.Rebuild();
Check( "so undoing the edit restores the sculpt exactly",
sculpt.Error is null && SamePositions( studio.Bodies[0].Mesh, sculpted ), sculpt.Error ?? "" );
}
static void TestBlobRoundTripsTheDeltas()
{
var cage = Primitives.Box( 2, 2, 2 );
var m = Levels( cage, 2 );
Bump( m, 1, new Vec3( 0, 0, 1 ), 0.15f );
Bump( m, 2, new Vec3( 1, 0, 0 ), 0.05f );
var before = m.Evaluate( 2 );
var bytes = SculptBlob.Write( m );
var back = SculptBlob.Read( bytes, cage );
var after = back.Evaluate( 2 );
Check( "the blob carries every level", back.LevelCount == m.LevelCount,
$"{m.LevelCount} became {back.LevelCount}" );
var worst = 0f;
for ( var i = 0; i < before.VertexCount; i++ )
worst = MathF.Max( worst, (before.Positions[i] - after.Positions[i]).Length );
// 16 bits across one level's delta box, on a cage two units across. The quantisation step is
// far below anything visible; this is the check that says so in numbers rather than in prose.
Check( "and 16-bit deltas come back within a ten-thousandth of a unit", worst < 1e-4f,
$"worst {worst:0.#######}" );
}
static void TestAnUntouchedLevelComesBackExact()
{
// A level nobody sculpted has to round-trip bit-identical, not merely close: a model saved
// and reloaded twenty times must not drift away from the cage a hundredth at a time.
var cage = Primitives.Box( 2, 2, 2 );
var m = Levels( cage, 2 );
Bump( m, 2, new Vec3( 0, 0, 1 ), 0.1f );
var back = SculptBlob.Read( SculptBlob.Write( m ), cage );
var exact = true;
foreach ( var d in back.LayerAt( 1 ).Deltas )
exact &= d.x == 0f && d.y == 0f && d.z == 0f;
Check( "an untouched level comes back exactly zero, not nearly zero", exact );
Check( "and an untouched cage level too", !back.HasDetail( 0 ) );
}
static void TestBlobCostsSixBytesAVertex()
{
var cage = Primitives.Box( 2, 2, 2 );
var m = Levels( cage, 3 );
Bump( m, 3, new Vec3( 0, 0, 1 ), 0.05f );
var bytes = SculptBlob.Write( m );
var vertices = 0;
for ( var level = 0; level < m.LevelCount; level++ )
vertices += m.LayerAt( level ).Count;
Check( "the predicted size is the actual size", SculptBlob.PredictBytes( m ) == bytes.Length,
$"predicted {SculptBlob.PredictBytes( m )}, wrote {bytes.Length}" );
Check( $"and {vertices} vertices cost six bytes each plus a small header",
bytes.Length - vertices * SculptBlob.BytesPerVertex is > 0 and < 256,
$"{bytes.Length} bytes for {vertices} vertices" );
}
static void TestBlobRefusesTheWrongCage()
{
var m = Levels( Primitives.Box( 2, 2, 2 ), 1 );
Bump( m, 1, new Vec3( 0, 0, 1 ), 0.1f );
var bytes = SculptBlob.Write( m );
Check( "a blob read against a different cage is refused",
Throws( () => SculptBlob.Read( bytes, Primitives.QuadSphere( 1f, 4 ) ), out var why ) );
Check( "and says why rather than producing a mangled model",
why is not null && why.Contains( "different cage" ), why ?? "" );
// The case a vertex count alone waves through.
var rewired = Primitives.Box( 2, 2, 2 );
Array.Reverse( rewired.Faces[0].Indices );
Check( "including a cage with the same counts but different faces",
Throws( () => SculptBlob.Read( bytes, rewired ), out _ ) );
Check( "and the same cage is accepted",
!Throws( () => SculptBlob.Read( bytes, Primitives.Box( 2, 2, 2 ) ), out _ ) );
}
static void TestBlobRefusesSomethingThatIsNotOne()
{
var cage = Primitives.Box( 2, 2, 2 );
Check( "a short file is refused", Throws( () => SculptBlob.Read( new byte[] { 1, 2, 3 }, cage ), out _ ) );
var notABlob = new byte[64];
Check( "so is something the right size that is not a blob",
Throws( () => SculptBlob.Read( notABlob, cage ), out var why ) && why.Contains( "EFFIGYSC" ), why ?? "" );
var newer = SculptBlob.Write( new MultiresSculpt( cage ) );
BitConverter.GetBytes( SculptBlob.Version + 9 ).CopyTo( newer, 8 );
Check( "and a blob from a newer build is refused by version",
Throws( () => SculptBlob.Read( newer, cage ), out var version ) && version.Contains( "newer" ),
version ?? "" );
}
static void TestTheSidecarCarriesTheSculptAcrossASaveAndLoad()
{
// The end-to-end claim of step 6: close the document, open it, and the sculpt is still there.
// Field-by-field checks on the feature tree can pass while this fails, because the deltas are
// deliberately not in the document at all.
var dir = Path.Combine( Path.GetTempPath(), $"effigy-sculpt-{Guid.NewGuid():N}" );
Directory.CreateDirectory( dir );
try
{
var path = Path.Combine( dir, "model" + StudioDocument.Extension );
var (studio, _, sculpt) = SculptStudio();
sculpt.Sculpt.AddLevel();
sculpt.Sculpt.AddLevel();
Bump( sculpt.Sculpt, 2, new Vec3( 0, 0, 1 ), 0.12f );
studio.Rebuild();
var before = studio.Bodies[0].Mesh.Clone();
StudioDocument.WriteFile( studio, path );
var written = SculptSidecar.Save( studio, path );
Check( "saving writes one blob beside the document", written == 1
&& File.Exists( SculptSidecar.PathFor( path, sculpt.Id ) ), $"wrote {written}" );
var back = StudioDocument.ReadFile( path );
var loaded = SculptSidecar.Load( back, path );
var report = back.Rebuild();
var reloaded = back.Features[1] as SculptFeature;
Check( "loading hands the blob to the feature that owns it", loaded == 1 && reloaded is not null );
Check( "the reloaded document rebuilds without errors", !report.HasErrors, report.ToString() );
Check( "the sculpt is on the model again, to the same vertex",
SamePositions( back.Bodies[0].Mesh, before ),
$"{before.VertexCount} verts vs {back.Bodies[0].Mesh.VertexCount}" );
Check( "and it came back as levels rather than a baked mesh",
reloaded is not null && reloaded.Sculpt is not null && reloaded.Sculpt.TopLevel == 2
&& reloaded.Sculpt.HasDetail( 2 ),
reloaded?.Sculpt is null ? "no sculpt" : $"top {reloaded.Sculpt.TopLevel}" );
}
finally
{
Directory.Delete( dir, recursive: true );
}
}
static void TestSavingDoesNotDeleteABlobItDidNotWrite()
{
var dir = Path.Combine( Path.GetTempPath(), $"effigy-sculpt-{Guid.NewGuid():N}" );
Directory.CreateDirectory( dir );
try
{
var path = Path.Combine( dir, "model" + StudioDocument.Extension );
var (studio, _, sculpt) = SculptStudio();
sculpt.Sculpt.AddLevel();
Bump( sculpt.Sculpt, 1, new Vec3( 0, 0, 1 ), 0.1f );
SculptSidecar.Save( studio, path );
var stray = Path.Combine( SculptSidecar.DirectoryFor( path ), "deadbeef.bin" );
File.WriteAllBytes( stray, new byte[] { 1, 2, 3 } );
SculptSidecar.Save( studio, path );
Check( "saving leaves a blob whose feature it does not know about", File.Exists( stray ) );
var pruned = SculptSidecar.Prune( studio, path );
Check( "and pruning removes it only when asked",
pruned == 1 && !File.Exists( stray ) && File.Exists( SculptSidecar.PathFor( path, sculpt.Id ) ),
$"pruned {pruned}" );
}
finally
{
Directory.Delete( dir, recursive: true );
}
}
/// <summary>A 2x2x2 box with a sculpt feature on it, rebuilt once.</summary>
static (PartStudio Studio, PrimitiveFeature Box, SculptFeature Sculpt) SculptStudio()
{
var studio = new PartStudio();
var box = studio.Add( new PrimitiveFeature() );
box.SizeX.Value = 2f;
box.SizeY.Value = 2f;
box.SizeZ.Value = 2f;
var sculpt = studio.Add( new SculptFeature() );
studio.Rebuild();
return (studio, box, sculpt);
}
static bool Throws( Action action, out string message )
{
message = null;
try
{
action();
return false;
}
catch ( Exception e )
{
message = e.Message;
return true;
}
}
/// <summary>A sculpt with `levels` levels above the cage, nothing sculpted yet.</summary>
static MultiresSculpt Levels( PolyMesh cage, int levels )
{
var m = new MultiresSculpt( cage );
for ( var i = 0; i < levels; i++ )
m.AddLevel();
return m;
}
/// <summary>Push one vertex out along its own normal and record it, as a brush stroke would.</summary>
static int Bump( MultiresSculpt m, int level, Vec3 towards, float height )
{
var mesh = m.Evaluate( level );
var frames = m.FramesFor( level );
var vi = FindMostAligned( mesh, frames, towards );
mesh.Positions[vi] += frames.At[vi].Normal * height;
m.Record( level, mesh );
return vi;
}
/// <summary>The level's rest surface with a layer applied — what Record wants handed back.</summary>
static PolyMesh ApplyLayer( MultiresSculpt m, int level, SculptLayer layer )
{
var mesh = m.Rest( level );
layer.Apply( mesh, m.FramesFor( level ) );
return mesh;
}
static bool SamePositions( PolyMesh a, PolyMesh b, float eps = 1e-4f )
{
if ( a.VertexCount != b.VertexCount )
return false;
for ( var i = 0; i < a.VertexCount; i++ )
{
if ( !a.Positions[i].AlmostEquals( b.Positions[i], eps ) )
return false;
}
return true;
}
static PolyMesh Sphere() => CatmullClark.Subdivide( Primitives.QuadSphere( 0.5f, 4 ), 1 );
static void Stroke( PolyMesh mesh, BrushKind kind, Vec3 point, float strength, float radius )
{
var n = point.LengthSquared > 1e-8f ? point.Normal : new Vec3( 0, 0, 1 );
var stroke = new BrushStroke { Kind = kind };
stroke.Samples.Add( new BrushSample( point, n, radius, strength ) );
Brush.Apply( mesh, stroke, SculptFrames.Build( mesh ) );
}
static float SurfaceArea( PolyMesh mesh )
{
var a = 0f;
foreach ( var f in mesh.Faces )
a += mesh.FaceArea( f );
return a;
}
static float LaplacianEnergy( PolyMesh mesh )
{
var edges = mesh.BuildVertexEdges();
var e = 0f;
for ( var vi = 0; vi < mesh.VertexCount; vi++ )
{
if ( edges[vi].Count == 0 )
continue;
var sum = Vec3.Zero;
foreach ( var key in edges[vi] )
sum += mesh.Positions[key.A == vi ? key.B : key.A];
var avg = sum / edges[vi].Count;
var d = mesh.Positions[vi] - avg;
e += d.LengthSquared;
}
return e;
}
static Vec3 Centroid( PolyMesh mesh )
{
var s = Vec3.Zero;
foreach ( var p in mesh.Positions )
s += p;
return s / mesh.VertexCount;
}
static float HighestZ( PolyMesh mesh )
{
var peak = float.NegativeInfinity;
foreach ( var p in mesh.Positions )
{
if ( p.z > peak )
peak = p.z;
}
return peak;
}
static float MeanDistanceToAxis( PolyMesh mesh, Vec3 origin, Vec3 axis, float radius )
{
axis = axis.Normal;
var r2 = radius * radius;
var sum = 0f;
var n = 0;
foreach ( var p in mesh.Positions )
{
if ( (p - origin).LengthSquared > r2 )
continue;
var closest = origin + axis * Vec3.Dot( p - origin, axis );
sum += (p - closest).Length;
n++;
}
return n == 0 ? 0f : sum / n;
}
static bool IsSymmetric( PolyMesh mesh, MirrorAxis axis )
{
for ( var i = 0; i < mesh.VertexCount; i++ )
{
var p = mesh.Positions[i];
var target = Brush.Mirror( p, axis );
var found = false;
for ( var j = 0; j < mesh.VertexCount; j++ )
{
if ( mesh.Positions[j].AlmostEquals( target, 2e-3f ) )
{
found = true;
break;
}
}
if ( !found )
return false;
}
return true;
}
static int FindMostAligned( PolyMesh mesh, SculptFrames frames, Vec3 direction )
{
var best = 0;
var bestDot = float.NegativeInfinity;
var dir = direction.Normal;
for ( var i = 0; i < frames.Count; i++ )
{
var d = Vec3.Dot( frames.At[i].Normal, dir );
if ( d > bestDot )
{
bestDot = d;
best = i;
}
}
return best;
}
static bool MapsEqual( SubdivisionMap a, SubdivisionMap b )
{
if ( a.SourceVertexCount != b.SourceVertexCount
|| a.SourceEdgeCount != b.SourceEdgeCount
|| a.SourceFaceCount != b.SourceFaceCount
|| a.Vertices.Length != b.Vertices.Length )
return false;
for ( var i = 0; i < a.Vertices.Length; i++ )
{
var va = a.Vertices[i];
var vb = b.Vertices[i];
if ( va.Origin != vb.Origin || va.A != vb.A || va.B != vb.B )
return false;
}
return true;
}
}