Effigy/Render/ModelRender.cs

Rendering utilities for orthographic model snapshots. Defines RenderView (named camera directions and a turntable generator), RenderOptions, and ModelRender which rasterizes Bodies and optional Skeletons into Raster images with grid, axes, lighting, wireframe, labels, sheets and turntables.

Native Interop
using System;
using System.Collections.Generic;
using System.Linq;

namespace Effigy.Render;

/// <summary>A named orthographic view: where the camera looks from, and which way is up on screen.</summary>
public sealed class RenderView
{
	public string Name;
	public Vec3 Forward;
	public Vec3 Up;

	/// <summary>Front looks at the model's face (+x is forward in this engine), so the model's left
	/// (+y) is on the viewer's right — the way a person in front of you appears. Side is from the
	/// model's right, with its front pointing right on screen. Top has forward at the top of the
	/// image. Iso is the three-quarter view from the front-right, a little above.</summary>
	public static readonly RenderView Front = new() { Name = "front", Forward = new Vec3( -1, 0, 0 ), Up = new Vec3( 0, 0, 1 ) };
	public static readonly RenderView Back = new() { Name = "back", Forward = new Vec3( 1, 0, 0 ), Up = new Vec3( 0, 0, 1 ) };
	public static readonly RenderView Side = new() { Name = "side", Forward = new Vec3( 0, 1, 0 ), Up = new Vec3( 0, 0, 1 ) };
	public static readonly RenderView Left = new() { Name = "left", Forward = new Vec3( 0, -1, 0 ), Up = new Vec3( 0, 0, 1 ) };
	public static readonly RenderView Top = new() { Name = "top", Forward = new Vec3( 0, 0, -1 ), Up = new Vec3( 1, 0, 0 ) };
	public static readonly RenderView Iso = new() { Name = "iso", Forward = new Vec3( -1f, 0.8f, -0.55f ).Normal, Up = new Vec3( 0, 0, 1 ) };

	public static readonly RenderView[] All = { Front, Side, Top, Iso, Back, Left };

	/// <summary>A view from <paramref name="yawDegrees"/> round the model (0 is the front, growing
	/// toward the model's left), <paramref name="elevationDegrees"/> above level. The turntable.</summary>
	public static RenderView Turn( float yawDegrees, float elevationDegrees = 18f )
	{
		var yaw = yawDegrees * MathF.PI / 180f;
		var pitch = elevationDegrees * MathF.PI / 180f;
		var eye = new Vec3( MathF.Cos( yaw ) * MathF.Cos( pitch ), MathF.Sin( yaw ) * MathF.Cos( pitch ), MathF.Sin( pitch ) );
		return new RenderView { Name = $"turn {yawDegrees:0}", Forward = -eye, Up = new Vec3( 0, 0, 1 ) };
	}

	/// <summary>A view by name: one of <see cref="All"/>, or `turn45`, `turn-30e5` — a yaw round the
	/// model and, after the e, an elevation in degrees (18 when left out). Null for nothing known.</summary>
	public static RenderView Named( string name )
	{
		if ( string.IsNullOrWhiteSpace( name ) )
			return null;

		name = name.Trim();

		var fixedView = All.FirstOrDefault( v => string.Equals( v.Name, name, StringComparison.OrdinalIgnoreCase ) );

		if ( fixedView is not null )
			return fixedView;

		var m = System.Text.RegularExpressions.Regex.Match( name, @"^turn\s*(-?\d+(?:\.\d+)?)(?:\s*e\s*(-?\d+(?:\.\d+)?))?$", System.Text.RegularExpressions.RegexOptions.IgnoreCase );

		if ( !m.Success )
			return null;

		var yaw = float.Parse( m.Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture );
		var elevation = m.Groups[2].Success ? float.Parse( m.Groups[2].Value, System.Globalization.CultureInfo.InvariantCulture ) : 18f;
		return Turn( yaw, elevation );
	}

