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

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

Camera & View

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

Edit & Selection

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

Animation Merging

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 @@ + + @@ -101,6 +103,13 @@ + + + Edit + + + + Help @@ -109,6 +118,7 @@ + @@ -142,13 +152,32 @@ false + - + + + + + + + View + + + TopToolBarArea + + + false + + + + + + @@ -290,6 +319,28 @@ Tools + + + true + + + true + + + Animation Control + + + + + true + + + true + + + View Toolbar + + true @@ -313,7 +364,7 @@ Move Object - Move Object + Move Object (W) true @@ -331,7 +382,7 @@ Rotate Object - Rotate Object + Rotate Object (E) @@ -349,7 +400,7 @@ Select Object - Select Object + Select Object (Q) @@ -361,7 +412,7 @@ Remove Object - Remove Object + Remove Object (Del) @@ -456,6 +507,48 @@ Verify Update + + + Undo + + + Ctrl+Z + + + + + Redo + + + Ctrl+Shift+Z + + + + + true + + + + :/icones/scale.png:/icones/scale.png + + + Scale Object + + + Scale Object (R) + + + + + true + + + World/Local + + + Toggle World/Local Transform Space (X) + + false