Effigy.Tests/NoteTests.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace Effigy.Tests;
/// <summary>
/// Grease-pencil notes.
///
/// THE FIRST SECTION IS THE POINT OF THE FILE. A note is annotation, and the promise it makes is
/// that it can never end up in an exported model — not in the OBJ, not in the DMX the rigged path
/// writes, not in the collision hull. That promise is currently kept by construction, because notes
/// hang off PartStudio rather than off the feature list, so no writer has to remember to skip them.
/// Construction is the strongest way to keep it and the easiest to undo by accident: one future
/// change that walks "everything in the studio" instead of "everything in Features" would quietly
/// weld somebody's handwriting onto their model. These tests are what would notice.
///
/// The rest is the ordinary business of a stroke tool — where samples land, what undo means, the
/// round trip through the document — verified in the kernel, where it can run without an engine.
/// </summary>
public static class NoteTests
{
public static void Run()
{
Report.Section( "notes: nothing about them reaches an exported mesh" );
TestNotesNeverExport();
Report.Section( "notes: they survive the document round trip" );
TestRoundTrip();
Report.Section( "notes: a rebuild leaves them alone" );
TestRebuildKeepsNotes();
Report.Section( "notes: strokes land where the ray points" );
TestProjection();
Report.Section( "notes: sampling, committing and erasing" );
TestStrokes();
Report.Section( "notes: undo and redo" );
TestUndo();
Report.Section( "notes: the stroke scales to the part, not to a guess" );
TestScaleTo();
}
/// <summary>A studio with one body in it, so there is a surface to draw on and a mesh to
/// compare.</summary>
static PartStudio Boxed()
{
var studio = new PartStudio();
studio.Add( new PrimitiveFeature() );
studio.Rebuild();
return studio;
}
// --- the guarantee -------------------------------------------------------------------------
static void TestNotesNeverExport()
{
var studio = Boxed();
var before = studio.ToMesh();
var beforeVertices = before.VertexCount;
var beforeFaces = before.FaceCount;
var beforeObj = ObjText( before );
// Notes drawn all over the part, including one running straight through the middle of it,
// which is the case a naive "merge everything" would show up in most obviously.
studio.Notes.Add( new Note
{
Text = "this face is proud",
Color = 1,
Points = { new Vec3( -50, -50, -50 ), new Vec3( 0, 0, 0 ), new Vec3( 50, 50, 50 ) },
} );
studio.Notes.Add( new Note
{
Text = "ask about the hinge",
Color = 3,
Points = { new Vec3( 10, 0, 0 ), new Vec3( 10, 0, 20 ) },
} );
studio.Rebuild();
var after = studio.ToMesh();
Report.Check( "ToMesh has the same vertex count with notes as without",
after.VertexCount == beforeVertices, $"{beforeVertices} -> {after.VertexCount}" );
Report.Check( "ToMesh has the same face count with notes as without",
after.FaceCount == beforeFaces, $"{beforeFaces} -> {after.FaceCount}" );
Report.Check( "ToVisibleMesh has the same vertex count too",
studio.ToVisibleMesh().VertexCount == beforeVertices );
var (withBodies, ranges) = studio.ToMeshWithBodies();
Report.Check( "the rigged path's mesh is unchanged as well",
withBodies.VertexCount == beforeVertices );
Report.Check( "no extra body range appears for a note",
ranges.Count == studio.Bodies.Count, $"{ranges.Count} ranges for {studio.Bodies.Count} bodies" );
// The written file, not just the counts. This is the artifact that actually ships, and it
// is the one place a stray vertex would be visible to the person who opened it.
var afterObj = ObjText( after );
Report.Check( "the exported OBJ is byte-for-byte what it was before the notes",
afterObj == beforeObj, "the OBJ changed when notes were added" );
Report.Check( "no note's text appears anywhere in the OBJ",
!afterObj.Contains( "hinge", StringComparison.OrdinalIgnoreCase )
&& !afterObj.Contains( "proud", StringComparison.OrdinalIgnoreCase ) );
// Notes are not features, which is the structural reason for all of the above. Asserted
// directly so a failure says WHY rather than only that a count moved.
Report.Check( "notes are not in the feature list",
studio.Features.Count == 1, $"{studio.Features.Count} features" );
Report.Check( "a rebuild does not evaluate a note",
studio.Rebuild().FeaturesEvaluated <= studio.Features.Count );
}
static string ObjText( PolyMesh mesh )
{
var path = Path.Combine( Path.GetTempPath(), $"effigy_note_{Guid.NewGuid():N}.obj" );
try
{
ObjWriter.WriteFile( mesh, path, "effigy_export" );
return File.ReadAllText( path );
}
finally
{
try { File.Delete( path ); } catch { /* a temp file that will not delete is not a test failure */ }
}
}
// --- the document --------------------------------------------------------------------------
static void TestRoundTrip()
{
var studio = Boxed();
studio.Notes.Add( new Note
{
Text = "chamfer this before it ships",
Color = 2,
Width = 0.75f,
Points = { new Vec3( 1.5f, -2.25f, 3f ), new Vec3( 4f, 0f, 3f ), new Vec3( 7.125f, 2f, 3f ) },
} );
studio.Notes.Add( new Note
{
Color = 0,
Points = { new Vec3( 0, 0, 0 ), new Vec3( 1, 1, 1 ) },
} );
var back = StudioDocument.Read( StudioDocument.Write( studio ) );
Report.Check( "both notes come back", back.Notes.Count == 2, $"{back.Notes.Count}" );
if ( back.Notes.Count != 2 )
return;
var a = back.Notes[0];
Report.Check( "the caption survives", a.Text == "chamfer this before it ships", a.Text );
Report.Check( "the colour survives", a.Color == 2, $"{a.Color}" );
Report.Check( "the width survives", Math.Abs( a.Width - 0.75f ) < 1e-6f, $"{a.Width}" );
Report.Check( "every point survives, in order",
a.Points.Count == 3 && Same( a.Points[0], new Vec3( 1.5f, -2.25f, 3f ) )
&& Same( a.Points[2], new Vec3( 7.125f, 2f, 3f ) ) );
Report.Check( "a note with no caption comes back with none",
string.IsNullOrEmpty( back.Notes[1].Text ), back.Notes[1].Text );
// Written twice, identical both times — the rule the whole format follows, so a diff
// between two saves means something changed.
//
// The rollback index is levelled first because Read clamps an unrolled int.MaxValue down to
// the feature count, so the two differ on that line for reasons that have nothing to do
// with notes. Levelling it rather than comparing a substring keeps this a whole-file
// comparison, which is what would catch a note block written in a different order.
studio.RollbackIndex = studio.Features.Count;
back.RollbackIndex = back.Features.Count;
Report.Check( "writing the reloaded studio gives the same bytes",
StudioDocument.Write( back ) == StudioDocument.Write( studio ) );
// A document from before notes existed still loads, and loads with none.
var old = StudioDocument.Read( "effigy 1\nrollback 2147483647\nfeature PrimitiveFeature\n\tid a\n\tsuppressed 0\n\tvisible 1\nend\n" );
Report.Check( "a document with no notes in it loads with an empty list",
old.Notes is { Count: 0 } );
// The features still parse after a note block, which is the thing a mis-counted block
// terminator would break.
Report.Check( "the features are still there alongside the notes",
back.Features.Count == studio.Features.Count );
var unterminated = false;
try
{
StudioDocument.Read( "effigy 1\nnote 0 0.4\n\tp 0 0 0\n" );
}
catch ( InvalidDataException )
{
unterminated = true;
}
Report.Check( "an unterminated note block is refused rather than half-read", unterminated );
}
static void TestRebuildKeepsNotes()
{
var studio = Boxed();
studio.Notes.Add( new Note { Text = "keep me", Points = { Vec3.Zero, new Vec3( 1, 0, 0 ) } } );
// The edits that throw the most away: a new feature, a full dirty, a rollback.
studio.Add( new PrimitiveFeature() );
studio.MarkAllDirty();
studio.Rebuild();
studio.RollbackIndex = 1;
studio.Rebuild();
Report.Check( "the note is still there after rebuilds and a rollback",
studio.Notes.Count == 1 && studio.Notes[0].Text == "keep me" );
// Rolled back past the feature the note was drawn over, the note stays — it is not owned by
// a feature and has no reason to disappear with one.
Report.Check( "rollback does not hide notes", studio.Notes.Count == 1 );
}
// --- the session ---------------------------------------------------------------------------
static void TestProjection()
{
var studio = Boxed();
var session = new NoteSession( studio.Notes ) { Pivot = Vec3.Zero };
session.SetBodies( studio.Bodies );
// Straight at the part from far out on +x. PrimitiveFeature's default box straddles the
// origin, so this must land on its near face rather than sailing through.
var origin = new Vec3( 500, 0, 0 );
var toward = new Vec3( -1, 0, 0 );
var onSurface = session.Project( origin, toward );
Report.Check( "a ray at the part lands on it", onSurface is not null );
if ( onSurface is { } surface )
{
var hit = MeshRaycast.Raycast( studio.Bodies, origin, toward );
Report.Check( "the sample sits in front of the face, not in it",
hit is not null && surface.x > hit.Value.Hit.Point.x - 1e-3f,
$"sample x {surface.x}" );
Report.Check( "and only just in front of it — one Lift, not a Lift per axis",
hit is not null && Math.Abs( (surface - hit.Value.Hit.Point).Length - session.Lift ) < 1e-3f );
}
// A ray that misses everything still draws, on the plane through the pivot. This is the
// case a sculpt brush refuses and a note must not: writing beside a part is the job.
var missOrigin = new Vec3( 500, 400, 0 );
var missed = session.Project( missOrigin, toward );
Report.Check( "a ray past the part still gives a point", missed is not null );
if ( missed is { } air )
{
Report.Check( "and that point is at the pivot's depth along the ray",
Math.Abs( air.x ) < 1e-2f, $"x {air.x}" );
}
Report.Check( "a degenerate direction gives nothing rather than a NaN",
session.Project( origin, Vec3.Zero ) is null );
}
static void TestStrokes()
{
var studio = Boxed();
var session = new NoteSession( studio.Notes ) { Pivot = Vec3.Zero, Color = 4, Width = 0.9f };
session.SetBodies( studio.Bodies );
var origin = new Vec3( 500, 0, 0 );
Report.Check( "a stroke starts", session.BeginStroke( origin, new Vec3( -1, 0, 0 ) ) );
Report.Check( "and it is running", session.IsStroking );
// The same ray again: no travel, so no sample. This is what keeps a stationary mouse from
// filling the file with a thousand copies of one point.
Report.Check( "a repeat of the same ray adds nothing",
!session.MoveTo( origin, new Vec3( -1, 0, 0 ) ) );
// Sweep across the face. Steps far enough apart to clear Spacing.
for ( var i = 1; i <= 8; i++ )
session.MoveTo( origin, new Vec3( -50, i * 2f, 0 ) );
var points = session.Working.Points.Count;
Report.Check( "a sweep adds samples", points > 2, $"{points} points" );
var committed = session.EndStroke();
Report.Check( "the stroke commits", committed is not null );
Report.Check( "and lands in the studio's own list",
studio.Notes.Count == 1 && ReferenceEquals( studio.Notes[0], committed ) );
Report.Check( "wearing the session's colour and width",
committed is { Color: 4 } && Math.Abs( committed.Width - 0.9f ) < 1e-6f );
Report.Check( "the session is no longer stroking", !session.IsStroking );
// A click that never moved is not a note. Committing those leaves invisible pins nobody can
// find to erase.
session.BeginStroke( origin, new Vec3( -1, 0, 0 ) );
Report.Check( "a click with no travel commits nothing", session.EndStroke() is null );
Report.Check( "and leaves the list alone", studio.Notes.Count == 1 );
// Cancelling mid-stroke throws the working note away rather than committing it.
session.BeginStroke( origin, new Vec3( -1, 0, 0 ) );
session.MoveTo( origin, new Vec3( -50, 6f, 0 ) );
session.CancelStroke();
Report.Check( "a cancelled stroke is not committed", studio.Notes.Count == 1 );
Report.Check( "and is not left running", !session.IsStroking );
// Erasing: aim down the ray the stroke was drawn along.
Report.Check( "a note under the cursor is picked",
session.Pick( origin, new Vec3( -50, 4f, 0 ) ) is not null );
Report.Check( "a ray nowhere near one picks nothing",
session.Pick( new Vec3( 0, 0, 900 ), new Vec3( 0, 1, 0 ) ) is null );
Report.Check( "erasing removes it", session.Erase( origin, new Vec3( -50, 4f, 0 ) ) );
Report.Check( "and the list is empty again", studio.Notes.Count == 0 );
Report.Check( "erasing nothing is a no-op rather than a throw",
!session.Erase( new Vec3( 0, 0, 900 ), new Vec3( 0, 1, 0 ) ) );
}
static void TestUndo()
{
var studio = Boxed();
var session = new NoteSession( studio.Notes ) { Pivot = Vec3.Zero };
session.SetBodies( studio.Bodies );
Report.Check( "nothing to undo on a fresh session", !session.CanUndo );
var first = Draw( session, 2f );
var second = Draw( session, 8f );
Report.Check( "two strokes are in", studio.Notes.Count == 2 );
Report.Check( "and undo is offered", session.CanUndo );
session.Undo();
Report.Check( "undo takes the last stroke back",
studio.Notes.Count == 1 && ReferenceEquals( studio.Notes[0], first ) );
session.Redo();
Report.Check( "redo puts it back",
studio.Notes.Count == 2 && ReferenceEquals( studio.Notes[1], second ) );
// Undo an ERASE, which is the direction that goes wrong when undo and redo are written as
// two switches that have to agree.
session.Remove( first );
Report.Check( "the first stroke is gone", studio.Notes.Count == 1 );
session.Undo();
Report.Check( "undoing an erase restores it", studio.Notes.Count == 2 );
Report.Check( "at the index it was erased from",
ReferenceEquals( studio.Notes[0], first ), "it came back in the wrong place" );
// A caption is undoable too.
session.SetText( second, "wrong" );
session.SetText( second, "right" );
Report.Check( "the caption is set", second.Text == "right" );
session.Undo();
Report.Check( "undo takes the caption back one step", second.Text == "wrong", second.Text );
session.Undo();
Report.Check( "and again to no caption", string.IsNullOrEmpty( second.Text ), second.Text );
// A new edit closes off the redo branch, which is what everyone expects and what a stack
// that is only ever pushed to gets wrong.
session.Redo();
Draw( session, 14f );
Report.Check( "a new stroke clears the redo stack", !session.CanRedo );
while ( session.CanUndo )
session.Undo();
Report.Check( "undoing everything empties the list", studio.Notes.Count == 0,
$"{studio.Notes.Count} left" );
}
static Note Draw( NoteSession session, float y )
{
var origin = new Vec3( 500, 0, 0 );
session.BeginStroke( origin, new Vec3( -50, y, 0 ) );
for ( var i = 1; i <= 4; i++ )
session.MoveTo( origin, new Vec3( -50, y + i * 2f, 0 ) );
return session.EndStroke();
}
/// <summary>
/// The distances a stroke uses track the size of the model.
///
/// THIS IS A REGRESSION TEST FOR A REAL BUG. Spacing, Lift and PickRadius shipped as constants
/// tuned for a part tens of units across. Effigy's units are dimensionless and a default
/// primitive is ONE unit across (PolyMesh.BoundsDiagonal says so), so on an ordinary box
/// PickRadius was wider than the whole model: every press after the first note landed on that
/// note and opened its caption box instead of starting a stroke. The tool looked like it had
/// stopped drawing.
///
/// The bar is relational rather than exact - the fractions are a judgement call and may be
/// retuned - so this asserts the PROPERTIES that made the constants wrong, not the numbers.
/// </summary>
static void TestScaleTo()
{
var session = new NoteSession( new List<Note>() );
session.ScaleTo( 1f );
var spacing = session.Spacing;
var lift = session.Lift;
var pick = session.PickRadius;
// The failure that started it: a pick radius you cannot get outside of on a default part.
Report.Check( "pick radius is well inside a one-unit part", pick < 0.25f, $"{pick}" );
Report.Check( "a one-unit part gets many samples across it", 1f / spacing > 20f,
$"spacing {spacing} gives {1f / spacing:0} samples" );
Report.Check( "but not absurdly many", 1f / spacing < 400f, $"spacing {spacing}" );
Report.Check( "the lift is small enough to read as on the surface", lift < 0.02f, $"{lift}" );
Report.Check( "and big enough to be off it", lift > 0f, $"{lift}" );
// Ordering the three have to keep whatever the fractions become: you must be able to aim at
// a stroke more loosely than you sampled it, and the lift must not dominate the spacing.
Report.Check( "pick radius is looser than the sampling", pick > spacing, $"{pick} vs {spacing}" );
Report.Check( "the lift is smaller than the sampling", lift < spacing, $"{lift} vs {spacing}" );
// A room-sized part: everything scales with it rather than staying put.
session.ScaleTo( 200f );
Report.Check( "a 200-unit part scales the spacing up", session.Spacing > spacing * 100f,
$"{session.Spacing}" );
Report.Check( "and the pick radius with it", session.PickRadius > pick * 100f,
$"{session.PickRadius}" );
Report.Check( "keeping the same ordering", session.PickRadius > session.Spacing
&& session.Lift < session.Spacing );
// An empty studio has no bounds to measure, and still has to be drawable.
session.ScaleTo( 0f );
Report.Check( "an empty studio falls back rather than collapsing to zero",
session.Spacing > 0f && session.PickRadius > 0f, $"spacing {session.Spacing}" );
session.ScaleTo( -5f );
Report.Check( "and a nonsense diagonal does too",
session.Spacing > 0f && session.PickRadius > 0f, $"spacing {session.Spacing}" );
// Width is pixels, not world units, so it is the one thing ScaleTo must NOT touch - a note
// that gets thinner as the part gets bigger is a note you cannot read.
var thickness = session.Width;
session.ScaleTo( 500f );
Report.Check( "width is a screen thickness and does not scale with the model",
Math.Abs( session.Width - thickness ) < 1e-6f, $"{thickness} -> {session.Width}" );
}
static bool Same( Vec3 a, Vec3 b ) => (a - b).Length < 1e-4f;
}