diff --git a/Editor/AGXUnityEditor/Menus/TopMenu.cs b/Editor/AGXUnityEditor/Menus/TopMenu.cs index 47a6ed13..7a95d481 100644 --- a/Editor/AGXUnityEditor/Menus/TopMenu.cs +++ b/Editor/AGXUnityEditor/Menus/TopMenu.cs @@ -27,8 +27,14 @@ private static GameObject CreateShape( MenuCommand command ) return null; var parent = command.context as GameObject; + var views = SceneView.sceneViews; if ( parent != null ) go.transform.SetParent( parent.transform, false ); + else if ( SceneView.sceneViews.Count > 0 ) { + var view = SceneView.sceneViews[0] as SceneView; + if ( view != null ) + view.MoveToView( go.transform ); + } AGXUnity.Rendering.ShapeVisual.Create( go.GetComponent() ); @@ -583,6 +589,12 @@ public static void ConvertRenderingMaterials() Windows.ConvertMaterialsWindow.Open(); } + [MenuItem( "AGXUnity/Utils/Convert PhysX components to AGX", priority = 80 )] + public static void ConvertPhysXToAGX() + { + Windows.ConvertPhysXToAGXWindow.Open(); + } + [MenuItem( "AGXUnity/Settings...", priority = 81 )] public static void OpenSettings() { diff --git a/Editor/AGXUnityEditor/Utils/PhysXConversion.cs b/Editor/AGXUnityEditor/Utils/PhysXConversion.cs new file mode 100644 index 00000000..1fe5114e --- /dev/null +++ b/Editor/AGXUnityEditor/Utils/PhysXConversion.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using System.Linq; +using UnityEngine; + +namespace AGXUnityEditor.Utils +{ + /// + /// Discovery and preflight are read-only. Execution creates all replacements before + /// removing sources. Unsupported dependencies block the entire plan. + /// + public static partial class PhysXConversion + { + public sealed class Candidate + { + public Component Source { get; internal set; } + public Collider[] Colliders { get; internal set; } = new Collider[ 0 ]; + public List Errors { get; } = new List(); + public List Warnings { get; } = new List(); + public bool Supported => Errors.Count == 0; + public int ComponentCount => 1 + Colliders.Length; + public string Description => Source == null ? "Missing source" : + Source.GetType().Name + ( Source is Rigidbody ? $" ({Colliders.Length} colliders)" : "" ); + } + + public sealed class Plan + { + public List Candidates { get; } = new List(); + public List Errors { get; } = new List(); + public List Warnings { get; } = new List(); + public int ComponentCount => Candidates.Sum( c => c.ComponentCount ); + public bool CanExecute => Candidates.Count > 0 && Errors.Count == 0; + } + + public sealed class Result + { + public int Converted; + public int Skipped; + public List Errors { get; } = new List(); + public override string ToString() => $"Converted {Converted} components; skipped {Skipped}; errors {Errors.Count}." + + ( Errors.Count == 0 ? "" : "\n" + string.Join( "\n", Errors ) ); + } + + public sealed class PrefabInfo + { + public string Path; + public string Name; + public int SupportedCount; + public int UnsupportedCount; + public string Error; + } + + } +} diff --git a/Editor/AGXUnityEditor/Utils/PhysXConversion.cs.meta b/Editor/AGXUnityEditor/Utils/PhysXConversion.cs.meta new file mode 100644 index 00000000..52963753 --- /dev/null +++ b/Editor/AGXUnityEditor/Utils/PhysXConversion.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1395f886ed844dd7a204bbd922ce7d50 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/AGXUnityEditor/Utils/PhysXConversionDiscovery.cs b/Editor/AGXUnityEditor/Utils/PhysXConversionDiscovery.cs new file mode 100644 index 00000000..bb9d3d7a --- /dev/null +++ b/Editor/AGXUnityEditor/Utils/PhysXConversionDiscovery.cs @@ -0,0 +1,205 @@ +using System.Collections.Generic; +using System.Linq; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; +using Shape = AGXUnity.Collide.Shape; + +namespace AGXUnityEditor.Utils +{ + public static partial class PhysXConversion + { + public static GameObject[] SceneRoots() + { + var roots = new List(); + for ( int i = 0; i < SceneManager.sceneCount; ++i ) { + var scene = SceneManager.GetSceneAt( i ); + if ( scene.isLoaded && !EditorSceneManager.IsPreviewScene( scene ) ) + roots.AddRange( scene.GetRootGameObjects() ); + } + return roots.ToArray(); + } + + public static List Discover( IEnumerable roots ) + { + var objects = roots.Where( r => r != null ).ToArray(); + var colliders = objects.SelectMany( r => r.GetComponentsInChildren( true ) ).Distinct().ToArray(); + var collidersByBody = colliders.ToLookup( ColliderOwner ); + var bodies = objects.SelectMany( r => r.GetComponentsInChildren( true ) ).Distinct().ToArray(); + var joints = objects.SelectMany( r => r.GetComponentsInChildren( true ) ).Distinct().ToArray(); + var jointBodies = new HashSet( joints.SelectMany( j => new[] { j.GetComponent(), j.connectedBody } ) + .Where( b => b != null ) ); + var pagedTerrains = new HashSet( objects.SelectMany( r => r.GetComponentsInChildren( true ) ) + .SelectMany( p => AGXUnity.Utils.TerrainUtils.CollectTerrains( p.Terrain ) ) ); + var candidates = new List(); + foreach ( var body in bodies ) { + var candidate = new Candidate + { + Source = body, + Colliders = collidersByBody[ body ].ToArray() + }; + ValidateBody( body, jointBodies, candidate ); + foreach ( var collider in candidate.Colliders ) + ValidateCollider( collider, candidate, pagedTerrains ); + candidates.Add( candidate ); + } + foreach ( var collider in collidersByBody[ null ] ) { + var candidate = new Candidate { Source = collider }; + ValidateCollider( collider, candidate, pagedTerrains ); + candidates.Add( candidate ); + } + foreach ( var joint in joints ) { + var candidate = new Candidate { Source = joint }; + candidate.Errors.Add( "Joint conversion is not supported. Both connected rigidbodies must remain PhysX." ); + candidates.Add( candidate ); + } + // Propagate blockers across nested body groups before advertising eligibility. + foreach ( var group in candidates.Where( c => c.Source is Rigidbody ).GroupBy( c => BodyRoot( (Rigidbody)c.Source ) ) ) + if ( group.Any( c => !c.Supported ) ) + foreach ( var candidate in group.Where( c => c.Supported ) ) + candidate.Errors.Add( "Another rigidbody in this nested hierarchy has unsupported dependencies." ); + return candidates.OrderBy( c => c.Source.gameObject.name ).ThenBy( c => c.Description ).ToList(); + } + + public static List DiscoverPrefabs() + { + var result = new List(); + foreach ( var path in AssetDatabase.FindAssets( "t:Prefab", new[] { "Assets" } ).Select( AssetDatabase.GUIDToAssetPath ) ) { + var root = AssetDatabase.LoadAssetAtPath( path ); + if ( root == null ) + continue; + var candidates = Discover( new[] { root } ); + if ( candidates.Count == 0 ) + continue; + result.Add( new PrefabInfo + { + Path = path, + Name = root.name, + SupportedCount = candidates.Where( c => c.Supported ).Sum( c => c.ComponentCount ), + UnsupportedCount = candidates.Where( c => !c.Supported ).Sum( c => c.ComponentCount ), + Error = PrefabUtility.IsPartOfImmutablePrefab( root ) ? "Imported or immutable prefab." : null + } ); + } + return result.OrderBy( p => p.Name ).ToList(); + } + + public static Plan BuildPlan( IEnumerable available, IEnumerable selected ) + { + var plan = new Plan(); + var candidates = available.ToArray(); + var sources = new HashSet( selected ); + // Nested bodies share an articulated root. Include the complete body hierarchy, + // so a partially converted parent cannot claim another PhysX body's shapes. + foreach ( var top in sources.OfType().Where( b => b != null ).Select( BodyRoot ).Distinct().ToArray() ) { + foreach ( var related in top.GetComponentsInChildren( true ) ) + sources.Add( related ); + } + foreach ( var source in sources ) { + var candidate = candidates.FirstOrDefault( c => c.Source == source && source != null ); + if ( candidate == null ) { + plan.Errors.Add( "A selected source is missing or outside the conversion scope. Refresh the view." ); + continue; + } + plan.Candidates.Add( candidate ); + var label = candidate.Source.gameObject.name + ": "; + plan.Errors.AddRange( candidate.Errors.Select( e => label + e ) ); + plan.Warnings.AddRange( candidate.Warnings.Select( w => label + w ) ); + } + return plan; + } + + private static Rigidbody BodyRoot( Rigidbody body ) + { + var top = body; + for ( var parent = body.transform.parent; parent != null; parent = parent.parent ) { + var parentBody = parent.GetComponent(); + if ( parentBody != null ) + top = parentBody; + } + return top; + } + + private static void ValidateBody( Rigidbody body, HashSet jointBodies, Candidate candidate ) + { + if ( body.GetComponent() != null ) + candidate.Errors.Add( "An AGX rigidbody already exists on this object." ); + if ( jointBodies.Contains( body ) ) + candidate.Errors.Add( "A joint references this rigidbody; joint conversion is not supported." ); + if ( !body.isKinematic && !body.useGravity ) + candidate.Errors.Add( "Per-body gravity disabling has no supported AGX mapping." ); + if ( body.constraints != RigidbodyConstraints.None ) + candidate.Errors.Add( "Frozen rigidbody axes require AGX constraints and are not supported yet." ); + if ( body.interpolation != RigidbodyInterpolation.None ) + candidate.Warnings.Add( "PhysX interpolation is not transferred." ); + if ( body.collisionDetectionMode != CollisionDetectionMode.Discrete ) + candidate.Warnings.Add( "PhysX collision detection mode is not transferred." ); + } + + private static Rigidbody ColliderOwner( Collider collider ) + { + if ( collider.attachedRigidbody != null ) + return collider.attachedRigidbody; + // Inactive objects and persistent prefab assets may not have a PhysX + // attachment. The nearest body in the hierarchy still owns the collider. + for ( var transform = collider.transform; transform != null; transform = transform.parent ) { + var body = transform.GetComponent(); + if ( body != null ) + return body; + } + return null; + } + + private static void ValidateCollider( Collider collider, Candidate candidate, HashSet pagedTerrains ) + { + var label = collider.GetType().Name + ": "; + if ( !( collider is BoxCollider || collider is SphereCollider || collider is CapsuleCollider || + collider is MeshCollider || collider is TerrainCollider ) ) { + candidate.Errors.Add( label + "Conversion is not supported." ); + return; + } + var scale = Abs( collider.transform.lossyScale ); + if ( Mathf.Min( scale.x, Mathf.Min( scale.y, scale.z ) ) < Shape.MinimumSize ) + candidate.Errors.Add( label + "Zero or near-zero transform scale." ); + if ( !( collider is MeshCollider ) && HasShear( collider.transform.localToWorldMatrix ) ) + candidate.Errors.Add( label + "Sheared primitive transforms cannot be represented exactly." ); + if ( collider.sharedMaterial != null ) + candidate.Warnings.Add( label + "Physics material friction, restitution and combine modes are not transferred." ); + if ( collider is MeshCollider mesh ) { + if ( mesh.sharedMesh == null || mesh.sharedMesh.vertexCount == 0 ) + candidate.Errors.Add( label + "Missing or empty source mesh." ); + else if ( !mesh.sharedMesh.isReadable ) + candidate.Errors.Add( label + "Enable Read/Write on the source mesh before converting." ); + else if ( !EditorUtility.IsPersistent( mesh.sharedMesh ) ) + candidate.Errors.Add( label + "Save the source mesh as an asset before converting." ); + else if ( mesh.sharedMesh.triangles.Length == 0 ) + candidate.Errors.Add( label + "The source mesh has no triangles." ); + if ( mesh.convex ) + candidate.Errors.Add( label + "Convex mesh conversion requires precomputed AGX collision meshes and is not supported yet." ); + } + if ( collider is TerrainCollider terrainCollider ) { + var terrain = collider.GetComponent(); + if ( terrain == null || terrain.terrainData == null || terrain.terrainData != terrainCollider.terrainData ) + candidate.Errors.Add( label + "A Terrain with matching TerrainData is required on this object." ); + if ( ColliderOwner( collider ) != null ) + candidate.Errors.Add( label + "Only static terrain conversion is supported." ); + if ( collider.GetComponent() != null || + collider.GetComponent() != null || pagedTerrains.Contains( terrain ) ) + candidate.Errors.Add( label + "AGX terrain collision already exists on this object." ); + if ( Quaternion.Angle( collider.transform.rotation, Quaternion.identity ) > 0.001f || + ( collider.transform.lossyScale - Vector3.one ).sqrMagnitude > 1.0E-8f ) + candidate.Errors.Add( label + "Deformable terrain requires unrotated, unit-scale terrain." ); + if ( collider.isTrigger ) + candidate.Errors.Add( label + "Deformable terrain does not support trigger conversion." ); + candidate.Warnings.Add( label + "Conversion creates DeformableTerrain; configure its soil and depth properties before simulation." ); + if ( terrainCollider.terrainData != null ) { + var data = terrainCollider.terrainData; + var holes = data.GetHoles( 0, 0, data.holesResolution, data.holesResolution ); + if ( holes.Cast().Any( solid => !solid ) ) + candidate.Errors.Add( label + "Terrain holes are not supported by deformable terrain." ); + } + } + } + + } +} diff --git a/Editor/AGXUnityEditor/Utils/PhysXConversionDiscovery.cs.meta b/Editor/AGXUnityEditor/Utils/PhysXConversionDiscovery.cs.meta new file mode 100644 index 00000000..068ec964 --- /dev/null +++ b/Editor/AGXUnityEditor/Utils/PhysXConversionDiscovery.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7472324958304f328bc3231e5019a4bc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/AGXUnityEditor/Utils/PhysXConversionExecution.cs b/Editor/AGXUnityEditor/Utils/PhysXConversionExecution.cs new file mode 100644 index 00000000..b9891909 --- /dev/null +++ b/Editor/AGXUnityEditor/Utils/PhysXConversionExecution.cs @@ -0,0 +1,227 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using Shape = AGXUnity.Collide.Shape; + +namespace AGXUnityEditor.Utils +{ + public static partial class PhysXConversion + { + public static Result ConvertScene( IEnumerable selected ) + { + var result = new Result(); + if ( EditorApplication.isPlayingOrWillChangePlaymode ) { + result.Errors.Add( "Conversion is only available in Edit mode." ); + return result; + } + var plan = BuildPlan( Discover( SceneRoots() ), selected ); + if ( !plan.CanExecute ) { + result.Errors.AddRange( plan.Errors ); + return result; + } + Undo.IncrementCurrentGroup(); + int group = Undo.GetCurrentGroup(); + Undo.SetCurrentGroupName( "Convert PhysX components to AGX" ); + try { + Execute( plan, true ); + Undo.FlushUndoRecordObjects(); + Undo.CollapseUndoOperations( group ); + result.Converted = plan.ComponentCount; + } + catch ( Exception exception ) { + Undo.RevertAllDownToGroup( group ); + result.Errors.Add( "Scene conversion rolled back: " + exception.Message ); + } + return result; + } + + public static Result ConvertPrefab( string path ) + { + var result = new Result(); + if ( EditorApplication.isPlayingOrWillChangePlaymode ) { + result.Errors.Add( "Conversion is only available in Edit mode." ); + return result; + } + GameObject root = null; + try { + var asset = AssetDatabase.LoadAssetAtPath( path ); + if ( asset == null || PrefabUtility.IsPartOfImmutablePrefab( asset ) || !path.StartsWith( "Assets/", StringComparison.Ordinal ) ) + throw new InvalidOperationException( "Prefab is missing, immutable, or outside Assets." ); + root = PrefabUtility.LoadPrefabContents( path ); + var candidates = Discover( new[] { root } ); + var plan = BuildPlan( candidates, candidates.Where( c => c.Supported ).Select( c => c.Source ) ); + result.Skipped = candidates.Where( c => !c.Supported ).Sum( c => c.ComponentCount ); + if ( !plan.CanExecute ) { + result.Errors.AddRange( plan.Errors ); + return result; + } + Execute( plan, false ); + PrefabUtility.SaveAsPrefabAsset( root, path, out bool success ); + if ( !success ) + throw new InvalidOperationException( "Unity could not save the converted prefab." ); + result.Converted = plan.ComponentCount; + } + catch ( Exception exception ) { + result.Errors.Add( path + ": " + exception.Message ); + } + finally { + if ( root != null ) + PrefabUtility.UnloadPrefabContents( root ); + } + return result; + } + + private static void Execute( Plan plan, bool undo ) + { + var sources = new List(); + var bodies = plan.Candidates.Select( c => c.Source ).OfType().ToArray(); + foreach ( var body in bodies ) { + ConvertBody( body, undo ); + sources.Add( body ); + } + var articulatedRoots = new HashSet(); + foreach ( var body in bodies ) { + var top = body.gameObject; + for ( var parent = body.transform.parent; parent != null; parent = parent.parent ) + if ( parent.GetComponent() != null ) + top = parent.gameObject; + if ( top != body.gameObject ) + articulatedRoots.Add( top ); + } + foreach ( var root in articulatedRoots ) + if ( root.GetComponentInParent( true ) == null ) + Add( root, undo ); + foreach ( var candidate in plan.Candidates ) { + var colliders = candidate.Source is Collider collider ? new[] { collider } : candidate.Colliders; + foreach ( var source in colliders ) { + ConvertCollider( source, undo ); + sources.Add( source ); + } + } + // PhysX colliders must be removed before their owning rigidbodies. + foreach ( var source in sources.OrderBy( s => s is Rigidbody ? 1 : 0 ) ) { + var scene = source.gameObject.scene; + if ( undo ) + Undo.DestroyObjectImmediate( source ); + else + UnityEngine.Object.DestroyImmediate( source ); + if ( undo ) + EditorSceneManager.MarkSceneDirty( scene ); + } + } + + private static T Add( GameObject gameObject, bool undo ) where T : Component => + undo ? Undo.AddComponent( gameObject ) : gameObject.AddComponent(); + + private static void ConvertBody( Rigidbody source, bool undo ) + { + var target = Add( source.gameObject, undo ); + target.MassProperties.Mass.UseDefault = false; + target.MassProperties.Mass.Value = source.mass; +#if UNITY_6000_0_OR_NEWER + target.LinearVelocityDamping = Vector3.one * source.linearDamping; + target.AngularVelocityDamping = Vector3.one * source.angularDamping; + target.LinearVelocity = source.linearVelocity; +#else + target.LinearVelocityDamping = Vector3.one * source.drag; + target.AngularVelocityDamping = Vector3.one * source.angularDrag; + target.LinearVelocity = source.velocity; +#endif + target.AngularVelocity = source.angularVelocity; + if ( !source.automaticCenterOfMass ) { + target.MassProperties.CenterOfMassOffset.UseDefault = false; + // Unity's centerOfMass is relative to position/rotation, independent of scale. + target.MassProperties.CenterOfMassOffset.Value = source.centerOfMass; + } + if ( !source.automaticInertiaTensor ) { + InertiaTensor( source.inertiaTensor, source.inertiaTensorRotation, out var diagonal, out var offDiagonal ); + target.MassProperties.InertiaDiagonal.UseDefault = false; + target.MassProperties.InertiaDiagonal.Value = diagonal; + target.MassProperties.InertiaOffDiagonal.UseDefault = false; + target.MassProperties.InertiaOffDiagonal.Value = offDiagonal; + } + if ( source.isKinematic ) + target.MotionControl = agx.RigidBody.MotionControl.KINEMATICS; + Finish( target, undo ); + } + + private static void ConvertCollider( Collider source, bool undo ) + { + if ( source is TerrainCollider ) { + var terrain = Add( source.gameObject, undo ); + terrain.enabled = source.enabled; + Finish( terrain, undo ); + return; + } + Shape target; + { + var child = new GameObject( "AGX " + source.GetType().Name ); + if ( undo ) { + Undo.RegisterCreatedObjectUndo( child, "Create AGX shape" ); + Undo.SetTransformParent( child.transform, source.transform, "Parent AGX shape" ); + } + else + child.transform.SetParent( source.transform, false ); + child.transform.localPosition = Vector3.zero; + child.transform.localRotation = Quaternion.identity; + child.transform.localScale = Vector3.one; + child.layer = source.gameObject.layer; + GameObjectUtility.SetStaticEditorFlags( child, GameObjectUtility.GetStaticEditorFlags( source.gameObject ) ); + var scale = Abs( source.transform.lossyScale ); + switch ( source ) { + case BoxCollider box: + var agxBox = Add( child, undo ); + child.transform.localPosition = box.center; + agxBox.HalfExtents = Vector3.Scale( box.size * 0.5f, scale ); + target = agxBox; + break; + case SphereCollider sphere: + var agxSphere = Add( child, undo ); + child.transform.localPosition = sphere.center; + agxSphere.Radius = sphere.radius * Mathf.Max( scale.x, Mathf.Max( scale.y, scale.z ) ); + target = agxSphere; + break; + case CapsuleCollider capsule: + CapsuleDimensions( capsule, out var radius, out var height, out var rotation ); + child.transform.localPosition = capsule.center; + child.transform.localRotation = rotation; + if ( height < Shape.MinimumSize ) { + var capsSphere = Add( child, undo ); + capsSphere.Radius = radius; + target = capsSphere; + } + else { + var agxCapsule = Add( child, undo ); + agxCapsule.Radius = radius; + agxCapsule.Height = height; + target = agxCapsule; + } + break; + case MeshCollider mesh: + var agxMesh = Add( child, undo ); + if ( !agxMesh.AddSourceObject( mesh.sharedMesh ) ) + throw new InvalidOperationException( "AGX rejected source mesh " + mesh.name ); + target = agxMesh; + break; + default: + throw new InvalidOperationException( "Unsupported collider " + source.GetType().Name ); + } + } + target.IsSensor = source.isTrigger; + target.enabled = source.enabled; + var owner = ColliderOwner( source ); + target.CollisionsEnabled = source.enabled && ( owner == null || owner.detectCollisions ); + Finish( target, undo ); + } + + private static void Finish( Component target, bool undo ) + { + EditorUtility.SetDirty( target ); + if ( undo && PrefabUtility.IsPartOfPrefabInstance( target ) ) + PrefabUtility.RecordPrefabInstancePropertyModifications( target ); + } + } +} diff --git a/Editor/AGXUnityEditor/Utils/PhysXConversionExecution.cs.meta b/Editor/AGXUnityEditor/Utils/PhysXConversionExecution.cs.meta new file mode 100644 index 00000000..cf5f2cda --- /dev/null +++ b/Editor/AGXUnityEditor/Utils/PhysXConversionExecution.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: dc6c80b223dd4b6bb6cce74b8b0859e2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/AGXUnityEditor/Utils/PhysXConversionGeometry.cs b/Editor/AGXUnityEditor/Utils/PhysXConversionGeometry.cs new file mode 100644 index 00000000..f5480afd --- /dev/null +++ b/Editor/AGXUnityEditor/Utils/PhysXConversionGeometry.cs @@ -0,0 +1,38 @@ +using UnityEngine; + +namespace AGXUnityEditor.Utils +{ + public static partial class PhysXConversion + { + public static bool HasShear( Matrix4x4 matrix ) + { + Vector3 x = matrix.GetColumn( 0 ); + Vector3 y = matrix.GetColumn( 1 ); + Vector3 z = matrix.GetColumn( 2 ); + return Mathf.Abs( Vector3.Dot( x.normalized, y.normalized ) ) > 1.0E-4f || + Mathf.Abs( Vector3.Dot( x.normalized, z.normalized ) ) > 1.0E-4f || + Mathf.Abs( Vector3.Dot( y.normalized, z.normalized ) ) > 1.0E-4f; + } + + public static void CapsuleDimensions( CapsuleCollider source, out float radius, out float height, out Quaternion rotation ) + { + var scale = Abs( source.transform.lossyScale ); + int axis = source.direction; + radius = source.radius * Mathf.Max( scale[ ( axis + 1 ) % 3 ], scale[ ( axis + 2 ) % 3 ] ); + height = Mathf.Max( 0, source.height * scale[ axis ] - 2 * radius ); + rotation = Quaternion.FromToRotation( Vector3.up, axis == 0 ? Vector3.right : axis == 2 ? Vector3.forward : Vector3.up ); + } + + public static void InertiaTensor( Vector3 principal, Quaternion rotation, out Vector3 diagonal, out Vector3 offDiagonal ) + { + var r = Matrix4x4.Rotate( rotation ); + var tensor = r * Matrix4x4.Scale( principal ) * r.transpose; + diagonal = new Vector3( tensor.m00, tensor.m11, tensor.m22 ); + // AGX uses right-handed coordinates, obtained by reflecting Unity's X axis. + offDiagonal = new Vector3( -tensor.m01, -tensor.m02, tensor.m12 ); + } + + private static Vector3 Abs( Vector3 value ) => new Vector3( Mathf.Abs( value.x ), Mathf.Abs( value.y ), Mathf.Abs( value.z ) ); + + } +} diff --git a/Editor/AGXUnityEditor/Utils/PhysXConversionGeometry.cs.meta b/Editor/AGXUnityEditor/Utils/PhysXConversionGeometry.cs.meta new file mode 100644 index 00000000..d2654682 --- /dev/null +++ b/Editor/AGXUnityEditor/Utils/PhysXConversionGeometry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 29ca153337804ce5a512999790cb349f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/AGXUnityEditor/Windows/ConvertPhysXToAGXWindow.cs b/Editor/AGXUnityEditor/Windows/ConvertPhysXToAGXWindow.cs new file mode 100644 index 00000000..1a76f791 --- /dev/null +++ b/Editor/AGXUnityEditor/Windows/ConvertPhysXToAGXWindow.cs @@ -0,0 +1,407 @@ +using AGXUnityEditor.UIElements; +using AGXUnityEditor.Utils; +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEditor; +using UnityEngine; +using UnityEngine.UIElements; + +namespace AGXUnityEditor.Windows +{ + /// Selection and reporting UI; conversion policy lives in PhysXConversion. + public class ConvertPhysXToAGXWindow : EditorWindow + { + [SerializeField] private List m_selectedSources = new List(); + [SerializeField] private List m_selectedPrefabs = new List(); + [SerializeField] private string m_report = ""; + [SerializeField] private string m_sort = "Name"; + private List m_candidates = new List(); + private List m_prefabs = new List(); + private bool m_sceneDirty; + private bool m_converting; + private Button m_convertButton; + private TextField m_reportField; + private readonly Dictionary m_icons = new Dictionary(); + + public static ConvertPhysXToAGXWindow Open() + { + var window = GetWindow( false, "Convert PhysX to AGX", true ); + window.minSize = new Vector2( 600, 500 ); + return window; + } + + private void OnEnable() + { + Undo.undoRedoPerformed += OnUndoRedo; + EditorApplication.hierarchyChanged += OnHierarchyChanged; + EditorApplication.playModeStateChanged += OnPlayModeChanged; + } + + private void OnDisable() + { + Undo.undoRedoPerformed -= OnUndoRedo; + EditorApplication.hierarchyChanged -= OnHierarchyChanged; + EditorApplication.playModeStateChanged -= OnPlayModeChanged; + } + + private void OnUndoRedo() => m_sceneDirty = true; + private void OnHierarchyChanged() => m_sceneDirty = true; + private void OnPlayModeChanged( PlayModeStateChange state ) => Refresh( false ); + + private void Update() + { + if ( m_sceneDirty && !m_converting ) + Refresh( false ); + } + + private void CreateGUI() => Refresh( true ); + + private void Refresh( bool prefabs ) + { + m_sceneDirty = false; + m_candidates = PhysXConversion.Discover( PhysXConversion.SceneRoots() ); + if ( prefabs ) + m_prefabs = PhysXConversion.DiscoverPrefabs(); + // Preserve identities during refresh, including Undo/Redo. Stale selections + // cannot execute: every conversion is preflighted against fresh discovery. + Draw(); + } + + private void Draw() + { + rootVisualElement.Clear(); + rootVisualElement.style.paddingLeft = 12; + rootVisualElement.style.paddingRight = 12; + rootVisualElement.style.paddingTop = 12; + rootVisualElement.style.paddingBottom = 12; + var separator = new VisualElement(); + separator.style.height = 2; + separator.style.flexShrink = 0; + separator.style.backgroundColor = InspectorGUISkin.BrandColor; + separator.style.marginBottom = 8; + rootVisualElement.Add( separator ); + + var instructions = new HelpBox( + "Convert colliders and rigidbodies in loaded scenes or prefabs. Nested rigidbodies are converted together. " + + "Terrain conversion creates DeformableTerrain. Unity layers are copied, but collision filtering, physics callbacks " + + "and script references require migration. Materials, joints and wheels are not converted.", HelpBoxMessageType.Info ); + // Keep the explanatory text at its measured height. Only the lists should + // shrink when the window is resized, otherwise HelpBox text escapes its border. + instructions.style.flexShrink = 0; + var instructionLabel = instructions.Q