	/// <summary>The screen axes: right, up, and forward (depth grows along it).</summary>
	public (Vec3 Right, Vec3 Up, Vec3 Forward) Axes()
	{
		var f = Forward.Normal;
		var right = Vec3.Cross( f, Up );

		if ( right.LengthSquared < 1e-8f )
			right = Vec3.Cross( f, new Vec3( 1, 0, 0 ) );

		right = right.Normal;
		var up = Vec3.Cross( right, f ).Normal;
		return (right, up, f);
	}
}

public sealed class RenderOptions
{
	public bool Wireframe = true;
	public bool Grid = true;
	public bool Axes = true;
	public bool Labels = true;
	public bool ColourPerBody = true;
	public bool Bones = false;

	/// <summary>Empty space round the model, as a fraction of the frame.</summary>
	public float Margin = 0.10f;

	/// <summary>Units per pixel. Zero fits the frame to the model; set it to compare renders at
	/// one scale, or to show the model at the size it really is next to a reference.</summary>
	public float UnitsPerPixel = 0f;

	/// <summary>Draw the frame round this body's bounds instead of everything's — a close-up.</summary>
	public string FocusBodyId;

	/// <summary>Bodies drawn lit; the rest faint. Null draws them all the same.</summary>
	public HashSet<string> Highlight;
}

/// <summary>
/// Draws a studio's bodies into a <see cref="Raster"/>, from named views, with a ground grid,
/// the axes, a label per body and the wire on top. This is how an agent looks at what it built.
///
/// ORTHOGRAPHIC, FITTED TO THE MODEL. A perspective camera is nicer to look at and worse to
/// reason from: two things the same size look different sizes, and a picture you cannot measure
/// off is a picture that only says "something is there". Every view here is a drawing you can
/// hold a ruler to, and the grid says what the ruler reads.
/// </summary>
public static class ModelRender
{
	const int Background = 0x121418;
	const int GridColour = 0x24282e;
	const int GridMajor = 0x33383f;
	const int WireColour = 0x0c0e11;
	const int LabelColour = 0x9aa2ae;


	/// <summary>Body colours, from a fixed palette by index so the same body keeps its colour from
	/// view to view and render to render.</summary>
	static readonly int[] Palette =
	{
		0x8fa3b8, 0xd4a35c, 0x7bbf8f, 0xc77b7b, 0x9a8fd0, 0x67b8c6, 0xc9a2d6, 0xb5b56a,
	};

