435 lines
18 KiB
C#
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
|
|
}
|
|
}
|