diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 302925cf..baf5bd4c 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -79,6 +79,7 @@ add_executable(${PROJECT_NAME} WIN32 src/panels/HierarchyPanel.cpp src/panels/SceneViewPanel.cpp src/Viewport/SceneViewportPicker.cpp + src/Viewport/SceneViewportMoveGizmo.cpp src/Viewport/SceneViewportGrid.cpp src/Viewport/SceneViewportInfiniteGridPass.cpp src/Viewport/SceneViewportSelectionMaskPass.cpp diff --git a/editor/src/Viewport/SceneViewportMath.h b/editor/src/Viewport/SceneViewportMath.h index 1ca7d6e7..c4e98831 100644 --- a/editor/src/Viewport/SceneViewportMath.h +++ b/editor/src/Viewport/SceneViewportMath.h @@ -3,10 +3,19 @@ #include "IViewportHostService.h" #include +#include +#include +#include namespace XCEngine { namespace Editor { +struct SceneViewportProjectedPoint { + Math::Vector2 screenPosition = Math::Vector2::Zero(); + float ndcDepth = 0.0f; + bool visible = false; +}; + inline Math::Matrix4x4 BuildSceneViewportViewMatrix(const SceneViewportOverlayData& overlay) { const Math::Vector3 right = overlay.cameraRight.Normalized(); const Math::Vector3 up = overlay.cameraUp.Normalized(); @@ -44,5 +53,122 @@ inline Math::Matrix4x4 BuildSceneViewportProjectionMatrix( overlay.farClipPlane); } +inline Math::Matrix4x4 BuildSceneViewportViewProjectionMatrix( + const SceneViewportOverlayData& overlay, + float viewportWidth, + float viewportHeight) { + return BuildSceneViewportProjectionMatrix(overlay, viewportWidth, viewportHeight) * + BuildSceneViewportViewMatrix(overlay); +} + +inline SceneViewportProjectedPoint ProjectSceneViewportWorldPoint( + const SceneViewportOverlayData& overlay, + float viewportWidth, + float viewportHeight, + const Math::Vector3& worldPoint) { + SceneViewportProjectedPoint result = {}; + if (!overlay.valid || viewportWidth <= 1.0f || viewportHeight <= 1.0f) { + return result; + } + + const Math::Vector4 clipPoint = + BuildSceneViewportViewProjectionMatrix(overlay, viewportWidth, viewportHeight) * + Math::Vector4(worldPoint, 1.0f); + if (clipPoint.w <= Math::EPSILON) { + return result; + } + + const Math::Vector3 ndcPoint = clipPoint.ToVector3() / clipPoint.w; + result.screenPosition.x = (ndcPoint.x * 0.5f + 0.5f) * viewportWidth; + result.screenPosition.y = (1.0f - (ndcPoint.y * 0.5f + 0.5f)) * viewportHeight; + result.ndcDepth = ndcPoint.z; + result.visible = + ndcPoint.x >= -1.0f && ndcPoint.x <= 1.0f && + ndcPoint.y >= -1.0f && ndcPoint.y <= 1.0f && + ndcPoint.z >= 0.0f && ndcPoint.z <= 1.0f; + return result; +} + +inline bool ProjectSceneViewportAxisDirection( + const SceneViewportOverlayData& overlay, + const Math::Vector3& worldAxis, + Math::Vector2& outScreenDirection) { + if (!overlay.valid) { + return false; + } + + const Math::Vector3 viewAxis = BuildSceneViewportViewMatrix(overlay).MultiplyVector(worldAxis.Normalized()); + const Math::Vector2 screenDirection(viewAxis.x, -viewAxis.y); + if (screenDirection.SqrMagnitude() <= Math::EPSILON) { + return false; + } + + outScreenDirection = screenDirection.Normalized(); + return true; +} + +inline float DistanceToSegmentSquared( + const Math::Vector2& point, + const Math::Vector2& segmentStart, + const Math::Vector2& segmentEnd, + float* outSegmentT = nullptr) { + const Math::Vector2 segment = segmentEnd - segmentStart; + const float segmentLengthSq = segment.SqrMagnitude(); + if (segmentLengthSq <= Math::EPSILON) { + if (outSegmentT != nullptr) { + *outSegmentT = 0.0f; + } + return (point - segmentStart).SqrMagnitude(); + } + + const float segmentT = std::clamp( + Math::Vector2::Dot(point - segmentStart, segment) / segmentLengthSq, + 0.0f, + 1.0f); + if (outSegmentT != nullptr) { + *outSegmentT = segmentT; + } + + const Math::Vector2 closestPoint = segmentStart + segment * segmentT; + return (point - closestPoint).SqrMagnitude(); +} + +inline Math::Plane BuildSceneViewportPlaneFromPointNormal( + const Math::Vector3& point, + const Math::Vector3& normal) { + const Math::Vector3 planeNormal = normal.Normalized(); + return Math::Plane(planeNormal, -Math::Vector3::Dot(planeNormal, point)); +} + +inline bool BuildSceneViewportAxisDragPlaneNormal( + const SceneViewportOverlayData& overlay, + const Math::Vector3& worldAxis, + Math::Vector3& outPlaneNormal) { + if (!overlay.valid || worldAxis.SqrMagnitude() <= Math::EPSILON) { + return false; + } + + const Math::Vector3 axis = worldAxis.Normalized(); + const Math::Vector3 candidates[] = { + Math::Vector3::ProjectOnPlane(overlay.cameraForward.Normalized(), axis), + Math::Vector3::ProjectOnPlane(overlay.cameraUp.Normalized(), axis), + Math::Vector3::ProjectOnPlane(overlay.cameraRight.Normalized(), axis), + Math::Vector3::ProjectOnPlane(Math::Vector3::Up(), axis), + Math::Vector3::ProjectOnPlane(Math::Vector3::Right(), axis), + Math::Vector3::ProjectOnPlane(Math::Vector3::Forward(), axis) + }; + + for (const Math::Vector3& candidate : candidates) { + if (candidate.SqrMagnitude() <= Math::EPSILON) { + continue; + } + + outPlaneNormal = candidate.Normalized(); + return true; + } + + return false; +} + } // namespace Editor } // namespace XCEngine diff --git a/editor/src/Viewport/SceneViewportMoveGizmo.cpp b/editor/src/Viewport/SceneViewportMoveGizmo.cpp new file mode 100644 index 00000000..aac3fb4e --- /dev/null +++ b/editor/src/Viewport/SceneViewportMoveGizmo.cpp @@ -0,0 +1,275 @@ +#include "SceneViewportMoveGizmo.h" + +#include "Core/IUndoManager.h" +#include "SceneViewportMath.h" +#include "SceneViewportPicker.h" + +#include + +namespace XCEngine { +namespace Editor { + +namespace { + +constexpr float kMoveGizmoHandleLengthPixels = 72.0f; +constexpr float kMoveGizmoHoverThresholdPixels = 10.0f; + +Math::Vector3 GetAxisVector(SceneViewportGizmoAxis axis) { + switch (axis) { + case SceneViewportGizmoAxis::X: + return Math::Vector3::Right(); + case SceneViewportGizmoAxis::Y: + return Math::Vector3::Up(); + case SceneViewportGizmoAxis::Z: + return Math::Vector3::Forward(); + case SceneViewportGizmoAxis::None: + default: + return Math::Vector3::Zero(); + } +} + +Math::Color GetAxisBaseColor(SceneViewportGizmoAxis axis) { + switch (axis) { + case SceneViewportGizmoAxis::X: + return Math::Color(0.937f, 0.325f, 0.314f, 1.0f); + case SceneViewportGizmoAxis::Y: + return Math::Color(0.400f, 0.733f, 0.416f, 1.0f); + case SceneViewportGizmoAxis::Z: + return Math::Color(0.259f, 0.647f, 0.961f, 1.0f); + case SceneViewportGizmoAxis::None: + default: + return Math::Color::White(); + } +} + +bool IsMouseInsideViewport(const SceneViewportMoveGizmoContext& context) { + return context.mousePosition.x >= 0.0f && + context.mousePosition.y >= 0.0f && + context.mousePosition.x <= context.viewportSize.x && + context.mousePosition.y <= context.viewportSize.y; +} + +} // namespace + +void SceneViewportMoveGizmo::Update(const SceneViewportMoveGizmoContext& context) { + BuildDrawData(context); + if (m_activeAxis == SceneViewportGizmoAxis::None && IsMouseInsideViewport(context)) { + m_hoveredAxis = HitTestAxis(context.mousePosition); + } else if (m_activeAxis == SceneViewportGizmoAxis::None) { + m_hoveredAxis = SceneViewportGizmoAxis::None; + } else { + m_hoveredAxis = m_activeAxis; + } + + RefreshHandleState(); +} + +bool SceneViewportMoveGizmo::TryBeginDrag(const SceneViewportMoveGizmoContext& context, IUndoManager& undoManager) { + if (m_activeAxis != SceneViewportGizmoAxis::None || + m_hoveredAxis == SceneViewportGizmoAxis::None || + context.selectedObject == nullptr || + !m_drawData.visible || + undoManager.HasPendingInteractiveChange()) { + return false; + } + + Math::Ray worldRay; + if (!BuildSceneViewportRay( + context.overlay, + context.viewportSize, + context.mousePosition, + worldRay)) { + return false; + } + + const Math::Vector3 worldAxis = GetAxisVector(m_hoveredAxis); + Math::Vector3 dragPlaneNormal = Math::Vector3::Zero(); + if (!BuildSceneViewportAxisDragPlaneNormal(context.overlay, worldAxis, dragPlaneNormal)) { + return false; + } + + const Math::Vector3 worldPosition = context.selectedObject->GetTransform()->GetPosition(); + const Math::Plane dragPlane = BuildSceneViewportPlaneFromPointNormal(worldPosition, dragPlaneNormal); + float hitDistance = 0.0f; + if (!worldRay.Intersects(dragPlane, hitDistance)) { + return false; + } + + undoManager.BeginInteractiveChange("Move Gizmo"); + if (!undoManager.HasPendingInteractiveChange()) { + return false; + } + + const Math::Vector3 hitPoint = worldRay.GetPoint(hitDistance); + m_activeAxis = m_hoveredAxis; + m_activeEntityId = context.selectedObject->GetID(); + m_activeAxisDirection = worldAxis; + m_dragPlane = dragPlane; + m_dragStartWorldPosition = worldPosition; + m_dragStartAxisScalar = Math::Vector3::Dot(hitPoint - worldPosition, worldAxis); + RefreshHandleState(); + return true; +} + +void SceneViewportMoveGizmo::UpdateDrag(const SceneViewportMoveGizmoContext& context) { + if (m_activeAxis == SceneViewportGizmoAxis::None || + context.selectedObject == nullptr || + context.selectedObject->GetID() != m_activeEntityId) { + return; + } + + 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 hitPoint = worldRay.GetPoint(hitDistance); + const float currentAxisScalar = Math::Vector3::Dot(hitPoint - m_dragStartWorldPosition, m_activeAxisDirection); + const float deltaScalar = currentAxisScalar - m_dragStartAxisScalar; + context.selectedObject->GetTransform()->SetPosition( + m_dragStartWorldPosition + m_activeAxisDirection * deltaScalar); +} + +void SceneViewportMoveGizmo::EndDrag(IUndoManager& undoManager) { + if (m_activeAxis == SceneViewportGizmoAxis::None) { + return; + } + + if (undoManager.HasPendingInteractiveChange()) { + undoManager.FinalizeInteractiveChange(); + } + + m_activeAxis = SceneViewportGizmoAxis::None; + m_activeEntityId = 0; + m_activeAxisDirection = Math::Vector3::Zero(); + m_dragStartWorldPosition = Math::Vector3::Zero(); + m_dragStartAxisScalar = 0.0f; + RefreshHandleState(); +} + +void SceneViewportMoveGizmo::CancelDrag(IUndoManager* undoManager) { + if (undoManager != nullptr && undoManager->HasPendingInteractiveChange()) { + undoManager->CancelInteractiveChange(); + } + + m_activeAxis = SceneViewportGizmoAxis::None; + m_activeEntityId = 0; + m_activeAxisDirection = Math::Vector3::Zero(); + m_dragStartWorldPosition = Math::Vector3::Zero(); + m_dragStartAxisScalar = 0.0f; + m_hoveredAxis = SceneViewportGizmoAxis::None; + RefreshHandleState(); +} + +bool SceneViewportMoveGizmo::IsHoveringHandle() const { + return m_hoveredAxis != SceneViewportGizmoAxis::None; +} + +bool SceneViewportMoveGizmo::IsActive() const { + return m_activeAxis != SceneViewportGizmoAxis::None; +} + +uint64_t SceneViewportMoveGizmo::GetActiveEntityId() const { + return m_activeEntityId; +} + +const SceneViewportMoveGizmoDrawData& SceneViewportMoveGizmo::GetDrawData() const { + return m_drawData; +} + +void SceneViewportMoveGizmo::BuildDrawData(const SceneViewportMoveGizmoContext& context) { + m_drawData = {}; + m_drawData.pivotRadius = 5.0f; + + const Components::GameObject* selectedObject = context.selectedObject; + if (selectedObject == nullptr || + !context.overlay.valid || + context.viewportSize.x <= 1.0f || + context.viewportSize.y <= 1.0f) { + return; + } + + const SceneViewportProjectedPoint projectedPivot = ProjectSceneViewportWorldPoint( + context.overlay, + context.viewportSize.x, + context.viewportSize.y, + selectedObject->GetTransform()->GetPosition()); + if (!projectedPivot.visible) { + return; + } + + m_drawData.visible = true; + m_drawData.pivot = projectedPivot.screenPosition; + + const SceneViewportGizmoAxis axes[] = { + SceneViewportGizmoAxis::X, + SceneViewportGizmoAxis::Y, + SceneViewportGizmoAxis::Z + }; + for (size_t index = 0; index < m_drawData.handles.size(); ++index) { + SceneViewportMoveGizmoHandleDrawData& handle = m_drawData.handles[index]; + handle.axis = axes[index]; + handle.start = projectedPivot.screenPosition; + + Math::Vector2 screenDirection = Math::Vector2::Zero(); + if (!ProjectSceneViewportAxisDirection(context.overlay, GetAxisVector(handle.axis), screenDirection)) { + continue; + } + + handle.end = handle.start + screenDirection * kMoveGizmoHandleLengthPixels; + handle.visible = true; + handle.color = GetAxisBaseColor(handle.axis); + } +} + +void SceneViewportMoveGizmo::RefreshHandleState() { + for (SceneViewportMoveGizmoHandleDrawData& 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() + : GetAxisBaseColor(handle.axis); + } +} + +SceneViewportGizmoAxis SceneViewportMoveGizmo::HitTestAxis(const Math::Vector2& mousePosition) const { + if (!m_drawData.visible) { + return SceneViewportGizmoAxis::None; + } + + const float hoverThresholdSq = kMoveGizmoHoverThresholdPixels * kMoveGizmoHoverThresholdPixels; + SceneViewportGizmoAxis bestAxis = SceneViewportGizmoAxis::None; + float bestDistanceSq = hoverThresholdSq; + + for (const SceneViewportMoveGizmoHandleDrawData& handle : m_drawData.handles) { + if (!handle.visible) { + continue; + } + + const float distanceSq = DistanceToSegmentSquared(mousePosition, handle.start, handle.end); + if (distanceSq > bestDistanceSq) { + continue; + } + + bestDistanceSq = distanceSq; + bestAxis = handle.axis; + } + + return bestAxis; +} + +} // namespace Editor +} // namespace XCEngine diff --git a/editor/src/Viewport/SceneViewportMoveGizmo.h b/editor/src/Viewport/SceneViewportMoveGizmo.h new file mode 100644 index 00000000..6cb2807d --- /dev/null +++ b/editor/src/Viewport/SceneViewportMoveGizmo.h @@ -0,0 +1,82 @@ +#pragma once + +#include "IViewportHostService.h" + +#include +#include +#include +#include + +#include +#include + +namespace XCEngine { +namespace Components { +class GameObject; +} // namespace Components + +namespace Editor { + +class IUndoManager; + +enum class SceneViewportGizmoAxis : uint8_t { + None = 0, + X, + Y, + Z +}; + +struct SceneViewportMoveGizmoHandleDrawData { + SceneViewportGizmoAxis axis = SceneViewportGizmoAxis::None; + Math::Vector2 start = Math::Vector2::Zero(); + Math::Vector2 end = Math::Vector2::Zero(); + Math::Color color = Math::Color::White(); + bool visible = false; + bool hovered = false; + bool active = false; +}; + +struct SceneViewportMoveGizmoDrawData { + bool visible = false; + Math::Vector2 pivot = Math::Vector2::Zero(); + float pivotRadius = 5.0f; + std::array handles = {}; +}; + +struct SceneViewportMoveGizmoContext { + SceneViewportOverlayData overlay = {}; + Math::Vector2 viewportSize = Math::Vector2::Zero(); + Math::Vector2 mousePosition = Math::Vector2::Zero(); + Components::GameObject* selectedObject = nullptr; +}; + +class SceneViewportMoveGizmo { +public: + void Update(const SceneViewportMoveGizmoContext& context); + bool TryBeginDrag(const SceneViewportMoveGizmoContext& context, IUndoManager& undoManager); + void UpdateDrag(const SceneViewportMoveGizmoContext& context); + void EndDrag(IUndoManager& undoManager); + void CancelDrag(IUndoManager* undoManager = nullptr); + + bool IsHoveringHandle() const; + bool IsActive() const; + uint64_t GetActiveEntityId() const; + const SceneViewportMoveGizmoDrawData& GetDrawData() const; + +private: + void BuildDrawData(const SceneViewportMoveGizmoContext& context); + void RefreshHandleState(); + SceneViewportGizmoAxis HitTestAxis(const Math::Vector2& mousePosition) const; + + SceneViewportMoveGizmoDrawData m_drawData = {}; + SceneViewportGizmoAxis m_hoveredAxis = SceneViewportGizmoAxis::None; + SceneViewportGizmoAxis m_activeAxis = SceneViewportGizmoAxis::None; + uint64_t m_activeEntityId = 0; + Math::Vector3 m_activeAxisDirection = Math::Vector3::Zero(); + Math::Plane m_dragPlane = {}; + Math::Vector3 m_dragStartWorldPosition = Math::Vector3::Zero(); + float m_dragStartAxisScalar = 0.0f; +}; + +} // namespace Editor +} // namespace XCEngine diff --git a/editor/src/Viewport/SceneViewportOverlayRenderer.cpp b/editor/src/Viewport/SceneViewportOverlayRenderer.cpp index a46202fb..a198e839 100644 --- a/editor/src/Viewport/SceneViewportOverlayRenderer.cpp +++ b/editor/src/Viewport/SceneViewportOverlayRenderer.cpp @@ -10,6 +10,18 @@ Math::Matrix4x4 BuildOverlayViewMatrix(const SceneViewportOverlayData& overlay) return BuildSceneViewportViewMatrix(overlay); } +ImU32 ToImGuiColor(const Math::Color& color) { + const auto toChannel = [](float value) -> int { + return static_cast(std::clamp(value, 0.0f, 1.0f) * 255.0f + 0.5f); + }; + + return IM_COL32( + toChannel(color.r), + toChannel(color.g), + toChannel(color.b), + toChannel(color.a)); +} + void DrawAxisLabel(ImDrawList* drawList, const ImVec2& position, const char* label, ImU32 color) { if (drawList == nullptr || label == nullptr) { return; @@ -61,6 +73,31 @@ void DrawSceneAxisWidget( } } +void DrawSceneMoveGizmo( + ImDrawList* drawList, + const SceneViewportMoveGizmoDrawData& moveGizmo) { + if (drawList == nullptr || !moveGizmo.visible) { + return; + } + + const ImVec2 pivot(moveGizmo.pivot.x, moveGizmo.pivot.y); + drawList->AddCircleFilled(pivot, moveGizmo.pivotRadius + 1.0f, IM_COL32(20, 22, 24, 220), 20); + drawList->AddCircle(pivot, moveGizmo.pivotRadius + 1.0f, IM_COL32(255, 255, 255, 48), 20, 1.0f); + + for (const SceneViewportMoveGizmoHandleDrawData& handle : moveGizmo.handles) { + if (!handle.visible) { + continue; + } + + const ImU32 color = ToImGuiColor(handle.color); + const float thickness = handle.active ? 4.0f : (handle.hovered ? 3.0f : 2.0f); + const ImVec2 start(handle.start.x, handle.start.y); + const ImVec2 end(handle.end.x, handle.end.y); + drawList->AddLine(start, end, color, thickness); + drawList->AddCircleFilled(end, handle.active ? 6.5f : 5.5f, color, 20); + } +} + } // namespace void DrawSceneViewportOverlay( @@ -68,13 +105,19 @@ void DrawSceneViewportOverlay( const SceneViewportOverlayData& overlay, const ImVec2& viewportMin, const ImVec2& viewportMax, - const ImVec2& viewportSize) { - if (drawList == nullptr || !overlay.valid || viewportSize.x <= 1.0f || viewportSize.y <= 1.0f) { + const ImVec2& viewportSize, + const SceneViewportMoveGizmoDrawData* moveGizmo) { + if (drawList == nullptr || viewportSize.x <= 1.0f || viewportSize.y <= 1.0f) { return; } drawList->PushClipRect(viewportMin, viewportMax, true); - DrawSceneAxisWidget(drawList, overlay, viewportMin, viewportMax); + if (overlay.valid) { + DrawSceneAxisWidget(drawList, overlay, viewportMin, viewportMax); + } + if (moveGizmo != nullptr) { + DrawSceneMoveGizmo(drawList, *moveGizmo); + } drawList->PopClipRect(); } diff --git a/editor/src/Viewport/SceneViewportOverlayRenderer.h b/editor/src/Viewport/SceneViewportOverlayRenderer.h index 887d3c5a..c76e470b 100644 --- a/editor/src/Viewport/SceneViewportOverlayRenderer.h +++ b/editor/src/Viewport/SceneViewportOverlayRenderer.h @@ -1,6 +1,7 @@ #pragma once #include "IViewportHostService.h" +#include "SceneViewportMoveGizmo.h" #include @@ -12,7 +13,8 @@ void DrawSceneViewportOverlay( const SceneViewportOverlayData& overlay, const ImVec2& viewportMin, const ImVec2& viewportMax, - const ImVec2& viewportSize); + const ImVec2& viewportSize, + const SceneViewportMoveGizmoDrawData* moveGizmo = nullptr); } // namespace Editor } // namespace XCEngine diff --git a/editor/src/panels/SceneViewPanel.cpp b/editor/src/panels/SceneViewPanel.cpp index ef99d296..49209649 100644 --- a/editor/src/panels/SceneViewPanel.cpp +++ b/editor/src/panels/SceneViewPanel.cpp @@ -1,5 +1,6 @@ #include "Actions/ActionRouting.h" #include "Core/IEditorContext.h" +#include "Core/ISceneManager.h" #include "Core/ISelectionManager.h" #include "SceneViewPanel.h" #include "Viewport/SceneViewportOverlayRenderer.h" @@ -11,10 +12,38 @@ namespace XCEngine { namespace Editor { +namespace { + +SceneViewportMoveGizmoContext BuildMoveGizmoContext( + IEditorContext& context, + const SceneViewportOverlayData& overlay, + const ViewportPanelContentResult& content, + const ImVec2& mousePosition) { + SceneViewportMoveGizmoContext 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") {} void SceneViewPanel::Render() { + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); UI::PanelWindowScope panel(m_name.c_str()); + ImGui::PopStyleVar(); if (!panel.IsOpen()) { return; } @@ -22,21 +51,55 @@ void SceneViewPanel::Render() { const ViewportPanelContentResult content = RenderViewportPanelContent(*m_context, EditorViewportKind::Scene); if (IViewportHostService* viewportHostService = m_context->GetViewportHostService()) { const ImGuiIO& io = ImGui::GetIO(); - const bool selectClick = + const bool hasInteractiveViewport = content.hasViewportArea && content.frame.hasTexture; + SceneViewportOverlayData overlay = {}; + SceneViewportMoveGizmoContext moveGizmoContext = {}; + + 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())) { + m_moveGizmo.CancelDrag(&m_context->GetUndoManager()); + } + m_moveGizmo.Update(moveGizmoContext); + } else if (m_moveGizmo.IsActive()) { + m_moveGizmo.CancelDrag(&m_context->GetUndoManager()); + } + + const bool beginMoveGizmo = + hasInteractiveViewport && content.hovered && - content.frame.hasTexture && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && !m_lookDragging && - !m_panDragging; + !m_panDragging && + m_moveGizmo.IsHoveringHandle(); + const bool selectClick = + hasInteractiveViewport && + content.hovered && + ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + !m_lookDragging && + !m_panDragging && + !m_moveGizmo.IsHoveringHandle() && + !m_moveGizmo.IsActive(); const bool beginLookDrag = - content.hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Right); + content.hovered && + !m_moveGizmo.IsActive() && + ImGui::IsMouseClicked(ImGuiMouseButton_Right); const bool beginPanDrag = - content.hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Middle); + content.hovered && + !m_moveGizmo.IsActive() && + ImGui::IsMouseClicked(ImGuiMouseButton_Middle); - if (selectClick || beginLookDrag || beginPanDrag) { + if (beginMoveGizmo || selectClick || beginLookDrag || beginPanDrag) { ImGui::SetWindowFocus(); } + if (beginMoveGizmo) { + m_moveGizmo.TryBeginDrag(moveGizmoContext, m_context->GetUndoManager()); + } + if (selectClick) { const ImVec2 localMousePosition( io.MousePos.x - content.itemMin.x, @@ -52,6 +115,14 @@ void SceneViewPanel::Render() { } } + if (m_moveGizmo.IsActive()) { + if (ImGui::IsMouseDown(ImGuiMouseButton_Left)) { + m_moveGizmo.UpdateDrag(moveGizmoContext); + } else { + m_moveGizmo.EndDrag(m_context->GetUndoManager()); + } + } + if (beginLookDrag) { m_lookDragging = true; m_lastLookDragDelta = ImVec2(0.0f, 0.0f); @@ -70,7 +141,7 @@ void SceneViewPanel::Render() { m_lastPanDragDelta = ImVec2(0.0f, 0.0f); } - if (m_lookDragging || m_panDragging) { + if (m_lookDragging || m_panDragging || m_moveGizmo.IsActive()) { ImGui::SetNextFrameWantCaptureMouse(true); } if (m_lookDragging) { @@ -126,13 +197,16 @@ void SceneViewPanel::Render() { viewportHostService->UpdateSceneViewInput(*m_context, input); if (content.hasViewportArea && content.frame.hasTexture) { - const SceneViewportOverlayData overlay = viewportHostService->GetSceneViewOverlayData(); + overlay = viewportHostService->GetSceneViewOverlayData(); + moveGizmoContext = BuildMoveGizmoContext(*m_context, overlay, content, io.MousePos); + m_moveGizmo.Update(moveGizmoContext); DrawSceneViewportOverlay( ImGui::GetWindowDrawList(), overlay, content.itemMin, content.itemMax, - content.availableSize); + content.availableSize, + &m_moveGizmo.GetDrawData()); } } diff --git a/editor/src/panels/SceneViewPanel.h b/editor/src/panels/SceneViewPanel.h index 8f21dec0..46f4eb42 100644 --- a/editor/src/panels/SceneViewPanel.h +++ b/editor/src/panels/SceneViewPanel.h @@ -1,6 +1,7 @@ #pragma once #include "Panel.h" +#include "Viewport/SceneViewportMoveGizmo.h" #include @@ -17,6 +18,7 @@ private: bool m_panDragging = false; ImVec2 m_lastLookDragDelta = ImVec2(0.0f, 0.0f); ImVec2 m_lastPanDragDelta = ImVec2(0.0f, 0.0f); + SceneViewportMoveGizmo m_moveGizmo; }; } diff --git a/tests/editor/CMakeLists.txt b/tests/editor/CMakeLists.txt index 174b77a2..037f38e6 100644 --- a/tests/editor/CMakeLists.txt +++ b/tests/editor/CMakeLists.txt @@ -5,6 +5,7 @@ project(XCEngine_EditorTests) set(EDITOR_TEST_SOURCES test_action_routing.cpp test_scene_viewport_camera_controller.cpp + test_scene_viewport_move_gizmo.cpp test_scene_viewport_picker.cpp test_scene_viewport_overlay_renderer.cpp test_scene_viewport_selection_utils.cpp @@ -12,6 +13,7 @@ set(EDITOR_TEST_SOURCES ${CMAKE_SOURCE_DIR}/editor/src/Managers/SceneManager.cpp ${CMAKE_SOURCE_DIR}/editor/src/Managers/ProjectManager.cpp ${CMAKE_SOURCE_DIR}/editor/src/Viewport/SceneViewportPicker.cpp + ${CMAKE_SOURCE_DIR}/editor/src/Viewport/SceneViewportMoveGizmo.cpp ${CMAKE_SOURCE_DIR}/editor/src/Viewport/SceneViewportGrid.cpp ) diff --git a/tests/editor/test_scene_viewport_move_gizmo.cpp b/tests/editor/test_scene_viewport_move_gizmo.cpp new file mode 100644 index 00000000..0bd5f80f --- /dev/null +++ b/tests/editor/test_scene_viewport_move_gizmo.cpp @@ -0,0 +1,96 @@ +#include + +#include "Core/EditorContext.h" +#include "Managers/SceneManager.h" +#include "Viewport/SceneViewportMoveGizmo.h" + +namespace XCEngine::Editor { +namespace { + +class SceneViewportMoveGizmoTest : public ::testing::Test { +protected: + void SetUp() override { + m_context.GetSceneManager().NewScene("Move 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 SceneViewportMoveGizmoContext MakeContext( + Components::GameObject* selectedObject, + const Math::Vector2& mousePosition) { + SceneViewportMoveGizmoContext context = {}; + context.overlay = MakeOverlay(); + 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(SceneViewportMoveGizmoTest, UpdateHighlightsXAxisWhenMouseIsNearXAxisHandle) { + Components::GameObject* target = GetSceneManager().CreateEntity("Target"); + ASSERT_NE(target, nullptr); + m_context.GetSelectionManager().SetSelectedEntity(target->GetID()); + + SceneViewportMoveGizmo gizmo; + gizmo.Update(MakeContext(target, Math::Vector2(436.0f, 300.0f))); + + ASSERT_TRUE(gizmo.GetDrawData().visible); + EXPECT_TRUE(gizmo.IsHoveringHandle()); + EXPECT_TRUE(gizmo.GetDrawData().handles[0].hovered); + EXPECT_FALSE(gizmo.GetDrawData().handles[1].hovered); + EXPECT_FALSE(gizmo.GetDrawData().handles[2].hovered); +} + +TEST_F(SceneViewportMoveGizmoTest, DraggingXAxisOnlyChangesWorldXAndCreatesUndoStep) { + Components::GameObject* target = GetSceneManager().CreateEntity("Target"); + ASSERT_NE(target, nullptr); + const uint64_t targetId = target->GetID(); + m_context.GetSelectionManager().SetSelectedEntity(target->GetID()); + + SceneViewportMoveGizmo gizmo; + const auto startContext = MakeContext(target, Math::Vector2(436.0f, 300.0f)); + gizmo.Update(startContext); + + ASSERT_TRUE(gizmo.TryBeginDrag(startContext, m_context.GetUndoManager())); + ASSERT_TRUE(gizmo.IsActive()); + + const auto dragContext = MakeContext(target, Math::Vector2(500.0f, 300.0f)); + gizmo.Update(dragContext); + gizmo.UpdateDrag(dragContext); + gizmo.EndDrag(m_context.GetUndoManager()); + + const Math::Vector3 movedPosition = target->GetTransform()->GetPosition(); + EXPECT_GT(movedPosition.x, 0.05f); + EXPECT_NEAR(movedPosition.y, 0.0f, 1e-4f); + EXPECT_NEAR(movedPosition.z, 0.0f, 1e-4f); + EXPECT_TRUE(m_context.GetUndoManager().CanUndo()); + + m_context.GetUndoManager().Undo(); + Components::GameObject* restoredTarget = GetSceneManager().GetEntity(targetId); + ASSERT_NE(restoredTarget, nullptr); + const Math::Vector3 restoredPosition = restoredTarget->GetTransform()->GetPosition(); + EXPECT_NEAR(restoredPosition.x, 0.0f, 1e-4f); + EXPECT_NEAR(restoredPosition.y, 0.0f, 1e-4f); + EXPECT_NEAR(restoredPosition.z, 0.0f, 1e-4f); +} + +} // namespace +} // namespace XCEngine::Editor diff --git a/tests/editor/test_scene_viewport_overlay_renderer.cpp b/tests/editor/test_scene_viewport_overlay_renderer.cpp index b4b42b0d..232be44b 100644 --- a/tests/editor/test_scene_viewport_overlay_renderer.cpp +++ b/tests/editor/test_scene_viewport_overlay_renderer.cpp @@ -48,9 +48,11 @@ bool IsPowerOfTenSpacing(float value) { } // namespace using XCEngine::Editor::BuildSceneGridParameters; +using XCEngine::Editor::BuildSceneViewportAxisDragPlaneNormal; using XCEngine::Editor::SceneViewportCameraController; using XCEngine::Editor::BuildSceneViewportProjectionMatrix; using XCEngine::Editor::BuildSceneViewportViewMatrix; +using XCEngine::Editor::ProjectSceneViewportWorldPoint; using XCEngine::Editor::SceneGridParameters; using XCEngine::Editor::SceneViewportOverlayData; using XCEngine::Components::GameObject; @@ -219,3 +221,34 @@ TEST(SceneViewportOverlayRenderer_Test, ViewMatrixMatchesSceneCameraTransformCon cameraObject.GetTransform()->GetWorldToLocalMatrix(), 1e-3f)); } + +TEST(SceneViewportOverlayRenderer_Test, ProjectSceneViewportWorldPointMapsOriginToViewportCenter) { + SceneViewportOverlayData overlay = {}; + overlay.valid = true; + overlay.cameraPosition = Vector3(0.0f, 0.0f, -5.0f); + overlay.cameraForward = Vector3::Forward(); + overlay.cameraRight = Vector3::Right(); + overlay.cameraUp = Vector3::Up(); + overlay.verticalFovDegrees = 60.0f; + overlay.nearClipPlane = 0.03f; + overlay.farClipPlane = 2000.0f; + + const auto projected = ProjectSceneViewportWorldPoint(overlay, 800.0f, 600.0f, Vector3::Zero()); + + EXPECT_TRUE(projected.visible); + EXPECT_NEAR(projected.screenPosition.x, 400.0f, 1e-3f); + EXPECT_NEAR(projected.screenPosition.y, 300.0f, 1e-3f); +} + +TEST(SceneViewportOverlayRenderer_Test, BuildSceneViewportAxisDragPlaneNormalFallsBackWhenForwardAlignsWithAxis) { + SceneViewportOverlayData overlay = {}; + overlay.valid = true; + overlay.cameraForward = Vector3::Right(); + overlay.cameraRight = Vector3::Back(); + overlay.cameraUp = Vector3::Up(); + + Vector3 planeNormal = Vector3::Zero(); + ASSERT_TRUE(BuildSceneViewportAxisDragPlaneNormal(overlay, Vector3::Right(), planeNormal)); + EXPECT_NEAR(Vector3::Dot(planeNormal, Vector3::Right()), 0.0f, 1e-4f); + EXPECT_GT(planeNormal.SqrMagnitude(), 0.5f); +}