editor/AdvancedMappingTools/Editing/PropWorkshopModel.cs

Editor tool that turns a selection of meshes or a single model into a Prop. Merges MeshComponents into one PolygonMesh, writes a .vmdl through EditorUtility.CreateModelFromPolygonMeshes, rewrites the physics block in the model text for the chosen collision mode, then spawns a GameObject with a Prop component, applies rigidbody settings and hides the sources.

using System.IO;

namespace AdvancedMappingTools;

public sealed partial class PropWorkshopSettings
{
	[Property, Group( "Prop" ), Title( "Replace Originals" ), WideMode, Description( "Turn Into Prop hides the selected objects and puts the prop in their place (undo brings them back). Off: the prop appears beside them, moved by the placement offset." )]
	public bool ReplaceOriginals { get; set; } = true;
}

public static partial class PropWorkshop
{
	/// <summary>
	/// Folder of the models Turn Into Prop writes, inside the project.
	/// </summary>
	public static string ModelFolder => Path.Combine( Project.Current.GetRootPath(), "Assets", "props", "generated" );

	/// <summary>
	/// Turns the selected meshes into a real prop: one model file (.vmdl) with collision, and a
	/// GameObject with a <see cref="Prop"/> component using it, where the selection was. A single
	/// model alone needs no new file. Returns the prop and the model file (null when none was written).
	/// </summary>
	public static (GameObject Prop, string ModelPath) MakeProp( Scene scene, IEnumerable<GameObject> selection, PropWorkshopSettings s )
	{
		if ( s is null || !float.IsFinite( s.Scale ) || s.Scale < 0.01f || s.Scale > 100.0f )
			throw new InvalidOperationException( "Scale must be between 0.01 and 100." );
		if ( s.Collision == PropCollision.ExactStatic && s.Motion != PropMotion.Static )
			throw new InvalidOperationException( "Exact mesh collision is for Static props. Choose Convex Parts or Box for physics." );
		if ( s.Motion != PropMotion.Static && s.Collision == PropCollision.None )
			throw new InvalidOperationException( "A physics prop needs Box or Convex Parts collision." );
		var sources = selection.Where( x => x.IsValid() ).Distinct().ToList();
		if ( sources.Any( x => x.Scene != scene ) )
			throw new InvalidOperationException( "The selected objects must belong to the target scene." );
		var parts = Geometry( sources );
		var bounds = Bounds( parts );
		var meshes = parts.OfType<MeshComponent>().ToList();
		var models = parts.OfType<ModelRenderer>().Where( x => x.GetType() == typeof( ModelRenderer ) ).ToList();
		var pivot = (s.Pivot switch
		{
			PropPivot.BoundsCenter => bounds.Center,
			PropPivot.FirstObject => sources[0].WorldPosition,
			_ => bounds.Center.WithZ( bounds.Mins.z )
		}) + s.PivotOffset;
		var origin = new Transform( pivot, Rotation.Identity );

		Model model;
		string path = null;
		if ( meshes.Count == 0 )
		{
			if ( models.Count != 1 )
				throw new InvalidOperationException( "Select built meshes to merge, or a single model." );
			model = models[0].Model;
		}
		else
		{
			// Every mesh in the space of the pivot, scale baked in, materials replaced as set.
			var polygons = new List<PolygonMesh>();
			foreach ( var source in meshes )
			{
				var mesh = new PolygonMesh();
				mesh.MergeMesh( source.Mesh, origin.ToLocal( source.WorldTransform ), out _, out _, out _ );
				foreach ( var face in mesh.FaceHandles )
					mesh.SetFaceMaterial( face, Pick( mesh.GetFaceMaterial( face ), s ) );
				if ( MathF.Abs( s.Scale - 1.0f ) > 0.0001f )
					mesh.Scale( new Vector3( s.Scale ) );
				polygons.Add( mesh );
			}

			path = FreePath( s.Name );
			var asset = EditorUtility.CreateModelFromPolygonMeshes( [.. polygons], path ) ?? throw new InvalidOperationException( "The engine could not write the model file." );

			// The engine writes collision from the render meshes. A moving prop needs a convex hull,
			// a prop without collision none.
			var text = File.ReadAllText( path );
			var changed = s.Collision switch
			{
				PropCollision.None => WithoutPhysics( text ),
				PropCollision.ExactStatic => text,
				_ => text.Replace( "\"PhysicsMeshFromRender\"", "\"PhysicsHullFromRender\"" )
			};
			if ( changed != text )
			{
				File.WriteAllText( path, changed );
				asset = AssetSystem.FindByPath( path ) ?? asset;
				if ( !asset.Compile( true ) )
					throw new InvalidOperationException( "The model file was written but could not be compiled: " + path );
			}

			model = Model.Load( asset.RelativePath );
			if ( model is null || model.IsError )
				throw new InvalidOperationException( "The model could not be loaded: " + asset.RelativePath );
		}

		GameObject root = null;
		try
		{
			using ( scene.Push() )
			{
				root = new GameObject( false, string.IsNullOrWhiteSpace( s.Name ) ? "My Prop" : s.Name.Trim() );
				root.WorldPosition = pivot + (s.ReplaceOriginals ? Vector3.Zero : s.PlacementOffset);
				if ( meshes.Count == 0 )
				{
					// A single model keeps its own rotation and scale.
					root.WorldPosition = models[0].WorldPosition + (s.ReplaceOriginals ? Vector3.Zero : s.PlacementOffset);
					root.WorldRotation = models[0].WorldRotation;
					root.WorldScale = models[0].WorldScale * s.Scale;
				}

				var prop = root.Components.Create<Prop>();
				prop.Model = model;
				prop.IsStatic = s.Motion == PropMotion.Static;
				prop.StartAsleep = s.StartAsleep;
				prop.Tint = s.Tint;
				root.Enabled = true;

				foreach ( var collider in root.Components.GetAll<Collider>( FindMode.EverythingInSelfAndDescendants ) )
				{
					collider.Friction = s.Friction;
					collider.Elasticity = s.Elasticity;
				}

				if ( root.Components.Get<Rigidbody>() is { } body )
				{
					body.MassOverride = Math.Clamp( s.Mass, 0.1f, 100000.0f );
					body.Gravity = s.Gravity;
					body.GravityScale = s.GravityScale;
					body.LinearDamping = s.LinearDamping;
					body.AngularDamping = s.AngularDamping;
					body.StartAsleep = s.StartAsleep;
					body.EnhancedCcd = s.EnhancedCcd;
					body.MotionEnabled = s.Motion == PropMotion.Dynamic;
					body.Locking = new PhysicsLock { X = s.LockX, Y = s.LockY, Z = s.LockZ, Pitch = s.LockPitch, Yaw = s.LockYaw, Roll = s.LockRoll };
				}

				if ( s.ReplaceOriginals )
					foreach ( var source in sources )
						source.Enabled = false;

				return (root, path);
			}
		}
		catch
		{
			root?.Destroy();
			scene.ProcessDeletes();
			throw;
		}
	}