	public static Raster Render( IReadOnlyList<Body> bodies, RenderView view, int size, RenderOptions options = null, Skeleton rig = null, bool[] silhouette = null )
	{
		options ??= new RenderOptions();
		var raster = new Raster( size, size, Background );
		var (right, up, forward) = view.Axes();

		// Three lights that ride with the camera: a key over the viewer's right shoulder, a dimmer
		// fill from the left, and a rim from behind-above so a silhouette edge catches. Form reads
		// from the gradient between them; one light leaves a curved surface reading as flat.
		var key = (forward * -0.6f + up * 0.55f + right * 0.55f).Normal;
		var fill = (forward * -0.5f + up * 0.1f - right * 0.8f).Normal;
		var rim = (forward * 0.6f + up * 0.7f - right * 0.2f).Normal;

		var visible = (bodies ?? Array.Empty<Body>()).Where( b => b?.Mesh is not null && b.Visible && b.Mesh.Positions.Count > 0 ).ToList();

		// --- the frame: fit the model's projected bounds, or a fixed scale about its centre ---
		var frameBodies = options.FocusBodyId is not null
			? visible.Where( b => b.Id == options.FocusBodyId ).ToList()
			: visible;

		if ( frameBodies.Count == 0 )
			frameBodies = visible;

		var minX = float.MaxValue; var maxX = float.MinValue;
		var minY = float.MaxValue; var maxY = float.MinValue;
		var worldMin = new Vec3( float.MaxValue, float.MaxValue, float.MaxValue );
		var worldMax = new Vec3( float.MinValue, float.MinValue, float.MinValue );

		foreach ( var body in frameBodies )
		{
			foreach ( var p in body.Mesh.Positions )
			{
				var sx = Vec3.Dot( p, right );
				var sy = Vec3.Dot( p, up );
				minX = MathF.Min( minX, sx ); maxX = MathF.Max( maxX, sx );
				minY = MathF.Min( minY, sy ); maxY = MathF.Max( maxY, sy );
				worldMin = new Vec3( MathF.Min( worldMin.x, p.x ), MathF.Min( worldMin.y, p.y ), MathF.Min( worldMin.z, p.z ) );
				worldMax = new Vec3( MathF.Max( worldMax.x, p.x ), MathF.Max( worldMax.y, p.y ), MathF.Max( worldMax.z, p.z ) );
			}
		}

		if ( frameBodies.Count == 0 )
		{
			minX = minY = -10f; maxX = maxY = 10f;
			worldMin = new Vec3( -10, -10, 0 ); worldMax = new Vec3( 10, 10, 10 );
		}

		var span = MathF.Max( MathF.Max( maxX - minX, maxY - minY ), 1e-3f );
		var scale = options.UnitsPerPixel > 0f
			? 1f / options.UnitsPerPixel
			: size * (1f - options.Margin * 2f) / span;

		var cx = (minX + maxX) * 0.5f;
		var cy = (minY + maxY) * 0.5f;

		Vec2 Screen( Vec3 p ) => new(
			size * 0.5f + (Vec3.Dot( p, right ) - cx) * scale,
			size * 0.5f - (Vec3.Dot( p, up ) - cy) * scale );

		float DepthOf( Vec3 p ) => Vec3.Dot( p, forward );

		// --- ground grid, behind everything: a line every step that reads as about 40px ---
		if ( options.Grid )
		{
			var step = GridStep( scale );
			var extent = MathF.Max( MathF.Max( MathF.Abs( worldMin.x ), MathF.Abs( worldMax.x ) ), MathF.Max( MathF.Abs( worldMin.y ), MathF.Abs( worldMax.y ) ) );
			extent = MathF.Max( extent, span * 0.5f ) + step * 2f;
			var lines = (int)MathF.Min( MathF.Ceiling( extent / step ), 200 );

			for ( var i = -lines; i <= lines; i++ )
			{
				var major = i % 5 == 0;
				var colour = major ? GridMajor : GridColour;
				var a = Screen( new Vec3( i * step, -lines * step, 0 ) );
				var b = Screen( new Vec3( i * step, lines * step, 0 ) );
				raster.Line( a.x, a.y, b.x, b.y, colour );
				a = Screen( new Vec3( -lines * step, i * step, 0 ) );
				b = Screen( new Vec3( lines * step, i * step, 0 ) );
				raster.Line( a.x, a.y, b.x, b.y, colour );
			}

			if ( options.Labels )
				raster.Text( 6, size - 12, $"grid {Trim( step )} u", LabelColour, 1 );
		}

		if ( options.Axes )
		{
			var o = Screen( Vec3.Zero );
			var length = GridStep( scale );
			var ax = Screen( new Vec3( length, 0, 0 ) );
			var ay = Screen( new Vec3( 0, length, 0 ) );
			var az = Screen( new Vec3( 0, 0, length ) );
			raster.Line( o.x, o.y, ax.x, ax.y, 0xe5625a );
			raster.Line( o.x, o.y, ay.x, ay.y, 0x3fbf8a );
			raster.Line( o.x, o.y, az.x, az.y, 0x5b93ff );
		}

		// --- the bodies, depth tested ---
		for ( var bi = 0; bi < visible.Count; bi++ )
		{
			var body = visible[bi];
			var mesh = body.Mesh;
			var baseColour = options.ColourPerBody ? Palette[bi % Palette.Length] : Palette[0];
			var dim = options.Highlight is not null && !options.Highlight.Contains( body.Id );

			var projected = new Vec2[mesh.Positions.Count];
			var depth = new float[mesh.Positions.Count];

			for ( var i = 0; i < mesh.Positions.Count; i++ )
			{
				projected[i] = Screen( mesh.Positions[i] );
				depth[i] = DepthOf( mesh.Positions[i] );
			}

			foreach ( var face in mesh.Faces )
			{
				if ( face.Indices.Length < 3 )
					continue;

				var normal = mesh.FaceNormal( face );

				// Both sides are drawn — an open mesh's back wall still says where the mesh is —
				// but a face turned away is darkened, so a flipped face reads as the dark patch it
				// would be in the engine.
				var facing = Vec3.Dot( normal, forward ) < 0f;
				var n = facing ? normal : -normal;
				var shade = 0.24f
					+ 0.56f * MathF.Max( 0f, Vec3.Dot( n, key ) )
					+ 0.20f * MathF.Max( 0f, Vec3.Dot( n, fill ) )
					+ 0.16f * MathF.Pow( MathF.Max( 0f, Vec3.Dot( n, rim ) ), 3f );
				shade = MathF.Min( shade, 1.05f );

				if ( !facing )
					shade *= 0.45f;

				if ( dim )
					shade *= 0.35f;

				var colour = Raster.Rgb(
					(int)(((baseColour >> 16) & 0xFF) * shade),
					(int)(((baseColour >> 8) & 0xFF) * shade),
					(int)((baseColour & 0xFF) * shade) );

				for ( var k = 1; k + 1 < face.Indices.Length; k++ )
				{
					var a = face.Indices[0];
					var b = face.Indices[k];
					var c = face.Indices[k + 1];
					raster.Triangle( projected[a], projected[b], projected[c], depth[a], depth[b], depth[c], colour, silhouette );
				}
			}

			if ( options.Wireframe && mesh.Faces.Count < 60000 )
			{
				var bias = span * 0.002f;

				foreach ( var face in mesh.Faces )
				{
					for ( var k = 0; k < face.Indices.Length; k++ )
					{
						var a = face.Indices[k];
						var b = face.Indices[(k + 1) % face.Indices.Length];
						raster.Line( projected[a].x, projected[a].y, projected[b].x, projected[b].y, WireColour, 0.75f, depth[a], depth[b], bias );
					}
				}
			}
		}

		if ( options.Bones && rig is { Count: > 0 } )
		{
			for ( var i = 0; i < rig.Count; i++ )
			{
				var head = Screen( rig.HeadWorld( i ) );
				var tail = Screen( rig.TailWorld( i ) );
				raster.Line( head.x, head.y, tail.x, tail.y, 0xf0a13a );
				raster.Rect( (int)head.x - 1, (int)head.y - 1, 3, 3, 0xffffff );
			}
		}

		// --- labels: the body's name at its centre, and the view's name ---
		if ( options.Labels )
		{
			foreach ( var body in visible )
			{
				var centre = Vec3.Zero;
				foreach ( var p in body.Mesh.Positions )
					centre += p;
				centre /= body.Mesh.Positions.Count;

				var at = Screen( centre );
				var text = body.Name ?? body.Id;
				var w = Raster.TextWidth( text, 1 );
				raster.Rect( (int)at.x - w / 2 - 2, (int)at.y - 4, w + 4, 9, 0x000000, 0.55f );
				raster.Text( (int)at.x - w / 2, (int)at.y - 3, text, 0xe7e9ee, 1 );
			}

			raster.Text( 6, 6, view.Name, LabelColour, 2 );
			raster.Text( 6, 20, $"{Trim( worldMax.x - worldMin.x )} x {Trim( worldMax.y - worldMin.y )} x {Trim( worldMax.z - worldMin.z )} u", LabelColour, 1 );
		}

		return raster;
	}

