Editor/Output/ArchShot.Camera.cs

Camera framing helper used by the Editor. Defines ArchShotCamera data and computes camera rotation, framing size and positioned camera (orthographic/perspective) to cover a bounding box with padding, zoom and cutaway options.

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

namespace Sunless.Architecture;

// Where the shot camera stands and what it can see from there. Its own type because the whole of it was solved
// inline in Render, which meant nothing could read it: the one path this tool exists to produce was the one path
// no test could reach, and a Top view that stood the camera UNDER the subject shipped as an empty frame.
public readonly struct ArchShotCamera
{
	public Vector3 Eye { get; init; }
	public Rotation Rotation { get; init; }
	public bool Orthographic { get; init; }
	public float OrthoHeight { get; init; }
	public float FieldOfView { get; init; }
	public float Near { get; init; }
	public float Far { get; init; }

	// Half-extents in camera space: x along the view, y across, z up. Carried so a caller measuring coverage
	// against the subject reads the same numbers the framing was solved from.
	public Vector3 Reach { get; init; }
}

public static partial class ArchShot
{
	public static Rotation Look( ArchAngle angle, float yaw )
	{
		return angle switch
		{
			ArchAngle.Iso => Rotation.From( -35.264f, 45f, 0f ),
			ArchAngle.IsoBack => Rotation.From( -35.264f, 225f, 0f ),
			ArchAngle.IsoLocal => Rotation.From( -30f, yaw + 145f, 0f ),
			ArchAngle.Front => Rotation.From( 0f, 0f, 0f ),
			ArchAngle.Back => Rotation.From( 0f, 180f, 0f ),
			ArchAngle.Left => Rotation.From( 0f, 90f, 0f ),
			ArchAngle.Right => Rotation.From( 0f, -90f, 0f ),
			// Pitch is positive DOWNWARD, so a plan view is the positive one. Signed the other way, Top stood
			// the camera under the subject looking up at an underside the generators do not even emit, and
			// every top-down shot of anything flat came back as an empty frame.
			ArchAngle.Top => Rotation.From( 89.9f, 0f, 0f ),
			ArchAngle.Bottom => Rotation.From( -89.9f, 0f, 0f ),
			ArchAngle.Along => Rotation.From( 0f, yaw, 0f ),
			ArchAngle.AlongBack => Rotation.From( 0f, yaw + 180f, 0f ),
			ArchAngle.Across => Rotation.From( 0f, yaw + 90f, 0f ),
			ArchAngle.AcrossBack => Rotation.From( 0f, yaw - 90f, 0f ),
			_ => Rotation.From( -35.264f, 45f, 0f )
		};
	}

	// Shared with plan mode's live viewport, so framed and rendered agree.
	public static float Framing( BBox bounds, Rotation rotation, float aspect, float padding )
	{
		var reach = Extents( bounds, rotation );
		var pad = 1f + MathF.Max( 0f, padding );
		var wide = aspect > 0.01f ? aspect : 1f;

		return MathF.Max( 8f, 2f * MathF.Max( reach.z, reach.y / wide ) * pad );
	}

	// ONE solve, read by the render and by anything checking it. Ortho keeps the near plane exactly on the near
	// face of the subject, which is what makes Cutaway a section cut rather than a separate mode; a perspective
	// shot backs off far enough to cover the same field the ortho one framed, so switching does not jump.
	public static ArchShotCamera Stand( BBox bounds, Rotation rotation, float aspect, ArchShotOptions options )
	{
		var reach = Extents( bounds, rotation );
		var scale = MathF.Max( 0.01f, options.Zoom );
		var ortho = Framing( bounds, rotation, aspect, options.Padding ) / scale;
		var backoff = options.Orthographic
			? reach.x + ortho
			: ortho * 0.5f / MathF.Tan( 22.5f.DegreeToRadian() ) + reach.x;

		return new ArchShotCamera
		{
			Eye = bounds.Center - rotation.Forward * backoff,
			Rotation = rotation,
			Orthographic = options.Orthographic,
			OrthoHeight = ortho,
			FieldOfView = 45f,
			Near = MathF.Max( 0.5f, backoff - reach.x + MathF.Max( 0f, options.Cutaway ) ),
			Far = backoff + reach.x * 2f + ortho,
			Reach = reach
		};
	}

	// Half-extents in camera space: x along the view, y across, z up.
	static Vector3 Extents( BBox bounds, Rotation rotation )
	{
		var centre = bounds.Center;
		var reach = Vector3.Zero;

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

			reach = Vector3.Max( reach, new Vector3( MathF.Abs( local.x ), MathF.Abs( local.y ), MathF.Abs( local.z ) ) );
		}

		return new Vector3( MathF.Max( 1f, reach.x ), MathF.Max( 1f, reach.y ), MathF.Max( 1f, reach.z ) );
	}
}