Editor/UI/ArchPresetPreview.cs

Editor UI helper for architecture preset thumbnails. It stages an ArchPlan into an editor Scene, strips scaffolding, frames and lights it, renders to a Pixmap and draws feature edges; it also caches generated pixmaps and pumps a queue one item per frame.

File AccessNative Interop
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;

namespace Sunless.Architecture;

// An opening is a unit CUT INTO a wall, so its card throws the wall away; a wall treatment IS the wall,
// so its card keeps it. One flag, because the staging is otherwise identical.
public enum ArchPreviewFocus
{
	Unit,
	Whole
}

// Interest is the box the unit actually occupies, and only the recipe that staged it knows: a wall's END
// CAPS are cut faces and carry the Reveal role, so role alone cannot tell the jamb beside a window from
// the cap 100 units away at the end of the staging run.
public readonly record struct ArchPreviewShot( Vector2 Size, Angles View, ArchPreviewFocus Focus, BBox? Interest );

public interface IArchPreviewWatcher
{
	bool Alive { get; }

	void PreviewTick( bool advanced );
}

// The frame hook has to hang off a NON-generic type: the event system keys handlers by the type it found
// in the assembly, which for ArchPresetBrowser<T> is the open definition, while every live instance is a
// closed one. Registered on the generic itself, the pump silently never runs and every card stays a
// placeholder forever.
public static class ArchPreviewPump
{
	static readonly List<IArchPreviewWatcher> watchers = new();

	public static void Watch( IArchPreviewWatcher watcher )
	{
		watchers.Add( watcher );
	}

	[EditorEvent.Frame]
	public static void Frame()
	{
		watchers.RemoveAll( watcher => !watcher.Alive );

		if ( watchers.Count == 0 )
		{
			return;
		}

		var advanced = ArchPresetPreview.Pump();

		foreach ( var watcher in watchers.ToList() )
		{
			watcher.PreviewTick( advanced );
		}
	}
}

// Card art comes from the PRODUCTION generators, staged the way a designer stages its turntable - a
// hand-drawn approximation is free to disagree with what the drag actually builds.
public static class ArchPresetPreview
{
	// Folded into every cache key: a change to the framing or the clay has to invalidate what it framed.
	const int Recipe = 6;

	const float Padding = 1.06f;

	public static readonly Angles Elevation = new( 0f, 90f, 0f );
	public static readonly Angles Quarter = new( 20f, 52f, 0f );

	sealed class Pending
	{
		public string Key { get; init; }
		public ArchPreviewShot Shot { get; init; }
		public ArchKit Kit { get; init; }
		public Func<ArchStaged> Compose { get; init; }
	}

	static readonly Dictionary<string, Pixmap> cache = new();
	static readonly HashSet<string> failed = new();
	static readonly List<Pending> queue = new();

	// Sized to the CARD's art rect, not to a square: Paint.Draw stretches source onto destination, so a
	// square pixmap in an 86x78 tile both squashes the unit and wastes the space it was squashed into.
	// A miss queues instead of rendering: eight cards staged the frame a tool opens is a visible stall.
	public static Pixmap For( string identity, ArchPreviewShot shot, ArchKit kit, Func<ArchStaged> compose )
	{
		// Interest belongs in the key: it decides what survives the strip, so two shots that differ only
		// by it are two different pictures, and leaving it out serves the first one forever.
		var key = $"{Recipe}|{identity}|{shot.Size.x:0}x{shot.Size.y:0}|{shot.View}|{shot.Focus}|{shot.Interest?.Size}";

		if ( cache.TryGetValue( key, out var hit ) )
		{
			return hit;
		}

		if ( failed.Contains( key ) || queue.Any( entry => entry.Key == key ) )
		{
			return null;
		}

		queue.Add( new Pending { Key = key, Shot = shot, Kit = kit, Compose = compose } );

		return null;
	}

	// One stage per frame, so a browser fills in over a few frames rather than blocking on open.
	public static bool Pump()
	{
		if ( queue.Count == 0 )
		{
			return false;
		}

		var next = queue[0];
		queue.RemoveAt( 0 );

		var pixmap = Render( next );

		if ( pixmap is null )
		{
			failed.Add( next.Key );
			return true;
		}

		cache[next.Key] = pixmap;

		return true;
	}

	public static void Invalidate( string identity = null )
	{
		queue.Clear();

		if ( string.IsNullOrWhiteSpace( identity ) )
		{
			cache.Clear();
			failed.Clear();
			return;
		}

		foreach ( var key in cache.Keys.Where( key => key.Contains( $"|{identity}|" ) ).ToList() )
		{
			cache.Remove( key );
		}

		foreach ( var key in failed.Where( key => key.Contains( $"|{identity}|" ) ).ToList() )
		{
			failed.Remove( key );
		}
	}

