// Copyright (c) 2012-2024 Wojciech Figat. All rights reserved. #if USE_LARGE_WORLDS using Real = System.Double; #else using Real = System.Single; #endif using System; using System.Collections.Generic; using FlaxEditor.SceneGraph; using FlaxEngine; namespace FlaxEditor.Gizmo { /// /// Base class for transformation gizmos that can be used to select objects and transform them. /// /// [HideInEditor] public abstract partial class TransformGizmoBase : GizmoBase { /// /// The start transforms list cached for selected objects before transformation apply. Can be used to create undo operations. /// protected readonly List _startTransforms = new List(); /// /// Flag used to indicate that navigation data was modified. /// protected bool _navigationDirty; /// /// The initial world bounds of the selected objects before performing any transformations. Used to find the dirty volume of the world during editing. /// protected BoundingBox _startBounds = BoundingBox.Empty; private Vector3 _accMoveDelta; private Transform _gizmoWorld = Transform.Identity; private Vector3 _intersectPosition; private bool _isActive; private bool _isDuplicating; private bool _isTransforming; private Vector3 _lastIntersectionPosition; private Quaternion _rotationDelta = Quaternion.Identity; private float _rotationSnapDelta; private Vector3 _scaleDelta; private float _screenScale; private Vector3 _tDelta; private Vector3 _translationDelta; private Vector3 _translationScaleSnapDelta; private SceneGraphNode _vertexSnapObject, _vertexSnapObjectTo; private Vector3 _vertexSnapPoint, _vertexSnapPointTo; /// /// Gets the gizmo position. /// public Vector3 Position { get; private set; } /// /// Gets the last transformation delta. /// public Transform LastDelta { get; private set; } /// /// Occurs when transforming selection started. /// public event Action TransformingStarted; /// /// Occurs when transforming selection ended. /// public event Action TransformingEnded; /// /// Initializes a new instance of the class. /// /// The gizmos owner. public TransformGizmoBase(IGizmoOwner owner) : base(owner) { InitDrawing(); ModeChanged += ResetTranslationScale; } /// /// Starts the objects transforming (optionally with duplicate). /// public void StartTransforming() { // Check if can start new action var count = SelectionCount; if (count == 0 || _isTransforming || !CanTransform) return; // Check if duplicate objects if (Owner.UseDuplicate && !_isDuplicating && CanDuplicate) { _isDuplicating = true; OnDuplicate(); return; } // Cache 'before' state _startTransforms.Clear(); if (_startTransforms.Capacity < count) _startTransforms.Capacity = Mathf.NextPowerOfTwo(count); for (var i = 0; i < count; i++) { _startTransforms.Add(GetSelectedTransform(i)); } GetSelectedObjectsBounds(out _startBounds, out _navigationDirty); // Start _isTransforming = true; OnStartTransforming(); } /// /// Ends the objects transforming. /// public void EndTransforming() { // Check if wasn't working at all if (!_isTransforming) return; // End action _isTransforming = false; _isDuplicating = false; OnEndTransforming(); _startTransforms.Clear(); } private void UpdateGizmoPosition() { var position = Vector3.Zero; // Get gizmo pivot switch (_activePivotType) { case PivotType.ObjectCenter: if (SelectionCount > 0) position = GetSelectedTransform(0).Translation; break; case PivotType.SelectionCenter: position = GetSelectionCenter(); break; } // Apply vertex snapping if (_vertexSnapObject != null) { Vector3 vertexSnapPoint = _vertexSnapObject.Transform.LocalToWorld(_vertexSnapPoint); position += vertexSnapPoint - position; } // Apply current movement position += _translationDelta; Position = position; } private void UpdateMatrices() { // Check there is no need to perform update if (SelectionCount == 0) return; // Set positions of the gizmo UpdateGizmoPosition(); // Scale gizmo to fit on-screen Vector3 position = Position; if (Owner.Viewport.UseOrthographicProjection) { //[hack] this is far form ideal the View Position is in wrong location, any think using the View Position will have problem //the camera system needs rewrite the to be a camera on springarm, similar how the ArcBallCamera is handled //the ortho projection cannot exist with fps camera because there is no // - focus point to calculate correct View Position with Orthographic Scale as a reference and Orthographic Scale from View Position // with make the camera jump // - and deaph so w and s movment in orto mode moves the cliping plane now float gizmoSize = Editor.Instance.Options.Options.Visual.GizmoSize; _screenScale = gizmoSize * (50 * Owner.Viewport.OrthographicScale); } else { Vector3 vLength = Owner.ViewPosition - position; float gizmoSize = Editor.Instance.Options.Options.Visual.GizmoSize; _screenScale = (float)(vLength.Length / GizmoScaleFactor * gizmoSize); } // Setup world Quaternion orientation = GetSelectedTransform(0).Orientation; _gizmoWorld = new Transform(position, orientation, new Float3(_screenScale)); if (_activeTransformSpace == TransformSpace.World && _activeMode != Mode.Scale) { _gizmoWorld.Orientation = Quaternion.Identity; } } private void UpdateTranslateScale() { bool isScaling = _activeMode == Mode.Scale; Vector3 delta = Vector3.Zero; Ray ray = Owner.MouseRay; Matrix.RotationQuaternion(ref _gizmoWorld.Orientation, out var rotationMatrix); Matrix.Invert(ref rotationMatrix, out var invRotationMatrix); ray.Position = Vector3.Transform(ray.Position, invRotationMatrix); Vector3.TransformNormal(ref ray.Direction, ref invRotationMatrix, out ray.Direction); var position = Position; var planeXY = new Plane(Vector3.Backward, Vector3.Transform(position, invRotationMatrix).Z); var planeYZ = new Plane(Vector3.Left, Vector3.Transform(position, invRotationMatrix).X); var planeZX = new Plane(Vector3.Down, Vector3.Transform(position, invRotationMatrix).Y); var dir = Vector3.Normalize(ray.Position - position); var planeDotXY = Mathf.Abs(Vector3.Dot(planeXY.Normal, dir)); var planeDotYZ = Mathf.Abs(Vector3.Dot(planeYZ.Normal, dir)); var planeDotZX = Mathf.Abs(Vector3.Dot(planeZX.Normal, dir)); Real intersection; switch (_activeAxis) { case Axis.X: { var plane = planeDotXY > planeDotZX ? planeXY : planeZX; if (ray.Intersects(ref plane, out intersection)) { _intersectPosition = ray.GetPoint(intersection); if (!_lastIntersectionPosition.IsZero) _tDelta = _intersectPosition - _lastIntersectionPosition; delta = new Vector3(_tDelta.X, 0, 0); } break; } case Axis.Y: { var plane = planeDotXY > planeDotYZ ? planeXY : planeYZ; if (ray.Intersects(ref plane, out intersection)) { _intersectPosition = ray.GetPoint(intersection); if (!_lastIntersectionPosition.IsZero) _tDelta = _intersectPosition - _lastIntersectionPosition; delta = new Vector3(0, _tDelta.Y, 0); } break; } case Axis.Z: { var plane = planeDotZX > planeDotYZ ? planeZX : planeYZ; if (ray.Intersects(ref plane, out intersection)) { _intersectPosition = ray.GetPoint(intersection); if (!_lastIntersectionPosition.IsZero) _tDelta = _intersectPosition - _lastIntersectionPosition; delta = new Vector3(0, 0, _tDelta.Z); } break; } case Axis.YZ: { if (ray.Intersects(ref planeYZ, out intersection)) { _intersectPosition = ray.GetPoint(intersection); if (!_lastIntersectionPosition.IsZero) _tDelta = _intersectPosition - _lastIntersectionPosition; delta = new Vector3(0, _tDelta.Y, _tDelta.Z); } break; } case Axis.XY: { if (ray.Intersects(ref planeXY, out intersection)) { _intersectPosition = ray.GetPoint(intersection); if (!_lastIntersectionPosition.IsZero) _tDelta = _intersectPosition - _lastIntersectionPosition; delta = new Vector3(_tDelta.X, _tDelta.Y, 0); } break; } case Axis.ZX: { if (ray.Intersects(ref planeZX, out intersection)) { _intersectPosition = ray.GetPoint(intersection); if (!_lastIntersectionPosition.IsZero) _tDelta = _intersectPosition - _lastIntersectionPosition; delta = new Vector3(_tDelta.X, 0, _tDelta.Z); } break; } case Axis.Center: { var gizmoToView = Position - Owner.ViewPosition; var plane = new Plane(-Vector3.Normalize(gizmoToView), gizmoToView.Length); if (ray.Intersects(ref plane, out intersection)) { _intersectPosition = ray.GetPoint(intersection); if (!_lastIntersectionPosition.IsZero) _tDelta = _intersectPosition - _lastIntersectionPosition; } delta = _tDelta; break; } } // Modifiers if (isScaling) delta *= 0.01f; if (Owner.IsAltKeyDown) delta *= 0.5f; if ((isScaling ? ScaleSnapEnabled : TranslationSnapEnable) || Owner.UseSnapping) { var snapValue = new Vector3(isScaling ? ScaleSnapValue : TranslationSnapValue); _translationScaleSnapDelta += delta; if (!isScaling && snapValue.X < 0.0f) { // Snap to object bounding box GetSelectedObjectsBounds(out var b, out _); if (b.Minimum.X < 0.0f) snapValue.X = (Real)Math.Abs(b.Minimum.X) + b.Maximum.X; else snapValue.X = (Real)b.Minimum.X - b.Maximum.X; if (b.Minimum.Y < 0.0f) snapValue.Y = (Real)Math.Abs(b.Minimum.Y) + b.Maximum.Y; else snapValue.Y = (Real)b.Minimum.Y - b.Maximum.Y; if (b.Minimum.Z < 0.0f) snapValue.Z = (Real)Math.Abs(b.Minimum.Z) + b.Maximum.Z; else snapValue.Z = (Real)b.Minimum.Z - b.Maximum.Z; } delta = new Vector3( (int)(_translationScaleSnapDelta.X / snapValue.X) * snapValue.X, (int)(_translationScaleSnapDelta.Y / snapValue.Y) * snapValue.Y, (int)(_translationScaleSnapDelta.Z / snapValue.Z) * snapValue.Z); _translationScaleSnapDelta -= delta; } if (_activeMode == Mode.Translate) { // Transform (local or world) delta = Vector3.Transform(delta, rotationMatrix); _translationDelta = delta; } else if (_activeMode == Mode.Scale) { // Scale _scaleDelta = delta; } } private void ResetTranslationScale() { _translationScaleSnapDelta.Normalize(); } private void UpdateRotate(float dt) { float mouseDelta = _activeAxis == Axis.Y ? -Owner.MouseDelta.X : Owner.MouseDelta.X; float delta = mouseDelta * dt; if (RotationSnapEnabled || Owner.UseSnapping) { float snapValue = RotationSnapValue * Mathf.DegreesToRadians; _rotationSnapDelta += delta; float snapped = Mathf.Round(_rotationSnapDelta / snapValue) * snapValue; _rotationSnapDelta -= snapped; delta = snapped; } switch (_activeAxis) { case Axis.X: case Axis.Y: case Axis.Z: { Float3 dir; if (_activeAxis == Axis.X) dir = Float3.Right * _gizmoWorld.Orientation; else if (_activeAxis == Axis.Y) dir = Float3.Up * _gizmoWorld.Orientation; else dir = Float3.Forward * _gizmoWorld.Orientation; Float3 viewDir = Owner.ViewPosition - Position; Float3.Dot(ref viewDir, ref dir, out float dot); if (dot < 0.0f) delta *= -1; Quaternion.RotationAxis(ref dir, delta, out _rotationDelta); break; } default: _rotationDelta = Quaternion.Identity; break; } } /// public override bool IsControllingMouse => _isTransforming; /// public override void Update(float dt) { LastDelta = Transform.Identity; if (!IsActive) return; bool isLeftBtnDown = Owner.IsLeftMouseButtonDown; // Snap to ground if (_activeAxis == Axis.None && SelectionCount != 0 && Owner.SnapToGround) { SnapToGround(); } // Only when is active else if (_isActive) { // Backup position _lastIntersectionPosition = _intersectPosition; _intersectPosition = Vector3.Zero; // Check if user is holding left mouse button and any axis is selected if (isLeftBtnDown && _activeAxis != Axis.None) { switch (_activeMode) { case Mode.Translate: UpdateTranslateScale(); break; case Mode.Scale: UpdateTranslateScale(); break; case Mode.Rotate: UpdateRotate(dt); break; } if (Owner.SnapToVertex) UpdateVertexSnapping(); } else { // If nothing selected, try to select any axis if (!isLeftBtnDown && !Owner.IsRightMouseButtonDown) { if (Owner.SnapToVertex) SelectVertexSnapping(); else SelectAxis(); } } // Set positions of the gizmo UpdateGizmoPosition(); // Trigger Translation, Rotation & Scale events if (isLeftBtnDown) { var anyValid = false; // Translation Vector3 translationDelta = Vector3.Zero; if (_translationDelta.LengthSquared > 0.000001f) { anyValid = true; translationDelta = _translationDelta; _translationDelta = Vector3.Zero; // Prevent from moving objects too far away, like to a different galaxy or sth Vector3 prevMoveDelta = _accMoveDelta; _accMoveDelta += _translationDelta; if (_accMoveDelta.Length > Owner.ViewFarPlane * 0.7f) _accMoveDelta = prevMoveDelta; } // Rotation Quaternion rotationDelta = Quaternion.Identity; if (!_rotationDelta.IsIdentity) { anyValid = true; rotationDelta = _rotationDelta; _rotationDelta = Quaternion.Identity; } // Scale Vector3 scaleDelta = Vector3.Zero; if (_scaleDelta.LengthSquared > 0.000001f) { anyValid = true; scaleDelta = _scaleDelta; _scaleDelta = Vector3.Zero; if (ActiveAxis == Axis.Center) scaleDelta = new Vector3(scaleDelta.AvgValue); } // Apply transformation (but to the parents, not whole selection pool) if (anyValid || (_isTransforming && Owner.UseDuplicate)) { StartTransforming(); LastDelta = new Transform(translationDelta, rotationDelta, scaleDelta); OnApplyTransformation(ref translationDelta, ref rotationDelta, ref scaleDelta); } } else { // Clear cache _accMoveDelta = Vector3.Zero; _lastIntersectionPosition = _intersectPosition = Vector3.Zero; EndTransforming(); } } // Check if has no objects selected if (SelectionCount == 0) { // Deactivate _isActive = false; _activeAxis = Axis.None; EndVertexSnapping(); return; } // Helps solve visual lag (1-frame-lag) after selecting a new entity if (!_isActive) UpdateGizmoPosition(); // Activate _isActive = true; // Update UpdateMatrices(); } private void SelectVertexSnapping() { // Find the closest object in selection that is hit by the mouse ray var ray = new SceneGraphNode.RayCastData { Ray = Owner.MouseRay, }; var closestDistance = Real.MaxValue; SceneGraphNode closestObject = null; for (int i = 0; i < SelectionCount; i++) { var obj = GetSelectedObject(i); if (obj.RayCastSelf(ref ray, out var distance, out _) && distance < closestDistance) { closestDistance = distance; closestObject = obj; } } if (closestObject == null) return; // ignore it if there is nothing under the mouse closestObject is only null if ray caster missed everything or Selection Count == 0 _vertexSnapObject = closestObject; if (!closestObject.OnVertexSnap(ref ray.Ray, closestDistance, out _vertexSnapPoint)) { // The OnVertexSnap is unimplemented or failed to get point return because there is nothing to do _vertexSnapPoint = Vector3.Zero; return; } // Transform back to the local space of the object to work when moving it _vertexSnapPoint = closestObject.Transform.WorldToLocal(_vertexSnapPoint); } private void EndVertexSnapping() { // Clear current vertex snapping data _vertexSnapObject = null; _vertexSnapObjectTo = null; _vertexSnapPoint = _vertexSnapPointTo = Vector3.Zero; } private void UpdateVertexSnapping() { _vertexSnapObjectTo = null; if (Owner.SceneGraphRoot == null) return; Profiler.BeginEvent("VertexSnap"); // Raycast nearby objects to snap to (excluding selection) var rayCast = new SceneGraphNode.RayCastData { Ray = Owner.MouseRay, Flags = SceneGraphNode.RayCastData.FlagTypes.SkipColliders | SceneGraphNode.RayCastData.FlagTypes.SkipEditorPrimitives, ExcludeObjects = new(), }; for (int i = 0; i < SelectionCount; i++) rayCast.ExcludeObjects.Add(GetSelectedObject(i)); var hit = Owner.SceneGraphRoot.RayCast(ref rayCast, out var distance, out var _); if (hit != null) { if (hit.OnVertexSnap(ref rayCast.Ray, distance, out var pointSnapped) //&& Vector3.Distance(point, pointSnapped) <= 25.0f ) { _vertexSnapObjectTo = hit; _vertexSnapPointTo = hit.Transform.WorldToLocal(pointSnapped); // Snap current vertex to the target vertex _translationDelta = pointSnapped - Position; } } Profiler.EndEvent(); } /// /// Gets a value indicating whether this tool can transform objects. /// protected virtual bool CanTransform => true; /// /// Gets a value indicating whether this tool can duplicate objects. /// protected virtual bool CanDuplicate => true; /// /// Gets the selected objects count. /// protected abstract int SelectionCount { get; } /// /// Gets the selected object. /// /// The selected object index. /// The selected object (eg. actor node). protected abstract SceneGraphNode GetSelectedObject(int index); /// /// Gets the selected object transformation. /// /// The selected object index. /// The transformation of the selected object. protected abstract Transform GetSelectedTransform(int index); /// /// Gets the selected objects bounding box (contains the whole selection). /// /// The bounds of the selected objects (merged bounds). /// True if editing the selected objects transformations marks the navigation system area dirty (for auto-rebuild), otherwise skip update. protected abstract void GetSelectedObjectsBounds(out BoundingBox bounds, out bool navigationDirty); /// /// Checks if the specified object is selected. /// /// The object to check. /// True if it's selected, otherwise false. protected abstract bool IsSelected(SceneGraphNode obj); /// /// Called when user starts transforming selected objects. /// protected virtual void OnStartTransforming() { TransformingStarted?.Invoke(); } /// /// Called when gizmo tools wants to apply transformation delta to the selected objects pool. /// /// The translation delta. /// The rotation delta. /// The scale delta. protected virtual void OnApplyTransformation(ref Vector3 translationDelta, ref Quaternion rotationDelta, ref Vector3 scaleDelta) { } /// /// Called when user ends transforming selected objects. /// protected virtual void OnEndTransforming() { TransformingEnded?.Invoke(); } /// /// Called when user duplicates selected objects. /// protected virtual void OnDuplicate() { } /// public override void OnSelectionChanged(List newSelection) { EndVertexSnapping(); UpdateGizmoPosition(); } } }