editor/AdvancedMappingTools/Editing/PropWorkshop.cs

Editor tool that builds a prop from selected meshes and models. PropWorkshopSettings holds the pivot, scale, material, collision and physics options; PropWorkshop copies the selected geometry into a new GameObject tree with MeshComponents, ModelRenderers, colliders and an optional Rigidbody, previews collision and mass, applies material/physics templates, and saves the result as a prefab under Assets/props/generated. PropWorkshopEditor adds viewport picking (Shift/Ctrl/Alt) and a sidebar sheet with the action buttons and the settings PropertySheet.

File Access
using System.IO;

namespace AdvancedMappingTools;
public enum PropPivot
{
	[Title( "Bottom Center" )]
	BottomCenter,
	[Title( "Bounds Center" )]
	BoundsCenter,
	[Title( "First Object" )]
	FirstObject
}

public enum PropMotion
{
	Static,
	Dynamic,
	Frozen
}

public enum PropCollision
{
	None,
	Box,
	[Title( "Convex Parts" )]
	ConvexParts,
	[Title( "Exact Static" )]
	ExactStatic
}

public sealed partial class PropWorkshopSettings
{
	[Property, Group( "Prop" ), WideMode]
	public string Name { get; set; } = "My Prop";

	[Property, Group( "Prop" ), WideMode]
	public PropPivot Pivot { get; set; } = PropPivot.BottomCenter;

	[Property, Group( "Prop" ), WideMode, Description( "World-space offset from the selected pivot." )]
	public Vector3 PivotOffset { get; set; }

	[Property, Group( "Prop" ), WideMode, Description( "Moves the copy away from the originals." )]
	public Vector3 PlacementOffset { get; set; } = new( 0, 128, 0 );

	[Property, Group( "Prop" ), Range( 0.01f, 100, slider: false ), WideMode, Description( "Uniform scale multiplier around the new pivot." )]
	public float Scale { get; set; } = 1.0f;

	[Property, Group( "Appearance" ), WideMode, Description( "Empty keeps the original materials. Otherwise replaces all materials, or only Replace Material when specified." )]
	public Material Material { get; set; }

	[Property, Group( "Appearance" ), Title( "Replace Material" ), WideMode, Description( "Optional filter: only replace faces or model slots using this material. Material above is the replacement." )]
	public Material ReplaceMaterial { get; set; }

	[Property, Group( "Appearance" ), WideMode, Description( "Prop colour. Preserve Part Colours chooses multiplication or replacement." )]
	public Color Tint { get; set; } = Color.White;

	[Property, Group( "Appearance" ), Title( "Preserve Part Colours" ), WideMode, Description( "On multiplies the original colours by Tint. Off replaces them with Tint." )]
	public bool PreserveColors { get; set; } = true;

	[Property, Group( "Appearance" ), WideMode, Description( "Shadow rendering mode for the copied parts." )]
	public ModelRenderer.ShadowRenderType Shadows { get; set; } = ModelRenderer.ShadowRenderType.On;

	[Property, Group( "Collision" ), WideMode, Description( "Box encloses the whole prop. Convex Parts uses a hull per mesh and a box per model. Exact Static uses mesh triangles and a model asset's own collision; requires Static motion." )]
	public PropCollision Collision { get; set; } = PropCollision.ConvexParts;

	[Property, Group( "Collision" ), Range( 0, 2 ), WideMode]
	public float Friction { get; set; } = 0.5f;

	[Property, Group( "Collision" ), Range( 0, 1 ), WideMode]
	public float Elasticity { get; set; } = 0.0f;

	[Property, Group( "Physics" ), WideMode, Description( "Static stays fixed. Dynamic simulates physics. Frozen has a rigidbody with motion initially disabled." )]
	public PropMotion Motion { get; set; } = PropMotion.Static;

	[Property, Group( "Physics" ), Title( "Mass (kg)" ), Range( 0.1f, 100000, slider: false ), WideMode]
	public float Mass { get; set; } = 10.0f;

	[Property, Group( "Physics" ), WideMode]
	public bool Gravity { get; set; } = true;

	[Property, Group( "Physics" ), Range( 0, 10, slider: false ), WideMode]
	public float GravityScale { get; set; } = 1.0f;

	[Property, Group( "Physics" ), Range( 0, 100, slider: false ), WideMode]
	public float LinearDamping { get; set; } = 0.1f;

	[Property, Group( "Physics" ), Range( 0, 100, slider: false ), WideMode]
	public float AngularDamping { get; set; } = 0.1f;

	[Property, Group( "Physics" ), WideMode]
	public bool StartAsleep { get; set; } = false;

	[Property, Group( "Physics" ), Title( "Continuous Collision" ), WideMode]
	public bool EnhancedCcd { get; set; } = true;

	[Property, Group( "Movement Locks" ), WideMode]
	public bool LockX { get; set; }

	[Property, Group( "Movement Locks" ), WideMode]
	public bool LockY { get; set; }

	[Property, Group( "Movement Locks" ), WideMode]
	public bool LockZ { get; set; }

	[Property, Group( "Movement Locks" ), WideMode]
	public bool LockPitch { get; set; }

	[Property, Group( "Movement Locks" ), WideMode]
	public bool LockYaw { get; set; }

	[Property, Group( "Movement Locks" ), WideMode]
	public bool LockRoll { get; set; }
}

/// <summary>Copies only geometry and appearance. Source scripts and physics are never inherited.</summary>
public static partial class PropWorkshop
{
	public static List<Component> Geometry( IEnumerable<GameObject> selection ) => selection.Where( x => x.IsValid() ).SelectMany( x => x.Components.GetAll<Component>( FindMode.EverythingInSelfAndDescendants ) ).Where( c => c.IsValid() && c.Active && (c is MeshComponent m && m.Mesh is not null && m.Mesh.FaceHandles.Any() && !m.HideInGame || c.GetType() == typeof(ModelRenderer) && ((ModelRenderer)c).Model is not null) ).Distinct().ToList();
	public static BBox Bounds( IReadOnlyList<Component> parts )
	{
		if ( parts.Count == 0 )
			throw new InvalidOperationException( "Select at least one visible mesh or static model." );
		BBox PartBounds( Component c ) => c is MeshComponent m ? m.Mesh.CalculateBounds( m.WorldTransform ) : ((ModelRenderer)c).Bounds;
		var bounds = PartBounds( parts[0] );
		foreach ( var part in parts.Skip( 1 ) )
		{
			var b = PartBounds( part );
			bounds = new BBox( Vector3.Min( bounds.Mins, b.Mins ), Vector3.Max( bounds.Maxs, b.Maxs ) );
		}

		return bounds;
	}

