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; 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(); } } }