214 lines
No EOL
10 KiB
C#
214 lines
No EOL
10 KiB
C#
using UnityEditor;
|
|
using UnityEditor.EditorTools;
|
|
using UnityEditor.ShortcutManagement;
|
|
using UnityEngine;
|
|
using TD.Core;
|
|
|
|
namespace TD.Levels.Editor
|
|
{
|
|
/// <summary>
|
|
/// Custom scene-view tool for resizing <see cref="VolumeBase"/> volumes by dragging individual
|
|
/// edges, instead of Unity's default symmetric BoxCollider sizing. Each drag is snapped to
|
|
/// whole tiles (1.0 world units), so volumes always stay tile-aligned.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Activate from the scene-view toolbar when a VolumeBase is selected. Four handles appear at
|
|
/// the midpoints of the volume's four horizontal edges (N/S/E/W). Dragging a handle moves only
|
|
/// that edge — the opposite edge stays put.
|
|
///
|
|
/// The tool refuses to operate on rotated volumes (P2-19 hard-error in bake). Rotation must
|
|
/// be zeroed before the tool will edit. This surfaces the bake constraint at authoring time.
|
|
///
|
|
/// Edits modify <see cref="BoxCollider.size"/> and <see cref="BoxCollider.center"/> together;
|
|
/// the GameObject's Transform is left alone. This keeps the asymmetry contained to the
|
|
/// collider component, where it belongs.
|
|
/// </remarks>
|
|
[EditorTool("Resize Volume Edge", typeof(VolumeBase))]
|
|
public class VolumeEditTool : EditorTool
|
|
{
|
|
// Visual constants.
|
|
private const float HandleY = 0.10f; // world Y at which to draw handles
|
|
private const float HandleSize = 0.30f; // world-space radius of the handle disc
|
|
private const float MinSize = 1.0f; // smallest allowed size on either tile axis (1 tile)
|
|
private const float SnapIncrement = 1.0f; // tile size — the snap unit
|
|
|
|
// Handle colors (X-axis red, Z-axis blue, matching Unity's standard convention).
|
|
private static readonly Color XAxisColor = new Color(0.95f, 0.35f, 0.35f, 1f);
|
|
private static readonly Color ZAxisColor = new Color(0.35f, 0.55f, 0.95f, 1f);
|
|
|
|
public override void OnToolGUI(EditorWindow window)
|
|
{
|
|
// Multi-selection: don't draw anything when more than one volume is selected. Bulk
|
|
// edge-edits are ambiguous (which volumes' edges align?) and out of scope for now.
|
|
if (targets == null) return;
|
|
int count = 0;
|
|
foreach (var _ in targets) { count++; if (count > 1) break; }
|
|
if (count != 1) return;
|
|
|
|
var volume = target as VolumeBase;
|
|
if (volume == null) return;
|
|
|
|
var col = volume.GetComponent<BoxCollider>();
|
|
if (col == null) return;
|
|
|
|
// Refuse to operate on rotated volumes. Show an in-scene warning instead.
|
|
Vector3 e = volume.transform.eulerAngles;
|
|
if (!IsZeroRotation(e))
|
|
{
|
|
DrawRotationWarning(volume, e);
|
|
return;
|
|
}
|
|
|
|
DrawEdgeHandles(volume, col);
|
|
}
|
|
|
|
private static bool IsZeroRotation(Vector3 eulerAngles)
|
|
{
|
|
const float tol = 0.01f;
|
|
// Normalize each axis to (-180..180] before comparing.
|
|
float dx = NormalizeAngle(eulerAngles.x);
|
|
float dy = NormalizeAngle(eulerAngles.y);
|
|
float dz = NormalizeAngle(eulerAngles.z);
|
|
return Mathf.Abs(dx) <= tol && Mathf.Abs(dy) <= tol && Mathf.Abs(dz) <= tol;
|
|
}
|
|
|
|
private static float NormalizeAngle(float a)
|
|
{
|
|
a = a % 360f;
|
|
if (a > 180f) a -= 360f;
|
|
else if (a < -180f) a += 360f;
|
|
return a;
|
|
}
|
|
|
|
private void DrawRotationWarning(VolumeBase volume, Vector3 euler)
|
|
{
|
|
var col = volume.GetComponent<BoxCollider>();
|
|
Vector3 labelPos = col != null
|
|
? new Vector3(col.bounds.center.x, HandleY + 0.5f, col.bounds.center.z)
|
|
: volume.transform.position + Vector3.up * 0.5f;
|
|
|
|
// Use the editor's Handles label with a colored background-ish hint via a sphere.
|
|
Handles.color = Color.red;
|
|
Handles.SphereHandleCap(0, labelPos, Quaternion.identity, 0.2f, EventType.Repaint);
|
|
Handles.Label(labelPos + new Vector3(0.3f, 0f, 0.3f),
|
|
$"VolumeEditTool: zero out rotation to edit edges (current: {euler}).");
|
|
}
|
|
|
|
private void DrawEdgeHandles(VolumeBase volume, BoxCollider col)
|
|
{
|
|
// Compute the four edge-midpoint world positions. We treat the volume's local axes as
|
|
// world axes (zero rotation already enforced above). The transform's position can still
|
|
// translate the volume, so we use TransformPoint to get world positions.
|
|
//
|
|
// BoxCollider.center and BoxCollider.size are LOCAL to the GameObject's transform.
|
|
Vector3 localCenter = col.center;
|
|
Vector3 localSize = col.size;
|
|
float halfX = localSize.x * 0.5f;
|
|
float halfZ = localSize.z * 0.5f;
|
|
|
|
Vector3 eastLocal = localCenter + new Vector3(halfX, 0f, 0f);
|
|
Vector3 westLocal = localCenter + new Vector3(-halfX, 0f, 0f);
|
|
Vector3 northLocal = localCenter + new Vector3(0f, 0f, halfZ);
|
|
Vector3 southLocal = localCenter + new Vector3(0f, 0f, -halfZ);
|
|
|
|
Transform t = volume.transform;
|
|
Vector3 eastWorld = WithY(t.TransformPoint(eastLocal), HandleY);
|
|
Vector3 westWorld = WithY(t.TransformPoint(westLocal), HandleY);
|
|
Vector3 northWorld = WithY(t.TransformPoint(northLocal), HandleY);
|
|
Vector3 southWorld = WithY(t.TransformPoint(southLocal), HandleY);
|
|
|
|
// Render and process each handle. Handles.Slider returns the new world position; we
|
|
// compute the snapped tile-delta and apply it to the collider's size/center.
|
|
DrawAxisHandle(col, eastWorld, Vector3.right, axisIsX: true, edgeIsPositive: true, XAxisColor);
|
|
DrawAxisHandle(col, westWorld, Vector3.right, axisIsX: true, edgeIsPositive: false, XAxisColor);
|
|
DrawAxisHandle(col, northWorld, Vector3.forward, axisIsX: false, edgeIsPositive: true, ZAxisColor);
|
|
DrawAxisHandle(col, southWorld, Vector3.forward, axisIsX: false, edgeIsPositive: false, ZAxisColor);
|
|
}
|
|
|
|
// Draw a single edge handle and apply the resulting size/center change to the collider.
|
|
// - `direction`: the world axis the handle slides along (Vector3.right for E/W, Vector3.forward for N/S).
|
|
// - `axisIsX`: true if this handle modifies size.x/center.x; false if size.z/center.z.
|
|
// - `edgeIsPositive`: true if this is the +axis edge (East or North); false for -axis (West or South).
|
|
private void DrawAxisHandle(BoxCollider col, Vector3 worldPos, Vector3 direction,
|
|
bool axisIsX, bool edgeIsPositive, Color color)
|
|
{
|
|
Handles.color = color;
|
|
|
|
EditorGUI.BeginChangeCheck();
|
|
// FreeMoveHandle would allow drag in any direction; we constrain to the axis using Slider.
|
|
Vector3 newWorldPos = Handles.Slider(worldPos, direction, HandleSize, Handles.SphereHandleCap, 0f);
|
|
if (!EditorGUI.EndChangeCheck()) return;
|
|
|
|
// How far did the handle move along its axis, in world units?
|
|
float worldDelta = axisIsX
|
|
? (newWorldPos.x - worldPos.x)
|
|
: (newWorldPos.z - worldPos.z);
|
|
|
|
// Snap to whole tiles. Discard sub-tile motion until the user drags a full tile.
|
|
int tileDelta = Mathf.RoundToInt(worldDelta / SnapIncrement);
|
|
if (tileDelta == 0) return;
|
|
|
|
float worldDeltaSnapped = tileDelta * SnapIncrement;
|
|
|
|
// For the negative edge (W or S), dragging "outward" means dragging toward more
|
|
// negative axis values; the size should still grow. Normalize so a positive `outwardDelta`
|
|
// always means "grow."
|
|
float outwardDelta = edgeIsPositive ? worldDeltaSnapped : -worldDeltaSnapped;
|
|
|
|
Vector3 size = col.size;
|
|
Vector3 center = col.center;
|
|
|
|
float currentSize = axisIsX ? size.x : size.z;
|
|
float newSize = currentSize + outwardDelta;
|
|
|
|
// Clamp at minimum tile size. If the user tries to shrink past min, discard the input
|
|
// (don't move the edge at all). This is more predictable than partially honoring it.
|
|
if (newSize < MinSize) return;
|
|
|
|
// Apply the change. The edge that's NOT being dragged should stay put. To keep the
|
|
// opposite edge fixed, the center must shift by half the size change in the direction
|
|
// of the edge being dragged.
|
|
//
|
|
// Example (east edge dragged outward by 2 tiles): size.x += 2; center.x += 1.
|
|
// Example (west edge dragged outward by 1 tile): size.x += 1; center.x -= 0.5.
|
|
float centerShift = (edgeIsPositive ? 1f : -1f) * (outwardDelta * 0.5f);
|
|
|
|
if (axisIsX)
|
|
{
|
|
size.x = newSize;
|
|
center.x += centerShift;
|
|
}
|
|
else
|
|
{
|
|
size.z = newSize;
|
|
center.z += centerShift;
|
|
}
|
|
|
|
Undo.RecordObject(col, "Resize Volume Edge");
|
|
col.size = size;
|
|
col.center = center;
|
|
EditorUtility.SetDirty(col);
|
|
}
|
|
|
|
private static Vector3 WithY(Vector3 v, float y)
|
|
{
|
|
v.y = y;
|
|
return v;
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// Hotkey activation. The [Shortcut] attribute registers this with Unity's Shortcut Manager
|
|
// (Edit → Shortcuts), where it can be rebound. The default binding is B, mnemonic for "Box".
|
|
//
|
|
// The shortcut activates the tool whether or not a VolumeBase is currently selected — when
|
|
// nothing relevant is selected, the tool is "active" but draws nothing. The handles appear
|
|
// as soon as a VolumeBase is selected.
|
|
// -------------------------------------------------------------------
|
|
|
|
[Shortcut("Tools/Resize Volume Edge", KeyCode.B)]
|
|
private static void ActivateShortcut()
|
|
{
|
|
ToolManager.SetActiveTool<VolumeEditTool>();
|
|
}
|
|
}
|
|
} |