	static Material Pick( Material original, PropWorkshopSettings s ) => s.Material is not null && (s.ReplaceMaterial is null || original?.ResourcePath == s.ReplaceMaterial.ResourcePath) ? s.Material : original;
	public static GameObject Create( 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 pivot = (s.Pivot switch
		{
			PropPivot.BoundsCenter => bounds.Center,
			PropPivot.FirstObject => sources[0].WorldPosition,
			_ => bounds.Center.WithZ( bounds.Mins.z )}) + s.PivotOffset;
		GameObject root = null;
		try
		{
			using ( scene.Push() )
			{
				root = new GameObject( false, string.IsNullOrWhiteSpace( s.Name ) ? "My Prop" : s.Name.Trim() );
				root.WorldPosition = pivot + s.PlacementOffset;
				root.WorldScale = new Vector3( s.Scale );
				var origin = new Transform( pivot, Rotation.Identity );
				foreach ( var source in parts )
				{
					var child = new GameObject( false, source.GameObject.Name );
					child.Parent = root;
					child.LocalTransform = origin.ToLocal( source.WorldTransform );
					if ( source is MeshComponent original )
					{
						var mesh = new PolygonMesh();
						mesh.MergeMesh( original.Mesh, new Transform( Vector3.Zero, Rotation.Identity ), out _, out _, out _ );
						foreach ( var face in mesh.FaceHandles )
							mesh.SetFaceMaterial( face, Pick( mesh.GetFaceMaterial( face ), s ) );
						var copy = child.Components.Create<MeshComponent>();
						copy.Mesh = mesh;
						copy.Color = s.PreserveColors ? original.Color * s.Tint : s.Tint;
						copy.SmoothingAngle = original.SmoothingAngle;
						copy.RenderType = s.Shadows;
						copy.Collision = s.Collision switch
						{
							PropCollision.ConvexParts => MeshComponent.CollisionType.Hull,
							PropCollision.ExactStatic => MeshComponent.CollisionType.Mesh,
							_ => MeshComponent.CollisionType.None
						};
						copy.Static = s.Motion == PropMotion.Static;
						copy.Friction = s.Friction;
						copy.Elasticity = s.Elasticity;
					}
					else if ( source is ModelRenderer originalModel )
					{
						var copy = child.Components.Create<ModelRenderer>();
						copy.Model = originalModel.Model;
						copy.Tint = s.PreserveColors ? originalModel.Tint * s.Tint : s.Tint;
						copy.CreateAttachments = false;
						copy.LodOverride = originalModel.LodOverride;
						copy.BodyGroups = originalModel.BodyGroups;
						copy.MaterialGroup = originalModel.MaterialGroup;
						copy.RenderType = s.Shadows;
						copy.MaterialOverride = Pick( originalModel.MaterialOverride, s );
						var baseMaterials = string.IsNullOrEmpty( originalModel.MaterialGroup ) ? originalModel.Model.Materials.ToArray() : originalModel.Model.GetMaterials( originalModel.MaterialGroup ).ToArray();
						for ( var i = 0; i < originalModel.Model.Materials.Length; i++ )
						{
							var effective = originalModel.GetMaterial( i ) ?? baseMaterials.ElementAtOrDefault( i ) ?? originalModel.Model.Materials[i];
							if ( s.Material is not null && (s.ReplaceMaterial is null || effective?.ResourcePath == s.ReplaceMaterial.ResourcePath) )
								copy.SetMaterial( s.Material, i );
							else if ( originalModel.Materials.HasOverride( i ) )
								copy.SetMaterial( originalModel.Materials.GetOverride( i ), i );
						}

						if ( s.Collision == PropCollision.ExactStatic )
						{
							var collider = child.Components.Create<ModelCollider>();
							collider.Model = originalModel.Model;
							collider.Static = true;
							collider.Friction = s.Friction;
							collider.Elasticity = s.Elasticity;
						}
						else if ( s.Collision == PropCollision.ConvexParts )
							AddBox( child, originalModel.LocalBounds, s );
					}

					child.Enabled = true;
				}

				if ( s.Collision == PropCollision.Box )
					AddBox( root, new BBox( bounds.Mins - pivot, bounds.Maxs - pivot ), s );
				if ( s.Motion != PropMotion.Static )
				{
					var body = root.Components.Create<Rigidbody>();
					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
					};
				}

				root.Enabled = true;
				return root;
			}
		}
		catch
		{
			root?.Destroy();
			scene.ProcessDeletes();
			throw;
		}
	}

	static void AddBox( GameObject go, BBox bounds, PropWorkshopSettings s )
	{
		var box = go.Components.Create<BoxCollider>();
		box.Center = bounds.Center;
		box.Scale = Vector3.Max( bounds.Size, new Vector3( 0.01f ) );
		box.Static = s.Motion == PropMotion.Static;
		box.Friction = s.Friction;
		box.Elasticity = s.Elasticity;
	}

	public static string SavePrefab( GameObject prop )
	{
		if ( !prop.IsValid() )
			throw new InvalidOperationException( "Create a prop copy first." );
		var slug = new string ( prop.Name.ToLowerInvariant().Select( c => char.IsAsciiLetterOrDigit( c ) ? c : '_' ).ToArray() ).Trim( '_' );
		if ( slug.Length == 0 )
			slug = "prop";
		if ( slug.Length > 80 )
			slug = slug[..80];
		var directory = Path.Combine( Project.Current.GetRootPath(), "Assets", "props", "generated" );
		Directory.CreateDirectory( directory );
		var path = Path.Combine( directory, slug + ".prefab" );
		for ( var n = 2; File.Exists( path ); n++ )
			path = Path.Combine( directory, $"{slug}_{n}.prefab" );
		var scene = new Scene();
		try
		{
			using ( scene.Push() )
			{
				var clone = prop.Clone( new Transform( Vector3.Zero, Rotation.Identity, prop.WorldScale ), null, false );
				clone.Enabled = true;
				if ( !EditorUtility.Prefabs.ConvertGameObjectToPrefab( clone, path ).IsValid() )
					throw new InvalidOperationException( "The engine could not save the prefab." );
			}
		}
		finally
		{
			scene.Destroy();
		}

		return path;
	}
}

