Effigy.Tests/SvgPreview.cs
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using Effigy;

namespace Effigy.Tests;

/// <summary>
/// Renders a mesh to a shaded SVG so kernel output can be looked at rather than only asserted
/// about.
///
/// A test suite proves the numbers are right; it does not show that a slot looks like a slot.
/// Painter's algorithm with flat shading is enough to catch the whole class of "the maths passed
/// and the shape is still wrong", and SVG means no image library and no renderer in the loop.
/// </summary>
public static class SvgPreview
{
	public static void Write( PolyMesh mesh, string path, string title, int size = 460, bool wireframe = false )
	{
		File.WriteAllText( path, Render( mesh, title, size, wireframe ) );
	}

	public static string Render( PolyMesh mesh, string title, int size = 460, bool wireframe = false )
	{
		var c = CultureInfo.InvariantCulture;

		// A three-quarter view: rotate around Z then tilt down, so nothing lands edge-on and the
		// silhouette actually reads.
		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 );

		// Positions go through `view` into VIEW space, and Screen() reads x and z from that — so
		// view-space Y is depth, and the viewer sits at -Y looking toward +Y. `camera` is therefore
		// a constant axis in view space, NOT a transformed world direction: transforming it mixes
		// the two spaces and the backface test becomes almost arbitrary.
		//
		// depth = dot(p, camera) = -p.y, so far geometry has the SMALLER depth and ascending order
		// paints back to front.
		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();

		if ( projected.Count == 0 )
			return $"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{size}\" height=\"{size}\"></svg>";

		// Fit the model to the viewport with a margin, preserving aspect.
		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 scale = (size - 60) / span;
		var cx = (minX + maxX) * 0.5f;
		var cz = (minZ + maxZ) * 0.5f;

		// SVG's Y axis points down, so the world Z term is negated to keep up looking up.
		Vec2 Screen( Vec3 p ) => new(
			size * 0.5f + (p.x - cx) * scale,
			size * 0.5f - (p.z - cz) * scale );

		// Painter's algorithm, back to front by centroid depth. With backface culling on a convex
		// solid the order is irrelevant; it only matters for shapes like the torus that can occlude
		// themselves.
		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();

		var sb = new StringBuilder();
		sb.Append( c, $"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {size} {size}\" width=\"{size}\" height=\"{size}\">\n" );
		sb.Append( "<rect width=\"100%\" height=\"100%\" fill=\"#14161a\"/>\n" );

		var drawn = 0;

		foreach ( var (fi, _) in order )
		{
			var face = mesh.Faces[fi];
			var normal = mesh.FaceNormal( face );
			var viewNormal = view.TransformDirection( normal );

			// Backface cull: keep faces whose view-space normal points back toward the viewer at
			// -Y, which is dot(normal, camera) > 0. Every mesh the kernel produces is a closed
			// solid with outward normals, so this is safe — and it doubles as a visual check on
			// the winding tests, because an inside-out solid renders as a hole.
			if ( Vec3.Dot( viewNormal, camera ) <= 0.01f )
				continue;

			var lambert = MathF.Max( 0f, Vec3.Dot( normal, light ) );
			var shade = 0.22f + 0.78f * lambert * lambert;

			var r = (int)(shade * 118 + 26);
			var g = (int)(shade * 168 + 30);
			var b = (int)(shade * 208 + 38);

			var points = string.Join( " ", face.Indices.Select( i =>
			{
				var s = Screen( projected[i] );
				return string.Format( c, "{0:0.##},{1:0.##}", s.x, s.y );
			} ) );

			var stroke = wireframe ? "#0b0c0e" : $"rgb({r},{g},{b})";
			var width = wireframe ? "0.7" : "0.35";

			sb.Append( c, $"<polygon points=\"{points}\" fill=\"rgb({r},{g},{b})\" stroke=\"{stroke}\" stroke-width=\"{width}\"/>\n" );
			drawn++;
		}

		sb.Append( c, $"<text x=\"16\" y=\"{size - 30}\" fill=\"#8b98a8\" font-family=\"ui-monospace,monospace\" font-size=\"13\">{Escape( title )}</text>\n" );
		sb.Append( c, $"<text x=\"16\" y=\"{size - 13}\" fill=\"#5d6874\" font-family=\"ui-monospace,monospace\" font-size=\"11\">{mesh.VertexCount} verts · {mesh.FaceCount} faces · {drawn} drawn</text>\n" );
		sb.Append( "</svg>\n" );

		return sb.ToString();
	}

	static string Escape( string s ) =>
		s.Replace( "&", "&amp;" ).Replace( "<", "&lt;" ).Replace( ">", "&gt;" );
}