Effigy.Tests/PaintDocumentTests.cs
using System;
using System.Linq;
using Effigy;
using static Effigy.Tests.Report;
namespace Effigy.Tests;
/// <summary>
/// Paint strokes survive the document round trip.
///
/// WHY THIS MATTERS MORE THAN THE OTHER DOCUMENT TESTS. Strokes are a LOG — colour blending does not
/// commute, so a round trip that reordered them would be silent corruption rather than a diff. The
/// order assertion below is the whole reason paint is stored as strokes rather than dabs, and it is
/// the one thing a field-by-field comparison cannot catch.
/// </summary>
public static class PaintDocumentTests
{
public static void Run()
{
Report.Section( "paint: a bare box is reachable" );
TestBareBoxIsReachable();
Section( "paint: strokes survive the document round trip" );
TestStrokesRoundTrip();
TestOrderIsPreserved();
TestEmptyPathRoundTrips();
TestSavingTwiceIsIdentical();
TestStaleness();
Section( "paint: erases round trip, and older documents have none" );
TestErasesRoundTripInPlace();
TestDocumentWithoutErasesReadsAsPaint();
Section( "paint: resolution is a parameter, not a constant" );
TestResolutionIsAParameter();
}
/// <summary>
/// THE CASE NOTHING COVERED: a bare box, which is the first thing anybody paints.
///
/// The vertex-colour brush could not reach a single vertex on an unsubdivided part — it painted
/// nothing, silently, and looked broken. A texel dab needs no vertices, so a bare box must paint
/// at brush resolution the moment it has usable UVs, which is what the auto-unwrap provides. This
/// is the acceptance case for the whole move back to a texture atlas.
/// </summary>
static void TestBareBoxIsReachable()
{
var studio = new PartStudio();
var box = studio.Add( new PrimitiveFeature() );
box.SizeX.Value = box.SizeY.Value = box.SizeZ.Value = 1f;
// The unwrap the editor inserts on entering paint — a bare box ships six overlapping islands,
// so without it the paint would scramble onto every face at once.
var uv = studio.Add( new UVProjectFeature() );
uv.Mode.Index = Array.IndexOf( uv.Mode.Options, "Unwrap" );
studio.Rebuild();
var mesh = studio.Bodies[0].Mesh;
Report.Check( "a 1-unit box really does have only its corners",
mesh.Positions.Count == 8, $"{mesh.Positions.Count} vertices" );
var session = new PaintSession( mesh, 1024 );
session.Radius = session.SuggestedRadius;
var hit = session.Hover( new Vec3( 0, 0, 10f ), new Vec3( 0, 0, -1f ) );
Report.Check( "a ray straight down finds the top face", hit is not null );
if ( hit is { } h )
{
session.BeginStroke( new Vec3( 0, 0, 10f ), new Vec3( 0, 0, -1f ) );
session.EndStroke();
Report.Check( "a single dab on a bare box paints texels",
CountPainted( session.Canvas ) > 0, $"{CountPainted( session.Canvas )} texels" );
}
}
static int CountPainted( PaintCanvas canvas )
{
var n = 0;
for ( var i = 3; i < canvas.Rgba.Length; i += 4 )
{
if ( canvas.Rgba[i] > 0 )
n++;
}
return n;
}
static PaintStroke MakeStroke( float r, float g, float b, float a, float radius, float strength,
BrushFalloff falloff, float spacing, int points )
{
var stroke = new PaintStroke
{
R = r,
G = g,
B = b,
A = a,
Radius = radius,
Strength = strength,
Falloff = falloff,
Spacing = spacing,
};
for ( var i = 0; i < points; i++ )
{
stroke.Path.Add( new PaintStrokePoint(
new Vec3( i, i * 0.5f, -i * 0.25f ),
new Vec3( 0f, 0f, 1f ) ) );
}
return stroke;
}
/// <summary>A studio with a paint feature carrying two strokes — red then green, several points
/// each, deliberately distinct so an order swap would show up as the wrong colour.</summary>
static PartStudio Painted()
{
var studio = new PartStudio();
var paint = studio.Add( new PaintFeature() );
paint.AddStroke( MakeStroke( 0.9f, 0.1f, 0.2f, 1f, 0.5f, 0.8f, BrushFalloff.Sharp, 0.25f, 3 ) );
paint.AddStroke( MakeStroke( 0.1f, 0.9f, 0.3f, 0.5f, 0.2f, 0.4f, BrushFalloff.Linear, 0.5f, 4 ) );
return studio;
}
static void TestStrokesRoundTrip()
{
var original = Painted();
var originalPaint = original.Features.OfType<PaintFeature>().Single();
var back = StudioDocument.Read( StudioDocument.Write( original ) ).Features.OfType<PaintFeature>().Single();
Check( "the same number of strokes come back",
back.Strokes.Count == originalPaint.Strokes.Count,
$"{originalPaint.Strokes.Count} became {back.Strokes.Count}" );
for ( var s = 0; s < originalPaint.Strokes.Count && s < back.Strokes.Count; s++ )
{
var a = originalPaint.Strokes[s];
var b = back.Strokes[s];
Check( $"stroke {s} keeps its point count", b.Path.Count == a.Path.Count,
$"{a.Path.Count} became {b.Path.Count}" );
Check( $"stroke {s} keeps its colour", b.R == a.R && b.G == a.G && b.B == a.B && b.A == a.A );
Check( $"stroke {s} keeps its radius", b.Radius == a.Radius );
Check( $"stroke {s} keeps its strength", b.Strength == a.Strength );
Check( $"stroke {s} keeps its falloff", b.Falloff == a.Falloff );
Check( $"stroke {s} keeps its spacing", b.Spacing == a.Spacing );
for ( var p = 0; p < a.Path.Count && p < b.Path.Count; p++ )
{
Check( $"stroke {s} point {p} keeps its position", Close( a.Path[p].Position, b.Path[p].Position ) );
Check( $"stroke {s} point {p} keeps its normal", Close( a.Path[p].Normal, b.Path[p].Normal ) );
}
}
}
/// <summary>
/// An erase is a different LINE KIND in the file, and the thing that has to survive is not just
/// the flag but its POSITION. Paint, erase, paint is three entries in one log and the middle one
/// only means anything where it sits — a round trip that grouped the erases at the end, or that
/// dropped them into a second list, would replay as a part with a hole in the wrong place.
/// </summary>
static void TestErasesRoundTripInPlace()
{
var studio = new PartStudio();
var paint = studio.Add( new PaintFeature() );
paint.AddStroke( MakeStroke( 0.9f, 0.1f, 0.2f, 1f, 0.5f, 0.8f, BrushFalloff.Sharp, 0.25f, 3 ) );
var rubbed = MakeStroke( 0.1f, 0.9f, 0.3f, 0.5f, 0.2f, 0.4f, BrushFalloff.Constant, 0.5f, 4 );
rubbed.Erase = true;
paint.AddStroke( rubbed );
paint.AddStroke( MakeStroke( 0.2f, 0.3f, 0.9f, 1f, 0.7f, 0.6f, BrushFalloff.Linear, 0.5f, 2 ) );
var text = StudioDocument.Write( studio );
var back = StudioDocument.Read( text ).Features.OfType<PaintFeature>().Single();
Check( "the file writes an erase as its own line kind", text.Contains( "\terase " ),
text.Contains( "\tstroke " ) ? "only stroke lines were written" : "no stroke lines at all" );
Check( "all three entries come back", back.Strokes?.Count == 3, $"{back.Strokes?.Count ?? 0}" );
if ( back.Strokes?.Count != 3 )
return;
Check( "the erase comes back in the middle, where it was made",
!back.Strokes[0].Erase && back.Strokes[1].Erase && !back.Strokes[2].Erase,
$"{back.Strokes[0].Erase}, {back.Strokes[1].Erase}, {back.Strokes[2].Erase}" );
// The erase carries a full brush header like any stroke, and replay reads every field of it.
Check( "and keeps the brush it was made with",
back.Strokes[1].Radius == rubbed.Radius && back.Strokes[1].Falloff == rubbed.Falloff
&& back.Strokes[1].Path.Count == rubbed.Path.Count );
}
/// <summary>
/// FORWARD COMPATIBILITY, WITHOUT A VERSION FIELD. Every .effigy written before erasing existed
/// has only "stroke" lines, and the absence of an "erase" line has to read as "nothing here is an
/// erase" — which it does, because the flag lives in the keyword rather than in the header. Had it
/// been a ninth header number instead, every one of those documents would read its first path
/// point as the flag and shift the whole path by one.
/// </summary>
static void TestDocumentWithoutErasesReadsAsPaint()
{
var text = StudioDocument.Write( Painted() );
Check( "a document with no erases writes none", !text.Contains( "\terase " ) );
var back = StudioDocument.Read( text ).Features.OfType<PaintFeature>().Single();
var allPaint = back.Strokes is { Count: 2 } && back.Strokes.TrueForAll( s => !s.Erase );
Check( "and reads back as paint, every stroke of it", allPaint );
}
static void TestOrderIsPreserved()
{
var original = Painted();
var originalPaint = original.Features.OfType<PaintFeature>().Single();
var back = StudioDocument.Read( StudioDocument.Write( original ) ).Features.OfType<PaintFeature>().Single();
// Red then green, deliberately. Blending does not commute, so the exact sequence is the thing
// a reorder destroys — assert the colours come back in the painted order, not sorted.
var orderKept = back.Strokes.Count == originalPaint.Strokes.Count;
for ( var s = 0; orderKept && s < originalPaint.Strokes.Count; s++ )
{
orderKept = back.Strokes[s].R == originalPaint.Strokes[s].R
&& back.Strokes[s].G == originalPaint.Strokes[s].G
&& back.Strokes[s].B == originalPaint.Strokes[s].B;
}
Check( "strokes come back in the order they were painted", orderKept );
}
static void TestEmptyPathRoundTrips()
{
var studio = new PartStudio();
var paint = studio.Add( new PaintFeature() );
paint.AddStroke( MakeStroke( 1f, 1f, 1f, 1f, 0.1f, 1f, BrushFalloff.Smooth, 0.5f, 0 ) );
// Reaching the assertions at all is the "does not throw" half — a write that threw would
// crash the runner rather than report a failed check.
var back = StudioDocument.Read( StudioDocument.Write( studio ) ).Features.OfType<PaintFeature>().Single();
Check( "a stroke with no points survives the round trip", back.Strokes.Count == 1,
$"{back.Strokes.Count} strokes" );
Check( "and keeps its empty path", back.Strokes[0].Path.Count == 0,
$"{back.Strokes[0].Path.Count} points" );
}
static void TestSavingTwiceIsIdentical()
{
var studio = Painted();
var first = StudioDocument.Write( studio );
var second = StudioDocument.Write( studio );
Check( "saving the same document twice gives the same bytes", first == second );
}
static void TestStaleness()
{
var studio = new PartStudio();
studio.Add( new PrimitiveFeature() );
var paint = studio.Add( new PaintFeature() );
studio.Rebuild();
Check( "a rebuilt paint feature is not stale", !paint.IsStale );
paint.AddStroke( MakeStroke( 1f, 0f, 0f, 1f, 0.1f, 1f, BrushFalloff.Smooth, 0.5f, 2 ) );
Check( "appending a stroke marks it stale", paint.IsStale );
studio.Rebuild();
Check( "and a rebuild clears it", !paint.IsStale );
}
static bool Close( Vec3 a, Vec3 b ) =>
MathF.Abs( a.x - b.x ) < 1e-5f && MathF.Abs( a.y - b.y ) < 1e-5f && MathF.Abs( a.z - b.z ) < 1e-5f;
/// <summary>
/// Resolution used to be a const, so every part painted at 1024 whether that was a matchbox or a
/// stadium. It is a parameter now, and the three things that used to break when it was a const
/// have to hold: the replay cache misses when it changes (no stale canvas served at the old
/// size), the strokes re-replay rather than scale or clear, and the setting survives a save.
/// </summary>
static void TestResolutionIsAParameter()
{
var studio = new PartStudio();
var box = studio.Add( new PrimitiveFeature() );
box.SizeX.Value = box.SizeY.Value = box.SizeZ.Value = 1f;
var uv = studio.Add( new UVProjectFeature() );
uv.Mode.Index = Array.IndexOf( uv.Mode.Options, "Unwrap" );
var paint = studio.Add( new PaintFeature() );
paint.AddStroke( TopFaceStroke() );
studio.Rebuild();
var first = paint.Canvas;
Check( "a fresh paint feature replays at the 1024 default",
first is not null && first.Width == 1024, first is null ? "no canvas" : $"{first.Width}" );
var paintedAtDefault = CountPainted( first );
Check( "and the box is painted", paintedAtDefault > 0, $"{paintedAtDefault} texels" );
// The editor marks the feature dirty when a parameter changes; done by hand here.
paint.Resolution.Value = 256;
studio.MarkDirty( paint );
studio.Rebuild();
var second = paint.Canvas;
Check( "changing the resolution replays at the new size",
second.Width == 256 && second.Height == 256, $"{second.Width}x{second.Height}" );
Check( "and is not the stale canvas", !ReferenceEquals( first, second ) );
Check( "and the paint is still there, not cleared", CountPainted( second ) > 0,
$"{CountPainted( second )} texels" );
var back = StudioDocument.Read( StudioDocument.Write( studio ) ).Features.OfType<PaintFeature>().Single();
Check( "save and reopen keeps the resolution", back.Resolution.Value == 256, $"{back.Resolution.Value}" );
}
/// <summary>One dab flat on the top of a 1-unit box, where the replay can actually land.</summary>
static PaintStroke TopFaceStroke()
{
var stroke = new PaintStroke
{
R = 1f, G = 0f, B = 0f, A = 1f,
Radius = 0.3f, Strength = 1f, Falloff = BrushFalloff.Sharp, Spacing = 0.5f,
};
stroke.Path.Add( new PaintStrokePoint( new Vec3( 0, 0, 0.5f ), new Vec3( 0, 0, 1 ) ) );
return stroke;
}
}