Editor/Designers/ArchDesignerStage.cs

Editor UI component that renders a preview staging scene for architectural plans. It creates an editor Scene with camera and lighting, composes a temporary ArchPlan via a provided delegate, generates preview geometry, manages camera orbit/zoom controls, and reports simple mesh summaries.

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

namespace Sunless.Architecture;

// The preview plan is throwaway - it never becomes the document.
public readonly record struct ArchStaged( ArchPlan Plan, Vector3 Focus, float Distance );

// Renders through the PRODUCTION generators, so what is previewed is what gets built.
public sealed class ArchDesignerStage : SceneRenderingWidget
{
	readonly ArchKit kit;
	readonly Func<ArchStaged> compose;
	readonly Scene previewScene;
	Vector2 lastMouse;
	float yaw = 35f;
	float pitch = 12f;
	float distance = 360f;
	float fitted = 360f;
	Vector3 focus;
	bool dirty = true;
	bool orbiting;
	bool framed;

	public ArchDesignerStage( Widget parent, ArchKit kit, Func<ArchStaged> compose ) : base( parent )
	{
		this.kit = kit;
		this.compose = compose;
		previewScene = Scene.CreateEditorScene();
		Scene = previewScene;
		MinimumSize = 420;

		using ( previewScene.Push() )
		{
			var camera = new GameObject( true, "camera" );
			camera.GetOrAddComponent<CameraComponent>().BackgroundColor = new Color( 0.075f, 0.08f, 0.095f );

			var sun = new GameObject( true, "sun" );
			var light = sun.GetOrAddComponent<DirectionalLight>();
			light.WorldRotation = Rotation.From( 42f, -145f, 0f );
			light.LightColor = Color.White * 3.2f;
			light.SkyColor = new Color( 0.38f, 0.43f, 0.52f ) * 2.2f;
			light.Shadows = false;
		}
	}

	public void Restage()
	{
		dirty = true;
	}

	// As numbers: 'too thin' is a measurement, and a screenshot of it is not.
	public string Describe()
	{
		var lines = new List<string>();

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

			var bounds = node.GetBounds();
			var roles = renderer.Mesh.FaceHandles
				.Select( handle => renderer.Mesh.GetFaceMaterial( handle )?.ResourceName ?? "none" )
				.Distinct()
				.OrderBy( role => role );

			lines.Add( $"{node.Name}: {renderer.Mesh.FaceHandles.Count()} faces, "
				+ $"size {bounds.Size.x:0.##} x {bounds.Size.y:0.##} x {bounds.Size.z:0.##}, "
				+ $"z {bounds.Mins.z:0.##} to {bounds.Maxs.z:0.##}, [{string.Join( " ", roles )}]" );
		}

		return lines.Count == 0 ? "nothing generated" : string.Join( "\n", lines );
	}

	public void Front()
	{
		yaw = 90f;
		pitch = 0f;
		Refit();
	}

	public void Inside()
	{
		yaw = -90f;
		pitch = 0f;
		Refit();
	}

	public void Perspective()
	{
		yaw = 35f;
		pitch = 12f;
		Refit();
	}

	void Refit()
	{
		distance = fitted;
		framed = false;
	}

	[EditorEvent.Frame]
	public void OnFrame()
	{
		if ( !Visible || Width < 1f || Height < 1f )
		{
			Scene = null;
			return;
		}

		Scene = previewScene;

		if ( dirty )
		{
			Rebuild();
		}

		using ( previewScene.Push() )
		{
			previewScene.EditorTick( RealTime.Now, RealTime.Delta );

			var look = new Angles( pitch, yaw, 0f );
			previewScene.Camera.WorldPosition = focus - look.Forward * distance;
			previewScene.Camera.WorldRotation = look.ToRotation();
			previewScene.Camera.FieldOfView = 38f;
			previewScene.Camera.ZNear = 1f;
			previewScene.Camera.ZFar = 5000f;
		}
	}

	void Rebuild()
	{
		dirty = false;

		var staged = compose();

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

		using ( previewScene.Push() )
		{
			ArchScene.Generate( previewScene, staged.Plan, kit );
		}

		focus = staged.Focus;
		fitted = staged.Distance;

		// Restage runs every keystroke; refitting each time would snatch a user's zoom back.
		if ( !framed )
		{
			distance = fitted;
		}
	}

	// Anchor on the PRESS: tracking moves alone turns the first drag into a jump.
	protected override void OnMousePress( MouseEvent e )
	{
		base.OnMousePress( e );

		lastMouse = e.LocalPosition;
		orbiting = (e.ButtonState & MouseButtons.Left) != 0;
	}

	protected override void OnMouseReleased( MouseEvent e )
	{
		base.OnMouseReleased( e );

		orbiting = false;
	}

	protected override void OnMouseMove( MouseEvent e )
	{
		base.OnMouseMove( e );

		var delta = e.LocalPosition - lastMouse;
		lastMouse = e.LocalPosition;

		if ( !orbiting || (e.ButtonState & MouseButtons.Left) == 0 )
		{
			orbiting = false;
			return;
		}

		yaw -= delta.x * 0.35f;
		pitch = Math.Clamp( pitch + delta.y * 0.25f, -75f, 75f );
	}

	protected override void OnMouseWheel( WheelEvent e )
	{
		distance = Math.Clamp( distance * (e.Delta > 0f ? 0.9f : 1.1f), 80f, 3000f );
		framed = true;
	}

	public override void OnDestroyed()
	{
		base.OnDestroyed();
		previewScene?.Destroy();
		Scene = null;
	}
}