	static Pixmap Render( Pending request )
	{
		Scene scene = null;

		try
		{
			var staged = request.Compose();

			if ( staged.Plan is null )
			{
				return null;
			}

			scene = Scene.CreateEditorScene();

			using ( scene.Push() )
			{
				Staged( scene, staged, request.Kit, request.Shot );
				Light( scene );

				if ( !Bounds( scene, out var bounds ) )
				{
					return null;
				}

				var camera = Aim( scene, request.Shot.View, bounds, request.Shot.Size.x / request.Shot.Size.y );
				var pixmap = new Pixmap( (int)request.Shot.Size.x, (int)request.Shot.Size.y );

				if ( !camera.RenderToPixmap( pixmap ) )
				{
					return null;
				}

				// The card is already rendered by here; a line pass that trips must not cost it its art.
				try
				{
					Edges( scene, camera, pixmap );
				}
				catch ( Exception exception )
				{
					Log.Warning( $"Architecture: could not line {request.Key}: {exception.Message}" );
				}

				return pixmap;
			}
		}
		catch ( Exception exception )
		{
			Log.Warning( $"Architecture: could not preview {request.Key}: {exception.Message}" );
			return null;
		}
		finally
		{
			scene?.Destroy();
		}
	}

	// The half of a card that is geometry, handed back as the roles left standing so a test can ask what
	// survived rather than being shown a picture of it.
	public static List<ArchSurface> Staged( Scene scene, ArchStaged staged, ArchKit kit, ArchPreviewShot shot )
	{
		Dress( scene, staged.Plan, kit );

		if ( shot.Focus == ArchPreviewFocus.Unit )
		{
			Strip( scene, shot.Interest );
		}

		return Standing( scene );
	}

	static List<ArchSurface> Standing( Scene scene )
	{
		var roles = ArchBlockout.Roles.ToDictionary(
			pair => ArchBlockout.PathFor( pair.Role ),
			pair => pair.Role,
			StringComparer.OrdinalIgnoreCase );

		return scene.GetAllObjects( true )
			.Select( node => node.Components.Get<MeshComponent>() )
			.Where( piece => piece is { Mesh: not null } )
			.SelectMany( piece => piece.Mesh.FaceHandles.Select( face => piece.Mesh.GetFaceMaterial( face )?.ResourcePath ) )
			.Where( path => path is not null && roles.ContainsKey( path ) )
			.Select( path => roles[path] )
			.Distinct()
			.ToList();
	}

	// The blockout grid rather than the map palette: a role per hue over one metre ruler reads as shape and
	// size, where a browser of scanned surfaces reads as a browser of textures. It also makes a face's ROLE
	// recoverable from its material, which is the only way Strip can tell wall from fitting.
	static void Dress( Scene scene, ArchPlan plan, ArchKit kit )
	{
		var dressed = kit.Palette;

		try
		{
			kit.Palette = Grid();
			ArchScene.Generate( scene, plan, kit );
		}
		finally
		{
			kit.Palette = dressed;
		}
	}

	static ArchPalette Grid()
	{
		if ( grid is not null )
		{
			return grid;
		}

		grid = new ArchPalette();
		ArchBlockout.Apply( grid );

		return grid;
	}

	static ArchPalette grid;

	// A preset card is the UNIT, not the wall it was cut into: the reveal, casing, sill, threshold, leaf,
	// sash and glass. The wall is only there because a hole needs something to be a hole in.
	static readonly ArchSurface[] Scaffolding =
	{
		ArchSurface.WallExterior,
		ArchSurface.WallInterior,
		ArchSurface.WallCap,
		ArchSurface.WallBase,
		ArchSurface.Siding,
		ArchSurface.Wainscot,
		ArchSurface.Baseboard,
		ArchSurface.Foundation,
		ArchSurface.Floor,
		ArchSurface.Ceiling
	};

