Skip to main content
Version: latest

Workspace Navigation Tutorial

This tutorial shows how to adjust the cursor scaling using the handle, providing the user control over precision and size of navigable workspace building.

This example builds on the scene created in Workspace Scaling and Placement by providing handle-based control over the Haptic Workspace. The goal is to use the handle button to trigger workspace offsetting and use the cursor position to change it, and to the second one to trigger workspace resizing and use handle roll to change it.

To this end, when the user presses a button the WorkspaceOffsetController script saves the last known cursor position and stops updating the avatar location. Alternatively, the WorkspaceScaleController saves the workspace scale and current handle orientation when a button is pressed. As a result of using the two scripts together, moving the cursor will create a new cursor offset while rotating the handle will change the workspace scaling. In this implementation, CCW rotation around the Z axis corresponds to reducing the workspace scaling and vice versa. When the user releases the button, the two offsets will stop changing and the cursor avatar will move once more.

Scene setup

Start by adding the HandleTread component to the Haptic Workspace GameObject. In the inspector view, set the Cursor as HandleThread Avatar then add a cube as a child of the cursor to visualise its rotation (see Quick Start Guide for more details).

handle thread

The WorkspaceOffsetController component

Create a new script WorkspaceOffsetController.cs and add it to the Haptic Workspace GameObject. Then add a reference to the cursor controlled by the HapticThread

    private Transform m_cursor;

private void Awake()
{
m_cursor = GetComponent<HapticThread>().avatar;
}

Then the following properties :

private Vector3 m_basePosition;
private Vector3 m_cursorBasePosition;

m_basePosition saves the workscape position prior to any modification, while m_cursorBasePosition saves the cursor position.

Next, add the active encapsulated field corresponding to the button state which initialises the previous fields and enables the workspace offsetting on each update:

public bool active
{
get => m_active;
set
{
if (value)
{
m_basePosition = transform.localPosition;
m_cursorBasePosition = m_cursor.localPosition;
}
m_active = value;
}
}
private bool m_active;

Finally, add Update which, when active, changes the workspace offset by calculating the change in cursor position relative to the base position. Note that the position change needs to be scaled by the workspace transform to remain accurate.

private void Update()
{
if (active)
{
// Move cursor offset relative to cursor position
transform.position = m_basePosition - Vector3.Scale(m_cursor.localPosition - m_cursorBasePosition, transform.lossyScale);
}
}

To bind the handle button to the active field, select the Haptic Workspace and from the inspector panel, add (Haptic Workspace)WorkspaceOffsetController.active = false to OnButtonUp() event and (Haptic Workspace)WorkspaceOffsetController.active = true to OnButtonDown().

workspace offset handle events

Optional: Binding the camera movement

Changing the cursor offset can be made intuitive by moving the camera along with the workspace. As a result, even if the offset is large, the cursor will never move out of frame.

no camera constraint with camera constraint

To achieve this add a Position Constraint component to the Main Camera, set the * Haptic Workspace* as source, and press the Activate button.

camera constraint

The WorkspaceScaleController component

Create a new script WorkspaceScaleController.cs and add it to the Haptic Workspace GameObject. Then add a reference to the cursor controlled by the HandleThread

private Transform m_cursor;

private void Awake()
{
m_cursor = GetComponent<HandleThread>().avatar;
}

Then add the following settings:

public float scalingFactor = 0.25f;
public float minimumScale = 1f;
public float maximumScale = 20f;

camera constraint

scalingFactor converts the rotation of the handle in degrees into a numeric scale which is bound by the minimumScale and the maximumScale.

Then add the following fields:

private float m_baseScale;
private float m_cursorBaseAngle;

m_baseScale saves the workscape scale prior to any modification, while m_cursorBaseAngle saves the cursor orientation on the Y-axis.

Next, add GetTotalDegrees method:

private float m_cursorPreviousAngle;
private int m_rotationCount;

private float GetTotalDegrees(float currentAngle, float baseAngle)
{
if (currentAngle - m_cursorPreviousAngle > 330)
m_rotationCount--;
else if (m_cursorPreviousAngle - currentAngle > 330)
m_rotationCount++;

m_cursorPreviousAngle = currentAngle;

return 360f * m_rotationCount + (currentAngle - baseAngle);
}

The handle can rotate more than once around its own axis which can lead to sudden jumps in the displacement when crossing 0°. This function checks the current angle against the previous angle and detects when 0° has been crossed and in which direction guaranteeing that the returned angle offset is accurate, even if more than one complete rotation took place.

Next, the active encapsulated field, corresponding to the button state, which initialises previous fields and enables the workspace scaling on each update:

public bool active
{
get => m_active;
set
{
if (value)
{
m_rotationCount = 0;
m_baseScale = transform.localScale.z;
m_cursorPreviousAngle = m_cursorBaseAngle = m_cursor.localEulerAngles.z;

}
m_active = value;
}
}
private bool m_active;