	/// <summary>
	/// A picture by name: "sheet" (front, side, top, iso), "turn" (eight views round the model at
	/// 45°, a turntable; turn4, turn12, turn16, turn24), any single view, or "turn45" / "turn-30e5"
	/// for one view from a yaw and an elevation. One entry for the CLI, the script and the
	/// editor tool, so they cannot disagree about what a name means.
	/// </summary>
	public static Raster Named( IReadOnlyList<Body> bodies, string name, int size, RenderOptions options = null, Skeleton rig = null )
	{
		name = (name ?? "sheet").Trim().ToLowerInvariant();

		if ( name == "sheet" )
			return Sheet( bodies, size / 2, options, rig );

		// "turn" and "turn12" are turntables; "turn45" or "turn-30e5" is one view from that angle.
		if ( name is "turn" or "turn4" or "turn8" or "turn12" or "turn16" or "turn24" )
			return Turntable( bodies, size / 4, name == "turn" ? 8 : int.Parse( name[4..] ), options, rig );

		var view = RenderView.Named( name );

		if ( view is null )
			throw new ArgumentException( $"No view called '{name}'. Views: sheet, turn, turn12, {string.Join( ", ", RenderView.All.Select( v => v.Name ) )}." );

		return Render( bodies, view, size, options, rig );
	}