	static void Strip( Scene scene, BBox? interest )
	{
		var dropped = Scaffolding.Select( ArchBlockout.PathFor ).ToHashSet( StringComparer.OrdinalIgnoreCase );

		foreach ( var node in scene.GetAllObjects( true ).ToList() )
		{
			if ( node.Components.Get<MeshComponent>() is not { Mesh: not null } piece )
			{
				continue;
			}

			var scaffold = piece.Mesh.FaceHandles
				.Where( face => dropped.Contains( piece.Mesh.GetFaceMaterial( face )?.ResourcePath )
					|| Outside( piece, face, interest ) )
				.ToList();

			// An archway IS its reveal, and a reveal is the one role a wall's own cut faces also wear - so on
			// the piece that holds the unit, where the interest clip would leave nothing, the role list has
			// the last word and the card keeps its lining. Only there: a wall's END CAPS are painted reveal
			// too, and rescued the same way they frame the card on the whole 192-wide elevation.
			if ( scaffold.Count == piece.Mesh.FaceHandles.Count() && node.Name == ArchPieces.Openings )
			{
				scaffold = piece.Mesh.FaceHandles
					.Where( face => dropped.Contains( piece.Mesh.GetFaceMaterial( face )?.ResourcePath ) )
					.ToList();
			}

			if ( scaffold.Count == 0 )
			{
				continue;
			}

			piece.Mesh.RemoveFaces( scaffold );

			// A mesh stripped to nothing still carries bounds, and empty bounds would frame the card on air.
			// A node holding pieces is kept whatever its own mesh came to - destroying it takes the unit
			// hanging under it down with it.
			if ( !piece.Mesh.FaceHandles.Any() )
			{
				if ( node.Children.Count == 0 )
				{
					node.Destroy();
				}

				continue;
			}

			piece.RebuildMesh();
		}
	}

	const float Crease = 8f;

	// Two flat faces of one hue meeting at a right angle are one shape at thumbnail size. Feature edges
	// are drawn over the render through the camera's OWN projection, so the line and the pixel it sits on
	// cannot disagree about where the geometry is.
	static void Edges( Scene scene, CameraComponent camera, Pixmap pixmap )
	{
		// OrthographicHeight is the FULL vertical extent in world units and the width follows the aspect,
		// so one scale carries both axes. Local y runs LEFT, which is why the horizontal is subtracted.
		var rotation = camera.WorldRotation;
		var origin = camera.WorldPosition;
		var scale = pixmap.Height / camera.OrthographicHeight;
		var middle = new Vector2( pixmap.Width, pixmap.Height ) * 0.5f;

		Vector2 Project( Vector3 world )
		{
			var local = rotation.Inverse * (world - origin);

			return new Vector2( middle.x - local.y * scale, middle.y - local.z * scale );
		}

		var forward = rotation.Forward;
		var lines = new List<(Vector2 From, Vector2 To)>();

		foreach ( var node in scene.GetAllObjects( true ) )
		{
			if ( node.Components.Get<MeshComponent>() is not { Mesh: not null } piece )
			{
				continue;
			}

			var mesh = piece.Mesh;
			var placed = piece.WorldTransform;

			foreach ( var edge in mesh.HalfEdgeHandles )
			{
				// A stripped mesh leaves half-edges whose face went with the faces that were removed.
				if ( !edge.IsValid || !edge.Face.IsValid )
				{
					continue;
				}

				var opposite = edge.OppositeEdge;

				// One edge is two half-edges; the lower index draws it, or every line is stroked twice.
				if ( opposite.IsValid && opposite.Face.IsValid && opposite.Index < edge.Index )
				{
					continue;
				}

				if ( !Feature( mesh, edge, opposite, placed, forward ) )
				{
					continue;
				}

				mesh.GetEdgeVertices( edge, out var from, out var to );

				lines.Add( (
					Project( placed.PointToWorld( mesh.GetVertexPosition( from ) ) ),
					Project( placed.PointToWorld( mesh.GetVertexPosition( to ) ) ) ) );
			}
		}

		if ( lines.Count == 0 )
		{
			return;
		}

		using ( Paint.ToPixmap( pixmap ) )
		{
			Paint.Antialiasing = true;
			Paint.ClearBrush();
			Paint.SetPen( Color.Black.WithAlpha( 0.55f ), 1f );

			foreach ( var line in lines )
			{
				Paint.DrawLine( line.From, line.To );
			}
		}
	}

	// A boundary, a silhouette or a crease - and never an edge whose faces both point away, or the card
	// fills with the hidden lines of the far side and reads as a scribble instead of a drawing.
	static bool Feature( PolygonMesh mesh, HalfEdgeMesh.HalfEdgeHandle edge, HalfEdgeMesh.HalfEdgeHandle opposite, Transform placed, Vector3 forward )
	{
		mesh.ComputeFaceNormal( edge.Face, out var near );

		var facing = Vector3.Dot( placed.NormalToWorld( near ), forward ) < 0f;

		if ( !opposite.IsValid || !opposite.Face.IsValid || opposite.Face == edge.Face )
		{
			return facing;
		}

		mesh.ComputeFaceNormal( opposite.Face, out var far );

		var behind = Vector3.Dot( placed.NormalToWorld( far ), forward ) < 0f;

		if ( !facing && !behind )
		{
			return false;
		}

		return facing != behind || Vector3.GetAngle( near, far ) > Crease;
	}

