diff --git a/docs/plan/NewEditor_UI模块三层与编辑器实现审核方向_2026-04-18.md b/docs/plan/NewEditor_UI模块三层与编辑器实现审核方向_2026-04-18.md new file mode 100644 index 00000000..2c8f3f0d --- /dev/null +++ b/docs/plan/NewEditor_UI模块三层与编辑器实现审核方向_2026-04-18.md @@ -0,0 +1,176 @@ +# NewEditor UI模块三层与编辑器实现审核方向 + +日期:2026-04-18 + +## 目的 + +本文档不直接下具体问题结论,只定义后续审核 `UI基础层`、`UI runtime层`、`UI editor层` 以及 `new_editor` 实现时应采用的审查方向。所有审查都以软件工程最佳实践为准,优先关注边界、依赖、状态、抽象与可演进性,而不是先陷入局部功能细节。 + +## 一、分层边界审核 + +重点确认三层职责是否真正清晰: + +- 基础层只承载通用能力,例如输入分发、布局、绘制、基础状态机、通用 widget 模型与无业务语义的交互原语。 +- runtime 层只面向运行时 UI 场景与游戏内使用,不应反向依赖 editor 语义、编辑器数据结构或编辑器交互逻辑。 +- editor 层只承载编辑器专用抽象,例如面板、dock、property grid、viewport shell、editor command、workspace model,不应把平台细节和项目业务直接揉进通用控件。 +- `new_editor/app` 应被视为产品组装层与宿主集成层,而不是继续沉淀“半通用框架能力”。 + +审核标准: + +- 下层不知道上层。 +- 通用层不带具体业务名词。 +- 产品层不反向定义基础规则。 + +## 二、依赖方向审核 + +重点确认依赖是否单向、可解释、可约束: + +- CMake target 依赖是否符合层次关系。 +- 头文件暴露面是否过宽,是否存在把实现细节暴露成公共 API 的情况。 +- include 路径是否允许跨层随意直连。 +- 命名空间是否真实表达层次,而不是目录分了层、代码却还是互相穿透。 +- `new_editor` 是否仍在通过直接复用旧 editor 文件形成隐式耦合。 + +审核标准: + +- 依赖方向必须单向。 +- 公共 API 必须小而稳定。 +- 跨层访问必须通过明确桥接或契约,而不是直接拿内部实现。 + +## 三、抽象质量审核 + +重点确认当前抽象是不是“真抽象”,而不是“为某个面板包一层名字”: + +- Tree、List、TabStrip、DockHost、Workspace、PropertyGrid、ViewportShell 这些是否能服务多个场景。 +- 抽象内部是否塞了大量 feature 特判。 +- 同类问题是否复用同一套模型、布局、命中测试、交互状态与事件语义。 +- editor 抽象与 app feature 是否耦合过深。 + +审核标准: + +- 抽象必须稳定。 +- 抽象必须可复用。 +- 抽象必须能脱离某个 feature 单独成立。 + +## 四、状态模型审核 + +重点确认状态是否单一、清晰、可追踪: + +- 持久状态、会话状态、瞬时交互状态是否分层保存。 +- selection、focus、hover、active、capture、drag、dock、undo 是否各有唯一真源。 +- 是否存在多个模块同时维护一份接近但不完全一致的状态。 +- UI 视觉状态是否只是状态投影,而不是新的状态源。 + +审核标准: + +- 每类核心状态只能有一个权威来源。 +- 交互状态的生命周期必须明确。 +- 状态同步必须可删减,而不是越补越多。 + +## 五、输入与交互一致性审核 + +重点确认整个 UI 模块是否存在统一交互规则: + +- 鼠标、键盘、快捷键、焦点、指针捕获、拖拽、弹出层、多窗口输入是否走统一分发路径。 +- 复杂控件是否共用统一输入桥,而不是每个 feature 私自命中、私自切焦点、私自维护 active 态。 +- 多窗口、dock、tab 拖拽、viewport 操作之间是否有一致的优先级与冲突消解规则。 +- editor 特有交互是否建立在通用输入机制之上。 + +审核标准: + +- 输入规则统一。 +- 捕获规则统一。 +- 拖拽与命中优先级统一。 + +## 六、渲染职责审核 + +重点确认“UI 逻辑”和“渲染后端”是否分离: + +- widget、panel、viewport shell 是否只产出布局与绘制意图。 +- native renderer、D3D12 host、平台窗口层是否只执行渲染与呈现职责。 +- scene viewport、overlay、gizmo、selection outline、host texture 是否边界清楚。 +- UI 抽象是否过度依赖某个 renderer 或平台宿主。 + +审核标准: + +- 逻辑层不理解渲染后端细节。 +- 渲染层不承载业务决策。 +- viewport 是组合点,不是大杂烩。 + +## 七、平台宿主隔离审核 + +重点确认平台细节是否被关在宿主层: + +- Win32 消息、窗口生命周期、D3D12 设备、swapchain、原生纹理互操作是否留在宿主适配层。 +- editor feature 是否直接依赖窗口句柄、平台消息或图形后端对象。 +- 平台适配接口是否足够窄,是否能支撑后续替换或重构。 + +审核标准: + +- 平台代码不泄漏到通用 editor 逻辑。 +- 业务层只能看到抽象宿主能力。 + +## 八、文件夹结构与命名审核 + +重点确认工程组织是否能表达真实架构: + +- 目录是否按层次与职责划分,而不是按历史来源堆积。 +- `include` 是否只暴露正式公共契约。 +- `src` 是否只包含内部实现。 +- `app` 是否只包含产品装配、feature 实现、平台宿主与渲染接线。 +- 命名中的 `UI`、`UIEditor`、`Shell`、`Workspace`、`Viewport`、`Runtime`、`Host`、`Bridge` 是否语义稳定。 + +审核标准: + +- 目录结构能反映架构。 +- 命名能反映边界。 +- 公开层和内部层必须分开。 + +## 九、测试体系审核 + +重点确认测试是否保护了架构,而不只是保护表面功能: + +- 基础层是否有纯单元测试,覆盖布局、命中、状态机、输入路由。 +- editor 层是否有交互级测试,覆盖 panel lifecycle、dock、workspace、tree、property grid、viewport。 +- app 层是否有 smoke test 与关键集成测试。 +- 复杂输入链路和多窗口行为是否有回归测试。 + +审核标准: + +- 基础抽象必须可单测。 +- 关键交互必须可回归。 +- 集成层必须可冒烟验证。 + +## 十、迁移与收口路径审核 + +重点确认当前架构是不是在朝终态收敛: + +- legacy editor 代码的复用是临时桥接还是永久依赖。 +- 每一个 bridge、compat、temporary、legacy 入口是否都有退出路径。 +- 是否存在“先能跑起来,后面再整理”的长期滞留区域。 +- 三层 UI 的长期归属是否已经定义清楚。 + +审核标准: + +- 临时桥必须可拆。 +- 迁移路径必须可描述。 +- 每次重构都应让终态更清晰,而不是更模糊。 + +## 建议审核顺序 + +建议按以下顺序推进,而不是一开始就逐个面板挑 bug: + +1. 先审分层图、target 依赖图、命名空间边界。 +2. 再审核心状态图,包括 workspace、selection、focus、capture、viewport interaction。 +3. 再审通用抽象,包括 tree、dock、viewport、property grid。 +4. 最后审具体 feature,包括 hierarchy、project、inspector、scene。 + +## 核心判断标准 + +后续所有审核都可以收敛到三个总标准: + +- 边界是否清晰。 +- 状态是否单一。 +- 交互是否统一。 + +如果这三项不成立,后面的命名、目录、实现细节都会持续反复出问题。 diff --git a/new_editor/app/Features/Scene/LegacySceneViewportGizmo.cpp b/new_editor/app/Features/Scene/LegacySceneViewportGizmo.cpp index ef00fb7d..89f481ed 100644 --- a/new_editor/app/Features/Scene/LegacySceneViewportGizmo.cpp +++ b/new_editor/app/Features/Scene/LegacySceneViewportGizmo.cpp @@ -233,6 +233,38 @@ ActiveLegacyGizmoKind ResolveActiveGizmoKind( return ActiveLegacyGizmoKind::None; } +bool IsTransformToolMode(SceneToolMode mode) { + return mode == SceneToolMode::Transform; +} + +bool ShouldShowMoveGizmo(SceneToolMode mode) { + return mode == SceneToolMode::Translate || IsTransformToolMode(mode); +} + +bool ShouldShowRotateGizmo(SceneToolMode mode) { + return mode == SceneToolMode::Rotate || IsTransformToolMode(mode); +} + +bool ShouldShowScaleGizmo(SceneToolMode mode) { + return mode == SceneToolMode::Scale || IsTransformToolMode(mode); +} + +Vector2 ResolveUpdatePointerPosition( + const Vector2& pointerPosition, + bool hoverEnabled, + ActiveLegacyGizmoKind activeKind, + ActiveLegacyGizmoKind gizmoKind) { + if (!hoverEnabled && activeKind == ActiveLegacyGizmoKind::None) { + return Vector2(-1.0f, -1.0f); + } + + if (activeKind != ActiveLegacyGizmoKind::None && activeKind != gizmoKind) { + return Vector2(-1.0f, -1.0f); + } + + return pointerPosition; +} + class SceneGizmoUndoBridge final : public IUndoManager { public: void Bind(EditorSceneRuntime& sceneRuntime) { @@ -391,24 +423,13 @@ void LegacySceneViewportGizmo::Refresh( return; } - const ActiveLegacyGizmoKind activeKind = ResolveActiveGizmoKind( - state.moveGizmo, - state.rotateGizmo, - state.scaleGizmo); Vector2 localPointer = ToLocalPoint(viewportRect, pointerScreen); - if (!hoverEnabled && activeKind == ActiveLegacyGizmoKind::None) { - localPointer = Vector2(-1.0f, -1.0f); - } - + const bool usingTransformTool = IsTransformToolMode(toolMode); + const bool showingMoveGizmo = ShouldShowMoveGizmo(toolMode); + const bool showingRotateGizmo = ShouldShowRotateGizmo(toolMode); + const bool showingScaleGizmo = ShouldShowScaleGizmo(toolMode); SceneViewportTransformGizmoHandleBuildInputs inputs = {}; - switch (toolMode) { - case SceneToolMode::Translate: { - if (activeKind == ActiveLegacyGizmoKind::Rotate) { - state.rotateGizmo.CancelDrag(&state.undoBridge); - } else if (activeKind == ActiveLegacyGizmoKind::Scale) { - state.scaleGizmo.CancelDrag(&state.undoBridge); - } - + if (showingMoveGizmo) { state.moveContext = BuildMoveContext( selection, overlay, @@ -421,19 +442,11 @@ void LegacySceneViewportGizmo::Refresh( state.moveGizmo.GetActiveEntityId()) { state.moveGizmo.CancelDrag(&state.undoBridge); } - state.moveGizmo.Update(state.moveContext); - inputs.moveGizmo = &state.moveGizmo.GetDrawData(); - inputs.moveEntityId = selection.primaryObject->GetID(); - break; + } else if (state.moveGizmo.IsActive()) { + state.moveGizmo.CancelDrag(&state.undoBridge); } - case SceneToolMode::Rotate: { - if (activeKind == ActiveLegacyGizmoKind::Move) { - state.moveGizmo.CancelDrag(&state.undoBridge); - } else if (activeKind == ActiveLegacyGizmoKind::Scale) { - state.scaleGizmo.CancelDrag(&state.undoBridge); - } - + if (showingRotateGizmo) { state.rotateContext = BuildRotateContext( selection, overlay, @@ -447,40 +460,67 @@ void LegacySceneViewportGizmo::Refresh( state.rotateGizmo.GetActiveEntityId()) { state.rotateGizmo.CancelDrag(&state.undoBridge); } - state.rotateGizmo.Update(state.rotateContext); - inputs.rotateGizmo = &state.rotateGizmo.GetDrawData(); - inputs.rotateEntityId = selection.primaryObject->GetID(); - break; + } else if (state.rotateGizmo.IsActive()) { + state.rotateGizmo.CancelDrag(&state.undoBridge); } - case SceneToolMode::Scale: { - if (activeKind == ActiveLegacyGizmoKind::Move) { - state.moveGizmo.CancelDrag(&state.undoBridge); - } else if (activeKind == ActiveLegacyGizmoKind::Rotate) { - state.rotateGizmo.CancelDrag(&state.undoBridge); - } - + if (showingScaleGizmo) { state.scaleContext = BuildScaleContext( selection, overlay, viewportRect, localPointer, localSpace); + state.scaleContext.uniformOnly = usingTransformTool; if (state.scaleGizmo.IsActive() && state.scaleContext.selectedObject != nullptr && state.scaleContext.selectedObject->GetID() != state.scaleGizmo.GetActiveEntityId()) { state.scaleGizmo.CancelDrag(&state.undoBridge); } - state.scaleGizmo.Update(state.scaleContext); - inputs.scaleGizmo = &state.scaleGizmo.GetDrawData(); - inputs.scaleEntityId = selection.primaryObject->GetID(); - break; + } else if (state.scaleGizmo.IsActive()) { + state.scaleGizmo.CancelDrag(&state.undoBridge); } - case SceneToolMode::View: - default: - break; + const ActiveLegacyGizmoKind activeKind = ResolveActiveGizmoKind( + state.moveGizmo, + state.rotateGizmo, + state.scaleGizmo); + + if (showingMoveGizmo) { + SceneViewportMoveGizmoContext updateContext = state.moveContext; + updateContext.mousePosition = ResolveUpdatePointerPosition( + state.moveContext.mousePosition, + hoverEnabled, + activeKind, + ActiveLegacyGizmoKind::Move); + state.moveGizmo.Update(updateContext); + inputs.moveGizmo = &state.moveGizmo.GetDrawData(); + inputs.moveEntityId = selection.primaryObject->GetID(); + } + + if (showingRotateGizmo) { + SceneViewportRotateGizmoContext updateContext = state.rotateContext; + updateContext.mousePosition = ResolveUpdatePointerPosition( + state.rotateContext.mousePosition, + hoverEnabled, + activeKind, + ActiveLegacyGizmoKind::Rotate); + state.rotateGizmo.Update(updateContext); + inputs.rotateGizmo = &state.rotateGizmo.GetDrawData(); + inputs.rotateEntityId = selection.primaryObject->GetID(); + } + + if (showingScaleGizmo) { + SceneViewportScaleGizmoContext updateContext = state.scaleContext; + updateContext.mousePosition = ResolveUpdatePointerPosition( + state.scaleContext.mousePosition, + hoverEnabled, + activeKind, + ActiveLegacyGizmoKind::Scale); + state.scaleGizmo.Update(updateContext); + inputs.scaleGizmo = &state.scaleGizmo.GetDrawData(); + inputs.scaleEntityId = selection.primaryObject->GetID(); } const SceneViewportOverlayFrameData overlayFrame = @@ -515,6 +555,23 @@ bool LegacySceneViewportGizmo::TryBeginDrag(EditorSceneRuntime& sceneRuntime) { return state.rotateGizmo.TryBeginDrag(state.rotateContext, state.undoBridge); case SceneToolMode::Scale: return state.scaleGizmo.TryBeginDrag(state.scaleContext, state.undoBridge); + case SceneToolMode::Transform: + if (state.scaleGizmo.EvaluateHit(state.scaleContext.mousePosition).HasHit()) { + return state.scaleGizmo.TryBeginDrag( + state.scaleContext, + state.undoBridge); + } + if (state.moveGizmo.EvaluateHit(state.moveContext.mousePosition).HasHit()) { + return state.moveGizmo.TryBeginDrag( + state.moveContext, + state.undoBridge); + } + if (state.rotateGizmo.EvaluateHit(state.rotateContext.mousePosition).HasHit()) { + return state.rotateGizmo.TryBeginDrag( + state.rotateContext, + state.undoBridge); + } + return false; case SceneToolMode::View: default: return false; diff --git a/new_editor/app/Features/Scene/SceneViewportController.cpp b/new_editor/app/Features/Scene/SceneViewportController.cpp index 507a7a68..f2401c1e 100644 --- a/new_editor/app/Features/Scene/SceneViewportController.cpp +++ b/new_editor/app/Features/Scene/SceneViewportController.cpp @@ -12,7 +12,6 @@ #include #include -#include namespace XCEngine::UI::Editor::App { @@ -22,10 +21,6 @@ using ::XCEngine::Input::KeyCode; using ::XCEngine::UI::UIPoint; using ::XCEngine::UI::UIPointerButton; using ::XCEngine::UI::UIRect; -using ::XCEngine::UI::Editor::Widgets::HitTestUIEditorViewportSlot; -using ::XCEngine::UI::Editor::Widgets::UIEditorViewportSlotHitTarget; -using ::XCEngine::UI::Editor::Widgets::UIEditorViewportSlotHitTargetKind; - constexpr float kWheelDeltaPerStep = 120.0f; bool ContainsPoint(const UIRect& rect, const UIPoint& point) { @@ -69,6 +64,17 @@ float NormalizeWheelDelta(float wheelDelta) { return wheelDelta / kWheelDeltaPerStep; } +bool IsViewMoveTool(SceneToolMode mode) { + return mode == SceneToolMode::View; +} + +bool IsPanDragButtonDown( + const UIEditorViewportInputBridgeState& state, + UIPointerButton button) { + return button != UIPointerButton::None && + IsUIEditorViewportInputBridgePointerButtonDown(state, button); +} + bool TryResolveToolModeShortcut( const UIEditorViewportInputBridgeFrame& inputFrame, SceneToolMode& outMode) { @@ -98,29 +104,6 @@ bool TryResolveToolModeShortcut( return false; } -bool ApplySceneToolbarCommand( - std::string_view itemId, - EditorSceneRuntime& sceneRuntime) { - if (itemId == "scene.pivot.pivot") { - sceneRuntime.SetToolPivotMode(SceneToolPivotMode::Pivot); - return true; - } - if (itemId == "scene.pivot.center") { - sceneRuntime.SetToolPivotMode(SceneToolPivotMode::Center); - return true; - } - if (itemId == "scene.space.world") { - sceneRuntime.SetToolSpaceMode(SceneToolSpaceMode::World); - return true; - } - if (itemId == "scene.space.local") { - sceneRuntime.SetToolSpaceMode(SceneToolSpaceMode::Local); - return true; - } - - return false; -} - void ApplySceneToolMode( SceneToolMode mode, EditorSceneRuntime& sceneRuntime, @@ -166,7 +149,6 @@ void SceneViewportController::Update( if (m_legacyGizmo.IsActive()) { m_legacyGizmo.CancelDrag(sceneRuntime); } - sceneRuntime.ClearToolbarInteraction(); sceneRuntime.SetHoveredToolHandle(SceneToolHandle::None); ResetInteractionState(); return; @@ -175,7 +157,6 @@ void SceneViewportController::Update( const auto& inputFrame = viewportFrame->viewportShellFrame.inputFrame; const auto& inputState = panelState->viewportShellState.inputBridgeState; const auto& slotLayout = viewportFrame->viewportShellFrame.slotLayout; - const auto& toolItems = viewportFrame->viewportShellModel.spec.toolItems; const bool leftMouseDown = IsUIEditorViewportInputBridgePointerButtonDown( inputState, UIPointerButton::Left); @@ -196,55 +177,8 @@ void SceneViewportController::Update( if (m_legacyGizmo.IsActive()) { m_legacyGizmo.CancelDrag(sceneRuntime); } - sceneRuntime.ClearToolbarInteraction(); } - std::size_t hoveredToolbarIndex = kSceneToolInvalidToolbarIndex; - if (!toolItems.empty() && inputState.hasPointerPosition) { - const UIEditorViewportSlotHitTarget toolbarHit = - HitTestUIEditorViewportSlot(slotLayout, pointerScreen); - if (toolbarHit.kind == UIEditorViewportSlotHitTargetKind::ToolItem && - toolbarHit.index < toolItems.size() && - toolItems[toolbarHit.index].enabled) { - hoveredToolbarIndex = toolbarHit.index; - } - } - - if (m_legacyGizmo.IsActive()) { - sceneRuntime.ClearToolbarInteraction(); - } else { - sceneRuntime.SetToolbarHoveredIndex(hoveredToolbarIndex); - if (inputFrame.changedPointerButton == UIPointerButton::Left && - leftMouseDown && - hoveredToolbarIndex != kSceneToolInvalidToolbarIndex) { - sceneRuntime.SetToolbarActiveIndex(hoveredToolbarIndex); - sceneRuntime.SetToolInteractionLock(SceneToolInteractionLock::Toolbar); - } else if (inputFrame.changedPointerButton == UIPointerButton::Left && - !leftMouseDown) { - const std::size_t activeToolbarIndex = - sceneRuntime.GetToolState().toolbarActiveIndex; - const bool applyCommand = - activeToolbarIndex != kSceneToolInvalidToolbarIndex && - activeToolbarIndex == hoveredToolbarIndex && - activeToolbarIndex < toolItems.size() && - toolItems[activeToolbarIndex].enabled; - sceneRuntime.ClearToolbarInteraction(); - if (applyCommand) { - ApplySceneToolbarCommand( - toolItems[activeToolbarIndex].itemId, - sceneRuntime); - } - } else if (!leftMouseDown && - sceneRuntime.GetToolState().interactionLock == - SceneToolInteractionLock::Toolbar) { - sceneRuntime.ClearToolbarInteraction(); - } - } - - const bool toolbarInteractionActive = - sceneRuntime.GetToolState().interactionLock == - SceneToolInteractionLock::Toolbar; - m_toolOverlay.BuildFrame( slotLayout.inputRect, sceneRuntime.GetToolMode(), @@ -258,24 +192,30 @@ void SceneViewportController::Update( ? m_toolOverlay.HitTest(pointerScreen) : kSceneViewportToolOverlayInvalidIndex; - if (inputFrame.changedPointerButton == UIPointerButton::Left && - leftMouseDown && - hoveredToolOverlayIndex != kSceneViewportToolOverlayInvalidIndex && - !toolbarInteractionActive && - !m_legacyGizmo.IsActive()) { - m_activeToolOverlayIndex = hoveredToolOverlayIndex; - } else if (inputFrame.changedPointerButton == UIPointerButton::Left && - !leftMouseDown) { - if (m_activeToolOverlayIndex != kSceneViewportToolOverlayInvalidIndex && - m_activeToolOverlayIndex == hoveredToolOverlayIndex && - m_activeToolOverlayIndex < m_toolOverlay.GetFrame().buttons.size()) { - ApplySceneToolMode( - m_toolOverlay.GetFrame().buttons[m_activeToolOverlayIndex].mode, - sceneRuntime, - m_legacyGizmo); + for (const auto& transition : inputFrame.pointerButtonTransitions) { + if (transition.button != UIPointerButton::Left) { + continue; } + + const std::size_t transitionHoveredToolOverlayIndex = + m_toolOverlay.HitTest(transition.screenPosition); + if (transition.pressed && + transitionHoveredToolOverlayIndex != + kSceneViewportToolOverlayInvalidIndex && + !m_legacyGizmo.IsActive()) { + m_activeToolOverlayIndex = transitionHoveredToolOverlayIndex; + if (m_activeToolOverlayIndex < m_toolOverlay.GetFrame().buttons.size()) { + ApplySceneToolMode( + m_toolOverlay.GetFrame().buttons[m_activeToolOverlayIndex].mode, + sceneRuntime, + m_legacyGizmo); + } + continue; + } + m_activeToolOverlayIndex = kSceneViewportToolOverlayInvalidIndex; - } else if (!leftMouseDown) { + } + if (!leftMouseDown) { m_activeToolOverlayIndex = kSceneViewportToolOverlayInvalidIndex; } m_hoveredToolOverlayIndex = hoveredToolOverlayIndex; @@ -289,43 +229,55 @@ void SceneViewportController::Update( const bool toolOverlayInteractionActive = m_activeToolOverlayIndex != kSceneViewportToolOverlayInvalidIndex; - if (!toolbarInteractionActive && - !toolOverlayInteractionActive && - !m_legacyGizmo.IsActive()) { - if (inputFrame.pointerPressedInside && - inputFrame.changedPointerButton == UIPointerButton::Right && - !pointerOverToolOverlay) { - m_navigationState.lookDragging = true; - m_navigationState.panDragging = false; - } - if (inputFrame.pointerPressedInside && - inputFrame.changedPointerButton == UIPointerButton::Middle && - !pointerOverToolOverlay) { - m_navigationState.panDragging = true; - m_navigationState.lookDragging = false; + const bool usingViewMoveTool = IsViewMoveTool(sceneRuntime.GetToolMode()); + + if (!m_legacyGizmo.IsActive()) { + for (const auto& transition : inputFrame.pointerButtonTransitions) { + if (!transition.pressed || + !ContainsPoint(slotLayout.inputRect, transition.screenPosition) || + m_toolOverlay.Contains(transition.screenPosition)) { + continue; + } + + if (usingViewMoveTool && + transition.button == UIPointerButton::Left) { + m_navigationState.panDragging = true; + m_navigationState.lookDragging = false; + m_navigationState.panDragButton = UIPointerButton::Left; + } else if (transition.button == UIPointerButton::Right) { + m_navigationState.lookDragging = true; + m_navigationState.panDragging = false; + m_navigationState.panDragButton = UIPointerButton::None; + } else if (transition.button == UIPointerButton::Middle) { + m_navigationState.panDragging = true; + m_navigationState.lookDragging = false; + m_navigationState.panDragButton = UIPointerButton::Middle; + } } } if (m_navigationState.lookDragging && !rightMouseDown) { m_navigationState.lookDragging = false; } - if (m_navigationState.panDragging && !middleMouseDown) { + if (m_navigationState.panDragging && + !IsPanDragButtonDown(inputState, m_navigationState.panDragButton)) { m_navigationState.panDragging = false; + m_navigationState.panDragButton = UIPointerButton::None; } const bool viewportHoverEligible = inputState.hasPointerPosition && ContainsPoint(slotLayout.inputRect, pointerScreen) && !pointerOverToolOverlay && - !toolbarInteractionActive && !toolOverlayInteractionActive && !m_navigationState.lookDragging && !m_navigationState.panDragging; if (!m_legacyGizmo.IsActive() && - !toolbarInteractionActive && !toolOverlayInteractionActive && - inputFrame.focused) { + inputFrame.focused && + !m_navigationState.lookDragging && + !m_navigationState.panDragging) { SceneToolMode shortcutMode = SceneToolMode::View; if (TryResolveToolModeShortcut(inputFrame, shortcutMode)) { ApplySceneToolMode(shortcutMode, sceneRuntime, m_legacyGizmo); @@ -356,8 +308,7 @@ void SceneViewportController::Update( return; } - if (inputFrame.changedPointerButton == UIPointerButton::Left && - !leftMouseDown) { + if (!leftMouseDown) { m_legacyGizmo.EndDrag(sceneRuntime); m_legacyGizmo.Refresh( sceneRuntime, @@ -376,48 +327,52 @@ void SceneViewportController::Update( return; } - const bool shouldStartTransformDrag = - inputFrame.pointerPressedInside && - inputFrame.changedPointerButton == UIPointerButton::Left && - viewportHoverEligible && - m_legacyGizmo.IsHoveringHandle(); - if (shouldStartTransformDrag && - m_legacyGizmo.TryBeginDrag(sceneRuntime)) { - m_navigationState = {}; - m_legacyGizmo.Refresh( - sceneRuntime, - slotLayout.inputRect, - pointerScreen, - viewportHoverEligible); - return; - } - - const bool shouldPickSelection = - inputFrame.pointerPressedInside && - inputFrame.changedPointerButton == UIPointerButton::Left && - viewportHoverEligible && - !m_legacyGizmo.IsHoveringHandle(); - if (shouldPickSelection) { - const ViewportObjectIdPickResult pickResult = - viewportHostService.PickSceneViewportObject( - viewportFrame->viewportShellFrame.requestedViewportSize, - inputFrame.localPointerPosition); - if (pickResult.status == ViewportObjectIdPickStatus::Success) { - if (pickResult.entityId != 0u) { - sceneRuntime.SetSelection(pickResult.entityId); - } else { - sceneRuntime.ClearSelection(); + if (!usingViewMoveTool) { + for (const auto& transition : inputFrame.pointerButtonTransitions) { + if (!transition.pressed || + transition.button != UIPointerButton::Left || + !ContainsPoint(slotLayout.inputRect, transition.screenPosition) || + m_toolOverlay.Contains(transition.screenPosition)) { + continue; } - } - m_legacyGizmo.Refresh( - sceneRuntime, - slotLayout.inputRect, - pointerScreen, - viewportHoverEligible); + m_legacyGizmo.Refresh( + sceneRuntime, + slotLayout.inputRect, + transition.screenPosition, + true); + if (m_legacyGizmo.IsHoveringHandle() && + m_legacyGizmo.TryBeginDrag(sceneRuntime)) { + m_navigationState = {}; + m_legacyGizmo.Refresh( + sceneRuntime, + slotLayout.inputRect, + pointerScreen, + viewportHoverEligible); + return; + } + + const ViewportObjectIdPickResult pickResult = + viewportHostService.PickSceneViewportObject( + viewportFrame->viewportShellFrame.requestedViewportSize, + transition.localPointerPosition); + if (pickResult.status == ViewportObjectIdPickStatus::Success) { + if (pickResult.entityId != 0u) { + sceneRuntime.SetSelection(pickResult.entityId); + } else { + sceneRuntime.ClearSelection(); + } + } + + m_legacyGizmo.Refresh( + sceneRuntime, + slotLayout.inputRect, + pointerScreen, + viewportHoverEligible); + } } - if (toolbarInteractionActive || toolOverlayInteractionActive) { + if (toolOverlayInteractionActive) { return; } @@ -443,7 +398,7 @@ void SceneViewportController::Update( } else if (m_navigationState.panDragging) { input.panDeltaX = inputFrame.pointerDelta.x; input.panDeltaY = inputFrame.pointerDelta.y; - } else if (inputFrame.hovered && !pointerOverToolOverlay) { + } else if (ContainsPoint(slotLayout.inputRect, pointerScreen) && !pointerOverToolOverlay) { input.zoomDelta = NormalizeWheelDelta(inputFrame.wheelDelta); } diff --git a/new_editor/app/Features/Scene/SceneViewportController.h b/new_editor/app/Features/Scene/SceneViewportController.h index cbb1c99a..cce91dfd 100644 --- a/new_editor/app/Features/Scene/SceneViewportController.h +++ b/new_editor/app/Features/Scene/SceneViewportController.h @@ -6,6 +6,7 @@ #include #include +#include #include #include @@ -44,6 +45,8 @@ private: struct NavigationState { bool lookDragging = false; bool panDragging = false; + ::XCEngine::UI::UIPointerButton panDragButton = + ::XCEngine::UI::UIPointerButton::None; }; void ResetFrameState(); diff --git a/new_editor/app/Features/Scene/SceneViewportToolOverlay.cpp b/new_editor/app/Features/Scene/SceneViewportToolOverlay.cpp index bd80059f..b2e5d5b4 100644 --- a/new_editor/app/Features/Scene/SceneViewportToolOverlay.cpp +++ b/new_editor/app/Features/Scene/SceneViewportToolOverlay.cpp @@ -14,27 +14,28 @@ using ::XCEngine::UI::UIDrawList; using ::XCEngine::UI::UIPoint; using ::XCEngine::UI::UIRect; -constexpr float kButtonExtent = 30.0f; -constexpr float kButtonSpacing = 6.0f; -constexpr float kPanelPadding = 6.0f; +constexpr float kButtonExtent = 26.0f; +constexpr float kButtonSpacing = 4.0f; +constexpr float kPanelPadding = 5.0f; constexpr float kViewportInset = 10.0f; -constexpr float kIconInset = 4.0f; -constexpr float kPanelCornerRounding = 7.0f; -constexpr float kButtonCornerRounding = 5.0f; -constexpr float kFallbackFontSize = 11.0f; +constexpr float kIconInset = 3.0f; +constexpr float kPanelCornerRounding = 6.0f; +constexpr float kButtonCornerRounding = 4.0f; +constexpr float kFallbackFontSize = 10.0f; -struct ToolTexturePath { +struct ToolButtonSpec { SceneToolMode mode = SceneToolMode::View; const char* label = ""; const char* inactiveFile = ""; const char* activeFile = ""; }; -constexpr std::array kToolTexturePaths = {{ +constexpr std::array kToolButtonSpecs = {{ { SceneToolMode::View, "View", "view_move_tool.png", "view_move_tool_on.png" }, { SceneToolMode::Translate, "Move", "move_tool.png", "move_tool_on.png" }, { SceneToolMode::Rotate, "Rotate", "rotate_tool.png", "rotate_tool_on.png" }, - { SceneToolMode::Scale, "Scale", "scale_tool.png", "scale_tool_on.png" } + { SceneToolMode::Scale, "Scale", "scale_tool.png", "scale_tool_on.png" }, + { SceneToolMode::Transform, "Transform", "transform_tool.png", "transform_tool_on.png" } }}; bool ContainsPoint(const UIRect& rect, const UIPoint& point) { @@ -62,8 +63,8 @@ bool SceneViewportToolOverlay::Initialize( const std::filesystem::path iconRoot = (repoRoot / "editor" / "resources" / "Icons").lexically_normal(); bool loadedAnyTexture = false; - for (std::size_t index = 0; index < kToolTexturePaths.size(); ++index) { - const ToolTexturePath& path = kToolTexturePaths[index]; + for (std::size_t index = 0; index < kToolButtonSpecs.size(); ++index) { + const ToolButtonSpec& path = kToolButtonSpecs[index]; ToolTextureSet& textureSet = m_toolTextures[index]; textureSet = {}; textureSet.mode = path.mode; @@ -121,13 +122,14 @@ void SceneViewportToolOverlay::BuildFrame( static_cast(m_frame.buttons.size() - 1u) * kButtonSpacing); for (std::size_t index = 0; index < m_frame.buttons.size(); ++index) { + const ToolButtonSpec& spec = kToolButtonSpecs[index]; const ToolTextureSet& textureSet = m_toolTextures[index]; SceneViewportToolOverlayButtonFrame& button = m_frame.buttons[index]; button = {}; - button.mode = textureSet.mode; - button.label = textureSet.label; + button.mode = spec.mode; + button.label = spec.label; button.rect = BuildButtonRect(m_frame.panelRect, index); - button.active = textureSet.mode == activeMode; + button.active = spec.mode == activeMode; button.hovered = index == hoveredIndex; button.pressed = index == pressedIndex; button.texture = button.active diff --git a/new_editor/app/Features/Scene/SceneViewportToolOverlay.h b/new_editor/app/Features/Scene/SceneViewportToolOverlay.h index ed72044d..f055d8bc 100644 --- a/new_editor/app/Features/Scene/SceneViewportToolOverlay.h +++ b/new_editor/app/Features/Scene/SceneViewportToolOverlay.h @@ -34,7 +34,7 @@ struct SceneViewportToolOverlayFrame { bool visible = false; ::XCEngine::UI::UIRect clipRect = {}; ::XCEngine::UI::UIRect panelRect = {}; - std::array buttons = {}; + std::array buttons = {}; }; class SceneViewportToolOverlay { @@ -64,7 +64,7 @@ private: ::XCEngine::UI::UITextureHandle activeTexture = {}; }; - std::array m_toolTextures = {}; + std::array m_toolTextures = {}; SceneViewportToolOverlayFrame m_frame = {}; }; diff --git a/new_editor/app/Scene/EditorSceneBridge.cpp b/new_editor/app/Scene/EditorSceneBridge.cpp new file mode 100644 index 00000000..d280d21a --- /dev/null +++ b/new_editor/app/Scene/EditorSceneBridge.cpp @@ -0,0 +1,320 @@ +#include "Scene/EditorSceneBridge.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace XCEngine::UI::Editor::App { + +namespace { + +using ::XCEngine::Components::Component; +using ::XCEngine::Components::ComponentFactoryRegistry; +using ::XCEngine::Components::GameObject; +using ::XCEngine::Components::Scene; +using ::XCEngine::Components::SceneManager; +using ::XCEngine::Components::TransformComponent; +using ::XCEngine::Resources::ResourceManager; + +struct ClipboardNode { + std::string name = {}; + std::string transformPayload = {}; + std::vector> components = {}; + std::vector children = {}; +}; + +Scene* ResolvePrimaryScene() { + SceneManager& sceneManager = SceneManager::Get(); + if (Scene* activeScene = sceneManager.GetActiveScene(); + activeScene != nullptr) { + return activeScene; + } + + const std::vector scenes = sceneManager.GetAllScenes(); + if (!scenes.empty() && scenes.front() != nullptr) { + sceneManager.SetActiveScene(scenes.front()); + return scenes.front(); + } + + return nullptr; +} + +std::pair SerializeComponent( + const Component* component) { + std::ostringstream payload = {}; + component->Serialize(payload); + return { component->GetName(), payload.str() }; +} + +ClipboardNode CopyGameObjectRecursive(const GameObject& gameObject) { + ClipboardNode node = {}; + node.name = gameObject.GetName(); + + if (const TransformComponent* transform = gameObject.GetTransform(); + transform != nullptr) { + std::ostringstream payload = {}; + transform->Serialize(payload); + node.transformPayload = payload.str(); + } + + const auto components = gameObject.GetComponents(); + for (const Component* component : components) { + if (component == nullptr || component == gameObject.GetTransform()) { + continue; + } + + node.components.push_back(SerializeComponent(component)); + } + + for (GameObject* child : gameObject.GetChildren()) { + if (child == nullptr) { + continue; + } + + node.children.push_back(CopyGameObjectRecursive(*child)); + } + + return node; +} + +GameObject* PasteGameObjectRecursive( + Scene& scene, + const ClipboardNode& node, + GameObject* parent) { + GameObject* gameObject = scene.CreateGameObject(node.name, parent); + if (gameObject == nullptr) { + return nullptr; + } + + if (!node.transformPayload.empty()) { + if (TransformComponent* transform = gameObject->GetTransform(); + transform != nullptr) { + std::istringstream transformStream(node.transformPayload); + transform->Deserialize(transformStream); + } + } + + for (const auto& componentData : node.components) { + Component* component = + ComponentFactoryRegistry::Get().CreateComponent( + gameObject, + componentData.first); + if (component == nullptr || componentData.second.empty()) { + continue; + } + + std::istringstream payloadStream(componentData.second); + component->Deserialize(payloadStream); + } + + for (const ClipboardNode& child : node.children) { + PasteGameObjectRecursive(scene, child, gameObject); + } + + return gameObject; +} + +bool WouldCreateCycle( + const GameObject& source, + const GameObject& targetParent) { + const GameObject* current = &targetParent; + while (current != nullptr) { + if (current == &source) { + return true; + } + current = current->GetParent(); + } + + return false; +} + +} // namespace + +EditorStartupSceneResult EnsureEditorStartupScene( + const std::filesystem::path& projectRoot) { + EditorStartupSceneResult result = {}; + if (projectRoot.empty()) { + return result; + } + + ResourceManager::Get().SetResourceRoot(projectRoot.string().c_str()); + + if (Scene* activeScene = ResolvePrimaryScene(); + activeScene != nullptr) { + result.ready = true; + result.sceneName = activeScene->GetName(); + return result; + } + + const std::filesystem::path startupScenePath = + (projectRoot / "Assets" / "Scenes" / "Main.xc").lexically_normal(); + SceneManager& sceneManager = SceneManager::Get(); + + if (std::filesystem::exists(startupScenePath) && + std::filesystem::is_regular_file(startupScenePath)) { + sceneManager.LoadScene(startupScenePath.string()); + Scene* loadedScene = sceneManager.GetScene(startupScenePath.stem().string()); + if (loadedScene == nullptr) { + loadedScene = ResolvePrimaryScene(); + } else { + sceneManager.SetActiveScene(loadedScene); + } + + if (loadedScene != nullptr) { + result.ready = true; + result.loadedFromDisk = true; + result.scenePath = startupScenePath; + result.sceneName = loadedScene->GetName(); + return result; + } + } + + if (Scene* scene = sceneManager.CreateScene("Main"); + scene != nullptr) { + sceneManager.SetActiveScene(scene); + result.ready = true; + result.sceneName = scene->GetName(); + } + + return result; +} + +Scene* GetActiveEditorScene() { + return ResolvePrimaryScene(); +} + +bool OpenEditorSceneAsset(const std::filesystem::path& scenePath) { + if (scenePath.empty()) { + return false; + } + + std::error_code errorCode = {}; + if (!std::filesystem::exists(scenePath, errorCode) || + errorCode || + !std::filesystem::is_regular_file(scenePath, errorCode)) { + return false; + } + + SceneManager& sceneManager = SceneManager::Get(); + sceneManager.LoadScene(scenePath.string()); + Scene* loadedScene = sceneManager.GetScene(scenePath.stem().string()); + if (loadedScene == nullptr) { + loadedScene = ResolvePrimaryScene(); + } else { + sceneManager.SetActiveScene(loadedScene); + } + + return loadedScene != nullptr; +} + +std::string MakeEditorGameObjectItemId(GameObject::ID id) { + return id == GameObject::INVALID_ID ? std::string() : std::to_string(id); +} + +std::optional ParseEditorGameObjectItemId( + std::string_view itemId) { + if (itemId.empty()) { + return std::nullopt; + } + + GameObject::ID parsedId = GameObject::INVALID_ID; + const char* first = itemId.data(); + const char* last = itemId.data() + itemId.size(); + const std::from_chars_result result = + std::from_chars(first, last, parsedId); + if (result.ec != std::errc() || result.ptr != last || + parsedId == GameObject::INVALID_ID) { + return std::nullopt; + } + + return parsedId; +} + +GameObject* FindEditorGameObject(std::string_view itemId) { + Scene* scene = ResolvePrimaryScene(); + const std::optional gameObjectId = + ParseEditorGameObjectItemId(itemId); + if (scene == nullptr || !gameObjectId.has_value()) { + return nullptr; + } + + return scene->FindByID(gameObjectId.value()); +} + +bool RenameEditorGameObject( + std::string_view itemId, + std::string_view newName) { + GameObject* gameObject = FindEditorGameObject(itemId); + if (gameObject == nullptr) { + return false; + } + + gameObject->SetName(std::string(newName)); + return true; +} + +bool DeleteEditorGameObject(std::string_view itemId) { + Scene* scene = ResolvePrimaryScene(); + GameObject* gameObject = FindEditorGameObject(itemId); + if (scene == nullptr || gameObject == nullptr) { + return false; + } + + scene->DestroyGameObject(gameObject); + return true; +} + +std::string DuplicateEditorGameObject(std::string_view itemId) { + Scene* scene = ResolvePrimaryScene(); + GameObject* gameObject = FindEditorGameObject(itemId); + if (scene == nullptr || gameObject == nullptr) { + return {}; + } + + const ClipboardNode clipboard = CopyGameObjectRecursive(*gameObject); + GameObject* duplicate = + PasteGameObjectRecursive(*scene, clipboard, gameObject->GetParent()); + return duplicate != nullptr + ? MakeEditorGameObjectItemId(duplicate->GetID()) + : std::string(); +} + +bool ReparentEditorGameObject( + std::string_view itemId, + std::string_view parentItemId) { + GameObject* gameObject = FindEditorGameObject(itemId); + GameObject* newParent = FindEditorGameObject(parentItemId); + if (gameObject == nullptr || newParent == nullptr || + gameObject == newParent || + gameObject->GetParent() == newParent || + WouldCreateCycle(*gameObject, *newParent)) { + return false; + } + + gameObject->SetParent(newParent); + return true; +} + +bool MoveEditorGameObjectToRoot(std::string_view itemId) { + GameObject* gameObject = FindEditorGameObject(itemId); + if (gameObject == nullptr || gameObject->GetParent() == nullptr) { + return false; + } + + gameObject->SetParent(nullptr); + return true; +} + +} // namespace XCEngine::UI::Editor::App diff --git a/new_editor/app/Scene/EditorSceneBridge.h b/new_editor/app/Scene/EditorSceneBridge.h new file mode 100644 index 00000000..215c5382 --- /dev/null +++ b/new_editor/app/Scene/EditorSceneBridge.h @@ -0,0 +1,44 @@ +#pragma once + +#include + +#include +#include +#include +#include + +namespace XCEngine::Components { + +class Scene; + +} // namespace XCEngine::Components + +namespace XCEngine::UI::Editor::App { + +struct EditorStartupSceneResult { + bool ready = false; + bool loadedFromDisk = false; + std::filesystem::path scenePath = {}; + std::string sceneName = {}; +}; + +EditorStartupSceneResult EnsureEditorStartupScene( + const std::filesystem::path& projectRoot); + +::XCEngine::Components::Scene* GetActiveEditorScene(); +bool OpenEditorSceneAsset(const std::filesystem::path& scenePath); +std::string MakeEditorGameObjectItemId(::XCEngine::Components::GameObject::ID id); +std::optional<::XCEngine::Components::GameObject::ID> ParseEditorGameObjectItemId( + std::string_view itemId); +::XCEngine::Components::GameObject* FindEditorGameObject(std::string_view itemId); +bool RenameEditorGameObject( + std::string_view itemId, + std::string_view newName); +bool DeleteEditorGameObject(std::string_view itemId); +std::string DuplicateEditorGameObject(std::string_view itemId); +bool ReparentEditorGameObject( + std::string_view itemId, + std::string_view parentItemId); +bool MoveEditorGameObjectToRoot(std::string_view itemId); + +} // namespace XCEngine::UI::Editor::App diff --git a/new_editor/app/Scene/EditorSceneRuntime.cpp b/new_editor/app/Scene/EditorSceneRuntime.cpp index 188b456a..aa49e11b 100644 --- a/new_editor/app/Scene/EditorSceneRuntime.cpp +++ b/new_editor/app/Scene/EditorSceneRuntime.cpp @@ -114,11 +114,13 @@ std::string_view GetSceneToolModeName(SceneToolMode mode) { case SceneToolMode::View: return "View"; case SceneToolMode::Translate: - return "Translate"; + return "Move"; case SceneToolMode::Rotate: return "Rotate"; case SceneToolMode::Scale: return "Scale"; + case SceneToolMode::Transform: + return "Transform"; default: return "Unknown"; } @@ -162,8 +164,6 @@ std::string_view GetSceneToolHandleName(SceneToolHandle handle) { std::string_view GetSceneToolInteractionLockName(SceneToolInteractionLock lock) { switch (lock) { - case SceneToolInteractionLock::Toolbar: - return "Toolbar"; case SceneToolInteractionLock::TransformDrag: return "TransformDrag"; case SceneToolInteractionLock::None: @@ -611,22 +611,6 @@ void EditorSceneRuntime::ClearToolInteractionLock() { m_toolState.interactionLock = SceneToolInteractionLock::None; } -void EditorSceneRuntime::SetToolbarHoveredIndex(std::size_t index) { - m_toolState.toolbarHoveredIndex = index; -} - -void EditorSceneRuntime::SetToolbarActiveIndex(std::size_t index) { - m_toolState.toolbarActiveIndex = index; -} - -void EditorSceneRuntime::ClearToolbarInteraction() { - m_toolState.toolbarHoveredIndex = kSceneToolInvalidToolbarIndex; - m_toolState.toolbarActiveIndex = kSceneToolInvalidToolbarIndex; - if (m_toolState.interactionLock == SceneToolInteractionLock::Toolbar) { - ClearToolInteractionLock(); - } -} - void EditorSceneRuntime::ResetToolInteractionState() { CancelTransformToolDrag(); ResetToolInteractionTransientState(); @@ -916,7 +900,6 @@ void EditorSceneRuntime::ResetTransformEditHistory() { void EditorSceneRuntime::ResetToolInteractionTransientState() { m_toolState.hoveredHandle = SceneToolHandle::None; m_toolState.activeHandle = SceneToolHandle::None; - ClearToolbarInteraction(); ClearToolInteractionLock(); m_toolState.dragState = {}; } diff --git a/new_editor/app/Scene/EditorSceneRuntime.h b/new_editor/app/Scene/EditorSceneRuntime.h index 980287e7..c80b54cd 100644 --- a/new_editor/app/Scene/EditorSceneRuntime.h +++ b/new_editor/app/Scene/EditorSceneRuntime.h @@ -120,9 +120,6 @@ public: void SetHoveredToolHandle(SceneToolHandle handle); void SetToolInteractionLock(SceneToolInteractionLock lock); void ClearToolInteractionLock(); - void SetToolbarHoveredIndex(std::size_t index); - void SetToolbarActiveIndex(std::size_t index); - void ClearToolbarInteraction(); void ResetToolInteractionState(); bool CaptureSelectedTransformSnapshot(SceneTransformSnapshot& outSnapshot) const; diff --git a/new_editor/app/Scene/SceneToolState.h b/new_editor/app/Scene/SceneToolState.h new file mode 100644 index 00000000..49507cb0 --- /dev/null +++ b/new_editor/app/Scene/SceneToolState.h @@ -0,0 +1,83 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include + +namespace XCEngine::UI::Editor::App { + +enum class SceneToolMode : std::uint8_t { + View = 0, + Translate, + Rotate, + Scale, + Transform +}; + +enum class SceneToolSpaceMode : std::uint8_t { + World = 0, + Local +}; + +enum class SceneToolPivotMode : std::uint8_t { + Pivot = 0, + Center +}; + +enum class SceneToolHandle : std::uint8_t { + None = 0, + AxisX, + AxisY, + AxisZ +}; + +enum class SceneToolInteractionLock : std::uint8_t { + None = 0, + TransformDrag +}; + +std::string_view GetSceneToolModeName(SceneToolMode mode); +std::string_view GetSceneToolSpaceModeName(SceneToolSpaceMode mode); +std::string_view GetSceneToolPivotModeName(SceneToolPivotMode mode); +std::string_view GetSceneToolHandleName(SceneToolHandle handle); +std::string_view GetSceneToolInteractionLockName(SceneToolInteractionLock lock); + +struct SceneTransformSnapshot { + ::XCEngine::Components::GameObject::ID targetId = + ::XCEngine::Components::GameObject::INVALID_ID; + ::XCEngine::Math::Vector3 position = ::XCEngine::Math::Vector3::Zero(); + ::XCEngine::Math::Quaternion rotation = + ::XCEngine::Math::Quaternion::Identity(); + ::XCEngine::Math::Vector3 scale = ::XCEngine::Math::Vector3::One(); + bool valid = false; + + bool IsValid() const { + return valid && + targetId != ::XCEngine::Components::GameObject::INVALID_ID; + } +}; + +struct SceneToolDragState { + bool active = false; + SceneToolMode mode = SceneToolMode::View; + SceneToolHandle handle = SceneToolHandle::None; + ::XCEngine::UI::UIPoint startPointerPosition = {}; + SceneTransformSnapshot initialTransform = {}; +}; + +struct SceneToolState { + SceneToolMode mode = SceneToolMode::Translate; + SceneToolSpaceMode spaceMode = SceneToolSpaceMode::World; + SceneToolPivotMode pivotMode = SceneToolPivotMode::Pivot; + SceneToolHandle hoveredHandle = SceneToolHandle::None; + SceneToolHandle activeHandle = SceneToolHandle::None; + SceneToolInteractionLock interactionLock = SceneToolInteractionLock::None; + SceneToolDragState dragState = {}; +}; + +} // namespace XCEngine::UI::Editor::App diff --git a/new_editor/app/Scene/SceneViewportCameraController.h b/new_editor/app/Scene/SceneViewportCameraController.h new file mode 100644 index 00000000..2e9ff944 --- /dev/null +++ b/new_editor/app/Scene/SceneViewportCameraController.h @@ -0,0 +1,308 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include + +namespace XCEngine::UI::Editor::App { + +struct SceneViewportCameraInputState { + float lookDeltaX = 0.0f; + float lookDeltaY = 0.0f; + float orbitDeltaX = 0.0f; + float orbitDeltaY = 0.0f; + float panDeltaX = 0.0f; + float panDeltaY = 0.0f; + float zoomDelta = 0.0f; + float flySpeedDelta = 0.0f; + float deltaTime = 0.0f; + float moveForward = 0.0f; + float moveRight = 0.0f; + float moveUp = 0.0f; + float viewportHeight = 0.0f; + bool fastMove = false; +}; + +class SceneViewportCameraController { +public: + void Reset() { + m_focalPoint = Math::Vector3::Zero(); + m_distance = 6.0f; + m_flySpeed = 5.0f; + m_yawDegrees = -35.0f; + m_pitchDegrees = -20.0f; + m_snapAnimating = false; + UpdatePositionFromFocalPoint(); + } + + const Math::Vector3& GetFocalPoint() const { + return m_focalPoint; + } + + float GetDistance() const { + return m_distance; + } + + float GetFlySpeed() const { + return m_flySpeed; + } + + float GetYawDegrees() const { + return m_yawDegrees; + } + + float GetPitchDegrees() const { + return m_pitchDegrees; + } + + Math::Vector3 GetForward() const { + const float yawRadians = m_yawDegrees * Math::DEG_TO_RAD; + const float pitchRadians = m_pitchDegrees * Math::DEG_TO_RAD; + + return Math::Vector3::Normalize(Math::Vector3( + std::cos(pitchRadians) * std::sin(yawRadians), + std::sin(pitchRadians), + std::cos(pitchRadians) * std::cos(yawRadians))); + } + + Math::Vector3 GetPosition() const { + return m_position; + } + + void Focus(const Math::Vector3& point) { + m_focalPoint = point; + 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, + kLookYawSensitivity, + kLookPitchSensitivity); + UpdateFocalPointFromPosition(); + } + + if (std::abs(input.orbitDeltaX) > Math::EPSILON || + std::abs(input.orbitDeltaY) > Math::EPSILON) { + ApplyRotationDelta( + input.orbitDeltaX, + input.orbitDeltaY, + kOrbitYawSensitivity, + kOrbitPitchSensitivity); + UpdatePositionFromFocalPoint(); + } + + if (std::abs(input.panDeltaX) > Math::EPSILON || + std::abs(input.panDeltaY) > Math::EPSILON) { + const Math::Vector3 right = GetRight(); + const Math::Vector3 up = GetUp(); + const float worldUnitsPerPixel = ComputeWorldUnitsPerPixel(input.viewportHeight); + const Math::Vector3 delta = + ((right * -input.panDeltaX) + (up * input.panDeltaY)) * worldUnitsPerPixel; + m_focalPoint += delta; + m_position += delta; + } + + if (std::abs(input.flySpeedDelta) > Math::EPSILON) { + const float speedFactor = std::pow(1.20f, input.flySpeedDelta); + m_flySpeed = std::clamp(m_flySpeed * speedFactor, 0.1f, 500.0f); + } + + if (input.deltaTime > 0.0f && + (std::abs(input.moveForward) > Math::EPSILON || + std::abs(input.moveRight) > Math::EPSILON || + std::abs(input.moveUp) > Math::EPSILON)) { + const Math::Vector3 movement = + GetForward() * input.moveForward + + GetRight() * input.moveRight + + Math::Vector3::Up() * input.moveUp; + if (movement.SqrMagnitude() > Math::EPSILON) { + const float speedMultiplier = input.fastMove ? 4.0f : 1.0f; + const float flySpeed = m_flySpeed * speedMultiplier; + const Math::Vector3 delta = + Math::Vector3::Normalize(movement) * flySpeed * input.deltaTime; + m_position += delta; + m_focalPoint += delta; + } + } + + if (std::abs(input.zoomDelta) > Math::EPSILON) { + const float zoomFactor = std::pow(0.85f, input.zoomDelta); + 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 { + transform.SetPosition(GetPosition()); + transform.SetRotation(Math::Quaternion::FromEulerAngles( + -m_pitchDegrees * Math::DEG_TO_RAD, + m_yawDegrees * Math::DEG_TO_RAD, + 0.0f)); + } + +private: + static constexpr float kSnapDurationSeconds = 0.22f; + static constexpr float kLookYawSensitivity = 0.18f; + static constexpr float kLookPitchSensitivity = 0.12f; + static constexpr float kOrbitYawSensitivity = 0.30f; + static constexpr float kOrbitPitchSensitivity = 0.20f; + + 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, + float yawSensitivity, + float pitchSensitivity) { + m_yawDegrees += deltaX * yawSensitivity; + m_pitchDegrees = + std::clamp(m_pitchDegrees - deltaY * pitchSensitivity, -89.0f, 89.0f); + } + + Math::Vector3 GetRight() const { + return Math::Vector3::Cross(Math::Vector3::Up(), GetForward()).Normalized(); + } + + Math::Vector3 GetUp() const { + return Math::Vector3::Cross(GetForward(), GetRight()).Normalized(); + } + + float ComputeWorldUnitsPerPixel(float viewportHeight) const { + if (viewportHeight <= Math::EPSILON) { + return 0.0f; + } + + const float verticalFovRadians = 60.0f * Math::DEG_TO_RAD; + const float viewportWorldHeight = + 2.0f * std::tan(verticalFovRadians * 0.5f) * m_distance; + return viewportWorldHeight / viewportHeight; + } + + void UpdatePositionFromFocalPoint() { + m_position = m_focalPoint - GetForward() * m_distance; + } + + void UpdateFocalPointFromPosition() { + m_focalPoint = m_position + GetForward() * m_distance; + } + + Math::Vector3 m_focalPoint = Math::Vector3::Zero(); + Math::Vector3 m_position = Math::Vector3(0.0f, 0.0f, -6.0f); + float m_distance = 6.0f; + float m_flySpeed = 5.0f; + float m_yawDegrees = -35.0f; + float m_pitchDegrees = -20.0f; + float m_snapStartYawDegrees = 0.0f; + float m_snapStartPitchDegrees = 0.0f; + float m_snapTargetYawDegrees = 0.0f; + float m_snapTargetPitchDegrees = 0.0f; + float m_snapElapsed = 0.0f; + bool m_snapAnimating = false; +}; + +} // namespace XCEngine::UI::Editor::App diff --git a/new_editor/app/State/EditorContext.cpp b/new_editor/app/State/EditorContext.cpp index 7e8c9c26..d41a203b 100644 --- a/new_editor/app/State/EditorContext.cpp +++ b/new_editor/app/State/EditorContext.cpp @@ -15,9 +15,6 @@ namespace { using ::XCEngine::UI::Editor::BuildEditorShellShortcutManager; using ::XCEngine::UI::Editor::UIEditorWorkspacePanelPresentationModel; -using ::XCEngine::UI::Editor::Widgets::UIEditorViewportSlotToolItem; -using ::XCEngine::UI::Editor::Widgets::UIEditorViewportSlotToolSlot; - std::string ComposeStatusText( std::string_view status, std::string_view message) { @@ -44,52 +41,6 @@ UIEditorWorkspacePanelPresentationModel* FindMutablePresentation( return nullptr; } -UIEditorViewportSlotToolItem BuildSceneToolItem( - std::string itemId, - std::string label, - UIEditorViewportSlotToolSlot slot, - bool selected, - float desiredWidth) { - UIEditorViewportSlotToolItem item = {}; - item.itemId = std::move(itemId); - item.label = std::move(label); - item.slot = slot; - item.enabled = true; - item.selected = selected; - item.desiredWidth = desiredWidth; - return item; -} - -std::vector BuildSceneViewportTopBarItems( - const EditorSceneRuntime& sceneRuntime) { - return { - BuildSceneToolItem( - "scene.pivot.pivot", - "Pivot", - UIEditorViewportSlotToolSlot::Leading, - sceneRuntime.GetToolPivotMode() == SceneToolPivotMode::Pivot, - 52.0f), - BuildSceneToolItem( - "scene.pivot.center", - "Center", - UIEditorViewportSlotToolSlot::Leading, - sceneRuntime.GetToolPivotMode() == SceneToolPivotMode::Center, - 58.0f), - BuildSceneToolItem( - "scene.space.world", - "World", - UIEditorViewportSlotToolSlot::Leading, - sceneRuntime.GetToolSpaceMode() == SceneToolSpaceMode::World, - 58.0f), - BuildSceneToolItem( - "scene.space.local", - "Local", - UIEditorViewportSlotToolSlot::Leading, - sceneRuntime.GetToolSpaceMode() == SceneToolSpaceMode::Local, - 56.0f) - }; -} - } // namespace bool EditorContext::Initialize(const std::filesystem::path& repoRoot) { @@ -214,13 +165,14 @@ UIEditorShellInteractionDefinition EditorContext::BuildShellDefinition( if (UIEditorWorkspacePanelPresentationModel* scenePresentation = FindMutablePresentation(definition.workspacePresentations, kScenePanelId); scenePresentation != nullptr) { - scenePresentation->viewportShellModel.spec.chrome.showTopBar = true; - scenePresentation->viewportShellModel.spec.toolItems = - BuildSceneViewportTopBarItems(m_sceneRuntime); + scenePresentation->viewportShellModel.spec.chrome.showTopBar = false; + scenePresentation->viewportShellModel.spec.chrome.title = {}; + scenePresentation->viewportShellModel.spec.chrome.subtitle = {}; + scenePresentation->viewportShellModel.spec.toolItems.clear(); scenePresentation->viewportShellModel.spec.visualState.hoveredToolIndex = - m_sceneRuntime.GetToolState().toolbarHoveredIndex; + Widgets::UIEditorViewportSlotInvalidIndex; scenePresentation->viewportShellModel.spec.visualState.activeToolIndex = - m_sceneRuntime.GetToolState().toolbarActiveIndex; + Widgets::UIEditorViewportSlotInvalidIndex; } return definition; diff --git a/new_editor/app/State/EditorSelectionStamp.h b/new_editor/app/State/EditorSelectionStamp.h new file mode 100644 index 00000000..2bfafe2c --- /dev/null +++ b/new_editor/app/State/EditorSelectionStamp.h @@ -0,0 +1,13 @@ +#pragma once + +#include +#include + +namespace XCEngine::UI::Editor::App { + +inline std::uint64_t GenerateEditorSelectionStamp() { + static std::atomic counter = 0u; + return counter.fetch_add(1u, std::memory_order_relaxed) + 1u; +} + +} // namespace XCEngine::UI::Editor::App diff --git a/tests/UI/Editor/unit/test_scene_viewport_runtime.cpp b/tests/UI/Editor/unit/test_scene_viewport_runtime.cpp index 7f160da2..d408bbf0 100644 --- a/tests/UI/Editor/unit/test_scene_viewport_runtime.cpp +++ b/tests/UI/Editor/unit/test_scene_viewport_runtime.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include @@ -30,6 +31,7 @@ using ::XCEngine::Components::SceneManager; using ::XCEngine::Input::KeyCode; using ::XCEngine::UI::UIInputEvent; using ::XCEngine::UI::UIInputEventType; +using ::XCEngine::UI::UIInputModifiers; using ::XCEngine::UI::UIPoint; using ::XCEngine::UI::UIPointerButton; using ::XCEngine::UI::UIRect; @@ -113,6 +115,45 @@ UIInputEvent MakePointerEvent( return event; } +UIInputEvent MakePointerEventWithModifiers( + UIInputEventType type, + float x, + float y, + const UIInputModifiers& modifiers, + UIPointerButton button = UIPointerButton::None) { + UIInputEvent event = {}; + event.type = type; + event.position = UIPoint(x, y); + event.pointerButton = button; + event.modifiers = modifiers; + return event; +} + +UIInputModifiers MakePointerModifiers(UIPointerButton button) { + UIInputModifiers modifiers = {}; + switch (button) { + case UIPointerButton::Left: + modifiers.leftMouse = true; + break; + case UIPointerButton::Right: + modifiers.rightMouse = true; + break; + case UIPointerButton::Middle: + modifiers.middleMouse = true; + break; + case UIPointerButton::X1: + modifiers.x1Mouse = true; + break; + case UIPointerButton::X2: + modifiers.x2Mouse = true; + break; + case UIPointerButton::None: + default: + break; + } + return modifiers; +} + UIInputEvent MakeWheelEvent(float x, float y, float wheelDelta) { UIInputEvent event = {}; event.type = UIInputEventType::PointerWheel; @@ -436,10 +477,11 @@ TEST(SceneViewportRuntimeTests, RightMouseDragRotatesSceneCameraThroughViewportC inputRect, { MakePointerEvent(UIInputEventType::PointerMove, 220.0f, 180.0f), - MakePointerEvent( + MakePointerEventWithModifiers( UIInputEventType::PointerButtonDown, 220.0f, 180.0f, + MakePointerModifiers(UIPointerButton::Right), UIPointerButton::Right) }); controller.Update( @@ -452,7 +494,11 @@ TEST(SceneViewportRuntimeTests, RightMouseDragRotatesSceneCameraThroughViewportC inputBridgeState, inputRect, { - MakePointerEvent(UIInputEventType::PointerMove, 280.0f, 220.0f) + MakePointerEventWithModifiers( + UIInputEventType::PointerMove, + 280.0f, + 220.0f, + MakePointerModifiers(UIPointerButton::Right)) }); controller.Update( runtime, @@ -515,10 +561,11 @@ TEST(SceneViewportRuntimeTests, MiddleMouseDragPansSceneCameraWithGrabSemantics) inputRect, { MakePointerEvent(UIInputEventType::PointerMove, 220.0f, 180.0f), - MakePointerEvent( + MakePointerEventWithModifiers( UIInputEventType::PointerButtonDown, 220.0f, 180.0f, + MakePointerModifiers(UIPointerButton::Middle), UIPointerButton::Middle) }); controller.Update( @@ -531,7 +578,70 @@ TEST(SceneViewportRuntimeTests, MiddleMouseDragPansSceneCameraWithGrabSemantics) inputBridgeState, inputRect, { - MakePointerEvent(UIInputEventType::PointerMove, 280.0f, 180.0f) + MakePointerEventWithModifiers( + UIInputEventType::PointerMove, + 280.0f, + 180.0f, + MakePointerModifiers(UIPointerButton::Middle)) + }); + controller.Update( + runtime, + viewportHostService, + BuildSceneComposeState(inputBridgeState), + BuildSceneComposeFrame(dragFrame, inputRect, viewportSize)); + + const Math::Vector3 after = transform->GetPosition(); + EXPECT_LT(after.x, before.x); +} + +TEST(SceneViewportRuntimeTests, ViewToolLeftMouseDragPansSceneCameraWithGrabSemantics) { + ScopedSceneManagerReset reset = {}; + TemporaryProjectRoot projectRoot = {}; + SaveMainScene(projectRoot, Math::Vector3(0.0f, 0.0f, 0.0f)); + + EditorSceneRuntime runtime = {}; + ASSERT_TRUE(runtime.Initialize(projectRoot.Root())); + runtime.SetToolMode(SceneToolMode::View); + + auto* camera = runtime.GetSceneViewCamera(); + ASSERT_NE(camera, nullptr); + auto* transform = camera->GetGameObject()->GetTransform(); + ASSERT_NE(transform, nullptr); + const Math::Vector3 before = transform->GetPosition(); + + SceneViewportController controller = {}; + ViewportHostService viewportHostService = {}; + UIEditorViewportInputBridgeState inputBridgeState = {}; + const UIRect inputRect(100.0f, 80.0f, 640.0f, 360.0f); + const UISize viewportSize(640.0f, 360.0f); + + const auto pressFrame = UpdateUIEditorViewportInputBridge( + inputBridgeState, + inputRect, + { + MakePointerEvent(UIInputEventType::PointerMove, 220.0f, 180.0f), + MakePointerEventWithModifiers( + UIInputEventType::PointerButtonDown, + 220.0f, + 180.0f, + MakePointerModifiers(UIPointerButton::Left), + UIPointerButton::Left) + }); + controller.Update( + runtime, + viewportHostService, + BuildSceneComposeState(inputBridgeState), + BuildSceneComposeFrame(pressFrame, inputRect, viewportSize)); + + const auto dragFrame = UpdateUIEditorViewportInputBridge( + inputBridgeState, + inputRect, + { + MakePointerEventWithModifiers( + UIInputEventType::PointerMove, + 280.0f, + 180.0f, + MakePointerModifiers(UIPointerButton::Left)) }); controller.Update( runtime, @@ -610,10 +720,11 @@ TEST(SceneViewportRuntimeTests, ToolShortcutSwitchesFocusedSceneViewportIntoTran inputRect, { MakePointerEvent(UIInputEventType::PointerMove, 220.0f, 180.0f), - MakePointerEvent( + MakePointerEventWithModifiers( UIInputEventType::PointerButtonDown, 220.0f, 180.0f, + MakePointerModifiers(UIPointerButton::Left), UIPointerButton::Left), MakePointerEvent( UIInputEventType::PointerButtonUp, @@ -646,5 +757,130 @@ TEST(SceneViewportRuntimeTests, ToolShortcutSwitchesFocusedSceneViewportIntoTran EXPECT_EQ(runtime.GetToolMode(), SceneToolMode::Translate); } +TEST(SceneViewportRuntimeTests, SceneToolOverlayClickSwitchesModeOnPointerDown) { + ScopedSceneManagerReset reset = {}; + TemporaryProjectRoot projectRoot = {}; + SaveMainScene(projectRoot, Math::Vector3(0.0f, 0.0f, 0.0f)); + + EditorSceneRuntime runtime = {}; + ASSERT_TRUE(runtime.Initialize(projectRoot.Root())); + runtime.SetToolMode(SceneToolMode::Translate); + + SceneViewportController controller = {}; + ViewportHostService viewportHostService = {}; + UIEditorViewportInputBridgeState inputBridgeState = {}; + const UIRect inputRect(100.0f, 104.0f, 640.0f, 360.0f); + const UISize viewportSize(640.0f, 360.0f); + const UIPoint rotateButtonCenter( + inputRect.x + 28.0f, + inputRect.y + 82.0f); + + const auto frame = UpdateUIEditorViewportInputBridge( + inputBridgeState, + inputRect, + { + MakePointerEvent(UIInputEventType::PointerMove, rotateButtonCenter.x, rotateButtonCenter.y), + MakePointerEvent( + UIInputEventType::PointerButtonDown, + rotateButtonCenter.x, + rotateButtonCenter.y, + UIPointerButton::Left) + }); + controller.Update( + runtime, + viewportHostService, + BuildSceneComposeState(inputBridgeState), + BuildSceneComposeFrame(frame, inputRect, viewportSize)); + + EXPECT_EQ(runtime.GetToolMode(), SceneToolMode::Rotate); +} + +TEST(SceneViewportRuntimeTests, SceneToolOverlayIncludesTransformButtonAndSwitchesModeOnPointerDown) { + ScopedSceneManagerReset reset = {}; + TemporaryProjectRoot projectRoot = {}; + SaveMainScene(projectRoot, Math::Vector3(0.0f, 0.0f, 0.0f)); + + EditorSceneRuntime runtime = {}; + ASSERT_TRUE(runtime.Initialize(projectRoot.Root())); + runtime.SetToolMode(SceneToolMode::Translate); + + SceneViewportController controller = {}; + ViewportHostService viewportHostService = {}; + UIEditorViewportInputBridgeState inputBridgeState = {}; + const UIRect inputRect(100.0f, 104.0f, 640.0f, 360.0f); + const UISize viewportSize(640.0f, 360.0f); + const UIPoint transformButtonCenter( + inputRect.x + 28.0f, + inputRect.y + 148.0f); + + const auto frame = UpdateUIEditorViewportInputBridge( + inputBridgeState, + inputRect, + { + MakePointerEvent( + UIInputEventType::PointerMove, + transformButtonCenter.x, + transformButtonCenter.y), + MakePointerEvent( + UIInputEventType::PointerButtonDown, + transformButtonCenter.x, + transformButtonCenter.y, + UIPointerButton::Left) + }); + controller.Update( + runtime, + viewportHostService, + BuildSceneComposeState(inputBridgeState), + BuildSceneComposeFrame(frame, inputRect, viewportSize)); + + EXPECT_EQ(runtime.GetToolMode(), SceneToolMode::Transform); +} + +TEST(SceneViewportRuntimeTests, SceneToolOverlayHandlesCoalescedClickInSingleFrame) { + ScopedSceneManagerReset reset = {}; + TemporaryProjectRoot projectRoot = {}; + SaveMainScene(projectRoot, Math::Vector3(0.0f, 0.0f, 0.0f)); + + EditorSceneRuntime runtime = {}; + ASSERT_TRUE(runtime.Initialize(projectRoot.Root())); + runtime.SetToolMode(SceneToolMode::Translate); + + SceneViewportController controller = {}; + ViewportHostService viewportHostService = {}; + UIEditorViewportInputBridgeState inputBridgeState = {}; + const UIRect inputRect(100.0f, 104.0f, 640.0f, 360.0f); + const UISize viewportSize(640.0f, 360.0f); + const UIPoint rotateButtonCenter( + inputRect.x + 28.0f, + inputRect.y + 82.0f); + + const auto frame = UpdateUIEditorViewportInputBridge( + inputBridgeState, + inputRect, + { + MakePointerEvent( + UIInputEventType::PointerMove, + rotateButtonCenter.x, + rotateButtonCenter.y), + MakePointerEvent( + UIInputEventType::PointerButtonDown, + rotateButtonCenter.x, + rotateButtonCenter.y, + UIPointerButton::Left), + MakePointerEvent( + UIInputEventType::PointerButtonUp, + rotateButtonCenter.x, + rotateButtonCenter.y, + UIPointerButton::Left) + }); + controller.Update( + runtime, + viewportHostService, + BuildSceneComposeState(inputBridgeState), + BuildSceneComposeFrame(frame, inputRect, viewportSize)); + + EXPECT_EQ(runtime.GetToolMode(), SceneToolMode::Rotate); +} + } // namespace } // namespace XCEngine::UI::Editor::App