diff --git a/CLAUDE.md b/CLAUDE.md
index 4e6d3d147..c1c7054bd 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,39 @@ 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).
+
+### 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 +166,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/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
+
+ | Q | Select mode |
+ | W | Translate (Move) |
+ | E | Rotate |
+ | R | Scale |
+ | X | Toggle World / Local space |
+ | Del | Delete selected |
+
+
+
+
+
Camera & View
+
+ | F | Frame selection (zoom to fit) |
+ | Middle Mouse | Orbit camera |
+ | Right Mouse | Pan camera |
+ | Scroll Wheel | Zoom in / out |
+ | Shift + Middle | Roll camera |
+ | Arrow Keys | Rotate camera |
+
+
+
+
+
Edit & Selection
+
+ | Ctrl + Z | Undo |
+ | Ctrl + Shift + Z | Redo |
+ | Ctrl + O | Open Scene |
+ | Ctrl + S | Save Scene |
+ | Ctrl + Click | Add to selection |
+ | Shift + Click | Remove from selection |
+
+
+
+
+
Animation Merging
diff --git a/qml/CollapsibleSection.qml b/qml/CollapsibleSection.qml
new file mode 100644
index 000000000..1fdb78adc
--- /dev/null
+++ b/qml/CollapsibleSection.qml
@@ -0,0 +1,64 @@
+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
+ activeFocusOnTab: true
+ onClicked: root.expanded = !root.expanded
+ Keys.onReturnPressed: root.expanded = !root.expanded
+ Keys.onSpacePressed: 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..580375205
--- /dev/null
+++ b/qml/PropertiesPanel.qml
@@ -0,0 +1,439 @@
+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
+ property bool delegatesActive: true
+
+ Repeater {
+ model: outlinerColumn.nodeCount
+
+ Loader {
+ active: outlinerColumn.delegatesActive
+ 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 })
+ if (item.refreshSelected)
+ item.refreshSelected()
+ }
+ }
+ }
+
+ Connections {
+ target: outlinerColumn.treeModel
+ function onModelReset() {
+ outlinerColumn.delegatesActive = false
+ outlinerColumn.nodeCount = outlinerColumn.treeModel
+ ? outlinerColumn.treeModel.rowCount() : 0
+ Qt.callLater(function() { outlinerColumn.delegatesActive = true })
+ }
+ }
+ }
+ }
+
+ // ---- 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..2e87601a0
--- /dev/null
+++ b/qml/SceneTreeNode.qml
@@ -0,0 +1,310 @@
+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: 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) || "") : ""
+ 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 })
+ if (item.refreshSelected)
+ item.refreshSelected()
+ }
+ }
+ }
+ }
+ }
+ }
+}
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 000000000..90a4e7493
Binary files /dev/null and b/resources/scale.png differ
diff --git a/src/BatchExporter.cpp b/src/BatchExporter.cpp
new file mode 100644
index 000000000..861190935
--- /dev/null
+++ b/src/BatchExporter.cpp
@@ -0,0 +1,48 @@
+#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 < static_cast(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 storage;
+ std::vector argv;
+ for (const auto& arg : args) {
+ storage.push_back(arg.toStdString());
+ }
+ 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());
+ 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..9a9627c9e 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,13 @@ RTShaderHelper.cpp
ViewCube/ViewCubeController.cpp
SDWorker.cpp
SDManager.cpp
+UndoManager.cpp
+commands/TransformCommands.cpp
+PropertiesPanelController.cpp
+SceneTreeModel.cpp
+ThemeManager.cpp
+BatchExporter.cpp
+MaterialPresetLibrary.cpp
)
set(HEADER_FILES
@@ -74,6 +82,7 @@ MeshImporterExporter.h
EditorViewport.h
RotationGizmo.h
TranslationGizmo.h
+ScaleGizmo.h
TransformOperator.h
TransformWidget.h
PrimitivesWidget.h
@@ -103,6 +112,13 @@ RTShaderHelper.h
ViewCube/ViewCubeController.h
SDWorker.h
SDManager.h
+UndoManager.h
+commands/TransformCommands.h
+PropertiesPanelController.h
+SceneTreeModel.h
+ThemeManager.h
+BatchExporter.h
+MaterialPresetLibrary.h
)
set(TEST_SOURCES "")
diff --git a/src/LLMManager.cpp b/src/LLMManager.cpp
index 4ae3a6f0e..efb72e60f 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) {
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..dff8ef0e8
--- /dev/null
+++ b/src/PropertiesPanelController.cpp
@@ -0,0 +1,502 @@
+#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);
+
+ // Refresh theme colors when the application palette changes (Light/Dark/Custom switch)
+ connect(qApp, &QApplication::paletteChanged, this, [this]() {
+ emit themeChanged();
+ });
+}
+
+// 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(static_cast(v), static_cast(mPosY), static_cast(mPosZ));
+ TransformOperator::getSingleton()->setSelectedPosition(pos);
+ }
+}
+void PropertiesPanelController::setPosY(double v) {
+ if (mPosY != v) {
+ 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(static_cast(mPosX), static_cast(mPosY), static_cast(v));
+ TransformOperator::getSingleton()->setSelectedPosition(pos);
+ }
+}
+void PropertiesPanelController::setRotX(double v) {
+ if (mRotX != v) {
+ 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(static_cast(mRotX), static_cast(v), static_cast(mRotZ));
+ TransformOperator::getSingleton()->setSelectedOrientation(rot);
+ }
+}
+void PropertiesPanelController::setRotZ(double v) {
+ if (mRotZ != 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(static_cast(v), static_cast(mScaleY), static_cast(mScaleZ));
+ TransformOperator::getSingleton()->setSelectedScale(scale);
+ }
+}
+void PropertiesPanelController::setScaleY(double v) {
+ if (mScaleY != v) {
+ 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(static_cast(mScaleX), static_cast(mScaleY), static_cast(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"] = 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);
+ 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/ThemeManager_test.cpp b/src/ThemeManager_test.cpp
new file mode 100644
index 000000000..b0e5cf2f1
--- /dev/null
+++ b/src/ThemeManager_test.cpp
@@ -0,0 +1,51 @@
+#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) {
+ 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());
+ 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();
+ ASSERT_NE(tm, nullptr);
+ QString name = tm->themeName();
+ EXPECT_FALSE(name.isEmpty());
+ EXPECT_TRUE(name == "light" || name == "dark");
+}
+
+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.cpp b/src/TransformOperator.cpp
index fb20cf736..27d28f0dc 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();
+ }
}
}
}
@@ -485,14 +546,27 @@ 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;
- emit selectedPositionChanged(SelectionSet::getSingleton()->getSelectionCenter()); //TODO connect this signal
+ emit selectedPositionChanged(SelectionSet::getSingleton()->getSelectionCenter());
updateGizmoPosition();
}
@@ -524,16 +598,83 @@ 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;
- 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 +683,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 +815,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 +854,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/TransformOperator_test.cpp b/src/TransformOperator_test.cpp
index 0f7f4b0fd..ba0a4c56e 100644
--- a/src/TransformOperator_test.cpp
+++ b/src/TransformOperator_test.cpp
@@ -682,3 +682,117 @@ 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));
+}
+
+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);
+
+ // 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();
+
+ // 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);
+}
+
+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);
+}
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..268d4367e
--- /dev/null
+++ b/src/commands/TransformCommands.cpp
@@ -0,0 +1,147 @@
+#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 = (node->numAttachedObjects() == 0)
+ || node->getAttachedObject(0)->getVisible();
+ mSnapshots.append(snap);
+ }
+}
+
+void DeleteCommand::undo()
+{
+ // Re-show the hidden nodes
+ for (auto& snap : mSnapshots)
+ {
+ if (snap.node)
+ {
+ snap.node->setVisible(snap.wasVisible, 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..2d5915f71 100755
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -20,6 +20,8 @@
#include "SDManager.h"
#endif
#include "ModelDownloader.h"
+#include "PropertiesPanelController.h"
+#include "ThemeManager.h"
#include "MCPServer.h"
#include "SentryReporter.h"
#include "CLIPipeline.h"
@@ -208,6 +210,18 @@ 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 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..7525153bc 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,15 @@
#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
+#include
MainWindow::MainWindow(QWidget *parent) :
QMainWindow(parent), ui(new Ui::MainWindow),
@@ -61,6 +68,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 +212,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 +254,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 +363,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 +555,25 @@ 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 if (sel->hasSubEntities())
+ statusMessage += QString("Submeshes: %1").arg(sel->getSubEntitiesCount());
+ else
+ statusMessage += "No selection";
ui->statusBar->showMessage(statusMessage);
@@ -511,20 +585,40 @@ 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
+ SpaceCamera* cam = nullptr;
+ for (auto* vp : mDockWidgetList) {
+ if (vp->getOgreWidget()->hasFocus()) {
+ cam = vp->getOgreWidget()->getSpaceCamera();
+ break;
+ }
+ }
+ if (!cam && !mDockWidgetList.isEmpty())
+ 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 +645,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
+#ifdef Q_OS_MACOS
+ _exit(0);
+#endif
}
void MainWindow::dragEnterEvent(QDragEnterEvent *event)
@@ -867,6 +973,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 +1001,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..293166d97 100644
--- a/src/qml_resources.qrc
+++ b/src/qml_resources.qrc
@@ -16,4 +16,10 @@
../qml/ViewCubeWindow.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..75429f5c4 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/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/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 @@
+
+
+
+
@@ -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