From 6d3a90ef7484d11ef1d4ada882813a61bf83268e Mon Sep 17 00:00:00 2001 From: ssdfasd <2156608475@qq.com> Date: Tue, 31 Mar 2026 21:26:40 +0800 Subject: [PATCH] feat: refine scene viewport gizmos and controls --- editor/src/Viewport/IViewportHostService.h | 12 + .../Viewport/SceneViewportCameraController.h | 103 +++++- .../SceneViewportInfiniteGridPass.cpp | 21 +- editor/src/Viewport/SceneViewportMath.h | 64 ++++ .../src/Viewport/SceneViewportMoveGizmo.cpp | 275 ++++++++++++-- editor/src/Viewport/SceneViewportMoveGizmo.h | 33 +- .../SceneViewportOrientationGizmo.cpp | 340 +++++++++++++----- .../Viewport/SceneViewportOrientationGizmo.h | 6 + .../Viewport/SceneViewportOverlayRenderer.cpp | 86 ++++- .../SceneViewportSelectionMaskPass.cpp | 4 +- .../Viewport/SceneViewportSelectionMaskPass.h | 3 +- editor/src/panels/GameViewPanel.cpp | 2 + editor/src/panels/HierarchyPanel.cpp | 1 + editor/src/panels/SceneViewPanel.cpp | 38 +- editor/src/panels/ViewportPanelContent.h | 54 ++- .../editor/test_scene_viewport_move_gizmo.cpp | 82 +++++ 16 files changed, 969 insertions(+), 155 deletions(-) diff --git a/editor/src/Viewport/IViewportHostService.h b/editor/src/Viewport/IViewportHostService.h index 6d2f6cbc..ab427402 100644 --- a/editor/src/Viewport/IViewportHostService.h +++ b/editor/src/Viewport/IViewportHostService.h @@ -4,6 +4,7 @@ #include +#include #include namespace XCEngine { @@ -59,6 +60,16 @@ struct SceneViewportOverlayData { float orbitDistance = 6.0f; }; +enum class SceneViewportOrientationAxis : uint8_t { + None = 0, + PositiveX, + NegativeX, + PositiveY, + NegativeY, + PositiveZ, + NegativeZ +}; + class IViewportHostService { public: virtual ~IViewportHostService() = default; @@ -70,6 +81,7 @@ public: IEditorContext& context, const ImVec2& viewportSize, const ImVec2& viewportMousePosition) = 0; + virtual void AlignSceneViewToOrientationAxis(SceneViewportOrientationAxis axis) = 0; virtual SceneViewportOverlayData GetSceneViewOverlayData() const = 0; virtual void RenderRequestedViewports( IEditorContext& context, diff --git a/editor/src/Viewport/SceneViewportCameraController.h b/editor/src/Viewport/SceneViewportCameraController.h index 71b9128f..b490467a 100644 --- a/editor/src/Viewport/SceneViewportCameraController.h +++ b/editor/src/Viewport/SceneViewportCameraController.h @@ -36,6 +36,7 @@ public: m_flySpeed = 5.0f; m_yawDegrees = -35.0f; m_pitchDegrees = -20.0f; + m_snapAnimating = false; UpdatePositionFromFocalPoint(); } @@ -78,11 +79,53 @@ public: UpdatePositionFromFocalPoint(); } + void SnapToForward(const Math::Vector3& forward) { + if (forward.SqrMagnitude() <= Math::EPSILON) { + return; + } + + const OrientationAngles target = ComputeOrientationAngles(forward); + m_yawDegrees = target.yawDegrees; + m_pitchDegrees = target.pitchDegrees; + m_snapAnimating = false; + UpdatePositionFromFocalPoint(); + } + + void AnimateToForward(const Math::Vector3& forward) { + if (forward.SqrMagnitude() <= Math::EPSILON) { + return; + } + + const OrientationAngles target = ComputeOrientationAngles(forward); + m_snapStartYawDegrees = m_yawDegrees; + m_snapStartPitchDegrees = m_pitchDegrees; + m_snapTargetYawDegrees = target.yawDegrees; + m_snapTargetPitchDegrees = target.pitchDegrees; + m_snapElapsed = 0.0f; + m_snapAnimating = true; + } + void ApplyInput(const SceneViewportCameraInputState& input) { if (input.viewportHeight <= 0.0f) { return; } + const bool hasManualInput = + std::abs(input.lookDeltaX) > Math::EPSILON || + std::abs(input.lookDeltaY) > Math::EPSILON || + std::abs(input.orbitDeltaX) > Math::EPSILON || + std::abs(input.orbitDeltaY) > Math::EPSILON || + std::abs(input.panDeltaX) > Math::EPSILON || + std::abs(input.panDeltaY) > Math::EPSILON || + std::abs(input.zoomDelta) > Math::EPSILON || + std::abs(input.flySpeedDelta) > Math::EPSILON || + std::abs(input.moveForward) > Math::EPSILON || + std::abs(input.moveRight) > Math::EPSILON || + std::abs(input.moveUp) > Math::EPSILON; + if (hasManualInput) { + m_snapAnimating = false; + } + if (std::abs(input.lookDeltaX) > Math::EPSILON || std::abs(input.lookDeltaY) > Math::EPSILON) { ApplyRotationDelta(input.lookDeltaX, input.lookDeltaY); @@ -108,7 +151,7 @@ public: if (std::abs(input.flySpeedDelta) > Math::EPSILON) { const float speedFactor = std::pow(1.20f, input.flySpeedDelta); - m_flySpeed = std::clamp(m_flySpeed * speedFactor, 0.5f, 500.0f); + m_flySpeed = std::clamp(m_flySpeed * speedFactor, 0.1f, 500.0f); } if (input.deltaTime > 0.0f && @@ -133,6 +176,18 @@ public: m_distance = std::clamp(m_distance * zoomFactor, 0.5f, 500.0f); UpdatePositionFromFocalPoint(); } + + if (m_snapAnimating && input.deltaTime > 0.0f) { + m_snapElapsed += input.deltaTime; + const float t = std::clamp(m_snapElapsed / kSnapDurationSeconds, 0.0f, 1.0f); + const float easedT = 1.0f - std::pow(1.0f - t, 3.0f); + m_yawDegrees = LerpAngleDegrees(m_snapStartYawDegrees, m_snapTargetYawDegrees, easedT); + m_pitchDegrees = m_snapStartPitchDegrees + (m_snapTargetPitchDegrees - m_snapStartPitchDegrees) * easedT; + UpdatePositionFromFocalPoint(); + if (t >= 1.0f) { + m_snapAnimating = false; + } + } } void ApplyTo(Components::TransformComponent& transform) const { @@ -144,6 +199,46 @@ public: } private: + static constexpr float kSnapDurationSeconds = 0.22f; + + struct OrientationAngles { + float yawDegrees = 0.0f; + float pitchDegrees = 0.0f; + }; + + static float NormalizeDegrees(float value) { + while (value > 180.0f) { + value -= 360.0f; + } + while (value < -180.0f) { + value += 360.0f; + } + return value; + } + + static float LerpAngleDegrees(float fromDegrees, float toDegrees, float t) { + const float delta = NormalizeDegrees(toDegrees - fromDegrees); + return NormalizeDegrees(fromDegrees + delta * t); + } + + static OrientationAngles ComputeOrientationAngles(const Math::Vector3& forward) { + OrientationAngles result = {}; + const Math::Vector3 normalizedForward = forward.Normalized(); + const float horizontalLengthSq = + normalizedForward.x * normalizedForward.x + + normalizedForward.z * normalizedForward.z; + if (horizontalLengthSq <= Math::EPSILON) { + result.yawDegrees = 0.0f; + } else { + result.yawDegrees = std::atan2(normalizedForward.x, normalizedForward.z) * Math::RAD_TO_DEG; + } + result.pitchDegrees = std::clamp( + std::asin(std::clamp(normalizedForward.y, -1.0f, 1.0f)) * Math::RAD_TO_DEG, + -89.0f, + 89.0f); + return result; + } + void ApplyRotationDelta(float deltaX, float deltaY) { m_yawDegrees += deltaX * 0.30f; m_pitchDegrees = std::clamp(m_pitchDegrees - deltaY * 0.20f, -89.0f, 89.0f); @@ -180,6 +275,12 @@ private: float m_flySpeed = 5.0f; float m_yawDegrees = -35.0f; float m_pitchDegrees = -20.0f; + bool m_snapAnimating = false; + float m_snapElapsed = 0.0f; + float m_snapStartYawDegrees = 0.0f; + float m_snapStartPitchDegrees = 0.0f; + float m_snapTargetYawDegrees = 0.0f; + float m_snapTargetPitchDegrees = 0.0f; }; } // namespace Editor diff --git a/editor/src/Viewport/SceneViewportInfiniteGridPass.cpp b/editor/src/Viewport/SceneViewportInfiniteGridPass.cpp index d6bacb8e..7be47232 100644 --- a/editor/src/Viewport/SceneViewportInfiniteGridPass.cpp +++ b/editor/src/Viewport/SceneViewportInfiniteGridPass.cpp @@ -88,6 +88,8 @@ PSOutput MainPS(VSOutput input) { const float tanHalfFov = max(gCameraUpAndTanHalfFov.w, 1e-4); const float aspect = max(gCameraForwardAndAspect.w, 1e-4); const float transitionBlend = saturate(gGridTransition.x); + const float nearClip = gViewportNearFar.z; + const float sceneFarClip = gViewportNearFar.w; const float2 ndc = float2( (input.position.x / viewportSize.x) * 2.0 - 1.0, @@ -104,19 +106,22 @@ PSOutput MainPS(VSOutput input) { } const float t = -cameraPosition.y / rayDirection.y; - if (t <= gViewportNearFar.z || t >= gViewportNearFar.w) { + if (t <= nearClip) { discard; } const float3 worldPosition = cameraPosition + rayDirection * t; - const float4 clipPosition = mul(gViewProjectionMatrix, float4(worldPosition, 1.0)); - if (clipPosition.w <= 1e-6) { - discard; - } + float depth = 0.999999; + if (t < sceneFarClip) { + const float4 clipPosition = mul(gViewProjectionMatrix, float4(worldPosition, 1.0)); + if (clipPosition.w <= 1e-6) { + discard; + } - const float depth = clipPosition.z / clipPosition.w; - if (depth <= 0.0 || depth >= 1.0) { - discard; + depth = clipPosition.z / clipPosition.w; + if (depth <= 0.0 || depth >= 1.0) { + discard; + } } const float radialFade = diff --git a/editor/src/Viewport/SceneViewportMath.h b/editor/src/Viewport/SceneViewportMath.h index c4e98831..4895c580 100644 --- a/editor/src/Viewport/SceneViewportMath.h +++ b/editor/src/Viewport/SceneViewportMath.h @@ -107,6 +107,70 @@ inline bool ProjectSceneViewportAxisDirection( return true; } +inline bool ProjectSceneViewportAxisDirectionAtPoint( + const SceneViewportOverlayData& overlay, + float viewportWidth, + float viewportHeight, + const Math::Vector3& worldPoint, + const Math::Vector3& worldAxis, + Math::Vector2& outScreenDirection, + float sampleDistance = 1.0f) { + const Math::Vector3 axis = worldAxis.Normalized(); + if (!overlay.valid || + viewportWidth <= 1.0f || + viewportHeight <= 1.0f || + axis.SqrMagnitude() <= Math::EPSILON || + sampleDistance <= Math::EPSILON) { + return false; + } + + const Math::Matrix4x4 viewProjection = + BuildSceneViewportViewProjectionMatrix(overlay, viewportWidth, viewportHeight); + const Math::Vector4 startClip = viewProjection * Math::Vector4(worldPoint, 1.0f); + const Math::Vector4 endClip = viewProjection * Math::Vector4(worldPoint + axis * sampleDistance, 1.0f); + if (startClip.w <= Math::EPSILON || endClip.w <= Math::EPSILON) { + return ProjectSceneViewportAxisDirection(overlay, axis, outScreenDirection); + } + + const Math::Vector3 startNdc = startClip.ToVector3() / startClip.w; + const Math::Vector3 endNdc = endClip.ToVector3() / endClip.w; + const Math::Vector2 startScreen( + (startNdc.x * 0.5f + 0.5f) * viewportWidth, + (1.0f - (startNdc.y * 0.5f + 0.5f)) * viewportHeight); + const Math::Vector2 endScreen( + (endNdc.x * 0.5f + 0.5f) * viewportWidth, + (1.0f - (endNdc.y * 0.5f + 0.5f)) * viewportHeight); + + const Math::Vector2 screenDirection = endScreen - startScreen; + if (screenDirection.SqrMagnitude() <= Math::EPSILON) { + return ProjectSceneViewportAxisDirection(overlay, axis, outScreenDirection); + } + + outScreenDirection = screenDirection.Normalized(); + return true; +} + +inline bool ProjectSceneViewportWorldPointClamped( + const SceneViewportOverlayData& overlay, + float viewportWidth, + float viewportHeight, + const Math::Vector3& worldPoint, + float edgePadding, + SceneViewportProjectedPoint& outProjectedPoint) { + outProjectedPoint = ProjectSceneViewportWorldPoint(overlay, viewportWidth, viewportHeight, worldPoint); + if (!overlay.valid || viewportWidth <= 1.0f || viewportHeight <= 1.0f || outProjectedPoint.ndcDepth < 0.0f) { + return false; + } + + const float minX = edgePadding; + const float minY = edgePadding; + const float maxX = viewportWidth - edgePadding; + const float maxY = viewportHeight - edgePadding; + outProjectedPoint.screenPosition.x = std::clamp(outProjectedPoint.screenPosition.x, minX, maxX); + outProjectedPoint.screenPosition.y = std::clamp(outProjectedPoint.screenPosition.y, minY, maxY); + return true; +} + inline float DistanceToSegmentSquared( const Math::Vector2& point, const Math::Vector2& segmentStart, diff --git a/editor/src/Viewport/SceneViewportMoveGizmo.cpp b/editor/src/Viewport/SceneViewportMoveGizmo.cpp index 8f702252..9d279673 100644 --- a/editor/src/Viewport/SceneViewportMoveGizmo.cpp +++ b/editor/src/Viewport/SceneViewportMoveGizmo.cpp @@ -14,7 +14,7 @@ namespace Editor { namespace { -constexpr float kMoveGizmoHandleLengthPixels = 72.0f; +constexpr float kMoveGizmoHandleLengthPixels = 88.0f; constexpr float kMoveGizmoHoverThresholdPixels = 10.0f; Math::Vector3 GetAxisVector(SceneViewportGizmoAxis axis) { @@ -45,10 +45,120 @@ Math::Color GetAxisBaseColor(SceneViewportGizmoAxis axis) { } } +Math::Color WithAlpha(const Math::Color& color, float alpha) { + return Math::Color(color.r, color.g, color.b, alpha); +} + +SceneViewportGizmoPlane GetPlaneForIndex(size_t index) { + switch (index) { + case 0: + return SceneViewportGizmoPlane::XY; + case 1: + return SceneViewportGizmoPlane::XZ; + case 2: + return SceneViewportGizmoPlane::YZ; + default: + return SceneViewportGizmoPlane::None; + } +} + +void GetPlaneAxes( + SceneViewportGizmoPlane plane, + Math::Vector3& outAxisA, + Math::Vector3& outAxisB) { + switch (plane) { + case SceneViewportGizmoPlane::XY: + outAxisA = Math::Vector3::Right(); + outAxisB = Math::Vector3::Up(); + return; + case SceneViewportGizmoPlane::XZ: + outAxisA = Math::Vector3::Right(); + outAxisB = Math::Vector3::Forward(); + return; + case SceneViewportGizmoPlane::YZ: + outAxisA = Math::Vector3::Up(); + outAxisB = Math::Vector3::Forward(); + return; + case SceneViewportGizmoPlane::None: + default: + outAxisA = Math::Vector3::Zero(); + outAxisB = Math::Vector3::Zero(); + return; + } +} + +Math::Vector3 GetPlaneNormal(SceneViewportGizmoPlane plane) { + switch (plane) { + case SceneViewportGizmoPlane::XY: + return Math::Vector3::Forward(); + case SceneViewportGizmoPlane::XZ: + return Math::Vector3::Up(); + case SceneViewportGizmoPlane::YZ: + return Math::Vector3::Right(); + case SceneViewportGizmoPlane::None: + default: + return Math::Vector3::Zero(); + } +} + +Math::Color GetPlaneBaseColor(SceneViewportGizmoPlane plane) { + switch (plane) { + case SceneViewportGizmoPlane::XY: + return GetAxisBaseColor(SceneViewportGizmoAxis::Z); + case SceneViewportGizmoPlane::XZ: + return GetAxisBaseColor(SceneViewportGizmoAxis::Y); + case SceneViewportGizmoPlane::YZ: + return GetAxisBaseColor(SceneViewportGizmoAxis::X); + case SceneViewportGizmoPlane::None: + default: + return Math::Color::White(); + } +} + Math::Vector3 NormalizeVector3(const Math::Vector3& value, const Math::Vector3& fallback) { return value.SqrMagnitude() <= Math::EPSILON ? fallback : value.Normalized(); } +float Cross2D(const Math::Vector2& a, const Math::Vector2& b) { + return a.x * b.y - a.y * b.x; +} + +float PolygonAreaTwice(const std::array& corners) { + float areaTwice = 0.0f; + for (size_t index = 0; index < corners.size(); ++index) { + const Math::Vector2& current = corners[index]; + const Math::Vector2& next = corners[(index + 1) % corners.size()]; + areaTwice += current.x * next.y - next.x * current.y; + } + return areaTwice; +} + +bool PointInTriangle( + const Math::Vector2& point, + const Math::Vector2& a, + const Math::Vector2& b, + const Math::Vector2& c) { + const float ab = Cross2D(b - a, point - a); + const float bc = Cross2D(c - b, point - b); + const float ca = Cross2D(a - c, point - c); + const bool hasNegative = ab < 0.0f || bc < 0.0f || ca < 0.0f; + const bool hasPositive = ab > 0.0f || bc > 0.0f || ca > 0.0f; + return !(hasNegative && hasPositive); +} + +bool PointInQuad(const Math::Vector2& point, const std::array& corners) { + return PointInTriangle(point, corners[0], corners[1], corners[2]) || + PointInTriangle(point, corners[0], corners[2], corners[3]); +} + +Math::Vector2 QuadCenter(const std::array& corners) { + Math::Vector2 center = Math::Vector2::Zero(); + for (const Math::Vector2& corner : corners) { + center += corner; + } + return center / 4.0f; +} + bool IsMouseInsideViewport(const SceneViewportMoveGizmoContext& context) { return context.mousePosition.x >= 0.0f && context.mousePosition.y >= 0.0f && @@ -141,20 +251,28 @@ Math::Vector3 GetGizmoWorldOrigin(const Components::GameObject& gameObject) { void SceneViewportMoveGizmo::Update(const SceneViewportMoveGizmoContext& context) { BuildDrawData(context); - if (m_activeAxis == SceneViewportGizmoAxis::None && IsMouseInsideViewport(context)) { + if (m_dragMode == DragMode::None && IsMouseInsideViewport(context)) { m_hoveredAxis = HitTestAxis(context.mousePosition); - } else if (m_activeAxis == SceneViewportGizmoAxis::None) { + m_hoveredPlane = m_hoveredAxis == SceneViewportGizmoAxis::None + ? HitTestPlane(context.mousePosition) + : SceneViewportGizmoPlane::None; + } else if (m_dragMode == DragMode::None) { m_hoveredAxis = SceneViewportGizmoAxis::None; - } else { + m_hoveredPlane = SceneViewportGizmoPlane::None; + } else if (m_dragMode == DragMode::Axis) { m_hoveredAxis = m_activeAxis; + m_hoveredPlane = SceneViewportGizmoPlane::None; + } else { + m_hoveredAxis = SceneViewportGizmoAxis::None; + m_hoveredPlane = m_activePlane; } RefreshHandleState(); } bool SceneViewportMoveGizmo::TryBeginDrag(const SceneViewportMoveGizmoContext& context, IUndoManager& undoManager) { - if (m_activeAxis != SceneViewportGizmoAxis::None || - m_hoveredAxis == SceneViewportGizmoAxis::None || + if (m_dragMode != DragMode::None || + (m_hoveredAxis == SceneViewportGizmoAxis::None && m_hoveredPlane == SceneViewportGizmoPlane::None) || context.selectedObject == nullptr || !m_drawData.visible || undoManager.HasPendingInteractiveChange()) { @@ -170,14 +288,23 @@ bool SceneViewportMoveGizmo::TryBeginDrag(const SceneViewportMoveGizmoContext& c 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 objectWorldPosition = context.selectedObject->GetTransform()->GetPosition(); const Math::Vector3 pivotWorldPosition = GetGizmoWorldOrigin(*context.selectedObject); + Math::Vector3 dragPlaneNormal = Math::Vector3::Zero(); + Math::Vector3 worldAxis = Math::Vector3::Zero(); + + if (m_hoveredAxis != SceneViewportGizmoAxis::None) { + worldAxis = GetAxisVector(m_hoveredAxis); + if (!BuildSceneViewportAxisDragPlaneNormal(context.overlay, worldAxis, dragPlaneNormal)) { + return false; + } + } else { + dragPlaneNormal = GetPlaneNormal(m_hoveredPlane); + if (dragPlaneNormal.SqrMagnitude() <= Math::EPSILON) { + return false; + } + } + const Math::Plane dragPlane = BuildSceneViewportPlaneFromPointNormal(pivotWorldPosition, dragPlaneNormal); float hitDistance = 0.0f; if (!worldRay.Intersects(dragPlane, hitDistance)) { @@ -190,19 +317,23 @@ bool SceneViewportMoveGizmo::TryBeginDrag(const SceneViewportMoveGizmoContext& c } const Math::Vector3 hitPoint = worldRay.GetPoint(hitDistance); + m_dragMode = m_hoveredAxis != SceneViewportGizmoAxis::None ? DragMode::Axis : DragMode::Plane; m_activeAxis = m_hoveredAxis; + m_activePlane = m_hoveredPlane; m_activeEntityId = context.selectedObject->GetID(); m_activeAxisDirection = worldAxis; + m_activePlaneNormal = dragPlaneNormal; m_dragPlane = dragPlane; m_dragStartObjectWorldPosition = objectWorldPosition; m_dragStartPivotWorldPosition = pivotWorldPosition; + m_dragStartHitWorldPosition = hitPoint; m_dragStartAxisScalar = Math::Vector3::Dot(hitPoint - pivotWorldPosition, worldAxis); RefreshHandleState(); return true; } void SceneViewportMoveGizmo::UpdateDrag(const SceneViewportMoveGizmoContext& context) { - if (m_activeAxis == SceneViewportGizmoAxis::None || + if (m_dragMode == DragMode::None || context.selectedObject == nullptr || context.selectedObject->GetID() != m_activeEntityId) { return; @@ -223,14 +354,25 @@ void SceneViewportMoveGizmo::UpdateDrag(const SceneViewportMoveGizmoContext& con } const Math::Vector3 hitPoint = worldRay.GetPoint(hitDistance); - const float currentAxisScalar = Math::Vector3::Dot(hitPoint - m_dragStartPivotWorldPosition, m_activeAxisDirection); - const float deltaScalar = currentAxisScalar - m_dragStartAxisScalar; - context.selectedObject->GetTransform()->SetPosition( - m_dragStartObjectWorldPosition + m_activeAxisDirection * deltaScalar); + if (m_dragMode == DragMode::Axis) { + const float currentAxisScalar = Math::Vector3::Dot(hitPoint - m_dragStartPivotWorldPosition, m_activeAxisDirection); + const float deltaScalar = currentAxisScalar - m_dragStartAxisScalar; + context.selectedObject->GetTransform()->SetPosition( + m_dragStartObjectWorldPosition + m_activeAxisDirection * deltaScalar); + return; + } + + if (m_dragMode == DragMode::Plane) { + const Math::Vector3 planeDelta = Math::Vector3::ProjectOnPlane( + hitPoint - m_dragStartHitWorldPosition, + m_activePlaneNormal); + context.selectedObject->GetTransform()->SetPosition( + m_dragStartObjectWorldPosition + planeDelta); + } } void SceneViewportMoveGizmo::EndDrag(IUndoManager& undoManager) { - if (m_activeAxis == SceneViewportGizmoAxis::None) { + if (m_dragMode == DragMode::None) { return; } @@ -238,11 +380,15 @@ void SceneViewportMoveGizmo::EndDrag(IUndoManager& undoManager) { undoManager.FinalizeInteractiveChange(); } + m_dragMode = DragMode::None; m_activeAxis = SceneViewportGizmoAxis::None; + m_activePlane = SceneViewportGizmoPlane::None; m_activeEntityId = 0; m_activeAxisDirection = Math::Vector3::Zero(); + m_activePlaneNormal = Math::Vector3::Zero(); m_dragStartObjectWorldPosition = Math::Vector3::Zero(); m_dragStartPivotWorldPosition = Math::Vector3::Zero(); + m_dragStartHitWorldPosition = Math::Vector3::Zero(); m_dragStartAxisScalar = 0.0f; RefreshHandleState(); } @@ -252,22 +398,28 @@ void SceneViewportMoveGizmo::CancelDrag(IUndoManager* undoManager) { undoManager->CancelInteractiveChange(); } + m_dragMode = DragMode::None; m_activeAxis = SceneViewportGizmoAxis::None; + m_activePlane = SceneViewportGizmoPlane::None; m_activeEntityId = 0; m_activeAxisDirection = Math::Vector3::Zero(); + m_activePlaneNormal = Math::Vector3::Zero(); m_dragStartObjectWorldPosition = Math::Vector3::Zero(); m_dragStartPivotWorldPosition = Math::Vector3::Zero(); + m_dragStartHitWorldPosition = Math::Vector3::Zero(); m_dragStartAxisScalar = 0.0f; m_hoveredAxis = SceneViewportGizmoAxis::None; + m_hoveredPlane = SceneViewportGizmoPlane::None; RefreshHandleState(); } bool SceneViewportMoveGizmo::IsHoveringHandle() const { - return m_hoveredAxis != SceneViewportGizmoAxis::None; + return m_hoveredAxis != SceneViewportGizmoAxis::None || + m_hoveredPlane != SceneViewportGizmoPlane::None; } bool SceneViewportMoveGizmo::IsActive() const { - return m_activeAxis != SceneViewportGizmoAxis::None; + return m_dragMode != DragMode::None; } uint64_t SceneViewportMoveGizmo::GetActiveEntityId() const { @@ -312,6 +464,8 @@ void SceneViewportMoveGizmo::BuildDrawData(const SceneViewportMoveGizmoContext& } const float axisLengthWorld = worldUnitsPerPixel * kMoveGizmoHandleLengthPixels; + const float planeInsetWorld = axisLengthWorld * 0.02f; + const float planeExtentWorld = axisLengthWorld * 0.36f; const SceneViewportGizmoAxis axes[] = { SceneViewportGizmoAxis::X, @@ -342,6 +496,49 @@ void SceneViewportMoveGizmo::BuildDrawData(const SceneViewportMoveGizmoContext& handle.visible = true; handle.color = GetAxisBaseColor(handle.axis); } + + for (size_t index = 0; index < m_drawData.planes.size(); ++index) { + SceneViewportMoveGizmoPlaneDrawData& plane = m_drawData.planes[index]; + plane.plane = GetPlaneForIndex(index); + + Math::Vector3 axisA = Math::Vector3::Zero(); + Math::Vector3 axisB = Math::Vector3::Zero(); + GetPlaneAxes(plane.plane, axisA, axisB); + if (axisA.SqrMagnitude() <= Math::EPSILON || axisB.SqrMagnitude() <= Math::EPSILON) { + continue; + } + + const Math::Vector3 worldCorners[] = { + gizmoWorldOrigin + axisA * planeInsetWorld + axisB * planeInsetWorld, + gizmoWorldOrigin + axisA * planeExtentWorld + axisB * planeInsetWorld, + gizmoWorldOrigin + axisA * planeExtentWorld + axisB * planeExtentWorld, + gizmoWorldOrigin + axisA * planeInsetWorld + axisB * planeExtentWorld + }; + + bool valid = true; + for (size_t cornerIndex = 0; cornerIndex < plane.corners.size(); ++cornerIndex) { + const SceneViewportProjectedPoint projectedCorner = ProjectSceneViewportWorldPoint( + context.overlay, + context.viewportSize.x, + context.viewportSize.y, + worldCorners[cornerIndex]); + if (projectedCorner.ndcDepth < 0.0f || projectedCorner.ndcDepth > 1.0f) { + valid = false; + break; + } + + plane.corners[cornerIndex] = projectedCorner.screenPosition; + } + + if (!valid || std::abs(PolygonAreaTwice(plane.corners)) <= 4.0f) { + continue; + } + + plane.visible = true; + const Math::Color baseColor = GetPlaneBaseColor(plane.plane); + plane.fillColor = WithAlpha(baseColor, 0.16f); + plane.outlineColor = WithAlpha(baseColor, 0.88f); + } } void SceneViewportMoveGizmo::RefreshHandleState() { @@ -356,6 +553,20 @@ void SceneViewportMoveGizmo::RefreshHandleState() { ? Math::Color::Yellow() : GetAxisBaseColor(handle.axis); } + + for (SceneViewportMoveGizmoPlaneDrawData& plane : m_drawData.planes) { + if (!plane.visible) { + continue; + } + + plane.hovered = plane.plane == m_hoveredPlane; + plane.active = plane.plane == m_activePlane; + const Math::Color baseColor = plane.hovered || plane.active + ? Math::Color::Yellow() + : GetPlaneBaseColor(plane.plane); + plane.fillColor = WithAlpha(baseColor, plane.active ? 0.34f : (plane.hovered ? 0.26f : 0.16f)); + plane.outlineColor = WithAlpha(baseColor, plane.active ? 1.0f : (plane.hovered ? 0.95f : 0.82f)); + } } SceneViewportGizmoAxis SceneViewportMoveGizmo::HitTestAxis(const Math::Vector2& mousePosition) const { @@ -384,5 +595,29 @@ SceneViewportGizmoAxis SceneViewportMoveGizmo::HitTestAxis(const Math::Vector2& return bestAxis; } +SceneViewportGizmoPlane SceneViewportMoveGizmo::HitTestPlane(const Math::Vector2& mousePosition) const { + if (!m_drawData.visible) { + return SceneViewportGizmoPlane::None; + } + + SceneViewportGizmoPlane bestPlane = SceneViewportGizmoPlane::None; + float bestDistanceSq = Math::FLOAT_MAX; + for (const SceneViewportMoveGizmoPlaneDrawData& plane : m_drawData.planes) { + if (!plane.visible || !PointInQuad(mousePosition, plane.corners)) { + continue; + } + + const float distanceSq = (QuadCenter(plane.corners) - mousePosition).SqrMagnitude(); + if (distanceSq >= bestDistanceSq) { + continue; + } + + bestDistanceSq = distanceSq; + bestPlane = plane.plane; + } + + return bestPlane; +} + } // namespace Editor } // namespace XCEngine diff --git a/editor/src/Viewport/SceneViewportMoveGizmo.h b/editor/src/Viewport/SceneViewportMoveGizmo.h index 6cb2807d..524ed170 100644 --- a/editor/src/Viewport/SceneViewportMoveGizmo.h +++ b/editor/src/Viewport/SceneViewportMoveGizmo.h @@ -26,6 +26,13 @@ enum class SceneViewportGizmoAxis : uint8_t { Z }; +enum class SceneViewportGizmoPlane : uint8_t { + None = 0, + XY, + XZ, + YZ +}; + struct SceneViewportMoveGizmoHandleDrawData { SceneViewportGizmoAxis axis = SceneViewportGizmoAxis::None; Math::Vector2 start = Math::Vector2::Zero(); @@ -36,11 +43,22 @@ struct SceneViewportMoveGizmoHandleDrawData { bool active = false; }; +struct SceneViewportMoveGizmoPlaneDrawData { + SceneViewportGizmoPlane plane = SceneViewportGizmoPlane::None; + std::array corners = {}; + Math::Color fillColor = Math::Color::White(); + Math::Color outlineColor = 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 = {}; + std::array planes = {}; }; struct SceneViewportMoveGizmoContext { @@ -64,17 +82,30 @@ public: const SceneViewportMoveGizmoDrawData& GetDrawData() const; private: + enum class DragMode : uint8_t { + None = 0, + Axis, + Plane + }; + void BuildDrawData(const SceneViewportMoveGizmoContext& context); void RefreshHandleState(); SceneViewportGizmoAxis HitTestAxis(const Math::Vector2& mousePosition) const; + SceneViewportGizmoPlane HitTestPlane(const Math::Vector2& mousePosition) const; SceneViewportMoveGizmoDrawData m_drawData = {}; SceneViewportGizmoAxis m_hoveredAxis = SceneViewportGizmoAxis::None; + SceneViewportGizmoPlane m_hoveredPlane = SceneViewportGizmoPlane::None; SceneViewportGizmoAxis m_activeAxis = SceneViewportGizmoAxis::None; + SceneViewportGizmoPlane m_activePlane = SceneViewportGizmoPlane::None; + DragMode m_dragMode = DragMode::None; uint64_t m_activeEntityId = 0; Math::Vector3 m_activeAxisDirection = Math::Vector3::Zero(); + Math::Vector3 m_activePlaneNormal = Math::Vector3::Zero(); Math::Plane m_dragPlane = {}; - Math::Vector3 m_dragStartWorldPosition = Math::Vector3::Zero(); + Math::Vector3 m_dragStartObjectWorldPosition = Math::Vector3::Zero(); + Math::Vector3 m_dragStartPivotWorldPosition = Math::Vector3::Zero(); + Math::Vector3 m_dragStartHitWorldPosition = Math::Vector3::Zero(); float m_dragStartAxisScalar = 0.0f; }; diff --git a/editor/src/Viewport/SceneViewportOrientationGizmo.cpp b/editor/src/Viewport/SceneViewportOrientationGizmo.cpp index 0ff16cf8..441536ac 100644 --- a/editor/src/Viewport/SceneViewportOrientationGizmo.cpp +++ b/editor/src/Viewport/SceneViewportOrientationGizmo.cpp @@ -130,6 +130,7 @@ ProjectedPoint ProjectPoint(const ImVec2& center, const Math::Vector3& point) { } struct AxisHandleVisual { + SceneViewportOrientationAxis axis = SceneViewportOrientationAxis::None; Math::Vector3 cameraDirection = Math::Vector3::Zero(); Math::Color baseColor = Math::Color::White(); const char* label = nullptr; @@ -137,6 +138,17 @@ struct AxisHandleVisual { float sortDepth = 0.0f; }; +struct AxisHandleProjection { + bool valid = false; + ProjectedPoint tip = {}; + ProjectedPoint capCenter = {}; + ImVec2 axisDirection = ImVec2(0.0f, 0.0f); + ImVec2 leftPoint = ImVec2(0.0f, 0.0f); + ImVec2 rightPoint = ImVec2(0.0f, 0.0f); + std::array capPoints = {}; + float capMinorSpan = 0.0f; +}; + struct CubeFaceVisual { std::array points = {}; Math::Vector3 cameraNormal = Math::Vector3::Zero(); @@ -157,6 +169,178 @@ float ComputePolygonSignedArea(const ImVec2* points, int pointCount) { return area * 0.5f; } +float ComputeSegmentDistanceSquared(const ImVec2& point, const ImVec2& a, const ImVec2& b) { + const float dx = b.x - a.x; + const float dy = b.y - a.y; + const float lengthSq = dx * dx + dy * dy; + if (lengthSq <= 1e-6f) { + const float px = point.x - a.x; + const float py = point.y - a.y; + return px * px + py * py; + } + + const float t = std::clamp( + ((point.x - a.x) * dx + (point.y - a.y) * dy) / lengthSq, + 0.0f, + 1.0f); + const float closestX = a.x + dx * t; + const float closestY = a.y + dy * t; + const float px = point.x - closestX; + const float py = point.y - closestY; + return px * px + py * py; +} + +bool PointInTriangle(const ImVec2& point, const ImVec2& a, const ImVec2& b, const ImVec2& c) { + const float ab = (point.x - b.x) * (a.y - b.y) - (a.x - b.x) * (point.y - b.y); + const float bc = (point.x - c.x) * (b.y - c.y) - (b.x - c.x) * (point.y - c.y); + const float ca = (point.x - a.x) * (c.y - a.y) - (c.x - a.x) * (point.y - a.y); + const bool hasNegative = ab < 0.0f || bc < 0.0f || ca < 0.0f; + const bool hasPositive = ab > 0.0f || bc > 0.0f || ca > 0.0f; + return !(hasNegative && hasPositive); +} + +bool PointInConvexPolygon(const ImVec2& point, const ImVec2* polygon, int pointCount) { + if (polygon == nullptr || pointCount < 3) { + return false; + } + + float sign = 0.0f; + for (int i = 0; i < pointCount; ++i) { + const ImVec2& a = polygon[i]; + const ImVec2& b = polygon[(i + 1) % pointCount]; + const float cross = (point.x - a.x) * (b.y - a.y) - (point.y - a.y) * (b.x - a.x); + if (std::abs(cross) <= 1e-4f) { + continue; + } + + if (sign == 0.0f) { + sign = cross; + continue; + } + + if ((sign > 0.0f) != (cross > 0.0f)) { + return false; + } + } + + return true; +} + +ImVec2 BuildWidgetCenter(const ImVec2& viewportMin, const ImVec2& viewportMax) { + return ImVec2(viewportMax.x - kWidgetInset, viewportMin.y + kWidgetInset); +} + +std::array BuildAxisHandles(const SceneViewportOverlayData& overlay) { + return {{ + { SceneViewportOrientationAxis::PositiveX, TransformToCameraSpace(overlay, Math::Vector3::Right()), Math::Color(0.91f, 0.09f, 0.05f, 1.0f), "x", true, 0.0f }, + { SceneViewportOrientationAxis::NegativeX, TransformToCameraSpace(overlay, Math::Vector3::Left()), Math::Color(1.0f, 1.0f, 1.0f, 1.0f), nullptr, false, 0.0f }, + { SceneViewportOrientationAxis::PositiveY, TransformToCameraSpace(overlay, Math::Vector3::Up()), Math::Color(0.45f, 1.0f, 0.12f, 1.0f), "y", true, 0.0f }, + { SceneViewportOrientationAxis::NegativeY, TransformToCameraSpace(overlay, Math::Vector3::Down()), Math::Color(1.0f, 1.0f, 1.0f, 1.0f), nullptr, false, 0.0f }, + { SceneViewportOrientationAxis::PositiveZ, TransformToCameraSpace(overlay, Math::Vector3::Forward()), Math::Color(0.11f, 0.29f, 1.0f, 1.0f), "z", true, 0.0f }, + { SceneViewportOrientationAxis::NegativeZ, TransformToCameraSpace(overlay, Math::Vector3::Back()), Math::Color(1.0f, 1.0f, 1.0f, 1.0f), nullptr, false, 0.0f } + }}; +} + +std::vector BuildSortedAxisHandles(const SceneViewportOverlayData& overlay) { + const std::array handles = BuildAxisHandles(overlay); + std::vector sortedHandles(handles.begin(), handles.end()); + for (AxisHandleVisual& handle : sortedHandles) { + handle.sortDepth = handle.cameraDirection.z; + } + + std::sort( + sortedHandles.begin(), + sortedHandles.end(), + [](const AxisHandleVisual& lhs, const AxisHandleVisual& rhs) { + return lhs.sortDepth > rhs.sortDepth; + }); + return sortedHandles; +} + +AxisHandleProjection BuildAxisHandleProjection(const ImVec2& center, const AxisHandleVisual& handle) { + AxisHandleProjection projection = {}; + if (handle.cameraDirection.SqrMagnitude() <= Math::EPSILON) { + return projection; + } + + const float tipDistance = kCubeHalfExtent + 0.65f; + const float capDistance = handle.positive ? kPositiveAxisLength : kNegativeAxisLength; + const float capRadius = 7.1f; + + const Math::Vector3 tipPoint3 = handle.cameraDirection * tipDistance; + const Math::Vector3 capCenter3 = handle.cameraDirection * capDistance; + + projection.tip = ProjectPoint(center, tipPoint3); + projection.capCenter = ProjectPoint(center, capCenter3); + const ImVec2 axisVector( + projection.capCenter.position.x - projection.tip.position.x, + projection.capCenter.position.y - projection.tip.position.y); + const float axisLengthSq = axisVector.x * axisVector.x + axisVector.y * axisVector.y; + const float axisLength = std::sqrt(axisLengthSq); + projection.axisDirection = axisLength > 1e-4f + ? ImVec2(axisVector.x / axisLength, axisVector.y / axisLength) + : ImVec2(0.0f, 0.0f); + const ImVec2 sideDirection(-projection.axisDirection.y, projection.axisDirection.x); + + Math::Vector3 capTangent = Math::Vector3::Right(); + Math::Vector3 capBitangent = Math::Vector3::Up(); + BuildPerpendicularBasis(handle.cameraDirection, capTangent, capBitangent); + + float maxSide = std::numeric_limits::lowest(); + float minSide = std::numeric_limits::max(); + float maxAxis = std::numeric_limits::lowest(); + float minAxis = std::numeric_limits::max(); + for (int i = 0; i < kConeCapSegments; ++i) { + const float angle = (static_cast(i) / static_cast(kConeCapSegments)) * kTau; + const Math::Vector3 ringPoint = + capCenter3 + + capTangent * (std::cos(angle) * capRadius) + + capBitangent * (std::sin(angle) * capRadius); + projection.capPoints[i] = ProjectPoint(center, ringPoint).position; + + const ImVec2 offset( + projection.capPoints[i].x - projection.capCenter.position.x, + projection.capPoints[i].y - projection.capCenter.position.y); + const float sideValue = offset.x * sideDirection.x + offset.y * sideDirection.y; + const float axisValue = offset.x * projection.axisDirection.x + offset.y * projection.axisDirection.y; + if (sideValue > maxSide) { + maxSide = sideValue; + projection.leftPoint = projection.capPoints[i]; + } + if (sideValue < minSide) { + minSide = sideValue; + projection.rightPoint = projection.capPoints[i]; + } + maxAxis = std::max(maxAxis, axisValue); + minAxis = std::min(minAxis, axisValue); + } + + projection.capMinorSpan = maxAxis - minAxis; + projection.valid = true; + return projection; +} + +bool HitTestAxisHandle(const ImVec2& mousePosition, const ImVec2& center, const AxisHandleVisual& handle) { + const AxisHandleProjection projection = BuildAxisHandleProjection(center, handle); + if (!projection.valid) { + return false; + } + + if (projection.capMinorSpan <= 1.35f) { + if (ComputeSegmentDistanceSquared(mousePosition, projection.rightPoint, projection.leftPoint) <= 16.0f) { + return true; + } + } else if (PointInConvexPolygon(mousePosition, projection.capPoints.data(), static_cast(projection.capPoints.size()))) { + return true; + } + + return PointInTriangle( + mousePosition, + projection.tip.position, + projection.leftPoint, + projection.rightPoint); +} + void DrawAxisLabel(ImDrawList* drawList, const ImVec2& position, const char* label) { if (drawList == nullptr || label == nullptr) { return; @@ -283,59 +467,9 @@ void DrawAxisHandle( } const float frontFactor = Saturate((-handle.cameraDirection.z + 1.0f) * 0.5f); - const float tipDistance = kCubeHalfExtent + 0.65f; - const float capDistance = handle.positive ? kPositiveAxisLength : kNegativeAxisLength; - const float capRadius = 7.1f; - - const Math::Vector3 tipPoint3 = handle.cameraDirection * tipDistance; - const Math::Vector3 capCenter3 = handle.cameraDirection * capDistance; - - const ProjectedPoint tip = ProjectPoint(center, tipPoint3); - const ProjectedPoint capCenter = ProjectPoint(center, capCenter3); - const ImVec2 axisVector( - capCenter.position.x - tip.position.x, - capCenter.position.y - tip.position.y); - const float axisLengthSq = axisVector.x * axisVector.x + axisVector.y * axisVector.y; - const float axisLength = std::sqrt(axisLengthSq); - const ImVec2 axisDirection = axisLength > 1e-4f - ? ImVec2(axisVector.x / axisLength, axisVector.y / axisLength) - : ImVec2(0.0f, 0.0f); - const ImVec2 sideDirection(-axisDirection.y, axisDirection.x); - - Math::Vector3 capTangent = Math::Vector3::Right(); - Math::Vector3 capBitangent = Math::Vector3::Up(); - BuildPerpendicularBasis(handle.cameraDirection, capTangent, capBitangent); - - std::array capPoints = {}; - ImVec2 leftPoint = capCenter.position; - ImVec2 rightPoint = capCenter.position; - float maxSide = std::numeric_limits::lowest(); - float minSide = std::numeric_limits::max(); - float maxAxis = std::numeric_limits::lowest(); - float minAxis = std::numeric_limits::max(); - for (int i = 0; i < kConeCapSegments; ++i) { - const float angle = (static_cast(i) / static_cast(kConeCapSegments)) * kTau; - const Math::Vector3 ringPoint = - capCenter3 + - capTangent * (std::cos(angle) * capRadius) + - capBitangent * (std::sin(angle) * capRadius); - capPoints[i] = ProjectPoint(center, ringPoint).position; - - const ImVec2 offset( - capPoints[i].x - capCenter.position.x, - capPoints[i].y - capCenter.position.y); - const float sideValue = offset.x * sideDirection.x + offset.y * sideDirection.y; - const float axisValue = offset.x * axisDirection.x + offset.y * axisDirection.y; - if (sideValue > maxSide) { - maxSide = sideValue; - leftPoint = capPoints[i]; - } - if (sideValue < minSide) { - minSide = sideValue; - rightPoint = capPoints[i]; - } - maxAxis = std::max(maxAxis, axisValue); - minAxis = std::min(minAxis, axisValue); + const AxisHandleProjection projection = BuildAxisHandleProjection(center, handle); + if (!projection.valid) { + return; } const Math::Color bodyColor = handle.positive @@ -348,58 +482,60 @@ void DrawAxisHandle( : LerpColor(bodyColor, Math::Color::White(), 0.16f + frontFactor * 0.14f); const ImVec2 shadowTriangle[] = { - ImVec2(tip.position.x + 1.2f, tip.position.y + 1.4f), - ImVec2(leftPoint.x + 1.2f, leftPoint.y + 1.4f), - ImVec2(rightPoint.x + 1.2f, rightPoint.y + 1.4f) + ImVec2(projection.tip.position.x + 1.2f, projection.tip.position.y + 1.4f), + ImVec2(projection.leftPoint.x + 1.2f, projection.leftPoint.y + 1.4f), + ImVec2(projection.rightPoint.x + 1.2f, projection.rightPoint.y + 1.4f) }; const ImVec2 leftFacet[] = { - tip.position, - leftPoint, - capCenter.position + projection.tip.position, + projection.leftPoint, + projection.capCenter.position }; const ImVec2 rightFacet[] = { - tip.position, - capCenter.position, - rightPoint + projection.tip.position, + projection.capCenter.position, + projection.rightPoint }; drawList->AddConvexPolyFilled(shadowTriangle, 3, IM_COL32(0, 0, 0, 58)); drawList->AddConvexPolyFilled(leftFacet, 3, ToImGuiColor(lightColor)); drawList->AddConvexPolyFilled(rightFacet, 3, ToImGuiColor(darkColor)); - drawList->AddLine(tip.position, leftPoint, IM_COL32(255, 255, 255, handle.positive ? 34 : 44), 1.0f); - drawList->AddLine(tip.position, rightPoint, IM_COL32(0, 0, 0, 38), 1.0f); - const float capMinorSpan = maxAxis - minAxis; - if (capMinorSpan <= 1.35f) { + drawList->AddLine(projection.tip.position, projection.leftPoint, IM_COL32(255, 255, 255, handle.positive ? 34 : 44), 1.0f); + drawList->AddLine(projection.tip.position, projection.rightPoint, IM_COL32(0, 0, 0, 38), 1.0f); + if (projection.capMinorSpan <= 1.35f) { drawList->AddLine( - ImVec2(rightPoint.x + 1.0f, rightPoint.y + 1.2f), - ImVec2(leftPoint.x + 1.0f, leftPoint.y + 1.2f), + ImVec2(projection.rightPoint.x + 1.0f, projection.rightPoint.y + 1.2f), + ImVec2(projection.leftPoint.x + 1.0f, projection.leftPoint.y + 1.2f), IM_COL32(0, 0, 0, 52), 2.4f); drawList->AddLine( - rightPoint, - leftPoint, + projection.rightPoint, + projection.leftPoint, ToImGuiColor(capColor), 2.0f); drawList->AddLine( - rightPoint, - leftPoint, + projection.rightPoint, + projection.leftPoint, IM_COL32(255, 255, 255, handle.positive ? 56 : 68), 1.0f); } else { - drawList->AddConvexPolyFilled(capPoints.data(), static_cast(capPoints.size()), ToImGuiColor(capColor)); + drawList->AddConvexPolyFilled(projection.capPoints.data(), static_cast(projection.capPoints.size()), ToImGuiColor(capColor)); drawList->AddPolyline( - capPoints.data(), - static_cast(capPoints.size()), + projection.capPoints.data(), + static_cast(projection.capPoints.size()), IM_COL32(255, 255, 255, handle.positive ? 60 : 72), true, 1.0f); } if (handle.positive && handle.label != nullptr) { + const float axisLength = std::sqrt( + (projection.capCenter.position.x - projection.tip.position.x) * (projection.capCenter.position.x - projection.tip.position.x) + + (projection.capCenter.position.y - projection.tip.position.y) * (projection.capCenter.position.y - projection.tip.position.y)); const float labelOffset = 9.0f * Saturate((axisLength - 2.0f) / 12.0f); const ImVec2 labelPosition( - capCenter.position.x + axisDirection.x * labelOffset, - capCenter.position.y + axisDirection.y * labelOffset); + projection.capCenter.position.x + projection.axisDirection.x * labelOffset, + projection.capCenter.position.y + projection.axisDirection.y * labelOffset); DrawAxisLabel(drawList, labelPosition, handle.label); } } @@ -415,27 +551,8 @@ void DrawSceneViewportOrientationGizmo( return; } - const ImVec2 center(viewportMax.x - kWidgetInset, viewportMin.y + kWidgetInset); - const std::array handles = {{ - { TransformToCameraSpace(overlay, Math::Vector3::Right()), Math::Color(0.91f, 0.09f, 0.05f, 1.0f), "x", true, 0.0f }, - { TransformToCameraSpace(overlay, Math::Vector3::Left()), Math::Color(1.0f, 1.0f, 1.0f, 1.0f), nullptr, false, 0.0f }, - { TransformToCameraSpace(overlay, Math::Vector3::Up()), Math::Color(0.45f, 1.0f, 0.12f, 1.0f), "y", true, 0.0f }, - { TransformToCameraSpace(overlay, Math::Vector3::Down()), Math::Color(1.0f, 1.0f, 1.0f, 1.0f), nullptr, false, 0.0f }, - { TransformToCameraSpace(overlay, Math::Vector3::Forward()), Math::Color(0.11f, 0.29f, 1.0f, 1.0f), "z", true, 0.0f }, - { TransformToCameraSpace(overlay, Math::Vector3::Back()), Math::Color(1.0f, 1.0f, 1.0f, 1.0f), nullptr, false, 0.0f } - }}; - - std::vector sortedHandles(handles.begin(), handles.end()); - for (AxisHandleVisual& handle : sortedHandles) { - handle.sortDepth = handle.cameraDirection.z; - } - - std::sort( - sortedHandles.begin(), - sortedHandles.end(), - [](const AxisHandleVisual& lhs, const AxisHandleVisual& rhs) { - return lhs.sortDepth > rhs.sortDepth; - }); + const ImVec2 center = BuildWidgetCenter(viewportMin, viewportMax); + const std::vector sortedHandles = BuildSortedAxisHandles(overlay); for (const AxisHandleVisual& handle : sortedHandles) { if (handle.sortDepth > 0.0f) { @@ -452,5 +569,32 @@ void DrawSceneViewportOrientationGizmo( } } +SceneViewportOrientationAxis HitTestSceneViewportOrientationGizmo( + const SceneViewportOverlayData& overlay, + const ImVec2& viewportMin, + const ImVec2& viewportMax, + const ImVec2& mousePosition) { + if (!overlay.valid) { + return SceneViewportOrientationAxis::None; + } + + const ImVec2 center = BuildWidgetCenter(viewportMin, viewportMax); + std::vector handles = BuildSortedAxisHandles(overlay); + std::sort( + handles.begin(), + handles.end(), + [](const AxisHandleVisual& lhs, const AxisHandleVisual& rhs) { + return lhs.sortDepth < rhs.sortDepth; + }); + + for (const AxisHandleVisual& handle : handles) { + if (HitTestAxisHandle(mousePosition, center, handle)) { + return handle.axis; + } + } + + return SceneViewportOrientationAxis::None; +} + } // namespace Editor } // namespace XCEngine diff --git a/editor/src/Viewport/SceneViewportOrientationGizmo.h b/editor/src/Viewport/SceneViewportOrientationGizmo.h index 3917ab93..a1c04e9b 100644 --- a/editor/src/Viewport/SceneViewportOrientationGizmo.h +++ b/editor/src/Viewport/SceneViewportOrientationGizmo.h @@ -13,5 +13,11 @@ void DrawSceneViewportOrientationGizmo( const ImVec2& viewportMin, const ImVec2& viewportMax); +SceneViewportOrientationAxis HitTestSceneViewportOrientationGizmo( + const SceneViewportOverlayData& overlay, + const ImVec2& viewportMin, + const ImVec2& viewportMax, + const ImVec2& mousePosition); + } // namespace Editor } // namespace XCEngine diff --git a/editor/src/Viewport/SceneViewportOverlayRenderer.cpp b/editor/src/Viewport/SceneViewportOverlayRenderer.cpp index d001c4e2..eb19f96e 100644 --- a/editor/src/Viewport/SceneViewportOverlayRenderer.cpp +++ b/editor/src/Viewport/SceneViewportOverlayRenderer.cpp @@ -2,12 +2,16 @@ #include "SceneViewportOrientationGizmo.h" #include +#include namespace XCEngine { namespace Editor { namespace { +constexpr float kMoveGizmoArrowLength = 14.0f; +constexpr float kMoveGizmoArrowHalfWidth = 7.0f; + 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); @@ -20,6 +24,69 @@ ImU32 ToImGuiColor(const Math::Color& color) { toChannel(color.a)); } +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) { + return fallback; + } + + const float inverseLength = 1.0f / std::sqrt(lengthSq); + return ImVec2(value.x * inverseLength, value.y * inverseLength); +} + +void DrawSceneMoveGizmoPlane( + ImDrawList* drawList, + const ImVec2& viewportMin, + const SceneViewportMoveGizmoPlaneDrawData& plane) { + if (drawList == nullptr || !plane.visible) { + return; + } + + ImVec2 points[4] = {}; + for (size_t index = 0; index < plane.corners.size(); ++index) { + points[index] = ImVec2( + viewportMin.x + plane.corners[index].x, + viewportMin.y + plane.corners[index].y); + } + + drawList->AddConvexPolyFilled(points, 4, ToImGuiColor(plane.fillColor)); + drawList->AddPolyline( + points, + 4, + ToImGuiColor(plane.outlineColor), + true, + plane.active ? 2.6f : (plane.hovered ? 2.0f : 1.4f)); +} + +void DrawSceneMoveGizmoAxis( + ImDrawList* drawList, + const ImVec2& viewportMin, + const SceneViewportMoveGizmoHandleDrawData& handle) { + if (drawList == nullptr || !handle.visible) { + return; + } + + const ImU32 color = ToImGuiColor(handle.color); + const float thickness = handle.active ? 4.0f : (handle.hovered ? 3.0f : 2.0f); + const ImVec2 start(viewportMin.x + handle.start.x, viewportMin.y + handle.start.y); + const ImVec2 end(viewportMin.x + handle.end.x, viewportMin.y + handle.end.y); + const ImVec2 direction = NormalizeImVec2(ImVec2(end.x - start.x, end.y - start.y)); + const ImVec2 normal(-direction.y, direction.x); + const ImVec2 arrowBase( + end.x - direction.x * kMoveGizmoArrowLength, + end.y - direction.y * kMoveGizmoArrowLength); + const ImVec2 arrowLeft( + arrowBase.x + normal.x * kMoveGizmoArrowHalfWidth, + arrowBase.y + normal.y * kMoveGizmoArrowHalfWidth); + const ImVec2 arrowRight( + arrowBase.x - normal.x * kMoveGizmoArrowHalfWidth, + arrowBase.y - normal.y * kMoveGizmoArrowHalfWidth); + + drawList->AddLine(start, arrowBase, color, thickness); + const ImVec2 triangle[3] = { end, arrowLeft, arrowRight }; + drawList->AddConvexPolyFilled(triangle, 3, color); +} + void DrawSceneMoveGizmo( ImDrawList* drawList, const ImVec2& viewportMin, @@ -29,21 +96,16 @@ void DrawSceneMoveGizmo( } const ImVec2 pivot(viewportMin.x + moveGizmo.pivot.x, viewportMin.y + 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 SceneViewportMoveGizmoPlaneDrawData& plane : moveGizmo.planes) { + DrawSceneMoveGizmoPlane(drawList, viewportMin, plane); + } 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(viewportMin.x + handle.start.x, viewportMin.y + handle.start.y); - const ImVec2 end(viewportMin.x + handle.end.x, viewportMin.y + handle.end.y); - drawList->AddLine(start, end, color, thickness); - drawList->AddCircleFilled(end, handle.active ? 6.5f : 5.5f, color, 20); + DrawSceneMoveGizmoAxis(drawList, viewportMin, handle); } + + 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); } } // namespace diff --git a/editor/src/Viewport/SceneViewportSelectionMaskPass.cpp b/editor/src/Viewport/SceneViewportSelectionMaskPass.cpp index db4fcce3..394acf2d 100644 --- a/editor/src/Viewport/SceneViewportSelectionMaskPass.cpp +++ b/editor/src/Viewport/SceneViewportSelectionMaskPass.cpp @@ -102,7 +102,9 @@ bool SceneViewportSelectionMaskPass::Render( const Rendering::RenderContext& renderContext, const Rendering::RenderSurface& surface, const Rendering::RenderCameraData& cameraData, - const std::vector& renderables) { + const std::vector& renderables, + bool debugColor) { + (void)debugColor; if (!renderContext.IsValid() || renderContext.backendType != RHI::RHIType::D3D12 || renderables.empty()) { diff --git a/editor/src/Viewport/SceneViewportSelectionMaskPass.h b/editor/src/Viewport/SceneViewportSelectionMaskPass.h index cfe42c7c..58f681a0 100644 --- a/editor/src/Viewport/SceneViewportSelectionMaskPass.h +++ b/editor/src/Viewport/SceneViewportSelectionMaskPass.h @@ -29,7 +29,8 @@ public: const Rendering::RenderContext& renderContext, const Rendering::RenderSurface& surface, const Rendering::RenderCameraData& cameraData, - const std::vector& renderables); + const std::vector& renderables, + bool debugColor = false); private: struct OwnedDescriptorSet { diff --git a/editor/src/panels/GameViewPanel.cpp b/editor/src/panels/GameViewPanel.cpp index 6ccc5871..0c7a12df 100644 --- a/editor/src/panels/GameViewPanel.cpp +++ b/editor/src/panels/GameViewPanel.cpp @@ -10,7 +10,9 @@ namespace Editor { GameViewPanel::GameViewPanel() : Panel("Game") {} void GameViewPanel::Render() { + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); UI::PanelWindowScope panel(m_name.c_str()); + ImGui::PopStyleVar(); if (!panel.IsOpen()) { return; } diff --git a/editor/src/panels/HierarchyPanel.cpp b/editor/src/panels/HierarchyPanel.cpp index de20622b..72bdb2bc 100644 --- a/editor/src/panels/HierarchyPanel.cpp +++ b/editor/src/panels/HierarchyPanel.cpp @@ -36,6 +36,7 @@ XCEngine::Editor::UI::TreeNodeDefinition BuildHierarchyNodeDefinition( XCEngine::Editor::UI::TreeNodeDefinition nodeDefinition; nodeDefinition.options.selected = context.GetSelectionManager().IsSelected(gameObject->GetID()); nodeDefinition.options.leaf = gameObject->GetChildCount() == 0; + nodeDefinition.options.openOnDoubleClick = false; nodeDefinition.style = XCEngine::Editor::UI::HierarchyTreeStyle(); nodeDefinition.prefix.width = XCEngine::Editor::UI::NavigationTreePrefixWidth(); nodeDefinition.prefix.draw = DrawHierarchyTreePrefix; diff --git a/editor/src/panels/SceneViewPanel.cpp b/editor/src/panels/SceneViewPanel.cpp index 49209649..045f30c0 100644 --- a/editor/src/panels/SceneViewPanel.cpp +++ b/editor/src/panels/SceneViewPanel.cpp @@ -3,6 +3,7 @@ #include "Core/ISceneManager.h" #include "Core/ISelectionManager.h" #include "SceneViewPanel.h" +#include "Viewport/SceneViewportOrientationGizmo.h" #include "Viewport/SceneViewportOverlayRenderer.h" #include "ViewportPanelContent.h" #include "UI/UI.h" @@ -75,24 +76,48 @@ void SceneViewPanel::Render() { !m_lookDragging && !m_panDragging && m_moveGizmo.IsHoveringHandle(); + const SceneViewportOrientationAxis orientationAxisHit = + hasInteractiveViewport && + content.hovered && + !m_lookDragging && + !m_panDragging && + !m_moveGizmo.IsHoveringHandle() && + !m_moveGizmo.IsActive() + ? HitTestSceneViewportOrientationGizmo( + overlay, + content.itemMin, + content.itemMax, + io.MousePos) + : SceneViewportOrientationAxis::None; + const bool orientationGizmoClick = + hasInteractiveViewport && + content.hovered && + ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + orientationAxisHit != SceneViewportOrientationAxis::None; const bool selectClick = hasInteractiveViewport && content.hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && !m_lookDragging && !m_panDragging && + !orientationGizmoClick && !m_moveGizmo.IsHoveringHandle() && !m_moveGizmo.IsActive(); const bool beginLookDrag = + hasInteractiveViewport && content.hovered && + !m_lookDragging && !m_moveGizmo.IsActive() && ImGui::IsMouseClicked(ImGuiMouseButton_Right); const bool beginPanDrag = + hasInteractiveViewport && content.hovered && + !m_panDragging && !m_moveGizmo.IsActive() && + !m_lookDragging && ImGui::IsMouseClicked(ImGuiMouseButton_Middle); - if (beginMoveGizmo || selectClick || beginLookDrag || beginPanDrag) { + if (beginMoveGizmo || orientationGizmoClick || selectClick || beginLookDrag || beginPanDrag) { ImGui::SetWindowFocus(); } @@ -100,6 +125,13 @@ void SceneViewPanel::Render() { m_moveGizmo.TryBeginDrag(moveGizmoContext, m_context->GetUndoManager()); } + if (orientationGizmoClick) { + viewportHostService->AlignSceneViewToOrientationAxis(orientationAxisHit); + overlay = viewportHostService->GetSceneViewOverlayData(); + moveGizmoContext = BuildMoveGizmoContext(*m_context, overlay, content, io.MousePos); + m_moveGizmo.Update(moveGizmoContext); + } + if (selectClick) { const ImVec2 localMousePosition( io.MousePos.x - content.itemMin.x, @@ -125,11 +157,15 @@ void SceneViewPanel::Render() { if (beginLookDrag) { m_lookDragging = true; + m_panDragging = false; m_lastLookDragDelta = ImVec2(0.0f, 0.0f); + m_lastPanDragDelta = ImVec2(0.0f, 0.0f); } if (beginPanDrag) { m_panDragging = true; + m_lookDragging = false; m_lastPanDragDelta = ImVec2(0.0f, 0.0f); + m_lastLookDragDelta = ImVec2(0.0f, 0.0f); } if (m_lookDragging && !ImGui::IsMouseDown(ImGuiMouseButton_Right)) { diff --git a/editor/src/panels/ViewportPanelContent.h b/editor/src/panels/ViewportPanelContent.h index d742b159..412ad329 100644 --- a/editor/src/panels/ViewportPanelContent.h +++ b/editor/src/panels/ViewportPanelContent.h @@ -2,6 +2,7 @@ #include "Core/IEditorContext.h" #include "Viewport/IViewportHostService.h" +#include "UI/UI.h" #include @@ -18,6 +19,9 @@ struct ViewportPanelContentResult { bool hasViewportArea = false; bool hovered = false; bool focused = false; + bool clickedLeft = false; + bool clickedRight = false; + bool clickedMiddle = false; }; inline void DrawViewportStatusMessage(const std::string& message) { @@ -40,8 +44,39 @@ inline void DrawViewportStatusMessage(const std::string& message) { drawList->AddText(textPos, ImGui::GetColorU32(ImGuiCol_TextDisabled), message.c_str()); } +inline const char* GetViewportInteractionSurfaceId(EditorViewportKind kind) { + switch (kind) { + case EditorViewportKind::Scene: + return "##SceneViewportInteractionSurface"; + case EditorViewportKind::Game: + return "##GameViewportInteractionSurface"; + default: + return "##ViewportInteractionSurface"; + } +} + +inline void RenderViewportInteractionSurface( + ViewportPanelContentResult& result, + EditorViewportKind kind, + const ImVec2& interactionSize) { + ImGui::InvisibleButton( + GetViewportInteractionSurfaceId(kind), + interactionSize, + ImGuiButtonFlags_MouseButtonLeft | + ImGuiButtonFlags_MouseButtonRight | + ImGuiButtonFlags_MouseButtonMiddle); + + result.itemMin = ImGui::GetItemRectMin(); + result.itemMax = ImGui::GetItemRectMax(); + result.hovered = ImGui::IsItemHovered(); + result.clickedLeft = ImGui::IsItemClicked(ImGuiMouseButton_Left); + result.clickedRight = ImGui::IsItemClicked(ImGuiMouseButton_Right); + result.clickedMiddle = ImGui::IsItemClicked(ImGuiMouseButton_Middle); +} + inline ViewportPanelContentResult RenderViewportPanelContent(IEditorContext& context, EditorViewportKind kind) { ViewportPanelContentResult result = {}; + UI::CollapsePanelSectionSpacing(); result.availableSize = ImGui::GetContentRegionAvail(); result.focused = ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows); @@ -54,24 +89,19 @@ inline ViewportPanelContentResult RenderViewportPanelContent(IEditorContext& con result.hasViewportArea = true; if (viewportHostService == nullptr) { - ImGui::Dummy(result.availableSize); - result.itemMin = ImGui::GetItemRectMin(); - result.itemMax = ImGui::GetItemRectMax(); - result.hovered = ImGui::IsMouseHoveringRect(result.itemMin, result.itemMax, true); + RenderViewportInteractionSurface(result, kind, result.availableSize); DrawViewportStatusMessage("Viewport host is unavailable"); return result; } result.frame = viewportHostService->RequestViewport(kind, result.availableSize); - if (result.frame.hasTexture) { - ImGui::Image(result.frame.textureId, result.availableSize); - } else { - ImGui::Dummy(result.availableSize); - } + RenderViewportInteractionSurface(result, kind, result.availableSize); - result.itemMin = ImGui::GetItemRectMin(); - result.itemMax = ImGui::GetItemRectMax(); - result.hovered = ImGui::IsMouseHoveringRect(result.itemMin, result.itemMax, true); + if (result.frame.hasTexture) { + if (ImDrawList* drawList = ImGui::GetWindowDrawList()) { + drawList->AddImage(result.frame.textureId, result.itemMin, result.itemMax); + } + } DrawViewportStatusMessage( result.frame.statusText.empty() && !result.frame.hasTexture diff --git a/tests/editor/test_scene_viewport_move_gizmo.cpp b/tests/editor/test_scene_viewport_move_gizmo.cpp index b90cfba4..db988392 100644 --- a/tests/editor/test_scene_viewport_move_gizmo.cpp +++ b/tests/editor/test_scene_viewport_move_gizmo.cpp @@ -1,5 +1,7 @@ #include +#include + #include "Core/EditorContext.h" #include "Managers/SceneManager.h" #include "Viewport/SceneViewportMoveGizmo.h" @@ -11,6 +13,15 @@ float HandleLength(const SceneViewportMoveGizmoHandleDrawData& handle) { return (handle.end - handle.start).Magnitude(); } +Math::Vector2 QuadCenter(const SceneViewportMoveGizmoPlaneDrawData& plane) { + Math::Vector2 center = Math::Vector2::Zero(); + for (const Math::Vector2& corner : plane.corners) { + center += corner; + } + + return center / 4.0f; +} + class SceneViewportMoveGizmoTest : public ::testing::Test { protected: void SetUp() override { @@ -43,6 +54,19 @@ protected: 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 SceneViewportMoveGizmoContext MakeContext( Components::GameObject* selectedObject, const Math::Vector2& mousePosition) { @@ -139,5 +163,63 @@ TEST_F(SceneViewportMoveGizmoTest, AxisNearlyFacingCameraShrinksInsteadOfKeeping EXPECT_LT(yLength, zLength * 0.5f); } +TEST_F(SceneViewportMoveGizmoTest, IsometricViewShowsPlaneHandleAndCanHoverIt) { + Components::GameObject* target = GetSceneManager().CreateEntity("Target"); + ASSERT_NE(target, nullptr); + + SceneViewportMoveGizmo gizmo; + const SceneViewportOverlayData overlay = MakeIsometricOverlay(); + gizmo.Update(MakeContext(target, Math::Vector2(400.0f, 300.0f), overlay)); + + ASSERT_TRUE(gizmo.GetDrawData().visible); + const SceneViewportMoveGizmoPlaneDrawData& xyPlane = gizmo.GetDrawData().planes[0]; + ASSERT_TRUE(xyPlane.visible); + + gizmo.Update(MakeContext(target, QuadCenter(xyPlane), overlay)); + + EXPECT_TRUE(gizmo.IsHoveringHandle()); + EXPECT_TRUE(gizmo.GetDrawData().planes[0].hovered); + EXPECT_FALSE(gizmo.GetDrawData().planes[1].hovered); + EXPECT_FALSE(gizmo.GetDrawData().planes[2].hovered); +} + +TEST_F(SceneViewportMoveGizmoTest, DraggingXYPlaneChangesWorldXAndYButKeepsZ) { + Components::GameObject* target = GetSceneManager().CreateEntity("Target"); + ASSERT_NE(target, nullptr); + const uint64_t targetId = target->GetID(); + + SceneViewportMoveGizmo gizmo; + const SceneViewportOverlayData overlay = MakeIsometricOverlay(); + gizmo.Update(MakeContext(target, Math::Vector2(400.0f, 300.0f), overlay)); + + const SceneViewportMoveGizmoPlaneDrawData& xyPlane = gizmo.GetDrawData().planes[0]; + ASSERT_TRUE(xyPlane.visible); + + const Math::Vector2 startMouse = QuadCenter(xyPlane); + const auto startContext = MakeContext(target, startMouse, overlay); + gizmo.Update(startContext); + ASSERT_TRUE(gizmo.TryBeginDrag(startContext, m_context.GetUndoManager())); + ASSERT_TRUE(gizmo.IsActive()); + + const auto dragContext = MakeContext(target, startMouse + Math::Vector2(48.0f, -24.0f), overlay); + gizmo.Update(dragContext); + gizmo.UpdateDrag(dragContext); + gizmo.EndDrag(m_context.GetUndoManager()); + + const Math::Vector3 movedPosition = target->GetTransform()->GetPosition(); + EXPECT_GT(std::abs(movedPosition.x), 0.05f); + EXPECT_GT(std::abs(movedPosition.y), 0.05f); + 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