using UnityEditor;
using UnityEditor.EditorTools;
using UnityEditor.ShortcutManagement;
using UnityEngine;
using TD.Core;
namespace TD.Levels.Editor
{
///
/// Custom scene-view tool for resizing 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.
///
///
/// 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 and together;
/// the GameObject's Transform is left alone. This keeps the asymmetry contained to the
/// collider component, where it belongs.
///
[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();
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();
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;
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. We achieve this
// by adjusting transform.position rather than BoxCollider.center, so the collider's
// center stays locked at (0, 0, 0) — the "center" of the volume's local frame is always
// the geometric center of the box. The position shifts by half the size change in the
// direction of the edge being dragged.
//
// Example (east edge dragged outward by 2 tiles): size.x += 2; transform.position.x += 1.
// Example (west edge dragged outward by 1 tile): size.x += 1; transform.position.x -= 0.5.
//
// Note: positions may land at half-integer values when the size is odd. That's correct
// under the edge-aligned tile convention — bounds align with tile edges when
// (position - size/2) and (position + size/2) are both integers,
// which requires position to have the same fractional part as size/2.
float positionShift = (edgeIsPositive ? 1f : -1f) * (outwardDelta * 0.5f);
if (axisIsX)
{
size.x = newSize;
}
else
{
size.z = newSize;
}
Vector3 newPosition = col.transform.position;
if (axisIsX) newPosition.x += positionShift;
else newPosition.z += positionShift;
Undo.RecordObject(col.transform, "Resize Volume Edge");
Undo.RecordObject(col, "Resize Volume Edge");
col.transform.position = newPosition;
col.size = size;
// Force-lock collider center to zero in case it had drifted from prior edits made
// before this behavior change. Safe to do unconditionally — by design, this tool now
// never wants a non-zero center.
col.center = Vector3.zero;
EditorUtility.SetDirty(col);
EditorUtility.SetDirty(col.transform);
}
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();
}
}
}