Editor/Output/ArchShot.cs

Editor utility that renders framed shots of architecture parts. It sets up a camera, optionally isolates target objects, renders to a bitmap or a grid sheet of bitmaps, and annotates results.

File Access
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;

namespace Sunless.Architecture;

// Along/Across use the part's OWN yaw: off-axis parts have no useful world-axis side view.
public enum ArchAngle
{
	Iso,
	IsoBack,
	IsoLocal,
	Front,
	Back,
	Left,
	Right,
	Top,
	Bottom,
	Along,
	AlongBack,
	Across,
	AcrossBack
}

public sealed class ArchShotOptions
{
	public int Width { get; set; } = 1280;
	public int Height { get; set; } = 800;
	public bool Orthographic { get; set; } = true;
	public float Padding { get; set; } = 0.08f;
	public bool Isolate { get; set; }
	public bool Annotate { get; set; } = true;
	public bool Frame { get; set; }
	// Ortho keeps the near plane: pushing it past the near face is a section cut.
	public float Cutaway { get; set; }
	public float Zoom { get; set; } = 1f;
}

// Framed orthos with a grid - the editor's own screenshot is a gizmo'd perspective grab.
public static partial class ArchShot
{
	const string IsolationTag = "arch_shot_subject_v2";
	const float MetresToInches = 39.37f;

	public static Bitmap Render( ArchTool tool, ArchTarget target, ArchAngle angle, ArchShotOptions options )
	{
		var bounds = target.Frame;
		var rotation = Look( angle, target.Yaw );
		var bitmap = new Bitmap( options.Width, options.Height );
		var stand = Stand( bounds, rotation, options.Width / (float)options.Height, options );
		var camera = Rig( tool.Scene );

		try
		{
			camera.WorldPosition = stand.Eye;
			camera.WorldRotation = stand.Rotation;
			camera.Orthographic = stand.Orthographic;
			camera.OrthographicHeight = stand.OrthoHeight;
			camera.FieldOfView = stand.FieldOfView;
			camera.CustomSize = new Vector2( options.Width, options.Height );
			camera.ZNear = stand.Near;
			camera.ZFar = stand.Far;

			using var isolation = new Isolation( tool, target, camera, options.Isolate );

			camera.RenderToBitmap( bitmap, false );
		}
		finally
		{
			camera.GameObject.DestroyImmediate();
		}

		if ( options.Annotate )
		{
			Annotate( bitmap, target, angle, stand.Eye, stand.Rotation, stand.OrthoHeight, options );
		}

		return bitmap;
	}

	public static Bitmap Sheet( ArchTool tool, ArchTarget target, IReadOnlyList<ArchAngle> angles, ArchShotOptions options )
	{
		var columns = angles.Count <= 1 ? 1 : 2;
		var rows = (angles.Count + columns - 1) / columns;

		var cell = new ArchShotOptions
		{
			Width = options.Width / columns,
			Height = options.Height / rows,
			Orthographic = options.Orthographic,
			Padding = options.Padding,
			Isolate = options.Isolate,
			Annotate = options.Annotate,
			Frame = options.Frame,
			Cutaway = options.Cutaway,
			Zoom = options.Zoom
		};

		var sheet = new Bitmap( cell.Width * columns, cell.Height * rows );
		sheet.Clear( new Color( 0.06f, 0.065f, 0.075f ) );

		for ( var index = 0; index < angles.Count; index++ )
		{
			using var pane = Render( tool, target, angles[index], cell );

			var column = index % columns;
			var row = index / columns;
			var at = new Rect( column * cell.Width, row * cell.Height, cell.Width, cell.Height );

			sheet.DrawBitmap( pane, at );

			sheet.SetPen( new Color( 0.28f, 0.3f, 0.34f ), 1f );
			sheet.DrawRect( at );
		}

		return sheet;
	}

	// Its own camera: no user viewport state, gizmos, or tool overlay.
	static CameraComponent Rig( Scene scene )
	{
		var node = scene.CreateObject();
		node.Name = "Architecture Shot";
		node.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.Hidden;

		var camera = node.Components.GetOrCreate<CameraComponent>();
		camera.BackgroundColor = new Color( 0.09f, 0.1f, 0.115f );

		return camera;
	}

	// Tag filter, not disabling renderers: a failed render can't leave the scene off.
	readonly struct Isolation : IDisposable
	{
		readonly List<GameObject> tagged;
		readonly List<(MeshComponent Mesh, bool Enabled)> hidden;

		public Isolation( ArchTool tool, ArchTarget target, CameraComponent camera, bool isolate )
		{
			tagged = new List<GameObject>();
			hidden = new List<(MeshComponent Mesh, bool Enabled)>();

			if ( !isolate )
			{
				return;
			}

			foreach ( var node in target.RenderObjects() )
			{
				node.Tags.Add( IsolationTag );
				tagged.Add( node );
			}

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

			camera.RenderTags.Add( IsolationTag );

			var allowed = target.Meshes().ToHashSet();

			foreach ( var root in ArchScene.FindRoots( tool.Scene ) )
			{
				foreach ( var node in ArchScene.Descendants( root ) )
				{
					foreach ( var mesh in node.Components.GetAll<MeshComponent>( FindMode.EverythingInSelf ) )
					{
						if ( allowed.Contains( mesh ) || !mesh.Enabled )
						{
							continue;
						}

						hidden.Add( (mesh, mesh.Enabled) );
						mesh.Enabled = false;
					}
				}
			}
		}

		public void Dispose()
		{
			foreach ( var (mesh, enabled) in hidden.Where( entry => entry.Mesh.IsValid() ) )
			{
				mesh.Enabled = enabled;
			}

			foreach ( var node in tagged.Where( node => node.IsValid() ) )
			{
				node.Tags.Remove( IsolationTag );
			}
		}
	}
}