From 25f9e96c52aad04a8c2a15734689f453c379e890 Mon Sep 17 00:00:00 2001 From: nicolas-lambert-tc Date: Mon, 29 Jun 2026 18:27:47 +0200 Subject: [PATCH 1/4] [core] Add explicit template file handling --- meshroom/core/__init__.py | 11 ++++++--- meshroom/core/files.py | 42 +++++++++++++++++++++++++++++++++++ meshroom/core/graph.py | 3 ++- meshroom/core/plugins/base.py | 15 ++++++++----- meshroom/multiview.py | 13 ++++++++--- 5 files changed, 72 insertions(+), 12 deletions(-) create mode 100644 meshroom/core/files.py diff --git a/meshroom/core/__init__.py b/meshroom/core/__init__.py index 452d922d54..fd10a1448c 100644 --- a/meshroom/core/__init__.py +++ b/meshroom/core/__init__.py @@ -22,6 +22,7 @@ from meshroom.core.plugins.base import NodeDescProvider, Plugin from meshroom.core.plugins.env import processEnvFactory from meshroom.core.plugins.manager import PluginManager +from meshroom.core.files import MESHROOM_PROJECT_EXTENSION, MESHROOM_TEMPLATE_EXTENSION, hasExtension, isTemplateFile from meshroom.core.submitter import BaseSubmitter from meshroom.env import EnvVar, meshroomFolder from . import desc @@ -418,9 +419,13 @@ def loadPipelineTemplates(folder: str): if not os.path.isdir(folder): logging.error(f"Pipeline templates folder '{folder}' does not exist.") return - for file in os.listdir(folder): - if file.endswith(".mg") and file not in pipelineTemplates: - pipelineTemplates[os.path.splitext(file)[0]] = os.path.join(folder, file) + for file in sorted(os.listdir(folder)): + filepath = os.path.join(folder, file) + templateName = Path(file).stem + if hasExtension(filepath, (MESHROOM_TEMPLATE_EXTENSION,)): + pipelineTemplates[templateName] = filepath + elif hasExtension(filepath, (MESHROOM_PROJECT_EXTENSION,)) and isTemplateFile(filepath): + pipelineTemplates.setdefault(templateName, filepath) def initNodes(): diff --git a/meshroom/core/files.py b/meshroom/core/files.py new file mode 100644 index 0000000000..caaf4b340c --- /dev/null +++ b/meshroom/core/files.py @@ -0,0 +1,42 @@ +import json +from pathlib import Path +from typing import Dict, Tuple + + +MESHROOM_PROJECT_EXTENSION = ".mg" +MESHROOM_TEMPLATE_EXTENSION = ".mgt" +MESHROOM_LEGACY_TEMPLATE_EXTENSION = MESHROOM_PROJECT_EXTENSION + + +def extensionLower(filepath) -> str: + return Path(filepath).suffix.lower() + + +def hasExtension(filepath, extensions: Tuple[str, ...]) -> bool: + return extensionLower(filepath) in extensions + + +def withExtension(filepath, extension: str) -> str: + """Return filepath with the requested extension if it has no matching suffix.""" + filepath = str(filepath) + if extensionLower(filepath) != extension: + filepath += extension + return filepath + + +def isTemplateGraphData(graphData: Dict) -> bool: + return bool(graphData.get("header", {}).get("template", False)) + + +def isTemplateFile(filepath) -> bool: + """Return whether filepath should be opened through the template flow.""" + path = Path(filepath) + if extensionLower(path) == MESHROOM_TEMPLATE_EXTENSION: + return True + if extensionLower(path) != MESHROOM_LEGACY_TEMPLATE_EXTENSION: + return False + try: + with open(path) as file: + return isTemplateGraphData(json.load(file)) + except (OSError, json.JSONDecodeError, UnicodeDecodeError): + return False diff --git a/meshroom/core/graph.py b/meshroom/core/graph.py index 4c24fbb33d..553629215b 100644 --- a/meshroom/core/graph.py +++ b/meshroom/core/graph.py @@ -19,6 +19,7 @@ from meshroom.core import submitters from meshroom.core.attribute import Attribute, AnySet, ListAttribute, GroupAttribute from meshroom.core.exception import GraphCompatibilityError, InvalidEdgeError, StopGraphVisit, StopBranchVisit, CyclicDependencyError +from meshroom.core.files import MESHROOM_PROJECT_EXTENSION, isTemplateFile from meshroom.core.graphIO import GraphIO, GraphSerializer, TemplateGraphSerializer, PartialGraphSerializer from meshroom.core.node import BaseNode, Status, Node, CompatibilityNode from meshroom.core.nodeFactory import nodeFactory, getNodeConstructor @@ -189,7 +190,7 @@ def generateTempProjectFilepath(tmpFolder=None): from meshroom.env import EnvVar tmpFolder = EnvVar.get(EnvVar.MESHROOM_TEMP_PATH) timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M") - return os.path.join(tmpFolder, f"meshroom_{timestamp}.mg") + return os.path.join(tmpFolder, f"meshroom_{timestamp}{MESHROOM_PROJECT_EXTENSION}") class Graph(BaseObject): diff --git a/meshroom/core/plugins/base.py b/meshroom/core/plugins/base.py index be89de06a7..abb1e22fc9 100644 --- a/meshroom/core/plugins/base.py +++ b/meshroom/core/plugins/base.py @@ -14,6 +14,7 @@ from meshroom.core import desc from meshroom.core.desc.attribute import ValueTypeErrors from meshroom.core.plugins.env import ProcessEnv +from meshroom.core.files import MESHROOM_PROJECT_EXTENSION, MESHROOM_TEMPLATE_EXTENSION, hasExtension, isTemplateFile class Plugin(BaseObject): @@ -26,8 +27,8 @@ class Plugin(BaseObject): user: whether the plugin is a user plugin (not maintained by the core Meshroom team) nodeDescProviders: dictionary mapping the name of a node descriptor provider contained in the plugin to its corresponding NodeDescProvider object - templates: dictionary mapping the name of templates (.mg files) associated to the plugin - with their absolute paths + templates: dictionary mapping the name of templates associated to the plugin with their + absolute paths configEnv: the environment variables and their values, as described in the plugin's configuration file configFullEnv: the static merge of os.environ and configEnv, with os.environ taking precedence @@ -165,9 +166,13 @@ def loadTemplates(self): before being filled again. """ self._templates.clear() - for file in os.listdir(self.path): - if file.endswith(".mg"): - self._templates[os.path.splitext(file)[0]] = os.path.join(self.path, file) + for file in sorted(os.listdir(self.path)): + filepath = os.path.join(self.path, file) + templateName = Path(file).stem + if hasExtension(filepath, (MESHROOM_TEMPLATE_EXTENSION,)): + self._templates[templateName] = filepath + elif hasExtension(filepath, (MESHROOM_PROJECT_EXTENSION,)) and isTemplateFile(filepath): + self._templates.setdefault(templateName, filepath) def loadConfig(self): """ diff --git a/meshroom/multiview.py b/meshroom/multiview.py index 00189eed6a..7feaa48826 100644 --- a/meshroom/multiview.py +++ b/meshroom/multiview.py @@ -1,5 +1,7 @@ import os +from meshroom.core.files import MESHROOM_PROJECT_EXTENSION, MESHROOM_TEMPLATE_EXTENSION, hasExtension as hasFileExtension + # Supported image extensions imageExtensions = [ # bmp: @@ -67,14 +69,15 @@ '.mxf', ] panoramaInfoExtensions = ['.xml'] -meshroomSceneExtensions = ['.mg'] +meshroomSceneExtensions = [MESHROOM_PROJECT_EXTENSION] +meshroomTemplateExtensions = [MESHROOM_TEMPLATE_EXTENSION] def hasExtension(filepath, extensions): """ Return whether filepath is one of the following extensions. """ if os.path.isdir(filepath): return False - return os.path.splitext(filepath)[1].lower() in extensions + return hasFileExtension(filepath, extensions) class FilesByType: @@ -83,16 +86,18 @@ def __init__(self): self.videos = [] self.panoramaInfo = [] self.meshroomScenes = [] + self.meshroomTemplates = [] self.other = [] def __bool__(self): - return self.images or self.videos or self.panoramaInfo or self.meshroomScenes + return self.images or self.videos or self.panoramaInfo or self.meshroomScenes or self.meshroomTemplates def extend(self, other): self.images.extend(other.images) self.videos.extend(other.videos) self.panoramaInfo.extend(other.panoramaInfo) self.meshroomScenes.extend(other.meshroomScenes) + self.meshroomTemplates.extend(other.meshroomTemplates) self.other.extend(other.other) def addFile(self, file): @@ -104,6 +109,8 @@ def addFile(self, file): self.panoramaInfo.append(file) elif hasExtension(file, meshroomSceneExtensions): self.meshroomScenes.append(file) + elif hasExtension(file, meshroomTemplateExtensions): + self.meshroomTemplates.append(file) else: self.other.append(file) From 27cad02051a161863f5710175720b719aff71850 Mon Sep 17 00:00:00 2001 From: nicolas-lambert-tc Date: Mon, 29 Jun 2026 18:28:07 +0200 Subject: [PATCH 2/4] [ui] Add explicit template actions and recents --- meshroom/ui/app.py | 98 ++++++++++++---- meshroom/ui/graph.py | 11 +- meshroom/ui/qml/Application.qml | 111 ++++++++++++------ meshroom/ui/qml/GraphEditor/GraphEditor.qml | 5 +- meshroom/ui/qml/ImageGallery/ImageGallery.qml | 2 +- .../ui/qml/ImageGallery/ImageGridView.qml | 10 +- .../ui/qml/ImageGallery/ImageListView.qml | 10 +- meshroom/ui/qml/WorkspaceView.qml | 9 +- meshroom/ui/qml/main.qml | 5 +- meshroom/ui/scene.py | 51 ++++++-- 10 files changed, 221 insertions(+), 91 deletions(-) diff --git a/meshroom/ui/app.py b/meshroom/ui/app.py index 6cc1da8cc8..d24b390915 100644 --- a/meshroom/ui/app.py +++ b/meshroom/ui/app.py @@ -262,6 +262,7 @@ def __init__(self, inputArgs): # Initialize the list of recent project files self._recentProjectFiles = self._getRecentProjectFilesFromSettings() + self._recentTemplateFiles = self._getRecentTemplateFilesFromSettings() # Flag set to True if, for all the project files in the list, thumbnails have been retrieved when they # are available. If set to False, then all the paths in the list are accurate, but some thumbnails might # be retrievable @@ -330,7 +331,10 @@ def __init__(self, inputArgs): if args.project: args.project = os.path.abspath(args.project) self._activeProject.load(args.project) - self.addRecentProjectFile(args.project) + if self._activeProject.graph.filepath: + self.addRecentProjectFile(args.project) + else: + self.addRecentTemplateFile(args.project) elif args.new: self._activeProject.new() elif args.latest or args.latest2 or args.latest3: @@ -467,6 +471,27 @@ def _getRecentProjectFilesFromSettings(self) -> list[dict[str, str]]: settings.endGroup() return projects + def _getRecentTemplateFilesFromSettings(self) -> list[dict[str, str]]: + """ + Read the list of recent template files from QSettings. + + Returns: + The list containing dictionaries of the form {"path": "/path/to/template/file", "status": 1}. + """ + templates = [] + settings = QSettings() + settings.beginGroup("RecentFiles") + size = settings.beginReadArray("Templates") + for i in range(size): + settings.setArrayIndex(i) + path = settings.value("filepath") + if path: + fileStatus = FileStatus.EXISTS if os.path.isfile(path) else FileStatus.MISSING + templates.append({"path": path, "status": fileStatus.value}) + settings.endArray() + settings.endGroup() + return templates + @Slot() def updateRecentProjectFilesThumbnails(self) -> None: """ @@ -498,16 +523,7 @@ def addRecentProjectFile(self, projectFile) -> None: Args: projectFile (str or QUrl): path to the project file to add to the list """ - if not isinstance(projectFile, (QUrl, str)): - raise TypeError(f"Unexpected data type: {projectFile.__class__}") - if isinstance(projectFile, QUrl): - projectFileNorm = projectFile.toLocalFile() - if not projectFileNorm: - projectFileNorm = projectFile.toString() - else: - projectFileNorm = QUrl(projectFile).toLocalFile() - if not projectFileNorm: - projectFileNorm = QUrl.fromLocalFile(projectFile).toLocalFile() + projectFileNorm = self._normalizeFilepath(projectFile) # Get the list of recent projects without re-reading the QSettings projects = self._recentProjectFiles @@ -542,6 +558,39 @@ def addRecentProjectFile(self, projectFile) -> None: self._updatedRecentProjectFilesThumbnails = False # Thumbnails may not be up-to-date self.recentProjectFilesChanged.emit() + @Slot(str) + @Slot(QUrl) + def addRecentTemplateFile(self, templateFile) -> None: + """ + Add a template file to the list of recent template files. + """ + templateFileNorm = self._normalizeFilepath(templateFile) + + templates = self._recentTemplateFiles + filepaths = [t["path"] for t in templates] + if templateFileNorm in filepaths: + idx = filepaths.index(templateFileNorm) + del templates[idx] + + templates.insert(0, {"path": templateFileNorm, "status": FileStatus.EXISTS.value}) + + maxNbTemplates = 40 + if len(templates) > maxNbTemplates: + templates = templates[0:maxNbTemplates] + + settings = QSettings() + settings.beginGroup("RecentFiles") + settings.beginWriteArray("Templates") + for i, t in enumerate(templates): + settings.setArrayIndex(i) + settings.setValue("filepath", t["path"]) + settings.endArray() + settings.endGroup() + settings.sync() + + self._recentTemplateFiles = templates + self.recentTemplateFilesChanged.emit() + @Slot(str) @Slot(QUrl) def removeRecentProjectFile(self, projectFile) -> None: @@ -550,16 +599,7 @@ def removeRecentProjectFile(self, projectFile) -> None: If the provided filepath is not already present in the list of recent project files, nothing is done. Otherwise, it is effectively removed and the QSettings are updated accordingly. """ - if not isinstance(projectFile, (QUrl, str)): - raise TypeError(f"Unexpected data type: {projectFile.__class__}") - if isinstance(projectFile, QUrl): - projectFileNorm = projectFile.toLocalFile() - if not projectFileNorm: - projectFileNorm = projectFile.toString() - else: - projectFileNorm = QUrl(projectFile).toLocalFile() - if not projectFileNorm: - projectFileNorm = QUrl.fromLocalFile(projectFile).toLocalFile() + projectFileNorm = self._normalizeFilepath(projectFile) # Get the list of recent projects without re-reading the QSettings projects = self._recentProjectFiles @@ -587,6 +627,20 @@ def removeRecentProjectFile(self, projectFile) -> None: self._recentProjectFiles = projects self.recentProjectFilesChanged.emit() + @staticmethod + def _normalizeFilepath(filepath) -> str: + if not isinstance(filepath, (QUrl, str)): + raise TypeError(f"Unexpected data type: {filepath.__class__}") + if isinstance(filepath, QUrl): + filepathNorm = filepath.toLocalFile() + if not filepathNorm: + filepathNorm = filepath.toString() + else: + filepathNorm = QUrl(filepath).toLocalFile() + if not filepathNorm: + filepathNorm = QUrl.fromLocalFile(filepath).toLocalFile() + return filepathNorm + def _recentImportedImagesFolders(self): folders = [] settings = QSettings() @@ -789,10 +843,12 @@ def setDefaultSubmitter(self, name): licensesModel = Property("QVariantList", _licensesModel, constant=True) pipelineTemplateFilesChanged = Signal() recentProjectFilesChanged = Signal() + recentTemplateFilesChanged = Signal() recentImportedImagesFoldersChanged = Signal() pipelineTemplateFiles = Property("QVariantList", _pipelineTemplateFiles, notify=pipelineTemplateFilesChanged) pipelineTemplateNames = Property("QVariantList", _pipelineTemplateNames, notify=pipelineTemplateFilesChanged) recentProjectFiles = Property("QVariantList", lambda self: self._recentProjectFiles, notify=recentProjectFilesChanged) + recentTemplateFiles = Property("QVariantList", lambda self: self._recentTemplateFiles, notify=recentTemplateFilesChanged) recentImportedImagesFolders = Property("QVariantList", _recentImportedImagesFolders, notify=recentImportedImagesFoldersChanged) default8bitViewerEnabled = Property(bool, _default8bitViewerEnabled, constant=True) defaultSequencePlayerEnabled = Property(bool, _defaultSequencePlayerEnabled, constant=True) diff --git a/meshroom/ui/graph.py b/meshroom/ui/graph.py index 974f330c27..3d147827fa 100644 --- a/meshroom/ui/graph.py +++ b/meshroom/ui/graph.py @@ -28,6 +28,7 @@ from meshroom.common import deprecated from meshroom.common.qt import QObjectListModel from meshroom.core.attribute import Attribute, AnySet, ListAttribute, ShapeAttribute +from meshroom.core.files import MESHROOM_PROJECT_EXTENSION, MESHROOM_TEMPLATE_EXTENSION, withExtension from meshroom.core.graph import Graph, Edge, generateTempProjectFilepath from meshroom.core.graphIO import GraphIO @@ -577,21 +578,19 @@ def importProject(self, filepath, position=None): @Slot(QUrl) def saveAs(self, url): - self._saveAs(url) + self._saveAs(url, extension=MESHROOM_PROJECT_EXTENSION) @Slot(QUrl) def saveAsTemplate(self, url): - self._saveAs(url, setupProjectFile=False, template=True) + self._saveAs(url, setupProjectFile=False, template=True, extension=MESHROOM_TEMPLATE_EXTENSION) - def _saveAs(self, url, setupProjectFile=True, template=False): + def _saveAs(self, url, setupProjectFile=True, template=False, extension=MESHROOM_PROJECT_EXTENSION): """ Helper function for 'save as' features. """ if isinstance(url, (str)): localFile = url else: localFile = url.toLocalFile() - # ensure file is saved with ".mg" extension - if os.path.splitext(localFile)[-1] != ".mg": - localFile += ".mg" + localFile = withExtension(localFile, extension) self._graph.save(localFile, setupProjectFile=setupProjectFile, template=template) self._undoStack.setClean() # saving file on disk impacts cache folder location diff --git a/meshroom/ui/qml/Application.qml b/meshroom/ui/qml/Application.qml index f4dd1cf31d..bcc00259df 100644 --- a/meshroom/ui/qml/Application.qml +++ b/meshroom/ui/qml/Application.qml @@ -102,7 +102,7 @@ Page { * Otherwise, show a warning dialog and returns false. * Closing the warning dialog reopens the specified `sourceSaveDialog`, to allow the user to try again. */ - const emptyFilename = Filepath.basename(filepath).trim() === ".mg"; + const emptyFilename = Filepath.basename(filepath).trim() === sourceSaveDialog.defaultSuffix; // Provided filename is not valid if (emptyFilename) { @@ -195,8 +195,8 @@ Page { signal closed(var result) title: "Save Template" - nameFilters: ["Meshroom Graphs (*.mg)"] - defaultSuffix: ".mg" + nameFilters: ["Meshroom Templates (*.mgt)"] + defaultSuffix: ".mgt" fileMode: Platform.FileDialog.SaveFile onAccepted: { if (!validateFilepathForSave(currentFile, saveTemplateDialog)) @@ -214,12 +214,11 @@ Page { Platform.FileDialog { id: loadTemplateDialog - title: "Load Template" - nameFilters: ["Meshroom Graphs (*.mg)"] + title: "Open Template" + nameFilters: ["Meshroom Templates (*.mgt *.mg)"] onAccepted: { - // Open the template as a regular file - if (_currentScene.load(currentFile)) { - MeshroomApp.addRecentProjectFile(currentFile.toString()) + if (_currentScene.loadTemplate(currentFile)) { + MeshroomApp.addRecentTemplateFile(currentFile.toString()) } } } @@ -654,8 +653,8 @@ Page { Action { id: loadTemplateAction - property string tooltip: "Load a template like a regular project file (any output node will be displayed)" - text: "Load Template" + property string tooltip: "Open a template as a new unsaved project (any output node will be displayed)" + text: "Open Template" onTriggered: { ensureSaved(function() { initFileDialogFolder(loadTemplateDialog) @@ -789,7 +788,6 @@ Page { } } } - MenuSeparator { } Action { id: saveAction text: "Save" @@ -830,6 +828,63 @@ Page { MeshroomApp.addRecentProjectFile(_currentScene.graph.filepath) } } + Menu { + title: "Templates" + + MenuItem { + action: loadTemplateAction + } + Menu { + id: openRecentTemplatesMenu + title: "Recent Templates" + enabled: recentTemplateFilesMenuItems.model != undefined && recentTemplateFilesMenuItems.model.length > 0 + property int maxWidth: 1000 + property int fullWidth: { + let result = 0; + for (let i = 0; i < count; ++i) { + const item = itemAt(i) + result = Math.max(item.implicitWidth + item.padding * 2, result) + } + return result + } + implicitWidth: fullWidth + Repeater { + id: recentTemplateFilesMenuItems + model: MeshroomApp.recentTemplateFiles + MenuItem { + enabled: modelData["status"] != 0 + + onTriggered: ensureSaved(function() { + openRecentTemplatesMenu.dismiss() + if (_currentScene.loadTemplate(modelData["path"])) { + MeshroomApp.addRecentTemplateFile(modelData["path"]) + } + }) + + text: fileTextMetrics.elidedText + TextMetrics { + id: fileTextMetrics + text: modelData["path"] + elide: Text.ElideLeft + elideWidth: openRecentTemplatesMenu.maxWidth + } + } + } + } + Action { + id: saveAsTemplateAction + text: "Save As Template..." + shortcut: Shortcut { + sequence: "Ctrl+Shift+T" + context: Qt.ApplicationShortcut + onActivated: saveAsTemplateAction.triggered() + } + onTriggered: { + initFileDialogFolder(saveTemplateDialog) + saveTemplateDialog.open() + } + } + } MenuSeparator { } Action { id: importImagesAction @@ -858,31 +913,6 @@ Page { title: "Advanced" implicitWidth: 300 - Action { - id: saveAsTemplateAction - text: "Save As Template..." - shortcut: Shortcut { - sequence: "Ctrl+Shift+T" - context: Qt.ApplicationShortcut - onActivated: saveAsTemplateAction.triggered() - } - onTriggered: { - initFileDialogFolder(saveTemplateDialog) - saveTemplateDialog.open() - } - } - - MenuItem { - action: loadTemplateAction - - ToolTip { - visible: parent.hovered - text: loadTemplateAction.tooltip - x: advancedMenu.implicitWidth - y: 0 - } - } - Action { id: importProjectAction text: "Import Project" @@ -1504,10 +1534,15 @@ Page { } onFilesDropped: function(drop, mousePosition) { var filesByType = _currentScene.getFilesByTypeFromDrop(drop.urls) - if (filesByType["meshroomScenes"].length == 1) { + if (filesByType["meshroomScenes"].length == 1 || filesByType["meshroomTemplates"].length == 1) { ensureSaved(function() { if (_currentScene.handleFilesUrl(filesByType, null, mousePosition)) { - MeshroomApp.addRecentProjectFile(filesByType["meshroomScenes"][0]) + if (_currentScene.graph.filepath) + MeshroomApp.addRecentProjectFile(filesByType["meshroomScenes"][0]) + else if (filesByType["meshroomTemplates"].length == 1) + MeshroomApp.addRecentTemplateFile(filesByType["meshroomTemplates"][0]) + else + MeshroomApp.addRecentTemplateFile(filesByType["meshroomScenes"][0]) } }) } else { diff --git a/meshroom/ui/qml/GraphEditor/GraphEditor.qml b/meshroom/ui/qml/GraphEditor/GraphEditor.qml index 9f63daeb64..8d4cb88f92 100755 --- a/meshroom/ui/qml/GraphEditor/GraphEditor.qml +++ b/meshroom/ui/qml/GraphEditor/GraphEditor.qml @@ -1269,7 +1269,8 @@ Item { nbDraggedFiles = drag.urls.length drag.urls.forEach(function(file) { - if (String(file).endsWith(".mg")) { + const extension = Filepath.extension(file) + if (extension === ".mg" || extension === ".mgt") { nbMeshroomScenes++ } }) @@ -1389,7 +1390,7 @@ Item { icon.color: "#F44336" title: "Different File Types" - text: "Do not mix .mg files and other types of files." + text: "Do not mix Meshroom project/template files and other types of files." standardButtons: Dialog.Ok parent: Overlay.overlay diff --git a/meshroom/ui/qml/ImageGallery/ImageGallery.qml b/meshroom/ui/qml/ImageGallery/ImageGallery.qml index 0db6709bce..626f037bcc 100644 --- a/meshroom/ui/qml/ImageGallery/ImageGallery.qml +++ b/meshroom/ui/qml/ImageGallery/ImageGallery.qml @@ -923,7 +923,7 @@ Panel { icon.color: "#F44336" title: "Different File Types" - text: "Do not mix .mg files and other types of files." + text: "Do not mix Meshroom project/template files and other types of files." standardButtons: Dialog.Ok parent: Overlay.overlay diff --git a/meshroom/ui/qml/ImageGallery/ImageGridView.qml b/meshroom/ui/qml/ImageGallery/ImageGridView.qml index e685dcaa55..f74477e8ee 100644 --- a/meshroom/ui/qml/ImageGallery/ImageGridView.qml +++ b/meshroom/ui/qml/ImageGallery/ImageGridView.qml @@ -178,7 +178,7 @@ GridView { onEntered: function(drag) { nbDraggedFiles = drag.urls.length filesByType = _currentScene.getFilesByTypeFromDrop(drag.urls) - nbMeshroomScenes = filesByType["meshroomScenes"].length + nbMeshroomScenes = filesByType["meshroomScenes"].length + filesByType["meshroomTemplates"].length } onDropped: function(drop) { if (nbMeshroomScenes === nbDraggedFiles || nbMeshroomScenes === 0) { @@ -206,13 +206,13 @@ GridView { verticalAlignment: Text.AlignVCenter text: { if (dropArea.nbMeshroomScenes != dropArea.nbDraggedFiles && dropArea.nbMeshroomScenes != 0) { - return "Cannot Add Projects And Images Together" + return "Cannot Add Meshroom Files And Images Together" } if (dropArea.nbMeshroomScenes == 1 && dropArea.nbMeshroomScenes == dropArea.nbDraggedFiles) { - return "Load Project" + return "Open Meshroom File" } else if (dropArea.nbMeshroomScenes == dropArea.nbDraggedFiles) { - return "Only One Project" + return "Only One Meshroom File" } else { return "Add Images" } @@ -234,4 +234,4 @@ GridView { mouse.accepted = false } } -} \ No newline at end of file +} diff --git a/meshroom/ui/qml/ImageGallery/ImageListView.qml b/meshroom/ui/qml/ImageGallery/ImageListView.qml index f90bc2f591..b4e96f562b 100644 --- a/meshroom/ui/qml/ImageGallery/ImageListView.qml +++ b/meshroom/ui/qml/ImageGallery/ImageListView.qml @@ -173,7 +173,7 @@ ListView { onEntered: function(drag) { nbDraggedFiles = drag.urls.length filesByType = _currentScene.getFilesByTypeFromDrop(drag.urls) - nbMeshroomScenes = filesByType["meshroomScenes"].length + nbMeshroomScenes = filesByType["meshroomScenes"].length + filesByType["meshroomTemplates"].length } onDropped: function(drop) { if (nbMeshroomScenes == nbDraggedFiles || nbMeshroomScenes == 0) { @@ -201,13 +201,13 @@ ListView { verticalAlignment: Text.AlignVCenter text: { if (dropArea.nbMeshroomScenes != dropArea.nbDraggedFiles && dropArea.nbMeshroomScenes != 0) { - return "Cannot Add Projects And Images Together" + return "Cannot Add Meshroom Files And Images Together" } if (dropArea.nbMeshroomScenes == 1 && dropArea.nbMeshroomScenes == dropArea.nbDraggedFiles) { - return "Load Project" + return "Open Meshroom File" } else if (dropArea.nbMeshroomScenes == dropArea.nbDraggedFiles) { - return "Only One Project" + return "Only One Meshroom File" } else { return "Add Images" } @@ -229,4 +229,4 @@ ListView { mouse.accepted = false } } -} \ No newline at end of file +} diff --git a/meshroom/ui/qml/WorkspaceView.qml b/meshroom/ui/qml/WorkspaceView.qml index 6070f2908a..d4bf6f67d4 100644 --- a/meshroom/ui/qml/WorkspaceView.qml +++ b/meshroom/ui/qml/WorkspaceView.qml @@ -87,10 +87,15 @@ Item { onRemoveSelectedImagesRequest: function(objects) { currentScene.removeImages(objects) } onAllViewpointsCleared: currentScene.selectedViewId = "-1" onFilesDropped: function(drop) { - if (drop["meshroomScenes"].length == 1) { + if (drop["meshroomScenes"].length == 1 || drop["meshroomTemplates"].length == 1) { ensureSaved(function() { if (currentScene.handleFilesUrl(drop, cameraInit)) { - MeshroomApp.addRecentProjectFile(drop["meshroomScenes"][0]) + if (currentScene.graph.filepath) + MeshroomApp.addRecentProjectFile(drop["meshroomScenes"][0]) + else if (drop["meshroomTemplates"].length == 1) + MeshroomApp.addRecentTemplateFile(drop["meshroomTemplates"][0]) + else + MeshroomApp.addRecentTemplateFile(drop["meshroomScenes"][0]) } }) } else { diff --git a/meshroom/ui/qml/main.qml b/meshroom/ui/qml/main.qml index ebad2d1a81..0559dee98d 100644 --- a/meshroom/ui/qml/main.qml +++ b/meshroom/ui/qml/main.qml @@ -130,7 +130,10 @@ ApplicationWindow { mainStack.push("Application.qml") } if (_currentScene.load(currentFile)) { - MeshroomApp.addRecentProjectFile(currentFile.toString()) + if (_currentScene.graph.filepath) + MeshroomApp.addRecentProjectFile(currentFile.toString()) + else + MeshroomApp.addRecentTemplateFile(currentFile.toString()) } } } diff --git a/meshroom/ui/scene.py b/meshroom/ui/scene.py index 94d1ccc333..271655ac42 100755 --- a/meshroom/ui/scene.py +++ b/meshroom/ui/scene.py @@ -16,6 +16,7 @@ from meshroom import multiview from meshroom.common.qt import QObjectListModel from meshroom.core import Version +from meshroom.core.files import extensionLower, isTemplateFile from meshroom.core.node import Node, CompatibilityNode, Status, Position, CompatibilityIssue from meshroom.core.taskManager import TaskManager from meshroom.core.evaluation import MathEvaluator @@ -499,13 +500,24 @@ def newWithCopyOutputs(self, pipeline: Optional[str] = None) -> bool: @Slot(str, result=bool) @Slot(QUrl, result=bool) def load(self, url): + localFile = self._urlToLocalFile(url) + if isTemplateFile(localFile): + return self.loadTemplate(localFile) + return self._loadWithErrorReport(self.loadGraph, localFile) + + @Slot(str, result=bool) + @Slot(QUrl, result=bool) + def loadTemplate(self, url): + localFile = self._urlToLocalFile(url) + return self._loadWithErrorReport(self._initFromTemplateWithCopyOutputs, localFile) + + @staticmethod + def _urlToLocalFile(url) -> str: if isinstance(url, QUrl): # depending how the QUrl has been initialized, # toLocalFile() may return the local path or an empty string - localFile = url.toLocalFile() or url.toString() - else: - localFile = url - return self._loadWithErrorReport(self.loadGraph, localFile) + return url.toLocalFile() or url.toString() + return url def _loadWithErrorReport(self, loadFunction: Callable[[str], None], filepath: str): logging.info(f"Load project file: '{filepath}'") @@ -696,8 +708,8 @@ def handleFilesUrl(self, filesByType, cameraInit=None, position=None): This method allows to reduce process time by doing it on Python side. Args: - {images, videos, panoramaInfo, meshroomScenes, otherFiles}: Map containing the - lists of paths for recognized images, videos, Meshroom scenes and other files. + {images, videos, panoramaInfo, meshroomScenes, meshroomTemplates, otherFiles}: Map containing the + lists of paths for recognized images, videos, Meshroom scenes, Meshroom templates and other files. Node: cameraInit node used to add new images to it QPoint: position to locate the node (usually the mouse position) """ @@ -771,6 +783,24 @@ def handleFilesUrl(self, filesByType, cameraInit=None, position=None): "", )) + if filesByType["meshroomTemplates"]: + if len(filesByType["meshroomTemplates"]) > 1: + self.error.emit( + Message( + "Too Many Meshroom Templates", + "A single Meshroom template (.mgt file) can be opened at once." + ) + ) + elif filesByType["meshroomScenes"]: + self.error.emit( + Message( + "Mixed Meshroom Files", + "Do not mix Meshroom projects and templates." + ) + ) + else: + return self.loadTemplate(filesByType["meshroomTemplates"][0]) + if filesByType["meshroomScenes"]: if len(filesByType["meshroomScenes"]) > 1: self.error.emit( @@ -784,9 +814,9 @@ def handleFilesUrl(self, filesByType, cameraInit=None, position=None): - if not filesByType["images"] and not filesByType["videos"] and not filesByType["panoramaInfo"] and not filesByType["meshroomScenes"]: + if not filesByType["images"] and not filesByType["videos"] and not filesByType["panoramaInfo"] and not filesByType["meshroomScenes"] and not filesByType["meshroomTemplates"]: if filesByType["other"]: - extensions = {os.path.splitext(url)[1] for url in filesByType["other"]} + extensions = {extensionLower(url) for url in filesByType["other"]} self.error.emit( Message( "No Recognized Input File", @@ -809,8 +839,8 @@ def getFilesByTypeFromDrop(self, urls): urls: list of filepaths Returns: - {images, videos, panoramaInfo, meshroomScenes, otherFiles}: Map containing the lists of paths for - recognized images, videos, Meshroom scenes and other files. + {images, videos, panoramaInfo, meshroomScenes, meshroomTemplates, otherFiles}: Map containing the lists of paths for + recognized images, videos, Meshroom scenes, Meshroom templates and other files. """ # Build the list of images paths filesByType = multiview.FilesByType() @@ -824,6 +854,7 @@ def getFilesByTypeFromDrop(self, urls): "videos": filesByType.videos, "panoramaInfo": filesByType.panoramaInfo, "meshroomScenes": filesByType.meshroomScenes, + "meshroomTemplates": filesByType.meshroomTemplates, "other": filesByType.other} @Slot(QObject, "QList") From e31a59c3df1211c5997d401a01d52d1805f27e1f Mon Sep 17 00:00:00 2001 From: nicolas-lambert-tc Date: Mon, 29 Jun 2026 18:28:27 +0200 Subject: [PATCH 3/4] [tests] Cover explicit template files --- tests/test_template_files.py | 102 +++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 tests/test_template_files.py diff --git a/tests/test_template_files.py b/tests/test_template_files.py new file mode 100644 index 0000000000..0552a79c8d --- /dev/null +++ b/tests/test_template_files.py @@ -0,0 +1,102 @@ +import json + +import meshroom.core +from meshroom.core.files import ( + MESHROOM_PROJECT_EXTENSION, + MESHROOM_TEMPLATE_EXTENSION, + isTemplateFile, + withExtension, +) +from meshroom.core.graph import Graph +from meshroom.core.plugins.base import Plugin + + +def write_graph_file(path, template=False): + path.write_text( + json.dumps( + { + "header": { + "fileVersion": "2.1", + "template": template, + }, + "graph": {}, + } + ) + ) + + +def test_template_file_detection_from_extension_and_legacy_metadata(tmp_path): + project_file = tmp_path / f"project{MESHROOM_PROJECT_EXTENSION}" + legacy_template_file = tmp_path / f"legacy{MESHROOM_PROJECT_EXTENSION}" + explicit_template_file = tmp_path / f"template{MESHROOM_TEMPLATE_EXTENSION}" + + write_graph_file(project_file, template=False) + write_graph_file(legacy_template_file, template=True) + write_graph_file(explicit_template_file, template=False) + + assert not isTemplateFile(project_file) + assert isTemplateFile(legacy_template_file) + assert isTemplateFile(explicit_template_file) + + +def test_template_initialization_does_not_bind_filepath(tmp_path): + template_file = tmp_path / f"template{MESHROOM_TEMPLATE_EXTENSION}" + graph = Graph("Template") + graph.save(template_file, setupProjectFile=False, template=True) + + loaded_graph = Graph("") + loaded_graph.initFromTemplate(template_file, keepOutputNodes=True) + + assert loaded_graph.filepath == "" + + +def test_template_extension_is_appended_for_template_saves(): + assert withExtension("template", MESHROOM_TEMPLATE_EXTENSION) == f"template{MESHROOM_TEMPLATE_EXTENSION}" + assert ( + withExtension(f"template{MESHROOM_TEMPLATE_EXTENSION}", MESHROOM_TEMPLATE_EXTENSION) + == f"template{MESHROOM_TEMPLATE_EXTENSION}" + ) + assert ( + withExtension(f"template{MESHROOM_PROJECT_EXTENSION}", MESHROOM_TEMPLATE_EXTENSION) + == f"template{MESHROOM_PROJECT_EXTENSION}{MESHROOM_TEMPLATE_EXTENSION}" + ) + + +def test_pipeline_template_discovery_supports_mgt_and_legacy_mg_metadata(tmp_path): + explicit_template_file = tmp_path / f"explicit{MESHROOM_TEMPLATE_EXTENSION}" + legacy_template_file = tmp_path / f"legacy{MESHROOM_PROJECT_EXTENSION}" + project_file = tmp_path / f"project{MESHROOM_PROJECT_EXTENSION}" + + write_graph_file(explicit_template_file, template=True) + write_graph_file(legacy_template_file, template=True) + write_graph_file(project_file, template=False) + + previous_templates = dict(meshroom.core.pipelineTemplates) + try: + meshroom.core.pipelineTemplates.clear() + meshroom.core.loadPipelineTemplates(str(tmp_path)) + + assert meshroom.core.pipelineTemplates == { + "explicit": str(explicit_template_file), + "legacy": str(legacy_template_file), + } + finally: + meshroom.core.pipelineTemplates.clear() + meshroom.core.pipelineTemplates.update(previous_templates) + + +def test_plugin_template_discovery_supports_mgt_and_legacy_mg_metadata(tmp_path): + explicit_template_file = tmp_path / f"explicit{MESHROOM_TEMPLATE_EXTENSION}" + legacy_template_file = tmp_path / f"legacy{MESHROOM_PROJECT_EXTENSION}" + project_file = tmp_path / f"project{MESHROOM_PROJECT_EXTENSION}" + + write_graph_file(explicit_template_file, template=True) + write_graph_file(legacy_template_file, template=True) + write_graph_file(project_file, template=False) + + plugin = Plugin("testPlugin", str(tmp_path)) + + assert plugin.templates == { + "explicit": str(explicit_template_file), + "legacy": str(legacy_template_file), + } From be70c131dbd514e9a9115677307e1f658ed72418 Mon Sep 17 00:00:00 2001 From: nicolas-lambert-tc Date: Tue, 30 Jun 2026 13:50:08 +0200 Subject: [PATCH 4/4] [ui] Explicitely indicate opened templates in graph editor --- meshroom/core/graph.py | 16 ++++++++ meshroom/ui/qml/Application.qml | 17 ++++++++ meshroom/ui/qml/GraphEditor/GraphEditor.qml | 44 +++++++++++++++++++++ meshroom/ui/scene.py | 5 ++- 4 files changed, 81 insertions(+), 1 deletion(-) diff --git a/meshroom/core/graph.py b/meshroom/core/graph.py index 553629215b..5c04229b90 100644 --- a/meshroom/core/graph.py +++ b/meshroom/core/graph.py @@ -229,6 +229,7 @@ def __init__(self, name: str = "", parent: BaseObject = None): self._relativeCacheDir: str = "" self._cacheDir: str = "" self._filepath: str = "" + self._templateFilepath: str = "" self._fileDateVersion = 0 self.header = {} @@ -1628,6 +1629,7 @@ def _setFilepath(self, filepath): if self._filepath == newFilepath: return self._filepath = newFilepath + self._setTemplateFilepath("") # For now: # * cache folder is located next to the graph file # * graph name if the basename of the graph file @@ -1640,10 +1642,22 @@ def _setFilepath(self, filepath): def _unsetFilepath(self): self._filepath = "" + self._setTemplateFilepath("") self.name = "" self.cacheDir = "" self.filepathChanged.emit() + @Slot(str) + def setTemplateFilepath(self, filepath): + self._setTemplateFilepath(filepath) + + def _setTemplateFilepath(self, filepath): + newFilepath = Path(filepath).as_posix() if filepath else "" + if self._templateFilepath == newFilepath: + return + self._templateFilepath = newFilepath + self.templateFilepathChanged.emit() + def updateInternals(self, startNodes=None, force=False): nodes, edges = self.dfsOnFinish(startNodes=startNodes) for node in nodes: @@ -1886,6 +1900,8 @@ def setVerbose(self, v): edges = Property(BaseObject, edges.fget, constant=True) filepathChanged = Signal() filepath = Property(str, lambda self: self._filepath, notify=filepathChanged) + templateFilepathChanged = Signal() + templateFilepath = Property(str, lambda self: self._templateFilepath, notify=templateFilepathChanged) isSaving = Property(bool, isSaving.fget, constant=True) fileReleaseVersion = Property(str, lambda self: self.header.get(GraphIO.Keys.ReleaseVersion, "0.0"), notify=filepathChanged) diff --git a/meshroom/ui/qml/Application.qml b/meshroom/ui/qml/Application.qml index bcc00259df..a808bcba75 100644 --- a/meshroom/ui/qml/Application.qml +++ b/meshroom/ui/qml/Application.qml @@ -1509,6 +1509,23 @@ Page { } } } + // TemplateBadge + MaterialToolButton { + + readonly property string templateFilePath: { + const coreGraph = _currentScene ? _currentScene.graph : null + const templatePath = coreGraph ? coreGraph.templateFilepath : "" + return templatePath + } + + text: MaterialIcons.library_books + visible: Boolean(templateFilePath) + font.pointSize: 11 + padding: 2 + checked: true + ToolTip.text: `Current graph is a template: ${templateFilePath}` + ToolTip.visible: hovered + } } GraphEditor { diff --git a/meshroom/ui/qml/GraphEditor/GraphEditor.qml b/meshroom/ui/qml/GraphEditor/GraphEditor.qml index 8d4cb88f92..b590586405 100755 --- a/meshroom/ui/qml/GraphEditor/GraphEditor.qml +++ b/meshroom/ui/qml/GraphEditor/GraphEditor.qml @@ -387,6 +387,50 @@ Item { } } + Canvas { + id: templateBackgroundStripes + + anchors.fill: parent + visible: Boolean(root.graph && root.graph.templateFilepath) + opacity: 0.03 + + readonly property color stripeColor: activePalette.highlight + readonly property int stripeWidth: 21 + readonly property int stripeGap: 42 + + onPaint: { + const ctx = getContext("2d") + ctx.clearRect(0, 0, width, height) + + if (!visible) { + return + } + + ctx.save() + ctx.strokeStyle = stripeColor + ctx.lineWidth = stripeWidth + + const diagonal = Math.sqrt(width * width + height * height) + const step = stripeWidth + stripeGap + ctx.translate(width * 0.5, height * 0.5) + ctx.rotate(-Math.PI / 4) + + for (let x = -diagonal; x <= diagonal; x += step) { + ctx.beginPath() + ctx.moveTo(x, -diagonal) + ctx.lineTo(x, diagonal) + ctx.stroke() + } + + ctx.restore() + } + + onVisibleChanged: requestPaint() + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() + onStripeColorChanged: requestPaint() + } + Item { id: draggable transformOrigin: Item.TopLeft diff --git a/meshroom/ui/scene.py b/meshroom/ui/scene.py index 271655ac42..90ff1fd805 100755 --- a/meshroom/ui/scene.py +++ b/meshroom/ui/scene.py @@ -509,7 +509,10 @@ def load(self, url): @Slot(QUrl, result=bool) def loadTemplate(self, url): localFile = self._urlToLocalFile(url) - return self._loadWithErrorReport(self._initFromTemplateWithCopyOutputs, localFile) + loaded = self._loadWithErrorReport(self._initFromTemplateWithOutputNodes, localFile) + if loaded: + self.graph.setTemplateFilepath(localFile) + return loaded @staticmethod def _urlToLocalFile(url) -> str: