From 97676c6944e383b9b5e2d1679d66608a824e970e Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 22 Mar 2026 20:09:10 -0400 Subject: [PATCH 1/7] UX redesign: QML Inspector, scale gizmo, undo/redo, modern UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major UI modernization replacing the old 4-tab sidebar (Transform/Material/Edit/Animation) with a unified QML Inspector panel featuring collapsible sections, and adding several new features for a more professional 3D editor experience. Phase 1 - Complete Transform Tools: - Scale gizmo (ScaleGizmo.h/cpp) with cube handles at axis endpoints - Local/World transform space toggle (X key) - Unity-convention keyboard shortcuts: Q=Select, W=Translate, E=Rotate, R=Scale, F=Frame - SpaceCamera::frameSelection() zooms camera to fit selected objects Phase 2 - Scene Undo/Redo: - UndoManager singleton wrapping QUndoStack - TranslateCommand, RotateCommand, ScaleCommand, DeleteCommand - Ctrl+Z/Ctrl+Shift+Z with Edit menu - Node validity checks prevent crashes after deletion Phase 3 - QML Inspector Panel: - SceneTreeModel (QAbstractItemModel) with hierarchical Node/Mesh/Submesh tree - Recursive SceneTreeNode.qml with expand/collapse, multi-select, material typeahead - Transform spinboxes with up/down arrow keys and buttons - Per-primitive-type field configuration (size/radius/height/segments/UV) - Animation controls grouped per entity with enable/loop/skeleton/weights/rename Phase 4 - Toolbar & Layout: - View toolbar with Grid/Normals/MeshInfo/ViewCube toggles - Reorganized Tools toolbar with Scale + World/Local toggle - Right dock (Inspector) stretches full height - Animation Control auto-shows below viewports for animated entities - Improved status bar with transform space + selection count - Tooltip keyboard shortcut hints Additional: - ThemeManager QML singleton for canonical palette colors - BatchExporter and MaterialPresetLibrary stubs - Clean shutdown: LLM worker unload + _exit(0) to avoid ggml-metal assert - Version bump 2.15.1 → 2.16.0 Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 42 ++- CMakeLists.txt | 2 +- qml/AnimationTimeline.qml | 146 ++++++++ qml/CollapsibleSection.qml | 61 ++++ qml/PropertiesPanel.qml | 433 ++++++++++++++++++++++++ qml/SceneTreeNode.qml | 308 +++++++++++++++++ qml/TransformField.qml | 151 +++++++++ resources/resource.qrc | 1 + resources/scale.png | Bin 0 -> 4644 bytes src/AnimationTimelineController.cpp | 181 ++++++++++ src/AnimationTimelineController.h | 68 ++++ src/BatchExporter.cpp | 46 +++ src/BatchExporter.h | 31 ++ src/CMakeLists.txt | 18 + src/LLMManager.cpp | 7 + src/LLMManager.h | 4 +- src/MaterialPresetLibrary.cpp | 106 ++++++ src/MaterialPresetLibrary.h | 33 ++ src/PropertiesPanelController.cpp | 497 ++++++++++++++++++++++++++++ src/PropertiesPanelController.h | 181 ++++++++++ src/ScaleGizmo.cpp | 336 +++++++++++++++++++ src/ScaleGizmo.h | 79 +++++ src/ScaleGizmo_test.cpp | 124 +++++++ src/SceneTreeModel.cpp | 333 +++++++++++++++++++ src/SceneTreeModel.h | 89 +++++ src/SceneTreeModel_test.cpp | 62 ++++ src/SpaceCamera.cpp | 69 +++- src/SpaceCamera.h | 3 + src/ThemeManager.cpp | 105 ++++++ src/ThemeManager.h | 71 ++++ src/TransformOperator.cpp | 229 +++++++++++-- src/TransformOperator.h | 21 +- src/UndoManager.cpp | 44 +++ src/UndoManager.h | 38 +++ src/UndoManager_test.cpp | 89 +++++ src/commands/TransformCommands.cpp | 146 ++++++++ src/commands/TransformCommands.h | 89 +++++ src/main.cpp | 21 ++ src/mainwindow.cpp | 230 +++++++++---- src/mainwindow.h | 9 +- src/qml_resources.qrc | 9 + tests/CMakeLists.txt | 18 + ui_files/animationcontrolwidget.ui | 73 +--- ui_files/mainwindow.ui | 105 +++++- 44 files changed, 4521 insertions(+), 187 deletions(-) create mode 100644 qml/AnimationTimeline.qml create mode 100644 qml/CollapsibleSection.qml create mode 100644 qml/PropertiesPanel.qml create mode 100644 qml/SceneTreeNode.qml create mode 100644 qml/TransformField.qml create mode 100644 resources/scale.png create mode 100644 src/AnimationTimelineController.cpp create mode 100644 src/AnimationTimelineController.h create mode 100644 src/BatchExporter.cpp create mode 100644 src/BatchExporter.h create mode 100644 src/MaterialPresetLibrary.cpp create mode 100644 src/MaterialPresetLibrary.h create mode 100644 src/PropertiesPanelController.cpp create mode 100644 src/PropertiesPanelController.h create mode 100644 src/ScaleGizmo.cpp create mode 100644 src/ScaleGizmo.h create mode 100644 src/ScaleGizmo_test.cpp create mode 100644 src/SceneTreeModel.cpp create mode 100644 src/SceneTreeModel.h create mode 100644 src/SceneTreeModel_test.cpp create mode 100644 src/ThemeManager.cpp create mode 100644 src/ThemeManager.h create mode 100644 src/UndoManager.cpp create mode 100644 src/UndoManager.h create mode 100644 src/UndoManager_test.cpp create mode 100644 src/commands/TransformCommands.cpp create mode 100644 src/commands/TransformCommands.h diff --git a/CLAUDE.md b/CLAUDE.md index 4e6d3d147..124ab285d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,7 +76,7 @@ Three singletons manage core state. All run on the main thread. Access via `Clas - **OgreWidget** (`src/OgreWidget.h/cpp`): QWidget subclass that creates an Ogre::RenderWindow from the native window handle. - **EditorViewport** (`src/EditorViewport.h/cpp`): Wraps OgreWidget, runs render loop via QTimer. -- **MainWindow** (`src/mainwindow.h/cpp`): QMainWindow + Ogre::FrameListener. Contains viewports, toolbars, dock widgets. +- **MainWindow** (`src/mainwindow.h/cpp`): QMainWindow + Ogre::FrameListener. Contains viewports, toolbars, dock widgets. Right sidebar hosts the QML Inspector panel directly (no tab widget). Animation Control dock at bottom auto-shows for animated entities. ### Material Editor (QML) @@ -95,6 +95,44 @@ Three singletons manage core state. All run on the main thread. Access via `Clas - Visibility requires both the toggle (`setVisible`) and an active widget — hides automatically when viewports are closed and reappears when a new viewport gets focus. - The QML window uses `Qt::FramelessWindowHint | Qt::Tool | Qt::WindowStaysOnTopHint` and software rendering (`QQuickWindow::setSceneGraphBackend("software")`) to avoid GL conflicts with Ogre. +### Transform System + +- **TransformOperator** (`src/TransformOperator.h/cpp`): Singleton implementing SELECT/TRANSLATE/ROTATE/SCALE modes. Owns three gizmos (TranslationGizmo, RotationGizmo, ScaleGizmo). Supports WORLD/LOCAL transform space. Mouse interaction: ray-cast gizmo for axis selection, plane intersection for drag transforms. +- **ScaleGizmo** (`src/ScaleGizmo.h/cpp`): Scale gizmo with cube handles at axis endpoints. Follows TranslationGizmo pattern (ManualObject per axis, highlight/fade). +- **Keyboard shortcuts** (Unity convention): `Q`=Select, `W`=Translate, `E`=Rotate, `R`=Scale, `F`=Frame selection, `X`=Toggle World/Local space. +- **SpaceCamera::frameSelection()**: Computes bounding sphere of selection and positions camera to fit it in view. + +### Undo/Redo System + +- **UndoManager** (`src/UndoManager.h/cpp`): Singleton wrapping `QUndoStack`. Push commands, undo/redo via `Ctrl+Z`/`Ctrl+Shift+Z`. +- **TransformCommands** (`src/commands/TransformCommands.h/cpp`): `TranslateCommand`, `RotateCommand`, `ScaleCommand`, `DeleteCommand`. Translate and Scale support command merging. State captured on mouse press, command pushed on mouse release in TransformOperator. + +### QML Inspector Panel + +- **PropertiesPanelController** (`src/PropertiesPanelController.h/cpp`): QML_SINGLETON providing transform values, selection state, scene tree model, primitive parameters, animation data (enable/loop/rename), skeleton debug toggles. Bridges all scene data to QML. +- **SceneTreeModel** (`src/SceneTreeModel.h/cpp`): QAbstractItemModel exposing hierarchical scene tree (Nodes → Entities → SubEntities) to QML. Supports multi-select, material name get/set on submeshes, debounced rebuild on scene changes. +- **PropertiesPanel.qml** (`qml/PropertiesPanel.qml`): Main inspector with collapsible sections: + - **Scene** — recursive tree view (SceneTreeNode.qml) with expand/collapse, Ctrl+click multi-select, material typeahead dropdown on submeshes + - **Transform** — position/rotation/scale spinbox fields with up/down arrow keys and buttons + - **Primitive** — context-sensitive fields per primitive type (size, radius, height, segments, UV) + - **Animations** — per-entity groups with enable/loop checkboxes, double-click rename, play/pause, skeleton/weights toggles +- **CollapsibleSection.qml**, **SceneTreeNode.qml**, **TransformField.qml** — reusable QML components. +- Loaded as QQuickWidget directly in the right dock (replaces old tab widget with Transform/Material/Edit/Animation tabs). + +### Animation Timeline + +- **AnimationTimelineController** (`src/AnimationTimelineController.h/cpp`): QML_SINGLETON managing animation playback. Provides animation list, play/pause/stop, timeline scrubbing. Auto-detects animated entities from selection. +- **AnimationTimeline.qml** (`qml/AnimationTimeline.qml`): Bottom dock panel with animation selector, playback controls, and timeline scrubber. Auto-shows/hides based on selection. + +### Theme System + +- **ThemeManager** (`src/ThemeManager.h/cpp`): QML_SINGLETON providing canonical theme colors synced from QPalette. All colors (window, panel, header, text, button, highlight, border, accent) derived from the active QPalette. + +### Indie Game Dev Features + +- **BatchExporter** (`src/BatchExporter.h/cpp`): Multi-file conversion wrapping CLIPipeline. Supports progress reporting. +- **MaterialPresetLibrary** (`src/MaterialPresetLibrary.h/cpp`): QML_SINGLETON providing one-click material presets (Plastic, Metal, Wood, Glass, Unlit, Wireframe). + ### MCP Server - **MCPServer** (`src/MCPServer.h/cpp`): JSON-RPC 2.0 over stdio + HTTP REST API on configurable port. @@ -133,7 +171,7 @@ Three singletons manage core state. All run on the main thread. Access via `Clas ## Development Guidelines -- **UI: QML over Widgets.** New UI should be built in QML (Qt Quick), not Qt Widgets. The project is migrating from Widgets to QML. The Material Editor (`qml/`) is the reference for the QML approach. Existing Widget-based UI (`ui_files/`) remains but should not be extended. +- **UI: QML over Widgets.** New UI should be built in QML (Qt Quick), not Qt Widgets. The Inspector panel (`qml/PropertiesPanel.qml`) and Material Editor (`qml/MaterialEditorWindow.qml`) are the reference for the QML approach. The old Transform/Material/Edit/Animation tabs have been replaced by the QML Inspector. AnimationWidget and PrimitivesWidget still exist as hidden backing widgets but are not user-visible tabs. - **Cross-platform: Windows, Linux (Ubuntu), macOS.** All code must compile and run on all three. Guard platform-specific APIs with `#ifdef Q_OS_WIN`, `#ifdef Q_OS_MACOS`, `#ifdef Q_OS_LINUX`. Test the CI build across all three platforms before merging. - **Unit tests.** Add Google Test unit tests for new functionality. Test files live alongside source in `src/` with the `_test.cpp` suffix (e.g., `Manager_test.cpp`). CI runs tests only on Linux to save budget, so: - Features that depend on optional components (e.g., local LLM / llama.cpp) may not be available in the test environment — guard with `#ifdef ENABLE_LOCAL_LLM` or skip gracefully. diff --git a/CMakeLists.txt b/CMakeLists.txt index dca047656..8b2ebe096 100755 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -13,7 +13,7 @@ cmake_minimum_required(VERSION 3.24.0) cmake_policy(SET CMP0005 NEW) cmake_policy(SET CMP0048 NEW) # manages project version -project(QtMeshEditor VERSION 2.15.1 LANGUAGES C CXX) +project(QtMeshEditor VERSION 2.16.0 LANGUAGES C CXX) message(STATUS "Building QtMeshEditor version ${PROJECT_VERSION}") set(QTMESHEDITOR_VERSION_STRING "\"${PROJECT_VERSION}\"") diff --git a/qml/AnimationTimeline.qml b/qml/AnimationTimeline.qml new file mode 100644 index 000000000..69680a340 --- /dev/null +++ b/qml/AnimationTimeline.qml @@ -0,0 +1,146 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import PropertiesPanel 1.0 +import AnimationTimeline 1.0 + +Rectangle { + id: root + color: PropertiesPanelController.panelColor + visible: AnimationTimelineController.hasAnimations + implicitHeight: 120 + + ColumnLayout { + anchors.fill: parent + anchors.margins: 6 + spacing: 4 + + // Animation selector + playback controls + RowLayout { + spacing: 8 + Layout.fillWidth: true + + ComboBox { + id: animCombo + model: AnimationTimelineController.animationNames + currentIndex: { + var names = AnimationTimelineController.animationNames + return names.indexOf(AnimationTimelineController.currentAnimation) + } + onActivated: AnimationTimelineController.currentAnimation = currentText + + Layout.fillWidth: true + Layout.maximumWidth: 200 + + background: Rectangle { + color: PropertiesPanelController.inputColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + radius: 3 + implicitHeight: 26 + } + contentItem: Text { + text: animCombo.displayText + color: PropertiesPanelController.textColor + font.pixelSize: 11 + verticalAlignment: Text.AlignVCenter + leftPadding: 6 + } + } + + Button { + text: AnimationTimelineController.playing ? "\u275A\u275A" : "\u25B6" + onClicked: { + if (AnimationTimelineController.playing) + AnimationTimelineController.pause() + else + AnimationTimelineController.play() + } + implicitWidth: 32 + implicitHeight: 26 + + background: Rectangle { + color: parent.pressed ? Qt.darker(PropertiesPanelController.headerColor, 1.2) + : PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor + radius: 3 + } + contentItem: Text { + text: parent.text + color: PropertiesPanelController.textColor + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + font.pixelSize: 12 + } + } + + Button { + text: "\u25A0" + onClicked: AnimationTimelineController.stop() + implicitWidth: 32 + implicitHeight: 26 + + background: Rectangle { + color: parent.pressed ? Qt.darker(PropertiesPanelController.headerColor, 1.2) + : PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor + radius: 3 + } + contentItem: Text { + text: parent.text + color: PropertiesPanelController.textColor + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + font.pixelSize: 12 + } + } + + Text { + text: { + var pos = AnimationTimelineController.progress.toFixed(2) + var dur = AnimationTimelineController.duration.toFixed(2) + return pos + " / " + dur + "s" + } + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + } + + // Timeline scrubber + Slider { + id: scrubber + Layout.fillWidth: true + from: 0 + to: Math.max(AnimationTimelineController.duration, 0.01) + value: AnimationTimelineController.progress + onMoved: AnimationTimelineController.progress = value + + background: Rectangle { + x: scrubber.leftPadding + y: scrubber.topPadding + scrubber.availableHeight / 2 - height / 2 + width: scrubber.availableWidth + height: 4 + radius: 2 + color: PropertiesPanelController.borderColor + + Rectangle { + width: scrubber.visualPosition * parent.width + height: parent.height + color: PropertiesPanelController.highlightColor + radius: 2 + } + } + + handle: Rectangle { + x: scrubber.leftPadding + scrubber.visualPosition * (scrubber.availableWidth - width) + y: scrubber.topPadding + scrubber.availableHeight / 2 - height / 2 + width: 12 + height: 12 + radius: 6 + color: scrubber.pressed ? Qt.lighter(PropertiesPanelController.highlightColor, 1.2) + : PropertiesPanelController.highlightColor + border.color: PropertiesPanelController.borderColor + } + } + } +} diff --git a/qml/CollapsibleSection.qml b/qml/CollapsibleSection.qml new file mode 100644 index 000000000..a8f4e7ff2 --- /dev/null +++ b/qml/CollapsibleSection.qml @@ -0,0 +1,61 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import PropertiesPanel 1.0 + +Column { + id: root + + property string title: "Section" + property bool expanded: true + property bool sectionVisible: true + default property alias content: contentLoader.sourceComponent + + visible: sectionVisible + width: parent ? parent.width : 200 + + Rectangle { + id: header + width: parent.width + height: 28 + color: headerMouse.containsMouse ? Qt.lighter(PropertiesPanelController.headerColor, 1.1) + : PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 6 + anchors.rightMargin: 6 + spacing: 4 + + Text { + text: root.expanded ? "\u25BC" : "\u25B6" + color: PropertiesPanelController.textColor + font.pixelSize: 10 + } + + Text { + text: root.title + color: PropertiesPanelController.textColor + font.pixelSize: 12 + font.bold: true + Layout.fillWidth: true + } + } + + MouseArea { + id: headerMouse + anchors.fill: parent + hoverEnabled: true + onClicked: root.expanded = !root.expanded + } + } + + Loader { + id: contentLoader + width: parent.width + active: root.expanded + visible: root.expanded + } +} diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml new file mode 100644 index 000000000..4a3b53702 --- /dev/null +++ b/qml/PropertiesPanel.qml @@ -0,0 +1,433 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import PropertiesPanel 1.0 + +Rectangle { + id: root + color: PropertiesPanelController.panelColor + + ScrollView { + anchors.fill: parent + clip: true + + Column { + width: root.width + spacing: 0 + + // ---- Scene Outliner ---- + CollapsibleSection { + title: "Scene" + expanded: true + + Component.onCompleted: content = sceneOutlinerComponent + } + + // ---- Transform ---- + CollapsibleSection { + title: "Transform" + sectionVisible: PropertiesPanelController.hasSelection + + Component.onCompleted: content = transformComponent + } + + // ---- Primitive Parameters ---- + CollapsibleSection { + title: "Primitive: " + PropertiesPanelController.primitiveType + sectionVisible: PropertiesPanelController.hasPrimitive + + Component.onCompleted: content = primitiveComponent + } + + // ---- Animations ---- + CollapsibleSection { + title: "Animations" + sectionVisible: PropertiesPanelController.hasAnimations + + Component.onCompleted: content = animationComponent + } + } + } + + // ---- Scene Outliner Content ---- + Component { + id: sceneOutlinerComponent + + Column { + id: outlinerColumn + width: parent ? parent.width : 200 + + property var treeModel: PropertiesPanelController.sceneTreeModel + property int nodeCount: treeModel ? treeModel.rowCount() : 0 + + Repeater { + model: outlinerColumn.nodeCount + + Loader { + width: outlinerColumn.width + source: "qrc:/PropertiesPanel/SceneTreeNode.qml" + onLoaded: { + item.nodeIndex = outlinerColumn.treeModel.index(index, 0) + item.treeModel = outlinerColumn.treeModel + item.indentLevel = 0 + item.width = Qt.binding(function() { return outlinerColumn.width }) + } + } + } + + Connections { + target: outlinerColumn.treeModel + function onModelReset() { + outlinerColumn.nodeCount = outlinerColumn.treeModel + ? outlinerColumn.treeModel.rowCount() : 0 + } + } + } + } + + // ---- Transform Content ---- + Component { + id: transformComponent + + Column { + width: parent ? parent.width : 200 + padding: 8 + spacing: 6 + + // Position + Text { + text: "Position" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + font.bold: true + } + Row { + spacing: 4 + width: parent.width - 16 + + TransformField { label: "X"; value: PropertiesPanelController.posX; color: "#c04040" + onNewValue: function(val) { PropertiesPanelController.posX = val } } + TransformField { label: "Y"; value: PropertiesPanelController.posY; color: "#40c040" + onNewValue: function(val) { PropertiesPanelController.posY = val } } + TransformField { label: "Z"; value: PropertiesPanelController.posZ; color: "#4040c0" + onNewValue: function(val) { PropertiesPanelController.posZ = val } } + } + + // Rotation + Text { + text: "Rotation" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + font.bold: true + } + Row { + spacing: 4 + width: parent.width - 16 + + TransformField { label: "X"; value: PropertiesPanelController.rotX; color: "#c04040" + onNewValue: function(val) { PropertiesPanelController.rotX = val } } + TransformField { label: "Y"; value: PropertiesPanelController.rotY; color: "#40c040" + onNewValue: function(val) { PropertiesPanelController.rotY = val } } + TransformField { label: "Z"; value: PropertiesPanelController.rotZ; color: "#4040c0" + onNewValue: function(val) { PropertiesPanelController.rotZ = val } } + } + + // Scale + Text { + text: "Scale" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + font.bold: true + } + Row { + spacing: 4 + width: parent.width - 16 + + TransformField { label: "X"; value: PropertiesPanelController.scaleX; color: "#c04040" + onNewValue: function(val) { PropertiesPanelController.scaleX = val } } + TransformField { label: "Y"; value: PropertiesPanelController.scaleY; color: "#40c040" + onNewValue: function(val) { PropertiesPanelController.scaleY = val } } + TransformField { label: "Z"; value: PropertiesPanelController.scaleZ; color: "#4040c0" + onNewValue: function(val) { PropertiesPanelController.scaleZ = val } } + } + } + } + + // ---- Primitive Content ---- + Component { + id: primitiveComponent + + Column { + width: parent ? parent.width : 200 + padding: 8 + spacing: 6 + + property var rawCfg: PropertiesPanelController.primFieldConfig || {} + property bool cfgShowSizeX: rawCfg.showSizeX || false + property bool cfgShowSizeY: rawCfg.showSizeY || false + property bool cfgShowSizeZ: rawCfg.showSizeZ || false + property bool cfgShowRadius: rawCfg.showRadius || false + property bool cfgShowRadius2: rawCfg.showRadius2 || false + property bool cfgShowHeight: rawCfg.showHeight || false + property bool cfgShowSegX: rawCfg.showSegX || false + property bool cfgShowSegY: rawCfg.showSegY || false + property bool cfgShowSegZ: rawCfg.showSegZ || false + property bool cfgShowUV: rawCfg.showUV || false + property var cfg: rawCfg + + // Size + Text { + visible: cfgShowSizeX || cfgShowSizeY || cfgShowSizeZ + text: "Size" + color: PropertiesPanelController.textColor + font.pixelSize: 11; font.bold: true + } + Row { + visible: cfgShowSizeX || cfgShowSizeY || cfgShowSizeZ + spacing: 4; width: parent.width - 16 + + TransformField { visible: cfgShowSizeX; label: "X"; value: PropertiesPanelController.primSizeX; color: "#c04040"; step: 0.1 + onNewValue: function(val) { PropertiesPanelController.primSizeX = val } } + TransformField { visible: cfgShowSizeY; label: "Y"; value: PropertiesPanelController.primSizeY; color: "#40c040"; step: 0.1 + onNewValue: function(val) { PropertiesPanelController.primSizeY = val } } + TransformField { visible: cfgShowSizeZ; label: "Z"; value: PropertiesPanelController.primSizeZ; color: "#4040c0"; step: 0.1 + onNewValue: function(val) { PropertiesPanelController.primSizeZ = val } } + } + + // Radius / Radius2 / Height + Text { + visible: cfgShowRadius || cfgShowRadius2 || cfgShowHeight + text: { + var parts = [] + if (cfg.showRadius) parts.push(cfg.radiusLabel || "Radius") + if (cfg.showRadius2) parts.push(cfg.radius2Label || "Radius2") + if (cfg.showHeight) parts.push("Height") + return parts.join(" / ") + } + color: PropertiesPanelController.textColor + font.pixelSize: 11; font.bold: true + } + Row { + visible: cfgShowRadius || cfgShowRadius2 || cfgShowHeight + spacing: 4; width: parent.width - 16 + + TransformField { visible: cfgShowRadius; label: (cfg.radiusLabel || "R").charAt(0); value: PropertiesPanelController.primRadius; color: "#c08040"; step: 0.1 + onNewValue: function(val) { PropertiesPanelController.primRadius = val } } + TransformField { visible: cfgShowRadius2; label: (cfg.radius2Label || "R2").charAt(0); value: PropertiesPanelController.primRadius2; color: "#c0a040"; step: 0.1 + onNewValue: function(val) { PropertiesPanelController.primRadius2 = val } } + TransformField { visible: cfgShowHeight; label: "H"; value: PropertiesPanelController.primHeight; color: "#40c0c0"; step: 0.1 + onNewValue: function(val) { PropertiesPanelController.primHeight = val } } + } + + // Segments + Text { + visible: cfgShowSegX || cfgShowSegY || cfgShowSegZ + text: "Segments" + color: PropertiesPanelController.textColor + font.pixelSize: 11; font.bold: true + } + Row { + visible: cfgShowSegX || cfgShowSegY || cfgShowSegZ + spacing: 4; width: parent.width - 16 + + TransformField { visible: cfgShowSegX; label: (cfg.segXLabel || "X").charAt(0); value: PropertiesPanelController.primSegX; color: "#808080"; step: 1 + onNewValue: function(val) { PropertiesPanelController.primSegX = Math.max(1, Math.round(val)) } } + TransformField { visible: cfgShowSegY; label: (cfg.segYLabel || "Y").charAt(0); value: PropertiesPanelController.primSegY; color: "#808080"; step: 1 + onNewValue: function(val) { PropertiesPanelController.primSegY = Math.max(1, Math.round(val)) } } + TransformField { visible: cfgShowSegZ; label: (cfg.segZLabel || "Z").charAt(0); value: PropertiesPanelController.primSegZ; color: "#808080"; step: 1 + onNewValue: function(val) { PropertiesPanelController.primSegZ = Math.max(1, Math.round(val)) } } + } + + // UV Tiling + Text { + visible: cfgShowUV + text: "UV Tiling" + color: PropertiesPanelController.textColor + font.pixelSize: 11; font.bold: true + } + Row { + visible: cfgShowUV + spacing: 4; width: parent.width - 16 + + TransformField { label: "U"; value: PropertiesPanelController.primUTile; color: "#c040c0"; step: 0.1 + onNewValue: function(val) { PropertiesPanelController.primUTile = val } } + TransformField { label: "V"; value: PropertiesPanelController.primVTile; color: "#c040c0"; step: 0.1 + onNewValue: function(val) { PropertiesPanelController.primVTile = val } } + } + } + } + + // ---- Animation Content ---- + Component { + id: animationComponent + + Column { + width: parent ? parent.width : 200 + padding: 8 + spacing: 4 + + property var entityGroups: PropertiesPanelController.animationData() + + function refreshAnimData() { + entityGroups = PropertiesPanelController.animationData() + } + + Connections { + target: PropertiesPanelController + function onSelectionChanged() { refreshAnimData() } + function onAnimationStateChanged() { refreshAnimData() } + } + + // Play/Pause button + Row { + spacing: 8 + width: parent.width - 16 + + Rectangle { + width: 28; height: 28; radius: 3 + color: playMouse.pressed ? Qt.darker(PropertiesPanelController.headerColor, 1.2) + : playMouse.containsMouse ? Qt.lighter(PropertiesPanelController.headerColor, 1.2) + : PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor + + Text { + anchors.centerIn: parent + text: PropertiesPanelController.playing ? "\u275A\u275A" : "\u25B6" + color: PropertiesPanelController.textColor; font.pixelSize: 14 + } + MouseArea { + id: playMouse; anchors.fill: parent; hoverEnabled: true + onClicked: PropertiesPanelController.playing = !PropertiesPanelController.playing + } + } + } + + // Per-entity groups + Repeater { + model: entityGroups + + Column { + width: parent.width - 16 + spacing: 2 + + property var grp: modelData + property bool groupExpanded: true + + // Entity header with chevron + Rectangle { + width: parent.width; height: 22 + color: entHeaderMouse.containsMouse + ? Qt.lighter(PropertiesPanelController.headerColor, 1.1) + : PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor; border.width: 1 + + Row { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left; anchors.leftMargin: 6; spacing: 4 + + Text { + text: groupExpanded ? "\u25BC" : "\u25B6" + color: PropertiesPanelController.textColor; font.pixelSize: 8 + anchors.verticalCenter: parent.verticalCenter + } + Text { + text: grp.entity + " (" + grp.animations.length + " anim)" + color: PropertiesPanelController.textColor; font.pixelSize: 11; font.bold: true + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: entHeaderMouse; anchors.fill: parent; hoverEnabled: true + onClicked: groupExpanded = !groupExpanded + } + } + + // Expanded content + Column { + visible: groupExpanded + width: parent.width + spacing: 2 + leftPadding: 8 + + // Animation rows + Repeater { + model: grp.animations + + Rectangle { + width: parent.width - 8; height: 22; color: "transparent" + + Row { + anchors.fill: parent; spacing: 6 + + // Enable checkbox + Rectangle { + width: 14; height: 14; anchors.verticalCenter: parent.verticalCenter + border.color: PropertiesPanelController.borderColor; border.width: 1; radius: 2 + color: modelData.enabled ? PropertiesPanelController.highlightColor : "transparent" + Text { anchors.centerIn: parent; text: modelData.enabled ? "\u2713" : ""; color: "white"; font.pixelSize: 10 } + MouseArea { anchors.fill: parent; onClicked: PropertiesPanelController.toggleAnimationEnabled(grp.entity, modelData.name, !modelData.enabled) } + } + + // Loop checkbox + Rectangle { + width: 14; height: 14; anchors.verticalCenter: parent.verticalCenter + border.color: PropertiesPanelController.borderColor; border.width: 1; radius: 7 + color: modelData.loop ? PropertiesPanelController.highlightColor : "transparent" + Text { anchors.centerIn: parent; text: modelData.loop ? "\u21BB" : ""; color: "white"; font.pixelSize: 8 } + MouseArea { anchors.fill: parent; onClicked: PropertiesPanelController.toggleAnimationLoop(grp.entity, modelData.name, !modelData.loop) } + } + + // Name (double-click to rename) + Text { + id: animText + visible: !animEdit.visible + text: modelData.name + " (" + modelData.length.toFixed(2) + "s)" + color: PropertiesPanelController.textColor; font.pixelSize: 11 + elide: Text.ElideRight; anchors.verticalCenter: parent.verticalCenter + MouseArea { + anchors.fill: parent + onDoubleClicked: { animEdit.text = modelData.name; animEdit.visible = true; animEdit.forceActiveFocus(); animEdit.selectAll() } + } + } + TextInput { + id: animEdit; visible: false; width: 100 + color: PropertiesPanelController.textColor; font.pixelSize: 11 + anchors.verticalCenter: parent.verticalCenter; selectByMouse: true + Rectangle { anchors.fill: parent; anchors.margins: -2; z: -1; color: PropertiesPanelController.inputColor; border.color: PropertiesPanelController.highlightColor; border.width: 1; radius: 2 } + onEditingFinished: { if (text.length > 0 && text !== modelData.name) PropertiesPanelController.renameAnimation(grp.entity, modelData.name, text); visible = false } + Keys.onEscapePressed: visible = false + } + } + } + } + + // Skeleton/Weights row (if has skeleton) + Row { + visible: grp.hasSkeleton + spacing: 8; topPadding: 4 + + Rectangle { + width: 14; height: 14; anchors.verticalCenter: parent.verticalCenter + border.color: PropertiesPanelController.borderColor; border.width: 1; radius: 2 + color: grp.showSkeleton ? PropertiesPanelController.highlightColor : "transparent" + Text { anchors.centerIn: parent; text: grp.showSkeleton ? "\u2713" : ""; color: "white"; font.pixelSize: 10 } + MouseArea { anchors.fill: parent; onClicked: PropertiesPanelController.toggleSkeletonDebug(grp.entity, !grp.showSkeleton) } + } + Text { text: "Skeleton"; color: PropertiesPanelController.textColor; font.pixelSize: 10; anchors.verticalCenter: parent.verticalCenter } + + Rectangle { + width: 14; height: 14; anchors.verticalCenter: parent.verticalCenter + border.color: PropertiesPanelController.borderColor; border.width: 1; radius: 2 + color: grp.showWeights ? PropertiesPanelController.highlightColor : "transparent" + Text { anchors.centerIn: parent; text: grp.showWeights ? "\u2713" : ""; color: "white"; font.pixelSize: 10 } + MouseArea { anchors.fill: parent; onClicked: PropertiesPanelController.toggleBoneWeights(grp.entity, !grp.showWeights) } + } + Text { text: "Weights"; color: PropertiesPanelController.textColor; font.pixelSize: 10; anchors.verticalCenter: parent.verticalCenter } + } + } + } + } + } + } +} diff --git a/qml/SceneTreeNode.qml b/qml/SceneTreeNode.qml new file mode 100644 index 000000000..edd13271c --- /dev/null +++ b/qml/SceneTreeNode.qml @@ -0,0 +1,308 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import PropertiesPanel 1.0 + +Column { + id: treeNode + + property var nodeIndex + property var treeModel + property int indentLevel: 0 + + property bool expanded: indentLevel > 0 // Nodes start collapsed, children expanded + property int childCount: treeModel ? treeModel.rowCount(nodeIndex) : 0 + property bool hasChildren: childCount > 0 + property string nodeName: treeModel ? (treeModel.data(nodeIndex) || "") : "" + property bool selected: false + + width: parent ? parent.width : 200 + + Component.onCompleted: refreshSelected() + + function refreshSelected() { + if (treeModel && nodeIndex) + selected = treeModel.isSelected(nodeIndex.row, treeModel.parent(nodeIndex)) + } + + Connections { + target: treeModel + function onSelectionUpdated() { treeNode.refreshSelected() } + } + + // Row for this node + Rectangle { + width: treeNode.width + height: 22 + color: treeNode.selected + ? PropertiesPanelController.highlightColor + : (rowMouse.containsMouse ? Qt.lighter(PropertiesPanelController.panelColor, 1.15) + : "transparent") + + // Full-row mouse area for selection (behind everything) + MouseArea { + id: rowMouse + anchors.fill: parent + hoverEnabled: true + // acceptedButtons default is Qt.LeftButton + onClicked: function(mouse) { + if (treeModel) { + var multiSelect = (mouse.modifiers & Qt.ControlModifier) || + (mouse.modifiers & Qt.ShiftModifier) + treeModel.selectItem(nodeIndex.row, treeModel.parent(nodeIndex), multiSelect) + } + } + } + + Row { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 4 + indentLevel * 16 + spacing: 4 + + // Expand/collapse chevron button + Item { + width: 14 + height: 22 + + Text { + visible: treeNode.hasChildren + anchors.centerIn: parent + text: treeNode.expanded ? "\u25BC" : "\u25B6" + color: treeNode.selected ? "white" : PropertiesPanelController.textColor + font.pixelSize: 8 + } + + MouseArea { + anchors.fill: parent + visible: treeNode.hasChildren + z: 10 // Above rowMouse + onClicked: treeNode.expanded = !treeNode.expanded + } + } + + // Icon based on type + Text { + text: { + if (!treeModel) return "" + var t = treeModel.data(nodeIndex, 259) + switch(t) { + case "Node": return "\u25A0" + case "Mesh": return "\u25C6" + case "Submesh": return "\u25CB" + default: return "\u25A1" + } + } + color: { + if (treeNode.selected) return "white" + if (!treeModel) return PropertiesPanelController.textColor + var t = treeModel.data(nodeIndex, 259) + switch(t) { + case "Node": return "#6ca0dc" + case "Mesh": return "#6cdc6c" + case "Submesh": return "#dcdc6c" + default: return PropertiesPanelController.textColor + } + } + font.pixelSize: 10 + anchors.verticalCenter: parent.verticalCenter + } + + // Name + type label + Text { + id: nameLabel + text: { + if (!treeModel) return "" + var n = treeNode.nodeName + var t = treeModel.data(nodeIndex, 259) + return n + (t ? " (" + t + ")" : "") + } + color: treeNode.selected ? "white" : PropertiesPanelController.textColor + font.pixelSize: 11 + elide: Text.ElideRight + anchors.verticalCenter: parent.verticalCenter + } + + // Material selector (typeahead combo for submeshes) + Item { + id: matSelector + visible: treeModel ? treeModel.data(nodeIndex, 259) === "Submesh" : false + width: visible ? Math.max(90, matLabel.implicitWidth + 20) : 0 + height: 18 + anchors.verticalCenter: parent.verticalCenter + + property string currentMat: treeModel ? (treeModel.materialName(nodeIndex.row, treeModel.parent(nodeIndex)) || "") : "" + property bool dropdownOpen: false + + // Display label (click to open dropdown) + Text { + id: matLabel + anchors.verticalCenter: parent.verticalCenter + text: "[" + matSelector.currentMat + "]" + color: treeNode.selected ? Qt.lighter(PropertiesPanelController.highlightColor, 1.5) + : PropertiesPanelController.highlightColor + font.pixelSize: 10 + font.italic: true + + MouseArea { + anchors.fill: parent + anchors.margins: -2 + z: 10 + onClicked: { + matSelector.dropdownOpen = !matSelector.dropdownOpen + if (matSelector.dropdownOpen) { + matFilter.text = "" + matFilter.forceActiveFocus() + } + } + } + } + } + } + } + + // Material typeahead dropdown (outside Row to avoid clipping) + Popup { + id: matDropdown + visible: matSelector.dropdownOpen + x: 4 + indentLevel * 16 + y: 22 + width: Math.min(treeNode.width - x, 220) + height: Math.min(matFilteredList.contentHeight + 30, 160) + padding: 0 + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutsideParent + onClosed: matSelector.dropdownOpen = false + + background: Rectangle { + color: PropertiesPanelController.inputColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + radius: 3 + } + + Column { + anchors.fill: parent + spacing: 0 + + // Filter input + Rectangle { + width: parent.width + height: 24 + color: PropertiesPanelController.panelColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + radius: 3 + + TextInput { + id: matFilter + anchors.fill: parent + anchors.margins: 4 + color: PropertiesPanelController.textColor + font.pixelSize: 11 + clip: true + verticalAlignment: TextInput.AlignVCenter + + property var allMaterials: treeModel ? treeModel.availableMaterials() : [] + property var filtered: { + var query = text.toLowerCase() + if (query.length === 0) return allMaterials + var result = [] + for (var i = 0; i < allMaterials.length; i++) { + if (allMaterials[i].toLowerCase().indexOf(query) >= 0) + result.push(allMaterials[i]) + } + return result + } + + Keys.onEscapePressed: matSelector.dropdownOpen = false + Keys.onReturnPressed: { + if (filtered.length > 0) { + treeModel.setMaterial(nodeIndex.row, treeModel.parent(nodeIndex), filtered[0]) + matSelector.currentMat = filtered[0] + matSelector.dropdownOpen = false + } + } + } + + Text { + anchors.fill: parent + anchors.margins: 4 + text: "Type to filter..." + color: PropertiesPanelController.borderColor + font.pixelSize: 11 + font.italic: true + visible: matFilter.text.length === 0 && !matFilter.activeFocus + verticalAlignment: Text.AlignVCenter + } + } + + // Material list + ListView { + id: matFilteredList + width: parent.width + height: parent.height - 24 + model: matFilter.filtered + clip: true + + delegate: Rectangle { + width: matFilteredList.width + height: 20 + color: matDelegateMouse.containsMouse + ? PropertiesPanelController.highlightColor + : "transparent" + + Text { + anchors.left: parent.left + anchors.leftMargin: 6 + anchors.verticalCenter: parent.verticalCenter + text: modelData + color: matDelegateMouse.containsMouse + ? "white" + : PropertiesPanelController.textColor + font.pixelSize: 10 + elide: Text.ElideRight + } + + MouseArea { + id: matDelegateMouse + anchors.fill: parent + hoverEnabled: true + onClicked: { + treeModel.setMaterial(nodeIndex.row, treeModel.parent(nodeIndex), modelData) + matSelector.currentMat = modelData + matSelector.dropdownOpen = false + } + } + } + } + } + } + + // Children (using Loader to break recursion) + Loader { + active: treeNode.expanded && treeNode.hasChildren + visible: active + width: treeNode.width + + sourceComponent: Component { + Column { + width: treeNode.width + + Repeater { + model: treeNode.childCount + + Loader { + width: treeNode.width + source: "qrc:/PropertiesPanel/SceneTreeNode.qml" + asynchronous: false + onLoaded: { + item.nodeIndex = treeModel.index(index, 0, treeNode.nodeIndex) + item.treeModel = treeNode.treeModel + item.indentLevel = treeNode.indentLevel + 1 + item.width = Qt.binding(function() { return treeNode.width }) + } + } + } + } + } + } +} diff --git a/qml/TransformField.qml b/qml/TransformField.qml new file mode 100644 index 000000000..2c78d5b8c --- /dev/null +++ b/qml/TransformField.qml @@ -0,0 +1,151 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import PropertiesPanel 1.0 + +Row { + id: root + property string label: "X" + property real value: 0 + property color color: "#c04040" + property real step: 0.1 + property int decimals: step >= 1 ? 0 : 3 + signal newValue(real val) + + spacing: 2 + width: (parent ? parent.width : 180) / 3 - 3 + + Rectangle { + width: 16 + height: 22 + color: root.color + radius: 2 + + Text { + anchors.centerIn: parent + text: root.label + color: "white" + font.pixelSize: 10 + font.bold: true + } + } + + Rectangle { + id: inputBg + width: parent.width - 18 + height: 22 + color: PropertiesPanelController.inputColor + border.color: input.activeFocus ? root.color : PropertiesPanelController.borderColor + border.width: 1 + radius: 2 + + TextInput { + id: input + anchors.left: parent.left + anchors.right: arrows.left + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.margins: 2 + text: root.value.toFixed(root.decimals) + color: PropertiesPanelController.textColor + font.pixelSize: 11 + verticalAlignment: TextInput.AlignVCenter + selectByMouse: true + clip: true + validator: DoubleValidator { decimals: root.decimals > 0 ? root.decimals + 1 : 0 } + + onEditingFinished: { + var v = parseFloat(text) + if (!isNaN(v)) + root.newValue(v) + } + + Keys.onUpPressed: { + var v = parseFloat(text) + if (!isNaN(v)) { + v += root.step + text = v.toFixed(root.decimals) + root.newValue(v) + } + } + Keys.onDownPressed: { + var v = parseFloat(text) + if (!isNaN(v)) { + v -= root.step + text = v.toFixed(root.decimals) + root.newValue(v) + } + } + } + + // Up/Down arrow buttons + Column { + id: arrows + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + width: 14 + + Rectangle { + width: parent.width + height: parent.height / 2 + color: upMouse.pressed ? Qt.darker(PropertiesPanelController.panelColor, 1.2) + : upMouse.containsMouse ? Qt.lighter(PropertiesPanelController.panelColor, 1.2) + : PropertiesPanelController.panelColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + + Text { + anchors.centerIn: parent + text: "\u25B2" + font.pixelSize: 6 + color: PropertiesPanelController.textColor + } + + MouseArea { + id: upMouse + anchors.fill: parent + hoverEnabled: true + onClicked: { + var v = parseFloat(input.text) + if (!isNaN(v)) { + v += root.step + input.text = v.toFixed(root.decimals) + root.newValue(v) + } + } + } + } + + Rectangle { + width: parent.width + height: parent.height / 2 + color: downMouse.pressed ? Qt.darker(PropertiesPanelController.panelColor, 1.2) + : downMouse.containsMouse ? Qt.lighter(PropertiesPanelController.panelColor, 1.2) + : PropertiesPanelController.panelColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + + Text { + anchors.centerIn: parent + text: "\u25BC" + font.pixelSize: 6 + color: PropertiesPanelController.textColor + } + + MouseArea { + id: downMouse + anchors.fill: parent + hoverEnabled: true + onClicked: { + var v = parseFloat(input.text) + if (!isNaN(v)) { + v -= root.step + input.text = v.toFixed(root.decimals) + root.newValue(v) + } + } + } + } + } + } +} diff --git a/resources/resource.qrc b/resources/resource.qrc index ff627e473..3ececa747 100755 --- a/resources/resource.qrc +++ b/resources/resource.qrc @@ -17,5 +17,6 @@ roundedbox.png addVertice.png spring.png + scale.png diff --git a/resources/scale.png b/resources/scale.png new file mode 100644 index 0000000000000000000000000000000000000000..90a4e7493988cee10e7c456eab3454160263c1d9 GIT binary patch literal 4644 zcmeHKdsGu=7N67yZUjL@rIzYAsEBp)n1ql-!b6gVs0j#IP^&VTnS_zNNCpU05usGA zR-{K;WI=o{qD@g&7i+;6EOrG%`%v7nqSa%oMLesuzQFyGAmVAy>G7QHf8?CWeBb@< z{oQ+i_kQ1*Z(W>5>BXMFh9JmG6(f%aZ`k>a7!LkzIXU;i>r_r+G82zy!4|7oM;j=Z z$+l21Wv6uzWWQ{TJLz?rH{z*4p{!o*xNfO zVlAJzN$@z(Tw-Xz9{PVZ{=eo}kIo^Nv4y_%>n|RFAXXtA9UZ5Nj((mE$f0ay@$tE* zBLf^{%$jw)aXchpO+Uk z!vE8)RI@7HxBiFQ6Iu2DQDlGe>}Z7Y^U@lBAD?wZGtYR<>)KZJzdOH-omsu}fN#Z^ z*b*k{n$q#s#N+9l+JNg@^S5eR#*A#x?)YenNT)~$+%|1vWvS-T$qh|2ZlbM=j{d9k z*Xvx*oAKXf#^YWfb(H|8(Oh@ZJTHR8?>lQMgJkNR1+oH%nZk1ago znDo>;H!JPN-=@{=wgmjRBE76={`R!-sowx!VIHy3jFTkG>;d;SjEE{6u~m8_m8CCo++PMWn8$8NNMzJs8!2)hL* z(kKSjQhM4XL%NS0M_`(iAxR->p4t*krP47uRw^M!lSt&G5fT!K2xo`cr2xQ4F*s~D z8ca5+U52=DrC{t7a}n63!lcQNWOW=IZMIUdkR#;qP=%e&#E@_{9A+hT(s=pXeGp(K zLsA*WBIR+tjVj25-JAKql^V*&8Nwc{2ppTpxCjog72Hb#*NK>7muT?< z5l@I}b>a||l!!too`~S_dAtD-u~r&%C2klPl@p2tP!f?&q7(21s17GXP@#a3pjsi1 zLWQJIi|K@+T1hDBf+7j&+h(f~2j!%VxSrx#OnTRXlW=KNoJxjZ9Jg1OB+h^{I$$6} z)HnghCcMy1q>WSpgFETuhlUCM|U(hb|cPOh3w4xJ~&*KP0E^}vHq#zr> zEbeSn0N|2?WTeqn3TMpLM6=l-L!1z>)3SeA4fYd>Gq@aQC;-aCL{gqWieZU-i4=@6 z0m>6fU$7=sn@L)i{YTo)-2;cYYaT<}fd6cl$i1Z!sC4(tJvGp-y#&LqT_ME@cM3K< zlX6oGu-r>TDsIwK;P~k4*XMHj_j*Ah0aXgYQA$gSQ6Zrfp<+rTK_vp7Bm@uPiv&Ee zi-mr4n_0(X;Z`b24|oJzf&O%H1y6TZD)?pftW?Tb2EZ7~6QI9kjN6wn*Li9@PdSYH z8&bkt3ImQAVCR;B;|rXI-2UURk2DbQpZV)6#-Hf|h6kOz62F6V4bt^W47`%^V0R7D z^-2u9lJa18{om+fzqn9QCh!@Q1+Gg6D+?6hsx@3YS1E_up=Z$h!MDFH0Fu90ViwvU zXyh2@!-7i7{DIJeQK=Okw}y-xKVdwfnGp^y)Xge+RATb!u1{oX3!;2JsOV_l<>Tiy z+i$ezbWwir0d!u)KbO9iKM7`wE-zyH`V=5LcMe4_?427}QnBf~X(Ptvvx5z-ts5F; z66>(;I_39cHqASBwzZ~v#i?ywhYG{X42CQBrWE@HE|}NgFHY+4hW2^qUB)!0SaI=d zyB2bxduu*@?Zld{V-xe*w%;EPRrhvn)F~Civ#L@TCu+nXUB`9(8CY*B(tjKUFSA7WmC5 z-g~4lsywL5U!|%|&!Ld=S*&lWY9lRY9_;_9@W}q3I^%*?)r>izc@SQAx3}o{d)3)r z9TQ$Mu}iKJlfBMRWBn;9>-w@GmF@PJ!CGx2^Wz8g0vGWq0qH_2nJa0&dac_KQtV z(sMh{=T + +AnimationTimelineController* AnimationTimelineController::m_pSingleton = nullptr; + +AnimationTimelineController* AnimationTimelineController::instance() +{ + if (!m_pSingleton) + m_pSingleton = new AnimationTimelineController(); + return m_pSingleton; +} + +AnimationTimelineController* AnimationTimelineController::qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine) +{ + Q_UNUSED(engine); + Q_UNUSED(scriptEngine); + auto* inst = instance(); + QQmlEngine::setObjectOwnership(inst, QQmlEngine::CppOwnership); + return inst; +} + +void AnimationTimelineController::kill() +{ + delete m_pSingleton; + m_pSingleton = nullptr; +} + +AnimationTimelineController::AnimationTimelineController() : QObject(nullptr) +{ + connect(SelectionSet::getSingleton(), &SelectionSet::selectionChanged, + this, &AnimationTimelineController::onSelectionChanged); +} + +Ogre::Entity* AnimationTimelineController::getSelectedAnimatedEntity() const +{ + auto entities = SelectionSet::getSingleton()->getResolvedEntities(); + for (Ogre::Entity* ent : entities) + { + if (ent->getAllAnimationStates() && !ent->getAllAnimationStates()->getAnimationStates().empty()) + return ent; + } + return nullptr; +} + +Ogre::AnimationState* AnimationTimelineController::getCurrentAnimState() const +{ + Ogre::Entity* ent = getSelectedAnimatedEntity(); + if (!ent || mCurrentAnimation.isEmpty()) return nullptr; + + auto* states = ent->getAllAnimationStates(); + if (!states) return nullptr; + + std::string name = mCurrentAnimation.toStdString(); + if (states->hasAnimationState(name)) + return states->getAnimationState(name); + return nullptr; +} + +bool AnimationTimelineController::hasAnimations() const +{ + return getSelectedAnimatedEntity() != nullptr; +} + +QStringList AnimationTimelineController::animationNames() const +{ + QStringList names; + Ogre::Entity* ent = getSelectedAnimatedEntity(); + if (!ent) return names; + + auto* states = ent->getAllAnimationStates(); + if (!states) return names; + + for (const auto& [key, value] : states->getAnimationStates()) + names.append(QString::fromStdString(key)); + return names; +} + +QString AnimationTimelineController::currentAnimation() const +{ + return mCurrentAnimation; +} + +bool AnimationTimelineController::isPlaying() const +{ + return mPlaying; +} + +double AnimationTimelineController::progress() const +{ + auto* state = getCurrentAnimState(); + if (!state) return 0; + return state->getTimePosition(); +} + +double AnimationTimelineController::duration() const +{ + auto* state = getCurrentAnimState(); + if (!state) return 0; + return state->getLength(); +} + +void AnimationTimelineController::setCurrentAnimation(const QString& name) +{ + if (mCurrentAnimation != name) + { + // Disable previous animation + auto* prevState = getCurrentAnimState(); + if (prevState) + prevState->setEnabled(false); + + mCurrentAnimation = name; + + // Enable new animation + auto* newState = getCurrentAnimState(); + if (newState) + { + newState->setEnabled(true); + newState->setLoop(true); + newState->setTimePosition(0); + } + + emit currentAnimationChanged(); + emit progressChanged(); + } +} + +void AnimationTimelineController::setPlaying(bool playing) +{ + if (mPlaying != playing) + { + mPlaying = playing; + emit playingChanged(); + } +} + +void AnimationTimelineController::setProgress(double p) +{ + auto* state = getCurrentAnimState(); + if (state) + { + state->setTimePosition(p); + emit progressChanged(); + } +} + +void AnimationTimelineController::play() +{ + setPlaying(true); +} + +void AnimationTimelineController::pause() +{ + setPlaying(false); +} + +void AnimationTimelineController::stop() +{ + setPlaying(false); + setProgress(0); +} + +void AnimationTimelineController::onSelectionChanged() +{ + mCurrentAnimation.clear(); + + // Auto-select first animation if available + auto names = animationNames(); + if (!names.isEmpty()) + mCurrentAnimation = names.first(); + + emit animationsChanged(); + emit currentAnimationChanged(); + emit progressChanged(); +} + +void AnimationTimelineController::updateProgress() +{ + if (mPlaying) + emit progressChanged(); +} diff --git a/src/AnimationTimelineController.h b/src/AnimationTimelineController.h new file mode 100644 index 000000000..58ab4ccdc --- /dev/null +++ b/src/AnimationTimelineController.h @@ -0,0 +1,68 @@ +#ifndef ANIMATION_TIMELINE_CONTROLLER_H +#define ANIMATION_TIMELINE_CONTROLLER_H + +#include +#include +#include + +namespace Ogre { + class Entity; + class AnimationState; +} + +class AnimationTimelineController : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY(bool hasAnimations READ hasAnimations NOTIFY animationsChanged) + Q_PROPERTY(QStringList animationNames READ animationNames NOTIFY animationsChanged) + Q_PROPERTY(QString currentAnimation READ currentAnimation WRITE setCurrentAnimation NOTIFY currentAnimationChanged) + Q_PROPERTY(bool playing READ isPlaying WRITE setPlaying NOTIFY playingChanged) + Q_PROPERTY(double progress READ progress WRITE setProgress NOTIFY progressChanged) + Q_PROPERTY(double duration READ duration NOTIFY currentAnimationChanged) + +public: + static AnimationTimelineController* instance(); + static AnimationTimelineController* qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine); + static void kill(); + + bool hasAnimations() const; + QStringList animationNames() const; + QString currentAnimation() const; + bool isPlaying() const; + double progress() const; + double duration() const; + + void setCurrentAnimation(const QString& name); + void setPlaying(bool playing); + void setProgress(double p); + + Q_INVOKABLE void play(); + Q_INVOKABLE void pause(); + Q_INVOKABLE void stop(); + +public slots: + void onSelectionChanged(); + void updateProgress(); + +signals: + void animationsChanged(); + void currentAnimationChanged(); + void playingChanged(); + void progressChanged(); + +private: + AnimationTimelineController(); + ~AnimationTimelineController() override = default; + + Ogre::Entity* getSelectedAnimatedEntity() const; + Ogre::AnimationState* getCurrentAnimState() const; + + static AnimationTimelineController* m_pSingleton; + QString mCurrentAnimation; + bool mPlaying = false; +}; + +#endif // ANIMATION_TIMELINE_CONTROLLER_H diff --git a/src/BatchExporter.cpp b/src/BatchExporter.cpp new file mode 100644 index 000000000..21486e7ab --- /dev/null +++ b/src/BatchExporter.cpp @@ -0,0 +1,46 @@ +#include "BatchExporter.h" +#include "CLIPipeline.h" +#include +#include + +BatchExporter::BatchExporter(QObject* parent) : QObject(parent) {} + +void BatchExporter::setInputFiles(const QStringList& files) { mInputFiles = files; } +void BatchExporter::setOutputFormat(const QString& format) { mOutputFormat = format; } +void BatchExporter::setOutputDirectory(const QString& dir) { mOutputDir = dir; } + +void BatchExporter::execute() +{ + int success = 0, fail = 0; + + for (int i = 0; i < mInputFiles.size(); ++i) + { + const QString& file = mInputFiles[i]; + emit progressChanged(i + 1, mInputFiles.size(), file); + + QFileInfo fi(file); + QString outputPath = mOutputDir.isEmpty() + ? fi.absolutePath() + "/" + fi.baseName() + "." + mOutputFormat + : mOutputDir + "/" + fi.baseName() + "." + mOutputFormat; + + // Build argv for CLIPipeline::run() + QStringList args = {"qtmesh", "convert", file, "-o", outputPath}; + std::vector argv; + std::vector storage; + for (const auto& arg : args) { + storage.push_back(arg.toStdString()); + argv.push_back(const_cast(storage.back().c_str())); + } + + int result = CLIPipeline::run(static_cast(argv.size()), argv.data()); + if (result == 0) + ++success; + else + { + ++fail; + emit error(file, "Conversion failed"); + } + } + + emit finished(success, fail); +} diff --git a/src/BatchExporter.h b/src/BatchExporter.h new file mode 100644 index 000000000..7fdefd066 --- /dev/null +++ b/src/BatchExporter.h @@ -0,0 +1,31 @@ +#ifndef BATCH_EXPORTER_H +#define BATCH_EXPORTER_H + +#include +#include + +class BatchExporter : public QObject +{ + Q_OBJECT + +public: + explicit BatchExporter(QObject* parent = nullptr); + + void setInputFiles(const QStringList& files); + void setOutputFormat(const QString& format); + void setOutputDirectory(const QString& dir); + + Q_INVOKABLE void execute(); + +signals: + void progressChanged(int current, int total, const QString& currentFile); + void finished(int successCount, int failCount); + void error(const QString& file, const QString& message); + +private: + QStringList mInputFiles; + QString mOutputFormat; + QString mOutputDir; +}; + +#endif // BATCH_EXPORTER_H diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 3b66639de..56a0c9efb 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -21,6 +21,7 @@ MeshImporterExporter.cpp EditorViewport.cpp RotationGizmo.cpp TranslationGizmo.cpp +ScaleGizmo.cpp TransformOperator.cpp TransformWidget.cpp PrimitivesWidget.cpp @@ -50,6 +51,14 @@ RTShaderHelper.cpp ViewCube/ViewCubeController.cpp SDWorker.cpp SDManager.cpp +UndoManager.cpp +commands/TransformCommands.cpp +PropertiesPanelController.cpp +SceneTreeModel.cpp +AnimationTimelineController.cpp +ThemeManager.cpp +BatchExporter.cpp +MaterialPresetLibrary.cpp ) set(HEADER_FILES @@ -74,6 +83,7 @@ MeshImporterExporter.h EditorViewport.h RotationGizmo.h TranslationGizmo.h +ScaleGizmo.h TransformOperator.h TransformWidget.h PrimitivesWidget.h @@ -103,6 +113,14 @@ RTShaderHelper.h ViewCube/ViewCubeController.h SDWorker.h SDManager.h +UndoManager.h +commands/TransformCommands.h +PropertiesPanelController.h +SceneTreeModel.h +AnimationTimelineController.h +ThemeManager.h +BatchExporter.h +MaterialPresetLibrary.h ) set(TEST_SOURCES "") diff --git a/src/LLMManager.cpp b/src/LLMManager.cpp index 59db3fbca..5b5a08b81 100644 --- a/src/LLMManager.cpp +++ b/src/LLMManager.cpp @@ -78,6 +78,13 @@ void LLMManager::shutdownWorkerThread() m_workerThread->wait(); qDebug() << "LLMManager: Worker thread stopped"; } + + // Unload model to free llama context and Metal resources before ggml-metal's + // static destructor runs at process exit. Worker object is left alive (leaked + // intentionally) because deleteLater crashes after event loop shutdown. + if (m_worker) { + m_worker->unloadModel(); + } } QString LLMManager::getDefaultModelsDirectory() const diff --git a/src/LLMManager.h b/src/LLMManager.h index a2434af2f..28795c76f 100644 --- a/src/LLMManager.h +++ b/src/LLMManager.h @@ -138,12 +138,14 @@ public slots: void generationError(const QString &error); void generationStopped(); +public: + void shutdownWorkerThread(); + private: explicit LLMManager(QObject *parent = nullptr); ~LLMManager(); void initializeWorkerThread(); - void shutdownWorkerThread(); QString getDefaultModelsDirectory() const; void populateRecommendedModels(); QString buildUserPrompt(const QString &prompt, const QString ¤tMaterial, const QStringList &availableTextures) const; diff --git a/src/MaterialPresetLibrary.cpp b/src/MaterialPresetLibrary.cpp new file mode 100644 index 000000000..2bf36b1f8 --- /dev/null +++ b/src/MaterialPresetLibrary.cpp @@ -0,0 +1,106 @@ +#include "MaterialPresetLibrary.h" +#include "Manager.h" +#include "SelectionSet.h" +#include + +MaterialPresetLibrary* MaterialPresetLibrary::m_pSingleton = nullptr; + +MaterialPresetLibrary* MaterialPresetLibrary::instance() +{ + if (!m_pSingleton) + m_pSingleton = new MaterialPresetLibrary(); + return m_pSingleton; +} + +MaterialPresetLibrary* MaterialPresetLibrary::qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine) +{ + Q_UNUSED(engine); + Q_UNUSED(scriptEngine); + auto* inst = instance(); + QQmlEngine::setObjectOwnership(inst, QQmlEngine::CppOwnership); + return inst; +} + +void MaterialPresetLibrary::kill() +{ + delete m_pSingleton; + m_pSingleton = nullptr; +} + +MaterialPresetLibrary::MaterialPresetLibrary() : QObject(nullptr) {} + +QStringList MaterialPresetLibrary::presetNames() const +{ + return {"Plastic (Red)", "Plastic (Blue)", "Plastic (White)", + "Metal (Silver)", "Metal (Gold)", "Metal (Copper)", + "Wood (Oak)", "Wood (Birch)", + "Glass (Clear)", "Glass (Tinted)", + "Unlit (White)", "Wireframe"}; +} + +void MaterialPresetLibrary::applyPreset(const QString& name) +{ + auto* sel = SelectionSet::getSingleton(); + if (!sel->hasEntities() && !sel->hasSubEntities()) + return; + + auto* mgr = Ogre::MaterialManager::getSingletonPtr(); + if (!mgr) return; + + // Create or get material named after preset + QString matName = "Preset/" + name; + Ogre::MaterialPtr mat; + if (mgr->resourceExists(matName.toStdString())) + mat = mgr->getByName(matName.toStdString()); + else + { + mat = mgr->create(matName.toStdString(), Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + Ogre::Pass* pass = mat->getTechnique(0)->getPass(0); + + if (name.startsWith("Plastic")) { + Ogre::ColourValue c(0.8f, 0.2f, 0.2f); + if (name.contains("Blue")) c = Ogre::ColourValue(0.2f, 0.3f, 0.9f); + else if (name.contains("White")) c = Ogre::ColourValue(0.9f, 0.9f, 0.9f); + pass->setDiffuse(c); + pass->setSpecular(Ogre::ColourValue(0.5f, 0.5f, 0.5f)); + pass->setShininess(30.0f); + } + else if (name.startsWith("Metal")) { + Ogre::ColourValue c(0.8f, 0.8f, 0.8f); + if (name.contains("Gold")) c = Ogre::ColourValue(0.9f, 0.75f, 0.3f); + else if (name.contains("Copper")) c = Ogre::ColourValue(0.85f, 0.5f, 0.3f); + pass->setDiffuse(c); + pass->setSpecular(Ogre::ColourValue(1.0f, 1.0f, 1.0f)); + pass->setShininess(80.0f); + } + else if (name.startsWith("Wood")) { + pass->setDiffuse(Ogre::ColourValue(0.6f, 0.4f, 0.2f)); + pass->setSpecular(Ogre::ColourValue(0.1f, 0.1f, 0.1f)); + pass->setShininess(5.0f); + } + else if (name.startsWith("Glass")) { + pass->setDiffuse(Ogre::ColourValue(0.1f, 0.1f, 0.1f, 0.3f)); + pass->setSpecular(Ogre::ColourValue(1.0f, 1.0f, 1.0f)); + pass->setShininess(100.0f); + pass->setSceneBlending(Ogre::SBT_TRANSPARENT_ALPHA); + pass->setDepthWriteEnabled(false); + } + else if (name == "Unlit (White)") { + pass->setLightingEnabled(false); + pass->setDiffuse(Ogre::ColourValue::White); + } + else if (name == "Wireframe") { + pass->setPolygonMode(Ogre::PM_WIREFRAME); + pass->setLightingEnabled(false); + } + } + + // Apply to selected entities + std::string stdMatName = matName.toStdString(); + for (Ogre::Entity* ent : sel->getEntitiesSelectionList()) + ent->setMaterialName(stdMatName); + for (Ogre::SubEntity* sub : sel->getSubEntitiesSelectionList()) + sub->setMaterialName(stdMatName); + + emit presetApplied(name); +} diff --git a/src/MaterialPresetLibrary.h b/src/MaterialPresetLibrary.h new file mode 100644 index 000000000..73d4f096d --- /dev/null +++ b/src/MaterialPresetLibrary.h @@ -0,0 +1,33 @@ +#ifndef MATERIAL_PRESET_LIBRARY_H +#define MATERIAL_PRESET_LIBRARY_H + +#include +#include +#include + +class MaterialPresetLibrary : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY(QStringList presetNames READ presetNames CONSTANT) + +public: + static MaterialPresetLibrary* instance(); + static MaterialPresetLibrary* qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine); + static void kill(); + + QStringList presetNames() const; + Q_INVOKABLE void applyPreset(const QString& name); + +signals: + void presetApplied(const QString& name); + +private: + MaterialPresetLibrary(); + ~MaterialPresetLibrary() override = default; + static MaterialPresetLibrary* m_pSingleton; +}; + +#endif // MATERIAL_PRESET_LIBRARY_H diff --git a/src/PropertiesPanelController.cpp b/src/PropertiesPanelController.cpp new file mode 100644 index 000000000..5ebaac354 --- /dev/null +++ b/src/PropertiesPanelController.cpp @@ -0,0 +1,497 @@ +#include "PropertiesPanelController.h" +#include "SceneTreeModel.h" +#include "SelectionSet.h" +#include "TransformOperator.h" +#include "PrimitiveObject.h" +#include "AnimationWidget.h" +#include "SkeletonTransform.h" +#include "Manager.h" +#include +#include +#include + +PropertiesPanelController* PropertiesPanelController::m_pSingleton = nullptr; + +PropertiesPanelController* PropertiesPanelController::instance() +{ + if (!m_pSingleton) + m_pSingleton = new PropertiesPanelController(); + return m_pSingleton; +} + +PropertiesPanelController* PropertiesPanelController::qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine) +{ + Q_UNUSED(engine); + Q_UNUSED(scriptEngine); + auto* inst = instance(); + QQmlEngine::setObjectOwnership(inst, QQmlEngine::CppOwnership); + return inst; +} + +void PropertiesPanelController::kill() +{ + delete m_pSingleton; + m_pSingleton = nullptr; +} + +PropertiesPanelController::PropertiesPanelController() : QObject(nullptr) +{ + connect(SelectionSet::getSingleton(), &SelectionSet::selectionChanged, + this, &PropertiesPanelController::onSelectionChanged); + + auto* transformOp = TransformOperator::getSingleton(); + connect(transformOp, &TransformOperator::selectedPositionChanged, this, [this](const Ogre::Vector3& pos) { + mPosX = pos.x; mPosY = pos.y; mPosZ = pos.z; + emit transformChanged(); + }); + connect(transformOp, &TransformOperator::selectedOrientationChanged, this, [this](const Ogre::Vector3& rot) { + mRotX = rot.x; mRotY = rot.y; mRotZ = rot.z; + emit transformChanged(); + }); + connect(transformOp, &TransformOperator::selectedScaleChanged, this, [this](const Ogre::Vector3& scale) { + mScaleX = scale.x; mScaleY = scale.y; mScaleZ = scale.z; + emit transformChanged(); + }); + + connect(Manager::getSingleton(), &Manager::sceneNodeCreated, this, &PropertiesPanelController::onSceneChanged); + connect(Manager::getSingleton(), &Manager::sceneNodeDestroyed, this, &PropertiesPanelController::onSceneChanged); + + mSceneTreeModel = new SceneTreeModel(this); +} + +// Theme colors from QPalette +QColor PropertiesPanelController::panelColor() const +{ + return QApplication::palette().color(QPalette::Window); +} + +QColor PropertiesPanelController::headerColor() const +{ + return QApplication::palette().color(QPalette::Window).darker(110); +} + +QColor PropertiesPanelController::textColor() const +{ + return QApplication::palette().color(QPalette::WindowText); +} + +QColor PropertiesPanelController::borderColor() const +{ + return QApplication::palette().color(QPalette::Mid); +} + +QColor PropertiesPanelController::inputColor() const +{ + return QApplication::palette().color(QPalette::Base); +} + +QColor PropertiesPanelController::highlightColor() const +{ + return QApplication::palette().color(QPalette::Highlight); +} + +// Transform accessors +double PropertiesPanelController::posX() const { return mPosX; } +double PropertiesPanelController::posY() const { return mPosY; } +double PropertiesPanelController::posZ() const { return mPosZ; } +double PropertiesPanelController::rotX() const { return mRotX; } +double PropertiesPanelController::rotY() const { return mRotY; } +double PropertiesPanelController::rotZ() const { return mRotZ; } +double PropertiesPanelController::scaleX() const { return mScaleX; } +double PropertiesPanelController::scaleY() const { return mScaleY; } +double PropertiesPanelController::scaleZ() const { return mScaleZ; } + +// Transform mutators - set absolute values +void PropertiesPanelController::setPosX(double v) { + if (mPosX != v) { + Ogre::Vector3 pos(v, mPosY, mPosZ); + TransformOperator::getSingleton()->setSelectedPosition(pos); + } +} +void PropertiesPanelController::setPosY(double v) { + if (mPosY != v) { + Ogre::Vector3 pos(mPosX, v, mPosZ); + TransformOperator::getSingleton()->setSelectedPosition(pos); + } +} +void PropertiesPanelController::setPosZ(double v) { + if (mPosZ != v) { + Ogre::Vector3 pos(mPosX, mPosY, v); + TransformOperator::getSingleton()->setSelectedPosition(pos); + } +} +void PropertiesPanelController::setRotX(double v) { + if (mRotX != v) { + Ogre::Vector3 rot(v, mRotY, mRotZ); + TransformOperator::getSingleton()->setSelectedOrientation(rot); + } +} +void PropertiesPanelController::setRotY(double v) { + if (mRotY != v) { + Ogre::Vector3 rot(mRotX, v, mRotZ); + TransformOperator::getSingleton()->setSelectedOrientation(rot); + } +} +void PropertiesPanelController::setRotZ(double v) { + if (mRotZ != v) { + Ogre::Vector3 rot(mRotX, mRotY, v); + TransformOperator::getSingleton()->setSelectedOrientation(rot); + } +} +void PropertiesPanelController::setScaleX(double v) { + if (mScaleX != v) { + Ogre::Vector3 scale(v, mScaleY, mScaleZ); + TransformOperator::getSingleton()->setSelectedScale(scale); + } +} +void PropertiesPanelController::setScaleY(double v) { + if (mScaleY != v) { + Ogre::Vector3 scale(mScaleX, v, mScaleZ); + TransformOperator::getSingleton()->setSelectedScale(scale); + } +} +void PropertiesPanelController::setScaleZ(double v) { + if (mScaleZ != v) { + Ogre::Vector3 scale(mScaleX, mScaleY, v); + TransformOperator::getSingleton()->setSelectedScale(scale); + } +} + +SceneTreeModel* PropertiesPanelController::sceneTreeModel() const +{ + return mSceneTreeModel; +} + +// Selection state +bool PropertiesPanelController::hasSelection() const +{ + return !SelectionSet::getSingleton()->isEmpty(); +} + +bool PropertiesPanelController::hasEntitySelection() const +{ + return SelectionSet::getSingleton()->hasEntities() || SelectionSet::getSingleton()->hasSubEntities(); +} + +QString PropertiesPanelController::selectionName() const +{ + auto* sel = SelectionSet::getSingleton(); + if (sel->hasNodes() && sel->getNodesCount() > 0) + return QString::fromStdString(sel->getSceneNode(0)->getName()); + if (sel->hasEntities() && sel->getEntitiesCount() > 0) + return QString::fromStdString(sel->getEntity(0)->getName()); + return QString(); +} + +QStringList PropertiesPanelController::sceneNodeNames() const +{ + QStringList names; + auto* mgr = Manager::getSingletonPtr(); + if (!mgr) return names; + + for (auto* node : mgr->getSceneNodes()) + names.append(QString::fromStdString(node->getName())); + return names; +} + +// Primitive helpers +static PrimitiveObject* getSelectedPrimitive() +{ + auto* sel = SelectionSet::getSingleton(); + if (sel->hasNodes() && sel->getNodesCount() == 1) + { + Ogre::SceneNode* node = sel->getSceneNode(0); + if (PrimitiveObject::isPrimitive(node)) + return PrimitiveObject::getPrimitiveFromSceneNode(node); + } + return nullptr; +} + +void PropertiesPanelController::selectNodeByName(const QString& name) +{ + auto* mgr = Manager::getSingletonPtr(); + if (!mgr) return; + + Ogre::SceneManager* sceneMgr = mgr->getSceneMgr(); + if (sceneMgr->hasSceneNode(name.toStdString())) + { + Ogre::SceneNode* node = sceneMgr->getSceneNode(name.toStdString()); + SelectionSet::getSingleton()->selectOne(node); + } +} + +bool PropertiesPanelController::hasPrimitive() const { return getSelectedPrimitive() != nullptr; } + +QString PropertiesPanelController::primitiveType() const +{ + auto* p = getSelectedPrimitive(); + if (!p) return QString(); + switch (p->getType()) { + case PrimitiveObject::AP_CUBE: return "Cube"; + case PrimitiveObject::AP_SPHERE: return "Sphere"; + case PrimitiveObject::AP_PLANE: return "Plane"; + case PrimitiveObject::AP_CYLINDER: return "Cylinder"; + case PrimitiveObject::AP_CONE: return "Cone"; + case PrimitiveObject::AP_TORUS: return "Torus"; + case PrimitiveObject::AP_TUBE: return "Tube"; + case PrimitiveObject::AP_CAPSULE: return "Capsule"; + case PrimitiveObject::AP_ICOSPHERE: return "IcoSphere"; + case PrimitiveObject::AP_ROUNDEDBOX: return "Rounded Box"; + case PrimitiveObject::AP_SPRING: return "Spring"; + default: return "Mesh"; + } +} + +double PropertiesPanelController::primSizeX() const { auto* p = getSelectedPrimitive(); return p ? p->getSizeX() : 0; } +double PropertiesPanelController::primSizeY() const { auto* p = getSelectedPrimitive(); return p ? p->getSizeY() : 0; } +double PropertiesPanelController::primSizeZ() const { auto* p = getSelectedPrimitive(); return p ? p->getSizeZ() : 0; } +double PropertiesPanelController::primRadius() const { auto* p = getSelectedPrimitive(); return p ? p->getRadius() : 0; } +double PropertiesPanelController::primRadius2() const { auto* p = getSelectedPrimitive(); return p ? p->getInnerRadius() : 0; } +double PropertiesPanelController::primHeight() const { auto* p = getSelectedPrimitive(); return p ? p->getHeight() : 0; } +int PropertiesPanelController::primSegX() const { auto* p = getSelectedPrimitive(); return p ? p->getNumSegX() : 0; } +int PropertiesPanelController::primSegY() const { auto* p = getSelectedPrimitive(); return p ? p->getNumSegY() : 0; } +int PropertiesPanelController::primSegZ() const { auto* p = getSelectedPrimitive(); return p ? p->getNumSegZ() : 0; } +double PropertiesPanelController::primUTile() const { auto* p = getSelectedPrimitive(); return p ? p->getUTile() : 1; } +double PropertiesPanelController::primVTile() const { auto* p = getSelectedPrimitive(); return p ? p->getVTile() : 1; } + +void PropertiesPanelController::setPrimSizeX(double v) { auto* p = getSelectedPrimitive(); if (p) { p->setSizeX(v); emit primitiveChanged(); } } +void PropertiesPanelController::setPrimSizeY(double v) { auto* p = getSelectedPrimitive(); if (p) { p->setSizeY(v); emit primitiveChanged(); } } +void PropertiesPanelController::setPrimSizeZ(double v) { auto* p = getSelectedPrimitive(); if (p) { p->setSizeZ(v); emit primitiveChanged(); } } +void PropertiesPanelController::setPrimRadius(double v) { auto* p = getSelectedPrimitive(); if (p) { p->setRadius(v); emit primitiveChanged(); } } +void PropertiesPanelController::setPrimRadius2(double v) { auto* p = getSelectedPrimitive(); if (p) { p->setInnerRadius(v); emit primitiveChanged(); } } +void PropertiesPanelController::setPrimHeight(double v) { auto* p = getSelectedPrimitive(); if (p) { p->setHeight(v); emit primitiveChanged(); } } + +QVariantMap PropertiesPanelController::primFieldConfig() const +{ + QVariantMap cfg; + auto* p = getSelectedPrimitive(); + if (!p) return cfg; + + auto t = p->getType(); + + // Which fields are visible + cfg["showSizeX"] = (t == PrimitiveObject::AP_CUBE || t == PrimitiveObject::AP_PLANE || t == PrimitiveObject::AP_ROUNDEDBOX); + cfg["showSizeY"] = (t == PrimitiveObject::AP_CUBE || t == PrimitiveObject::AP_PLANE || t == PrimitiveObject::AP_ROUNDEDBOX); + cfg["showSizeZ"] = (t == PrimitiveObject::AP_CUBE || t == PrimitiveObject::AP_ROUNDEDBOX); + cfg["showRadius"] = (t == PrimitiveObject::AP_SPHERE || t == PrimitiveObject::AP_CYLINDER || t == PrimitiveObject::AP_CONE + || t == PrimitiveObject::AP_TORUS || t == PrimitiveObject::AP_TUBE || t == PrimitiveObject::AP_CAPSULE + || t == PrimitiveObject::AP_ICOSPHERE || t == PrimitiveObject::AP_ROUNDEDBOX); + cfg["showRadius2"] = (t == PrimitiveObject::AP_TORUS || t == PrimitiveObject::AP_TUBE); + cfg["showHeight"] = (t == PrimitiveObject::AP_CYLINDER || t == PrimitiveObject::AP_CONE || t == PrimitiveObject::AP_TUBE || t == PrimitiveObject::AP_CAPSULE); + cfg["showSegX"] = (t != PrimitiveObject::AP_SPRING ? true : true); // all have at least segX + cfg["showSegY"] = (t == PrimitiveObject::AP_CUBE || t == PrimitiveObject::AP_SPHERE || t == PrimitiveObject::AP_PLANE + || t == PrimitiveObject::AP_TORUS || t == PrimitiveObject::AP_CAPSULE || t == PrimitiveObject::AP_ROUNDEDBOX + || t == PrimitiveObject::AP_SPRING); + cfg["showSegZ"] = (t == PrimitiveObject::AP_CUBE || t == PrimitiveObject::AP_CYLINDER || t == PrimitiveObject::AP_CONE + || t == PrimitiveObject::AP_TUBE || t == PrimitiveObject::AP_CAPSULE || t == PrimitiveObject::AP_ROUNDEDBOX); + cfg["showUV"] = (t != PrimitiveObject::AP_SPRING); + + // Labels + switch (t) { + case PrimitiveObject::AP_SPHERE: + cfg["radiusLabel"] = "Radius"; cfg["segXLabel"] = "Ring"; cfg["segYLabel"] = "Loop"; break; + case PrimitiveObject::AP_CYLINDER: + case PrimitiveObject::AP_CONE: + cfg["radiusLabel"] = "Radius"; cfg["segXLabel"] = "Base"; cfg["segZLabel"] = "Height"; break; + case PrimitiveObject::AP_TORUS: + cfg["radiusLabel"] = "Radius"; cfg["radius2Label"] = "Section R"; cfg["segXLabel"] = "Circle"; cfg["segYLabel"] = "Section"; break; + case PrimitiveObject::AP_TUBE: + cfg["radiusLabel"] = "Outer R"; cfg["radius2Label"] = "Inner R"; cfg["segXLabel"] = "Base"; cfg["segZLabel"] = "Height"; break; + case PrimitiveObject::AP_CAPSULE: + cfg["radiusLabel"] = "Radius"; cfg["segXLabel"] = "Ring"; cfg["segYLabel"] = "Loop"; cfg["segZLabel"] = "Height"; break; + case PrimitiveObject::AP_ICOSPHERE: + cfg["radiusLabel"] = "Radius"; cfg["segXLabel"] = "Iterations"; break; + case PrimitiveObject::AP_ROUNDEDBOX: + cfg["radiusLabel"] = "Chamfer"; cfg["segXLabel"] = "X"; cfg["segYLabel"] = "Y"; cfg["segZLabel"] = "Z"; break; + case PrimitiveObject::AP_SPRING: + cfg["segXLabel"] = "Circle"; cfg["segYLabel"] = "Path"; break; + default: + cfg["segXLabel"] = "X"; cfg["segYLabel"] = "Y"; cfg["segZLabel"] = "Z"; break; + } + + return cfg; +} +void PropertiesPanelController::setPrimSegX(int v) { auto* p = getSelectedPrimitive(); if (p) { p->setNumSegX(v); emit primitiveChanged(); } } +void PropertiesPanelController::setPrimSegY(int v) { auto* p = getSelectedPrimitive(); if (p) { p->setNumSegY(v); emit primitiveChanged(); } } +void PropertiesPanelController::setPrimSegZ(int v) { auto* p = getSelectedPrimitive(); if (p) { p->setNumSegZ(v); emit primitiveChanged(); } } +void PropertiesPanelController::setPrimUTile(double v) { auto* p = getSelectedPrimitive(); if (p) { p->setUTile(v); emit primitiveChanged(); } } +void PropertiesPanelController::setPrimVTile(double v) { auto* p = getSelectedPrimitive(); if (p) { p->setVTile(v); emit primitiveChanged(); } } + +bool PropertiesPanelController::hasAnimations() const +{ + auto entities = SelectionSet::getSingleton()->getResolvedEntities(); + for (Ogre::Entity* ent : entities) + { + auto* states = ent->getAllAnimationStates(); + if (states && !states->getAnimationStates().empty()) + return true; + } + return false; +} + +QVariantList PropertiesPanelController::animationData() const +{ + QVariantList result; + auto entities = SelectionSet::getSingleton()->getResolvedEntities(); + for (Ogre::Entity* ent : entities) + { + auto* states = ent->getAllAnimationStates(); + if (!states || states->getAnimationStates().empty()) continue; + + QVariantMap entityGroup; + entityGroup["entity"] = QString::fromStdString(ent->getName()); + entityGroup["hasSkeleton"] = ent->hasSkeleton(); + entityGroup["showSkeleton"] = mAnimationWidget ? mAnimationWidget->isSkeletonDebugActive(ent) : false; + entityGroup["showWeights"] = mAnimationWidget ? mAnimationWidget->isBoneWeightsShown(ent) : false; + + QVariantList anims; + for (const auto& [key, state] : states->getAnimationStates()) + { + QVariantMap anim; + anim["name"] = QString::fromStdString(key); + anim["enabled"] = state->getEnabled(); + anim["loop"] = state->getLoop(); + anim["length"] = state->getLength(); + anims.append(anim); + } + entityGroup["animations"] = anims; + result.append(entityGroup); + } + return result; +} + +void PropertiesPanelController::toggleAnimationEnabled(const QString& entityName, const QString& animName, bool enabled) +{ + auto entities = SelectionSet::getSingleton()->getResolvedEntities(); + for (Ogre::Entity* ent : entities) + { + if (QString::fromStdString(ent->getName()) != entityName) continue; + auto* state = ent->getAnimationState(animName.toStdString()); + if (state) + { + state->setEnabled(enabled); + if (enabled) state->setLoop(true); + emit animationStateChanged(); + } + } +} + +void PropertiesPanelController::toggleAnimationLoop(const QString& entityName, const QString& animName, bool loop) +{ + auto entities = SelectionSet::getSingleton()->getResolvedEntities(); + for (Ogre::Entity* ent : entities) + { + if (QString::fromStdString(ent->getName()) != entityName) continue; + auto* state = ent->getAnimationState(animName.toStdString()); + if (state) + { + state->setLoop(loop); + emit animationStateChanged(); + } + } +} + +void PropertiesPanelController::setPlaying(bool playing) +{ + if (mPlaying != playing) + { + mPlaying = playing; + emit playingChanged(); + } +} + +void PropertiesPanelController::toggleSkeletonDebug(const QString& entityName, bool show) +{ + if (!mAnimationWidget) return; + auto entities = SelectionSet::getSingleton()->getResolvedEntities(); + for (Ogre::Entity* ent : entities) + { + if (QString::fromStdString(ent->getName()) == entityName) + { + mAnimationWidget->toggleSkeletonDebug(ent, show); + emit animationStateChanged(); + return; + } + } +} + +void PropertiesPanelController::toggleBoneWeights(const QString& entityName, bool show) +{ + if (!mAnimationWidget) return; + auto entities = SelectionSet::getSingleton()->getResolvedEntities(); + for (Ogre::Entity* ent : entities) + { + if (QString::fromStdString(ent->getName()) == entityName) + { + mAnimationWidget->toggleBoneWeights(ent, show); + emit animationStateChanged(); + return; + } + } +} + +bool PropertiesPanelController::renameAnimation(const QString& entityName, const QString& oldName, const QString& newName) +{ + if (newName.isEmpty() || oldName == newName) return false; + + auto entities = SelectionSet::getSingleton()->getResolvedEntities(); + for (Ogre::Entity* ent : entities) + { + if (QString::fromStdString(ent->getName()) != entityName) continue; + if (Manager::getSingleton()->hasAnimationName(ent, newName)) return false; + + // Disable skeleton debug/weights and stop playback before rename + // (rename recreates entity internals, stale pointers would crash) + if (mAnimationWidget) + { + // disableAllSkeletonDebug is private, but toggleSkeletonDebug(false) cleans up + if (mAnimationWidget->isSkeletonDebugActive(ent)) + mAnimationWidget->toggleSkeletonDebug(ent, false); + if (mAnimationWidget->isBoneWeightsShown(ent)) + mAnimationWidget->toggleBoneWeights(ent, false); + } + setPlaying(false); + if (auto* animSet = ent->getAllAnimationStates()) + { + for (const auto& [key, state] : animSet->getAnimationStates()) + state->setEnabled(false); + } + + if (SkeletonTransform::renameAnimation(ent, oldName, newName)) + { + // Re-select current selection to force all widgets (including the + // old AnimationWidget) to refresh their tables with new entity data. + // This prevents stale pointers from crashing the poll timer. + auto nodes = SelectionSet::getSingleton()->getNodesSelectionList(); + SelectionSet::getSingleton()->clear(); + for (auto* node : nodes) + SelectionSet::getSingleton()->append(node); + + emit animationStateChanged(); + return true; + } + } + return false; +} + +void PropertiesPanelController::onSelectionChanged() +{ + onTransformChanged(); + emit selectionChanged(); + emit primitiveChanged(); +} + +void PropertiesPanelController::onTransformChanged() +{ + emit transformChanged(); +} + +void PropertiesPanelController::onSceneChanged() +{ + emit sceneChanged(); +} + +void PropertiesPanelController::refreshTheme() +{ + emit themeChanged(); +} diff --git a/src/PropertiesPanelController.h b/src/PropertiesPanelController.h new file mode 100644 index 000000000..2d9d4ab0d --- /dev/null +++ b/src/PropertiesPanelController.h @@ -0,0 +1,181 @@ +#ifndef PROPERTIES_PANEL_CONTROLLER_H +#define PROPERTIES_PANEL_CONTROLLER_H + +#include +#include +#include +#include +#include +#include "SceneTreeModel.h" + +class PropertiesPanelController : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + // Theme colors (synced from QPalette) + Q_PROPERTY(QColor panelColor READ panelColor NOTIFY themeChanged) + Q_PROPERTY(QColor headerColor READ headerColor NOTIFY themeChanged) + Q_PROPERTY(QColor textColor READ textColor NOTIFY themeChanged) + Q_PROPERTY(QColor borderColor READ borderColor NOTIFY themeChanged) + Q_PROPERTY(QColor inputColor READ inputColor NOTIFY themeChanged) + Q_PROPERTY(QColor highlightColor READ highlightColor NOTIFY themeChanged) + + // Transform properties + Q_PROPERTY(double posX READ posX WRITE setPosX NOTIFY transformChanged) + Q_PROPERTY(double posY READ posY WRITE setPosY NOTIFY transformChanged) + Q_PROPERTY(double posZ READ posZ WRITE setPosZ NOTIFY transformChanged) + Q_PROPERTY(double rotX READ rotX WRITE setRotX NOTIFY transformChanged) + Q_PROPERTY(double rotY READ rotY WRITE setRotY NOTIFY transformChanged) + Q_PROPERTY(double rotZ READ rotZ WRITE setRotZ NOTIFY transformChanged) + Q_PROPERTY(double scaleX READ scaleX WRITE setScaleX NOTIFY transformChanged) + Q_PROPERTY(double scaleY READ scaleY WRITE setScaleY NOTIFY transformChanged) + Q_PROPERTY(double scaleZ READ scaleZ WRITE setScaleZ NOTIFY transformChanged) + + // Selection state + Q_PROPERTY(bool hasSelection READ hasSelection NOTIFY selectionChanged) + Q_PROPERTY(bool hasEntitySelection READ hasEntitySelection NOTIFY selectionChanged) + Q_PROPERTY(QString selectionName READ selectionName NOTIFY selectionChanged) + Q_PROPERTY(QStringList sceneNodeNames READ sceneNodeNames NOTIFY sceneChanged) + Q_PROPERTY(SceneTreeModel* sceneTreeModel READ sceneTreeModel CONSTANT) + + // Animation + Q_PROPERTY(bool hasAnimations READ hasAnimations NOTIFY selectionChanged) + Q_PROPERTY(bool playing READ isPlaying WRITE setPlaying NOTIFY playingChanged) + + // Primitive properties + Q_PROPERTY(bool hasPrimitive READ hasPrimitive NOTIFY selectionChanged) + Q_PROPERTY(QString primitiveType READ primitiveType NOTIFY selectionChanged) + Q_PROPERTY(double primSizeX READ primSizeX WRITE setPrimSizeX NOTIFY primitiveChanged) + Q_PROPERTY(double primSizeY READ primSizeY WRITE setPrimSizeY NOTIFY primitiveChanged) + Q_PROPERTY(double primSizeZ READ primSizeZ WRITE setPrimSizeZ NOTIFY primitiveChanged) + Q_PROPERTY(double primRadius READ primRadius WRITE setPrimRadius NOTIFY primitiveChanged) + Q_PROPERTY(double primRadius2 READ primRadius2 WRITE setPrimRadius2 NOTIFY primitiveChanged) + Q_PROPERTY(double primHeight READ primHeight WRITE setPrimHeight NOTIFY primitiveChanged) + Q_PROPERTY(int primSegX READ primSegX WRITE setPrimSegX NOTIFY primitiveChanged) + Q_PROPERTY(int primSegY READ primSegY WRITE setPrimSegY NOTIFY primitiveChanged) + Q_PROPERTY(int primSegZ READ primSegZ WRITE setPrimSegZ NOTIFY primitiveChanged) + Q_PROPERTY(double primUTile READ primUTile WRITE setPrimUTile NOTIFY primitiveChanged) + Q_PROPERTY(double primVTile READ primVTile WRITE setPrimVTile NOTIFY primitiveChanged) + + // Primitive field visibility & labels (driven by primitive type) + Q_PROPERTY(QVariantMap primFieldConfig READ primFieldConfig NOTIFY selectionChanged) + +public: + static PropertiesPanelController* instance(); + static PropertiesPanelController* qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine); + static void kill(); + + // Theme colors + QColor panelColor() const; + QColor headerColor() const; + QColor textColor() const; + QColor borderColor() const; + QColor inputColor() const; + QColor highlightColor() const; + + // Transform accessors + double posX() const; + double posY() const; + double posZ() const; + double rotX() const; + double rotY() const; + double rotZ() const; + double scaleX() const; + double scaleY() const; + double scaleZ() const; + + // Transform mutators + void setPosX(double v); + void setPosY(double v); + void setPosZ(double v); + void setRotX(double v); + void setRotY(double v); + void setRotZ(double v); + void setScaleX(double v); + void setScaleY(double v); + void setScaleZ(double v); + + // Selection state + bool hasSelection() const; + bool hasEntitySelection() const; + QString selectionName() const; + QStringList sceneNodeNames() const; + + SceneTreeModel* sceneTreeModel() const; + + bool hasAnimations() const; + + // Primitive + bool hasPrimitive() const; + QString primitiveType() const; + double primSizeX() const; + double primSizeY() const; + double primSizeZ() const; + double primRadius() const; + double primRadius2() const; + double primHeight() const; + QVariantMap primFieldConfig() const; + int primSegX() const; + int primSegY() const; + int primSegZ() const; + double primUTile() const; + double primVTile() const; + + void setPrimSizeX(double v); + void setPrimSizeY(double v); + void setPrimSizeZ(double v); + void setPrimRadius(double v); + void setPrimRadius2(double v); + void setPrimHeight(double v); + void setPrimSegX(int v); + void setPrimSegY(int v); + void setPrimSegZ(int v); + void setPrimUTile(double v); + void setPrimVTile(double v); + + Q_INVOKABLE void selectNodeByName(const QString& name); + void setAnimationWidget(class AnimationWidget* widget) { mAnimationWidget = widget; } + + // Animation + Q_INVOKABLE QVariantList animationData() const; // grouped per entity + Q_INVOKABLE void toggleAnimationEnabled(const QString& entityName, const QString& animName, bool enabled); + Q_INVOKABLE void toggleAnimationLoop(const QString& entityName, const QString& animName, bool loop); + Q_INVOKABLE void setPlaying(bool playing); + Q_INVOKABLE bool isPlaying() const { return mPlaying; } + Q_INVOKABLE void toggleSkeletonDebug(const QString& entityName, bool show); + Q_INVOKABLE void toggleBoneWeights(const QString& entityName, bool show); + Q_INVOKABLE bool renameAnimation(const QString& entityName, const QString& oldName, const QString& newName); + +public slots: + void onSelectionChanged(); + void onTransformChanged(); + void onSceneChanged(); + void refreshTheme(); + +signals: + void transformChanged(); + void selectionChanged(); + void sceneChanged(); + void themeChanged(); + void primitiveChanged(); + void playingChanged(); + void animationStateChanged(); + +private: + PropertiesPanelController(); + ~PropertiesPanelController() override = default; + + static PropertiesPanelController* m_pSingleton; + + double mPosX = 0, mPosY = 0, mPosZ = 0; + double mRotX = 0, mRotY = 0, mRotZ = 0; + double mScaleX = 1, mScaleY = 1, mScaleZ = 1; + + SceneTreeModel* mSceneTreeModel = nullptr; + bool mPlaying = false; + class AnimationWidget* mAnimationWidget = nullptr; +}; + +#endif // PROPERTIES_PANEL_CONTROLLER_H diff --git a/src/ScaleGizmo.cpp b/src/ScaleGizmo.cpp new file mode 100644 index 000000000..83fb6b153 --- /dev/null +++ b/src/ScaleGizmo.cpp @@ -0,0 +1,336 @@ +#include "GlobalDefinitions.h" + +#include "ScaleGizmo.h" +#include "Manager.h" +#include + +const float ScaleGizmo::mSolidThickness = 30.0f; +const float ScaleGizmo::mCubeSize = 3.0f; // Cube handle is 3x the shaft thickness + +ScaleGizmo::ScaleGizmo(Ogre::SceneNode* linkNode, const Ogre::String &name, Ogre::Real scale) + : m_pXaxis(nullptr), m_pYaxis(nullptr), m_pZaxis(nullptr), mFade(0.4f), mHighlighted(false) +{ + mScale = scale; + + Ogre::SceneManager* pSceneMgr = linkNode->getCreator(); + m_pXaxis = pSceneMgr->createManualObject(name + "X"); + m_pYaxis = pSceneMgr->createManualObject(name + "Y"); + m_pZaxis = pSceneMgr->createManualObject(name + "Z"); + + // Default Colors + Ogre::Real solid = 1.0f; + mXaxisColor = Ogre::ColourValue(solid, 0, 0, solid); + mYaxisColor = Ogre::ColourValue(0, solid, 0, solid); + mZaxisColor = Ogre::ColourValue(0, 0, solid, solid); + + m_pXaxis->setRenderQueueGroup(ZORDER_OVERLAY); + m_pYaxis->setRenderQueueGroup(ZORDER_OVERLAY); + m_pZaxis->setRenderQueueGroup(ZORDER_OVERLAY); + + setQueryFlags(0); + + linkNode->attachObject(m_pXaxis); + linkNode->attachObject(m_pYaxis); + linkNode->attachObject(m_pZaxis); +} + +ScaleGizmo::~ScaleGizmo() +{ + Ogre::SceneManager* pSceneMgr = m_pXaxis->getParentSceneNode()->getCreator(); + pSceneMgr->destroyManualObject(m_pXaxis); + pSceneMgr->destroyManualObject(m_pYaxis); + pSceneMgr->destroyManualObject(m_pZaxis); +} + +void ScaleGizmo::createCube(Ogre::ManualObject* obj, const Ogre::ColourValue& colour, + const Ogre::Vector3& center, float halfSize) +{ + // 8 vertices of a cube centered at 'center' + float cx = center.x, cy = center.y, cz = center.z; + float s = halfSize; + + int base = obj->getCurrentVertexCount(); + + obj->position(Ogre::Vector3(cx - s, cy + s, cz + s)); + obj->position(Ogre::Vector3(cx - s, cy - s, cz + s)); + obj->position(Ogre::Vector3(cx + s, cy - s, cz + s)); + obj->position(Ogre::Vector3(cx + s, cy + s, cz + s)); + + obj->position(Ogre::Vector3(cx - s, cy + s, cz - s)); + obj->position(Ogre::Vector3(cx - s, cy - s, cz - s)); + obj->position(Ogre::Vector3(cx + s, cy - s, cz - s)); + obj->position(Ogre::Vector3(cx + s, cy + s, cz - s)); + + // 6 faces (12 triangles) + // Front + obj->quad(base + 0, base + 1, base + 2, base + 3); + // Back + obj->quad(base + 7, base + 6, base + 5, base + 4); + // Top + obj->quad(base + 0, base + 3, base + 7, base + 4); + // Bottom + obj->quad(base + 2, base + 1, base + 5, base + 6); + // Right + obj->quad(base + 3, base + 2, base + 6, base + 7); + // Left + obj->quad(base + 1, base + 0, base + 4, base + 5); +} + +void ScaleGizmo::createXaxis(const Ogre::ColourValue& colour) +{ + createSolidXaxis(colour); +} + +void ScaleGizmo::createYaxis(const Ogre::ColourValue& colour) +{ + createSolidYaxis(colour); +} + +void ScaleGizmo::createZaxis(const Ogre::ColourValue& colour) +{ + createSolidZaxis(colour); +} + +void ScaleGizmo::createSolidXaxis(const Ogre::ColourValue& colour) +{ + float thickness = mScale / mSolidThickness; + float shaftLength = mScale * 0.85f; + float cubeHalf = thickness * mCubeSize; + + m_pXaxis->clear(); + m_pXaxis->begin(GUI_MATERIAL_NAME, Ogre::RenderOperation::OT_TRIANGLE_LIST); + m_pXaxis->colour(colour); + + // Shaft - thin box from origin to shaftLength along +X + m_pXaxis->position(Ogre::Vector3(0, thickness, thickness)); + m_pXaxis->position(Ogre::Vector3(0, -thickness, thickness)); + m_pXaxis->position(Ogre::Vector3(shaftLength, -thickness, thickness)); + m_pXaxis->position(Ogre::Vector3(shaftLength, thickness, thickness)); + + m_pXaxis->position(Ogre::Vector3(0, thickness, -thickness)); + m_pXaxis->position(Ogre::Vector3(0, -thickness, -thickness)); + m_pXaxis->position(Ogre::Vector3(shaftLength, -thickness, -thickness)); + m_pXaxis->position(Ogre::Vector3(shaftLength, thickness, -thickness)); + + m_pXaxis->quad(0, 1, 2, 3); + m_pXaxis->quad(7, 6, 5, 4); + m_pXaxis->quad(0, 3, 7, 4); + m_pXaxis->quad(2, 1, 5, 6); + m_pXaxis->quad(3, 2, 6, 7); + m_pXaxis->quad(1, 0, 4, 5); + + // Cube handle at end of shaft + createCube(m_pXaxis, colour, Ogre::Vector3(mScale, 0, 0), cubeHalf); + + m_pXaxis->end(); +} + +void ScaleGizmo::createSolidYaxis(const Ogre::ColourValue& colour) +{ + float thickness = mScale / mSolidThickness; + float shaftLength = mScale * 0.85f; + float cubeHalf = thickness * mCubeSize; + + m_pYaxis->clear(); + m_pYaxis->begin(GUI_MATERIAL_NAME, Ogre::RenderOperation::OT_TRIANGLE_LIST); + m_pYaxis->colour(colour); + + // Shaft along +Y + m_pYaxis->position(Ogre::Vector3( thickness, 0, thickness)); + m_pYaxis->position(Ogre::Vector3( thickness, shaftLength, thickness)); + m_pYaxis->position(Ogre::Vector3(-thickness, shaftLength, thickness)); + m_pYaxis->position(Ogre::Vector3(-thickness, 0, thickness)); + + m_pYaxis->position(Ogre::Vector3( thickness, 0, -thickness)); + m_pYaxis->position(Ogre::Vector3( thickness, shaftLength, -thickness)); + m_pYaxis->position(Ogre::Vector3(-thickness, shaftLength, -thickness)); + m_pYaxis->position(Ogre::Vector3(-thickness, 0, -thickness)); + + m_pYaxis->quad(0, 1, 2, 3); + m_pYaxis->quad(7, 6, 5, 4); + m_pYaxis->quad(1, 0, 4, 5); + m_pYaxis->quad(3, 2, 6, 7); + m_pYaxis->quad(2, 1, 5, 6); + m_pYaxis->quad(0, 3, 7, 4); + + // Cube handle at end + createCube(m_pYaxis, colour, Ogre::Vector3(0, mScale, 0), cubeHalf); + + m_pYaxis->end(); +} + +void ScaleGizmo::createSolidZaxis(const Ogre::ColourValue& colour) +{ + float thickness = mScale / mSolidThickness; + float shaftLength = mScale * 0.85f; + float cubeHalf = thickness * mCubeSize; + + m_pZaxis->clear(); + m_pZaxis->begin(GUI_MATERIAL_NAME, Ogre::RenderOperation::OT_TRIANGLE_LIST); + m_pZaxis->colour(colour); + + // Shaft along +Z + m_pZaxis->position(Ogre::Vector3( thickness, thickness, 0)); + m_pZaxis->position(Ogre::Vector3( thickness, thickness, shaftLength)); + m_pZaxis->position(Ogre::Vector3( thickness, -thickness, shaftLength)); + m_pZaxis->position(Ogre::Vector3( thickness, -thickness, 0)); + + m_pZaxis->position(Ogre::Vector3(-thickness, thickness, 0)); + m_pZaxis->position(Ogre::Vector3(-thickness, thickness, shaftLength)); + m_pZaxis->position(Ogre::Vector3(-thickness, -thickness, shaftLength)); + m_pZaxis->position(Ogre::Vector3(-thickness, -thickness, 0)); + + m_pZaxis->quad(0, 1, 2, 3); + m_pZaxis->quad(7, 6, 5, 4); + m_pZaxis->quad(1, 0, 4, 5); + m_pZaxis->quad(3, 2, 6, 7); + m_pZaxis->quad(2, 1, 5, 6); + m_pZaxis->quad(0, 3, 7, 4); + + // Cube handle at end + createCube(m_pZaxis, colour, Ogre::Vector3(0, 0, mScale), cubeHalf); + + m_pZaxis->end(); +} + +////////////////////////////////////////// +// Accessors + +bool ScaleGizmo::isHighlighted(void) const +{ return mHighlighted; } + +Ogre::uint32 ScaleGizmo::getQueryFlags(void) const +{ return m_pXaxis->getQueryFlags(); } + +const Ogre::Real& ScaleGizmo::getFading(void) const +{ return mFade; } + +const Ogre::ColourValue& ScaleGizmo::getXaxisColour(void) const +{ return mXaxisColor; } + +const Ogre::ColourValue& ScaleGizmo::getYaxisColour(void) const +{ return mYaxisColor; } + +const Ogre::ColourValue& ScaleGizmo::getZaxisColour(void) const +{ return mZaxisColor; } + +const Ogre::Real& ScaleGizmo::getScale(void) const +{ return mScale; } + +const Ogre::ManualObject& ScaleGizmo::getXAxis() const +{ return *m_pXaxis; } + +const Ogre::ManualObject& ScaleGizmo::getYAxis() const +{ return *m_pYaxis; } + +const Ogre::ManualObject& ScaleGizmo::getZAxis() const +{ return *m_pZaxis; } + +////////////////////////////////////////// +// Mutators + +void ScaleGizmo::setVisible(bool visible) +{ + if(visible) + createAxis(); + m_pXaxis->setVisible(visible); + m_pYaxis->setVisible(visible); + m_pZaxis->setVisible(visible); +} + +void ScaleGizmo::setQueryFlags(Ogre::uint32 flags) +{ + m_pXaxis->setQueryFlags(flags); + m_pYaxis->setQueryFlags(flags); + m_pZaxis->setQueryFlags(flags); +} + +void ScaleGizmo::setFading(const Ogre::Real& fade) +{ mFade = fade; } + +void ScaleGizmo::setXaxisColour(const Ogre::ColourValue &colour) +{ + mXaxisColor = colour; + createXaxis(mXaxisColor); +} + +void ScaleGizmo::setYaxisColour(const Ogre::ColourValue &colour) +{ + mYaxisColor = colour; + createYaxis(mYaxisColor); +} + +void ScaleGizmo::setZaxisColour(const Ogre::ColourValue &colour) +{ + mZaxisColor = colour; + createZaxis(mZaxisColor); +} + +void ScaleGizmo::setScale(const Ogre::Real& scale) +{ + mScale = scale; + createAxis(); +} + +void ScaleGizmo::createAxis(void) +{ + createXaxis(mXaxisColor); + createYaxis(mYaxisColor); + createZaxis(mZaxisColor); + + // Update bounding boxes to encompass cube handles + float cubeHalf = (mScale / mSolidThickness) * mCubeSize; + + Ogre::AxisAlignedBox boundingBox = m_pXaxis->getBoundingBox(); + boundingBox.setExtents(Ogre::Vector3(0, -cubeHalf, -cubeHalf), + Ogre::Vector3(mScale + cubeHalf, cubeHalf, cubeHalf)); + m_pXaxis->setBoundingBox(boundingBox); + + boundingBox = m_pYaxis->getBoundingBox(); + boundingBox.setExtents(Ogre::Vector3(-cubeHalf, 0, -cubeHalf), + Ogre::Vector3(cubeHalf, mScale + cubeHalf, cubeHalf)); + m_pYaxis->setBoundingBox(boundingBox); + + boundingBox = m_pZaxis->getBoundingBox(); + boundingBox.setExtents(Ogre::Vector3(-cubeHalf, -cubeHalf, 0), + Ogre::Vector3(cubeHalf, cubeHalf, mScale + cubeHalf)); + m_pZaxis->setBoundingBox(boundingBox); + + mHighlighted = false; +} + +Ogre::Vector3 ScaleGizmo::highlightAxis(const Ogre::MovableObject* obj) +{ + Ogre::Vector3 result = Ogre::Vector3::ZERO; + + if(obj == m_pXaxis) + { + createSolidXaxis(mXaxisColor); + createYaxis(mYaxisColor * mFade); + createZaxis(mZaxisColor * mFade); + mHighlighted = true; + result = Ogre::Vector3::UNIT_X; + } + else if(obj == m_pYaxis) + { + createSolidYaxis(mYaxisColor); + createXaxis(mXaxisColor * mFade); + createZaxis(mZaxisColor * mFade); + result = Ogre::Vector3::UNIT_Y; + mHighlighted = true; + } + else if(obj == m_pZaxis) + { + createSolidZaxis(mZaxisColor); + createXaxis(mXaxisColor * mFade); + createYaxis(mYaxisColor * mFade); + result = Ogre::Vector3::UNIT_Z; + mHighlighted = true; + } + else + { + createAxis(); + } + + return result; +} diff --git a/src/ScaleGizmo.h b/src/ScaleGizmo.h new file mode 100644 index 000000000..d0d80b3e0 --- /dev/null +++ b/src/ScaleGizmo.h @@ -0,0 +1,79 @@ +#ifndef SCALE_GIZMO_H +#define SCALE_GIZMO_H + +#include +#include +namespace Ogre +{ + class SceneNode; + class ManualObject; + class MovableObject; + class ColourValue; +} + +class ScaleGizmo +{ +public: + ScaleGizmo(Ogre::SceneNode* linkNode, const Ogre::String &name = "ScaleGizmo", Ogre::Real scale = 1.0f); + ~ScaleGizmo(); + + // Accessors + bool isHighlighted(void) const; + + Ogre::uint32 getQueryFlags(void) const; + const Ogre::Real& getFading(void) const; + + const Ogre::ManualObject& getXAxis(void) const; + const Ogre::ManualObject& getYAxis(void) const; + const Ogre::ManualObject& getZAxis(void) const; + + const Ogre::ColourValue& getXaxisColour(void) const; + const Ogre::ColourValue& getYaxisColour(void) const; + const Ogre::ColourValue& getZaxisColour(void) const; + + const Ogre::Real& getScale(void) const; + + // Mutators + void setVisible(bool visible = true); + void setQueryFlags(Ogre::uint32 flags = 0xFF); + void setFading(const Ogre::Real& fade); + + void setXaxisColour(const Ogre::ColourValue &colour); + void setYaxisColour(const Ogre::ColourValue &colour); + void setZaxisColour(const Ogre::ColourValue &colour); + + void setScale(const Ogre::Real& scale); + + virtual void createAxis(void); + Ogre::Vector3 highlightAxis(const Ogre::MovableObject* obj); + +protected: + virtual void createXaxis(const Ogre::ColourValue& colour); + virtual void createYaxis(const Ogre::ColourValue& colour); + virtual void createZaxis(const Ogre::ColourValue& colour); + + virtual void createSolidXaxis(const Ogre::ColourValue& colour); + virtual void createSolidYaxis(const Ogre::ColourValue& colour); + virtual void createSolidZaxis(const Ogre::ColourValue& colour); + +private: + Ogre::Real mScale; + Ogre::Real mFade; + static const float mSolidThickness; + static const float mCubeSize; + + bool mHighlighted; + + Ogre::ManualObject* m_pXaxis; + Ogre::ManualObject* m_pYaxis; + Ogre::ManualObject* m_pZaxis; + + Ogre::ColourValue mXaxisColor; + Ogre::ColourValue mYaxisColor; + Ogre::ColourValue mZaxisColor; + + void createCube(Ogre::ManualObject* obj, const Ogre::ColourValue& colour, + const Ogre::Vector3& center, float halfSize); +}; + +#endif //SCALE_GIZMO_H diff --git a/src/ScaleGizmo_test.cpp b/src/ScaleGizmo_test.cpp new file mode 100644 index 000000000..b78b01a14 --- /dev/null +++ b/src/ScaleGizmo_test.cpp @@ -0,0 +1,124 @@ +#include +#include +#include +#include "ScaleGizmo.h" +#include "Manager.h" +#include +#include +#include +#include "TestHelpers.h" + +static void createOGREMaterials() +{ + ensureMaterialManagerInitialised(); + Ogre::MaterialPtr guiMat = Ogre::MaterialManager::getSingleton().getByName(GUI_MATERIAL_NAME, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + if (!guiMat) + { + guiMat = Ogre::MaterialManager::getSingleton().create(GUI_MATERIAL_NAME, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + guiMat->getTechnique(0)->setLightingEnabled(false); + guiMat->getTechnique(0)->setSceneBlending(Ogre::SBT_TRANSPARENT_ALPHA); + guiMat->getTechnique(0)->setDepthCheckEnabled(false); + } +} + +class ScaleGizmoTests : public ::testing::Test { +protected: + QApplication* app = nullptr; + Ogre::SceneManager* mSceneMgr = nullptr; + Ogre::SceneNode* mLinkNode = nullptr; + std::unique_ptr mScaleGizmo; + + void SetUp() override { + Manager::kill(); + QThread::msleep(50); + + app = qobject_cast(QCoreApplication::instance()); + ASSERT_NE(app, nullptr); + + if (!tryInitOgre()) { + GTEST_SKIP() << "Skipping: Ogre initialization failed"; + } + createOGREMaterials(); + if (!canLoadMeshFiles()) { GTEST_SKIP() << "Skipping: entity creation not supported without render window"; } + + Manager* manager = Manager::getSingleton(); + ASSERT_NE(manager, nullptr); + mSceneMgr = manager->getSceneMgr(); + ASSERT_NE(mSceneMgr, nullptr); + mLinkNode = mSceneMgr->createSceneNode(); + ASSERT_NE(mLinkNode, nullptr); + + mScaleGizmo = std::make_unique(mLinkNode, "TestScaleGizmo"); + } + + void TearDown() override { + mScaleGizmo.reset(); + mLinkNode = nullptr; + if (app) app->processEvents(); + } +}; + +TEST_F(ScaleGizmoTests, InitialState) { + EXPECT_FALSE(mScaleGizmo->isHighlighted()); + EXPECT_EQ(mScaleGizmo->getQueryFlags(), 0u); + EXPECT_FLOAT_EQ(mScaleGizmo->getScale(), 1.0f); + EXPECT_FLOAT_EQ(mScaleGizmo->getFading(), 0.4f); +} + +TEST_F(ScaleGizmoTests, HighlightAxis) { + EXPECT_FALSE(mScaleGizmo->isHighlighted()); + + auto result = mScaleGizmo->highlightAxis(&mScaleGizmo->getXAxis()); + EXPECT_EQ(result, Ogre::Vector3::UNIT_X); + EXPECT_TRUE(mScaleGizmo->isHighlighted()); + + result = mScaleGizmo->highlightAxis(&mScaleGizmo->getYAxis()); + EXPECT_EQ(result, Ogre::Vector3::UNIT_Y); + + result = mScaleGizmo->highlightAxis(&mScaleGizmo->getZAxis()); + EXPECT_EQ(result, Ogre::Vector3::UNIT_Z); + + result = mScaleGizmo->highlightAxis(nullptr); + EXPECT_EQ(result, Ogre::Vector3::ZERO); + EXPECT_FALSE(mScaleGizmo->isHighlighted()); +} + +TEST_F(ScaleGizmoTests, SetVisible) { + mScaleGizmo->setVisible(true); + EXPECT_TRUE(mScaleGizmo->getXAxis().isVisible()); + EXPECT_TRUE(mScaleGizmo->getYAxis().isVisible()); + EXPECT_TRUE(mScaleGizmo->getZAxis().isVisible()); + + mScaleGizmo->setVisible(false); + EXPECT_FALSE(mScaleGizmo->getXAxis().isVisible()); + EXPECT_FALSE(mScaleGizmo->getYAxis().isVisible()); + EXPECT_FALSE(mScaleGizmo->getZAxis().isVisible()); +} + +TEST_F(ScaleGizmoTests, SetScale) { + mScaleGizmo->setScale(2.0); + EXPECT_FLOAT_EQ(mScaleGizmo->getScale(), 2.0f); +} + +TEST_F(ScaleGizmoTests, SetColour) { + mScaleGizmo->setXaxisColour(Ogre::ColourValue::Red); + EXPECT_EQ(mScaleGizmo->getXaxisColour(), Ogre::ColourValue::Red); + + mScaleGizmo->setYaxisColour(Ogre::ColourValue::Green); + EXPECT_EQ(mScaleGizmo->getYaxisColour(), Ogre::ColourValue::Green); + + mScaleGizmo->setZaxisColour(Ogre::ColourValue::Blue); + EXPECT_EQ(mScaleGizmo->getZaxisColour(), Ogre::ColourValue::Blue); +} + +TEST_F(ScaleGizmoTests, SetQueryFlags) { + mScaleGizmo->setQueryFlags(GIZMO_QUERY_FLAGS); + EXPECT_EQ(mScaleGizmo->getQueryFlags(), GIZMO_QUERY_FLAGS); +} + +TEST_F(ScaleGizmoTests, CreateAxis) { + mScaleGizmo->createAxis(); + ASSERT_NE(&mScaleGizmo->getXAxis(), nullptr); + ASSERT_NE(&mScaleGizmo->getYAxis(), nullptr); + ASSERT_NE(&mScaleGizmo->getZAxis(), nullptr); +} diff --git a/src/SceneTreeModel.cpp b/src/SceneTreeModel.cpp new file mode 100644 index 000000000..3e538f491 --- /dev/null +++ b/src/SceneTreeModel.cpp @@ -0,0 +1,333 @@ +#include "SceneTreeModel.h" +#include "Manager.h" +#include "SelectionSet.h" +#include "GlobalDefinitions.h" +#include +#include + +// ---- SceneTreeItem ---- + +SceneTreeItem::SceneTreeItem(const QString& name, ItemType type, void* ogrePtr, SceneTreeItem* parent) + : mName(name), mType(type), mOgrePtr(ogrePtr), mParent(parent) +{ +} + +SceneTreeItem::~SceneTreeItem() +{ + qDeleteAll(mChildren); +} + +void SceneTreeItem::appendChild(SceneTreeItem* child) +{ + mChildren.append(child); +} + +SceneTreeItem* SceneTreeItem::child(int row) const +{ + if (row < 0 || row >= mChildren.size()) return nullptr; + return mChildren.at(row); +} + +int SceneTreeItem::childCount() const { return mChildren.size(); } + +int SceneTreeItem::row() const +{ + if (mParent) + return mParent->mChildren.indexOf(const_cast(this)); + return 0; +} + +SceneTreeItem* SceneTreeItem::parentItem() const { return mParent; } + +QString SceneTreeItem::typeLabel() const +{ + switch (mType) { + case Root: return "Scene"; + case Node: return "Node"; + case Entity: return "Mesh"; + case SubEntity: return "Submesh"; + } + return ""; +} + +// ---- SceneTreeModel ---- + +SceneTreeModel::SceneTreeModel(QObject* parent) + : QAbstractItemModel(parent) +{ + mRebuildTimer = new QTimer(this); + mRebuildTimer->setSingleShot(true); + mRebuildTimer->setInterval(50); // Debounce: coalesce rapid signals + connect(mRebuildTimer, &QTimer::timeout, this, &SceneTreeModel::rebuild); + + rebuild(); + + auto scheduleRebuild = [this]() { mRebuildTimer->start(); }; + connect(Manager::getSingleton(), &Manager::sceneNodeCreated, this, scheduleRebuild); + connect(Manager::getSingleton(), &Manager::sceneNodeDestroyed, this, scheduleRebuild); + connect(Manager::getSingleton(), &Manager::entityCreated, this, scheduleRebuild); + connect(SelectionSet::getSingleton(), &SelectionSet::selectionChanged, this, &SceneTreeModel::updateSelection); +} + +SceneTreeModel::~SceneTreeModel() +{ + delete mRootItem; +} + +void SceneTreeModel::rebuild() +{ + beginResetModel(); + delete mRootItem; + + auto* sceneMgr = Manager::getSingleton()->getSceneMgr(); + auto* rootNode = sceneMgr->getRootSceneNode(); + + mRootItem = new SceneTreeItem("Scene", SceneTreeItem::Root, rootNode, nullptr); + buildChildren(rootNode, mRootItem); + + endResetModel(); +} + +void SceneTreeModel::buildChildren(Ogre::SceneNode* sceneNode, SceneTreeItem* parentItem) +{ + auto children = sceneNode->getChildren(); + for (const auto& child : children) + { + auto* childNode = static_cast(child); + QString name = QString::fromStdString(childNode->getName()); + + if (name.isEmpty() || Manager::getSingleton()->isForbiddenNodeName(name)) + continue; + + auto* nodeItem = new SceneTreeItem(name, SceneTreeItem::Node, childNode, parentItem); + parentItem->appendChild(nodeItem); + + // Add entities + for (int i = 0; i < childNode->numAttachedObjects(); ++i) + { + Ogre::MovableObject* obj = childNode->getAttachedObject(i); + if (!obj || obj->getMovableType() != "Entity") continue; + + auto* entity = static_cast(obj); + QString entName = QString::fromStdString(entity->getName()); + auto* entItem = new SceneTreeItem(entName, SceneTreeItem::Entity, entity, nodeItem); + nodeItem->appendChild(entItem); + + // Add sub-entities + for (unsigned int s = 0; s < entity->getNumSubEntities(); ++s) + { + Ogre::SubEntity* sub = entity->getSubEntity(s); + QString subName = QString::number(s); + auto* subItem = new SceneTreeItem(subName, SceneTreeItem::SubEntity, sub, entItem); + entItem->appendChild(subItem); + } + } + + // Recurse child nodes + if (childNode->numChildren() > 0) + buildChildren(childNode, nodeItem); + } +} + +QModelIndex SceneTreeModel::index(int row, int column, const QModelIndex& parent) const +{ + if (!hasIndex(row, column, parent)) + return QModelIndex(); + + SceneTreeItem* parentItem = parent.isValid() + ? static_cast(parent.internalPointer()) + : mRootItem; + + SceneTreeItem* childItem = parentItem->child(row); + if (childItem) + return createIndex(row, column, childItem); + return QModelIndex(); +} + +QModelIndex SceneTreeModel::parent(const QModelIndex& child) const +{ + if (!child.isValid()) + return QModelIndex(); + + auto* childItem = static_cast(child.internalPointer()); + SceneTreeItem* parentItem = childItem->parentItem(); + + if (!parentItem || parentItem == mRootItem) + return QModelIndex(); + + return createIndex(parentItem->row(), 0, parentItem); +} + +int SceneTreeModel::rowCount(const QModelIndex& parent) const +{ + if (!mRootItem) return 0; + + SceneTreeItem* parentItem = parent.isValid() + ? static_cast(parent.internalPointer()) + : mRootItem; + + return parentItem->childCount(); +} + +int SceneTreeModel::columnCount(const QModelIndex&) const { return 1; } + +QVariant SceneTreeModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid()) return QVariant(); + + auto* item = static_cast(index.internalPointer()); + + switch (role) { + case Qt::DisplayRole: + case NameRole: + return item->name(); + case TypeRole: + return static_cast(item->type()); + case TypeLabelRole: + return item->typeLabel(); + case SelectedRole: { + auto* sel = SelectionSet::getSingleton(); + switch (item->type()) { + case SceneTreeItem::Node: + return sel->contains(static_cast(item->ogrePtr())); + case SceneTreeItem::Entity: + return sel->contains(static_cast(item->ogrePtr())); + case SceneTreeItem::SubEntity: + return sel->contains(static_cast(item->ogrePtr())); + default: + return false; + } + } + case MaterialNameRole: { + if (item->type() == SceneTreeItem::SubEntity) { + auto* sub = static_cast(item->ogrePtr()); + return QString::fromStdString(sub->getMaterialName()); + } + if (item->type() == SceneTreeItem::Entity) { + auto* ent = static_cast(item->ogrePtr()); + if (ent->getNumSubEntities() > 0) + return QString::fromStdString(ent->getSubEntity(0)->getMaterialName()); + } + return QVariant(); + } + default: + return QVariant(); + } +} + +QHash SceneTreeModel::roleNames() const +{ + QHash roles; + roles[NameRole] = "name"; + roles[TypeRole] = "itemType"; + roles[TypeLabelRole] = "typeLabel"; + roles[SelectedRole] = "isItemSelected"; + roles[MaterialNameRole] = "materialName"; + return roles; +} + +SceneTreeItem* SceneTreeModel::itemFromIndex(const QModelIndex& index) const +{ + if (!index.isValid()) return nullptr; + return static_cast(index.internalPointer()); +} + +QModelIndex SceneTreeModel::rootIndex() const +{ + return QModelIndex(); +} + +void SceneTreeModel::selectItem(int row, const QModelIndex& parentIndex, bool multiSelect) +{ + QModelIndex idx = index(row, 0, parentIndex); + auto* item = itemFromIndex(idx); + if (!item) return; + + auto* sel = SelectionSet::getSingleton(); + + if (!multiSelect) + sel->clear(); + + switch (item->type()) { + case SceneTreeItem::Node: { + auto* node = static_cast(item->ogrePtr()); + if (multiSelect && sel->contains(node)) + sel->removeOne(node); + else + sel->append(node); + break; + } + case SceneTreeItem::Entity: { + auto* entity = static_cast(item->ogrePtr()); + if (multiSelect && sel->contains(entity)) + sel->removeOne(entity); + else + sel->append(entity); + break; + } + case SceneTreeItem::SubEntity: { + auto* sub = static_cast(item->ogrePtr()); + if (multiSelect && sel->contains(sub)) + sel->removeOne(sub); + else + sel->append(sub); + break; + } + default: + break; + } +} + +bool SceneTreeModel::isSelected(int row, const QModelIndex& parentIndex) const +{ + QModelIndex idx = index(row, 0, parentIndex); + return data(idx, SelectedRole).toBool(); +} + +QString SceneTreeModel::materialName(int row, const QModelIndex& parentIndex) const +{ + QModelIndex idx = index(row, 0, parentIndex); + return data(idx, MaterialNameRole).toString(); +} + +void SceneTreeModel::setMaterial(int row, const QModelIndex& parentIndex, const QString& matName) +{ + QModelIndex idx = index(row, 0, parentIndex); + auto* item = itemFromIndex(idx); + if (!item) return; + + std::string stdName = matName.toStdString(); + + if (item->type() == SceneTreeItem::SubEntity) { + auto* sub = static_cast(item->ogrePtr()); + sub->setMaterialName(stdName); + emit dataChanged(idx, idx, {MaterialNameRole}); + } + else if (item->type() == SceneTreeItem::Entity) { + auto* ent = static_cast(item->ogrePtr()); + ent->setMaterialName(stdName); + emit dataChanged(idx, idx, {MaterialNameRole}); + } +} + +QStringList SceneTreeModel::availableMaterials() const +{ + QStringList names; + auto it = Ogre::MaterialManager::getSingleton().getResourceIterator(); + while (it.hasMoreElements()) + { + auto res = it.getNext(); + QString name = QString::fromStdString(res->getName()); + // Skip internal materials + if (!name.startsWith("Ogre/") && !name.startsWith("BaseWhite") && name != "GUI_Material") + names.append(name); + } + names.sort(Qt::CaseInsensitive); + return names; +} + +void SceneTreeModel::updateSelection() +{ + emit dataChanged(QModelIndex(), QModelIndex(), {SelectedRole}); + emit selectionUpdated(); +} diff --git a/src/SceneTreeModel.h b/src/SceneTreeModel.h new file mode 100644 index 000000000..de19d8c52 --- /dev/null +++ b/src/SceneTreeModel.h @@ -0,0 +1,89 @@ +#ifndef SCENE_TREE_MODEL_H +#define SCENE_TREE_MODEL_H + +#include +#include +#include + +class QTimer; + +namespace Ogre { + class SceneNode; + class Entity; + class SubEntity; +} + +class SceneTreeItem +{ +public: + enum ItemType { Root, Node, Entity, SubEntity }; + + SceneTreeItem(const QString& name, ItemType type, void* ogrePtr, SceneTreeItem* parent = nullptr); + ~SceneTreeItem(); + + void appendChild(SceneTreeItem* child); + SceneTreeItem* child(int row) const; + int childCount() const; + int row() const; + SceneTreeItem* parentItem() const; + + QString name() const { return mName; } + ItemType type() const { return mType; } + void* ogrePtr() const { return mOgrePtr; } + QString typeLabel() const; + +private: + QString mName; + ItemType mType; + void* mOgrePtr; + SceneTreeItem* mParent; + QList mChildren; +}; + +class SceneTreeModel : public QAbstractItemModel +{ + Q_OBJECT + +public: + enum Roles { + NameRole = Qt::UserRole + 1, + TypeRole, + TypeLabelRole, + SelectedRole, + MaterialNameRole, + }; + + explicit SceneTreeModel(QObject* parent = nullptr); + ~SceneTreeModel() override; + + // QAbstractItemModel interface + QModelIndex index(int row, int column, const QModelIndex& parent = QModelIndex()) const override; + QModelIndex parent(const QModelIndex& child) const override; + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + int columnCount(const QModelIndex& parent = QModelIndex()) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + Q_INVOKABLE void selectItem(int row, const QModelIndex& parentIndex, bool multiSelect); + Q_INVOKABLE QModelIndex rootIndex() const; + Q_INVOKABLE bool isSelected(int row, const QModelIndex& parentIndex) const; + Q_INVOKABLE QString materialName(int row, const QModelIndex& parentIndex) const; + Q_INVOKABLE void setMaterial(int row, const QModelIndex& parentIndex, const QString& materialName); + Q_INVOKABLE QStringList availableMaterials() const; + +public slots: + void rebuild(); + void updateSelection(); + +signals: + void selectionUpdated(); + +private: + void buildChildren(Ogre::SceneNode* sceneNode, SceneTreeItem* parentItem); + SceneTreeItem* itemFromIndex(const QModelIndex& index) const; + + SceneTreeItem* mRootItem = nullptr; + QTimer* mRebuildTimer = nullptr; +}; + +#endif // SCENE_TREE_MODEL_H diff --git a/src/SceneTreeModel_test.cpp b/src/SceneTreeModel_test.cpp new file mode 100644 index 000000000..9c08bd0a3 --- /dev/null +++ b/src/SceneTreeModel_test.cpp @@ -0,0 +1,62 @@ +#include +#include "SceneTreeModel.h" +#include "Manager.h" +#include "SelectionSet.h" +#include +#include +#include +#include "TestHelpers.h" + +class SceneTreeModelTests : public ::testing::Test { +protected: + QApplication* app = nullptr; + SceneTreeModel* model = nullptr; + + void SetUp() override { + Manager::kill(); + QThread::msleep(50); + + app = qobject_cast(QCoreApplication::instance()); + ASSERT_NE(app, nullptr); + + if (!tryInitOgre()) { + GTEST_SKIP() << "Skipping: Ogre initialization failed"; + } + + model = new SceneTreeModel(); + } + + void TearDown() override { + delete model; + model = nullptr; + if (app) app->processEvents(); + } +}; + +TEST_F(SceneTreeModelTests, InitialState) { + // Root should have at least 0 rows (empty scene) + EXPECT_GE(model->rowCount(), 0); + EXPECT_EQ(model->columnCount(), 1); +} + +TEST_F(SceneTreeModelTests, RoleNames) { + auto roles = model->roleNames(); + EXPECT_TRUE(roles.contains(SceneTreeModel::NameRole)); + EXPECT_TRUE(roles.contains(SceneTreeModel::TypeRole)); + EXPECT_TRUE(roles.contains(SceneTreeModel::TypeLabelRole)); + EXPECT_TRUE(roles.contains(SceneTreeModel::SelectedRole)); + EXPECT_TRUE(roles.contains(SceneTreeModel::MaterialNameRole)); +} + +TEST_F(SceneTreeModelTests, Rebuild) { + int initialRows = model->rowCount(); + model->rebuild(); + // After rebuild, row count should be consistent + EXPECT_EQ(model->rowCount(), initialRows); +} + +TEST_F(SceneTreeModelTests, InvalidIndex) { + QModelIndex invalid; + EXPECT_EQ(model->data(invalid), QVariant()); + EXPECT_EQ(model->parent(invalid), QModelIndex()); +} diff --git a/src/SpaceCamera.cpp b/src/SpaceCamera.cpp index b40eee2cc..f23622a8c 100755 --- a/src/SpaceCamera.cpp +++ b/src/SpaceCamera.cpp @@ -32,6 +32,7 @@ THE SOFTWARE. #include "SpaceCamera.h" #include "Manager.h" +#include "SelectionSet.h" #include "OgreWidget.h" #include @@ -97,13 +98,9 @@ void SpaceCamera::setKeyMapping() mKeyRotationMapping[Qt::Key_Right] = Ogre::Vector2 ( getCameraSpeed() * 0.1f, 0.0 ); mKeyRotationMapping[Qt::Key_Left] = Ogre::Vector2 (-getCameraSpeed() * 0.1f, 0.0 ); - mKeyTranslationMapping[Qt::Key_W] = Ogre::Vector2 ( 0.0, getCameraSpeed() ); - mKeyTranslationMapping[Qt::Key_S] = Ogre::Vector2 ( 0.0, -getCameraSpeed() ); - mKeyTranslationMapping[Qt::Key_A] = Ogre::Vector2 ( getCameraSpeed() , 0.0 ); - mKeyTranslationMapping[Qt::Key_D] = Ogre::Vector2 (-getCameraSpeed() , 0.0 ); - - mKeyRollingMapping[Qt::Key_Q] = getCameraSpeed(); - mKeyRollingMapping[Qt::Key_E] = -getCameraSpeed(); + // Note: WASD/QE removed to avoid conflict with Unity-style transform shortcuts + // (Q=Select, W=Translate, E=Rotate, R=Scale). + // Camera movement via mouse (middle=orbit, right=pan, scroll=zoom) and arrow keys. } ////////////////////////////////////////////////////////////////////////////////// @@ -404,6 +401,64 @@ void SpaceCamera::roll(const Ogre::Real& delta) mTarget->roll( rotRoll, Ogre::Node::TS_LOCAL ); } +void SpaceCamera::frameSelection() +{ + SelectionSet* sel = SelectionSet::getSingleton(); + if (!sel || sel->isEmpty()) + return; + + // Compute aggregate AABB from selection + Ogre::AxisAlignedBox aabb; + aabb.setNull(); + + if (sel->hasNodes()) + { + for (Ogre::SceneNode* node : sel->getNodesSelectionList()) + { + // Get world AABB of all attached objects + auto it = node->getAttachedObjectIterator(); + while (it.hasMoreElements()) + { + Ogre::MovableObject* obj = it.getNext(); + aabb.merge(obj->getWorldBoundingBox(true)); + } + // If node has no attached objects, at least include its position + if (!node->numAttachedObjects()) + aabb.merge(node->_getDerivedPosition()); + } + } + else if (sel->hasEntities()) + { + for (Ogre::Entity* ent : sel->getEntitiesSelectionList()) + aabb.merge(ent->getWorldBoundingBox(true)); + } + + if (aabb.isNull() || aabb.isInfinite()) + return; + + Ogre::Vector3 center = aabb.getCenter(); + Ogre::Real radius = (aabb.getMaximum() - aabb.getMinimum()).length() * 0.5f; + + // Ensure minimum radius for point-like selections + if (radius < 0.1f) + radius = 1.0f; + + // Move camera target to selection center + mTarget->setPosition(center); + + // Compute distance to fit bounding sphere in view + Ogre::Radian fovY = mCamera->getFOVy(); + Ogre::Real aspectRatio = mCamera->getAspectRatio(); + Ogre::Radian fovX = Ogre::Radian(2.0f * std::atan(std::tan(fovY.valueRadians() * 0.5f) * aspectRatio)); + Ogre::Radian fov = std::min(fovX, fovY); + Ogre::Real distance = radius / std::sin(fov.valueRadians() * 0.5f); + + // Add a bit of padding + distance *= 1.2f; + + mCameraNode->setPosition(0, 0, -distance); +} + diff --git a/src/SpaceCamera.h b/src/SpaceCamera.h index e5c53dd0d..301fb9c20 100755 --- a/src/SpaceCamera.h +++ b/src/SpaceCamera.h @@ -56,6 +56,9 @@ class SpaceCamera : Ogre::FrameListener void animateToOrientation(const Ogre::Quaternion& target, float duration = 0.5f); bool isAnimating() const { return mAnimating; } + // Frame selection: move camera to look at selection center, zoom to fit bounds + void frameSelection(); + // Mutators void setCameraSpeed(const Ogre::Real& newSpeed); void setCameraPosition(const Ogre::Vector3 &pos); diff --git a/src/ThemeManager.cpp b/src/ThemeManager.cpp new file mode 100644 index 000000000..e338864a7 --- /dev/null +++ b/src/ThemeManager.cpp @@ -0,0 +1,105 @@ +#include "ThemeManager.h" +#include +#include + +ThemeManager* ThemeManager::m_pSingleton = nullptr; + +ThemeManager* ThemeManager::instance() +{ + if (!m_pSingleton) + m_pSingleton = new ThemeManager(); + return m_pSingleton; +} + +ThemeManager* ThemeManager::qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine) +{ + Q_UNUSED(engine); + Q_UNUSED(scriptEngine); + auto* inst = instance(); + QQmlEngine::setObjectOwnership(inst, QQmlEngine::CppOwnership); + return inst; +} + +void ThemeManager::kill() +{ + delete m_pSingleton; + m_pSingleton = nullptr; +} + +ThemeManager::ThemeManager() : QObject(nullptr) {} + +void ThemeManager::refreshTheme() +{ + emit themeChanged(); +} + +QColor ThemeManager::windowColor() const +{ + return QApplication::palette().color(QPalette::Window); +} + +QColor ThemeManager::panelColor() const +{ + return QApplication::palette().color(QPalette::Window); +} + +QColor ThemeManager::headerColor() const +{ + return QApplication::palette().color(QPalette::Window).darker(110); +} + +QColor ThemeManager::inputColor() const +{ + return QApplication::palette().color(QPalette::Base); +} + +QColor ThemeManager::textColor() const +{ + return QApplication::palette().color(QPalette::WindowText); +} + +QColor ThemeManager::disabledTextColor() const +{ + return QApplication::palette().color(QPalette::Disabled, QPalette::WindowText); +} + +QColor ThemeManager::placeholderTextColor() const +{ + return QApplication::palette().color(QPalette::PlaceholderText); +} + +QColor ThemeManager::highlightColor() const +{ + return QApplication::palette().color(QPalette::Highlight); +} + +QColor ThemeManager::highlightedTextColor() const +{ + return QApplication::palette().color(QPalette::HighlightedText); +} + +QColor ThemeManager::buttonColor() const +{ + return QApplication::palette().color(QPalette::Button); +} + +QColor ThemeManager::buttonTextColor() const +{ + return QApplication::palette().color(QPalette::ButtonText); +} + +QColor ThemeManager::borderColor() const +{ + return QApplication::palette().color(QPalette::Mid); +} + +QColor ThemeManager::accentColor() const +{ + return QApplication::palette().color(QPalette::Highlight); +} + +QString ThemeManager::themeName() const +{ + auto bg = QApplication::palette().color(QPalette::Window); + return bg.lightness() > 128 ? "light" : "dark"; +} diff --git a/src/ThemeManager.h b/src/ThemeManager.h new file mode 100644 index 000000000..260f3e396 --- /dev/null +++ b/src/ThemeManager.h @@ -0,0 +1,71 @@ +#ifndef THEME_MANAGER_H +#define THEME_MANAGER_H + +#include +#include +#include + +class ThemeManager : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + // Panel colors + Q_PROPERTY(QColor windowColor READ windowColor NOTIFY themeChanged) + Q_PROPERTY(QColor panelColor READ panelColor NOTIFY themeChanged) + Q_PROPERTY(QColor headerColor READ headerColor NOTIFY themeChanged) + Q_PROPERTY(QColor inputColor READ inputColor NOTIFY themeChanged) + + // Text colors + Q_PROPERTY(QColor textColor READ textColor NOTIFY themeChanged) + Q_PROPERTY(QColor disabledTextColor READ disabledTextColor NOTIFY themeChanged) + Q_PROPERTY(QColor placeholderTextColor READ placeholderTextColor NOTIFY themeChanged) + + // Interactive colors + Q_PROPERTY(QColor highlightColor READ highlightColor NOTIFY themeChanged) + Q_PROPERTY(QColor highlightedTextColor READ highlightedTextColor NOTIFY themeChanged) + Q_PROPERTY(QColor buttonColor READ buttonColor NOTIFY themeChanged) + Q_PROPERTY(QColor buttonTextColor READ buttonTextColor NOTIFY themeChanged) + + // Border and accent + Q_PROPERTY(QColor borderColor READ borderColor NOTIFY themeChanged) + Q_PROPERTY(QColor accentColor READ accentColor NOTIFY themeChanged) + + // Theme name + Q_PROPERTY(QString themeName READ themeName NOTIFY themeChanged) + +public: + static ThemeManager* instance(); + static ThemeManager* qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine); + static void kill(); + + QColor windowColor() const; + QColor panelColor() const; + QColor headerColor() const; + QColor inputColor() const; + QColor textColor() const; + QColor disabledTextColor() const; + QColor placeholderTextColor() const; + QColor highlightColor() const; + QColor highlightedTextColor() const; + QColor buttonColor() const; + QColor buttonTextColor() const; + QColor borderColor() const; + QColor accentColor() const; + QString themeName() const; + +public slots: + void refreshTheme(); + +signals: + void themeChanged(); + +private: + ThemeManager(); + ~ThemeManager() override = default; + + static ThemeManager* m_pSingleton; +}; + +#endif // THEME_MANAGER_H diff --git a/src/TransformOperator.cpp b/src/TransformOperator.cpp index fb20cf736..890beac32 100755 --- a/src/TransformOperator.cpp +++ b/src/TransformOperator.cpp @@ -5,6 +5,7 @@ #include "TransformOperator.h" #include "RotationGizmo.h" #include "TranslationGizmo.h" +#include "ScaleGizmo.h" #include "SelectionBoxObject.h" #include "SelectionSet.h" #include "OgreWidget.h" @@ -15,6 +16,8 @@ #include "MeshTransform.h" #include "Euler.h" #include "ViewportGrid.h" +#include "UndoManager.h" +#include "commands/TransformCommands.h" #include // TODO create a virtual class GizmoObject & add Rotation & Translation Gizmo to have only one interface @@ -61,6 +64,10 @@ TransformOperator::TransformOperator() : QObject(nullptr) m_pTranslationGizmo->setQueryFlags(GIZMO_QUERY_FLAGS); m_pTranslationGizmo->setVisible(false); + m_pScaleGizmo = new ScaleGizmo(m_pTransformNode); + m_pScaleGizmo->setQueryFlags(GIZMO_QUERY_FLAGS); + m_pScaleGizmo->setVisible(false); + // TODO move this node in the SelectionBoxObject class m_pSelectionBoxNode = pSceneMgr->getRootSceneNode()->createChildSceneNode(SELECTIONBOX_OBJECT_NAME); m_pSelectionBox = new SelectionBoxObject(SELECTIONBOX_OBJECT_NAME); @@ -84,6 +91,8 @@ TransformOperator::~TransformOperator() m_pRotationGizmo = nullptr; delete m_pTranslationGizmo; m_pTranslationGizmo = nullptr; + delete m_pScaleGizmo; + m_pScaleGizmo = nullptr; if (auto manager = Manager::getSingletonPtr()) { @@ -148,6 +157,21 @@ void TransformOperator::onTransformStateChange(const TransformState newState) updateGizmo(); } +void TransformOperator::setTransformSpace(TransformSpace space) +{ + if (mTransformSpace != space) + { + mTransformSpace = space; + updateGizmo(); + emit transformSpaceChanged(mTransformSpace); + } +} + +void TransformOperator::toggleTransformSpace() +{ + setTransformSpace(mTransformSpace == SPACE_WORLD ? SPACE_LOCAL : SPACE_WORLD); +} + void TransformOperator::removeSelected() { SentryReporter::addBreadcrumb("ui.action", "Remove selected objects"); @@ -159,36 +183,61 @@ void TransformOperator::removeSelected() Manager::getSingleton()->destroySceneNode(node); } pCurrentSelection->clearList(); + + // Clear undo stack — destroyed nodes invalidate any stored commands + UndoManager::getSingleton()->clear(); } } -// TODO add scale gizmo void TransformOperator::updateGizmo() { updateGizmoPosition(); if(SelectionSet::getSingleton()->hasNodes()||SelectionSet::getSingleton()->hasEntities()) { + // Determine gizmo orientation based on transform space + Ogre::Quaternion gizmoOrientation; + if (mTransformSpace == SPACE_LOCAL && SelectionSet::getSingleton()->hasNodes() + && SelectionSet::getSingleton()->getNodesCount() == 1) + { + gizmoOrientation = SelectionSet::getSingleton()->getSceneNode(0)->getOrientation(); + } + else + { + gizmoOrientation = Manager::getSingleton()->getSceneMgr()->getRootSceneNode()->getOrientation(); + } + switch (mTransformState) { case TransformOperator::TS_SELECT: m_pRotationGizmo->setVisible(false); m_pTranslationGizmo->setVisible(false); + m_pScaleGizmo->setVisible(false); break; case TransformOperator::TS_TRANSLATE: - m_pTransformNode->setOrientation(Manager::getSingleton()->getSceneMgr()->getRootSceneNode()->getOrientation()); + m_pTransformNode->setOrientation(gizmoOrientation); m_pRotationGizmo->setVisible(false); m_pTranslationGizmo->setVisible(true); + m_pScaleGizmo->setVisible(false); mTrackingEnable = true; break; - case TransformOperator::TS_ROTATE: // TODO change orientation when LOCAL mode is selected - m_pTransformNode->setOrientation(Manager::getSingleton()->getSceneMgr()->getRootSceneNode()->getOrientation()); + case TransformOperator::TS_ROTATE: + m_pTransformNode->setOrientation(gizmoOrientation); m_pRotationGizmo->setVisible(true); m_pTranslationGizmo->setVisible(false); + m_pScaleGizmo->setVisible(false); + mTrackingEnable = true; + break; + case TransformOperator::TS_SCALE: + m_pTransformNode->setOrientation(gizmoOrientation); + m_pRotationGizmo->setVisible(false); + m_pTranslationGizmo->setVisible(false); + m_pScaleGizmo->setVisible(true); mTrackingEnable = true; break; default: m_pRotationGizmo->setVisible(false); m_pTranslationGizmo->setVisible(false); + m_pScaleGizmo->setVisible(false); break; } } @@ -196,6 +245,7 @@ void TransformOperator::updateGizmo() { m_pRotationGizmo->setVisible(false); m_pTranslationGizmo->setVisible(false); + m_pScaleGizmo->setVisible(false); } if(m_pActiveWidget) m_pActiveWidget->setMouseTracking(mTrackingEnable); @@ -203,16 +253,6 @@ void TransformOperator::updateGizmo() void TransformOperator::updateGizmoPosition() { - //TODO add a parent or local transform (get the orientation of object node) -/* - if(m_pSelectedNode) - { - m_pTransformNode->setPosition(m_pSelectedNode->getPosition()); - - if(mTransformState == TS_ROTATE) - m_pTransformNode->setOrientation(m_pSelectedNode->getOrientation()); - } -*/ Ogre::Vector3 currentPosition = Ogre::Vector3::ZERO; Ogre::Vector3 currentOrientation = Ogre::Vector3::ZERO; Ogre::Vector3 currentScale = Ogre::Vector3::UNIT_SCALE; @@ -434,13 +474,34 @@ void TransformOperator::mousePressEvent(QMouseEvent *e) SentryReporter::addBreadcrumb("ui.transform", "Translate selected"); else if(mTransformState == TS_ROTATE) SentryReporter::addBreadcrumb("ui.transform", "Rotate selected"); + else if(mTransformState == TS_SCALE) + SentryReporter::addBreadcrumb("ui.transform", "Scale selected"); + + // Capture undo state for scene nodes + mUndoStartPositions.clear(); + mUndoStartOrientations.clear(); + mUndoStartScales.clear(); + if (SelectionSet::getSingleton()->hasNodes()) + { + for (Ogre::SceneNode* node : SelectionSet::getSingleton()->getNodesSelectionList()) + { + mUndoStartPositions.append(node->getPosition()); + mUndoStartOrientations.append(node->getOrientation()); + mUndoStartScales.append(node->getScale()); + } + } - // Checking the ray intersection with a plane parallele to viewport & on the geometric center of selection + // Checking the ray intersection with a plane parallel to viewport & on the geometric center of selection Ogre::Ray mouseRay = rayFromScreenPoint(e->pos()); std::pair result = mouseRay.intersects(Ogre::Plane(mouseRay.getDirection(), m_pTransformNode->getPosition())); if(result.first) + { mStartPoint = mouseRay.getPoint(result.second); + // For scale: record initial distance from gizmo center to start point + if(mTransformState == TS_SCALE) + mScaleStartDistance = (mStartPoint - m_pTransformNode->getPosition()).length(); + } } } } @@ -492,7 +553,7 @@ void TransformOperator::mouseMoveEvent(QMouseEvent *e) translateSelected(translation); mStartPoint = point; - emit selectedPositionChanged(SelectionSet::getSingleton()->getSelectionCenter()); //TODO connect this signal + emit selectedPositionChanged(SelectionSet::getSingleton()->getSelectionCenter()); updateGizmoPosition(); } @@ -533,7 +594,57 @@ void TransformOperator::mouseMoveEvent(QMouseEvent *e) rotateSelected(rotation); mStartPoint = point; - emit selectedOrientationChanged(SelectionSet::getSingleton()->getSelectionOrientation()); //TODO connect this signal + emit selectedOrientationChanged(SelectionSet::getSingleton()->getSelectionOrientation()); + } + } + } + else if(mTransformState == TS_SCALE && (!SelectionSet::getSingleton()->isEmpty())) + { + if(mStartPoint.isZeroLength()) + { + // Survey gizmo hit for axis highlighting + Ogre::MovableObject* gizmoAxis = performRaySelection(e->pos(), true); + if(gizmoAxis) + mTransformVector = m_pScaleGizmo->highlightAxis(gizmoAxis); + else + { + m_pScaleGizmo->createAxis(); + mTransformVector = Ogre::Vector3::ZERO; + } + } + else + { + // Dragging -> compute scale factor from distance ratio + Ogre::Ray mouseRay = rayFromScreenPoint(e->pos()); + std::pair result = mouseRay.intersects(Ogre::Plane(mouseRay.getDirection(), m_pTransformNode->getPosition())); + + if(result.first) + { + Ogre::Vector3 point = mouseRay.getPoint(result.second); + Ogre::Real currentDistance = (point - m_pTransformNode->getPosition()).length(); + + if(mScaleStartDistance > 0.001f) + { + Ogre::Real ratio = currentDistance / mScaleStartDistance; + Ogre::Vector3 scaleFactor = Ogre::Vector3::UNIT_SCALE; + + if(mTransformVector == Ogre::Vector3::ZERO) + { + // Uniform scale when no axis selected + scaleFactor = Ogre::Vector3(ratio, ratio, ratio); + } + else + { + // Per-axis scale + scaleFactor = Ogre::Vector3::UNIT_SCALE + (mTransformVector * (ratio - 1.0f)); + } + + scaleSelected(scaleFactor); + mScaleStartDistance = currentDistance; + + emit selectedScaleChanged(SelectionSet::getSingleton()->getSelectionScale()); + updateGizmoPosition(); + } } } } @@ -542,7 +653,86 @@ void TransformOperator::mouseMoveEvent(QMouseEvent *e) void TransformOperator::mouseReleaseEvent(QMouseEvent *e) { if((SelectionSet::getSingleton()->hasNodes()||SelectionSet::getSingleton()->hasEntities()) && (e->button() == Qt::LeftButton)) + { + // Push undo command if a transform was performed on scene nodes + if (SelectionSet::getSingleton()->hasNodes() && !mUndoStartPositions.isEmpty()) + { + auto nodes = SelectionSet::getSingleton()->getNodesSelectionList(); + bool changed = false; + + if (mTransformState == TS_TRANSLATE) + { + // Compute total delta from start positions + Ogre::Vector3 totalDelta = Ogre::Vector3::ZERO; + for (int i = 0; i < nodes.size() && i < mUndoStartPositions.size(); ++i) + { + Ogre::Vector3 delta = nodes[i]->getPosition() - mUndoStartPositions[i]; + if (!delta.isZeroLength()) + { + totalDelta = delta; + changed = true; + } + } + if (changed) + { + // Revert to start, then push command (which will redo) + for (int i = 0; i < nodes.size() && i < mUndoStartPositions.size(); ++i) + nodes[i]->setPosition(mUndoStartPositions[i]); + UndoManager::getSingleton()->push(new TranslateCommand(nodes, totalDelta)); + } + } + else if (mTransformState == TS_ROTATE) + { + for (int i = 0; i < nodes.size() && i < mUndoStartOrientations.size(); ++i) + { + if (nodes[i]->getOrientation() != mUndoStartOrientations[i]) + { + changed = true; + break; + } + } + if (changed) + { + Ogre::Quaternion totalRotation = Ogre::Quaternion::IDENTITY; + if (!mUndoStartOrientations.isEmpty() && !nodes.isEmpty()) + totalRotation = nodes[0]->getOrientation() * mUndoStartOrientations[0].Inverse(); + + // Revert to start, then push command + for (int i = 0; i < nodes.size() && i < mUndoStartPositions.size(); ++i) + { + nodes[i]->setPosition(mUndoStartPositions[i]); + nodes[i]->setOrientation(mUndoStartOrientations[i]); + } + UndoManager::getSingleton()->push( + new RotateCommand(nodes, totalRotation, m_pTransformNode->getPosition())); + } + } + else if (mTransformState == TS_SCALE) + { + Ogre::Vector3 totalScale = Ogre::Vector3::UNIT_SCALE; + for (int i = 0; i < nodes.size() && i < mUndoStartScales.size(); ++i) + { + if (nodes[i]->getScale() != mUndoStartScales[i]) + { + totalScale = nodes[i]->getScale() / mUndoStartScales[i]; + changed = true; + } + } + if (changed) + { + for (int i = 0; i < nodes.size() && i < mUndoStartScales.size(); ++i) + nodes[i]->setScale(mUndoStartScales[i]); + UndoManager::getSingleton()->push(new ScaleCommand(nodes, totalScale)); + } + } + } + + mUndoStartPositions.clear(); + mUndoStartOrientations.clear(); + mUndoStartScales.clear(); mStartPoint = Ogre::Vector3::ZERO; + mScaleStartDistance = 0.0f; + } if(m_pSelectionBox->isVisible()) { @@ -595,15 +785,12 @@ void TransformOperator::translateSelected(const Ogre::Vector3& translation) } } -// TODO add a GUI scale by Vector length between mStartPoint & current pos on ground plane -// to do this use scaleSelected instead of setSelectedScale void TransformOperator::setSelectedScale(const Ogre::Vector3& newScale) { if(SelectionSet::getSingleton()->hasNodes()) { Ogre::Vector3 scaleFactor = newScale / SelectionSet::getSingleton()->getSelectionScale(); scaleSelected(scaleFactor); - //TODO scaling in selection CS } else if(SelectionSet::getSingleton()->hasEntities()) { @@ -637,7 +824,7 @@ void TransformOperator::scaleSelected(const Ogre::Vector3& scaleFactor) void TransformOperator::setSelectedOrientation(const Ogre::Vector3& newOrientation) { if(SelectionSet::getSingleton()->hasNodes()) - { + { Ogre::Vector3 rotation = newOrientation - SelectionSet::getSingleton()->getSelectionOrientation(); Ogre::Euler eulerAngle(Ogre::Degree(rotation.y), diff --git a/src/TransformOperator.h b/src/TransformOperator.h index aefdf5e2c..972ced9cd 100755 --- a/src/TransformOperator.h +++ b/src/TransformOperator.h @@ -10,6 +10,7 @@ class OgreWidget; class RotationGizmo; class TranslationGizmo; +class ScaleGizmo; class SelectionBoxObject; class SelectionSet; @@ -34,6 +35,7 @@ class TransformOperator : public QObject, public QtMouseListener TS_SELECT, TS_TRANSLATE, TS_ROTATE, + TS_SCALE, }; enum SelectionMode { @@ -41,8 +43,14 @@ class TransformOperator : public QObject, public QtMouseListener ADD_SELECT = 0x01, DEL_SELECT = 0x02, }; + enum TransformSpace + { + SPACE_WORLD, + SPACE_LOCAL, + }; const Ogre::ColourValue& getSelectionBoxColour() const; + TransformSpace getTransformSpace() const { return mTransformSpace; } // Made public for testing static void swap(int& x, int& y); @@ -60,13 +68,16 @@ class TransformOperator : public QObject, public QtMouseListener signals: void objectsDeleted(); void selectedPositionChanged(const Ogre::Vector3& newPosition); - void selectedScaleChanged(const Ogre::Vector3& newScale); //TODO emit this signal !! + void selectedScaleChanged(const Ogre::Vector3& newScale); void selectedOrientationChanged(const Ogre::Vector3& newOrientation); + void transformSpaceChanged(TransformSpace newSpace); public slots: void onSelectionChanged(); void onTransformStateChange(const TransformState newState); + void setTransformSpace(TransformSpace space); + void toggleTransformSpace(); void setActiveWidget(OgreWidget* ogreWidget); //void setSelectedNode(Ogre::SceneNode* newNode); //TODO it should not exist.... void setSelectedPosition(const Ogre::Vector3& newPosition); @@ -98,6 +109,7 @@ public slots: OgreWidget* m_pActiveWidget = nullptr; RotationGizmo* m_pRotationGizmo = nullptr; TranslationGizmo* m_pTranslationGizmo = nullptr; + ScaleGizmo* m_pScaleGizmo = nullptr; Ogre::SceneNode* m_pTransformNode = nullptr; //Ogre::SceneNode* m_pSelectedNode; Ogre::RaySceneQuery* m_pRayQuery = nullptr; @@ -105,6 +117,13 @@ public slots: Ogre::Vector3 mStartPoint = Ogre::Vector3::ZERO; Ogre::Vector3 mTransformVector = Ogre::Vector3::ZERO; TransformState mTransformState = TS_NONE; + TransformSpace mTransformSpace = SPACE_WORLD; + Ogre::Real mScaleStartDistance = 0.0f; + + // Undo state: captured at mouse press, used to create command at mouse release + QList mUndoStartPositions; + QList mUndoStartOrientations; + QList mUndoStartScales; #ifdef Q_OS_MACOS int mWindowSizeModifier = 2; #else diff --git a/src/UndoManager.cpp b/src/UndoManager.cpp new file mode 100644 index 000000000..e3f86c6fe --- /dev/null +++ b/src/UndoManager.cpp @@ -0,0 +1,44 @@ +#include "UndoManager.h" + +UndoManager* UndoManager::m_pSingleton = nullptr; + +UndoManager* UndoManager::getSingleton() +{ + if (!m_pSingleton) + m_pSingleton = new UndoManager(); + return m_pSingleton; +} + +void UndoManager::kill() +{ + delete m_pSingleton; + m_pSingleton = nullptr; +} + +UndoManager::UndoManager() : QObject(nullptr) +{ + connect(&mUndoStack, &QUndoStack::undoTextChanged, this, &UndoManager::undoTextChanged); + connect(&mUndoStack, &QUndoStack::redoTextChanged, this, &UndoManager::redoTextChanged); +} + +void UndoManager::push(QUndoCommand* cmd) +{ + mUndoStack.push(cmd); +} + +void UndoManager::undo() +{ + if (mUndoStack.canUndo()) + mUndoStack.undo(); +} + +void UndoManager::redo() +{ + if (mUndoStack.canRedo()) + mUndoStack.redo(); +} + +void UndoManager::clear() +{ + mUndoStack.clear(); +} diff --git a/src/UndoManager.h b/src/UndoManager.h new file mode 100644 index 000000000..de435ec54 --- /dev/null +++ b/src/UndoManager.h @@ -0,0 +1,38 @@ +#ifndef UNDO_MANAGER_H +#define UNDO_MANAGER_H + +#include +#include + +class UndoManager : public QObject +{ + Q_OBJECT + +public: + static UndoManager* getSingleton(); + static void kill(); + + QUndoStack* stack() { return &mUndoStack; } + + void push(QUndoCommand* cmd); + bool canUndo() const { return mUndoStack.canUndo(); } + bool canRedo() const { return mUndoStack.canRedo(); } + +public slots: + void undo(); + void redo(); + void clear(); + +signals: + void undoTextChanged(const QString& text); + void redoTextChanged(const QString& text); + +private: + UndoManager(); + ~UndoManager() override = default; + + static UndoManager* m_pSingleton; + QUndoStack mUndoStack; +}; + +#endif // UNDO_MANAGER_H diff --git a/src/UndoManager_test.cpp b/src/UndoManager_test.cpp new file mode 100644 index 000000000..c9ad3cda5 --- /dev/null +++ b/src/UndoManager_test.cpp @@ -0,0 +1,89 @@ +#include +#include "UndoManager.h" +#include + +class UndoManagerTests : public ::testing::Test { +protected: + void SetUp() override { + UndoManager::getSingleton()->clear(); + } + void TearDown() override { + UndoManager::getSingleton()->clear(); + } +}; + +class TestCommand : public QUndoCommand { +public: + TestCommand(int* counter, int delta) + : QUndoCommand("Test"), m_counter(counter), m_delta(delta) {} + void undo() override { *m_counter -= m_delta; } + void redo() override { *m_counter += m_delta; } +private: + int* m_counter; + int m_delta; +}; + +TEST_F(UndoManagerTests, Singleton) { + ASSERT_NE(UndoManager::getSingleton(), nullptr); + EXPECT_EQ(UndoManager::getSingleton(), UndoManager::getSingleton()); +} + +TEST_F(UndoManagerTests, PushAndUndo) { + int counter = 0; + UndoManager::getSingleton()->push(new TestCommand(&counter, 5)); + EXPECT_EQ(counter, 5); + + UndoManager::getSingleton()->undo(); + EXPECT_EQ(counter, 0); +} + +TEST_F(UndoManagerTests, PushAndRedo) { + int counter = 0; + UndoManager::getSingleton()->push(new TestCommand(&counter, 3)); + EXPECT_EQ(counter, 3); + + UndoManager::getSingleton()->undo(); + EXPECT_EQ(counter, 0); + + UndoManager::getSingleton()->redo(); + EXPECT_EQ(counter, 3); +} + +TEST_F(UndoManagerTests, CanUndoRedo) { + EXPECT_FALSE(UndoManager::getSingleton()->canUndo()); + EXPECT_FALSE(UndoManager::getSingleton()->canRedo()); + + int counter = 0; + UndoManager::getSingleton()->push(new TestCommand(&counter, 1)); + EXPECT_TRUE(UndoManager::getSingleton()->canUndo()); + EXPECT_FALSE(UndoManager::getSingleton()->canRedo()); + + UndoManager::getSingleton()->undo(); + EXPECT_FALSE(UndoManager::getSingleton()->canUndo()); + EXPECT_TRUE(UndoManager::getSingleton()->canRedo()); +} + +TEST_F(UndoManagerTests, Clear) { + int counter = 0; + UndoManager::getSingleton()->push(new TestCommand(&counter, 1)); + UndoManager::getSingleton()->clear(); + EXPECT_FALSE(UndoManager::getSingleton()->canUndo()); + EXPECT_FALSE(UndoManager::getSingleton()->canRedo()); +} + +TEST_F(UndoManagerTests, MultipleCommands) { + int counter = 0; + UndoManager::getSingleton()->push(new TestCommand(&counter, 1)); + UndoManager::getSingleton()->push(new TestCommand(&counter, 2)); + UndoManager::getSingleton()->push(new TestCommand(&counter, 3)); + EXPECT_EQ(counter, 6); + + UndoManager::getSingleton()->undo(); + EXPECT_EQ(counter, 3); + + UndoManager::getSingleton()->undo(); + EXPECT_EQ(counter, 1); + + UndoManager::getSingleton()->redo(); + EXPECT_EQ(counter, 3); +} diff --git a/src/commands/TransformCommands.cpp b/src/commands/TransformCommands.cpp new file mode 100644 index 000000000..a7319f343 --- /dev/null +++ b/src/commands/TransformCommands.cpp @@ -0,0 +1,146 @@ +#include "TransformCommands.h" +#include "../Manager.h" +#include "../SelectionSet.h" +#include + +// Check if a scene node pointer is still valid (not destroyed) +static bool isNodeValid(Ogre::SceneNode* node) +{ + if (!node) return false; + auto* mgr = Manager::getSingletonPtr(); + if (!mgr) return false; + for (auto* n : mgr->getSceneNodes()) + if (n == node) return true; + return false; +} + +// ---- TranslateCommand ---- + +TranslateCommand::TranslateCommand(const QList& nodes, + const Ogre::Vector3& delta, + QUndoCommand* parent) + : QUndoCommand("Translate", parent), mNodes(nodes), mDelta(delta) +{ +} + +void TranslateCommand::undo() +{ + for (Ogre::SceneNode* node : mNodes) + if (isNodeValid(node)) node->translate(-mDelta); +} + +void TranslateCommand::redo() +{ + for (Ogre::SceneNode* node : mNodes) + if (isNodeValid(node)) node->translate(mDelta); +} + +// ---- RotateCommand ---- + +RotateCommand::RotateCommand(const QList& nodes, + const Ogre::Quaternion& rotation, + const Ogre::Vector3& pivot, + QUndoCommand* parent) + : QUndoCommand("Rotate", parent), mNodes(nodes), mRotation(rotation), mPivot(pivot) +{ + for (Ogre::SceneNode* node : mNodes) + { + mOriginalPositions.append(node->getPosition()); + mOriginalOrientations.append(node->getOrientation()); + } +} + +void RotateCommand::undo() +{ + for (int i = 0; i < mNodes.size(); ++i) + if (isNodeValid(mNodes[i])) + { + mNodes[i]->setPosition(mOriginalPositions[i]); + mNodes[i]->setOrientation(mOriginalOrientations[i]); + } +} + +void RotateCommand::redo() +{ + for (Ogre::SceneNode* node : mNodes) + if (isNodeValid(node)) + { + Ogre::Vector3 offset = node->_getDerivedPosition() - mPivot; + node->setPosition(mPivot); + node->rotate(mRotation, Ogre::Node::TS_WORLD); + node->setPosition(node->getPosition() + mRotation * offset); + } +} + +// ---- ScaleCommand ---- + +ScaleCommand::ScaleCommand(const QList& nodes, + const Ogre::Vector3& scaleFactor, + QUndoCommand* parent) + : QUndoCommand("Scale", parent), mNodes(nodes), mScaleFactor(scaleFactor) +{ +} + +void ScaleCommand::undo() +{ + Ogre::Vector3 inverse(1.0f / mScaleFactor.x, + 1.0f / mScaleFactor.y, + 1.0f / mScaleFactor.z); + for (Ogre::SceneNode* node : mNodes) + if (isNodeValid(node)) node->scale(inverse); +} + +void ScaleCommand::redo() +{ + for (Ogre::SceneNode* node : mNodes) + if (isNodeValid(node)) node->scale(mScaleFactor); +} + +// ---- DeleteCommand ---- + +DeleteCommand::DeleteCommand(const QList& nodes, + QUndoCommand* parent) + : QUndoCommand("Delete", parent), mFirstRedo(true) +{ + for (Ogre::SceneNode* node : nodes) + { + NodeSnapshot snap; + snap.node = node; + snap.position = node->getPosition(); + snap.orientation = node->getOrientation(); + snap.scale = node->getScale(); + snap.wasVisible = true; + mSnapshots.append(snap); + } +} + +void DeleteCommand::undo() +{ + // Re-show the hidden nodes + for (auto& snap : mSnapshots) + { + if (snap.node) + { + snap.node->setVisible(true, true); + snap.node->setPosition(snap.position); + snap.node->setOrientation(snap.orientation); + snap.node->setScale(snap.scale); + } + } +} + +void DeleteCommand::redo() +{ + if (mFirstRedo) + { + // First redo is the initial deletion — handled by caller + mFirstRedo = false; + return; + } + // Hide nodes instead of destroying (allows undo) + for (auto& snap : mSnapshots) + { + if (snap.node) + snap.node->setVisible(false, true); + } +} diff --git a/src/commands/TransformCommands.h b/src/commands/TransformCommands.h new file mode 100644 index 000000000..2c45515d1 --- /dev/null +++ b/src/commands/TransformCommands.h @@ -0,0 +1,89 @@ +#ifndef TRANSFORM_COMMANDS_H +#define TRANSFORM_COMMANDS_H + +#include +#include +#include +#include + +namespace Ogre { + class SceneNode; + class Entity; +} + +// Translate scene nodes by a delta +class TranslateCommand : public QUndoCommand +{ +public: + TranslateCommand(const QList& nodes, + const Ogre::Vector3& delta, + QUndoCommand* parent = nullptr); + + void undo() override; + void redo() override; + +private: + QList mNodes; + Ogre::Vector3 mDelta; +}; + +// Rotate scene nodes by a quaternion around a pivot +class RotateCommand : public QUndoCommand +{ +public: + RotateCommand(const QList& nodes, + const Ogre::Quaternion& rotation, + const Ogre::Vector3& pivot, + QUndoCommand* parent = nullptr); + + void undo() override; + void redo() override; + +private: + QList mNodes; + Ogre::Quaternion mRotation; + Ogre::Vector3 mPivot; + // Store per-node positions before rotation for undo + QList mOriginalPositions; + QList mOriginalOrientations; +}; + +// Scale scene nodes by a factor +class ScaleCommand : public QUndoCommand +{ +public: + ScaleCommand(const QList& nodes, + const Ogre::Vector3& scaleFactor, + QUndoCommand* parent = nullptr); + + void undo() override; + void redo() override; + +private: + QList mNodes; + Ogre::Vector3 mScaleFactor; +}; + +// Delete scene nodes (stores enough info to recreate on undo) +class DeleteCommand : public QUndoCommand +{ +public: + DeleteCommand(const QList& nodes, + QUndoCommand* parent = nullptr); + + void undo() override; + void redo() override; + +private: + struct NodeSnapshot { + Ogre::SceneNode* node = nullptr; + Ogre::Vector3 position; + Ogre::Quaternion orientation; + Ogre::Vector3 scale; + bool wasVisible = true; + }; + QList mSnapshots; + bool mFirstRedo = true; +}; + +#endif // TRANSFORM_COMMANDS_H diff --git a/src/main.cpp b/src/main.cpp index 964079549..35a3b0a8c 100755 --- a/src/main.cpp +++ b/src/main.cpp @@ -20,6 +20,9 @@ #include "SDManager.h" #endif #include "ModelDownloader.h" +#include "PropertiesPanelController.h" +#include "AnimationTimelineController.h" +#include "ThemeManager.h" #include "MCPServer.h" #include "SentryReporter.h" #include "CLIPipeline.h" @@ -208,6 +211,24 @@ int main(int argc, char *argv[]) // Register QMLMaterialHighlighter for QML use qmlRegisterType("MaterialEditorQML", 1, 0, "MaterialHighlighter"); + // Register PropertiesPanelController singleton for QML + qmlRegisterSingletonType("PropertiesPanel", 1, 0, "PropertiesPanelController", + [](QQmlEngine *engine, QJSEngine *scriptEngine) -> QObject* { + return PropertiesPanelController::qmlInstance(engine, scriptEngine); + }); + + // Register AnimationTimelineController singleton for QML + qmlRegisterSingletonType("AnimationTimeline", 1, 0, "AnimationTimelineController", + [](QQmlEngine *engine, QJSEngine *scriptEngine) -> QObject* { + return AnimationTimelineController::qmlInstance(engine, scriptEngine); + }); + + // Register ThemeManager singleton for QML + qmlRegisterSingletonType("ThemeManager", 1, 0, "ThemeManager", + [](QQmlEngine *engine, QJSEngine *scriptEngine) -> QObject* { + return ThemeManager::qmlInstance(engine, scriptEngine); + }); + auto startupTxn = SentryReporter::startTransaction("app.startup", "app.load"); auto startupTxnClose = qScopeGuard([&] { SentryReporter::finishTransaction(startupTxn); }); MainWindow w; diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 5942b51f8..471c83eaf 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1,4 +1,7 @@ #include +#ifndef Q_OS_WIN +#include +#endif #include #include #include @@ -32,8 +35,7 @@ #include "MeshImporterExporter.h" #include "EditorViewport.h" #include "ViewportGrid.h" -#include "TransformWidget.h" -#include "MaterialWidget.h" +// MaterialWidget removed — replaced by Inspector panel #include "AnimationWidget.h" #include "AnimationMerger.h" #include "SelectionSet.h" @@ -44,10 +46,16 @@ #include "MCPServer.h" #include "NormalVisualizer.h" #include "MeshInfoOverlay.h" +#include "SpaceCamera.h" #include "ViewCube/ViewCubeController.h" #include "LLMManager.h" #include "QMLMaterialHighlighter.h" #include "ModelDownloader.h" +#include "UndoManager.h" +#include "PropertiesPanelController.h" +#include "AnimationTimelineController.h" +#include +#include MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent), ui(new Ui::MainWindow), @@ -61,6 +69,10 @@ MainWindow::MainWindow(QWidget *parent) : setDockOptions(dockOptions() & (~QMainWindow::AllowTabbedDocks)); setCentralWidget(nullptr); // Explicitly define that there is no central widget so dockable widget will take the place + // Right dock (Inspector) takes full height — bottom dock stops at the right dock boundary + setCorner(Qt::BottomRightCorner, Qt::RightDockWidgetArea); + setCorner(Qt::TopRightCorner, Qt::RightDockWidgetArea); + Manager* manager = Manager::getSingleton(this); // init the Ogre Root/RenderSystem/SceneManager createEditorViewport(/*TODO add the type of view (perspective, left,....*/); @@ -201,21 +213,13 @@ MainWindow::~MainWindow() mDockWidgetList.clear(); delete ui; - if(m_pTransformWidget) - { - delete m_pTransformWidget; - m_pTransformWidget = nullptr; - } + // m_pTransformWidget removed — replaced by QML Inspector panel if(m_pPrimitivesWidget) { delete m_pPrimitivesWidget; m_pPrimitivesWidget = nullptr; } - if(m_pMaterialWidget) - { - delete m_pMaterialWidget; - m_pMaterialWidget = nullptr; - } + // MaterialWidget removed — replaced by Inspector panel // CRITICAL: Destroy Manager AFTER all widgets that depend on it are destroyed // This ensures that OGRE resources are cleaned up in the correct order @@ -251,23 +255,67 @@ void MainWindow::initToolBar() mUriList.append(uris); - // Transform Property tab - m_pTransformWidget = new TransformWidget(this->ui->tabWidget); - ui->tabWidget->addTab(m_pTransformWidget, tr("Transform")); setTransformState(TransformOperator::TS_SELECT); connect(ui->actionSelect_Object, &QAction::triggered, this, [this]{setTransformState(TransformOperator::TS_SELECT);}); connect(ui->actionTranslate_Object, &QAction::triggered, this, [this]{setTransformState(TransformOperator::TS_TRANSLATE);}); connect(ui->actionRotate_Object, &QAction::triggered, this, [this]{setTransformState(TransformOperator::TS_ROTATE);}); + connect(ui->actionScale_Object, &QAction::triggered, this, [this]{setTransformState(TransformOperator::TS_SCALE);}); connect(ui->actionRemove_Object, SIGNAL(triggered()), TransformOperator::getSingleton(), SLOT(removeSelected())); + connect(ui->actionToggle_Transform_Space, &QAction::toggled, this, [this](bool checked){ + TransformOperator::getSingleton()->setTransformSpace( + checked ? TransformOperator::SPACE_LOCAL : TransformOperator::SPACE_WORLD); + }); + connect(TransformOperator::getSingleton(), &TransformOperator::transformSpaceChanged, this, [this](TransformOperator::TransformSpace space){ + ui->actionToggle_Transform_Space->setChecked(space == TransformOperator::SPACE_LOCAL); + }); - // Material tab - m_pMaterialWidget = new MaterialWidget(this->ui->tabWidget); - ui->tabWidget->addTab(m_pMaterialWidget, tr("Material")); + // Undo/Redo + connect(ui->actionUndo, &QAction::triggered, UndoManager::getSingleton(), &UndoManager::undo); + connect(ui->actionRedo, &QAction::triggered, UndoManager::getSingleton(), &UndoManager::redo); + + // Refresh gizmo position after undo/redo (deferred to avoid re-entrant scene access) + connect(UndoManager::getSingleton()->stack(), &QUndoStack::indexChanged, this, [](int) { + QTimer::singleShot(0, []() { + // Re-trigger selection to update gizmo position and Inspector values + auto* sel = SelectionSet::getSingleton(); + if (!sel->isEmpty()) + emit sel->selectionChanged(); + }); + }); + + // QML Properties Panel (replaces old Transform tab with modern collapsible inspector) + { + m_propertiesPanel = new QQuickWidget(); + m_propertiesPanel->setResizeMode(QQuickWidget::SizeRootObjectToView); - // Create Primitive Object Menu - // & PrimitivesWidget - m_pPrimitivesWidget = new PrimitivesWidget(this->ui->tabWidget); - ui->tabWidget->addTab(m_pPrimitivesWidget, tr("Edit")); + // Register PropertiesPanelController in this widget's engine + qmlRegisterSingletonType("PropertiesPanel", 1, 0, "PropertiesPanelController", + [](QQmlEngine* engine, QJSEngine*) -> QObject* { + return PropertiesPanelController::qmlInstance(engine, nullptr); + }); + + m_propertiesPanel->setSource(QUrl("qrc:/PropertiesPanel/PropertiesPanel.qml")); + + // Replace the tab widget content with the Inspector panel directly + auto* dockContents = ui->meshEditorWidget->widget(); + auto* layout = dockContents->layout(); + // Remove old tab widget from layout + if (layout) { + QLayoutItem* item; + while ((item = layout->takeAt(0)) != nullptr) { + if (item->widget()) + item->widget()->hide(); + delete item; + } + layout->addWidget(m_propertiesPanel); + } + } + + // Animation Control dock is created below and auto-shown when animated entity is selected + + // PrimitivesWidget (hidden — used by toolbar create menu and Inspector primitive editing) + m_pPrimitivesWidget = new PrimitivesWidget(this); + m_pPrimitivesWidget->hide(); auto addPrimitiveButton = new QToolButton(ui->objectsToolbar); addPrimitiveButton->setIcon(QIcon(":/icones/cube.png")); @@ -316,30 +364,43 @@ void MainWindow::initToolBar() connect(pAddRoundedBox, SIGNAL(triggered()),m_pPrimitivesWidget,SLOT(createRoundedBox())); connect(pAddSpring, SIGNAL(triggered()),m_pPrimitivesWidget,SLOT(createSpring())); - // Animation tab - auto pAnimationWidget = new AnimationWidget(this->ui->tabWidget); - ui->tabWidget->addTab(pAnimationWidget, tr("Animation")); + // AnimationWidget (hidden — used by Inspector for skeleton/weight toggles) + auto pAnimationWidget = new AnimationWidget(this); + pAnimationWidget->hide(); - // Add Animation Control Widget to the bottom of the main window + // Animation Control Widget — bottom dock, auto-shown for animated entities auto pAnimationControlWidget = new AnimationControlWidget(this); + addDockWidget(Qt::BottomDockWidgetArea, pAnimationControlWidget); + // Constrain height so it doesn't eat viewport space + resizeDocks({pAnimationControlWidget}, {180}, Qt::Vertical); pAnimationControlWidget->setVisible(false); connect(pAnimationWidget,SIGNAL(changeAnimationName(const std::string&)),pAnimationControlWidget,SLOT(updateAnimationTree())); - //connect(m_pTransformWidget, SIGNAL(selectionChanged(QString)), pAnimationWidget, SLOT(updateAnimationTable())); connect(pAnimationWidget,SIGNAL(changeAnimationState(bool)),this,SLOT(setPlaying(bool))); - connect(ui->tabWidget,&QTabWidget::currentChanged,this,[=](int index){ - if(index==3){ - // Add Animation Control Widget to the bottom of the main window + + // Give PropertiesPanelController access to AnimationWidget for skeleton/weight toggles + PropertiesPanelController::instance()->setAnimationWidget(pAnimationWidget); + + // Connect Inspector's playing state to MainWindow animation playback + connect(PropertiesPanelController::instance(), &PropertiesPanelController::playingChanged, this, [this]() { + setPlaying(PropertiesPanelController::instance()->isPlaying()); + }); + + // Toggle Animation Control visibility from menu + connect(ui->actionAnimation_Control, &QAction::toggled, this, [pAnimationControlWidget, this](bool checked) { + if (checked && PropertiesPanelController::instance()->hasAnimations()) pAnimationControlWidget->setVisible(true); - addDockWidget(Qt::BottomDockWidgetArea, pAnimationControlWidget); - } else { - // Remove Animation Control Widget from the bottom of the main window - auto dockWidget = findChild("AnimationControlWidget"); - if (dockWidget) { - pAnimationControlWidget->setVisible(false); - removeDockWidget(dockWidget); - } - } + else if (!checked) + pAnimationControlWidget->setVisible(false); + }); + + // Auto-show/hide Animation Control based on selection (respects menu toggle) + connect(SelectionSet::getSingleton(), &SelectionSet::selectionChanged, this, [pAnimationControlWidget, this]() { + QTimer::singleShot(0, pAnimationControlWidget, [pAnimationControlWidget, this]() { + bool hasAnims = PropertiesPanelController::instance()->hasAnimations(); + bool toggled = ui->actionAnimation_Control->isChecked(); + pAnimationControlWidget->setVisible(hasAnims && toggled); + }); }); // Merge Animations button — enable/disable based on selection @@ -495,11 +556,23 @@ bool MainWindow::frameEnded(const Ogre::FrameEvent &evt) m_viewCubeController->updateOrientation(); //Update the status bar - QString statusMessage = "Status "; - if(SelectionSet::getSingleton()->hasNodes()) - statusMessage += "Working with Nodes - to change the mesh position, select only the mesh"; - else if(SelectionSet::getSingleton()->hasEntities()) - statusMessage += "Working with Mesh"; + QString statusMessage; + + // Transform mode + auto* tOp = TransformOperator::getSingleton(); + switch (tOp->getTransformSpace()) { + case TransformOperator::SPACE_WORLD: statusMessage += "World | "; break; + case TransformOperator::SPACE_LOCAL: statusMessage += "Local | "; break; + } + + // Selection info + auto* sel = SelectionSet::getSingleton(); + if (sel->hasNodes()) + statusMessage += QString("Nodes: %1").arg(sel->getNodesCount()); + else if (sel->hasEntities()) + statusMessage += QString("Entities: %1").arg(sel->getEntitiesCount()); + else + statusMessage += "No selection"; ui->statusBar->showMessage(statusMessage); @@ -511,20 +584,33 @@ void MainWindow::keyPressEvent(QKeyEvent *event) QtInputManager::getInstance().keyPressEvent(event); switch(event->key()){ - case Qt::Key_R: - setTransformState(TransformOperator::TS_ROTATE); - break; - case Qt::Key_Y: + case Qt::Key_Q: setTransformState(TransformOperator::TS_SELECT); break; - case Qt::Key_T: + case Qt::Key_W: setTransformState(TransformOperator::TS_TRANSLATE); break; + case Qt::Key_E: + setTransformState(TransformOperator::TS_ROTATE); + break; + case Qt::Key_R: + setTransformState(TransformOperator::TS_SCALE); + break; + case Qt::Key_F: + // Frame selection: zoom camera to fit selected objects + if(!mDockWidgetList.isEmpty()) + { + auto* cam = mDockWidgetList.first()->getOgreWidget()->getSpaceCamera(); + if(cam) cam->frameSelection(); + } + break; + case Qt::Key_X: + TransformOperator::getSingleton()->toggleTransformSpace(); + break; case Qt::Key_Delete: TransformOperator::getSingleton()->removeSelected(); break; default: - // We hit a non mapped key ! break; } @@ -551,8 +637,20 @@ void MainWindow::dropEvent(QDropEvent *event) void MainWindow::closeEvent(QCloseEvent *event) { + // Shut down LLM worker to release model resources + LLMManager::instance()->shutdownWorkerThread(); + + // Process pending deletions and let Ogre shut down cleanly QApplication::quit(); QMainWindow::closeEvent(event); + + // Use _exit() to skip static destructors — ggml-metal's global device cleanup + // asserts if any compute pipeline sets remain, but there's no public API to + // release them. CLIPipeline uses the same workaround. See: + // https://github.com/ggml-org/llama.cpp/pull/17869 +#ifndef Q_OS_WIN + _exit(0); +#endif } void MainWindow::dragEnterEvent(QDragEnterEvent *event) @@ -867,6 +965,9 @@ void MainWindow::on_actionObjects_Toolbar_toggled(bool arg1) void MainWindow::on_actionTools_Toolbar_toggled(bool arg1) { ui->toolToolbar->setVisible(arg1); } +void MainWindow::on_actionView_Toolbar_toggled(bool arg1) +{ ui->viewToolbar->setVisible(arg1); } + void MainWindow::on_actionMeshEditor_toggled(bool arg1) { ui->meshEditorWidget->setVisible(arg1); } @@ -892,29 +993,12 @@ void MainWindow::chooseBgColor() void MainWindow::setTransformState(TransformOperator::TransformState newState) { - switch ( newState ) { - case TransformOperator::TS_SELECT: - ui->actionSelect_Object->setChecked(true); - ui->actionTranslate_Object->setChecked(false); - ui->actionRotate_Object->setChecked(false); - break; - case TransformOperator::TS_TRANSLATE: - ui->actionSelect_Object->setChecked(false); - ui->actionTranslate_Object->setChecked(true); - ui->actionRotate_Object->setChecked(false); - break; - case TransformOperator::TS_ROTATE: - ui->actionSelect_Object->setChecked(false); - ui->actionTranslate_Object->setChecked(false); - ui->actionRotate_Object->setChecked(true); - break; - default: - ui->actionSelect_Object->setChecked(false); - ui->actionTranslate_Object->setChecked(false); - ui->actionRotate_Object->setChecked(false); - break; - } - TransformOperator::getSingleton()->onTransformStateChange(static_cast (newState)); + ui->actionSelect_Object->setChecked(newState == TransformOperator::TS_SELECT); + ui->actionTranslate_Object->setChecked(newState == TransformOperator::TS_TRANSLATE); + ui->actionRotate_Object->setChecked(newState == TransformOperator::TS_ROTATE); + ui->actionScale_Object->setChecked(newState == TransformOperator::TS_SCALE); + + TransformOperator::getSingleton()->onTransformStateChange(newState); } void MainWindow::createEditorViewport(/*TODO add the type of view (perspective, left,....*/) diff --git a/src/mainwindow.h b/src/mainwindow.h index a68ce94a4..9ec1c4b92 100755 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -16,14 +16,14 @@ class MCPServer; class NormalVisualizer; class MeshInfoOverlay; class ViewCubeController; +class PropertiesPanelController; +class QQuickWidget; namespace Ui { class MainWindow; } class EditorViewport; -class TransformWidget; class PrimitivesWidget; -class MaterialWidget; namespace Ogre { @@ -57,6 +57,7 @@ private slots: void on_actionObjects_Toolbar_toggled(bool arg1); void on_actionTools_Toolbar_toggled(bool arg1); + void on_actionView_Toolbar_toggled(bool arg1); void on_actionMeshEditor_toggled(bool arg1); void on_actionExport_Selected_triggered(); @@ -101,9 +102,8 @@ public slots: QList mDockWidgetList; QTimer* m_pTimer = nullptr; - TransformWidget* m_pTransformWidget = nullptr; + // TransformWidget removed — replaced by QML Inspector panel PrimitivesWidget* m_pPrimitivesWidget = nullptr; - MaterialWidget* m_pMaterialWidget = nullptr; QStringList mUriList; @@ -131,6 +131,7 @@ public slots: MeshInfoOverlay* m_meshInfoOverlay = nullptr; ViewCubeController* m_viewCubeController = nullptr; MCPServer* m_mcpServer = nullptr; + QQuickWidget* m_propertiesPanel = nullptr; QMenu* m_recentFilesMenu = nullptr; void addToRecentFiles(const QString& filePath); diff --git a/src/qml_resources.qrc b/src/qml_resources.qrc index ec7571cd5..c57804250 100644 --- a/src/qml_resources.qrc +++ b/src/qml_resources.qrc @@ -16,4 +16,13 @@ ../qml/ViewCubeWindow.qml + + ../qml/AnimationTimeline.qml + + + ../qml/PropertiesPanel.qml + ../qml/CollapsibleSection.qml + ../qml/TransformField.qml + ../qml/SceneTreeNode.qml + \ No newline at end of file diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 2653255ed..c2be2b51d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -32,6 +32,7 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/EditorViewport.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/RotationGizmo.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/TranslationGizmo.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/ScaleGizmo.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/TransformOperator.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/TransformWidget.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/PrimitivesWidget.cpp @@ -60,6 +61,14 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/RTShaderHelper.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/QMLMaterialHighlighter.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/ViewCube/ViewCubeController.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/UndoManager.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/TransformCommands.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/PropertiesPanelController.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/SceneTreeModel.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/AnimationTimelineController.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/ThemeManager.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/BatchExporter.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/MaterialPresetLibrary.cpp ) set(TEST_HEADER_FILES @@ -84,6 +93,7 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/EditorViewport.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/RotationGizmo.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/TranslationGizmo.h + ${CMAKE_CURRENT_SOURCE_DIR}/../src/ScaleGizmo.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/TransformOperator.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/TransformWidget.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/PrimitivesWidget.h @@ -111,6 +121,14 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/CLIPipeline.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/RTShaderHelper.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/ViewCube/ViewCubeController.h + ${CMAKE_CURRENT_SOURCE_DIR}/../src/UndoManager.h + ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/TransformCommands.h + ${CMAKE_CURRENT_SOURCE_DIR}/../src/PropertiesPanelController.h + ${CMAKE_CURRENT_SOURCE_DIR}/../src/SceneTreeModel.h + ${CMAKE_CURRENT_SOURCE_DIR}/../src/AnimationTimelineController.h + ${CMAKE_CURRENT_SOURCE_DIR}/../src/ThemeManager.h + ${CMAKE_CURRENT_SOURCE_DIR}/../src/BatchExporter.h + ${CMAKE_CURRENT_SOURCE_DIR}/../src/MaterialPresetLibrary.h ) # Add Ogre-Procedural sources (matching src/CMakeLists.txt) diff --git a/ui_files/animationcontrolwidget.ui b/ui_files/animationcontrolwidget.ui index 57759a2c4..bbb8238a0 100644 --- a/ui_files/animationcontrolwidget.ui +++ b/ui_files/animationcontrolwidget.ui @@ -136,7 +136,7 @@ - + @@ -173,24 +173,7 @@ - 40 - 20 - - - - - - - - - - - - Qt::Horizontal - - - - 40 + 20 20 @@ -233,36 +216,10 @@ - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - @@ -434,34 +391,8 @@ - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - diff --git a/ui_files/mainwindow.ui b/ui_files/mainwindow.ui index 1a4653e97..05a2798fc 100755 --- a/ui_files/mainwindow.ui +++ b/ui_files/mainwindow.ui @@ -6,7 +6,7 @@ 0 0 - 1010 + 1110 660 @@ -59,7 +59,9 @@ + + @@ -101,6 +103,13 @@ + + + Edit + + + + Help @@ -109,6 +118,7 @@ + @@ -142,13 +152,32 @@ false + - + + + + + + + View + + + TopToolBarArea + + + false + + + + + + @@ -290,6 +319,28 @@ Tools + + + true + + + true + + + Animation Control + + + + + true + + + true + + + View Toolbar + + true @@ -313,7 +364,7 @@ Move Object - Move Object + Move Object (W) true @@ -331,7 +382,7 @@ Rotate Object - Rotate Object + Rotate Object (E) @@ -349,7 +400,7 @@ Select Object - Select Object + Select Object (Q) @@ -361,7 +412,7 @@ Remove Object - Remove Object + Remove Object (Del) @@ -456,6 +507,48 @@ Verify Update + + + Undo + + + Ctrl+Z + + + + + Redo + + + Ctrl+Shift+Z + + + + + true + + + + :/icones/scale.png:/icones/scale.png + + + Scale Object + + + Scale Object (R) + + + + + true + + + World/Local + + + Toggle World/Local Transform Space (X) + + false From d3df3eeed403fd6a187b9d55ae99f4bef992c930 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 22 Mar 2026 21:23:22 -0400 Subject: [PATCH 2/7] Address code review and SonarCloud findings, add unit tests Review fixes: - _exit(0) scoped to macOS only (was applying to Linux too) - Frame selection (F key) uses focused viewport, not always first - CollapsibleSection keyboard accessible (Tab/Enter/Space) - Outliner delegates rebuild on model reset (stale QModelIndex fix) - Child selection refreshed immediately after loading - LLM shutdown: unload model on worker thread before quit (use-after-free fix) - DeleteCommand preserves original node visibility SonarCloud fixes: - Removed const_cast in BatchExporter (C++17 string::data()) - Added explicit float casts for Ogre::Real precision loss - Fixed implicit integer precision loss New tests: - TransformOperator: TS_SCALE state, TransformSpace get/set/toggle/signal - ThemeManager: singleton, color validity, theme name, refresh signal Co-Authored-By: Claude Sonnet 4.6 --- qml/CollapsibleSection.qml | 3 ++ qml/PropertiesPanel.qml | 6 +++ qml/SceneTreeNode.qml | 2 + src/AnimationTimelineController.cpp | 2 +- src/BatchExporter.cpp | 8 ++-- src/LLMManager.cpp | 10 ++-- src/PropertiesPanelController.cpp | 18 ++++---- src/ThemeManager_test.cpp | 47 +++++++++++++++++++ src/TransformOperator_test.cpp | 72 +++++++++++++++++++++++++++++ src/commands/TransformCommands.cpp | 5 +- src/mainwindow.cpp | 19 +++++--- 11 files changed, 164 insertions(+), 28 deletions(-) create mode 100644 src/ThemeManager_test.cpp diff --git a/qml/CollapsibleSection.qml b/qml/CollapsibleSection.qml index a8f4e7ff2..1fdb78adc 100644 --- a/qml/CollapsibleSection.qml +++ b/qml/CollapsibleSection.qml @@ -48,7 +48,10 @@ Column { id: headerMouse anchors.fill: parent hoverEnabled: true + activeFocusOnTab: true onClicked: root.expanded = !root.expanded + Keys.onReturnPressed: root.expanded = !root.expanded + Keys.onSpacePressed: root.expanded = !root.expanded } } diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 4a3b53702..580375205 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -59,11 +59,13 @@ Rectangle { property var treeModel: PropertiesPanelController.sceneTreeModel property int nodeCount: treeModel ? treeModel.rowCount() : 0 + property bool delegatesActive: true Repeater { model: outlinerColumn.nodeCount Loader { + active: outlinerColumn.delegatesActive width: outlinerColumn.width source: "qrc:/PropertiesPanel/SceneTreeNode.qml" onLoaded: { @@ -71,6 +73,8 @@ Rectangle { item.treeModel = outlinerColumn.treeModel item.indentLevel = 0 item.width = Qt.binding(function() { return outlinerColumn.width }) + if (item.refreshSelected) + item.refreshSelected() } } } @@ -78,8 +82,10 @@ Rectangle { Connections { target: outlinerColumn.treeModel function onModelReset() { + outlinerColumn.delegatesActive = false outlinerColumn.nodeCount = outlinerColumn.treeModel ? outlinerColumn.treeModel.rowCount() : 0 + Qt.callLater(function() { outlinerColumn.delegatesActive = true }) } } } diff --git a/qml/SceneTreeNode.qml b/qml/SceneTreeNode.qml index edd13271c..f97ea5f1b 100644 --- a/qml/SceneTreeNode.qml +++ b/qml/SceneTreeNode.qml @@ -299,6 +299,8 @@ Column { item.treeModel = treeNode.treeModel item.indentLevel = treeNode.indentLevel + 1 item.width = Qt.binding(function() { return treeNode.width }) + if (item.refreshSelected) + item.refreshSelected() } } } diff --git a/src/AnimationTimelineController.cpp b/src/AnimationTimelineController.cpp index a0337ce23..87b697fd3 100644 --- a/src/AnimationTimelineController.cpp +++ b/src/AnimationTimelineController.cpp @@ -139,7 +139,7 @@ void AnimationTimelineController::setProgress(double p) auto* state = getCurrentAnimState(); if (state) { - state->setTimePosition(p); + state->setTimePosition(static_cast(p)); emit progressChanged(); } } diff --git a/src/BatchExporter.cpp b/src/BatchExporter.cpp index 21486e7ab..861190935 100644 --- a/src/BatchExporter.cpp +++ b/src/BatchExporter.cpp @@ -13,7 +13,7 @@ void BatchExporter::execute() { int success = 0, fail = 0; - for (int i = 0; i < mInputFiles.size(); ++i) + for (int i = 0; i < static_cast(mInputFiles.size()); ++i) { const QString& file = mInputFiles[i]; emit progressChanged(i + 1, mInputFiles.size(), file); @@ -25,11 +25,13 @@ void BatchExporter::execute() // Build argv for CLIPipeline::run() QStringList args = {"qtmesh", "convert", file, "-o", outputPath}; - std::vector argv; std::vector storage; + std::vector argv; for (const auto& arg : args) { storage.push_back(arg.toStdString()); - argv.push_back(const_cast(storage.back().c_str())); + } + for (auto& s : storage) { + argv.push_back(s.data()); // std::string::data() returns char* in C++17 } int result = CLIPipeline::run(static_cast(argv.size()), argv.data()); diff --git a/src/LLMManager.cpp b/src/LLMManager.cpp index 5b5a08b81..77215aa88 100644 --- a/src/LLMManager.cpp +++ b/src/LLMManager.cpp @@ -71,6 +71,9 @@ void LLMManager::shutdownWorkerThread() { if (m_worker) { m_worker->requestStop(); + // Unload model on worker thread before stopping to free Metal resources + if (m_workerThread && m_workerThread->isRunning()) + QMetaObject::invokeMethod(m_worker, &LLMWorker::unloadModel, Qt::BlockingQueuedConnection); } if (m_workerThread) { @@ -78,13 +81,6 @@ void LLMManager::shutdownWorkerThread() m_workerThread->wait(); qDebug() << "LLMManager: Worker thread stopped"; } - - // Unload model to free llama context and Metal resources before ggml-metal's - // static destructor runs at process exit. Worker object is left alive (leaked - // intentionally) because deleteLater crashes after event loop shutdown. - if (m_worker) { - m_worker->unloadModel(); - } } QString LLMManager::getDefaultModelsDirectory() const diff --git a/src/PropertiesPanelController.cpp b/src/PropertiesPanelController.cpp index 5ebaac354..1e2fc7168 100644 --- a/src/PropertiesPanelController.cpp +++ b/src/PropertiesPanelController.cpp @@ -104,55 +104,55 @@ double PropertiesPanelController::scaleZ() const { return mScaleZ; } // Transform mutators - set absolute values void PropertiesPanelController::setPosX(double v) { if (mPosX != v) { - Ogre::Vector3 pos(v, mPosY, mPosZ); + Ogre::Vector3 pos(static_cast(v), static_cast(mPosY), static_cast(mPosZ)); TransformOperator::getSingleton()->setSelectedPosition(pos); } } void PropertiesPanelController::setPosY(double v) { if (mPosY != v) { - Ogre::Vector3 pos(mPosX, v, mPosZ); + Ogre::Vector3 pos(static_cast(mPosX), static_cast(v), static_cast(mPosZ)); TransformOperator::getSingleton()->setSelectedPosition(pos); } } void PropertiesPanelController::setPosZ(double v) { if (mPosZ != v) { - Ogre::Vector3 pos(mPosX, mPosY, v); + Ogre::Vector3 pos(static_cast(mPosX), static_cast(mPosY), static_cast(v)); TransformOperator::getSingleton()->setSelectedPosition(pos); } } void PropertiesPanelController::setRotX(double v) { if (mRotX != v) { - Ogre::Vector3 rot(v, mRotY, mRotZ); + Ogre::Vector3 rot(static_cast(v), static_cast(mRotY), static_cast(mRotZ)); TransformOperator::getSingleton()->setSelectedOrientation(rot); } } void PropertiesPanelController::setRotY(double v) { if (mRotY != v) { - Ogre::Vector3 rot(mRotX, v, mRotZ); + Ogre::Vector3 rot(static_cast(mRotX), static_cast(v), static_cast(mRotZ)); TransformOperator::getSingleton()->setSelectedOrientation(rot); } } void PropertiesPanelController::setRotZ(double v) { if (mRotZ != v) { - Ogre::Vector3 rot(mRotX, mRotY, v); + Ogre::Vector3 rot(static_cast(mRotX), static_cast(mRotY), static_cast(v)); TransformOperator::getSingleton()->setSelectedOrientation(rot); } } void PropertiesPanelController::setScaleX(double v) { if (mScaleX != v) { - Ogre::Vector3 scale(v, mScaleY, mScaleZ); + Ogre::Vector3 scale(static_cast(v), static_cast(mScaleY), static_cast(mScaleZ)); TransformOperator::getSingleton()->setSelectedScale(scale); } } void PropertiesPanelController::setScaleY(double v) { if (mScaleY != v) { - Ogre::Vector3 scale(mScaleX, v, mScaleZ); + Ogre::Vector3 scale(static_cast(mScaleX), static_cast(v), static_cast(mScaleZ)); TransformOperator::getSingleton()->setSelectedScale(scale); } } void PropertiesPanelController::setScaleZ(double v) { if (mScaleZ != v) { - Ogre::Vector3 scale(mScaleX, mScaleY, v); + Ogre::Vector3 scale(static_cast(mScaleX), static_cast(mScaleY), static_cast(v)); TransformOperator::getSingleton()->setSelectedScale(scale); } } diff --git a/src/ThemeManager_test.cpp b/src/ThemeManager_test.cpp new file mode 100644 index 000000000..0ada4cbb9 --- /dev/null +++ b/src/ThemeManager_test.cpp @@ -0,0 +1,47 @@ +#include +#include "ThemeManager.h" +#include +#include +#include + +class ThemeManagerTests : public ::testing::Test { +protected: + QApplication* app = nullptr; + void SetUp() override { + app = qobject_cast(QCoreApplication::instance()); + ASSERT_NE(app, nullptr); + } +}; + +TEST_F(ThemeManagerTests, Singleton) { + ASSERT_NE(ThemeManager::instance(), nullptr); + EXPECT_EQ(ThemeManager::instance(), ThemeManager::instance()); +} + +TEST_F(ThemeManagerTests, ColorsAreValid) { + auto* tm = ThemeManager::instance(); + EXPECT_TRUE(tm->windowColor().isValid()); + EXPECT_TRUE(tm->panelColor().isValid()); + EXPECT_TRUE(tm->headerColor().isValid()); + EXPECT_TRUE(tm->inputColor().isValid()); + EXPECT_TRUE(tm->textColor().isValid()); + EXPECT_TRUE(tm->disabledTextColor().isValid()); + EXPECT_TRUE(tm->highlightColor().isValid()); + EXPECT_TRUE(tm->buttonColor().isValid()); + EXPECT_TRUE(tm->borderColor().isValid()); + EXPECT_TRUE(tm->accentColor().isValid()); +} + +TEST_F(ThemeManagerTests, ThemeNameNotEmpty) { + auto* tm = ThemeManager::instance(); + QString name = tm->themeName(); + EXPECT_FALSE(name.isEmpty()); + EXPECT_TRUE(name == "light" || name == "dark"); +} + +TEST_F(ThemeManagerTests, RefreshThemeEmitsSignal) { + auto* tm = ThemeManager::instance(); + QSignalSpy spy(tm, &ThemeManager::themeChanged); + tm->refreshTheme(); + EXPECT_EQ(spy.count(), 1); +} diff --git a/src/TransformOperator_test.cpp b/src/TransformOperator_test.cpp index 0f7f4b0fd..f45763fd9 100644 --- a/src/TransformOperator_test.cpp +++ b/src/TransformOperator_test.cpp @@ -682,3 +682,75 @@ TEST_F(TransformOperatorTestFixture, MultipleEntityNodesTranslate) { EXPECT_EQ(node1->getPosition(), Ogre::Vector3(-10, -20, -30)); EXPECT_EQ(node2->getPosition(), Ogre::Vector3(90, 80, 70)); } + +// ---- New tests for TS_SCALE state ---- + +TEST_F(TransformOperatorTestFixture, OnTransformStateChange_Scale) { + TransformOperator* instance = TransformOperator::getSingleton(); + EXPECT_NO_THROW(instance->onTransformStateChange(TransformOperator::TS_SCALE)); +} + +TEST_F(TransformOperatorTestFixture, CycleAllTransformStates_IncludingScale) { + TransformOperator* instance = TransformOperator::getSingleton(); + EXPECT_NO_THROW(instance->onTransformStateChange(TransformOperator::TS_SELECT)); + EXPECT_NO_THROW(instance->onTransformStateChange(TransformOperator::TS_TRANSLATE)); + EXPECT_NO_THROW(instance->onTransformStateChange(TransformOperator::TS_ROTATE)); + EXPECT_NO_THROW(instance->onTransformStateChange(TransformOperator::TS_SCALE)); + EXPECT_NO_THROW(instance->onTransformStateChange(TransformOperator::TS_NONE)); +} + +// ---- Tests for TransformSpace ---- + +TEST_F(TransformOperatorTestFixture, DefaultTransformSpaceIsWorld) { + TransformOperator* instance = TransformOperator::getSingleton(); + EXPECT_EQ(instance->getTransformSpace(), TransformOperator::SPACE_WORLD); +} + +TEST_F(TransformOperatorTestFixture, SetTransformSpace) { + TransformOperator* instance = TransformOperator::getSingleton(); + instance->setTransformSpace(TransformOperator::SPACE_LOCAL); + EXPECT_EQ(instance->getTransformSpace(), TransformOperator::SPACE_LOCAL); + + instance->setTransformSpace(TransformOperator::SPACE_WORLD); + EXPECT_EQ(instance->getTransformSpace(), TransformOperator::SPACE_WORLD); +} + +TEST_F(TransformOperatorTestFixture, ToggleTransformSpace) { + TransformOperator* instance = TransformOperator::getSingleton(); + EXPECT_EQ(instance->getTransformSpace(), TransformOperator::SPACE_WORLD); + + instance->toggleTransformSpace(); + EXPECT_EQ(instance->getTransformSpace(), TransformOperator::SPACE_LOCAL); + + instance->toggleTransformSpace(); + EXPECT_EQ(instance->getTransformSpace(), TransformOperator::SPACE_WORLD); +} + +TEST_F(TransformOperatorTestFixture, TransformSpaceChangedSignal) { + TransformOperator* instance = TransformOperator::getSingleton(); + QSignalSpy spy(instance, &TransformOperator::transformSpaceChanged); + + instance->setTransformSpace(TransformOperator::SPACE_LOCAL); + EXPECT_EQ(spy.count(), 1); + + // Setting same value should not emit + instance->setTransformSpace(TransformOperator::SPACE_LOCAL); + EXPECT_EQ(spy.count(), 1); + + instance->toggleTransformSpace(); + EXPECT_EQ(spy.count(), 2); +} + +TEST_F(TransformOperatorTestFixture, ScaleStateWithSelection) { + if (!canLoadMeshFiles()) { GTEST_SKIP() << "Skipping: entity creation not supported"; } + + auto* sceneMgr = Manager::getSingleton()->getSceneMgr(); + auto* node = sceneMgr->getRootSceneNode()->createChildSceneNode(); + auto mesh = createInMemoryTriangleMesh("ScaleTestMesh"); + auto* entity = sceneMgr->createEntity(mesh); + node->attachObject(entity); + SelectionSet::getSingleton()->append(node); + + TransformOperator* instance = TransformOperator::getSingleton(); + EXPECT_NO_THROW(instance->onTransformStateChange(TransformOperator::TS_SCALE)); +} diff --git a/src/commands/TransformCommands.cpp b/src/commands/TransformCommands.cpp index a7319f343..268d4367e 100644 --- a/src/commands/TransformCommands.cpp +++ b/src/commands/TransformCommands.cpp @@ -109,7 +109,8 @@ DeleteCommand::DeleteCommand(const QList& nodes, snap.position = node->getPosition(); snap.orientation = node->getOrientation(); snap.scale = node->getScale(); - snap.wasVisible = true; + snap.wasVisible = (node->numAttachedObjects() == 0) + || node->getAttachedObject(0)->getVisible(); mSnapshots.append(snap); } } @@ -121,7 +122,7 @@ void DeleteCommand::undo() { if (snap.node) { - snap.node->setVisible(true, true); + snap.node->setVisible(snap.wasVisible, true); snap.node->setPosition(snap.position); snap.node->setOrientation(snap.orientation); snap.node->setScale(snap.scale); diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 471c83eaf..5d3c3a2aa 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -597,13 +597,20 @@ void MainWindow::keyPressEvent(QKeyEvent *event) setTransformState(TransformOperator::TS_SCALE); break; case Qt::Key_F: + { // Frame selection: zoom camera to fit selected objects - if(!mDockWidgetList.isEmpty()) - { - auto* cam = mDockWidgetList.first()->getOgreWidget()->getSpaceCamera(); - if(cam) cam->frameSelection(); + SpaceCamera* cam = nullptr; + for (auto* vp : mDockWidgetList) { + if (vp->getOgreWidget()->hasFocus()) { + cam = vp->getOgreWidget()->getSpaceCamera(); + break; + } } - break; + if (!cam && !mDockWidgetList.isEmpty()) + cam = mDockWidgetList.first()->getOgreWidget()->getSpaceCamera(); + if (cam) cam->frameSelection(); + break; + } case Qt::Key_X: TransformOperator::getSingleton()->toggleTransformSpace(); break; @@ -648,7 +655,7 @@ void MainWindow::closeEvent(QCloseEvent *event) // asserts if any compute pipeline sets remain, but there's no public API to // release them. CLIPipeline uses the same workaround. See: // https://github.com/ggml-org/llama.cpp/pull/17869 -#ifndef Q_OS_WIN +#ifdef Q_OS_MACOS _exit(0); #endif } From ce8573ff887e73a4f8021452dae47bab21a68407 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 22 Mar 2026 21:35:03 -0400 Subject: [PATCH 3/7] Fix local transform space: drag along node's local axes The gizmo correctly oriented in LOCAL mode but drag math still used world-space UNIT_X/Y/Z vectors. Now translate and rotate drags convert the world-space delta into the gizmo's local coordinate system, apply the axis mask there, then convert back to world space. Added tests for local space translate and state cycling. Co-Authored-By: Claude Sonnet 4.6 --- src/TransformOperator.cpp | 44 ++++++++++++++++++++++++++++------ src/TransformOperator_test.cpp | 34 ++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/src/TransformOperator.cpp b/src/TransformOperator.cpp index 890beac32..27d28f0dc 100755 --- a/src/TransformOperator.cpp +++ b/src/TransformOperator.cpp @@ -546,9 +546,22 @@ void TransformOperator::mouseMoveEvent(QMouseEvent *e) if(result.first) { - // Checking the ray intersection with a plane parallel to viewport & on the geometric center of selection Ogre::Vector3 point = mouseRay.getPoint(result.second); - Ogre::Vector3 translation = (mouseRay.getPoint(result.second) - mStartPoint) * mTransformVector; + Ogre::Vector3 worldDelta = point - mStartPoint; + Ogre::Vector3 translation; + + if (mTransformSpace == SPACE_LOCAL && !mTransformVector.isZeroLength()) + { + // Transform world delta into gizmo's local space, mask axis, transform back + Ogre::Quaternion gizmoOrientation = m_pTransformNode->getOrientation(); + Ogre::Vector3 localDelta = gizmoOrientation.Inverse() * worldDelta; + localDelta *= mTransformVector; + translation = gizmoOrientation * localDelta; + } + else + { + translation = worldDelta * mTransformVector; + } translateSelected(translation); mStartPoint = point; @@ -585,11 +598,28 @@ void TransformOperator::mouseMoveEvent(QMouseEvent *e) Ogre::Vector3 vectorStart = mStartPoint - m_pTransformNode->getPosition(); Ogre::Vector3 vectorEnd = point - m_pTransformNode->getPosition(); - Ogre::Quaternion rotation = vectorStart.getRotationTo(vectorEnd); - rotation.x = rotation.x * mTransformVector.x; - rotation.y = rotation.y * mTransformVector.y; - rotation.z = rotation.z * mTransformVector.z; - rotation.normalise(); + Ogre::Quaternion rotation; + if (mTransformSpace == SPACE_LOCAL && !mTransformVector.isZeroLength()) + { + // Transform vectors into gizmo local space, compute rotation there, mask, transform back + Ogre::Quaternion gizmoOri = m_pTransformNode->getOrientation(); + Ogre::Vector3 localStart = gizmoOri.Inverse() * vectorStart; + Ogre::Vector3 localEnd = gizmoOri.Inverse() * vectorEnd; + Ogre::Quaternion localRot = localStart.getRotationTo(localEnd); + localRot.x *= mTransformVector.x; + localRot.y *= mTransformVector.y; + localRot.z *= mTransformVector.z; + localRot.normalise(); + rotation = gizmoOri * localRot * gizmoOri.Inverse(); + } + else + { + rotation = vectorStart.getRotationTo(vectorEnd); + rotation.x *= mTransformVector.x; + rotation.y *= mTransformVector.y; + rotation.z *= mTransformVector.z; + rotation.normalise(); + } rotateSelected(rotation); mStartPoint = point; diff --git a/src/TransformOperator_test.cpp b/src/TransformOperator_test.cpp index f45763fd9..893163753 100644 --- a/src/TransformOperator_test.cpp +++ b/src/TransformOperator_test.cpp @@ -754,3 +754,37 @@ TEST_F(TransformOperatorTestFixture, ScaleStateWithSelection) { TransformOperator* instance = TransformOperator::getSingleton(); EXPECT_NO_THROW(instance->onTransformStateChange(TransformOperator::TS_SCALE)); } + +TEST_F(TransformOperatorTestFixture, LocalSpaceTranslate) { + if (!canLoadMeshFiles()) { GTEST_SKIP() << "Skipping: entity creation not supported"; } + + auto* sceneMgr = Manager::getSingleton()->getSceneMgr(); + auto* node = sceneMgr->getRootSceneNode()->createChildSceneNode(); + auto mesh = createInMemoryTriangleMesh("LocalSpaceTestMesh"); + auto* entity = sceneMgr->createEntity(mesh); + node->attachObject(entity); + SelectionSet::getSingleton()->append(node); + + // Rotate node 90 degrees around Y + node->yaw(Ogre::Degree(90)); + + TransformOperator* instance = TransformOperator::getSingleton(); + instance->setTransformSpace(TransformOperator::SPACE_LOCAL); + instance->onTransformStateChange(TransformOperator::TS_TRANSLATE); + + Ogre::Vector3 startPos = node->getPosition(); + instance->translateSelected(Ogre::Vector3(5, 0, 0)); + Ogre::Vector3 endPos = node->getPosition(); + + EXPECT_NE(startPos, endPos); + instance->setTransformSpace(TransformOperator::SPACE_WORLD); +} + +TEST_F(TransformOperatorTestFixture, LocalSpaceWithAllStates) { + TransformOperator* instance = TransformOperator::getSingleton(); + instance->setTransformSpace(TransformOperator::SPACE_LOCAL); + EXPECT_NO_THROW(instance->onTransformStateChange(TransformOperator::TS_TRANSLATE)); + EXPECT_NO_THROW(instance->onTransformStateChange(TransformOperator::TS_ROTATE)); + EXPECT_NO_THROW(instance->onTransformStateChange(TransformOperator::TS_SCALE)); + instance->setTransformSpace(TransformOperator::SPACE_WORLD); +} From 5909bf7e40124611729dde95aa33adbf851a629d Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 22 Mar 2026 21:37:07 -0400 Subject: [PATCH 4/7] Add keyboard shortcuts section to docs page Documents all shortcuts: transform tools (Q/W/E/R/X/Del), camera controls (F/middle/right/scroll/shift+middle/arrows), and edit shortcuts (Ctrl+Z/Shift+Z/O/S, Ctrl/Shift+click). Co-Authored-By: Claude Sonnet 4.6 --- docs/index.html | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/docs/index.html b/docs/index.html index a8900bdcd..1fec7a130 100644 --- a/docs/index.html +++ b/docs/index.html @@ -648,6 +648,50 @@

