UI/Preview3D.cs

A helper that renders a small preview scene inside a menu ScenePanel, with its own camera and lights. Shows a tinted model, a weapon with body groups, or a dressed character, and rotates or frames the subject each frame. Used by the vehicle dealer, inventory and ID photo UI.

using Sandbox.UI;

namespace HorizonRP;

/// <summary>
/// Un aperçu 3D dans un menu : une petite scène à part, rendue dans un
/// ScenePanel, avec sa caméra, sa lumière et un modèle qui tourne lentement.
/// Sert à la concession (le véhicule, à sa couleur), à l'inventaire (le
/// personnage en entier) et au portefeuille (la photo d'identité).
///
/// La scène est propre au panneau : elle n'a rien à voir avec la ville, n'est
/// pas en réseau, et disparaît avec le menu.
/// </summary>
public sealed class Preview3D
{
	public ScenePanel Panel { get; private set; }

	/// <summary>Degrés par seconde ; 0 pour une photo fixe.</summary>
	public float TurnSpeed { get; set; } = 18f;

	/// <summary>Le menu de l'arme fait tourner l'aperçu à la souris : lacet, tangage, et recul (1 = cadrage d'origine).</summary>
	public float Yaw { get => yaw; set => yaw = value; }
	public float Pitch { get; set; }
	public float Zoom { get; set; } = 1f;

	GameObject cameraObject;
	GameObject subject;
	string shownKey;
	float yaw = 200f;
	Vector3 lookAt;
	float distance = 200f;

	/// <summary>Cadrage « tête seule » : hauteur visée et recul (photo d'identité : le visage).</summary>
	public float HeadHeight { get; set; } = 64f;
	public float HeadDistance { get; set; } = 38f;

	/// <summary>Rotation du personnage en cadrage tête seule : 0 de face, quelques degrés pour un trois-quarts.</summary>
	public float HeadYaw { get; set; }

	/// <summary>
	/// Photo d'identité : seuls le visage et ce qui le touche (peau, cheveux,
	/// pilosité, maquillage, yeux) sont portés ; pas de vêtements dans le cadre.
	/// </summary>
	public bool FaceOnly { get; set; }

	/// <summary>Cadrage corps entier : recul, et rotation de départ (0 = de face ; l'inventaire reste fixe).</summary>
	public float BodyDistance { get; set; } = 150f;
	public float BodyYaw { get; set; } = 200f;

	/// <summary>Hauteur visée en corps entier : plus basse, le personnage monte dans le cadre.</summary>
	public float BodyHeight { get; set; } = 38f;

	static bool IsFacePart( Clothing c )
	{
		if ( c is null ) return false;
		if ( c.HasHumanSkin ) return true;
		var category = c.Category.ToString();
		return category.StartsWith( "Hair" ) || category.StartsWith( "Facial" ) || category.StartsWith( "Makeup" )
			|| category.StartsWith( "Complexion" ) || category.StartsWith( "Eye" );
	}
	float height;
	bool headMode;
	GameObject weaponHolder;
	SkinnedModelRenderer weaponRenderer;
	int recenterFrames;
	Vector3 lastBoundsCenter;
	SkinnedModelRenderer body;

	/// <summary>Branche l'aperçu sur le ScenePanel du menu (appelé à chaque image : sans effet si rien ne change).</summary>
	public void Attach( ScenePanel panel )
	{
		if ( panel is null || Panel == panel )
			return;

		Panel = panel;
		shownKey = null;

		using ( Panel.RenderScene.Push() )
		{
			cameraObject = new GameObject( true, "Caméra de l'aperçu" );
			var camera = cameraObject.Components.Create<CameraComponent>();
			camera.IsMainCamera = true;
			camera.FieldOfView = 30;
			camera.BackgroundColor = Color.Transparent;
			camera.ZNear = 1;
			camera.ZFar = 4000;

			var sun = new GameObject( true, "Lumière de l'aperçu" );
			sun.WorldRotation = Rotation.From( 50, 140, 0 );
			sun.Components.Create<DirectionalLight>();
			sun.Components.Create<AmbientLight>();

			subject = new GameObject( true, "Sujet de l'aperçu" );
		}
	}

	/// <summary>Un modèle (véhicule, objet), teinté. Ne recharge que si le modèle ou la teinte changent.</summary>
	public void ShowModel( string source, Color tint )
	{
		var key = $"modele|{source}|{tint.Hex}";
		if ( Panel is null || key == shownKey )
			return;

		shownKey = key;
		_ = LoadModel( source, tint, key );
	}

	async Task LoadModel( string source, Color tint, string key )
	{
		var model = await ModelSources.Load( source );
		if ( Panel is null || shownKey != key )
			return;

		using ( Panel.RenderScene.Push() )
		{
			Clear();
			var renderer = subject.Components.Create<ModelRenderer>();
			renderer.Model = model;
			renderer.Tint = tint;

			var bounds = model.Bounds;
			lookAt = bounds.Center;
			distance = MathF.Max( 80, bounds.Size.Length * 1.6f );
			height = bounds.Size.z * 0.35f;
		}
	}

	/// <summary>Une arme (son modèle au sol), avec les pièces montées : des groupes de corps (nom, choix).</summary>
	public void ShowWeapon( string source, IReadOnlyList<(string Group, int Choice)> parts )
	{
		var key = $"arme|{source}|{string.Join( ",", parts.Select( x => $"{x.Group}={x.Choice}" ) )}";
		if ( Panel is null || key == shownKey )
			return;

		shownKey = key;
		_ = LoadWeapon( source, parts, key );
	}

