Effigy.Tests/PngPreview.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using Effigy;
namespace Effigy.Tests;
/// <summary>
/// Rasterises meshes to a PNG.
///
/// The SVG previews are fine in a browser but not everything renders SVG, and a PNG can be looked
/// at anywhere. No image library is available here, so this does the whole job: scanline polygon
/// fill into a framebuffer, 2x supersampling, then a hand-rolled PNG encoder.
///
/// Worth the ~200 lines because a test suite proves the numbers and an image proves the shape.
/// They catch different mistakes.
/// </summary>
public static class PngPreview
{
const int Supersample = 2;
public sealed class Tile
{
public PolyMesh Mesh;
public string Label;
public bool Wireframe;
public Tile( PolyMesh mesh, string label, bool wireframe = false )
{
Mesh = mesh;
Label = label;
Wireframe = wireframe;
}
}
/// <summary>A grid of previews in one image — a contact sheet.</summary>
public static void WriteSheet( IReadOnlyList<Tile> tiles, string path, int columns = 4, int tileSize = 300 )
{
var rows = (tiles.Count + columns - 1) / columns;
var w = columns * tileSize;
var h = rows * tileSize;
var ss = Supersample;
var buffer = new int[w * ss * h * ss];
var background = Rgb( 18, 20, 24 );
for ( var i = 0; i < buffer.Length; i++ )
buffer[i] = background;
for ( var t = 0; t < tiles.Count; t++ )
{
var col = t % columns;
var row = t / columns;
DrawTile( buffer, w * ss, h * ss,
col * tileSize * ss, row * tileSize * ss, tileSize * ss, tiles[t] );
}
var downsampled = Downsample( buffer, w * ss, h * ss, ss );
// Labels are drawn after downsampling so the 3x5 bitmap font stays crisp rather than
// being blurred by the box filter.
for ( var t = 0; t < tiles.Count; t++ )
{
var col = t % columns;
var row = t / columns;
var tile = tiles[t];
var caption = $"{tile.Label}";
var stats = $"{tile.Mesh.VertexCount}v {tile.Mesh.FaceCount}f";
DrawText( downsampled, w, h, col * tileSize + 10, row * tileSize + tileSize - 011 - 12, caption, Rgb( 150, 165, 182 ) );
DrawText( downsampled, w, h, col * tileSize + 10, row * tileSize + tileSize - 011, stats, Rgb( 96, 108, 122 ) );
}
WritePng( path, downsampled, w, h );
}
static void DrawTile( int[] buffer, int bufW, int bufH, int ox, int oy, int size, Tile tile )
{
var mesh = tile.Mesh;
if ( mesh.VertexCount == 0 )
return;
var yaw = 35f * MathF.PI / 180f;
var pitch = 24f * MathF.PI / 180f;
var view = Xform.Rotate( new Vec3( 1, 0, 0 ), -pitch ) * Xform.Rotate( new Vec3( 0, 0, 1 ), yaw );
// Same convention as SvgPreview: positions go into VIEW space, the screen reads x and z,
// so view-space Y is depth and the viewer sits at -Y. camera is a constant view-space axis.
var camera = new Vec3( 0, -1, 0 );
var light = new Vec3( -0.4f, -0.75f, 0.53f ).Normal;
var projected = mesh.Positions.Select( p => view.TransformPoint( p ) ).ToList();
var minX = projected.Min( p => p.x );
var maxX = projected.Max( p => p.x );
var minZ = projected.Min( p => p.z );
var maxZ = projected.Max( p => p.z );
var span = MathF.Max( maxX - minX, maxZ - minZ );
if ( span < 1e-6f )
span = 1f;
var margin = size * 0.16f;
var scale = (size - margin * 2f) / span;
var cx = (minX + maxX) * 0.5f;
var cz = (minZ + maxZ) * 0.5f;
Vec2 Screen( Vec3 p ) => new(
ox + size * 0.5f + (p.x - cx) * scale,
oy + size * 0.46f - (p.z - cz) * scale );
var order = Enumerable.Range( 0, mesh.FaceCount )
.Select( fi => (Index: fi, Depth: mesh.Faces[fi].Indices.Average( i => Vec3.Dot( projected[i], camera ) )) )
.OrderBy( x => x.Depth )
.ToList();
foreach ( var (fi, _) in order )
{
var face = mesh.Faces[fi];
var viewNormal = view.TransformDirection( mesh.FaceNormal( face ) );
// Backface cull. An inside-out solid renders as a hole, which makes this a visual
// double-check on the winding tests.
if ( Vec3.Dot( viewNormal, camera ) <= 0.01f )
continue;
var lambert = MathF.Max( 0f, Vec3.Dot( mesh.FaceNormal( face ), light ) );
var shade = 0.22f + 0.78f * lambert * lambert;
// The unpainted surface, and the thing paint composes OVER. Kept as the base rather
// than as an else-branch so a half-opaque dab reads as a tint of this rather than as a
// colour that arrived from nowhere - which is what the engine's standard material does
// with vertex colours, and therefore what this preview has to do to be worth trusting.
float br = shade * 118 + 26;
float bg = shade * 168 + 30;
float bb = shade * 208 + 38;
// VERTEX COLOURS, AVERAGED ACROSS THE FACE. A PolyMesh face is flat-shaded here, so
// there is no per-pixel interpolation to carry a gradient; the average is the honest
// summary of what the four corners say. It means a dab smaller than one face shows up
// as a weak tint of the whole face rather than as a spot, which is the truth about
// vertex paint on a coarse mesh and exactly the thing the Known Issue warns about.
if ( mesh.HasVertexColors )
{
float pr = 0, pg = 0, pb = 0, pa = 0;
foreach ( var i in face.Indices )
{
var c = mesh.VertexColors[i];
pr += c.x; pg += c.y; pb += c.z; pa += c.w;
}
var n = face.Indices.Length;
pa /= n;
if ( pa > 0.001f )
{
// Source-over, with the paint lit by the same lambert the base gets. Painting
// a face does not make it stop facing away from the light.
br += (pr / n * 255f * shade - br) * pa;
bg += (pg / n * 255f * shade - bg) * pa;
bb += (pb / n * 255f * shade - bb) * pa;
}
}
var colour = Rgb( (int)br, (int)bg, (int)bb );
var poly = face.Indices.Select( i => Screen( projected[i] ) ).ToArray();
if ( !tile.Wireframe )
FillPolygon( buffer, bufW, bufH, poly, colour );
var edge = tile.Wireframe ? Rgb( 126, 196, 235 ) : Rgb( 12, 14, 17 );
for ( var i = 0; i < poly.Length; i++ )
DrawLine( buffer, bufW, bufH, poly[i], poly[(i + 1) % poly.Length], edge );
}
}
static int Rgb( int r, int g, int b ) =>
(Math.Clamp( r, 0, 255 ) << 16) | (Math.Clamp( g, 0, 255 ) << 8) | Math.Clamp( b, 0, 255 );
/// <summary>Scanline fill with the even-odd rule, which is correct for the convex and simple
/// faces the kernel produces.</summary>
static void FillPolygon( int[] buffer, int w, int h, Vec2[] poly, int colour )
{
var minY = Math.Max( 0, (int)MathF.Floor( poly.Min( p => p.y ) ) );
var maxY = Math.Min( h - 1, (int)MathF.Ceiling( poly.Max( p => p.y ) ) );
var crossings = new List<float>( 8 );
for ( var y = minY; y <= maxY; y++ )
{
crossings.Clear();
var scan = y + 0.5f;
for ( var i = 0; i < poly.Length; i++ )
{
var a = poly[i];
var b = poly[(i + 1) % poly.Length];
if ( a.y > scan == b.y > scan )
continue;
crossings.Add( a.x + (scan - a.y) / (b.y - a.y) * (b.x - a.x) );
}
if ( crossings.Count < 2 )
continue;
crossings.Sort();
for ( var c = 0; c + 1 < crossings.Count; c += 2 )
{
var x0 = Math.Max( 0, (int)MathF.Ceiling( crossings[c] - 0.5f ) );
var x1 = Math.Min( w - 1, (int)MathF.Floor( crossings[c + 1] - 0.5f ) );
for ( var x = x0; x <= x1; x++ )
buffer[y * w + x] = colour;
}
}
}
static void DrawLine( int[] buffer, int w, int h, Vec2 a, Vec2 b, int colour )
{
var steps = (int)MathF.Max( MathF.Abs( b.x - a.x ), MathF.Abs( b.y - a.y ) ) + 1;
for ( var i = 0; i <= steps; i++ )
{
var t = i / (float)steps;
var x = (int)MathF.Round( a.x + (b.x - a.x) * t );
var y = (int)MathF.Round( a.y + (b.y - a.y) * t );
if ( x >= 0 && x < w && y >= 0 && y < h )
buffer[y * w + x] = colour;
}
}
static int[] Downsample( int[] src, int w, int h, int factor )
{
var dw = w / factor;
var dh = h / factor;
var dst = new int[dw * dh];
for ( var y = 0; y < dh; y++ )
{
for ( var x = 0; x < dw; x++ )
{
int r = 0, g = 0, b = 0;
for ( var sy = 0; sy < factor; sy++ )
{
for ( var sx = 0; sx < factor; sx++ )
{
var p = src[(y * factor + sy) * w + x * factor + sx];
r += (p >> 16) & 0xFF;
g += (p >> 8) & 0xFF;
b += p & 0xFF;
}
}
var n = factor * factor;
dst[y * dw + x] = Rgb( r / n, g / n, b / n );
}
}
return dst;
}
// --- a 3x5 bitmap font, enough for labels -------------------------------------------------
static readonly Dictionary<char, string[]> Glyphs = BuildFont();
static Dictionary<char, string[]> BuildFont()
{
var f = new Dictionary<char, string[]>
{
['a'] = new[] { "###", "# #", "###", "# #", "# #" },
['b'] = new[] { "## ", "# #", "## ", "# #", "## " },
['c'] = new[] { "###", "# ", "# ", "# ", "###" },
['d'] = new[] { "## ", "# #", "# #", "# #", "## " },
['e'] = new[] { "###", "# ", "###", "# ", "###" },
['f'] = new[] { "###", "# ", "###", "# ", "# " },
['g'] = new[] { "###", "# ", "# #", "# #", "###" },
['h'] = new[] { "# #", "# #", "###", "# #", "# #" },
['i'] = new[] { "###", " # ", " # ", " # ", "###" },
['j'] = new[] { " #", " #", " #", "# #", "###" },
['k'] = new[] { "# #", "# #", "## ", "# #", "# #" },
['l'] = new[] { "# ", "# ", "# ", "# ", "###" },
['m'] = new[] { "# #", "###", "###", "# #", "# #" },
['n'] = new[] { "## ", "# #", "# #", "# #", "# #" },
['o'] = new[] { "###", "# #", "# #", "# #", "###" },
['p'] = new[] { "###", "# #", "###", "# ", "# " },
['q'] = new[] { "###", "# #", "# #", "###", " #" },
['r'] = new[] { "###", "# #", "## ", "# #", "# #" },
['s'] = new[] { "###", "# ", "###", " #", "###" },
['t'] = new[] { "###", " # ", " # ", " # ", " # " },
['u'] = new[] { "# #", "# #", "# #", "# #", "###" },
['v'] = new[] { "# #", "# #", "# #", "# #", " # " },
['w'] = new[] { "# #", "# #", "###", "###", "# #" },
['x'] = new[] { "# #", "# #", " # ", "# #", "# #" },
['y'] = new[] { "# #", "# #", "###", " # ", " # " },
['z'] = new[] { "###", " #", " # ", "# ", "###" },
['0'] = new[] { "###", "# #", "# #", "# #", "###" },
['1'] = new[] { " # ", "## ", " # ", " # ", "###" },
['2'] = new[] { "###", " #", "###", "# ", "###" },
['3'] = new[] { "###", " #", "###", " #", "###" },
['4'] = new[] { "# #", "# #", "###", " #", " #" },
['5'] = new[] { "###", "# ", "###", " #", "###" },
['6'] = new[] { "###", "# ", "###", "# #", "###" },
['7'] = new[] { "###", " #", " #", " #", " #" },
['8'] = new[] { "###", "# #", "###", "# #", "###" },
['9'] = new[] { "###", "# #", "###", " #", "###" },
['_'] = new[] { " ", " ", " ", " ", "###" },
['-'] = new[] { " ", " ", "###", " ", " " },
['('] = new[] { " ##", "# ", "# ", "# ", " ##" },
[')'] = new[] { "## ", " #", " #", " #", "## " },
['.'] = new[] { " ", " ", " ", " ", " # " },
[' '] = new[] { " ", " ", " ", " ", " " },
};
return f;
}
static void DrawText( int[] buffer, int w, int h, int x, int y, string text, int colour, int scale = 2 )
{
var cursor = x;
foreach ( var raw in text.ToLowerInvariant() )
{
if ( !Glyphs.TryGetValue( raw, out var glyph ) )
glyph = Glyphs[' '];
for ( var gy = 0; gy < 5; gy++ )
{
for ( var gx = 0; gx < 3; gx++ )
{
if ( glyph[gy][gx] != '#' )
continue;
for ( var sy = 0; sy < scale; sy++ )
{
for ( var sx = 0; sx < scale; sx++ )
{
var px = cursor + gx * scale + sx;
var py = y + gy * scale + sy;
if ( px >= 0 && px < w && py >= 0 && py < h )
buffer[py * w + px] = colour;
}
}
}
}
cursor += 4 * scale;
}
}
// --- PNG encoding -------------------------------------------------------------------------
internal static void WritePng( string path, int[] pixels, int w, int h )
{
// Raw scanlines, each prefixed with filter type 0 (None). Simplest valid encoding.
var raw = new byte[h * (w * 3 + 1)];
var o = 0;
for ( var y = 0; y < h; y++ )
{
raw[o++] = 0;
for ( var x = 0; x < w; x++ )
{
var p = pixels[y * w + x];
raw[o++] = (byte)((p >> 16) & 0xFF);
raw[o++] = (byte)((p >> 8) & 0xFF);
raw[o++] = (byte)(p & 0xFF);
}
}
using var fs = File.Create( path );
fs.Write( new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A } );
var ihdr = new byte[13];
WriteBe( ihdr, 0, w );
WriteBe( ihdr, 4, h );
ihdr[8] = 8; // bit depth
ihdr[9] = 2; // colour type 2 = truecolour RGB
Chunk( fs, "IHDR", ihdr );
Chunk( fs, "IDAT", ZlibCompress( raw ) );
Chunk( fs, "IEND", Array.Empty<byte>() );
}
static void WriteBe( byte[] b, int offset, int value )
{
b[offset] = (byte)(value >> 24);
b[offset + 1] = (byte)(value >> 16);
b[offset + 2] = (byte)(value >> 8);
b[offset + 3] = (byte)value;
}
static void Chunk( Stream s, string type, byte[] data )
{
var length = new byte[4];
WriteBe( length, 0, data.Length );
s.Write( length );
var typeBytes = System.Text.Encoding.ASCII.GetBytes( type );
s.Write( typeBytes );
s.Write( data );
var crc = Crc32( typeBytes, data );
var crcBytes = new byte[4];
WriteBe( crcBytes, 0, unchecked((int)crc) );
s.Write( crcBytes );
}
/// <summary>DeflateStream emits a raw deflate stream; zlib wants a 2-byte header in front and
/// an Adler-32 of the UNCOMPRESSED data on the end.</summary>
static byte[] ZlibCompress( byte[] data )
{
using var ms = new MemoryStream();
ms.WriteByte( 0x78 );
ms.WriteByte( 0x01 );
using ( var deflate = new DeflateStream( ms, CompressionLevel.Optimal, leaveOpen: true ) )
deflate.Write( data );
uint a = 1, b = 0;
foreach ( var x in data )
{
a = (a + x) % 65521;
b = (b + a) % 65521;
}
var adler = (b << 16) | a;
ms.WriteByte( (byte)(adler >> 24) );
ms.WriteByte( (byte)(adler >> 16) );
ms.WriteByte( (byte)(adler >> 8) );
ms.WriteByte( (byte)adler );
return ms.ToArray();
}
static readonly uint[] CrcTable = BuildCrcTable();
static uint[] BuildCrcTable()
{
var table = new uint[256];
for ( uint n = 0; n < 256; n++ )
{
var c = n;
for ( var k = 0; k < 8; k++ )
c = (c & 1) != 0 ? 0xEDB88320u ^ (c >> 1) : c >> 1;
table[n] = c;
}
return table;
}
static uint Crc32( byte[] a, byte[] b )
{
var c = 0xFFFFFFFFu;
foreach ( var x in a )
c = CrcTable[(c ^ x) & 0xFF] ^ (c >> 8);
foreach ( var x in b )
c = CrcTable[(c ^ x) & 0xFF] ^ (c >> 8);
return c ^ 0xFFFFFFFFu;
}
}