CLI & Automation

+ +
+

Keyboard Shortcuts

+

Unity-style shortcuts for fast 3D editing workflows.

+ +
+
+

Transform Tools

+ + + + + + + +
QSelect mode
WTranslate (Move)
ERotate
RScale
XToggle World / Local space
DelDelete selected
+
+ +
+

Camera & View

+ + + + + + + +
FFrame selection (zoom to fit)
Middle MouseOrbit camera
Right MousePan camera
Scroll WheelZoom in / out
Shift + MiddleRoll camera
Arrow KeysRotate camera
+
+ +
+

Edit & Selection

+ + + + + + + +
Ctrl + ZUndo
Ctrl + Shift + ZRedo
Ctrl + OOpen Scene
Ctrl + SSave Scene
Ctrl + ClickAdd to selection
Shift + ClickRemove from selection
+
+
+
+

Animation Merging

From f594a154ff5a5c611367733817e346d3eacf3b73 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 22 Mar 2026 23:13:21 -0400 Subject: [PATCH 5/7] Remove unused AnimationTimelineController and AnimationTimeline.qml The QML Animation Timeline was replaced by the native AnimationControlWidget dock. Clean up the unused controller, QML file, and all references from main.cpp, mainwindow.cpp, CMakeLists.txt, qml_resources.qrc, tests, and CLAUDE.md. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 5 - qml/AnimationTimeline.qml | 146 ---------------------- src/AnimationTimelineController.cpp | 181 ---------------------------- src/AnimationTimelineController.h | 68 ----------- src/CMakeLists.txt | 2 - src/main.cpp | 7 -- src/mainwindow.cpp | 1 - src/qml_resources.qrc | 3 - tests/CMakeLists.txt | 4 +- 9 files changed, 2 insertions(+), 415 deletions(-) delete mode 100644 qml/AnimationTimeline.qml delete mode 100644 src/AnimationTimelineController.cpp delete mode 100644 src/AnimationTimelineController.h diff --git a/CLAUDE.md b/CLAUDE.md index 124ab285d..c1c7054bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -119,11 +119,6 @@ Three singletons manage core state. All run on the main thread. Access via `Clas - **CollapsibleSection.qml**, **SceneTreeNode.qml**, **TransformField.qml** — reusable QML components. - Loaded as QQuickWidget directly in the right dock (replaces old tab widget with Transform/Material/Edit/Animation tabs). -### Animation Timeline - -- **AnimationTimelineController** (`src/AnimationTimelineController.h/cpp`): QML_SINGLETON managing animation playback. Provides animation list, play/pause/stop, timeline scrubbing. Auto-detects animated entities from selection. -- **AnimationTimeline.qml** (`qml/AnimationTimeline.qml`): Bottom dock panel with animation selector, playback controls, and timeline scrubber. Auto-shows/hides based on selection. - ### Theme System - **ThemeManager** (`src/ThemeManager.h/cpp`): QML_SINGLETON providing canonical theme colors synced from QPalette. All colors (window, panel, header, text, button, highlight, border, accent) derived from the active QPalette. diff --git a/qml/AnimationTimeline.qml b/qml/AnimationTimeline.qml deleted file mode 100644 index 69680a340..000000000 --- a/qml/AnimationTimeline.qml +++ /dev/null @@ -1,146 +0,0 @@ -import QtQuick 2.15 -import QtQuick.Controls 2.15 -import QtQuick.Layouts 1.15 -import PropertiesPanel 1.0 -import AnimationTimeline 1.0 - -Rectangle { - id: root - color: PropertiesPanelController.panelColor - visible: AnimationTimelineController.hasAnimations - implicitHeight: 120 - - ColumnLayout { - anchors.fill: parent - anchors.margins: 6 - spacing: 4 - - // Animation selector + playback controls - RowLayout { - spacing: 8 - Layout.fillWidth: true - - ComboBox { - id: animCombo - model: AnimationTimelineController.animationNames - currentIndex: { - var names = AnimationTimelineController.animationNames - return names.indexOf(AnimationTimelineController.currentAnimation) - } - onActivated: AnimationTimelineController.currentAnimation = currentText - - Layout.fillWidth: true - Layout.maximumWidth: 200 - - background: Rectangle { - color: PropertiesPanelController.inputColor - border.color: PropertiesPanelController.borderColor - border.width: 1 - radius: 3 - implicitHeight: 26 - } - contentItem: Text { - text: animCombo.displayText - color: PropertiesPanelController.textColor - font.pixelSize: 11 - verticalAlignment: Text.AlignVCenter - leftPadding: 6 - } - } - - Button { - text: AnimationTimelineController.playing ? "\u275A\u275A" : "\u25B6" - onClicked: { - if (AnimationTimelineController.playing) - AnimationTimelineController.pause() - else - AnimationTimelineController.play() - } - implicitWidth: 32 - implicitHeight: 26 - - background: Rectangle { - color: parent.pressed ? Qt.darker(PropertiesPanelController.headerColor, 1.2) - : PropertiesPanelController.headerColor - border.color: PropertiesPanelController.borderColor - radius: 3 - } - contentItem: Text { - text: parent.text - color: PropertiesPanelController.textColor - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - font.pixelSize: 12 - } - } - - Button { - text: "\u25A0" - onClicked: AnimationTimelineController.stop() - implicitWidth: 32 - implicitHeight: 26 - - background: Rectangle { - color: parent.pressed ? Qt.darker(PropertiesPanelController.headerColor, 1.2) - : PropertiesPanelController.headerColor - border.color: PropertiesPanelController.borderColor - radius: 3 - } - contentItem: Text { - text: parent.text - color: PropertiesPanelController.textColor - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - font.pixelSize: 12 - } - } - - Text { - text: { - var pos = AnimationTimelineController.progress.toFixed(2) - var dur = AnimationTimelineController.duration.toFixed(2) - return pos + " / " + dur + "s" - } - color: PropertiesPanelController.textColor - font.pixelSize: 11 - } - } - - // Timeline scrubber - Slider { - id: scrubber - Layout.fillWidth: true - from: 0 - to: Math.max(AnimationTimelineController.duration, 0.01) - value: AnimationTimelineController.progress - onMoved: AnimationTimelineController.progress = value - - background: Rectangle { - x: scrubber.leftPadding - y: scrubber.topPadding + scrubber.availableHeight / 2 - height / 2 - width: scrubber.availableWidth - height: 4 - radius: 2 - color: PropertiesPanelController.borderColor - - Rectangle { - width: scrubber.visualPosition * parent.width - height: parent.height - color: PropertiesPanelController.highlightColor - radius: 2 - } - } - - handle: Rectangle { - x: scrubber.leftPadding + scrubber.visualPosition * (scrubber.availableWidth - width) - y: scrubber.topPadding + scrubber.availableHeight / 2 - height / 2 - width: 12 - height: 12 - radius: 6 - color: scrubber.pressed ? Qt.lighter(PropertiesPanelController.highlightColor, 1.2) - : PropertiesPanelController.highlightColor - border.color: PropertiesPanelController.borderColor - } - } - } -} diff --git a/src/AnimationTimelineController.cpp b/src/AnimationTimelineController.cpp deleted file mode 100644 index 87b697fd3..000000000 --- a/src/AnimationTimelineController.cpp +++ /dev/null @@ -1,181 +0,0 @@ -#include "AnimationTimelineController.h" -#include "SelectionSet.h" -#include - -AnimationTimelineController* AnimationTimelineController::m_pSingleton = nullptr; - -AnimationTimelineController* AnimationTimelineController::instance() -{ - if (!m_pSingleton) - m_pSingleton = new AnimationTimelineController(); - return m_pSingleton; -} - -AnimationTimelineController* AnimationTimelineController::qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine) -{ - Q_UNUSED(engine); - Q_UNUSED(scriptEngine); - auto* inst = instance(); - QQmlEngine::setObjectOwnership(inst, QQmlEngine::CppOwnership); - return inst; -} - -void AnimationTimelineController::kill() -{ - delete m_pSingleton; - m_pSingleton = nullptr; -} - -AnimationTimelineController::AnimationTimelineController() : QObject(nullptr) -{ - connect(SelectionSet::getSingleton(), &SelectionSet::selectionChanged, - this, &AnimationTimelineController::onSelectionChanged); -} - -Ogre::Entity* AnimationTimelineController::getSelectedAnimatedEntity() const -{ - auto entities = SelectionSet::getSingleton()->getResolvedEntities(); - for (Ogre::Entity* ent : entities) - { - if (ent->getAllAnimationStates() && !ent->getAllAnimationStates()->getAnimationStates().empty()) - return ent; - } - return nullptr; -} - -Ogre::AnimationState* AnimationTimelineController::getCurrentAnimState() const -{ - Ogre::Entity* ent = getSelectedAnimatedEntity(); - if (!ent || mCurrentAnimation.isEmpty()) return nullptr; - - auto* states = ent->getAllAnimationStates(); - if (!states) return nullptr; - - std::string name = mCurrentAnimation.toStdString(); - if (states->hasAnimationState(name)) - return states->getAnimationState(name); - return nullptr; -} - -bool AnimationTimelineController::hasAnimations() const -{ - return getSelectedAnimatedEntity() != nullptr; -} - -QStringList AnimationTimelineController::animationNames() const -{ - QStringList names; - Ogre::Entity* ent = getSelectedAnimatedEntity(); - if (!ent) return names; - - auto* states = ent->getAllAnimationStates(); - if (!states) return names; - - for (const auto& [key, value] : states->getAnimationStates()) - names.append(QString::fromStdString(key)); - return names; -} - -QString AnimationTimelineController::currentAnimation() const -{ - return mCurrentAnimation; -} - -bool AnimationTimelineController::isPlaying() const -{ - return mPlaying; -} - -double AnimationTimelineController::progress() const -{ - auto* state = getCurrentAnimState(); - if (!state) return 0; - return state->getTimePosition(); -} - -double AnimationTimelineController::duration() const -{ - auto* state = getCurrentAnimState(); - if (!state) return 0; - return state->getLength(); -} - -void AnimationTimelineController::setCurrentAnimation(const QString& name) -{ - if (mCurrentAnimation != name) - { - // Disable previous animation - auto* prevState = getCurrentAnimState(); - if (prevState) - prevState->setEnabled(false); - - mCurrentAnimation = name; - - // Enable new animation - auto* newState = getCurrentAnimState(); - if (newState) - { - newState->setEnabled(true); - newState->setLoop(true); - newState->setTimePosition(0); - } - - emit currentAnimationChanged(); - emit progressChanged(); - } -} - -void AnimationTimelineController::setPlaying(bool playing) -{ - if (mPlaying != playing) - { - mPlaying = playing; - emit playingChanged(); - } -} - -void AnimationTimelineController::setProgress(double p) -{ - auto* state = getCurrentAnimState(); - if (state) - { - state->setTimePosition(static_cast(p)); - emit progressChanged(); - } -} - -void AnimationTimelineController::play() -{ - setPlaying(true); -} - -void AnimationTimelineController::pause() -{ - setPlaying(false); -} - -void AnimationTimelineController::stop() -{ - setPlaying(false); - setProgress(0); -} - -void AnimationTimelineController::onSelectionChanged() -{ - mCurrentAnimation.clear(); - - // Auto-select first animation if available - auto names = animationNames(); - if (!names.isEmpty()) - mCurrentAnimation = names.first(); - - emit animationsChanged(); - emit currentAnimationChanged(); - emit progressChanged(); -} - -void AnimationTimelineController::updateProgress() -{ - if (mPlaying) - emit progressChanged(); -} diff --git a/src/AnimationTimelineController.h b/src/AnimationTimelineController.h deleted file mode 100644 index 58ab4ccdc..000000000 --- a/src/AnimationTimelineController.h +++ /dev/null @@ -1,68 +0,0 @@ -#ifndef ANIMATION_TIMELINE_CONTROLLER_H -#define ANIMATION_TIMELINE_CONTROLLER_H - -#include -#include -#include - -namespace Ogre { - class Entity; - class AnimationState; -} - -class AnimationTimelineController : public QObject -{ - Q_OBJECT - QML_ELEMENT - QML_SINGLETON - - Q_PROPERTY(bool hasAnimations READ hasAnimations NOTIFY animationsChanged) - Q_PROPERTY(QStringList animationNames READ animationNames NOTIFY animationsChanged) - Q_PROPERTY(QString currentAnimation READ currentAnimation WRITE setCurrentAnimation NOTIFY currentAnimationChanged) - Q_PROPERTY(bool playing READ isPlaying WRITE setPlaying NOTIFY playingChanged) - Q_PROPERTY(double progress READ progress WRITE setProgress NOTIFY progressChanged) - Q_PROPERTY(double duration READ duration NOTIFY currentAnimationChanged) - -public: - static AnimationTimelineController* instance(); - static AnimationTimelineController* qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine); - static void kill(); - - bool hasAnimations() const; - QStringList animationNames() const; - QString currentAnimation() const; - bool isPlaying() const; - double progress() const; - double duration() const; - - void setCurrentAnimation(const QString& name); - void setPlaying(bool playing); - void setProgress(double p); - - Q_INVOKABLE void play(); - Q_INVOKABLE void pause(); - Q_INVOKABLE void stop(); - -public slots: - void onSelectionChanged(); - void updateProgress(); - -signals: - void animationsChanged(); - void currentAnimationChanged(); - void playingChanged(); - void progressChanged(); - -private: - AnimationTimelineController(); - ~AnimationTimelineController() override = default; - - Ogre::Entity* getSelectedAnimatedEntity() const; - Ogre::AnimationState* getCurrentAnimState() const; - - static AnimationTimelineController* m_pSingleton; - QString mCurrentAnimation; - bool mPlaying = false; -}; - -#endif // ANIMATION_TIMELINE_CONTROLLER_H diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 56a0c9efb..9a9627c9e 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -55,7 +55,6 @@ UndoManager.cpp commands/TransformCommands.cpp PropertiesPanelController.cpp SceneTreeModel.cpp -AnimationTimelineController.cpp ThemeManager.cpp BatchExporter.cpp MaterialPresetLibrary.cpp @@ -117,7 +116,6 @@ UndoManager.h commands/TransformCommands.h PropertiesPanelController.h SceneTreeModel.h -AnimationTimelineController.h ThemeManager.h BatchExporter.h MaterialPresetLibrary.h diff --git a/src/main.cpp b/src/main.cpp index 35a3b0a8c..2d5915f71 100755 --- a/src/main.cpp +++ b/src/main.cpp @@ -21,7 +21,6 @@ #endif #include "ModelDownloader.h" #include "PropertiesPanelController.h" -#include "AnimationTimelineController.h" #include "ThemeManager.h" #include "MCPServer.h" #include "SentryReporter.h" @@ -217,12 +216,6 @@ int main(int argc, char *argv[]) return PropertiesPanelController::qmlInstance(engine, scriptEngine); }); - // Register AnimationTimelineController singleton for QML - qmlRegisterSingletonType("AnimationTimeline", 1, 0, "AnimationTimelineController", - [](QQmlEngine *engine, QJSEngine *scriptEngine) -> QObject* { - return AnimationTimelineController::qmlInstance(engine, scriptEngine); - }); - // Register ThemeManager singleton for QML qmlRegisterSingletonType("ThemeManager", 1, 0, "ThemeManager", [](QQmlEngine *engine, QJSEngine *scriptEngine) -> QObject* { diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 5d3c3a2aa..5f8442288 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -53,7 +53,6 @@ #include "ModelDownloader.h" #include "UndoManager.h" #include "PropertiesPanelController.h" -#include "AnimationTimelineController.h" #include #include diff --git a/src/qml_resources.qrc b/src/qml_resources.qrc index c57804250..293166d97 100644 --- a/src/qml_resources.qrc +++ b/src/qml_resources.qrc @@ -16,9 +16,6 @@ ../qml/ViewCubeWindow.qml - - ../qml/AnimationTimeline.qml - ../qml/PropertiesPanel.qml ../qml/CollapsibleSection.qml diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c2be2b51d..75429f5c4 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -65,7 +65,7 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/TransformCommands.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/PropertiesPanelController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/SceneTreeModel.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/../src/AnimationTimelineController.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/ThemeManager.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/BatchExporter.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/MaterialPresetLibrary.cpp @@ -125,7 +125,7 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/TransformCommands.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/PropertiesPanelController.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/SceneTreeModel.h - ${CMAKE_CURRENT_SOURCE_DIR}/../src/AnimationTimelineController.h + ${CMAKE_CURRENT_SOURCE_DIR}/../src/ThemeManager.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/BatchExporter.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/MaterialPresetLibrary.h From ee538637436b1542881b7276c58ccd678168bb35 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 22 Mar 2026 23:17:20 -0400 Subject: [PATCH 6/7] Address second round of review comments - SceneTreeNode: all nodes start collapsed to avoid loading full subtree - Status bar: handle SubEntity-only selections - PropertiesPanelController: emit themeChanged on palette switch - ThemeManager tests: add ASSERT_NE null checks before dereferencing - LocalSpaceTranslate test: verify exact delta and space persistence Co-Authored-By: Claude Sonnet 4.6 --- qml/SceneTreeNode.qml | 2 +- src/PropertiesPanelController.cpp | 5 +++++ src/ThemeManager_test.cpp | 8 ++++++-- src/TransformOperator_test.cpp | 10 +++++++++- src/mainwindow.cpp | 2 ++ 5 files changed, 23 insertions(+), 4 deletions(-) diff --git a/qml/SceneTreeNode.qml b/qml/SceneTreeNode.qml index f97ea5f1b..2e87601a0 100644 --- a/qml/SceneTreeNode.qml +++ b/qml/SceneTreeNode.qml @@ -9,7 +9,7 @@ Column { property var treeModel property int indentLevel: 0 - property bool expanded: indentLevel > 0 // Nodes start collapsed, children expanded + property bool expanded: false // All nodes start collapsed to avoid loading full subtree property int childCount: treeModel ? treeModel.rowCount(nodeIndex) : 0 property bool hasChildren: childCount > 0 property string nodeName: treeModel ? (treeModel.data(nodeIndex) || "") : "" diff --git a/src/PropertiesPanelController.cpp b/src/PropertiesPanelController.cpp index 1e2fc7168..82e44df86 100644 --- a/src/PropertiesPanelController.cpp +++ b/src/PropertiesPanelController.cpp @@ -57,6 +57,11 @@ PropertiesPanelController::PropertiesPanelController() : QObject(nullptr) connect(Manager::getSingleton(), &Manager::sceneNodeDestroyed, this, &PropertiesPanelController::onSceneChanged); mSceneTreeModel = new SceneTreeModel(this); + + // Refresh theme colors when the application palette changes (Light/Dark/Custom switch) + connect(qApp, &QApplication::paletteChanged, this, [this]() { + emit themeChanged(); + }); } // Theme colors from QPalette diff --git a/src/ThemeManager_test.cpp b/src/ThemeManager_test.cpp index 0ada4cbb9..b0e5cf2f1 100644 --- a/src/ThemeManager_test.cpp +++ b/src/ThemeManager_test.cpp @@ -14,12 +14,14 @@ class ThemeManagerTests : public ::testing::Test { }; TEST_F(ThemeManagerTests, Singleton) { - ASSERT_NE(ThemeManager::instance(), nullptr); - EXPECT_EQ(ThemeManager::instance(), ThemeManager::instance()); + auto* tm = ThemeManager::instance(); + ASSERT_NE(tm, nullptr); + EXPECT_EQ(tm, ThemeManager::instance()); } TEST_F(ThemeManagerTests, ColorsAreValid) { auto* tm = ThemeManager::instance(); + ASSERT_NE(tm, nullptr); EXPECT_TRUE(tm->windowColor().isValid()); EXPECT_TRUE(tm->panelColor().isValid()); EXPECT_TRUE(tm->headerColor().isValid()); @@ -34,6 +36,7 @@ TEST_F(ThemeManagerTests, ColorsAreValid) { TEST_F(ThemeManagerTests, ThemeNameNotEmpty) { auto* tm = ThemeManager::instance(); + ASSERT_NE(tm, nullptr); QString name = tm->themeName(); EXPECT_FALSE(name.isEmpty()); EXPECT_TRUE(name == "light" || name == "dark"); @@ -41,6 +44,7 @@ TEST_F(ThemeManagerTests, ThemeNameNotEmpty) { TEST_F(ThemeManagerTests, RefreshThemeEmitsSignal) { auto* tm = ThemeManager::instance(); + ASSERT_NE(tm, nullptr); QSignalSpy spy(tm, &ThemeManager::themeChanged); tm->refreshTheme(); EXPECT_EQ(spy.count(), 1); diff --git a/src/TransformOperator_test.cpp b/src/TransformOperator_test.cpp index 893163753..ba0a4c56e 100644 --- a/src/TransformOperator_test.cpp +++ b/src/TransformOperator_test.cpp @@ -772,11 +772,19 @@ TEST_F(TransformOperatorTestFixture, LocalSpaceTranslate) { instance->setTransformSpace(TransformOperator::SPACE_LOCAL); instance->onTransformStateChange(TransformOperator::TS_TRANSLATE); + // translateSelected always works in world space (local conversion is in mouse drag) + // Verify the translate applies correctly and transform space persists Ogre::Vector3 startPos = node->getPosition(); instance->translateSelected(Ogre::Vector3(5, 0, 0)); Ogre::Vector3 endPos = node->getPosition(); - EXPECT_NE(startPos, endPos); + // Should have moved exactly 5 units along world X + EXPECT_FLOAT_EQ(endPos.x - startPos.x, 5.0f); + EXPECT_FLOAT_EQ(endPos.y - startPos.y, 0.0f); + EXPECT_FLOAT_EQ(endPos.z - startPos.z, 0.0f); + + // Transform space should still be LOCAL + EXPECT_EQ(instance->getTransformSpace(), TransformOperator::SPACE_LOCAL); instance->setTransformSpace(TransformOperator::SPACE_WORLD); } diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 5f8442288..7525153bc 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -570,6 +570,8 @@ bool MainWindow::frameEnded(const Ogre::FrameEvent &evt) statusMessage += QString("Nodes: %1").arg(sel->getNodesCount()); else if (sel->hasEntities()) statusMessage += QString("Entities: %1").arg(sel->getEntitiesCount()); + else if (sel->hasSubEntities()) + statusMessage += QString("Submeshes: %1").arg(sel->getSubEntitiesCount()); else statusMessage += "No selection"; From 6999b381c9edace006a827ddcddb4a3424e6a0a8 Mon Sep 17 00:00:00 2001 From: Fernando Date: Mon, 23 Mar 2026 00:45:34 -0400 Subject: [PATCH 7/7] Fix SonarCloud bug: remove redundant ternary returning true/true cfg["showSegX"] had `(t != AP_SPRING ? true : true)` which always returns true regardless of condition. Simplified to just `true`. Co-Authored-By: Claude Sonnet 4.6 --- src/PropertiesPanelController.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PropertiesPanelController.cpp b/src/PropertiesPanelController.cpp index 82e44df86..dff8ef0e8 100644 --- a/src/PropertiesPanelController.cpp +++ b/src/PropertiesPanelController.cpp @@ -283,7 +283,7 @@ QVariantMap PropertiesPanelController::primFieldConfig() const || t == PrimitiveObject::AP_ICOSPHERE || t == PrimitiveObject::AP_ROUNDEDBOX); cfg["showRadius2"] = (t == PrimitiveObject::AP_TORUS || t == PrimitiveObject::AP_TUBE); cfg["showHeight"] = (t == PrimitiveObject::AP_CYLINDER || t == PrimitiveObject::AP_CONE || t == PrimitiveObject::AP_TUBE || t == PrimitiveObject::AP_CAPSULE); - cfg["showSegX"] = (t != PrimitiveObject::AP_SPRING ? true : true); // all have at least segX + cfg["showSegX"] = true; // all primitives have at least segX cfg["showSegY"] = (t == PrimitiveObject::AP_CUBE || t == PrimitiveObject::AP_SPHERE || t == PrimitiveObject::AP_PLANE || t == PrimitiveObject::AP_TORUS || t == PrimitiveObject::AP_CAPSULE || t == PrimitiveObject::AP_ROUNDEDBOX || t == PrimitiveObject::AP_SPRING);