[ToolCategory( "Props" ), Title( "Prop Workshop" ), Icon( "inventory_2" )]
public sealed partial class PropWorkshopEditor( PrimitiveTool tool ) : BuilderEditor( tool )
{
	protected override int MinPoints => 0;
	public override bool CanBuild => false;

	public override void OnUpdate( SceneTrace trace )
	{
		// The mesh tool switches the viewport's own object selection off. This tool is about picking
		// objects, so it turns it back on: hovered objects light up and a click selects them, with
		// Shift and Ctrl as everywhere in the editor. The mesh tool turns it off again every frame.
		if ( Tool?.MeshTool is { } meshTool )
			meshTool.AllowGameObjectSelection = true;
		PickInViewport( trace );
		DrawWorkshopPreview();
	}

	/// <summary>
	/// The mapping tool does not select objects itself, so this tool does: click picks an object,
	/// Shift adds or removes it, Ctrl removes it, Alt takes the whole object it belongs to. Clicking
	/// empty space clears the selection. The sheet captures the new selection by itself.
	/// </summary>
	void PickInViewport( SceneTrace trace )
	{
		var selection = SceneEditorSession.Active?.Selection;
		if ( selection is null )
			return;

		foreach ( var go in selection.OfType<GameObject>().Where( x => x.IsValid() ) )
		{
			var bounds = go.GetBounds();
			using ( Gizmo.Scope( "prop_workshop_pick" ) )
			{
				Gizmo.Draw.Color = Color.Cyan.WithAlpha( 0.6f );
				Gizmo.Draw.LineBBox( bounds );
			}
		}

		if ( !Gizmo.WasLeftMousePressed || Gizmo.HasHovered )
			return;

		var hit = trace.Run().GameObject;
		if ( hit.IsValid() && Gizmo.IsAltPressed )
			while ( hit.Parent.IsValid() && hit.Parent is not Scene )
				hit = hit.Parent;

		if ( !hit.IsValid() )
		{
			if ( !Gizmo.IsShiftPressed && !Gizmo.IsCtrlPressed )
				selection.Clear();
			return;
		}

		if ( Gizmo.IsCtrlPressed || Gizmo.IsShiftPressed && selection.Contains( hit ) )
			selection.Remove( hit );
		else if ( Gizmo.IsShiftPressed )
			selection.Add( hit );
		else
			selection.Set( hit );
	}

	protected override void BuildMesh( MeshKit kit )
	{
	}

	public override PolygonMesh Build() => null;
	public PropWorkshopSettings Settings { get; } = new();

	public override Widget CreateWidget() => new WorkshopSheet( this );
	sealed class WorkshopSheet : ToolSidebarWidget
	{
		readonly PropWorkshopSettings _settings;
		readonly PropWorkshopEditor _owner;
		List<GameObject> _sources = [];
		GameObject _created;
		readonly Label _status;
		readonly SerializedObject _serialized;
		public WorkshopSheet( PropWorkshopEditor owner )
		{
			_owner = owner;
			_settings = owner.Settings;
			var actions = AddGroup( "Prop From Selection" );
			actions.Add( new Label( "1. Click the objects in the viewport (Shift adds, Ctrl removes, Alt takes the whole object). 2. Set name, collision and physics below. 3. Turn Into Prop: one model file with collision and a real Prop in their place." ) { WordWrap = true } );
			actions.Add( new ListButton( "Turn Into Prop", "auto_awesome" ) { Clicked = MakeProp } );
			actions.Add( new ListButton( "Capture Selection", "select_all" ) { Clicked = Capture } );
			actions.Add( new ListButton( "Apply Physics Template", "science" ) { Clicked = () =>
			{
				PropWorkshop.ApplyPhysicsTemplate( _settings );
			} } );
			actions.Add( new ListButton( "Save Material Template", "save" ) { Clicked = () => MaterialTemplate( true ) } );
			actions.Add( new ListButton( "Load Material Template", "palette" ) { Clicked = () => MaterialTemplate( false ) } );
			actions.Add( new ListButton( "Update Collision / Mass Preview", "visibility" ) { Clicked = Preview } );
			actions.Add( new ListButton( "Create Prop Copy", "add_box" ) { Clicked = Create } );
			actions.Add( new ListButton( "Select Last Prop", "my_location" ) { Clicked = SelectLast } );
			actions.Add( new ListButton( "Save Last Prop As Prefab", "save" ) { Clicked = Save } );
			_status = actions.Add( new Label( "No source captured." ) { WordWrap = true } );
			_serialized = _settings.GetSerialized();
			PropertySheet.Fill( this, _serialized, "Prop Settings" );
			Capture();
		}