	static void Light( Scene scene )
	{
		var key = new GameObject( true, "key" );
		key.SetParent( scene );

		var sun = key.Components.GetOrCreate<DirectionalLight>();
		sun.WorldRotation = Rotation.From( 44f, -142f, 0f );
		sun.LightColor = Color.White * 3.4f;
		sun.SkyColor = new Color( 0.4f, 0.45f, 0.55f ) * 2.4f;
		sun.Shadows = false;

		var fill = new GameObject( true, "fill" );
		fill.SetParent( scene );

		var bounce = fill.Components.GetOrCreate<DirectionalLight>();
		bounce.WorldRotation = Rotation.From( -12f, 38f, 0f );
		bounce.LightColor = Color.White * 1.2f;
		bounce.SkyColor = Color.Black;
		bounce.Shadows = false;
	}

	// Measured off the SURVIVING half-edges, never off GetBounds: an object whose wall faces were stripped
	// still reports the box it had before, so every unit was framed inside a full-width wall and a small
	// window sat in the middle of a card sized for one four times its width.
	// Judged on the CENTRE: a jamb reveal reaches a little past the hole it lines, and clipping on any
	// corner outside would take the casing off with the wall.
	static bool Outside( MeshComponent piece, HalfEdgeMesh.FaceHandle face, BBox? interest )
	{
		if ( interest is not { } box )
		{
			return false;
		}

		return !box.Contains( piece.WorldTransform.PointToWorld( piece.Mesh.GetFaceCenter( face ) ) );
	}

	public static BBox? Framed( Scene scene ) => Bounds( scene, out var bounds ) ? bounds : null;

	// How much of the card's height the staged unit actually covers, asked through the ENGINE's own
	// projection rather than ours - the whole question is whether the camera we set up agrees with the
	// one that renders it.
	public static float Fills( Scene scene, ArchPreviewShot shot )
	{
		if ( !Bounds( scene, out var bounds ) )
		{
			return 0f;
		}

		var camera = Aim( scene, shot.View, bounds, shot.Size.x / shot.Size.y );

		var top = camera.PointToScreenNormal( bounds.Center + Vector3.Up * bounds.Size.z * 0.5f ).y;
		var bottom = camera.PointToScreenNormal( bounds.Center - Vector3.Up * bounds.Size.z * 0.5f ).y;

		return MathF.Abs( bottom - top );
	}

	static bool Bounds( Scene scene, out BBox bounds )
	{
		var found = false;

		bounds = default;

		foreach ( var node in scene.GetAllObjects( true ) )
		{
			if ( node.Components.Get<MeshComponent>() is not { Mesh: not null } piece )
			{
				continue;
			}

			var mesh = piece.Mesh;
			var placed = piece.WorldTransform;

			// The FACES are the surviving set - the half-edges of a removed face outlive it, so walking
			// those measures the wall that was stripped right back into the frame.
			foreach ( var face in mesh.FaceHandles )
			{
				foreach ( var vertex in mesh.GetFaceVertices( face ) )
				{
					var point = placed.PointToWorld( mesh.GetVertexPosition( vertex ) );

					bounds = found ? bounds.AddPoint( point ) : BBox.FromPositionAndSize( point, 0f );
					found = true;
				}
			}
		}

		return found;
	}

	static CameraComponent Aim( Scene scene, Angles view, BBox bounds, float aspect )
	{
		var node = new GameObject( true, "camera" );
		node.SetParent( scene );

		var camera = node.Components.GetOrCreate<CameraComponent>();
		camera.BackgroundColor = new Color( 0.085f, 0.09f, 0.1f );
		camera.Orthographic = true;
		camera.OrthographicHeight = Extent( bounds, view.ToRotation(), aspect ) * Padding;
		camera.ZNear = 1f;
		camera.ZFar = 40000f;

		var reach = bounds.Size.Length + 512f;

		node.WorldRotation = view.ToRotation();
		node.WorldPosition = bounds.Center - node.WorldRotation.Forward * reach;

		return camera;
	}

	// Whichever on-screen axis runs out first decides the frame, and across is measured against the card's
	// own aspect - a tall sash judged as if the card were square leaves half the tile empty.
	static float Extent( BBox bounds, Rotation rotation, float aspect )
	{
		var needed = 0f;

		foreach ( var corner in bounds.Corners )
		{
			var local = rotation.Inverse * (corner - bounds.Center);

			needed = MathF.Max( needed, MathF.Max( MathF.Abs( local.y ) / aspect, MathF.Abs( local.z ) ) );
		}

		return MathF.Max( 1f, needed * 2f );
	}
}