mixedreality/com.microsoft.mixedreality..../SDK/Features/Utilities/Solvers/TapToPlace.cs

435 lines
18 KiB
C#

// 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
{
/// <summary>
/// Tap to place is a far interaction component used to place objects on a surface.
/// </summary>
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;
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>
/// The max distance (in meters) to place an object if there is a raycast hit on a surface
/// </summary>
public float MaxRaycastDistance
{
get => maxRaycastDistance;
set => maxRaycastDistance = value;
}
/// <summary>
/// If true, the game object to place is selected.
/// </summary>
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;
/// <summary>
/// The distance between the center of the game object to place and a surface along the surface normal, if the raycast hits a surface.
/// </summary>
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;
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>
/// If true, the game object to place will remain upright and in line with Vector3.up
/// </summary>
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;
/// <summary>
/// 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.
/// </summary>
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 };
/// <summary>
/// Array of LayerMask to execute from highest to lowest priority. First layermask to provide a raycast hit will be used by component.
/// </summary>
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;
/// <summary>
/// If true and in the Unity Editor, the normal of the raycast hit will be drawn in yellow.
/// </summary>
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();
/// <summary>
/// This event is triggered once when the game object to place is selected.
/// </summary>
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();
/// <summary>
/// This event is triggered once when the game object to place is unselected, placed.
/// </summary>
public UnityEvent OnPlacingStopped
{
get => onPlacingStopped;
set => onPlacingStopped = value;
}
/// <summary>
/// The current game object layer before it is temporarily switched to IgnoreRaycast while placing the game object.
/// </summary>
protected internal int GameObjectLayer { get; protected set; }
protected internal bool IsColliderPresent => gameObject != null && gameObject.GetComponent<Collider>() != null;
/// <summary>
/// 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.
/// </summary>
private float defaultSurfaceNormalOffset => gameObject.GetComponent<Collider>().bounds.extents.z;
private int ignoreRaycastLayer;
/// <summary>
/// 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.
/// </summary>
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<IMixedRealityPointerHandler>(this);
}
#endregion
/// <summary>
/// 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().
/// </summary>
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<IMixedRealityPointerHandler>(this);
}
}
/// <summary>
/// Stop the placement of a game object without the need of the OnPointerClicked event.
/// </summary>
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<IMixedRealityPointerHandler>(this);
}
}
/// <inheritdoc/>
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);
}
/// <summary>
/// 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
/// </summary>
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
/// <inheritdoc/>
public void OnPointerDown(MixedRealityPointerEventData eventData) { }
/// <inheritdoc/>
public void OnPointerDragged(MixedRealityPointerEventData eventData) { }
/// <inheritdoc/>
public void OnPointerUp(MixedRealityPointerEventData eventData) { }
/// <inheritdoc/>
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
}
}