Effigy.Tests/PaintGen.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Effigy;
namespace Effigy.Tests;
/// <summary>
/// The paint pipeline, end to end, without s&box.
///
/// WHY THIS EXISTS SEPARATELY FROM PaintReplayTests. Those test the DAB — one brush, one hand-built
/// mesh, the colour maths. Nothing tested the CHAIN: a PaintFeature sitting in a real feature tree,
/// above a real primitive, surviving a rebuild, surviving an UPSTREAM EDIT, surviving a save and a
/// reload, and reaching the DMX with its colours attached. That chain is the actual product claim —
/// "paint follows the part through later edits instead of smearing" — and it was the one thing no
/// test made. A dab that works on a fixture and a paint feature that works in a document are two
/// different statements.
///
/// IT ALSO RENDERS THE RESULT. The remaining Known Issue is that nobody has LOOKED at a painted
/// model; the preview sheet here is the closest a headless run can get, and it is close enough to
/// answer "did the brush go where I aimed it" and "is a bare box really too coarse to paint". What
/// it cannot answer is how s&box's own shader composites the vertex colours — that needs the
/// editor open, and this deliberately does not pretend otherwise.
///
/// Invoked as: Effigy.Tests.exe --paint [outDir]
/// </summary>
public static class PaintGen
{
static int _passed;
static int _failed;
// A 2-unit box centred on the origin, so the top face sits at z = +1 and a brush aimed at
// (x, y, 1) with normal +Z lands on it. Radius is in the same units — see the memory of every
// dab that missed because it was aimed at a point in space rather than at the surface.
const float Half = 1f;
public static int Run( string outDir )
{
Directory.CreateDirectory( outDir );
Console.WriteLine( "painting a model, headless" );
Console.WriteLine();
// --- the part ------------------------------------------------------------------------
var studio = new PartStudio();
studio.MaterialNames[0] = "models/effigy/paint_base.vmat";
var box = studio.Add( new PrimitiveFeature() );
box.Name = "block";
box.Shape.Index = 0; // Box
box.SizeX.Value = Half * 2f;
box.SizeY.Value = Half * 2f;
box.SizeZ.Value = Half * 2f;
box.Material.Value = 0;
// SUBDIVIDE BEFORE PAINTING, which is the whole of the Known Issue said as a build step.
// Vertex colours live one per vertex, so the eight corners of a bare box can express eight
// colours and no more. Three levels is 6 -> 1536 faces, which is enough for a brush to read
// as a brush.
var dense = studio.Add( new SubdivideFeature() );
dense.Name = "density";
dense.Bodies.BodyIds.Add( box.Id + "b0" );
dense.Levels.Value = 3;
// REBUILD BEFORE PAINTING, because the strokes have to be aimed at the surface that
// actually exists. Catmull-Clark pulls a box a long way toward a sphere - the top face
// stops being at z = 1 - and a stroke aimed at where the box USED to be reaches nothing.
// This is the same order the editor works in: the mesh exists, then a ray off the cursor
// finds it. Aiming at remembered coordinates is the trap.
var pre = studio.Rebuild();
if ( pre.HasErrors )
{
foreach ( var e in pre.Errors )
Console.WriteLine( " ERROR " + e );
return 1;
}
var canvas = studio.ToMesh();
var paint = studio.Add( new PaintFeature() );
paint.Name = "paint";
paint.Bodies.BodyIds.Add( box.Id + "b0" );
// Three strokes in three colours, laid across the top. Adjacent stripes overlap
// deliberately, so the source-over blend has something to prove.
paint.AddStroke( Stripe( canvas, 1f, 0.25f, 0.15f, y: -0.42f ) );
paint.AddStroke( Stripe( canvas, 0.15f, 0.85f, 0.35f, y: 0f ) );
paint.AddStroke( Stripe( canvas, 0.25f, 0.45f, 1f, y: 0.42f ) );
var report = studio.Rebuild();
if ( report.HasErrors )
{
foreach ( var e in report.Errors )
Console.WriteLine( " ERROR " + e );
return 1;
}
var mesh = studio.ToMesh();
Console.WriteLine( $" {mesh.VertexCount} verts, {mesh.FaceCount} faces" );
Console.WriteLine();
// --- what the chain has to prove ---------------------------------------------------
Check( "the rebuilt mesh carries vertex colours", mesh.HasVertexColors );
var painted = CountPainted( mesh );
Check( "the brush painted some of it", painted > 0, $"{painted} of {mesh.VertexCount} verts" );
Check( "and not all of it - three stripes are not a coat of paint",
painted < mesh.VertexCount, $"{painted} of {mesh.VertexCount} verts" );
// The underside must be clean. It is the same test the dab already passes on a fixture,
// asked here of a real solid where the far side is a whole face away rather than a
// double-sided quad.
var underside = mesh.Positions
.Select( ( p, i ) => (p, i) )
.Where( t => t.p.z < -Half + 0.01f )
.Count( t => mesh.VertexColors[t.i].w > 0.01f );
Check( "the bottom of the box is unpainted", underside == 0, $"{underside} painted verts" );
// THE HEADLINE CLAIM. Change the box UNDER the paint and rebuild: the strokes are in object
// space and replayed, so they must still be there. If paint were baked into the mesh this
// is where it would smear or vanish.
box.SizeX.Value = Half * 3f;
var report2 = studio.Rebuild();
Check( "the part still rebuilds after an upstream edit", !report2.HasErrors );
var edited = studio.ToMesh();
var paintedAfter = CountPainted( edited );
Check( "paint survives an edit to the feature below it", paintedAfter > 0,
$"{paintedAfter} verts still painted" );
box.SizeX.Value = Half * 2f;
studio.Rebuild();
mesh = studio.ToMesh();
// --- save, reload, repaint ------------------------------------------------------------
var docPath = Path.Combine( outDir, "painted.effigy" );
StudioDocument.WriteFile( studio, docPath );
var reloaded = StudioDocument.ReadFile( docPath );
var reloadedPaint = reloaded.Features.OfType<PaintFeature>().FirstOrDefault();
Check( "the .effigy file carries the paint feature", reloadedPaint is not null );
Check( "with all three strokes", reloadedPaint?.Strokes?.Count == 3,
$"{reloadedPaint?.Strokes?.Count ?? 0} strokes" );
reloaded.Rebuild();
var reloadedMesh = reloaded.ToMesh();
Check( "and reopening the file paints the same model",
reloadedMesh.HasVertexColors && SameColors( mesh, reloadedMesh ),
$"{CountPainted( reloadedMesh )} verts painted" );
// --- the export the engine actually reads ----------------------------------------------
var obj = Path.Combine( outDir, "painted.obj" );
var dmx = Path.Combine( outDir, "painted.dmx" );
ObjWriter.WriteFile( mesh, obj, "painted" );
DmxWriter.WriteFile( mesh, dmx, modelName: "painted", materialName: studio.NameForSlot );
var dmxText = File.ReadAllText( dmx );
// The field names were read out of the compiler's own binary, so the thing worth asserting
// is that they are PRESENT and that the stream is the right length — not that the engine
// likes them, which only the engine can say.
Check( "the DMX declares a vertex colour stream", dmxText.Contains( "VertexPaintBlendParams" )
|| dmxText.Contains( "$color" ) || dmxText.Contains( "color$0" ),
FirstColorField( dmxText ) );
Console.WriteLine();
Console.WriteLine( " OBJ " + obj );
Console.WriteLine( " DMX " + dmx );
Console.WriteLine( " EFFIGY " + docPath );
// --- the picture ------------------------------------------------------------------------
// A COARSE MODEL BESIDE THE DENSE ONE, because the Known Issue is a claim about mesh
// density and the only honest way to show it is both. One level rather than none: at
// zero the box's eight corners are all further from the stroke than the brush is wide,
// so the same brush paints literally nothing and the tile is a picture of an unpainted
// box, which reads as a broken renderer rather than as the point being made.
var coarse = CoarsePainted( 1 );
PngPreview.WriteSheet( new[]
{
new PngPreview.Tile( mesh, "box + Subdivide x3" ),
new PngPreview.Tile( coarse, "same brush, Subdivide x1" ),
new PngPreview.Tile( mesh, "wireframe", wireframe: true ),
new PngPreview.Tile( reloadedMesh, "reopened from .effigy" ),
}, Path.Combine( outDir, "painted_preview.png" ), columns: 2, tileSize: 460 );
Console.WriteLine( " PNG " + Path.Combine( outDir, "painted_preview.png" ) );
Console.WriteLine();
Console.WriteLine( "------------------------------------------------------------" );
Console.WriteLine( $" {_passed} passed, {_failed} failed" );
Console.WriteLine( "------------------------------------------------------------" );
Console.WriteLine();
Console.WriteLine( " NOT PROVEN HERE: how s&box composites these colours over the" );
Console.WriteLine( " material. That needs the editor open - compile painted.dmx and look." );
return _failed == 0 ? 0 : 1;
}
// --- helpers ---------------------------------------------------------------------------------
/// <summary>
/// One stroke drawn across the top of whatever mesh it is given, left to right at a fixed y.
///
/// EVERY POINT IS A RAYCAST, straight down from well above the part, exactly as the editor
/// gets its points from a ray off the cursor. That is not ceremony: a dab aims at a point in
/// SPACE and colours the vertices within its radius of that point, so a stroke aimed at
/// remembered coordinates lands in mid-air the moment anything upstream moves the surface —
/// which is precisely what a Subdivide above a box does. A sample that hits nothing is
/// dropped rather than guessed at.
/// </summary>
static PaintStroke Stripe( PolyMesh mesh, float r, float g, float b, float y )
{
var stroke = new PaintStroke { R = r, G = g, B = b, A = 1f, Radius = 0.34f, Strength = 1f };
for ( var i = 0; i <= 24; i++ )
{
var x = -Half * 0.75f + (Half * 1.5f) * (i / 24f);
var hit = MeshRaycast.Raycast( mesh, new Vec3( x, y, Half * 10f ), new Vec3( 0, 0, -1 ) );
if ( hit is { } h )
stroke.Path.Add( new PaintStrokePoint( h.Point, h.Normal ) );
}
return stroke;
}
/// <summary>The same strokes replayed onto an unsubdivided box — the Known Issue, drawn.</summary>
static PolyMesh CoarsePainted( int levels )
{
var studio = new PartStudio();
var box = studio.Add( new PrimitiveFeature() );
box.Shape.Index = 0;
box.SizeX.Value = Half * 2f;
box.SizeY.Value = Half * 2f;
box.SizeZ.Value = Half * 2f;
if ( levels > 0 )
{
var dense = studio.Add( new SubdivideFeature() );
dense.Bodies.BodyIds.Add( box.Id + "b0" );
dense.Levels.Value = levels;
}
studio.Rebuild();
var bare = studio.ToMesh();
var paint = studio.Add( new PaintFeature() );
paint.Bodies.BodyIds.Add( box.Id + "b0" );
// RE-AIMED at the box rather than replayed from the dense model's strokes, for the same
// reason as above — the two surfaces are not in the same place, and a comparison of
// density has to be a comparison of the same brush landing, not of one brush missing.
foreach ( var c in new[] { (1f, 0.25f, 0.15f, -0.42f), (0.15f, 0.85f, 0.35f, 0f), (0.25f, 0.45f, 1f, 0.42f) } )
paint.AddStroke( Stripe( bare, c.Item1, c.Item2, c.Item3, c.Item4 ) );
studio.Rebuild();
return studio.ToMesh();
}
static int CountPainted( PolyMesh mesh ) =>
!mesh.HasVertexColors ? 0 : mesh.VertexColors.Count( c => c.w > 0.01f );
static bool SameColors( PolyMesh a, PolyMesh b )
{
if ( !a.HasVertexColors || !b.HasVertexColors ) return false;
if ( a.VertexColors.Length != b.VertexColors.Length ) return false;
for ( var i = 0; i < a.VertexColors.Length; i++ )
{
var x = a.VertexColors[i];
var y = b.VertexColors[i];
if ( MathF.Abs( x.x - y.x ) > 1e-4f || MathF.Abs( x.y - y.y ) > 1e-4f
|| MathF.Abs( x.z - y.z ) > 1e-4f || MathF.Abs( x.w - y.w ) > 1e-4f )
return false;
}
return true;
}
/// <summary>Whatever the DMX calls its colour field, quoted back — so a failure says what the
/// writer actually emitted rather than only that the guess was wrong.</summary>
static string FirstColorField( string dmx )
{
var line = dmx.Split( '\n' ).FirstOrDefault( l => l.Contains( "color", StringComparison.OrdinalIgnoreCase ) );
return line?.Trim() ?? "no line mentioning colour";
}
static void Check( string what, bool ok, string detail = null )
{
if ( ok ) _passed++; else _failed++;
var mark = ok ? " ok " : " FAIL";
Console.WriteLine( detail is null ? $"{mark} {what}" : $"{mark} {what} ({detail})" );
}
}