	/// <summary>Views round the model, four to a row: the turntable. Elevated 18°, so the top reads too.</summary>
	public static Raster Turntable( IReadOnlyList<Body> bodies, int tile, int steps, RenderOptions options = null, Skeleton rig = null )
	{
		var columns = Math.Min( 4, steps );
		var rows = (steps + columns - 1) / columns;
		var sheet = new Raster( tile * columns, tile * rows, Background );

		for ( var i = 0; i < steps; i++ )
		{
			var one = Render( bodies, RenderView.Turn( 360f * i / steps ), tile, options, rig );
			var ox = (i % columns) * tile;
			var oy = (i / columns) * tile;

			for ( var y = 0; y < tile; y++ )
				Array.Copy( one.Pixels, y * tile, sheet.Pixels, (oy + y) * sheet.Width + ox, tile );
		}

		return sheet;
	}

	/// <summary>The four views on one sheet: front, side, top, iso.</summary>
	public static Raster Sheet( IReadOnlyList<Body> bodies, int tile, RenderOptions options = null, Skeleton rig = null )
	{
		var views = new[] { RenderView.Front, RenderView.Side, RenderView.Top, RenderView.Iso };
		var sheet = new Raster( tile * 2, tile * 2, Background );

		for ( var i = 0; i < views.Length; i++ )
		{
			var one = Render( bodies, views[i], tile, options, rig );
			var ox = (i % 2) * tile;
			var oy = (i / 2) * tile;

			for ( var y = 0; y < tile; y++ )
				Array.Copy( one.Pixels, y * tile, sheet.Pixels, (oy + y) * sheet.Width + ox, tile );
		}

		// Hairlines between the tiles.
		for ( var i = 0; i < sheet.Width; i++ )
		{
			sheet.Plot( i, tile, 0x2a2e35 );
			sheet.Plot( tile, i, 0x2a2e35 );
		}

		return sheet;
	}

	/// <summary>
	/// The silhouette from a view, fitted to the model's own bounds with no margin, as a bool per
	/// pixel. What <see cref="ReferenceMatch"/> compares against a reference drawing.
	/// </summary>
	public static bool[] Silhouette( IReadOnlyList<Body> bodies, RenderView view, int size )
	{
		var mask = new bool[size * size];
		Render( bodies, view, size, new RenderOptions { Wireframe = false, Grid = false, Axes = false, Labels = false, Margin = 0f }, null, mask );
		return mask;
	}

	static float GridStep( float scale )
	{
		// A grid line about every 40 pixels, snapped to 1, 2, 5 × 10^n units.
		var raw = 40f / MathF.Max( scale, 1e-6f );
		var magnitude = MathF.Pow( 10f, MathF.Floor( MathF.Log10( raw ) ) );
		var mantissa = raw / magnitude;
		var snapped = mantissa < 1.5f ? 1f : mantissa < 3.5f ? 2f : mantissa < 7.5f ? 5f : 10f;
		return snapped * magnitude;
	}

	static string Trim( float v ) => v >= 100f ? $"{v:0}" : v >= 10f ? $"{v:0.#}" : $"{v:0.##}";
}