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