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;
}
}