From ac2b7c1fa2c945d44398ac7ed00bf02130a0c98a Mon Sep 17 00:00:00 2001 From: ssdfasd <2156608475@qq.com> Date: Tue, 31 Mar 2026 23:45:08 +0800 Subject: [PATCH] Add Unity-style scene rotate gizmo --- .../Viewport/SceneViewportOverlayRenderer.cpp | 81 ++- .../Viewport/SceneViewportOverlayRenderer.h | 4 +- .../src/Viewport/SceneViewportRotateGizmo.cpp | 522 ++++++++++++++++++ .../src/Viewport/SceneViewportRotateGizmo.h | 100 ++++ editor/src/panels/SceneViewPanel.cpp | 164 ++++-- editor/src/panels/SceneViewPanel.h | 8 + .../test_scene_viewport_rotate_gizmo.cpp | 277 ++++++++++ 7 files changed, 1119 insertions(+), 37 deletions(-) create mode 100644 editor/src/Viewport/SceneViewportRotateGizmo.cpp create mode 100644 editor/src/Viewport/SceneViewportRotateGizmo.h create mode 100644 tests/editor/test_scene_viewport_rotate_gizmo.cpp diff --git a/editor/src/Viewport/SceneViewportOverlayRenderer.cpp b/editor/src/Viewport/SceneViewportOverlayRenderer.cpp index eb19f96e..5fe0857a 100644 --- a/editor/src/Viewport/SceneViewportOverlayRenderer.cpp +++ b/editor/src/Viewport/SceneViewportOverlayRenderer.cpp @@ -24,6 +24,18 @@ ImU32 ToImGuiColor(const Math::Color& color) { toChannel(color.a)); } +Math::Color WithAlpha(const Math::Color& color, float alpha) { + return Math::Color(color.r, color.g, color.b, alpha); +} + +Math::Color LerpColor(const Math::Color& a, const Math::Color& b, float t) { + return Math::Color( + a.r + (b.r - a.r) * t, + a.g + (b.g - a.g) * t, + a.b + (b.b - a.b) * t, + a.a + (b.a - a.a) * t); +} + ImVec2 NormalizeImVec2(const ImVec2& value, const ImVec2& fallback = ImVec2(1.0f, 0.0f)) { const float lengthSq = value.x * value.x + value.y * value.y; if (lengthSq <= 1e-6f) { @@ -87,6 +99,42 @@ void DrawSceneMoveGizmoAxis( drawList->AddConvexPolyFilled(triangle, 3, color); } +void DrawSceneRotateGizmoHandle( + ImDrawList* drawList, + const ImVec2& viewportMin, + const SceneViewportRotateGizmoHandleDrawData& handle, + bool frontPass) { + if (drawList == nullptr || !handle.visible) { + return; + } + + const bool isViewHandle = handle.axis == SceneViewportRotateGizmoAxis::View; + if (isViewHandle && !frontPass) { + return; + } + + const float thickness = handle.active ? 3.6f : (handle.hovered ? 3.0f : 2.1f); + for (const SceneViewportRotateGizmoSegmentDrawData& segment : handle.segments) { + if (!segment.visible || (!isViewHandle && segment.frontFacing != frontPass)) { + continue; + } + + Math::Color drawColor = handle.color; + if (!isViewHandle && !frontPass) { + drawColor = LerpColor(handle.color, Math::Color(0.72f, 0.72f, 0.72f, 1.0f), 0.78f); + drawColor = WithAlpha(drawColor, handle.active ? 0.55f : 0.38f); + } else if (isViewHandle) { + drawColor = WithAlpha(drawColor, handle.active ? 0.95f : (handle.hovered ? 0.88f : 0.78f)); + } + + drawList->AddLine( + ImVec2(viewportMin.x + segment.start.x, viewportMin.y + segment.start.y), + ImVec2(viewportMin.x + segment.end.x, viewportMin.y + segment.end.y), + ToImGuiColor(drawColor), + thickness); + } +} + void DrawSceneMoveGizmo( ImDrawList* drawList, const ImVec2& viewportMin, @@ -108,6 +156,33 @@ void DrawSceneMoveGizmo( drawList->AddCircle(pivot, moveGizmo.pivotRadius + 1.0f, IM_COL32(255, 255, 255, 48), 20, 1.0f); } +void DrawSceneRotateGizmo( + ImDrawList* drawList, + const ImVec2& viewportMin, + const SceneViewportRotateGizmoDrawData& rotateGizmo) { + if (drawList == nullptr || !rotateGizmo.visible) { + return; + } + + for (const SceneViewportRotateGizmoHandleDrawData& handle : rotateGizmo.handles) { + if (handle.axis == SceneViewportRotateGizmoAxis::View) { + DrawSceneRotateGizmoHandle(drawList, viewportMin, handle, true); + } + } + + for (const SceneViewportRotateGizmoHandleDrawData& handle : rotateGizmo.handles) { + if (handle.axis != SceneViewportRotateGizmoAxis::View) { + DrawSceneRotateGizmoHandle(drawList, viewportMin, handle, false); + } + } + + for (const SceneViewportRotateGizmoHandleDrawData& handle : rotateGizmo.handles) { + if (handle.axis != SceneViewportRotateGizmoAxis::View) { + DrawSceneRotateGizmoHandle(drawList, viewportMin, handle, true); + } + } +} + } // namespace void DrawSceneViewportOverlay( @@ -116,7 +191,8 @@ void DrawSceneViewportOverlay( const ImVec2& viewportMin, const ImVec2& viewportMax, const ImVec2& viewportSize, - const SceneViewportMoveGizmoDrawData* moveGizmo) { + const SceneViewportMoveGizmoDrawData* moveGizmo, + const SceneViewportRotateGizmoDrawData* rotateGizmo) { if (drawList == nullptr || viewportSize.x <= 1.0f || viewportSize.y <= 1.0f) { return; } @@ -128,6 +204,9 @@ void DrawSceneViewportOverlay( if (moveGizmo != nullptr) { DrawSceneMoveGizmo(drawList, viewportMin, *moveGizmo); } + if (rotateGizmo != nullptr) { + DrawSceneRotateGizmo(drawList, viewportMin, *rotateGizmo); + } drawList->PopClipRect(); } diff --git a/editor/src/Viewport/SceneViewportOverlayRenderer.h b/editor/src/Viewport/SceneViewportOverlayRenderer.h index c76e470b..b2ecb513 100644 --- a/editor/src/Viewport/SceneViewportOverlayRenderer.h +++ b/editor/src/Viewport/SceneViewportOverlayRenderer.h @@ -2,6 +2,7 @@ #include "IViewportHostService.h" #include "SceneViewportMoveGizmo.h" +#include "SceneViewportRotateGizmo.h" #include @@ -14,7 +15,8 @@ void DrawSceneViewportOverlay( const ImVec2& viewportMin, const ImVec2& viewportMax, const ImVec2& viewportSize, - const SceneViewportMoveGizmoDrawData* moveGizmo = nullptr); + const SceneViewportMoveGizmoDrawData* moveGizmo = nullptr, + const SceneViewportRotateGizmoDrawData* rotateGizmo = nullptr); } // namespace Editor } // namespace XCEngine diff --git a/editor/src/Viewport/SceneViewportRotateGizmo.cpp b/editor/src/Viewport/SceneViewportRotateGizmo.cpp new file mode 100644 index 00000000..a4b7e0be --- /dev/null +++ b/editor/src/Viewport/SceneViewportRotateGizmo.cpp @@ -0,0 +1,522 @@ +#include "SceneViewportRotateGizmo.h" + +#include "Core/IUndoManager.h" +#include "SceneViewportMath.h" +#include "SceneViewportPicker.h" + +#include + +#include + +namespace XCEngine { +namespace Editor { + +namespace { + +constexpr float kRotateGizmoAxisRadiusPixels = 84.0f; +constexpr float kRotateGizmoViewRadiusPixels = 92.0f; +constexpr float kRotateGizmoHoverThresholdPixels = 9.0f; + +Math::Vector3 NormalizeVector3(const Math::Vector3& value, const Math::Vector3& fallback) { + return value.SqrMagnitude() <= Math::EPSILON ? fallback : value.Normalized(); +} + +bool IsMouseInsideViewport(const SceneViewportRotateGizmoContext& context) { + return context.mousePosition.x >= 0.0f && + context.mousePosition.y >= 0.0f && + context.mousePosition.x <= context.viewportSize.x && + context.mousePosition.y <= context.viewportSize.y; +} + +Math::Color GetRotateAxisBaseColor(SceneViewportRotateGizmoAxis axis) { + switch (axis) { + case SceneViewportRotateGizmoAxis::X: + return Math::Color(0.91f, 0.09f, 0.05f, 1.0f); + case SceneViewportRotateGizmoAxis::Y: + return Math::Color(0.45f, 1.0f, 0.12f, 1.0f); + case SceneViewportRotateGizmoAxis::Z: + return Math::Color(0.11f, 0.29f, 1.0f, 1.0f); + case SceneViewportRotateGizmoAxis::View: + return Math::Color(0.78f, 0.78f, 0.78f, 0.9f); + case SceneViewportRotateGizmoAxis::None: + default: + return Math::Color::White(); + } +} + +Math::Vector3 GetRotateAxisVector( + SceneViewportRotateGizmoAxis axis, + const SceneViewportOverlayData& overlay) { + switch (axis) { + case SceneViewportRotateGizmoAxis::X: + return Math::Vector3::Right(); + case SceneViewportRotateGizmoAxis::Y: + return Math::Vector3::Up(); + case SceneViewportRotateGizmoAxis::Z: + return Math::Vector3::Forward(); + case SceneViewportRotateGizmoAxis::View: + return NormalizeVector3(overlay.cameraForward, Math::Vector3::Forward()); + case SceneViewportRotateGizmoAxis::None: + default: + return Math::Vector3::Zero(); + } +} + +bool GetRotateRingBasis( + SceneViewportRotateGizmoAxis axis, + const SceneViewportOverlayData& overlay, + Math::Vector3& outBasisA, + Math::Vector3& outBasisB) { + switch (axis) { + case SceneViewportRotateGizmoAxis::X: + outBasisA = Math::Vector3::Up(); + outBasisB = Math::Vector3::Forward(); + return true; + case SceneViewportRotateGizmoAxis::Y: + outBasisA = Math::Vector3::Forward(); + outBasisB = Math::Vector3::Right(); + return true; + case SceneViewportRotateGizmoAxis::Z: + outBasisA = Math::Vector3::Right(); + outBasisB = Math::Vector3::Up(); + return true; + case SceneViewportRotateGizmoAxis::View: + outBasisA = NormalizeVector3(overlay.cameraRight, Math::Vector3::Right()); + outBasisB = NormalizeVector3(overlay.cameraUp, Math::Vector3::Up()); + return outBasisA.SqrMagnitude() > Math::EPSILON && outBasisB.SqrMagnitude() > Math::EPSILON; + case SceneViewportRotateGizmoAxis::None: + default: + outBasisA = Math::Vector3::Zero(); + outBasisB = Math::Vector3::Zero(); + return false; + } +} + +float ComputeWorldUnitsPerPixel( + const SceneViewportOverlayData& overlay, + const Math::Vector3& worldPoint, + float viewportHeight) { + if (!overlay.valid || viewportHeight <= 1.0f) { + return 0.0f; + } + + const Math::Vector3 cameraForward = NormalizeVector3(overlay.cameraForward, Math::Vector3::Forward()); + const float depth = Math::Vector3::Dot(worldPoint - overlay.cameraPosition, cameraForward); + if (depth <= Math::EPSILON) { + return 0.0f; + } + + return 2.0f * depth * std::tan(overlay.verticalFovDegrees * Math::DEG_TO_RAD * 0.5f) / viewportHeight; +} + +float SignedAngleRadiansAroundAxis( + const Math::Vector3& from, + const Math::Vector3& to, + const Math::Vector3& axis) { + const Math::Vector3 normalizedFrom = from.Normalized(); + const Math::Vector3 normalizedTo = to.Normalized(); + const float dot = std::clamp( + Math::Vector3::Dot(normalizedFrom, normalizedTo), + -1.0f, + 1.0f); + const float sine = Math::Vector3::Dot(axis.Normalized(), Math::Vector3::Cross(normalizedFrom, normalizedTo)); + return std::atan2(sine, dot); +} + +float NormalizeSignedAngleRadians(float radians) { + while (radians > Math::PI) { + radians -= Math::PI * 2.0f; + } + + while (radians < -Math::PI) { + radians += Math::PI * 2.0f; + } + + return radians; +} + +SceneViewportRotateGizmoAxis GetRotateAxisForIndex(size_t index) { + switch (index) { + case 0: + return SceneViewportRotateGizmoAxis::X; + case 1: + return SceneViewportRotateGizmoAxis::Y; + case 2: + return SceneViewportRotateGizmoAxis::Z; + case 3: + return SceneViewportRotateGizmoAxis::View; + default: + return SceneViewportRotateGizmoAxis::None; + } +} + +} // namespace + +void SceneViewportRotateGizmo::Update(const SceneViewportRotateGizmoContext& context) { + BuildDrawData(context); + if (m_activeAxis == SceneViewportRotateGizmoAxis::None && IsMouseInsideViewport(context)) { + m_hoveredAxis = HitTestAxis(context.mousePosition); + } else if (m_activeAxis == SceneViewportRotateGizmoAxis::None) { + m_hoveredAxis = SceneViewportRotateGizmoAxis::None; + } else { + m_hoveredAxis = m_activeAxis; + } + + RefreshHandleState(); +} + +bool SceneViewportRotateGizmo::TryBeginDrag(const SceneViewportRotateGizmoContext& context, IUndoManager& undoManager) { + if (m_activeAxis != SceneViewportRotateGizmoAxis::None || + m_hoveredAxis == SceneViewportRotateGizmoAxis::None || + context.selectedObject == nullptr || + !m_drawData.visible || + undoManager.HasPendingInteractiveChange()) { + return false; + } + + const Math::Vector3 pivotWorldPosition = context.selectedObject->GetTransform()->GetPosition(); + const Math::Vector3 worldAxis = GetRotateAxisVector(m_hoveredAxis, context.overlay); + if (worldAxis.SqrMagnitude() <= Math::EPSILON) { + return false; + } + + const Math::Plane dragPlane = BuildSceneViewportPlaneFromPointNormal(pivotWorldPosition, worldAxis); + Math::Vector3 startDirection = Math::Vector3::Zero(); + bool useScreenSpaceDrag = true; + + Math::Ray worldRay; + if (BuildSceneViewportRay( + context.overlay, + context.viewportSize, + context.mousePosition, + worldRay)) { + float hitDistance = 0.0f; + if (worldRay.Intersects(dragPlane, hitDistance)) { + const Math::Vector3 hitPoint = worldRay.GetPoint(hitDistance); + startDirection = Math::Vector3::ProjectOnPlane(hitPoint - pivotWorldPosition, worldAxis); + if (startDirection.SqrMagnitude() > Math::EPSILON) { + useScreenSpaceDrag = false; + } + } + } + + float startRingAngle = 0.0f; + if (useScreenSpaceDrag && + !TryGetClosestRingAngle(m_hoveredAxis, context.mousePosition, false, startRingAngle)) { + return false; + } + + undoManager.BeginInteractiveChange("Rotate Gizmo"); + if (!undoManager.HasPendingInteractiveChange()) { + return false; + } + + m_activeAxis = m_hoveredAxis; + m_activeEntityId = context.selectedObject->GetID(); + m_activeWorldAxis = worldAxis.Normalized(); + m_screenSpaceDrag = useScreenSpaceDrag; + m_dragPlane = dragPlane; + m_dragStartWorldRotation = context.selectedObject->GetTransform()->GetRotation(); + m_dragStartDirectionWorld = useScreenSpaceDrag ? Math::Vector3::Zero() : startDirection.Normalized(); + m_dragStartRingAngle = useScreenSpaceDrag ? startRingAngle : 0.0f; + RefreshHandleState(); + return true; +} + +void SceneViewportRotateGizmo::UpdateDrag(const SceneViewportRotateGizmoContext& context) { + if (m_activeAxis == SceneViewportRotateGizmoAxis::None || + context.selectedObject == nullptr || + context.selectedObject->GetID() != m_activeEntityId) { + return; + } + + float deltaRadians = 0.0f; + if (m_screenSpaceDrag) { + float currentRingAngle = 0.0f; + if (!TryGetClosestRingAngle(m_activeAxis, context.mousePosition, false, currentRingAngle)) { + return; + } + + deltaRadians = NormalizeSignedAngleRadians(currentRingAngle - m_dragStartRingAngle); + } else { + Math::Ray worldRay; + if (!BuildSceneViewportRay( + context.overlay, + context.viewportSize, + context.mousePosition, + worldRay)) { + return; + } + + float hitDistance = 0.0f; + if (!worldRay.Intersects(m_dragPlane, hitDistance)) { + return; + } + + const Math::Vector3 pivotWorldPosition = context.selectedObject->GetTransform()->GetPosition(); + const Math::Vector3 hitPoint = worldRay.GetPoint(hitDistance); + const Math::Vector3 currentDirection = Math::Vector3::ProjectOnPlane(hitPoint - pivotWorldPosition, m_activeWorldAxis); + if (currentDirection.SqrMagnitude() <= Math::EPSILON) { + return; + } + + deltaRadians = SignedAngleRadiansAroundAxis( + m_dragStartDirectionWorld, + currentDirection, + m_activeWorldAxis); + } + + const Math::Quaternion deltaRotation = Math::Quaternion::FromAxisAngle(m_activeWorldAxis, deltaRadians); + context.selectedObject->GetTransform()->SetRotation(deltaRotation * m_dragStartWorldRotation); +} + +void SceneViewportRotateGizmo::EndDrag(IUndoManager& undoManager) { + if (m_activeAxis == SceneViewportRotateGizmoAxis::None) { + return; + } + + if (undoManager.HasPendingInteractiveChange()) { + undoManager.FinalizeInteractiveChange(); + } + + m_activeAxis = SceneViewportRotateGizmoAxis::None; + m_activeEntityId = 0; + m_screenSpaceDrag = false; + m_activeWorldAxis = Math::Vector3::Zero(); + m_dragStartWorldRotation = Math::Quaternion::Identity(); + m_dragStartDirectionWorld = Math::Vector3::Zero(); + m_dragStartRingAngle = 0.0f; + RefreshHandleState(); +} + +void SceneViewportRotateGizmo::CancelDrag(IUndoManager* undoManager) { + if (undoManager != nullptr && undoManager->HasPendingInteractiveChange()) { + undoManager->CancelInteractiveChange(); + } + + m_activeAxis = SceneViewportRotateGizmoAxis::None; + m_activeEntityId = 0; + m_screenSpaceDrag = false; + m_activeWorldAxis = Math::Vector3::Zero(); + m_dragStartWorldRotation = Math::Quaternion::Identity(); + m_dragStartDirectionWorld = Math::Vector3::Zero(); + m_dragStartRingAngle = 0.0f; + m_hoveredAxis = SceneViewportRotateGizmoAxis::None; + RefreshHandleState(); +} + +bool SceneViewportRotateGizmo::IsHoveringHandle() const { + return m_hoveredAxis != SceneViewportRotateGizmoAxis::None; +} + +bool SceneViewportRotateGizmo::IsActive() const { + return m_activeAxis != SceneViewportRotateGizmoAxis::None; +} + +uint64_t SceneViewportRotateGizmo::GetActiveEntityId() const { + return m_activeEntityId; +} + +const SceneViewportRotateGizmoDrawData& SceneViewportRotateGizmo::GetDrawData() const { + return m_drawData; +} + +void SceneViewportRotateGizmo::BuildDrawData(const SceneViewportRotateGizmoContext& context) { + m_drawData = {}; + + const Components::GameObject* selectedObject = context.selectedObject; + if (selectedObject == nullptr || + !context.overlay.valid || + context.viewportSize.x <= 1.0f || + context.viewportSize.y <= 1.0f) { + return; + } + + const Math::Vector3 pivotWorldPosition = selectedObject->GetTransform()->GetPosition(); + const SceneViewportProjectedPoint projectedPivot = ProjectSceneViewportWorldPoint( + context.overlay, + context.viewportSize.x, + context.viewportSize.y, + pivotWorldPosition); + if (!projectedPivot.visible) { + return; + } + + const float worldUnitsPerPixel = ComputeWorldUnitsPerPixel( + context.overlay, + pivotWorldPosition, + context.viewportSize.y); + if (worldUnitsPerPixel <= Math::EPSILON) { + return; + } + + m_drawData.visible = true; + m_drawData.pivot = projectedPivot.screenPosition; + + for (size_t handleIndex = 0; handleIndex < m_drawData.handles.size(); ++handleIndex) { + SceneViewportRotateGizmoHandleDrawData& handle = m_drawData.handles[handleIndex]; + handle.axis = GetRotateAxisForIndex(handleIndex); + handle.color = GetRotateAxisBaseColor(handle.axis); + const float ringRadiusWorld = worldUnitsPerPixel * + (handle.axis == SceneViewportRotateGizmoAxis::View + ? kRotateGizmoViewRadiusPixels + : kRotateGizmoAxisRadiusPixels); + + Math::Vector3 basisA = Math::Vector3::Zero(); + Math::Vector3 basisB = Math::Vector3::Zero(); + if (!GetRotateRingBasis(handle.axis, context.overlay, basisA, basisB)) { + continue; + } + + bool anyVisibleSegment = false; + for (size_t segmentIndex = 0; segmentIndex < handle.segments.size(); ++segmentIndex) { + const float angle0 = static_cast(segmentIndex) / static_cast(handle.segments.size()) * Math::PI * 2.0f; + const float angle1 = static_cast(segmentIndex + 1) / static_cast(handle.segments.size()) * Math::PI * 2.0f; + const float midAngle = (angle0 + angle1) * 0.5f; + + const Math::Vector3 startWorld = + pivotWorldPosition + (basisA * std::cos(angle0) + basisB * std::sin(angle0)) * ringRadiusWorld; + const Math::Vector3 endWorld = + pivotWorldPosition + (basisA * std::cos(angle1) + basisB * std::sin(angle1)) * ringRadiusWorld; + const Math::Vector3 midWorld = + pivotWorldPosition + (basisA * std::cos(midAngle) + basisB * std::sin(midAngle)) * ringRadiusWorld; + + const SceneViewportProjectedPoint projectedStart = ProjectSceneViewportWorldPoint( + context.overlay, + context.viewportSize.x, + context.viewportSize.y, + startWorld); + const SceneViewportProjectedPoint projectedEnd = ProjectSceneViewportWorldPoint( + context.overlay, + context.viewportSize.x, + context.viewportSize.y, + endWorld); + if (projectedStart.ndcDepth < 0.0f || projectedStart.ndcDepth > 1.0f || + projectedEnd.ndcDepth < 0.0f || projectedEnd.ndcDepth > 1.0f) { + continue; + } + + SceneViewportRotateGizmoSegmentDrawData& segment = handle.segments[segmentIndex]; + segment.start = projectedStart.screenPosition; + segment.end = projectedEnd.screenPosition; + segment.startAngle = angle0; + segment.endAngle = angle1; + segment.visible = (segment.end - segment.start).SqrMagnitude() > Math::EPSILON; + if (!segment.visible) { + continue; + } + + anyVisibleSegment = true; + if (handle.axis == SceneViewportRotateGizmoAxis::View) { + segment.frontFacing = true; + } else { + const Math::Vector3 radial = (midWorld - pivotWorldPosition).Normalized(); + segment.frontFacing = Math::Vector3::Dot( + radial, + NormalizeVector3(context.overlay.cameraForward, Math::Vector3::Forward())) < 0.0f; + } + } + + handle.visible = anyVisibleSegment; + } +} + +void SceneViewportRotateGizmo::RefreshHandleState() { + for (SceneViewportRotateGizmoHandleDrawData& handle : m_drawData.handles) { + if (!handle.visible) { + continue; + } + + handle.hovered = handle.axis == m_hoveredAxis; + handle.active = handle.axis == m_activeAxis; + handle.color = (handle.hovered || handle.active) + ? Math::Color::Yellow() + : GetRotateAxisBaseColor(handle.axis); + } +} + +SceneViewportRotateGizmoAxis SceneViewportRotateGizmo::HitTestAxis(const Math::Vector2& mousePosition) const { + if (!m_drawData.visible) { + return SceneViewportRotateGizmoAxis::None; + } + + const float hoverThresholdSq = kRotateGizmoHoverThresholdPixels * kRotateGizmoHoverThresholdPixels; + SceneViewportRotateGizmoAxis bestAxis = SceneViewportRotateGizmoAxis::None; + float bestDistanceSq = hoverThresholdSq; + + for (const SceneViewportRotateGizmoHandleDrawData& handle : m_drawData.handles) { + if (!handle.visible) { + continue; + } + + for (const SceneViewportRotateGizmoSegmentDrawData& segment : handle.segments) { + if (!segment.visible || + (handle.axis != SceneViewportRotateGizmoAxis::View && !segment.frontFacing)) { + continue; + } + + const float distanceSq = DistanceToSegmentSquared(mousePosition, segment.start, segment.end); + if (distanceSq > bestDistanceSq) { + continue; + } + + bestDistanceSq = distanceSq; + bestAxis = handle.axis; + } + } + + return bestAxis; +} + +bool SceneViewportRotateGizmo::TryGetClosestRingAngle( + SceneViewportRotateGizmoAxis axis, + const Math::Vector2& mousePosition, + bool allowBackFacing, + float& outAngle) const { + if (!m_drawData.visible || axis == SceneViewportRotateGizmoAxis::None) { + return false; + } + + const SceneViewportRotateGizmoHandleDrawData* targetHandle = nullptr; + for (const SceneViewportRotateGizmoHandleDrawData& handle : m_drawData.handles) { + if (handle.axis == axis && handle.visible) { + targetHandle = &handle; + break; + } + } + + if (targetHandle == nullptr) { + return false; + } + + const bool isViewHandle = axis == SceneViewportRotateGizmoAxis::View; + const SceneViewportRotateGizmoSegmentDrawData* bestSegment = nullptr; + float bestSegmentT = 0.0f; + float bestDistanceSq = Math::FLOAT_MAX; + + for (const SceneViewportRotateGizmoSegmentDrawData& segment : targetHandle->segments) { + if (!segment.visible || (!isViewHandle && !allowBackFacing && !segment.frontFacing)) { + continue; + } + + float segmentT = 0.0f; + const float distanceSq = DistanceToSegmentSquared(mousePosition, segment.start, segment.end, &segmentT); + if (distanceSq >= bestDistanceSq) { + continue; + } + + bestDistanceSq = distanceSq; + bestSegment = &segment; + bestSegmentT = segmentT; + } + + if (bestSegment == nullptr) { + return false; + } + + outAngle = bestSegment->startAngle + (bestSegment->endAngle - bestSegment->startAngle) * bestSegmentT; + return true; +} + +} // namespace Editor +} // namespace XCEngine diff --git a/editor/src/Viewport/SceneViewportRotateGizmo.h b/editor/src/Viewport/SceneViewportRotateGizmo.h new file mode 100644 index 00000000..832518ce --- /dev/null +++ b/editor/src/Viewport/SceneViewportRotateGizmo.h @@ -0,0 +1,100 @@ +#pragma once + +#include "IViewportHostService.h" + +#include +#include +#include +#include +#include + +#include +#include + +namespace XCEngine { +namespace Components { +class GameObject; +} // namespace Components + +namespace Editor { + +class IUndoManager; + +enum class SceneViewportRotateGizmoAxis : uint8_t { + None = 0, + X, + Y, + Z, + View +}; + +constexpr size_t kSceneViewportRotateGizmoSegmentCount = 96; + +struct SceneViewportRotateGizmoSegmentDrawData { + Math::Vector2 start = Math::Vector2::Zero(); + Math::Vector2 end = Math::Vector2::Zero(); + float startAngle = 0.0f; + float endAngle = 0.0f; + bool visible = false; + bool frontFacing = true; +}; + +struct SceneViewportRotateGizmoHandleDrawData { + SceneViewportRotateGizmoAxis axis = SceneViewportRotateGizmoAxis::None; + std::array segments = {}; + Math::Color color = Math::Color::White(); + bool visible = false; + bool hovered = false; + bool active = false; +}; + +struct SceneViewportRotateGizmoDrawData { + bool visible = false; + Math::Vector2 pivot = Math::Vector2::Zero(); + std::array handles = {}; +}; + +struct SceneViewportRotateGizmoContext { + SceneViewportOverlayData overlay = {}; + Math::Vector2 viewportSize = Math::Vector2::Zero(); + Math::Vector2 mousePosition = Math::Vector2::Zero(); + Components::GameObject* selectedObject = nullptr; +}; + +class SceneViewportRotateGizmo { +public: + void Update(const SceneViewportRotateGizmoContext& context); + bool TryBeginDrag(const SceneViewportRotateGizmoContext& context, IUndoManager& undoManager); + void UpdateDrag(const SceneViewportRotateGizmoContext& context); + void EndDrag(IUndoManager& undoManager); + void CancelDrag(IUndoManager* undoManager = nullptr); + + bool IsHoveringHandle() const; + bool IsActive() const; + uint64_t GetActiveEntityId() const; + const SceneViewportRotateGizmoDrawData& GetDrawData() const; + +private: + void BuildDrawData(const SceneViewportRotateGizmoContext& context); + void RefreshHandleState(); + SceneViewportRotateGizmoAxis HitTestAxis(const Math::Vector2& mousePosition) const; + bool TryGetClosestRingAngle( + SceneViewportRotateGizmoAxis axis, + const Math::Vector2& mousePosition, + bool allowBackFacing, + float& outAngle) const; + + SceneViewportRotateGizmoDrawData m_drawData = {}; + SceneViewportRotateGizmoAxis m_hoveredAxis = SceneViewportRotateGizmoAxis::None; + SceneViewportRotateGizmoAxis m_activeAxis = SceneViewportRotateGizmoAxis::None; + uint64_t m_activeEntityId = 0; + bool m_screenSpaceDrag = false; + Math::Vector3 m_activeWorldAxis = Math::Vector3::Zero(); + Math::Plane m_dragPlane = {}; + Math::Quaternion m_dragStartWorldRotation = Math::Quaternion::Identity(); + Math::Vector3 m_dragStartDirectionWorld = Math::Vector3::Zero(); + float m_dragStartRingAngle = 0.0f; +}; + +} // namespace Editor +} // namespace XCEngine diff --git a/editor/src/panels/SceneViewPanel.cpp b/editor/src/panels/SceneViewPanel.cpp index 045f30c0..16aca034 100644 --- a/editor/src/panels/SceneViewPanel.cpp +++ b/editor/src/panels/SceneViewPanel.cpp @@ -37,6 +37,28 @@ SceneViewportMoveGizmoContext BuildMoveGizmoContext( return gizmoContext; } +SceneViewportRotateGizmoContext BuildRotateGizmoContext( + IEditorContext& context, + const SceneViewportOverlayData& overlay, + const ViewportPanelContentResult& content, + const ImVec2& mousePosition) { + SceneViewportRotateGizmoContext gizmoContext = {}; + gizmoContext.overlay = overlay; + gizmoContext.viewportSize = Math::Vector2(content.availableSize.x, content.availableSize.y); + gizmoContext.mousePosition = Math::Vector2( + mousePosition.x - content.itemMin.x, + mousePosition.y - content.itemMin.y); + + if (context.GetSelectionManager().GetSelectionCount() == 1) { + const uint64_t selectedEntity = context.GetSelectionManager().GetSelectedEntity(); + if (selectedEntity != 0) { + gizmoContext.selectedObject = context.GetSceneManager().GetEntity(selectedEntity); + } + } + + return gizmoContext; +} + } // namespace SceneViewPanel::SceneViewPanel() : Panel("Scene") {} @@ -53,36 +75,78 @@ void SceneViewPanel::Render() { if (IViewportHostService* viewportHostService = m_context->GetViewportHostService()) { const ImGuiIO& io = ImGui::GetIO(); const bool hasInteractiveViewport = content.hasViewportArea && content.frame.hasTexture; + + if (content.focused && !io.WantTextInput && !m_lookDragging && !m_panDragging) { + if (ImGui::IsKeyPressed(ImGuiKey_W, false)) { + if (m_rotateGizmo.IsActive()) { + m_rotateGizmo.CancelDrag(&m_context->GetUndoManager()); + } + m_transformTool = SceneViewportTransformTool::Move; + } else if (ImGui::IsKeyPressed(ImGuiKey_E, false)) { + if (m_moveGizmo.IsActive()) { + m_moveGizmo.CancelDrag(&m_context->GetUndoManager()); + } + m_transformTool = SceneViewportTransformTool::Rotate; + } + } + + const bool usingMoveGizmo = m_transformTool == SceneViewportTransformTool::Move; SceneViewportOverlayData overlay = {}; SceneViewportMoveGizmoContext moveGizmoContext = {}; + SceneViewportRotateGizmoContext rotateGizmoContext = {}; if (hasInteractiveViewport) { overlay = viewportHostService->GetSceneViewOverlayData(); - moveGizmoContext = BuildMoveGizmoContext(*m_context, overlay, content, io.MousePos); - if (m_moveGizmo.IsActive() && - (moveGizmoContext.selectedObject == nullptr || - m_context->GetSelectionManager().GetSelectedEntity() != m_moveGizmo.GetActiveEntityId())) { + if (usingMoveGizmo) { + if (m_rotateGizmo.IsActive()) { + m_rotateGizmo.CancelDrag(&m_context->GetUndoManager()); + } + + moveGizmoContext = BuildMoveGizmoContext(*m_context, overlay, content, io.MousePos); + if (m_moveGizmo.IsActive() && + (moveGizmoContext.selectedObject == nullptr || + m_context->GetSelectionManager().GetSelectedEntity() != m_moveGizmo.GetActiveEntityId())) { + m_moveGizmo.CancelDrag(&m_context->GetUndoManager()); + } + m_moveGizmo.Update(moveGizmoContext); + } else { + if (m_moveGizmo.IsActive()) { + m_moveGizmo.CancelDrag(&m_context->GetUndoManager()); + } + + rotateGizmoContext = BuildRotateGizmoContext(*m_context, overlay, content, io.MousePos); + if (m_rotateGizmo.IsActive() && + (rotateGizmoContext.selectedObject == nullptr || + m_context->GetSelectionManager().GetSelectedEntity() != m_rotateGizmo.GetActiveEntityId())) { + m_rotateGizmo.CancelDrag(&m_context->GetUndoManager()); + } + m_rotateGizmo.Update(rotateGizmoContext); + } + } else { + if (m_moveGizmo.IsActive()) { m_moveGizmo.CancelDrag(&m_context->GetUndoManager()); } - m_moveGizmo.Update(moveGizmoContext); - } else if (m_moveGizmo.IsActive()) { - m_moveGizmo.CancelDrag(&m_context->GetUndoManager()); + if (m_rotateGizmo.IsActive()) { + m_rotateGizmo.CancelDrag(&m_context->GetUndoManager()); + } } - const bool beginMoveGizmo = + const bool gizmoHovering = usingMoveGizmo ? m_moveGizmo.IsHoveringHandle() : m_rotateGizmo.IsHoveringHandle(); + const bool gizmoActive = usingMoveGizmo ? m_moveGizmo.IsActive() : m_rotateGizmo.IsActive(); + + const bool beginTransformGizmo = hasInteractiveViewport && - content.hovered && - ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + content.clickedLeft && !m_lookDragging && !m_panDragging && - m_moveGizmo.IsHoveringHandle(); + gizmoHovering; const SceneViewportOrientationAxis orientationAxisHit = hasInteractiveViewport && content.hovered && !m_lookDragging && !m_panDragging && - !m_moveGizmo.IsHoveringHandle() && - !m_moveGizmo.IsActive() + !gizmoHovering && + !gizmoActive ? HitTestSceneViewportOrientationGizmo( overlay, content.itemMin, @@ -91,45 +155,52 @@ void SceneViewPanel::Render() { : SceneViewportOrientationAxis::None; const bool orientationGizmoClick = hasInteractiveViewport && - content.hovered && - ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + content.clickedLeft && orientationAxisHit != SceneViewportOrientationAxis::None; const bool selectClick = hasInteractiveViewport && - content.hovered && - ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + content.clickedLeft && !m_lookDragging && !m_panDragging && !orientationGizmoClick && - !m_moveGizmo.IsHoveringHandle() && - !m_moveGizmo.IsActive(); + !gizmoHovering && + !gizmoActive; const bool beginLookDrag = hasInteractiveViewport && content.hovered && !m_lookDragging && - !m_moveGizmo.IsActive() && + !gizmoActive && ImGui::IsMouseClicked(ImGuiMouseButton_Right); const bool beginPanDrag = hasInteractiveViewport && content.hovered && !m_panDragging && - !m_moveGizmo.IsActive() && + !gizmoActive && !m_lookDragging && ImGui::IsMouseClicked(ImGuiMouseButton_Middle); - if (beginMoveGizmo || orientationGizmoClick || selectClick || beginLookDrag || beginPanDrag) { + if (beginTransformGizmo || orientationGizmoClick || selectClick || beginLookDrag || beginPanDrag) { ImGui::SetWindowFocus(); } - if (beginMoveGizmo) { - m_moveGizmo.TryBeginDrag(moveGizmoContext, m_context->GetUndoManager()); + if (beginTransformGizmo) { + if (usingMoveGizmo) { + m_moveGizmo.TryBeginDrag(moveGizmoContext, m_context->GetUndoManager()); + } else { + m_rotateGizmo.TryBeginDrag(rotateGizmoContext, m_context->GetUndoManager()); + } } if (orientationGizmoClick) { viewportHostService->AlignSceneViewToOrientationAxis(orientationAxisHit); overlay = viewportHostService->GetSceneViewOverlayData(); - moveGizmoContext = BuildMoveGizmoContext(*m_context, overlay, content, io.MousePos); - m_moveGizmo.Update(moveGizmoContext); + if (usingMoveGizmo) { + moveGizmoContext = BuildMoveGizmoContext(*m_context, overlay, content, io.MousePos); + m_moveGizmo.Update(moveGizmoContext); + } else { + rotateGizmoContext = BuildRotateGizmoContext(*m_context, overlay, content, io.MousePos); + m_rotateGizmo.Update(rotateGizmoContext); + } } if (selectClick) { @@ -147,11 +218,19 @@ void SceneViewPanel::Render() { } } - if (m_moveGizmo.IsActive()) { + if (gizmoActive) { if (ImGui::IsMouseDown(ImGuiMouseButton_Left)) { - m_moveGizmo.UpdateDrag(moveGizmoContext); + if (usingMoveGizmo) { + m_moveGizmo.UpdateDrag(moveGizmoContext); + } else { + m_rotateGizmo.UpdateDrag(rotateGizmoContext); + } } else { - m_moveGizmo.EndDrag(m_context->GetUndoManager()); + if (usingMoveGizmo) { + m_moveGizmo.EndDrag(m_context->GetUndoManager()); + } else { + m_rotateGizmo.EndDrag(m_context->GetUndoManager()); + } } } @@ -177,7 +256,7 @@ void SceneViewPanel::Render() { m_lastPanDragDelta = ImVec2(0.0f, 0.0f); } - if (m_lookDragging || m_panDragging || m_moveGizmo.IsActive()) { + if (m_lookDragging || m_panDragging || m_moveGizmo.IsActive() || m_rotateGizmo.IsActive()) { ImGui::SetNextFrameWantCaptureMouse(true); } if (m_lookDragging) { @@ -222,8 +301,16 @@ void SceneViewPanel::Render() { if (m_panDragging) { const ImVec2 panDragDelta = ImGui::GetMouseDragDelta(ImGuiMouseButton_Middle, 0.0f); - input.mouseDelta.x += panDragDelta.x - m_lastPanDragDelta.x; - input.mouseDelta.y += panDragDelta.y - m_lastPanDragDelta.y; + ImVec2 framePanDelta( + panDragDelta.x - m_lastPanDragDelta.x, + panDragDelta.y - m_lastPanDragDelta.y); + // Some middle-button drags report a zero drag delta on the interaction surface. + if ((framePanDelta.x == 0.0f && framePanDelta.y == 0.0f) && + (io.MouseDelta.x != 0.0f || io.MouseDelta.y != 0.0f)) { + framePanDelta = io.MouseDelta; + } + input.mouseDelta.x += framePanDelta.x; + input.mouseDelta.y += framePanDelta.y; m_lastPanDragDelta = panDragDelta; } else { m_lastPanDragDelta = ImVec2(0.0f, 0.0f); @@ -234,15 +321,22 @@ void SceneViewPanel::Render() { if (content.hasViewportArea && content.frame.hasTexture) { overlay = viewportHostService->GetSceneViewOverlayData(); - moveGizmoContext = BuildMoveGizmoContext(*m_context, overlay, content, io.MousePos); - m_moveGizmo.Update(moveGizmoContext); + if (usingMoveGizmo) { + moveGizmoContext = BuildMoveGizmoContext(*m_context, overlay, content, io.MousePos); + m_moveGizmo.Update(moveGizmoContext); + } else { + rotateGizmoContext = BuildRotateGizmoContext(*m_context, overlay, content, io.MousePos); + m_rotateGizmo.Update(rotateGizmoContext); + } + DrawSceneViewportOverlay( ImGui::GetWindowDrawList(), overlay, content.itemMin, content.itemMax, content.availableSize, - &m_moveGizmo.GetDrawData()); + usingMoveGizmo ? &m_moveGizmo.GetDrawData() : nullptr, + usingMoveGizmo ? nullptr : &m_rotateGizmo.GetDrawData()); } } diff --git a/editor/src/panels/SceneViewPanel.h b/editor/src/panels/SceneViewPanel.h index 46f4eb42..1f964a63 100644 --- a/editor/src/panels/SceneViewPanel.h +++ b/editor/src/panels/SceneViewPanel.h @@ -2,23 +2,31 @@ #include "Panel.h" #include "Viewport/SceneViewportMoveGizmo.h" +#include "Viewport/SceneViewportRotateGizmo.h" #include namespace XCEngine { namespace Editor { +enum class SceneViewportTransformTool : uint8_t { + Move = 0, + Rotate +}; + class SceneViewPanel : public Panel { public: SceneViewPanel(); void Render() override; private: + SceneViewportTransformTool m_transformTool = SceneViewportTransformTool::Move; bool m_lookDragging = false; bool m_panDragging = false; ImVec2 m_lastLookDragDelta = ImVec2(0.0f, 0.0f); ImVec2 m_lastPanDragDelta = ImVec2(0.0f, 0.0f); SceneViewportMoveGizmo m_moveGizmo; + SceneViewportRotateGizmo m_rotateGizmo; }; } diff --git a/tests/editor/test_scene_viewport_rotate_gizmo.cpp b/tests/editor/test_scene_viewport_rotate_gizmo.cpp new file mode 100644 index 00000000..99aff85f --- /dev/null +++ b/tests/editor/test_scene_viewport_rotate_gizmo.cpp @@ -0,0 +1,277 @@ +#include + +#include + +#include "Core/EditorContext.h" +#include "Managers/SceneManager.h" +#include "Viewport/SceneViewportRotateGizmo.h" + +namespace XCEngine::Editor { +namespace { + +Math::Vector2 SegmentMidpoint(const SceneViewportRotateGizmoSegmentDrawData& segment) { + return (segment.start + segment.end) * 0.5f; +} + +const SceneViewportRotateGizmoHandleDrawData* FindHandle( + const SceneViewportRotateGizmoDrawData& drawData, + SceneViewportRotateGizmoAxis axis) { + for (const SceneViewportRotateGizmoHandleDrawData& handle : drawData.handles) { + if (handle.axis == axis) { + return &handle; + } + } + + return nullptr; +} + +const SceneViewportRotateGizmoSegmentDrawData* FindVisibleSegment( + const SceneViewportRotateGizmoHandleDrawData& handle, + bool frontOnly) { + for (const SceneViewportRotateGizmoSegmentDrawData& segment : handle.segments) { + if (!segment.visible) { + continue; + } + if (frontOnly && !segment.frontFacing) { + continue; + } + + return &segment; + } + + return nullptr; +} + +const SceneViewportRotateGizmoSegmentDrawData* FindLongestVisibleSegment( + const SceneViewportRotateGizmoHandleDrawData& handle, + bool frontOnly) { + const SceneViewportRotateGizmoSegmentDrawData* bestSegment = nullptr; + float bestLengthSq = -1.0f; + for (const SceneViewportRotateGizmoSegmentDrawData& segment : handle.segments) { + if (!segment.visible) { + continue; + } + if (frontOnly && !segment.frontFacing) { + continue; + } + + const float lengthSq = (segment.end - segment.start).SqrMagnitude(); + if (lengthSq <= bestLengthSq) { + continue; + } + + bestLengthSq = lengthSq; + bestSegment = &segment; + } + + return bestSegment; +} + +const SceneViewportRotateGizmoSegmentDrawData* FindFarthestVisibleSegment( + const SceneViewportRotateGizmoHandleDrawData& handle, + const Math::Vector2& fromPoint, + bool frontOnly) { + const SceneViewportRotateGizmoSegmentDrawData* bestSegment = nullptr; + float bestDistanceSq = -1.0f; + for (const SceneViewportRotateGizmoSegmentDrawData& segment : handle.segments) { + if (!segment.visible) { + continue; + } + if (frontOnly && !segment.frontFacing) { + continue; + } + + const float distanceSq = (SegmentMidpoint(segment) - fromPoint).SqrMagnitude(); + if (distanceSq <= bestDistanceSq) { + continue; + } + + bestDistanceSq = distanceSq; + bestSegment = &segment; + } + + return bestSegment; +} + +class SceneViewportRotateGizmoTest : public ::testing::Test { +protected: + void SetUp() override { + m_context.GetSceneManager().NewScene("Rotate Gizmo Test Scene"); + } + + static SceneViewportOverlayData MakeOverlay() { + SceneViewportOverlayData overlay = {}; + overlay.valid = true; + overlay.cameraPosition = Math::Vector3(0.0f, 0.0f, -5.0f); + overlay.cameraForward = Math::Vector3::Forward(); + overlay.cameraRight = Math::Vector3::Right(); + overlay.cameraUp = Math::Vector3::Up(); + overlay.verticalFovDegrees = 60.0f; + overlay.nearClipPlane = 0.03f; + overlay.farClipPlane = 2000.0f; + return overlay; + } + + static SceneViewportOverlayData MakeIsometricOverlay() { + SceneViewportOverlayData overlay = {}; + overlay.valid = true; + overlay.cameraPosition = Math::Vector3(-5.0f, 5.0f, -5.0f); + overlay.cameraForward = (Math::Vector3::Zero() - overlay.cameraPosition).Normalized(); + overlay.cameraRight = Math::Vector3::Cross(Math::Vector3::Up(), overlay.cameraForward).Normalized(); + overlay.cameraUp = Math::Vector3::Cross(overlay.cameraForward, overlay.cameraRight).Normalized(); + overlay.verticalFovDegrees = 60.0f; + overlay.nearClipPlane = 0.03f; + overlay.farClipPlane = 2000.0f; + return overlay; + } + + static SceneViewportRotateGizmoContext MakeContext( + Components::GameObject* selectedObject, + const Math::Vector2& mousePosition) { + SceneViewportRotateGizmoContext context = {}; + context.overlay = MakeOverlay(); + context.viewportSize = Math::Vector2(800.0f, 600.0f); + context.mousePosition = mousePosition; + context.selectedObject = selectedObject; + return context; + } + + static SceneViewportRotateGizmoContext MakeContext( + Components::GameObject* selectedObject, + const Math::Vector2& mousePosition, + const SceneViewportOverlayData& overlay) { + SceneViewportRotateGizmoContext context = {}; + context.overlay = overlay; + context.viewportSize = Math::Vector2(800.0f, 600.0f); + context.mousePosition = mousePosition; + context.selectedObject = selectedObject; + return context; + } + + SceneManager& GetSceneManager() { + return dynamic_cast(m_context.GetSceneManager()); + } + + EditorContext m_context; +}; + +TEST_F(SceneViewportRotateGizmoTest, UpdateHighlightsXAxisWhenMouseIsNearVisibleXAxisRing) { + Components::GameObject* target = GetSceneManager().CreateEntity("Target"); + ASSERT_NE(target, nullptr); + + SceneViewportRotateGizmo gizmo; + gizmo.Update(MakeContext(target, Math::Vector2(400.0f, 300.0f))); + + ASSERT_TRUE(gizmo.GetDrawData().visible); + const SceneViewportRotateGizmoHandleDrawData* xHandle = + FindHandle(gizmo.GetDrawData(), SceneViewportRotateGizmoAxis::X); + ASSERT_NE(xHandle, nullptr); + const SceneViewportRotateGizmoSegmentDrawData* xSegment = FindLongestVisibleSegment(*xHandle, true); + ASSERT_NE(xSegment, nullptr); + + gizmo.Update(MakeContext(target, SegmentMidpoint(*xSegment))); + + EXPECT_TRUE(gizmo.IsHoveringHandle()); + EXPECT_TRUE(FindHandle(gizmo.GetDrawData(), SceneViewportRotateGizmoAxis::X)->hovered); +} + +TEST_F(SceneViewportRotateGizmoTest, DraggingXAxisRotatesAroundWorldXAndCreatesUndoStep) { + Components::GameObject* target = GetSceneManager().CreateEntity("Target"); + ASSERT_NE(target, nullptr); + const uint64_t targetId = target->GetID(); + + SceneViewportRotateGizmo gizmo; + const SceneViewportOverlayData overlay = MakeIsometricOverlay(); + gizmo.Update(MakeContext(target, Math::Vector2(400.0f, 300.0f), overlay)); + + const SceneViewportRotateGizmoHandleDrawData* xHandle = + FindHandle(gizmo.GetDrawData(), SceneViewportRotateGizmoAxis::X); + ASSERT_NE(xHandle, nullptr); + const SceneViewportRotateGizmoSegmentDrawData* startSegment = FindLongestVisibleSegment(*xHandle, true); + ASSERT_NE(startSegment, nullptr); + const Math::Vector2 startMouse = SegmentMidpoint(*startSegment); + const SceneViewportRotateGizmoSegmentDrawData* endSegment = FindFarthestVisibleSegment(*xHandle, startMouse, true); + ASSERT_NE(endSegment, nullptr); + + const auto startContext = MakeContext(target, startMouse, overlay); + gizmo.Update(startContext); + ASSERT_TRUE(gizmo.IsHoveringHandle()); + ASSERT_TRUE(gizmo.TryBeginDrag(startContext, m_context.GetUndoManager())); + ASSERT_TRUE(gizmo.IsActive()); + + const auto dragContext = MakeContext(target, SegmentMidpoint(*endSegment), overlay); + gizmo.Update(dragContext); + gizmo.UpdateDrag(dragContext); + gizmo.EndDrag(m_context.GetUndoManager()); + + const Math::Vector3 rotatedRight = target->GetTransform()->GetRight(); + const Math::Vector3 rotatedForward = target->GetTransform()->GetForward(); + EXPECT_NEAR(rotatedRight.x, 1.0f, 1e-3f); + EXPECT_NEAR(rotatedRight.y, 0.0f, 1e-3f); + EXPECT_NEAR(rotatedRight.z, 0.0f, 1e-3f); + EXPECT_GT(std::abs(rotatedForward.y), 0.05f); + EXPECT_TRUE(m_context.GetUndoManager().CanUndo()); + + m_context.GetUndoManager().Undo(); + Components::GameObject* restoredTarget = GetSceneManager().GetEntity(targetId); + ASSERT_NE(restoredTarget, nullptr); + const Math::Vector3 restoredForward = restoredTarget->GetTransform()->GetForward(); + EXPECT_NEAR(restoredForward.x, 0.0f, 1e-4f); + EXPECT_NEAR(restoredForward.y, 0.0f, 1e-4f); + EXPECT_NEAR(restoredForward.z, 1.0f, 1e-4f); +} + +TEST_F(SceneViewportRotateGizmoTest, DraggingEdgeOnXAxisFallsBackToScreenSpaceRotation) { + Components::GameObject* target = GetSceneManager().CreateEntity("Target"); + ASSERT_NE(target, nullptr); + + SceneViewportRotateGizmo gizmo; + gizmo.Update(MakeContext(target, Math::Vector2(400.0f, 300.0f))); + + const SceneViewportRotateGizmoHandleDrawData* xHandle = + FindHandle(gizmo.GetDrawData(), SceneViewportRotateGizmoAxis::X); + ASSERT_NE(xHandle, nullptr); + const SceneViewportRotateGizmoSegmentDrawData* startSegment = FindLongestVisibleSegment(*xHandle, true); + ASSERT_NE(startSegment, nullptr); + const Math::Vector2 startMouse = SegmentMidpoint(*startSegment); + const SceneViewportRotateGizmoSegmentDrawData* endSegment = FindFarthestVisibleSegment(*xHandle, startMouse, true); + ASSERT_NE(endSegment, nullptr); + + const auto startContext = MakeContext(target, startMouse); + gizmo.Update(startContext); + ASSERT_TRUE(gizmo.IsHoveringHandle()); + ASSERT_TRUE(gizmo.TryBeginDrag(startContext, m_context.GetUndoManager())); + ASSERT_TRUE(gizmo.IsActive()); + + const auto dragContext = MakeContext(target, SegmentMidpoint(*endSegment)); + gizmo.Update(dragContext); + gizmo.UpdateDrag(dragContext); + gizmo.EndDrag(m_context.GetUndoManager()); + + const Math::Vector3 rotatedForward = target->GetTransform()->GetForward(); + EXPECT_GT(std::abs(rotatedForward.y), 0.05f); + EXPECT_TRUE(m_context.GetUndoManager().CanUndo()); +} + +TEST_F(SceneViewportRotateGizmoTest, ViewRingIsVisibleAndHoverable) { + Components::GameObject* target = GetSceneManager().CreateEntity("Target"); + ASSERT_NE(target, nullptr); + + SceneViewportRotateGizmo gizmo; + gizmo.Update(MakeContext(target, Math::Vector2(400.0f, 300.0f))); + + ASSERT_TRUE(gizmo.GetDrawData().visible); + const SceneViewportRotateGizmoHandleDrawData* viewHandle = + FindHandle(gizmo.GetDrawData(), SceneViewportRotateGizmoAxis::View); + ASSERT_NE(viewHandle, nullptr); + const SceneViewportRotateGizmoSegmentDrawData* viewSegment = FindLongestVisibleSegment(*viewHandle, false); + ASSERT_NE(viewSegment, nullptr); + + gizmo.Update(MakeContext(target, SegmentMidpoint(*viewSegment))); + + EXPECT_TRUE(gizmo.IsHoveringHandle()); + EXPECT_TRUE(FindHandle(gizmo.GetDrawData(), SceneViewportRotateGizmoAxis::View)->hovered); +} + +} // namespace +} // namespace XCEngine::Editor