		void Capture()
		{
			_sources = SceneEditorSession.Active?.Selection.OfType<GameObject>().Where( x => x.IsValid() ).ToList() ?? [];
			_captured = Signature();
			var parts = PropWorkshop.Geometry( _sources ).Count;
			_status.Text = parts == 0
				? "Nothing captured yet. Click built meshes or models in the viewport, or select them in the hierarchy."
				: $"Captured {parts} visible mesh/model parts from {_sources.Count} object(s). Originals will be kept.";
		}

		string _captured = "";

		string Signature() => string.Join( ",", SceneEditorSession.Active?.Selection.OfType<GameObject>().Where( x => x.IsValid() ).Select( x => x.Id ) ?? [] );

		// No selection event exists, so compare once a frame and capture again when it changed.
		[EditorEvent.Frame]
		void FollowSelection()
		{
			if ( !IsValid || Signature() == _captured )
				return;
			Capture();
		}

		void Preview()
		{
			try
			{
				_owner.preview = PropWorkshop.Preview( _sources, _settings );
				_status.Text = _settings.Collision == PropCollision.ConvexParts ? "Preview updated. Yellow shows each convex input envelope; center marker is the engine mass center." : "Collision / center preview updated.";
			}
			catch ( Exception ex )
			{
				_status.Text = ex.Message;
			}
		}

		void MaterialTemplate( bool save )
		{
			try
			{
				if ( save )
					PropWorkshop.SaveMaterialTemplate( _settings );
				else
					PropWorkshop.LoadMaterialTemplate( _settings );
				_status.Text = save ? "Material template saved." : "Material template loaded. Update preview or create a new copy.";
			}
			catch ( Exception ex )
			{
				_status.Text = ex.Message;
			}
		}

		void Create()
		{
			try
			{
				if ( _sources.Count == 0 || PropWorkshop.Geometry( _sources ).Count == 0 )
				{
					_status.Text = "Nothing to copy: click built meshes or models in the viewport first.";
					return;
				}

				var session = SceneEditorSession.Active ?? throw new InvalidOperationException( "No editor scene." );
				using ( SceneEditorSession.Scope() )
				using ( session.UndoScope( "Create Prop Copy" ).WithGameObjectCreations().Push() )
					_created = PropWorkshop.Create( session.Scene, _sources, _settings );
				_status.Text = $"Created {_created.Name}. Edit its parts, materials and physics in the inspector, or save it as a prefab.";
			}
			catch ( Exception ex )
			{
				_status.Text = ex.Message;
			}
		}

		void MakeProp()
		{
			try
			{
				if ( _sources.Count == 0 || PropWorkshop.Geometry( _sources ).Count == 0 )
				{
					_status.Text = "Nothing selected: click built meshes or models in the viewport first.";
					return;
				}

				var session = SceneEditorSession.Active ?? throw new InvalidOperationException( "No editor scene." );
				string path;
				using ( SceneEditorSession.Scope() )
				using ( session.UndoScope( "Turn Into Prop" ).WithGameObjectCreations().WithGameObjectChanges( _sources, GameObjectUndoFlags.All ).Push() )
					(_created, path) = PropWorkshop.MakeProp( session.Scene, _sources, _settings );
				session.Selection.Clear();
				session.Selection.Add( _created );
				_status.Text = path is null
					? $"{_created.Name} is now a Prop using the selected model."
					: $"{_created.Name} is now a Prop. Model saved: {path}" + (_settings.ReplaceOriginals ? " The originals are hidden; undo brings them back." : "");
			}
			catch ( Exception ex )
			{
				_status.Text = ex.Message;
			}
		}

		void Save()
		{
			try
			{
				_status.Text = "Saved: " + PropWorkshop.SavePrefab( _created );
			}
			catch ( Exception ex )
			{
				_status.Text = ex.Message;
			}
		}

		void SelectLast()
		{
			if ( !_created.IsValid() )
			{
				_status.Text = "Create a prop copy first.";
				return;
			}

			var selection = SceneEditorSession.Active?.Selection;
			if ( selection is null )
				return;
			selection.Clear();
			selection.Add( _created );
		}
	}
}