diff --git a/CMakeLists.txt b/CMakeLists.txt index cb39ed143..29ce8932d 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.23.0 LANGUAGES C CXX) +project(QtMeshEditor VERSION 2.24.0 LANGUAGES C CXX) message(STATUS "Building QtMeshEditor version ${PROJECT_VERSION}") set(QTMESHEDITOR_VERSION_STRING "\"${PROJECT_VERSION}\"") diff --git a/qml/AssetBrowser.qml b/qml/AssetBrowser.qml new file mode 100644 index 000000000..1f98b934b --- /dev/null +++ b/qml/AssetBrowser.qml @@ -0,0 +1,361 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import AssetBrowser 1.0 +import PropertiesPanel 1.0 + +Rectangle { + id: root + color: PropertiesPanelController.panelColor + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + // ---- Path bar ---- + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 36 + color: PropertiesPanelController.headerColor + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 6 + anchors.rightMargin: 6 + spacing: 4 + + // Up button + Rectangle { + width: 28 + height: 24 + radius: 3 + color: upMouse.containsMouse ? Qt.lighter(PropertiesPanelController.inputColor, 1.3) + : PropertiesPanelController.inputColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + + Text { + anchors.centerIn: parent + text: "\u2191" + color: PropertiesPanelController.textColor + font.pixelSize: 14 + } + + MouseArea { + id: upMouse + anchors.fill: parent + hoverEnabled: true + onClicked: AssetBrowserController.navigateUp() + } + } + + // Path display + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 24 + radius: 3 + color: PropertiesPanelController.inputColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + clip: true + + Text { + anchors.fill: parent + anchors.leftMargin: 6 + anchors.rightMargin: 6 + verticalAlignment: Text.AlignVCenter + text: AssetBrowserController.rootPath + color: PropertiesPanelController.textColor + font.pixelSize: 11 + elide: Text.ElideLeft + } + } + + // Browse button + Rectangle { + width: 60 + height: 24 + radius: 3 + color: browseMouse.containsMouse ? Qt.lighter(PropertiesPanelController.inputColor, 1.3) + : PropertiesPanelController.inputColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + + Text { + anchors.centerIn: parent + text: "Browse..." + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + + MouseArea { + id: browseMouse + anchors.fill: parent + hoverEnabled: true + onClicked: AssetBrowserController.browseForDirectory() + } + } + } + } + + // ---- Filter bar ---- + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 32 + color: Qt.darker(PropertiesPanelController.headerColor, 1.05) + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 6 + anchors.rightMargin: 6 + spacing: 4 + + Repeater { + model: [ + { label: "All", value: "all" }, + { label: "Meshes", value: "meshes" }, + { label: "Textures", value: "textures" }, + { label: "Materials", value: "materials" } + ] + + Rectangle { + required property var modelData + width: filterText.implicitWidth + 16 + height: 22 + radius: 11 + color: AssetBrowserController.filter === modelData.value + ? PropertiesPanelController.highlightColor + : (filterMouse.containsMouse ? Qt.lighter(PropertiesPanelController.inputColor, 1.2) + : "transparent") + border.color: AssetBrowserController.filter === modelData.value + ? "transparent" + : PropertiesPanelController.borderColor + border.width: 1 + + Text { + id: filterText + anchors.centerIn: parent + text: modelData.label + color: AssetBrowserController.filter === modelData.value + ? "#ffffff" + : PropertiesPanelController.textColor + font.pixelSize: 11 + font.bold: AssetBrowserController.filter === modelData.value + } + + MouseArea { + id: filterMouse + anchors.fill: parent + hoverEnabled: true + onClicked: AssetBrowserController.filter = modelData.value + } + } + } + + Item { Layout.fillWidth: true } + + // Search field + Rectangle { + Layout.preferredWidth: 140 + Layout.preferredHeight: 22 + radius: 3 + color: PropertiesPanelController.inputColor + border.color: searchInput.activeFocus + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.borderColor + border.width: 1 + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 4 + anchors.rightMargin: 4 + spacing: 2 + + Text { + text: "\uD83D\uDD0D" + font.pixelSize: 10 + color: PropertiesPanelController.textColor + opacity: 0.5 + } + + TextInput { + id: searchInput + Layout.fillWidth: true + Layout.fillHeight: true + verticalAlignment: TextInput.AlignVCenter + color: PropertiesPanelController.textColor + font.pixelSize: 11 + clip: true + selectByMouse: true + onTextChanged: AssetBrowserController.searchQuery = text + + Text { + anchors.fill: parent + verticalAlignment: Text.AlignVCenter + text: "Search..." + color: PropertiesPanelController.textColor + opacity: 0.4 + font.pixelSize: 11 + visible: !searchInput.text && !searchInput.activeFocus + } + } + } + } + } + } + + // ---- File list ---- + ListView { + id: fileList + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: AssetBrowserController.files + + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + } + + delegate: Rectangle { + required property var modelData + required property int index + width: fileList.width + height: 30 + color: delegateMouse.containsMouse + ? Qt.lighter(PropertiesPanelController.panelColor, 1.15) + : (index % 2 === 0 ? PropertiesPanelController.panelColor + : Qt.darker(PropertiesPanelController.panelColor, 1.03)) + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + spacing: 8 + + // Type icon / thumbnail + Item { + Layout.preferredWidth: 28 + Layout.preferredHeight: 28 + + // Image thumbnail for textures + Image { + anchors.fill: parent + visible: modelData.type === "texture" + source: modelData.type === "texture" ? "file:///" + modelData.path : "" + fillMode: Image.PreserveAspectFit + asynchronous: true + sourceSize.width: 28 + sourceSize.height: 28 + } + + // Material preview sphere (RTT) + Image { + anchors.fill: parent + visible: modelData.type === "material" && !!modelData.previewUrl + source: (modelData.type === "material" && modelData.previewUrl) ? modelData.previewUrl : "" + fillMode: Image.PreserveAspectFit + asynchronous: true + sourceSize.width: 28 + sourceSize.height: 28 + } + + // Emoji icon for non-texture files (fallback for materials without preview) + Text { + anchors.centerIn: parent + visible: modelData.type !== "texture" + && !(modelData.type === "material" && !!modelData.previewUrl) + text: { + if (modelData.isDir) return "\uD83D\uDCC1" + if (modelData.type === "mesh") return "\uD83D\uDFE6" + if (modelData.type === "material") return "\uD83D\uDD35" + return "\uD83D\uDCC4" + } + font.pixelSize: 14 + } + } + + // File name + Text { + Layout.fillWidth: true + text: modelData.name + color: PropertiesPanelController.textColor + font.pixelSize: 12 + font.bold: modelData.isDir + elide: Text.ElideRight + } + + // File size (skip for directories) + Text { + visible: !modelData.isDir + text: { + var size = modelData.size + if (size < 1024) return size + " B" + if (size < 1048576) return (size / 1024).toFixed(1) + " KB" + return (size / 1048576).toFixed(1) + " MB" + } + color: PropertiesPanelController.textColor + opacity: 0.6 + font.pixelSize: 10 + } + } + + MouseArea { + id: delegateMouse + anchors.fill: parent + hoverEnabled: true + onClicked: { + // Single click navigates into directories + if (modelData.isDir) + AssetBrowserController.navigateToDirectory(modelData.path) + } + onDoubleClicked: { + // Double click opens/loads files + if (!modelData.isDir) + AssetBrowserController.openFile(modelData.path) + } + } + } + + // Empty state + Text { + anchors.centerIn: parent + visible: fileList.count === 0 + text: AssetBrowserController.filter !== "all" + ? "No " + AssetBrowserController.filter + " files found" + : "No files found" + color: PropertiesPanelController.textColor + opacity: 0.5 + font.pixelSize: 13 + } + } + + // ---- Status bar ---- + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 22 + color: PropertiesPanelController.headerColor + + Text { + anchors.fill: parent + anchors.leftMargin: 8 + verticalAlignment: Text.AlignVCenter + text: { + var count = AssetBrowserController.files.length + var dirs = 0 + var filesCount = 0 + for (var i = 0; i < count; i++) { + if (AssetBrowserController.files[i].isDir) dirs++ + else filesCount++ + } + var parts = [] + if (dirs > 0) parts.push(dirs + (dirs === 1 ? " folder" : " folders")) + if (filesCount > 0) parts.push(filesCount + (filesCount === 1 ? " file" : " files")) + return parts.join(", ") || "Empty" + } + color: PropertiesPanelController.textColor + opacity: 0.7 + font.pixelSize: 10 + } + } + } +} diff --git a/qml/MaterialListModal.qml b/qml/MaterialListModal.qml index 3b07bd69f..a2f35b3bd 100644 --- a/qml/MaterialListModal.qml +++ b/qml/MaterialListModal.qml @@ -6,11 +6,11 @@ import MaterialEditorQML 1.0 ApplicationWindow { id: materialListModal title: "Material List" - width: 600 - height: 500 + width: 700 + height: 550 visible: true color: backgroundColor - + // Theme colors readonly property color backgroundColor: palette.window readonly property color panelColor: palette.base @@ -21,276 +21,300 @@ ApplicationWindow { readonly property color buttonTextColor: palette.buttonText readonly property color alternateColor: palette.alternateBase readonly property color disabledTextColor: palette.placeholderText - + SystemPalette { id: palette colorGroup: SystemPalette.Active } - - // Material list model - ListModel { - id: materialListModel - } - - // Selected material index - property int selectedIndex: -1 + property string selectedMaterial: "" - - // Signals - signal materialSelected(string materialName) + signal editMaterial(string materialName) signal exportMaterial(string materialName) signal createNewMaterial() signal importMaterials() - - // Main content + + // Filtered material list + property var allMaterials: [] + property var filteredMaterials: [] + property string searchText: "" + + function refreshMaterialList() { + allMaterials = MaterialEditorQML.getMaterialList() + applyFilter() + } + + function applyFilter() { + if (searchText === "") { + filteredMaterials = allMaterials + } else { + var result = [] + var lower = searchText.toLowerCase() + for (var i = 0; i < allMaterials.length; i++) { + if (allMaterials[i].toLowerCase().indexOf(lower) !== -1) + result.push(allMaterials[i]) + } + filteredMaterials = result + } + } + ColumnLayout { anchors.fill: parent - spacing: 10 - - // Material list - ScrollView { + anchors.margins: 10 + spacing: 8 + + // Search bar + Rectangle { Layout.fillWidth: true - Layout.fillHeight: true - Layout.preferredHeight: 300 - - ListView { - id: materialListView - model: materialListModel - clip: true - - delegate: Rectangle { - width: materialListView.width - height: 30 - color: index === selectedIndex ? highlightColor : - (index % 2 === 0 ? panelColor : alternateColor) - border.color: borderColor - border.width: 1 - + height: 32 + color: panelColor + border.color: borderColor + border.width: 1 + radius: 4 + + RowLayout { + anchors.fill: parent + anchors.margins: 4 + spacing: 4 + + Text { + text: "\uD83D\uDD0D" + font.pixelSize: 14 + verticalAlignment: Text.AlignVCenter + } + + TextInput { + id: searchField + Layout.fillWidth: true + color: textColor + font.pixelSize: 12 + clip: true + onTextChanged: { + materialListModal.searchText = text + materialListModal.applyFilter() + } + Text { - anchors.left: parent.left - anchors.leftMargin: 10 - anchors.verticalCenter: parent.verticalCenter - text: model.name - color: textColor + anchors.fill: parent + text: "Search materials..." + color: disabledTextColor font.pixelSize: 12 + visible: !searchField.text && !searchField.activeFocus + verticalAlignment: Text.AlignVCenter } - - MouseArea { - anchors.fill: parent - onClicked: { - selectedIndex = index - selectedMaterial = model.name - materialSelected(model.name) + } + + Text { + text: filteredMaterials.length + " materials" + color: disabledTextColor + font.pixelSize: 10 + } + } + } + + // Material grid + ScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + + GridView { + id: materialGrid + clip: true + cellWidth: Math.max(90, (width - 20) / Math.floor((width - 20) / 90)) + cellHeight: 100 + + model: filteredMaterials + + delegate: Item { + width: materialGrid.cellWidth + height: materialGrid.cellHeight + + Rectangle { + id: card + anchors.centerIn: parent + width: parent.width - 6 + height: parent.height - 6 + radius: 6 + color: selectedMaterial === modelData ? Qt.lighter(highlightColor, 1.6) + : cardMa.containsMouse ? Qt.lighter(panelColor, 1.3) + : panelColor + border.color: selectedMaterial === modelData ? highlightColor : borderColor + border.width: selectedMaterial === modelData ? 2 : 1 + + Column { + anchors.centerIn: parent + spacing: 4 + + // RTT sphere preview + Image { + anchors.horizontalCenter: parent.horizontalCenter + width: 52; height: 52 + source: MaterialEditorQML.materialPreview(modelData) + fillMode: Image.PreserveAspectFit + asynchronous: true + sourceSize.width: 52 + sourceSize.height: 52 + + // Fallback circle if preview fails + Rectangle { + anchors.centerIn: parent + width: 44; height: 44; radius: 22 + color: Qt.darker(highlightColor, 1.5) + visible: parent.status !== Image.Ready + Text { + anchors.centerIn: parent + text: "\uD83D\uDD35" + font.pixelSize: 20 + } + } + } + + // Material name + Text { + width: card.width - 8 + anchors.horizontalCenter: parent.horizontalCenter + text: modelData + color: textColor + font.pixelSize: 9 + elide: Text.ElideMiddle + horizontalAlignment: Text.AlignHCenter + } } - onDoubleClicked: { - selectedIndex = index - selectedMaterial = model.name - editMaterial(model.name) + + MouseArea { + id: cardMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: selectedMaterial = modelData + onDoubleClicked: { + selectedMaterial = modelData + editMaterial(modelData) + } } } } } } - + // Button row RowLayout { Layout.fillWidth: true - Layout.alignment: Qt.AlignHCenter - spacing: 10 - + spacing: 8 + Item { Layout.fillWidth: true } - + Button { text: "New" background: Rectangle { - color: parent.enabled ? (parent.hovered ? Qt.lighter(buttonColor, 1.2) : buttonColor) : Qt.darker(buttonColor, 1.5) - border.color: borderColor - border.width: 1 - radius: 3 - } - contentItem: Text { - text: parent.text - color: parent.enabled ? buttonTextColor : Qt.darker(buttonTextColor, 2.0) - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - } - onClicked: { - newMaterialDialog.open() + color: parent.hovered ? Qt.lighter(buttonColor, 1.2) : buttonColor + border.color: borderColor; border.width: 1; radius: 3 } + contentItem: Text { text: parent.text; color: buttonTextColor; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } + onClicked: newMaterialDialog.open() } - + Button { text: "Import" background: Rectangle { - color: parent.enabled ? (parent.hovered ? Qt.lighter(buttonColor, 1.2) : buttonColor) : Qt.darker(buttonColor, 1.5) - border.color: borderColor - border.width: 1 - radius: 3 - } - contentItem: Text { - text: parent.text - color: parent.enabled ? buttonTextColor : Qt.darker(buttonTextColor, 2.0) - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - } - onClicked: { - console.log("Import button clicked") - importMaterials() + color: parent.hovered ? Qt.lighter(buttonColor, 1.2) : buttonColor + border.color: borderColor; border.width: 1; radius: 3 } + contentItem: Text { text: parent.text; color: buttonTextColor; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } + onClicked: importMaterials() } - + Button { text: "Edit" - enabled: selectedIndex >= 0 + enabled: selectedMaterial !== "" background: Rectangle { - color: parent.enabled ? (parent.hovered ? Qt.lighter(buttonColor, 1.2) : buttonColor) : Qt.darker(buttonColor, 1.5) - border.color: borderColor - border.width: 1 - radius: 3 - } - contentItem: Text { - text: parent.text - color: parent.enabled ? buttonTextColor : Qt.darker(buttonTextColor, 2.0) - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter + color: parent.enabled ? (parent.hovered ? Qt.lighter(highlightColor, 1.2) : highlightColor) : Qt.darker(buttonColor, 1.5) + border.color: borderColor; border.width: 1; radius: 3 } + contentItem: Text { text: parent.text; color: parent.enabled ? "white" : disabledTextColor; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } onClicked: { - if (selectedIndex >= 0) { + if (selectedMaterial !== "") { editMaterial(selectedMaterial) materialListModal.close() } } } - + Button { text: "Export" - enabled: selectedIndex >= 0 + enabled: selectedMaterial !== "" background: Rectangle { color: parent.enabled ? (parent.hovered ? Qt.lighter(buttonColor, 1.2) : buttonColor) : Qt.darker(buttonColor, 1.5) - border.color: borderColor - border.width: 1 - radius: 3 - } - contentItem: Text { - text: parent.text - color: parent.enabled ? buttonTextColor : Qt.darker(buttonTextColor, 2.0) - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter + border.color: borderColor; border.width: 1; radius: 3 } + contentItem: Text { text: parent.text; color: parent.enabled ? buttonTextColor : disabledTextColor; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } onClicked: { - console.log("Export button clicked, selectedMaterial:", selectedMaterial) - if (selectedIndex >= 0) { - exportMaterial(selectedMaterial) - } + if (selectedMaterial !== "") exportMaterial(selectedMaterial) } } - + Item { Layout.fillWidth: true } } } - - // Note: Using C++ file dialogs instead of QML FileDialog for better compatibility - + // New Material Dialog Dialog { id: newMaterialDialog title: "New Material" modal: true anchors.centerIn: parent - + ColumnLayout { - Label { - text: "Material Name:" - color: textColor - } + Label { text: "Material Name:"; color: textColor } TextField { id: newMaterialNameField placeholderText: "Enter material name" - background: Rectangle { - color: panelColor - border.color: borderColor - border.width: 1 - radius: 3 - } + background: Rectangle { color: panelColor; border.color: borderColor; border.width: 1; radius: 3 } color: textColor placeholderTextColor: disabledTextColor } } - + standardButtons: Dialog.Ok | Dialog.Cancel onAccepted: { if (newMaterialNameField.text.trim() !== "") { - // Create the new material and open the editor MaterialEditorQML.createNewMaterial(newMaterialNameField.text) openMaterialEditor(newMaterialNameField.text) newMaterialNameField.text = "" materialListModal.close() - } else { - // Show error message for empty name - console.log("Material name cannot be empty") } } - onRejected: { - // Clear the field when cancelled - newMaterialNameField.text = "" - } + onRejected: newMaterialNameField.text = "" } - - // Functions - function refreshMaterialList() { - materialListModel.clear() - var materials = MaterialEditorQML.getMaterialList() - for (var i = 0; i < materials.length; i++) { - materialListModel.append({"name": materials[i]}) - } - } - + function openImportDialog() { - console.log("Opening material import dialog...") var selectedFile = MaterialEditorQML.openMaterialImportDialog() if (selectedFile !== "") { - console.log("Material file selected for import:", selectedFile) MaterialEditorQML.importMaterialFile(selectedFile) refreshMaterialList() - } else { - console.log("Import cancelled") } } - + function openExportDialog() { - console.log("Opening material export dialog...") if (selectedMaterial !== "") { var selectedFile = MaterialEditorQML.openMaterialExportDialog(selectedMaterial) if (selectedFile !== "") { - console.log("File selected for export:", selectedFile) MaterialEditorQML.exportMaterial(selectedFile, selectedMaterial) - } else { - console.log("Export cancelled") } - } else { - console.log("No material selected for export") } } - + function openMaterialEditor(materialName) { - // Use the C++ function to open the material editor - MaterialEditorQML.openMaterialEditorWindow(materialName); + MaterialEditorQML.openMaterialEditorWindow(materialName) } - - // Initialize on component completion + Component.onCompleted: { refreshMaterialList() - selectedIndex = -1 selectedMaterial = "" } - - // Connect signals + onImportMaterials: openImportDialog() onExportMaterial: openExportDialog() onCreateNewMaterial: newMaterialDialog.open() onEditMaterial: openMaterialEditor(materialName) - onMaterialSelected: { - // Just update selection, no action needed - } } diff --git a/qml/PreferencesDialog.qml b/qml/PreferencesDialog.qml new file mode 100644 index 000000000..833527a9e --- /dev/null +++ b/qml/PreferencesDialog.qml @@ -0,0 +1,457 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import PropertiesPanel 1.0 + +Rectangle { + id: root + color: backgroundColor + + SystemPalette { + id: palette + colorGroup: SystemPalette.Active + } + + property color backgroundColor: palette.window + property color panelColor: palette.base + property color textColor: palette.windowText + property color borderColor: palette.mid + property color highlightColor: palette.highlight + property color buttonColor: palette.button + property color buttonTextColor: palette.buttonText + property color dimTextColor: Qt.darker(textColor, 1.4) + property color inputBgColor: palette.base + + property int currentTab: 0 + + // Helper to read a setting with default + function readSetting(key, defaultVal) { + return PropertiesPanelController.getSetting(key, defaultVal); + } + + // Helper to write a setting + function writeSetting(key, val) { + PropertiesPanelController.setSetting(key, val); + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + // Header + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 46 + color: panelColor + + Text { + text: "Preferences" + font.pointSize: 14 + font.bold: true + color: textColor + anchors.centerIn: parent + } + + Rectangle { + anchors.bottom: parent.bottom + width: parent.width + height: 1 + color: borderColor + } + } + + // Tab bar + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 36 + color: Qt.lighter(backgroundColor, 1.02) + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + spacing: 2 + + Repeater { + model: ["General", "Appearance", "Viewport"] + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: currentTab === index ? highlightColor + : tabMouse.containsMouse ? Qt.lighter(panelColor, 1.5) + : Qt.darker(panelColor, 1.1) + border.color: borderColor; border.width: 1 + radius: 3 + Behavior on color { ColorAnimation { duration: 50 } } + + Text { + anchors.centerIn: parent + text: modelData + font.pixelSize: 11 + color: textColor + } + + MouseArea { + id: tabMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + currentTab = index + // Force repaint of all tabs + var m = parent.parent.parent.model + parent.parent.parent.model = null + parent.parent.parent.model = m + } + } + } + } + } + + Rectangle { + anchors.bottom: parent.bottom + width: parent.width + height: 1 + color: borderColor + } + } + + // Tab content + ScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + + Flickable { + contentWidth: parent.width + contentHeight: contentColumn.implicitHeight + + Column { + id: contentColumn + width: parent.width + spacing: 0 + padding: 16 + + // --- General Tab --- + Column { + width: parent.width - 32 + spacing: 12 + visible: currentTab === 0 + + // Recent files count + Column { + width: parent.width + spacing: 4 + + Text { + text: "Recent Files Count" + font.pixelSize: 12 + font.bold: true + color: textColor + } + + RowLayout { + spacing: 8 + + Rectangle { + width: 80 + height: 30 + color: inputBgColor + border.color: recentCountField.activeFocus ? highlightColor : borderColor + border.width: 1 + radius: 3 + + TextInput { + id: recentCountField + anchors.fill: parent + anchors.margins: 6 + verticalAlignment: TextInput.AlignVCenter + font.pixelSize: 12 + color: textColor + clip: true + selectByMouse: true + validator: IntValidator { bottom: 1; top: 50 } + text: readSetting("General/recentFilesCount", 10).toString() + + onEditingFinished: writeSetting("General/recentFilesCount", parseInt(text) || 10) + } + } + + Text { + text: "(1-50)" + font.pixelSize: 11 + color: dimTextColor + } + } + } + + // Telemetry opt-out (themed checkbox matching snap settings) + Row { + spacing: 6 + width: parent.width + + property bool telemetryOn: readSetting("Sentry/enabled", true) === true + || readSetting("Sentry/enabled", true) === "true" + + Rectangle { + width: 14; height: 14; anchors.verticalCenter: parent.verticalCenter + border.color: borderColor; border.width: 1; radius: 2 + color: parent.telemetryOn ? highlightColor : "transparent" + Text { anchors.centerIn: parent; text: parent.parent.telemetryOn ? "\u2713" : ""; color: "white"; font.pixelSize: 10 } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor + onClicked: { parent.parent.telemetryOn = !parent.parent.telemetryOn; writeSetting("Sentry/enabled", parent.parent.telemetryOn) } + } + } + Text { text: "Enable anonymous telemetry"; font.pixelSize: 12; color: textColor; anchors.verticalCenter: parent.verticalCenter } + } + + Text { + text: "Telemetry helps improve QtMeshEditor by sending anonymous usage data." + font.pixelSize: 11; font.italic: true; color: dimTextColor; wrapMode: Text.WordWrap; width: parent.width + } + + // Welcome screen toggle + Row { + spacing: 6 + width: parent.width + + property bool welcomeOn: !(readSetting("WelcomeScreen/dontShowAgain", false) === true + || readSetting("WelcomeScreen/dontShowAgain", false) === "true") + + Rectangle { + width: 14; height: 14; anchors.verticalCenter: parent.verticalCenter + border.color: borderColor; border.width: 1; radius: 2 + color: parent.welcomeOn ? highlightColor : "transparent" + Text { anchors.centerIn: parent; text: parent.parent.welcomeOn ? "\u2713" : ""; color: "white"; font.pixelSize: 10 } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor + onClicked: { parent.parent.welcomeOn = !parent.parent.welcomeOn; writeSetting("WelcomeScreen/dontShowAgain", !parent.parent.welcomeOn) } + } + } + Text { text: "Show welcome screen on startup"; font.pixelSize: 12; color: textColor; anchors.verticalCenter: parent.verticalCenter } + } + } + + // --- Appearance Tab --- + Column { + id: appearanceCol + width: parent.width - 32 + spacing: 12 + visible: currentTab === 1 + + property int activeThemeIdx: { + var saved = readSetting("palette", "dark") + if (saved === "light") return 0 + if (saved === "dark") return 1 + return 1 + } + + Column { + width: parent.width + spacing: 4 + + Text { + text: "Theme" + font.pixelSize: 12; font.bold: true; color: textColor + } + + Flow { + spacing: 3 + width: parent.width + + Repeater { + model: ["Light", "Dark"] + + Rectangle { + width: Math.max(60, (parent.width - 3) / 2) + height: 22; radius: 3 + color: index === appearanceCol.activeThemeIdx ? highlightColor + : themeBtnMa.containsMouse ? Qt.lighter(panelColor, 1.5) + : Qt.darker(panelColor, 1.1) + border.color: borderColor; border.width: 1 + Behavior on color { ColorAnimation { duration: 50 } } + + Text { anchors.centerIn: parent; text: modelData; color: textColor; font.pixelSize: 11 } + + MouseArea { + id: themeBtnMa; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor + onClicked: { + appearanceCol.activeThemeIdx = index + writeSetting("palette", modelData.toLowerCase()) + writeSetting("Appearance/theme", modelData) + } + } + } + } + } + + Text { + text: "Theme is applied immediately." + font.pixelSize: 11; font.italic: true; color: dimTextColor; topPadding: 4 + } + } + } + + // --- Viewport Tab --- + Column { + width: parent.width - 32 + spacing: 12 + visible: currentTab === 2 + + // Grid visibility (themed checkbox) + Row { + spacing: 6 + width: parent.width + + property bool gridOn: readSetting("Viewport/gridVisible", true) === true + || readSetting("Viewport/gridVisible", true) === "true" + + Rectangle { + width: 14; height: 14; anchors.verticalCenter: parent.verticalCenter + border.color: borderColor; border.width: 1; radius: 2 + color: parent.gridOn ? highlightColor : "transparent" + Text { anchors.centerIn: parent; text: parent.parent.gridOn ? "\u2713" : ""; color: "white"; font.pixelSize: 10 } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor + onClicked: { parent.parent.gridOn = !parent.parent.gridOn; writeSetting("Viewport/gridVisible", parent.parent.gridOn) } + } + } + Text { text: "Show Grid"; font.pixelSize: 12; color: textColor; anchors.verticalCenter: parent.verticalCenter } + } + + // Camera speed + Column { + width: parent.width + spacing: 4 + + Text { + text: "Default Camera Speed" + font.pixelSize: 12 + font.bold: true + color: textColor + } + + RowLayout { + width: parent.width + spacing: 8 + + Slider { + id: camSpeedSlider + Layout.fillWidth: true + from: 0.1 + to: 10.0 + stepSize: 0.1 + value: parseFloat(readSetting("Viewport/cameraSpeed", 1.0)) || 1.0 + onMoved: writeSetting("Viewport/cameraSpeed", value.toFixed(1)) + } + + Text { + text: camSpeedSlider.value.toFixed(1) + font.pixelSize: 12 + color: textColor + Layout.preferredWidth: 30 + horizontalAlignment: Text.AlignRight + } + } + } + + // Near clip + Column { + width: parent.width + spacing: 4 + + Text { + text: "Near Clip Distance" + font.pixelSize: 12 + font.bold: true + color: textColor + } + + Rectangle { + width: 120 + height: 30 + color: inputBgColor + border.color: nearClipField.activeFocus ? highlightColor : borderColor + border.width: 1 + radius: 3 + + TextInput { + id: nearClipField + anchors.fill: parent + anchors.margins: 6 + verticalAlignment: TextInput.AlignVCenter + font.pixelSize: 12 + color: textColor + clip: true + selectByMouse: true + validator: DoubleValidator { bottom: 0.001; top: 1000; decimals: 3 } + text: readSetting("Viewport/nearClip", "0.1") + + onEditingFinished: writeSetting("Viewport/nearClip", parseFloat(text) || 0.1) + } + } + } + + // Far clip + Column { + width: parent.width + spacing: 4 + + Text { + text: "Far Clip Distance" + font.pixelSize: 12 + font.bold: true + color: textColor + } + + Rectangle { + width: 120 + height: 30 + color: inputBgColor + border.color: farClipField.activeFocus ? highlightColor : borderColor + border.width: 1 + radius: 3 + + TextInput { + id: farClipField + anchors.fill: parent + anchors.margins: 6 + verticalAlignment: TextInput.AlignVCenter + font.pixelSize: 12 + color: textColor + clip: true + selectByMouse: true + validator: DoubleValidator { bottom: 1; top: 100000; decimals: 1 } + text: readSetting("Viewport/farClip", "10000") + + onEditingFinished: writeSetting("Viewport/farClip", parseFloat(text) || 10000) + } + } + } + } + + } + } + } + + // Footer + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 36 + color: panelColor + + Rectangle { + anchors.top: parent.top + width: parent.width + height: 1 + color: borderColor + } + + Text { + anchors.centerIn: parent + text: "Settings are saved automatically" + font.pixelSize: 11 + color: dimTextColor + } + } + } +} diff --git a/qml/ShortcutReference.qml b/qml/ShortcutReference.qml new file mode 100644 index 000000000..e17405805 --- /dev/null +++ b/qml/ShortcutReference.qml @@ -0,0 +1,322 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import PropertiesPanel 1.0 + +Rectangle { + id: root + color: backgroundColor + + SystemPalette { + id: palette + colorGroup: SystemPalette.Active + } + + property color backgroundColor: palette.window + property color panelColor: palette.base + property color textColor: palette.windowText + property color borderColor: palette.mid + property color highlightColor: palette.highlight + property color buttonColor: palette.button + property color buttonTextColor: palette.buttonText + property color dimTextColor: Qt.darker(textColor, 1.4) + property color keyBgColor: Qt.lighter(panelColor, 1.15) + + property string searchText: "" + property var shortcutData: PropertiesPanelController.shortcutData() + + function matchesSearch(entry) { + if (searchText.length === 0) return true; + var lower = searchText.toLowerCase(); + return entry.key.toLowerCase().indexOf(lower) >= 0 + || entry.description.toLowerCase().indexOf(lower) >= 0; + } + + function categoryHasMatches(category) { + for (var i = 0; i < shortcutData.length; i++) { + if (shortcutData[i].category === category && matchesSearch(shortcutData[i])) + return true; + } + return false; + } + + function uniqueCategories() { + var seen = {}; + var cats = []; + for (var i = 0; i < shortcutData.length; i++) { + var c = shortcutData[i].category; + if (!seen[c]) { + seen[c] = true; + cats.push(c); + } + } + return cats; + } + + function entriesForCategory(category) { + var result = []; + for (var i = 0; i < shortcutData.length; i++) { + if (shortcutData[i].category === category && matchesSearch(shortcutData[i])) + result.push(shortcutData[i]); + } + return result; + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + // Header + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 46 + color: panelColor + + Text { + text: "Keyboard Shortcuts" + font.pointSize: 14 + font.bold: true + color: textColor + anchors.centerIn: parent + } + + Rectangle { + anchors.bottom: parent.bottom + width: parent.width + height: 1 + color: borderColor + } + } + + // Search bar + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 36 + Layout.leftMargin: 12 + Layout.rightMargin: 12 + Layout.topMargin: 8 + Layout.bottomMargin: 4 + color: panelColor + border.color: searchField.activeFocus ? highlightColor : borderColor + border.width: 1 + radius: 4 + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + spacing: 6 + + Text { + text: "\u2315" + font.pixelSize: 16 + color: dimTextColor + Layout.alignment: Qt.AlignVCenter + } + + TextInput { + id: searchField + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + font.pixelSize: 13 + color: textColor + clip: true + selectByMouse: true + onTextChanged: root.searchText = text + + Text { + anchors.fill: parent + text: "Search shortcuts..." + color: dimTextColor + font.pixelSize: 13 + visible: !searchField.text && !searchField.activeFocus + verticalAlignment: Text.AlignVCenter + } + } + + Text { + text: "\u2715" + font.pixelSize: 12 + color: clearMouse.containsMouse ? textColor : dimTextColor + visible: searchField.text.length > 0 + Layout.alignment: Qt.AlignVCenter + + MouseArea { + id: clearMouse + anchors.fill: parent + anchors.margins: -4 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + searchField.text = ""; + searchField.forceActiveFocus(); + } + } + } + } + } + + // Shortcut list + ScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + + Flickable { + contentWidth: parent.width + contentHeight: categoriesColumn.implicitHeight + + Column { + id: categoriesColumn + width: parent.width + spacing: 4 + + Repeater { + model: uniqueCategories() + + Column { + width: categoriesColumn.width + visible: categoryHasMatches(modelData) + spacing: 0 + + // Category header + Rectangle { + width: parent.width + height: 30 + color: Qt.lighter(backgroundColor, 1.05) + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 14 + anchors.rightMargin: 14 + spacing: 6 + + Text { + text: modelData + font.pixelSize: 12 + font.bold: true + font.capitalization: Font.AllUppercase + color: highlightColor + Layout.fillWidth: true + } + } + } + + // Entries for this category + Repeater { + model: entriesForCategory(modelData) + + Rectangle { + width: categoriesColumn.width + height: 32 + color: entryMouse.containsMouse ? Qt.lighter(backgroundColor, 1.08) : "transparent" + + MouseArea { + id: entryMouse + anchors.fill: parent + hoverEnabled: true + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 20 + anchors.rightMargin: 14 + spacing: 12 + + Text { + text: modelData.description + font.pixelSize: 12 + color: textColor + Layout.fillWidth: true + elide: Text.ElideRight + } + + // Key badge(s) + Row { + spacing: 4 + Layout.alignment: Qt.AlignRight + + Repeater { + model: modelData.key.split("+") + + Rectangle { + width: Math.max(keyLabel.implicitWidth + 12, 28) + height: 22 + radius: 3 + color: keyBgColor + border.color: borderColor + border.width: 1 + + Text { + id: keyLabel + anchors.centerIn: parent + text: modelData.trim() + font.pixelSize: 11 + font.family: "monospace" + color: textColor + } + } + } + } + } + + // Subtle separator line + Rectangle { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 20 + anchors.rightMargin: 14 + height: 1 + color: Qt.lighter(borderColor, 1.3) + opacity: 0.4 + } + } + } + } + } + + // No results message + Text { + visible: { + var cats = uniqueCategories(); + for (var i = 0; i < cats.length; i++) { + if (categoryHasMatches(cats[i])) return false; + } + return true; + } + text: "No shortcuts match your search." + font.pixelSize: 13 + font.italic: true + color: dimTextColor + anchors.horizontalCenter: parent.horizontalCenter + topPadding: 30 + } + } + } + } + + // Footer + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 36 + color: panelColor + + Rectangle { + anchors.top: parent.top + width: parent.width + height: 1 + color: borderColor + } + + Text { + anchors.centerIn: parent + text: "Press Ctrl+/ to toggle | Esc to close" + font.pixelSize: 11 + color: dimTextColor + } + } + } + + Component.onCompleted: searchField.forceActiveFocus() +} diff --git a/qml/WelcomeScreen.qml b/qml/WelcomeScreen.qml new file mode 100644 index 000000000..60fd2fcd0 --- /dev/null +++ b/qml/WelcomeScreen.qml @@ -0,0 +1,406 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import WelcomeScreen 1.0 +import PropertiesPanel 1.0 + +Rectangle { + id: root + color: "#c0000000" // semi-transparent dark overlay + visible: WelcomeScreenController.visible + + // Click on the overlay background dismisses the welcome screen + MouseArea { + anchors.fill: parent + onClicked: WelcomeScreenController.dismiss(dontShowCheckbox.checked) + } + + // Centered card + Rectangle { + id: card + anchors.centerIn: parent + width: Math.min(Math.max(parent.width - 60, 200), 560) + height: Math.min(cardLayout.implicitHeight + 48, Math.max(parent.height - 40, 200)) + radius: 12 + color: PropertiesPanelController.panelColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + + // Prevent clicks on the card from dismissing + MouseArea { + anchors.fill: parent + onClicked: {} // absorb click + } + + Flickable { + anchors { + fill: parent; topMargin: 24; bottomMargin: 24 + leftMargin: 28; rightMargin: 28 + } + contentHeight: cardLayout.implicitHeight + clip: true + flickableDirection: Flickable.VerticalFlick + boundsBehavior: Flickable.StopAtBounds + + ColumnLayout { + id: cardLayout + width: parent.width + spacing: 16 + + // ---- Header: Title ---- + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + Layout.alignment: Qt.AlignHCenter + + Text { + text: "QtMeshEditor" + color: PropertiesPanelController.textColor + font.pixelSize: 22 + font.bold: true + Layout.alignment: Qt.AlignHCenter + } + + Text { + text: "3D Mesh Editor & Converter" + color: Qt.darker(PropertiesPanelController.textColor, 1.4) + font.pixelSize: 12 + Layout.alignment: Qt.AlignHCenter + } + } + + // ---- Separator ---- + Rectangle { + Layout.fillWidth: true + height: 1 + color: PropertiesPanelController.borderColor + } + + // ---- Quick Start Buttons ---- + RowLayout { + Layout.fillWidth: true + spacing: 10 + + // New Scene button + Rectangle { + Layout.fillWidth: true + height: 40 + radius: 6 + color: newSceneMA.containsMouse + ? Qt.lighter(PropertiesPanelController.highlightColor, 1.1) + : PropertiesPanelController.highlightColor + Text { + anchors.centerIn: parent + text: "New Scene" + color: "#ffffff" + font.pixelSize: 13 + font.bold: true + } + MouseArea { + id: newSceneMA + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: WelcomeScreenController.newScene() + } + } + + // Open File button + Rectangle { + Layout.fillWidth: true + height: 40 + radius: 6 + color: openFileMA.containsMouse + ? Qt.lighter(PropertiesPanelController.inputColor, 1.3) + : PropertiesPanelController.inputColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + Text { + anchors.centerIn: parent + text: "Open File..." + color: PropertiesPanelController.textColor + font.pixelSize: 13 + font.bold: true + } + MouseArea { + id: openFileMA + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: WelcomeScreenController.openFileDialog() + } + } + } + + // ---- Recent Files ---- + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + visible: WelcomeScreenController.recentFiles.length > 0 + + Text { + text: "Recent Files" + color: Qt.darker(PropertiesPanelController.textColor, 1.3) + font.pixelSize: 11 + font.bold: true + font.capitalization: Font.AllUppercase + } + + Repeater { + model: WelcomeScreenController.recentFiles + + Rectangle { + Layout.fillWidth: true + height: 30 + radius: 4 + color: recentMA.containsMouse + ? Qt.lighter(PropertiesPanelController.headerColor, 1.2) + : "transparent" + + RowLayout { + anchors { + fill: parent + leftMargin: 8 + rightMargin: 8 + } + spacing: 6 + + Text { + text: WelcomeScreenController.recentFileNames[index] + color: PropertiesPanelController.textColor + font.pixelSize: 12 + elide: Text.ElideMiddle + Layout.fillWidth: true + } + Text { + text: modelData + color: Qt.darker(PropertiesPanelController.textColor, 1.6) + font.pixelSize: 10 + elide: Text.ElideLeft + Layout.maximumWidth: 200 + } + } + + MouseArea { + id: recentMA + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: WelcomeScreenController.openFile(modelData) + } + } + } + } + + // ---- Separator ---- + Rectangle { + Layout.fillWidth: true + height: 1 + color: PropertiesPanelController.borderColor + } + + // ---- Feature Tips ---- + Text { + text: "Quick Tips" + color: Qt.darker(PropertiesPanelController.textColor, 1.3) + font.pixelSize: 11 + font.bold: true + font.capitalization: Font.AllUppercase + } + + GridLayout { + Layout.fillWidth: true + columns: 3 + columnSpacing: 10 + rowSpacing: 8 + + // Tip 1: AI Chat + Rectangle { + Layout.fillWidth: true + implicitHeight: tipCol1.implicitHeight + 16 + radius: 6 + color: PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + + ColumnLayout { + id: tipCol1 + anchors { + left: parent.left; right: parent.right + top: parent.top + margins: 8 + } + spacing: 2 + + Text { + text: "\u2728 AI Chat" + color: PropertiesPanelController.textColor + font.pixelSize: 12 + font.bold: true + } + Text { + text: "Describe changes in natural language. Open from the toolbar or AI menu." + color: Qt.darker(PropertiesPanelController.textColor, 1.4) + font.pixelSize: 10 + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + } + } + + // Tip 2: Keyboard Shortcuts + Rectangle { + Layout.fillWidth: true + implicitHeight: tipCol2.implicitHeight + 16 + radius: 6 + color: PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + + ColumnLayout { + id: tipCol2 + anchors { + left: parent.left; right: parent.right + top: parent.top + margins: 8 + } + spacing: 2 + + Text { + text: "\u2328 Shortcuts" + color: PropertiesPanelController.textColor + font.pixelSize: 12 + font.bold: true + } + Text { + text: "Q Select, W Move, E Rotate, R Scale, F Frame, X Toggle space." + color: Qt.darker(PropertiesPanelController.textColor, 1.4) + font.pixelSize: 10 + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + } + } + + // Tip 3: CLI Pipeline + Rectangle { + Layout.fillWidth: true + implicitHeight: tipCol3.implicitHeight + 16 + radius: 6 + color: PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + + ColumnLayout { + id: tipCol3 + anchors { + left: parent.left; right: parent.right + top: parent.top + margins: 8 + } + spacing: 2 + + Text { + text: "> CLI Pipeline" + color: PropertiesPanelController.textColor + font.pixelSize: 12 + font.bold: true + } + Text { + text: "Use 'qtmesh' for batch ops: convert, validate, LOD, animations." + color: Qt.darker(PropertiesPanelController.textColor, 1.4) + font.pixelSize: 10 + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + } + } + } + + // ---- Separator ---- + Rectangle { + Layout.fillWidth: true + height: 1 + color: PropertiesPanelController.borderColor + } + + // ---- Don't show again + Dismiss ---- + RowLayout { + Layout.fillWidth: true + spacing: 12 + + CheckBox { + id: dontShowCheckbox + text: "Don't show again" + checked: false + + contentItem: Text { + text: dontShowCheckbox.text + color: Qt.darker(PropertiesPanelController.textColor, 1.3) + font.pixelSize: 11 + leftPadding: dontShowCheckbox.indicator.width + 6 + verticalAlignment: Text.AlignVCenter + } + + indicator: Rectangle { + implicitWidth: 16 + implicitHeight: 16 + x: dontShowCheckbox.leftPadding + y: (dontShowCheckbox.height - height) / 2 + radius: 3 + color: PropertiesPanelController.inputColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + + Text { + anchors.centerIn: parent + text: "\u2713" + color: PropertiesPanelController.textColor + font.pixelSize: 12 + visible: dontShowCheckbox.checked + } + } + } + + Item { Layout.fillWidth: true } + + Rectangle { + width: 90 + height: 28 + radius: 4 + color: dismissMA.containsMouse + ? Qt.lighter(PropertiesPanelController.inputColor, 1.3) + : PropertiesPanelController.inputColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + + Text { + anchors.centerIn: parent + text: "Get Started" + color: PropertiesPanelController.textColor + font.pixelSize: 12 + } + MouseArea { + id: dismissMA + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: WelcomeScreenController.dismiss(dontShowCheckbox.checked) + } + } + } + + // Bottom spacer for padding + Item { height: 4 } + } + } // Flickable + } + + // Dismiss on Escape key + Keys.onEscapePressed: WelcomeScreenController.dismiss(dontShowCheckbox.checked) + + // Grab focus when visible so Escape works + onVisibleChanged: { + if (visible) forceActiveFocus() + } +} diff --git a/src/AssetBrowserController.cpp b/src/AssetBrowserController.cpp new file mode 100644 index 000000000..e5bf7f8c6 --- /dev/null +++ b/src/AssetBrowserController.cpp @@ -0,0 +1,311 @@ +#include "AssetBrowserController.h" +#include "MaterialPreviewRenderer.h" +#include "SentryReporter.h" + +#include +#include +#include +#include +#include +#include + +AssetBrowserController* AssetBrowserController::m_pSingleton = nullptr; + +const QStringList AssetBrowserController::s_meshExtensions = { + "fbx", "gltf", "glb", "gltf2", "obj", "dae", "stl", "mesh", "3ds", "blend", "ply", + "x", "x3d", "lwo", "lws", "ac", "ms3d", "cob", "scn", "bvh", "irrmesh", "irr", + "mdl", "md2", "md3", "md5mesh", "smd", "ogex", "b3d", "q3d", "nff", "off", + "raw", "ter", "hmp", "assbin", "mesh.xml" +}; + +const QStringList AssetBrowserController::s_textureExtensions = { + "png", "jpg", "jpeg", "tga", "bmp", "dds", "hdr", "exr", "tif", "tiff" +}; + +const QStringList AssetBrowserController::s_materialExtensions = { + "material" +}; + +AssetBrowserController::AssetBrowserController() + : QObject(nullptr) + , m_watcher(new QFileSystemWatcher(this)) +{ + // Restore last browsed directory from QSettings + QSettings settings; + QString savedPath = settings.value("AssetBrowser/rootPath").toString(); + if (!savedPath.isEmpty() && QDir(savedPath).exists()) { + m_rootPath = savedPath; + } else { + m_rootPath = QStandardPaths::writableLocation(QStandardPaths::HomeLocation); + } + + connect(m_watcher, &QFileSystemWatcher::directoryChanged, this, [this](const QString&) { + refreshFiles(); + }); + + setupWatcher(); + refreshFiles(); +} + +AssetBrowserController::~AssetBrowserController() +{ + // m_watcher is a child QObject, cleaned up automatically +} + +AssetBrowserController* AssetBrowserController::instance() +{ + if (!m_pSingleton) + m_pSingleton = new AssetBrowserController(); + return m_pSingleton; +} + +AssetBrowserController* AssetBrowserController::qmlInstance(QQmlEngine* engine, QJSEngine* /*scriptEngine*/) +{ + auto* inst = instance(); + engine->setObjectOwnership(inst, QQmlEngine::CppOwnership); + return inst; +} + +void AssetBrowserController::kill() +{ + delete m_pSingleton; + m_pSingleton = nullptr; +} + +QString AssetBrowserController::rootPath() const +{ + return m_rootPath; +} + +void AssetBrowserController::setRootPath(const QString& path) +{ + if (m_rootPath == path) + return; + + QDir dir(path); + if (!dir.exists()) + return; + + m_rootPath = dir.absolutePath(); + + SentryReporter::addBreadcrumb("ui.action", "Root directory changed: " + m_rootPath); + + // Persist to settings + QSettings settings; + settings.setValue("AssetBrowser/rootPath", m_rootPath); + + emit rootPathChanged(); + setupWatcher(); + refreshFiles(); +} + +QVariantList AssetBrowserController::files() const +{ + return m_files; +} + +QString AssetBrowserController::filter() const +{ + return m_filter; +} + +void AssetBrowserController::setFilter(const QString& filter) +{ + if (m_filter == filter) + return; + m_filter = filter; + emit filterChanged(); + refreshFiles(); +} + +QString AssetBrowserController::searchQuery() const +{ + return m_searchQuery; +} + +void AssetBrowserController::setSearchQuery(const QString& query) +{ + if (m_searchQuery == query) + return; + m_searchQuery = query; + emit searchQueryChanged(); + refreshFiles(); +} + +void AssetBrowserController::browseForDirectory() +{ + SentryReporter::addBreadcrumb("ui.action", "Browse for directory requested"); + emit browseRequested(); +} + +void AssetBrowserController::openFile(const QString& path) +{ + QFileInfo fi(path); + if (!fi.exists()) + return; + + if (fi.isDir()) { + SentryReporter::addBreadcrumb("ui.action", + QString("Navigate into directory: %1").arg(fi.fileName())); + navigateToDirectory(path); + return; + } + + QString type = classifyExtension(fi.suffix().toLower()); + + SentryReporter::addBreadcrumb("ui.action", + QString("Open file: %1 (type: %2)").arg(fi.fileName(), type)); + + if (type == "mesh") { + emit importMeshRequested(QStringList{path}); + } + // Texture and material handling can be added as stretch goals +} + +void AssetBrowserController::navigateToDirectory(const QString& path) +{ + setRootPath(path); +} + +void AssetBrowserController::navigateUp() +{ + QDir dir(m_rootPath); + if (dir.cdUp()) { + setRootPath(dir.absolutePath()); + } +} + +QString AssetBrowserController::fileTypeForPath(const QString& path) const +{ + QFileInfo fi(path); + if (fi.isDir()) + return "directory"; + return classifyExtension(fi.suffix().toLower()); +} + +void AssetBrowserController::refreshFiles() +{ + m_files.clear(); + + QDir dir(m_rootPath); + if (!dir.exists()) { + emit filesChanged(); + return; + } + + // List directories first, then files + QFileInfoList entries = dir.entryInfoList( + QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot, + QDir::DirsFirst | QDir::Name | QDir::IgnoreCase); + + for (const QFileInfo& fi : entries) { + QString type; + if (fi.isDir()) { + type = "directory"; + } else { + type = classifyExtension(fi.suffix().toLower()); + } + + // Apply filter + if (m_filter != "all") { + if (fi.isDir()) { + // Always show directories regardless of filter + } else if (m_filter == "meshes" && type != "mesh") { + continue; + } else if (m_filter == "textures" && type != "texture") { + continue; + } else if (m_filter == "materials" && type != "material") { + continue; + } + } + + // Apply search query + if (!m_searchQuery.isEmpty()) { + if (!fi.fileName().contains(m_searchQuery, Qt::CaseInsensitive)) + continue; + } + + QVariantMap entry; + entry["name"] = fi.fileName(); + entry["path"] = fi.absoluteFilePath(); + entry["type"] = type; + entry["size"] = fi.isDir() ? 0 : fi.size(); + entry["isDir"] = fi.isDir(); + + // Generate a material preview for .material files + if (type == "material") { + QString previewUrl = materialPreview(fi.absoluteFilePath()); + if (!previewUrl.isEmpty()) + entry["previewUrl"] = previewUrl; + } + + m_files.append(entry); + } + + emit filesChanged(); +} + +void AssetBrowserController::setupWatcher() +{ + // Remove old watched directories + QStringList watched = m_watcher->directories(); + if (!watched.isEmpty()) + m_watcher->removePaths(watched); + + // Watch the current root path + if (!m_rootPath.isEmpty() && QDir(m_rootPath).exists()) + m_watcher->addPath(m_rootPath); +} + +QString AssetBrowserController::materialPreview(const QString& filePath) const +{ + // Parse the .material file for the first material name + QString matName = MaterialPreviewRenderer::firstMaterialNameInFile(filePath); + if (matName.isEmpty()) + return {}; + + // Try to load the material if it doesn't already exist in Ogre + auto* matMgr = Ogre::MaterialManager::getSingletonPtr(); + if (!matMgr) + return {}; + + if (!matMgr->resourceExists(matName.toStdString())) { + QFileInfo fi(filePath); + try { + // Register the material's directory so Ogre can find referenced textures + Ogre::ResourceGroupManager::getSingleton().addResourceLocation( + fi.absolutePath().toStdString(), "FileSystem", + Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME, false); + } catch (...) {} + + // Parse the .material script via Ogre's script compiler + try { + std::string scriptContent; + QFile f(filePath); + if (f.open(QIODevice::ReadOnly | QIODevice::Text)) { + scriptContent = f.readAll().toStdString(); + f.close(); + } + if (!scriptContent.empty()) { + Ogre::DataStreamPtr ds(new Ogre::MemoryDataStream( + const_cast(scriptContent.c_str()), + scriptContent.size(), false)); + Ogre::ScriptCompilerManager::getSingleton().parseScript( + ds, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + } + } catch (...) {} + } + + return MaterialPreviewRenderer::instance()->renderPreviewAsDataUri(matName); +} + +QString AssetBrowserController::classifyExtension(const QString& ext) const +{ + if (s_meshExtensions.contains(ext, Qt::CaseInsensitive)) + return "mesh"; + if (s_textureExtensions.contains(ext, Qt::CaseInsensitive)) + return "texture"; + if (s_materialExtensions.contains(ext, Qt::CaseInsensitive)) + return "material"; + return "other"; +} diff --git a/src/AssetBrowserController.h b/src/AssetBrowserController.h new file mode 100644 index 000000000..3c98ebcd8 --- /dev/null +++ b/src/AssetBrowserController.h @@ -0,0 +1,94 @@ +#ifndef ASSET_BROWSER_CONTROLLER_H +#define ASSET_BROWSER_CONTROLLER_H + +#include +#include +#include +#include + +/** + * @brief QML_SINGLETON that drives the Asset Browser panel. + * + * Exposes a filtered file list from a configurable root directory, watches for + * filesystem changes, and provides actions to load meshes or apply textures. + * The last browsed directory is persisted via QSettings. + */ +class AssetBrowserController : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY(QString rootPath READ rootPath WRITE setRootPath NOTIFY rootPathChanged) + Q_PROPERTY(QVariantList files READ files NOTIFY filesChanged) + Q_PROPERTY(QString filter READ filter WRITE setFilter NOTIFY filterChanged) + Q_PROPERTY(QString searchQuery READ searchQuery WRITE setSearchQuery NOTIFY searchQueryChanged) + +public: + static AssetBrowserController* instance(); + static AssetBrowserController* qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine); + static void kill(); + + QString rootPath() const; + void setRootPath(const QString& path); + + QVariantList files() const; + + QString filter() const; + void setFilter(const QString& filter); + + QString searchQuery() const; + void setSearchQuery(const QString& query); + + /// Open a native directory picker and set rootPath to the chosen folder. + Q_INVOKABLE void browseForDirectory(); + + /// Load/import the given file (mesh import or texture apply). + Q_INVOKABLE void openFile(const QString& path); + + /// Navigate into a subdirectory. + Q_INVOKABLE void navigateToDirectory(const QString& path); + + /// Navigate up one directory level. + Q_INVOKABLE void navigateUp(); + + /// Returns the file-type category for a given file extension. + Q_INVOKABLE QString fileTypeForPath(const QString& path) const; + + /// Returns a data-URI preview image for a material file, or "" if unavailable. + Q_INVOKABLE QString materialPreview(const QString& filePath) const; + +signals: + void rootPathChanged(); + void filesChanged(); + void filterChanged(); + void searchQueryChanged(); + + /// Emitted when a mesh file should be imported (handled by MainWindow). + void importMeshRequested(const QStringList& paths); + + /// Emitted when the user clicks "Browse..." so MainWindow opens a native dialog. + void browseRequested(); + +private: + AssetBrowserController(); + ~AssetBrowserController() override; + + static AssetBrowserController* m_pSingleton; + + void refreshFiles(); + void setupWatcher(); + QString classifyExtension(const QString& ext) const; + + QString m_rootPath; + QString m_filter = "all"; // "all", "meshes", "textures", "materials" + QString m_searchQuery; + QVariantList m_files; + QFileSystemWatcher* m_watcher = nullptr; + + static const QStringList s_meshExtensions; + static const QStringList s_textureExtensions; + static const QStringList s_materialExtensions; +}; + +#endif // ASSET_BROWSER_CONTROLLER_H diff --git a/src/AssetBrowserController_test.cpp b/src/AssetBrowserController_test.cpp new file mode 100644 index 000000000..aaa59e0cf --- /dev/null +++ b/src/AssetBrowserController_test.cpp @@ -0,0 +1,388 @@ +#include +#include "AssetBrowserController.h" +#include +#include +#include +#include +#include +#include +#include + +class AssetBrowserControllerTests : public ::testing::Test { +protected: + QApplication* app = nullptr; + + void SetUp() override { + app = qobject_cast(QCoreApplication::instance()); + ASSERT_NE(app, nullptr); + } + + void TearDown() override { + AssetBrowserController::kill(); + } +}; + +TEST_F(AssetBrowserControllerTests, Singleton) { + auto* abc = AssetBrowserController::instance(); + ASSERT_NE(abc, nullptr); + EXPECT_EQ(abc, AssetBrowserController::instance()); +} + +TEST_F(AssetBrowserControllerTests, QmlInstanceReturnsSameAsInstance) { + auto* abc1 = AssetBrowserController::instance(); + auto* abc2 = AssetBrowserController::qmlInstance(nullptr, nullptr); + EXPECT_EQ(abc1, abc2); +} + +TEST_F(AssetBrowserControllerTests, KillAndRecreate) { + auto* abc1 = AssetBrowserController::instance(); + ASSERT_NE(abc1, nullptr); + + AssetBrowserController::kill(); + + auto* abc2 = AssetBrowserController::instance(); + ASSERT_NE(abc2, nullptr); + EXPECT_FALSE(abc2->rootPath().isEmpty()); +} + +TEST_F(AssetBrowserControllerTests, DefaultRootPathIsValid) { + auto* abc = AssetBrowserController::instance(); + ASSERT_NE(abc, nullptr); + EXPECT_FALSE(abc->rootPath().isEmpty()); + EXPECT_TRUE(QDir(abc->rootPath()).exists()); +} + +TEST_F(AssetBrowserControllerTests, SetRootPathEmitsSignal) { + auto* abc = AssetBrowserController::instance(); + QSignalSpy spy(abc, &AssetBrowserController::rootPathChanged); + + QTemporaryDir tmpDir; + ASSERT_TRUE(tmpDir.isValid()); + + abc->setRootPath(tmpDir.path()); + EXPECT_EQ(spy.count(), 1); + EXPECT_EQ(abc->rootPath(), tmpDir.path()); +} + +TEST_F(AssetBrowserControllerTests, SetRootPathIgnoresNonexistent) { + auto* abc = AssetBrowserController::instance(); + QString original = abc->rootPath(); + QSignalSpy spy(abc, &AssetBrowserController::rootPathChanged); + + abc->setRootPath("/this/path/does/not/exist/at/all"); + EXPECT_EQ(spy.count(), 0); + EXPECT_EQ(abc->rootPath(), original); +} + +TEST_F(AssetBrowserControllerTests, SetRootPathSameValueNoSignal) { + auto* abc = AssetBrowserController::instance(); + QSignalSpy spy(abc, &AssetBrowserController::rootPathChanged); + + abc->setRootPath(abc->rootPath()); + EXPECT_EQ(spy.count(), 0); +} + +TEST_F(AssetBrowserControllerTests, FilterDefaultIsAll) { + auto* abc = AssetBrowserController::instance(); + EXPECT_EQ(abc->filter(), "all"); +} + +TEST_F(AssetBrowserControllerTests, SetFilterEmitsSignal) { + auto* abc = AssetBrowserController::instance(); + QSignalSpy spy(abc, &AssetBrowserController::filterChanged); + + abc->setFilter("meshes"); + EXPECT_EQ(spy.count(), 1); + EXPECT_EQ(abc->filter(), "meshes"); +} + +TEST_F(AssetBrowserControllerTests, SetFilterSameValueNoSignal) { + auto* abc = AssetBrowserController::instance(); + QSignalSpy spy(abc, &AssetBrowserController::filterChanged); + + abc->setFilter("all"); // already the default + EXPECT_EQ(spy.count(), 0); +} + +TEST_F(AssetBrowserControllerTests, SearchQueryEmitsSignal) { + auto* abc = AssetBrowserController::instance(); + QSignalSpy spy(abc, &AssetBrowserController::searchQueryChanged); + + abc->setSearchQuery("test"); + EXPECT_EQ(spy.count(), 1); + EXPECT_EQ(abc->searchQuery(), "test"); +} + +TEST_F(AssetBrowserControllerTests, FilesListFromTempDir) { + QTemporaryDir tmpDir; + ASSERT_TRUE(tmpDir.isValid()); + + // Create some test files + QFile(tmpDir.path() + "/model.fbx").open(QIODevice::WriteOnly); + QFile(tmpDir.path() + "/texture.png").open(QIODevice::WriteOnly); + QFile(tmpDir.path() + "/mat.material").open(QIODevice::WriteOnly); + QFile(tmpDir.path() + "/readme.txt").open(QIODevice::WriteOnly); + QDir(tmpDir.path()).mkdir("subdir"); + + auto* abc = AssetBrowserController::instance(); + abc->setRootPath(tmpDir.path()); + + QVariantList files = abc->files(); + EXPECT_EQ(files.size(), 5); // 1 dir + 4 files +} + +TEST_F(AssetBrowserControllerTests, FilterMeshesOnly) { + QTemporaryDir tmpDir; + ASSERT_TRUE(tmpDir.isValid()); + + QFile(tmpDir.path() + "/model.fbx").open(QIODevice::WriteOnly); + QFile(tmpDir.path() + "/texture.png").open(QIODevice::WriteOnly); + QDir(tmpDir.path()).mkdir("subdir"); + + auto* abc = AssetBrowserController::instance(); + abc->setRootPath(tmpDir.path()); + abc->setFilter("meshes"); + + QVariantList files = abc->files(); + // Should include the directory + the mesh file, not the texture + int meshCount = 0; + int dirCount = 0; + for (const QVariant& v : files) { + QVariantMap m = v.toMap(); + if (m["isDir"].toBool()) dirCount++; + else if (m["type"].toString() == "mesh") meshCount++; + } + EXPECT_EQ(meshCount, 1); + EXPECT_EQ(dirCount, 1); // directories always shown +} + +TEST_F(AssetBrowserControllerTests, FilterTexturesOnly) { + QTemporaryDir tmpDir; + ASSERT_TRUE(tmpDir.isValid()); + + QFile(tmpDir.path() + "/model.fbx").open(QIODevice::WriteOnly); + QFile(tmpDir.path() + "/texture.png").open(QIODevice::WriteOnly); + QFile(tmpDir.path() + "/another.jpg").open(QIODevice::WriteOnly); + + auto* abc = AssetBrowserController::instance(); + abc->setRootPath(tmpDir.path()); + abc->setFilter("textures"); + + int textureCount = 0; + for (const QVariant& v : abc->files()) { + QVariantMap m = v.toMap(); + if (!m["isDir"].toBool() && m["type"].toString() == "texture") + textureCount++; + } + EXPECT_EQ(textureCount, 2); +} + +TEST_F(AssetBrowserControllerTests, SearchQueryFiltersFiles) { + QTemporaryDir tmpDir; + ASSERT_TRUE(tmpDir.isValid()); + + QFile(tmpDir.path() + "/player.fbx").open(QIODevice::WriteOnly); + QFile(tmpDir.path() + "/enemy.fbx").open(QIODevice::WriteOnly); + QFile(tmpDir.path() + "/player_diffuse.png").open(QIODevice::WriteOnly); + + auto* abc = AssetBrowserController::instance(); + abc->setRootPath(tmpDir.path()); + abc->setSearchQuery("player"); + + QVariantList files = abc->files(); + EXPECT_EQ(files.size(), 2); // player.fbx and player_diffuse.png +} + +TEST_F(AssetBrowserControllerTests, NavigateUp) { + QTemporaryDir tmpDir; + ASSERT_TRUE(tmpDir.isValid()); + + QDir(tmpDir.path()).mkdir("child"); + QString childPath = tmpDir.path() + "/child"; + + auto* abc = AssetBrowserController::instance(); + abc->setRootPath(childPath); + EXPECT_EQ(abc->rootPath(), childPath); + + abc->navigateUp(); + EXPECT_EQ(abc->rootPath(), tmpDir.path()); +} + +TEST_F(AssetBrowserControllerTests, NavigateToDirectory) { + QTemporaryDir tmpDir; + ASSERT_TRUE(tmpDir.isValid()); + + QDir(tmpDir.path()).mkdir("subdir"); + QString subdirPath = tmpDir.path() + "/subdir"; + + auto* abc = AssetBrowserController::instance(); + abc->setRootPath(tmpDir.path()); + + abc->navigateToDirectory(subdirPath); + EXPECT_EQ(abc->rootPath(), subdirPath); +} + +TEST_F(AssetBrowserControllerTests, FileTypeClassification) { + auto* abc = AssetBrowserController::instance(); + EXPECT_EQ(abc->fileTypeForPath("/foo/bar.fbx"), "mesh"); + EXPECT_EQ(abc->fileTypeForPath("/foo/bar.gltf"), "mesh"); + EXPECT_EQ(abc->fileTypeForPath("/foo/bar.obj"), "mesh"); + EXPECT_EQ(abc->fileTypeForPath("/foo/bar.png"), "texture"); + EXPECT_EQ(abc->fileTypeForPath("/foo/bar.jpg"), "texture"); + EXPECT_EQ(abc->fileTypeForPath("/foo/bar.tga"), "texture"); + EXPECT_EQ(abc->fileTypeForPath("/foo/bar.material"), "material"); + EXPECT_EQ(abc->fileTypeForPath("/foo/bar.txt"), "other"); +} + +TEST_F(AssetBrowserControllerTests, OpenFileMeshEmitsImportSignal) { + QTemporaryDir tmpDir; + ASSERT_TRUE(tmpDir.isValid()); + + QString meshPath = tmpDir.path() + "/model.fbx"; + QFile(meshPath).open(QIODevice::WriteOnly); + + auto* abc = AssetBrowserController::instance(); + QSignalSpy spy(abc, &AssetBrowserController::importMeshRequested); + + abc->openFile(meshPath); + EXPECT_EQ(spy.count(), 1); + QStringList paths = spy.at(0).at(0).toStringList(); + EXPECT_EQ(paths.size(), 1); + EXPECT_EQ(paths.at(0), meshPath); +} + +TEST_F(AssetBrowserControllerTests, OpenFileNonexistentDoesNothing) { + auto* abc = AssetBrowserController::instance(); + QSignalSpy spy(abc, &AssetBrowserController::importMeshRequested); + + abc->openFile("/nonexistent/path/file.fbx"); + EXPECT_EQ(spy.count(), 0); +} + +TEST_F(AssetBrowserControllerTests, OpenFileDirectoryNavigates) { + QTemporaryDir tmpDir; + ASSERT_TRUE(tmpDir.isValid()); + + QDir(tmpDir.path()).mkdir("subdir"); + QString subdirPath = tmpDir.path() + "/subdir"; + + auto* abc = AssetBrowserController::instance(); + abc->setRootPath(tmpDir.path()); + QSignalSpy spy(abc, &AssetBrowserController::importMeshRequested); + + abc->openFile(subdirPath); + EXPECT_EQ(spy.count(), 0); // should not emit import signal + EXPECT_EQ(abc->rootPath(), subdirPath); // should navigate into the directory +} + +TEST_F(AssetBrowserControllerTests, BrowseRequestedSignal) { + auto* abc = AssetBrowserController::instance(); + QSignalSpy spy(abc, &AssetBrowserController::browseRequested); + + abc->browseForDirectory(); + EXPECT_EQ(spy.count(), 1); +} + +TEST_F(AssetBrowserControllerTests, RootPathPersistedInSettings) { + QTemporaryDir tmpDir; + ASSERT_TRUE(tmpDir.isValid()); + + { + auto* abc = AssetBrowserController::instance(); + abc->setRootPath(tmpDir.path()); + } + + QSettings settings; + EXPECT_EQ(settings.value("AssetBrowser/rootPath").toString(), tmpDir.path()); +} + +// --- Additional file type classification tests --- + +TEST_F(AssetBrowserControllerTests, ClassifyExtensionForDotX) { + auto* abc = AssetBrowserController::instance(); + EXPECT_EQ(abc->fileTypeForPath("/foo/model.x"), "mesh"); +} + +TEST_F(AssetBrowserControllerTests, ClassifyExtensionForMeshXml) { + auto* abc = AssetBrowserController::instance(); + // .mesh.xml files: fileTypeForPath uses suffix which gives "xml" + // Verify the behavior for the .mesh extension itself + EXPECT_EQ(abc->fileTypeForPath("/foo/model.mesh"), "mesh"); +} + +TEST_F(AssetBrowserControllerTests, ClassifyExtensionForStl) { + auto* abc = AssetBrowserController::instance(); + EXPECT_EQ(abc->fileTypeForPath("/foo/part.stl"), "mesh"); +} + +TEST_F(AssetBrowserControllerTests, ClassifyExtensionFor3ds) { + auto* abc = AssetBrowserController::instance(); + EXPECT_EQ(abc->fileTypeForPath("/foo/scene.3ds"), "mesh"); +} + +TEST_F(AssetBrowserControllerTests, ClassifyExtensionForDds) { + auto* abc = AssetBrowserController::instance(); + EXPECT_EQ(abc->fileTypeForPath("/foo/texture.dds"), "texture"); +} + +TEST_F(AssetBrowserControllerTests, ClassifyExtensionForHdr) { + auto* abc = AssetBrowserController::instance(); + EXPECT_EQ(abc->fileTypeForPath("/foo/env.hdr"), "texture"); +} + +TEST_F(AssetBrowserControllerTests, ClassifyExtensionForExr) { + auto* abc = AssetBrowserController::instance(); + EXPECT_EQ(abc->fileTypeForPath("/foo/light.exr"), "texture"); +} + +TEST_F(AssetBrowserControllerTests, ClassifyExtensionCaseInsensitive) { + auto* abc = AssetBrowserController::instance(); + EXPECT_EQ(abc->fileTypeForPath("/foo/Model.FBX"), "mesh"); + EXPECT_EQ(abc->fileTypeForPath("/foo/Texture.PNG"), "texture"); + EXPECT_EQ(abc->fileTypeForPath("/foo/Mat.MATERIAL"), "material"); +} + +TEST_F(AssetBrowserControllerTests, MaterialPreviewReturnsEmptyForNonMaterialFile) { + QTemporaryDir tmpDir; + ASSERT_TRUE(tmpDir.isValid()); + + // Create a non-material file + QString path = tmpDir.path() + "/model.fbx"; + QFile f(path); + f.open(QIODevice::WriteOnly); + f.write("not a material"); + f.close(); + + auto* abc = AssetBrowserController::instance(); + // materialPreview on a non-material file (or one with no valid material name) + // should return empty + QString preview = abc->materialPreview(path); + EXPECT_TRUE(preview.isEmpty()); +} + +TEST_F(AssetBrowserControllerTests, MaterialPreviewReturnsEmptyForNonexistentFile) { + auto* abc = AssetBrowserController::instance(); + QString preview = abc->materialPreview("/nonexistent/path/foo.material"); + EXPECT_TRUE(preview.isEmpty()); +} + +TEST_F(AssetBrowserControllerTests, FilterMaterialsOnly) { + QTemporaryDir tmpDir; + ASSERT_TRUE(tmpDir.isValid()); + + QFile(tmpDir.path() + "/model.fbx").open(QIODevice::WriteOnly); + QFile(tmpDir.path() + "/texture.png").open(QIODevice::WriteOnly); + QFile(tmpDir.path() + "/shader.material").open(QIODevice::WriteOnly); + + auto* abc = AssetBrowserController::instance(); + abc->setRootPath(tmpDir.path()); + abc->setFilter("materials"); + + int materialCount = 0; + for (const QVariant& v : abc->files()) { + QVariantMap m = v.toMap(); + if (!m["isDir"].toBool() && m["type"].toString() == "material") + materialCount++; + } + EXPECT_EQ(materialCount, 1); +} diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 0ab4f2f96..bf8eb0da2 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -60,8 +60,12 @@ MaterialPresetLibrary.cpp MeshLodController.cpp MeshValidator.cpp AIChatManager.cpp +WelcomeScreenController.cpp +WelcomeDialog.cpp ScanConfig.cpp ScanEngine.cpp +AssetBrowserController.cpp +MaterialPreviewRenderer.cpp ) set(HEADER_FILES @@ -125,8 +129,12 @@ MaterialPresetLibrary.h MeshLodController.h MeshValidator.h AIChatManager.h +WelcomeScreenController.h +WelcomeDialog.h ScanConfig.h ScanEngine.h +AssetBrowserController.h +MaterialPreviewRenderer.h ) set(TEST_SOURCES "") diff --git a/src/MaterialEditorQML.cpp b/src/MaterialEditorQML.cpp index 040a45fd9..3e4bac913 100644 --- a/src/MaterialEditorQML.cpp +++ b/src/MaterialEditorQML.cpp @@ -1,4 +1,5 @@ #include "MaterialEditorQML.h" +#include "MaterialPreviewRenderer.h" #include "Manager.h" #include "SentryReporter.h" #include "LLMManager.h" @@ -3124,6 +3125,11 @@ QStringList MaterialEditorQML::getMaterialList() const return materialList; } +QString MaterialEditorQML::materialPreview(const QString& materialName) const +{ + return MaterialPreviewRenderer::instance()->renderPreviewAsDataUri(materialName); +} + void MaterialEditorQML::importMaterialFile(const QString &filePath) { if (filePath.isEmpty()) { diff --git a/src/MaterialEditorQML.h b/src/MaterialEditorQML.h index 874b453b5..b8982db3d 100644 --- a/src/MaterialEditorQML.h +++ b/src/MaterialEditorQML.h @@ -363,6 +363,7 @@ public slots: // Material list operations Q_INVOKABLE QStringList getMaterialList() const; + Q_INVOKABLE QString materialPreview(const QString& materialName) const; Q_INVOKABLE void importMaterialFile(const QString &filePath); Q_INVOKABLE void exportMaterial(const QString &fileName, const QString &materialName); Q_INVOKABLE void openMaterialEditorWindow(const QString &materialName = ""); diff --git a/src/MaterialPreviewRenderer.cpp b/src/MaterialPreviewRenderer.cpp new file mode 100644 index 000000000..ea9dcd5b9 --- /dev/null +++ b/src/MaterialPreviewRenderer.cpp @@ -0,0 +1,237 @@ +#include "MaterialPreviewRenderer.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "ProceduralSphereGenerator.h" + +MaterialPreviewRenderer* MaterialPreviewRenderer::m_pSingleton = nullptr; + +MaterialPreviewRenderer* MaterialPreviewRenderer::instance() +{ + if (!m_pSingleton) + m_pSingleton = new MaterialPreviewRenderer(); + return m_pSingleton; +} + +MaterialPreviewRenderer* MaterialPreviewRenderer::qmlInstance(QQmlEngine* engine, QJSEngine* /*scriptEngine*/) +{ + auto* inst = instance(); + if (engine) + engine->setObjectOwnership(inst, QQmlEngine::CppOwnership); + return inst; +} + +void MaterialPreviewRenderer::kill() +{ + delete m_pSingleton; + m_pSingleton = nullptr; +} + +MaterialPreviewRenderer::MaterialPreviewRenderer() + : QObject(nullptr) +{ +} + +MaterialPreviewRenderer::~MaterialPreviewRenderer() +{ + if (m_initialized) { + auto* root = Ogre::Root::getSingletonPtr(); + if (root) { + // Remove the render texture first + if (m_rttTexture) { + Ogre::TextureManager::getSingleton().remove(m_rttTexture); + m_rttTexture.reset(); + } + + // Destroy the preview scene manager (cleans up all its nodes/entities/lights) + if (m_sceneMgr) { + root->destroySceneManager(m_sceneMgr); + m_sceneMgr = nullptr; + } + } + } +} + +bool MaterialPreviewRenderer::ensureScene() +{ + if (m_initialized) + return true; + + auto* root = Ogre::Root::getSingletonPtr(); + if (!root || !root->getRenderSystem()) + return false; + + try { + // Create a dedicated scene manager for previews + m_sceneMgr = root->createSceneManager("DefaultSceneManager", "MaterialPreviewSM"); + + // Ambient light for base illumination + m_sceneMgr->setAmbientLight(Ogre::ColourValue(0.55f, 0.55f, 0.55f)); + + // Camera looking at the origin (Ogre 14: position via scene node) + m_camera = m_sceneMgr->createCamera("PreviewCam"); + m_camera->setNearClipDistance(0.1f); + m_camera->setFarClipDistance(10.0f); + m_camera->setAspectRatio(1.0f); + + auto* camNode = m_sceneMgr->getRootSceneNode()->createChildSceneNode(); + camNode->setPosition(Ogre::Vector3(0, 0, 2.5f)); + camNode->lookAt(Ogre::Vector3::ZERO, Ogre::Node::TS_WORLD); + camNode->attachObject(m_camera); + + // Directional light from upper-right + m_light = m_sceneMgr->createLight("PreviewLight"); + m_light->setType(Ogre::Light::LT_DIRECTIONAL); + m_light->setDiffuseColour(0.8f, 0.8f, 0.8f); + m_light->setSpecularColour(1.0f, 1.0f, 1.0f); + auto* lightNode = m_sceneMgr->getRootSceneNode()->createChildSceneNode(); + lightNode->attachObject(m_light); + lightNode->setDirection(Ogre::Vector3(-1, -1, -1).normalisedCopy()); + + // Create sphere mesh using ogre-procedural + const std::string meshName = "__MaterialPreviewSphere__"; + if (!Ogre::MeshManager::getSingleton().resourceExists(meshName)) { + Procedural::SphereGenerator() + .setRadius(1.0f) + .setUTile(1.0f) + .setVTile(1.0f) + .setNumRings(16) + .setNumSegments(16) + .realizeMesh(meshName); + } + + m_sphere = m_sceneMgr->createEntity("PreviewSphereEntity", meshName); + m_sphereNode = m_sceneMgr->getRootSceneNode()->createChildSceneNode(); + m_sphereNode->attachObject(m_sphere); + + // Create render-to-texture + m_rttTexture = Ogre::TextureManager::getSingleton().createManual( + "MatPreviewRTT", + Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME, + Ogre::TEX_TYPE_2D, + PREVIEW_SIZE, PREVIEW_SIZE, + 0, + Ogre::PF_BYTE_RGBA, + Ogre::TU_RENDERTARGET); + + m_renderTarget = m_rttTexture->getBuffer()->getRenderTarget(); + Ogre::Viewport* vp = m_renderTarget->addViewport(m_camera); + vp->setClearEveryFrame(true); + vp->setBackgroundColour(Ogre::ColourValue(0.15f, 0.15f, 0.15f, 1.0f)); + vp->setOverlaysEnabled(false); + + m_initialized = true; + return true; + } catch (const Ogre::Exception&) { + // Clean up partial state on failure + if (m_sceneMgr) { + Ogre::Root::getSingletonPtr()->destroySceneManager(m_sceneMgr); + m_sceneMgr = nullptr; + } + m_camera = nullptr; + m_light = nullptr; + m_sphere = nullptr; + m_sphereNode = nullptr; + m_renderTarget = nullptr; + return false; + } catch (...) { + return false; + } +} + +QImage MaterialPreviewRenderer::renderPreview(const QString& materialName) +{ + if (!ensureScene()) + return {}; + + // Check that the material exists + auto* matMgr = Ogre::MaterialManager::getSingletonPtr(); + if (!matMgr) + return {}; + + std::string stdName = materialName.toStdString(); + if (!matMgr->resourceExists(stdName)) + return {}; + + try { + // Apply the material to the sphere + m_sphere->setMaterialName(stdName); + + // Render + m_renderTarget->update(); + + // Read pixels from the render target + QImage image(PREVIEW_SIZE, PREVIEW_SIZE, QImage::Format_RGBA8888); + + Ogre::PixelBox pb(PREVIEW_SIZE, PREVIEW_SIZE, 1, Ogre::PF_BYTE_RGBA, image.bits()); + m_renderTarget->copyContentsToMemory( + Ogre::Box(0, 0, PREVIEW_SIZE, PREVIEW_SIZE), pb, + Ogre::RenderTarget::FB_AUTO); + + return image; + } catch (const Ogre::Exception&) { + return {}; + } catch (...) { + return {}; + } +} + +QString MaterialPreviewRenderer::renderPreviewAsDataUri(const QString& materialName) +{ + // Check cache first + auto it = m_cache.find(materialName); + if (it != m_cache.end()) + return it.value(); + + QImage image = renderPreview(materialName); + if (image.isNull()) + return {}; + + // Convert to PNG base64 + QByteArray ba; + QBuffer buffer(&ba); + buffer.open(QIODevice::WriteOnly); + image.save(&buffer, "PNG"); + buffer.close(); + + QString dataUri = QStringLiteral("data:image/png;base64,") + ba.toBase64(); + m_cache.insert(materialName, dataUri); + return dataUri; +} + +void MaterialPreviewRenderer::clearCache() +{ + m_cache.clear(); +} + +QString MaterialPreviewRenderer::firstMaterialNameInFile(const QString& filePath) +{ + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) + return {}; + + // Ogre .material script: lines like "material SomeName" or "material SomeName : ParentName" + static const QRegularExpression rx( + QStringLiteral(R"(^\s*material\s+(\S+))"), + QRegularExpression::MultilineOption); + + QTextStream stream(&file); + while (!stream.atEnd()) { + QString line = stream.readLine(); + auto match = rx.match(line); + if (match.hasMatch()) { + return match.captured(1); + } + } + + return {}; +} diff --git a/src/MaterialPreviewRenderer.h b/src/MaterialPreviewRenderer.h new file mode 100644 index 000000000..f41b05346 --- /dev/null +++ b/src/MaterialPreviewRenderer.h @@ -0,0 +1,69 @@ +#ifndef MATERIAL_PREVIEW_RENDERER_H +#define MATERIAL_PREVIEW_RENDERER_H + +#include +#include +#include +#include +#include +#include + +/** + * @brief Singleton that renders material previews using Ogre render-to-texture. + * + * Maintains a small offscreen Ogre scene with a lit sphere. For a given + * material name the sphere is assigned that material, the scene is rendered + * to a 64x64 RGBA texture, and the result is returned as a QImage or + * base64-encoded data URI suitable for QML Image.source. + */ +class MaterialPreviewRenderer : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + +public: + static MaterialPreviewRenderer* instance(); + static MaterialPreviewRenderer* qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine); + static void kill(); + + /// Render a 64x64 preview of the named Ogre material on a lit sphere. + /// Returns a null QImage if the material cannot be found or Ogre is not ready. + QImage renderPreview(const QString& materialName); + + /// Convenience: returns the preview as a data:image/png;base64,... URI string. + /// Returns an empty string on failure. + Q_INVOKABLE QString renderPreviewAsDataUri(const QString& materialName); + + /// Clear the cached previews (e.g. when materials are reloaded). + Q_INVOKABLE void clearCache(); + + /// Parse a .material file and return the first material name found. + /// Returns an empty string if none found. + static QString firstMaterialNameInFile(const QString& filePath); + +private: + MaterialPreviewRenderer(); + ~MaterialPreviewRenderer() override; + + bool ensureScene(); + + static MaterialPreviewRenderer* m_pSingleton; + + Ogre::SceneManager* m_sceneMgr = nullptr; + Ogre::Camera* m_camera = nullptr; + Ogre::Light* m_light = nullptr; + Ogre::Entity* m_sphere = nullptr; + Ogre::SceneNode* m_sphereNode = nullptr; + Ogre::TexturePtr m_rttTexture; + Ogre::RenderTarget* m_renderTarget = nullptr; + + bool m_initialized = false; + + // Cache: materialName -> base64 data URI + QHash m_cache; + + static constexpr int PREVIEW_SIZE = 64; +}; + +#endif // MATERIAL_PREVIEW_RENDERER_H diff --git a/src/MaterialPreviewRenderer_test.cpp b/src/MaterialPreviewRenderer_test.cpp new file mode 100644 index 000000000..296dd8c02 --- /dev/null +++ b/src/MaterialPreviewRenderer_test.cpp @@ -0,0 +1,272 @@ +#include +#include "MaterialPreviewRenderer.h" +#include "TestHelpers.h" + +#include +#include +#include +#include +#include +#include + +class MaterialPreviewRendererTests : public ::testing::Test { +protected: + void SetUp() override { + ASSERT_NE(qobject_cast(QCoreApplication::instance()), nullptr); + } + + void TearDown() override { + MaterialPreviewRenderer::kill(); + } +}; + +TEST_F(MaterialPreviewRendererTests, Singleton) { + auto* renderer = MaterialPreviewRenderer::instance(); + ASSERT_NE(renderer, nullptr); + EXPECT_EQ(renderer, MaterialPreviewRenderer::instance()); +} + +TEST_F(MaterialPreviewRendererTests, KillAndRecreate) { + auto* r1 = MaterialPreviewRenderer::instance(); + ASSERT_NE(r1, nullptr); + + MaterialPreviewRenderer::kill(); + + auto* r2 = MaterialPreviewRenderer::instance(); + ASSERT_NE(r2, nullptr); +} + +TEST_F(MaterialPreviewRendererTests, RenderPreviewReturnsNullForUnknownMaterial) { + auto* renderer = MaterialPreviewRenderer::instance(); + QImage img = renderer->renderPreview("NonExistentMaterial_XYZ_123"); + EXPECT_TRUE(img.isNull()); +} + +TEST_F(MaterialPreviewRendererTests, DataUriReturnsEmptyForUnknownMaterial) { + auto* renderer = MaterialPreviewRenderer::instance(); + QString uri = renderer->renderPreviewAsDataUri("NonExistentMaterial_XYZ_123"); + EXPECT_TRUE(uri.isEmpty()); +} + +TEST_F(MaterialPreviewRendererTests, ClearCacheDoesNotCrash) { + auto* renderer = MaterialPreviewRenderer::instance(); + renderer->clearCache(); +} + +TEST_F(MaterialPreviewRendererTests, FirstMaterialNameFromEmptyFile) { + QTemporaryDir tmpDir; + ASSERT_TRUE(tmpDir.isValid()); + + QString path = tmpDir.path() + "/empty.material"; + QFile f(path); + f.open(QIODevice::WriteOnly); + f.close(); + + EXPECT_TRUE(MaterialPreviewRenderer::firstMaterialNameInFile(path).isEmpty()); +} + +TEST_F(MaterialPreviewRendererTests, FirstMaterialNameFromValidFile) { + QTemporaryDir tmpDir; + ASSERT_TRUE(tmpDir.isValid()); + + QString path = tmpDir.path() + "/test.material"; + QFile f(path); + ASSERT_TRUE(f.open(QIODevice::WriteOnly | QIODevice::Text)); + QTextStream out(&f); + out << "// A comment line\n"; + out << "\n"; + out << "material MyTestMaterial\n"; + out << "{\n"; + out << " technique\n"; + out << " {\n"; + out << " pass\n"; + out << " {\n"; + out << " diffuse 1.0 0.0 0.0 1.0\n"; + out << " }\n"; + out << " }\n"; + out << "}\n"; + f.close(); + + QString matName = MaterialPreviewRenderer::firstMaterialNameInFile(path); + EXPECT_EQ(matName, "MyTestMaterial"); +} + +TEST_F(MaterialPreviewRendererTests, FirstMaterialNameWithInheritance) { + QTemporaryDir tmpDir; + ASSERT_TRUE(tmpDir.isValid()); + + QString path = tmpDir.path() + "/inherit.material"; + QFile f(path); + ASSERT_TRUE(f.open(QIODevice::WriteOnly | QIODevice::Text)); + QTextStream out(&f); + out << "material DerivedMat : BaseMat\n"; + out << "{\n"; + out << "}\n"; + f.close(); + + QString matName = MaterialPreviewRenderer::firstMaterialNameInFile(path); + EXPECT_EQ(matName, "DerivedMat"); +} + +TEST_F(MaterialPreviewRendererTests, FirstMaterialNameFromNonexistentFile) { + EXPECT_TRUE(MaterialPreviewRenderer::firstMaterialNameInFile("/no/such/file.material").isEmpty()); +} + +TEST_F(MaterialPreviewRendererTests, RenderPreviewWithOgreBaseWhite) { + if (!tryInitOgre()) { + GTEST_SKIP() << "Ogre not available"; + } + if (!canLoadMeshFiles()) { + GTEST_SKIP() << "Cannot create meshes (no GL context)"; + } + + createStandardOgreMaterials(); + + auto* renderer = MaterialPreviewRenderer::instance(); + QImage img = renderer->renderPreview("BaseWhite"); + // The preview should succeed when Ogre is fully initialized + if (!img.isNull()) { + EXPECT_EQ(img.width(), 64); + EXPECT_EQ(img.height(), 64); + EXPECT_EQ(img.format(), QImage::Format_RGBA8888); + } + // If null, the RTT may not be supported in this test environment (acceptable) +} + +TEST_F(MaterialPreviewRendererTests, DataUriCachesResults) { + if (!tryInitOgre()) { + GTEST_SKIP() << "Ogre not available"; + } + if (!canLoadMeshFiles()) { + GTEST_SKIP() << "Cannot create meshes (no GL context)"; + } + + createStandardOgreMaterials(); + + auto* renderer = MaterialPreviewRenderer::instance(); + QString uri1 = renderer->renderPreviewAsDataUri("BaseWhite"); + if (uri1.isEmpty()) { + GTEST_SKIP() << "RTT preview not available in this environment"; + } + + QString uri2 = renderer->renderPreviewAsDataUri("BaseWhite"); + EXPECT_EQ(uri1, uri2); // Should be cached + + renderer->clearCache(); + QString uri3 = renderer->renderPreviewAsDataUri("BaseWhite"); + EXPECT_FALSE(uri3.isEmpty()); // Should regenerate +} + +TEST_F(MaterialPreviewRendererTests, ClearCacheInvalidatesPreviousResults) { + if (!tryInitOgre()) { + GTEST_SKIP() << "Ogre not available"; + } + if (!canLoadMeshFiles()) { + GTEST_SKIP() << "Cannot create meshes (no GL context)"; + } + + createStandardOgreMaterials(); + + auto* renderer = MaterialPreviewRenderer::instance(); + + // Generate a preview to populate the cache + QString uri1 = renderer->renderPreviewAsDataUri("BaseWhite"); + if (uri1.isEmpty()) { + GTEST_SKIP() << "RTT preview not available in this environment"; + } + + // Verify cache is populated (second call returns same result) + QString uri2 = renderer->renderPreviewAsDataUri("BaseWhite"); + EXPECT_EQ(uri1, uri2); + + // Clear and verify cache was actually emptied by getting a new result + renderer->clearCache(); + // After clearing, the next call should regenerate (still valid, but confirms clear didn't crash) + QString uri3 = renderer->renderPreviewAsDataUri("BaseWhite"); + EXPECT_FALSE(uri3.isEmpty()); +} + +TEST_F(MaterialPreviewRendererTests, FirstMaterialNameWithCommentLines) { + QTemporaryDir tmpDir; + ASSERT_TRUE(tmpDir.isValid()); + + QString path = tmpDir.path() + "/commented.material"; + QFile f(path); + ASSERT_TRUE(f.open(QIODevice::WriteOnly | QIODevice::Text)); + QTextStream out(&f); + out << "// This is a comment\n"; + out << "// Another comment\n"; + out << "/* block comment */\n"; + out << "\n"; + out << "// material FakeName (in a comment)\n"; + out << "material RealMaterial\n"; + out << "{\n"; + out << "}\n"; + f.close(); + + QString matName = MaterialPreviewRenderer::firstMaterialNameInFile(path); + EXPECT_EQ(matName, "RealMaterial"); +} + +TEST_F(MaterialPreviewRendererTests, FirstMaterialNameWithQuotedName) { + QTemporaryDir tmpDir; + ASSERT_TRUE(tmpDir.isValid()); + + QString path = tmpDir.path() + "/quoted.material"; + QFile f(path); + ASSERT_TRUE(f.open(QIODevice::WriteOnly | QIODevice::Text)); + QTextStream out(&f); + out << "material \"My Material With Spaces\"\n"; + out << "{\n"; + out << "}\n"; + f.close(); + + QString matName = MaterialPreviewRenderer::firstMaterialNameInFile(path); + // Should return some non-empty name (the exact format depends on parsing) + EXPECT_FALSE(matName.isEmpty()); +} + +TEST_F(MaterialPreviewRendererTests, FirstMaterialNameOnlyWhitespace) { + QTemporaryDir tmpDir; + ASSERT_TRUE(tmpDir.isValid()); + + QString path = tmpDir.path() + "/whitespace.material"; + QFile f(path); + ASSERT_TRUE(f.open(QIODevice::WriteOnly | QIODevice::Text)); + QTextStream out(&f); + out << " \n"; + out << "\t\n"; + out << "\n"; + f.close(); + + QString matName = MaterialPreviewRenderer::firstMaterialNameInFile(path); + EXPECT_TRUE(matName.isEmpty()); +} + +TEST_F(MaterialPreviewRendererTests, QmlInstanceReturnsSameAsInstance) { + auto* r1 = MaterialPreviewRenderer::instance(); + // qmlInstance requires a non-null engine in production, but for singleton + // pattern verification we can check instance() consistency + EXPECT_EQ(r1, MaterialPreviewRenderer::instance()); +} + +TEST_F(MaterialPreviewRendererTests, MultipleFirstMaterialNamesReturnsFirst) { + QTemporaryDir tmpDir; + ASSERT_TRUE(tmpDir.isValid()); + + QString path = tmpDir.path() + "/multi.material"; + QFile f(path); + ASSERT_TRUE(f.open(QIODevice::WriteOnly | QIODevice::Text)); + QTextStream out(&f); + out << "material FirstMaterial\n"; + out << "{\n"; + out << "}\n"; + out << "\n"; + out << "material SecondMaterial\n"; + out << "{\n"; + out << "}\n"; + f.close(); + + QString matName = MaterialPreviewRenderer::firstMaterialNameInFile(path); + EXPECT_EQ(matName, "FirstMaterial"); +} diff --git a/src/PropertiesPanelController.cpp b/src/PropertiesPanelController.cpp index f114866d5..fbff06337 100644 --- a/src/PropertiesPanelController.cpp +++ b/src/PropertiesPanelController.cpp @@ -9,8 +9,13 @@ #include "UndoManager.h" #include "Manager.h" #include "SentryReporter.h" +#include "OgreWidget.h" +#include "SpaceCamera.h" +#include "EditorViewport.h" +#include "ViewportGrid.h" #include #include +#include #include #include @@ -234,6 +239,57 @@ static PrimitiveObject* getSelectedPrimitive() return nullptr; } +QVariantList PropertiesPanelController::shortcutData() const +{ + QVariantList data; + + auto entry = [](const QString& cat, const QString& key, const QString& desc) { + QVariantMap m; + m["category"] = cat; + m["key"] = key; + m["description"] = desc; + return m; + }; + + // Transform + data << entry("Transform", "Q", "Select mode"); + data << entry("Transform", "W", "Translate mode"); + data << entry("Transform", "E", "Rotate mode"); + data << entry("Transform", "R", "Scale mode"); + data << entry("Transform", "X", "Toggle World / Local space"); + data << entry("Transform", "P", "Cycle pivot mode"); + + // Navigation + data << entry("Navigation", "F", "Frame selection"); + data << entry("Navigation", "Middle Mouse", "Orbit camera"); + data << entry("Navigation", "Right Mouse", "Pan camera"); + data << entry("Navigation", "Scroll Wheel", "Zoom camera"); + + // Editing + data << entry("Editing", "Ctrl + D", "Duplicate selection"); + data << entry("Editing", "Ctrl + G", "Group nodes"); + data << entry("Editing", "Ctrl + Shift + G", "Ungroup nodes"); + data << entry("Editing", "Delete", "Remove selected"); + + // File + data << entry("File", "Ctrl + O", "Open scene"); + data << entry("File", "Ctrl + S", "Save scene"); + data << entry("File", "Ctrl + Z", "Undo"); + data << entry("File", "Ctrl + Shift + Z", "Redo"); + data << entry("File", "Ctrl + ,", "Open preferences"); + + // View + data << entry("View", "Show Grid", "Toggle grid display (Options menu)"); + data << entry("View", "Show Normals", "Toggle vertex normals (Options menu)"); + data << entry("View", "Show Mesh Info", "Toggle mesh info overlay (Options menu)"); + data << entry("View", "Show View Cube", "Toggle 3D view cube (Options menu)"); + + // Help + data << entry("Help", "Ctrl + /", "Open keyboard shortcut reference"); + + return data; +} + void PropertiesPanelController::selectNodeByName(const QString& name) { auto* mgr = Manager::getSingletonPtr(); @@ -699,3 +755,75 @@ QVariantList PropertiesPanelController::scaleStepPresets() const result.append(v); return result; } + +// Generic QSettings accessors for Preferences dialog +QVariant PropertiesPanelController::getSetting(const QString& key, const QVariant& defaultValue) const +{ + QSettings settings; + return settings.value(key, defaultValue); +} + +void PropertiesPanelController::setSetting(const QString& key, const QVariant& value) +{ + QSettings settings; + settings.setValue(key, value); + SentryReporter::addBreadcrumb("ui.action", + QString("Preference changed: %1").arg(key)); + + // Apply settings immediately to the running app + if (key == "Viewport/gridVisible") { + auto* grid = Manager::getSingleton()->getViewportGrid(); + if (grid) grid->setVisible(value.toBool()); + } else if (key == "Viewport/cameraSpeed") { + Ogre::Real speed = value.toReal(); + if (speed <= 0) speed = 0.5f; + // Apply to the active viewport (TransformOperator tracks it) + auto* activeWidget = TransformOperator::getSingleton()->getActiveWidget(); + if (activeWidget && activeWidget->getSpaceCamera()) + activeWidget->getSpaceCamera()->setCameraSpeed(speed); + // Also apply to all viewports + auto* mainWin = Manager::getSingleton()->getMainWindow(); + if (mainWin) { + for (auto* ow : mainWin->findChildren()) { + if (ow->getSpaceCamera()) + ow->getSpaceCamera()->setCameraSpeed(speed); + } + } + } else if (key == "Viewport/nearClip" || key == "Viewport/farClip") { + auto* mainWin = Manager::getSingleton()->getMainWindow(); + if (mainWin) { + for (auto* ow : mainWin->findChildren()) { + if (ow->getSpaceCamera() && ow->getSpaceCamera()->getCamera()) { + if (key == "Viewport/nearClip") + ow->getSpaceCamera()->getCamera()->setNearClipDistance(value.toReal()); + else + ow->getSpaceCamera()->getCamera()->setFarClipDistance(value.toReal()); + } + } + } + } else if (key == "Sentry/enabled" || key == "Telemetry/enabled") { + SentryReporter::setEnabled(value.toBool()); + } else if (key == "Appearance/theme" || key == "palette") { + QString theme = value.toString().toLower(); + if (theme == "dark") { + // Match MainWindow::on_actionDark_toggled — use dark palette + QPalette dark; + dark.setColor(QPalette::Window, QColor(53, 53, 53)); + dark.setColor(QPalette::WindowText, Qt::white); + dark.setColor(QPalette::Base, QColor(35, 35, 35)); + dark.setColor(QPalette::AlternateBase, QColor(53, 53, 53)); + dark.setColor(QPalette::ToolTipBase, QColor(25, 25, 25)); + dark.setColor(QPalette::ToolTipText, Qt::white); + dark.setColor(QPalette::Text, Qt::white); + dark.setColor(QPalette::Button, QColor(53, 53, 53)); + dark.setColor(QPalette::ButtonText, Qt::white); + dark.setColor(QPalette::Link, QColor(42, 130, 218)); + dark.setColor(QPalette::Highlight, QColor(42, 130, 218)); + dark.setColor(QPalette::HighlightedText, Qt::black); + QApplication::setPalette(dark); + } else if (theme == "light") { + QApplication::setPalette(QColor("ghostwhite")); + } + // System = use platform default (requires restart) + } +} diff --git a/src/PropertiesPanelController.h b/src/PropertiesPanelController.h index 57d09d897..11a34fa62 100644 --- a/src/PropertiesPanelController.h +++ b/src/PropertiesPanelController.h @@ -177,6 +177,12 @@ class PropertiesPanelController : public QObject Q_INVOKABLE void undoToIndex(int index); Q_INVOKABLE void clearUndoHistory(); + Q_INVOKABLE QVariantList shortcutData() const; + + // Generic QSettings accessors for Preferences dialog + Q_INVOKABLE QVariant getSetting(const QString& key, const QVariant& defaultValue) const; + Q_INVOKABLE void setSetting(const QString& key, const QVariant& value); + Q_INVOKABLE void selectNodeByName(const QString& name); Q_INVOKABLE bool canReparentNode(const QString& nodeName, const QString& newParentName); Q_INVOKABLE bool reparentNode(const QString& nodeName, const QString& newParentName); diff --git a/src/PropertiesPanelController_test.cpp b/src/PropertiesPanelController_test.cpp index 1902af348..bbb865f99 100644 --- a/src/PropertiesPanelController_test.cpp +++ b/src/PropertiesPanelController_test.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -525,3 +526,205 @@ TEST_F(PropertiesPanelControllerTests, SceneTreeReparentWrappersHandleValidAndIn EXPECT_TRUE(controller->reparentNode("PanelReparentNode", "PanelReparentParent")); EXPECT_EQ(static_cast(node->getParent()), parent); } + +// ---- shortcutData tests ---- + +TEST_F(PropertiesPanelControllerTests, ShortcutDataReturnsNonEmptyList) +{ + QVariantList shortcuts = controller->shortcutData(); + EXPECT_FALSE(shortcuts.isEmpty()); + EXPECT_GE(shortcuts.size(), 10); // There are many shortcuts defined +} + +TEST_F(PropertiesPanelControllerTests, ShortcutDataEntriesHaveRequiredKeys) +{ + QVariantList shortcuts = controller->shortcutData(); + for (const QVariant& entry : shortcuts) { + QVariantMap m = entry.toMap(); + EXPECT_TRUE(m.contains("category")) << "Missing 'category' key"; + EXPECT_TRUE(m.contains("key")) << "Missing 'key' key"; + EXPECT_TRUE(m.contains("description")) << "Missing 'description' key"; + EXPECT_FALSE(m["category"].toString().isEmpty()); + EXPECT_FALSE(m["key"].toString().isEmpty()); + EXPECT_FALSE(m["description"].toString().isEmpty()); + } +} + +TEST_F(PropertiesPanelControllerTests, ShortcutDataContainsExpectedTransformShortcuts) +{ + QVariantList shortcuts = controller->shortcutData(); + + auto findShortcut = [&](const QString& key) -> QVariantMap { + for (const QVariant& entry : shortcuts) { + QVariantMap m = entry.toMap(); + if (m["key"].toString() == key) + return m; + } + return QVariantMap(); + }; + + // Check for the Unity-style transform shortcuts + QVariantMap qShortcut = findShortcut("Q"); + EXPECT_FALSE(qShortcut.isEmpty()); + EXPECT_EQ(qShortcut["category"].toString(), "Transform"); + EXPECT_EQ(qShortcut["description"].toString(), "Select mode"); + + QVariantMap wShortcut = findShortcut("W"); + EXPECT_FALSE(wShortcut.isEmpty()); + EXPECT_EQ(wShortcut["category"].toString(), "Transform"); + EXPECT_EQ(wShortcut["description"].toString(), "Translate mode"); + + QVariantMap eShortcut = findShortcut("E"); + EXPECT_FALSE(eShortcut.isEmpty()); + EXPECT_EQ(eShortcut["category"].toString(), "Transform"); + EXPECT_EQ(eShortcut["description"].toString(), "Rotate mode"); + + QVariantMap rShortcut = findShortcut("R"); + EXPECT_FALSE(rShortcut.isEmpty()); + EXPECT_EQ(rShortcut["category"].toString(), "Transform"); + EXPECT_EQ(rShortcut["description"].toString(), "Scale mode"); +} + +TEST_F(PropertiesPanelControllerTests, ShortcutDataHasAllSixCategories) +{ + QVariantList shortcuts = controller->shortcutData(); + QSet categories; + for (const QVariant& entry : shortcuts) { + categories.insert(entry.toMap()["category"].toString()); + } + + EXPECT_TRUE(categories.contains("Transform")); + EXPECT_TRUE(categories.contains("Navigation")); + EXPECT_TRUE(categories.contains("Editing")); + EXPECT_TRUE(categories.contains("File")); + EXPECT_TRUE(categories.contains("View")); + EXPECT_TRUE(categories.contains("Help")); + EXPECT_EQ(categories.size(), 6); +} + +// ---- getSetting / setSetting tests ---- + +TEST_F(PropertiesPanelControllerTests, GetSetSettingStringRoundTrip) +{ + const QString key = "TestSuite/testStringKey"; + QSettings settings; + QVariant saved = settings.value(key); + + controller->setSetting(key, QVariant("hello world")); + EXPECT_EQ(controller->getSetting(key, QVariant()).toString(), "hello world"); + + // Cleanup + if (saved.isValid()) + settings.setValue(key, saved); + else + settings.remove(key); +} + +TEST_F(PropertiesPanelControllerTests, GetSetSettingIntRoundTrip) +{ + const QString key = "TestSuite/testIntKey"; + QSettings settings; + QVariant saved = settings.value(key); + + controller->setSetting(key, QVariant(42)); + EXPECT_EQ(controller->getSetting(key, QVariant()).toInt(), 42); + + // Cleanup + if (saved.isValid()) + settings.setValue(key, saved); + else + settings.remove(key); +} + +TEST_F(PropertiesPanelControllerTests, GetSetSettingBoolRoundTrip) +{ + const QString key = "TestSuite/testBoolKey"; + QSettings settings; + QVariant saved = settings.value(key); + + controller->setSetting(key, QVariant(true)); + EXPECT_EQ(controller->getSetting(key, QVariant()).toBool(), true); + + controller->setSetting(key, QVariant(false)); + EXPECT_EQ(controller->getSetting(key, QVariant()).toBool(), false); + + // Cleanup + if (saved.isValid()) + settings.setValue(key, saved); + else + settings.remove(key); +} + +TEST_F(PropertiesPanelControllerTests, GetSetSettingDoubleRoundTrip) +{ + const QString key = "TestSuite/testDoubleKey"; + QSettings settings; + QVariant saved = settings.value(key); + + controller->setSetting(key, QVariant(3.14)); + EXPECT_DOUBLE_EQ(controller->getSetting(key, QVariant()).toDouble(), 3.14); + + // Cleanup + if (saved.isValid()) + settings.setValue(key, saved); + else + settings.remove(key); +} + +TEST_F(PropertiesPanelControllerTests, GetSettingReturnsDefaultWhenKeyMissing) +{ + const QString key = "TestSuite/nonExistentKeyForTest_12345"; + QSettings settings; + settings.remove(key); + + EXPECT_EQ(controller->getSetting(key, QVariant("defaultVal")).toString(), "defaultVal"); + EXPECT_EQ(controller->getSetting(key, QVariant(99)).toInt(), 99); + EXPECT_EQ(controller->getSetting(key, QVariant(true)).toBool(), true); +} + +// ---- undoHistory / undoIndex / clearUndoHistory (additional tests) ---- + +TEST_F(PropertiesPanelControllerTests, UndoHistoryInitiallyEmptyAfterClear) +{ + controller->clearUndoHistory(); + + EXPECT_TRUE(controller->undoHistory().isEmpty()); + EXPECT_EQ(controller->undoIndex(), 0); +} + +TEST_F(PropertiesPanelControllerTests, ClearUndoHistoryEmitsSignal) +{ + auto* stack = UndoManager::getSingleton()->stack(); + stack->push(new QUndoCommand("TestCmd")); + + QSignalSpy spy(controller, &PropertiesPanelController::undoHistoryChanged); + ASSERT_TRUE(spy.isValid()); + + controller->clearUndoHistory(); + EXPECT_GE(spy.count(), 1); + EXPECT_EQ(stack->count(), 0); +} + +TEST_F(PropertiesPanelControllerTests, UndoIndexTracksPushAndUndoOperations) +{ + controller->clearUndoHistory(); + auto* stack = UndoManager::getSingleton()->stack(); + + stack->push(new QUndoCommand("A")); + EXPECT_EQ(controller->undoIndex(), 1); + + stack->push(new QUndoCommand("B")); + EXPECT_EQ(controller->undoIndex(), 2); + + stack->push(new QUndoCommand("C")); + EXPECT_EQ(controller->undoIndex(), 3); + + stack->undo(); + EXPECT_EQ(controller->undoIndex(), 2); + + stack->undo(); + EXPECT_EQ(controller->undoIndex(), 1); + + stack->redo(); + EXPECT_EQ(controller->undoIndex(), 2); +} diff --git a/src/SpaceCamera.cpp b/src/SpaceCamera.cpp index f23622a8c..f80e5d326 100755 --- a/src/SpaceCamera.cpp +++ b/src/SpaceCamera.cpp @@ -118,7 +118,10 @@ const Ogre::Real& SpaceCamera::getCameraSpeed() const //Mutators void SpaceCamera::setCameraSpeed(const Ogre::Real& newSpeed) -{ mCameraSpeed = newSpeed; } +{ + mCameraSpeed = newSpeed; + mBaseCameraSpeed = newSpeed; +} void SpaceCamera::setCameraPosition(const Ogre::Vector3 &pos) { @@ -215,8 +218,9 @@ void SpaceCamera::mouseMoveEvent(QMouseEvent *event) void SpaceCamera::wheelEvent(QWheelEvent *event) { - Ogre::Real xDelta = event->angleDelta().x() / 120.0f; - Ogre::Real yDelta = event->angleDelta().y() / 120.0f; + Ogre::Real speedScale = mCameraSpeed / 0.5f; // normalize around default 0.5 + Ogre::Real xDelta = event->angleDelta().x() / 120.0f * speedScale; + Ogre::Real yDelta = event->angleDelta().y() / 120.0f * speedScale; if (event->modifiers().testFlag(Qt::ControlModifier)) { @@ -304,7 +308,7 @@ void SpaceCamera::keyPressEvent(QKeyEvent *event) // TODO add some customization in the UI for Camera speed if(event->key() == Qt::Key_Control) { - setCameraSpeed(0.01f); + mCameraSpeed = mBaseCameraSpeed * 0.1f; // Ctrl = 10x slower (don't update base) event->accept(); } } @@ -337,7 +341,7 @@ void SpaceCamera::keyReleaseEvent(QKeyEvent *event) if(event->key() == Qt::Key_Control) { - setCameraSpeed(0.1f); + setCameraSpeed(mBaseCameraSpeed); // Restore base speed event->accept(); } } @@ -347,7 +351,8 @@ void SpaceCamera::keyReleaseEvent(QKeyEvent *event) void SpaceCamera::zoomByDelta(Ogre::Real delta) { - zoom(delta); + Ogre::Real speedScale = mCameraSpeed / 0.5f; + zoom(delta * speedScale); } void SpaceCamera::zoom(Ogre::Real delta) diff --git a/src/SpaceCamera.h b/src/SpaceCamera.h index 301fb9c20..aa95c3fbf 100755 --- a/src/SpaceCamera.h +++ b/src/SpaceCamera.h @@ -87,6 +87,7 @@ class SpaceCamera : Ogre::FrameListener Ogre::SceneNode* mTarget = nullptr; Ogre::Camera* mCamera = nullptr; // Ogre camera Ogre::Real mCameraSpeed = 0.0f; + Ogre::Real mBaseCameraSpeed = 0.5f; Ogre::SceneManager* mSceneMgr=nullptr; //SceneManager static const QPoint invalidPoint; diff --git a/src/SpaceCamera_test.cpp b/src/SpaceCamera_test.cpp index 1f83de1d5..e2f2cdefb 100644 --- a/src/SpaceCamera_test.cpp +++ b/src/SpaceCamera_test.cpp @@ -139,7 +139,7 @@ TEST(SpaceCamera, KeyPressControlChangesSpeed) QKeyEvent pressCtrl(QEvent::KeyPress, Qt::Key_Control, Qt::ControlModifier); spaceCamera.keyPressEvent(&pressCtrl); - EXPECT_FLOAT_EQ(spaceCamera.getCameraSpeed(), 0.01f); + EXPECT_FLOAT_EQ(spaceCamera.getCameraSpeed(), 0.05f); // base 0.5 * 0.1 } TEST(SpaceCamera, KeyReleaseControlRestoresSpeed) @@ -150,12 +150,12 @@ TEST(SpaceCamera, KeyReleaseControlRestoresSpeed) // Press Control QKeyEvent pressCtrl(QEvent::KeyPress, Qt::Key_Control, Qt::ControlModifier); spaceCamera.keyPressEvent(&pressCtrl); - EXPECT_FLOAT_EQ(spaceCamera.getCameraSpeed(), 0.01f); + EXPECT_FLOAT_EQ(spaceCamera.getCameraSpeed(), 0.05f); // base 0.5 * 0.1 // Release Control QKeyEvent releaseCtrl(QEvent::KeyRelease, Qt::Key_Control, Qt::NoModifier); spaceCamera.keyReleaseEvent(&releaseCtrl); - EXPECT_FLOAT_EQ(spaceCamera.getCameraSpeed(), 0.1f); + EXPECT_FLOAT_EQ(spaceCamera.getCameraSpeed(), 0.5f); // restores base speed } TEST(SpaceCamera, MousePressLeftButtonIgnored) @@ -251,7 +251,7 @@ TEST(SpaceCamera, KeyPressControlModifier) QKeyEvent pressEvent(QEvent::KeyPress, Qt::Key_Control, Qt::ControlModifier); spaceCamera.keyPressEvent(&pressEvent); // Control sets precision mode speed to 0.01 - EXPECT_FLOAT_EQ(spaceCamera.getCameraSpeed(), 0.01f); + EXPECT_FLOAT_EQ(spaceCamera.getCameraSpeed(), 0.05f); // base 0.5 * 0.1 } TEST(SpaceCamera, KeyReleaseControlModifier) @@ -264,7 +264,7 @@ TEST(SpaceCamera, KeyReleaseControlModifier) spaceCamera.keyReleaseEvent(&releaseCtrl); // After releasing Control, speed is restored to 0.1 EXPECT_GT(spaceCamera.getCameraSpeed(), speedWithCtrl); - EXPECT_FLOAT_EQ(spaceCamera.getCameraSpeed(), 0.1f); + EXPECT_FLOAT_EQ(spaceCamera.getCameraSpeed(), 0.5f); // restores base speed } TEST(SpaceCamera, MultipleKeyPressesInSequence) @@ -554,21 +554,21 @@ TEST(SpaceCamera, ControlKeySpeedTransitionCycle) spaceCamera.setCameraSpeed(1.0f); EXPECT_FLOAT_EQ(spaceCamera.getCameraSpeed(), 1.0f); - // Press Control → precision mode + // Press Control → precision mode (base 1.0 * 0.1 = 0.1) QKeyEvent pressCtrl(QEvent::KeyPress, Qt::Key_Control, Qt::ControlModifier); spaceCamera.keyPressEvent(&pressCtrl); - EXPECT_FLOAT_EQ(spaceCamera.getCameraSpeed(), 0.01f); + EXPECT_FLOAT_EQ(spaceCamera.getCameraSpeed(), 0.1f); - // Release Control → restored speed + // Release Control → restored speed (base 1.0) QKeyEvent releaseCtrl(QEvent::KeyRelease, Qt::Key_Control, Qt::NoModifier); spaceCamera.keyReleaseEvent(&releaseCtrl); - EXPECT_FLOAT_EQ(spaceCamera.getCameraSpeed(), 0.1f); + EXPECT_FLOAT_EQ(spaceCamera.getCameraSpeed(), 1.0f); // Press again spaceCamera.keyPressEvent(&pressCtrl); - EXPECT_FLOAT_EQ(spaceCamera.getCameraSpeed(), 0.01f); - spaceCamera.keyReleaseEvent(&releaseCtrl); EXPECT_FLOAT_EQ(spaceCamera.getCameraSpeed(), 0.1f); + spaceCamera.keyReleaseEvent(&releaseCtrl); + EXPECT_FLOAT_EQ(spaceCamera.getCameraSpeed(), 1.0f); } TEST(SpaceCamera, SetCameraSpeedExtremeValues) @@ -604,16 +604,15 @@ TEST(SpaceCamera, SpeedChangesWithControlKey) spaceCamera.setCameraSpeed(2.0f); EXPECT_FLOAT_EQ(spaceCamera.getCameraSpeed(), 2.0f); - // Press control + // Press control (base 2.0 * 0.1 = 0.2) QKeyEvent pressCtrl(QEvent::KeyPress, Qt::Key_Control, Qt::ControlModifier); spaceCamera.keyPressEvent(&pressCtrl); - EXPECT_FLOAT_EQ(spaceCamera.getCameraSpeed(), 0.01f); + EXPECT_FLOAT_EQ(spaceCamera.getCameraSpeed(), 0.2f); - // Release control + // Release control → restores base speed (2.0) QKeyEvent releaseCtrl(QEvent::KeyRelease, Qt::Key_Control, Qt::NoModifier); spaceCamera.keyReleaseEvent(&releaseCtrl); - // Speed restored to default (0.1f) - EXPECT_FLOAT_EQ(spaceCamera.getCameraSpeed(), 0.1f); + EXPECT_FLOAT_EQ(spaceCamera.getCameraSpeed(), 2.0f); } // ========================================================================== diff --git a/src/WelcomeDialog.cpp b/src/WelcomeDialog.cpp new file mode 100644 index 000000000..d2829cd2f --- /dev/null +++ b/src/WelcomeDialog.cpp @@ -0,0 +1,153 @@ +#include "WelcomeDialog.h" +#include "SentryReporter.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +bool WelcomeDialog::shouldShow() +{ + QSettings settings; + return !settings.value("WelcomeScreen/dontShowAgain", false).toBool(); +} + +WelcomeDialog::WelcomeDialog(QWidget* parent) + : QDialog(parent) +{ + setWindowTitle("QtMeshEditor"); + setFixedSize(480, 420); + setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint); + + auto* mainLayout = new QVBoxLayout(this); + mainLayout->setSpacing(12); + mainLayout->setContentsMargins(24, 20, 24, 16); + + // Title + auto* titleLabel = new QLabel("QtMeshEditor"); + QFont titleFont = titleLabel->font(); + titleFont.setPointSize(20); + titleFont.setBold(true); + titleLabel->setFont(titleFont); + titleLabel->setAlignment(Qt::AlignCenter); + mainLayout->addWidget(titleLabel); + + auto* subtitleLabel = new QLabel("Automate your 3D asset pipeline"); + subtitleLabel->setAlignment(Qt::AlignCenter); + subtitleLabel->setStyleSheet("color: gray;"); + mainLayout->addWidget(subtitleLabel); + + mainLayout->addSpacing(4); + + // Action buttons + auto* btnLayout = new QHBoxLayout(); + btnLayout->setSpacing(10); + + auto* newSceneBtn = new QPushButton("New Scene"); + newSceneBtn->setMinimumHeight(36); + newSceneBtn->setStyleSheet( + "QPushButton { background-color: #3080d0; color: white; border-radius: 4px; padding: 0 16px; font-weight: bold; }" + "QPushButton:hover { background-color: #4090e0; }"); + connect(newSceneBtn, &QPushButton::clicked, this, [this]() { + m_action = NewScene; + SentryReporter::addBreadcrumb("ui.action", "Welcome: New Scene"); + accept(); + }); + btnLayout->addWidget(newSceneBtn); + + auto* openFileBtn = new QPushButton("Open File..."); + openFileBtn->setMinimumHeight(36); + connect(openFileBtn, &QPushButton::clicked, this, [this]() { + QString file = QFileDialog::getOpenFileName( + this, "Open 3D File", QString(), + "3D Files (*.fbx *.gltf *.glb *.obj *.dae *.stl *.mesh *.3ds *.x);;All Files (*)"); + if (!file.isEmpty()) { + m_action = OpenFile; + m_selectedFile = file; + SentryReporter::addBreadcrumb("ui.action", "Welcome: Open File"); + accept(); + } + }); + btnLayout->addWidget(openFileBtn); + + mainLayout->addLayout(btnLayout); + + // Recent files + QSettings settings; + QStringList recentFiles = settings.value("RecentFiles/files").toStringList(); + + if (!recentFiles.isEmpty()) { + auto* recentLabel = new QLabel("Recent Files"); + QFont recentFont = recentLabel->font(); + recentFont.setBold(true); + recentLabel->setFont(recentFont); + mainLayout->addWidget(recentLabel); + + auto* recentList = new QListWidget(); + recentList->setMaximumHeight(120); + for (const QString& path : recentFiles) { + QFileInfo fi(path); + if (fi.exists()) { + auto* item = new QListWidgetItem(fi.fileName()); + item->setToolTip(path); + item->setData(Qt::UserRole, path); + recentList->addItem(item); + } + } + auto openRecent = [this](QListWidgetItem* item) { + m_action = OpenRecent; + m_selectedFile = item->data(Qt::UserRole).toString(); + SentryReporter::addBreadcrumb("ui.action", "Welcome: Open Recent"); + accept(); + }; + connect(recentList, &QListWidget::itemDoubleClicked, this, openRecent); + connect(recentList, &QListWidget::itemActivated, this, openRecent); + mainLayout->addWidget(recentList); + } + + // Tips + auto* tipsLabel = new QLabel( + "Tips: Press Ctrl+/ for keyboard shortcuts, " + "Ctrl+, for preferences. " + "Use the AI Chat panel to control the editor with natural language."); + tipsLabel->setWordWrap(true); + tipsLabel->setStyleSheet("color: gray; font-size: 11px;"); + mainLayout->addWidget(tipsLabel); + + mainLayout->addStretch(); + + // Bottom row: don't show again + Get Started + auto* bottomLayout = new QHBoxLayout(); + + auto* dontShowCheck = new QCheckBox("Don't show again"); + bottomLayout->addWidget(dontShowCheck); + + // Persist "Don't show again" on ANY exit path (New Scene, Open File, Recent, Get Started) + connect(this, &QDialog::accepted, this, [dontShowCheck]() { + if (dontShowCheck->isChecked()) { + QSettings settings; + settings.setValue("WelcomeScreen/dontShowAgain", true); + } + }); + + bottomLayout->addStretch(); + + auto* getStartedBtn = new QPushButton("Get Started"); + getStartedBtn->setMinimumHeight(32); + connect(getStartedBtn, &QPushButton::clicked, this, [this]() { + m_action = Dismissed; + SentryReporter::addBreadcrumb("ui.action", "Welcome: Dismissed"); + accept(); + }); + bottomLayout->addWidget(getStartedBtn); + + mainLayout->addLayout(bottomLayout); +} diff --git a/src/WelcomeDialog.h b/src/WelcomeDialog.h new file mode 100644 index 000000000..cd6323ad8 --- /dev/null +++ b/src/WelcomeDialog.h @@ -0,0 +1,26 @@ +#ifndef WELCOMEDIALOG_H +#define WELCOMEDIALOG_H + +#include +#include + +class WelcomeDialog : public QDialog +{ + Q_OBJECT +public: + enum Result { Dismissed, NewScene, OpenFile, OpenRecent }; + + explicit WelcomeDialog(QWidget* parent = nullptr); + + Result userAction() const { return m_action; } + QString selectedFile() const { return m_selectedFile; } + + /// Returns true if dialog should be shown (checks QSettings "WelcomeScreen/dontShowAgain") + static bool shouldShow(); + +private: + Result m_action = Dismissed; + QString m_selectedFile; +}; + +#endif // WELCOMEDIALOG_H diff --git a/src/WelcomeDialog_test.cpp b/src/WelcomeDialog_test.cpp new file mode 100644 index 000000000..fe45a072a --- /dev/null +++ b/src/WelcomeDialog_test.cpp @@ -0,0 +1,83 @@ +#include +#include "WelcomeDialog.h" + +#include +#include +#include + +class WelcomeDialogTests : public ::testing::Test { +protected: + void SetUp() override { + ASSERT_NE(qobject_cast(QCoreApplication::instance()), nullptr); + // Clear the setting before each test + QSettings settings; + settings.remove("WelcomeScreen/dontShowAgain"); + } + + void TearDown() override { + // Clean up the setting after each test + QSettings settings; + settings.remove("WelcomeScreen/dontShowAgain"); + } +}; + +TEST_F(WelcomeDialogTests, ShouldShowReturnsTrueByDefault) { + EXPECT_TRUE(WelcomeDialog::shouldShow()); +} + +TEST_F(WelcomeDialogTests, ShouldShowReturnsFalseAfterDontShowAgain) { + QSettings settings; + settings.setValue("WelcomeScreen/dontShowAgain", true); + EXPECT_FALSE(WelcomeDialog::shouldShow()); +} + +TEST_F(WelcomeDialogTests, ShouldShowReturnsTrueAfterSettingCleared) { + QSettings settings; + settings.setValue("WelcomeScreen/dontShowAgain", true); + EXPECT_FALSE(WelcomeDialog::shouldShow()); + + settings.remove("WelcomeScreen/dontShowAgain"); + EXPECT_TRUE(WelcomeDialog::shouldShow()); +} + +TEST_F(WelcomeDialogTests, DefaultActionIsDismissed) { + WelcomeDialog dialog; + EXPECT_EQ(dialog.userAction(), WelcomeDialog::Dismissed); +} + +TEST_F(WelcomeDialogTests, DefaultSelectedFileIsEmpty) { + WelcomeDialog dialog; + EXPECT_TRUE(dialog.selectedFile().isEmpty()); +} + +TEST_F(WelcomeDialogTests, ShouldShowRespectsDontShowAgainSetting) { + // Explicitly set the setting and verify shouldShow reads it + QSettings settings; + settings.setValue("WelcomeScreen/dontShowAgain", true); + settings.sync(); + EXPECT_FALSE(WelcomeDialog::shouldShow()); + + settings.setValue("WelcomeScreen/dontShowAgain", false); + settings.sync(); + EXPECT_TRUE(WelcomeDialog::shouldShow()); +} + +TEST_F(WelcomeDialogTests, ShouldShowReturnsTrueWhenSettingIsNonBooleanFalsy) { + // When the setting is set to a value that converts to false, shouldShow returns true + QSettings settings; + settings.setValue("WelcomeScreen/dontShowAgain", 0); + // QVariant(0).toBool() == false, so shouldShow should return true + EXPECT_TRUE(WelcomeDialog::shouldShow()); +} + +TEST_F(WelcomeDialogTests, MultipleDialogInstancesShareSameSettingsState) { + QSettings settings; + settings.setValue("WelcomeScreen/dontShowAgain", true); + + // Both calls should return the same result since they read from QSettings + EXPECT_FALSE(WelcomeDialog::shouldShow()); + EXPECT_FALSE(WelcomeDialog::shouldShow()); + + settings.remove("WelcomeScreen/dontShowAgain"); + EXPECT_TRUE(WelcomeDialog::shouldShow()); +} diff --git a/src/WelcomeScreenController.cpp b/src/WelcomeScreenController.cpp new file mode 100644 index 000000000..9d11b512d --- /dev/null +++ b/src/WelcomeScreenController.cpp @@ -0,0 +1,95 @@ +#include "WelcomeScreenController.h" +#include "SentryReporter.h" +#include +#include + +WelcomeScreenController* WelcomeScreenController::m_pSingleton = nullptr; + +WelcomeScreenController::WelcomeScreenController() + : QObject(nullptr) +{ +} + +WelcomeScreenController* WelcomeScreenController::instance() +{ + if (!m_pSingleton) + m_pSingleton = new WelcomeScreenController(); + return m_pSingleton; +} + +WelcomeScreenController* WelcomeScreenController::qmlInstance(QQmlEngine* engine, QJSEngine* /*scriptEngine*/) +{ + auto* inst = instance(); + engine->setObjectOwnership(inst, QQmlEngine::CppOwnership); + return inst; +} + +void WelcomeScreenController::kill() +{ + delete m_pSingleton; + m_pSingleton = nullptr; +} + +QStringList WelcomeScreenController::recentFiles() const +{ + QSettings settings; + return settings.value("RecentFiles/files").toStringList(); +} + +QStringList WelcomeScreenController::recentFileNames() const +{ + QStringList names; + for (const QString& path : recentFiles()) { + QFileInfo fi(path); + names.append(fi.fileName()); + } + return names; +} + +bool WelcomeScreenController::shouldShow() const +{ + QSettings settings; + return !settings.value("WelcomeScreen/dontShowAgain", false).toBool(); +} + +void WelcomeScreenController::setVisible(bool visible) +{ + if (m_visible == visible) + return; + m_visible = visible; + emit visibleChanged(); +} + +void WelcomeScreenController::openFile(const QString& path) +{ + SentryReporter::addBreadcrumb("ui.action", "Welcome screen: open recent file"); + setVisible(false); + emit requestOpenFile(path); +} + +void WelcomeScreenController::openFileDialog() +{ + SentryReporter::addBreadcrumb("ui.action", "Welcome screen: open file dialog"); + setVisible(false); + emit requestOpenFileDialog(); +} + +void WelcomeScreenController::newScene() +{ + SentryReporter::addBreadcrumb("ui.action", "Welcome screen: new scene"); + setVisible(false); + emit requestNewScene(); +} + +void WelcomeScreenController::dismiss(bool dontShowAgain) +{ + SentryReporter::addBreadcrumb("ui.action", + QString("Welcome screen dismissed (dontShowAgain=%1)").arg(dontShowAgain)); + + if (dontShowAgain) { + QSettings settings; + settings.setValue("WelcomeScreen/dontShowAgain", true); + } + + setVisible(false); +} diff --git a/src/WelcomeScreenController.h b/src/WelcomeScreenController.h new file mode 100644 index 000000000..b26302af4 --- /dev/null +++ b/src/WelcomeScreenController.h @@ -0,0 +1,63 @@ +#ifndef WELCOME_SCREEN_CONTROLLER_H +#define WELCOME_SCREEN_CONTROLLER_H + +#include +#include +#include + +class MainWindow; + +/** + * @brief QML_SINGLETON that bridges the Welcome Screen overlay with MainWindow actions. + * + * Provides recent files list, file-open/new-scene triggers, and the "don't show again" + * persistence via QSettings. Registered as a QML singleton under the "WelcomeScreen" module. + */ +class WelcomeScreenController : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY(QStringList recentFiles READ recentFiles NOTIFY recentFilesChanged) + Q_PROPERTY(QStringList recentFileNames READ recentFileNames NOTIFY recentFilesChanged) + Q_PROPERTY(bool visible READ isVisible WRITE setVisible NOTIFY visibleChanged) + +public: + static WelcomeScreenController* instance(); + static WelcomeScreenController* qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine); + static void kill(); + + void setMainWindow(MainWindow* mainWindow) { m_mainWindow = mainWindow; } + + QStringList recentFiles() const; + QStringList recentFileNames() const; + + bool isVisible() const { return m_visible; } + void setVisible(bool visible); + + /// Should the welcome screen be shown on startup? + bool shouldShow() const; + + Q_INVOKABLE void openFile(const QString& path); + Q_INVOKABLE void openFileDialog(); + Q_INVOKABLE void newScene(); + Q_INVOKABLE void dismiss(bool dontShowAgain); + +signals: + void recentFilesChanged(); + void visibleChanged(); + void requestOpenFile(const QString& path); + void requestOpenFileDialog(); + void requestNewScene(); + +private: + WelcomeScreenController(); + ~WelcomeScreenController() override = default; + + static WelcomeScreenController* m_pSingleton; + MainWindow* m_mainWindow = nullptr; + bool m_visible = false; +}; + +#endif // WELCOME_SCREEN_CONTROLLER_H diff --git a/src/WelcomeScreenController_test.cpp b/src/WelcomeScreenController_test.cpp new file mode 100644 index 000000000..2820e4982 --- /dev/null +++ b/src/WelcomeScreenController_test.cpp @@ -0,0 +1,200 @@ +#include +#include "WelcomeScreenController.h" + +#include +#include +#include +#include + +class WelcomeScreenControllerTests : public ::testing::Test { +protected: + void SetUp() override { + ASSERT_NE(qobject_cast(QCoreApplication::instance()), nullptr); + + // Save and clear relevant QSettings before each test + QSettings settings; + m_savedDontShow = settings.value("WelcomeScreen/dontShowAgain"); + m_savedRecent = settings.value("RecentFiles/files"); + settings.remove("WelcomeScreen/dontShowAgain"); + + WelcomeScreenController::kill(); + controller = WelcomeScreenController::instance(); + ASSERT_NE(controller, nullptr); + } + + void TearDown() override { + WelcomeScreenController::kill(); + + // Restore QSettings + QSettings settings; + if (m_savedDontShow.isValid()) + settings.setValue("WelcomeScreen/dontShowAgain", m_savedDontShow); + else + settings.remove("WelcomeScreen/dontShowAgain"); + if (m_savedRecent.isValid()) + settings.setValue("RecentFiles/files", m_savedRecent); + } + + WelcomeScreenController* controller = nullptr; + QVariant m_savedDontShow; + QVariant m_savedRecent; +}; + +// --- Singleton pattern --- + +TEST_F(WelcomeScreenControllerTests, SingletonReturnsSameInstance) { + EXPECT_EQ(controller, WelcomeScreenController::instance()); +} + +TEST_F(WelcomeScreenControllerTests, KillAndRecreateYieldsNewInstance) { + auto* first = WelcomeScreenController::instance(); + WelcomeScreenController::kill(); + auto* second = WelcomeScreenController::instance(); + ASSERT_NE(second, nullptr); + // After kill + recreate, the pointer may differ (new allocation) + // The key point is it doesn't crash and returns a valid object + EXPECT_NE(second, nullptr); + controller = second; // update for TearDown +} + +// --- recentFiles / recentFileNames --- + +TEST_F(WelcomeScreenControllerTests, RecentFilesReturnsQStringListFromSettings) { + QSettings settings; + QStringList paths = {"/tmp/model1.fbx", "/home/user/model2.obj", "/var/data/scene.gltf"}; + settings.setValue("RecentFiles/files", paths); + + QStringList result = controller->recentFiles(); + EXPECT_EQ(result, paths); +} + +TEST_F(WelcomeScreenControllerTests, RecentFilesReturnsEmptyWhenNoSetting) { + QSettings settings; + settings.remove("RecentFiles/files"); + + QStringList result = controller->recentFiles(); + EXPECT_TRUE(result.isEmpty()); +} + +TEST_F(WelcomeScreenControllerTests, RecentFileNamesReturnsFilenameOnly) { + QSettings settings; + QStringList paths = {"/tmp/model1.fbx", "/home/user/model2.obj"}; + settings.setValue("RecentFiles/files", paths); + + QStringList names = controller->recentFileNames(); + ASSERT_EQ(names.size(), 2); + EXPECT_EQ(names[0], "model1.fbx"); + EXPECT_EQ(names[1], "model2.obj"); +} + +TEST_F(WelcomeScreenControllerTests, RecentFileNamesEmptyWhenNoFiles) { + QSettings settings; + settings.remove("RecentFiles/files"); + + QStringList names = controller->recentFileNames(); + EXPECT_TRUE(names.isEmpty()); +} + +// --- shouldShow / dismiss --- + +TEST_F(WelcomeScreenControllerTests, ShouldShowReturnsTrueInitially) { + // dontShowAgain was cleared in SetUp + EXPECT_TRUE(controller->shouldShow()); +} + +TEST_F(WelcomeScreenControllerTests, ShouldShowReturnsFalseAfterDismissWithDontShowAgain) { + controller->dismiss(true); + EXPECT_FALSE(controller->shouldShow()); +} + +TEST_F(WelcomeScreenControllerTests, DismissWithDontShowAgainPersistsToSettings) { + controller->dismiss(true); + + QSettings settings; + EXPECT_TRUE(settings.value("WelcomeScreen/dontShowAgain", false).toBool()); +} + +TEST_F(WelcomeScreenControllerTests, DismissWithoutDontShowAgainDoesNotPersist) { + controller->dismiss(false); + + QSettings settings; + EXPECT_FALSE(settings.value("WelcomeScreen/dontShowAgain", false).toBool()); + EXPECT_TRUE(controller->shouldShow()); +} + +TEST_F(WelcomeScreenControllerTests, ShouldShowReturnsTrueAfterClearingSetting) { + controller->dismiss(true); + EXPECT_FALSE(controller->shouldShow()); + + QSettings settings; + settings.remove("WelcomeScreen/dontShowAgain"); + EXPECT_TRUE(controller->shouldShow()); +} + +// --- visible property --- + +TEST_F(WelcomeScreenControllerTests, InitiallyNotVisible) { + EXPECT_FALSE(controller->isVisible()); +} + +TEST_F(WelcomeScreenControllerTests, SetVisibleEmitsSignal) { + QSignalSpy spy(controller, &WelcomeScreenController::visibleChanged); + ASSERT_TRUE(spy.isValid()); + + controller->setVisible(true); + EXPECT_TRUE(controller->isVisible()); + EXPECT_EQ(spy.count(), 1); +} + +TEST_F(WelcomeScreenControllerTests, SetVisibleSameValueNoSignal) { + controller->setVisible(false); // already false + QSignalSpy spy(controller, &WelcomeScreenController::visibleChanged); + ASSERT_TRUE(spy.isValid()); + + controller->setVisible(false); + EXPECT_EQ(spy.count(), 0); +} + +TEST_F(WelcomeScreenControllerTests, DismissSetsVisibleFalse) { + controller->setVisible(true); + EXPECT_TRUE(controller->isVisible()); + + controller->dismiss(false); + EXPECT_FALSE(controller->isVisible()); +} + +// --- signals from actions --- + +TEST_F(WelcomeScreenControllerTests, OpenFileEmitsRequestAndHides) { + QSignalSpy spy(controller, &WelcomeScreenController::requestOpenFile); + ASSERT_TRUE(spy.isValid()); + + controller->setVisible(true); + controller->openFile("/tmp/test.fbx"); + + EXPECT_EQ(spy.count(), 1); + EXPECT_EQ(spy.at(0).at(0).toString(), "/tmp/test.fbx"); + EXPECT_FALSE(controller->isVisible()); +} + +TEST_F(WelcomeScreenControllerTests, OpenFileDialogEmitsRequestAndHides) { + QSignalSpy spy(controller, &WelcomeScreenController::requestOpenFileDialog); + ASSERT_TRUE(spy.isValid()); + + controller->setVisible(true); + controller->openFileDialog(); + + EXPECT_EQ(spy.count(), 1); + EXPECT_FALSE(controller->isVisible()); +} + +TEST_F(WelcomeScreenControllerTests, NewSceneEmitsRequestAndHides) { + QSignalSpy spy(controller, &WelcomeScreenController::requestNewScene); + ASSERT_TRUE(spy.isValid()); + + controller->setVisible(true); + controller->newScene(); + + EXPECT_EQ(spy.count(), 1); + EXPECT_FALSE(controller->isVisible()); +} diff --git a/src/main.cpp b/src/main.cpp index 664892142..2581c1640 100755 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,6 +1,7 @@ #include #include #include +#include "WelcomeDialog.h" #include #include #include @@ -244,6 +245,20 @@ int main(int argc, char *argv[]) return ThemeManager::qmlInstance(engine, scriptEngine); }); + // Show welcome dialog before creating MainWindow + QString welcomeOpenFile; + bool welcomeNewScene = false; + if (WelcomeDialog::shouldShow()) { + WelcomeDialog welcome; + welcome.exec(); + if (welcome.userAction() == WelcomeDialog::OpenFile || + welcome.userAction() == WelcomeDialog::OpenRecent) { + welcomeOpenFile = welcome.selectedFile(); + } else if (welcome.userAction() == WelcomeDialog::NewScene) { + welcomeNewScene = true; + } + } + int result = 0; try { auto startupTxn = SentryReporter::startTransaction("app.startup", "app.load"); @@ -251,6 +266,13 @@ int main(int argc, char *argv[]) MainWindow w; w.show(); + // Act on welcome dialog choice + if (!welcomeOpenFile.isEmpty()) { + QTimer::singleShot(0, &w, [&w, welcomeOpenFile]() { + w.loadFile(welcomeOpenFile); + }); + } + // Start MCP server alongside GUI if requested if (mcpWithGuiMode) { auto *mcpServer = new MCPServer(&w); diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 44541f345..87d0f8996 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -57,7 +57,10 @@ #include "MeshLodController.h" #include "MeshValidator.h" #include "MaterialPresetLibrary.h" +#include "MaterialPreviewRenderer.h" #include "AIChatManager.h" +#include "WelcomeScreenController.h" +#include "AssetBrowserController.h" #include #include #include @@ -236,6 +239,7 @@ MainWindow::~MainWindow() MeshLodController::kill(); MeshValidator::kill(); MaterialPresetLibrary::kill(); + MaterialPreviewRenderer::kill(); AIChatManager::kill(); // Only destroy Manager if it still exists and belongs to this MainWindow // (In tests, Manager may be destroyed separately in TearDown) @@ -360,6 +364,14 @@ void MainWindow::initToolBar() [](QQmlEngine* engine, QJSEngine*) -> QObject* { return AIChatManager::qmlInstance(engine, nullptr); }); + qmlRegisterSingletonType("WelcomeScreen", 1, 0, "WelcomeScreenController", + [](QQmlEngine* engine, QJSEngine*) -> QObject* { + return WelcomeScreenController::qmlInstance(engine, nullptr); + }); + qmlRegisterSingletonType("AssetBrowser", 1, 0, "AssetBrowserController", + [](QQmlEngine* engine, QJSEngine*) -> QObject* { + return AssetBrowserController::qmlInstance(engine, nullptr); + }); m_propertiesPanel->setSource(QUrl("qrc:/PropertiesPanel/PropertiesPanel.qml")); @@ -421,6 +433,77 @@ void MainWindow::initToolBar() }); } + // Asset Browser dock + { + auto* assetBrowserWidget = new QQuickWidget(); + assetBrowserWidget->setResizeMode(QQuickWidget::SizeRootObjectToView); + assetBrowserWidget->setMinimumWidth(250); + assetBrowserWidget->setMinimumHeight(200); + assetBrowserWidget->setFocusPolicy(Qt::StrongFocus); + assetBrowserWidget->setSource(QUrl("qrc:/AssetBrowser/AssetBrowser.qml")); + m_assetBrowserDock = new QDockWidget(tr("Asset Browser"), this); + m_assetBrowserDock->setWidget(assetBrowserWidget); + m_assetBrowserDock->setObjectName("AssetBrowserDock"); + addDockWidget(Qt::BottomDockWidgetArea, m_assetBrowserDock); + m_assetBrowserDock->hide(); + + // Connect Browse button — open a native directory picker from MainWindow + // (QFileDialog needs a proper parent widget on macOS) + auto* abController = AssetBrowserController::instance(); + connect(abController, &AssetBrowserController::importMeshRequested, this, [this](const QStringList& paths) { + SentryReporter::addBreadcrumb("ui.action", "Asset Browser: import mesh"); + importMeshs(paths); + }); + } + + // Welcome Screen overlay — shown on first launch or when user hasn't opted out + { + m_welcomeController = WelcomeScreenController::instance(); + m_welcomeController->setMainWindow(this); + + m_welcomeScreen = new QQuickWidget(this); + m_welcomeScreen->setResizeMode(QQuickWidget::SizeRootObjectToView); + m_welcomeScreen->setAttribute(Qt::WA_TranslucentBackground); + m_welcomeScreen->setClearColor(Qt::transparent); + m_welcomeScreen->setSource(QUrl("qrc:/WelcomeScreen/WelcomeScreen.qml")); + m_welcomeScreen->setFocusPolicy(Qt::StrongFocus); + m_welcomeScreen->raise(); + + // Connect controller signals to MainWindow actions + connect(m_welcomeController, &WelcomeScreenController::requestOpenFile, + this, [this](const QString& path) { + if (QFileInfo::exists(path)) { + addToRecentFiles(path); + if (path.endsWith(".scene.glb") || path.endsWith(".scene.gltf")) + MeshImporterExporter::sceneImporter(path); + else + mUriList.append(path); + } + }); + connect(m_welcomeController, &WelcomeScreenController::requestOpenFileDialog, + this, &MainWindow::on_actionImport_triggered); + connect(m_welcomeController, &WelcomeScreenController::requestNewScene, + this, [this]() { + Manager::getSingleton()->CreateEmptyScene(); + }); + + // Show/hide the overlay widget when controller visibility changes + connect(m_welcomeController, &WelcomeScreenController::visibleChanged, + this, [this]() { + if (m_welcomeController->isVisible()) { + showWelcomeScreen(); + } else { + hideWelcomeScreen(); + } + }); + + // Welcome screen is now a standalone dialog shown before MainWindow (in main.cpp). + // The QML overlay is kept for programmatic use but not shown on startup. + { + m_welcomeScreen->hide(); + } + } + // 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) @@ -534,6 +617,33 @@ void MainWindow::initToolBar() for (EditorViewport* vp : mDockWidgetList) connect(vp->getOgreWidget(), &OgreWidget::focusOnWidget, m_meshInfoOverlay, &MeshInfoOverlay::setActiveWidget); + // Asset Browser dock toggle via View menu + connect(ui->actionAsset_Browser, &QAction::toggled, this, [this](bool checked) { + SentryReporter::addBreadcrumb("ui.action", + checked ? "Asset Browser shown" : "Asset Browser hidden"); + if (m_assetBrowserDock) { + m_assetBrowserDock->setVisible(checked); + } + }); + // Sync menu checkmark when dock is closed via its title bar + if (m_assetBrowserDock) { + connect(m_assetBrowserDock, &QDockWidget::visibilityChanged, + ui->actionAsset_Browser, &QAction::setChecked); + } + + // Connect Browse button to a native file dialog (must be parented to MainWindow on macOS) + connect(AssetBrowserController::instance(), &AssetBrowserController::browseRequested, + this, [this]() { + QTimer::singleShot(0, this, [this]() { + QString dir = QFileDialog::getExistingDirectory( + this, tr("Select Asset Directory"), + AssetBrowserController::instance()->rootPath(), + QFileDialog::DontUseNativeDialog | QFileDialog::ShowDirsOnly); + if (!dir.isEmpty()) + AssetBrowserController::instance()->setRootPath(dir); + }); + }); + // ViewCube (3D navigation gizmo) — top-level window positioned over the active viewport m_viewCubeController = new ViewCubeController(this); // Force software rendering for the ViewCube QML widget (avoid GL conflicts with Ogre) @@ -574,6 +684,38 @@ void MainWindow::initToolBar() QAction* mcpSettingsAction = aiMenu->addAction(tr("MCP Server Settings...")); connect(mcpSettingsAction, &QAction::triggered, this, &MainWindow::showMCPSettings); + // Keyboard Shortcuts reference in Help menu + QAction* shortcutsAction = ui->menuHelp->addAction(tr("Keyboard Shortcuts")); + shortcutsAction->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_Slash)); + connect(shortcutsAction, &QAction::triggered, this, [this]() { + SentryReporter::addBreadcrumb("ui.action", "Help > Keyboard Shortcuts opened"); + + auto* widget = new QQuickWidget(this); + widget->setResizeMode(QQuickWidget::SizeRootObjectToView); + widget->setSource(QUrl("qrc:/ShortcutReference/ShortcutReference.qml")); + widget->setAttribute(Qt::WA_DeleteOnClose); + widget->setMinimumSize(520, 560); + widget->resize(520, 560); + widget->setWindowFlags(Qt::Dialog); + widget->setWindowTitle(tr("Keyboard Shortcuts")); + widget->show(); + }); + + // Preferences dialog (Edit > Preferences, Ctrl+,) + connect(ui->actionPreferences, &QAction::triggered, this, [this]() { + SentryReporter::addBreadcrumb("ui.action", "Edit > Preferences opened"); + + auto* widget = new QQuickWidget(this); + widget->setResizeMode(QQuickWidget::SizeRootObjectToView); + widget->setSource(QUrl("qrc:/PreferencesDialog/PreferencesDialog.qml")); + widget->setAttribute(Qt::WA_DeleteOnClose); + widget->setMinimumSize(480, 520); + widget->resize(480, 520); + widget->setWindowFlags(Qt::Dialog); + widget->setWindowTitle(tr("Preferences")); + widget->show(); + }); + // Crash reporting toggle in Help menu ui->menuHelp->addSeparator(); QAction* crashReportAction = ui->menuHelp->addAction(tr("Send Crash Reports")); @@ -660,6 +802,10 @@ bool MainWindow::frameEnded(const Ogre::FrameEvent &evt) { if(mUriList.size()) { + // Auto-hide the welcome screen when a file is loaded + if (m_welcomeController && m_welcomeController->isVisible()) + m_welcomeController->setVisible(false); + importMeshs(mUriList); mUriList.clear(); } @@ -849,6 +995,13 @@ void MainWindow::dragEnterEvent(QDragEnterEvent *event) event->acceptProposedAction(); } +void MainWindow::resizeEvent(QResizeEvent *event) +{ + QMainWindow::resizeEvent(event); + if (m_welcomeScreen && m_welcomeScreen->isVisible()) + repositionWelcomeScreen(); +} + //////////////////////////////////////////////////////////////////////////////////////////////////////// // LCOV_EXCL_START — opens QFileDialog void MainWindow::on_actionImport_triggered() @@ -866,6 +1019,16 @@ void MainWindow::on_actionImport_triggered() } // LCOV_EXCL_STOP +void MainWindow::loadFile(const QString& filePath) +{ + if (filePath.isEmpty()) return; + addToRecentFiles(filePath); + if (filePath.endsWith(".scene.glb") || filePath.endsWith(".scene.gltf")) + MeshImporterExporter::sceneImporter(filePath); + else + mUriList.append(filePath); +} + void MainWindow::importMeshs(const QStringList &_uriList) { auto txn = SentryReporter::startTransaction("ui.import", "file.import"); @@ -1710,10 +1873,15 @@ void MainWindow::addToRecentFiles(const QString& filePath) QStringList files = settings.value("RecentFiles/files").toStringList(); files.removeAll(filePath); files.prepend(filePath); - while (files.size() > 10) + int maxRecent = settings.value("General/recentFilesCount", 10).toInt(); + while (files.size() > maxRecent) files.removeLast(); settings.setValue("RecentFiles/files", files); updateRecentFilesMenu(); + + // Keep the welcome screen's recent files list in sync + if (m_welcomeController) + emit m_welcomeController->recentFilesChanged(); } void MainWindow::updateRecentFilesMenu() @@ -1740,6 +1908,8 @@ void MainWindow::updateRecentFilesMenu() QSettings settings; settings.remove("RecentFiles/files"); updateRecentFilesMenu(); + if (m_welcomeController) + emit m_welcomeController->recentFilesChanged(); }); } @@ -1764,6 +1934,42 @@ void MainWindow::openRecentFile() files.removeAll(filePath); settings.setValue("RecentFiles/files", files); updateRecentFilesMenu(); + if (m_welcomeController) + emit m_welcomeController->recentFilesChanged(); } } +// LCOV_EXCL_START — requires display +void MainWindow::showWelcomeScreen() +{ + if (!m_welcomeScreen) return; + repositionWelcomeScreen(); + m_welcomeScreen->show(); + m_welcomeScreen->raise(); + m_welcomeScreen->setFocus(); + + // Hide the ViewCube while welcome screen is showing (it has WindowStaysOnTopHint) + if (m_viewCubeController) + m_viewCubeController->setVisible(false); +} + +void MainWindow::hideWelcomeScreen() +{ + if (m_welcomeScreen) + m_welcomeScreen->hide(); + + // Restore ViewCube visibility based on the menu toggle state + if (m_viewCubeController && ui->actionShow_View_Cube->isChecked()) + m_viewCubeController->setVisible(true); +} + +void MainWindow::repositionWelcomeScreen() +{ + if (!m_welcomeScreen) return; + + // Cover the entire main window — the QML overlay has its own + // semi-transparent background that handles the visual layering. + m_welcomeScreen->setGeometry(rect()); +} +// LCOV_EXCL_STOP + diff --git a/src/mainwindow.h b/src/mainwindow.h index bdf0bb6bf..4fc48224a 100755 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -17,6 +17,8 @@ class NormalVisualizer; class MeshInfoOverlay; class ViewCubeController; class PropertiesPanelController; +class WelcomeScreenController; +class AssetBrowserController; class QQuickWidget; namespace Ui { @@ -42,6 +44,7 @@ class MainWindow : public QMainWindow, public Ogre::FrameListener explicit MainWindow(QWidget *parent = nullptr); virtual ~MainWindow(); void importMeshs(const QStringList &_uriList); + void loadFile(const QString& filePath); void setMCPServer(MCPServer* server); void keyPressEvent(QKeyEvent *event) override; @@ -125,6 +128,7 @@ public slots: void closeEvent(QCloseEvent *event) override; void keyReleaseEvent(QKeyEvent *event) override; void dragEnterEvent(QDragEnterEvent *event) override; + void resizeEvent(QResizeEvent *event) override; private: void initToolBar(); @@ -137,11 +141,18 @@ public slots: MCPServer* m_mcpServer = nullptr; QQuickWidget* m_propertiesPanel = nullptr; QDockWidget* m_chatDock = nullptr; + QDockWidget* m_assetBrowserDock = nullptr; QMenu* m_recentFilesMenu = nullptr; void addToRecentFiles(const QString& filePath); void updateRecentFilesMenu(); void openRecentFile(); + + WelcomeScreenController* m_welcomeController = nullptr; + QQuickWidget* m_welcomeScreen = nullptr; + void showWelcomeScreen(); + void hideWelcomeScreen(); + void repositionWelcomeScreen(); }; #endif // MAINWINDOW_H diff --git a/src/qml_resources.qrc b/src/qml_resources.qrc index 1d8859fd3..37eaf0be4 100644 --- a/src/qml_resources.qrc +++ b/src/qml_resources.qrc @@ -28,4 +28,16 @@ ../qml/AIChatPanel.qml + + ../qml/WelcomeScreen.qml + + + ../qml/ShortcutReference.qml + + + ../qml/PreferencesDialog.qml + + + ../qml/AssetBrowser.qml + \ No newline at end of file diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b3631f9ee..7b08f567c 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -69,10 +69,14 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/MeshLodController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/MeshValidator.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/AIChatManager.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/WelcomeScreenController.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/WelcomeDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/SubMeshTransform.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/SubEntityHighlight.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/ScanConfig.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/ScanEngine.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/AssetBrowserController.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/MaterialPreviewRenderer.cpp ) set(TEST_HEADER_FILES @@ -134,10 +138,14 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/MeshLodController.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/MeshValidator.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/AIChatManager.h + ${CMAKE_CURRENT_SOURCE_DIR}/../src/WelcomeScreenController.h + ${CMAKE_CURRENT_SOURCE_DIR}/../src/WelcomeDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/SubMeshTransform.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/SubEntityHighlight.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/ScanConfig.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/ScanEngine.h + ${CMAKE_CURRENT_SOURCE_DIR}/../src/AssetBrowserController.h + ${CMAKE_CURRENT_SOURCE_DIR}/../src/MaterialPreviewRenderer.h ) # Add Ogre-Procedural sources (matching src/CMakeLists.txt) diff --git a/ui_files/mainwindow.ui b/ui_files/mainwindow.ui index 390264399..1c80ad645 100755 --- a/ui_files/mainwindow.ui +++ b/ui_files/mainwindow.ui @@ -61,6 +61,7 @@ + @@ -113,6 +114,8 @@ + + @@ -251,6 +254,9 @@ Show Grid + + Toggle Grid Visibility + @@ -262,6 +268,9 @@ Show Normals + + Toggle Vertex Normals Display + @@ -273,11 +282,17 @@ Show Mesh Info + + Toggle Mesh Info Overlay + Import Mesh + + Import Mesh + @@ -295,6 +310,9 @@ Material Editor + + Open Material Editor + @@ -357,7 +375,7 @@ Move Object - Move Object (W) + Translate Mode (W) true @@ -375,7 +393,7 @@ Rotate Object - Rotate Object (E) + Rotate Mode (E) @@ -393,7 +411,7 @@ Select Object - Select Object (Q) + Select Mode (Q) @@ -405,13 +423,16 @@ Remove Object - Remove Object (Del) + Delete Selected (Del) Export Selected + + Export Selected Mesh + @@ -504,6 +525,9 @@ Undo + + Undo (Ctrl+Z) + Ctrl+Z @@ -512,6 +536,9 @@ Redo + + Redo (Ctrl+Shift+Z) + Ctrl+Shift+Z @@ -520,6 +547,9 @@ Duplicate + + Duplicate (Ctrl+D) + Ctrl+D @@ -536,7 +566,7 @@ Scale Object - Scale Object (R) + Scale Mode (R) @@ -571,11 +601,28 @@ Show View Cube + + Toggle 3D View Cube + + + + + Preferences... + + + Ctrl+, + + + Open Preferences (Ctrl+,) + Open Scene + + Open Scene (Ctrl+O) + Ctrl+O @@ -584,6 +631,9 @@ Save Scene + + Save Scene (Ctrl+S) + Ctrl+S @@ -595,6 +645,9 @@ Group Nodes + + Group Nodes (Ctrl+G) + Ctrl+G @@ -606,10 +659,27 @@ Ungroup Nodes + + Ungroup Nodes (Ctrl+Shift+G) + Ctrl+Shift+G + + + true + + + false + + + Asset Browser + + + Toggle Asset Browser Panel + +