// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using Microsoft.MixedReality.Toolkit.Input; using Microsoft.MixedReality.Toolkit.Physics; using UnityEngine; using UnityEngine.Events; using UnityPhysics = UnityEngine.Physics; namespace Microsoft.MixedReality.Toolkit.Utilities.Solvers { /// /// Tap to place is a far interaction component used to place objects on a surface. /// public class TapToPlace : Solver, IMixedRealityPointerHandler { [Space(10)] [SerializeField] [Tooltip("If true, the game object to place will start selected. The object will immediately start" + " following the TrackedTargetType (Head or Controller Ray) and then a tap is required to place the object." + " This value must be modified before Start() is invoked in order to have any effect")] private bool autoStart = false; /// /// If true, the game object to place will start out selected. The object will immediately start /// following the TrackedTargetType (Head or Controller Ray) and then a tap is required to place the object. /// This value must be modified before Start() is invoked in order to have any effect. /// public bool AutoStart { get => autoStart; set => autoStart = value; } [SerializeField] [Tooltip("The default distance (in meters) an object will be placed relative to the TrackedTargetType forward in the SolverHandler." + " The GameObjectToPlace will be placed at the default placement distance if a surface is not hit by the raycast.")] private float defaultPlacementDistance = 1.5f; /// /// The default distance (in meters) an object will be placed relative to the TrackedTargetType forward in the SolverHandler. /// The GameObjectToPlace will be placed at the default placement distance if a surface is not hit by the raycast. /// public float DefaultPlacementDistance { get => defaultPlacementDistance; set => defaultPlacementDistance = value; } [SerializeField] [Tooltip("Max distance (in meters) to place an object if there is a raycast hit on a surface.")] private float maxRaycastDistance = 20.0f; /// /// The max distance (in meters) to place an object if there is a raycast hit on a surface /// public float MaxRaycastDistance { get => maxRaycastDistance; set => maxRaycastDistance = value; } /// /// If true, the game object to place is selected. /// public bool IsBeingPlaced { get; protected set; } [SerializeField] [Tooltip("The distance between the center of the game object to place and a surface along the surface normal, if the raycast hits a surface")] private float surfaceNormalOffset = 0.0f; /// /// The distance between the center of the game object to place and a surface along the surface normal, if the raycast hits a surface. /// public float SurfaceNormalOffset { get => surfaceNormalOffset; set { // If a user were to configure Tap to Place via script and they try to set the SurfaceNormalOffset while UseDefaultSurfaceNormalOffset is true, display the following error: Debug.Assert(!UseDefaultSurfaceNormalOffset, $"The new value for SurfaceNormalOffset on the Tap to Place object will not be applied because UseDefaultSurfaceNormalOffset is true, set UseDefaultSurfaceNormalOffset to false."); surfaceNormalOffset = value; } } [SerializeField] [Tooltip("If true, the default surface normal offset will be used instead of any value specified for the SurfaceNormalOffset property. If false, the " + "SurfaceNormalOffset is used. The default surface normal offset is the Z extents of the bounds on the attached collider, this ensures the object being " + "placed is aligned on a surface. This property is automatically set to false if the SurfaceNormalOffset property is set and is not " + "the default value.")] private bool useDefaultSurfaceNormalOffset = true; /// /// If true, the default surface normal offset will be used instead of any value specified for the SurfaceNormalOffset property. /// If false, the SurfaceNormalOffset is used. The default surface normal offset is the Z extents of the bounds on the attached collider, this /// ensures the object being placed is aligned on a surface. /// public bool UseDefaultSurfaceNormalOffset { get => useDefaultSurfaceNormalOffset; set => useDefaultSurfaceNormalOffset = value; } [SerializeField] [Tooltip("If true, the game object to place will remain upright and in line with Vector3.up")] private bool keepOrientationVertical = false; /// /// If true, the game object to place will remain upright and in line with Vector3.up /// public bool KeepOrientationVertical { get => keepOrientationVertical; set => keepOrientationVertical = value; } [SerializeField] [Tooltip("If false, the game object to place will not change its rotation according to the surface hit. The object will" + " remain facing the camera while IsBeingPlaced is true. If true, the object will rotate according to the surface normal" + " if there is a hit.")] private bool rotateAccordingToSurface = false; /// /// If false, the game object to place will not change its rotation according to the surface hit. The object will /// remain facing the camera while IsBeingPlaced is true. If true, the object will rotate according to the surface normal /// if there is a hit. /// public bool RotateAccordingToSurface { get => rotateAccordingToSurface; set => rotateAccordingToSurface = value; } [SerializeField] [Tooltip("Array of LayerMask to execute from highest to lowest priority. First layermask to provide a raycast hit will be used by component.")] private LayerMask[] magneticSurfaces = { UnityEngine.Physics.DefaultRaycastLayers }; /// /// Array of LayerMask to execute from highest to lowest priority. First layermask to provide a raycast hit will be used by component. /// public LayerMask[] MagneticSurfaces { get => magneticSurfaces; set => magneticSurfaces = value; } [SerializeField] [Tooltip("If true and in the Unity Editor, the normal of the raycast hit will be drawn in yellow.")] private bool debugEnabled = true; /// /// If true and in the Unity Editor, the normal of the raycast hit will be drawn in yellow. /// public bool DebugEnabled { get => debugEnabled; set => debugEnabled = value; } [SerializeField] [Tooltip("This event is triggered once when the game object to place is selected.")] private UnityEvent onPlacingStarted = new UnityEvent(); /// /// This event is triggered once when the game object to place is selected. /// public UnityEvent OnPlacingStarted { get => onPlacingStarted; set => onPlacingStarted = value; } [SerializeField] [Tooltip("This event is triggered once when the game object to place is unselected, placed.")] private UnityEvent onPlacingStopped = new UnityEvent(); /// /// This event is triggered once when the game object to place is unselected, placed. /// public UnityEvent OnPlacingStopped { get => onPlacingStopped; set => onPlacingStopped = value; } /// /// The current game object layer before it is temporarily switched to IgnoreRaycast while placing the game object. /// protected internal int GameObjectLayer { get; protected set; } protected internal bool IsColliderPresent => gameObject != null && gameObject.GetComponent() != null; /// /// The default value for SurfaceNormalOffset if UseDefaultSurfaceNormalOffset is true. This value ensures an object /// will be placed in alignment with a surface. This value is not cached to specifically support adjustments to object scale /// while in the placing state. /// private float defaultSurfaceNormalOffset => gameObject.GetComponent().bounds.extents.z; private int ignoreRaycastLayer; /// /// The current ray is based on the TrackedTargetType (Controller Ray, Head, Hand Joint). /// The following properties are updated each frame while the game object is selected to determine /// object placement if there is a hit on a surface. /// protected RayStep CurrentRay; protected bool DidHitSurface; protected RaycastHit CurrentHit; // Used to record the time (seconds) between OnPointerClicked calls to avoid two calls in a row. protected float LastTimeClicked = 0; protected float DoubleClickTimeout = 0.5f; // Used to mark whether Start() has been called. private bool startCalled; // Used to mark whether StartPlacement() is called before Start() is called. private bool placementRequested; #region MonoBehaviour Implementation protected override void Start() { base.Start(); Debug.Assert(IsColliderPresent, $"The game object {gameObject.name} does not have a collider attached, please attach a collider to use Tap to Place"); // When a game object is created via script, the bounds of the collider remain at the default size // of (1, 1, 1) which always returns a 0.5 SurfaceNormalOffset. Adding SyncTransforms updates the // size of the collider to match the game object before we calculate the SurfaceNormalOffset. UnityPhysics.SyncTransforms(); ignoreRaycastLayer = LayerMask.NameToLayer("Ignore Raycast"); startCalled = true; if (AutoStart || placementRequested) { StartPlacement(); } else { SolverHandler.UpdateSolvers = false; } } private void OnDisable() { CoreServices.InputSystem?.UnregisterHandler(this); } #endregion /// /// Start the placement of a game object without the need of the OnPointerClicked event. The game object will begin to follow the /// TrackedTargetType (Head by default) at a default distance. StopPlacement() must be called after StartPlacement() to stop the /// game object from following the TrackedTargetType. The game object layer is changed to IgnoreRaycast temporarily and then /// restored to its original layer in StopPlacement(). /// public void StartPlacement() { // Check to see if Start() has been called, if not set placementRequested to true. This will make sure StartPlacement() will be // called again when Start() is called. if (!startCalled) { placementRequested = true; return; } // Added for code configurability to avoid multiple calls to StartPlacement in a row if (!IsBeingPlaced) { // Store the initial game object layer GameObjectLayer = gameObject.layer; // Temporarily change the game object layer to IgnoreRaycastLayer to enable a surface hit beyond the game object gameObject.layer = ignoreRaycastLayer; SolverHandler.UpdateSolvers = true; IsBeingPlaced = true; OnPlacingStarted?.Invoke(); // A global pointer handler is needed to enable object placement without the need for focus. // The object's layer is changed to IgnoreRaycast in this method, which means the game object cannot receive focus. // Without a global handler, the game object would not receive pointer events. CoreServices.InputSystem?.RegisterHandler(this); } } /// /// Stop the placement of a game object without the need of the OnPointerClicked event. /// public void StopPlacement() { // Added for code configurability to avoid multiple calls to StopPlacement in a row if (IsBeingPlaced) { // Change the game object layer back to the game object's layer on start gameObject.layer = GameObjectLayer; SolverHandler.UpdateSolvers = false; IsBeingPlaced = false; OnPlacingStopped?.Invoke(); CoreServices.InputSystem?.UnregisterHandler(this); } } /// public override void SolverUpdate() { // Make sure the Transform target is not null, added for the case where auto start is true // and the tracked target type is the controller ray, if the hand is not in the frame we cannot // calculate the position of the object if (SolverHandler.TransformTarget != null) { PerformRaycast(); SetPosition(); SetRotation(); } } protected virtual void PerformRaycast() { // The transform target is the transform of the TrackedTargetType, i.e. Controller Ray, Head or Hand Joint var transform = SolverHandler.TransformTarget; Vector3 origin = transform.position; Vector3 endpoint = transform.position + transform.forward; CurrentRay.UpdateRayStep(ref origin, ref endpoint); // Check if the current ray hits a magnetic surface DidHitSurface = MixedRealityRaycaster.RaycastSimplePhysicsStep(CurrentRay, MaxRaycastDistance, MagneticSurfaces, false, out CurrentHit); } /// /// Change the position of the game object if there was a hit, if not then place the object at the default distance /// relative to the TrackedTargetType origin position /// protected virtual void SetPosition() { if (DidHitSurface) { // Take the current hit point and add an offset relative to the surface to avoid half of the object in the surface GoalPosition = CurrentHit.point; // Allow switching between a specified SurfaceNormalOffset and the defaultSurfaceNormalOffset while the object is in the placing state // The defaultSurfaceNormalOffset is based on the Z extents of the bounds on a collider which is subject to change while the object is in the placing state float currentSurfaceNormalOffset = UseDefaultSurfaceNormalOffset ? defaultSurfaceNormalOffset : SurfaceNormalOffset; AddOffset(CurrentHit.normal * currentSurfaceNormalOffset); #if UNITY_EDITOR if (DebugEnabled) { // Draw the normal of the raycast hit for debugging Debug.DrawRay(CurrentHit.point, CurrentHit.normal * 0.5f, Color.yellow); } #endif // UNITY_EDITOR } else { GoalPosition = SolverHandler.TransformTarget.position + (SolverHandler.TransformTarget.forward * DefaultPlacementDistance); } } protected virtual void SetRotation() { Vector3 direction = CurrentRay.Direction; Vector3 surfaceNormal = CurrentHit.normal; if (KeepOrientationVertical) { direction.y = 0; surfaceNormal.y = 0; } // If the object is on a surface then change the rotation according to the normal of the hit point if (DidHitSurface && rotateAccordingToSurface) { GoalRotation = Quaternion.LookRotation(-surfaceNormal, Vector3.up); } else { GoalRotation = Quaternion.LookRotation(direction, Vector3.up); } } #region IMixedRealityPointerHandler /// public void OnPointerDown(MixedRealityPointerEventData eventData) { } /// public void OnPointerDragged(MixedRealityPointerEventData eventData) { } /// public void OnPointerUp(MixedRealityPointerEventData eventData) { } /// public void OnPointerClicked(MixedRealityPointerEventData eventData) { // Checking the amount of time passed between OnPointerClicked calls is handling the case when OnPointerClicked is called // twice after one click. If OnPointerClicked is called twice after one click, the object will be selected and then immediately // unselected. If OnPointerClicked calls are within 0.5 secs of each other, then return to prevent an immediate object state switch. if ((Time.time - LastTimeClicked) < DoubleClickTimeout) { return; } if (!IsBeingPlaced) { StartPlacement(); } else { StopPlacement(); } // Get the time of this click action LastTimeClicked = Time.time; } #endregion } }