	/// <summary>A model file name from the prop name that is not taken yet.</summary>
	static string FreePath( string name )
	{
		var slug = new string( (name ?? "").ToLowerInvariant().Select( c => char.IsAsciiLetterOrDigit( c ) ? c : '_' ).ToArray() ).Trim( '_' );
		if ( slug.Length == 0 ) slug = "prop";
		if ( slug.Length > 80 ) slug = slug[..80];
		Directory.CreateDirectory( ModelFolder );
		var path = Path.Combine( ModelFolder, slug + ".vmdl" );
		for ( var n = 2; File.Exists( path ); n++ )
			path = Path.Combine( ModelFolder, $"{slug}_{n}.vmdl" );
		return path;
	}

	/// <summary>The model document without its list of physics shapes.</summary>
	static string WithoutPhysics( string text )
	{
		var start = text.IndexOf( "_class = \"PhysicsShapeList\"", StringComparison.Ordinal );
		if ( start < 0 ) return text;
		var open = text.LastIndexOf( '{', start );
		var depth = 0;
		for ( var i = open; i < text.Length; i++ )
		{
			if ( text[i] == '{' ) depth++;
			else if ( text[i] == '}' && --depth == 0 )
			{
				var end = i + 1;
				while ( end < text.Length && (text[end] == ',' || text[end] == '\r' || text[end] == '\n' || text[end] == '\t' || text[end] == ' ') ) end++;
				return text[..open] + text[end..];
			}
		}

		return text;
	}
}