Code/CharacterKit/CharacterCreatorPreview.cs

A UI ScenePanel that previews a character model for a character creator. It creates a local render Scene and CameraComponent, manages orbit/zoom/pan/pitch interactivity, drives a CharacterVisual instance to update the skinned model renderer, and handles mouse input and lifecycle cleanup.

File Access
using System;
using System.Text.Json;
using System.Threading.Tasks;
using Sandbox.UI;

namespace CharacterEditor;
/// <summary>Menu/metadata preview with only local native renderers, lights and shape volumes. It never spawns a gameplay actor.</summary>
public sealed class CharacterCreatorPreview : ScenePanel
{
	public const float FaceZoom = 1;
	public const float MaxZoom = 2;
	public CharacterAppearanceData Appearance { get; set; }
	public bool Portrait { get; set; }
	public bool Hero { get; set; }
	public float? Zoom { get; set; }
	public float Yaw { get; set; }

	public const float MinPitch = -25, MaxPitch = 35;
	public float Pitch { get; set; }
	public Action<float> PitchChanged { get; set; }
	public Vector3 PanOffset { get; set; }
	public bool Interactive { get; set; }
	public bool ShowClothing { get; set; } = true;
	public Action<float> OrbitChanged { get; set; }
	public Action<float> ZoomChanged { get; set; }
	public Action<Vector3> PanChanged { get; set; }

	MouseButtons? dragButton;
	public string Error { get; private set; }
	public bool Loading => visual?.Loading == true;
	public SkinnedModelRenderer Body => body;

	CameraComponent camera;
	SkinnedModelRenderer body;
	CharacterVisual visual;
	public CharacterCreatorPreview()
	{
		RenderScene = new Scene();
		using var scope = RenderScene.Push();
		camera = new GameObject
		{
			NetworkMode = NetworkMode.Never
		}.Components.Create<CameraComponent>();
		camera.FieldOfView = 34;
		camera.ZNear = 0.25f;
		camera.BackgroundColor = Color.Transparent;
		CharacterStudioLighting.Create();
	}

	public void Orbit(float delta)
	{
		if (float.IsFinite(delta))
		{
			Yaw = (Yaw + delta) % 360;
			OrbitChanged?.Invoke(Yaw);
		}
	}

	public void SetZoom(float value)
	{
		if (float.IsFinite(value))
		{
			Zoom = Math.Clamp(value, 0, MaxZoom);
			Portrait = Zoom >= FaceZoom - 0.001f;
			ZoomChanged?.Invoke(Zoom.Value);
		}
	}

	public void Pan(Vector2 delta)
	{
		if (!float.IsFinite(delta.x) || !float.IsFinite(delta.y) || !camera.IsValid())
			return;
		var distance = Framing().Distance;
		var unitsPerPixel = 2 * distance * MathF.Tan(camera.FieldOfView * MathF.PI / 360) / MathF.Max(1, Box.Rect.Width);
		var offset = PanOffset + (camera.WorldRotation.Left * delta.x + camera.WorldRotation.Up * delta.y) * unitsPerPixel;
		PanOffset = new(Math.Clamp(offset.x, -35, 35), Math.Clamp(offset.y, -35, 35), Math.Clamp(offset.z, -75, 75));
		PanChanged?.Invoke(PanOffset);
	}

	public void SetPitch(float value)
	{
		if (float.IsFinite(value))
		{
			Pitch = Math.Clamp(value, MinPitch, MaxPitch);
			PitchChanged?.Invoke(Pitch);
		}
	}

	public void Recenter()
	{
		Yaw = 0;
		OrbitChanged?.Invoke(0);
		PanOffset = Vector3.Zero;
		PanChanged?.Invoke(PanOffset);
		SetPitch(0);
	}

	public void Drag(Vector2 delta)
	{
		if (!Interactive || !HasMouseCapture)
			return;
		if (dragButton == MouseButtons.Middle)
			Pan(delta);
		else if (dragButton == MouseButtons.Right)
		{
			Orbit(delta.x * 0.35f);
			SetPitch(Pitch - delta.y * 0.25f);
		}
		else if (dragButton == MouseButtons.Left)
			Orbit(delta.x * 0.35f);
	}

	(Vector3 Position, Vector3 Target, float Distance) Framing()
	{
		var aspect = Box.Rect.Width / MathF.Max(1, Box.Rect.Height);
		var zoom = Math.Clamp(Zoom ?? (Portrait ? FaceZoom : 0), 0, MaxZoom);
		var bodyZoom = Math.Clamp(zoom, 0, FaceZoom);
		var detailZoom = Math.Max(0, zoom - FaceZoom);
		var fullDistance = Hero ? MathF.Max(112, 135 * aspect) : MathF.Max(125, 145 * aspect);
		var faceDistance = Hero ? MathF.Max(28, 28 * aspect) : MathF.Max(22, 30 * aspect);
		var distance = detailZoom > 0 ? faceDistance * MathF.Pow(14 / faceDistance, detailZoom) : fullDistance + (faceDistance - fullDistance) * bodyZoom;
		return (new(distance, -distance * 0.15f, 44 + 20 * bodyZoom), new(0, 0, 37 + 25 * bodyZoom + 1.5f * detailZoom), distance);
	}

	protected override void OnMouseDown(MousePanelEvent e)
	{
		if (Interactive && !HasMouseCapture && e.MouseButton is MouseButtons.Left or MouseButtons.Right or MouseButtons.Middle)
		{
			dragButton = e.MouseButton;
			SetMouseCapture(true);
			e.StopPropagation();
			return;
		}

		base.OnMouseDown(e);
	}

	protected override void OnMouseUp(MousePanelEvent e)
	{
		if (HasMouseCapture && e.MouseButton == dragButton)
		{
			dragButton = null;
			SetMouseCapture(false);
			e.StopPropagation();
			return;
		}

		base.OnMouseUp(e);
	}

	public override void OnMouseWheel(Vector2 value)
	{
		if (!Interactive)
		{
			base.OnMouseWheel(value);
			return;
		}

		SetZoom((Zoom ?? (Portrait ? 1 : 0)) - value.y * 0.035f);
	}

	public override void Tick()
	{
		base.Tick();
		if (!camera.IsValid())
			return;
		if (HasMouseCapture)
		{
			if (!Interactive)
			{
				dragButton = null;
				SetMouseCapture(false);
			}
			else
				Drag(Mouse.Delta);
		}

		using var scope = RenderScene.Push();
		// Native camera FOV is horizontal. Preserve room for the whole silhouette when
		// a wide UI panel reduces its vertical view, while keeping tall portraits close.
		var framing = Framing();
		var baseLook = Rotation.LookAt(framing.Target - framing.Position);
		var position = framing.Target + Rotation.FromAxis(baseLook.Left, -Math.Clamp(Pitch, MinPitch, MaxPitch)) * (framing.Position - framing.Target);
		camera.WorldPosition = position + PanOffset;
		camera.WorldRotation = Rotation.LookAt(framing.Target + PanOffset - camera.WorldPosition);
		if (body.IsValid())
			body.LocalRotation = Rotation.FromYaw(float.IsFinite(Yaw) ? Yaw : 0);
		visual ??= new CharacterVisual(new GameObject { NetworkMode = NetworkMode.Never });
		visual.Update(Appearance, ShowClothing);
		body = visual.Body;
		Error = visual.Error;
	}

	public override void Delete(bool immediate = false)
	{
		if (HasMouseCapture)
			SetMouseCapture(false);
		var scene = RenderScene;
		base.Delete(immediate);
		scene?.Destroy();
		visual?.Dispose();
	}
}