	async Task LoadWeapon( string source, IReadOnlyList<(string Group, int Choice)> parts, string key )
	{
		var model = await ModelSources.Load( source );
		if ( Panel is null || shownKey != key )
			return;

		using ( Panel.RenderScene.Push() )
		{
			Clear();
			// L'arme tourne autour de son centre : elle est décalée dans un enfant du sujet.
			var bounds = model.Bounds;
			var holder = new GameObject( subject, true, "Arme de l'aperçu" );
			holder.LocalPosition = -bounds.Center;
			var renderer = holder.Components.Create<SkinnedModelRenderer>();
			renderer.Model = model;
			// Pose de repos : un modèle de vue suivrait sinon son graphe (l'arme en main, devant la caméra).
			renderer.UseAnimGraph = false;
			foreach ( var (group, choice) in parts.Where( x => model.BodyParts.Any( b => b.Name == x.Group ) ) )
				renderer.SetBodyGroup( group, choice );

			// Les dimensions du modèle comptent aussi ce qui n'est pas dessiné (la place des
			// bras d'un modèle de vue) : on recentre sur le maillage réel dès qu'il est posé.
			weaponHolder = holder;
			weaponRenderer = renderer;
			recenterFrames = 60;

			lookAt = Vector3.Zero;
			distance = MathF.Max( 20, bounds.Size.Length * 1.5f );
			height = 0;
		}
	}

	/// <summary>Un personnage habillé par son apparence (JSON ClothingContainer), corps entier ou visage.</summary>
	public void ShowCharacter( string appearanceJson, string sex, bool headOnly )
	{
		var key = $"perso|{headOnly}|{sex}|{appearanceJson}";
		if ( Panel is null || key == shownKey )
			return;

		shownKey = key;

		using ( Panel.RenderScene.Push() )
		{
			Clear();
			body = subject.Components.Create<SkinnedModelRenderer>();
			headMode = headOnly;
			body.Model = Model.Load( RoleplayPlayer.BodyModelFor( sex ) );

			var dresser = subject.Components.Create<Dresser>();
			dresser.BodyTarget = body;
			dresser.Source = Dresser.ClothingSource.Manual;

			if ( !string.IsNullOrWhiteSpace( appearanceJson ) )
			{
				var container = ClothingContainer.CreateFromJson( appearanceJson );
				CharacterRules.EnsureSkin( container, sex );
				if ( FaceOnly && headOnly )
					container.Clothing.RemoveAll( x => !IsFacePart( x.Clothing ) );
				dresser.UpdateAppearance( container );
				_ = TintedSkin.DressAsync( dresser, container );
			}

			// Corps entier, ou gros plan du visage pour une photo d'identité.
			lookAt = headOnly ? new Vector3( 0, 0, HeadHeight ) : new Vector3( 0, 0, BodyHeight );
			distance = headOnly ? HeadDistance : BodyDistance;
			height = 0;

			// Un personnage regarde vers +X, là où se tient la caméra : à 0°, il est de face.
			yaw = headOnly ? HeadYaw : BodyYaw;
		}
	}

	void Clear()
	{
		foreach ( var component in subject.Components.GetAll().ToList() )
			component.Destroy();
		foreach ( var child in subject.Children.ToList() )
			child.Destroy();
	}

	/// <summary>À appeler dans l'OnUpdate du menu : fait tourner le sujet et place la caméra.</summary>
	public void Tick()
	{
		if ( Panel is null || !subject.IsValid() )
			return;

		using ( Panel.RenderScene.Push() )
		{
			yaw += TurnSpeed * RealTime.Delta;
			subject.WorldRotation = Rotation.From( Pitch, yaw, 0 );

			// Les bornes du maillage suivent le déplacement avec une image de retard : on ne
			// corrige que sur des bornes stables, jusqu'à ce que l'arme soit au centre.
			if ( recenterFrames > 0 && weaponRenderer.IsValid() )
			{
				var bounds = weaponRenderer.Bounds;
				var offset = subject.WorldTransform.PointToLocal( bounds.Center );
				if ( bounds.Center.Distance( lastBoundsCenter ) < 0.01f && offset.Length > 0.2f )
				{
					weaponHolder.LocalPosition -= offset;
					distance = MathF.Max( 12, bounds.Size.Length * 1.4f );
				}
				else if ( offset.Length <= 0.2f )
					recenterFrames = 0;
				lastBoundsCenter = bounds.Center;
				recenterFrames = Math.Max( 0, recenterFrames - 1 );
			}

			// Tête seule : on vise l'os de la tête plutôt qu'une hauteur fixe, sinon un
			// personnage de trois quarts sort du cadre (la tête n'est pas sur l'axe).
			if ( headMode && body.IsValid() && body.TryGetBoneTransform( "head", out var head ) )
				lookAt = head.Position + Vector3.Up * (HeadHeight - 64f);

			var position = lookAt + Vector3.Forward * distance * Zoom + Vector3.Up * height;
			cameraObject.WorldPosition = position;
			cameraObject.WorldRotation = Rotation.LookAt( lookAt - position, Vector3.Up );
		}
	}
}