Implementing Interactive Area and Angle Measurement Tools in Unity
Prerequisites
Prepare the following prefabs before implementing the measurement systems:
- Vertex Marker: A small sphere to indicate clicked positions in world space
- Edge Renderer: An empty GameObject with a LineRenderer component attached
- Measurement Container: An empty parent Transform to organize generated elements hierarchically
- Angle Label (for angle tool): A 3D TextMesh for displaying values in the scene
Polygon Area Measurement
This system captures user input to construct a closed polygon, then calculates the surface area using vector cross products.
Area Calculator Implementation
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;
public class PolygonAreaTool : MonoBehaviour
{
[Header("Assets")]
public GameObject vertexMarker;
public LineRenderer perimeterRenderer;
public Transform container;
[Header("Configuration")]
public float verticalOffset = 0.05f;
public int decimalPrecision = 1;
private bool isActive;
private List<Vector3> boundaryPoints = new List<Vector3>();
private LineRenderer currentPerimeter;
private GUIStyle hudStyle;
void Start()
{
hudStyle = new GUIStyle { fontSize = 28, fontStyle = FontStyle.Bold };
}
public void Activate() => isActive = true;
public void DeactivateAndClear()
{
isActive = false;
boundaryPoints.Clear();
for (int i = container.childCount - 1; i >= 0; i--)
Destroy(container.GetChild(i).gameObject);
}
void Update()
{
if (!isActive || EventSystem.current.IsPointerOverGameObject())
return;
if (Input.GetMouseButtonUp(0))
{
RaycastHit hitInfo;
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
if (Physics.Raycast(ray, out hitInfo))
{
AddPolygonVertex(hitInfo.point);
}
}
}
void AddPolygonVertex(Vector3 surfacePoint)
{
Vector3 elevatedPoint = surfacePoint + Vector3.up * verticalOffset;
Instantiate(vertexMarker, elevatedPoint, Quaternion.identity, container);
if (boundaryPoints.Count == 0)
{
currentPerimeter = Instantiate(perimeterRenderer, container);
}
boundaryPoints.Add(elevatedPoint);
UpdateVisualRepresentation();
}
void UpdateVisualRepresentation()
{
if (boundaryPoints.Count < 3)
{
currentPerimeter.positionCount = boundaryPoints.Count;
currentPerimeter.SetPositions(boundaryPoints.ToArray());
return;
}
// Close the loop for visualization
Vector3[] closedPath = new Vector3[boundaryPoints.Count + 1];
boundaryPoints.CopyTo(closedPath);
closedPath[closedPath.Length - 1] = boundaryPoints[0];
currentPerimeter.positionCount = closedPath.Length;
currentPerimeter.SetPositions(closedPath);
}
void OnGUI()
{
if (boundaryPoints.Count < 3) return;
Vector3 center = GetCentroid(boundaryPoints);
Vector3 screenLocation = Camera.main.WorldToScreenPoint(center);
float area = CalculateSurfaceArea(boundaryPoints);
string displayText = $"<color=#FF6B6B>{area.ToString($"F{decimalPrecision}")} m²</color>";
Rect labelRect = new Rect(screenLocation.x - 60, Screen.height - screenLocation.y - 15, 120, 30);
GUI.Label(labelRect, displayText, hudStyle);
}
Vector3 GetCentroid(List<Vector3> vertices)
{
Vector3 accumulator = Vector3.zero;
foreach (Vector3 v in vertices)
accumulator += v;
return accumulator / vertices.Count;
}
float CalculateSurfaceArea(List<Vector3> vertices)
{
int n = vertices.Count;
if (n < 3) return 0f;
// Calculate normal using Newell's method for robustness
Vector3 normal = Vector3.zero;
for (int i = 0; i < n; i++)
{
Vector3 current = vertices[i];
Vector3 next = vertices[(i + 1) % n];
normal.x += (current.y - next.y) * (current.z + next.z);
normal.y += (current.z - next.z) * (current.x + next.x);
normal.z += (current.x - next.x) * (current.y + next.y);
}
normal.Normalize();
// Project onto plane perpendicular to normal and apply shoelace formula
float totalArea = 0f;
for (int i = 0; i < n; i++)
{
Vector3 v1 = vertices[i];
Vector3 v2 = vertices[(i + 1) % n];
totalArea += Vector3.Dot(normal, Vector3.Cross(v1, v2));
}
return Mathf.Abs(totalArea) * 0.5f;
}
}
Surface Measurement Adjustment
When measuring on horizontal planes without obstruction, eliminate the vertical offset to prevent floating artfiacts:
// Set verticalOffset to 0 in the inspector for ground-level measurements
// Or modify the elevation logic:
Vector3 measurementPoint = surfacePoint; // No offset for flat surfaces
Vector Angle Measurement
Angle measurement requires three discrete clicks to define a vertex and two connected rays.
Angle Calculator Implementation
using UnityEngine;
using UnityEngine.EventSystems;
public class AngleMeasurementTool : MonoBehaviour
{
public GameObject markerPrefab;
public LineRenderer rayVisualizer;
public TextMesh floatingLabel;
public Transform measurementRoot;
private enum MeasurementState { Idle, FirstRay, SecondRay }
private MeasurementState currentState = MeasurementState.Idle;
private Transform originMarker;
private Transform pivotMarker;
private Transform terminalMarker;
private LineRenderer activeRays;
private TextMesh currentLabel;
void Update()
{
if (EventSystem.current.IsPointerOverGameObject()) return;
HandleInput();
UpdateDynamicPreview();
}
void HandleInput()
{
if (!Input.GetMouseButtonUp(0)) return;
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
if (!Physics.Raycast(ray, out RaycastHit hit)) return;
switch (currentState)
{
case MeasurementState.Idle:
StartMeasurement(hit.point);
break;
case MeasurementState.FirstRay:
SetPivotPoint(hit.point);
break;
case MeasurementState.SecondRay:
CompleteMeasurement(hit.point);
break;
}
}
void StartMeasurement(Vector3 position)
{
originMarker = Instantiate(markerPrefab, position, Quaternion.identity, measurementRoot).transform;
activeRays = Instantiate(rayVisualizer, measurementRoot);
activeRays.positionCount = 2;
activeRays.SetPosition(0, position);
currentState = MeasurementState.FirstRay;
}
void SetPivotPoint(Vector3 position)
{
pivotMarker = Instantiate(markerPrefab, position, Quaternion.identity, measurementRoot).transform;
activeRays.positionCount = 3;
activeRays.SetPosition(1, position);
currentLabel = Instantiate(floatingLabel, position + Vector3.up * 0.3f, Quaternion.identity, measurementRoot);
currentState = MeasurementState.SecondRay;
}
void CompleteMeasurement(Vector3 position)
{
terminalMarker = Instantiate(markerPrefab, position, Quaternion.identity, measurementRoot).transform;
activeRays.SetPosition(2, position);
ComputeAndDisplayAngle();
currentState = MeasurementState.Idle;
}
void UpdateDynamicPreview()
{
if (currentState == MeasurementState.Idle) return;
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
if (!Physics.Raycast(ray, out RaycastHit hit)) return;
if (currentState == MeasurementState.FirstRay)
{
activeRays.SetPosition(1, hit.point);
}
else if (currentState == MeasurementState.SecondRay)
{
activeRays.SetPosition(2, hit.point);
Vector3 fromOrigin = (originMarker.position - pivotMarker.position).normalized;
Vector3 toCursor = (hit.point - pivotMarker.position).normalized;
float angle = Vector3.Angle(fromOrigin, toCursor);
currentLabel.text = $"{angle:F0}°";
}
}
void ComputeAndDisplayAngle()
{
Vector3 vectorA = (originMarker.position - pivotMarker.position).normalized;
Vector3 vectorB = (terminalMarker.position - pivotMarker.position).normalized;
float degrees = Vector3.Angle(vectorA, vectorB);
currentLabel.text = $"{degrees:F1}°";
}
public void ClearAll()
{
currentState = MeasurementState.Idle;
foreach (Transform child in measurementRoot)
Destroy(child.gameObject);
}
}