Finally, add Update which, if active, modifies the transforms of the Haptic Workspace based on the handle angle.

private void Update()
{
if (active)
{
// Calculate scale relative to cursor roll on Z-axis rotation
var totalDegrees = GetTotalDegrees(m_cursor.localEulerAngles.z, m_cursorBaseAngle);
var scale = m_baseScale - totalDegrees * scalingFactor / 100f;

// Limit between minimumScale and maximumScale
scale = Mathf.Clamp(scale, minimumScale, maximumScale);

// Set cursor offset scale (same on each axis)
transform.localScale = Vector3.one * scale;
// Invert cursor scale to keep its original size
m_cursor.localScale = Vector3.one / scale;
}
}

Binding handle button

As before, to bind the handle button to the active field, select the Haptic Workspace and from the inspector panel, add (Haptic Workspace)WorkspaceScaleController.active = false to OnButtonUp() event and (Haptic Workspace)WorkspaceScaleController.active = true to OnButtonDown().

workspace scale handle events

Result

Now you can easily navigate in the scene by scaling and moving the workspace by pressing the Handle button.

workspace scale handle events

Source files

The final scene and all associated files used by this example can be imported from the Basic Force Feedback and Workspace Control sample in Unity's package manager. The Unity Sample contains additional quality of life improvements that were outside of the scope of this tutorial:

  • A transparent bubble visualising the the workspace size
  • Keyboard shortcuts
    • Pressing M key makes workspace move only
    • Pressing S key makes workspace scale only
  • A UI to display current offset and scale values

WorkspaceOffsetController.cs

using Haply.HardwareAPI.Unity;
using UnityEngine;

public class WorkspaceOffsetController : MonoBehaviour
{
// Movable cursor with position controlled by Haptic Thread
private Transform m_cursor;

// Saved workspace and cursor values at transformation beginning
private Vector3 m_basePosition;
private Vector3 m_cursorBasePosition;

// If true, the workspace offset is set relatively to the cursor position on each Update() loop
public bool active
{
get => m_active;
set
{
if (value)
{
m_basePosition = transform.localPosition;
m_cursorBasePosition = m_cursor.localPosition;
}
m_active = value;
}
}

private bool m_active;

private void Awake()
{
// Get the moving cursor from the HapticThread
m_cursor = GetComponent<HapticThread>().avatar;
}

private void Update()
{
if (active)
{
// Update the workspace offset relative to cursor position
transform.position = m_basePosition - Vector3.Scale(m_cursor.localPosition - m_cursorBasePosition, transform.lossyScale);
}
}
}

WorkspaceScaleController.cs

using System;
using Haply.HardwareAPI.Unity;
using UnityEngine;

public class WorkspaceScaleController : MonoBehaviour
{
// Movable cursor with rotation controlled by Handle Thread
private Transform m_cursor;

[Tooltip("Sensitivity of scaling on handle rotation")]
public float scalingFactor = 3f;
public float minimumScale = 1f;
public float maximumScale = 5f;

// Saved workspace and cursor values at transformation beginning
private float m_baseScale;
private float m_cursorBaseAngle;
private float m_cursorPreviousAngle;
private int m_rotationCount;

// If enabled the workspace will be uniformly scaled relatively to cursor roll (Z-axis rotation) on each Update() loop
public bool active
{
get => m_active;
set
{
if (value)
{
m_rotationCount = 0;
m_baseScale = transform.localScale.z;
m_cursorPreviousAngle = m_cursorBaseAngle = m_cursor.localEulerAngles.z;
}
m_active = value;
}
}
private bool m_active;

private void Awake()
{
// Get the rotating cursor from the HandleThread
m_cursor = GetComponent<HandleThread>().avatar;
}

private void Update()
{
if (active)
{
// Calculate scale relative to cursor roll on Z-axis rotation
var totalDegrees = GetTotalDegrees(m_cursor.localEulerAngles.z, m_cursorBaseAngle);
var scale = m_baseScale - totalDegrees * scalingFactor / 100f;

// Limit between minimumScale and maximumScale
scale = Mathf.Clamp(scale, minimumScale, maximumScale);

// Set cursor offset scale (same on each axis)
transform.localScale = Vector3.one * scale;
// Invert cursor scale to keep its original size
m_cursor.localScale = Vector3.one / scale;
}
}

// Return the total degrees between baseAngle and currentAngle over the 360 degrees limitation
private float GetTotalDegrees(float currentAngle, float baseAngle)
{
if (currentAngle - m_cursorPreviousAngle > 330)
m_rotationCount--;
else if (m_cursorPreviousAngle - currentAngle > 330)
m_rotationCount++;

m_cursorPreviousAngle = currentAngle;

return 360f * m_rotationCount + (currentAngle - baseAngle);
}
}